oficina / home Public

Passkeys as an optional second factor, gated on both sign-in paths

§13 step 2, Task 6. Unblocked by ruling 21: §11 has its carve-out, so
the enrolment and ceremony screens may load script and nothing else may.

## The Gate is on both paths, structurally

The plan says to call passkey.Gate twice, once on the magic-link path
and once on the keymail callback, because an earlier draft named only
the first — which left a keymail-upgraded account with a passkey
enrolled signing in on a single factor.

Both handlers funnel through one completeSignIn, so the Gate is called
once and covers both. That is better than remembering two call sites,
and a test now enforces it: sessions.SignIn must appear in exactly one
non-test file in the package, passkey.Gate in exactly one, and they must
be the same file. A future handler that mints a session directly would
bypass the second factor silently; this makes it loud.

Verified by breaking it. Removing the Gate fails both
TestEnrolledPersonCannotSignInWithOneFactor ("one factor minted a full
session for an enrolled person") and the call-site guard.

## What is here

- passkey.Schema merged into BootSchema. Not optional once the Gate is
  wired: Gate INSERTs into passkey_pending on the first enrolled
  sign-in, so omitting it fails at the database rather than at boot. All
  four passkey tables key on subject, which here is already the person
  ref, so the worry about inheriting an address-bearing subject does not
  arise.
- Seven ceremony routes plus the pages and assets they imply, all behind
  csrf.Protect.
- The confirm page carries the ceremony button AND the recovery form on
  one screen. The recovery form is a plain POST that needs no script,
  which is the point: recovery is exactly the moment WebAuthn is not
  working. Ten codes, single use, asserted.
- The two script files sit on stable, unfingerprinted paths, because the
  scriptless sweep's allowlist has to name them and a rule that cannot
  name its subject is not a rule. Content-Type is set explicitly: a
  wrong MIME type blocks a module silently.
- userName is the person ref, never the address. The credential origin
  never renders an account-authored string (§4) and this app does not
  hold the address anyway. It reads poorly in a password manager; that
  is the cost of §0, accepted rather than worked around.
- LegacyRPID is deliberately empty — it exists for an instance that
  changed hostname, and naming one that was never used would widen what
  assertions are accepted for no benefit.
- The enrolment button starts hidden and is revealed only when script
  has run and the platform has WebAuthn, so nobody is offered a control
  that cannot work.

## One deviation from the plan, deliberate

The plan lists GET /passkey/codes as the show-once page. Minting ten
codes invalidates the previous ten, so a GET that regenerates is a
state-changing GET — and a link prefetcher would silently destroy
somebody's recovery set. That is the same argument this whole step rests
on for the magic link. GET renders the count and a form; POST
regenerates. Both behind RequireFresh at five minutes: short, because
the page shows ten codes once, and long enough to exceed a ceremony
round trip. Tested by ageing a session a day and watching the page
close — both time columns, because sessions.Fresh falls back to At when
AuthTime is zero.

Enrolment in tests runs the real ceremony against rastrillo's own
software authenticator rather than a hand-rolled credential.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E4uVkB6xnZJYuxTooPiUn4
Paul Campbell pushed by paul@keymail.dev 3328173f78cce349ce0d87c279a1c4660d05c7b3 parent 494db92
10 files changed, +651 −5
  • internal/home/app.go +37 −2
  • internal/home/models.go +9 −1
  • internal/home/passkey.go +165 −0
  • internal/home/passkey_test.go +261 −0
  • internal/home/signin.go +25 −2
  • internal/home/static/passkey.js +83 −0
  • internal/home/templates/passkey_confirm.html +18 −0
  • internal/home/templates/passkey_enrol.html +9 −0
  • internal/home/templates/recovery_codes.html +17 −0
  • internal/home/testapp_test.go +27 −0
diff --git a/internal/home/app.go b/internal/home/app.go
index b1d5584..802209c 100644
--- a/internal/home/app.go
+++ b/internal/home/app.go
@@ -19,6 +19,7 @@ import (
"github.com/carlosframework/rastrillo/csrf"
"github.com/carlosframework/rastrillo/db"
+ "github.com/carlosframework/rastrillo/passkey"
"github.com/carlosframework/rastrillo/sessions"
"github.com/go-chi/chi/v5"
"github.com/keymaildev/signin"
@@ -37,6 +38,7 @@ type App struct {
renderer *renderer
flow *signin.Flow
sessions *sessions.Sessions
+ passkeys *passkey.Handlers
}
// New builds an App on validated secrets, and refuses anything less.
@@ -89,7 +91,7 @@ func New(secrets Secrets, d *db.DB, logger *slog.Logger) (*App, error) {
return nil, err
}
- return &App{
+ a := &App{
secrets: secrets,
db: d,
links: links,
@@ -97,7 +99,12 @@ func New(secrets Secrets, d *db.DB, logger *slog.Logger) (*App, error) {
renderer: rend,
flow: newFlow(secrets, links),
sessions: sess,
- }, nil
+ }
+
+ if a.passkeys, err = newPasskeys(a); err != nil {
+ return nil, err
+ }
+ return a, nil
}
// Router returns the mounted handler.
@@ -132,5 +139,33 @@ func (a *App) Router() http.Handler {
r.Post(linkPath, a.handleVerify)
r.Get(callbackPath, a.handleCallback)
+ // The second factor. The ceremony endpoints are JSON POSTs driven by
+ // static/passkey.js; the recovery POST is a plain form and works
+ // with script off, because recovery is exactly the moment WebAuthn
+ // is not working.
+ r.Get(confirmPath, a.handlePasskeyConfirm)
+ r.Post("/passkey/register/begin", a.passkeys.RegisterBegin)
+ r.Post("/passkey/register/finish", a.passkeys.RegisterFinish)
+ r.Post("/passkey/stepup/begin", a.passkeys.StepUpBegin)
+ r.Post("/passkey/stepup/finish", a.passkeys.StepUpFinish)
+ r.Post("/passkey/signin/begin", a.passkeys.SignInBegin)
+ r.Post("/passkey/signin/finish", a.passkeys.SignInFinish)
+ r.Post("/passkey/signin/recovery", a.passkeys.SignInRecovery)
+ r.Get("/passkey/enrol", a.handlePasskeyEnrol)
+
+ // The codes page shows ten codes once, so it wants a recent session
+ // rather than merely a valid one.
+ r.Group(func(r chi.Router) {
+ r.Use(a.sessions.RequireFresh(codesFreshness))
+ r.Get("/passkey/codes", a.handleRecoveryCodesPage)
+ r.Post("/passkey/codes", a.handleRegenerateRecoveryCodes)
+ })
+
+ // Stable, unfingerprinted paths: Task 7's scriptless allowlist has
+ // to name them, and a rule that cannot name its subject is not a
+ // rule.
+ r.Get("/static/webauthn.mjs", serveAsset(webauthnJS(), "text/javascript; charset=utf-8"))
+ r.Get("/static/passkey.js", serveAsset(passkeyGlue(), "text/javascript; charset=utf-8"))
+
return r
}
diff --git a/internal/home/models.go b/internal/home/models.go
index 3b3291c..cd6f085 100644
--- a/internal/home/models.go
+++ b/internal/home/models.go
@@ -4,6 +4,7 @@ import (
"embed"
"github.com/carlosframework/rastrillo/migrate"
+ "github.com/carlosframework/rastrillo/passkey"
"github.com/carlosframework/rastrillo/sessions"
)
@@ -28,4 +29,11 @@ var Schema = migrate.MustFromFS(migrationsFS, "home")
// subject is the part that matters — it must be the person ref, never
// the address — and that is enforced where sessions are minted, not
// here.
-var BootSchema = migrate.Merge(sessions.Schema, Schema)
+// passkey.Schema is not optional once the Gate is wired: Gate INSERTs
+// into passkey_pending on the first enrolled sign-in, so omitting it
+// fails at the database rather than at boot.
+//
+// All four passkey tables key on subject, which here is already the
+// person ref — so the worry about inheriting an address-bearing subject
+// does not arise. Said here rather than left to be rediscovered.
+var BootSchema = migrate.Merge(sessions.Schema, passkey.Schema, Schema)
diff --git a/internal/home/passkey.go b/internal/home/passkey.go
new file mode 100644
index 0000000..a18f9d1
--- /dev/null
+++ b/internal/home/passkey.go
@@ -0,0 +1,165 @@
+package home
+
+import (
+ "embed"
+ "encoding/base64"
+ "net/http"
+ "net/url"
+ "time"
+
+ "github.com/carlosframework/rastrillo/passkey"
+ "github.com/carlosframework/rastrillo/webauthn"
+)
+
+//go:embed static/passkey.js
+var staticFS embed.FS
+
+// confirmPath is where the Gate sends a first-factor-verified caller who
+// still has a passkey to assert. Named rather than defaulted: it happens
+// to be passkey's own default, and a route this file mounts should not
+// depend on a default in another package staying put.
+const confirmPath = "/passkey/confirm"
+
+// codesFreshness is how recent a session must be to see recovery codes.
+//
+// Short, because the page shows ten codes once. Long enough to exceed a
+// passkey ceremony round trip, or enrolling and then being asked for
+// your codes would bounce straight back to a step-up.
+const codesFreshness = 5 * time.Minute
+
+// newPasskeys wires the second factor.
+//
+// LegacyRPID is deliberately empty. It exists for an instance that has
+// changed hostname, so that passkeys enrolled under the old name still
+// assert; this app has had exactly one credential origin, and naming a
+// legacy RP that was never used would widen what assertions are accepted
+// for no benefit.
+func newPasskeys(a *App) (*passkey.Handlers, error) {
+ return passkey.New(passkey.Config{
+ Sessions: a.sessions,
+ DB: a.db.Writer(),
+ Origin: Origin,
+ ConfirmPath: confirmPath,
+ Logger: a.logger,
+ })
+}
+
+// pageData is what every passkey page needs: the values the glue reads
+// off <body> to run a ceremony.
+//
+// UserName is the person ref, NOT the address. The credential origin
+// never renders an account-authored string (§4), and this app does not
+// hold the address anyway. A person ref reads poorly in a password
+// manager; that is the cost of §0, and it is accepted rather than worked
+// around.
+func (a *App) passkeyPageData(subject string, extra map[string]any) map[string]any {
+ data := map[string]any{
+ "RPID": rpID(),
+ "UserID": base64.RawURLEncoding.EncodeToString([]byte(subject)),
+ "UserName": subject,
+ }
+ for k, v := range extra {
+ data[k] = v
+ }
+ return data
+}
+
+// handlePasskeyConfirm is the Gate's landing page: the ceremony button
+// and the recovery form on one screen.
+//
+// The recovery form is a plain POST and works with script off, which is
+// the point — recovery is exactly the moment WebAuthn is not working.
+func (a *App) handlePasskeyConfirm(w http.ResponseWriter, r *http.Request) {
+ a.render(w, http.StatusOK, "passkey_confirm.html", a.passkeyPageData("", map[string]any{
+ "RecoveryFailed": r.URL.Query().Get("recovery") == "failed",
+ }))
+}
+
+// handlePasskeyEnrol offers the optional second factor to a signed-in
+// person.
+func (a *App) handlePasskeyEnrol(w http.ResponseWriter, r *http.Request) {
+ sess, ok := a.sessions.From(r)
+ if !ok {
+ http.Redirect(w, r, "/signin", http.StatusSeeOther)
+ return
+ }
+ a.render(w, http.StatusOK, "passkey_enrol.html", a.passkeyPageData(sess.Subject, nil))
+}
+
+// handleRecoveryCodesPage shows how many codes are left, and the form
+// that replaces them.
+//
+// Regenerating is a POST, not a GET. The plan's route list named only a
+// GET, but minting ten codes and invalidating the previous ten is a
+// state change, and this whole step exists on the principle that a GET
+// does not change state — a link prefetcher would otherwise silently
+// invalidate somebody's recovery codes.
+func (a *App) handleRecoveryCodesPage(w http.ResponseWriter, r *http.Request) {
+ sess, ok := a.sessions.From(r)
+ if !ok {
+ http.Redirect(w, r, "/signin", http.StatusSeeOther)
+ return
+ }
+ remaining, err := a.passkeys.RecoveryCodesRemaining(sess.Subject)
+ if err != nil {
+ a.logger.Error("count recovery codes", "person", sess.Subject, "err", err)
+ http.Error(w, "internal error", http.StatusInternalServerError)
+ return
+ }
+ a.render(w, http.StatusOK, "recovery_codes.html", map[string]any{"Remaining": remaining})
+}
+
+// handleRegenerateRecoveryCodes mints a fresh set and shows it once.
+func (a *App) handleRegenerateRecoveryCodes(w http.ResponseWriter, r *http.Request) {
+ sess, ok := a.sessions.From(r)
+ if !ok {
+ http.Redirect(w, r, "/signin", http.StatusSeeOther)
+ return
+ }
+ codes, err := a.passkeys.RegenerateRecoveryCodes(sess.Subject)
+ if err != nil {
+ a.logger.Error("regenerate recovery codes", "person", sess.Subject, "err", err)
+ http.Error(w, "internal error", http.StatusInternalServerError)
+ return
+ }
+ a.render(w, http.StatusOK, "recovery_codes.html", map[string]any{"Codes": codes})
+}
+
+// serveAsset serves one of the two script files.
+//
+// Stable and unfingerprinted on purpose: the scriptless sweep's
+// allowlist has to name them.
+//
+// The Content-Type is set explicitly even though Go's mime table already
+// maps .mjs. A wrong MIME type makes a browser refuse a module silently,
+// which is the worst way for this to fail.
+func serveAsset(body []byte, contentType string) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", contentType)
+ w.Header().Set("Cache-Control", "no-cache")
+ w.WriteHeader(http.StatusOK)
+ w.Write(body)
+ }
+}
+
+func passkeyGlue() []byte {
+ b, err := staticFS.ReadFile("static/passkey.js")
+ if err != nil {
+ // Embedded above; unreachable unless the build is broken.
+ panic(err)
+ }
+ return b
+}
+
+func webauthnJS() []byte { return webauthn.JS() }
+
+// rpID is the WebAuthn relying-party ID: the credential origin's
+// hostname, derived from the one Origin constant rather than written out
+// again, so the two cannot drift.
+func rpID() string {
+ u, err := url.Parse(Origin)
+ if err != nil {
+ panic("home: Origin is not a URL: " + err.Error())
+ }
+ return u.Hostname()
+}
diff --git a/internal/home/passkey_test.go b/internal/home/passkey_test.go
new file mode 100644
index 0000000..2f719d8
--- /dev/null
+++ b/internal/home/passkey_test.go
@@ -0,0 +1,261 @@
+package home
+
+import (
+ "encoding/base64"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/rastrillo/webauthn/authtest"
+)
+
+// signInFully takes a person all the way to a full session and returns
+// the session cookie.
+func signInFully(t *testing.T, app *testApp, addr string) *http.Cookie {
+ t.Helper()
+ signIn(t, app, addr)
+ rec := redeem(t, app, capturedLink(t, app))
+ c := sessionCookie(rec)
+ if c == nil {
+ t.Fatalf("no session cookie after redeeming (status %d)", rec.Code)
+ }
+ return c
+}
+
+// postJSON drives one of the ceremony endpoints.
+func postJSON(t *testing.T, app *testApp, path string, cookie *http.Cookie, body any) *httptest.ResponseRecorder {
+ t.Helper()
+ var payload string
+ if body != nil {
+ b, err := json.Marshal(body)
+ if err != nil {
+ t.Fatal(err)
+ }
+ payload = string(b)
+ } else {
+ payload = "{}"
+ }
+ req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(payload))
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("Origin", Origin)
+ if cookie != nil {
+ req.AddCookie(cookie)
+ }
+ w := httptest.NewRecorder()
+ app.Router().ServeHTTP(w, req)
+ return w
+}
+
+func challengeFrom(t *testing.T, rec *httptest.ResponseRecorder) []byte {
+ t.Helper()
+ if rec.Code != http.StatusOK {
+ t.Fatalf("begin: status %d, body %s", rec.Code, rec.Body.String())
+ }
+ var body struct {
+ Challenge string `json:"challenge"`
+ }
+ if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
+ t.Fatalf("decode challenge: %v (body %s)", err, rec.Body.String())
+ }
+ raw, err := base64.RawURLEncoding.DecodeString(body.Challenge)
+ if err != nil {
+ t.Fatalf("challenge is not base64url: %v", err)
+ }
+ return raw
+}
+
+// enrol runs the real registration ceremony against a software
+// authenticator — rastrillo's own, rather than a hand-rolled credential.
+func enrol(t *testing.T, app *testApp, cookie *http.Cookie) *authtest.Authenticator {
+ t.Helper()
+ a, err := authtest.New()
+ if err != nil {
+ t.Fatal(err)
+ }
+ challenge := challengeFrom(t, postJSON(t, app, "/passkey/register/begin", cookie, nil))
+ clientData, attestation := a.Create(challenge, authtest.Options{RPID: rpID(), Origin: Origin})
+ rec := postJSON(t, app, "/passkey/register/finish", cookie, map[string]string{
+ "clientDataJSON": base64.RawURLEncoding.EncodeToString(clientData),
+ "attestationObject": base64.RawURLEncoding.EncodeToString(attestation),
+ })
+ if rec.Code != http.StatusOK {
+ t.Fatalf("RegisterFinish: status %d, body %s", rec.Code, rec.Body.String())
+ }
+ return a
+}
+
+// TestEnrolledPersonCannotSignInWithOneFactor is what the whole task is
+// for. A magic link alone must not open an enrolled account.
+func TestEnrolledPersonCannotSignInWithOneFactor(t *testing.T) {
+ app := newTestApp(t)
+ const addr = "person@example.invalid"
+
+ cookie := signInFully(t, app, addr)
+ enrol(t, app, cookie)
+
+ // A fresh sign-in, first factor only.
+ signIn(t, app, addr)
+ rec := redeem(t, app, capturedLink(t, app))
+
+ if got := rec.Header().Get("Location"); got != confirmPath {
+ t.Errorf("redemption went to %q, want %q — the Gate did not take over", got, confirmPath)
+ }
+ if c := sessionCookie(rec); c != nil {
+ t.Fatal("one factor minted a full session for an enrolled person")
+ }
+}
+
+// The control: an account with no passkey is unaffected.
+func TestUnenrolledPersonSignsInWithOneFactor(t *testing.T) {
+ app := newTestApp(t)
+ rec := redeemAfterSignIn(t, app, "nobody@example.invalid")
+ if got := rec.Header().Get("Location"); got != "/" {
+ t.Errorf("redemption went to %q, want / — the Gate should pass an unenrolled account through", got)
+ }
+ if sessionCookie(rec) == nil {
+ t.Error("an unenrolled person got no session")
+ }
+}
+
+func redeemAfterSignIn(t *testing.T, app *testApp, addr string) *httptest.ResponseRecorder {
+ t.Helper()
+ signIn(t, app, addr)
+ return redeem(t, app, capturedLink(t, app))
+}
+
+// The Gate is on both sign-in paths because there is exactly one place
+// that mints a session, not because two call sites were remembered.
+// This is the guard: a future handler that calls sessions.SignIn
+// directly would bypass the second factor silently, and this makes that
+// loud.
+func TestSessionsSignInHasExactlyOneCallSite(t *testing.T) {
+ got := grepPackage(t, "a.sessions.SignIn(")
+ if len(got) != 1 {
+ t.Errorf("sessions.SignIn is called in %d places (%v); it must be exactly one, "+
+ "or a caller can mint a session without passing passkey.Gate", len(got), got)
+ }
+ gate := grepPackage(t, "a.passkeys.Gate(")
+ if len(gate) != 1 {
+ t.Errorf("passkey.Gate is called in %d places (%v), want exactly one", len(gate), gate)
+ }
+ if len(got) == 1 && len(gate) == 1 && got[0] != gate[0] {
+ t.Errorf("the Gate is in %s but the session is minted in %s", gate[0], got[0])
+ }
+}
+
+// A recovery code signs somebody in with no script at all — a plain
+// form POST, which is the point: recovery is exactly the moment
+// WebAuthn is not working.
+func TestRecoveryCodeCompletesSignInWithoutScript(t *testing.T) {
+ app := newTestApp(t)
+ const addr = "person@example.invalid"
+
+ cookie := signInFully(t, app, addr)
+ enrol(t, app, cookie)
+
+ codes, err := app.passkeys.RegenerateRecoveryCodes(subjectOf(t, app, cookie))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(codes) != 10 {
+ t.Fatalf("got %d recovery codes, want 10 (§5.2 says ten)", len(codes))
+ }
+
+ // First factor again, which now stops at the Gate and leaves the
+ // pending half-session cookie behind.
+ signIn(t, app, addr)
+ gated := redeem(t, app, capturedLink(t, app))
+
+ form := url.Values{"code": {codes[0]}}
+ req := httptest.NewRequest(http.MethodPost, "/passkey/signin/recovery", strings.NewReader(form.Encode()))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ req.Header.Set("Origin", Origin)
+ for _, c := range (&http.Response{Header: gated.Header()}).Cookies() {
+ req.AddCookie(c)
+ }
+ w := httptest.NewRecorder()
+ app.Router().ServeHTTP(w, req)
+
+ if sessionCookie(w) == nil {
+ t.Fatalf("a recovery code did not sign anybody in (status %d, location %q)",
+ w.Code, w.Header().Get("Location"))
+ }
+
+ // And it is single-use.
+ var n int
+ if err := app.db.Writer().QueryRow("SELECT count(*) FROM passkey_recovery_codes").Scan(&n); err != nil {
+ t.Fatal(err)
+ }
+ if n != 9 {
+ t.Errorf("%d codes left after redeeming one of ten, want 9", n)
+ }
+}
+
+func subjectOf(t *testing.T, app *testApp, cookie *http.Cookie) string {
+ t.Helper()
+ req := httptest.NewRequest(http.MethodGet, "/", nil)
+ req.AddCookie(cookie)
+ sess, ok := app.sessions.From(req)
+ if !ok {
+ t.Fatal("the session cookie does not resolve")
+ }
+ return sess.Subject
+}
+
+// Both script files must stay on stable, unfingerprinted paths, because
+// the scriptless sweep's allowlist names them.
+func TestScriptAssetsAreServedOnNamedPaths(t *testing.T) {
+ app := newTestApp(t)
+ for _, path := range []string{"/static/webauthn.mjs", "/static/passkey.js"} {
+ req := httptest.NewRequest(http.MethodGet, path, nil)
+ w := httptest.NewRecorder()
+ app.Router().ServeHTTP(w, req)
+ if w.Code != http.StatusOK {
+ t.Errorf("GET %s = %d, want 200", path, w.Code)
+ }
+ if ct := w.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/javascript") {
+ t.Errorf("GET %s Content-Type = %q; a wrong MIME type blocks a module silently", path, ct)
+ }
+ if w.Body.Len() == 0 {
+ t.Errorf("GET %s served an empty body", path)
+ }
+ }
+}
+
+// The codes page shows ten codes once, so a merely valid session is not
+// enough — it wants a recent one. Without this, a stolen session cookie
+// of any age reads somebody's whole recovery set.
+func TestRecoveryCodesPageNeedsAFreshSession(t *testing.T) {
+ app := newTestApp(t)
+ cookie := signInFully(t, app, "person@example.invalid")
+
+ get := func() int {
+ req := httptest.NewRequest(http.MethodGet, "/passkey/codes", nil)
+ req.AddCookie(cookie)
+ w := httptest.NewRecorder()
+ app.Router().ServeHTTP(w, req)
+ return w.Code
+ }
+
+ if code := get(); code != http.StatusOK {
+ t.Fatalf("a fresh session got %d from /passkey/codes, want 200", code)
+ }
+
+ // Age the session past the freshness window. Both columns, because
+ // sessions.Fresh falls back to At when AuthTime is zero — checking
+ // only one would leave this passing on a session that is still
+ // fresh by the other.
+ old := time.Now().Add(-24 * time.Hour).UTC().Format(time.RFC3339)
+ if _, err := app.db.Writer().Exec(
+ `UPDATE sessions SET auth_time = ?, created_at = ?`, old, old); err != nil {
+ t.Fatal(err)
+ }
+
+ if code := get(); code == http.StatusOK {
+ t.Error("a day-old session still reads the recovery codes page")
+ }
+}
diff --git a/internal/home/signin.go b/internal/home/signin.go
index 05483d5..0a5c33a 100644
--- a/internal/home/signin.go
+++ b/internal/home/signin.go
@@ -249,12 +249,35 @@ func (a *App) completeSignIn(w http.ResponseWriter, r *http.Request, id signin.I
a.signinFailed(w)
return
}
- if err := a.sessions.SignIn(w, r, sessions.Session{
+ sess := sessions.Session{
Subject: ref,
Method: string(id.Method),
AuthTime: id.AuthTime,
At: id.At,
- }); err != nil {
+ }
+
+ // The second factor, on BOTH sign-in paths — and on both because
+ // there is one place to put it, not because two call sites were
+ // remembered. handleVerify (the magic link) and handleCallback (the
+ // keymail return) both funnel through here, so an enrolled person
+ // cannot obtain a full session from one factor down either door.
+ // The bug this guards against is real: it is what happens when only
+ // the magic-link path is gated and a keymail-upgraded account walks
+ // in through the other one.
+ //
+ // Gate redirects and reports true when it has taken over. An account
+ // with no passkey passes through untouched.
+ gated, err := a.passkeys.Gate(w, r, sess)
+ if err != nil {
+ a.logger.Error("passkey gate", "person", ref, "err", err)
+ a.signinFailed(w)
+ return
+ }
+ if gated {
+ return
+ }
+
+ if err := a.sessions.SignIn(w, r, sess); err != nil {
a.logger.Error("mint session", "person", ref, "err", err)
a.signinFailed(w)
return
diff --git a/internal/home/static/passkey.js b/internal/home/static/passkey.js
new file mode 100644
index 0000000..6598320
--- /dev/null
+++ b/internal/home/static/passkey.js
@@ -0,0 +1,83 @@
+// The app's own glue. rastrillo's webauthn.mjs only exports register()
+// and authenticate() and makes no network call of its own, so this file
+// is what imports it, calls it, POSTs to /passkey/* and follows where
+// the server says to go.
+//
+// It is a static, same-origin module, not an inline block: rastrillo's
+// default CSP forbids inline script, and §11's carve-out is for these
+// two static files and nothing else. Its path is stable and
+// unfingerprinted so the scriptless sweep can name it — a rule that
+// cannot name its subject is not a rule.
+import { register, authenticate, available } from "/static/webauthn.mjs";
+
+async function post(path, body) {
+ const res = await fetch(path, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body || {}),
+ });
+ if (!res.ok) throw new Error(path + ": " + res.status);
+ return res.json();
+}
+
+function say(el, message) {
+ const status = document.getElementById("passkey-status");
+ if (status) status.textContent = message;
+ if (el) el.disabled = false;
+}
+
+// Every button here starts hidden in the markup and is revealed only
+// once script has run and the platform actually has WebAuthn. A person
+// with script off, or on a device with no authenticator, never sees a
+// control that cannot work — they see the recovery form, which needs
+// neither.
+function reveal() {
+ if (!available()) return;
+ for (const el of document.querySelectorAll("[data-passkey]")) el.hidden = false;
+}
+
+async function enrol(button) {
+ button.disabled = true;
+ try {
+ const begin = await post("/passkey/register/begin");
+ const made = await register({
+ challenge: begin.challenge,
+ rpId: document.body.dataset.rpid,
+ rpName: "Oficina",
+ userId: document.body.dataset.userid,
+ userName: document.body.dataset.username,
+ });
+ await post("/passkey/register/finish", {
+ clientDataJSON: made.clientDataJSON,
+ attestationObject: made.attestationObject,
+ });
+ window.location.assign("/passkey/codes");
+ } catch (err) {
+ say(button, "That did not work. You can try again.");
+ }
+}
+
+async function confirm(button) {
+ button.disabled = true;
+ try {
+ const begin = await post("/passkey/signin/begin");
+ const got = await authenticate({
+ challenge: begin.challenge,
+ rpId: document.body.dataset.rpid,
+ legacyRpId: document.body.dataset.legacyrpid || undefined,
+ });
+ const done = await post("/passkey/signin/finish", {
+ id: got.credentialId,
+ clientDataJSON: got.clientDataJSON,
+ authenticatorData: got.authenticatorData,
+ signature: got.signature,
+ });
+ window.location.assign(done.to || "/");
+ } catch (err) {
+ say(button, "That did not work. Use a recovery code below.");
+ }
+}
+
+reveal();
+document.getElementById("passkey-enrol")?.addEventListener("click", (e) => enrol(e.currentTarget));
+document.getElementById("passkey-confirm")?.addEventListener("click", (e) => confirm(e.currentTarget));
diff --git a/internal/home/templates/passkey_confirm.html b/internal/home/templates/passkey_confirm.html
new file mode 100644
index 0000000..e3af196
--- /dev/null
+++ b/internal/home/templates/passkey_confirm.html
@@ -0,0 +1,18 @@
+{{define "title"}}Confirm it is you · Oficina{{end}}
+{{define "content"}}
+<h1>Confirm it is you</h1>
+
+{{if .RecoveryFailed}}<p role="alert">That code did not work.</p>{{end}}
+
+<button id="passkey-confirm" type="button" data-passkey hidden>Use your passkey</button>
+<p id="passkey-status" role="status"></p>
+
+<h2>Or use a recovery code</h2>
+<p>Recovery is exactly the moment a passkey is not working, so this form
+needs no script and no device.</p>
+<form method="post" action="/passkey/signin/recovery">
+ <label for="code">Recovery code</label>
+ <input id="code" name="code" type="text" autocomplete="one-time-code" required>
+ <button type="submit">Sign in</button>
+</form>
+{{end}}
diff --git a/internal/home/templates/passkey_enrol.html b/internal/home/templates/passkey_enrol.html
new file mode 100644
index 0000000..7e55572
--- /dev/null
+++ b/internal/home/templates/passkey_enrol.html
@@ -0,0 +1,9 @@
+{{define "title"}}Add a passkey · Oficina{{end}}
+{{define "content"}}
+<h1>Add a passkey</h1>
+<p>A passkey is an optional second step. Without one, your email link is
+the only thing standing between someone with your inbox and your account.</p>
+<button id="passkey-enrol" type="button" data-passkey hidden>Add a passkey</button>
+<p id="passkey-status" role="status"></p>
+<noscript><p>Adding a passkey needs JavaScript. Everything else here does not.</p></noscript>
+{{end}}
diff --git a/internal/home/templates/recovery_codes.html b/internal/home/templates/recovery_codes.html
new file mode 100644
index 0000000..6972661
--- /dev/null
+++ b/internal/home/templates/recovery_codes.html
@@ -0,0 +1,17 @@
+{{define "title"}}Recovery codes · Oficina{{end}}
+{{define "content"}}
+<h1>Recovery codes</h1>
+{{if .Codes}}
+<p><strong>These are shown once.</strong> Keep them somewhere your passkey
+is not. Each one works a single time.</p>
+<ul>{{range .Codes}}<li><code>{{.}}</code></li>{{end}}</ul>
+<p>Losing your passkey and these codes together locks you out permanently.
+There is no operator who can undo that, by design.</p>
+{{else}}
+<p>You have {{.Remaining}} unused recovery codes.</p>
+<p>Generating a new set <strong>invalidates every code you have now</strong>.</p>
+<form method="post" action="/passkey/codes">
+ <button type="submit">Generate a new set</button>
+</form>
+{{end}}
+{{end}}
diff --git a/internal/home/testapp_test.go b/internal/home/testapp_test.go
index 0b3da0b..c059684 100644
--- a/internal/home/testapp_test.go
+++ b/internal/home/testapp_test.go
@@ -3,7 +3,9 @@ package home
import (
"context"
"errors"
+ "os"
"path/filepath"
+ "strings"
"sync"
"testing"
@@ -126,3 +128,28 @@ func newTestApp(t *testing.T) *testApp {
return &testApp{App: app, mailer: mailer, db: d, dbPath: dbPath}
}
+
+// grepPackage returns the files in this package (non-test) containing
+// needle. Used by the guards that count call sites of the two functions
+// whose "exactly once" property is load-bearing.
+func grepPackage(t *testing.T, needle string) []string {
+ t.Helper()
+ files, err := filepath.Glob("*.go")
+ if err != nil {
+ t.Fatal(err)
+ }
+ var found []string
+ for _, f := range files {
+ if strings.HasSuffix(f, "_test.go") {
+ continue
+ }
+ b, err := os.ReadFile(f)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for range strings.Count(string(b), needle) {
+ found = append(found, f)
+ }
+ }
+ return found
+}