oficina / home Public

Specify and reproduce lost credential-page continuation

Paul Campbell pushed by paul@keymail.dev efa03d959b130170b299c271cd36f2118d8df644 parent 651e5b3
2 files changed, +74 −0
  • docs/superpowers/specs/2026-09-08-credential-return.md +13 −0
  • internal/home/signin_return_test.go +61 −0
diff --git a/docs/superpowers/specs/2026-09-08-credential-return.md b/docs/superpowers/specs/2026-09-08-credential-return.md
new file mode 100644
index 0000000..bea4718
--- /dev/null
+++ b/docs/superpowers/specs/2026-09-08-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=/, lifetime ten minutes. 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/internal/home/signin_return_test.go b/internal/home/signin_return_test.go
new file mode 100644
index 0000000..f3e5715
--- /dev/null
+++ b/internal/home/signin_return_test.go
@@ -0,0 +1,61 @@
+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
+}
+
+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)
+ }
+ })
+ }
+}