Skip to content

fix(clerk_flutter): silent sign out after external browser sso#426

Open
MarkOSullivan94 wants to merge 1 commit into
mainfrom
425-silent-sign-out-after-external-browser-sso-client-refresh-races-the-rotating-token-nonce-exchange
Open

fix(clerk_flutter): silent sign out after external browser sso#426
MarkOSullivan94 wants to merge 1 commit into
mainfrom
425-silent-sign-out-after-external-browser-sso-client-refresh-races-the-rotating-token-nonce-exchange

Conversation

@MarkOSullivan94

@MarkOSullivan94 MarkOSullivan94 commented Jul 3, 2026

Copy link
Copy Markdown
Collaborator

Fixes #425

Fixes the silent sign-out that occurs when the periodic client refresh races the rotating_token_nonce exchange after an external-browser OAuth flow (see the companion issue for the full diagnosis).

The client token is a rotating credential: the completion of an SSO flow invalidates the token presented by any request still in flight, and the back end can answer such a request with a freshly minted session-less client — which the SDK then adopted, signing the user out and persisting the wrong identity.

The fix is layered: serialise requests so the race cannot form, pin the client identity so a stray response cannot re-identify the app, and contain failures so that a background refresh can never sign the user out.

Changes

clerk_auth

Api._fetch — requests are serialised (root fix)

All front-end API requests now run through a single-flight queue, and the authorization header is stamped from the token cache at send time rather than at call time. No request is ever in flight while another response can rotate the token it presents, and responses apply to the token cache in a well-defined order. FAPI traffic in this SDK is low-volume, so the lost parallelism is negligible.

TokenCache.updateFrom — client-id pinning (defence in depth)

Once a client id is cached, credentials resolving to a different client id are discarded (updateFrom now returns whether they were accepted). A response naming a different client means the request's token had already been rotated away; adopting the new identity would orphan the signed-in client. Explicit client-creating operations
pass allowClientChange: true.

Api._fetchClient — mismatch recovery

POST /client (an explicit create) may always change the pinned client. A GET must return the pinned client; a mismatch is discarded and counted, and only after 3 consecutive mismatches — the pinned client is demonstrably gone — is the new client adopted, so a genuinely expired/deleted client still recovers instead of wedging.

Auth.refreshClient — a refresh can never sign you out (failure containment)

An empty result (transient failure, non-200, or a discarded mismatched response) now leaves the current client intact. Only explicit operations (signOut,resetClient) clear a signed-in client. This also fixes the production-instance variant of the bug, where a stale token yields a 401 → Client.empty → sign-out.

Api._processResponse — no credential updates from error responses

The token cache is only updated from 200 responses.

clerk_flutter

ClerkAuthState._processDeepLink — holds the update lock

The deep-link completion path now acquires the same update lock as safelyCall, so the existing Guard 1 on refreshClient suppresses timer-fired refreshes for the duration of the nonce exchange. This matches the webview SSO path, which was already protected; the external-browser deep-link path was not.

Tests

Kept — the existing refreshClient race condition group in clerk_auth_state_test.dart (stale/equal-timestamp clients, Guard 1 via safelyCall) still covers the previous incarnation of this bug and passes unchanged.

Replacedauth_test.dart's refreshClient test asserted the old, unsafe behaviour (a refresh adopting a client with a different id).

It is replaced by:

  • updates the current client from the API — same-id refresh still applies.
  • discards a refresh that resolves to a different client — the minted-client regression, exercising the pinning path.
  • adopts a persistently mismatched client after repeated refreshes — the recovery path after the mismatch threshold.
  • keeps the current client when the refresh fails — non-200 containment.

Added

  • request_serialization_test.dart (clerk_auth) — concurrent refreshClient() calls are observed by a counting HttpService to have max in-flight of 1.

  • refresh resolving to a freshly minted client must not sign out (clerk_auth_state_test.dart) — the exact observed failure: different id, newer timestamp, zero sessions; the timestamp guard alone would not block it.

  • failed refresh must not sign the user out — 500 on refresh keeps the session.

  • client refresh is suppressed while a deep link is being parsed — drives a deep link through config.deepLinkStream with the nonce exchange held open, and assertsrefreshClient issues no request until the parse completes (and works again after).

TestClerkAuthConfig gained a deepLinkStream passthrough to support the last test.

Verification

  • clerk_auth: 649 unit + 27 integration tests pass (dart test)
  • clerk_flutter: 784 tests pass (flutter test)
  • dart analyze / flutter analyze lib: no issues

Out of scope / follow-ups

  • Dev-instance FAPI answers a stale-rotating-token GET /client with 200 + a freshly minted client rather than a 401, which made this failure silent; worth raising with the FAPI team.
  • A cold start that arrives mid-OAuth with a rotated persisted token still cannot complete the flow if the initial createClient falls back to POST /client before the deep-link nonce is seen; presenting the nonce on the initial client fetch would close that gap.

@github-actions github-actions Bot requested a review from slightfoot July 3, 2026 19:47
@MarkOSullivan94 MarkOSullivan94 marked this pull request as ready for review July 3, 2026 19:52
@MarkOSullivan94 MarkOSullivan94 added clerk_auth package: clerk_auth clerk_flutter package: clerk_flutter sign in with google bug Something isn't working labels Jul 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working clerk_auth package: clerk_auth clerk_flutter package: clerk_flutter sign in with google

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Silent sign-out after external-browser SSO: client refresh races the rotating-token nonce exchange

1 participant