Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
03309bd
feat(llc)!: let a TokenManager switch users
xsahil03x Aug 19, 2026
f39237c
docs(llc): drop the AuthInterceptor user_id entry from the changelog
xsahil03x Aug 19, 2026
4802c50
test(llc): cover the deliberate user_id/token divergence in AuthInter…
xsahil03x Aug 19, 2026
5f56044
fix(llc): discard a token load invalidated while it was in flight
xsahil03x Aug 19, 2026
42c2215
refactor(llc): make token provider mismatches assertable without thei…
xsahil03x Aug 19, 2026
b0fede1
refactor(llc): drop the redundant prefix from token mismatch messages
xsahil03x Aug 19, 2026
761741e
test(llc): share one JWT builder across the token tests
xsahil03x Aug 19, 2026
bd1bdcd
refactor(llc)!: remove AuthInterceptor.withProvider
xsahil03x Aug 19, 2026
a52b63b
style(llc): align with STYLE_GUIDE and TESTING conventions
xsahil03x Aug 19, 2026
79c4df9
test(llc): keep the JWT builder local to each test file
xsahil03x Aug 19, 2026
6785fe7
test(llc): share one JWT builder, and document the pattern
xsahil03x Aug 19, 2026
93c9679
docs(repo): drop the incident detail from the test-fixture rule
xsahil03x Aug 19, 2026
c4bfea2
refactor(llc): restore the original token mismatch messages
xsahil03x Aug 19, 2026
b6f8441
refactor(llc): drop the ArgumentError name argument
xsahil03x Aug 19, 2026
a636589
docs(llc): drop an inaccurate comment on the token type check
xsahil03x Aug 19, 2026
f395b96
test(llc): reach the type check now that the user id is validated first
xsahil03x Aug 19, 2026
1fb2c5a
feat(llc): let a TokenManager exist before its user does
xsahil03x Aug 20, 2026
d852fad
fix(llc): report a wrong-type token as such, not as a wrong user
xsahil03x Aug 20, 2026
f451bc4
fix(llc): stop serving a token for a user the manager has dropped
xsahil03x Aug 20, 2026
644eb3c
fix(llc): keep the token-expired error when there is no user to refre…
xsahil03x Aug 20, 2026
2b1e547
docs(llc): record the behaviour changes raised in review
xsahil03x Aug 20, 2026
d0e083a
fix(llc): compare a replacement provider by instance, not by equality
xsahil03x Aug 20, 2026
1fc8c6f
feat(llc): bound a token load so one provider cannot block the rest
xsahil03x Aug 20, 2026
d435cfb
Revert "feat(llc): bound a token load so one provider cannot block th…
xsahil03x Aug 20, 2026
067586c
docs(llc): say that a provider's equality is never consulted
xsahil03x Aug 20, 2026
b7d6e36
Revert "fix(llc): compare a replacement provider by instance, not by …
xsahil03x Aug 20, 2026
4f12972
refactor(llc): name the anonymous token's claim as the user id it is
xsahil03x Aug 20, 2026
ea9fd19
feat(llc): give a token an expiry it can be asked about
xsahil03x Aug 21, 2026
3d3a927
refactor(llc): read the token's expiry claim directly
xsahil03x Aug 21, 2026
ad275a1
fix(llc): replace a cached token the server would refuse for having e…
xsahil03x Aug 24, 2026
e5b273c
fix(llc): include a token's expiry in its equality
xsahil03x Aug 24, 2026
7fcf6a4
docs(llc): correct the token docs that name what is not there
xsahil03x Aug 24, 2026
f46598b
docs(llc): trim the changelog to what a consumer needs
xsahil03x Aug 24, 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
7 changes: 7 additions & 0 deletions STYLE_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions melos.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
34 changes: 22 additions & 12 deletions packages/stream_core/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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

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
Loading
Loading