oficina / home Public

Merge branch 'feat/team-entry-transport' into main

claude agent, operated by paul@keymail.dev a1c145951d24da4f91485a916eb5d2881153b6e3 parent 7d68e87parent 3c550c3
42 files changed, +2360 −304
  • Makefile +7 −0
  • cmd/home/main.go +8 −1
  • docs/superpowers/specs/2026-09-07-credential-return.md +13 −0
  • go.mod +2 −1
  • go.sum +20 −0
  • handoff/go.mod +21 −0
  • handoff/go.sum +55 −0
  • handoff/http.go +209 −0
  • handoff/http_test.go +186 −0
  • handoff/migrations/0001_exchange.sql +26 −0
  • handoff/store.go +362 −0
  • handoff/store_test.go +325 −0
  • implementation/team-entry.md +54 −0
  • internal/home/app.go +89 −64
  • internal/home/app_test.go +2 −0
  • internal/home/keymail_test.go +1 −1
  • internal/home/keymailstub_test.go +1 −0
  • internal/home/logs_test.go +8 −0
  • internal/home/markup_test.go +33 −0
  • internal/home/noscript_test.go +30 −15
  • internal/home/origins.go +106 −0
  • internal/home/origins_config_test.go +139 −0
  • internal/home/origins_test.go +102 −0
  • internal/home/passkey.go +3 −15
  • internal/home/passkey_test.go +10 −1
  • internal/home/rpid_test.go +2 −2
  • internal/home/security.go +8 −0
  • internal/home/security_test.go +38 −0
  • internal/home/signin.go +9 −6
  • internal/home/signin_return.go +68 −0
  • internal/home/signin_return_factor_test.go +98 −0
  • internal/home/signin_return_test.go +161 −0
  • internal/home/signin_test.go +11 −0
  • internal/home/static/theme.css +0 −174
  • internal/home/static/tokens.css +126 −12
  • internal/home/templates/layout.html +1 −1
  • internal/home/templates/passkey_confirm.html +7 −3
  • internal/home/templates/passkey_enrol.html +1 −1
  • internal/home/templates/recovery_codes.html +2 −2
  • internal/home/templates/signin.html +7 −3
  • internal/home/templates/signin_confirm.html +3 −1
  • internal/home/testapp_test.go +6 −1

CI passed — run details

