Summary
Every progressive sign-in flow (password, email code, phone code, email link, client trust) freezes on its first screen: tapping Continue appears to do nothing. The API call succeeds and the server creates/progresses the sign-in, but the response is silently discarded client-side.
Introduced in cd4b95b (“test(patrol): sign in with google (#410)”, sub-commit fix(clerk_flutter): sso race condition).
Root cause
That commit added an override of the client setter in ClerkAuthState (packages/clerk_flutter/lib/src/clerk_auth_state.dart):
@override
set client(clerk.Client newClient) {
final newTs = newClient.updatedAt.microsecondsSinceEpoch;
final currentTs = client.updatedAt.microsecondsSinceEpoch;
if (newTs > 0 && currentTs > 0 && newTs <= currentTs) return;
super.client = newClient;
}
It rejects any incoming client whose updated_at is not strictly newer than the current one — resting on the assumption that any meaningful change to the client bumps client.updated_at.
That assumption is false for sign-in progression: the Frontend API does not advance client.updated_at when a sign_in is created or moves through its statuses. The SDK’s own recorded fixtures show this — in packages/clerk_auth/test/_responses/clerk_auth/sign_in_test/, client.updated_at carries the identical timestamp on every response of every flow, even as sign_in.status progresses to complete.
Failure sequence
- App startup fetches the client —
updated_at = T. Accepted; becomes the high-water mark.
- User enters an identifier and taps Continue →
attemptSignIn → POST /v1/client/sign_ins → 200 with sign_in.status: needs_first_factor and updated_at: T (unchanged).
- The setter guard sees
newTs <= currentTs and returns without assigning. The new sign_in never lands in state.
notifyListeners() fires, the panel rebuilds, signIn is still null → identifier screen again. Every tap repeats the cycle.
The same drop occurs at every later stage (first → second factor, code verification), so no progressive flow can complete.
Why nothing caught it
- The fixture-backed sign-in tests drive
Auth (clerk_auth), which has no guard — the override lives in ClerkAuthState (clerk_flutter).
- Widget-test fixtures build each client with
updatedAt: DateTime.now(), so successive test clients always have increasing timestamps and pass the guard — exactly the behavior the real API doesn’t have.
- The patrol test added in the same commit covers Google SSO, where completion creates a session and the client genuinely changes — masking the progressive flows.
Reproduction
Deterministic reproduction (no live network): drive ClerkAuthState through a mocked sign-in where consecutive clients share one updated_at —
- state-level:
attemptSignIn(strategy: password, identifier: …) → signIn stays null;
- widget-level: pump
ClerkSignInPanel, enter an email, tap Continue → flow never reaches needs_first_factor.
Or manually: run the example app against any instance with password/email-code sign-in and tap Continue on the identifier screen.
Fix
The guard cannot simply be deleted — the refreshClient() race-condition tests depend on it screening stale/equal-timestamp refresh responses. Scope it instead: apply the timestamp check only while refreshClient() is resolving, so foreground auth responses (_housekeeping writes) are never screened and background refreshes keep exactly the protection they had.
A regression test should drive ClerkAuthState (not just Auth) with constant updated_at across responses and assert signIn.status progresses.
Related
🤖 Generated with Claude Code
Summary
Every progressive sign-in flow (password, email code, phone code, email link, client trust) freezes on its first screen: tapping Continue appears to do nothing. The API call succeeds and the server creates/progresses the sign-in, but the response is silently discarded client-side.
Introduced in
cd4b95b(“test(patrol): sign in with google (#410)”, sub-commit fix(clerk_flutter): sso race condition).Root cause
That commit added an override of the
clientsetter inClerkAuthState(packages/clerk_flutter/lib/src/clerk_auth_state.dart):It rejects any incoming client whose
updated_atis not strictly newer than the current one — resting on the assumption that any meaningful change to the client bumpsclient.updated_at.That assumption is false for sign-in progression: the Frontend API does not advance
client.updated_atwhen asign_inis created or moves through its statuses. The SDK’s own recorded fixtures show this — inpackages/clerk_auth/test/_responses/clerk_auth/sign_in_test/,client.updated_atcarries the identical timestamp on every response of every flow, even assign_in.statusprogresses tocomplete.Failure sequence
updated_at = T. Accepted; becomes the high-water mark.attemptSignIn→POST /v1/client/sign_ins→200withsign_in.status: needs_first_factorandupdated_at: T(unchanged).newTs <= currentTsand returns without assigning. The newsign_innever lands in state.notifyListeners()fires, the panel rebuilds,signInis stillnull→ identifier screen again. Every tap repeats the cycle.The same drop occurs at every later stage (first → second factor, code verification), so no progressive flow can complete.
Why nothing caught it
Auth(clerk_auth), which has no guard — the override lives inClerkAuthState(clerk_flutter).updatedAt: DateTime.now(), so successive test clients always have increasing timestamps and pass the guard — exactly the behavior the real API doesn’t have.Reproduction
Deterministic reproduction (no live network): drive
ClerkAuthStatethrough a mocked sign-in where consecutive clients share oneupdated_at—attemptSignIn(strategy: password, identifier: …)→signInstaysnull;ClerkSignInPanel, enter an email, tap Continue → flow never reachesneeds_first_factor.Or manually: run the example app against any instance with password/email-code sign-in and tap Continue on the identifier screen.
Fix
The guard cannot simply be deleted — the
refreshClient()race-condition tests depend on it screening stale/equal-timestamp refresh responses. Scope it instead: apply the timestamp check only whilerefreshClient()is resolving, so foreground auth responses (_housekeepingwrites) are never screened and background refreshes keep exactly the protection they had.A regression test should drive
ClerkAuthState(not justAuth) with constantupdated_atacross responses and assertsignIn.statusprogresses.Related
cd4b95b)DateTime.now()per client, which masks this bug — worth adding a constant-updated_atcase🤖 Generated with Claude Code