Skip to content

fix(gotrue): avoid duplicate auth error on recoverSession with invalid refresh token#1450

Open
spydon wants to merge 10 commits into
mainfrom
fix/recover-session-duplicate-auth-error
Open

fix(gotrue): avoid duplicate auth error on recoverSession with invalid refresh token#1450
spydon wants to merge 10 commits into
mainfrom
fix/recover-session-duplicate-auth-error

Conversation

@spydon

@spydon spydon commented Jun 22, 2026

Copy link
Copy Markdown
Contributor

What

Fixes the long-standing reports of AuthException(message: Invalid Refresh Token: Refresh Token Not Found, statusCode: 400) surfacing as an uncaught error (often seen in Sentry).

Closes #930

Why

When recoverSession restores a persisted session whose refresh token is invalid or expired, the refresh goes through _callRefreshToken -> _executeRefresh. For a non-retryable AuthException (like "Refresh Token Not Found"), _executeRefresh already does the right thing: it removes the session and emits a signedOut event, deliberately not calling notifyException.

The problem was that recoverSession's blanket catch then called notifyException(error, stackTrace) a second time, pushing the AuthException onto the onAuthStateChange stream. For an expected condition (a rotated, expired, or revoked token at app startup), consumers listening to onAuthStateChange without an onError handler get an uncaught async error. That is what those Sentry reports were.

How

Notification in recoverSession is now explicit per path instead of a catch-all re-notify:

  • The refresh-token path defers entirely to _callRefreshToken / _executeRefresh, the single owner of that outcome (signedOut for an invalid token, an exception for a retryable failure). No more duplicate stream error.
  • Genuinely unexpected parse failures (corrupt JSON) are still notified.
  • The explicit "missing data" and "Session expired" (autoRefreshToken: false) notifications are preserved unchanged.

Tests

  • Updated Sign out on wrong refresh token to assert the invalid-token path surfaces only a signedOut event and never a stream error, while still throwing to the direct caller.
  • Verified the unaffected autoRefreshToken: false path still emits its exception (supabase_flutter "emits exception when no auto refresh" still passes).

…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
@spydon spydon requested a review from a team as a code owner June 22, 2026 11:52
@github-actions github-actions Bot added the auth This issue or pull request is related to authentication label Jun 22, 2026
spydon added 3 commits June 22, 2026 13:59
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.
@Vinzent03

Copy link
Copy Markdown
Collaborator

Am I right that on a failed recovery there is only a signed out event without an exception on the stream? This removes the ability to have different handling or notification to the user why they got signed out. Also for future refreshes it still emits an exception to the stream on an issue, right? I don't think the solution to people not having a proper stream listener is to remove the errors.

@spydon

spydon commented Jun 22, 2026

Copy link
Copy Markdown
Contributor Author

Am I right that on a failed recovery there is only a signed out event without an exception on the stream?
This removes the ability to have different handling or notification to the user why they got signed out.

So there are two cases right:

  • Invalid/expired refresh token (non-retryable) → signedOut event, no stream error. This sign-out (and the signedOut event) is already emitted by _executeRefresh today. This PR just removes the duplicate error that recoverSession's catch was adding on top of it.
  • Network / retryable / unexpected errors → still emitted as an exception on the stream, and the user is not signed out. So this is the same behavior as today.

Also for future refreshes it still emits an exception to the stream on an issue, right? I don't think the solution to people not having a proper stream listener is to remove the errors.

Removing errors to paper over a missing onError would be the wrong fix indeed. The case here though is that we no longer emit an error in a successful, expected state transition: the refresh token is definitively invalid/expired, so the user gets signed out and a signedOut event fires. I.e. a normal lifecycle outcome, not an error condition. _executeRefresh already treats it that way (it signs out and intentionally doesn't notifyException). recoverSession was the odd one out, re-emitting that same outcome a second time as an error.

We should probably add a reason to the signedOut AuthState though, so that the user can differentiate on the sign out reason. But I think that should be a follow-up. What do you think?

@Vinzent03

Copy link
Copy Markdown
Collaborator

Removing the additional redundant exception on the stream is great. I took now a deeper look in the existing code and saw that we no longer emit an exception on refresh_token_already_used on timer based refreshes as well. I had the impression that we emit an exception there as well. This got changed in a fix by you:

https://github.com/supabase/supabase-flutter/pull/1351/changes#diff-d471e660d61f380c883733e18e96a1098d984fc6de28771f762b558b6e922872L1430-L1446

I didn't catch this in that pr. So yeah this aligns the behavior of the initial refresh. I think adding a reason to the sign out event that it was not initiated by the user itself would be great.

Comment thread packages/gotrue/lib/src/gotrue_client.dart Outdated
Comment thread packages/gotrue/lib/src/gotrue_client.dart Outdated
@spydon

spydon commented Jun 22, 2026

Copy link
Copy Markdown
Contributor Author

I think adding a reason to the sign out event that it was not initiated by the user itself would be great.

@Vinzent03 I opened a stacked PR implementing this here: #1453
I was considering just adding it as an enum, but it's probably easier if we just expose the exception.

@Vinzent03

Copy link
Copy Markdown
Collaborator

I am currently thinking whether these changes and the one before is actually breaking? There is no analysis breaking change, but an application that depends on the auth exception being emitted would not work quite as before. Really unsure here.

spydon added 3 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.
@spydon

spydon commented Jun 22, 2026

Copy link
Copy Markdown
Contributor Author

I am currently thinking whether these changes and the one before is actually breaking? There is no analysis breaking change, but an application that depends on the auth exception being emitted would not work quite as before. Really unsure here.

With the one before, do you mean #1351? That is already released.

Yeah I guess this is technically breaking in the sense that it is a behavior change, but I don't think it deserves a major bump due to how strange/unintentional it was behaving before. We should surely make a note of it in the changelog though.

Comment thread packages/gotrue/lib/src/gotrue_client.dart
Comment thread packages/gotrue/lib/src/gotrue_client.dart Outdated
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.
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.

Sentry reports AuthException(message: Invalid Refresh Token: Refresh Token Not Found, statusCode: 400)

2 participants