diff --git a/apps/web/src/hooks/useSignInFlow.ts b/apps/web/src/hooks/useSignInFlow.ts index e135ae0eae..24600963a0 100644 --- a/apps/web/src/hooks/useSignInFlow.ts +++ b/apps/web/src/hooks/useSignInFlow.ts @@ -8,6 +8,7 @@ import { ProdNonSSOAuthProviders } from '@/lib/auth/provider-metadata'; import { useSignInHint, type SignInHint } from '@/hooks/useSignInHint'; import { emailSchema, validateMagicLinkSignupEmail } from '@/lib/schemas/email'; import { sendMagicLink } from '@/lib/auth/send-magic-link'; +import { shouldDiscardSsoHintOnError } from '@/lib/auth/sign-in-hint-recovery'; import type { SSOOrganizationsResponse } from '@/lib/schemas/sso-organizations'; export type FlowState = 'landing' | 'provider-select' | 'magic-link-sent' | 'redirecting'; @@ -162,6 +163,34 @@ export function useSignInFlow({ storybookInitialState?.showEmailInput ?? (ssoMode || initialError === 'DIFFERENT-OAUTH') ); + // Recover from a stale SSO hint. A hint pointing at a WorkOS organization that + // no longer resolves renders an "Enterprise SSO" button that can only ever fail, + // and that screen has no alternative method, so retries loop forever. Dropping + // the hint falls back to the email prompt, which re-runs the server-side + // organization lookup. See shouldDiscardSsoHintOnError for the full reasoning. + // + // One-shot: only the hint the user arrived with is inspected, so a hint saved + // later in this session (just before signIn redirects) is never clobbered. + const hintRecoveryCheckedRef = useRef(false); + useEffect(() => { + if (storybookInitialState || !isHintLoaded || hintRecoveryCheckedRef.current) { + return; + } + hintRecoveryCheckedRef.current = true; + + if (!shouldDiscardSsoHintOnError(hint, initialError)) { + return; + } + + const rememberedEmail = hint?.lastEmail; + clearHint(); + // Keep the address so recovery is a single click instead of a retype. + if (rememberedEmail) { + setEmailState(rememberedEmail); + } + setShowEmailInput(true); + }, [isHintLoaded, hint, initialError, clearHint, storybookInitialState]); + // Store pending SSO orgId in ref instead of window object const pendingSSOOrgIdRef = useRef(null); diff --git a/apps/web/src/lib/auth/sign-in-hint-recovery.test.ts b/apps/web/src/lib/auth/sign-in-hint-recovery.test.ts new file mode 100644 index 0000000000..4e97490d29 --- /dev/null +++ b/apps/web/src/lib/auth/sign-in-hint-recovery.test.ts @@ -0,0 +1,49 @@ +import { shouldDiscardSsoHintOnError } from '@/lib/auth/sign-in-hint-recovery'; + +const ssoHint = { lastAuthMethod: 'workos', orgId: 'org_01KY7Q7B3W99QKBKYGWYK6SFK1' } as const; + +describe('shouldDiscardSsoHintOnError', () => { + it('discards an SSO hint when NextAuth rejects the WorkOS redirect', () => { + // WorkOS answers `error=organization_invalid` for an organization with no + // connection, which NextAuth surfaces as `Callback`. + expect(shouldDiscardSsoHintOnError(ssoHint, 'Callback')).toBe(true); + }); + + it('discards an SSO hint for any other error code that lands on sign-in', () => { + for (const error of [ + 'OAuthCallback', + 'OAuthSignin', + 'AccessDenied', + 'OAUTH_ERROR', + 'DIFFERENT-OAUTH', + 'UNKNOWN-ERROR', + 'some unmapped thrown message', + ]) { + expect(shouldDiscardSsoHintOnError(ssoHint, error)).toBe(true); + } + }); + + it('keeps the SSO hint when the page is not showing an error', () => { + expect(shouldDiscardSsoHintOnError(ssoHint, undefined)).toBe(false); + expect(shouldDiscardSsoHintOnError(ssoHint, null)).toBe(false); + expect(shouldDiscardSsoHintOnError(ssoHint, '')).toBe(false); + }); + + it('keeps non-SSO hints, which always have a working escape hatch', () => { + expect(shouldDiscardSsoHintOnError({ lastAuthMethod: 'google' }, 'Callback')).toBe(false); + expect(shouldDiscardSsoHintOnError({ lastAuthMethod: 'email' }, 'Callback')).toBe(false); + expect(shouldDiscardSsoHintOnError({ lastAuthMethod: 'github' }, 'OAuthCallback')).toBe(false); + }); + + it('keeps a workos hint that carries no organization id, since nothing is stale', () => { + expect(shouldDiscardSsoHintOnError({ lastAuthMethod: 'workos' }, 'Callback')).toBe(false); + expect(shouldDiscardSsoHintOnError({ lastAuthMethod: 'workos', orgId: '' }, 'Callback')).toBe( + false + ); + }); + + it('tolerates a missing hint', () => { + expect(shouldDiscardSsoHintOnError(null, 'Callback')).toBe(false); + expect(shouldDiscardSsoHintOnError(undefined, 'Callback')).toBe(false); + }); +}); diff --git a/apps/web/src/lib/auth/sign-in-hint-recovery.ts b/apps/web/src/lib/auth/sign-in-hint-recovery.ts new file mode 100644 index 0000000000..d86a723f85 --- /dev/null +++ b/apps/web/src/lib/auth/sign-in-hint-recovery.ts @@ -0,0 +1,35 @@ +import type { SignInHint } from '@/hooks/useSignInHint'; + +type SsoHintFields = Pick; + +/** + * Decides whether a stored sign-in hint must be discarded after a failed sign-in. + * + * An SSO hint is the only hint that pins the browser to a specific 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, the user + * is bounced back here with an `error`, and the same failing button is rendered + * again. Every retry reproduces the failure. + * + * Discarding the hint returns the user to the email prompt, which re-runs the + * server-side `/api/sso/organizations` lookup and resolves the organization that + * is live right now. + * + * Any error is treated as disqualifying rather than an allowlist of codes. No + * error code means "the SSO redirect succeeded", the set of codes that can land + * on the sign-in page is large and drifts (NextAuth internals plus our own), and + * the outcomes are wildly asymmetric: a false positive costs one retyped email + * address, while a missed case leaves an account permanently unable to sign in. + */ +export function shouldDiscardSsoHintOnError( + hint: SsoHintFields | null | undefined, + error: string | null | undefined +): boolean { + if (!error) { + return false; + } + return hint?.lastAuthMethod === 'workos' && !!hint.orgId; +}