Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions MIGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
95 changes: 77 additions & 18 deletions packages/supabase_auth/lib/src/auth_client.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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();
}
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Comment thread
spydon marked this conversation as resolved.
authLogger.warning(
'Ignoring a ${event.name} event that carries no session',
);
return;
Comment thread
spydon marked this conversation as resolved.
}
_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]) {
Expand Down
150 changes: 126 additions & 24 deletions packages/supabase_auth/lib/src/types/auth_state.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down
Loading