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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -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).
84 changes: 84 additions & 0 deletions acceptance/PASSKEY-TESTING.md
Original file line number Diff line number Diff line change
@@ -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://<local-host>/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://<local-host> # the shared origin
ZITADEL_SERVICE_USER_TOKEN=<service-user PAT>
NODE_EXTRA_CA_CERTS=<your local CA cert, if the proxy uses one>
```

Browse `https://<local-host>/id/login` (RP ID = `<local-host>`). 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 `<Browser> on <OS>`) |
| 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 <today's date>" 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:<id>: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 <you> — 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=<you>` 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.
4 changes: 2 additions & 2 deletions app/components/auth-ceremony/auth-ceremony.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -49,7 +49,7 @@ export function AuthCeremony({
}: AuthCeremonyProps) {
return (
<AuthCard title={title} description={description}>
<div className={CEREMONY_LAYOUT}>
<div className={CEREMONY_LAYOUT} data-testid="auth-ceremony-body">
{/* IdentityBadge requires a loginName (it returns null without one); only mount it
when present so requestId/organization are threaded through "Not you?". */}
{loginName && (
Expand Down
11 changes: 10 additions & 1 deletion app/components/auth-form/auth-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 (
Expand All @@ -31,6 +39,7 @@ export function SubmitButton({ children, loading, className, onClick }: SubmitBu
block
htmlType="submit"
loading={isSubmitting}
disabled={disabled}
className={className}
onClick={onClick}>
{children}
Expand Down
4 changes: 4 additions & 0 deletions app/components/auth-form/idp-button-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
11 changes: 10 additions & 1 deletion app/components/back-link/previous-step.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand Down
41 changes: 32 additions & 9 deletions app/components/identity-badge/identity-badge.tsx
Original file line number Diff line number Diff line change
@@ -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 <loginName> — 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.
* "<verb> <loginName> — <linkLabel>" 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 = <Trans>Signing in as</Trans>,
linkLabel = <Trans>Not you?</Trans>,
linkTarget,
showLink = true,
}: IdentityBadgeProps) {
if (!loginName) return null;
if (!showLink) {
return (
<p className="text-foreground text-center text-sm">
{verb} <strong>{loginName}</strong>
</p>
);
}
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 (
<p className="text-foreground text-center text-sm">
<Trans>Signing in as</Trans> <strong>{loginName}</strong>.{' '}
{verb} <strong>{loginName}</strong>.{' '}
<Link to={to} className="underline">
<Trans>Not you?</Trans>
{linkLabel}
</Link>
</p>
);
Expand Down
36 changes: 36 additions & 0 deletions app/components/sign-out-button/sign-out-button.tsx
Original file line number Diff line number Diff line change
@@ -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 <form> hits the logout INDEX route's action, not its action-less layout.
*/
export function SignOutButton({ csrf, emphasis = 'secondary' }: SignOutButtonProps) {
return (
<form method="post" action={`${APP_BASENAME}/logout?index`}>
<AuthFormFields csrf={csrf} />
{emphasis === 'primary' ? (
<Button type="primary" theme="solid" htmlType="submit" block>
<Trans>Sign out</Trans>
</Button>
) : (
<Button type="secondary" theme="link" htmlType="submit" block>
<Trans>Sign out</Trans>
</Button>
)}
</form>
);
}
Loading
Loading