Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
56bf6dc
feat(llc)!: let a TokenManager switch users
xsahil03x Aug 19, 2026
84e948e
docs(llc): drop the AuthInterceptor user_id entry from the changelog
xsahil03x Aug 19, 2026
8e963f4
test(llc): cover the deliberate user_id/token divergence in AuthInter…
xsahil03x Aug 19, 2026
ed252e8
fix(llc): discard a token load invalidated while it was in flight
xsahil03x Aug 19, 2026
a64dbb0
refactor(llc): make token provider mismatches assertable without thei…
xsahil03x Aug 19, 2026
64a85e0
refactor(llc): drop the redundant prefix from token mismatch messages
xsahil03x Aug 19, 2026
07db567
test(llc): share one JWT builder across the token tests
xsahil03x Aug 19, 2026
c91b3af
refactor(llc)!: remove AuthInterceptor.withProvider
xsahil03x Aug 19, 2026
0d88226
style(llc): align with STYLE_GUIDE and TESTING conventions
xsahil03x Aug 19, 2026
b9d1928
test(llc): keep the JWT builder local to each test file
xsahil03x Aug 19, 2026
8f834a4
test(llc): share one JWT builder, and document the pattern
xsahil03x Aug 19, 2026
500beee
docs(repo): drop the incident detail from the test-fixture rule
xsahil03x Aug 19, 2026
5215424
refactor(llc): restore the original token mismatch messages
xsahil03x Aug 19, 2026
df2d4f9
refactor(llc): drop the ArgumentError name argument
xsahil03x Aug 19, 2026
6c5eeca
docs(llc): drop an inaccurate comment on the token type check
xsahil03x Aug 19, 2026
a6bb26c
test(llc): reach the type check now that the user id is validated first
xsahil03x Aug 19, 2026
1d50f2c
feat(llc): let a TokenManager exist before its user does
xsahil03x Aug 20, 2026
06747e0
fix(llc): report a wrong-type token as such, not as a wrong user
xsahil03x Aug 20, 2026
1ed6718
fix(llc): stop serving a token for a user the manager has dropped
xsahil03x Aug 20, 2026
7541144
fix(llc): keep the token-expired error when there is no user to refre…
xsahil03x Aug 20, 2026
49f63f3
docs(llc): record the behaviour changes raised in review
xsahil03x Aug 20, 2026
c03f7fd
fix(llc): compare a replacement provider by instance, not by equality
xsahil03x Aug 20, 2026
0605c01
feat(llc): bound a token load so one provider cannot block the rest
xsahil03x Aug 20, 2026
19a1a91
Revert "feat(llc): bound a token load so one provider cannot block th…
xsahil03x Aug 20, 2026
4253b38
docs(llc): say that a provider's equality is never consulted
xsahil03x Aug 20, 2026
7a19674
Revert "fix(llc): compare a replacement provider by instance, not by …
xsahil03x Aug 20, 2026
a2e4abe
refactor(llc): name the anonymous token's claim as the user id it is
xsahil03x Aug 20, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions STYLE_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions melos.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
37 changes: 25 additions & 12 deletions packages/stream_core/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,63 +4,26 @@ 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<void> onRequest(
RequestOptions options,
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;

Copy link
Copy Markdown
Collaborator

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.anonymous pinned to !anon, anonymous requests now always send user_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 that user_id=!anon is 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.

options.headers['Authorization'] = token.rawValue;
options.headers['stream-auth-type'] = token.authType.headerValue;

Expand Down Expand Up @@ -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;
Expand Down
169 changes: 141 additions & 28 deletions packages/stream_core/lib/src/user/token_manager.dart
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.
Expand All @@ -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,
}) {
Comment thread
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();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Behaviour change worth a deliberate nod: the old setter had if (_tokenProvider == provider) return;, so re-setting the same provider was a no-op. Now every call expires the cached token, including one that re-sets the identity it already has.

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
Expand All @@ -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;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

false when unconfigured is right for the name, but it makes the interceptor take the wrong branch: AuthInterceptor.onError reads usesStaticProvider to decide whether a token-expired error is worth retrying (auth_interceptor.dart:61). On a manager that's been reset(), it's false, so we expire and retry, the retry's getToken() throws ClientException, and the caller ends up with "Failed to load auth token" instead of the original token-expired error.

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.
///
Expand All @@ -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);
Expand All @@ -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;
}

Expand All @@ -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;
}
}
Loading
Loading