diff --git a/packages/gotrue/lib/src/gotrue_client.dart b/packages/gotrue/lib/src/gotrue_client.dart index ea626fe04..cce2fc1ae 100644 --- a/packages/gotrue/lib/src/gotrue_client.dart +++ b/packages/gotrue/lib/src/gotrue_client.dart @@ -747,12 +747,58 @@ 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, skipping the `/token` refresh round-trip. + 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 + Constants.expiryMargin.inSeconds; + + if (hasExpired) { + return await _callRefreshToken(refreshToken); + } + + 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); + + final response = AuthResponse(session: session); + return response; } /// 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..3c243a1e1 100644 --- a/packages/gotrue/test/client_test.dart +++ b/packages/gotrue/test/client_test.dart @@ -283,12 +283,98 @@ 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( + '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 { + await expectLater( + () => client.setSession('some-refresh-token', accessToken: ''), + throwsA(isA()), + ); + }); + + test( + 'Set session with malformed access token throws AuthInvalidJwtException', + () async { + await expectLater( + () => client.setSession('some-refresh-token', + accessToken: 'not-a-valid-jwt'), + throwsA(isA()), + ); }); test('Refresh session with refreshToken when no current session exists', 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..64c8fb48e --- /dev/null +++ b/packages/gotrue/test/src/set_session_test.dart @@ -0,0 +1,219 @@ +import 'dart:async'; +import 'dart:convert'; + +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). +class _SetSessionMockClient extends BaseClient { + int userCallCount = 0; + + @override + Future send(BaseRequest request) async { + if (request.url.path.endsWith('/user')) { + userCallCount++; + 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 = + _makeRawJwt({'exp': exp, 'iat': iat, 'sub': 'mock-user-id'}); + 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); + } +} + +/// 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() { + 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 = + _makeRawJwt({'exp': exp, 'iat': exp - 3600, 'sub': 'mock-user-id'}); + + 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 = _makeRawJwt( + {'exp': timeNow + 20, 'iat': timeNow - 3580, 'sub': 'mock-user-id'}); + + 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 = _makeRawJwt({'role': 'authenticated', 'sub': 'mock-user-id'}); + + 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 = _makeRawJwt({'exp': exp, 'iat': iat, 'sub': 'mock-user-id'}); + + 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 = _makeRawJwt({'exp': exp, 'sub': 'mock-user-id'}); + + 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 = _makeRawJwt({'exp': exp, 'iat': iat, 'sub': 'mock-user-id'}); + + 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 = _makeRawJwt({'exp': exp, 'iat': iat, 'sub': 'mock-user-id'}); + + 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 = _makeRawJwt({'exp': exp, 'iat': iat, 'sub': 'mock-user-id'}); + + 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 = _makeRawJwt( + {'exp': timeNow - 100, 'iat': timeNow - 3700, 'sub': 'mock-user-id'}); + + expect( + client.onAuthStateChange, + emits(predicate( + (s) => s.event == AuthChangeEvent.tokenRefreshed)), + ); + + await client.setSession('some-refresh-token', accessToken: at); + }); + }); +}