Unverified Commit 742f15f3 authored by Minseong Choi's avatar Minseong Choi 💬
Browse files

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.
parent f2c916d3
Loading
Loading
Loading
Loading
+152 −0
Changes for docs/openapi.yaml: 152 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -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 −0
Changes for internal/api/api.go: 21 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -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 −0
Changes for internal/api/api_test.go: 131 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -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 −0

File added.

Preview size limit exceeded, changes collapsed.

+344 −0

File added.

Preview size limit exceeded, changes collapsed.