diff --git a/MIGRATION.md b/MIGRATION.md index 7ed625103..4e6a3218a 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -473,6 +473,54 @@ supabase.auth.onAuthStateChange.listen((data) { A client you construct yourself emits the event too. Earlier events and errors are no longer replayed. +### `AuthState` is a sealed class + +Each `AuthChangeEvent` has its own `AuthState` subtype carrying the data that event produces: +`AuthInitialSession`, `AuthSignedIn`, `AuthSignedOut`, `AuthTokenRefreshed`, `AuthUserUpdated`, +`AuthPasswordRecovery` and `AuthMfaChallengeVerified`. `session` is non-nullable on every subtype +except `AuthInitialSession`, where it is the session at subscription time or `null`, and +`AuthSignedOut`, where it is always `null`. + +`AuthState.event` and `AuthState.session` are still there, so a listener that compares `event` and +null-checks `session` keeps compiling. What changes: + +- `AuthState.signOutReason` moved to `AuthSignedOut.reason`. +- `AuthState` itself can no longer be constructed. Construct the subtype instead, for example in a + test that feeds a fake stream. + +```dart +// Before +supabase.auth.onAuthStateChange.listen((state) { + if (state.event == AuthChangeEvent.signedOut) { + if (state.signOutReason == SignOutReason.sessionExpired) { + showSessionExpired(); + } + showLogin(); + } else if (state.session != null) { + showHome(state.session!.user); + } +}); + +// After +supabase.auth.onAuthStateChange.listen((state) { + switch (state) { + case AuthSignedOut(reason: SignOutReason.sessionExpired): + showSessionExpired(); + showLogin(); + case AuthSignedOut(): + case AuthInitialSession(session: null): + showLogin(); + case AuthInitialSession(session: final session?): + case AuthSignedIn(:final session): + case AuthTokenRefreshed(:final session): + case AuthUserUpdated(:final session): + case AuthPasswordRecovery(:final session): + case AuthMfaChallengeVerified(:final session): + showHome(session.user); + } +}); +``` + ### The session is persisted with `SharedPreferencesAsync` `SharedPreferencesAuthAsyncStorage`, the storage `Supabase.initialize` uses by default, now writes diff --git a/packages/supabase_auth/lib/src/auth_client.dart b/packages/supabase_auth/lib/src/auth_client.dart index 3f19ca047..883870bea 100644 --- a/packages/supabase_auth/lib/src/auth_client.dart +++ b/packages/supabase_auth/lib/src/auth_client.dart @@ -250,17 +250,20 @@ class AuthClient { /// /// When the user is signed out because the session could not be recovered /// (e.g. an invalid or expired refresh token), an [AuthChangeEvent.signedOut] - /// event is emitted with [AuthState.signOutReason] set to the matching + /// event is emitted as an [AuthSignedOut] whose `reason` is the matching /// [SignOutReason], so you can tell it apart from an explicit [signOut] /// without relying on the `onError` handler. /// /// ```dart /// supabase.auth.onAuthStateChange.listen( - /// (data) { - /// final AuthChangeEvent event = data.event; - /// final Session? session = data.session; - /// if (event == AuthChangeEvent.signedIn) { - /// // handle signIn event + /// (state) { + /// switch (state) { + /// case AuthSignedIn(:final session): + /// showHome(session.user); + /// case AuthSignedOut(:final reason): + /// showLogin(expired: reason == SignOutReason.sessionExpired); + /// default: + /// // The other events, see [AuthState] for the full list. /// } /// }, /// onError: (error, stackTrace) { @@ -314,9 +317,7 @@ class AuthClient { return; } initialSent = true; - controller.addSync( - AuthState(AuthChangeEvent.initialSession, currentSession), - ); + controller.addSync(AuthInitialSession(currentSession)); for (final deliver in held) { deliver(); } @@ -1205,8 +1206,8 @@ class AuthClient { final session = currentSession; if (session != null) { _saveSession(session.copyWith(user: userResponse.user)); + notifyAllSubscribers(AuthChangeEvent.userUpdated); } - notifyAllSubscribers(AuthChangeEvent.userUpdated); return userResponse; } @@ -1943,10 +1944,18 @@ class AuthClient { if (messageEvent['session'] != null) { session = Session.fromJson(messageEvent['session']); } + final state = _authStateFor(event, session, fromBroadcast: true); + if (state == null) { + authLogger.warning( + 'Ignoring a broadcast ${event.name} event that carries no ' + 'session', + ); + return; + } // The tab that sent the event has already written the session // to the storage both tabs share. _currentSession = session; - notifyAllSubscribers(event, session: session, broadcast: false); + _emit(state, broadcast: false); } }); } catch (error, stackTrace) { @@ -2097,23 +2106,73 @@ class AuthClient { SignOutReason? signOutReason, }) { session ??= currentSession; - if (broadcast && event != AuthChangeEvent.initialSession) { - _broadcastChannel?.postMessage({ - 'event': event.value, - 'session': session?.toJson(), - }); - } - final state = AuthState( + final state = _authStateFor( event, session, fromBroadcast: !broadcast, signOutReason: signOutReason, ); + if (state == null) { + authLogger.warning( + 'Ignoring a ${event.name} event that carries no session', + ); + return; + } + _emit(state, broadcast: broadcast); + } + + /// Delivers [state] to the subscribers, and to the other tabs when + /// [broadcast] is set. + void _emit(AuthState state, {required bool broadcast}) { + if (broadcast && state is! AuthInitialSession) { + _broadcastChannel?.postMessage({ + 'event': state.event.value, + 'session': state.session?.toJson(), + }); + } authLogger.finest('onAuthStateChange: $state'); _onAuthStateChangeController.add(state); _onAuthStateChangeControllerSync.add(state); } + /// Builds the [AuthState] for [event], `null` when [event] carries a + /// session and [session] is missing. + AuthState? _authStateFor( + AuthChangeEvent event, + Session? session, { + required bool fromBroadcast, + SignOutReason? signOutReason, + }) { + return switch (event) { + AuthChangeEvent.initialSession => AuthInitialSession(session), + AuthChangeEvent.signedOut => AuthSignedOut( + reason: signOutReason, + fromBroadcast: fromBroadcast, + ), + _ when session == null => null, + AuthChangeEvent.signedIn => AuthSignedIn( + session, + fromBroadcast: fromBroadcast, + ), + AuthChangeEvent.tokenRefreshed => AuthTokenRefreshed( + session, + fromBroadcast: fromBroadcast, + ), + AuthChangeEvent.userUpdated => AuthUserUpdated( + session, + fromBroadcast: fromBroadcast, + ), + AuthChangeEvent.passwordRecovery => AuthPasswordRecovery( + session, + fromBroadcast: fromBroadcast, + ), + AuthChangeEvent.mfaChallengeVerified => AuthMfaChallengeVerified( + session, + fromBroadcast: fromBroadcast, + ), + }; + } + /// For internal use only. @internal Object notifyException(Object exception, [StackTrace? stackTrace]) { diff --git a/packages/supabase_auth/lib/src/types/auth_state.dart b/packages/supabase_auth/lib/src/types/auth_state.dart index 95b5f6713..82085ee4f 100644 --- a/packages/supabase_auth/lib/src/types/auth_state.dart +++ b/packages/supabase_auth/lib/src/types/auth_state.dart @@ -3,39 +3,141 @@ import 'package:supabase_auth/src/types/session.dart'; import 'package:supabase_auth/src/types/sign_out_reason.dart'; /// An event emitted on `AuthClient.onAuthStateChange`. -class AuthState { - const AuthState( - this.event, - this.session, { - this.fromBroadcast = false, - this.signOutReason, - }); +/// +/// Each kind of change is its own subtype carrying exactly the data that +/// change produces, so a `switch` over the state is exhaustive and the +/// [session] is non-nullable wherever the event guarantees one: +/// +/// ```dart +/// supabase.auth.onAuthStateChange.listen((state) { +/// switch (state) { +/// case AuthSignedIn(:final session): +/// showHome(session.user); +/// case AuthSignedOut(reason: SignOutReason.sessionExpired): +/// showSessionExpired(); +/// case AuthSignedOut(): +/// case AuthInitialSession(session: null): +/// showLogin(); +/// case AuthInitialSession(session: final session?): +/// case AuthTokenRefreshed(:final session): +/// case AuthUserUpdated(:final session): +/// case AuthPasswordRecovery(:final session): +/// case AuthMfaChallengeVerified(:final session): +/// updateUser(session.user); +/// } +/// }); +/// ``` +/// +/// [event] and [session] on the base type give a flat view of every state. +sealed class AuthState { + const AuthState({this.fromBroadcast = false}); /// The kind of change. - final AuthChangeEvent event; + AuthChangeEvent get event; - /// The session after the change, `null` when the user is signed out. + /// The session after the change, `null` when there is none. + Session? get session; + + /// Whether this state was broadcasted via `web.BroadcastChannel` on web from + /// another tab or window. + final bool fromBroadcast; + + @override + String toString() => + '$runtimeType(session: $session, fromBroadcast: $fromBroadcast)'; +} + +/// The first event every new subscriber of `AuthClient.onAuthStateChange` +/// receives, with the session at that moment or `null` if there is none. +/// +/// A subscriber that arrives while a persisted session is still being +/// restored receives it once the restore is done. +final class AuthInitialSession extends AuthState { + const AuthInitialSession(this.session); + + @override final Session? session; - /// Why the user was signed out, when [event] is - /// [AuthChangeEvent.signedOut]. + @override + AuthChangeEvent get event => AuthChangeEvent.initialSession; +} + +/// Emitted after a successful sign-in. +final class AuthSignedIn extends AuthState { + const AuthSignedIn(this.session, {super.fromBroadcast}); + + @override + final Session session; + + @override + AuthChangeEvent get event => AuthChangeEvent.signedIn; +} + +/// Emitted after the user signs out. +final class AuthSignedOut extends AuthState { + const AuthSignedOut({this.reason, super.fromBroadcast}); + + /// Why the user was signed out. /// /// Lets listeners tell an explicit [AuthClient.signOut] apart from an /// involuntary sign out, such as an invalid or expired refresh token, - /// directly from the `signedOut` event rather than from the matching stream - /// error. An `onError` handler is still needed to catch the other exceptions - /// emitted on the stream. It is `null` for every event other than - /// [AuthChangeEvent.signedOut] and for `signedOut` events received from - /// another tab via `web.BroadcastChannel`. - final SignOutReason? signOutReason; + /// without relying on the matching stream error. An `onError` handler is + /// still needed to catch the other exceptions emitted on the stream. `null` + /// for sign outs received from another tab via `web.BroadcastChannel`. + final SignOutReason? reason; - /// Whether this state was broadcasted via `web.BroadcastChannel` on web from - /// another tab or window. - final bool fromBroadcast; + @override + Session? get session => null; + + @override + AuthChangeEvent get event => AuthChangeEvent.signedOut; + + @override + String toString() => + '$runtimeType(reason: ${reason?.name}, fromBroadcast: $fromBroadcast)'; +} + +/// Emitted after the access token is refreshed. +final class AuthTokenRefreshed extends AuthState { + const AuthTokenRefreshed(this.session, {super.fromBroadcast}); + + @override + final Session session; + + @override + AuthChangeEvent get event => AuthChangeEvent.tokenRefreshed; +} + +/// Emitted after the user's profile is updated. +final class AuthUserUpdated extends AuthState { + const AuthUserUpdated(this.session, {super.fromBroadcast}); + + @override + final Session session; + + @override + AuthChangeEvent get event => AuthChangeEvent.userUpdated; +} + +/// Emitted after the user follows a password recovery link or verifies a +/// recovery code. +final class AuthPasswordRecovery extends AuthState { + const AuthPasswordRecovery(this.session, {super.fromBroadcast}); + + @override + final Session session; + + @override + AuthChangeEvent get event => AuthChangeEvent.passwordRecovery; +} + +/// Emitted after a multi-factor authentication challenge is verified. +final class AuthMfaChallengeVerified extends AuthState { + const AuthMfaChallengeVerified(this.session, {super.fromBroadcast}); + + @override + final Session session; @override - String toString() { - return 'AuthState(event: ${event.name}, session: $session, fromBroadcast: ' - '$fromBroadcast, signOutReason: ${signOutReason?.name})'; - } + AuthChangeEvent get event => AuthChangeEvent.mfaChallengeVerified; } diff --git a/packages/supabase_auth/lib/src/types/sign_out_reason.dart b/packages/supabase_auth/lib/src/types/sign_out_reason.dart index 3e86315ed..74698f14f 100644 --- a/packages/supabase_auth/lib/src/types/sign_out_reason.dart +++ b/packages/supabase_auth/lib/src/types/sign_out_reason.dart @@ -1,6 +1,6 @@ /// The reason why an [AuthChangeEvent.signedOut] event was emitted. /// -/// Available on [AuthState.signOutReason] and lets listeners distinguish an +/// Available on [AuthSignedOut.reason] and lets listeners distinguish an /// explicit sign out from an involuntary one, such as an expired session, /// without inspecting error messages. enum SignOutReason { diff --git a/packages/supabase_auth/test/auth_state_test.dart b/packages/supabase_auth/test/auth_state_test.dart new file mode 100644 index 000000000..22defd449 --- /dev/null +++ b/packages/supabase_auth/test/auth_state_test.dart @@ -0,0 +1,189 @@ +import 'package:supabase_auth/supabase_auth.dart'; +import 'package:test/test.dart'; + +import 'utils.dart'; + +/// Exhaustive over the sealed hierarchy, so a new subtype fails to compile +/// here until it is handled. +String describe(AuthState state) => switch (state) { + AuthInitialSession(session: null) => 'initial, signed out', + AuthInitialSession(session: final session?) => 'initial ${session.user.id}', + AuthSignedIn(:final session) => 'signed in ${session.user.id}', + AuthSignedOut(:final reason) => 'signed out ${reason?.name}', + AuthTokenRefreshed(:final session) => 'refreshed ${session.user.id}', + AuthUserUpdated(:final session) => 'updated ${session.user.id}', + AuthPasswordRecovery(:final session) => 'recovery ${session.user.id}', + AuthMfaChallengeVerified(:final session) => 'mfa ${session.user.id}', +}; + +void main() { + group('AuthState', () { + late MockSupabaseHttpClient httpClient; + late AuthClient client; + late List states; + + Future signIn() => client.signInWithPassword( + email: 'fake1@email.com', + password: 'password', + ); + + setUp(() async { + httpClient = MockSupabaseHttpClient() + ..stubSignIn() + ..stubSignOut() + ..stubUser(); + client = AuthClient( + url: 'http://localhost:9999', + httpClient: httpClient, + autoRefreshToken: false, + asyncStorage: TestAsyncStorage(), + ); + states = []; + client.onAuthStateChange.listen(states.add, onError: (_) {}); + await pumpEventQueue(); + }); + + tearDown(() { + client.dispose(); + }); + + test('the first event is an AuthInitialSession without a session', () { + expect(states, hasLength(1)); + expect( + states.single, + isA() + .having((state) => state.session, 'session', isNull) + .having( + (state) => state.event, + 'event', + AuthChangeEvent.initialSession, + ), + ); + expect(describe(states.single), 'initial, signed out'); + }); + + test('signing in emits an AuthSignedIn carrying the new session', () async { + final session = await signIn(); + await pumpEventQueue(); + + expect( + states.last, + isA() + .having((state) => state.session, 'session', session) + .having((state) => state.event, 'event', AuthChangeEvent.signedIn) + .having((state) => state.fromBroadcast, 'fromBroadcast', isFalse), + ); + expect(describe(states.last), 'signed in $testUserId'); + }); + + test( + 'refreshing emits an AuthTokenRefreshed carrying the refreshed session', + () async { + await signIn(); + final refreshed = await client.refreshSession(); + await pumpEventQueue(); + + expect( + states.last, + isA() + .having((state) => state.session, 'session', refreshed) + .having( + (state) => state.event, + 'event', + AuthChangeEvent.tokenRefreshed, + ), + ); + }, + ); + + test( + 'updating the user emits an AuthUserUpdated carrying the session', + () async { + await signIn(); + await client.updateUser(UserAttributes(data: {'name': 'Alice'})); + await pumpEventQueue(); + + expect( + states.last, + isA() + .having( + (state) => state.session, + 'session', + client.currentSession, + ) + .having( + (state) => state.event, + 'event', + AuthChangeEvent.userUpdated, + ), + ); + }, + ); + + test('signing out emits an AuthSignedOut with the reason', () async { + await signIn(); + await client.signOut(); + await pumpEventQueue(); + + expect( + states.last, + isA() + .having((state) => state.session, 'session', isNull) + .having( + (state) => state.reason, + 'reason', + SignOutReason.userInitiated, + ) + .having((state) => state.event, 'event', AuthChangeEvent.signedOut), + ); + expect(describe(states.last), 'signed out userInitiated'); + }); + + test('a signedIn event without a session is not emitted', () async { + client.notifyAllSubscribers(AuthChangeEvent.signedIn, broadcast: false); + await pumpEventQueue(); + + expect(states, hasLength(1)); + expect(states.single, isA()); + }); + + test( + 'an event received from another tab is marked fromBroadcast', + () async { + final session = await signIn(); + client.notifyAllSubscribers( + AuthChangeEvent.tokenRefreshed, + session: session, + broadcast: false, + ); + await pumpEventQueue(); + + expect( + states.last, + isA() + .having((state) => state.session, 'session', session) + .having((state) => state.fromBroadcast, 'fromBroadcast', isTrue), + ); + }, + ); + + test( + 'a late subscriber gets the current session as AuthInitialSession', + () async { + final session = await signIn(); + + final first = await client.onAuthStateChange.first; + + expect( + first, + isA().having( + (state) => state.session, + 'session', + session, + ), + ); + expect(describe(first), 'initial $testUserId'); + }, + ); + }); +} diff --git a/packages/supabase_auth/test/refresh_token_race_test.dart b/packages/supabase_auth/test/refresh_token_race_test.dart index 242675d8c..a22a5eeee 100644 --- a/packages/supabase_auth/test/refresh_token_race_test.dart +++ b/packages/supabase_auth/test/refresh_token_race_test.dart @@ -515,8 +515,12 @@ void main() { reason: 'An invalid refresh token should sign the user out', ); expect( - signedOutState!.signOutReason, - SignOutReason.sessionExpired, + signedOutState, + isA().having( + (state) => state.reason, + 'reason', + SignOutReason.sessionExpired, + ), reason: 'The signedOut event should report why the session ended', ); expect(signedOutState!.session, isNull); @@ -552,8 +556,12 @@ void main() { expect(signedOutState, isNotNull); expect( - signedOutState!.signOutReason, - SignOutReason.userInitiated, + signedOutState, + isA().having( + (state) => state.reason, + 'reason', + SignOutReason.userInitiated, + ), reason: 'An explicit signOut should report a userInitiated reason', ); diff --git a/packages/supabase_auth/test/session_persistence_test.dart b/packages/supabase_auth/test/session_persistence_test.dart index c1c88bca9..c6d361dd7 100644 --- a/packages/supabase_auth/test/session_persistence_test.dart +++ b/packages/supabase_auth/test/session_persistence_test.dart @@ -173,7 +173,14 @@ void main() { expect(states.first.event, AuthChangeEvent.initialSession); expect(states.first.session?.accessToken, expired.accessToken); expect(states.last.event, AuthChangeEvent.signedOut); - expect(states.last.signOutReason, SignOutReason.sessionExpired); + expect( + states.last, + isA().having( + (state) => state.reason, + 'reason', + SignOutReason.sessionExpired, + ), + ); await settle(); expect(await storage.getItem(storageKey), isNull); }); diff --git a/packages/supabase_flutter/README.md b/packages/supabase_flutter/README.md index 34fce51fe..1d5ac247c 100644 --- a/packages/supabase_flutter/README.md +++ b/packages/supabase_flutter/README.md @@ -95,10 +95,15 @@ await supabase.auth.signInWithPassword( await supabase.auth.signInWithOtp(email: 'my_email@example.com'); // Listen to auth state changes -supabase.auth.onAuthStateChange.listen((data) { - final AuthChangeEvent event = data.event; - final Session? session = data.session; - // Do something when there is an auth event +supabase.auth.onAuthStateChange.listen((state) { + switch (state) { + case AuthSignedIn(:final session): + print('Signed in as ${session.user.email}'); + case AuthSignedOut(:final reason): + print('Signed out, reason: ${reason?.name}'); + default: + print('Auth event: ${state.event.name}'); + } }); ``` @@ -284,10 +289,9 @@ await supabase.auth.signInWithOAuth( ); // Listen to auth state changes in order to detect when the OAuth login is complete. -supabase.auth.onAuthStateChange.listen((data) { - final AuthChangeEvent event = data.event; - if(event == AuthChangeEvent.signedIn) { - // Do something when user sign in +supabase.auth.onAuthStateChange.listen((state) { + if (state is AuthSignedIn) { + // Do something with state.session when the user signs in } }); ``` diff --git a/sdk-compliance.yaml b/sdk-compliance.yaml index 1dce7c249..02a635be9 100644 --- a/sdk-compliance.yaml +++ b/sdk-compliance.yaml @@ -266,10 +266,39 @@ features: - AuthState.fromBroadcast - AuthState.session - AuthState.toString + - AuthInitialSession + - AuthInitialSession.AuthInitialSession + - AuthInitialSession.event + - AuthInitialSession.session + - AuthSignedIn + - AuthSignedIn.AuthSignedIn + - AuthSignedIn.event + - AuthSignedIn.session + - AuthSignedOut + - AuthSignedOut.AuthSignedOut + - AuthSignedOut.event + - AuthSignedOut.session + - AuthSignedOut.toString + - AuthTokenRefreshed + - AuthTokenRefreshed.AuthTokenRefreshed + - AuthTokenRefreshed.event + - AuthTokenRefreshed.session + - AuthUserUpdated + - AuthUserUpdated.AuthUserUpdated + - AuthUserUpdated.event + - AuthUserUpdated.session + - AuthPasswordRecovery + - AuthPasswordRecovery.AuthPasswordRecovery + - AuthPasswordRecovery.event + - AuthPasswordRecovery.session + - AuthMfaChallengeVerified + - AuthMfaChallengeVerified.AuthMfaChallengeVerified + - AuthMfaChallengeVerified.event + - AuthMfaChallengeVerified.session auth.session.sign_out_reason: status: implemented symbols: - - AuthState.signOutReason + - AuthSignedOut.reason - SignOutReason # auth — identities