Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions apps/web/src/hooks/useSignInFlow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<string | null>(null);

Expand Down
49 changes: 49 additions & 0 deletions apps/web/src/lib/auth/sign-in-hint-recovery.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
35 changes: 35 additions & 0 deletions apps/web/src/lib/auth/sign-in-hint-recovery.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import type { SignInHint } from '@/hooks/useSignInHint';

type SsoHintFields = Pick<SignInHint, 'lastAuthMethod' | 'orgId'>;

/**
* 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;
}