fix(web): discard stale SSO sign-in hint after a failed sign-in - #4934
Conversation
The sign-in hint in localStorage pins a returning SSO user to one WorkOS organization id, and the returning-user screen intentionally offers no alternative method for it. When that organization stops resolving - deleted in WorkOS, its connection detached, or organizations.sso_domain cleared - the redirect fails, NextAuth bounces the user back to /users/sign_in with an error, and the same failing "Sign in with Enterprise SSO" button is rendered again. Every retry reproduces the failure, so affected accounts cannot sign in at all until they manually clear site data. Observed in production for anaconda.com: WorkOS returned error=organization_invalid "No Connection associated with Organization org_01KY7Q7B3W99QKBKYGWYK6SFK1" for users holding a hint from an earlier organization, while users who re-ran the email lookup resolved the current organization and signed in fine. Discard the hint when we arrive on the sign-in page with an error and the stored hint is an SSO hint. That falls back to the email prompt, which re-runs the server-side /api/sso/organizations lookup and picks up the organization that is live now. The remembered address is kept so recovery is a single submit rather than a retype. Any error disqualifies the hint rather than an allowlist of codes: no code means the redirect succeeded, the set of codes reaching this page is large and drifts across NextAuth and our own emitters, and the outcomes are asymmetric - a false positive costs one prefilled submit, a missed case leaves an account permanently locked out. Non-SSO hints are untouched; they already render an escape hatch. The check is a one-shot on arrival so a hint saved later in the session, just before signIn() redirects, is never clobbered.
Code Review SummaryStatus: No Issues Found | Recommendation: Merge Executive SummaryThe stale-SSO-hint recovery is correctly scoped and self-consistent: the predicate only fires for Notes verified during review
Assumptions stated: read-only mode, so tests and typechecks were not executed; the reported Files Reviewed (3 files)
Reviewed by claude-opus-5 · Input: 34 · Output: 10K · Cached: 865.5K Review guidance: REVIEW.md from base branch |
Problem
The
signin_hintentry inlocalStoragepins a returning SSO user to one WorkOS organization id, and the returning-user screen intentionally offers no alternative method for it (other methods genuinely cannot work for a domain where SSO is required).That combination makes a stale SSO hint unrecoverable. If the organization stops resolving — deleted in WorkOS, its connection detached, or
organizations.sso_domaincleared — the redirect fails, NextAuth bounces the user back to/users/sign_inwith anerror, andSignInFormrenders the same failing Sign in with Enterprise SSO button from the same hint. Every retry reproduces the failure. Affected accounts cannot sign in at all until they manually clear site data, which no user will think to do.Observed in production
anaconda.comusers holding a hint for an earlier WorkOS organization got:Meanwhile users who re-entered their email resolved the current organization and signed in fine — every redirect preceded by a
/api/sso/organizationslookup succeeded; every redirect to the dead org had no preceding lookup. 133 such error callbacks over four days across 5 client IPs.This class of failure emits no Sentry event (NextAuth aborts before our
signIncallback) and no log line containing the user's email, so it is invisible to the usual searches.Change
Discard the hint when we arrive on the sign-in page with an error and the stored hint is an SSO hint. The user falls back to the email prompt, which re-runs the server-side
/api/sso/organizationslookup and resolves the organization that is live right now. The remembered address is restored into the field so recovery is a single submit rather than a retype.apps/web/src/lib/auth/sign-in-hint-recovery.ts(new) — pure predicateshouldDiscardSsoHintOnError(hint, error).apps/web/src/hooks/useSignInFlow.ts— one-shot effect that runs once the hint has loaded, calls the existingclearHint(), restores the email, and opens the email input.apps/web/src/lib/auth/sign-in-hint-recovery.test.ts(new) — 6 cases.Two decisions worth reviewing
Any error disqualifies the hint, rather than an allowlist of codes. No error code means "the SSO redirect succeeded"; the set of codes that can reach this page is large and already drifts across four places (the
AuthErrorTypeunion, the emitters,AuthErrorNotification's switch, and NextAuth internals); and the outcomes are asymmetric — a false positive costs one prefilled submit, a missed case leaves an account permanently locked out. Non-SSO hints are untouched, since they already render an escape hatch.The check is one-shot on arrival. Only the hint the user arrived with is inspected. Without the latch, the hint saved moments before
signIn()redirects would be wiped whileinitialErrorwas still in props.The decision lives in a separate plain module because
apps/webjest runstestEnvironment: 'node'with no jsdom or testing-library, andtestMatchexcludes.test.tsx, so the hook itself is not unit-testable today.Verification
Reproduced the production state against local dev in an isolated browser session, seeding
signin_hintwith the real dead org id:error?error=Callbacksignin_hint→null, SSO button gone, email prefilled and focused?error=CallbackChecks run:
npx tsgo --noEmitinapps/web— exit 0oxlinton the three files — 0 warnings, 0 errorsapps/webjest suite — 690 suites, 8796 passed, 3 skippedoxfmtapplied;git diff --checkcleanScope
Fixes recovery, not the cause. The WorkOS organization behind the production incident is already deleted, so re-attaching a connection is no longer an option — shipping this is the path for the remaining affected users.
Deliberately not included: an escape hatch on the SSO branch of
SignInForm.tsx. For a domain where SSO is required, other methods cannot work, so offering them would mislead. Note that screen does already render "Not you? Use a different account" when the hint carries an email; the hard dead-end is a hint withorgIdbut nolastEmail.Worth a separate PR:
OAUTH_ERRORis still absent fromAuthErrorType, and every NextAuth code (Callback,OAuthCallback, …) falls through to the generic "Oops! Something went wrong trying to log in." A message like "We couldn't reach your organization's SSO provider — enter your email to try again" would make this class of failure self-explanatory.