Skip to content

Commit a066a34

Browse files
committed
fix(auth-ui): preserve OIDC requestId across ceremony-continuation links
A mid-OIDC user who continued the ceremony via certain links/redirects lost the requestId and dead-ended at the default post-login redirect (cloud portal) instead of returning to the relying party (e.g. datumctl). - accounts: 'Add another account' / 'Add an account' now thread requestId. - recovery hook (useAuthErrorRecovery/useAuthActionRecovery): the 'Sign in again' / 'Start over' links on every ceremony screen (setup/*, login/verify/*, login/mfa) now carry requestId + organization. - password: the SESSION_EXPIRED 'Sign in again' link threads requestId + organization. - broken-session guard redirects (setup/authenticator, login/method) preserve it. withQuery drops undefined, so non-OIDC flows keep the bare /login. Audited the full surface against the Zitadel login-v2 pattern; the query-carrying links (method chooser, passkey, OTP resend, BackLink, Not-you, signup<->login) were already correct.
1 parent 80b3bba commit a066a34

23 files changed

Lines changed: 311 additions & 94 deletions

app/hooks/__tests__/use-auth-action-recovery.test.tsx

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,17 @@ import { describe, it, expect, vi } from 'vitest';
1212
vi.mock('@/utils/errors/auth-error-messages', () => ({
1313
useAuthErrorMessage: () => (code?: string) => (code ? `msg:${code}` : undefined),
1414
}));
15+
// The mock echoes the ctx it received into the recovery `to`, so a test can assert the
16+
// wrapper FORWARDS the OIDC ceremony context (requestId/organization) down to
17+
// useAuthErrorRecovery (real implementation threads it onto /login).
1518
vi.mock('@/utils/errors/auth-error-recovery', () => ({
16-
useAuthErrorRecovery: () => (code?: string) =>
17-
code === 'SESSION_EXPIRED' ? { to: '/login', label: 'Sign in again' } : undefined,
19+
useAuthErrorRecovery: (ctx?: { requestId?: string; organization?: string }) => (code?: string) =>
20+
code === 'SESSION_EXPIRED'
21+
? {
22+
to: ctx?.requestId ? `/login?requestId=${ctx.requestId}` : '/login',
23+
label: 'Sign in again',
24+
}
25+
: undefined,
1826
}));
1927

2028
describe('useAuthActionRecovery', () => {
@@ -35,4 +43,25 @@ describe('useAuthActionRecovery', () => {
3543
expect(result.current.message).toBeUndefined();
3644
expect(result.current.recovery).toBeUndefined();
3745
});
46+
47+
// OIDC ceremony preservation: the wrapper forwards the in-scope ceremony context
48+
// (requestId/organization) to useAuthErrorRecovery so the recovery <Link> returns the
49+
// user to the relying party. The mock echoes requestId into `to` to prove forwarding.
50+
it('forwards the ceremony ctx (requestId) to the recovery resolver', () => {
51+
const { result } = renderHook(() =>
52+
useAuthActionRecovery(
53+
{ error: 'SESSION_EXPIRED' },
54+
{ requestId: 'rq1', organization: 'acme' }
55+
)
56+
);
57+
expect(result.current.recovery).toEqual({
58+
to: '/login?requestId=rq1',
59+
label: 'Sign in again',
60+
});
61+
});
62+
63+
it('yields a bare /login recovery when no ctx is forwarded', () => {
64+
const { result } = renderHook(() => useAuthActionRecovery({ error: 'SESSION_EXPIRED' }));
65+
expect(result.current.recovery).toEqual({ to: '/login', label: 'Sign in again' });
66+
});
3867
});

app/hooks/use-auth-action-recovery.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,15 @@ export interface AuthActionRecovery {
1414
// the SAME code, so an adopting route can render an inline banner + a recovery
1515
// <Link> inside <AuthCeremony>. Like useAuthActionError it fires NO toast — the
1616
// inline-only surface stands (the recovery link is ADDITIVE, not a toast).
17-
export function useAuthActionRecovery(actionData: unknown): AuthActionRecovery {
17+
export function useAuthActionRecovery(
18+
actionData: unknown,
19+
ctx?: { requestId?: string; organization?: string }
20+
): AuthActionRecovery {
1821
const getErrorMessage = useAuthErrorMessage();
19-
const getRecovery = useAuthErrorRecovery();
22+
// Forward the in-scope OIDC ceremony context so the recovery <Link> preserves
23+
// requestId/organization (mid-OIDC users return to the relying party, not the
24+
// default post-login redirect). undefined ctx → bare /login (non-OIDC flows).
25+
const getRecovery = useAuthErrorRecovery(ctx);
2026
const code = (actionData as { error?: string } | undefined)?.error;
2127
return { message: getErrorMessage(code), recovery: getRecovery(code) };
2228
}

0 commit comments

Comments
 (0)