From 62711cede4bf081f205e3f974e38670aa4b806fb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 21 Sep 2026 13:47:07 +0000 Subject: [PATCH 1/2] chore(ci)(deps): bump the all-actions group with 4 updates Bumps the all-actions group with 4 updates: [actions/checkout](https://github.com/actions/checkout), [actions/configure-pages](https://github.com/actions/configure-pages), [actions/upload-pages-artifact](https://github.com/actions/upload-pages-artifact) and [actions/deploy-pages](https://github.com/actions/deploy-pages). Updates `actions/checkout` from 4 to 7 - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v4...v7) Updates `actions/configure-pages` from 5 to 6 - [Release notes](https://github.com/actions/configure-pages/releases) - [Commits](https://github.com/actions/configure-pages/compare/v5...v6) Updates `actions/upload-pages-artifact` from 3 to 5 - [Release notes](https://github.com/actions/upload-pages-artifact/releases) - [Commits](https://github.com/actions/upload-pages-artifact/compare/v3...v5) Updates `actions/deploy-pages` from 4 to 5 - [Release notes](https://github.com/actions/deploy-pages/releases) - [Commits](https://github.com/actions/deploy-pages/compare/v4...v5) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major dependency-group: all-actions - dependency-name: actions/configure-pages dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major dependency-group: all-actions - dependency-name: actions/upload-pages-artifact dependency-version: '5' dependency-type: direct:production update-type: version-update:semver-major dependency-group: all-actions - dependency-name: actions/deploy-pages dependency-version: '5' dependency-type: direct:production update-type: version-update:semver-major dependency-group: all-actions ... Signed-off-by: dependabot[bot] --- .github/workflows/pages.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index b2ff617..11292f7 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -29,16 +29,16 @@ jobs: url: ${{ steps.deployment.outputs.page_url }} steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Configure Pages - uses: actions/configure-pages@v5 + uses: actions/configure-pages@v6 - name: Upload docs artifact - uses: actions/upload-pages-artifact@v3 + uses: actions/upload-pages-artifact@v5 with: path: docs - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@v4 + uses: actions/deploy-pages@v5 From 89d1d97c980d0b8d0430547c77c17664068e5178 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 21 Sep 2026 13:47:33 +0000 Subject: [PATCH 2/2] style: auto-format with dart format --- example/lib/main.dart | 200 ++++++++++++------------ example/lib/secure_token_store.dart | 2 +- example/test/widget_test.dart | 6 +- lib/src/auth_manager.dart | 34 ++--- lib/src/auth_manager_group.dart | 4 +- lib/src/auth_session.dart | 67 ++++---- lib/src/exceptions.dart | 6 +- server/lib/src/app.dart | 6 +- server/lib/src/auth/auth_service.dart | 22 +-- server/lib/src/auth/token_service.dart | 6 +- server/lib/src/auth/user_store.dart | 2 +- server/lib/src/config.dart | 5 +- server/lib/src/logging/logger.dart | 4 +- test/auth_manager_group_test.dart | 92 +++++------ test/auth_manager_hardening_test.dart | 61 ++++---- test/auth_manager_lifecycle_test.dart | 203 +++++++++++++------------ test/auth_manager_robustness_test.dart | 39 +++-- test/auth_manager_test.dart | 10 +- test/auth_state_test.dart | 5 +- test/fake_strategy.dart | 12 +- 20 files changed, 383 insertions(+), 403 deletions(-) diff --git a/example/lib/main.dart b/example/lib/main.dart index b052495..8ae6e9e 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -49,8 +49,8 @@ class _DemoStrategy implements AuthStrategy { @override Future refresh(RefreshToken token) async => login( - const Credentials(username: _validUsername, password: _validPassword), - ); + const Credentials(username: _validUsername, password: _validPassword), + ); } /// Real HTTP backend strategy. Talks to the Dart server in `../../server`. @@ -64,14 +64,14 @@ class _HttpAuthStrategy implements AuthStrategy { res.data as Map; AuthSession _toSession(Map data) => AuthSession( - accessToken: data['accessToken'] as String, - refreshToken: RefreshToken(data['refreshToken'] as String), - expiresAt: data['expiresIn'] != null - ? DateTime.now().add(Duration(seconds: data['expiresIn'] as int)) - : null, - userId: data['userId'] as String, - displayName: data['displayName'] as String, - ); + accessToken: data['accessToken'] as String, + refreshToken: RefreshToken(data['refreshToken'] as String), + expiresAt: data['expiresIn'] != null + ? DateTime.now().add(Duration(seconds: data['expiresIn'] as int)) + : null, + userId: data['userId'] as String, + displayName: data['displayName'] as String, + ); @override Future login(Credentials credentials) async { @@ -194,10 +194,10 @@ class _DemoAppState extends State { } void _toggleBackend(bool value) => setState(() { - unawaited(_auth.dispose()); - _useBackend = value; - _init(); - }); + unawaited(_auth.dispose()); + _useBackend = value; + _init(); + }); /// Runs an auth action and swallows the rethrown error: [AuthManager] already /// surfaces it as an [AuthError] state, so there is nothing left to handle. @@ -244,10 +244,7 @@ class _DemoAppState extends State { /// `Call /me` afterwards shows the transparent renewal. Future _expireTokenSoon(BuildContext context, int seconds) async { try { - await _dio.post( - '$_baseUrl/debug/access-ttl', - data: {'seconds': seconds}, - ); + await _dio.post('$_baseUrl/debug/access-ttl', data: {'seconds': seconds}); await _invoke(() => _auth.refresh()); if (!context.mounted) return; _snack( @@ -274,53 +271,50 @@ class _DemoAppState extends State { @override Widget build(BuildContext context) => MaterialApp( - title: 'zero_auth demo', - theme: _theme(Brightness.light), - darkTheme: _theme(Brightness.dark), - home: Scaffold( - appBar: AppBar(title: const Text('zero_auth demo')), - body: SafeArea( - child: StreamBuilder( - initialData: _auth.current, - stream: _auth.state, - builder: (context, snapshot) => _DemoBody( - state: snapshot.data, - auth: _auth, - useBackend: _useBackend, - baseUrl: _baseUrl, - username: _username, - password: _password, - onToggleBackend: _toggleBackend, - onLogin: () => _invoke( - () => _auth.login( - Credentials( - username: _username.text, - password: _password.text, - ), - ), - ), - onRefresh: () => _invoke(() => _auth.refresh()), - onLogout: () => _invoke(() => _auth.logout()), - onCallMe: () => _callMe(context), - onExpireNow: () => _expireTokenNow(context), - onExpireSoon: () => _expireTokenSoon(context, 10), - onResetDebug: () => _resetDebug(context), + title: 'zero_auth demo', + theme: _theme(Brightness.light), + darkTheme: _theme(Brightness.dark), + home: Scaffold( + appBar: AppBar(title: const Text('zero_auth demo')), + body: SafeArea( + child: StreamBuilder( + initialData: _auth.current, + stream: _auth.state, + builder: (context, snapshot) => _DemoBody( + state: snapshot.data, + auth: _auth, + useBackend: _useBackend, + baseUrl: _baseUrl, + username: _username, + password: _password, + onToggleBackend: _toggleBackend, + onLogin: () => _invoke( + () => _auth.login( + Credentials(username: _username.text, password: _password.text), ), ), + onRefresh: () => _invoke(() => _auth.refresh()), + onLogout: () => _invoke(() => _auth.logout()), + onCallMe: () => _callMe(context), + onExpireNow: () => _expireTokenNow(context), + onExpireSoon: () => _expireTokenSoon(context, 10), + onResetDebug: () => _resetDebug(context), ), ), - ); + ), + ), + ); /// One seed colour drives the whole palette; widgets read shades from the /// theme instead of hardcoding colors. static ThemeData _theme(Brightness brightness) => ThemeData( - useMaterial3: true, - brightness: brightness, - colorScheme: ColorScheme.fromSeed( - seedColor: const Color(0xFF00695C), - brightness: brightness, - ), - ); + useMaterial3: true, + brightness: brightness, + colorScheme: ColorScheme.fromSeed( + seedColor: const Color(0xFF00695C), + brightness: brightness, + ), + ); } /// The scrollable demo surface. Adapts to the viewport: a full-width column on @@ -452,8 +446,9 @@ class _StatusCard extends StatelessWidget { Expanded( child: Text( 'state: ${state?.runtimeType ?? 'Unknown'}', - style: theme.textTheme.titleMedium - ?.copyWith(fontWeight: FontWeight.w600), + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), overflow: TextOverflow.ellipsis, ), ), @@ -583,17 +578,16 @@ class _Badge extends StatelessWidget { @override Widget build(BuildContext context) => Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), - decoration: BoxDecoration( - color: color, - borderRadius: const BorderRadius.all(Radius.circular(8)), - ), - child: Text( - label, - style: - Theme.of(context).textTheme.labelMedium?.copyWith(color: onColor), - ), - ); + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: color, + borderRadius: const BorderRadius.all(Radius.circular(8)), + ), + child: Text( + label, + style: Theme.of(context).textTheme.labelMedium?.copyWith(color: onColor), + ), + ); } /// The complete access token, wrapped over as many lines as it needs. @@ -617,8 +611,9 @@ class _TokenBlock extends StatelessWidget { children: [ Text( 'token', - style: theme.textTheme.bodySmall - ?.copyWith(color: theme.colorScheme.onSurfaceVariant), + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), ), const SizedBox(height: 4), Text( @@ -663,8 +658,9 @@ class _LabeledRow extends StatelessWidget { width: 96, child: Text( label, - style: theme.textTheme.bodySmall - ?.copyWith(color: theme.colorScheme.onSurfaceVariant), + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), ), ), Expanded(child: child), @@ -683,13 +679,13 @@ class _InfoRow extends StatelessWidget { @override Widget build(BuildContext context) => _LabeledRow( - label: label, - child: Text( - value, - style: Theme.of(context).textTheme.bodyMedium, - overflow: TextOverflow.ellipsis, - ), - ); + label: label, + child: Text( + value, + style: Theme.of(context).textTheme.bodyMedium, + overflow: TextOverflow.ellipsis, + ), + ); } /// Failure surface. Uses the theme's error container rather than literal red, @@ -718,8 +714,9 @@ class _ErrorCard extends StatelessWidget { children: [ Text( error.message, - style: theme.textTheme.bodyMedium - ?.copyWith(color: colors.onErrorContainer), + style: theme.textTheme.bodyMedium?.copyWith( + color: colors.onErrorContainer, + ), ), if (error.code != null) ...[ const SizedBox(height: 4), @@ -754,15 +751,15 @@ class _BackendCard extends StatelessWidget { @override Widget build(BuildContext context) => Card( - child: SwitchListTile.adaptive( - value: useBackend, - onChanged: onChanged, - title: const Text('Live backend'), - subtitle: Text(useBackend ? baseUrl : 'offline double, no server'), - secondary: const Icon(Icons.cloud_outlined), - contentPadding: const EdgeInsets.symmetric(horizontal: 16), - ), - ); + child: SwitchListTile.adaptive( + value: useBackend, + onChanged: onChanged, + title: const Text('Live backend'), + subtitle: Text(useBackend ? baseUrl : 'offline double, no server'), + secondary: const Icon(Icons.cloud_outlined), + contentPadding: const EdgeInsets.symmetric(horizontal: 16), + ), + ); } /// Sign-in form. The password can be revealed, and submitting from the keyboard @@ -832,8 +829,9 @@ class _LoginCardState extends State<_LoginCard> { const SizedBox(height: 8), Text( 'Demo account: user / user', - style: theme.textTheme.bodySmall - ?.copyWith(color: theme.colorScheme.onSurfaceVariant), + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), ), const SizedBox(height: 16), FilledButton( @@ -890,8 +888,9 @@ class _SessionCard extends StatelessWidget { Text( 'Requests carry the bearer token; an expired one is renewed ' 'transparently before it is sent.', - style: theme.textTheme.bodySmall - ?.copyWith(color: colors.onSurfaceVariant), + style: theme.textTheme.bodySmall?.copyWith( + color: colors.onSurfaceVariant, + ), ), const SizedBox(height: 16), FilledButton( @@ -967,8 +966,9 @@ class _DebugCard extends StatelessWidget { const SizedBox(height: 4), Text( 'Force token expiry without waiting for it to happen.', - style: theme.textTheme.bodySmall - ?.copyWith(color: theme.colorScheme.onSurfaceVariant), + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), ), const SizedBox(height: 12), Wrap( @@ -991,9 +991,7 @@ class _DebugCard extends StatelessWidget { ), TextButton( onPressed: onReset, - style: TextButton.styleFrom( - minimumSize: const Size(120, 48), - ), + style: TextButton.styleFrom(minimumSize: const Size(120, 48)), child: const Text('Reset'), ), ], diff --git a/example/lib/secure_token_store.dart b/example/lib/secure_token_store.dart index e04e9c5..5d8b5d9 100644 --- a/example/lib/secure_token_store.dart +++ b/example/lib/secure_token_store.dart @@ -7,7 +7,7 @@ import 'package:zero_auth/zero_auth.dart'; /// is required. Swap for your own codec as needed. final class SecureTokenStore implements TokenStore { SecureTokenStore([FlutterSecureStorage? storage]) - : _storage = storage ?? const FlutterSecureStorage(); + : _storage = storage ?? const FlutterSecureStorage(); final FlutterSecureStorage _storage; diff --git a/example/test/widget_test.dart b/example/test/widget_test.dart index 1d829a7..eccebcf 100644 --- a/example/test/widget_test.dart +++ b/example/test/widget_test.dart @@ -15,11 +15,7 @@ void main() { testWidgets('renders without overflow from phone to desktop', (tester) async { // A phone, a tablet and a desktop window, all at 1x so logical size equals // the physical size we set. - const sizes = [ - Size(360, 780), - Size(768, 1024), - Size(1440, 900), - ]; + const sizes = [Size(360, 780), Size(768, 1024), Size(1440, 900)]; for (final size in sizes) { tester.view.devicePixelRatio = 1.0; diff --git a/lib/src/auth_manager.dart b/lib/src/auth_manager.dart index cdc0fed..e06c993 100644 --- a/lib/src/auth_manager.dart +++ b/lib/src/auth_manager.dart @@ -89,14 +89,14 @@ final class AuthManager implements AuthTokenSource { RefreshFailurePolicy? refreshFailurePolicy, DateTime Function()? clock, this.onStateChanged, - }) : tokenStore = tokenStore ?? InMemoryTokenStore(), - _autoRefreshAhead = autoRefreshAhead, - _autoRefreshRetryDelay = - autoRefreshRetryDelay ?? const Duration(seconds: 30), - _autoRefreshMaxRetries = autoRefreshMaxRetries ?? 3, - refreshFailurePolicy = - refreshFailurePolicy ?? defaultRefreshFailurePolicy, - clock = clock ?? _systemClock; + }) : tokenStore = tokenStore ?? InMemoryTokenStore(), + _autoRefreshAhead = autoRefreshAhead, + _autoRefreshRetryDelay = + autoRefreshRetryDelay ?? const Duration(seconds: 30), + _autoRefreshMaxRetries = autoRefreshMaxRetries ?? 3, + refreshFailurePolicy = + refreshFailurePolicy ?? defaultRefreshFailurePolicy, + clock = clock ?? _systemClock; final Duration? _autoRefreshAhead; @@ -157,10 +157,10 @@ final class AuthManager implements AuthTokenSource { /// 当前活动会话;未认证时为 `null`。在 [Authenticated] 与 [Refreshing] 下均可用 /// (续期中会话依然有效),但 [LoggingOut] 下为空。 AuthSession? get currentSession => switch (_state) { - Authenticated(:final session) => session, - Refreshing(:final session) => session, - _ => null, - }; + Authenticated(:final session) => session, + Refreshing(:final session) => session, + _ => null, + }; @override String? get accessToken => currentSession?.accessToken; @@ -483,11 +483,11 @@ final class AuthManager implements AuthTokenSource { /// being discarded during [LoggingOut]. /// 驱动进行中操作或已建立认证的会话,包含在 [LoggingOut] 期间正被丢弃的那个。 AuthSession? get _activeSession => switch (_state) { - Authenticated(:final session) => session, - Refreshing(:final session) => session, - LoggingOut(:final session) => session, - _ => null, - }; + Authenticated(:final session) => session, + Refreshing(:final session) => session, + LoggingOut(:final session) => session, + _ => null, + }; bool _isCurrent(int epoch) => !_disposed && epoch == _epoch; diff --git a/lib/src/auth_manager_group.dart b/lib/src/auth_manager_group.dart index 0c05ea6..8b1b24a 100644 --- a/lib/src/auth_manager_group.dart +++ b/lib/src/auth_manager_group.dart @@ -41,8 +41,8 @@ final class AuthManagerGroup implements AuthTokenSource { AuthManagerGroup({ required AuthStrategy Function(String accountId) strategyFactory, required TokenStore Function(String accountId) storeFactory, - }) : _strategyFactory = strategyFactory, - _storeFactory = storeFactory; + }) : _strategyFactory = strategyFactory, + _storeFactory = storeFactory; /// Called once per account. Returning the same instance for every account is /// fine — and typical — since a strategy usually just talks to one backend. diff --git a/lib/src/auth_session.dart b/lib/src/auth_session.dart index cac49b5..4327b35 100644 --- a/lib/src/auth_session.dart +++ b/lib/src/auth_session.dart @@ -94,15 +94,14 @@ final class AuthSession { String? userId, String? displayName, Map? claims, - }) => - AuthSession( - accessToken: accessToken ?? this.accessToken, - refreshToken: refreshToken ?? this.refreshToken, - expiresAt: expiresAt ?? this.expiresAt, - userId: userId ?? this.userId, - displayName: displayName ?? this.displayName, - claims: claims ?? this.claims, - ); + }) => AuthSession( + accessToken: accessToken ?? this.accessToken, + refreshToken: refreshToken ?? this.refreshToken, + expiresAt: expiresAt ?? this.expiresAt, + userId: userId ?? this.userId, + displayName: displayName ?? this.displayName, + claims: claims ?? this.claims, + ); /// How long until the access token expires, or `null` when there is no expiry. /// 距离访问令牌过期还有多久;无过期时间时为 `null`。 @@ -132,28 +131,28 @@ final class AuthSession { /// 序列化为 JSON 安全映射,供持久化使用(如写入磁盘或安全存储的 [TokenStore])。 /// 为 `null` 的字段会被省略。 Map toJson() => { - 'accessToken': accessToken, - if (refreshToken != null) 'refreshToken': refreshToken!.value, - if (expiresAt != null) 'expiresAt': expiresAt!.toIso8601String(), - if (userId != null) 'userId': userId, - if (displayName != null) 'displayName': displayName, - if (claims != null) 'claims': claims, - }; + 'accessToken': accessToken, + if (refreshToken != null) 'refreshToken': refreshToken!.value, + if (expiresAt != null) 'expiresAt': expiresAt!.toIso8601String(), + if (userId != null) 'userId': userId, + if (displayName != null) 'displayName': displayName, + if (claims != null) 'claims': claims, + }; /// Deserialize from a map produced by [toJson]. /// 从 [toJson] 生成的映射反序列化。 factory AuthSession.fromJson(Map json) => AuthSession( - accessToken: json['accessToken'] as String, - refreshToken: json['refreshToken'] == null - ? null - : RefreshToken(json['refreshToken'] as String), - expiresAt: json['expiresAt'] == null - ? null - : DateTime.parse(json['expiresAt'] as String), - userId: json['userId'] as String?, - displayName: json['displayName'] as String?, - claims: (json['claims'] as Map?)?.cast(), - ); + accessToken: json['accessToken'] as String, + refreshToken: json['refreshToken'] == null + ? null + : RefreshToken(json['refreshToken'] as String), + expiresAt: json['expiresAt'] == null + ? null + : DateTime.parse(json['expiresAt'] as String), + userId: json['userId'] as String?, + displayName: json['displayName'] as String?, + claims: (json['claims'] as Map?)?.cast(), + ); @override bool operator ==(Object other) => @@ -167,13 +166,13 @@ final class AuthSession { @override int get hashCode => Object.hash( - accessToken, - refreshToken, - expiresAt, - userId, - displayName, - _claimsHash(claims), - ); + accessToken, + refreshToken, + expiresAt, + userId, + displayName, + _claimsHash(claims), + ); /// Claims participate in equality so a session whose *only* change is in /// `claims` still counts as new — otherwise a state emission could be diff --git a/lib/src/exceptions.dart b/lib/src/exceptions.dart index 8de9a7e..1dc6e81 100644 --- a/lib/src/exceptions.dart +++ b/lib/src/exceptions.dart @@ -15,12 +15,12 @@ class AuthException extends AppException { final AuthFail fail; AuthException.fromFail(this.fail) - : super(fail.message, code: fail.code, cause: fail.cause); + : super(fail.message, code: fail.code, cause: fail.cause); /// Convenience constructor for manager-internal failures. /// 供管理器内部失败使用的便捷构造。 AuthException(String message, {String? code, Object? cause}) - : this.fromFail(AuthFail(message, code: code, cause: cause)); + : this.fromFail(AuthFail(message, code: code, cause: cause)); } /// Credentials were rejected by the backend (wrong password, unknown user…). @@ -38,7 +38,7 @@ final class InvalidCredentialsException extends AuthException { /// 授权已不可用:会话 / 刷新令牌 / 访问令牌已过期或被吊销,只能重新登录。 final class SessionExpiredException extends AuthException { SessionExpiredException({String message = 'Session expired', Object? cause}) - : super(message, code: 'session_expired', cause: cause); + : super(message, code: 'session_expired', cause: cause); } /// An operation that requires an active session was called with none. diff --git a/server/lib/src/app.dart b/server/lib/src/app.dart index 1aab6ad..a0a2b19 100644 --- a/server/lib/src/app.dart +++ b/server/lib/src/app.dart @@ -13,11 +13,7 @@ import 'logging/logger.dart'; /// The assembled demo backend: owns the socket, the middleware chain and the /// dependency graph. final class AuthServer { - AuthServer({ - required this.config, - required this.logger, - required this.auth, - }) { + AuthServer({required this.config, required this.logger, required this.auth}) { _router ..post('/login', _controller.login) ..post('/refresh', _controller.refresh) diff --git a/server/lib/src/auth/auth_service.dart b/server/lib/src/auth/auth_service.dart index 7069daf..e3c9850 100644 --- a/server/lib/src/auth/auth_service.dart +++ b/server/lib/src/auth/auth_service.dart @@ -156,15 +156,15 @@ final class AuthService { } AuthSuccess _issue(UserRecord user, [String? refreshToken]) => AuthSuccess( - user: user, - accessToken: tokens.sign( - subject: user.id, - displayName: user.displayName, - type: TokenType.access, - ttl: accessTtl, - tokenId: tokens.newId(), - ), - refreshToken: refreshToken ?? refreshTokens.issue(user), - expiresIn: accessTtl.inSeconds, - ); + user: user, + accessToken: tokens.sign( + subject: user.id, + displayName: user.displayName, + type: TokenType.access, + ttl: accessTtl, + tokenId: tokens.newId(), + ), + refreshToken: refreshToken ?? refreshTokens.issue(user), + expiresIn: accessTtl.inSeconds, + ); } diff --git a/server/lib/src/auth/token_service.dart b/server/lib/src/auth/token_service.dart index 99e67fd..ea8d519 100644 --- a/server/lib/src/auth/token_service.dart +++ b/server/lib/src/auth/token_service.dart @@ -33,9 +33,9 @@ final class TokenService { required String secret, Duration clockSkew = const Duration(seconds: 1), Random? random, - }) : _secret = utf8.encode(secret), - _clockSkew = clockSkew, - _random = random ?? Random.secure(); + }) : _secret = utf8.encode(secret), + _clockSkew = clockSkew, + _random = random ?? Random.secure(); final List _secret; final Duration _clockSkew; diff --git a/server/lib/src/auth/user_store.dart b/server/lib/src/auth/user_store.dart index c2d5083..098acff 100644 --- a/server/lib/src/auth/user_store.dart +++ b/server/lib/src/auth/user_store.dart @@ -13,7 +13,7 @@ final class UserRecord { /// in the backend has to change. final class UserStore { UserStore({Map? credentials}) - : _credentials = credentials ?? _defaultCredentials; + : _credentials = credentials ?? _defaultCredentials; static const _defaultCredentials = {'user': 'user'}; diff --git a/server/lib/src/config.dart b/server/lib/src/config.dart index e6b1e62..76e9a1b 100644 --- a/server/lib/src/config.dart +++ b/server/lib/src/config.dart @@ -32,9 +32,8 @@ final class ServerConfig { static const _defaultSecret = 'demo-secret-change-me'; static Duration _durationFromEnv(String key, int defaultSeconds) => Duration( - seconds: - int.tryParse(Platform.environment[key] ?? '') ?? defaultSeconds, - ); + seconds: int.tryParse(Platform.environment[key] ?? '') ?? defaultSeconds, + ); final String host; final int port; diff --git a/server/lib/src/logging/logger.dart b/server/lib/src/logging/logger.dart index 21a3f9f..582b142 100644 --- a/server/lib/src/logging/logger.dart +++ b/server/lib/src/logging/logger.dart @@ -9,8 +9,8 @@ enum LogLevel { debug, info, warn, error } /// `LOG_LEVEL=debug` to also see per-route debug lines. final class Logger { Logger({LogLevel minimum = LogLevel.info, Stdout? output}) - : _minimum = minimum, - _out = output ?? stdout; + : _minimum = minimum, + _out = output ?? stdout; final LogLevel _minimum; final Stdout _out; diff --git a/test/auth_manager_group_test.dart b/test/auth_manager_group_test.dart index e3de17e..8baad12 100644 --- a/test/auth_manager_group_test.dart +++ b/test/auth_manager_group_test.dart @@ -15,10 +15,8 @@ void main() { userId: accountId, ), ), - storeFactory: (accountId) => stores.putIfAbsent( - accountId, - InMemoryTokenStore.new, - ), + storeFactory: (accountId) => + stores.putIfAbsent(accountId, InMemoryTokenStore.new), ); group('AuthManagerGroup — per-account managers', () { @@ -38,9 +36,9 @@ void main() { final stores = {}; final group = buildGroup(stores); - await group.forAccount('alice').login( - const Credentials(username: 'alice', password: 'pw'), - ); + await group + .forAccount('alice') + .login(const Credentials(username: 'alice', password: 'pw')); expect(await stores['alice']!.load(), isNotNull); expect(await stores['bob']?.load(), isNull); @@ -54,12 +52,12 @@ void main() { final stores = {}; final group = buildGroup(stores); - await group.forAccount('alice').login( - const Credentials(username: 'alice', password: 'pw'), - ); - await group.forAccount('bob').login( - const Credentials(username: 'bob', password: 'pw'), - ); + await group + .forAccount('alice') + .login(const Credentials(username: 'alice', password: 'pw')); + await group + .forAccount('bob') + .login(const Credentials(username: 'bob', password: 'pw')); group.switchTo('alice'); expect(group.activeId, 'alice'); @@ -77,9 +75,9 @@ void main() { final stores = {}; final group = buildGroup(stores); - await group.forAccount('alice').login( - const Credentials(username: 'alice', password: 'pw'), - ); + await group + .forAccount('alice') + .login(const Credentials(username: 'alice', password: 'pw')); group.switchTo('alice'); final seen = []; @@ -101,9 +99,9 @@ void main() { final stores = {}; final group = buildGroup(stores); - await group.forAccount('alice').login( - const Credentials(username: 'alice', password: 'pw'), - ); + await group + .forAccount('alice') + .login(const Credentials(username: 'alice', password: 'pw')); group.switchTo('alice'); final seen = []; @@ -123,35 +121,37 @@ void main() { }); group('AuthManagerGroup — lifecycle', () { - test('restoreAll restores every account and activates the requested one', - () async { - final stores = {}; - final seed = buildGroup(stores); - for (final id in ['alice', 'bob']) { - await seed.forAccount(id).login( - Credentials(username: id, password: 'pw'), - ); - } - await seed.disposeAll(); - - // Fresh group reading the same persisted stores. - final group = buildGroup(stores); - await group.restoreAll(['alice', 'bob'], activeId: 'bob'); - - expect(group.activeId, 'bob'); - expect(group.accessToken, 'token-bob'); - expect(group.forAccount('alice').current, isA()); - - await group.disposeAll(); - }); + test( + 'restoreAll restores every account and activates the requested one', + () async { + final stores = {}; + final seed = buildGroup(stores); + for (final id in ['alice', 'bob']) { + await seed + .forAccount(id) + .login(Credentials(username: id, password: 'pw')); + } + await seed.disposeAll(); + + // Fresh group reading the same persisted stores. + final group = buildGroup(stores); + await group.restoreAll(['alice', 'bob'], activeId: 'bob'); + + expect(group.activeId, 'bob'); + expect(group.accessToken, 'token-bob'); + expect(group.forAccount('alice').current, isA()); + + await group.disposeAll(); + }, + ); test('remove signs the account out and clears the active slot', () async { final stores = {}; final group = buildGroup(stores); - await group.forAccount('alice').login( - const Credentials(username: 'alice', password: 'pw'), - ); + await group + .forAccount('alice') + .login(const Credentials(username: 'alice', password: 'pw')); group.switchTo('alice'); final seen = []; @@ -175,9 +175,9 @@ void main() { final stores = {}; final group = buildGroup(stores); - await group.forAccount('alice').login( - const Credentials(username: 'alice', password: 'pw'), - ); + await group + .forAccount('alice') + .login(const Credentials(username: 'alice', password: 'pw')); group.switchTo('alice'); await group.disposeAll(); diff --git a/test/auth_manager_hardening_test.dart b/test/auth_manager_hardening_test.dart index c79ceda..a215dd1 100644 --- a/test/auth_manager_hardening_test.dart +++ b/test/auth_manager_hardening_test.dart @@ -93,21 +93,21 @@ void main() { String token = 'access', String name = 'user@demo', bool expired = false, - }) => - AuthSession( - accessToken: token, - refreshToken: const RefreshToken('refresh'), - expiresAt: expired - ? now.subtract(const Duration(minutes: 1)) - : now.add(const Duration(minutes: 5)), - userId: 'user', - displayName: name, - ); + }) => AuthSession( + accessToken: token, + refreshToken: const RefreshToken('refresh'), + expiresAt: expired + ? now.subtract(const Duration(minutes: 1)) + : now.add(const Duration(minutes: 5)), + userId: 'user', + displayName: name, + ); group('restore hardening', () { test('a logout during restore does not resurrect the session', () async { - final store = - _FlakyTokenStore(loadDelay: const Duration(milliseconds: 20)); + final store = _FlakyTokenStore( + loadDelay: const Duration(milliseconds: 20), + ); await store.save(session()); final manager = AuthManager( strategy: FakeAuthStrategy(session: session()), @@ -225,14 +225,14 @@ void main() { await expectLater( manager.login(const Credentials(username: 'u', password: 'p')), throwsA( - isA() - .having((e) => e.code, 'code', 'manager_disposed'), + isA().having( + (e) => e.code, + 'code', + 'manager_disposed', + ), ), ); - await expectLater( - manager.restore(), - throwsA(isA()), - ); + await expectLater(manager.restore(), throwsA(isA())); }); }); @@ -299,9 +299,9 @@ void main() { group('proactive refresh retry', () { test('a transient failure re-arms the renewal', () { FakeAsync().run((async) { - final strategy = FakeAuthStrategy(session: session(expired: true)) - ..refreshError = - AuthException('offline', code: 'network_unreachable'); + final strategy = FakeAuthStrategy( + session: session(expired: true), + )..refreshError = AuthException('offline', code: 'network_unreachable'); final manager = AuthManager( strategy: strategy, tokenStore: _FlakyTokenStore(), @@ -324,11 +324,8 @@ void main() { group('session equality', () { test('claims participate in equality', () { - AuthSession build(Map? claims) => AuthSession( - accessToken: 'access', - userId: 'user', - claims: claims, - ); + AuthSession build(Map? claims) => + AuthSession(accessToken: 'access', userId: 'user', claims: claims); expect(build({'plan': 'pro'}), isNot(build({'plan': 'free'}))); expect(build({'plan': 'pro'}), build({'plan': 'pro'})); @@ -344,12 +341,12 @@ void main() { storeFactory: (id) => _FlakyTokenStore(), ); - await group.addAccount('alice').login( - const Credentials(username: 'alice', password: 'pw'), - ); - await group.addAccount('bob').login( - const Credentials(username: 'bob', password: 'pw'), - ); + await group + .addAccount('alice') + .login(const Credentials(username: 'alice', password: 'pw')); + await group + .addAccount('bob') + .login(const Credentials(username: 'bob', password: 'pw')); group.switchTo('bob'); expect(group.accessToken, isNotNull); diff --git a/test/auth_manager_lifecycle_test.dart b/test/auth_manager_lifecycle_test.dart index 943810f..3dd6e9d 100644 --- a/test/auth_manager_lifecycle_test.dart +++ b/test/auth_manager_lifecycle_test.dart @@ -37,15 +37,14 @@ void main() { bool expired = false, bool withRefresh = true, Duration ttl = const Duration(hours: 1), - }) => - AuthSession( - accessToken: accessToken, - refreshToken: withRefresh ? const RefreshToken('refresh') : null, - expiresAt: expired - ? fixedNow.subtract(const Duration(minutes: 5)) - : fixedNow.add(ttl), - userId: 'u1', - ); + }) => AuthSession( + accessToken: accessToken, + refreshToken: withRefresh ? const RefreshToken('refresh') : null, + expiresAt: expired + ? fixedNow.subtract(const Duration(minutes: 5)) + : fixedNow.add(ttl), + userId: 'u1', + ); /// Lets pending microtasks settle so stream emissions become observable. /// 让挂起的微任务执行完,使状态流的新值可被观察。 @@ -56,28 +55,30 @@ void main() { } group('AuthManager — refresh lifecycle', () { - test('emits Refreshing then Authenticated, and stays usable meanwhile', - () async { - final strategy = FakeAuthStrategy(session: buildSession()); - final manager = AuthManager( - strategy: strategy, - tokenStore: InMemoryTokenStore(), - clock: () => fixedNow, - ); - await manager.login(credentials); - - final states = []; - final sub = manager.state.listen(states.add); - await manager.refresh(); - await pump(); - - expect(states.any((s) => s is Refreshing), isTrue); - expect(states.last, isA()); - expect(manager.current.isAuthenticated, isTrue); - expect(manager.accessToken, 'access'); - await sub.cancel(); - await manager.dispose(); - }); + test( + 'emits Refreshing then Authenticated, and stays usable meanwhile', + () async { + final strategy = FakeAuthStrategy(session: buildSession()); + final manager = AuthManager( + strategy: strategy, + tokenStore: InMemoryTokenStore(), + clock: () => fixedNow, + ); + await manager.login(credentials); + + final states = []; + final sub = manager.state.listen(states.add); + await manager.refresh(); + await pump(); + + expect(states.any((s) => s is Refreshing), isTrue); + expect(states.last, isA()); + expect(manager.current.isAuthenticated, isTrue); + expect(manager.accessToken, 'access'); + await sub.cancel(); + await manager.dispose(); + }, + ); test('a session stays available during Refreshing', () async { final strategy = _GatedStrategy(buildSession()); @@ -266,26 +267,28 @@ void main() { await manager.dispose(); }); - test('lands unauthenticated when refreshing an expired session fails', - () async { - final stale = buildSession(expired: true); - final store = InMemoryTokenStore(); - await store.save(stale); - final strategy = FakeAuthStrategy(session: stale) - ..refreshError = SessionExpiredException(); - final manager = AuthManager( - strategy: strategy, - tokenStore: store, - clock: () => fixedNow, - ); + test( + 'lands unauthenticated when refreshing an expired session fails', + () async { + final stale = buildSession(expired: true); + final store = InMemoryTokenStore(); + await store.save(stale); + final strategy = FakeAuthStrategy(session: stale) + ..refreshError = SessionExpiredException(); + final manager = AuthManager( + strategy: strategy, + tokenStore: store, + clock: () => fixedNow, + ); - await manager.restore(); - await pump(); + await manager.restore(); + await pump(); - expect(manager.current, const Unauthenticated()); - expect(await store.load(), isNull); - await manager.dispose(); - }); + expect(manager.current, const Unauthenticated()); + expect(await store.load(), isNull); + await manager.dispose(); + }, + ); test('refreshIfExpired: false keeps the session untouched', () async { final stale = buildSession(expired: true); @@ -307,61 +310,60 @@ void main() { }); group('AuthManager — races', () { - test('a refresh landing after logout does not resurrect the session', - () async { - final strategy = _GatedStrategy(buildSession()); - final store = InMemoryTokenStore(); - final manager = AuthManager( - strategy: strategy, - tokenStore: store, - clock: () => fixedNow, - ); - await manager.login(credentials); - - Object? refreshFailure; - final refreshing = manager.refresh(); - unawaited( - refreshing.then( - (_) {}, - onError: (e) { - refreshFailure = e; - }, - ), - ); - - await manager.logout(); - strategy.gate.complete(buildSession(accessToken: 'late')); - await pump(); + test( + 'a refresh landing after logout does not resurrect the session', + () async { + final strategy = _GatedStrategy(buildSession()); + final store = InMemoryTokenStore(); + final manager = AuthManager( + strategy: strategy, + tokenStore: store, + clock: () => fixedNow, + ); + await manager.login(credentials); + + Object? refreshFailure; + final refreshing = manager.refresh(); + unawaited( + refreshing.then( + (_) {}, + onError: (e) { + refreshFailure = e; + }, + ), + ); - expect(refreshFailure, isA()); - expect(manager.current, const Unauthenticated()); - expect(manager.currentSession, isNull); - expect(await store.load(), isNull); - await manager.dispose(); - }); + await manager.logout(); + strategy.gate.complete(buildSession(accessToken: 'late')); + await pump(); + + expect(refreshFailure, isA()); + expect(manager.current, const Unauthenticated()); + expect(manager.currentSession, isNull); + expect(await store.load(), isNull); + await manager.dispose(); + }, + ); }); group('AuthManager — proactive refresh', () { test('failures never leak an unhandled async error', () async { final errors = []; - final zoneRun = runZonedGuarded>( - () async { - final strategy = FakeAuthStrategy( - session: buildSession(ttl: Duration.zero), - )..refreshError = SessionExpiredException(); - final manager = AuthManager( - strategy: strategy, - tokenStore: InMemoryTokenStore(), - autoRefreshAhead: const Duration(minutes: 5), - clock: () => fixedNow, - ); - await manager.login(credentials); - await pump(); - expect(manager.current, const Unauthenticated()); - await manager.dispose(); - }, - (error, stack) => errors.add(error), - ); + final zoneRun = runZonedGuarded>(() async { + final strategy = FakeAuthStrategy( + session: buildSession(ttl: Duration.zero), + )..refreshError = SessionExpiredException(); + final manager = AuthManager( + strategy: strategy, + tokenStore: InMemoryTokenStore(), + autoRefreshAhead: const Duration(minutes: 5), + clock: () => fixedNow, + ); + await manager.login(credentials); + await pump(); + expect(manager.current, const Unauthenticated()); + await manager.dispose(); + }, (error, stack) => errors.add(error)); await (zoneRun ?? Future.value()); expect(errors, isEmpty); }); @@ -455,8 +457,9 @@ void main() { group('mapAuthFailure', () { test('maps invalid_credentials', () { - final mapped = - mapAuthFailure(AuthException('nope', code: 'invalid_credentials')); + final mapped = mapAuthFailure( + AuthException('nope', code: 'invalid_credentials'), + ); expect(mapped, isA()); expect(mapped.code, 'invalid_credentials'); }); diff --git a/test/auth_manager_robustness_test.dart b/test/auth_manager_robustness_test.dart index 5e808b7..f5f2dff 100644 --- a/test/auth_manager_robustness_test.dart +++ b/test/auth_manager_robustness_test.dart @@ -23,10 +23,7 @@ final class _UndeletableStore implements TokenStore { void main() { final now = DateTime.utc(2026, 1, 1, 12); - AuthSession session({ - bool expired = false, - bool withRefresh = true, - }) => + AuthSession session({bool expired = false, bool withRefresh = true}) => AuthSession( accessToken: 'access', refreshToken: withRefresh ? const RefreshToken('refresh') : null, @@ -67,10 +64,7 @@ void main() { test('renews an expired token when a refresh token exists', () async { final strategy = FakeAuthStrategy(session: session()); - final manager = AuthManager( - strategy: strategy, - clock: () => now, - ); + final manager = AuthManager(strategy: strategy, clock: () => now); await manager.login( const Credentials(username: 'user', password: 'user'), ); @@ -159,9 +153,9 @@ void main() { group('proactive retry is bounded', () { test('gives up after the configured number of attempts', () { FakeAsync().run((async) { - final strategy = FakeAuthStrategy(session: session(expired: true)) - ..refreshError = - AuthException('offline', code: 'network_unreachable'); + final strategy = FakeAuthStrategy( + session: session(expired: true), + )..refreshError = AuthException('offline', code: 'network_unreachable'); final manager = AuthManager( strategy: strategy, tokenStore: InMemoryTokenStore(), @@ -191,9 +185,7 @@ void main() { }); group('AuthManagerGroup robustness', () { - AuthManagerGroup build({ - TokenStore Function(String)? storeFactory, - }) => + AuthManagerGroup build({TokenStore Function(String)? storeFactory}) => AuthManagerGroup( strategyFactory: (id) => FakeAuthStrategy(session: session()), storeFactory: storeFactory ?? (id) => InMemoryTokenStore(), @@ -215,8 +207,11 @@ void main() { expect( () => manager.refresh(), throwsA( - isA() - .having((e) => e.code, 'code', 'manager_disposed'), + isA().having( + (e) => e.code, + 'code', + 'manager_disposed', + ), ), ); await group.disposeAll(); @@ -240,12 +235,12 @@ void main() { test('restoreAll can drop accounts that are no longer known', () async { final group = build(); - await group.addAccount('alice').login( - const Credentials(username: 'alice', password: 'pw'), - ); - await group.addAccount('carol').login( - const Credentials(username: 'carol', password: 'pw'), - ); + await group + .addAccount('alice') + .login(const Credentials(username: 'alice', password: 'pw')); + await group + .addAccount('carol') + .login(const Credentials(username: 'carol', password: 'pw')); await group.restoreAll(['alice'], activeId: 'alice', dropOthers: true); diff --git a/test/auth_manager_test.dart b/test/auth_manager_test.dart index ccf12cc..dcfb62d 100644 --- a/test/auth_manager_test.dart +++ b/test/auth_manager_test.dart @@ -13,11 +13,11 @@ final class ExtendingAuthStrategy implements AuthStrategy { int refreshCount = 0; AuthSession _session(DateTime expiresAt) => AuthSession( - accessToken: 'access', - refreshToken: const RefreshToken('refresh'), - expiresAt: expiresAt, - userId: 'u1', - ); + accessToken: 'access', + refreshToken: const RefreshToken('refresh'), + expiresAt: expiresAt, + userId: 'u1', + ); @override Future login(Credentials credentials) async => diff --git a/test/auth_state_test.dart b/test/auth_state_test.dart index 9ad0669..5fbb604 100644 --- a/test/auth_state_test.dart +++ b/test/auth_state_test.dart @@ -36,10 +36,7 @@ void main() { expect(const Refreshing(a), const Refreshing(b)); expect(const LoggingOut(a), const LoggingOut(b)); expect(const Refreshing(a), isNot(const LoggingOut(a))); - expect( - const Refreshing(a).hashCode, - const Refreshing(b).hashCode, - ); + expect(const Refreshing(a).hashCode, const Refreshing(b).hashCode); }); test('AuthError holds the exception', () { diff --git a/test/fake_strategy.dart b/test/fake_strategy.dart index 93e6db8..14267b0 100644 --- a/test/fake_strategy.dart +++ b/test/fake_strategy.dart @@ -17,14 +17,14 @@ final class FakeAuthStrategy implements AuthStrategy { bool logoutCalled = false; FakeAuthStrategy({AuthSession? session}) - : nextSession = session ?? _default(); + : nextSession = session ?? _default(); static AuthSession _default() => const AuthSession( - accessToken: 'access', - refreshToken: RefreshToken('refresh'), - userId: 'u1', - displayName: 'User', - ); + accessToken: 'access', + refreshToken: RefreshToken('refresh'), + userId: 'u1', + displayName: 'User', + ); @override Future login(Credentials credentials) async {