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

feat(auth): add discoverable (usernameless) passkey login

A from-zero login door: the browser calls navigator.credentials.get() with an
empty allowCredentials, the authenticator returns an assertion carrying the
resident credential's userHandle, and the server resolves the account from that
handle alone — nothing is typed or client-named.

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

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

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

Whether an authenticator actually stores a resident key is a device property no
test can reach, so this door is INERT for a credential until its owner enrolls a
NEW passkey against these options; "preferred" (not "required") preserves the
no-lockout fallback to username-first + email-OTP.
parent 7db57b9f
Loading
Loading
Loading
Loading
+147 −0
Changes for docs/openapi.yaml: 147 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -1709,6 +1709,153 @@ paths:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /api/v1/auth/passkey/login/discoverable/begin:
    post:
      tags: [auth]
      operationId: passkeyLoginDiscoverableBegin
      summary: Begin a usernameless (discoverable) passkey login (spec §14, §B, task #40).
      description: >-
        First leg of the truly from-zero passkey door: unlike the email-first sibling
        above, the caller supplies NO identifier — the request has no body (only the
        application/json Content-Type is required as the cross-origin CSRF guard). The
        response is the WebAuthn PublicKeyCredentialRequestOptions with an EMPTY
        allowCredentials, plus an opaque login_id: the authenticator picks a resident
        credential it holds for this RP and the account is revealed only by the
        userHandle inside the signed assertion at finish. The challenge cannot be
        user-keyed, so it is stashed under login_id in a non-user-keyed store and echoed
        back at finish. Mounted Public and gated on local_auth_enabled. There is no
        recipient or principal to key a per-caller cooldown on (that volumetric limiting
        is delegated to the edge), so the server-side brake is a hard global cap on live
        challenges (429 too_many_challenges). Inert for a credential until its owner
        enrolls a resident passkey; email-OTP and username-first passkey remain the
        fallbacks, so no authenticator is ever locked out.
      x-felis-face: [external]
      x-felis-tier: public
      security: []
      requestBody:
        required: false
        description: >-
          No body is read — the whole point is that the caller supplies no identifier —
          but the application/json Content-Type is required (415 otherwise).
        content:
          application/json:
            schema: { type: object }
      responses:
        '200':
          description: >-
            The WebAuthn assertion options (PublicKeyCredentialRequestOptions) with an
            empty allowCredentials, passed through verbatim for the browser to consume,
            plus an opaque login_id the caller echoes at finish. The publicKey member is
            the WebAuthn standard shape and is not modelled here.
          content:
            application/json:
              schema:
                type: object
                required: [publicKey, login_id]
                properties:
                  publicKey: { type: object, additionalProperties: true }
                  login_id: { type: string }
        '400':
          description: The authenticator library could not start the ceremony (passkey_login_failed).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '403':
          description: Local session login is disabled on this deployment (local_auth_disabled).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '415':
          description: Request Content-Type was not application/json.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '429':
          description: >-
            Too many discoverable logins are in flight server-wide; the global cap is hit
            (too_many_challenges). No per-recipient signal is leaked — the cap is global.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '503':
          description: No passkey verifier is wired on this deployment (passkey_unavailable).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /api/v1/auth/passkey/login/discoverable/finish:
    post:
      tags: [auth]
      operationId: passkeyLoginDiscoverableFinish
      summary: Complete a usernameless (discoverable) passkey login and mint a session (spec §14, §B, task #40).
      description: >-
        Second leg of the from-zero door: the caller returns the opaque login_id from
        begin (the only link to the stashed challenge, since it is not user-keyed) and
        the raw navigator.credentials.get() assertion — and NOTHING that names an
        account. The stashed challenge is consumed atomically and the assertion is
        verified against it; the account is resolved from the authenticator-revealed
        userHandle (the account's stable id), never from anything the client supplied,
        and the session is minted for the account the assertion actually resolved AND
        verified to. Both players and staff may log in this way. Every failure mode — a
        missing/expired/consumed login_id, a bad assertion, AND a userHandle that
        resolves to no account — collapses into one uniform passkey_login_invalid, so
        the door reveals nothing (not even whether the handle was well-formed).
      x-felis-face: [external]
      x-felis-tier: public
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [login_id, assertion]
              properties:
                login_id:
                  type: string
                  description: The opaque handle returned by discoverable/begin.
                assertion:
                  type: object
                  additionalProperties: true
                  description: >-
                    The raw PublicKeyCredential from navigator.credentials.get(),
                    passed to the verifier verbatim (WebAuthn standard shape). Its
                    userHandle selects the account server-side.
      responses:
        '200':
          description: Assertion verified; a host-only session cookie is set on the response.
          content:
            application/json:
              schema:
                type: object
                required: [user_id, role]
                properties:
                  user_id: { type: string }
                  role: { type: string, enum: [user, admin] }
        '400':
          description: >-
            Missing login_id or assertion (bad_request); or the login could not be
            completed — no live/expired/consumed challenge, a failed assertion, or a
            userHandle that resolves to no account, all uniform (passkey_login_invalid).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '403':
          description: Local session login is disabled on this deployment (local_auth_disabled).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '415':
          description: Request body was not application/json.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '503':
          description: No passkey verifier is wired on this deployment (passkey_unavailable).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /api/v1/auth/email/start:
    post:
      tags: [auth]
+5 −0
Changes for internal/api/api.go: 5 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -282,6 +282,11 @@ func (a *API) externalAPIRoutes() []apiRoute {
		{Method: "GET", Pattern: "/api/v1/auth/setup/status", SetupAllowed: true, h: a.handleSetupStatus},
		{Method: "POST", Pattern: "/api/v1/auth/passkey/login/begin", Public: true, h: a.handlePasskeyLoginBegin},
		{Method: "POST", Pattern: "/api/v1/auth/passkey/login/finish", Public: true, h: a.handlePasskeyLoginFinish},
		// Discoverable ("usernameless") passkey login (task #40): the from-zero sibling of the
		// email-first pair above — no identifier typed, the account is resolved from the
		// userHandle inside the signed assertion (handlers_passkey_discoverable.go).
		{Method: "POST", Pattern: "/api/v1/auth/passkey/login/discoverable/begin", Public: true, h: a.handlePasskeyLoginDiscoverableBegin},
		{Method: "POST", Pattern: "/api/v1/auth/passkey/login/discoverable/finish", Public: true, h: a.handlePasskeyLoginDiscoverableFinish},
		{Method: "POST", Pattern: "/api/v1/auth/email/start", Public: true, h: a.handleLoginEmailStart},
		{Method: "POST", Pattern: "/api/v1/auth/email/verify", Public: true, h: a.handleLoginEmailVerify},
		{Method: "POST", Pattern: "/api/v1/auth/op-login/start", Public: true, h: a.handleOpLoginStart},
+73 −0
Changes for internal/api/api_test.go: 73 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -81,6 +81,12 @@ type fakeRepo struct {
	// just as the PG query does.
	passkeyCreds      map[string]PasskeyCredential
	passkeyChallenges map[string]*fakePasskeyChallenge
	// discoverable ("usernameless") login challenge store (task #40), keyed by opaque handle id
	// with no user key, mirroring migration 0013. discoverableFull forces the capped-out path
	// (ErrTooManyDiscoverableChallenges) so the begin 429 branch is reachable without inserting
	// thousands of rows.
	discoverableChallenges map[string]*fakeDiscoverableChallenge
	discoverableFull       bool
	// user admin fakes
	seededUsers []seededUser
	fakeQuotas  map[string]*QuotaView
@@ -99,6 +105,15 @@ type fakePasskeyChallenge struct {
	createdAt   time.Time
}

// fakeDiscoverableChallenge mirrors a webauthn_discoverable_challenges row (task #40): no user
// or purpose (a from-zero begin has neither), just the opaque stashed SessionData, its expiry,
// and single-use via consumed. Keyed by the opaque handle in the map, like the real table's id.
type fakeDiscoverableChallenge struct {
	sessionData []byte
	expiresAt   time.Time
	consumed    bool
}

// fakeDataHold mirrors a player_data_holds row at the granularity the verifiable
// (write-only) layer exercises: which name/data was stashed for the squatter UUID
// and when the 30-day window ends. reclaimed_by_user_id/reclaimed_at have no fake
@@ -191,6 +206,8 @@ func newFakeRepo() *fakeRepo {
		holds:             map[string]fakeDataHold{},
		passkeyCreds:      map[string]PasskeyCredential{},
		passkeyChallenges: map[string]*fakePasskeyChallenge{},

		discoverableChallenges: map[string]*fakeDiscoverableChallenge{},
		fakeQuotas:             map[string]*QuotaView{},
	}
}
@@ -366,6 +383,31 @@ func (f *fakeRepo) ConsumePasskeyChallengeByUser(_ context.Context, userID, purp
	return live.sessionData, nil
}

// CreateDiscoverableChallenge / ConsumeDiscoverableChallenge mirror PGRepo's non-user-keyed
// contract (task #40): begin reaps expired/consumed rows then stashes under the opaque handle,
// and consume redeems by handle, single-use, expiry checked. discoverableFull forces the capped
// path so the begin 429 branch is reachable without inserting thousands of rows.
func (f *fakeRepo) CreateDiscoverableChallenge(_ context.Context, id string, sessionData []byte, now, expiresAt time.Time) error {
	if f.discoverableFull {
		return ErrTooManyDiscoverableChallenges
	}
	for k, c := range f.discoverableChallenges { // reap (DELETE ... expires_at<=now OR consumed_at NOT NULL)
		if c.consumed || !c.expiresAt.After(now) {
			delete(f.discoverableChallenges, k)
		}
	}
	f.discoverableChallenges[id] = &fakeDiscoverableChallenge{sessionData: sessionData, expiresAt: expiresAt}
	return nil
}
func (f *fakeRepo) ConsumeDiscoverableChallenge(_ context.Context, id string, now time.Time) ([]byte, error) {
	c, ok := f.discoverableChallenges[id]
	if !ok || c.consumed || !c.expiresAt.After(now) {
		return nil, ErrPasskeyChallengeInvalid
	}
	c.consumed = true
	return c.sessionData, nil
}

// CreatePasskeyCredential mirrors PGRepo: a credential_id already bound to ANY account
// → ErrConflict (the UNIQUE guard), never a silent rebind.
func (f *fakeRepo) CreatePasskeyCredential(_ context.Context, c PasskeyCredential) error {
@@ -436,6 +478,10 @@ type fakePasskeyVerifier struct {
	// stashed SessionData round-trips and the existing credentials reach the verifier.
	lastUser    PasskeyUser
	lastSession []byte
	// discoverableUserHandle is the userHandle the fake feeds to FinishDiscoverableLogin's
	// resolver, so a handler test drives the userHandle → UserByID → session-mint wiring for a
	// chosen account (or an unknown handle, to exercise the resolve-fails branch).
	discoverableUserHandle []byte
}

func (v *fakePasskeyVerifier) BeginRegistration(user PasskeyUser) (json.RawMessage, []byte, error) {
@@ -477,6 +523,33 @@ func (v *fakePasskeyVerifier) FinishLogin(user PasskeyUser, sessionData []byte,
	return v.assertion, nil
}

func (v *fakePasskeyVerifier) BeginDiscoverableLogin() (json.RawMessage, []byte, error) {
	if v.beginLoginErr != nil {
		return nil, nil, v.beginLoginErr
	}
	opts := v.options
	if opts == nil {
		opts = json.RawMessage(`{"publicKey":{"challenge":"ZGlzYw"}}`)
	}
	return opts, []byte("disc-session"), nil
}

func (v *fakePasskeyVerifier) FinishDiscoverableLogin(resolveUser func([]byte) (PasskeyUser, error), sessionData []byte, _ io.Reader) (VerifiedAssertion, error) {
	v.lastSession = sessionData
	if v.failErr != nil {
		return VerifiedAssertion{}, v.failErr
	}
	// Drive the resolver with the configured user handle so the handler's userHandle → UserByID
	// → session-mint wiring runs end to end; a resolve error (unknown handle) fails the ceremony
	// exactly as the real ValidateDiscoverableLogin would when the handler cannot be resolved.
	u, err := resolveUser(v.discoverableUserHandle)
	if err != nil {
		return VerifiedAssertion{}, err
	}
	v.lastUser = u
	return v.assertion, nil
}

func (f *fakeRepo) UserInAllowlist(_ context.Context, n, u string) (bool, error) {
	return f.allowlist[n][u], nil
}
+8 −0
Changes for internal/api/errors.go: 8 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -58,6 +58,14 @@ var (
	// guard and gets a 409 instead of a raw unique-violation 500. Distinct from
	// ErrConflict so the message can name the cause (the email is spoken for).
	ErrEmailTaken = errors.New("email already verified on another account")
	// ErrTooManyDiscoverableChallenges means the non-user-keyed discoverable ("usernameless")
	// login challenge store is at its hard cap of live rows (task #40, migration 0013).
	// Unlike the user-keyed enrollment/login challenges — which self-bound via a per-user
	// supersede — a from-zero begin has no principal to key a fair per-caller limit on, so the
	// table is capped globally and a begin over the cap is refused. Distinct from the other
	// sentinels so the handler answers 429 (a transient "too busy, retry" — the cap self-clears
	// as challenges expire), never a 400 that invites an immediate retry.
	ErrTooManyDiscoverableChallenges = errors.New("too many discoverable login challenges in flight")
)

// apiError is a handler-level error carrying an HTTP status and a stable,
+29 −9
Changes for internal/api/handlers_passkey.go: 29 added lines, 9 removed lines.
Original line number Diff line number Diff line
@@ -40,15 +40,20 @@ import (
//     (0010_verified_email_unique.sql) plus UserByEmail gave the door the typable handle
//     it keys on: begin resolves email → account → its bound passkeys.
//
// This is an EMAIL-first assertion, not a usernameless one. The system's returning-player
// root of trust is still re-link (control of the in-game identity — handlers_onboard.go
// re-mints a session through the bind-code flow even after passkey/OTP are bound); the
// email and passkey login doors are convenience layered on top, never the root. The real
// enabler for a TRULY from-zero passkey login (no identifier typed at all) is discoverable
// ("usernameless") credentials, which sidestep even the email handle but reshape enrollment
// (residentKey) and need a non-user-keyed challenge store — a future migration and its own
// checkpoint, task #40 (that door partly bypasses the in-game-identity root of trust). The
// adapter crypto is verified now so that slice inherits correct crypto.
// That EMAIL-first assertion is one of TWO login doors this subsystem now offers. The other,
// the TRULY from-zero door, is discoverable ("usernameless") login (handlers_passkey_discoverable.go,
// task #40): the browser calls navigator.credentials.get() with an EMPTY allowCredentials, the
// authenticator offers a resident credential it holds, and the account is resolved from the
// userHandle inside the signed assertion — no identifier typed at all. It reshaped enrollment
// (ResidentKey=Preferred in the verifier) and added a non-user-keyed challenge store (migration
// 0013). Two honest limits frame it: (1) the from-zero door partly bypasses the returning-player
// root of trust — control of the in-game identity, which handlers_onboard.go re-mints a session
// through even after passkey/OTP are bound — but it stands on the same footing as the email door
// (#72): a passkey is a possession+UV two-factor authenticator strong enough to stand alone; and
// (2) whether an authenticator actually STORES a resident key is a device property no server
// request compels, so a credential enrolled before this slice, or on hardware that declines
// residency, stays username-first (BeginLogin) — the from-zero door is inert for it until its
// owner enrolls a new passkey. The assertion crypto for both doors is Oracle-verified.
//
// The cryptographic half is a seam (PasskeyVerifier) so this package never imports
// go-webauthn: ceremony state crosses the boundary as opaque bytes, the attestation
@@ -102,6 +107,21 @@ type PasskeyVerifier interface {
	// the browser posts back; sessionData is the blob BeginLogin returned. A failed
	// verification returns a non-nil error; the handler maps it to 400.
	FinishLogin(user PasskeyUser, sessionData []byte, assertion io.Reader) (VerifiedAssertion, error)
	// BeginDiscoverableLogin starts a USERNAMELESS assertion ceremony (task #40): there is no
	// user yet, so no allowCredentials — the authenticator offers a resident (discoverable)
	// credential it holds for this RP and reveals the account only in the signed response. It
	// returns the {"publicKey": {...}} request options for navigator.credentials.get() and the
	// opaque SessionData the handler stashes under an opaque handle (not a user id) and replays
	// at finish.
	BeginDiscoverableLogin() (options json.RawMessage, sessionData []byte, err error)
	// FinishDiscoverableLogin verifies a usernameless assertion. resolveUser is called with the
	// authenticator-revealed user handle so the caller loads the account and its bound
	// credentials WITHOUT any client-supplied identifier; the verifier then checks the asserted
	// credential id is one that user holds and verifies the signature. A resolveUser error
	// (unknown handle) fails the ceremony closed; the handle is the account's stable user id, so
	// resolveUser is a direct id lookup. A failed verification returns a non-nil error the
	// handler maps to 400.
	FinishDiscoverableLogin(resolveUser func(userHandle []byte) (PasskeyUser, error), sessionData []byte, assertion io.Reader) (VerifiedAssertion, error)
}

// PasskeyUser is the relying-party view of the enrolling principal the verifier needs:
Loading