diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000000..181d7165b9 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,76 @@ +# Contributing to auth-ui + +Thank you for contributing. This document covers the essentials you need before +opening a pull request. + +## Prerequisites + +- [Bun](https://bun.sh/) >= 1.3.14 +- Node 20+ (managed via `.nvmrc`) +- A running instance of the dev environment — see [Getting Started](docs/getting-started/) + +## Local development + +```bash +bun install # install dependencies and wire up lefthook pre-commit hooks +bun run dev # start the local dev server +``` + +## Before you push + +Run the full local gate: + +```bash +bun run lint # ESLint (auto-fix) +bun run typecheck # TypeScript + route type generation +bun run lint:boundaries # architecture fitness — dependency-cruiser +bun run lint:cycles # circular import check — madge +bun run size # bundle budget (requires bun run build first) +``` + +CI runs the same checks. A PR that fails any of these will not be merged. + +## Architecture must respect module boundaries + +This project enforces a strict module layering contract via +[dependency-cruiser](https://github.com/sverweij/dependency-cruiser). The rules +live in [`.dependency-cruiser.cjs`](.dependency-cruiser.cjs). + +**Core constraint:** `app/modules/` sub-directories (`auth`, `analytics`, `fraud`, +`i18n`) are isolated from each other. Cross-module imports are forbidden except +through explicit composition seams defined in `app/server/composition.ts`. + +| What is forbidden | Why | +|---|---| +| Importing `app/modules/auth/providers/` from outside `auth/` | Provider implementations are private; use the interface | +| `app/resources/` importing from `app/server/edge/` | Edge utilities must not leak into the resource layer | +| `app/shared/` importing from routes, resources, modules, or server | `shared/` is a leaf — it must not depend on the layers above it | +| `app/resources/` importing from `app/routes/` (except `paths.ts`) | Resources must not couple to route modules | +| Circular imports anywhere | Cycles make dependency graphs unresolvable | + +If `bun run lint:boundaries` rejects your import, the fix is to restructure the +code — not to weaken or comment-out the rule. If you believe the rule is +wrong for your use case, open an issue to discuss it first. + +See [Architecture Overview](docs/architecture/overview.md) and +[Provider Seam](docs/architecture/provider-seam.md) for the rationale. + +## Testing + +```bash +bun run test:unit # Cypress component tests +bun run test:e2e:fast # E2E suite against the fake auth provider +``` + +See [Testing](docs/development/testing.md) for the full test strategy. + +## Commits and PRs + +- Follow [Conventional Commits](https://www.conventionalcommits.org/) (`feat:`, `fix:`, `refactor:`, etc.) +- Keep PRs focused — one logical change per PR +- All CI jobs must pass before a PR can be merged + +## License + +By contributing, you agree that your contributions will be licensed under the +[Apache License 2.0](LICENSE). diff --git a/acceptance/PASSKEY-TESTING.md b/acceptance/PASSKEY-TESTING.md new file mode 100644 index 0000000000..770780a93a --- /dev/null +++ b/acceptance/PASSKEY-TESTING.md @@ -0,0 +1,84 @@ +# Local passkey testing against real Zitadel (A-H1) + +Manual integration script for the passkey surfaces (`/id/passkeys`, `/id/reauth`, +`/setup/passkey`) against a **real** Zitadel. CI never runs this: the Cypress +suites cover the same flows on the fake provider (pre-baked credential); this +script is the real-WebAuthn spot check (automated real-Zitadel WebAuthn is +parked — C7). + +## 1. Environment requirements + +Any local Zitadel works (Kind, docker-compose, dev instance) as long as it +provides: + +- A Zitadel instance with a project/app configured for auth-ui and a service + user PAT. +- Login policy `passwordlessType: ALLOWED`. **This must be set explicitly** — + verified live: the FirstInstance default is **NOT_ALLOWED** (the proto zero + value is simply omitted from the policy JSON). Policy updates are idempotent. +- Optionally, an SMTP catcher (e.g. Mailpit) wired as Zitadel's SMTP target for + the email-OTP reauth step and as a canary for unexpected mail. + +## 2. Same-origin WebAuthn proxy (required for real ceremonies) + +WebAuthn requires the RP ID to match the page origin. Put auth-ui and Zitadel +behind ONE https origin (mirrors staging's same-host routing) with any local +reverse proxy — e.g. `https:///id/*` → the auth-ui dev server on +`:3000`, everything else → Zitadel. + +auth-ui `.env` for this setup: + +```bash +AUTH_PROVIDER=zitadel +ZITADEL_API_URL=https:// # the shared origin +ZITADEL_SERVICE_USER_TOKEN= +NODE_EXTRA_CA_CERTS= +``` + +Browse `https:///id/login` (RP ID = ``). Enroll with a +platform authenticator directly, or with Chrome DevTools → WebAuthn panel → +**virtual authenticator** (`ctap2`, resident keys ON, user verification ON). + +## 3. Round-trip checklist (run in order) + +| # | Step | Pass criterion | +|---|------|----------------| +| 1 | Policy honored | `/id/setup/passkey` issues a creation challenge (no `passwordless not allowed` error) | +| 2 | Enroll | virtual/platform authenticator completes; the name step pre-fills from AAGUID (or ` on `) | +| 3 | List | `/id/passkeys` shows the new row with the chosen name (ListPasskeys round-trip) | +| 4 | Sudo | wait >10 min (or clear the fresh factor) → `/id/passkeys` bounces to `/id/reauth`; re-verify returns | +| 5 | Remove | confirm dialog → row gone (RemovePasskey round-trip); last-method guard refuses when it is the only method | +| 6 | Reauth email-OTP | `/id/reauth?method=otp_email` delivers a code to the SMTP catcher | +| 7 | Mail canary | **no unexpected mail** during steps 1–5 — any surprise message is a flow that silently no-ops in prod (SMTP disabled there); catalogue it for the Phase B pipeline | + +- **Created-at:** after enrolling, `/id/passkeys` shows "Added " under the + new passkey's name. This is also the live server-support check for the v2 metadata RPCs + (SetUserMetadata/ListUserMetadata/DeleteUserMetadata) on the deployed Zitadel. +- **Cleanup:** after removing that passkey, the `passkey::created` user-metadata key + is gone (Zitadel console → user → Metadata, or `ListUserMetadata`); pre-existing passkeys + with no created-at metadata show no date line — expected, no backfill. +- **Cross-device sign-out:** sign in on two devices/browsers as the same user; on one, + remove a passkey and choose "Sign out other sessions" — the OTHER device's session is + invalid on next request (Zitadel session deleted). Confirms the session v2 `userIdQuery` + search AND that the service PAT may delete a session WITHOUT its session token. +- **Sign-out sudo gate:** a `signout-others` POST with a stale (>10 min) session factor bounces + through `/id/reauth` instead of executing. + (Sweep completeness is proven only up to one default page of the session search — + per-user session counts are expected to be tiny; accepted at final review.) +- **Login-flow rework:** with ≥2 methods, the chooser shows "Signing in as — Not you?" + and the Passkey entry runs Touch ID in place (no page change; other methods remain on + failure). With passkey as the ONLY method, submitting the email runs the ceremony directly + on the login page (auto-fire; "Continue with passkey" appears if the browser blocks it). +- **Fallback page:** `/id/login/passkey?loginName=` still works when visited directly. + +The SMTP catcher is a local-only canary + OTP delivery aid. It does **not** +simulate production (prod SMTP is disabled). + +## Deprecated lightweight alternative + +`acceptance/docker-compose.zitadel.yml` is a bare Zitadel+Postgres stub +(localhost:8080, no seeding, no mail catcher, no same-origin proxy). It is kept +only as a minimal scratch harness; prefer a staging-shaped seeded stack per §1. +(The stub's completion work was dropped when A-H1 was reframed, 2026-07-17.) + +> This harness feeds the `test:acceptance` suite later; it stays manual for now. diff --git a/app/components/auth-ceremony/auth-ceremony.tsx b/app/components/auth-ceremony/auth-ceremony.tsx index 21466ae445..7d41c11937 100644 --- a/app/components/auth-ceremony/auth-ceremony.tsx +++ b/app/components/auth-ceremony/auth-ceremony.tsx @@ -34,7 +34,7 @@ export interface AuthCeremonyProps { // Tokenized ceremony spacing — owned here so every ceremony screen shares one rhythm // (was previously repeated literally across the verify/setup/login routes). -const CEREMONY_LAYOUT = 'flex flex-col items-baseline justify-center gap-4'; +const CEREMONY_LAYOUT = 'flex flex-col items-center justify-center gap-4'; export function AuthCeremony({ title, @@ -49,7 +49,7 @@ export function AuthCeremony({ }: AuthCeremonyProps) { return ( -
+
{/* IdentityBadge requires a loginName (it returns null without one); only mount it when present so requestId/organization are threaded through "Not you?". */} {loginName && ( diff --git a/app/components/auth-form/auth-form.tsx b/app/components/auth-form/auth-form.tsx index ca3062a514..272e8aff1f 100644 --- a/app/components/auth-form/auth-form.tsx +++ b/app/components/auth-form/auth-form.tsx @@ -6,6 +6,8 @@ interface SubmitButtonProps { children: ReactNode; /** Override the auto navigation-state loading (e.g. when using a fetcher). */ loading?: boolean; + /** Force-disable regardless of navigation state (e.g. a sibling ceremony is busy). */ + disabled?: boolean; className?: string; /** * Optional click handler, fired before the browser's native form submission (see @@ -21,7 +23,13 @@ interface SubmitButtonProps { * router's navigation state so it reflects the in-flight server action — NOT RHF's * isSubmitting, which resolves instantly under the native-RR-submit pattern. */ -export function SubmitButton({ children, loading, className, onClick }: SubmitButtonProps) { +export function SubmitButton({ + children, + loading, + disabled, + className, + onClick, +}: SubmitButtonProps) { const navigation = useNavigation(); const isSubmitting = loading ?? navigation.state === 'submitting'; return ( @@ -31,6 +39,7 @@ export function SubmitButton({ children, loading, className, onClick }: SubmitBu block htmlType="submit" loading={isSubmitting} + disabled={disabled} className={className} onClick={onClick}> {children} diff --git a/app/components/auth-form/idp-button-list.tsx b/app/components/auth-form/idp-button-list.tsx index df7f5d699e..d73a979405 100644 --- a/app/components/auth-form/idp-button-list.tsx +++ b/app/components/auth-form/idp-button-list.tsx @@ -23,6 +23,8 @@ export interface IdpButtonListProps { submittingIdpId: string | null; relative?: boolean; lastUsedLogin?: string | null; + /** Force-disable every row regardless of submittingIdpId (e.g. a sibling ceremony is busy). */ + disabled?: boolean; } export function IdpButtonList({ @@ -33,6 +35,7 @@ export function IdpButtonList({ submittingIdpId, relative = false, lastUsedLogin, + disabled = false, }: IdpButtonListProps): React.JSX.Element { // MaxMind mirrors the captured device-fingerprint token into sessionStorage asynchronously // (see modules/fraud/maxmind-tracker) — read it client-side only (post-mount) so SSR and the @@ -63,6 +66,7 @@ export function IdpButtonList({ block htmlType="submit" loading={submittingIdpId === idp.id} + disabled={disabled} iconPosition="left" icon={ // Span wrapper keeps the Button's `icon` slot a single element; the optimistic diff --git a/app/components/back-link/previous-step.ts b/app/components/back-link/previous-step.ts index 5053de9a27..2d835e9cec 100644 --- a/app/components/back-link/previous-step.ts +++ b/app/components/back-link/previous-step.ts @@ -6,8 +6,17 @@ const PREVIOUS_STEP: Array<[match: (p: string) => boolean, target: string]> = [ [(p) => p === '/login/password', '/login'], [(p) => p === '/login/mfa', '/login/password'], - [(p) => p.startsWith('/login/verify/'), '/login/mfa'], + // Verify screens AND /login/security-key are all USE_SCREEN targets of + // resolveMfaPicker's sole-factor short-circuit (mfa.service.ts): whenever the user has + // exactly one enrolled+policy-allowed second factor, /login/mfa's loader redirects + // straight back to that screen BEFORE any picker UI renders. A Back target of + // /login/mfa therefore silently loops back to the same page. Go straight to /login + // instead (matches "Not you?" semantics) — 2+-factor users still reach the real + // picker via forward navigation from /login/password, which is unaffected. + [(p) => p.startsWith('/login/verify/'), '/login'], + [(p) => p === '/login/security-key', '/login'], [(p) => p === '/signup/password', '/signup'], + [(p) => p === '/signup/method', '/signup'], [(p) => p === '/password/reset', '/login/password'], // Password-management screens previously had no Back control. [(p) => p === '/password/new', '/login/password'], diff --git a/app/components/identity-badge/identity-badge.tsx b/app/components/identity-badge/identity-badge.tsx index da9e9acb6b..d07d2b2924 100644 --- a/app/components/identity-badge/identity-badge.tsx +++ b/app/components/identity-badge/identity-badge.tsx @@ -1,31 +1,54 @@ import { Trans } from '@lingui/react/macro'; +import type { ReactNode } from 'react'; import { Link } from 'react-router'; interface IdentityBadgeProps { loginName: string; - /** Threaded through "Not you?" so the OIDC/org ceremony continues. loginName is intentionally dropped. */ + /** Threaded through the link so the OIDC/org ceremony continues. loginName is intentionally dropped. */ requestId?: string; organization?: string; + /** Text before the bolded loginName. Default: "Signing in as". */ + verb?: ReactNode; + /** Text of the trailing link. Default: "Not you?". Ignored when showLink is false. */ + linkLabel?: ReactNode; + /** Explicit link target. Default: /login (+ requestId/organization qs, loginName dropped). */ + linkTarget?: string; + /** Set false to render identity context with no link at all. Default: true. */ + showLink?: boolean; } /** - * "Signing in as — Not you?" shown on post-identifier steps. "Not you?" - * returns to /login (navigation only; the multi-account session list is untouched), - * preserving requestId + organization but clearing loginName so a different account - * can be entered. Renders nothing without a loginName. + * " " shown on post-identifier steps. The link + * (when shown) preserves requestId + organization but clears loginName so a + * different account can be entered/switched to. Renders nothing without a loginName. */ -export function IdentityBadge({ loginName, requestId, organization }: IdentityBadgeProps) { +export function IdentityBadge({ + loginName, + requestId, + organization, + verb = Signing in as, + linkLabel = Not you?, + linkTarget, + showLink = true, +}: IdentityBadgeProps) { if (!loginName) return null; + if (!showLink) { + return ( +

+ {verb} {loginName} +

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

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

); diff --git a/app/components/sign-out-button/sign-out-button.tsx b/app/components/sign-out-button/sign-out-button.tsx new file mode 100644 index 0000000000..77916780fe --- /dev/null +++ b/app/components/sign-out-button/sign-out-button.tsx @@ -0,0 +1,36 @@ +import { AuthFormFields } from '@/components/auth-form/auth-form-fields'; +import { APP_BASENAME } from '@/resources/shared/app-basename'; +import { Button } from '@datum-cloud/datum-ui/button'; +import { Trans } from '@lingui/react/macro'; + +export interface SignOutButtonProps { + csrf: string; + /** + * Visual weight. 'primary' (solid) for screens where signing out IS the point + * (signed-in.tsx, logout/index.tsx). 'secondary' (link) for account-management + * screens where sign-out is a minor action among many (sso/index.tsx, passkeys.tsx). + * Default: 'secondary'. + */ + emphasis?: 'primary' | 'secondary'; +} + +/** + * Shared "Sign out" control. POSTs to /id/logout?index — the ?index is required so a + * native
hits the logout INDEX route's action, not its action-less layout. + */ +export function SignOutButton({ csrf, emphasis = 'secondary' }: SignOutButtonProps) { + return ( + + + {emphasis === 'primary' ? ( + + ) : ( + + )} + + ); +} diff --git a/app/components/webauthn-button/webauthn-button.tsx b/app/components/webauthn-button/webauthn-button.tsx index e88831287c..9e5c53798b 100644 --- a/app/components/webauthn-button/webauthn-button.tsx +++ b/app/components/webauthn-button/webauthn-button.tsx @@ -3,8 +3,10 @@ import { createAttestation, marshalAssertion, isWebAuthnSupported, + WebAuthnCeremonyError, WebAuthnUnsupportedError, type WebAuthnChallengeInput, + type WebAuthnReason, } from '@/resources/webauthn/webauthn'; import { Button } from '@datum-cloud/datum-ui/button'; import { Icon } from '@datum-cloud/datum-ui/icons'; @@ -15,7 +17,7 @@ import { useNavigation, useSubmit } from 'react-router'; // Pre-baked credential for Cypress / test environments where navigator.credentials is unavailable. // The fake provider's updateSession accepts any webAuthN payload so this value is arbitrary. -const CYPRESS_CREDENTIAL = { +export const CYPRESS_CREDENTIAL = { id: 'fake-credential-id', rawId: 'ZmFrZS1jcmVkZW50aWFsLWlk', type: 'public-key', @@ -27,6 +29,92 @@ const CYPRESS_CREDENTIAL = { }, }; +/** + * Rendered failure state. + * - 'browser-unsupported': the browser lacks WebAuthn entirely (pre-ceremony support check / + * WebAuthnUnsupportedError) — a single shared message. + * - 'ceremony': the create/get ceremony failed; `reason` (from classifyWebAuthnError) plus the + * flow (enroll vs sign-in) selects the specific copy. + */ +type WebAuthnErrorState = + { kind: 'browser-unsupported' } | { kind: 'ceremony'; reason: WebAuthnReason } | null; + +/** + * Reason → user copy for a SIGN-IN (assertion) ceremony failure. Every branch is a literal + * so Lingui extraction picks it up statically. Moved verbatim out of the button so + * surfaces that run the login ceremony without this button (usePasskeyLoginCeremony) can render + * the identical messages. `already-registered` has no sign-in meaning, so it falls back to the + * generic copy via the default branch. + */ +export function WebAuthnReasonCopy({ reason }: { reason: WebAuthnReason }) { + switch (reason) { + case 'not-allowed': + return ( + + Passkey sign-in was cancelled, or no passkey for this account is available on this device. + Make sure you're using a device where you set up your passkey, then try again. + + ); + case 'unsupported': + return ( + + This device can't use a passkey to sign in. Try another device or a different sign-in + method. + + ); + case 'security': + return ( + + Passkey sign-in couldn't be completed for security reasons. Please contact support if this + continues. + + ); + default: + return The passkey verification failed. Please try again.; + } +} + +/** + * Reason- AND flow-specific ceremony failure copy. enroll = attestation (create), sign-in = + * assertion (get). The sign-in branch delegates to the exported WebAuthnReasonCopy so both + * copies stay byte-identical wherever they're rendered. + */ +function ceremonyMessage( + reason: WebAuthnReason, + mode: 'assertion' | 'attestation' +): React.ReactNode { + if (mode === 'attestation') { + switch (reason) { + case 'not-allowed': + return ( + + Passkey setup was cancelled, or this device has no passkey support. Make sure Touch ID, + Windows Hello, a security key, or a password manager is available, then try again. + + ); + case 'already-registered': + return You already have a passkey for this account on this device.; + case 'unsupported': + return ( + + This device can't create a passkey. Try another device, a security key, or a password + manager. + + ); + case 'security': + return ( + + Passkey setup couldn't be completed for security reasons. Please contact support if this + continues. + + ); + default: + return We couldn't set up your passkey. Please try again.; + } + } + return ; +} + interface WebAuthnButtonProps { /** The publicKey options from the loader challenge. * - assertion mode (default): publicKeyCredentialRequestOptions.publicKey — may be null when @@ -49,6 +137,13 @@ interface WebAuthnButtonProps { * setup/security-key enrollment screens. */ mode?: 'assertion' | 'attestation'; + /** + * When set, the finished ceremony credential is handed to the parent + * INSTEAD of auto-submitting the form — the two-step enroll flow (ceremony → name + * step → submit) holds it in route state between steps. Runs on the Cypress + * pre-baked path too, so tests exercise the same handoff. + */ + onCredential?: (credential: Record) => void; } /** @@ -74,15 +169,20 @@ export function WebAuthnButton({ loading, label, mode = 'assertion', + onCredential, }: WebAuthnButtonProps) { const navigation = useNavigation(); const submit = useSubmit(); - const [error, setError] = useState(null); + const [error, setError] = useState(null); const [mounted, setMounted] = useState(false); useEffect(() => { setMounted(true); }, []); - const isSubmitting = loading ?? navigation.state !== 'idle'; + // 'submitting' means THIS form's own action is in flight. 'loading' also covers an + // unrelated navigation elsewhere on the page (e.g. clicking the BackLink or an + // identity "Not you?" link) — using navigation.state !== 'idle' here made this + // button flash busy/disabled for a click it had nothing to do with. + const isSubmitting = loading ?? navigation.state === 'submitting'; async function handleClick() { setError(null); @@ -93,20 +193,24 @@ export function WebAuthnButton({ if (!isCypress && !isWebAuthnSupported()) { // Real browser without WebAuthn — surface the unsupported message immediately // instead of submitting the fake Cypress credential to the production backend. - setError('webauthn-unsupported'); + setError({ kind: 'browser-unsupported' }); return; } - if (isCypress) { - // Cypress fake-credential path: no publicKey needed. - // The pre-baked credential works for both assertion and attestation because - // the fake provider accepts any payload for both verifyPasskey and verifyU2F. + // Cypress fake-credential path: no publicKey needed. The pre-baked credential works for + // both assertion and attestation because the fake provider accepts any payload. A spec may + // opt OUT of the fake (window.__webAuthnRealCeremony) to drive the REAL ceremony against a + // stubbed navigator.credentials and assert reason-specific failure copy; undefined in prod. + const useFakeCredential = + isCypress && + !(window as unknown as { __webAuthnRealCeremony?: boolean }).__webAuthnRealCeremony; + if (useFakeCredential) { credential = CYPRESS_CREDENTIAL; } else { // Guard: if the server-side challenge failed (loader caught an error and left // the options null), we have no options to pass to the authenticator — surface // the failure instead of calling the WebAuthn API with null. if (!publicKey) { - setError('webauthn-failed'); + setError({ kind: 'ceremony', reason: 'unknown' }); return; } if (mode === 'attestation') { @@ -118,9 +222,15 @@ export function WebAuthnButton({ } } + // Two-step enroll: hand the credential up instead of submitting. + if (onCredential) { + onCredential(credential); + return; + } + const form = formRef.current; if (!form) { - setError('webauthn-failed'); + setError({ kind: 'ceremony', reason: 'unknown' }); return; } @@ -130,28 +240,25 @@ export function WebAuthnButton({ void submit(formData, { method: 'post' }); } catch (err) { if (err instanceof WebAuthnUnsupportedError) { - setError('webauthn-unsupported'); + setError({ kind: 'browser-unsupported' }); + } else if (err instanceof WebAuthnCeremonyError) { + // Reason-classified ceremony failure (thrown DOMException / null credential). + setError({ kind: 'ceremony', reason: err.reason }); } else { - setError('webauthn-failed'); + setError({ kind: 'ceremony', reason: 'unknown' }); } } } return (
- {error === 'webauthn-unsupported' ? ( + {error?.kind === 'browser-unsupported' ? ( Your browser does not support passkeys. Please use a supported browser. - ) : error === 'webauthn-failed' ? ( - - {mode === 'attestation' ? ( - // Enrollment (attestation) failure — distinct from verification. - We couldn't set up your passkey. Please try again. - ) : ( - The passkey verification failed. Please try again. - )} - + ) : error?.kind === 'ceremony' ? ( + // Reason- AND flow-specific copy (enroll vs sign-in). See ceremonyMessage. + {ceremonyMessage(error.reason, mode)} ) : null}
- {view.showIdpButtons ? ( - - ) : null} - - {view.showPasskeyPrompt ? ( - }> - Passkey - - + {/* Shared ceremony-error surface — rendered whether the FAILURE happened on the + auto-fired/manual inline ceremony OR on the gated shortcut below (both drive + the same `ceremony`), so it must show in both the inline and the else branch. */} + {ceremony.reason ? ( + + + + ) : ceremonyServerError ? ( + {ceremonyServerError} ) : null} - {view.showPasswordForm && view.showIdpButtons ? : null} - - {view.showPasswordForm ? ( + {showInline && passkeyInline ? ( +
+

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

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

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

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

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

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

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

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

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

+ + ) : null} - ) : null} + )} ); } diff --git a/app/routes/login/method.tsx b/app/routes/login/method.tsx index db8d1383af..ed93291411 100644 --- a/app/routes/login/method.tsx +++ b/app/routes/login/method.tsx @@ -1,17 +1,39 @@ +import { IdpButtonList } from '@/components/auth-form/idp-button-list'; +import { FormError } from '@/components/form-error/form-error'; +import { WebAuthnReasonCopy } from '@/components/webauthn-button/webauthn-button'; +import { useAuthActionError } from '@/hooks/use-auth-action-error'; import { useLoginContext } from '@/hooks/use-login-context'; +import { usePasskeyLoginCeremony } from '@/hooks/use-passkey-login-ceremony'; import SplitLayout from '@/layouts/split.layout'; +import type { IdProvider } from '@/modules/auth/types'; +import { startIdpIntent } from '@/resources/login'; import { decideAfterIdentifier } from '@/resources/login/login-decision'; +import { loginIdpSchema } from '@/resources/login/login.schema'; import { readCeremonyParams } from '@/resources/shared/ceremony-params'; import { resolveOrg } from '@/resources/shared/resolve-org'; +import { joinLinkedIdps } from '@/resources/sso'; +import { getActiveIdPs } from '@/resources/sso/idp-providers'; +import type { LinkedIdpView } from '@/resources/sso/sso-management'; import { redirectToLogin } from '@/routes/login-bounce'; import { paths } from '@/routes/paths'; import { providerForRequest } from '@/server/auth-context.server'; +import { loaderCsrf, assertCsrf } from '@/server/csrf'; +import { trustedAppOrigin } from '@/server/infra/app-origin.server'; import { env } from '@/server/infra/env.server'; -import { LinkButton } from '@datum-cloud/datum-ui/button'; +import { Button, LinkButton } from '@datum-cloud/datum-ui/button'; import { Icon } from '@datum-cloud/datum-ui/icons'; +import { cn } from '@datum-cloud/datum-ui/utils'; import { Trans } from '@lingui/react/macro'; -import { Key, Lock, Mail, UserCircle } from 'lucide-react'; -import { redirect, useLoaderData, type LoaderFunctionArgs, type MetaFunction } from 'react-router'; +import { Key, Lock, Mail } from 'lucide-react'; +import { + data, + redirect, + useLoaderData, + useNavigation, + type ActionFunctionArgs, + type LoaderFunctionArgs, + type MetaFunction, +} from 'react-router'; import { Link } from 'react-router'; export const meta: MetaFunction = () => [{ title: 'Choose how to sign in' }]; @@ -30,10 +52,11 @@ export async function loader({ request }: LoaderFunctionArgs) { // Org-first: an explicit org wins, else the default org (matches the old app's // `organization ?? getDefaultOrg()`). findUser above stays instance-wide by design. const settingsOrg = await resolveOrg(provider, organization); - const [methods, settings, branding] = await Promise.all([ + const [methods, settings, branding, { csrfToken, headers }] = await Promise.all([ provider.listAuthMethods(user.id), provider.getLoginSettings(settingsOrg), provider.getBranding(settingsOrg), + loaderCsrf(request), ]); // Compute available primary sign-in methods using the same policy gates as @@ -42,7 +65,27 @@ export async function loader({ request }: LoaderFunctionArgs) { const available: Array<'passkey' | 'password' | 'otp_email' | 'idp'> = []; if (methods.includes('passkey') && settings.passkeysType !== 'not_allowed') available.push('passkey'); - if (methods.includes('idp') && settings.allowExternalIdp) available.push('idp'); + + // Resolve the user's linked (redirect-based) IdPs directly into IdpButtonList's + // IdProvider shape — mirrors reauth.service.ts's loadReauth idp-resolution, including + // the LDAP exclusion (LDAP needs its own credential form, not an OAuth round-trip). A + // link whose provider is no longer active has no name/type to join — filtered out, + // since there's no sign-in button to offer for a dead provider. + let idps: IdProvider[] = []; + if (methods.includes('idp') && settings.allowExternalIdp) { + const [links, active] = await Promise.all([ + provider.listIdpLinks(user.id), + getActiveIdPs(provider, settingsOrg), + ]); + idps = joinLinkedIdps(links, active) + .filter( + (l): l is LinkedIdpView & { name: string; type: string } => + l.name !== undefined && l.type !== undefined && l.type !== 'LDAP' + ) + .map((l) => ({ id: l.idpId, name: l.name, type: l.type, logoUrl: l.logoUrl })); + if (idps.length > 0) available.push('idp'); + } + if (methods.includes('password') && settings.allowPassword) available.push('password'); if (methods.includes('otp_email') && env.AUTH_EMAIL_DELIVERY_ENABLED) available.push('otp_email'); @@ -63,17 +106,77 @@ export async function loader({ request }: LoaderFunctionArgs) { return redirect(`${target}?${params.toString()}`); } - return { loginName, requestId, organization, methods: available, branding }; + return data( + { loginName, requestId, organization, methods: available, branding, idps, csrfToken }, + { headers } + ); +} + +export async function action({ request }: ActionFunctionArgs) { + const provider = providerForRequest(request); + const form = await request.formData(); + await assertCsrf(request, form); + + const parsed = loginIdpSchema.safeParse(Object.fromEntries(form)); + if (!parsed.success) return data({ error: 'INVALID_INPUT' as const }, { status: 400 }); + const { idpId, requestId, organization } = parsed.data; + + const { loginName } = readCeremonyParams(new URL(request.url)); + if (!loginName) return redirect(redirectToLogin(requestId, organization)); + + const user = await provider.findUser(loginName, organization); + // This action is unauthenticated (driven by the loginName ceremony param, not a session). + // "Unknown user" and "known user, idp not linked" must return the IDENTICAL response — + // previously the former redirected to /login while the latter returned this 400, making the + // action a linked-IdP identity oracle (redirect vs 400 vs the eventual 302-to-provider on + // success let an attacker probe both account existence and which IdP a given address uses). + if (!user) return data({ error: 'INVALID_INPUT' as const }, { status: 400 }); + + // Defensive server-side re-check: never trust the client's idpId — confirm it + // resolves to one of THIS identified user's own linked, active, non-LDAP providers + // before starting the round-trip. Mirrors the same check reauth.tsx's idp-reauth + // action uses for its own linked-IdP chooser. + const settingsOrg = await resolveOrg(provider, organization); + const [links, active] = await Promise.all([ + provider.listIdpLinks(user.id), + getActiveIdPs(provider, settingsOrg), + ]); + const isLinked = joinLinkedIdps(links, active).some( + (l) => l.idpId === idpId && l.type !== 'LDAP' + ); + if (!isLinked) return data({ error: 'INVALID_INPUT' as const }, { status: 400 }); + + const result = await startIdpIntent(provider, { + idpId, + origin: trustedAppOrigin(request), + requestId, + organization, + reauthHint: loginName, + }); + if (!result.ok) return data({ error: result.error }, { status: 502 }); + return redirect(result.authUrl); } export default function LoginMethod() { - const { methods, branding } = useLoaderData(); + const { methods, branding, idps, csrfToken } = useLoaderData(); const { loginName, requestId, organization } = useLoginContext(); // Typed paths.* emit the identical query string buildParams produced // (loginName, then requestId, then organization — undefined values are skipped). const query = { loginName, requestId, organization }; + const ceremony = usePasskeyLoginCeremony({ loginName, requestId, organization }); + const serverError = useAuthActionError(ceremony.actionData); + const passkeyBusy = ceremony.phase !== 'idle'; + + // Mirrors login/index.tsx's own submittingIdpId computation — drives IdpButtonList's + // per-row loading state while its POST to this route's own action is in flight. + const navigation = useNavigation(); + const submittingIdpId = + navigation.state !== 'idle' && navigation.formData?.get('intent') === 'idp' + ? String(navigation.formData.get('idpId') ?? '') + : null; + return (
@@ -81,37 +184,55 @@ export default function LoginMethod() { Choose how to sign in

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

+ {ceremony.reason ? ( + + + + ) : serverError ? ( + {serverError} + ) : null}
diff --git a/app/routes/login/passkey.tsx b/app/routes/login/passkey.tsx index 4bd7a7663a..8a97beb4c3 100644 --- a/app/routes/login/passkey.tsx +++ b/app/routes/login/passkey.tsx @@ -30,6 +30,24 @@ const _handlers = createWebAuthnVerifyHandlers({ }); export const loader = _handlers.loader; +// The in-place login ceremony (usePasskeyLoginCeremony) loads this route's loader +// for a challenge via fetcher, then submits the assertion to this route's action. +// RR's default post-submit revalidation would re-run the loader and mint a FRESH +// challenge on the Zitadel session, invalidating the just-signed assertion mid-flight +// (WEBAU-3M9si). Suppress ONLY that self-triggered revalidation — the hook tags its +// submit with `passkeyCeremony`. A full-page visit or retry (no marker) revalidates +// normally, so it always renders a fresh challenge. +export function shouldRevalidate({ + formData, + defaultShouldRevalidate, +}: { + formData?: FormData; + defaultShouldRevalidate: boolean; +}) { + if (formData?.get('passkeyCeremony') === '1') return false; + return defaultShouldRevalidate; +} + // Wrap the factory action to append the last-used-login cookie on successful // passkey sign-in. Two Set-Cookie headers cannot be joined into one string, so // we clone the redirect response and append via Headers.append(). @@ -76,7 +94,8 @@ export default function LoginPasskey() { error={errorMessage} loginName={loginName} requestId={requestId} - organization={organization}> + organization={organization} + showBackLink={false}> {/* Hidden form that WebAuthnButton populates and submits. */} - + {/* Generic label — at sign-in the credential may live in a + password manager, security key, or another device, so platform branding misleads. */} + Sign in with your passkey} + /> ); diff --git a/app/routes/logout/index.tsx b/app/routes/logout/index.tsx index c3251645a6..82c7f2c605 100644 --- a/app/routes/logout/index.tsx +++ b/app/routes/logout/index.tsx @@ -1,9 +1,10 @@ import { AuthCard } from '@/components/auth-card/auth-card'; -import { AuthFormFields } from '@/components/auth-form/auth-form-fields'; +import { IdentityBadge } from '@/components/identity-badge/identity-badge'; +import { SignOutButton } from '@/components/sign-out-button/sign-out-button'; +import { mostRecent, readSessions } from '@/modules/auth/session/cookie'; import { performLogout, logoutOutcomeToResponse, completeOidcLogout } from '@/resources/session'; import { providerForRequest } from '@/server/auth-context.server'; import { assertCsrf, loaderCsrf } from '@/server/csrf'; -import { Button } from '@datum-cloud/datum-ui/button'; import { Trans } from '@lingui/react/macro'; import { data, @@ -28,7 +29,10 @@ export async function loader({ request }: LoaderFunctionArgs) { } const { csrfToken, headers } = await loaderCsrf(request); - return data({ csrfToken }, { headers }); + // Show which account is being signed out of, mirroring password/change.tsx's pattern. + // Empty string (no active session) falls back to the generic confirm copy below. + const loginName = mostRecent(await readSessions(request))?.loginName ?? ''; + return data({ csrfToken, loginName }, { headers }); } export async function action({ request }: ActionFunctionArgs) { @@ -41,18 +45,19 @@ export async function action({ request }: ActionFunctionArgs) { } export default function Logout() { - const { csrfToken } = useLoaderData(); + const { csrfToken, loginName } = useLoaderData(); return ( Sign out} - description={Are you sure you want to sign out?}> + description={ + loginName ? ( + Sign out of} showLink={false} /> + ) : ( + Are you sure you want to sign out? + ) + }>
-
- - - +
); diff --git a/app/routes/passkeys.tsx b/app/routes/passkeys.tsx new file mode 100644 index 0000000000..838382275b --- /dev/null +++ b/app/routes/passkeys.tsx @@ -0,0 +1,322 @@ +// /id/passkeys — the passkey management page (SSO-precedent purpose page). +// +// List + add (→ /setup/passkey with return) + sudo-gated remove with the server-side +// last-method guard, the post-removal "sign out other sessions?" dialog, +// and the backup-method banner when only one sign-in method exists. +import { AuthCard } from '@/components/auth-card/auth-card'; +import { AuthFormFields } from '@/components/auth-form/auth-form-fields'; +import { FormError } from '@/components/form-error/form-error'; +import { IdentityBadge } from '@/components/identity-badge/identity-badge'; +import { SignOutButton } from '@/components/sign-out-button/sign-out-button'; +import { useAuthActionError } from '@/hooks/use-auth-action-error'; +import { readSessions, serializeSessions } from '@/modules/auth/session/cookie'; +import { passkeysActionSchema } from '@/resources/passkeys/passkeys.schema'; +import { + loadPasskeysView, + removeUserPasskey, + signOutOtherSessions, + type PasskeyRow, +} from '@/resources/passkeys/passkeys.service'; +import { paths } from '@/routes/paths'; +import { providerForRequest } from '@/server/auth-context.server'; +import { getCsrfToken, assertCsrf } from '@/server/csrf'; +import { env } from '@/server/infra/env.server'; +import { actionError } from '@/utils/errors/auth-error'; +import { Badge } from '@datum-cloud/datum-ui/badge'; +import { Button, LinkButton } from '@datum-cloud/datum-ui/button'; +import { Dialog } from '@datum-cloud/datum-ui/dialog'; +import { Icon } from '@datum-cloud/datum-ui/icons'; +import { Trans, useLingui } from '@lingui/react/macro'; +import { KeyRound, Plus, Trash2 } from 'lucide-react'; +import { useEffect, useState } from 'react'; +import { + data, + redirect, + useActionData, + useLoaderData, + type ActionFunctionArgs, + type LoaderFunctionArgs, + type MetaFunction, +} from 'react-router'; +import { Form as RRForm, Link } from 'react-router'; + +export const meta: MetaFunction = () => [{ title: 'Passkeys' }]; + +export async function loader({ request }: LoaderFunctionArgs) { + const url = new URL(request.url); + const provider = providerForRequest(request); + const sessions = await readSessions(request); + + const result = await loadPasskeysView(provider, sessions, { + returnTo: url.searchParams.get('returnTo'), + nowMs: Date.now(), + emailDeliveryEnabled: env.AUTH_EMAIL_DELIVERY_ENABLED, + }); + if (result.kind === 'redirect') return redirect(result.target); + + const [csrfToken, setCookie] = await getCsrfToken(request); + const headers: Record = {}; + if (setCookie !== null) headers['set-cookie'] = setCookie; + + return data({ csrfToken, view: result }, { headers }); +} + +export async function action({ request }: ActionFunctionArgs) { + const provider = providerForRequest(request); + const form = await request.formData(); + await assertCsrf(request, form); + + const parsed = passkeysActionSchema.safeParse(Object.fromEntries(form)); + if (!parsed.success) return data({ error: 'INVALID_INPUT' as const }, { status: 400 }); + + const sessions = await readSessions(request); + try { + if (parsed.data.intent === 'remove') { + const result = await removeUserPasskey(provider, sessions, { + passkeyId: parsed.data.passkeyId, + nowMs: Date.now(), + emailDeliveryEnabled: env.AUTH_EMAIL_DELIVERY_ENABLED, + }); + if (!result.ok) { + // Stale sudo: bounce through /reauth and return here (server-side enforcement). + if (result.error === 'SUDO_REQUIRED') { + return redirect(paths.reauth({ returnTo: paths.passkeys() })); + } + return data({ error: result.error }, { status: 400 }); + } + return data({ removed: result.removedName ?? '' }); + } + // intent === 'signout-others': sudo-gated; deletes the user's OTHER + // sessions cookie- AND provider-wide (all devices), keeps the active one. + const result = await signOutOtherSessions(provider, sessions, { nowMs: Date.now() }); + if (!result.ok) { + if (result.error === 'SUDO_REQUIRED') { + return redirect(paths.reauth({ returnTo: paths.passkeys() })); + } + return data({ error: result.error }, { status: 400 }); + } + return redirect(paths.passkeys(), { + headers: { 'set-cookie': await serializeSessions(result.sessions) }, + }); + } catch (err) { + return actionError(err); + } +} + +/** + * Confirm-before-remove dialog for one passkey row. The destructive submit lives inside + * the dialog so a stray click can't remove a sign-in method (mirrors UnlinkConfirmDialog). + * Icon-only trash trigger (accounts.tsx precedent) + danger confirm. + */ +function RemoveConfirmDialog({ row, csrfToken }: { row: PasskeyRow; csrfToken: string }) { + const [open, setOpen] = useState(false); + const actionData = useActionData(); + const { t } = useLingui(); + // Close when any action result lands: on success the row unmounts anyway, but on a + // refusal (LAST_METHOD) the inline error must not hide behind the modal overlay. + useEffect(() => { + if (actionData !== undefined) setOpen(false); + }, [actionData]); + return ( + + + + + + Remove this passkey?} + description={ + "{row.name}" will no longer work for signing in. This cannot be undone. + } + /> + + + + + + + + + + + + ); +} + +/** + * Post-removal session-hygiene offer as a dialog. "Not now" only + * closes it; the sign-out submit reuses the existing signout-others action intent. + */ +function SignOutOthersDialog({ + open, + onOpenChange, + csrfToken, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + csrfToken: string; +}) { + return ( + + + Passkey removed} + description={ + + Signed-in sessions on other devices can still be active. Sign out your other sessions? + + } + /> + + + + + + + + + + + ); +} + +export default function Passkeys() { + const { csrfToken, view } = useLoaderData(); + const actionData = useActionData(); + const { passkeys, loginName, returnTo } = view; + + const errorCode = (actionData as { error?: string } | undefined)?.error; + const genericError = useAuthActionError(actionData); + const { i18n } = useLingui(); + + // Open the sign-out-others dialog on each successful removal — actionData + // is a fresh object per POST, so the effect fires exactly once per removal. + const [signOutOpen, setSignOutOpen] = useState(false); + useEffect(() => { + // Open on a successful removal; close when any OTHER action result lands + // (the signout-others redirect clears actionData without remounting this route). + setSignOutOpen((actionData as { removed?: string } | undefined)?.removed !== undefined); + }, [actionData]); + + return ( + Passkeys} + description={ + <> + Passkeys let you sign in with your fingerprint, face, or device PIN. + {loginName && ( + Logged in as} + linkLabel={Not you?} + linkTarget={paths.accounts()} + /> + )} + + } + className="max-w-[480px]"> +
+ {errorCode === 'LAST_METHOD' ? ( + + You can't remove your only sign-in method. Add another method first. + + ) : genericError ? ( + {genericError} + ) : null} + + {passkeys.length === 0 ? ( + // Same minimal empty-state shape as /accounts and /sso. +

+ No passkeys yet. +

+ ) : ( +
    + {passkeys.map((row) => ( +
  • +
    + +
    +
    + {row.name} + {row.state === 'inactive' ? ( + // State is only surfaced when something is wrong — no "Active" noise. + + Inactive + + ) : null} +
    + {row.createdAt ? ( + // Enroll date; absent for passkeys with no created-at metadata (no backfill). + + + Added {i18n.date(new Date(row.createdAt), { dateStyle: 'medium' })} + + + ) : null} +
    +
    + +
  • + ))} +
+ )} + + }> + Add passkey + + + {returnTo && /^https?:\/\//.test(returnTo) ? ( + // Validated external entry point (portal round-trip) — offer the way back. + + Back + + ) : null} + + +
+ + +
+ ); +} diff --git a/app/routes/password/change.tsx b/app/routes/password/change.tsx index 05a28d250a..fb3eafa137 100644 --- a/app/routes/password/change.tsx +++ b/app/routes/password/change.tsx @@ -4,6 +4,7 @@ import { AuthFormFields } from '@/components/auth-form/auth-form-fields'; import { PasswordRequirements } from '@/components/auth-form/password-requirements'; import { BackLink } from '@/components/back-link/back-link'; import { FormError } from '@/components/form-error/form-error'; +import { IdentityBadge } from '@/components/identity-badge/identity-badge'; import { useAuthActionError } from '@/hooks/use-auth-action-error'; import { readSessions, mostRecent, byId } from '@/modules/auth/session/cookie'; import { changePassword } from '@/resources/password'; @@ -105,7 +106,13 @@ export default function PasswordChange() { className="flex w-full flex-col gap-4"> - {loginName ?

{loginName}

: null} + {loginName ? ( + Signing in as} + showLink={false} + /> + ) : null} diff --git a/app/routes/paths.ts b/app/routes/paths.ts index ef1c34adc3..cf50fefc02 100644 --- a/app/routes/paths.ts +++ b/app/routes/paths.ts @@ -68,6 +68,15 @@ export const paths = { index: (q?: Query) => withQuery('/logout', q), success: (q?: Query) => withQuery('/logout/success', q), }, + // Passkey management + sudo re-auth interstitial. + passkeys: (q?: Query) => withQuery('/passkeys', q), + reauth: (q?: Query) => withQuery('/reauth', q), + /** Separate from `reauth` (a callable function, unlike paths.sso's object shape) — + * see the Global Constraints note in the reauth-idp-verification plan for why. */ + reauthIdp: { + callback: (provider: string, q?: Query) => withQuery(`/reauth/${provider}/callback`, q), + error: (provider: string, q?: Query) => withQuery(`/reauth/${provider}/error`, q), + }, accounts: (q?: Query) => withQuery('/accounts', q), signedIn: (q?: Query) => withQuery('/signed-in', q), error: (q?: Query) => withQuery('/error', q), diff --git a/app/routes/reauth.tsx b/app/routes/reauth.tsx new file mode 100644 index 0000000000..0f52fd8a2d --- /dev/null +++ b/app/routes/reauth.tsx @@ -0,0 +1,410 @@ +// /id/reauth — the sudo interstitial ("Confirm it's you"). +// +// Verifies ONE enrolled factor onto the EXISTING session (SetSession semantics via +// reauth.service). Never touches login-decision.ts / next-step routing: on success the +// user returns to the validated returnTo (default /passkeys). +import { AuthCard } from '@/components/auth-card/auth-card'; +import { SubmitButton } from '@/components/auth-form/auth-form'; +import { AuthFormFields } from '@/components/auth-form/auth-form-fields'; +import { FormError } from '@/components/form-error/form-error'; +import { IdentityBadge } from '@/components/identity-badge/identity-badge'; +import { IdpIcon } from '@/components/idp-icon/idp-icon'; +import { WebAuthnButton, WebAuthnReasonCopy } from '@/components/webauthn-button/webauthn-button'; +import { useAuthActionError } from '@/hooks/use-auth-action-error'; +import { usePasskeyReauthCeremony } from '@/hooks/use-passkey-reauth-ceremony'; +import { mostRecent, readSessions, serializeSessions } from '@/modules/auth/session/cookie'; +import { isStaleSessionError } from '@/modules/auth/types'; +import { + loadReauth, + performReauth, + resolveDefaultReturnTo, + startReauthIdpIntent, + type ReauthLoadResult, + type ReauthMethod, +} from '@/resources/reauth/reauth.service'; +import { resolveOrg } from '@/resources/shared/resolve-org'; +import { validateReturnTo } from '@/resources/shared/return-to'; +import { joinLinkedIdps } from '@/resources/sso'; +import { getActiveIdPs } from '@/resources/sso/idp-providers'; +import { paths } from '@/routes/paths'; +import { providerForRequest } from '@/server/auth-context.server'; +import { getCsrfToken, assertCsrf } from '@/server/csrf'; +import { trustedAppOrigin } from '@/server/infra/app-origin.server'; +import { env } from '@/server/infra/env.server'; +import { actionError } from '@/utils/errors/auth-error'; +import { Button, LinkButton } from '@datum-cloud/datum-ui/button'; +import { Form } from '@datum-cloud/datum-ui/form'; +import { Icon } from '@datum-cloud/datum-ui/icons'; +import { cn } from '@datum-cloud/datum-ui/utils'; +import { Trans, useLingui } from '@lingui/react/macro'; +import { Key, Lock, Mail } from 'lucide-react'; +import { useRef } from 'react'; +import { + data, + redirect, + useActionData, + useLoaderData, + useNavigation, + type ActionFunctionArgs, + type LoaderFunctionArgs, + type MetaFunction, +} from 'react-router'; +import { Form as RRForm, Link } from 'react-router'; +import { z } from 'zod'; + +export const meta: MetaFunction = () => [{ title: "Confirm it's you" }]; + +const METHOD_PARAMS = ['passkey', 'password', 'otp_email'] as const; + +type ReauthView = Extract; + +export interface ReauthLoaderData { + csrfToken: string; + view: ReauthView; +} + +export async function loader({ request }: LoaderFunctionArgs) { + const url = new URL(request.url); + const rawMethod = url.searchParams.get('method'); + const method = (METHOD_PARAMS as readonly string[]).includes(rawMethod ?? '') + ? (rawMethod as ReauthMethod) + : null; + + const provider = providerForRequest(request); + const sessions = await readSessions(request); + + const result = await loadReauth(provider, sessions, { + returnTo: url.searchParams.get('returnTo'), + method, + domain: url.hostname, + emailDeliveryEnabled: env.AUTH_EMAIL_DELIVERY_ENABLED, + consoleUrl: `${env.ZITADEL_API_URL}/ui/console`, + defaultAppUrl: env.DEFAULT_APP_URL, + }); + if (result.kind === 'redirect') return redirect(result.target); + + const [csrfToken, setCookie] = await getCsrfToken(request); + const headers: Record = {}; + if (setCookie !== null) headers['set-cookie'] = setCookie; + + return data({ csrfToken, view: result }, { headers }); +} + +const reauthActionSchema = z.object({ + factor: z.enum(['passkey', 'password', 'otp_email']), + password: z.string().optional(), + code: z.string().optional(), + credential: z.string().optional(), + returnTo: z.string().optional(), +}); + +export async function action({ request }: ActionFunctionArgs) { + const provider = providerForRequest(request); + const form = await request.formData(); + await assertCsrf(request, form); + + if (form.get('intent') === 'idp-reauth') { + const idpId = String(form.get('idpId') ?? ''); + if (!idpId) return data({ error: 'INVALID_INPUT' as const }, { status: 400 }); + + const sessions = await readSessions(request); + const entry = mostRecent(sessions); + if (!entry) return redirect(paths.login.index()); + + // Never trust a client-echoed absolute returnTo (same fix as performReauth's success + // path) — re-resolve the configured default server-side when the submitted value + // doesn't validate, instead of collapsing to a hardcoded /passkeys before this + // round-trip even starts (which would then be baked into the callback URL with no + // way to recover the real destination downstream). + const returnTo = + validateReturnTo(String(form.get('returnTo') ?? '')) ?? + (await resolveDefaultReturnTo(provider, entry, { + consoleUrl: `${env.ZITADEL_API_URL}/ui/console`, + defaultAppUrl: env.DEFAULT_APP_URL, + })); + + // Resolve the session's own user — needed to verify idpId is actually linked to + // THEM, not just any active org provider. Same stale-session recovery loadReauth + // uses: getSession can throw a non-transient ProviderError for a revoked/stale + // cross-browser token instead of returning null. + let userId: string | undefined; + try { + const session = await provider.getSession(entry.id, entry.token); + if (!session) return redirect(paths.login.index()); + userId = session.user?.id ?? (await provider.findUser(entry.loginName))?.id; + if (!userId) return redirect(paths.login.index()); + } catch (err) { + if (isStaleSessionError(err)) return redirect(paths.login.index()); + throw err; + } + + // Defensive server-side re-check: never trust the client's idpId — confirm it + // resolves to an ACTIVE, non-LDAP provider AND is actually linked to THIS user + // before starting the round-trip (the chooser only ever shows linked providers; + // this must match, not just re-check "is some org IdP active"). Scoped to the SAME + // org loadReauth used to build the chooser's linkedIdps (Task 3). + const [links, active] = await Promise.all([ + provider.listIdpLinks(userId), + getActiveIdPs(provider, await resolveOrg(provider, entry.organization)), + ]); + const isLinked = joinLinkedIdps(links, active).some( + (l) => l.idpId === idpId && l.type !== 'LDAP' + ); + if (!isLinked) { + return data({ error: 'INVALID_INPUT' as const }, { status: 400 }); + } + + const result = await startReauthIdpIntent(provider, { + idpId, + origin: trustedAppOrigin(request), + returnTo, + loginHint: entry.loginName, + }); + if (!result.ok) return data({ error: result.error }, { status: 502 }); + return redirect(result.authUrl); + } + + const parsed = reauthActionSchema.safeParse(Object.fromEntries(form)); + if (!parsed.success) return data({ error: 'INVALID_INPUT' as const }, { status: 400 }); + + const sessions = await readSessions(request); + try { + const result = await performReauth(provider, sessions, { + factor: parsed.data.factor, + password: parsed.data.password, + code: parsed.data.code, + credential: parsed.data.credential, + returnTo: parsed.data.returnTo ?? null, + consoleUrl: `${env.ZITADEL_API_URL}/ui/console`, + defaultAppUrl: env.DEFAULT_APP_URL, + }); + if (!result.ok) { + const status = result.error === 'INVALID_CREDENTIALS' ? 401 : 400; + return data({ error: result.error }, { status }); + } + return redirect(result.target, { + headers: { 'set-cookie': await serializeSessions(result.sessions) }, + }); + } catch (err) { + return actionError(err); + } +} + +// The in-place passkey ceremony (usePasskeyReauthCeremony) loads THIS route's own +// loader (?method=passkey) for a challenge via fetcher, then submits the assertion +// to THIS route's action. RR's default post-submit revalidation would re-run the +// loader and mint a FRESH WebAuthn challenge on the Zitadel session, invalidating +// the just-signed assertion mid-flight (same class of bug as WEBAU-3M9si in +// login/passkey.tsx). Suppress ONLY that self-triggered revalidation — the hook +// tags its submit with passkeyCeremony. A full-page visit or retry (no marker) +// revalidates normally, so it always renders a fresh challenge. +export function shouldRevalidate({ + formData, + defaultShouldRevalidate, +}: { + formData?: FormData; + defaultShouldRevalidate: boolean; +}) { + if (formData?.get('passkeyCeremony') === '1') return false; + return defaultShouldRevalidate; +} + +// ── chooser row ──────────────────────────────────────────────────────────────── + +function MethodRow({ + href, + icon, + children, + disabled, +}: { + href: string; + icon: React.ReactNode; + children: React.ReactNode; + /** Inert while a sibling ceremony (the Passkey row) is busy. */ + disabled?: boolean; +}) { + // LinkButton (single styled
) — NOT Button asChild (nested-interactive axe violation). + return ( + disabled && e.preventDefault()} + iconPosition="left" + icon={icon}> + {children} + + ); +} + +export default function Reauth() { + const { csrfToken, view } = useLoaderData(); + const actionData = useActionData(); + const navigation = useNavigation(); + const { t } = useLingui(); + const formRef = useRef(null); + const errorMessage = useAuthActionError(actionData); + + const { loginName, methods, method, returnTo, linkedIdps, publicKeyCredentialRequestOptions } = + view; + + // In-place passkey ceremony — fires from the chooser (method === null) without + // navigating to the two-step ?method=passkey verify screen below. + const passkeyCeremony = usePasskeyReauthCeremony({ returnTo }); + const passkeyCeremonyError = useAuthActionError(passkeyCeremony.actionData); + const passkeyBusy = passkeyCeremony.phase !== 'idle'; + + // Extract the inner publicKey object that marshalAssertion expects. + const publicKey = + publicKeyCredentialRequestOptions !== null && + typeof publicKeyCredentialRequestOptions === 'object' && + 'publicKey' in (publicKeyCredentialRequestOptions as object) + ? (publicKeyCredentialRequestOptions as { publicKey: unknown }).publicKey + : publicKeyCredentialRequestOptions; + + return ( + Confirm it's you} + description={ + <> + For your security, verify one of your sign-in methods to continue. + {loginName && ( + Logged in as} + linkLabel={Not you?} + linkTarget={paths.accounts()} + /> + )} + + }> + {method === null ? ( +
+ {passkeyCeremony.reason ? ( + + + + ) : passkeyCeremonyError ? ( + {passkeyCeremonyError} + ) : null} + {methods.includes('passkey') ? ( + // Button (not MethodRow/LinkButton) — fires the ceremony IN PLACE (lazy + // challenge via fetcher) instead of navigating to ?method=passkey. The + // chooser stays visible as the fallback on failure. + + ) : null} + {methods.includes('idp') && + linkedIdps.map((idp) => ( + + + + + + + + ))} + {methods.includes('password') ? ( + } + disabled={passkeyBusy}> + Password + + ) : null} + {methods.includes('otp_email') ? ( + } + disabled={passkeyBusy}> + Email me a code + + ) : null} + {methods.length === 0 ? ( + + No sign-in method is available for re-authentication. + + ) : null} +
+ ) : method === 'passkey' ? ( + // Hidden form that WebAuthnButton populates and submits. + + + + + + {errorMessage ? {errorMessage} : null} + + + ) : method === 'password' ? ( + + + + + + + + {errorMessage} + + Confirm + + + ) : ( + + + + +

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

+ + + + {errorMessage} + + Confirm + +
+ )} +
+ ); +} diff --git a/app/routes/reauth/provider/callback.tsx b/app/routes/reauth/provider/callback.tsx new file mode 100644 index 0000000000..de92ec2310 --- /dev/null +++ b/app/routes/reauth/provider/callback.tsx @@ -0,0 +1,66 @@ +// /reauth/:provider/callback — headless loader completing an idp-reauth round-trip. +// Verifies the returned idpIntent onto the EXISTING session via performReauth; never +// creates a session or signs in as a different identity (that's the ordinary /sso +// callback's job, a deliberately separate code path — see the design doc). +// +// No app-level state/nonce binds this GET to the request that started the round-trip +// (reviewed and deliberately kept this way, not an oversight): the idpIntentId/idpIntentToken +// pair is minted and validated by ZITADEL itself, opaque and unforgeable without actually +// completing a real OAuth round-trip with the provider — an attacker cannot manufacture a +// valid pair out-of-band the way they could forge our own state param. The actual security +// boundary is performReauth's identity check (FAILED_PRECONDITION → 'access-denied' when the +// intent was verified against a DIFFERENT user than the active session), which a same-origin +// app-level nonce would not add to. /sso/:provider/callback (processIdpCallback) relies on the +// identical Zitadel-token + identity-check pairing with no state param either — this matches +// that established, deliberate pattern rather than diverging from it. +import { readSessions, mostRecent, serializeSessions } from '@/modules/auth/session/cookie'; +import { performReauth } from '@/resources/reauth/reauth.service'; +import { validateReturnTo } from '@/resources/shared/return-to'; +import { paths } from '@/routes/paths'; +import { providerForRequest } from '@/server/auth-context.server'; +import { env } from '@/server/infra/env.server'; +import { redirect, type LoaderFunctionArgs } from 'react-router'; + +export async function loader({ request, params }: LoaderFunctionArgs) { + const url = new URL(request.url); + const idpIntentId = url.searchParams.get('id'); + const idpIntentToken = url.searchParams.get('token'); + const rawReturnTo = url.searchParams.get('returnTo'); + // Degraded fallback for the two error-redirect paths below (context-missing / + // access-denied) — just carrying a best-effort value forward for a retry, always safe + // since /reauth re-validates it. The SUCCESS path passes rawReturnTo (not this) to + // performReauth so its own default-resolution logic runs when this doesn't validate, + // instead of prematurely collapsing to /passkeys before performReauth ever sees it. + const returnTo = validateReturnTo(rawReturnTo) ?? paths.passkeys(); + const providerSlug = params.provider ?? 'idp'; + + const provider = providerForRequest(request); + const sessions = await readSessions(request); + if (!mostRecent(sessions)) return redirect(paths.login.index()); + + if (!idpIntentId || !idpIntentToken) { + return redirect(paths.reauthIdp.error(providerSlug, { returnTo, reason: 'context-missing' })); + } + + const result = await performReauth(provider, sessions, { + factor: 'idp', + idpIntentId, + idpIntentToken, + returnTo: rawReturnTo, + consoleUrl: `${env.ZITADEL_API_URL}/ui/console`, + defaultAppUrl: env.DEFAULT_APP_URL, + }); + if (!result.ok) { + // The only failure performReauth's idp branch returns (rather than throws) is an + // idpIntent verified against a DIFFERENT user than the active session — same copy + // and reason key as /sso's access-denied case. + return redirect(paths.reauthIdp.error(providerSlug, { returnTo, reason: 'access-denied' })); + } + return redirect(result.target, { + headers: { 'set-cookie': await serializeSessions(result.sessions) }, + }); +} + +export default function ReauthProviderCallback() { + return null; +} diff --git a/app/routes/reauth/provider/error.tsx b/app/routes/reauth/provider/error.tsx new file mode 100644 index 0000000000..a3e32cd246 --- /dev/null +++ b/app/routes/reauth/provider/error.tsx @@ -0,0 +1,43 @@ +// /reauth/:provider/error — thin error screen for a failed idp-reauth round-trip. +// Mirrors sso/provider/error.tsx's structure exactly; the only difference is the +// "back" target (the reauth chooser, not /login) and the copy (reauth, not sign-in). +import { AuthCard } from '@/components/auth-card/auth-card'; +import { paths } from '@/routes/paths'; +import { LinkButton } from '@datum-cloud/datum-ui/button'; +import { Trans } from '@lingui/react/macro'; +import { Link, useParams, useSearchParams } from 'react-router'; + +export function meta() { + return [{ title: "Couldn't verify" }]; +} + +// Same reason vocabulary/copy as sso/provider/error.tsx's REASONS map (reuses its i18n +// keys) — only the two reasons the reauth-idp round-trip can actually produce. +const REASONS: Record = { + 'access-denied': That identity belongs to a different account., + 'context-missing': The sign-in link was incomplete or expired., +}; + +export default function ReauthProviderError() { + const { provider } = useParams(); + const [sp] = useSearchParams(); + const returnTo = sp.get('returnTo') ?? undefined; + const reason = sp.get('reason') ?? ''; + const message = REASONS[reason] ?? ( + + Something went wrong with {provider}. + + ); + return ( + Couldn't verify} description={message}> + + Try again + + + ); +} diff --git a/app/routes/setup/passkey.tsx b/app/routes/setup/passkey.tsx index 0669268501..732b3bf6ca 100644 --- a/app/routes/setup/passkey.tsx +++ b/app/routes/setup/passkey.tsx @@ -8,9 +8,13 @@ import { PASSKEY_ENROLL_CONFIG, type WebAuthnEnrollActionData, } from '@/resources/webauthn'; +import { aaguidFromAttestationObject, defaultPasskeyName } from '@/resources/webauthn/aaguid'; +import { Button } from '@datum-cloud/datum-ui/button'; +import { Input } from '@datum-cloud/datum-ui/input'; +import { Label } from '@datum-cloud/datum-ui/label'; import { Trans } from '@lingui/react/macro'; -import { useRef } from 'react'; -import { useActionData, useLoaderData, type MetaFunction } from 'react-router'; +import { useEffect, useRef, useState } from 'react'; +import { useActionData, useLoaderData, useNavigation, type MetaFunction } from 'react-router'; import { Form as RRForm } from 'react-router'; export const meta: MetaFunction = () => [{ title: 'Set up passkey' }]; @@ -21,6 +25,15 @@ const h = createWebAuthnEnrollHandlers(PASSKEY_ENROLL_CONFIG); export const loader = h.loader; export const action = h.action; +/** The credential held between the ceremony (step 1) and the name step (step 2). */ +interface HeldCredential { + credential: Record; + /** AAGUID catalog name, else the UA-derived fallback — the name input's pre-fill. */ + defaultName: string; + /** UA-derived device label — rendered as a hint when it adds context over defaultName. */ + uaName: string; +} + export default function SetupPasskey() { // The local-const re-export lets RR7 infer the loader return (data). const { @@ -33,9 +46,22 @@ export default function SetupPasskey() { credentialId, publicKey, challengeFailed, + returnTo, } = useLoaderData(); const actionData = useActionData() as WebAuthnEnrollActionData | undefined; const formRef = useRef(null); + const navigation = useNavigation(); + + // Name-after-ceremony: null = step 1 (run the ceremony), non-null = step 2 + // (name the held credential). The AAGUID exists only in the returned attestation, + // so the pre-fill is computed at this transition. + const [held, setHeld] = useState(null); + // A step-2 (Save) failure means navigator.credentials.create() ALREADY ran and the + // credential already exists in the user's authenticator/password manager — dropping back + // to step 1 silently, with no warning, means the next "Register passkey" click creates a + // SECOND credential and orphans the first (there is no WebAuthn API to cancel/delete it + // remotely). Persists across the reset so step 1 can warn instead of silently duplicating. + const [orphanRisk, setOrphanRisk] = useState(false); // Inline message + a recovery for recoverable codes (SESSION_EXPIRED → "Sign in again"). const { message: errorMessage, recovery } = useAuthActionRecovery(actionData, { @@ -43,21 +69,50 @@ export default function SetupPasskey() { organization, }); + // A verify failure while holding a credential (challenge expiry surfaces as + // INVALID_CREDENTIALS) auto-resets to step 1 — React Router's post-action + // revalidation has already fetched a fresh challenge, so one click retries the + // full ceremony. + useEffect(() => { + if (actionData?.error) { + setOrphanRisk(true); + setHeld(null); + } + }, [actionData]); + + function handleCredential(credential: Record) { + const att = (credential.response as { attestationObject?: string } | undefined) + ?.attestationObject; + const aaguid = att ? aaguidFromAttestationObject(att) : null; + setOrphanRisk(false); + setHeld({ + credential, + defaultName: defaultPasskeyName(aaguid, navigator.userAgent), + uaName: defaultPasskeyName(null, navigator.userAgent), + }); + } + return ( Set up passkey} + title={held ? Name your passkey : Set up passkey} description={ - - Register a passkey using your device's biometric sensor or PIN to sign in securely without - a password. - + held ? ( + Your passkey is ready — give it a name so you can recognize it later. + ) : ( + + Register a passkey using your device's biometric sensor or PIN to sign in securely + without a password. + + ) } - error={errorMessage} - recovery={recovery} + /* A prior failure's banner is step-1 context — a freshly re-entered + name step must not show "setup failed" beside "your passkey is ready". */ + error={held ? undefined : errorMessage} + recovery={held ? undefined : recovery} loginName={loginName} requestId={requestId} organization={organization}> - {/* Hidden form that WebAuthnButton populates and submits. */} + {/* One form spans both steps — the hidden fields ride along on the step-2 submit. */} {force ? : null} {checkAfter ? : null} - {/* credential is populated by WebAuthnButton before submit */} - + {/* Validated return target (/passkeys round-trip) — posted, re-validated server-side. */} + {returnTo ? : null} + {/* Carries the held ceremony result into the step-2 submit. */} + - {/* The loader couldn't fetch an attestation challenge — warn up front - with enrollment-specific copy (distinct from the assertion verification error). */} - {challengeFailed ? ( - - We couldn't start passkey setup. Please try again. - - ) : null} + {held ? ( + <> + {/* Step 2 — Save-only: the authenticator-side credential already exists, + so there is no cancel (it would orphan the entry in the user's password + manager). Pre-fill from AAGUID, else the UA fallback; set-once (no rename RPC). */} +
+ + + {held.uaName !== held.defaultName ? ( +

+ Created using {held.uaName} +

+ ) : null} +

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

+
+ + + ) : ( + <> + {/* The loader couldn't fetch an attestation challenge — warn up front + with enrollment-specific copy (distinct from the assertion verification error). */} + {challengeFailed ? ( + + We couldn't start passkey setup. Please try again. + + ) : null} - Register passkey} - /> + {orphanRisk ? ( +

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

+ ) : null} + + Register passkey} + onCredential={handleCredential} + /> + + )}
); diff --git a/app/routes/signed-in.tsx b/app/routes/signed-in.tsx index f6f85b800c..3fc03138f5 100644 --- a/app/routes/signed-in.tsx +++ b/app/routes/signed-in.tsx @@ -1,11 +1,12 @@ import { AuthCard } from '@/components/auth-card/auth-card'; -import { AuthFormFields } from '@/components/auth-form/auth-form-fields'; +import { IdentityBadge } from '@/components/identity-badge/identity-badge'; +import { SignOutButton } from '@/components/sign-out-button/sign-out-button'; import { TrackOnMount, identifyUser } from '@/modules/analytics/rybbit'; import { resolveSignedIn } from '@/resources/session'; +import { paths } from '@/routes/paths'; import { providerForRequest } from '@/server/auth-context.server'; import { loaderCsrf } from '@/server/csrf'; import { env } from '@/server/infra/env.server'; -import { Button } from '@datum-cloud/datum-ui/button'; import { Trans } from '@lingui/react/macro'; import { useEffect } from 'react'; import { data, redirect, useLoaderData, type LoaderFunctionArgs } from 'react-router'; @@ -42,25 +43,17 @@ export default function SignedIn() { title={You are signed in} description={ loginName ? ( - - You are signed in as {loginName} - + You are signed in as} + linkLabel={Not you?} + linkTarget={paths.accounts()} + /> ) : null }>
- {/* Sign-out form posts to the logout INDEX route. Its action lives on - routes/logout/index, which shares /id/logout with the action-less layout, so - React Router needs ?index to target the index action — a native
won't - append it the way RR does; without it the POST 405s on the layout. - Explicit literal path because RR basename-prefixing only applies to RR . - A logout journey should select form[action^="/id/logout"] — keep that prefix. */} - - - - +
); diff --git a/app/routes/signup/method.tsx b/app/routes/signup/method.tsx index 329eca392e..69437433b3 100644 --- a/app/routes/signup/method.tsx +++ b/app/routes/signup/method.tsx @@ -1,7 +1,7 @@ import { AuthCard } from '@/components/auth-card/auth-card'; import { AuthCeremony } from '@/components/auth-ceremony/auth-ceremony'; import { AuthFormFields } from '@/components/auth-form/auth-form-fields'; -import { BackLink } from '@/components/back-link/back-link'; +import { IdentityBadge } from '@/components/identity-badge/identity-badge'; import { useAuthActionError } from '@/hooks/use-auth-action-error'; import { readSessions, serializeSessions } from '@/modules/auth/session/cookie'; import { MaxMindTracker, syncMaxMindTokenToRef } from '@/modules/fraud/maxmind-tracker'; @@ -14,6 +14,7 @@ import { } from '@/resources/signup'; import { resolveSignupView } from '@/resources/signup/signup-view'; import { signupMethodSchema } from '@/resources/signup/signup.schema'; +import { paths } from '@/routes/paths'; import { providerForRequest } from '@/server/auth-context.server'; import { loaderCsrf, assertCsrf } from '@/server/csrf'; import { requireEmailVerification } from '@/server/env'; @@ -275,6 +276,9 @@ export default function SignupMethod() { // replaces the per-route toast). const errorMessage = useAuthActionError(actionData); + // "Not you?" returns to the signup start (mirrors signup/password.tsx one step later). + const notYouHref = paths.signup.index({ requestId, organization }); + // Enumeration-safe terminal: the email-link path (and an existing-email passkey/IdP // attempt) returns a generic "check your email" — render it here, otherwise the screen // would silently re-render with no feedback. @@ -310,9 +314,11 @@ export default function SignupMethod() { Finish creating your account} description={ - - You're almost done! {loginName} - + Signing up as} + linkTarget={notYouHref} + /> } error={errorMessage}>
@@ -373,8 +379,6 @@ export default function SignupMethod() { ) : null}
- -
); diff --git a/app/routes/signup/password.tsx b/app/routes/signup/password.tsx index 2e792dfbf6..1f5906e7bb 100644 --- a/app/routes/signup/password.tsx +++ b/app/routes/signup/password.tsx @@ -3,6 +3,7 @@ import { AuthCeremony } from '@/components/auth-ceremony/auth-ceremony'; import { SubmitButton } from '@/components/auth-form/auth-form'; import { AuthFormFields } from '@/components/auth-form/auth-form-fields'; import { PasswordRequirements } from '@/components/auth-form/password-requirements'; +import { IdentityBadge } from '@/components/identity-badge/identity-badge'; import { useAuthActionError } from '@/hooks/use-auth-action-error'; import { TrackOnMount } from '@/modules/analytics/rybbit'; import { readSessions, serializeSessions } from '@/modules/auth/session/cookie'; @@ -35,7 +36,7 @@ import { type LoaderFunctionArgs, type MetaFunction, } from 'react-router'; -import { Form as RRForm, Link } from 'react-router'; +import { Form as RRForm } from 'react-router'; export const meta: MetaFunction = () => [{ title: 'Set a password' }]; @@ -173,8 +174,8 @@ export default function SignupPassword() { // "Not you?" returns to the signup start (not /login — this is a signup ceremony) so a // different email can be entered. Mirrors IdentityBadge's requestId/organization threading, - // loginName intentionally dropped. AuthCeremony's shared IdentityBadge is login-only copy - // ("Signing in as" → /login), so the identity + link are rendered inline here instead. + // loginName intentionally dropped. This route uses IdentityBadge with overridden verb and + // linkTarget props to adapt its default login-only copy ("Signing in as" → /login) to signup. const notYouHref = paths.signup.index({ requestId, organization }); if (actionData && 'sent' in actionData) { @@ -197,12 +198,11 @@ export default function SignupPassword() { Set a password} description={ - <> - Signing up as {loginName}.{' '} - - Not you? - - + Signing up as} + linkTarget={notYouHref} + /> } error={errorMessage}> Linked accounts} description={ - - You can link multiple accounts to your Datum account.{' '} + <> + You can link multiple accounts to your Datum account. {loginName && ( - <> - Logged in as {loginName}. - + Logged in as} + linkLabel={Not you?} + linkTarget={paths.accounts()} + /> )} - + }>
{/* Linked IdPs */} @@ -248,14 +254,7 @@ export default function SsoPage() { ) : null} - {/* Sign-out link → logout INDEX action; ?index disambiguates from the - action-less logout/layout (native
won't add it like RR does). */} - - - - +
); diff --git a/app/routes/sso/ldap.tsx b/app/routes/sso/ldap.tsx index 8b8e8229f4..2f9514ab00 100644 --- a/app/routes/sso/ldap.tsx +++ b/app/routes/sso/ldap.tsx @@ -59,7 +59,10 @@ export default function SsoLdap() { const errorMessage = useAuthActionError(actionData); return ( - Sign in with LDAP} error={errorMessage}> + Sign in with LDAP} + error={errorMessage} + showBackLink={false}> ({ app.use('*', mfaEnrollRateLimit); app.use('*', accountsRateLimit); app.use('*', verifyEmailSendRateLimit); + app.use('*', reauthRateLimit); + app.use('*', passkeysRateLimit); + app.use('*', loginMethodRateLimit); app.get('/healthz', (c) => c.json({ status: 'ok' })); app.get('/readyz', (c) => c.json({ status: 'ready' })); app.get('/security', (c) => diff --git a/app/server/middleware/rate-limit.ts b/app/server/middleware/rate-limit.ts index be732402c9..0cc517ec16 100644 --- a/app/server/middleware/rate-limit.ts +++ b/app/server/middleware/rate-limit.ts @@ -247,3 +247,44 @@ export const passwordResetRateLimit: MiddlewareHandler = createRateLimit({ match: (c, pathname) => c.req.method === 'POST' && pathname === '/id/password/reset', key: (_c, ip) => ip, }); + +// One shared limiter for /id/reauth: 10 attempts / 5 min per ip — same class as +// mfaVerifyLimiter/webauthnVerifyLimiter (a verification ceremony onto an EXISTING session). +// Covers every factor (password, otp_email, passkey, idp-reauth) alike: the sudo re-verify +// screen was previously unthrottled despite sitting beside ten throttled siblings, and +// Zitadel's own failedAttempts lockout only partly covers the password case (none for the +// emailed OTP code). +// BODY-STREAM HAZARD: factor/password/code/credential are in the POST body — key is ip-only. +const reauthLimiter = new RateLimiter({ limit: 10, windowMs: 5 * 60_000 }); + +export const reauthRateLimit: MiddlewareHandler = createRateLimit({ + limiter: reauthLimiter, + match: (c, pathname) => c.req.method === 'POST' && pathname === '/id/reauth', + key: (_c, ip) => ip, +}); + +// One shared limiter for /id/passkeys: 15 attempts / 5 min per ip — matches accountsRateLimit's +// reasoning (session-gated, not a brute-force target, but `signout-others` is destructive and +// cross-device, so scripted abuse from a compromised session should still be throttled). +// BODY-STREAM HAZARD: intent/passkeyId are in the POST body — key is ip-only. +const passkeysLimiter = new RateLimiter({ limit: 15, windowMs: 5 * 60_000 }); + +export const passkeysRateLimit: MiddlewareHandler = createRateLimit({ + limiter: passkeysLimiter, + match: (c, pathname) => c.req.method === 'POST' && pathname === '/id/passkeys', + key: (_c, ip) => ip, +}); + +// One shared limiter for /id/login/method: 10 attempts / 5 min per ip — same class as +// mfaVerifyLimiter. This action is unauthenticated (driven by a loginName ceremony param, not +// a session) and its three distinguishable outcomes (unknown user / known-but-unlinked idp / +// linked idp) make it a linked-IdP identity oracle; this bounds the per-IP probing rate as a +// backstop alongside the response-collapsing fix at the route. +// BODY-STREAM HAZARD: idpId is in the POST body — key is ip-only. +const loginMethodLimiter = new RateLimiter({ limit: 10, windowMs: 5 * 60_000 }); + +export const loginMethodRateLimit: MiddlewareHandler = createRateLimit({ + limiter: loginMethodLimiter, + match: (c, pathname) => c.req.method === 'POST' && pathname === '/id/login/method', + key: (_c, ip) => ip, +}); diff --git a/bun.lock b/bun.lock index bada31677f..20537c102d 100644 --- a/bun.lock +++ b/bun.lock @@ -77,6 +77,7 @@ "@opentelemetry/core": "2.8.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/sdk-trace-base": "2.8.0", + "axios": "^1.18.0", "dompurify": "^3.4.11", "esbuild": "^0.28.1", "form-data": "^4.0.6", @@ -999,7 +1000,7 @@ "ecc-jsbn": ["ecc-jsbn@0.1.2", "", { "dependencies": { "jsbn": "~0.1.0", "safer-buffer": "^2.1.0" } }, "sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw=="], - "electron-to-chromium": ["electron-to-chromium@1.5.395", "", {}, "sha512-7zt9Aw+SrmxLWLN0zhaTWZQiCdryLVrYTq5R7iZakLvi2UQPYMMsROYV/2qVCzMeCiSXHwKOU+sZ4zOVVlrtKA=="], + "electron-to-chromium": ["electron-to-chromium@1.5.396", "", {}, "sha512-yHiw2Y3C3H9U6TMbOfoWK/BPreiOPXRfTWPBwQBoZG6/8TB6eOPnsy5oaRYuatR7Fw2SJ4kKforgufeo7fq0EQ=="], "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], @@ -1007,7 +1008,7 @@ "enhanced-resolve": ["enhanced-resolve@5.24.2", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-rpsZEGT1jFuve6QlpyRp9ckQ+kN61hvF9BzCPyMdaKTm8UJce96KBn3sorXOFXlzjPrs3Vc4T1NsSroZ3PxlFw=="], - "entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], + "entities": ["entities@8.0.0", "", {}, "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA=="], "environment": ["environment@1.1.0", "", {}, "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q=="], @@ -1891,6 +1892,8 @@ "@typescript-eslint/typescript-estree/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], + "@vue/compiler-core/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], + "aria-hidden/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "axios/proxy-from-env": ["proxy-from-env@2.1.0", "", {}, "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA=="], @@ -1965,8 +1968,6 @@ "ora/string-width": ["string-width@8.2.2", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg=="], - "parse5/entities": ["entities@8.0.0", "", {}, "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA=="], - "path-scurry/lru-cache": ["lru-cache@11.5.2", "", {}, "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g=="], "pkg-types/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], diff --git a/cypress/component/components/auth-ceremony/auth-ceremony.cy.tsx b/cypress/component/components/auth-ceremony/auth-ceremony.cy.tsx index c3efa9fb17..3b5bea9a80 100644 --- a/cypress/component/components/auth-ceremony/auth-ceremony.cy.tsx +++ b/cypress/component/components/auth-ceremony/auth-ceremony.cy.tsx @@ -35,6 +35,36 @@ describe('AuthCeremony shell', () => { }); }); +describe('AuthCeremony shell — identity centering', () => { + it('centers the IdentityBadge row (items-center, not items-baseline)', () => { + cy.mount( + + c + , + OPTS + ); + cy.get('[data-testid="auth-ceremony-body"]') + .should('have.class', 'items-center') + .and('not.have.class', 'items-baseline'); + }); +}); + +describe('AuthCeremony shell — showBackLink suppression', () => { + it('renders no Back control when showBackLink={false}, even at a path with a real predecessor', () => { + // OPTS mounts at /login/password, which DOES have a predecessor in previous-step.ts + // (-> /login) — proving suppression here (not just at a dead-link path) is what makes + // Tasks 3 and 5's showBackLink={false} route changes meaningfully tested: this test + // proves the mechanism; those tasks prove the specific routes wire it through. + cy.mount( + + c + , + OPTS + ); + cy.get('a').should('not.exist'); + }); +}); + describe('OtpCodeField', () => { function mountOtpField(label = 'Email code', name = 'code') { cy.mount( diff --git a/cypress/component/components/back-link/previous-step.cy.ts b/cypress/component/components/back-link/previous-step.cy.ts index aa5d586c72..5fe9e983d4 100644 --- a/cypress/component/components/back-link/previous-step.cy.ts +++ b/cypress/component/components/back-link/previous-step.cy.ts @@ -4,7 +4,6 @@ describe('previousStepFor', () => { it('maps each ceremony step to its predecessor, including /setup/* enrollment screens, the MFA chooser, password-management screens, and terminal/headless steps (spec §5)', () => { expect(previousStepFor('/login/password')).to.equal('/login'); expect(previousStepFor('/login/mfa')).to.equal('/login/password'); - expect(previousStepFor('/login/verify/email')).to.equal('/login/mfa'); expect(previousStepFor('/signup/password')).to.equal('/signup'); expect(previousStepFor('/password/reset')).to.equal('/login/password'); @@ -24,4 +23,19 @@ describe('previousStepFor', () => { expect(previousStepFor('/signed-in')).to.be.null; expect(previousStepFor('/login/passkey')).to.be.null; }); + + it('verify/* and security-key Back goes straight to /login, not /login/mfa (fixes the sole-factor loop, 2026-07-22)', () => { + // resolveMfaPicker's sole-factor short-circuit (mfa.service.ts) redirects /login/mfa + // straight back to whichever verify/security-key screen is the user's only enrolled + // factor, BEFORE any picker UI renders — so a Back target of /login/mfa silently + // loops. All four sole-factor USE_SCREEN targets go to /login instead. + expect(previousStepFor('/login/verify/email')).to.equal('/login'); + expect(previousStepFor('/login/verify/sms')).to.equal('/login'); + expect(previousStepFor('/login/verify/authenticator')).to.equal('/login'); + expect(previousStepFor('/login/security-key')).to.equal('/login'); + }); + + it('signup/method returns to /signup (mirrors signup/password)', () => { + expect(previousStepFor('/signup/method')).to.equal('/signup'); + }); }); diff --git a/cypress/component/components/identity-badge/identity-badge.cy.tsx b/cypress/component/components/identity-badge/identity-badge.cy.tsx index 9e7f2ab911..b79bf344f6 100644 --- a/cypress/component/components/identity-badge/identity-badge.cy.tsx +++ b/cypress/component/components/identity-badge/identity-badge.cy.tsx @@ -18,4 +18,29 @@ describe('IdentityBadge', () => { cy.get('p').should('not.exist'); cy.get('a').should('not.exist'); }); + + it('supports a custom verb, link label, and link target (defaults unchanged)', () => { + cy.mount( + + ); + cy.contains('Signing up as').should('exist'); + cy.contains('bob@acme.test').should('exist'); + cy.findByRole('link', { name: /not you/i }).should( + 'have.attr', + 'href', + '/signup?requestId=oidc_z' + ); + }); + + it('renders no link at all when showLink is false', () => { + cy.mount(); + cy.contains('Sign out of').should('exist'); + cy.contains('carol@acme.test').should('exist'); + cy.get('a').should('not.exist'); + }); }); diff --git a/cypress/component/components/sign-out-button/sign-out-button.cy.tsx b/cypress/component/components/sign-out-button/sign-out-button.cy.tsx new file mode 100644 index 0000000000..ecdd1ac1c4 --- /dev/null +++ b/cypress/component/components/sign-out-button/sign-out-button.cy.tsx @@ -0,0 +1,26 @@ +import { SignOutButton } from '@/components/sign-out-button/sign-out-button'; + +// The datum-ui Button does not render a `data-theme` attribute — its `theme`/`type` props +// are compiled to Tailwind utility classes only (confirmed by mounting Button directly and +// inspecting outerHTML: theme="solid" + type="primary" emits a class containing +// "bg-btn-primary"; theme="link" + type="secondary" emits a class containing "underline", +// with no compound-variant overlap between the two). Assert on those classes instead. +describe('SignOutButton', () => { + it('posts to /id/logout?index with the CSRF token and defaults to the secondary/link treatment', () => { + cy.mount(); + cy.get('form').should('have.attr', 'action', '/id/logout?index'); + cy.get('input[name="csrf"]').should('have.value', 'tok-1'); + cy.contains('button', 'Sign out') + .invoke('attr', 'class') + .should('include', 'underline') + .and('not.include', 'bg-btn-primary'); + }); + + it('renders the primary/solid treatment when emphasis="primary"', () => { + cy.mount(); + cy.contains('button', 'Sign out') + .invoke('attr', 'class') + .should('include', 'bg-btn-primary') + .and('not.include', 'underline'); + }); +}); diff --git a/cypress/component/components/webauthn-button/webauthn-button.ceremony-errors.cy.tsx b/cypress/component/components/webauthn-button/webauthn-button.ceremony-errors.cy.tsx new file mode 100644 index 0000000000..afaed8200f --- /dev/null +++ b/cypress/component/components/webauthn-button/webauthn-button.ceremony-errors.cy.tsx @@ -0,0 +1,109 @@ +// Browser-error-specific WebAuthn failure copy. +// +// navigator.credentials.create()/.get() THROW a DOMException on a real failure (no +// authenticator, cancelled, already-registered). WebAuthnButton must classify that +// DOMException and render a message specific to BOTH the reason AND the flow — +// enroll (mode="attestation") vs sign-in (mode="assertion", the default). +// +// Under Cypress the button normally takes the pre-baked-credential shortcut (see +// webauthn-button.cy.tsx). Here we opt INTO the real ceremony via the documented +// window.__webAuthnRealCeremony seam and stub navigator.credentials to reject with a +// specific DOMException, so the full path (ceremony → classifyWebAuthnError → copy) +// is exercised end-to-end. +import { WebAuthnButton } from '@/components/webauthn-button/webauthn-button'; +import React from 'react'; + +const PK_GET = { challenge: 'YQ', allowCredentials: [] }; +const PK_CREATE = { challenge: 'YQ', user: { id: 'YQ' }, excludeCredentials: [] }; + +function ensureWebAuthnEnv(win: Window): void { + const w = win as unknown as { PublicKeyCredential?: unknown }; + if (typeof w.PublicKeyCredential === 'undefined') { + w.PublicKeyCredential = function () {} as unknown; + } + if (!win.navigator.credentials) { + Object.defineProperty(win.navigator, 'credentials', { + value: { create: () => Promise.resolve(null), get: () => Promise.resolve(null) }, + configurable: true, + }); + } +} + +/** Mount the button, force the real ceremony, and reject the given ceremony method with a DOMException. */ +function mountRejecting( + mode: 'assertion' | 'attestation', + method: 'create' | 'get', + domName: string +) { + const formRef = React.createRef(); + const publicKey = mode === 'attestation' ? PK_CREATE : PK_GET; + cy.mountRemixRoute(, { + path: '/passkey', + initialEntries: ['/passkey'], + }); + cy.window().then((win) => { + (win as unknown as { __webAuthnRealCeremony?: boolean }).__webAuthnRealCeremony = true; + ensureWebAuthnEnv(win); + cy.stub(win.navigator.credentials, method).rejects(new win.DOMException('boom', domName)); + }); + cy.findByRole('button').should('not.be.disabled').click(); +} + +describe('WebAuthnButton enroll (attestation) failure copy', () => { + it('NotAllowedError → cancelled / no-support setup guidance', () => { + mountRejecting('attestation', 'create', 'NotAllowedError'); + cy.findByText(/passkey setup was cancelled/i).should('exist'); + cy.findByText(/verification failed/i).should('not.exist'); + }); + + it('InvalidStateError → already-registered', () => { + mountRejecting('attestation', 'create', 'InvalidStateError'); + cy.findByText(/already have a passkey for this account/i).should('exist'); + }); + + it("NotSupportedError → device can't create a passkey", () => { + mountRejecting('attestation', 'create', 'NotSupportedError'); + cy.findByText(/can't create a passkey/i).should('exist'); + }); + + it('SecurityError → security-reasons setup copy', () => { + mountRejecting('attestation', 'create', 'SecurityError'); + cy.findByText(/passkey setup couldn't be completed for security reasons/i).should('exist'); + }); + + it('unmapped DOMException → generic enroll copy', () => { + mountRejecting('attestation', 'create', 'NetworkError'); + cy.findByText(/couldn't set up your passkey/i).should('exist'); + }); +}); + +describe('WebAuthnButton sign-in (assertion) failure copy', () => { + it('NotAllowedError → cancelled / no-passkey sign-in guidance', () => { + mountRejecting('assertion', 'get', 'NotAllowedError'); + cy.findByText(/passkey sign-in was cancelled/i).should('exist'); + cy.findByText(/set up your passkey/i).should('exist'); + }); + + it("NotSupportedError → device can't use a passkey to sign in", () => { + mountRejecting('assertion', 'get', 'NotSupportedError'); + cy.findByText(/can't use a passkey to sign in/i).should('exist'); + }); + + it('SecurityError → security-reasons sign-in copy', () => { + mountRejecting('assertion', 'get', 'SecurityError'); + cy.findByText(/passkey sign-in couldn't be completed for security reasons/i).should('exist'); + }); + + it('unmapped DOMException → generic sign-in copy', () => { + mountRejecting('assertion', 'get', 'NetworkError'); + cy.findByText(/verification failed/i).should('exist'); + }); + + // already-registered (InvalidStateError) does not apply to a sign-in ceremony; it falls + // back to the generic verification copy rather than showing enroll-only wording. + it('InvalidStateError → generic sign-in copy (already-registered N/A on sign-in)', () => { + mountRejecting('assertion', 'get', 'InvalidStateError'); + cy.findByText(/verification failed/i).should('exist'); + cy.findByText(/already have a passkey/i).should('not.exist'); + }); +}); diff --git a/cypress/component/components/webauthn-button/webauthn-button.cy.tsx b/cypress/component/components/webauthn-button/webauthn-button.cy.tsx index 38ead4392f..b49b9abb03 100644 --- a/cypress/component/components/webauthn-button/webauthn-button.cy.tsx +++ b/cypress/component/components/webauthn-button/webauthn-button.cy.tsx @@ -6,8 +6,9 @@ import React from 'react'; // // In Cypress, window.Cypress is defined, so the component takes the CYPRESS_CREDENTIAL // shortcut (skips navigator.credentials). By NOT wrapping in a
, formRef.current -// remains null, which triggers setError('webauthn-failed') — the same error path that -// the Vitest test exercised via publicKey=null + mocked isWebAuthnSupported=true. +// remains null, which sets the generic ceremony failure (reason 'unknown') and renders the +// per-mode generic copy — asserting enroll wording is distinct from sign-in wording. +// (Reason-specific DOMException copy is covered by webauthn-button.ceremony-errors.cy.tsx.) function mountBtn(mode: 'assertion' | 'attestation') { const formRef = React.createRef(); cy.mountRemixRoute(, { diff --git a/cypress/component/components/webauthn-button/webauthn-button.on-credential.cy.tsx b/cypress/component/components/webauthn-button/webauthn-button.on-credential.cy.tsx new file mode 100644 index 0000000000..38e4d75f94 --- /dev/null +++ b/cypress/component/components/webauthn-button/webauthn-button.on-credential.cy.tsx @@ -0,0 +1,71 @@ +// WebAuthnButton onCredential: when the prop is set, the finished +// ceremony credential is handed to the parent INSTEAD of auto-submitting the form — +// the two-step enroll flow (ceremony → name step → submit) holds it in route state. +// Same createMemoryRouter recording harness as the other button specs; the Cypress +// pre-baked credential path exercises the same handoff the real ceremony runs. +import { WebAuthnButton } from '@/components/webauthn-button/webauthn-button'; +import { setupI18n } from '@lingui/core'; +import { I18nProvider } from '@lingui/react'; +import { mount } from 'cypress/react'; +import React, { useRef } from 'react'; +import { createMemoryRouter, RouterProvider, Form as RRForm } from 'react-router'; + +interface Recorded { + fields?: Record; + credential?: Record; +} + +function Harness({ recorded }: { recorded: Recorded }) { + const formRef = useRef(null); + return ( + + + { + recorded.credential = credential; + }} + /> + + ); +} + +function mountHarness(recorded: Recorded) { + const i18n = setupI18n({ locale: 'en', messages: { en: {} } }); + const router = createMemoryRouter( + [ + { + path: '/', + element: ( + + + + ), + action: async ({ request }) => { + const form = await request.formData(); + recorded.fields = Object.fromEntries([...form.entries()].map(([k, v]) => [k, String(v)])); + return null; + }, + }, + ], + { initialEntries: ['/'] } + ); + return mount(); +} + +describe('WebAuthnButton onCredential (two-step enroll handoff)', () => { + it('hands the credential to the parent and does NOT submit the form', () => { + const recorded: Recorded = {}; + mountHarness(recorded); + cy.findByRole('button').should('not.be.disabled').click(); + cy.wrap(recorded).should((r) => { + expect(r.credential, 'parent received the credential').to.have.property( + 'id', + 'fake-credential-id' + ); + expect(r.fields, 'form was not auto-submitted').to.equal(undefined); + }); + }); +}); diff --git a/cypress/component/modules/auth/fake-passkeys.cy.ts b/cypress/component/modules/auth/fake-passkeys.cy.ts new file mode 100644 index 0000000000..6baeeba812 --- /dev/null +++ b/cypress/component/modules/auth/fake-passkeys.cy.ts @@ -0,0 +1,59 @@ +// cypress/component/modules/auth/fake-passkeys.cy.ts +// +// NO-MOUNT: FakeAuthProvider passkey-inventory mirror of the port additions +// (listPasskeys / removePasskey). Style of cypress/component/routes/paths.cy.ts. +import { FakeAuthProvider } from '@/modules/auth/providers/fake/fake-provider'; + +const seedUser = { id: 'u1', loginName: 'alice@acme.test' }; + +describe('FakeAuthProvider — passkey inventory (port mirror)', () => { + it('verifyPasskey records a named active passkey; listPasskeys returns it', async () => { + const fake = new FakeAuthProvider({ users: [seedUser] }); + await fake.verifyPasskey('u1', 'pk-1', {}, 'MacBook Touch ID'); + const [passkey] = await fake.listPasskeys('u1'); + const { createdAt, ...rest } = passkey; + expect(rest).to.deep.equal({ id: 'pk-1', state: 'active', name: 'MacBook Touch ID' }); + expect(createdAt).to.be.a('string'); + expect(new Date(createdAt).toISOString()).to.equal(createdAt); + expect(await fake.listAuthMethods('u1')).to.include('passkey'); + }); + + it('defaults the name when verifyPasskey gets no passkeyName (Zitadel parity)', async () => { + const fake = new FakeAuthProvider({ users: [seedUser] }); + await fake.verifyPasskey('u1', 'pk-1', {}); + expect((await fake.listPasskeys('u1'))[0].name).to.equal('Passkey'); + }); + + it('removePasskey is idempotent and un-enrolls the method with the last passkey', async () => { + const fake = new FakeAuthProvider({ users: [seedUser] }); + await fake.verifyPasskey('u1', 'pk-1', {}, 'A'); + await fake.removePasskey('u1', 'pk-1'); + await fake.removePasskey('u1', 'pk-1'); // second call must not throw (removal race) + expect(await fake.listPasskeys('u1')).to.deep.equal([]); + expect(await fake.listAuthMethods('u1')).to.not.include('passkey'); + }); + + it('honors the passkeys seed (e2e fixture path)', async () => { + const fake = new FakeAuthProvider({ + users: [seedUser], + passkeys: { u1: [{ id: 'pk-s', state: 'active', name: 'Seeded key' }] }, + }); + expect(await fake.listPasskeys('u1')).to.have.length(1); + }); + + it('removePasskey also clears a SEEDED static authMethods entry, not just the dynamic enrolled set', async () => { + // listAuthMethods unions the dynamic `enrolled` set with the seed-time `authMethods` + // array — a test seeding BOTH (the e2e-fixture pattern) would otherwise still see + // 'passkey' reported as enrolled after the last passkey is removed, since only the + // dynamic set was ever cleared. + const fake = new FakeAuthProvider({ + users: [seedUser], + authMethods: { u1: ['passkey'] }, + passkeys: { u1: [{ id: 'pk-s', state: 'active', name: 'Seeded key' }] }, + }); + expect(await fake.listAuthMethods('u1')).to.include('passkey'); + await fake.removePasskey('u1', 'pk-s'); + expect(await fake.listPasskeys('u1')).to.deep.equal([]); + expect(await fake.listAuthMethods('u1')).to.not.include('passkey'); + }); +}); diff --git a/cypress/component/modules/auth/providers/fake/fake-provider-idp-intent.cy.ts b/cypress/component/modules/auth/providers/fake/fake-provider-idp-intent.cy.ts new file mode 100644 index 0000000000..9dc553f6e0 --- /dev/null +++ b/cypress/component/modules/auth/providers/fake/fake-provider-idp-intent.cy.ts @@ -0,0 +1,61 @@ +// cypress/component/modules/auth/providers/fake/fake-provider-idp-intent.cy.ts +// +// FakeAuthProvider.updateSession must replicate Zitadel's own identity-binding +// enforcement for idpIntent checks: a session can only be verified with an +// idpIntent that resolves to THAT session's own user. +import { FakeAuthProvider } from '@/modules/auth/providers/fake/fake-provider'; +import { ProviderError } from '@/modules/auth/types'; + +describe('FakeAuthProvider.updateSession — idpIntent check', () => { + it("stamps the idpIntent factor when the intent resolves to the session's own user", async () => { + const fake = new FakeAuthProvider({ + users: [{ id: 'u1', loginName: 'mia@acme.test' }], + idpIntents: { + 'intent-1': { idpIntentId: 'intent-1', idpIntentToken: 'tok-1', userId: 'u1' }, + }, + }); + const s = await fake.createSession({}, { userId: 'u1' }); + const updated = await fake.updateSession(s.id, s.token, { + idpIntent: { idpIntentId: 'intent-1', idpIntentToken: 'tok-1' }, + }); + expect(updated.factors.idpIntent?.verifiedAt).to.not.equal(null); + }); + + it("rejects an idpIntent that resolves to a DIFFERENT user than the session's own", async () => { + const fake = new FakeAuthProvider({ + users: [ + { id: 'u1', loginName: 'mia@acme.test' }, + { id: 'u2', loginName: 'bob@acme.test' }, + ], + idpIntents: { + 'intent-2': { idpIntentId: 'intent-2', idpIntentToken: 'tok-2', userId: 'u2' }, + }, + }); + const s = await fake.createSession({}, { userId: 'u1' }); + let threw: unknown; + try { + await fake.updateSession(s.id, s.token, { + idpIntent: { idpIntentId: 'intent-2', idpIntentToken: 'tok-2' }, + }); + } catch (err) { + threw = err; + } + expect(threw).to.be.instanceOf(ProviderError); + expect((threw as ProviderError).code).to.equal('FAILED_PRECONDITION'); + }); + + it('rejects an unseeded/unknown idpIntentId', async () => { + const fake = new FakeAuthProvider({ users: [{ id: 'u1', loginName: 'mia@acme.test' }] }); + const s = await fake.createSession({}, { userId: 'u1' }); + let threw: unknown; + try { + await fake.updateSession(s.id, s.token, { + idpIntent: { idpIntentId: 'no-such-intent', idpIntentToken: 'x' }, + }); + } catch (err) { + threw = err; + } + expect(threw).to.be.instanceOf(ProviderError); + expect((threw as ProviderError).code).to.equal('FAILED_PRECONDITION'); + }); +}); diff --git a/cypress/component/modules/auth/providers/parity.cy.ts b/cypress/component/modules/auth/providers/parity.cy.ts index 3c62d59299..4d3bdf8cc7 100644 --- a/cypress/component/modules/auth/providers/parity.cy.ts +++ b/cypress/component/modules/auth/providers/parity.cy.ts @@ -55,6 +55,8 @@ const PORT_METHODS = [ 'passkeyRegisterLink', 'registerPasskey', 'verifyPasskey', + 'listPasskeys', + 'removePasskey', 'registerU2F', 'verifyU2F', 'registerTotp', diff --git a/cypress/component/modules/auth/providers/zitadel/index.cy.ts b/cypress/component/modules/auth/providers/zitadel/index.cy.ts index f9ed75b3e9..9446bd26c9 100644 --- a/cypress/component/modules/auth/providers/zitadel/index.cy.ts +++ b/cypress/component/modules/auth/providers/zitadel/index.cy.ts @@ -13,6 +13,7 @@ import { ZitadelAuthProvider } from '@/modules/auth/providers/zitadel/index'; import * as transport from '@/modules/auth/providers/zitadel/transport'; import { ProviderError } from '@/modules/auth/types'; +import { AuthFactorState } from '@zitadel/proto/zitadel/user/v2/user_pb'; const provider = () => new ZitadelAuthProvider({ serviceUrl: 'https://z.test', serviceToken: 't' }); @@ -243,3 +244,118 @@ describe('ZitadelAuthProvider — session/credential request building', () => { expect(getSpy).to.have.callCount(2); }); }); + +// ── Passkey created-at metadata — best-effort scopes ────────────────────── +// +// verifyPasskey/listPasskeys/removePasskey each touch a user-metadata RPC (set/list/delete) +// in its own ctx.call scope specifically so that RPC can NEVER fail the primary operation. +// These tests drive that metadata RPC into failure and assert the primary operation still +// resolves, plus the listPasskeys join behaviour (present/absent createdAt per row). +describe('ZitadelAuthProvider — passkey metadata best-effort scopes', () => { + it('verifyPasskey resolves even when the setUserMetadata created-at stamp throws', async () => { + const verifySpy = cy.stub().resolves({}); + stubClient({ + verifyPasskeyRegistration: verifySpy, + setUserMetadata: async () => { + throw new Error('metadata backend down'); + }, + }); + // Must not throw — enrollment is not allowed to fail because of the best-effort stamp. + await provider().verifyPasskey('u1', 'pk1', { fake: true }); + expect(verifySpy).to.have.callCount(1); + }); + + it('listPasskeys degrades to date-less rows (all createdAt absent) when listUserMetadata throws', async () => { + stubClient({ + listUserMetadata: async () => { + throw new Error('metadata backend down'); + }, + listPasskeys: async () => ({ + result: [ + { id: 'pk1', state: AuthFactorState.READY, name: 'Laptop' }, + { id: 'pk2', state: AuthFactorState.NOT_READY, name: 'Phone' }, + ], + }), + }); + const rows = await provider().listPasskeys('u1'); + expect(rows).to.have.length(2); + expect(rows[0]).to.deep.equal({ id: 'pk1', state: 'active', name: 'Laptop' }); + expect(rows[1]).to.deep.equal({ id: 'pk2', state: 'inactive', name: 'Phone' }); + for (const row of rows) { + expect(row).to.not.have.property('createdAt'); + } + }); + + it('listPasskeys joins createdAt from a matching passkey::created entry; unmatched rows omit the property', async () => { + const createdAt = '2026-07-01T00:00:00.000Z'; + const seconds = Math.floor(new Date(createdAt).getTime() / 1000); + stubClient({ + listUserMetadata: async () => ({ + metadata: [ + { key: 'passkey:pk1:created', creationDate: { seconds: BigInt(seconds), nanos: 0 } }, + ], + }), + listPasskeys: async () => ({ + result: [ + { id: 'pk1', state: AuthFactorState.READY, name: 'Laptop' }, + { id: 'pk2', state: AuthFactorState.NOT_READY, name: 'Phone' }, + ], + }), + }); + const rows = await provider().listPasskeys('u1'); + const pk1 = rows.find((r) => r.id === 'pk1'); + const pk2 = rows.find((r) => r.id === 'pk2'); + expect(pk1?.createdAt).to.equal(createdAt); + // Absence must be a missing property (omitted via the `...(createdAt ? {...} : {})` spread), + // not just an undefined value. + expect(pk2).to.not.have.property('createdAt'); + }); + + it('removePasskey resolves even when the deleteUserMetadata cleanup throws', async () => { + const removeSpy = cy.stub().resolves({}); + stubClient({ + removePasskey: removeSpy, + deleteUserMetadata: async () => { + throw new Error('metadata backend down'); + }, + }); + // Must not throw — removal succeeded; an orphaned metadata key is harmless. + await provider().removePasskey('u1', 'pk1'); + expect(removeSpy).to.have.callCount(1); + }); +}); + +// ── Cross-device session methods ─────────────────────────────────────────── + +describe('ZitadelAuthProvider — cross-device session methods', () => { + it('listUserSessions searches by userIdQuery and maps sessions with token ""', async () => { + let captured: unknown; + stubClient({ + listSessions: async (req: unknown) => { + captured = req; + return { sessions: [{ id: 's1' }, { id: 's2' }] }; + }, + }); + const rows = await provider().listUserSessions('u1'); + const q = (captured as { queries: Array<{ query: { case: string; value: { id?: string } } }> }) + .queries[0].query; + expect(q.case).to.equal('userIdQuery'); + expect(q.value.id).to.equal('u1'); + expect(rows).to.have.length(2); + expect(rows[0].id).to.equal('s1'); + expect(rows[0].token).to.equal(''); + }); + + it('deleteUserSession sends sessionId WITHOUT a sessionToken field', async () => { + let captured: unknown; + stubClient({ + deleteSession: async (req: Record) => { + captured = req; + return {}; + }, + }); + await provider().deleteUserSession('s2'); + expect((captured as { sessionId: string }).sessionId).to.equal('s2'); + expect(captured).to.not.have.property('sessionToken'); + }); +}); diff --git a/cypress/component/resources/mfa/mfa-routing.cy.ts b/cypress/component/resources/mfa/mfa-routing.cy.ts index feab1d201d..cb6cdf7510 100644 --- a/cypress/component/resources/mfa/mfa-routing.cy.ts +++ b/cypress/component/resources/mfa/mfa-routing.cy.ts @@ -150,4 +150,66 @@ describe('nextMfaStep', () => { { force: 'true', checkAfter: 'true' } ); }); + + // ── enrolled passkey suppresses the step-6 skippable MFA-setup nudge ────────── + // A passkey is passwordless-primary strong auth the user already has, so the *optional* + // "set up MFA" nudge is confusing UX ("I have a passkey, why set up MFA?") and is suppressed. + // This affects ONLY the skippable step-6 nudge — a hard org policy (forced MFA) still applies. + // NOTE: this is a DIFFERENT nudge from the backup-method/lockout banner on the /passkeys + // management route (methodCount === 1). No overlap: separate file, trigger, and intent. + + it('is done (step-6 nudge suppressed) when an enrolled passkey exists, password login, skip window elapsed', () => { + // No fresh passkey factor (password login, userVerified:false), skip window configured and + // never skipped → WITHOUT the passkey rule this routes to /setup/mfa?force=false. + expect( + nextMfaStep( + base({ + enrolledMethods: ['passkey'], + settings: settings({ mfaInitSkipLifetimeMs: 1000 }), + mfaInitSkippedAt: null, + }) + ) + ).to.deep.equal({ kind: 'done' }); + }); + + it('an enrolled passkey does NOT bypass FORCED MFA (step 5 still routes to setup, force=true)', () => { + expectRoute( + nextMfaStep( + base({ + enrolledMethods: ['passkey'], + settings: settings({ forceMfa: true, mfaInitSkipLifetimeMs: 1000 }), + }) + ), + '/setup/mfa', + { force: 'true', checkAfter: 'true' } + ); + }); + + it('WITHOUT a passkey, no 2nd factor, not forced, skip window elapsed → skippable nudge still fires (unchanged)', () => { + expectRoute( + nextMfaStep( + base({ + enrolledMethods: [], + settings: settings({ mfaInitSkipLifetimeMs: 1000 }), + mfaInitSkippedAt: null, + }) + ), + '/setup/mfa', + { force: 'false', checkAfter: 'true' } + ); + }); + + it('a fresh user-verified passwordless passkey is still done via step 1 (unchanged), even when enrolled', () => { + const factors: Factors = { passkey: { verifiedAt: freshDate } }; + expect( + nextMfaStep( + base({ + factors, + userVerified: true, + enrolledMethods: ['passkey'], + settings: settings({ multiFactorCheckLifetimeMs: 1000 }), + }) + ) + ).to.deep.equal({ kind: 'done' }); + }); }); diff --git a/cypress/component/resources/passkeys/passkeys.service.cy.ts b/cypress/component/resources/passkeys/passkeys.service.cy.ts new file mode 100644 index 0000000000..9e2e0f2785 --- /dev/null +++ b/cypress/component/resources/passkeys/passkeys.service.cy.ts @@ -0,0 +1,305 @@ +// cypress/component/resources/passkeys/passkeys.service.cy.ts +// +// NO-MOUNT: /id/passkeys management service — sudo gate, last-method guard, +// idempotent removal, sign-out-others. Direct-import style with a local fake: +// the service takes an already-read SessionEntry[], so no cookie/env stubs needed. +import { FakeAuthProvider } from '@/modules/auth/providers/fake/fake-provider'; +import type { SessionEntry } from '@/modules/auth/session/session'; +import type { AuthMethod, ProviderErrorCode } from '@/modules/auth/types'; +import { ProviderError } from '@/modules/auth/types'; +import { + loadPasskeysView, + removeUserPasskey, + signOutOtherSessions, +} from '@/resources/passkeys/passkeys.service'; +import { SUDO_TTL_MS } from '@/resources/shared/sudo'; + +const USER = { id: 'u1', loginName: 'mia@acme.test' }; + +async function seeded(opts?: { + authMethods?: AuthMethod[]; + passkeys?: Array<{ id: string; state: 'active' | 'inactive'; name: string }>; + provider?: FakeAuthProvider; +}) { + const fake = + opts?.provider ?? + new FakeAuthProvider({ + users: [USER], + passwords: { u1: 'Password1!' }, + authMethods: { u1: opts?.authMethods ?? ['password', 'passkey'] }, + passkeys: { u1: opts?.passkeys ?? [{ id: 'pk-1', state: 'active', name: 'Seeded laptop' }] }, + // Real factor stamps: the sudo check compares against real Date.now()-based nowMs. + realFactorTimestamps: true, + }); + const s = await fake.createSession({ password: 'Password1!' }, { userId: 'u1' }); + const entry: SessionEntry = { + id: s.id, + token: s.token, + loginName: USER.loginName, + creationTs: s.changedAt, + expirationTs: s.expiresAt, + changeTs: s.changedAt, + }; + return { fake, sessions: [entry] }; +} + +describe('passkeys.service — /id/passkeys management', () => { + it('fresh sudo ⇒ view with rows + methodCount; validated returnTo is threaded', async () => { + const { fake, sessions } = await seeded(); + const v = await loadPasskeysView(fake, sessions, { returnTo: '/passkeys', nowMs: Date.now() }); + expect(v.kind).to.equal('view'); + if (v.kind === 'view') { + expect(v.passkeys).to.deep.equal([{ id: 'pk-1', state: 'active', name: 'Seeded laptop' }]); + expect(v.methodCount).to.equal(2); + expect(v.loginName).to.equal(USER.loginName); + expect(v.returnTo).to.equal('/passkeys'); + } + }); + + it('a stale/revoked session token (getSession throws PERMISSION_DENIED) recovers to /login, not a crash', async () => { + // Mirrors the reauth.service.ts fix: a stored session token from a different browser/tab + // may be stale or revoked provider-side, and the real Zitadel backend throws + // PERMISSION_DENIED on getSession instead of returning null. + const { fake, sessions } = await seeded(); + fake.setSessionResult(sessions[0].id, { mode: 'throw', code: 'PERMISSION_DENIED' }); + const v = await loadPasskeysView(fake, sessions, { returnTo: '/passkeys', nowMs: Date.now() }); + expect(v).to.deep.equal({ kind: 'redirect', target: '/login' }); + }); + + it('stale sudo ⇒ loader redirects to /reauth AND removeUserPasskey refuses server-side', async () => { + const { fake, sessions } = await seeded(); + const staleNow = Date.now() + SUDO_TTL_MS + 1; + const v = await loadPasskeysView(fake, sessions, { returnTo: null, nowMs: staleNow }); + expect(v).to.deep.equal({ kind: 'redirect', target: '/reauth?returnTo=%2Fpasskeys' }); + // Server-side enforcement is independent of the loader: + const r = await removeUserPasskey(fake, sessions, { passkeyId: 'pk-1', nowMs: staleNow }); + expect(r).to.deep.equal({ ok: false, error: 'SUDO_REQUIRED' }); + }); + + it('last-method guard serializes concurrent removes: only one of two racing removals succeeds', async () => { + // Without the per-user lock, two concurrent removes of DIFFERENT passkeys could both + // read "2 passkeys left, no other method" before either completed, both pass the + // guard, and both proceed — leaving zero sign-in methods. The lock forces the second + // call's read to happen AFTER the first's removal completes, so it correctly sees 1 + // passkey left and refuses. + const solo = await seeded({ + authMethods: ['passkey'], + passkeys: [ + { id: 'pk-1', state: 'active', name: 'Laptop' }, + { id: 'pk-2', state: 'active', name: 'Phone' }, + ], + }); + const [a, b] = await Promise.all([ + removeUserPasskey(solo.fake, solo.sessions, { passkeyId: 'pk-1', nowMs: Date.now() }), + removeUserPasskey(solo.fake, solo.sessions, { passkeyId: 'pk-2', nowMs: Date.now() }), + ]); + const results = [a, b]; + expect(results.filter((r) => r.ok)).to.have.length(1); + expect(results.filter((r) => !r.ok && r.error === 'LAST_METHOD')).to.have.length(1); + // Exactly one passkey remains — not zero. + expect(await solo.fake.listPasskeys('u1')).to.have.length(1); + }); + + it('last-method guard refuses removal when the only backup method is policy-disabled', async () => { + // Enrolled ['passkey', 'password'] with 1 passkey — raw enrolled methods would say + // "password is a backup," but the org has since disabled password auth. That backup + // isn't usable, so removing the last passkey must still be refused (not a real lockout + // escape). Mirrors the same gate decideAfterIdentifier/login/method.tsx apply per-method. + const fake = new FakeAuthProvider({ + users: [USER], + passwords: { u1: 'Password1!' }, + authMethods: { u1: ['passkey', 'password'] }, + passkeys: { u1: [{ id: 'pk-1', state: 'active', name: 'Seeded laptop' }] }, + settingsByOrg: { 'org-default-fake': { allowPassword: false } }, + realFactorTimestamps: true, + }); + const s = await fake.createSession({ password: 'Password1!' }, { userId: 'u1' }); + const sessions: SessionEntry[] = [ + { + id: s.id, + token: s.token, + loginName: USER.loginName, + creationTs: s.changedAt, + expirationTs: s.expiresAt, + changeTs: s.changedAt, + }, + ]; + const refused = await removeUserPasskey(fake, sessions, { + passkeyId: 'pk-1', + nowMs: Date.now(), + emailDeliveryEnabled: true, + }); + expect(refused).to.deep.equal({ ok: false, error: 'LAST_METHOD' }); + // Same gap existed in the loader's methodCount (drives the backup-method banner). + const view = await loadPasskeysView(fake, sessions, { + returnTo: null, + nowMs: Date.now(), + emailDeliveryEnabled: true, + }); + expect(view.kind).to.equal('view'); + if (view.kind === 'view') expect(view.methodCount).to.equal(1); + }); + + it('last-method guard: passkey-only user with one passkey ⇒ LAST_METHOD; password backup ⇒ ok', async () => { + const solo = await seeded({ authMethods: ['passkey'] }); + const refused = await removeUserPasskey(solo.fake, solo.sessions, { + passkeyId: 'pk-1', + nowMs: Date.now(), + }); + expect(refused).to.deep.equal({ ok: false, error: 'LAST_METHOD' }); + + const backed = await seeded({ authMethods: ['password', 'passkey'] }); + const removed = await removeUserPasskey(backed.fake, backed.sessions, { + passkeyId: 'pk-1', + nowMs: Date.now(), + }); + expect(removed).to.deep.equal({ ok: true, removedName: 'Seeded laptop' }); + expect(await backed.fake.listPasskeys('u1')).to.deep.equal([]); + }); + + it('removal race: already-gone passkey and a NOT_FOUND adapter error are both idempotent success', async () => { + // Fake path: the id simply is not in the list (silent no-op). + const { fake, sessions } = await seeded({ passkeys: [] }); + const gone = await removeUserPasskey(fake, sessions, { passkeyId: 'pk-x', nowMs: Date.now() }); + expect(gone.ok).to.equal(true); + + // Real-adapter path: RemovePasskey rejects with NOT_FOUND — treated identically. + class NotFoundOnRemove extends FakeAuthProvider { + override async removePasskey(_userId: string, _passkeyId: string): Promise { + throw new ProviderError('NOT_FOUND' as ProviderErrorCode, 'passkey not found'); + } + } + const scripted = new NotFoundOnRemove({ + users: [USER], + passwords: { u1: 'Password1!' }, + authMethods: { u1: ['password', 'passkey'] }, + passkeys: { u1: [{ id: 'pk-1', state: 'active', name: 'Seeded laptop' }] }, + realFactorTimestamps: true, + }); + const withScripted = await seeded({ provider: scripted }); + const raced = await removeUserPasskey(scripted, withScripted.sessions, { + passkeyId: 'pk-1', + nowMs: Date.now(), + }); + expect(raced.ok).to.equal(true); + }); + + it('signOutOtherSessions deletes every non-active entry provider-side and keeps only the active one', async () => { + const { fake, sessions } = await seeded(); + const other = await fake.createSession({ password: 'Password1!' }, { userId: 'u1' }); + const otherEntry: SessionEntry = { + id: other.id, + token: other.token, + loginName: USER.loginName, + creationTs: '2026-01-01T00:00:00.000Z', // older ⇒ sessions[0] stays the active entry + expirationTs: other.expiresAt, + changeTs: '2026-01-01T00:00:00.000Z', + }; + const all = [...sessions, otherEntry]; + + const result = await signOutOtherSessions(fake, all, { nowMs: Date.now() }); + expect(result.ok).to.equal(true); + if (result.ok) { + expect(result.sessions).to.have.length(1); + expect(result.sessions[0].id).to.equal(sessions[0].id); + } + // The other session is gone provider-side; the active one survives. + expect(await fake.getSession(other.id, other.token)).to.equal(null); + expect(await fake.getSession(sessions[0].id, sessions[0].token)).to.not.equal(null); + }); + + it('cross-device sessions (not in the cookie) are deleted; the active one survives', async () => { + const { fake, sessions } = await seeded(); + // A session on "another device": provider-side only, NOT in the cookie list. + const remote = await fake.createSession({ password: 'Password1!' }, { userId: 'u1' }); + + const result = await signOutOtherSessions(fake, sessions, { nowMs: Date.now() }); + expect(result.ok).to.equal(true); + expect(await fake.getSession(remote.id, remote.token)).to.equal(null); + expect(await fake.getSession(sessions[0].id, sessions[0].token)).to.not.equal(null); + }); + + it('stale sudo refuses signout-others server-side', async () => { + const { fake, sessions } = await seeded(); + const result = await signOutOtherSessions(fake, sessions, { + nowMs: Date.now() + SUDO_TTL_MS + 1, + }); + expect(result).to.deep.equal({ ok: false, error: 'SUDO_REQUIRED' }); + }); + + it('no live active session ⇒ SESSION_EXPIRED', async () => { + const { fake } = await seeded(); + const result = await signOutOtherSessions(fake, [], { nowMs: Date.now() }); + expect(result).to.deep.equal({ ok: false, error: 'SESSION_EXPIRED' }); + }); + + it('a failed user-session search degrades to cookie-scoped behavior', async () => { + class SearchFails extends FakeAuthProvider { + override async listUserSessions(_userId: string): Promise { + throw new ProviderError('UNAVAILABLE' as ProviderErrorCode, 'search down'); + } + } + const scripted = new SearchFails({ + users: [USER], + passwords: { u1: 'Password1!' }, + authMethods: { u1: ['password', 'passkey'] }, + passkeys: { u1: [{ id: 'pk-1', state: 'active', name: 'Seeded laptop' }] }, + realFactorTimestamps: true, + }); + const { sessions } = await seeded({ provider: scripted }); + const other = await scripted.createSession({ password: 'Password1!' }, { userId: 'u1' }); + const otherEntry: SessionEntry = { + id: other.id, + token: other.token, + loginName: USER.loginName, + creationTs: '2026-01-01T00:00:00.000Z', + expirationTs: other.expiresAt, + changeTs: '2026-01-01T00:00:00.000Z', + }; + + const result = await signOutOtherSessions(scripted, [...sessions, otherEntry], { + nowMs: Date.now(), + }); + expect(result.ok).to.equal(true); + // The cookie-known other entry is still deleted (token path), search failure notwithstanding. + expect(await scripted.getSession(other.id, other.token)).to.equal(null); + }); + + it('a failing deleteUserSession is swallowed per-session — the sweep completes remaining deletions before returning', async () => { + class DeleteFailsFor extends FakeAuthProvider { + failId: string | null = null; + override async deleteUserSession(sessionId: string): Promise { + if (sessionId === this.failId) { + throw new ProviderError('UNAVAILABLE' as ProviderErrorCode, 'device unreachable'); + } + // Real macrotask delay: completion-before-return then depends on the sweep + // AWAITING every per-session outcome. Without the inner per-session catch, the + // first rejection short-circuits Promise.all and the service returns via the + // OUTER catch before this delayed delete lands — the r2 assertion below fails. + await new Promise((resolve) => setTimeout(resolve, 20)); + await super.deleteUserSession(sessionId); + } + } + const scripted = new DeleteFailsFor({ + users: [USER], + passwords: { u1: 'Password1!' }, + authMethods: { u1: ['password', 'passkey'] }, + passkeys: { u1: [{ id: 'pk-1', state: 'active', name: 'Seeded laptop' }] }, + realFactorTimestamps: true, + }); + const { sessions } = await seeded({ provider: scripted }); + const r1 = await scripted.createSession({ password: 'Password1!' }, { userId: 'u1' }); + const r2 = await scripted.createSession({ password: 'Password1!' }, { userId: 'u1' }); + scripted.failId = r1.id; + + const result = await signOutOtherSessions(scripted, sessions, { nowMs: Date.now() }); + expect(result.ok).to.equal(true); + // Asserted IMMEDIATELY after resolution (microtasks only, no timer yields): r2 must + // ALREADY be gone — with the per-session catch removed the service returns early + // and r2's delayed delete has not landed yet, failing this line. + expect(await scripted.getSession(r2.id, r2.token)).to.equal(null); + // The unreachable session survives (failure swallowed); the active one survives. + expect(await scripted.getSession(r1.id, r1.token)).to.not.equal(null); + expect(await scripted.getSession(sessions[0].id, sessions[0].token)).to.not.equal(null); + }); +}); diff --git a/cypress/component/resources/reauth/reauth-idp-intent.cy.ts b/cypress/component/resources/reauth/reauth-idp-intent.cy.ts new file mode 100644 index 0000000000..1ec346a677 --- /dev/null +++ b/cypress/component/resources/reauth/reauth-idp-intent.cy.ts @@ -0,0 +1,57 @@ +// cypress/component/resources/reauth/reauth-idp-intent.cy.ts +// +// NO-MOUNT: startReauthIdpIntent starts a fresh OAuth round-trip pointed at the +// dedicated /reauth/:provider/callback + /reauth/:provider/error routes (NOT the +// existing /sso/:provider/callback, which is a separate sign-in/link/register/error +// decision tree for a different purpose). +import { FakeAuthProvider } from '@/modules/auth/providers/fake/fake-provider'; +import { ProviderError } from '@/modules/auth/types'; +import { startReauthIdpIntent } from '@/resources/reauth/reauth.service'; + +describe('startReauthIdpIntent', () => { + it('builds success/failure URLs pointed at /reauth/:provider/callback|error with returnTo threaded', async () => { + const fake = new FakeAuthProvider({ users: [{ id: 'u1', loginName: 'mia@acme.test' }] }); + let capturedUrls: { success: string; failure: string } | undefined; + fake.startIdpIntent = async (_idpId, urls) => { + capturedUrls = urls; + return { authUrl: 'https://accounts.google.com/o/oauth2/auth?...' }; + }; + const result = await startReauthIdpIntent(fake, { + idpId: 'idp-google', + origin: 'http://localhost:3000', + returnTo: '/passkeys', + }); + expect(result).to.deep.equal({ + ok: true, + authUrl: 'https://accounts.google.com/o/oauth2/auth?...', + }); + expect(capturedUrls?.success).to.include('/id/reauth/idp-google/callback'); + expect(capturedUrls?.success).to.include('returnTo=%2Fpasskeys'); + expect(capturedUrls?.failure).to.include('/id/reauth/idp-google/error'); + expect(capturedUrls?.failure).to.include('returnTo=%2Fpasskeys'); + }); + + it('maps a ProviderError from provider.startIdpIntent to IDP_UNAVAILABLE', async () => { + const fake = new FakeAuthProvider({ users: [{ id: 'u1', loginName: 'mia@acme.test' }] }); + fake.startIdpIntent = async () => { + throw new ProviderError('UNAVAILABLE', 'idp down'); + }; + const result = await startReauthIdpIntent(fake, { + idpId: 'idp-google', + origin: 'http://localhost:3000', + returnTo: '/passkeys', + }); + expect(result).to.deep.equal({ ok: false, error: 'IDP_UNAVAILABLE' }); + }); + + it('maps a missing authUrl to IDP_UNAVAILABLE', async () => { + const fake = new FakeAuthProvider({ users: [{ id: 'u1', loginName: 'mia@acme.test' }] }); + fake.startIdpIntent = async () => ({}); + const result = await startReauthIdpIntent(fake, { + idpId: 'idp-google', + origin: 'http://localhost:3000', + returnTo: '/passkeys', + }); + expect(result).to.deep.equal({ ok: false, error: 'IDP_UNAVAILABLE' }); + }); +}); diff --git a/cypress/component/resources/reauth/reauth.service.cy.ts b/cypress/component/resources/reauth/reauth.service.cy.ts new file mode 100644 index 0000000000..a886707cb4 --- /dev/null +++ b/cypress/component/resources/reauth/reauth.service.cy.ts @@ -0,0 +1,305 @@ +// cypress/component/resources/reauth/reauth.service.cy.ts +// +// NO-MOUNT: reauth.service verifies one factor onto the EXISTING session (SetSession +// semantics). Direct-import style with a locally constructed FakeAuthProvider — the +// service takes an already-read SessionEntry[], so no cookie/env stubs are needed. +import { FakeAuthProvider } from '@/modules/auth/providers/fake/fake-provider'; +import type { SessionEntry } from '@/modules/auth/session/cookie'; +import { performReauth, loadReauth } from '@/resources/reauth/reauth.service'; + +const USER = { id: 'u1', loginName: 'alice@acme.test' }; +async function seeded() { + const fake = new FakeAuthProvider({ + users: [USER], + passwords: { u1: 'Password1!' }, + authMethods: { u1: ['password', 'passkey'] }, + }); + const s = await fake.createSession({}, { userId: 'u1' }); + const sessions: SessionEntry[] = [ + { + id: s.id, + token: s.token, + loginName: USER.loginName, + creationTs: s.changedAt, + expirationTs: s.expiresAt, + changeTs: s.changedAt, + }, + ]; + return { fake, sessions }; +} + +describe('reauth.service — verify one factor onto the EXISTING session', () => { + it('loadReauth lists only enrolled methods and threads the validated returnTo', async () => { + const { fake, sessions } = await seeded(); + const v = await loadReauth(fake, sessions, { + returnTo: '/passkeys', + method: null, + domain: 'localhost', + emailDeliveryEnabled: false, + consoleUrl: 'https://console.acme.test', + }); + expect(v.kind).to.equal('view'); + if (v.kind === 'view') { + expect(v.methods).to.deep.equal(['passkey', 'password']); // otp_email gated off + expect(v.returnTo).to.equal('/passkeys'); + } + }); + + it('loadReauth falls back to the Zitadel-configured default destination when returnTo is absent', async () => { + // Mirrors /signed-in's own fallback priority (admin console → Zitadel default → + // env default → /passkeys) — reauth is reached from multiple flows (passkeys, + // sso, ...), so a caller-less visit shouldn't blindly land on /passkeys. + const { fake, sessions } = await seeded(); + fake.setLoginDefaultRedirectUri('https://app.acme.test/dashboard'); + const v = await loadReauth(fake, sessions, { + returnTo: null, + method: null, + domain: 'localhost', + emailDeliveryEnabled: false, + consoleUrl: 'https://console.acme.test', + }); + expect(v.kind).to.equal('view'); + if (v.kind === 'view') expect(v.returnTo).to.equal('https://app.acme.test/dashboard'); + }); + + it('performReauth preserves the Zitadel-configured default returnTo across the full round-trip', async () => { + // Continues the scenario above: the resolved absolute default lands in the form's + // hidden returnTo field and gets echoed back on submit. performReauth must NOT let it + // fall through to a hardcoded /passkeys just because it's an absolute URL not on + // POST_LOGOUT_ALLOWLIST — it re-resolves the SAME configured default server-side + // instead of trusting the client-echoed absolute URL. + const { fake, sessions } = await seeded(); + fake.setLoginDefaultRedirectUri('https://app.acme.test/dashboard'); + const v = await loadReauth(fake, sessions, { + returnTo: null, + method: null, + domain: 'localhost', + emailDeliveryEnabled: false, + consoleUrl: 'https://console.acme.test', + }); + expect(v.kind).to.equal('view'); + if (v.kind !== 'view') return; + expect(v.returnTo).to.equal('https://app.acme.test/dashboard'); + + const r = await performReauth(fake, sessions, { + factor: 'password', + password: 'Password1!', + returnTo: v.returnTo, + consoleUrl: 'https://console.acme.test', + }); + expect(r.ok).to.equal(true); + if (r.ok) expect(r.target).to.equal('https://app.acme.test/dashboard'); + }); + + it('loadReauth falls back to /passkeys when returnTo is absent AND nothing is configured', async () => { + const { fake, sessions } = await seeded(); + const v = await loadReauth(fake, sessions, { + returnTo: null, + method: null, + domain: 'localhost', + emailDeliveryEnabled: false, + consoleUrl: 'https://console.acme.test', + }); + expect(v.kind).to.equal('view'); + if (v.kind === 'view') expect(v.returnTo).to.equal('/passkeys'); + }); + + it('performReauth(password) updates the SAME session id, rotates the token, and targets returnTo', async () => { + const { fake, sessions } = await seeded(); + const r = await performReauth(fake, sessions, { + factor: 'password', + password: 'Password1!', + returnTo: '/passkeys', + }); + expect(r.ok).to.equal(true); + if (r.ok) { + expect(r.target).to.equal('/passkeys'); + expect(r.sessions[0].id).to.equal(sessions[0].id); // same session — SetSession semantics + } + }); + it('maps a wrong password to INVALID_CREDENTIALS and a dead cookie to SESSION_EXPIRED', async () => { + const { fake, sessions } = await seeded(); + const bad = await performReauth(fake, sessions, { + factor: 'password', + password: 'nope', + returnTo: null, + }); + expect(bad).to.deep.equal({ ok: false, error: 'INVALID_CREDENTIALS' }); + const dead = await performReauth(fake, [], { + factor: 'password', + password: 'x', + returnTo: null, + }); + expect(dead).to.deep.equal({ ok: false, error: 'SESSION_EXPIRED' }); + }); + + it('performReauth(idp) verifies the intent onto the SAME session id and targets returnTo', async () => { + const fake = new FakeAuthProvider({ + users: [USER], + passwords: { u1: 'Password1!' }, + authMethods: { u1: ['password', 'idp'] }, + idpIntents: { + 'intent-1': { idpIntentId: 'intent-1', idpIntentToken: 'tok-1', userId: 'u1' }, + }, + }); + const s = await fake.createSession({}, { userId: 'u1' }); + const sessions: SessionEntry[] = [ + { + id: s.id, + token: s.token, + loginName: USER.loginName, + creationTs: s.changedAt, + expirationTs: s.expiresAt, + changeTs: s.changedAt, + }, + ]; + const r = await performReauth(fake, sessions, { + factor: 'idp', + idpIntentId: 'intent-1', + idpIntentToken: 'tok-1', + returnTo: '/passkeys', + }); + expect(r.ok).to.equal(true); + if (r.ok) { + expect(r.target).to.equal('/passkeys'); + expect(r.sessions[0].id).to.equal(sessions[0].id); + } + }); + + it('performReauth(idp) maps a mismatched idpIntent to INVALID_CREDENTIALS', async () => { + const fake = new FakeAuthProvider({ + users: [USER, { id: 'u2', loginName: 'bob@acme.test' }], + idpIntents: { + 'intent-2': { idpIntentId: 'intent-2', idpIntentToken: 'tok-2', userId: 'u2' }, + }, + }); + const s = await fake.createSession({}, { userId: 'u1' }); + const sessions: SessionEntry[] = [ + { + id: s.id, + token: s.token, + loginName: USER.loginName, + creationTs: s.changedAt, + expirationTs: s.expiresAt, + changeTs: s.changedAt, + }, + ]; + const r = await performReauth(fake, sessions, { + factor: 'idp', + idpIntentId: 'intent-2', + idpIntentToken: 'tok-2', + returnTo: null, + }); + expect(r).to.deep.equal({ ok: false, error: 'INVALID_CREDENTIALS' }); + }); + + it('loadReauth recovers to /login (not a crash) when getSession throws a non-transient ProviderError', async () => { + // Reproduces the live bug: a stored session token from a DIFFERENT browser/tab is stale or + // revoked, and the real Zitadel backend throws PERMISSION_DENIED on getSession instead of + // returning null. Before the fix this propagated uncaught and 500'd the whole request. + const { fake, sessions } = await seeded(); + fake.setSessionResult(sessions[0].id, { mode: 'throw', code: 'PERMISSION_DENIED' }); + const v = await loadReauth(fake, sessions, { + returnTo: '/passkeys', + method: 'passkey', + domain: 'localhost', + emailDeliveryEnabled: false, + consoleUrl: 'https://console.acme.test', + }); + expect(v).to.deep.equal({ kind: 'redirect', target: '/login' }); + }); + + it('loadReauth re-throws a genuinely transient ProviderError (real outage) instead of masking it', async () => { + const { fake, sessions } = await seeded(); + fake.setSessionResult(sessions[0].id, { mode: 'throw', code: 'UNAVAILABLE' }); + let threw: unknown; + try { + await loadReauth(fake, sessions, { + returnTo: '/passkeys', + method: null, + domain: 'localhost', + emailDeliveryEnabled: false, + consoleUrl: 'https://console.acme.test', + }); + } catch (err) { + threw = err; + } + expect(threw).to.exist; + }); + + it('loadReauth lists idp as a method and populates linkedIdps for a Google-linked user', async () => { + const fake = new FakeAuthProvider({ + users: [USER], + authMethods: { u1: ['idp'] }, + capabilities: { externalIdp: true }, + }); + fake.setActiveIdPs?.([{ id: 'idp-google', name: 'Google', type: 'GOOGLE' }]); + fake.setIdpLinks?.('u1', [ + { idpId: 'idp-google', idpUserId: 'g-1', idpUserName: 'mia@gmail.com' }, + ]); + const s = await fake.createSession({}, { userId: 'u1' }); + const sessions: SessionEntry[] = [ + { + id: s.id, + token: s.token, + loginName: USER.loginName, + creationTs: s.changedAt, + expirationTs: s.expiresAt, + changeTs: s.changedAt, + }, + ]; + const v = await loadReauth(fake, sessions, { + returnTo: '/passkeys', + method: null, + domain: 'localhost', + emailDeliveryEnabled: false, + consoleUrl: 'https://console.acme.test', + }); + expect(v.kind).to.equal('view'); + if (v.kind === 'view') { + expect(v.methods).to.include('idp'); + expect(v.linkedIdps).to.deep.equal([ + { + idpId: 'idp-google', + idpUserId: 'g-1', + idpUserName: 'mia@gmail.com', + name: 'Google', + type: 'GOOGLE', + }, + ]); + } + }); + + it('loadReauth omits idp when the only linked provider is LDAP', async () => { + const fake = new FakeAuthProvider({ + users: [USER], + authMethods: { u1: ['idp'] }, + capabilities: { externalIdp: true }, + }); + fake.setActiveIdPs?.([{ id: 'idp-ldap', name: 'Corporate LDAP', type: 'LDAP' }]); + fake.setIdpLinks?.('u1', [{ idpId: 'idp-ldap', idpUserId: 'ldap-1', idpUserName: 'mia' }]); + const s = await fake.createSession({}, { userId: 'u1' }); + const sessions: SessionEntry[] = [ + { + id: s.id, + token: s.token, + loginName: USER.loginName, + creationTs: s.changedAt, + expirationTs: s.expiresAt, + changeTs: s.changedAt, + }, + ]; + const v = await loadReauth(fake, sessions, { + returnTo: '/passkeys', + method: null, + domain: 'localhost', + emailDeliveryEnabled: false, + consoleUrl: 'https://console.acme.test', + }); + expect(v.kind).to.equal('view'); + if (v.kind === 'view') { + expect(v.methods).to.not.include('idp'); + expect(v.linkedIdps).to.deep.equal([]); + } + }); +}); diff --git a/cypress/component/resources/shared/return-to.cy.ts b/cypress/component/resources/shared/return-to.cy.ts new file mode 100644 index 0000000000..6606469f22 --- /dev/null +++ b/cypress/component/resources/shared/return-to.cy.ts @@ -0,0 +1,26 @@ +// cypress/component/resources/shared/return-to.cy.ts +// +// NO-MOUNT: fail-closed returnTo guard. Allowlist injected so the browser +// bundle never parses server env — same injectability rationale as validatePostLogoutRedirect. +import { validateReturnTo } from '@/resources/shared/return-to'; + +describe('validateReturnTo — fail-closed returnTo guard', () => { + const allow = ['https://portal.staging.env.datum.net']; + it('accepts app-relative paths (served under /id by the basename)', () => { + expect(validateReturnTo('/passkeys', allow)).to.equal('/passkeys'); + expect(validateReturnTo('/setup/passkey?loginName=a%40b.c', allow)).to.equal( + '/setup/passkey?loginName=a%40b.c' + ); + }); + it('rejects scheme-relative, backslash, and non-allowlisted absolute URLs', () => { + expect(validateReturnTo('//evil.test/x', allow)).to.equal(null); + expect(validateReturnTo('/\\evil.test', allow)).to.equal(null); + expect(validateReturnTo('https://evil.test/cb', allow)).to.equal(null); + expect(validateReturnTo(null, allow)).to.equal(null); + }); + it('accepts an allowlisted external origin (portal entry-point round-trip)', () => { + expect(validateReturnTo('https://portal.staging.env.datum.net/settings', allow)).to.equal( + 'https://portal.staging.env.datum.net/settings' + ); + }); +}); diff --git a/cypress/component/resources/shared/sudo.cy.ts b/cypress/component/resources/shared/sudo.cy.ts new file mode 100644 index 0000000000..3ac392d1da --- /dev/null +++ b/cypress/component/resources/shared/sudo.cy.ts @@ -0,0 +1,31 @@ +// cypress/component/resources/shared/sudo.cy.ts +// +// NO-MOUNT: pure-function assertions for the sudo-freshness window. +import { isSudoFresh, SUDO_TTL_MS } from '@/resources/shared/sudo'; + +const at = (iso: string) => ({ verifiedAt: new Date(iso) }); +const NOW = Date.parse('2026-07-17T12:00:00Z'); + +describe('isSudoFresh — 10-minute authentication-factor window', () => { + it('accepts each authentication factor at exactly the TTL boundary', () => { + const edge = at(new Date(NOW - SUDO_TTL_MS).toISOString()); + for (const key of [ + 'password', + 'passkey', + 'u2f', + 'totp', + 'otpEmail', + 'otpSms', + 'idpIntent', + ] as const) { + expect(isSudoFresh({ [key]: edge }, NOW), key).to.equal(true); + } + }); + it('rejects a factor 1ms past the window, unverified factors, and the empty factor set', () => { + expect( + isSudoFresh({ password: at(new Date(NOW - SUDO_TTL_MS - 1).toISOString()) }, NOW) + ).to.equal(false); + expect(isSudoFresh({ password: { verifiedAt: null } }, NOW)).to.equal(false); + expect(isSudoFresh({}, NOW)).to.equal(false); // bare user check ⇒ no Factors entry ⇒ never sudo + }); +}); diff --git a/cypress/component/resources/webauthn/aaguid.cy.ts b/cypress/component/resources/webauthn/aaguid.cy.ts new file mode 100644 index 0000000000..be82a207f1 --- /dev/null +++ b/cypress/component/resources/webauthn/aaguid.cy.ts @@ -0,0 +1,45 @@ +// cypress/component/resources/webauthn/aaguid.cy.ts +// +// NO-MOUNT: AAGUID extraction from a WebAuthn attestation object + default-name +// resolution (vendored catalog → UA fallback). Fixtures are built with the shared +// CBOR writer in cypress/support/attestation-fixture.ts — a minimal-but-valid +// attestation object { fmt, attStmt, authData }. +import { attestationObject, authDataWith } from '../../../support/attestation-fixture'; +import { aaguidFromAttestationObject, defaultPasskeyName } from '@/resources/webauthn/aaguid'; + +const MAC_CHROME_UA = + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36'; + +describe('aaguidFromAttestationObject / defaultPasskeyName — default naming', () => { + it('decodes a known AAGUID and maps it to the vendored catalog name', () => { + // fbfc3007-154e-4ecc-8c0b-6e020557d7bd — Apple's authenticator (named + // 'Apple Passwords' in the vendored catalog snapshot, upstream 9e867bf). + const att = attestationObject(authDataWith(0x45, 'fbfc3007154e4ecc8c0b6e020557d7bd')); + expect(aaguidFromAttestationObject(att)).to.equal('fbfc3007-154e-4ecc-8c0b-6e020557d7bd'); + expect(defaultPasskeyName('fbfc3007-154e-4ecc-8c0b-6e020557d7bd', MAC_CHROME_UA)).to.equal( + 'Apple Passwords' + ); + }); + + it('returns the zero UUID for a zeroed AAGUID and falls back to the UA name', () => { + const att = attestationObject(authDataWith(0x45, '00000000000000000000000000000000')); + expect(aaguidFromAttestationObject(att)).to.equal('00000000-0000-0000-0000-000000000000'); + expect(defaultPasskeyName('00000000-0000-0000-0000-000000000000', MAC_CHROME_UA)).to.equal( + 'Chrome on macOS' + ); + }); + + it('never throws on garbage input — returns null and the UA fallback (AAGUID failure never blocks enrollment)', () => { + expect(aaguidFromAttestationObject('!!!')).to.equal(null); + expect(aaguidFromAttestationObject('')).to.equal(null); + // truncated CBOR: a map header promising entries that never arrive + expect(aaguidFromAttestationObject('owFj')).to.equal(null); + expect(defaultPasskeyName(null, MAC_CHROME_UA)).to.equal('Chrome on macOS'); + expect(defaultPasskeyName(null, '')).to.equal('This device'); + }); + + it('returns null when the AT flag is absent (no attested credential data)', () => { + const att = attestationObject(authDataWith(0x01)); // UP only, no AT, no AAGUID bytes + expect(aaguidFromAttestationObject(att)).to.equal(null); + }); +}); diff --git a/cypress/component/resources/webauthn/webauthn.cy.ts b/cypress/component/resources/webauthn/webauthn.cy.ts index 15f6ab3a0e..129842d23f 100644 --- a/cypress/component/resources/webauthn/webauthn.cy.ts +++ b/cypress/component/resources/webauthn/webauthn.cy.ts @@ -1,15 +1,19 @@ // cypress/component/resources/webauthn/webauthn.cy.ts // // Component (no-mount) port of app/resources/webauthn/__tests__/webauthn.test.ts. -// The base64url codec + marshalAssertion cancel handling are browser-side concerns (atob/btoa, -// navigator.credentials) — they belong in the real browser, which Cypress provides. KEPT: the -// cancel→named-error mapping is the assertion-ceremony UX guard (no opaque TypeError on a null -// credential). +// The base64url codec + ceremony error handling are browser-side concerns (atob/btoa, +// navigator.credentials, DOMException) — they belong in the real browser, which Cypress +// provides. KEPT + EXTENDED: the ceremony now classifies the DOMException that +// navigator.credentials.create/get THROWS on a real failure (no authenticator, cancel, +// already-registered) into a stable reason, instead of only handling the rare null return. import { base64UrlToBuffer, bufferToBase64Url, + classifyWebAuthnError, + createAttestation, marshalAssertion, - WebAuthnCeremonyCancelledError, + WebAuthnCeremonyError, + type WebAuthnReason, } from '@/resources/webauthn/webauthn'; describe('webauthn base64url codec', () => { @@ -24,33 +28,114 @@ describe('webauthn base64url codec', () => { }); }); -const PK = { challenge: 'YQ', allowCredentials: [] }; - -describe('marshalAssertion cancel handling', () => { - it('throws a clear cancellation error when credentials.get resolves null', () => { - // isWebAuthnSupported() needs window.PublicKeyCredential defined (Chromium/Electron provides it; - // define a stand-in only if a headless context lacks it). - const w = window as unknown as { PublicKeyCredential?: unknown }; - if (typeof w.PublicKeyCredential === 'undefined') { - w.PublicKeyCredential = function () {} as unknown; - } - // navigator.credentials.get resolving null is the user-cancel signal the SUT must map to a - // NAMED error (instead of an opaque TypeError on the null deref). - if (!window.navigator.credentials) { - Object.defineProperty(window.navigator, 'credentials', { - value: { get: () => Promise.resolve(null) }, - configurable: true, - }); - } else { - cy.stub(window.navigator.credentials, 'get').resolves(null); - } - - return marshalAssertion(PK).then( +// classifyWebAuthnError is the pure regression guard: the WebAuthn spec surfaces every real +// failure as a DOMException whose `.name` disambiguates the cause. This mapping is the single +// source of truth the ceremony wrappers + the button copy both rely on. +describe('classifyWebAuthnError', () => { + const cases: Array<[string, WebAuthnReason]> = [ + ['NotAllowedError', 'not-allowed'], + ['AbortError', 'not-allowed'], + ['TimeoutError', 'not-allowed'], + ['InvalidStateError', 'already-registered'], + ['NotSupportedError', 'unsupported'], + ['ConstraintError', 'unsupported'], + ['SecurityError', 'security'], + ['NetworkError', 'unknown'], // a DOMException whose name is not mapped + ]; + for (const [name, reason] of cases) { + it(`maps DOMException "${name}" → "${reason}"`, () => { + expect(classifyWebAuthnError(new DOMException('boom', name))).to.equal(reason); + }); + } + + it('maps a plain Error and other non-DOMException values → "unknown"', () => { + expect(classifyWebAuthnError(new Error('nope'))).to.equal('unknown'); + // A plain object carrying a spoofed WebAuthn name is still not a DOMException. + expect(classifyWebAuthnError({ name: 'NotAllowedError' })).to.equal('unknown'); + expect(classifyWebAuthnError('NotAllowedError')).to.equal('unknown'); + expect(classifyWebAuthnError(null)).to.equal('unknown'); + expect(classifyWebAuthnError(undefined)).to.equal('unknown'); + }); +}); + +const PK_GET = { challenge: 'YQ', allowCredentials: [] }; +const PK_CREATE = { challenge: 'YQ', user: { id: 'YQ' }, excludeCredentials: [] }; + +// Ensure isWebAuthnSupported() passes (needs window.PublicKeyCredential) and a +// navigator.credentials object exists to stub. Chromium/Electron provides both; define +// stand-ins only if a headless context lacks them. +function ensureWebAuthnEnv(): void { + const w = window as unknown as { PublicKeyCredential?: unknown }; + if (typeof w.PublicKeyCredential === 'undefined') { + w.PublicKeyCredential = function () {} as unknown; + } + if (!window.navigator.credentials) { + Object.defineProperty(window.navigator, 'credentials', { + value: { create: () => Promise.resolve(null), get: () => Promise.resolve(null) }, + configurable: true, + }); + } +} + +describe('marshalAssertion (sign-in) ceremony error handling', () => { + it('maps a null credential (user-cancel) to WebAuthnCeremonyError reason "not-allowed"', () => { + ensureWebAuthnEnv(); + cy.stub(window.navigator.credentials, 'get').resolves(null); + return marshalAssertion(PK_GET).then( + () => { + throw new Error('expected a WebAuthnCeremonyError'); + }, + (err: unknown) => { + expect(err).to.be.instanceOf(WebAuthnCeremonyError); + expect((err as WebAuthnCeremonyError).reason).to.equal('not-allowed'); + } + ); + }); + + it('classifies a thrown DOMException (NotAllowedError → "not-allowed")', () => { + ensureWebAuthnEnv(); + cy.stub(window.navigator.credentials, 'get').rejects( + new DOMException('no authenticator', 'NotAllowedError') + ); + return marshalAssertion(PK_GET).then( + () => { + throw new Error('expected a WebAuthnCeremonyError'); + }, + (err: unknown) => { + expect(err).to.be.instanceOf(WebAuthnCeremonyError); + expect((err as WebAuthnCeremonyError).reason).to.equal('not-allowed'); + } + ); + }); +}); + +describe('createAttestation (enroll) ceremony error handling', () => { + it('classifies a thrown DOMException (InvalidStateError → "already-registered")', () => { + ensureWebAuthnEnv(); + cy.stub(window.navigator.credentials, 'create').rejects( + new DOMException('excluded credential present', 'InvalidStateError') + ); + return createAttestation(PK_CREATE).then( + () => { + throw new Error('expected a WebAuthnCeremonyError'); + }, + (err: unknown) => { + expect(err).to.be.instanceOf(WebAuthnCeremonyError); + expect((err as WebAuthnCeremonyError).reason).to.equal('already-registered'); + } + ); + }); + + it('maps a null credential (user-cancel) to WebAuthnCeremonyError reason "not-allowed"', () => { + ensureWebAuthnEnv(); + cy.stub(window.navigator.credentials, 'create').resolves(null); + return createAttestation(PK_CREATE).then( () => { - throw new Error('expected a WebAuthnCeremonyCancelledError'); + throw new Error('expected a WebAuthnCeremonyError'); }, (err: unknown) => { - expect(err).to.be.instanceOf(WebAuthnCeremonyCancelledError); + expect(err).to.be.instanceOf(WebAuthnCeremonyError); + expect((err as WebAuthnCeremonyError).reason).to.equal('not-allowed'); } ); }); diff --git a/cypress/component/resources/webauthn/webauthn.service.cy.ts b/cypress/component/resources/webauthn/webauthn.service.cy.ts index 0f5c95abc3..12e28ccba5 100644 --- a/cypress/component/resources/webauthn/webauthn.service.cy.ts +++ b/cypress/component/resources/webauthn/webauthn.service.cy.ts @@ -184,6 +184,9 @@ describe('verifyPasskeyEnrollment', () => { callService({ fn: 'verifyPasskeyEnrollment', provider: 'singleton', + // The sudo gate reads the ACTIVE session's factors — seed s1 live (the + // singleton stamps REAL factor timestamps, so the 10-min window passes). + liveSessions: [{ id: 's1', token: 't1' }], request: { url: 'http://localhost/id/setup/passkey', sessions: sessionsFor('org-1') }, verifyEnrollInput: { credential: VALID_CRED, @@ -217,6 +220,9 @@ describe('verifyPasskeyEnrollment', () => { fn: 'verifyPasskeyEnrollment', provider: 'singleton', failVerifyPasskey: 'INVALID_CREDENTIALS', + // Seed the live session so the sudo gate passes and the scenario still + // exercises the INVALID_CREDENTIALS mapping. + liveSessions: [{ id: 's1', token: 't1' }], request: { url: 'http://localhost/id/setup/passkey', sessions: sessionsFor() }, verifyEnrollInput: { credential: VALID_CRED, passkeyId: 'pk-1', loginName: ALICE }, }).then((v) => { @@ -233,5 +239,21 @@ describe('verifyPasskeyEnrollment', () => { }).then((v) => { expect(v.outcome).to.deep.equal({ ok: false, error: 'SESSION_EXPIRED' }); }); + + // A stored session token from a different browser/tab can be stale or revoked + // provider-side by the time this route sees it — the real Zitadel backend throws a + // non-transient ProviderError (e.g. PERMISSION_DENIED) from the sudo gate's own + // getSession call instead of returning null. Recovers to SESSION_EXPIRED, not a crash + // (mirrors passkeys.service.ts/reauth.service.ts's own stale-session fix). + callService({ + fn: 'verifyPasskeyEnrollment', + provider: 'singleton', + liveSessions: [{ id: 's1', token: 't1' }], + sessionResults: { s1: { mode: 'throw', code: 'PERMISSION_DENIED' } }, + request: { url: 'http://localhost/id/setup/passkey', sessions: sessionsFor() }, + verifyEnrollInput: { credential: VALID_CRED, passkeyId: 'pk-1', loginName: ALICE }, + }).then((v) => { + expect(v.outcome).to.deep.equal({ ok: false, error: 'SESSION_EXPIRED' }); + }); }); }); diff --git a/cypress/component/routes/device/authorize-identity.cy.tsx b/cypress/component/routes/device/authorize-identity.cy.tsx new file mode 100644 index 0000000000..08f869ebf7 --- /dev/null +++ b/cypress/component/routes/device/authorize-identity.cy.tsx @@ -0,0 +1,55 @@ +// cypress/component/routes/device/authorize-identity.cy.tsx +// +// Pins the "Authorizing as X — Not you?" identity row now rendered +// through the shared IdentityBadge component instead of bespoke flex markup. +import DeviceAuthorize from '@/routes/device/authorize'; +import { ConformAdapter } from '@datum-cloud/datum-ui/form/adapters/conform'; +import { setupI18n } from '@lingui/core'; +import { I18nProvider } from '@lingui/react'; +import { mount } from 'cypress/react'; +import { createMemoryRouter, RouterProvider } from 'react-router'; + +function withI18n(node: React.ReactNode) { + const i18n = setupI18n({ locale: 'en', messages: { en: {} } }); + return ( + + {node} + + ); +} + +const LOADER_DATA = { + csrfToken: 'tok-1', + appName: 'Acme CLI', + scope: ['profile', 'email'], + deviceAuthId: 'da-1', + requestId: 'device_ABC123', + activeLoginName: 'mia@acme.test', +}; + +function mountAuthorize() { + const router = createMemoryRouter( + [ + { + id: 'device-authorize', + path: '/device/authorize', + element: , + loader: async () => LOADER_DATA, + }, + ], + { initialEntries: ['/device/authorize'] } + ); + return mount(withI18n()); +} + +describe('/device/authorize — identity via shared IdentityBadge', () => { + it('shows "Authorizing as " with a "Not you?" link to /accounts?user_code=...', () => { + mountAuthorize(); + cy.contains('Authorizing as').should('be.visible'); + cy.contains(LOADER_DATA.activeLoginName).should('be.visible'); + cy.findByRole('link', { name: /not you\?/i }) + .should('have.attr', 'href') + .and('include', '/accounts') + .and('include', 'user_code=ABC123'); + }); +}); diff --git a/cypress/component/routes/login/index.cy.tsx b/cypress/component/routes/login/index.cy.tsx new file mode 100644 index 0000000000..a41bb772c8 --- /dev/null +++ b/cypress/component/routes/login/index.cy.tsx @@ -0,0 +1,212 @@ +// cypress/component/routes/login/index.cy.tsx +// +// UI contract for /login (A-P10): the sole-passkey action-data variant fires the +// shared ceremony INLINE ("Signing in as ." + auto-fire via beginWith), with +// a manual "Continue with passkey" fallback (begin(), a FRESH challenge) and a +// "Not you?" dismissal back to the ordinary identifier form. Mirrors method.cy.tsx's +// stub /login/passkey route + capturedPosts convention (Task 2). +import Login from '@/routes/login/index'; +import { ConformAdapter } from '@datum-cloud/datum-ui/form/adapters/conform'; +import { setupI18n } from '@lingui/core'; +import { I18nProvider } from '@lingui/react'; +import { mount } from 'cypress/react'; +import { createMemoryRouter, RouterProvider } from 'react-router'; + +const LOGIN_CONTEXT = { loginName: '', requestId: undefined, organization: undefined }; + +// Settings shaped so the ordinary identifier form (the "Email" button) renders by +// default — the baseline the inline ceremony state must replace/restore around. +const INDEX_LOADER_DATA = { + csrfToken: 'tok-0', + idps: [], + settings: { + allowPassword: true, + allowRegister: false, + allowExternalIdp: false, + passkeysType: 'allowed', + forceMfa: false, + disableLoginWithEmail: false, + disableLoginWithPhone: false, + }, + branding: null, + emailDeliveryEnabled: false, + notice: undefined, + lastUsedLogin: undefined, +}; + +const capturedPosts: Array> = []; + +function withI18n(node: React.ReactNode) { + const i18n = setupI18n({ locale: 'en', messages: { en: {} } }); + return ( + + {node} + + ); +} + +function mountLogin(opts?: { + actionData?: unknown; + // Known loginName gates the Passkey SHORTCUT visible (view.showPasskeyPrompt && loginName). + loginName?: string; + // Overrides the /login (index) route's own action — used to simulate a REAL identifier + // resubmit that resolves to a fresh sole-passkey challenge (Finding 2 coverage). + indexAction?: (args: { request: Request }) => unknown | Promise; + // Overrides the /login/passkey stub's action — used to simulate a ceremony failure + // (Finding 1 coverage). Defaults to capturing the POST into capturedPosts. + passkeyAction?: (args: { request: Request }) => unknown | Promise; +}) { + const loginContext = { ...LOGIN_CONTEXT, loginName: opts?.loginName ?? LOGIN_CONTEXT.loginName }; + const router = createMemoryRouter( + [ + { + id: 'login', + path: '/login', + loader: () => loginContext, + children: [ + { + id: 'index', + index: true, + element: , + loader: async () => INDEX_LOADER_DATA, + action: opts?.indexAction ?? (async () => null), + }, + // Stub /login/passkey — same route the shared ceremony hook lazily loads + // (challenge) then posts to (credential). Mirrors method.cy.tsx's convention: + // no navigation assertions, just the captured POST (or a stubbed failure datum). + { + id: 'passkey', + path: 'passkey', + loader: async () => ({ + csrfToken: 'tok-1', + loginName: opts?.loginName ?? 'solo@acme.test', + requestId: undefined, + organization: undefined, + publicKeyCredentialRequestOptions: { publicKey: { challenge: 'x' } }, + }), + action: + opts?.passkeyAction ?? + (async ({ request }: { request: Request }) => { + capturedPosts.push(Object.fromEntries(await request.formData())); + // Truthy (not null) — mirrors a real completed action (redirect navigates away; + // a rejection returns a truthy {error} object). A falsy return would leave the + // ceremony's busy-until-idle effect (submitFetcher.data truthiness) stuck forever, + // which is never reachable in production but would wrongly keep sibling controls + // disabled here since redirect-following is RR-internal and not under test. + return {}; + }), + }, + ], + }, + ], + { + initialEntries: ['/login'], + hydrationData: { + loaderData: { login: loginContext, index: INDEX_LOADER_DATA }, + ...(opts?.actionData !== undefined ? { actionData: { index: opts.actionData } } : {}), + }, + } + ); + return mount(withI18n()); +} + +describe('/login — sole-passkey inline ceremony', () => { + beforeEach(() => { + capturedPosts.length = 0; + }); + + it('sole-passkey action data swaps to the inline ceremony state and auto-fires', () => { + mountLogin({ + actionData: { + passkeyInline: { + loginName: 'solo@acme.test', + csrfToken: 'tok-1', + publicKeyCredentialRequestOptions: { publicKey: { challenge: 'x' } }, + }, + }, + }); + cy.contains('Signing in as').should('be.visible'); + cy.contains('solo@acme.test').should('be.visible'); + cy.contains('Not you?').should('be.visible'); + // Auto-fire: the pre-baked credential reaches the stub action without a click. + cy.wrap(null).should(() => { + expect(capturedPosts).to.have.length(1); + expect(capturedPosts[0].loginName).to.equal('solo@acme.test'); + }); + // Identifier form is gone while the ceremony state shows. + cy.contains('button', 'Email').should('not.exist'); + }); + + it('inline state offers the manual button and Not you? returns to the identifier form', () => { + mountLogin({ + actionData: { + passkeyInline: { + loginName: 'solo@acme.test', + csrfToken: 'tok-1', + publicKeyCredentialRequestOptions: { publicKey: { challenge: 'x' } }, + }, + }, + }); + cy.contains('button', 'Continue with passkey').should('be.visible'); + cy.contains('Not you?').click(); + cy.contains('button', 'Email').should('be.visible'); + }); + + // Finding 1: the gated Passkey SHORTCUT drives the same `ceremony` as the inline + // state but never sets passkeyInline — a failure there must still surface visibly. + it('the gated Passkey shortcut surfaces a ceremony failure through the shared error region', () => { + mountLogin({ + loginName: 'solo@acme.test', + // The /login/passkey ACTION rejects the (fake, Cypress-marshalled) credential — + // this is the server-side rejection path (ceremony.actionData), not a browser-side + // ceremony throw (ceremony.reason); the shared region must render either. + passkeyAction: async () => ({ error: 'INVALID_CREDENTIALS' }), + }); + cy.contains('button', 'Passkey').should('not.be.disabled').click(); + cy.contains('Incorrect credentials. Please try again.').should('be.visible'); + // The shortcut re-enables once the ceremony drops back to idle (not stuck busy). + cy.contains('button', 'Passkey').should('not.be.disabled'); + }); + + // Finding 2: dismissal is keyed to the SPECIFIC challenge, not a component-lifetime + // boolean — a later, unrelated sole-passkey resolution must still auto-fire. Driven via + // a REAL identifier resubmit (not a remount, which would reset all component state and + // prove nothing about the dismiss→re-arm transition on the same mounted instance). + it('a fresh sole-passkey challenge after "Not you?" auto-fires again (re-arm, not stuck)', () => { + const CHALLENGE_B = { publicKey: { challenge: 'chal-b' } }; + mountLogin({ + loginName: 'solo@acme.test', + actionData: { + passkeyInline: { + loginName: 'solo@acme.test', + csrfToken: 'tok-1', + publicKeyCredentialRequestOptions: { publicKey: { challenge: 'chal-a' } }, + }, + }, + // Simulates the /login action resolving a DIFFERENT identifier to a fresh + // sole-passkey challenge — a real resubmit, not the same dismissed challenge. + indexAction: async () => ({ + passkeyInline: { + loginName: 'other@acme.test', + csrfToken: 'tok-2', + publicKeyCredentialRequestOptions: CHALLENGE_B, + }, + }), + }); + + // Auto-fire #1 (challenge A) reaches the stub /login/passkey action. + cy.wrap(null).should(() => expect(capturedPosts).to.have.length(1)); + + cy.contains('Not you?').click(); + cy.contains('button', 'Email').should('be.visible').click(); + cy.contains('button', 'Continue').click(); + + // The fresh challenge (different identity, different publicKeyCredentialRequestOptions + // object) shows the inline state again and auto-fires a SECOND time. + cy.contains('other@acme.test').should('be.visible'); + cy.wrap(null).should(() => { + expect(capturedPosts).to.have.length(2); + expect(capturedPosts[1].loginName).to.equal('other@acme.test'); + }); + }); +}); diff --git a/cypress/component/routes/login/method-chooser.cy.ts b/cypress/component/routes/login/method-chooser.cy.ts index cf2c17430a..385fb97820 100644 --- a/cypress/component/routes/login/method-chooser.cy.ts +++ b/cypress/component/routes/login/method-chooser.cy.ts @@ -43,4 +43,72 @@ describe('/login/method loader', () => { expect(loc).to.contain('/login/password'); }); }); + + it("resolves idps to only this user's linked (active, non-LDAP) providers", () => { + // Google is active AND linked to u1; GitHub is active but NOT linked — must not surface. + callService({ + fn: 'loginMethodLoader', + seed: { + users: [{ id: 'u1', loginName: 'mia@acme.test' }], + authMethods: { u1: ['password', 'idp'] }, + idps: [ + { id: 'idp-google', name: 'Google', type: 'GOOGLE' }, + { id: 'idp-github', name: 'GitHub', type: 'GITHUB' }, + ], + idpLinks: { u1: [{ idpId: 'idp-google', idpUserId: 'g-1' }] }, + }, + env: { AUTH_EMAIL_DELIVERY_ENABLED: 'true' }, + request: { url: 'http://localhost/id/login/method?loginName=mia%40acme.test' }, + }).then((v) => { + const body = v.response?.dataBody as { methods: string[]; idps: Array<{ id: string }> }; + expect(body.methods).to.include('idp'); + expect(body.idps).to.deep.equal([{ id: 'idp-google', name: 'Google', type: 'GOOGLE' }]); + }); + }); +}); + +describe('/login/method action — intent=idp', () => { + it('a linked IdP starts the OAuth round-trip (redirects to the authUrl)', () => { + callService({ + fn: 'loginMethodAction', + seed: { + users: [{ id: 'u1', loginName: 'mia@acme.test' }], + authMethods: { u1: ['password', 'idp'] }, + idps: [{ id: 'idp-google', name: 'Google', type: 'GOOGLE' }], + idpLinks: { u1: [{ idpId: 'idp-google', idpUserId: 'g-1' }] }, + }, + request: { + url: 'http://localhost/id/login/method?loginName=mia%40acme.test', + form: { intent: 'idp', idpId: 'idp-google' }, + csrf: true, + }, + }).then((v) => { + expect(v.response?.status).to.equal(302); + expect(v.response?.location).to.contain('idp-google'); + }); + }); + + it('rejects an idpId that is active in the org but NOT linked to this user', () => { + // Never trust the client's idpId — a crafted POST for an active-but-unlinked provider + // must not start a round-trip, mirroring reauth.tsx's own defense-in-depth check. + callService({ + fn: 'loginMethodAction', + seed: { + users: [{ id: 'u1', loginName: 'mia@acme.test' }], + authMethods: { u1: ['password', 'idp'] }, + idps: [ + { id: 'idp-google', name: 'Google', type: 'GOOGLE' }, + { id: 'idp-github', name: 'GitHub', type: 'GITHUB' }, + ], + idpLinks: { u1: [{ idpId: 'idp-google', idpUserId: 'g-1' }] }, + }, + request: { + url: 'http://localhost/id/login/method?loginName=mia%40acme.test', + form: { intent: 'idp', idpId: 'idp-github' }, + csrf: true, + }, + }).then((v) => { + expect(v.response?.dataStatus).to.equal(400); + }); + }); }); diff --git a/cypress/component/routes/login/method.cy.tsx b/cypress/component/routes/login/method.cy.tsx new file mode 100644 index 0000000000..1786d9c541 --- /dev/null +++ b/cypress/component/routes/login/method.cy.tsx @@ -0,0 +1,162 @@ +// cypress/component/routes/login/method.cy.tsx +// +// UI contract for /login/method (A-P10): identity header ("Signing in as " / +// "Not you?") and the Passkey entry firing usePasskeyLoginCeremony IN PLACE (a Button +// that lazily loads the /login/passkey challenge and submits the pre-baked Cypress +// credential) instead of navigating there. The other method entries stay plain links. +import LoginMethod from '@/routes/login/method'; +import { ConformAdapter } from '@datum-cloud/datum-ui/form/adapters/conform'; +import { setupI18n } from '@lingui/core'; +import { I18nProvider } from '@lingui/react'; +import { mount } from 'cypress/react'; +import { createMemoryRouter, RouterProvider } from 'react-router'; + +const LOGIN_CONTEXT = { + loginName: 'mia@acme.test', + requestId: undefined, + organization: undefined, +}; +const METHOD_LOADER_DATA = { + methods: ['passkey', 'password'], + branding: null, + idps: [], + csrfToken: 'tok-1', +}; + +const capturedPosts: Array> = []; + +function withI18n(node: React.ReactNode) { + const i18n = setupI18n({ locale: 'en', messages: { en: {} } }); + return ( + + {node} + + ); +} + +function mountMethod() { + const router = createMemoryRouter( + [ + { + id: 'login', + path: '/login', + loader: () => LOGIN_CONTEXT, + children: [ + { + id: 'method', + path: 'method', + element: , + loader: async () => METHOD_LOADER_DATA, + }, + // Stub /login/passkey — same route the shared ceremony hook lazily loads + // (challenge) then posts to (credential). Mirrors passkeys-ui.cy.tsx's + // render-only convention: no navigation assertions, just the captured POST. + { + id: 'passkey', + path: 'passkey', + loader: async () => ({ + csrfToken: 'tok-1', + loginName: 'mia@acme.test', + requestId: undefined, + organization: undefined, + publicKeyCredentialRequestOptions: { publicKey: { challenge: 'x' } }, + }), + action: async ({ request }: { request: Request }) => { + capturedPosts.push(Object.fromEntries(await request.formData())); + // Truthy (not null) — mirrors a real completed action so the ceremony's + // busy-until-idle effect doesn't stay stuck (see index.cy.tsx for the failure + // this caused once sibling controls started disabling on ceremony.phase). + return {}; + }, + }, + ], + }, + ], + { + initialEntries: ['/login/method?loginName=mia%40acme.test'], + hydrationData: { + loaderData: { login: LOGIN_CONTEXT, method: METHOD_LOADER_DATA }, + }, + } + ); + return mount(withI18n()); +} + +describe('/login/method — identity header + in-place passkey ceremony', () => { + beforeEach(() => { + capturedPosts.length = 0; + }); + + it('shows the identity header with a Not you? action back to /login', () => { + mountMethod(); + cy.contains('Signing in as').should('be.visible'); + cy.contains('mia@acme.test').should('be.visible'); + cy.contains('a', 'Not you?') + .should('have.attr', 'href') + .and('match', /\/login(\?|$)/) + .and('not.contain', 'loginName'); + }); + + it('Passkey fires the ceremony in place and submits the pre-baked credential', () => { + mountMethod(); + cy.contains('button', 'Passkey').click(); + // Lazy challenge → pre-baked credential → POST to the stub action. + cy.wrap(null).should(() => { + expect(capturedPosts).to.have.length(1); + expect(capturedPosts[0].loginName).to.equal('mia@acme.test'); + expect(JSON.parse(String(capturedPosts[0].credential)).id).to.equal('fake-credential-id'); + }); + // No navigation happened — the chooser is still on screen as the fallback. + // Password stays a plain link (unchanged), so it's matched by its tag. + cy.contains('a', 'Password').should('be.visible'); + }); + + it("posts intent=idp + idpId to this route's own action instead of navigating to /sso", () => { + const methodData = { + methods: ['idp', 'password'], + branding: null, + idps: [{ id: 'idp-google', name: 'Google', type: 'GOOGLE' }], + csrfToken: 'tok-1', + }; + const capturedIdpPosts: Array> = []; + const router = createMemoryRouter( + [ + { + id: 'login', + path: '/login', + loader: () => LOGIN_CONTEXT, + children: [ + { + id: 'method', + path: 'method', + element: , + loader: async () => methodData, + action: async ({ request }: { request: Request }) => { + capturedIdpPosts.push(Object.fromEntries(await request.formData())); + return null; + }, + }, + ], + }, + ], + { + initialEntries: ['/login/method?loginName=mia%40acme.test'], + hydrationData: { + loaderData: { login: LOGIN_CONTEXT, method: methodData }, + }, + } + ); + mount(withI18n()); + + // Named after the actual provider ("Google") — IdpButtonList's own copy, matching + // /login's own idp buttons — rendered as a submit button (not an to /sso), since + // starting sign-in is a provider-side call that has to happen in an action. + cy.contains('button', 'Google').click(); + cy.wrap(null).should(() => { + expect(capturedIdpPosts).to.have.length(1); + expect(capturedIdpPosts[0].intent).to.equal('idp'); + expect(capturedIdpPosts[0].idpId).to.equal('idp-google'); + }); + cy.contains('a', 'Password').should('be.visible'); + }); +}); diff --git a/cypress/component/routes/login/passkey-back-link.cy.tsx b/cypress/component/routes/login/passkey-back-link.cy.tsx new file mode 100644 index 0000000000..1438d6ec79 --- /dev/null +++ b/cypress/component/routes/login/passkey-back-link.cy.tsx @@ -0,0 +1,49 @@ +// cypress/component/routes/login/passkey-back-link.cy.tsx +// +// /login/passkey is now a fallback/deep-link-only screen (2026-07-22 passkey rework) — +// no mainstream forward navigation lands here, so it has no meaningful "previous step". +// Pins that Back is explicitly suppressed rather than silently rendering null. +import LoginPasskey from '@/routes/login/passkey'; +import { ConformAdapter } from '@datum-cloud/datum-ui/form/adapters/conform'; +import { setupI18n } from '@lingui/core'; +import { I18nProvider } from '@lingui/react'; +import { mount } from 'cypress/react'; +import { createMemoryRouter, RouterProvider } from 'react-router'; + +function withI18n(node: React.ReactNode) { + const i18n = setupI18n({ locale: 'en', messages: { en: {} } }); + return ( + + {node} + + ); +} + +function mountPasskey() { + const router = createMemoryRouter( + [ + { + id: 'passkey', + path: '/login/passkey', + element: , + loader: async () => ({ + csrfToken: 'tok-1', + loginName: 'mia@acme.test', + requestId: undefined, + organization: undefined, + publicKeyCredentialRequestOptions: { publicKey: { challenge: 'x' } }, + }), + }, + ], + { initialEntries: ['/login/passkey'] } + ); + return mount(withI18n()); +} + +describe('/login/passkey — Back link explicitly suppressed', () => { + it('renders the identity header but no Back control', () => { + mountPasskey(); + cy.contains('a', 'Not you?').should('exist'); + cy.contains('a', 'Back').should('not.exist'); + }); +}); diff --git a/cypress/component/routes/logout/logout.cy.tsx b/cypress/component/routes/logout/logout.cy.tsx index 32411f50ea..99bf6e23be 100644 --- a/cypress/component/routes/logout/logout.cy.tsx +++ b/cypress/component/routes/logout/logout.cy.tsx @@ -36,4 +36,30 @@ describe('Logout confirm form — index-route POST disambiguation', () => { // action-less logout/layout (→ 405); ?index targets routes/logout/index which owns the action. cy.get('form').invoke('attr', 'action').should('include', '?index'); }); + + it('shows "Sign out of " when an active session exists (no switch link)', () => { + const router = createMemoryRouter([{ id: 'logout', path: '/logout', element: }], { + initialEntries: ['/logout'], + hydrationData: { + loaderData: { logout: { csrfToken: 'test-csrf', loginName: 'mia@acme.test' } }, + }, + }); + mount(withProviders()); + cy.contains('Sign out of').should('be.visible'); + cy.contains('mia@acme.test').should('be.visible'); + // Scoped, not a blanket "no on the page": BrandLogo always renders a home link. + // What IdentityBadge's showLink=false must suppress is its OWN "Not you?" switch-account + // link (which targets /login). This assertion catches if showLink={false} is accidentally removed. + cy.contains(/not you\?/i).should('not.exist'); + cy.get('a[href="/login"], a[href^="/login?"]').should('not.exist'); + }); + + it('falls back to the generic confirm copy when there is no active session', () => { + const router = createMemoryRouter([{ id: 'logout', path: '/logout', element: }], { + initialEntries: ['/logout'], + hydrationData: { loaderData: { logout: { csrfToken: 'test-csrf', loginName: '' } } }, + }); + mount(withProviders()); + cy.contains('Are you sure you want to sign out?').should('be.visible'); + }); }); diff --git a/cypress/component/routes/passkeys-ui.cy.tsx b/cypress/component/routes/passkeys-ui.cy.tsx new file mode 100644 index 0000000000..e9c90b3eec --- /dev/null +++ b/cypress/component/routes/passkeys-ui.cy.tsx @@ -0,0 +1,169 @@ +// cypress/component/routes/passkeys-ui.cy.tsx +// +// UI contract for /id/passkeys: trash-icon remove trigger (confirm dialog kept), +// badge only when inactive, post-delete "sign out other sessions?" dialog +// (reshaped from an inline alert), designed empty state, one-time "Passkey added." notice. +// Mounted with hydrationData (no loader function — render-only, like setup-render.cy.tsx). +import Passkeys from '@/routes/passkeys'; +import { ConformAdapter } from '@datum-cloud/datum-ui/form/adapters/conform'; +import { setupI18n } from '@lingui/core'; +import { I18nProvider } from '@lingui/react'; +import { mount } from 'cypress/react'; +import { createMemoryRouter, RouterProvider } from 'react-router'; + +type Row = { id: string; state: 'active' | 'inactive'; name: string; createdAt?: string }; + +const BASE_LOADER = { + csrfToken: 'tok-1', + view: { + passkeys: [{ id: 'pk1', state: 'active', name: 'Seeded laptop' }] as Row[], + methodCount: 2, + loginName: 'mia@acme.test', + returnTo: null, + }, + added: false, +}; + +function mountPasskeys( + loaderOverrides?: Partial, + actionData?: unknown, + // What a real form submission returns (e.g. { error: 'LAST_METHOD' } for a refusal). + actionResult: unknown = null +) { + const i18n = setupI18n({ locale: 'en', messages: { en: {} } }); + const router = createMemoryRouter( + [ + { + id: 'passkeys', + path: '/passkeys', + element: ( + + + + + + ), + action: async () => actionResult, + // Deviation (not in the reviewer's fix text): without a loader, React Router's + // post-action revalidation leaves useLoaderData() undefined and the route crashes + // (caught by RR's default ErrorBoundary), which made the new "sign-out submit + // closes the dialog" test pass for the wrong reason — the whole page unmounts, + // not our close logic. Returning the same data hydration already seeded keeps + // revalidation truthful (mirrors the real redirect-to-same-route-and-reload). + loader: async () => ({ ...BASE_LOADER, ...loaderOverrides }), + }, + ], + { + initialEntries: ['/passkeys'], + hydrationData: { + loaderData: { passkeys: { ...BASE_LOADER, ...loaderOverrides } }, + ...(actionData !== undefined ? { actionData: { passkeys: actionData } } : {}), + }, + } + ); + return mount(); +} + +describe('/id/passkeys — UI contract', () => { + it('active row: no badge, trash trigger with aria-label opens the danger confirm dialog', () => { + mountPasskeys(); + cy.contains('Seeded laptop').should('be.visible'); + cy.contains('Active').should('not.exist'); + cy.get('button[aria-label="Remove Seeded laptop"]').should('exist').click(); + cy.contains('Remove this passkey?').should('be.visible'); + cy.contains('button', 'Remove passkey').should('be.visible'); + }); + + it('inactive row keeps a muted Inactive badge', () => { + mountPasskeys({ + view: { + ...BASE_LOADER.view, + passkeys: [{ id: 'pk2', state: 'inactive', name: 'Stuck enrollment' }], + }, + }); + cy.contains('Inactive').should('be.visible'); + }); + + it('successful removal opens the sign-out-others dialog; Not now closes it', () => { + mountPasskeys(undefined, { removed: 'Seeded laptop' }); + cy.contains('Passkey removed').should('be.visible'); + cy.contains('button', 'Sign out other sessions').should('be.visible'); + cy.contains('button', 'Not now').click(); + cy.contains('Passkey removed').should('not.exist'); + // The page (list) is still there — no navigation happened. + cy.contains('Seeded laptop').should('be.visible'); + }); + + it('sign-out submit closes the dialog when the action result lands', () => { + mountPasskeys(undefined, { removed: 'Seeded laptop' }); + cy.contains('Passkey removed').should('be.visible'); + cy.contains('button', 'Sign out other sessions').click(); + cy.contains('Passkey removed').should('not.exist'); + }); + + it('remove refusal (LAST_METHOD) closes the confirm dialog so the inline error is visible', () => { + mountPasskeys({ view: { ...BASE_LOADER.view, methodCount: 1 } }, undefined, { + error: 'LAST_METHOD', + }); + cy.get('button[aria-label="Remove Seeded laptop"]').click(); + cy.contains('Remove this passkey?').should('be.visible'); + cy.contains('button', 'Remove passkey').click(); + // The refusal must not hide behind the modal overlay. + cy.contains('Remove this passkey?').should('not.exist'); + cy.contains("You can't remove your only sign-in method").should('be.visible'); + }); + + it('empty list renders the minimal empty state', () => { + mountPasskeys({ view: { ...BASE_LOADER.view, passkeys: [] } }); + cy.contains('No passkeys yet.').should('be.visible'); + cy.get('ul').should('not.exist'); + }); + + it('shows the active login name, a "Not you?" switch link, and a sign-out action', () => { + mountPasskeys(); + cy.contains('Logged in as').should('be.visible'); + cy.contains('mia@acme.test').should('be.visible'); + cy.findByRole('link', { name: /not you\?/i }).should('have.attr', 'href', '/accounts'); + cy.contains('button', 'Sign out').should('be.visible'); + }); + + it('row with createdAt renders the muted "Added " second line', () => { + const createdAt = '2026-07-21T10:00:00.000Z'; + // Same Intl path the app uses — deterministic across CI timezones. + const expected = new Intl.DateTimeFormat('en', { dateStyle: 'medium' }).format( + new Date(createdAt) + ); + mountPasskeys({ + view: { + ...BASE_LOADER.view, + passkeys: [{ id: 'pk1', state: 'active', name: 'Seeded laptop', createdAt }], + }, + }); + // Styling contract: the date line must carry the muted/small-text treatment. + cy.contains(`Added ${expected}`) + .should('be.visible') + .and('have.class', 'text-muted-foreground') + .and('have.class', 'text-xs'); + }); + + it('row without createdAt renders no Added line (no created-at metadata)', () => { + mountPasskeys(); + cy.contains('Seeded laptop').should('be.visible'); + cy.contains('Added ').should('not.exist'); + }); + + it('inactive row with createdAt renders both the Inactive badge and the Added line', () => { + const createdAt = '2026-07-15T08:30:00.000Z'; + const expected = new Intl.DateTimeFormat('en', { dateStyle: 'medium' }).format( + new Date(createdAt) + ); + mountPasskeys({ + view: { + ...BASE_LOADER.view, + passkeys: [{ id: 'pk2', state: 'inactive', name: 'Stuck enrollment', createdAt }], + }, + }); + cy.contains('Inactive').should('be.visible'); + cy.contains(`Added ${expected}`).should('be.visible'); + }); +}); diff --git a/cypress/component/routes/password/change-identity.cy.tsx b/cypress/component/routes/password/change-identity.cy.tsx new file mode 100644 index 0000000000..d1ae5f8f78 --- /dev/null +++ b/cypress/component/routes/password/change-identity.cy.tsx @@ -0,0 +1,64 @@ +// cypress/component/routes/password/change-identity.cy.tsx +// +// password/change.tsx previously showed a bare inert loginName paragraph. Upgrades it +// to the shared IdentityBadge styling with showLink=false (mid-password-change is not +// a good place to offer an account-switch/abandon affordance). +import PasswordChange from '@/routes/password/change'; +import { ConformAdapter } from '@datum-cloud/datum-ui/form/adapters/conform'; +import { setupI18n } from '@lingui/core'; +import { I18nProvider } from '@lingui/react'; +import { mount } from 'cypress/react'; +import { createMemoryRouter, RouterProvider } from 'react-router'; + +function withI18n(node: React.ReactNode) { + const i18n = setupI18n({ locale: 'en', messages: { en: {} } }); + return ( + + {node} + + ); +} + +function mountPasswordChange() { + const router = createMemoryRouter( + [ + { + id: 'password-change', + path: '/password/change', + element: , + loader: async () => ({ + csrfToken: 'tok-1', + sessionId: 's1', + loginName: 'mia@acme.test', + requestId: undefined, + // Real shape is `PasswordComplexity` (app/modules/auth/types.ts): minLength + + // requires{Uppercase,Lowercase,Number,Symbol} — confirmed against + // app/resources/password/password.schema.ts and password-complexity.ts. + passwordComplexity: { + minLength: 8, + requiresUppercase: false, + requiresLowercase: false, + requiresNumber: false, + requiresSymbol: false, + }, + }), + }, + ], + { initialEntries: ['/password/change'] } + ); + return mount(withI18n()); +} + +describe('/password/change — identity via IdentityBadge, no switch link', () => { + it('shows "Signing in as " with no switch-account link', () => { + mountPasswordChange(); + cy.contains('Signing in as').should('be.visible'); + cy.contains('mia@acme.test').should('be.visible'); + // Scoped, not a blanket "no on the page": /password/change always renders an + // unrelated BackLink anchor to /login/password (previous-step.ts) plus a logo link — + // both pre-exist this change. What IdentityBadge's showLink=false must suppress is + // its OWN "Not you?" switch-account link (which targets /login, not /login/password). + cy.contains(/not you\?/i).should('not.exist'); + cy.get('a[href="/login"], a[href^="/login?"]').should('not.exist'); + }); +}); diff --git a/cypress/component/routes/paths.cy.ts b/cypress/component/routes/paths.cy.ts index 14ec3c4b7a..d36cd19243 100644 --- a/cypress/component/routes/paths.cy.ts +++ b/cypress/component/routes/paths.cy.ts @@ -18,4 +18,15 @@ describe("paths.ts — typed builders return today's exact strings", () => { expect(paths.login.verify.sms({})).to.equal('/login/verify/sms'); expect(paths.login.verify.authenticator({})).to.equal('/login/verify/authenticator'); }); + + it('builds passkey-management and reauth paths', () => { + expect(paths.passkeys()).to.equal('/passkeys'); + expect(paths.reauth()).to.equal('/reauth'); + expect(paths.reauth({ method: 'password', returnTo: '/passkeys' })).to.equal( + '/reauth?method=password&returnTo=%2Fpasskeys' + ); + expect(paths.passkeys({ returnTo: 'https://portal.test/settings' })).to.equal( + '/passkeys?returnTo=https%3A%2F%2Fportal.test%2Fsettings' + ); + }); }); diff --git a/cypress/component/routes/reauth-action.cy.ts b/cypress/component/routes/reauth-action.cy.ts new file mode 100644 index 0000000000..867ad42435 --- /dev/null +++ b/cypress/component/routes/reauth-action.cy.ts @@ -0,0 +1,108 @@ +// cypress/component/routes/reauth-action.cy.ts +// +// /reauth action, intent=idp-reauth: never trust the client's idpId — it must resolve +// to an ACTIVE, non-LDAP provider actually LINKED to the current session's own user +// before starting the round-trip. Previously only re-checked active+non-LDAP (any org +// IdP would pass), relying entirely on the callback's identity-mismatch rejection. +import { callService } from '../../support/node/call-service'; + +const USER = { id: 'u1', loginName: 'mia@acme.test' }; + +describe('/reauth action — intent=idp-reauth', () => { + it('a linked IdP starts the OAuth round-trip (redirects to the authUrl)', () => { + callService({ + fn: 'reauthAction', + seed: { + users: [USER], + idps: [{ id: 'idp-google', name: 'Google', type: 'GOOGLE' }], + idpLinks: { u1: [{ idpId: 'idp-google', idpUserId: 'g-1' }] }, + }, + liveSessions: [{ id: 'sess-1', token: 'sess-tok-1', user: USER }], + request: { + url: 'http://localhost/id/reauth', + sessions: [{ id: 'sess-1', token: 'sess-tok-1', loginName: USER.loginName }], + form: { intent: 'idp-reauth', idpId: 'idp-google', returnTo: '/passkeys' }, + csrf: true, + }, + }).then((v) => { + expect(v.response?.status).to.equal(302); + expect(v.response?.location).to.contain('idp-google'); + }); + }); + + it('rejects an idpId that is active in the org but NOT linked to this user', () => { + // Never trust the client's idpId — a crafted POST for an active-but-unlinked + // provider must not start a round-trip, even though it would still be caught at + // the callback stage by the identity-mismatch check. + callService({ + fn: 'reauthAction', + seed: { + users: [USER], + idps: [ + { id: 'idp-google', name: 'Google', type: 'GOOGLE' }, + { id: 'idp-github', name: 'GitHub', type: 'GITHUB' }, + ], + idpLinks: { u1: [{ idpId: 'idp-google', idpUserId: 'g-1' }] }, + }, + liveSessions: [{ id: 'sess-1', token: 'sess-tok-1', user: USER }], + request: { + url: 'http://localhost/id/reauth', + sessions: [{ id: 'sess-1', token: 'sess-tok-1', loginName: USER.loginName }], + form: { intent: 'idp-reauth', idpId: 'idp-github', returnTo: '/passkeys' }, + csrf: true, + }, + }).then((v) => { + expect(v.response?.dataStatus).to.equal(400); + }); + }); + + it('preserves the Zitadel-configured default returnTo instead of collapsing to /passkeys', () => { + // The client echoes back the absolute default loadReauth resolved earlier (the same + // hidden-field round-trip performReauth's own fix covers) — this must not collapse to + // /passkeys just because it's not on POST_LOGOUT_ALLOWLIST, since that would then be + // baked into the callback URL with no way to recover the real destination downstream. + callService({ + fn: 'reauthAction', + seed: { + users: [USER], + idps: [{ id: 'idp-google', name: 'Google', type: 'GOOGLE' }], + idpLinks: { u1: [{ idpId: 'idp-google', idpUserId: 'g-1' }] }, + }, + loginDefaultRedirectUri: 'https://app.acme.test/dashboard', + liveSessions: [{ id: 'sess-1', token: 'sess-tok-1', user: USER }], + recordCalls: ['startIdpIntent'], + request: { + url: 'http://localhost/id/reauth', + sessions: [{ id: 'sess-1', token: 'sess-tok-1', loginName: USER.loginName }], + form: { + intent: 'idp-reauth', + idpId: 'idp-google', + returnTo: 'https://app.acme.test/dashboard', + }, + csrf: true, + }, + }).then((v) => { + expect(v.response?.status).to.equal(302); + const calls = v.calls?.startIdpIntent as + Array<[string, { success: string; failure: string }]> | undefined; + expect(calls).to.have.length(1); + const [, urls] = calls![0]; + expect(urls.success).to.include(encodeURIComponent('https://app.acme.test/dashboard')); + }); + }); + + it('no session redirects to /login', () => { + callService({ + fn: 'reauthAction', + seed: { users: [USER] }, + request: { + url: 'http://localhost/id/reauth', + form: { intent: 'idp-reauth', idpId: 'idp-google', returnTo: '/passkeys' }, + csrf: true, + }, + }).then((v) => { + expect(v.response?.status).to.equal(302); + expect(v.response?.location).to.equal('/login'); + }); + }); +}); diff --git a/cypress/component/routes/reauth.cy.tsx b/cypress/component/routes/reauth.cy.tsx new file mode 100644 index 0000000000..f5bd4b8915 --- /dev/null +++ b/cypress/component/routes/reauth.cy.tsx @@ -0,0 +1,196 @@ +// cypress/component/routes/reauth.cy.tsx +// +// UI contract for /reauth's Passkey entry: firing usePasskeyReauthCeremony IN PLACE +// (a Button that lazily loads this route's own ?method=passkey challenge and submits +// the pre-baked Cypress credential) instead of navigating to the two-step +// chooser → "Verify with passkey" flow. Mirrors login/method.cy.tsx. +import Reauth from '@/routes/reauth'; +import { ConformAdapter } from '@datum-cloud/datum-ui/form/adapters/conform'; +import { setupI18n } from '@lingui/core'; +import { I18nProvider } from '@lingui/react'; +import { mount } from 'cypress/react'; +import { createMemoryRouter, RouterProvider } from 'react-router'; + +const CHOOSER_VIEW = { + kind: 'view' as const, + loginName: 'mia@acme.test', + methods: ['passkey', 'password'] as const, + method: null, + publicKeyCredentialRequestOptions: null, + returnTo: '/passkeys', + linkedIdps: [], +}; + +const capturedPosts: Array> = []; +// Set by mountReauth({ deferChallenge: true }) — lets a test freeze mid-ceremony +// (phase === 'loading-challenge') to assert the busy/disabled UI deterministically, +// then release it to let the ceremony proceed. Avoids an arbitrary-timeout wait. +let releaseChallenge: (() => void) | null = null; + +function withI18n(node: React.ReactNode) { + const i18n = setupI18n({ locale: 'en', messages: { en: {} } }); + return ( + + {node} + + ); +} + +function mountReauth(opts?: { deferChallenge?: boolean }) { + const router = createMemoryRouter( + [ + { + id: 'reauth', + path: '/reauth', + element: , + // Same route serves both the chooser (?method absent) AND the lazily-loaded + // passkey challenge (?method=passkey) — the ceremony hook fetcher.load()s the + // latter without navigating away from the former. + loader: async ({ request }: { request: Request }) => { + const method = new URL(request.url).searchParams.get('method'); + if (method === 'passkey') { + if (opts?.deferChallenge) { + await new Promise((resolve) => { + releaseChallenge = resolve; + }); + } + return { + csrfToken: 'tok-1', + view: { + ...CHOOSER_VIEW, + method: 'passkey' as const, + publicKeyCredentialRequestOptions: { publicKey: { challenge: 'x' } }, + }, + }; + } + return { csrfToken: 'tok-1', view: CHOOSER_VIEW }; + }, + action: async ({ request }: { request: Request }) => { + capturedPosts.push(Object.fromEntries(await request.formData())); + // Truthy (not null) — mirrors a real completed action so the ceremony's + // busy-until-idle effect doesn't stay stuck (see login/index.cy.tsx for the + // failure this caused once sibling controls started disabling on ceremony.phase). + return {}; + }, + }, + ], + { + initialEntries: ['/reauth?returnTo=/passkeys'], + hydrationData: { + loaderData: { reauth: { csrfToken: 'tok-1', view: CHOOSER_VIEW } }, + }, + } + ); + return mount(withI18n()); +} + +describe('/reauth — in-place passkey ceremony', () => { + beforeEach(() => { + capturedPosts.length = 0; + releaseChallenge = null; + }); + + it('shows the active identity with a "Not you?" link to /accounts', () => { + mountReauth(); + cy.contains('Logged in as').should('be.visible'); + cy.contains(CHOOSER_VIEW.loginName).should('be.visible'); + cy.contains('a', 'Not you?').should('have.attr', 'href', '/accounts'); + }); + + it('lists Passkey and Password as chooser entries', () => { + mountReauth(); + cy.contains("Confirm it's you").should('be.visible'); + cy.contains('button', 'Passkey').should('be.visible'); + cy.contains('a', 'Password').should('be.visible'); + }); + + it('Passkey fires the ceremony in place and submits the pre-baked credential', () => { + mountReauth(); + cy.contains('button', 'Passkey').click(); + // Lazy challenge (fetcher.load ?method=passkey) → pre-baked credential → POST. + cy.wrap(null).should(() => { + expect(capturedPosts).to.have.length(1); + expect(capturedPosts[0].factor).to.equal('passkey'); + expect(capturedPosts[0].returnTo).to.equal('/passkeys'); + expect(capturedPosts[0].passkeyCeremony).to.equal('1'); + expect(JSON.parse(String(capturedPosts[0].credential)).id).to.equal('fake-credential-id'); + }); + // No navigation happened — the chooser is still on screen as the fallback. + // Password stays a plain link (unchanged), so it's matched by its tag. + cy.contains('a', 'Password').should('be.visible'); + }); + + it('shows a busy/loading Passkey button and disables the Password sibling until the challenge resolves', () => { + mountReauth({ deferChallenge: true }); + cy.contains('button', 'Passkey').click(); + + // Mid-flight (phase === 'loading-challenge', frozen by the deferred loader): the + // Passkey button is disabled (loading=true implies disabled — datum-ui Button) and + // the Password row is rendered inert (aria-disabled + pointer-events-none), not just + // visually dimmed — a real click must not navigate it away mid-ceremony. + cy.contains('button', 'Passkey').should('be.disabled'); + cy.contains('a', 'Password').should('have.attr', 'aria-disabled', 'true'); + cy.contains('a', 'Password').click({ force: true }); + cy.wrap(null).should(() => expect(capturedPosts).to.have.length(0)); + + // Release the deferred challenge — the ceremony completes normally. + cy.then(() => releaseChallenge?.()); + cy.wrap(null).should(() => expect(capturedPosts).to.have.length(1)); + + // Busy state clears once the ceremony finishes; Password is interactive again. + cy.contains('button', 'Passkey').should('not.be.disabled'); + cy.contains('a', 'Password').should('have.attr', 'aria-disabled', 'false'); + }); +}); + +describe('/reauth — idp chooser entry', () => { + it('renders a button per linked IdP and posts intent=idp-reauth to start the round-trip', () => { + const capturedIdpPosts: Array> = []; + const router = createMemoryRouter( + [ + { + id: 'reauth', + path: '/reauth', + element: , + loader: async () => ({ + csrfToken: 'tok-1', + view: { + ...CHOOSER_VIEW, + methods: ['idp', 'password'] as const, + linkedIdps: [{ idpId: 'idp-google', name: 'Google', type: 'GOOGLE' }], + }, + }), + action: async ({ request }: { request: Request }) => { + const body = Object.fromEntries(await request.formData()); + capturedIdpPosts.push(body); + return null; + }, + }, + ], + { + initialEntries: ['/reauth?returnTo=/passkeys'], + hydrationData: { + loaderData: { + reauth: { + csrfToken: 'tok-1', + view: { + ...CHOOSER_VIEW, + methods: ['idp', 'password'], + linkedIdps: [{ idpId: 'idp-google', name: 'Google', type: 'GOOGLE' }], + }, + }, + }, + }, + } + ); + mount(withI18n()); + + cy.contains('button', 'Google').click(); + cy.wrap(null).should(() => { + expect(capturedIdpPosts).to.have.length(1); + expect(capturedIdpPosts[0].intent).to.equal('idp-reauth'); + expect(capturedIdpPosts[0].idpId).to.equal('idp-google'); + expect(capturedIdpPosts[0].returnTo).to.equal('/passkeys'); + }); + }); +}); diff --git a/cypress/component/routes/reauth/provider-callback.cy.tsx b/cypress/component/routes/reauth/provider-callback.cy.tsx new file mode 100644 index 0000000000..b589fe8985 --- /dev/null +++ b/cypress/component/routes/reauth/provider-callback.cy.tsx @@ -0,0 +1,80 @@ +// cypress/component/routes/reauth/provider-callback.cy.tsx +// +// /reauth/:provider/callback loader: reads the CURRENT session, verifies the idpIntent +// onto it via performReauth, and redirects — success to returnTo, failure to +// /reauth/:provider/error, no-session to /login. Headless (element renders null), +// mirrors sso/provider/callback.tsx's own test-via-loader-harness convention. +import { callService } from '../../../support/node/call-service'; + +const URL = + 'http://localhost/id/reauth/idp-google/callback?id=intent-1&token=idp-tok-1&returnTo=%2Fpasskeys'; + +describe('/reauth/:provider/callback loader', () => { + it('a matching idpIntent verifies onto the existing session and redirects to returnTo', () => { + callService({ + fn: 'reauthProviderCallback', + seed: { + users: [{ id: 'u1', loginName: 'mia@acme.test' }], + idpIntents: { + 'intent-1': { idpIntentId: 'intent-1', idpIntentToken: 'idp-tok-1', userId: 'u1' }, + }, + }, + slug: 'idp-google', + liveSessions: [ + { id: 'sess-1', token: 'sess-tok-1', user: { id: 'u1', loginName: 'mia@acme.test' } }, + ], + request: { + url: URL, + sessions: [{ id: 'sess-1', token: 'sess-tok-1', loginName: 'mia@acme.test' }], + }, + }).then((v) => { + expect(v.response?.status).to.equal(302); + expect(v.response?.location).to.equal('/passkeys'); + expect(v.response?.setCookie ?? '').to.include('sessions='); + }); + }); + + it('a mismatched idpIntent redirects to /reauth/:provider/error with returnTo preserved', () => { + callService({ + fn: 'reauthProviderCallback', + seed: { + users: [ + { id: 'u1', loginName: 'mia@acme.test' }, + { id: 'u2', loginName: 'bob@acme.test' }, + ], + idpIntents: { + 'intent-1': { idpIntentId: 'intent-1', idpIntentToken: 'idp-tok-1', userId: 'u2' }, + }, + }, + slug: 'idp-google', + liveSessions: [ + { id: 'sess-1', token: 'sess-tok-1', user: { id: 'u1', loginName: 'mia@acme.test' } }, + ], + request: { + url: URL, + sessions: [{ id: 'sess-1', token: 'sess-tok-1', loginName: 'mia@acme.test' }], + }, + }).then((v) => { + expect(v.response?.status).to.equal(302); + expect(v.response?.location).to.include('/reauth/idp-google/error'); + expect(v.response?.location).to.include('returnTo=%2Fpasskeys'); + // Real Zitadel rejects this exact case with FAILED_PRECONDITION ("Intent meant + // for another user"), not INVALID_CREDENTIALS — performReauth must catch it + // (not let it escape as an unhandled 500) and the redirect must carry a reason + // the error page can render distinct copy for. + expect(v.response?.location).to.include('reason=access-denied'); + }); + }); + + it('no session redirects to /login', () => { + callService({ + fn: 'reauthProviderCallback', + seed: { users: [{ id: 'u1', loginName: 'mia@acme.test' }] }, + slug: 'idp-google', + request: { url: URL }, + }).then((v) => { + expect(v.response?.status).to.equal(302); + expect(v.response?.location).to.equal('/login'); + }); + }); +}); diff --git a/cypress/component/routes/reauth/provider-error.cy.tsx b/cypress/component/routes/reauth/provider-error.cy.tsx new file mode 100644 index 0000000000..c19194dc14 --- /dev/null +++ b/cypress/component/routes/reauth/provider-error.cy.tsx @@ -0,0 +1,36 @@ +// cypress/component/routes/reauth/provider-error.cy.tsx +// +// /reauth/:provider/error — thin error screen, mirrors sso/provider/error.tsx exactly +// (a generic message naming the provider, a link back into the live ceremony). +import ReauthProviderError from '@/routes/reauth/provider/error'; +import { setupI18n } from '@lingui/core'; +import { I18nProvider } from '@lingui/react'; +import { mount } from 'cypress/react'; +import { createMemoryRouter, RouterProvider } from 'react-router'; + +function mountError(initialEntry: string) { + const router = createMemoryRouter( + [{ id: 'reauth-idp-error', path: '/reauth/:provider/error', element: }], + { initialEntries: [initialEntry] } + ); + const i18n = setupI18n({ locale: 'en', messages: { en: {} } }); + return mount( + + + + ); +} + +describe('/reauth/:provider/error', () => { + it('names the provider and links back to /reauth preserving returnTo', () => { + mountError('/reauth/idp-google/error?returnTo=%2Fpasskeys'); + cy.contains('idp-google').should('be.visible'); + cy.contains('a', 'Try again').should('have.attr', 'href', '/reauth?returnTo=%2Fpasskeys'); + }); + + it('shows the access-denied copy instead of the generic provider-name fallback', () => { + mountError('/reauth/idp-google/error?returnTo=%2Fpasskeys&reason=access-denied'); + cy.contains('That identity belongs to a different account.').should('be.visible'); + cy.contains('Something went wrong').should('not.exist'); + }); +}); diff --git a/cypress/component/routes/setup/setup-passkey-naming.cy.tsx b/cypress/component/routes/setup/setup-passkey-naming.cy.tsx new file mode 100644 index 0000000000..09b1d93245 --- /dev/null +++ b/cypress/component/routes/setup/setup-passkey-naming.cy.tsx @@ -0,0 +1,190 @@ +// cypress/component/routes/setup/setup-passkey-naming.cy.tsx +// +// Name-after-ceremony: /setup/passkey runs the create() ceremony FIRST (only +// ordering where the AAGUID — available only in the returned attestation — can +// pre-fill the name), then shows a Save-only name step, then submits the held +// credential + passkeyName to the enroll action. Uses a createMemoryRouter harness +// whose action RECORDS the posted FormData; the route has no loader here, so +// post-action revalidation is a no-op and hydrated loaderData persists. +import { attestationObject, authDataWith } from '../../../support/attestation-fixture'; +import type { WebAuthnEnrollActionData } from '@/resources/webauthn'; +import { defaultPasskeyName } from '@/resources/webauthn/aaguid'; +import { base64UrlToBuffer } from '@/resources/webauthn/webauthn'; +import SetupPasskey from '@/routes/setup/passkey'; +import { ConformAdapter } from '@datum-cloud/datum-ui/form/adapters/conform'; +import { setupI18n } from '@lingui/core'; +import { I18nProvider } from '@lingui/react'; +import { mount } from 'cypress/react'; +import { createMemoryRouter, RouterProvider } from 'react-router'; + +interface Recorded { + fields?: Record; +} + +// createAttestation() reads challenge/user.id/excludeCredentials — a minimal valid shape. +const PK_CREATE = { challenge: 'YQ', user: { id: 'YQ' }, excludeCredentials: [] }; + +const LOADER_DATA = { + csrfToken: 'tok-1', + loginName: 'a@b.test', + requestId: 'rq1', + organization: 'acme', + force: undefined, + checkAfter: undefined, + credentialId: 'pk1', + publicKey: PK_CREATE, + challengeFailed: false, + returnTo: null, +}; + +function mountRoute( + recorded: Recorded, + actionError?: WebAuthnEnrollActionData['error'], + loaderOverrides?: Partial +) { + const i18n = setupI18n({ locale: 'en', messages: { en: {} } }); + const loaderData = { ...LOADER_DATA, ...loaderOverrides }; + const router = createMemoryRouter( + [ + { + id: 'setup-passkey', + path: '/setup/passkey', + element: ( + + + + + + ), + action: async ({ request }) => { + const form = await request.formData(); + recorded.fields = Object.fromEntries([...form.entries()].map(([k, v]) => [k, String(v)])); + return actionError ? { error: actionError } : null; + }, + // Deviation from the brief: React Router's mergeLoaderData only carries hydrated + // loaderData forward across a post-action revalidation when `match.route.loader` is + // truthy (see mergeLoaderData in react-router's router.ts) — a loader-less route's + // entry is dropped, not preserved, crashing useLoaderData's destructure on the + // subsequent render. The brief's spec comment ("no-op and hydrated loaderData + // persists") does not hold for react-router@7.18. A trivial loader mirrors the real + // route (which always has one) and keeps loaderData stable across the action. + loader: async () => loaderData, + }, + ], + { + initialEntries: ['/setup/passkey'], + hydrationData: { loaderData: { 'setup-passkey': loaderData } }, + } + ); + return mount(); +} + +describe('setup/passkey — name-after-ceremony', () => { + // Deviation from the brief: Cypress component testing does not reload the AUT window + // between tests in the same spec (unlike e2e's testIsolation page reset), so the + // real-ceremony opt-in flag set by the AAGUID test below would otherwise leak into + // later tests and make them attempt the real navigator.credentials.create path. + afterEach(() => { + cy.window().then((win) => { + delete (win as unknown as { __webAuthnRealCeremony?: boolean }).__webAuthnRealCeremony; + }); + }); + + it('runs the ceremony first, then shows the Save-only name step pre-filled from the device', () => { + const recorded: Recorded = {}; + mountRoute(recorded); + + // Step 1: no name input — the AAGUID needed for the pre-fill exists only after the ceremony. + cy.contains('button', 'Register passkey').should('not.be.disabled'); + cy.get('input[name="passkeyName"]').should('not.exist'); + + cy.contains('button', 'Register passkey').click(); + + // Step 2: pre-filled (pre-baked credential has no attestationObject → UA fallback), + // scoping helptext + no-rename note, Save-only (Register gone, no cancel). + cy.contains(/Name your passkey/i).should('exist'); + cy.window().then((win) => { + const expected = defaultPasskeyName(null, win.navigator.userAgent); + cy.get('input[name="passkeyName"]').should('have.value', expected); + }); + cy.contains('your password manager labels it separately').should('be.visible'); + cy.contains("Names can't be changed later").should('be.visible'); + cy.contains('button', 'Register passkey').should('not.exist'); + cy.contains('button', /cancel/i).should('not.exist'); + }); + + it('submits the held credential with the edited name (edited-name-reaches-verify)', () => { + const recorded: Recorded = {}; + mountRoute(recorded, undefined, { returnTo: '/passkeys' }); + cy.contains('button', 'Register passkey').should('not.be.disabled').click(); + cy.get('input[name="passkeyName"]').clear(); + cy.get('input[name="passkeyName"]').type('My yubikey'); + cy.contains('button', 'Save').click(); + cy.wrap(recorded).should((r) => { + expect(r.fields, 'action received the form').to.not.equal(undefined); + expect(r.fields!.passkeyName).to.equal('My yubikey'); + expect(JSON.parse(r.fields!.credential)).to.have.property('id', 'fake-credential-id'); + expect(r.fields!.passkeyId).to.equal('pk1'); + expect(r.fields!.returnTo).to.equal('/passkeys'); + }); + }); + + it('pre-fills the catalog name from the attestation AAGUID and shows the device hint', () => { + const recorded: Recorded = {}; + mountRoute(recorded); + + // Opt INTO the real ceremony (documented __webAuthnRealCeremony seam) and resolve + // create() with a crafted Apple Passwords attestation (AAGUID fbfc3007-…). + cy.window().then((win) => { + (win as unknown as { __webAuthnRealCeremony?: boolean }).__webAuthnRealCeremony = true; + const w = win as unknown as { PublicKeyCredential?: unknown }; + if (typeof w.PublicKeyCredential === 'undefined') { + w.PublicKeyCredential = function () {} as unknown; + } + if (!win.navigator.credentials) { + Object.defineProperty(win.navigator, 'credentials', { + value: { create: () => Promise.resolve(null), get: () => Promise.resolve(null) }, + configurable: true, + }); + } + const attB64 = attestationObject(authDataWith(0x45, 'fbfc3007154e4ecc8c0b6e020557d7bd')); + cy.stub(win.navigator.credentials, 'create').resolves({ + id: 'real-cred', + rawId: base64UrlToBuffer('cmVhbC1jcmVk'), + type: 'public-key', + response: { + attestationObject: base64UrlToBuffer(attB64), + clientDataJSON: base64UrlToBuffer('e30'), + }, + }); + }); + + cy.contains('button', 'Register passkey').should('not.be.disabled').click(); + + // Catalog name wins the pre-fill; the UA-derived hint differs → rendered. + cy.get('input[name="passkeyName"]').should('have.value', 'Apple Passwords'); + cy.contains(/Created using/).should('be.visible'); + }); + + it('auto-resets to step 1 when the verify action fails (challenge expiry)', () => { + const recorded: Recorded = {}; + mountRoute(recorded, 'INVALID_CREDENTIALS'); + + cy.contains('button', 'Register passkey').should('not.be.disabled').click(); + cy.get('input[name="passkeyName"]').should('exist'); + cy.contains('button', 'Save').click(); + + // Back on step 1 with the inline error — one click retries the full ceremony. + cy.get('[role="alert"]').should('exist'); + cy.get('input[name="passkeyName"]').should('not.exist'); + cy.contains('button', 'Register passkey').should('not.be.disabled'); + + // The controlled hidden input must drop the stale credential with the reset. + cy.get('input[name="credential"]').should('have.value', ''); + + // Re-entering the name step must not carry the stale failure banner. + cy.contains('button', 'Register passkey').click(); + cy.contains(/Name your passkey/i).should('exist'); + cy.get('[role="alert"]').should('not.exist'); + }); +}); diff --git a/cypress/component/routes/setup/setup-render.cy.tsx b/cypress/component/routes/setup/setup-render.cy.tsx index 54cf4574a9..f820d8fb87 100644 --- a/cypress/component/routes/setup/setup-render.cy.tsx +++ b/cypress/component/routes/setup/setup-render.cy.tsx @@ -116,4 +116,41 @@ describe('setup/* — BackLink renders to the predecessor', () => { mountRoute(SetupMfa, 'mfa', { ...IDENTITY, offerableKeys: ['passkey'] }); cy.get('a[href*="/login/password"]').should('not.exist'); }); + + it('clicking Back on /setup/passkey does not disable "Register passkey" while the predecessor loads', () => { + // Regression: WebAuthnButton's busy state defaulted to navigation.state !== 'idle', + // which is also true for an UNRELATED Link navigation elsewhere on the page (the + // BackLink). Registers a real /setup/mfa route whose loader never resolves, so the + // Back navigation stays pending — proving the CURRENT page's WebAuthnButton stays + // interactive throughout, since it has nothing to do with that click. + const router = createMemoryRouter( + [ + { id: 'setup-passkey', path: '/setup/passkey', element: }, + { + id: 'setup-mfa', + path: '/setup/mfa', + element: , + loader: () => new Promise(() => {}), + }, + ], + { + initialEntries: ['/setup/passkey'], + hydrationData: { + loaderData: { + 'setup-passkey': { + ...IDENTITY, + credentialId: 'pk1', + publicKey: null, + challengeFailed: false, + }, + }, + }, + } + ); + mount(withProviders()); + + cy.contains('button', 'Register passkey').should('not.be.disabled'); + cy.contains('a', 'Back').click(); + cy.contains('button', 'Register passkey').should('not.be.disabled'); + }); }); diff --git a/cypress/component/routes/signed-in.cy.tsx b/cypress/component/routes/signed-in.cy.tsx new file mode 100644 index 0000000000..cd487c0b52 --- /dev/null +++ b/cypress/component/routes/signed-in.cy.tsx @@ -0,0 +1,53 @@ +// cypress/component/routes/signed-in.cy.tsx +// +// /signed-in previously showed loginName as bare text with no switch-account +// affordance at all. Adds "Not you?" (mirrors device/authorize.tsx). +import SignedIn from '@/routes/signed-in'; +import { ConformAdapter } from '@datum-cloud/datum-ui/form/adapters/conform'; +import { setupI18n } from '@lingui/core'; +import { I18nProvider } from '@lingui/react'; +import { mount } from 'cypress/react'; +import { createMemoryRouter, RouterProvider } from 'react-router'; + +function withI18n(node: React.ReactNode) { + const i18n = setupI18n({ locale: 'en', messages: { en: {} } }); + return ( + + {node} + + ); +} + +function mountSignedIn(loginName: string | null = 'mia@acme.test') { + const router = createMemoryRouter( + [ + { + id: 'signed-in', + path: '/signed-in', + element: , + loader: async () => ({ loginName, userId: 'u1', csrfToken: 'tok-1' }), + }, + ], + { initialEntries: ['/signed-in'] } + ); + return mount(withI18n()); +} + +describe('/signed-in — identity + switch-account link + sign-out', () => { + it('shows "You are signed in as " with a "Not you?" link to /accounts', () => { + mountSignedIn(); + cy.contains('You are signed in as').should('be.visible'); + cy.contains('mia@acme.test').should('be.visible'); + cy.findByRole('link', { name: /not you\?/i }).should('have.attr', 'href', '/accounts'); + }); + + it('the Sign out form posts to /id/logout?index', () => { + mountSignedIn(); + cy.get('form[action="/id/logout?index"]').contains('button', 'Sign out').should('be.visible'); + }); + + it('renders no identity line when loginName is absent', () => { + mountSignedIn(null); + cy.contains('You are signed in as').should('not.exist'); + }); +}); diff --git a/cypress/component/routes/signup/method-back-link.cy.tsx b/cypress/component/routes/signup/method-back-link.cy.tsx new file mode 100644 index 0000000000..491931d569 --- /dev/null +++ b/cypress/component/routes/signup/method-back-link.cy.tsx @@ -0,0 +1,70 @@ +// cypress/component/routes/signup/method-back-link.cy.tsx +// +// signup/method.tsx used to double-mount BackLink (an explicit plus +// AuthCeremony's own default-true auto-render) — both previously resolved to null +// (no /signup/method entry in previous-step.ts), so it was a latent duplicate-render +// bug. Now that the map has an entry (Task 2), exactly one Back control must render. +import SignupMethod from '@/routes/signup/method'; +import { ConformAdapter } from '@datum-cloud/datum-ui/form/adapters/conform'; +import { setupI18n } from '@lingui/core'; +import { I18nProvider } from '@lingui/react'; +import { mount } from 'cypress/react'; +import { createMemoryRouter, RouterProvider } from 'react-router'; + +function withI18n(node: React.ReactNode) { + const i18n = setupI18n({ locale: 'en', messages: { en: {} } }); + return ( + + {node} + + ); +} + +const LOADER_DATA = { + csrfToken: 'tok-1', + loginName: 'mia@acme.test', + firstName: 'Mia', + lastName: 'Doe', + organization: undefined, + requestId: undefined, + deviceTrackingToken: undefined, + maxmindAccountId: '', + view: { showEmailLink: false, showPasskey: false, showPassword: true }, +}; + +function mountSignupMethod() { + const router = createMemoryRouter( + [ + { + id: 'signup-method', + path: '/signup/method', + element: , + loader: async () => LOADER_DATA, + }, + ], + { initialEntries: ['/signup/method'] } + ); + return mount(withI18n()); +} + +describe('/signup/method — exactly one Back control, targeting /signup', () => { + it('renders a single Back link (no duplicate)', () => { + mountSignupMethod(); + cy.get('a') + .filter(':contains("Back")') + .should('have.length', 1) + .and('have.attr', 'href') + .and('match', /^\/signup(\?|$)/); + }); +}); + +describe('/signup/method — identity + Not you? (new, mirrors signup/password)', () => { + it('shows "Signing up as . Not you?" linking back to /signup', () => { + mountSignupMethod(); + cy.contains('Signing up as').should('be.visible'); + cy.contains(LOADER_DATA.loginName).should('be.visible'); + cy.findByRole('link', { name: /not you/i }) + .should('have.attr', 'href') + .and('match', /^\/signup(\?|$)/); + }); +}); diff --git a/cypress/component/routes/sso/ldap-back-link.cy.tsx b/cypress/component/routes/sso/ldap-back-link.cy.tsx new file mode 100644 index 0000000000..889b71ee94 --- /dev/null +++ b/cypress/component/routes/sso/ldap-back-link.cy.tsx @@ -0,0 +1,47 @@ +// cypress/component/routes/sso/ldap-back-link.cy.tsx +// +// /sso/ldap is an IdP-initiated entry point with no natural single previous step. +// Pins that Back is explicitly suppressed rather than silently rendering null. +import SsoLdap from '@/routes/sso/ldap'; +import { ConformAdapter } from '@datum-cloud/datum-ui/form/adapters/conform'; +import { setupI18n } from '@lingui/core'; +import { I18nProvider } from '@lingui/react'; +import { mount } from 'cypress/react'; +import { createMemoryRouter, RouterProvider } from 'react-router'; + +function withI18n(node: React.ReactNode) { + const i18n = setupI18n({ locale: 'en', messages: { en: {} } }); + return ( + + {node} + + ); +} + +function mountSsoLdap() { + const router = createMemoryRouter( + [ + { + id: 'sso-ldap', + path: '/sso/ldap', + element: , + loader: async () => ({ + csrfToken: 'tok-1', + idpId: 'idp-1', + requestId: undefined, + organization: undefined, + }), + }, + ], + { initialEntries: ['/sso/ldap'] } + ); + return mount(withI18n()); +} + +describe('/sso/ldap — Back link explicitly suppressed', () => { + it('renders the LDAP form with no Back control', () => { + mountSsoLdap(); + cy.contains('Sign in with LDAP').should('be.visible'); + cy.contains('a', 'Back').should('not.exist'); + }); +}); diff --git a/cypress/component/routes/sso/sso-render.cy.tsx b/cypress/component/routes/sso/sso-render.cy.tsx index 439ce6ee99..2659f8c79b 100644 --- a/cypress/component/routes/sso/sso-render.cy.tsx +++ b/cypress/component/routes/sso/sso-render.cy.tsx @@ -116,6 +116,14 @@ describe('SsoIndex — unlink guard: dialog confirm + disabled sole sign-in meth // …and exposes an enabled submit button to complete the unlink (the "Confirm submits" path). cy.get('button[type="submit"]').contains('Unlink').should('exist').and('not.be.disabled'); }); + + it('shows the active login name with a "Not you?" switch link and a Sign out control', () => { + mountRoute(SsoIndex, 'sso-index', '/sso', '/sso', loaderData); + cy.contains('Logged in as').should('exist'); + cy.contains(loaderData.loginName).should('exist'); + cy.findByRole('link', { name: /not you\?/i }).should('have.attr', 'href', '/accounts'); + cy.get('form[action="/id/logout?index"]').contains('button', 'Sign out').should('exist'); + }); }); // ── sso/provider/error ──────────────────────────────────────────────────────── diff --git a/cypress/e2e/a11y-sweep.cy.ts b/cypress/e2e/a11y-sweep.cy.ts index 07861c3294..2c72e8ecf5 100644 --- a/cypress/e2e/a11y-sweep.cy.ts +++ b/cypress/e2e/a11y-sweep.cy.ts @@ -135,7 +135,7 @@ describe('a11y sweep — /login/passkey (session required)', () => { cy.location('pathname').should('include', '/login/passkey'); // Positive assertion: WebAuthnButton renders its "Verify with passkey" text // (disabled until React hydrates, but SSR-rendered DOM is present). - cy.contains('button', /verify with passkey/i).should('exist'); + cy.contains('button', /sign in with .*passkey|touch id|windows hello/i).should('exist'); checkA11y(); }); }); diff --git a/cypress/e2e/passkey-use.cy.ts b/cypress/e2e/passkey-use.cy.ts index 9b8b652d56..ad975de15b 100644 --- a/cypress/e2e/passkey-use.cy.ts +++ b/cypress/e2e/passkey-use.cy.ts @@ -18,7 +18,7 @@ describe('Passkey verify (/login/passkey)', () => { // The WebAuthnButton detects window.Cypress and uses the pre-baked credential. // Hydration gate: the button is disabled until React mounts; Cypress waits for it. - cy.contains('button', /verify with passkey/i) + cy.contains('button', /sign in with .*passkey|touch id|windows hello/i) .should('not.be.disabled') .click(); diff --git a/cypress/e2e/passkeys-manage.cy.ts b/cypress/e2e/passkeys-manage.cy.ts new file mode 100644 index 0000000000..d2e7b185d3 --- /dev/null +++ b/cypress/e2e/passkeys-manage.cy.ts @@ -0,0 +1,175 @@ +import { checkA11y } from '../support/a11y'; +import { extractCsrf, loginAndGetSession } from '../support/session'; + +// /id/passkeys — management journey (fake provider, real factor timestamps ⇒ sudo fresh). +// Order matters: the fake singleton persists across tests in one run — (b) removes mia's +// seeded row, (d) adds a fresh one. + +// mia has TWO primary methods (password + passkey), so the identifier step routes to the +// /login/method chooser — loginAndGetSession leaves the session at the bare user check +// (never sudo-fresh). Complete the password factor via cy.request (deterministic; the +// password UI journey is core-signin.cy.ts's subject) so verifiedAt is stamped. +function signInMiaWithPassword() { + loginAndGetSession('mia@acme.test'); + cy.request('/id/login/password?loginName=mia%40acme.test').then((resp) => { + const csrf = extractCsrf(resp.body as string); + cy.request({ + method: 'POST', + url: '/id/login/password.data?loginName=mia%40acme.test', + form: true, + body: { csrf, loginName: 'mia@acme.test', password: 'hunter2' }, + followRedirect: false, + }); + }); +} + +describe('/id/passkeys — list / remove / last-method guard / sign-out offer / add', () => { + // Warm Vite's dep optimization once (mirrors core-signin.cy.ts): the first cold route + // load triggers a hard reload that eats the first click/submit of a test. Also warm the + // WebAuthn ceremony chunk with the existing u5 fixture (pre-existing cold-start flake — + // passkey-use.cy.ts fails standalone on main the same way) so ceremony clicks land. + before(() => { + cy.visit('/id/login'); + cy.contains('button', 'Email'); + loginAndGetSession('passkey-user@acme.test'); + cy.visit('/id/login/passkey?loginName=passkey-user%40acme.test', { + onBeforeLoad: (win) => { + win.__CYPRESS_HYDRATE__ = true; + }, + }); + cy.settleHydration(); + cy.contains('button', /sign in with .*passkey|touch id|windows hello/i).should( + 'not.be.disabled' + ); + cy.clearCookies(); + }); + + it('(a) lists the seeded passkey without a state badge (sudo fresh after password login)', () => { + signInMiaWithPassword(); + cy.visit('/id/passkeys', { + onBeforeLoad: (win) => { + win.__CYPRESS_HYDRATE__ = true; // dialogs need JS (see entry.client.tsx) + }, + }); + cy.settleHydration(); + cy.location('pathname').should('eq', '/id/passkeys'); + cy.contains('Seeded laptop').should('be.visible'); + // Active rows carry no state badge (no disable feature exists). + cy.contains('Active').should('not.exist'); + // The seeded row predates created-at metadata — no Added line (no backfill). + cy.contains('Added ').should('not.exist'); + checkA11y(); + }); + + it('(b) remove flow: confirm dialog → row gone → sign-out-others offer → decline keeps the session', () => { + signInMiaWithPassword(); + cy.visit('/id/passkeys', { + onBeforeLoad: (win) => { + win.__CYPRESS_HYDRATE__ = true; + }, + }); + cy.settleHydration(); + + cy.get('button[aria-label="Remove Seeded laptop"]').should('not.be.disabled').click(); + cy.contains('Remove this passkey?').should('be.visible'); + cy.contains('button', 'Remove passkey').click(); + + // Row gone + the sign-out dialog opens (modal instead of inline alert). + cy.contains('Seeded laptop').should('not.exist'); + cy.contains('Passkey removed').should('be.visible'); + cy.contains('button', 'Sign out other sessions').should('be.visible'); + + // Decline path: dialog closes in place; cookie/session untouched (no login bounce). + cy.contains('button', 'Not now').click(); + cy.contains('Passkey removed').should('not.exist'); + cy.location('pathname').should('eq', '/id/passkeys'); + cy.contains('Passkeys').should('be.visible'); + }); + + it('(c) passkey-only user: remove is refused (last method)', () => { + // solo has no password — a REAL authentication factor must land on the session + // (bare user-check never sudo-qualifies). Complete the passkey assertion via + // cy.request (the loginAndGetSession pattern): the ceremony UI itself is covered by + // passkey-use.cy.ts; this test's subject is the last-method guard. + loginAndGetSession('solo@acme.test'); + cy.request('/id/login/passkey?loginName=solo%40acme.test').then((resp) => { + const csrf = extractCsrf(resp.body as string); + cy.request({ + method: 'POST', + url: '/id/login/passkey.data?loginName=solo%40acme.test', + form: true, + body: { + csrf, + loginName: 'solo@acme.test', + // The fake provider accepts any webAuthN assertion payload. + credential: JSON.stringify({ id: 'fake-credential-id', type: 'public-key' }), + }, + followRedirect: false, + }); + }); + + cy.visit('/id/passkeys', { + onBeforeLoad: (win) => { + win.__CYPRESS_HYDRATE__ = true; + }, + }); + cy.settleHydration(); + cy.location('pathname').should('eq', '/id/passkeys'); + + // Last-method guard: refusal surfaces the inline error, the row stays. + cy.get('button[aria-label="Remove Solo key"]').should('not.be.disabled').click(); + cy.contains('button', 'Remove passkey').click(); + cy.contains("You can't remove your only sign-in method").should('be.visible'); + cy.contains('Solo key').should('be.visible'); + }); + + it('(d) add entry point: /setup/passkey round-trip returns to /id/passkeys with the new row', () => { + signInMiaWithPassword(); + cy.visit('/id/passkeys', { + onBeforeLoad: (win) => { + win.__CYPRESS_HYDRATE__ = true; + }, + }); + cy.settleHydration(); + + cy.contains('a', 'Add passkey').click(); + cy.location('pathname').should('eq', '/id/setup/passkey'); + // The Add link's return target points back at /passkeys. + cy.location('search').should('contain', 'returnTo=%2Fpasskeys'); + + // Complete the ceremony via cy.request (deterministic; the ceremony-click UI is + // setup-passkey-mfa.cy.ts's subject). The action must prefer the posted returnTo + // over the derived next step — asserted via the turbo-stream redirect below. + const setupUrl = '/id/setup/passkey?loginName=mia%40acme.test&returnTo=%2Fpasskeys'; + cy.request(setupUrl).then((resp) => { + const html = resp.body as string; + const csrf = extractCsrf(html); + const passkeyId = /name="passkeyId" value="([^"]+)"/.exec(html)?.[1] ?? ''; + cy.request({ + method: 'POST', + url: '/id/setup/passkey.data?loginName=mia%40acme.test&returnTo=%2Fpasskeys', + form: true, + body: { + csrf, + loginName: 'mia@acme.test', + passkeyId, + returnTo: '/passkeys', + credential: JSON.stringify({ id: 'fake-credential-id', type: 'public-key' }), + }, + followRedirect: false, + }).then((post) => { + // returnTo wins over the derived next step. + expect(String(post.body ?? '')).to.contain('"redirect","/passkeys"'); + }); + }); + + // The round-trip lands back on /id/passkeys with the fresh row. + cy.visit('/id/passkeys'); + cy.location('pathname').should('eq', '/id/passkeys'); + // (b) removed the seeded row, so the fresh enrollment is the only row. + cy.get('ul li').should('have.length', 1); + // The fake mirror stamps createdAt at verify — the fresh row shows today. + const expectedDate = new Intl.DateTimeFormat('en', { dateStyle: 'medium' }).format(new Date()); + cy.contains(`Added ${expectedDate}`).should('be.visible'); + }); +}); diff --git a/cypress/e2e/reauth.cy.ts b/cypress/e2e/reauth.cy.ts new file mode 100644 index 0000000000..423f6af13d --- /dev/null +++ b/cypress/e2e/reauth.cy.ts @@ -0,0 +1,80 @@ +import { checkA11y } from '../support/a11y'; +import { loginAndGetSession } from '../support/session'; + +// /id/reauth — sudo interstitial journey (fake provider). +// Sudo staleness is NOT required here: this spec drives /reauth directly. +describe('/id/reauth — verify one enrolled factor onto the existing session', () => { + it('chooser lists enrolled methods only; password completion lands on returnTo', () => { + // alice has authMethods: ['password'] only → chooser shows Password, never Passkey. + loginAndGetSession('alice@acme.test'); + + cy.visit('/id/reauth?returnTo=/passkeys', { + onBeforeLoad: (win) => { + win.__CYPRESS_HYDRATE__ = true; // form submit needs JS (see entry.client.tsx) + }, + }); + cy.settleHydration(); + checkA11y(); // chooser renders + + cy.contains("Confirm it's you"); + cy.contains('a', 'Password').should('be.visible'); + cy.contains('a', 'Passkey').should('not.exist'); + + cy.contains('a', 'Password').click(); + cy.location('search').should('contain', 'method=password'); + checkA11y(); // password verify form renders + + cy.get('input[name="password"]').type('hunter2'); + cy.get('input[name="password"]:visible').closest('form').submit(); + + // Success → redirect to the validated returnTo (served under the /id basename). + cy.location('pathname').should('eq', '/id/passkeys'); + }); + + it('lists Passkey for a passkey-enrolled user', () => { + loginAndGetSession('passkey-user@acme.test'); + cy.visit('/id/reauth?returnTo=/passkeys'); + cy.contains('button', 'Passkey').should('be.visible'); + cy.contains('a', 'Password').should('be.visible'); + }); + + it('Passkey completes the ceremony in place with a single click (no ?method=passkey navigation)', () => { + loginAndGetSession('passkey-user@acme.test'); + cy.visit('/id/reauth?returnTo=/passkeys', { + onBeforeLoad: (win) => { + win.__CYPRESS_HYDRATE__ = true; // ceremony fetcher needs JS + }, + }); + cy.settleHydration(); + + cy.contains('button', 'Passkey').click(); + + // Success → redirect straight to the validated returnTo. A single click, never + // a navigation to ?method=passkey / the old "Verify with passkey" second step. + cy.location('pathname').should('eq', '/id/passkeys'); + }); + + it('falls back to /id/passkeys when returnTo is tampered (//evil.test)', () => { + loginAndGetSession('alice@acme.test'); + + cy.visit('/id/reauth?returnTo=%2F%2Fevil.test%2Fx&method=password', { + onBeforeLoad: (win) => { + win.__CYPRESS_HYDRATE__ = true; + }, + }); + cy.settleHydration(); + + cy.get('input[name="password"]').type('hunter2'); + cy.get('input[name="password"]:visible').closest('form').submit(); + + // The tampered returnTo is rejected server-side → default target, never evil.test. + cy.location('pathname').should('eq', '/id/passkeys'); + cy.location('host').should('eq', 'localhost:3000'); + }); + + it('bounces to /login without a session', () => { + cy.clearCookies(); + cy.visit('/id/reauth', { failOnStatusCode: false }); + cy.location('pathname').should('eq', '/id/login'); + }); +}); diff --git a/cypress/e2e/setup-passkey-mfa.cy.ts b/cypress/e2e/setup-passkey-mfa.cy.ts index c928f8c4b5..11018d60f3 100644 --- a/cypress/e2e/setup-passkey-mfa.cy.ts +++ b/cypress/e2e/setup-passkey-mfa.cy.ts @@ -1,58 +1,8 @@ import { checkA11y } from '../support/a11y'; - -// Seeded password for all fake-provider test users (see app/providers/select.server.ts passwords map). -const FAKE_PASSWORD = 'hunter2'; - -/** - * Extract the csrf hidden-input token from SSR HTML. React entity-escapes - * attribute values (& → & etc.), so decode before round-tripping the token — - * otherwise tokens containing escapable chars 403 intermittently. - */ -function extractCsrf(html: string): string { - const raw = /name="csrf" value="([^"]+)"/.exec(html)?.[1] ?? ''; - return raw - .replace(/&/g, '&') - .replace(/</g, '<') - .replace(/>/g, '>') - .replace(/"/g, '"') - .replace(/'|'/g, "'"); -} - -/** - * Establishes a session cookie for the given loginName via cy.request (no UI). - * Drives identifier → password steps so byLoginName finds a valid entry. - * cy.request shares the browser cookie jar; set-cookie headers from the actions - * apply to the subsequent cy.visit. - */ -function loginAndGetSession(loginName: string) { - cy.request('/id/login').then((resp) => { - const csrf = extractCsrf(resp.body as string); - cy.request({ - method: 'POST', - url: '/id/login', - form: true, - body: { csrf, loginName }, - followRedirect: false, - }).then((post) => { - const target = String(post.headers.location ?? ''); - if (target.includes('/login/password')) { - const pwPageUrl = target.startsWith('http') - ? target - : `/id${target.startsWith('/id') ? target.slice(3) : target}`; - cy.request(pwPageUrl).then((pwPage) => { - const pwCsrf = extractCsrf(pwPage.body as string) || csrf; - cy.request({ - method: 'POST', - url: target, - form: true, - body: { csrf: pwCsrf, loginName, password: FAKE_PASSWORD }, - followRedirect: false, - }); - }); - } - }); - }); -} +// Shared session-planting helper (posts the single-fetch `.data` endpoints). The old +// file-local copy document-POSTed /id/login, which now 405s (no action on the layout +// route) — every session it "planted" was silently empty. +import { extractCsrf, loginAndGetSession } from '../support/session'; // ─── Passkey enrollment (/setup/passkey) ───────────────────────────────────── @@ -77,9 +27,62 @@ describe('Passkey enrollment (/setup/passkey)', () => { .should('not.be.disabled') .click(); + // Name step: the ceremony ran first; the name arrives pre-filled (UA fallback under + // the pre-baked credential) with the scoping helptext, and Save submits credential+name. + cy.contains(/Name your passkey/i).should('be.visible'); + cy.get('input[name="passkeyName"]').invoke('val').should('not.be.empty'); + cy.contains('your password manager labels it separately').should('be.visible'); + cy.contains("Names can't be changed later").should('be.visible'); + checkA11y(); + cy.contains('button', /^save$/i).click(); + // checkAfter=true → redirect into the matching passkey verify screen after enrollment. cy.location('pathname').should('eq', '/id/login/passkey'); }); + + it('threads passkeyName through enrollment and shows it on /id/passkeys', () => { + // Dedicated namer@acme.test fixture — enrollments here never contaminate the + // ordering-sensitive nofactor/mfa-skip users. The name field renders on the setup + // screen; the SUBMIT is driven via cy.request (the ceremony-click UI path and the + // client-side name pre-fill are covered by the route component spec — + // setup-passkey-naming.cy.tsx — because WebAuthn ceremony clicks are + // unreliable under the local dev-mode hydration recovery). + loginAndGetSession('namer@acme.test'); + + // The name field is on the page with its set-once helper copy. + cy.visit('/id/setup/passkey?loginName=namer%40acme.test&returnTo=%2Fpasskeys'); + // Step 1 shows no name field — naming happens AFTER the ceremony. The + // two-step UI (pre-fill, helptext, Save) is covered by the hydrated test above + // and by cypress/component/routes/setup/setup-passkey-naming.cy.tsx. + cy.get('input[name="passkeyName"]').should('not.exist'); + + // Enrollment with a typed name → the exact name lands on the inventory row. + const setupUrl = '/id/setup/passkey?loginName=namer%40acme.test&returnTo=%2Fpasskeys'; + cy.request(setupUrl).then((resp) => { + const html = resp.body as string; + const csrf = extractCsrf(html); + const passkeyId = /name="passkeyId" value="([^"]+)"/.exec(html)?.[1] ?? ''; + cy.request({ + method: 'POST', + url: '/id/setup/passkey.data?loginName=namer%40acme.test&returnTo=%2Fpasskeys', + form: true, + body: { + csrf, + loginName: 'namer@acme.test', + passkeyId, + returnTo: '/passkeys', + passkeyName: 'My yubikey', + credential: JSON.stringify({ id: 'fake-credential-id', type: 'public-key' }), + }, + followRedirect: false, + }).then((post) => { + expect(String(post.body ?? '')).to.contain('"redirect","/passkeys"'); + }); + }); + + cy.visit('/id/passkeys'); + cy.contains('ul li', 'My yubikey').should('be.visible'); + }); }); // ─── Security-key enrollment (/setup/security-key) ─────────────────────────── diff --git a/cypress/e2e/verify-otp.cy.ts b/cypress/e2e/verify-otp.cy.ts index cf962e8df0..1980665245 100644 --- a/cypress/e2e/verify-otp.cy.ts +++ b/cypress/e2e/verify-otp.cy.ts @@ -10,7 +10,15 @@ import { checkA11y } from '../support/a11y'; * We then navigate directly to the verify screen; the session cookie is set. */ function loginAndGetSession(loginName: string) { - cy.visit('/id/login'); + cy.visit('/id/login', { + onBeforeLoad: (win) => { + win.__CYPRESS_HYDRATE__ = true; // hydrate so the IdP-first "Email" reveal works (see entry.client.tsx) + }, + }); + cy.settleHydration(); + // The email input is behind an "Email" reveal button (IdP-first UX); click it first + // (mirrors core-signin.cy.ts's identifier flow). + cy.contains('button', 'Email').click(); cy.get('input[name="loginName"]').type(loginName); cy.get('input[name="loginName"]:visible').closest('form').submit(); cy.location('pathname').should('eq', '/id/login/password'); @@ -82,3 +90,42 @@ describe('MFA verify — no session guard', () => { cy.location('pathname').should('eq', '/id/login'); }); }); + +describe('MFA verify — Back navigation (regression: sole-factor loop)', () => { + // Motivating bug: Back from /login/verify/{channel} used to target /login/mfa. + // For a user with exactly one enrolled+policy-allowed second factor, + // /login/mfa's loader short-circuits (resolveMfaPicker's sole-factor redirect + // in mfa.service.ts) straight back to that same verify screen before any + // picker UI renders — so clicking Back silently looped back to the page the + // user was already on. previous-step.ts now points Back at /login directly + // for /login/verify/* (see previousStepFor). These tests click the real + // rendered "Back" control (not just the pure previousStepFor function) so a + // reverted map entry would fail here even though the unit tests still pass. + + it('clicking Back on the email verify screen lands on /login, not looping back', () => { + loginAndGetSession('email-otp-user@acme.test'); + cy.visit('/id/login/verify/email?loginName=email-otp-user%40acme.test'); + + cy.contains('a, button', /^back$/i).click(); + + cy.location('pathname').should('eq', '/id/login'); + }); + + it('clicking Back on the SMS verify screen lands on /login, not looping back', () => { + loginAndGetSession('sms-otp-user@acme.test'); + cy.visit('/id/login/verify/sms?loginName=sms-otp-user%40acme.test'); + + cy.contains('a, button', /^back$/i).click(); + + cy.location('pathname').should('eq', '/id/login'); + }); + + it('clicking Back on the TOTP verify screen lands on /login, not looping back', () => { + loginAndGetSession('totp-user@acme.test'); + cy.visit('/id/login/verify/authenticator?loginName=totp-user%40acme.test'); + + cy.contains('a, button', /^back$/i).click(); + + cy.location('pathname').should('eq', '/id/login'); + }); +}); diff --git a/cypress/support/attestation-fixture.ts b/cypress/support/attestation-fixture.ts new file mode 100644 index 0000000000..b50f0a2aba --- /dev/null +++ b/cypress/support/attestation-fixture.ts @@ -0,0 +1,43 @@ +// cypress/support/attestation-fixture.ts +// +// Tiny CBOR writer producing a minimal-but-valid WebAuthn attestation object +// { fmt: 'none', attStmt: {}, authData } as base64url. Shared by the aaguid unit +// spec and the setup/passkey naming route spec (AAGUID pre-fill). + +function cborText(s: string): number[] { + const bytes = [...new TextEncoder().encode(s)]; + return [0x60 + bytes.length, ...bytes]; // major 3, len < 24 +} +function cborBytes(b: number[]): number[] { + if (b.length < 24) return [0x40 + b.length, ...b]; // major 2, len < 24 + if (b.length < 256) return [0x58, b.length, ...b]; // major 2, 1-byte length + return [0x59, b.length >> 8, b.length & 0xff, ...b]; // major 2, 2-byte length +} + +/** Map { fmt: 'none', attStmt: {}, authData: } — key order as browsers emit. */ +export function attestationObject(authData: number[]): string { + const map = [ + 0xa3, // map(3) + ...cborText('fmt'), + ...cborText('none'), + ...cborText('attStmt'), + 0xa0, // map(0) + ...cborText('authData'), + ...cborBytes(authData), + ]; + // base64url encode + const bin = String.fromCharCode(...map); + return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} + +/** authData: 32-byte rpIdHash + flags + 4-byte signCount [+ 16-byte AAGUID + credIdLen]. */ +export function authDataWith(flags: number, aaguidHex?: string): number[] { + const rpIdHash = Array.from({ length: 32 }, () => 0x11); + const signCount = [0, 0, 0, 1]; + const out = [...rpIdHash, flags, ...signCount]; + if (aaguidHex !== undefined) { + const aaguid = aaguidHex.match(/.{2}/g)!.map((h) => parseInt(h, 16)); + out.push(...aaguid, 0x00, 0x00); // AAGUID + 2-byte credIdLen (0) + } + return out; +} diff --git a/cypress/support/audit-coverage.ts b/cypress/support/audit-coverage.ts index 935a1c21b4..f524dc6f0e 100644 --- a/cypress/support/audit-coverage.ts +++ b/cypress/support/audit-coverage.ts @@ -144,6 +144,12 @@ const SHARED_FACTORY_PATHS: Record = { // provider→service→redirect/data translators. Registered here so the delegation + registry // checks resolve those events at their new call site in resources/login/login.service.ts. 'login.service.ts': join(RESOURCES_DIR, 'login/login.service.ts'), + // The reauth route (reauth.tsx) delegates its action logic (incl. the reauth / + // reauth_challenge logAuthEvent calls) to the reauth domain service. + 'reauth.service.ts': join(RESOURCES_DIR, 'reauth/reauth.service.ts'), + // The passkeys route (passkeys.tsx) delegates its action logic (incl. the + // passkey_remove / logout logAuthEvent calls) to the passkeys domain service. + 'passkeys.service.ts': join(RESOURCES_DIR, 'passkeys/passkeys.service.ts'), }; // --------------------------------------------------------------------------- @@ -289,6 +295,10 @@ const DELEGATED_TO_SHARED: Record = { // login/password.tsx) lives in resources/login/login.service.ts. 'login/index.tsx': ['login.service.ts'], 'login/password.tsx': ['login.service.ts'], + // The reauth + passkeys routes are thin — their action logic (and the reauth / + // passkey_remove logAuthEvent calls) lives in the reauth / passkeys domain services. + 'reauth.tsx': ['reauth.service.ts'], + 'passkeys.tsx': ['passkeys.service.ts'], }; /** @@ -363,6 +373,15 @@ export const REQUIRED_EVENTS = [ 'device_authorize', // --- Session --- 'logout', + // --- Sudo re-auth + passkey management (snake_case per the P5+ convention) --- + // reauth: one enrolled factor re-verified onto the EXISTING session (/id/reauth action). + // reauth_challenge: assertion/OTP challenge request failure on the reauth loader path. + // reauth_idp_start: OAuth round-trip initiated for IdP re-verification via startReauthIdpIntent. + // passkey_remove: sudo-gated passkey removal (success / sudo_required / last_method). + 'reauth', + 'reauth_challenge', + 'reauth_idp_start', + 'passkey_remove', // --- Password --- 'password.change', 'password.reset.completed', diff --git a/cypress/support/ceremony-guard.ts b/cypress/support/ceremony-guard.ts index 52b98b02e2..f8f14151fc 100644 --- a/cypress/support/ceremony-guard.ts +++ b/cypress/support/ceremony-guard.ts @@ -81,6 +81,14 @@ const ALLOWLIST: Record = { 'The "Enter a new code" recovery link for a stale/expired device code. The device flow is ' + "keyed by user_code/deviceAuthId, not requestId; /device's code-entry screen takes no " + 'ceremony query params, and a stale code has nothing left to resume.', + 'reauth.tsx': + 'The idp-reauth action branch bounces a session-less request straight to /login. ' + + '/reauth is a returnTo-scoped sudo interstitial with no OIDC/SAML/device requestId in ' + + 'its own contract — there is no ceremony to preserve on a dead session.', + 'reauth/provider/callback.tsx': + 'The idp-reauth callback bounces a session-less request straight to /login. Same ' + + 'reasoning as reauth.tsx: this callback only ever completes a returnTo-scoped sudo ' + + 'reauth, never an OIDC/SAML/device ceremony, so there is nothing to carry forward.', }; const PATTERNS: ReadonlyArray<{ name: string; re: RegExp }> = [ diff --git a/cypress/support/node/harness.ts b/cypress/support/node/harness.ts index 320245813c..b31f38cafd 100644 --- a/cypress/support/node/harness.ts +++ b/cypress/support/node/harness.ts @@ -107,7 +107,7 @@ import { loader as deviceCompleteLoader } from '@/routes/device/complete'; import { loader as deviceIndexLoader } from '@/routes/device/index'; // ── routes/login handlers (batch 13b) ───────────────────────────────────────────────────────── import { loader as loginLoader, action as loginAction } from '@/routes/login/index'; -import { loader as loginMethodLoader } from '@/routes/login/method'; +import { loader as loginMethodLoader, action as loginMethodAction } from '@/routes/login/method'; import { action as loginMfaAction } from '@/routes/login/mfa'; import { action as loginPasswordAction } from '@/routes/login/password'; import { action as securityKeyAction } from '@/routes/login/security-key'; @@ -122,6 +122,8 @@ import { loader as passwordResetLoader, action as passwordResetAction, } from '@/routes/password/reset'; +import { action as reauthAction } from '@/routes/reauth'; +import { loader as reauthProviderCallbackLoader } from '@/routes/reauth/provider/callback'; import { loader as setupAuthenticatorLoader } from '@/routes/setup/authenticator'; import { loader as signupCompleteLoader } from '@/routes/signup/complete'; import { loader as signupIndexLoader, action as signupIndexAction } from '@/routes/signup/index'; @@ -618,6 +620,48 @@ export async function runScenario(s: Scenario): Promise { outcome = await signInWithIdpIntent(provider, request, s.signInOpts); break; } + case 'reauthAction': { + const originalFake = providerRegistry.fake; + providerRegistry.fake = () => provider; + try { + const { request } = await buildHandlerRequest( + s.request ?? { url: 'http://localhost/id/reauth', csrf: true } + ); + const result = await reauthAction({ + request, + params: {}, + context: {} as never, + } as never); + response = await serializeResponse(result); + } finally { + providerRegistry.fake = originalFake; + } + break; + } + + case 'reauthProviderCallback': { + // The route loader resolves its own provider via providerForRequest → getAuthProvider('fake') + // → providerRegistry.fake(), which is INDEPENDENT of the `provider` this harness already + // built above (buildProvider constructs a FRESH FakeAuthProvider whenever `seed` is present — + // and this fn's tests always seed idpIntents/users). Without this bridge the loader would see + // the unrelated process-fake singleton (no seeded idpIntent, no seeded live session) instead + // of the one this scenario actually configured. Point the registry at the SAME seeded + // provider for the duration of this call only, then restore it. + const originalFake = providerRegistry.fake; + providerRegistry.fake = () => provider; + try { + const { request: req } = await buildHandlerRequest(sr); + const res = await reauthProviderCallbackLoader({ + request: req, + params: { provider: s.slug ?? 'idp' }, + } as never); + outcome = res; + response = await serializeResponse(res); + } finally { + providerRegistry.fake = originalFake; + } + break; + } case 'submitLdapCredentials': { const o = await submitLdapCredentials(provider, request, buildForm(sr.form)); outcome = o; @@ -2178,20 +2222,42 @@ export async function runScenario(s: Scenario): Promise { } case 'loginMethodLoader': { - const { request } = await buildHandlerRequest( - s.request ?? { url: 'http://localhost/id/login/method' } - ); - const result = await loginMethodLoader({ - request, - params: {}, - context: {} as never, - } as never); - if (result instanceof Response) { + // Same providerRegistry.fake bridge as reauthProviderCallback below — this loader + // resolves its own provider via providerForRequest, independent of the `provider` + // this harness built above whenever the scenario passes a custom `seed`. + const originalFake = providerRegistry.fake; + providerRegistry.fake = () => provider; + try { + const { request } = await buildHandlerRequest( + s.request ?? { url: 'http://localhost/id/login/method' } + ); + const result = await loginMethodLoader({ + request, + params: {}, + context: {} as never, + } as never); response = await serializeResponse(result); - } else { - // loginMethodLoader returns a plain object (not data()-wrapped like most loaders), so - // serializeResponse would lose the payload. Carry it directly as dataBody. - response = { isResponse: false, dataBody: result as Record }; + } finally { + providerRegistry.fake = originalFake; + } + break; + } + + case 'loginMethodAction': { + const originalFake = providerRegistry.fake; + providerRegistry.fake = () => provider; + try { + const { request } = await buildHandlerRequest( + s.request ?? { url: 'http://localhost/id/login/method', csrf: true } + ); + const result = await loginMethodAction({ + request, + params: {}, + context: {} as never, + } as never); + response = await serializeResponse(result); + } finally { + providerRegistry.fake = originalFake; } break; } diff --git a/cypress/support/node/scenario.ts b/cypress/support/node/scenario.ts index 0a94c15dcf..f7923eadfa 100644 --- a/cypress/support/node/scenario.ts +++ b/cypress/support/node/scenario.ts @@ -43,6 +43,17 @@ export interface ScenarioSeed { capabilities?: Partial>; deviceAuths?: Array<{ userCode: string; id: string; appName?: string; scope: string[] }>; samlRequests?: Array<{ id: string; clientId: string; binding: 'redirect' | 'post' }>; + /** FakeAuthProvider's own idpIntents seed, narrowed to the one field the idp-reauth + * tests read (userId) — the real IdpIntentResult also carries information/draft, + * unused by updateSession's idpIntent check. */ + idpIntents?: Record< + string, + { idpIntentId: string; idpIntentToken: string; userId: string | null } + >; + /** Active org IdPs (getActiveIdPs) — narrowed to the fields joinLinkedIdps reads. */ + idps?: Array<{ id: string; name: string; type: string; logoUrl?: string }>; + /** Pre-linked IdP identities (userId → links), narrowed to the fields joinLinkedIdps reads. */ + idpLinks?: Record>; } /** A live session to inject via provider.seedLiveSession (getSession/listSessions resolve it). */ @@ -127,6 +138,8 @@ export type ServiceFn = // ── sso (batch 8b) ── | 'processIdpCallback' | 'signInWithIdpIntent' + | 'reauthAction' + | 'reauthProviderCallback' | 'submitLdapCredentials' | 'runSsoAction' // sso IdP-DISPLAY flows: org-first / default-org fallback probes. Each reads a real Request + @@ -220,6 +233,7 @@ export type ServiceFn = | 'securityKeyAction' | 'loginVerifyEmailLoader' | 'loginMethodLoader' + | 'loginMethodAction' // login/mfa.tsx action: covers the SESSION_EXPIRED path now returning inline data() (instead // of a hard redirect(paths.login.index())) so useAuthActionRecovery's banner can thread // requestId/organization. diff --git a/package.json b/package.json index 2f78c1ed67..2a9e446197 100644 --- a/package.json +++ b/package.json @@ -59,6 +59,7 @@ "zod": "^4.4.3" }, "overrides": { + "axios": "^1.18.0", "@opentelemetry/sdk-trace-base": "2.8.0", "@opentelemetry/core": "2.8.0", "@opentelemetry/resources": "2.8.0", diff --git a/public/images/idps/google.dark.png b/public/images/idps/google.dark.png new file mode 100644 index 0000000000..494acede0c Binary files /dev/null and b/public/images/idps/google.dark.png differ