Skip to content

Commit dd36b21

Browse files
ottonomyclaude
andcommitted
feat(auth): passkey (WebAuthn) login + superadmin-org 2FA
Add passkeys as an additional authentication method for all users, and require a second factor (email + passkey) in the superadmin org when the user has a passkey. - @simplewebauthn/{server,browser} v13; all verification in a server-only src/lib/server/webauthn module (per-org-domain RP ID + full origin, derived per request; never hardcoded). Signature counter persisted (clone detection). - Credentials stored as PASSKEY-type Identifier rows (new Identifier.json holds publicKey/counter/transports/deviceType/backedUp/label/lastUsedAt; identifier = credentialId) toward a unified identifier model. PASSKEY rows are excluded from all contact/identity displays + visibility settings. - WebAuthn challenge stored on the Session row (single-use, short TTL), closing the replay hole; new Session columns passkeyChallenge/passkeyChallengeExpiresAt/ emailVerifiedAt (additive migration). - Register + manage passkeys in /settings (add/list/rename/delete). Usernameless (discoverable) passkey login. Superadmin org: email code sets emailVerifiedAt and defers activation until a passkey assertion at /webauthn/2fa/verify; the two factors are bound to one cookie session and neither can be skipped or swapped. Non-superadmin orgs and superadmins without a passkey are unchanged (passkey OR email). Library choice follows the incomplete PR #27; the insecure parts (no server-side challenge, hardcoded RP, client-side verification, stubbed auth, no counter) were rebuilt. ADR: docs/adr/2026-06-13-passkey-webauthn-auth.md Plan: planning/orca/2026-06-13-passkey-auth/plan.md Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent bdc55f6 commit dd36b21

34 files changed

Lines changed: 2407 additions & 35 deletions

File tree

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
# Passkey (WebAuthn) authentication
2+
3+
- Status: Accepted
4+
- Date: 2026-06-13
5+
- Deciders: ORCA maintainers
6+
7+
## Context
8+
9+
ORCA authenticated users only via a magic-link email code. We want passkeys (WebAuthn)
10+
as an additional method for all users, and — as the moderation fast-follow — to harden
11+
the dedicated superadmin org with a second factor. An incomplete prior PR (#27, on the
12+
old SvelteKit-4 branch) chose good libraries but was insecure (hardcoded `localhost` RP,
13+
no server-side challenge, server verification importable from the client, stubbed
14+
authentication, no signature counter). We reuse the libraries and rebuild the logic.
15+
16+
## Decision
17+
18+
1. **Libraries:** `@simplewebauthn/server` + `@simplewebauthn/browser` (v13). All
19+
verification lives in a **server-only** module (`src/lib/server/webauthn/`);
20+
`@simplewebauthn/browser` is imported only in `.svelte` components.
21+
22+
2. **Credentials are stored as `Identifier` rows** (`type=PASSKEY`; `identifier` =
23+
credentialId; a new `Identifier.json` holds `{ publicKey (base64url), counter,
24+
transports, deviceType, backedUp, label, lastUsedAt }`). This advances a future
25+
**unified identifier model** (wallet-based identifiers later become new types) rather
26+
than a dedicated authenticator table. PASSKEY identifiers are authenticators, never
27+
contact/identity — they are excluded from all identity displays and
28+
identifier-visibility settings.
29+
30+
3. **Per-org-domain RP ID.** ORCA is multi-org by domain, so the WebAuthn RP ID is the
31+
org's domain (hostname, no port) and the expected origin is the full request URL,
32+
derived per request — never hardcoded. A passkey is bound to one org's domain,
33+
consistent with ORCA's per-org `User` rows.
34+
35+
4. **Challenge stored on the `Session` row** (`passkeyChallenge` +
36+
`passkeyChallengeExpiresAt`), single-use (cleared on consume) and short-TTL — closing
37+
the replay hole. Registration stores it on the logged-in user's session;
38+
authentication on a cookie-bound pre-auth session (created `valid:false`, activated on
39+
success). The signature **counter** is persisted on every assertion (clone detection).
40+
41+
5. **Login model.** Normal orgs: a passkey alone (usernameless/discoverable) OR an email
42+
code alone — either single factor. **Superadmin org** (`SUPERADMIN_ORG_ID`): when the
43+
user has ≥1 passkey, the email code is only the first factor — the session records
44+
`emailVerifiedAt` and is **not** activated until a passkey assertion completes at
45+
`…/webauthn/2fa/verify`. The two factors are bound to the same cookie session; neither
46+
can be skipped (2FA verify requires `emailVerifiedAt`) or swapped (the asserted
47+
credential must belong to the session's user). A superadmin with no passkey logs in
48+
with email alone.
49+
50+
## Consequences
51+
52+
- Additive migration: `IdentifierType.PASSKEY`, `Identifier.json`, three nullable
53+
`Session` columns (`passkeyChallenge`, `passkeyChallengeExpiresAt`, `emailVerifiedAt`).
54+
Non-destructive.
55+
- A passkey works only on the org domain it was registered for; the same human in two
56+
orgs registers separately (matches per-org users).
57+
- The superadmin org now supports real 2FA, materially hardening the cross-org
58+
moderation capability introduced by the reporting-moderation plan.
59+
- WebAuthn ceremonies can't run headlessly in unit tests without a virtual authenticator;
60+
the pure pieces (RP derivation, codec, single-use challenge, the 2FA decision) are
61+
unit-tested and the `@simplewebauthn` verify boundary is mocked; full-ceremony coverage
62+
relies on manual/dev smoke.
63+
64+
## Alternatives considered
65+
66+
- **Dedicated authenticator table** (typed `publicKey Bytes`, `counter Int`). Rejected in
67+
favor of the `Identifier` reuse to advance the unified-identifier goal; the mutable
68+
counter living in `json` is an accepted minor tradeoff.
69+
- **Shared-root RP ID** (one registrable root, cross-subdomain). Rejected: breaks for
70+
custom org domains and implies cross-org credential sharing (conflicts with per-org users).
71+
- **Separate challenge table.** Rejected: the cookie-bound `Session` already models the
72+
pre-auth/partial-auth state cleanly; a single-use field there is sufficient.
73+
- **2FA for every org once a passkey exists.** Rejected: the requirement scopes mandatory
74+
second-factor to the superadmin org; elsewhere passkeys are an alternative, not a burden.

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,8 @@
119119
"@neondatabase/serverless": "^0.6.1",
120120
"@prisma/adapter-neon": "^5.7.1",
121121
"@prisma/client": "^5.7.1",
122+
"@simplewebauthn/browser": "^13.3.0",
123+
"@simplewebauthn/server": "^13.3.1",
122124
"@vitejs/plugin-basic-ssl": "^2.3.0",
123125
"carta-md": "^4.11.2",
124126
"credential-handler-polyfill": "^3.2.0",

0 commit comments

Comments
 (0)