Skip to content

fix(web): discard stale SSO sign-in hint after a failed sign-in - #4934

Merged
jrf0110 merged 1 commit into
mainfrom
fix/web-clear-stale-sso-hint-on-error
Jul 31, 2026
Merged

fix(web): discard stale SSO sign-in hint after a failed sign-in#4934
jrf0110 merged 1 commit into
mainfrom
fix/web-clear-stale-sso-hint-on-error

Conversation

@RSO

@RSO RSO commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Problem

The signin_hint entry in localStorage pins 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_domain cleared — the redirect fails, NextAuth bounces the user back to /users/sign_in with an error, and SignInForm renders 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.com users holding a hint for an earlier WorkOS organization got:

GET /api/auth/callback/workos
  ?error=organization_invalid
  &error_description=No Connection associated with Organization 'org_01KY7Q7B3W99QKBKYGWYK6SFK1'
→ /api/auth/error?error=Callback
→ /users/sign_in?...&error=Callback     (renders the same broken button)

Meanwhile users who re-entered their email resolved the current organization and signed in fine — every redirect preceded by a /api/sso/organizations lookup 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 signIn callback) 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/organizations lookup 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 predicate shouldDiscardSsoHintOnError(hint, error).
  • apps/web/src/hooks/useSignInFlow.ts — one-shot effect that runs once the hint has loaded, calls the existing clearHint(), 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 AuthErrorType union, 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 while initialError was still in props.

The decision lives in a separate plain module because apps/web jest runs testEnvironment: 'node' with no jsdom or testing-library, and testMatch excludes .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_hint with the real dead org id:

Scenario Result
SSO hint, no error Hint kept, SSO button still rendered — happy path intact
SSO hint + ?error=Callback signin_hintnull, SSO button gone, email prefilled and focused
Google hint + ?error=Callback Hint kept, "Continue with Google" + "or see other sign-in methods" — no regression

Checks run:

  • npx tsgo --noEmit in apps/web — exit 0
  • oxlint on the three files — 0 warnings, 0 errors
  • Full apps/web jest suite — 690 suites, 8796 passed, 3 skipped
  • oxfmt applied; git diff --check clean

Scope

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 with orgId but no lastEmail.

Worth a separate PR: OAUTH_ERROR is still absent from AuthErrorType, 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.

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.
@kilo-code-bot

kilo-code-bot Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Code Review Summary

Status: No Issues Found | Recommendation: Merge

Executive Summary

The stale-SSO-hint recovery is correctly scoped and self-consistent: the predicate only fires for workos hints carrying an orgId, the one-shot ref latch prevents clobbering a hint saved later in the session, and the post-clear state transition (tiernew with showEmailInput true and the remembered email preserved) renders a working email prompt — high confidence, no defects found in the changed lines.

Notes verified during review

  • workos is the only SSO method in AllAuthMethodIds, so the predicate's single-method check covers every org-pinned hint.
  • Effect ordering is safe: useSignInHint sets hint and isLoaded in the same commit, so the latch is never consumed while isHintLoaded is still false; the pre-existing email-init effect (useSignInFlow.ts:133) re-runs after clearHint() and does not overwrite the restored address (and self-corrects for the invite tier).
  • No timers, subscriptions, or retained listeners are introduced, so no leak surface.
  • apps/web testMatch (**/src/**/*.test.ts) picks up the new sign-in-hint-recovery.test.ts, so the 6 cases actually run in CI.

Assumptions stated: read-only mode, so tests and typechecks were not executed; the reported tsgo/jest/oxlint results are taken from the PR description. Discarding a valid SSO hint on an unrelated error code (e.g. a stale error param in the URL) is treated as the author's documented, accepted tradeoff rather than a defect.

Files Reviewed (3 files)
  • apps/web/src/hooks/useSignInFlow.ts
  • apps/web/src/lib/auth/sign-in-hint-recovery.ts
  • apps/web/src/lib/auth/sign-in-hint-recovery.test.ts

Reviewed by claude-opus-5 · Input: 34 · Output: 10K · Cached: 865.5K

Review guidance: REVIEW.md from base branch main

@jrf0110
jrf0110 merged commit 205cc99 into main Jul 31, 2026
16 checks passed
@jrf0110
jrf0110 deleted the fix/web-clear-stale-sso-hint-on-error branch July 31, 2026 15:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants