Files
Felis/internal/store/migrations/0013_webauthn_discoverable_login.sql
T
flyemoji ec468baef9 feat(auth): add discoverable (usernameless) passkey login
A from-zero login door: the browser calls navigator.credentials.get() with an
empty allowCredentials, the authenticator returns an assertion carrying the
resident credential's userHandle, and the server resolves the account from that
handle alone — nothing is typed or client-named.

Routes (both Public):
  POST /api/v1/auth/passkey/login/discoverable/begin
  POST /api/v1/auth/passkey/login/discoverable/finish

Begin stashes the ceremony SessionData server-side keyed by an opaque login_id
under a global cap; finish consumes it single-use, hands the
authenticator-revealed userHandle to a UserByID resolver, and mints a session
only for the account the assertion actually verified to. Every finish rejection
— no live challenge, expired, bad assertion, unresolvable handle — collapses to
one passkey_login_invalid envelope, so finish is never an existence/state
oracle. SignCount is surfaced but not yet consumed, exactly as the
username-first door, so the from-zero path offers no clone-detection bypass.

The discoverable VERIFY path is Oracle-verified end to end against a virtual
authenticator (internal/passkey): it resolves the account from the signed
userHandle, fails closed when the handle names no account, and rejects an
assertion signed by a credential not bound to the resolved user — the
impersonation guard unique to usernameless login. Enrollment now requests a
resident key (authenticatorSelection.residentKey=preferred), the only
server-side half a unit test can pin.

Whether an authenticator actually stores a resident key is a device property no
test can reach, so this door is INERT for a credential until its owner enrolls a
NEW passkey against these options; "preferred" (not "required") preserves the
no-lockout fallback to username-first + email-OTP.
2026-07-05 04:05:56 +09:00

37 lines
2.7 KiB
SQL

-- Phase 6 passkey — the challenge store for DISCOVERABLE ("usernameless") login (spec §14,
-- task #40). webauthn_challenges (0007) is keyed by (user_id, purpose) because both
-- enrollment and email-first login already know WHO is authenticating before the ceremony
-- starts. A from-zero passkey login does not: the browser calls navigator.credentials.get()
-- with an EMPTY allowCredentials list, the authenticator offers a resident credential it
-- holds for this RP, and the account is revealed only inside the signed assertion at finish.
-- So this challenge cannot be keyed by user — it is keyed by an opaque, server-minted handle
-- (login_id) the browser echoes back at finish, and the marshaled WebAuthn SessionData is the
-- only server-held ceremony state. This is exactly the "non-user-keyed challenge store, a
-- future migration" that 0007's own comment anticipated.
--
-- Bounding. webauthn_challenges self-bounds via a per-(user,purpose) supersede-DELETE on each
-- begin — one live row per user+purpose. That key does not exist here (there is no user at
-- begin), so this table is bounded two ways instead, both inside CreateDiscoverableChallenge's
-- one transaction: (1) every begin first reaps rows that already expired or were consumed by a
-- prior finish, and (2) a hard cap (maxLiveDiscoverableChallenges) refuses a new begin once the
-- live count is reached, so an abusive begin-flood is bounded to trivial storage rather than
-- growing without limit. A reap alone does NOT bound a burst — freshly inserted rows have a
-- future expiry, so N begins inside the TTL leave N live rows — which is why the cap, not the
-- reap, is the real ceiling. Volumetric per-source (client-IP) limiting is deliberately left to
-- the edge, the same stance handlers_auth_email.go documents (behind Cloudflare RemoteAddr is
-- the proxy, and CGNAT would false-positive) and unavoidable here since a usernameless door has
-- neither a principal NOR a typed recipient to key a fair per-caller limit on.
--
-- The expires_at index serves the reap's WHERE clause; consumed_at (nullable) makes the row
-- single-use, stamped at finish and swept by a later begin's reap.
CREATE TABLE webauthn_discoverable_challenges (
id text PRIMARY KEY, -- opaque login handle (login_id): 128-bit hex
session_data bytea NOT NULL, -- marshaled webauthn.SessionData (the challenge lives here)
expires_at timestamptz NOT NULL,
consumed_at timestamptz, -- single-use: NULL until finish stamps it
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX webauthn_discoverable_challenges_expires_idx
ON webauthn_discoverable_challenges (expires_at);