feat(api): add passkey enrollment endpoints

Phase 6 WebAuthn bind, enrollment-only slice (spec section 14), web app face.
An already-authenticated principal binds a passkey to their own account and
manages the credentials they have bound; email-OTP stays the fallback factor.

- four account routes: POST register/begin mints a credential-creation
  challenge, POST register/finish verifies the attestation against the
  server-stashed SessionData and binds the credential, GET/DELETE credentials
  list and unbind the caller's OWN passkeys. App-tier, principal-scoped (the
  body never names a user).
- PasskeyVerifier seam keeps go-webauthn out of this package: ceremony state
  crosses as opaque bytes, attestation as an io.Reader, result as a plain
  VerifiedCredential. A nil verifier makes begin/finish report 503 so the
  authenticated boundary is exercised before the real verifier is wired in.
- the view never leaks the public key; credential_id collisions map to 409.
- OpenAPI: the four paths plus the PasskeyCredential schema, keeping the
  served-routes parity gate green.

Scope: ENROLLMENT only. The passkey login/assertion path (proving a passkey
from an unauthenticated state) is deferred; every ceremony here rides on a
known principal.

Tests: handler + challenge state machine against a fake repo and a fake
verifier (no real attestation crypto, no SQL). The decisive assertion is the
session-data round-trip -- the finish body carries no challenge, so the only
path for the stashed blob into FinishRegistration is store-stash then consume,
proving the challenge is server-held and never client-echoed. Also covers
supersede-on-begin, single-use, expiry, 503-unavailable, 409-already-bound,
owner-scoped list/delete, and external-only face separation.
This commit is contained in:
flyemoji committed 2026-07-01 02:14:29 +09:00
1 parent f2c916d378
commit 742f15f348
5 files changed
+926

No files matched your search

+152
View File
@@ -167,6 +167,22 @@ components:
type: string
description: Correlates the response with server logs (withRequestID middleware).
PasskeyCredential:
type: object
description: >
Display projection of one bound passkey (internal/api/handlers_passkey.go
passkeyCredentialView). Carries no secret — the public key is never returned.
required: [id, name, created_at]
properties:
id: { type: string, description: Opaque passkey row id (used to unbind it). }
name: { type: string, description: Caller-supplied nickname; empty if none. }
aaguid: { type: string, description: Authenticator model id, present only when known. }
created_at: { type: string, format: date-time }
last_used_at:
type: string
format: date-time
description: Present only once an assertion is verified (deferred login path).
Phase:
type: string
description: MinecraftServer lifecycle phase (internal/apis/felis/v1alpha1).
@@ -1649,6 +1665,142 @@ paths:
application/json:
schema: { $ref: '#/components/schemas/Error' }
/api/v1/account/passkey/register/begin:
post:
tags: [account]
operationId: passkeyRegisterBegin
summary: Begin a passkey (WebAuthn) registration ceremony for the caller (spec §14, Phase 6 bind).
description: >
Mints a credential-creation challenge bound to the authenticated principal,
stashes the server-side ceremony state under a short TTL, and returns the
WebAuthn publicKey creation options for navigator.credentials.create(). The
challenge is never echoed by the client. Enrollment only — passkey login is a
deferred slice. 503 when the WebAuthn verifier is not configured on this instance.
x-felis-face: [external]
x-felis-tier: app
security: [{ accessJWT: [] }]
responses:
'200':
description: "WebAuthn credential-creation options (the publicKey document)."
content:
application/json:
schema:
type: object
description: Opaque WebAuthn PublicKeyCredentialCreationOptions, passed verbatim to the browser.
'401':
$ref: '#/components/responses/Unauthorized'
'503':
description: Passkey subsystem is not configured.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
/api/v1/account/passkey/register/finish:
post:
tags: [account]
operationId: passkeyRegisterFinish
summary: Finish a passkey registration ceremony and bind the credential (spec §14, Phase 6 bind).
description: >
Consumes the caller's live registration challenge (single-use), verifies the
authenticator's attestation against the server-stashed ceremony state, and
persists the public credential. A missing or expired ceremony is a 400; an
attestation that fails verification is a 400; a credential already bound to any
account is a 409. 503 when the WebAuthn verifier is not configured.
x-felis-face: [external]
x-felis-tier: app
security: [{ accessJWT: [] }]
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [attestation]
properties:
name: { type: string, description: Human nickname for the passkey (e.g. "My phone"). }
attestation:
type: object
description: The raw navigator.credentials.create() result the browser posts back.
responses:
'201':
description: Passkey bound.
content:
application/json:
schema: { $ref: '#/components/schemas/PasskeyCredential' }
'400':
description: No live ceremony, or the attestation could not be verified.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'401':
$ref: '#/components/responses/Unauthorized'
'409':
description: This passkey is already bound to an account.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'503':
description: Passkey subsystem is not configured.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
/api/v1/account/passkey/credentials:
get:
tags: [account]
operationId: passkeyList
summary: List the passkeys the caller has bound (spec §14, Phase 6 bind).
description: >
Returns the authenticated principal's own bound passkeys, newest first, as
display projections (never the public key). Reading the credential list does
not need the WebAuthn verifier, so it succeeds even where begin/finish report 503.
x-felis-face: [external]
x-felis-tier: app
security: [{ accessJWT: [] }]
responses:
'200':
description: The caller's bound passkeys.
content:
application/json:
schema:
type: object
required: [credentials]
properties:
credentials:
type: array
items: { $ref: '#/components/schemas/PasskeyCredential' }
'401':
$ref: '#/components/responses/Unauthorized'
/api/v1/account/passkey/credentials/{id}:
delete:
tags: [account]
operationId: passkeyDelete
summary: Unbind one of the caller's passkeys (spec §14, Phase 6 bind).
description: >
Removes a passkey scoped to the authenticated principal, so a caller can only
unbind their OWN credential. An unknown or cross-user id is a 404; it never
silently no-ops as success.
x-felis-face: [external]
x-felis-tier: app
security: [{ accessJWT: [] }]
parameters:
- name: id
in: path
required: true
schema: { type: string }
description: The passkey row id (from the credential list).
responses:
'204':
description: Passkey unbound.
'401':
$ref: '#/components/responses/Unauthorized'
'404':
description: No such passkey for this caller.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
/api/v1/me/submissions:
post:
tags: [submissions]
+21
View File
@@ -73,6 +73,15 @@ type API struct {
// sender. The code is never returned to the client on either path.
Mailer OTPMailer
// Passkey verifies WebAuthn credential-creation ceremonies (spec §14 / Phase 6
// passkey bind). It is optional: when nil the passkey register routes report 503
// rather than panic, so the authenticated enrollment boundary is exercised before
// the go-webauthn verifier is wired in (cmd/felis). The credential-management
// reads/deletes do not need it (they read the Repo), only the begin/finish
// ceremony. Tests inject a fake verifier so the enrollment state machine is
// exercised without real attestation crypto.
Passkey PasskeyVerifier
// RootDomain is injected from config (spec §2). It is the only place the
// deployment zone enters the API; hostnames are validated against it and
// never hardcoded.
@@ -268,6 +277,18 @@ func (a *API) externalAPIRoutes() []apiRoute {
// email is an ordinary authenticated operation, scoped to the principal.
{Method: "POST", Pattern: "/api/v1/account/email/start", h: a.handleEmailOTPStart},
{Method: "POST", Pattern: "/api/v1/account/email/verify", h: a.handleEmailOTPVerify},
// Passkey enrollment (spec §14 WebAuthn / Phase 6 bind), web side: /register/begin
// mints a credential-creation challenge for the caller, /register/finish verifies
// the authenticator's attestation and binds the passkey, and the credentials
// collection lists and unbinds the caller's OWN passkeys. App-tier like the email
// routes — binding a passkey to your own account is an ordinary authenticated
// operation, scoped entirely to the principal (the body never names a user). This
// is enrollment only; passkey LOGIN/assertion is a deferred slice (see migration
// 0007 and handlers_passkey.go).
{Method: "POST", Pattern: "/api/v1/account/passkey/register/begin", h: a.handlePasskeyRegisterBegin},
{Method: "POST", Pattern: "/api/v1/account/passkey/register/finish", h: a.handlePasskeyRegisterFinish},
{Method: "GET", Pattern: "/api/v1/account/passkey/credentials", h: a.handlePasskeyList},
{Method: "DELETE", Pattern: "/api/v1/account/passkey/credentials/{id}", h: a.handlePasskeyDelete},
// Modpack submission (user-directed lane over §16), user side: a user files an upload for review
// and lists their own. App-tier — the submitter and the "my uploads" scope are
// both taken from the principal, never the body, so an ordinary authenticated
+131
View File
@@ -4,8 +4,10 @@ import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"sort"
"strings"
"testing"
"time"
@@ -61,6 +63,26 @@ type fakeRepo struct {
// the same all-or-nothing contract the PG transaction enforces.
blacklist map[string]bool
holds map[string]fakeDataHold
// player passkey enrollment (spec §14 / Phase 6). passkeyCreds is keyed by row id
// and mirrors webauthn_credentials (the credential_id UNIQUE guard is enforced in
// CreatePasskeyCredential); passkeyChallenges is keyed by row id and mirrors
// webauthn_challenges, so the consume path scans the newest live (user, purpose)
// just as the PG query does.
passkeyCreds map[string]PasskeyCredential
passkeyChallenges map[string]*fakePasskeyChallenge
}
// fakePasskeyChallenge mirrors a webauthn_challenges row: its owner and purpose, the
// opaque stashed SessionData, single-use via consumed, and createdAt to order the
// newest-live lookup the consume path performs.
type fakePasskeyChallenge struct {
id string
userID string
purpose string
sessionData []byte
expiresAt time.Time
consumed bool
createdAt time.Time
}
// fakeDataHold mirrors a player_data_holds row at the granularity the verifiable
@@ -127,6 +149,8 @@ func newFakeRepo() *fakeRepo {
otps: map[string]*fakeEmailOTP{},
blacklist: map[string]bool{},
holds: map[string]fakeDataHold{},
passkeyCreds: map[string]PasskeyCredential{},
passkeyChallenges: map[string]*fakePasskeyChallenge{},
}
}
@@ -218,6 +242,113 @@ func (f *fakeRepo) VerifyEmailOTP(_ context.Context, userID, purpose, codeHash s
}
return live.email, nil
}
// CreatePasskeyChallenge / ConsumePasskeyChallengeByUser mirror PGRepo's contract so
// the hermetic tests exercise the same semantics: a fresh begin supersedes the prior
// live challenge for (user, purpose), and the consume path redeems the newest live one
// (expiry checked before consuming), single-use.
func (f *fakeRepo) CreatePasskeyChallenge(_ context.Context, id, userID, purpose string, sessionData []byte, expiresAt time.Time) error {
for k, c := range f.passkeyChallenges { // supersede prior live (DELETE ... consumed_at IS NULL)
if c.userID == userID && c.purpose == purpose && !c.consumed {
delete(f.passkeyChallenges, k)
}
}
f.passkeyChallenges[id] = &fakePasskeyChallenge{
id: id, userID: userID, purpose: purpose, sessionData: sessionData,
expiresAt: expiresAt, createdAt: expiresAt, // createdAt proxy: constant TTL ⇒ later expiry == later creation
}
return nil
}
func (f *fakeRepo) ConsumePasskeyChallengeByUser(_ context.Context, userID, purpose string, now time.Time) ([]byte, error) {
var live *fakePasskeyChallenge
for _, c := range f.passkeyChallenges { // newest live (user, purpose)
if c.userID != userID || c.purpose != purpose || c.consumed {
continue
}
if live == nil || c.createdAt.After(live.createdAt) {
live = c
}
}
if live == nil || !live.expiresAt.After(now) {
return nil, ErrPasskeyChallengeInvalid
}
live.consumed = true
return live.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 {
for _, ex := range f.passkeyCreds {
if ex.CredentialID == c.CredentialID {
return ErrConflict
}
}
f.passkeyCreds[c.ID] = c
return nil
}
// PasskeyCredentialsForUser mirrors PGRepo: the user's own passkeys, newest first.
// CreatedAt orders the list; id is a deterministic tie-break for the frozen test clock
// (the PG ORDER BY is created_at DESC; same-instant rows are simply stable here).
func (f *fakeRepo) PasskeyCredentialsForUser(_ context.Context, userID string) ([]PasskeyCredential, error) {
var out []PasskeyCredential
for _, c := range f.passkeyCreds {
if c.UserID == userID {
out = append(out, c)
}
}
sort.Slice(out, func(i, j int) bool {
if out[i].CreatedAt.Equal(out[j].CreatedAt) {
return out[i].ID > out[j].ID
}
return out[i].CreatedAt.After(out[j].CreatedAt)
})
return out, nil
}
// DeletePasskeyCredential mirrors PGRepo: scoped to userID so a caller can only unbind
// their OWN credential; no matching (user, id) row → ErrNotFound.
func (f *fakeRepo) DeletePasskeyCredential(_ context.Context, userID, id string) error {
if c, ok := f.passkeyCreds[id]; ok && c.UserID == userID {
delete(f.passkeyCreds, id)
return nil
}
return ErrNotFound
}
// fakePasskeyVerifier is the hermetic PasskeyVerifier: it performs no real attestation
// crypto, so it exercises the enrollment STATE MACHINE (challenge persistence, consume,
// conflict, audit) without go-webauthn. BeginRegistration returns a fixed options blob
// and an opaque session marker; FinishRegistration returns the credential the test
// preloaded, or a forced error when failErr is set (to drive the 400 path).
type fakePasskeyVerifier struct {
options json.RawMessage
credential VerifiedCredential
failErr error
// lastUser/lastSession capture what the handler passed, so a test can assert the
// stashed SessionData round-trips and the existing credentials reach the verifier.
lastUser PasskeyUser
lastSession []byte
}
func (v *fakePasskeyVerifier) BeginRegistration(user PasskeyUser) (json.RawMessage, []byte, error) {
v.lastUser = user
opts := v.options
if opts == nil {
opts = json.RawMessage(`{"publicKey":{"challenge":"ZmFrZQ"}}`)
}
return opts, []byte("session:" + user.ID), nil
}
func (v *fakePasskeyVerifier) FinishRegistration(user PasskeyUser, sessionData []byte, _ io.Reader) (VerifiedCredential, error) {
v.lastUser = user
v.lastSession = sessionData
if v.failErr != nil {
return VerifiedCredential{}, v.failErr
}
return v.credential, nil
}
func (f *fakeRepo) UserInAllowlist(_ context.Context, n, u string) (bool, error) {
return f.allowlist[n][u], nil
}
+278
View File
@@ -0,0 +1,278 @@
package api
import (
"bytes"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"io"
"net/http"
"time"
)
// Passkey enrollment (spec §14 WebAuthn / Phase 6 bind). An already-authenticated
// principal binds a passkey to their account — the WebAuthn credential-creation
// ceremony — and manages the credentials they have bound. Email-OTP (handlers_email_otp.go)
// stays the fallback factor, so a player with no passkey is never locked out.
//
// Scope: ENROLLMENT only. The login/assertion path (proving a passkey to mint or
// elevate a session from an unauthenticated state) is a deferred slice — the panel.*
// passkey relying-party boundary is a later decision (see migration 0007). So every
// ceremony here rides on a known principal: the challenge is bound to the caller's
// user_id and the finish verifies against the server-stashed SessionData, never a
// client-echoed challenge.
//
// The cryptographic half is a seam (PasskeyVerifier) so this package never imports
// go-webauthn: ceremony state crosses the boundary as opaque bytes, the attestation
// as an io.Reader, and the verified result as a plain VerifiedCredential. Production
// wires the real go-webauthn verifier (cmd/felis); a nil verifier makes the begin and
// finish routes report 503 (the authenticated enrollment boundary is still exercised),
// and tests inject a fake so the state machine runs without real attestation crypto.
const (
// passkeyChallengeTTL bounds how long a freshly minted credential-creation
// challenge is accepted. The ceremony is interactive (a user taps an
// authenticator), so a few minutes is ample; a shorter window shrinks the gap in
// which a stashed challenge is live.
passkeyChallengeTTL = 5 * time.Minute
// passkeyPurposeRegister scopes a challenge to the enrollment (credential-creation)
// flow. The purpose column exists so a later assertion/login flow can mint
// challenges that never collide with an enrollment challenge for the same user.
passkeyPurposeRegister = "passkey_register"
)
// PasskeyVerifier performs the cryptographic half of a WebAuthn credential-creation
// ceremony. It is a seam so the api package stays free of go-webauthn types: the real
// implementation (cmd/felis) wraps github.com/go-webauthn/webauthn, while tests inject
// a fake. All ceremony state crosses the seam as opaque bytes — the marshaled
// SessionData the server stashes between begin and finish — so the handler persists it
// without understanding it.
type PasskeyVerifier interface {
// BeginRegistration starts a credential-creation ceremony for user. It returns the
// publicKey creation options to hand to the browser's navigator.credentials.create()
// AND the opaque sessionData the server must stash and replay at finish.
// user.Credentials carries the passkeys already bound so the authenticator can be
// told to exclude them (no double-binding one device).
BeginRegistration(user PasskeyUser) (options json.RawMessage, sessionData []byte, err error)
// FinishRegistration verifies the authenticator's attestation response against the
// stashed sessionData and returns the credential to persist. attestation is the raw
// navigator.credentials.create() result the browser posts back; sessionData is the
// blob BeginRegistration returned. A failed verification returns a non-nil error;
// the handler maps it to 400 (the ceremony state exists; the attestation is bad).
FinishRegistration(user PasskeyUser, sessionData []byte, attestation io.Reader) (VerifiedCredential, error)
}
// PasskeyUser is the relying-party view of the enrolling principal the verifier needs:
// a stable user handle (ID), the names an authenticator shows the human, and the
// passkeys already bound (so the ceremony can exclude them). It is a plain value so the
// api package stays free of go-webauthn types; the real verifier adapts it to a
// webauthn.User.
type PasskeyUser struct {
ID string
Name string
DisplayName string
Credentials []PasskeyCredential
}
// VerifiedCredential is the public, persist-ready output of a finished registration
// ceremony — the material CreatePasskeyCredential stores. It carries no secret: a
// WebAuthn public key is public by design, so it is safe at rest.
type VerifiedCredential struct {
CredentialID string // base64url(raw credential id)
PublicKey string // base64(COSE public key bytes)
SignCount uint32
AAGUID string
}
// errPasskeyUnavailable is returned when the WebAuthn verifier is not configured on
// this api instance, so the begin/finish ceremony routes answer 503 rather than panic.
var errPasskeyUnavailable = newError(http.StatusServiceUnavailable, "passkey_unavailable",
"passkey subsystem is not configured")
// newPasskeyID returns an opaque random row id (128 bits, hex) for a passkey row.
func newPasskeyID() (string, error) {
var b [16]byte
if _, err := rand.Read(b[:]); err != nil {
return "", err
}
return hex.EncodeToString(b[:]), nil
}
// passkeyUserFor builds the relying-party view of a principal for the verifier. The
// human-facing names fall back to the user id when no email is bound yet (a player
// mid-onboarding), so the authenticator always shows a stable, non-empty label.
func passkeyUserFor(p *Principal, creds []PasskeyCredential) PasskeyUser {
label := auditActor(p)
return PasskeyUser{ID: p.UserID, Name: label, DisplayName: label, Credentials: creds}
}
// handlePasskeyRegisterBegin mints a credential-creation challenge for the caller
// (spec §14, external app face). It loads the passkeys the caller has already bound so
// the ceremony excludes them (one authenticator binds once), asks the verifier for the
// creation options + opaque SessionData, stashes the SessionData under a short TTL, and
// returns the options verbatim for navigator.credentials.create(). The challenge never
// leaves the server in a forgeable form — only the publicKey options the browser needs.
func (a *API) handlePasskeyRegisterBegin(w http.ResponseWriter, r *http.Request) {
if a.Passkey == nil {
writeError(w, r, errPasskeyUnavailable)
return
}
p := principalFromContext(r.Context())
creds, err := a.Repo.PasskeyCredentialsForUser(r.Context(), p.UserID)
if err != nil {
writeError(w, r, err)
return
}
options, sessionData, err := a.Passkey.BeginRegistration(passkeyUserFor(p, creds))
if err != nil {
writeError(w, r, err)
return
}
id, err := newPasskeyID()
if err != nil {
writeError(w, r, err)
return
}
expiresAt := a.now().Add(passkeyChallengeTTL)
if err := a.Repo.CreatePasskeyChallenge(r.Context(), id, p.UserID, passkeyPurposeRegister, sessionData, expiresAt); err != nil {
writeError(w, r, err)
return
}
// The creation options are the WebAuthn {"publicKey": {...}} document the browser
// passes straight to navigator.credentials.create(); return them verbatim.
writeJSON(w, http.StatusOK, options)
}
// passkeyFinishRequest is the finish body: the human nickname for the new passkey and
// the raw navigator.credentials.create() attestation response. Attestation is captured
// as RawMessage so the handler hands the exact bytes the browser produced to the
// verifier without re-encoding (a re-marshal could perturb the signed payload).
type passkeyFinishRequest struct {
Name string `json:"name"`
Attestation json.RawMessage `json:"attestation"`
}
// handlePasskeyRegisterFinish verifies an attestation and binds the passkey (spec §14,
// external app face). It atomically consumes the caller's live challenge (a missing or
// expired one → 400, single-use), verifies the attestation against the stashed
// SessionData, and persists the public credential. A credential_id already bound to any
// account → 409 (the UNIQUE guard); the handler never silently rebinds an authenticator.
func (a *API) handlePasskeyRegisterFinish(w http.ResponseWriter, r *http.Request) {
if a.Passkey == nil {
writeError(w, r, errPasskeyUnavailable)
return
}
p := principalFromContext(r.Context())
var req passkeyFinishRequest
if err := decodeJSON(w, r, &req); err != nil {
writeError(w, r, err)
return
}
if len(req.Attestation) == 0 {
writeError(w, r, newError(http.StatusBadRequest, "bad_request", "attestation is required"))
return
}
sessionData, err := a.Repo.ConsumePasskeyChallengeByUser(r.Context(), p.UserID, passkeyPurposeRegister, a.now())
if err != nil {
if errors.Is(err, ErrPasskeyChallengeInvalid) {
writeError(w, r, newError(http.StatusBadRequest, "passkey_challenge_invalid",
"no live passkey registration in progress; begin again"))
return
}
writeError(w, r, err)
return
}
vc, err := a.Passkey.FinishRegistration(passkeyUserFor(p, nil), sessionData, bytes.NewReader(req.Attestation))
if err != nil {
writeError(w, r, newError(http.StatusBadRequest, "invalid_attestation",
"passkey attestation could not be verified"))
return
}
id, err := newPasskeyID()
if err != nil {
writeError(w, r, err)
return
}
cred := PasskeyCredential{
ID: id,
UserID: p.UserID,
CredentialID: vc.CredentialID,
PublicKey: vc.PublicKey,
SignCount: vc.SignCount,
AAGUID: vc.AAGUID,
Name: req.Name,
CreatedAt: a.now(),
}
if err := a.Repo.CreatePasskeyCredential(r.Context(), cred); err != nil {
if errors.Is(err, ErrConflict) {
writeError(w, r, newError(http.StatusConflict, "passkey_already_bound",
"this passkey is already bound to an account"))
return
}
writeError(w, r, err)
return
}
a.audit(r, auditActor(p), "account.passkey.registered", "")
writeJSON(w, http.StatusCreated, passkeyView(cred))
}
// passkeyCredentialView is the display projection of a bound passkey: never the public
// key (the client has no use for it), only what the credential-management UI renders.
type passkeyCredentialView struct {
ID string `json:"id"`
Name string `json:"name"`
AAGUID string `json:"aaguid,omitempty"`
CreatedAt time.Time `json:"created_at"`
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
}
// passkeyView maps a stored credential to its display projection.
func passkeyView(c PasskeyCredential) passkeyCredentialView {
return passkeyCredentialView{
ID: c.ID,
Name: c.Name,
AAGUID: c.AAGUID,
CreatedAt: c.CreatedAt.UTC(),
LastUsedAt: c.LastUsedAt,
}
}
// handlePasskeyList returns the passkeys the caller has bound (spec §14, external app
// face), newest first, for the credential-management view. It reads only the
// principal's own credentials and returns display fields only (never a secret).
func (a *API) handlePasskeyList(w http.ResponseWriter, r *http.Request) {
p := principalFromContext(r.Context())
creds, err := a.Repo.PasskeyCredentialsForUser(r.Context(), p.UserID)
if err != nil {
writeError(w, r, err)
return
}
views := make([]passkeyCredentialView, 0, len(creds))
for _, c := range creds {
views = append(views, passkeyView(c))
}
writeJSON(w, http.StatusOK, map[string]any{"credentials": views})
}
// handlePasskeyDelete unbinds one of the caller's passkeys (spec §14, external app
// face). The delete is scoped to the principal, so a caller can only remove their OWN
// credential; an unknown or cross-user id → 404 (it never silently no-ops as success).
func (a *API) handlePasskeyDelete(w http.ResponseWriter, r *http.Request) {
p := principalFromContext(r.Context())
id := r.PathValue("id")
if id == "" {
writeError(w, r, newError(http.StatusBadRequest, "bad_request", "credential id is required"))
return
}
if err := a.Repo.DeletePasskeyCredential(r.Context(), p.UserID, id); err != nil {
if errors.Is(err, ErrNotFound) {
writeError(w, r, newError(http.StatusNotFound, "not_found", "no such passkey"))
return
}
writeError(w, r, err)
return
}
a.audit(r, auditActor(p), "account.passkey.removed", id)
w.WriteHeader(http.StatusNoContent)
}
+344
View File
@@ -0,0 +1,344 @@
package api
import (
"bytes"
"encoding/json"
"errors"
"net/http"
"testing"
"time"
)
// Passkey enrollment handler tests (spec §14 / Phase 6 bind), enrollment-only slice.
// They drive the four account routes against the fakeRepo state machine and a fake
// PasskeyVerifier — no real attestation crypto, no SQL — so what these PROVE is the
// handler + challenge state machine, not the pgrepo SQL (mirrored, not run here) nor
// the cryptographic verification (Slice 1).
// frozenNow is the test clock newTestAPI installs; a live challenge expires at
// frozenNow+passkeyChallengeTTL, an expired one strictly before frozenNow.
var frozenNow = time.Unix(1_700_000_000, 0)
// newPasskeyAPI wires an external face with a fake verifier and a fixed principal.
func newPasskeyAPI(repo *fakeRepo, v PasskeyVerifier, p *Principal) http.Handler {
api := newTestAPI(repo, newFakeCluster())
api.External = staticExternal{p: p}
api.Passkey = v
return api.ExternalHandler()
}
// plantPasskeyChallenge seeds a stashed registration challenge for u1 directly, so the
// finish-side branches (expired, conflict) are reachable under the frozen clock without
// running begin first.
func plantPasskeyChallenge(repo *fakeRepo, id string, expiresAt time.Time, session []byte) {
repo.passkeyChallenges[id] = &fakePasskeyChallenge{
id: id, userID: "u1", purpose: passkeyPurposeRegister,
sessionData: session, expiresAt: expiresAt, createdAt: expiresAt,
}
}
// TestPasskeyRegisterVertical walks the whole enrollment slice across the external
// face: begin mints + stashes a challenge, finish verifies the attestation against the
// SERVER-STASHED session data and binds the credential, list shows it, delete unbinds
// it. The decisive assertion is the session-data round-trip: the finish body carries
// only name+attestation, so the only path for the stashed blob into FinishRegistration
// is store-stash → consume — proving the challenge is never client-echoed.
func TestPasskeyRegisterVertical(t *testing.T) {
user := &Principal{UserID: "u1", Email: "[email protected]", Role: "user"}
repo := newFakeRepo()
v := &fakePasskeyVerifier{
options: json.RawMessage(`{"publicKey":{"challenge":"Y2hhbGxlbmdl"}}`),
credential: VerifiedCredential{
CredentialID: "cred-abc", PublicKey: "SECRET_COSE_KEY", SignCount: 0, AAGUID: "aaguid-1",
},
}
eh := newPasskeyAPI(repo, v, user)
// 1) begin returns the verifier's creation options verbatim and stashes exactly one
// challenge bound to the caller.
w := do(eh, "POST", "/api/v1/account/passkey/register/begin", `{}`, nil)
if w.Code != http.StatusOK {
t.Fatalf("begin: code = %d, want 200 (%s)", w.Code, w.Body.String())
}
if b := acctBody(t, w); b["publicKey"] == nil {
t.Errorf("begin must return the publicKey creation options verbatim, got %s", w.Body.String())
}
if len(repo.passkeyChallenges) != 1 {
t.Fatalf("begin must stash exactly one challenge, got %d", len(repo.passkeyChallenges))
}
if string(v.lastUser.ID) != "u1" {
t.Errorf("begin passed user id %q, want u1", v.lastUser.ID)
}
// 2) finish binds the credential. The finish body carries NO challenge — only the
// nickname and attestation.
body := `{"name":"My YubiKey","attestation":{"id":"abc","type":"public-key"}}`
w = do(eh, "POST", "/api/v1/account/passkey/register/finish", body, nil)
if w.Code != http.StatusCreated {
t.Fatalf("finish: code = %d, want 201 (%s)", w.Code, w.Body.String())
}
// THE security assertion: the blob the verifier saw at finish is exactly what begin
// stashed — it travelled store-stash → consume, never the client.
if !bytes.Equal(v.lastSession, []byte("session:u1")) {
t.Fatalf("finish session data = %q, want the server-stashed %q (challenge must not be client-echoed)",
v.lastSession, "session:u1")
}
// The view never leaks the public key.
if bytes.Contains(w.Body.Bytes(), []byte("SECRET_COSE_KEY")) {
t.Error("finish response leaked the credential public key")
}
fb := acctBody(t, w)
if fb["name"] != "My YubiKey" {
t.Errorf("finish view name = %v, want \"My YubiKey\"", fb["name"])
}
if len(repo.passkeyCreds) != 1 {
t.Fatalf("finish must persist exactly one credential, got %d", len(repo.passkeyCreds))
}
// 3) the challenge is single-use: a second finish (no new begin) → 400.
if w := do(eh, "POST", "/api/v1/account/passkey/register/finish", body, nil); w.Code != http.StatusBadRequest || decodeErr(t, w) != "passkey_challenge_invalid" {
t.Fatalf("replayed finish: code = %d body %s, want 400 passkey_challenge_invalid", w.Code, w.Body.String())
}
// 4) list shows the bound credential (display fields only, never the public key).
w = do(eh, "GET", "/api/v1/account/passkey/credentials", "", nil)
if w.Code != http.StatusOK {
t.Fatalf("list: code = %d, want 200 (%s)", w.Code, w.Body.String())
}
if bytes.Contains(w.Body.Bytes(), []byte("SECRET_COSE_KEY")) {
t.Error("list response leaked the credential public key")
}
creds, _ := acctBody(t, w)["credentials"].([]any)
if len(creds) != 1 {
t.Fatalf("list returned %d credentials, want 1 (%s)", len(creds), w.Body.String())
}
id, _ := creds[0].(map[string]any)["id"].(string)
if id == "" {
t.Fatalf("listed credential has no id: %s", w.Body.String())
}
// 5) delete unbinds it (204) and the row is gone.
if w := do(eh, "DELETE", "/api/v1/account/passkey/credentials/"+id, "", nil); w.Code != http.StatusNoContent {
t.Fatalf("delete: code = %d, want 204 (%s)", w.Code, w.Body.String())
}
if len(repo.passkeyCreds) != 0 {
t.Fatalf("delete left %d credentials, want 0", len(repo.passkeyCreds))
}
// Both mutating halves audit by the caller's Access email.
var registered, removed bool
for _, a := range repo.audits {
switch a.Action {
case "account.passkey.registered":
registered = a.Actor == "[email protected]"
case "account.passkey.removed":
removed = a.Actor == "[email protected]"
}
}
if !registered || !removed {
t.Errorf("want registered+removed audits by [email protected], got %+v", repo.audits)
}
}
// TestPasskeyBeginPassesExistingCredentials proves excludeCredentials is wired
// end-to-end: a caller who already has a passkey hands that credential to the verifier
// at begin, so the authenticator can refuse to double-bind one device. The hook is
// PasskeyCredentialsForUser → passkeyUserFor → BeginRegistration.
func TestPasskeyBeginPassesExistingCredentials(t *testing.T) {
user := &Principal{UserID: "u1", Email: "[email protected]", Role: "user"}
repo := newFakeRepo()
repo.passkeyCreds["row1"] = PasskeyCredential{
ID: "row1", UserID: "u1", CredentialID: "existing-cred", PublicKey: "k", CreatedAt: frozenNow,
}
v := &fakePasskeyVerifier{}
eh := newPasskeyAPI(repo, v, user)
if w := do(eh, "POST", "/api/v1/account/passkey/register/begin", `{}`, nil); w.Code != http.StatusOK {
t.Fatalf("begin: code = %d, want 200 (%s)", w.Code, w.Body.String())
}
if len(v.lastUser.Credentials) != 1 || v.lastUser.Credentials[0].CredentialID != "existing-cred" {
t.Fatalf("begin must hand the caller's existing credentials to the verifier, got %+v", v.lastUser.Credentials)
}
}
// TestPasskeyBeginSupersedes proves a fresh begin invalidates the prior in-flight
// challenge for the same user: two begins leave exactly one stashed challenge, so an
// abandoned ceremony cannot be finished after the user restarts.
func TestPasskeyBeginSupersedes(t *testing.T) {
user := &Principal{UserID: "u1", Email: "[email protected]", Role: "user"}
repo := newFakeRepo()
eh := newPasskeyAPI(repo, &fakePasskeyVerifier{}, user)
do(eh, "POST", "/api/v1/account/passkey/register/begin", `{}`, nil)
do(eh, "POST", "/api/v1/account/passkey/register/begin", `{}`, nil)
if len(repo.passkeyChallenges) != 1 {
t.Fatalf("a second begin must supersede the first; stashed challenges = %d, want 1", len(repo.passkeyChallenges))
}
}
// TestPasskeyUnavailable pins the graceful-degradation path: when no verifier is wired
// the ceremony routes answer 503 (not a panic). It is NOT an auth assertion — auth runs
// in middleware upstream of these handlers regardless. List/delete need no verifier, so
// they keep working.
func TestPasskeyUnavailable(t *testing.T) {
user := &Principal{UserID: "u1", Email: "[email protected]", Role: "user"}
repo := newFakeRepo()
eh := newPasskeyAPI(repo, nil, user) // nil verifier
if w := do(eh, "POST", "/api/v1/account/passkey/register/begin", `{}`, nil); w.Code != http.StatusServiceUnavailable || decodeErr(t, w) != "passkey_unavailable" {
t.Fatalf("begin with no verifier: code = %d body %s, want 503 passkey_unavailable", w.Code, w.Body.String())
}
if w := do(eh, "POST", "/api/v1/account/passkey/register/finish", `{"name":"x","attestation":{"a":1}}`, nil); w.Code != http.StatusServiceUnavailable || decodeErr(t, w) != "passkey_unavailable" {
t.Fatalf("finish with no verifier: code = %d body %s, want 503 passkey_unavailable", w.Code, w.Body.String())
}
// list still works without a verifier (it touches only the store).
if w := do(eh, "GET", "/api/v1/account/passkey/credentials", "", nil); w.Code != http.StatusOK {
t.Errorf("list with no verifier: code = %d, want 200 (it needs no verifier)", w.Code)
}
}
// TestPasskeyFinishRejections is the finish-side failure matrix. The expired and
// conflict cases plant state directly: the test clock is frozen, so a pre-expired
// challenge or a pre-bound credential is the only way to reach those branches.
func TestPasskeyFinishRejections(t *testing.T) {
user := &Principal{UserID: "u1", Email: "[email protected]", Role: "user"}
const goodBody = `{"name":"k","attestation":{"id":"abc"}}`
t.Run("missing attestation -> 400 bad_request", func(t *testing.T) {
repo := newFakeRepo()
eh := newPasskeyAPI(repo, &fakePasskeyVerifier{}, user)
plantPasskeyChallenge(repo, "c1", frozenNow.Add(passkeyChallengeTTL), []byte("s"))
if w := do(eh, "POST", "/api/v1/account/passkey/register/finish", `{"name":"k"}`, nil); w.Code != http.StatusBadRequest || decodeErr(t, w) != "bad_request" {
t.Fatalf("code = %d body %s, want 400 bad_request", w.Code, w.Body.String())
}
})
t.Run("unknown field -> 400 (strict decode)", func(t *testing.T) {
repo := newFakeRepo()
eh := newPasskeyAPI(repo, &fakePasskeyVerifier{}, user)
if w := do(eh, "POST", "/api/v1/account/passkey/register/finish", `{"name":"k","attestation":{"a":1},"x":1}`, nil); w.Code != http.StatusBadRequest {
t.Fatalf("strict decode must reject unknown field, code = %d", w.Code)
}
})
t.Run("no live challenge -> 400 passkey_challenge_invalid", func(t *testing.T) {
repo := newFakeRepo()
eh := newPasskeyAPI(repo, &fakePasskeyVerifier{}, user)
if w := do(eh, "POST", "/api/v1/account/passkey/register/finish", goodBody, nil); w.Code != http.StatusBadRequest || decodeErr(t, w) != "passkey_challenge_invalid" {
t.Fatalf("code = %d body %s, want 400 passkey_challenge_invalid", w.Code, w.Body.String())
}
})
t.Run("expired challenge -> 400 passkey_challenge_invalid, not consumed", func(t *testing.T) {
repo := newFakeRepo()
eh := newPasskeyAPI(repo, &fakePasskeyVerifier{}, user)
plantPasskeyChallenge(repo, "ex", frozenNow.Add(-time.Second), []byte("s"))
if w := do(eh, "POST", "/api/v1/account/passkey/register/finish", goodBody, nil); w.Code != http.StatusBadRequest || decodeErr(t, w) != "passkey_challenge_invalid" {
t.Fatalf("code = %d body %s, want 400 passkey_challenge_invalid", w.Code, w.Body.String())
}
if repo.passkeyChallenges["ex"].consumed {
t.Error("an expired challenge must not be consumed")
}
})
t.Run("attestation fails verification -> 400 invalid_attestation", func(t *testing.T) {
repo := newFakeRepo()
v := &fakePasskeyVerifier{failErr: errors.New("bad signature")}
eh := newPasskeyAPI(repo, v, user)
plantPasskeyChallenge(repo, "c1", frozenNow.Add(passkeyChallengeTTL), []byte("s"))
if w := do(eh, "POST", "/api/v1/account/passkey/register/finish", goodBody, nil); w.Code != http.StatusBadRequest || decodeErr(t, w) != "invalid_attestation" {
t.Fatalf("code = %d body %s, want 400 invalid_attestation", w.Code, w.Body.String())
}
// A bad attestation still consumes the challenge (single-use): the consume is
// committed before verification, so a re-finish finds nothing live.
if !repo.passkeyChallenges["c1"].consumed {
t.Error("a consumed challenge must stay consumed even when verification fails")
}
})
t.Run("credential already bound -> 409 passkey_already_bound", func(t *testing.T) {
repo := newFakeRepo()
// Some other account already holds this credential id.
repo.passkeyCreds["other"] = PasskeyCredential{ID: "other", UserID: "u2", CredentialID: "dup-cred", CreatedAt: frozenNow}
v := &fakePasskeyVerifier{credential: VerifiedCredential{CredentialID: "dup-cred", PublicKey: "k"}}
eh := newPasskeyAPI(repo, v, user)
plantPasskeyChallenge(repo, "c1", frozenNow.Add(passkeyChallengeTTL), []byte("s"))
if w := do(eh, "POST", "/api/v1/account/passkey/register/finish", goodBody, nil); w.Code != http.StatusConflict || decodeErr(t, w) != "passkey_already_bound" {
t.Fatalf("code = %d body %s, want 409 passkey_already_bound", w.Code, w.Body.String())
}
})
}
// TestPasskeyDeleteScoping proves a caller can only unbind their OWN passkey: u2's
// credential is invisible to u1's list and u1's delete of it 404s (never a silent
// success that would let one account strip another's factor).
func TestPasskeyDeleteScoping(t *testing.T) {
u1 := &Principal{UserID: "u1", Email: "[email protected]", Role: "user"}
repo := newFakeRepo()
repo.passkeyCreds["row2"] = PasskeyCredential{ID: "row2", UserID: "u2", CredentialID: "u2-cred", CreatedAt: frozenNow}
eh := newPasskeyAPI(repo, &fakePasskeyVerifier{}, u1)
// u1's list does not show u2's credential.
w := do(eh, "GET", "/api/v1/account/passkey/credentials", "", nil)
if creds, _ := acctBody(t, w)["credentials"].([]any); len(creds) != 0 {
t.Fatalf("u1 list shows %d credentials, want 0 (u2's must be invisible)", len(creds))
}
// u1 cannot delete u2's credential.
if w := do(eh, "DELETE", "/api/v1/account/passkey/credentials/row2", "", nil); w.Code != http.StatusNotFound || decodeErr(t, w) != "not_found" {
t.Fatalf("cross-user delete: code = %d body %s, want 404 not_found", w.Code, w.Body.String())
}
if _, ok := repo.passkeyCreds["row2"]; !ok {
t.Error("u2's credential must survive u1's failed delete")
}
}
// TestPasskeyDeleteUnknown pins the unknown-id path: deleting an id that does not exist
// is a 404, never a silent 204.
func TestPasskeyDeleteUnknown(t *testing.T) {
user := &Principal{UserID: "u1", Email: "[email protected]", Role: "user"}
eh := newPasskeyAPI(newFakeRepo(), &fakePasskeyVerifier{}, user)
if w := do(eh, "DELETE", "/api/v1/account/passkey/credentials/nope", "", nil); w.Code != http.StatusNotFound || decodeErr(t, w) != "not_found" {
t.Fatalf("delete unknown: code = %d body %s, want 404 not_found", w.Code, w.Body.String())
}
}
// TestPasskeyListNewestFirst proves the management view orders newest-first, matching
// the pgrepo ORDER BY created_at DESC the fake mirrors.
func TestPasskeyListNewestFirst(t *testing.T) {
user := &Principal{UserID: "u1", Email: "[email protected]", Role: "user"}
repo := newFakeRepo()
repo.passkeyCreds["old"] = PasskeyCredential{ID: "old", UserID: "u1", CredentialID: "c-old", Name: "old", CreatedAt: frozenNow.Add(-time.Hour)}
repo.passkeyCreds["new"] = PasskeyCredential{ID: "new", UserID: "u1", CredentialID: "c-new", Name: "new", CreatedAt: frozenNow}
eh := newPasskeyAPI(repo, &fakePasskeyVerifier{}, user)
w := do(eh, "GET", "/api/v1/account/passkey/credentials", "", nil)
creds, _ := acctBody(t, w)["credentials"].([]any)
if len(creds) != 2 {
t.Fatalf("list returned %d, want 2 (%s)", len(creds), w.Body.String())
}
if first, _ := creds[0].(map[string]any)["name"].(string); first != "new" {
t.Errorf("list order: first = %q, want \"new\" (newest first)", first)
}
}
// TestPasskeyFaceSeparation enforces that the enrollment routes are web-only: they
// require a logged-in principal the internal (service-token) face never carries, so
// crossing the face boundary must 404 rather than silently work.
func TestPasskeyFaceSeparation(t *testing.T) {
user := &Principal{UserID: "u1", Email: "[email protected]", Role: "user"}
api := newTestAPI(newFakeRepo(), newFakeCluster())
api.External = staticExternal{p: user}
api.Passkey = &fakePasskeyVerifier{}
ih := api.InternalHandler()
for _, tc := range []struct{ method, path, body string }{
{"POST", "/api/v1/account/passkey/register/begin", `{}`},
{"POST", "/api/v1/account/passkey/register/finish", `{"name":"k","attestation":{"a":1}}`},
{"GET", "/api/v1/account/passkey/credentials", ""},
{"DELETE", "/api/v1/account/passkey/credentials/x", ""},
} {
if w := do(ih, tc.method, tc.path, tc.body, nil); w.Code != http.StatusNotFound {
t.Errorf("%s %s on internal face: code = %d, want 404", tc.method, tc.path, w.Code)
}
}
}