fix(clerk_flutter): silent sign out after external browser sso#426
Open
MarkOSullivan94 wants to merge 1 commit into
Conversation
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #425
Fixes the silent sign-out that occurs when the periodic client refresh races the
rotating_token_nonceexchange 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_authApi._fetch— requests are serialised (root fix)All front-end API requests now run through a single-flight queue, and the
authorizationheader 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 (
updateFromnow 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 operationspass
allowClientChange: true.Api._fetchClient— mismatch recoveryPOST /client(an explicit create) may always change the pinned client. AGETmust 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 responsesThe token cache is only updated from 200 responses.
clerk_flutterClerkAuthState._processDeepLink— holds the update lockThe deep-link completion path now acquires the same update lock as
safelyCall, so the existing Guard 1 onrefreshClientsuppresses 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 conditiongroup inclerk_auth_state_test.dart(stale/equal-timestamp clients, Guard 1 viasafelyCall) still covers the previous incarnation of this bug and passes unchanged.Replaced —
auth_test.dart'srefreshClienttest 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) — concurrentrefreshClient()calls are observed by a countingHttpServiceto 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 throughconfig.deepLinkStreamwith the nonce exchange held open, and assertsrefreshClientissues no request until the parse completes (and works again after).TestClerkAuthConfiggained adeepLinkStreampassthrough 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 issuesOut of scope / follow-ups
GET /clientwith 200 + a freshly minted client rather than a 401, which made this failure silent; worth raising with the FAPI team.createClientfalls back toPOST /clientbefore the deep-link nonce is seen; presenting the nonce on the initial client fetch would close that gap.