Sign-in: continue to Keymail instead of redirecting a form POST there
Signing in with a Keymail address could not leave the sign-in page.
Begin answers such an address with 303 to the Keymail server's
/oauth/authorize, and memoria's CSP says form-action 'self' — which
browsers enforce across a form submission's WHOLE redirect chain. Safari
refused the redirect and the button span forever (memoria.oficina.works,
2026-09-29, reported by Paul with the console error). The rastrillo docs
describe exactly this refusal.
The suite already has the fix: home/connect's OAuthContinuation,
extracted from Docs and used by Sheets. It replaces exactly that 303 —
after checking it is a well-formed authorize URL for this origin and
callback, with PKCE S256 — with a no-store, no-referrer 200 page whose
meta refresh (and link) is a navigation rather than a form submission,
so form-action does not apply. Every other response passes through
untouched. memoria had simply never wired it. The CSP is not widened:
that would have to name every Keymail server.
signinBegin composes the admission guard and the continuation, with its
own tests, so MountAuth holds nothing worth getting wrong. The page
renders inside memoria's layout, footer and AI state included.
Tested through rastrillo's REAL Begin and the real mounted route, with
only the network faked — an NXDOMAIN resolver, so the classifier takes
the domain itself as the Keymail candidate, and a fake Keymail answering
/.well-known/keymail and /api/federation/lookup. The test asserts the
classifier reached the Keymail probes before asserting anything else.
Restoring the old wiring fails it with the exact production symptom
("still redirects", Location https://…/oauth/authorize). Removing the
guard fails it and gate 33. Swapping the guard and the continuation
stays green, correctly: the continuation rewrites only an external 303
and the guard only answers with a same-origin one, so the orders are
identical — the comment now says the order is a preference, not a guard.
Docs, Calendar and Calls do not wire OAuthContinuation either, and very
likely have the same bug. Not touched here.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
5 files changed,
+278
−3
internal/memoria/app.go+3 −2internal/memoria/auth.go+43 −0internal/memoria/render.go+2 −1internal/memoria/templates/signin_oauth.html+10 −0internal/memoriatest/signin_keymail_test.go+220 −0
diff --git a/internal/memoria/app.go b/internal/memoria/app.go| index 30cad65..150b625 100644 |
| --- a/internal/memoria/app.go |
| +++ b/internal/memoria/app.go |
| @@ -176,8 +176,9 @@ func (a *app) MountAuth(mux *http.ServeMux, origin string) { |
| r := chi.NewRouter() |
| r.Use(a.homeMiddleware(origin)) |
| // Guarded, not bare: the classifier inside Begin dials the submitted |
| - // address's domain before Authorize runs. beginGuard says why. |
| - r.Post("/auth/begin", a.beginGuard(origin, a.auth.Begin)) |
| + // address's domain before Authorize runs. beginGuard says why. And |
| + // continued, not redirected: signinBegin says why. |
| + r.Post("/auth/begin", a.signinBegin(origin, a.auth.Begin)) |
| if a.home == nil { |
| r.Get("/auth/callback", a.auth.Callback) |
| r.Get("/auth/verify", a.auth.Verify) |
diff --git a/internal/memoria/auth.go b/internal/memoria/auth.go| index a9a6b41..d5de3e1 100644 |
| --- a/internal/memoria/auth.go |
| +++ b/internal/memoria/auth.go |
| @@ -7,6 +7,7 @@ import ( |
| "net/http" |
| "strings" |
| + "amadan.net/oficina/home/connect" |
| "github.com/carlosframework/rastrillo/auth" |
| "github.com/carlosframework/rastrillo/mail" |
| ) |
| @@ -211,3 +212,45 @@ func NewAuth(sqlDB *sql.DB, env AuthEnv, logger *slog.Logger, authorize func(str |
| } |
| return a, nil |
| } |
| + |
| +// signinBegin is POST /auth/begin's whole handler chain, in one place with |
| +// its own tests rather than composed inline in MountAuth. |
| +// |
| +// **The guard is what keeps a non-admitted address from being dialled** |
| +// (gate 33), and it runs before Begin either way. It is placed outermost |
| +// by preference, so a refused address never reaches the continuation code |
| +// at all — but the two orders are behaviourally identical, and a review of |
| +// the mutation that swaps them found nothing to tell them apart: the |
| +// continuation rewrites only an EXTERNAL 303, and the guard only ever |
| +// answers with a same-origin one. The order is a preference, not a guard. |
| +// |
| +// **Inside it, Begin is wrapped in Home's OAuthContinuation.** For a |
| +// Keymail address Begin answers 303 to the Keymail server's |
| +// /oauth/authorize — and CSP's form-action 'self' is enforced across a |
| +// form submission's WHOLE redirect chain, so the browser refused it and |
| +// sign-in with a Keymail address could not leave the sign-in page (seen |
| +// in Safari, 2026-09-29; the rastrillo docs describe the same refusal). |
| +// OAuthContinuation replaces exactly that 303 — after checking it is a |
| +// well-formed authorize URL for THIS origin and callback, with PKCE S256 — |
| +// with a no-store, no-referrer continuation page whose navigation is not a |
| +// form submission. Every other response, including the magic-link path's |
| +// same-origin redirect, passes through untouched. The CSP is not widened: |
| +// that would have to name every Keymail server, and the framework notes |
| +// that a federated address on an unlisted host would still be refused. |
| +func (a *app) signinBegin(origin string, begin http.HandlerFunc) http.HandlerFunc { |
| + return a.beginGuard(origin, connect.OAuthContinuation(origin, begin, a.renderOAuth)) |
| +} |
| + |
| +// renderOAuth renders the continuation page inside memoria's layout, footer |
| +// and AI state included — it is a page like any other. |
| +func (a *app) renderOAuth(w http.ResponseWriter, r *http.Request, destination string) { |
| + render(w, "signin_oauth", oauthPage{ |
| + page: page{Chunks: a.chunkCount(), ModelOn: a.modelOn()}, |
| + Data: connect.OAuthPage(destination), |
| + }) |
| +} |
| + |
| +type oauthPage struct { |
| + page |
| + Data connect.OAuthData |
| +} |
diff --git a/internal/memoria/render.go b/internal/memoria/render.go| index fb5a43b..7b9d4f1 100644 |
| --- a/internal/memoria/render.go |
| +++ b/internal/memoria/render.go |
| @@ -52,13 +52,14 @@ func funcs() template.FuncMap { |
| } |
| func init() { |
| - for _, name := range []string{"index", "document", "signin", "connectors", "notion", "home", "credits"} { |
| + for _, name := range []string{"index", "document", "signin", "signin_oauth", "connectors", "notion", "home", "credits"} { |
| pages[name] = template.Must(template.New("layout"). |
| Funcs(funcs()). |
| ParseFS(appFS, "templates/layout.html", "templates/results.html", "templates/"+name+".html")) |
| } |
| pages["credits"] = template.Must(pages["credits"].ParseFS(credits.Templates(), "templates/credits.html")) |
| pages["home"] = template.Must(pages["home"].ParseFS(connect.Templates(), "templates/home_link.html")) |
| + pages["signin_oauth"] = template.Must(pages["signin_oauth"].ParseFS(connect.Templates(), "templates/oauth.html")) |
| fragments = template.Must(template.New("fragments"). |
| Funcs(funcs()). |
| ParseFS(appFS, "templates/results.html")) |
diff --git a/internal/memoria/templates/signin_oauth.html b/internal/memoria/templates/signin_oauth.html| new file mode 100644 |
| index 0000000..7823ba5 |
| --- /dev/null |
| +++ b/internal/memoria/templates/signin_oauth.html |
| @@ -0,0 +1,10 @@ |
| +{{/* The Keymail continuation page (home/connect OAuthContinuation). The |
| + sign-in POST ends here, with a 200, instead of a 303 to Keymail: CSP's |
| + form-action 'self' applies to a form submission's WHOLE redirect chain, |
| + so the 303 was refused and sign-in with a Keymail address could not |
| + leave the page. This page's meta refresh — and its link, for anyone |
| + without it — is a navigation, not a form submission, so form-action |
| + does not apply. The markup is Home's shared template (Estilo |
| + homeconnect), rendered inside memoria's own layout as Sheets does. */}} |
| +{{define "head"}}{{template "home-oauth-head" .Data}}{{end}} |
| +{{define "content"}}{{template "home-oauth" .Data}}{{end}} |
diff --git a/internal/memoriatest/signin_keymail_test.go b/internal/memoriatest/signin_keymail_test.go| new file mode 100644 |
| index 0000000..658f96a |
| --- /dev/null |
| +++ b/internal/memoriatest/signin_keymail_test.go |
| @@ -0,0 +1,220 @@ |
| +package memoriatest |
| + |
| +import ( |
| + "context" |
| + "encoding/binary" |
| + "io" |
| + "log/slog" |
| + "net" |
| + "net/http" |
| + "net/http/httptest" |
| + "net/url" |
| + "path/filepath" |
| + "strings" |
| + stdsync "sync" |
| + "testing" |
| + |
| + "github.com/carlosframework/rastrillo/db" |
| + |
| + "amadan.net/oficina/memoria/internal/memoria" |
| +) |
| + |
| +// fakeKeymail answers the two probes signin's classifier makes of a |
| +// Keymail server — /.well-known/keymail and /api/federation/lookup — for |
| +// one host, and 404s everything else. It stands in for http.DefaultTransport, |
| +// which the classifier's client falls back to. |
| +type fakeKeymail struct { |
| + host string |
| + mu stdsync.Mutex |
| + hits []string |
| +} |
| + |
| +func (f *fakeKeymail) RoundTrip(r *http.Request) (*http.Response, error) { |
| + f.mu.Lock() |
| + f.hits = append(f.hits, r.URL.Host+r.URL.Path) |
| + f.mu.Unlock() |
| + body, code := `{}`, http.StatusNotFound |
| + if r.URL.Host == f.host { |
| + switch r.URL.Path { |
| + case "/.well-known/keymail": |
| + body, code = `{"version":"1"}`, http.StatusOK |
| + case "/api/federation/lookup": |
| + code = http.StatusOK |
| + } |
| + } |
| + return &http.Response{StatusCode: code, Body: io.NopCloser(strings.NewReader(body)), |
| + Header: http.Header{"Content-Type": {"application/json"}}, Request: r}, nil |
| +} |
| + |
| +// nxdomainResolver answers every DNS query NXDOMAIN — "this domain |
| +// publishes no _keymail delegation" — which signin's classifier reads as |
| +// "the domain itself is the Keymail candidate". Go's resolver speaks |
| +// length-prefixed DNS over any conn that is not a PacketConn, which a |
| +// net.Pipe is not. |
| +func nxdomainResolver() *net.Resolver { |
| + return &net.Resolver{PreferGo: true, Dial: func(context.Context, string, string) (net.Conn, error) { |
| + client, server := net.Pipe() |
| + go func() { |
| + defer server.Close() |
| + for { |
| + var n [2]byte |
| + if _, err := io.ReadFull(server, n[:]); err != nil { |
| + return |
| + } |
| + q := make([]byte, binary.BigEndian.Uint16(n[:])) |
| + if _, err := io.ReadFull(server, q); err != nil || len(q) < 12 { |
| + return |
| + } |
| + // Header plus the question, and nothing after it. |
| + i := 12 |
| + for i < len(q) && q[i] != 0 { |
| + i += int(q[i]) + 1 |
| + } |
| + i += 5 // the root label, QTYPE, QCLASS |
| + if i > len(q) { |
| + return |
| + } |
| + resp := append([]byte(nil), q[:i]...) |
| + rd := binary.BigEndian.Uint16(q[2:4]) & 0x0100 |
| + binary.BigEndian.PutUint16(resp[2:4], 0x8000|0x0400|rd|0x0080|3) // QR AA RD RA, NXDOMAIN |
| + binary.BigEndian.PutUint16(resp[4:6], 1) |
| + for _, off := range []int{6, 8, 10} { |
| + binary.BigEndian.PutUint16(resp[off:off+2], 0) |
| + } |
| + binary.BigEndian.PutUint16(n[:], uint16(len(resp))) |
| + if _, err := server.Write(append(n[:], resp...)); err != nil { |
| + return |
| + } |
| + } |
| + }() |
| + return client, nil |
| + }} |
| +} |
| + |
| +// keymailSigninApp is the real app with the real sign-in wired, whose |
| +// owner's domain resolves — through the fakes above — to a Keymail server. |
| +func keymailSigninApp(t *testing.T) (http.Handler, *fakeKeymail) { |
| + t.Helper() |
| + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) |
| + d, err := db.Open(filepath.Join(t.TempDir(), "app.db"), logger) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { d.Close() }) |
| + mux, a, err := memoria.App(d, testOrigin, testDEK(t), logger) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + au, err := memoria.NewAuth(d.Writer(), memoria.AuthEnv{ |
| + Origin: testOrigin, InstanceKey: "signin-keymail-instance-key", |
| + Owners: []string{dialTestOwner}, Mailer: &stubMailer{}, |
| + }, logger, a.Admit) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + a.UseAuth(au) |
| + a.SetOwners([]string{dialTestOwner}) |
| + a.MountAuth(mux, testOrigin) |
| + |
| + km := &fakeKeymail{host: "example.test"} |
| + prevT, prevR := http.DefaultTransport, net.DefaultResolver |
| + http.DefaultTransport, net.DefaultResolver = km, nxdomainResolver() |
| + t.Cleanup(func() { http.DefaultTransport, net.DefaultResolver = prevT, prevR }) |
| + return mux, km |
| +} |
| + |
| +// **Signing in with a Keymail address must be able to leave the page.** |
| +// |
| +// Begin answers a Keymail address with 303 to the server's /oauth/authorize, |
| +// and memoria's CSP says form-action 'self' — which browsers enforce across |
| +// a form submission's WHOLE redirect chain. Safari refused the redirect and |
| +// the sign-in button span forever (2026-09-29, memoria.oficina.works). The |
| +// POST now ends in a 200 continuation page whose navigation is not a form |
| +// submission. |
| +// |
| +// Through rastrillo's REAL Begin and the REAL mounted route: the classifier |
| +// is only faked at the network, so what is asserted is what production |
| +// sends. |
| +func TestAKeymailSigninCanLeaveThePage(t *testing.T) { |
| + h, km := keymailSigninApp(t) |
| + rr := beginSignin(t, h, dialTestOwner) |
| + |
| + // Presence: the classifier really took the Keymail path. Without this, |
| + // a 200 below could be the magic-link page and prove nothing. |
| + if !contains(km.hits, "example.test/api/federation/lookup") { |
| + t.Fatalf("the classifier never reached the Keymail probes (hits %v); this proves nothing", km.hits) |
| + } |
| + |
| + if rr.Code == http.StatusSeeOther { |
| + t.Fatalf("POST /auth/begin still redirects (Location %q): form-action 'self' refuses this in the browser", rr.Header().Get("Location")) |
| + } |
| + if rr.Code != http.StatusOK { |
| + t.Fatalf("status %d, want 200", rr.Code) |
| + } |
| + body := rr.Body.String() |
| + |
| + // The continuation navigates to the authorize URL Begin chose, for THIS |
| + // origin and callback. |
| + i := strings.Index(body, `http-equiv="refresh"`) |
| + if i < 0 { |
| + t.Fatalf("no meta refresh in the continuation page:\n%s", body) |
| + } |
| + for _, want := range []string{ |
| + "https://example.test/oauth/authorize", |
| + "client_id=" + url.QueryEscape(testOrigin), |
| + "redirect_uri=" + url.QueryEscape(testOrigin+"/auth/callback"), |
| + "code_challenge_method=S256", |
| + } { |
| + if !strings.Contains(strings.ReplaceAll(body, "&", "&"), want) { |
| + t.Errorf("continuation page lacks %q", want) |
| + } |
| + } |
| + if got := rr.Header().Get("Cache-Control"); got != "no-store" { |
| + t.Errorf("Cache-Control = %q, want no-store: the page carries a one-time PKCE state", got) |
| + } |
| + if got := rr.Header().Get("Referrer-Policy"); got != "no-referrer" { |
| + t.Errorf("Referrer-Policy = %q, want no-referrer", got) |
| + } |
| + // Begin's pending cookie must survive the wrapping, or the callback |
| + // cannot match the flow it started. |
| + if !strings.Contains(strings.Join(rr.Header().Values("Set-Cookie"), "\n"), "rastrillo_pending") { |
| + t.Errorf("the pending cookie did not survive: %q", rr.Header().Values("Set-Cookie")) |
| + } |
| + // Rendered as a memoria page, footer and all. |
| + if !strings.Contains(body, "<footer") { |
| + t.Error("the continuation page is not inside memoria's layout") |
| + } |
| +} |
| + |
| +// And the paths that must NOT change: a non-owner still gets the guard's |
| +// same-origin redirect with no dial (gate 33's ground), and an address with |
| +// no Keymail still gets the magic link's same-origin redirect, untouched. |
| +func TestSigninPathsThatDoNotLeaveAreUnchanged(t *testing.T) { |
| + h, km := keymailSigninApp(t) |
| + |
| + rr := beginSignin(t, h, "stranger@elsewhere.test") |
| + if rr.Code != http.StatusSeeOther || rr.Header().Get("Location") != "/signin?sent=1" { |
| + t.Errorf("non-owner: %d %q, want 303 /signin?sent=1", rr.Code, rr.Header().Get("Location")) |
| + } |
| + if len(km.hits) != 0 { |
| + t.Errorf("a non-owner's sign-in reached the network: %v", km.hits) |
| + } |
| + |
| + // An owner whose domain has no Keymail (the fake 404s other hosts). |
| + km.host = "nowhere.invalid" |
| + rr = beginSignin(t, h, dialTestOwner) |
| + if rr.Code != http.StatusSeeOther || !strings.HasPrefix(rr.Header().Get("Location"), "/") { |
| + t.Errorf("magic-link path: %d %q, want a same-origin 303", rr.Code, rr.Header().Get("Location")) |
| + } |
| +} |
| + |
| +func contains(xs []string, want string) bool { |
| + for _, x := range xs { |
| + if x == want { |
| + return true |
| + } |
| + } |
| + return false |
| +} |
| + |
| +var _ = httptest.NewRecorder |