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:
5 files changed
+926
No files matched your search
@@ -167,6 +167,22 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
description: Correlates the response with server logs (withRequestID middleware).
|
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:
|
Phase:
|
||||||
type: string
|
type: string
|
||||||
description: MinecraftServer lifecycle phase (internal/apis/felis/v1alpha1).
|
description: MinecraftServer lifecycle phase (internal/apis/felis/v1alpha1).
|
||||||
@@ -1649,6 +1665,142 @@ paths:
|
|||||||
application/json:
|
application/json:
|
||||||
schema: { $ref: '#/components/schemas/Error' }
|
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:
|
/api/v1/me/submissions:
|
||||||
post:
|
post:
|
||||||
tags: [submissions]
|
tags: [submissions]
|
||||||
|
|||||||
@@ -73,6 +73,15 @@ type API struct {
|
|||||||
// sender. The code is never returned to the client on either path.
|
// sender. The code is never returned to the client on either path.
|
||||||
Mailer OTPMailer
|
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
|
// RootDomain is injected from config (spec §2). It is the only place the
|
||||||
// deployment zone enters the API; hostnames are validated against it and
|
// deployment zone enters the API; hostnames are validated against it and
|
||||||
// never hardcoded.
|
// never hardcoded.
|
||||||
@@ -268,6 +277,18 @@ func (a *API) externalAPIRoutes() []apiRoute {
|
|||||||
// email is an ordinary authenticated operation, scoped to the principal.
|
// 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/start", h: a.handleEmailOTPStart},
|
||||||
{Method: "POST", Pattern: "/api/v1/account/email/verify", h: a.handleEmailOTPVerify},
|
{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
|
// 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
|
// 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
|
// both taken from the principal, never the body, so an ordinary authenticated
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -61,6 +63,26 @@ type fakeRepo struct {
|
|||||||
// the same all-or-nothing contract the PG transaction enforces.
|
// the same all-or-nothing contract the PG transaction enforces.
|
||||||
blacklist map[string]bool
|
blacklist map[string]bool
|
||||||
holds map[string]fakeDataHold
|
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
|
// fakeDataHold mirrors a player_data_holds row at the granularity the verifiable
|
||||||
@@ -127,6 +149,8 @@ func newFakeRepo() *fakeRepo {
|
|||||||
otps: map[string]*fakeEmailOTP{},
|
otps: map[string]*fakeEmailOTP{},
|
||||||
blacklist: map[string]bool{},
|
blacklist: map[string]bool{},
|
||||||
holds: map[string]fakeDataHold{},
|
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
|
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) {
|
func (f *fakeRepo) UserInAllowlist(_ context.Context, n, u string) (bool, error) {
|
||||||
return f.allowlist[n][u], nil
|
return f.allowlist[n][u], nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user