From 096a3eb216051eb1284df5f022eddcb534b8e0f7 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Thu, 17 Sep 2026 11:48:19 +0200 Subject: [PATCH 1/4] refactor(auth)!: make AuthState a sealed class with per-event payloads Each AuthChangeEvent gets its own AuthState subtype: AuthInitialSession, AuthSignedIn, AuthSignedOut, AuthTokenRefreshed, AuthUserUpdated, AuthPasswordRecovery and AuthMfaChallengeVerified. The session is non-nullable on every subtype that guarantees one, AuthSignedOut carries the SignOutReason as `reason`, and a switch over the state is exhaustive. AuthState.event and AuthState.session stay on the base type, so listeners comparing the event keep compiling. AuthState.signOutReason moves to AuthSignedOut.reason and AuthState loses its public constructor. A broadcast event from another tab that carries no session for an event that needs one is dropped with a warning instead of being emitted. --- MIGRATION.md | 48 ++++++ .../supabase_auth/lib/src/auth_client.dart | 52 +++++- .../lib/src/types/auth_state.dart | 150 +++++++++++++--- .../lib/src/types/sign_out_reason.dart | 2 +- .../supabase_auth/test/auth_state_test.dart | 161 ++++++++++++++++++ .../test/refresh_token_race_test.dart | 16 +- .../test/session_persistence_test.dart | 9 +- packages/supabase_flutter/README.md | 13 +- sdk-compliance.yaml | 31 +++- 9 files changed, 442 insertions(+), 40 deletions(-) create mode 100644 packages/supabase_auth/test/auth_state_test.dart diff --git a/MIGRATION.md b/MIGRATION.md index 7ed625103..9249ef0c0 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` has no public constructor. 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..2ca07634b 100644 --- a/packages/supabase_auth/lib/src/auth_client.dart +++ b/packages/supabase_auth/lib/src/auth_client.dart @@ -250,7 +250,7 @@ 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. /// @@ -314,9 +314,7 @@ class AuthClient { return; } initialSent = true; - controller.addSync( - AuthState(AuthChangeEvent.initialSession, currentSession), - ); + controller.addSync(AuthInitialSession(currentSession)); for (final deliver in held) { deliver(); } @@ -2103,17 +2101,61 @@ class AuthClient { 'session': session?.toJson(), }); } - final state = AuthState( + final state = _authStateFor( event, session, fromBroadcast: !broadcast, signOutReason: signOutReason, ); + if (state == null) { + assert(!broadcast, 'A local ${event.name} event needs a session.'); + authLogger.warning( + 'Ignoring a broadcast ${event.name} event that carries no session', + ); + return; + } 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, + ), + AuthChangeEvent.signedIn => + session == null + ? null + : AuthSignedIn(session, fromBroadcast: fromBroadcast), + AuthChangeEvent.tokenRefreshed => + session == null + ? null + : AuthTokenRefreshed(session, fromBroadcast: fromBroadcast), + AuthChangeEvent.userUpdated => + session == null + ? null + : AuthUserUpdated(session, fromBroadcast: fromBroadcast), + AuthChangeEvent.passwordRecovery => + session == null + ? null + : AuthPasswordRecovery(session, fromBroadcast: fromBroadcast), + AuthChangeEvent.mfaChallengeVerified => + session == null + ? null + : 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..dcf3b7fa3 --- /dev/null +++ b/packages/supabase_auth/test/auth_state_test.dart @@ -0,0 +1,161 @@ +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 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..9a6820b64 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}'); + } }); ``` 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 From c512e16eb130a0b960ea6595ab08c11deab10bc5 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Thu, 17 Sep 2026 12:00:52 +0200 Subject: [PATCH 2/4] fix(auth): never emit or broadcast a session event without a session Follow-ups from reviewing the sealed AuthState: - updateUser only emits userUpdated when the session is still present after the request; a sign out that completed during the request has already emitted signedOut, and emitting a session event without a session tripped the new assert in debug builds. - notifyAllSubscribers builds the state before broadcasting, so a session event with no session is neither emitted locally nor posted to other tabs. - _authStateFor is one exhaustive switch over the (event, session) record. - Tests for the dropped event and for fromBroadcast on the new subtypes; the onAuthStateChange dartdoc and README examples use the sealed switch. --- MIGRATION.md | 4 +- .../supabase_auth/lib/src/auth_client.dart | 83 ++++++++++--------- .../supabase_auth/test/auth_state_test.dart | 28 +++++++ packages/supabase_flutter/README.md | 7 +- 4 files changed, 77 insertions(+), 45 deletions(-) diff --git a/MIGRATION.md b/MIGRATION.md index 9249ef0c0..4e6a3218a 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -485,8 +485,8 @@ except `AuthInitialSession`, where it is the session at subscription time or `nu null-checks `session` keeps compiling. What changes: - `AuthState.signOutReason` moved to `AuthSignedOut.reason`. -- `AuthState` has no public constructor. Construct the subtype instead, for example in a test that - feeds a fake stream. +- `AuthState` itself can no longer be constructed. Construct the subtype instead, for example in a + test that feeds a fake stream. ```dart // Before diff --git a/packages/supabase_auth/lib/src/auth_client.dart b/packages/supabase_auth/lib/src/auth_client.dart index 2ca07634b..7c2557e26 100644 --- a/packages/supabase_auth/lib/src/auth_client.dart +++ b/packages/supabase_auth/lib/src/auth_client.dart @@ -256,11 +256,14 @@ class AuthClient { /// /// ```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) { @@ -1203,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; } @@ -2095,12 +2098,6 @@ class AuthClient { SignOutReason? signOutReason, }) { session ??= currentSession; - if (broadcast && event != AuthChangeEvent.initialSession) { - _broadcastChannel?.postMessage({ - 'event': event.value, - 'session': session?.toJson(), - }); - } final state = _authStateFor( event, session, @@ -2108,51 +2105,59 @@ class AuthClient { signOutReason: signOutReason, ); if (state == null) { - assert(!broadcast, 'A local ${event.name} event needs a session.'); authLogger.warning( - 'Ignoring a broadcast ${event.name} event that carries no session', + 'Ignoring a ${event.name} event that carries no session', ); return; } + if (broadcast && event != AuthChangeEvent.initialSession) { + _broadcastChannel?.postMessage({ + 'event': event.value, + 'session': 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. + /// session and [sessionOrNull] is missing. AuthState? _authStateFor( AuthChangeEvent event, - Session? session, { + Session? sessionOrNull, { required bool fromBroadcast, SignOutReason? signOutReason, }) { - return switch (event) { - AuthChangeEvent.initialSession => AuthInitialSession(session), - AuthChangeEvent.signedOut => AuthSignedOut( + return switch ((event, sessionOrNull)) { + (AuthChangeEvent.initialSession, _) => AuthInitialSession(sessionOrNull), + (AuthChangeEvent.signedOut, _) => AuthSignedOut( reason: signOutReason, fromBroadcast: fromBroadcast, ), - AuthChangeEvent.signedIn => - session == null - ? null - : AuthSignedIn(session, fromBroadcast: fromBroadcast), - AuthChangeEvent.tokenRefreshed => - session == null - ? null - : AuthTokenRefreshed(session, fromBroadcast: fromBroadcast), - AuthChangeEvent.userUpdated => - session == null - ? null - : AuthUserUpdated(session, fromBroadcast: fromBroadcast), - AuthChangeEvent.passwordRecovery => - session == null - ? null - : AuthPasswordRecovery(session, fromBroadcast: fromBroadcast), - AuthChangeEvent.mfaChallengeVerified => - session == null - ? null - : AuthMfaChallengeVerified(session, fromBroadcast: fromBroadcast), + (_, null) => null, + (AuthChangeEvent.signedIn, final session?) => AuthSignedIn( + session, + fromBroadcast: fromBroadcast, + ), + (AuthChangeEvent.tokenRefreshed, final session?) => AuthTokenRefreshed( + session, + fromBroadcast: fromBroadcast, + ), + (AuthChangeEvent.userUpdated, final session?) => AuthUserUpdated( + session, + fromBroadcast: fromBroadcast, + ), + (AuthChangeEvent.passwordRecovery, final session?) => + AuthPasswordRecovery( + session, + fromBroadcast: fromBroadcast, + ), + ( + AuthChangeEvent.mfaChallengeVerified, + final session?, + ) => + AuthMfaChallengeVerified(session, fromBroadcast: fromBroadcast), }; } diff --git a/packages/supabase_auth/test/auth_state_test.dart b/packages/supabase_auth/test/auth_state_test.dart index dcf3b7fa3..22defd449 100644 --- a/packages/supabase_auth/test/auth_state_test.dart +++ b/packages/supabase_auth/test/auth_state_test.dart @@ -139,6 +139,34 @@ void main() { 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 { diff --git a/packages/supabase_flutter/README.md b/packages/supabase_flutter/README.md index 9a6820b64..1d5ac247c 100644 --- a/packages/supabase_flutter/README.md +++ b/packages/supabase_flutter/README.md @@ -289,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 } }); ``` From 894cf592c93f892c5f0d3cbdd07e7db172d4d050 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Thu, 17 Sep 2026 12:05:15 +0200 Subject: [PATCH 3/4] refactor(auth): keep the session parameter name in _authStateFor --- .../supabase_auth/lib/src/auth_client.dart | 36 +++++++++---------- 1 file changed, 17 insertions(+), 19 deletions(-) diff --git a/packages/supabase_auth/lib/src/auth_client.dart b/packages/supabase_auth/lib/src/auth_client.dart index 7c2557e26..7c5b98b54 100644 --- a/packages/supabase_auth/lib/src/auth_client.dart +++ b/packages/supabase_auth/lib/src/auth_client.dart @@ -2122,42 +2122,40 @@ class AuthClient { } /// Builds the [AuthState] for [event], `null` when [event] carries a - /// session and [sessionOrNull] is missing. + /// session and [session] is missing. AuthState? _authStateFor( AuthChangeEvent event, - Session? sessionOrNull, { + Session? session, { required bool fromBroadcast, SignOutReason? signOutReason, }) { - return switch ((event, sessionOrNull)) { - (AuthChangeEvent.initialSession, _) => AuthInitialSession(sessionOrNull), - (AuthChangeEvent.signedOut, _) => AuthSignedOut( + return switch (event) { + AuthChangeEvent.initialSession => AuthInitialSession(session), + AuthChangeEvent.signedOut => AuthSignedOut( reason: signOutReason, fromBroadcast: fromBroadcast, ), - (_, null) => null, - (AuthChangeEvent.signedIn, final session?) => AuthSignedIn( + _ when session == null => null, + AuthChangeEvent.signedIn => AuthSignedIn( session, fromBroadcast: fromBroadcast, ), - (AuthChangeEvent.tokenRefreshed, final session?) => AuthTokenRefreshed( + AuthChangeEvent.tokenRefreshed => AuthTokenRefreshed( session, fromBroadcast: fromBroadcast, ), - (AuthChangeEvent.userUpdated, final session?) => AuthUserUpdated( + AuthChangeEvent.userUpdated => AuthUserUpdated( + session, + fromBroadcast: fromBroadcast, + ), + AuthChangeEvent.passwordRecovery => AuthPasswordRecovery( + session, + fromBroadcast: fromBroadcast, + ), + AuthChangeEvent.mfaChallengeVerified => AuthMfaChallengeVerified( session, fromBroadcast: fromBroadcast, ), - (AuthChangeEvent.passwordRecovery, final session?) => - AuthPasswordRecovery( - session, - fromBroadcast: fromBroadcast, - ), - ( - AuthChangeEvent.mfaChallengeVerified, - final session?, - ) => - AuthMfaChallengeVerified(session, fromBroadcast: fromBroadcast), }; } From 01d0c31d7f30b2626f7df80b6c749af63fc50072 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Thu, 17 Sep 2026 12:09:03 +0200 Subject: [PATCH 4/4] fix(auth): validate a broadcast event before replacing the current session --- .../supabase_auth/lib/src/auth_client.dart | 22 +++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/packages/supabase_auth/lib/src/auth_client.dart b/packages/supabase_auth/lib/src/auth_client.dart index 7c5b98b54..883870bea 100644 --- a/packages/supabase_auth/lib/src/auth_client.dart +++ b/packages/supabase_auth/lib/src/auth_client.dart @@ -1944,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) { @@ -2110,10 +2118,16 @@ class AuthClient { ); return; } - if (broadcast && event != AuthChangeEvent.initialSession) { + _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': event.value, - 'session': session?.toJson(), + 'event': state.event.value, + 'session': state.session?.toJson(), }); } authLogger.finest('onAuthStateChange: $state');