From a8da50bf06814671d7f0a2143d3a7e939b410cf7 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Thu, 17 Sep 2026 11:16:12 +0200 Subject: [PATCH 1/2] refactor(auth)!: return Session from the auth methods that always issue one signInAnonymously, signInWithPassword, signInWithIdToken, signInWithWeb3, linkIdentityWithIdToken, refreshSession, setSession, recoverSession and AuthPasskeyApi.verifyAuthentication (and with it signInWithPasskey and signInWithRestoreKey) return the Session directly instead of an AuthResponse with a nullable session. A token response without a session throws an AuthException instead of resolving with session set to null. signUp and verifyOTP keep returning AuthResponse, as both can legitimately complete without a session. --- MIGRATION.md | 33 +++++ .../authentication/lib/auth_repository.dart | 4 +- packages/supabase_auth/example/main.dart | 2 +- .../supabase_auth/lib/src/auth_client.dart | 130 ++++++++---------- .../lib/src/auth_passkey_api.dart | 16 +-- .../lib/src/types/auth_response.dart | 3 +- packages/supabase_auth/test/client_test.dart | 71 ++++------ .../supabase_auth/test/otp_mock_test.dart | 6 +- packages/supabase_auth/test/passkey_test.dart | 9 +- .../test/refresh_token_race_test.dart | 33 ++--- .../test/session_persistence_test.dart | 26 ++-- .../test/src/auth_mfa_api_test.dart | 5 +- .../test/src/auth_oauth_api_test.dart | 4 +- .../test/src/set_session_test.dart | 30 ++-- .../test/src/token_refresh_race_test.dart | 11 +- .../test/web3_auth_integration_test.dart | 9 +- .../supabase_auth/test/web3_auth_test.dart | 11 +- packages/supabase_flutter/README.md | 6 +- .../lib/src/supabase_passkey.dart | 2 +- .../lib/src/supabase_restore_credential.dart | 2 +- .../test/restore_credential_test.dart | 10 +- .../lib/src/test_supabase_client.dart | 5 +- .../test/mock_supabase_http_client_test.dart | 4 +- 23 files changed, 194 insertions(+), 238 deletions(-) diff --git a/MIGRATION.md b/MIGRATION.md index ae64a417b..d5c74e5fc 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -140,6 +140,39 @@ final Uri url = response.url; Use `response.url.toString()` if you need the string, and drop any `Uri.parse()` you were doing yourself. +### Sign-in methods that always issue a session return the `Session` + +The auth methods that cannot complete without a session return it directly instead of an +`AuthResponse` with a nullable `session`: + +- `signInAnonymously()` +- `signInWithPassword()` +- `signInWithIdToken()` +- `signInWithWeb3()` +- `linkIdentityWithIdToken()` +- `refreshSession()` +- `setSession()` +- `recoverSession()` +- `passkey.verifyAuthentication()`, and with it `signInWithPasskey()` and `signInWithRestoreKey()` + +```dart +// Before +final response = await supabase.auth.signInWithPassword(email: email, password: password); +final session = response.session!; +final user = response.user!; + +// After +final session = await supabase.auth.signInWithPassword(email: email, password: password); +final user = session.user; +``` + +A response without a session throws an `AuthException` where it used to resolve with `session` set +to `null`. + +`signUp()` and `verifyOTP()` keep returning an `AuthResponse`, since both can legitimately complete +without a session: a sign-up that needs email confirmation first, and the first step of a secure +email or phone change. + ### `admin.listUsers()` returns pagination metadata `listUsers()` returns a `ListUsersResponse` instead of a `List`. The users are under `users`, diff --git a/examples/authentication/lib/auth_repository.dart b/examples/authentication/lib/auth_repository.dart index 39fdbfdee..e3a734db3 100644 --- a/examples/authentication/lib/auth_repository.dart +++ b/examples/authentication/lib/auth_repository.dart @@ -31,7 +31,7 @@ class AuthRepository { } /// Signs an existing user in with their email and password. - Future signInWithPassword({ + Future signInWithPassword({ required String email, required String password, }) { @@ -128,7 +128,7 @@ class AuthRepository { // Anonymous ---------------------------------------------------------------- /// Signs in without any credentials, creating a throwaway anonymous user. - Future signInAnonymously() { + Future signInAnonymously() { return _client.auth.signInAnonymously(); } diff --git a/packages/supabase_auth/example/main.dart b/packages/supabase_auth/example/main.dart index f498bcf2a..8722f887b 100644 --- a/packages/supabase_auth/example/main.dart +++ b/packages/supabase_auth/example/main.dart @@ -22,7 +22,7 @@ Future main() async { email: 'email', password: '12345', ); - print('Logged in, user id: ${login.session!.user.id}'); + print('Logged in, user id: ${login.user.id}'); } on AuthException catch (error) { print('Sign in error: ${error.message}'); } diff --git a/packages/supabase_auth/lib/src/auth_client.dart b/packages/supabase_auth/lib/src/auth_client.dart index 658c316f0..3f19ca047 100644 --- a/packages/supabase_auth/lib/src/auth_client.dart +++ b/packages/supabase_auth/lib/src/auth_client.dart @@ -174,7 +174,7 @@ class AuthClient { /// Tracks all pending (in-flight) refreshes keyed by token. /// Concurrent calls with the same token return the existing /// [Completer.future] instead of starting a duplicate request. - final Map> _pendingRefreshes = {}; + final Map> _pendingRefreshes = {}; /// Set by [dispose] to prevent [_doRefresh] from mutating state /// or emitting events on closed stream controllers, and to stop @@ -421,8 +421,7 @@ class AuthClient { // Concurrent callers share a single refresh through the same // de-duplication used by [refreshSession], so an expired session's // refresh token is only spent once. - final response = await _callRefreshToken(refreshToken); - return response.session; + return await _callRefreshToken(refreshToken); } on AuthException { final current = _currentSession; if (current != null && !current.isExpiredWithoutMargin) { @@ -434,9 +433,9 @@ class AuthClient { /// Creates a new anonymous user. /// - /// Returns An `AuthResponse` with a session where the `is_anonymous` claim - /// in the access token JWT is set to true - Future signInAnonymously({ + /// Returns the new session, whose access token carries an `is_anonymous` + /// claim set to true. + Future signInAnonymously({ Map? data, String? captchaToken, }) async { @@ -452,15 +451,10 @@ class AuthClient { ), ); - final authResponse = AuthResponse.fromJson(response); - - final session = authResponse.session; - if (session != null) { - _saveSession(session); - notifyAllSubscribers(AuthChangeEvent.signedIn); - } - - return authResponse; + final session = _sessionFromResponse(response); + _saveSession(session); + notifyAllSubscribers(AuthChangeEvent.signedIn); + return session; } /// Creates a new user. @@ -554,7 +548,9 @@ class AuthClient { } /// Log in an existing user with an email and password or phone and password. - Future signInWithPassword({ + /// + /// Returns the new session. + Future signInWithPassword({ String? email, String? phone, required String password, @@ -597,13 +593,10 @@ class AuthClient { ); } - final authResponse = AuthResponse.fromJson(response); - - if (authResponse.session?.accessToken != null) { - _saveSession(authResponse.session!); - notifyAllSubscribers(AuthChangeEvent.signedIn); - } - return authResponse; + final session = _sessionFromResponse(response); + _saveSession(session); + notifyAllSubscribers(AuthChangeEvent.signedIn); + return session; } /// Generates a link to log in an user via a third-party provider. @@ -763,7 +756,7 @@ class AuthClient { /// /// [captchaToken] is the verification token received when the user /// completes the captcha on the app. - Future signInWithIdToken({ + Future signInWithIdToken({ required OAuthProvider provider, required String idToken, String? accessToken, @@ -786,16 +779,10 @@ class AuthClient { ), ); - final authResponse = AuthResponse.fromJson(response); - - if (authResponse.session == null) { - throw AuthException('An error occurred on token verification.'); - } - - _saveSession(authResponse.session!); + final session = _sessionFromResponse(response); + _saveSession(session); notifyAllSubscribers(AuthChangeEvent.signedIn); - - return authResponse; + return session; } /// Signs in a user by verifying a message signed with their Web3 wallet. @@ -812,7 +799,7 @@ class AuthClient { /// completes the captcha on the app. /// /// See also https://eips.ethereum.org/EIPS/eip-4361 - Future signInWithWeb3({ + Future signInWithWeb3({ required Web3Chain chain, required String message, required String signature, @@ -834,16 +821,10 @@ class AuthClient { ), ); - final authResponse = AuthResponse.fromJson(response); - - if (authResponse.session == null) { - throw AuthException('An error occurred on token verification.'); - } - - _saveSession(authResponse.session!); + final session = _sessionFromResponse(response); + _saveSession(session); notifyAllSubscribers(AuthChangeEvent.signedIn); - - return authResponse; + return session; } /// Log in a user using magiclink or a one-time password (OTP). @@ -1066,7 +1047,7 @@ class AuthClient { /// [refreshToken]. If not provided, then refreshSession() will attempt to /// retrieve it from the current session. If no refresh token is available /// (neither provided nor in current session), an error will be thrown. - Future refreshSession([String? refreshToken]) async { + Future refreshSession([String? refreshToken]) async { authLogger.info('Refresh session'); final currentSessionRefreshToken = @@ -1235,7 +1216,7 @@ class AuthClient { /// 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( + Future setSession( String refreshToken, { String? accessToken, }) async { @@ -1282,8 +1263,7 @@ class AuthClient { _saveSession(session); notifyAllSubscribers(AuthChangeEvent.signedIn); - final response = AuthResponse(session: session); - return response; + return session; } /// Gets the session data from a magic link or oauth2 callback URL @@ -1478,7 +1458,7 @@ class AuthClient { /// /// [captchaToken] is the verification token received when the user /// completes the captcha on the app. - Future linkIdentityWithIdToken({ + Future linkIdentityWithIdToken({ required OAuthProvider provider, required String idToken, String? accessToken, @@ -1503,16 +1483,10 @@ class AuthClient { ), ); - final authResponse = AuthResponse.fromJson(response); - - if (authResponse.session == null) { - throw AuthException('An error occurred on token verification.'); - } - - _saveSession(authResponse.session!); + final session = _sessionFromResponse(response); + _saveSession(session); notifyAllSubscribers(AuthChangeEvent.userUpdated); - - return authResponse; + return session; } /// Returns the URL to link the user's identity with an OAuth provider. @@ -1582,7 +1556,7 @@ class AuthClient { } /// Recover session from stringified [Session]. - Future recoverSession(String jsonString) async { + Future recoverSession(String jsonString) async { final String refreshToken; try { final session = Session.fromJson(json.decode(jsonString)); @@ -1612,7 +1586,7 @@ class AuthClient { notifyAllSubscribers(AuthChangeEvent.tokenRefreshed); } - return AuthResponse(session: session); + return session; } authLogger.fine('Session from recovery is expired'); @@ -1624,7 +1598,7 @@ class AuthClient { authLogger.fine( 'Session was already refreshed elsewhere, skipping recovery', ); - return AuthResponse(session: existingSession); + return existingSession; } final token = session.refreshToken; @@ -1728,7 +1702,7 @@ class AuthClient { /// Generates a new JWT. /// [refreshToken] A valid refresh token that was returned on login. - Future _refreshAccessToken(String refreshToken) async { + Future _refreshAccessToken(String refreshToken) async { final startedAt = DateTime.now(); var attempt = 0; return await retry( @@ -1746,8 +1720,7 @@ class AuthClient { HttpMethod.post, options: options, ); - final authResponse = AuthResponse.fromJson(response); - return authResponse; + return _sessionFromResponse(response); }, options: retryOptions, retryIf: (e) { @@ -1812,6 +1785,16 @@ class AuthClient { return url; } + /// Parses the session out of a token response, failing when the server + /// answered without one. + Session _sessionFromResponse(Map response) { + final session = Session.fromJson(response); + if (session == null) { + throw AuthException('The server response did not contain a session.'); + } + return session; + } + /// Sets the current session and persists it. void _saveSession(Session session) { authLogger.fine('Saving session'); @@ -2005,7 +1988,7 @@ class AuthClient { /// saved, [AuthChangeEvent.tokenRefreshed] emitted) when /// [_sessionVersion] has not changed — meaning no sign-in, sign-out, /// or other session mutation occurred while the request was in-flight. - Future _callRefreshToken(String refreshToken) { + Future _callRefreshToken(String refreshToken) { // De-duplicate: return existing future if this token is already // in-flight. final existing = _pendingRefreshes[refreshToken]; @@ -2017,7 +2000,7 @@ class AuthClient { // The completer is kept as an external handle so [dispose] can cancel an // in-flight refresh: the network request itself cannot be interrupted, so // a hung refresh can only be resolved by completing this completer. - final completer = Completer(); + final completer = Completer(); completer.future.ignore(); _pendingRefreshes[refreshToken] = completer; @@ -2042,22 +2025,17 @@ class AuthClient { /// Performs a single token refresh, applies the outcome to the local session /// and notifies subscribers. /// - /// Returns the refreshed [AuthResponse] or throws the underlying error. This + /// Returns the refreshed [Session] or throws the underlying error. This /// is the single place that emits refresh outcomes: /// [AuthChangeEvent.tokenRefreshed] on success, [AuthChangeEvent.signedOut] /// when the refresh token is invalid, or a stream error ([notifyException]) /// for a retryable/unexpected failure. - Future _doRefresh(String refreshToken) async { + Future _doRefresh(String refreshToken) async { final versionBeforeRefresh = _sessionVersion; authLogger.fine('Refresh access token'); try { - final data = await _refreshAccessToken(refreshToken); - - final session = data.session; - if (session == null) { - throw AuthSessionMissingException(); - } + final session = await _refreshAccessToken(refreshToken); // Discard the result if the client was disposed or the session was // mutated (e.g. a concurrent signIn or signOut) while we were awaiting @@ -2066,12 +2044,12 @@ class AuthClient { authLogger.fine( 'Session changed during refresh, discarding stale result.', ); - return data; + return session; } _saveSession(session); notifyAllSubscribers(AuthChangeEvent.tokenRefreshed); - return data; + return session; } on AuthException catch (error, stack) { final existingSession = _currentSession; if (error is AuthApiException && @@ -2082,7 +2060,7 @@ class AuthClient { 'Refresh token already used but current session is still valid, ' 'returning it instead of signing out', ); - return AuthResponse(session: existingSession); + return existingSession; } if (error is! AuthRetryableFetchException) { diff --git a/packages/supabase_auth/lib/src/auth_passkey_api.dart b/packages/supabase_auth/lib/src/auth_passkey_api.dart index e954a1bc2..1ca6eb917 100644 --- a/packages/supabase_auth/lib/src/auth_passkey_api.dart +++ b/packages/supabase_auth/lib/src/auth_passkey_api.dart @@ -31,7 +31,7 @@ part of 'auth_client.dart'; /// final authentication = await supabase.auth.passkey.startAuthentication(); /// // Perform the platform ceremony with authentication.options. /// final credential = await platformGetPasskey(authentication.options); -/// final response = await supabase.auth.passkey.verifyAuthentication( +/// final session = await supabase.auth.passkey.verifyAuthentication( /// challengeId: authentication.challengeId, /// credential: credential, /// ); @@ -169,7 +169,7 @@ class AuthPasskeyApi { /// /// On success the session is persisted and an /// [AuthChangeEvent.signedIn] event is fired. - Future verifyAuthentication({ + Future verifyAuthentication({ required String challengeId, required Map credential, }) async { @@ -185,14 +185,10 @@ class AuthPasskeyApi { ), ); - final authResponse = AuthResponse.fromJson(data); - final session = authResponse.session; - if (session != null) { - _client._saveSession(session); - _client.notifyAllSubscribers(AuthChangeEvent.signedIn); - } - - return authResponse; + final session = _client._sessionFromResponse(data); + _client._saveSession(session); + _client.notifyAllSubscribers(AuthChangeEvent.signedIn); + return session; } /// Returns the list of passkeys registered to the signed in user. diff --git a/packages/supabase_auth/lib/src/types/auth_response.dart b/packages/supabase_auth/lib/src/types/auth_response.dart index 802272e51..c10d365c6 100644 --- a/packages/supabase_auth/lib/src/types/auth_response.dart +++ b/packages/supabase_auth/lib/src/types/auth_response.dart @@ -1,6 +1,7 @@ import 'package:supabase_auth/supabase_auth.dart'; -/// Response which might or might not contain session and/or user +/// Response of `AuthClient.signUp` and `AuthClient.verifyOTP`, the two calls +/// that can complete without issuing a session. class AuthResponse { AuthResponse({ this.session, diff --git a/packages/supabase_auth/test/client_test.dart b/packages/supabase_auth/test/client_test.dart index 7e7acfa83..a1e3e64cf 100644 --- a/packages/supabase_auth/test/client_test.dart +++ b/packages/supabase_auth/test/client_test.dart @@ -96,10 +96,10 @@ void main() { }); test('anonymous sign-in', () async { - final response = await client.signInAnonymously(data: {'Hello': 'World'}); - expect(response.session?.accessToken, isA()); - expect(response.user?.isAnonymous, isTrue); - expect(response.user?.userMetadata, {'Hello': 'World'}); + final session = await client.signInAnonymously(data: {'Hello': 'World'}); + expect(session.accessToken, isA()); + expect(session.user.isAnonymous, isTrue); + expect(session.user.userMetadata, {'Hello': 'World'}); }); test('signUp() with email', () async { @@ -237,19 +237,17 @@ void main() { }); test('signInWithPassword() with email', () async { - final response = await client.signInWithPassword( + final session = await client.signInWithPassword( email: email1, password: password, ); - final data = response.session; + expect(session.accessToken, isA()); + expect(session.refreshToken, isA()); + expect(session.user.id, isA()); - expect(data?.accessToken, isA()); - expect(data?.refreshToken, isA()); - expect(data?.user.id, isA()); - - final payload = decodeJwt(data!.accessToken).payload; + final payload = decodeJwt(session.accessToken).payload; expect( - data.expiresAt, + session.expiresAt, DateTime.fromMillisecondsSinceEpoch( payload.expiresAt! * 1000, isUtc: true, @@ -267,19 +265,17 @@ void main() { }); test('signInWithPassword() with phone', () async { - final response = await client.signInWithPassword( + final session = await client.signInWithPassword( phone: phone1, password: password, ); - final data = response.session; - - expect(data?.accessToken, isA()); - expect(data?.refreshToken, isA()); - expect(data?.user.id, isA()); + expect(session.accessToken, isA()); + expect(session.refreshToken, isA()); + expect(session.user.id, isA()); - final payload = decodeJwt(data!.accessToken).payload; + final payload = decodeJwt(session.accessToken).payload; expect( - data.expiresAt, + session.expiresAt, DateTime.fromMillisecondsSinceEpoch( payload.expiresAt! * 1000, isUtc: true, @@ -345,15 +341,13 @@ void main() { ]), ); - final response = await newClient.setSession( + final session = 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(session.accessToken, equals(accessToken)); + expect(session.refreshToken, equals(refreshToken)); expect(newClient.currentSession?.accessToken, equals(accessToken)); }, ); @@ -381,16 +375,12 @@ void main() { ); // Should fall back to _callRefreshToken and succeed. - final response = await newClient.setSession( + final session = await newClient.setSession( refreshToken, accessToken: expiredAccessToken, ); - expect(response.session, isNotNull); - expect( - response.session?.accessToken, - isNot(equals(expiredAccessToken)), - ); + expect(session.accessToken, isNot(equals(expiredAccessToken))); expect(newClient.currentSession?.accessToken, isNotEmpty); }, ); @@ -436,9 +426,8 @@ void main() { // This should work even though there's no current session, // because we're providing a refreshToken parameter - final response = await newClient.refreshSession(refreshToken); - expect(response.session, isNotNull); - expect(response.session?.accessToken, isNotEmpty); + final session = await newClient.refreshSession(refreshToken); + expect(session.accessToken, isNotEmpty); expect(newClient.currentSession?.accessToken, isNotEmpty); }, ); @@ -611,16 +600,12 @@ void main() { // These 3 are bundled and in sum 1 refresh token requests is made, // because the first 3 fail in [RetryTestHttpClient] - final responses = await Future.wait([ + final sessions = await Future.wait([ bundledClient.recoverSession(session), bundledClient.recoverSession(session), ]); - expect(responses[0].session?.accessToken, isNotNull); - expect( - responses[0].session?.accessToken, - responses[1].session?.accessToken, - ); + expect(sessions[0].accessToken, sessions[1].accessToken); expect(httpClient.retryCount, 4); }); @@ -951,8 +936,7 @@ void main() { // First recovery refreshes the expired session, advancing the in-memory // session onto a brand new refresh token. final first = await client.recoverSession(expiredSessionString); - expect(first.session, isNotNull); - expect(first.session!.isExpired, isFalse); + expect(first.isExpired, isFalse); expect(httpClient.refreshCount, 1); var signedOut = false; @@ -968,8 +952,7 @@ void main() { // the first refresh completed. It must not resend the already-used // refresh token. final second = await client.recoverSession(expiredSessionString); - expect(second.session, isNotNull); - expect(second.session!.isExpired, isFalse); + expect(second.isExpired, isFalse); // No second refresh request was made and the user stays signed in. expect(httpClient.refreshCount, 1); diff --git a/packages/supabase_auth/test/otp_mock_test.dart b/packages/supabase_auth/test/otp_mock_test.dart index 1b40f1b7d..d91f91ea7 100644 --- a/packages/supabase_auth/test/otp_mock_test.dart +++ b/packages/supabase_auth/test/otp_mock_test.dart @@ -271,14 +271,12 @@ void main() { }); test('signInWithPassword() with phone number', () async { - final response = await client.signInWithPassword( + final session = await client.signInWithPassword( phone: testPhone, password: testPassword, ); - expect(response.session, isNotNull); - expect(response.user, isNotNull); - expect(response.user?.phone, testPhone); + expect(session.user.phone, testPhone); // Verify session was set expect(client.currentSession, isNotNull); diff --git a/packages/supabase_auth/test/passkey_test.dart b/packages/supabase_auth/test/passkey_test.dart index a2e8e0f72..cdbadc283 100644 --- a/packages/supabase_auth/test/passkey_test.dart +++ b/packages/supabase_auth/test/passkey_test.dart @@ -24,7 +24,7 @@ void main() { client.dispose(); }); - Future signInWithPasskey() { + Future signInWithPasskey() { return client.passkey.verifyAuthentication( challengeId: PasskeyMockClient.challengeId, credential: { @@ -80,7 +80,7 @@ void main() { onError: (_) {}, ); - final response = await signInWithPasskey(); + final session = await signInWithPasskey(); expect(mockClient.lastUrl?.path, '/passkeys/authentication/verify'); expect( @@ -91,9 +91,8 @@ void main() { mockClient.lastRequestBody?['credential'], isA>(), ); - expect(response.session, isNotNull); - expect(response.session?.accessToken, 'mock-access-token'); - expect(response.user?.id, PasskeyMockClient.userId); + expect(session.accessToken, 'mock-access-token'); + expect(session.user.id, PasskeyMockClient.userId); expect(client.currentSession?.accessToken, 'mock-access-token'); await Future.delayed(Duration.zero); expect(events, contains(AuthChangeEvent.signedIn)); diff --git a/packages/supabase_auth/test/refresh_token_race_test.dart b/packages/supabase_auth/test/refresh_token_race_test.dart index bb7128755..242675d8c 100644 --- a/packages/supabase_auth/test/refresh_token_race_test.dart +++ b/packages/supabase_auth/test/refresh_token_race_test.dart @@ -177,11 +177,7 @@ void main() { ]); // Both should succeed with same token (bundled into one request) - expect(results[0].session?.accessToken, isNotNull); - expect( - results[0].session?.accessToken, - results[1].session?.accessToken, - ); + expect(results[0].accessToken, results[1].accessToken); // Only ONE HTTP request should have been made (bundling works) expect( @@ -207,8 +203,7 @@ void main() { final expiredSession = createExpiredSessionForUser1(); // First call succeeds and refreshes - final result1 = await client.recoverSession(expiredSession); - expect(result1.session?.accessToken, isNotNull); + await client.recoverSession(expiredSession); expect(httpClient.requestCount, 1); final newRefreshToken = client.currentSession?.refreshToken; @@ -218,10 +213,8 @@ void main() { // FIXED: Should return current valid session without making new request final result2 = await client.recoverSession(expiredSession); - // Should succeed (not throw) - expect(result2.session, isNotNull); // Should return the CURRENT valid session - expect(result2.session?.refreshToken, newRefreshToken); + expect(result2.refreshToken, newRefreshToken); // Should NOT have made another HTTP request (early return in // recoverSession) expect( @@ -266,8 +259,7 @@ void main() { holdFirstRequest.complete(); // Wait for recovery to complete - final result = await recoverFuture; - expect(result.session?.accessToken, isNotNull); + await recoverFuture; // Stop auto-refresh to clean up client.stopAutoRefresh(); @@ -308,8 +300,7 @@ void main() { await Future.delayed(Duration(milliseconds: 100)); // FIXED: Should succeed without throwing - final result = await recoverFuture; - expect(result.session, isNotNull); + await recoverFuture; client.stopAutoRefresh(); @@ -356,8 +347,7 @@ void main() { // 5. Attempt refresh - this will get "already_used" error from server // The error handler should detect we have a valid session and return it - final response = await client.refreshSession(); - expect(response.session, isNotNull); + await client.refreshSession(); // Session should still be valid (the error handler returned current // session) @@ -396,10 +386,7 @@ void main() { // Second call with stale token (same user) - should return current // session - final result2 = await client.recoverSession(expiredSession); - - // Should succeed - expect(result2.session, isNotNull); + await client.recoverSession(expiredSession); // Wait for any events await Future.delayed(Duration(milliseconds: 50)); @@ -447,8 +434,7 @@ void main() { await Future.delayed(Duration(milliseconds: 10)); // FIXED: Both should succeed - final result = await recoverFuture; - expect(result.session, isNotNull); + await recoverFuture; client.stopAutoRefresh(); @@ -486,8 +472,7 @@ void main() { // FIXED: Should return current session without new request final result2 = await client.recoverSession(expiredSession); - expect(result2.session, isNotNull); - expect(result2.session?.refreshToken, currentToken); + expect(result2.refreshToken, currentToken); expect( httpClient.requestCount, 1, diff --git a/packages/supabase_auth/test/session_persistence_test.dart b/packages/supabase_auth/test/session_persistence_test.dart index dcdf30d76..c1c88bca9 100644 --- a/packages/supabase_auth/test/session_persistence_test.dart +++ b/packages/supabase_auth/test/session_persistence_test.dart @@ -73,7 +73,7 @@ void main() { final client = createClient(); await client.initialized; - final response = await client.signInWithPassword( + final session = await client.signInWithPassword( email: email1, password: password, ); @@ -83,7 +83,7 @@ void main() { expect(persisted, isNotNull); expect( Session.fromJson(jsonDecode(persisted!))?.accessToken, - response.session?.accessToken, + session.accessToken, ); await client.signOut(); @@ -95,7 +95,7 @@ void main() { test('restores the persisted session in a new client', () async { final client = createClient(); await client.initialized; - final response = await client.signInWithPassword( + final session = await client.signInWithPassword( email: email1, password: password, ); @@ -106,11 +106,11 @@ void main() { expect( restored.currentSession?.accessToken, - response.session?.accessToken, + session.accessToken, ); final state = await restored.onAuthStateChange.first; expect(state.event, AuthChangeEvent.initialSession); - expect(state.session?.accessToken, response.session?.accessToken); + expect(state.session?.accessToken, session.accessToken); }); test('stores the session under a custom storage key', () async { @@ -239,18 +239,18 @@ void main() { final subscription = client.onAuthStateChange.listen(states.add); addTearDown(subscription.cancel); - final response = await client.signInWithPassword( + final session = await client.signInWithPassword( email: email1, password: password, ); await client.initialized; await settle(); - expect(client.currentSession?.accessToken, response.session?.accessToken); + expect(client.currentSession?.accessToken, session.accessToken); final persisted = await slowStorage.getItem(storageKey); expect( Session.fromJson(jsonDecode(persisted!))?.accessToken, - response.session?.accessToken, + session.accessToken, ); expect(states.map((state) => state.event), [ AuthChangeEvent.initialSession, @@ -258,7 +258,7 @@ void main() { ]); expect( states.first.session?.accessToken, - response.session?.accessToken, + session.accessToken, ); }); @@ -319,7 +319,7 @@ void main() { 'event', () async { final client = createClient(persistSession: false); await client.initialized; - final response = await client.signInWithPassword( + final session = await client.signInWithPassword( email: email1, password: password, ); @@ -327,7 +327,7 @@ void main() { final state = await client.onAuthStateChange.first; expect(state.event, AuthChangeEvent.initialSession); - expect(state.session?.accessToken, response.session?.accessToken); + expect(state.session?.accessToken, session.accessToken); }); test('every subscriber receives its own initial event', () async { @@ -353,13 +353,13 @@ void main() { addTearDown(client.dispose); await client.initialized; - final response = await client.signInWithPassword( + final session = await client.signInWithPassword( email: email1, password: password, ); await settle(); - expect(client.currentSession?.accessToken, response.session?.accessToken); + expect(client.currentSession?.accessToken, session.accessToken); }); } diff --git a/packages/supabase_auth/test/src/auth_mfa_api_test.dart b/packages/supabase_auth/test/src/auth_mfa_api_test.dart index c4d0346a6..eb702625c 100644 --- a/packages/supabase_auth/test/src/auth_mfa_api_test.dart +++ b/packages/supabase_auth/test/src/auth_mfa_api_test.dart @@ -251,9 +251,8 @@ void main() { test('Session object can be properly json serialized', () async { await client.signInWithPassword(password: password, email: email2); await client.mfa.challengeAndVerify(factorId: factorId2, code: getTOTP()); - final response = await client.refreshSession(); - final session = response.session; - final deserializedSession = Session.fromJson(session!.toJson()); + final session = await client.refreshSession(); + final deserializedSession = Session.fromJson(session.toJson()); expect(session, deserializedSession); }); }); diff --git a/packages/supabase_auth/test/src/auth_oauth_api_test.dart b/packages/supabase_auth/test/src/auth_oauth_api_test.dart index fa90a8417..252c35593 100644 --- a/packages/supabase_auth/test/src/auth_oauth_api_test.dart +++ b/packages/supabase_auth/test/src/auth_oauth_api_test.dart @@ -222,7 +222,7 @@ void main() { expect(details.redirectUri, equals(clientParameters.redirectUris.first)); expect(details.client.clientId, equals(oauthClient.clientId)); expect(details.client.clientName, equals(oauthClient.clientName)); - expect(details.user.id, equals(auth.user?.id)); + expect(details.user.id, equals(auth.user.id)); expect(details.user.email, equals(email1)); }); @@ -452,7 +452,7 @@ class AuthOauthApiFixture { return Uri.parse(location).queryParameters['authorization_id']!; } - Future logIn({ + Future logIn({ required String email, required String password, }) { diff --git a/packages/supabase_auth/test/src/set_session_test.dart b/packages/supabase_auth/test/src/set_session_test.dart index 7a1bdcf9f..321341cfb 100644 --- a/packages/supabase_auth/test/src/set_session_test.dart +++ b/packages/supabase_auth/test/src/set_session_test.dart @@ -75,15 +75,14 @@ void main() { 'sub': 'mock-user-id', }); - final response = await client.setSession( + final session = await client.setSession( 'some-refresh-token', accessToken: accessToken, ); - expect(response.session, isNotNull); // The returned token must be the freshly refreshed one, not our // near-expired JWT. - expect(response.session?.accessToken, isNot(equals(accessToken))); + expect(session.accessToken, isNot(equals(accessToken))); expect(mockClient.requestsTo('/user'), isEmpty); // /user was NOT called }); @@ -95,13 +94,12 @@ void main() { 'sub': 'mock-user-id', }); - final response = await client.setSession( + final session = await client.setSession( 'some-refresh-token', accessToken: accessToken, ); - expect(response.session, isNotNull); - expect(response.session?.accessToken, isNot(equals(accessToken))); + expect(session.accessToken, isNot(equals(accessToken))); expect(mockClient.requestsTo('/user'), isEmpty); }); }); @@ -118,13 +116,13 @@ void main() { 'sub': 'mock-user-id', }); - final response = await client.setSession( + final session = await client.setSession( 'some-refresh-token', accessToken: accessToken, ); // expiresIn should be the total token lifetime (exp - iat = 3600). - expect(response.session?.expiresIn, equals(expiresAt - issuedAt)); + expect(session.expiresIn, equals(expiresAt - issuedAt)); }, ); @@ -136,12 +134,12 @@ void main() { 'sub': 'mock-user-id', }); - final response = await client.setSession( + final session = await client.setSession( 'some-refresh-token', accessToken: accessToken, ); - expect(response.session?.expiresIn, isNull); + expect(session.expiresIn, isNull); }); test('expiresAt matches the exp claim in the JWT', () async { @@ -153,14 +151,14 @@ void main() { 'sub': 'mock-user-id', }); - final response = await client.setSession( + final session = await client.setSession( 'some-refresh-token', accessToken: accessToken, ); // expiresAt is re-derived from the JWT's own exp, not from expiresIn. expect( - response.session?.expiresAt, + session.expiresAt, equals( DateTime.fromMillisecondsSinceEpoch(expiresAt * 1000, isUtc: true), ), @@ -179,14 +177,14 @@ void main() { 'sub': 'mock-user-id', }); - final response = await client.setSession( + final session = await client.setSession( refreshToken, accessToken: accessToken, ); - expect(response.session?.accessToken, equals(accessToken)); - expect(response.session?.refreshToken, equals(refreshToken)); - expect(response.session?.tokenType, equals('bearer')); + expect(session.accessToken, equals(accessToken)); + expect(session.refreshToken, equals(refreshToken)); + expect(session.tokenType, equals('bearer')); }, ); }); diff --git a/packages/supabase_auth/test/src/token_refresh_race_test.dart b/packages/supabase_auth/test/src/token_refresh_race_test.dart index c84abca18..3088f7ae0 100644 --- a/packages/supabase_auth/test/src/token_refresh_race_test.dart +++ b/packages/supabase_auth/test/src/token_refresh_race_test.dart @@ -200,11 +200,7 @@ void main() { ]); // Both should return the same access token (same network request). - expect(results[0].session?.accessToken, isNotNull); - expect( - results[0].session?.accessToken, - results[1].session?.accessToken, - ); + expect(results[0].accessToken, results[1].accessToken); // Only one /token request should have been made. expect(mockClient.tokenRequestCount, 1); @@ -240,10 +236,7 @@ void main() { final results = await Future.wait([futureA1, futureB, futureA2]); // Both A calls get the same access token - expect( - results[0].session?.accessToken, - results[2].session?.accessToken, - ); + expect(results[0].accessToken, results[2].accessToken); // Only 2 HTTP requests: one for A, one for B expect(mockClient.tokenRequestCount, 2); diff --git a/packages/supabase_auth/test/web3_auth_integration_test.dart b/packages/supabase_auth/test/web3_auth_integration_test.dart index 37904dbd8..b51800379 100644 --- a/packages/supabase_auth/test/web3_auth_integration_test.dart +++ b/packages/supabase_auth/test/web3_auth_integration_test.dart @@ -83,16 +83,15 @@ void main() { onError: (_) {}, ); - final response = await client.signInWithWeb3( + final session = await client.signInWithWeb3( chain: Web3Chain.solana, message: signed.message, signature: signed.signature, ); - expect(response.session, isNotNull); - expect(response.session?.accessToken, isNotEmpty); - expect(response.user?.appMetadata['provider'], 'web3'); - expect(client.currentSession?.accessToken, response.session?.accessToken); + expect(session.accessToken, isNotEmpty); + expect(session.user.appMetadata['provider'], 'web3'); + expect(client.currentSession?.accessToken, session.accessToken); await Future.delayed(Duration.zero); expect(events, contains(AuthChangeEvent.signedIn)); diff --git a/packages/supabase_auth/test/web3_auth_test.dart b/packages/supabase_auth/test/web3_auth_test.dart index e67222297..0018769f1 100644 --- a/packages/supabase_auth/test/web3_auth_test.dart +++ b/packages/supabase_auth/test/web3_auth_test.dart @@ -30,7 +30,7 @@ void main() { onError: (_) {}, ); - final response = await client.signInWithWeb3( + final session = await client.signInWithWeb3( chain: Web3Chain.ethereum, message: 'example.com wants you to sign in', signature: '0xdeadbeef', @@ -49,9 +49,8 @@ void main() { isFalse, ); - expect(response.session, isNotNull); - expect(response.session?.accessToken, 'mock-access-token'); - expect(response.user?.id, 'mock-user-id-web3'); + expect(session.accessToken, 'mock-access-token'); + expect(session.user.id, 'mock-user-id-web3'); expect(client.currentSession?.accessToken, 'mock-access-token'); await Future.delayed(Duration.zero); @@ -59,7 +58,7 @@ void main() { }); test('exchanges a Solana signature for a session', () async { - final response = await client.signInWithWeb3( + final session = await client.signInWithWeb3( chain: Web3Chain.solana, message: 'example.com wants you to sign in', signature: 'base64url-signature', @@ -67,7 +66,7 @@ void main() { expect(mockClient.lastRequestBody?['chain'], 'solana'); expect(mockClient.lastRequestBody?['signature'], 'base64url-signature'); - expect(response.session, isNotNull); + expect(client.currentSession, session); }); test('includes the captcha token when provided', () async { diff --git a/packages/supabase_flutter/README.md b/packages/supabase_flutter/README.md index 615e77dcf..34fce51fe 100644 --- a/packages/supabase_flutter/README.md +++ b/packages/supabase_flutter/README.md @@ -114,7 +114,7 @@ import 'package:sign_in_with_apple/sign_in_with_apple.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; /// Performs Apple sign in on iOS or macOS -Future signInWithApple() async { +Future signInWithApple() async { final rawNonce = supabase.auth.generateRawNonce(); final hashedNonce = sha256.convert(utf8.encode(rawNonce)).toString(); @@ -163,7 +163,7 @@ import 'package:supabase_flutter/supabase_flutter.dart'; ... -Future _googleSignIn() async { +Future _googleSignIn() async { /// TODO: update the Web client ID with your own. /// /// Web Client ID that you registered with Google Cloud. @@ -215,7 +215,7 @@ import 'dart:io'; import 'package:flutter_facebook_auth/flutter_facebook_auth.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; -Future _facebookSignIn() async { +Future _facebookSignIn() async { late final LoginResult result; if (Platform.isIOS) { diff --git a/packages/supabase_flutter/lib/src/supabase_passkey.dart b/packages/supabase_flutter/lib/src/supabase_passkey.dart index 5e906fea1..a8a4c967f 100644 --- a/packages/supabase_flutter/lib/src/supabase_passkey.dart +++ b/packages/supabase_flutter/lib/src/supabase_passkey.dart @@ -64,7 +64,7 @@ extension AuthClientPasskey on AuthClient { /// /// Does not require an existing session. On success the session is persisted /// and an [AuthChangeEvent.signedIn] event is fired. - Future signInWithPasskey( + Future signInWithPasskey( PasskeyAuthenticatorInterface authenticator, { String? captchaToken, }) async { diff --git a/packages/supabase_flutter/lib/src/supabase_restore_credential.dart b/packages/supabase_flutter/lib/src/supabase_restore_credential.dart index 97d10342a..3800092fa 100644 --- a/packages/supabase_flutter/lib/src/supabase_restore_credential.dart +++ b/packages/supabase_flutter/lib/src/supabase_restore_credential.dart @@ -122,7 +122,7 @@ extension AuthClientRestoreCredential on AuthClient { /// Call it on the first launch after the app has been restored on a new /// device. Does not require an existing session. On success the session is /// persisted and an [AuthChangeEvent.signedIn] event is fired. - Future signInWithRestoreKey( + Future signInWithRestoreKey( RestoreCredentialInterface restoreCredential, { String? captchaToken, }) async { diff --git a/packages/supabase_flutter/test/restore_credential_test.dart b/packages/supabase_flutter/test/restore_credential_test.dart index 7dd0629dd..e4e919812 100644 --- a/packages/supabase_flutter/test/restore_credential_test.dart +++ b/packages/supabase_flutter/test/restore_credential_test.dart @@ -278,7 +278,7 @@ void main() { ); final restore = _FakeRestoreCredential(); - final response = await client.signInWithRestoreKey( + final session = await client.signInWithRestoreKey( restore, captchaToken: 'captcha-token', ); @@ -304,13 +304,9 @@ void main() { 'credential': _FakeRestoreCredential.authenticationResponse.toJson(), }); - expect(response.session, isNotNull); - expect(client.currentSession?.accessToken, response.session?.accessToken); + expect(client.currentSession?.accessToken, session.accessToken); expect(client.currentUser?.id, testUserId); - expect( - (await signedIn).session?.accessToken, - response.session?.accessToken, - ); + expect((await signedIn).session?.accessToken, session.accessToken); }); test('rethrows platform errors without verifying', () async { diff --git a/packages/supabase_test/lib/src/test_supabase_client.dart b/packages/supabase_test/lib/src/test_supabase_client.dart index aeefa6600..829c21711 100644 --- a/packages/supabase_test/lib/src/test_supabase_client.dart +++ b/packages/supabase_test/lib/src/test_supabase_client.dart @@ -81,7 +81,7 @@ Future signInTestUser( String role = 'authenticated', Map claims = const {}, DateTime? expiresAt, -}) async { +}) { final expiry = expiresAt ?? DateTime.now().add(const Duration(hours: 1)); final accessToken = unsignedTestJwt({ 'exp': expiry.millisecondsSinceEpoch ~/ 1000, @@ -90,7 +90,7 @@ Future signInTestUser( 'email': email, ...claims, }); - final response = await auth.recoverSession( + return auth.recoverSession( jsonEncode( testSessionResponseJson( accessToken: accessToken, @@ -98,5 +98,4 @@ Future signInTestUser( ), ), ); - return response.session!; } diff --git a/packages/supabase_test/test/mock_supabase_http_client_test.dart b/packages/supabase_test/test/mock_supabase_http_client_test.dart index e918db4ab..12e4770c6 100644 --- a/packages/supabase_test/test/mock_supabase_http_client_test.dart +++ b/packages/supabase_test/test/mock_supabase_http_client_test.dart @@ -143,12 +143,12 @@ void main() { test('stubSignIn lets a password sign-in produce a session', () async { httpClient.stubSignIn(); - final response = await supabase.auth.signInWithPassword( + final session = await supabase.auth.signInWithPassword( email: 'fake1@email.com', password: 'password', ); - expect(response.session, isNotNull); + expect(session.user.id, testUserId); expect(supabase.auth.currentUser?.id, testUserId); }); From 5e96871b7963dff28ee5397cff1f1831269b23a8 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Thu, 17 Sep 2026 12:07:23 +0200 Subject: [PATCH 2/2] test(auth): cover a token response without a session and narrow the migration wording --- MIGRATION.md | 5 +- .../test/missing_session_response_test.dart | 79 +++++++++++++++++++ 2 files changed, 82 insertions(+), 2 deletions(-) create mode 100644 packages/supabase_auth/test/missing_session_response_test.dart diff --git a/MIGRATION.md b/MIGRATION.md index d5c74e5fc..7ed625103 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -166,8 +166,9 @@ final session = await supabase.auth.signInWithPassword(email: email, password: p final user = session.user; ``` -A response without a session throws an `AuthException` where it used to resolve with `session` set -to `null`. +`signInAnonymously()`, `signInWithPassword()` and `passkey.verifyAuthentication()` used to resolve +with `session` set to `null` when the response carried no session. They now throw an +`AuthException` in that case, as the other methods in the list already did. `signUp()` and `verifyOTP()` keep returning an `AuthResponse`, since both can legitimately complete without a session: a sign-up that needs email confirmation first, and the first step of a secure diff --git a/packages/supabase_auth/test/missing_session_response_test.dart b/packages/supabase_auth/test/missing_session_response_test.dart new file mode 100644 index 000000000..e039868d4 --- /dev/null +++ b/packages/supabase_auth/test/missing_session_response_test.dart @@ -0,0 +1,79 @@ +import 'package:supabase_auth/supabase_auth.dart'; +import 'package:test/test.dart'; + +import 'utils.dart'; + +void main() { + group('a token response without a session', () { + late MockSupabaseHttpClient httpClient; + late AuthClient client; + late List events; + + setUp(() async { + httpClient = MockSupabaseHttpClient()..stub(testUserJson()); + client = AuthClient( + url: 'http://localhost:9999', + httpClient: httpClient, + autoRefreshToken: false, + asyncStorage: TestAsyncStorage(), + ); + events = []; + client.onAuthStateChange.listen( + (state) => events.add(state.event), + onError: (_) {}, + ); + await pumpEventQueue(); + }); + + tearDown(() { + client.dispose(); + }); + + void expectNothingSignedIn() { + expect(client.currentSession, isNull); + expect(events, [AuthChangeEvent.initialSession]); + } + + test('makes signInWithPassword throw without signing in', () async { + await expectLater( + client.signInWithPassword( + email: 'fake1@email.com', + password: 'password', + ), + throwsA( + isA().having( + (error) => error.message, + 'message', + 'The server response did not contain a session.', + ), + ), + ); + await pumpEventQueue(); + + expectNothingSignedIn(); + }); + + test('makes signInAnonymously throw without signing in', () async { + await expectLater( + client.signInAnonymously(), + throwsA(isA()), + ); + await pumpEventQueue(); + + expectNothingSignedIn(); + }); + + test('makes signInWithIdToken throw without signing in', () async { + await expectLater( + client.signInWithIdToken( + provider: OAuthProvider.google, + idToken: 'id-token', + ), + throwsA(isA()), + ); + await pumpEventQueue(); + + expectNothingSignedIn(); + }); + }); +}