Skip to content

feat(gotrue): surface the sign-out reason on the signedOut event#1453

Merged
spydon merged 21 commits into
mainfrom
feat/signed-out-reason
Jun 27, 2026
Merged

feat(gotrue): surface the sign-out reason on the signedOut event#1453
spydon merged 21 commits into
mainfrom
feat/signed-out-reason

Conversation

@spydon

@spydon spydon commented Jun 22, 2026

Copy link
Copy Markdown
Contributor

What

Adds AuthState.signOutReason, a typed SignOutReason populated on the signedOut event so listeners can tell why the user was signed out:

  • userInitiated for an explicit signOut().
  • sessionExpired when the refresh token was rejected (invalid or expired) and the session was removed.
  • sessionMissing when a stored session could not be recovered because it was missing required data.

It is null for every event other than signedOut, and also null for signedOut events received from another tab via web.BroadcastChannel.

Why

Follow-up to #1450, which stops recoverSession from double-reporting an expected sign-out as an uncaught stream error. The remaining concern was that a pure onAuthStateChange listener then sees signedOut without knowing why the user was signed out, removing the ability to handle it differently or notify the user.

This restores that capability through the event itself, rather than through an error that only reaches listeners with an onError handler:

supabase.auth.onAuthStateChange.listen((state) {
  if (state.event == AuthChangeEvent.signedOut &&
      state.signOutReason != null &&
      state.signOutReason != SignOutReason.userInitiated) {
    // involuntary sign out (e.g. expired session) -> show a message
  }
});

How

  • New SignOutReason enum (userInitiated, sessionExpired, sessionMissing), exported from gotrue.dart.
  • AuthState gains a nullable signOutReason field, threaded through notifyAllSubscribers.
  • signOut() now delegates to a private _signOut({scope, reason}) so the public API is unchanged while internal callers can pass a reason.
  • recoverSession and setInitialSession sign out with the appropriate reason and now throw AuthExceptions tagged with ErrorCode.sessionMissing / ErrorCode.sessionExpired (new error codes).
  • The non-retryable refresh failure path signs the user out with sessionExpired.

The reason is intentionally not propagated across tabs via the broadcast channel, so it is always null when fromBroadcast is true.

Tests

  • signedOut event carries SignOutReason.sessionExpired on an invalid refresh token.
  • signedOut event reports SignOutReason.userInitiated on an explicit signOut.

spydon added 5 commits June 22, 2026 13:52
…d refresh token

When recoverSession restores a persisted session with an invalid or expired
refresh token, _executeRefresh already removes the session and emits a
signedOut event. The blanket catch in recoverSession then called
notifyException a second time, pushing an AuthException onto onAuthStateChange.
For listeners without an onError handler this surfaces as an uncaught async
error (reported via Sentry).

Notification is now explicit per path: the refresh path defers to
_callRefreshToken, unexpected parse failures are still notified, and the
missing-data and session-expired paths keep their explicit notifications.

Closes #930
Returning the _callRefreshToken future without awaiting keeps its error out
of recoverSession's catch, so the refresh outcome is owned solely by
_executeRefresh (signedOut for an invalid token, an exception for a retryable
failure). All other error surfacing in recoverSession (parse errors, signOut
failures, the explicit missing-data and session-expired notifications) is
preserved, so existing behavior and tests are unchanged.
Calling _callRefreshToken after the try/catch (instead of returning an
un-awaited future from inside it) keeps its error out of recoverSession's
notification path while satisfying the prefer-return-await lint. Behavior is
unchanged: the refresh outcome is owned solely by _executeRefresh.
Add AuthState.exception, populated when the user is signed out involuntarily
because the session could not be recovered (an invalid or expired refresh
token). It is null for an explicit signOut and for all other events, letting
listeners tell the two apart without attaching an onError handler.
@spydon spydon requested a review from a team as a code owner June 22, 2026 13:38
@github-actions github-actions Bot added the auth This issue or pull request is related to authentication label Jun 22, 2026
spydon added 8 commits June 22, 2026 15:54
Returning the future without awaiting dropped recoverSession from the
asynchronous stack trace. Await it (still outside the catch, so the error is
not re-notified) so the frame is preserved for debugging.
…resh failure

The refresh runs fire-and-forget through a completer, so a failure was rooted
at _executeRefresh with no recoverSession or caller frames. Rethrow with the
current stack (still outside the catch, so the error is not re-notified) so the
call site is visible. Add a test asserting recoverSession appears in the trace.
…ned-out-reason

# Conflicts:
#	packages/gotrue/test/refresh_token_race_test.dart
@Vinzent03

Copy link
Copy Markdown
Collaborator

Adding an exception field to the class feels a bit strange, if we simultaneously use errors on the stream itself. But I cannot come up with a better solution right now. I thought about an enum at first as well like you did here: #1450 (comment), but that is probably hard to properly maintain and this solution is more general.
However, I think you've missed the sign out reasons in the recover method, right? They only have a human message and no special code to handle. Might be interesting to create a code that we use across similar issues in the recover method?

spydon added 3 commits June 22, 2026 23:08
The missing-data and session-expired paths called notifyException explicitly
and then threw, and the surrounding catch notified again, so two errors hit
onAuthStateChange. Throw without the explicit notify and let the catch be the
single notification point. Add a test asserting exactly one error is emitted.
The missing-data and session-expired paths called notifyException explicitly
and then threw, and the surrounding catch notified again, so two errors hit
onAuthStateChange. Throw without the explicit notify and let the catch be the
single notification point. Add a test asserting exactly one error is emitted.
Replaces the `AuthState.exception` field with a typed `SignOutReason`
(userInitiated, sessionExpired, sessionMissing) so listeners can tell an
explicit sign out apart from an involuntary one without inspecting an
exception. The reason is threaded through `notifyAllSubscribers` and set on
every local sign-out path; it is still not broadcast across tabs.

The involuntary recoverSession/setInitialSession exceptions now also carry
stable `session_expired` / `session_missing` codes (added to `ErrorCode`,
which is now exported) so the same reason is available on the stream error.

Registers the new public symbols in sdk-compliance.yaml.
@spydon

spydon commented Jun 26, 2026

Copy link
Copy Markdown
Contributor Author

@Vinzent03 I went with an enum anyways, I think it should be enough and it's much cleaner. What do you think?

Base automatically changed from fix/recover-session-duplicate-auth-error to main June 26, 2026 08:37
# Conflicts:
#	packages/gotrue/lib/src/gotrue_client.dart
#	packages/gotrue/test/refresh_token_race_test.dart
Comment thread packages/gotrue/lib/src/types/auth_state.dart Outdated
Comment thread packages/gotrue/lib/src/types/sign_out_reason.dart
@Vinzent03

Copy link
Copy Markdown
Collaborator

I like it! Should be much easier to handle for the user and is not too specific to properly maintain for us.

spydon added 2 commits June 26, 2026 13:18
Re-points the signOutReason symbols to the new canonical auth.session.sign_out_reason capability instead of folding them into subscribe_auth_events.
@spydon spydon merged commit a182b20 into main Jun 27, 2026
27 checks passed
@spydon spydon deleted the feat/signed-out-reason branch June 27, 2026 04:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

auth This issue or pull request is related to authentication

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants