From f95747c4b74934e74e564a4646a0a5b82f433665 Mon Sep 17 00:00:00 2001 From: Yahya Fakhroji Date: Mon, 27 Jul 2026 19:31:33 +0700 Subject: [PATCH 1/9] feat(passkeys): passkey management, enrollment, created-at, and cross-device sign-out Adds /id/passkeys (list, sudo-gated remove with a server-side last-method guard, post-removal "sign out other sessions" offer), two-step passkey enrollment (ceremony, then name the credential), created-at metadata on enrolled passkeys, and true cross-device sign-out (deletes the user's other provider-side sessions, not just the local cookie). --- .../identity-badge/identity-badge.tsx | 41 ++- .../sign-out-button/sign-out-button.tsx | 35 ++ .../webauthn-button/webauthn-button.tsx | 145 ++++++-- app/modules/auth/auth-provider.ts | 20 ++ .../auth/providers/fake/fake-provider.ts | 96 +++++- app/modules/auth/providers/zitadel/index.ts | 16 + app/modules/auth/providers/zitadel/mappers.ts | 2 +- app/modules/auth/providers/zitadel/mfa.ts | 106 +++++- app/modules/auth/providers/zitadel/session.ts | 24 ++ app/modules/auth/select.server.ts | 20 ++ app/modules/i18n/locales/en.po | 169 +++++++++- app/resources/mfa/mfa-routing.ts | 10 + app/resources/passkeys/passkeys.schema.ts | 9 + app/resources/passkeys/passkeys.service.ts | 223 +++++++++++++ app/resources/reauth/reauth.service.ts | 176 ++++++++++ app/resources/shared/return-to.ts | 30 ++ app/resources/shared/sudo.ts | 24 ++ app/resources/webauthn/aaguid-names.json | 50 +++ app/resources/webauthn/aaguid.ts | 161 +++++++++ app/resources/webauthn/webauthn-enroll.ts | 73 +++- app/resources/webauthn/webauthn.service.ts | 47 ++- app/resources/webauthn/webauthn.ts | 87 +++-- app/routes/passkeys.tsx | 312 ++++++++++++++++++ app/routes/paths.ts | 3 + app/routes/reauth.tsx | 251 ++++++++++++++ app/routes/setup/passkey.tsx | 144 ++++++-- .../identity-badge/identity-badge.cy.tsx | 25 ++ .../sign-out-button/sign-out-button.cy.tsx | 26 ++ .../webauthn-button.ceremony-errors.cy.tsx | 109 ++++++ .../webauthn-button/webauthn-button.cy.tsx | 5 +- .../webauthn-button.on-credential.cy.tsx | 71 ++++ .../modules/auth/fake-passkeys.cy.ts | 43 +++ .../modules/auth/providers/parity.cy.ts | 2 + .../auth/providers/zitadel/index.cy.ts | 116 +++++++ .../component/resources/mfa/mfa-routing.cy.ts | 62 ++++ .../resources/passkeys/passkeys.service.cy.ts | 231 +++++++++++++ .../resources/reauth/reauth.service.cy.ts | 74 +++++ .../resources/shared/return-to.cy.ts | 26 ++ cypress/component/resources/shared/sudo.cy.ts | 31 ++ .../component/resources/webauthn/aaguid.cy.ts | 45 +++ .../resources/webauthn/webauthn.cy.ts | 143 ++++++-- .../resources/webauthn/webauthn.service.cy.ts | 6 + cypress/component/routes/passkeys-ui.cy.tsx | 173 ++++++++++ .../routes/setup/setup-passkey-naming.cy.tsx | 190 +++++++++++ cypress/e2e/passkey-use.cy.ts | 2 +- cypress/e2e/passkeys-manage.cy.ts | 174 ++++++++++ cypress/e2e/reauth.cy.ts | 64 ++++ cypress/e2e/setup-passkey-mfa.cy.ts | 111 ++++--- cypress/support/attestation-fixture.ts | 43 +++ cypress/support/audit-coverage.ts | 17 + 50 files changed, 3874 insertions(+), 189 deletions(-) create mode 100644 app/components/sign-out-button/sign-out-button.tsx create mode 100644 app/resources/passkeys/passkeys.schema.ts create mode 100644 app/resources/passkeys/passkeys.service.ts create mode 100644 app/resources/reauth/reauth.service.ts create mode 100644 app/resources/shared/return-to.ts create mode 100644 app/resources/shared/sudo.ts create mode 100644 app/resources/webauthn/aaguid-names.json create mode 100644 app/resources/webauthn/aaguid.ts create mode 100644 app/routes/passkeys.tsx create mode 100644 app/routes/reauth.tsx create mode 100644 cypress/component/components/sign-out-button/sign-out-button.cy.tsx create mode 100644 cypress/component/components/webauthn-button/webauthn-button.ceremony-errors.cy.tsx create mode 100644 cypress/component/components/webauthn-button/webauthn-button.on-credential.cy.tsx create mode 100644 cypress/component/modules/auth/fake-passkeys.cy.ts create mode 100644 cypress/component/resources/passkeys/passkeys.service.cy.ts create mode 100644 cypress/component/resources/reauth/reauth.service.cy.ts create mode 100644 cypress/component/resources/shared/return-to.cy.ts create mode 100644 cypress/component/resources/shared/sudo.cy.ts create mode 100644 cypress/component/resources/webauthn/aaguid.cy.ts create mode 100644 cypress/component/routes/passkeys-ui.cy.tsx create mode 100644 cypress/component/routes/setup/setup-passkey-naming.cy.tsx create mode 100644 cypress/e2e/passkeys-manage.cy.ts create mode 100644 cypress/e2e/reauth.cy.ts create mode 100644 cypress/support/attestation-fixture.ts diff --git a/app/components/identity-badge/identity-badge.tsx b/app/components/identity-badge/identity-badge.tsx index da9e9acb6b..d07d2b2924 100644 --- a/app/components/identity-badge/identity-badge.tsx +++ b/app/components/identity-badge/identity-badge.tsx @@ -1,31 +1,54 @@ import { Trans } from '@lingui/react/macro'; +import type { ReactNode } from 'react'; import { Link } from 'react-router'; interface IdentityBadgeProps { loginName: string; - /** Threaded through "Not you?" so the OIDC/org ceremony continues. loginName is intentionally dropped. */ + /** Threaded through the link so the OIDC/org ceremony continues. loginName is intentionally dropped. */ requestId?: string; organization?: string; + /** Text before the bolded loginName. Default: "Signing in as". */ + verb?: ReactNode; + /** Text of the trailing link. Default: "Not you?". Ignored when showLink is false. */ + linkLabel?: ReactNode; + /** Explicit link target. Default: /login (+ requestId/organization qs, loginName dropped). */ + linkTarget?: string; + /** Set false to render identity context with no link at all. Default: true. */ + showLink?: boolean; } /** - * "Signing in as — Not you?" shown on post-identifier steps. "Not you?" - * returns to /login (navigation only; the multi-account session list is untouched), - * preserving requestId + organization but clearing loginName so a different account - * can be entered. Renders nothing without a loginName. + * " " shown on post-identifier steps. The link + * (when shown) preserves requestId + organization but clears loginName so a + * different account can be entered/switched to. Renders nothing without a loginName. */ -export function IdentityBadge({ loginName, requestId, organization }: IdentityBadgeProps) { +export function IdentityBadge({ + loginName, + requestId, + organization, + verb = Signing in as, + linkLabel = Not you?, + linkTarget, + showLink = true, +}: IdentityBadgeProps) { if (!loginName) return null; + if (!showLink) { + return ( +

+ {verb} {loginName} +

+ ); + } const params = new URLSearchParams(); if (requestId) params.set('requestId', requestId); if (organization) params.set('organization', organization); const qs = params.toString(); - const to = qs ? `/login?${qs}` : '/login'; + const to = linkTarget ?? (qs ? `/login?${qs}` : '/login'); return (

- Signing in as {loginName}.{' '} + {verb} {loginName}.{' '} - Not you? + {linkLabel}

); diff --git a/app/components/sign-out-button/sign-out-button.tsx b/app/components/sign-out-button/sign-out-button.tsx new file mode 100644 index 0000000000..72fd497285 --- /dev/null +++ b/app/components/sign-out-button/sign-out-button.tsx @@ -0,0 +1,35 @@ +import { AuthFormFields } from '@/components/auth-form/auth-form-fields'; +import { Button } from '@datum-cloud/datum-ui/button'; +import { Trans } from '@lingui/react/macro'; + +export interface SignOutButtonProps { + csrf: string; + /** + * Visual weight. 'primary' (solid) for screens where signing out IS the point + * (signed-in.tsx, logout/index.tsx). 'secondary' (link) for account-management + * screens where sign-out is a minor action among many (sso/index.tsx, passkeys.tsx). + * Default: 'secondary'. + */ + emphasis?: 'primary' | 'secondary'; +} + +/** + * Shared "Sign out" control. POSTs to /id/logout?index — the ?index is required so a + * native
hits the logout INDEX route's action, not its action-less layout. + */ +export function SignOutButton({ csrf, emphasis = 'secondary' }: SignOutButtonProps) { + return ( + + + {emphasis === 'primary' ? ( + + ) : ( + + )} + + ); +} diff --git a/app/components/webauthn-button/webauthn-button.tsx b/app/components/webauthn-button/webauthn-button.tsx index e88831287c..2747bfae5a 100644 --- a/app/components/webauthn-button/webauthn-button.tsx +++ b/app/components/webauthn-button/webauthn-button.tsx @@ -3,8 +3,10 @@ import { createAttestation, marshalAssertion, isWebAuthnSupported, + WebAuthnCeremonyError, WebAuthnUnsupportedError, type WebAuthnChallengeInput, + type WebAuthnReason, } from '@/resources/webauthn/webauthn'; import { Button } from '@datum-cloud/datum-ui/button'; import { Icon } from '@datum-cloud/datum-ui/icons'; @@ -15,7 +17,7 @@ import { useNavigation, useSubmit } from 'react-router'; // Pre-baked credential for Cypress / test environments where navigator.credentials is unavailable. // The fake provider's updateSession accepts any webAuthN payload so this value is arbitrary. -const CYPRESS_CREDENTIAL = { +export const CYPRESS_CREDENTIAL = { id: 'fake-credential-id', rawId: 'ZmFrZS1jcmVkZW50aWFsLWlk', type: 'public-key', @@ -27,6 +29,92 @@ const CYPRESS_CREDENTIAL = { }, }; +/** + * Rendered failure state. + * - 'browser-unsupported': the browser lacks WebAuthn entirely (pre-ceremony support check / + * WebAuthnUnsupportedError) — a single shared message. + * - 'ceremony': the create/get ceremony failed; `reason` (from classifyWebAuthnError) plus the + * flow (enroll vs sign-in) selects the specific copy. + */ +type WebAuthnErrorState = + { kind: 'browser-unsupported' } | { kind: 'ceremony'; reason: WebAuthnReason } | null; + +/** + * Reason → user copy for a SIGN-IN (assertion) ceremony failure. Every branch is a literal + * so Lingui extraction picks it up statically. Moved verbatim out of the button so + * surfaces that run the login ceremony without this button (usePasskeyLoginCeremony) can render + * the identical messages. `already-registered` has no sign-in meaning, so it falls back to the + * generic copy via the default branch. + */ +export function WebAuthnReasonCopy({ reason }: { reason: WebAuthnReason }) { + switch (reason) { + case 'not-allowed': + return ( + + Passkey sign-in was cancelled, or no passkey for this account is available on this device. + Make sure you're using a device where you set up your passkey, then try again. + + ); + case 'unsupported': + return ( + + This device can't use a passkey to sign in. Try another device or a different sign-in + method. + + ); + case 'security': + return ( + + Passkey sign-in couldn't be completed for security reasons. Please contact support if this + continues. + + ); + default: + return The passkey verification failed. Please try again.; + } +} + +/** + * Reason- AND flow-specific ceremony failure copy. enroll = attestation (create), sign-in = + * assertion (get). The sign-in branch delegates to the exported WebAuthnReasonCopy so both + * copies stay byte-identical wherever they're rendered. + */ +function ceremonyMessage( + reason: WebAuthnReason, + mode: 'assertion' | 'attestation' +): React.ReactNode { + if (mode === 'attestation') { + switch (reason) { + case 'not-allowed': + return ( + + Passkey setup was cancelled, or this device has no passkey support. Make sure Touch ID, + Windows Hello, a security key, or a password manager is available, then try again. + + ); + case 'already-registered': + return You already have a passkey for this account on this device.; + case 'unsupported': + return ( + + This device can't create a passkey. Try another device, a security key, or a password + manager. + + ); + case 'security': + return ( + + Passkey setup couldn't be completed for security reasons. Please contact support if this + continues. + + ); + default: + return We couldn't set up your passkey. Please try again.; + } + } + return ; +} + interface WebAuthnButtonProps { /** The publicKey options from the loader challenge. * - assertion mode (default): publicKeyCredentialRequestOptions.publicKey — may be null when @@ -49,6 +137,13 @@ interface WebAuthnButtonProps { * setup/security-key enrollment screens. */ mode?: 'assertion' | 'attestation'; + /** + * When set, the finished ceremony credential is handed to the parent + * INSTEAD of auto-submitting the form — the two-step enroll flow (ceremony → name + * step → submit) holds it in route state between steps. Runs on the Cypress + * pre-baked path too, so tests exercise the same handoff. + */ + onCredential?: (credential: Record) => void; } /** @@ -74,10 +169,11 @@ export function WebAuthnButton({ loading, label, mode = 'assertion', + onCredential, }: WebAuthnButtonProps) { const navigation = useNavigation(); const submit = useSubmit(); - const [error, setError] = useState(null); + const [error, setError] = useState(null); const [mounted, setMounted] = useState(false); useEffect(() => { setMounted(true); @@ -93,20 +189,24 @@ export function WebAuthnButton({ if (!isCypress && !isWebAuthnSupported()) { // Real browser without WebAuthn — surface the unsupported message immediately // instead of submitting the fake Cypress credential to the production backend. - setError('webauthn-unsupported'); + setError({ kind: 'browser-unsupported' }); return; } - if (isCypress) { - // Cypress fake-credential path: no publicKey needed. - // The pre-baked credential works for both assertion and attestation because - // the fake provider accepts any payload for both verifyPasskey and verifyU2F. + // Cypress fake-credential path: no publicKey needed. The pre-baked credential works for + // both assertion and attestation because the fake provider accepts any payload. A spec may + // opt OUT of the fake (window.__webAuthnRealCeremony) to drive the REAL ceremony against a + // stubbed navigator.credentials and assert reason-specific failure copy; undefined in prod. + const useFakeCredential = + isCypress && + !(window as unknown as { __webAuthnRealCeremony?: boolean }).__webAuthnRealCeremony; + if (useFakeCredential) { credential = CYPRESS_CREDENTIAL; } else { // Guard: if the server-side challenge failed (loader caught an error and left // the options null), we have no options to pass to the authenticator — surface // the failure instead of calling the WebAuthn API with null. if (!publicKey) { - setError('webauthn-failed'); + setError({ kind: 'ceremony', reason: 'unknown' }); return; } if (mode === 'attestation') { @@ -118,9 +218,15 @@ export function WebAuthnButton({ } } + // Two-step enroll: hand the credential up instead of submitting. + if (onCredential) { + onCredential(credential); + return; + } + const form = formRef.current; if (!form) { - setError('webauthn-failed'); + setError({ kind: 'ceremony', reason: 'unknown' }); return; } @@ -130,28 +236,25 @@ export function WebAuthnButton({ void submit(formData, { method: 'post' }); } catch (err) { if (err instanceof WebAuthnUnsupportedError) { - setError('webauthn-unsupported'); + setError({ kind: 'browser-unsupported' }); + } else if (err instanceof WebAuthnCeremonyError) { + // Reason-classified ceremony failure (thrown DOMException / null credential). + setError({ kind: 'ceremony', reason: err.reason }); } else { - setError('webauthn-failed'); + setError({ kind: 'ceremony', reason: 'unknown' }); } } } return (
- {error === 'webauthn-unsupported' ? ( + {error?.kind === 'browser-unsupported' ? ( Your browser does not support passkeys. Please use a supported browser. - ) : error === 'webauthn-failed' ? ( - - {mode === 'attestation' ? ( - // Enrollment (attestation) failure — distinct from verification. - We couldn't set up your passkey. Please try again. - ) : ( - The passkey verification failed. Please try again. - )} - + ) : error?.kind === 'ceremony' ? ( + // Reason- AND flow-specific copy (enroll vs sign-in). See ceremonyMessage. + {ceremonyMessage(error.reason, mode)} ) : null} + + + Remove this passkey?} + description={ + "{row.name}" will no longer work for signing in. This cannot be undone. + } + /> + + + + + + + + + + + + ); +} + +/** + * Post-removal session-hygiene offer as a dialog. "Not now" only + * closes it; the sign-out submit reuses the existing signout-others action intent. + */ +function SignOutOthersDialog({ + open, + onOpenChange, + csrfToken, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + csrfToken: string; +}) { + return ( + + + Passkey removed} + description={ + + Signed-in sessions on other devices can still be active. Sign out your other sessions? + + } + /> + + + + + + + + + + + ); +} + +export default function Passkeys() { + const { csrfToken, view } = useLoaderData(); + const actionData = useActionData(); + const { passkeys, loginName, returnTo } = view; + + const errorCode = (actionData as { error?: string } | undefined)?.error; + const genericError = useAuthActionError(actionData); + const { i18n } = useLingui(); + + // Open the sign-out-others dialog on each successful removal — actionData + // is a fresh object per POST, so the effect fires exactly once per removal. + const [signOutOpen, setSignOutOpen] = useState(false); + useEffect(() => { + // Open on a successful removal; close when any OTHER action result lands + // (the signout-others redirect clears actionData without remounting this route). + setSignOutOpen((actionData as { removed?: string } | undefined)?.removed !== undefined); + }, [actionData]); + + return ( + Passkeys} + description={ + <> + Passkeys let you sign in with your fingerprint, face, or device PIN. + {loginName && ( + Logged in as} + linkLabel={Use a different account} + linkTarget={paths.accounts()} + /> + )} + + } + className="max-w-[480px]"> +
+ {errorCode === 'LAST_METHOD' ? ( + + You can't remove your only sign-in method. Add another method first. + + ) : genericError ? ( + {genericError} + ) : null} + + {passkeys.length === 0 ? ( + // Same minimal empty-state shape as /accounts and /sso. +

+ No passkeys yet. +

+ ) : ( +
    + {passkeys.map((row) => ( +
  • +
    + +
    +
    + {row.name} + {row.state === 'inactive' ? ( + // State is only surfaced when something is wrong — no "Active" noise. + + Inactive + + ) : null} +
    + {row.createdAt ? ( + // Enroll date; absent for passkeys with no created-at metadata (no backfill). + + + Added {i18n.date(new Date(row.createdAt), { dateStyle: 'medium' })} + + + ) : null} +
    +
    + +
  • + ))} +
+ )} + + }> + Add passkey + + + {returnTo && /^https?:\/\//.test(returnTo) ? ( + // Validated external entry point (portal round-trip) — offer the way back. + + Back + + ) : null} + + +
+ + +
+ ); +} diff --git a/app/routes/paths.ts b/app/routes/paths.ts index ef1c34adc3..73323d8018 100644 --- a/app/routes/paths.ts +++ b/app/routes/paths.ts @@ -68,6 +68,9 @@ export const paths = { index: (q?: Query) => withQuery('/logout', q), success: (q?: Query) => withQuery('/logout/success', q), }, + // Passkey management + sudo re-auth interstitial. + passkeys: (q?: Query) => withQuery('/passkeys', q), + reauth: (q?: Query) => withQuery('/reauth', q), accounts: (q?: Query) => withQuery('/accounts', q), signedIn: (q?: Query) => withQuery('/signed-in', q), error: (q?: Query) => withQuery('/error', q), diff --git a/app/routes/reauth.tsx b/app/routes/reauth.tsx new file mode 100644 index 0000000000..b447220e76 --- /dev/null +++ b/app/routes/reauth.tsx @@ -0,0 +1,251 @@ +// /id/reauth — the sudo interstitial ("Confirm it's you"). +// +// Verifies ONE enrolled factor onto the EXISTING session (SetSession semantics via +// reauth.service). Never touches login-decision.ts / next-step routing: on success the +// user returns to the validated returnTo (default /passkeys). +import { AuthCard } from '@/components/auth-card/auth-card'; +import { SubmitButton } from '@/components/auth-form/auth-form'; +import { AuthFormFields } from '@/components/auth-form/auth-form-fields'; +import { FormError } from '@/components/form-error/form-error'; +import { WebAuthnButton } from '@/components/webauthn-button/webauthn-button'; +import { useAuthActionError } from '@/hooks/use-auth-action-error'; +import { readSessions, serializeSessions } from '@/modules/auth/session/cookie'; +import { + loadReauth, + performReauth, + type ReauthLoadResult, + type ReauthMethod, +} from '@/resources/reauth/reauth.service'; +import { paths } from '@/routes/paths'; +import { providerForRequest } from '@/server/auth-context.server'; +import { getCsrfToken, assertCsrf } from '@/server/csrf'; +import { env } from '@/server/infra/env.server'; +import { actionError } from '@/utils/errors/auth-error'; +import { LinkButton } from '@datum-cloud/datum-ui/button'; +import { Form } from '@datum-cloud/datum-ui/form'; +import { Icon } from '@datum-cloud/datum-ui/icons'; +import { Trans, useLingui } from '@lingui/react/macro'; +import { Key, Lock, Mail } from 'lucide-react'; +import { useRef } from 'react'; +import { + data, + redirect, + useActionData, + useLoaderData, + useNavigation, + type ActionFunctionArgs, + type LoaderFunctionArgs, + type MetaFunction, +} from 'react-router'; +import { Form as RRForm, Link } from 'react-router'; +import { z } from 'zod'; + +export const meta: MetaFunction = () => [{ title: "Confirm it's you" }]; + +const METHOD_PARAMS = ['passkey', 'password', 'otp_email'] as const; + +type ReauthView = Extract; + +interface ReauthLoaderData { + csrfToken: string; + view: ReauthView; +} + +export async function loader({ request }: LoaderFunctionArgs) { + const url = new URL(request.url); + const rawMethod = url.searchParams.get('method'); + const method = (METHOD_PARAMS as readonly string[]).includes(rawMethod ?? '') + ? (rawMethod as ReauthMethod) + : null; + + const provider = providerForRequest(request); + const sessions = await readSessions(request); + + const result = await loadReauth(provider, sessions, { + returnTo: url.searchParams.get('returnTo'), + method, + domain: url.hostname, + emailDeliveryEnabled: env.AUTH_EMAIL_DELIVERY_ENABLED, + }); + if (result.kind === 'redirect') return redirect(result.target); + + const [csrfToken, setCookie] = await getCsrfToken(request); + const headers: Record = {}; + if (setCookie !== null) headers['set-cookie'] = setCookie; + + return data({ csrfToken, view: result }, { headers }); +} + +const reauthActionSchema = z.object({ + factor: z.enum(['passkey', 'password', 'otp_email']), + password: z.string().optional(), + code: z.string().optional(), + credential: z.string().optional(), + returnTo: z.string().optional(), +}); + +export async function action({ request }: ActionFunctionArgs) { + const provider = providerForRequest(request); + const form = await request.formData(); + await assertCsrf(request, form); + + const parsed = reauthActionSchema.safeParse(Object.fromEntries(form)); + if (!parsed.success) return data({ error: 'INVALID_INPUT' as const }, { status: 400 }); + + const sessions = await readSessions(request); + try { + const result = await performReauth(provider, sessions, { + factor: parsed.data.factor, + password: parsed.data.password, + code: parsed.data.code, + credential: parsed.data.credential, + returnTo: parsed.data.returnTo ?? null, + }); + if (!result.ok) { + const status = result.error === 'INVALID_CREDENTIALS' ? 401 : 400; + return data({ error: result.error }, { status }); + } + return redirect(result.target, { + headers: { 'set-cookie': await serializeSessions(result.sessions) }, + }); + } catch (err) { + return actionError(err); + } +} + +// ── chooser row ──────────────────────────────────────────────────────────────── + +function MethodRow({ + href, + icon, + children, +}: { + href: string; + icon: React.ReactNode; + children: React.ReactNode; +}) { + // LinkButton (single styled ) — NOT Button asChild (nested-interactive axe violation). + return ( + + {children} + + ); +} + +export default function Reauth() { + const { csrfToken, view } = useLoaderData(); + const actionData = useActionData(); + const navigation = useNavigation(); + const { t } = useLingui(); + const formRef = useRef(null); + const errorMessage = useAuthActionError(actionData); + + const { methods, method, returnTo, publicKeyCredentialRequestOptions } = view; + + // Extract the inner publicKey object that marshalAssertion expects. + const publicKey = + publicKeyCredentialRequestOptions !== null && + typeof publicKeyCredentialRequestOptions === 'object' && + 'publicKey' in (publicKeyCredentialRequestOptions as object) + ? (publicKeyCredentialRequestOptions as { publicKey: unknown }).publicKey + : publicKeyCredentialRequestOptions; + + return ( + Confirm it's you} + description={ + For your security, verify one of your sign-in methods to continue. + }> + {method === null ? ( +
+ {methods.includes('passkey') ? ( + }> + Passkey + + ) : null} + {methods.includes('password') ? ( + }> + Password + + ) : null} + {methods.includes('otp_email') ? ( + }> + Email me a code + + ) : null} + {methods.length === 0 ? ( + + No sign-in method is available for re-authentication. + + ) : null} +
+ ) : method === 'passkey' ? ( + // Hidden form that WebAuthnButton populates and submits. + + + + + + {errorMessage ? {errorMessage} : null} + + + ) : method === 'password' ? ( + + + + + + + + {errorMessage} + + Confirm + + + ) : ( + + + + +

+ We sent a verification code to your email address. +

+ + + + {errorMessage} + + Confirm + +
+ )} +
+ ); +} diff --git a/app/routes/setup/passkey.tsx b/app/routes/setup/passkey.tsx index 0669268501..2d094cc30d 100644 --- a/app/routes/setup/passkey.tsx +++ b/app/routes/setup/passkey.tsx @@ -8,9 +8,13 @@ import { PASSKEY_ENROLL_CONFIG, type WebAuthnEnrollActionData, } from '@/resources/webauthn'; +import { aaguidFromAttestationObject, defaultPasskeyName } from '@/resources/webauthn/aaguid'; +import { Button } from '@datum-cloud/datum-ui/button'; +import { Input } from '@datum-cloud/datum-ui/input'; +import { Label } from '@datum-cloud/datum-ui/label'; import { Trans } from '@lingui/react/macro'; -import { useRef } from 'react'; -import { useActionData, useLoaderData, type MetaFunction } from 'react-router'; +import { useEffect, useRef, useState } from 'react'; +import { useActionData, useLoaderData, useNavigation, type MetaFunction } from 'react-router'; import { Form as RRForm } from 'react-router'; export const meta: MetaFunction = () => [{ title: 'Set up passkey' }]; @@ -21,6 +25,15 @@ const h = createWebAuthnEnrollHandlers(PASSKEY_ENROLL_CONFIG); export const loader = h.loader; export const action = h.action; +/** The credential held between the ceremony (step 1) and the name step (step 2). */ +interface HeldCredential { + credential: Record; + /** AAGUID catalog name, else the UA-derived fallback — the name input's pre-fill. */ + defaultName: string; + /** UA-derived device label — rendered as a hint when it adds context over defaultName. */ + uaName: string; +} + export default function SetupPasskey() { // The local-const re-export lets RR7 infer the loader return (data). const { @@ -33,9 +46,16 @@ export default function SetupPasskey() { credentialId, publicKey, challengeFailed, + returnTo, } = useLoaderData(); const actionData = useActionData() as WebAuthnEnrollActionData | undefined; const formRef = useRef(null); + const navigation = useNavigation(); + + // Name-after-ceremony: null = step 1 (run the ceremony), non-null = step 2 + // (name the held credential). The AAGUID exists only in the returned attestation, + // so the pre-fill is computed at this transition. + const [held, setHeld] = useState(null); // Inline message + a recovery for recoverable codes (SESSION_EXPIRED → "Sign in again"). const { message: errorMessage, recovery } = useAuthActionRecovery(actionData, { @@ -43,21 +63,46 @@ export default function SetupPasskey() { organization, }); + // A verify failure while holding a credential (challenge expiry surfaces as + // INVALID_CREDENTIALS) auto-resets to step 1 — React Router's post-action + // revalidation has already fetched a fresh challenge, so one click retries the + // full ceremony. + useEffect(() => { + if (actionData?.error) setHeld(null); + }, [actionData]); + + function handleCredential(credential: Record) { + const att = (credential.response as { attestationObject?: string } | undefined) + ?.attestationObject; + const aaguid = att ? aaguidFromAttestationObject(att) : null; + setHeld({ + credential, + defaultName: defaultPasskeyName(aaguid, navigator.userAgent), + uaName: defaultPasskeyName(null, navigator.userAgent), + }); + } + return ( Set up passkey} + title={held ? Name your passkey : Set up passkey} description={ - - Register a passkey using your device's biometric sensor or PIN to sign in securely without - a password. - + held ? ( + Your passkey is ready — give it a name so you can recognize it later. + ) : ( + + Register a passkey using your device's biometric sensor or PIN to sign in securely + without a password. + + ) } - error={errorMessage} - recovery={recovery} + /* A prior failure's banner is step-1 context — a freshly re-entered + name step must not show "setup failed" beside "your passkey is ready". */ + error={held ? undefined : errorMessage} + recovery={held ? undefined : recovery} loginName={loginName} requestId={requestId} organization={organization}> - {/* Hidden form that WebAuthnButton populates and submits. */} + {/* One form spans both steps — the hidden fields ride along on the step-2 submit. */} {force ? : null} {checkAfter ? : null} - {/* credential is populated by WebAuthnButton before submit */} - + {/* Validated return target (/passkeys round-trip) — posted, re-validated server-side. */} + {returnTo ? : null} + {/* Carries the held ceremony result into the step-2 submit. */} + - {/* The loader couldn't fetch an attestation challenge — warn up front - with enrollment-specific copy (distinct from the assertion verification error). */} - {challengeFailed ? ( - - We couldn't start passkey setup. Please try again. - - ) : null} + {held ? ( + <> + {/* Step 2 — Save-only: the authenticator-side credential already exists, + so there is no cancel (it would orphan the entry in the user's password + manager). Pre-fill from AAGUID, else the UA fallback; set-once (no rename RPC). */} +
+ + + {held.uaName !== held.defaultName ? ( +

+ Created using {held.uaName} +

+ ) : null} +

+ + This name is for your Datum passkey list — your password manager labels it + separately. Names can't be changed later. + +

+
+ + + ) : ( + <> + {/* The loader couldn't fetch an attestation challenge — warn up front + with enrollment-specific copy (distinct from the assertion verification error). */} + {challengeFailed ? ( + + We couldn't start passkey setup. Please try again. + + ) : null} - Register passkey} - /> + Register passkey} + onCredential={handleCredential} + /> + + )}
); diff --git a/cypress/component/components/identity-badge/identity-badge.cy.tsx b/cypress/component/components/identity-badge/identity-badge.cy.tsx index 9e7f2ab911..b79bf344f6 100644 --- a/cypress/component/components/identity-badge/identity-badge.cy.tsx +++ b/cypress/component/components/identity-badge/identity-badge.cy.tsx @@ -18,4 +18,29 @@ describe('IdentityBadge', () => { cy.get('p').should('not.exist'); cy.get('a').should('not.exist'); }); + + it('supports a custom verb, link label, and link target (defaults unchanged)', () => { + cy.mount( + + ); + cy.contains('Signing up as').should('exist'); + cy.contains('bob@acme.test').should('exist'); + cy.findByRole('link', { name: /not you/i }).should( + 'have.attr', + 'href', + '/signup?requestId=oidc_z' + ); + }); + + it('renders no link at all when showLink is false', () => { + cy.mount(); + cy.contains('Sign out of').should('exist'); + cy.contains('carol@acme.test').should('exist'); + cy.get('a').should('not.exist'); + }); }); diff --git a/cypress/component/components/sign-out-button/sign-out-button.cy.tsx b/cypress/component/components/sign-out-button/sign-out-button.cy.tsx new file mode 100644 index 0000000000..ecdd1ac1c4 --- /dev/null +++ b/cypress/component/components/sign-out-button/sign-out-button.cy.tsx @@ -0,0 +1,26 @@ +import { SignOutButton } from '@/components/sign-out-button/sign-out-button'; + +// The datum-ui Button does not render a `data-theme` attribute — its `theme`/`type` props +// are compiled to Tailwind utility classes only (confirmed by mounting Button directly and +// inspecting outerHTML: theme="solid" + type="primary" emits a class containing +// "bg-btn-primary"; theme="link" + type="secondary" emits a class containing "underline", +// with no compound-variant overlap between the two). Assert on those classes instead. +describe('SignOutButton', () => { + it('posts to /id/logout?index with the CSRF token and defaults to the secondary/link treatment', () => { + cy.mount(); + cy.get('form').should('have.attr', 'action', '/id/logout?index'); + cy.get('input[name="csrf"]').should('have.value', 'tok-1'); + cy.contains('button', 'Sign out') + .invoke('attr', 'class') + .should('include', 'underline') + .and('not.include', 'bg-btn-primary'); + }); + + it('renders the primary/solid treatment when emphasis="primary"', () => { + cy.mount(); + cy.contains('button', 'Sign out') + .invoke('attr', 'class') + .should('include', 'bg-btn-primary') + .and('not.include', 'underline'); + }); +}); diff --git a/cypress/component/components/webauthn-button/webauthn-button.ceremony-errors.cy.tsx b/cypress/component/components/webauthn-button/webauthn-button.ceremony-errors.cy.tsx new file mode 100644 index 0000000000..afaed8200f --- /dev/null +++ b/cypress/component/components/webauthn-button/webauthn-button.ceremony-errors.cy.tsx @@ -0,0 +1,109 @@ +// Browser-error-specific WebAuthn failure copy. +// +// navigator.credentials.create()/.get() THROW a DOMException on a real failure (no +// authenticator, cancelled, already-registered). WebAuthnButton must classify that +// DOMException and render a message specific to BOTH the reason AND the flow — +// enroll (mode="attestation") vs sign-in (mode="assertion", the default). +// +// Under Cypress the button normally takes the pre-baked-credential shortcut (see +// webauthn-button.cy.tsx). Here we opt INTO the real ceremony via the documented +// window.__webAuthnRealCeremony seam and stub navigator.credentials to reject with a +// specific DOMException, so the full path (ceremony → classifyWebAuthnError → copy) +// is exercised end-to-end. +import { WebAuthnButton } from '@/components/webauthn-button/webauthn-button'; +import React from 'react'; + +const PK_GET = { challenge: 'YQ', allowCredentials: [] }; +const PK_CREATE = { challenge: 'YQ', user: { id: 'YQ' }, excludeCredentials: [] }; + +function ensureWebAuthnEnv(win: Window): void { + const w = win as unknown as { PublicKeyCredential?: unknown }; + if (typeof w.PublicKeyCredential === 'undefined') { + w.PublicKeyCredential = function () {} as unknown; + } + if (!win.navigator.credentials) { + Object.defineProperty(win.navigator, 'credentials', { + value: { create: () => Promise.resolve(null), get: () => Promise.resolve(null) }, + configurable: true, + }); + } +} + +/** Mount the button, force the real ceremony, and reject the given ceremony method with a DOMException. */ +function mountRejecting( + mode: 'assertion' | 'attestation', + method: 'create' | 'get', + domName: string +) { + const formRef = React.createRef(); + const publicKey = mode === 'attestation' ? PK_CREATE : PK_GET; + cy.mountRemixRoute(, { + path: '/passkey', + initialEntries: ['/passkey'], + }); + cy.window().then((win) => { + (win as unknown as { __webAuthnRealCeremony?: boolean }).__webAuthnRealCeremony = true; + ensureWebAuthnEnv(win); + cy.stub(win.navigator.credentials, method).rejects(new win.DOMException('boom', domName)); + }); + cy.findByRole('button').should('not.be.disabled').click(); +} + +describe('WebAuthnButton enroll (attestation) failure copy', () => { + it('NotAllowedError → cancelled / no-support setup guidance', () => { + mountRejecting('attestation', 'create', 'NotAllowedError'); + cy.findByText(/passkey setup was cancelled/i).should('exist'); + cy.findByText(/verification failed/i).should('not.exist'); + }); + + it('InvalidStateError → already-registered', () => { + mountRejecting('attestation', 'create', 'InvalidStateError'); + cy.findByText(/already have a passkey for this account/i).should('exist'); + }); + + it("NotSupportedError → device can't create a passkey", () => { + mountRejecting('attestation', 'create', 'NotSupportedError'); + cy.findByText(/can't create a passkey/i).should('exist'); + }); + + it('SecurityError → security-reasons setup copy', () => { + mountRejecting('attestation', 'create', 'SecurityError'); + cy.findByText(/passkey setup couldn't be completed for security reasons/i).should('exist'); + }); + + it('unmapped DOMException → generic enroll copy', () => { + mountRejecting('attestation', 'create', 'NetworkError'); + cy.findByText(/couldn't set up your passkey/i).should('exist'); + }); +}); + +describe('WebAuthnButton sign-in (assertion) failure copy', () => { + it('NotAllowedError → cancelled / no-passkey sign-in guidance', () => { + mountRejecting('assertion', 'get', 'NotAllowedError'); + cy.findByText(/passkey sign-in was cancelled/i).should('exist'); + cy.findByText(/set up your passkey/i).should('exist'); + }); + + it("NotSupportedError → device can't use a passkey to sign in", () => { + mountRejecting('assertion', 'get', 'NotSupportedError'); + cy.findByText(/can't use a passkey to sign in/i).should('exist'); + }); + + it('SecurityError → security-reasons sign-in copy', () => { + mountRejecting('assertion', 'get', 'SecurityError'); + cy.findByText(/passkey sign-in couldn't be completed for security reasons/i).should('exist'); + }); + + it('unmapped DOMException → generic sign-in copy', () => { + mountRejecting('assertion', 'get', 'NetworkError'); + cy.findByText(/verification failed/i).should('exist'); + }); + + // already-registered (InvalidStateError) does not apply to a sign-in ceremony; it falls + // back to the generic verification copy rather than showing enroll-only wording. + it('InvalidStateError → generic sign-in copy (already-registered N/A on sign-in)', () => { + mountRejecting('assertion', 'get', 'InvalidStateError'); + cy.findByText(/verification failed/i).should('exist'); + cy.findByText(/already have a passkey/i).should('not.exist'); + }); +}); diff --git a/cypress/component/components/webauthn-button/webauthn-button.cy.tsx b/cypress/component/components/webauthn-button/webauthn-button.cy.tsx index 38ead4392f..b49b9abb03 100644 --- a/cypress/component/components/webauthn-button/webauthn-button.cy.tsx +++ b/cypress/component/components/webauthn-button/webauthn-button.cy.tsx @@ -6,8 +6,9 @@ import React from 'react'; // // In Cypress, window.Cypress is defined, so the component takes the CYPRESS_CREDENTIAL // shortcut (skips navigator.credentials). By NOT wrapping in a
, formRef.current -// remains null, which triggers setError('webauthn-failed') — the same error path that -// the Vitest test exercised via publicKey=null + mocked isWebAuthnSupported=true. +// remains null, which sets the generic ceremony failure (reason 'unknown') and renders the +// per-mode generic copy — asserting enroll wording is distinct from sign-in wording. +// (Reason-specific DOMException copy is covered by webauthn-button.ceremony-errors.cy.tsx.) function mountBtn(mode: 'assertion' | 'attestation') { const formRef = React.createRef(); cy.mountRemixRoute(, { diff --git a/cypress/component/components/webauthn-button/webauthn-button.on-credential.cy.tsx b/cypress/component/components/webauthn-button/webauthn-button.on-credential.cy.tsx new file mode 100644 index 0000000000..38e4d75f94 --- /dev/null +++ b/cypress/component/components/webauthn-button/webauthn-button.on-credential.cy.tsx @@ -0,0 +1,71 @@ +// WebAuthnButton onCredential: when the prop is set, the finished +// ceremony credential is handed to the parent INSTEAD of auto-submitting the form — +// the two-step enroll flow (ceremony → name step → submit) holds it in route state. +// Same createMemoryRouter recording harness as the other button specs; the Cypress +// pre-baked credential path exercises the same handoff the real ceremony runs. +import { WebAuthnButton } from '@/components/webauthn-button/webauthn-button'; +import { setupI18n } from '@lingui/core'; +import { I18nProvider } from '@lingui/react'; +import { mount } from 'cypress/react'; +import React, { useRef } from 'react'; +import { createMemoryRouter, RouterProvider, Form as RRForm } from 'react-router'; + +interface Recorded { + fields?: Record; + credential?: Record; +} + +function Harness({ recorded }: { recorded: Recorded }) { + const formRef = useRef(null); + return ( + + + { + recorded.credential = credential; + }} + /> + + ); +} + +function mountHarness(recorded: Recorded) { + const i18n = setupI18n({ locale: 'en', messages: { en: {} } }); + const router = createMemoryRouter( + [ + { + path: '/', + element: ( + + + + ), + action: async ({ request }) => { + const form = await request.formData(); + recorded.fields = Object.fromEntries([...form.entries()].map(([k, v]) => [k, String(v)])); + return null; + }, + }, + ], + { initialEntries: ['/'] } + ); + return mount(); +} + +describe('WebAuthnButton onCredential (two-step enroll handoff)', () => { + it('hands the credential to the parent and does NOT submit the form', () => { + const recorded: Recorded = {}; + mountHarness(recorded); + cy.findByRole('button').should('not.be.disabled').click(); + cy.wrap(recorded).should((r) => { + expect(r.credential, 'parent received the credential').to.have.property( + 'id', + 'fake-credential-id' + ); + expect(r.fields, 'form was not auto-submitted').to.equal(undefined); + }); + }); +}); diff --git a/cypress/component/modules/auth/fake-passkeys.cy.ts b/cypress/component/modules/auth/fake-passkeys.cy.ts new file mode 100644 index 0000000000..1796ac0ace --- /dev/null +++ b/cypress/component/modules/auth/fake-passkeys.cy.ts @@ -0,0 +1,43 @@ +// cypress/component/modules/auth/fake-passkeys.cy.ts +// +// NO-MOUNT: FakeAuthProvider passkey-inventory mirror of the port additions +// (listPasskeys / removePasskey). Style of cypress/component/routes/paths.cy.ts. +import { FakeAuthProvider } from '@/modules/auth/providers/fake/fake-provider'; + +const seedUser = { id: 'u1', loginName: 'alice@acme.test' }; + +describe('FakeAuthProvider — passkey inventory (port mirror)', () => { + it('verifyPasskey records a named active passkey; listPasskeys returns it', async () => { + const fake = new FakeAuthProvider({ users: [seedUser] }); + await fake.verifyPasskey('u1', 'pk-1', {}, 'MacBook Touch ID'); + const [passkey] = await fake.listPasskeys('u1'); + const { createdAt, ...rest } = passkey; + expect(rest).to.deep.equal({ id: 'pk-1', state: 'active', name: 'MacBook Touch ID' }); + expect(createdAt).to.be.a('string'); + expect(new Date(createdAt).toISOString()).to.equal(createdAt); + expect(await fake.listAuthMethods('u1')).to.include('passkey'); + }); + + it('defaults the name when verifyPasskey gets no passkeyName (Zitadel parity)', async () => { + const fake = new FakeAuthProvider({ users: [seedUser] }); + await fake.verifyPasskey('u1', 'pk-1', {}); + expect((await fake.listPasskeys('u1'))[0].name).to.equal('Passkey'); + }); + + it('removePasskey is idempotent and un-enrolls the method with the last passkey', async () => { + const fake = new FakeAuthProvider({ users: [seedUser] }); + await fake.verifyPasskey('u1', 'pk-1', {}, 'A'); + await fake.removePasskey('u1', 'pk-1'); + await fake.removePasskey('u1', 'pk-1'); // second call must not throw (removal race) + expect(await fake.listPasskeys('u1')).to.deep.equal([]); + expect(await fake.listAuthMethods('u1')).to.not.include('passkey'); + }); + + it('honors the passkeys seed (e2e fixture path)', async () => { + const fake = new FakeAuthProvider({ + users: [seedUser], + passkeys: { u1: [{ id: 'pk-s', state: 'active', name: 'Seeded key' }] }, + }); + expect(await fake.listPasskeys('u1')).to.have.length(1); + }); +}); diff --git a/cypress/component/modules/auth/providers/parity.cy.ts b/cypress/component/modules/auth/providers/parity.cy.ts index 3c62d59299..4d3bdf8cc7 100644 --- a/cypress/component/modules/auth/providers/parity.cy.ts +++ b/cypress/component/modules/auth/providers/parity.cy.ts @@ -55,6 +55,8 @@ const PORT_METHODS = [ 'passkeyRegisterLink', 'registerPasskey', 'verifyPasskey', + 'listPasskeys', + 'removePasskey', 'registerU2F', 'verifyU2F', 'registerTotp', diff --git a/cypress/component/modules/auth/providers/zitadel/index.cy.ts b/cypress/component/modules/auth/providers/zitadel/index.cy.ts index f9ed75b3e9..9446bd26c9 100644 --- a/cypress/component/modules/auth/providers/zitadel/index.cy.ts +++ b/cypress/component/modules/auth/providers/zitadel/index.cy.ts @@ -13,6 +13,7 @@ import { ZitadelAuthProvider } from '@/modules/auth/providers/zitadel/index'; import * as transport from '@/modules/auth/providers/zitadel/transport'; import { ProviderError } from '@/modules/auth/types'; +import { AuthFactorState } from '@zitadel/proto/zitadel/user/v2/user_pb'; const provider = () => new ZitadelAuthProvider({ serviceUrl: 'https://z.test', serviceToken: 't' }); @@ -243,3 +244,118 @@ describe('ZitadelAuthProvider — session/credential request building', () => { expect(getSpy).to.have.callCount(2); }); }); + +// ── Passkey created-at metadata — best-effort scopes ────────────────────── +// +// verifyPasskey/listPasskeys/removePasskey each touch a user-metadata RPC (set/list/delete) +// in its own ctx.call scope specifically so that RPC can NEVER fail the primary operation. +// These tests drive that metadata RPC into failure and assert the primary operation still +// resolves, plus the listPasskeys join behaviour (present/absent createdAt per row). +describe('ZitadelAuthProvider — passkey metadata best-effort scopes', () => { + it('verifyPasskey resolves even when the setUserMetadata created-at stamp throws', async () => { + const verifySpy = cy.stub().resolves({}); + stubClient({ + verifyPasskeyRegistration: verifySpy, + setUserMetadata: async () => { + throw new Error('metadata backend down'); + }, + }); + // Must not throw — enrollment is not allowed to fail because of the best-effort stamp. + await provider().verifyPasskey('u1', 'pk1', { fake: true }); + expect(verifySpy).to.have.callCount(1); + }); + + it('listPasskeys degrades to date-less rows (all createdAt absent) when listUserMetadata throws', async () => { + stubClient({ + listUserMetadata: async () => { + throw new Error('metadata backend down'); + }, + listPasskeys: async () => ({ + result: [ + { id: 'pk1', state: AuthFactorState.READY, name: 'Laptop' }, + { id: 'pk2', state: AuthFactorState.NOT_READY, name: 'Phone' }, + ], + }), + }); + const rows = await provider().listPasskeys('u1'); + expect(rows).to.have.length(2); + expect(rows[0]).to.deep.equal({ id: 'pk1', state: 'active', name: 'Laptop' }); + expect(rows[1]).to.deep.equal({ id: 'pk2', state: 'inactive', name: 'Phone' }); + for (const row of rows) { + expect(row).to.not.have.property('createdAt'); + } + }); + + it('listPasskeys joins createdAt from a matching passkey::created entry; unmatched rows omit the property', async () => { + const createdAt = '2026-07-01T00:00:00.000Z'; + const seconds = Math.floor(new Date(createdAt).getTime() / 1000); + stubClient({ + listUserMetadata: async () => ({ + metadata: [ + { key: 'passkey:pk1:created', creationDate: { seconds: BigInt(seconds), nanos: 0 } }, + ], + }), + listPasskeys: async () => ({ + result: [ + { id: 'pk1', state: AuthFactorState.READY, name: 'Laptop' }, + { id: 'pk2', state: AuthFactorState.NOT_READY, name: 'Phone' }, + ], + }), + }); + const rows = await provider().listPasskeys('u1'); + const pk1 = rows.find((r) => r.id === 'pk1'); + const pk2 = rows.find((r) => r.id === 'pk2'); + expect(pk1?.createdAt).to.equal(createdAt); + // Absence must be a missing property (omitted via the `...(createdAt ? {...} : {})` spread), + // not just an undefined value. + expect(pk2).to.not.have.property('createdAt'); + }); + + it('removePasskey resolves even when the deleteUserMetadata cleanup throws', async () => { + const removeSpy = cy.stub().resolves({}); + stubClient({ + removePasskey: removeSpy, + deleteUserMetadata: async () => { + throw new Error('metadata backend down'); + }, + }); + // Must not throw — removal succeeded; an orphaned metadata key is harmless. + await provider().removePasskey('u1', 'pk1'); + expect(removeSpy).to.have.callCount(1); + }); +}); + +// ── Cross-device session methods ─────────────────────────────────────────── + +describe('ZitadelAuthProvider — cross-device session methods', () => { + it('listUserSessions searches by userIdQuery and maps sessions with token ""', async () => { + let captured: unknown; + stubClient({ + listSessions: async (req: unknown) => { + captured = req; + return { sessions: [{ id: 's1' }, { id: 's2' }] }; + }, + }); + const rows = await provider().listUserSessions('u1'); + const q = (captured as { queries: Array<{ query: { case: string; value: { id?: string } } }> }) + .queries[0].query; + expect(q.case).to.equal('userIdQuery'); + expect(q.value.id).to.equal('u1'); + expect(rows).to.have.length(2); + expect(rows[0].id).to.equal('s1'); + expect(rows[0].token).to.equal(''); + }); + + it('deleteUserSession sends sessionId WITHOUT a sessionToken field', async () => { + let captured: unknown; + stubClient({ + deleteSession: async (req: Record) => { + captured = req; + return {}; + }, + }); + await provider().deleteUserSession('s2'); + expect((captured as { sessionId: string }).sessionId).to.equal('s2'); + expect(captured).to.not.have.property('sessionToken'); + }); +}); diff --git a/cypress/component/resources/mfa/mfa-routing.cy.ts b/cypress/component/resources/mfa/mfa-routing.cy.ts index feab1d201d..cb6cdf7510 100644 --- a/cypress/component/resources/mfa/mfa-routing.cy.ts +++ b/cypress/component/resources/mfa/mfa-routing.cy.ts @@ -150,4 +150,66 @@ describe('nextMfaStep', () => { { force: 'true', checkAfter: 'true' } ); }); + + // ── enrolled passkey suppresses the step-6 skippable MFA-setup nudge ────────── + // A passkey is passwordless-primary strong auth the user already has, so the *optional* + // "set up MFA" nudge is confusing UX ("I have a passkey, why set up MFA?") and is suppressed. + // This affects ONLY the skippable step-6 nudge — a hard org policy (forced MFA) still applies. + // NOTE: this is a DIFFERENT nudge from the backup-method/lockout banner on the /passkeys + // management route (methodCount === 1). No overlap: separate file, trigger, and intent. + + it('is done (step-6 nudge suppressed) when an enrolled passkey exists, password login, skip window elapsed', () => { + // No fresh passkey factor (password login, userVerified:false), skip window configured and + // never skipped → WITHOUT the passkey rule this routes to /setup/mfa?force=false. + expect( + nextMfaStep( + base({ + enrolledMethods: ['passkey'], + settings: settings({ mfaInitSkipLifetimeMs: 1000 }), + mfaInitSkippedAt: null, + }) + ) + ).to.deep.equal({ kind: 'done' }); + }); + + it('an enrolled passkey does NOT bypass FORCED MFA (step 5 still routes to setup, force=true)', () => { + expectRoute( + nextMfaStep( + base({ + enrolledMethods: ['passkey'], + settings: settings({ forceMfa: true, mfaInitSkipLifetimeMs: 1000 }), + }) + ), + '/setup/mfa', + { force: 'true', checkAfter: 'true' } + ); + }); + + it('WITHOUT a passkey, no 2nd factor, not forced, skip window elapsed → skippable nudge still fires (unchanged)', () => { + expectRoute( + nextMfaStep( + base({ + enrolledMethods: [], + settings: settings({ mfaInitSkipLifetimeMs: 1000 }), + mfaInitSkippedAt: null, + }) + ), + '/setup/mfa', + { force: 'false', checkAfter: 'true' } + ); + }); + + it('a fresh user-verified passwordless passkey is still done via step 1 (unchanged), even when enrolled', () => { + const factors: Factors = { passkey: { verifiedAt: freshDate } }; + expect( + nextMfaStep( + base({ + factors, + userVerified: true, + enrolledMethods: ['passkey'], + settings: settings({ multiFactorCheckLifetimeMs: 1000 }), + }) + ) + ).to.deep.equal({ kind: 'done' }); + }); }); diff --git a/cypress/component/resources/passkeys/passkeys.service.cy.ts b/cypress/component/resources/passkeys/passkeys.service.cy.ts new file mode 100644 index 0000000000..47059e6a11 --- /dev/null +++ b/cypress/component/resources/passkeys/passkeys.service.cy.ts @@ -0,0 +1,231 @@ +// cypress/component/resources/passkeys/passkeys.service.cy.ts +// +// NO-MOUNT: /id/passkeys management service — sudo gate, last-method guard, +// idempotent removal, sign-out-others. Direct-import style with a local fake: +// the service takes an already-read SessionEntry[], so no cookie/env stubs needed. +import { FakeAuthProvider } from '@/modules/auth/providers/fake/fake-provider'; +import type { SessionEntry } from '@/modules/auth/session/session'; +import type { AuthMethod, ProviderErrorCode } from '@/modules/auth/types'; +import { ProviderError } from '@/modules/auth/types'; +import { + loadPasskeysView, + removeUserPasskey, + signOutOtherSessions, +} from '@/resources/passkeys/passkeys.service'; +import { SUDO_TTL_MS } from '@/resources/shared/sudo'; + +const USER = { id: 'u1', loginName: 'mia@acme.test' }; + +async function seeded(opts?: { + authMethods?: AuthMethod[]; + passkeys?: Array<{ id: string; state: 'active' | 'inactive'; name: string }>; + provider?: FakeAuthProvider; +}) { + const fake = + opts?.provider ?? + new FakeAuthProvider({ + users: [USER], + passwords: { u1: 'Password1!' }, + authMethods: { u1: opts?.authMethods ?? ['password', 'passkey'] }, + passkeys: { u1: opts?.passkeys ?? [{ id: 'pk-1', state: 'active', name: 'Seeded laptop' }] }, + // Real factor stamps: the sudo check compares against real Date.now()-based nowMs. + realFactorTimestamps: true, + }); + const s = await fake.createSession({ password: 'Password1!' }, { userId: 'u1' }); + const entry: SessionEntry = { + id: s.id, + token: s.token, + loginName: USER.loginName, + creationTs: s.changedAt, + expirationTs: s.expiresAt, + changeTs: s.changedAt, + }; + return { fake, sessions: [entry] }; +} + +describe('passkeys.service — /id/passkeys management', () => { + it('fresh sudo ⇒ view with rows + methodCount; validated returnTo is threaded', async () => { + const { fake, sessions } = await seeded(); + const v = await loadPasskeysView(fake, sessions, { returnTo: '/passkeys', nowMs: Date.now() }); + expect(v.kind).to.equal('view'); + if (v.kind === 'view') { + expect(v.passkeys).to.deep.equal([{ id: 'pk-1', state: 'active', name: 'Seeded laptop' }]); + expect(v.methodCount).to.equal(2); + expect(v.loginName).to.equal(USER.loginName); + expect(v.returnTo).to.equal('/passkeys'); + } + }); + + it('stale sudo ⇒ loader redirects to /reauth AND removeUserPasskey refuses server-side', async () => { + const { fake, sessions } = await seeded(); + const staleNow = Date.now() + SUDO_TTL_MS + 1; + const v = await loadPasskeysView(fake, sessions, { returnTo: null, nowMs: staleNow }); + expect(v).to.deep.equal({ kind: 'redirect', target: '/reauth?returnTo=%2Fpasskeys' }); + // Server-side enforcement is independent of the loader: + const r = await removeUserPasskey(fake, sessions, { passkeyId: 'pk-1', nowMs: staleNow }); + expect(r).to.deep.equal({ ok: false, error: 'SUDO_REQUIRED' }); + }); + + it('last-method guard: passkey-only user with one passkey ⇒ LAST_METHOD; password backup ⇒ ok', async () => { + const solo = await seeded({ authMethods: ['passkey'] }); + const refused = await removeUserPasskey(solo.fake, solo.sessions, { + passkeyId: 'pk-1', + nowMs: Date.now(), + }); + expect(refused).to.deep.equal({ ok: false, error: 'LAST_METHOD' }); + + const backed = await seeded({ authMethods: ['password', 'passkey'] }); + const removed = await removeUserPasskey(backed.fake, backed.sessions, { + passkeyId: 'pk-1', + nowMs: Date.now(), + }); + expect(removed).to.deep.equal({ ok: true, removedName: 'Seeded laptop' }); + expect(await backed.fake.listPasskeys('u1')).to.deep.equal([]); + }); + + it('removal race: already-gone passkey and a NOT_FOUND adapter error are both idempotent success', async () => { + // Fake path: the id simply is not in the list (silent no-op). + const { fake, sessions } = await seeded({ passkeys: [] }); + const gone = await removeUserPasskey(fake, sessions, { passkeyId: 'pk-x', nowMs: Date.now() }); + expect(gone.ok).to.equal(true); + + // Real-adapter path: RemovePasskey rejects with NOT_FOUND — treated identically. + class NotFoundOnRemove extends FakeAuthProvider { + override async removePasskey(_userId: string, _passkeyId: string): Promise { + throw new ProviderError('NOT_FOUND' as ProviderErrorCode, 'passkey not found'); + } + } + const scripted = new NotFoundOnRemove({ + users: [USER], + passwords: { u1: 'Password1!' }, + authMethods: { u1: ['password', 'passkey'] }, + passkeys: { u1: [{ id: 'pk-1', state: 'active', name: 'Seeded laptop' }] }, + realFactorTimestamps: true, + }); + const withScripted = await seeded({ provider: scripted }); + const raced = await removeUserPasskey(scripted, withScripted.sessions, { + passkeyId: 'pk-1', + nowMs: Date.now(), + }); + expect(raced.ok).to.equal(true); + }); + + it('signOutOtherSessions deletes every non-active entry provider-side and keeps only the active one', async () => { + const { fake, sessions } = await seeded(); + const other = await fake.createSession({ password: 'Password1!' }, { userId: 'u1' }); + const otherEntry: SessionEntry = { + id: other.id, + token: other.token, + loginName: USER.loginName, + creationTs: '2026-01-01T00:00:00.000Z', // older ⇒ sessions[0] stays the active entry + expirationTs: other.expiresAt, + changeTs: '2026-01-01T00:00:00.000Z', + }; + const all = [...sessions, otherEntry]; + + const result = await signOutOtherSessions(fake, all, { nowMs: Date.now() }); + expect(result.ok).to.equal(true); + if (result.ok) { + expect(result.sessions).to.have.length(1); + expect(result.sessions[0].id).to.equal(sessions[0].id); + } + // The other session is gone provider-side; the active one survives. + expect(await fake.getSession(other.id, other.token)).to.equal(null); + expect(await fake.getSession(sessions[0].id, sessions[0].token)).to.not.equal(null); + }); + + it('cross-device sessions (not in the cookie) are deleted; the active one survives', async () => { + const { fake, sessions } = await seeded(); + // A session on "another device": provider-side only, NOT in the cookie list. + const remote = await fake.createSession({ password: 'Password1!' }, { userId: 'u1' }); + + const result = await signOutOtherSessions(fake, sessions, { nowMs: Date.now() }); + expect(result.ok).to.equal(true); + expect(await fake.getSession(remote.id, remote.token)).to.equal(null); + expect(await fake.getSession(sessions[0].id, sessions[0].token)).to.not.equal(null); + }); + + it('stale sudo refuses signout-others server-side', async () => { + const { fake, sessions } = await seeded(); + const result = await signOutOtherSessions(fake, sessions, { + nowMs: Date.now() + SUDO_TTL_MS + 1, + }); + expect(result).to.deep.equal({ ok: false, error: 'SUDO_REQUIRED' }); + }); + + it('no live active session ⇒ SESSION_EXPIRED', async () => { + const { fake } = await seeded(); + const result = await signOutOtherSessions(fake, [], { nowMs: Date.now() }); + expect(result).to.deep.equal({ ok: false, error: 'SESSION_EXPIRED' }); + }); + + it('a failed user-session search degrades to cookie-scoped behavior', async () => { + class SearchFails extends FakeAuthProvider { + override async listUserSessions(_userId: string): Promise { + throw new ProviderError('UNAVAILABLE' as ProviderErrorCode, 'search down'); + } + } + const scripted = new SearchFails({ + users: [USER], + passwords: { u1: 'Password1!' }, + authMethods: { u1: ['password', 'passkey'] }, + passkeys: { u1: [{ id: 'pk-1', state: 'active', name: 'Seeded laptop' }] }, + realFactorTimestamps: true, + }); + const { sessions } = await seeded({ provider: scripted }); + const other = await scripted.createSession({ password: 'Password1!' }, { userId: 'u1' }); + const otherEntry: SessionEntry = { + id: other.id, + token: other.token, + loginName: USER.loginName, + creationTs: '2026-01-01T00:00:00.000Z', + expirationTs: other.expiresAt, + changeTs: '2026-01-01T00:00:00.000Z', + }; + + const result = await signOutOtherSessions(scripted, [...sessions, otherEntry], { + nowMs: Date.now(), + }); + expect(result.ok).to.equal(true); + // The cookie-known other entry is still deleted (token path), search failure notwithstanding. + expect(await scripted.getSession(other.id, other.token)).to.equal(null); + }); + + it('a failing deleteUserSession is swallowed per-session — the sweep completes remaining deletions before returning', async () => { + class DeleteFailsFor extends FakeAuthProvider { + failId: string | null = null; + override async deleteUserSession(sessionId: string): Promise { + if (sessionId === this.failId) { + throw new ProviderError('UNAVAILABLE' as ProviderErrorCode, 'device unreachable'); + } + // Real macrotask delay: completion-before-return then depends on the sweep + // AWAITING every per-session outcome. Without the inner per-session catch, the + // first rejection short-circuits Promise.all and the service returns via the + // OUTER catch before this delayed delete lands — the r2 assertion below fails. + await new Promise((resolve) => setTimeout(resolve, 20)); + await super.deleteUserSession(sessionId); + } + } + const scripted = new DeleteFailsFor({ + users: [USER], + passwords: { u1: 'Password1!' }, + authMethods: { u1: ['password', 'passkey'] }, + passkeys: { u1: [{ id: 'pk-1', state: 'active', name: 'Seeded laptop' }] }, + realFactorTimestamps: true, + }); + const { sessions } = await seeded({ provider: scripted }); + const r1 = await scripted.createSession({ password: 'Password1!' }, { userId: 'u1' }); + const r2 = await scripted.createSession({ password: 'Password1!' }, { userId: 'u1' }); + scripted.failId = r1.id; + + const result = await signOutOtherSessions(scripted, sessions, { nowMs: Date.now() }); + expect(result.ok).to.equal(true); + // Asserted IMMEDIATELY after resolution (microtasks only, no timer yields): r2 must + // ALREADY be gone — with the per-session catch removed the service returns early + // and r2's delayed delete has not landed yet, failing this line. + expect(await scripted.getSession(r2.id, r2.token)).to.equal(null); + // The unreachable session survives (failure swallowed); the active one survives. + expect(await scripted.getSession(r1.id, r1.token)).to.not.equal(null); + expect(await scripted.getSession(sessions[0].id, sessions[0].token)).to.not.equal(null); + }); +}); diff --git a/cypress/component/resources/reauth/reauth.service.cy.ts b/cypress/component/resources/reauth/reauth.service.cy.ts new file mode 100644 index 0000000000..afb3f1cf9d --- /dev/null +++ b/cypress/component/resources/reauth/reauth.service.cy.ts @@ -0,0 +1,74 @@ +// cypress/component/resources/reauth/reauth.service.cy.ts +// +// NO-MOUNT: reauth.service verifies one factor onto the EXISTING session (SetSession +// semantics). Direct-import style with a locally constructed FakeAuthProvider — the +// service takes an already-read SessionEntry[], so no cookie/env stubs are needed. +import { FakeAuthProvider } from '@/modules/auth/providers/fake/fake-provider'; +import type { SessionEntry } from '@/modules/auth/session/cookie'; +import { performReauth, loadReauth } from '@/resources/reauth/reauth.service'; + +const USER = { id: 'u1', loginName: 'alice@acme.test' }; +async function seeded() { + const fake = new FakeAuthProvider({ + users: [USER], + passwords: { u1: 'Password1!' }, + authMethods: { u1: ['password', 'passkey'] }, + }); + const s = await fake.createSession({}, { userId: 'u1' }); + const sessions: SessionEntry[] = [ + { + id: s.id, + token: s.token, + loginName: USER.loginName, + creationTs: s.changedAt, + expirationTs: s.expiresAt, + changeTs: s.changedAt, + }, + ]; + return { fake, sessions }; +} + +describe('reauth.service — verify one factor onto the EXISTING session', () => { + it('loadReauth lists only enrolled methods and threads the validated returnTo', async () => { + const { fake, sessions } = await seeded(); + const v = await loadReauth(fake, sessions, { + returnTo: '/passkeys', + method: null, + domain: 'localhost', + emailDeliveryEnabled: false, + }); + expect(v.kind).to.equal('view'); + if (v.kind === 'view') { + expect(v.methods).to.deep.equal(['passkey', 'password']); // otp_email gated off + expect(v.returnTo).to.equal('/passkeys'); + } + }); + it('performReauth(password) updates the SAME session id, rotates the token, and targets returnTo', async () => { + const { fake, sessions } = await seeded(); + const r = await performReauth(fake, sessions, { + factor: 'password', + password: 'Password1!', + returnTo: '/passkeys', + }); + expect(r.ok).to.equal(true); + if (r.ok) { + expect(r.target).to.equal('/passkeys'); + expect(r.sessions[0].id).to.equal(sessions[0].id); // same session — SetSession semantics + } + }); + it('maps a wrong password to INVALID_CREDENTIALS and a dead cookie to SESSION_EXPIRED', async () => { + const { fake, sessions } = await seeded(); + const bad = await performReauth(fake, sessions, { + factor: 'password', + password: 'nope', + returnTo: null, + }); + expect(bad).to.deep.equal({ ok: false, error: 'INVALID_CREDENTIALS' }); + const dead = await performReauth(fake, [], { + factor: 'password', + password: 'x', + returnTo: null, + }); + expect(dead).to.deep.equal({ ok: false, error: 'SESSION_EXPIRED' }); + }); +}); diff --git a/cypress/component/resources/shared/return-to.cy.ts b/cypress/component/resources/shared/return-to.cy.ts new file mode 100644 index 0000000000..6606469f22 --- /dev/null +++ b/cypress/component/resources/shared/return-to.cy.ts @@ -0,0 +1,26 @@ +// cypress/component/resources/shared/return-to.cy.ts +// +// NO-MOUNT: fail-closed returnTo guard. Allowlist injected so the browser +// bundle never parses server env — same injectability rationale as validatePostLogoutRedirect. +import { validateReturnTo } from '@/resources/shared/return-to'; + +describe('validateReturnTo — fail-closed returnTo guard', () => { + const allow = ['https://portal.staging.env.datum.net']; + it('accepts app-relative paths (served under /id by the basename)', () => { + expect(validateReturnTo('/passkeys', allow)).to.equal('/passkeys'); + expect(validateReturnTo('/setup/passkey?loginName=a%40b.c', allow)).to.equal( + '/setup/passkey?loginName=a%40b.c' + ); + }); + it('rejects scheme-relative, backslash, and non-allowlisted absolute URLs', () => { + expect(validateReturnTo('//evil.test/x', allow)).to.equal(null); + expect(validateReturnTo('/\\evil.test', allow)).to.equal(null); + expect(validateReturnTo('https://evil.test/cb', allow)).to.equal(null); + expect(validateReturnTo(null, allow)).to.equal(null); + }); + it('accepts an allowlisted external origin (portal entry-point round-trip)', () => { + expect(validateReturnTo('https://portal.staging.env.datum.net/settings', allow)).to.equal( + 'https://portal.staging.env.datum.net/settings' + ); + }); +}); diff --git a/cypress/component/resources/shared/sudo.cy.ts b/cypress/component/resources/shared/sudo.cy.ts new file mode 100644 index 0000000000..3ac392d1da --- /dev/null +++ b/cypress/component/resources/shared/sudo.cy.ts @@ -0,0 +1,31 @@ +// cypress/component/resources/shared/sudo.cy.ts +// +// NO-MOUNT: pure-function assertions for the sudo-freshness window. +import { isSudoFresh, SUDO_TTL_MS } from '@/resources/shared/sudo'; + +const at = (iso: string) => ({ verifiedAt: new Date(iso) }); +const NOW = Date.parse('2026-07-17T12:00:00Z'); + +describe('isSudoFresh — 10-minute authentication-factor window', () => { + it('accepts each authentication factor at exactly the TTL boundary', () => { + const edge = at(new Date(NOW - SUDO_TTL_MS).toISOString()); + for (const key of [ + 'password', + 'passkey', + 'u2f', + 'totp', + 'otpEmail', + 'otpSms', + 'idpIntent', + ] as const) { + expect(isSudoFresh({ [key]: edge }, NOW), key).to.equal(true); + } + }); + it('rejects a factor 1ms past the window, unverified factors, and the empty factor set', () => { + expect( + isSudoFresh({ password: at(new Date(NOW - SUDO_TTL_MS - 1).toISOString()) }, NOW) + ).to.equal(false); + expect(isSudoFresh({ password: { verifiedAt: null } }, NOW)).to.equal(false); + expect(isSudoFresh({}, NOW)).to.equal(false); // bare user check ⇒ no Factors entry ⇒ never sudo + }); +}); diff --git a/cypress/component/resources/webauthn/aaguid.cy.ts b/cypress/component/resources/webauthn/aaguid.cy.ts new file mode 100644 index 0000000000..be82a207f1 --- /dev/null +++ b/cypress/component/resources/webauthn/aaguid.cy.ts @@ -0,0 +1,45 @@ +// cypress/component/resources/webauthn/aaguid.cy.ts +// +// NO-MOUNT: AAGUID extraction from a WebAuthn attestation object + default-name +// resolution (vendored catalog → UA fallback). Fixtures are built with the shared +// CBOR writer in cypress/support/attestation-fixture.ts — a minimal-but-valid +// attestation object { fmt, attStmt, authData }. +import { attestationObject, authDataWith } from '../../../support/attestation-fixture'; +import { aaguidFromAttestationObject, defaultPasskeyName } from '@/resources/webauthn/aaguid'; + +const MAC_CHROME_UA = + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36'; + +describe('aaguidFromAttestationObject / defaultPasskeyName — default naming', () => { + it('decodes a known AAGUID and maps it to the vendored catalog name', () => { + // fbfc3007-154e-4ecc-8c0b-6e020557d7bd — Apple's authenticator (named + // 'Apple Passwords' in the vendored catalog snapshot, upstream 9e867bf). + const att = attestationObject(authDataWith(0x45, 'fbfc3007154e4ecc8c0b6e020557d7bd')); + expect(aaguidFromAttestationObject(att)).to.equal('fbfc3007-154e-4ecc-8c0b-6e020557d7bd'); + expect(defaultPasskeyName('fbfc3007-154e-4ecc-8c0b-6e020557d7bd', MAC_CHROME_UA)).to.equal( + 'Apple Passwords' + ); + }); + + it('returns the zero UUID for a zeroed AAGUID and falls back to the UA name', () => { + const att = attestationObject(authDataWith(0x45, '00000000000000000000000000000000')); + expect(aaguidFromAttestationObject(att)).to.equal('00000000-0000-0000-0000-000000000000'); + expect(defaultPasskeyName('00000000-0000-0000-0000-000000000000', MAC_CHROME_UA)).to.equal( + 'Chrome on macOS' + ); + }); + + it('never throws on garbage input — returns null and the UA fallback (AAGUID failure never blocks enrollment)', () => { + expect(aaguidFromAttestationObject('!!!')).to.equal(null); + expect(aaguidFromAttestationObject('')).to.equal(null); + // truncated CBOR: a map header promising entries that never arrive + expect(aaguidFromAttestationObject('owFj')).to.equal(null); + expect(defaultPasskeyName(null, MAC_CHROME_UA)).to.equal('Chrome on macOS'); + expect(defaultPasskeyName(null, '')).to.equal('This device'); + }); + + it('returns null when the AT flag is absent (no attested credential data)', () => { + const att = attestationObject(authDataWith(0x01)); // UP only, no AT, no AAGUID bytes + expect(aaguidFromAttestationObject(att)).to.equal(null); + }); +}); diff --git a/cypress/component/resources/webauthn/webauthn.cy.ts b/cypress/component/resources/webauthn/webauthn.cy.ts index 15f6ab3a0e..129842d23f 100644 --- a/cypress/component/resources/webauthn/webauthn.cy.ts +++ b/cypress/component/resources/webauthn/webauthn.cy.ts @@ -1,15 +1,19 @@ // cypress/component/resources/webauthn/webauthn.cy.ts // // Component (no-mount) port of app/resources/webauthn/__tests__/webauthn.test.ts. -// The base64url codec + marshalAssertion cancel handling are browser-side concerns (atob/btoa, -// navigator.credentials) — they belong in the real browser, which Cypress provides. KEPT: the -// cancel→named-error mapping is the assertion-ceremony UX guard (no opaque TypeError on a null -// credential). +// The base64url codec + ceremony error handling are browser-side concerns (atob/btoa, +// navigator.credentials, DOMException) — they belong in the real browser, which Cypress +// provides. KEPT + EXTENDED: the ceremony now classifies the DOMException that +// navigator.credentials.create/get THROWS on a real failure (no authenticator, cancel, +// already-registered) into a stable reason, instead of only handling the rare null return. import { base64UrlToBuffer, bufferToBase64Url, + classifyWebAuthnError, + createAttestation, marshalAssertion, - WebAuthnCeremonyCancelledError, + WebAuthnCeremonyError, + type WebAuthnReason, } from '@/resources/webauthn/webauthn'; describe('webauthn base64url codec', () => { @@ -24,33 +28,114 @@ describe('webauthn base64url codec', () => { }); }); -const PK = { challenge: 'YQ', allowCredentials: [] }; - -describe('marshalAssertion cancel handling', () => { - it('throws a clear cancellation error when credentials.get resolves null', () => { - // isWebAuthnSupported() needs window.PublicKeyCredential defined (Chromium/Electron provides it; - // define a stand-in only if a headless context lacks it). - const w = window as unknown as { PublicKeyCredential?: unknown }; - if (typeof w.PublicKeyCredential === 'undefined') { - w.PublicKeyCredential = function () {} as unknown; - } - // navigator.credentials.get resolving null is the user-cancel signal the SUT must map to a - // NAMED error (instead of an opaque TypeError on the null deref). - if (!window.navigator.credentials) { - Object.defineProperty(window.navigator, 'credentials', { - value: { get: () => Promise.resolve(null) }, - configurable: true, - }); - } else { - cy.stub(window.navigator.credentials, 'get').resolves(null); - } - - return marshalAssertion(PK).then( +// classifyWebAuthnError is the pure regression guard: the WebAuthn spec surfaces every real +// failure as a DOMException whose `.name` disambiguates the cause. This mapping is the single +// source of truth the ceremony wrappers + the button copy both rely on. +describe('classifyWebAuthnError', () => { + const cases: Array<[string, WebAuthnReason]> = [ + ['NotAllowedError', 'not-allowed'], + ['AbortError', 'not-allowed'], + ['TimeoutError', 'not-allowed'], + ['InvalidStateError', 'already-registered'], + ['NotSupportedError', 'unsupported'], + ['ConstraintError', 'unsupported'], + ['SecurityError', 'security'], + ['NetworkError', 'unknown'], // a DOMException whose name is not mapped + ]; + for (const [name, reason] of cases) { + it(`maps DOMException "${name}" → "${reason}"`, () => { + expect(classifyWebAuthnError(new DOMException('boom', name))).to.equal(reason); + }); + } + + it('maps a plain Error and other non-DOMException values → "unknown"', () => { + expect(classifyWebAuthnError(new Error('nope'))).to.equal('unknown'); + // A plain object carrying a spoofed WebAuthn name is still not a DOMException. + expect(classifyWebAuthnError({ name: 'NotAllowedError' })).to.equal('unknown'); + expect(classifyWebAuthnError('NotAllowedError')).to.equal('unknown'); + expect(classifyWebAuthnError(null)).to.equal('unknown'); + expect(classifyWebAuthnError(undefined)).to.equal('unknown'); + }); +}); + +const PK_GET = { challenge: 'YQ', allowCredentials: [] }; +const PK_CREATE = { challenge: 'YQ', user: { id: 'YQ' }, excludeCredentials: [] }; + +// Ensure isWebAuthnSupported() passes (needs window.PublicKeyCredential) and a +// navigator.credentials object exists to stub. Chromium/Electron provides both; define +// stand-ins only if a headless context lacks them. +function ensureWebAuthnEnv(): void { + const w = window as unknown as { PublicKeyCredential?: unknown }; + if (typeof w.PublicKeyCredential === 'undefined') { + w.PublicKeyCredential = function () {} as unknown; + } + if (!window.navigator.credentials) { + Object.defineProperty(window.navigator, 'credentials', { + value: { create: () => Promise.resolve(null), get: () => Promise.resolve(null) }, + configurable: true, + }); + } +} + +describe('marshalAssertion (sign-in) ceremony error handling', () => { + it('maps a null credential (user-cancel) to WebAuthnCeremonyError reason "not-allowed"', () => { + ensureWebAuthnEnv(); + cy.stub(window.navigator.credentials, 'get').resolves(null); + return marshalAssertion(PK_GET).then( + () => { + throw new Error('expected a WebAuthnCeremonyError'); + }, + (err: unknown) => { + expect(err).to.be.instanceOf(WebAuthnCeremonyError); + expect((err as WebAuthnCeremonyError).reason).to.equal('not-allowed'); + } + ); + }); + + it('classifies a thrown DOMException (NotAllowedError → "not-allowed")', () => { + ensureWebAuthnEnv(); + cy.stub(window.navigator.credentials, 'get').rejects( + new DOMException('no authenticator', 'NotAllowedError') + ); + return marshalAssertion(PK_GET).then( + () => { + throw new Error('expected a WebAuthnCeremonyError'); + }, + (err: unknown) => { + expect(err).to.be.instanceOf(WebAuthnCeremonyError); + expect((err as WebAuthnCeremonyError).reason).to.equal('not-allowed'); + } + ); + }); +}); + +describe('createAttestation (enroll) ceremony error handling', () => { + it('classifies a thrown DOMException (InvalidStateError → "already-registered")', () => { + ensureWebAuthnEnv(); + cy.stub(window.navigator.credentials, 'create').rejects( + new DOMException('excluded credential present', 'InvalidStateError') + ); + return createAttestation(PK_CREATE).then( + () => { + throw new Error('expected a WebAuthnCeremonyError'); + }, + (err: unknown) => { + expect(err).to.be.instanceOf(WebAuthnCeremonyError); + expect((err as WebAuthnCeremonyError).reason).to.equal('already-registered'); + } + ); + }); + + it('maps a null credential (user-cancel) to WebAuthnCeremonyError reason "not-allowed"', () => { + ensureWebAuthnEnv(); + cy.stub(window.navigator.credentials, 'create').resolves(null); + return createAttestation(PK_CREATE).then( () => { - throw new Error('expected a WebAuthnCeremonyCancelledError'); + throw new Error('expected a WebAuthnCeremonyError'); }, (err: unknown) => { - expect(err).to.be.instanceOf(WebAuthnCeremonyCancelledError); + expect(err).to.be.instanceOf(WebAuthnCeremonyError); + expect((err as WebAuthnCeremonyError).reason).to.equal('not-allowed'); } ); }); diff --git a/cypress/component/resources/webauthn/webauthn.service.cy.ts b/cypress/component/resources/webauthn/webauthn.service.cy.ts index 0f5c95abc3..74cf3b91e5 100644 --- a/cypress/component/resources/webauthn/webauthn.service.cy.ts +++ b/cypress/component/resources/webauthn/webauthn.service.cy.ts @@ -184,6 +184,9 @@ describe('verifyPasskeyEnrollment', () => { callService({ fn: 'verifyPasskeyEnrollment', provider: 'singleton', + // The sudo gate reads the ACTIVE session's factors — seed s1 live (the + // singleton stamps REAL factor timestamps, so the 10-min window passes). + liveSessions: [{ id: 's1', token: 't1' }], request: { url: 'http://localhost/id/setup/passkey', sessions: sessionsFor('org-1') }, verifyEnrollInput: { credential: VALID_CRED, @@ -217,6 +220,9 @@ describe('verifyPasskeyEnrollment', () => { fn: 'verifyPasskeyEnrollment', provider: 'singleton', failVerifyPasskey: 'INVALID_CREDENTIALS', + // Seed the live session so the sudo gate passes and the scenario still + // exercises the INVALID_CREDENTIALS mapping. + liveSessions: [{ id: 's1', token: 't1' }], request: { url: 'http://localhost/id/setup/passkey', sessions: sessionsFor() }, verifyEnrollInput: { credential: VALID_CRED, passkeyId: 'pk-1', loginName: ALICE }, }).then((v) => { diff --git a/cypress/component/routes/passkeys-ui.cy.tsx b/cypress/component/routes/passkeys-ui.cy.tsx new file mode 100644 index 0000000000..c9261433af --- /dev/null +++ b/cypress/component/routes/passkeys-ui.cy.tsx @@ -0,0 +1,173 @@ +// cypress/component/routes/passkeys-ui.cy.tsx +// +// UI contract for /id/passkeys: trash-icon remove trigger (confirm dialog kept), +// badge only when inactive, post-delete "sign out other sessions?" dialog +// (reshaped from an inline alert), designed empty state, one-time "Passkey added." notice. +// Mounted with hydrationData (no loader function — render-only, like setup-render.cy.tsx). +import Passkeys from '@/routes/passkeys'; +import { ConformAdapter } from '@datum-cloud/datum-ui/form/adapters/conform'; +import { setupI18n } from '@lingui/core'; +import { I18nProvider } from '@lingui/react'; +import { mount } from 'cypress/react'; +import { createMemoryRouter, RouterProvider } from 'react-router'; + +type Row = { id: string; state: 'active' | 'inactive'; name: string; createdAt?: string }; + +const BASE_LOADER = { + csrfToken: 'tok-1', + view: { + passkeys: [{ id: 'pk1', state: 'active', name: 'Seeded laptop' }] as Row[], + methodCount: 2, + loginName: 'mia@acme.test', + returnTo: null, + }, + added: false, +}; + +function mountPasskeys( + loaderOverrides?: Partial, + actionData?: unknown, + // What a real form submission returns (e.g. { error: 'LAST_METHOD' } for a refusal). + actionResult: unknown = null +) { + const i18n = setupI18n({ locale: 'en', messages: { en: {} } }); + const router = createMemoryRouter( + [ + { + id: 'passkeys', + path: '/passkeys', + element: ( + + + + + + ), + action: async () => actionResult, + // Deviation (not in the reviewer's fix text): without a loader, React Router's + // post-action revalidation leaves useLoaderData() undefined and the route crashes + // (caught by RR's default ErrorBoundary), which made the new "sign-out submit + // closes the dialog" test pass for the wrong reason — the whole page unmounts, + // not our close logic. Returning the same data hydration already seeded keeps + // revalidation truthful (mirrors the real redirect-to-same-route-and-reload). + loader: async () => ({ ...BASE_LOADER, ...loaderOverrides }), + }, + ], + { + initialEntries: ['/passkeys'], + hydrationData: { + loaderData: { passkeys: { ...BASE_LOADER, ...loaderOverrides } }, + ...(actionData !== undefined ? { actionData: { passkeys: actionData } } : {}), + }, + } + ); + return mount(); +} + +describe('/id/passkeys — UI contract', () => { + it('active row: no badge, trash trigger with aria-label opens the danger confirm dialog', () => { + mountPasskeys(); + cy.contains('Seeded laptop').should('be.visible'); + cy.contains('Active').should('not.exist'); + cy.get('button[aria-label="Remove Seeded laptop"]').should('exist').click(); + cy.contains('Remove this passkey?').should('be.visible'); + cy.contains('button', 'Remove passkey').should('be.visible'); + }); + + it('inactive row keeps a muted Inactive badge', () => { + mountPasskeys({ + view: { + ...BASE_LOADER.view, + passkeys: [{ id: 'pk2', state: 'inactive', name: 'Stuck enrollment' }], + }, + }); + cy.contains('Inactive').should('be.visible'); + }); + + it('successful removal opens the sign-out-others dialog; Not now closes it', () => { + mountPasskeys(undefined, { removed: 'Seeded laptop' }); + cy.contains('Passkey removed').should('be.visible'); + cy.contains('button', 'Sign out other sessions').should('be.visible'); + cy.contains('button', 'Not now').click(); + cy.contains('Passkey removed').should('not.exist'); + // The page (list) is still there — no navigation happened. + cy.contains('Seeded laptop').should('be.visible'); + }); + + it('sign-out submit closes the dialog when the action result lands', () => { + mountPasskeys(undefined, { removed: 'Seeded laptop' }); + cy.contains('Passkey removed').should('be.visible'); + cy.contains('button', 'Sign out other sessions').click(); + cy.contains('Passkey removed').should('not.exist'); + }); + + it('remove refusal (LAST_METHOD) closes the confirm dialog so the inline error is visible', () => { + mountPasskeys({ view: { ...BASE_LOADER.view, methodCount: 1 } }, undefined, { + error: 'LAST_METHOD', + }); + cy.get('button[aria-label="Remove Seeded laptop"]').click(); + cy.contains('Remove this passkey?').should('be.visible'); + cy.contains('button', 'Remove passkey').click(); + // The refusal must not hide behind the modal overlay. + cy.contains('Remove this passkey?').should('not.exist'); + cy.contains("You can't remove your only sign-in method").should('be.visible'); + }); + + it('empty list renders the minimal empty state', () => { + mountPasskeys({ view: { ...BASE_LOADER.view, passkeys: [] } }); + cy.contains('No passkeys yet.').should('be.visible'); + cy.get('ul').should('not.exist'); + }); + + it('shows the active login name, a "Use a different account" switch link, and a sign-out action', () => { + mountPasskeys(); + cy.contains('Logged in as').should('be.visible'); + cy.contains('mia@acme.test').should('be.visible'); + cy.findByRole('link', { name: /use a different account/i }).should( + 'have.attr', + 'href', + '/accounts' + ); + cy.contains('button', 'Sign out').should('be.visible'); + }); + + it('row with createdAt renders the muted "Added " second line', () => { + const createdAt = '2026-07-21T10:00:00.000Z'; + // Same Intl path the app uses — deterministic across CI timezones. + const expected = new Intl.DateTimeFormat('en', { dateStyle: 'medium' }).format( + new Date(createdAt) + ); + mountPasskeys({ + view: { + ...BASE_LOADER.view, + passkeys: [{ id: 'pk1', state: 'active', name: 'Seeded laptop', createdAt }], + }, + }); + // Styling contract: the date line must carry the muted/small-text treatment. + cy.contains(`Added ${expected}`) + .should('be.visible') + .and('have.class', 'text-muted-foreground') + .and('have.class', 'text-xs'); + }); + + it('row without createdAt renders no Added line (no created-at metadata)', () => { + mountPasskeys(); + cy.contains('Seeded laptop').should('be.visible'); + cy.contains('Added ').should('not.exist'); + }); + + it('inactive row with createdAt renders both the Inactive badge and the Added line', () => { + const createdAt = '2026-07-15T08:30:00.000Z'; + const expected = new Intl.DateTimeFormat('en', { dateStyle: 'medium' }).format( + new Date(createdAt) + ); + mountPasskeys({ + view: { + ...BASE_LOADER.view, + passkeys: [{ id: 'pk2', state: 'inactive', name: 'Stuck enrollment', createdAt }], + }, + }); + cy.contains('Inactive').should('be.visible'); + cy.contains(`Added ${expected}`).should('be.visible'); + }); +}); diff --git a/cypress/component/routes/setup/setup-passkey-naming.cy.tsx b/cypress/component/routes/setup/setup-passkey-naming.cy.tsx new file mode 100644 index 0000000000..09b1d93245 --- /dev/null +++ b/cypress/component/routes/setup/setup-passkey-naming.cy.tsx @@ -0,0 +1,190 @@ +// cypress/component/routes/setup/setup-passkey-naming.cy.tsx +// +// Name-after-ceremony: /setup/passkey runs the create() ceremony FIRST (only +// ordering where the AAGUID — available only in the returned attestation — can +// pre-fill the name), then shows a Save-only name step, then submits the held +// credential + passkeyName to the enroll action. Uses a createMemoryRouter harness +// whose action RECORDS the posted FormData; the route has no loader here, so +// post-action revalidation is a no-op and hydrated loaderData persists. +import { attestationObject, authDataWith } from '../../../support/attestation-fixture'; +import type { WebAuthnEnrollActionData } from '@/resources/webauthn'; +import { defaultPasskeyName } from '@/resources/webauthn/aaguid'; +import { base64UrlToBuffer } from '@/resources/webauthn/webauthn'; +import SetupPasskey from '@/routes/setup/passkey'; +import { ConformAdapter } from '@datum-cloud/datum-ui/form/adapters/conform'; +import { setupI18n } from '@lingui/core'; +import { I18nProvider } from '@lingui/react'; +import { mount } from 'cypress/react'; +import { createMemoryRouter, RouterProvider } from 'react-router'; + +interface Recorded { + fields?: Record; +} + +// createAttestation() reads challenge/user.id/excludeCredentials — a minimal valid shape. +const PK_CREATE = { challenge: 'YQ', user: { id: 'YQ' }, excludeCredentials: [] }; + +const LOADER_DATA = { + csrfToken: 'tok-1', + loginName: 'a@b.test', + requestId: 'rq1', + organization: 'acme', + force: undefined, + checkAfter: undefined, + credentialId: 'pk1', + publicKey: PK_CREATE, + challengeFailed: false, + returnTo: null, +}; + +function mountRoute( + recorded: Recorded, + actionError?: WebAuthnEnrollActionData['error'], + loaderOverrides?: Partial +) { + const i18n = setupI18n({ locale: 'en', messages: { en: {} } }); + const loaderData = { ...LOADER_DATA, ...loaderOverrides }; + const router = createMemoryRouter( + [ + { + id: 'setup-passkey', + path: '/setup/passkey', + element: ( + + + + + + ), + action: async ({ request }) => { + const form = await request.formData(); + recorded.fields = Object.fromEntries([...form.entries()].map(([k, v]) => [k, String(v)])); + return actionError ? { error: actionError } : null; + }, + // Deviation from the brief: React Router's mergeLoaderData only carries hydrated + // loaderData forward across a post-action revalidation when `match.route.loader` is + // truthy (see mergeLoaderData in react-router's router.ts) — a loader-less route's + // entry is dropped, not preserved, crashing useLoaderData's destructure on the + // subsequent render. The brief's spec comment ("no-op and hydrated loaderData + // persists") does not hold for react-router@7.18. A trivial loader mirrors the real + // route (which always has one) and keeps loaderData stable across the action. + loader: async () => loaderData, + }, + ], + { + initialEntries: ['/setup/passkey'], + hydrationData: { loaderData: { 'setup-passkey': loaderData } }, + } + ); + return mount(); +} + +describe('setup/passkey — name-after-ceremony', () => { + // Deviation from the brief: Cypress component testing does not reload the AUT window + // between tests in the same spec (unlike e2e's testIsolation page reset), so the + // real-ceremony opt-in flag set by the AAGUID test below would otherwise leak into + // later tests and make them attempt the real navigator.credentials.create path. + afterEach(() => { + cy.window().then((win) => { + delete (win as unknown as { __webAuthnRealCeremony?: boolean }).__webAuthnRealCeremony; + }); + }); + + it('runs the ceremony first, then shows the Save-only name step pre-filled from the device', () => { + const recorded: Recorded = {}; + mountRoute(recorded); + + // Step 1: no name input — the AAGUID needed for the pre-fill exists only after the ceremony. + cy.contains('button', 'Register passkey').should('not.be.disabled'); + cy.get('input[name="passkeyName"]').should('not.exist'); + + cy.contains('button', 'Register passkey').click(); + + // Step 2: pre-filled (pre-baked credential has no attestationObject → UA fallback), + // scoping helptext + no-rename note, Save-only (Register gone, no cancel). + cy.contains(/Name your passkey/i).should('exist'); + cy.window().then((win) => { + const expected = defaultPasskeyName(null, win.navigator.userAgent); + cy.get('input[name="passkeyName"]').should('have.value', expected); + }); + cy.contains('your password manager labels it separately').should('be.visible'); + cy.contains("Names can't be changed later").should('be.visible'); + cy.contains('button', 'Register passkey').should('not.exist'); + cy.contains('button', /cancel/i).should('not.exist'); + }); + + it('submits the held credential with the edited name (edited-name-reaches-verify)', () => { + const recorded: Recorded = {}; + mountRoute(recorded, undefined, { returnTo: '/passkeys' }); + cy.contains('button', 'Register passkey').should('not.be.disabled').click(); + cy.get('input[name="passkeyName"]').clear(); + cy.get('input[name="passkeyName"]').type('My yubikey'); + cy.contains('button', 'Save').click(); + cy.wrap(recorded).should((r) => { + expect(r.fields, 'action received the form').to.not.equal(undefined); + expect(r.fields!.passkeyName).to.equal('My yubikey'); + expect(JSON.parse(r.fields!.credential)).to.have.property('id', 'fake-credential-id'); + expect(r.fields!.passkeyId).to.equal('pk1'); + expect(r.fields!.returnTo).to.equal('/passkeys'); + }); + }); + + it('pre-fills the catalog name from the attestation AAGUID and shows the device hint', () => { + const recorded: Recorded = {}; + mountRoute(recorded); + + // Opt INTO the real ceremony (documented __webAuthnRealCeremony seam) and resolve + // create() with a crafted Apple Passwords attestation (AAGUID fbfc3007-…). + cy.window().then((win) => { + (win as unknown as { __webAuthnRealCeremony?: boolean }).__webAuthnRealCeremony = true; + const w = win as unknown as { PublicKeyCredential?: unknown }; + if (typeof w.PublicKeyCredential === 'undefined') { + w.PublicKeyCredential = function () {} as unknown; + } + if (!win.navigator.credentials) { + Object.defineProperty(win.navigator, 'credentials', { + value: { create: () => Promise.resolve(null), get: () => Promise.resolve(null) }, + configurable: true, + }); + } + const attB64 = attestationObject(authDataWith(0x45, 'fbfc3007154e4ecc8c0b6e020557d7bd')); + cy.stub(win.navigator.credentials, 'create').resolves({ + id: 'real-cred', + rawId: base64UrlToBuffer('cmVhbC1jcmVk'), + type: 'public-key', + response: { + attestationObject: base64UrlToBuffer(attB64), + clientDataJSON: base64UrlToBuffer('e30'), + }, + }); + }); + + cy.contains('button', 'Register passkey').should('not.be.disabled').click(); + + // Catalog name wins the pre-fill; the UA-derived hint differs → rendered. + cy.get('input[name="passkeyName"]').should('have.value', 'Apple Passwords'); + cy.contains(/Created using/).should('be.visible'); + }); + + it('auto-resets to step 1 when the verify action fails (challenge expiry)', () => { + const recorded: Recorded = {}; + mountRoute(recorded, 'INVALID_CREDENTIALS'); + + cy.contains('button', 'Register passkey').should('not.be.disabled').click(); + cy.get('input[name="passkeyName"]').should('exist'); + cy.contains('button', 'Save').click(); + + // Back on step 1 with the inline error — one click retries the full ceremony. + cy.get('[role="alert"]').should('exist'); + cy.get('input[name="passkeyName"]').should('not.exist'); + cy.contains('button', 'Register passkey').should('not.be.disabled'); + + // The controlled hidden input must drop the stale credential with the reset. + cy.get('input[name="credential"]').should('have.value', ''); + + // Re-entering the name step must not carry the stale failure banner. + cy.contains('button', 'Register passkey').click(); + cy.contains(/Name your passkey/i).should('exist'); + cy.get('[role="alert"]').should('not.exist'); + }); +}); diff --git a/cypress/e2e/passkey-use.cy.ts b/cypress/e2e/passkey-use.cy.ts index 9b8b652d56..ad975de15b 100644 --- a/cypress/e2e/passkey-use.cy.ts +++ b/cypress/e2e/passkey-use.cy.ts @@ -18,7 +18,7 @@ describe('Passkey verify (/login/passkey)', () => { // The WebAuthnButton detects window.Cypress and uses the pre-baked credential. // Hydration gate: the button is disabled until React mounts; Cypress waits for it. - cy.contains('button', /verify with passkey/i) + cy.contains('button', /sign in with .*passkey|touch id|windows hello/i) .should('not.be.disabled') .click(); diff --git a/cypress/e2e/passkeys-manage.cy.ts b/cypress/e2e/passkeys-manage.cy.ts new file mode 100644 index 0000000000..a813202709 --- /dev/null +++ b/cypress/e2e/passkeys-manage.cy.ts @@ -0,0 +1,174 @@ +import { checkA11y } from '../support/a11y'; +import { extractCsrf, loginAndGetSession } from '../support/session'; + +// /id/passkeys — management journey (fake provider, real factor timestamps ⇒ sudo fresh). +// Order matters: the fake singleton persists across tests in one run — (b) removes mia's +// seeded row, (d) adds a fresh one. + +// mia has TWO primary methods (password + passkey), so the identifier step routes to the +// /login/method chooser — loginAndGetSession leaves the session at the bare user check +// (never sudo-fresh). Complete the password factor via cy.request (deterministic; the +// password UI journey is core-signin.cy.ts's subject) so verifiedAt is stamped. +function signInMiaWithPassword() { + loginAndGetSession('mia@acme.test'); + cy.request('/id/login/password?loginName=mia%40acme.test').then((resp) => { + const csrf = extractCsrf(resp.body as string); + cy.request({ + method: 'POST', + url: '/id/login/password.data?loginName=mia%40acme.test', + form: true, + body: { csrf, loginName: 'mia@acme.test', password: 'hunter2' }, + followRedirect: false, + }); + }); +} + +describe('/id/passkeys — list / remove / last-method guard / sign-out offer / add', () => { + // Warm Vite's dep optimization once (mirrors core-signin.cy.ts): the first cold route + // load triggers a hard reload that eats the first click/submit of a test. Also warm the + // WebAuthn ceremony chunk with the existing u5 fixture (pre-existing cold-start flake — + // passkey-use.cy.ts fails standalone on main the same way) so ceremony clicks land. + before(() => { + cy.visit('/id/login'); + cy.contains('button', 'Email'); + loginAndGetSession('passkey-user@acme.test'); + cy.visit('/id/login/passkey?loginName=passkey-user%40acme.test', { + onBeforeLoad: (win) => { + win.__CYPRESS_HYDRATE__ = true; + }, + }); + cy.settleHydration(); + cy.contains('button', /sign in with .*passkey|touch id|windows hello/i).should( + 'not.be.disabled' + ); + cy.clearCookies(); + }); + + it('(a) lists the seeded passkey without a state badge (sudo fresh after password login)', () => { + signInMiaWithPassword(); + cy.visit('/id/passkeys', { + onBeforeLoad: (win) => { + win.__CYPRESS_HYDRATE__ = true; // dialogs need JS (see entry.client.tsx) + }, + }); + cy.settleHydration(); + cy.location('pathname').should('eq', '/id/passkeys'); + cy.contains('Seeded laptop').should('be.visible'); + // Active rows carry no state badge (no disable feature exists). + cy.contains('Active').should('not.exist'); + // The seeded row predates created-at metadata — no Added line (no backfill). + cy.contains('Added ').should('not.exist'); + checkA11y(); + }); + + it('(b) remove flow: confirm dialog → row gone → sign-out-others offer → decline keeps the session', () => { + signInMiaWithPassword(); + cy.visit('/id/passkeys', { + onBeforeLoad: (win) => { + win.__CYPRESS_HYDRATE__ = true; + }, + }); + cy.settleHydration(); + + cy.get('button[aria-label="Remove Seeded laptop"]').should('not.be.disabled').click(); + cy.contains('Remove this passkey?').should('be.visible'); + cy.contains('button', 'Remove passkey').click(); + + // Row gone + the sign-out dialog opens (modal instead of inline alert). + cy.contains('Seeded laptop').should('not.exist'); + cy.contains('Passkey removed').should('be.visible'); + cy.contains('button', 'Sign out other sessions').should('be.visible'); + + // Decline path: dialog closes in place; cookie/session untouched (no login bounce). + cy.contains('button', 'Not now').click(); + cy.contains('Passkey removed').should('not.exist'); + cy.location('pathname').should('eq', '/id/passkeys'); + cy.contains('Passkeys').should('be.visible'); + }); + + it('(c) passkey-only user: remove is refused (last method)', () => { + // solo has no password — a REAL authentication factor must land on the session + // (bare user-check never sudo-qualifies). Complete the passkey assertion via + // cy.request (the loginAndGetSession pattern): the ceremony UI itself is covered by + // passkey-use.cy.ts; this test's subject is the last-method guard. + loginAndGetSession('solo@acme.test'); + cy.request('/id/login/passkey?loginName=solo%40acme.test').then((resp) => { + const csrf = extractCsrf(resp.body as string); + cy.request({ + method: 'POST', + url: '/id/login/passkey.data?loginName=solo%40acme.test', + form: true, + body: { + csrf, + loginName: 'solo@acme.test', + // The fake provider accepts any webAuthN assertion payload. + credential: JSON.stringify({ id: 'fake-credential-id', type: 'public-key' }), + }, + followRedirect: false, + }); + }); + + cy.visit('/id/passkeys', { + onBeforeLoad: (win) => { + win.__CYPRESS_HYDRATE__ = true; + }, + }); + cy.settleHydration(); + cy.location('pathname').should('eq', '/id/passkeys'); + // Last-method guard: refusal surfaces the inline error, the row stays. + cy.get('button[aria-label="Remove Solo key"]').should('not.be.disabled').click(); + cy.contains('button', 'Remove passkey').click(); + cy.contains("You can't remove your only sign-in method").should('be.visible'); + cy.contains('Solo key').should('be.visible'); + }); + + it('(d) add entry point: /setup/passkey round-trip returns to /id/passkeys with the new row', () => { + signInMiaWithPassword(); + cy.visit('/id/passkeys', { + onBeforeLoad: (win) => { + win.__CYPRESS_HYDRATE__ = true; + }, + }); + cy.settleHydration(); + + cy.contains('a', 'Add passkey').click(); + cy.location('pathname').should('eq', '/id/setup/passkey'); + // The Add link's return target points back at /passkeys. + cy.location('search').should('contain', 'returnTo=%2Fpasskeys'); + + // Complete the ceremony via cy.request (deterministic; the ceremony-click UI is + // setup-passkey-mfa.cy.ts's subject). The action must prefer the posted returnTo + // over the derived next step — asserted via the turbo-stream redirect below. + const setupUrl = '/id/setup/passkey?loginName=mia%40acme.test&returnTo=%2Fpasskeys'; + cy.request(setupUrl).then((resp) => { + const html = resp.body as string; + const csrf = extractCsrf(html); + const passkeyId = /name="passkeyId" value="([^"]+)"/.exec(html)?.[1] ?? ''; + cy.request({ + method: 'POST', + url: '/id/setup/passkey.data?loginName=mia%40acme.test&returnTo=%2Fpasskeys', + form: true, + body: { + csrf, + loginName: 'mia@acme.test', + passkeyId, + returnTo: '/passkeys', + credential: JSON.stringify({ id: 'fake-credential-id', type: 'public-key' }), + }, + followRedirect: false, + }).then((post) => { + // returnTo wins over the derived next step. + expect(String(post.body ?? '')).to.contain('"redirect","/passkeys"'); + }); + }); + + // The round-trip lands back on /id/passkeys with the fresh row. + cy.visit('/id/passkeys'); + cy.location('pathname').should('eq', '/id/passkeys'); + // (b) removed the seeded row, so the fresh enrollment is the only row. + cy.get('ul li').should('have.length', 1); + // The fake mirror stamps createdAt at verify — the fresh row shows today. + const expectedDate = new Intl.DateTimeFormat('en', { dateStyle: 'medium' }).format(new Date()); + cy.contains(`Added ${expectedDate}`).should('be.visible'); + }); +}); diff --git a/cypress/e2e/reauth.cy.ts b/cypress/e2e/reauth.cy.ts new file mode 100644 index 0000000000..d7840d0791 --- /dev/null +++ b/cypress/e2e/reauth.cy.ts @@ -0,0 +1,64 @@ +import { checkA11y } from '../support/a11y'; +import { loginAndGetSession } from '../support/session'; + +// /id/reauth — sudo interstitial journey (fake provider). +// Sudo staleness is NOT required here: this spec drives /reauth directly. +describe('/id/reauth — verify one enrolled factor onto the existing session', () => { + it('chooser lists enrolled methods only; password completion lands on returnTo', () => { + // alice has authMethods: ['password'] only → chooser shows Password, never Passkey. + loginAndGetSession('alice@acme.test'); + + cy.visit('/id/reauth?returnTo=/passkeys', { + onBeforeLoad: (win) => { + win.__CYPRESS_HYDRATE__ = true; // form submit needs JS (see entry.client.tsx) + }, + }); + cy.settleHydration(); + checkA11y(); // chooser renders + + cy.contains("Confirm it's you"); + cy.contains('a', 'Password').should('be.visible'); + cy.contains('a', 'Passkey').should('not.exist'); + + cy.contains('a', 'Password').click(); + cy.location('search').should('contain', 'method=password'); + checkA11y(); // password verify form renders + + cy.get('input[name="password"]').type('hunter2'); + cy.get('input[name="password"]:visible').closest('form').submit(); + + // Success → redirect to the validated returnTo (served under the /id basename). + cy.location('pathname').should('eq', '/id/passkeys'); + }); + + it('lists Passkey for a passkey-enrolled user', () => { + loginAndGetSession('passkey-user@acme.test'); + cy.visit('/id/reauth?returnTo=/passkeys'); + cy.contains('a', 'Passkey').should('be.visible'); + cy.contains('a', 'Password').should('be.visible'); + }); + + it('falls back to /id/passkeys when returnTo is tampered (//evil.test)', () => { + loginAndGetSession('alice@acme.test'); + + cy.visit('/id/reauth?returnTo=%2F%2Fevil.test%2Fx&method=password', { + onBeforeLoad: (win) => { + win.__CYPRESS_HYDRATE__ = true; + }, + }); + cy.settleHydration(); + + cy.get('input[name="password"]').type('hunter2'); + cy.get('input[name="password"]:visible').closest('form').submit(); + + // The tampered returnTo is rejected server-side → default target, never evil.test. + cy.location('pathname').should('eq', '/id/passkeys'); + cy.location('host').should('eq', 'localhost:3000'); + }); + + it('bounces to /login without a session', () => { + cy.clearCookies(); + cy.visit('/id/reauth', { failOnStatusCode: false }); + cy.location('pathname').should('eq', '/id/login'); + }); +}); diff --git a/cypress/e2e/setup-passkey-mfa.cy.ts b/cypress/e2e/setup-passkey-mfa.cy.ts index c928f8c4b5..11018d60f3 100644 --- a/cypress/e2e/setup-passkey-mfa.cy.ts +++ b/cypress/e2e/setup-passkey-mfa.cy.ts @@ -1,58 +1,8 @@ import { checkA11y } from '../support/a11y'; - -// Seeded password for all fake-provider test users (see app/providers/select.server.ts passwords map). -const FAKE_PASSWORD = 'hunter2'; - -/** - * Extract the csrf hidden-input token from SSR HTML. React entity-escapes - * attribute values (& → & etc.), so decode before round-tripping the token — - * otherwise tokens containing escapable chars 403 intermittently. - */ -function extractCsrf(html: string): string { - const raw = /name="csrf" value="([^"]+)"/.exec(html)?.[1] ?? ''; - return raw - .replace(/&/g, '&') - .replace(/</g, '<') - .replace(/>/g, '>') - .replace(/"/g, '"') - .replace(/'|'/g, "'"); -} - -/** - * Establishes a session cookie for the given loginName via cy.request (no UI). - * Drives identifier → password steps so byLoginName finds a valid entry. - * cy.request shares the browser cookie jar; set-cookie headers from the actions - * apply to the subsequent cy.visit. - */ -function loginAndGetSession(loginName: string) { - cy.request('/id/login').then((resp) => { - const csrf = extractCsrf(resp.body as string); - cy.request({ - method: 'POST', - url: '/id/login', - form: true, - body: { csrf, loginName }, - followRedirect: false, - }).then((post) => { - const target = String(post.headers.location ?? ''); - if (target.includes('/login/password')) { - const pwPageUrl = target.startsWith('http') - ? target - : `/id${target.startsWith('/id') ? target.slice(3) : target}`; - cy.request(pwPageUrl).then((pwPage) => { - const pwCsrf = extractCsrf(pwPage.body as string) || csrf; - cy.request({ - method: 'POST', - url: target, - form: true, - body: { csrf: pwCsrf, loginName, password: FAKE_PASSWORD }, - followRedirect: false, - }); - }); - } - }); - }); -} +// Shared session-planting helper (posts the single-fetch `.data` endpoints). The old +// file-local copy document-POSTed /id/login, which now 405s (no action on the layout +// route) — every session it "planted" was silently empty. +import { extractCsrf, loginAndGetSession } from '../support/session'; // ─── Passkey enrollment (/setup/passkey) ───────────────────────────────────── @@ -77,9 +27,62 @@ describe('Passkey enrollment (/setup/passkey)', () => { .should('not.be.disabled') .click(); + // Name step: the ceremony ran first; the name arrives pre-filled (UA fallback under + // the pre-baked credential) with the scoping helptext, and Save submits credential+name. + cy.contains(/Name your passkey/i).should('be.visible'); + cy.get('input[name="passkeyName"]').invoke('val').should('not.be.empty'); + cy.contains('your password manager labels it separately').should('be.visible'); + cy.contains("Names can't be changed later").should('be.visible'); + checkA11y(); + cy.contains('button', /^save$/i).click(); + // checkAfter=true → redirect into the matching passkey verify screen after enrollment. cy.location('pathname').should('eq', '/id/login/passkey'); }); + + it('threads passkeyName through enrollment and shows it on /id/passkeys', () => { + // Dedicated namer@acme.test fixture — enrollments here never contaminate the + // ordering-sensitive nofactor/mfa-skip users. The name field renders on the setup + // screen; the SUBMIT is driven via cy.request (the ceremony-click UI path and the + // client-side name pre-fill are covered by the route component spec — + // setup-passkey-naming.cy.tsx — because WebAuthn ceremony clicks are + // unreliable under the local dev-mode hydration recovery). + loginAndGetSession('namer@acme.test'); + + // The name field is on the page with its set-once helper copy. + cy.visit('/id/setup/passkey?loginName=namer%40acme.test&returnTo=%2Fpasskeys'); + // Step 1 shows no name field — naming happens AFTER the ceremony. The + // two-step UI (pre-fill, helptext, Save) is covered by the hydrated test above + // and by cypress/component/routes/setup/setup-passkey-naming.cy.tsx. + cy.get('input[name="passkeyName"]').should('not.exist'); + + // Enrollment with a typed name → the exact name lands on the inventory row. + const setupUrl = '/id/setup/passkey?loginName=namer%40acme.test&returnTo=%2Fpasskeys'; + cy.request(setupUrl).then((resp) => { + const html = resp.body as string; + const csrf = extractCsrf(html); + const passkeyId = /name="passkeyId" value="([^"]+)"/.exec(html)?.[1] ?? ''; + cy.request({ + method: 'POST', + url: '/id/setup/passkey.data?loginName=namer%40acme.test&returnTo=%2Fpasskeys', + form: true, + body: { + csrf, + loginName: 'namer@acme.test', + passkeyId, + returnTo: '/passkeys', + passkeyName: 'My yubikey', + credential: JSON.stringify({ id: 'fake-credential-id', type: 'public-key' }), + }, + followRedirect: false, + }).then((post) => { + expect(String(post.body ?? '')).to.contain('"redirect","/passkeys"'); + }); + }); + + cy.visit('/id/passkeys'); + cy.contains('ul li', 'My yubikey').should('be.visible'); + }); }); // ─── Security-key enrollment (/setup/security-key) ─────────────────────────── diff --git a/cypress/support/attestation-fixture.ts b/cypress/support/attestation-fixture.ts new file mode 100644 index 0000000000..b50f0a2aba --- /dev/null +++ b/cypress/support/attestation-fixture.ts @@ -0,0 +1,43 @@ +// cypress/support/attestation-fixture.ts +// +// Tiny CBOR writer producing a minimal-but-valid WebAuthn attestation object +// { fmt: 'none', attStmt: {}, authData } as base64url. Shared by the aaguid unit +// spec and the setup/passkey naming route spec (AAGUID pre-fill). + +function cborText(s: string): number[] { + const bytes = [...new TextEncoder().encode(s)]; + return [0x60 + bytes.length, ...bytes]; // major 3, len < 24 +} +function cborBytes(b: number[]): number[] { + if (b.length < 24) return [0x40 + b.length, ...b]; // major 2, len < 24 + if (b.length < 256) return [0x58, b.length, ...b]; // major 2, 1-byte length + return [0x59, b.length >> 8, b.length & 0xff, ...b]; // major 2, 2-byte length +} + +/** Map { fmt: 'none', attStmt: {}, authData: } — key order as browsers emit. */ +export function attestationObject(authData: number[]): string { + const map = [ + 0xa3, // map(3) + ...cborText('fmt'), + ...cborText('none'), + ...cborText('attStmt'), + 0xa0, // map(0) + ...cborText('authData'), + ...cborBytes(authData), + ]; + // base64url encode + const bin = String.fromCharCode(...map); + return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} + +/** authData: 32-byte rpIdHash + flags + 4-byte signCount [+ 16-byte AAGUID + credIdLen]. */ +export function authDataWith(flags: number, aaguidHex?: string): number[] { + const rpIdHash = Array.from({ length: 32 }, () => 0x11); + const signCount = [0, 0, 0, 1]; + const out = [...rpIdHash, flags, ...signCount]; + if (aaguidHex !== undefined) { + const aaguid = aaguidHex.match(/.{2}/g)!.map((h) => parseInt(h, 16)); + out.push(...aaguid, 0x00, 0x00); // AAGUID + 2-byte credIdLen (0) + } + return out; +} diff --git a/cypress/support/audit-coverage.ts b/cypress/support/audit-coverage.ts index 935a1c21b4..3c88706a03 100644 --- a/cypress/support/audit-coverage.ts +++ b/cypress/support/audit-coverage.ts @@ -144,6 +144,12 @@ const SHARED_FACTORY_PATHS: Record = { // provider→service→redirect/data translators. Registered here so the delegation + registry // checks resolve those events at their new call site in resources/login/login.service.ts. 'login.service.ts': join(RESOURCES_DIR, 'login/login.service.ts'), + // The reauth route (reauth.tsx) delegates its action logic (incl. the reauth / + // reauth_challenge logAuthEvent calls) to the reauth domain service. + 'reauth.service.ts': join(RESOURCES_DIR, 'reauth/reauth.service.ts'), + // The passkeys route (passkeys.tsx) delegates its action logic (incl. the + // passkey_remove / logout logAuthEvent calls) to the passkeys domain service. + 'passkeys.service.ts': join(RESOURCES_DIR, 'passkeys/passkeys.service.ts'), }; // --------------------------------------------------------------------------- @@ -289,6 +295,10 @@ const DELEGATED_TO_SHARED: Record = { // login/password.tsx) lives in resources/login/login.service.ts. 'login/index.tsx': ['login.service.ts'], 'login/password.tsx': ['login.service.ts'], + // The reauth + passkeys routes are thin — their action logic (and the reauth / + // passkey_remove logAuthEvent calls) lives in the reauth / passkeys domain services. + 'reauth.tsx': ['reauth.service.ts'], + 'passkeys.tsx': ['passkeys.service.ts'], }; /** @@ -363,6 +373,13 @@ export const REQUIRED_EVENTS = [ 'device_authorize', // --- Session --- 'logout', + // --- Sudo re-auth + passkey management (snake_case per the P5+ convention) --- + // reauth: one enrolled factor re-verified onto the EXISTING session (/id/reauth action). + // reauth_challenge: assertion/OTP challenge request failure on the reauth loader path. + // passkey_remove: sudo-gated passkey removal (success / sudo_required / last_method). + 'reauth', + 'reauth_challenge', + 'passkey_remove', // --- Password --- 'password.change', 'password.reset.completed', From 7e7b02072ad683b56b38f9e46f8c931605d968f9 Mon Sep 17 00:00:00 2001 From: Yahya Fakhroji Date: Mon, 27 Jul 2026 19:31:33 +0700 Subject: [PATCH 2/9] feat(auth): consolidate login navigation, identity UI, and passkey sign-in ceremony MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces the shared IdentityBadge ("Logged in/Signing up as X — Not you?") used consistently across login, signup, reauth, passkeys, and accounts; consolidates Back-link and sign-out affordances that had drifted per-route; and adds the in-place passkey sign-in ceremony (challenge → assertion → submit without navigating away) reused by /login and /login/method. --- CONTRIBUTING.md | 76 ++++ acceptance/PASSKEY-TESTING.md | 84 +++++ .../auth-ceremony/auth-ceremony.tsx | 4 +- app/components/back-link/previous-step.ts | 11 +- app/hooks/use-passkey-login-ceremony.ts | 139 +++++++ app/modules/i18n/locales/en.po | 183 +++++----- app/routes.ts | 3 + app/routes/device/authorize.tsx | 21 +- app/routes/login/index.tsx | 338 ++++++++++++------ app/routes/login/method.tsx | 40 ++- app/routes/login/passkey.tsx | 29 +- app/routes/logout/index.tsx | 27 +- app/routes/password/change.tsx | 9 +- app/routes/signed-in.tsx | 27 +- app/routes/signup/method.tsx | 16 +- app/routes/signup/password.tsx | 18 +- app/routes/sso/index.tsx | 27 +- app/routes/sso/ldap.tsx | 5 +- bun.lock | 9 +- .../auth-ceremony/auth-ceremony.cy.tsx | 30 ++ .../components/back-link/previous-step.cy.ts | 16 +- .../routes/device/authorize-identity.cy.tsx | 55 +++ cypress/component/routes/login/index.cy.tsx | 207 +++++++++++ cypress/component/routes/login/method.cy.tsx | 108 ++++++ .../routes/login/passkey-back-link.cy.tsx | 49 +++ cypress/component/routes/logout/logout.cy.tsx | 26 ++ .../routes/password/change-identity.cy.tsx | 64 ++++ cypress/component/routes/paths.cy.ts | 11 + cypress/component/routes/signed-in.cy.tsx | 57 +++ .../routes/signup/method-back-link.cy.tsx | 70 ++++ .../routes/sso/ldap-back-link.cy.tsx | 47 +++ .../component/routes/sso/sso-render.cy.tsx | 12 + cypress/e2e/a11y-sweep.cy.ts | 2 +- cypress/e2e/passkeys-manage.cy.ts | 1 + cypress/e2e/verify-otp.cy.ts | 49 ++- package.json | 1 + 36 files changed, 1589 insertions(+), 282 deletions(-) create mode 100644 CONTRIBUTING.md create mode 100644 acceptance/PASSKEY-TESTING.md create mode 100644 app/hooks/use-passkey-login-ceremony.ts create mode 100644 cypress/component/routes/device/authorize-identity.cy.tsx create mode 100644 cypress/component/routes/login/index.cy.tsx create mode 100644 cypress/component/routes/login/method.cy.tsx create mode 100644 cypress/component/routes/login/passkey-back-link.cy.tsx create mode 100644 cypress/component/routes/password/change-identity.cy.tsx create mode 100644 cypress/component/routes/signed-in.cy.tsx create mode 100644 cypress/component/routes/signup/method-back-link.cy.tsx create mode 100644 cypress/component/routes/sso/ldap-back-link.cy.tsx diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000000..181d7165b9 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,76 @@ +# Contributing to auth-ui + +Thank you for contributing. This document covers the essentials you need before +opening a pull request. + +## Prerequisites + +- [Bun](https://bun.sh/) >= 1.3.14 +- Node 20+ (managed via `.nvmrc`) +- A running instance of the dev environment — see [Getting Started](docs/getting-started/) + +## Local development + +```bash +bun install # install dependencies and wire up lefthook pre-commit hooks +bun run dev # start the local dev server +``` + +## Before you push + +Run the full local gate: + +```bash +bun run lint # ESLint (auto-fix) +bun run typecheck # TypeScript + route type generation +bun run lint:boundaries # architecture fitness — dependency-cruiser +bun run lint:cycles # circular import check — madge +bun run size # bundle budget (requires bun run build first) +``` + +CI runs the same checks. A PR that fails any of these will not be merged. + +## Architecture must respect module boundaries + +This project enforces a strict module layering contract via +[dependency-cruiser](https://github.com/sverweij/dependency-cruiser). The rules +live in [`.dependency-cruiser.cjs`](.dependency-cruiser.cjs). + +**Core constraint:** `app/modules/` sub-directories (`auth`, `analytics`, `fraud`, +`i18n`) are isolated from each other. Cross-module imports are forbidden except +through explicit composition seams defined in `app/server/composition.ts`. + +| What is forbidden | Why | +|---|---| +| Importing `app/modules/auth/providers/` from outside `auth/` | Provider implementations are private; use the interface | +| `app/resources/` importing from `app/server/edge/` | Edge utilities must not leak into the resource layer | +| `app/shared/` importing from routes, resources, modules, or server | `shared/` is a leaf — it must not depend on the layers above it | +| `app/resources/` importing from `app/routes/` (except `paths.ts`) | Resources must not couple to route modules | +| Circular imports anywhere | Cycles make dependency graphs unresolvable | + +If `bun run lint:boundaries` rejects your import, the fix is to restructure the +code — not to weaken or comment-out the rule. If you believe the rule is +wrong for your use case, open an issue to discuss it first. + +See [Architecture Overview](docs/architecture/overview.md) and +[Provider Seam](docs/architecture/provider-seam.md) for the rationale. + +## Testing + +```bash +bun run test:unit # Cypress component tests +bun run test:e2e:fast # E2E suite against the fake auth provider +``` + +See [Testing](docs/development/testing.md) for the full test strategy. + +## Commits and PRs + +- Follow [Conventional Commits](https://www.conventionalcommits.org/) (`feat:`, `fix:`, `refactor:`, etc.) +- Keep PRs focused — one logical change per PR +- All CI jobs must pass before a PR can be merged + +## License + +By contributing, you agree that your contributions will be licensed under the +[Apache License 2.0](LICENSE). diff --git a/acceptance/PASSKEY-TESTING.md b/acceptance/PASSKEY-TESTING.md new file mode 100644 index 0000000000..770780a93a --- /dev/null +++ b/acceptance/PASSKEY-TESTING.md @@ -0,0 +1,84 @@ +# Local passkey testing against real Zitadel (A-H1) + +Manual integration script for the passkey surfaces (`/id/passkeys`, `/id/reauth`, +`/setup/passkey`) against a **real** Zitadel. CI never runs this: the Cypress +suites cover the same flows on the fake provider (pre-baked credential); this +script is the real-WebAuthn spot check (automated real-Zitadel WebAuthn is +parked — C7). + +## 1. Environment requirements + +Any local Zitadel works (Kind, docker-compose, dev instance) as long as it +provides: + +- A Zitadel instance with a project/app configured for auth-ui and a service + user PAT. +- Login policy `passwordlessType: ALLOWED`. **This must be set explicitly** — + verified live: the FirstInstance default is **NOT_ALLOWED** (the proto zero + value is simply omitted from the policy JSON). Policy updates are idempotent. +- Optionally, an SMTP catcher (e.g. Mailpit) wired as Zitadel's SMTP target for + the email-OTP reauth step and as a canary for unexpected mail. + +## 2. Same-origin WebAuthn proxy (required for real ceremonies) + +WebAuthn requires the RP ID to match the page origin. Put auth-ui and Zitadel +behind ONE https origin (mirrors staging's same-host routing) with any local +reverse proxy — e.g. `https:///id/*` → the auth-ui dev server on +`:3000`, everything else → Zitadel. + +auth-ui `.env` for this setup: + +```bash +AUTH_PROVIDER=zitadel +ZITADEL_API_URL=https:// # the shared origin +ZITADEL_SERVICE_USER_TOKEN= +NODE_EXTRA_CA_CERTS= +``` + +Browse `https:///id/login` (RP ID = ``). Enroll with a +platform authenticator directly, or with Chrome DevTools → WebAuthn panel → +**virtual authenticator** (`ctap2`, resident keys ON, user verification ON). + +## 3. Round-trip checklist (run in order) + +| # | Step | Pass criterion | +|---|------|----------------| +| 1 | Policy honored | `/id/setup/passkey` issues a creation challenge (no `passwordless not allowed` error) | +| 2 | Enroll | virtual/platform authenticator completes; the name step pre-fills from AAGUID (or ` on `) | +| 3 | List | `/id/passkeys` shows the new row with the chosen name (ListPasskeys round-trip) | +| 4 | Sudo | wait >10 min (or clear the fresh factor) → `/id/passkeys` bounces to `/id/reauth`; re-verify returns | +| 5 | Remove | confirm dialog → row gone (RemovePasskey round-trip); last-method guard refuses when it is the only method | +| 6 | Reauth email-OTP | `/id/reauth?method=otp_email` delivers a code to the SMTP catcher | +| 7 | Mail canary | **no unexpected mail** during steps 1–5 — any surprise message is a flow that silently no-ops in prod (SMTP disabled there); catalogue it for the Phase B pipeline | + +- **Created-at:** after enrolling, `/id/passkeys` shows "Added " under the + new passkey's name. This is also the live server-support check for the v2 metadata RPCs + (SetUserMetadata/ListUserMetadata/DeleteUserMetadata) on the deployed Zitadel. +- **Cleanup:** after removing that passkey, the `passkey::created` user-metadata key + is gone (Zitadel console → user → Metadata, or `ListUserMetadata`); pre-existing passkeys + with no created-at metadata show no date line — expected, no backfill. +- **Cross-device sign-out:** sign in on two devices/browsers as the same user; on one, + remove a passkey and choose "Sign out other sessions" — the OTHER device's session is + invalid on next request (Zitadel session deleted). Confirms the session v2 `userIdQuery` + search AND that the service PAT may delete a session WITHOUT its session token. +- **Sign-out sudo gate:** a `signout-others` POST with a stale (>10 min) session factor bounces + through `/id/reauth` instead of executing. + (Sweep completeness is proven only up to one default page of the session search — + per-user session counts are expected to be tiny; accepted at final review.) +- **Login-flow rework:** with ≥2 methods, the chooser shows "Signing in as — Not you?" + and the Passkey entry runs Touch ID in place (no page change; other methods remain on + failure). With passkey as the ONLY method, submitting the email runs the ceremony directly + on the login page (auto-fire; "Continue with passkey" appears if the browser blocks it). +- **Fallback page:** `/id/login/passkey?loginName=` still works when visited directly. + +The SMTP catcher is a local-only canary + OTP delivery aid. It does **not** +simulate production (prod SMTP is disabled). + +## Deprecated lightweight alternative + +`acceptance/docker-compose.zitadel.yml` is a bare Zitadel+Postgres stub +(localhost:8080, no seeding, no mail catcher, no same-origin proxy). It is kept +only as a minimal scratch harness; prefer a staging-shaped seeded stack per §1. +(The stub's completion work was dropped when A-H1 was reframed, 2026-07-17.) + +> This harness feeds the `test:acceptance` suite later; it stays manual for now. diff --git a/app/components/auth-ceremony/auth-ceremony.tsx b/app/components/auth-ceremony/auth-ceremony.tsx index 21466ae445..7d41c11937 100644 --- a/app/components/auth-ceremony/auth-ceremony.tsx +++ b/app/components/auth-ceremony/auth-ceremony.tsx @@ -34,7 +34,7 @@ export interface AuthCeremonyProps { // Tokenized ceremony spacing — owned here so every ceremony screen shares one rhythm // (was previously repeated literally across the verify/setup/login routes). -const CEREMONY_LAYOUT = 'flex flex-col items-baseline justify-center gap-4'; +const CEREMONY_LAYOUT = 'flex flex-col items-center justify-center gap-4'; export function AuthCeremony({ title, @@ -49,7 +49,7 @@ export function AuthCeremony({ }: AuthCeremonyProps) { return ( -
+
{/* IdentityBadge requires a loginName (it returns null without one); only mount it when present so requestId/organization are threaded through "Not you?". */} {loginName && ( diff --git a/app/components/back-link/previous-step.ts b/app/components/back-link/previous-step.ts index 5053de9a27..2d835e9cec 100644 --- a/app/components/back-link/previous-step.ts +++ b/app/components/back-link/previous-step.ts @@ -6,8 +6,17 @@ const PREVIOUS_STEP: Array<[match: (p: string) => boolean, target: string]> = [ [(p) => p === '/login/password', '/login'], [(p) => p === '/login/mfa', '/login/password'], - [(p) => p.startsWith('/login/verify/'), '/login/mfa'], + // Verify screens AND /login/security-key are all USE_SCREEN targets of + // resolveMfaPicker's sole-factor short-circuit (mfa.service.ts): whenever the user has + // exactly one enrolled+policy-allowed second factor, /login/mfa's loader redirects + // straight back to that screen BEFORE any picker UI renders. A Back target of + // /login/mfa therefore silently loops back to the same page. Go straight to /login + // instead (matches "Not you?" semantics) — 2+-factor users still reach the real + // picker via forward navigation from /login/password, which is unaffected. + [(p) => p.startsWith('/login/verify/'), '/login'], + [(p) => p === '/login/security-key', '/login'], [(p) => p === '/signup/password', '/signup'], + [(p) => p === '/signup/method', '/signup'], [(p) => p === '/password/reset', '/login/password'], // Password-management screens previously had no Back control. [(p) => p === '/password/new', '/login/password'], diff --git a/app/hooks/use-passkey-login-ceremony.ts b/app/hooks/use-passkey-login-ceremony.ts new file mode 100644 index 0000000000..9433d7c55d --- /dev/null +++ b/app/hooks/use-passkey-login-ceremony.ts @@ -0,0 +1,139 @@ +import { CYPRESS_CREDENTIAL } from '@/components/webauthn-button/webauthn-button'; +import { + marshalAssertion, + isWebAuthnSupported, + WebAuthnCeremonyError, + WebAuthnUnsupportedError, + type WebAuthnChallengeInput, + type WebAuthnReason, +} from '@/resources/webauthn/webauthn'; +import type { WebAuthnVerifyLoaderData } from '@/resources/webauthn/webauthn-verify'; +import { paths } from '@/routes/paths'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useFetcher } from 'react-router'; + +export type PasskeyCeremonyPhase = 'idle' | 'loading-challenge' | 'ceremony' | 'submitting'; + +export interface PasskeyLoginCeremonyInput { + loginName: string; + requestId?: string; + organization?: string; +} + +/** Extract the inner publicKey the marshaller expects (mirrors login/passkey.tsx). */ +function unwrapPublicKey(options: unknown): unknown { + return options !== null && typeof options === 'object' && 'publicKey' in (options as object) + ? (options as { publicKey: unknown }).publicKey + : options; +} + +/** + * Drives a passkey SIGN-IN from any surface without navigating: challenge from + * the /login/passkey LOADER (lazy via fetcher.load, or pre-minted by a caller), + * WebAuthn assertion (Cypress pre-baked path included), credential submit to + * the /login/passkey ACTION — whose success redirect the fetcher follows + * (lastUsed cookie + reauth identity guard come along for free). + */ +export function usePasskeyLoginCeremony(input: PasskeyLoginCeremonyInput) { + const challengeFetcher = useFetcher(); + const submitFetcher = useFetcher(); + const [phase, setPhase] = useState('idle'); + const [reason, setReason] = useState(null); + // One ceremony per acquired challenge — survives re-renders, resets on begin(). + const consumedChallenge = useRef(null); + + const passkeyPath = paths.login.passkey({ + loginName: input.loginName, + requestId: input.requestId, + organization: input.organization, + }); + + const runCeremony = useCallback( + async (csrfToken: string, publicKeyCredentialRequestOptions: unknown) => { + if (consumedChallenge.current === publicKeyCredentialRequestOptions) return; + consumedChallenge.current = publicKeyCredentialRequestOptions; + setPhase('ceremony'); + try { + let credential: Record; + // Cypress fake-credential path — same gate as WebAuthnButton so component + // specs exercise the identical handoff. + const useFake = + typeof window !== 'undefined' && + (window as unknown as { Cypress?: unknown }).Cypress !== undefined && + !(window as unknown as { __webAuthnRealCeremony?: boolean }).__webAuthnRealCeremony; + if (useFake) { + credential = CYPRESS_CREDENTIAL; + } else { + if (!isWebAuthnSupported()) throw new WebAuthnUnsupportedError(); + credential = await marshalAssertion( + unwrapPublicKey(publicKeyCredentialRequestOptions) as WebAuthnChallengeInput + ); + } + setPhase('submitting'); + submitFetcher.submit( + { + csrf: csrfToken, + credential: JSON.stringify(credential), + loginName: input.loginName, + ...(input.requestId ? { requestId: input.requestId } : {}), + ...(input.organization ? { organization: input.organization } : {}), + // Marker read by /login/passkey's shouldRevalidate: this in-place ceremony + // submits the assertion to the SAME route its challenge was loaded from, so + // RR's default post-submit revalidation would re-run the loader and rotate + // the Zitadel challenge out from under the just-signed assertion (WEBAU-3M9si). + // The verify schema ignores this extra field. + passkeyCeremony: '1', + }, + { method: 'post', action: passkeyPath } + ); + } catch (err) { + setPhase('idle'); + // WebAuthnCeremonyError carries a classified reason; WebAuthnUnsupportedError has none + // (it has no `.reason` field) — its closest existing WebAuthnReason is 'unsupported'. + setReason( + err instanceof WebAuthnCeremonyError + ? err.reason + : err instanceof WebAuthnUnsupportedError + ? 'unsupported' + : 'unknown' + ); + } + }, + [input.loginName, input.requestId, input.organization, passkeyPath, submitFetcher] + ); + + /** Lazy path: fetch a fresh challenge from the /login/passkey loader, then run. */ + const begin = useCallback(() => { + setReason(null); + consumedChallenge.current = null; + setPhase('loading-challenge'); + challengeFetcher.load(passkeyPath); + }, [challengeFetcher, passkeyPath]); + + /** Pre-minted path (sole-passkey inline): run immediately with caller-supplied data. */ + const beginWith = useCallback( + (preMinted: { csrfToken: string; publicKeyCredentialRequestOptions: unknown }) => { + setReason(null); + void runCeremony(preMinted.csrfToken, preMinted.publicKeyCredentialRequestOptions); + }, + [runCeremony] + ); + + // Lazy path completion: when the loader data lands, run the ceremony once. + useEffect(() => { + if (phase !== 'loading-challenge' || challengeFetcher.state !== 'idle') return; + const d = challengeFetcher.data; + if (!d) return; + void runCeremony(d.csrfToken, d.publicKeyCredentialRequestOptions); + }, [phase, challengeFetcher.state, challengeFetcher.data, runCeremony]); + + // Action rejection (e.g. INVALID_CREDENTIALS on challenge expiry) returns data, + // not a redirect — drop back to idle so the surface can offer retry. + useEffect(() => { + if (phase === 'submitting' && submitFetcher.state === 'idle' && submitFetcher.data) { + setPhase('idle'); + } + }, [phase, submitFetcher.state, submitFetcher.data]); + + return { begin, beginWith, phase, reason, actionData: submitFetcher.data as unknown }; +} diff --git a/app/modules/i18n/locales/en.po b/app/modules/i18n/locales/en.po index 47050bdac3..27cc2f656e 100644 --- a/app/modules/i18n/locales/en.po +++ b/app/modules/i18n/locales/en.po @@ -29,7 +29,7 @@ msgstr "{0, plural, one {# attempt remaining.} other {# attempts remaining.}}" msgid "{0}" msgstr "{0}" -#: app/routes/device/authorize.tsx:111 +#: app/routes/device/authorize.tsx:112 msgid "<0>{appName} is requesting access." msgstr "<0>{appName} is requesting access." @@ -78,7 +78,7 @@ msgstr "Additional verification is required to continue." msgid "Already have an account?" msgstr "Already have an account?" -#: app/routes/login/index.tsx:309 +#: app/routes/login/index.tsx:387 msgid "An account with this email already exists — sign in to continue." msgstr "An account with this email already exists — sign in to continue." @@ -91,7 +91,7 @@ msgstr "An account with this email already exists. Sign in to that account, then msgid "An account with this email already exists. Try signing in instead." msgstr "An account with this email already exists. Try signing in instead." -#: app/routes/logout/index.tsx:48 +#: app/routes/logout/index.tsx:56 msgid "Are you sure you want to sign out?" msgstr "Are you sure you want to sign out?" @@ -114,19 +114,19 @@ msgstr "Authenticator QR code" msgid "Authorization complete" msgstr "Authorization complete" -#: app/routes/device/authorize.tsx:162 +#: app/routes/device/authorize.tsx:155 msgid "Authorize" msgstr "Authorize" -#: app/routes/device/authorize.tsx:107 +#: app/routes/device/authorize.tsx:108 msgid "Authorize device" msgstr "Authorize device" -#: app/routes/device/authorize.tsx:120 +#: app/routes/device/authorize.tsx:121 msgid "Authorizing as" msgstr "Authorizing as" -#: app/routes/sso/index.tsx:218 +#: app/routes/sso/index.tsx:224 msgid "Available accounts to link" msgstr "Available accounts to link" @@ -146,21 +146,21 @@ msgid "By continuing, you agree to Datum's <0>Terms of Service and <1>Privac msgstr "By continuing, you agree to Datum's <0>Terms of Service and <1>Privacy Policy, and to receive periodic emails with updates." #: app/routes/passkeys.tsx:139 -#: app/routes/sso/index.tsx:104 +#: app/routes/sso/index.tsx:107 msgid "Cancel" msgstr "Cancel" -#: app/routes/password/change.tsx:119 +#: app/routes/password/change.tsx:126 msgid "Change password" msgstr "Change password" -#: app/routes/password/change.tsx:98 +#: app/routes/password/change.tsx:99 msgid "Change your password" msgstr "Change your password" #: app/routes/password/reset.tsx:109 -#: app/routes/signup/method.tsx:284 -#: app/routes/signup/password.tsx:183 +#: app/routes/signup/method.tsx:288 +#: app/routes/signup/password.tsx:184 msgid "Check your email" msgstr "Check your email" @@ -172,7 +172,7 @@ msgstr "Choose a new password" msgid "Choose an account" msgstr "Choose an account" -#: app/routes/login/method.tsx:81 +#: app/routes/login/method.tsx:89 msgid "Choose how to sign in" msgstr "Choose how to sign in" @@ -180,11 +180,11 @@ msgstr "Choose how to sign in" msgid "Choose how you want to verify your identity." msgstr "Choose how you want to verify your identity." -#: app/routes/login/index.tsx:305 +#: app/routes/login/index.tsx:383 msgid "Choose your login method" msgstr "Choose your login method" -#: app/routes/device/authorize.tsx:83 +#: app/routes/device/authorize.tsx:84 msgid "Code expired" msgstr "Code expired" @@ -201,22 +201,26 @@ msgstr "Confirm it's you" msgid "Confirm new password" msgstr "Confirm new password" -#: app/routes/password/change.tsx:113 +#: app/routes/password/change.tsx:120 #: app/routes/signup/password.tsx:233 msgid "Confirm password" msgstr "Confirm password" -#: app/routes/sso/index.tsx:143 +#: app/routes/sso/index.tsx:149 msgid "Connected accounts" msgstr "Connected accounts" #: app/routes/device/index.tsx:73 -#: app/routes/login/index.tsx:386 +#: app/routes/login/index.tsx:511 #: app/routes/signup/index.tsx:291 msgid "Continue" msgstr "Continue" -#: app/routes/login/method.tsx:147 +#: app/routes/login/index.tsx:428 +msgid "Continue with passkey" +msgstr "Continue with passkey" + +#: app/routes/login/method.tsx:171 msgid "Continue with your provider" msgstr "Continue with your provider" @@ -236,7 +240,7 @@ msgstr "Couldn't sign in" msgid "Create a new account" msgstr "Create a new account" -#: app/routes/login/index.tsx:417 +#: app/routes/login/index.tsx:543 #: app/routes/signup/password.tsx:237 msgid "Create account" msgstr "Create account" @@ -246,12 +250,12 @@ msgstr "Create account" msgid "Created using {0}" msgstr "Created using {0}" -#: app/routes/device/authorize.tsx:172 -#: app/routes/device/authorize.tsx:198 +#: app/routes/device/authorize.tsx:165 +#: app/routes/device/authorize.tsx:191 msgid "Deny" msgstr "Deny" -#: app/routes/device/authorize.tsx:115 +#: app/routes/device/authorize.tsx:116 msgid "Device authorization requested" msgstr "Device authorization requested" @@ -263,8 +267,8 @@ msgstr "Device code" msgid "Device denied" msgstr "Device denied" -#: app/routes/login/index.tsx:262 -#: app/routes/login/index.tsx:359 +#: app/routes/login/index.tsx:350 +#: app/routes/login/index.tsx:480 #: app/routes/signup/index.tsx:250 #: app/routes/signup/index.tsx:275 msgid "Email" @@ -278,9 +282,9 @@ msgstr "Email code" msgid "Email me a code" msgstr "Email me a code" -#: app/routes/login/index.tsx:395 -#: app/routes/login/method.tsx:117 -#: app/routes/signup/method.tsx:333 +#: app/routes/login/index.tsx:520 +#: app/routes/login/method.tsx:141 +#: app/routes/signup/method.tsx:339 msgid "Email me a sign-in link" msgstr "Email me a sign-in link" @@ -300,7 +304,7 @@ msgstr "Email OTP" msgid "Email sign-in isn't available — use your username." msgstr "Email sign-in isn't available — use your username." -#: app/routes/login/index.tsx:261 +#: app/routes/login/index.tsx:349 msgid "Email, phone, or username" msgstr "Email, phone, or username" @@ -312,7 +316,7 @@ msgstr "Enable email one-time code" msgid "Enable SMS one-time code" msgstr "Enable SMS one-time code" -#: app/routes/device/authorize.tsx:89 +#: app/routes/device/authorize.tsx:90 msgid "Enter a new code" msgstr "Enter a new code" @@ -349,7 +353,7 @@ msgstr "Enter your password" msgid "Enter your SMS code" msgstr "Enter your SMS code" -#: app/routes/signup/method.tsx:311 +#: app/routes/signup/method.tsx:315 msgid "Finish creating your account" msgstr "Finish creating your account" @@ -389,11 +393,12 @@ msgstr "Link expired" msgid "Link your account" msgstr "Link your account" -#: app/routes/sso/index.tsx:127 +#: app/routes/sso/index.tsx:130 msgid "Linked accounts" msgstr "Linked accounts" #: app/routes/passkeys.tsx:227 +#: app/routes/sso/index.tsx:137 msgid "Logged in as" msgstr "Logged in as" @@ -409,7 +414,7 @@ msgstr "Name your passkey" msgid "Needs re-authentication" msgstr "Needs re-authentication" -#: app/routes/password/change.tsx:109 +#: app/routes/password/change.tsx:116 #: app/routes/password/new.tsx:102 msgid "New password" msgstr "New password" @@ -438,12 +443,13 @@ msgstr "No signed-in accounts." msgid "Not now" msgstr "Not now" -#: app/routes/login/index.tsx:415 +#: app/routes/login/index.tsx:541 msgid "Not registered?" msgstr "Not registered?" #: app/components/identity-badge/identity-badge.tsx:30 -#: app/routes/signup/password.tsx:203 +#: app/routes/login/index.tsx:416 +#: app/routes/login/method.tsx:98 msgid "Not you?" msgstr "Not you?" @@ -459,8 +465,8 @@ msgstr "or" msgid "Or import this URI in your authenticator app" msgstr "Or import this URI in your authenticator app" -#: app/routes/login/index.tsx:340 -#: app/routes/login/method.tsx:102 +#: app/routes/login/index.tsx:461 +#: app/routes/login/method.tsx:126 #: app/routes/reauth.tsx:174 #: app/routes/setup/mfa.tsx:46 msgid "Passkey" @@ -498,11 +504,11 @@ msgstr "Passkeys" msgid "Passkeys let you sign in with your fingerprint, face, or device PIN." msgstr "Passkeys let you sign in with your fingerprint, face, or device PIN." -#: app/routes/login/method.tsx:132 +#: app/routes/login/method.tsx:156 #: app/routes/reauth.tsx:181 #: app/routes/reauth.tsx:218 #: app/routes/signup/password.tsx:229 -#: app/routes/sso/ldap.tsx:77 +#: app/routes/sso/ldap.tsx:80 msgid "Password" msgstr "Password" @@ -530,7 +536,7 @@ msgstr "Password must contain an uppercase letter." msgid "Password sign-in isn't available for this account." msgstr "Password sign-in isn't available for this account." -#: app/routes/login/index.tsx:264 +#: app/routes/login/index.tsx:352 msgid "Phone" msgstr "Phone" @@ -594,10 +600,6 @@ msgstr "Security key" msgid "Select an account to continue or add a new one." msgstr "Select an account to continue or add a new one." -#: app/routes/login/method.tsx:84 -msgid "Select your preferred sign-in method." -msgstr "Select your preferred sign-in method." - #: app/routes/password/reset.tsx:140 msgid "Send reset link" msgstr "Send reset link" @@ -610,8 +612,8 @@ msgstr "Service temporarily unavailable. Please try again." msgid "Session active" msgstr "Session active" -#: app/routes/signup/method.tsx:371 -#: app/routes/signup/password.tsx:198 +#: app/routes/signup/method.tsx:377 +#: app/routes/signup/password.tsx:199 msgid "Set a password" msgstr "Set a password" @@ -646,7 +648,7 @@ msgstr "Set up SMS one-time code" #: app/routes/login/password.tsx:190 #: app/routes/signup/index.tsx:308 -#: app/routes/sso/ldap.tsx:82 +#: app/routes/sso/ldap.tsx:85 msgid "Sign in" msgstr "Sign in" @@ -656,7 +658,7 @@ msgstr "Sign in" msgid "Sign in again" msgstr "Sign in again" -#: app/routes/device/authorize.tsx:184 +#: app/routes/device/authorize.tsx:177 msgid "Sign in to continue" msgstr "Sign in to continue" @@ -664,24 +666,29 @@ msgstr "Sign in to continue" msgid "Sign in with" msgstr "Sign in with" -#: app/routes/sso/ldap.tsx:62 +#: app/routes/sso/ldap.tsx:63 msgid "Sign in with LDAP" msgstr "Sign in with LDAP" +#: app/routes/login/passkey.tsx:115 +msgid "Sign in with your passkey" +msgstr "Sign in with your passkey" + #: app/components/sign-out-button/sign-out-button.tsx:26 #: app/components/sign-out-button/sign-out-button.tsx:30 -#: app/routes/logout/index.tsx:47 -#: app/routes/logout/index.tsx:53 -#: app/routes/signed-in.tsx:61 -#: app/routes/sso/index.tsx:256 +#: app/routes/logout/index.tsx:51 msgid "Sign out" msgstr "Sign out" +#: app/routes/logout/index.tsx:54 +msgid "Sign out of" +msgstr "Sign out of" + #: app/routes/passkeys.tsx:191 msgid "Sign out other sessions" msgstr "Sign out other sessions" -#: app/routes/login/index.tsx:405 +#: app/routes/login/index.tsx:530 msgid "Sign-in is currently unavailable for this account. Please contact your administrator." msgstr "Sign-in is currently unavailable for this account. Please contact your administrator." @@ -690,10 +697,21 @@ msgid "Signed-in sessions on other devices can still be active. Sign out your ot msgstr "Signed-in sessions on other devices can still be active. Sign out your other sessions?" #: app/components/identity-badge/identity-badge.tsx:29 +#: app/routes/password/change.tsx:112 msgid "Signing in as" msgstr "Signing in as" -#: app/routes/signup/password.tsx:201 +#. placeholder {0}: passkeyInline.loginName +#: app/routes/login/index.tsx:409 +msgid "Signing in as <0>{0}." +msgstr "Signing in as <0>{0}." + +#: app/routes/login/method.tsx:92 +msgid "Signing in as <0>{loginName}." +msgstr "Signing in as <0>{loginName}." + +#: app/routes/signup/method.tsx:319 +#: app/routes/signup/password.tsx:203 msgid "Signing up as" msgstr "Signing up as" @@ -771,7 +789,7 @@ msgstr "This device can't create a passkey. Try another device, a security key, msgid "This device can't use a passkey to sign in. Try another device or a different sign-in method." msgstr "This device can't use a passkey to sign in. Try another device or a different sign-in method." -#: app/routes/device/authorize.tsx:84 +#: app/routes/device/authorize.tsx:85 msgid "This device code is invalid or has expired." msgstr "This device code is invalid or has expired." @@ -787,7 +805,7 @@ msgstr "This directory account can't be linked here yet. Sign in with your passw msgid "This helps us keep our platform stable by heading off fraud and abusive behavior." msgstr "This helps us keep our platform stable by heading off fraud and abusive behavior." -#: app/routes/sso/index.tsx:182 +#: app/routes/sso/index.tsx:188 msgid "This is your only sign-in method" msgstr "This is your only sign-in method" @@ -795,7 +813,7 @@ msgstr "This is your only sign-in method" msgid "This name is for your Datum passkey list — your password manager labels it separately. Names can't be changed later." msgstr "This name is for your Datum passkey list — your password manager labels it separately. Names can't be changed later." -#: app/routes/sso/index.tsx:100 +#: app/routes/sso/index.tsx:103 msgid "This removes it as a sign-in method for your account." msgstr "This removes it as a sign-in method for your account." @@ -815,26 +833,28 @@ msgstr "Too many attempts. Please wait a moment and try again." msgid "Two-factor verification" msgstr "Two-factor verification" -#: app/routes/sso/index.tsx:94 -#: app/routes/sso/index.tsx:113 -#: app/routes/sso/index.tsx:191 +#: app/routes/sso/index.tsx:97 +#: app/routes/sso/index.tsx:116 +#: app/routes/sso/index.tsx:197 msgid "Unlink" msgstr "Unlink" -#: app/routes/sso/index.tsx:99 +#: app/routes/sso/index.tsx:102 msgid "Unlink {providerLabel}?" msgstr "Unlink {providerLabel}?" -#: app/routes/device/authorize.tsx:129 +#: app/routes/device/authorize.tsx:122 #: app/routes/passkeys.tsx:228 +#: app/routes/signed-in.tsx:49 +#: app/routes/sso/index.tsx:138 msgid "Use a different account" msgstr "Use a different account" -#: app/routes/signup/method.tsx:352 +#: app/routes/signup/method.tsx:358 msgid "Use a passkey" msgstr "Use a passkey" -#: app/routes/login/passkey.tsx:75 +#: app/routes/login/passkey.tsx:93 msgid "Use your passkey to verify your identity." msgstr "Use your passkey to verify your identity." @@ -842,8 +862,8 @@ msgstr "Use your passkey to verify your identity." msgid "Use your security key to verify your identity." msgstr "Use your security key to verify your identity." -#: app/routes/login/index.tsx:265 -#: app/routes/sso/ldap.tsx:73 +#: app/routes/login/index.tsx:353 +#: app/routes/sso/ldap.tsx:76 msgid "Username" msgstr "Username" @@ -864,7 +884,7 @@ msgid "Verify and enable" msgstr "Verify and enable" #: app/components/webauthn-button/webauthn-button.tsx:273 -#: app/routes/login/passkey.tsx:74 +#: app/routes/login/passkey.tsx:92 msgid "Verify with passkey" msgstr "Verify with passkey" @@ -908,12 +928,12 @@ msgstr "We've sent a password reset link to <0>{0}" #. placeholder {0}: (actionData as { email: string }).email #. placeholder {0}: actionData.email -#: app/routes/signup/method.tsx:286 -#: app/routes/signup/password.tsx:185 +#: app/routes/signup/method.tsx:290 +#: app/routes/signup/password.tsx:186 msgid "We've sent a verification link to <0>{0}" msgstr "We've sent a verification link to <0>{0}" -#: app/routes/login/index.tsx:302 +#: app/routes/login/index.tsx:380 msgid "Welcome" msgstr "Welcome" @@ -925,18 +945,17 @@ msgstr "While Datum is currently free of charge to use, we require a valid payme msgid "You already have a passkey for this account on this device." msgstr "You already have a passkey for this account on this device." -#: app/routes/signed-in.tsx:42 +#: app/routes/signed-in.tsx:43 msgid "You are signed in" msgstr "You are signed in" -#: app/routes/signed-in.tsx:45 -msgid "You are signed in as <0>{loginName}" -msgstr "You are signed in as <0>{loginName}" +#: app/routes/signed-in.tsx:48 +msgid "You are signed in as" +msgstr "You are signed in as" -#. placeholder {0}: loginName && ( <> Logged in as {loginName}. ) -#: app/routes/sso/index.tsx:129 -msgid "You can link multiple accounts to your Datum account. {0}" -msgstr "You can link multiple accounts to your Datum account. {0}" +#: app/routes/sso/index.tsx:133 +msgid "You can link multiple accounts to your Datum account." +msgstr "You can link multiple accounts to your Datum account." #: app/routes/verify/success.tsx:28 msgid "You can now sign in using <0>{loginName}." @@ -962,14 +981,10 @@ msgstr "You must be signed in to link an external account." msgid "You signed in as a different account than the one you were re-authenticating. Both are kept — choose an account to continue." msgstr "You signed in as a different account than the one you were re-authenticating. Both are kept — choose an account to continue." -#: app/routes/device/authorize.tsx:134 +#: app/routes/device/authorize.tsx:127 msgid "You'll be asked to sign in before authorizing." msgstr "You'll be asked to sign in before authorizing." -#: app/routes/signup/method.tsx:313 -msgid "You're almost done! <0>{loginName}" -msgstr "You're almost done! <0>{loginName}" - #: app/routes/logout/success.tsx:18 msgid "You've been signed out" msgstr "You've been signed out" diff --git a/app/routes.ts b/app/routes.ts index c23c89b242..cf74baeae7 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -69,6 +69,9 @@ export default [ route('logout/success', 'routes/logout/success.tsx'), // Flat standalone routes. + // Passkey management + sudo re-auth interstitial. + route('passkeys', 'routes/passkeys.tsx'), + route('reauth', 'routes/reauth.tsx'), route('accounts', 'routes/accounts.tsx'), route('signed-in', 'routes/signed-in.tsx'), route('error', 'routes/error.tsx'), diff --git a/app/routes/device/authorize.tsx b/app/routes/device/authorize.tsx index 275680fcbd..59836c47f1 100644 --- a/app/routes/device/authorize.tsx +++ b/app/routes/device/authorize.tsx @@ -1,6 +1,7 @@ import { AuthCard } from '@/components/auth-card/auth-card'; import { AuthFormFields } from '@/components/auth-form/auth-form-fields'; import { FormError } from '@/components/form-error/form-error'; +import { IdentityBadge } from '@/components/identity-badge/identity-badge'; import { useAuthActionError } from '@/hooks/use-auth-action-error'; import { mostRecent, readSessions } from '@/modules/auth/session/cookie'; import { @@ -115,20 +116,12 @@ export default function DeviceAuthorize() { Device authorization requested )} {activeLoginName ? ( - - - Authorizing as{' '} - {activeLoginName} - - - Use a different account - - + Authorizing as} + linkLabel={Use a different account} + linkTarget={paths.accounts({ user_code: userCode })} + /> ) : ( You'll be asked to sign in before authorizing. diff --git a/app/routes/login/index.tsx b/app/routes/login/index.tsx index 262af0f455..0094af8af7 100644 --- a/app/routes/login/index.tsx +++ b/app/routes/login/index.tsx @@ -4,8 +4,10 @@ import { IdpButtonList } from '@/components/auth-form/idp-button-list'; import { LastUsedBadge } from '@/components/auth-form/last-used-badge'; import { OrDivider } from '@/components/auth-form/or-divider'; import { FormError } from '@/components/form-error/form-error'; +import { WebAuthnReasonCopy } from '@/components/webauthn-button/webauthn-button'; import { useAuthActionError } from '@/hooks/use-auth-action-error'; import { useLoginContext } from '@/hooks/use-login-context'; +import { usePasskeyLoginCeremony } from '@/hooks/use-passkey-login-ceremony'; import SplitLayout from '@/layouts/split.layout'; import { idpTypeToSlug } from '@/modules/auth/idp-slug'; // ADAPTATION (plan-drift fix): readSessions + serializeSessions live in @/modules/auth/session/cookie. @@ -24,19 +26,20 @@ import { } from '@/resources/login/login.schema'; import { resolveOrg } from '@/resources/shared/resolve-org'; import { getActiveIdPs } from '@/resources/sso/idp-providers'; +import { requestWebAuthnChallenge } from '@/resources/webauthn/webauthn.service'; import { paths } from '@/routes/paths'; import { providerForRequest } from '@/server/auth-context.server'; -import { loaderCsrf, assertCsrf } from '@/server/csrf'; +import { loaderCsrf, assertCsrf, getCsrfToken } from '@/server/csrf'; import { trustedAppOrigin } from '@/server/infra/app-origin.server'; import { env } from '@/server/infra/env.server'; import { getOrCreateFingerprintId, userAgentFromRequest } from '@/server/user-agent'; -import { Button, LinkButton } from '@datum-cloud/datum-ui/button'; +import { Button } from '@datum-cloud/datum-ui/button'; import { Form } from '@datum-cloud/datum-ui/form'; import { Icon } from '@datum-cloud/datum-ui/icons'; import { cn } from '@datum-cloud/datum-ui/utils'; import { Trans, useLingui } from '@lingui/react/macro'; import { Mail, UserKey } from 'lucide-react'; -import { useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { data, redirect, @@ -235,6 +238,34 @@ export async function action({ request }: ActionFunctionArgs) { if (!idpResult.ok) return data({ error: idpResult.error }, { status: 502 }); return redirect(idpResult.authUrl, { headers }); } + // Sole-passkey: don't bounce through the interstitial — mint the challenge NOW + // (same service the /login/passkey loader uses) and return it so the page runs + // the ceremony inline. Any mint failure falls back to the ordinary redirect + // (the fallback page handles its own errors). + if (result.target === paths.login.passkey()) { + const challenge = await requestWebAuthnChallenge( + provider, + result.sessions, + { userVerificationRequirement: 'required', challengeAuditEvent: 'mfa_passkey_challenge' }, + { loginName, requestId, organization, domain: new URL(request.url).hostname } + ); + if (challenge.kind !== 'redirect') { + const [csrfToken, csrfCookie] = await getCsrfToken(request); + if (csrfCookie !== null) headers.append('set-cookie', csrfCookie); + return data( + { + passkeyInline: { + loginName, + requestId, + organization, + csrfToken, + publicKeyCredentialRequestOptions: challenge.publicKeyCredentialRequestOptions, + }, + }, + { headers } + ); + } + } return redirect(`${result.target}?${result.params}`, { headers }); } @@ -253,6 +284,63 @@ export default function Login() { // ceremony screen, so we mount the same inline FormError surface AuthCeremony owns. const errorMessage = useAuthActionError(actionData); + // Sole-passkey inline ceremony (A-P10): the action mints the challenge server-side + // and returns it instead of redirecting to /login/passkey, so the ceremony fires + // IN PLACE — auto-fired once via beginWith(passkeyInline), with a manual + // "Continue with passkey" fallback (begin(), a FRESH challenge) and a "Not you?" + // dismissal back to the ordinary identifier form. + const passkeyInline = ( + actionData as + | { + passkeyInline?: { + loginName: string; + requestId?: string; + organization?: string; + csrfToken: string; + publicKeyCredentialRequestOptions: unknown; + }; + } + | undefined + )?.passkeyInline; + const ceremony = usePasskeyLoginCeremony({ + loginName: passkeyInline?.loginName ?? loginName, + requestId: passkeyInline?.requestId ?? requestId, + organization: passkeyInline?.organization ?? organization, + }); + // Dismissal + auto-fire are keyed to the SPECIFIC challenge object + // (publicKeyCredentialRequestOptions), not a one-shot boolean — a "Not you?" on + // challenge A must not suppress a later, unrelated sole-passkey resolution + // (challenge B) from a resubmitted identifier. `dismissedChallenge` is state (not a + // ref) because "Not you?" needs to trigger a re-render; `autoFiredChallenge` is a + // ref because the auto-fire effect itself drives the re-render that matters (the + // ceremony starting), not this bookkeeping value. + const [dismissedChallenge, setDismissedChallenge] = useState(null); + const autoFiredChallenge = useRef(null); + const showInline = Boolean( + passkeyInline && dismissedChallenge !== passkeyInline.publicKeyCredentialRequestOptions + ); + useEffect(() => { + if ( + !passkeyInline || + !showInline || + autoFiredChallenge.current === passkeyInline.publicKeyCredentialRequestOptions + ) { + return; + } + autoFiredChallenge.current = passkeyInline.publicKeyCredentialRequestOptions; + ceremony.beginWith(passkeyInline); + }, [passkeyInline, showInline, ceremony]); + const ceremonyBusy = + ceremony.phase === 'loading-challenge' || + ceremony.phase === 'ceremony' || + ceremony.phase === 'submitting'; + // Server-side ceremony rejection (e.g. INVALID_CREDENTIALS on an expired/replayed + // challenge) lands in ceremony.actionData, NOT ceremony.reason (that's browser-side + // ceremony failures only — cancel, unsupported, security). Mirrors login/method.tsx's + // identical serverError wiring so both surfaces (auto-fired/manual inline AND the + // gated shortcut, which never sets passkeyInline) report ceremony failures the same way. + const ceremonyServerError = useAuthActionError(ceremony.actionData); + const view = resolveLoginView(settings, idps, emailDeliveryEnabled); const field = resolveIdentifierField(settings); @@ -283,16 +371,6 @@ export default function Login() { // produced (insertion order requestId → organization; undefined values skipped). const signupHref = paths.signup.index({ requestId, organization }); - // Passkey-first prompt (P2): link to the existing /login/passkey webauthn flow, - // carrying any known loginName + the ceremony context. /login/passkey redirects - // back gracefully when there is no resolvable session, so this is safe cold too. - // Empty loginName ('') is skipped (treated as absent) to preserve the prior URL. - const passkeyHref = paths.login.passkey({ - loginName: loginName || undefined, - requestId, - organization, - }); - const [showEmailField, setShowEmailField] = useState(false); return ( @@ -314,111 +392,161 @@ export default function Login() { {errorMessage}
- {view.showIdpButtons ? ( - - ) : null} - - {view.showPasskeyPrompt ? ( - }> - Passkey - - + {/* Shared ceremony-error surface — rendered whether the FAILURE happened on the + auto-fired/manual inline ceremony OR on the gated shortcut below (both drive + the same `ceremony`), so it must show in both the inline and the else branch. */} + {ceremony.reason ? ( + + + + ) : ceremonyServerError ? ( + {ceremonyServerError} ) : null} - {view.showPasswordForm && view.showIdpButtons ? : null} - - {view.showPasswordForm ? ( + {showInline && passkeyInline ? ( +
+

+ + Signing in as {passkeyInline.loginName}. + {' '} + +

+ +
+ ) : ( <> - {!showEmailField ? ( + {view.showIdpButtons ? ( + + ) : null} + + {/* Usernameless passkey sign-in is unsupported upstream (the session API only + mints challenges for a KNOWN user — zitadel/zitadel#8899), so without a + resolvable loginName /login/passkey silently bounces back here. Only offer + the shortcut when it can actually work; email-first stays the passkey path. */} + {view.showPasskeyPrompt && loginName ? ( - ) : ( - - - - - - - Continue - - {view.showEmailLink ? ( - - ) : null} - - )} - - ) : null} + ) : null} - {view.signInUnavailable ? ( -

- - Sign-in is currently unavailable for this account. Please contact your administrator. - -

- ) : null} + {view.showPasswordForm && view.showIdpButtons ? : null} - {view.showRegisterLink ? ( - <> -
-

- Not registered?{' '} - - Create account - -

+ {view.showPasswordForm ? ( + <> + {!showEmailField ? ( + + ) : ( + + + + + + + Continue + + {view.showEmailLink ? ( + + ) : null} + + )} + + ) : null} + + {view.signInUnavailable ? ( +

+ + Sign-in is currently unavailable for this account. Please contact your + administrator. + +

+ ) : null} + + {view.showRegisterLink ? ( + <> +
+

+ Not registered?{' '} + + Create account + +

+ + ) : null} - ) : null} + )} ); } diff --git a/app/routes/login/method.tsx b/app/routes/login/method.tsx index db8d1383af..1777de8405 100644 --- a/app/routes/login/method.tsx +++ b/app/routes/login/method.tsx @@ -1,4 +1,8 @@ +import { FormError } from '@/components/form-error/form-error'; +import { WebAuthnReasonCopy } from '@/components/webauthn-button/webauthn-button'; +import { useAuthActionError } from '@/hooks/use-auth-action-error'; import { useLoginContext } from '@/hooks/use-login-context'; +import { usePasskeyLoginCeremony } from '@/hooks/use-passkey-login-ceremony'; import SplitLayout from '@/layouts/split.layout'; import { decideAfterIdentifier } from '@/resources/login/login-decision'; import { readCeremonyParams } from '@/resources/shared/ceremony-params'; @@ -7,7 +11,7 @@ import { redirectToLogin } from '@/routes/login-bounce'; import { paths } from '@/routes/paths'; import { providerForRequest } from '@/server/auth-context.server'; import { env } from '@/server/infra/env.server'; -import { LinkButton } from '@datum-cloud/datum-ui/button'; +import { Button, LinkButton } from '@datum-cloud/datum-ui/button'; import { Icon } from '@datum-cloud/datum-ui/icons'; import { Trans } from '@lingui/react/macro'; import { Key, Lock, Mail, UserCircle } from 'lucide-react'; @@ -74,6 +78,10 @@ export default function LoginMethod() { // (loginName, then requestId, then organization — undefined values are skipped). const query = { loginName, requestId, organization }; + const ceremony = usePasskeyLoginCeremony({ loginName, requestId, organization }); + const serverError = useAuthActionError(ceremony.actionData); + const passkeyBusy = ceremony.phase !== 'idle'; + return (
@@ -81,26 +89,42 @@ export default function LoginMethod() { Choose how to sign in

- Select your preferred sign-in method. + + Signing in as {loginName}. + {' '} + + Not you? +

+ {ceremony.reason ? ( + + + + ) : serverError ? ( + {serverError} + ) : null}
- {/* LinkButton (single styled ) — NOT Button asChild, which emits - ) : null} {methods.includes('otp_email') ? ( diff --git a/app/routes/login/passkey.tsx b/app/routes/login/passkey.tsx index 4bd7a7663a..8a97beb4c3 100644 --- a/app/routes/login/passkey.tsx +++ b/app/routes/login/passkey.tsx @@ -30,6 +30,24 @@ const _handlers = createWebAuthnVerifyHandlers({ }); export const loader = _handlers.loader; +// The in-place login ceremony (usePasskeyLoginCeremony) loads this route's loader +// for a challenge via fetcher, then submits the assertion to this route's action. +// RR's default post-submit revalidation would re-run the loader and mint a FRESH +// challenge on the Zitadel session, invalidating the just-signed assertion mid-flight +// (WEBAU-3M9si). Suppress ONLY that self-triggered revalidation — the hook tags its +// submit with `passkeyCeremony`. A full-page visit or retry (no marker) revalidates +// normally, so it always renders a fresh challenge. +export function shouldRevalidate({ + formData, + defaultShouldRevalidate, +}: { + formData?: FormData; + defaultShouldRevalidate: boolean; +}) { + if (formData?.get('passkeyCeremony') === '1') return false; + return defaultShouldRevalidate; +} + // Wrap the factory action to append the last-used-login cookie on successful // passkey sign-in. Two Set-Cookie headers cannot be joined into one string, so // we clone the redirect response and append via Headers.append(). @@ -76,7 +94,8 @@ export default function LoginPasskey() { error={errorMessage} loginName={loginName} requestId={requestId} - organization={organization}> + organization={organization} + showBackLink={false}> {/* Hidden form that WebAuthnButton populates and submits. */} - + {/* Generic label — at sign-in the credential may live in a + password manager, security key, or another device, so platform branding misleads. */} + Sign in with your passkey} + /> ); diff --git a/app/routes/logout/index.tsx b/app/routes/logout/index.tsx index c3251645a6..82c7f2c605 100644 --- a/app/routes/logout/index.tsx +++ b/app/routes/logout/index.tsx @@ -1,9 +1,10 @@ import { AuthCard } from '@/components/auth-card/auth-card'; -import { AuthFormFields } from '@/components/auth-form/auth-form-fields'; +import { IdentityBadge } from '@/components/identity-badge/identity-badge'; +import { SignOutButton } from '@/components/sign-out-button/sign-out-button'; +import { mostRecent, readSessions } from '@/modules/auth/session/cookie'; import { performLogout, logoutOutcomeToResponse, completeOidcLogout } from '@/resources/session'; import { providerForRequest } from '@/server/auth-context.server'; import { assertCsrf, loaderCsrf } from '@/server/csrf'; -import { Button } from '@datum-cloud/datum-ui/button'; import { Trans } from '@lingui/react/macro'; import { data, @@ -28,7 +29,10 @@ export async function loader({ request }: LoaderFunctionArgs) { } const { csrfToken, headers } = await loaderCsrf(request); - return data({ csrfToken }, { headers }); + // Show which account is being signed out of, mirroring password/change.tsx's pattern. + // Empty string (no active session) falls back to the generic confirm copy below. + const loginName = mostRecent(await readSessions(request))?.loginName ?? ''; + return data({ csrfToken, loginName }, { headers }); } export async function action({ request }: ActionFunctionArgs) { @@ -41,18 +45,19 @@ export async function action({ request }: ActionFunctionArgs) { } export default function Logout() { - const { csrfToken } = useLoaderData(); + const { csrfToken, loginName } = useLoaderData(); return ( Sign out} - description={Are you sure you want to sign out?}> + description={ + loginName ? ( + Sign out of} showLink={false} /> + ) : ( + Are you sure you want to sign out? + ) + }>
- - - - +
); diff --git a/app/routes/password/change.tsx b/app/routes/password/change.tsx index 05a28d250a..fb3eafa137 100644 --- a/app/routes/password/change.tsx +++ b/app/routes/password/change.tsx @@ -4,6 +4,7 @@ import { AuthFormFields } from '@/components/auth-form/auth-form-fields'; import { PasswordRequirements } from '@/components/auth-form/password-requirements'; import { BackLink } from '@/components/back-link/back-link'; import { FormError } from '@/components/form-error/form-error'; +import { IdentityBadge } from '@/components/identity-badge/identity-badge'; import { useAuthActionError } from '@/hooks/use-auth-action-error'; import { readSessions, mostRecent, byId } from '@/modules/auth/session/cookie'; import { changePassword } from '@/resources/password'; @@ -105,7 +106,13 @@ export default function PasswordChange() { className="flex w-full flex-col gap-4"> - {loginName ?

{loginName}

: null} + {loginName ? ( + Signing in as} + showLink={false} + /> + ) : null} diff --git a/app/routes/signed-in.tsx b/app/routes/signed-in.tsx index f6f85b800c..d4874a0bbe 100644 --- a/app/routes/signed-in.tsx +++ b/app/routes/signed-in.tsx @@ -1,11 +1,12 @@ import { AuthCard } from '@/components/auth-card/auth-card'; -import { AuthFormFields } from '@/components/auth-form/auth-form-fields'; +import { IdentityBadge } from '@/components/identity-badge/identity-badge'; +import { SignOutButton } from '@/components/sign-out-button/sign-out-button'; import { TrackOnMount, identifyUser } from '@/modules/analytics/rybbit'; import { resolveSignedIn } from '@/resources/session'; +import { paths } from '@/routes/paths'; import { providerForRequest } from '@/server/auth-context.server'; import { loaderCsrf } from '@/server/csrf'; import { env } from '@/server/infra/env.server'; -import { Button } from '@datum-cloud/datum-ui/button'; import { Trans } from '@lingui/react/macro'; import { useEffect } from 'react'; import { data, redirect, useLoaderData, type LoaderFunctionArgs } from 'react-router'; @@ -42,25 +43,17 @@ export default function SignedIn() { title={You are signed in} description={ loginName ? ( - - You are signed in as {loginName} - + You are signed in as} + linkLabel={Use a different account} + linkTarget={paths.accounts()} + /> ) : null }>
- {/* Sign-out form posts to the logout INDEX route. Its action lives on - routes/logout/index, which shares /id/logout with the action-less layout, so - React Router needs ?index to target the index action — a native
won't - append it the way RR does; without it the POST 405s on the layout. - Explicit literal path because RR basename-prefixing only applies to RR . - A logout journey should select form[action^="/id/logout"] — keep that prefix. */} - - - - +
); diff --git a/app/routes/signup/method.tsx b/app/routes/signup/method.tsx index 329eca392e..69437433b3 100644 --- a/app/routes/signup/method.tsx +++ b/app/routes/signup/method.tsx @@ -1,7 +1,7 @@ import { AuthCard } from '@/components/auth-card/auth-card'; import { AuthCeremony } from '@/components/auth-ceremony/auth-ceremony'; import { AuthFormFields } from '@/components/auth-form/auth-form-fields'; -import { BackLink } from '@/components/back-link/back-link'; +import { IdentityBadge } from '@/components/identity-badge/identity-badge'; import { useAuthActionError } from '@/hooks/use-auth-action-error'; import { readSessions, serializeSessions } from '@/modules/auth/session/cookie'; import { MaxMindTracker, syncMaxMindTokenToRef } from '@/modules/fraud/maxmind-tracker'; @@ -14,6 +14,7 @@ import { } from '@/resources/signup'; import { resolveSignupView } from '@/resources/signup/signup-view'; import { signupMethodSchema } from '@/resources/signup/signup.schema'; +import { paths } from '@/routes/paths'; import { providerForRequest } from '@/server/auth-context.server'; import { loaderCsrf, assertCsrf } from '@/server/csrf'; import { requireEmailVerification } from '@/server/env'; @@ -275,6 +276,9 @@ export default function SignupMethod() { // replaces the per-route toast). const errorMessage = useAuthActionError(actionData); + // "Not you?" returns to the signup start (mirrors signup/password.tsx one step later). + const notYouHref = paths.signup.index({ requestId, organization }); + // Enumeration-safe terminal: the email-link path (and an existing-email passkey/IdP // attempt) returns a generic "check your email" — render it here, otherwise the screen // would silently re-render with no feedback. @@ -310,9 +314,11 @@ export default function SignupMethod() { Finish creating your account} description={ - - You're almost done! {loginName} - + Signing up as} + linkTarget={notYouHref} + /> } error={errorMessage}>
@@ -373,8 +379,6 @@ export default function SignupMethod() { ) : null}
- -
); diff --git a/app/routes/signup/password.tsx b/app/routes/signup/password.tsx index 2e792dfbf6..1f5906e7bb 100644 --- a/app/routes/signup/password.tsx +++ b/app/routes/signup/password.tsx @@ -3,6 +3,7 @@ import { AuthCeremony } from '@/components/auth-ceremony/auth-ceremony'; import { SubmitButton } from '@/components/auth-form/auth-form'; import { AuthFormFields } from '@/components/auth-form/auth-form-fields'; import { PasswordRequirements } from '@/components/auth-form/password-requirements'; +import { IdentityBadge } from '@/components/identity-badge/identity-badge'; import { useAuthActionError } from '@/hooks/use-auth-action-error'; import { TrackOnMount } from '@/modules/analytics/rybbit'; import { readSessions, serializeSessions } from '@/modules/auth/session/cookie'; @@ -35,7 +36,7 @@ import { type LoaderFunctionArgs, type MetaFunction, } from 'react-router'; -import { Form as RRForm, Link } from 'react-router'; +import { Form as RRForm } from 'react-router'; export const meta: MetaFunction = () => [{ title: 'Set a password' }]; @@ -173,8 +174,8 @@ export default function SignupPassword() { // "Not you?" returns to the signup start (not /login — this is a signup ceremony) so a // different email can be entered. Mirrors IdentityBadge's requestId/organization threading, - // loginName intentionally dropped. AuthCeremony's shared IdentityBadge is login-only copy - // ("Signing in as" → /login), so the identity + link are rendered inline here instead. + // loginName intentionally dropped. This route uses IdentityBadge with overridden verb and + // linkTarget props to adapt its default login-only copy ("Signing in as" → /login) to signup. const notYouHref = paths.signup.index({ requestId, organization }); if (actionData && 'sent' in actionData) { @@ -197,12 +198,11 @@ export default function SignupPassword() { Set a password} description={ - <> - Signing up as {loginName}.{' '} - - Not you? - - + Signing up as} + linkTarget={notYouHref} + /> } error={errorMessage}> Linked accounts} description={ - - You can link multiple accounts to your Datum account.{' '} + <> + You can link multiple accounts to your Datum account. {loginName && ( - <> - Logged in as {loginName}. - + Logged in as} + linkLabel={Use a different account} + linkTarget={paths.accounts()} + /> )} - + }>
{/* Linked IdPs */} @@ -248,14 +254,7 @@ export default function SsoPage() { ) : null} - {/* Sign-out link → logout INDEX action; ?index disambiguates from the - action-less logout/layout (native
won't add it like RR does). */} - - - - +
); diff --git a/app/routes/sso/ldap.tsx b/app/routes/sso/ldap.tsx index 8b8e8229f4..2f9514ab00 100644 --- a/app/routes/sso/ldap.tsx +++ b/app/routes/sso/ldap.tsx @@ -59,7 +59,10 @@ export default function SsoLdap() { const errorMessage = useAuthActionError(actionData); return ( - Sign in with LDAP} error={errorMessage}> + Sign in with LDAP} + error={errorMessage} + showBackLink={false}> { }); }); +describe('AuthCeremony shell — identity centering', () => { + it('centers the IdentityBadge row (items-center, not items-baseline)', () => { + cy.mount( + + c + , + OPTS + ); + cy.get('[data-testid="auth-ceremony-body"]') + .should('have.class', 'items-center') + .and('not.have.class', 'items-baseline'); + }); +}); + +describe('AuthCeremony shell — showBackLink suppression', () => { + it('renders no Back control when showBackLink={false}, even at a path with a real predecessor', () => { + // OPTS mounts at /login/password, which DOES have a predecessor in previous-step.ts + // (-> /login) — proving suppression here (not just at a dead-link path) is what makes + // Tasks 3 and 5's showBackLink={false} route changes meaningfully tested: this test + // proves the mechanism; those tasks prove the specific routes wire it through. + cy.mount( + + c + , + OPTS + ); + cy.get('a').should('not.exist'); + }); +}); + describe('OtpCodeField', () => { function mountOtpField(label = 'Email code', name = 'code') { cy.mount( diff --git a/cypress/component/components/back-link/previous-step.cy.ts b/cypress/component/components/back-link/previous-step.cy.ts index aa5d586c72..5fe9e983d4 100644 --- a/cypress/component/components/back-link/previous-step.cy.ts +++ b/cypress/component/components/back-link/previous-step.cy.ts @@ -4,7 +4,6 @@ describe('previousStepFor', () => { it('maps each ceremony step to its predecessor, including /setup/* enrollment screens, the MFA chooser, password-management screens, and terminal/headless steps (spec §5)', () => { expect(previousStepFor('/login/password')).to.equal('/login'); expect(previousStepFor('/login/mfa')).to.equal('/login/password'); - expect(previousStepFor('/login/verify/email')).to.equal('/login/mfa'); expect(previousStepFor('/signup/password')).to.equal('/signup'); expect(previousStepFor('/password/reset')).to.equal('/login/password'); @@ -24,4 +23,19 @@ describe('previousStepFor', () => { expect(previousStepFor('/signed-in')).to.be.null; expect(previousStepFor('/login/passkey')).to.be.null; }); + + it('verify/* and security-key Back goes straight to /login, not /login/mfa (fixes the sole-factor loop, 2026-07-22)', () => { + // resolveMfaPicker's sole-factor short-circuit (mfa.service.ts) redirects /login/mfa + // straight back to whichever verify/security-key screen is the user's only enrolled + // factor, BEFORE any picker UI renders — so a Back target of /login/mfa silently + // loops. All four sole-factor USE_SCREEN targets go to /login instead. + expect(previousStepFor('/login/verify/email')).to.equal('/login'); + expect(previousStepFor('/login/verify/sms')).to.equal('/login'); + expect(previousStepFor('/login/verify/authenticator')).to.equal('/login'); + expect(previousStepFor('/login/security-key')).to.equal('/login'); + }); + + it('signup/method returns to /signup (mirrors signup/password)', () => { + expect(previousStepFor('/signup/method')).to.equal('/signup'); + }); }); diff --git a/cypress/component/routes/device/authorize-identity.cy.tsx b/cypress/component/routes/device/authorize-identity.cy.tsx new file mode 100644 index 0000000000..674dc83ff7 --- /dev/null +++ b/cypress/component/routes/device/authorize-identity.cy.tsx @@ -0,0 +1,55 @@ +// cypress/component/routes/device/authorize-identity.cy.tsx +// +// Pins the "Authorizing as X — Use a different account" identity row now rendered +// through the shared IdentityBadge component instead of bespoke flex markup. +import DeviceAuthorize from '@/routes/device/authorize'; +import { ConformAdapter } from '@datum-cloud/datum-ui/form/adapters/conform'; +import { setupI18n } from '@lingui/core'; +import { I18nProvider } from '@lingui/react'; +import { mount } from 'cypress/react'; +import { createMemoryRouter, RouterProvider } from 'react-router'; + +function withI18n(node: React.ReactNode) { + const i18n = setupI18n({ locale: 'en', messages: { en: {} } }); + return ( + + {node} + + ); +} + +const LOADER_DATA = { + csrfToken: 'tok-1', + appName: 'Acme CLI', + scope: ['profile', 'email'], + deviceAuthId: 'da-1', + requestId: 'device_ABC123', + activeLoginName: 'mia@acme.test', +}; + +function mountAuthorize() { + const router = createMemoryRouter( + [ + { + id: 'device-authorize', + path: '/device/authorize', + element: , + loader: async () => LOADER_DATA, + }, + ], + { initialEntries: ['/device/authorize'] } + ); + return mount(withI18n()); +} + +describe('/device/authorize — identity via shared IdentityBadge', () => { + it('shows "Authorizing as " with a "Use a different account" link to /accounts?user_code=...', () => { + mountAuthorize(); + cy.contains('Authorizing as').should('be.visible'); + cy.contains(LOADER_DATA.activeLoginName).should('be.visible'); + cy.findByRole('link', { name: /use a different account/i }) + .should('have.attr', 'href') + .and('include', '/accounts') + .and('include', 'user_code=ABC123'); + }); +}); diff --git a/cypress/component/routes/login/index.cy.tsx b/cypress/component/routes/login/index.cy.tsx new file mode 100644 index 0000000000..0a6bed7c25 --- /dev/null +++ b/cypress/component/routes/login/index.cy.tsx @@ -0,0 +1,207 @@ +// cypress/component/routes/login/index.cy.tsx +// +// UI contract for /login (A-P10): the sole-passkey action-data variant fires the +// shared ceremony INLINE ("Signing in as ." + auto-fire via beginWith), with +// a manual "Continue with passkey" fallback (begin(), a FRESH challenge) and a +// "Not you?" dismissal back to the ordinary identifier form. Mirrors method.cy.tsx's +// stub /login/passkey route + capturedPosts convention (Task 2). +import Login from '@/routes/login/index'; +import { ConformAdapter } from '@datum-cloud/datum-ui/form/adapters/conform'; +import { setupI18n } from '@lingui/core'; +import { I18nProvider } from '@lingui/react'; +import { mount } from 'cypress/react'; +import { createMemoryRouter, RouterProvider } from 'react-router'; + +const LOGIN_CONTEXT = { loginName: '', requestId: undefined, organization: undefined }; + +// Settings shaped so the ordinary identifier form (the "Email" button) renders by +// default — the baseline the inline ceremony state must replace/restore around. +const INDEX_LOADER_DATA = { + csrfToken: 'tok-0', + idps: [], + settings: { + allowPassword: true, + allowRegister: false, + allowExternalIdp: false, + passkeysType: 'allowed', + forceMfa: false, + disableLoginWithEmail: false, + disableLoginWithPhone: false, + }, + branding: null, + emailDeliveryEnabled: false, + notice: undefined, + lastUsedLogin: undefined, +}; + +const capturedPosts: Array> = []; + +function withI18n(node: React.ReactNode) { + const i18n = setupI18n({ locale: 'en', messages: { en: {} } }); + return ( + + {node} + + ); +} + +function mountLogin(opts?: { + actionData?: unknown; + // Known loginName gates the Passkey SHORTCUT visible (view.showPasskeyPrompt && loginName). + loginName?: string; + // Overrides the /login (index) route's own action — used to simulate a REAL identifier + // resubmit that resolves to a fresh sole-passkey challenge (Finding 2 coverage). + indexAction?: (args: { request: Request }) => unknown | Promise; + // Overrides the /login/passkey stub's action — used to simulate a ceremony failure + // (Finding 1 coverage). Defaults to capturing the POST into capturedPosts. + passkeyAction?: (args: { request: Request }) => unknown | Promise; +}) { + const loginContext = { ...LOGIN_CONTEXT, loginName: opts?.loginName ?? LOGIN_CONTEXT.loginName }; + const router = createMemoryRouter( + [ + { + id: 'login', + path: '/login', + loader: () => loginContext, + children: [ + { + id: 'index', + index: true, + element: , + loader: async () => INDEX_LOADER_DATA, + action: opts?.indexAction ?? (async () => null), + }, + // Stub /login/passkey — same route the shared ceremony hook lazily loads + // (challenge) then posts to (credential). Mirrors method.cy.tsx's convention: + // no navigation assertions, just the captured POST (or a stubbed failure datum). + { + id: 'passkey', + path: 'passkey', + loader: async () => ({ + csrfToken: 'tok-1', + loginName: opts?.loginName ?? 'solo@acme.test', + requestId: undefined, + organization: undefined, + publicKeyCredentialRequestOptions: { publicKey: { challenge: 'x' } }, + }), + action: + opts?.passkeyAction ?? + (async ({ request }: { request: Request }) => { + capturedPosts.push(Object.fromEntries(await request.formData())); + return null; // surface stays; redirect-following is RR-internal, not under test + }), + }, + ], + }, + ], + { + initialEntries: ['/login'], + hydrationData: { + loaderData: { login: loginContext, index: INDEX_LOADER_DATA }, + ...(opts?.actionData !== undefined ? { actionData: { index: opts.actionData } } : {}), + }, + } + ); + return mount(withI18n()); +} + +describe('/login — sole-passkey inline ceremony', () => { + beforeEach(() => { + capturedPosts.length = 0; + }); + + it('sole-passkey action data swaps to the inline ceremony state and auto-fires', () => { + mountLogin({ + actionData: { + passkeyInline: { + loginName: 'solo@acme.test', + csrfToken: 'tok-1', + publicKeyCredentialRequestOptions: { publicKey: { challenge: 'x' } }, + }, + }, + }); + cy.contains('Signing in as').should('be.visible'); + cy.contains('solo@acme.test').should('be.visible'); + cy.contains('Not you?').should('be.visible'); + // Auto-fire: the pre-baked credential reaches the stub action without a click. + cy.wrap(null).should(() => { + expect(capturedPosts).to.have.length(1); + expect(capturedPosts[0].loginName).to.equal('solo@acme.test'); + }); + // Identifier form is gone while the ceremony state shows. + cy.contains('button', 'Email').should('not.exist'); + }); + + it('inline state offers the manual button and Not you? returns to the identifier form', () => { + mountLogin({ + actionData: { + passkeyInline: { + loginName: 'solo@acme.test', + csrfToken: 'tok-1', + publicKeyCredentialRequestOptions: { publicKey: { challenge: 'x' } }, + }, + }, + }); + cy.contains('button', 'Continue with passkey').should('be.visible'); + cy.contains('Not you?').click(); + cy.contains('button', 'Email').should('be.visible'); + }); + + // Finding 1: the gated Passkey SHORTCUT drives the same `ceremony` as the inline + // state but never sets passkeyInline — a failure there must still surface visibly. + it('the gated Passkey shortcut surfaces a ceremony failure through the shared error region', () => { + mountLogin({ + loginName: 'solo@acme.test', + // The /login/passkey ACTION rejects the (fake, Cypress-marshalled) credential — + // this is the server-side rejection path (ceremony.actionData), not a browser-side + // ceremony throw (ceremony.reason); the shared region must render either. + passkeyAction: async () => ({ error: 'INVALID_CREDENTIALS' }), + }); + cy.contains('button', 'Passkey').should('not.be.disabled').click(); + cy.contains('Incorrect credentials. Please try again.').should('be.visible'); + // The shortcut re-enables once the ceremony drops back to idle (not stuck busy). + cy.contains('button', 'Passkey').should('not.be.disabled'); + }); + + // Finding 2: dismissal is keyed to the SPECIFIC challenge, not a component-lifetime + // boolean — a later, unrelated sole-passkey resolution must still auto-fire. Driven via + // a REAL identifier resubmit (not a remount, which would reset all component state and + // prove nothing about the dismiss→re-arm transition on the same mounted instance). + it('a fresh sole-passkey challenge after "Not you?" auto-fires again (re-arm, not stuck)', () => { + const CHALLENGE_B = { publicKey: { challenge: 'chal-b' } }; + mountLogin({ + loginName: 'solo@acme.test', + actionData: { + passkeyInline: { + loginName: 'solo@acme.test', + csrfToken: 'tok-1', + publicKeyCredentialRequestOptions: { publicKey: { challenge: 'chal-a' } }, + }, + }, + // Simulates the /login action resolving a DIFFERENT identifier to a fresh + // sole-passkey challenge — a real resubmit, not the same dismissed challenge. + indexAction: async () => ({ + passkeyInline: { + loginName: 'other@acme.test', + csrfToken: 'tok-2', + publicKeyCredentialRequestOptions: CHALLENGE_B, + }, + }), + }); + + // Auto-fire #1 (challenge A) reaches the stub /login/passkey action. + cy.wrap(null).should(() => expect(capturedPosts).to.have.length(1)); + + cy.contains('Not you?').click(); + cy.contains('button', 'Email').should('be.visible').click(); + cy.contains('button', 'Continue').click(); + + // The fresh challenge (different identity, different publicKeyCredentialRequestOptions + // object) shows the inline state again and auto-fires a SECOND time. + cy.contains('other@acme.test').should('be.visible'); + cy.wrap(null).should(() => { + expect(capturedPosts).to.have.length(2); + expect(capturedPosts[1].loginName).to.equal('other@acme.test'); + }); + }); +}); diff --git a/cypress/component/routes/login/method.cy.tsx b/cypress/component/routes/login/method.cy.tsx new file mode 100644 index 0000000000..4897b9d3eb --- /dev/null +++ b/cypress/component/routes/login/method.cy.tsx @@ -0,0 +1,108 @@ +// cypress/component/routes/login/method.cy.tsx +// +// UI contract for /login/method (A-P10): identity header ("Signing in as " / +// "Not you?") and the Passkey entry firing usePasskeyLoginCeremony IN PLACE (a Button +// that lazily loads the /login/passkey challenge and submits the pre-baked Cypress +// credential) instead of navigating there. The other method entries stay plain links. +import LoginMethod from '@/routes/login/method'; +import { ConformAdapter } from '@datum-cloud/datum-ui/form/adapters/conform'; +import { setupI18n } from '@lingui/core'; +import { I18nProvider } from '@lingui/react'; +import { mount } from 'cypress/react'; +import { createMemoryRouter, RouterProvider } from 'react-router'; + +const LOGIN_CONTEXT = { + loginName: 'mia@acme.test', + requestId: undefined, + organization: undefined, +}; +const METHOD_LOADER_DATA = { + methods: ['passkey', 'password'], + branding: null, +}; + +const capturedPosts: Array> = []; + +function withI18n(node: React.ReactNode) { + const i18n = setupI18n({ locale: 'en', messages: { en: {} } }); + return ( + + {node} + + ); +} + +function mountMethod() { + const router = createMemoryRouter( + [ + { + id: 'login', + path: '/login', + loader: () => LOGIN_CONTEXT, + children: [ + { + id: 'method', + path: 'method', + element: , + loader: async () => METHOD_LOADER_DATA, + }, + // Stub /login/passkey — same route the shared ceremony hook lazily loads + // (challenge) then posts to (credential). Mirrors passkeys-ui.cy.tsx's + // render-only convention: no navigation assertions, just the captured POST. + { + id: 'passkey', + path: 'passkey', + loader: async () => ({ + csrfToken: 'tok-1', + loginName: 'mia@acme.test', + requestId: undefined, + organization: undefined, + publicKeyCredentialRequestOptions: { publicKey: { challenge: 'x' } }, + }), + action: async ({ request }: { request: Request }) => { + capturedPosts.push(Object.fromEntries(await request.formData())); + return null; // surface stays; redirect-following is RR-internal, not under test + }, + }, + ], + }, + ], + { + initialEntries: ['/login/method?loginName=mia%40acme.test'], + hydrationData: { + loaderData: { login: LOGIN_CONTEXT, method: METHOD_LOADER_DATA }, + }, + } + ); + return mount(withI18n()); +} + +describe('/login/method — identity header + in-place passkey ceremony', () => { + beforeEach(() => { + capturedPosts.length = 0; + }); + + it('shows the identity header with a Not you? action back to /login', () => { + mountMethod(); + cy.contains('Signing in as').should('be.visible'); + cy.contains('mia@acme.test').should('be.visible'); + cy.contains('a', 'Not you?') + .should('have.attr', 'href') + .and('match', /\/login(\?|$)/) + .and('not.contain', 'loginName'); + }); + + it('Passkey fires the ceremony in place and submits the pre-baked credential', () => { + mountMethod(); + cy.contains('button', 'Passkey').click(); + // Lazy challenge → pre-baked credential → POST to the stub action. + cy.wrap(null).should(() => { + expect(capturedPosts).to.have.length(1); + expect(capturedPosts[0].loginName).to.equal('mia@acme.test'); + expect(JSON.parse(String(capturedPosts[0].credential)).id).to.equal('fake-credential-id'); + }); + // No navigation happened — the chooser is still on screen as the fallback. + // Password stays a plain link (unchanged), so it's matched by its
tag. + cy.contains('a', 'Password').should('be.visible'); + }); +}); diff --git a/cypress/component/routes/login/passkey-back-link.cy.tsx b/cypress/component/routes/login/passkey-back-link.cy.tsx new file mode 100644 index 0000000000..1438d6ec79 --- /dev/null +++ b/cypress/component/routes/login/passkey-back-link.cy.tsx @@ -0,0 +1,49 @@ +// cypress/component/routes/login/passkey-back-link.cy.tsx +// +// /login/passkey is now a fallback/deep-link-only screen (2026-07-22 passkey rework) — +// no mainstream forward navigation lands here, so it has no meaningful "previous step". +// Pins that Back is explicitly suppressed rather than silently rendering null. +import LoginPasskey from '@/routes/login/passkey'; +import { ConformAdapter } from '@datum-cloud/datum-ui/form/adapters/conform'; +import { setupI18n } from '@lingui/core'; +import { I18nProvider } from '@lingui/react'; +import { mount } from 'cypress/react'; +import { createMemoryRouter, RouterProvider } from 'react-router'; + +function withI18n(node: React.ReactNode) { + const i18n = setupI18n({ locale: 'en', messages: { en: {} } }); + return ( + + {node} + + ); +} + +function mountPasskey() { + const router = createMemoryRouter( + [ + { + id: 'passkey', + path: '/login/passkey', + element: , + loader: async () => ({ + csrfToken: 'tok-1', + loginName: 'mia@acme.test', + requestId: undefined, + organization: undefined, + publicKeyCredentialRequestOptions: { publicKey: { challenge: 'x' } }, + }), + }, + ], + { initialEntries: ['/login/passkey'] } + ); + return mount(withI18n()); +} + +describe('/login/passkey — Back link explicitly suppressed', () => { + it('renders the identity header but no Back control', () => { + mountPasskey(); + cy.contains('a', 'Not you?').should('exist'); + cy.contains('a', 'Back').should('not.exist'); + }); +}); diff --git a/cypress/component/routes/logout/logout.cy.tsx b/cypress/component/routes/logout/logout.cy.tsx index 32411f50ea..99bf6e23be 100644 --- a/cypress/component/routes/logout/logout.cy.tsx +++ b/cypress/component/routes/logout/logout.cy.tsx @@ -36,4 +36,30 @@ describe('Logout confirm form — index-route POST disambiguation', () => { // action-less logout/layout (→ 405); ?index targets routes/logout/index which owns the action. cy.get('form').invoke('attr', 'action').should('include', '?index'); }); + + it('shows "Sign out of " when an active session exists (no switch link)', () => { + const router = createMemoryRouter([{ id: 'logout', path: '/logout', element: }], { + initialEntries: ['/logout'], + hydrationData: { + loaderData: { logout: { csrfToken: 'test-csrf', loginName: 'mia@acme.test' } }, + }, + }); + mount(withProviders()); + cy.contains('Sign out of').should('be.visible'); + cy.contains('mia@acme.test').should('be.visible'); + // Scoped, not a blanket "no on the page": BrandLogo always renders a home link. + // What IdentityBadge's showLink=false must suppress is its OWN "Not you?" switch-account + // link (which targets /login). This assertion catches if showLink={false} is accidentally removed. + cy.contains(/not you\?/i).should('not.exist'); + cy.get('a[href="/login"], a[href^="/login?"]').should('not.exist'); + }); + + it('falls back to the generic confirm copy when there is no active session', () => { + const router = createMemoryRouter([{ id: 'logout', path: '/logout', element: }], { + initialEntries: ['/logout'], + hydrationData: { loaderData: { logout: { csrfToken: 'test-csrf', loginName: '' } } }, + }); + mount(withProviders()); + cy.contains('Are you sure you want to sign out?').should('be.visible'); + }); }); diff --git a/cypress/component/routes/password/change-identity.cy.tsx b/cypress/component/routes/password/change-identity.cy.tsx new file mode 100644 index 0000000000..d1ae5f8f78 --- /dev/null +++ b/cypress/component/routes/password/change-identity.cy.tsx @@ -0,0 +1,64 @@ +// cypress/component/routes/password/change-identity.cy.tsx +// +// password/change.tsx previously showed a bare inert loginName paragraph. Upgrades it +// to the shared IdentityBadge styling with showLink=false (mid-password-change is not +// a good place to offer an account-switch/abandon affordance). +import PasswordChange from '@/routes/password/change'; +import { ConformAdapter } from '@datum-cloud/datum-ui/form/adapters/conform'; +import { setupI18n } from '@lingui/core'; +import { I18nProvider } from '@lingui/react'; +import { mount } from 'cypress/react'; +import { createMemoryRouter, RouterProvider } from 'react-router'; + +function withI18n(node: React.ReactNode) { + const i18n = setupI18n({ locale: 'en', messages: { en: {} } }); + return ( + + {node} + + ); +} + +function mountPasswordChange() { + const router = createMemoryRouter( + [ + { + id: 'password-change', + path: '/password/change', + element: , + loader: async () => ({ + csrfToken: 'tok-1', + sessionId: 's1', + loginName: 'mia@acme.test', + requestId: undefined, + // Real shape is `PasswordComplexity` (app/modules/auth/types.ts): minLength + + // requires{Uppercase,Lowercase,Number,Symbol} — confirmed against + // app/resources/password/password.schema.ts and password-complexity.ts. + passwordComplexity: { + minLength: 8, + requiresUppercase: false, + requiresLowercase: false, + requiresNumber: false, + requiresSymbol: false, + }, + }), + }, + ], + { initialEntries: ['/password/change'] } + ); + return mount(withI18n()); +} + +describe('/password/change — identity via IdentityBadge, no switch link', () => { + it('shows "Signing in as " with no switch-account link', () => { + mountPasswordChange(); + cy.contains('Signing in as').should('be.visible'); + cy.contains('mia@acme.test').should('be.visible'); + // Scoped, not a blanket "no on the page": /password/change always renders an + // unrelated BackLink anchor to /login/password (previous-step.ts) plus a logo link — + // both pre-exist this change. What IdentityBadge's showLink=false must suppress is + // its OWN "Not you?" switch-account link (which targets /login, not /login/password). + cy.contains(/not you\?/i).should('not.exist'); + cy.get('a[href="/login"], a[href^="/login?"]').should('not.exist'); + }); +}); diff --git a/cypress/component/routes/paths.cy.ts b/cypress/component/routes/paths.cy.ts index 14ec3c4b7a..d36cd19243 100644 --- a/cypress/component/routes/paths.cy.ts +++ b/cypress/component/routes/paths.cy.ts @@ -18,4 +18,15 @@ describe("paths.ts — typed builders return today's exact strings", () => { expect(paths.login.verify.sms({})).to.equal('/login/verify/sms'); expect(paths.login.verify.authenticator({})).to.equal('/login/verify/authenticator'); }); + + it('builds passkey-management and reauth paths', () => { + expect(paths.passkeys()).to.equal('/passkeys'); + expect(paths.reauth()).to.equal('/reauth'); + expect(paths.reauth({ method: 'password', returnTo: '/passkeys' })).to.equal( + '/reauth?method=password&returnTo=%2Fpasskeys' + ); + expect(paths.passkeys({ returnTo: 'https://portal.test/settings' })).to.equal( + '/passkeys?returnTo=https%3A%2F%2Fportal.test%2Fsettings' + ); + }); }); diff --git a/cypress/component/routes/signed-in.cy.tsx b/cypress/component/routes/signed-in.cy.tsx new file mode 100644 index 0000000000..1b71b51bcc --- /dev/null +++ b/cypress/component/routes/signed-in.cy.tsx @@ -0,0 +1,57 @@ +// cypress/component/routes/signed-in.cy.tsx +// +// /signed-in previously showed loginName as bare text with no switch-account +// affordance at all. Adds "Use a different account" (mirrors device/authorize.tsx). +import SignedIn from '@/routes/signed-in'; +import { ConformAdapter } from '@datum-cloud/datum-ui/form/adapters/conform'; +import { setupI18n } from '@lingui/core'; +import { I18nProvider } from '@lingui/react'; +import { mount } from 'cypress/react'; +import { createMemoryRouter, RouterProvider } from 'react-router'; + +function withI18n(node: React.ReactNode) { + const i18n = setupI18n({ locale: 'en', messages: { en: {} } }); + return ( + + {node} + + ); +} + +function mountSignedIn(loginName: string | null = 'mia@acme.test') { + const router = createMemoryRouter( + [ + { + id: 'signed-in', + path: '/signed-in', + element: , + loader: async () => ({ loginName, userId: 'u1', csrfToken: 'tok-1' }), + }, + ], + { initialEntries: ['/signed-in'] } + ); + return mount(withI18n()); +} + +describe('/signed-in — identity + switch-account link + sign-out', () => { + it('shows "You are signed in as " with a "Use a different account" link to /accounts', () => { + mountSignedIn(); + cy.contains('You are signed in as').should('be.visible'); + cy.contains('mia@acme.test').should('be.visible'); + cy.findByRole('link', { name: /use a different account/i }).should( + 'have.attr', + 'href', + '/accounts' + ); + }); + + it('the Sign out form posts to /id/logout?index', () => { + mountSignedIn(); + cy.get('form[action="/id/logout?index"]').contains('button', 'Sign out').should('be.visible'); + }); + + it('renders no identity line when loginName is absent', () => { + mountSignedIn(null); + cy.contains('You are signed in as').should('not.exist'); + }); +}); diff --git a/cypress/component/routes/signup/method-back-link.cy.tsx b/cypress/component/routes/signup/method-back-link.cy.tsx new file mode 100644 index 0000000000..491931d569 --- /dev/null +++ b/cypress/component/routes/signup/method-back-link.cy.tsx @@ -0,0 +1,70 @@ +// cypress/component/routes/signup/method-back-link.cy.tsx +// +// signup/method.tsx used to double-mount BackLink (an explicit plus +// AuthCeremony's own default-true auto-render) — both previously resolved to null +// (no /signup/method entry in previous-step.ts), so it was a latent duplicate-render +// bug. Now that the map has an entry (Task 2), exactly one Back control must render. +import SignupMethod from '@/routes/signup/method'; +import { ConformAdapter } from '@datum-cloud/datum-ui/form/adapters/conform'; +import { setupI18n } from '@lingui/core'; +import { I18nProvider } from '@lingui/react'; +import { mount } from 'cypress/react'; +import { createMemoryRouter, RouterProvider } from 'react-router'; + +function withI18n(node: React.ReactNode) { + const i18n = setupI18n({ locale: 'en', messages: { en: {} } }); + return ( + + {node} + + ); +} + +const LOADER_DATA = { + csrfToken: 'tok-1', + loginName: 'mia@acme.test', + firstName: 'Mia', + lastName: 'Doe', + organization: undefined, + requestId: undefined, + deviceTrackingToken: undefined, + maxmindAccountId: '', + view: { showEmailLink: false, showPasskey: false, showPassword: true }, +}; + +function mountSignupMethod() { + const router = createMemoryRouter( + [ + { + id: 'signup-method', + path: '/signup/method', + element: , + loader: async () => LOADER_DATA, + }, + ], + { initialEntries: ['/signup/method'] } + ); + return mount(withI18n()); +} + +describe('/signup/method — exactly one Back control, targeting /signup', () => { + it('renders a single Back link (no duplicate)', () => { + mountSignupMethod(); + cy.get('a') + .filter(':contains("Back")') + .should('have.length', 1) + .and('have.attr', 'href') + .and('match', /^\/signup(\?|$)/); + }); +}); + +describe('/signup/method — identity + Not you? (new, mirrors signup/password)', () => { + it('shows "Signing up as . Not you?" linking back to /signup', () => { + mountSignupMethod(); + cy.contains('Signing up as').should('be.visible'); + cy.contains(LOADER_DATA.loginName).should('be.visible'); + cy.findByRole('link', { name: /not you/i }) + .should('have.attr', 'href') + .and('match', /^\/signup(\?|$)/); + }); +}); diff --git a/cypress/component/routes/sso/ldap-back-link.cy.tsx b/cypress/component/routes/sso/ldap-back-link.cy.tsx new file mode 100644 index 0000000000..889b71ee94 --- /dev/null +++ b/cypress/component/routes/sso/ldap-back-link.cy.tsx @@ -0,0 +1,47 @@ +// cypress/component/routes/sso/ldap-back-link.cy.tsx +// +// /sso/ldap is an IdP-initiated entry point with no natural single previous step. +// Pins that Back is explicitly suppressed rather than silently rendering null. +import SsoLdap from '@/routes/sso/ldap'; +import { ConformAdapter } from '@datum-cloud/datum-ui/form/adapters/conform'; +import { setupI18n } from '@lingui/core'; +import { I18nProvider } from '@lingui/react'; +import { mount } from 'cypress/react'; +import { createMemoryRouter, RouterProvider } from 'react-router'; + +function withI18n(node: React.ReactNode) { + const i18n = setupI18n({ locale: 'en', messages: { en: {} } }); + return ( + + {node} + + ); +} + +function mountSsoLdap() { + const router = createMemoryRouter( + [ + { + id: 'sso-ldap', + path: '/sso/ldap', + element: , + loader: async () => ({ + csrfToken: 'tok-1', + idpId: 'idp-1', + requestId: undefined, + organization: undefined, + }), + }, + ], + { initialEntries: ['/sso/ldap'] } + ); + return mount(withI18n()); +} + +describe('/sso/ldap — Back link explicitly suppressed', () => { + it('renders the LDAP form with no Back control', () => { + mountSsoLdap(); + cy.contains('Sign in with LDAP').should('be.visible'); + cy.contains('a', 'Back').should('not.exist'); + }); +}); diff --git a/cypress/component/routes/sso/sso-render.cy.tsx b/cypress/component/routes/sso/sso-render.cy.tsx index 439ce6ee99..08e699c6fa 100644 --- a/cypress/component/routes/sso/sso-render.cy.tsx +++ b/cypress/component/routes/sso/sso-render.cy.tsx @@ -116,6 +116,18 @@ describe('SsoIndex — unlink guard: dialog confirm + disabled sole sign-in meth // …and exposes an enabled submit button to complete the unlink (the "Confirm submits" path). cy.get('button[type="submit"]').contains('Unlink').should('exist').and('not.be.disabled'); }); + + it('shows the active login name with a "Use a different account" switch link and a Sign out control', () => { + mountRoute(SsoIndex, 'sso-index', '/sso', '/sso', loaderData); + cy.contains('Logged in as').should('exist'); + cy.contains(loaderData.loginName).should('exist'); + cy.findByRole('link', { name: /use a different account/i }).should( + 'have.attr', + 'href', + '/accounts' + ); + cy.get('form[action="/id/logout?index"]').contains('button', 'Sign out').should('exist'); + }); }); // ── sso/provider/error ──────────────────────────────────────────────────────── diff --git a/cypress/e2e/a11y-sweep.cy.ts b/cypress/e2e/a11y-sweep.cy.ts index 07861c3294..2c72e8ecf5 100644 --- a/cypress/e2e/a11y-sweep.cy.ts +++ b/cypress/e2e/a11y-sweep.cy.ts @@ -135,7 +135,7 @@ describe('a11y sweep — /login/passkey (session required)', () => { cy.location('pathname').should('include', '/login/passkey'); // Positive assertion: WebAuthnButton renders its "Verify with passkey" text // (disabled until React hydrates, but SSR-rendered DOM is present). - cy.contains('button', /verify with passkey/i).should('exist'); + cy.contains('button', /sign in with .*passkey|touch id|windows hello/i).should('exist'); checkA11y(); }); }); diff --git a/cypress/e2e/passkeys-manage.cy.ts b/cypress/e2e/passkeys-manage.cy.ts index a813202709..d2e7b185d3 100644 --- a/cypress/e2e/passkeys-manage.cy.ts +++ b/cypress/e2e/passkeys-manage.cy.ts @@ -115,6 +115,7 @@ describe('/id/passkeys — list / remove / last-method guard / sign-out offer / }); cy.settleHydration(); cy.location('pathname').should('eq', '/id/passkeys'); + // Last-method guard: refusal surfaces the inline error, the row stays. cy.get('button[aria-label="Remove Solo key"]').should('not.be.disabled').click(); cy.contains('button', 'Remove passkey').click(); diff --git a/cypress/e2e/verify-otp.cy.ts b/cypress/e2e/verify-otp.cy.ts index cf962e8df0..1980665245 100644 --- a/cypress/e2e/verify-otp.cy.ts +++ b/cypress/e2e/verify-otp.cy.ts @@ -10,7 +10,15 @@ import { checkA11y } from '../support/a11y'; * We then navigate directly to the verify screen; the session cookie is set. */ function loginAndGetSession(loginName: string) { - cy.visit('/id/login'); + cy.visit('/id/login', { + onBeforeLoad: (win) => { + win.__CYPRESS_HYDRATE__ = true; // hydrate so the IdP-first "Email" reveal works (see entry.client.tsx) + }, + }); + cy.settleHydration(); + // The email input is behind an "Email" reveal button (IdP-first UX); click it first + // (mirrors core-signin.cy.ts's identifier flow). + cy.contains('button', 'Email').click(); cy.get('input[name="loginName"]').type(loginName); cy.get('input[name="loginName"]:visible').closest('form').submit(); cy.location('pathname').should('eq', '/id/login/password'); @@ -82,3 +90,42 @@ describe('MFA verify — no session guard', () => { cy.location('pathname').should('eq', '/id/login'); }); }); + +describe('MFA verify — Back navigation (regression: sole-factor loop)', () => { + // Motivating bug: Back from /login/verify/{channel} used to target /login/mfa. + // For a user with exactly one enrolled+policy-allowed second factor, + // /login/mfa's loader short-circuits (resolveMfaPicker's sole-factor redirect + // in mfa.service.ts) straight back to that same verify screen before any + // picker UI renders — so clicking Back silently looped back to the page the + // user was already on. previous-step.ts now points Back at /login directly + // for /login/verify/* (see previousStepFor). These tests click the real + // rendered "Back" control (not just the pure previousStepFor function) so a + // reverted map entry would fail here even though the unit tests still pass. + + it('clicking Back on the email verify screen lands on /login, not looping back', () => { + loginAndGetSession('email-otp-user@acme.test'); + cy.visit('/id/login/verify/email?loginName=email-otp-user%40acme.test'); + + cy.contains('a, button', /^back$/i).click(); + + cy.location('pathname').should('eq', '/id/login'); + }); + + it('clicking Back on the SMS verify screen lands on /login, not looping back', () => { + loginAndGetSession('sms-otp-user@acme.test'); + cy.visit('/id/login/verify/sms?loginName=sms-otp-user%40acme.test'); + + cy.contains('a, button', /^back$/i).click(); + + cy.location('pathname').should('eq', '/id/login'); + }); + + it('clicking Back on the TOTP verify screen lands on /login, not looping back', () => { + loginAndGetSession('totp-user@acme.test'); + cy.visit('/id/login/verify/authenticator?loginName=totp-user%40acme.test'); + + cy.contains('a, button', /^back$/i).click(); + + cy.location('pathname').should('eq', '/id/login'); + }); +}); diff --git a/package.json b/package.json index 2f78c1ed67..2a9e446197 100644 --- a/package.json +++ b/package.json @@ -59,6 +59,7 @@ "zod": "^4.4.3" }, "overrides": { + "axios": "^1.18.0", "@opentelemetry/sdk-trace-base": "2.8.0", "@opentelemetry/core": "2.8.0", "@opentelemetry/resources": "2.8.0", From 233e0ee5c9a1181f518bad7680d79d041ea5fdab Mon Sep 17 00:00:00 2001 From: Yahya Fakhroji Date: Mon, 27 Jul 2026 19:31:33 +0700 Subject: [PATCH 3/9] feat(reauth): inline passkey ceremony for sudo re-verification; stale-session recovery Adds the in-place passkey ceremony to /reauth (mirrors the login-side one) with busy/disabled states across the chooser while it runs, and an active-identity badge with a Back-link fix. Also recovers from stale cross-browser session tokens (getSession/findUser throwing a non-transient ProviderError instead of returning null) by bouncing to /login instead of crashing, and standardizes "Not you?" wording across surfaces. --- app/components/auth-form/auth-form.tsx | 11 +- app/components/auth-form/idp-button-list.tsx | 4 + .../webauthn-button/webauthn-button.tsx | 6 +- app/hooks/use-passkey-reauth-ceremony.ts | 124 +++++++++++++++ app/modules/auth/types.ts | 21 +++ app/modules/i18n/locales/en.po | 69 +++++---- app/resources/passkeys/passkeys.service.ts | 28 +++- app/resources/reauth/reauth.service.ts | 71 ++++++++- app/routes/device/authorize.tsx | 2 +- app/routes/login/index.tsx | 12 +- app/routes/login/method.tsx | 15 +- app/routes/passkeys.tsx | 2 +- app/routes/reauth.tsx | 87 +++++++++-- app/routes/signed-in.tsx | 2 +- app/routes/sso/index.tsx | 2 +- .../resources/passkeys/passkeys.service.cy.ts | 10 ++ .../resources/reauth/reauth.service.cy.ts | 66 ++++++++ .../routes/device/authorize-identity.cy.tsx | 6 +- cypress/component/routes/login/index.cy.tsx | 7 +- cypress/component/routes/login/method.cy.tsx | 5 +- cypress/component/routes/passkeys-ui.cy.tsx | 8 +- cypress/component/routes/reauth.cy.tsx | 143 ++++++++++++++++++ .../routes/setup/setup-render.cy.tsx | 37 +++++ cypress/component/routes/signed-in.cy.tsx | 10 +- .../component/routes/sso/sso-render.cy.tsx | 8 +- cypress/e2e/reauth.cy.ts | 18 ++- 26 files changed, 674 insertions(+), 100 deletions(-) create mode 100644 app/hooks/use-passkey-reauth-ceremony.ts create mode 100644 cypress/component/routes/reauth.cy.tsx diff --git a/app/components/auth-form/auth-form.tsx b/app/components/auth-form/auth-form.tsx index ca3062a514..272e8aff1f 100644 --- a/app/components/auth-form/auth-form.tsx +++ b/app/components/auth-form/auth-form.tsx @@ -6,6 +6,8 @@ interface SubmitButtonProps { children: ReactNode; /** Override the auto navigation-state loading (e.g. when using a fetcher). */ loading?: boolean; + /** Force-disable regardless of navigation state (e.g. a sibling ceremony is busy). */ + disabled?: boolean; className?: string; /** * Optional click handler, fired before the browser's native form submission (see @@ -21,7 +23,13 @@ interface SubmitButtonProps { * router's navigation state so it reflects the in-flight server action — NOT RHF's * isSubmitting, which resolves instantly under the native-RR-submit pattern. */ -export function SubmitButton({ children, loading, className, onClick }: SubmitButtonProps) { +export function SubmitButton({ + children, + loading, + disabled, + className, + onClick, +}: SubmitButtonProps) { const navigation = useNavigation(); const isSubmitting = loading ?? navigation.state === 'submitting'; return ( @@ -31,6 +39,7 @@ export function SubmitButton({ children, loading, className, onClick }: SubmitBu block htmlType="submit" loading={isSubmitting} + disabled={disabled} className={className} onClick={onClick}> {children} diff --git a/app/components/auth-form/idp-button-list.tsx b/app/components/auth-form/idp-button-list.tsx index df7f5d699e..d73a979405 100644 --- a/app/components/auth-form/idp-button-list.tsx +++ b/app/components/auth-form/idp-button-list.tsx @@ -23,6 +23,8 @@ export interface IdpButtonListProps { submittingIdpId: string | null; relative?: boolean; lastUsedLogin?: string | null; + /** Force-disable every row regardless of submittingIdpId (e.g. a sibling ceremony is busy). */ + disabled?: boolean; } export function IdpButtonList({ @@ -33,6 +35,7 @@ export function IdpButtonList({ submittingIdpId, relative = false, lastUsedLogin, + disabled = false, }: IdpButtonListProps): React.JSX.Element { // MaxMind mirrors the captured device-fingerprint token into sessionStorage asynchronously // (see modules/fraud/maxmind-tracker) — read it client-side only (post-mount) so SSR and the @@ -63,6 +66,7 @@ export function IdpButtonList({ block htmlType="submit" loading={submittingIdpId === idp.id} + disabled={disabled} iconPosition="left" icon={ // Span wrapper keeps the Button's `icon` slot a single element; the optimistic diff --git a/app/components/webauthn-button/webauthn-button.tsx b/app/components/webauthn-button/webauthn-button.tsx index 2747bfae5a..9e5c53798b 100644 --- a/app/components/webauthn-button/webauthn-button.tsx +++ b/app/components/webauthn-button/webauthn-button.tsx @@ -178,7 +178,11 @@ export function WebAuthnButton({ useEffect(() => { setMounted(true); }, []); - const isSubmitting = loading ?? navigation.state !== 'idle'; + // 'submitting' means THIS form's own action is in flight. 'loading' also covers an + // unrelated navigation elsewhere on the page (e.g. clicking the BackLink or an + // identity "Not you?" link) — using navigation.state !== 'idle' here made this + // button flash busy/disabled for a click it had nothing to do with. + const isSubmitting = loading ?? navigation.state === 'submitting'; async function handleClick() { setError(null); diff --git a/app/hooks/use-passkey-reauth-ceremony.ts b/app/hooks/use-passkey-reauth-ceremony.ts new file mode 100644 index 0000000000..43cf9d0c98 --- /dev/null +++ b/app/hooks/use-passkey-reauth-ceremony.ts @@ -0,0 +1,124 @@ +import { CYPRESS_CREDENTIAL } from '@/components/webauthn-button/webauthn-button'; +import { + marshalAssertion, + isWebAuthnSupported, + WebAuthnCeremonyError, + WebAuthnUnsupportedError, + type WebAuthnChallengeInput, + type WebAuthnReason, +} from '@/resources/webauthn/webauthn'; +import { paths } from '@/routes/paths'; +import type { ReauthLoaderData } from '@/routes/reauth'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useFetcher } from 'react-router'; + +export type PasskeyReauthCeremonyPhase = 'idle' | 'loading-challenge' | 'ceremony' | 'submitting'; + +export interface PasskeyReauthCeremonyInput { + returnTo: string; +} + +/** Extract the inner publicKey the marshaller expects (mirrors use-passkey-login-ceremony). */ +function unwrapPublicKey(options: unknown): unknown { + return options !== null && typeof options === 'object' && 'publicKey' in (options as object) + ? (options as { publicKey: unknown }).publicKey + : options; +} + +/** + * Drives a passkey RE-AUTH from the /reauth chooser without navigating: challenge + * from THIS route's own loader (lazy via fetcher.load, ?method=passkey), WebAuthn + * assertion (Cypress pre-baked path included), credential submit to THIS route's + * action — whose success redirect the fetcher follows. Mirrors + * usePasskeyLoginCeremony's lazy path; reauth has no pre-minted path since it's + * only ever reached from the multi-method chooser, never a sole-factor shortcut. + */ +export function usePasskeyReauthCeremony(input: PasskeyReauthCeremonyInput) { + const challengeFetcher = useFetcher(); + const submitFetcher = useFetcher(); + const [phase, setPhase] = useState('idle'); + const [reason, setReason] = useState(null); + // One ceremony per acquired challenge — survives re-renders, resets on begin(). + const consumedChallenge = useRef(null); + + const challengePath = paths.reauth({ method: 'passkey', returnTo: input.returnTo }); + + const runCeremony = useCallback( + async (csrfToken: string, publicKeyCredentialRequestOptions: unknown) => { + if (consumedChallenge.current === publicKeyCredentialRequestOptions) return; + consumedChallenge.current = publicKeyCredentialRequestOptions; + setPhase('ceremony'); + try { + let credential: Record; + // Cypress fake-credential path — same gate as WebAuthnButton so component + // specs exercise the identical handoff. + const useFake = + typeof window !== 'undefined' && + (window as unknown as { Cypress?: unknown }).Cypress !== undefined && + !(window as unknown as { __webAuthnRealCeremony?: boolean }).__webAuthnRealCeremony; + if (useFake) { + credential = CYPRESS_CREDENTIAL; + } else { + if (!isWebAuthnSupported()) throw new WebAuthnUnsupportedError(); + credential = await marshalAssertion( + unwrapPublicKey(publicKeyCredentialRequestOptions) as WebAuthnChallengeInput + ); + } + setPhase('submitting'); + submitFetcher.submit( + { + csrf: csrfToken, + factor: 'passkey', + credential: JSON.stringify(credential), + returnTo: input.returnTo, + // Marker read by reauth.tsx's shouldRevalidate: this in-place ceremony submits + // to the SAME route its challenge was loaded from, so RR's default post-submit + // revalidation would re-run the loader and rotate the Zitadel challenge out from + // under the just-signed assertion (same class of bug as WEBAU-3M9si in + // login/passkey.tsx). The action schema ignores this extra field. + passkeyCeremony: '1', + }, + { method: 'post', action: paths.reauth() } + ); + } catch (err) { + setPhase('idle'); + // WebAuthnCeremonyError carries a classified reason; WebAuthnUnsupportedError has none + // (it has no `.reason` field) — its closest existing WebAuthnReason is 'unsupported'. + setReason( + err instanceof WebAuthnCeremonyError + ? err.reason + : err instanceof WebAuthnUnsupportedError + ? 'unsupported' + : 'unknown' + ); + } + }, + [input.returnTo, submitFetcher] + ); + + /** Fetch a fresh challenge from this route's own loader, then run. */ + const begin = useCallback(() => { + setReason(null); + consumedChallenge.current = null; + setPhase('loading-challenge'); + challengeFetcher.load(challengePath); + }, [challengeFetcher, challengePath]); + + // Challenge-load completion: when the loader data lands, run the ceremony once. + useEffect(() => { + if (phase !== 'loading-challenge' || challengeFetcher.state !== 'idle') return; + const d = challengeFetcher.data; + if (!d) return; + void runCeremony(d.csrfToken, d.view.publicKeyCredentialRequestOptions); + }, [phase, challengeFetcher.state, challengeFetcher.data, runCeremony]); + + // Action rejection (e.g. INVALID_CREDENTIALS on challenge expiry) returns data, + // not a redirect — drop back to idle so the chooser can offer retry. + useEffect(() => { + if (phase === 'submitting' && submitFetcher.state === 'idle' && submitFetcher.data) { + setPhase('idle'); + } + }, [phase, submitFetcher.state, submitFetcher.data]); + + return { begin, phase, reason, actionData: submitFetcher.data as unknown }; +} diff --git a/app/modules/auth/types.ts b/app/modules/auth/types.ts index 6762edfa3c..6a521cc47f 100644 --- a/app/modules/auth/types.ts +++ b/app/modules/auth/types.ts @@ -209,6 +209,27 @@ export class ProviderError extends Error { } } +// Codes that indicate a genuine transient backend problem (NOT a dead/stale session) — mirrors +// session.service.ts's SWITCH_TRANSIENT_CODES. Kept here so any caller re-validating a STORED +// session (not one just created in the same request) can share the same classification. +const TRANSIENT_PROVIDER_CODES = new Set([ + 'UNAVAILABLE', + 'DEADLINE_EXCEEDED', + 'RATE_LIMITED', +]); + +/** + * True when a thrown error means a stored session token is stale/revoked rather than a + * genuine backend outage — i.e. any ProviderError except the transient ones. Callers + * re-validating a stored session (e.g. from a cookie, possibly created in a different + * browser/tab) should treat this as "needs re-authentication" and recover by redirecting, + * instead of letting it crash the request. NOT appropriate for a session just created earlier + * in the SAME request (a real failure there is a genuine error, not staleness). + */ +export function isStaleSessionError(err: unknown): boolean { + return err instanceof ProviderError && !TRANSIENT_PROVIDER_CODES.has(err.code); +} + // ── external IdP (Phase 4) ──────────────────────────────────── export interface IdpIntentRef { idpIntentId: string; diff --git a/app/modules/i18n/locales/en.po b/app/modules/i18n/locales/en.po index 27cc2f656e..4c8b4f11cf 100644 --- a/app/modules/i18n/locales/en.po +++ b/app/modules/i18n/locales/en.po @@ -24,7 +24,7 @@ msgstr "{0, plural, one {# attempt remaining.} other {# attempts remaining.}}" #. placeholder {0}: idp.name #. placeholder {0}: r.label -#: app/components/auth-form/idp-button-list.tsx:81 +#: app/components/auth-form/idp-button-list.tsx:85 #: app/routes/setup/mfa.tsx:186 msgid "{0}" msgstr "{0}" @@ -172,7 +172,7 @@ msgstr "Choose a new password" msgid "Choose an account" msgstr "Choose an account" -#: app/routes/login/method.tsx:89 +#: app/routes/login/method.tsx:90 msgid "Choose how to sign in" msgstr "Choose how to sign in" @@ -188,12 +188,12 @@ msgstr "Choose your login method" msgid "Code expired" msgstr "Code expired" -#: app/routes/reauth.tsx:223 -#: app/routes/reauth.tsx:245 +#: app/routes/reauth.tsx:288 +#: app/routes/reauth.tsx:310 msgid "Confirm" msgstr "Confirm" -#: app/routes/reauth.tsx:164 +#: app/routes/reauth.tsx:199 msgid "Confirm it's you" msgstr "Confirm it's you" @@ -211,7 +211,7 @@ msgid "Connected accounts" msgstr "Connected accounts" #: app/routes/device/index.tsx:73 -#: app/routes/login/index.tsx:511 +#: app/routes/login/index.tsx:513 #: app/routes/signup/index.tsx:291 msgid "Continue" msgstr "Continue" @@ -220,7 +220,7 @@ msgstr "Continue" msgid "Continue with passkey" msgstr "Continue with passkey" -#: app/routes/login/method.tsx:171 +#: app/routes/login/method.tsx:178 msgid "Continue with your provider" msgstr "Continue with your provider" @@ -240,7 +240,7 @@ msgstr "Couldn't sign in" msgid "Create a new account" msgstr "Create a new account" -#: app/routes/login/index.tsx:543 +#: app/routes/login/index.tsx:545 #: app/routes/signup/password.tsx:237 msgid "Create account" msgstr "Create account" @@ -268,7 +268,7 @@ msgid "Device denied" msgstr "Device denied" #: app/routes/login/index.tsx:350 -#: app/routes/login/index.tsx:480 +#: app/routes/login/index.tsx:482 #: app/routes/signup/index.tsx:250 #: app/routes/signup/index.tsx:275 msgid "Email" @@ -278,12 +278,12 @@ msgstr "Email" msgid "Email code" msgstr "Email code" -#: app/routes/reauth.tsx:188 +#: app/routes/reauth.tsx:253 msgid "Email me a code" msgstr "Email me a code" -#: app/routes/login/index.tsx:520 -#: app/routes/login/method.tsx:141 +#: app/routes/login/index.tsx:522 +#: app/routes/login/method.tsx:144 #: app/routes/signup/method.tsx:339 msgid "Email me a sign-in link" msgstr "Email me a sign-in link" @@ -357,7 +357,7 @@ msgstr "Enter your SMS code" msgid "Finish creating your account" msgstr "Finish creating your account" -#: app/routes/reauth.tsx:166 +#: app/routes/reauth.tsx:202 msgid "For your security, verify one of your sign-in methods to continue." msgstr "For your security, verify one of your sign-in methods to continue." @@ -398,6 +398,7 @@ msgid "Linked accounts" msgstr "Linked accounts" #: app/routes/passkeys.tsx:227 +#: app/routes/reauth.tsx:206 #: app/routes/sso/index.tsx:137 msgid "Logged in as" msgstr "Logged in as" @@ -427,7 +428,7 @@ msgstr "No account was found and sign-up is not available." msgid "No passkeys yet." msgstr "No passkeys yet." -#: app/routes/reauth.tsx:193 +#: app/routes/reauth.tsx:258 msgid "No sign-in method is available for re-authentication." msgstr "No sign-in method is available for re-authentication." @@ -443,13 +444,18 @@ msgstr "No signed-in accounts." msgid "Not now" msgstr "Not now" -#: app/routes/login/index.tsx:541 +#: app/routes/login/index.tsx:543 msgid "Not registered?" msgstr "Not registered?" #: app/components/identity-badge/identity-badge.tsx:30 +#: app/routes/device/authorize.tsx:122 #: app/routes/login/index.tsx:416 -#: app/routes/login/method.tsx:98 +#: app/routes/login/method.tsx:99 +#: app/routes/passkeys.tsx:228 +#: app/routes/reauth.tsx:207 +#: app/routes/signed-in.tsx:49 +#: app/routes/sso/index.tsx:138 msgid "Not you?" msgstr "Not you?" @@ -465,9 +471,9 @@ msgstr "or" msgid "Or import this URI in your authenticator app" msgstr "Or import this URI in your authenticator app" -#: app/routes/login/index.tsx:461 -#: app/routes/login/method.tsx:126 -#: app/routes/reauth.tsx:174 +#: app/routes/login/index.tsx:462 +#: app/routes/login/method.tsx:127 +#: app/routes/reauth.tsx:237 #: app/routes/setup/mfa.tsx:46 msgid "Passkey" msgstr "Passkey" @@ -504,9 +510,9 @@ msgstr "Passkeys" msgid "Passkeys let you sign in with your fingerprint, face, or device PIN." msgstr "Passkeys let you sign in with your fingerprint, face, or device PIN." -#: app/routes/login/method.tsx:156 -#: app/routes/reauth.tsx:181 -#: app/routes/reauth.tsx:218 +#: app/routes/login/method.tsx:161 +#: app/routes/reauth.tsx:245 +#: app/routes/reauth.tsx:283 #: app/routes/signup/password.tsx:229 #: app/routes/sso/ldap.tsx:80 msgid "Password" @@ -688,7 +694,7 @@ msgstr "Sign out of" msgid "Sign out other sessions" msgstr "Sign out other sessions" -#: app/routes/login/index.tsx:530 +#: app/routes/login/index.tsx:532 msgid "Sign-in is currently unavailable for this account. Please contact your administrator." msgstr "Sign-in is currently unavailable for this account. Please contact your administrator." @@ -706,7 +712,7 @@ msgstr "Signing in as" msgid "Signing in as <0>{0}." msgstr "Signing in as <0>{0}." -#: app/routes/login/method.tsx:92 +#: app/routes/login/method.tsx:93 msgid "Signing in as <0>{loginName}." msgstr "Signing in as <0>{loginName}." @@ -843,13 +849,6 @@ msgstr "Unlink" msgid "Unlink {providerLabel}?" msgstr "Unlink {providerLabel}?" -#: app/routes/device/authorize.tsx:122 -#: app/routes/passkeys.tsx:228 -#: app/routes/signed-in.tsx:49 -#: app/routes/sso/index.tsx:138 -msgid "Use a different account" -msgstr "Use a different account" - #: app/routes/signup/method.tsx:358 msgid "Use a passkey" msgstr "Use a passkey" @@ -867,7 +866,7 @@ msgstr "Use your security key to verify your identity." msgid "Username" msgstr "Username" -#: app/routes/reauth.tsx:240 +#: app/routes/reauth.tsx:305 #: app/routes/verify/index.tsx:159 msgid "Verification code" msgstr "Verification code" @@ -883,7 +882,7 @@ msgstr "Verify" msgid "Verify and enable" msgstr "Verify and enable" -#: app/components/webauthn-button/webauthn-button.tsx:273 +#: app/components/webauthn-button/webauthn-button.tsx:277 #: app/routes/login/passkey.tsx:92 msgid "Verify with passkey" msgstr "Verify with passkey" @@ -917,7 +916,7 @@ msgstr "We couldn't set up your passkey. Please try again." msgid "We couldn't start passkey setup. Please try again." msgstr "We couldn't start passkey setup. Please try again." -#: app/routes/reauth.tsx:238 +#: app/routes/reauth.tsx:303 msgid "We sent a verification code to your email address." msgstr "We sent a verification code to your email address." @@ -993,7 +992,7 @@ msgstr "You've been signed out" msgid "Your account is temporarily locked after too many attempts." msgstr "Your account is temporarily locked after too many attempts." -#: app/components/webauthn-button/webauthn-button.tsx:253 +#: app/components/webauthn-button/webauthn-button.tsx:257 msgid "Your browser does not support passkeys. Please use a supported browser." msgstr "Your browser does not support passkeys. Please use a supported browser." diff --git a/app/resources/passkeys/passkeys.service.ts b/app/resources/passkeys/passkeys.service.ts index 50dfbdf5b5..bac1656ec9 100644 --- a/app/resources/passkeys/passkeys.service.ts +++ b/app/resources/passkeys/passkeys.service.ts @@ -8,7 +8,7 @@ import type { AuthProvider } from '@/modules/auth/auth-provider'; // Pure helpers from session/session (not cookie.ts) — cookie.ts is stubbed to no-ops // in the Cypress component bundle; identical at runtime (cookie.ts re-exports it). import { mostRecent, type SessionEntry } from '@/modules/auth/session/session'; -import { ProviderError } from '@/modules/auth/types'; +import { ProviderError, isStaleSessionError } from '@/modules/auth/types'; import { validateReturnTo } from '@/resources/shared/return-to'; import { isSudoFresh } from '@/resources/shared/sudo'; import { paths } from '@/routes/paths'; @@ -49,11 +49,27 @@ async function resolveActive( } | null> { const entry = mostRecent(sessions); if (!entry) return null; - const session = await provider.getSession(entry.id, entry.token); - if (!session) return null; - const userId = session.user?.id ?? (await provider.findUser(entry.loginName))?.id; - if (!userId) return null; - return { entry, factors: session.factors, userId }; + // The stored session token may belong to a DIFFERENT browser/tab than the one hitting this + // route right now, and may be stale or revoked provider-side. getSession/findUser throw + // (rather than returning null) when that's the case — treat any non-transient ProviderError + // the same as a null session (return null; the caller already redirects to /login). A + // genuinely transient error (real backend outage) still propagates. + try { + const session = await provider.getSession(entry.id, entry.token); + if (!session) return null; + const userId = session.user?.id ?? (await provider.findUser(entry.loginName))?.id; + if (!userId) return null; + return { entry, factors: session.factors, userId }; + } catch (err) { + if (isStaleSessionError(err)) { + logAuthEvent('passkeys', 'failure', { + actor: hashActor(entry.loginName), + reason: err instanceof ProviderError ? err.code : 'UNKNOWN', + }); + return null; + } + throw err; + } } /** diff --git a/app/resources/reauth/reauth.service.ts b/app/resources/reauth/reauth.service.ts index 20e4ceb7ca..b8165ebbf9 100644 --- a/app/resources/reauth/reauth.service.ts +++ b/app/resources/reauth/reauth.service.ts @@ -12,7 +12,9 @@ import type { SessionChecks } from '@/modules/auth/auth-provider'; // and identical at runtime (cookie.ts re-exports it). import { mostRecent, addSession, type SessionEntry } from '@/modules/auth/session/session'; import type { Session } from '@/modules/auth/types'; -import { ProviderError } from '@/modules/auth/types'; +import { ProviderError, isStaleSessionError } from '@/modules/auth/types'; +import { postLoginDestinationWithSource } from '@/resources/login/post-login-destination'; +import { resolveOrg } from '@/resources/shared/resolve-org'; import { validateReturnTo } from '@/resources/shared/return-to'; import { paths } from '@/routes/paths'; import { logAuthEvent, hashActor } from '@/server/observability'; @@ -25,6 +27,10 @@ export interface ReauthLoadInput { /** Request hostname — the FIDO2 relying-party domain for the assertion challenge. */ domain: string; emailDeliveryEnabled: boolean; + /** `${ZITADEL_API_URL}/ui/console` — where instance admins land when returnTo is unset. */ + consoleUrl: string; + /** DEFAULT_APP_URL env — fallback when Zitadel has no default configured. */ + defaultAppUrl?: string; } export type ReauthLoadResult = @@ -38,6 +44,32 @@ export type ReauthLoadResult = returnTo: string; }; +/** + * Same admin console → Zitadel default → env default priority /signed-in uses + * (postLoginDestinationWithSource), falling back to /passkeys only when nothing is + * configured at all. Best-effort: a failed settings/admin-check read degrades to + * the /passkeys fallback rather than failing the whole reauth load. + */ +async function resolveDefaultReturnTo( + provider: AuthProvider, + entry: SessionEntry, + input: Pick +): Promise { + const [settings, isAdmin] = await Promise.all([ + provider + .getLoginSettings(entry.organization ?? (await resolveOrg(provider))) + .catch(() => ({}) as { defaultRedirectUri?: string }), + provider.isInstanceAdmin({ id: entry.id, token: entry.token }).catch(() => false), + ]); + const { dest } = postLoginDestinationWithSource({ + isAdmin, + consoleUrl: input.consoleUrl, + defaultRedirectUri: settings.defaultRedirectUri, + defaultAppUrl: input.defaultAppUrl, + }); + return dest ?? paths.passkeys(); +} + /** * Resolve the reauth view: enrolled-methods chooser (constrained to reauth-capable * factors) or a single-method verify screen. No session → bounce to /login. @@ -53,19 +85,42 @@ export async function loadReauth( const entry = mostRecent(sessions); if (!entry) return { kind: 'redirect', target: paths.login.index() }; - const session = await provider.getSession(entry.id, entry.token); - if (!session) return { kind: 'redirect', target: paths.login.index() }; - - const userId = session.user?.id ?? (await provider.findUser(entry.loginName))?.id; - if (!userId) return { kind: 'redirect', target: paths.login.index() }; + // The stored session token may belong to a DIFFERENT browser/tab than the one hitting this + // route right now, and may be stale or revoked provider-side. getSession/findUser/listAuthMethods + // throw (rather than returning null) when that's the case — treat any non-transient + // ProviderError the same as a null session: bounce to /login instead of crashing the request. + // A genuinely transient error (real backend outage) still propagates. + let userId: string | undefined; + let enrolled: Awaited>; + try { + const session = await provider.getSession(entry.id, entry.token); + if (!session) return { kind: 'redirect', target: paths.login.index() }; + userId = session.user?.id ?? (await provider.findUser(entry.loginName))?.id; + if (!userId) return { kind: 'redirect', target: paths.login.index() }; + enrolled = await provider.listAuthMethods(userId); + } catch (err) { + if (isStaleSessionError(err)) { + logAuthEvent('reauth', 'failure', { + actor: hashActor(entry.loginName), + reason: err instanceof ProviderError ? err.code : 'UNKNOWN', + }); + return { kind: 'redirect', target: paths.login.index() }; + } + throw err; + } - const enrolled = await provider.listAuthMethods(userId); const methods: ReauthMethod[] = []; if (enrolled.includes('passkey') && provider.capabilities.passkey) methods.push('passkey'); if (enrolled.includes('password')) methods.push('password'); if (enrolled.includes('otp_email') && input.emailDeliveryEnabled) methods.push('otp_email'); - const returnTo = validateReturnTo(input.returnTo) ?? paths.passkeys(); + // No caller-supplied returnTo (or a tampered one) — resolve the SAME post-login + // destination /signed-in uses (admin console → Zitadel default → env default), + // rather than blindly landing every reauth on /passkeys regardless of which flow + // sent the user here. Only done when actually needed — the common case (an explicit + // returnTo from the caller) skips these extra provider calls entirely. + const returnTo = + validateReturnTo(input.returnTo) ?? (await resolveDefaultReturnTo(provider, entry, input)); let publicKeyCredentialRequestOptions: unknown = null; if (input.method === 'passkey') { diff --git a/app/routes/device/authorize.tsx b/app/routes/device/authorize.tsx index 59836c47f1..b31aaceb30 100644 --- a/app/routes/device/authorize.tsx +++ b/app/routes/device/authorize.tsx @@ -119,7 +119,7 @@ export default function DeviceAuthorize() { Authorizing as} - linkLabel={Use a different account} + linkLabel={Not you?} linkTarget={paths.accounts({ user_code: userCode })} /> ) : ( diff --git a/app/routes/login/index.tsx b/app/routes/login/index.tsx index 0094af8af7..e6a2b60dd7 100644 --- a/app/routes/login/index.tsx +++ b/app/routes/login/index.tsx @@ -423,7 +423,7 @@ export default function Login() { theme="outline" block htmlType="button" - disabled={ceremonyBusy} + loading={ceremonyBusy} onClick={() => ceremony.begin()}> Continue with passkey @@ -439,6 +439,7 @@ export default function Login() { submittingIdpId={submittingIdpId} relative lastUsedLogin={lastUsedLogin} + disabled={ceremonyBusy} /> ) : null} @@ -454,7 +455,7 @@ export default function Login() { theme="outline" block htmlType="button" - disabled={ceremonyBusy} + loading={ceremonyBusy} onClick={() => ceremony.begin()} iconPosition="left" icon={}> @@ -474,6 +475,7 @@ export default function Login() { type="quaternary" theme="outline" block + disabled={ceremonyBusy} iconPosition="left" icon={} onClick={() => setShowEmailField(true)}> @@ -507,7 +509,7 @@ export default function Login() { className="h-9" /> - + Continue {view.showEmailLink ? ( @@ -515,8 +517,8 @@ export default function Login() { type="submit" name="intent" value="email-link" - className="text-foreground/70 hover:text-foreground mt-1 w-full text-center text-sm underline underline-offset-2 transition-colors" - disabled={navigation.state !== 'idle'}> + className="text-foreground/70 hover:text-foreground mt-1 w-full text-center text-sm underline underline-offset-2 transition-colors disabled:cursor-not-allowed disabled:opacity-50" + disabled={navigation.state !== 'idle' || ceremonyBusy}> Email me a sign-in link ) : null} diff --git a/app/routes/login/method.tsx b/app/routes/login/method.tsx index 1777de8405..2590892e83 100644 --- a/app/routes/login/method.tsx +++ b/app/routes/login/method.tsx @@ -13,6 +13,7 @@ import { providerForRequest } from '@/server/auth-context.server'; import { env } from '@/server/infra/env.server'; import { Button, LinkButton } from '@datum-cloud/datum-ui/button'; import { Icon } from '@datum-cloud/datum-ui/icons'; +import { cn } from '@datum-cloud/datum-ui/utils'; import { Trans } from '@lingui/react/macro'; import { Key, Lock, Mail, UserCircle } from 'lucide-react'; import { redirect, useLoaderData, type LoaderFunctionArgs, type MetaFunction } from 'react-router'; @@ -119,7 +120,7 @@ export default function LoginMethod() { theme="outline" block htmlType="button" - disabled={passkeyBusy} + loading={passkeyBusy} onClick={() => ceremony.begin()} iconPosition="left" icon={}> @@ -130,12 +131,14 @@ export default function LoginMethod() { {methods.includes('otp_email') ? ( passkeyBusy && e.preventDefault()} iconPosition="left" icon={}> Email me a sign-in link @@ -145,12 +148,14 @@ export default function LoginMethod() { {methods.includes('password') ? ( passkeyBusy && e.preventDefault()} iconPosition="left" icon={}> Password @@ -160,12 +165,14 @@ export default function LoginMethod() { {methods.includes('idp') ? ( passkeyBusy && e.preventDefault()} iconPosition="left" icon={}> Continue with your provider diff --git a/app/routes/passkeys.tsx b/app/routes/passkeys.tsx index 686c229884..5f820aee9a 100644 --- a/app/routes/passkeys.tsx +++ b/app/routes/passkeys.tsx @@ -225,7 +225,7 @@ export default function Passkeys() { Logged in as} - linkLabel={Use a different account} + linkLabel={Not you?} linkTarget={paths.accounts()} /> )} diff --git a/app/routes/reauth.tsx b/app/routes/reauth.tsx index b447220e76..4376918f81 100644 --- a/app/routes/reauth.tsx +++ b/app/routes/reauth.tsx @@ -7,8 +7,10 @@ import { AuthCard } from '@/components/auth-card/auth-card'; import { SubmitButton } from '@/components/auth-form/auth-form'; import { AuthFormFields } from '@/components/auth-form/auth-form-fields'; import { FormError } from '@/components/form-error/form-error'; -import { WebAuthnButton } from '@/components/webauthn-button/webauthn-button'; +import { IdentityBadge } from '@/components/identity-badge/identity-badge'; +import { WebAuthnButton, WebAuthnReasonCopy } from '@/components/webauthn-button/webauthn-button'; import { useAuthActionError } from '@/hooks/use-auth-action-error'; +import { usePasskeyReauthCeremony } from '@/hooks/use-passkey-reauth-ceremony'; import { readSessions, serializeSessions } from '@/modules/auth/session/cookie'; import { loadReauth, @@ -21,9 +23,10 @@ import { providerForRequest } from '@/server/auth-context.server'; import { getCsrfToken, assertCsrf } from '@/server/csrf'; import { env } from '@/server/infra/env.server'; import { actionError } from '@/utils/errors/auth-error'; -import { LinkButton } from '@datum-cloud/datum-ui/button'; +import { Button, LinkButton } from '@datum-cloud/datum-ui/button'; import { Form } from '@datum-cloud/datum-ui/form'; import { Icon } from '@datum-cloud/datum-ui/icons'; +import { cn } from '@datum-cloud/datum-ui/utils'; import { Trans, useLingui } from '@lingui/react/macro'; import { Key, Lock, Mail } from 'lucide-react'; import { useRef } from 'react'; @@ -46,7 +49,7 @@ const METHOD_PARAMS = ['passkey', 'password', 'otp_email'] as const; type ReauthView = Extract; -interface ReauthLoaderData { +export interface ReauthLoaderData { csrfToken: string; view: ReauthView; } @@ -66,6 +69,8 @@ export async function loader({ request }: LoaderFunctionArgs) { method, domain: url.hostname, emailDeliveryEnabled: env.AUTH_EMAIL_DELIVERY_ENABLED, + consoleUrl: `${env.ZITADEL_API_URL}/ui/console`, + defaultAppUrl: env.DEFAULT_APP_URL, }); if (result.kind === 'redirect') return redirect(result.target); @@ -113,27 +118,51 @@ export async function action({ request }: ActionFunctionArgs) { } } +// The in-place passkey ceremony (usePasskeyReauthCeremony) loads THIS route's own +// loader (?method=passkey) for a challenge via fetcher, then submits the assertion +// to THIS route's action. RR's default post-submit revalidation would re-run the +// loader and mint a FRESH WebAuthn challenge on the Zitadel session, invalidating +// the just-signed assertion mid-flight (same class of bug as WEBAU-3M9si in +// login/passkey.tsx). Suppress ONLY that self-triggered revalidation — the hook +// tags its submit with passkeyCeremony. A full-page visit or retry (no marker) +// revalidates normally, so it always renders a fresh challenge. +export function shouldRevalidate({ + formData, + defaultShouldRevalidate, +}: { + formData?: FormData; + defaultShouldRevalidate: boolean; +}) { + if (formData?.get('passkeyCeremony') === '1') return false; + return defaultShouldRevalidate; +} + // ── chooser row ──────────────────────────────────────────────────────────────── function MethodRow({ href, icon, children, + disabled, }: { href: string; icon: React.ReactNode; children: React.ReactNode; + /** Inert while a sibling ceremony (the Passkey row) is busy. */ + disabled?: boolean; }) { // LinkButton (single styled ) — NOT Button asChild (nested-interactive axe violation). return ( disabled && e.preventDefault()} iconPosition="left" icon={icon}> {children} @@ -149,7 +178,13 @@ export default function Reauth() { const formRef = useRef(null); const errorMessage = useAuthActionError(actionData); - const { methods, method, returnTo, publicKeyCredentialRequestOptions } = view; + const { loginName, methods, method, returnTo, publicKeyCredentialRequestOptions } = view; + + // In-place passkey ceremony — fires from the chooser (method === null) without + // navigating to the two-step ?method=passkey verify screen below. + const passkeyCeremony = usePasskeyReauthCeremony({ returnTo }); + const passkeyCeremonyError = useAuthActionError(passkeyCeremony.actionData); + const passkeyBusy = passkeyCeremony.phase !== 'idle'; // Extract the inner publicKey object that marshalAssertion expects. const publicKey = @@ -163,28 +198,58 @@ export default function Reauth() { Confirm it's you} description={ - For your security, verify one of your sign-in methods to continue. + <> + For your security, verify one of your sign-in methods to continue. + {loginName && ( + Logged in as} + linkLabel={Not you?} + linkTarget={paths.accounts()} + /> + )} + }> {method === null ? (
+ {passkeyCeremony.reason ? ( + + + + ) : passkeyCeremonyError ? ( + {passkeyCeremonyError} + ) : null} {methods.includes('passkey') ? ( - passkeyCeremony.begin()} + iconPosition="left" icon={}> Passkey - + ) : null} {methods.includes('password') ? ( }> + icon={} + disabled={passkeyBusy}> Password ) : null} {methods.includes('otp_email') ? ( }> + icon={} + disabled={passkeyBusy}> Email me a code ) : null} diff --git a/app/routes/signed-in.tsx b/app/routes/signed-in.tsx index d4874a0bbe..3fc03138f5 100644 --- a/app/routes/signed-in.tsx +++ b/app/routes/signed-in.tsx @@ -46,7 +46,7 @@ export default function SignedIn() { You are signed in as} - linkLabel={Use a different account} + linkLabel={Not you?} linkTarget={paths.accounts()} /> ) : null diff --git a/app/routes/sso/index.tsx b/app/routes/sso/index.tsx index 047aaadee9..2da1c09fc7 100644 --- a/app/routes/sso/index.tsx +++ b/app/routes/sso/index.tsx @@ -135,7 +135,7 @@ export default function SsoPage() { Logged in as} - linkLabel={Use a different account} + linkLabel={Not you?} linkTarget={paths.accounts()} /> )} diff --git a/cypress/component/resources/passkeys/passkeys.service.cy.ts b/cypress/component/resources/passkeys/passkeys.service.cy.ts index 47059e6a11..da3e757caa 100644 --- a/cypress/component/resources/passkeys/passkeys.service.cy.ts +++ b/cypress/component/resources/passkeys/passkeys.service.cy.ts @@ -56,6 +56,16 @@ describe('passkeys.service — /id/passkeys management', () => { } }); + it('a stale/revoked session token (getSession throws PERMISSION_DENIED) recovers to /login, not a crash', async () => { + // Mirrors the reauth.service.ts fix: a stored session token from a different browser/tab + // may be stale or revoked provider-side, and the real Zitadel backend throws + // PERMISSION_DENIED on getSession instead of returning null. + const { fake, sessions } = await seeded(); + fake.setSessionResult(sessions[0].id, { mode: 'throw', code: 'PERMISSION_DENIED' }); + const v = await loadPasskeysView(fake, sessions, { returnTo: '/passkeys', nowMs: Date.now() }); + expect(v).to.deep.equal({ kind: 'redirect', target: '/login' }); + }); + it('stale sudo ⇒ loader redirects to /reauth AND removeUserPasskey refuses server-side', async () => { const { fake, sessions } = await seeded(); const staleNow = Date.now() + SUDO_TTL_MS + 1; diff --git a/cypress/component/resources/reauth/reauth.service.cy.ts b/cypress/component/resources/reauth/reauth.service.cy.ts index afb3f1cf9d..0e66cfb235 100644 --- a/cypress/component/resources/reauth/reauth.service.cy.ts +++ b/cypress/component/resources/reauth/reauth.service.cy.ts @@ -36,6 +36,7 @@ describe('reauth.service — verify one factor onto the EXISTING session', () => method: null, domain: 'localhost', emailDeliveryEnabled: false, + consoleUrl: 'https://console.acme.test', }); expect(v.kind).to.equal('view'); if (v.kind === 'view') { @@ -43,6 +44,37 @@ describe('reauth.service — verify one factor onto the EXISTING session', () => expect(v.returnTo).to.equal('/passkeys'); } }); + + it('loadReauth falls back to the Zitadel-configured default destination when returnTo is absent', async () => { + // Mirrors /signed-in's own fallback priority (admin console → Zitadel default → + // env default → /passkeys) — reauth is reached from multiple flows (passkeys, + // sso, ...), so a caller-less visit shouldn't blindly land on /passkeys. + const { fake, sessions } = await seeded(); + fake.setLoginDefaultRedirectUri('https://app.acme.test/dashboard'); + const v = await loadReauth(fake, sessions, { + returnTo: null, + method: null, + domain: 'localhost', + emailDeliveryEnabled: false, + consoleUrl: 'https://console.acme.test', + }); + expect(v.kind).to.equal('view'); + if (v.kind === 'view') expect(v.returnTo).to.equal('https://app.acme.test/dashboard'); + }); + + it('loadReauth falls back to /passkeys when returnTo is absent AND nothing is configured', async () => { + const { fake, sessions } = await seeded(); + const v = await loadReauth(fake, sessions, { + returnTo: null, + method: null, + domain: 'localhost', + emailDeliveryEnabled: false, + consoleUrl: 'https://console.acme.test', + }); + expect(v.kind).to.equal('view'); + if (v.kind === 'view') expect(v.returnTo).to.equal('/passkeys'); + }); + it('performReauth(password) updates the SAME session id, rotates the token, and targets returnTo', async () => { const { fake, sessions } = await seeded(); const r = await performReauth(fake, sessions, { @@ -71,4 +103,38 @@ describe('reauth.service — verify one factor onto the EXISTING session', () => }); expect(dead).to.deep.equal({ ok: false, error: 'SESSION_EXPIRED' }); }); + + it('loadReauth recovers to /login (not a crash) when getSession throws a non-transient ProviderError', async () => { + // Reproduces the live bug: a stored session token from a DIFFERENT browser/tab is stale or + // revoked, and the real Zitadel backend throws PERMISSION_DENIED on getSession instead of + // returning null. Before the fix this propagated uncaught and 500'd the whole request. + const { fake, sessions } = await seeded(); + fake.setSessionResult(sessions[0].id, { mode: 'throw', code: 'PERMISSION_DENIED' }); + const v = await loadReauth(fake, sessions, { + returnTo: '/passkeys', + method: 'passkey', + domain: 'localhost', + emailDeliveryEnabled: false, + consoleUrl: 'https://console.acme.test', + }); + expect(v).to.deep.equal({ kind: 'redirect', target: '/login' }); + }); + + it('loadReauth re-throws a genuinely transient ProviderError (real outage) instead of masking it', async () => { + const { fake, sessions } = await seeded(); + fake.setSessionResult(sessions[0].id, { mode: 'throw', code: 'UNAVAILABLE' }); + let threw: unknown; + try { + await loadReauth(fake, sessions, { + returnTo: '/passkeys', + method: null, + domain: 'localhost', + emailDeliveryEnabled: false, + consoleUrl: 'https://console.acme.test', + }); + } catch (err) { + threw = err; + } + expect(threw).to.exist; + }); }); diff --git a/cypress/component/routes/device/authorize-identity.cy.tsx b/cypress/component/routes/device/authorize-identity.cy.tsx index 674dc83ff7..08f869ebf7 100644 --- a/cypress/component/routes/device/authorize-identity.cy.tsx +++ b/cypress/component/routes/device/authorize-identity.cy.tsx @@ -1,6 +1,6 @@ // cypress/component/routes/device/authorize-identity.cy.tsx // -// Pins the "Authorizing as X — Use a different account" identity row now rendered +// Pins the "Authorizing as X — Not you?" identity row now rendered // through the shared IdentityBadge component instead of bespoke flex markup. import DeviceAuthorize from '@/routes/device/authorize'; import { ConformAdapter } from '@datum-cloud/datum-ui/form/adapters/conform'; @@ -43,11 +43,11 @@ function mountAuthorize() { } describe('/device/authorize — identity via shared IdentityBadge', () => { - it('shows "Authorizing as " with a "Use a different account" link to /accounts?user_code=...', () => { + it('shows "Authorizing as " with a "Not you?" link to /accounts?user_code=...', () => { mountAuthorize(); cy.contains('Authorizing as').should('be.visible'); cy.contains(LOADER_DATA.activeLoginName).should('be.visible'); - cy.findByRole('link', { name: /use a different account/i }) + cy.findByRole('link', { name: /not you\?/i }) .should('have.attr', 'href') .and('include', '/accounts') .and('include', 'user_code=ABC123'); diff --git a/cypress/component/routes/login/index.cy.tsx b/cypress/component/routes/login/index.cy.tsx index 0a6bed7c25..a41bb772c8 100644 --- a/cypress/component/routes/login/index.cy.tsx +++ b/cypress/component/routes/login/index.cy.tsx @@ -88,7 +88,12 @@ function mountLogin(opts?: { opts?.passkeyAction ?? (async ({ request }: { request: Request }) => { capturedPosts.push(Object.fromEntries(await request.formData())); - return null; // surface stays; redirect-following is RR-internal, not under test + // Truthy (not null) — mirrors a real completed action (redirect navigates away; + // a rejection returns a truthy {error} object). A falsy return would leave the + // ceremony's busy-until-idle effect (submitFetcher.data truthiness) stuck forever, + // which is never reachable in production but would wrongly keep sibling controls + // disabled here since redirect-following is RR-internal and not under test. + return {}; }), }, ], diff --git a/cypress/component/routes/login/method.cy.tsx b/cypress/component/routes/login/method.cy.tsx index 4897b9d3eb..76bd6bc736 100644 --- a/cypress/component/routes/login/method.cy.tsx +++ b/cypress/component/routes/login/method.cy.tsx @@ -61,7 +61,10 @@ function mountMethod() { }), action: async ({ request }: { request: Request }) => { capturedPosts.push(Object.fromEntries(await request.formData())); - return null; // surface stays; redirect-following is RR-internal, not under test + // Truthy (not null) — mirrors a real completed action so the ceremony's + // busy-until-idle effect doesn't stay stuck (see index.cy.tsx for the failure + // this caused once sibling controls started disabling on ceremony.phase). + return {}; }, }, ], diff --git a/cypress/component/routes/passkeys-ui.cy.tsx b/cypress/component/routes/passkeys-ui.cy.tsx index c9261433af..e9c90b3eec 100644 --- a/cypress/component/routes/passkeys-ui.cy.tsx +++ b/cypress/component/routes/passkeys-ui.cy.tsx @@ -119,15 +119,11 @@ describe('/id/passkeys — UI contract', () => { cy.get('ul').should('not.exist'); }); - it('shows the active login name, a "Use a different account" switch link, and a sign-out action', () => { + it('shows the active login name, a "Not you?" switch link, and a sign-out action', () => { mountPasskeys(); cy.contains('Logged in as').should('be.visible'); cy.contains('mia@acme.test').should('be.visible'); - cy.findByRole('link', { name: /use a different account/i }).should( - 'have.attr', - 'href', - '/accounts' - ); + cy.findByRole('link', { name: /not you\?/i }).should('have.attr', 'href', '/accounts'); cy.contains('button', 'Sign out').should('be.visible'); }); diff --git a/cypress/component/routes/reauth.cy.tsx b/cypress/component/routes/reauth.cy.tsx new file mode 100644 index 0000000000..0d94d1780d --- /dev/null +++ b/cypress/component/routes/reauth.cy.tsx @@ -0,0 +1,143 @@ +// cypress/component/routes/reauth.cy.tsx +// +// UI contract for /reauth's Passkey entry: firing usePasskeyReauthCeremony IN PLACE +// (a Button that lazily loads this route's own ?method=passkey challenge and submits +// the pre-baked Cypress credential) instead of navigating to the two-step +// chooser → "Verify with passkey" flow. Mirrors login/method.cy.tsx. +import Reauth from '@/routes/reauth'; +import { ConformAdapter } from '@datum-cloud/datum-ui/form/adapters/conform'; +import { setupI18n } from '@lingui/core'; +import { I18nProvider } from '@lingui/react'; +import { mount } from 'cypress/react'; +import { createMemoryRouter, RouterProvider } from 'react-router'; + +const CHOOSER_VIEW = { + kind: 'view' as const, + loginName: 'mia@acme.test', + methods: ['passkey', 'password'] as const, + method: null, + publicKeyCredentialRequestOptions: null, + returnTo: '/passkeys', +}; + +const capturedPosts: Array> = []; +// Set by mountReauth({ deferChallenge: true }) — lets a test freeze mid-ceremony +// (phase === 'loading-challenge') to assert the busy/disabled UI deterministically, +// then release it to let the ceremony proceed. Avoids an arbitrary-timeout wait. +let releaseChallenge: (() => void) | null = null; + +function withI18n(node: React.ReactNode) { + const i18n = setupI18n({ locale: 'en', messages: { en: {} } }); + return ( + + {node} + + ); +} + +function mountReauth(opts?: { deferChallenge?: boolean }) { + const router = createMemoryRouter( + [ + { + id: 'reauth', + path: '/reauth', + element: , + // Same route serves both the chooser (?method absent) AND the lazily-loaded + // passkey challenge (?method=passkey) — the ceremony hook fetcher.load()s the + // latter without navigating away from the former. + loader: async ({ request }: { request: Request }) => { + const method = new URL(request.url).searchParams.get('method'); + if (method === 'passkey') { + if (opts?.deferChallenge) { + await new Promise((resolve) => { + releaseChallenge = resolve; + }); + } + return { + csrfToken: 'tok-1', + view: { + ...CHOOSER_VIEW, + method: 'passkey' as const, + publicKeyCredentialRequestOptions: { publicKey: { challenge: 'x' } }, + }, + }; + } + return { csrfToken: 'tok-1', view: CHOOSER_VIEW }; + }, + action: async ({ request }: { request: Request }) => { + capturedPosts.push(Object.fromEntries(await request.formData())); + // Truthy (not null) — mirrors a real completed action so the ceremony's + // busy-until-idle effect doesn't stay stuck (see login/index.cy.tsx for the + // failure this caused once sibling controls started disabling on ceremony.phase). + return {}; + }, + }, + ], + { + initialEntries: ['/reauth?returnTo=/passkeys'], + hydrationData: { + loaderData: { reauth: { csrfToken: 'tok-1', view: CHOOSER_VIEW } }, + }, + } + ); + return mount(withI18n()); +} + +describe('/reauth — in-place passkey ceremony', () => { + beforeEach(() => { + capturedPosts.length = 0; + releaseChallenge = null; + }); + + it('shows the active identity with a "Not you?" link to /accounts', () => { + mountReauth(); + cy.contains('Logged in as').should('be.visible'); + cy.contains(CHOOSER_VIEW.loginName).should('be.visible'); + cy.contains('a', 'Not you?').should('have.attr', 'href', '/accounts'); + }); + + it('lists Passkey and Password as chooser entries', () => { + mountReauth(); + cy.contains("Confirm it's you").should('be.visible'); + cy.contains('button', 'Passkey').should('be.visible'); + cy.contains('a', 'Password').should('be.visible'); + }); + + it('Passkey fires the ceremony in place and submits the pre-baked credential', () => { + mountReauth(); + cy.contains('button', 'Passkey').click(); + // Lazy challenge (fetcher.load ?method=passkey) → pre-baked credential → POST. + cy.wrap(null).should(() => { + expect(capturedPosts).to.have.length(1); + expect(capturedPosts[0].factor).to.equal('passkey'); + expect(capturedPosts[0].returnTo).to.equal('/passkeys'); + expect(capturedPosts[0].passkeyCeremony).to.equal('1'); + expect(JSON.parse(String(capturedPosts[0].credential)).id).to.equal('fake-credential-id'); + }); + // No navigation happened — the chooser is still on screen as the fallback. + // Password stays a plain link (unchanged), so it's matched by its tag. + cy.contains('a', 'Password').should('be.visible'); + }); + + it('shows a busy/loading Passkey button and disables the Password sibling until the challenge resolves', () => { + mountReauth({ deferChallenge: true }); + cy.contains('button', 'Passkey').click(); + + // Mid-flight (phase === 'loading-challenge', frozen by the deferred loader): the + // Passkey button is disabled (loading=true implies disabled — datum-ui Button) and + // the Password row is rendered inert (aria-disabled + pointer-events-none), not just + // visually dimmed — a real click must not navigate it away mid-ceremony. + cy.contains('button', 'Passkey').should('be.disabled'); + cy.contains('a', 'Password').should('have.attr', 'aria-disabled', 'true'); + cy.contains('a', 'Password').click({ force: true }); + cy.wrap(null).should(() => expect(capturedPosts).to.have.length(0)); + + // Release the deferred challenge — the ceremony completes normally. + cy.then(() => releaseChallenge?.()); + cy.wrap(null).should(() => expect(capturedPosts).to.have.length(1)); + + // Busy state clears once the ceremony finishes; Password is interactive again. + cy.contains('button', 'Passkey').should('not.be.disabled'); + cy.contains('a', 'Password').should('have.attr', 'aria-disabled', 'false'); + }); +}); diff --git a/cypress/component/routes/setup/setup-render.cy.tsx b/cypress/component/routes/setup/setup-render.cy.tsx index 54cf4574a9..f820d8fb87 100644 --- a/cypress/component/routes/setup/setup-render.cy.tsx +++ b/cypress/component/routes/setup/setup-render.cy.tsx @@ -116,4 +116,41 @@ describe('setup/* — BackLink renders to the predecessor', () => { mountRoute(SetupMfa, 'mfa', { ...IDENTITY, offerableKeys: ['passkey'] }); cy.get('a[href*="/login/password"]').should('not.exist'); }); + + it('clicking Back on /setup/passkey does not disable "Register passkey" while the predecessor loads', () => { + // Regression: WebAuthnButton's busy state defaulted to navigation.state !== 'idle', + // which is also true for an UNRELATED Link navigation elsewhere on the page (the + // BackLink). Registers a real /setup/mfa route whose loader never resolves, so the + // Back navigation stays pending — proving the CURRENT page's WebAuthnButton stays + // interactive throughout, since it has nothing to do with that click. + const router = createMemoryRouter( + [ + { id: 'setup-passkey', path: '/setup/passkey', element: }, + { + id: 'setup-mfa', + path: '/setup/mfa', + element: , + loader: () => new Promise(() => {}), + }, + ], + { + initialEntries: ['/setup/passkey'], + hydrationData: { + loaderData: { + 'setup-passkey': { + ...IDENTITY, + credentialId: 'pk1', + publicKey: null, + challengeFailed: false, + }, + }, + }, + } + ); + mount(withProviders()); + + cy.contains('button', 'Register passkey').should('not.be.disabled'); + cy.contains('a', 'Back').click(); + cy.contains('button', 'Register passkey').should('not.be.disabled'); + }); }); diff --git a/cypress/component/routes/signed-in.cy.tsx b/cypress/component/routes/signed-in.cy.tsx index 1b71b51bcc..cd487c0b52 100644 --- a/cypress/component/routes/signed-in.cy.tsx +++ b/cypress/component/routes/signed-in.cy.tsx @@ -1,7 +1,7 @@ // cypress/component/routes/signed-in.cy.tsx // // /signed-in previously showed loginName as bare text with no switch-account -// affordance at all. Adds "Use a different account" (mirrors device/authorize.tsx). +// affordance at all. Adds "Not you?" (mirrors device/authorize.tsx). import SignedIn from '@/routes/signed-in'; import { ConformAdapter } from '@datum-cloud/datum-ui/form/adapters/conform'; import { setupI18n } from '@lingui/core'; @@ -34,15 +34,11 @@ function mountSignedIn(loginName: string | null = 'mia@acme.test') { } describe('/signed-in — identity + switch-account link + sign-out', () => { - it('shows "You are signed in as " with a "Use a different account" link to /accounts', () => { + it('shows "You are signed in as " with a "Not you?" link to /accounts', () => { mountSignedIn(); cy.contains('You are signed in as').should('be.visible'); cy.contains('mia@acme.test').should('be.visible'); - cy.findByRole('link', { name: /use a different account/i }).should( - 'have.attr', - 'href', - '/accounts' - ); + cy.findByRole('link', { name: /not you\?/i }).should('have.attr', 'href', '/accounts'); }); it('the Sign out form posts to /id/logout?index', () => { diff --git a/cypress/component/routes/sso/sso-render.cy.tsx b/cypress/component/routes/sso/sso-render.cy.tsx index 08e699c6fa..2659f8c79b 100644 --- a/cypress/component/routes/sso/sso-render.cy.tsx +++ b/cypress/component/routes/sso/sso-render.cy.tsx @@ -117,15 +117,11 @@ describe('SsoIndex — unlink guard: dialog confirm + disabled sole sign-in meth cy.get('button[type="submit"]').contains('Unlink').should('exist').and('not.be.disabled'); }); - it('shows the active login name with a "Use a different account" switch link and a Sign out control', () => { + it('shows the active login name with a "Not you?" switch link and a Sign out control', () => { mountRoute(SsoIndex, 'sso-index', '/sso', '/sso', loaderData); cy.contains('Logged in as').should('exist'); cy.contains(loaderData.loginName).should('exist'); - cy.findByRole('link', { name: /use a different account/i }).should( - 'have.attr', - 'href', - '/accounts' - ); + cy.findByRole('link', { name: /not you\?/i }).should('have.attr', 'href', '/accounts'); cy.get('form[action="/id/logout?index"]').contains('button', 'Sign out').should('exist'); }); }); diff --git a/cypress/e2e/reauth.cy.ts b/cypress/e2e/reauth.cy.ts index d7840d0791..423f6af13d 100644 --- a/cypress/e2e/reauth.cy.ts +++ b/cypress/e2e/reauth.cy.ts @@ -34,10 +34,26 @@ describe('/id/reauth — verify one enrolled factor onto the existing session', it('lists Passkey for a passkey-enrolled user', () => { loginAndGetSession('passkey-user@acme.test'); cy.visit('/id/reauth?returnTo=/passkeys'); - cy.contains('a', 'Passkey').should('be.visible'); + cy.contains('button', 'Passkey').should('be.visible'); cy.contains('a', 'Password').should('be.visible'); }); + it('Passkey completes the ceremony in place with a single click (no ?method=passkey navigation)', () => { + loginAndGetSession('passkey-user@acme.test'); + cy.visit('/id/reauth?returnTo=/passkeys', { + onBeforeLoad: (win) => { + win.__CYPRESS_HYDRATE__ = true; // ceremony fetcher needs JS + }, + }); + cy.settleHydration(); + + cy.contains('button', 'Passkey').click(); + + // Success → redirect straight to the validated returnTo. A single click, never + // a navigation to ?method=passkey / the old "Verify with passkey" second step. + cy.location('pathname').should('eq', '/id/passkeys'); + }); + it('falls back to /id/passkeys when returnTo is tampered (//evil.test)', () => { loginAndGetSession('alice@acme.test'); From baa98a29d722875057ce2af8b6cdec259e907580 Mon Sep 17 00:00:00 2001 From: Yahya Fakhroji Date: Mon, 27 Jul 2026 19:31:33 +0700 Subject: [PATCH 4/9] feat(reauth): IdP-based sudo re-verification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a fourth reauth factor: re-verifying the active session via one of the user's linked (non-LDAP) external IdPs, mirroring the login-side IdP flow but pointed at dedicated /reauth/:provider/callback|error routes so it can never create a session or sign in as a different identity — it only verifies the idpIntent onto the EXISTING session. --- .../auth/providers/fake/fake-provider.ts | 28 ++++ app/modules/i18n/locales/en.po | 39 +++-- app/resources/reauth/reauth.service.ts | 109 +++++++++++++- app/routes.ts | 2 + app/routes/paths.ts | 6 + app/routes/reauth.tsx | 60 +++++++- app/routes/reauth/provider/callback.tsx | 46 ++++++ app/routes/reauth/provider/error.tsx | 43 ++++++ .../fake/fake-provider-idp-intent.cy.ts | 61 ++++++++ .../resources/reauth/reauth-idp-intent.cy.ts | 57 ++++++++ .../resources/reauth/reauth.service.cy.ts | 136 ++++++++++++++++++ cypress/component/routes/reauth.cy.tsx | 53 +++++++ .../routes/reauth/provider-callback.cy.tsx | 80 +++++++++++ .../routes/reauth/provider-error.cy.tsx | 36 +++++ cypress/support/audit-coverage.ts | 2 + cypress/support/ceremony-guard.ts | 8 ++ cypress/support/node/harness.ts | 24 ++++ cypress/support/node/scenario.ts | 8 ++ 18 files changed, 780 insertions(+), 18 deletions(-) create mode 100644 app/routes/reauth/provider/callback.tsx create mode 100644 app/routes/reauth/provider/error.tsx create mode 100644 cypress/component/modules/auth/providers/fake/fake-provider-idp-intent.cy.ts create mode 100644 cypress/component/resources/reauth/reauth-idp-intent.cy.ts create mode 100644 cypress/component/routes/reauth/provider-callback.cy.tsx create mode 100644 cypress/component/routes/reauth/provider-error.cy.tsx diff --git a/app/modules/auth/providers/fake/fake-provider.ts b/app/modules/auth/providers/fake/fake-provider.ts index b1e7125fe3..5c0fdb9651 100644 --- a/app/modules/auth/providers/fake/fake-provider.ts +++ b/app/modules/auth/providers/fake/fake-provider.ts @@ -515,6 +515,25 @@ export class FakeAuthProvider implements AuthProvider { }; } + if (checks.idpIntent !== undefined) { + // Mirrors the password branch's fidelity note: real Zitadel checks the session's + // OWN user; the users[0] fallback exists for single-user seeds. Multi-user + // fixtures MUST pass metadata.userId (via createSession's opts.userId) or the + // check silently targets the first seeded user. + const sessionUserId = (s.user ?? this.users[0])?.id; + const intent = this.idpIntents[checks.idpIntent.idpIntentId]; + if (!intent || intent.userId !== sessionUserId) { + // Matches real Zitadel's actual code/message for this exact case (observed in + // production logs) — NOT INVALID_CREDENTIALS, which performReauth's catch used to + // assume and therefore let this case fall through uncaught. + throw new ProviderError('FAILED_PRECONDITION', 'Intent meant for another user'); + } + updated = { + ...updated, + factors: { ...updated.factors, idpIntent: { verifiedAt: this.stamp() } }, + }; + } + // P5 — challenge requests: ride back on the returned session copy, NOT persisted. // Early return intentionally skips factor-check persistence when a challenge is requested; // no current caller combines challenge + factor checks in the same call, and Zitadel @@ -770,6 +789,15 @@ export class FakeAuthProvider implements AuthProvider { setLoginDefaultRedirectUri(uri: string | undefined): void { this.loginDefaultRedirectUri = uri; } + // Override the active-IdP list getActiveIdPs returns (P4 seed default: seed.idps ?? []). + setActiveIdPs(list: IdProvider[]): void { + this.idps = list; + } + // Overwrite (not append) a user's linked-IdP rows — a deterministic seam for reauth/sso + // specs that need a specific link set without going through addIdpLink's upsert-by-idpId. + setIdpLinks(userId: string, links: IdpLink[]): void { + this.idpLinks.set(userId, links); + } // Override the instance Default Organization getDefaultOrg returns (null ⇒ no default org). setDefaultOrg(id: string | null): void { this.defaultOrgId = id; diff --git a/app/modules/i18n/locales/en.po b/app/modules/i18n/locales/en.po index 4c8b4f11cf..bf5fa975ed 100644 --- a/app/modules/i18n/locales/en.po +++ b/app/modules/i18n/locales/en.po @@ -137,6 +137,7 @@ msgstr "Available accounts to link" msgid "Back" msgstr "Back" +#: app/routes/reauth/provider/error.tsx:39 #: app/routes/sso/provider/error.tsx:77 msgid "Back to sign in" msgstr "Back to sign in" @@ -188,12 +189,12 @@ msgstr "Choose your login method" msgid "Code expired" msgstr "Code expired" -#: app/routes/reauth.tsx:288 -#: app/routes/reauth.tsx:310 +#: app/routes/reauth.tsx:344 +#: app/routes/reauth.tsx:366 msgid "Confirm" msgstr "Confirm" -#: app/routes/reauth.tsx:199 +#: app/routes/reauth.tsx:234 msgid "Confirm it's you" msgstr "Confirm it's you" @@ -216,6 +217,11 @@ msgstr "Connected accounts" msgid "Continue" msgstr "Continue" +#. placeholder {0}: idp.name ?? idp.idpId +#: app/routes/reauth.tsx:292 +msgid "Continue with {0}" +msgstr "Continue with {0}" + #: app/routes/login/index.tsx:428 msgid "Continue with passkey" msgstr "Continue with passkey" @@ -236,6 +242,10 @@ msgstr "Couldn't complete sign-in. Return to your application and try again." msgid "Couldn't sign in" msgstr "Couldn't sign in" +#: app/routes/reauth/provider/error.tsx:32 +msgid "Couldn't verify" +msgstr "Couldn't verify" + #: app/routes/signup/index.tsx:216 msgid "Create a new account" msgstr "Create a new account" @@ -278,7 +288,7 @@ msgstr "Email" msgid "Email code" msgstr "Email code" -#: app/routes/reauth.tsx:253 +#: app/routes/reauth.tsx:309 msgid "Email me a code" msgstr "Email me a code" @@ -357,7 +367,7 @@ msgstr "Enter your SMS code" msgid "Finish creating your account" msgstr "Finish creating your account" -#: app/routes/reauth.tsx:202 +#: app/routes/reauth.tsx:237 msgid "For your security, verify one of your sign-in methods to continue." msgstr "For your security, verify one of your sign-in methods to continue." @@ -398,7 +408,7 @@ msgid "Linked accounts" msgstr "Linked accounts" #: app/routes/passkeys.tsx:227 -#: app/routes/reauth.tsx:206 +#: app/routes/reauth.tsx:241 #: app/routes/sso/index.tsx:137 msgid "Logged in as" msgstr "Logged in as" @@ -428,7 +438,7 @@ msgstr "No account was found and sign-up is not available." msgid "No passkeys yet." msgstr "No passkeys yet." -#: app/routes/reauth.tsx:258 +#: app/routes/reauth.tsx:314 msgid "No sign-in method is available for re-authentication." msgstr "No sign-in method is available for re-authentication." @@ -453,7 +463,7 @@ msgstr "Not registered?" #: app/routes/login/index.tsx:416 #: app/routes/login/method.tsx:99 #: app/routes/passkeys.tsx:228 -#: app/routes/reauth.tsx:207 +#: app/routes/reauth.tsx:242 #: app/routes/signed-in.tsx:49 #: app/routes/sso/index.tsx:138 msgid "Not you?" @@ -473,7 +483,7 @@ msgstr "Or import this URI in your authenticator app" #: app/routes/login/index.tsx:462 #: app/routes/login/method.tsx:127 -#: app/routes/reauth.tsx:237 +#: app/routes/reauth.tsx:272 #: app/routes/setup/mfa.tsx:46 msgid "Passkey" msgstr "Passkey" @@ -511,8 +521,8 @@ msgid "Passkeys let you sign in with your fingerprint, face, or device PIN." msgstr "Passkeys let you sign in with your fingerprint, face, or device PIN." #: app/routes/login/method.tsx:161 -#: app/routes/reauth.tsx:245 -#: app/routes/reauth.tsx:283 +#: app/routes/reauth.tsx:301 +#: app/routes/reauth.tsx:339 #: app/routes/signup/password.tsx:229 #: app/routes/sso/ldap.tsx:80 msgid "Password" @@ -737,6 +747,7 @@ msgstr "SMS one-time code" msgid "SMS OTP" msgstr "SMS OTP" +#: app/routes/reauth/provider/error.tsx:27 #: app/routes/sso/provider/error.tsx:63 msgid "Something went wrong with <0>{provider}." msgstr "Something went wrong with <0>{provider}." @@ -766,6 +777,7 @@ msgstr "That device code isn't valid. Check the code on your device and try agai msgid "That device code was not found. Check the code on your device and try again." msgstr "That device code was not found. Check the code on your device and try again." +#: app/routes/reauth/provider/error.tsx:17 #: app/routes/sso/provider/error.tsx:13 #: app/routes/sso/provider/error.tsx:18 msgid "That identity belongs to a different account." @@ -783,6 +795,7 @@ msgstr "The passkey verification failed. Please try again." msgid "The request timed out. Please try again." msgstr "The request timed out. Please try again." +#: app/routes/reauth/provider/error.tsx:18 #: app/routes/sso/provider/error.tsx:12 msgid "The sign-in link was incomplete or expired." msgstr "The sign-in link was incomplete or expired." @@ -866,7 +879,7 @@ msgstr "Use your security key to verify your identity." msgid "Username" msgstr "Username" -#: app/routes/reauth.tsx:305 +#: app/routes/reauth.tsx:361 #: app/routes/verify/index.tsx:159 msgid "Verification code" msgstr "Verification code" @@ -916,7 +929,7 @@ msgstr "We couldn't set up your passkey. Please try again." msgid "We couldn't start passkey setup. Please try again." msgstr "We couldn't start passkey setup. Please try again." -#: app/routes/reauth.tsx:303 +#: app/routes/reauth.tsx:359 msgid "We sent a verification code to your email address." msgstr "We sent a verification code to your email address." diff --git a/app/resources/reauth/reauth.service.ts b/app/resources/reauth/reauth.service.ts index b8165ebbf9..8255be9b4d 100644 --- a/app/resources/reauth/reauth.service.ts +++ b/app/resources/reauth/reauth.service.ts @@ -7,6 +7,7 @@ // returnTo (default /passkeys). import type { AuthProvider } from '@/modules/auth/auth-provider'; import type { SessionChecks } from '@/modules/auth/auth-provider'; +import { idpTypeToSlug } from '@/modules/auth/idp-slug'; // NOTE: import the PURE helpers from session/session (not cookie.ts) — cookie.ts is // stubbed to no-ops in the Cypress component bundle; the pure module is browser-safe // and identical at runtime (cookie.ts re-exports it). @@ -14,12 +15,24 @@ import { mostRecent, addSession, type SessionEntry } from '@/modules/auth/sessio import type { Session } from '@/modules/auth/types'; import { ProviderError, isStaleSessionError } from '@/modules/auth/types'; import { postLoginDestinationWithSource } from '@/resources/login/post-login-destination'; +import { APP_BASENAME } from '@/resources/shared/app-basename'; import { resolveOrg } from '@/resources/shared/resolve-org'; import { validateReturnTo } from '@/resources/shared/return-to'; +import { joinLinkedIdps } from '@/resources/sso'; +import { getActiveIdPs } from '@/resources/sso/idp-providers'; import { paths } from '@/routes/paths'; import { logAuthEvent, hashActor } from '@/server/observability'; -export type ReauthMethod = 'passkey' | 'password' | 'otp_email'; +export type ReauthMethod = 'passkey' | 'password' | 'otp_email' | 'idp'; + +/** A redirect-based (OAuth/OIDC) IdP the user can reauth with. LDAP is excluded — it needs + * its own credential form, not an OAuth round-trip (see sso/ldap.tsx). */ +export interface ReauthLinkedIdp { + idpId: string; + name?: string; + type?: string; + logoUrl?: string; +} export interface ReauthLoadInput { returnTo: string | null; @@ -42,6 +55,7 @@ export type ReauthLoadResult = method: ReauthMethod | null; publicKeyCredentialRequestOptions: unknown; returnTo: string; + linkedIdps: ReauthLinkedIdp[]; }; /** @@ -114,6 +128,24 @@ export async function loadReauth( if (enrolled.includes('password')) methods.push('password'); if (enrolled.includes('otp_email') && input.emailDeliveryEnabled) methods.push('otp_email'); + // idp: only redirect-based (non-LDAP) linked providers count — LDAP needs its own + // credential form (sso/ldap.tsx), not an OAuth round-trip. + let linkedIdps: ReauthLinkedIdp[] = []; + if (enrolled.includes('idp')) { + const [links, active] = await Promise.all([ + provider.listIdpLinks(userId), + getActiveIdPs(provider, await resolveOrg(provider, entry.organization)), + ]); + // joinLinkedIdps always sets a `logoUrl` key (even when the matched provider has none), + // which would otherwise leave `logoUrl: undefined` as an own enumerable property — harmless + // at runtime, but it trips up strict deep-equal assertions on the chooser payload. Drop it + // when absent instead of forwarding the key. + linkedIdps = joinLinkedIdps(links, active) + .filter((l) => l.type !== 'LDAP') + .map(({ logoUrl, ...rest }) => (logoUrl === undefined ? rest : { ...rest, logoUrl })); + if (linkedIdps.length > 0) methods.push('idp'); + } + // No caller-supplied returnTo (or a tampered one) — resolve the SAME post-login // destination /signed-in uses (admin console → Zitadel default → env default), // rather than blindly landing every reauth on /passkeys regardless of which flow @@ -152,6 +184,7 @@ export async function loadReauth( method: input.method, publicKeyCredentialRequestOptions, returnTo, + linkedIdps, }; } @@ -161,6 +194,9 @@ export interface ReauthPerformInput { credential?: string; password?: string; code?: string; + /** factor='idp' — resolved from the IdP callback's ?id=&token= query params. */ + idpIntentId?: string; + idpIntentToken?: string; returnTo: string | null; } @@ -192,7 +228,12 @@ export async function performReauth( } else if (input.factor === 'otp_email') { if (!input.code) return { ok: false, error: 'INVALID_INPUT' }; checks = { otpEmail: input.code }; - } else { + } else if (input.factor === 'idp') { + if (!input.idpIntentId || !input.idpIntentToken) return { ok: false, error: 'INVALID_INPUT' }; + checks = { + idpIntent: { idpIntentId: input.idpIntentId, idpIntentToken: input.idpIntentToken }, + }; + } else if (input.factor === 'passkey') { if (!input.credential) return { ok: false, error: 'INVALID_INPUT' }; let credentialAssertionData: unknown; try { @@ -201,6 +242,9 @@ export async function performReauth( return { ok: false, error: 'INVALID_INPUT' }; } checks = { webAuthN: { credentialAssertionData } }; + } else { + const exhaustive: never = input.factor; + return exhaustive; } let session: Session; @@ -211,7 +255,15 @@ export async function performReauth( actor: hashActor(entry.loginName), factor: input.factor, }); - if (err instanceof ProviderError && err.code === 'INVALID_CREDENTIALS') { + if ( + err instanceof ProviderError && + (err.code === 'INVALID_CREDENTIALS' || + // Real Zitadel rejects an idpIntent verified against a DIFFERENT user with + // FAILED_PRECONDITION ("Intent meant for another user"), not INVALID_CREDENTIALS — + // scoped to the idp factor only, since FAILED_PRECONDITION means something else + // entirely for the other factors. + (input.factor === 'idp' && err.code === 'FAILED_PRECONDITION')) + ) { return { ok: false, error: 'INVALID_CREDENTIALS' }; } throw err; @@ -229,3 +281,54 @@ export async function performReauth( return { ok: true, target: validateReturnTo(input.returnTo) ?? paths.passkeys(), sessions: next }; } + +export interface StartReauthIdpInput { + idpId: string; + /** TRUSTED app origin from trustedAppOrigin(request) — never the raw request Host header. */ + origin: string; + /** Where to land after a successful reauth (already validated by the caller). */ + returnTo: string; +} + +export type StartReauthIdpResult = + { ok: true; authUrl: string } | { ok: false; error: 'IDP_UNAVAILABLE' }; + +/** Build the /reauth/:provider/callback + /reauth/:provider/error return URLs — the reauth + * analog of sso's idpReturnUrls, pointed at the dedicated reauth callback instead. */ +function reauthIdpReturnUrls( + origin: string, + slug: string, + returnTo: string +): { success: string; failure: string } { + const qs = `returnTo=${encodeURIComponent(returnTo)}`; + const base = `${origin}${APP_BASENAME}/reauth/${encodeURIComponent(slug)}`; + return { success: `${base}/callback?${qs}`, failure: `${base}/error?${qs}` }; +} + +/** + * Start a fresh OAuth round-trip to re-verify the CURRENT session's identity via one of + * its linked IdPs (the 'idp' reauth method). Mirrors login.service.ts's startIdpIntent — + * same try/catch shape, same IDP_UNAVAILABLE mapping — but points the return URLs at the + * dedicated /reauth/:provider/callback|error routes instead of /sso/:provider/.... + */ +export async function startReauthIdpIntent( + provider: AuthProvider, + { idpId, origin, returnTo }: StartReauthIdpInput +): Promise { + const slug = idpTypeToSlug(idpId) ?? idpId; + const { success, failure } = reauthIdpReturnUrls(origin, slug, returnTo); + let result: Awaited>; + try { + result = await provider.startIdpIntent(idpId, { success, failure }); + } catch (err) { + if (!(err instanceof ProviderError)) throw err; + logAuthEvent('reauth_idp_start', 'failure', { idpId, reason: 'provider_error' }); + return { ok: false, error: 'IDP_UNAVAILABLE' }; + } + if (!result.authUrl) { + logAuthEvent('reauth_idp_start', 'failure', { idpId, reason: 'no_auth_url' }); + return { ok: false, error: 'IDP_UNAVAILABLE' }; + } + logAuthEvent('reauth_idp_start', 'success', { idpId }); + return { ok: true, authUrl: result.authUrl }; +} diff --git a/app/routes.ts b/app/routes.ts index cf74baeae7..33211bdaf6 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -72,6 +72,8 @@ export default [ // Passkey management + sudo re-auth interstitial. route('passkeys', 'routes/passkeys.tsx'), route('reauth', 'routes/reauth.tsx'), + route('reauth/:provider/callback', 'routes/reauth/provider/callback.tsx'), + route('reauth/:provider/error', 'routes/reauth/provider/error.tsx'), route('accounts', 'routes/accounts.tsx'), route('signed-in', 'routes/signed-in.tsx'), route('error', 'routes/error.tsx'), diff --git a/app/routes/paths.ts b/app/routes/paths.ts index 73323d8018..cf50fefc02 100644 --- a/app/routes/paths.ts +++ b/app/routes/paths.ts @@ -71,6 +71,12 @@ export const paths = { // Passkey management + sudo re-auth interstitial. passkeys: (q?: Query) => withQuery('/passkeys', q), reauth: (q?: Query) => withQuery('/reauth', q), + /** Separate from `reauth` (a callable function, unlike paths.sso's object shape) — + * see the Global Constraints note in the reauth-idp-verification plan for why. */ + reauthIdp: { + callback: (provider: string, q?: Query) => withQuery(`/reauth/${provider}/callback`, q), + error: (provider: string, q?: Query) => withQuery(`/reauth/${provider}/error`, q), + }, accounts: (q?: Query) => withQuery('/accounts', q), signedIn: (q?: Query) => withQuery('/signed-in', q), error: (q?: Query) => withQuery('/error', q), diff --git a/app/routes/reauth.tsx b/app/routes/reauth.tsx index 4376918f81..a808e5c0da 100644 --- a/app/routes/reauth.tsx +++ b/app/routes/reauth.tsx @@ -8,19 +8,25 @@ import { SubmitButton } from '@/components/auth-form/auth-form'; import { AuthFormFields } from '@/components/auth-form/auth-form-fields'; import { FormError } from '@/components/form-error/form-error'; import { IdentityBadge } from '@/components/identity-badge/identity-badge'; +import { IdpIcon } from '@/components/idp-icon/idp-icon'; import { WebAuthnButton, WebAuthnReasonCopy } from '@/components/webauthn-button/webauthn-button'; import { useAuthActionError } from '@/hooks/use-auth-action-error'; import { usePasskeyReauthCeremony } from '@/hooks/use-passkey-reauth-ceremony'; -import { readSessions, serializeSessions } from '@/modules/auth/session/cookie'; +import { mostRecent, readSessions, serializeSessions } from '@/modules/auth/session/cookie'; import { loadReauth, performReauth, + startReauthIdpIntent, type ReauthLoadResult, type ReauthMethod, } from '@/resources/reauth/reauth.service'; +import { resolveOrg } from '@/resources/shared/resolve-org'; +import { validateReturnTo } from '@/resources/shared/return-to'; +import { getActiveIdPs } from '@/resources/sso/idp-providers'; import { paths } from '@/routes/paths'; import { providerForRequest } from '@/server/auth-context.server'; import { getCsrfToken, assertCsrf } from '@/server/csrf'; +import { trustedAppOrigin } from '@/server/infra/app-origin.server'; import { env } from '@/server/infra/env.server'; import { actionError } from '@/utils/errors/auth-error'; import { Button, LinkButton } from '@datum-cloud/datum-ui/button'; @@ -94,6 +100,34 @@ export async function action({ request }: ActionFunctionArgs) { const form = await request.formData(); await assertCsrf(request, form); + if (form.get('intent') === 'idp-reauth') { + const idpId = String(form.get('idpId') ?? ''); + const returnTo = validateReturnTo(String(form.get('returnTo') ?? '')) ?? paths.passkeys(); + if (!idpId) return data({ error: 'INVALID_INPUT' as const }, { status: 400 }); + + const sessions = await readSessions(request); + const entry = mostRecent(sessions); + if (!entry) return redirect(paths.login.index()); + + // Defensive server-side re-check: never trust the client's idpId — confirm it + // resolves to an ACTIVE, non-LDAP provider before starting the round-trip. Scoped + // to the SAME org loadReauth used to build the chooser's linkedIdps (Task 3), so + // this never rejects a provider the user legitimately just saw a button for. + const active = await getActiveIdPs(provider, await resolveOrg(provider, entry.organization)); + const targetIdp = active.find((i) => i.id === idpId); + if (!targetIdp || targetIdp.type === 'LDAP') { + return data({ error: 'INVALID_INPUT' as const }, { status: 400 }); + } + + const result = await startReauthIdpIntent(provider, { + idpId, + origin: trustedAppOrigin(request), + returnTo, + }); + if (!result.ok) return data({ error: result.error }, { status: 502 }); + return redirect(result.authUrl); + } + const parsed = reauthActionSchema.safeParse(Object.fromEntries(form)); if (!parsed.success) return data({ error: 'INVALID_INPUT' as const }, { status: 400 }); @@ -178,7 +212,8 @@ export default function Reauth() { const formRef = useRef(null); const errorMessage = useAuthActionError(actionData); - const { loginName, methods, method, returnTo, publicKeyCredentialRequestOptions } = view; + const { loginName, methods, method, returnTo, linkedIdps, publicKeyCredentialRequestOptions } = + view; // In-place passkey ceremony — fires from the chooser (method === null) without // navigating to the two-step ?method=passkey verify screen below. @@ -237,6 +272,27 @@ export default function Reauth() { Passkey ) : null} + {methods.includes('idp') && + linkedIdps.map((idp) => ( + + + + + + + + ))} {methods.includes('password') ? ( = { + 'access-denied': That identity belongs to a different account., + 'context-missing': The sign-in link was incomplete or expired., +}; + +export default function ReauthProviderError() { + const { provider } = useParams(); + const [sp] = useSearchParams(); + const returnTo = sp.get('returnTo') ?? undefined; + const reason = sp.get('reason') ?? ''; + const message = REASONS[reason] ?? ( + + Something went wrong with {provider}. + + ); + return ( + Couldn't verify} description={message}> + + Back to sign in + + + ); +} diff --git a/cypress/component/modules/auth/providers/fake/fake-provider-idp-intent.cy.ts b/cypress/component/modules/auth/providers/fake/fake-provider-idp-intent.cy.ts new file mode 100644 index 0000000000..9dc553f6e0 --- /dev/null +++ b/cypress/component/modules/auth/providers/fake/fake-provider-idp-intent.cy.ts @@ -0,0 +1,61 @@ +// cypress/component/modules/auth/providers/fake/fake-provider-idp-intent.cy.ts +// +// FakeAuthProvider.updateSession must replicate Zitadel's own identity-binding +// enforcement for idpIntent checks: a session can only be verified with an +// idpIntent that resolves to THAT session's own user. +import { FakeAuthProvider } from '@/modules/auth/providers/fake/fake-provider'; +import { ProviderError } from '@/modules/auth/types'; + +describe('FakeAuthProvider.updateSession — idpIntent check', () => { + it("stamps the idpIntent factor when the intent resolves to the session's own user", async () => { + const fake = new FakeAuthProvider({ + users: [{ id: 'u1', loginName: 'mia@acme.test' }], + idpIntents: { + 'intent-1': { idpIntentId: 'intent-1', idpIntentToken: 'tok-1', userId: 'u1' }, + }, + }); + const s = await fake.createSession({}, { userId: 'u1' }); + const updated = await fake.updateSession(s.id, s.token, { + idpIntent: { idpIntentId: 'intent-1', idpIntentToken: 'tok-1' }, + }); + expect(updated.factors.idpIntent?.verifiedAt).to.not.equal(null); + }); + + it("rejects an idpIntent that resolves to a DIFFERENT user than the session's own", async () => { + const fake = new FakeAuthProvider({ + users: [ + { id: 'u1', loginName: 'mia@acme.test' }, + { id: 'u2', loginName: 'bob@acme.test' }, + ], + idpIntents: { + 'intent-2': { idpIntentId: 'intent-2', idpIntentToken: 'tok-2', userId: 'u2' }, + }, + }); + const s = await fake.createSession({}, { userId: 'u1' }); + let threw: unknown; + try { + await fake.updateSession(s.id, s.token, { + idpIntent: { idpIntentId: 'intent-2', idpIntentToken: 'tok-2' }, + }); + } catch (err) { + threw = err; + } + expect(threw).to.be.instanceOf(ProviderError); + expect((threw as ProviderError).code).to.equal('FAILED_PRECONDITION'); + }); + + it('rejects an unseeded/unknown idpIntentId', async () => { + const fake = new FakeAuthProvider({ users: [{ id: 'u1', loginName: 'mia@acme.test' }] }); + const s = await fake.createSession({}, { userId: 'u1' }); + let threw: unknown; + try { + await fake.updateSession(s.id, s.token, { + idpIntent: { idpIntentId: 'no-such-intent', idpIntentToken: 'x' }, + }); + } catch (err) { + threw = err; + } + expect(threw).to.be.instanceOf(ProviderError); + expect((threw as ProviderError).code).to.equal('FAILED_PRECONDITION'); + }); +}); diff --git a/cypress/component/resources/reauth/reauth-idp-intent.cy.ts b/cypress/component/resources/reauth/reauth-idp-intent.cy.ts new file mode 100644 index 0000000000..1ec346a677 --- /dev/null +++ b/cypress/component/resources/reauth/reauth-idp-intent.cy.ts @@ -0,0 +1,57 @@ +// cypress/component/resources/reauth/reauth-idp-intent.cy.ts +// +// NO-MOUNT: startReauthIdpIntent starts a fresh OAuth round-trip pointed at the +// dedicated /reauth/:provider/callback + /reauth/:provider/error routes (NOT the +// existing /sso/:provider/callback, which is a separate sign-in/link/register/error +// decision tree for a different purpose). +import { FakeAuthProvider } from '@/modules/auth/providers/fake/fake-provider'; +import { ProviderError } from '@/modules/auth/types'; +import { startReauthIdpIntent } from '@/resources/reauth/reauth.service'; + +describe('startReauthIdpIntent', () => { + it('builds success/failure URLs pointed at /reauth/:provider/callback|error with returnTo threaded', async () => { + const fake = new FakeAuthProvider({ users: [{ id: 'u1', loginName: 'mia@acme.test' }] }); + let capturedUrls: { success: string; failure: string } | undefined; + fake.startIdpIntent = async (_idpId, urls) => { + capturedUrls = urls; + return { authUrl: 'https://accounts.google.com/o/oauth2/auth?...' }; + }; + const result = await startReauthIdpIntent(fake, { + idpId: 'idp-google', + origin: 'http://localhost:3000', + returnTo: '/passkeys', + }); + expect(result).to.deep.equal({ + ok: true, + authUrl: 'https://accounts.google.com/o/oauth2/auth?...', + }); + expect(capturedUrls?.success).to.include('/id/reauth/idp-google/callback'); + expect(capturedUrls?.success).to.include('returnTo=%2Fpasskeys'); + expect(capturedUrls?.failure).to.include('/id/reauth/idp-google/error'); + expect(capturedUrls?.failure).to.include('returnTo=%2Fpasskeys'); + }); + + it('maps a ProviderError from provider.startIdpIntent to IDP_UNAVAILABLE', async () => { + const fake = new FakeAuthProvider({ users: [{ id: 'u1', loginName: 'mia@acme.test' }] }); + fake.startIdpIntent = async () => { + throw new ProviderError('UNAVAILABLE', 'idp down'); + }; + const result = await startReauthIdpIntent(fake, { + idpId: 'idp-google', + origin: 'http://localhost:3000', + returnTo: '/passkeys', + }); + expect(result).to.deep.equal({ ok: false, error: 'IDP_UNAVAILABLE' }); + }); + + it('maps a missing authUrl to IDP_UNAVAILABLE', async () => { + const fake = new FakeAuthProvider({ users: [{ id: 'u1', loginName: 'mia@acme.test' }] }); + fake.startIdpIntent = async () => ({}); + const result = await startReauthIdpIntent(fake, { + idpId: 'idp-google', + origin: 'http://localhost:3000', + returnTo: '/passkeys', + }); + expect(result).to.deep.equal({ ok: false, error: 'IDP_UNAVAILABLE' }); + }); +}); diff --git a/cypress/component/resources/reauth/reauth.service.cy.ts b/cypress/component/resources/reauth/reauth.service.cy.ts index 0e66cfb235..f42b811acf 100644 --- a/cypress/component/resources/reauth/reauth.service.cy.ts +++ b/cypress/component/resources/reauth/reauth.service.cy.ts @@ -104,6 +104,66 @@ describe('reauth.service — verify one factor onto the EXISTING session', () => expect(dead).to.deep.equal({ ok: false, error: 'SESSION_EXPIRED' }); }); + it('performReauth(idp) verifies the intent onto the SAME session id and targets returnTo', async () => { + const fake = new FakeAuthProvider({ + users: [USER], + passwords: { u1: 'Password1!' }, + authMethods: { u1: ['password', 'idp'] }, + idpIntents: { + 'intent-1': { idpIntentId: 'intent-1', idpIntentToken: 'tok-1', userId: 'u1' }, + }, + }); + const s = await fake.createSession({}, { userId: 'u1' }); + const sessions: SessionEntry[] = [ + { + id: s.id, + token: s.token, + loginName: USER.loginName, + creationTs: s.changedAt, + expirationTs: s.expiresAt, + changeTs: s.changedAt, + }, + ]; + const r = await performReauth(fake, sessions, { + factor: 'idp', + idpIntentId: 'intent-1', + idpIntentToken: 'tok-1', + returnTo: '/passkeys', + }); + expect(r.ok).to.equal(true); + if (r.ok) { + expect(r.target).to.equal('/passkeys'); + expect(r.sessions[0].id).to.equal(sessions[0].id); + } + }); + + it('performReauth(idp) maps a mismatched idpIntent to INVALID_CREDENTIALS', async () => { + const fake = new FakeAuthProvider({ + users: [USER, { id: 'u2', loginName: 'bob@acme.test' }], + idpIntents: { + 'intent-2': { idpIntentId: 'intent-2', idpIntentToken: 'tok-2', userId: 'u2' }, + }, + }); + const s = await fake.createSession({}, { userId: 'u1' }); + const sessions: SessionEntry[] = [ + { + id: s.id, + token: s.token, + loginName: USER.loginName, + creationTs: s.changedAt, + expirationTs: s.expiresAt, + changeTs: s.changedAt, + }, + ]; + const r = await performReauth(fake, sessions, { + factor: 'idp', + idpIntentId: 'intent-2', + idpIntentToken: 'tok-2', + returnTo: null, + }); + expect(r).to.deep.equal({ ok: false, error: 'INVALID_CREDENTIALS' }); + }); + it('loadReauth recovers to /login (not a crash) when getSession throws a non-transient ProviderError', async () => { // Reproduces the live bug: a stored session token from a DIFFERENT browser/tab is stale or // revoked, and the real Zitadel backend throws PERMISSION_DENIED on getSession instead of @@ -137,4 +197,80 @@ describe('reauth.service — verify one factor onto the EXISTING session', () => } expect(threw).to.exist; }); + + it('loadReauth lists idp as a method and populates linkedIdps for a Google-linked user', async () => { + const fake = new FakeAuthProvider({ + users: [USER], + authMethods: { u1: ['idp'] }, + capabilities: { externalIdp: true }, + }); + fake.setActiveIdPs?.([{ id: 'idp-google', name: 'Google', type: 'GOOGLE' }]); + fake.setIdpLinks?.('u1', [ + { idpId: 'idp-google', idpUserId: 'g-1', idpUserName: 'mia@gmail.com' }, + ]); + const s = await fake.createSession({}, { userId: 'u1' }); + const sessions: SessionEntry[] = [ + { + id: s.id, + token: s.token, + loginName: USER.loginName, + creationTs: s.changedAt, + expirationTs: s.expiresAt, + changeTs: s.changedAt, + }, + ]; + const v = await loadReauth(fake, sessions, { + returnTo: '/passkeys', + method: null, + domain: 'localhost', + emailDeliveryEnabled: false, + consoleUrl: 'https://console.acme.test', + }); + expect(v.kind).to.equal('view'); + if (v.kind === 'view') { + expect(v.methods).to.include('idp'); + expect(v.linkedIdps).to.deep.equal([ + { + idpId: 'idp-google', + idpUserId: 'g-1', + idpUserName: 'mia@gmail.com', + name: 'Google', + type: 'GOOGLE', + }, + ]); + } + }); + + it('loadReauth omits idp when the only linked provider is LDAP', async () => { + const fake = new FakeAuthProvider({ + users: [USER], + authMethods: { u1: ['idp'] }, + capabilities: { externalIdp: true }, + }); + fake.setActiveIdPs?.([{ id: 'idp-ldap', name: 'Corporate LDAP', type: 'LDAP' }]); + fake.setIdpLinks?.('u1', [{ idpId: 'idp-ldap', idpUserId: 'ldap-1', idpUserName: 'mia' }]); + const s = await fake.createSession({}, { userId: 'u1' }); + const sessions: SessionEntry[] = [ + { + id: s.id, + token: s.token, + loginName: USER.loginName, + creationTs: s.changedAt, + expirationTs: s.expiresAt, + changeTs: s.changedAt, + }, + ]; + const v = await loadReauth(fake, sessions, { + returnTo: '/passkeys', + method: null, + domain: 'localhost', + emailDeliveryEnabled: false, + consoleUrl: 'https://console.acme.test', + }); + expect(v.kind).to.equal('view'); + if (v.kind === 'view') { + expect(v.methods).to.not.include('idp'); + expect(v.linkedIdps).to.deep.equal([]); + } + }); }); diff --git a/cypress/component/routes/reauth.cy.tsx b/cypress/component/routes/reauth.cy.tsx index 0d94d1780d..f5bd4b8915 100644 --- a/cypress/component/routes/reauth.cy.tsx +++ b/cypress/component/routes/reauth.cy.tsx @@ -18,6 +18,7 @@ const CHOOSER_VIEW = { method: null, publicKeyCredentialRequestOptions: null, returnTo: '/passkeys', + linkedIdps: [], }; const capturedPosts: Array> = []; @@ -141,3 +142,55 @@ describe('/reauth — in-place passkey ceremony', () => { cy.contains('a', 'Password').should('have.attr', 'aria-disabled', 'false'); }); }); + +describe('/reauth — idp chooser entry', () => { + it('renders a button per linked IdP and posts intent=idp-reauth to start the round-trip', () => { + const capturedIdpPosts: Array> = []; + const router = createMemoryRouter( + [ + { + id: 'reauth', + path: '/reauth', + element: , + loader: async () => ({ + csrfToken: 'tok-1', + view: { + ...CHOOSER_VIEW, + methods: ['idp', 'password'] as const, + linkedIdps: [{ idpId: 'idp-google', name: 'Google', type: 'GOOGLE' }], + }, + }), + action: async ({ request }: { request: Request }) => { + const body = Object.fromEntries(await request.formData()); + capturedIdpPosts.push(body); + return null; + }, + }, + ], + { + initialEntries: ['/reauth?returnTo=/passkeys'], + hydrationData: { + loaderData: { + reauth: { + csrfToken: 'tok-1', + view: { + ...CHOOSER_VIEW, + methods: ['idp', 'password'], + linkedIdps: [{ idpId: 'idp-google', name: 'Google', type: 'GOOGLE' }], + }, + }, + }, + }, + } + ); + mount(withI18n()); + + cy.contains('button', 'Google').click(); + cy.wrap(null).should(() => { + expect(capturedIdpPosts).to.have.length(1); + expect(capturedIdpPosts[0].intent).to.equal('idp-reauth'); + expect(capturedIdpPosts[0].idpId).to.equal('idp-google'); + expect(capturedIdpPosts[0].returnTo).to.equal('/passkeys'); + }); + }); +}); diff --git a/cypress/component/routes/reauth/provider-callback.cy.tsx b/cypress/component/routes/reauth/provider-callback.cy.tsx new file mode 100644 index 0000000000..b589fe8985 --- /dev/null +++ b/cypress/component/routes/reauth/provider-callback.cy.tsx @@ -0,0 +1,80 @@ +// cypress/component/routes/reauth/provider-callback.cy.tsx +// +// /reauth/:provider/callback loader: reads the CURRENT session, verifies the idpIntent +// onto it via performReauth, and redirects — success to returnTo, failure to +// /reauth/:provider/error, no-session to /login. Headless (element renders null), +// mirrors sso/provider/callback.tsx's own test-via-loader-harness convention. +import { callService } from '../../../support/node/call-service'; + +const URL = + 'http://localhost/id/reauth/idp-google/callback?id=intent-1&token=idp-tok-1&returnTo=%2Fpasskeys'; + +describe('/reauth/:provider/callback loader', () => { + it('a matching idpIntent verifies onto the existing session and redirects to returnTo', () => { + callService({ + fn: 'reauthProviderCallback', + seed: { + users: [{ id: 'u1', loginName: 'mia@acme.test' }], + idpIntents: { + 'intent-1': { idpIntentId: 'intent-1', idpIntentToken: 'idp-tok-1', userId: 'u1' }, + }, + }, + slug: 'idp-google', + liveSessions: [ + { id: 'sess-1', token: 'sess-tok-1', user: { id: 'u1', loginName: 'mia@acme.test' } }, + ], + request: { + url: URL, + sessions: [{ id: 'sess-1', token: 'sess-tok-1', loginName: 'mia@acme.test' }], + }, + }).then((v) => { + expect(v.response?.status).to.equal(302); + expect(v.response?.location).to.equal('/passkeys'); + expect(v.response?.setCookie ?? '').to.include('sessions='); + }); + }); + + it('a mismatched idpIntent redirects to /reauth/:provider/error with returnTo preserved', () => { + callService({ + fn: 'reauthProviderCallback', + seed: { + users: [ + { id: 'u1', loginName: 'mia@acme.test' }, + { id: 'u2', loginName: 'bob@acme.test' }, + ], + idpIntents: { + 'intent-1': { idpIntentId: 'intent-1', idpIntentToken: 'idp-tok-1', userId: 'u2' }, + }, + }, + slug: 'idp-google', + liveSessions: [ + { id: 'sess-1', token: 'sess-tok-1', user: { id: 'u1', loginName: 'mia@acme.test' } }, + ], + request: { + url: URL, + sessions: [{ id: 'sess-1', token: 'sess-tok-1', loginName: 'mia@acme.test' }], + }, + }).then((v) => { + expect(v.response?.status).to.equal(302); + expect(v.response?.location).to.include('/reauth/idp-google/error'); + expect(v.response?.location).to.include('returnTo=%2Fpasskeys'); + // Real Zitadel rejects this exact case with FAILED_PRECONDITION ("Intent meant + // for another user"), not INVALID_CREDENTIALS — performReauth must catch it + // (not let it escape as an unhandled 500) and the redirect must carry a reason + // the error page can render distinct copy for. + expect(v.response?.location).to.include('reason=access-denied'); + }); + }); + + it('no session redirects to /login', () => { + callService({ + fn: 'reauthProviderCallback', + seed: { users: [{ id: 'u1', loginName: 'mia@acme.test' }] }, + slug: 'idp-google', + request: { url: URL }, + }).then((v) => { + expect(v.response?.status).to.equal(302); + expect(v.response?.location).to.equal('/login'); + }); + }); +}); diff --git a/cypress/component/routes/reauth/provider-error.cy.tsx b/cypress/component/routes/reauth/provider-error.cy.tsx new file mode 100644 index 0000000000..f3d9324e4b --- /dev/null +++ b/cypress/component/routes/reauth/provider-error.cy.tsx @@ -0,0 +1,36 @@ +// cypress/component/routes/reauth/provider-error.cy.tsx +// +// /reauth/:provider/error — thin error screen, mirrors sso/provider/error.tsx exactly +// (a generic message naming the provider, a link back into the live ceremony). +import ReauthProviderError from '@/routes/reauth/provider/error'; +import { setupI18n } from '@lingui/core'; +import { I18nProvider } from '@lingui/react'; +import { mount } from 'cypress/react'; +import { createMemoryRouter, RouterProvider } from 'react-router'; + +function mountError(initialEntry: string) { + const router = createMemoryRouter( + [{ id: 'reauth-idp-error', path: '/reauth/:provider/error', element: }], + { initialEntries: [initialEntry] } + ); + const i18n = setupI18n({ locale: 'en', messages: { en: {} } }); + return mount( + + + + ); +} + +describe('/reauth/:provider/error', () => { + it('names the provider and links back to /reauth preserving returnTo', () => { + mountError('/reauth/idp-google/error?returnTo=%2Fpasskeys'); + cy.contains('idp-google').should('be.visible'); + cy.contains('a', 'Back to sign in').should('have.attr', 'href', '/reauth?returnTo=%2Fpasskeys'); + }); + + it('shows the access-denied copy instead of the generic provider-name fallback', () => { + mountError('/reauth/idp-google/error?returnTo=%2Fpasskeys&reason=access-denied'); + cy.contains('That identity belongs to a different account.').should('be.visible'); + cy.contains('Something went wrong').should('not.exist'); + }); +}); diff --git a/cypress/support/audit-coverage.ts b/cypress/support/audit-coverage.ts index 3c88706a03..f524dc6f0e 100644 --- a/cypress/support/audit-coverage.ts +++ b/cypress/support/audit-coverage.ts @@ -376,9 +376,11 @@ export const REQUIRED_EVENTS = [ // --- Sudo re-auth + passkey management (snake_case per the P5+ convention) --- // reauth: one enrolled factor re-verified onto the EXISTING session (/id/reauth action). // reauth_challenge: assertion/OTP challenge request failure on the reauth loader path. + // reauth_idp_start: OAuth round-trip initiated for IdP re-verification via startReauthIdpIntent. // passkey_remove: sudo-gated passkey removal (success / sudo_required / last_method). 'reauth', 'reauth_challenge', + 'reauth_idp_start', 'passkey_remove', // --- Password --- 'password.change', diff --git a/cypress/support/ceremony-guard.ts b/cypress/support/ceremony-guard.ts index 52b98b02e2..f8f14151fc 100644 --- a/cypress/support/ceremony-guard.ts +++ b/cypress/support/ceremony-guard.ts @@ -81,6 +81,14 @@ const ALLOWLIST: Record = { 'The "Enter a new code" recovery link for a stale/expired device code. The device flow is ' + "keyed by user_code/deviceAuthId, not requestId; /device's code-entry screen takes no " + 'ceremony query params, and a stale code has nothing left to resume.', + 'reauth.tsx': + 'The idp-reauth action branch bounces a session-less request straight to /login. ' + + '/reauth is a returnTo-scoped sudo interstitial with no OIDC/SAML/device requestId in ' + + 'its own contract — there is no ceremony to preserve on a dead session.', + 'reauth/provider/callback.tsx': + 'The idp-reauth callback bounces a session-less request straight to /login. Same ' + + 'reasoning as reauth.tsx: this callback only ever completes a returnTo-scoped sudo ' + + 'reauth, never an OIDC/SAML/device ceremony, so there is nothing to carry forward.', }; const PATTERNS: ReadonlyArray<{ name: string; re: RegExp }> = [ diff --git a/cypress/support/node/harness.ts b/cypress/support/node/harness.ts index 320245813c..c32cfae0b3 100644 --- a/cypress/support/node/harness.ts +++ b/cypress/support/node/harness.ts @@ -122,6 +122,7 @@ import { loader as passwordResetLoader, action as passwordResetAction, } from '@/routes/password/reset'; +import { loader as reauthProviderCallbackLoader } from '@/routes/reauth/provider/callback'; import { loader as setupAuthenticatorLoader } from '@/routes/setup/authenticator'; import { loader as signupCompleteLoader } from '@/routes/signup/complete'; import { loader as signupIndexLoader, action as signupIndexAction } from '@/routes/signup/index'; @@ -618,6 +619,29 @@ export async function runScenario(s: Scenario): Promise { outcome = await signInWithIdpIntent(provider, request, s.signInOpts); break; } + case 'reauthProviderCallback': { + // The route loader resolves its own provider via providerForRequest → getAuthProvider('fake') + // → providerRegistry.fake(), which is INDEPENDENT of the `provider` this harness already + // built above (buildProvider constructs a FRESH FakeAuthProvider whenever `seed` is present — + // and this fn's tests always seed idpIntents/users). Without this bridge the loader would see + // the unrelated process-fake singleton (no seeded idpIntent, no seeded live session) instead + // of the one this scenario actually configured. Point the registry at the SAME seeded + // provider for the duration of this call only, then restore it. + const originalFake = providerRegistry.fake; + providerRegistry.fake = () => provider; + try { + const { request: req } = await buildHandlerRequest(sr); + const res = await reauthProviderCallbackLoader({ + request: req, + params: { provider: s.slug ?? 'idp' }, + } as never); + outcome = res; + response = await serializeResponse(res); + } finally { + providerRegistry.fake = originalFake; + } + break; + } case 'submitLdapCredentials': { const o = await submitLdapCredentials(provider, request, buildForm(sr.form)); outcome = o; diff --git a/cypress/support/node/scenario.ts b/cypress/support/node/scenario.ts index 0a94c15dcf..905df8a7a7 100644 --- a/cypress/support/node/scenario.ts +++ b/cypress/support/node/scenario.ts @@ -43,6 +43,13 @@ export interface ScenarioSeed { capabilities?: Partial>; deviceAuths?: Array<{ userCode: string; id: string; appName?: string; scope: string[] }>; samlRequests?: Array<{ id: string; clientId: string; binding: 'redirect' | 'post' }>; + /** FakeAuthProvider's own idpIntents seed, narrowed to the one field the idp-reauth + * tests read (userId) — the real IdpIntentResult also carries information/draft, + * unused by updateSession's idpIntent check. */ + idpIntents?: Record< + string, + { idpIntentId: string; idpIntentToken: string; userId: string | null } + >; } /** A live session to inject via provider.seedLiveSession (getSession/listSessions resolve it). */ @@ -127,6 +134,7 @@ export type ServiceFn = // ── sso (batch 8b) ── | 'processIdpCallback' | 'signInWithIdpIntent' + | 'reauthProviderCallback' | 'submitLdapCredentials' | 'runSsoAction' // sso IdP-DISPLAY flows: org-first / default-org fallback probes. Each reads a real Request + From b3880e53101682a1ea720fc384f84f886490d0cf Mon Sep 17 00:00:00 2001 From: Yahya Fakhroji Date: Mon, 27 Jul 2026 19:31:33 +0700 Subject: [PATCH 5/9] fix(login): real per-provider icons on /login/method's IdP entries /login/method rendered a generic UserCircle icon for every linked IdP instead of the provider's real logo. Adds the missing google.dark.png IdP icon asset and wires the method chooser to the same IdpIcon/logoUrl resolution the rest of the app already uses. --- app/modules/i18n/locales/en.po | 17 +++--- app/routes/login/method.tsx | 60 ++++++++++++------- cypress/component/routes/login/method.cy.tsx | 37 ++++++++++++ public/images/idps/google.dark.png | Bin 0 -> 20865 bytes 4 files changed, 84 insertions(+), 30 deletions(-) create mode 100644 public/images/idps/google.dark.png diff --git a/app/modules/i18n/locales/en.po b/app/modules/i18n/locales/en.po index bf5fa975ed..f4e9da6eb7 100644 --- a/app/modules/i18n/locales/en.po +++ b/app/modules/i18n/locales/en.po @@ -173,7 +173,7 @@ msgstr "Choose a new password" msgid "Choose an account" msgstr "Choose an account" -#: app/routes/login/method.tsx:90 +#: app/routes/login/method.tsx:107 msgid "Choose how to sign in" msgstr "Choose how to sign in" @@ -218,6 +218,7 @@ msgid "Continue" msgstr "Continue" #. placeholder {0}: idp.name ?? idp.idpId +#: app/routes/login/method.tsx:197 #: app/routes/reauth.tsx:292 msgid "Continue with {0}" msgstr "Continue with {0}" @@ -226,10 +227,6 @@ msgstr "Continue with {0}" msgid "Continue with passkey" msgstr "Continue with passkey" -#: app/routes/login/method.tsx:178 -msgid "Continue with your provider" -msgstr "Continue with your provider" - #: app/routes/login/password.tsx:114 msgid "Could not verify password" msgstr "Could not verify password" @@ -293,7 +290,7 @@ msgid "Email me a code" msgstr "Email me a code" #: app/routes/login/index.tsx:522 -#: app/routes/login/method.tsx:144 +#: app/routes/login/method.tsx:161 #: app/routes/signup/method.tsx:339 msgid "Email me a sign-in link" msgstr "Email me a sign-in link" @@ -461,7 +458,7 @@ msgstr "Not registered?" #: app/components/identity-badge/identity-badge.tsx:30 #: app/routes/device/authorize.tsx:122 #: app/routes/login/index.tsx:416 -#: app/routes/login/method.tsx:99 +#: app/routes/login/method.tsx:116 #: app/routes/passkeys.tsx:228 #: app/routes/reauth.tsx:242 #: app/routes/signed-in.tsx:49 @@ -482,7 +479,7 @@ msgid "Or import this URI in your authenticator app" msgstr "Or import this URI in your authenticator app" #: app/routes/login/index.tsx:462 -#: app/routes/login/method.tsx:127 +#: app/routes/login/method.tsx:144 #: app/routes/reauth.tsx:272 #: app/routes/setup/mfa.tsx:46 msgid "Passkey" @@ -520,7 +517,7 @@ msgstr "Passkeys" msgid "Passkeys let you sign in with your fingerprint, face, or device PIN." msgstr "Passkeys let you sign in with your fingerprint, face, or device PIN." -#: app/routes/login/method.tsx:161 +#: app/routes/login/method.tsx:178 #: app/routes/reauth.tsx:301 #: app/routes/reauth.tsx:339 #: app/routes/signup/password.tsx:229 @@ -722,7 +719,7 @@ msgstr "Signing in as" msgid "Signing in as <0>{0}." msgstr "Signing in as <0>{0}." -#: app/routes/login/method.tsx:93 +#: app/routes/login/method.tsx:110 msgid "Signing in as <0>{loginName}." msgstr "Signing in as <0>{loginName}." diff --git a/app/routes/login/method.tsx b/app/routes/login/method.tsx index 2590892e83..e6b09ad972 100644 --- a/app/routes/login/method.tsx +++ b/app/routes/login/method.tsx @@ -1,4 +1,5 @@ import { FormError } from '@/components/form-error/form-error'; +import { IdpIcon } from '@/components/idp-icon/idp-icon'; import { WebAuthnReasonCopy } from '@/components/webauthn-button/webauthn-button'; import { useAuthActionError } from '@/hooks/use-auth-action-error'; import { useLoginContext } from '@/hooks/use-login-context'; @@ -7,6 +8,9 @@ import SplitLayout from '@/layouts/split.layout'; import { decideAfterIdentifier } from '@/resources/login/login-decision'; import { readCeremonyParams } from '@/resources/shared/ceremony-params'; import { resolveOrg } from '@/resources/shared/resolve-org'; +import { joinLinkedIdps } from '@/resources/sso'; +import { getActiveIdPs } from '@/resources/sso/idp-providers'; +import type { LinkedIdpView } from '@/resources/sso/sso-management'; import { redirectToLogin } from '@/routes/login-bounce'; import { paths } from '@/routes/paths'; import { providerForRequest } from '@/server/auth-context.server'; @@ -15,7 +19,7 @@ import { Button, LinkButton } from '@datum-cloud/datum-ui/button'; import { Icon } from '@datum-cloud/datum-ui/icons'; import { cn } from '@datum-cloud/datum-ui/utils'; import { Trans } from '@lingui/react/macro'; -import { Key, Lock, Mail, UserCircle } from 'lucide-react'; +import { Key, Lock, Mail } from 'lucide-react'; import { redirect, useLoaderData, type LoaderFunctionArgs, type MetaFunction } from 'react-router'; import { Link } from 'react-router'; @@ -47,7 +51,20 @@ export async function loader({ request }: LoaderFunctionArgs) { const available: Array<'passkey' | 'password' | 'otp_email' | 'idp'> = []; if (methods.includes('passkey') && settings.passkeysType !== 'not_allowed') available.push('passkey'); - if (methods.includes('idp') && settings.allowExternalIdp) available.push('idp'); + + // Resolve the user's linked (redirect-based) IdPs for real icon/name rendering — + // mirrors reauth.service.ts's loadReauth idp-resolution exactly, including the LDAP + // exclusion (LDAP needs its own credential form, not an OAuth round-trip). + let linkedIdps: LinkedIdpView[] = []; + if (methods.includes('idp') && settings.allowExternalIdp) { + const [links, active] = await Promise.all([ + provider.listIdpLinks(user.id), + getActiveIdPs(provider, settingsOrg), + ]); + linkedIdps = joinLinkedIdps(links, active).filter((l) => l.type !== 'LDAP'); + if (linkedIdps.length > 0) available.push('idp'); + } + if (methods.includes('password') && settings.allowPassword) available.push('password'); if (methods.includes('otp_email') && env.AUTH_EMAIL_DELIVERY_ENABLED) available.push('otp_email'); @@ -68,11 +85,11 @@ export async function loader({ request }: LoaderFunctionArgs) { return redirect(`${target}?${params.toString()}`); } - return { loginName, requestId, organization, methods: available, branding }; + return { loginName, requestId, organization, methods: available, branding, linkedIdps }; } export default function LoginMethod() { - const { methods, branding } = useLoaderData(); + const { methods, branding, linkedIdps } = useLoaderData(); const { loginName, requestId, organization } = useLoginContext(); // Typed paths.* emit the identical query string buildParams produced @@ -162,22 +179,25 @@ export default function LoginMethod() { ) : null} - {methods.includes('idp') ? ( - passkeyBusy && e.preventDefault()} - iconPosition="left" - icon={}> - Continue with your provider - - ) : null} + {methods.includes('idp') + ? linkedIdps.map((idp) => ( + passkeyBusy && e.preventDefault()} + iconPosition="left" + icon={}> + Continue with {idp.name ?? idp.idpId} + + )) + : null}
); diff --git a/cypress/component/routes/login/method.cy.tsx b/cypress/component/routes/login/method.cy.tsx index 76bd6bc736..0d8f0b97b6 100644 --- a/cypress/component/routes/login/method.cy.tsx +++ b/cypress/component/routes/login/method.cy.tsx @@ -19,6 +19,7 @@ const LOGIN_CONTEXT = { const METHOD_LOADER_DATA = { methods: ['passkey', 'password'], branding: null, + linkedIdps: [], }; const capturedPosts: Array> = []; @@ -108,4 +109,40 @@ describe('/login/method — identity header + in-place passkey ceremony', () => // Password stays a plain link (unchanged), so it's matched by its tag. cy.contains('a', 'Password').should('be.visible'); }); + + it('renders a link per linked IdP, named after the actual provider', () => { + const methodData = { + methods: ['idp', 'password'], + branding: null, + linkedIdps: [{ idpId: 'idp-google', name: 'Google', type: 'GOOGLE' }], + }; + const router = createMemoryRouter( + [ + { + id: 'login', + path: '/login', + loader: () => LOGIN_CONTEXT, + children: [ + { + id: 'method', + path: 'method', + element: , + loader: async () => methodData, + }, + ], + }, + ], + { + initialEntries: ['/login/method?loginName=mia%40acme.test'], + hydrationData: { + loaderData: { login: LOGIN_CONTEXT, method: methodData }, + }, + } + ); + mount(withI18n()); + + // Named after the actual provider ("Google"), not the generic "your provider" copy. + cy.contains('a', 'Continue with Google').should('be.visible').and('have.attr', 'href'); + cy.contains('a', 'Password').should('be.visible'); + }); }); diff --git a/public/images/idps/google.dark.png b/public/images/idps/google.dark.png new file mode 100644 index 0000000000000000000000000000000000000000..494acede0c520f847f75982ca1f671fc6eaa889e GIT binary patch literal 20865 zcmYJb2Rv2(|2Y1-F0OeILfH}$Qpx6$?QO4ym7S4&&A6EtWmGD%$t*KlTqKH&ka2Cw zy!M{IqtEyM`1g49h;v@!x%VkbS6h{u;t~Y_fLdKmNgn_r=tm+zMhgA2?K^M?{X^`j zsBTCG{Rt$q!9u^2yQ$sx1OWN<{4Xp~5P2DT$l|4Z&&$Bo&dcYKhb{2&@ezLf#L3h8 zk(;fstB3vjRhdfwZ~}ED1w-GIm2saG!<{c@8+-H8x9|&iA3fT|tgH~(OT;Rxcom0J02ot+m&}bf3E7wj&%wu-inv zlqfn(An*eourT_KL#L&zD2eNwD7C2w!%Hs|T-0 z@s*fXPg4TW^1h2*d>>cJLd*ltya{?wX}3eNm@usGUC1wk2Ljh;!iWRza@Rjs@FUyT zRhm$;`xX+C#9?&r1rLP;5-7Q6y+Z6hTVesE3Zk?)P5=knr4N70;NiH9FcR&1rJY!3 zFAx2qRqKb8QjF|gY| zJ7Td3f4CBco=2p2KdgzCCQ)i{hG7yHu~ zk`??qIszVr&TrLy3V2|b@g2>SVB!WcUgy4wsla^f!HMx?GLttasr54MnKR76rqoD# zu?b7q=9jcBIv1~-Zl!=&f-v_(381UdCqR}N)XRd2oj8-~X_jb~p6^sgBpUGjl&nD_Cbs=GLzDuyEa$ISE9a?VU<>7wuwiAP8 zF3Oz|qh0m>_&FTa&WyU%Vs|&W*VycyLkZ# zY*LYzFDg75XQVwLW#9qKc&&mEIBJvmnO#`d4=*0x1Cb2|*ho7J^RZ7I?yvYKM)%|n zG2iH_x-u(RQ4`Paxv|-EJ#RYFj49CsznVGl6^41Kp=r6^jY$Ach~0Sg7aEVuzrt|B za0k2Lk3Yydbv)NWdU2MZ&d02&PsFBDSm{ zusgCKz#2X5=8KP7CdZs-gFBusd%A9rm?QfiwQ}>1Zjpd$MbTa~O(i=5aL{qKswu1R z8|ttB8f_Zx<~o}NC_*le_77Cv=MvySneoMWPZ#eF*7LUGSGpOt>O;b9eMX;foy`E0 z%RlS%`CEy?PCM`)UCrEuV03+~a_Q#Xzdhk1Z%;bVlm|2j;8=O0Gqb$OmVZj)>w1cs z0<=_*xe9)U)mE%0mQS7^HU&^ZqrpWJvS%by+Mo?0RgeTA)~rhy{TM%%cD7k3Km3Gi z4Fc`wXz)QJItuPES`xR*lyv-GuT0(1}gD7Y@J3Es9qp#!M`%@|C zG9T~b2DuqTdRJrtXe5=36}NQ*YHXyv+K;xZ{F9HHQv7Tys{%UYkU&72kIBB7uxW;J zh4;3hX?U3sm;rjaD7)~V90z?0Gax>Tf%ZREU$KlmB8l&4nvrH0r?)NhfK77miv!Be zw2E;>G<3FtRPE$_T~7{%`H?4F^%5Yb=41%*vD>TFWKQ@&#Sb*{{9J~tY@OAJ?R76O zNtE8J3Q_JToOo2=${Bx`d2nhM>J@=q0||jNgKIxLC7vS23(c{yf(6~jD!aDfKef(n z(v$z;i(9mPdc<|HktWO9xjKFik`Nwp%*WC|+QrvmRhEBM86h^fLu?p0<=go%iCl<- z<&3OyxkBXqNgnf6D*Qvv`X6r}{FBVD5Jyo`1(OH5Tb!8Ys)rqTNX|9O70Y=A9l!l; z(5q@eQ?@~%gFXR1n=H9yZEyxRpHuyWgJB-b;%c&AZJwND8J7QBG&d&u$(P5%8= zIOULqYkjy>1RN8$7dC-n!5-G$c|zu1IM0c`ld3qY~!* z3v&V6KqP#sWTOz8ec;XYH27%Y)mqv45;lC)`!2d0P``=F|l1PXI|CNp_ zHn*^W=<4oA*P|rvzx^w6?Gq#b?d@zJu3tS=1sBzTfMA(}dyA%+i0m*XS|6G^*N4YK zCPh_%Q_6ixNO#W`C|3nwu@8#+lz*?PTrXrE+%v}u4alSst#hWza54$ABW`q~DM>Et zz;v^`DLN5$CUKO}Z-qZWct_46G3^TwXtgnGxsD{jytFRw{} zE6=o9Z(odUgf&N1J;Y)g3i}3&>sz`bjd*lULkMpSagZ;92(<=QS%Db@0-c{(m$Jj# zA|2!BizZg}ybHe6sE`GQFkShnC!iWKPL9YD7^lec#XB?%!lrxPcs7MLHVkp^xn2a} z#a`qvnMqng*$xnJPMQ5ZrO@xcwS)8NG3)EvISP>*(%b`ECph{2%6z8as82u+^ zs@LyRNQmW}2E8yT_uhNh2C|Iz=esgu>S`v?cjMiF17GTJF!xj=;OvKJu6NDBZH(TV zxTQ)u1aimHD;CFpB*Ef2G4;bK)r|7j93UDr!!llYDW3JfHbnr-zjrU9V@tXMAhZ)2Ef!ZN0S8!KdQnXz&-<^&z3Ir2x|Z~wRW zCqxjDh|7meHT(2*ej#MEpB6V7AYMQ_sH8H_YH5?G9o|4lbB~JL%_;O<{N?`-g())V zB@5!o8omueobKs0AsajBKqxec?EMZuLfNomR?l%;FyMlV-x^rAhIX~UrU2Wd2u-mx zM_?fbUBbLgcvsB$U)Pbx^*m}40Q3snmAc!-iaNwU;o{C}4Oh-uq&oiVAgs__~(=!hFtxC03^gD0qhv7hI3G*9t@SrBxp1WxZwx zH{5Zuw1MBzq;zy_pFD>f^lRKsb|5BQvqgYZiFHBnq-xRXw~=+ zb0FhfA{NqJd}!(V`s%`dX3c~z3zv=4c?pPvh5ewXT$?oD@jooxOpjmjeINyF(pN5j z>mrGVY&jN-Y;TjV)wct^P$Ys?vIoqN>;2!;1L!I0(Kts2^JD4Ie~%_(Uo!)*85{@j ze?>q-D6ldG2N21Fg|7-s;E)o}rljO0A$*D^@dv$Sp)p`jay>ejn_Fjk#?`_S0od+b z4=LTcj^V}&DJg9W2%k>S4#PmWcQ9CI!L$6|Bf1V4ijS%$jp>Ar&X1BQ*p&_s+sLIy zP{YT>VK$JyYJ|vwDp)?LE=k4(oHoia;{ifp{PgVg_)*jN0Ng=lzco>-tQlAieD`YG z2B1dGB!QjcXP9HLqK9GMIHQ-*ptf-QB2kj4Nx$K%!^yl?(Gf~y9>-E0NR39=OTLjNSmE>pNZ#))JBDAzk))UWJdo zXw9gY)-to43a*=7s$_dZmBN=ajDMQI(k}9xg@`HJcM&*X3gc8+Jr(RP-1eGa=r?=IsB73pA(Ny@eri~E!wXGA7 z0VQEm=pY160NRbzMNNd(iQH8z!3{KWRs2|XQ9V#6U}wgjK28$<%4*8)J$~!5nOFI^ z(x-Nx)6=Olm}LttlJn4_N}7LAT~^8Bnbk#NPn+H3PgOA?IsRQ31<|1|qJ{o`ce@C) zq*H6f88gzsXX`snN|588&-DF)9+H1d~YcKb}=|a%K1!;n0hk0?yxQ5 z9nzi1U!Gnb+7`S)02yzouSaBe!fc@M=XE(8C!Z+=-;uRQRF1K#3e`_S-p#pIe^#AU ze6;hlSbEr9l4`gG0&}(#hJrPJJ>3Ef{lrI{%&xVj*u$$cW&yu z%y#moiC?XCO9p18$HXNCc7OC!ux(LD{?l;W*BhENYy`g^^kjP345b-+se z3TgH9PypMB;y~YRylhZ=+vM4?82E1?SXwC;znX!%Ptm-lXL8ISvY<&#@$QG_(Wy>o zw!EX;XB;|?!+nE>bdm~4J?ec-yJrSc#tO1cL65?^VA7YrE_hKJoj>_OCgOz?6+P}p z8_CVTr%8gFr(@5fK|}B!YgX%1z^omIcJ{+D+N|rgAZ~b-)57oa4f)rSMW-&(VIkQw zzk-Iuzm83c)r_{HHE%)$xzmFL{|gTFfg}_>GgT>W7<8F!g>f297U^XfL6Jnc_|oZ> zCC56#8>QK*;B~*fW|k*&P`Ifhe>R1`>Gbm&Uf{L=HPvnTY|**XY`MQ_&h@-$ zHH362nQGk9dDLPlfXReFs`$aRU(jW~^^QQB^+WV&Q#Axn)_3Iy!kgHcne+z@J`@>^ zXVp;j68kfJX)T_SrK2sqfjhXlZZdDkZr%&|-sG#NYr%sO$o73}ldtq$;xJ+B7w1Um zb%a8@>iGGOq3=q*uNcKRN79ProlE9&athyNv*7XSu(e}@SJi01of**FmX4e!s^RE+ zo@sjgec1!bzB5Lpt2Kwawg&2pW~XM->twci^0isLU{j6cG-3(f%bcq_H|w3tAgHT0 zTb}^=rMIIpW$+A&P}e7QEU8u;kb#fW^<(Ced=(>qV6=K%&ZI~k?q}BWR|_xH-V%41 zz&GKtth#@54##%(2K^M#!^qH*Ku<7E;H}R%6iTY!eT?T)+9-dz{at?Sa;u1SAs1c< zRG&PSUxs6Xp8q#s#fULaKhEUQdY4#D98;f8uFMqR{n{|vXDZoguFdGOOCD|8!$GLZ zwJUb}OI1&w;(IpJ<3-LRI7%RwsAl<{#TZ*Ljz@OL+Uc-%y!Trzt zcG402dJH%4s(VlEbOutbX77Z^%e=Pw<^ zl^U;Bn8@2phwXyhH$+gD z{-KI`=00tRpzXN^8DL+2a%{-ORx8;QZ8{_9cy9jcYCkLX9J@uoa|xpLAQVEVTwU6q zv)CoYFutQ{dxz%8ka7`Zzc%rF>GRg?JLRZ(MK@`?k^>95+Bk4*b zuh_GDNtSB#zitKoaJs zHMsDK@3-W;zB$K8pF6GGWx{k@p~r83tj4HbCVBM2}+tBY0F1?Y*!QkgE zE!<`x`CR(GU7}D0o8p|&WtHb09XB}SAFM4I5o%F4uJmeiC8fTxYkYUB7YwZG z9N;sr+i1;`0Lm5>DfmrMTR3<2M zJ+tJlkimV-jp8>+MSH{AT$?5W(U3Ni$qg1lCNGXYH@p$RGO4tfoT=1 zH1j}U;?^a}Uhes={Yjl{ourh`{=Tg<>hvq~H2jO!(2uMa)Kzz(P}NPnUZnCG6?*D1 zAc4e$;WpzVB`xuq&5L*J(4_H+pNDegr&q+zc;i;0N0Cs3n$7EiKxw+OVfbF|_^Xaw z9*t6sk#G6dMG3ADRv!we>v4Myu^#PqD?#zLItxIi9G%hP3cmY(nZ2eniHQs_w)WX~ z#tSs(RClIS`rwN>xwB?9yFn?dN%IuPci&ION2OagkH6$!&iXZk)3U-nX zgL2_FDKJZtiDy>6H_eN*Sfi|rC#NwTXs26Uqlus~kDcAYv%$=BV=Vo0se%t?(G|Fik@ zMZ*qPW(K7Mgtzf9)DSm~)-V+(4_$k;%Kz#0PDG3qUQ(U!9#O$%-t*Wh4??#6%3mH= zt^KPZRr_!Z%1z48P===x{KW; z*;i{}_+KKQQ(syRx{Ytyn2$(7c2S9eV}k?8wY=r>y)x0h^EpncDK3w)f>sRfoOcBU z|H>v%(RmSnE;aj6XJBd&e|j~!rLAZrri{~v({ygMJBprUPV=UH<(E8(3v_ylU%er& znG#R!__Fu=?1rPK`?6?C<(7#69bS?xyWh5Zw4h25i{(F^kp(j3Q=`zb>Z&}~uXkmO zN;~u7*o^OEw2S!GXM>cKni;BLC=+gka1|%k_hFU1gnEjKlJadHI6g!474rhajgt zlW#}jSJ-_&&C;5qBwepE!!oEZ5ZfjA@X_K<6qf(HR1lpd!%%2U@KN|}Y7;~A=MRU5 zvP(CFK=Lu!0sDE$W8l{Y8%F;++|7>l0U5Ys^XV&(vgitB>YE&sK!v{_Yq`}+l$Dg& z`!b;r%$)-BOQNz@AzcP7QO#t!n1nMaROI(h?i$lgzzuP7bJHvh3xMXgGXn~dQv1Wx zi?*_EhiOA#MYD@?QT#lY{*a4a1NkkMBzK6`TNqcGjZ>@GXqa48j7B9lr1Ryjc>WF& z3u@=#=9Yha1_`(xFpssStjxJy#1O{_r?yi=#C@h#F)hcajH)huX_R zA7opc(2g11VV*6V49O@QZ>I}XRXXuw_Mdx38|ni$$_(J2iMm`O7NW(@LMsaC5}QA$du zy~zfEhu&Oaqg07>Rt!%E5ufH03lLWKCGk#~vah0th$A0+bQ>k4d?Ys0RZ;O~;C+@&#PMLXzbDxvNNMy4?gXMc9YNG!1=$f?q6lH2VOC zYdcV1$=KCB9y}+*fx#9Wcd7&8eO#JpyDHDgb?Vg+1A4R`kW{vZa=ZQGhCG`m=uMO$ zN9lPO}U>MEG ziy#a=n0w%uIU8{IF#9)}Sg$BC|9krzgEp~HqrS_esY=u$H?BJJnGA?VYw#DccKrSv z-)La2Z#p?K%XSCg+>Rz&_gB*cQM|#92M(x4Dj&Qr=j%^U_k}?>|BoE^*##2h>5)wa zw$`C5y^f&utlQ%fH;vKcdqc3Iy7Ih z)}UJJc5i)iKUV3^O4tI2^~;T-R9?wXqe5~Xz?T_E9mSuGyf1RMcL+_~SLSu}J(qnk zq2q+s)CDR}l`2p-8XD;z+i6bv?ki(LVC0%vvF}`^F$?!RTqmV&$O@Iej*(BW-=<@V z*<2g;_dH(*GML|$UU8IfQ^b=isb1qB2`UubB*n-<%NOgLQ(Gyd z#(fIYpCep}7M|3HVn~u|#V?5FHg@r<@=soNwko&*9J0nr(&^=6AT4#2?opeXZi!#V%R zRL9lAu*b?Id924fr!W+h_j`uH0Ucdi5_>HiFg(=A{QIet4Kg^-Zqf2OVssD~q8L%H z>qz5si6{DafXPxf0EZIsLit^`B2)56d_K zI7`NISeVdWZzge(3m>FhHw1QrH+wjL5Q2O3K31HYw&p8{)e~!4l|}-yRLJJBJRKcx zM9|H1h3uII(EBJ)OE5)(G5WLH85I4FO*y;vC$Dd|2ranuIOlTrzkcbb(=O=&1g|f2gK@rNT(z zS-i-Ui~rF^>J!vm8zS5M$K5HYZJr^6he0It4S+~Xjo(p6<3oida`P7Y5P9#uBX^8wg%BHRz=LXhUr~ffK7KLV! zUd4th|r zeQzuHcSn37z_ITCw2R^Lx=wocAL5|Em0<>Ln zk{pl;-TDWPWneizctHR_qUe?IkNeNdL!0)!fLqz921TV!&cA0ACv!hf^Wd1~Yl1=p zFEb%iea@tgGve~ysSU_*uu;UL-O4rNw>q9%PPQ-afZKhO4f&ISN&8tFQJ8Y$;8 zaUK5raXkGKJ21ruYj8|~&c=Q`$AYaq?{K~rPjmXW1BMxb`5JJT#Wq**@K+WrMOIzL z=ARJXgJao`Po^OdHglNtmYM%6O+^(5*>n~Cd%cZ~JK~T84e0}y94Vn0m_al{BjtME zw9+ZBAkE46BRGaiQ4&*_16gsioaNr1XyOm07u+*9;|WyUjvS(^h{)3k*4cm>$|)_n z+m=}bG7Xo1JD*{&-MJfZ9@>4` z(yBcTiTT@&ItnvqRb9uTkNl_9m};mCq4)l}{5Cznm63vJ0YKA<>5PGWFF)dSd>+W& zV2w}|6jA0IHD#*%Et7rmo7J*m!^7sjd)}L`$R3M5|m9WYo20D1L&+=dm z&RLC-s}KVUHKQkuW@ACl7~~%-QCfm%08nKT)1m|BENZP{&vDuJC_>c{5h%b7!-vv_H6 z>Xj+1Y602K$ijCMTS-3>mEnUO4B=fwr9EhRIvDNo{BZCXl~9{8Xd|c0icP+H*Da4g zmi0Hqu$;@fsvSvtI9N;b8+rZ7+G&fUoWr9A$Ey#FcE$ zd)A9ryBvxr3;3GmX8U2L4)viAIQ^me%(Gc4;K9AC;Cl%-OU0@^rDXH$@?ivl*fHtS zV>Fxmgc!ig4Au`VTuE{(QQz4~K%#eUuMbqm6V3 zRzFfbZk%}id(K4On(^fO>b%;Ls@vcgft%PT)f*EC${3wF6N$n~t*X30ZP!=b%C{ww zTz$VkD}3oDu@>#^`lqP0K8_FX9H65@L`p1=yn#Q||9B&?KDyC3v01MIVM`P85VAOW zy#?5XV0>|gaa1jJLNLeLm<7LCEOaEp?Jt7=(!avpAMtpm9d{oPiQ_{0SUK~0}^+EETzkz1l=1UTSAf6^3sI| zK*JcJ556x53I%BvA+Nl{Pcv&E-_`>qhT!5NpOGgS#(2D~>d9rHrv}TU7_z@GH|YmT z>Y{okZW?@9``_Q!xybCVPU@HoAKg9s_NKAG2#(oSWyJVMrzm~T+)skM2nF)lo8c!J zcanyZT^&1rT0<<67yhOH{G4y6Z!PqLNHCtxKDx`{tPR7-Yfez-j1>g_oiC(?WOdG( z*KvxX=j2Fpv8p~syz=kmhw^|5s?0JHgtBs^5rP|j=c_FIv=68;X;3gXs`&84?#mz? zLuLv*M+fhs`B(d>J4zw`p1v~CAGxpOE1S^6{LVP_Z1U`j3)jBtd#8@vSEWpo)etmt zbKio9GCI(tmEVh5D=G$i)*jgfk*d8nW80RJx&POS^(x0a$K;rkl9T^sWwt55>j*<`&OPV@z8GT>53kJvkX^UYp8TY{Ms|R@z#c zDOK!=iqgFE!Oy6p(al>>MxP^piR<__X2`JZ*Tm)8KgTcLK9N1ky2dAT)KNm|W*ql% zTzu4lhdY?xt!kXgGM|8SM6}7{4|!ZBVF0{^%vP4(&)kP-Dsw4^EnfSe-hs-=jYbp0 z)$aGVGH={>flv+S#cS&~e}af(b}9R6_8`ZZbpBH~O1z6i2Y&BE^l090N@+&VH@$G0 zl~u8iiSoZ&y~mH}FR3Us&ei$LLt~lJ`D})N*RFqmyp=pw3`1#umBZA7H#J4cQcYqu zTi9=F#?&nR`B%RP$$#wqE?LoTmY6TWy_tS2{iBI&N*|)Jc2-Z-a{K6!{~?|5v#G@0 zQ<=(fZNAOsN=AFzhW_5CmI#{4@q~4OdcL0pQJTzj2gawL7)i*aJ7McVT&IS7JGrGZ zXB2x?I|#fFH2)njB?dUEkLC*ot*+to2(0V77%iU2Z;v(*m=;>Arr1_@Y{!~ncRH?Z zvgdR#t&L*=2i&GcfKJuke1XwY`sBm?2Wwi{btg|u(mijdsTz;P%Q|%pZpd7L^709` zhfPE<9rDAZW_rR&AM)vMgSXZ)EkCHW7igN!R0Z(Lixpwva1Co4a8Y5>68?T^HzA9>m{S_f;V6Nj8H z@Aj?#WJ*n+YU2PN2L&LD!7%IUTyi@E_9U@Rwa*4Z3YtbH^ZnS`&M9$?vEK*jyPWeP zn^b7*ZNP(abCb+h74|Pqpr7KfD}rv-Pc1OeY_o2A&9Aclb+*Rw>|!OPsWWVU>;UcP zUzFMY(y2Pt+c-5s`k|wPu1WW&+T%7Fp)YcDyz0|z_-?fFBr!Ar9WIsg+2g@R0h=T$ z(g)bwK%>@6V%hl^UEFU@NcwCkGrmns_bh9JAinu^!Jw_;>eix#672UEo4Y4$bB_Tg-Z-F!W{EoeT+TTS$5J+xG~^*rqzv&ttf#9S&0=o2S8$c$@D; z0X%hns~ahn^iF1?FZ3%RO6NheKFIsJ?5mK2`f1^_XD;bIkMm?>)R9Zl$+Y;G)PUhw zyF&3Uq~MRdA!hH4EFw=ZkI@1T?X^b$z(DhraIcyEDTMR#$(`7n)dXg*XC_R+fOg8Y zw&GiwEfieLcDc#G zZ%I1zj+w`sl8eDWH-#Y~H*bA~p=Z^QR!?v&pDrxbzIGoGbu7jnBTw670FF+0yW6+e zNYJvn_@l~+%c^^Ub;a`|RRN<~?vLZ1k3O!t1%+#yl-1*iDJwi1gSeK}T%Rx7^(T1% z`Affn8rMW=aa0Rwq)4L&e*IaZwtq3=CSY>kMZib=W`vy=FK>uNS3kib|hMZg_X{ z(;fdnBii1IZsL8}MNXx`8h6Nl9}#Yf4RHi9WX^F~a^vM7gZ@25ybyU6&)iHyVdwnY z5hPdEtYCvRo|f_H-Dm7o5BZBv4J1G9H~6yOSd|7hS@qOPOGtE;PnwpZCsr!my|gK= zpMv;V=B|~)nPF$B(m&8J_VQ<2@dB&xX){lu`_ny|E6@NA)c+a*a-_7MA9gN9-245q zsET^+i)8YBXX$StXG*`k4BLiD0-%OW-*mYq+KamWMG2hQu|9m}iPN9mEV&wstPL3l z?qV2*s#)7>T|n8~Aa9y69WU45wf{G!Tc;0_@a`94-*5SPD-EN`=$<;NsXu-&>~hFg zvyXUr4GJyWIMjNfk4bcoktkESB8OMmeZ_<;OAQEw|Zi_dhC-EMh$Kfe*k}Zm-f|5U4Ku6D1Q-;T{?W9 z+{ok@qWyM10SwJ|v3KI5FWk?2x%T8JhR6U?mKK@Doiuv%l^I+V9`Xat+hOeT3RMy; zYbhri%F<8rC9J>7QIim)B5HJ}{;nih=Z&s%YS=!1`HvL`?Q#yxIVEuyH*;X6#bJuS-Bwn^ta@>t0}i9-!cVX z@O!HEDBLK*Kx%uNN{5Zl}9(IZehwdh|&t{un6RWpjFtj z7*`851+RAGN*5xG0`S|-JqdEjY9Ej zkR?s|$fP-n#PKosTwPxX_~$Rvsk13-_XEE1KPLM!cw#XNh53qe2oherj@b0V1-A_y zNyqrlK4+K@NBCum28uSU1p6U7y~W%ZMhGBppLCr8`Dfa(A#UfzibIa^r4`5R-XF+hjk5k(XZg zM|Tsb8N8X*AYB!t{OIqqnBZ&r2(`lsqmQ!F+M1#?xSHM5hV1e`-DdEiv)bG}<*8SP z$4biXYq7TTlhH3P456tKym>DPyH#)fZY&^hB=$$Y3M$L{37cX79DuBPLv`7ru;=M~ zQKCK7g97f~UMBRoa{P9(zFowh`RMia4_r(phGue2U1&1B@;`50r`wty)1zGRLN{ikl1Ow9(!L@_pWOpJ!2nAVas(Gs*U~=(f;8Q9CvV7WxmgO!km=fb`5+%9?a%e zU-G|xFtdBB=c6}|nRBJyFCp)*wRU?sZ-=1%YIOCO^p8lE=6-NKMXED=;STdXsPyhC zh&%rvua0Qw?gle(hQfz~vELPxomTguk7Bq59hEmy*&vL@AdCX@8U7pJl_gm{{{$;S zW;O=mNcv}%sTVNRqr$g&CtHz;dE06AB~w&*S(|iJn%6C>$-jBT9X;p1IKFf%gGl{L zGo{~qux9)&%TtfrSe}9q1sRHR`?y+g&9&vOmd*Ipp@p|-Ez|Y7^I@H9@9K88C=Pfq zrqI_50)?bJg&NVH&c6IDXuA`zI~O8yzracs7KeBjobXE6a!WU=6>@sdK^T;3BJ!QTN9(i*mNRVHc>G{u4S2;%9gO z%`rVFZMbH*&4;Ip+#Ev!93@eKH^V)fuLr+l)kR9qr|J2sa{_&#J`y7{^sR5t9>zg=Zu*XKWf1 znYd7+{^*BCWAAe|#sn~-S=i$^U{odDv2|0){r#(?#;XCl?=EkwypAVuQsmpKdUXc{ z&dFcS#{d52!EtuIX5V1hP?ajkN_ys=j5JtDk}0|reO5KD(5Oe8*kot_`!w$;MU)92 zn+f#Zt?E2KG^-weFgws*97gG0{x=1yYdwIz~$++6$L*|8mnS(7Fz%Ly;qXU7~XFn9{)G6n1k$EQJ) zz+IQdHE9pY00)JV%LkBl>#A44&evKe=l1wuuK=sLAv*8iXIHCLD<*9+5`I{$-F(sZ zGnnQ>`RKdsyw!&K`Sz;2$+HKL=y}+^I7(9twyzHj6eMk?S5e$^qs^FI+#SHa&w|Ud&z2hn;qYp82IX9j#U}NA@ysVM7MA3TLF}M)OYsAIq!UP2kG#@pNy8pz{gl8 z4YD3oeyVYNDo-`DR}Eb}C`Jz}66)5R@6^lp7yVhwG0cosHtLU2A*)Pb z*l|qBG~(rZGlF~^qy50ZD-*dBZa0BP}H^7p{4+4f=L#=8wEsVaE5q zv;N#+F>KpBwem1a?VZmj-%d9qE*`uG@`dk=k1yldK1~T(wo;(xCX0g!?35`C)3&4Ed$$RMW8(0i7|T@&2?^?;IVeOpq-vAM$Fm;Y_T@w0C! zYmV6girf|&|E*C!<~YmJ;kF2saWNUTbnsK(u)}kG`cn_F%)g7(=N0y}4UHg~P^Fb& zMqBBam^YTTps6a&cW?2u*SzrOOHSDu(E3H)Lv{PpVVdK}5TA%ZiE0{muajWzPT(k7 z_2Iq;fT(`o+8(~XLVb~6mlc#ytous+N)t?%DVjjjvVA&w#S;)yR9&aH=cpux*HFsw zv_1TW*!C9zxXPUAT7m9Q84N}^}GyvWbG34U#!CCgh0jgg1nD;8Lp!G zY&&~Z$87WdY{y<|{?>h^1edea(~a=$vC8R9j!o-l3Va@}f6SDthr1m_CTRd4R3WL5 ztJrk`d81Fbq)@OxV410CPQK>k`(-Iv*}E$HIH%Lonl${9l{i<#(16q$snA-q-JywR3bjro(dPs9q z>Wzy-vG3RFx5A@d?`^l)f3uo)GHYj}Rz+~-vQ)j7`Vkf_u6*aLt{Tb=5} zHkXg&5V`PuIH78TE=XTMcl&K(zyE}DJvTd%7?^8xyU#RqTbKkFL0E$QM_ zr~1&D8G?*~r{3f5@uhdP?Ye6Fth9v(q zcq`lto7=9O7OM8J|J7s~{$F9T@$DY8nhfte`-r;nEx+bh6l0J*#*zWn`x8tF8bx_d z`EbK0?O=bJ~Me;U-4EJ7u7Qx-Ngu z|6c%rTmR4(Pi_WZFvbfgDJ`u?DAWu8om4&DRJgb9ji-ycFa`st!LPM=*ewziXYueA zZVm&^hi*=NWnp?97YuoI?q3d{HLtJrRYD~4tlOV|n4o9=^zNvn3>c;JH^Re|;T!bRCLhk70s=tCF?dcCl&dGLBvn=5PJuay~x)}*0J zeQ4~>5=UFTA1ODrQ(*viE*NZs--!%Io^WL`{4M+%9o*dyCN|TO*Tvp(#S-Fe)C(ZH ziMFV+3>Dl@0WYB81AgyXtxDx5yPcPzoDrz`b@g=?X>2Y(IGvbMtA!RjUi}#izcyJq z4%PSnqgy^ygFWx$yEbg(%3!k6P;dXk>}^6hP2TXH*T@me&`u1QTN%_|dFZ9ng;sR* z9LL%iB_`EX9CTPpTM5s3LcF;+f-K|W^#RDjB#AR5Rt{*(JT>1;pzdK8js z(_5|IM~4Vf*alnaaIF5TWoKFSTPDWCt7?oy2g-`s50dTIh`_|}SuLx*6F9A=cN{B=;s7XgN#N6j7|#W|p)+pnNio3#>R%Rv#2& zdUdO7mRDC6w0%~F-RnH52hD~n@qM;=*@>~<*BFNe!%>R&%s(QZI2m1BQcEOrz%;~< zT_%c{oA3-efwOt@St@$0dG^#!_B36Y4~iuBYdr3j?CCd`-&k{w>>xgVcBHgcK5pjL zm=hbmmdyFXaX;Mx1!PrpXly^ zI%avKn@)+S;bs5(V^5{Z{zw^qtY4r?W3ej^Zzj0ZP z;>Rb8b8>j~o8*)@&KXTK8?b@C`5k49#;#~ex$1=Ww@u1s&l66cJ>$(2f4wt`eEIku z5YDR4UG&!b!iMuZKQhMmM%4G625Lzuq|)>HjCoZPk1T1jix|y?LYE1+Z}rv~;MU+1 z=@V@e^j!`ku%2E;w353jICiE#*3EWZ{*R5{l5=Kxu`CFBbl%}8V{3I~@HNqL2(tH5d{4NZ6iZ@> zwW`#54>Kdj*!$A!J*YXLHsFLG$F=u<$^O3n$@Dlfg2a-!&M7%KGejDPfJgCD-(FO? z@QPj5PCAlR54MC+-4D>Hl?b<$+LjefZ2Ygh@;o zg~>?vNk$UJG8nrgDGHGxOZF*-!Vt0*UZuR2-XuzyXhGS=)(}}z_T5-AWM32C_1(Xp zbIv+Qc#L0|eZ1yb%NnS?y;D~Q_dDYJ@!Ng=%?rd&uMnsgY5Pqs zk%H|P3}lNSTK+*FSc!AEFbJ0Z`LXJp;1dzBeb0m>Hz75aT2cHq)SY0IhjRLJxU;(K zltzT8j00yehB0qia$;MbBM`_IXGOc04Wx=WZ$?Mex=Avho+!$ayt{qd$Zm6xYPI3C z<~oLOiu&vsxO2vyB0#&eSB)~2bXmXV?2zHW2juC3Im^_$x+Kjp^e!tp z7XEcr&&MFN#0RB`jsI!CZN!IU$j+372Hk%7cK140U@z2#HLPFzZ9IlLEc(^I&%O~E z7^QDBKV2Sz4*D$B52{n@Ni^>7luE!N5SZ1;KljUj~7lLU#M%fU3^M{RC3FdgL z8q8QIBCuP<;MqI0rVcIE@KLSk?kwr>Id2)0BL)|8bk>vlb0(NxM%?mfnjo9*9dZSc z09)zVraI}-x4MQqx`C9!S)-+$y&exBz}xpxUAv7L}T6;`OF;h!{z?Tso0 zI60q}Thi95kl#bAUT;Dvh}5kOc1R)b&a^EXTbD?42Fy^ab^h7{oczF*(D#xx#^I~q zM5W&Jo;sYPNPJCT%M?zH`SKxYu3C`DWL8B4)3$4&h?el(%_eEP8~gh4F(ck&mq4Sx z$9>X$jv$Gg)Aq&lUhCiUge*hBfJ$tvVyeRae2MS9Hwa8A%}2glQ~s2vEetoHY~Ivm zK(Ss41~w4W8m1eFWJa0V)iJ{_c@UWM(?SiEnk_Q#{ou*hK6_>i86W?L0tT-Ukf_b1 z$C?hmgep2b3Cw&1^}_u>`|Y<+@yUR6Q;7y%90L|&tRF@YpSIx6_3f^L*J9-C=oxW{ z!VVM8@5d=JL&g1GIVW$rD1)(r#xx8S_;NQW?t+VGTHDGGRq?r_l&~tEqlonb*c`J( z?A3biHr7@>gN) zg80)}Z27C03AWY0e((|UpjpQH)av~|1+rcHMzqcx6N#!lF<=d~D?2hfcH3NA)p25M z-Zv8iec=aDEIl&qa8T1!;BHp1GF8!BfDMi2~-*4FAL|%DdQTIJN&Bnj^?B>#ZEx{KUUzkgr z?b+oLKhoR3EQy-jIZcwwIp^8sRIt80&#?yE@~iq)ot+TAHiEz+g?0);^6*23fC33^ zxf-nTszggjoBAT-=0$I%nSt&dK7pjZR?@Eqcyghj)d%km$Jps4khpg68cP5NF1O(S zrgVL4)_6Wa@G_DhP?1@AJnUT2NKV<3aOC^hfpu>5(B>ZO10V7HRzQ4!-;_Q zJuiAIP!{!LnM=zKZ{lub@(XE|#xVITMh*OgPcevd{9gnNX4(>pB$isx5eT zH_H5N%(wO1Gi#czP$>?gfrxcSSAnPG5?7GG0aY}OhHRoW@$RaW%yjehJ%Dn`K~uDl zXy_Ln;5`m*?9ZV=z^A?6^_!x9HpCUVYS(EM{Iob%@@h$&A} zb5(*~hac1U)Y|3Kw5jmMhjq8|rU?$HxLjCoZPV%$-Bcy*pc$n8V0V@+fY&loGuzkv zTN(fI*;Jz6WA1H7aL{531DcmAsK8v-ML;earI>p$NgRy)#N%1^ZgyeeRBv`))?mqavQ9B*Zwy^aWYhur zM+o$QgW)FD=^MaGx3!v|stZ-Ig7}5(1#)9onYhEL>BbPY1>3r{lbIyeqOHsPFqy+1 z%J*SgAS-1mT5$c7{5rDe5H#v>R74C(lol|q|6ta$P4-&-&0ErGxlja|MG5VFiN~ia zZ#2NPA2id-lP8BQZx3NnYzmpM(HSlCz zvE#>QRWy?4Zx;4aY6Hc1w9&xyt@{tbw%|%rq4juYIqE|4MI1=urWJXlCT~tr6V|zM z{`^xSd4A0ahP`2$flezo`4KnP5BYRt$%qyM+p*XiwUfMYQPsnh?}Dr6)E;Kpg2t-K zWqm0($_>FSXo3-@R3hn$MAK9fjQ8tJI)+>^6*pXohCy;$UJNV^<%Z5Xyvi;$rD@1Q zeRi(B806|2w@Qx9qQPr}R@@rB1>b(b^P5wtCNLZ83^V4%FYx2< z)%#~B77qQT^asas*1?JCzRgR z>N#W5<61be%BsXQOjlXwowerWR%N9C9FTPR?~rmKSB5ZUNneK*p~;fL0hZk(yR@mS zX7**!7y$bXES(6c!$rWJ6J^}jiiw*JfNF%bFMjW7e(W0h^^Da% zYX!^egjDe&a3Ce(UMz)_s43jGc(g7))Xm%In1~!2@MOFHc-T6m(;1E9H|DjTiqK)- zb%u<+BBBn1+m^>ynU;{~Y=CvDDNBBMoX4n?=_SLzDPGp~U!sKZZ7 zOg)Tob*neqzQFv~20Cn{ho(62(4hnuWV(FY$4>%DDh7=EAi$gXPUKB!8r33! zzixyZ+sS+$`c)c2|CBGqwXvJoSyQiujz#LglthU=f%KbA1#rL7)0JvsVv%YmzfcD9 zKaE^aCBv{=nO{#dFSkxSP3LW}=bDTgV*`3omcm820}%6XNJ;fuNQ-Ij8K3a}GV|qB z2^rGSv{N6#Y3ql(`3JV)vJDq=W5 zdY~|;lzK;K(V zH;#aV3?~1Ip8pkuOl9)XphR`~ztsF2-?9Z>?GPk#QZ~cK;*Y_@Z0OtodQ1!~NOV1? G$o~QRsPyvy literal 0 HcmV?d00001 From 42310ef70aa9930a8585484cee71ee948324a877 Mon Sep 17 00:00:00 2001 From: Yahya Fakhroji Date: Mon, 27 Jul 2026 19:31:33 +0700 Subject: [PATCH 6/9] =?UTF-8?q?fix:=20address=20team=20code=20review=20rou?= =?UTF-8?q?nd=201=20(mattdjenkinson)=20=E2=80=94=208=20findings,=206=20are?= =?UTF-8?q?as?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - reauth: never trust a client-echoed absolute returnTo; re-resolve the configured default (admin console / Zitadel default / env default) server-side instead of silently collapsing to a hardcoded /passkeys. - login/method: the IdP entry now POSTs through an action (CSRF, server-side linked-provider re-check) and starts sign-in in place, instead of navigating straight to /sso with no verification. - webauthn: recover from stale cross-browser session tokens during enrollment (loader pre-check) the same way other surfaces do. - reauth: the idp-reauth action verifies the target IdP is actually linked to the current user before starting the OAuth round-trip. - passkeys: serialize concurrent removals (per-user in-process mutex) to close a TOCTOU race in the last-method guard — two simultaneous removes could otherwise both read "still have a backup" and both succeed. - reauth/fake-provider: corrected error copy and removePasskey fidelity (static seed authMethods now cleared consistently with the dynamic set). --- .../auth/providers/fake/fake-provider.ts | 14 ++ app/modules/i18n/locales/en.po | 46 +++---- app/resources/passkeys/passkeys.service.ts | 75 +++++++---- app/resources/reauth/reauth.service.ts | 19 ++- app/resources/webauthn/webauthn-enroll.ts | 15 ++- app/resources/webauthn/webauthn.service.ts | 21 ++- app/routes/login/method.tsx | 123 +++++++++++++----- app/routes/reauth.tsx | 37 +++++- app/routes/reauth/provider/callback.tsx | 13 +- app/routes/reauth/provider/error.tsx | 2 +- .../modules/auth/fake-passkeys.cy.ts | 16 +++ .../resources/passkeys/passkeys.service.cy.ts | 24 ++++ .../resources/reauth/reauth.service.cy.ts | 29 +++++ .../resources/webauthn/webauthn.service.cy.ts | 16 +++ .../routes/login/method-chooser.cy.ts | 68 ++++++++++ cypress/component/routes/login/method.cy.tsx | 24 +++- cypress/component/routes/reauth-action.cy.ts | 73 +++++++++++ .../routes/reauth/provider-error.cy.tsx | 2 +- cypress/support/node/harness.ts | 70 ++++++++-- cypress/support/node/scenario.ts | 6 + 20 files changed, 584 insertions(+), 109 deletions(-) create mode 100644 cypress/component/routes/reauth-action.cy.ts diff --git a/app/modules/auth/providers/fake/fake-provider.ts b/app/modules/auth/providers/fake/fake-provider.ts index 5c0fdb9651..2f2980b199 100644 --- a/app/modules/auth/providers/fake/fake-provider.ts +++ b/app/modules/auth/providers/fake/fake-provider.ts @@ -115,6 +115,9 @@ interface Seed { string, Array<{ id: string; state: 'active' | 'inactive'; name: string; createdAt?: string }> >; + /** Pre-linked IdP identities (userId → links) — a constructor-time convenience for the + * same data setIdpLinks/addIdpLink set post-construction. */ + idpLinks?: Record; /** * Stamp factor verifiedAt with REAL Date (new Date()) instead of FIXED_NOW_DATE. * Sudo freshness compares against real Date.now() at the route layer, so the e2e singleton @@ -205,6 +208,8 @@ export class FakeAuthProvider implements AuthProvider { // Passkey inventory seed + real-time factor stamps flag. for (const [uid, list] of Object.entries(seed.passkeys ?? {})) this.passkeys.set(uid, [...list]); + for (const [uid, links] of Object.entries(seed.idpLinks ?? {})) + this.idpLinks.set(uid, [...links]); this.realFactorTimestamps = seed.realFactorTimestamps ?? false; this.passwordComplexity = seed.passwordComplexity ?? { minLength: 8, @@ -680,6 +685,15 @@ export class FakeAuthProvider implements AuthProvider { const set = new Set(this.enrolled.get(userId) ?? []); set.delete('passkey'); this.enrolled.set(userId, set); + // listAuthMethods unions this dynamic set with the SEEDED static authMethods entry — + // without also clearing 'passkey' there, a test seeding both authMethods: ['passkey'] + // and a passkeys array would still report it enrolled after the last one is removed. + if (this.authMethods[userId]) { + this.authMethods = { + ...this.authMethods, + [userId]: this.authMethods[userId].filter((m) => m !== 'passkey'), + }; + } } } diff --git a/app/modules/i18n/locales/en.po b/app/modules/i18n/locales/en.po index f4e9da6eb7..24bb2403ec 100644 --- a/app/modules/i18n/locales/en.po +++ b/app/modules/i18n/locales/en.po @@ -137,7 +137,6 @@ msgstr "Available accounts to link" msgid "Back" msgstr "Back" -#: app/routes/reauth/provider/error.tsx:39 #: app/routes/sso/provider/error.tsx:77 msgid "Back to sign in" msgstr "Back to sign in" @@ -173,7 +172,7 @@ msgstr "Choose a new password" msgid "Choose an account" msgstr "Choose an account" -#: app/routes/login/method.tsx:107 +#: app/routes/login/method.tsx:179 msgid "Choose how to sign in" msgstr "Choose how to sign in" @@ -189,12 +188,12 @@ msgstr "Choose your login method" msgid "Code expired" msgstr "Code expired" -#: app/routes/reauth.tsx:344 -#: app/routes/reauth.tsx:366 +#: app/routes/reauth.tsx:369 +#: app/routes/reauth.tsx:391 msgid "Confirm" msgstr "Confirm" -#: app/routes/reauth.tsx:234 +#: app/routes/reauth.tsx:259 msgid "Confirm it's you" msgstr "Confirm it's you" @@ -218,8 +217,7 @@ msgid "Continue" msgstr "Continue" #. placeholder {0}: idp.name ?? idp.idpId -#: app/routes/login/method.tsx:197 -#: app/routes/reauth.tsx:292 +#: app/routes/reauth.tsx:317 msgid "Continue with {0}" msgstr "Continue with {0}" @@ -285,12 +283,12 @@ msgstr "Email" msgid "Email code" msgstr "Email code" -#: app/routes/reauth.tsx:309 +#: app/routes/reauth.tsx:334 msgid "Email me a code" msgstr "Email me a code" #: app/routes/login/index.tsx:522 -#: app/routes/login/method.tsx:161 +#: app/routes/login/method.tsx:233 #: app/routes/signup/method.tsx:339 msgid "Email me a sign-in link" msgstr "Email me a sign-in link" @@ -364,7 +362,7 @@ msgstr "Enter your SMS code" msgid "Finish creating your account" msgstr "Finish creating your account" -#: app/routes/reauth.tsx:237 +#: app/routes/reauth.tsx:262 msgid "For your security, verify one of your sign-in methods to continue." msgstr "For your security, verify one of your sign-in methods to continue." @@ -405,7 +403,7 @@ msgid "Linked accounts" msgstr "Linked accounts" #: app/routes/passkeys.tsx:227 -#: app/routes/reauth.tsx:241 +#: app/routes/reauth.tsx:266 #: app/routes/sso/index.tsx:137 msgid "Logged in as" msgstr "Logged in as" @@ -435,7 +433,7 @@ msgstr "No account was found and sign-up is not available." msgid "No passkeys yet." msgstr "No passkeys yet." -#: app/routes/reauth.tsx:314 +#: app/routes/reauth.tsx:339 msgid "No sign-in method is available for re-authentication." msgstr "No sign-in method is available for re-authentication." @@ -458,9 +456,9 @@ msgstr "Not registered?" #: app/components/identity-badge/identity-badge.tsx:30 #: app/routes/device/authorize.tsx:122 #: app/routes/login/index.tsx:416 -#: app/routes/login/method.tsx:116 +#: app/routes/login/method.tsx:188 #: app/routes/passkeys.tsx:228 -#: app/routes/reauth.tsx:242 +#: app/routes/reauth.tsx:267 #: app/routes/signed-in.tsx:49 #: app/routes/sso/index.tsx:138 msgid "Not you?" @@ -479,8 +477,8 @@ msgid "Or import this URI in your authenticator app" msgstr "Or import this URI in your authenticator app" #: app/routes/login/index.tsx:462 -#: app/routes/login/method.tsx:144 -#: app/routes/reauth.tsx:272 +#: app/routes/login/method.tsx:216 +#: app/routes/reauth.tsx:297 #: app/routes/setup/mfa.tsx:46 msgid "Passkey" msgstr "Passkey" @@ -517,9 +515,9 @@ msgstr "Passkeys" msgid "Passkeys let you sign in with your fingerprint, face, or device PIN." msgstr "Passkeys let you sign in with your fingerprint, face, or device PIN." -#: app/routes/login/method.tsx:178 -#: app/routes/reauth.tsx:301 -#: app/routes/reauth.tsx:339 +#: app/routes/login/method.tsx:250 +#: app/routes/reauth.tsx:326 +#: app/routes/reauth.tsx:364 #: app/routes/signup/password.tsx:229 #: app/routes/sso/ldap.tsx:80 msgid "Password" @@ -719,7 +717,7 @@ msgstr "Signing in as" msgid "Signing in as <0>{0}." msgstr "Signing in as <0>{0}." -#: app/routes/login/method.tsx:110 +#: app/routes/login/method.tsx:182 msgid "Signing in as <0>{loginName}." msgstr "Signing in as <0>{loginName}." @@ -845,6 +843,10 @@ msgstr "This sign-in provider is currently unavailable. Please try again later." msgid "Too many attempts. Please wait a moment and try again." msgstr "Too many attempts. Please wait a moment and try again." +#: app/routes/reauth/provider/error.tsx:39 +msgid "Try again" +msgstr "Try again" + #: app/routes/login/mfa.tsx:124 msgid "Two-factor verification" msgstr "Two-factor verification" @@ -876,7 +878,7 @@ msgstr "Use your security key to verify your identity." msgid "Username" msgstr "Username" -#: app/routes/reauth.tsx:361 +#: app/routes/reauth.tsx:386 #: app/routes/verify/index.tsx:159 msgid "Verification code" msgstr "Verification code" @@ -926,7 +928,7 @@ msgstr "We couldn't set up your passkey. Please try again." msgid "We couldn't start passkey setup. Please try again." msgstr "We couldn't start passkey setup. Please try again." -#: app/routes/reauth.tsx:359 +#: app/routes/reauth.tsx:384 msgid "We sent a verification code to your email address." msgstr "We sent a verification code to your email address." diff --git a/app/resources/passkeys/passkeys.service.ts b/app/resources/passkeys/passkeys.service.ts index bac1656ec9..d6a935d727 100644 --- a/app/resources/passkeys/passkeys.service.ts +++ b/app/resources/passkeys/passkeys.service.ts @@ -108,6 +108,30 @@ export type RemovePasskeyResult = | { ok: true; removedName: string | null } | { ok: false; error: 'SESSION_EXPIRED' | 'SUDO_REQUIRED' | 'LAST_METHOD' }; +// Per-user in-process mutex for the last-method read-check-remove sequence below: two +// concurrent removals of DIFFERENT passkeys could otherwise both read "2 passkeys left, +// no other method" and both pass the guard, leaving zero sign-in methods. Zitadel's +// removePasskey has no conditional-delete primitive to make this atomic server-side, and +// this app has no external lock store — an in-process queue per userId is the realistic +// mitigation available here (closes the window for the common case: a double-click or +// two tabs hitting the SAME instance; a multi-instance deployment would need a real +// distributed lock, out of scope for this fix). +const userRemovalQueues = new Map>(); + +function withUserRemovalLock(userId: string, fn: () => Promise): Promise { + const prior = userRemovalQueues.get(userId) ?? Promise.resolve(); + const run = prior.then(fn, fn); + const settled = run.then( + () => undefined, + () => undefined + ); + userRemovalQueues.set(userId, settled); + void settled.finally(() => { + if (userRemovalQueues.get(userId) === settled) userRemovalQueues.delete(userId); + }); + return run; +} + /** * Sudo-gated passkey removal with the server-side last-method guard * (listAuthMethods IS Zitadel's listAuthenticationMethodTypes — refuse removing the @@ -129,33 +153,36 @@ export async function removeUserPasskey( return { ok: false, error: 'SUDO_REQUIRED' }; } - const [passkeys, methods] = await Promise.all([ - provider.listPasskeys(active.userId), - provider.listAuthMethods(active.userId), - ]); - // Refuse removing the user's FINAL sign-in method: at most one passkey left AND no - // other method enrolled. - if (passkeys.length <= 1 && !methods.some((m) => m !== 'passkey')) { - logAuthEvent('passkey_remove', 'failure', { - userId: active.userId, - reason: 'last_method', - }); - return { ok: false, error: 'LAST_METHOD' }; - } + const userId = active.userId; + return withUserRemovalLock(userId, async () => { + const [passkeys, methods] = await Promise.all([ + provider.listPasskeys(userId), + provider.listAuthMethods(userId), + ]); + // Refuse removing the user's FINAL sign-in method: at most one passkey left AND no + // other method enrolled. + if (passkeys.length <= 1 && !methods.some((m) => m !== 'passkey')) { + logAuthEvent('passkey_remove', 'failure', { + userId, + reason: 'last_method', + }); + return { ok: false, error: 'LAST_METHOD' }; + } - const removedName = passkeys.find((p) => p.id === input.passkeyId)?.name ?? null; - try { - await provider.removePasskey(active.userId, input.passkeyId); - } catch (err) { - if (!(err instanceof ProviderError && err.code === 'NOT_FOUND')) { - logAuthEvent('passkey_remove', 'failure', { userId: active.userId }); - throw err; + const removedName = passkeys.find((p) => p.id === input.passkeyId)?.name ?? null; + try { + await provider.removePasskey(userId, input.passkeyId); + } catch (err) { + if (!(err instanceof ProviderError && err.code === 'NOT_FOUND')) { + logAuthEvent('passkey_remove', 'failure', { userId }); + throw err; + } + // Removal race — the passkey is already gone; treat as success. } - // Removal race — the passkey is already gone; treat as success. - } - logAuthEvent('passkey_remove', 'success', { userId: active.userId }); - return { ok: true, removedName }; + logAuthEvent('passkey_remove', 'success', { userId }); + return { ok: true, removedName }; + }); } export type SignOutOthersResult = diff --git a/app/resources/reauth/reauth.service.ts b/app/resources/reauth/reauth.service.ts index 8255be9b4d..e065c1ea2c 100644 --- a/app/resources/reauth/reauth.service.ts +++ b/app/resources/reauth/reauth.service.ts @@ -198,6 +198,11 @@ export interface ReauthPerformInput { idpIntentId?: string; idpIntentToken?: string; returnTo: string | null; + /** Same context loadReauth's resolveDefaultReturnTo needs — used ONLY to re-resolve the + * configured destination server-side when `returnTo` doesn't validate (never trust an + * absolute URL echoed back from the client, even one the server itself set moments ago). */ + consoleUrl: string; + defaultAppUrl?: string; } export type ReauthPerformResult = @@ -279,7 +284,19 @@ export async function performReauth( expirationTs: session.expiresAt, }); - return { ok: true, target: validateReturnTo(input.returnTo) ?? paths.passkeys(), sessions: next }; + // Never trust an absolute URL echoed back from the client, even one the server itself + // set as loadReauth's resolved default moments ago — re-derive it server-side instead of + // falling back to a hardcoded /passkeys, which silently dropped the configured + // destination (admin console / Zitadel default / env default) whenever it wasn't on + // POST_LOGOUT_ALLOWLIST (a different allowlist, for a different purpose). + const target = + validateReturnTo(input.returnTo) ?? + (await resolveDefaultReturnTo(provider, entry, { + consoleUrl: input.consoleUrl, + defaultAppUrl: input.defaultAppUrl, + })); + + return { ok: true, target, sessions: next }; } export interface StartReauthIdpInput { diff --git a/app/resources/webauthn/webauthn-enroll.ts b/app/resources/webauthn/webauthn-enroll.ts index c774c5e8aa..dbd353ad16 100644 --- a/app/resources/webauthn/webauthn-enroll.ts +++ b/app/resources/webauthn/webauthn-enroll.ts @@ -34,6 +34,8 @@ import { } from './webauthn.service'; import type { AuthProvider } from '@/modules/auth/auth-provider'; import { readSessions, byLoginName, type SessionEntry } from '@/modules/auth/session/cookie'; +import type { Session } from '@/modules/auth/types'; +import { isStaleSessionError } from '@/modules/auth/types'; import { credentialSchema, setupSkipSchema } from '@/resources/mfa/mfa.schema'; import { validateReturnTo } from '@/resources/shared/return-to'; import { isSudoFresh } from '@/resources/shared/sudo'; @@ -185,7 +187,18 @@ export function createWebAuthnEnrollHandlers(cfg: WebAuthnEnrollConfig) { if (cfg.requireSudo) { const entry = byLoginName(sessions, loginName, organization); if (entry) { - const session = await provider.getSession(entry.id, entry.token); + // The stored session token may be stale/revoked by the time this route sees it + // (e.g. created in a different browser) — getSession throws a non-transient + // ProviderError in that case rather than returning null. Recover the same way + // passkeys.service.ts/reauth.service.ts do instead of crashing — this pre-check + // is UX only, requestAttestation re-enforces the actual sudo gate below. + let session: Session | null; + try { + session = await provider.getSession(entry.id, entry.token); + } catch (err) { + if (!isStaleSessionError(err)) throw err; + session = null; + } if (!session || !isSudoFresh(session.factors, Date.now())) { return redirect( paths.reauth({ diff --git a/app/resources/webauthn/webauthn.service.ts b/app/resources/webauthn/webauthn.service.ts index 1a380e14b9..625b653c6c 100644 --- a/app/resources/webauthn/webauthn.service.ts +++ b/app/resources/webauthn/webauthn.service.ts @@ -18,7 +18,7 @@ import type { AuthProvider } from '@/modules/auth/auth-provider'; import { byLoginName, addSession, type SessionEntry } from '@/modules/auth/session/cookie'; import type { Session } from '@/modules/auth/types'; -import { ProviderError } from '@/modules/auth/types'; +import { ProviderError, isStaleSessionError } from '@/modules/auth/types'; import { nextStepFromSession as sharedNextStepFromSession, threadParams, @@ -492,7 +492,24 @@ async function verifyEnrollment( // verified within the sudo window. Runs before the provider verify so a hijacked or // unattended stale session can never plant a persistent credential. if (cfg.requireSudo) { - const sudoSession = await provider.getSession(entry.id, entry.token); + // A stored session token can be stale/revoked (e.g. created in a different browser) + // by the time this route sees it — getSession throws a non-transient ProviderError + // in that case rather than returning null. Same recovery as passkeys.service.ts's + // resolveActive: treat it as no session at all, not an unhandled 500. + let sudoSession: Session | null; + try { + sudoSession = await provider.getSession(entry.id, entry.token); + } catch (err) { + if (isStaleSessionError(err)) { + logAuthEvent('mfa_enroll', 'failure', { + userId, + factor: cfg.factor, + reason: 'session_expired', + }); + return { ok: false, error: 'SESSION_EXPIRED' }; + } + throw err; + } if (!sudoSession || !isSudoFresh(sudoSession.factors, Date.now())) { logAuthEvent('mfa_enroll', 'failure', { userId, diff --git a/app/routes/login/method.tsx b/app/routes/login/method.tsx index e6b09ad972..2ad754834e 100644 --- a/app/routes/login/method.tsx +++ b/app/routes/login/method.tsx @@ -1,11 +1,14 @@ +import { IdpButtonList } from '@/components/auth-form/idp-button-list'; import { FormError } from '@/components/form-error/form-error'; -import { IdpIcon } from '@/components/idp-icon/idp-icon'; import { WebAuthnReasonCopy } from '@/components/webauthn-button/webauthn-button'; import { useAuthActionError } from '@/hooks/use-auth-action-error'; import { useLoginContext } from '@/hooks/use-login-context'; import { usePasskeyLoginCeremony } from '@/hooks/use-passkey-login-ceremony'; import SplitLayout from '@/layouts/split.layout'; +import type { IdProvider } from '@/modules/auth/types'; +import { startIdpIntent } from '@/resources/login'; import { decideAfterIdentifier } from '@/resources/login/login-decision'; +import { loginIdpSchema } from '@/resources/login/login.schema'; import { readCeremonyParams } from '@/resources/shared/ceremony-params'; import { resolveOrg } from '@/resources/shared/resolve-org'; import { joinLinkedIdps } from '@/resources/sso'; @@ -14,13 +17,23 @@ import type { LinkedIdpView } from '@/resources/sso/sso-management'; import { redirectToLogin } from '@/routes/login-bounce'; import { paths } from '@/routes/paths'; import { providerForRequest } from '@/server/auth-context.server'; +import { loaderCsrf, assertCsrf } from '@/server/csrf'; +import { trustedAppOrigin } from '@/server/infra/app-origin.server'; import { env } from '@/server/infra/env.server'; import { Button, LinkButton } from '@datum-cloud/datum-ui/button'; import { Icon } from '@datum-cloud/datum-ui/icons'; import { cn } from '@datum-cloud/datum-ui/utils'; import { Trans } from '@lingui/react/macro'; import { Key, Lock, Mail } from 'lucide-react'; -import { redirect, useLoaderData, type LoaderFunctionArgs, type MetaFunction } from 'react-router'; +import { + data, + redirect, + useLoaderData, + useNavigation, + type ActionFunctionArgs, + type LoaderFunctionArgs, + type MetaFunction, +} from 'react-router'; import { Link } from 'react-router'; export const meta: MetaFunction = () => [{ title: 'Choose how to sign in' }]; @@ -39,10 +52,11 @@ export async function loader({ request }: LoaderFunctionArgs) { // Org-first: an explicit org wins, else the default org (matches the old app's // `organization ?? getDefaultOrg()`). findUser above stays instance-wide by design. const settingsOrg = await resolveOrg(provider, organization); - const [methods, settings, branding] = await Promise.all([ + const [methods, settings, branding, { csrfToken, headers }] = await Promise.all([ provider.listAuthMethods(user.id), provider.getLoginSettings(settingsOrg), provider.getBranding(settingsOrg), + loaderCsrf(request), ]); // Compute available primary sign-in methods using the same policy gates as @@ -52,17 +66,24 @@ export async function loader({ request }: LoaderFunctionArgs) { if (methods.includes('passkey') && settings.passkeysType !== 'not_allowed') available.push('passkey'); - // Resolve the user's linked (redirect-based) IdPs for real icon/name rendering — - // mirrors reauth.service.ts's loadReauth idp-resolution exactly, including the LDAP - // exclusion (LDAP needs its own credential form, not an OAuth round-trip). - let linkedIdps: LinkedIdpView[] = []; + // Resolve the user's linked (redirect-based) IdPs directly into IdpButtonList's + // IdProvider shape — mirrors reauth.service.ts's loadReauth idp-resolution, including + // the LDAP exclusion (LDAP needs its own credential form, not an OAuth round-trip). A + // link whose provider is no longer active has no name/type to join — filtered out, + // since there's no sign-in button to offer for a dead provider. + let idps: IdProvider[] = []; if (methods.includes('idp') && settings.allowExternalIdp) { const [links, active] = await Promise.all([ provider.listIdpLinks(user.id), getActiveIdPs(provider, settingsOrg), ]); - linkedIdps = joinLinkedIdps(links, active).filter((l) => l.type !== 'LDAP'); - if (linkedIdps.length > 0) available.push('idp'); + idps = joinLinkedIdps(links, active) + .filter( + (l): l is LinkedIdpView & { name: string; type: string } => + l.name !== undefined && l.type !== undefined && l.type !== 'LDAP' + ) + .map((l) => ({ id: l.idpId, name: l.name, type: l.type, logoUrl: l.logoUrl })); + if (idps.length > 0) available.push('idp'); } if (methods.includes('password') && settings.allowPassword) available.push('password'); @@ -85,11 +106,54 @@ export async function loader({ request }: LoaderFunctionArgs) { return redirect(`${target}?${params.toString()}`); } - return { loginName, requestId, organization, methods: available, branding, linkedIdps }; + return data( + { loginName, requestId, organization, methods: available, branding, idps, csrfToken }, + { headers } + ); +} + +export async function action({ request }: ActionFunctionArgs) { + const provider = providerForRequest(request); + const form = await request.formData(); + await assertCsrf(request, form); + + const parsed = loginIdpSchema.safeParse(Object.fromEntries(form)); + if (!parsed.success) return data({ error: 'INVALID_INPUT' as const }, { status: 400 }); + const { idpId, requestId, organization } = parsed.data; + + const { loginName } = readCeremonyParams(new URL(request.url)); + if (!loginName) return redirect(redirectToLogin(requestId, organization)); + + const user = await provider.findUser(loginName, organization); + if (!user) return redirect(redirectToLogin(requestId, organization)); + + // Defensive server-side re-check: never trust the client's idpId — confirm it + // resolves to one of THIS identified user's own linked, active, non-LDAP providers + // before starting the round-trip. Mirrors the same check reauth.tsx's idp-reauth + // action uses for its own linked-IdP chooser. + const settingsOrg = await resolveOrg(provider, organization); + const [links, active] = await Promise.all([ + provider.listIdpLinks(user.id), + getActiveIdPs(provider, settingsOrg), + ]); + const isLinked = joinLinkedIdps(links, active).some( + (l) => l.idpId === idpId && l.type !== 'LDAP' + ); + if (!isLinked) return data({ error: 'INVALID_INPUT' as const }, { status: 400 }); + + const result = await startIdpIntent(provider, { + idpId, + origin: trustedAppOrigin(request), + requestId, + organization, + reauthHint: loginName, + }); + if (!result.ok) return data({ error: result.error }, { status: 502 }); + return redirect(result.authUrl); } export default function LoginMethod() { - const { methods, branding, linkedIdps } = useLoaderData(); + const { methods, branding, idps, csrfToken } = useLoaderData(); const { loginName, requestId, organization } = useLoginContext(); // Typed paths.* emit the identical query string buildParams produced @@ -100,6 +164,14 @@ export default function LoginMethod() { const serverError = useAuthActionError(ceremony.actionData); const passkeyBusy = ceremony.phase !== 'idle'; + // Mirrors login/index.tsx's own submittingIdpId computation — drives IdpButtonList's + // per-row loading state while its POST to this route's own action is in flight. + const navigation = useNavigation(); + const submittingIdpId = + navigation.state !== 'idle' && navigation.formData?.get('intent') === 'idp' + ? String(navigation.formData.get('idpId') ?? '') + : null; + return (
@@ -179,25 +251,16 @@ export default function LoginMethod() { ) : null} - {methods.includes('idp') - ? linkedIdps.map((idp) => ( - passkeyBusy && e.preventDefault()} - iconPosition="left" - icon={}> - Continue with {idp.name ?? idp.idpId} - - )) - : null} + {methods.includes('idp') ? ( + + ) : null}
); diff --git a/app/routes/reauth.tsx b/app/routes/reauth.tsx index a808e5c0da..13149b74df 100644 --- a/app/routes/reauth.tsx +++ b/app/routes/reauth.tsx @@ -13,6 +13,7 @@ import { WebAuthnButton, WebAuthnReasonCopy } from '@/components/webauthn-button import { useAuthActionError } from '@/hooks/use-auth-action-error'; import { usePasskeyReauthCeremony } from '@/hooks/use-passkey-reauth-ceremony'; import { mostRecent, readSessions, serializeSessions } from '@/modules/auth/session/cookie'; +import { isStaleSessionError } from '@/modules/auth/types'; import { loadReauth, performReauth, @@ -22,6 +23,7 @@ import { } from '@/resources/reauth/reauth.service'; import { resolveOrg } from '@/resources/shared/resolve-org'; import { validateReturnTo } from '@/resources/shared/return-to'; +import { joinLinkedIdps } from '@/resources/sso'; import { getActiveIdPs } from '@/resources/sso/idp-providers'; import { paths } from '@/routes/paths'; import { providerForRequest } from '@/server/auth-context.server'; @@ -109,13 +111,34 @@ export async function action({ request }: ActionFunctionArgs) { const entry = mostRecent(sessions); if (!entry) return redirect(paths.login.index()); + // Resolve the session's own user — needed to verify idpId is actually linked to + // THEM, not just any active org provider. Same stale-session recovery loadReauth + // uses: getSession can throw a non-transient ProviderError for a revoked/stale + // cross-browser token instead of returning null. + let userId: string | undefined; + try { + const session = await provider.getSession(entry.id, entry.token); + if (!session) return redirect(paths.login.index()); + userId = session.user?.id ?? (await provider.findUser(entry.loginName))?.id; + if (!userId) return redirect(paths.login.index()); + } catch (err) { + if (isStaleSessionError(err)) return redirect(paths.login.index()); + throw err; + } + // Defensive server-side re-check: never trust the client's idpId — confirm it - // resolves to an ACTIVE, non-LDAP provider before starting the round-trip. Scoped - // to the SAME org loadReauth used to build the chooser's linkedIdps (Task 3), so - // this never rejects a provider the user legitimately just saw a button for. - const active = await getActiveIdPs(provider, await resolveOrg(provider, entry.organization)); - const targetIdp = active.find((i) => i.id === idpId); - if (!targetIdp || targetIdp.type === 'LDAP') { + // resolves to an ACTIVE, non-LDAP provider AND is actually linked to THIS user + // before starting the round-trip (the chooser only ever shows linked providers; + // this must match, not just re-check "is some org IdP active"). Scoped to the SAME + // org loadReauth used to build the chooser's linkedIdps (Task 3). + const [links, active] = await Promise.all([ + provider.listIdpLinks(userId), + getActiveIdPs(provider, await resolveOrg(provider, entry.organization)), + ]); + const isLinked = joinLinkedIdps(links, active).some( + (l) => l.idpId === idpId && l.type !== 'LDAP' + ); + if (!isLinked) { return data({ error: 'INVALID_INPUT' as const }, { status: 400 }); } @@ -139,6 +162,8 @@ export async function action({ request }: ActionFunctionArgs) { code: parsed.data.code, credential: parsed.data.credential, returnTo: parsed.data.returnTo ?? null, + consoleUrl: `${env.ZITADEL_API_URL}/ui/console`, + defaultAppUrl: env.DEFAULT_APP_URL, }); if (!result.ok) { const status = result.error === 'INVALID_CREDENTIALS' ? 401 : 400; diff --git a/app/routes/reauth/provider/callback.tsx b/app/routes/reauth/provider/callback.tsx index 86ae9e4b49..e77fa1a92c 100644 --- a/app/routes/reauth/provider/callback.tsx +++ b/app/routes/reauth/provider/callback.tsx @@ -7,13 +7,20 @@ import { performReauth } from '@/resources/reauth/reauth.service'; import { validateReturnTo } from '@/resources/shared/return-to'; import { paths } from '@/routes/paths'; import { providerForRequest } from '@/server/auth-context.server'; +import { env } from '@/server/infra/env.server'; import { redirect, type LoaderFunctionArgs } from 'react-router'; export async function loader({ request, params }: LoaderFunctionArgs) { const url = new URL(request.url); const idpIntentId = url.searchParams.get('id'); const idpIntentToken = url.searchParams.get('token'); - const returnTo = validateReturnTo(url.searchParams.get('returnTo')) ?? paths.passkeys(); + const rawReturnTo = url.searchParams.get('returnTo'); + // Degraded fallback for the two error-redirect paths below (context-missing / + // access-denied) — just carrying a best-effort value forward for a retry, always safe + // since /reauth re-validates it. The SUCCESS path passes rawReturnTo (not this) to + // performReauth so its own default-resolution logic runs when this doesn't validate, + // instead of prematurely collapsing to /passkeys before performReauth ever sees it. + const returnTo = validateReturnTo(rawReturnTo) ?? paths.passkeys(); const providerSlug = params.provider ?? 'idp'; const provider = providerForRequest(request); @@ -28,7 +35,9 @@ export async function loader({ request, params }: LoaderFunctionArgs) { factor: 'idp', idpIntentId, idpIntentToken, - returnTo, + returnTo: rawReturnTo, + consoleUrl: `${env.ZITADEL_API_URL}/ui/console`, + defaultAppUrl: env.DEFAULT_APP_URL, }); if (!result.ok) { // The only failure performReauth's idp branch returns (rather than throws) is an diff --git a/app/routes/reauth/provider/error.tsx b/app/routes/reauth/provider/error.tsx index 505533dc9a..a3e32cd246 100644 --- a/app/routes/reauth/provider/error.tsx +++ b/app/routes/reauth/provider/error.tsx @@ -36,7 +36,7 @@ export default function ReauthProviderError() { className="mt-4" as={Link} href={paths.reauth({ returnTo })}> - Back to sign in + Try again ); diff --git a/cypress/component/modules/auth/fake-passkeys.cy.ts b/cypress/component/modules/auth/fake-passkeys.cy.ts index 1796ac0ace..6baeeba812 100644 --- a/cypress/component/modules/auth/fake-passkeys.cy.ts +++ b/cypress/component/modules/auth/fake-passkeys.cy.ts @@ -40,4 +40,20 @@ describe('FakeAuthProvider — passkey inventory (port mirror)', () => { }); expect(await fake.listPasskeys('u1')).to.have.length(1); }); + + it('removePasskey also clears a SEEDED static authMethods entry, not just the dynamic enrolled set', async () => { + // listAuthMethods unions the dynamic `enrolled` set with the seed-time `authMethods` + // array — a test seeding BOTH (the e2e-fixture pattern) would otherwise still see + // 'passkey' reported as enrolled after the last passkey is removed, since only the + // dynamic set was ever cleared. + const fake = new FakeAuthProvider({ + users: [seedUser], + authMethods: { u1: ['passkey'] }, + passkeys: { u1: [{ id: 'pk-s', state: 'active', name: 'Seeded key' }] }, + }); + expect(await fake.listAuthMethods('u1')).to.include('passkey'); + await fake.removePasskey('u1', 'pk-s'); + expect(await fake.listPasskeys('u1')).to.deep.equal([]); + expect(await fake.listAuthMethods('u1')).to.not.include('passkey'); + }); }); diff --git a/cypress/component/resources/passkeys/passkeys.service.cy.ts b/cypress/component/resources/passkeys/passkeys.service.cy.ts index da3e757caa..a09d66863f 100644 --- a/cypress/component/resources/passkeys/passkeys.service.cy.ts +++ b/cypress/component/resources/passkeys/passkeys.service.cy.ts @@ -76,6 +76,30 @@ describe('passkeys.service — /id/passkeys management', () => { expect(r).to.deep.equal({ ok: false, error: 'SUDO_REQUIRED' }); }); + it('last-method guard serializes concurrent removes: only one of two racing removals succeeds', async () => { + // Without the per-user lock, two concurrent removes of DIFFERENT passkeys could both + // read "2 passkeys left, no other method" before either completed, both pass the + // guard, and both proceed — leaving zero sign-in methods. The lock forces the second + // call's read to happen AFTER the first's removal completes, so it correctly sees 1 + // passkey left and refuses. + const solo = await seeded({ + authMethods: ['passkey'], + passkeys: [ + { id: 'pk-1', state: 'active', name: 'Laptop' }, + { id: 'pk-2', state: 'active', name: 'Phone' }, + ], + }); + const [a, b] = await Promise.all([ + removeUserPasskey(solo.fake, solo.sessions, { passkeyId: 'pk-1', nowMs: Date.now() }), + removeUserPasskey(solo.fake, solo.sessions, { passkeyId: 'pk-2', nowMs: Date.now() }), + ]); + const results = [a, b]; + expect(results.filter((r) => r.ok)).to.have.length(1); + expect(results.filter((r) => !r.ok && r.error === 'LAST_METHOD')).to.have.length(1); + // Exactly one passkey remains — not zero. + expect(await solo.fake.listPasskeys('u1')).to.have.length(1); + }); + it('last-method guard: passkey-only user with one passkey ⇒ LAST_METHOD; password backup ⇒ ok', async () => { const solo = await seeded({ authMethods: ['passkey'] }); const refused = await removeUserPasskey(solo.fake, solo.sessions, { diff --git a/cypress/component/resources/reauth/reauth.service.cy.ts b/cypress/component/resources/reauth/reauth.service.cy.ts index f42b811acf..a886707cb4 100644 --- a/cypress/component/resources/reauth/reauth.service.cy.ts +++ b/cypress/component/resources/reauth/reauth.service.cy.ts @@ -62,6 +62,35 @@ describe('reauth.service — verify one factor onto the EXISTING session', () => if (v.kind === 'view') expect(v.returnTo).to.equal('https://app.acme.test/dashboard'); }); + it('performReauth preserves the Zitadel-configured default returnTo across the full round-trip', async () => { + // Continues the scenario above: the resolved absolute default lands in the form's + // hidden returnTo field and gets echoed back on submit. performReauth must NOT let it + // fall through to a hardcoded /passkeys just because it's an absolute URL not on + // POST_LOGOUT_ALLOWLIST — it re-resolves the SAME configured default server-side + // instead of trusting the client-echoed absolute URL. + const { fake, sessions } = await seeded(); + fake.setLoginDefaultRedirectUri('https://app.acme.test/dashboard'); + const v = await loadReauth(fake, sessions, { + returnTo: null, + method: null, + domain: 'localhost', + emailDeliveryEnabled: false, + consoleUrl: 'https://console.acme.test', + }); + expect(v.kind).to.equal('view'); + if (v.kind !== 'view') return; + expect(v.returnTo).to.equal('https://app.acme.test/dashboard'); + + const r = await performReauth(fake, sessions, { + factor: 'password', + password: 'Password1!', + returnTo: v.returnTo, + consoleUrl: 'https://console.acme.test', + }); + expect(r.ok).to.equal(true); + if (r.ok) expect(r.target).to.equal('https://app.acme.test/dashboard'); + }); + it('loadReauth falls back to /passkeys when returnTo is absent AND nothing is configured', async () => { const { fake, sessions } = await seeded(); const v = await loadReauth(fake, sessions, { diff --git a/cypress/component/resources/webauthn/webauthn.service.cy.ts b/cypress/component/resources/webauthn/webauthn.service.cy.ts index 74cf3b91e5..12e28ccba5 100644 --- a/cypress/component/resources/webauthn/webauthn.service.cy.ts +++ b/cypress/component/resources/webauthn/webauthn.service.cy.ts @@ -239,5 +239,21 @@ describe('verifyPasskeyEnrollment', () => { }).then((v) => { expect(v.outcome).to.deep.equal({ ok: false, error: 'SESSION_EXPIRED' }); }); + + // A stored session token from a different browser/tab can be stale or revoked + // provider-side by the time this route sees it — the real Zitadel backend throws a + // non-transient ProviderError (e.g. PERMISSION_DENIED) from the sudo gate's own + // getSession call instead of returning null. Recovers to SESSION_EXPIRED, not a crash + // (mirrors passkeys.service.ts/reauth.service.ts's own stale-session fix). + callService({ + fn: 'verifyPasskeyEnrollment', + provider: 'singleton', + liveSessions: [{ id: 's1', token: 't1' }], + sessionResults: { s1: { mode: 'throw', code: 'PERMISSION_DENIED' } }, + request: { url: 'http://localhost/id/setup/passkey', sessions: sessionsFor() }, + verifyEnrollInput: { credential: VALID_CRED, passkeyId: 'pk-1', loginName: ALICE }, + }).then((v) => { + expect(v.outcome).to.deep.equal({ ok: false, error: 'SESSION_EXPIRED' }); + }); }); }); diff --git a/cypress/component/routes/login/method-chooser.cy.ts b/cypress/component/routes/login/method-chooser.cy.ts index cf2c17430a..385fb97820 100644 --- a/cypress/component/routes/login/method-chooser.cy.ts +++ b/cypress/component/routes/login/method-chooser.cy.ts @@ -43,4 +43,72 @@ describe('/login/method loader', () => { expect(loc).to.contain('/login/password'); }); }); + + it("resolves idps to only this user's linked (active, non-LDAP) providers", () => { + // Google is active AND linked to u1; GitHub is active but NOT linked — must not surface. + callService({ + fn: 'loginMethodLoader', + seed: { + users: [{ id: 'u1', loginName: 'mia@acme.test' }], + authMethods: { u1: ['password', 'idp'] }, + idps: [ + { id: 'idp-google', name: 'Google', type: 'GOOGLE' }, + { id: 'idp-github', name: 'GitHub', type: 'GITHUB' }, + ], + idpLinks: { u1: [{ idpId: 'idp-google', idpUserId: 'g-1' }] }, + }, + env: { AUTH_EMAIL_DELIVERY_ENABLED: 'true' }, + request: { url: 'http://localhost/id/login/method?loginName=mia%40acme.test' }, + }).then((v) => { + const body = v.response?.dataBody as { methods: string[]; idps: Array<{ id: string }> }; + expect(body.methods).to.include('idp'); + expect(body.idps).to.deep.equal([{ id: 'idp-google', name: 'Google', type: 'GOOGLE' }]); + }); + }); +}); + +describe('/login/method action — intent=idp', () => { + it('a linked IdP starts the OAuth round-trip (redirects to the authUrl)', () => { + callService({ + fn: 'loginMethodAction', + seed: { + users: [{ id: 'u1', loginName: 'mia@acme.test' }], + authMethods: { u1: ['password', 'idp'] }, + idps: [{ id: 'idp-google', name: 'Google', type: 'GOOGLE' }], + idpLinks: { u1: [{ idpId: 'idp-google', idpUserId: 'g-1' }] }, + }, + request: { + url: 'http://localhost/id/login/method?loginName=mia%40acme.test', + form: { intent: 'idp', idpId: 'idp-google' }, + csrf: true, + }, + }).then((v) => { + expect(v.response?.status).to.equal(302); + expect(v.response?.location).to.contain('idp-google'); + }); + }); + + it('rejects an idpId that is active in the org but NOT linked to this user', () => { + // Never trust the client's idpId — a crafted POST for an active-but-unlinked provider + // must not start a round-trip, mirroring reauth.tsx's own defense-in-depth check. + callService({ + fn: 'loginMethodAction', + seed: { + users: [{ id: 'u1', loginName: 'mia@acme.test' }], + authMethods: { u1: ['password', 'idp'] }, + idps: [ + { id: 'idp-google', name: 'Google', type: 'GOOGLE' }, + { id: 'idp-github', name: 'GitHub', type: 'GITHUB' }, + ], + idpLinks: { u1: [{ idpId: 'idp-google', idpUserId: 'g-1' }] }, + }, + request: { + url: 'http://localhost/id/login/method?loginName=mia%40acme.test', + form: { intent: 'idp', idpId: 'idp-github' }, + csrf: true, + }, + }).then((v) => { + expect(v.response?.dataStatus).to.equal(400); + }); + }); }); diff --git a/cypress/component/routes/login/method.cy.tsx b/cypress/component/routes/login/method.cy.tsx index 0d8f0b97b6..1786d9c541 100644 --- a/cypress/component/routes/login/method.cy.tsx +++ b/cypress/component/routes/login/method.cy.tsx @@ -19,7 +19,8 @@ const LOGIN_CONTEXT = { const METHOD_LOADER_DATA = { methods: ['passkey', 'password'], branding: null, - linkedIdps: [], + idps: [], + csrfToken: 'tok-1', }; const capturedPosts: Array> = []; @@ -110,12 +111,14 @@ describe('/login/method — identity header + in-place passkey ceremony', () => cy.contains('a', 'Password').should('be.visible'); }); - it('renders a link per linked IdP, named after the actual provider', () => { + it("posts intent=idp + idpId to this route's own action instead of navigating to /sso", () => { const methodData = { methods: ['idp', 'password'], branding: null, - linkedIdps: [{ idpId: 'idp-google', name: 'Google', type: 'GOOGLE' }], + idps: [{ id: 'idp-google', name: 'Google', type: 'GOOGLE' }], + csrfToken: 'tok-1', }; + const capturedIdpPosts: Array> = []; const router = createMemoryRouter( [ { @@ -128,6 +131,10 @@ describe('/login/method — identity header + in-place passkey ceremony', () => path: 'method', element: , loader: async () => methodData, + action: async ({ request }: { request: Request }) => { + capturedIdpPosts.push(Object.fromEntries(await request.formData())); + return null; + }, }, ], }, @@ -141,8 +148,15 @@ describe('/login/method — identity header + in-place passkey ceremony', () => ); mount(withI18n()); - // Named after the actual provider ("Google"), not the generic "your provider" copy. - cy.contains('a', 'Continue with Google').should('be.visible').and('have.attr', 'href'); + // Named after the actual provider ("Google") — IdpButtonList's own copy, matching + // /login's own idp buttons — rendered as a submit button (not an
to /sso), since + // starting sign-in is a provider-side call that has to happen in an action. + cy.contains('button', 'Google').click(); + cy.wrap(null).should(() => { + expect(capturedIdpPosts).to.have.length(1); + expect(capturedIdpPosts[0].intent).to.equal('idp'); + expect(capturedIdpPosts[0].idpId).to.equal('idp-google'); + }); cy.contains('a', 'Password').should('be.visible'); }); }); diff --git a/cypress/component/routes/reauth-action.cy.ts b/cypress/component/routes/reauth-action.cy.ts new file mode 100644 index 0000000000..f108a4eb96 --- /dev/null +++ b/cypress/component/routes/reauth-action.cy.ts @@ -0,0 +1,73 @@ +// cypress/component/routes/reauth-action.cy.ts +// +// /reauth action, intent=idp-reauth: never trust the client's idpId — it must resolve +// to an ACTIVE, non-LDAP provider actually LINKED to the current session's own user +// before starting the round-trip. Previously only re-checked active+non-LDAP (any org +// IdP would pass), relying entirely on the callback's identity-mismatch rejection. +import { callService } from '../../support/node/call-service'; + +const USER = { id: 'u1', loginName: 'mia@acme.test' }; + +describe('/reauth action — intent=idp-reauth', () => { + it('a linked IdP starts the OAuth round-trip (redirects to the authUrl)', () => { + callService({ + fn: 'reauthAction', + seed: { + users: [USER], + idps: [{ id: 'idp-google', name: 'Google', type: 'GOOGLE' }], + idpLinks: { u1: [{ idpId: 'idp-google', idpUserId: 'g-1' }] }, + }, + liveSessions: [{ id: 'sess-1', token: 'sess-tok-1', user: USER }], + request: { + url: 'http://localhost/id/reauth', + sessions: [{ id: 'sess-1', token: 'sess-tok-1', loginName: USER.loginName }], + form: { intent: 'idp-reauth', idpId: 'idp-google', returnTo: '/passkeys' }, + csrf: true, + }, + }).then((v) => { + expect(v.response?.status).to.equal(302); + expect(v.response?.location).to.contain('idp-google'); + }); + }); + + it('rejects an idpId that is active in the org but NOT linked to this user', () => { + // Never trust the client's idpId — a crafted POST for an active-but-unlinked + // provider must not start a round-trip, even though it would still be caught at + // the callback stage by the identity-mismatch check. + callService({ + fn: 'reauthAction', + seed: { + users: [USER], + idps: [ + { id: 'idp-google', name: 'Google', type: 'GOOGLE' }, + { id: 'idp-github', name: 'GitHub', type: 'GITHUB' }, + ], + idpLinks: { u1: [{ idpId: 'idp-google', idpUserId: 'g-1' }] }, + }, + liveSessions: [{ id: 'sess-1', token: 'sess-tok-1', user: USER }], + request: { + url: 'http://localhost/id/reauth', + sessions: [{ id: 'sess-1', token: 'sess-tok-1', loginName: USER.loginName }], + form: { intent: 'idp-reauth', idpId: 'idp-github', returnTo: '/passkeys' }, + csrf: true, + }, + }).then((v) => { + expect(v.response?.dataStatus).to.equal(400); + }); + }); + + it('no session redirects to /login', () => { + callService({ + fn: 'reauthAction', + seed: { users: [USER] }, + request: { + url: 'http://localhost/id/reauth', + form: { intent: 'idp-reauth', idpId: 'idp-google', returnTo: '/passkeys' }, + csrf: true, + }, + }).then((v) => { + expect(v.response?.status).to.equal(302); + expect(v.response?.location).to.equal('/login'); + }); + }); +}); diff --git a/cypress/component/routes/reauth/provider-error.cy.tsx b/cypress/component/routes/reauth/provider-error.cy.tsx index f3d9324e4b..c19194dc14 100644 --- a/cypress/component/routes/reauth/provider-error.cy.tsx +++ b/cypress/component/routes/reauth/provider-error.cy.tsx @@ -25,7 +25,7 @@ describe('/reauth/:provider/error', () => { it('names the provider and links back to /reauth preserving returnTo', () => { mountError('/reauth/idp-google/error?returnTo=%2Fpasskeys'); cy.contains('idp-google').should('be.visible'); - cy.contains('a', 'Back to sign in').should('have.attr', 'href', '/reauth?returnTo=%2Fpasskeys'); + cy.contains('a', 'Try again').should('have.attr', 'href', '/reauth?returnTo=%2Fpasskeys'); }); it('shows the access-denied copy instead of the generic provider-name fallback', () => { diff --git a/cypress/support/node/harness.ts b/cypress/support/node/harness.ts index c32cfae0b3..b31f38cafd 100644 --- a/cypress/support/node/harness.ts +++ b/cypress/support/node/harness.ts @@ -107,7 +107,7 @@ import { loader as deviceCompleteLoader } from '@/routes/device/complete'; import { loader as deviceIndexLoader } from '@/routes/device/index'; // ── routes/login handlers (batch 13b) ───────────────────────────────────────────────────────── import { loader as loginLoader, action as loginAction } from '@/routes/login/index'; -import { loader as loginMethodLoader } from '@/routes/login/method'; +import { loader as loginMethodLoader, action as loginMethodAction } from '@/routes/login/method'; import { action as loginMfaAction } from '@/routes/login/mfa'; import { action as loginPasswordAction } from '@/routes/login/password'; import { action as securityKeyAction } from '@/routes/login/security-key'; @@ -122,6 +122,7 @@ import { loader as passwordResetLoader, action as passwordResetAction, } from '@/routes/password/reset'; +import { action as reauthAction } from '@/routes/reauth'; import { loader as reauthProviderCallbackLoader } from '@/routes/reauth/provider/callback'; import { loader as setupAuthenticatorLoader } from '@/routes/setup/authenticator'; import { loader as signupCompleteLoader } from '@/routes/signup/complete'; @@ -619,6 +620,25 @@ export async function runScenario(s: Scenario): Promise { outcome = await signInWithIdpIntent(provider, request, s.signInOpts); break; } + case 'reauthAction': { + const originalFake = providerRegistry.fake; + providerRegistry.fake = () => provider; + try { + const { request } = await buildHandlerRequest( + s.request ?? { url: 'http://localhost/id/reauth', csrf: true } + ); + const result = await reauthAction({ + request, + params: {}, + context: {} as never, + } as never); + response = await serializeResponse(result); + } finally { + providerRegistry.fake = originalFake; + } + break; + } + case 'reauthProviderCallback': { // The route loader resolves its own provider via providerForRequest → getAuthProvider('fake') // → providerRegistry.fake(), which is INDEPENDENT of the `provider` this harness already @@ -2202,20 +2222,42 @@ export async function runScenario(s: Scenario): Promise { } case 'loginMethodLoader': { - const { request } = await buildHandlerRequest( - s.request ?? { url: 'http://localhost/id/login/method' } - ); - const result = await loginMethodLoader({ - request, - params: {}, - context: {} as never, - } as never); - if (result instanceof Response) { + // Same providerRegistry.fake bridge as reauthProviderCallback below — this loader + // resolves its own provider via providerForRequest, independent of the `provider` + // this harness built above whenever the scenario passes a custom `seed`. + const originalFake = providerRegistry.fake; + providerRegistry.fake = () => provider; + try { + const { request } = await buildHandlerRequest( + s.request ?? { url: 'http://localhost/id/login/method' } + ); + const result = await loginMethodLoader({ + request, + params: {}, + context: {} as never, + } as never); response = await serializeResponse(result); - } else { - // loginMethodLoader returns a plain object (not data()-wrapped like most loaders), so - // serializeResponse would lose the payload. Carry it directly as dataBody. - response = { isResponse: false, dataBody: result as Record }; + } finally { + providerRegistry.fake = originalFake; + } + break; + } + + case 'loginMethodAction': { + const originalFake = providerRegistry.fake; + providerRegistry.fake = () => provider; + try { + const { request } = await buildHandlerRequest( + s.request ?? { url: 'http://localhost/id/login/method', csrf: true } + ); + const result = await loginMethodAction({ + request, + params: {}, + context: {} as never, + } as never); + response = await serializeResponse(result); + } finally { + providerRegistry.fake = originalFake; } break; } diff --git a/cypress/support/node/scenario.ts b/cypress/support/node/scenario.ts index 905df8a7a7..f7923eadfa 100644 --- a/cypress/support/node/scenario.ts +++ b/cypress/support/node/scenario.ts @@ -50,6 +50,10 @@ export interface ScenarioSeed { string, { idpIntentId: string; idpIntentToken: string; userId: string | null } >; + /** Active org IdPs (getActiveIdPs) — narrowed to the fields joinLinkedIdps reads. */ + idps?: Array<{ id: string; name: string; type: string; logoUrl?: string }>; + /** Pre-linked IdP identities (userId → links), narrowed to the fields joinLinkedIdps reads. */ + idpLinks?: Record>; } /** A live session to inject via provider.seedLiveSession (getSession/listSessions resolve it). */ @@ -134,6 +138,7 @@ export type ServiceFn = // ── sso (batch 8b) ── | 'processIdpCallback' | 'signInWithIdpIntent' + | 'reauthAction' | 'reauthProviderCallback' | 'submitLdapCredentials' | 'runSsoAction' @@ -228,6 +233,7 @@ export type ServiceFn = | 'securityKeyAction' | 'loginVerifyEmailLoader' | 'loginMethodLoader' + | 'loginMethodAction' // login/mfa.tsx action: covers the SESSION_EXPIRED path now returning inline data() (instead // of a hard redirect(paths.login.index())) so useAuthActionRecovery's banner can thread // requestId/organization. From b690f1f3c06269c07da86d02ffb27af1f6cb5d2d Mon Sep 17 00:00:00 2001 From: Yahya Fakhroji Date: Mon, 27 Jul 2026 19:31:33 +0700 Subject: [PATCH 7/9] =?UTF-8?q?fix:=20address=20independent=20code=20revie?= =?UTF-8?q?w=20round=202=20=E2=80=94=203=20findings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - reauth: the idp-reauth action also re-resolves the configured default returnTo (same fix as the main performReauth path) instead of hardcoding /passkeys when the caller's returnTo doesn't validate. - passkeys: the last-method guard now gates by ACTUAL usable policy (passkeysType / allowExternalIdp / allowPassword / emailDeliveryEnabled), not just raw enrollment — an enrolled-but-policy-disabled method is not a real backup. - auth: isStaleSessionError flipped from a fail-open denylist (assume stale unless the code is on a known-transient allowlist) to a fail-closed allowlist (assume the error propagates unless the code is a confirmed-stale one) — safer default when a new ProviderErrorCode is added later. --- app/modules/auth/types.ts | 27 ++++---- app/modules/i18n/locales/en.po | 64 +++++++++---------- app/resources/passkeys/passkeys.service.ts | 29 ++++++--- app/resources/reauth/reauth.service.ts | 2 +- app/resources/shared/usable-methods.ts | 25 ++++++++ app/routes/passkeys.tsx | 3 + app/routes/reauth.tsx | 14 +++- .../resources/passkeys/passkeys.service.cy.ts | 40 ++++++++++++ cypress/component/routes/reauth-action.cy.ts | 35 ++++++++++ 9 files changed, 186 insertions(+), 53 deletions(-) create mode 100644 app/resources/shared/usable-methods.ts diff --git a/app/modules/auth/types.ts b/app/modules/auth/types.ts index 6a521cc47f..2b5c4acf03 100644 --- a/app/modules/auth/types.ts +++ b/app/modules/auth/types.ts @@ -212,22 +212,27 @@ export class ProviderError extends Error { // Codes that indicate a genuine transient backend problem (NOT a dead/stale session) — mirrors // session.service.ts's SWITCH_TRANSIENT_CODES. Kept here so any caller re-validating a STORED // session (not one just created in the same request) can share the same classification. -const TRANSIENT_PROVIDER_CODES = new Set([ - 'UNAVAILABLE', - 'DEADLINE_EXCEEDED', - 'RATE_LIMITED', -]); +// Codes empirically confirmed to mean "this stored session token no longer resolves to +// a live session" — NOT_FOUND (the session record is gone) and PERMISSION_DENIED (the +// real Zitadel backend's actual response for a stale/revoked cross-browser token, per +// the production bug this guard was built for). +const STALE_SESSION_CODES = new Set(['NOT_FOUND', 'PERMISSION_DENIED']); /** * True when a thrown error means a stored session token is stale/revoked rather than a - * genuine backend outage — i.e. any ProviderError except the transient ones. Callers - * re-validating a stored session (e.g. from a cookie, possibly created in a different - * browser/tab) should treat this as "needs re-authentication" and recover by redirecting, - * instead of letting it crash the request. NOT appropriate for a session just created earlier - * in the SAME request (a real failure there is a genuine error, not staleness). + * genuine backend fault. Callers re-validating a stored session (e.g. from a cookie, + * possibly created in a different browser/tab) should treat this as "needs + * re-authentication" and recover by redirecting, instead of letting it crash the request. + * + * Fail-closed by design: only classify a code as "stale" when it's a confirmed match for + * that meaning. Every other ProviderError — including UNKNOWN, the catch-all for any + * unmapped backend fault — propagates as a genuine error instead of being silently + * swallowed into a redirect loop that looks identical to normal session expiry. NOT + * appropriate for a session just created earlier in the SAME request (a real failure + * there is a genuine error, not staleness). */ export function isStaleSessionError(err: unknown): boolean { - return err instanceof ProviderError && !TRANSIENT_PROVIDER_CODES.has(err.code); + return err instanceof ProviderError && STALE_SESSION_CODES.has(err.code); } // ── external IdP (Phase 4) ──────────────────────────────────── diff --git a/app/modules/i18n/locales/en.po b/app/modules/i18n/locales/en.po index 24bb2403ec..307c1d01ec 100644 --- a/app/modules/i18n/locales/en.po +++ b/app/modules/i18n/locales/en.po @@ -13,7 +13,7 @@ msgstr "" "Plural-Forms: \n" #. placeholder {0}: row.name -#: app/routes/passkeys.tsx:134 +#: app/routes/passkeys.tsx:137 msgid "\"{0}\" will no longer work for signing in. This cannot be undone." msgstr "\"{0}\" will no longer work for signing in. This cannot be undone." @@ -53,7 +53,7 @@ msgstr "Add an extra layer of security to your account by setting up a second fa msgid "Add another account" msgstr "Add another account" -#: app/routes/passkeys.tsx:296 +#: app/routes/passkeys.tsx:299 msgid "Add passkey" msgstr "Add passkey" @@ -66,7 +66,7 @@ msgid "Add your phone number as a second factor. We will send a one-time code vi msgstr "Add your phone number as a second factor. We will send a one-time code via SMS each time you sign in." #. placeholder {0}: i18n.date(new Date(row.createdAt), { dateStyle: 'medium' }) -#: app/routes/passkeys.tsx:273 +#: app/routes/passkeys.tsx:276 msgid "Added {0}" msgstr "Added {0}" @@ -131,7 +131,7 @@ msgid "Available accounts to link" msgstr "Available accounts to link" #: app/components/back-link/back-link.tsx:24 -#: app/routes/passkeys.tsx:302 +#: app/routes/passkeys.tsx:305 #: app/routes/sso/link.tsx:68 #: app/routes/verify/success.tsx:36 msgid "Back" @@ -145,7 +145,7 @@ msgstr "Back to sign in" msgid "By continuing, you agree to Datum's <0>Terms of Service and <1>Privacy Policy, and to receive periodic emails with updates." msgstr "By continuing, you agree to Datum's <0>Terms of Service and <1>Privacy Policy, and to receive periodic emails with updates." -#: app/routes/passkeys.tsx:139 +#: app/routes/passkeys.tsx:142 #: app/routes/sso/index.tsx:107 msgid "Cancel" msgstr "Cancel" @@ -188,12 +188,12 @@ msgstr "Choose your login method" msgid "Code expired" msgstr "Code expired" -#: app/routes/reauth.tsx:369 -#: app/routes/reauth.tsx:391 +#: app/routes/reauth.tsx:381 +#: app/routes/reauth.tsx:403 msgid "Confirm" msgstr "Confirm" -#: app/routes/reauth.tsx:259 +#: app/routes/reauth.tsx:271 msgid "Confirm it's you" msgstr "Confirm it's you" @@ -217,7 +217,7 @@ msgid "Continue" msgstr "Continue" #. placeholder {0}: idp.name ?? idp.idpId -#: app/routes/reauth.tsx:317 +#: app/routes/reauth.tsx:329 msgid "Continue with {0}" msgstr "Continue with {0}" @@ -283,7 +283,7 @@ msgstr "Email" msgid "Email code" msgstr "Email code" -#: app/routes/reauth.tsx:334 +#: app/routes/reauth.tsx:346 msgid "Email me a code" msgstr "Email me a code" @@ -362,7 +362,7 @@ msgstr "Enter your SMS code" msgid "Finish creating your account" msgstr "Finish creating your account" -#: app/routes/reauth.tsx:262 +#: app/routes/reauth.tsx:274 msgid "For your security, verify one of your sign-in methods to continue." msgstr "For your security, verify one of your sign-in methods to continue." @@ -378,7 +378,7 @@ msgstr "Get started" msgid "If you prefer a demo, just <0>reach out." msgstr "If you prefer a demo, just <0>reach out." -#: app/routes/passkeys.tsx:266 +#: app/routes/passkeys.tsx:269 msgid "Inactive" msgstr "Inactive" @@ -402,8 +402,8 @@ msgstr "Link your account" msgid "Linked accounts" msgstr "Linked accounts" -#: app/routes/passkeys.tsx:227 -#: app/routes/reauth.tsx:266 +#: app/routes/passkeys.tsx:230 +#: app/routes/reauth.tsx:278 #: app/routes/sso/index.tsx:137 msgid "Logged in as" msgstr "Logged in as" @@ -429,11 +429,11 @@ msgstr "New password" msgid "No account was found and sign-up is not available." msgstr "No account was found and sign-up is not available." -#: app/routes/passkeys.tsx:247 +#: app/routes/passkeys.tsx:250 msgid "No passkeys yet." msgstr "No passkeys yet." -#: app/routes/reauth.tsx:339 +#: app/routes/reauth.tsx:351 msgid "No sign-in method is available for re-authentication." msgstr "No sign-in method is available for re-authentication." @@ -445,7 +445,7 @@ msgstr "No sign-in method is available for this account." msgid "No signed-in accounts." msgstr "No signed-in accounts." -#: app/routes/passkeys.tsx:185 +#: app/routes/passkeys.tsx:188 msgid "Not now" msgstr "Not now" @@ -457,8 +457,8 @@ msgstr "Not registered?" #: app/routes/device/authorize.tsx:122 #: app/routes/login/index.tsx:416 #: app/routes/login/method.tsx:188 -#: app/routes/passkeys.tsx:228 -#: app/routes/reauth.tsx:267 +#: app/routes/passkeys.tsx:231 +#: app/routes/reauth.tsx:279 #: app/routes/signed-in.tsx:49 #: app/routes/sso/index.tsx:138 msgid "Not you?" @@ -478,7 +478,7 @@ msgstr "Or import this URI in your authenticator app" #: app/routes/login/index.tsx:462 #: app/routes/login/method.tsx:216 -#: app/routes/reauth.tsx:297 +#: app/routes/reauth.tsx:309 #: app/routes/setup/mfa.tsx:46 msgid "Passkey" msgstr "Passkey" @@ -487,7 +487,7 @@ msgstr "Passkey" msgid "Passkey name" msgstr "Passkey name" -#: app/routes/passkeys.tsx:172 +#: app/routes/passkeys.tsx:175 msgid "Passkey removed" msgstr "Passkey removed" @@ -507,17 +507,17 @@ msgstr "Passkey sign-in couldn't be completed for security reasons. Please conta msgid "Passkey sign-in was cancelled, or no passkey for this account is available on this device. Make sure you're using a device where you set up your passkey, then try again." msgstr "Passkey sign-in was cancelled, or no passkey for this account is available on this device. Make sure you're using a device where you set up your passkey, then try again." -#: app/routes/passkeys.tsx:220 +#: app/routes/passkeys.tsx:223 msgid "Passkeys" msgstr "Passkeys" -#: app/routes/passkeys.tsx:223 +#: app/routes/passkeys.tsx:226 msgid "Passkeys let you sign in with your fingerprint, face, or device PIN." msgstr "Passkeys let you sign in with your fingerprint, face, or device PIN." #: app/routes/login/method.tsx:250 -#: app/routes/reauth.tsx:326 -#: app/routes/reauth.tsx:364 +#: app/routes/reauth.tsx:338 +#: app/routes/reauth.tsx:376 #: app/routes/signup/password.tsx:229 #: app/routes/sso/ldap.tsx:80 msgid "Password" @@ -576,11 +576,11 @@ msgstr "Register passkey" msgid "Registration is currently unavailable. Please contact your administrator." msgstr "Registration is currently unavailable. Please contact your administrator." -#: app/routes/passkeys.tsx:146 +#: app/routes/passkeys.tsx:149 msgid "Remove passkey" msgstr "Remove passkey" -#: app/routes/passkeys.tsx:132 +#: app/routes/passkeys.tsx:135 msgid "Remove this passkey?" msgstr "Remove this passkey?" @@ -695,7 +695,7 @@ msgstr "Sign out" msgid "Sign out of" msgstr "Sign out of" -#: app/routes/passkeys.tsx:191 +#: app/routes/passkeys.tsx:194 msgid "Sign out other sessions" msgstr "Sign out other sessions" @@ -703,7 +703,7 @@ msgstr "Sign out other sessions" msgid "Sign-in is currently unavailable for this account. Please contact your administrator." msgstr "Sign-in is currently unavailable for this account. Please contact your administrator." -#: app/routes/passkeys.tsx:174 +#: app/routes/passkeys.tsx:177 msgid "Signed-in sessions on other devices can still be active. Sign out your other sessions?" msgstr "Signed-in sessions on other devices can still be active. Sign out your other sessions?" @@ -878,7 +878,7 @@ msgstr "Use your security key to verify your identity." msgid "Username" msgstr "Username" -#: app/routes/reauth.tsx:386 +#: app/routes/reauth.tsx:398 #: app/routes/verify/index.tsx:159 msgid "Verification code" msgstr "Verification code" @@ -928,7 +928,7 @@ msgstr "We couldn't set up your passkey. Please try again." msgid "We couldn't start passkey setup. Please try again." msgstr "We couldn't start passkey setup. Please try again." -#: app/routes/reauth.tsx:384 +#: app/routes/reauth.tsx:396 msgid "We sent a verification code to your email address." msgstr "We sent a verification code to your email address." @@ -972,7 +972,7 @@ msgstr "You can link multiple accounts to your Datum account." msgid "You can now sign in using <0>{loginName}." msgstr "You can now sign in using <0>{loginName}." -#: app/routes/passkeys.tsx:238 +#: app/routes/passkeys.tsx:241 msgid "You can't remove your only sign-in method. Add another method first." msgstr "You can't remove your only sign-in method. Add another method first." diff --git a/app/resources/passkeys/passkeys.service.ts b/app/resources/passkeys/passkeys.service.ts index d6a935d727..64c6b7a47c 100644 --- a/app/resources/passkeys/passkeys.service.ts +++ b/app/resources/passkeys/passkeys.service.ts @@ -9,8 +9,10 @@ import type { AuthProvider } from '@/modules/auth/auth-provider'; // in the Cypress component bundle; identical at runtime (cookie.ts re-exports it). import { mostRecent, type SessionEntry } from '@/modules/auth/session/session'; import { ProviderError, isStaleSessionError } from '@/modules/auth/types'; +import { resolveOrg } from '@/resources/shared/resolve-org'; import { validateReturnTo } from '@/resources/shared/return-to'; import { isSudoFresh } from '@/resources/shared/sudo'; +import { usableSignInMethods } from '@/resources/shared/usable-methods'; import { paths } from '@/routes/paths'; import { logAuthEvent, hashActor } from '@/server/observability'; @@ -80,7 +82,7 @@ async function resolveActive( export async function loadPasskeysView( provider: AuthProvider, sessions: SessionEntry[], - input: { returnTo: string | null; nowMs: number } + input: { returnTo: string | null; nowMs: number; emailDeliveryEnabled: boolean } ): Promise { const active = await resolveActive(provider, sessions); if (!active) return { kind: 'redirect', target: paths.login.index() }; @@ -89,17 +91,22 @@ export async function loadPasskeysView( return { kind: 'redirect', target: reauthTarget() }; } - const [passkeys, methods] = await Promise.all([ + const [passkeys, methods, settings] = await Promise.all([ provider.listPasskeys(active.userId), provider.listAuthMethods(active.userId), + provider.getLoginSettings(await resolveOrg(provider, active.entry.organization)), ]); + // methodCount drives the backup-method banner (shown when === 1) — must reflect + // methods actually USABLE right now, not just enrolled (a policy-disabled method + // is not a real backup). + const usable = usableSignInMethods(methods, settings, input.emailDeliveryEnabled); return { kind: 'view', loginName: active.entry.loginName, userId: active.userId, passkeys, - methodCount: methods.length, + methodCount: usable.length, returnTo: validateReturnTo(input.returnTo), }; } @@ -140,7 +147,7 @@ function withUserRemovalLock(userId: string, fn: () => Promise): Promise { const active = await resolveActive(provider, sessions); if (!active) return { ok: false, error: 'SESSION_EXPIRED' }; @@ -154,14 +161,20 @@ export async function removeUserPasskey( } const userId = active.userId; + const organization = active.entry.organization; + const emailDeliveryEnabled = input.emailDeliveryEnabled; return withUserRemovalLock(userId, async () => { - const [passkeys, methods] = await Promise.all([ + const [passkeys, methods, settings] = await Promise.all([ provider.listPasskeys(userId), provider.listAuthMethods(userId), + provider.getLoginSettings(await resolveOrg(provider, organization)), ]); - // Refuse removing the user's FINAL sign-in method: at most one passkey left AND no - // other method enrolled. - if (passkeys.length <= 1 && !methods.some((m) => m !== 'passkey')) { + // Refuse removing the user's FINAL USABLE sign-in method: at most one passkey left + // AND no other method that's both enrolled AND currently allowed by org/instance + // policy — an enrolled-but-policy-disabled method (e.g. password auth turned off + // after the user set one) is not a real backup. + const usable = usableSignInMethods(methods, settings, emailDeliveryEnabled); + if (passkeys.length <= 1 && !usable.some((m) => m !== 'passkey')) { logAuthEvent('passkey_remove', 'failure', { userId, reason: 'last_method', diff --git a/app/resources/reauth/reauth.service.ts b/app/resources/reauth/reauth.service.ts index e065c1ea2c..778a6f1192 100644 --- a/app/resources/reauth/reauth.service.ts +++ b/app/resources/reauth/reauth.service.ts @@ -64,7 +64,7 @@ export type ReauthLoadResult = * configured at all. Best-effort: a failed settings/admin-check read degrades to * the /passkeys fallback rather than failing the whole reauth load. */ -async function resolveDefaultReturnTo( +export async function resolveDefaultReturnTo( provider: AuthProvider, entry: SessionEntry, input: Pick diff --git a/app/resources/shared/usable-methods.ts b/app/resources/shared/usable-methods.ts new file mode 100644 index 0000000000..2870ed3a30 --- /dev/null +++ b/app/resources/shared/usable-methods.ts @@ -0,0 +1,25 @@ +// app/resources/shared/usable-methods.ts +// +// Filters ENROLLED auth methods down to ones actually USABLE right now, given org login +// policy + instance config — the same gates decideAfterIdentifier (login-decision.ts) and +// /login/method's own chooser already apply per-method. A method can be enrolled but not +// currently offered (e.g. an org disables password auth, or an operator turns off email +// delivery, after a user already enrolled that method) — callers deciding "does this user +// still have a working backup method" need the gated view, not the raw enrolled list. +import type { AuthMethod, LoginSettings } from '@/modules/auth/types'; + +export function usableSignInMethods( + enrolled: AuthMethod[], + settings: LoginSettings, + emailDeliveryEnabled: boolean +): AuthMethod[] { + return enrolled.filter((m) => { + if (m === 'passkey') return settings.passkeysType !== 'not_allowed'; + if (m === 'idp') return settings.allowExternalIdp; + if (m === 'password') return settings.allowPassword; + if (m === 'otp_email') return emailDeliveryEnabled; + // totp / u2f / otp_sms are second-factor methods, not gated by these primary + // sign-in settings — pass through unchanged. + return true; + }); +} diff --git a/app/routes/passkeys.tsx b/app/routes/passkeys.tsx index 5f820aee9a..43e05dab4d 100644 --- a/app/routes/passkeys.tsx +++ b/app/routes/passkeys.tsx @@ -20,6 +20,7 @@ import { import { paths } from '@/routes/paths'; import { providerForRequest } from '@/server/auth-context.server'; import { getCsrfToken, assertCsrf } from '@/server/csrf'; +import { env } from '@/server/infra/env.server'; import { actionError } from '@/utils/errors/auth-error'; import { Badge } from '@datum-cloud/datum-ui/badge'; import { Button, LinkButton } from '@datum-cloud/datum-ui/button'; @@ -49,6 +50,7 @@ export async function loader({ request }: LoaderFunctionArgs) { const result = await loadPasskeysView(provider, sessions, { returnTo: url.searchParams.get('returnTo'), nowMs: Date.now(), + emailDeliveryEnabled: env.AUTH_EMAIL_DELIVERY_ENABLED, }); if (result.kind === 'redirect') return redirect(result.target); @@ -73,6 +75,7 @@ export async function action({ request }: ActionFunctionArgs) { const result = await removeUserPasskey(provider, sessions, { passkeyId: parsed.data.passkeyId, nowMs: Date.now(), + emailDeliveryEnabled: env.AUTH_EMAIL_DELIVERY_ENABLED, }); if (!result.ok) { // Stale sudo: bounce through /reauth and return here (server-side enforcement). diff --git a/app/routes/reauth.tsx b/app/routes/reauth.tsx index 13149b74df..13f2446d7c 100644 --- a/app/routes/reauth.tsx +++ b/app/routes/reauth.tsx @@ -17,6 +17,7 @@ import { isStaleSessionError } from '@/modules/auth/types'; import { loadReauth, performReauth, + resolveDefaultReturnTo, startReauthIdpIntent, type ReauthLoadResult, type ReauthMethod, @@ -104,13 +105,24 @@ export async function action({ request }: ActionFunctionArgs) { if (form.get('intent') === 'idp-reauth') { const idpId = String(form.get('idpId') ?? ''); - const returnTo = validateReturnTo(String(form.get('returnTo') ?? '')) ?? paths.passkeys(); if (!idpId) return data({ error: 'INVALID_INPUT' as const }, { status: 400 }); const sessions = await readSessions(request); const entry = mostRecent(sessions); if (!entry) return redirect(paths.login.index()); + // Never trust a client-echoed absolute returnTo (same fix as performReauth's success + // path) — re-resolve the configured default server-side when the submitted value + // doesn't validate, instead of collapsing to a hardcoded /passkeys before this + // round-trip even starts (which would then be baked into the callback URL with no + // way to recover the real destination downstream). + const returnTo = + validateReturnTo(String(form.get('returnTo') ?? '')) ?? + (await resolveDefaultReturnTo(provider, entry, { + consoleUrl: `${env.ZITADEL_API_URL}/ui/console`, + defaultAppUrl: env.DEFAULT_APP_URL, + })); + // Resolve the session's own user — needed to verify idpId is actually linked to // THEM, not just any active org provider. Same stale-session recovery loadReauth // uses: getSession can throw a non-transient ProviderError for a revoked/stale diff --git a/cypress/component/resources/passkeys/passkeys.service.cy.ts b/cypress/component/resources/passkeys/passkeys.service.cy.ts index a09d66863f..9e2e0f2785 100644 --- a/cypress/component/resources/passkeys/passkeys.service.cy.ts +++ b/cypress/component/resources/passkeys/passkeys.service.cy.ts @@ -100,6 +100,46 @@ describe('passkeys.service — /id/passkeys management', () => { expect(await solo.fake.listPasskeys('u1')).to.have.length(1); }); + it('last-method guard refuses removal when the only backup method is policy-disabled', async () => { + // Enrolled ['passkey', 'password'] with 1 passkey — raw enrolled methods would say + // "password is a backup," but the org has since disabled password auth. That backup + // isn't usable, so removing the last passkey must still be refused (not a real lockout + // escape). Mirrors the same gate decideAfterIdentifier/login/method.tsx apply per-method. + const fake = new FakeAuthProvider({ + users: [USER], + passwords: { u1: 'Password1!' }, + authMethods: { u1: ['passkey', 'password'] }, + passkeys: { u1: [{ id: 'pk-1', state: 'active', name: 'Seeded laptop' }] }, + settingsByOrg: { 'org-default-fake': { allowPassword: false } }, + realFactorTimestamps: true, + }); + const s = await fake.createSession({ password: 'Password1!' }, { userId: 'u1' }); + const sessions: SessionEntry[] = [ + { + id: s.id, + token: s.token, + loginName: USER.loginName, + creationTs: s.changedAt, + expirationTs: s.expiresAt, + changeTs: s.changedAt, + }, + ]; + const refused = await removeUserPasskey(fake, sessions, { + passkeyId: 'pk-1', + nowMs: Date.now(), + emailDeliveryEnabled: true, + }); + expect(refused).to.deep.equal({ ok: false, error: 'LAST_METHOD' }); + // Same gap existed in the loader's methodCount (drives the backup-method banner). + const view = await loadPasskeysView(fake, sessions, { + returnTo: null, + nowMs: Date.now(), + emailDeliveryEnabled: true, + }); + expect(view.kind).to.equal('view'); + if (view.kind === 'view') expect(view.methodCount).to.equal(1); + }); + it('last-method guard: passkey-only user with one passkey ⇒ LAST_METHOD; password backup ⇒ ok', async () => { const solo = await seeded({ authMethods: ['passkey'] }); const refused = await removeUserPasskey(solo.fake, solo.sessions, { diff --git a/cypress/component/routes/reauth-action.cy.ts b/cypress/component/routes/reauth-action.cy.ts index f108a4eb96..867ad42435 100644 --- a/cypress/component/routes/reauth-action.cy.ts +++ b/cypress/component/routes/reauth-action.cy.ts @@ -56,6 +56,41 @@ describe('/reauth action — intent=idp-reauth', () => { }); }); + it('preserves the Zitadel-configured default returnTo instead of collapsing to /passkeys', () => { + // The client echoes back the absolute default loadReauth resolved earlier (the same + // hidden-field round-trip performReauth's own fix covers) — this must not collapse to + // /passkeys just because it's not on POST_LOGOUT_ALLOWLIST, since that would then be + // baked into the callback URL with no way to recover the real destination downstream. + callService({ + fn: 'reauthAction', + seed: { + users: [USER], + idps: [{ id: 'idp-google', name: 'Google', type: 'GOOGLE' }], + idpLinks: { u1: [{ idpId: 'idp-google', idpUserId: 'g-1' }] }, + }, + loginDefaultRedirectUri: 'https://app.acme.test/dashboard', + liveSessions: [{ id: 'sess-1', token: 'sess-tok-1', user: USER }], + recordCalls: ['startIdpIntent'], + request: { + url: 'http://localhost/id/reauth', + sessions: [{ id: 'sess-1', token: 'sess-tok-1', loginName: USER.loginName }], + form: { + intent: 'idp-reauth', + idpId: 'idp-google', + returnTo: 'https://app.acme.test/dashboard', + }, + csrf: true, + }, + }).then((v) => { + expect(v.response?.status).to.equal(302); + const calls = v.calls?.startIdpIntent as + Array<[string, { success: string; failure: string }]> | undefined; + expect(calls).to.have.length(1); + const [, urls] = calls![0]; + expect(urls.success).to.include(encodeURIComponent('https://app.acme.test/dashboard')); + }); + }); + it('no session redirects to /login', () => { callService({ fn: 'reauthAction', From a07efcfc9b95edf7a867dda1b41c6340bc8e368c Mon Sep 17 00:00:00 2001 From: Yahya Fakhroji Date: Mon, 27 Jul 2026 19:31:33 +0700 Subject: [PATCH 8/9] =?UTF-8?q?fix:=20address=20team=20code=20review=20rou?= =?UTF-8?q?nd=203=20(gaghan430)=20=E2=80=94=20high=20severity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Both passkey ceremony hooks (login + reauth) used `null` as the ref sentinel for "already consumed this challenge", but a challenge-mint failure also legitimately returns null — on first render this made the ref's initial value match, so the hook returned before setPhase('ceremony') ever ran and stayed stuck on 'loading-challenge' forever, disabling every other sign-in method on /login/method and /reauth. Fixed with a Symbol sentinel and an explicit null-challenge branch. - /id/reauth, /id/passkeys, and /id/login/method had no rate limiting despite sitting beside ten throttled siblings — added three ip-keyed limiters. - performReauth's updateSession catch rethrew a stale cross-browser session token uncaught (500), including mid-OAuth at /reauth/:provider/callback; now classified via isStaleSessionError → SESSION_EXPIRED. Its returnTo fallback also used the PRE-rotation session token for the isInstanceAdmin check, silently misclassifying admins after a token rotation — now uses the just-rotated token. Also threads a best-effort login_hint into the reauth IdP round-trip, matching the login-side behavior. --- app/hooks/use-passkey-login-ceremony.ts | 20 ++++- app/hooks/use-passkey-reauth-ceremony.ts | 20 ++++- app/modules/i18n/locales/en.po | 110 ++++++++++++----------- app/resources/login/login.service.ts | 2 +- app/resources/reauth/reauth.service.ts | 39 ++++++-- app/routes/reauth.tsx | 1 + app/routes/reauth/provider/callback.tsx | 11 +++ app/server.ts | 6 ++ app/server/middleware/rate-limit.ts | 41 +++++++++ 9 files changed, 185 insertions(+), 65 deletions(-) diff --git a/app/hooks/use-passkey-login-ceremony.ts b/app/hooks/use-passkey-login-ceremony.ts index 9433d7c55d..3577ebd46c 100644 --- a/app/hooks/use-passkey-login-ceremony.ts +++ b/app/hooks/use-passkey-login-ceremony.ts @@ -39,8 +39,13 @@ export function usePasskeyLoginCeremony(input: PasskeyLoginCeremonyInput) { const submitFetcher = useFetcher(); const [phase, setPhase] = useState('idle'); const [reason, setReason] = useState(null); - // One ceremony per acquired challenge — survives re-renders, resets on begin(). - const consumedChallenge = useRef(null); + // One ceremony per acquired challenge — survives re-renders, resets on begin(). A sentinel + // (not `null`) is required: requestWebAuthnChallenge legitimately returns a null challenge on + // a non-fatal mint failure, and `null === null` on the very first call would otherwise match + // the ref's initial value and return before setPhase('ceremony') ever runs, leaving `phase` + // stuck at 'loading-challenge' forever (passkeyBusy gates the whole chooser). + const UNSET = useRef(Symbol('unset')).current; + const consumedChallenge = useRef(UNSET); const passkeyPath = paths.login.passkey({ loginName: input.loginName, @@ -52,6 +57,13 @@ export function usePasskeyLoginCeremony(input: PasskeyLoginCeremonyInput) { async (csrfToken: string, publicKeyCredentialRequestOptions: unknown) => { if (consumedChallenge.current === publicKeyCredentialRequestOptions) return; consumedChallenge.current = publicKeyCredentialRequestOptions; + // Challenge mint failed server-side (non-fatal there) — nothing to hand the + // authenticator. Mirrors WebAuthnButton's own `if (!publicKey)` guard. + if (!publicKeyCredentialRequestOptions) { + setPhase('idle'); + setReason('unknown'); + return; + } setPhase('ceremony'); try { let credential: Record; @@ -105,10 +117,10 @@ export function usePasskeyLoginCeremony(input: PasskeyLoginCeremonyInput) { /** Lazy path: fetch a fresh challenge from the /login/passkey loader, then run. */ const begin = useCallback(() => { setReason(null); - consumedChallenge.current = null; + consumedChallenge.current = UNSET; setPhase('loading-challenge'); challengeFetcher.load(passkeyPath); - }, [challengeFetcher, passkeyPath]); + }, [challengeFetcher, passkeyPath, UNSET]); /** Pre-minted path (sole-passkey inline): run immediately with caller-supplied data. */ const beginWith = useCallback( diff --git a/app/hooks/use-passkey-reauth-ceremony.ts b/app/hooks/use-passkey-reauth-ceremony.ts index 43cf9d0c98..c239d84ede 100644 --- a/app/hooks/use-passkey-reauth-ceremony.ts +++ b/app/hooks/use-passkey-reauth-ceremony.ts @@ -38,8 +38,13 @@ export function usePasskeyReauthCeremony(input: PasskeyReauthCeremonyInput) { const submitFetcher = useFetcher(); const [phase, setPhase] = useState('idle'); const [reason, setReason] = useState(null); - // One ceremony per acquired challenge — survives re-renders, resets on begin(). - const consumedChallenge = useRef(null); + // One ceremony per acquired challenge — survives re-renders, resets on begin(). A sentinel + // (not `null`) is required: loadReauth legitimately returns a null challenge on a non-fatal + // mint failure, and `null === null` on the very first call would otherwise match the ref's + // initial value and return before setPhase('ceremony') ever runs, leaving `phase` stuck at + // 'loading-challenge' forever (the /reauth chooser gates every method behind it). + const UNSET = useRef(Symbol('unset')).current; + const consumedChallenge = useRef(UNSET); const challengePath = paths.reauth({ method: 'passkey', returnTo: input.returnTo }); @@ -47,6 +52,13 @@ export function usePasskeyReauthCeremony(input: PasskeyReauthCeremonyInput) { async (csrfToken: string, publicKeyCredentialRequestOptions: unknown) => { if (consumedChallenge.current === publicKeyCredentialRequestOptions) return; consumedChallenge.current = publicKeyCredentialRequestOptions; + // Challenge mint failed server-side (non-fatal there) — nothing to hand the + // authenticator. Mirrors WebAuthnButton's own `if (!publicKey)` guard. + if (!publicKeyCredentialRequestOptions) { + setPhase('idle'); + setReason('unknown'); + return; + } setPhase('ceremony'); try { let credential: Record; @@ -99,10 +111,10 @@ export function usePasskeyReauthCeremony(input: PasskeyReauthCeremonyInput) { /** Fetch a fresh challenge from this route's own loader, then run. */ const begin = useCallback(() => { setReason(null); - consumedChallenge.current = null; + consumedChallenge.current = UNSET; setPhase('loading-challenge'); challengeFetcher.load(challengePath); - }, [challengeFetcher, challengePath]); + }, [challengeFetcher, challengePath, UNSET]); // Challenge-load completion: when the loader data lands, run the ceremony once. useEffect(() => { diff --git a/app/modules/i18n/locales/en.po b/app/modules/i18n/locales/en.po index 307c1d01ec..b2ad486737 100644 --- a/app/modules/i18n/locales/en.po +++ b/app/modules/i18n/locales/en.po @@ -13,7 +13,7 @@ msgstr "" "Plural-Forms: \n" #. placeholder {0}: row.name -#: app/routes/passkeys.tsx:137 +#: app/routes/passkeys.tsx:138 msgid "\"{0}\" will no longer work for signing in. This cannot be undone." msgstr "\"{0}\" will no longer work for signing in. This cannot be undone." @@ -53,7 +53,7 @@ msgstr "Add an extra layer of security to your account by setting up a second fa msgid "Add another account" msgstr "Add another account" -#: app/routes/passkeys.tsx:299 +#: app/routes/passkeys.tsx:306 msgid "Add passkey" msgstr "Add passkey" @@ -66,7 +66,7 @@ msgid "Add your phone number as a second factor. We will send a one-time code vi msgstr "Add your phone number as a second factor. We will send a one-time code via SMS each time you sign in." #. placeholder {0}: i18n.date(new Date(row.createdAt), { dateStyle: 'medium' }) -#: app/routes/passkeys.tsx:276 +#: app/routes/passkeys.tsx:277 msgid "Added {0}" msgstr "Added {0}" @@ -131,7 +131,7 @@ msgid "Available accounts to link" msgstr "Available accounts to link" #: app/components/back-link/back-link.tsx:24 -#: app/routes/passkeys.tsx:305 +#: app/routes/passkeys.tsx:312 #: app/routes/sso/link.tsx:68 #: app/routes/verify/success.tsx:36 msgid "Back" @@ -145,7 +145,7 @@ msgstr "Back to sign in" msgid "By continuing, you agree to Datum's <0>Terms of Service and <1>Privacy Policy, and to receive periodic emails with updates." msgstr "By continuing, you agree to Datum's <0>Terms of Service and <1>Privacy Policy, and to receive periodic emails with updates." -#: app/routes/passkeys.tsx:142 +#: app/routes/passkeys.tsx:143 #: app/routes/sso/index.tsx:107 msgid "Cancel" msgstr "Cancel" @@ -172,7 +172,7 @@ msgstr "Choose a new password" msgid "Choose an account" msgstr "Choose an account" -#: app/routes/login/method.tsx:179 +#: app/routes/login/method.tsx:184 msgid "Choose how to sign in" msgstr "Choose how to sign in" @@ -188,12 +188,12 @@ msgstr "Choose your login method" msgid "Code expired" msgstr "Code expired" -#: app/routes/reauth.tsx:381 -#: app/routes/reauth.tsx:403 +#: app/routes/reauth.tsx:382 +#: app/routes/reauth.tsx:404 msgid "Confirm" msgstr "Confirm" -#: app/routes/reauth.tsx:271 +#: app/routes/reauth.tsx:272 msgid "Confirm it's you" msgstr "Confirm it's you" @@ -217,7 +217,7 @@ msgid "Continue" msgstr "Continue" #. placeholder {0}: idp.name ?? idp.idpId -#: app/routes/reauth.tsx:329 +#: app/routes/reauth.tsx:330 msgid "Continue with {0}" msgstr "Continue with {0}" @@ -251,7 +251,7 @@ msgid "Create account" msgstr "Create account" #. placeholder {0}: held.uaName -#: app/routes/setup/passkey.tsx:144 +#: app/routes/setup/passkey.tsx:154 msgid "Created using {0}" msgstr "Created using {0}" @@ -283,12 +283,12 @@ msgstr "Email" msgid "Email code" msgstr "Email code" -#: app/routes/reauth.tsx:346 +#: app/routes/reauth.tsx:347 msgid "Email me a code" msgstr "Email me a code" #: app/routes/login/index.tsx:522 -#: app/routes/login/method.tsx:233 +#: app/routes/login/method.tsx:238 #: app/routes/signup/method.tsx:339 msgid "Email me a sign-in link" msgstr "Email me a sign-in link" @@ -362,7 +362,7 @@ msgstr "Enter your SMS code" msgid "Finish creating your account" msgstr "Finish creating your account" -#: app/routes/reauth.tsx:274 +#: app/routes/reauth.tsx:275 msgid "For your security, verify one of your sign-in methods to continue." msgstr "For your security, verify one of your sign-in methods to continue." @@ -378,7 +378,7 @@ msgstr "Get started" msgid "If you prefer a demo, just <0>reach out." msgstr "If you prefer a demo, just <0>reach out." -#: app/routes/passkeys.tsx:269 +#: app/routes/passkeys.tsx:270 msgid "Inactive" msgstr "Inactive" @@ -402,8 +402,8 @@ msgstr "Link your account" msgid "Linked accounts" msgstr "Linked accounts" -#: app/routes/passkeys.tsx:230 -#: app/routes/reauth.tsx:278 +#: app/routes/passkeys.tsx:231 +#: app/routes/reauth.tsx:279 #: app/routes/sso/index.tsx:137 msgid "Logged in as" msgstr "Logged in as" @@ -412,7 +412,7 @@ msgstr "Logged in as" msgid "Manual setup key" msgstr "Manual setup key" -#: app/routes/setup/passkey.tsx:87 +#: app/routes/setup/passkey.tsx:97 msgid "Name your passkey" msgstr "Name your passkey" @@ -429,11 +429,11 @@ msgstr "New password" msgid "No account was found and sign-up is not available." msgstr "No account was found and sign-up is not available." -#: app/routes/passkeys.tsx:250 +#: app/routes/passkeys.tsx:251 msgid "No passkeys yet." msgstr "No passkeys yet." -#: app/routes/reauth.tsx:351 +#: app/routes/reauth.tsx:352 msgid "No sign-in method is available for re-authentication." msgstr "No sign-in method is available for re-authentication." @@ -445,7 +445,7 @@ msgstr "No sign-in method is available for this account." msgid "No signed-in accounts." msgstr "No signed-in accounts." -#: app/routes/passkeys.tsx:188 +#: app/routes/passkeys.tsx:189 msgid "Not now" msgstr "Not now" @@ -456,9 +456,9 @@ msgstr "Not registered?" #: app/components/identity-badge/identity-badge.tsx:30 #: app/routes/device/authorize.tsx:122 #: app/routes/login/index.tsx:416 -#: app/routes/login/method.tsx:188 -#: app/routes/passkeys.tsx:231 -#: app/routes/reauth.tsx:279 +#: app/routes/login/method.tsx:193 +#: app/routes/passkeys.tsx:232 +#: app/routes/reauth.tsx:280 #: app/routes/signed-in.tsx:49 #: app/routes/sso/index.tsx:138 msgid "Not you?" @@ -477,17 +477,17 @@ msgid "Or import this URI in your authenticator app" msgstr "Or import this URI in your authenticator app" #: app/routes/login/index.tsx:462 -#: app/routes/login/method.tsx:216 -#: app/routes/reauth.tsx:309 +#: app/routes/login/method.tsx:221 +#: app/routes/reauth.tsx:310 #: app/routes/setup/mfa.tsx:46 msgid "Passkey" msgstr "Passkey" -#: app/routes/setup/passkey.tsx:133 +#: app/routes/setup/passkey.tsx:143 msgid "Passkey name" msgstr "Passkey name" -#: app/routes/passkeys.tsx:175 +#: app/routes/passkeys.tsx:176 msgid "Passkey removed" msgstr "Passkey removed" @@ -507,17 +507,17 @@ msgstr "Passkey sign-in couldn't be completed for security reasons. Please conta msgid "Passkey sign-in was cancelled, or no passkey for this account is available on this device. Make sure you're using a device where you set up your passkey, then try again." msgstr "Passkey sign-in was cancelled, or no passkey for this account is available on this device. Make sure you're using a device where you set up your passkey, then try again." -#: app/routes/passkeys.tsx:223 +#: app/routes/passkeys.tsx:224 msgid "Passkeys" msgstr "Passkeys" -#: app/routes/passkeys.tsx:226 +#: app/routes/passkeys.tsx:227 msgid "Passkeys let you sign in with your fingerprint, face, or device PIN." msgstr "Passkeys let you sign in with your fingerprint, face, or device PIN." -#: app/routes/login/method.tsx:250 -#: app/routes/reauth.tsx:338 -#: app/routes/reauth.tsx:376 +#: app/routes/login/method.tsx:255 +#: app/routes/reauth.tsx:339 +#: app/routes/reauth.tsx:377 #: app/routes/signup/password.tsx:229 #: app/routes/sso/ldap.tsx:80 msgid "Password" @@ -564,11 +564,11 @@ msgstr "Please check your input and try again." msgid "Register a hardware security key (e.g. YubiKey) as a second factor for your account." msgstr "Register a hardware security key (e.g. YubiKey) as a second factor for your account." -#: app/routes/setup/passkey.tsx:92 +#: app/routes/setup/passkey.tsx:102 msgid "Register a passkey using your device's biometric sensor or PIN to sign in securely without a password." msgstr "Register a passkey using your device's biometric sensor or PIN to sign in securely without a password." -#: app/routes/setup/passkey.tsx:177 +#: app/routes/setup/passkey.tsx:197 msgid "Register passkey" msgstr "Register passkey" @@ -576,11 +576,17 @@ msgstr "Register passkey" msgid "Registration is currently unavailable. Please contact your administrator." msgstr "Registration is currently unavailable. Please contact your administrator." -#: app/routes/passkeys.tsx:149 +#. placeholder {0}: row.name +#: app/routes/passkeys.tsx:129 +#: app/routes/passkeys.tsx:130 +msgid "Remove {0}" +msgstr "Remove {0}" + +#: app/routes/passkeys.tsx:150 msgid "Remove passkey" msgstr "Remove passkey" -#: app/routes/passkeys.tsx:135 +#: app/routes/passkeys.tsx:136 msgid "Remove this passkey?" msgstr "Remove this passkey?" @@ -594,7 +600,7 @@ msgstr "Resend code" msgid "Reset your password" msgstr "Reset your password" -#: app/routes/setup/passkey.tsx:160 +#: app/routes/setup/passkey.tsx:170 msgid "Save" msgstr "Save" @@ -644,7 +650,7 @@ msgstr "Set up email one-time code" msgid "Set up multi-factor authentication" msgstr "Set up multi-factor authentication" -#: app/routes/setup/passkey.tsx:87 +#: app/routes/setup/passkey.tsx:97 msgid "Set up passkey" msgstr "Set up passkey" @@ -685,8 +691,8 @@ msgstr "Sign in with LDAP" msgid "Sign in with your passkey" msgstr "Sign in with your passkey" -#: app/components/sign-out-button/sign-out-button.tsx:26 -#: app/components/sign-out-button/sign-out-button.tsx:30 +#: app/components/sign-out-button/sign-out-button.tsx:27 +#: app/components/sign-out-button/sign-out-button.tsx:31 #: app/routes/logout/index.tsx:51 msgid "Sign out" msgstr "Sign out" @@ -695,7 +701,7 @@ msgstr "Sign out" msgid "Sign out of" msgstr "Sign out of" -#: app/routes/passkeys.tsx:194 +#: app/routes/passkeys.tsx:195 msgid "Sign out other sessions" msgstr "Sign out other sessions" @@ -703,7 +709,7 @@ msgstr "Sign out other sessions" msgid "Sign-in is currently unavailable for this account. Please contact your administrator." msgstr "Sign-in is currently unavailable for this account. Please contact your administrator." -#: app/routes/passkeys.tsx:177 +#: app/routes/passkeys.tsx:178 msgid "Signed-in sessions on other devices can still be active. Sign out your other sessions?" msgstr "Signed-in sessions on other devices can still be active. Sign out your other sessions?" @@ -717,7 +723,7 @@ msgstr "Signing in as" msgid "Signing in as <0>{0}." msgstr "Signing in as <0>{0}." -#: app/routes/login/method.tsx:182 +#: app/routes/login/method.tsx:187 msgid "Signing in as <0>{loginName}." msgstr "Signing in as <0>{loginName}." @@ -823,7 +829,7 @@ msgstr "This helps us keep our platform stable by heading off fraud and abusive msgid "This is your only sign-in method" msgstr "This is your only sign-in method" -#: app/routes/setup/passkey.tsx:148 +#: app/routes/setup/passkey.tsx:158 msgid "This name is for your Datum passkey list — your password manager labels it separately. Names can't be changed later." msgstr "This name is for your Datum passkey list — your password manager labels it separately. Names can't be changed later." @@ -878,7 +884,7 @@ msgstr "Use your security key to verify your identity." msgid "Username" msgstr "Username" -#: app/routes/reauth.tsx:398 +#: app/routes/reauth.tsx:399 #: app/routes/verify/index.tsx:159 msgid "Verification code" msgstr "Verification code" @@ -924,11 +930,11 @@ msgstr "We couldn't find what you were looking for. Please try again." msgid "We couldn't set up your passkey. Please try again." msgstr "We couldn't set up your passkey. Please try again." -#: app/routes/setup/passkey.tsx:169 +#: app/routes/setup/passkey.tsx:179 msgid "We couldn't start passkey setup. Please try again." msgstr "We couldn't start passkey setup. Please try again." -#: app/routes/reauth.tsx:396 +#: app/routes/reauth.tsx:397 msgid "We sent a verification code to your email address." msgstr "We sent a verification code to your email address." @@ -972,7 +978,7 @@ msgstr "You can link multiple accounts to your Datum account." msgid "You can now sign in using <0>{loginName}." msgstr "You can now sign in using <0>{loginName}." -#: app/routes/passkeys.tsx:241 +#: app/routes/passkeys.tsx:242 msgid "You can't remove your only sign-in method. Add another method first." msgstr "You can't remove your only sign-in method. Add another method first." @@ -1016,7 +1022,7 @@ msgstr "Your email is already verified. You can sign in." msgid "Your email is verified" msgstr "Your email is verified" -#: app/routes/setup/passkey.tsx:90 +#: app/routes/setup/passkey.tsx:100 msgid "Your passkey is ready — give it a name so you can recognize it later." msgstr "Your passkey is ready — give it a name so you can recognize it later." @@ -1024,6 +1030,10 @@ msgstr "Your passkey is ready — give it a name so you can recognize it later." msgid "Your password has expired. Please reset it to continue." msgstr "Your password has expired. Please reset it to continue." +#: app/routes/setup/passkey.tsx:185 +msgid "Your previous attempt may have already created a passkey on this device. Retrying will register a new one — you can remove the unused entry later from your device or password manager." +msgstr "Your previous attempt may have already created a passkey on this device. Retrying will register a new one — you can remove the unused entry later from your device or password manager." + #: app/routes/logout/success.tsx:20 msgid "Your session has ended and you've been securely signed out of Datum. You can safely close this tab, or sign back in any time to pick up where you left off." msgstr "Your session has ended and you've been securely signed out of Datum. You can safely close this tab, or sign back in any time to pick up where you left off." diff --git a/app/resources/login/login.service.ts b/app/resources/login/login.service.ts index cf029b8945..4f45d02a97 100644 --- a/app/resources/login/login.service.ts +++ b/app/resources/login/login.service.ts @@ -119,7 +119,7 @@ export async function startIdpIntent( * returned untouched — this is a UX nicety, never load-bearing (the callback's identity check is * the real guard). */ -function withLoginHint(authUrl: string, reauthHint?: string): string { +export function withLoginHint(authUrl: string, reauthHint?: string): string { if (!reauthHint) return authUrl; try { const url = new URL(authUrl); diff --git a/app/resources/reauth/reauth.service.ts b/app/resources/reauth/reauth.service.ts index 778a6f1192..1672c006e3 100644 --- a/app/resources/reauth/reauth.service.ts +++ b/app/resources/reauth/reauth.service.ts @@ -8,6 +8,7 @@ import type { AuthProvider } from '@/modules/auth/auth-provider'; import type { SessionChecks } from '@/modules/auth/auth-provider'; import { idpTypeToSlug } from '@/modules/auth/idp-slug'; +import { withLoginHint } from '@/resources/login/login.service'; // NOTE: import the PURE helpers from session/session (not cookie.ts) — cookie.ts is // stubbed to no-ops in the Cypress component bundle; the pure module is browser-safe // and identical at runtime (cookie.ts re-exports it). @@ -260,6 +261,15 @@ export async function performReauth( actor: hashActor(entry.loginName), factor: input.factor, }); + // The stored session token may belong to a DIFFERENT browser/tab than the one hitting this + // route right now, and may be stale or revoked provider-side (same recovery loadReauth + // already applies to its own getSession/listAuthMethods calls) — this updateSession call is + // a THIRD site with the identical failure mode and, unlike loadReauth, previously had no + // isStaleSessionError guard, so a stale cross-browser token rethrew uncaught into a 500 here + // and at /reauth/:provider/callback's mid-OAuth return. + if (isStaleSessionError(err)) { + return { ok: false, error: 'SESSION_EXPIRED' }; + } if ( err instanceof ProviderError && (err.code === 'INVALID_CREDENTIALS' || @@ -289,12 +299,21 @@ export async function performReauth( // falling back to a hardcoded /passkeys, which silently dropped the configured // destination (admin console / Zitadel default / env default) whenever it wasn't on // POST_LOGOUT_ALLOWLIST (a different allowlist, for a different purpose). + // Use the JUST-ROTATED token (session.token), not entry's pre-rotation one — updateSession + // above may have rotated it (SetSession semantics), and resolveDefaultReturnTo's + // isInstanceAdmin check would otherwise silently fail (.catch(() => false)) against the + // now-superseded token, misclassifying an admin as non-admin and landing them on the wrong + // default destination. const target = validateReturnTo(input.returnTo) ?? - (await resolveDefaultReturnTo(provider, entry, { - consoleUrl: input.consoleUrl, - defaultAppUrl: input.defaultAppUrl, - })); + (await resolveDefaultReturnTo( + provider, + { ...entry, token: session.token }, + { + consoleUrl: input.consoleUrl, + defaultAppUrl: input.defaultAppUrl, + } + )); return { ok: true, target, sessions: next }; } @@ -305,6 +324,14 @@ export interface StartReauthIdpInput { origin: string; /** Where to land after a successful reauth (already validated by the caller). */ returnTo: string; + /** + * Best-effort pre-selection: the identity being re-authenticated (mirrors + * login.service.ts's startIdpIntent's reauthHint). On reauth, picking the wrong Google + * account is MORE likely than on a fresh login (the user is already signed in as someone + * specific) and fails loudly with FAILED_PRECONDITION — cheap UX win, never load-bearing + * (the callback's identity-mismatch check is the real guard). + */ + loginHint?: string; } export type StartReauthIdpResult = @@ -330,7 +357,7 @@ function reauthIdpReturnUrls( */ export async function startReauthIdpIntent( provider: AuthProvider, - { idpId, origin, returnTo }: StartReauthIdpInput + { idpId, origin, returnTo, loginHint }: StartReauthIdpInput ): Promise { const slug = idpTypeToSlug(idpId) ?? idpId; const { success, failure } = reauthIdpReturnUrls(origin, slug, returnTo); @@ -347,5 +374,5 @@ export async function startReauthIdpIntent( return { ok: false, error: 'IDP_UNAVAILABLE' }; } logAuthEvent('reauth_idp_start', 'success', { idpId }); - return { ok: true, authUrl: result.authUrl }; + return { ok: true, authUrl: withLoginHint(result.authUrl, loginHint) }; } diff --git a/app/routes/reauth.tsx b/app/routes/reauth.tsx index 13f2446d7c..0f52fd8a2d 100644 --- a/app/routes/reauth.tsx +++ b/app/routes/reauth.tsx @@ -158,6 +158,7 @@ export async function action({ request }: ActionFunctionArgs) { idpId, origin: trustedAppOrigin(request), returnTo, + loginHint: entry.loginName, }); if (!result.ok) return data({ error: result.error }, { status: 502 }); return redirect(result.authUrl); diff --git a/app/routes/reauth/provider/callback.tsx b/app/routes/reauth/provider/callback.tsx index e77fa1a92c..de92ec2310 100644 --- a/app/routes/reauth/provider/callback.tsx +++ b/app/routes/reauth/provider/callback.tsx @@ -2,6 +2,17 @@ // Verifies the returned idpIntent onto the EXISTING session via performReauth; never // creates a session or signs in as a different identity (that's the ordinary /sso // callback's job, a deliberately separate code path — see the design doc). +// +// No app-level state/nonce binds this GET to the request that started the round-trip +// (reviewed and deliberately kept this way, not an oversight): the idpIntentId/idpIntentToken +// pair is minted and validated by ZITADEL itself, opaque and unforgeable without actually +// completing a real OAuth round-trip with the provider — an attacker cannot manufacture a +// valid pair out-of-band the way they could forge our own state param. The actual security +// boundary is performReauth's identity check (FAILED_PRECONDITION → 'access-denied' when the +// intent was verified against a DIFFERENT user than the active session), which a same-origin +// app-level nonce would not add to. /sso/:provider/callback (processIdpCallback) relies on the +// identical Zitadel-token + identity-check pairing with no state param either — this matches +// that established, deliberate pattern rather than diverging from it. import { readSessions, mostRecent, serializeSessions } from '@/modules/auth/session/cookie'; import { performReauth } from '@/resources/reauth/reauth.service'; import { validateReturnTo } from '@/resources/shared/return-to'; diff --git a/app/server.ts b/app/server.ts index 2be72310d5..a66940305c 100644 --- a/app/server.ts +++ b/app/server.ts @@ -10,6 +10,9 @@ import { mfaEnrollRateLimit, accountsRateLimit, verifyEmailSendRateLimit, + reauthRateLimit, + passkeysRateLimit, + loginMethodRateLimit, } from '@/server/middleware/rate-limit'; import { requestContext, type RequestContextEnv } from '@/server/middleware/request-context'; import { appSecureHeaders, resolveFrameAncestors } from '@/server/middleware/secure-headers'; @@ -114,6 +117,9 @@ export default await createHonoServer({ app.use('*', mfaEnrollRateLimit); app.use('*', accountsRateLimit); app.use('*', verifyEmailSendRateLimit); + app.use('*', reauthRateLimit); + app.use('*', passkeysRateLimit); + app.use('*', loginMethodRateLimit); app.get('/healthz', (c) => c.json({ status: 'ok' })); app.get('/readyz', (c) => c.json({ status: 'ready' })); app.get('/security', (c) => diff --git a/app/server/middleware/rate-limit.ts b/app/server/middleware/rate-limit.ts index be732402c9..0cc517ec16 100644 --- a/app/server/middleware/rate-limit.ts +++ b/app/server/middleware/rate-limit.ts @@ -247,3 +247,44 @@ export const passwordResetRateLimit: MiddlewareHandler = createRateLimit({ match: (c, pathname) => c.req.method === 'POST' && pathname === '/id/password/reset', key: (_c, ip) => ip, }); + +// One shared limiter for /id/reauth: 10 attempts / 5 min per ip — same class as +// mfaVerifyLimiter/webauthnVerifyLimiter (a verification ceremony onto an EXISTING session). +// Covers every factor (password, otp_email, passkey, idp-reauth) alike: the sudo re-verify +// screen was previously unthrottled despite sitting beside ten throttled siblings, and +// Zitadel's own failedAttempts lockout only partly covers the password case (none for the +// emailed OTP code). +// BODY-STREAM HAZARD: factor/password/code/credential are in the POST body — key is ip-only. +const reauthLimiter = new RateLimiter({ limit: 10, windowMs: 5 * 60_000 }); + +export const reauthRateLimit: MiddlewareHandler = createRateLimit({ + limiter: reauthLimiter, + match: (c, pathname) => c.req.method === 'POST' && pathname === '/id/reauth', + key: (_c, ip) => ip, +}); + +// One shared limiter for /id/passkeys: 15 attempts / 5 min per ip — matches accountsRateLimit's +// reasoning (session-gated, not a brute-force target, but `signout-others` is destructive and +// cross-device, so scripted abuse from a compromised session should still be throttled). +// BODY-STREAM HAZARD: intent/passkeyId are in the POST body — key is ip-only. +const passkeysLimiter = new RateLimiter({ limit: 15, windowMs: 5 * 60_000 }); + +export const passkeysRateLimit: MiddlewareHandler = createRateLimit({ + limiter: passkeysLimiter, + match: (c, pathname) => c.req.method === 'POST' && pathname === '/id/passkeys', + key: (_c, ip) => ip, +}); + +// One shared limiter for /id/login/method: 10 attempts / 5 min per ip — same class as +// mfaVerifyLimiter. This action is unauthenticated (driven by a loginName ceremony param, not +// a session) and its three distinguishable outcomes (unknown user / known-but-unlinked idp / +// linked idp) make it a linked-IdP identity oracle; this bounds the per-IP probing rate as a +// backstop alongside the response-collapsing fix at the route. +// BODY-STREAM HAZARD: idpId is in the POST body — key is ip-only. +const loginMethodLimiter = new RateLimiter({ limit: 10, windowMs: 5 * 60_000 }); + +export const loginMethodRateLimit: MiddlewareHandler = createRateLimit({ + limiter: loginMethodLimiter, + match: (c, pathname) => c.req.method === 'POST' && pathname === '/id/login/method', + key: (_c, ip) => ip, +}); From 721e71c055d25da1922e240fde75c2a1697ba2ee Mon Sep 17 00:00:00 2001 From: Yahya Fakhroji Date: Mon, 27 Jul 2026 19:31:33 +0700 Subject: [PATCH 9/9] =?UTF-8?q?fix:=20address=20team=20code=20review=20rou?= =?UTF-8?q?nd=203=20(gaghan430)=20=E2=80=94=20medium/low=20severity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - passkeys: the last-method guard counted raw passkey rows including inactive ones, so a passkey-only account with 1 active + 1 inactive passkey slipped past the <= 1 check and could be locked out. Now filters to state === 'active' first. - login/method: collapsed the unknown-user and idp-not-linked action responses into an identical one — the action is unauthenticated and the two previously-distinguishable outcomes made it a linked-IdP identity oracle. - setup/passkey: a failed step-2 (Save) submit silently reset to step 1, but the WebAuthn credential was already created in step 1 — retrying silently created a second, orphaned credential. Now shows a persistent notice instead of silently duplicating. - Misc: translated the Remove-passkey aria-label/title, dropped the unused userId field from the passkeys view payload, threaded the page's own external returnTo through the Add-passkey round-trip, SignOutButton now uses APP_BASENAME instead of a hardcoded literal, and aaguid.ts's CBOR length reader now forces an unsigned 32-bit result. --- .../sign-out-button/sign-out-button.tsx | 3 ++- app/resources/passkeys/passkeys.service.ts | 12 +++++----- app/resources/reauth/reauth.service.ts | 2 +- app/resources/webauthn/aaguid.ts | 4 +++- app/routes/login/method.tsx | 7 +++++- app/routes/passkeys.tsx | 13 ++++++++--- app/routes/setup/passkey.tsx | 22 ++++++++++++++++++- 7 files changed, 50 insertions(+), 13 deletions(-) diff --git a/app/components/sign-out-button/sign-out-button.tsx b/app/components/sign-out-button/sign-out-button.tsx index 72fd497285..77916780fe 100644 --- a/app/components/sign-out-button/sign-out-button.tsx +++ b/app/components/sign-out-button/sign-out-button.tsx @@ -1,4 +1,5 @@ import { AuthFormFields } from '@/components/auth-form/auth-form-fields'; +import { APP_BASENAME } from '@/resources/shared/app-basename'; import { Button } from '@datum-cloud/datum-ui/button'; import { Trans } from '@lingui/react/macro'; @@ -19,7 +20,7 @@ export interface SignOutButtonProps { */ export function SignOutButton({ csrf, emphasis = 'secondary' }: SignOutButtonProps) { return ( -
+ {emphasis === 'primary' ? ( @@ -293,7 +294,13 @@ export default function Passkeys() { theme="outline" block as={Link} - href={paths.setup.passkey({ loginName, returnTo: paths.passkeys() })} + href={paths.setup.passkey({ + loginName, + // Preserve the page's OWN external returnTo (the portal round-trip that produced + // the "Back" button below) across the add-passkey round-trip — hardcoding + // paths.passkeys() here silently dropped it, so "Back" was gone after adding a key. + returnTo: paths.passkeys({ returnTo: returnTo ?? undefined }), + })} iconPosition="left" icon={}> Add passkey diff --git a/app/routes/setup/passkey.tsx b/app/routes/setup/passkey.tsx index 2d094cc30d..732b3bf6ca 100644 --- a/app/routes/setup/passkey.tsx +++ b/app/routes/setup/passkey.tsx @@ -56,6 +56,12 @@ export default function SetupPasskey() { // (name the held credential). The AAGUID exists only in the returned attestation, // so the pre-fill is computed at this transition. const [held, setHeld] = useState(null); + // A step-2 (Save) failure means navigator.credentials.create() ALREADY ran and the + // credential already exists in the user's authenticator/password manager — dropping back + // to step 1 silently, with no warning, means the next "Register passkey" click creates a + // SECOND credential and orphans the first (there is no WebAuthn API to cancel/delete it + // remotely). Persists across the reset so step 1 can warn instead of silently duplicating. + const [orphanRisk, setOrphanRisk] = useState(false); // Inline message + a recovery for recoverable codes (SESSION_EXPIRED → "Sign in again"). const { message: errorMessage, recovery } = useAuthActionRecovery(actionData, { @@ -68,13 +74,17 @@ export default function SetupPasskey() { // revalidation has already fetched a fresh challenge, so one click retries the // full ceremony. useEffect(() => { - if (actionData?.error) setHeld(null); + if (actionData?.error) { + setOrphanRisk(true); + setHeld(null); + } }, [actionData]); function handleCredential(credential: Record) { const att = (credential.response as { attestationObject?: string } | undefined) ?.attestationObject; const aaguid = att ? aaguidFromAttestationObject(att) : null; + setOrphanRisk(false); setHeld({ credential, defaultName: defaultPasskeyName(aaguid, navigator.userAgent), @@ -170,6 +180,16 @@ export default function SetupPasskey() { ) : null} + {orphanRisk ? ( +

+ + Your previous attempt may have already created a passkey on this device. Retrying + will register a new one — you can remove the unused entry later from your device + or password manager. + +

+ ) : null} +