// Package flow implements SPEC-006: the GUID-driven enroll/login state machine. // // GET /{GUID} renders the enrollment screen (pending, in window) or the login // screen (active, no session); the POST actions run the WebAuthn and TOTP // ceremonies (`do=auth&action=…`) and mint, rotate or revoke the session. // // Two factors only — Passkey (primary) + TOTP (fallback) (D-005). Enrollment // completes ONLY when BOTH a passkey credential AND a verified TOTP code are // present (AC-2); outside the bounded window it is refused (A1/AC-4). The // /setup wizard is the one exception (D-041/TOTPOnlyEnroll): it enrolls TOTP // only, because a loopback passkey is unusable at the public domain — a nudged // TOTP login later adds one (§8). // // The package holds no HTTP routing and no storage of its own: it drives the // core auth services and renders the two screens. The door verifies the // pre-session CSRF token before invoking an action (A15). // // Spec: docs/specs/spec-006-enroll-login.md. package flow import ( "context" "encoding/json" "errors" "fmt" "log/slog" "net/http" "net/url" "strings" "time" "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/audit" "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/auth" "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/auth/totp" "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/auth/webauthn" "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/httpx" "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/users" ) // CeremonyCookieName carries the signed WebAuthn session data between begin and // finish (no server-side table needed). const CeremonyCookieName = "myday_webauthn" // errNotPending reports that a GUID does not map to a pending identity inside // its enrollment window — the ceremony's "not found" (A1). It is unexported: // only this package renders a response for it (TOTPBegin's 404). var errNotPending = errors.New("flow: no pending identity") // Deps assembles the flow's dependencies. type Deps struct { // Identities resolves GUIDs, activates identities and checks the window. Identities auth.Identities // Credentials stores the WebAuthn credentials. Credentials auth.Credentials // Sessions mints/rotates/revokes the credential. Sessions auth.Sessions // Users loads the display name for the WebAuthn user entity. Users users.Users // WebAuthn is the ceremony seam (fake in tests). WebAuthn webauthn.Service // TOTP issues and verifies the second factor. TOTP totp.Service // CSRF issues/verifies the pre-session double-submit token and lends its // signer for the ceremony cookie. CSRF *httpx.PreSessionCSRF // Audit receives the enroll/login/logout events (optional). Audit audit.Audit // SuccessPath, when set, overrides where a successful enroll/login lands // (default "/{guid}/home"). SPEC-005's wizard injects "/setup" so the // bootstrap stays on the wizard until it is finished. SuccessPath func(guid string) string // TOTPOnlyEnroll activates a PENDING identity on a verified TOTP code // ALONE (D-041). The /setup wizard sets it: a passkey registered over // plain-HTTP loopback binds RP ID 127.0.0.1 and is unusable at the public // domain (O-13). Default false keeps SPEC-006 §4's mandatory two factors. TOTPOnlyEnroll bool // SuppressNudge, when non-nil and true, disables the passkey nudge // (SPEC-006 §8). The door wires it to setup mode, where a passkey is // useless. nil = the nudge runs (subject to the other conditions). SuppressNudge func() bool // BaseURL is the CONFIGURED public origin. It decides the nudge's origin // signal: a proxy that rewrites `Host` must not flip the decision (L7c). // Empty falls back to the request `Host`. BaseURL string // Logger receives structured logs. Defaults to slog.Default(). Logger *slog.Logger // Now is the clock (tests). Now func() time.Time } // Actions is the SPEC-006 flow engine. type Actions struct { d Deps } // New validates and builds the flow. func New(d Deps) (*Actions, error) { switch { case d.Identities == nil: return nil, errors.New("flow: Identities is required") case d.Credentials == nil: return nil, errors.New("flow: Credentials is required") case d.Sessions == nil: return nil, errors.New("flow: Sessions is required") case d.WebAuthn == nil: return nil, errors.New("flow: WebAuthn is required") case d.TOTP == nil: return nil, errors.New("flow: TOTP is required") case d.CSRF == nil: return nil, errors.New("flow: CSRF is required") } if d.Logger == nil { d.Logger = slog.Default() } if d.Now == nil { d.Now = time.Now } return &Actions{d: d}, nil } // ── screens ───────────────────────────────────────────────────────────────── // EnrollScreen renders the enrollment screen for a pending GUID (SPEC-006 §4). func (a *Actions) EnrollScreen(w http.ResponseWriter, r *http.Request, guid string) error { ctx := r.Context() idn, ok, err := a.pendingInWindow(ctx, guid) if err != nil { return err } if !ok { // Unknown, revoked, already active or past the window. An already-active // identity is sent to the app; everything else is a generic 404 (A1). if idn.Status == auth.StatusActive { http.Redirect(w, r, "/"+guid+"/home", http.StatusFound) return nil } writeError(w, http.StatusNotFound, "not found") return nil } token, err := a.d.CSRF.Ensure(w, r) if err != nil { return err } return renderHTML(w, r, http.StatusOK, enrollPage(guid, token)) } // LoginScreen renders the login screen for an active GUID with no session // (SPEC-006 §5). It answers 200 — a rendered page is a successful response; // only a FAILED credential attempt is 401/400 (F-1, SPEC-005 §7). func (a *Actions) LoginScreen(w http.ResponseWriter, r *http.Request, guid string) error { ctx := r.Context() idn, err := a.resolve(ctx, guid) if err != nil { if errors.Is(err, auth.ErrIdentityNotFound) { writeError(w, http.StatusNotFound, "not found") return nil } return err } if idn.Status != auth.StatusActive { writeError(w, http.StatusNotFound, "not found") return nil } token, err := a.d.CSRF.Ensure(w, r) if err != nil { return err } return renderHTML(w, r, http.StatusOK, loginPage(guid, token)) } // ── enrollment actions ────────────────────────────────────────────────────── // EnrollBegin starts the WebAuthn registration ceremony (SPEC-006 §4). func (a *Actions) EnrollBegin(w http.ResponseWriter, r *http.Request, guid string) error { ctx := r.Context() idn, ok, err := a.pendingInWindow(ctx, guid) if err != nil { return err } if !ok { writeError(w, http.StatusNotFound, "not found") return nil } creds, err := a.d.Credentials.WebAuthnCredentials(ctx, idn.ID) if err != nil { return err } begin, err := a.d.WebAuthn.BeginRegistration(ctx, a.webauthnUser(ctx, idn), creds) if err != nil { // A ceremony error is a client-side problem, not a server fault. a.d.Logger.Warn("flow: begin registration failed", "error", err) writeError(w, http.StatusBadRequest, "bad request") return nil } if err := a.putCeremony(w, r, begin); err != nil { return err } return writeJSON(w, http.StatusOK, begin.Options) } // EnrollFinish completes the WebAuthn registration and stores the credential. // It does NOT enroll the identity — TOTP must still follow (AC-2). func (a *Actions) EnrollFinish(w http.ResponseWriter, r *http.Request, guid string) error { ctx := r.Context() idn, ok, err := a.pendingInWindow(ctx, guid) if err != nil { return err } if !ok { writeError(w, http.StatusNotFound, "not found") return nil } begin, err := a.takeCeremony(w, r) if err != nil { writeError(w, http.StatusBadRequest, "bad request") return nil } cred, err := a.d.WebAuthn.FinishRegistration(ctx, a.webauthnUser(ctx, idn), begin, r) if err != nil { a.d.Logger.Warn("flow: finish registration failed", "error", err) writeError(w, http.StatusBadRequest, "bad request") return nil } if err := a.d.Credentials.PutWebAuthnCredential(ctx, idn.ID, cred); err != nil { return err } a.audit(ctx, "enroll.passkey", idn, "passkey registered") return writeJSON(w, http.StatusOK, []byte(`{"status":"credential-stored"}`)) } // IssueTOTP issues (and encrypts) a fresh TOTP secret for the identity behind // guid and returns the otpauth URL. It WRITES NO RESPONSE — the caller owns the // reply: the JSON ceremony below (the WebAuthn client's contract), or SPEC-005's // wizard, which is server-rendered and no-JS and renders the secret itself. // // A guid that does not resolve to a pending identity inside its bounded // enrollment window reports errNotPending (mapped to 404 by TOTPBegin). func (a *Actions) IssueTOTP(ctx context.Context, guid string) (string, error) { idn, ok, err := a.pendingInWindow(ctx, guid) if err != nil { return "", err } if !ok { return "", errNotPending } issued, err := a.d.TOTP.Begin(ctx, idn.ID, idn.GUID) if err != nil { return "", err } a.audit(ctx, "enroll.totp.begin", idn, "secret issued") return issued.URL, nil } // TOTPBegin answers the WebAuthn client with the otpauth URL as JSON — // webauthn.js's contract, kept intact. It is IssueTOTP plus the JSON write. func (a *Actions) TOTPBegin(w http.ResponseWriter, r *http.Request, guid string) error { url, err := a.IssueTOTP(r.Context(), guid) if err != nil { if errors.Is(err, errNotPending) { writeError(w, http.StatusNotFound, "not found") return nil } return err } payload, err := json.Marshal(map[string]string{"otpauth_url": url}) if err != nil { return err } return writeJSON(w, http.StatusOK, payload) } // TOTPVerify verifies a code. For a PENDING identity it completes enrollment — // but ONLY when a passkey credential already exists (two-factor, AC-2), unless // TOTPOnlyEnroll is set (the /setup wizard, D-041). For an ACTIVE identity it // is the login fallback (SPEC-006 §5) and may trigger the passkey nudge (§8). func (a *Actions) TOTPVerify(w http.ResponseWriter, r *http.Request, guid string) error { ctx := r.Context() idn, err := a.resolve(ctx, guid) if err != nil { if errors.Is(err, auth.ErrIdentityNotFound) { writeError(w, http.StatusNotFound, "not found") return nil } return err } code := r.FormValue("code") switch idn.Status { case auth.StatusActive: // Login fallback (Passkey is primary; this is device-loss recovery). if err := a.d.TOTP.Verify(ctx, idn.ID, code); err != nil { a.failAuth(w) return nil } a.audit(ctx, "login.totp", idn, "totp") // The passkey nudge (SPEC-006 §8): a TOTP login with no passkey, on a // non-loopback origin, is nudged to add one. A TOTP login WITH a // passkey is never nudged; nor is a passkey login (login.finish). target := "" if a.shouldNudge(ctx, r, idn) { target = nudgePath(idn.GUID) } return a.establishSessionAt(w, r, idn, target) case auth.StatusPendingEnrollment: inWindow, err := a.d.Identities.WithinEnrollmentWindow(ctx, idn.ID) if err != nil { return err } if !inWindow { writeError(w, http.StatusNotFound, "not found") return nil } // Both factors are mandatory for a normal enrollment (AC-2). The // /setup wizard is the exception: it enrolls TOTP only (D-041), since // a loopback-enrolled passkey is unusable at the public domain. detail := "passkey+totp" if a.d.TOTPOnlyEnroll { detail = "totp" } else { hasPasskey, err := a.d.Credentials.HasWebAuthnCredential(ctx, idn.ID) if err != nil { return err } if !hasPasskey { writeError(w, http.StatusBadRequest, "bad request") return nil } } if err := a.d.TOTP.Verify(ctx, idn.ID, code); err != nil { a.failAuth(w) return nil } if err := a.d.Identities.Activate(ctx, idn.ID); err != nil { return err } a.audit(ctx, "enroll.complete", idn, detail) return a.establishSession(w, r, idn) default: // revoked — tombstoned writeError(w, http.StatusNotFound, "not found") return nil } } // ── login actions ─────────────────────────────────────────────────────────── // LoginBegin starts the WebAuthn assertion ceremony (SPEC-006 §5). func (a *Actions) LoginBegin(w http.ResponseWriter, r *http.Request, guid string) error { ctx := r.Context() idn, err := a.resolveActive(ctx, guid) if err != nil { if errors.Is(err, auth.ErrIdentityNotFound) { writeError(w, http.StatusNotFound, "not found") return nil } return err } creds, err := a.d.Credentials.WebAuthnCredentials(ctx, idn.ID) if err != nil { return err } if len(creds) == 0 { writeError(w, http.StatusBadRequest, "bad request") return nil } begin, err := a.d.WebAuthn.BeginLogin(ctx, a.webauthnUser(ctx, idn), creds) if err != nil { a.d.Logger.Warn("flow: begin login failed", "error", err) writeError(w, http.StatusBadRequest, "bad request") return nil } if err := a.putCeremony(w, r, begin); err != nil { return err } return writeJSON(w, http.StatusOK, begin.Options) } // LoginFinish verifies the assertion and mints (or rotates) the session. func (a *Actions) LoginFinish(w http.ResponseWriter, r *http.Request, guid string) error { ctx := r.Context() idn, err := a.resolveActive(ctx, guid) if err != nil { if errors.Is(err, auth.ErrIdentityNotFound) { writeError(w, http.StatusNotFound, "not found") return nil } return err } begin, err := a.takeCeremony(w, r) if err != nil { writeError(w, http.StatusBadRequest, "bad request") return nil } creds, err := a.d.Credentials.WebAuthnCredentials(ctx, idn.ID) if err != nil { return err } cred, err := a.d.WebAuthn.FinishLogin(ctx, a.webauthnUser(ctx, idn), begin, r, creds) if err != nil { a.d.Logger.Warn("flow: finish login failed", "error", err) a.failAuth(w) return nil } if err := a.d.Credentials.UpdateWebAuthnSignCount(ctx, idn.ID, cred.ID, cred.SignCount); err != nil { return err } a.audit(ctx, "login.passkey", idn, "passkey") return a.establishSession(w, r, idn) } // Logout revokes the current session and clears the cookie (SPEC-006 §5). func (a *Actions) Logout(w http.ResponseWriter, r *http.Request, guid string) error { ctx := r.Context() // The cookie name/Secure flag follow the request scheme (D-043). secure := httpx.SecureFromRequest(r) if c, err := r.Cookie(httpx.SessionCookieNameFor(secure)); err == nil && c.Value != "" { if err := a.d.Sessions.Revoke(ctx, c.Value); err != nil { a.d.Logger.Warn("flow: logout revoke failed", "error", err) } } httpx.ClearSessionCookie(w, httpx.SessionCookieNameFor(secure), secure) http.Redirect(w, r, "/home", http.StatusSeeOther) return nil } // ── internals ─────────────────────────────────────────────────────────────── // resolve loads the identity for a GUID. A missing or tombstoned GUID reports // auth.ErrIdentityNotFound (mapped to 404); any other error is a real fault and // is propagated (500). func (a *Actions) resolve(ctx context.Context, guid string) (auth.Identity, error) { if guid == "" { return auth.Identity{}, auth.ErrIdentityNotFound } idn, err := a.d.Identities.ByGUID(ctx, guid) if err != nil { return auth.Identity{}, err } if idn.Status == auth.StatusRevoked { return auth.Identity{}, auth.ErrIdentityNotFound } return idn, nil } // resolveActive resolves a GUID that must map to an ACTIVE identity. func (a *Actions) resolveActive(ctx context.Context, guid string) (auth.Identity, error) { idn, err := a.resolve(ctx, guid) if err != nil { return auth.Identity{}, err } if idn.Status != auth.StatusActive { return auth.Identity{}, auth.ErrIdentityNotFound } return idn, nil } // pendingInWindow resolves a GUID that must map to a pending identity still // inside its bounded enrollment window (A1). The returned identity is also // present when ok=false so the caller can distinguish "already active". func (a *Actions) pendingInWindow(ctx context.Context, guid string) (auth.Identity, bool, error) { idn, err := a.resolve(ctx, guid) if err != nil { if errors.Is(err, auth.ErrIdentityNotFound) { return auth.Identity{}, false, nil } return auth.Identity{}, false, err } if idn.Status != auth.StatusPendingEnrollment { return idn, false, nil } inWindow, err := a.d.Identities.WithinEnrollmentWindow(ctx, idn.ID) if err != nil { return auth.Identity{}, false, err } return idn, inWindow, nil } // webauthnUser builds the ceremony user for an identity. func (a *Actions) webauthnUser(ctx context.Context, idn auth.Identity) webauthn.User { display := idn.GUID if a.d.Users != nil { if u, err := a.d.Users.Get(ctx, idn.UserID); err == nil && u.DisplayName != "" { display = u.DisplayName } } return webauthn.User{ ID: handleFor(idn.UserID), Name: idn.GUID, DisplayName: display, } } // handleFor derives the WebAuthn user handle from the stable user id. func handleFor(userID int64) []byte { return []byte(fmt.Sprintf("myday-user-%d", userID)) } // establishSession mints a session on success, rotating a live one when the // request already carries a valid session for the same identity (fixation // defence, SPEC-006 §5), then redirects to the app. func (a *Actions) establishSession(w http.ResponseWriter, r *http.Request, idn auth.Identity) error { return a.establishSessionAt(w, r, idn, "") } // establishSessionAt is establishSession with an optional explicit landing // path (the passkey nudge overrides the default success target, SPEC-006 §8). func (a *Actions) establishSessionAt(w http.ResponseWriter, r *http.Request, idn auth.Identity, target string) error { sess, err := a.issueSession(r, idn) if err != nil { return err } secure := httpx.SecureFromRequest(r) // per-request scheme (D-043) httpx.SetSessionCookie(w, httpx.SessionCookieNameFor(secure), sess.ID, secure) if target == "" { target = a.successTarget(idn.GUID) } http.Redirect(w, r, target, http.StatusSeeOther) return nil } // successTarget is where a successful flow lands: the configured SuccessPath // (the wizard's "/setup") or the user's dashboard. func (a *Actions) successTarget(guid string) string { if a.d.SuccessPath != nil { if p := a.d.SuccessPath(guid); p != "" { return p } } return "/" + guid + "/home" } // nudgePath is where a TOTP login lands when the passkey nudge applies // (SPEC-006 §8): the session-gated passkey screen carried by the `auth` module. func nudgePath(guid string) string { return "/" + guid + "/auth/passkey" } // shouldNudge reports whether the passkey nudge applies to a TOTP login // (SPEC-006 §8): the identity has NO passkey, the user has not opted out, the // origin is NOT loopback, and the nudge is not suppressed (setup mode). Any // lookup error fails safe — no nudge. func (a *Actions) shouldNudge(ctx context.Context, r *http.Request, idn auth.Identity) bool { if a.d.SuppressNudge != nil && a.d.SuppressNudge() { return false } if httpx.IsLoopbackHost(a.originHost(r)) { return false } if a.d.Users == nil { return false } optOut, err := a.d.Users.PasskeyOptOut(ctx, idn.UserID) if err != nil { a.d.Logger.Warn("flow: passkey opt-out lookup failed", "error", err) return false } if optOut { return false } hasPasskey, err := a.d.Credentials.HasWebAuthnCredential(ctx, idn.ID) if err != nil { a.d.Logger.Warn("flow: credential lookup failed", "error", err) return false } return !hasPasskey } // originHost is the origin the nudge is judged against (L7c): the CONFIGURED // base_url's host when set — so a reverse proxy that rewrites `Host` cannot // wrongly suppress (or enable) the nudge — falling back to the request `Host`. func (a *Actions) originHost(r *http.Request) string { if u, err := url.Parse(strings.TrimSpace(a.d.BaseURL)); err == nil && u.Host != "" { return u.Host } return r.Host } // ── the passkey nudge (SPEC-006 §8, D-041) ────────────────────────────────── // PasskeyPrompt renders the passkey nudge screen. It is a session-gated ROUTE // on the `auth` module (GET /{GUID}/auth/passkey) — the redirect target of a // nudged TOTP login. It BEGINS the WebAuthn registration ceremony and embeds // the options for the browser, so the single `passkey.add` action completes it. // // The door has already validated the session, so the forms carry the // SESSION-bound CSRF token (SPEC-003 §6) — A15 does not apply. func (a *Actions) PasskeyPrompt(w http.ResponseWriter, r *http.Request, guid string) error { ctx := r.Context() idn, err := a.resolveActive(ctx, guid) if err != nil { if errors.Is(err, auth.ErrIdentityNotFound) { writeError(w, http.StatusNotFound, "not found") return nil } return err } creds, err := a.d.Credentials.WebAuthnCredentials(ctx, idn.ID) if err != nil { return err } begin, err := a.d.WebAuthn.BeginRegistration(ctx, a.webauthnUser(ctx, idn), creds) if err != nil { a.d.Logger.Warn("flow: begin passkey registration failed", "error", err) writeError(w, http.StatusBadRequest, "bad request") return nil } if err := a.putCeremony(w, r, begin); err != nil { return err } return renderHTML(w, r, http.StatusOK, passkeyPage(guid, httpx.TokenFromRequest(r), string(begin.Options))) } // PasskeyAdd completes the WebAuthn registration begun by PasskeyPrompt and // stores the credential (audited, AC-11). It is session-gated by the door. func (a *Actions) PasskeyAdd(w http.ResponseWriter, r *http.Request, guid string) error { ctx := r.Context() idn, err := a.resolveActive(ctx, guid) if err != nil { if errors.Is(err, auth.ErrIdentityNotFound) { writeError(w, http.StatusNotFound, "not found") return nil } return err } begin, err := a.takeCeremony(w, r) if err != nil { writeError(w, http.StatusBadRequest, "bad request") return nil } cred, err := a.d.WebAuthn.FinishRegistration(ctx, a.webauthnUser(ctx, idn), begin, r) if err != nil { a.d.Logger.Warn("flow: finish passkey registration failed", "error", err) writeError(w, http.StatusBadRequest, "bad request") return nil } if err := a.d.Credentials.PutWebAuthnCredential(ctx, idn.ID, cred); err != nil { return err } a.audit(ctx, "passkey.add", idn, "passkey registered from the login nudge") http.Redirect(w, r, a.successTarget(idn.GUID), http.StatusSeeOther) return nil } // PasskeyLater skips the nudge this time. Nothing is persisted (SPEC-006 §8) — // the user is nudged again at the next TOTP login. func (a *Actions) PasskeyLater(w http.ResponseWriter, r *http.Request, guid string) error { ctx := r.Context() idn, err := a.resolveActive(ctx, guid) if err != nil { if errors.Is(err, auth.ErrIdentityNotFound) { writeError(w, http.StatusNotFound, "not found") return nil } return err } http.Redirect(w, r, a.successTarget(idn.GUID), http.StatusSeeOther) return nil } // PasskeyNever records the persistent opt-out (users.passkey_opt_out, D-041). // The write is audited by the users service (A11); later TOTP logins never // nudge again. The marker survives identity rotation. func (a *Actions) PasskeyNever(w http.ResponseWriter, r *http.Request, guid string) error { ctx := r.Context() idn, err := a.resolveActive(ctx, guid) if err != nil { if errors.Is(err, auth.ErrIdentityNotFound) { writeError(w, http.StatusNotFound, "not found") return nil } return err } if a.d.Users == nil { return errors.New("flow: Users is required for the passkey opt-out") } if err := a.d.Users.SetPasskeyOptOut(ctx, idn.UserID, true); err != nil { return err } http.Redirect(w, r, a.successTarget(idn.GUID), http.StatusSeeOther) return nil } // issueSession rotates a matching live session, else mints a fresh one. func (a *Actions) issueSession(r *http.Request, idn auth.Identity) (auth.Session, error) { ctx := r.Context() var ip string if m, ok := audit.MetaFrom(ctx); ok { ip = m.ClientIP } if c, err := r.Cookie(httpx.SessionCookieNameFor(httpx.SecureFromRequest(r))); err == nil && c.Value != "" { if old, err := a.d.Sessions.Validate(ctx, c.Value); err == nil && old.IdentityID == idn.ID { return a.d.Sessions.Rotate(ctx, c.Value) } } return a.d.Sessions.Mint(ctx, idn.ID, ip, r.UserAgent()) } // putCeremony signs the WebAuthn state and stores it in a short-lived cookie. func (a *Actions) putCeremony(w http.ResponseWriter, r *http.Request, b *webauthn.Begin) error { raw, err := json.Marshal(b) if err != nil { return fmt.Errorf("flow: marshal ceremony: %w", err) } http.SetCookie(w, &http.Cookie{ Name: CeremonyCookieName, Value: a.d.CSRF.Signer().Sign(string(raw)), Path: "/", HttpOnly: true, Secure: httpx.SecureFromRequest(r), // per-request scheme (D-043) SameSite: http.SameSiteLaxMode, MaxAge: 300, }) return nil } // takeCeremony reads and clears the ceremony cookie. func (a *Actions) takeCeremony(w http.ResponseWriter, r *http.Request) (*webauthn.Begin, error) { c, err := r.Cookie(CeremonyCookieName) if err != nil || c.Value == "" { return nil, errors.New("flow: missing ceremony state") } raw, ok := a.d.CSRF.Signer().Unsign(c.Value) if !ok { return nil, errors.New("flow: invalid ceremony state") } var b webauthn.Begin if err := json.Unmarshal([]byte(raw), &b); err != nil { return nil, fmt.Errorf("flow: decode ceremony: %w", err) } // Single use. http.SetCookie(w, &http.Cookie{ Name: CeremonyCookieName, Value: "", Path: "/", HttpOnly: true, Secure: httpx.SecureFromRequest(r), // per-request scheme (D-043) SameSite: http.SameSiteLaxMode, MaxAge: -1, }) return &b, nil } // audit records a flow event. The actor is the affected user (they proved it); // the identity is the subject. func (a *Actions) audit(ctx context.Context, action string, idn auth.Identity, detail string) { if a.d.Audit == nil { return } a.d.Audit.Record(ctx, audit.Event{ ActorUserID: idn.UserID, Action: action, Subject: fmt.Sprintf("identity:%d", idn.ID), Detail: detail, }) } // failAuth answers with a terse, generic 401 — never revealing which factor // failed, whether the identity exists, or why (A3). func (a *Actions) failAuth(w http.ResponseWriter) { writeError(w, http.StatusUnauthorized, "unauthorized") } // writeError writes a terse plain-text error. func writeError(w http.ResponseWriter, status int, msg string) { w.Header().Set("Content-Type", "text/plain; charset=utf-8") w.Header().Set("X-Content-Type-Options", "nosniff") w.WriteHeader(status) _, _ = w.Write([]byte(msg + "\n")) } // writeJSON writes a JSON body. Every ceremony response is `no-store` (F-2): // the TOTP response embeds the otpauth URL, which contains the secret. func writeJSON(w http.ResponseWriter, status int, body []byte) error { w.Header().Set("Cache-Control", "no-store") w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(status) _, err := w.Write(body) return err }