diff --git a/STYLE_GUIDE.md b/STYLE_GUIDE.md index 4efd5389..0fe5ce0f 100644 --- a/STYLE_GUIDE.md +++ b/STYLE_GUIDE.md @@ -842,6 +842,13 @@ 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: +copies of one 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..fb7fcfe7 100644 --- a/melos.yaml +++ b/melos.yaml @@ -19,6 +19,7 @@ command: # List of all the dependencies used in the project. dependencies: cached_network_image_ce: ^4.9.0 + clock: ^1.1.2 collection: ^1.19.0 cross_file: ^0.3.4+2 dio: ^5.8.0+1 @@ -53,6 +54,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..d71611b1 100644 --- a/packages/stream_core/CHANGELOG.md +++ b/packages/stream_core/CHANGELOG.md @@ -1,24 +1,34 @@ ## 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. A mismatch fails to compile in a const context, and throws in debug mode otherwise + ### ✨ 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; handed the identity it already has, it does nothing +- 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; its `user_id` claim must be `!anon` +- Added `UserToken.expiresAt`, from the token's `exp` claim, and `UserToken.isExpired`, which takes an optional `leeway` +- Added `User.anonymousUserId`, the id every anonymous user has +- Added `TokenManager.unconfigured`, for a client that exists before its user does, and `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 three faults in `TokenManager`'s token cache: `getToken` contacted the provider on every call instead of returning the cached token, handed out a token that had already expired rather than replacing it, and cached one that finished loading after `expireToken` or `setTokenProvider` had invalidated it. A static provider is left alone, having nothing fresher to give +- 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` +- Anonymous requests now always send `user_id=!anon`, rather than whatever id the `TokenManager` was configured with +- `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.getToken` fails when `reset` runs while the token is loading, and rejects a token whose `user_id` is not the user it was loading for; a `setTokenProvider` during a load still serves the caller that started it +- `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..6ff19bc8 100644 --- a/packages/stream_core/lib/src/user/token_manager.dart +++ b/packages/stream_core/lib/src/user/token_manager.dart @@ -1,12 +1,13 @@ 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. typedef OnTokenUpdated = void Function(UserToken token); /// Manages user authentication tokens with caching and thread-safe access. @@ -19,7 +20,7 @@ typedef OnTokenUpdated = void Function(UserToken token); /// ```dart /// final manager = TokenManager( /// userId: 'user-123', -/// tokenProvider: TokenProvider.static(UserToken('jwt-token')), +/// tokenProvider: TokenProvider.static(UserToken(rawJwt)), /// ); /// /// // Get a token (loads and caches if needed) @@ -32,79 +33,165 @@ 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. - /// - /// An optional [onTokenUpdated] callback is invoked after every successful - /// token load. It is not invoked for callers served from the cache. + /// An optional [onTokenUpdated] callback is invoked whenever a loaded token is cached. Not for a + /// caller served from the cache, and not for a load that [expireToken] or [setTokenProvider] + /// invalidated while it ran: that token reaches its caller but is never cached. TokenManager({ - required this.userId, - required this._tokenProvider, - this.onTokenUpdated, - }); + required String userId, + required TokenProvider tokenProvider, + this._onTokenUpdated, + }) : _identity = (userId: userId, provider: tokenProvider); + + /// 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; + + // A single field rather than two, so a user can never be paired with another user's provider. + // `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; - /// The unique identifier of the user whose tokens are managed. - final String userId; + /// 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. Setting the + /// identity it already has does nothing; providers are compared with `==`. + /// + /// Useful to reuse a manager across users, or to adopt an id that is only known after an + /// authenticated request, such as a guest's. + /// + /// ```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)), + /// ); + /// ``` + /// + /// See also: + /// + /// * [reset], which drops the identity rather than replacing it. + 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. Consider this when the + /// user is logging out; to keep the identity and force only a reload, consider [expireToken]. + void reset() { + _identity = null; expireToken(); } // The currently cached token, if any. UserToken? _cachedToken; - /// Returns the currently cached token without loading a new one. + // 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 cached token, without loading a new one. /// - /// Returns the cached [UserToken] if available, or null if no token - /// is currently cached or if the token has been expired. + /// `null` when nothing is cached, or when the cache was expired. UserToken? peekToken() => _cachedToken; - /// Whether this manager uses a static token provider. + /// Whether tokens come from a provider that always returns the same one. /// - /// 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` when no identity is configured. + bool get usesStaticProvider => _identity?.provider is StaticTokenProvider; - /// Gets a valid token for the user, loading one if necessary. + /// Returns the cached token, loading one from the [TokenProvider] when nothing is cached. /// - /// Returns the cached token if available, otherwise loads a new token - /// from the [TokenProvider] and caches it for future use. This method - /// is thread-safe and ensures only one token loading operation occurs - /// at a time. + /// 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. /// - /// Returns a [Future] that resolves to a [UserToken] for the user. - Future getToken() { - final cached = _cachedToken; - if (cached != null) return Future.value(cached); + /// Fails with a [ClientException] when no identity is configured, or when [reset] runs while the + /// token is loading, and with an [ArgumentError] when the provider returns a token that does not + /// belong to the user it was loading for. + Future getToken() async { + final cached = peekToken(); + if (cached != null && !_isSpent(cached)) return cached; return synchronized(() { - final currentToken = _cachedToken; - if (currentToken != null) return Future.value(currentToken); + final currentToken = peekToken(); + if (currentToken != null && !_isSpent(currentToken)) return currentToken; return _loadAndNotify(); }); } - // Loads a token from the provider, caches it, and notifies the - // [onTokenUpdated] callback. + bool _isSpent(UserToken token) { + // A static provider has nothing fresher to replace it with. + if (usesStaticProvider) return false; + return token.isExpired(); + } + + // 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 need not: caching another user's token + // 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) { + // After a `reset` the user is gone, so the token is not returned. After a switch it is: the + // caller 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 +200,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, so it cannot cache 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..938ef2cc 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 provider that hands out the same token for every load. 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 provider that calls a loader for each token it hands out. 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 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}', + ); + } + + 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..bfd0612f 100644 --- a/packages/stream_core/lib/src/user/user_token.dart +++ b/packages/stream_core/lib/src/user/user_token.dart @@ -1,6 +1,9 @@ +import 'package:clock/clock.dart'; 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 +28,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,11 +39,12 @@ 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 either an + /// [ArgumentError] or a [FormatException] if [rawValue] cannot be parsed as a JWT at all — + /// which of the two depends on how it is malformed. factory UserToken(String rawValue) { - final jwtBody = JsonWebToken.unverified(rawValue); - final userId = jwtBody.claims.getTyped('user_id'); + final claims = JsonWebToken.unverified(rawValue).claims; + final userId = claims.getTyped('user_id'); if (userId == null || userId.isEmpty) { throw ArgumentError.value( rawValue, @@ -53,25 +57,43 @@ class UserToken extends Equatable { rawValue: rawValue, userId: userId, authType: AuthType.jwt, + expiresAt: claims.expiry?.toUtc(), ); } /// 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 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 either an [ArgumentError] or a [FormatException] if it cannot be + /// parsed as a JWT at all — which of the two depends on how it is malformed. + factory UserToken.anonymous({String rawValue = ''}) { + DateTime? expiresAt; + if (rawValue.isNotEmpty) { + final claims = JsonWebToken.unverified(rawValue).claims; + expiresAt = claims.expiry?.toUtc(); + final userId = 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, + expiresAt: expiresAt, ); } @@ -79,18 +101,19 @@ class UserToken extends Equatable { required this.rawValue, required this.userId, required this.authType, + this.expiresAt, }); /// 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. @@ -98,8 +121,27 @@ class UserToken extends Equatable { /// Indicates whether this token uses JWT authentication or anonymous access. final AuthType authType; + /// The moment this token stops being valid, from its 'exp' claim, in UTC. + /// + /// `null` when the token names no expiry, including an anonymous token carrying no raw value. + /// Such a token is never considered expired. + final DateTime? expiresAt; + + /// Whether this token has expired, or expires within [leeway]. + /// + /// A [leeway] covers the gap between the check and the token being used, so one that would run + /// out mid-request counts as expired before it is sent. It also absorbs a client clock running + /// behind. + bool isExpired({Duration leeway = Duration.zero}) { + final expiresAt = this.expiresAt; + if (expiresAt == null) return false; + + // Not `isAfter`: a token expiring at exactly this moment has expired. + return !clock.now().add(leeway).isBefore(expiresAt); + } + @override - List get props => [rawValue, userId, authType]; + List get props => [rawValue, userId, authType, expiresAt]; } /// Represents the types of authentication available for API access. diff --git a/packages/stream_core/pubspec.yaml b/packages/stream_core/pubspec.yaml index c28d1a61..f56f6ab2 100644 --- a/packages/stream_core/pubspec.yaml +++ b/packages/stream_core/pubspec.yaml @@ -19,6 +19,7 @@ environment: sdk: ^3.12.0 dependencies: + clock: ^1.1.2 collection: ^1.19.0 cross_file: ^0.3.4+2 dio: ^5.8.0+1 @@ -37,6 +38,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..4707a5b3 --- /dev/null +++ b/packages/stream_core/test/helpers/user_token.dart @@ -0,0 +1,30 @@ +import 'dart:convert'; + +import 'package:stream_core/stream_core.dart'; + +/// Builds an unsigned JWT carrying [userId] as its 'user_id' claim. +/// +/// Unsigned is enough for [UserToken], which parses without verifying. Pass [nonce] to tell two +/// tokens for the same user apart, and [expiresAt] to give the token an 'exp' claim. +String generateTestJwt(String userId, {String? nonce, DateTime? expiresAt}) { + 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, + // 'exp' is in whole seconds since the epoch. + 'exp': ?expiresAt?.millisecondsSinceEpoch.let((it) => it ~/ 1000), + }; + + // The trailing dot is the empty signature that `alg: none` requires. + return '${b64UrlNoPad(header)}.${b64UrlNoPad(payload)}.'; +} + +/// Builds a JWT [UserToken] carrying [userId] as its 'user_id' claim. +UserToken generateTestUserToken(String userId, {String? nonce, DateTime? expiresAt}) { + return UserToken(generateTestJwt(userId, nonce: nonce, expiresAt: expiresAt)); +} diff --git a/packages/stream_core/test/user/token_manager_test.dart b/packages/stream_core/test/user/token_manager_test.dart index 0d3272a9..7ae0d585 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:clock/clock.dart'; +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 whose instances all compare equal, as an implementation is free to define. +@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,37 +40,113 @@ 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); + + final first = await manager.getToken(); + final second = await manager.getToken(); + + expect(first, generateTestUserToken('user-1')); + expect(second, generateTestUserToken('user-1')); + expect(provider.loadCount, 1); + expect(manager.peekToken(), generateTestUserToken('user-1')); + }); + + test('replaces a cached token that has expired', () async { + final expiry = DateTime.utc(2030); + final provider = _CountingProvider((id) async => generateTestUserToken(id, expiresAt: expiry)); + final manager = TokenManager(userId: 'user-1', tokenProvider: provider); + + await withClock(Clock.fixed(expiry.subtract(const Duration(hours: 1))), manager.getToken); + expect(provider.loadCount, 1); + + // Past its expiry the server would refuse it, so presenting it costs a request to find out + // what the token already said. + await withClock(Clock.fixed(expiry.add(const Duration(seconds: 1))), manager.getToken); + + expect(provider.loadCount, 2); + }); + + test('keeps a cached token that has not expired yet', () async { + final expiry = DateTime.utc(2030); + final provider = _CountingProvider((id) async => generateTestUserToken(id, expiresAt: expiry)); + final manager = TokenManager(userId: 'user-1', tokenProvider: provider); + + await withClock(Clock.fixed(expiry.subtract(const Duration(hours: 1))), manager.getToken); + await withClock(Clock.fixed(expiry.subtract(const Duration(seconds: 1))), manager.getToken); + + // A second of life left is still life: a margin ahead of the expiry would throw it away. + expect(provider.loadCount, 1); + }); + + test('contacts the provider once for a token that is short-lived, not once per call', () async { + // A backend issuing tokens that live for seconds. Treating anything near its expiry as spent + // makes every call a load, which is the caching this manager exists for, undone. + final issued = DateTime.utc(2030); + var loads = 0; + final provider = _CountingProvider( + (id) async => generateTestUserToken(id, expiresAt: issued.add(Duration(seconds: 30 + ++loads))), + ); + final manager = TokenManager(userId: 'user-1', tokenProvider: provider); + + await withClock(Clock.fixed(issued), () async { + for (var i = 0; i < 5; i++) { + await manager.getToken(); + } + }); + + expect(provider.loadCount, 1); + }); + + test('hands out a static provider token that has expired, rather than asking again', () async { + final expiry = DateTime.utc(2030); + final manager = TokenManager( + userId: 'user-1', + tokenProvider: TokenProvider.static(generateTestUserToken('user-1', expiresAt: expiry)), + ); + + // A static provider has nothing fresher to give. The server refusing it is what tells a + // guest to exchange for a new identity, and asking again only produces the same token. + final token = await withClock( + Clock.fixed(expiry.add(const Duration(hours: 1))), + manager.getToken, + ); + + expect(token.expiresAt, expiry); + expect(manager.peekToken(), isNotNull); + }); + + test('notifies once per load, not once per call for a token near its expiry', () async { + final expiry = DateTime.utc(2030); + final updated = []; + final provider = _CountingProvider((id) async => generateTestUserToken(id, expiresAt: expiry)); final manager = TokenManager( userId: 'user-1', tokenProvider: provider, + onTokenUpdated: updated.add, ); - final first = await manager.getToken(); - final second = await manager.getToken(); + await withClock(Clock.fixed(expiry.subtract(const Duration(seconds: 1))), () async { + for (var i = 0; i < 4; i++) { + await manager.getToken(); + } + }); - expect(first, _token('token-1')); - expect(second, _token('token-1')); - expect(provider.loadCount, 1); - expect(manager.peekToken(), _token('token-1')); + // A notification per call would have every listener re-running for a token that never moved. + expect(updated, hasLength(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', - tokenProvider: provider, - ); + final manager = TokenManager(userId: 'user-1', tokenProvider: provider); await manager.getToken(); @@ -59,16 +156,13 @@ void main() { test('coalesces concurrent calls into a single load', () async { final completer = Completer(); final provider = _CountingProvider((_) => completer.future); - final manager = TokenManager( - userId: 'user-1', - tokenProvider: provider, - ); + final manager = TokenManager(userId: 'user-1', tokenProvider: provider); 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,18 +171,15 @@ 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', - tokenProvider: provider, - ); + final manager = TokenManager(userId: 'user-1', tokenProvider: provider); await expectLater(manager.getToken(), throwsStateError); 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 +187,232 @@ void main() { group('expireToken', () { test('clears the cache and forces a reload', () async { var version = 0; - final provider = _CountingProvider((_) async => _token('v${++version}')); - final manager = TokenManager( - userId: 'user-1', - tokenProvider: provider, - ); + 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); + }); + + test('adopts a user id and token that were not known up front', () async { + const serverId = 'guest-abc-guest-123'; - expect(manager.peekToken(), _token('token-1')); + 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; + + // The token for user-1 must not be waiting in the cache for user-2 to send. + expect(manager.peekToken(), isNull); + expect((await manager.getToken()).userId, 'user-2'); + }); + + 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, different 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 swapped provider', () { + 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, so the token is neither cached nor handed to the caller. + 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); + + // A defensive re-set on reconnect does this routinely, and must not cost a load. + 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')), + ); + + // The provider declares the replacement equal, 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, and caching + // such a token would authenticate later requests as the wrong user. + final manager = TokenManager( + userId: 'user-1', + tokenProvider: _CountingProvider((_) async => generateTestUserToken('someone-else')), + ); + + await expectLater(manager.getToken(), throwsArgumentError); + expect(manager.peekToken(), isNull); }); }); @@ -162,11 +420,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,12 +436,8 @@ 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 manager = TokenManager( - userId: 'user-1', - tokenProvider: provider, - onTokenUpdated: updates.add, - ); + final provider = _CountingProvider((userId) async => generateTestUserToken(userId, nonce: 'v${++version}')); + final manager = TokenManager(userId: 'user-1', tokenProvider: provider, onTokenUpdated: updates.add); await manager.getToken(); await manager.getToken(); // served from cache @@ -191,14 +445,14 @@ 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 +466,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..7dfeebcf 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,39 @@ 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 stand in for a named user. + 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); @@ -50,9 +55,7 @@ void main() { group('DynamicTokenProvider', () { test('returns JWT tokens from the loader', () async { - final provider = TokenProvider.dynamic( - (userId) async => UserToken(_fakeJwt(userId)), - ); + final provider = TokenProvider.dynamic((userId) async => generateTestUserToken(userId)); final token = await provider.loadToken('user-1'); @@ -61,9 +64,19 @@ void main() { }); test('throws when the loader returns a non-JWT token', () { - final provider = TokenProvider.dynamic( - (userId) async => UserToken.anonymous(userId: userId), + final provider = TokenProvider.dynamic((_) async => UserToken.anonymous()); + + // 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..e3a85ce1 --- /dev/null +++ b/packages/stream_core/test/user/user_test.dart @@ -0,0 +1,41 @@ +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'); + }); + }); +} diff --git a/packages/stream_core/test/user/user_token_test.dart b/packages/stream_core/test/user/user_token_test.dart new file mode 100644 index 00000000..25aeef32 --- /dev/null +++ b/packages/stream_core/test/user/user_token_test.dart @@ -0,0 +1,114 @@ +import 'package:clock/clock.dart'; +import 'package:fake_async/fake_async.dart'; +import 'package:stream_core/stream_core.dart'; +import 'package:test/test.dart'; + +import '../helpers/user_token.dart'; + +void main() { + group('UserToken.expiresAt', () { + test('is read from the exp claim', () { + // 'exp' has no room for milliseconds, so a finer-grained moment comes back truncated. + final expiry = DateTime.utc(2030, 1, 1, 12); + + final token = generateTestUserToken('user-id', expiresAt: expiry); + + expect(token.expiresAt, expiry); + }); + + test('is null for a token that names no expiry', () { + final token = generateTestUserToken('user-id'); + + // With no expiry there is nothing to compare against, so the token never expires. + expect(token.expiresAt, isNull); + expect(token.isExpired(), isFalse); + }); + + test('is null for an anonymous token carrying no raw value', () { + final token = UserToken.anonymous(); + + expect(token.expiresAt, isNull); + expect(token.isExpired(), isFalse); + }); + + test('is read from an anonymous token that carries one', () { + final expiry = DateTime.utc(2030, 1, 1, 12); + final rawValue = generateTestJwt(User.anonymousUserId, expiresAt: expiry); + + final token = UserToken.anonymous(rawValue: rawValue); + + expect(token.expiresAt, expiry); + }); + }); + + group('UserToken.isExpired', () { + test('is false while the token still has life left', () { + final token = generateTestUserToken('user-id', expiresAt: DateTime.timestamp().add(const Duration(hours: 1))); + + expect(token.isExpired(), isFalse); + }); + + test('is true once the expiry has passed', () { + final token = generateTestUserToken( + 'user-id', + expiresAt: DateTime.timestamp().subtract(const Duration(seconds: 1)), + ); + + expect(token.isExpired(), isTrue); + }); + + test('is true for a token expiring within the leeway', () { + final token = generateTestUserToken('user-id', expiresAt: DateTime.timestamp().add(const Duration(seconds: 10))); + + // Still valid, but not for longer than the leeway. + expect(token.isExpired(), isFalse); + expect(token.isExpired(leeway: const Duration(seconds: 30)), isTrue); + }); + + test('is false for a token outliving the leeway', () { + final token = generateTestUserToken('user-id', expiresAt: DateTime.timestamp().add(const Duration(minutes: 5))); + + expect(token.isExpired(leeway: const Duration(seconds: 30)), isFalse); + }); + + test('is true for a token expiring at exactly this moment', () { + final expiry = DateTime.utc(2030, 1, 1, 12); + final token = generateTestUserToken('user-id', expiresAt: expiry); + + // A token stops being valid at its expiry, not after it. + withClock(Clock.fixed(expiry), () { + expect(token.isExpired(), isTrue); + }); + }); + + test('is answered against the ambient clock, not the wall clock', () { + final token = generateTestUserToken('user-id', expiresAt: DateTime.utc(2030, 1, 1, 12)); + + withClock(Clock.fixed(DateTime.utc(2030, 1, 1, 11, 59)), () { + expect(token.isExpired(), isFalse); + }); + + withClock(Clock.fixed(DateTime.utc(2030, 1, 1, 12, 1)), () { + expect(token.isExpired(), isTrue); + }); + }); + + test('expires as time passes under fakeAsync', () { + fakeAsync((async) { + final token = generateTestUserToken('user-id', expiresAt: clock.now().add(const Duration(hours: 1))); + expect(token.isExpired(), isFalse); + + // The same elapse that drives backoff and health checks moves token expiry too. + async.elapse(const Duration(hours: 2)); + + expect(token.isExpired(), isTrue); + }); + }); + + test('leaves a token with no expiry alone whatever the leeway', () { + final token = generateTestUserToken('user-id'); + + expect(token.isExpired(leeway: const Duration(days: 365)), isFalse); + }); + }); +}