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.
This commit is contained in:
flyemoji committed 2026-07-05 04:05:56 +09:00
1 parent 7db57b9fff
commit ec468baef9
12 files changed
+1193 -21

No files matched your search

+147
View File
@@ -1709,6 +1709,153 @@ paths:
application/json:
schema: { $ref: '#/components/schemas/Error' }
/api/v1/auth/passkey/login/discoverable/begin:
post:
tags: [auth]
operationId: passkeyLoginDiscoverableBegin
summary: Begin a usernameless (discoverable) passkey login (spec §14, §B, task #40).
description: >-
First leg of the truly from-zero passkey door: unlike the email-first sibling
above, the caller supplies NO identifier — the request has no body (only the
application/json Content-Type is required as the cross-origin CSRF guard). The
response is the WebAuthn PublicKeyCredentialRequestOptions with an EMPTY
allowCredentials, plus an opaque login_id: the authenticator picks a resident
credential it holds for this RP and the account is revealed only by the
userHandle inside the signed assertion at finish. The challenge cannot be
user-keyed, so it is stashed under login_id in a non-user-keyed store and echoed
back at finish. Mounted Public and gated on local_auth_enabled. There is no
recipient or principal to key a per-caller cooldown on (that volumetric limiting
is delegated to the edge), so the server-side brake is a hard global cap on live
challenges (429 too_many_challenges). Inert for a credential until its owner
enrolls a resident passkey; email-OTP and username-first passkey remain the
fallbacks, so no authenticator is ever locked out.
x-felis-face: [external]
x-felis-tier: public
security: []
requestBody:
required: false
description: >-
No body is read — the whole point is that the caller supplies no identifier —
but the application/json Content-Type is required (415 otherwise).
content:
application/json:
schema: { type: object }
responses:
'200':
description: >-
The WebAuthn assertion options (PublicKeyCredentialRequestOptions) with an
empty allowCredentials, passed through verbatim for the browser to consume,
plus an opaque login_id the caller echoes at finish. The publicKey member is
the WebAuthn standard shape and is not modelled here.
content:
application/json:
schema:
type: object
required: [publicKey, login_id]
properties:
publicKey: { type: object, additionalProperties: true }
login_id: { type: string }
'400':
description: The authenticator library could not start the ceremony (passkey_login_failed).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'403':
description: Local session login is disabled on this deployment (local_auth_disabled).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'415':
description: Request Content-Type was not application/json.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'429':
description: >-
Too many discoverable logins are in flight server-wide; the global cap is hit
(too_many_challenges). No per-recipient signal is leaked — the cap is global.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'503':
description: No passkey verifier is wired on this deployment (passkey_unavailable).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
/api/v1/auth/passkey/login/discoverable/finish:
post:
tags: [auth]
operationId: passkeyLoginDiscoverableFinish
summary: Complete a usernameless (discoverable) passkey login and mint a session (spec §14, §B, task #40).
description: >-
Second leg of the from-zero door: the caller returns the opaque login_id from
begin (the only link to the stashed challenge, since it is not user-keyed) and
the raw navigator.credentials.get() assertion — and NOTHING that names an
account. The stashed challenge is consumed atomically and the assertion is
verified against it; the account is resolved from the authenticator-revealed
userHandle (the account's stable id), never from anything the client supplied,
and the session is minted for the account the assertion actually resolved AND
verified to. Both players and staff may log in this way. Every failure mode — a
missing/expired/consumed login_id, a bad assertion, AND a userHandle that
resolves to no account — collapses into one uniform passkey_login_invalid, so
the door reveals nothing (not even whether the handle was well-formed).
x-felis-face: [external]
x-felis-tier: public
security: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [login_id, assertion]
properties:
login_id:
type: string
description: The opaque handle returned by discoverable/begin.
assertion:
type: object
additionalProperties: true
description: >-
The raw PublicKeyCredential from navigator.credentials.get(),
passed to the verifier verbatim (WebAuthn standard shape). Its
userHandle selects the account server-side.
responses:
'200':
description: Assertion verified; a host-only session cookie is set on the response.
content:
application/json:
schema:
type: object
required: [user_id, role]
properties:
user_id: { type: string }
role: { type: string, enum: [user, admin] }
'400':
description: >-
Missing login_id or assertion (bad_request); or the login could not be
completed — no live/expired/consumed challenge, a failed assertion, or a
userHandle that resolves to no account, all uniform (passkey_login_invalid).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'403':
description: Local session login is disabled on this deployment (local_auth_disabled).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'415':
description: Request body was not application/json.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'503':
description: No passkey verifier is wired on this deployment (passkey_unavailable).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
/api/v1/auth/email/start:
post:
tags: [auth]
+5
View File
@@ -282,6 +282,11 @@ func (a *API) externalAPIRoutes() []apiRoute {
{Method: "GET", Pattern: "/api/v1/auth/setup/status", SetupAllowed: true, h: a.handleSetupStatus},
{Method: "POST", Pattern: "/api/v1/auth/passkey/login/begin", Public: true, h: a.handlePasskeyLoginBegin},
{Method: "POST", Pattern: "/api/v1/auth/passkey/login/finish", Public: true, h: a.handlePasskeyLoginFinish},
// Discoverable ("usernameless") passkey login (task #40): the from-zero sibling of the
// email-first pair above — no identifier typed, the account is resolved from the
// userHandle inside the signed assertion (handlers_passkey_discoverable.go).
{Method: "POST", Pattern: "/api/v1/auth/passkey/login/discoverable/begin", Public: true, h: a.handlePasskeyLoginDiscoverableBegin},
{Method: "POST", Pattern: "/api/v1/auth/passkey/login/discoverable/finish", Public: true, h: a.handlePasskeyLoginDiscoverableFinish},
{Method: "POST", Pattern: "/api/v1/auth/email/start", Public: true, h: a.handleLoginEmailStart},
{Method: "POST", Pattern: "/api/v1/auth/email/verify", Public: true, h: a.handleLoginEmailVerify},
{Method: "POST", Pattern: "/api/v1/auth/op-login/start", Public: true, h: a.handleOpLoginStart},
+74 -1
View File
@@ -81,6 +81,12 @@ type fakeRepo struct {
// just as the PG query does.
passkeyCreds map[string]PasskeyCredential
passkeyChallenges map[string]*fakePasskeyChallenge
// discoverable ("usernameless") login challenge store (task #40), keyed by opaque handle id
// with no user key, mirroring migration 0013. discoverableFull forces the capped-out path
// (ErrTooManyDiscoverableChallenges) so the begin 429 branch is reachable without inserting
// thousands of rows.
discoverableChallenges map[string]*fakeDiscoverableChallenge
discoverableFull bool
// user admin fakes
seededUsers []seededUser
fakeQuotas map[string]*QuotaView
@@ -99,6 +105,15 @@ type fakePasskeyChallenge struct {
createdAt time.Time
}
// fakeDiscoverableChallenge mirrors a webauthn_discoverable_challenges row (task #40): no user
// or purpose (a from-zero begin has neither), just the opaque stashed SessionData, its expiry,
// and single-use via consumed. Keyed by the opaque handle in the map, like the real table's id.
type fakeDiscoverableChallenge struct {
sessionData []byte
expiresAt time.Time
consumed bool
}
// fakeDataHold mirrors a player_data_holds row at the granularity the verifiable
// (write-only) layer exercises: which name/data was stashed for the squatter UUID
// and when the 30-day window ends. reclaimed_by_user_id/reclaimed_at have no fake
@@ -191,7 +206,9 @@ func newFakeRepo() *fakeRepo {
holds: map[string]fakeDataHold{},
passkeyCreds: map[string]PasskeyCredential{},
passkeyChallenges: map[string]*fakePasskeyChallenge{},
fakeQuotas: map[string]*QuotaView{},
discoverableChallenges: map[string]*fakeDiscoverableChallenge{},
fakeQuotas: map[string]*QuotaView{},
}
}
@@ -366,6 +383,31 @@ func (f *fakeRepo) ConsumePasskeyChallengeByUser(_ context.Context, userID, purp
return live.sessionData, nil
}
// CreateDiscoverableChallenge / ConsumeDiscoverableChallenge mirror PGRepo's non-user-keyed
// contract (task #40): begin reaps expired/consumed rows then stashes under the opaque handle,
// and consume redeems by handle, single-use, expiry checked. discoverableFull forces the capped
// path so the begin 429 branch is reachable without inserting thousands of rows.
func (f *fakeRepo) CreateDiscoverableChallenge(_ context.Context, id string, sessionData []byte, now, expiresAt time.Time) error {
if f.discoverableFull {
return ErrTooManyDiscoverableChallenges
}
for k, c := range f.discoverableChallenges { // reap (DELETE ... expires_at<=now OR consumed_at NOT NULL)
if c.consumed || !c.expiresAt.After(now) {
delete(f.discoverableChallenges, k)
}
}
f.discoverableChallenges[id] = &fakeDiscoverableChallenge{sessionData: sessionData, expiresAt: expiresAt}
return nil
}
func (f *fakeRepo) ConsumeDiscoverableChallenge(_ context.Context, id string, now time.Time) ([]byte, error) {
c, ok := f.discoverableChallenges[id]
if !ok || c.consumed || !c.expiresAt.After(now) {
return nil, ErrPasskeyChallengeInvalid
}
c.consumed = true
return c.sessionData, nil
}
// CreatePasskeyCredential mirrors PGRepo: a credential_id already bound to ANY account
// → ErrConflict (the UNIQUE guard), never a silent rebind.
func (f *fakeRepo) CreatePasskeyCredential(_ context.Context, c PasskeyCredential) error {
@@ -436,6 +478,10 @@ type fakePasskeyVerifier struct {
// stashed SessionData round-trips and the existing credentials reach the verifier.
lastUser PasskeyUser
lastSession []byte
// discoverableUserHandle is the userHandle the fake feeds to FinishDiscoverableLogin's
// resolver, so a handler test drives the userHandle → UserByID → session-mint wiring for a
// chosen account (or an unknown handle, to exercise the resolve-fails branch).
discoverableUserHandle []byte
}
func (v *fakePasskeyVerifier) BeginRegistration(user PasskeyUser) (json.RawMessage, []byte, error) {
@@ -477,6 +523,33 @@ func (v *fakePasskeyVerifier) FinishLogin(user PasskeyUser, sessionData []byte,
return v.assertion, nil
}
func (v *fakePasskeyVerifier) BeginDiscoverableLogin() (json.RawMessage, []byte, error) {
if v.beginLoginErr != nil {
return nil, nil, v.beginLoginErr
}
opts := v.options
if opts == nil {
opts = json.RawMessage(`{"publicKey":{"challenge":"ZGlzYw"}}`)
}
return opts, []byte("disc-session"), nil
}
func (v *fakePasskeyVerifier) FinishDiscoverableLogin(resolveUser func([]byte) (PasskeyUser, error), sessionData []byte, _ io.Reader) (VerifiedAssertion, error) {
v.lastSession = sessionData
if v.failErr != nil {
return VerifiedAssertion{}, v.failErr
}
// Drive the resolver with the configured user handle so the handler's userHandle → UserByID
// → session-mint wiring runs end to end; a resolve error (unknown handle) fails the ceremony
// exactly as the real ValidateDiscoverableLogin would when the handler cannot be resolved.
u, err := resolveUser(v.discoverableUserHandle)
if err != nil {
return VerifiedAssertion{}, err
}
v.lastUser = u
return v.assertion, nil
}
func (f *fakeRepo) UserInAllowlist(_ context.Context, n, u string) (bool, error) {
return f.allowlist[n][u], nil
}
+8
View File
@@ -58,6 +58,14 @@ var (
// guard and gets a 409 instead of a raw unique-violation 500. Distinct from
// ErrConflict so the message can name the cause (the email is spoken for).
ErrEmailTaken = errors.New("email already verified on another account")
// ErrTooManyDiscoverableChallenges means the non-user-keyed discoverable ("usernameless")
// login challenge store is at its hard cap of live rows (task #40, migration 0013).
// Unlike the user-keyed enrollment/login challenges — which self-bound via a per-user
// supersede — a from-zero begin has no principal to key a fair per-caller limit on, so the
// table is capped globally and a begin over the cap is refused. Distinct from the other
// sentinels so the handler answers 429 (a transient "too busy, retry" — the cap self-clears
// as challenges expire), never a 400 that invites an immediate retry.
ErrTooManyDiscoverableChallenges = errors.New("too many discoverable login challenges in flight")
)
// apiError is a handler-level error carrying an HTTP status and a stable,
+29 -9
View File
@@ -40,15 +40,20 @@ import (
// (0010_verified_email_unique.sql) plus UserByEmail gave the door the typable handle
// it keys on: begin resolves email → account → its bound passkeys.
//
// This is an EMAIL-first assertion, not a usernameless one. The system's returning-player
// root of trust is still re-link (control of the in-game identity — handlers_onboard.go
// re-mints a session through the bind-code flow even after passkey/OTP are bound); the
// email and passkey login doors are convenience layered on top, never the root. The real
// enabler for a TRULY from-zero passkey login (no identifier typed at all) is discoverable
// ("usernameless") credentials, which sidestep even the email handle but reshape enrollment
// (residentKey) and need a non-user-keyed challenge store — a future migration and its own
// checkpoint, task #40 (that door partly bypasses the in-game-identity root of trust). The
// adapter crypto is verified now so that slice inherits correct crypto.
// That EMAIL-first assertion is one of TWO login doors this subsystem now offers. The other,
// the TRULY from-zero door, is discoverable ("usernameless") login (handlers_passkey_discoverable.go,
// task #40): the browser calls navigator.credentials.get() with an EMPTY allowCredentials, the
// authenticator offers a resident credential it holds, and the account is resolved from the
// userHandle inside the signed assertion — no identifier typed at all. It reshaped enrollment
// (ResidentKey=Preferred in the verifier) and added a non-user-keyed challenge store (migration
// 0013). Two honest limits frame it: (1) the from-zero door partly bypasses the returning-player
// root of trust — control of the in-game identity, which handlers_onboard.go re-mints a session
// through even after passkey/OTP are bound — but it stands on the same footing as the email door
// (#72): a passkey is a possession+UV two-factor authenticator strong enough to stand alone; and
// (2) whether an authenticator actually STORES a resident key is a device property no server
// request compels, so a credential enrolled before this slice, or on hardware that declines
// residency, stays username-first (BeginLogin) — the from-zero door is inert for it until its
// owner enrolls a new passkey. The assertion crypto for both doors is Oracle-verified.
//
// The cryptographic half is a seam (PasskeyVerifier) so this package never imports
// go-webauthn: ceremony state crosses the boundary as opaque bytes, the attestation
@@ -102,6 +107,21 @@ type PasskeyVerifier interface {
// the browser posts back; sessionData is the blob BeginLogin returned. A failed
// verification returns a non-nil error; the handler maps it to 400.
FinishLogin(user PasskeyUser, sessionData []byte, assertion io.Reader) (VerifiedAssertion, error)
// BeginDiscoverableLogin starts a USERNAMELESS assertion ceremony (task #40): there is no
// user yet, so no allowCredentials — the authenticator offers a resident (discoverable)
// credential it holds for this RP and reveals the account only in the signed response. It
// returns the {"publicKey": {...}} request options for navigator.credentials.get() and the
// opaque SessionData the handler stashes under an opaque handle (not a user id) and replays
// at finish.
BeginDiscoverableLogin() (options json.RawMessage, sessionData []byte, err error)
// FinishDiscoverableLogin verifies a usernameless assertion. resolveUser is called with the
// authenticator-revealed user handle so the caller loads the account and its bound
// credentials WITHOUT any client-supplied identifier; the verifier then checks the asserted
// credential id is one that user holds and verifies the signature. A resolveUser error
// (unknown handle) fails the ceremony closed; the handle is the account's stable user id, so
// resolveUser is a direct id lookup. A failed verification returns a non-nil error the
// handler maps to 400.
FinishDiscoverableLogin(resolveUser func(userHandle []byte) (PasskeyUser, error), sessionData []byte, assertion io.Reader) (VerifiedAssertion, error)
}
// PasskeyUser is the relying-party view of the enrolling principal the verifier needs:
@@ -0,0 +1,209 @@
package api
import (
"bytes"
"encoding/json"
"errors"
"net/http"
"strings"
)
// Discoverable ("usernameless") passkey login (spec §14, task #40) — the TRULY from-zero
// console.<root_domain> door. Its email-first sibling (handlers_passkey.go) still needs a typed
// email to resolve the account before offering its passkeys; this door needs nothing typed at
// all. The browser calls navigator.credentials.get() with an EMPTY allowCredentials, the
// authenticator offers a resident credential it holds for this RP, and the account is revealed
// only by the userHandle inside the signed assertion. Because there is no identifier at begin,
// the challenge cannot be user-keyed: it is stashed under an opaque server-minted handle
// (login_id) in the non-user-keyed store (migration 0013) and echoed back at finish. Email-OTP
// and username-first passkey remain the fallbacks, so an authenticator that stored no resident
// key is never locked out — only its from-zero convenience is unavailable.
//
// Anti-abuse divergence from the email-first door: that door reserves a per-recipient cooldown
// (a.otpLimiter) keyed on the typed email. A usernameless begin has no recipient OR principal to
// key a fair per-caller limit on, so — matching the stance in handlers_auth_email.go (behind
// Cloudflare RemoteAddr is the proxy; CGNAT false-positives) — volumetric per-source limiting is
// left to the edge, and the server-side bound is a hard global cap on live challenges enforced
// atomically in CreateDiscoverableChallenge (ErrTooManyDiscoverableChallenges → 429).
// handlePasskeyLoginDiscoverableBegin starts a usernameless assertion ceremony (Public,
// pre-session). It has no request body — the whole point is that the caller supplies no
// identifier — but requires the JSON Content-Type as the same cross-origin CSRF guard the other
// pre-session doors use. It asks the verifier for assertion options with an empty
// allowCredentials + opaque SessionData, stashes the SessionData under a fresh opaque handle in
// the capped non-user-keyed store, and returns the options with that handle merged in as
// login_id for the browser to echo at finish.
func (a *API) handlePasskeyLoginDiscoverableBegin(w http.ResponseWriter, r *http.Request) {
if !localAuthEnabled(r.Context(), a.Repo) {
writeError(w, r, newError(http.StatusForbidden, "local_auth_disabled",
"session login is disabled"))
return
}
if a.Passkey == nil {
writeError(w, r, errPasskeyUnavailable)
return
}
if err := requireJSONContentType(r); err != nil {
writeError(w, r, err)
return
}
options, sessionData, err := a.Passkey.BeginDiscoverableLogin()
if err != nil {
writeError(w, r, newError(http.StatusBadRequest, "passkey_login_failed",
"could not start passkey login"))
return
}
id, err := newPasskeyID()
if err != nil {
writeError(w, r, err)
return
}
now := a.now()
if err := a.Repo.CreateDiscoverableChallenge(r.Context(), id, sessionData, now, now.Add(passkeyChallengeTTL)); err != nil {
if errors.Is(err, ErrTooManyDiscoverableChallenges) {
writeError(w, r, newError(http.StatusTooManyRequests, "too_many_challenges",
"too many passkey logins in progress; try again shortly"))
return
}
writeError(w, r, err)
return
}
// Merge the opaque login handle into the options envelope so the response is a single
// {"publicKey": {...}, "login_id": "..."} document. The browser passes publicKey to
// navigator.credentials.get() and echoes login_id back at finish (the challenge is never
// user-keyed, so this handle is the only link between begin and finish).
envelope, err := mergeLoginID(options, id)
if err != nil {
writeError(w, r, err)
return
}
writeJSON(w, http.StatusOK, envelope)
}
// passkeyDiscoverableFinishRequest is the finish body: the opaque login_id that begin returned
// (the only link to the stashed challenge, since it is not user-keyed) and the raw
// navigator.credentials.get() assertion. Assertion is RawMessage so the exact bytes the browser
// produced reach the verifier without a re-encode that could perturb the signed payload.
type passkeyDiscoverableFinishRequest struct {
LoginID string `json:"login_id"`
Assertion json.RawMessage `json:"assertion"`
}
// handlePasskeyLoginDiscoverableFinish verifies a usernameless assertion and mints a session
// (Public, pre-session). It consumes the stashed challenge under login_id (a missing/expired/
// consumed handle → 400), then verifies the assertion — the verifier resolves the account from
// the authenticator-revealed userHandle via the resolve callback below, WITHOUT any
// client-supplied identifier. On success the session is minted for the account the assertion
// actually resolved AND verified to (the resolved user is hoisted out of the callback), never
// anything the client named. All rejection branches collapse to one passkey_login_invalid
// envelope so finish is never an existence/state oracle.
func (a *API) handlePasskeyLoginDiscoverableFinish(w http.ResponseWriter, r *http.Request) {
if !localAuthEnabled(r.Context(), a.Repo) {
writeError(w, r, newError(http.StatusForbidden, "local_auth_disabled",
"session login is disabled"))
return
}
if a.Passkey == nil {
writeError(w, r, errPasskeyUnavailable)
return
}
if err := requireJSONContentType(r); err != nil {
writeError(w, r, err)
return
}
var req passkeyDiscoverableFinishRequest
if err := decodeJSON(w, r, &req); err != nil {
writeError(w, r, err)
return
}
if strings.TrimSpace(req.LoginID) == "" {
writeError(w, r, newError(http.StatusBadRequest, "bad_request", "login_id is required"))
return
}
if len(req.Assertion) == 0 {
writeError(w, r, newError(http.StatusBadRequest, "bad_request", "assertion is required"))
return
}
sessionData, err := a.Repo.ConsumeDiscoverableChallenge(r.Context(), req.LoginID, a.now())
if err != nil {
if errors.Is(err, ErrPasskeyChallengeInvalid) {
writeError(w, r, newError(http.StatusBadRequest, "passkey_login_invalid",
"passkey login could not be completed; begin again"))
return
}
writeError(w, r, err)
return
}
// The verifier hands the authenticator-revealed userHandle to this resolver; it loads the
// account and its bound credentials so ValidateDiscoverableLogin can check the asserted
// credential belongs to that user and verify the signature. The userHandle IS the account's
// stable id (WebAuthnID), so this is a direct id lookup. The resolved user is hoisted here so
// the session below is minted for the account the assertion actually resolved AND verified to
// — not anything the client supplied (the body carries only a challenge handle).
var resolved *StaffUser
resolve := func(userHandle []byte) (PasskeyUser, error) {
u, err := a.Repo.UserByID(r.Context(), string(userHandle))
if err != nil {
return PasskeyUser{}, err
}
creds, err := a.Repo.PasskeyCredentialsForUser(r.Context(), u.ID)
if err != nil {
return PasskeyUser{}, err
}
resolved = u
// Name/DisplayName are cosmetic at assertion time (nothing is shown to the user); use the
// stable username so a nil email never matters.
return PasskeyUser{ID: u.ID, Name: u.Username, DisplayName: u.Username, Credentials: creds}, nil
}
if _, err := a.Passkey.FinishDiscoverableLogin(resolve, sessionData, bytes.NewReader(req.Assertion)); err != nil {
writeError(w, r, newError(http.StatusBadRequest, "passkey_login_invalid",
"passkey login could not be completed; begin again"))
return
}
// A verified assertion guarantees resolve ran and set resolved: go-webauthn calls the handler
// to obtain the user BEFORE checking the signature, and a resolve error would have failed
// FinishDiscoverableLogin above. Guard anyway so a future verifier that could return success
// without invoking the resolver fails closed rather than nil-dereferencing.
if resolved == nil {
writeError(w, r, newError(http.StatusBadRequest, "passkey_login_invalid",
"passkey login could not be completed; begin again"))
return
}
token, err := newSessionToken()
if err != nil {
writeError(w, r, err)
return
}
expires := a.now().Add(sessionTTL)
if err := a.Repo.CreateSession(r.Context(), hashCookie(token), resolved.ID, expires); err != nil {
writeError(w, r, err)
return
}
setSessionCookie(w, token, expires)
a.audit(r, resolved.Username, "auth.passkey_login_discoverable", "")
writeJSON(w, http.StatusOK, map[string]any{
"user_id": resolved.ID,
"role": resolved.Role,
})
}
// mergeLoginID returns options with an added top-level "login_id" member, so a discoverable
// begin can hand the browser one {"publicKey": {...}, "login_id": "..."} document. It parses the
// options into a generic envelope (they are already a JSON object with a publicKey member) and
// re-marshals with the handle added; a malformed options blob surfaces as an error rather than a
// silently unmergeable response.
func mergeLoginID(options json.RawMessage, id string) (json.RawMessage, error) {
var envelope map[string]json.RawMessage
if err := json.Unmarshal(options, &envelope); err != nil {
return nil, err
}
idJSON, err := json.Marshal(id)
if err != nil {
return nil, err
}
envelope["login_id"] = idJSON
return json.Marshal(envelope)
}
@@ -0,0 +1,319 @@
package api
import (
"bytes"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
"time"
)
// Pre-session DISCOVERABLE ("usernameless") passkey login tests (spec §14, task #40 — the
// truly from-zero console.<root_domain> door). These drive the two Public routes against the
// fakeRepo's non-user-keyed challenge store and a fake PasskeyVerifier, so what they PROVE is
// the handler + login state machine (opaque-handle stash → consume → userHandle-resolve →
// session mint), NOT the pgrepo SQL nor the cryptographic assertion verification (the latter is
// the parity subject of internal/passkey/verifier_test.go's TestDiscoverableLoginRoundTrip).
// The load-bearing properties, in flow order:
//
// - No identifier crosses the wire: begin has no request body and finish carries only the
// opaque login_id + the assertion. The account is revealed solely by the userHandle the
// verifier surfaces, resolved server-side via UserByID — never anything the client names.
// - Session-data round-trip: the stashed SessionData reaches FinishDiscoverableLogin only via
// store-stash → consume (the finish body has no session data), so it is never client-echoed.
// - Fail-closed anti-enumeration on finish: a bogus/expired/consumed handle, a failed
// assertion, AND a userHandle that resolves to no account all collapse to ONE
// passkey_login_invalid envelope — finish is never an existence/state oracle.
// - Volumetric bound: begin has no per-caller identity to rate-limit (that is delegated to the
// edge), so the server-side guard is the hard global cap → 429 too_many_challenges.
// seedDiscoverableLoginAPI wires the public from-zero door: local sessions enabled, a single
// verified player "player" (id u1) with one bound passkey, and a verifier primed with fixed
// options, a verified assertion, and a discoverableUserHandle of "u1" — so the default resolve
// path surfaces that account exactly as a real resident credential's userHandle would. Both
// routes are Public (no External principal), proving they are truly pre-session.
func seedDiscoverableLoginAPI(t *testing.T) (*API, *fakeRepo, *fakePasskeyVerifier) {
t.Helper()
repo := newFakeRepo()
repo.settings[LocalAuthEnabledKey] = []byte("true")
repo.staff["player"] = &StaffUser{
ID: "u1", Username: "player", Email: "[email protected]",
Role: "user", EmailVerified: true,
}
repo.passkeyCreds["row1"] = PasskeyCredential{
ID: "row1", UserID: "u1", CredentialID: "cred-1", PublicKey: "k", CreatedAt: frozenNow,
}
v := &fakePasskeyVerifier{
options: json.RawMessage(`{"publicKey":{"challenge":"ZGlzYw"}}`),
assertion: VerifiedAssertion{CredentialID: "cred-1", UserVerified: true},
discoverableUserHandle: []byte("u1"),
}
api := newTestAPI(repo, newFakeCluster())
api.Passkey = v
return api, repo, v
}
// TestPasskeyDiscoverableLoginVertical walks the whole from-zero slice across the external face:
// begin stashes one challenge under an opaque login_id and returns the assertion options with
// that handle merged in; finish consumes the handle, verifies the assertion against the
// SERVER-STASHED session data, resolves the account from the authenticator-revealed userHandle
// (NOT from anything typed), and mints the same host-only felis_session as the other doors. The
// decisive assertion is the session-data round-trip: the finish body carries only login_id +
// assertion, so the only path for the stashed blob into FinishDiscoverableLogin is store-stash →
// consume — the challenge is never client-echoed.
func TestPasskeyDiscoverableLoginVertical(t *testing.T) {
api, repo, v := seedDiscoverableLoginAPI(t)
eh := api.ExternalHandler()
// 1) begin: usernameless — no request body by design (the caller supplies no identifier),
// only the JSON Content-Type CSRF guard. The response is one {"publicKey":{...},"login_id":
// "..."} document, and exactly one challenge is stashed, keyed by the returned handle.
w := do(eh, "POST", "/api/v1/auth/passkey/login/discoverable/begin", `{}`, jsonHeader)
if w.Code != http.StatusOK {
t.Fatalf("begin: code = %d, want 200 (%s)", w.Code, w.Body.String())
}
body := acctBody(t, w)
if body["publicKey"] == nil {
t.Errorf("begin must return the assertion options verbatim, got %s", w.Body.String())
}
loginID, _ := body["login_id"].(string)
if loginID == "" {
t.Fatalf("begin must return a non-empty login_id, got %s", w.Body.String())
}
if len(repo.discoverableChallenges) != 1 {
t.Fatalf("begin must stash exactly one discoverable challenge, got %d", len(repo.discoverableChallenges))
}
if _, ok := repo.discoverableChallenges[loginID]; !ok {
t.Errorf("the stashed challenge must be keyed by the returned login_id %q", loginID)
}
// 2) finish: the body carries ONLY the login_id and the assertion — no identifier and no
// session data. The account is revealed by the userHandle the verifier surfaces (u1).
w = do(eh, "POST", "/api/v1/auth/passkey/login/discoverable/finish",
`{"login_id":"`+loginID+`","assertion":{"id":"cred-1","type":"public-key"}}`, jsonHeader)
if w.Code != http.StatusOK {
t.Fatalf("finish: code = %d, want 200 (%s)", w.Code, w.Body.String())
}
// THE security assertion: the session data the verifier saw is exactly what begin stashed —
// it travelled store-stash → consume, never the client (the finish body has no session data).
if !bytes.Equal(v.lastSession, []byte("disc-session")) {
t.Fatalf("finish session data = %q, want the server-stashed %q (challenge must not be client-echoed)",
v.lastSession, "disc-session")
}
vb := acctBody(t, w)
if vb["user_id"] != "u1" || vb["role"] != "user" {
t.Fatalf("finish body = %v, want user_id:u1 role:user", vb)
}
// The host-only HttpOnly cookie is the whole point — same contract as the other doors.
cookies := w.Result().Cookies()
if len(cookies) != 1 || cookies[0].Name != sessionCookieName || cookies[0].Value == "" {
t.Fatalf("want one non-empty %s cookie, got %v", sessionCookieName, cookies)
}
s, ok := repo.sessions[hashCookie(cookies[0].Value)]
if !ok {
t.Fatal("no session row for the issued cookie (must be stored hashed)")
}
if s.userID != "u1" {
t.Errorf("session userID = %q, want u1", s.userID)
}
if want := frozenNow.Add(sessionTTL); !s.expiresAt.Equal(want) {
t.Errorf("session expiresAt = %v, want now+sessionTTL = %v", s.expiresAt, want)
}
// Audited once, by the RESOLVED account's username (there is no principal yet); begin is silent.
if n := len(repo.audits); n != 1 {
t.Fatalf("want exactly 1 audit (passkey_login_discoverable), got %d: %+v", n, repo.audits)
}
if repo.audits[0].Action != "auth.passkey_login_discoverable" || repo.audits[0].Actor != "player" {
t.Errorf("audit = %+v, want auth.passkey_login_discoverable by player", repo.audits[0])
}
// 3) single-use: the consumed login_id buys nothing a second time.
if w := do(eh, "POST", "/api/v1/auth/passkey/login/discoverable/finish",
`{"login_id":"`+loginID+`","assertion":{"id":"cred-1","type":"public-key"}}`, jsonHeader); w.Code != http.StatusBadRequest || decodeErr(t, w) != "passkey_login_invalid" {
t.Fatalf("replay of consumed login_id: code = %d body %s, want 400 passkey_login_invalid", w.Code, w.Body.String())
}
}
// TestPasskeyDiscoverableLoginBeginCapped pins the server-side volumetric bound: with the store
// at its hard cap, begin answers 429 too_many_challenges and stashes nothing. This is the only
// per-server brake on the usernameless begin (there is no recipient/principal to key a per-caller
// cooldown on, so volumetric per-source limiting is delegated to the edge) — a reap alone cannot
// bound a burst, since freshly-inserted rows are not yet expired.
func TestPasskeyDiscoverableLoginBeginCapped(t *testing.T) {
api, repo, _ := seedDiscoverableLoginAPI(t)
repo.discoverableFull = true
eh := api.ExternalHandler()
w := do(eh, "POST", "/api/v1/auth/passkey/login/discoverable/begin", `{}`, jsonHeader)
if w.Code != http.StatusTooManyRequests || decodeErr(t, w) != "too_many_challenges" {
t.Fatalf("capped begin: code = %d body %s, want 429 too_many_challenges", w.Code, w.Body.String())
}
if len(repo.discoverableChallenges) != 0 {
t.Errorf("a capped begin must stash nothing, got %d", len(repo.discoverableChallenges))
}
}
// TestPasskeyDiscoverableLoginBeginVerifierError pins the verifier-fault path: a
// BeginDiscoverableLogin failure is a server-side fault, answered passkey_login_failed, and
// stashes no challenge (there is nothing to stash — the ceremony never started).
func TestPasskeyDiscoverableLoginBeginVerifierError(t *testing.T) {
api, repo, v := seedDiscoverableLoginAPI(t)
v.beginLoginErr = errors.New("cannot begin discoverable")
eh := api.ExternalHandler()
w := do(eh, "POST", "/api/v1/auth/passkey/login/discoverable/begin", `{}`, jsonHeader)
if w.Code != http.StatusBadRequest || decodeErr(t, w) != "passkey_login_failed" {
t.Fatalf("verifier error: code = %d body %s, want 400 passkey_login_failed", w.Code, w.Body.String())
}
if len(repo.discoverableChallenges) != 0 {
t.Errorf("a failed begin must stash nothing, got %d", len(repo.discoverableChallenges))
}
}
// TestPasskeyDiscoverableLoginGates covers the shared front doors of both halves: the
// fail-closed local-auth toggle, graceful degradation when no verifier is wired, the CSRF
// Content-Type guard (these are Public, credential-minting routes), and the finish input gates
// that must reject before any consume or resolve.
func TestPasskeyDiscoverableLoginGates(t *testing.T) {
const beginPath = "/api/v1/auth/passkey/login/discoverable/begin"
const finishPath = "/api/v1/auth/passkey/login/discoverable/finish"
const goodFinish = `{"login_id":"x","assertion":{"id":"cred-1"}}`
t.Run("local auth disabled -> 403 on both halves", func(t *testing.T) {
api := newTestAPI(newFakeRepo(), newFakeCluster()) // no LocalAuthEnabledKey: fails closed
api.Passkey = &fakePasskeyVerifier{}
eh := api.ExternalHandler()
if w := do(eh, "POST", beginPath, `{}`, jsonHeader); w.Code != http.StatusForbidden || decodeErr(t, w) != "local_auth_disabled" {
t.Errorf("begin: code = %d body %s, want 403 local_auth_disabled", w.Code, w.Body.String())
}
if w := do(eh, "POST", finishPath, goodFinish, jsonHeader); w.Code != http.StatusForbidden || decodeErr(t, w) != "local_auth_disabled" {
t.Errorf("finish: code = %d body %s, want 403 local_auth_disabled", w.Code, w.Body.String())
}
})
t.Run("no verifier wired -> 503 passkey_unavailable on both halves", func(t *testing.T) {
api, _, _ := seedDiscoverableLoginAPI(t)
api.Passkey = nil // unwire it: the degraded path must be a clean 503, not a panic
eh := api.ExternalHandler()
if w := do(eh, "POST", beginPath, `{}`, jsonHeader); w.Code != http.StatusServiceUnavailable || decodeErr(t, w) != "passkey_unavailable" {
t.Errorf("begin: code = %d body %s, want 503 passkey_unavailable", w.Code, w.Body.String())
}
if w := do(eh, "POST", finishPath, goodFinish, jsonHeader); w.Code != http.StatusServiceUnavailable || decodeErr(t, w) != "passkey_unavailable" {
t.Errorf("finish: code = %d body %s, want 503 passkey_unavailable", w.Code, w.Body.String())
}
})
t.Run("non-JSON content type -> 415 on both halves", func(t *testing.T) {
api, _, _ := seedDiscoverableLoginAPI(t)
eh := api.ExternalHandler()
for _, ct := range []string{"", "text/plain", "application/x-www-form-urlencoded"} {
if w := do(eh, "POST", beginPath, `{}`, ctHeader(ct)); w.Code != http.StatusUnsupportedMediaType {
t.Errorf("begin with Content-Type %q: code = %d, want 415", ct, w.Code)
}
if w := do(eh, "POST", finishPath, goodFinish, ctHeader(ct)); w.Code != http.StatusUnsupportedMediaType {
t.Errorf("finish with Content-Type %q: code = %d, want 415", ct, w.Code)
}
}
})
t.Run("finish missing inputs -> 400 bad_request, nothing minted", func(t *testing.T) {
cases := map[string]string{
"missing login_id": `{"assertion":{"id":"cred-1"}}`,
"empty login_id": `{"login_id":"","assertion":{"id":"cred-1"}}`,
"missing assertion": `{"login_id":"x"}`,
}
for name, body := range cases {
api, repo, _ := seedDiscoverableLoginAPI(t)
w := do(api.ExternalHandler(), "POST", finishPath, body, jsonHeader)
if w.Code != http.StatusBadRequest || decodeErr(t, w) != "bad_request" {
t.Errorf("%s: code = %d body %s, want 400 bad_request", name, w.Code, w.Body.String())
}
if len(repo.sessions) != 0 {
t.Errorf("%s: a rejected finish must mint no session (%d)", name, len(repo.sessions))
}
}
})
}
// TestPasskeyDiscoverableLoginFinishRejections is the redeem-side failure matrix and the anchor
// for from-zero anti-enumeration: a bogus handle, an expired challenge, an assertion that fails
// verification, AND a userHandle that resolves to no account must ALL answer the byte-identical
// passkey_login_invalid envelope (code AND message) and mint no session. The last case is the
// one unique to this door — the account is chosen by the authenticator, so an unresolvable
// handle must fail exactly like a bad signature, never leaking that the handle was well-formed.
func TestPasskeyDiscoverableLoginFinishRejections(t *testing.T) {
const finishPath = "/api/v1/auth/passkey/login/discoverable/finish"
finish := func(eh http.Handler, loginID string) *httptest.ResponseRecorder {
return do(eh, "POST", finishPath,
`{"login_id":"`+loginID+`","assertion":{"id":"cred-1","type":"public-key"}}`, jsonHeader)
}
cases := []struct {
name string
loginID string
setup func(repo *fakeRepo, v *fakePasskeyVerifier)
}{
{"no live challenge", "ghost", func(repo *fakeRepo, v *fakePasskeyVerifier) {}},
{"expired challenge", "ex", func(repo *fakeRepo, v *fakePasskeyVerifier) {
repo.discoverableChallenges["ex"] = &fakeDiscoverableChallenge{
sessionData: []byte("disc-session"), expiresAt: frozenNow.Add(-time.Second),
}
}},
{"assertion fails verification", "live", func(repo *fakeRepo, v *fakePasskeyVerifier) {
repo.discoverableChallenges["live"] = &fakeDiscoverableChallenge{
sessionData: []byte("disc-session"), expiresAt: frozenNow.Add(passkeyChallengeTTL),
}
v.failErr = errors.New("bad assertion")
}},
{"userHandle resolves to no account", "live", func(repo *fakeRepo, v *fakePasskeyVerifier) {
repo.discoverableChallenges["live"] = &fakeDiscoverableChallenge{
sessionData: []byte("disc-session"), expiresAt: frozenNow.Add(passkeyChallengeTTL),
}
v.discoverableUserHandle = []byte("nonexistent")
}},
}
var envelopes [][2]string
for _, c := range cases {
api, repo, v := seedDiscoverableLoginAPI(t)
c.setup(repo, v)
w := finish(api.ExternalHandler(), c.loginID)
if w.Code != http.StatusBadRequest {
t.Fatalf("%s: code = %d, want 400 (%s)", c.name, w.Code, w.Body.String())
}
code, msg := errEnvelope(t, w)
if code != "passkey_login_invalid" {
t.Errorf("%s: error code = %q, want passkey_login_invalid", c.name, code)
}
if len(repo.sessions) != 0 {
t.Errorf("%s: a rejected finish must mint no session (got %d)", c.name, len(repo.sessions))
}
if len(w.Result().Cookies()) != 0 {
t.Errorf("%s: a rejected finish must set no cookie", c.name)
}
envelopes = append(envelopes, [2]string{code, msg})
}
// The anchor: every envelope is identical (code AND message), so no branch — including the
// unresolvable-handle branch — is distinguishable from another.
for i := 1; i < len(envelopes); i++ {
if envelopes[i] != envelopes[0] {
t.Errorf("envelope for %q %v differs from %q %v — all rejections must be identical",
cases[i].name, envelopes[i], cases[0].name, envelopes[0])
}
}
}
// TestPasskeyDiscoverableLoginFaceSeparation enforces that both halves are web-only: the
// internal (service-token) face must 404 them, never serve them.
func TestPasskeyDiscoverableLoginFaceSeparation(t *testing.T) {
api, _, _ := seedDiscoverableLoginAPI(t)
ih := api.InternalHandler()
if w := do(ih, "POST", "/api/v1/auth/passkey/login/discoverable/begin", `{}`, jsonHeader); w.Code != http.StatusNotFound {
t.Errorf("begin on internal face: code = %d, want 404", w.Code)
}
if w := do(ih, "POST", "/api/v1/auth/passkey/login/discoverable/finish", `{"login_id":"x","assertion":{"id":"y"}}`, jsonHeader); w.Code != http.StatusNotFound {
t.Errorf("finish on internal face: code = %d, want 404", w.Code)
}
}
+85
View File
@@ -1004,6 +1004,91 @@ func (p *PGRepo) ConsumePasskeyChallengeByUser(ctx context.Context, userID, purp
return sessionData, nil
}
// ---- discoverable ("usernameless") passkey login (task #40, migration 0013) ----
// maxLiveDiscoverableChallenges hard-bounds the non-user-keyed discoverable-login challenge
// store. webauthn_challenges self-bounds via a per-(user,purpose) supersede; a from-zero begin
// has no such key, so the table is capped: once this many LIVE (unexpired, unconsumed) rows
// exist, a new begin is refused (ErrTooManyDiscoverableChallenges → 429). The cap is generous —
// a login challenge lives only passkeyChallengeTTL (5 min) and each row is ~1 KB — so real
// concurrency never approaches it, while an abusive begin-flood is bounded to a few MB instead
// of growing without limit. Volumetric per-IP limiting is the edge's job (handlers_auth_email.go):
// behind Cloudflare RemoteAddr is the proxy, and a usernameless door has no recipient to key a
// fair per-caller limit on.
const maxLiveDiscoverableChallenges = 4096
// CreateDiscoverableChallenge stashes a discoverable-login ceremony under an opaque handle,
// bounding the table in one transaction (see the Repo interface for the full contract). It
// reaps expired/consumed rows first — the non-user-keyed analog of CreatePasskeyChallenge's
// supersede — then refuses over the cap rather than inserting. Because the reap ran first, the
// COUNT is exactly the live-row count, so the cap bounds an adversarial begin-flood (which a
// reap alone cannot: a burst inside the TTL leaves every fresh row live).
func (p *PGRepo) CreateDiscoverableChallenge(ctx context.Context, id string, sessionData []byte, now, expiresAt time.Time) error {
tx, err := p.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback() //nolint:errcheck // no-op after commit
if _, err := tx.ExecContext(ctx,
`DELETE FROM webauthn_discoverable_challenges WHERE expires_at <= $1 OR consumed_at IS NOT NULL`,
now); err != nil {
return fmt.Errorf("reap discoverable challenges: %w", err)
}
var live int
if err := tx.QueryRowContext(ctx,
`SELECT count(*) FROM webauthn_discoverable_challenges`).Scan(&live); err != nil {
return fmt.Errorf("count discoverable challenges: %w", err)
}
if live >= maxLiveDiscoverableChallenges {
return ErrTooManyDiscoverableChallenges
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO webauthn_discoverable_challenges (id, session_data, expires_at)
VALUES ($1, $2, $3)`,
id, sessionData, expiresAt); err != nil {
return fmt.Errorf("insert discoverable challenge: %w", err)
}
return tx.Commit()
}
// ConsumeDiscoverableChallenge redeems the challenge under handle id, single-use (see the Repo
// interface for the contract). The row is taken FOR UPDATE so a concurrent finish cannot
// double-spend it; expiry is checked before consuming. No live row → ErrPasskeyChallengeInvalid.
func (p *PGRepo) ConsumeDiscoverableChallenge(ctx context.Context, id string, now time.Time) ([]byte, error) {
tx, err := p.db.BeginTx(ctx, nil)
if err != nil {
return nil, err
}
defer tx.Rollback() //nolint:errcheck // no-op after commit
var (
sessionData []byte
expiresAt time.Time
)
switch err := tx.QueryRowContext(ctx,
`SELECT session_data, expires_at FROM webauthn_discoverable_challenges
WHERE id = $1 AND consumed_at IS NULL FOR UPDATE`,
id).Scan(&sessionData, &expiresAt); {
case errors.Is(err, sql.ErrNoRows):
return nil, ErrPasskeyChallengeInvalid
case err != nil:
return nil, err
}
if !expiresAt.After(now) {
return nil, ErrPasskeyChallengeInvalid
}
if _, err := tx.ExecContext(ctx,
`UPDATE webauthn_discoverable_challenges SET consumed_at = $2 WHERE id = $1`, id, now); err != nil {
return nil, fmt.Errorf("consume discoverable challenge: %w", err)
}
if err := tx.Commit(); err != nil {
return nil, err
}
return sessionData, nil
}
// CreatePasskeyCredential stores a freshly verified passkey (enrollment). Only public
// attestation material is written; a credential_id already bound to ANY account is left
// untouched (ON CONFLICT DO NOTHING) and reported as ErrConflict via a zero RowsAffected,
+18 -2
View File
@@ -350,9 +350,25 @@ type Repo interface {
// returns the stashed SessionData so finish can validate the attestation against
// it. No live challenge → ErrPasskeyChallengeInvalid. Single-use: a second finish
// for the same ceremony finds nothing live and fails. now is the API clock so
// expiry is testable. Bound to user_id — enrollment always has a principal, so
// there is no usernameless consume-by-hash variant (login is a deferred slice).
// expiry is testable. Bound to user_id — enrollment and username-first login both
// know the principal at begin; the usernameless from-zero door instead uses the
// non-user-keyed pair below.
ConsumePasskeyChallengeByUser(ctx context.Context, userID, purpose string, now time.Time) (sessionData []byte, err error)
// CreateDiscoverableChallenge persists a DISCOVERABLE ("usernameless") login ceremony
// (task #40, migration 0013), keyed by an opaque server-minted handle id — NOT a user,
// since a from-zero begin has no principal. In one transaction it reaps expired/consumed
// rows (the non-user-keyed analog of CreatePasskeyChallenge's supersede) and then, if the
// live count is at the hard cap, refuses with ErrTooManyDiscoverableChallenges rather than
// inserting — the cap, not the reap, bounds an adversarial begin-flood, since a burst
// inside the TTL leaves every fresh row live. now and expiresAt are both the API clock
// (now drives the reap; expiresAt = now + TTL drives liveness).
CreateDiscoverableChallenge(ctx context.Context, id string, sessionData []byte, now, expiresAt time.Time) error
// ConsumeDiscoverableChallenge redeems the discoverable challenge under handle id,
// atomically and single-use (mirrors ConsumePasskeyChallengeByUser without the user key):
// it takes the row FOR UPDATE, checks expiry against now, stamps consumed_at, and returns
// the stashed SessionData. An unknown, expired, or already-consumed handle →
// ErrPasskeyChallengeInvalid, so the finish door never doubles as a state oracle.
ConsumeDiscoverableChallenge(ctx context.Context, id string, now time.Time) (sessionData []byte, err error)
// CreatePasskeyCredential stores a freshly verified passkey for a user (Phase 6
// enrollment). It writes only public attestation material (credential_id,
// public_key, sign_count, aaguid) plus the caller's nickname. A credential_id
+86 -9
View File
@@ -6,15 +6,19 @@
// this package imports api for the seam types; api never imports this package, which is
// what keeps the seam (and the api test suite's fake verifier) honest.
//
// Scope: the full WebAuthn ceremony crypto — both the credential-creation (enrollment:
// BeginRegistration/FinishRegistration over go-webauthn's BeginRegistration/CreateCredential)
// and the assertion (login: BeginLogin/FinishLogin over BeginLogin/ValidateLogin) halves.
// Both are Oracle-verified in verifier_test.go against a virtual authenticator. Only the
// enrollment half is wired to HTTP handlers today (handlers_passkey.go); the login
// handlers, session minting, and the panel.* relying-party boundary are a deferred slice,
// so BeginLogin/FinishLogin here have no api-package caller yet. They are added to the
// concrete adapter (not the api.PasskeyVerifier interface) precisely so the crypto is
// built and verified now while the interface grows only when a handler consumes it.
// Scope: the full WebAuthn ceremony crypto across three halves, each Oracle-verified in
// verifier_test.go against a virtual authenticator:
//
// - enrollment (BeginRegistration/FinishRegistration over go-webauthn's
// BeginRegistration/CreateCredential),
// - username-first login (BeginLogin/FinishLogin over BeginLogin/ValidateLogin), where the
// account is known and its bound credentials scope allowCredentials, and
// - discoverable, "usernameless" login (BeginDiscoverableLogin/FinishDiscoverableLogin over
// BeginDiscoverableLogin/ValidateDiscoverableLogin), where the account is unknown at begin
// and revealed only by the userHandle inside the signed assertion (task #40).
//
// All three are in the api.PasskeyVerifier interface and consumed by handlers today (enrollment
// + login in handlers_passkey.go, from-zero login in handlers_passkey_discoverable.go).
package passkey
import (
@@ -68,6 +72,17 @@ func New(rpID, displayName string, origins []string) (*Verifier, error) {
// email-OTP factor (migration 0004); no one is locked out.
AuthenticatorSelection: protocol.AuthenticatorSelection{
UserVerification: protocol.VerificationRequired,
// Prefer a discoverable (resident) credential so a passkey can later be asserted
// usernamelessly (task #40 from-zero login): the authenticator stores the credential
// and can present it with no identifier typed. PREFERRED, not Required, keeps the
// no-lockout ethos — an authenticator that cannot make a resident key still binds a
// working username-first passkey (BeginLogin) and falls back to email-OTP; only the
// from-zero convenience is unavailable. This shapes only the creation options a browser
// receives (a server-side request, asserted in TestEnrollmentRequestsResidentKey);
// whether a real authenticator honors it — actually storing a resident key — is a device
// property no unit test can prove, so already-bound non-resident credentials stay
// username-first until their owner enrolls a new passkey.
ResidentKey: protocol.ResidentKeyRequirementPreferred,
},
})
if err != nil {
@@ -200,6 +215,68 @@ func (v *Verifier) FinishLogin(user api.PasskeyUser, sessionData []byte, asserti
}, nil
}
// BeginDiscoverableLogin starts a USERNAMELESS assertion ceremony (task #40): the caller is
// not yet identified, so — unlike BeginLogin — there is no user and no allowCredentials. The
// authenticator picks a resident (discoverable) credential it holds for this RP and reveals
// the account only inside the signed response at finish. It returns the {"publicKey": {...}}
// request options for navigator.credentials.get() and the opaque, marshaled SessionData the
// handler stashes under an opaque handle (migration 0013's non-user-keyed store) and replays
// at finish. User verification is required, matching enrollment, so a from-zero login still
// proves possession AND user.
func (v *Verifier) BeginDiscoverableLogin() (json.RawMessage, []byte, error) {
assertion, session, err := v.wa.BeginDiscoverableLogin(webauthn.WithUserVerification(protocol.VerificationRequired))
if err != nil {
return nil, nil, err
}
// CredentialAssertion marshals to {"publicKey": {...}} with an EMPTY allowCredentials —
// exactly the usernameless document the browser hands to navigator.credentials.get().
options, err := json.Marshal(assertion)
if err != nil {
return nil, nil, err
}
// As with the other ceremonies, we stash the marshaled SessionData verbatim and let the
// challenge row's TTL be the sole authority on liveness (no expiry inside SessionData).
sessionData, err := json.Marshal(session)
if err != nil {
return nil, nil, err
}
return options, sessionData, nil
}
// FinishDiscoverableLogin verifies a usernameless assertion (task #40). go-webauthn hands the
// authenticator-revealed user handle to resolveUser, which the caller uses to load the account
// and its bound credentials WITHOUT any client-supplied identifier; go-webauthn then checks
// the asserted credential id is one that user holds and verifies the signature against its
// stored COSE public key. The user handle is the account's stable id (webauthnUser.WebAuthnID),
// so resolveUser is a direct id lookup. A resolveUser error (unknown handle) fails the ceremony
// closed. resolveUser is a plain api-typed callback so the api package still never imports
// go-webauthn: the adapter wraps it into go-webauthn's DiscoverableUserHandler here.
func (v *Verifier) FinishDiscoverableLogin(resolveUser func(userHandle []byte) (api.PasskeyUser, error), sessionData []byte, assertion io.Reader) (api.VerifiedAssertion, error) {
var session webauthn.SessionData
if err := json.Unmarshal(sessionData, &session); err != nil {
return api.VerifiedAssertion{}, err
}
parsed, err := protocol.ParseCredentialRequestResponseBody(assertion)
if err != nil {
return api.VerifiedAssertion{}, err
}
handler := func(_, userHandle []byte) (webauthn.User, error) {
u, err := resolveUser(userHandle)
if err != nil {
return nil, err
}
return webauthnUser{u: u}, nil
}
cred, err := v.wa.ValidateDiscoverableLogin(handler, session, parsed)
if err != nil {
return api.VerifiedAssertion{}, err
}
return api.VerifiedAssertion{
CredentialID: base64.RawURLEncoding.EncodeToString(cred.ID),
SignCount: cred.Authenticator.SignCount,
}, nil
}
// excludeDescriptors turns the principal's already-bound passkeys into the
// excludeCredentials list for a creation ceremony. A stored credential id that does not
// decode as base64url is skipped rather than aborting the whole ceremony — a single
+177
View File
@@ -2,6 +2,7 @@ package passkey
import (
"encoding/base64"
"encoding/json"
"slices"
"strings"
"testing"
@@ -333,3 +334,179 @@ func TestLoginUnknownCredentialRejected(t *testing.T) {
t.Fatal("FinishLogin accepted an assertion from an unbound credential; want rejection")
}
}
// TestDiscoverableLoginRoundTrip is the PARITY check for the usernameless (from-zero) half
// (task #40), and the proof its VERIFY path is real crypto rather than a stub. It differs from
// TestLoginRoundTrip in the two ways that define discoverable login: the begin names no user
// (so the request's allowCredentials must be EMPTY), and the account is revealed only by the
// userHandle the authenticator embeds in the signed assertion — the verifier hands that handle
// to a resolve callback that stands in for the handler's userHandle → UserByID lookup. Chained
// onto a REAL enrollment so the assertion validates against a genuine COSE key, and the
// authenticator's counter is advanced first so the surfaced SignCount is proven real, not a
// hardcoded 0. What this does NOT prove: that a real authenticator actually STORED a resident
// key — that residency is a device property (see TestEnrollmentRequestsResidentKey for the only
// thing a unit test can pin, the request the browser receives).
func TestDiscoverableLoginRoundTrip(t *testing.T) {
v := newTestVerifier(t)
rp := virtualRP()
authenticator := virtualwebauthn.NewAuthenticator()
cred := virtualwebauthn.NewCredential(virtualwebauthn.KeyTypeEC2)
stored := enrollCredential(t, v, rp, authenticator, cred)
// The authenticator returns the user handle in the assertion — this is what a resident
// credential does and what lets the account be resolved from nothing typed. It is the
// account's stable user id (WebAuthnID), so the resolver must receive exactly these bytes.
authenticator.Options.UserHandle = []byte(testUserID)
// Advance the counter so a real (non-zero, strictly increasing) SignCount must survive.
cred.Counter = 9
options, sessionData, err := v.BeginDiscoverableLogin()
if err != nil {
t.Fatalf("BeginDiscoverableLogin: %v", err)
}
assertionOpts, err := virtualwebauthn.ParseAssertionOptions(string(options))
if err != nil {
t.Fatalf("ParseAssertionOptions: %v (options=%s)", err, options)
}
if assertionOpts.RelyingPartyID != testRPID {
t.Fatalf("options RP id = %q, want %q", assertionOpts.RelyingPartyID, testRPID)
}
// The defining property of a usernameless request: no credential is named. If this were
// non-empty the ceremony would be username-first and the test would prove nothing about #40.
if len(assertionOpts.AllowCredentials) != 0 {
t.Fatalf("allowCredentials = %v, want empty (usernameless request names no credential)", assertionOpts.AllowCredentials)
}
assertionResponse := virtualwebauthn.CreateAssertionResponse(rp, authenticator, cred, *assertionOpts)
// resolve stands in for the handler's userHandle → UserByID lookup: it records the handle
// it was handed (to prove the account is revealed by the authenticator, not the client) and
// returns the stored credential so ValidateDiscoverableLogin can verify the signature.
var gotHandle []byte
resolve := func(userHandle []byte) (api.PasskeyUser, error) {
gotHandle = userHandle
return testUser(stored), nil
}
va, err := v.FinishDiscoverableLogin(resolve, sessionData, strings.NewReader(assertionResponse))
if err != nil {
t.Fatalf("FinishDiscoverableLogin: %v", err)
}
if string(gotHandle) != testUserID {
t.Errorf("resolver received userHandle %q, want %q (the account is revealed by the assertion)", gotHandle, testUserID)
}
if va.CredentialID != stored.CredentialID {
t.Errorf("asserted CredentialID = %q, want %q", va.CredentialID, stored.CredentialID)
}
if va.SignCount != 9 {
t.Errorf("SignCount = %d, want 9 (the authenticator's advanced counter)", va.SignCount)
}
}
// TestDiscoverableLoginResolveFailsClosed proves the from-zero door fails CLOSED when the
// authenticator-revealed account cannot be resolved: a resolve callback that returns an error
// (the handler's UserByID found nothing — a handle for a deleted/unknown account) must abort
// the ceremony, never mint an assertion. Without this the usernameless path could be coaxed
// into treating an unresolvable handle as success. Pairs with the round-trip above so the
// resolver neither over- nor under-blocks.
func TestDiscoverableLoginResolveFailsClosed(t *testing.T) {
v := newTestVerifier(t)
rp := virtualRP()
authenticator := virtualwebauthn.NewAuthenticator()
cred := virtualwebauthn.NewCredential(virtualwebauthn.KeyTypeEC2)
enrollCredential(t, v, rp, authenticator, cred)
authenticator.Options.UserHandle = []byte("nonexistent-account")
options, sessionData, err := v.BeginDiscoverableLogin()
if err != nil {
t.Fatalf("BeginDiscoverableLogin: %v", err)
}
assertionOpts, err := virtualwebauthn.ParseAssertionOptions(string(options))
if err != nil {
t.Fatalf("ParseAssertionOptions: %v", err)
}
assertionResponse := virtualwebauthn.CreateAssertionResponse(rp, authenticator, cred, *assertionOpts)
resolve := func(userHandle []byte) (api.PasskeyUser, error) {
return api.PasskeyUser{}, api.ErrNotFound
}
if _, err := v.FinishDiscoverableLogin(resolve, sessionData, strings.NewReader(assertionResponse)); err == nil {
t.Fatal("FinishDiscoverableLogin accepted an assertion whose account could not be resolved; want rejection")
}
}
// TestDiscoverableLoginUnboundCredentialRejected proves the credential-ownership binding for
// the usernameless door — the defense unique to it. In username-first login the server names
// allowCredentials, so an assertion must match a credential the server itself offered. The
// from-zero door names NOTHING: the authenticator reveals BOTH the userHandle and the signing
// credential, so the ONLY barrier stopping an attacker from signing with their own resident key
// while embedding a victim's userHandle is go-webauthn's check that the asserted credential id
// belongs to the resolved user. Here the resolve callback succeeds (the handle names a REAL
// account, u1, holding credential A) — unlike TestDiscoverableLoginResolveFailsClosed where it
// resolves to nothing — but the assertion is signed by credential B, never bound to u1.
// FinishDiscoverableLogin must reject: a good signature over the right challenge under a valid
// userHandle is still not enough without membership. This is the exact guard the "account is
// revealed by the assertion, never named by the client" claim leans on.
func TestDiscoverableLoginUnboundCredentialRejected(t *testing.T) {
v := newTestVerifier(t)
rp := virtualRP()
authenticator := virtualwebauthn.NewAuthenticator()
credA := virtualwebauthn.NewCredential(virtualwebauthn.KeyTypeEC2)
stored := enrollCredential(t, v, rp, authenticator, credA)
// A valid handle: it resolves to the real account u1, which holds credential A.
authenticator.Options.UserHandle = []byte(testUserID)
options, sessionData, err := v.BeginDiscoverableLogin()
if err != nil {
t.Fatalf("BeginDiscoverableLogin: %v", err)
}
assertionOpts, err := virtualwebauthn.ParseAssertionOptions(string(options))
if err != nil {
t.Fatalf("ParseAssertionOptions: %v", err)
}
// Sign with a fresh credential never bound to u1. The resolver still returns u1's real
// credential set (credential A) — so the ONLY thing that can reject this is the check that
// the asserted credential (B) is among the resolved user's credentials.
credB := virtualwebauthn.NewCredential(virtualwebauthn.KeyTypeEC2)
assertionResponse := virtualwebauthn.CreateAssertionResponse(rp, authenticator, credB, *assertionOpts)
resolve := func(userHandle []byte) (api.PasskeyUser, error) {
return testUser(stored), nil
}
if _, err := v.FinishDiscoverableLogin(resolve, sessionData, strings.NewReader(assertionResponse)); err == nil {
t.Fatal("FinishDiscoverableLogin accepted an assertion signed by a credential not bound to the resolved user; want rejection")
}
}
// TestEnrollmentRequestsResidentKey pins the ONLY server-side half of the from-zero enabler a
// unit test can prove: that enrollment ASKS the browser for a resident (discoverable) key, i.e.
// the creation options carry authenticatorSelection.residentKey = "preferred". Whether a real
// authenticator honors the request — actually persisting a resident key so it can later be
// asserted usernamelessly — is a device property no unit test can reach, which is exactly why
// the from-zero door is inert for a credential until its owner enrolls a NEW passkey against
// these options. "preferred" (not "required") is deliberate: an authenticator that cannot store
// a resident key still binds a working username-first passkey and falls back to email-OTP, so
// no one is locked out — asserting the exact string guards against a silent drop to "" (ask for
// nothing) or a tightening to "required" (which would break the no-lockout ethos).
func TestEnrollmentRequestsResidentKey(t *testing.T) {
v := newTestVerifier(t)
options, _, err := v.BeginRegistration(testUser())
if err != nil {
t.Fatalf("BeginRegistration: %v", err)
}
var doc struct {
PublicKey struct {
AuthenticatorSelection struct {
ResidentKey string `json:"residentKey"`
} `json:"authenticatorSelection"`
} `json:"publicKey"`
}
if err := json.Unmarshal(options, &doc); err != nil {
t.Fatalf("unmarshal creation options: %v (options=%s)", err, options)
}
if got := doc.PublicKey.AuthenticatorSelection.ResidentKey; got != "preferred" {
t.Errorf("authenticatorSelection.residentKey = %q, want %q", got, "preferred")
}
}
@@ -0,0 +1,36 @@
-- 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);