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.
210 lines
9.1 KiB
Go
210 lines
9.1 KiB
Go
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)
|
|
}
|