diff --git a/MIGRATION.md b/MIGRATION.md index ae64a417b..7ed625103 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -140,6 +140,40 @@ 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; +``` + +`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 +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/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(); + }); + }); +} 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); });