From e94cf55dac5eeecc6d263083f565c2e13d39fcc3 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Tue, 24 Mar 2026 08:51:53 -0300 Subject: [PATCH 1/9] feat(auth): add optional accessToken parameter to setSession() Mirrors the JS SDK behaviour: when both tokens are supplied and the access token has not yet expired, the session is restored directly (via a getUser() call) without an extra /token refresh round-trip. If the access token is expired or omitted the existing _callRefreshToken path is preserved unchanged. - Valid, non-expired accessToken: calls getUser(accessToken), builds Session locally, fires AuthChangeEvent.signedIn. - Expired accessToken: falls back to _callRefreshToken(refreshToken). - Malformed accessToken: throws AuthInvalidJwtException. - Empty accessToken: throws AuthSessionMissingException. Linear: SDK-784 Co-Authored-By: Claude Sonnet 4.6 --- packages/gotrue/lib/src/gotrue_client.dart | 54 ++++++++++++- packages/gotrue/test/client_test.dart | 92 ++++++++++++++++++++++ 2 files changed, 143 insertions(+), 3 deletions(-) diff --git a/packages/gotrue/lib/src/gotrue_client.dart b/packages/gotrue/lib/src/gotrue_client.dart index ea626fe04..b5650c4c1 100644 --- a/packages/gotrue/lib/src/gotrue_client.dart +++ b/packages/gotrue/lib/src/gotrue_client.dart @@ -747,12 +747,60 @@ class GoTrueClient { return userResponse; } - /// Sets the session data from refresh_token and returns the current session. - Future setSession(String refreshToken) async { + /// Sets the session data from [refreshToken] and returns the current session. + /// + /// If [accessToken] is provided and not yet expired, the session is restored + /// directly from the supplied tokens without a network round-trip. The user + /// is fetched via [getUser] to populate the session's user object. + /// + /// If [accessToken] is expired (or not provided), the method falls back to + /// calling [_callRefreshToken] with [refreshToken], matching the existing + /// behaviour. + Future setSession( + String refreshToken, { + String? accessToken, + }) async { if (refreshToken.isEmpty) { throw AuthSessionMissingException('Refresh token cannot be empty'); } - return await _callRefreshToken(refreshToken); + + if (accessToken == null) { + return await _callRefreshToken(refreshToken); + } + + if (accessToken.isEmpty) { + throw AuthSessionMissingException('Access token cannot be empty'); + } + + final timeNow = DateTime.now().millisecondsSinceEpoch ~/ 1000; + + // Throws AuthInvalidJwtException if the token is malformed. + final decoded = decodeJwt(accessToken); + final exp = decoded.payload.exp; + final hasExpired = exp == null || exp <= timeNow; + + if (hasExpired) { + return await _callRefreshToken(refreshToken); + } + + final userResponse = await getUser(accessToken); + final user = userResponse.user; + if (user == null) { + throw AuthSessionMissingException(); + } + + final session = Session( + accessToken: accessToken, + refreshToken: refreshToken, + user: user, + tokenType: 'bearer', + expiresIn: exp - timeNow, + ); + + _saveSession(session); + notifyAllSubscribers(AuthChangeEvent.signedIn); + + return AuthResponse(session: session); } /// Gets the session data from a magic link or oauth2 callback URL diff --git a/packages/gotrue/test/client_test.dart b/packages/gotrue/test/client_test.dart index a6c0e1d9d..0ba608974 100644 --- a/packages/gotrue/test/client_test.dart +++ b/packages/gotrue/test/client_test.dart @@ -291,6 +291,98 @@ void main() { } }); + test( + 'Set session with both access token and refresh token skips network refresh', + () async { + await client.signInWithPassword(email: email1, password: password); + + final refreshToken = client.currentSession?.refreshToken ?? ''; + final accessToken = client.currentSession?.accessToken ?? ''; + expect(refreshToken, isNotEmpty); + expect(accessToken, isNotEmpty); + + final newClient = GoTrueClient( + url: gotrueUrl, + headers: { + 'apikey': anonToken, + }, + ); + + expect(newClient.currentSession, isNull); + + expect( + newClient.onAuthStateChange, + emits(predicate((s) => s.event == AuthChangeEvent.signedIn)), + ); + + final response = await newClient.setSession( + refreshToken, + accessToken: accessToken, + ); + + expect(response.session, isNotNull); + expect(response.session?.accessToken, equals(accessToken)); + expect(response.session?.refreshToken, equals(refreshToken)); + expect(response.user, isNotNull); + expect(newClient.currentSession?.accessToken, equals(accessToken)); + }); + + test('Set session with expired access token falls back to refresh token', + () async { + await client.signInWithPassword(email: email1, password: password); + + final refreshToken = client.currentSession?.refreshToken ?? ''; + expect(refreshToken, isNotEmpty); + + // A JWT that is syntactically valid but expired (exp in the past). + // Header: {"alg":"HS256","typ":"JWT"} + // Payload: {"sub":"user","exp":1} (epoch second 1 = Jan 1, 1970) + // Signature: 3 zero bytes as valid base64url ("AAAA") + const expiredAccessToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9' + '.eyJzdWIiOiJ1c2VyIiwiZXhwIjoxfQ' + '.AAAA'; + + final newClient = GoTrueClient( + url: gotrueUrl, + headers: { + 'apikey': anonToken, + }, + ); + + // Should fall back to _callRefreshToken and succeed. + final response = await newClient.setSession( + refreshToken, + accessToken: expiredAccessToken, + ); + + expect(response.session, isNotNull); + expect(response.session?.accessToken, isNot(equals(expiredAccessToken))); + expect(newClient.currentSession?.accessToken, isNotEmpty); + }); + + test( + 'Set session with empty access token throws AuthSessionMissingException', + () async { + try { + await client.setSession('some-refresh-token', accessToken: ''); + fail('setSession did not throw'); + } catch (error) { + expect(error, isA()); + } + }); + + test( + 'Set session with malformed access token throws AuthInvalidJwtException', + () async { + try { + await client.setSession('some-refresh-token', + accessToken: 'not-a-valid-jwt'); + fail('setSession did not throw'); + } catch (error) { + expect(error, isA()); + } + }); + test('Refresh session with refreshToken when no current session exists', () async { await client.signInWithPassword(email: email1, password: password); From 81cd5be139ba8961f0fca77fa0bab5bbe674b6e8 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Wed, 25 Mar 2026 11:40:17 -0300 Subject: [PATCH 2/9] docs: clarify setSession network behavior in doc comment (#1332) * Initial plan * docs: clarify setSession skips /token refresh but still calls /user Co-authored-by: grdsdev <5923044+grdsdev@users.noreply.github.com> Agent-Logs-Url: https://github.com/supabase/supabase-flutter/sessions/db643f38-73ec-4b87-92e3-9d995d319a73 --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: grdsdev <5923044+grdsdev@users.noreply.github.com> --- packages/gotrue/lib/src/gotrue_client.dart | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/gotrue/lib/src/gotrue_client.dart b/packages/gotrue/lib/src/gotrue_client.dart index b5650c4c1..205785796 100644 --- a/packages/gotrue/lib/src/gotrue_client.dart +++ b/packages/gotrue/lib/src/gotrue_client.dart @@ -750,8 +750,9 @@ class GoTrueClient { /// Sets the session data from [refreshToken] and returns the current session. /// /// If [accessToken] is provided and not yet expired, the session is restored - /// directly from the supplied tokens without a network round-trip. The user - /// is fetched via [getUser] to populate the session's user object. + /// directly from the supplied tokens, skipping the `/token` refresh + /// round-trip. A `/user` request is still made to populate the session's + /// user object. /// /// If [accessToken] is expired (or not provided), the method falls back to /// calling [_callRefreshToken] with [refreshToken], matching the existing From 979da8e9835644e3d7c279a24479e5970ec2d3c0 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Wed, 25 Mar 2026 11:48:27 -0300 Subject: [PATCH 3/9] fix(auth): apply expiryMargin buffer to hasExpired check in setSession() (#1331) * Initial plan * fix(auth): apply expiryMargin buffer to hasExpired check in setSession() Co-authored-by: grdsdev <5923044+grdsdev@users.noreply.github.com> Agent-Logs-Url: https://github.com/supabase/supabase-flutter/sessions/80e8e775-47d3-4e08-aa75-07f715c27411 --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: grdsdev <5923044+grdsdev@users.noreply.github.com> --- packages/gotrue/lib/src/gotrue_client.dart | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/gotrue/lib/src/gotrue_client.dart b/packages/gotrue/lib/src/gotrue_client.dart index 205785796..7d6b675e0 100644 --- a/packages/gotrue/lib/src/gotrue_client.dart +++ b/packages/gotrue/lib/src/gotrue_client.dart @@ -778,7 +778,8 @@ class GoTrueClient { // Throws AuthInvalidJwtException if the token is malformed. final decoded = decodeJwt(accessToken); final exp = decoded.payload.exp; - final hasExpired = exp == null || exp <= timeNow; + final hasExpired = + exp == null || exp <= timeNow + Constants.expiryMargin.inSeconds; if (hasExpired) { return await _callRefreshToken(refreshToken); From 1898462db5f6c7e2af1b7848052458c1aba7850c Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Fri, 27 Mar 2026 09:35:04 -0300 Subject: [PATCH 4/9] fix(gotrue): address setSession review feedback - Use exp - iat for expiresIn (full token lifetime, not remaining seconds) - Deduplicate concurrent fast-path calls via _refreshTokenCompleter - Use idiomatic throwsA in error-throwing tests Co-Authored-By: Claude Sonnet 4.6 --- packages/gotrue/lib/src/gotrue_client.dart | 48 +++++++++++++++------- packages/gotrue/test/client_test.dart | 22 ++++------ 2 files changed, 43 insertions(+), 27 deletions(-) diff --git a/packages/gotrue/lib/src/gotrue_client.dart b/packages/gotrue/lib/src/gotrue_client.dart index 7d6b675e0..a204f7811 100644 --- a/packages/gotrue/lib/src/gotrue_client.dart +++ b/packages/gotrue/lib/src/gotrue_client.dart @@ -785,24 +785,44 @@ class GoTrueClient { return await _callRefreshToken(refreshToken); } - final userResponse = await getUser(accessToken); - final user = userResponse.user; - if (user == null) { - throw AuthSessionMissingException(); + // Join an in-progress session operation rather than racing with it. + if (_refreshTokenCompleter != null) { + return _refreshTokenCompleter!.future; } - final session = Session( - accessToken: accessToken, - refreshToken: refreshToken, - user: user, - tokenType: 'bearer', - expiresIn: exp - timeNow, - ); + try { + _refreshTokenCompleter = Completer(); + // Catch any error in case nobody awaits the future + _refreshTokenCompleter!.future + .then((_) => null, onError: (_, __) => null); - _saveSession(session); - notifyAllSubscribers(AuthChangeEvent.signedIn); + final userResponse = await getUser(accessToken); + final user = userResponse.user; + if (user == null) { + throw AuthSessionMissingException(); + } + + final iat = decoded.payload.iat; + final session = Session( + accessToken: accessToken, + refreshToken: refreshToken, + user: user, + tokenType: 'bearer', + expiresIn: (iat != null) ? exp - iat : null, + ); + + _saveSession(session); + notifyAllSubscribers(AuthChangeEvent.signedIn); - return AuthResponse(session: session); + final response = AuthResponse(session: session); + _refreshTokenCompleter?.complete(response); + return response; + } catch (error) { + _refreshTokenCompleter?.completeError(error); + rethrow; + } finally { + _refreshTokenCompleter = null; + } } /// Gets the session data from a magic link or oauth2 callback URL diff --git a/packages/gotrue/test/client_test.dart b/packages/gotrue/test/client_test.dart index 0ba608974..a873d811b 100644 --- a/packages/gotrue/test/client_test.dart +++ b/packages/gotrue/test/client_test.dart @@ -363,24 +363,20 @@ void main() { test( 'Set session with empty access token throws AuthSessionMissingException', () async { - try { - await client.setSession('some-refresh-token', accessToken: ''); - fail('setSession did not throw'); - } catch (error) { - expect(error, isA()); - } + await expectLater( + () => client.setSession('some-refresh-token', accessToken: ''), + throwsA(isA()), + ); }); test( 'Set session with malformed access token throws AuthInvalidJwtException', () async { - try { - await client.setSession('some-refresh-token', - accessToken: 'not-a-valid-jwt'); - fail('setSession did not throw'); - } catch (error) { - expect(error, isA()); - } + await expectLater( + () => client.setSession('some-refresh-token', + accessToken: 'not-a-valid-jwt'), + throwsA(isA()), + ); }); test('Refresh session with refreshToken when no current session exists', From 4178521008ab1f3568479adad1032500845dcaa7 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Fri, 27 Mar 2026 09:40:28 -0300 Subject: [PATCH 5/9] test(gotrue): add edge case tests for setSession fast path - New set_session_test.dart with 10 mock-based tests covering: - Validation order (empty RT checked before AT) - Expiry margin boundary (exp within 30 s treated as expired) - JWT without exp claim falls back to refresh - expiresIn = exp - iat when both claims present - expiresIn = null when iat absent - expiresAt matches JWT exp - Preserved access/refresh token values - Fast path fires signedIn, fallback fires tokenRefreshed - Concurrent calls deduplicated to one /user request - signedIn fires exactly once under concurrent load - Fix remaining try/catch in client_test.dart to use throwsA Co-Authored-By: Claude Sonnet 4.6 --- packages/gotrue/test/client_test.dart | 10 +- .../gotrue/test/src/set_session_test.dart | 270 ++++++++++++++++++ 2 files changed, 274 insertions(+), 6 deletions(-) create mode 100644 packages/gotrue/test/src/set_session_test.dart diff --git a/packages/gotrue/test/client_test.dart b/packages/gotrue/test/client_test.dart index a873d811b..3c243a1e1 100644 --- a/packages/gotrue/test/client_test.dart +++ b/packages/gotrue/test/client_test.dart @@ -283,12 +283,10 @@ void main() { test( 'Set session with an empty refresh token throws AuthSessionMissingException', () async { - try { - await client.setSession(''); - fail('setSession did not throw'); - } catch (error) { - expect(error, isA()); - } + await expectLater( + () => client.setSession(''), + throwsA(isA()), + ); }); test( diff --git a/packages/gotrue/test/src/set_session_test.dart b/packages/gotrue/test/src/set_session_test.dart new file mode 100644 index 000000000..be33da351 --- /dev/null +++ b/packages/gotrue/test/src/set_session_test.dart @@ -0,0 +1,270 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart'; +import 'package:gotrue/gotrue.dart'; +import 'package:http/http.dart'; +import 'package:test/test.dart'; + +import '../utils.dart'; + +// Minimal user payload accepted by User.fromJson. +Map get _mockUserJson => { + 'id': 'mock-user-id', + 'aud': 'authenticated', + 'role': 'authenticated', + 'email': 'mock@example.com', + 'app_metadata': { + 'provider': 'email', + 'providers': ['email'] + }, + 'user_metadata': {}, + 'created_at': '2024-01-01T00:00:00.000Z', + 'updated_at': '2024-01-01T00:00:00.000Z', + }; + +/// Mock HTTP client for setSession tests. +/// +/// Handles `GET /user` (returns [_mockUserJson]) and +/// `POST /token` (returns a fresh session via the refresh path). +/// Use [userCallPause] to hold the /user response until the completer completes, +/// which lets you race two concurrent calls. +class _SetSessionMockClient extends BaseClient { + int userCallCount = 0; + Completer? userCallPause; + + @override + Future send(BaseRequest request) async { + if (request.url.path.endsWith('/user')) { + userCallCount++; + if (userCallPause != null) { + await userCallPause!.future; + } + return StreamedResponse( + Stream.value(utf8.encode(jsonEncode(_mockUserJson))), + 200, + ); + } + + if (request.url.path.contains('/token')) { + // Refresh-token fallback response with a freshly minted access token. + final exp = DateTime.now().millisecondsSinceEpoch ~/ 1000 + 3600; + final iat = exp - 3600; + final freshAt = JWT({'exp': exp, 'iat': iat}, subject: 'mock-user-id') + .sign(SecretKey('test-secret')); + return StreamedResponse( + Stream.value(utf8.encode(jsonEncode({ + 'access_token': freshAt, + 'token_type': 'bearer', + 'expires_in': 3600, + 'refresh_token': 'new-refresh-token', + 'user': _mockUserJson, + }))), + 200, + ); + } + + return StreamedResponse(Stream.empty(), 404); + } +} + +/// Returns a signed JWT with the given [exp] and optional [iat] claims. +/// If [iat] is null, the claim is omitted entirely. +String _makeJwt({required int exp, int? iat}) { + final payload = {'exp': exp}; + if (iat != null) payload['iat'] = iat; + return JWT(payload, subject: 'mock-user-id').sign(SecretKey('test-secret')); +} + +void main() { + late _SetSessionMockClient mockClient; + late GoTrueClient client; + + setUp(() { + mockClient = _SetSessionMockClient(); + client = GoTrueClient( + url: 'https://example.supabase.co', + httpClient: mockClient, + asyncStorage: TestAsyncStorage(), + ); + }); + + group('setSession — validation edge cases', () { + test( + 'empty refresh token with a non-null access token throws before ' + 'inspecting the access token', () async { + final exp = DateTime.now().millisecondsSinceEpoch ~/ 1000 + 3600; + final at = _makeJwt(exp: exp, iat: exp - 3600); + + await expectLater( + () => client.setSession('', accessToken: at), + throwsA(isA()), + ); + // No network call should have been made. + expect(mockClient.userCallCount, 0); + }); + + test( + 'access token with exp within the 30-second expiry margin is treated ' + 'as expired and falls back to the refresh-token path', () async { + final timeNow = DateTime.now().millisecondsSinceEpoch ~/ 1000; + // exp is 20 s in the future, inside the 30 s Constants.expiryMargin. + final at = _makeJwt(exp: timeNow + 20, iat: timeNow - 3580); + + final response = + await client.setSession('some-refresh-token', accessToken: at); + + expect(response.session, isNotNull); + // The returned token must be the freshly refreshed one, not our near-expired JWT. + expect(response.session?.accessToken, isNot(equals(at))); + expect(mockClient.userCallCount, 0); // /user was NOT called + }); + + test( + 'access token with no exp claim is treated as expired and falls back ' + 'to the refresh-token path', () async { + // JWT without an exp claim: decodeJwt succeeds but exp == null. + final at = JWT({'role': 'authenticated'}, subject: 'mock-user-id') + .sign(SecretKey('test-secret')); + + final response = + await client.setSession('some-refresh-token', accessToken: at); + + expect(response.session, isNotNull); + expect(response.session?.accessToken, isNot(equals(at))); + expect(mockClient.userCallCount, 0); + }); + }); + + group('setSession — fast path session fields', () { + test('expiresIn equals exp minus iat when both claims are present', + () async { + final iat = DateTime.now().millisecondsSinceEpoch ~/ 1000 - 60; + final exp = iat + 3600; + final at = _makeJwt(exp: exp, iat: iat); + + final response = + await client.setSession('some-refresh-token', accessToken: at); + + // expiresIn should be the total token lifetime (exp - iat = 3600). + expect(response.session?.expiresIn, equals(exp - iat)); + }); + + test('expiresIn is null when iat claim is absent', () async { + final exp = DateTime.now().millisecondsSinceEpoch ~/ 1000 + 3600; + // JWT without iat. + final at = _makeJwt(exp: exp); + + final response = + await client.setSession('some-refresh-token', accessToken: at); + + expect(response.session?.expiresIn, isNull); + }); + + test('expiresAt matches the exp claim in the JWT', () async { + final iat = DateTime.now().millisecondsSinceEpoch ~/ 1000 - 60; + final exp = iat + 3600; + final at = _makeJwt(exp: exp, iat: iat); + + final response = + await client.setSession('some-refresh-token', accessToken: at); + + // expiresAt is re-derived from the JWT's own exp, not from expiresIn. + expect(response.session?.expiresAt, equals(exp)); + }); + + test('returned session preserves the supplied access and refresh tokens', + () async { + final iat = DateTime.now().millisecondsSinceEpoch ~/ 1000 - 60; + final exp = iat + 3600; + const refreshToken = 'my-refresh-token'; + final at = _makeJwt(exp: exp, iat: iat); + + final response = await client.setSession(refreshToken, accessToken: at); + + expect(response.session?.accessToken, equals(at)); + expect(response.session?.refreshToken, equals(refreshToken)); + expect(response.session?.tokenType, equals('bearer')); + }); + }); + + group('setSession — auth state events', () { + test('fast path emits signedIn (not tokenRefreshed)', () async { + final iat = DateTime.now().millisecondsSinceEpoch ~/ 1000 - 60; + final exp = iat + 3600; + final at = _makeJwt(exp: exp, iat: iat); + + expect( + client.onAuthStateChange, + emits(predicate((s) => s.event == AuthChangeEvent.signedIn)), + ); + + await client.setSession('some-refresh-token', accessToken: at); + }); + + test('expired-fallback path emits tokenRefreshed (not signedIn)', () async { + final timeNow = DateTime.now().millisecondsSinceEpoch ~/ 1000; + // Clearly expired token (exp well in the past). + final at = _makeJwt(exp: timeNow - 100, iat: timeNow - 3700); + + expect( + client.onAuthStateChange, + emits(predicate( + (s) => s.event == AuthChangeEvent.tokenRefreshed)), + ); + + await client.setSession('some-refresh-token', accessToken: at); + }); + }); + + group('setSession — concurrent call deduplication', () { + test( + 'two simultaneous fast-path calls issue only one GET /user request ' + 'and both receive the same response', () async { + final iat = DateTime.now().millisecondsSinceEpoch ~/ 1000 - 60; + final exp = iat + 3600; + final at = _makeJwt(exp: exp, iat: iat); + + // Pause the mock /user response so both calls are in-flight together. + final pause = Completer(); + mockClient.userCallPause = pause; + + final future1 = client.setSession('some-refresh-token', accessToken: at); + final future2 = client.setSession('some-refresh-token', accessToken: at); + + // Unblock the single /user call that should be in-flight. + pause.complete(); + + final results = await Future.wait([future1, future2]); + + expect(mockClient.userCallCount, 1); + expect(results[0].session?.accessToken, equals(at)); + expect(results[1].session?.accessToken, equals(at)); + }); + + test('deduplicated call emits signedIn exactly once', () async { + final iat = DateTime.now().millisecondsSinceEpoch ~/ 1000 - 60; + final exp = iat + 3600; + final at = _makeJwt(exp: exp, iat: iat); + + final pause = Completer(); + mockClient.userCallPause = pause; + + final events = []; + final sub = client.onAuthStateChange.listen((s) => events.add(s.event)); + + final future1 = client.setSession('some-refresh-token', accessToken: at); + final future2 = client.setSession('some-refresh-token', accessToken: at); + + pause.complete(); + await Future.wait([future1, future2]); + + // Flush microtasks so the stream listener has a chance to fire. + await Future.delayed(Duration.zero); + + expect(events.where((e) => e == AuthChangeEvent.signedIn).length, 1); + + await sub.cancel(); + }); + }); +} From 45b60069de243a4310d56be876b0c8d1f7398107 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Fri, 27 Mar 2026 10:01:29 -0300 Subject: [PATCH 6/9] test(gotrue): fix expiresIn tests by using raw base64url JWT crafting dart_jsonwebtoken auto-injects an iat claim when signing, which broke: - 'expiresIn equals exp minus iat' (iat value was overridden) - 'expiresIn is null when iat absent' (iat was auto-added) Replace dart_jsonwebtoken usage with _makeRawJwt(), which base64url-encodes the payload map directly (no auto-claims, no signature verification needed). Also removes the dart_jsonwebtoken import from the test file. Co-Authored-By: Claude Sonnet 4.6 --- .../gotrue/test/src/set_session_test.dart | 48 +++++++++++-------- 1 file changed, 27 insertions(+), 21 deletions(-) diff --git a/packages/gotrue/test/src/set_session_test.dart b/packages/gotrue/test/src/set_session_test.dart index be33da351..f442ec768 100644 --- a/packages/gotrue/test/src/set_session_test.dart +++ b/packages/gotrue/test/src/set_session_test.dart @@ -1,7 +1,6 @@ import 'dart:async'; import 'dart:convert'; -import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart'; import 'package:gotrue/gotrue.dart'; import 'package:http/http.dart'; import 'package:test/test.dart'; @@ -50,8 +49,8 @@ class _SetSessionMockClient extends BaseClient { // Refresh-token fallback response with a freshly minted access token. final exp = DateTime.now().millisecondsSinceEpoch ~/ 1000 + 3600; final iat = exp - 3600; - final freshAt = JWT({'exp': exp, 'iat': iat}, subject: 'mock-user-id') - .sign(SecretKey('test-secret')); + final freshAt = + _makeRawJwt({'exp': exp, 'iat': iat, 'sub': 'mock-user-id'}); return StreamedResponse( Stream.value(utf8.encode(jsonEncode({ 'access_token': freshAt, @@ -68,12 +67,17 @@ class _SetSessionMockClient extends BaseClient { } } -/// Returns a signed JWT with the given [exp] and optional [iat] claims. -/// If [iat] is null, the claim is omitted entirely. -String _makeJwt({required int exp, int? iat}) { - final payload = {'exp': exp}; - if (iat != null) payload['iat'] = iat; - return JWT(payload, subject: 'mock-user-id').sign(SecretKey('test-secret')); +/// Crafts a JWT by base64url-encoding [payload] directly. +/// +/// Unlike using dart_jsonwebtoken, this gives exact control over every claim — +/// no auto-injected `iat`, no claim overrides. The signature is a stub; +/// [decodeJwt] does not verify signatures. +String _makeRawJwt(Map payload) { + final header = + base64Url.encode(utf8.encode(jsonEncode({'alg': 'HS256', 'typ': 'JWT'}))); + final body = base64Url.encode(utf8.encode(jsonEncode(payload))); + const sig = 'AAAA'; + return '$header.$body.$sig'; } void main() { @@ -94,7 +98,8 @@ void main() { 'empty refresh token with a non-null access token throws before ' 'inspecting the access token', () async { final exp = DateTime.now().millisecondsSinceEpoch ~/ 1000 + 3600; - final at = _makeJwt(exp: exp, iat: exp - 3600); + final at = + _makeRawJwt({'exp': exp, 'iat': exp - 3600, 'sub': 'mock-user-id'}); await expectLater( () => client.setSession('', accessToken: at), @@ -109,7 +114,8 @@ void main() { 'as expired and falls back to the refresh-token path', () async { final timeNow = DateTime.now().millisecondsSinceEpoch ~/ 1000; // exp is 20 s in the future, inside the 30 s Constants.expiryMargin. - final at = _makeJwt(exp: timeNow + 20, iat: timeNow - 3580); + final at = _makeRawJwt( + {'exp': timeNow + 20, 'iat': timeNow - 3580, 'sub': 'mock-user-id'}); final response = await client.setSession('some-refresh-token', accessToken: at); @@ -124,8 +130,7 @@ void main() { 'access token with no exp claim is treated as expired and falls back ' 'to the refresh-token path', () async { // JWT without an exp claim: decodeJwt succeeds but exp == null. - final at = JWT({'role': 'authenticated'}, subject: 'mock-user-id') - .sign(SecretKey('test-secret')); + final at = _makeRawJwt({'role': 'authenticated', 'sub': 'mock-user-id'}); final response = await client.setSession('some-refresh-token', accessToken: at); @@ -141,7 +146,7 @@ void main() { () async { final iat = DateTime.now().millisecondsSinceEpoch ~/ 1000 - 60; final exp = iat + 3600; - final at = _makeJwt(exp: exp, iat: iat); + final at = _makeRawJwt({'exp': exp, 'iat': iat, 'sub': 'mock-user-id'}); final response = await client.setSession('some-refresh-token', accessToken: at); @@ -153,7 +158,7 @@ void main() { test('expiresIn is null when iat claim is absent', () async { final exp = DateTime.now().millisecondsSinceEpoch ~/ 1000 + 3600; // JWT without iat. - final at = _makeJwt(exp: exp); + final at = _makeRawJwt({'exp': exp, 'sub': 'mock-user-id'}); final response = await client.setSession('some-refresh-token', accessToken: at); @@ -164,7 +169,7 @@ void main() { test('expiresAt matches the exp claim in the JWT', () async { final iat = DateTime.now().millisecondsSinceEpoch ~/ 1000 - 60; final exp = iat + 3600; - final at = _makeJwt(exp: exp, iat: iat); + final at = _makeRawJwt({'exp': exp, 'iat': iat, 'sub': 'mock-user-id'}); final response = await client.setSession('some-refresh-token', accessToken: at); @@ -178,7 +183,7 @@ void main() { final iat = DateTime.now().millisecondsSinceEpoch ~/ 1000 - 60; final exp = iat + 3600; const refreshToken = 'my-refresh-token'; - final at = _makeJwt(exp: exp, iat: iat); + final at = _makeRawJwt({'exp': exp, 'iat': iat, 'sub': 'mock-user-id'}); final response = await client.setSession(refreshToken, accessToken: at); @@ -192,7 +197,7 @@ void main() { test('fast path emits signedIn (not tokenRefreshed)', () async { final iat = DateTime.now().millisecondsSinceEpoch ~/ 1000 - 60; final exp = iat + 3600; - final at = _makeJwt(exp: exp, iat: iat); + final at = _makeRawJwt({'exp': exp, 'iat': iat, 'sub': 'mock-user-id'}); expect( client.onAuthStateChange, @@ -205,7 +210,8 @@ void main() { test('expired-fallback path emits tokenRefreshed (not signedIn)', () async { final timeNow = DateTime.now().millisecondsSinceEpoch ~/ 1000; // Clearly expired token (exp well in the past). - final at = _makeJwt(exp: timeNow - 100, iat: timeNow - 3700); + final at = _makeRawJwt( + {'exp': timeNow - 100, 'iat': timeNow - 3700, 'sub': 'mock-user-id'}); expect( client.onAuthStateChange, @@ -223,7 +229,7 @@ void main() { 'and both receive the same response', () async { final iat = DateTime.now().millisecondsSinceEpoch ~/ 1000 - 60; final exp = iat + 3600; - final at = _makeJwt(exp: exp, iat: iat); + final at = _makeRawJwt({'exp': exp, 'iat': iat, 'sub': 'mock-user-id'}); // Pause the mock /user response so both calls are in-flight together. final pause = Completer(); @@ -245,7 +251,7 @@ void main() { test('deduplicated call emits signedIn exactly once', () async { final iat = DateTime.now().millisecondsSinceEpoch ~/ 1000 - 60; final exp = iat + 3600; - final at = _makeJwt(exp: exp, iat: iat); + final at = _makeRawJwt({'exp': exp, 'iat': iat, 'sub': 'mock-user-id'}); final pause = Completer(); mockClient.userCallPause = pause; From 9fad576b1c2ba47e507b04228477e0a7bf55aeb4 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Mon, 30 Mar 2026 10:46:45 -0300 Subject: [PATCH 7/9] simplify setSession --- packages/gotrue/lib/src/gotrue_client.dart | 50 +++++++--------------- 1 file changed, 16 insertions(+), 34 deletions(-) diff --git a/packages/gotrue/lib/src/gotrue_client.dart b/packages/gotrue/lib/src/gotrue_client.dart index a204f7811..567a7cf51 100644 --- a/packages/gotrue/lib/src/gotrue_client.dart +++ b/packages/gotrue/lib/src/gotrue_client.dart @@ -785,44 +785,26 @@ class GoTrueClient { return await _callRefreshToken(refreshToken); } - // Join an in-progress session operation rather than racing with it. - if (_refreshTokenCompleter != null) { - return _refreshTokenCompleter!.future; + final userResponse = await getUser(accessToken); + final user = userResponse.user; + if (user == null) { + throw AuthSessionMissingException(); } - try { - _refreshTokenCompleter = Completer(); - // Catch any error in case nobody awaits the future - _refreshTokenCompleter!.future - .then((_) => null, onError: (_, __) => null); - - final userResponse = await getUser(accessToken); - final user = userResponse.user; - if (user == null) { - throw AuthSessionMissingException(); - } - - final iat = decoded.payload.iat; - final session = Session( - accessToken: accessToken, - refreshToken: refreshToken, - user: user, - tokenType: 'bearer', - expiresIn: (iat != null) ? exp - iat : null, - ); + final iat = decoded.payload.iat; + final session = Session( + accessToken: accessToken, + refreshToken: refreshToken, + user: user, + tokenType: 'bearer', + expiresIn: (iat != null) ? exp - iat : null, + ); - _saveSession(session); - notifyAllSubscribers(AuthChangeEvent.signedIn); + _saveSession(session); + notifyAllSubscribers(AuthChangeEvent.signedIn); - final response = AuthResponse(session: session); - _refreshTokenCompleter?.complete(response); - return response; - } catch (error) { - _refreshTokenCompleter?.completeError(error); - rethrow; - } finally { - _refreshTokenCompleter = null; - } + final response = AuthResponse(session: session); + return response; } /// Gets the session data from a magic link or oauth2 callback URL From 96240afbaa7a650d4486c90c7eb0fe47f16081db Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Mon, 30 Mar 2026 10:50:22 -0300 Subject: [PATCH 8/9] remove concurrent tests --- .../gotrue/test/src/set_session_test.dart | 57 ------------------- 1 file changed, 57 deletions(-) diff --git a/packages/gotrue/test/src/set_session_test.dart b/packages/gotrue/test/src/set_session_test.dart index f442ec768..64c8fb48e 100644 --- a/packages/gotrue/test/src/set_session_test.dart +++ b/packages/gotrue/test/src/set_session_test.dart @@ -26,19 +26,13 @@ Map get _mockUserJson => { /// /// Handles `GET /user` (returns [_mockUserJson]) and /// `POST /token` (returns a fresh session via the refresh path). -/// Use [userCallPause] to hold the /user response until the completer completes, -/// which lets you race two concurrent calls. class _SetSessionMockClient extends BaseClient { int userCallCount = 0; - Completer? userCallPause; @override Future send(BaseRequest request) async { if (request.url.path.endsWith('/user')) { userCallCount++; - if (userCallPause != null) { - await userCallPause!.future; - } return StreamedResponse( Stream.value(utf8.encode(jsonEncode(_mockUserJson))), 200, @@ -222,55 +216,4 @@ void main() { await client.setSession('some-refresh-token', accessToken: at); }); }); - - group('setSession — concurrent call deduplication', () { - test( - 'two simultaneous fast-path calls issue only one GET /user request ' - 'and both receive the same response', () async { - final iat = DateTime.now().millisecondsSinceEpoch ~/ 1000 - 60; - final exp = iat + 3600; - final at = _makeRawJwt({'exp': exp, 'iat': iat, 'sub': 'mock-user-id'}); - - // Pause the mock /user response so both calls are in-flight together. - final pause = Completer(); - mockClient.userCallPause = pause; - - final future1 = client.setSession('some-refresh-token', accessToken: at); - final future2 = client.setSession('some-refresh-token', accessToken: at); - - // Unblock the single /user call that should be in-flight. - pause.complete(); - - final results = await Future.wait([future1, future2]); - - expect(mockClient.userCallCount, 1); - expect(results[0].session?.accessToken, equals(at)); - expect(results[1].session?.accessToken, equals(at)); - }); - - test('deduplicated call emits signedIn exactly once', () async { - final iat = DateTime.now().millisecondsSinceEpoch ~/ 1000 - 60; - final exp = iat + 3600; - final at = _makeRawJwt({'exp': exp, 'iat': iat, 'sub': 'mock-user-id'}); - - final pause = Completer(); - mockClient.userCallPause = pause; - - final events = []; - final sub = client.onAuthStateChange.listen((s) => events.add(s.event)); - - final future1 = client.setSession('some-refresh-token', accessToken: at); - final future2 = client.setSession('some-refresh-token', accessToken: at); - - pause.complete(); - await Future.wait([future1, future2]); - - // Flush microtasks so the stream listener has a chance to fire. - await Future.delayed(Duration.zero); - - expect(events.where((e) => e == AuthChangeEvent.signedIn).length, 1); - - await sub.cancel(); - }); - }); } From 30337decaf1186ffeea8827c953ea58670c56560 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Wed, 1 Apr 2026 08:20:48 -0300 Subject: [PATCH 9/9] docs: simplify doc string --- packages/gotrue/lib/src/gotrue_client.dart | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/packages/gotrue/lib/src/gotrue_client.dart b/packages/gotrue/lib/src/gotrue_client.dart index 567a7cf51..cce2fc1ae 100644 --- a/packages/gotrue/lib/src/gotrue_client.dart +++ b/packages/gotrue/lib/src/gotrue_client.dart @@ -750,13 +750,7 @@ class GoTrueClient { /// Sets the session data from [refreshToken] and returns the current session. /// /// If [accessToken] is provided and not yet expired, the session is restored - /// directly from the supplied tokens, skipping the `/token` refresh - /// round-trip. A `/user` request is still made to populate the session's - /// user object. - /// - /// If [accessToken] is expired (or not provided), the method falls back to - /// calling [_callRefreshToken] with [refreshToken], matching the existing - /// behaviour. + /// directly from the supplied tokens, skipping the `/token` refresh round-trip. Future setSession( String refreshToken, { String? accessToken,