diff --git a/Makefile b/Makefile
index 14576af..37690c1 100644
--- a/Makefile
+++ b/Makefile
@@ -1,3 +1,6 @@
+private_comma := ,
+export GOPRIVATE := $(if $(GOPRIVATE),$(GOPRIVATE)$(private_comma))amadan.net/oficina/estilo.git
+
.PHONY: build test test-vet test-fmt test-build test-go test-race
# One binary, CGO_ENABLED=0, cgo-free SQLite (AGENTS.md / Deploys). Set
@@ -19,6 +22,7 @@ test: test-vet test-fmt test-build test-go test-race
test-vet:
CGO_ENABLED=0 go vet ./...
+ cd handoff && CGO_ENABLED=0 go vet ./...
# Fails on unformatted files rather than rewriting them. CI must never
# silently "fix" a diff out from under a branch — a red test-fmt tells
@@ -32,9 +36,11 @@ test-fmt:
test-build:
CGO_ENABLED=0 go build ./...
+ cd handoff && CGO_ENABLED=0 go build ./...
test-go:
CGO_ENABLED=0 go test -count=1 ./...
+ cd handoff && CGO_ENABLED=0 go test -count=1 ./...
# The one concurrency-bearing package. internal/home's link store is
# taken by a single DELETE ... RETURNING precisely so that two clicks on
@@ -48,3 +54,4 @@ test-go:
# cgo-free binary rule, which is about what gets deployed.
test-race:
go test -race -count=1 ./internal/home/
+ cd handoff && CGO_ENABLED=1 go test -race -count=1 ./...
diff --git a/cmd/home/main.go b/cmd/home/main.go
index 27df38a..de742d6 100644
--- a/cmd/home/main.go
+++ b/cmd/home/main.go
@@ -95,7 +95,13 @@ func main() {
os.Exit(1)
}
- app, err := home.New(secrets, d, logger)
+ origins, err := home.LoadOrigins()
+ if err != nil {
+ logger.Error("config", "err", err)
+ os.Exit(1)
+ }
+
+ app, err := home.NewWithOrigins(secrets, d, logger, origins)
if err != nil {
logger.Error("wire app", "err", err)
os.Exit(1)
@@ -106,6 +112,7 @@ func main() {
mux := http.NewServeMux()
mux.Handle("/", app.Router())
opts.Mux = mux
+ opts.CSP = app.SecurityPolicy()
// Blanked because this file opened the handle itself: Serve would
// otherwise open a second one against the same file.
diff --git a/docs/superpowers/specs/2026-09-07-credential-return.md b/docs/superpowers/specs/2026-09-07-credential-return.md
new file mode 100644
index 0000000..b501134
--- /dev/null
+++ b/docs/superpowers/specs/2026-09-07-credential-return.md
@@ -0,0 +1,13 @@
+# Credential-page sign-in continuation
+
+Status: bounded draft, independent highest-model review pending; base651e5b3. No deployment approval.
+
+Authority: Home design §4 credential-origin separation, §5.1 sign-in, §5.2 optional second factor/recovery and existing freshness-protected routes. No auth protocol, factor or freshness rule changes.
+
+RequireFresh already redirects stale enrol/codes GETs to /signin?return_to=... . The form discards that navigation intent and completeSignIn always redirects /. Carry only the exact compiled destinations /passkey/enrol and /passkey/codes in a host-only Secure HttpOnly SameSite=Lax cookie, Path=/, browser MaxAge ten minutes. This bounds ordinary browser navigation retention, not a server-validated authentication lifetime. Replaying an allowed navigation target grants no authority; existing session and freshness validation remain authoritative. No address, identity, authority or content is stored. A hostile allowed target grants nothing: its existing RequireFresh still applies. No custom signing/encryption/session protocol.
+
+GET /signin accepts one query return_to only when it matches the allowlist. Plain sign-in clears previous intent; the existing force=1 Keymail fallback preserves a validated existing intent. Invalid/duplicate query input clears it. POST body/query return_to never supplies intent. Verified magic-link or Keymail completion validates the cookie again and clears it. Missing/cross-browser cookie returns /. Failed first-factor completion never mints a session; ordinary Keymail failure preserves navigation through its force fallback.
+
+Pass the validated target through a cloned request to existing passkey.Gate, replacing both Form and PostForm for its SafeReturn read. Do not let posted or query fields override it. Existing Gate stores that navigation target in its normal pending row; its real assertion/recovery completion consumes it. Without a factor, only the existing completeSignIn session mint redirects to the target. No additional mint, freshness update or gate bypass.
+
+Regressions: stale enrol/codes → form → magic link or Keymail → exact destination; failed Keymail → forced magic link; missing/invalid/duplicate intent returns /; external/protocol-relative/encoded/CRLF targets refused; body/query smuggling cannot change pending target; enrolled users remain pending without full session until real passkey or recovery; failed factor cannot navigate with authorization; intent cookie alone never opens credential pages. Check short-lived host-only cookie attributes and clearing. Preserve sealed-at-rest/no-address gates. Local checks and exact-head CI precede queued independent review, then parent-controlled canary.
diff --git a/go.mod b/go.mod
index 08b036c..16b4c93 100644
--- a/go.mod
+++ b/go.mod
@@ -3,7 +3,8 @@ module amadan.net/oficina/home
go 1.25.0
require (
- github.com/carlosframework/rastrillo v0.22.0
+ amadan.net/oficina/estilo.git v0.0.0-20260908001419-672a447da335
+ github.com/carlosframework/rastrillo v0.23.0
github.com/go-chi/chi/v5 v5.3.2
github.com/keymaildev/signin v0.1.1
golang.org/x/crypto v0.55.0
diff --git a/go.sum b/go.sum
index 46d8a82..43597ac 100644
--- a/go.sum
+++ b/go.sum
@@ -1,11 +1,31 @@
+amadan.net/oficina/estilo.git v0.0.0-20260907201639-532746acdbc1 h1:9Qrzr6xT0QuaTAK4Bq3M8nWDCgkBmXy7H9BR3C+DZOo=
+amadan.net/oficina/estilo.git v0.0.0-20260907201639-532746acdbc1/go.mod h1:AZvUebwQkfBh2d0TIOAO/AViIIsRx774jL7ic8XOckg=
+amadan.net/oficina/estilo.git v0.0.0-20260908001419-672a447da335 h1:YogZKYINrPNXRfPedXxOIDbGCMY4ff7yAdohYphEWMg=
+amadan.net/oficina/estilo.git v0.0.0-20260908001419-672a447da335/go.mod h1:0R45/vCUAT+kpS7S6K/ZxBdE8f7YxFN4HT3HyDJ3nPY=
github.com/carlosframework/rastrillo v0.22.0 h1:tG02WAiy2VSYqmLT6kRfYbUaiJd/pBhTIWxxjjsEoI8=
github.com/carlosframework/rastrillo v0.22.0/go.mod h1:pZlrE5F5OhspvZFzef0DrfjHAjA8FXycb9J5/8vc8Tw=
+github.com/carlosframework/rastrillo v0.23.0 h1:tKAAlwPt4Pg/rkqk7o6jvV12bbZ0r34oEVpuOlKfX7w=
+github.com/carlosframework/rastrillo v0.23.0/go.mod h1:pZlrE5F5OhspvZFzef0DrfjHAjA8FXycb9J5/8vc8Tw=
+github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327 h1:UQ4AU+BGti3Sy/aLU8KVseYKNALcX9UXY6DfpwQ6J8E=
+github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327/go.mod h1:NItd7aLkcfOA/dcMXvl8p1u+lQqioRMq/SqDp71Pb/k=
+github.com/chromedp/chromedp v0.14.2 h1:r3b/WtwM50RsBZHMUm9fsNhhzRStTHrKdr2zmwbZSzM=
+github.com/chromedp/chromedp v0.14.2/go.mod h1:rHzAv60xDE7VNy/MYtTUrYreSc0ujt2O1/C3bzctYBo=
+github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM=
+github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/go-chi/chi/v5 v5.3.2 h1:5YQkICvTCSZ25hoRsyJazN0scjzKGiu4VAUc7H1o1nY=
github.com/go-chi/chi/v5 v5.3.2/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
+github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 h1:iizUGZ9pEquQS5jTGkh4AqeeHCMbfbjeb0zMt0aEFzs=
+github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2/go.mod h1:TiCD2a1pcmjd7YnhGH0f/zKNcCD06B029pHhzV23c2M=
github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc=
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
+github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
+github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
+github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
+github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
+github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
+github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
diff --git a/handoff/go.mod b/handoff/go.mod
new file mode 100644
index 0000000..6be0a1e
--- /dev/null
+++ b/handoff/go.mod
@@ -0,0 +1,21 @@
+module amadan.net/oficina/home/handoff
+
+go 1.25.0
+
+require (
+ amadan.net/rastrillo/rastrillo v0.26.1-0.20260908075707-29e4491631bd
+ modernc.org/sqlite v1.55.0
+)
+
+require (
+ github.com/dustin/go-humanize v1.0.1 // indirect
+ github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 // indirect
+ github.com/google/uuid v1.6.0 // indirect
+ github.com/mattn/go-isatty v0.0.20 // indirect
+ github.com/ncruces/go-strftime v1.0.0 // indirect
+ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
+ golang.org/x/sys v0.46.0 // indirect
+ modernc.org/libc v1.74.1 // indirect
+ modernc.org/mathutil v1.7.1 // indirect
+ modernc.org/memory v1.11.0 // indirect
+)
diff --git a/handoff/go.sum b/handoff/go.sum
new file mode 100644
index 0000000..8bd49e5
--- /dev/null
+++ b/handoff/go.sum
@@ -0,0 +1,55 @@
+amadan.net/rastrillo/rastrillo v0.26.1-0.20260908075707-29e4491631bd h1:jlFSt95M1ontOg+VePU+kIJpChtWqDQAbnmlxqCnbgc=
+amadan.net/rastrillo/rastrillo v0.26.1-0.20260908075707-29e4491631bd/go.mod h1:RpyHVPD0udcSfHJroY0KZE/FUjgw6Tvfo8SJm2qeZGE=
+github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
+github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
+github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 h1:iizUGZ9pEquQS5jTGkh4AqeeHCMbfbjeb0zMt0aEFzs=
+github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2/go.mod h1:TiCD2a1pcmjd7YnhGH0f/zKNcCD06B029pHhzV23c2M=
+github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
+github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
+github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
+github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
+github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
+golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
+golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
+golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
+golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
+golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
+golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
+modernc.org/cc/v4 v4.29.0 h1:CXgwL8cvxmyzBQZzbSl/6xFtMCryb6u8IOqDci39cgc=
+modernc.org/cc/v4 v4.29.0/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
+modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU=
+modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk=
+modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
+modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
+modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
+modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
+modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI=
+modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
+modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
+modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
+modernc.org/libc v1.74.1 h1:bdR4VTKFMC4966QSNZ05XLGI/VwzVa2kTUX51Dm0riQ=
+modernc.org/libc v1.74.1/go.mod h1:uH4t5bOx3G3g9Xcmj10YKlTcVISlRDwv8VoQJG9n8Os=
+modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
+modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
+modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
+modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
+modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
+modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
+modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
+modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
+modernc.org/sqlite v1.55.0 h1:hIFh0MCH0rGinQ/4KYb5/UbCkRkb+UP+OkLCVWa5MTM=
+modernc.org/sqlite v1.55.0/go.mod h1:4ntCLuNmnH8+GNqjka1wNg7KJd5/Hi5FYp8K+XQ7GZw=
+modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
+modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
+modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
+modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
diff --git a/handoff/http.go b/handoff/http.go
new file mode 100644
index 0000000..0eaaf54
--- /dev/null
+++ b/handoff/http.go
@@ -0,0 +1,209 @@
+package handoff
+
+import (
+ "io"
+ "mime"
+ "net/http"
+ "net/url"
+ "strings"
+ "time"
+
+ "amadan.net/rastrillo/rastrillo/assertion"
+)
+
+const (
+ StartPath = "/auth/start"
+ CallbackPath = "/auth/assertion"
+ FinishPath = "/auth/finish"
+ pendingCookie = "__Host-oficina-assertion-pending"
+ inboxCookie = "__Host-oficina-assertion-inbox"
+)
+
+type ConsumerConfig struct {
+ Store *Store
+ ConsumerID string
+ // Resolve before store transactions, using the app's session middleware.
+ CurrentSubject func(*http.Request) string
+ // Complete runs only after replay consumption is committed. It creates the
+ // app's session, preserving AuthTime. An error cannot restore the assertion.
+ Complete func(http.ResponseWriter, *http.Request, assertion.Claims) error
+ Fail func(http.ResponseWriter, *http.Request, int)
+ Now func() time.Time
+}
+type Consumer struct{ cfg ConsumerConfig }
+
+func NewConsumer(cfg ConsumerConfig) (*Consumer, error) {
+ if cfg.Store == nil || cfg.Store.db == nil || cfg.Complete == nil || !consumerID(cfg.ConsumerID) {
+ return nil, ErrConfig
+ }
+ if cfg.Now == nil {
+ cfg.Now = time.Now
+ }
+ if cfg.CurrentSubject == nil {
+ cfg.CurrentSubject = func(*http.Request) string { return "" }
+ }
+ if cfg.Fail == nil {
+ cfg.Fail = func(w http.ResponseWriter, r *http.Request, status int) {
+ http.Error(w, "Unable to sign in. Start again.", status)
+ }
+ }
+ return &Consumer{cfg}, nil
+}
+func consumerID(s string) bool {
+ if len(s) == 0 || len(s) > 64 {
+ return false
+ }
+ for _, b := range []byte(s) {
+ if !(b >= 'a' && b <= 'z' || b >= 'A' && b <= 'Z' || b >= '0' && b <= '9' || b == '_' || b == '-') {
+ return false
+ }
+ }
+ return true
+}
+
+func (c *Consumer) guard(w http.ResponseWriter, r *http.Request, method, path string) bool {
+ w.Header().Set("Cache-Control", "no-store")
+ if r.Host != strings.TrimPrefix(c.cfg.Store.audience, "https://") || r.URL.Path != path {
+ c.cfg.Fail(w, r, http.StatusNotFound)
+ return false
+ }
+ if r.Method != method {
+ w.Header().Set("Allow", method)
+ c.cfg.Fail(w, r, http.StatusMethodNotAllowed)
+ return false
+ }
+ return true
+}
+func cookie(r *http.Request, name string) (string, bool) {
+ value := ""
+ count := 0
+ for _, c := range r.Cookies() {
+ if c.Name == name {
+ count++
+ value = c.Value
+ }
+ }
+ return value, count == 1 && validHandle(value)
+}
+func setCookie(w http.ResponseWriter, name, value string, seconds int) {
+ http.SetCookie(w, &http.Cookie{Name: name, Value: value, Path: "/", MaxAge: seconds, Secure: true, HttpOnly: true, SameSite: http.SameSiteLaxMode})
+}
+func (c *Consumer) fail(w http.ResponseWriter, r *http.Request, e error) {
+ status := http.StatusBadRequest
+ if e == ErrStorage || e == ErrClock || e == ErrFull {
+ status = http.StatusServiceUnavailable
+ }
+ c.cfg.Fail(w, r, status)
+}
+
+// Start accepts a bounded local return_to query and writes only sealed intent.
+func (c *Consumer) Start(w http.ResponseWriter, r *http.Request) {
+ if !c.guard(w, r, http.MethodGet, StartPath) {
+ return
+ }
+ q, e := url.ParseQuery(r.URL.RawQuery)
+ if e != nil || len(q) > 1 || len(q["return_to"]) > 1 {
+ c.fail(w, r, ErrRefused)
+ return
+ }
+ path := "/"
+ if len(q) > 0 {
+ values, ok := q["return_to"]
+ if !ok || len(values) != 1 {
+ c.fail(w, r, ErrRefused)
+ return
+ }
+ path = values[0]
+ }
+ matches := 0
+ for _, item := range r.Cookies() {
+ if item.Name == pendingCookie {
+ matches++
+ }
+ }
+ if matches > 1 {
+ c.fail(w, r, ErrRefused)
+ return
+ }
+ previous, ok := cookie(r, pendingCookie)
+ if !ok {
+ previous = ""
+ }
+ secret, nonce, e := c.cfg.Store.Start(r.Context(), path, previous, c.cfg.Now())
+ if e != nil {
+ c.fail(w, r, e)
+ return
+ }
+ setCookie(w, pendingCookie, secret, int(pendingLifetime))
+ target := c.cfg.Store.issuer + "/authorize?" + url.Values{"consumer": {c.cfg.ConsumerID}, "nonce": {nonce}}.Encode()
+ http.Redirect(w, r, target, http.StatusSeeOther)
+}
+
+// Callback is the one route mounted outside ordinary same-origin CSRF checks.
+// It requires the exact issuer Origin even for a same-site request and needs no
+// cookies. Keep every other app mutation inside the app's CSRF middleware.
+func (c *Consumer) Callback(w http.ResponseWriter, r *http.Request) {
+ if !c.guard(w, r, http.MethodPost, CallbackPath) {
+ return
+ }
+ origins := r.Header.Values("Origin")
+ if len(origins) != 1 || origins[0] != c.cfg.Store.issuer || r.URL.RawQuery != "" {
+ c.fail(w, r, ErrRefused)
+ return
+ }
+ media, _, e := mime.ParseMediaType(r.Header.Get("Content-Type"))
+ if e != nil || media != "application/x-www-form-urlencoded" {
+ c.fail(w, r, ErrRefused)
+ return
+ }
+ // Form encoding expands the 8192-byte core wire bound; 12 KiB comfortably
+ // holds a base64url token while bounding input before parsing or crypto.
+ body, e := io.ReadAll(http.MaxBytesReader(w, r.Body, 12*1024))
+ if e != nil {
+ c.fail(w, r, ErrRefused)
+ return
+ }
+ form, e := url.ParseQuery(string(body))
+ if e != nil || len(form) != 1 || len(form["assertion"]) != 1 {
+ c.fail(w, r, ErrRefused)
+ return
+ }
+ handle, e := c.cfg.Store.Receive(r.Context(), form["assertion"][0], c.cfg.Now())
+ if e != nil {
+ c.fail(w, r, e)
+ return
+ }
+ setCookie(w, inboxCookie, handle, int(inboxLifetime))
+ http.Redirect(w, r, FinishPath, http.StatusSeeOther)
+}
+
+// Finish receives the Lax cookies on the top-level GET after the callback's
+// redirect. Only its browser-bound transaction can reach Complete.
+func (c *Consumer) Finish(w http.ResponseWriter, r *http.Request) {
+ if !c.guard(w, r, http.MethodGet, FinishPath) {
+ return
+ }
+ if r.URL.RawQuery != "" {
+ c.fail(w, r, ErrRefused)
+ return
+ }
+ secret, ok := cookie(r, pendingCookie)
+ handle, have := cookie(r, inboxCookie)
+ if !ok || !have {
+ c.fail(w, r, ErrRefused)
+ return
+ }
+ setCookie(w, pendingCookie, "", -1)
+ setCookie(w, inboxCookie, "", -1)
+ existing := c.cfg.CurrentSubject(r)
+ claims, path, e := c.cfg.Store.Finish(r.Context(), secret, handle, existing, c.cfg.Now())
+ if e != nil {
+ c.fail(w, r, e)
+ return
+ }
+ if e = c.cfg.Complete(w, r, claims); e != nil {
+ c.fail(w, r, ErrStorage)
+ return
+ }
+ http.Redirect(w, r, path, http.StatusSeeOther)
+}
diff --git a/handoff/http_test.go b/handoff/http_test.go
new file mode 100644
index 0000000..3decfe3
--- /dev/null
+++ b/handoff/http_test.go
@@ -0,0 +1,186 @@
+package handoff
+
+import (
+ "errors"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strings"
+ "testing"
+ "time"
+
+ "amadan.net/rastrillo/rastrillo/assertion"
+)
+
+func newConsumer(t *testing.T, f fixture, done func(http.ResponseWriter, *http.Request, assertion.Claims) error) *Consumer {
+ t.Helper()
+ c, e := NewConsumer(ConsumerConfig{Store: f.s, ConsumerID: "directory", Now: func() time.Time { return f.now }, Complete: done})
+ if e != nil {
+ t.Fatal(e)
+ }
+ return c
+}
+func request(method, path, body string) *http.Request {
+ r := httptest.NewRequest(method, "https://app.example.com"+path, strings.NewReader(body))
+ if method == "POST" {
+ r.Header.Set("Origin", "https://id.example.com")
+ r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ }
+ return r
+}
+func matchingCookie(t *testing.T, r *httptest.ResponseRecorder, name string) *http.Cookie {
+ t.Helper()
+ for _, c := range r.Result().Cookies() {
+ if c.Name == name {
+ return c
+ }
+ }
+ t.Fatal("cookie missing", name)
+ return nil
+}
+func TestHTTPExchangeWithoutCallbackCookie(t *testing.T) {
+ f := fresh(t)
+ completed := 0
+ c := newConsumer(t, f, func(w http.ResponseWriter, r *http.Request, a assertion.Claims) error {
+ completed++
+ if a.Subject != "person_123" || a.AuthTime != f.now.Unix()-900 {
+ t.Fatal("identity/freshness lost")
+ }
+ return nil
+ })
+ start := httptest.NewRecorder()
+ c.Start(start, request("GET", StartPath+"?return_to=%2Fdocuments%2Fx%3Fteam%3Dblue", ""))
+ if start.Code != 303 {
+ t.Fatal(start.Code)
+ }
+ target, e := url.Parse(start.Header().Get("Location"))
+ if e != nil {
+ t.Fatal(e)
+ }
+ if target.Host != "id.example.com" || target.Path != "/authorize" || target.Query().Get("consumer") != "directory" {
+ t.Fatal("bad issuer target")
+ }
+ secret := matchingCookie(t, start, pendingCookie)
+ token := f.token(t, target.Query().Get("nonce"))
+ callback := httptest.NewRecorder()
+ c.Callback(callback, request("POST", CallbackPath, url.Values{"assertion": {token}}.Encode()))
+ if callback.Code != 303 || completed != 0 {
+ t.Fatal("callback minted session")
+ }
+ handle := matchingCookie(t, callback, inboxCookie)
+ for _, cookie := range []*http.Cookie{secret, handle} {
+ if !cookie.Secure || !cookie.HttpOnly || cookie.Path != "/" || cookie.Domain != "" || cookie.SameSite != http.SameSiteLaxMode || !strings.HasPrefix(cookie.Name, "__Host-") {
+ t.Fatal("cookie isolation lost")
+ }
+ }
+ if strings.Contains(callback.Header().Get("Location"), token) || callback.Header().Get("Location") != FinishPath {
+ t.Fatal("token in navigation")
+ }
+ finish := httptest.NewRecorder()
+ r := request("GET", FinishPath, "")
+ r.AddCookie(secret)
+ r.AddCookie(handle)
+ c.Finish(finish, r)
+ if completed != 1 || finish.Code != 303 || finish.Header().Get("Location") != "/documents/x?team=blue" {
+ t.Fatal("finish failed")
+ }
+ for _, rec := range []*httptest.ResponseRecorder{start, callback, finish} {
+ if rec.Header().Get("Cache-Control") != "no-store" {
+ t.Fatal("cacheable auth response")
+ }
+ }
+}
+func TestCallbackRefusalsNeverSetCookies(t *testing.T) {
+ f := fresh(t)
+ _, nonce := f.pending(t)
+ token := f.token(t, nonce)
+ body := url.Values{"assertion": {token}}.Encode()
+ c := newConsumer(t, f, func(http.ResponseWriter, *http.Request, assertion.Claims) error {
+ t.Fatal("invalid callback completed")
+ return nil
+ })
+ cases := map[string]func(*http.Request){"missing origin": func(r *http.Request) { r.Header.Del("Origin") }, "null origin": func(r *http.Request) { r.Header.Set("Origin", "null") }, "foreign same site": func(r *http.Request) {
+ r.Header.Set("Origin", "https://evil.example.com")
+ r.Header.Set("Sec-Fetch-Site", "same-site")
+ }, "duplicate origin": func(r *http.Request) { r.Header.Add("Origin", "https://id.example.com") }, "JSON": func(r *http.Request) { r.Header.Set("Content-Type", "application/json") }, "query": func(r *http.Request) { r.URL.RawQuery = "assertion=" + token }, "wrong host": func(r *http.Request) { r.Host = "other.example.com" }, "GET": func(r *http.Request) { r.Method = "GET" }, "wrong path": func(r *http.Request) { r.URL.Path = "/other" }}
+ for name, change := range cases {
+ t.Run(name, func(t *testing.T) {
+ r := request("POST", CallbackPath, body)
+ change(r)
+ rec := httptest.NewRecorder()
+ c.Callback(rec, r)
+ if rec.Code < 400 || len(rec.Result().Cookies()) != 0 || count(t, f.db, "oficina_handoff_inbox") != 0 {
+ t.Fatal("callback accepted")
+ }
+ })
+ }
+ for _, bad := range []string{body + "&" + body, body + "&extra=1", strings.Repeat("x", 12*1024+1), "assertion=%zz", ""} {
+ rec := httptest.NewRecorder()
+ c.Callback(rec, request("POST", CallbackPath, bad))
+ if rec.Code != 400 || len(rec.Result().Cookies()) != 0 {
+ t.Fatal("bad body accepted")
+ }
+ }
+}
+func TestFinishRefusesMissingDuplicateOrWrongBrowserCookies(t *testing.T) {
+ f := fresh(t)
+ secret, nonce := f.pending(t)
+ handle, _ := f.received(t, nonce)
+ c := newConsumer(t, f, func(http.ResponseWriter, *http.Request, assertion.Claims) error {
+ t.Fatal("wrong browser completed")
+ return nil
+ })
+ other, _, e := f.s.Start(t.Context(), "/", "", f.now)
+ if e != nil {
+ t.Fatal(e)
+ }
+ for _, cookies := range [][]*http.Cookie{nil, {{Name: pendingCookie, Value: secret}}, {{Name: inboxCookie, Value: handle}}, {{Name: pendingCookie, Value: other}, {Name: inboxCookie, Value: handle}}, {{Name: pendingCookie, Value: secret}, {Name: pendingCookie, Value: secret}, {Name: inboxCookie, Value: handle}}, {{Name: pendingCookie, Value: secret}, {Name: inboxCookie, Value: handle}, {Name: inboxCookie, Value: handle}}} {
+ r := request("GET", FinishPath, "")
+ for _, cookie := range cookies {
+ r.AddCookie(cookie)
+ }
+ rec := httptest.NewRecorder()
+ c.Finish(rec, r)
+ if rec.Code != 400 {
+ t.Fatal("bad finish accepted")
+ }
+ }
+}
+func TestSessionFailureKeepsAssertionConsumed(t *testing.T) {
+ f := fresh(t)
+ secret, nonce := f.pending(t)
+ handle, token := f.received(t, nonce)
+ c := newConsumer(t, f, func(http.ResponseWriter, *http.Request, assertion.Claims) error {
+ return errors.New("synthetic failure")
+ })
+ r := request("GET", FinishPath, "")
+ r.AddCookie(&http.Cookie{Name: pendingCookie, Value: secret})
+ r.AddCookie(&http.Cookie{Name: inboxCookie, Value: handle})
+ rec := httptest.NewRecorder()
+ c.Finish(rec, r)
+ if rec.Code != 503 || count(t, f.db, "oficina_handoff_replay") != 1 {
+ t.Fatal("failed session unconsumed assertion")
+ }
+ if _, e := f.s.Receive(t.Context(), token, f.now); e != ErrRefused {
+ t.Fatal("failed session replayed")
+ }
+}
+func TestStartRefusesAmbiguousIntent(t *testing.T) {
+ f := fresh(t)
+ c := newConsumer(t, f, func(http.ResponseWriter, *http.Request, assertion.Claims) error { return nil })
+ for _, query := range []string{"?other=1", "?return_to=/&return_to=/documents", "?return_to=//evil.example", "?return_to=/auth/start", "?return_to=%zz"} {
+ rec := httptest.NewRecorder()
+ c.Start(rec, request("GET", StartPath+query, ""))
+ if rec.Code != 400 || len(rec.Result().Cookies()) != 0 {
+ t.Fatal(query)
+ }
+ }
+ r := request("GET", StartPath, "")
+ r.AddCookie(&http.Cookie{Name: pendingCookie, Value: strings.Repeat("A", 43)})
+ r.AddCookie(&http.Cookie{Name: pendingCookie, Value: strings.Repeat("A", 43)})
+ rec := httptest.NewRecorder()
+ c.Start(rec, r)
+ if rec.Code != 400 {
+ t.Fatal("duplicate cookie accepted")
+ }
+}
diff --git a/handoff/migrations/0001_exchange.sql b/handoff/migrations/0001_exchange.sql
new file mode 100644
index 0000000..b0d69d9
--- /dev/null
+++ b/handoff/migrations/0001_exchange.sql
@@ -0,0 +1,26 @@
+CREATE TABLE oficina_handoff_clock (
+ id INTEGER PRIMARY KEY CHECK (id = 1),
+ observed INTEGER NOT NULL
+);
+INSERT INTO oficina_handoff_clock (id, observed) VALUES (1, 0);
+CREATE TABLE oficina_handoff_pending (
+ secret_hash TEXT PRIMARY KEY,
+ nonce_hash TEXT NOT NULL UNIQUE,
+ sealed_path BLOB NOT NULL,
+ created_at INTEGER NOT NULL,
+ expires_at INTEGER NOT NULL
+);
+CREATE INDEX oficina_handoff_pending_expiry ON oficina_handoff_pending(expires_at);
+CREATE TABLE oficina_handoff_inbox (
+ handle_hash TEXT PRIMARY KEY,
+ jti_hash TEXT NOT NULL UNIQUE,
+ sealed_token BLOB NOT NULL,
+ created_at INTEGER NOT NULL,
+ expires_at INTEGER NOT NULL
+);
+CREATE INDEX oficina_handoff_inbox_expiry ON oficina_handoff_inbox(expires_at);
+CREATE TABLE oficina_handoff_replay (
+ jti_hash TEXT PRIMARY KEY,
+ expires_at INTEGER NOT NULL
+);
+CREATE INDEX oficina_handoff_replay_expiry ON oficina_handoff_replay(expires_at);
diff --git a/handoff/store.go b/handoff/store.go
new file mode 100644
index 0000000..0d03bdf
--- /dev/null
+++ b/handoff/store.go
@@ -0,0 +1,362 @@
+// Package handoff receives Home identity assertions. It binds each exchange to
+// the browser that started it and records use before the app issues a session.
+// Your app still decides team membership, account linking and item access.
+package handoff
+
+import (
+ "context"
+ "crypto/rand"
+ "crypto/sha256"
+ "crypto/subtle"
+ "database/sql"
+ "embed"
+ "encoding/base64"
+ "encoding/hex"
+ "errors"
+ "math"
+ "net/url"
+ "strings"
+ "time"
+ "unicode/utf8"
+
+ "amadan.net/rastrillo/rastrillo/assertion"
+)
+
+// Migrations must be applied by the app's migration runner before NewStore.
+// Keeping them here gives every consumer the same replay and browser binding.
+//
+//go:embed migrations/*.sql
+var Migrations embed.FS
+
+var (
+ ErrRefused = errors.New("handoff: refused")
+ ErrStorage = errors.New("handoff: storage")
+ ErrClock = errors.New("handoff: clock")
+ ErrFull = errors.New("handoff: capacity")
+ ErrConfig = errors.New("handoff: configuration")
+)
+
+// A login can take several minutes when the user must open their mail client.
+const pendingLifetime = int64(600)
+
+// The inbox never needs to outlive the assertion it holds.
+const inboxLifetime = int64(120)
+
+// Retention matches the tolerated backward adjustment. Pruning earlier could
+// allow a token to be reused after the clock moves backwards.
+const clockTolerance = int64(30)
+const replayRetention = clockTolerance
+
+// Caps bound unauthenticated navigation and verified callback storage. Pending
+// eviction only loses navigation; inbox eviction could disrupt a valid proof.
+const rowLimit = 10000
+
+// Sealer must authenticate its purpose and plaintext. Apps supply their own
+// existing data-key implementation; this package never creates a storage key.
+type Sealer interface {
+ Seal(purpose string, plaintext []byte) ([]byte, error)
+ Open(purpose string, ciphertext []byte) ([]byte, error)
+}
+
+type Store struct {
+ db *sql.DB
+ sealer Sealer
+ verifier *assertion.Verifier
+ issuer, audience string
+}
+
+func NewStore(db *sql.DB, sealer Sealer, issuer, audience string, keys map[string][]byte) (*Store, error) {
+ if db == nil || sealer == nil {
+ return nil, ErrConfig
+ }
+ v, e := assertion.NewVerifier(issuer, audience, keys)
+ if e != nil {
+ return nil, ErrConfig
+ }
+ return &Store{db: db, sealer: sealer, verifier: v, issuer: issuer, audience: audience}, nil
+}
+
+func digest(s string) string { h := sha256.Sum256([]byte(s)); return hex.EncodeToString(h[:]) }
+func jtiHash(c assertion.Claims) string { return digest(c.Issuer + "\x00" + c.ID) }
+func randomHandle() (string, error) {
+ var b [32]byte
+ if _, e := rand.Read(b[:]); e != nil {
+ return "", ErrStorage
+ }
+ return base64.RawURLEncoding.EncodeToString(b[:]), nil
+}
+func validHandle(s string) bool {
+ if len(s) != 43 {
+ return false
+ }
+ b, e := base64.RawURLEncoding.Strict().DecodeString(s)
+ return e == nil && len(b) == 32 && base64.RawURLEncoding.EncodeToString(b) == s
+}
+
+// ValidNonce rejects alternate encodings, including CR/LF ignored by the base64
+// decoder. The issuer uses this before retaining a browser's request.
+func ValidNonce(s string) bool {
+ if len(s) != 22 {
+ return false
+ }
+ b, e := base64.RawURLEncoding.Strict().DecodeString(s)
+ return e == nil && len(b) == 16 && base64.RawURLEncoding.EncodeToString(b) == s
+}
+
+// SafePath accepts only local navigation outside the exchange itself. The
+// caller stores the returned value sealed, even when it looks harmless.
+func SafePath(s string) bool {
+ if !utf8.ValidString(s) || strings.Contains(s, "#") || len(s) == 0 || len(s) > 2048 || !strings.HasPrefix(s, "/") || strings.HasPrefix(s, "//") {
+ return false
+ }
+ for _, b := range []byte(s) {
+ if b <= 32 || b == 127 || b == '\\' {
+ return false
+ }
+ }
+ u, e := url.ParseRequestURI(s)
+ if e != nil || u.IsAbs() || u.Host != "" || u.Fragment != "" || u.Opaque != "" {
+ return false
+ }
+ path := u.Path
+ for _, b := range []byte(path) {
+ if b < 32 || b == 127 {
+ return false
+ }
+ }
+ if !strings.HasPrefix(path, "/") || strings.HasPrefix(path, "//") || strings.ContainsAny(path, "\\\r\n\x00") {
+ return false
+ }
+ // Encoded or dot-segment aliases must not route back into authentication.
+ for _, part := range strings.Split(path, "/") {
+ if part == "." || part == ".." {
+ return false
+ }
+ }
+ return path != "/auth" && !strings.HasPrefix(path, "/auth/") && path != "/signin" && !strings.HasPrefix(path, "/signin/") && path != "/authorize" && !strings.HasPrefix(path, "/authorize/")
+}
+
+// begin takes the SQLite write lock before reading state, so several database
+// connections cannot race a capacity check or upgrade a stale read snapshot.
+func (s *Store) begin(ctx context.Context, now time.Time) (*sql.Tx, int64, error) {
+ n := now.Unix()
+ if n <= 0 || n > math.MaxInt64-pendingLifetime {
+ return nil, 0, ErrClock
+ }
+ tx, e := s.db.BeginTx(ctx, nil)
+ if e != nil {
+ return nil, 0, ErrStorage
+ }
+ result, e := tx.ExecContext(ctx, `UPDATE oficina_handoff_clock SET observed=MAX(observed,?) WHERE id=1 AND observed<=?`, n, n+clockTolerance)
+ if e != nil {
+ tx.Rollback()
+ return nil, 0, ErrStorage
+ }
+ rows, e := result.RowsAffected()
+ if e != nil || rows != 1 {
+ tx.Rollback()
+ return nil, 0, ErrClock
+ }
+ var fence int64
+ if e = tx.QueryRowContext(ctx, `SELECT observed FROM oficina_handoff_clock WHERE id=1`).Scan(&fence); e != nil {
+ tx.Rollback()
+ return nil, 0, ErrStorage
+ }
+ for _, q := range []struct {
+ sql string
+ n int64
+ }{
+ {`DELETE FROM oficina_handoff_pending WHERE expires_at<=?`, n},
+ {`DELETE FROM oficina_handoff_inbox WHERE expires_at<=?`, n},
+ {`DELETE FROM oficina_handoff_replay WHERE expires_at<?`, fence - replayRetention},
+ } {
+ if _, e = tx.ExecContext(ctx, q.sql, q.n); e != nil {
+ tx.Rollback()
+ return nil, 0, ErrStorage
+ }
+ }
+ return tx, n, nil
+}
+
+func (s *Store) Sweep(ctx context.Context, now time.Time) error {
+ tx, _, e := s.begin(ctx, now)
+ if e != nil {
+ return e
+ }
+ defer tx.Rollback()
+ if tx.Commit() != nil {
+ return ErrStorage
+ }
+ return nil
+}
+
+// Start replaces this browser's previous pending request. Browser secrets and
+// nonces are returned to the caller, but only their hashes enter the database.
+func (s *Store) Start(ctx context.Context, path, previous string, now time.Time) (secret, nonce string, err error) {
+ if !SafePath(path) {
+ return "", "", ErrRefused
+ }
+ secret, err = randomHandle()
+ if err != nil {
+ return "", "", err
+ }
+ nonce, err = assertion.RandomID()
+ if err != nil {
+ return "", "", ErrStorage
+ }
+ hash := digest(secret)
+ sealed, e := s.sealer.Seal("handoff.path."+hash, []byte(path))
+ if e != nil {
+ return "", "", ErrStorage
+ }
+ tx, n, e := s.begin(ctx, now)
+ if e != nil {
+ return "", "", e
+ }
+ defer tx.Rollback()
+ if validHandle(previous) {
+ if _, e = tx.ExecContext(ctx, `DELETE FROM oficina_handoff_pending WHERE secret_hash=?`, digest(previous)); e != nil {
+ return "", "", ErrStorage
+ }
+ }
+ var count int
+ if e = tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM oficina_handoff_pending`).Scan(&count); e != nil {
+ return "", "", ErrStorage
+ }
+ if count >= rowLimit {
+ if _, e = tx.ExecContext(ctx, `DELETE FROM oficina_handoff_pending WHERE secret_hash IN (SELECT secret_hash FROM oficina_handoff_pending ORDER BY created_at,secret_hash LIMIT ?)`, count-rowLimit+1); e != nil {
+ return "", "", ErrStorage
+ }
+ }
+ if _, e = tx.ExecContext(ctx, `INSERT INTO oficina_handoff_pending(secret_hash,nonce_hash,sealed_path,created_at,expires_at) VALUES(?,?,?,?,?)`, hash, digest(nonce), sealed, n, n+pendingLifetime); e != nil {
+ return "", "", ErrStorage
+ }
+ if tx.Commit() != nil {
+ return "", "", ErrStorage
+ }
+ return secret, nonce, nil
+}
+
+// Receive deliberately needs no cookie: a cross-site POST may carry none. A
+// repeated assertion gets no new inbox row or cookie, even before Finish runs.
+func (s *Store) Receive(ctx context.Context, token string, now time.Time) (string, error) {
+ claims, e := s.verifier.Verify(token, now)
+ if e != nil {
+ return "", ErrRefused
+ }
+ handle, e := randomHandle()
+ if e != nil {
+ return "", e
+ }
+ hash := digest(handle)
+ jti := jtiHash(claims)
+ sealed, e := s.sealer.Seal("handoff.token."+hash, []byte(token))
+ if e != nil {
+ return "", ErrStorage
+ }
+ tx, n, e := s.begin(ctx, now)
+ if e != nil {
+ return "", e
+ }
+ defer tx.Rollback()
+ var count int
+ if e = tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM oficina_handoff_replay WHERE jti_hash=?`, jti).Scan(&count); e != nil {
+ return "", ErrStorage
+ }
+ if count != 0 {
+ return "", ErrRefused
+ }
+ if e = tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM oficina_handoff_inbox WHERE jti_hash=?`, jti).Scan(&count); e != nil {
+ return "", ErrStorage
+ }
+ if count != 0 {
+ return "", ErrRefused
+ }
+ if e = tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM oficina_handoff_inbox`).Scan(&count); e != nil {
+ return "", ErrStorage
+ }
+ if count >= rowLimit {
+ return "", ErrFull
+ }
+ expires := claims.ExpiresAt
+ if expires > n+inboxLifetime {
+ expires = n + inboxLifetime
+ }
+ if _, e = tx.ExecContext(ctx, `INSERT INTO oficina_handoff_inbox(handle_hash,jti_hash,sealed_token,created_at,expires_at) VALUES(?,?,?,?,?)`, hash, jti, sealed, n, expires); e != nil {
+ return "", ErrStorage
+ }
+ if tx.Commit() != nil {
+ return "", ErrStorage
+ }
+ return handle, nil
+}
+
+// Finish commits replay consumption before returning an identity to the app.
+// existingSubject is resolved before calling: reading a session from inside
+// this transaction could deadlock an app's single-connection writer.
+func (s *Store) Finish(ctx context.Context, secret, handle, existingSubject string, now time.Time) (assertion.Claims, string, error) {
+ zero := assertion.Claims{}
+ if !validHandle(secret) || !validHandle(handle) {
+ return zero, "", ErrRefused
+ }
+ tx, n, e := s.begin(ctx, now)
+ if e != nil {
+ return zero, "", e
+ }
+ defer tx.Rollback()
+ secretHash, handleHash := digest(secret), digest(handle)
+ var nonceHash string
+ var sealedPath, sealedToken []byte
+ if e = tx.QueryRowContext(ctx, `SELECT nonce_hash,sealed_path FROM oficina_handoff_pending WHERE secret_hash=? AND expires_at>?`, secretHash, n).Scan(&nonceHash, &sealedPath); e != nil {
+ return zero, "", readError(e)
+ }
+ if e = tx.QueryRowContext(ctx, `SELECT sealed_token FROM oficina_handoff_inbox WHERE handle_hash=? AND expires_at>?`, handleHash, n).Scan(&sealedToken); e != nil {
+ return zero, "", readError(e)
+ }
+ token, e := s.sealer.Open("handoff.token."+handleHash, sealedToken)
+ if e != nil {
+ return zero, "", ErrRefused
+ }
+ claims, e := s.verifier.Verify(string(token), now)
+ if e != nil {
+ return zero, "", ErrRefused
+ }
+ if subtle.ConstantTimeCompare([]byte(nonceHash), []byte(digest(claims.Nonce))) != 1 || existingSubject != "" && existingSubject != claims.Subject {
+ return zero, "", ErrRefused
+ }
+ path, e := s.sealer.Open("handoff.path."+secretHash, sealedPath)
+ if e != nil || !SafePath(string(path)) {
+ return zero, "", ErrRefused
+ }
+ result, e := tx.ExecContext(ctx, `INSERT INTO oficina_handoff_replay(jti_hash,expires_at) VALUES(?,?) ON CONFLICT DO NOTHING`, jtiHash(claims), claims.ExpiresAt)
+ if e != nil {
+ return zero, "", ErrStorage
+ }
+ rows, e := result.RowsAffected()
+ if e != nil {
+ return zero, "", ErrStorage
+ }
+ if rows != 1 {
+ return zero, "", ErrRefused
+ }
+ for _, q := range []struct{ sql, key string }{{`DELETE FROM oficina_handoff_pending WHERE secret_hash=?`, secretHash}, {`DELETE FROM oficina_handoff_inbox WHERE handle_hash=?`, handleHash}} {
+ result, e = tx.ExecContext(ctx, q.sql, q.key)
+ if e != nil {
+ return zero, "", ErrStorage
+ }
+ rows, e = result.RowsAffected()
+ if e != nil || rows != 1 {
+ return zero, "", ErrStorage
+ }
+ }
+ if tx.Commit() != nil {
+ return zero, "", ErrStorage
+ }
+ return claims, string(path), nil
+}
+func readError(e error) error {
+ if errors.Is(e, sql.ErrNoRows) {
+ return ErrRefused
+ }
+ return ErrStorage
+}
diff --git a/handoff/store_test.go b/handoff/store_test.go
new file mode 100644
index 0000000..9f259a4
--- /dev/null
+++ b/handoff/store_test.go
@@ -0,0 +1,325 @@
+package handoff
+
+import (
+ "bytes"
+ "context"
+ "crypto/rand"
+ "database/sql"
+ "os"
+ "path/filepath"
+ "reflect"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "amadan.net/rastrillo/rastrillo/assertion"
+ "amadan.net/rastrillo/rastrillo/crypto"
+ _ "modernc.org/sqlite"
+)
+
+type testSealer struct{ key []byte }
+
+func (s testSealer) Seal(p string, b []byte) ([]byte, error) {
+ return crypto.SealSym(crypto.Derive(s.key, p), b)
+}
+func (s testSealer) Open(p string, b []byte) ([]byte, error) {
+ return crypto.OpenSym(crypto.Derive(s.key, p), b)
+}
+
+type fixture struct {
+ s *Store
+ k *crypto.Keypair
+ now time.Time
+ dbpath string
+ db *sql.DB
+}
+
+func fresh(t *testing.T) fixture {
+ t.Helper()
+ path := filepath.Join(t.TempDir(), "handoff.db")
+ db, e := sql.Open("sqlite", path+"?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)")
+ if e != nil {
+ t.Fatal(e)
+ }
+ db.SetMaxOpenConns(4)
+ t.Cleanup(func() { db.Close() })
+ schema, e := Migrations.ReadFile("migrations/0001_exchange.sql")
+ if e != nil {
+ t.Fatal(e)
+ }
+ if _, e = db.Exec(string(schema)); e != nil {
+ t.Fatal(e)
+ }
+ key := make([]byte, 32)
+ rand.Read(key)
+ k, e := crypto.Generate()
+ if e != nil {
+ t.Fatal(e)
+ }
+ s, e := NewStore(db, testSealer{key}, "https://id.example.com", "https://app.example.com", map[string][]byte{"k1": k.SignPub()})
+ if e != nil {
+ t.Fatal(e)
+ }
+ return fixture{s: s, k: k, now: time.Unix(1700000000, 0), dbpath: path, db: db}
+}
+func (f fixture) token(t *testing.T, nonce string) string {
+ t.Helper()
+ id, e := assertion.RandomID()
+ if e != nil {
+ t.Fatal(e)
+ }
+ s, e := assertion.Sign(f.k, assertion.Claims{Version: 1, Issuer: "https://id.example.com", Audience: "https://app.example.com", Subject: "person_123", KeyID: "k1", ID: id, Nonce: nonce, IssuedAt: f.now.Unix(), ExpiresAt: f.now.Unix() + 120, AuthTime: f.now.Unix() - 900})
+ if e != nil {
+ t.Fatal(e)
+ }
+ return s
+}
+func (f fixture) pending(t *testing.T) (string, string) {
+ t.Helper()
+ secret, nonce, e := f.s.Start(context.Background(), "/documents/private-canary?team=blue", "", f.now)
+ if e != nil {
+ t.Fatal(e)
+ }
+ return secret, nonce
+}
+func (f fixture) received(t *testing.T, nonce string) (string, string) {
+ t.Helper()
+ token := f.token(t, nonce)
+ handle, e := f.s.Receive(context.Background(), token, f.now)
+ if e != nil {
+ t.Fatal(e)
+ }
+ return handle, token
+}
+func count(t *testing.T, db *sql.DB, table string) int {
+ t.Helper()
+ var n int
+ if e := db.QueryRow("SELECT COUNT(*) FROM " + table).Scan(&n); e != nil {
+ t.Fatal(e)
+ }
+ return n
+}
+func failFinish(t *testing.T, f fixture, secret, handle, subject string, now time.Time, want error) {
+ t.Helper()
+ c, p, e := f.s.Finish(context.Background(), secret, handle, subject, now)
+ if e != want || p != "" || !reflect.DeepEqual(c, assertion.Claims{}) {
+ t.Fatalf("finish refused: %v wanted %v zero=%v", e, want, reflect.DeepEqual(c, assertion.Claims{}))
+ }
+}
+
+func TestExchangeAndReplay(t *testing.T) {
+ f := fresh(t)
+ secret, nonce := f.pending(t)
+ handle, token := f.received(t, nonce)
+ c, path, e := f.s.Finish(context.Background(), secret, handle, "", f.now)
+ if e != nil || c.Subject != "person_123" || c.AuthTime != f.now.Unix()-900 || path != "/documents/private-canary?team=blue" {
+ t.Fatalf("finish: %v", e)
+ }
+ if count(t, f.db, "oficina_handoff_pending") != 0 || count(t, f.db, "oficina_handoff_inbox") != 0 || count(t, f.db, "oficina_handoff_replay") != 1 {
+ t.Fatal("consumption not atomic")
+ }
+ failFinish(t, f, secret, handle, "", f.now, ErrRefused)
+ if h, e := f.s.Receive(context.Background(), token, f.now); e != ErrRefused || h != "" {
+ t.Fatal("replayed callback accepted")
+ }
+ if count(t, f.db, "oficina_handoff_inbox") != 0 {
+ t.Fatal("replay allocated inbox")
+ }
+}
+func TestBrowserAndSubjectBinding(t *testing.T) {
+ f := fresh(t)
+ alice, an := f.pending(t)
+ bob, bn := f.pending(t)
+ ah, _ := f.received(t, an)
+ bh, _ := f.received(t, bn)
+ failFinish(t, f, alice, bh, "", f.now, ErrRefused)
+ failFinish(t, f, bob, ah, "", f.now, ErrRefused)
+ failFinish(t, f, alice, ah, "someone_else", f.now, ErrRefused)
+ if _, _, e := f.s.Finish(context.Background(), alice, ah, "person_123", f.now); e != nil {
+ t.Fatal("failed attempts consumed legitimate request", e)
+ }
+}
+func TestConcurrentFinishAndCallback(t *testing.T) {
+ f := fresh(t)
+ secret, nonce := f.pending(t)
+ token := f.token(t, nonce)
+ var mu sync.Mutex
+ var handles []string
+ var wg sync.WaitGroup
+ for range 12 {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ h, e := f.s.Receive(context.Background(), token, f.now)
+ if e == nil {
+ mu.Lock()
+ handles = append(handles, h)
+ mu.Unlock()
+ } else if e != ErrRefused {
+ t.Error(e)
+ }
+ }()
+ }
+ wg.Wait()
+ if len(handles) != 1 {
+ t.Fatalf("callback winners %d", len(handles))
+ }
+ wins := 0
+ for range 12 {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ _, _, e := f.s.Finish(context.Background(), secret, handles[0], "", f.now)
+ if e == nil {
+ mu.Lock()
+ wins++
+ mu.Unlock()
+ } else if e != ErrRefused {
+ t.Error(e)
+ }
+ }()
+ }
+ wg.Wait()
+ if wins != 1 {
+ t.Fatalf("finish winners %d", wins)
+ }
+}
+func TestReplacementAndExpiry(t *testing.T) {
+ f := fresh(t)
+ old, nonce := f.pending(t)
+ handle, _ := f.received(t, nonce)
+ if _, _, e := f.s.Start(context.Background(), "/", old, f.now); e != nil {
+ t.Fatal(e)
+ }
+ failFinish(t, f, old, handle, "", f.now, ErrRefused)
+ secret, nonce := f.pending(t)
+ handle, _ = f.received(t, nonce)
+ failFinish(t, f, secret, handle, "", f.now.Add(120*time.Second), ErrRefused)
+ if e := f.s.Sweep(context.Background(), f.now.Add(601*time.Second)); e != nil {
+ t.Fatal(e)
+ }
+ if count(t, f.db, "oficina_handoff_pending") != 0 || count(t, f.db, "oficina_handoff_inbox") != 0 {
+ t.Fatal("expired rows survive")
+ }
+}
+func TestClockFenceAndRestart(t *testing.T) {
+ f := fresh(t)
+ secret, nonce := f.pending(t)
+ handle, token := f.received(t, nonce)
+ if _, _, e := f.s.Finish(context.Background(), secret, handle, "", f.now); e != nil {
+ t.Fatal(e)
+ }
+ if e := f.s.Sweep(context.Background(), f.now.Add(150*time.Second)); e != nil {
+ t.Fatal(e)
+ }
+ if count(t, f.db, "oficina_handoff_replay") != 1 {
+ t.Fatal("replay pruned at retention boundary")
+ }
+ if e := f.s.Sweep(context.Background(), f.now.Add(151*time.Second)); e != nil {
+ t.Fatal(e)
+ }
+ if count(t, f.db, "oficina_handoff_replay") != 0 {
+ t.Fatal("old replay not pruned")
+ }
+ if e := f.db.Close(); e != nil {
+ t.Fatal(e)
+ }
+ db, e := sql.Open("sqlite", f.dbpath)
+ if e != nil {
+ t.Fatal(e)
+ }
+ defer db.Close()
+ f.s.db = db
+ if e = f.s.Sweep(context.Background(), f.now.Add(121*time.Second)); e != nil {
+ t.Fatal("tolerated rollback refused", e)
+ }
+ if _, e = f.s.Receive(context.Background(), token, f.now.Add(121*time.Second)); e != ErrRefused {
+ t.Fatal("pruned token reappeared", e)
+ }
+ if e = f.s.Sweep(context.Background(), f.now.Add(120*time.Second)); e != ErrClock {
+ t.Fatal("large rollback not fenced", e)
+ }
+ if replayRetention < clockTolerance {
+ t.Fatal("clock rollback exceeds retained history")
+ }
+}
+func TestStorageFailureDoesNotReturnIdentity(t *testing.T) {
+ f := fresh(t)
+ secret, nonce := f.pending(t)
+ handle, _ := f.received(t, nonce)
+ if _, e := f.db.Exec(`CREATE TRIGGER break_delete BEFORE DELETE ON oficina_handoff_pending BEGIN SELECT RAISE(ABORT,'synthetic failure'); END`); e != nil {
+ t.Fatal(e)
+ }
+ failFinish(t, f, secret, handle, "", f.now, ErrStorage)
+ if count(t, f.db, "oficina_handoff_replay") != 0 || count(t, f.db, "oficina_handoff_pending") != 1 || count(t, f.db, "oficina_handoff_inbox") != 1 {
+ t.Fatal("partial transaction survived")
+ }
+}
+func TestPrivateValuesNeverReachDBOrWAL(t *testing.T) {
+ f := fresh(t)
+ secret, nonce := f.pending(t)
+ handle, token := f.received(t, nonce)
+ for _, path := range []string{f.dbpath, f.dbpath + "-wal"} {
+ b, e := os.ReadFile(path)
+ if e != nil {
+ t.Fatal(e)
+ }
+ for _, value := range []string{secret, nonce, handle, token, "private-canary", "?team=blue", "person_123"} {
+ if bytes.Contains(b, []byte(value)) {
+ t.Fatalf("plaintext leaked in %s", filepath.Base(path))
+ }
+ }
+ }
+}
+func TestBounds(t *testing.T) {
+ f := fresh(t)
+ tx, e := f.db.Begin()
+ if e != nil {
+ t.Fatal(e)
+ }
+ for i := 0; i < rowLimit; i++ {
+ if _, e = tx.Exec(`INSERT INTO oficina_handoff_pending(secret_hash,nonce_hash,sealed_path,created_at,expires_at) VALUES(printf('%d',?),printf('%d',?),x'00',?,?)`, i, i, f.now.Unix()-1, f.now.Unix()+600); e != nil {
+ t.Fatal(e)
+ }
+ }
+ if e = tx.Commit(); e != nil {
+ t.Fatal(e)
+ }
+ _, nonce := f.pending(t)
+ if count(t, f.db, "oficina_handoff_pending") != rowLimit {
+ t.Fatal("pending cap not bounded")
+ }
+ tx, e = f.db.Begin()
+ if e != nil {
+ t.Fatal(e)
+ }
+ for i := 0; i < rowLimit; i++ {
+ if _, e = tx.Exec(`INSERT INTO oficina_handoff_inbox(handle_hash,jti_hash,sealed_token,created_at,expires_at) VALUES(printf('%d',?),printf('%d',?),x'00',?,?)`, i, i, f.now.Unix(), f.now.Unix()+120); e != nil {
+ t.Fatal(e)
+ }
+ }
+ if e = tx.Commit(); e != nil {
+ t.Fatal(e)
+ }
+ if h, e := f.s.Receive(context.Background(), f.token(t, nonce), f.now); e != ErrFull || h != "" {
+ t.Fatal("full inbox admitted", e)
+ }
+}
+func TestSafePathsAndNonce(t *testing.T) {
+ for _, s := range []string{"/", "/documents/a?team=1", "/sheets?q=hello%20world"} {
+ if !SafePath(s) {
+ t.Fatal(s)
+ }
+ }
+ for _, s := range []string{"", "//evil.example/", "https://evil.example/", "/\\evil", "/%2f%2fevil", "/%2e%2e/auth/start", "/auth/start", "/%61uth/start", "/signin", "/authorize/resume", "/a\n", "/a#fragment", strings.Repeat("a", 2049)} {
+ if SafePath(s) {
+ t.Fatal(s)
+ }
+ }
+ for _, s := range []string{"", "A", "AAAAAAAAAAAAAAAAAAAAAA\n", "AAAAAAAAAAAAAAAAAAAAAB"} {
+ if ValidNonce(s) {
+ t.Fatal("invalid nonce accepted")
+ }
+ }
+}
diff --git a/implementation/team-entry.md b/implementation/team-entry.md
new file mode 100644
index 0000000..9ccfe9e
--- /dev/null
+++ b/implementation/team-entry.md
@@ -0,0 +1,54 @@
+# Home and team app entry
+
+Status: bounded Home/directory transport design approved by Claude Fable 5.1 at 36f876c on 2026-09-08; code still requires review. The user has prioritized docs.oficina.works → Home sign-in → team choice → team Docs, and accepted the existing team-subdomain shape. This branch starts with the shared Home/directory exchange; the acceptance target still includes Docs and two teams. Copy review is waived for this round. Fable reviews each implementation stage.
+
+## Deployment and authority
+
+The credential origin remains id.oficina.works and the directory remains oficina.works. Team instances use <team>.docs.oficina.works. The common docs host becomes an entry point only after the existing production Docs instance has a verified team alias and all existing links still resolve. No replacement of the production database or keys is part of this work.
+
+The Home binary runs in two explicitly configured roles, credential and directory, with separate processes, databases and host-only session cookies. Each role refuses the other origin's routes even when a caller supplies its Host header. The credential role has the assertion private key; the directory has only pinned public verification keys. Existing sign-in secrets stay with their existing databases. The directory never obtains a credential session, and the credential origin never renders a team label or account-authored string.
+
+For the first isolated pilot, the operator supplies a bounded immutable consumer manifest through deployment configuration. Each consumer has an opaque ID, exact canonical HTTPS origin and fixed callback path /auth/assertion. No request can register or alter a consumer. The directory is a consumer too. Consumer registration in the eventual production provisioning/drain path remains explicit; pilot bootstrap is not a web administration endpoint.
+
+The new rastrillo assertion package signs and verifies the opaque person_ref, exact origins, independent jti and consumer nonce, issued/expiry/authentication time. P-256 reuses crypto.MarshalKeypair for the credential-only signing secret. Max lifetime is 120 seconds. Verification keys are pinned for this first deployment; no token-driven key fetch. A rotation deploys old+new public keys before switching the signer, then removes the old key after the handoff horizon. No email, role, membership, team authority or resource path rides in an assertion.
+
+## Browser exchange
+
+1. A consumer's GET /auth/start selects a validated local return path (bounded, no scheme/authority/control characters/backslashes, no recursive auth routes). It generates independent random browser secret and signed nonce. Store only their hashes with purpose, local return path and ten-minute expiry in the consumer database. Every local return path is sealed before storage. The maximum path+query length is 2048 bytes; a query string is permitted for existing ?team= navigation. The __Host- pending cookie is Secure, HttpOnly, Path=/, SameSite=Lax and has no Domain. Duplicate cookies are refused. One pending request per browser is the initial bound: starting another in the same consumer replaces the previous pending request. Different team origins remain independent.
+2. Redirect to the configured credential origin /authorize with only registered consumer ID and nonce. These are request identifiers, not signed assertions or identity. Strict query parsing rejects duplicates and unknown keys. The credential origin validates the registered consumer and nonce before doing anything. It retains the authorization request in a sealed, ten-minute __Host- intent cookie. No arbitrary return URL is accepted. Signing in continues to the fixed /authorize/resume route through the existing sign-in return machinery, including second factor and recovery.
+3. Only a complete credential session may mint. The original AuthTime (or At when absent) is retained. The issuer makes a fresh jti and a 120-second assertion for exactly that consumer origin and nonce. A handoff page posts it to the registered callback. The page contains no account-authored visible string, uses no-store and strict-origin-when-cross-origin, and uses the process-wide CSP with form-action self plus every exact manifest origin (including the directory). The handoff URL carries no identity or signed token. Never use no-referrer here: browsers may send Origin: null on the cross-origin POST.
+4. The automatic handoff needs one narrowly allowed static same-origin script that submits this form; it has a visible Continue button when JavaScript is off. This is an implementation decision under the user's explicit approval of the automatic-return flow in this session, not an attribution to the older passkey-only ruling. Record it as that current authorization when amending the spec and the exact template/path script allowlist. All other credential pages keep their existing script restrictions. No inline script or arbitrary form target is allowed.
+5. A callback accepts only POST application/x-www-form-urlencoded with a bounded 12 KiB body, exactly one assertion field and the exact configured issuer Origin. Mount this route outside ordinary same-origin CSRF middleware only; all other mutations retain that middleware. Reject a foreign Origin even when its Sec-Fetch-Site says same-site. No CORS is added.
+6. SameSite=Lax cookies may be absent on a cross-site POST, so the callback must not depend on receiving them. Verify the assertion, write it sealed into a two-minute consumer inbox row keyed by the hash of a fresh random 32-byte handle, set that handle in a __Host- handoff cookie with Secure, HttpOnly, Path=/, SameSite=Lax and no Domain, and 303 to the consumer's fixed GET /auth/finish. No session is minted yet. Prune expired inbox/pending rows on each write and boot; cap each table at 10,000 live rows. Evict the oldest pending row to admit a new start when full; this discards only unauthenticated navigation intent and requires that browser to restart. Refuse callbacks if the inbox is full. Tokens stay in sealed storage, never in a cookie large enough to hit browser size limits. Inbox has a unique (issuer, jti-hash) index, so replaying a callback cannot allocate another row. That top-level GET carries the consumer's pending cookie. Read and clear the handoff cookie, load its sealed inbox row by handle hash, reverify the assertion against the current clock, compare the signed nonce with the pending request using constant-time equality, and compare the browser secret hash. An attacker may overwrite a handoff cookie only to cause a failed login: a different browser's pending state never matches.
+7. Atomically delete the live pending request and its inbox row and insert (issuer, jti, expiry) into the replay table before issuing a session. Duplicate/concurrent submission has one winner. On a storage failure, refuse. A later session-write failure consumes the assertion; the user starts again. Never roll back replay consumption after a session cookie may have been sent. Existing identity is not silently switched by a handoff: an already signed-in consumer with a different subject refuses and requires explicit sign-out first.
+8. Replay pruning requires a durable clock fence. Track the maximum observed wall time and maximum accepted assertion expiry in the same transaction. Refuse exchanges after clock rollback more than 30 seconds below that wall-time fence; do not prune or mint while fenced. Retain replay rows through expiry plus 30 seconds so a tolerated backward clock adjustment cannot resurrect a pruned token. On normal operation, delete entries whose expiry plus 30 seconds precedes the stored wall-time fence, after advancing that fence transactionally. All comparisons are overflow-safe. A process restart uses the stored fence. A restored consumer database requires draining and an explicit reset of assertion trust/pending state before accepting new traffic; deployment must not claim arbitrary snapshot rollback is replay safe.
+9. Issue a separate consumer session with the verified subject and original AuthTime, then redirect to the stored local path. The directory session proves identity only. Every team choice and app request still needs membership or an existing individual grant.
+
+## Directory and Docs stages
+
+The directory owns teams, authoritative memberships and team instance routes. Choose only among the signed-in person's active memberships that have the requested app provisioned; one team redirects automatically, several render a shared Estilo choice page, none render an empty state. Only server-owned origins enter redirect targets. POST choice checks current membership again. Persisting a last choice may change the default, never access.
+
+Call 8 uses a consumer token scoped to one team. A complete roster includes generation and epoch and is paged with epoch-bound cursors. Apps stage all pages and publish one complete roster atomically, including an explicitly complete empty roster. Stale or failed refresh never extends the maximum age. Every request, including an existing session, loses team-standing access when the roster becomes stale; existing individual grants remain their own authority. A restored Home generation requires directory-owner confirmation before consumers trust it. Roster timing and recovery tests precede its deployment.
+
+Existing Docs people are keyed by verified email, while Home uses person_ref. Add a stable (issuer, subject) → local PersonID mapping. Link an existing person only after proving both an existing Docs sign-in and the Home identity in one browser-bound transaction. Never infer a mapping by email from an assertion, generate a fake email, or replace document ownership IDs. New Home-only people need a nullable legacy-address field or separate identity model and a forward migration preserving the existing unique addresses. Existing address grants continue to resolve through a linked verified legacy identity. Home membership itself grants no access to existing private documents.
+
+The common app host has no authority over team content. Its GET routes initiate directory selection and preserve bounded navigation intent. Existing production document links must continue to resolve through the original team's verified alias; a deployment must not redirect every old link to whichever team the user most recently selected. Exact behavior for this compatibility entry is reviewed before the common host changes role.
+
+## Release evidence
+
+First exercise the exchange between isolated credential/directory origins with real HTTPS, Chromium and WebKit, JavaScript on/off. Test foreign origin, missing/duplicate cookies, wrong nonce, wrong audience, expired/reused tokens, parallel callbacks, stale auth_time, second-factor continuation, clock rollback/restart and no token in URLs/logs. Existing Home privacy and script gates are extended rather than replaced.
+
+Then run two isolated Docs teams: one-team automatic choice, multiple-team selection, direct resource return, separate tabs for each team, membership removal and failed roster refresh, account linking preserving existing ownership and grants, sign-out and session expiry. Full app CI, Fable review and verified canary versions precede production. Notify the user after every verified deployment with the exact URL and what to try; a library merge is not a deployed sign-in flow.
+
+## Review corrections and implementation choices
+
+Fable reviewed d116692 and found a functional referrer-policy bug, missing explicit cookie/CSP details and write amplification. The revised protocol above fixes each. Two-process topology is the already deployed preview shape, now made explicit and enforced by role; each database needs its own privacy and restore checks. The directory loader requires only its sealing key and pinned verification keys, never credential mail, pepper or signing material. Pilot immutable registration stays scoped to isolated canaries; it is not a completed production provisioning design. Oldest-pending eviction is the chosen bounded-cache policy.
+
+Update the Home spec in the implementation commit: §4/§10 describe the explicit role topology; §5.3 records P-256, signed nonce/auth_time and pinned pilot keys; claims omit optional prefs and roster_epoch in this bounded stage rather than falsely claiming those consumers are implemented. Preserve their later contracts. Amend script ruling 21 with the current user's approved automatic-return requirement and the narrowly chosen static handoff enhancement; label the implementation decision honestly, not as a verbatim historical ruling. Extend scriptAllowed with exactly the handoff template and static path, credentialReturn with /authorize/resume, and existing privacy/log gates with sealed inbox/pending, replay, directory session and known assertion-byte canaries.
+
+Reuse the consumer exchange across Home and Docs rather than implementing two subtly different callbacks. The framework's pure core approval does not approve this adapter: its storage/HTTP implementation will have its own tests and review. A shared consumer accepts the application's database handle, sealer and session-issuance callback; it does not hold memberships or item grants. Keep legacy app authentication working while adding opaque Home subject mapping; do not require a full framework/UI migration merely to import the pure assertion primitive.
+
+
+Shared consumer implementation is a nested module at handoff/, module amadan.net/oficina/home/handoff, with a minimal dependency graph. The actual hub now resolves go-import for both amadan.net/oficina/home and amadan.net/rastrillo/rastrillo: go list -m successfully fetched Home36f876c and Rastrillo29e4491 on 2026-09-08. This corrects the review's stale discovery assumption. A nested module remains necessary to avoid importing the Home binary's unrelated dependency requirements. Verify remote fetching of the nested module from a frozen pushed commit before any app pins it.
+
+The pure core29e4491 passed full local and actual CI and Fable whole-code/docs review, and merged on Rastrillo main997935b; mirror fast-forward verified. The standalone consumer first adds sealed pending/inbox/replay storage and HTTP handlers with tests; it is not yet mounted in Home. Before mounting, amend the Home spec, credential script allowlist and privacy gates as listed above. No deployment is claimed for these library changes.
diff --git a/internal/home/app.go b/internal/home/app.go
index e5562cb..318f119 100644
--- a/internal/home/app.go
+++ b/internal/home/app.go
@@ -19,8 +19,10 @@ import (
"errors"
"log/slog"
"net/http"
+ "strings"
"time"
+ "amadan.net/oficina/estilo.git/theme"
"github.com/carlosframework/rastrillo/csrf"
"github.com/carlosframework/rastrillo/db"
"github.com/carlosframework/rastrillo/passkey"
@@ -35,14 +37,17 @@ import (
// is sealed on the way in, and the database handle because that is what
// it is sealed into. Nothing here reaches for a raw key.
type App struct {
- secrets Secrets
- db *db.DB
- links *linkStore
- logger *slog.Logger
- renderer *renderer
- flow *signin.Flow
- sessions *sessions.Sessions
- passkeys *passkey.Handlers
+ origins Origins
+ credentialHost string
+ directoryHost string
+ secrets Secrets
+ db *db.DB
+ links *linkStore
+ logger *slog.Logger
+ renderer *renderer
+ flow *signin.Flow
+ sessions *sessions.Sessions
+ passkeys *passkey.Handlers
// passkeyCfg is the config handed to passkey.New. Retained because
// passkey.Handlers keeps its own copy unexported, and the RP-ID
@@ -63,6 +68,13 @@ type App struct {
// boot path; this guards every other way an App can be constructed,
// which is every test and every future caller.
func New(secrets Secrets, d *db.DB, logger *slog.Logger) (*App, error) {
+ return NewWithOrigins(secrets, d, logger, Origins{Origin, directoryOrigin})
+}
+
+func NewWithOrigins(secrets Secrets, d *db.DB, logger *slog.Logger, origins Origins) (*App, error) {
+ if err := origins.validate(); err != nil {
+ return nil, err
+ }
if logger == nil {
logger = slog.Default()
}
@@ -91,7 +103,7 @@ func New(secrets Secrets, d *db.DB, logger *slog.Logger) (*App, error) {
// rastrillo.
sess, err := sessions.New(sessions.Config{
DB: d.Writer(),
- Origin: Origin,
+ Origin: origins.Credential,
TTL: 30 * 24 * time.Hour,
SigninPath: "/signin",
Logger: logger,
@@ -101,13 +113,16 @@ func New(secrets Secrets, d *db.DB, logger *slog.Logger) (*App, error) {
}
a := &App{
- secrets: secrets,
- db: d,
- links: links,
- logger: logger,
- renderer: rend,
- flow: newFlow(secrets, links),
- sessions: sess,
+ origins: origins,
+ credentialHost: strings.TrimPrefix(origins.Credential, "https://"),
+ directoryHost: strings.TrimPrefix(origins.Directory, "https://"),
+ secrets: secrets,
+ db: d,
+ links: links,
+ logger: logger,
+ renderer: rend,
+ flow: newFlow(secrets, links, origins.Credential),
+ sessions: sess,
}
if a.passkeys, err = newPasskeys(a); err != nil {
@@ -130,15 +145,16 @@ func New(secrets Secrets, d *db.DB, logger *slog.Logger) (*App, error) {
func (a *App) Router() http.Handler {
r := chi.NewRouter()
- // Router-wide, not just on the verify POST. POST /signin needs it
- // too: it is an unauthenticated endpoint that sends mail and makes
- // outbound DNS and HTTPS calls decided by the submitted address — a
- // blind-SSRF surface by design — and csrf.Protect is pure origin
- // evidence (Sec-Fetch-Site, then Origin, then Referer) that never
- // consults a session, so it works before anybody is signed in.
- r.Use(csrf.Protect(Origin))
+ r.Use(a.serveKnownHost)
- r.Get("/", a.handleIndex)
+ r.Get("/", func(w http.ResponseWriter, r *http.Request) {
+ if strings.ToLower(r.Host) == a.credentialHost {
+ w.Header().Set("Location", a.origins.Directory+"/")
+ w.WriteHeader(http.StatusSeeOther)
+ return
+ }
+ a.handleIndex(w, r)
+ })
// This /healthz is NOT what answers in a deployed instance:
// rastrillo.Serve registers its own GET /healthz on the outer mux
// (serve.go's buildHandler), and Go 1.22+ http.ServeMux prefers that
@@ -150,53 +166,62 @@ func (a *App) Router() http.Handler {
w.WriteHeader(http.StatusOK)
})
- r.Get("/signin", a.handleSigninForm)
- r.Post("/signin", a.handleSigninBegin)
- r.Get("/signin/sent", a.handleSigninSent)
- // GET renders, POST redeems. See handleVerifyForm for why.
- r.Get(linkPath, a.handleVerifyForm)
- 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/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)
- // Enrolling changes who can get in, and there is no operator reset,
- // so it wants a recent session rather than merely a valid one — see
- // enrolFreshness for the lockout this closes.
r.Group(func(r chi.Router) {
- r.Use(a.sessions.RequireFresh(enrolFreshness))
- r.Get("/passkey/enrol", a.handlePasskeyEnrol)
- r.Post("/passkey/register/begin", a.passkeys.RegisterBegin)
- r.Post("/passkey/register/finish", a.passkeys.RegisterFinish)
+ r.Use(a.credentialOnly)
+ // POST /signin is an unauthenticated endpoint that sends mail and makes
+ // outbound DNS and HTTPS calls decided by the submitted address — a
+ // blind-SSRF surface by design — and csrf.Protect is pure origin
+ // evidence (Sec-Fetch-Site, then Origin, then Referer) that never
+ // consults a session, so it works before anybody is signed in.
+ r.Use(csrf.Protect(a.origins.Credential))
+ r.Get("/signin", a.handleSigninForm)
+ r.Post("/signin", a.handleSigninBegin)
+ r.Get("/signin/sent", a.handleSigninSent)
+ // GET renders, POST redeems. See handleVerifyForm for why.
+ r.Get(linkPath, a.handleVerifyForm)
+ 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/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)
+ // Enrolling changes who can get in, and there is no operator reset,
+ // so it wants a recent session rather than merely a valid one — see
+ // enrolFreshness for the lockout this closes.
+ r.Group(func(r chi.Router) {
+ r.Use(a.sessions.RequireFresh(enrolFreshness))
+ r.Get("/passkey/enrol", a.handlePasskeyEnrol)
+ r.Post("/passkey/register/begin", a.passkeys.RegisterBegin)
+ r.Post("/passkey/register/finish", a.passkeys.RegisterFinish)
+ })
+
+ // 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(staticAsset("passkey.js"), "text/javascript; charset=utf-8"))
})
- // 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(staticAsset("passkey.js"), "text/javascript; charset=utf-8"))
-
// Vendored from rastrillo's ui package, not linked from it: an app
// owns its stylesheet from the moment it lands, and
// `rastrillo doctor --fix` is what reconciles it on an upgrade.
r.Get("/static/tokens.css", serveAsset(staticAsset("tokens.css"), "text/css; charset=utf-8"))
- r.Get("/static/theme.css", serveAsset(staticAsset("theme.css"), "text/css; charset=utf-8"))
+ r.Get("/static/theme.css", serveAsset(theme.CSS(), "text/css; charset=utf-8"))
return r
}
diff --git a/internal/home/app_test.go b/internal/home/app_test.go
index 76b5755..efd584e 100644
--- a/internal/home/app_test.go
+++ b/internal/home/app_test.go
@@ -12,6 +12,7 @@ import (
func TestIndex(t *testing.T) {
app := newTestApp(t)
req := httptest.NewRequest(http.MethodGet, "/", nil)
+ req.Host = directoryHost
rec := httptest.NewRecorder()
app.Router().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
@@ -30,6 +31,7 @@ func TestIndex(t *testing.T) {
func TestHealthz(t *testing.T) {
app := newTestApp(t)
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
+ req.Host = directoryHost
rec := httptest.NewRecorder()
app.Router().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
diff --git a/internal/home/keymail_test.go b/internal/home/keymail_test.go
index 52f0f62..888de15 100644
--- a/internal/home/keymail_test.go
+++ b/internal/home/keymail_test.go
@@ -174,7 +174,7 @@ func TestCallbackWithWrongStateMintsNothing(t *testing.T) {
// submitter picked.
func TestFlowUsesBoundedHTTPClients(t *testing.T) {
app := newTestApp(t)
- fresh := newFlow(app.secrets, app.links)
+ fresh := newFlow(app.secrets, app.links, app.origins.Credential)
if fresh.Classifier.HTTP == nil {
t.Error("the classifier has no HTTP client, so it uses an unbounded default")
diff --git a/internal/home/keymailstub_test.go b/internal/home/keymailstub_test.go
index bc28754..1cde613 100644
--- a/internal/home/keymailstub_test.go
+++ b/internal/home/keymailstub_test.go
@@ -174,6 +174,7 @@ func callback(t *testing.T, app *testApp, code, state string, cookies ...*http.C
t.Helper()
req := httptest.NewRequest(http.MethodGet,
callbackPath+"?code="+url.QueryEscape(code)+"&state="+url.QueryEscape(state), nil)
+ req.Host = credentialHost
for _, c := range cookies {
if c != nil {
req.AddCookie(c)
diff --git a/internal/home/logs_test.go b/internal/home/logs_test.go
index 73dbdea..ef84f57 100644
--- a/internal/home/logs_test.go
+++ b/internal/home/logs_test.go
@@ -122,6 +122,10 @@ func TestNoContentInLogs(t *testing.T) {
}.Encode())
req := httptest.NewRequest(tg.method, tg.path+"?q="+url.QueryEscape(logtest.Canaries[0]), body)
+ req.Host = credentialHost
+ if req.URL.Path == "/" {
+ req.Host = directoryHost
+ }
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
// csrf.Protect is mounted router-wide and short-circuits a
// state-changing request with no origin evidence — before chi
@@ -172,6 +176,10 @@ func TestNoContentInLogs(t *testing.T) {
}
body := strings.NewReader(url.Values{"address": {logtest.Canaries[0]}}.Encode())
req := httptest.NewRequest(tg.method, tg.path, body)
+ req.Host = credentialHost
+ if req.URL.Path == "/" {
+ req.Host = directoryHost
+ }
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Referer", "https://"+logtest.Canaries[1]+".example.invalid")
req.Header.Set("User-Agent", logtest.Canaries[0])
diff --git a/internal/home/markup_test.go b/internal/home/markup_test.go
new file mode 100644
index 0000000..c010d36
--- /dev/null
+++ b/internal/home/markup_test.go
@@ -0,0 +1,33 @@
+package home
+
+import (
+ "os"
+ "path/filepath"
+ "regexp"
+ "strings"
+ "testing"
+)
+
+func TestTemplateComponentSpelling(t *testing.T) {
+ allowed := map[string]bool{"rst-sr-only": true, "rst-mono": true, "rst-m-hide": true, "rst-grow": true, "rst-nm": true, "rst-danger": true, "rst-cell-mut": true}
+ classes := regexp.MustCompile(`(?i)(?:^|\s)class\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))`)
+ paths, err := filepath.Glob("templates/*.html")
+ if err != nil || len(paths) == 0 {
+ t.Fatalf("template enumeration: %v", err)
+ }
+ for _, path := range paths {
+ data, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, match := range classes.FindAllStringSubmatch(string(data), -1) {
+ for _, value := range match[1:] {
+ for _, class := range strings.Fields(value) {
+ if strings.HasPrefix(class, "rst-") && !allowed[class] {
+ t.Errorf("%s: component class %q must use attribute spelling", path, class)
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/internal/home/noscript_test.go b/internal/home/noscript_test.go
index d463d20..e60e27c 100644
--- a/internal/home/noscript_test.go
+++ b/internal/home/noscript_test.go
@@ -14,6 +14,7 @@ import (
"strings"
"testing"
+ "amadan.net/oficina/estilo.git/theme"
"github.com/carlosframework/rastrillo"
"github.com/carlosframework/rastrillo/ui"
"github.com/go-chi/chi/v5"
@@ -231,7 +232,7 @@ func TestServedCSPAllowsNoInlineScript(t *testing.T) {
mux := http.NewServeMux()
mux.Handle("/", app.Router())
- h, cleanup, err := rastrillo.Handler(rastrillo.Options{Mux: mux, Logger: quietLogger()})
+ h, cleanup, err := rastrillo.Handler(rastrillo.Options{Mux: mux, Logger: quietLogger(), CSP: app.SecurityPolicy()})
if err != nil {
t.Fatalf("rastrillo.Handler: %v", err)
}
@@ -300,6 +301,10 @@ func TestServedCSPAllowsNoInlineScript(t *testing.T) {
for _, tg := range targets {
p := tg.path
req := httptest.NewRequest(tg.method, p, strings.NewReader("address=someone@example.invalid"))
+ req.Host = credentialHost
+ if req.URL.Path == "/" {
+ req.Host = directoryHost
+ }
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
// The POSTs are behind csrf.Protect, which short-circuits before
// the handler runs — and a handler that never runs sets no
@@ -333,16 +338,15 @@ func TestServedCSPAllowsNoInlineScript(t *testing.T) {
}
}
-// The app must not replace the CSP at all. Setting Options.CSP is the
-// one move that turns the carve-out into a blanket exemption, and it
-// would not show up in any rendered page.
-func TestAppNeverSetsItsOwnCSP(t *testing.T) {
+// The binary uses the policy whose only framework change is tested separately.
+func TestAppUsesReviewedCSP(t *testing.T) {
b, err := os.ReadFile("../../cmd/home/main.go")
if err != nil {
t.Fatal(err)
}
- if bytes.Contains(b, []byte("CSP")) {
- t.Error("cmd/home/main.go mentions CSP; the default is what ruling 21 assumes")
+ assignment := []byte("opts.CSP = app.SecurityPolicy()")
+ if bytes.Count(b, assignment) != 1 || bytes.Contains(bytes.ReplaceAll(b, assignment, nil), []byte("CSP")) {
+ t.Error("binary must wire exactly the reviewed policy")
}
}
@@ -376,9 +380,6 @@ func TestConfirmPageRecoveryFormSurvivesScriptStripping(t *testing.T) {
}
}
-// The vendored stylesheets must match the library they came from, so an
-// upgrade that changes them is a visible diff rather than a silent
-// drift. `rastrillo doctor --fix` is what reconciles them.
func TestVendoredAssetsMatchTheLibrary(t *testing.T) {
tokens, err := os.ReadFile("static/tokens.css")
if err != nil {
@@ -388,16 +389,30 @@ func TestVendoredAssetsMatchTheLibrary(t *testing.T) {
t.Error("static/tokens.css differs from ui.TokensCSS(); run `rastrillo doctor --fix`")
}
- theme, err := os.ReadFile("static/theme.css")
- if err != nil {
- t.Fatal(err)
+ stripHeader := func(css []byte) []byte {
+ if bytes.HasPrefix(css, []byte("/*")) {
+ if end := bytes.Index(css, []byte("*/")); end >= 0 {
+ return css[end+2:]
+ }
+ }
+ return css
}
want, ok := ui.ThemeCSS("day")
if !ok {
t.Fatal("ui has no day theme")
}
- if !bytes.Equal(theme, want) {
- t.Error("static/theme.css differs from ui.ThemeCSS(\"day\"); run `rastrillo doctor --fix`")
+ if !bytes.Equal(stripHeader(theme.CSS()), stripHeader(want)) {
+ t.Fatal("Estilo theme differs from selected Rastrillo day")
+ }
+ app := newTestApp(t)
+ for _, host := range []string{directoryHost, credentialHost} {
+ req := httptest.NewRequest(http.MethodGet, "/static/theme.css", nil)
+ req.Host = host
+ rec := httptest.NewRecorder()
+ app.Router().ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK || rec.Header().Get("Content-Type") != "text/css; charset=utf-8" || !bytes.Equal(rec.Body.Bytes(), theme.CSS()) {
+ t.Fatalf("%s: shared theme response mismatch (status %d)", host, rec.Code)
+ }
}
}
diff --git a/internal/home/origins.go b/internal/home/origins.go
new file mode 100644
index 0000000..fb6829f
--- /dev/null
+++ b/internal/home/origins.go
@@ -0,0 +1,106 @@
+package home
+
+import (
+ "errors"
+ "net"
+ "net/http"
+ "net/url"
+ "os"
+ "strings"
+)
+
+const (
+ suiteDomain = "oficina.works"
+ credentialHost = "id." + suiteDomain
+ directoryHost = suiteDomain
+ directoryOrigin = "https://" + directoryHost
+)
+
+var errOrigins = errors.New("home: invalid origin configuration")
+
+type Origins struct {
+ Credential string
+ Directory string
+}
+
+func LoadOrigins() (Origins, error) {
+ credential, haveCredential := os.LookupEnv("HOME_CREDENTIAL_ORIGIN")
+ directory, haveDirectory := os.LookupEnv("HOME_DIRECTORY_ORIGIN")
+ if !haveCredential && !haveDirectory {
+ return Origins{Origin, directoryOrigin}, nil
+ }
+ origins := Origins{credential, directory}
+ if !haveCredential || !haveDirectory {
+ return Origins{}, errOrigins
+ }
+ return origins, origins.validate()
+}
+
+func originHost(origin string) (string, error) {
+ u, err := url.Parse(origin)
+ if err != nil || u.Scheme != "https" || u.User != nil || u.Host == "" || origin != "https://"+u.Host || u.Host != strings.ToLower(u.Host) || net.ParseIP(u.Host) != nil {
+ return "", errOrigins
+ }
+ if len(u.Host) > 253 {
+ return "", errOrigins
+ }
+ labels := strings.Split(u.Host, ".")
+ if len(labels) < 2 {
+ return "", errOrigins
+ }
+ for _, label := range labels {
+ if len(label) == 0 || len(label) > 63 || label[0] == '-' || label[len(label)-1] == '-' {
+ return "", errOrigins
+ }
+ for _, c := range label {
+ if !(c >= 'a' && c <= 'z' || c >= '0' && c <= '9' || c == '-') {
+ return "", errOrigins
+ }
+ }
+ }
+ return u.Host, nil
+}
+
+func (o Origins) validate() error {
+ credential, err := originHost(o.Credential)
+ if err != nil {
+ return err
+ }
+ directory, err := originHost(o.Directory)
+ if err != nil {
+ return err
+ }
+ if directory == credential || strings.HasSuffix(directory, "."+credential) {
+ return errOrigins
+ }
+ if o.Credential == Origin && o.Directory == directoryOrigin {
+ return nil
+ }
+ for _, host := range []string{credential, directory} {
+ if host == directoryHost || host == credentialHost || strings.HasSuffix(host, "."+credentialHost) {
+ return errOrigins
+ }
+ }
+ return nil
+}
+
+func (a *App) serveKnownHost(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ host := strings.ToLower(r.Host)
+ if host != a.credentialHost && host != a.directoryHost {
+ w.WriteHeader(http.StatusNotFound)
+ return
+ }
+ next.ServeHTTP(w, r)
+ })
+}
+
+func (a *App) credentialOnly(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if strings.ToLower(r.Host) != a.credentialHost {
+ w.WriteHeader(http.StatusNotFound)
+ return
+ }
+ next.ServeHTTP(w, r)
+ })
+}
diff --git a/internal/home/origins_config_test.go b/internal/home/origins_config_test.go
new file mode 100644
index 0000000..9859282
--- /dev/null
+++ b/internal/home/origins_config_test.go
@@ -0,0 +1,139 @@
+package home
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "os"
+ "strings"
+ "testing"
+)
+
+func TestConfiguredOriginsRequireAnIsolatedPair(t *testing.T) {
+ valid := Origins{"https://id.preview.example", "https://home.preview.example"}
+ for _, pair := range []Origins{{Origin, directoryOrigin}, valid, {"https://notid.oficina.works", "https://preview.oficina.works"}} {
+ if err := pair.validate(); err != nil {
+ t.Errorf("valid %+v: %v", pair, err)
+ }
+ }
+ for _, pair := range []Origins{
+ {}, {Origin, valid.Directory}, {valid.Credential, directoryOrigin},
+ {valid.Credential, valid.Credential}, {valid.Credential, "https://child.id.preview.example"},
+ {"https://preview.id.oficina.works", valid.Directory}, {valid.Credential, "https://child.id.oficina.works"},
+ } {
+ if err := pair.validate(); err == nil {
+ t.Errorf("accepted unsafe pair %+v", pair)
+ }
+ }
+ for _, bad := range []string{"", "http://id.preview.example", "https://ID.preview.example", "https://id.preview.example/", "https://id.preview.example:443", "https://user@id.preview.example", "https://id.preview.example?x", "https://id.preview.example#x", "https://127.0.0.1", "https://[::1]", "https://localhost", "https://id.preview.example.", "https://-id.example", "https://id_.example"} {
+ for _, pair := range []Origins{{bad, valid.Directory}, {valid.Credential, bad}} {
+ if err := pair.validate(); err == nil {
+ t.Errorf("accepted malformed pair %+v", pair)
+ }
+ }
+ }
+}
+
+func TestLoadOriginsRequiresBothVariablesOrNeither(t *testing.T) {
+ for _, key := range []string{"HOME_CREDENTIAL_ORIGIN", "HOME_DIRECTORY_ORIGIN"} {
+ t.Setenv(key, "")
+ if err := os.Unsetenv(key); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if got, err := LoadOrigins(); err != nil || got != (Origins{Origin, directoryOrigin}) {
+ t.Fatalf("defaults %+v %v", got, err)
+ }
+ t.Setenv("HOME_CREDENTIAL_ORIGIN", "https://id.preview.example")
+ if _, err := LoadOrigins(); err == nil {
+ t.Fatal("accepted credential-only setting")
+ }
+ t.Setenv("HOME_DIRECTORY_ORIGIN", "https://home.preview.example")
+ if got, err := LoadOrigins(); err != nil || got.Credential != "https://id.preview.example" {
+ t.Fatalf("pair %+v %v", got, err)
+ }
+ t.Setenv("HOME_CREDENTIAL_ORIGIN", "")
+ if _, err := LoadOrigins(); err == nil {
+ t.Fatal("accepted explicit empty setting")
+ }
+}
+
+func TestCustomOriginsReachEveryCredentialSeam(t *testing.T) {
+ origins := Origins{"https://id.preview.example", "https://home.preview.example"}
+ app := newTestAppWithOrigins(t, origins)
+ if app.flow.Origin != origins.Credential || app.flow.Keymail("keymail.example").Origin != origins.Credential || app.passkeyCfg.Origin != origins.Credential || app.passkeyCfg.LegacyRPID != "" || app.passkeyPageData("subject", nil)["RPID"] != "id.preview.example" {
+ t.Fatal("credential origin did not reach signin/keymail/passkey configuration")
+ }
+ router := app.Router()
+ for _, c := range []struct {
+ origin, path string
+ status int
+ }{
+ {origins.Credential, "/signin", 200}, {origins.Directory, "/", 200}, {origins.Credential, "/", 303},
+ {origins.Directory, "/signin", 404}, {Origin, "/signin", 404}, {directoryOrigin, "/", 404},
+ {strings.ToUpper(origins.Credential), "/signin", 200},
+ } {
+ req := httptest.NewRequest(http.MethodGet, c.origin+c.path, nil)
+ rec := httptest.NewRecorder()
+ router.ServeHTTP(rec, req)
+ if rec.Code != c.status {
+ t.Errorf("%s%s=%d want%d", c.origin, c.path, rec.Code, c.status)
+ }
+ if c.status == 303 && (rec.Header().Get("Location") != origins.Directory+"/" || rec.Body.Len() != 0) {
+ t.Fatal("credential root escaped configured directory")
+ }
+ }
+ for _, origin := range []string{origins.Credential, origins.Directory} {
+ for _, path := range []string{"/healthz", "/static/tokens.css", "/static/theme.css"} {
+ rec := httptest.NewRecorder()
+ router.ServeHTTP(rec, httptest.NewRequest("GET", origin+path, nil))
+ if rec.Code != 200 {
+ t.Errorf("shared %s%s=%d", origin, path, rec.Code)
+ }
+ }
+ }
+ post := func(path, source string, values url.Values) *httptest.ResponseRecorder {
+ req := httptest.NewRequest("POST", origins.Credential+path, strings.NewReader(values.Encode()))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ req.Header.Set("Origin", source)
+ rec := httptest.NewRecorder()
+ router.ServeHTTP(rec, req)
+ return rec
+ }
+ if rec := post("/signin", Origin, url.Values{"address": {"person@example.invalid"}}); rec.Code != 403 {
+ t.Fatalf("production CSRF origin accepted: %d", rec.Code)
+ }
+ if _, ok := app.mailer.last(); ok {
+ t.Fatal("wrong CSRF origin sent mail")
+ }
+ if rec := post("/signin", origins.Credential, url.Values{"address": {"person@example.invalid"}}); rec.Code != 303 {
+ t.Fatalf("configured signin=%d", rec.Code)
+ }
+ sent, ok := app.mailer.last()
+ if !ok {
+ t.Fatal("no captured mail")
+ }
+ var token string
+ for _, word := range strings.Fields(sent.Body) {
+ if strings.HasPrefix(word, origins.Credential+linkPath) {
+ u, err := url.Parse(word)
+ if err != nil {
+ t.Fatal(err)
+ }
+ token = u.Query().Get("token")
+ }
+ }
+ if token == "" {
+ t.Fatal("mail did not contain configured credential link")
+ }
+ rec := post(linkPath, origins.Credential, url.Values{"token": {token}})
+ cookie := sessionCookie(rec)
+ if rec.Code != 303 || cookie == nil {
+ t.Fatalf("redemption=%d missing session=%v", rec.Code, cookie == nil)
+ }
+ for _, c := range rec.Result().Cookies() {
+ if c.Domain != "" || !c.Secure || c.Path != "/" {
+ t.Errorf("unsafe configured cookie %s", c.Name)
+ }
+ }
+}
diff --git a/internal/home/origins_test.go b/internal/home/origins_test.go
new file mode 100644
index 0000000..51b8fa6
--- /dev/null
+++ b/internal/home/origins_test.go
@@ -0,0 +1,102 @@
+package home
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/go-chi/chi/v5"
+)
+
+func TestEveryCredentialRouteRefusesDirectoryHost(t *testing.T) {
+ app := newTestApp(t)
+ router := app.Router()
+ routes := router.(chi.Routes)
+ count := 0
+ err := chi.Walk(routes, func(method, route string, _ http.Handler, _ ...func(http.Handler) http.Handler) error {
+ switch route {
+ case "/", "/healthz", "/static/tokens.css", "/static/theme.css":
+ return nil
+ }
+ count++
+ req := httptest.NewRequest(method, "https://"+directoryHost+route, nil)
+ req.Header.Set("Origin", Origin)
+ rec := httptest.NewRecorder()
+ router.ServeHTTP(rec, req)
+ if rec.Code != http.StatusNotFound || len(rec.Result().Cookies()) != 0 || rec.Body.Len() != 0 {
+ t.Errorf("%s %s on directory: status=%d cookies=%d body=%q", method, route, rec.Code, len(rec.Result().Cookies()), rec.Body.String())
+ }
+ return nil
+ })
+ if err != nil || count < 19 {
+ t.Fatalf("route walk err=%v credential routes=%d", err, count)
+ }
+ if _, ok := app.mailer.last(); ok {
+ t.Fatal("directory request sent mail")
+ }
+}
+
+func TestDirectoryContentNeverServedOnCredentialHost(t *testing.T) {
+ app := newTestApp(t)
+ req := httptest.NewRequest(http.MethodGet, Origin+"/?team=untrusted", nil)
+ rec := httptest.NewRecorder()
+ app.Router().ServeHTTP(rec, req)
+ if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != directoryOrigin+"/" || rec.Body.Len() != 0 {
+ t.Fatalf("credential root: status=%d location=%q body=%q", rec.Code, rec.Header().Get("Location"), rec.Body.String())
+ }
+}
+
+func TestUnknownHostsCannotReachRoutes(t *testing.T) {
+ app := newTestApp(t)
+ router := app.Router()
+ hosts := []string{"", "example.com", "oficina.works.attacker.invalid", "id.oficina.works.attacker.invalid", "id.oficina.works.", "id.oficina.works:443", "oficina.works:443", "localhost"}
+ err := chi.Walk(router.(chi.Routes), func(method, route string, _ http.Handler, _ ...func(http.Handler) http.Handler) error {
+ for _, host := range hosts {
+ req := httptest.NewRequest(method, route, nil)
+ req.Host = host
+ req.Header.Set("Origin", Origin)
+ req.Header.Set("X-Forwarded-Host", credentialHost)
+ req.Header.Set("Forwarded", "host="+credentialHost)
+ rec := httptest.NewRecorder()
+ router.ServeHTTP(rec, req)
+ if rec.Code != http.StatusNotFound || len(rec.Result().Cookies()) != 0 || rec.Body.Len() != 0 {
+ t.Errorf("%s %s host=%q status=%d", method, route, host, rec.Code)
+ }
+ }
+ return nil
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestDirectoryIsNotWithinCredentialRP(t *testing.T) {
+ app := newTestApp(t)
+ if credentialHost == directoryHost || app.credentialHost == directoryHost || Origin == directoryOrigin {
+ t.Fatal("directory overlaps the credential origin or relying party")
+ }
+}
+
+func TestSharedRoutesOnBothOrigins(t *testing.T) {
+ app := newTestApp(t)
+ for _, host := range []string{credentialHost, directoryHost} {
+ for _, path := range []string{"/healthz", "/static/tokens.css", "/static/theme.css"} {
+ req := httptest.NewRequest(http.MethodGet, "https://"+host+path, nil)
+ rec := httptest.NewRecorder()
+ app.Router().ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK || len(rec.Result().Cookies()) != 0 {
+ t.Errorf("host=%s path=%s status=%d cookies=%d", host, path, rec.Code, len(rec.Result().Cookies()))
+ }
+ }
+ }
+}
+
+func TestDirectoryCredentialPostRefusedBeforeCSRF(t *testing.T) {
+ app := newTestApp(t)
+ req := httptest.NewRequest(http.MethodPost, directoryOrigin+"/signin", nil)
+ rec := httptest.NewRecorder()
+ app.Router().ServeHTTP(rec, req)
+ if rec.Code != http.StatusNotFound || rec.Body.Len() != 0 {
+ t.Fatalf("directory credential POST status=%d body=%q", rec.Code, rec.Body.String())
+ }
+}
diff --git a/internal/home/passkey.go b/internal/home/passkey.go
index b713368..3966bef 100644
--- a/internal/home/passkey.go
+++ b/internal/home/passkey.go
@@ -4,14 +4,13 @@ import (
"embed"
"encoding/base64"
"net/http"
- "net/url"
"time"
"github.com/carlosframework/rastrillo/passkey"
"github.com/carlosframework/rastrillo/webauthn"
)
-//go:embed static/passkey.js static/tokens.css static/theme.css
+//go:embed static/passkey.js static/tokens.css
var staticFS embed.FS
// confirmPath is where the Gate sends a first-factor-verified caller who
@@ -97,7 +96,7 @@ func passkeyConfig(a *App) passkey.Config {
return passkey.Config{
Sessions: a.sessions,
DB: a.db.Writer(),
- Origin: Origin,
+ Origin: a.origins.Credential,
ConfirmPath: confirmPath,
Logger: a.logger,
}
@@ -119,7 +118,7 @@ func newPasskeys(a *App) (*passkey.Handlers, error) {
// around.
func (a *App) passkeyPageData(subject string, extra map[string]any) map[string]any {
data := map[string]any{
- "RPID": rpID(),
+ "RPID": a.credentialHost,
"LegacyRPID": a.passkeyCfg.LegacyRPID,
"UserID": base64.RawURLEncoding.EncodeToString([]byte(subject)),
"UserName": subject,
@@ -218,14 +217,3 @@ func staticAsset(name string) []byte {
}
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
index fde7d45..d1b1aed 100644
--- a/internal/home/passkey_test.go
+++ b/internal/home/passkey_test.go
@@ -40,6 +40,7 @@ func postJSON(t *testing.T, app *testApp, path string, cookie *http.Cookie, body
payload = "{}"
}
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(payload))
+ req.Host = credentialHost
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Origin", Origin)
if cookie != nil {
@@ -77,7 +78,7 @@ func enrol(t *testing.T, app *testApp, cookie *http.Cookie) *authtest.Authentica
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})
+ clientData, attestation := a.Create(challenge, authtest.Options{RPID: app.credentialHost, Origin: Origin})
rec := postJSON(t, app, "/passkey/register/finish", cookie, map[string]string{
"clientDataJSON": base64.RawURLEncoding.EncodeToString(clientData),
"attestationObject": base64.RawURLEncoding.EncodeToString(attestation),
@@ -190,6 +191,7 @@ func TestRecoveryCodeCompletesSignInWithoutScript(t *testing.T) {
form := url.Values{"code": {codes[0]}}
req := httptest.NewRequest(http.MethodPost, "/passkey/signin/recovery", strings.NewReader(form.Encode()))
+ req.Host = credentialHost
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Origin", Origin)
for _, c := range (&http.Response{Header: gated.Header()}).Cookies() {
@@ -216,6 +218,7 @@ func TestRecoveryCodeCompletesSignInWithoutScript(t *testing.T) {
func subjectOf(t *testing.T, app *testApp, cookie *http.Cookie) string {
t.Helper()
req := httptest.NewRequest(http.MethodGet, "/", nil)
+ req.Host = credentialHost
req.AddCookie(cookie)
sess, ok := app.sessions.From(req)
if !ok {
@@ -230,6 +233,7 @@ 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)
+ req.Host = credentialHost
w := httptest.NewRecorder()
app.Router().ServeHTTP(w, req)
if w.Code != http.StatusOK {
@@ -253,6 +257,7 @@ func TestRecoveryCodesPageNeedsAFreshSession(t *testing.T) {
get := func() int {
req := httptest.NewRequest(http.MethodGet, "/passkey/codes", nil)
+ req.Host = credentialHost
req.AddCookie(cookie)
w := httptest.NewRecorder()
app.Router().ServeHTTP(w, req)
@@ -304,6 +309,7 @@ func TestRecoveryCodeProducesAFreshSession(t *testing.T) {
form := url.Values{"code": {codes[0]}}
req := httptest.NewRequest(http.MethodPost, "/passkey/signin/recovery", strings.NewReader(form.Encode()))
+ req.Host = credentialHost
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Origin", Origin)
for _, c := range (&http.Response{Header: gated.Header()}).Cookies() {
@@ -319,6 +325,7 @@ func TestRecoveryCodeProducesAFreshSession(t *testing.T) {
// The RequireFresh-guarded page opens to it.
page := httptest.NewRequest(http.MethodGet, "/passkey/codes", nil)
+ page.Host = credentialHost
page.AddCookie(recovered)
pw := httptest.NewRecorder()
app.Router().ServeHTTP(pw, page)
@@ -361,6 +368,7 @@ func TestEnrolmentNeedsAFreshSession(t *testing.T) {
// The page itself redirects to re-authenticate rather than 404ing or
// 403ing: a detour, not a dead end.
req := httptest.NewRequest(http.MethodGet, "/passkey/enrol", nil)
+ req.Host = credentialHost
req.AddCookie(cookie)
w := httptest.NewRecorder()
app.Router().ServeHTTP(w, req)
@@ -402,6 +410,7 @@ func TestEnrolThenReadCodesWithoutReauthenticating(t *testing.T) {
enrol(t, app, cookie)
req := httptest.NewRequest(http.MethodGet, "/passkey/codes", nil)
+ req.Host = credentialHost
req.AddCookie(cookie)
w := httptest.NewRecorder()
app.Router().ServeHTTP(w, req)
diff --git a/internal/home/rpid_test.go b/internal/home/rpid_test.go
index 003fe2d..ebc1105 100644
--- a/internal/home/rpid_test.go
+++ b/internal/home/rpid_test.go
@@ -60,8 +60,8 @@ func TestRPIDIsExactlyTheCredentialHost(t *testing.T) {
// the ceremony and the server disagree about who the relying party
// is and every assertion fails — or worse, succeeds under a wider
// name.
- if rpID() != u.Hostname() {
- t.Errorf("the pages advertise RP ID %q but the server derives %q", rpID(), u.Hostname())
+ if app.credentialHost != u.Hostname() {
+ t.Errorf("the pages advertise RP ID %q but the server derives %q", app.credentialHost, u.Hostname())
}
}
diff --git a/internal/home/security.go b/internal/home/security.go
new file mode 100644
index 0000000..21fb5fc
--- /dev/null
+++ b/internal/home/security.go
@@ -0,0 +1,8 @@
+package home
+
+// SecurityPolicy preserves the framework policy while allowing the configured
+// directory origin at the end of a credential form's redirect chain.
+func (a *App) SecurityPolicy() string {
+ return "default-src 'self'; style-src 'self' 'unsafe-inline'; " +
+ "img-src 'self' data:; frame-ancestors 'none'; base-uri 'self'; form-action 'self' " + a.origins.Directory
+}
diff --git a/internal/home/security_test.go b/internal/home/security_test.go
new file mode 100644
index 0000000..e166f32
--- /dev/null
+++ b/internal/home/security_test.go
@@ -0,0 +1,38 @@
+package home
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/carlosframework/rastrillo"
+)
+
+func TestSecurityPolicyPreservesFrameworkDefaults(t *testing.T) {
+ mux := http.NewServeMux()
+ mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) })
+ handler, cleanup, err := rastrillo.Handler(rastrillo.Options{Mux: mux, Logger: quietLogger()})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if cleanup != nil {
+ defer cleanup()
+ }
+ rec := httptest.NewRecorder()
+ handler.ServeHTTP(rec, httptest.NewRequest("GET", "https://id.example.test/", nil))
+ baseline := rec.Header().Get("Content-Security-Policy")
+ for _, origins := range []Origins{{Origin, directoryOrigin}, {"https://id.example.test", "https://directory.example.test"}} {
+ if err := origins.validate(); err != nil {
+ t.Fatal(err)
+ }
+ app := &App{origins: origins}
+ want := strings.Replace(baseline, "form-action 'self'", "form-action 'self' "+origins.Directory, 1)
+ if baseline == want {
+ t.Fatal("framework form-action changed; review policy")
+ }
+ if got := app.SecurityPolicy(); got != want {
+ t.Fatalf("policy differs beyond directory permission: %q", got)
+ }
+ }
+}
diff --git a/internal/home/signin.go b/internal/home/signin.go
index 4931bcf..83be359 100644
--- a/internal/home/signin.go
+++ b/internal/home/signin.go
@@ -15,7 +15,7 @@ import (
// the session cookie and is the WebAuthn RP. §4 splits it from the
// directory origin deliberately, and both the CSRF check and the cookie
// attributes are keyed on this exact string.
-const Origin = "https://id.oficina.works"
+const Origin = "https://" + credentialHost
// linkPath is where magic links land. It must match the route mounted
// below; signin's own default is /auth/verify, which this app does not
@@ -77,14 +77,14 @@ const classifierTimeout = 5 * time.Second
// newFlow wires signin.Flow. Exactly one per process: the rate limiter's
// state lives on the value, so one per request would hand every caller a
// fresh empty budget and rate-limit nothing, silently.
-func newFlow(secrets Secrets, links signin.LinkStore) *signin.Flow {
+func newFlow(secrets Secrets, links signin.LinkStore, origin string) *signin.Flow {
http := &http.Client{Timeout: classifierTimeout}
return &signin.Flow{
Classifier: &signin.Classifier{HTTP: http, Scheme: "https"},
Keymail: func(server string) *signin.Keymail {
return &signin.Keymail{
Base: "https://" + server,
- Origin: Origin,
+ Origin: origin,
RedirectPath: callbackPath,
HTTP: http,
}
@@ -92,7 +92,7 @@ func newFlow(secrets Secrets, links signin.LinkStore) *signin.Flow {
Links: links,
Mailer: secrets.Mailer,
Key: secrets.SigninKey,
- Origin: Origin,
+ Origin: origin,
LinkPath: linkPath,
Subject: "Sign in to Oficina",
}
@@ -155,6 +155,7 @@ func clientIP(r *http.Request) string {
// handleSigninForm renders the address form.
func (a *App) handleSigninForm(w http.ResponseWriter, r *http.Request) {
+ rememberReturn(w, r)
a.render(w, http.StatusOK, "signin.html", map[string]any{})
}
@@ -319,7 +320,9 @@ func (a *App) completeSignIn(w http.ResponseWriter, r *http.Request, id signin.I
//
// 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)
+ target := readReturn(r)
+ writeReturn(w, "/")
+ gated, err := a.passkeys.Gate(w, requestWithReturn(r, target), sess)
if err != nil {
a.logger.Error("passkey gate", "person", ref, "err", err)
a.signinFailed(w)
@@ -334,7 +337,7 @@ func (a *App) completeSignIn(w http.ResponseWriter, r *http.Request, id signin.I
a.signinFailed(w)
return
}
- http.Redirect(w, r, "/", http.StatusSeeOther)
+ http.Redirect(w, r, target, http.StatusSeeOther)
}
// signinFailed is the one answer every failed redemption gets. One page,
diff --git a/internal/home/signin_return.go b/internal/home/signin_return.go
new file mode 100644
index 0000000..7464359
--- /dev/null
+++ b/internal/home/signin_return.go
@@ -0,0 +1,68 @@
+package home
+
+import (
+ "net/http"
+ "net/url"
+ "time"
+)
+
+const returnCookie = "__Host-oficina-return"
+const returnTTL = 10 * time.Minute
+
+func credentialReturn(value string) string {
+ switch value {
+ case "/passkey/enrol", "/passkey/codes":
+ return value
+ }
+ return "/"
+}
+
+func writeReturn(w http.ResponseWriter, value string) {
+ maxAge := int(returnTTL.Seconds())
+ if value == "/" {
+ value = ""
+ maxAge = -1
+ }
+ http.SetCookie(w, &http.Cookie{Name: returnCookie, Value: value, Path: "/", MaxAge: maxAge, Secure: true, HttpOnly: true, SameSite: http.SameSiteLaxMode})
+}
+
+func readReturn(r *http.Request) string {
+ value := ""
+ count := 0
+ for _, c := range r.Cookies() {
+ if c.Name == returnCookie {
+ value = c.Value
+ count++
+ }
+ }
+ if count != 1 {
+ return "/"
+ }
+ return credentialReturn(value)
+}
+
+func rememberReturn(w http.ResponseWriter, r *http.Request) {
+ q, err := url.ParseQuery(r.URL.RawQuery)
+ if err != nil {
+ writeReturn(w, "/")
+ return
+ }
+ values, present := q["return_to"]
+ if !present && q.Get("force") == "1" && readReturn(r) != "/" {
+ return
+ }
+ target := "/"
+ if len(values) == 1 {
+ target = credentialReturn(values[0])
+ }
+ writeReturn(w, target)
+}
+
+// Gate reads FormValue. Give it only validated navigation intent, so neither
+// the callback query nor the magic-link POST can override the cookie.
+func requestWithReturn(r *http.Request, target string) *http.Request {
+ clone := r.Clone(r.Context())
+ clone.Form = url.Values{"return_to": {target}}
+ clone.PostForm = url.Values{"return_to": {target}}
+ return clone
+}
diff --git a/internal/home/signin_return_factor_test.go b/internal/home/signin_return_factor_test.go
new file mode 100644
index 0000000..c0f44ff
--- /dev/null
+++ b/internal/home/signin_return_factor_test.go
@@ -0,0 +1,98 @@
+package home
+
+import (
+ "encoding/base64"
+ "encoding/json"
+ "net/http"
+ "net/url"
+ "strings"
+ "testing"
+
+ "github.com/carlosframework/rastrillo/webauthn/authtest"
+)
+
+// Mutation-checked 2026-09-07: bypassing Gate or passing the original
+// request allows factor bypass or hostile FormValue precedence and fails.
+func TestCredentialReturnSecondFactors(t *testing.T) {
+ for _, first := range []string{"magic", "keymail"} {
+ for _, factor := range []string{"passkey", "recovery"} {
+ t.Run(first+factor, func(t *testing.T) {
+ app := newTestApp(t)
+ addr := "person@example.invalid"
+ var session *http.Cookie
+ if first == "keymail" {
+ addr = "person@keymail.invalid"
+ newKeymailStub(t, app, addr)
+ p, state := beginKeymail(t, app, addr)
+ session = sessionCookie(callback(t, app, "code-1", state, p))
+ } else {
+ session = signInFully(t, app, addr)
+ }
+ authenticator := enrol(t, app, session)
+ codes, err := app.passkeys.RegenerateRecoveryCodes(subjectOf(t, app, session))
+ if err != nil {
+ t.Fatal(err)
+ }
+ intent := intentFrom(returnRequest(app, "GET", "/signin?return_to=/passkey/enrol", nil))
+ var gatedCookies []*http.Cookie
+ if first == "keymail" {
+ p, state := beginKeymail(t, app, addr)
+ got := returnRequest(app, "GET", callbackPath+"?code=code-2&state="+url.QueryEscape(state)+"&return_to=/passkey/codes", nil, p, intent)
+ if sessionCookie(got) != nil || got.Header().Get("Location") != confirmPath {
+ t.Fatal("Keymail bypassed second factor")
+ }
+ gatedCookies = got.Result().Cookies()
+ } else {
+ signIn(t, app, addr)
+ got := returnRequest(app, "POST", linkPath+"?return_to=/passkey/codes", url.Values{"token": {capturedLink(t, app)}, "return_to": {"//evil.invalid"}}, intent)
+ if sessionCookie(got) != nil || got.Header().Get("Location") != confirmPath {
+ t.Fatal("magic link bypassed second factor")
+ }
+ gatedCookies = got.Result().Cookies()
+ }
+ var pending *http.Cookie
+ for _, c := range gatedCookies {
+ if strings.Contains(c.Name, "passkey") && c.Value != "" {
+ pending = c
+ }
+ }
+ if pending == nil {
+ t.Fatal("no pending second factor")
+ }
+ denied := returnRequest(app, "POST", "/passkey/signin/recovery", url.Values{"code": {"invalid"}}, pending)
+ if sessionCookie(denied) != nil {
+ t.Fatal("invalid recovery minted session")
+ }
+ if factor == "recovery" {
+ got := returnRequest(app, "POST", "/passkey/signin/recovery", url.Values{"code": {codes[0]}, "return_to": {"/passkey/codes"}}, pending)
+ if got.Header().Get("Location") != "/passkey/enrol" || sessionCookie(got) == nil {
+ t.Fatalf("recovery continuation=%q status=%d", got.Header().Get("Location"), got.Code)
+ }
+ if dest := returnRequest(app, "GET", "/passkey/enrol", nil, sessionCookie(got)); dest.Code != 200 {
+ t.Fatalf("recovery freshness=%d", dest.Code)
+ }
+ } else {
+ challenge := challengeFrom(t, postJSON(t, app, "/passkey/signin/begin", pending, nil))
+ clientData, authData, sig, err := authenticator.Get(challenge, authtest.Options{RPID: app.credentialHost, Origin: Origin})
+ if err != nil {
+ t.Fatal(err)
+ }
+ b64 := base64.RawURLEncoding.EncodeToString
+ got := postJSON(t, app, "/passkey/signin/finish", pending, map[string]string{"id": b64(authenticator.CredID), "clientDataJSON": b64(clientData), "authenticatorData": b64(authData), "signature": b64(sig)})
+ var response struct {
+ To string `json:"to"`
+ }
+ if err := json.Unmarshal(got.Body.Bytes(), &response); err != nil {
+ t.Fatal(err)
+ }
+ if response.To != "/passkey/enrol" || sessionCookie(got) == nil {
+ t.Fatalf("passkey continuation=%q status=%d", response.To, got.Code)
+ }
+ if dest := returnRequest(app, "GET", "/passkey/enrol", nil, sessionCookie(got)); dest.Code != 200 {
+ t.Fatalf("passkey freshness=%d", dest.Code)
+ }
+ }
+ })
+ }
+ }
+}
diff --git a/internal/home/signin_return_test.go b/internal/home/signin_return_test.go
new file mode 100644
index 0000000..16cc2e8
--- /dev/null
+++ b/internal/home/signin_return_test.go
@@ -0,0 +1,161 @@
+package home
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strings"
+ "testing"
+ "time"
+)
+
+func returnRequest(app *testApp, method, path string, form url.Values, cookies ...*http.Cookie) *httptest.ResponseRecorder {
+ req := httptest.NewRequest(method, path, strings.NewReader(form.Encode()))
+ req.Host = credentialHost
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ req.Header.Set("Origin", Origin)
+ for _, c := range cookies {
+ if c != nil {
+ req.AddCookie(c)
+ }
+ }
+ w := httptest.NewRecorder()
+ app.Router().ServeHTTP(w, req)
+ return w
+}
+
+func intentFrom(w *httptest.ResponseRecorder) *http.Cookie {
+ for _, c := range w.Result().Cookies() {
+ if c.Name == "__Host-oficina-return" {
+ return c
+ }
+ }
+ return nil
+}
+
+// Mutation-checked 2026-09-07: discarding validated intent restores the
+// original wrong destination; trusting cookie values fails hostile cases.
+func TestCredentialReturnMagicLink(t *testing.T) {
+ for _, target := range []string{"/passkey/enrol", "/passkey/codes"} {
+ t.Run(target, func(t *testing.T) {
+ app := newTestApp(t)
+ session := signInFully(t, app, "person@example.invalid")
+ 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)
+ }
+ stale := returnRequest(app, "GET", target, nil, session)
+ form := returnRequest(app, "GET", stale.Header().Get("Location"), nil, session)
+ intent := intentFrom(form)
+ returnRequest(app, "POST", "/signin", url.Values{"address": {"person@example.invalid"}}, intent)
+ got := returnRequest(app, "POST", linkPath, url.Values{"token": {capturedLink(t, app)}}, intent)
+ if got.Header().Get("Location") != target {
+ t.Fatalf("location=%q, want%q", got.Header().Get("Location"), target)
+ }
+ if sessionCookie(got) == nil {
+ t.Fatal("missing verified session")
+ }
+ if r := returnRequest(app, "GET", target, nil, sessionCookie(got)); r.Code != 200 {
+ t.Fatalf("fresh destination=%d", r.Code)
+ }
+ })
+ }
+}
+
+func TestCredentialReturnKeymailAndFallback(t *testing.T) {
+ for _, fallback := range []bool{false, true} {
+ t.Run(string(rune('0'+boolInt(fallback))), func(t *testing.T) {
+ app := newTestApp(t)
+ newKeymailStub(t, app, "person@keymail.invalid")
+ intent := intentFrom(returnRequest(app, "GET", "/signin?return_to=%2Fpasskey%2Fcodes", nil))
+ pending, state := beginKeymail(t, app, "person@keymail.invalid")
+ code := "code-1"
+ if fallback {
+ state = "invalid-state"
+ }
+ got := callback(t, app, code, state, pending, intent)
+ if fallback {
+ if got.Header().Get("Location") != "/signin?force=1" || sessionCookie(got) != nil {
+ t.Fatal("invalid Keymail callback did not fall back")
+ }
+ form := returnRequest(app, "GET", got.Header().Get("Location"), nil, intent)
+ if changed := intentFrom(form); changed != nil {
+ intent = changed
+ }
+ returnRequest(app, "POST", "/signin?force=1", url.Values{"address": {"person@keymail.invalid"}}, intent)
+ got = returnRequest(app, "POST", linkPath, url.Values{"token": {capturedLink(t, app)}}, intent)
+ }
+ if got.Header().Get("Location") != "/passkey/codes" || sessionCookie(got) == nil {
+ t.Fatalf("continuation=%q status=%d", got.Header().Get("Location"), got.Code)
+ }
+ })
+ }
+}
+
+func boolInt(b bool) int {
+ if b {
+ return 1
+ }
+ return 0
+}
+
+func TestCredentialReturnHostileAndMissingIntent(t *testing.T) {
+ for _, value := range []string{"https://evil.invalid/", "//evil.invalid/", "/passkey/codes?x=1", "/passkey/enrol/", "%2f%2fevil.invalid", "/passkey/codes\r\nLocation: x", "/"} {
+ t.Run(value, func(t *testing.T) {
+ app := newTestApp(t)
+ c := intentFrom(returnRequest(app, "GET", "/signin?return_to="+url.QueryEscape(value), nil))
+ if c == nil || c.MaxAge >= 0 {
+ t.Fatal("unsafe target retained")
+ }
+ })
+ }
+ for _, query := range []string{"", "?return_to=/passkey/enrol&return_to=/passkey/codes", "?return_to=%zz"} {
+ app := newTestApp(t)
+ old := &http.Cookie{Name: returnCookie, Value: "/passkey/codes"}
+ c := intentFrom(returnRequest(app, "GET", "/signin"+query, nil, old))
+ if c == nil || c.MaxAge >= 0 {
+ t.Fatal("ambiguous/plain sign-in retained old intent")
+ }
+ }
+ for _, cookies := range [][]*http.Cookie{nil, {{Name: returnCookie, Value: "https://evil.invalid"}}, {{Name: returnCookie, Value: "/passkey/codes"}, {Name: returnCookie, Value: "/passkey/enrol"}}} {
+ app := newTestApp(t)
+ signIn(t, app, "person@example.invalid")
+ got := returnRequest(app, "POST", linkPath+"?return_to=/passkey/codes", url.Values{"token": {capturedLink(t, app)}, "return_to": {"/passkey/enrol"}}, cookies...)
+ if got.Header().Get("Location") != "/" || sessionCookie(got) == nil {
+ t.Fatalf("missing/hostile cookie continuation=%q", got.Header().Get("Location"))
+ }
+ }
+}
+
+func TestCredentialReturnCookieGrantsNothing(t *testing.T) {
+ app := newTestApp(t)
+ c := intentFrom(returnRequest(app, "GET", "/signin?return_to=/passkey/codes", nil))
+ if c == nil || !c.Secure || !c.HttpOnly || c.Domain != "" || c.Path != "/" || c.SameSite != http.SameSiteLaxMode || c.MaxAge != 600 {
+ t.Fatalf("invalid intent cookie attributes: %+v", c)
+ }
+ if got := returnRequest(app, "GET", "/passkey/codes", nil, c); got.Code == 200 {
+ t.Fatal("intent granted access without session")
+ }
+ signIn(t, app, "person@example.invalid")
+ got := returnRequest(app, "POST", linkPath, url.Values{"token": {capturedLink(t, app)}}, c)
+ if gone := intentFrom(got); gone == nil || gone.MaxAge >= 0 {
+ t.Fatal("successful completion did not clear intent")
+ }
+}
+
+// Mutation-checked 2026-09-07: replacing AuthTime with time.Now grants
+// access after an old gesture and fails this freshness check.
+func TestCredentialReturnDoesNotRefreshOldKeymailGesture(t *testing.T) {
+ app := newTestApp(t)
+ stub := newKeymailStub(t, app, "person@keymail.invalid")
+ stub.authTime = time.Now().Add(-24 * time.Hour)
+ intent := intentFrom(returnRequest(app, "GET", "/signin?return_to=/passkey/codes", nil))
+ pending, state := beginKeymail(t, app, "person@keymail.invalid")
+ got := callback(t, app, "code-1", state, pending, intent)
+ if sessionCookie(got) == nil || got.Header().Get("Location") != "/passkey/codes" {
+ t.Fatal("verified sign-in lost navigation")
+ }
+ if dest := returnRequest(app, "GET", "/passkey/codes", nil, sessionCookie(got)); dest.Code == 200 {
+ t.Fatal("navigation intent refreshed stale credential authentication")
+ }
+}
diff --git a/internal/home/signin_test.go b/internal/home/signin_test.go
index 3a0d078..e21409c 100644
--- a/internal/home/signin_test.go
+++ b/internal/home/signin_test.go
@@ -27,6 +27,7 @@ func signIn(t *testing.T, app *testApp, addr string) *httptest.ResponseRecorder
t.Helper()
form := url.Values{"address": {addr}}
req := httptest.NewRequest(http.MethodPost, "/signin", strings.NewReader(form.Encode()))
+ req.Host = credentialHost
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Origin", Origin)
w := httptest.NewRecorder()
@@ -65,6 +66,7 @@ func redeem(t *testing.T, app *testApp, token string) *httptest.ResponseRecorder
t.Helper()
form := url.Values{"token": {token}}
req := httptest.NewRequest(http.MethodPost, linkPath, strings.NewReader(form.Encode()))
+ req.Host = credentialHost
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Origin", Origin)
w := httptest.NewRecorder()
@@ -164,6 +166,7 @@ func TestGetDoesNotRedeemTheLink(t *testing.T) {
token := capturedLink(t, app)
req := httptest.NewRequest(http.MethodGet, linkPath+"?token="+url.QueryEscape(token), nil)
+ req.Host = credentialHost
w := httptest.NewRecorder()
app.Router().ServeHTTP(w, req)
@@ -197,6 +200,7 @@ func TestVerifyPageDoesNotDistinguishTokens(t *testing.T) {
get := func(token string) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodGet, linkPath+"?token="+url.QueryEscape(token), nil)
+ req.Host = credentialHost
w := httptest.NewRecorder()
app.Router().ServeHTTP(w, req)
return w
@@ -331,6 +335,7 @@ func TestSigninPostRequiresOriginEvidence(t *testing.T) {
for _, path := range []string{"/signin", linkPath} {
form := url.Values{"address": {"person@example.invalid"}}
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(form.Encode()))
+ req.Host = credentialHost
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
app.Router().ServeHTTP(w, req)
@@ -350,6 +355,7 @@ func TestSigninPostRequiresOriginEvidence(t *testing.T) {
func TestClientIPNeverCollapsesToOneBucket(t *testing.T) {
req := func(remote, xff string) *http.Request {
r := httptest.NewRequest(http.MethodPost, "/signin", nil)
+ r.Host = credentialHost
r.RemoteAddr = remote
if xff != "" {
r.Header.Set("X-Forwarded-For", xff)
@@ -396,6 +402,7 @@ func TestCallbackWithNoPendingCookieMintsNothing(t *testing.T) {
"",
} {
req := httptest.NewRequest(http.MethodGet, callbackPath+q, nil)
+ req.Host = credentialHost
w := httptest.NewRecorder()
app.Router().ServeHTTP(w, req)
@@ -431,6 +438,7 @@ func TestForgedPendingBlobIsRefused(t *testing.T) {
app := newTestApp(t)
req := httptest.NewRequest(http.MethodGet, callbackPath+"?code=x&state=s", nil)
+ req.Host = credentialHost
req.AddCookie(&http.Cookie{
Name: pendingCookie,
// Well-formed shape — payload.mac — with a MAC that is not one.
@@ -461,6 +469,7 @@ func TestSigninIsRateLimitedPerIPNotGlobally(t *testing.T) {
post := func(ip, addr string) int {
form := url.Values{"address": {addr}}
req := httptest.NewRequest(http.MethodPost, "/signin", strings.NewReader(form.Encode()))
+ req.Host = credentialHost
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Origin", Origin)
req.Header.Set("X-Forwarded-For", ip)
@@ -499,6 +508,7 @@ func TestSigninIsRateLimitedPerAddress(t *testing.T) {
for i := range 12 {
form := url.Values{"address": {"target@example.invalid"}}
req := httptest.NewRequest(http.MethodPost, "/signin", strings.NewReader(form.Encode()))
+ req.Host = credentialHost
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Origin", Origin)
req.Header.Set("X-Forwarded-For", fmt.Sprintf("198.51.100.%d", i+1))
@@ -527,6 +537,7 @@ func TestVerifyPageWritesNothing(t *testing.T) {
before := dumpDatabase(t, app)
for _, tok := range []string{token, "not-a-real-token", ""} {
req := httptest.NewRequest(http.MethodGet, linkPath+"?token="+url.QueryEscape(tok), nil)
+ req.Host = credentialHost
app.Router().ServeHTTP(httptest.NewRecorder(), req)
}
if after := dumpDatabase(t, app); after != before {
diff --git a/internal/home/static/theme.css b/internal/home/static/theme.css
deleted file mode 100644
index c3ab91f..0000000
--- a/internal/home/static/theme.css
+++ /dev/null
@@ -1,174 +0,0 @@
-/* day — rastrillo's default theme: an everyday blue on white and grey.
- Structure lives in tokens.css; this file is colour, type and shape.
- rastrillo new writes it once as static/theme.css.
-
- The look: the ordinary, personable interface. A white page, a plain
- grey text scale, one friendly blue that means "this does something",
- and the conventional green/amber/red for status. Nothing here is
- trying to be memorable — day is the theme an app ships on while it
- works out what it wants to look like, and the one that never gets in
- the way of the content. Semantic status colours are functional and
- never the accent, so a pill can never be mistaken for a link.
-
- ── One block, both schemes ───────────────────────────────────────────
- Every token is declared once, as light-dark(<light>, <dark>), under a
- single :root that sets color-scheme: light dark. The browser picks the
- half that matches the resolved scheme, so there is no second copy of
- the palette to keep in sync and no way to author dark half-done. The
- two rules at the bottom are the whole toggle: setting color-scheme on
- :root[data-theme=…] re-resolves every light-dark() in the file at
- once, in both directions, which is why an explicit choice beats the OS
- without restating a single colour.
-
- Tokens with one value in both schemes — the font stack, the radii —
- are written plain, because light-dark(x, x) says nothing twice.
-
- light-dark() is a colour function: it may only stand where a <color>
- is expected. So a shadow token wraps its colour in it and writes the
- geometry once, outside the call — `0 8px 24px light-dark(a, b)`, never
- `light-dark(0 8px 24px a, …)`, which no browser would accept as a
- box-shadow.
-
- ── The shape axis ────────────────────────────────────────────────────
- Radius and shadow are the theme's now, not tokens.css's: a theme that
- can only change colour cannot change how an app feels. day is soft and
- unremarkable — 8px cards, 6px controls, shadows with a wide blur and
- little alpha, the depth of paper resting on paper.
-
- ── WCAG 2.2 AA, measured ─────────────────────────────────────────────
- Normal text needs 4.5:1; large text and UI component boundaries need
- 3:1. Every pair below was computed from these exact hex values, in
- both schemes. If you change a token, recompute its rows.
-
- LIGHT ratio requirement
- --rst-text on --rst-surface 16.91 4.5:1 text
- --rst-text on --rst-bg 16.91 4.5:1 text
- --rst-text on --rst-surface-2 15.77 4.5:1 text
- --rst-text on --rst-accent-soft 15.21 4.5:1 text
- --rst-text-muted on --rst-surface 7.56 4.5:1 text
- --rst-text-muted on --rst-bg 7.56 4.5:1 text
- --rst-text-muted on --rst-surface-2 7.05 4.5:1 text
- --rst-text-muted on --rst-accent-soft 6.80 4.5:1 text
- --rst-text-faint on --rst-surface 5.29 4.5:1 text
- --rst-text-faint on --rst-bg 5.29 4.5:1 text
- --rst-text-faint on --rst-surface-2 4.93 4.5:1 text
- --rst-text-faint on --rst-accent-soft 4.76 4.5:1 text
- --rst-accent on --rst-surface 5.28 4.5:1 text + focus ring
- --rst-accent on --rst-bg 5.28 4.5:1 text + focus ring
- --rst-accent on --rst-surface-2 4.93 4.5:1 text
- --rst-accent on --rst-accent-soft 4.75 4.5:1 text
- --rst-on-accent on --rst-accent 5.28 4.5:1 button label
- --rst-on-accent on --rst-accent-strong 7.18 4.5:1 button label, hover
- --rst-line-strong on --rst-surface 3.63 3:1 control border
- --rst-line-strong on --rst-bg 3.63 3:1 control border
- --rst-line-strong on --rst-surface-2 3.39 3:1 control border
- pill neutral fg on its own tint 9.02 4.5:1 text
- pill positive fg on its own tint 6.49 4.5:1 text
- pill warning fg on its own tint 6.15 4.5:1 text
- pill negative fg on its own tint 5.30 4.5:1 text
- --rst-on-accent on negative fg (fill) 6.47 4.5:1 danger button label
-
- DARK ratio requirement
- --rst-text on --rst-surface 13.74 4.5:1 text
- --rst-text on --rst-bg 15.32 4.5:1 text
- --rst-text on --rst-surface-2 14.63 4.5:1 text
- --rst-text on --rst-accent-soft 13.02 4.5:1 text
- --rst-text-muted on --rst-surface 7.56 4.5:1 text
- --rst-text-muted on --rst-bg 8.43 4.5:1 text
- --rst-text-muted on --rst-surface-2 8.05 4.5:1 text
- --rst-text-muted on --rst-accent-soft 7.16 4.5:1 text
- --rst-text-faint on --rst-surface 6.33 4.5:1 text
- --rst-text-faint on --rst-bg 7.06 4.5:1 text
- --rst-text-faint on --rst-surface-2 6.74 4.5:1 text
- --rst-text-faint on --rst-accent-soft 6.00 4.5:1 text
- --rst-accent on --rst-surface 6.95 4.5:1 text + focus ring
- --rst-accent on --rst-bg 7.75 4.5:1 text + focus ring
- --rst-accent on --rst-surface-2 7.40 4.5:1 text
- --rst-accent on --rst-accent-soft 6.59 4.5:1 text
- --rst-on-accent on --rst-accent 7.86 4.5:1 button label
- --rst-on-accent on --rst-accent-strong 10.29 4.5:1 button label, hover
- --rst-line-strong on --rst-surface 4.26 3:1 control border
- --rst-line-strong on --rst-bg 4.75 3:1 control border
- --rst-line-strong on --rst-surface-2 4.53 3:1 control border
- pill neutral fg on its own tint 8.44 4.5:1 text
- pill positive fg on its own tint 10.18 4.5:1 text
- pill warning fg on its own tint 9.62 4.5:1 text
- pill negative fg on its own tint 7.85 4.5:1 text
- --rst-on-accent on negative fg (fill) 8.99 4.5:1 danger button label
-
- The tightest pair in the set is --rst-accent on --rst-accent-soft in
- light, at 4.75:1 against a 4.5:1 floor. Put a token on a surface not
- listed above and you are guessing; recompute first.
-
- --rst-line is a hairline divider between rows, not a control border or
- a meaningful graphic, so 1.4.11's 3:1 does not apply to it. Control
- borders use --rst-line-strong, which does clear 3:1.
-
- Pagination chips are the one deliberate exception: they are bordered
- with --rst-line rather than --rst-line-strong, because a page strip
- ringed in hard outlines shouts louder than the list it belongs to.
- 1.4.11 is satisfied without that border — each chip's meaning is
- carried by its text label at 4.5:1 or better, and the current page
- adds a filled background, a heavier weight and aria-current on top.
-
- --rst-shadow-pop, --rst-shadow-knob, --rst-shadow-lift and
- --rst-overlay are not in the table: they paint a translucent shadow or
- scrim over whatever sits underneath, not text over a fixed
- background, so a contrast ratio does not apply. The scrim deepens in
- dark, matching --rst-shadow-pop's own jump: black reads lighter
- against a dark backdrop, so the overlay needs more alpha to still
- read as a dimming layer.
- ──────────────────────────────────────────────────────────────────── */
-
-:root {
- color-scheme: light dark;
-
- /* Type: the family only. Sizes and spacing are structural and stay in
- tokens.css. */
- --rst-font: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
-
- /* Shape: soft, ordinary, unremarkable. */
- --rst-radius: 8px;
- --rst-radius-sm: 6px;
- --rst-radius-pill: 999px;
-
- /* Surfaces and lines */
- --rst-bg: light-dark(#ffffff, #111418);
- --rst-surface: light-dark(#ffffff, #1a1f26);
- --rst-surface-2: light-dark(#f6f7f8, #15191f);
- --rst-line: light-dark(#e5e7eb, #2a3038);
- --rst-line-strong: light-dark(#7f8792, #79828e);
-
- /* Text */
- --rst-text: light-dark(#1a1d21, #e8eaed);
- --rst-text-muted: light-dark(#4b5563, #a8b0ba);
- --rst-text-faint: light-dark(#656c78, #98a1ac);
-
- /* Accent */
- --rst-accent: light-dark(#2464e0, #7ba7ff);
- --rst-accent-strong: light-dark(#1a4fc0, #a3c0ff);
- --rst-accent-soft: light-dark(#eef3fe, #17233a);
- --rst-on-accent: light-dark(#ffffff, #0b1220);
-
- /* Status tones */
- --rst-tone-neutral-fg: light-dark(#374151, #c3c9d1);
- --rst-tone-neutral-bg: light-dark(#eef0f2, #262c34);
- --rst-tone-positive-fg: light-dark(#166534, #6ee7a8);
- --rst-tone-positive-bg: light-dark(#dcfce7, #10281c);
- --rst-tone-warning-fg: light-dark(#854d0e, #f0c46a);
- --rst-tone-warning-bg: light-dark(#fef3c7, #2c2110);
- --rst-tone-negative-fg: light-dark(#b91c1c, #f79aa0);
- --rst-tone-negative-bg: light-dark(#fee2e2, #331819);
-
- /* Depth */
- --rst-shadow-pop: 0 8px 24px light-dark(rgba(0, 0, 0, 0.12), rgba(0, 0, 0, 0.5));
- --rst-shadow-knob: 0 1px 2px rgba(0, 0, 0, 0.3);
- --rst-shadow-lift: 0 1px 2px rgba(0, 0, 0, 0.08);
- --rst-overlay: light-dark(rgba(0, 0, 0, 0.45), rgba(0, 0, 0, 0.6));
-}
-
-/* The whole toggle. Setting color-scheme re-resolves every light-dark()
- above, so an explicit choice beats the OS in both directions without
- restating a colour. */
-:root[data-theme="light"] { color-scheme: light; }
-:root[data-theme="dark"] { color-scheme: dark; }
diff --git a/internal/home/static/tokens.css b/internal/home/static/tokens.css
index e855ec7..16a8809 100644
--- a/internal/home/static/tokens.css
+++ b/internal/home/static/tokens.css
@@ -281,7 +281,7 @@ body {
/* ── page-header ──────────────────────────────────────────────────── */
.rst-page-header, [rst-page-header] {
align-items: baseline;
- border-bottom: 1px solid var(--rst-line);
+ border-bottom: 1px solid var(--rst-header-rule);
display: flex;
flex-wrap: wrap;
gap: var(--rst-sp-3);
@@ -290,18 +290,33 @@ body {
padding-bottom: var(--rst-sp-3);
position: relative;
}
-/* The rake line: one short accent stroke laid over the header's rule.
- The library's one flourish — the tool it is named for, drawn once per
- screen and nowhere else. Absolutely positioned, so it is not a flex
- item. */
+/* The rake line is retired (design doc §6-v2.2). It drew a 2.5rem
+ accent stroke over this rule, flush to the inline start — the
+ library's one flourish, and, built from the same parts in the same
+ order, indistinguishable from a determinate progress bar at 12%. The
+ rule itself carries the theme now: --rst-header-rule is derived from
+ the theme's accent and line, so plain draws grey, day draws a barely
+ tinted hairline and signal a visibly tinted one, and an app that
+ wants its own sets one custom property instead of fighting an
+ ::after it cannot remove.
+
+ The declaration stays, rather than the rule being deleted, because a
+ stylesheet that merely stops mentioning the stroke says nothing to
+ the next reader; this says the stroke is gone on purpose. It also
+ neutralises a vendored theme or app sheet that still carries the old
+ flourish at a lower specificity.
+
+ THE RULE IS DECORATIVE AND CARRIES NO CONTRAST FLOOR. The heading's
+ size, weight and spacing carry the structure; the rule does not. It
+ is deliberately absent from ui/contrast_test.go's pair table, and a
+ later pass must not argue it up to 1.4.11's 3:1 — that floor was
+ never its. See ui/contrast_test.go's colorMixSkip comment.
+
+ position: relative stays: it is the containing block anything an app
+ absolutely positions inside its own header expects to find, and
+ removing it would move those elements rather than tidy the file. */
.rst-page-header::after, [rst-page-header]::after {
- background: var(--rst-accent);
- block-size: 2px;
- content: "";
- inline-size: 2.5rem;
- inset-block-end: -1px;
- inset-inline-start: 0;
- position: absolute;
+ content: none;
}
.rst-page-header__titles, [rst-page-header-titles] {
flex: 1 1 auto;
@@ -1492,6 +1507,105 @@ dialog.rst-modal-panel, dialog[rst-modal-panel] { block-size: auto; color: inher
}
}
+
+/* console shell — a brand-and-account bar across the top AND a
+ navigation rail down the side. topbar without the rail is topbar;
+ sidebar without the bar is sidebar; this is the admin shape both of
+ them were half of, and the one an app used to hand-write.
+
+ TWO CHROMES, ONE CONTROL. Below 800px the bar's tail and the rail
+ both have to go away, and two disclosures would be two things to
+ learn. So one <details rst-shell-menu> gates both: its own next
+ sibling with +, and the rail from the shell root with :has(). One
+ [open], two reveals, one summary on screen at 390px.
+
+ Both reveals are written as HIDE-WHEN-CLOSED rather than
+ show-when-open. In an engine without :has() the rail's hide rule
+ never matches and the rail renders as a column of links under the
+ bar — longer page, navigation intact. Spelled the other way round,
+ the same missing selector is a phone that cannot navigate. Choose
+ the failure. */
+.rst-shell-console .rst-shell__bar, [rst-shell-console] [rst-shell-bar] { align-items: center; background: var(--rst-surface); border-block-end: 1px solid var(--rst-line); display: flex; flex-wrap: wrap; gap: var(--rst-sp-4); padding-block: 0.5rem; padding-inline: var(--rst-sp-4); }
+.rst-shell-console .rst-shell__menu, [rst-shell-console] [rst-shell-menu] { margin-inline-start: auto; }
+.rst-shell-console .rst-shell__menu > summary, [rst-shell-console] [rst-shell-menu] > summary { align-items: center; border: 1px solid var(--rst-line); border-radius: var(--rst-radius-sm); cursor: pointer; display: flex; font-size: var(--rst-fs-sm); font-weight: 550; gap: 0.35rem; list-style: none; min-block-size: 24px; padding-block: 0.3rem; padding-inline: 0.55rem; }
+.rst-shell-console .rst-shell__menu > summary::-webkit-details-marker, [rst-shell-console] [rst-shell-menu] > summary::-webkit-details-marker { display: none; }
+/* The rail, narrow: a full-width column under the bar, disclosed. It
+ holds navigation and nothing else — the language switcher and the
+ person are on the bar — so there is no rail-foot here and nothing
+ pinned to a bottom edge. box-sizing all the same, because the wide
+ query below sizes something in this subtree against the viewport and
+ a viewport promise has to be kept by the border box. */
+.rst-shell-console > .rst-shell__rail, [rst-shell-console] > [rst-shell-rail] { background: var(--rst-surface); border-block-end: 1px solid var(--rst-line); box-sizing: border-box; display: flex; flex-direction: column; gap: var(--rst-sp-3); padding: var(--rst-sp-4); }
+.rst-shell-console .rst-shell__nav, [rst-shell-console] [rst-shell-nav] { flex-direction: column; gap: 2px; }
+.rst-shell-console .rst-shell__nav a, [rst-shell-console] [rst-shell-nav] a { border-block-end: 0; border-radius: var(--rst-radius-sm); display: block; padding-block: 0.35rem; padding-inline: 0.55rem; }
+.rst-shell-console .rst-shell__nav a:hover, [rst-shell-console] [rst-shell-nav] a:hover { background: var(--rst-accent-soft); color: var(--rst-text); }
+.rst-shell-console .rst-shell__nav a[aria-current], [rst-shell-console] [rst-shell-nav] a[aria-current] { background: var(--rst-accent-soft); color: var(--rst-accent); font-weight: 600; }
+/* The two hide rules. Same [open], two combinators. */
+.rst-shell-console .rst-shell__menu:not([open]) + .rst-shell__tail, [rst-shell-console] [rst-shell-menu]:not([open]) + [rst-shell-tail] { display: none; }
+.rst-shell-console:has(.rst-shell__menu:not([open])) > .rst-shell__rail, [rst-shell-console]:has([rst-shell-menu]:not([open])) > [rst-shell-rail] { display: none; }
+/* Disclosed, the tail is a column across the full width of the wrapped
+ bar. The account's margin-inline-start: auto has to be undone by
+ hand and stretch does not do it: in a column flex box the inline
+ axis is the CROSS axis, and an item with an auto cross-axis margin
+ is exempt from stretching — the same trap the topbar documents. */
+.rst-shell-console .rst-shell__menu[open] + .rst-shell__tail, [rst-shell-console] [rst-shell-menu][open] + [rst-shell-tail] { display: flex; flex-basis: 100%; flex-direction: column; gap: var(--rst-sp-3); }
+.rst-shell-console .rst-shell__tail > .rst-shell__account, [rst-shell-console] [rst-shell-tail] > [rst-shell-account] { margin-inline-start: 0; }
+@media (min-width: 800px) {
+ /* The frame: bar across the top, rail beside the page, footer under
+ the page and not under the rail. Named areas rather than DOM
+ order, because the DOM order is the reading order and must not be
+ spent on layout — and areas map to the block and inline axes, so
+ an RTL locale gets the rail on the other side with no second rule.
+ min-block-size, never block-size: the rail reaches the foot of a
+ short page, and a long one still scrolls the document. */
+ .rst-shell-console, [rst-shell-console] { display: grid; grid-template-areas: "bar bar" "rail main" "rail foot"; grid-template-columns: 15rem 1fr; grid-template-rows: auto 1fr auto; min-block-size: 100dvh; }
+ .rst-shell-console > .rst-shell__bar, [rst-shell-console] > [rst-shell-bar] { grid-area: bar; }
+ .rst-shell-console > .rst-shell__main, [rst-shell-console] > [rst-shell-main] { grid-area: main; }
+ .rst-shell-console > .rst-shell__foot, [rst-shell-console] > [rst-shell-foot] { grid-area: foot; }
+ .rst-shell-console .rst-shell__menu, [rst-shell-console] [rst-shell-menu] { display: none; }
+ /* Every undo below repeats the narrow rule's own selector, because a
+ media query adds no specificity: without the :has() spelling here
+ a reader who opened the menu and then widened the window would
+ keep a hidden rail. */
+ .rst-shell-console .rst-shell__tail,
+ [rst-shell-console] [rst-shell-tail],
+ .rst-shell-console .rst-shell__menu:not([open]) + .rst-shell__tail,
+ [rst-shell-console] [rst-shell-menu]:not([open]) + [rst-shell-tail],
+ .rst-shell-console .rst-shell__menu[open] + .rst-shell__tail,
+ [rst-shell-console] [rst-shell-menu][open] + [rst-shell-tail] { display: contents; }
+ .rst-shell-console .rst-shell__tail > .rst-shell__account, [rst-shell-console] [rst-shell-tail] > [rst-shell-account] { margin-inline-start: auto; }
+ .rst-shell-console > .rst-shell__rail, [rst-shell-console] > [rst-shell-rail] { border-block-end: 0; border-inline-end: 1px solid var(--rst-line); display: flex; grid-area: rail; }
+ /* The undo is a rule of ITS OWN, and this is the whole reason: a
+ selector list is invalid as a whole if any selector in it is
+ invalid, so an engine that cannot parse :has() drops every
+ declaration in the list — including grid-area: rail, which has
+ nothing to do with :has() and which this shell's wide layout is
+ built on. Co-listed, the four selectors above cost the legacy
+ engine its declared frame and left the rail in the right cell only
+ because grid auto-placement happened to put it there. Split, the
+ plain pair always applies and only the fight over `display` — the
+ one declaration the narrow hide rule contests — rides on :has().
+ That is the failure direction this shell chose, actually chosen
+ rather than nearly chosen. */
+ .rst-shell-console:has(.rst-shell__menu:not([open])) > .rst-shell__rail,
+ [rst-shell-console]:has([rst-shell-menu]:not([open])) > [rst-shell-rail] { display: flex; }
+ /* THE VIEWPORT. The rail itself is a grid item stretched to the row,
+ which can be taller than the window — that is what makes the
+ background reach the foot of a long page. So the thing that has to
+ fit the window is the nav, and it is the nav that is sized against
+ it: sticky, capped at the viewport MINUS the two gaps it is inset
+ by, measured as a border box, scrolling itself when an app's
+ navigation is longer than that.
+ This is the shipped regression, avoided by construction rather
+ than by care: block-size: 100dvh on a content box that also
+ carried padding gave a border box 32px taller than the window, and
+ the gate that missed it asked where the person sat inside the rail
+ — a question that stayed true while the rail hung off the screen.
+ TestTheConsoleRailFitsTheViewport asks the other question, and
+ carries a control page that must answer it the other way. */
+ .rst-shell-console > .rst-shell__rail > .rst-shell__nav, [rst-shell-console] > [rst-shell-rail] > [rst-shell-nav] { box-sizing: border-box; inset-block-start: var(--rst-sp-4); max-block-size: calc(100dvh - 2 * var(--rst-sp-4)); overflow-y: auto; position: sticky; }
+}
+
/* error page — one centred column, whatever shell it lands in: the
status as a quiet label above the sentence, one or two ways out, and
the reference last, where an operator asked to quote it will find it.
diff --git a/internal/home/templates/layout.html b/internal/home/templates/layout.html
index 1de11fa..9340be0 100644
--- a/internal/home/templates/layout.html
+++ b/internal/home/templates/layout.html
@@ -8,7 +8,7 @@
<link rel="stylesheet" href="/static/theme.css">
</head>
<body{{block "bodyattrs" .}}{{end}}>
-<main rst-stack="lg">
+<main rst-page>
{{template "content" .}}
</main>
{{block "scripts" .}}{{end}}
diff --git a/internal/home/templates/passkey_confirm.html b/internal/home/templates/passkey_confirm.html
index 6976287..e6de3f2 100644
--- a/internal/home/templates/passkey_confirm.html
+++ b/internal/home/templates/passkey_confirm.html
@@ -3,7 +3,7 @@
{{define "content"}}
<h1>Confirm it is you</h1>
-{{if .RecoveryFailed}}<p role="alert" rst-note="danger">That code did not work.</p>{{end}}
+{{if .RecoveryFailed}}<p role="alert" rst-callout rst-tone="negative"><span rst-callout-body>That code did not work.</span></p>{{end}}
<button id="passkey-confirm" type="button" rst-btn="primary" data-passkey hidden>Use your passkey</button>
<p id="passkey-status" role="status"></p>
@@ -11,10 +11,14 @@
<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" rst-stack="sm">
- <label for="code">Recovery code</label>
+<form method="post" action="/passkey/signin/recovery" rst-form>
+ <div rst-field>
+ <label rst-field-label for="code">Recovery code</label>
<input id="code" name="code" type="text" autocomplete="one-time-code" required rst-input>
+ </div>
+ <div rst-form-foot>
<button type="submit" rst-btn="primary">Sign in</button>
+ </div>
</form>
{{end}}
{{define "scripts"}}<script type="module" src="/static/passkey.js"></script>{{end}}
diff --git a/internal/home/templates/passkey_enrol.html b/internal/home/templates/passkey_enrol.html
index b8be72d..a2edcb5 100644
--- a/internal/home/templates/passkey_enrol.html
+++ b/internal/home/templates/passkey_enrol.html
@@ -6,6 +6,6 @@
the only thing standing between someone with your inbox and your account.</p>
<button id="passkey-enrol" type="button" rst-btn="primary" data-passkey hidden>Add a passkey</button>
<p id="passkey-status" role="status"></p>
-<noscript><p rst-note>Adding a passkey needs JavaScript. Everything else here does not.</p></noscript>
+<noscript><p rst-callout><span rst-callout-body>Adding a passkey needs JavaScript. Everything else here does not.</span></p></noscript>
{{end}}
{{define "scripts"}}<script type="module" src="/static/passkey.js"></script>{{end}}
diff --git a/internal/home/templates/recovery_codes.html b/internal/home/templates/recovery_codes.html
index a8c43be..d88ca24 100644
--- a/internal/home/templates/recovery_codes.html
+++ b/internal/home/templates/recovery_codes.html
@@ -2,8 +2,8 @@
{{define "content"}}
<h1>Recovery codes</h1>
{{if .Codes}}
-<p rst-note="warning"><strong>These are shown once.</strong> Keep them somewhere your
-passkey is not. Each one works a single time.</p>
+<p rst-callout rst-tone="warning"><span rst-callout-body><strong>These are shown once.</strong> Keep them somewhere your
+passkey is not. Each one works a single time.</span></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>
diff --git a/internal/home/templates/signin.html b/internal/home/templates/signin.html
index 3abf4a4..94b0e20 100644
--- a/internal/home/templates/signin.html
+++ b/internal/home/templates/signin.html
@@ -1,10 +1,14 @@
{{define "title"}}Sign in · Oficina{{end}}
{{define "content"}}
<h1>Sign in</h1>
-{{if .Error}}<p role="alert" rst-note="danger">{{.Error}}</p>{{end}}
-<form method="post" action="/signin" rst-stack="sm">
- <label for="address">Email address</label>
+{{if .Error}}<p role="alert" rst-callout rst-tone="negative"><span rst-callout-body>{{.Error}}</span></p>{{end}}
+<form method="post" action="/signin" rst-form>
+ <div rst-field>
+ <label rst-field-label for="address">Email address</label>
<input id="address" name="address" type="email" autocomplete="email" required autofocus rst-input>
+ </div>
+ <div rst-form-foot>
<button type="submit" rst-btn="primary">Continue</button>
+ </div>
</form>
{{end}}
diff --git a/internal/home/templates/signin_confirm.html b/internal/home/templates/signin_confirm.html
index 685fba2..b27fe86 100644
--- a/internal/home/templates/signin_confirm.html
+++ b/internal/home/templates/signin_confirm.html
@@ -2,8 +2,10 @@
{{define "content"}}
<h1>Finish signing in</h1>
<p>Press the button to sign in on this device.</p>
-<form method="post" action="/signin/verify" rst-stack="sm">
+<form method="post" action="/signin/verify" rst-form>
<input type="hidden" name="token" value="{{.Token}}">
+ <div rst-form-foot>
<button type="submit" rst-btn="primary">Sign in</button>
+ </div>
</form>
{{end}}
diff --git a/internal/home/testapp_test.go b/internal/home/testapp_test.go
index 89b0cf1..a36a848 100644
--- a/internal/home/testapp_test.go
+++ b/internal/home/testapp_test.go
@@ -97,6 +97,11 @@ type testApp struct {
// long-lived connection, and a hook registered after that misses the
// only connection that ever deletes anything.
func newTestApp(t *testing.T) *testApp {
+ t.Helper()
+ return newTestAppWithOrigins(t, Origins{Origin, directoryOrigin})
+}
+
+func newTestAppWithOrigins(t *testing.T, origins Origins) *testApp {
t.Helper()
secrets, mailer := testSecrets(t)
@@ -113,7 +118,7 @@ func newTestApp(t *testing.T) *testApp {
t.Fatalf("migrate.Apply: %v", err)
}
- app, err := New(secrets, d, quietLogger())
+ app, err := NewWithOrigins(secrets, d, quietLogger(), origins)
if err != nil {
t.Fatalf("New: %v", err)
}