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
|
||||
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]
|
||||
|
||||
Reference in new issue
Block a user