diff --git a/STYLE_GUIDE.md b/STYLE_GUIDE.md index 4efd5389..e56ff027 100644 --- a/STYLE_GUIDE.md +++ b/STYLE_GUIDE.md @@ -842,6 +842,14 @@ 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, 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 Organize tests into smaller files grouped by feature, widget, or behavior. Split 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 93f58768..8b86e432 100644 --- a/packages/stream_core/CHANGELOG.md +++ b/packages/stream_core/CHANGELOG.md @@ -1,24 +1,37 @@ ## Upcoming +### 💥 BREAKING CHANGES + +- 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 -- 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, 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 +- 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 `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 -- Raised the minimum Dart SDK to `^3.12.0`. +- 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. 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 ## 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 489ea92d..ba19df0f 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,14 +21,9 @@ class AuthInterceptor extends QueuedInterceptor { RequestInterceptorHandler handler, ) async { try { - final token = await _effectiveTokenManager.getToken(); + final token = await _tokenManager.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. - options.queryParameters['user_id'] = _effectiveTokenManager.userId; + options.queryParameters['user_id'] = token.userId; options.headers['Authorization'] = token.rawValue; options.headers['stream-auth-type'] = token.authType.headerValue; @@ -94,11 +57,12 @@ 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); + // 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(); + _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 4fbb5a82..501e97d6 100644 --- a/packages/stream_core/lib/src/user/token_manager.dart +++ b/packages/stream_core/lib/src/user/token_manager.dart @@ -1,12 +1,14 @@ import 'package:synchronized/extension.dart'; +import '../errors/client_exception.dart'; import 'token_provider.dart'; 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,41 +34,105 @@ 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._tokenProvider, - this.onTokenUpdated, - }); + required String userId, + required TokenProvider tokenProvider, + this._onTokenUpdated, + }) : _identity = (userId: userId, provider: tokenProvider); - /// The unique identifier of the user whose tokens are managed. - final String userId; + /// 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], and returns to `null` after [reset]. + String? get userId => _identity?.userId; + + // 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 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. + /// + /// 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. + /// final manager = TokenManager( + /// userId: User.anonymousUserId, + /// tokenProvider: TokenProvider.static(UserToken.anonymous()), + /// ); + /// + /// // Adopt the identity once it is known. + /// manager.setTokenProvider( + /// userId, + /// 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. + /// 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, + }) { + final identity = (userId: userId, provider: tokenProvider); + if (_identity == identity) return; - /// Invoked after every successful token load. - final OnTokenUpdated? onTokenUpdated; + _identity = identity; - // The provider used to load tokens when needed. - 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. + expireToken(); + } - /// Replaces the provider used to load tokens. + /// Drops the configured identity, returning this manager to the state of + /// [TokenManager.unconfigured]. /// - /// 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; + /// [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; + // 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 @@ -76,8 +142,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. /// @@ -87,6 +154,15 @@ 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, 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); @@ -99,12 +175,43 @@ class TokenManager { }); } - // Loads a token from the provider, caches it, and notifies the - // [onTokenUpdated] callback. + // 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 updatedToken = await _tokenProvider.loadToken(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 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) { + // 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); + _onTokenUpdated?.call(updatedToken); + return updatedToken; } @@ -113,5 +220,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 1f6ddb62..0488a098 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 "$userId", got "${_rawToken.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,32 @@ 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 returned token is a JWT token - if (token.authType == AuthType.jwt) return token; - throw ArgumentError( - 'Token type mismatch: expected jwt, got ${token.authType.headerValue}', - ); + // 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( + 'Token type mismatch: expected ${AuthType.jwt.headerValue}, got ${token.authType.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}"', + ); + } + + return token; } } 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 1dfca446..8a425ae8 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]. @@ -25,7 +27,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 { @@ -36,8 +38,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'); @@ -58,19 +60,34 @@ 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 [User.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 its 'user_id' claim + /// 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 userId = jwtBody.claims.getTyped('user_id'); + if (userId != User.anonymousUserId) { + throw ArgumentError.value( + userId, + 'rawValue', + 'Expected a JWT claiming user_id "${User.anonymousUserId}"', + ); + } + } + return UserToken._( rawValue: rawValue, - userId: userId ?? '!anon', + userId: User.anonymousUserId, authType: AuthType.anonymous, ); } @@ -84,13 +101,13 @@ class UserToken extends Equatable { /// 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 [User.anonymousUserId]. final String userId; /// The authentication type of this token. 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/api/interceptors/auth_interceptor_test.dart b/packages/stream_core/test/api/interceptors/auth_interceptor_test.dart index 37708067..5117099b 100644 --- a/packages/stream_core/test/api/interceptors/auth_interceptor_test.dart +++ b/packages/stream_core/test/api/interceptors/auth_interceptor_test.dart @@ -1,8 +1,11 @@ +import 'dart:async'; 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 { @@ -68,30 +71,16 @@ 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( - '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', tokenProvider: TokenProvider.static( - _generateTestUserToken('user-123'), + generateTestUserToken('user-123'), ), ); @@ -115,59 +104,129 @@ 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 before + // the next request goes out, so nothing is in flight across the swap. + const serverId = 'server-assigned-id'; + + final tokenManager = TokenManager( + userId: User.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)); + + tokenManager.setTokenProvider( + serverId, + tokenProvider: TokenProvider.static(generateTestUserToken(serverId)), + ); + + await dio.get('/test'); + + expect(adapter.lastRequest?.queryParameters['user_id'], serverId); + expect( + adapter.lastRequest?.headers['stream-auth-type'], + AuthType.jwt.headerValue, + ); + }, + ); + + test( + 'sends an anonymous token as an empty Authorization header with the ' + 'anonymous auth type', + () async { + final tokenManager = TokenManager( + userId: User.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)); 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'], - 'server-assigned-id', + User.anonymousUserId, ); }, ); test( - 'uses the current TokenManager userId when nothing swaps it ' - '(regular/anonymous users)', + 'sends a restricted anonymous token as the Authorization header', () async { + final restricted = generateTestUserToken(User.anonymousUserId); final tokenManager = TokenManager( - userId: 'user-123', + userId: User.anonymousUserId, tokenProvider: TokenProvider.static( - _generateTestUserToken('user-123'), + UserToken.anonymous(rawValue: restricted.rawValue), ), ); 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)); await dio.get('/test'); - expect(adapter.lastRequest?.queryParameters['user_id'], 'user-123'); + expect(adapter.lastRequest?.headers['Authorization'], restricted.rawValue); + expect( + adapter.lastRequest?.headers['stream-auth-type'], + AuthType.anonymous.headerValue, + ); + }, + ); + + test( + 'sends the user id of the token it actually sent', + () async { + // 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', + 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 userOneToken = generateTestUserToken('user-1'); + tokenManager.setTokenProvider( + 'user-2', + tokenProvider: TokenProvider.static(generateTestUserToken('user-2')), + ); + slowLoad.complete(userOneToken); + await pending; + + expect(adapter.lastRequest?.queryParameters['user_id'], 'user-1'); + expect( + adapter.lastRequest?.headers['Authorization'], + userOneToken.rawValue, + ); }, ); @@ -178,13 +237,13 @@ 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')); 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'), @@ -197,42 +256,75 @@ 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 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 - // 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. - var tokenManager = TokenManager( + // 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 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'), + (_) async => generateTestUserToken('requested-id'), ), ); 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'), + generateTestUserToken('server-assigned-id'), ), ); }, ); 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); }, 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..457776b4 --- /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. 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, '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, {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 0d3272a9..24ef2ca1 100644 --- a/packages/stream_core/test/user/token_manager_test.dart +++ b/packages/stream_core/test/user/token_manager_test.dart @@ -1,8 +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 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); + + final UserToken _token; + + @override + Future loadToken(String userId) async => _token; + + @override + bool operator ==(Object other) => other is _AlwaysEqualProvider; + + @override + int get hashCode => 0; +} + /// A token provider that counts loads and delegates to a configurable loader. class _CountingProvider implements TokenProvider { _CountingProvider(this._load); @@ -19,13 +40,11 @@ class _CountingProvider implements TokenProvider { } } -UserToken _token(String value) => UserToken.anonymous(userId: value); - 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('user-1')); final manager = TokenManager( userId: 'user-1', tokenProvider: provider, @@ -34,17 +53,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('user-1')); + expect(second, generateTestUserToken('user-1')); expect(provider.loadCount, 1); - expect(manager.peekToken(), _token('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 _token('token-1'); + return generateTestUserToken(userId); }); final manager = TokenManager( userId: 'user-1', @@ -65,10 +84,10 @@ void main() { ); final futures = [manager.getToken(), manager.getToken()]; - completer.complete(_token('token-1')); + completer.complete(generateTestUserToken('user-1')); final tokens = await Future.wait(futures); - expect(tokens, everyElement(_token('token-1'))); + expect(tokens, everyElement(generateTestUserToken('user-1'))); expect(provider.loadCount, 1); }); @@ -77,7 +96,7 @@ void main() { final provider = _CountingProvider((_) async { attempts++; if (attempts == 1) throw StateError('load failed'); - return _token('token-2'); + return generateTestUserToken('user-1'); }); final manager = TokenManager( userId: 'user-1', @@ -88,7 +107,7 @@ void main() { expect(manager.peekToken(), isNull); final token = await manager.getToken(); - expect(token, _token('token-2')); + expect(token, generateTestUserToken('user-1')); expect(provider.loadCount, 2); }); }); @@ -96,65 +115,283 @@ 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( + (userId) async => generateTestUserToken(userId, nonce: 'v${++version}'), + ); final manager = TokenManager( userId: 'user-1', tokenProvider: provider, ); - expect(await manager.getToken(), _token('v1')); + expect(await manager.getToken(), generateTestUserToken('user-1', nonce: 'v1')); manager.expireToken(); expect(manager.peekToken(), isNull); - expect(await manager.getToken(), _token('v2')); + expect(await manager.getToken(), generateTestUserToken('user-1', nonce: 'v2')); expect(provider.loadCount, 2); }); + + test( + 'discards a load in flight', + () async { + final slowLoad = Completer(); + final manager = TokenManager( + userId: 'user-1', + tokenProvider: _CountingProvider((_) => slowLoad.future), + ); + + final pending = manager.getToken(); + + manager.expireToken(); + slowLoad.complete(generateTestUserToken('user-1')); + await pending; + + expect(manager.peekToken(), isNull); + }, + ); }); - 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(generateTestUserToken('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(generateTestUserToken('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(generateTestUserToken('user-1')), ); await manager.getToken(); - manager.tokenProvider = provider; + expect(manager.peekToken(), generateTestUserToken('user-1')); + + manager.setTokenProvider( + 'user-2', + tokenProvider: TokenProvider.static(generateTestUserToken('user-2')), + ); + + expect(manager.peekToken(), isNull); + }); - expect(manager.peekToken(), _token('token-1')); + test( + 'adopts a user id and token that were not known up front', + () async { + const serverId = 'guest-abc-guest-123'; + + final manager = TokenManager( + userId: User.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(generateTestUserToken(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(generateTestUserToken('user-2')), + ); + slowLoad.complete(generateTestUserToken('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 swapped provider', () { + test( + 'discards a load in flight when only the provider changes', + () 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(generateTestUserToken('user-1')), + ); + slowLoad.complete(generateTestUserToken('user-1')); + await pending; + + expect(manager.peekToken(), isNull); + }, + ); + + 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.tokenProvider = _CountingProvider((_) async => _token('t')); + manager.setTokenProvider( + 'user-1', + tokenProvider: _CountingProvider((_) async => generateTestUserToken('user-1')), + ); + + expect(manager.usesStaticProvider, isFalse); + }); + }); + + 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')); + + // 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); + }); + + 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')), + ); + expect(await manager.getToken(), generateTestUserToken('user-1', nonce: 'first')); + + manager.setTokenProvider( + 'user-1', + tokenProvider: _AlwaysEqualProvider(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')); + }); + }); + + 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); }); }); @@ -162,11 +399,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); @@ -178,7 +415,9 @@ 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( + (userId) async => generateTestUserToken(userId, nonce: 'v${++version}'), + ); final manager = TokenManager( userId: 'user-1', tokenProvider: provider, @@ -191,14 +430,17 @@ void main() { manager.expireToken(); await manager.getToken(); - expect(updates, [_token('v1'), _token('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 => _token('token-1')), + tokenProvider: _CountingProvider((userId) async => generateTestUserToken(userId)), onTokenUpdated: (token) => notified = token, ); @@ -212,7 +454,7 @@ void main() { Future? reentrantCall; manager = TokenManager( userId: 'user-1', - tokenProvider: _CountingProvider((_) async => _token('token-1')), + tokenProvider: _CountingProvider((userId) async => generateTestUserToken(userId)), 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 a0b9746c..78b2e4ae 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,24 +14,51 @@ void main() { }); test('carries an optional raw value for restricted access', () { - final token = UserToken.anonymous(rawValue: 'restricted-jwt'); + final restricted = generateTestJwt(User.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', + () { + // An anonymous token must not be able to stand in for someone else. + expect( + () => UserToken.anonymous(rawValue: generateTestJwt('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 = 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.anonymous(userId: 'user-1'); + final token = generateTestUserToken('user-1'); final provider = TokenProvider.static(token); expect(() => provider.loadToken('user-2'), throwsArgumentError); @@ -51,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'); @@ -62,10 +79,33 @@ 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); + // 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-1'), + throwsA( + isA().having( + (it) => it.message, + 'message', + contains('Token type mismatch'), + ), + ), + ); }); + + test( + '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'), + ); + + expect(() => provider.loadToken('user-1'), 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'); + }); + }); +}