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.
This commit is contained in:
12 files changed
+1193
-21
No files matched your search
@@ -0,0 +1,209 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Discoverable ("usernameless") passkey login (spec §14, task #40) — the TRULY from-zero
|
||||
// console.<root_domain> door. Its email-first sibling (handlers_passkey.go) still needs a typed
|
||||
// email to resolve the account before offering its passkeys; this door needs nothing typed at
|
||||
// all. The browser calls navigator.credentials.get() with an EMPTY allowCredentials, the
|
||||
// authenticator offers a resident credential it holds for this RP, and the account is revealed
|
||||
// only by the userHandle inside the signed assertion. Because there is no identifier at begin,
|
||||
// the challenge cannot be user-keyed: it is stashed under an opaque server-minted handle
|
||||
// (login_id) in the non-user-keyed store (migration 0013) and echoed back at finish. Email-OTP
|
||||
// and username-first passkey remain the fallbacks, so an authenticator that stored no resident
|
||||
// key is never locked out — only its from-zero convenience is unavailable.
|
||||
//
|
||||
// Anti-abuse divergence from the email-first door: that door reserves a per-recipient cooldown
|
||||
// (a.otpLimiter) keyed on the typed email. A usernameless begin has no recipient OR principal to
|
||||
// key a fair per-caller limit on, so — matching the stance in handlers_auth_email.go (behind
|
||||
// Cloudflare RemoteAddr is the proxy; CGNAT false-positives) — volumetric per-source limiting is
|
||||
// left to the edge, and the server-side bound is a hard global cap on live challenges enforced
|
||||
// atomically in CreateDiscoverableChallenge (ErrTooManyDiscoverableChallenges → 429).
|
||||
|
||||
// handlePasskeyLoginDiscoverableBegin starts a usernameless assertion ceremony (Public,
|
||||
// pre-session). It has no request body — the whole point is that the caller supplies no
|
||||
// identifier — but requires the JSON Content-Type as the same cross-origin CSRF guard the other
|
||||
// pre-session doors use. It asks the verifier for assertion options with an empty
|
||||
// allowCredentials + opaque SessionData, stashes the SessionData under a fresh opaque handle in
|
||||
// the capped non-user-keyed store, and returns the options with that handle merged in as
|
||||
// login_id for the browser to echo at finish.
|
||||
func (a *API) handlePasskeyLoginDiscoverableBegin(w http.ResponseWriter, r *http.Request) {
|
||||
if !localAuthEnabled(r.Context(), a.Repo) {
|
||||
writeError(w, r, newError(http.StatusForbidden, "local_auth_disabled",
|
||||
"session login is disabled"))
|
||||
return
|
||||
}
|
||||
if a.Passkey == nil {
|
||||
writeError(w, r, errPasskeyUnavailable)
|
||||
return
|
||||
}
|
||||
if err := requireJSONContentType(r); err != nil {
|
||||
writeError(w, r, err)
|
||||
return
|
||||
}
|
||||
options, sessionData, err := a.Passkey.BeginDiscoverableLogin()
|
||||
if err != nil {
|
||||
writeError(w, r, newError(http.StatusBadRequest, "passkey_login_failed",
|
||||
"could not start passkey login"))
|
||||
return
|
||||
}
|
||||
id, err := newPasskeyID()
|
||||
if err != nil {
|
||||
writeError(w, r, err)
|
||||
return
|
||||
}
|
||||
now := a.now()
|
||||
if err := a.Repo.CreateDiscoverableChallenge(r.Context(), id, sessionData, now, now.Add(passkeyChallengeTTL)); err != nil {
|
||||
if errors.Is(err, ErrTooManyDiscoverableChallenges) {
|
||||
writeError(w, r, newError(http.StatusTooManyRequests, "too_many_challenges",
|
||||
"too many passkey logins in progress; try again shortly"))
|
||||
return
|
||||
}
|
||||
writeError(w, r, err)
|
||||
return
|
||||
}
|
||||
// Merge the opaque login handle into the options envelope so the response is a single
|
||||
// {"publicKey": {...}, "login_id": "..."} document. The browser passes publicKey to
|
||||
// navigator.credentials.get() and echoes login_id back at finish (the challenge is never
|
||||
// user-keyed, so this handle is the only link between begin and finish).
|
||||
envelope, err := mergeLoginID(options, id)
|
||||
if err != nil {
|
||||
writeError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, envelope)
|
||||
}
|
||||
|
||||
// passkeyDiscoverableFinishRequest is the finish body: the opaque login_id that begin returned
|
||||
// (the only link to the stashed challenge, since it is not user-keyed) and the raw
|
||||
// navigator.credentials.get() assertion. Assertion is RawMessage so the exact bytes the browser
|
||||
// produced reach the verifier without a re-encode that could perturb the signed payload.
|
||||
type passkeyDiscoverableFinishRequest struct {
|
||||
LoginID string `json:"login_id"`
|
||||
Assertion json.RawMessage `json:"assertion"`
|
||||
}
|
||||
|
||||
// handlePasskeyLoginDiscoverableFinish verifies a usernameless assertion and mints a session
|
||||
// (Public, pre-session). It consumes the stashed challenge under login_id (a missing/expired/
|
||||
// consumed handle → 400), then verifies the assertion — the verifier resolves the account from
|
||||
// the authenticator-revealed userHandle via the resolve callback below, WITHOUT any
|
||||
// client-supplied identifier. On success the session is minted for the account the assertion
|
||||
// actually resolved AND verified to (the resolved user is hoisted out of the callback), never
|
||||
// anything the client named. All rejection branches collapse to one passkey_login_invalid
|
||||
// envelope so finish is never an existence/state oracle.
|
||||
func (a *API) handlePasskeyLoginDiscoverableFinish(w http.ResponseWriter, r *http.Request) {
|
||||
if !localAuthEnabled(r.Context(), a.Repo) {
|
||||
writeError(w, r, newError(http.StatusForbidden, "local_auth_disabled",
|
||||
"session login is disabled"))
|
||||
return
|
||||
}
|
||||
if a.Passkey == nil {
|
||||
writeError(w, r, errPasskeyUnavailable)
|
||||
return
|
||||
}
|
||||
if err := requireJSONContentType(r); err != nil {
|
||||
writeError(w, r, err)
|
||||
return
|
||||
}
|
||||
var req passkeyDiscoverableFinishRequest
|
||||
if err := decodeJSON(w, r, &req); err != nil {
|
||||
writeError(w, r, err)
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.LoginID) == "" {
|
||||
writeError(w, r, newError(http.StatusBadRequest, "bad_request", "login_id is required"))
|
||||
return
|
||||
}
|
||||
if len(req.Assertion) == 0 {
|
||||
writeError(w, r, newError(http.StatusBadRequest, "bad_request", "assertion is required"))
|
||||
return
|
||||
}
|
||||
|
||||
sessionData, err := a.Repo.ConsumeDiscoverableChallenge(r.Context(), req.LoginID, a.now())
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrPasskeyChallengeInvalid) {
|
||||
writeError(w, r, newError(http.StatusBadRequest, "passkey_login_invalid",
|
||||
"passkey login could not be completed; begin again"))
|
||||
return
|
||||
}
|
||||
writeError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
// The verifier hands the authenticator-revealed userHandle to this resolver; it loads the
|
||||
// account and its bound credentials so ValidateDiscoverableLogin can check the asserted
|
||||
// credential belongs to that user and verify the signature. The userHandle IS the account's
|
||||
// stable id (WebAuthnID), so this is a direct id lookup. The resolved user is hoisted here so
|
||||
// the session below is minted for the account the assertion actually resolved AND verified to
|
||||
// — not anything the client supplied (the body carries only a challenge handle).
|
||||
var resolved *StaffUser
|
||||
resolve := func(userHandle []byte) (PasskeyUser, error) {
|
||||
u, err := a.Repo.UserByID(r.Context(), string(userHandle))
|
||||
if err != nil {
|
||||
return PasskeyUser{}, err
|
||||
}
|
||||
creds, err := a.Repo.PasskeyCredentialsForUser(r.Context(), u.ID)
|
||||
if err != nil {
|
||||
return PasskeyUser{}, err
|
||||
}
|
||||
resolved = u
|
||||
// Name/DisplayName are cosmetic at assertion time (nothing is shown to the user); use the
|
||||
// stable username so a nil email never matters.
|
||||
return PasskeyUser{ID: u.ID, Name: u.Username, DisplayName: u.Username, Credentials: creds}, nil
|
||||
}
|
||||
if _, err := a.Passkey.FinishDiscoverableLogin(resolve, sessionData, bytes.NewReader(req.Assertion)); err != nil {
|
||||
writeError(w, r, newError(http.StatusBadRequest, "passkey_login_invalid",
|
||||
"passkey login could not be completed; begin again"))
|
||||
return
|
||||
}
|
||||
// A verified assertion guarantees resolve ran and set resolved: go-webauthn calls the handler
|
||||
// to obtain the user BEFORE checking the signature, and a resolve error would have failed
|
||||
// FinishDiscoverableLogin above. Guard anyway so a future verifier that could return success
|
||||
// without invoking the resolver fails closed rather than nil-dereferencing.
|
||||
if resolved == nil {
|
||||
writeError(w, r, newError(http.StatusBadRequest, "passkey_login_invalid",
|
||||
"passkey login could not be completed; begin again"))
|
||||
return
|
||||
}
|
||||
|
||||
token, err := newSessionToken()
|
||||
if err != nil {
|
||||
writeError(w, r, err)
|
||||
return
|
||||
}
|
||||
expires := a.now().Add(sessionTTL)
|
||||
if err := a.Repo.CreateSession(r.Context(), hashCookie(token), resolved.ID, expires); err != nil {
|
||||
writeError(w, r, err)
|
||||
return
|
||||
}
|
||||
setSessionCookie(w, token, expires)
|
||||
a.audit(r, resolved.Username, "auth.passkey_login_discoverable", "")
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"user_id": resolved.ID,
|
||||
"role": resolved.Role,
|
||||
})
|
||||
}
|
||||
|
||||
// mergeLoginID returns options with an added top-level "login_id" member, so a discoverable
|
||||
// begin can hand the browser one {"publicKey": {...}, "login_id": "..."} document. It parses the
|
||||
// options into a generic envelope (they are already a JSON object with a publicKey member) and
|
||||
// re-marshals with the handle added; a malformed options blob surfaces as an error rather than a
|
||||
// silently unmergeable response.
|
||||
func mergeLoginID(options json.RawMessage, id string) (json.RawMessage, error) {
|
||||
var envelope map[string]json.RawMessage
|
||||
if err := json.Unmarshal(options, &envelope); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
idJSON, err := json.Marshal(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
envelope["login_id"] = idJSON
|
||||
return json.Marshal(envelope)
|
||||
}
|
||||
Reference in new issue
Block a user