From 57dc94d40de4bce72507d366f0ba35f0e5e879f5 Mon Sep 17 00:00:00 2001 From: Yahya Fakhroji Date: Fri, 31 Jul 2026 11:05:01 +0700 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20usernameless=20passkey=20login=20?= =?UTF-8?q?=E2=80=94=20hint=20fast=20path=20+=20button=20discovery?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Works around the Zitadel constraint that a WebAuthn challenge requires an already-identified user with two complementary entry points: - Returning browsers: a signed passkey-hint cookie (loginName of the last authenticated user, written on every login success, cleared on logout) lets the /login loader mint a user-bound challenge and arm a conditional ceremony — one tap, zero typing. - Fresh browsers: the Passkey button runs a modal discovery ceremony over a self-minted challenge; the assertion's userHandle (== Zitadel userId) resolves the user via POST /login/passkey-discover, which mints the real challenge for the standard /login/passkey verify. Every user-dependent discover failure is one opaque 400 (enumeration parity); the endpoint shares the webauthn verify rate limit. - Ambient arming is hinted-only — no auto-prompt on fresh loads (password managers escalate conditional requests into full pickers); discovery is button-initiated, with failures surfaced through WebAuthnReasonCopy and the identifier field as fallback. - Sole-passkey identifiers keep REDIRECTING to /login/passkey (Task-12 product ruling) — supersedes the inline ceremony #107 shipped; its reachability + view logic (showIdentifierForm/showContinue) survive. - Chooser buttons rebranded to short labels (Email / Phone / Username). - v0.1.0 release prep (CONTRIBUTING/SECURITY, untrack .claude/settings). --- .claude/settings.json | 14 - .gitignore | 2 + CONTRIBUTING.md | 3 +- SECURITY.md | 20 + app/components/back-link/previous-step.ts | 1 + .../webauthn-button/webauthn-button.tsx | 5 +- app/hooks/use-conditional-passkey.ts | 342 +++++++++++ app/hooks/use-passkey-login-ceremony.ts | 2 +- app/modules/auth/session/passkey-hint.ts | 38 ++ app/modules/i18n/locales/en.po | 123 ++-- app/resources/otp/otp-verify.ts | 2 + .../session/session-logout.service.ts | 25 +- app/resources/session/session.service.ts | 11 +- app/resources/signup/signup.service.ts | 4 +- app/resources/sso/sso-callback.ts | 9 + app/resources/sso/sso-outcome.ts | 4 + app/resources/webauthn/identity-challenge.ts | 30 + app/resources/webauthn/webauthn.service.ts | 89 ++- app/resources/webauthn/webauthn.ts | 9 +- app/routes.ts | 1 + app/routes/accounts.tsx | 7 +- app/routes/login/index.tsx | 545 ++++++++++-------- app/routes/login/passkey-discover.tsx | 108 ++++ app/routes/login/passkey.tsx | 13 +- app/routes/login/password.tsx | 2 + app/routes/paths.ts | 1 + app/routes/signup/complete.tsx | 2 + app/server/middleware/rate-limit.ts | 6 +- package.json | 2 +- 29 files changed, 1074 insertions(+), 346 deletions(-) delete mode 100644 .claude/settings.json create mode 100644 SECURITY.md create mode 100644 app/hooks/use-conditional-passkey.ts create mode 100644 app/modules/auth/session/passkey-hint.ts create mode 100644 app/resources/webauthn/identity-challenge.ts create mode 100644 app/routes/login/passkey-discover.tsx diff --git a/.claude/settings.json b/.claude/settings.json deleted file mode 100644 index 97f9f1bbaf..0000000000 --- a/.claude/settings.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "extraKnownMarketplaces": { - "datum-claude-code-plugins": { - "source": { - "source": "github", - "repo": "datum-cloud/claude-code-plugins" - } - } - }, - "enabledPlugins": { - "datum-platform@datum-claude-code-plugins": true, - "datum-gtm@datum-claude-code-plugins": true - } -} diff --git a/.gitignore b/.gitignore index aa2e36b0c0..9940b90710 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,8 @@ node_modules /cypress/videos /cypress/screenshots .claude/settings.local.json +.claude/settings.json +.superpowers/ app/modules/i18n/locales/*.ts app/modules/i18n/locales/*.js /.lighthouseci diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 181d7165b9..e96476df88 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -72,5 +72,4 @@ See [Testing](docs/development/testing.md) for the full test strategy. ## License -By contributing, you agree that your contributions will be licensed under the -[Apache License 2.0](LICENSE). +By contributing, you agree that your contributions will be licensed under the MIT License, the same as this project. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000000..a8d8ff30fc --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,20 @@ +# Security Policy + +## Reporting a Vulnerability + +If you discover a security vulnerability in auth-ui, please report it responsibly by emailing the Datum Cloud security team rather than opening a public issue. This allows us to address the vulnerability before it is disclosed publicly. + +For details on Datum Cloud's security policy and how to report vulnerabilities, please see the [Datum Cloud Security Policy](https://github.com/datum-cloud/.github/blob/main/SECURITY.md). + +## Security Considerations + +This project handles authentication and authorization. Security is a critical concern. When contributing, please: + +- Review the [Architecture Security Documentation](docs/architecture/session-and-security.md) for security design principles +- Run the full test suite before submitting changes +- Be mindful of authentication and authorization boundaries +- Report suspected security issues privately, not in issues or pull requests + +## Supported Versions + +Security updates are provided for the current major version only. Users are encouraged to keep their deployment up to date with the latest releases. diff --git a/app/components/back-link/previous-step.ts b/app/components/back-link/previous-step.ts index 2d835e9cec..e11a535544 100644 --- a/app/components/back-link/previous-step.ts +++ b/app/components/back-link/previous-step.ts @@ -14,6 +14,7 @@ const PREVIOUS_STEP: Array<[match: (p: string) => boolean, target: string]> = [ // 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/passkey', '/login'], [(p) => p === '/login/security-key', '/login'], [(p) => p === '/signup/password', '/signup'], [(p) => p === '/signup/method', '/signup'], diff --git a/app/components/webauthn-button/webauthn-button.tsx b/app/components/webauthn-button/webauthn-button.tsx index 9e5c53798b..fc0333171e 100644 --- a/app/components/webauthn-button/webauthn-button.tsx +++ b/app/components/webauthn-button/webauthn-button.tsx @@ -25,7 +25,10 @@ export const CYPRESS_CREDENTIAL = { authenticatorData: 'ZmFrZS1hdXRoZW50aWNhdG9yLWRhdGE', clientDataJSON: 'ZmFrZS1jbGllbnQtZGF0YS1qc29u', signature: 'ZmFrZS1zaWduYXR1cmU', - userHandle: null, + // base64url('u5') — the fake singleton's passkey user. The verify path ignores + // userHandle; the /login/passkey-discover action reads it to resolve identity, + // so the pre-baked credential must claim a real seeded passkey user. + userHandle: 'dTU', }, }; diff --git a/app/hooks/use-conditional-passkey.ts b/app/hooks/use-conditional-passkey.ts new file mode 100644 index 0000000000..0f78854ff6 --- /dev/null +++ b/app/hooks/use-conditional-passkey.ts @@ -0,0 +1,342 @@ +import { CYPRESS_CREDENTIAL } from '@/components/webauthn-button/webauthn-button'; +import { unwrapPublicKey } from '@/hooks/use-passkey-login-ceremony'; +import { APP_BASENAME } from '@/resources/shared/app-basename'; +import { + marshalAssertion, + isWebAuthnSupported, + WebAuthnCeremonyError, + 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 ConditionalPasskeyPhase = 'idle' | 'armed' | 'submitting' | 'done'; + +/** The discover action's success payload (declared locally — importing the route + * module would drag its server-only imports into the client graph). */ +interface DiscoverResponse { + loginName: string; + csrfToken: string; + publicKeyCredentialRequestOptions: unknown; +} + +export interface ConditionalPasskeyInput { + /** Master switch — false keeps the hook fully inert (no detection, no arming). */ + enabled: boolean; + /** 'hinted' (default): the loader pre-minted a user-bound Zitadel challenge; the + * assertion submits straight to the verify action. 'discovery': the loader minted + * a SELF-issued identity challenge; the first assertion is an identity claim that + * posts to /login/passkey-discover, whose response carries the real challenge for + * a modal second ceremony (spec: usernameless discovery design). */ + mode?: 'hinted' | 'discovery'; + /** The hinted user the pre-minted challenge belongs to (loader-resolved loginName). + * Empty in discovery mode — the discover response resolves it. */ + loginName: string; + csrfToken: string; + /** Pre-minted by the /login loader (base64url fields still encoded). Null → inert. */ + publicKeyCredentialRequestOptions: unknown; + requestId?: string; + organization?: string; +} + +/** Read the Cypress seam flags (fake path — real conditional mediation can't run headless). */ +function cypressSeam() { + const w = + typeof window !== 'undefined' + ? (window as unknown as { + Cypress?: unknown; + __webAuthnRealCeremony?: boolean; + __conditionalPasskeyAutoResolve?: boolean; + }) + : undefined; + return { w, fake: w?.Cypress !== undefined && !w?.__webAuthnRealCeremony }; +} + +/** + * Arms the usernameless fast path: navigator.credentials.get({ mediation: 'conditional' }) + * over the loader-minted challenge. The promise parks until the user taps their passkey in + * the browser's own autofill dropdown. HINTED mode submits the signed assertion to the + * existing /login/passkey verify action; DISCOVERY mode first posts it to + * /login/passkey-discover via plain fetch (identity resolution — the assertion is an + * untrusted claim, only its userHandle is read server-side), then runs a MODAL ceremony + * over the returned real challenge and submits THAT assertion to the verify action. The + * verify submits are tagged passkeyCeremony so neither /login's nor /login/passkey's + * loader re-mints mid-flight (the discover hop bypasses RR entirely — see submitDiscover). + * ONE-SHOT by design: abort() — form submit, inline-ceremony takeover — permanently + * retires it. Every failure is a designed NON-EVENT (the ordinary form is already on + * screen). A rejected assertion re-fetches + re-arms exactly once in hinted mode; in + * discovery mode it stops silently (a cross-device QR passkey must never be forced + * through a second phone round-trip). + */ +export function useConditionalPasskey(input: ConditionalPasskeyInput) { + const mode = input.mode ?? 'hinted'; + const challengeFetcher = useFetcher(); + const submitFetcher = useFetcher(); + // Discovery's verify submission is STAGED through state + an effect rather than + // dispatched straight from the fetch continuation: a fetcher.submit fired from a + // deep async context can leave this instance's effect subscription stale (the + // rejection effect never re-runs even though the fetcher data renders), while a + // dispatch from inside an effect — the same context the hinted fake path uses — + // is reliably observed. Correct-by-construction over scheduler-dependent. + const [pendingVerify, setPendingVerify] = useState<{ + csrfToken: string; + credential: Record; + loginName: string; + } | null>(null); + const [phase, setPhase] = useState('idle'); + // Failure copy for EXPLICIT (button-initiated) discovery only — the ambient + // conditional flow keeps every failure a silent non-event (spec error matrix). + const [reason, setReason] = useState(null); + const armedRef = useRef(false); // one-shot arming (also latched by abort()) + const retriedRef = useRef(false); // expired-challenge re-arm, once (hinted only) + const abortedRef = useRef(false); // abort() latch for the async discovery stages + const explicitRef = useRef(false); // true while a button-initiated (modal) flow runs + const abortRef = useRef(null); + + const verifyPath = useCallback( + (loginName: string) => + paths.login.passkey({ + loginName, + requestId: input.requestId, + organization: input.organization, + }), + [input.requestId, input.organization] + ); + + const submit = useCallback( + (csrfToken: string, credential: Record, loginName: string) => { + setPhase('submitting'); + submitFetcher.submit( + { + csrf: csrfToken, + credential: JSON.stringify(credential), + loginName, + ...(input.requestId ? { requestId: input.requestId } : {}), + ...(input.organization ? { organization: input.organization } : {}), + // Read by /login's AND /login/passkey's shouldRevalidate (WEBAU-3M9si guard). + passkeyCeremony: '1', + }, + { method: 'post', action: verifyPath(loginName) } + ); + }, + [input.requestId, input.organization, verifyPath, submitFetcher] + ); + + /** Discovery handoff: run the MODAL ceremony over the discover-returned real challenge. */ + const completeDiscovery = useCallback(async (d: DiscoverResponse) => { + const { fake } = cypressSeam(); + if (fake) { + // Headless can't run a modal ceremony; the pre-baked credential stands in for + // assertion #2 exactly as it does for the other ceremony hooks. + setPendingVerify({ + csrfToken: d.csrfToken, + credential: CYPRESS_CREDENTIAL, + loginName: d.loginName, + }); + return; + } + try { + const credential = await marshalAssertion( + unwrapPublicKey(d.publicKeyCredentialRequestOptions) as WebAuthnChallengeInput + ); + // The single biometric of the whole flow fires here (identity tap was UV + // 'discouraged'; this real challenge is UV 'required'). + setPendingVerify({ csrfToken: d.csrfToken, credential, loginName: d.loginName }); + } catch { + // Modal cancel / ceremony failure — designed non-event, never retried. + setPhase('done'); + } + }, []); + + // Staged verify dispatch (see the pendingVerify comment above). + useEffect(() => { + if (!pendingVerify || abortedRef.current) return; + setPendingVerify(null); + submit(pendingVerify.csrfToken, pendingVerify.credential, pendingVerify.loginName); + }, [pendingVerify, submit]); + + /** + * Discovery: post the identity assertion (assertion #1) to the discover action. + * PLAIN fetch(), deliberately NOT an RR fetcher: this is a pure JSON API hop, and a + * fetcher would drag in lazy route discovery plus a client route-module load + * mid-ceremony — React Router hard-reloads the page when that load hiccups + * (loadRouteModule → location.reload()), killing the armed ceremony — and would + * trigger loader revalidation this hop has no use for. fetch() sends the csrf + * cookie (same-origin default) and applies the response's Set-Cookie natively. + */ + const submitDiscover = useCallback( + async (csrfToken: string, credential: Record) => { + setPhase('submitting'); + try { + const res = await fetch(`${APP_BASENAME}${paths.login.passkeyDiscover()}`, { + method: 'POST', + body: new URLSearchParams({ csrf: csrfToken, credential: JSON.stringify(credential) }), + }); + if (abortedRef.current) return; + if (!res.ok) { + // Opaque 400 (unknown user, no passkey, mint failure). Ambient flow: designed + // non-event. Explicit (button) flow: the user acted, so say something — the + // 'not-allowed' copy ("cancelled, or no passkey for this account is available") + // is the truthful fit (spec, open decision §3). + if (explicitRef.current) setReason('not-allowed'); + setPhase('done'); + return; + } + const d = (await res.json()) as Partial | null; + if (abortedRef.current) return; + if ( + !d || + typeof d.loginName !== 'string' || + d.loginName.length === 0 || + typeof d.csrfToken !== 'string' || + !d.publicKeyCredentialRequestOptions + ) { + if (explicitRef.current) setReason('unknown'); + setPhase('done'); + return; + } + await completeDiscovery(d as DiscoverResponse); + } catch { + // Network failure / malformed body — non-event (ambient) or generic copy (explicit). + if (explicitRef.current) setReason('unknown'); + setPhase('done'); + } + }, + [completeDiscovery] + ); + + /** + * EXPLICIT discovery (spec, open decision §3 as built): the Passkey button runs the + * same discovery pipeline MODALLY — credentials.get over the loader's self-minted + * options WITHOUT conditional mediation, so the browser opens its native picker + * (including cross-device QR). Retires the ambient ceremony first (explicit intent + * supersedes it, and clears a prior abort/cancel latch so the button works after the + * user dismissed the ambient prompt). Returns false when it cannot start — discovery + * not armed, WebAuthn unsupported, or a flow already in flight — so the caller can + * fall back to the identifier step. + */ + const beginDiscovery = useCallback((): boolean => { + if (mode !== 'discovery' || !input.publicKeyCredentialRequestOptions) return false; + if (phase === 'submitting') return false; // single-flight + const { fake } = cypressSeam(); + if (!fake && !isWebAuthnSupported()) return false; + abortRef.current?.abort(); + abortRef.current = null; + armedRef.current = true; // the ambient conditional arm stays permanently retired + abortedRef.current = false; // a prior abort() must not swallow THIS flow + explicitRef.current = true; + setReason(null); + setPhase('submitting'); + void (async () => { + if (fake) { + // Explicit click under Cypress — the pre-baked credential IS the picked passkey + // (same convention as WebAuthnButton's click path; no auto-resolve gate). + await submitDiscover(input.csrfToken, CYPRESS_CREDENTIAL); + return; + } + try { + const credential = await marshalAssertion( + unwrapPublicKey(input.publicKeyCredentialRequestOptions) as WebAuthnChallengeInput + // no mediation option → MODAL picker + ); + await submitDiscover(input.csrfToken, credential); + } catch (err) { + // Picker cancel / ceremony failure — the user acted, so surface the copy. + setReason(err instanceof WebAuthnCeremonyError ? err.reason : 'unknown'); + setPhase('done'); + } + })(); + return true; + }, [mode, phase, input.publicKeyCredentialRequestOptions, input.csrfToken, submitDiscover]); + + const arm = useCallback( + async (csrfToken: string, options: unknown) => { + if (!options) return; + // Cypress fake path — same gate as WebAuthnButton/usePasskeyLoginCeremony. Real + // conditional mediation can't run headless; __conditionalPasskeyAutoResolve simulates + // "the user tapped the passkey" with the pre-baked credential, and WITHOUT the flag + // the ceremony parks in 'armed' (lets specs assert abort/no-fire behavior). + const { w, fake } = cypressSeam(); + if (fake) { + setPhase('armed'); + if (w?.__conditionalPasskeyAutoResolve) { + if (mode === 'discovery') void submitDiscover(csrfToken, CYPRESS_CREDENTIAL); + else submit(csrfToken, CYPRESS_CREDENTIAL, input.loginName); + } + return; + } + if (!isWebAuthnSupported()) return; + const pkc = window.PublicKeyCredential as typeof PublicKeyCredential & { + isConditionalMediationAvailable?: () => Promise; + }; + if (typeof pkc.isConditionalMediationAvailable !== 'function') return; + if (!(await pkc.isConditionalMediationAvailable())) return; + const controller = new AbortController(); + abortRef.current = controller; + setPhase('armed'); + try { + const credential = await marshalAssertion( + unwrapPublicKey(options) as WebAuthnChallengeInput, + { mediation: 'conditional', signal: controller.signal } + ); + if (mode === 'discovery') void submitDiscover(csrfToken, credential); + else submit(csrfToken, credential, input.loginName); + } catch { + // Deliberate abort() or a browser-side ceremony failure — designed non-events. + setPhase('done'); + } + }, + [mode, input.loginName, submit, submitDiscover] + ); + + // Arm once when enabled with a loader-minted challenge. + useEffect(() => { + if (!input.enabled || armedRef.current || !input.publicKeyCredentialRequestOptions) return; + armedRef.current = true; + void arm(input.csrfToken, input.publicKeyCredentialRequestOptions); + }, [input.enabled, input.publicKeyCredentialRequestOptions, input.csrfToken, arm]); + + /** Retire the ceremony (the user stated explicit intent). One-shot — never re-arms. */ + const abort = useCallback(() => { + abortRef.current?.abort(); + abortRef.current = null; + armedRef.current = true; // latch: suppress any future arming + abortedRef.current = true; // latch: suppress the async discovery handoff + setPhase('done'); + }, []); + + // A rejected assertion returns DATA (a success is a redirect the fetcher follows). + // Hinted: re-fetch a fresh challenge and re-arm exactly once, then stop silently. + // Discovery: stop immediately — never force a second discover round-trip. + useEffect(() => { + if (phase !== 'submitting' || submitFetcher.state !== 'idle' || !submitFetcher.data) return; + if (mode === 'discovery' || retriedRef.current) { + setPhase('done'); + return; + } + retriedRef.current = true; + setPhase('idle'); + challengeFetcher.load(verifyPath(input.loginName)); + }, [ + phase, + mode, + submitFetcher.state, + submitFetcher.data, + challengeFetcher, + verifyPath, + input.loginName, + ]); + + // Retry completion (hinted only): the fresh challenge landed → re-arm with ITS csrf token. + useEffect(() => { + if (!retriedRef.current || phase !== 'idle' || challengeFetcher.state !== 'idle') return; + const d = challengeFetcher.data; + if (!d) return; + void arm(d.csrfToken, d.publicKeyCredentialRequestOptions); + }, [phase, challengeFetcher.state, challengeFetcher.data, arm]); + + return { abort, phase, reason, beginDiscovery }; +} diff --git a/app/hooks/use-passkey-login-ceremony.ts b/app/hooks/use-passkey-login-ceremony.ts index 3577ebd46c..39ded81bcc 100644 --- a/app/hooks/use-passkey-login-ceremony.ts +++ b/app/hooks/use-passkey-login-ceremony.ts @@ -21,7 +21,7 @@ export interface PasskeyLoginCeremonyInput { } /** Extract the inner publicKey the marshaller expects (mirrors login/passkey.tsx). */ -function unwrapPublicKey(options: unknown): unknown { +export function unwrapPublicKey(options: unknown): unknown { return options !== null && typeof options === 'object' && 'publicKey' in (options as object) ? (options as { publicKey: unknown }).publicKey : options; diff --git a/app/modules/auth/session/passkey-hint.ts b/app/modules/auth/session/passkey-hint.ts new file mode 100644 index 0000000000..9b89238898 --- /dev/null +++ b/app/modules/auth/session/passkey-hint.ts @@ -0,0 +1,38 @@ +import { env } from '@/server/infra/env.server'; +import { createCookie } from 'react-router'; + +/** + * Identity hint for the usernameless-passkey fast path: the loginName of the last + * successfully authenticated user in this browser. NEVER an auth signal and NEVER + * rendered — it only lets the /login loader mint a WebAuthn challenge for a known + * user so the browser can offer their passkey via conditional mediation. + * Same protection class as the `sessions` cookie (which already stores loginName): + * httpOnly, sameSite lax, path /id, signed with SESSION_SECRET. + * 7-day maxAge — outlives the 24h sessions cookie (the fast path exists precisely + * for returning users whose session has expired) yet a shared browser forgets + * within a week. + */ +export const passkeyHintCookie = createCookie('passkey-hint', { + httpOnly: true, + sameSite: 'lax', + path: '/id', + secure: env.NODE_ENV === 'production', + secrets: [env.SESSION_SECRET], + maxAge: 60 * 60 * 24 * 7, // 7 days +}); + +/** Serialize the hint (a loginName) to a Set-Cookie string. Written on every successful login. */ +export async function serializePasskeyHint(loginName: string): Promise { + return passkeyHintCookie.serialize(loginName); +} + +/** Read the hinted loginName. Returns null when the cookie is absent, invalid, or empty. */ +export async function readPasskeyHint(request: Request): Promise { + const value = await passkeyHintCookie.parse(request.headers.get('cookie')); + return typeof value === 'string' && value.length > 0 ? value : null; +} + +/** A Set-Cookie that expires the hint immediately (logout / unresolvable hinted user). */ +export async function clearPasskeyHint(): Promise { + return passkeyHintCookie.serialize('', { maxAge: 0 }); +} diff --git a/app/modules/i18n/locales/en.po b/app/modules/i18n/locales/en.po index 840857e102..406068fc0d 100644 --- a/app/modules/i18n/locales/en.po +++ b/app/modules/i18n/locales/en.po @@ -18,7 +18,7 @@ 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." #. placeholder {0}: attempts.count -#: app/routes/login/password.tsx:180 +#: app/routes/login/password.tsx:182 msgid "{0, plural, one {# attempt remaining.} other {# attempts remaining.}}" msgstr "{0, plural, one {# attempt remaining.} other {# attempts remaining.}}" @@ -41,7 +41,7 @@ msgstr "A new code has been sent to your email." msgid "Activate your device" msgstr "Activate your device" -#: app/routes/accounts.tsx:172 +#: app/routes/accounts.tsx:175 msgid "Add an account" msgstr "Add an account" @@ -49,7 +49,7 @@ msgstr "Add an account" msgid "Add an extra layer of security to your account by setting up a second factor." msgstr "Add an extra layer of security to your account by setting up a second factor." -#: app/routes/accounts.tsx:264 +#: app/routes/accounts.tsx:267 msgid "Add another account" msgstr "Add another account" @@ -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:396 +#: app/routes/login/index.tsx:473 msgid "An account with this email already exists — sign in to continue." msgstr "An account with this email already exists — sign in to continue." @@ -168,7 +168,7 @@ msgstr "Check your email" msgid "Choose a new password" msgstr "Choose a new password" -#: app/routes/accounts.tsx:146 +#: app/routes/accounts.tsx:149 msgid "Choose an account" msgstr "Choose an account" @@ -180,7 +180,7 @@ 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:392 +#: app/routes/login/index.tsx:469 msgid "Choose your login method" msgstr "Choose your login method" @@ -211,7 +211,7 @@ msgid "Connected accounts" msgstr "Connected accounts" #: app/routes/device/index.tsx:73 -#: app/routes/login/index.tsx:527 +#: app/routes/login/index.tsx:594 #: app/routes/signup/index.tsx:291 msgid "Continue" msgstr "Continue" @@ -221,23 +221,7 @@ msgstr "Continue" msgid "Continue with {0}" msgstr "Continue with {0}" -#: app/routes/login/index.tsx:364 -msgid "Continue with email" -msgstr "Continue with email" - -#: app/routes/login/index.tsx:437 -msgid "Continue with passkey" -msgstr "Continue with passkey" - -#: app/routes/login/index.tsx:366 -msgid "Continue with phone" -msgstr "Continue with phone" - -#: app/routes/login/index.tsx:367 -msgid "Continue with username" -msgstr "Continue with username" - -#: app/routes/login/password.tsx:128 +#: app/routes/login/password.tsx:130 msgid "Could not verify password" msgstr "Could not verify password" @@ -257,7 +241,7 @@ msgstr "Couldn't verify" msgid "Create a new account" msgstr "Create a new account" -#: app/routes/login/index.tsx:573 +#: app/routes/login/index.tsx:639 #: app/routes/signup/password.tsx:237 msgid "Create account" msgstr "Create account" @@ -284,7 +268,8 @@ msgstr "Device code" msgid "Device denied" msgstr "Device denied" -#: app/routes/login/index.tsx:350 +#: app/routes/login/index.tsx:426 +#: app/routes/login/index.tsx:441 #: app/routes/signup/index.tsx:250 #: app/routes/signup/index.tsx:275 msgid "Email" @@ -298,8 +283,8 @@ msgstr "Email code" msgid "Email me a code" msgstr "Email me a code" -#: app/routes/login/index.tsx:538 -#: app/routes/login/index.tsx:548 +#: app/routes/login/index.tsx:605 +#: app/routes/login/index.tsx:615 #: app/routes/login/method.tsx:238 #: app/routes/signup/method.tsx:339 msgid "Email me a sign-in link" @@ -321,7 +306,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:349 +#: app/routes/login/index.tsx:425 msgid "Email, phone, or username" msgstr "Email, phone, or username" @@ -361,8 +346,8 @@ msgstr "Enter your authenticator code" msgid "Enter your email code" msgstr "Enter your email code" -#: app/routes/login/password.tsx:148 -#: app/routes/login/password.tsx:165 +#: app/routes/login/password.tsx:150 +#: app/routes/login/password.tsx:167 msgid "Enter your password" msgstr "Enter your password" @@ -378,7 +363,7 @@ msgstr "Finish creating your account" 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." -#: app/routes/login/password.tsx:211 +#: app/routes/login/password.tsx:213 msgid "Forgot password?" msgstr "Forgot password?" @@ -402,7 +387,7 @@ msgstr "Incorrect credentials. Please try again." msgid "Last used" msgstr "Last used" -#: app/routes/signup/complete.tsx:108 +#: app/routes/signup/complete.tsx:110 msgid "Link expired" msgstr "Link expired" @@ -428,7 +413,7 @@ msgstr "Manual setup key" msgid "Name your passkey" msgstr "Name your passkey" -#: app/routes/accounts.tsx:223 +#: app/routes/accounts.tsx:226 msgid "Needs re-authentication" msgstr "Needs re-authentication" @@ -453,7 +438,7 @@ msgstr "No sign-in method is available for re-authentication." msgid "No sign-in method is available for this account." msgstr "No sign-in method is available for this account." -#: app/routes/accounts.tsx:164 +#: app/routes/accounts.tsx:167 msgid "No signed-in accounts." msgstr "No signed-in accounts." @@ -461,13 +446,12 @@ msgstr "No signed-in accounts." msgid "Not now" msgstr "Not now" -#: app/routes/login/index.tsx:571 +#: app/routes/login/index.tsx:637 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:425 #: app/routes/login/method.tsx:193 #: app/routes/passkeys.tsx:249 #: app/routes/reauth.tsx:280 @@ -488,7 +472,7 @@ 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:471 +#: app/routes/login/index.tsx:535 #: app/routes/login/method.tsx:221 #: app/routes/reauth.tsx:310 #: app/routes/setup/mfa.tsx:46 @@ -503,19 +487,19 @@ msgstr "Passkey name" msgid "Passkey removed" msgstr "Passkey removed" -#: app/components/webauthn-button/webauthn-button.tsx:106 +#: app/components/webauthn-button/webauthn-button.tsx:109 msgid "Passkey setup couldn't be completed for security reasons. Please contact support if this continues." msgstr "Passkey setup couldn't be completed for security reasons. Please contact support if this continues." -#: app/components/webauthn-button/webauthn-button.tsx:90 +#: app/components/webauthn-button/webauthn-button.tsx:93 msgid "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." msgstr "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." -#: app/components/webauthn-button/webauthn-button.tsx:67 +#: app/components/webauthn-button/webauthn-button.tsx:70 msgid "Passkey sign-in couldn't be completed for security reasons. Please contact support if this continues." msgstr "Passkey sign-in couldn't be completed for security reasons. Please contact support if this continues." -#: app/components/webauthn-button/webauthn-button.tsx:53 +#: app/components/webauthn-button/webauthn-button.tsx:56 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." @@ -559,7 +543,8 @@ 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:352 +#: app/routes/login/index.tsx:428 +#: app/routes/login/index.tsx:443 msgid "Phone" msgstr "Phone" @@ -567,7 +552,7 @@ msgstr "Phone" msgid "Phone sign-in isn't available — use your email or username." msgstr "Phone sign-in isn't available — use your email or username." -#: app/routes/login/password.tsx:199 +#: app/routes/login/password.tsx:201 #: app/utils/errors/auth-error-messages.tsx:27 msgid "Please check your input and try again." msgstr "Please check your input and try again." @@ -625,7 +610,7 @@ msgstr "Scan the QR code below with your authenticator app, then enter the 6-dig msgid "Security key" msgstr "Security key" -#: app/routes/accounts.tsx:147 +#: app/routes/accounts.tsx:150 msgid "Select an account to continue or add a new one." msgstr "Select an account to continue or add a new one." @@ -637,7 +622,7 @@ msgstr "Send reset link" msgid "Service temporarily unavailable. Please try again." msgstr "Service temporarily unavailable. Please try again." -#: app/routes/accounts.tsx:221 +#: app/routes/accounts.tsx:224 msgid "Session active" msgstr "Session active" @@ -675,13 +660,13 @@ msgstr "Set up security key" msgid "Set up SMS one-time code" msgstr "Set up SMS one-time code" -#: app/routes/login/password.tsx:204 +#: app/routes/login/password.tsx:206 #: app/routes/signup/index.tsx:308 #: app/routes/sso/ldap.tsx:85 msgid "Sign in" msgstr "Sign in" -#: app/routes/login/password.tsx:193 +#: app/routes/login/password.tsx:195 #: app/routes/logout/success.tsx:27 #: app/utils/errors/auth-error-recovery.tsx:47 msgid "Sign in again" @@ -699,7 +684,7 @@ msgstr "Sign in with" msgid "Sign in with LDAP" msgstr "Sign in with LDAP" -#: app/routes/login/passkey.tsx:115 +#: app/routes/login/passkey.tsx:120 msgid "Sign in with your passkey" msgstr "Sign in with your passkey" @@ -717,7 +702,7 @@ msgstr "Sign out of" msgid "Sign out other sessions" msgstr "Sign out other sessions" -#: app/routes/login/index.tsx:560 +#: app/routes/login/index.tsx:627 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." @@ -730,11 +715,6 @@ msgstr "Signed-in sessions on other devices can still be active. Sign out your o msgid "Signing in as" msgstr "Signing in as" -#. placeholder {0}: passkeyInline.loginName -#: app/routes/login/index.tsx:418 -msgid "Signing in as <0>{0}." -msgstr "Signing in as <0>{0}." - #: app/routes/login/method.tsx:187 msgid "Signing in as <0>{loginName}." msgstr "Signing in as <0>{loginName}." @@ -769,7 +749,7 @@ msgstr "Something went wrong with <0>{provider}." msgid "Something went wrong. Please try again." msgstr "Something went wrong. Please try again." -#: app/routes/signup/complete.tsx:119 +#: app/routes/signup/complete.tsx:121 #: app/utils/errors/auth-error-recovery.tsx:50 msgid "Start over" msgstr "Start over" @@ -800,7 +780,7 @@ msgstr "That identity belongs to a different account." msgid "That's already been done." msgstr "That's already been done." -#: app/components/webauthn-button/webauthn-button.tsx:73 +#: app/components/webauthn-button/webauthn-button.tsx:76 msgid "The passkey verification failed. Please try again." msgstr "The passkey verification failed. Please try again." @@ -813,11 +793,11 @@ msgstr "The request timed out. Please try again." msgid "The sign-in link was incomplete or expired." msgstr "The sign-in link was incomplete or expired." -#: app/components/webauthn-button/webauthn-button.tsx:99 +#: app/components/webauthn-button/webauthn-button.tsx:102 msgid "This device can't create a passkey. Try another device, a security key, or a password manager." msgstr "This device can't create a passkey. Try another device, a security key, or a password manager." -#: app/components/webauthn-button/webauthn-button.tsx:60 +#: app/components/webauthn-button/webauthn-button.tsx:63 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." @@ -849,7 +829,7 @@ msgstr "This name is for your Datum passkey list — your password manager label msgid "This removes it as a sign-in method for your account." msgstr "This removes it as a sign-in method for your account." -#: app/routes/signup/complete.tsx:109 +#: app/routes/signup/complete.tsx:111 msgid "This sign-in link is invalid or has expired." msgstr "This sign-in link is invalid or has expired." @@ -883,7 +863,7 @@ msgstr "Unlink {providerLabel}?" msgid "Use a passkey" msgstr "Use a passkey" -#: app/routes/login/passkey.tsx:93 +#: app/routes/login/passkey.tsx:98 msgid "Use your passkey to verify your identity." msgstr "Use your passkey to verify your identity." @@ -891,7 +871,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:353 +#: app/routes/login/index.tsx:429 +#: app/routes/login/index.tsx:444 #: app/routes/sso/ldap.tsx:76 msgid "Username" msgstr "Username" @@ -912,8 +893,8 @@ msgstr "Verify" msgid "Verify and enable" msgstr "Verify and enable" -#: app/components/webauthn-button/webauthn-button.tsx:277 -#: app/routes/login/passkey.tsx:92 +#: app/components/webauthn-button/webauthn-button.tsx:280 +#: app/routes/login/passkey.tsx:97 msgid "Verify with passkey" msgstr "Verify with passkey" @@ -938,7 +919,7 @@ msgstr "We could not find an account for that identifier." msgid "We couldn't find what you were looking for. Please try again." msgstr "We couldn't find what you were looking for. Please try again." -#: app/components/webauthn-button/webauthn-button.tsx:112 +#: app/components/webauthn-button/webauthn-button.tsx:115 msgid "We couldn't set up your passkey. Please try again." msgstr "We couldn't set up your passkey. Please try again." @@ -962,7 +943,7 @@ msgstr "We've sent a password reset link to <0>{0}" 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:389 +#: app/routes/login/index.tsx:466 msgid "Welcome" msgstr "Welcome" @@ -970,7 +951,7 @@ msgstr "Welcome" msgid "While Datum is currently free of charge to use, we require a valid payment method during the signup process." msgstr "While Datum is currently free of charge to use, we require a valid payment method during the signup process." -#: app/components/webauthn-button/webauthn-button.tsx:96 +#: app/components/webauthn-button/webauthn-button.tsx:99 msgid "You already have a passkey for this account on this device." msgstr "You already have a passkey for this account on this device." @@ -1006,7 +987,7 @@ msgstr "You may return to your device." msgid "You must be signed in to link an external account." msgstr "You must be signed in to link an external account." -#: app/routes/accounts.tsx:154 +#: app/routes/accounts.tsx:157 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." @@ -1018,11 +999,11 @@ msgstr "You'll be asked to sign in before authorizing." msgid "You've been signed out" msgstr "You've been signed out" -#: app/routes/login/password.tsx:175 +#: app/routes/login/password.tsx:177 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:257 +#: app/components/webauthn-button/webauthn-button.tsx:260 msgid "Your browser does not support passkeys. Please use a supported browser." msgstr "Your browser does not support passkeys. Please use a supported browser." @@ -1050,7 +1031,7 @@ msgstr "Your previous attempt may have already created a passkey on this device. 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." -#: app/routes/login/password.tsx:191 +#: app/routes/login/password.tsx:193 #: app/utils/errors/auth-error-messages.tsx:56 msgid "Your session has expired." msgstr "Your session has expired." diff --git a/app/resources/otp/otp-verify.ts b/app/resources/otp/otp-verify.ts index 9bf2a58747..889b23c267 100644 --- a/app/resources/otp/otp-verify.ts +++ b/app/resources/otp/otp-verify.ts @@ -29,6 +29,7 @@ import { serializeSessions, } from '@/modules/auth/session/cookie'; import { serializeLastUsedLogin } from '@/modules/auth/session/last-used-login'; +import { serializePasskeyHint } from '@/modules/auth/session/passkey-hint'; import { dispatchEmailChallenge, dispatchSmsChallenge, @@ -166,6 +167,7 @@ export function createOtpVerifyHandlers(cfg: OtpVerifyConfig) { headers.append('set-cookie', await serializeSessions(updatedSessions)); if (cfg.writeLastUsedLogin) { headers.append('set-cookie', await serializeLastUsedLogin(cfg.writeLastUsedLogin)); + headers.append('set-cookie', await serializePasskeyHint(loginName)); } // email only: continue passkey enrollment when requested. diff --git a/app/resources/session/session-logout.service.ts b/app/resources/session/session-logout.service.ts index 809869d198..735e8e0872 100644 --- a/app/resources/session/session-logout.service.ts +++ b/app/resources/session/session-logout.service.ts @@ -12,6 +12,7 @@ import { removeSession, serializeSessions, } from '@/modules/auth/session/cookie'; +import { readPasskeyHint, clearPasskeyHint } from '@/modules/auth/session/passkey-hint'; import { env } from '@/server/infra/env.server'; import { logAuthEvent, hashActor } from '@/server/observability'; import { redirect } from 'react-router'; @@ -23,6 +24,8 @@ import { redirect } from 'react-router'; export interface LogoutOutcome { location: string; setCookie: string; + /** Clears the passkey-hint — set only when the signing-out identity owns it (or on sign-out-of-all). */ + clearHintCookie?: string; } /** Parse the comma-separated POST_LOGOUT_ALLOWLIST env into a list of origins. */ @@ -113,12 +116,23 @@ export async function performLogout( const hasResidualSessions = next.length > 0; const target = explicitTarget ?? (hasResidualSessions ? '/accounts' : '/logout/success'); - return { location: target, setCookie: await serializeSessions(next) }; + // Owner-scoped hint clearing: "logout clears everything" from the perspective of WHOEVER + // signed out. Alice signing out must not erase Bob's fast path (spec: hint-maintenance matrix). + const hint = await readPasskeyHint(request); + const clearHintCookie = + active && hint && hint.toLowerCase() === active.loginName.toLowerCase() + ? await clearPasskeyHint() + : undefined; + + return { location: target, setCookie: await serializeSessions(next), clearHintCookie }; } /** Turn a LogoutOutcome into the Response the /logout route returns. */ export function logoutOutcomeToResponse(outcome: LogoutOutcome) { - return redirect(outcome.location, { headers: { 'set-cookie': outcome.setCookie } }); + const headers = new Headers(); + headers.append('set-cookie', outcome.setCookie); + if (outcome.clearHintCookie) headers.append('set-cookie', outcome.clearHintCookie); + return redirect(outcome.location, { headers }); } /** @@ -157,5 +171,10 @@ export async function completeOidcLogout( ); const target = validatePostLogoutRedirect(request) ?? '/logout/success'; - return { location: target, setCookie: await serializeSessions([]) }; + return { + location: target, + setCookie: await serializeSessions([]), + // Sign-out-of-all: no session survives, so no identity keeps a claim on the hint. + clearHintCookie: await clearPasskeyHint(), + }; } diff --git a/app/resources/session/session.service.ts b/app/resources/session/session.service.ts index 9caa821aaf..c4c18c249c 100644 --- a/app/resources/session/session.service.ts +++ b/app/resources/session/session.service.ts @@ -26,6 +26,7 @@ import { byId, type SessionEntry, } from '@/modules/auth/session/cookie'; +import { serializePasskeyHint } from '@/modules/auth/session/passkey-hint'; import { serializeReauthIntent } from '@/modules/auth/session/reauth-intent'; import type { Session, AuthMethod, LoginSettings, ProviderErrorCode } from '@/modules/auth/types'; import { ProviderError } from '@/modules/auth/types'; @@ -535,7 +536,15 @@ export async function switchAccount( // /device/authorize consent screen for review + Authorize. Standalone/OIDC switches keep the // normal resolved destination. const location = userCode ? paths.device.authorize({ user_code: userCode }) : nextPath; - return { kind: 'redirect', location, setCookie: await serializeSessions(updated) }; + return { + kind: 'redirect', + location, + setCookie: await serializeSessions(updated), + // The switched-to account is now this browser's active identity — refresh the hint + // (spec: hint-maintenance matrix). reauthRedirect (dead session) intentionally does + // NOT rewrite it: identity is not re-established until re-auth actually succeeds. + cookies: [await serializePasskeyHint(entry.loginName)], + }; } /** diff --git a/app/resources/signup/signup.service.ts b/app/resources/signup/signup.service.ts index 001f03a52a..d829a7e4c2 100644 --- a/app/resources/signup/signup.service.ts +++ b/app/resources/signup/signup.service.ts @@ -47,6 +47,8 @@ export interface SignupRedirectResult { kind: 'redirect'; target: string; sessions: SessionEntry[]; + /** The created/authenticated account's loginName — write-site key for the passkey-hint. */ + loginName?: string; } /** @@ -151,7 +153,7 @@ export async function registerAndLinkIdp( // a brand-new IdP user completing a prompt=select_account / prompt=login ceremony loops straight // back to /accounts (or /login). Mirrors the password path's hand-back. const target = authorizeHandbackTarget(requestId, session.id); - return { kind: 'redirect', target, sessions }; + return { kind: 'redirect', target, sessions, loginName: user.loginName }; } // ── password-first hand-off (allowPassword) ──────────────────────────────────── diff --git a/app/resources/sso/sso-callback.ts b/app/resources/sso/sso-callback.ts index 59d7730c10..8e3720b313 100644 --- a/app/resources/sso/sso-callback.ts +++ b/app/resources/sso/sso-callback.ts @@ -13,6 +13,7 @@ import { serializeSessions, } from '@/modules/auth/session/cookie'; import { serializeLastUsedLogin } from '@/modules/auth/session/last-used-login'; +import { serializePasskeyHint } from '@/modules/auth/session/passkey-hint'; import { clearReauthIntent, readReauthIntent } from '@/modules/auth/session/reauth-intent'; import { ProviderError } from '@/modules/auth/types'; import type { IdpIntentResult } from '@/modules/auth/types'; @@ -291,11 +292,13 @@ export async function processIdpCallback( requestId, }); const lastUsedCookie = await serializeLastUsedLogin(`idp:${intent.information.idpId}`); + const passkeyHintCookie = await serializePasskeyHint(intent.information.idpUserName); return { kind: 'redirect', location: target, setCookie, lastUsedCookie, + passkeyHintCookie, fingerprintCookie: fingerprintCookie ?? undefined, reauthClearCookie, }; @@ -353,11 +356,13 @@ export async function processIdpCallback( // straight back to /accounts (or /login). Mirrors the password path's hand-back. const target = authorizeHandbackTarget(requestId, session.id); const lastUsedCookie = await serializeLastUsedLogin(`idp:${decision.link.idpId}`); + const passkeyHintCookie = await serializePasskeyHint(loginName); return { kind: 'redirect', location: target, setCookie: await serializeSessions(next), lastUsedCookie, + passkeyHintCookie, fingerprintCookie: fingerprintCookie ?? undefined, reauthClearCookie: reauthClear, }; @@ -436,11 +441,15 @@ export async function processIdpCallback( deviceTrackingToken, }); const lastUsedCookie = await serializeLastUsedLogin(`idp:${decision.link.idpId}`); + const passkeyHintCookie = result.loginName + ? await serializePasskeyHint(result.loginName) + : undefined; return { kind: 'redirect', location: result.target, setCookie: await serializeSessions(result.sessions), lastUsedCookie, + passkeyHintCookie, fingerprintCookie: fingerprintCookie ?? undefined, reauthClearCookie: reauthClear, }; diff --git a/app/resources/sso/sso-outcome.ts b/app/resources/sso/sso-outcome.ts index 367be4c798..f55f5ccd4c 100644 --- a/app/resources/sso/sso-outcome.ts +++ b/app/resources/sso/sso-outcome.ts @@ -17,6 +17,8 @@ export type SsoOutcome = location: string; setCookie?: string; lastUsedCookie?: string; + /** Writes the passkey-hint (usernameless fast path) for the just-signed-in loginName. */ + passkeyHintCookie?: string; // fingerprintId Set-Cookie minted for a browser that lacked it (null/absent on reuse). fingerprintCookie?: string; // Clears the `reauth-intent` marker once a re-auth flow resolves (match or mismatch). @@ -36,12 +38,14 @@ export function outcomeToResponse(outcome: SsoOutcome): Response | ReturnType } { + const bytes = new Uint8Array(32); + crypto.getRandomValues(bytes); + return { + publicKey: { + challenge: Buffer.from(bytes).toString('base64url'), + rpId, + // Empty on purpose — the browser offers EVERY resident key for this RP. + allowCredentials: [], + // The single biometric belongs to the authenticating ceremony (UV 'required' + // on the real Zitadel challenge); the identity tap is selection only. + userVerification: 'discouraged', + timeout: IDENTITY_CHALLENGE_TIMEOUT_MS, + }, + }; +} diff --git a/app/resources/webauthn/webauthn.service.ts b/app/resources/webauthn/webauthn.service.ts index 625b653c6c..760941ebe6 100644 --- a/app/resources/webauthn/webauthn.service.ts +++ b/app/resources/webauthn/webauthn.service.ts @@ -16,8 +16,14 @@ // folder during Pass 1; the barrel (index.ts) re-exports them and the verify // factory so callers/tests reach the whole domain through one specifier. 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 { + byLoginName, + addSession, + sessionEntryFromSession, + serializeSessions, + type SessionEntry, +} from '@/modules/auth/session/cookie'; +import type { Session, User } from '@/modules/auth/types'; import { ProviderError, isStaleSessionError } from '@/modules/auth/types'; import { nextStepFromSession as sharedNextStepFromSession, @@ -27,6 +33,7 @@ import { import { resolveOrg } from '@/resources/shared/resolve-org'; import { isSudoFresh } from '@/resources/shared/sudo'; import { logAuthEvent, hashActor } from '@/server/observability'; +import { getOrCreateFingerprintId, userAgentFromRequest } from '@/server/user-agent'; // ── shared: derive the post-ceremony next step from a session ───────────────── @@ -139,6 +146,84 @@ export async function requestWebAuthnChallenge( return { kind: 'challenge', publicKeyCredentialRequestOptions }; } +// ── USER-BOUND CHALLENGE ARM (usernameless entry points) ────────────────────── + +export interface ArmedUserBoundChallenge { + loginName: string; + publicKeyCredentialRequestOptions: unknown; + /** Set-Cookie values the caller must append: the updated sessions list, plus + * the fingerprint cookie when one was newly minted. */ + setCookies: string[]; +} + +/** + * Mint a Zitadel session bound to `user`, then request a WebAuthn assertion + * challenge on it — the sequence Zitadel's "a challenge requires a bound user" + * constraint (zitadel/zitadel#8899) forces on every usernameless entry point. + * Two callers: the /login loader (passkey-hint fast path) and the + * /login/passkey-discover action (identity-discovery path). + * + * CALLER CONTRACT: call only after verifying no LIVE session exists for + * user.loginName. The same-loginName supersede below is safe precisely because + * that guard ran — see the comment on the filter. Failure split: a + * session-creation failure THROWS (each caller owns the response: clear the + * hint / opaque 400); a challenge-request failure returns null (non-fatal — + * the ordinary page renders, nothing armed, no cookies to set). + */ +export async function armUserBoundChallenge( + provider: AuthProvider, + request: Request, + sessions: SessionEntry[], + user: User, + domain: string +): Promise { + const [fingerprintId, fpCookie] = getOrCreateFingerprintId(request); + const session = await provider.createSession( + {}, + { userId: user.id, userAgent: userAgentFromRequest(request, fingerprintId) } + ); + // Supersede any PRIOR entry for the same loginName before persisting — same motivating + // bug as resolveIdentifier's known-user supersede (login.service.ts:317): a stale, + // cookie-resident duplicate can shadow the fresh ceremony entry in byLoginName's + // mostRecent tie-break, sending the challenge request to a session the provider has + // never heard of. The SCOPE here is narrower than that precedent, deliberately: + // resolveIdentifier keys its supersede on (loginName, organization) because it mints + // one org-tagged entry per call; this function's `user` arrives from an instance-wide, + // org-unscoped lookup (findUser on a bare hint / getUser on a userHandle) and the mint + // below never sets `organization` on the new entry, so there is no org value to key a + // filter by. Scoping this loginName-only rather than by identity tuple is safe because + // the blast radius is bounded to DEAD data: the caller contract requires a live-session + // scan across every organization for this loginName before calling, so every + // same-loginName entry still in `sessions` here — under any organization — is already + // expired. Clearing them can only ever drop stale cookie residue, never a live session. + // Cross-org regression coverage: conditional-passkey-loader.cy.ts ("stale + // cross-organization session entry"). + const priorCleared = sessions.filter((s) => s.loginName !== user.loginName); + const withCeremony = addSession( + priorCleared, + sessionEntryFromSession(session, { loginName: user.loginName }) + ); + const challenge = await requestWebAuthnChallenge( + provider, + withCeremony, + { + userVerificationRequirement: 'required', + challengeAuditEvent: 'mfa_passkey_challenge', + }, + { loginName: user.loginName, domain } + ); + if (challenge.kind !== 'challenge' || !challenge.publicKeyCredentialRequestOptions) { + return null; + } + const setCookies = [await serializeSessions(withCeremony)]; + if (fpCookie) setCookies.push(fpCookie); + return { + loginName: user.loginName, + publicKeyCredentialRequestOptions: challenge.publicKeyCredentialRequestOptions, + setCookies, + }; +} + // ── VERIFY (assertion) action ───────────────────────────────────────────────── export interface WebAuthnVerifyAuditConfig { diff --git a/app/resources/webauthn/webauthn.ts b/app/resources/webauthn/webauthn.ts index 31383d2439..32b01ac3c9 100644 --- a/app/resources/webauthn/webauthn.ts +++ b/app/resources/webauthn/webauthn.ts @@ -77,9 +77,12 @@ export function isWebAuthnSupported(): boolean { return typeof window !== 'undefined' && typeof window.PublicKeyCredential !== 'undefined'; } -/** USE: marshal a server assertion challenge through navigator.credentials.get. Returns plain JSON for the provider. */ +/** USE: marshal a server assertion challenge through navigator.credentials.get. Returns plain + * JSON for the provider. `opts` (conditional-mediation fast path) forwards mediation + an + * AbortSignal to credentials.get — omitted for every pre-existing caller (modal behavior). */ export async function marshalAssertion( - publicKey: WebAuthnChallengeInput + publicKey: WebAuthnChallengeInput, + opts?: { mediation?: CredentialMediationRequirement; signal?: AbortSignal } ): Promise> { if (!isWebAuthnSupported()) throw new WebAuthnUnsupportedError(); const pk = { @@ -101,6 +104,8 @@ export async function marshalAssertion( try { cred = (await navigator.credentials.get({ publicKey: pk as unknown as PublicKeyCredentialRequestOptions, + mediation: opts?.mediation, + signal: opts?.signal, })) as PublicKeyCredential | null; } catch (err) { throw new WebAuthnCeremonyError(classifyWebAuthnError(err)); diff --git a/app/routes.ts b/app/routes.ts index 33211bdaf6..b3073bd1c0 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -17,6 +17,7 @@ export default [ route('password', 'routes/login/password.tsx'), route('mfa', 'routes/login/mfa.tsx'), route('passkey', 'routes/login/passkey.tsx'), + route('passkey-discover', 'routes/login/passkey-discover.tsx'), route('security-key', 'routes/login/security-key.tsx'), route('verify/email', 'routes/login/verify/email.tsx'), route('verify/sms', 'routes/login/verify/sms.tsx'), diff --git a/app/routes/accounts.tsx b/app/routes/accounts.tsx index e34c12f6ea..6f7b62f262 100644 --- a/app/routes/accounts.tsx +++ b/app/routes/accounts.tsx @@ -123,9 +123,12 @@ export function addAccountHref({ organization: string | undefined; userCode: string | null; }): string { + // add=1 marks an EXPLICIT "different account" intent: the /login loader suppresses the + // usernameless fast path so the previously remembered user's passkey is never offered + // to someone who asked to add another account (spec: required change, /accounts §). return userCode - ? paths.login.index({ requestId: `device_${userCode}`, organization }) - : paths.login.index({ requestId: requestId ?? undefined, organization }); + ? paths.login.index({ requestId: `device_${userCode}`, organization, add: '1' }) + : paths.login.index({ requestId: requestId ?? undefined, organization, add: '1' }); } // ─── Component ─────────────────────────────────────────────────────────────── diff --git a/app/routes/login/index.tsx b/app/routes/login/index.tsx index 182e6e7bb0..61c333b630 100644 --- a/app/routes/login/index.tsx +++ b/app/routes/login/index.tsx @@ -6,6 +6,7 @@ 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 { useConditionalPasskey } from '@/hooks/use-conditional-passkey'; import { useLoginContext } from '@/hooks/use-login-context'; import { usePasskeyLoginCeremony } from '@/hooks/use-passkey-login-ceremony'; import SplitLayout from '@/layouts/split.layout'; @@ -13,8 +14,9 @@ import { idpTypeToSlug } from '@/modules/auth/idp-slug'; // ADAPTATION (plan-drift fix): readSessions + serializeSessions live in @/modules/auth/session/cookie. // The locked plan block incorrectly listed them as coming from @/modules/auth/session/session // (that module only has pure helpers, no cookie I/O). -import { readSessions, serializeSessions } from '@/modules/auth/session/cookie'; +import { readSessions, serializeSessions, listSessions } from '@/modules/auth/session/cookie'; import { readLastUsedLogin } from '@/modules/auth/session/last-used-login'; +import { readPasskeyHint, clearPasskeyHint } from '@/modules/auth/session/passkey-hint'; import { readReauthIntent } from '@/modules/auth/session/reauth-intent'; import { shouldBridgeToAuthorize, startIdpIntent, resolveIdentifier } from '@/resources/login'; import { resolveLoginView, resolveIdentifierField } from '@/resources/login/login-view'; @@ -26,10 +28,11 @@ 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 { mintIdentityChallenge } from '@/resources/webauthn/identity-challenge'; +import { armUserBoundChallenge } from '@/resources/webauthn/webauthn.service'; import { paths } from '@/routes/paths'; import { providerForRequest } from '@/server/auth-context.server'; -import { loaderCsrf, assertCsrf, getCsrfToken } from '@/server/csrf'; +import { loaderCsrf, assertCsrf } 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'; @@ -39,7 +42,7 @@ 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 { useEffect, useRef, useState } from 'react'; +import { useEffect, useState } from 'react'; import { data, redirect, @@ -81,6 +84,78 @@ export async function loader({ request }: LoaderFunctionArgs) { readLastUsedLogin(request), ]); const notice = url.searchParams.get('notice') ?? undefined; + + // ── Usernameless fast path: arm a conditional-mediation passkey ceremony ──── + // A hint is an inference; arm ONLY when nothing more specific is known. Explicit + // suppression list (spec, /accounts interaction §): ?add=1 (user asked for a different + // account), hinted user already live (nothing to log in), unresolvable user (clear the + // stale hint), no passkey method. Every suppression — and every mint failure — renders + // the ordinary page; arming is invisible either way. + const responseHeaders = new Headers(headers); + let conditionalPasskey: { + loginName: string; + publicKeyCredentialRequestOptions: unknown; + } | null = null; + let identityDiscovery: { publicKeyCredentialRequestOptions: unknown } | null = null; + const hint = await readPasskeyHint(request); + const isAddAccount = url.searchParams.get('add') === '1'; + if (hint && !isAddAccount) { + const sessions = await readSessions(request); + // LIVE session, not just any cookie entry: raw readSessions() output can carry stale + // (expired) entries, and a stale entry must not suppress the fast path — the spec's + // suppression criterion is a LIVE session. listSessions is the codebase's expiry-aware + // filter (same usage as session.service.ts); unknown expiry counts as live. + const hasLiveSession = listSessions(sessions, Date.now()).some( + (s) => s.loginName.toLowerCase() === hint.toLowerCase() + ); + if (!hasLiveSession) { + const user = await provider.findUser(hint); + if (!user) { + // Deleted/renamed user — the hint can never fire; drop it so we stop re-checking. + responseHeaders.append('set-cookie', await clearPasskeyHint()); + } else if ((await provider.listAuthMethods(user.id)).includes('passkey')) { + try { + // Mirror resolveIdentifier's known-user session mint, then persist the entry so + // the /login/passkey verify action can resolve it by loginName. The loader-side + // Set-Cookie is the accepted side effect (spec, verified-before-building §2). + // `hasLiveSession` above satisfies armUserBoundChallenge's caller contract + // (its same-loginName supersede is only safe against dead entries). + const armed = await armUserBoundChallenge( + provider, + request, + sessions, + user, + url.hostname + ); + if (armed) { + for (const cookie of armed.setCookies) responseHeaders.append('set-cookie', cookie); + conditionalPasskey = { + loginName: armed.loginName, + publicKeyCredentialRequestOptions: armed.publicKeyCredentialRequestOptions, + }; + } + } catch { + // Session creation failed (deactivated user, provider hiccup) — spec error + // matrix: clear the hint, render normally. + responseHeaders.append('set-cookie', await clearPasskeyHint()); + } + } + } + } else if (!hint && !isAddAccount) { + // ── Discovery arm (spec: usernameless discovery design) ────────────────── + // Hintless visitors get a SELF-MINTED challenge: no Zitadel call, nothing + // persisted — the identity tap posts to /login/passkey-discover, which mints + // the real user-bound challenge only after a passkey was actually tapped. + // Suppressed when ANY live session exists (arming is inference; a logged-in + // visitor is better served by the ordinary page — spec, open decision §2). + const sessions = await readSessions(request); + if (listSessions(sessions, Date.now()).length === 0) { + identityDiscovery = { + publicKeyCredentialRequestOptions: mintIdentityChallenge(url.hostname), + }; + } + } + return data( { settings, @@ -90,8 +165,10 @@ export async function loader({ request }: LoaderFunctionArgs) { emailDeliveryEnabled: env.AUTH_EMAIL_DELIVERY_ENABLED, notice, lastUsedLogin, + conditionalPasskey, + identityDiscovery, }, - { headers } + { headers: responseHeaders } ); } @@ -238,40 +315,42 @@ 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 } - ); - } - } + // Sole-passkey: redirect to /login/passkey like every other post-identifier path + // (password, OTP). Pre-A-P10 behavior, reinstated by product ruling (Task 12) — + // /login renders the chooser and nothing else; no route inlines an identity-bound, + // single-method screen except /login/method and /reauth, which are out of scope here. return redirect(`${result.target}?${result.params}`, { headers }); } +// Both in-place ceremonies on this page (the conditional fast path and the gated Passkey +// shortcut) submit assertions to /login/passkey while /login stays mounted. RR's default +// post-submit revalidation would re-run THIS loader, mint a fresh session + challenge for +// the hinted user, and rotate the armed ceremony's challenge out from under the just-signed +// assertion (WEBAU-3M9si class — same guard as /login/passkey:40 and /reauth:201). The +// ceremony hooks tag their submits with passkeyCeremony; anything else revalidates normally. +export function shouldRevalidate({ + formData, + defaultShouldRevalidate, +}: { + formData?: FormData; + defaultShouldRevalidate: boolean; +}) { + if (formData?.get('passkeyCeremony') === '1') return false; + return defaultShouldRevalidate; +} + export default function Login() { - const { csrfToken, idps, settings, branding, emailDeliveryEnabled, notice, lastUsedLogin } = - useLoaderData(); + const { + csrfToken, + idps, + settings, + branding, + emailDeliveryEnabled, + notice, + lastUsedLogin, + conditionalPasskey, + identityDiscovery, + } = useLoaderData(); const { loginName, requestId, organization } = useLoginContext(); const actionData = useActionData(); const navigation = useNavigation(); @@ -284,61 +363,58 @@ 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; + // Identity the passkey ceremony binds to: the URL identifier, else the usernameless + // hint the loader resolved. `||` (not `??`) because useLoginContext yields '' — not + // undefined — when no identifier is threaded. Empty means NO resolvable identity: + // Zitadel cannot mint a challenge for an unbound user, so the button must not start a + // ceremony in that state (it would hang at 'loading-challenge' and disable the whole + // chooser, which is why the old `&& loginName` gate existed). + const passkeyIdentity = loginName || conditionalPasskey?.loginName || ''; const ceremony = usePasskeyLoginCeremony({ - loginName: passkeyInline?.loginName ?? loginName, - requestId: passkeyInline?.requestId ?? requestId, - organization: passkeyInline?.organization ?? organization, + loginName: passkeyIdentity, + requestId, + 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 - ); + // Usernameless fast path — armed by the loader for a hinted returning user (hinted + // mode) or for a hintless fresh browser (discovery mode: identity tap → discover + // round-trip → modal ceremony). Retired by the user's OWN form submission below + // (spec interaction §2's single-flight concern no longer applies: the sole-passkey + // path redirects to /login/passkey — Task 12 — so there is no competing in-place + // ceremony on this page to race against). + const conditional = useConditionalPasskey({ + // AMBIENT arming is hinted-only (spec, decision §4): a fresh-browser auto-prompt + // is intrusive — password managers (1Password) escalate the quiet conditional + // request into a full picker on page load. Discovery stays BUTTON-initiated + // (beginDiscovery below); the loader-armed identityDiscovery options feed it. + enabled: Boolean(conditionalPasskey), + mode: conditionalPasskey ? 'hinted' : 'discovery', + loginName: conditionalPasskey?.loginName ?? '', + csrfToken, + publicKeyCredentialRequestOptions: + conditionalPasskey?.publicKeyCredentialRequestOptions ?? + identityDiscovery?.publicKeyCredentialRequestOptions ?? + null, + requestId, + organization, + }); + const conditionalAbort = conditional.abort; + // ANY form submission (identifier, email-link, IdP button) is the user stating explicit + // intent — retire the armed conditional ceremony (spec error matrix: "user types and + // submits → abort()"). useEffect(() => { - if ( - !passkeyInline || - !showInline || - autoFiredChallenge.current === passkeyInline.publicKeyCredentialRequestOptions - ) { - return; - } - autoFiredChallenge.current = passkeyInline.publicKeyCredentialRequestOptions; - ceremony.beginWith(passkeyInline); - }, [passkeyInline, showInline, ceremony]); + if (navigation.state === 'submitting') conditionalAbort(); + }, [navigation.state, conditionalAbort]); const ceremonyBusy = ceremony.phase === 'loading-challenge' || ceremony.phase === 'ceremony' || - ceremony.phase === 'submitting'; + ceremony.phase === 'submitting' || + // Button-initiated modal discovery in flight (picker open / discover round-trip). + conditional.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. + // identical serverError wiring so the gated Passkey shortcut reports ceremony failures + // the same way. const ceremonyServerError = useAuthActionError(ceremony.actionData); const view = resolveLoginView(settings, idps, emailDeliveryEnabled); @@ -358,13 +434,14 @@ export default function Login() { : 'username'; // Derived separately from identifierLabel: the field label spells out every accepted type // ("Email, phone, or username"), too long for a button. Email wins the both-allowed case — - // it is the dominant identifier and the field states the full set once opened. Separate `t` - // literals so Lingui extracts each string. + // it is the dominant identifier and the field states the full set once opened. Short noun + // labels (rebranding ruling, 2026-07-31): the chooser reads as a method list — "Passkey", + // "Google", "Email" — not as instructions. Separate `t` literals so Lingui extracts each. const identifierButtonLabel = field.allowEmail - ? t`Continue with email` + ? t`Email` : field.allowPhone - ? t`Continue with phone` - : t`Continue with username`; + ? t`Phone` + : t`Username`; const identifierClientSchema = makeLoginIdentifierClientSchema({ rejectPhone: field.rejectPhone, }); @@ -401,182 +478,170 @@ export default function Login() { {errorMessage} - {/* 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. */} + {/* Shared ceremony-error surface — surfaces a FAILURE from the gated Passkey + shortcut below (both drive the same `ceremony`). */} {ceremony.reason ? ( + ) : conditional.reason ? ( + // Button-initiated modal discovery failure (cancel / no usable passkey) — + // the user explicitly acted, so this is a message, not a non-event. + + + ) : ceremonyServerError ? ( {ceremonyServerError} ) : null} - {showInline && passkeyInline ? ( -
-

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

+ <> + {view.showIdpButtons ? ( + + ) : null} + + {view.showPasskeyPrompt ? ( -
- ) : ( - <> - {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 ? ( - - ) : null} + ) : null} - {view.showIdentifierForm && view.showIdpButtons ? : null} + {view.showIdentifierForm && view.showIdpButtons ? : null} - {view.showIdentifierForm ? ( - <> - {!showEmailField ? ( - - ) : ( - - + {!showEmailField ? ( + + ) : ( + + + + {/* `username webauthn` (not plain `username`): the webauthn token is the + conditional-mediation autofill anchor — the browser attaches the + passkey suggestion to this field (usernameless discovery spec). */} + - - - - {/* Continue hands off to decideAfterIdentifier. With neither password nor - passkey that resolves to NO_SUPPORTED_METHOD, so it is hidden rather than - left to dead-end. Dropping it also makes the email-link button the first - submit button, so Enter in the field triggers email-link. */} - {view.showContinue ? ( - - Continue - - ) : null} - {view.showEmailLink ? ( - view.showContinue ? ( - + ) : ( + // Sole action — render as the primary button, not a secondary link. The + // intent rides on a hidden field rather than the button's name/value: + // SubmitButton does not forward those, and with no Continue button this + // form has exactly one submission meaning anyway. + <> + + Email me a sign-in link - - ) : ( - // Sole action — render as the primary button, not a secondary link. The - // intent rides on a hidden field rather than the button's name/value: - // SubmitButton does not forward those, and with no Continue button this - // form has exactly one submission meaning anyway. - <> - - - Email me a sign-in link - - - ) - ) : null} - - )} - - ) : null} + + + ) + ) : null} + + )} + + ) : null} - {view.signInUnavailable ? ( -

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

- ) : null} + {view.signInUnavailable ? ( +

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

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

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

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

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

+ + ) : null} + ); } diff --git a/app/routes/login/passkey-discover.tsx b/app/routes/login/passkey-discover.tsx new file mode 100644 index 0000000000..d2867d70ec --- /dev/null +++ b/app/routes/login/passkey-discover.tsx @@ -0,0 +1,108 @@ +// app/routes/login/passkey-discover.tsx +// +// Resource route (action only): the identity-resolution step of the usernameless +// discovery path (spec: 2026-07-31-usernameless-passkey-discovery-design.md). +// The posted assertion is an UNTRUSTED identity claim — its signature is never +// checked; only response.userHandle (== Zitadel userId, probe-verified) is read. +// Every user-dependent failure collapses into ONE opaque 400 so this endpoint +// leaks exactly what the identifier form leaks (enumeration parity). The +// authenticating ceremony is the SECOND assertion, verified by Zitadel through +// the unchanged /login/passkey action. +// RESPONSE SHAPE: plain Response.json (NOT data()) — the client calls this action +// with a direct fetch(), not an RR fetcher, so the body must be raw JSON rather +// than the single-fetch envelope. See useConditionalPasskey.submitDiscover for why +// (fetcher lazy route discovery reloads the page mid-ceremony when the client +// module load hiccups; a pure JSON API hop needs none of that machinery). +import { readSessions, listSessions } from '@/modules/auth/session/cookie'; +import { armUserBoundChallenge } from '@/resources/webauthn/webauthn.service'; +import { providerForRequest } from '@/server/auth-context.server'; +import { getCsrfToken, assertCsrf } from '@/server/csrf'; +import { type ActionFunctionArgs } from 'react-router'; +import { z } from 'zod'; + +const discoverSchema = z.object({ credential: z.string().min(1) }); + +export interface PasskeyDiscoverData { + loginName: string; + csrfToken: string; + publicKeyCredentialRequestOptions: unknown; +} + +export type PasskeyDiscoverError = { error: 'INVALID_INPUT' | 'DISCOVERY_FAILED' }; + +// Sanity bounds only — a userHandle is at most 64 bytes by WebAuthn spec; the +// base64url of that is under 128 chars. Anything outside is a shape violation. +const MAX_USER_HANDLE_B64 = 128; +const MAX_USER_HANDLE_BYTES = 64; + +/** Read the assertion's userHandle (base64url → utf8 Zitadel userId). Null on any shape violation. */ +function decodeUserHandle(credentialJson: string): string | null { + try { + const cred = JSON.parse(credentialJson) as { response?: { userHandle?: unknown } }; + const raw = cred.response?.userHandle; + if (typeof raw !== 'string' || raw.length === 0 || raw.length > MAX_USER_HANDLE_B64) { + return null; + } + const decoded = Buffer.from(raw, 'base64url').toString('utf8'); + return decoded.length > 0 && decoded.length <= MAX_USER_HANDLE_BYTES ? decoded : null; + } catch { + return null; + } +} + +export async function action({ request }: ActionFunctionArgs) { + const provider = providerForRequest(request); + const form = await request.formData(); + await assertCsrf(request, form); + + const parsed = discoverSchema.safeParse(Object.fromEntries(form)); + if (!parsed.success) return Response.json({ error: 'INVALID_INPUT' }, { status: 400 }); + + // ONE opaque failure for everything user-dependent — "no such user", "no passkey + // method", "mint failed" and shape violations must be indistinguishable. + const opaque = () => Response.json({ error: 'DISCOVERY_FAILED' }, { status: 400 }); + + const userHandle = decodeUserHandle(parsed.data.credential); + if (!userHandle) return opaque(); // non-resident key / malformed — client treats as non-event + + const user = await provider.getUser(userHandle); + if (!user) return opaque(); + if (!(await provider.listAuthMethods(user.id)).includes('passkey')) return opaque(); + + const sessions = await readSessions(request); + // armUserBoundChallenge caller contract + crafted-POST guard: the loader suppresses + // discovery whenever a live session exists, so a live entry here means the POST + // bypassed the page. Refuse rather than let the arm supersede a LIVE cookie entry. + const hasLiveSession = listSessions(sessions, Date.now()).some( + (s) => s.loginName.toLowerCase() === user.loginName.toLowerCase() + ); + if (hasLiveSession) return opaque(); + + let armed; + try { + armed = await armUserBoundChallenge( + provider, + request, + sessions, + user, + new URL(request.url).hostname + ); + } catch { + return opaque(); // deactivated user / provider hiccup — enumeration parity + } + if (!armed) return opaque(); + + const [csrfToken, csrfSetCookie] = await getCsrfToken(request); + const headers = new Headers(); + for (const cookie of armed.setCookies) headers.append('set-cookie', cookie); + if (csrfSetCookie) headers.append('set-cookie', csrfSetCookie); + // Deliberately NO passkey-hint write: the hint means "last successfully + // AUTHENTICATED user", and the /login/passkey verify action writes it on + // success — discovery only identifies (spec, design decisions). + const payload: PasskeyDiscoverData = { + loginName: armed.loginName, + csrfToken, + publicKeyCredentialRequestOptions: armed.publicKeyCredentialRequestOptions, + }; + return Response.json(payload, { headers }); +} diff --git a/app/routes/login/passkey.tsx b/app/routes/login/passkey.tsx index 8a97beb4c3..b26ebafc6c 100644 --- a/app/routes/login/passkey.tsx +++ b/app/routes/login/passkey.tsx @@ -3,6 +3,7 @@ import { AuthFormFields } from '@/components/auth-form/auth-form-fields'; import { WebAuthnButton } from '@/components/webauthn-button/webauthn-button'; import { useAuthActionError } from '@/hooks/use-auth-action-error'; import { serializeLastUsedLogin } from '@/modules/auth/session/last-used-login'; +import { serializePasskeyHint } from '@/modules/auth/session/passkey-hint'; import { createWebAuthnVerifyHandlers, type WebAuthnVerifyActionData, @@ -48,10 +49,13 @@ export function shouldRevalidate({ 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(). +// Wrap the factory action to append the last-used-login + passkey-hint cookies 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(). loginName is read +// from a CLONE of the request BEFORE the factory action consumes the body. export async function action(args: ActionFunctionArgs) { + const form = await args.request.clone().formData(); + const loginName = String(form.get('loginName') ?? ''); const result = await _handlers.action(args); // Only decorate successful redirects (3xx with a Location header). if (!(result instanceof Response) || !result.headers.get('location')) { @@ -59,6 +63,7 @@ export async function action(args: ActionFunctionArgs) { } const headers = new Headers(result.headers); headers.append('set-cookie', await serializeLastUsedLogin('passkey')); + if (loginName) headers.append('set-cookie', await serializePasskeyHint(loginName)); return new Response(result.body, { status: result.status, statusText: result.statusText, @@ -95,7 +100,7 @@ export default function LoginPasskey() { loginName={loginName} requestId={requestId} organization={organization} - showBackLink={false}> + showBackLink={true}> {/* Hidden form that WebAuthnButton populates and submits. */} withQuery('/login/password', q), mfa: (q?: Query) => withQuery('/login/mfa', q), passkey: (q?: Query) => withQuery('/login/passkey', q), + passkeyDiscover: (q?: Query) => withQuery('/login/passkey-discover', q), securityKey: (q?: Query) => withQuery('/login/security-key', q), verify: { email: (q?: Query) => withQuery('/login/verify/email', q), diff --git a/app/routes/signup/complete.tsx b/app/routes/signup/complete.tsx index 22c9b308dc..b09154e4d6 100644 --- a/app/routes/signup/complete.tsx +++ b/app/routes/signup/complete.tsx @@ -11,6 +11,7 @@ import { AuthCard } from '@/components/auth-card/auth-card'; import { readSessions, serializeSessions } from '@/modules/auth/session/cookie'; import { serializeLastUsedLogin } from '@/modules/auth/session/last-used-login'; +import { serializePasskeyHint } from '@/modules/auth/session/passkey-hint'; import { ProviderError } from '@/modules/auth/types'; import { completeEmailLinkSignup } from '@/resources/signup'; import { paths } from '@/routes/paths'; @@ -76,6 +77,7 @@ export async function loader({ request }: LoaderFunctionArgs) { const headers = new Headers(); headers.append('set-cookie', await serializeSessions(result.sessions)); headers.append('set-cookie', await serializeLastUsedLogin('email')); + headers.append('set-cookie', await serializePasskeyHint(user.loginName)); if (fpCookie) headers.append('set-cookie', fpCookie); return redirect(result.target, { headers }); } catch (err) { diff --git a/app/server/middleware/rate-limit.ts b/app/server/middleware/rate-limit.ts index 0cc517ec16..84e9517ea8 100644 --- a/app/server/middleware/rate-limit.ts +++ b/app/server/middleware/rate-limit.ts @@ -187,7 +187,10 @@ const webauthnVerifyLimiter = new RateLimiter({ limit: 10, windowMs: 5 * 60_000 // Self-guards on POST + exact normalized paths to avoid double-limiting: // - loginPasswordRateLimit already covers /id/login/password // - mfaVerifyRateLimit already covers /id/login/verify/* -// This middleware only counts its three exact paths, so no overlap occurs. +// This middleware only counts its four exact paths, so no overlap occurs. +// /id/login/passkey-discover shares this budget deliberately: it is the same +// endpoint class (assertion-adjacent POST, enumeration-parity 400s) and a real +// discovery login costs 1 discover + 1 verify — well inside 10/5min. // BODY-STREAM HAZARD: assertion payloads are in the POST body — key is ip-only. // mfaVerifyRateLimit keying decision (2026-06-12): ip-only stays. Body-stream hazard // prevents reading loginName without consuming the stream; the per-account lockout @@ -197,6 +200,7 @@ export const webauthnVerifyRateLimit: MiddlewareHandler = createRateLimit({ match: (c, pathname) => c.req.method === 'POST' && (pathname === '/id/login/passkey' || + pathname === '/id/login/passkey-discover' || pathname === '/id/login/security-key' || pathname === '/id/login/mfa'), key: (_c, ip) => ip, diff --git a/package.json b/package.json index 2a9e446197..d2602aeba8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "auth-ui", - "version": "0.0.1", + "version": "0.1.0", "private": true, "license": "MIT", "type": "module", From cf597303cbfec8a420490f89f4250d6b2c59b431 Mon Sep 17 00:00:00 2001 From: Yahya Fakhroji Date: Fri, 31 Jul 2026 11:05:01 +0700 Subject: [PATCH 2/4] =?UTF-8?q?test:=20usernameless=20passkey=20coverage?= =?UTF-8?q?=20=E2=80=94=20component=20matrix=20+=20e2e=20journeys?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Hint lifecycle (write on every success path, clear on logout, add=1 suppression), loader arming + suppression lists (hinted and discovery), discover action matrix (userHandle resolution, opaque-400 parity, crafted-POST live-session guard), hook modes incl. beginDiscovery, button binding, #107 view-logic grafts - E2E: hinted one-tap return journey; fresh-browser button discovery with hint self-upgrade; harness passkeyDiscoverAction dispatch + JSON body capture --- .../components/back-link/previous-step.cy.ts | 2 +- .../use-conditional-passkey-discovery.cy.tsx | 180 ++++++++++++++++ .../hooks/use-conditional-passkey.cy.tsx | 138 +++++++++++++ .../modules/auth/session/passkey-hint.cy.ts | 48 +++++ .../resources/sso/sso-callback.cy.ts | 29 +++ .../routes/accounts-loader-action.cy.ts | 21 +- .../routes/accounts-passkey-hint.cy.ts | 43 ++++ cypress/component/routes/accounts-row.cy.tsx | 8 +- .../login/conditional-passkey-loader.cy.ts | 133 ++++++++++++ .../routes/login/discovery-loader.cy.ts | 96 +++++++++ cypress/component/routes/login/index.cy.tsx | 161 ++++++--------- .../routes/login/passkey-back-link.cy.tsx | 37 +++- .../login/passkey-button-visibility.cy.tsx | 193 ++++++++++++++++++ .../routes/login/passkey-discover.cy.ts | 142 +++++++++++++ .../routes/login/passkey-hint-write.cy.ts | 66 ++++++ .../routes/logout/passkey-hint-clear.cy.ts | 63 ++++++ .../component/routes/signup/complete.cy.ts | 1 + cypress/e2e/core-signin.cy.ts | 6 +- cypress/e2e/login-hydrated-submit.cy.ts | 2 +- cypress/e2e/passkey-conditional.cy.ts | 114 +++++++++++ cypress/e2e/passkey-discovery.cy.ts | 88 ++++++++ cypress/e2e/passkeys-manage.cy.ts | 2 +- cypress/e2e/verify-otp.cy.ts | 5 +- cypress/support/node/harness.ts | 105 +++++++++- cypress/support/node/scenario.ts | 19 ++ 25 files changed, 1578 insertions(+), 124 deletions(-) create mode 100644 cypress/component/hooks/use-conditional-passkey-discovery.cy.tsx create mode 100644 cypress/component/hooks/use-conditional-passkey.cy.tsx create mode 100644 cypress/component/modules/auth/session/passkey-hint.cy.ts create mode 100644 cypress/component/routes/accounts-passkey-hint.cy.ts create mode 100644 cypress/component/routes/login/conditional-passkey-loader.cy.ts create mode 100644 cypress/component/routes/login/discovery-loader.cy.ts create mode 100644 cypress/component/routes/login/passkey-button-visibility.cy.tsx create mode 100644 cypress/component/routes/login/passkey-discover.cy.ts create mode 100644 cypress/component/routes/login/passkey-hint-write.cy.ts create mode 100644 cypress/component/routes/logout/passkey-hint-clear.cy.ts create mode 100644 cypress/e2e/passkey-conditional.cy.ts create mode 100644 cypress/e2e/passkey-discovery.cy.ts diff --git a/cypress/component/components/back-link/previous-step.cy.ts b/cypress/component/components/back-link/previous-step.cy.ts index 5fe9e983d4..4ba7b6392e 100644 --- a/cypress/component/components/back-link/previous-step.cy.ts +++ b/cypress/component/components/back-link/previous-step.cy.ts @@ -21,7 +21,6 @@ describe('previousStepFor', () => { // Terminal/headless steps have no predecessor. expect(previousStepFor('/login')).to.be.null; 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)', () => { @@ -32,6 +31,7 @@ describe('previousStepFor', () => { 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/passkey')).to.equal('/login'); expect(previousStepFor('/login/security-key')).to.equal('/login'); }); diff --git a/cypress/component/hooks/use-conditional-passkey-discovery.cy.tsx b/cypress/component/hooks/use-conditional-passkey-discovery.cy.tsx new file mode 100644 index 0000000000..91f1ed9377 --- /dev/null +++ b/cypress/component/hooks/use-conditional-passkey-discovery.cy.tsx @@ -0,0 +1,180 @@ +// cypress/component/hooks/use-conditional-passkey-discovery.cy.tsx +// +// The DISCOVERY mode of the conditional ceremony driver (spec: +// 2026-07-31-usernameless-passkey-discovery-design.md): assertion #1 (identity +// claim) posts to /login/passkey-discover via PLAIN fetch (not an RR fetcher — +// see submitDiscover's comment); the response carries the REAL user-bound +// challenge + resolved loginName + fresh csrf, over which the modal ceremony runs +// and submits to the verify action. Failures are non-events and discovery NEVER +// retries (cross-device QR must not force a second round-trip). The discover hop +// is stubbed with cy.intercept (it is a raw fetch); the verify action stays a +// memory-router stub. Sibling of use-conditional-passkey.cy.tsx (hinted mode). +import { useConditionalPasskey } from '@/hooks/use-conditional-passkey'; +import { mount } from 'cypress/react'; +import React from 'react'; +import { createMemoryRouter, redirect, RouterProvider } from 'react-router'; + +const IDENTITY_OPTIONS = { publicKey: { challenge: 'identity-x', allowCredentials: [] } }; +const REAL_OPTIONS = { publicKey: { challenge: 'zitadel-x' } }; +const RESOLVED = 'mia@acme.test'; + +type W = Window & { + __conditionalPasskeyAutoResolve?: boolean; + __webAuthnRealCeremony?: boolean; +}; + +function Harness({ enabled, options }: { enabled: boolean; options: unknown }) { + const cond = useConditionalPasskey({ + enabled, + mode: 'discovery', + loginName: '', // unknown until the discover response resolves it + csrfToken: 'tok-page', + publicKeyCredentialRequestOptions: options, + }); + return ( +
+
{cond.phase}
+
{cond.reason ?? ''}
+ + +
+ ); +} + +function mountHarness({ + enabled = true, + options = IDENTITY_OPTIONS as unknown, + discoverReply = undefined as { statusCode: number; body: unknown } | undefined, + verifyResult = undefined as unknown, // undefined → redirect('/signed-in') +} = {}) { + const discoverPosts: string[] = []; + const verifyPosts: Array> = []; + cy.intercept('POST', '**/id/login/passkey-discover', (req) => { + discoverPosts.push(String(req.body)); + req.reply( + discoverReply ?? { + statusCode: 200, + body: { + loginName: RESOLVED, + csrfToken: 'tok-discover', + publicKeyCredentialRequestOptions: REAL_OPTIONS, + }, + } + ); + }).as('discover'); + const router = createMemoryRouter( + [ + { path: '/login', element: }, + { path: '/signed-in', element:
}, + { + path: '/login/passkey', + action: async ({ request }: { request: Request }) => { + verifyPosts.push(Object.fromEntries(await request.formData())); + return verifyResult ?? redirect('/signed-in'); + }, + }, + ], + { initialEntries: ['/login'] } + ); + mount(); + return { discoverPosts, verifyPosts }; +} + +describe('useConditionalPasskey — discovery mode', () => { + afterEach(() => { + delete (window as W).__conditionalPasskeyAutoResolve; + delete (window as W).__webAuthnRealCeremony; + }); + + it('full flow: identity tap → discover fetch → modal over the REAL challenge → verify, redirect followed', () => { + (window as W).__conditionalPasskeyAutoResolve = true; + const { discoverPosts, verifyPosts } = mountHarness({}); + cy.get('[data-testid="signed-in"]').should('exist'); + cy.then(() => { + expect(discoverPosts).to.have.length(1); + // Assertion #1 carries the PAGE csrf; NO RR marker needed — the plain fetch + // bypasses React Router, so no revalidation exists to suppress. + const p = new URLSearchParams(discoverPosts[0]); + expect(p.get('csrf')).to.equal('tok-page'); + expect(p.get('passkeyCeremony')).to.equal(null); + expect(String(p.get('credential'))).to.contain('"id"'); + // Assertion #2 uses the DISCOVER response's csrf + resolved loginName. + expect(verifyPosts).to.have.length(1); + expect(verifyPosts[0].csrf).to.equal('tok-discover'); + expect(verifyPosts[0].loginName).to.equal(RESOLVED); + expect(verifyPosts[0].passkeyCeremony).to.equal('1'); + }); + }); + + it('opaque discover 400 → phase done, NO verify POST (non-event)', () => { + (window as W).__conditionalPasskeyAutoResolve = true; + const { verifyPosts } = mountHarness({ + discoverReply: { statusCode: 400, body: { error: 'DISCOVERY_FAILED' } }, + }); + cy.get('[data-testid="phase"]').should('have.text', 'done'); + cy.then(() => expect(verifyPosts).to.have.length(0)); + }); + + it('rejected verify → phase done after ONE attempt — discovery never retries', () => { + (window as W).__conditionalPasskeyAutoResolve = true; + const { verifyPosts } = mountHarness({ + verifyResult: { error: 'INVALID_CREDENTIALS' }, + }); + cy.get('[data-testid="phase"]').should('have.text', 'done'); + cy.then(() => expect(verifyPosts).to.have.length(1)); + }); + + it('without auto-resolve the ceremony parks in armed — no discover POST', () => { + const { discoverPosts } = mountHarness({}); + cy.get('[data-testid="phase"]').should('have.text', 'armed'); + cy.then(() => expect(discoverPosts).to.have.length(0)); + }); + + it('abort() retires discovery permanently', () => { + const { discoverPosts, verifyPosts } = mountHarness({}); + cy.get('[data-testid="phase"]').should('have.text', 'armed'); + cy.get('[data-testid="abort"]').click(); + cy.get('[data-testid="phase"]').should('have.text', 'done'); + cy.then(() => { + expect(discoverPosts).to.have.length(0); + expect(verifyPosts).to.have.length(0); + }); + }); + + // ── beginDiscovery: the EXPLICIT (Passkey-button) modal flow — spec, decision §3 ── + + it('beginDiscovery runs the modal flow: discover → verify → redirect (no auto-resolve needed)', () => { + const { discoverPosts, verifyPosts } = mountHarness({}); + cy.get('[data-testid="phase"]').should('have.text', 'armed'); // ambient parks + cy.get('[data-testid="begin"]').click(); + cy.get('[data-testid="signed-in"]').should('exist'); + cy.then(() => { + expect(discoverPosts).to.have.length(1); + expect(verifyPosts).to.have.length(1); + expect(verifyPosts[0].loginName).to.equal(RESOLVED); + }); + }); + + it('beginDiscovery works AFTER abort() — explicit intent clears the cancel latch', () => { + const { verifyPosts } = mountHarness({}); + cy.get('[data-testid="abort"]').click(); + cy.get('[data-testid="phase"]').should('have.text', 'done'); + cy.get('[data-testid="begin"]').click(); + cy.get('[data-testid="signed-in"]').should('exist'); + cy.then(() => expect(verifyPosts).to.have.length(1)); + }); + + it('opaque 400 during beginDiscovery surfaces a reason (message, not silence)', () => { + const { verifyPosts } = mountHarness({ + discoverReply: { statusCode: 400, body: { error: 'DISCOVERY_FAILED' } }, + }); + cy.get('[data-testid="begin"]').click(); + cy.get('[data-testid="phase"]').should('have.text', 'done'); + cy.get('[data-testid="reason"]').should('have.text', 'not-allowed'); + cy.then(() => expect(verifyPosts).to.have.length(0)); + }); +}); diff --git a/cypress/component/hooks/use-conditional-passkey.cy.tsx b/cypress/component/hooks/use-conditional-passkey.cy.tsx new file mode 100644 index 0000000000..f2b5403ed9 --- /dev/null +++ b/cypress/component/hooks/use-conditional-passkey.cy.tsx @@ -0,0 +1,138 @@ +// cypress/component/hooks/use-conditional-passkey.cy.tsx +// +// The conditional ceremony driver in isolation: arming gates, the Cypress auto-resolve +// seam, one-shot abort, and the retry-once contract. Mounted inside a memory router with +// a stubbed /login/passkey (same convention as routes/login/method.cy.tsx): the stub +// loader hands out challenges, the stub action captures ceremony POSTs. A SUCCESSFUL +// verify is a redirect (which the fetcher follows); returned DATA means rejection. +import { useConditionalPasskey } from '@/hooks/use-conditional-passkey'; +import { mount } from 'cypress/react'; +import React from 'react'; +import { createMemoryRouter, redirect, RouterProvider } from 'react-router'; + +const OPTIONS = { publicKey: { challenge: 'x' } }; +type W = Window & { + __conditionalPasskeyAutoResolve?: boolean; + __webAuthnRealCeremony?: boolean; +}; + +function Harness({ enabled, options }: { enabled: boolean; options: unknown }) { + const cond = useConditionalPasskey({ + enabled, + loginName: 'mia@acme.test', + csrfToken: 'tok-1', + publicKeyCredentialRequestOptions: options, + }); + return ( +
+
{cond.phase}
+ +
+ ); +} + +function mountHarness({ + enabled = true, + options = OPTIONS as unknown, + actionResult = undefined as unknown, // undefined → redirect('/signed-in') (success) +} = {}) { + const capturedPosts: Array> = []; + const router = createMemoryRouter( + [ + { path: '/login', element: }, + { path: '/signed-in', element:
}, + { + path: '/login/passkey', + loader: async () => ({ + csrfToken: 'tok-2', + loginName: 'mia@acme.test', + requestId: undefined, + organization: undefined, + publicKeyCredentialRequestOptions: OPTIONS, + }), + action: async ({ request }: { request: Request }) => { + capturedPosts.push(Object.fromEntries(await request.formData())); + return actionResult ?? redirect('/signed-in'); + }, + }, + ], + { initialEntries: ['/login'] } + ); + mount(); + return capturedPosts; +} + +describe('useConditionalPasskey', () => { + afterEach(() => { + delete (window as W).__conditionalPasskeyAutoResolve; + delete (window as W).__webAuthnRealCeremony; + }); + + it('auto-resolve seam: submits the pre-baked credential with the ceremony marker, follows the redirect', () => { + (window as W).__conditionalPasskeyAutoResolve = true; + const posts = mountHarness({}); + cy.get('[data-testid="signed-in"]').should('exist'); + cy.then(() => { + expect(posts).to.have.length(1); + expect(posts[0].loginName).to.equal('mia@acme.test'); + expect(posts[0].passkeyCeremony).to.equal('1'); + expect(posts[0].csrf).to.equal('tok-1'); + expect(String(posts[0].credential)).to.contain('"id"'); + }); + }); + + it('without auto-resolve the ceremony parks in armed — no POST', () => { + const posts = mountHarness({}); + cy.get('[data-testid="phase"]').should('have.text', 'armed'); + cy.then(() => expect(posts).to.have.length(0)); + }); + + it('enabled=false stays fully inert', () => { + (window as W).__conditionalPasskeyAutoResolve = true; + const posts = mountHarness({ enabled: false }); + cy.get('[data-testid="phase"]').should('have.text', 'idle'); + cy.then(() => expect(posts).to.have.length(0)); + }); + + it('null options stay fully inert', () => { + (window as W).__conditionalPasskeyAutoResolve = true; + const posts = mountHarness({ options: null }); + cy.get('[data-testid="phase"]').should('have.text', 'idle'); + cy.then(() => expect(posts).to.have.length(0)); + }); + + it('abort() retires the ceremony permanently', () => { + const posts = mountHarness({}); + cy.get('[data-testid="phase"]').should('have.text', 'armed'); + cy.get('[data-testid="abort"]').click(); + cy.get('[data-testid="phase"]').should('have.text', 'done'); + cy.then(() => expect(posts).to.have.length(0)); + }); + + it('a rejected assertion re-fetches and re-arms exactly ONCE, then stops silently', () => { + (window as W).__conditionalPasskeyAutoResolve = true; + const posts = mountHarness({ actionResult: { error: 'INVALID_CREDENTIALS' } }); + cy.get('[data-testid="phase"]').should('have.text', 'done'); + cy.then(() => { + expect(posts).to.have.length(2); + expect(posts[1].csrf).to.equal('tok-2'); // retry uses the re-fetched challenge's token + }); + }); + + it('real path skips arming when conditional mediation is unavailable', () => { + (window as W).__webAuthnRealCeremony = true; // force the REAL branch under Cypress + const pkc = window.PublicKeyCredential as unknown as { + isConditionalMediationAvailable?: () => Promise; + }; + const original = pkc.isConditionalMediationAvailable; + pkc.isConditionalMediationAvailable = () => Promise.resolve(false); + const posts = mountHarness({}); + cy.get('[data-testid="phase"]').should('have.text', 'idle'); + cy.then(() => { + expect(posts).to.have.length(0); + pkc.isConditionalMediationAvailable = original; + }); + }); +}); diff --git a/cypress/component/modules/auth/session/passkey-hint.cy.ts b/cypress/component/modules/auth/session/passkey-hint.cy.ts new file mode 100644 index 0000000000..3d0262b164 --- /dev/null +++ b/cypress/component/modules/auth/session/passkey-hint.cy.ts @@ -0,0 +1,48 @@ +// cypress/component/modules/auth/session/passkey-hint.cy.ts +// +// Node-bound (cy.task) spec — passkey-hint.ts is signed with SESSION_SECRET (env.server is +// stubbed out of the browser bundle), so the REAL serialize→parse round-trip runs in Bun. +import { callService } from '../../../../support/node/call-service'; + +describe('passkey-hint cookie', () => { + it('round-trips a loginName through serialize → parse, and returns null when absent', () => { + callService({ + fn: 'passkeyHintCheck', + passkeyHintOp: 'roundTrip', + request: { url: 'http://localhost/id' }, + }) + .then((v) => { + expect((v.outcome as { parsed: string }).parsed).to.equal('alice@acme.test'); + return callService({ + fn: 'passkeyHintCheck', + passkeyHintOp: 'absent', + request: { url: 'http://localhost/id' }, + }); + }) + .then((v) => { + expect((v.outcome as { parsed: string | null }).parsed).to.be.null; + }); + }); + + it('clear expires immediately; attrs pin Path=/id, HttpOnly, and the 7-day Max-Age', () => { + callService({ + fn: 'passkeyHintCheck', + passkeyHintOp: 'clear', + request: { url: 'http://localhost/id' }, + }) + .then((v) => { + expect((v.outcome as { setCookie: string }).setCookie).to.include('Max-Age=0'); + return callService({ + fn: 'passkeyHintCheck', + passkeyHintOp: 'attrs', + request: { url: 'http://localhost/id' }, + }); + }) + .then((v) => { + const sc = (v.outcome as { setCookie: string }).setCookie; + expect(sc).to.include('Path=/id'); + expect(sc).to.include('HttpOnly'); + expect(sc).to.include('Max-Age=604800'); + }); + }); +}); diff --git a/cypress/component/resources/sso/sso-callback.cy.ts b/cypress/component/resources/sso/sso-callback.cy.ts index 50beba0f8d..56fcd8bb9e 100644 --- a/cypress/component/resources/sso/sso-callback.cy.ts +++ b/cypress/component/resources/sso/sso-callback.cy.ts @@ -353,3 +353,32 @@ describe('processIdpCallback — fresh-identity link ceremony (Req 2)', () => { }); }); }); + +describe('processIdpCallback — passkey-hint write', () => { + it('auto-link sign-in writes passkey-hint = the IdP-vouched loginName', () => { + callService({ + fn: 'processIdpCallback', + slug: 'google', + env: { ALLOW_IDP_AUTO_LINK: 'true' }, + seed: { users: [{ id: 'u1', loginName: 'you@gmail.com', displayName: 'You User' }] }, + idpIntent: REGISTER_INTENT_VERIFIED, + request: { url: CB() }, + }).then((v) => { + expect(v.response?.status).to.equal(302); + expect(v.response?.passkeyHint).to.equal('you@gmail.com'); + }); + }); + + it('auto-create writes passkey-hint = the freshly created loginName', () => { + callService({ + fn: 'processIdpCallback', + slug: 'google', + seed: { users: [] }, // no existing account → auto-create path + idpIntent: REGISTER_INTENT_VERIFIED, + request: { url: CB() }, + }).then((v) => { + expect(v.response?.status).to.equal(302); + expect(v.response?.passkeyHint).to.equal('you@gmail.com'); + }); + }); +}); diff --git a/cypress/component/routes/accounts-loader-action.cy.ts b/cypress/component/routes/accounts-loader-action.cy.ts index 7b34e7c796..8332c2cfe8 100644 --- a/cypress/component/routes/accounts-loader-action.cy.ts +++ b/cypress/component/routes/accounts-loader-action.cy.ts @@ -72,7 +72,7 @@ describe('accounts loader', () => { request: { url: `${BASE}?authRequest=abc` }, }).then((v) => { expect(v.response!.isResponse).to.be.true; - expect(v.response!.location).to.equal('/login?requestId=oidc_abc'); + expect(v.response!.location).to.equal('/login?requestId=oidc_abc&add=1'); }); }); @@ -82,7 +82,7 @@ describe('accounts loader', () => { request: { url: `${BASE}?samlRequest=xyz` }, }).then((v) => { expect(v.response!.isResponse).to.be.true; - expect(v.response!.location).to.equal('/login?requestId=saml_xyz'); + expect(v.response!.location).to.equal('/login?requestId=saml_xyz&add=1'); }); }); @@ -91,7 +91,7 @@ describe('accounts loader', () => { fn: 'accountsLoader', request: { url: `${BASE}?requestId=oidc_threaded&authRequest=raw` }, }).then((v) => { - expect(v.response!.location).to.equal('/login?requestId=oidc_threaded'); + expect(v.response!.location).to.equal('/login?requestId=oidc_threaded&add=1'); }); }); @@ -102,7 +102,7 @@ describe('accounts loader', () => { }).then((v) => { expect(v.response!.isResponse).to.be.true; expect(v.response!.status).to.equal(302); - expect(v.response!.location).to.equal('/login?requestId=oidc_abc&organization=org-1'); + expect(v.response!.location).to.equal('/login?requestId=oidc_abc&organization=org-1&add=1'); }); }); @@ -112,7 +112,7 @@ describe('accounts loader', () => { request: { url: `${BASE}?user_code=WDJB-MJHT` }, }).then((v) => { expect(v.response!.isResponse).to.be.true; - expect(v.response!.location).to.equal('/login?requestId=device_WDJB-MJHT'); + expect(v.response!.location).to.equal('/login?requestId=device_WDJB-MJHT&add=1'); }); }); @@ -141,6 +141,17 @@ describe('accounts loader', () => { expect(v.response!.isResponse).to.be.false; }); }); + + it('empty-picker mid-ceremony redirect carries the add=1 suppression marker', () => { + callService({ + fn: 'accountsLoader', + request: { url: 'http://localhost/id/accounts?requestId=oidc_x' }, + }).then((v) => { + expect(v.response?.status).to.equal(302); + expect(v.response?.location ?? '').to.include('add=1'); + expect(v.response?.location ?? '').to.include('requestId=oidc_x'); + }); + }); }); describe('accounts action', () => { diff --git a/cypress/component/routes/accounts-passkey-hint.cy.ts b/cypress/component/routes/accounts-passkey-hint.cy.ts new file mode 100644 index 0000000000..051d33b367 --- /dev/null +++ b/cypress/component/routes/accounts-passkey-hint.cy.ts @@ -0,0 +1,43 @@ +// Switch = "this account is now the browser's active identity" → hint refresh. +// Dead-session switch (reauthRedirect) must NOT rewrite it. +import { callService } from '../../support/node/call-service'; + +const ALICE = 'alice@acme.test'; + +describe('/accounts switch — passkey-hint refresh', () => { + it('a successful switch rewrites the hint to the switched-to account', () => { + callService({ + fn: 'accountsAction', + provider: 'singleton', + liveSessions: [{ id: 's1', token: 't1', user: { id: 'u1', loginName: ALICE } }], + request: { + url: 'http://localhost/id/accounts', + sessions: [{ id: 's1', token: 't1', loginName: ALICE }], + form: { intent: 'switch', sessionId: 's1' }, + csrf: true, + }, + }).then((v) => { + expect(v.response?.status).to.equal(302); + expect(v.response?.passkeyHint).to.equal(ALICE); + }); + }); + + it('a dead-session switch (re-auth recovery) does NOT rewrite the hint', () => { + callService({ + fn: 'accountsAction', + provider: 'singleton', + sessionResults: { s1: { mode: 'throw', code: 'NOT_FOUND' } }, + request: { + url: 'http://localhost/id/accounts', + sessions: [{ id: 's1', token: 't1', loginName: ALICE }], + form: { intent: 'switch', sessionId: 's1' }, + csrf: true, + }, + }).then((v) => { + expect(v.response?.status).to.equal(302); + expect( + (v.response?.setCookies ?? []).some((c: string) => c.startsWith('passkey-hint=')) + ).to.equal(false); + }); + }); +}); diff --git a/cypress/component/routes/accounts-row.cy.tsx b/cypress/component/routes/accounts-row.cy.tsx index 84b82d63be..c12c1ff7e6 100644 --- a/cypress/component/routes/accounts-row.cy.tsx +++ b/cypress/component/routes/accounts-row.cy.tsx @@ -91,7 +91,7 @@ describe('accounts row — switch form structure', () => { cy.contains('a', 'Add another account').should( 'have.attr', 'href', - '/login?requestId=oidc_V2_123&organization=org-1' + '/login?requestId=oidc_V2_123&organization=org-1&add=1' ); }); }); @@ -100,18 +100,18 @@ describe('addAccountHref', () => { it('carries an OIDC ceremony requestId and organization', () => { expect( addAccountHref({ requestId: 'oidc_abc', organization: 'org-1', userCode: null }) - ).to.equal('/login?requestId=oidc_abc&organization=org-1'); + ).to.equal('/login?requestId=oidc_abc&organization=org-1&add=1'); }); it('prefers the device user_code, rewriting it as a device_ requestId', () => { expect( addAccountHref({ requestId: 'oidc_abc', organization: undefined, userCode: 'WDJB-MJHT' }) - ).to.equal('/login?requestId=device_WDJB-MJHT'); + ).to.equal('/login?requestId=device_WDJB-MJHT&add=1'); }); it('omits absent values rather than emitting empty params', () => { expect(addAccountHref({ requestId: null, organization: undefined, userCode: null })).to.equal( - '/login' + '/login?add=1' ); }); }); diff --git a/cypress/component/routes/login/conditional-passkey-loader.cy.ts b/cypress/component/routes/login/conditional-passkey-loader.cy.ts new file mode 100644 index 0000000000..6e3d647ab7 --- /dev/null +++ b/cypress/component/routes/login/conditional-passkey-loader.cy.ts @@ -0,0 +1,133 @@ +// cypress/component/routes/login/conditional-passkey-loader.cy.ts +// +// The /login loader's usernameless arming + suppression list, at the HTTP boundary. +// ?organization=org1 is threaded so the loader RENDERS (same note as +// last-used-login-loader.cy.ts). Singleton seed: u5 passkey-user@acme.test has +// authMethods ['password','passkey']; u1 alice@acme.test is password-only. +import { callService } from '../../../support/node/call-service'; + +const PK_USER = 'passkey-user@acme.test'; +const URL_BASE = 'http://localhost/id/login?organization=org1'; +type LoaderBody = { + conditionalPasskey?: { loginName?: string; publicKeyCredentialRequestOptions?: unknown } | null; +}; + +describe('/login loader — conditional passkey arming', () => { + it('no hint → arms nothing', () => { + callService({ fn: 'loginLoader', provider: 'singleton', request: { url: URL_BASE } }).then( + (v) => { + expect((v.response?.dataBody as LoaderBody).conditionalPasskey).to.equal(null); + } + ); + }); + + it('hinted passkey user → arms: challenge returned, ceremony session persisted', () => { + callService({ + fn: 'loginLoader', + provider: 'singleton', + request: { url: URL_BASE, passkeyHint: PK_USER }, + }).then((v) => { + const body = v.response?.dataBody as LoaderBody; + expect(body.conditionalPasskey?.loginName).to.equal(PK_USER); + expect(body.conditionalPasskey?.publicKeyCredentialRequestOptions).to.exist; + expect( + (v.response?.dataSetCookies ?? []).some((c: string) => c.startsWith('sessions=')) + ).to.equal(true); + }); + }); + + it('?add=1 (add-another-account arrival) suppresses arming', () => { + callService({ + fn: 'loginLoader', + provider: 'singleton', + request: { url: `${URL_BASE}&add=1`, passkeyHint: PK_USER }, + }).then((v) => { + expect((v.response?.dataBody as LoaderBody).conditionalPasskey).to.equal(null); + expect( + (v.response?.dataSetCookies ?? []).some((c: string) => c.startsWith('sessions=')) + ).to.equal(false); + }); + }); + + it('hinted user already has a live session → suppresses arming', () => { + callService({ + fn: 'loginLoader', + provider: 'singleton', + request: { + url: URL_BASE, + passkeyHint: PK_USER, + sessions: [{ id: 's5', token: 't5', loginName: PK_USER }], + }, + }).then((v) => { + expect((v.response?.dataBody as LoaderBody).conditionalPasskey).to.equal(null); + }); + }); + + it('a STALE (expired) session entry for the hinted user does NOT suppress arming', () => { + callService({ + fn: 'loginLoader', + provider: 'singleton', + request: { + url: URL_BASE, + passkeyHint: PK_USER, + // expirationTs in the known past → listSessions drops it → the fast path arms. + sessions: [{ id: 's5', token: 't5', loginName: PK_USER, expirationTs: '1000' }], + }, + }).then((v) => { + const body = v.response?.dataBody as LoaderBody; + expect(body.conditionalPasskey?.loginName).to.equal(PK_USER); + }); + }); + + it('a stale cross-organization session entry for the hinted user does not break the mint', () => { + callService({ + fn: 'loginLoader', + provider: 'singleton', + request: { + url: URL_BASE, + passkeyHint: PK_USER, + // Same loginName as the entry about to be minted, but tagged with a DIFFERENT + // organization than the request's ?organization=org1, and stale (expirationTs in the + // past) so hasLiveSession doesn't suppress arming. Regression guard for the loader's + // loginName-only supersede (index.tsx: `priorCleared`): a same-loginName duplicate + // under ANY organization must be cleared before minting, or it can shadow the fresh + // ceremony entry in byLoginName's mostRecent tie-break and the challenge mint silently + // returns null (same class of bug the same-org stale case above guards). + sessions: [ + { id: 's7', token: 't7', loginName: PK_USER, organization: 'org2', expirationTs: '1000' }, + ], + }, + }).then((v) => { + const body = v.response?.dataBody as LoaderBody; + expect(body.conditionalPasskey?.loginName).to.equal(PK_USER); + expect(body.conditionalPasskey?.publicKeyCredentialRequestOptions).to.exist; + }); + }); + + it('hint names an unknown user → arms nothing AND clears the hint', () => { + callService({ + fn: 'loginLoader', + provider: 'singleton', + request: { url: URL_BASE, passkeyHint: 'ghost@acme.test' }, + }).then((v) => { + expect((v.response?.dataBody as LoaderBody).conditionalPasskey).to.equal(null); + const cleared = (v.response?.dataSetCookies ?? []).find((c: string) => + c.startsWith('passkey-hint=') + ); + expect(cleared ?? '').to.include('Max-Age=0'); + }); + }); + + it('hinted user without a passkey → suppresses arming, keeps the hint', () => { + callService({ + fn: 'loginLoader', + provider: 'singleton', + request: { url: URL_BASE, passkeyHint: 'alice@acme.test' }, + }).then((v) => { + expect((v.response?.dataBody as LoaderBody).conditionalPasskey).to.equal(null); + expect( + (v.response?.dataSetCookies ?? []).some((c: string) => c.startsWith('passkey-hint=')) + ).to.equal(false); + }); + }); +}); diff --git a/cypress/component/routes/login/discovery-loader.cy.ts b/cypress/component/routes/login/discovery-loader.cy.ts new file mode 100644 index 0000000000..a83e1e1cef --- /dev/null +++ b/cypress/component/routes/login/discovery-loader.cy.ts @@ -0,0 +1,96 @@ +// cypress/component/routes/login/discovery-loader.cy.ts +// +// The /login loader's identity-discovery arming + suppression list, at the HTTP +// boundary (spec: 2026-07-31-usernameless-passkey-discovery-design.md). Discovery +// arms ONLY for the hintless population — and a discovery arm must be free: +// self-minted options, NO Zitadel session, NO Set-Cookie. Sibling of +// conditional-passkey-loader.cy.ts (the hinted path). +import { callService } from '../../../support/node/call-service'; + +const PK_USER = 'passkey-user@acme.test'; // u5, authMethods ['password','passkey'] +const URL_BASE = 'http://localhost/id/login?organization=org1'; + +type LoaderBody = { + conditionalPasskey?: { loginName?: string } | null; + identityDiscovery?: { + publicKeyCredentialRequestOptions?: { publicKey?: Record }; + } | null; +}; + +describe('/login loader — identity-discovery arming', () => { + it('no hint, no session → arms discovery: self-minted options, NO Zitadel mint, NO cookies', () => { + callService({ fn: 'loginLoader', provider: 'singleton', request: { url: URL_BASE } }).then( + (v) => { + const body = v.response?.dataBody as LoaderBody; + expect(body.conditionalPasskey).to.equal(null); + const pk = body.identityDiscovery?.publicKeyCredentialRequestOptions?.publicKey; + expect(pk, 'self-minted options present').to.exist; + expect(pk?.allowCredentials).to.deep.equal([]); + expect(pk?.userVerification).to.equal('discouraged'); + expect(pk?.challenge).to.be.a('string').and.not.be.empty; + expect(pk?.rpId).to.equal('localhost'); + // Free by design: no ceremony session minted, nothing persisted. + expect( + (v.response?.dataSetCookies ?? []).some((c: string) => c.startsWith('sessions=')) + ).to.equal(false); + } + ); + }); + + it('?add=1 suppresses discovery (explicit intent)', () => { + callService({ + fn: 'loginLoader', + provider: 'singleton', + request: { url: `${URL_BASE}&add=1` }, + }).then((v) => { + expect((v.response?.dataBody as LoaderBody).identityDiscovery).to.equal(null); + }); + }); + + it('ANY live session suppresses discovery', () => { + callService({ + fn: 'loginLoader', + provider: 'singleton', + liveSessions: [{ id: 's5', token: 't5', user: { id: 'u5', loginName: PK_USER } }], + request: { + url: URL_BASE, + sessions: [{ id: 's5', token: 't5', loginName: PK_USER }], + }, + }).then((v) => { + expect((v.response?.dataBody as LoaderBody).identityDiscovery).to.equal(null); + }); + }); + + it('a STALE (expired) session entry does NOT suppress discovery', () => { + callService({ + fn: 'loginLoader', + provider: 'singleton', + request: { + url: URL_BASE, + sessions: [ + { + id: 's-old', + token: 't-old', + loginName: PK_USER, + expirationTs: '2020-01-01T00:00:00.000Z', + }, + ], + }, + }).then((v) => { + const body = v.response?.dataBody as LoaderBody; + expect(body.identityDiscovery?.publicKeyCredentialRequestOptions).to.exist; + }); + }); + + it('hint present → hinted path arms, discovery stays dark', () => { + callService({ + fn: 'loginLoader', + provider: 'singleton', + request: { url: URL_BASE, passkeyHint: PK_USER }, + }).then((v) => { + const body = v.response?.dataBody as LoaderBody; + expect(body.conditionalPasskey?.loginName).to.equal(PK_USER); + expect(body.identityDiscovery).to.equal(null); + }); + }); +}); diff --git a/cypress/component/routes/login/index.cy.tsx b/cypress/component/routes/login/index.cy.tsx index f7641a9add..47039e35e1 100644 --- a/cypress/component/routes/login/index.cy.tsx +++ b/cypress/component/routes/login/index.cy.tsx @@ -1,10 +1,13 @@ // 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). +// UI contract for /login (post-A-P10-reversal, Task 12 — product ruling): the chooser +// renders unconditionally. There is no identity-bound inline passkey ceremony state on +// this route anymore — a sole-passkey identifier now REDIRECTS to /login/passkey (see +// the node-bound action test below) instead of the action returning inline challenge +// data. The gated Passkey SHORTCUT (Task 11) still drives the shared ceremony in place +// on this page, and its own failure surface is still covered here. Mirrors +// method.cy.tsx's stub /login/passkey route + capturedPosts convention (Task 2). +import { callService } from '../../../support/node/call-service'; import Login from '@/routes/login/index'; import { ConformAdapter } from '@datum-cloud/datum-ui/form/adapters/conform'; import { setupI18n } from '@lingui/core'; @@ -14,8 +17,8 @@ import { createMemoryRouter, RouterProvider } from 'react-router'; const LOGIN_CONTEXT = { loginName: '', requestId: undefined, organization: undefined }; -// Settings shaped so the ordinary identifier form (the "Continue with email" button) renders by -// default — the baseline the inline ceremony state must replace/restore around. +// Settings shaped so the ordinary identifier form (the "Email" button) renders by +// default — the baseline the chooser must always render, regardless of actionData. const INDEX_LOADER_DATA = { csrfToken: 'tok-0', idps: [], @@ -49,13 +52,11 @@ 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; - // Org-policy overrides — used to cover configurations where password is disabled. + // Org-policy overrides — cover configurations where password is disabled (#107's + // showIdentifierForm/showContinue view logic, grafted from main's spec version). settings?: Partial<(typeof INDEX_LOADER_DATA)['settings']>; emailDeliveryEnabled?: boolean; }) { @@ -79,7 +80,7 @@ function mountLogin(opts?: { index: true, element: , loader: async () => indexData, - action: opts?.indexAction ?? (async () => null), + action: async () => null, }, // Stub /login/passkey — same route the shared ceremony hook lazily loads // (challenge) then posts to (credential). Mirrors method.cy.tsx's convention: @@ -120,12 +121,14 @@ function mountLogin(opts?: { return mount(withI18n()); } -describe('/login — sole-passkey inline ceremony', () => { +describe('/login — chooser (no inline ceremony)', () => { beforeEach(() => { capturedPosts.length = 0; }); - it('sole-passkey action data swaps to the inline ceremony state and auto-fires', () => { + it('never renders an identity-bound inline block — the chooser always stays on screen', () => { + // Mount with the action having returned what USED to trigger the inline block. + // The chooser must render regardless; no identity, no "Not you?", no inline CTA. mountLogin({ actionData: { passkeyInline: { @@ -135,18 +138,53 @@ describe('/login — sole-passkey inline ceremony', () => { }, }, }); - 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'); + cy.contains(/signing in as/i).should('not.exist'); + cy.contains(/not you\?/i).should('not.exist'); + cy.contains('button', /continue with passkey/i).should('not.exist'); + cy.get('input[name="loginName"], button').should('exist'); // chooser is intact + }); + + // Finding 1: the gated Passkey SHORTCUT drives the shared `ceremony` in place on this + // page — 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' }), }); - // Identifier form is gone while the ceremony state shows. - cy.contains('button', 'Continue with email').should('not.exist'); + 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'); }); +}); +describe('/login action — sole-passkey identifier', () => { + it('a sole-passkey identifier now REDIRECTS to /login/passkey instead of returning inline data', () => { + callService({ + fn: 'loginAction', + provider: 'singleton', + request: { + url: 'http://localhost/id/login', + form: { loginName: 'solo@acme.test' }, // u21 — the seeded passkey-ONLY fixture + csrf: true, + }, + }).then((v) => { + expect(v.response?.isResponse).to.equal(true); + expect(v.response?.status).to.equal(302); + expect(v.response?.location ?? '').to.contain('/login/passkey'); + expect(v.response?.location ?? '').to.contain('loginName='); + }); + }); +}); + +// ── #107 view logic (grafted from main's spec version at the merge) ────────────── +// The inline-ceremony tests main carried were dropped — the merged runtime keeps the +// Task-12 product ruling (sole-passkey REDIRECTS to /login/passkey, asserted above) — +// but these two cover showIdentifierForm/showContinue, which survive unchanged. +describe('/login — identifier-form view logic (#107)', () => { // REGRESSION: with allowPassword=false the identifier form used to be hidden entirely, // while signInUnavailable stayed false (suppressed by showPasskeyPrompt) — so a fresh // visitor at a passkey-only org got an EMPTY card: no sign-in path and no error. @@ -156,7 +194,7 @@ describe('/login — sole-passkey inline ceremony', () => { settings: { allowPassword: false, passkeysType: 'allowed' }, emailDeliveryEnabled: false, }); - cy.contains('button', 'Continue with email').should('be.visible'); + cy.contains('button', 'Email').should('be.visible'); cy.contains('Sign-in is currently unavailable').should('not.exist'); }); @@ -168,7 +206,7 @@ describe('/login — sole-passkey inline ceremony', () => { settings: { allowPassword: false, passkeysType: 'not_allowed' }, emailDeliveryEnabled: true, }); - cy.contains('button', 'Continue with email').should('be.visible').click(); + cy.contains('button', 'Email').should('be.visible').click(); cy.contains('button', 'Email me a sign-in link').should('be.visible'); // Exactly one submit action — no "Continue" alongside it. cy.get('form button[type="submit"]').should('have.length', 1); @@ -176,77 +214,4 @@ describe('/login — sole-passkey inline ceremony', () => { // The intent rides on a hidden field, so implicit submission still means email-link. cy.get('form input[name="intent"]').should('have.value', 'email-link'); }); - - 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', 'Continue with 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', 'Continue with 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/passkey-back-link.cy.tsx b/cypress/component/routes/login/passkey-back-link.cy.tsx index 1438d6ec79..0c0c6915ba 100644 --- a/cypress/component/routes/login/passkey-back-link.cy.tsx +++ b/cypress/component/routes/login/passkey-back-link.cy.tsx @@ -1,8 +1,8 @@ // 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. +// /login/passkey is once again the mainstream destination for a sole-passkey login +// (the inline ceremony on /login was removed), so a failed or wrong-account ceremony must +// have a way back. Mirrors /login/security-key, which has always had this target. import LoginPasskey from '@/routes/login/passkey'; import { ConformAdapter } from '@datum-cloud/datum-ui/form/adapters/conform'; import { setupI18n } from '@lingui/core'; @@ -40,10 +40,35 @@ function mountPasskey() { return mount(withI18n()); } -describe('/login/passkey — Back link explicitly suppressed', () => { - it('renders the identity header but no Back control', () => { +describe('/login/passkey — Back link', () => { + it('renders Back with href to /login', () => { mountPasskey(); cy.contains('a', 'Not you?').should('exist'); - cy.contains('a', 'Back').should('not.exist'); + cy.contains('a', 'Back').should('exist').and('have.attr', 'href').and('include', '/login'); + }); + + it('preserves the ceremony query string on the Back target', () => { + // mount at /login/passkey?loginName=mia%40acme.test + 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?loginName=mia%40acme.test'] } + ); + mount( + withI18n() + ); + cy.contains('a', 'Back').should('have.attr', 'href').and('include', 'loginName=mia%40acme.test'); }); }); diff --git a/cypress/component/routes/login/passkey-button-visibility.cy.tsx b/cypress/component/routes/login/passkey-button-visibility.cy.tsx new file mode 100644 index 0000000000..9e8e609440 --- /dev/null +++ b/cypress/component/routes/login/passkey-button-visibility.cy.tsx @@ -0,0 +1,193 @@ +// cypress/component/routes/login/passkey-button-visibility.cy.tsx +// +// Review finding (2026-07-30): the Passkey button used to render ONLY when a +// loginName was already known (`view.showPasskeyPrompt && loginName`), which hid it on +// every ordinary cold /login visit. Ruling: keep the in-place ceremony (no href, no +// identity in the DOM), render the button unconditionally, and bind it to whatever +// identity IS resolvable (URL identifier, else the loader's usernameless hint). With NO +// resolvable identity a click must route into the identifier field instead of firing a +// ceremony Zitadel would refuse (it cannot mint a challenge for an unbound user). +// Mirrors index.cy.tsx's mount harness (same router stub, loader data shape, and +// /login/passkey action capture convention). +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 both the IdP/password chooser AND the Passkey prompt render — +// the baseline the visibility/identity-binding behavior is exercised against. +const BASE_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?: { + // Known loginName threads through useLoginContext (URL identifier — e.g. ?loginName=). + loginName?: string; + // Loader-resolved usernameless hint (Task 7's conditionalPasskey field). Null/undefined + // mirrors a cold visit with no hint. + conditionalPasskey?: { loginName: string; publicKeyCredentialRequestOptions: unknown } | null; + // Loader-armed identity discovery (fresh browser, no hint). Null/undefined mirrors a + // loader-SUPPRESSED visit (?add=1 / live session) where the button must fall back. + identityDiscovery?: { publicKeyCredentialRequestOptions: unknown } | null; +}) { + const loginContext = { ...LOGIN_CONTEXT, loginName: opts?.loginName ?? LOGIN_CONTEXT.loginName }; + const indexLoaderData = { + ...BASE_LOADER_DATA, + conditionalPasskey: opts?.conditionalPasskey ?? null, + identityDiscovery: opts?.identityDiscovery ?? null, + }; + const router = createMemoryRouter( + [ + { + id: 'login', + path: '/login', + loader: () => loginContext, + children: [ + { + id: 'index', + index: true, + element: , + loader: async () => indexLoaderData, + action: async () => null, + }, + // Stub /login/passkey — same route the shared ceremony hook lazily loads + // (challenge) then posts to (credential). Mirrors index.cy.tsx's convention: + // no navigation assertions, just the captured POST. + { + id: 'passkey', + path: 'passkey', + loader: async () => ({ + csrfToken: 'tok-1', + loginName: opts?.loginName ?? 'solo@acme.test', + requestId: undefined, + organization: undefined, + publicKeyCredentialRequestOptions: { publicKey: { challenge: 'x' } }, + }), + action: async ({ request }: { request: Request }) => { + capturedPosts.push(Object.fromEntries(await request.formData())); + return {}; + }, + }, + ], + }, + ], + { + initialEntries: ['/login'], + hydrationData: { + loaderData: { login: loginContext, index: indexLoaderData }, + }, + } + ); + return mount(withI18n()); +} + +describe('/login Passkey button — visibility and identity binding', () => { + beforeEach(() => { + capturedPosts.length = 0; + }); + + it('renders with no loginName and no hint (cold visit)', () => { + mountLogin(); + cy.contains('button', /passkey/i) + .should('exist') + .and('not.be.disabled'); + }); + + it('cold click with discovery UNARMED (loader-suppressed) falls back to the identifier field', () => { + // identityDiscovery null = the loader suppressed arming (?add=1 / live session). + // beginDiscovery has no options to run over → the identifier step is the fallback + // (spec, open decision §3 as built). + mountLogin(); + cy.contains('button', /passkey/i).click(); + cy.get('input[name="loginName"]').should('be.visible'); + cy.then(() => expect(capturedPosts).to.have.length(0)); + }); + + it('cold click with discovery ARMED fires the modal discovery flow, not the email field', () => { + cy.intercept('POST', '**/id/login/passkey-discover', { + statusCode: 200, + body: { + loginName: 'mia@acme.test', + csrfToken: 'tok-d', + publicKeyCredentialRequestOptions: { publicKey: { challenge: 'zitadel-x' } }, + }, + }).as('discover'); + mountLogin({ + identityDiscovery: { + publicKeyCredentialRequestOptions: { publicKey: { challenge: 'identity-x' } }, + }, + }); + cy.contains('button', /passkey/i).click(); + cy.wait('@discover'); + // The discover response's resolved identity drives the verify POST. + cy.then(() => { + expect(capturedPosts).to.have.length(1); + expect(capturedPosts[0].loginName).to.equal('mia@acme.test'); + expect(capturedPosts[0].csrf).to.equal('tok-d'); + }); + }); + + it('hinted click fires the ceremony bound to the hinted loginName', () => { + mountLogin({ + conditionalPasskey: { + loginName: 'mia@acme.test', + publicKeyCredentialRequestOptions: { publicKey: { challenge: 'x' } }, + }, + }); + cy.contains('button', /passkey/i).click(); + cy.then(() => { + expect(capturedPosts).to.have.length(1); + expect(capturedPosts[0].loginName).to.equal('mia@acme.test'); + }); + }); + + it('renders identically hinted vs unhinted — no name, no badge, no extra affordance', () => { + mountLogin({ + conditionalPasskey: { + loginName: 'mia@acme.test', + publicKeyCredentialRequestOptions: { publicKey: { challenge: 'x' } }, + }, + }); + cy.contains('mia@acme.test').should('not.exist'); + // Scope to
(SplitLayout's form panel) — the stable container for everything + // this component renders, excluding the surrounding chrome and the Cypress runner's + // own bootstrap script tags a `body`-wide `.text()` read would otherwise pick up. + cy.get('main').then(($hinted) => { + const hintedText = $hinted.text(); + mountLogin(); + cy.contains('mia@acme.test').should('not.exist'); + cy.get('main').should(($cold) => { + expect($cold.text()).to.equal(hintedText); + }); + }); + }); +}); diff --git a/cypress/component/routes/login/passkey-discover.cy.ts b/cypress/component/routes/login/passkey-discover.cy.ts new file mode 100644 index 0000000000..0d32c8b269 --- /dev/null +++ b/cypress/component/routes/login/passkey-discover.cy.ts @@ -0,0 +1,142 @@ +// cypress/component/routes/login/passkey-discover.cy.ts +// +// /login/passkey-discover action — the identity-resolution step of the usernameless +// discovery path (spec: 2026-07-31-usernameless-passkey-discovery-design.md). +// The posted assertion is an UNTRUSTED identity claim: only response.userHandle is +// read (== Zitadel userId). Every user-dependent failure must collapse into ONE +// opaque 400 (enumeration parity with the identifier form). The action returns +// plain Response.json (direct-fetch API, not an RR fetcher target). Node-bound +// action spec: signed sessions cookie + CSRF round-trip (see passkey-hint-write.cy.ts). +import { callService } from '../../../support/node/call-service'; + +const PK_USER = 'passkey-user@acme.test'; // u5, authMethods ['password','passkey'] +const B64_U5 = 'dTU'; // base64url('u5') +const B64_U1 = 'dTE'; // base64url('u1') — alice, authMethods ['password'] only +const B64_UNKNOWN = 'bm8tc3VjaC11c2Vy'; // base64url('no-such-user') + +/** Minimal marshalled-assertion JSON — discover reads ONLY response.userHandle. */ +function assertionWith(userHandle: string | null): string { + return JSON.stringify({ + id: 'cred-1', + rawId: 'cred-1', + type: 'public-key', + response: { + authenticatorData: 'x', + clientDataJSON: 'x', + signature: 'x', + userHandle, + }, + }); +} + +const URL = 'http://localhost/id/login/passkey-discover'; + +describe('/login/passkey-discover action', () => { + it('resolves the userHandle to a user-bound challenge; sessions cookie set, NO passkey-hint', () => { + callService({ + fn: 'passkeyDiscoverAction', + provider: 'singleton', + request: { + url: URL, + form: { credential: assertionWith(B64_U5) }, + csrf: true, + }, + }).then((v) => { + expect(v.response?.isResponse).to.equal(true); + expect(v.response?.status).to.equal(200); + const body = v.response?.dataBody as { + loginName?: string; + csrfToken?: string; + publicKeyCredentialRequestOptions?: unknown; + }; + expect(body.loginName).to.equal(PK_USER); + expect(body.csrfToken).to.be.a('string').and.not.be.empty; + expect(body.publicKeyCredentialRequestOptions, 'real Zitadel-issued options').to.exist; + const cookies = v.response?.setCookies ?? []; + expect( + cookies.some((c) => c.startsWith('sessions=')), + 'ceremony session entry persisted' + ).to.equal(true); + // Hint invariant: "last successfully AUTHENTICATED user" — the verify action + // writes it on success; discover must NOT (spec, design decisions). + expect( + cookies.some((c) => c.startsWith('passkey-hint=')), + 'no hint write on discover' + ).to.equal(false); + }); + }); + + it('absent userHandle (non-resident key) → opaque DISCOVERY_FAILED 400', () => { + callService({ + fn: 'passkeyDiscoverAction', + provider: 'singleton', + request: { url: URL, form: { credential: assertionWith(null) }, csrf: true }, + }).then((v) => { + expect(v.response?.status).to.equal(400); + expect((v.response?.dataBody as { error?: string }).error).to.equal('DISCOVERY_FAILED'); + }); + }); + + it('unknown userHandle → the SAME opaque DISCOVERY_FAILED 400', () => { + callService({ + fn: 'passkeyDiscoverAction', + provider: 'singleton', + request: { url: URL, form: { credential: assertionWith(B64_UNKNOWN) }, csrf: true }, + }).then((v) => { + expect(v.response?.status).to.equal(400); + expect((v.response?.dataBody as { error?: string }).error).to.equal('DISCOVERY_FAILED'); + }); + }); + + it('user without a passkey method → the SAME opaque DISCOVERY_FAILED 400', () => { + callService({ + fn: 'passkeyDiscoverAction', + provider: 'singleton', + request: { url: URL, form: { credential: assertionWith(B64_U1) }, csrf: true }, + }).then((v) => { + expect(v.response?.status).to.equal(400); + expect((v.response?.dataBody as { error?: string }).error).to.equal('DISCOVERY_FAILED'); + }); + }); + + it('live session for the resolved user → opaque 400 (crafted-POST supersede guard)', () => { + // The loader suppresses discovery when a live session exists; a crafted POST must + // not bypass that and supersede a LIVE cookie entry via armUserBoundChallenge. + callService({ + fn: 'passkeyDiscoverAction', + provider: 'singleton', + liveSessions: [{ id: 's5', token: 't5', user: { id: 'u5', loginName: PK_USER } }], + request: { + url: URL, + sessions: [{ id: 's5', token: 't5', loginName: PK_USER }], + form: { credential: assertionWith(B64_U5) }, + csrf: true, + }, + }).then((v) => { + expect(v.response?.status).to.equal(400); + expect((v.response?.dataBody as { error?: string }).error).to.equal('DISCOVERY_FAILED'); + }); + }); + + it('malformed credential JSON → opaque DISCOVERY_FAILED 400 (shape violations are non-events)', () => { + callService({ + fn: 'passkeyDiscoverAction', + provider: 'singleton', + request: { url: URL, form: { credential: 'not-json{' }, csrf: true }, + }).then((v) => { + expect(v.response?.status).to.equal(400); + expect((v.response?.dataBody as { error?: string }).error).to.equal('DISCOVERY_FAILED'); + }); + }); + + it('missing credential field → INVALID_INPUT 400 (schema boundary, not user-dependent)', () => { + callService({ + fn: 'passkeyDiscoverAction', + provider: 'singleton', + request: { url: URL, form: {}, csrf: true }, + }).then((v) => { + expect(v.response?.status).to.equal(400); + expect((v.response?.dataBody as { error?: string }).error).to.equal('INVALID_INPUT'); + }); + }); +}); diff --git a/cypress/component/routes/login/passkey-hint-write.cy.ts b/cypress/component/routes/login/passkey-hint-write.cy.ts new file mode 100644 index 0000000000..d48977ef6b --- /dev/null +++ b/cypress/component/routes/login/passkey-hint-write.cy.ts @@ -0,0 +1,66 @@ +// cypress/component/routes/login/passkey-hint-write.cy.ts +// +// The hint mirrors serializeLastUsedLogin at every login-success write site. These two are +// node-bound action specs: signed sessions cookie + CSRF round-trip (see password-reauth.cy.ts). +import { callService } from '../../../support/node/call-service'; +import { CYPRESS_CREDENTIAL } from '@/components/webauthn-button/webauthn-button'; + +const ALICE = 'alice@acme.test'; // u1, password 'hunter2' in the fake singleton +const PK_USER = 'passkey-user@acme.test'; // u5, authMethods ['password','passkey'] + +describe('passkey-hint written on login success', () => { + it('password action: the success redirect carries passkey-hint=', () => { + callService({ + fn: 'loginPasswordAction', + provider: 'singleton', + liveSessions: [{ id: 's1', token: 't1', user: { id: 'u1', loginName: ALICE } }], + request: { + url: 'http://localhost/id/login/password', + sessions: [{ id: 's1', token: 't1', loginName: ALICE }], + form: { loginName: ALICE, password: 'hunter2' }, + csrf: true, + }, + }).then((v) => { + expect(v.response?.status).to.equal(302); + expect(v.response?.passkeyHint).to.equal(ALICE); + }); + }); + + it('passkey verify action: the success redirect carries passkey-hint= (rolling refresh)', () => { + callService({ + fn: 'loginPasskeyAction', + provider: 'singleton', + liveSessions: [{ id: 's5', token: 't5', user: { id: 'u5', loginName: PK_USER } }], + request: { + url: 'http://localhost/id/login/passkey', + sessions: [{ id: 's5', token: 't5', loginName: PK_USER }], + form: { + loginName: PK_USER, + credential: JSON.stringify(CYPRESS_CREDENTIAL), + }, + csrf: true, + }, + }).then((v) => { + expect(v.response?.status).to.equal(302); + expect(v.response?.passkeyHint).to.equal(PK_USER); + // Sanity: the existing last-used write is untouched. + expect(v.response?.lastUsedLogin).to.equal('passkey'); + }); + }); + + it('password action failure writes NO hint', () => { + callService({ + fn: 'loginPasswordAction', + provider: 'singleton', + liveSessions: [{ id: 's1', token: 't1', user: { id: 'u1', loginName: ALICE } }], + request: { + url: 'http://localhost/id/login/password', + sessions: [{ id: 's1', token: 't1', loginName: ALICE }], + form: { loginName: ALICE, password: 'wrong-password' }, + csrf: true, + }, + }).then((v) => { + expect(v.response?.passkeyHint ?? null).to.equal(null); + }); + }); +}); diff --git a/cypress/component/routes/logout/passkey-hint-clear.cy.ts b/cypress/component/routes/logout/passkey-hint-clear.cy.ts new file mode 100644 index 0000000000..451c43d4cd --- /dev/null +++ b/cypress/component/routes/logout/passkey-hint-clear.cy.ts @@ -0,0 +1,63 @@ +// cypress/component/routes/logout/passkey-hint-clear.cy.ts +// +// Owner-scoped hint clearing: /logout clears the passkey-hint ONLY when it names the +// signing-out (most-recent) account; the OIDC sign-out-of-all path always clears it. +import { callService } from '../../../support/node/call-service'; + +const BOB = 'bob@acme.test'; +const ALICE = 'alice@acme.test'; +const BOB_SESSION = { id: 's1', token: 't1', loginName: BOB }; + +describe('/logout — passkey-hint clearing', () => { + it('clears the hint when it names the signing-out user (case-insensitive)', () => { + callService({ + fn: 'logoutAction', + provider: 'singleton', + liveSessions: [{ id: 's1', token: 't1', user: { id: 'u9', loginName: BOB } }], + request: { + url: 'http://localhost/id/logout', + sessions: [BOB_SESSION], + csrf: true, + passkeyHint: 'Bob@ACME.test', + }, + }).then((v) => { + expect(v.response?.status).to.equal(302); + expect(v.response?.passkeyHint).to.equal(''); // '' = cleared (Max-Age=0) + }); + }); + + it("leaves the hint alone when a DIFFERENT user's hint is stored (Alice signs out, hint = Bob)", () => { + callService({ + fn: 'logoutAction', + provider: 'singleton', + liveSessions: [{ id: 's2', token: 't2', user: { id: 'u1', loginName: ALICE } }], + request: { + url: 'http://localhost/id/logout', + sessions: [{ id: 's2', token: 't2', loginName: ALICE }], + csrf: true, + passkeyHint: BOB, + }, + }).then((v) => { + expect(v.response?.status).to.equal(302); + expect( + (v.response?.setCookies ?? []).some((c: string) => c.startsWith('passkey-hint=')) + ).to.equal(false); + }); + }); + + it('OIDC sign-out-of-all (logout_token) always clears the hint', () => { + callService({ + fn: 'logoutLoader', + provider: 'singleton', + liveSessions: [{ id: 's1', token: 't1', user: { id: 'u9', loginName: BOB } }], + request: { + url: 'http://localhost/id/logout?logout_token=tok', + sessions: [BOB_SESSION], + passkeyHint: ALICE, // even a hint for someone ELSE is cleared on sign-out-of-all + }, + }).then((v) => { + expect(v.response?.status).to.equal(302); + expect(v.response?.passkeyHint).to.equal(''); + }); + }); +}); diff --git a/cypress/component/routes/signup/complete.cy.ts b/cypress/component/routes/signup/complete.cy.ts index 6c9f767e06..faefe8efbe 100644 --- a/cypress/component/routes/signup/complete.cy.ts +++ b/cypress/component/routes/signup/complete.cy.ts @@ -27,6 +27,7 @@ describe('signup/complete — success path', () => { expect(url.pathname).to.equal('/setup/passkey'); expect(url.searchParams.get('loginName')).to.equal('alice@acme.test'); expect(url.searchParams.get('userId')).to.be.ok; + expect(v.response?.passkeyHint).to.equal('alice@acme.test'); }); }); }); diff --git a/cypress/e2e/core-signin.cy.ts b/cypress/e2e/core-signin.cy.ts index 41e8e9486f..5d4cd2a94c 100644 --- a/cypress/e2e/core-signin.cy.ts +++ b/cypress/e2e/core-signin.cy.ts @@ -7,7 +7,7 @@ describe('core sign-in (fake provider)', () => { // visit hydrated. We wait for the SSR'd "Email" button so the cold reload has fully settled. before(() => { cy.visit('/id/login'); - cy.contains('button', 'Continue with email'); + cy.contains('button', 'Email'); }); it('identifier → password → signed-in', () => { @@ -20,7 +20,7 @@ describe('core sign-in (fake provider)', () => { checkA11y(); // /login renders // The email input is behind an "Email" reveal button (IdP-first UX); click it first. - cy.contains('button', 'Continue with email').click(); + cy.contains('button', 'Email').click(); cy.get('input[name="loginName"]').type('alice@acme.test'); cy.get('input[name="loginName"]:visible').closest('form').submit(); @@ -42,7 +42,7 @@ describe('core sign-in (fake provider)', () => { }, }); cy.settleHydration(); - cy.contains('button', 'Continue with email').click(); + cy.contains('button', 'Email').click(); cy.get('input[name="loginName"]').type('alice@acme.test'); cy.get('input[name="loginName"]:visible').closest('form').submit(); cy.get('input[name="password"]').type('wrong-password'); diff --git a/cypress/e2e/login-hydrated-submit.cy.ts b/cypress/e2e/login-hydrated-submit.cy.ts index 5a98e223d8..1dc1ed374b 100644 --- a/cypress/e2e/login-hydrated-submit.cy.ts +++ b/cypress/e2e/login-hydrated-submit.cy.ts @@ -46,7 +46,7 @@ describe('login form submits when hydrated (RHF-adapter regression)', () => { // entry.client.tsx + routes/login/index.tsx). Click it first to mount the loginName input — // mirrors core-signin.cy.ts. (Pre-IdP-first this field was visible on load; the reveal is the // current behavior.) The hydration regression this spec guards is unaffected by the reveal. - cy.contains('button', 'Continue with email').click(); + cy.contains('button', 'Email').click(); // Type into the (now React-controlled) identifier field and click the real // Continue button — the exact interaction the RHF adapter used to swallow. diff --git a/cypress/e2e/passkey-conditional.cy.ts b/cypress/e2e/passkey-conditional.cy.ts new file mode 100644 index 0000000000..0361ee69f1 --- /dev/null +++ b/cypress/e2e/passkey-conditional.cy.ts @@ -0,0 +1,114 @@ +// cypress/e2e/passkey-conditional.cy.ts +// +// The usernameless fast path end-to-end against the fake provider: +// hint written on an ordinary login → session expires → /login signs the user in with +// zero typing (conditional ceremony auto-resolved via the Cypress seam) → logout clears +// the hint → the fast path goes inert. u5 passkey-user@acme.test is the one seeded user +// with BOTH a password (writes the hint via ordinary login) and a passkey (verify path). +import { extractCsrf, loginAndGetSession } from '../support/session'; + +const USER = 'passkey-user@acme.test'; + +// USER has TWO primary methods (password + passkey), so the identifier step routes to +// the /login/method chooser — loginAndGetSession leaves the session at the bare identifier +// check (never signed in), same gap passkeys-manage.cy.ts's signInMiaWithPassword documents +// for mia@acme.test. Complete the password factor via cy.request (deterministic; the +// password UI journey is core-signin.cy.ts's subject) so the action's passkey-hint write fires. +function signInWithPassword(loginName: string) { + loginAndGetSession(loginName); + cy.request(`/id/login/password?loginName=${encodeURIComponent(loginName)}`).then((resp) => { + const csrf = extractCsrf(resp.body as string); + cy.request({ + method: 'POST', + url: `/id/login/password.data?loginName=${encodeURIComponent(loginName)}`, + form: true, + body: { csrf, loginName, password: 'hunter2' }, + followRedirect: false, + }); + }); +} + +const visitLoginArmed = (path = '/id/login') => + cy.visit(path, { + onBeforeLoad: (win) => { + win.__CYPRESS_HYDRATE__ = true; // the ceremony needs JS (see entry.client.tsx) + ( + win as unknown as { __conditionalPasskeyAutoResolve?: boolean } + ).__conditionalPasskeyAutoResolve = true; // simulate "user taps the passkey in the autofill dropdown" + }, + }); + +describe('usernameless passkey fast path', () => { + // Warm Vite's dep optimization once (mirrors core-signin.cy.ts / passkeys-manage.cy.ts): the + // first cold route load triggers a hard reload that drops any onBeforeLoad-injected window + // flag — fatal here, since every test below relies on __conditionalPasskeyAutoResolve / + // __CYPRESS_HYDRATE__ surviving the visit uninterrupted. Wait for the SSR'd "Email" button so + // the cold reload has fully settled before any real test-critical visit occurs. + before(() => { + cy.visit('/id/login'); + cy.contains('button', /email/i); + }); + + it('hint written on login → fast path signs in with zero typing → logout clears it', () => { + // 1. Ordinary password login writes the hint (alongside last-used-login). + signInWithPassword(USER); + cy.getCookie('passkey-hint').should('exist'); + + // 2. Session gone, hint intact — exactly the returning-user population the path serves. + cy.clearCookie('sessions'); + + // 3. Zero-typing sign-in: loader arms, ceremony auto-resolves, verify redirects. + // settleHydration's sacrificial click forces React past the Cypress-only head-injection + // hydration mismatch (see support/e2e.ts) — without it the auto-fire effect that arms the + // conditional ceremony never runs, since React defers regenerating the mismatched tree + // until the first dispatched event. + visitLoginArmed(); + cy.settleHydration(); + cy.location('pathname').should('eq', '/id/signed-in'); + cy.contains(USER); + + // 4. Sign out → the hint pointed at the signing-out user, so it is cleared. + // SignOutButton's form action is `${APP_BASENAME}/logout?index` (the ?index routes a + // native
post to the logout INDEX route's action, not its action-less layout). + cy.get('form[action="/id/logout?index"]').submit(); + cy.location('pathname').should('eq', '/id/logout/success'); + cy.getCookie('passkey-hint').should('not.exist'); + + // 5. No hint → the page stays QUIET even with auto-resolve armed (spec, decision + // §4: ambient arming is hinted-only — no fresh-load auto-prompt). Discovery is + // BUTTON-initiated: the Passkey button signs in via userHandle resolution, and + // the verify success re-writes the hint (browser self-upgrade). + visitLoginArmed(); + cy.settleHydration(); + cy.location('pathname').should('eq', '/id/login'); + cy.contains('button', /passkey/i).click(); + cy.location('pathname').should('eq', '/id/signed-in'); + cy.getCookie('passkey-hint').should('exist'); + }); + + it('add-another-account arrival suppresses the fast path', () => { + signInWithPassword(USER); + cy.clearCookie('sessions'); + visitLoginArmed('/id/login?add=1'); + cy.settleHydration(); + cy.location('pathname').should('eq', '/id/login'); + }); + + it('an armed (un-resolved) ceremony never blocks the ordinary identifier flow', () => { + // Hint present but NO auto-resolve: the ceremony parks; typing + submitting must win + // (the page aborts the armed ceremony on submit — spec: "user types and submits"). + signInWithPassword(USER); + cy.clearCookie('sessions'); + cy.visit('/id/login', { + onBeforeLoad: (win) => { + win.__CYPRESS_HYDRATE__ = true; + }, + }); + cy.settleHydration(); + cy.contains('button', /email/i).click(); + cy.get('input[name="loginName"]').should('have.attr', 'autocomplete', 'username webauthn'); + cy.get('input[name="loginName"]').type('alice@acme.test'); + cy.get('input[name="loginName"]:visible').closest('form').submit(); + cy.location('pathname').should('eq', '/id/login/password'); + }); +}); diff --git a/cypress/e2e/passkey-discovery.cy.ts b/cypress/e2e/passkey-discovery.cy.ts new file mode 100644 index 0000000000..b73bc48e6e --- /dev/null +++ b/cypress/e2e/passkey-discovery.cy.ts @@ -0,0 +1,88 @@ +// cypress/e2e/passkey-discovery.cy.ts +// +// The usernameless DISCOVERY path end-to-end against the fake provider (spec: +// 2026-07-31-usernameless-passkey-discovery-design.md): a FRESH browser — no +// passkey-hint, no session, nothing — signs in with zero typing. The identity tap +// (auto-resolved via the Cypress seam; CYPRESS_CREDENTIAL carries userHandle +// base64url('u5')) posts to /login/passkey-discover, which resolves u5 and mints +// the real user-bound challenge; the modal ceremony auto-resolves and the verify +// action signs the user in AND writes the hint — so the next visit takes the +// one-tap HINTED path (browser self-upgrade, at-most-once-per-browser discovery). +const USER = 'passkey-user@acme.test'; // u5 — the seeded passkey user + +const visitLoginArmed = () => + cy.visit('/id/login', { + onBeforeLoad: (win) => { + win.__CYPRESS_HYDRATE__ = true; // the ceremony needs JS (see entry.client.tsx) + ( + win as unknown as { __conditionalPasskeyAutoResolve?: boolean } + ).__conditionalPasskeyAutoResolve = true; // simulate "user taps the passkey in the dropdown" + }, + }); + +describe('usernameless passkey discovery (fresh browser)', () => { + // Warm Vite's dep optimization AND the hydrated client graph once (same rationale as + // passkey-conditional.cy.ts, plus hydration: the discovery journey's first armed visit + // is this spec's very first interaction, so any cold-load auto-reload would eat the + // onBeforeLoad flags). Settle a fully hydrated page before any test-critical visit. + before(() => { + // Stage 1 — plain SSR visit: absorbs Vite's cold-start dep-optimization reload + // (which would silently drop any onBeforeLoad flag) exactly like + // passkey-conditional.cy.ts's warm-up. + cy.visit('/id/login'); + cy.contains('button', /email/i); + // Stage 2 — hydrated visit on the now-warm server: settles the full client + // graph so the first test-critical armed visit isn't the first hydration. + cy.visit('/id/login', { + onBeforeLoad: (win) => { + win.__CYPRESS_HYDRATE__ = true; + }, + }); + cy.settleHydration(); + cy.contains('button', /email/i); + }); + + it('fresh browser: BUTTON-initiated discovery signs in, then self-upgrades to the hinted path', () => { + // 1. FRESH-browser preconditions: no hint, no session. Cleared individually — + // cy.clearAllCookies() nukes Cypress-internal cookies too, and the next visit + // loses its onBeforeLoad flags to an uninstrumented reload (repo convention: + // passkey-conditional.cy.ts also clears specific cookies only). + cy.clearCookie('passkey-hint'); + cy.clearCookie('sessions'); + // Ambient arming is hinted-only (spec, decision §4): a hintless load stays quiet + // even with auto-resolve armed; the Passkey BUTTON is the discovery entry + // (beginDiscovery — under Cypress the pre-baked credential IS the picked passkey). + visitLoginArmed(); + cy.settleHydration(); + cy.location('pathname').should('eq', '/id/login'); + cy.contains('button', /passkey/i).click(); + cy.location('pathname').should('eq', '/id/signed-in'); + cy.contains(USER); + + // 2. Self-upgrade: the verify success wrote the hint (discover itself must not). + cy.getCookie('passkey-hint').should('exist'); + + // 3. Session expires (hint survives) → the returning visit takes the HINTED + // one-tap path — discovery ran at most once for this browser. + cy.clearCookie('sessions'); + visitLoginArmed(); + cy.settleHydration(); + cy.location('pathname').should('eq', '/id/signed-in'); + cy.contains(USER); + }); + + it('a parked discovery ceremony (no tap) never blocks the ordinary identifier flow', () => { + cy.clearCookie('passkey-hint'); + cy.clearCookie('sessions'); + cy.visit('/id/login', { + onBeforeLoad: (win) => { + win.__CYPRESS_HYDRATE__ = true; // armed but NO auto-resolve → ceremony parks + }, + }); + cy.settleHydration(); + cy.contains('button', /email/i).click(); + cy.get('input[name="loginName"]').type('alice@acme.test'); + cy.get('input[name="loginName"]:visible').closest('form').submit(); + cy.location('pathname').should('eq', '/id/login/password'); + }); +}); diff --git a/cypress/e2e/passkeys-manage.cy.ts b/cypress/e2e/passkeys-manage.cy.ts index d078ffaaf0..d2e7b185d3 100644 --- a/cypress/e2e/passkeys-manage.cy.ts +++ b/cypress/e2e/passkeys-manage.cy.ts @@ -30,7 +30,7 @@ describe('/id/passkeys — list / remove / last-method guard / sign-out offer / // passkey-use.cy.ts fails standalone on main the same way) so ceremony clicks land. before(() => { cy.visit('/id/login'); - cy.contains('button', 'Continue with email'); + cy.contains('button', 'Email'); loginAndGetSession('passkey-user@acme.test'); cy.visit('/id/login/passkey?loginName=passkey-user%40acme.test', { onBeforeLoad: (win) => { diff --git a/cypress/e2e/verify-otp.cy.ts b/cypress/e2e/verify-otp.cy.ts index 0747de254c..615083b9f2 100644 --- a/cypress/e2e/verify-otp.cy.ts +++ b/cypress/e2e/verify-otp.cy.ts @@ -18,7 +18,7 @@ function loginAndGetSession(loginName: string) { 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', 'Continue with email').click(); + 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'); @@ -63,6 +63,9 @@ describe('MFA verify — Email OTP (/login/verify/email)', () => { cy.contains('button', /verify/i).click(); cy.location('pathname').should('eq', '/id/signed-in'); + + // The OTP-email login completion writes the passkey-hint (mirrors last-used-login). + cy.getCookie('passkey-hint').should('exist'); }); }); diff --git a/cypress/support/node/harness.ts b/cypress/support/node/harness.ts index 28abc72130..6777aff3a5 100644 --- a/cypress/support/node/harness.ts +++ b/cypress/support/node/harness.ts @@ -32,6 +32,11 @@ import { lastUsedLoginCookie, serializeLastUsedLogin, } from '@/modules/auth/session/last-used-login'; +import { + passkeyHintCookie, + serializePasskeyHint, + clearPasskeyHint, +} from '@/modules/auth/session/passkey-hint'; import { serializeReauthIntent, readReauthIntent, @@ -109,13 +114,15 @@ import { loader as deviceIndexLoader } from '@/routes/device/index'; import { loader as loginLoader, action as loginAction } from '@/routes/login/index'; import { loader as loginMethodLoader, action as loginMethodAction } from '@/routes/login/method'; import { action as loginMfaAction } from '@/routes/login/mfa'; +import { action as loginPasskeyAction } from '@/routes/login/passkey'; +import { action as passkeyDiscoverAction } from '@/routes/login/passkey-discover'; import { action as loginPasswordAction, loader as loginPasswordLoader, } from '@/routes/login/password'; import { action as securityKeyAction } from '@/routes/login/security-key'; import { loader as loginVerifyEmailLoader } from '@/routes/login/verify/email'; -import { loader as logoutLoader } from '@/routes/logout/index'; +import { loader as logoutLoader, action as logoutAction } from '@/routes/logout/index'; import { loader as passwordChangeLoader, action as passwordChangeAction, @@ -206,6 +213,7 @@ async function buildCookieHeader(req: RequestSpec): Promise if (req.reauthIntent) parts.push((await serializeReauthIntent(req.reauthIntent)).split(';')[0]); if (req.lastUsedLogin) parts.push((await serializeLastUsedLogin(req.lastUsedLogin)).split(';')[0]); + if (req.passkeyHint) parts.push((await serializePasskeyHint(req.passkeyHint)).split(';')[0]); return parts.length > 0 ? parts.join('; ') : undefined; } @@ -273,6 +281,11 @@ async function buildHandlerRequest( const request = new Request(req.url, { method }); Object.defineProperty(request, 'headers', { value: headers, configurable: true }); Object.defineProperty(request, 'formData', { value: async () => form, configurable: true }); + // login/passkey's action wrapper reads loginName via request.clone().formData() BEFORE + // delegating to the factory action (which consumes the body). A clone of this synthetic + // body-less Request would NOT inherit the formData override, so hand back the same + // object — the override is repeatable (async () => form). + Object.defineProperty(request, 'clone', { value: () => request, configurable: true }); return { request, form }; } @@ -467,6 +480,19 @@ async function serializeResponse(res: unknown): Promise { } } + // passkey-hint loginName — signed, so parse via the real cookie module. A CLEARING + // Set-Cookie (empty signed value + Max-Age=0) parses to '' — distinct from null (untouched). + let passkeyHint: string | null = null; + const hintStr = setCookies.find((c) => c.startsWith('passkey-hint=')); + if (hintStr) { + try { + const parsed = await passkeyHintCookie.parse(hintStr.split(';')[0]); + passkeyHint = typeof parsed === 'string' ? parsed : (parsed ?? null); + } catch { + passkeyHint = null; + } + } + // fingerprintId is a BARE (unsigned) cookie value — decode it directly. let fingerprintId: string | null = null; const fpStr = setCookies.find((c) => c.startsWith('fingerprintId=')); @@ -474,6 +500,17 @@ async function serializeResponse(res: unknown): Promise { fingerprintId = decodeURIComponent(fpStr.split(';')[0].slice('fingerprintId='.length)); } + // Plain-JSON responses (Response.json from direct-fetch API actions like + // /login/passkey-discover) — capture the body so specs can assert on it. + let dataBody: unknown; + if ((res.headers.get('content-type') ?? '').includes('application/json')) { + try { + dataBody = await res.clone().json(); + } catch { + dataBody = undefined; + } + } + return { isResponse: true, status: res.status, @@ -482,12 +519,22 @@ async function serializeResponse(res: unknown): Promise { cookieEntries, setCookies, lastUsedLogin, + passkeyHint, fingerprintId, + dataBody, }; } - // react-router data() object: { data, init: { status } } - const d = res as { data?: unknown; init?: { status?: number } }; - return { isResponse: false, dataStatus: d?.init?.status, dataBody: d?.data }; + // react-router data() object: { data, init: { status, headers } } + const d = res as { data?: unknown; init?: { status?: number; headers?: HeadersInit } }; + const initHeaders = d?.init?.headers ? new Headers(d.init.headers) : null; + const getInitSetCookie = initHeaders + ? (initHeaders as Headers & { getSetCookie?: () => string[] }).getSetCookie + : undefined; + const dataSetCookies = + initHeaders && typeof getInitSetCookie === 'function' + ? getInitSetCookie.call(initHeaders) + : undefined; + return { isResponse: false, dataStatus: d?.init?.status, dataBody: d?.data, dataSetCookies }; } /** Parse a captured audit JSON line into a structured event (or null if it isn't one). */ @@ -1223,6 +1270,21 @@ export async function runScenario(s: Scenario): Promise { } break; } + case 'passkeyHintCheck': { + const op = s.passkeyHintOp; + if (!op) throw new Error('passkeyHintCheck requires passkeyHintOp'); + if (op === 'absent') { + outcome = { parsed: await passkeyHintCookie.parse(null) }; + } else if (op === 'clear') { + outcome = { setCookie: await clearPasskeyHint() }; + } else if (op === 'attrs') { + outcome = { setCookie: await serializePasskeyHint('alice@acme.test') }; + } else { + const sc = await serializePasskeyHint('alice@acme.test'); + outcome = { parsed: await passkeyHintCookie.parse(sc.split(';')[0].trim()) }; + } + break; + } case 'reauthIntentCheck': { const op = s.reauthOp; if (!op) throw new Error('reauthIntentCheck requires reauthOp'); @@ -2224,6 +2286,32 @@ export async function runScenario(s: Scenario): Promise { break; } + case 'passkeyDiscoverAction': { + const { request } = await buildHandlerRequest( + s.request ?? { url: 'http://localhost/id/login/passkey-discover', csrf: true } + ); + const result = await passkeyDiscoverAction({ + request, + params: {}, + context: {} as never, + } as never); + response = await serializeResponse(result); + break; + } + + case 'loginPasskeyAction': { + const { request } = await buildHandlerRequest( + s.request ?? { url: 'http://localhost/id/login/passkey', csrf: true } + ); + const result = await loginPasskeyAction({ + request, + params: {}, + context: {} as never, + } as never); + response = await serializeResponse(result); + break; + } + case 'loginVerifyEmailLoader': { const { request } = await buildHandlerRequest( s.request ?? { url: 'http://localhost/id/login/verify/email' } @@ -2596,6 +2684,15 @@ export async function runScenario(s: Scenario): Promise { break; } + case 'logoutAction': { + const { request } = await buildHandlerRequest( + s.request ?? { url: 'http://localhost/id/logout', csrf: true } + ); + const result = await logoutAction({ request, params: {}, context: {} as never } as never); + response = await serializeResponse(result); + break; + } + case 'passwordNewLoader': { const { request } = await buildHandlerRequest( s.request ?? { url: 'http://localhost/id/password/new' } diff --git a/cypress/support/node/scenario.ts b/cypress/support/node/scenario.ts index 761ab92e88..ce106fafc9 100644 --- a/cypress/support/node/scenario.ts +++ b/cypress/support/node/scenario.ts @@ -97,6 +97,9 @@ export interface RequestSpec { /** Signed last-used-login cookie value (e.g. 'email', 'passkey', 'idp:google'). Merged into the * Cookie header so loginLoader's readLastUsedLogin returns the value on the spec. */ lastUsedLogin?: string; + /** A loginName signed into a REAL `passkey-hint` cookie (readPasskeyHint reads it). + * Merged into the Cookie header alongside `sessions`. */ + passkeyHint?: string; } /** A serializable IdP intent, injected via the SSO callback's `retrieveIdpIntent` DI seam. @@ -209,6 +212,7 @@ export type ServiceFn = | 'cookieGuardCheck' | 'cookieRoundTripCheck' | 'lastUsedLoginCheck' + | 'passkeyHintCheck' | 'reauthIntentCheck' // select.server is stubbed in the browser bundle (a fake-only registry), so the REAL // provider-selection binding point (fake↔zitadel) is exercised node-side. @@ -230,6 +234,10 @@ export type ServiceFn = | 'loginLoader' | 'loginAction' | 'loginPasswordAction' + | 'loginPasskeyAction' + // /login/passkey-discover action: identity-resolution step of the usernameless + // discovery path — userHandle → user-bound challenge (opaque 400s on failure). + | 'passkeyDiscoverAction' | 'loginPasswordLoader' | 'securityKeyAction' | 'loginVerifyEmailLoader' @@ -261,6 +269,7 @@ export type ServiceFn = | 'accountsLoader' | 'accountsAction' | 'logoutLoader' + | 'logoutAction' | 'passwordNewLoader' | 'passwordNewAction' | 'passwordChangeLoader' @@ -632,6 +641,10 @@ export interface Scenario { /** serializeLastUsedLogin → parse round-trips + path scoping. outcome: { parsed } | { setCookie }. */ lastUsedOp?: 'roundTripIdp' | 'absent' | 'roundTripEmail' | 'roundTripPasskey' | 'scopedToId'; + // ── passkey-hint cookie (fn: 'passkeyHintCheck') ─────────────────────────── + /** serializePasskeyHint → parse round-trips, clear, attribute pinning. outcome: { parsed } | { setCookie }. */ + passkeyHintOp?: 'roundTrip' | 'absent' | 'clear' | 'attrs'; + // ── reauth-intent cookie + shared identity guard (fn: 'reauthIntentCheck') ─ /** serialize/read/clear/check. outcome: { value } | { cleared } | ReauthCheck. */ reauthOp?: @@ -671,11 +684,17 @@ export interface SerializedResponse { setCookies?: string[]; /** Parsed `last-used-login` token (e.g. `idp:`), or null when absent. */ lastUsedLogin?: string | null; + /** Parsed value of a `passkey-hint` Set-Cookie on the response: the written loginName, + * '' when the response CLEARS the hint (empty value + Max-Age=0), null/absent when untouched. */ + passkeyHint?: string | null; /** Raw `fingerprintId` cookie value, or null when no fingerprintId Set-Cookie was emitted. */ fingerprintId?: string | null; /** react-router data() object shape (non-Response path). */ dataStatus?: number; dataBody?: unknown; + /** Set-Cookie strings from a data() object's init.headers (loaders that both return data + * AND set cookies — e.g. /login's ceremony-session persist + hint clear). */ + dataSetCookies?: string[]; } export interface Verdict { From a7930f5d540b09c22898e106081da6b56add09cc Mon Sep 17 00:00:00 2001 From: Yahya Fakhroji Date: Fri, 31 Jul 2026 11:05:01 +0700 Subject: [PATCH 3/4] =?UTF-8?q?fix:=20apply=20PR=20#108=20review=20finding?= =?UTF-8?q?s=20=E2=80=94=20audit,=20kill=20switch,=20guard=20order,=20hard?= =?UTF-8?q?ening?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MEDIUM: - passkey-discover: reorder getUser → live-session guard → listAuthMethods (the local cookie guard now trips BEFORE the second provider round-trip) - passkey_discover audit events: success (hashed actor) + per-reason failures (disabled/invalid_input/no_user_handle/unresolved_user/live_session/ no_passkey_method/mint_failed/challenge_failed) — enumeration parity stays caller-facing only - AUTH_PASSKEY_DISCOVERY_ENABLED kill switch (env, default ON; 'false'/'0' disables) gating the loader arm + the discover action; documented in .env.example LOW: - PasskeyDiscoverData moved to a shared types-only module (route + hook) - decodeUserHandle: strict base64url charset gate before Buffer's lenient decode - identity-challenge: web-safe base64url (no Buffer) — portable if ever client-bundled - use-conditional-passkey: capacity watermark comment (next mode ⇒ split) - specs: kill-switch cases (action + loader), audit assertions, beginDiscovery single-flight (delayed-reply harness knob) --- .claude/reviews/pr-108-review.md | 72 ++++++++++++++++++ .env.example | 5 ++ app/hooks/use-conditional-passkey.ts | 15 ++-- app/modules/i18n/locales/en.po | 34 ++++----- app/resources/webauthn/identity-challenge.ts | 11 ++- .../webauthn/passkey-discover.types.ts | 14 ++++ app/routes/login/index.tsx | 5 +- app/routes/login/passkey-discover.tsx | 73 ++++++++++++------- app/server/infra/env.server.ts | 9 +++ .../use-conditional-passkey-discovery.cy.tsx | 21 +++++- .../routes/login/discovery-loader.cy.ts | 11 +++ .../routes/login/passkey-back-link.cy.tsx | 8 +- .../routes/login/passkey-discover.cy.ts | 39 +++++++++- 13 files changed, 255 insertions(+), 62 deletions(-) create mode 100644 .claude/reviews/pr-108-review.md create mode 100644 app/resources/webauthn/passkey-discover.types.ts diff --git a/.claude/reviews/pr-108-review.md b/.claude/reviews/pr-108-review.md new file mode 100644 index 0000000000..3748809b0d --- /dev/null +++ b/.claude/reviews/pr-108-review.md @@ -0,0 +1,72 @@ +# PR Review: #108 — feat(login): usernameless passkey sign-in + +**Reviewed**: 2026-07-31 +**Author**: yahya +**Branch**: feat/usernameless-passkey-login → main +**Focus**: code efficiency, code structure, clean code (per request) +**Decision**: APPROVE-equivalent (posted as COMMENT — self-authored PR) + +## Summary + +Two well-scoped commits (feat 28 files / test 25 files). Structure follows the +repo's route/resource/hook layering; the shared mint block extraction +(`armUserBoundChallenge`) is the right seam and both callers read cleanly. +No CRITICAL or HIGH findings. Two MEDIUMs worth fixing before production, +six LOWs recorded for follow-up. + +## Findings + +### CRITICAL — None + +### HIGH — None + +### MEDIUM + +1. **Efficiency — `passkey-discover.tsx:67-79`: provider call before the cheap + guard.** `listAuthMethods` (Zitadel round-trip) runs before `readSessions` + + the live-session guard (local cookie parse). Reorder to + `getUser → sessions guard → listAuthMethods` and the crafted-POST/suppressed + path costs one provider call instead of two. Five-line change, no behavior + difference on the happy path. +2. **Observability — no dedicated audit event for discover outcomes.** Only the + inner challenge-failure audit fires; success and the opaque-400 reasons are + invisible in logs. Fine for staging; add an `auth_event` (+ ideally the env + kill-switch from the spec notes) before the production flip. + +### LOW + +3. `use-conditional-passkey.ts` (342 lines): dual-mode + staged dispatch + retry + matrix is inherently stateful (5 refs, 2 fetchers). Well-commented and + latch-guarded; spec decision §1 chose the single hook deliberately. Rule of + thumb going forward: the next mode/feature added here should trigger a split. +4. `DiscoverResponse` duplicated in the hook rather than imported from the route + module — deliberate (keeps server-only imports out of the client graph) and + documented inline. Acceptable; a shared types-only module would also work. +5. `decodeUserHandle`: `Buffer.from(x, 'base64url')` decodes leniently, so + malformed input can pass garbage to `getUser`. Harmless (opaque 400 either + way) but a strict charset check would be tidier. +6. `identity-challenge.ts` uses `Buffer` — server-safe today (loader-only + import) but breaks if ever imported client-side. Worth a comment or a + web-safe base64url encoding. +7. Test gap: `beginDiscovery` single-flight (re-click while 'submitting' + returns false) has no spec. +8. Rate-limit budget: one discovery login consumes 2 of the shared 10/5-min + webauthn budget (documented in rate-limit.ts). Fine by design — watch 429 + rates at launch. + +## Validation Results (identical tree, this session) + +| Check | Result | +|---|---| +| Type check (app + cypress) | Pass | +| ESLint / Prettier / i18n (lefthook gate) | Pass | +| Component suite | Pass — 739/739 | +| E2E (cold fake server): passkey journeys, core-signin, hydrated-submit, verify-otp | Pass — 22/22 | +| Manual staging (real Zitadel): quiet load, button picker, cancel copy | Pass | + +## Files Reviewed + +All 54 changed files (28 source via feat commit, 25 specs via test commit, +en.po regenerated). Key modules read in full: passkey-discover.tsx, +identity-challenge.ts, use-conditional-passkey.ts, webauthn.service.ts, +login/index.tsx, passkey-hint.ts, rate-limit.ts, harness/scenario support. diff --git a/.env.example b/.env.example index e3fc109bd6..51256dac5b 100644 --- a/.env.example +++ b/.env.example @@ -80,6 +80,11 @@ AUTH_PROVIDER=fake # the email sign-in link + password-reset (fail-safe; Zitadel SMTP is NOT the signal here). # AUTH_EMAIL_DELIVERY_ENABLED=false +# Usernameless passkey discovery kill switch — INVERSE polarity (default ON): unset keeps +# the /login discovery arm + /login/passkey-discover live; only 'false'/'0' disables. +# Exists for incident mitigation by config instead of a revert deploy. +# AUTH_PASSKEY_DISCOVERY_ENABLED=true + # Require email verification on signup. Unset/false = verification is skipped. # KNOWN GAP: read raw from process.env in app/server/env.ts — NOT in the validated Zod schema, # so it has no typed default and a typo silently means "off". See docs/operations/configuration.md. diff --git a/app/hooks/use-conditional-passkey.ts b/app/hooks/use-conditional-passkey.ts index 0f78854ff6..8c6d1c7c31 100644 --- a/app/hooks/use-conditional-passkey.ts +++ b/app/hooks/use-conditional-passkey.ts @@ -1,6 +1,7 @@ import { CYPRESS_CREDENTIAL } from '@/components/webauthn-button/webauthn-button'; import { unwrapPublicKey } from '@/hooks/use-passkey-login-ceremony'; import { APP_BASENAME } from '@/resources/shared/app-basename'; +import type { PasskeyDiscoverData } from '@/resources/webauthn/passkey-discover.types'; import { marshalAssertion, isWebAuthnSupported, @@ -15,13 +16,13 @@ import { useFetcher } from 'react-router'; export type ConditionalPasskeyPhase = 'idle' | 'armed' | 'submitting' | 'done'; -/** The discover action's success payload (declared locally — importing the route - * module would drag its server-only imports into the client graph). */ -interface DiscoverResponse { - loginName: string; - csrfToken: string; - publicKeyCredentialRequestOptions: unknown; -} +// CAPACITY WATERMARK (review #108): two modes + explicit begin + staged dispatch is +// this hook's ceiling — the NEXT mode or feature added here should split the +// hinted/discovery halves instead of growing the shared latch matrix further. + +/** The discover action's success payload (shared types-only module — the route + * module itself carries server-only imports the client graph must not see). */ +type DiscoverResponse = PasskeyDiscoverData; export interface ConditionalPasskeyInput { /** Master switch — false keeps the hook fully inert (no detection, no arming). */ diff --git a/app/modules/i18n/locales/en.po b/app/modules/i18n/locales/en.po index 406068fc0d..b63c5fd76f 100644 --- a/app/modules/i18n/locales/en.po +++ b/app/modules/i18n/locales/en.po @@ -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:473 +#: app/routes/login/index.tsx:474 msgid "An account with this email already exists — sign in to continue." msgstr "An account with this email already exists — sign in to continue." @@ -180,7 +180,7 @@ 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:469 +#: app/routes/login/index.tsx:470 msgid "Choose your login method" msgstr "Choose your login method" @@ -211,7 +211,7 @@ msgid "Connected accounts" msgstr "Connected accounts" #: app/routes/device/index.tsx:73 -#: app/routes/login/index.tsx:594 +#: app/routes/login/index.tsx:595 #: app/routes/signup/index.tsx:291 msgid "Continue" msgstr "Continue" @@ -241,7 +241,7 @@ msgstr "Couldn't verify" msgid "Create a new account" msgstr "Create a new account" -#: app/routes/login/index.tsx:639 +#: app/routes/login/index.tsx:640 #: app/routes/signup/password.tsx:237 msgid "Create account" msgstr "Create account" @@ -268,8 +268,8 @@ msgstr "Device code" msgid "Device denied" msgstr "Device denied" -#: app/routes/login/index.tsx:426 -#: app/routes/login/index.tsx:441 +#: app/routes/login/index.tsx:427 +#: app/routes/login/index.tsx:442 #: app/routes/signup/index.tsx:250 #: app/routes/signup/index.tsx:275 msgid "Email" @@ -283,8 +283,8 @@ msgstr "Email code" msgid "Email me a code" msgstr "Email me a code" -#: app/routes/login/index.tsx:605 -#: app/routes/login/index.tsx:615 +#: app/routes/login/index.tsx:606 +#: app/routes/login/index.tsx:616 #: app/routes/login/method.tsx:238 #: app/routes/signup/method.tsx:339 msgid "Email me a sign-in link" @@ -306,7 +306,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:425 +#: app/routes/login/index.tsx:426 msgid "Email, phone, or username" msgstr "Email, phone, or username" @@ -446,7 +446,7 @@ msgstr "No signed-in accounts." msgid "Not now" msgstr "Not now" -#: app/routes/login/index.tsx:637 +#: app/routes/login/index.tsx:638 msgid "Not registered?" msgstr "Not registered?" @@ -472,7 +472,7 @@ 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:535 +#: app/routes/login/index.tsx:536 #: app/routes/login/method.tsx:221 #: app/routes/reauth.tsx:310 #: app/routes/setup/mfa.tsx:46 @@ -543,8 +543,8 @@ 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:428 -#: app/routes/login/index.tsx:443 +#: app/routes/login/index.tsx:429 +#: app/routes/login/index.tsx:444 msgid "Phone" msgstr "Phone" @@ -702,7 +702,7 @@ msgstr "Sign out of" msgid "Sign out other sessions" msgstr "Sign out other sessions" -#: app/routes/login/index.tsx:627 +#: app/routes/login/index.tsx:628 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." @@ -871,8 +871,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:429 -#: app/routes/login/index.tsx:444 +#: app/routes/login/index.tsx:430 +#: app/routes/login/index.tsx:445 #: app/routes/sso/ldap.tsx:76 msgid "Username" msgstr "Username" @@ -943,7 +943,7 @@ msgstr "We've sent a password reset link to <0>{0}" 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:466 +#: app/routes/login/index.tsx:467 msgid "Welcome" msgstr "Welcome" diff --git a/app/resources/webauthn/identity-challenge.ts b/app/resources/webauthn/identity-challenge.ts index dcb4b90398..6c14e2de8a 100644 --- a/app/resources/webauthn/identity-challenge.ts +++ b/app/resources/webauthn/identity-challenge.ts @@ -12,12 +12,21 @@ /** 2 minutes — generous for an autofill tap; browsers may ignore it under conditional mediation. */ const IDENTITY_CHALLENGE_TIMEOUT_MS = 120_000; +/** Web-safe base64url (no Buffer): keeps this module portable if it ever lands in + * the client graph — today it is loader-only, but nothing enforces that. */ +function toBase64Url(bytes: Uint8Array): string { + return btoa(String.fromCharCode(...bytes)) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); +} + export function mintIdentityChallenge(rpId: string): { publicKey: Record } { const bytes = new Uint8Array(32); crypto.getRandomValues(bytes); return { publicKey: { - challenge: Buffer.from(bytes).toString('base64url'), + challenge: toBase64Url(bytes), rpId, // Empty on purpose — the browser offers EVERY resident key for this RP. allowCredentials: [], diff --git a/app/resources/webauthn/passkey-discover.types.ts b/app/resources/webauthn/passkey-discover.types.ts new file mode 100644 index 0000000000..d794a1c8cd --- /dev/null +++ b/app/resources/webauthn/passkey-discover.types.ts @@ -0,0 +1,14 @@ +// app/resources/webauthn/passkey-discover.types.ts +// +// Types-only module for the /login/passkey-discover contract, shared by the route +// action (server) and useConditionalPasskey (client). Kept separate from the route +// module so the client hook never imports the action's server-only dependencies. + +/** Success payload: the resolved identity + the REAL user-bound challenge. */ +export interface PasskeyDiscoverData { + loginName: string; + csrfToken: string; + publicKeyCredentialRequestOptions: unknown; +} + +export type PasskeyDiscoverError = { error: 'INVALID_INPUT' | 'DISCOVERY_FAILED' }; diff --git a/app/routes/login/index.tsx b/app/routes/login/index.tsx index 61c333b630..cbc384c74b 100644 --- a/app/routes/login/index.tsx +++ b/app/routes/login/index.tsx @@ -147,9 +147,10 @@ export async function loader({ request }: LoaderFunctionArgs) { // persisted — the identity tap posts to /login/passkey-discover, which mints // the real user-bound challenge only after a passkey was actually tapped. // Suppressed when ANY live session exists (arming is inference; a logged-in - // visitor is better served by the ordinary page — spec, open decision §2). + // visitor is better served by the ordinary page — spec, open decision §2) and + // by the operational kill switch (env, default ON — incident mitigation). const sessions = await readSessions(request); - if (listSessions(sessions, Date.now()).length === 0) { + if (env.AUTH_PASSKEY_DISCOVERY_ENABLED && listSessions(sessions, Date.now()).length === 0) { identityDiscovery = { publicKeyCredentialRequestOptions: mintIdentityChallenge(url.hostname), }; diff --git a/app/routes/login/passkey-discover.tsx b/app/routes/login/passkey-discover.tsx index d2867d70ec..6dd08fe807 100644 --- a/app/routes/login/passkey-discover.tsx +++ b/app/routes/login/passkey-discover.tsx @@ -5,42 +5,46 @@ // The posted assertion is an UNTRUSTED identity claim — its signature is never // checked; only response.userHandle (== Zitadel userId, probe-verified) is read. // Every user-dependent failure collapses into ONE opaque 400 so this endpoint -// leaks exactly what the identifier form leaks (enumeration parity). The -// authenticating ceremony is the SECOND assertion, verified by Zitadel through -// the unchanged /login/passkey action. +// leaks exactly what the identifier form leaks (enumeration parity) — the AUDIT +// event carries the real reason instead. The authenticating ceremony is the +// SECOND assertion, verified by Zitadel through the unchanged /login/passkey +// action. // RESPONSE SHAPE: plain Response.json (NOT data()) — the client calls this action // with a direct fetch(), not an RR fetcher, so the body must be raw JSON rather // than the single-fetch envelope. See useConditionalPasskey.submitDiscover for why // (fetcher lazy route discovery reloads the page mid-ceremony when the client // module load hiccups; a pure JSON API hop needs none of that machinery). import { readSessions, listSessions } from '@/modules/auth/session/cookie'; +import type { PasskeyDiscoverData } from '@/resources/webauthn/passkey-discover.types'; import { armUserBoundChallenge } from '@/resources/webauthn/webauthn.service'; import { providerForRequest } from '@/server/auth-context.server'; import { getCsrfToken, assertCsrf } from '@/server/csrf'; +import { env } from '@/server/infra/env.server'; +import { logAuthEvent, hashActor } from '@/server/observability'; import { type ActionFunctionArgs } from 'react-router'; import { z } from 'zod'; const discoverSchema = z.object({ credential: z.string().min(1) }); -export interface PasskeyDiscoverData { - loginName: string; - csrfToken: string; - publicKeyCredentialRequestOptions: unknown; -} - -export type PasskeyDiscoverError = { error: 'INVALID_INPUT' | 'DISCOVERY_FAILED' }; - // Sanity bounds only — a userHandle is at most 64 bytes by WebAuthn spec; the // base64url of that is under 128 chars. Anything outside is a shape violation. const MAX_USER_HANDLE_B64 = 128; const MAX_USER_HANDLE_BYTES = 64; +// Strict base64url alphabet — Buffer decodes leniently (silently drops invalid +// chars), so gate the charset ourselves rather than pass garbage to getUser. +const BASE64URL_RE = /^[A-Za-z0-9_-]+$/; /** Read the assertion's userHandle (base64url → utf8 Zitadel userId). Null on any shape violation. */ function decodeUserHandle(credentialJson: string): string | null { try { const cred = JSON.parse(credentialJson) as { response?: { userHandle?: unknown } }; const raw = cred.response?.userHandle; - if (typeof raw !== 'string' || raw.length === 0 || raw.length > MAX_USER_HANDLE_B64) { + if ( + typeof raw !== 'string' || + raw.length === 0 || + raw.length > MAX_USER_HANDLE_B64 || + !BASE64URL_RE.test(raw) + ) { return null; } const decoded = Buffer.from(raw, 'base64url').toString('utf8'); @@ -55,28 +59,43 @@ export async function action({ request }: ActionFunctionArgs) { const form = await request.formData(); await assertCsrf(request, form); - const parsed = discoverSchema.safeParse(Object.fromEntries(form)); - if (!parsed.success) return Response.json({ error: 'INVALID_INPUT' }, { status: 400 }); - // ONE opaque failure for everything user-dependent — "no such user", "no passkey - // method", "mint failed" and shape violations must be indistinguishable. - const opaque = () => Response.json({ error: 'DISCOVERY_FAILED' }, { status: 400 }); + // method", "mint failed" and shape violations must be indistinguishable to the + // CALLER. The audit event carries the real reason for operators. + const opaque = (reason: string) => { + logAuthEvent('passkey_discover', 'failure', { reason }); + return Response.json({ error: 'DISCOVERY_FAILED' }, { status: 400 }); + }; + + // Operational kill switch (env, default ON) — same opaque shape as every other + // failure so flipping it mid-incident does not create a new observable state. + if (!env.AUTH_PASSKEY_DISCOVERY_ENABLED) return opaque('disabled'); + + const parsed = discoverSchema.safeParse(Object.fromEntries(form)); + if (!parsed.success) { + logAuthEvent('passkey_discover', 'failure', { reason: 'invalid_input' }); + return Response.json({ error: 'INVALID_INPUT' }, { status: 400 }); + } const userHandle = decodeUserHandle(parsed.data.credential); - if (!userHandle) return opaque(); // non-resident key / malformed — client treats as non-event + if (!userHandle) return opaque('no_user_handle'); // non-resident key / malformed const user = await provider.getUser(userHandle); - if (!user) return opaque(); - if (!(await provider.listAuthMethods(user.id)).includes('passkey')) return opaque(); + if (!user) return opaque('unresolved_user'); + // Cheap LOCAL guard before the second provider round-trip: armUserBoundChallenge's + // caller contract + crafted-POST protection. The loader suppresses discovery + // whenever a live session exists, so a live entry here means the POST bypassed + // the page. Refuse rather than let the arm supersede a LIVE cookie entry. const sessions = await readSessions(request); - // armUserBoundChallenge caller contract + crafted-POST guard: the loader suppresses - // discovery whenever a live session exists, so a live entry here means the POST - // bypassed the page. Refuse rather than let the arm supersede a LIVE cookie entry. const hasLiveSession = listSessions(sessions, Date.now()).some( (s) => s.loginName.toLowerCase() === user.loginName.toLowerCase() ); - if (hasLiveSession) return opaque(); + if (hasLiveSession) return opaque('live_session'); + + if (!(await provider.listAuthMethods(user.id)).includes('passkey')) { + return opaque('no_passkey_method'); + } let armed; try { @@ -88,9 +107,11 @@ export async function action({ request }: ActionFunctionArgs) { new URL(request.url).hostname ); } catch { - return opaque(); // deactivated user / provider hiccup — enumeration parity + return opaque('mint_failed'); // deactivated user / provider hiccup } - if (!armed) return opaque(); + if (!armed) return opaque('challenge_failed'); + + logAuthEvent('passkey_discover', 'success', { actor: hashActor(user.loginName) }); const [csrfToken, csrfSetCookie] = await getCsrfToken(request); const headers = new Headers(); diff --git a/app/server/infra/env.server.ts b/app/server/infra/env.server.ts index 5e5218c967..c66ecade85 100644 --- a/app/server/infra/env.server.ts +++ b/app/server/infra/env.server.ts @@ -84,6 +84,15 @@ const schema = z .string() .optional() .transform((v) => v === 'true' || v === '1'), + // Operational kill switch for the usernameless discovery entry points (the /login + // loader's identity-challenge arm and the /login/passkey-discover action). Default ON + // — unset keeps the feature live; ONLY the explicit strings 'false'/'0' disable it. + // Inverse polarity from the fail-safe-off flags above on purpose: this exists so an + // incident can be mitigated by config instead of a revert deploy, not to gate rollout. + AUTH_PASSKEY_DISCOVERY_ENABLED: z + .string() + .optional() + .transform((v) => v !== 'false' && v !== '0'), // Whether identity-provider UNLINK is permitted in this environment. Defaults to false // (fail-closed): only the exact string 'true' enables it. Was an unvalidated raw // process.env read in sso.service.ts. diff --git a/cypress/component/hooks/use-conditional-passkey-discovery.cy.tsx b/cypress/component/hooks/use-conditional-passkey-discovery.cy.tsx index 91f1ed9377..55e316cf6e 100644 --- a/cypress/component/hooks/use-conditional-passkey-discovery.cy.tsx +++ b/cypress/component/hooks/use-conditional-passkey-discovery.cy.tsx @@ -49,22 +49,26 @@ function mountHarness({ enabled = true, options = IDENTITY_OPTIONS as unknown, discoverReply = undefined as { statusCode: number; body: unknown } | undefined, + // Holds the discover reply open so a test can act WHILE phase === 'submitting' + // (the fake path otherwise completes the whole flow synchronously). + discoverDelayMs = 0, verifyResult = undefined as unknown, // undefined → redirect('/signed-in') } = {}) { const discoverPosts: string[] = []; const verifyPosts: Array> = []; cy.intercept('POST', '**/id/login/passkey-discover', (req) => { discoverPosts.push(String(req.body)); - req.reply( - discoverReply ?? { + req.reply({ + ...(discoverReply ?? { statusCode: 200, body: { loginName: RESOLVED, csrfToken: 'tok-discover', publicKeyCredentialRequestOptions: REAL_OPTIONS, }, - } - ); + }), + ...(discoverDelayMs ? { delay: discoverDelayMs } : {}), + }); }).as('discover'); const router = createMemoryRouter( [ @@ -168,6 +172,15 @@ describe('useConditionalPasskey — discovery mode', () => { cy.then(() => expect(verifyPosts).to.have.length(1)); }); + it('beginDiscovery is single-flight — a second click while submitting is a no-op', () => { + const { discoverPosts } = mountHarness({ discoverDelayMs: 300 }); + cy.get('[data-testid="begin"]').click(); + cy.get('[data-testid="phase"]').should('have.text', 'submitting'); + cy.get('[data-testid="begin"]').click(); // phase 'submitting' → beginDiscovery() === false + cy.get('[data-testid="signed-in"]').should('exist'); + cy.then(() => expect(discoverPosts).to.have.length(1)); + }); + it('opaque 400 during beginDiscovery surfaces a reason (message, not silence)', () => { const { verifyPosts } = mountHarness({ discoverReply: { statusCode: 400, body: { error: 'DISCOVERY_FAILED' } }, diff --git a/cypress/component/routes/login/discovery-loader.cy.ts b/cypress/component/routes/login/discovery-loader.cy.ts index a83e1e1cef..8807347e73 100644 --- a/cypress/component/routes/login/discovery-loader.cy.ts +++ b/cypress/component/routes/login/discovery-loader.cy.ts @@ -82,6 +82,17 @@ describe('/login loader — identity-discovery arming', () => { }); }); + it('kill switch (AUTH_PASSKEY_DISCOVERY_ENABLED=false) suppresses discovery arming', () => { + callService({ + fn: 'loginLoader', + provider: 'singleton', + env: { AUTH_PASSKEY_DISCOVERY_ENABLED: 'false' }, + request: { url: URL_BASE }, + }).then((v) => { + expect((v.response?.dataBody as LoaderBody).identityDiscovery).to.equal(null); + }); + }); + it('hint present → hinted path arms, discovery stays dark', () => { callService({ fn: 'loginLoader', diff --git a/cypress/component/routes/login/passkey-back-link.cy.tsx b/cypress/component/routes/login/passkey-back-link.cy.tsx index 0c0c6915ba..53ab784624 100644 --- a/cypress/component/routes/login/passkey-back-link.cy.tsx +++ b/cypress/component/routes/login/passkey-back-link.cy.tsx @@ -66,9 +66,9 @@ describe('/login/passkey — Back link', () => { ], { initialEntries: ['/login/passkey?loginName=mia%40acme.test'] } ); - mount( - withI18n() - ); - cy.contains('a', 'Back').should('have.attr', 'href').and('include', 'loginName=mia%40acme.test'); + mount(withI18n()); + cy.contains('a', 'Back') + .should('have.attr', 'href') + .and('include', 'loginName=mia%40acme.test'); }); }); diff --git a/cypress/component/routes/login/passkey-discover.cy.ts b/cypress/component/routes/login/passkey-discover.cy.ts index 0d32c8b269..2f66c69362 100644 --- a/cypress/component/routes/login/passkey-discover.cy.ts +++ b/cypress/component/routes/login/passkey-discover.cy.ts @@ -63,6 +63,33 @@ describe('/login/passkey-discover action', () => { cookies.some((c) => c.startsWith('passkey-hint=')), 'no hint write on discover' ).to.equal(false); + // Observability: success emits the audit event with a HASHED actor. + const success = v.audit?.find( + (a) => a.event === 'passkey_discover' && a.outcome === 'success' + ) as { actor?: string } | undefined; + expect(success, 'passkey_discover success audit').to.exist; + expect(success?.actor).to.be.a('string').and.not.contain('@'); + }); + }); + + it('kill switch (AUTH_PASSKEY_DISCOVERY_ENABLED=false) → the SAME opaque 400', () => { + callService({ + fn: 'passkeyDiscoverAction', + provider: 'singleton', + env: { AUTH_PASSKEY_DISCOVERY_ENABLED: 'false' }, + request: { url: URL, form: { credential: assertionWith(B64_U5) }, csrf: true }, + }).then((v) => { + expect(v.response?.status).to.equal(400); + expect((v.response?.dataBody as { error?: string }).error).to.equal('DISCOVERY_FAILED'); + expect( + v.audit?.some( + (a) => + a.event === 'passkey_discover' && + a.outcome === 'failure' && + (a as { reason?: string }).reason === 'disabled' + ), + 'audited as disabled' + ).to.equal(true); }); }); @@ -77,7 +104,7 @@ describe('/login/passkey-discover action', () => { }); }); - it('unknown userHandle → the SAME opaque DISCOVERY_FAILED 400', () => { + it('unknown userHandle → the SAME opaque DISCOVERY_FAILED 400, real reason in the audit', () => { callService({ fn: 'passkeyDiscoverAction', provider: 'singleton', @@ -85,6 +112,16 @@ describe('/login/passkey-discover action', () => { }).then((v) => { expect(v.response?.status).to.equal(400); expect((v.response?.dataBody as { error?: string }).error).to.equal('DISCOVERY_FAILED'); + // Enumeration parity is CALLER-facing only — operators get the specific reason. + expect( + v.audit?.some( + (a) => + a.event === 'passkey_discover' && + a.outcome === 'failure' && + (a as { reason?: string }).reason === 'unresolved_user' + ), + 'audited as unresolved_user' + ).to.equal(true); }); }); From 3b3db40d61826d4e8bbb27a51a4bb362d862dda7 Mon Sep 17 00:00:00 2001 From: Yahya Fakhroji Date: Fri, 31 Jul 2026 11:11:32 +0700 Subject: [PATCH 4/4] docs: generalize code comments; drop the tracked review artifact MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Comments across the branch now describe behavior in plain terms — internal process references (spec sections, decision numbers, task/phase tags, review pointers, upstream issue links) removed - .claude/reviews/pr-108-review.md untracked (local review artifact, committed by accident) --- .claude/reviews/pr-108-review.md | 72 ------------------- app/hooks/use-conditional-passkey.ts | 10 +-- app/modules/i18n/locales/en.po | 14 ++-- .../session/session-logout.service.ts | 2 +- app/resources/session/session.service.ts | 2 +- app/resources/webauthn/identity-challenge.ts | 4 +- app/resources/webauthn/webauthn.service.ts | 2 +- app/routes/accounts.tsx | 2 +- app/routes/login/index.tsx | 29 ++++---- app/routes/login/passkey-discover.tsx | 6 +- .../use-conditional-passkey-discovery.cy.tsx | 5 +- .../routes/login/discovery-loader.cy.ts | 2 +- cypress/component/routes/login/index.cy.tsx | 22 +++--- .../login/passkey-button-visibility.cy.tsx | 3 +- .../routes/login/passkey-discover.cy.ts | 4 +- cypress/e2e/passkey-conditional.cy.ts | 6 +- cypress/e2e/passkey-discovery.cy.ts | 5 +- 17 files changed, 56 insertions(+), 134 deletions(-) delete mode 100644 .claude/reviews/pr-108-review.md diff --git a/.claude/reviews/pr-108-review.md b/.claude/reviews/pr-108-review.md deleted file mode 100644 index 3748809b0d..0000000000 --- a/.claude/reviews/pr-108-review.md +++ /dev/null @@ -1,72 +0,0 @@ -# PR Review: #108 — feat(login): usernameless passkey sign-in - -**Reviewed**: 2026-07-31 -**Author**: yahya -**Branch**: feat/usernameless-passkey-login → main -**Focus**: code efficiency, code structure, clean code (per request) -**Decision**: APPROVE-equivalent (posted as COMMENT — self-authored PR) - -## Summary - -Two well-scoped commits (feat 28 files / test 25 files). Structure follows the -repo's route/resource/hook layering; the shared mint block extraction -(`armUserBoundChallenge`) is the right seam and both callers read cleanly. -No CRITICAL or HIGH findings. Two MEDIUMs worth fixing before production, -six LOWs recorded for follow-up. - -## Findings - -### CRITICAL — None - -### HIGH — None - -### MEDIUM - -1. **Efficiency — `passkey-discover.tsx:67-79`: provider call before the cheap - guard.** `listAuthMethods` (Zitadel round-trip) runs before `readSessions` + - the live-session guard (local cookie parse). Reorder to - `getUser → sessions guard → listAuthMethods` and the crafted-POST/suppressed - path costs one provider call instead of two. Five-line change, no behavior - difference on the happy path. -2. **Observability — no dedicated audit event for discover outcomes.** Only the - inner challenge-failure audit fires; success and the opaque-400 reasons are - invisible in logs. Fine for staging; add an `auth_event` (+ ideally the env - kill-switch from the spec notes) before the production flip. - -### LOW - -3. `use-conditional-passkey.ts` (342 lines): dual-mode + staged dispatch + retry - matrix is inherently stateful (5 refs, 2 fetchers). Well-commented and - latch-guarded; spec decision §1 chose the single hook deliberately. Rule of - thumb going forward: the next mode/feature added here should trigger a split. -4. `DiscoverResponse` duplicated in the hook rather than imported from the route - module — deliberate (keeps server-only imports out of the client graph) and - documented inline. Acceptable; a shared types-only module would also work. -5. `decodeUserHandle`: `Buffer.from(x, 'base64url')` decodes leniently, so - malformed input can pass garbage to `getUser`. Harmless (opaque 400 either - way) but a strict charset check would be tidier. -6. `identity-challenge.ts` uses `Buffer` — server-safe today (loader-only - import) but breaks if ever imported client-side. Worth a comment or a - web-safe base64url encoding. -7. Test gap: `beginDiscovery` single-flight (re-click while 'submitting' - returns false) has no spec. -8. Rate-limit budget: one discovery login consumes 2 of the shared 10/5-min - webauthn budget (documented in rate-limit.ts). Fine by design — watch 429 - rates at launch. - -## Validation Results (identical tree, this session) - -| Check | Result | -|---|---| -| Type check (app + cypress) | Pass | -| ESLint / Prettier / i18n (lefthook gate) | Pass | -| Component suite | Pass — 739/739 | -| E2E (cold fake server): passkey journeys, core-signin, hydrated-submit, verify-otp | Pass — 22/22 | -| Manual staging (real Zitadel): quiet load, button picker, cancel copy | Pass | - -## Files Reviewed - -All 54 changed files (28 source via feat commit, 25 specs via test commit, -en.po regenerated). Key modules read in full: passkey-discover.tsx, -identity-challenge.ts, use-conditional-passkey.ts, webauthn.service.ts, -login/index.tsx, passkey-hint.ts, rate-limit.ts, harness/scenario support. diff --git a/app/hooks/use-conditional-passkey.ts b/app/hooks/use-conditional-passkey.ts index 8c6d1c7c31..ac0e4f0dc8 100644 --- a/app/hooks/use-conditional-passkey.ts +++ b/app/hooks/use-conditional-passkey.ts @@ -16,7 +16,7 @@ import { useFetcher } from 'react-router'; export type ConditionalPasskeyPhase = 'idle' | 'armed' | 'submitting' | 'done'; -// CAPACITY WATERMARK (review #108): two modes + explicit begin + staged dispatch is +// NOTE: two modes + explicit begin + staged dispatch is // this hook's ceiling — the NEXT mode or feature added here should split the // hinted/discovery halves instead of growing the shared latch matrix further. @@ -31,7 +31,7 @@ export interface ConditionalPasskeyInput { * assertion submits straight to the verify action. 'discovery': the loader minted * a SELF-issued identity challenge; the first assertion is an identity claim that * posts to /login/passkey-discover, whose response carries the real challenge for - * a modal second ceremony (spec: usernameless discovery design). */ + * a modal second ceremony. */ mode?: 'hinted' | 'discovery'; /** The hinted user the pre-minted challenge belongs to (loader-resolved loginName). * Empty in discovery mode — the discover response resolves it. */ @@ -89,7 +89,7 @@ export function useConditionalPasskey(input: ConditionalPasskeyInput) { } | null>(null); const [phase, setPhase] = useState('idle'); // Failure copy for EXPLICIT (button-initiated) discovery only — the ambient - // conditional flow keeps every failure a silent non-event (spec error matrix). + // conditional flow keeps every failure a silent non-event. const [reason, setReason] = useState(null); const armedRef = useRef(false); // one-shot arming (also latched by abort()) const retriedRef = useRef(false); // expired-challenge re-arm, once (hinted only) @@ -181,7 +181,7 @@ export function useConditionalPasskey(input: ConditionalPasskeyInput) { // Opaque 400 (unknown user, no passkey, mint failure). Ambient flow: designed // non-event. Explicit (button) flow: the user acted, so say something — the // 'not-allowed' copy ("cancelled, or no passkey for this account is available") - // is the truthful fit (spec, open decision §3). + // is the truthful fit. if (explicitRef.current) setReason('not-allowed'); setPhase('done'); return; @@ -210,7 +210,7 @@ export function useConditionalPasskey(input: ConditionalPasskeyInput) { ); /** - * EXPLICIT discovery (spec, open decision §3 as built): the Passkey button runs the + * EXPLICIT discovery: the Passkey button runs the * same discovery pipeline MODALLY — credentials.get over the loader's self-minted * options WITHOUT conditional mediation, so the browser opens its native picker * (including cross-device QR). Retires the ambient ceremony first (explicit intent diff --git a/app/modules/i18n/locales/en.po b/app/modules/i18n/locales/en.po index b63c5fd76f..47dd60b957 100644 --- a/app/modules/i18n/locales/en.po +++ b/app/modules/i18n/locales/en.po @@ -211,7 +211,7 @@ msgid "Connected accounts" msgstr "Connected accounts" #: app/routes/device/index.tsx:73 -#: app/routes/login/index.tsx:595 +#: app/routes/login/index.tsx:594 #: app/routes/signup/index.tsx:291 msgid "Continue" msgstr "Continue" @@ -241,7 +241,7 @@ msgstr "Couldn't verify" msgid "Create a new account" msgstr "Create a new account" -#: app/routes/login/index.tsx:640 +#: app/routes/login/index.tsx:639 #: app/routes/signup/password.tsx:237 msgid "Create account" msgstr "Create account" @@ -283,8 +283,8 @@ msgstr "Email code" msgid "Email me a code" msgstr "Email me a code" -#: app/routes/login/index.tsx:606 -#: app/routes/login/index.tsx:616 +#: app/routes/login/index.tsx:605 +#: app/routes/login/index.tsx:615 #: app/routes/login/method.tsx:238 #: app/routes/signup/method.tsx:339 msgid "Email me a sign-in link" @@ -446,7 +446,7 @@ msgstr "No signed-in accounts." msgid "Not now" msgstr "Not now" -#: app/routes/login/index.tsx:638 +#: app/routes/login/index.tsx:637 msgid "Not registered?" msgstr "Not registered?" @@ -472,7 +472,7 @@ 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:536 +#: app/routes/login/index.tsx:535 #: app/routes/login/method.tsx:221 #: app/routes/reauth.tsx:310 #: app/routes/setup/mfa.tsx:46 @@ -702,7 +702,7 @@ msgstr "Sign out of" msgid "Sign out other sessions" msgstr "Sign out other sessions" -#: app/routes/login/index.tsx:628 +#: app/routes/login/index.tsx:627 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." diff --git a/app/resources/session/session-logout.service.ts b/app/resources/session/session-logout.service.ts index 735e8e0872..f256b1b089 100644 --- a/app/resources/session/session-logout.service.ts +++ b/app/resources/session/session-logout.service.ts @@ -117,7 +117,7 @@ export async function performLogout( const target = explicitTarget ?? (hasResidualSessions ? '/accounts' : '/logout/success'); // Owner-scoped hint clearing: "logout clears everything" from the perspective of WHOEVER - // signed out. Alice signing out must not erase Bob's fast path (spec: hint-maintenance matrix). + // signed out. Alice signing out must not erase Bob's fast path. const hint = await readPasskeyHint(request); const clearHintCookie = active && hint && hint.toLowerCase() === active.loginName.toLowerCase() diff --git a/app/resources/session/session.service.ts b/app/resources/session/session.service.ts index c4c18c249c..c4c672dada 100644 --- a/app/resources/session/session.service.ts +++ b/app/resources/session/session.service.ts @@ -541,7 +541,7 @@ export async function switchAccount( location, setCookie: await serializeSessions(updated), // The switched-to account is now this browser's active identity — refresh the hint - // (spec: hint-maintenance matrix). reauthRedirect (dead session) intentionally does + // reauthRedirect (dead session) intentionally does // NOT rewrite it: identity is not re-established until re-auth actually succeeds. cookies: [await serializePasskeyHint(entry.loginName)], }; diff --git a/app/resources/webauthn/identity-challenge.ts b/app/resources/webauthn/identity-challenge.ts index 6c14e2de8a..4e47775439 100644 --- a/app/resources/webauthn/identity-challenge.ts +++ b/app/resources/webauthn/identity-challenge.ts @@ -1,7 +1,7 @@ // app/resources/webauthn/identity-challenge.ts // -// Self-minted discovery challenge for the usernameless identity-resolution path -// (spec: 2026-07-31-usernameless-passkey-discovery-design.md). NOT a Zitadel +// Self-minted discovery challenge for the usernameless identity-resolution path. +// NOT a Zitadel // challenge and NEVER verified: the assertion it produces is an identity CLAIM // (userHandle read only) at the trust level of the passkey-hint cookie. Because // nothing checks the signature, nothing is persisted server-side either — the diff --git a/app/resources/webauthn/webauthn.service.ts b/app/resources/webauthn/webauthn.service.ts index 760941ebe6..2a93ab87a5 100644 --- a/app/resources/webauthn/webauthn.service.ts +++ b/app/resources/webauthn/webauthn.service.ts @@ -159,7 +159,7 @@ export interface ArmedUserBoundChallenge { /** * Mint a Zitadel session bound to `user`, then request a WebAuthn assertion * challenge on it — the sequence Zitadel's "a challenge requires a bound user" - * constraint (zitadel/zitadel#8899) forces on every usernameless entry point. + * constraint forces on every usernameless entry point. * Two callers: the /login loader (passkey-hint fast path) and the * /login/passkey-discover action (identity-discovery path). * diff --git a/app/routes/accounts.tsx b/app/routes/accounts.tsx index 6f7b62f262..8ac84502ca 100644 --- a/app/routes/accounts.tsx +++ b/app/routes/accounts.tsx @@ -125,7 +125,7 @@ export function addAccountHref({ }): string { // add=1 marks an EXPLICIT "different account" intent: the /login loader suppresses the // usernameless fast path so the previously remembered user's passkey is never offered - // to someone who asked to add another account (spec: required change, /accounts §). + // to someone who asked to add another account. return userCode ? paths.login.index({ requestId: `device_${userCode}`, organization, add: '1' }) : paths.login.index({ requestId: requestId ?? undefined, organization, add: '1' }); diff --git a/app/routes/login/index.tsx b/app/routes/login/index.tsx index cbc384c74b..adfd6dac6e 100644 --- a/app/routes/login/index.tsx +++ b/app/routes/login/index.tsx @@ -87,7 +87,7 @@ export async function loader({ request }: LoaderFunctionArgs) { // ── Usernameless fast path: arm a conditional-mediation passkey ceremony ──── // A hint is an inference; arm ONLY when nothing more specific is known. Explicit - // suppression list (spec, /accounts interaction §): ?add=1 (user asked for a different + // suppression list: ?add=1 (user asked for a different // account), hinted user already live (nothing to log in), unresolvable user (clear the // stale hint), no passkey method. Every suppression — and every mint failure — renders // the ordinary page; arming is invisible either way. @@ -102,7 +102,7 @@ export async function loader({ request }: LoaderFunctionArgs) { if (hint && !isAddAccount) { const sessions = await readSessions(request); // LIVE session, not just any cookie entry: raw readSessions() output can carry stale - // (expired) entries, and a stale entry must not suppress the fast path — the spec's + // (expired) entries, and a stale entry must not suppress the fast path — the // suppression criterion is a LIVE session. listSessions is the codebase's expiry-aware // filter (same usage as session.service.ts); unknown expiry counts as live. const hasLiveSession = listSessions(sessions, Date.now()).some( @@ -117,7 +117,7 @@ export async function loader({ request }: LoaderFunctionArgs) { try { // Mirror resolveIdentifier's known-user session mint, then persist the entry so // the /login/passkey verify action can resolve it by loginName. The loader-side - // Set-Cookie is the accepted side effect (spec, verified-before-building §2). + // Set-Cookie is the accepted side effect. // `hasLiveSession` above satisfies armUserBoundChallenge's caller contract // (its same-loginName supersede is only safe against dead entries). const armed = await armUserBoundChallenge( @@ -135,19 +135,19 @@ export async function loader({ request }: LoaderFunctionArgs) { }; } } catch { - // Session creation failed (deactivated user, provider hiccup) — spec error - // matrix: clear the hint, render normally. + // Session creation failed (deactivated user, provider hiccup) — + // clear the hint, render normally. responseHeaders.append('set-cookie', await clearPasskeyHint()); } } } } else if (!hint && !isAddAccount) { - // ── Discovery arm (spec: usernameless discovery design) ────────────────── + // ── Discovery arm ───────────────────────────────────────────────────────── // Hintless visitors get a SELF-MINTED challenge: no Zitadel call, nothing // persisted — the identity tap posts to /login/passkey-discover, which mints // the real user-bound challenge only after a passkey was actually tapped. // Suppressed when ANY live session exists (arming is inference; a logged-in - // visitor is better served by the ordinary page — spec, open decision §2) and + // visitor is better served by the ordinary page) and // by the operational kill switch (env, default ON — incident mitigation). const sessions = await readSessions(request); if (env.AUTH_PASSKEY_DISCOVERY_ENABLED && listSessions(sessions, Date.now()).length === 0) { @@ -317,7 +317,7 @@ export async function action({ request }: ActionFunctionArgs) { return redirect(idpResult.authUrl, { headers }); } // Sole-passkey: redirect to /login/passkey like every other post-identifier path - // (password, OTP). Pre-A-P10 behavior, reinstated by product ruling (Task 12) — + // (password, OTP) — // /login renders the chooser and nothing else; no route inlines an identity-bound, // single-method screen except /login/method and /reauth, which are out of scope here. return redirect(`${result.target}?${result.params}`, { headers }); @@ -379,11 +379,11 @@ export default function Login() { // Usernameless fast path — armed by the loader for a hinted returning user (hinted // mode) or for a hintless fresh browser (discovery mode: identity tap → discover // round-trip → modal ceremony). Retired by the user's OWN form submission below - // (spec interaction §2's single-flight concern no longer applies: the sole-passkey - // path redirects to /login/passkey — Task 12 — so there is no competing in-place + // (no competing single-flight concern: the sole-passkey + // path redirects to /login/passkey, so there is no competing in-place // ceremony on this page to race against). const conditional = useConditionalPasskey({ - // AMBIENT arming is hinted-only (spec, decision §4): a fresh-browser auto-prompt + // AMBIENT arming is hinted-only: a fresh-browser auto-prompt // is intrusive — password managers (1Password) escalate the quiet conditional // request into a full picker on page load. Discovery stays BUTTON-initiated // (beginDiscovery below); the loader-armed identityDiscovery options feed it. @@ -400,7 +400,7 @@ export default function Login() { }); const conditionalAbort = conditional.abort; // ANY form submission (identifier, email-link, IdP button) is the user stating explicit - // intent — retire the armed conditional ceremony (spec error matrix: "user types and + // intent — retire the armed conditional ceremony ("user types and // submits → abort()"). useEffect(() => { if (navigation.state === 'submitting') conditionalAbort(); @@ -436,7 +436,7 @@ export default function Login() { // Derived separately from identifierLabel: the field label spells out every accepted type // ("Email, phone, or username"), too long for a button. Email wins the both-allowed case — // it is the dominant identifier and the field states the full set once opened. Short noun - // labels (rebranding ruling, 2026-07-31): the chooser reads as a method list — "Passkey", + // labels: the chooser reads as a method list — "Passkey", // "Google", "Email" — not as instructions. Separate `t` literals so Lingui extracts each. const identifierButtonLabel = field.allowEmail ? t`Email` @@ -519,8 +519,7 @@ export default function Login() { htmlType="button" loading={ceremonyBusy} onClick={() => { - // No resolvable identity — run the discovery ceremony MODALLY (spec, open - // decision §3 as built): the browser's native picker over the loader's + // No resolvable identity — run the discovery ceremony MODALLY: the browser's native picker over the loader's // self-minted challenge, then the discover → verify pipeline. Fall back to // the identifier step only when discovery can't start (not armed — e.g. // ?add=1 or a live session — or WebAuthn unsupported). diff --git a/app/routes/login/passkey-discover.tsx b/app/routes/login/passkey-discover.tsx index 6dd08fe807..8fc5a9e023 100644 --- a/app/routes/login/passkey-discover.tsx +++ b/app/routes/login/passkey-discover.tsx @@ -1,9 +1,9 @@ // app/routes/login/passkey-discover.tsx // // Resource route (action only): the identity-resolution step of the usernameless -// discovery path (spec: 2026-07-31-usernameless-passkey-discovery-design.md). +// discovery path. // The posted assertion is an UNTRUSTED identity claim — its signature is never -// checked; only response.userHandle (== Zitadel userId, probe-verified) is read. +// checked; only response.userHandle (the Zitadel user ID) is read. // Every user-dependent failure collapses into ONE opaque 400 so this endpoint // leaks exactly what the identifier form leaks (enumeration parity) — the AUDIT // event carries the real reason instead. The authenticating ceremony is the @@ -119,7 +119,7 @@ export async function action({ request }: ActionFunctionArgs) { if (csrfSetCookie) headers.append('set-cookie', csrfSetCookie); // Deliberately NO passkey-hint write: the hint means "last successfully // AUTHENTICATED user", and the /login/passkey verify action writes it on - // success — discovery only identifies (spec, design decisions). + // success — discovery only identifies. const payload: PasskeyDiscoverData = { loginName: armed.loginName, csrfToken, diff --git a/cypress/component/hooks/use-conditional-passkey-discovery.cy.tsx b/cypress/component/hooks/use-conditional-passkey-discovery.cy.tsx index 55e316cf6e..c58e2e3a76 100644 --- a/cypress/component/hooks/use-conditional-passkey-discovery.cy.tsx +++ b/cypress/component/hooks/use-conditional-passkey-discovery.cy.tsx @@ -1,7 +1,6 @@ // cypress/component/hooks/use-conditional-passkey-discovery.cy.tsx // -// The DISCOVERY mode of the conditional ceremony driver (spec: -// 2026-07-31-usernameless-passkey-discovery-design.md): assertion #1 (identity +// The DISCOVERY mode of the conditional ceremony driver: assertion #1 (identity // claim) posts to /login/passkey-discover via PLAIN fetch (not an RR fetcher — // see submitDiscover's comment); the response carries the REAL user-bound // challenge + resolved loginName + fresh csrf, over which the modal ceremony runs @@ -149,7 +148,7 @@ describe('useConditionalPasskey — discovery mode', () => { }); }); - // ── beginDiscovery: the EXPLICIT (Passkey-button) modal flow — spec, decision §3 ── + // ── beginDiscovery: the EXPLICIT (Passkey-button) modal flow ───────────── it('beginDiscovery runs the modal flow: discover → verify → redirect (no auto-resolve needed)', () => { const { discoverPosts, verifyPosts } = mountHarness({}); diff --git a/cypress/component/routes/login/discovery-loader.cy.ts b/cypress/component/routes/login/discovery-loader.cy.ts index 8807347e73..1d553c0a67 100644 --- a/cypress/component/routes/login/discovery-loader.cy.ts +++ b/cypress/component/routes/login/discovery-loader.cy.ts @@ -1,7 +1,7 @@ // cypress/component/routes/login/discovery-loader.cy.ts // // The /login loader's identity-discovery arming + suppression list, at the HTTP -// boundary (spec: 2026-07-31-usernameless-passkey-discovery-design.md). Discovery +// boundary. Discovery // arms ONLY for the hintless population — and a discovery arm must be free: // self-minted options, NO Zitadel session, NO Set-Cookie. Sibling of // conditional-passkey-loader.cy.ts (the hinted path). diff --git a/cypress/component/routes/login/index.cy.tsx b/cypress/component/routes/login/index.cy.tsx index 47039e35e1..2105697f7b 100644 --- a/cypress/component/routes/login/index.cy.tsx +++ b/cypress/component/routes/login/index.cy.tsx @@ -1,10 +1,10 @@ // cypress/component/routes/login/index.cy.tsx // -// UI contract for /login (post-A-P10-reversal, Task 12 — product ruling): the chooser +// UI contract for /login: the chooser // renders unconditionally. There is no identity-bound inline passkey ceremony state on // this route anymore — a sole-passkey identifier now REDIRECTS to /login/passkey (see // the node-bound action test below) instead of the action returning inline challenge -// data. The gated Passkey SHORTCUT (Task 11) still drives the shared ceremony in place +// data. The gated Passkey SHORTCUT still drives the shared ceremony in place // on this page, and its own failure surface is still covered here. Mirrors // method.cy.tsx's stub /login/passkey route + capturedPosts convention (Task 2). import { callService } from '../../../support/node/call-service'; @@ -53,10 +53,9 @@ function mountLogin(opts?: { // Known loginName gates the Passkey SHORTCUT visible (view.showPasskeyPrompt && loginName). loginName?: string; // Overrides the /login/passkey stub's action — used to simulate a ceremony failure - // (Finding 1 coverage). Defaults to capturing the POST into capturedPosts. + // Defaults to capturing the POST into capturedPosts. passkeyAction?: (args: { request: Request }) => unknown | Promise; - // Org-policy overrides — cover configurations where password is disabled (#107's - // showIdentifierForm/showContinue view logic, grafted from main's spec version). + // Org-policy overrides — cover configurations where password is disabled. settings?: Partial<(typeof INDEX_LOADER_DATA)['settings']>; emailDeliveryEnabled?: boolean; }) { @@ -144,7 +143,7 @@ describe('/login — chooser (no inline ceremony)', () => { cy.get('input[name="loginName"], button').should('exist'); // chooser is intact }); - // Finding 1: the gated Passkey SHORTCUT drives the shared `ceremony` in place on this + // The gated Passkey SHORTCUT drives the shared `ceremony` in place on this // page — a failure there must still surface visibly. it('the gated Passkey shortcut surfaces a ceremony failure through the shared error region', () => { mountLogin({ @@ -180,15 +179,14 @@ describe('/login action — sole-passkey identifier', () => { }); }); -// ── #107 view logic (grafted from main's spec version at the merge) ────────────── -// The inline-ceremony tests main carried were dropped — the merged runtime keeps the -// Task-12 product ruling (sole-passkey REDIRECTS to /login/passkey, asserted above) — -// but these two cover showIdentifierForm/showContinue, which survive unchanged. -describe('/login — identifier-form view logic (#107)', () => { +// ── Identifier-form view logic ─────────────────────────────────────────────── +// Sole-passkey identifiers REDIRECT to /login/passkey (asserted above); these two +// cover the identifier-form visibility logic under restricted org policies. +describe('/login — identifier-form view logic', () => { // REGRESSION: with allowPassword=false the identifier form used to be hidden entirely, // while signInUnavailable stayed false (suppressed by showPasskeyPrompt) — so a fresh // visitor at a passkey-only org got an EMPTY card: no sign-in path and no error. - // Passkey needs a known user (zitadel/zitadel#8899), so the identifier IS the entry point. + // Passkey needs a known user, so the identifier IS the entry point. it('a passkey-only org still offers the identifier form, not an empty card', () => { mountLogin({ settings: { allowPassword: false, passkeysType: 'allowed' }, diff --git a/cypress/component/routes/login/passkey-button-visibility.cy.tsx b/cypress/component/routes/login/passkey-button-visibility.cy.tsx index 9e8e609440..4899e9677b 100644 --- a/cypress/component/routes/login/passkey-button-visibility.cy.tsx +++ b/cypress/component/routes/login/passkey-button-visibility.cy.tsx @@ -124,8 +124,7 @@ describe('/login Passkey button — visibility and identity binding', () => { it('cold click with discovery UNARMED (loader-suppressed) falls back to the identifier field', () => { // identityDiscovery null = the loader suppressed arming (?add=1 / live session). - // beginDiscovery has no options to run over → the identifier step is the fallback - // (spec, open decision §3 as built). + // beginDiscovery has no options to run over → the identifier step is the fallback. mountLogin(); cy.contains('button', /passkey/i).click(); cy.get('input[name="loginName"]').should('be.visible'); diff --git a/cypress/component/routes/login/passkey-discover.cy.ts b/cypress/component/routes/login/passkey-discover.cy.ts index 2f66c69362..38df64d95c 100644 --- a/cypress/component/routes/login/passkey-discover.cy.ts +++ b/cypress/component/routes/login/passkey-discover.cy.ts @@ -1,7 +1,7 @@ // cypress/component/routes/login/passkey-discover.cy.ts // // /login/passkey-discover action — the identity-resolution step of the usernameless -// discovery path (spec: 2026-07-31-usernameless-passkey-discovery-design.md). +// discovery path. // The posted assertion is an UNTRUSTED identity claim: only response.userHandle is // read (== Zitadel userId). Every user-dependent failure must collapse into ONE // opaque 400 (enumeration parity with the identifier form). The action returns @@ -58,7 +58,7 @@ describe('/login/passkey-discover action', () => { 'ceremony session entry persisted' ).to.equal(true); // Hint invariant: "last successfully AUTHENTICATED user" — the verify action - // writes it on success; discover must NOT (spec, design decisions). + // writes it on success; discover must NOT. expect( cookies.some((c) => c.startsWith('passkey-hint=')), 'no hint write on discover' diff --git a/cypress/e2e/passkey-conditional.cy.ts b/cypress/e2e/passkey-conditional.cy.ts index 0361ee69f1..f14182cdf9 100644 --- a/cypress/e2e/passkey-conditional.cy.ts +++ b/cypress/e2e/passkey-conditional.cy.ts @@ -74,8 +74,8 @@ describe('usernameless passkey fast path', () => { cy.location('pathname').should('eq', '/id/logout/success'); cy.getCookie('passkey-hint').should('not.exist'); - // 5. No hint → the page stays QUIET even with auto-resolve armed (spec, decision - // §4: ambient arming is hinted-only — no fresh-load auto-prompt). Discovery is + // 5. No hint → the page stays QUIET even with auto-resolve armed (ambient arming is + // hinted-only — no fresh-load auto-prompt). Discovery is // BUTTON-initiated: the Passkey button signs in via userHandle resolution, and // the verify success re-writes the hint (browser self-upgrade). visitLoginArmed(); @@ -96,7 +96,7 @@ describe('usernameless passkey fast path', () => { it('an armed (un-resolved) ceremony never blocks the ordinary identifier flow', () => { // Hint present but NO auto-resolve: the ceremony parks; typing + submitting must win - // (the page aborts the armed ceremony on submit — spec: "user types and submits"). + // (the page aborts the armed ceremony on submit). signInWithPassword(USER); cy.clearCookie('sessions'); cy.visit('/id/login', { diff --git a/cypress/e2e/passkey-discovery.cy.ts b/cypress/e2e/passkey-discovery.cy.ts index b73bc48e6e..1f75830d89 100644 --- a/cypress/e2e/passkey-discovery.cy.ts +++ b/cypress/e2e/passkey-discovery.cy.ts @@ -1,7 +1,6 @@ // cypress/e2e/passkey-discovery.cy.ts // -// The usernameless DISCOVERY path end-to-end against the fake provider (spec: -// 2026-07-31-usernameless-passkey-discovery-design.md): a FRESH browser — no +// The usernameless DISCOVERY path end-to-end against the fake provider: a FRESH browser — no // passkey-hint, no session, nothing — signs in with zero typing. The identity tap // (auto-resolved via the Cypress seam; CYPRESS_CREDENTIAL carries userHandle // base64url('u5')) posts to /login/passkey-discover, which resolves u5 and mints @@ -49,7 +48,7 @@ describe('usernameless passkey discovery (fresh browser)', () => { // passkey-conditional.cy.ts also clears specific cookies only). cy.clearCookie('passkey-hint'); cy.clearCookie('sessions'); - // Ambient arming is hinted-only (spec, decision §4): a hintless load stays quiet + // Ambient arming is hinted-only: a hintless load stays quiet // even with auto-resolve armed; the Passkey BUTTON is the discovery entry // (beginDiscovery — under Cypress the pre-baked credential IS the picked passkey). visitLoginArmed();