From 56bf6dc92a8512bb435c9b941fc5776bae7eb597 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Wed, 19 Aug 2026 11:12:32 +0200 Subject: [PATCH 01/27] feat(llc)!: let a TokenManager switch users MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `TokenManager` could only ever serve the user it was constructed with: `userId` was final and the `tokenProvider` setter could not assign, because the field was final too. A flow whose user is only known after an authenticated request — a guest, whose id and token are both issued in exchange for an anonymous one — had no way to adopt the result. - Add `setTokenProvider(userId, tokenProvider:)`, which changes the user and the provider together so the manager can never report one user while holding another's token, and expires the cached token. - Remove the `tokenProvider` setter, superseded by the above. - Discard a token that finishes loading after the manager was pointed at another user, so it cannot be cached for the wrong one. Alongside that, three defects in the same area: - `getToken()` consulted its cache only when a concurrent caller had populated it while waiting for the lock, so a sequential call always reloaded — a dynamic provider was invoked on every request. - `AuthInterceptor` read `user_id` from the manager after awaiting the token, so the two could describe different users. It now takes both from the loaded token. - `DynamicTokenProvider` validated only the token type, so a loader returning someone else's token authenticated every later request as that user. It now checks the `user_id` claim, as the static provider already did. And `UserToken.anonymous` no longer takes a `userId`: anonymous tokens always use `UserToken.anonymousUserId`, any other id was ignored, and `rawValue` is now rejected unless its `user_id` claim matches. Co-Authored-By: Claude Opus 5 (1M context) --- packages/stream_core/CHANGELOG.md | 28 +++-- .../lib/src/user/token_manager.dart | 83 ++++++++++---- .../lib/src/user/token_provider.dart | 44 +++++--- .../stream_core/lib/src/user/user_token.dart | 37 +++++-- .../interceptors/auth_interceptor_test.dart | 103 +++++++++++++----- .../test/user/token_manager_test.dart | 101 +++++++++++++---- .../test/user/token_provider_test.dart | 49 ++++++++- 7 files changed, 336 insertions(+), 109 deletions(-) diff --git a/packages/stream_core/CHANGELOG.md b/packages/stream_core/CHANGELOG.md index 93f58768..2b739d60 100644 --- a/packages/stream_core/CHANGELOG.md +++ b/packages/stream_core/CHANGELOG.md @@ -1,24 +1,28 @@ ## Upcoming +### 💥 BREAKING CHANGES + +- Removed the `userId` parameter from `UserToken.anonymous`, anonymous tokens always use `UserToken.anonymousUserId` +- Removed the `TokenManager.tokenProvider` setter, use `setTokenProvider` instead + ### ✨ Features -- Added `AuthInterceptor.withProvider`, which takes a `TokenManager Function()` getter instead of a fixed `TokenManager` instance. This lets callers swap the active `TokenManager` at runtime — e.g. after a guest token exchange resolves a server-assigned user id — and have the interceptor pick up the new instance (and its `userId`) on the next request. The existing `AuthInterceptor(dio, tokenManager)` constructor is unchanged. -- Added `teams` field to `User` class. -- Added optional `onTokenUpdated` callback to `TokenManager`, invoked after every successful - token load. -- Added optional `rawValue` parameter to `UserToken.anonymous` so anonymous tokens can carry - a JWT (e.g. call-restricted tokens for closed livestreams). +- Added `TokenManager.setTokenProvider`, which points an existing manager at another user and expires the cached token +- Added optional `onTokenUpdated` callback to `TokenManager`, invoked after every successful token load +- Added optional `rawValue` to `UserToken.anonymous`, so an anonymous token can carry a JWT granting restricted access +- Added `UserToken.anonymousUserId`, the user id used for anonymous authentication +- Added `AuthInterceptor.withProvider`, which takes a `TokenManager Function()` getter instead of a fixed instance +- Added `teams` field to `User` class -### 🐞 Fixed +### 🐛 Bug Fixes -- `TokenManager.getToken()` now returns the cached token instead of contacting the - `TokenProvider` on every call. -- The `TokenManager.tokenProvider` setter now stores the new provider, previously it only - expired the cached token. +- Fixed `TokenManager.getToken()` contacting the `TokenProvider` on every call instead of returning the cached token +- Fixed `AuthInterceptor` sending a `user_id` that could disagree with the token in the `Authorization` header +- Fixed `DynamicTokenProvider` accepting a token issued for a different user than the one requested ### 🔄 Changed -- Raised the minimum Dart SDK to `^3.12.0`. +- Raised the minimum Dart SDK to `^3.12.0` ## 0.4.0 diff --git a/packages/stream_core/lib/src/user/token_manager.dart b/packages/stream_core/lib/src/user/token_manager.dart index 4fbb5a82..92c7f8c7 100644 --- a/packages/stream_core/lib/src/user/token_manager.dart +++ b/packages/stream_core/lib/src/user/token_manager.dart @@ -6,7 +6,8 @@ import 'user_token.dart'; /// A callback invoked whenever the manager caches a newly loaded token. /// /// Invoked synchronously after the token is cached, before it is returned to -/// the caller that triggered the load. The manager does not await the result. +/// the caller that triggered the load. Throwing from it surfaces to that +/// caller, even though the token was loaded and cached successfully. typedef OnTokenUpdated = void Function(UserToken token); /// Manages user authentication tokens with caching and thread-safe access. @@ -32,36 +33,66 @@ typedef OnTokenUpdated = void Function(UserToken token); /// manager.expireToken(); /// ``` class TokenManager { - /// Creates a [TokenManager] for the specified [userId] with the given [_tokenProvider]. + /// Creates a [TokenManager] for the specified `userId` with the given + /// `tokenProvider`. /// - /// The [userId] identifies the user for whom tokens will be managed. - /// The [_tokenProvider] is used to load tokens when needed. + /// The `userId` identifies the user for whom tokens will be managed. + /// The `tokenProvider` is used to load tokens when needed. /// - /// An optional [onTokenUpdated] callback is invoked after every successful + /// An optional `onTokenUpdated` callback is invoked after every successful /// token load. It is not invoked for callers served from the cache. TokenManager({ - required this.userId, + required this._userId, required this._tokenProvider, - this.onTokenUpdated, + this._onTokenUpdated, }); /// The unique identifier of the user whose tokens are managed. - final String userId; - - /// Invoked after every successful token load. - final OnTokenUpdated? onTokenUpdated; + /// + /// Changes when the manager is pointed at another user with + /// [setTokenProvider]. + String get userId => _userId; + String _userId; // The provider used to load tokens when needed. TokenProvider _tokenProvider; - /// Replaces the provider used to load tokens. + // Invoked after every successful token load. + final OnTokenUpdated? _onTokenUpdated; + + /// Points this manager at `userId`, loading its tokens from `tokenProvider`. + /// + /// The user and the provider change together, so the manager can never report + /// one user while holding another's token. Expires the cached token, so the + /// next [getToken] call loads a fresh one for the new user. + /// + /// Use this to reuse a manager across users, and when a user's identity is + /// only known after an authenticated request — a guest, whose id and token + /// are both issued in exchange for an anonymous one: /// - /// Expires the cached token when the provider changes, so the next - /// [getToken] call loads a fresh token from the new provider. - set tokenProvider(TokenProvider provider) { - if (_tokenProvider == provider) return; - _tokenProvider = provider; - expireToken(); + /// ```dart + /// // Authenticate anonymously while the real identity is being obtained. + /// final manager = TokenManager( + /// userId: UserToken.anonymousUserId, + /// tokenProvider: TokenProvider.static(UserToken.anonymous()), + /// ); + /// + /// // Adopt the identity once it is known. + /// manager.setTokenProvider( + /// userId, + /// tokenProvider: TokenProvider.static(UserToken(rawToken)), + /// ); + /// ``` + void setTokenProvider( + String userId, { + required TokenProvider tokenProvider, + }) { + _userId = userId; + _tokenProvider = tokenProvider; + + // The cached token belongs to the previous user and provider, so drop it + // and let the next `getToken` call load a fresh one. + return expireToken(); } // The currently cached token, if any. @@ -99,12 +130,20 @@ class TokenManager { }); } - // Loads a token from the provider, caches it, and notifies the - // [onTokenUpdated] callback. + // Loads a token from the provider and, unless the manager has since been + // pointed at another user, caches it and notifies `onTokenUpdated`. Future _loadAndNotify() async { - final updatedToken = await _tokenProvider.loadToken(userId); + final loadingFor = _userId; + final updatedToken = await _tokenProvider.loadToken(loadingFor); + + // Only cache the token if the manager still points at the user it was + // loaded for; `setTokenProvider` may have run during the load, and the + // token belongs to whoever we were before. + if (loadingFor != _userId) return updatedToken; + _cachedToken = updatedToken; - onTokenUpdated?.call(updatedToken); + _onTokenUpdated?.call(updatedToken); + return updatedToken; } diff --git a/packages/stream_core/lib/src/user/token_provider.dart b/packages/stream_core/lib/src/user/token_provider.dart index 1f6ddb62..bec7813c 100644 --- a/packages/stream_core/lib/src/user/token_provider.dart +++ b/packages/stream_core/lib/src/user/token_provider.dart @@ -42,8 +42,8 @@ abstract interface class TokenProvider { /// Returns a [Future] that resolves to a [UserToken] configured for either /// JWT authentication or anonymous access, depending on the provider type. /// - /// Throws an [ArgumentError] if the loaded token is not valid (for JWT providers) - /// or if the 'user_id' claim is missing or empty (for JWT tokens). + /// Throws an [ArgumentError] if the token does not belong to [userId], or if + /// it is not valid for the provider's authentication type. Future loadToken(String userId); } @@ -56,7 +56,7 @@ abstract interface class TokenProvider { /// Useful for scenarios where tokens don't expire, long-lived tokens, /// or for testing purposes. class StaticTokenProvider implements TokenProvider { - /// Creates a static token provider with the given [_rawToken]. + /// Creates a static token provider with the given `token`. const StaticTokenProvider(this._rawToken); // The pre-configured token. @@ -73,11 +73,13 @@ class StaticTokenProvider implements TokenProvider { @override Future loadToken(String userId) async { // Validate that the token's user_id matches the requested userId - if (_rawToken.userId == userId) return _rawToken; + if (_rawToken.userId != userId) { + throw ArgumentError( + 'User ID mismatch: expected "${_rawToken.userId}", got "$userId"', + ); + } - throw ArgumentError( - 'User ID mismatch: expected "${_rawToken.userId}", got "$userId"', - ); + return _rawToken; } } @@ -87,7 +89,7 @@ class StaticTokenProvider implements TokenProvider { /// for users when needed. The loader function is called with the user ID /// and must return a fresh JWT token, typically used for token refresh scenarios. class DynamicTokenProvider implements TokenProvider { - /// Creates a dynamic token provider with the given [_loader] function. + /// Creates a dynamic token provider with the given `loader` function. const DynamicTokenProvider(this._loader); // The function used to load tokens for users. @@ -95,21 +97,31 @@ class DynamicTokenProvider implements TokenProvider { /// Loads a fresh JWT token for the specified [userId] using the configured loader. /// - /// Calls the [_loader] function with the [userId] to fetch a fresh JWT token - /// and returns the [UserToken] instance from the result. + /// Calls the loader with [userId] to fetch a fresh JWT token and returns the + /// [UserToken] instance from the result. /// /// Returns a [Future] that resolves to a [UserToken] configured for JWT authentication. /// - /// Throws an [ArgumentError] if the token returned by the loader is not a JWT token - /// or if the 'user_id' claim is missing or empty. + /// Throws an [ArgumentError] if the token returned by the loader is not a JWT + /// token, or if its 'user_id' claim is not [userId]. @override Future loadToken(String userId) async { final token = await _loader.call(userId); + + // Validate that the token's user_id matches the requested userId + if (token.userId != userId) { + throw ArgumentError( + 'User ID mismatch: expected "$userId", got "${token.userId}"', + ); + } + // Validate that the returned token is a JWT token - if (token.authType == AuthType.jwt) return token; + if (token.authType != AuthType.jwt) { + throw ArgumentError( + 'Token type mismatch: expected jwt, got ${token.authType.headerValue}', + ); + } - throw ArgumentError( - 'Token type mismatch: expected jwt, got ${token.authType.headerValue}', - ); + return token; } } diff --git a/packages/stream_core/lib/src/user/user_token.dart b/packages/stream_core/lib/src/user/user_token.dart index 1dfca446..0e0e0961 100644 --- a/packages/stream_core/lib/src/user/user_token.dart +++ b/packages/stream_core/lib/src/user/user_token.dart @@ -25,7 +25,7 @@ typedef UserTokenLoader = Future Function(String userId); /// /// Create an anonymous token: /// ```dart -/// final token = UserToken.anonymous(userId: 'guest-123'); +/// final token = UserToken.anonymous(); /// print(token.authType); // AuthType.anonymous /// ``` class UserToken extends Equatable { @@ -58,19 +58,33 @@ class UserToken extends Equatable { /// Creates an anonymous user token. /// - /// Creates a token for anonymous authentication with the specified [userId]. - /// When [userId] is not provided, defaults to '!anon' for anonymous users. + /// Anonymous tokens always use [anonymousUserId] as their user id. /// /// An optional [rawValue] can carry a JWT that is sent along with anonymous - /// requests, e.g. a call-restricted token granting an anonymous user access - /// to specific resources (such as a closed livestream). When omitted, the - /// token carries no raw value and requests are sent without credentials. + /// requests, granting the caller access to the specific resources its claims + /// name. When omitted, the token carries no raw value and requests are sent + /// without credentials. /// /// Returns a [UserToken] configured for anonymous access. - factory UserToken.anonymous({String? userId, String rawValue = ''}) { + /// + /// Throws an [ArgumentError] if [rawValue] is given and is not a valid JWT, + /// or if its 'user_id' claim is not [anonymousUserId]. + factory UserToken.anonymous({String rawValue = ''}) { + if (rawValue.isNotEmpty) { + final jwtBody = JsonWebToken.unverified(rawValue); + final claim = jwtBody.claims.getTyped('user_id'); + if (claim != anonymousUserId) { + throw ArgumentError.value( + rawValue, + 'rawValue', + 'Invalid anonymous JWT token: user_id claim must be "$anonymousUserId", got "$claim"', + ); + } + } + return UserToken._( rawValue: rawValue, - userId: userId ?? '!anon', + userId: anonymousUserId, authType: AuthType.anonymous, ); } @@ -81,16 +95,19 @@ class UserToken extends Equatable { required this.authType, }); + /// The user id used for anonymous authentication. + static const anonymousUserId = '!anon'; + /// The raw token value. /// /// For JWT tokens, contains the complete JWT string. For anonymous tokens, - /// this field is empty as no token value is required. + /// it is empty unless one was supplied to grant restricted access. final String rawValue; /// The unique identifier of the user. /// /// For JWT tokens, this value is extracted from the 'user_id' claim. - /// For anonymous tokens, this can be a custom identifier or defaults to '!anon'. + /// For anonymous tokens, it is always [anonymousUserId]. final String userId; /// The authentication type of this token. diff --git a/packages/stream_core/test/api/interceptors/auth_interceptor_test.dart b/packages/stream_core/test/api/interceptors/auth_interceptor_test.dart index 37708067..2c0ed9e0 100644 --- a/packages/stream_core/test/api/interceptors/auth_interceptor_test.dart +++ b/packages/stream_core/test/api/interceptors/auth_interceptor_test.dart @@ -115,43 +115,42 @@ void main() { ); test( - 'picks up a TokenManager swapped in while the token is loading, so the ' - 'user_id query parameter reflects a server-resolved id (guest exchange)', + 'sends the user id a guest exchange returned, once the token manager is ' + 'pointed at it', () async { - // Simulates the guest flow: the token provider resolves to a - // server-assigned id and swaps in a new TokenManager carrying that id - // before the request headers are written. The interceptor reads the - // manager through the getter, so it observes the swapped instance. - late TokenManager tokenManager; - tokenManager = TokenManager( - userId: 'requested-id', - tokenProvider: TokenProvider.dynamic((_) async { - final token = _generateTestUserToken('server-assigned-id'); - tokenManager = TokenManager( - userId: token.userId, - tokenProvider: TokenProvider.static(token), - ); - return token; - }), + // Simulates the guest flow: the exchange is authenticated anonymously, + // then the manager is pointed at the id the exchange returned. The user + // id and the token change together, so the `user_id` query parameter + // always describes the token in the `Authorization` header. + const serverId = 'server-assigned-id'; + + final tokenManager = TokenManager( + userId: UserToken.anonymousUserId, + tokenProvider: TokenProvider.static(UserToken.anonymous()), ); final dio = Dio(BaseOptions(baseUrl: 'https://example.com')); final adapter = _CapturingHttpClientAdapter(); dio.httpClientAdapter = adapter; - dio.interceptors.add(AuthInterceptor.withProvider(dio, tokenManagerProvider: () => tokenManager)); + dio.interceptors.add(AuthInterceptor(dio, tokenManager)); + + tokenManager.setTokenProvider( + serverId, + tokenProvider: TokenProvider.static(_generateTestUserToken(serverId)), + ); await dio.get('/test'); + expect(adapter.lastRequest?.queryParameters['user_id'], serverId); expect( - adapter.lastRequest?.queryParameters['user_id'], - 'server-assigned-id', + adapter.lastRequest?.headers['stream-auth-type'], + AuthType.jwt.headerValue, ); }, ); test( - 'uses the current TokenManager userId when nothing swaps it ' - '(regular/anonymous users)', + 'uses the current TokenManager userId when nothing swaps it', () async { final tokenManager = TokenManager( userId: 'user-123', @@ -171,6 +170,60 @@ void main() { }, ); + test( + 'sends an anonymous token as an empty Authorization header with the ' + 'anonymous auth type', + () async { + final tokenManager = TokenManager( + userId: UserToken.anonymousUserId, + tokenProvider: TokenProvider.static(UserToken.anonymous()), + ); + + final dio = Dio(BaseOptions(baseUrl: 'https://example.com')); + final adapter = _CapturingHttpClientAdapter(); + dio.httpClientAdapter = adapter; + dio.interceptors.add(AuthInterceptor(dio, tokenManager)); + + await dio.get('/test'); + + expect(adapter.lastRequest?.headers['Authorization'], isEmpty); + expect( + adapter.lastRequest?.headers['stream-auth-type'], + AuthType.anonymous.headerValue, + ); + expect( + adapter.lastRequest?.queryParameters['user_id'], + UserToken.anonymousUserId, + ); + }, + ); + + test( + 'sends a restricted anonymous token as the Authorization header', + () async { + final restricted = _generateTestUserToken(UserToken.anonymousUserId); + final tokenManager = TokenManager( + userId: UserToken.anonymousUserId, + tokenProvider: TokenProvider.static( + UserToken.anonymous(rawValue: restricted.rawValue), + ), + ); + + final dio = Dio(BaseOptions(baseUrl: 'https://example.com')); + final adapter = _CapturingHttpClientAdapter(); + dio.httpClientAdapter = adapter; + dio.interceptors.add(AuthInterceptor(dio, tokenManager)); + + await dio.get('/test'); + + expect(adapter.lastRequest?.headers['Authorization'], restricted.rawValue); + expect( + adapter.lastRequest?.headers['stream-auth-type'], + AuthType.anonymous.headerValue, + ); + }, + ); + test( 'does not retry a token-expired response when using a static provider ' '(e.g. a guest token): the error is surfaced to the caller instead of ' @@ -203,9 +256,9 @@ void main() { '(guest exchange resolving mid-flight)', () async { // Starts on a dynamic manager and swaps to a static one carrying the - // server-resolved id once the request is already in flight, mirroring - // the guest flow. onError observes the swapped-in (static) manager and - // must forward the error rather than expire + retry. + // exchanged id once the request is already in flight, mirroring the + // guest flow. onError observes the swapped-in (static) manager and must + // forward the error rather than expire + retry. var tokenManager = TokenManager( userId: 'requested-id', tokenProvider: TokenProvider.dynamic( diff --git a/packages/stream_core/test/user/token_manager_test.dart b/packages/stream_core/test/user/token_manager_test.dart index 0d3272a9..89d461e1 100644 --- a/packages/stream_core/test/user/token_manager_test.dart +++ b/packages/stream_core/test/user/token_manager_test.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:convert'; import 'package:stream_core/stream_core.dart'; import 'package:test/test.dart'; @@ -19,7 +20,15 @@ class _CountingProvider implements TokenProvider { } } -UserToken _token(String value) => UserToken.anonymous(userId: value); +/// Builds a JWT [UserToken] with the given [userId] claim, sufficient for +/// [UserToken]'s unverified parsing. +UserToken _token(String userId) { + String encode(Map json) => base64Url.encode(utf8.encode(jsonEncode(json))).replaceAll('=', ''); + final header = encode({'alg': 'HS256', 'typ': 'JWT'}); + final payload = encode({'user_id': userId}); + final signature = encode({'sig': 'fake'}); + return UserToken('$header.$payload.$signature'); +} void main() { group('TokenManager', () { @@ -112,39 +121,90 @@ void main() { }); }); - group('tokenProvider setter', () { - test('swaps the provider and expires the cached token', () async { - final oldProvider = _CountingProvider((_) async => _token('old')); - final newProvider = _CountingProvider((_) async => _token('new')); + group('setTokenProvider', () { + test('points the manager at another user', () async { final manager = TokenManager( userId: 'user-1', - tokenProvider: oldProvider, + tokenProvider: TokenProvider.static(_token('user-1')), ); - expect(await manager.getToken(), _token('old')); + expect((await manager.getToken()).userId, 'user-1'); - manager.tokenProvider = newProvider; + manager.setTokenProvider( + 'user-2', + tokenProvider: TokenProvider.static(_token('user-2')), + ); - expect(manager.peekToken(), isNull); - expect(await manager.getToken(), _token('new')); - expect(oldProvider.loadCount, 1); - expect(newProvider.loadCount, 1); + expect(manager.userId, 'user-2'); + expect((await manager.getToken()).userId, 'user-2'); }); - test('keeps the cached token when the provider is unchanged', () async { - final provider = _CountingProvider((_) async => _token('token-1')); + test('expires the token cached for the previous user', () async { final manager = TokenManager( userId: 'user-1', - tokenProvider: provider, + tokenProvider: TokenProvider.static(_token('user-1')), ); await manager.getToken(); - manager.tokenProvider = provider; + expect(manager.peekToken(), _token('user-1')); - expect(manager.peekToken(), _token('token-1')); + manager.setTokenProvider( + 'user-2', + tokenProvider: TokenProvider.static(_token('user-2')), + ); + + expect(manager.peekToken(), isNull); }); - test('usesStaticProvider reflects the swapped provider', () { + test( + 'supports a guest exchange, which is authenticated anonymously before ' + 'its user id and token are known', + () async { + const serverId = 'guest-abc-guest-123'; + + final manager = TokenManager( + userId: UserToken.anonymousUserId, + tokenProvider: TokenProvider.static(UserToken.anonymous()), + ); + + final anonymous = await manager.getToken(); + expect(anonymous.authType, AuthType.anonymous); + expect(anonymous.rawValue, isEmpty); + + manager.setTokenProvider( + serverId, + tokenProvider: TokenProvider.static(_token(serverId)), + ); + + final guest = await manager.getToken(); + expect(manager.userId, serverId); + expect(guest.authType, AuthType.jwt); + expect(guest.userId, serverId); + }, + ); + + test('a load in flight does not cache its token over the new user', () async { + final slowLoad = Completer(); + final manager = TokenManager( + userId: 'user-1', + tokenProvider: _CountingProvider((_) => slowLoad.future), + ); + + final pending = manager.getToken(); + + manager.setTokenProvider( + 'user-2', + tokenProvider: TokenProvider.static(_token('user-2')), + ); + slowLoad.complete(_token('user-1')); + await pending; + + // user-1's token must not be waiting in the cache for user-2 to send. + expect(manager.peekToken(), isNull); + expect((await manager.getToken()).userId, 'user-2'); + }); + + test('usesStaticProvider reflects the new provider', () { final manager = TokenManager( userId: 'user-1', tokenProvider: TokenProvider.static(_token('user-1')), @@ -152,7 +212,10 @@ void main() { expect(manager.usesStaticProvider, isTrue); - manager.tokenProvider = _CountingProvider((_) async => _token('t')); + manager.setTokenProvider( + 'user-1', + tokenProvider: _CountingProvider((_) async => _token('user-1')), + ); expect(manager.usesStaticProvider, isFalse); }); diff --git a/packages/stream_core/test/user/token_provider_test.dart b/packages/stream_core/test/user/token_provider_test.dart index a0b9746c..78524540 100644 --- a/packages/stream_core/test/user/token_provider_test.dart +++ b/packages/stream_core/test/user/token_provider_test.dart @@ -24,24 +24,51 @@ void main() { }); test('carries an optional raw value for restricted access', () { - final token = UserToken.anonymous(rawValue: 'restricted-jwt'); + final restricted = _fakeJwt(UserToken.anonymousUserId); + final token = UserToken.anonymous(rawValue: restricted); expect(token.userId, '!anon'); - expect(token.rawValue, 'restricted-jwt'); + expect(token.rawValue, restricted); expect(token.authType, AuthType.anonymous); }); + + test( + 'rejects a raw value claiming a real user, so an anonymous token cannot ' + 'stand in for someone else', + () { + expect( + () => UserToken.anonymous(rawValue: _fakeJwt('alice')), + throwsArgumentError, + ); + }, + ); + + test('rejects a raw value that is not a JWT at all', () { + expect( + () => UserToken.anonymous(rawValue: 'not-a-jwt'), + throwsArgumentError, + ); + }); + + test('rejects a raw value whose segments are not valid base64', () { + // Shaped like a JWT, so parsing gets further before failing. + expect( + () => UserToken.anonymous(rawValue: 'a.b.c'), + throwsFormatException, + ); + }); }); group('StaticTokenProvider', () { test('returns the token when the user ID matches', () async { - final token = UserToken.anonymous(userId: 'user-1'); + final token = UserToken(_fakeJwt('user-1')); final provider = TokenProvider.static(token); expect(await provider.loadToken('user-1'), token); }); test('throws when the user ID does not match', () { - final token = UserToken.anonymous(userId: 'user-1'); + final token = UserToken(_fakeJwt('user-1')); final provider = TokenProvider.static(token); expect(() => provider.loadToken('user-2'), throwsArgumentError); @@ -62,10 +89,22 @@ void main() { test('throws when the loader returns a non-JWT token', () { final provider = TokenProvider.dynamic( - (userId) async => UserToken.anonymous(userId: userId), + (_) async => UserToken.anonymous(), ); expect(() => provider.loadToken('user-1'), throwsArgumentError); }); + + test( + 'throws when the loader returns a token for a different user, which would ' + 'otherwise authenticate every later request as that user', + () { + final provider = TokenProvider.dynamic( + (_) async => UserToken(_fakeJwt('someone-else')), + ); + + expect(() => provider.loadToken('user-1'), throwsArgumentError); + }, + ); }); } From 84e948eb009ddd15a87639dc228f969f9dcf9fa4 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Wed, 19 Aug 2026 11:19:09 +0200 Subject: [PATCH 02/27] docs(llc): drop the AuthInterceptor user_id entry from the changelog The entry claimed a fix this branch does not make. `AuthInterceptor` reads `user_id` from the token manager rather than from the loaded token on purpose: taking it from the token would make every request internally consistent and therefore always accepted, hiding a manager/token divergence instead of surfacing it. Co-Authored-By: Claude Opus 5 (1M context) --- packages/stream_core/CHANGELOG.md | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/stream_core/CHANGELOG.md b/packages/stream_core/CHANGELOG.md index 2b739d60..c42cfae8 100644 --- a/packages/stream_core/CHANGELOG.md +++ b/packages/stream_core/CHANGELOG.md @@ -17,7 +17,6 @@ ### 🐛 Bug Fixes - Fixed `TokenManager.getToken()` contacting the `TokenProvider` on every call instead of returning the cached token -- Fixed `AuthInterceptor` sending a `user_id` that could disagree with the token in the `Authorization` header - Fixed `DynamicTokenProvider` accepting a token issued for a different user than the one requested ### 🔄 Changed From 8e963f4ee42e6504b2f873dd2f325debb8169aa3 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Wed, 19 Aug 2026 11:22:09 +0200 Subject: [PATCH 03/27] test(llc): cover the deliberate user_id/token divergence in AuthInterceptor `setTokenProvider` makes it reachable for a request to carry `user_id` for one user and a token for another, when the manager is re-pointed while a token is loading. That is allowed on purpose so the server rejects it; deriving `user_id` from the token would make the request self-consistent and silently act as the token's owner. Pin it with a test so it is not "fixed" the other way, and trim the comment that claimed the opposite. Co-Authored-By: Claude Opus 5 (1M context) --- .../api/interceptors/auth_interceptor.dart | 7 +--- .../interceptors/auth_interceptor_test.dart | 41 +++++++++++++++++++ 2 files changed, 43 insertions(+), 5 deletions(-) diff --git a/packages/stream_core/lib/src/api/interceptors/auth_interceptor.dart b/packages/stream_core/lib/src/api/interceptors/auth_interceptor.dart index 489ea92d..863c5ae6 100644 --- a/packages/stream_core/lib/src/api/interceptors/auth_interceptor.dart +++ b/packages/stream_core/lib/src/api/interceptors/auth_interceptor.dart @@ -55,11 +55,8 @@ class AuthInterceptor extends QueuedInterceptor { try { final token = await _effectiveTokenManager.getToken(); - // Re-read the token manager after awaiting the token: loading it may - // have swapped in a new manager carrying a server-resolved user id - // (e.g. a guest exchange). Reading `userId` here keeps the `user_id` - // query parameter consistent with the identity in the `Authorization` - // header below. + // Read from the manager rather than the token, so a token that belongs + // to someone else is rejected instead of silently accepted. options.queryParameters['user_id'] = _effectiveTokenManager.userId; options.headers['Authorization'] = token.rawValue; options.headers['stream-auth-type'] = token.authType.headerValue; diff --git a/packages/stream_core/test/api/interceptors/auth_interceptor_test.dart b/packages/stream_core/test/api/interceptors/auth_interceptor_test.dart index 2c0ed9e0..58051428 100644 --- a/packages/stream_core/test/api/interceptors/auth_interceptor_test.dart +++ b/packages/stream_core/test/api/interceptors/auth_interceptor_test.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:convert'; import 'package:stream_core/stream_core.dart'; @@ -224,6 +225,46 @@ void main() { }, ); + test( + 'sends the token manager user id, not the loaded token user id, so a ' + 'manager pointed at another user mid-load is rejected rather than ' + 'silently authenticated as whoever the token belongs to', + () async { + final slowLoad = Completer(); + final tokenManager = TokenManager( + userId: 'user-1', + tokenProvider: TokenProvider.dynamic((_) => slowLoad.future), + ); + + final dio = Dio(BaseOptions(baseUrl: 'https://example.com')); + final adapter = _CapturingHttpClientAdapter(); + dio.httpClientAdapter = adapter; + dio.interceptors.add(AuthInterceptor(dio, tokenManager)); + + final pending = dio.get('/test'); + await pumpEventQueue(); + + // The load is already running for user-1 when the manager moves on. + final userTwoToken = _generateTestUserToken('user-2'); + tokenManager.setTokenProvider( + 'user-2', + tokenProvider: TokenProvider.static(userTwoToken), + ); + slowLoad.complete(_generateTestUserToken('user-1')); + await pending; + + // The mismatch is deliberate: `user_id` describes who we believe we + // are, so a request carrying someone else's token is rejected and the + // divergence surfaces. Deriving `user_id` from the token instead would + // make the request self-consistent and silently act as that user. + expect(adapter.lastRequest?.queryParameters['user_id'], 'user-2'); + expect( + adapter.lastRequest?.headers['Authorization'], + isNot(userTwoToken.rawValue), + ); + }, + ); + test( 'does not retry a token-expired response when using a static provider ' '(e.g. a guest token): the error is surfaced to the caller instead of ' From ed252e881eaef1000657e04297fb90b90ba813ed Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Wed, 19 Aug 2026 11:32:18 +0200 Subject: [PATCH 04/27] fix(llc): discard a token load invalidated while it was in flight The stale-load guard compared user ids, which let two cases through: `setTokenProvider` with the same user id and a new provider, and a plain `expireToken()` during a load. Both ended up caching the token the caller had just asked to stop using. Loads now carry a generation stamp that `expireToken` bumps, which subsumes the user id case. Also address review feedback: order `DynamicTokenProvider`'s checks so a non-JWT token is reported as the wrong type rather than the wrong user, align `StaticTokenProvider`'s mismatch message with it, and document that `UserToken.anonymous` throws FormatException for an unparsable rawValue. Co-Authored-By: Claude Opus 5 (1M context) --- packages/stream_core/CHANGELOG.md | 3 +- .../lib/src/user/token_manager.dart | 25 ++++++++--- .../lib/src/user/token_provider.dart | 17 ++++--- .../stream_core/lib/src/user/user_token.dart | 5 ++- .../test/user/token_manager_test.dart | 45 +++++++++++++++++++ .../test/user/token_provider_test.dart | 13 +++++- 6 files changed, 90 insertions(+), 18 deletions(-) diff --git a/packages/stream_core/CHANGELOG.md b/packages/stream_core/CHANGELOG.md index c42cfae8..4f5fe5c0 100644 --- a/packages/stream_core/CHANGELOG.md +++ b/packages/stream_core/CHANGELOG.md @@ -9,7 +9,7 @@ - Added `TokenManager.setTokenProvider`, which points an existing manager at another user and expires the cached token - Added optional `onTokenUpdated` callback to `TokenManager`, invoked after every successful token load -- Added optional `rawValue` to `UserToken.anonymous`, so an anonymous token can carry a JWT granting restricted access +- Added optional `rawValue` to `UserToken.anonymous`, so an anonymous token can carry a JWT granting restricted access, provided its `user_id` claim is `UserToken.anonymousUserId` - Added `UserToken.anonymousUserId`, the user id used for anonymous authentication - Added `AuthInterceptor.withProvider`, which takes a `TokenManager Function()` getter instead of a fixed instance - Added `teams` field to `User` class @@ -18,6 +18,7 @@ - Fixed `TokenManager.getToken()` contacting the `TokenProvider` on every call instead of returning the cached token - Fixed `DynamicTokenProvider` accepting a token issued for a different user than the one requested +- Fixed `TokenManager` caching a token that finished loading after `expireToken` or `setTokenProvider` had invalidated it ### 🔄 Changed diff --git a/packages/stream_core/lib/src/user/token_manager.dart b/packages/stream_core/lib/src/user/token_manager.dart index 92c7f8c7..dbf67c3a 100644 --- a/packages/stream_core/lib/src/user/token_manager.dart +++ b/packages/stream_core/lib/src/user/token_manager.dart @@ -98,6 +98,10 @@ class TokenManager { // The currently cached token, if any. UserToken? _cachedToken; + // Bumped every time the cached token is invalidated, so a load that started + // before that point can tell its result is no longer wanted. + var _generation = 0; + /// Returns the currently cached token without loading a new one. /// /// Returns the cached [UserToken] if available, or null if no token @@ -130,16 +134,17 @@ class TokenManager { }); } - // Loads a token from the provider and, unless the manager has since been - // pointed at another user, caches it and notifies `onTokenUpdated`. + // Loads a token from the provider and, unless the cached token was + // invalidated while it loaded, caches it and notifies `onTokenUpdated`. Future _loadAndNotify() async { final loadingFor = _userId; + final loadingGeneration = _generation; final updatedToken = await _tokenProvider.loadToken(loadingFor); - // Only cache the token if the manager still points at the user it was - // loaded for; `setTokenProvider` may have run during the load, and the - // token belongs to whoever we were before. - if (loadingFor != _userId) return updatedToken; + // Only cache the token if nothing invalidated the cache while it loaded. + // `setTokenProvider` or `expireToken` may have run, which means this token + // is the one the caller asked us to stop using. + if (loadingGeneration != _generation) return updatedToken; _cachedToken = updatedToken; _onTokenUpdated?.call(updatedToken); @@ -152,5 +157,11 @@ class TokenManager { /// Clears the cached token, forcing the next call to [getToken] to /// load a fresh token from the provider. This is useful when a token /// becomes invalid or needs to be refreshed. - void expireToken() => _cachedToken = null; + /// + /// A load already in flight is discarded too, rather than caching the token + /// this call asked to stop using. + void expireToken() { + _generation++; + _cachedToken = null; + } } diff --git a/packages/stream_core/lib/src/user/token_provider.dart b/packages/stream_core/lib/src/user/token_provider.dart index bec7813c..843fb399 100644 --- a/packages/stream_core/lib/src/user/token_provider.dart +++ b/packages/stream_core/lib/src/user/token_provider.dart @@ -75,7 +75,7 @@ class StaticTokenProvider implements TokenProvider { // Validate that the token's user_id matches the requested userId if (_rawToken.userId != userId) { throw ArgumentError( - 'User ID mismatch: expected "${_rawToken.userId}", got "$userId"', + 'User ID mismatch: expected "$userId", got "${_rawToken.userId}"', ); } @@ -108,17 +108,20 @@ class DynamicTokenProvider implements TokenProvider { Future loadToken(String userId) async { final token = await _loader.call(userId); - // Validate that the token's user_id matches the requested userId - if (token.userId != userId) { + // Validate the type before the user id, so a non-JWT token is reported as + // the wrong type rather than as belonging to the wrong user: an anonymous + // token always carries `UserToken.anonymousUserId`, so it would otherwise + // fail the user id check first. + if (token.authType != AuthType.jwt) { throw ArgumentError( - 'User ID mismatch: expected "$userId", got "${token.userId}"', + 'Token type mismatch: expected jwt, got ${token.authType.headerValue}', ); } - // Validate that the returned token is a JWT token - if (token.authType != AuthType.jwt) { + // Validate that the token's user_id matches the requested userId + if (token.userId != userId) { throw ArgumentError( - 'Token type mismatch: expected jwt, got ${token.authType.headerValue}', + 'User ID mismatch: expected "$userId", got "${token.userId}"', ); } diff --git a/packages/stream_core/lib/src/user/user_token.dart b/packages/stream_core/lib/src/user/user_token.dart index 0e0e0961..a4a0621a 100644 --- a/packages/stream_core/lib/src/user/user_token.dart +++ b/packages/stream_core/lib/src/user/user_token.dart @@ -67,8 +67,9 @@ class UserToken extends Equatable { /// /// Returns a [UserToken] configured for anonymous access. /// - /// Throws an [ArgumentError] if [rawValue] is given and is not a valid JWT, - /// or if its 'user_id' claim is not [anonymousUserId]. + /// Throws an [ArgumentError] if [rawValue] is given and its 'user_id' claim + /// is not [anonymousUserId], and a [FormatException] if it cannot be parsed + /// as a JWT. factory UserToken.anonymous({String rawValue = ''}) { if (rawValue.isNotEmpty) { final jwtBody = JsonWebToken.unverified(rawValue); diff --git a/packages/stream_core/test/user/token_manager_test.dart b/packages/stream_core/test/user/token_manager_test.dart index 89d461e1..c24379e2 100644 --- a/packages/stream_core/test/user/token_manager_test.dart +++ b/packages/stream_core/test/user/token_manager_test.dart @@ -119,6 +119,26 @@ void main() { expect(await manager.getToken(), _token('v2')); expect(provider.loadCount, 2); }); + + test( + 'discards a load in flight, rather than caching the token it was told ' + 'to stop using', + () async { + final slowLoad = Completer(); + final manager = TokenManager( + userId: 'user-1', + tokenProvider: _CountingProvider((_) => slowLoad.future), + ); + + final pending = manager.getToken(); + + manager.expireToken(); + slowLoad.complete(_token('user-1')); + await pending; + + expect(manager.peekToken(), isNull); + }, + ); }); group('setTokenProvider', () { @@ -204,6 +224,31 @@ void main() { expect((await manager.getToken()).userId, 'user-2'); }); + test( + 'discards a load in flight when only the provider changes, so the ' + 'replaced provider cannot cache its token for the same user', + () async { + final slowLoad = Completer(); + final manager = TokenManager( + userId: 'user-1', + tokenProvider: _CountingProvider((_) => slowLoad.future), + ); + + final pending = manager.getToken(); + + // Same user, fresh provider — the user id guard alone would let the + // replaced provider's token through. + manager.setTokenProvider( + 'user-1', + tokenProvider: TokenProvider.static(_token('user-1')), + ); + slowLoad.complete(_token('user-1')); + await pending; + + expect(manager.peekToken(), isNull); + }, + ); + test('usesStaticProvider reflects the new provider', () { final manager = TokenManager( userId: 'user-1', diff --git a/packages/stream_core/test/user/token_provider_test.dart b/packages/stream_core/test/user/token_provider_test.dart index 78524540..36742cf5 100644 --- a/packages/stream_core/test/user/token_provider_test.dart +++ b/packages/stream_core/test/user/token_provider_test.dart @@ -92,7 +92,18 @@ void main() { (_) async => UserToken.anonymous(), ); - expect(() => provider.loadToken('user-1'), throwsArgumentError); + // Reported as the wrong type, not the wrong user: an anonymous token + // also carries a user id that cannot match the one requested. + expect( + () => provider.loadToken('user-1'), + throwsA( + isArgumentError.having( + (it) => it.message, + 'message', + contains('Token type mismatch'), + ), + ), + ); }); test( From a64dbb0fd8dae2b58471f1cf3acee4555bf79356 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Wed, 19 Aug 2026 11:33:32 +0200 Subject: [PATCH 05/27] refactor(llc): make token provider mismatches assertable without their text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ordering test matched on the message prose, which means rewording the error breaks the test. Throw ArgumentError.value with a name instead — as UserToken already does — so a test can assert which check failed rather than how it was phrased. Co-Authored-By: Claude Opus 5 (1M context) --- .../lib/src/user/token_provider.dart | 18 ++++++++++++------ .../test/user/token_provider_test.dart | 8 +------- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/packages/stream_core/lib/src/user/token_provider.dart b/packages/stream_core/lib/src/user/token_provider.dart index 843fb399..aa28350d 100644 --- a/packages/stream_core/lib/src/user/token_provider.dart +++ b/packages/stream_core/lib/src/user/token_provider.dart @@ -74,8 +74,10 @@ class StaticTokenProvider implements TokenProvider { Future loadToken(String userId) async { // Validate that the token's user_id matches the requested userId if (_rawToken.userId != userId) { - throw ArgumentError( - 'User ID mismatch: expected "$userId", got "${_rawToken.userId}"', + throw ArgumentError.value( + _rawToken.userId, + 'userId', + 'User ID mismatch: expected "$userId"', ); } @@ -113,15 +115,19 @@ class DynamicTokenProvider implements TokenProvider { // token always carries `UserToken.anonymousUserId`, so it would otherwise // fail the user id check first. if (token.authType != AuthType.jwt) { - throw ArgumentError( - 'Token type mismatch: expected jwt, got ${token.authType.headerValue}', + throw ArgumentError.value( + token.authType.headerValue, + 'authType', + 'Token type mismatch: expected ${AuthType.jwt.headerValue}', ); } // Validate that the token's user_id matches the requested userId if (token.userId != userId) { - throw ArgumentError( - 'User ID mismatch: expected "$userId", got "${token.userId}"', + throw ArgumentError.value( + token.userId, + 'userId', + 'User ID mismatch: expected "$userId"', ); } diff --git a/packages/stream_core/test/user/token_provider_test.dart b/packages/stream_core/test/user/token_provider_test.dart index 36742cf5..700f40be 100644 --- a/packages/stream_core/test/user/token_provider_test.dart +++ b/packages/stream_core/test/user/token_provider_test.dart @@ -96,13 +96,7 @@ void main() { // also carries a user id that cannot match the one requested. expect( () => provider.loadToken('user-1'), - throwsA( - isArgumentError.having( - (it) => it.message, - 'message', - contains('Token type mismatch'), - ), - ), + throwsA(isArgumentError.having((it) => it.name, 'name', 'authType')), ); }); From 64a85e0aaa64cc21eae2ff14c3f169d5cac4687c Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Wed, 19 Aug 2026 11:35:50 +0200 Subject: [PATCH 06/27] refactor(llc): drop the redundant prefix from token mismatch messages `Invalid argument (authType)` already says what failed, so restating it as "Token type mismatch" left three colons in one line. Co-Authored-By: Claude Opus 5 (1M context) --- packages/stream_core/lib/src/user/token_provider.dart | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/stream_core/lib/src/user/token_provider.dart b/packages/stream_core/lib/src/user/token_provider.dart index aa28350d..814d0f05 100644 --- a/packages/stream_core/lib/src/user/token_provider.dart +++ b/packages/stream_core/lib/src/user/token_provider.dart @@ -77,7 +77,7 @@ class StaticTokenProvider implements TokenProvider { throw ArgumentError.value( _rawToken.userId, 'userId', - 'User ID mismatch: expected "$userId"', + 'Expected "$userId"', ); } @@ -118,7 +118,7 @@ class DynamicTokenProvider implements TokenProvider { throw ArgumentError.value( token.authType.headerValue, 'authType', - 'Token type mismatch: expected ${AuthType.jwt.headerValue}', + 'Expected ${AuthType.jwt.headerValue}', ); } @@ -127,7 +127,7 @@ class DynamicTokenProvider implements TokenProvider { throw ArgumentError.value( token.userId, 'userId', - 'User ID mismatch: expected "$userId"', + 'Expected "$userId"', ); } From 07db567bfbde0ec187d223f71c1d4348afc6e492 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Wed, 19 Aug 2026 11:37:34 +0200 Subject: [PATCH 07/27] test(llc): share one JWT builder across the token tests Three test files each defined their own, and two of them claimed alg HS256 while attaching a base64 blob that is not a signature. Adopt the alg=none builder stream_feeds_test already uses, which is an honest unsigned JWT, and expose both the raw string and the UserToken since both are needed. Co-Authored-By: Claude Opus 5 (1M context) --- .../interceptors/auth_interceptor_test.dart | 33 +++----- .../stream_core/test/helpers/user_token.dart | 25 +++++++ .../test/user/token_manager_test.dart | 75 ++++++++----------- .../test/user/token_provider_test.dart | 24 ++---- 4 files changed, 76 insertions(+), 81 deletions(-) create mode 100644 packages/stream_core/test/helpers/user_token.dart diff --git a/packages/stream_core/test/api/interceptors/auth_interceptor_test.dart b/packages/stream_core/test/api/interceptors/auth_interceptor_test.dart index 58051428..82df36f7 100644 --- a/packages/stream_core/test/api/interceptors/auth_interceptor_test.dart +++ b/packages/stream_core/test/api/interceptors/auth_interceptor_test.dart @@ -4,6 +4,8 @@ import 'dart:convert'; import 'package:stream_core/stream_core.dart'; import 'package:test/test.dart'; +import '../../helpers/user_token.dart'; + // A minimal HttpClientAdapter that captures the outgoing RequestOptions and // always responds with an empty successful response. class _CapturingHttpClientAdapter implements HttpClientAdapter { @@ -69,19 +71,6 @@ class _TokenExpiredHttpClientAdapter implements HttpClientAdapter { void close({bool force = false}) {} } -UserToken _generateTestUserToken(String userId) { - String b64UrlNoPad(Object jsonObj) { - final bytes = utf8.encode(jsonEncode(jsonObj)); - return base64Url.encode(bytes).replaceAll('=', ''); - } - - final header = {'alg': 'none', 'typ': 'JWT'}; - final payload = {'user_id': userId}; - - final jwt = '${b64UrlNoPad(header)}.${b64UrlNoPad(payload)}.'; - return UserToken(jwt); -} - void main() { group('AuthInterceptor', () { test( @@ -92,7 +81,7 @@ void main() { final tokenManager = TokenManager( userId: 'user-123', tokenProvider: TokenProvider.static( - _generateTestUserToken('user-123'), + generateTestUserToken('user-123'), ), ); @@ -137,7 +126,7 @@ void main() { tokenManager.setTokenProvider( serverId, - tokenProvider: TokenProvider.static(_generateTestUserToken(serverId)), + tokenProvider: TokenProvider.static(generateTestUserToken(serverId)), ); await dio.get('/test'); @@ -156,7 +145,7 @@ void main() { final tokenManager = TokenManager( userId: 'user-123', tokenProvider: TokenProvider.static( - _generateTestUserToken('user-123'), + generateTestUserToken('user-123'), ), ); @@ -202,7 +191,7 @@ void main() { test( 'sends a restricted anonymous token as the Authorization header', () async { - final restricted = _generateTestUserToken(UserToken.anonymousUserId); + final restricted = generateTestUserToken(UserToken.anonymousUserId); final tokenManager = TokenManager( userId: UserToken.anonymousUserId, tokenProvider: TokenProvider.static( @@ -245,12 +234,12 @@ void main() { await pumpEventQueue(); // The load is already running for user-1 when the manager moves on. - final userTwoToken = _generateTestUserToken('user-2'); + final userTwoToken = generateTestUserToken('user-2'); tokenManager.setTokenProvider( 'user-2', tokenProvider: TokenProvider.static(userTwoToken), ); - slowLoad.complete(_generateTestUserToken('user-1')); + slowLoad.complete(generateTestUserToken('user-1')); await pending; // The mismatch is deliberate: `user_id` describes who we believe we @@ -272,7 +261,7 @@ void main() { () async { final tokenManager = TokenManager( userId: 'guest-1', - tokenProvider: TokenProvider.static(_generateTestUserToken('guest-1')), + tokenProvider: TokenProvider.static(generateTestUserToken('guest-1')), ); final dio = Dio(BaseOptions(baseUrl: 'https://example.com')); @@ -303,7 +292,7 @@ void main() { var tokenManager = TokenManager( userId: 'requested-id', tokenProvider: TokenProvider.dynamic( - (_) async => _generateTestUserToken('requested-id'), + (_) async => generateTestUserToken('requested-id'), ), ); @@ -313,7 +302,7 @@ void main() { tokenManager = TokenManager( userId: 'server-assigned-id', tokenProvider: TokenProvider.static( - _generateTestUserToken('server-assigned-id'), + generateTestUserToken('server-assigned-id'), ), ); }, diff --git a/packages/stream_core/test/helpers/user_token.dart b/packages/stream_core/test/helpers/user_token.dart new file mode 100644 index 00000000..fb32afd6 --- /dev/null +++ b/packages/stream_core/test/helpers/user_token.dart @@ -0,0 +1,25 @@ +import 'dart:convert'; + +import 'package:stream_core/stream_core.dart'; + +/// Builds an unsigned JWT carrying [userId] as its 'user_id' claim. +/// +/// Sufficient for [UserToken]'s unverified parsing — nothing in these tests +/// checks a signature. +String generateTestJwt(String userId) { + String b64UrlNoPad(Object jsonObj) { + final bytes = utf8.encode(jsonEncode(jsonObj)); + return base64Url.encode(bytes).replaceAll('=', ''); + } + + final header = {'alg': 'none', 'typ': 'JWT'}; + final payload = {'user_id': userId}; + + // Trailing dot = empty signature, which is what alg=none means. + return '${b64UrlNoPad(header)}.${b64UrlNoPad(payload)}.'; +} + +/// Builds a JWT [UserToken] carrying [userId] as its 'user_id' claim. +UserToken generateTestUserToken(String userId) { + return UserToken(generateTestJwt(userId)); +} diff --git a/packages/stream_core/test/user/token_manager_test.dart b/packages/stream_core/test/user/token_manager_test.dart index c24379e2..f4e57afc 100644 --- a/packages/stream_core/test/user/token_manager_test.dart +++ b/packages/stream_core/test/user/token_manager_test.dart @@ -1,9 +1,10 @@ import 'dart:async'; -import 'dart:convert'; import 'package:stream_core/stream_core.dart'; import 'package:test/test.dart'; +import '../helpers/user_token.dart'; + /// A token provider that counts loads and delegates to a configurable loader. class _CountingProvider implements TokenProvider { _CountingProvider(this._load); @@ -20,21 +21,11 @@ class _CountingProvider implements TokenProvider { } } -/// Builds a JWT [UserToken] with the given [userId] claim, sufficient for -/// [UserToken]'s unverified parsing. -UserToken _token(String userId) { - String encode(Map json) => base64Url.encode(utf8.encode(jsonEncode(json))).replaceAll('=', ''); - final header = encode({'alg': 'HS256', 'typ': 'JWT'}); - final payload = encode({'user_id': userId}); - final signature = encode({'sig': 'fake'}); - return UserToken('$header.$payload.$signature'); -} - void main() { group('TokenManager', () { group('getToken', () { test('loads from the provider and caches the result', () async { - final provider = _CountingProvider((_) async => _token('token-1')); + final provider = _CountingProvider((_) async => generateTestUserToken('token-1')); final manager = TokenManager( userId: 'user-1', tokenProvider: provider, @@ -43,17 +34,17 @@ void main() { final first = await manager.getToken(); final second = await manager.getToken(); - expect(first, _token('token-1')); - expect(second, _token('token-1')); + expect(first, generateTestUserToken('token-1')); + expect(second, generateTestUserToken('token-1')); expect(provider.loadCount, 1); - expect(manager.peekToken(), _token('token-1')); + expect(manager.peekToken(), generateTestUserToken('token-1')); }); test('passes the manager userId to the provider', () async { String? requestedUserId; final provider = _CountingProvider((userId) async { requestedUserId = userId; - return _token('token-1'); + return generateTestUserToken('token-1'); }); final manager = TokenManager( userId: 'user-1', @@ -74,10 +65,10 @@ void main() { ); final futures = [manager.getToken(), manager.getToken()]; - completer.complete(_token('token-1')); + completer.complete(generateTestUserToken('token-1')); final tokens = await Future.wait(futures); - expect(tokens, everyElement(_token('token-1'))); + expect(tokens, everyElement(generateTestUserToken('token-1'))); expect(provider.loadCount, 1); }); @@ -86,7 +77,7 @@ void main() { final provider = _CountingProvider((_) async { attempts++; if (attempts == 1) throw StateError('load failed'); - return _token('token-2'); + return generateTestUserToken('token-2'); }); final manager = TokenManager( userId: 'user-1', @@ -97,7 +88,7 @@ void main() { expect(manager.peekToken(), isNull); final token = await manager.getToken(); - expect(token, _token('token-2')); + expect(token, generateTestUserToken('token-2')); expect(provider.loadCount, 2); }); }); @@ -105,18 +96,18 @@ void main() { group('expireToken', () { test('clears the cache and forces a reload', () async { var version = 0; - final provider = _CountingProvider((_) async => _token('v${++version}')); + final provider = _CountingProvider((_) async => generateTestUserToken('v${++version}')); final manager = TokenManager( userId: 'user-1', tokenProvider: provider, ); - expect(await manager.getToken(), _token('v1')); + expect(await manager.getToken(), generateTestUserToken('v1')); manager.expireToken(); expect(manager.peekToken(), isNull); - expect(await manager.getToken(), _token('v2')); + expect(await manager.getToken(), generateTestUserToken('v2')); expect(provider.loadCount, 2); }); @@ -133,7 +124,7 @@ void main() { final pending = manager.getToken(); manager.expireToken(); - slowLoad.complete(_token('user-1')); + slowLoad.complete(generateTestUserToken('user-1')); await pending; expect(manager.peekToken(), isNull); @@ -145,14 +136,14 @@ void main() { test('points the manager at another user', () async { final manager = TokenManager( userId: 'user-1', - tokenProvider: TokenProvider.static(_token('user-1')), + tokenProvider: TokenProvider.static(generateTestUserToken('user-1')), ); expect((await manager.getToken()).userId, 'user-1'); manager.setTokenProvider( 'user-2', - tokenProvider: TokenProvider.static(_token('user-2')), + tokenProvider: TokenProvider.static(generateTestUserToken('user-2')), ); expect(manager.userId, 'user-2'); @@ -162,15 +153,15 @@ void main() { test('expires the token cached for the previous user', () async { final manager = TokenManager( userId: 'user-1', - tokenProvider: TokenProvider.static(_token('user-1')), + tokenProvider: TokenProvider.static(generateTestUserToken('user-1')), ); await manager.getToken(); - expect(manager.peekToken(), _token('user-1')); + expect(manager.peekToken(), generateTestUserToken('user-1')); manager.setTokenProvider( 'user-2', - tokenProvider: TokenProvider.static(_token('user-2')), + tokenProvider: TokenProvider.static(generateTestUserToken('user-2')), ); expect(manager.peekToken(), isNull); @@ -193,7 +184,7 @@ void main() { manager.setTokenProvider( serverId, - tokenProvider: TokenProvider.static(_token(serverId)), + tokenProvider: TokenProvider.static(generateTestUserToken(serverId)), ); final guest = await manager.getToken(); @@ -214,9 +205,9 @@ void main() { manager.setTokenProvider( 'user-2', - tokenProvider: TokenProvider.static(_token('user-2')), + tokenProvider: TokenProvider.static(generateTestUserToken('user-2')), ); - slowLoad.complete(_token('user-1')); + slowLoad.complete(generateTestUserToken('user-1')); await pending; // user-1's token must not be waiting in the cache for user-2 to send. @@ -240,9 +231,9 @@ void main() { // replaced provider's token through. manager.setTokenProvider( 'user-1', - tokenProvider: TokenProvider.static(_token('user-1')), + tokenProvider: TokenProvider.static(generateTestUserToken('user-1')), ); - slowLoad.complete(_token('user-1')); + slowLoad.complete(generateTestUserToken('user-1')); await pending; expect(manager.peekToken(), isNull); @@ -252,14 +243,14 @@ void main() { test('usesStaticProvider reflects the new provider', () { final manager = TokenManager( userId: 'user-1', - tokenProvider: TokenProvider.static(_token('user-1')), + tokenProvider: TokenProvider.static(generateTestUserToken('user-1')), ); expect(manager.usesStaticProvider, isTrue); manager.setTokenProvider( 'user-1', - tokenProvider: _CountingProvider((_) async => _token('user-1')), + tokenProvider: _CountingProvider((_) async => generateTestUserToken('user-1')), ); expect(manager.usesStaticProvider, isFalse); @@ -270,11 +261,11 @@ void main() { test('reflects the provider type', () { final staticManager = TokenManager( userId: 'user-1', - tokenProvider: TokenProvider.static(_token('user-1')), + tokenProvider: TokenProvider.static(generateTestUserToken('user-1')), ); final dynamicManager = TokenManager( userId: 'user-1', - tokenProvider: _CountingProvider((_) async => _token('t')), + tokenProvider: _CountingProvider((_) async => generateTestUserToken('t')), ); expect(staticManager.usesStaticProvider, isTrue); @@ -286,7 +277,7 @@ void main() { test('fires once per load with the loaded token', () async { final updates = []; var version = 0; - final provider = _CountingProvider((_) async => _token('v${++version}')); + final provider = _CountingProvider((_) async => generateTestUserToken('v${++version}')); final manager = TokenManager( userId: 'user-1', tokenProvider: provider, @@ -299,14 +290,14 @@ void main() { manager.expireToken(); await manager.getToken(); - expect(updates, [_token('v1'), _token('v2')]); + expect(updates, [generateTestUserToken('v1'), generateTestUserToken('v2')]); }); test('is invoked before the token is returned', () async { UserToken? notified; final manager = TokenManager( userId: 'user-1', - tokenProvider: _CountingProvider((_) async => _token('token-1')), + tokenProvider: _CountingProvider((_) async => generateTestUserToken('token-1')), onTokenUpdated: (token) => notified = token, ); @@ -320,7 +311,7 @@ void main() { Future? reentrantCall; manager = TokenManager( userId: 'user-1', - tokenProvider: _CountingProvider((_) async => _token('token-1')), + tokenProvider: _CountingProvider((_) async => generateTestUserToken('token-1')), onTokenUpdated: (_) { reentrantCall = manager.getToken(); }, diff --git a/packages/stream_core/test/user/token_provider_test.dart b/packages/stream_core/test/user/token_provider_test.dart index 700f40be..809cf14a 100644 --- a/packages/stream_core/test/user/token_provider_test.dart +++ b/packages/stream_core/test/user/token_provider_test.dart @@ -1,17 +1,7 @@ -import 'dart:convert'; - import 'package:stream_core/stream_core.dart'; import 'package:test/test.dart'; -/// Builds an unsigned JWT with the given [userId] claim, sufficient for -/// [UserToken]'s unverified parsing. -String _fakeJwt(String userId) { - String encode(Map json) => base64Url.encode(utf8.encode(jsonEncode(json))).replaceAll('=', ''); - final header = encode({'alg': 'HS256', 'typ': 'JWT'}); - final payload = encode({'user_id': userId}); - final signature = encode({'sig': 'fake'}); - return '$header.$payload.$signature'; -} +import '../helpers/user_token.dart'; void main() { group('UserToken.anonymous', () { @@ -24,7 +14,7 @@ void main() { }); test('carries an optional raw value for restricted access', () { - final restricted = _fakeJwt(UserToken.anonymousUserId); + final restricted = generateTestJwt(UserToken.anonymousUserId); final token = UserToken.anonymous(rawValue: restricted); expect(token.userId, '!anon'); @@ -37,7 +27,7 @@ void main() { 'stand in for someone else', () { expect( - () => UserToken.anonymous(rawValue: _fakeJwt('alice')), + () => UserToken.anonymous(rawValue: generateTestJwt('alice')), throwsArgumentError, ); }, @@ -61,14 +51,14 @@ void main() { group('StaticTokenProvider', () { test('returns the token when the user ID matches', () async { - final token = UserToken(_fakeJwt('user-1')); + final token = generateTestUserToken('user-1'); final provider = TokenProvider.static(token); expect(await provider.loadToken('user-1'), token); }); test('throws when the user ID does not match', () { - final token = UserToken(_fakeJwt('user-1')); + final token = generateTestUserToken('user-1'); final provider = TokenProvider.static(token); expect(() => provider.loadToken('user-2'), throwsArgumentError); @@ -78,7 +68,7 @@ void main() { group('DynamicTokenProvider', () { test('returns JWT tokens from the loader', () async { final provider = TokenProvider.dynamic( - (userId) async => UserToken(_fakeJwt(userId)), + (userId) async => generateTestUserToken(userId), ); final token = await provider.loadToken('user-1'); @@ -105,7 +95,7 @@ void main() { 'otherwise authenticate every later request as that user', () { final provider = TokenProvider.dynamic( - (_) async => UserToken(_fakeJwt('someone-else')), + (_) async => generateTestUserToken('someone-else'), ); expect(() => provider.loadToken('user-1'), throwsArgumentError); From c91b3affb68cfbd6266529cf32192bd837a89977 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Wed, 19 Aug 2026 11:43:28 +0200 Subject: [PATCH 08/27] refactor(llc)!: remove AuthInterceptor.withProvider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It existed so callers could swap in a whole new TokenManager once a guest exchange resolved its user id. `setTokenProvider` does that on the manager itself, so the indirection buys nothing and leaves two ways to do one thing. The interceptor file reverts to its pre-#128 state exactly. Never shipped — #128 added it in this same unreleased cycle — so its changelog entry is dropped rather than recorded as a breaking change. Also from review: document the FormatException that `UserToken`'s factories can throw, note that `setTokenProvider` discards an in-flight load, and fix a test comment that restated a guarantee the file's own test contradicts. Co-Authored-By: Claude Opus 5 (1M context) --- packages/stream_core/CHANGELOG.md | 1 - .../api/interceptors/auth_interceptor.dart | 51 +++---------------- .../lib/src/user/token_manager.dart | 9 ++-- .../stream_core/lib/src/user/user_token.dart | 8 +-- .../interceptors/auth_interceptor_test.dart | 46 +++++------------ 5 files changed, 29 insertions(+), 86 deletions(-) diff --git a/packages/stream_core/CHANGELOG.md b/packages/stream_core/CHANGELOG.md index 4f5fe5c0..de8f5ea1 100644 --- a/packages/stream_core/CHANGELOG.md +++ b/packages/stream_core/CHANGELOG.md @@ -11,7 +11,6 @@ - Added optional `onTokenUpdated` callback to `TokenManager`, invoked after every successful token load - Added optional `rawValue` to `UserToken.anonymous`, so an anonymous token can carry a JWT granting restricted access, provided its `user_id` claim is `UserToken.anonymousUserId` - Added `UserToken.anonymousUserId`, the user id used for anonymous authentication -- Added `AuthInterceptor.withProvider`, which takes a `TokenManager Function()` getter instead of a fixed instance - Added `teams` field to `User` class ### 🐛 Bug Fixes diff --git a/packages/stream_core/lib/src/api/interceptors/auth_interceptor.dart b/packages/stream_core/lib/src/api/interceptors/auth_interceptor.dart index 863c5ae6..ea7a0ed3 100644 --- a/packages/stream_core/lib/src/api/interceptors/auth_interceptor.dart +++ b/packages/stream_core/lib/src/api/interceptors/auth_interceptor.dart @@ -4,48 +4,16 @@ import '../../errors.dart'; import '../../user.dart'; import '../stream_core_dio_error.dart'; -/// Provides the [TokenManager] currently in use by an [AuthInterceptor]. -/// -/// A getter rather than a fixed reference so the caller can swap the underlying -/// [TokenManager] at runtime — e.g. after a guest token exchange resolves a -/// server-assigned user id — and have the interceptor pick up the new instance. -typedef TokenManagerProvider = TokenManager Function(); - /// Authentication interceptor that refreshes the token if /// an auth error is received class AuthInterceptor extends QueuedInterceptor { - /// Initialize a new auth interceptor backed by a fixed [tokenManager]. - /// - /// Use this when the [TokenManager] never changes for the lifetime of the - /// interceptor. If you need to swap the manager at runtime — e.g. after a - /// guest token exchange resolves a server-assigned user id — use - /// [AuthInterceptor.withProvider] instead. - AuthInterceptor( - this._dio, - TokenManager tokenManager, - ) : _tokenManager = tokenManager, - _tokenManagerProvider = null; - - /// Initialize a new auth interceptor backed by a [_tokenManagerProvider]. - /// - /// The provider is a getter rather than a fixed reference so the caller can - /// swap the underlying [TokenManager] — e.g. after a guest token exchange - /// resolves a server-assigned user id — and have this interceptor pick up - /// the new instance on its next request. - AuthInterceptor.withProvider( - this._dio, { - required TokenManagerProvider this._tokenManagerProvider, - }) : _tokenManager = null; + /// Initialize a new auth interceptor + AuthInterceptor(this._dio, this._tokenManager); final Dio _dio; - final TokenManager? _tokenManager; - - /// Provides the token manager currently in use. - final TokenManagerProvider? _tokenManagerProvider; - - /// The token manager currently in use. - TokenManager get _effectiveTokenManager => _tokenManager ?? _tokenManagerProvider!.call(); + /// The token manager used in the client + final TokenManager _tokenManager; @override Future onRequest( @@ -53,11 +21,9 @@ class AuthInterceptor extends QueuedInterceptor { RequestInterceptorHandler handler, ) async { try { - final token = await _effectiveTokenManager.getToken(); + final token = await _tokenManager.getToken(); - // Read from the manager rather than the token, so a token that belongs - // to someone else is rejected instead of silently accepted. - options.queryParameters['user_id'] = _effectiveTokenManager.userId; + options.queryParameters['user_id'] = _tokenManager.userId; options.headers['Authorization'] = token.rawValue; options.headers['stream-auth-type'] = token.authType.headerValue; @@ -91,11 +57,10 @@ class AuthInterceptor extends QueuedInterceptor { final error = StreamApiError.fromJson(data); if (error.isTokenExpiredError) { - final tokenManager = _effectiveTokenManager; // Don't try to refresh the token if we're using a static provider - if (tokenManager.usesStaticProvider) return handler.next(err); + if (_tokenManager.usesStaticProvider) return handler.next(err); // Otherwise, mark the current token as expired. - tokenManager.expireToken(); + _tokenManager.expireToken(); try { final options = err.requestOptions; diff --git a/packages/stream_core/lib/src/user/token_manager.dart b/packages/stream_core/lib/src/user/token_manager.dart index dbf67c3a..dfecb0b8 100644 --- a/packages/stream_core/lib/src/user/token_manager.dart +++ b/packages/stream_core/lib/src/user/token_manager.dart @@ -62,9 +62,10 @@ class TokenManager { /// Points this manager at `userId`, loading its tokens from `tokenProvider`. /// - /// The user and the provider change together, so the manager can never report - /// one user while holding another's token. Expires the cached token, so the - /// next [getToken] call loads a fresh one for the new user. + /// The user and the provider change together, so the manager can never cache + /// one user's token under another. Expires the cached token, and discards a + /// load already in flight, so the next [getToken] call loads a fresh one for + /// the new user. /// /// Use this to reuse a manager across users, and when a user's identity is /// only known after an authenticated request — a guest, whose id and token @@ -92,7 +93,7 @@ class TokenManager { // The cached token belongs to the previous user and provider, so drop it // and let the next `getToken` call load a fresh one. - return expireToken(); + expireToken(); } // The currently cached token, if any. diff --git a/packages/stream_core/lib/src/user/user_token.dart b/packages/stream_core/lib/src/user/user_token.dart index a4a0621a..1337c10e 100644 --- a/packages/stream_core/lib/src/user/user_token.dart +++ b/packages/stream_core/lib/src/user/user_token.dart @@ -36,8 +36,8 @@ class UserToken extends Equatable { /// /// Returns a [UserToken] configured for JWT authentication. /// - /// Throws an [ArgumentError] if the [rawValue] is not a valid JWT token - /// or if the 'user_id' claim is missing or empty. + /// Throws an [ArgumentError] if the 'user_id' claim is missing or empty, and + /// a [FormatException] if [rawValue] cannot be parsed as a JWT. factory UserToken(String rawValue) { final jwtBody = JsonWebToken.unverified(rawValue); final userId = jwtBody.claims.getTyped('user_id'); @@ -76,9 +76,9 @@ class UserToken extends Equatable { final claim = jwtBody.claims.getTyped('user_id'); if (claim != anonymousUserId) { throw ArgumentError.value( - rawValue, + claim, 'rawValue', - 'Invalid anonymous JWT token: user_id claim must be "$anonymousUserId", got "$claim"', + 'Expected a JWT claiming user_id "$anonymousUserId"', ); } } diff --git a/packages/stream_core/test/api/interceptors/auth_interceptor_test.dart b/packages/stream_core/test/api/interceptors/auth_interceptor_test.dart index 82df36f7..1cc064f6 100644 --- a/packages/stream_core/test/api/interceptors/auth_interceptor_test.dart +++ b/packages/stream_core/test/api/interceptors/auth_interceptor_test.dart @@ -109,9 +109,8 @@ void main() { 'pointed at it', () async { // Simulates the guest flow: the exchange is authenticated anonymously, - // then the manager is pointed at the id the exchange returned. The user - // id and the token change together, so the `user_id` query parameter - // always describes the token in the `Authorization` header. + // then the manager is pointed at the id the exchange returned before + // the next request goes out, so nothing is in flight across the swap. const serverId = 'server-assigned-id'; final tokenManager = TokenManager( @@ -139,27 +138,6 @@ void main() { }, ); - test( - 'uses the current TokenManager userId when nothing swaps it', - () async { - final tokenManager = TokenManager( - userId: 'user-123', - tokenProvider: TokenProvider.static( - generateTestUserToken('user-123'), - ), - ); - - final dio = Dio(BaseOptions(baseUrl: 'https://example.com')); - final adapter = _CapturingHttpClientAdapter(); - dio.httpClientAdapter = adapter; - dio.interceptors.add(AuthInterceptor.withProvider(dio, tokenManagerProvider: () => tokenManager)); - - await dio.get('/test'); - - expect(adapter.lastRequest?.queryParameters['user_id'], 'user-123'); - }, - ); - test( 'sends an anonymous token as an empty Authorization header with the ' 'anonymous auth type', @@ -267,7 +245,7 @@ void main() { final dio = Dio(BaseOptions(baseUrl: 'https://example.com')); final adapter = _TokenExpiredHttpClientAdapter(); dio.httpClientAdapter = adapter; - dio.interceptors.add(AuthInterceptor.withProvider(dio, tokenManagerProvider: () => tokenManager)); + dio.interceptors.add(AuthInterceptor(dio, tokenManager)); await expectLater( dio.get('/test'), @@ -282,14 +260,14 @@ void main() { test( 'forwards a token-expired error without retrying when the token manager ' - 'is swapped to a static provider after the request was dispatched ' + 'is pointed at a static provider after the request was dispatched ' '(guest exchange resolving mid-flight)', () async { - // Starts on a dynamic manager and swaps to a static one carrying the + // Starts on a dynamic provider and adopts a static one carrying the // exchanged id once the request is already in flight, mirroring the - // guest flow. onError observes the swapped-in (static) manager and must - // forward the error rather than expire + retry. - var tokenManager = TokenManager( + // guest flow. onError sees the static provider and must forward the + // error rather than expire + retry. + final tokenManager = TokenManager( userId: 'requested-id', tokenProvider: TokenProvider.dynamic( (_) async => generateTestUserToken('requested-id'), @@ -299,8 +277,8 @@ void main() { final dio = Dio(BaseOptions(baseUrl: 'https://example.com')); final adapter = _TokenExpiredHttpClientAdapter( onFetch: () { - tokenManager = TokenManager( - userId: 'server-assigned-id', + tokenManager.setTokenProvider( + 'server-assigned-id', tokenProvider: TokenProvider.static( generateTestUserToken('server-assigned-id'), ), @@ -308,14 +286,14 @@ void main() { }, ); dio.httpClientAdapter = adapter; - dio.interceptors.add(AuthInterceptor.withProvider(dio, tokenManagerProvider: () => tokenManager)); + dio.interceptors.add(AuthInterceptor(dio, tokenManager)); await expectLater( dio.get('/test'), throwsA(isA()), ); - // The swapped-in manager is static, so the error is surfaced without a + // The adopted provider is static, so the error is surfaced without a // refresh-and-retry: the request is attempted exactly once. expect(adapter.requestCount, 1); }, From 0d8822630b53e2583078ac64a4e60c1686cb156d Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Wed, 19 Aug 2026 11:54:47 +0200 Subject: [PATCH 09/27] style(llc): align with STYLE_GUIDE and TESTING conventions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use `### 🛑 Breaking / Removals`; the guide lists `### 💥 BREAKING CHANGES` as grandfathered, for existing entries only - Shorten test names to the behaviour and move the rationale into the body, per TESTING.md — a name should be scannable in the runner output - Drop "positional constructor / backwards-compatible API" from a test name; with `withProvider` gone there is only one constructor - Recommend rather than instruct in `setTokenProvider`'s dartdoc, and trim two inline comments to the why Pre-existing and deliberately left: the nested `group('TokenManager')` > `group('getToken')` layout, which the guide would rather see split into files. Co-Authored-By: Claude Opus 5 (1M context) --- packages/stream_core/CHANGELOG.md | 2 +- .../stream_core/lib/src/user/token_manager.dart | 11 +++++------ .../stream_core/lib/src/user/token_provider.dart | 6 ++---- .../api/interceptors/auth_interceptor_test.dart | 16 ++++++---------- .../test/user/token_manager_test.dart | 9 +++------ .../test/user/token_provider_test.dart | 8 ++++---- 6 files changed, 21 insertions(+), 31 deletions(-) diff --git a/packages/stream_core/CHANGELOG.md b/packages/stream_core/CHANGELOG.md index de8f5ea1..481bd544 100644 --- a/packages/stream_core/CHANGELOG.md +++ b/packages/stream_core/CHANGELOG.md @@ -1,6 +1,6 @@ ## Upcoming -### 💥 BREAKING CHANGES +### 🛑 Breaking / Removals - Removed the `userId` parameter from `UserToken.anonymous`, anonymous tokens always use `UserToken.anonymousUserId` - Removed the `TokenManager.tokenProvider` setter, use `setTokenProvider` instead diff --git a/packages/stream_core/lib/src/user/token_manager.dart b/packages/stream_core/lib/src/user/token_manager.dart index dfecb0b8..a06c9e3a 100644 --- a/packages/stream_core/lib/src/user/token_manager.dart +++ b/packages/stream_core/lib/src/user/token_manager.dart @@ -67,9 +67,9 @@ class TokenManager { /// load already in flight, so the next [getToken] call loads a fresh one for /// the new user. /// - /// Use this to reuse a manager across users, and when a user's identity is - /// only known after an authenticated request — a guest, whose id and token - /// are both issued in exchange for an anonymous one: + /// To reuse a manager across users, or to authenticate as a user whose + /// identity is only known after an authenticated request — a guest, whose id + /// and token are both issued in exchange for an anonymous one — consider: /// /// ```dart /// // Authenticate anonymously while the real identity is being obtained. @@ -142,9 +142,8 @@ class TokenManager { final loadingGeneration = _generation; final updatedToken = await _tokenProvider.loadToken(loadingFor); - // Only cache the token if nothing invalidated the cache while it loaded. - // `setTokenProvider` or `expireToken` may have run, which means this token - // is the one the caller asked us to stop using. + // `setTokenProvider` or `expireToken` may have run while this loaded, in + // which case the token is the one the caller asked to stop using. if (loadingGeneration != _generation) return updatedToken; _cachedToken = updatedToken; diff --git a/packages/stream_core/lib/src/user/token_provider.dart b/packages/stream_core/lib/src/user/token_provider.dart index 814d0f05..8525412e 100644 --- a/packages/stream_core/lib/src/user/token_provider.dart +++ b/packages/stream_core/lib/src/user/token_provider.dart @@ -110,10 +110,8 @@ class DynamicTokenProvider implements TokenProvider { Future loadToken(String userId) async { final token = await _loader.call(userId); - // Validate the type before the user id, so a non-JWT token is reported as - // the wrong type rather than as belonging to the wrong user: an anonymous - // token always carries `UserToken.anonymousUserId`, so it would otherwise - // fail the user id check first. + // Checked before the user id: an anonymous token carries a user id that can + // never match, so it would otherwise be reported as the wrong user. if (token.authType != AuthType.jwt) { throw ArgumentError.value( token.authType.headerValue, diff --git a/packages/stream_core/test/api/interceptors/auth_interceptor_test.dart b/packages/stream_core/test/api/interceptors/auth_interceptor_test.dart index 1cc064f6..5dece376 100644 --- a/packages/stream_core/test/api/interceptors/auth_interceptor_test.dart +++ b/packages/stream_core/test/api/interceptors/auth_interceptor_test.dart @@ -74,9 +74,8 @@ class _TokenExpiredHttpClientAdapter implements HttpClientAdapter { void main() { group('AuthInterceptor', () { test( - 'uses the TokenManager passed to the positional constructor, setting the ' - 'Authorization header and user_id query parameter (backwards-compatible ' - 'API)', + 'sets the Authorization header, the auth type, and the user_id query ' + 'parameter', () async { final tokenManager = TokenManager( userId: 'user-123', @@ -193,10 +192,11 @@ void main() { ); test( - 'sends the token manager user id, not the loaded token user id, so a ' - 'manager pointed at another user mid-load is rejected rather than ' - 'silently authenticated as whoever the token belongs to', + 'sends the token manager user id, not the loaded token user id', () async { + // The mismatch is deliberate: a request carrying someone else's token + // is rejected, where deriving `user_id` from the token would make it + // self-consistent and silently act as the token's owner. final slowLoad = Completer(); final tokenManager = TokenManager( userId: 'user-1', @@ -220,10 +220,6 @@ void main() { slowLoad.complete(generateTestUserToken('user-1')); await pending; - // The mismatch is deliberate: `user_id` describes who we believe we - // are, so a request carrying someone else's token is rejected and the - // divergence surfaces. Deriving `user_id` from the token instead would - // make the request self-consistent and silently act as that user. expect(adapter.lastRequest?.queryParameters['user_id'], 'user-2'); expect( adapter.lastRequest?.headers['Authorization'], diff --git a/packages/stream_core/test/user/token_manager_test.dart b/packages/stream_core/test/user/token_manager_test.dart index f4e57afc..80197c2b 100644 --- a/packages/stream_core/test/user/token_manager_test.dart +++ b/packages/stream_core/test/user/token_manager_test.dart @@ -112,8 +112,7 @@ void main() { }); test( - 'discards a load in flight, rather than caching the token it was told ' - 'to stop using', + 'discards a load in flight', () async { final slowLoad = Completer(); final manager = TokenManager( @@ -168,8 +167,7 @@ void main() { }); test( - 'supports a guest exchange, which is authenticated anonymously before ' - 'its user id and token are known', + 'adopts a user id and token that were not known up front', () async { const serverId = 'guest-abc-guest-123'; @@ -216,8 +214,7 @@ void main() { }); test( - 'discards a load in flight when only the provider changes, so the ' - 'replaced provider cannot cache its token for the same user', + 'discards a load in flight when only the provider changes', () async { final slowLoad = Completer(); final manager = TokenManager( diff --git a/packages/stream_core/test/user/token_provider_test.dart b/packages/stream_core/test/user/token_provider_test.dart index 809cf14a..fed37963 100644 --- a/packages/stream_core/test/user/token_provider_test.dart +++ b/packages/stream_core/test/user/token_provider_test.dart @@ -23,9 +23,9 @@ void main() { }); test( - 'rejects a raw value claiming a real user, so an anonymous token cannot ' - 'stand in for someone else', + 'rejects a raw value claiming a real user', () { + // An anonymous token must not be able to stand in for someone else. expect( () => UserToken.anonymous(rawValue: generateTestJwt('alice')), throwsArgumentError, @@ -91,9 +91,9 @@ void main() { }); test( - 'throws when the loader returns a token for a different user, which would ' - 'otherwise authenticate every later request as that user', + 'throws when the loader returns a token for a different user', () { + // Caching it would authenticate every later request as that user. final provider = TokenProvider.dynamic( (_) async => generateTestUserToken('someone-else'), ); From b9d19281256e451e98bd853438df65bde9342a28 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Wed, 19 Aug 2026 11:58:28 +0200 Subject: [PATCH 10/27] test(llc): keep the JWT builder local to each test file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit STYLE_GUIDE asks tests to embrace duplication and stay self-contained, and `test/helpers/` had no precedent in the repo — those three imports were the only cross-test-file imports that existed. Each file carries its own builder again, all three now the honest alg=none one rather than the two that claimed HS256 over a fake signature. token_provider_test keeps a string variant since it feeds `UserToken.anonymous(rawValue:)` directly. Also keep `### 💥 BREAKING CHANGES`, the form already used three times in this changelog. Co-Authored-By: Claude Opus 5 (1M context) --- packages/stream_core/CHANGELOG.md | 2 +- .../interceptors/auth_interceptor_test.dart | 32 +++++--- .../stream_core/test/helpers/user_token.dart | 25 ------ .../test/user/token_manager_test.dart | 79 +++++++++++-------- .../test/user/token_provider_test.dart | 31 ++++++-- 5 files changed, 93 insertions(+), 76 deletions(-) delete mode 100644 packages/stream_core/test/helpers/user_token.dart diff --git a/packages/stream_core/CHANGELOG.md b/packages/stream_core/CHANGELOG.md index 481bd544..de8f5ea1 100644 --- a/packages/stream_core/CHANGELOG.md +++ b/packages/stream_core/CHANGELOG.md @@ -1,6 +1,6 @@ ## Upcoming -### 🛑 Breaking / Removals +### 💥 BREAKING CHANGES - Removed the `userId` parameter from `UserToken.anonymous`, anonymous tokens always use `UserToken.anonymousUserId` - Removed the `TokenManager.tokenProvider` setter, use `setTokenProvider` instead diff --git a/packages/stream_core/test/api/interceptors/auth_interceptor_test.dart b/packages/stream_core/test/api/interceptors/auth_interceptor_test.dart index 5dece376..36715163 100644 --- a/packages/stream_core/test/api/interceptors/auth_interceptor_test.dart +++ b/packages/stream_core/test/api/interceptors/auth_interceptor_test.dart @@ -4,8 +4,6 @@ import 'dart:convert'; import 'package:stream_core/stream_core.dart'; import 'package:test/test.dart'; -import '../../helpers/user_token.dart'; - // A minimal HttpClientAdapter that captures the outgoing RequestOptions and // always responds with an empty successful response. class _CapturingHttpClientAdapter implements HttpClientAdapter { @@ -71,6 +69,20 @@ class _TokenExpiredHttpClientAdapter implements HttpClientAdapter { void close({bool force = false}) {} } +/// Builds a JWT [UserToken] carrying [userId] as its 'user_id' claim. +UserToken _generateTestUserToken(String userId) { + String b64UrlNoPad(Object jsonObj) { + final bytes = utf8.encode(jsonEncode(jsonObj)); + return base64Url.encode(bytes).replaceAll('=', ''); + } + + final header = {'alg': 'none', 'typ': 'JWT'}; + final payload = {'user_id': userId}; + + // Trailing dot = empty signature, which is what alg=none means. + return UserToken('${b64UrlNoPad(header)}.${b64UrlNoPad(payload)}.'); +} + void main() { group('AuthInterceptor', () { test( @@ -80,7 +92,7 @@ void main() { final tokenManager = TokenManager( userId: 'user-123', tokenProvider: TokenProvider.static( - generateTestUserToken('user-123'), + _generateTestUserToken('user-123'), ), ); @@ -124,7 +136,7 @@ void main() { tokenManager.setTokenProvider( serverId, - tokenProvider: TokenProvider.static(generateTestUserToken(serverId)), + tokenProvider: TokenProvider.static(_generateTestUserToken(serverId)), ); await dio.get('/test'); @@ -168,7 +180,7 @@ void main() { test( 'sends a restricted anonymous token as the Authorization header', () async { - final restricted = generateTestUserToken(UserToken.anonymousUserId); + final restricted = _generateTestUserToken(UserToken.anonymousUserId); final tokenManager = TokenManager( userId: UserToken.anonymousUserId, tokenProvider: TokenProvider.static( @@ -212,12 +224,12 @@ void main() { await pumpEventQueue(); // The load is already running for user-1 when the manager moves on. - final userTwoToken = generateTestUserToken('user-2'); + final userTwoToken = _generateTestUserToken('user-2'); tokenManager.setTokenProvider( 'user-2', tokenProvider: TokenProvider.static(userTwoToken), ); - slowLoad.complete(generateTestUserToken('user-1')); + slowLoad.complete(_generateTestUserToken('user-1')); await pending; expect(adapter.lastRequest?.queryParameters['user_id'], 'user-2'); @@ -235,7 +247,7 @@ void main() { () async { final tokenManager = TokenManager( userId: 'guest-1', - tokenProvider: TokenProvider.static(generateTestUserToken('guest-1')), + tokenProvider: TokenProvider.static(_generateTestUserToken('guest-1')), ); final dio = Dio(BaseOptions(baseUrl: 'https://example.com')); @@ -266,7 +278,7 @@ void main() { final tokenManager = TokenManager( userId: 'requested-id', tokenProvider: TokenProvider.dynamic( - (_) async => generateTestUserToken('requested-id'), + (_) async => _generateTestUserToken('requested-id'), ), ); @@ -276,7 +288,7 @@ void main() { tokenManager.setTokenProvider( 'server-assigned-id', tokenProvider: TokenProvider.static( - generateTestUserToken('server-assigned-id'), + _generateTestUserToken('server-assigned-id'), ), ); }, diff --git a/packages/stream_core/test/helpers/user_token.dart b/packages/stream_core/test/helpers/user_token.dart deleted file mode 100644 index fb32afd6..00000000 --- a/packages/stream_core/test/helpers/user_token.dart +++ /dev/null @@ -1,25 +0,0 @@ -import 'dart:convert'; - -import 'package:stream_core/stream_core.dart'; - -/// Builds an unsigned JWT carrying [userId] as its 'user_id' claim. -/// -/// Sufficient for [UserToken]'s unverified parsing — nothing in these tests -/// checks a signature. -String generateTestJwt(String userId) { - String b64UrlNoPad(Object jsonObj) { - final bytes = utf8.encode(jsonEncode(jsonObj)); - return base64Url.encode(bytes).replaceAll('=', ''); - } - - final header = {'alg': 'none', 'typ': 'JWT'}; - final payload = {'user_id': userId}; - - // Trailing dot = empty signature, which is what alg=none means. - return '${b64UrlNoPad(header)}.${b64UrlNoPad(payload)}.'; -} - -/// Builds a JWT [UserToken] carrying [userId] as its 'user_id' claim. -UserToken generateTestUserToken(String userId) { - return UserToken(generateTestJwt(userId)); -} diff --git a/packages/stream_core/test/user/token_manager_test.dart b/packages/stream_core/test/user/token_manager_test.dart index 80197c2b..d3058b9c 100644 --- a/packages/stream_core/test/user/token_manager_test.dart +++ b/packages/stream_core/test/user/token_manager_test.dart @@ -1,10 +1,9 @@ import 'dart:async'; +import 'dart:convert'; import 'package:stream_core/stream_core.dart'; import 'package:test/test.dart'; -import '../helpers/user_token.dart'; - /// A token provider that counts loads and delegates to a configurable loader. class _CountingProvider implements TokenProvider { _CountingProvider(this._load); @@ -21,11 +20,25 @@ class _CountingProvider implements TokenProvider { } } +/// Builds a JWT [UserToken] carrying [userId] as its 'user_id' claim. +UserToken _generateTestUserToken(String userId) { + String b64UrlNoPad(Object jsonObj) { + final bytes = utf8.encode(jsonEncode(jsonObj)); + return base64Url.encode(bytes).replaceAll('=', ''); + } + + final header = {'alg': 'none', 'typ': 'JWT'}; + final payload = {'user_id': userId}; + + // Trailing dot = empty signature, which is what alg=none means. + return UserToken('${b64UrlNoPad(header)}.${b64UrlNoPad(payload)}.'); +} + void main() { group('TokenManager', () { group('getToken', () { test('loads from the provider and caches the result', () async { - final provider = _CountingProvider((_) async => generateTestUserToken('token-1')); + final provider = _CountingProvider((_) async => _generateTestUserToken('token-1')); final manager = TokenManager( userId: 'user-1', tokenProvider: provider, @@ -34,17 +47,17 @@ void main() { final first = await manager.getToken(); final second = await manager.getToken(); - expect(first, generateTestUserToken('token-1')); - expect(second, generateTestUserToken('token-1')); + expect(first, _generateTestUserToken('token-1')); + expect(second, _generateTestUserToken('token-1')); expect(provider.loadCount, 1); - expect(manager.peekToken(), generateTestUserToken('token-1')); + expect(manager.peekToken(), _generateTestUserToken('token-1')); }); test('passes the manager userId to the provider', () async { String? requestedUserId; final provider = _CountingProvider((userId) async { requestedUserId = userId; - return generateTestUserToken('token-1'); + return _generateTestUserToken('token-1'); }); final manager = TokenManager( userId: 'user-1', @@ -65,10 +78,10 @@ void main() { ); final futures = [manager.getToken(), manager.getToken()]; - completer.complete(generateTestUserToken('token-1')); + completer.complete(_generateTestUserToken('token-1')); final tokens = await Future.wait(futures); - expect(tokens, everyElement(generateTestUserToken('token-1'))); + expect(tokens, everyElement(_generateTestUserToken('token-1'))); expect(provider.loadCount, 1); }); @@ -77,7 +90,7 @@ void main() { final provider = _CountingProvider((_) async { attempts++; if (attempts == 1) throw StateError('load failed'); - return generateTestUserToken('token-2'); + return _generateTestUserToken('token-2'); }); final manager = TokenManager( userId: 'user-1', @@ -88,7 +101,7 @@ void main() { expect(manager.peekToken(), isNull); final token = await manager.getToken(); - expect(token, generateTestUserToken('token-2')); + expect(token, _generateTestUserToken('token-2')); expect(provider.loadCount, 2); }); }); @@ -96,18 +109,18 @@ void main() { group('expireToken', () { test('clears the cache and forces a reload', () async { var version = 0; - final provider = _CountingProvider((_) async => generateTestUserToken('v${++version}')); + final provider = _CountingProvider((_) async => _generateTestUserToken('v${++version}')); final manager = TokenManager( userId: 'user-1', tokenProvider: provider, ); - expect(await manager.getToken(), generateTestUserToken('v1')); + expect(await manager.getToken(), _generateTestUserToken('v1')); manager.expireToken(); expect(manager.peekToken(), isNull); - expect(await manager.getToken(), generateTestUserToken('v2')); + expect(await manager.getToken(), _generateTestUserToken('v2')); expect(provider.loadCount, 2); }); @@ -123,7 +136,7 @@ void main() { final pending = manager.getToken(); manager.expireToken(); - slowLoad.complete(generateTestUserToken('user-1')); + slowLoad.complete(_generateTestUserToken('user-1')); await pending; expect(manager.peekToken(), isNull); @@ -135,14 +148,14 @@ void main() { test('points the manager at another user', () async { final manager = TokenManager( userId: 'user-1', - tokenProvider: TokenProvider.static(generateTestUserToken('user-1')), + tokenProvider: TokenProvider.static(_generateTestUserToken('user-1')), ); expect((await manager.getToken()).userId, 'user-1'); manager.setTokenProvider( 'user-2', - tokenProvider: TokenProvider.static(generateTestUserToken('user-2')), + tokenProvider: TokenProvider.static(_generateTestUserToken('user-2')), ); expect(manager.userId, 'user-2'); @@ -152,15 +165,15 @@ void main() { test('expires the token cached for the previous user', () async { final manager = TokenManager( userId: 'user-1', - tokenProvider: TokenProvider.static(generateTestUserToken('user-1')), + tokenProvider: TokenProvider.static(_generateTestUserToken('user-1')), ); await manager.getToken(); - expect(manager.peekToken(), generateTestUserToken('user-1')); + expect(manager.peekToken(), _generateTestUserToken('user-1')); manager.setTokenProvider( 'user-2', - tokenProvider: TokenProvider.static(generateTestUserToken('user-2')), + tokenProvider: TokenProvider.static(_generateTestUserToken('user-2')), ); expect(manager.peekToken(), isNull); @@ -182,7 +195,7 @@ void main() { manager.setTokenProvider( serverId, - tokenProvider: TokenProvider.static(generateTestUserToken(serverId)), + tokenProvider: TokenProvider.static(_generateTestUserToken(serverId)), ); final guest = await manager.getToken(); @@ -203,9 +216,9 @@ void main() { manager.setTokenProvider( 'user-2', - tokenProvider: TokenProvider.static(generateTestUserToken('user-2')), + tokenProvider: TokenProvider.static(_generateTestUserToken('user-2')), ); - slowLoad.complete(generateTestUserToken('user-1')); + slowLoad.complete(_generateTestUserToken('user-1')); await pending; // user-1's token must not be waiting in the cache for user-2 to send. @@ -228,9 +241,9 @@ void main() { // replaced provider's token through. manager.setTokenProvider( 'user-1', - tokenProvider: TokenProvider.static(generateTestUserToken('user-1')), + tokenProvider: TokenProvider.static(_generateTestUserToken('user-1')), ); - slowLoad.complete(generateTestUserToken('user-1')); + slowLoad.complete(_generateTestUserToken('user-1')); await pending; expect(manager.peekToken(), isNull); @@ -240,14 +253,14 @@ void main() { test('usesStaticProvider reflects the new provider', () { final manager = TokenManager( userId: 'user-1', - tokenProvider: TokenProvider.static(generateTestUserToken('user-1')), + tokenProvider: TokenProvider.static(_generateTestUserToken('user-1')), ); expect(manager.usesStaticProvider, isTrue); manager.setTokenProvider( 'user-1', - tokenProvider: _CountingProvider((_) async => generateTestUserToken('user-1')), + tokenProvider: _CountingProvider((_) async => _generateTestUserToken('user-1')), ); expect(manager.usesStaticProvider, isFalse); @@ -258,11 +271,11 @@ void main() { test('reflects the provider type', () { final staticManager = TokenManager( userId: 'user-1', - tokenProvider: TokenProvider.static(generateTestUserToken('user-1')), + tokenProvider: TokenProvider.static(_generateTestUserToken('user-1')), ); final dynamicManager = TokenManager( userId: 'user-1', - tokenProvider: _CountingProvider((_) async => generateTestUserToken('t')), + tokenProvider: _CountingProvider((_) async => _generateTestUserToken('t')), ); expect(staticManager.usesStaticProvider, isTrue); @@ -274,7 +287,7 @@ void main() { test('fires once per load with the loaded token', () async { final updates = []; var version = 0; - final provider = _CountingProvider((_) async => generateTestUserToken('v${++version}')); + final provider = _CountingProvider((_) async => _generateTestUserToken('v${++version}')); final manager = TokenManager( userId: 'user-1', tokenProvider: provider, @@ -287,14 +300,14 @@ void main() { manager.expireToken(); await manager.getToken(); - expect(updates, [generateTestUserToken('v1'), generateTestUserToken('v2')]); + expect(updates, [_generateTestUserToken('v1'), _generateTestUserToken('v2')]); }); test('is invoked before the token is returned', () async { UserToken? notified; final manager = TokenManager( userId: 'user-1', - tokenProvider: _CountingProvider((_) async => generateTestUserToken('token-1')), + tokenProvider: _CountingProvider((_) async => _generateTestUserToken('token-1')), onTokenUpdated: (token) => notified = token, ); @@ -308,7 +321,7 @@ void main() { Future? reentrantCall; manager = TokenManager( userId: 'user-1', - tokenProvider: _CountingProvider((_) async => generateTestUserToken('token-1')), + tokenProvider: _CountingProvider((_) async => _generateTestUserToken('token-1')), onTokenUpdated: (_) { reentrantCall = manager.getToken(); }, diff --git a/packages/stream_core/test/user/token_provider_test.dart b/packages/stream_core/test/user/token_provider_test.dart index fed37963..85724c47 100644 --- a/packages/stream_core/test/user/token_provider_test.dart +++ b/packages/stream_core/test/user/token_provider_test.dart @@ -1,7 +1,24 @@ +import 'dart:convert'; + import 'package:stream_core/stream_core.dart'; import 'package:test/test.dart'; -import '../helpers/user_token.dart'; +/// Builds an unsigned JWT carrying [userId] as its 'user_id' claim. +String _generateTestJwt(String userId) { + String b64UrlNoPad(Object jsonObj) { + final bytes = utf8.encode(jsonEncode(jsonObj)); + return base64Url.encode(bytes).replaceAll('=', ''); + } + + final header = {'alg': 'none', 'typ': 'JWT'}; + final payload = {'user_id': userId}; + + // Trailing dot = empty signature, which is what alg=none means. + return '${b64UrlNoPad(header)}.${b64UrlNoPad(payload)}.'; +} + +/// Builds a JWT [UserToken] carrying [userId] as its 'user_id' claim. +UserToken _generateTestUserToken(String userId) => UserToken(_generateTestJwt(userId)); void main() { group('UserToken.anonymous', () { @@ -14,7 +31,7 @@ void main() { }); test('carries an optional raw value for restricted access', () { - final restricted = generateTestJwt(UserToken.anonymousUserId); + final restricted = _generateTestJwt(UserToken.anonymousUserId); final token = UserToken.anonymous(rawValue: restricted); expect(token.userId, '!anon'); @@ -27,7 +44,7 @@ void main() { () { // An anonymous token must not be able to stand in for someone else. expect( - () => UserToken.anonymous(rawValue: generateTestJwt('alice')), + () => UserToken.anonymous(rawValue: _generateTestJwt('alice')), throwsArgumentError, ); }, @@ -51,14 +68,14 @@ void main() { group('StaticTokenProvider', () { test('returns the token when the user ID matches', () async { - final token = generateTestUserToken('user-1'); + final token = _generateTestUserToken('user-1'); final provider = TokenProvider.static(token); expect(await provider.loadToken('user-1'), token); }); test('throws when the user ID does not match', () { - final token = generateTestUserToken('user-1'); + final token = _generateTestUserToken('user-1'); final provider = TokenProvider.static(token); expect(() => provider.loadToken('user-2'), throwsArgumentError); @@ -68,7 +85,7 @@ void main() { group('DynamicTokenProvider', () { test('returns JWT tokens from the loader', () async { final provider = TokenProvider.dynamic( - (userId) async => generateTestUserToken(userId), + (userId) async => _generateTestUserToken(userId), ); final token = await provider.loadToken('user-1'); @@ -95,7 +112,7 @@ void main() { () { // Caching it would authenticate every later request as that user. final provider = TokenProvider.dynamic( - (_) async => generateTestUserToken('someone-else'), + (_) async => _generateTestUserToken('someone-else'), ); expect(() => provider.loadToken('user-1'), throwsArgumentError); From 8f834a4989ea25757b9d14b4024686f958be0c48 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Wed, 19 Aug 2026 12:08:24 +0200 Subject: [PATCH 11/27] test(llc): share one JWT builder, and document the pattern Restores test/helpers/user_token.dart as the single definition for the three token test files, and amends STYLE_GUIDE's "Make each test entirely self-contained" to say what it already meant: the rule is about shared state, not pure construction, so a stateless fixture builder may be shared. Written down rather than improvised, because the repo had no precedent for cross-test-file imports and the guide read as forbidding them. The motivating evidence is in the amendment: of the three copies this replaces, two claimed alg HS256 while attaching something that was not a signature. Co-Authored-By: Claude Opus 5 (1M context) --- STYLE_GUIDE.md | 9 +++ .../interceptors/auth_interceptor_test.dart | 32 +++----- .../stream_core/test/helpers/user_token.dart | 25 ++++++ .../test/user/token_manager_test.dart | 79 ++++++++----------- .../test/user/token_provider_test.dart | 31 ++------ 5 files changed, 84 insertions(+), 92 deletions(-) create mode 100644 packages/stream_core/test/helpers/user_token.dart diff --git a/STYLE_GUIDE.md b/STYLE_GUIDE.md index 4efd5389..8bb50c0c 100644 --- a/STYLE_GUIDE.md +++ b/STYLE_GUIDE.md @@ -842,6 +842,15 @@ debugging, and refactoring significantly harder. Instead of `setUp`, use local helper functions called inside each test block. For cleanup, prefer `addTearDown` over the global `tearDown` callback. +The rule targets shared state, not pure construction. A deterministic fixture +builder that holds no state — a signed token, an encoded payload, a fixed +timestamp — may live under `test/helpers/` and be imported by several test files, +so one correct definition serves all of them. Copies of a fixture builder tend to +drift: `stream_core` carried three JWT builders, two of which claimed `alg: HS256` +while attaching something that was not a signature. Anything that holds state +between tests, or that arranges a scenario rather than building a value, stays +local to the test file. + ### Prefer more test files, avoid long test files Organize tests into smaller files grouped by feature, widget, or behavior. Split diff --git a/packages/stream_core/test/api/interceptors/auth_interceptor_test.dart b/packages/stream_core/test/api/interceptors/auth_interceptor_test.dart index 36715163..5dece376 100644 --- a/packages/stream_core/test/api/interceptors/auth_interceptor_test.dart +++ b/packages/stream_core/test/api/interceptors/auth_interceptor_test.dart @@ -4,6 +4,8 @@ import 'dart:convert'; import 'package:stream_core/stream_core.dart'; import 'package:test/test.dart'; +import '../../helpers/user_token.dart'; + // A minimal HttpClientAdapter that captures the outgoing RequestOptions and // always responds with an empty successful response. class _CapturingHttpClientAdapter implements HttpClientAdapter { @@ -69,20 +71,6 @@ class _TokenExpiredHttpClientAdapter implements HttpClientAdapter { void close({bool force = false}) {} } -/// Builds a JWT [UserToken] carrying [userId] as its 'user_id' claim. -UserToken _generateTestUserToken(String userId) { - String b64UrlNoPad(Object jsonObj) { - final bytes = utf8.encode(jsonEncode(jsonObj)); - return base64Url.encode(bytes).replaceAll('=', ''); - } - - final header = {'alg': 'none', 'typ': 'JWT'}; - final payload = {'user_id': userId}; - - // Trailing dot = empty signature, which is what alg=none means. - return UserToken('${b64UrlNoPad(header)}.${b64UrlNoPad(payload)}.'); -} - void main() { group('AuthInterceptor', () { test( @@ -92,7 +80,7 @@ void main() { final tokenManager = TokenManager( userId: 'user-123', tokenProvider: TokenProvider.static( - _generateTestUserToken('user-123'), + generateTestUserToken('user-123'), ), ); @@ -136,7 +124,7 @@ void main() { tokenManager.setTokenProvider( serverId, - tokenProvider: TokenProvider.static(_generateTestUserToken(serverId)), + tokenProvider: TokenProvider.static(generateTestUserToken(serverId)), ); await dio.get('/test'); @@ -180,7 +168,7 @@ void main() { test( 'sends a restricted anonymous token as the Authorization header', () async { - final restricted = _generateTestUserToken(UserToken.anonymousUserId); + final restricted = generateTestUserToken(UserToken.anonymousUserId); final tokenManager = TokenManager( userId: UserToken.anonymousUserId, tokenProvider: TokenProvider.static( @@ -224,12 +212,12 @@ void main() { await pumpEventQueue(); // The load is already running for user-1 when the manager moves on. - final userTwoToken = _generateTestUserToken('user-2'); + final userTwoToken = generateTestUserToken('user-2'); tokenManager.setTokenProvider( 'user-2', tokenProvider: TokenProvider.static(userTwoToken), ); - slowLoad.complete(_generateTestUserToken('user-1')); + slowLoad.complete(generateTestUserToken('user-1')); await pending; expect(adapter.lastRequest?.queryParameters['user_id'], 'user-2'); @@ -247,7 +235,7 @@ void main() { () async { final tokenManager = TokenManager( userId: 'guest-1', - tokenProvider: TokenProvider.static(_generateTestUserToken('guest-1')), + tokenProvider: TokenProvider.static(generateTestUserToken('guest-1')), ); final dio = Dio(BaseOptions(baseUrl: 'https://example.com')); @@ -278,7 +266,7 @@ void main() { final tokenManager = TokenManager( userId: 'requested-id', tokenProvider: TokenProvider.dynamic( - (_) async => _generateTestUserToken('requested-id'), + (_) async => generateTestUserToken('requested-id'), ), ); @@ -288,7 +276,7 @@ void main() { tokenManager.setTokenProvider( 'server-assigned-id', tokenProvider: TokenProvider.static( - _generateTestUserToken('server-assigned-id'), + generateTestUserToken('server-assigned-id'), ), ); }, diff --git a/packages/stream_core/test/helpers/user_token.dart b/packages/stream_core/test/helpers/user_token.dart new file mode 100644 index 00000000..fb32afd6 --- /dev/null +++ b/packages/stream_core/test/helpers/user_token.dart @@ -0,0 +1,25 @@ +import 'dart:convert'; + +import 'package:stream_core/stream_core.dart'; + +/// Builds an unsigned JWT carrying [userId] as its 'user_id' claim. +/// +/// Sufficient for [UserToken]'s unverified parsing — nothing in these tests +/// checks a signature. +String generateTestJwt(String userId) { + String b64UrlNoPad(Object jsonObj) { + final bytes = utf8.encode(jsonEncode(jsonObj)); + return base64Url.encode(bytes).replaceAll('=', ''); + } + + final header = {'alg': 'none', 'typ': 'JWT'}; + final payload = {'user_id': userId}; + + // Trailing dot = empty signature, which is what alg=none means. + return '${b64UrlNoPad(header)}.${b64UrlNoPad(payload)}.'; +} + +/// Builds a JWT [UserToken] carrying [userId] as its 'user_id' claim. +UserToken generateTestUserToken(String userId) { + return UserToken(generateTestJwt(userId)); +} diff --git a/packages/stream_core/test/user/token_manager_test.dart b/packages/stream_core/test/user/token_manager_test.dart index d3058b9c..80197c2b 100644 --- a/packages/stream_core/test/user/token_manager_test.dart +++ b/packages/stream_core/test/user/token_manager_test.dart @@ -1,9 +1,10 @@ import 'dart:async'; -import 'dart:convert'; import 'package:stream_core/stream_core.dart'; import 'package:test/test.dart'; +import '../helpers/user_token.dart'; + /// A token provider that counts loads and delegates to a configurable loader. class _CountingProvider implements TokenProvider { _CountingProvider(this._load); @@ -20,25 +21,11 @@ class _CountingProvider implements TokenProvider { } } -/// Builds a JWT [UserToken] carrying [userId] as its 'user_id' claim. -UserToken _generateTestUserToken(String userId) { - String b64UrlNoPad(Object jsonObj) { - final bytes = utf8.encode(jsonEncode(jsonObj)); - return base64Url.encode(bytes).replaceAll('=', ''); - } - - final header = {'alg': 'none', 'typ': 'JWT'}; - final payload = {'user_id': userId}; - - // Trailing dot = empty signature, which is what alg=none means. - return UserToken('${b64UrlNoPad(header)}.${b64UrlNoPad(payload)}.'); -} - void main() { group('TokenManager', () { group('getToken', () { test('loads from the provider and caches the result', () async { - final provider = _CountingProvider((_) async => _generateTestUserToken('token-1')); + final provider = _CountingProvider((_) async => generateTestUserToken('token-1')); final manager = TokenManager( userId: 'user-1', tokenProvider: provider, @@ -47,17 +34,17 @@ void main() { final first = await manager.getToken(); final second = await manager.getToken(); - expect(first, _generateTestUserToken('token-1')); - expect(second, _generateTestUserToken('token-1')); + expect(first, generateTestUserToken('token-1')); + expect(second, generateTestUserToken('token-1')); expect(provider.loadCount, 1); - expect(manager.peekToken(), _generateTestUserToken('token-1')); + expect(manager.peekToken(), generateTestUserToken('token-1')); }); test('passes the manager userId to the provider', () async { String? requestedUserId; final provider = _CountingProvider((userId) async { requestedUserId = userId; - return _generateTestUserToken('token-1'); + return generateTestUserToken('token-1'); }); final manager = TokenManager( userId: 'user-1', @@ -78,10 +65,10 @@ void main() { ); final futures = [manager.getToken(), manager.getToken()]; - completer.complete(_generateTestUserToken('token-1')); + completer.complete(generateTestUserToken('token-1')); final tokens = await Future.wait(futures); - expect(tokens, everyElement(_generateTestUserToken('token-1'))); + expect(tokens, everyElement(generateTestUserToken('token-1'))); expect(provider.loadCount, 1); }); @@ -90,7 +77,7 @@ void main() { final provider = _CountingProvider((_) async { attempts++; if (attempts == 1) throw StateError('load failed'); - return _generateTestUserToken('token-2'); + return generateTestUserToken('token-2'); }); final manager = TokenManager( userId: 'user-1', @@ -101,7 +88,7 @@ void main() { expect(manager.peekToken(), isNull); final token = await manager.getToken(); - expect(token, _generateTestUserToken('token-2')); + expect(token, generateTestUserToken('token-2')); expect(provider.loadCount, 2); }); }); @@ -109,18 +96,18 @@ void main() { group('expireToken', () { test('clears the cache and forces a reload', () async { var version = 0; - final provider = _CountingProvider((_) async => _generateTestUserToken('v${++version}')); + final provider = _CountingProvider((_) async => generateTestUserToken('v${++version}')); final manager = TokenManager( userId: 'user-1', tokenProvider: provider, ); - expect(await manager.getToken(), _generateTestUserToken('v1')); + expect(await manager.getToken(), generateTestUserToken('v1')); manager.expireToken(); expect(manager.peekToken(), isNull); - expect(await manager.getToken(), _generateTestUserToken('v2')); + expect(await manager.getToken(), generateTestUserToken('v2')); expect(provider.loadCount, 2); }); @@ -136,7 +123,7 @@ void main() { final pending = manager.getToken(); manager.expireToken(); - slowLoad.complete(_generateTestUserToken('user-1')); + slowLoad.complete(generateTestUserToken('user-1')); await pending; expect(manager.peekToken(), isNull); @@ -148,14 +135,14 @@ void main() { test('points the manager at another user', () async { final manager = TokenManager( userId: 'user-1', - tokenProvider: TokenProvider.static(_generateTestUserToken('user-1')), + tokenProvider: TokenProvider.static(generateTestUserToken('user-1')), ); expect((await manager.getToken()).userId, 'user-1'); manager.setTokenProvider( 'user-2', - tokenProvider: TokenProvider.static(_generateTestUserToken('user-2')), + tokenProvider: TokenProvider.static(generateTestUserToken('user-2')), ); expect(manager.userId, 'user-2'); @@ -165,15 +152,15 @@ void main() { test('expires the token cached for the previous user', () async { final manager = TokenManager( userId: 'user-1', - tokenProvider: TokenProvider.static(_generateTestUserToken('user-1')), + tokenProvider: TokenProvider.static(generateTestUserToken('user-1')), ); await manager.getToken(); - expect(manager.peekToken(), _generateTestUserToken('user-1')); + expect(manager.peekToken(), generateTestUserToken('user-1')); manager.setTokenProvider( 'user-2', - tokenProvider: TokenProvider.static(_generateTestUserToken('user-2')), + tokenProvider: TokenProvider.static(generateTestUserToken('user-2')), ); expect(manager.peekToken(), isNull); @@ -195,7 +182,7 @@ void main() { manager.setTokenProvider( serverId, - tokenProvider: TokenProvider.static(_generateTestUserToken(serverId)), + tokenProvider: TokenProvider.static(generateTestUserToken(serverId)), ); final guest = await manager.getToken(); @@ -216,9 +203,9 @@ void main() { manager.setTokenProvider( 'user-2', - tokenProvider: TokenProvider.static(_generateTestUserToken('user-2')), + tokenProvider: TokenProvider.static(generateTestUserToken('user-2')), ); - slowLoad.complete(_generateTestUserToken('user-1')); + slowLoad.complete(generateTestUserToken('user-1')); await pending; // user-1's token must not be waiting in the cache for user-2 to send. @@ -241,9 +228,9 @@ void main() { // replaced provider's token through. manager.setTokenProvider( 'user-1', - tokenProvider: TokenProvider.static(_generateTestUserToken('user-1')), + tokenProvider: TokenProvider.static(generateTestUserToken('user-1')), ); - slowLoad.complete(_generateTestUserToken('user-1')); + slowLoad.complete(generateTestUserToken('user-1')); await pending; expect(manager.peekToken(), isNull); @@ -253,14 +240,14 @@ void main() { test('usesStaticProvider reflects the new provider', () { final manager = TokenManager( userId: 'user-1', - tokenProvider: TokenProvider.static(_generateTestUserToken('user-1')), + tokenProvider: TokenProvider.static(generateTestUserToken('user-1')), ); expect(manager.usesStaticProvider, isTrue); manager.setTokenProvider( 'user-1', - tokenProvider: _CountingProvider((_) async => _generateTestUserToken('user-1')), + tokenProvider: _CountingProvider((_) async => generateTestUserToken('user-1')), ); expect(manager.usesStaticProvider, isFalse); @@ -271,11 +258,11 @@ void main() { test('reflects the provider type', () { final staticManager = TokenManager( userId: 'user-1', - tokenProvider: TokenProvider.static(_generateTestUserToken('user-1')), + tokenProvider: TokenProvider.static(generateTestUserToken('user-1')), ); final dynamicManager = TokenManager( userId: 'user-1', - tokenProvider: _CountingProvider((_) async => _generateTestUserToken('t')), + tokenProvider: _CountingProvider((_) async => generateTestUserToken('t')), ); expect(staticManager.usesStaticProvider, isTrue); @@ -287,7 +274,7 @@ void main() { test('fires once per load with the loaded token', () async { final updates = []; var version = 0; - final provider = _CountingProvider((_) async => _generateTestUserToken('v${++version}')); + final provider = _CountingProvider((_) async => generateTestUserToken('v${++version}')); final manager = TokenManager( userId: 'user-1', tokenProvider: provider, @@ -300,14 +287,14 @@ void main() { manager.expireToken(); await manager.getToken(); - expect(updates, [_generateTestUserToken('v1'), _generateTestUserToken('v2')]); + expect(updates, [generateTestUserToken('v1'), generateTestUserToken('v2')]); }); test('is invoked before the token is returned', () async { UserToken? notified; final manager = TokenManager( userId: 'user-1', - tokenProvider: _CountingProvider((_) async => _generateTestUserToken('token-1')), + tokenProvider: _CountingProvider((_) async => generateTestUserToken('token-1')), onTokenUpdated: (token) => notified = token, ); @@ -321,7 +308,7 @@ void main() { Future? reentrantCall; manager = TokenManager( userId: 'user-1', - tokenProvider: _CountingProvider((_) async => _generateTestUserToken('token-1')), + tokenProvider: _CountingProvider((_) async => generateTestUserToken('token-1')), onTokenUpdated: (_) { reentrantCall = manager.getToken(); }, diff --git a/packages/stream_core/test/user/token_provider_test.dart b/packages/stream_core/test/user/token_provider_test.dart index 85724c47..fed37963 100644 --- a/packages/stream_core/test/user/token_provider_test.dart +++ b/packages/stream_core/test/user/token_provider_test.dart @@ -1,24 +1,7 @@ -import 'dart:convert'; - import 'package:stream_core/stream_core.dart'; import 'package:test/test.dart'; -/// Builds an unsigned JWT carrying [userId] as its 'user_id' claim. -String _generateTestJwt(String userId) { - String b64UrlNoPad(Object jsonObj) { - final bytes = utf8.encode(jsonEncode(jsonObj)); - return base64Url.encode(bytes).replaceAll('=', ''); - } - - final header = {'alg': 'none', 'typ': 'JWT'}; - final payload = {'user_id': userId}; - - // Trailing dot = empty signature, which is what alg=none means. - return '${b64UrlNoPad(header)}.${b64UrlNoPad(payload)}.'; -} - -/// Builds a JWT [UserToken] carrying [userId] as its 'user_id' claim. -UserToken _generateTestUserToken(String userId) => UserToken(_generateTestJwt(userId)); +import '../helpers/user_token.dart'; void main() { group('UserToken.anonymous', () { @@ -31,7 +14,7 @@ void main() { }); test('carries an optional raw value for restricted access', () { - final restricted = _generateTestJwt(UserToken.anonymousUserId); + final restricted = generateTestJwt(UserToken.anonymousUserId); final token = UserToken.anonymous(rawValue: restricted); expect(token.userId, '!anon'); @@ -44,7 +27,7 @@ void main() { () { // An anonymous token must not be able to stand in for someone else. expect( - () => UserToken.anonymous(rawValue: _generateTestJwt('alice')), + () => UserToken.anonymous(rawValue: generateTestJwt('alice')), throwsArgumentError, ); }, @@ -68,14 +51,14 @@ void main() { group('StaticTokenProvider', () { test('returns the token when the user ID matches', () async { - final token = _generateTestUserToken('user-1'); + final token = generateTestUserToken('user-1'); final provider = TokenProvider.static(token); expect(await provider.loadToken('user-1'), token); }); test('throws when the user ID does not match', () { - final token = _generateTestUserToken('user-1'); + final token = generateTestUserToken('user-1'); final provider = TokenProvider.static(token); expect(() => provider.loadToken('user-2'), throwsArgumentError); @@ -85,7 +68,7 @@ void main() { group('DynamicTokenProvider', () { test('returns JWT tokens from the loader', () async { final provider = TokenProvider.dynamic( - (userId) async => _generateTestUserToken(userId), + (userId) async => generateTestUserToken(userId), ); final token = await provider.loadToken('user-1'); @@ -112,7 +95,7 @@ void main() { () { // Caching it would authenticate every later request as that user. final provider = TokenProvider.dynamic( - (_) async => _generateTestUserToken('someone-else'), + (_) async => generateTestUserToken('someone-else'), ); expect(() => provider.loadToken('user-1'), throwsArgumentError); From 500beeed1cf505d75fa72734d4fe0926be93cdc5 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Wed, 19 Aug 2026 12:09:07 +0200 Subject: [PATCH 12/27] docs(repo): drop the incident detail from the test-fixture rule A style guide outlives the change that prompted it, so the rule keeps the general reason and the specific case stays in the PR that found it. Co-Authored-By: Claude Opus 5 (1M context) --- STYLE_GUIDE.md | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/STYLE_GUIDE.md b/STYLE_GUIDE.md index 8bb50c0c..e56ff027 100644 --- a/STYLE_GUIDE.md +++ b/STYLE_GUIDE.md @@ -846,10 +846,9 @@ The rule targets shared state, not pure construction. A deterministic fixture builder that holds no state — a signed token, an encoded payload, a fixed timestamp — may live under `test/helpers/` and be imported by several test files, so one correct definition serves all of them. Copies of a fixture builder tend to -drift: `stream_core` carried three JWT builders, two of which claimed `alg: HS256` -while attaching something that was not a signature. Anything that holds state -between tests, or that arranges a scenario rather than building a value, stays -local to the test file. +drift, and a subtly wrong fixture is harder to spot than a shared one. Anything +that holds state between tests, or that arranges a scenario rather than building a +value, stays local to the test file. ### Prefer more test files, avoid long test files From 5215424ac2d2c216e7ee12d1e5739685901cafd2 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Wed, 19 Aug 2026 12:13:38 +0200 Subject: [PATCH 13/27] refactor(llc): restore the original token mismatch messages Shortening them was scope creep: the ask was only that tests stop matching the message text. `ArgumentError`'s two-arg form sets `name` while leaving the message verbatim, so the test keeps its structural handle and the wording is unchanged. It also avoids `ArgumentError.value` repeating the value after the message. The only wording change left is the argument order in `StaticTokenProvider`, which review asked for so both providers read the same way. Co-Authored-By: Claude Opus 5 (1M context) --- .../stream_core/lib/src/user/token_provider.dart | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/packages/stream_core/lib/src/user/token_provider.dart b/packages/stream_core/lib/src/user/token_provider.dart index 8525412e..8aa4d641 100644 --- a/packages/stream_core/lib/src/user/token_provider.dart +++ b/packages/stream_core/lib/src/user/token_provider.dart @@ -74,10 +74,9 @@ class StaticTokenProvider implements TokenProvider { Future loadToken(String userId) async { // Validate that the token's user_id matches the requested userId if (_rawToken.userId != userId) { - throw ArgumentError.value( - _rawToken.userId, + throw ArgumentError( + 'User ID mismatch: expected "$userId", got "${_rawToken.userId}"', 'userId', - 'Expected "$userId"', ); } @@ -113,19 +112,17 @@ class DynamicTokenProvider implements TokenProvider { // Checked before the user id: an anonymous token carries a user id that can // never match, so it would otherwise be reported as the wrong user. if (token.authType != AuthType.jwt) { - throw ArgumentError.value( - token.authType.headerValue, + throw ArgumentError( + 'Token type mismatch: expected ${AuthType.jwt.headerValue}, got ${token.authType.headerValue}', 'authType', - 'Expected ${AuthType.jwt.headerValue}', ); } // Validate that the token's user_id matches the requested userId if (token.userId != userId) { - throw ArgumentError.value( - token.userId, + throw ArgumentError( + 'User ID mismatch: expected "$userId", got "${token.userId}"', 'userId', - 'Expected "$userId"', ); } From df2d4f941fc21c78e6d181d6e47859ab73dc8307 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Wed, 19 Aug 2026 12:15:34 +0200 Subject: [PATCH 14/27] refactor(llc): drop the ArgumentError name argument Plain `ArgumentError(message)`, as before. The check-order test loses its structural handle and goes back to `throwsArgumentError`; ordering the type check first still gives a human a better message, and the comment records why, but nothing asserts it. Co-Authored-By: Claude Opus 5 (1M context) --- packages/stream_core/lib/src/user/token_provider.dart | 3 --- packages/stream_core/test/user/token_provider_test.dart | 7 +------ 2 files changed, 1 insertion(+), 9 deletions(-) diff --git a/packages/stream_core/lib/src/user/token_provider.dart b/packages/stream_core/lib/src/user/token_provider.dart index 8aa4d641..4d5b6d27 100644 --- a/packages/stream_core/lib/src/user/token_provider.dart +++ b/packages/stream_core/lib/src/user/token_provider.dart @@ -76,7 +76,6 @@ class StaticTokenProvider implements TokenProvider { if (_rawToken.userId != userId) { throw ArgumentError( 'User ID mismatch: expected "$userId", got "${_rawToken.userId}"', - 'userId', ); } @@ -114,7 +113,6 @@ class DynamicTokenProvider implements TokenProvider { if (token.authType != AuthType.jwt) { throw ArgumentError( 'Token type mismatch: expected ${AuthType.jwt.headerValue}, got ${token.authType.headerValue}', - 'authType', ); } @@ -122,7 +120,6 @@ class DynamicTokenProvider implements TokenProvider { if (token.userId != userId) { throw ArgumentError( 'User ID mismatch: expected "$userId", got "${token.userId}"', - 'userId', ); } diff --git a/packages/stream_core/test/user/token_provider_test.dart b/packages/stream_core/test/user/token_provider_test.dart index fed37963..21079e45 100644 --- a/packages/stream_core/test/user/token_provider_test.dart +++ b/packages/stream_core/test/user/token_provider_test.dart @@ -82,12 +82,7 @@ void main() { (_) async => UserToken.anonymous(), ); - // Reported as the wrong type, not the wrong user: an anonymous token - // also carries a user id that cannot match the one requested. - expect( - () => provider.loadToken('user-1'), - throwsA(isArgumentError.having((it) => it.name, 'name', 'authType')), - ); + expect(() => provider.loadToken('user-1'), throwsArgumentError); }); test( From 6c5eeca9f46c9ebf95729df427f69ff83cc61376 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Wed, 19 Aug 2026 12:16:45 +0200 Subject: [PATCH 15/27] docs(llc): drop an inaccurate comment on the token type check It claimed an anonymous token's user id "can never match" the requested one. It can: an anonymous TokenManager requests `!anon`, which is exactly what an anonymous token carries. Checking the type before the identity needs no comment anyway, so restore the file's existing one. Co-Authored-By: Claude Opus 5 (1M context) --- packages/stream_core/lib/src/user/token_provider.dart | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/stream_core/lib/src/user/token_provider.dart b/packages/stream_core/lib/src/user/token_provider.dart index 4d5b6d27..e8dfa4d3 100644 --- a/packages/stream_core/lib/src/user/token_provider.dart +++ b/packages/stream_core/lib/src/user/token_provider.dart @@ -108,8 +108,7 @@ class DynamicTokenProvider implements TokenProvider { Future loadToken(String userId) async { final token = await _loader.call(userId); - // Checked before the user id: an anonymous token carries a user id that can - // never match, so it would otherwise be reported as the wrong user. + // Validate that the returned token is a JWT token if (token.authType != AuthType.jwt) { throw ArgumentError( 'Token type mismatch: expected ${AuthType.jwt.headerValue}, got ${token.authType.headerValue}', From a6bb26c16c5c2a7ffc732db7ee6b8f08eeb9cde9 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Wed, 19 Aug 2026 12:18:00 +0200 Subject: [PATCH 16/27] test(llc): reach the type check now that the user id is validated first MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With the user id checked first, the non-JWT test was requesting "user-1" for an anonymous token, so it threw on the id check and the type check had no coverage at all. Requesting `!anon` — the id an anonymous token carries — passes the id check and reaches the type check. Verified by deleting the type check: the test now fails, where before it still passed. Co-Authored-By: Claude Opus 5 (1M context) --- .../stream_core/lib/src/user/token_provider.dart | 12 ++++++------ .../stream_core/test/user/token_provider_test.dart | 7 ++++++- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/packages/stream_core/lib/src/user/token_provider.dart b/packages/stream_core/lib/src/user/token_provider.dart index e8dfa4d3..53bb63ac 100644 --- a/packages/stream_core/lib/src/user/token_provider.dart +++ b/packages/stream_core/lib/src/user/token_provider.dart @@ -108,17 +108,17 @@ class DynamicTokenProvider implements TokenProvider { Future loadToken(String userId) async { final token = await _loader.call(userId); - // Validate that the returned token is a JWT token - if (token.authType != AuthType.jwt) { + // Validate that the token's user_id matches the requested userId + if (token.userId != userId) { throw ArgumentError( - 'Token type mismatch: expected ${AuthType.jwt.headerValue}, got ${token.authType.headerValue}', + 'User ID mismatch: expected "$userId", got "${token.userId}"', ); } - // Validate that the token's user_id matches the requested userId - if (token.userId != userId) { + // Validate that the returned token is a JWT token + if (token.authType != AuthType.jwt) { throw ArgumentError( - 'User ID mismatch: expected "$userId", got "${token.userId}"', + 'Token type mismatch: expected ${AuthType.jwt.headerValue}, got ${token.authType.headerValue}', ); } diff --git a/packages/stream_core/test/user/token_provider_test.dart b/packages/stream_core/test/user/token_provider_test.dart index 21079e45..3e068f4c 100644 --- a/packages/stream_core/test/user/token_provider_test.dart +++ b/packages/stream_core/test/user/token_provider_test.dart @@ -82,7 +82,12 @@ void main() { (_) async => UserToken.anonymous(), ); - expect(() => provider.loadToken('user-1'), throwsArgumentError); + // Requested id matches the one an anonymous token carries, so this + // reaches the type check rather than failing the user id check first. + expect( + () => provider.loadToken(UserToken.anonymousUserId), + throwsArgumentError, + ); }); test( From 1d50f2c53b0ace0dfd6b689c853fb95b0029daea Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Thu, 20 Aug 2026 13:02:17 +0200 Subject: [PATCH 17/27] feat(llc): let a TokenManager exist before its user does MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `TokenManager` required a user id and a provider up front, so it could not represent a client that is constructed before anyone signs in — the shape Chat needs, where `connectUser` arrives after the client, and where `disconnectUser` has to return the manager to having no user at all. The user and the provider now live in one nullable field rather than two, so they cannot disagree: a user without a provider cannot load, and a provider without a user has nothing to load for. `userId` is therefore nullable, and `getToken` fails with a `ClientException` while no identity is configured. Adds `TokenManager.unconfigured` for that starting state and `reset` for returning to it, distinct from `expireToken`, which keeps the identity and only drops the cached token. Moves `anonymousUserId` from `UserToken` to `User`: it is a user id, every call site passes it where one is expected, and `User.anonymous` was hardcoding the literal rather than sharing the constant. `User` now asserts that an anonymous user carries it, matching the validation `UserToken.anonymous` already performs on the claim. `AuthInterceptor` sources the `user_id` query parameter from the loaded token instead of the manager, so the parameter and the token always describe the same user and the server cannot reject the pair as a mismatch. Co-Authored-By: Claude Opus 5 (1M context) --- packages/stream_core/CHANGELOG.md | 10 ++- .../api/interceptors/auth_interceptor.dart | 2 +- .../lib/src/user/token_manager.dart | 66 ++++++++++++----- packages/stream_core/lib/src/user/user.dart | 14 +++- .../stream_core/lib/src/user/user_token.dart | 17 ++--- .../interceptors/auth_interceptor_test.dart | 30 ++++---- .../test/user/token_manager_test.dart | 74 ++++++++++++++++++- .../test/user/token_provider_test.dart | 4 +- packages/stream_core/test/user/user_test.dart | 44 +++++++++++ 9 files changed, 212 insertions(+), 49 deletions(-) create mode 100644 packages/stream_core/test/user/user_test.dart diff --git a/packages/stream_core/CHANGELOG.md b/packages/stream_core/CHANGELOG.md index de8f5ea1..baeeb305 100644 --- a/packages/stream_core/CHANGELOG.md +++ b/packages/stream_core/CHANGELOG.md @@ -2,15 +2,18 @@ ### 💥 BREAKING CHANGES -- Removed the `userId` parameter from `UserToken.anonymous`, anonymous tokens always use `UserToken.anonymousUserId` +- Removed the `userId` parameter from `UserToken.anonymous`, anonymous tokens always use `User.anonymousUserId` - Removed the `TokenManager.tokenProvider` setter, use `setTokenProvider` instead +- `TokenManager.userId` is now nullable, and is `null` until an identity is configured ### ✨ Features - Added `TokenManager.setTokenProvider`, which points an existing manager at another user and expires the cached token - Added optional `onTokenUpdated` callback to `TokenManager`, invoked after every successful token load -- Added optional `rawValue` to `UserToken.anonymous`, so an anonymous token can carry a JWT granting restricted access, provided its `user_id` claim is `UserToken.anonymousUserId` -- Added `UserToken.anonymousUserId`, the user id used for anonymous authentication +- Added optional `rawValue` to `UserToken.anonymous`, so an anonymous token can carry a JWT granting restricted access, provided its `user_id` claim is `User.anonymousUserId` +- Added `User.anonymousUserId`, the id every anonymous user has +- Added `TokenManager.unconfigured`, for a client that exists before its user does +- Added `TokenManager.reset`, which drops the configured identity and its cached token - Added `teams` field to `User` class ### 🐛 Bug Fixes @@ -22,6 +25,7 @@ ### 🔄 Changed - Raised the minimum Dart SDK to `^3.12.0` +- `User` now asserts that a user of type `UserType.anonymous` carries `User.anonymousUserId` as its id ## 0.4.0 diff --git a/packages/stream_core/lib/src/api/interceptors/auth_interceptor.dart b/packages/stream_core/lib/src/api/interceptors/auth_interceptor.dart index ea7a0ed3..f810f44e 100644 --- a/packages/stream_core/lib/src/api/interceptors/auth_interceptor.dart +++ b/packages/stream_core/lib/src/api/interceptors/auth_interceptor.dart @@ -23,7 +23,7 @@ class AuthInterceptor extends QueuedInterceptor { try { final token = await _tokenManager.getToken(); - options.queryParameters['user_id'] = _tokenManager.userId; + options.queryParameters['user_id'] = token.userId; options.headers['Authorization'] = token.rawValue; options.headers['stream-auth-type'] = token.authType.headerValue; diff --git a/packages/stream_core/lib/src/user/token_manager.dart b/packages/stream_core/lib/src/user/token_manager.dart index a06c9e3a..1b1ee075 100644 --- a/packages/stream_core/lib/src/user/token_manager.dart +++ b/packages/stream_core/lib/src/user/token_manager.dart @@ -1,5 +1,6 @@ import 'package:synchronized/extension.dart'; +import '../errors/client_exception.dart'; import 'token_provider.dart'; import 'user_token.dart'; @@ -42,20 +43,31 @@ class TokenManager { /// An optional `onTokenUpdated` callback is invoked after every successful /// token load. It is not invoked for callers served from the cache. TokenManager({ - required this._userId, - required this._tokenProvider, + required String userId, + required TokenProvider tokenProvider, this._onTokenUpdated, - }); + }) : _identity = (userId: userId, provider: tokenProvider); - /// The unique identifier of the user whose tokens are managed. + /// Creates a [TokenManager] that manages no user yet. + /// + /// [getToken] fails until [setTokenProvider] supplies one. Distinct from a + /// manager holding an anonymous identity, which is a user that can load a + /// token; this one has no user at all. + TokenManager.unconfigured({this._onTokenUpdated}) : _identity = null; + + // The user being managed and the provider that loads their tokens. + // + // A single field rather than two, so the two can never disagree: a user + // without a provider cannot load, and a provider without a user has nothing + // to load for. `null` means no identity is configured. + ({String userId, TokenProvider provider})? _identity; + + /// The unique identifier of the user whose tokens are managed, or `null` when + /// no identity is configured. /// /// Changes when the manager is pointed at another user with - /// [setTokenProvider]. - String get userId => _userId; - String _userId; - - // The provider used to load tokens when needed. - TokenProvider _tokenProvider; + /// [setTokenProvider], and returns to `null` after [reset]. + String? get userId => _identity?.userId; // Invoked after every successful token load. final OnTokenUpdated? _onTokenUpdated; @@ -74,7 +86,7 @@ class TokenManager { /// ```dart /// // Authenticate anonymously while the real identity is being obtained. /// final manager = TokenManager( - /// userId: UserToken.anonymousUserId, + /// userId: User.anonymousUserId, /// tokenProvider: TokenProvider.static(UserToken.anonymous()), /// ); /// @@ -88,14 +100,24 @@ class TokenManager { String userId, { required TokenProvider tokenProvider, }) { - _userId = userId; - _tokenProvider = tokenProvider; + _identity = (userId: userId, provider: tokenProvider); // The cached token belongs to the previous user and provider, so drop it // and let the next `getToken` call load a fresh one. expireToken(); } + /// Drops the configured identity, returning this manager to the state of + /// [TokenManager.unconfigured]. + /// + /// [getToken] fails until [setTokenProvider] supplies an identity again. Use + /// this when the user is going away for good; to keep the identity and only + /// force a reload, use [expireToken]. + void reset() { + _identity = null; + expireToken(); + } + // The currently cached token, if any. UserToken? _cachedToken; @@ -112,8 +134,9 @@ class TokenManager { /// Whether this manager uses a static token provider. /// /// Returns true if the token provider is static (doesn't refresh tokens), - /// false if it's dynamic (fetches fresh tokens on each call). - bool get usesStaticProvider => _tokenProvider is StaticTokenProvider; + /// false if it's dynamic (fetches fresh tokens on each call) or if no + /// identity is configured. + bool get usesStaticProvider => _identity?.provider is StaticTokenProvider; /// Gets a valid token for the user, loading one if necessary. /// @@ -123,6 +146,10 @@ class TokenManager { /// at a time. /// /// Returns a [Future] that resolves to a [UserToken] for the user. + /// + /// Fails with a [ClientException] when no identity is configured, either + /// because the manager was created with [TokenManager.unconfigured] or + /// because [reset] dropped the previous one. Future getToken() { final cached = _cachedToken; if (cached != null) return Future.value(cached); @@ -138,9 +165,14 @@ class TokenManager { // Loads a token from the provider and, unless the cached token was // invalidated while it loaded, caches it and notifies `onTokenUpdated`. Future _loadAndNotify() async { - final loadingFor = _userId; + final identity = _identity; + if (identity == null) { + throw ClientException(message: 'No user is configured, call setTokenProvider before loading a token'); + } + + final loadingFor = identity.userId; final loadingGeneration = _generation; - final updatedToken = await _tokenProvider.loadToken(loadingFor); + final updatedToken = await identity.provider.loadToken(loadingFor); // `setTokenProvider` or `expireToken` may have run while this loaded, in // which case the token is the one the caller asked to stop using. diff --git a/packages/stream_core/lib/src/user/user.dart b/packages/stream_core/lib/src/user/user.dart index 3bd661fe..53eed28d 100644 --- a/packages/stream_core/lib/src/user/user.dart +++ b/packages/stream_core/lib/src/user/user.dart @@ -17,7 +17,11 @@ class User extends Equatable { this.type = UserType.regular, Map? custom, this.teams = const [], - }) : originalName = name, + }) : assert( + type != UserType.anonymous || id == anonymousUserId, + 'An anonymous user must use `User.anonymousUserId` as its id', + ), + originalName = name, custom = custom ?? const {}; /// Creates a guest user with the provided id and an optional display name. @@ -28,7 +32,13 @@ class User extends Equatable { /// Creates an anonymous user. /// - Returns: an anonymous `User`. - const User.anonymous() : this(id: '!anon', type: UserType.anonymous); + const User.anonymous() : this(id: anonymousUserId, type: UserType.anonymous); + + /// The id every anonymous user has. + /// + /// Anonymous users are not distinguishable from one another, so this is the + /// only id a [User] of type [UserType.anonymous] can carry. + static const anonymousUserId = '!anon'; /// The user's id. final String id; diff --git a/packages/stream_core/lib/src/user/user_token.dart b/packages/stream_core/lib/src/user/user_token.dart index 1337c10e..32f773aa 100644 --- a/packages/stream_core/lib/src/user/user_token.dart +++ b/packages/stream_core/lib/src/user/user_token.dart @@ -1,6 +1,8 @@ import 'package:equatable/equatable.dart'; import 'package:jose/jose.dart'; +import 'user.dart'; + /// A function that loads user tokens. /// /// Takes a [userId] and returns a [Future] that resolves to a [UserToken]. @@ -58,7 +60,7 @@ class UserToken extends Equatable { /// Creates an anonymous user token. /// - /// Anonymous tokens always use [anonymousUserId] as their user id. + /// Anonymous tokens always use [User.anonymousUserId] as their user id. /// /// An optional [rawValue] can carry a JWT that is sent along with anonymous /// requests, granting the caller access to the specific resources its claims @@ -68,24 +70,24 @@ class UserToken extends Equatable { /// Returns a [UserToken] configured for anonymous access. /// /// Throws an [ArgumentError] if [rawValue] is given and its 'user_id' claim - /// is not [anonymousUserId], and a [FormatException] if it cannot be parsed + /// is not [User.anonymousUserId], and a [FormatException] if it cannot be parsed /// as a JWT. factory UserToken.anonymous({String rawValue = ''}) { if (rawValue.isNotEmpty) { final jwtBody = JsonWebToken.unverified(rawValue); final claim = jwtBody.claims.getTyped('user_id'); - if (claim != anonymousUserId) { + if (claim != User.anonymousUserId) { throw ArgumentError.value( claim, 'rawValue', - 'Expected a JWT claiming user_id "$anonymousUserId"', + 'Expected a JWT claiming user_id "${User.anonymousUserId}"', ); } } return UserToken._( rawValue: rawValue, - userId: anonymousUserId, + userId: User.anonymousUserId, authType: AuthType.anonymous, ); } @@ -96,9 +98,6 @@ class UserToken extends Equatable { required this.authType, }); - /// The user id used for anonymous authentication. - static const anonymousUserId = '!anon'; - /// The raw token value. /// /// For JWT tokens, contains the complete JWT string. For anonymous tokens, @@ -108,7 +107,7 @@ class UserToken extends Equatable { /// The unique identifier of the user. /// /// For JWT tokens, this value is extracted from the 'user_id' claim. - /// For anonymous tokens, it is always [anonymousUserId]. + /// For anonymous tokens, it is always [User.anonymousUserId]. final String userId; /// The authentication type of this token. diff --git a/packages/stream_core/test/api/interceptors/auth_interceptor_test.dart b/packages/stream_core/test/api/interceptors/auth_interceptor_test.dart index 5dece376..deff8dec 100644 --- a/packages/stream_core/test/api/interceptors/auth_interceptor_test.dart +++ b/packages/stream_core/test/api/interceptors/auth_interceptor_test.dart @@ -113,7 +113,7 @@ void main() { const serverId = 'server-assigned-id'; final tokenManager = TokenManager( - userId: UserToken.anonymousUserId, + userId: User.anonymousUserId, tokenProvider: TokenProvider.static(UserToken.anonymous()), ); @@ -142,7 +142,7 @@ void main() { 'anonymous auth type', () async { final tokenManager = TokenManager( - userId: UserToken.anonymousUserId, + userId: User.anonymousUserId, tokenProvider: TokenProvider.static(UserToken.anonymous()), ); @@ -160,7 +160,7 @@ void main() { ); expect( adapter.lastRequest?.queryParameters['user_id'], - UserToken.anonymousUserId, + User.anonymousUserId, ); }, ); @@ -168,9 +168,9 @@ void main() { test( 'sends a restricted anonymous token as the Authorization header', () async { - final restricted = generateTestUserToken(UserToken.anonymousUserId); + final restricted = generateTestUserToken(User.anonymousUserId); final tokenManager = TokenManager( - userId: UserToken.anonymousUserId, + userId: User.anonymousUserId, tokenProvider: TokenProvider.static( UserToken.anonymous(rawValue: restricted.rawValue), ), @@ -192,11 +192,13 @@ void main() { ); test( - 'sends the token manager user id, not the loaded token user id', + 'sends the user id of the token it actually sent', () async { - // The mismatch is deliberate: a request carrying someone else's token - // is rejected, where deriving `user_id` from the token would make it - // self-consistent and silently act as the token's owner. + // The two can disagree: a load already running for one user finishes + // after the manager has moved to another, and that token is still + // handed to the request that triggered it. Deriving `user_id` from the + // token keeps the pair self-consistent, so the request is accepted as + // the token's owner rather than rejected for a mismatch. final slowLoad = Completer(); final tokenManager = TokenManager( userId: 'user-1', @@ -212,18 +214,18 @@ void main() { await pumpEventQueue(); // The load is already running for user-1 when the manager moves on. - final userTwoToken = generateTestUserToken('user-2'); + final userOneToken = generateTestUserToken('user-1'); tokenManager.setTokenProvider( 'user-2', - tokenProvider: TokenProvider.static(userTwoToken), + tokenProvider: TokenProvider.static(generateTestUserToken('user-2')), ); - slowLoad.complete(generateTestUserToken('user-1')); + slowLoad.complete(userOneToken); await pending; - expect(adapter.lastRequest?.queryParameters['user_id'], 'user-2'); + expect(adapter.lastRequest?.queryParameters['user_id'], 'user-1'); expect( adapter.lastRequest?.headers['Authorization'], - isNot(userTwoToken.rawValue), + userOneToken.rawValue, ); }, ); diff --git a/packages/stream_core/test/user/token_manager_test.dart b/packages/stream_core/test/user/token_manager_test.dart index 80197c2b..b4d73c3f 100644 --- a/packages/stream_core/test/user/token_manager_test.dart +++ b/packages/stream_core/test/user/token_manager_test.dart @@ -172,7 +172,7 @@ void main() { const serverId = 'guest-abc-guest-123'; final manager = TokenManager( - userId: UserToken.anonymousUserId, + userId: User.anonymousUserId, tokenProvider: TokenProvider.static(UserToken.anonymous()), ); @@ -254,6 +254,78 @@ void main() { }); }); + group('unconfigured', () { + test('has no user and fails to load a token', () async { + final manager = TokenManager.unconfigured(); + + expect(manager.userId, isNull); + expect(manager.peekToken(), isNull); + expect(manager.usesStaticProvider, isFalse); + await expectLater(manager.getToken(), throwsA(isA())); + }); + + test('loads once an identity is supplied', () async { + final manager = TokenManager.unconfigured(); + + manager.setTokenProvider( + 'user-1', + tokenProvider: TokenProvider.static(generateTestUserToken('user-1')), + ); + + expect(manager.userId, 'user-1'); + expect((await manager.getToken()).userId, 'user-1'); + }); + }); + + group('reset', () { + test('drops the identity and the cached token', () async { + final manager = TokenManager( + userId: 'user-1', + tokenProvider: TokenProvider.static(generateTestUserToken('user-1')), + ); + + await manager.getToken(); + expect(manager.peekToken(), isNotNull); + + manager.reset(); + + expect(manager.userId, isNull); + expect(manager.peekToken(), isNull); + await expectLater(manager.getToken(), throwsA(isA())); + }); + + test('leaves the manager reusable for another user', () async { + final manager = TokenManager( + userId: 'user-1', + tokenProvider: TokenProvider.static(generateTestUserToken('user-1')), + )..reset(); + + manager.setTokenProvider( + 'user-2', + tokenProvider: TokenProvider.static(generateTestUserToken('user-2')), + ); + + expect((await manager.getToken()).userId, 'user-2'); + }); + + test('discards a load already in flight', () async { + final completer = Completer(); + final manager = TokenManager( + userId: 'user-1', + tokenProvider: _CountingProvider((_) => completer.future), + ); + + final inFlight = manager.getToken(); + manager.reset(); + completer.complete(generateTestUserToken('user-1')); + + // The caller that started the load is still served, but its token is + // not cached over the reset. + expect((await inFlight).userId, 'user-1'); + expect(manager.peekToken(), isNull); + }); + }); + group('usesStaticProvider', () { test('reflects the provider type', () { final staticManager = TokenManager( diff --git a/packages/stream_core/test/user/token_provider_test.dart b/packages/stream_core/test/user/token_provider_test.dart index 3e068f4c..c79669f3 100644 --- a/packages/stream_core/test/user/token_provider_test.dart +++ b/packages/stream_core/test/user/token_provider_test.dart @@ -14,7 +14,7 @@ void main() { }); test('carries an optional raw value for restricted access', () { - final restricted = generateTestJwt(UserToken.anonymousUserId); + final restricted = generateTestJwt(User.anonymousUserId); final token = UserToken.anonymous(rawValue: restricted); expect(token.userId, '!anon'); @@ -85,7 +85,7 @@ void main() { // Requested id matches the one an anonymous token carries, so this // reaches the type check rather than failing the user id check first. expect( - () => provider.loadToken(UserToken.anonymousUserId), + () => provider.loadToken(User.anonymousUserId), throwsArgumentError, ); }); diff --git a/packages/stream_core/test/user/user_test.dart b/packages/stream_core/test/user/user_test.dart new file mode 100644 index 00000000..1e0e87c2 --- /dev/null +++ b/packages/stream_core/test/user/user_test.dart @@ -0,0 +1,44 @@ +import 'package:stream_core/stream_core.dart'; +import 'package:test/test.dart'; + +void main() { + group('User.anonymous', () { + test('carries the id every anonymous user has', () { + const user = User.anonymous(); + + expect(user.id, User.anonymousUserId); + expect(user.type, UserType.anonymous); + }); + }); + + group('User', () { + test('rejects an anonymous user built with any other id', () { + // Not `const`: a const context evaluates the assert at compile time. + expect( + () => User(id: 'someone-else', type: UserType.anonymous), + throwsA(isA()), + ); + }); + + test('allows the anonymous id for a user of another type', () { + // The invariant runs one way: anonymous implies the id, not the reverse. + const user = User(id: User.anonymousUserId); + + expect(user.type, UserType.regular); + }); + + test('reports the id as the name when none was given', () { + const user = User.guest('bob'); + + expect(user.originalName, isNull); + expect(user.name, 'bob'); + }); + + test('keeps the name it was given', () { + const user = User.guest('bob', name: 'Bob'); + + expect(user.originalName, 'Bob'); + expect(user.name, 'Bob'); + }); + }); +} From 06747e081ea1d99a61ab105357a794f5645a9926 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Thu, 20 Aug 2026 16:59:04 +0200 Subject: [PATCH 18/27] fix(llc): report a wrong-type token as such, not as a wrong user MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `DynamicTokenProvider` checked the identity before the type, so a loader returning an anonymous token for a real user reported "User ID mismatch" — the id an anonymous token carries rather than the reason it was rejected. The test had to request `User.anonymousUserId` to reach the type check at all, which is how the ordering surfaced in review. Checking the type first reports what is actually wrong. The identity check still runs for tokens of the right type, which is the case that matters for security. Co-Authored-By: Claude Opus 5 (1M context) --- .../stream_core/lib/src/user/token_provider.dart | 13 +++++++------ .../stream_core/test/user/token_provider_test.dart | 14 ++++++++++---- 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/packages/stream_core/lib/src/user/token_provider.dart b/packages/stream_core/lib/src/user/token_provider.dart index 53bb63ac..0488a098 100644 --- a/packages/stream_core/lib/src/user/token_provider.dart +++ b/packages/stream_core/lib/src/user/token_provider.dart @@ -108,17 +108,18 @@ class DynamicTokenProvider implements TokenProvider { Future loadToken(String userId) async { final token = await _loader.call(userId); - // Validate that the token's user_id matches the requested userId - if (token.userId != userId) { + // Validate the type before the identity: an anonymous token carries an id + // of its own, so checking the id first would report the wrong problem. + if (token.authType != AuthType.jwt) { throw ArgumentError( - 'User ID mismatch: expected "$userId", got "${token.userId}"', + 'Token type mismatch: expected ${AuthType.jwt.headerValue}, got ${token.authType.headerValue}', ); } - // Validate that the returned token is a JWT token - if (token.authType != AuthType.jwt) { + // Validate that the token's user_id matches the requested userId + if (token.userId != userId) { throw ArgumentError( - 'Token type mismatch: expected ${AuthType.jwt.headerValue}, got ${token.authType.headerValue}', + 'User ID mismatch: expected "$userId", got "${token.userId}"', ); } diff --git a/packages/stream_core/test/user/token_provider_test.dart b/packages/stream_core/test/user/token_provider_test.dart index c79669f3..78b2e4ae 100644 --- a/packages/stream_core/test/user/token_provider_test.dart +++ b/packages/stream_core/test/user/token_provider_test.dart @@ -82,11 +82,17 @@ void main() { (_) async => UserToken.anonymous(), ); - // Requested id matches the one an anonymous token carries, so this - // reaches the type check rather than failing the user id check first. + // The type is checked first, so this reports the wrong type rather than + // the id an anonymous token happens to carry. expect( - () => provider.loadToken(User.anonymousUserId), - throwsArgumentError, + () => provider.loadToken('user-1'), + throwsA( + isA().having( + (it) => it.message, + 'message', + contains('Token type mismatch'), + ), + ), ); }); From 1ed67182135d91395121630dcda5a77d37977cce Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Thu, 20 Aug 2026 16:59:04 +0200 Subject: [PATCH 19/27] fix(llc): stop serving a token for a user the manager has dropped MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three things `setTokenProvider` and `reset` made reachable. A load that finishes after `reset` handed its token to the caller. `reset` is a logout: the request that started as that user should not go out as them. It now fails with a `ClientException`, which `AuthInterceptor.onRequest` already turns into a rejected request. A `setTokenProvider` during a load still serves the caller that started it — that request began as the previous user and finishing as them is the defensible reading, and a test pins it. The manager now rejects a token whose `user_id` is not the user it was loading for. Both built-in providers check this, but `TokenProvider` is an `abstract interface class`, so a custom one is under no obligation to — and caching another user's token authenticates every later request as them. `setTokenProvider` no longer expires the cached token when handed the identity it already has, restoring the old setter's no-op. A reconnect or resume path that defensively re-sets the same provider was otherwise hitting the token endpoint every time. Providers compare by identity, so this only applies when the same instance is passed again, which is that case. Also documents that loads are serialised, so a provider that never returns blocks every later caller, including one for a different user configured in the meantime. Bounding that needs a timeout policy the SDK has nowhere to configure yet, so for now it is written down rather than fixed. The test fixtures issued tokens whose `user_id` was a version marker rather than the user being managed — something no real provider could return, and which the new check rejects. They now issue tokens for the user under test and tell two loads apart with a `nonce` claim. Co-Authored-By: Claude Opus 5 (1M context) --- .../lib/src/user/token_manager.dart | 36 ++++++++- .../stream_core/test/helpers/user_token.dart | 10 +-- .../test/user/token_manager_test.dart | 75 ++++++++++++++----- 3 files changed, 94 insertions(+), 27 deletions(-) diff --git a/packages/stream_core/lib/src/user/token_manager.dart b/packages/stream_core/lib/src/user/token_manager.dart index 1b1ee075..a9abf140 100644 --- a/packages/stream_core/lib/src/user/token_manager.dart +++ b/packages/stream_core/lib/src/user/token_manager.dart @@ -96,11 +96,18 @@ class TokenManager { /// tokenProvider: TokenProvider.static(UserToken(rawToken)), /// ); /// ``` + /// Re-setting the identity this manager already has does nothing: expiring + /// the cached token would send the next caller to the provider for no reason. + /// Note that a [TokenProvider] compares by identity, so this only applies + /// when the same instance is passed again. void setTokenProvider( String userId, { required TokenProvider tokenProvider, }) { - _identity = (userId: userId, provider: tokenProvider); + final identity = (userId: userId, provider: tokenProvider); + if (_identity == identity) return; + + _identity = identity; // The cached token belongs to the previous user and provider, so drop it // and let the next `getToken` call load a fresh one. @@ -149,7 +156,12 @@ class TokenManager { /// /// Fails with a [ClientException] when no identity is configured, either /// because the manager was created with [TokenManager.unconfigured] or - /// because [reset] dropped the previous one. + /// because [reset] dropped the previous one, and when [reset] runs while the + /// token is loading. + /// + /// Loads are serialised, so a provider that never returns blocks every later + /// caller — including one for a different user configured by + /// [setTokenProvider] in the meantime. Future getToken() { final cached = _cachedToken; if (cached != null) return Future.value(cached); @@ -174,9 +186,27 @@ class TokenManager { final loadingGeneration = _generation; final updatedToken = await identity.provider.loadToken(loadingFor); + // Both built-in providers check this, but a custom one is under no + // obligation to, and caching a token for another user would authenticate + // every later request as them. + if (updatedToken.userId != loadingFor) { + throw ArgumentError( + 'User ID mismatch: expected "$loadingFor", got "${updatedToken.userId}"', + ); + } + // `setTokenProvider` or `expireToken` may have run while this loaded, in // which case the token is the one the caller asked to stop using. - if (loadingGeneration != _generation) return updatedToken; + if (loadingGeneration != _generation) { + // A `reset` means the user is gone, so nothing may go out as them. A + // switch is different: the request that started as this user may finish + // as them. + if (_identity == null) { + throw ClientException(message: 'The user was reset while its token was loading'); + } + + return updatedToken; + } _cachedToken = updatedToken; _onTokenUpdated?.call(updatedToken); diff --git a/packages/stream_core/test/helpers/user_token.dart b/packages/stream_core/test/helpers/user_token.dart index fb32afd6..457776b4 100644 --- a/packages/stream_core/test/helpers/user_token.dart +++ b/packages/stream_core/test/helpers/user_token.dart @@ -5,21 +5,21 @@ import 'package:stream_core/stream_core.dart'; /// Builds an unsigned JWT carrying [userId] as its 'user_id' claim. /// /// Sufficient for [UserToken]'s unverified parsing — nothing in these tests -/// checks a signature. -String generateTestJwt(String userId) { +/// checks a signature. Pass [nonce] to tell two tokens for the same user apart. +String generateTestJwt(String userId, {String? nonce}) { String b64UrlNoPad(Object jsonObj) { final bytes = utf8.encode(jsonEncode(jsonObj)); return base64Url.encode(bytes).replaceAll('=', ''); } final header = {'alg': 'none', 'typ': 'JWT'}; - final payload = {'user_id': userId}; + final payload = {'user_id': userId, 'nonce': ?nonce}; // Trailing dot = empty signature, which is what alg=none means. return '${b64UrlNoPad(header)}.${b64UrlNoPad(payload)}.'; } /// Builds a JWT [UserToken] carrying [userId] as its 'user_id' claim. -UserToken generateTestUserToken(String userId) { - return UserToken(generateTestJwt(userId)); +UserToken generateTestUserToken(String userId, {String? nonce}) { + return UserToken(generateTestJwt(userId, nonce: nonce)); } diff --git a/packages/stream_core/test/user/token_manager_test.dart b/packages/stream_core/test/user/token_manager_test.dart index b4d73c3f..cdebedd5 100644 --- a/packages/stream_core/test/user/token_manager_test.dart +++ b/packages/stream_core/test/user/token_manager_test.dart @@ -25,7 +25,7 @@ void main() { group('TokenManager', () { group('getToken', () { test('loads from the provider and caches the result', () async { - final provider = _CountingProvider((_) async => generateTestUserToken('token-1')); + final provider = _CountingProvider((_) async => generateTestUserToken('user-1')); final manager = TokenManager( userId: 'user-1', tokenProvider: provider, @@ -34,17 +34,17 @@ void main() { final first = await manager.getToken(); final second = await manager.getToken(); - expect(first, generateTestUserToken('token-1')); - expect(second, generateTestUserToken('token-1')); + expect(first, generateTestUserToken('user-1')); + expect(second, generateTestUserToken('user-1')); expect(provider.loadCount, 1); - expect(manager.peekToken(), generateTestUserToken('token-1')); + expect(manager.peekToken(), generateTestUserToken('user-1')); }); test('passes the manager userId to the provider', () async { String? requestedUserId; final provider = _CountingProvider((userId) async { requestedUserId = userId; - return generateTestUserToken('token-1'); + return generateTestUserToken(userId); }); final manager = TokenManager( userId: 'user-1', @@ -65,10 +65,10 @@ void main() { ); final futures = [manager.getToken(), manager.getToken()]; - completer.complete(generateTestUserToken('token-1')); + completer.complete(generateTestUserToken('user-1')); final tokens = await Future.wait(futures); - expect(tokens, everyElement(generateTestUserToken('token-1'))); + expect(tokens, everyElement(generateTestUserToken('user-1'))); expect(provider.loadCount, 1); }); @@ -77,7 +77,7 @@ void main() { final provider = _CountingProvider((_) async { attempts++; if (attempts == 1) throw StateError('load failed'); - return generateTestUserToken('token-2'); + return generateTestUserToken('user-1'); }); final manager = TokenManager( userId: 'user-1', @@ -88,7 +88,7 @@ void main() { expect(manager.peekToken(), isNull); final token = await manager.getToken(); - expect(token, generateTestUserToken('token-2')); + expect(token, generateTestUserToken('user-1')); expect(provider.loadCount, 2); }); }); @@ -96,18 +96,20 @@ void main() { group('expireToken', () { test('clears the cache and forces a reload', () async { var version = 0; - final provider = _CountingProvider((_) async => generateTestUserToken('v${++version}')); + final provider = _CountingProvider( + (userId) async => generateTestUserToken(userId, nonce: 'v${++version}'), + ); final manager = TokenManager( userId: 'user-1', tokenProvider: provider, ); - expect(await manager.getToken(), generateTestUserToken('v1')); + expect(await manager.getToken(), generateTestUserToken('user-1', nonce: 'v1')); manager.expireToken(); expect(manager.peekToken(), isNull); - expect(await manager.getToken(), generateTestUserToken('v2')); + expect(await manager.getToken(), generateTestUserToken('user-1', nonce: 'v2')); expect(provider.loadCount, 2); }); @@ -319,9 +321,39 @@ void main() { manager.reset(); completer.complete(generateTestUserToken('user-1')); - // The caller that started the load is still served, but its token is - // not cached over the reset. - expect((await inFlight).userId, 'user-1'); + // A reset is a logout: the token is neither cached nor handed to the + // caller, so no request goes out as a user the manager no longer has. + await expectLater(inFlight, throwsA(isA())); + expect(manager.peekToken(), isNull); + }); + }); + + group('setTokenProvider', () { + test('keeps the cached token when re-set with the same identity', () async { + final provider = _CountingProvider((userId) async => generateTestUserToken(userId)); + final manager = TokenManager(userId: 'user-1', tokenProvider: provider); + await manager.getToken(); + + manager.setTokenProvider('user-1', tokenProvider: provider); + + // Expiring here would send a caller to the provider for an identity it + // already has, which a defensive re-set on reconnect does routinely. + expect(manager.peekToken(), isNotNull); + await manager.getToken(); + expect(provider.loadCount, 1); + }); + }); + + group('_loadAndNotify', () { + test('rejects a token a custom provider issued for another user', () async { + // Neither built-in provider can do this, but `TokenProvider` is an + // interface: caching it would authenticate later requests as them. + final manager = TokenManager( + userId: 'user-1', + tokenProvider: _CountingProvider((_) async => generateTestUserToken('someone-else')), + ); + + await expectLater(manager.getToken(), throwsArgumentError); expect(manager.peekToken(), isNull); }); }); @@ -346,7 +378,9 @@ void main() { test('fires once per load with the loaded token', () async { final updates = []; var version = 0; - final provider = _CountingProvider((_) async => generateTestUserToken('v${++version}')); + final provider = _CountingProvider( + (userId) async => generateTestUserToken(userId, nonce: 'v${++version}'), + ); final manager = TokenManager( userId: 'user-1', tokenProvider: provider, @@ -359,14 +393,17 @@ void main() { manager.expireToken(); await manager.getToken(); - expect(updates, [generateTestUserToken('v1'), generateTestUserToken('v2')]); + expect(updates, [ + generateTestUserToken('user-1', nonce: 'v1'), + generateTestUserToken('user-1', nonce: 'v2'), + ]); }); test('is invoked before the token is returned', () async { UserToken? notified; final manager = TokenManager( userId: 'user-1', - tokenProvider: _CountingProvider((_) async => generateTestUserToken('token-1')), + tokenProvider: _CountingProvider((userId) async => generateTestUserToken(userId)), onTokenUpdated: (token) => notified = token, ); @@ -380,7 +417,7 @@ void main() { Future? reentrantCall; manager = TokenManager( userId: 'user-1', - tokenProvider: _CountingProvider((_) async => generateTestUserToken('token-1')), + tokenProvider: _CountingProvider((userId) async => generateTestUserToken(userId)), onTokenUpdated: (_) { reentrantCall = manager.getToken(); }, From 75411444610adae020f3f0667e1cf411eb24a6c7 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Thu, 20 Aug 2026 16:59:04 +0200 Subject: [PATCH 20/27] fix(llc): keep the token-expired error when there is no user to refresh for MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `AuthInterceptor.onError` asked `usesStaticProvider` to decide whether a token-expired error was worth retrying. On a manager that has been `reset` that is `false` — correct for the name, wrong for the question — so the interceptor expired the token and retried, the retry's `getToken` failed for want of an identity, and the caller was handed "Failed to load auth token" in place of the token-expired error the server actually sent. It now asks what it means: there must be a user to load a token for, and a provider capable of returning a different one. Co-Authored-By: Claude Opus 5 (1M context) --- .../api/interceptors/auth_interceptor.dart | 6 ++-- .../interceptors/auth_interceptor_test.dart | 33 +++++++++++++++++++ 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/packages/stream_core/lib/src/api/interceptors/auth_interceptor.dart b/packages/stream_core/lib/src/api/interceptors/auth_interceptor.dart index f810f44e..ba19df0f 100644 --- a/packages/stream_core/lib/src/api/interceptors/auth_interceptor.dart +++ b/packages/stream_core/lib/src/api/interceptors/auth_interceptor.dart @@ -57,8 +57,10 @@ class AuthInterceptor extends QueuedInterceptor { final error = StreamApiError.fromJson(data); if (error.isTokenExpiredError) { - // Don't try to refresh the token if we're using a static provider - if (_tokenManager.usesStaticProvider) return handler.next(err); + // Don't try to refresh the token when there is no user to load one for, + // or when the provider would return the same token again. + final canRefresh = _tokenManager.userId != null && !_tokenManager.usesStaticProvider; + if (!canRefresh) return handler.next(err); // Otherwise, mark the current token as expired. _tokenManager.expireToken(); diff --git a/packages/stream_core/test/api/interceptors/auth_interceptor_test.dart b/packages/stream_core/test/api/interceptors/auth_interceptor_test.dart index deff8dec..5117099b 100644 --- a/packages/stream_core/test/api/interceptors/auth_interceptor_test.dart +++ b/packages/stream_core/test/api/interceptors/auth_interceptor_test.dart @@ -256,6 +256,39 @@ void main() { }, ); + test( + 'forwards a token-expired error without retrying when the manager has no ' + 'identity left to load a token for', + () async { + final tokenManager = TokenManager( + userId: 'user-123', + tokenProvider: TokenProvider.dynamic( + (userId) async => generateTestUserToken(userId), + ), + ); + + final dio = Dio(BaseOptions(baseUrl: 'https://example.com')); + final adapter = _TokenExpiredHttpClientAdapter(onFetch: tokenManager.reset); + dio.httpClientAdapter = adapter; + dio.interceptors.add(AuthInterceptor(dio, tokenManager)); + + await expectLater( + dio.get('/test'), + throwsA( + // Retrying would replace this with the failure to load a token for + // a user the manager no longer has, which says less. + isA().having( + (it) => (it.response?.data as Map?)?['code'], + 'the original token-expired error', + 40, + ), + ), + ); + + expect(adapter.requestCount, 1); + }, + ); + test( 'forwards a token-expired error without retrying when the token manager ' 'is pointed at a static provider after the request was dispatched ' From 49f63f379c60b05a7fa4c33711d20953188f182f Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Thu, 20 Aug 2026 16:59:04 +0200 Subject: [PATCH 21/27] docs(llc): record the behaviour changes raised in review The anonymous `user_id=!anon` query parameter is wire-visible and was not in the changelog: the value used to come from the `TokenManager`, so it was whatever the caller configured. The server requires the token's claim to be `!anon` and derives the anonymous session itself, so sending it is consistent rather than merely harmless. Adds the entries for this round of review fixes, and makes the `!anon` claim requirement on `UserToken.anonymous(rawValue:)` explicit. Co-Authored-By: Claude Opus 5 (1M context) --- packages/stream_core/CHANGELOG.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/stream_core/CHANGELOG.md b/packages/stream_core/CHANGELOG.md index baeeb305..d3458136 100644 --- a/packages/stream_core/CHANGELOG.md +++ b/packages/stream_core/CHANGELOG.md @@ -10,7 +10,7 @@ - Added `TokenManager.setTokenProvider`, which points an existing manager at another user and expires the cached token - Added optional `onTokenUpdated` callback to `TokenManager`, invoked after every successful token load -- Added optional `rawValue` to `UserToken.anonymous`, so an anonymous token can carry a JWT granting restricted access, provided its `user_id` claim is `User.anonymousUserId` +- Added optional `rawValue` to `UserToken.anonymous`, so an anonymous token can carry a JWT granting restricted access, provided its `user_id` claim is `User.anonymousUserId` (`!anon`), which the server also requires - Added `User.anonymousUserId`, the id every anonymous user has - Added `TokenManager.unconfigured`, for a client that exists before its user does - Added `TokenManager.reset`, which drops the configured identity and its cached token @@ -26,6 +26,12 @@ - Raised the minimum Dart SDK to `^3.12.0` - `User` now asserts that a user of type `UserType.anonymous` carries `User.anonymousUserId` as its id +- Anonymous requests now always send `user_id=!anon`. The value previously came from the `TokenManager`, so it was whatever the caller configured; the server requires the claim to be `!anon` and derives the anonymous session itself, so the parameter now matches +- `DynamicTokenProvider` checks the token type before its user id, so a token of the wrong type is reported as such instead of as a mismatched user +- `TokenManager.setTokenProvider` does nothing when handed the identity it already has, instead of expiring the cached token +- `TokenManager.getToken` fails when `reset` runs while the token is loading, instead of returning a token for a user the manager no longer has. A `setTokenProvider` during a load still serves the caller that started it +- `TokenManager.getToken` rejects a token whose `user_id` is not the user it was loading for, which a custom `TokenProvider` is not obliged to check itself +- `AuthInterceptor` no longer attempts a token refresh when the manager has no identity, so the original token-expired error is surfaced rather than a failure to load a token ## 0.4.0 From c03f7fd16be23f871aa8ba9699b8727be4cca167 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Thu, 20 Aug 2026 17:33:41 +0200 Subject: [PATCH 22/27] fix(llc): compare a replacement provider by instance, not by equality MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The no-op guard added for a defensive re-set compared the whole identity record, which delegates the provider to `TokenProvider.operator ==`. A provider defines its own equality — `TokenProvider` is an interface, so one may well compare by value — and a replacement that calls itself equal to the outgoing provider would be dropped along with the cache invalidation it was meant to trigger, leaving the manager serving the previous provider's token. The user id is still compared by value; the provider now by instance, which is the case the guard exists for: the same instance handed back on a reconnect. Erring the other way costs a token load that was not needed; erring this way authenticates as the wrong provider's token. Also lists the `User` anonymous-id invariant as a breaking change rather than a behavioural note. Its constructor is `const`, so a mismatch in a const context does not throw in debug mode — it fails to compile: error - Evaluation of this constant expression throws an exception const_eval_throws_exception Co-Authored-By: Claude Opus 5 (1M context) --- packages/stream_core/CHANGELOG.md | 2 +- .../lib/src/user/token_manager.dart | 13 ++++--- .../test/user/token_manager_test.dart | 38 +++++++++++++++++++ 3 files changed, 47 insertions(+), 6 deletions(-) diff --git a/packages/stream_core/CHANGELOG.md b/packages/stream_core/CHANGELOG.md index d3458136..9983e74f 100644 --- a/packages/stream_core/CHANGELOG.md +++ b/packages/stream_core/CHANGELOG.md @@ -5,6 +5,7 @@ - Removed the `userId` parameter from `UserToken.anonymous`, anonymous tokens always use `User.anonymousUserId` - Removed the `TokenManager.tokenProvider` setter, use `setTokenProvider` instead - `TokenManager.userId` is now nullable, and is `null` until an identity is configured +- `User` now requires a user of type `UserType.anonymous` to carry `User.anonymousUserId` as its id. The constructor is `const`, so a mismatch in a const context fails to compile rather than throwing in debug mode ### ✨ Features @@ -25,7 +26,6 @@ ### 🔄 Changed - Raised the minimum Dart SDK to `^3.12.0` -- `User` now asserts that a user of type `UserType.anonymous` carries `User.anonymousUserId` as its id - Anonymous requests now always send `user_id=!anon`. The value previously came from the `TokenManager`, so it was whatever the caller configured; the server requires the claim to be `!anon` and derives the anonymous session itself, so the parameter now matches - `DynamicTokenProvider` checks the token type before its user id, so a token of the wrong type is reported as such instead of as a mismatched user - `TokenManager.setTokenProvider` does nothing when handed the identity it already has, instead of expiring the cached token diff --git a/packages/stream_core/lib/src/user/token_manager.dart b/packages/stream_core/lib/src/user/token_manager.dart index a9abf140..efff2064 100644 --- a/packages/stream_core/lib/src/user/token_manager.dart +++ b/packages/stream_core/lib/src/user/token_manager.dart @@ -98,16 +98,19 @@ class TokenManager { /// ``` /// Re-setting the identity this manager already has does nothing: expiring /// the cached token would send the next caller to the provider for no reason. - /// Note that a [TokenProvider] compares by identity, so this only applies - /// when the same instance is passed again. + /// The provider is compared by instance, so this only applies when the same + /// one is passed again. void setTokenProvider( String userId, { required TokenProvider tokenProvider, }) { - final identity = (userId: userId, provider: tokenProvider); - if (_identity == identity) return; + // Compared with `identical` rather than `==`: a provider defines its own + // equality, and one that calls itself equal to another would keep the + // provider and the cached token this call means to replace. + final unchanged = userId == this.userId && identical(tokenProvider, _identity?.provider); + if (unchanged) return; - _identity = identity; + _identity = (userId: userId, provider: tokenProvider); // The cached token belongs to the previous user and provider, so drop it // and let the next `getToken` call load a fresh one. diff --git a/packages/stream_core/test/user/token_manager_test.dart b/packages/stream_core/test/user/token_manager_test.dart index cdebedd5..36624d50 100644 --- a/packages/stream_core/test/user/token_manager_test.dart +++ b/packages/stream_core/test/user/token_manager_test.dart @@ -1,10 +1,29 @@ import 'dart:async'; +import 'package:meta/meta.dart'; import 'package:stream_core/stream_core.dart'; import 'package:test/test.dart'; import '../helpers/user_token.dart'; +/// A token provider that reports itself equal to any other of its kind, the way +/// a provider with value equality can. +@immutable +class _EquatableProvider implements TokenProvider { + const _EquatableProvider(this._token); + + final UserToken _token; + + @override + Future loadToken(String userId) async => _token; + + @override + bool operator ==(Object other) => other is _EquatableProvider; + + @override + int get hashCode => 0; +} + /// A token provider that counts loads and delegates to a configurable loader. class _CountingProvider implements TokenProvider { _CountingProvider(this._load); @@ -342,6 +361,25 @@ void main() { await manager.getToken(); expect(provider.loadCount, 1); }); + + test('replaces a provider that merely compares equal to the previous one', () async { + final manager = TokenManager( + userId: 'user-1', + tokenProvider: _EquatableProvider(generateTestUserToken('user-1', nonce: 'first')), + ); + expect(await manager.getToken(), generateTestUserToken('user-1', nonce: 'first')); + + manager.setTokenProvider( + 'user-1', + tokenProvider: _EquatableProvider(generateTestUserToken('user-1', nonce: 'second')), + ); + + // A provider defines its own equality, so keeping the cached token + // because the replacement called itself equal would serve a token the + // previous provider issued. + expect(manager.peekToken(), isNull); + expect(await manager.getToken(), generateTestUserToken('user-1', nonce: 'second')); + }); }); group('_loadAndNotify', () { From 0605c016356b2a45c987569a78702529c796798a Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Thu, 20 Aug 2026 17:41:31 +0200 Subject: [PATCH 23/27] feat(llc): bound a token load so one provider cannot block the rest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `getToken` serialises loads through `synchronized`, so a provider that never returns held the lock for good: every later caller waited with it, including one for a different user that `setTokenProvider` had since configured. The thing that hangs is usually a customer's own token endpoint, so it is not an exotic case — it predates this PR, but `setTokenProvider` is what makes it reachable for a user who has nothing to do with the hung request. A load now fails with a `ClientException` after `loadTimeout`, ten seconds by default and configurable per manager. Dart cannot cancel the provider, so a slow one keeps running; what changes is that it no longer holds the lock, and the cache is invalidated as it gives up so whatever the abandoned load eventually returns is discarded rather than served to a later caller. Adds `fake_async` as a dev dependency, so the timeout tests do not spend ten seconds each. Co-Authored-By: Claude Opus 5 (1M context) --- melos.yaml | 1 + packages/stream_core/CHANGELOG.md | 1 + .../lib/src/user/token_manager.dart | 39 +++++++++++-- packages/stream_core/pubspec.yaml | 1 + .../test/user/token_manager_test.dart | 58 +++++++++++++++++++ 5 files changed, 95 insertions(+), 5 deletions(-) diff --git a/melos.yaml b/melos.yaml index adda26e6..b2641fe8 100644 --- a/melos.yaml +++ b/melos.yaml @@ -53,6 +53,7 @@ command: dev_dependencies: alchemist: ^0.13.0 build_runner: ^2.10.5 + fake_async: ^1.3.3 json_serializable: ^6.9.5 melos: ^6.2.0 mocktail: ^1.0.4 diff --git a/packages/stream_core/CHANGELOG.md b/packages/stream_core/CHANGELOG.md index 9983e74f..8e8b20aa 100644 --- a/packages/stream_core/CHANGELOG.md +++ b/packages/stream_core/CHANGELOG.md @@ -15,6 +15,7 @@ - Added `User.anonymousUserId`, the id every anonymous user has - Added `TokenManager.unconfigured`, for a client that exists before its user does - Added `TokenManager.reset`, which drops the configured identity and its cached token +- Added `TokenManager.loadTimeout`, which bounds a single token load and defaults to `TokenManager.defaultLoadTimeout`. Loads are serialised, so a provider that never returned used to block every later caller indefinitely - Added `teams` field to `User` class ### 🐛 Bug Fixes diff --git a/packages/stream_core/lib/src/user/token_manager.dart b/packages/stream_core/lib/src/user/token_manager.dart index efff2064..0f215bb3 100644 --- a/packages/stream_core/lib/src/user/token_manager.dart +++ b/packages/stream_core/lib/src/user/token_manager.dart @@ -42,9 +42,12 @@ class TokenManager { /// /// An optional `onTokenUpdated` callback is invoked after every successful /// token load. It is not invoked for callers served from the cache. + /// + /// `loadTimeout` bounds a single load; see [getToken]. TokenManager({ required String userId, required TokenProvider tokenProvider, + this.loadTimeout = defaultLoadTimeout, this._onTokenUpdated, }) : _identity = (userId: userId, provider: tokenProvider); @@ -53,7 +56,18 @@ class TokenManager { /// [getToken] fails until [setTokenProvider] supplies one. Distinct from a /// manager holding an anonymous identity, which is a user that can load a /// token; this one has no user at all. - TokenManager.unconfigured({this._onTokenUpdated}) : _identity = null; + TokenManager.unconfigured({ + this.loadTimeout = defaultLoadTimeout, + this._onTokenUpdated, + }) : _identity = null; + + /// How long a single token load may take before it fails. + /// + /// Loads are serialised, so an unbounded one would block every later caller. + final Duration loadTimeout; + + /// The [loadTimeout] used when none is given. + static const defaultLoadTimeout = Duration(seconds: 10); // The user being managed and the provider that loads their tokens. // @@ -162,9 +176,11 @@ class TokenManager { /// because [reset] dropped the previous one, and when [reset] runs while the /// token is loading. /// - /// Loads are serialised, so a provider that never returns blocks every later - /// caller — including one for a different user configured by - /// [setTokenProvider] in the meantime. + /// Loads are serialised, so a load is given [loadTimeout] to finish and fails + /// with a [ClientException] once it elapses. Dart cannot cancel the provider, + /// so a slow one keeps running — but it no longer holds up the callers behind + /// it, including one for a different user configured by [setTokenProvider] in + /// the meantime, and whatever it eventually returns is discarded. Future getToken() { final cached = _cachedToken; if (cached != null) return Future.value(cached); @@ -187,7 +203,20 @@ class TokenManager { final loadingFor = identity.userId; final loadingGeneration = _generation; - final updatedToken = await identity.provider.loadToken(loadingFor); + final updatedToken = await identity.provider + .loadToken(loadingFor) + .timeout( + loadTimeout, + onTimeout: () { + // Invalidates the cache as well as failing, so the load this gave up on + // cannot cache a token later on. + expireToken(); + + throw ClientException( + message: 'Timed out after $loadTimeout loading a token for "$loadingFor"', + ); + }, + ); // Both built-in providers check this, but a custom one is under no // obligation to, and caching a token for another user would authenticate diff --git a/packages/stream_core/pubspec.yaml b/packages/stream_core/pubspec.yaml index c28d1a61..7fa4b4fa 100644 --- a/packages/stream_core/pubspec.yaml +++ b/packages/stream_core/pubspec.yaml @@ -37,6 +37,7 @@ dependencies: dev_dependencies: build_runner: ^2.10.5 + fake_async: ^1.3.3 json_serializable: ^6.9.5 mocktail: ^1.0.4 test: ^1.26.2 diff --git a/packages/stream_core/test/user/token_manager_test.dart b/packages/stream_core/test/user/token_manager_test.dart index 36624d50..c3497001 100644 --- a/packages/stream_core/test/user/token_manager_test.dart +++ b/packages/stream_core/test/user/token_manager_test.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:fake_async/fake_async.dart'; import 'package:meta/meta.dart'; import 'package:stream_core/stream_core.dart'; import 'package:test/test.dart'; @@ -382,6 +383,63 @@ void main() { }); }); + group('loadTimeout', () { + test('fails a load that never returns and lets later callers through', () { + fakeAsync((async) { + final manager = TokenManager( + userId: 'user-1', + tokenProvider: _CountingProvider((_) => Completer().future), + loadTimeout: const Duration(seconds: 5), + ); + + Object? error; + manager.getToken().onError((it, _) { + error = it; + return generateTestUserToken('user-1'); + }); + + async.elapse(const Duration(seconds: 5)); + async.flushMicrotasks(); + + expect(error, isA()); + + // The point of failing rather than waiting: the lock is free, so a + // working provider can serve the next caller. + UserToken? served; + manager.setTokenProvider( + 'user-1', + tokenProvider: _CountingProvider((userId) async => generateTestUserToken(userId)), + ); + manager.getToken().then((it) => served = it); + async.flushMicrotasks(); + + expect(served, generateTestUserToken('user-1')); + }); + }); + + test('discards a token the load it gave up on returns later', () { + fakeAsync((async) { + final slow = Completer(); + final manager = TokenManager( + userId: 'user-1', + tokenProvider: _CountingProvider((_) => slow.future), + loadTimeout: const Duration(seconds: 5), + ); + + manager.getToken().ignore(); + async.elapse(const Duration(seconds: 5)); + async.flushMicrotasks(); + + slow.complete(generateTestUserToken('user-1')); + async.flushMicrotasks(); + + // Caching it would hand a caller a token from a load already reported + // as failed. + expect(manager.peekToken(), isNull); + }); + }); + }); + group('_loadAndNotify', () { test('rejects a token a custom provider issued for another user', () async { // Neither built-in provider can do this, but `TokenProvider` is an From 19a1a91b024e370e6c64bbfc13d042afa6efc64c Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Thu, 20 Aug 2026 17:54:46 +0200 Subject: [PATCH 24/27] Revert "feat(llc): bound a token load so one provider cannot block the rest" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 0605c01, keeping the `fake_async` dev dependency it added since #160 uses it. The timeout was the wrong instrument. The failure it was meant to address is a load for the user who is gone blocking the user who replaced them, and its cause is that `getToken` serialises across identities — not that a load takes too long. A timeout papers over that by failing everyone once it elapses, including the caller who did nothing wrong, and imposes a default on a token endpoint whose timeout the customer already owns: shorter than theirs, and it silently fails loads that would have succeeded. The serialisation remains documented on `getToken`, which was what review asked for as a minimum. The targeted fix, if we want one, is a lock per identity, so a hang for the departed user cannot hold up the one that replaced them. Co-Authored-By: Claude Opus 5 (1M context) --- packages/stream_core/CHANGELOG.md | 1 - .../lib/src/user/token_manager.dart | 39 ++----------- .../test/user/token_manager_test.dart | 58 ------------------- 3 files changed, 5 insertions(+), 93 deletions(-) diff --git a/packages/stream_core/CHANGELOG.md b/packages/stream_core/CHANGELOG.md index 8e8b20aa..9983e74f 100644 --- a/packages/stream_core/CHANGELOG.md +++ b/packages/stream_core/CHANGELOG.md @@ -15,7 +15,6 @@ - Added `User.anonymousUserId`, the id every anonymous user has - Added `TokenManager.unconfigured`, for a client that exists before its user does - Added `TokenManager.reset`, which drops the configured identity and its cached token -- Added `TokenManager.loadTimeout`, which bounds a single token load and defaults to `TokenManager.defaultLoadTimeout`. Loads are serialised, so a provider that never returned used to block every later caller indefinitely - Added `teams` field to `User` class ### 🐛 Bug Fixes diff --git a/packages/stream_core/lib/src/user/token_manager.dart b/packages/stream_core/lib/src/user/token_manager.dart index 0f215bb3..efff2064 100644 --- a/packages/stream_core/lib/src/user/token_manager.dart +++ b/packages/stream_core/lib/src/user/token_manager.dart @@ -42,12 +42,9 @@ class TokenManager { /// /// An optional `onTokenUpdated` callback is invoked after every successful /// token load. It is not invoked for callers served from the cache. - /// - /// `loadTimeout` bounds a single load; see [getToken]. TokenManager({ required String userId, required TokenProvider tokenProvider, - this.loadTimeout = defaultLoadTimeout, this._onTokenUpdated, }) : _identity = (userId: userId, provider: tokenProvider); @@ -56,18 +53,7 @@ class TokenManager { /// [getToken] fails until [setTokenProvider] supplies one. Distinct from a /// manager holding an anonymous identity, which is a user that can load a /// token; this one has no user at all. - TokenManager.unconfigured({ - this.loadTimeout = defaultLoadTimeout, - this._onTokenUpdated, - }) : _identity = null; - - /// How long a single token load may take before it fails. - /// - /// Loads are serialised, so an unbounded one would block every later caller. - final Duration loadTimeout; - - /// The [loadTimeout] used when none is given. - static const defaultLoadTimeout = Duration(seconds: 10); + TokenManager.unconfigured({this._onTokenUpdated}) : _identity = null; // The user being managed and the provider that loads their tokens. // @@ -176,11 +162,9 @@ class TokenManager { /// because [reset] dropped the previous one, and when [reset] runs while the /// token is loading. /// - /// Loads are serialised, so a load is given [loadTimeout] to finish and fails - /// with a [ClientException] once it elapses. Dart cannot cancel the provider, - /// so a slow one keeps running — but it no longer holds up the callers behind - /// it, including one for a different user configured by [setTokenProvider] in - /// the meantime, and whatever it eventually returns is discarded. + /// Loads are serialised, so a provider that never returns blocks every later + /// caller — including one for a different user configured by + /// [setTokenProvider] in the meantime. Future getToken() { final cached = _cachedToken; if (cached != null) return Future.value(cached); @@ -203,20 +187,7 @@ class TokenManager { final loadingFor = identity.userId; final loadingGeneration = _generation; - final updatedToken = await identity.provider - .loadToken(loadingFor) - .timeout( - loadTimeout, - onTimeout: () { - // Invalidates the cache as well as failing, so the load this gave up on - // cannot cache a token later on. - expireToken(); - - throw ClientException( - message: 'Timed out after $loadTimeout loading a token for "$loadingFor"', - ); - }, - ); + final updatedToken = await identity.provider.loadToken(loadingFor); // Both built-in providers check this, but a custom one is under no // obligation to, and caching a token for another user would authenticate diff --git a/packages/stream_core/test/user/token_manager_test.dart b/packages/stream_core/test/user/token_manager_test.dart index c3497001..36624d50 100644 --- a/packages/stream_core/test/user/token_manager_test.dart +++ b/packages/stream_core/test/user/token_manager_test.dart @@ -1,6 +1,5 @@ import 'dart:async'; -import 'package:fake_async/fake_async.dart'; import 'package:meta/meta.dart'; import 'package:stream_core/stream_core.dart'; import 'package:test/test.dart'; @@ -383,63 +382,6 @@ void main() { }); }); - group('loadTimeout', () { - test('fails a load that never returns and lets later callers through', () { - fakeAsync((async) { - final manager = TokenManager( - userId: 'user-1', - tokenProvider: _CountingProvider((_) => Completer().future), - loadTimeout: const Duration(seconds: 5), - ); - - Object? error; - manager.getToken().onError((it, _) { - error = it; - return generateTestUserToken('user-1'); - }); - - async.elapse(const Duration(seconds: 5)); - async.flushMicrotasks(); - - expect(error, isA()); - - // The point of failing rather than waiting: the lock is free, so a - // working provider can serve the next caller. - UserToken? served; - manager.setTokenProvider( - 'user-1', - tokenProvider: _CountingProvider((userId) async => generateTestUserToken(userId)), - ); - manager.getToken().then((it) => served = it); - async.flushMicrotasks(); - - expect(served, generateTestUserToken('user-1')); - }); - }); - - test('discards a token the load it gave up on returns later', () { - fakeAsync((async) { - final slow = Completer(); - final manager = TokenManager( - userId: 'user-1', - tokenProvider: _CountingProvider((_) => slow.future), - loadTimeout: const Duration(seconds: 5), - ); - - manager.getToken().ignore(); - async.elapse(const Duration(seconds: 5)); - async.flushMicrotasks(); - - slow.complete(generateTestUserToken('user-1')); - async.flushMicrotasks(); - - // Caching it would hand a caller a token from a load already reported - // as failed. - expect(manager.peekToken(), isNull); - }); - }); - }); - group('_loadAndNotify', () { test('rejects a token a custom provider issued for another user', () async { // Neither built-in provider can do this, but `TokenProvider` is an From 4253b38fceeed11e0f55f0a143872aa914edc8b9 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Thu, 20 Aug 2026 18:40:42 +0200 Subject: [PATCH 25/27] docs(llc): say that a provider's equality is never consulted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `setTokenProvider` compares provider instances, so whether a `TokenProvider` defines `==` makes no difference to it — a provider has no reason to implement equality for the manager's sake, and none can talk it into keeping a token the replacement was meant to supersede. Probing both comparisons showed why that is the right way round. Against every provider that ships the two are indistinguishable, since neither built-in defines `==`. Where they differ, value equality buys one avoided token load in the case where the credentials match anyway, and costs a stale token in the case where they do not: a provider comparing the endpoint it loads from — a perfectly reasonable thing to write — reports itself equal while carrying a refreshed token, and the manager would serve the old one. The test provider is renamed to say what it is for: it claims to equal anything of its kind, which is the statement the rule has to survive. Co-Authored-By: Claude Opus 5 (1M context) --- .../lib/src/user/token_manager.dart | 9 +++---- .../test/user/token_manager_test.dart | 24 ++++++++++--------- 2 files changed, 18 insertions(+), 15 deletions(-) diff --git a/packages/stream_core/lib/src/user/token_manager.dart b/packages/stream_core/lib/src/user/token_manager.dart index efff2064..1ca9aa24 100644 --- a/packages/stream_core/lib/src/user/token_manager.dart +++ b/packages/stream_core/lib/src/user/token_manager.dart @@ -98,14 +98,15 @@ class TokenManager { /// ``` /// Re-setting the identity this manager already has does nothing: expiring /// the cached token would send the next caller to the provider for no reason. - /// The provider is compared by instance, so this only applies when the same - /// one is passed again. + /// This applies only when the same provider instance is passed again — a + /// provider's own `==` is never consulted, so whether it defines equality + /// makes no difference here. void setTokenProvider( String userId, { required TokenProvider tokenProvider, }) { - // Compared with `identical` rather than `==`: a provider defines its own - // equality, and one that calls itself equal to another would keep the + // Compared with `identical` rather than `==`: equality is the provider's own + // to define, and one that called itself equal to another would keep the // provider and the cached token this call means to replace. final unchanged = userId == this.userId && identical(tokenProvider, _identity?.provider); if (unchanged) return; diff --git a/packages/stream_core/test/user/token_manager_test.dart b/packages/stream_core/test/user/token_manager_test.dart index 36624d50..1856da0e 100644 --- a/packages/stream_core/test/user/token_manager_test.dart +++ b/packages/stream_core/test/user/token_manager_test.dart @@ -6,11 +6,14 @@ import 'package:test/test.dart'; import '../helpers/user_token.dart'; -/// A token provider that reports itself equal to any other of its kind, the way -/// a provider with value equality can. +/// A token provider that claims to equal any other of its kind, whatever +/// credentials it carries. +/// +/// Equality is a provider's own to define, and the manager must not take its +/// word for it — that is what this provider is for. @immutable -class _EquatableProvider implements TokenProvider { - const _EquatableProvider(this._token); +class _AlwaysEqualProvider implements TokenProvider { + const _AlwaysEqualProvider(this._token); final UserToken _token; @@ -18,7 +21,7 @@ class _EquatableProvider implements TokenProvider { Future loadToken(String userId) async => _token; @override - bool operator ==(Object other) => other is _EquatableProvider; + bool operator ==(Object other) => other is _AlwaysEqualProvider; @override int get hashCode => 0; @@ -362,21 +365,20 @@ void main() { expect(provider.loadCount, 1); }); - test('replaces a provider that merely compares equal to the previous one', () async { + test('replaces a provider even when it claims to equal the previous one', () async { final manager = TokenManager( userId: 'user-1', - tokenProvider: _EquatableProvider(generateTestUserToken('user-1', nonce: 'first')), + tokenProvider: _AlwaysEqualProvider(generateTestUserToken('user-1', nonce: 'first')), ); expect(await manager.getToken(), generateTestUserToken('user-1', nonce: 'first')); manager.setTokenProvider( 'user-1', - tokenProvider: _EquatableProvider(generateTestUserToken('user-1', nonce: 'second')), + tokenProvider: _AlwaysEqualProvider(generateTestUserToken('user-1', nonce: 'second')), ); - // A provider defines its own equality, so keeping the cached token - // because the replacement called itself equal would serve a token the - // previous provider issued. + // The manager compares instances, not values, so a provider cannot talk + // it into keeping a token the replacement was meant to supersede. expect(manager.peekToken(), isNull); expect(await manager.getToken(), generateTestUserToken('user-1', nonce: 'second')); }); From 7a196746dd30137a482017a07ada911d23eaa4be Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Thu, 20 Aug 2026 18:49:42 +0200 Subject: [PATCH 26/27] Revert "fix(llc): compare a replacement provider by instance, not by equality" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverts the provider half of c03f7fd, keeping its `User` changelog correction. Comparing with `identical` second-guessed a type's own equality contract. `==` means substitutable: a provider that defines it is declaring that a replacement is the same as what it replaces, and honouring that declaration is the correct behaviour rather than a hazard. One that defines nothing gets identity, which is what the record comparison already did. Both branches are right, so there was nothing to protect against. It was also inconsistent. Dart honours a type's `==` everywhere else that type goes — sets, maps, `contains` — so an equality that claims interchangeability where there is none is a bug that surfaces in all of those, not something for this one call site to work around. And the consequence here was bounded anyway: both tokens must belong to the same user or the load throws, so a retained token either still works or is rejected and refreshed on the next request. The test now pins the contract rather than its opposite: a provider that reports itself unchanged keeps the cached token. Co-Authored-By: Claude Opus 5 (1M context) --- packages/stream_core/CHANGELOG.md | 2 +- .../stream_core/lib/src/user/token_manager.dart | 15 ++++++--------- .../test/user/token_manager_test.dart | 17 +++++++---------- 3 files changed, 14 insertions(+), 20 deletions(-) diff --git a/packages/stream_core/CHANGELOG.md b/packages/stream_core/CHANGELOG.md index 9983e74f..8b86e432 100644 --- a/packages/stream_core/CHANGELOG.md +++ b/packages/stream_core/CHANGELOG.md @@ -28,7 +28,7 @@ - Raised the minimum Dart SDK to `^3.12.0` - Anonymous requests now always send `user_id=!anon`. The value previously came from the `TokenManager`, so it was whatever the caller configured; the server requires the claim to be `!anon` and derives the anonymous session itself, so the parameter now matches - `DynamicTokenProvider` checks the token type before its user id, so a token of the wrong type is reported as such instead of as a mismatched user -- `TokenManager.setTokenProvider` does nothing when handed the identity it already has, instead of expiring the cached token +- `TokenManager.setTokenProvider` does nothing when handed the identity it already has, instead of expiring the cached token. The provider is compared with `==`, so one that defines value equality decides when a replacement counts as the same - `TokenManager.getToken` fails when `reset` runs while the token is loading, instead of returning a token for a user the manager no longer has. A `setTokenProvider` during a load still serves the caller that started it - `TokenManager.getToken` rejects a token whose `user_id` is not the user it was loading for, which a custom `TokenProvider` is not obliged to check itself - `AuthInterceptor` no longer attempts a token refresh when the manager has no identity, so the original token-expired error is surfaced rather than a failure to load a token diff --git a/packages/stream_core/lib/src/user/token_manager.dart b/packages/stream_core/lib/src/user/token_manager.dart index 1ca9aa24..501e97d6 100644 --- a/packages/stream_core/lib/src/user/token_manager.dart +++ b/packages/stream_core/lib/src/user/token_manager.dart @@ -98,20 +98,17 @@ class TokenManager { /// ``` /// Re-setting the identity this manager already has does nothing: expiring /// the cached token would send the next caller to the provider for no reason. - /// This applies only when the same provider instance is passed again — a - /// provider's own `==` is never consulted, so whether it defines equality - /// makes no difference here. + /// The provider is compared with `==`, so a provider that defines value + /// equality decides for itself when a replacement is the same as what it + /// replaces; one that does not is compared by instance. void setTokenProvider( String userId, { required TokenProvider tokenProvider, }) { - // Compared with `identical` rather than `==`: equality is the provider's own - // to define, and one that called itself equal to another would keep the - // provider and the cached token this call means to replace. - final unchanged = userId == this.userId && identical(tokenProvider, _identity?.provider); - if (unchanged) return; + final identity = (userId: userId, provider: tokenProvider); + if (_identity == identity) return; - _identity = (userId: userId, provider: tokenProvider); + _identity = identity; // The cached token belongs to the previous user and provider, so drop it // and let the next `getToken` call load a fresh one. diff --git a/packages/stream_core/test/user/token_manager_test.dart b/packages/stream_core/test/user/token_manager_test.dart index 1856da0e..24ef2ca1 100644 --- a/packages/stream_core/test/user/token_manager_test.dart +++ b/packages/stream_core/test/user/token_manager_test.dart @@ -6,11 +6,8 @@ import 'package:test/test.dart'; import '../helpers/user_token.dart'; -/// A token provider that claims to equal any other of its kind, whatever -/// credentials it carries. -/// -/// Equality is a provider's own to define, and the manager must not take its -/// word for it — that is what this provider is for. +/// A token provider that defines value equality, as an implementation is free +/// to do — here on nothing but its own type. @immutable class _AlwaysEqualProvider implements TokenProvider { const _AlwaysEqualProvider(this._token); @@ -365,7 +362,7 @@ void main() { expect(provider.loadCount, 1); }); - test('replaces a provider even when it claims to equal the previous one', () async { + test('keeps the cached token when the provider says it is unchanged', () async { final manager = TokenManager( userId: 'user-1', tokenProvider: _AlwaysEqualProvider(generateTestUserToken('user-1', nonce: 'first')), @@ -377,10 +374,10 @@ void main() { tokenProvider: _AlwaysEqualProvider(generateTestUserToken('user-1', nonce: 'second')), ); - // The manager compares instances, not values, so a provider cannot talk - // it into keeping a token the replacement was meant to supersede. - expect(manager.peekToken(), isNull); - expect(await manager.getToken(), generateTestUserToken('user-1', nonce: 'second')); + // Equality is a declaration of interchangeability, and it is the + // provider's own to make: this one says the replacement is the same, so + // the cached token stands. + expect(manager.peekToken(), generateTestUserToken('user-1', nonce: 'first')); }); }); From a2e4abedd931fbd6efaf8defa8430952a5b8d743 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Thu, 20 Aug 2026 18:54:49 +0200 Subject: [PATCH 27/27] refactor(llc): name the anonymous token's claim as the user id it is `UserToken` calls the same extraction `userId`; `UserToken.anonymous` called it `claim`. Same value, same line of code, two names. Co-Authored-By: Claude Opus 5 (1M context) --- packages/stream_core/lib/src/user/user_token.dart | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/stream_core/lib/src/user/user_token.dart b/packages/stream_core/lib/src/user/user_token.dart index 32f773aa..8a425ae8 100644 --- a/packages/stream_core/lib/src/user/user_token.dart +++ b/packages/stream_core/lib/src/user/user_token.dart @@ -75,10 +75,10 @@ class UserToken extends Equatable { factory UserToken.anonymous({String rawValue = ''}) { if (rawValue.isNotEmpty) { final jwtBody = JsonWebToken.unverified(rawValue); - final claim = jwtBody.claims.getTyped('user_id'); - if (claim != User.anonymousUserId) { + final userId = jwtBody.claims.getTyped('user_id'); + if (userId != User.anonymousUserId) { throw ArgumentError.value( - claim, + userId, 'rawValue', 'Expected a JWT claiming user_id "${User.anonymousUserId}"', );