-
Notifications
You must be signed in to change notification settings - Fork 0
feat(llc)!: let a TokenManager switch users #159
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
56bf6dc
84e948e
8e963f4
ed252e8
a64dbb0
64a85e0
07db567
c91b3af
0d88226
b9d1928
8f834a4
500beee
5215424
df2d4f9
6c5eeca
a6bb26c
1d50f2c
06747e0
1ed6718
7541144
49f63f3
c03f7fd
0605c01
19a1a91
4253b38
7a19674
a2e4abe
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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, | ||
| }) { | ||
|
xsahil03x marked this conversation as resolved.
|
||
| 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(); | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Behaviour change worth a deliberate nod: the old setter had Probably what we want — the generation guard is the point — but a reconnect or resume path that defensively re-sets the same provider will now hit the token endpoint every time instead of reusing the cache. |
||
| } | ||
|
|
||
| /// 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; | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Diagnostics only — no loop, and the request fails either way — but the surfaced error is the less useful of the two. |
||
|
|
||
| /// 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<UserToken> 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<UserToken> _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; | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Wire-visible change that isn't in the changelog: with
UserToken.anonymouspinned to!anon, anonymous requests now always senduser_id=!anon. Before this PR the value came from the manager, so it was whatever the caller constructed it with — which for video's guest bootstrap was a real id.The test
sends an anonymous token as an empty Authorization header…pins the new value, so it's clearly intended. Two asks: a changelog line for it, and confirmation thatuser_id=!anonis actually what the backend wants on an anonymous request, since a test can only tell us we send it consistently, not that it's correct.