Sign out ends the id.* session too
Sign out on the directory cleared only the directory (app list) session. The 30-day credential-origin session survived, so "Sign in" on the signed-out page, or any app's "Sign in with Home", went straight back through /authorize with no prompt (polish audit B2). The directory's POST /signout now ends its own session and answers 307 to a new POST /signout on the credential origin, which revokes the id.* session row, clears its cookie, and lands on the directory's /signed-out. 307 keeps the hop a POST, so it is never a linkable GET. The credential route sits outside csrf.Protect (the hop is same-site, not same-origin) with its own exact check: Origin is the directory, or the ordinary same-origin check for id.* passes, or, when the browser nulls Origin across the 307 (Chromium and WebKit both do, verified live), Referer is exactly the directory origin on a same-site request. Team instances and every other origin are refused. The directory CSP's form-action gains the credential origin, because form-action is enforced across a form's redirects. Instance sessions minted from earlier assertions are not revoked: they are the instance's own, and the home keeps no list of them (spec §deferred, "Suite-wide sign-out"). What this guarantees is that no app gets a new assertion for this browser without the email or passkey step.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
5 files changed,
+309
−2
internal/home/app.go+7 −0internal/home/directory.go+5 −2internal/home/security.go+6 −0internal/home/signout.go+91 −0internal/home/signout_test.go+200 −0
CI failed — run details
CI log
amadan: .amadan/timeout: this job's budget is 15m0s (this runner allows up to 30m0s) === vet === CGO_ENABLED=0 go vet ./... go: downloading amadan.net/carlos/platform.git/launchctx v0.0.0-20260911135504-fb577f96f7d1 go: downloading amadan.net/oficina/estilo.git v0.0.0-20260911182719-0bcb9287988d internal/home/credits.go:8:2: amadan.net/oficina/estilo.git@v0.0.0-20260911182719-0bcb9287988d: invalid version: git fetch -f --end-of-options origin refs/heads/*:refs/heads/* refs/tags/*:refs/tags/* in /home/paulca/.local/state/amadan/runner/repos/oficina__home/cache/go-mod/cache/vcs/bf4984e7a537af013cffcd0a654026b503a4e1566cddf740e3c37d2f6ae937e4: exit status 128: fatal: unable to access 'https://amadan.net/oficina/estilo/': The requested URL returned error: 429 internal/home/app.go:25:2: amadan.net/oficina/estilo.git@v0.0.0-20260911182719-0bcb9287988d: invalid version: git fetch -f --end-of-options origin refs/heads/*:refs/heads/* refs/tags/*:refs/tags/* in /home/paulca/.local/state/amadan/runner/repos/oficina__home/cache/go-mod/cache/vcs/bf4984e7a537af013cffcd0a654026b503a4e1566cddf740e3c37d2f6ae937e4: exit status 128: fatal: unable to access 'https://amadan.net/oficina/estilo/': The requested URL returned error: 429 internal/operator/command_linux.go:10:2: amadan.net/carlos/platform.git/launchctx@v0.0.0-20260911135504-fb577f96f7d1: invalid version: git fetch -f --end-of-options origin refs/heads/*:refs/heads/* refs/tags/*:refs/tags/* in /home/paulca/.local/state/amadan/runner/repos/oficina__home/cache/go-mod/cache/vcs/55cd75de7b117f7c5cd43edfd800f8d47521af42d6b939d46f385b6d34c14c9a: exit status 128: fatal: unable to access 'https://amadan.net/carlos/platform/': The requested URL returned error: 429 internal/operator/command_linux.go:11:2: amadan.net/carlos/platform.git/launchctx@v0.0.0-20260911135504-fb577f96f7d1: invalid version: git fetch -f --end-of-options origin refs/heads/*:refs/heads/* refs/tags/*:refs/tags/* in /home/paulca/.local/state/amadan/runner/repos/oficina__home/cache/go-mod/cache/vcs/55cd75de7b117f7c5cd43edfd800f8d47521af42d6b939d46f385b6d34c14c9a: exit status 128: fatal: unable to access 'https://amadan.net/carlos/platform/': The requested URL returned error: 429 make: *** [Makefile:69: test-vet] Error 1 amadan: step vet failed: exit status 2 amadan: job failed at step vet — its output is above, not at the tail of this log
diff --git a/internal/home/app.go b/internal/home/app.go| index 408a2f3..85bb3e9 100644 |
| --- a/internal/home/app.go |
| +++ b/internal/home/app.go |
| @@ -174,6 +174,13 @@ func (a *App) Router() http.Handler { |
| w.WriteHeader(http.StatusOK) |
| }) |
| + if a.runtime != nil && a.runtime.role == "credential" { |
| + // Outside csrf.Protect on purpose, with its own exact origin |
| + // check: the directory's sign-out arrives same-site, not |
| + // same-origin. See signoutPath. |
| + r.With(a.credentialOnly).Post(signoutPath, a.handleCredentialSignout) |
| + } |
| + |
| r.Group(func(r chi.Router) { |
| r.Use(a.credentialOnly) |
| // POST /signin is an unauthenticated endpoint that sends mail and makes |
diff --git a/internal/home/directory.go b/internal/home/directory.go| index fefa080..fad22de 100644 |
| --- a/internal/home/directory.go |
| +++ b/internal/home/directory.go |
| @@ -105,9 +105,12 @@ func (a *App) directoryRouter() http.Handler { |
| } |
| a.render(w, 200, "directory.html", map[string]any{"Apps": directoryCatalog(*a.runtime)}) |
| }) |
| - r.Post("/signout", func(w http.ResponseWriter, r *http.Request) { |
| + // Sign out ends both sessions: this one here, then the id.* |
| + // session by handing the POST on. See signoutPath. |
| + r.Post(signoutPath, func(w http.ResponseWriter, r *http.Request) { |
| + w.Header().Set("Cache-Control", "no-store") |
| a.sessions.SignOut(w, r) |
| - http.Redirect(w, r, "/signed-out", http.StatusSeeOther) |
| + http.Redirect(w, r, a.origins.Credential+signoutPath, http.StatusTemporaryRedirect) |
| }) |
| r.Get("/signed-out", func(w http.ResponseWriter, r *http.Request) { |
| w.Header().Set("Cache-Control", "no-store") |
diff --git a/internal/home/security.go b/internal/home/security.go| index caaf7e9..9165dce 100644 |
| --- a/internal/home/security.go |
| +++ b/internal/home/security.go |
| @@ -13,6 +13,12 @@ func (a *App) SecurityPolicy() string { |
| for _, origin := range a.runtime.consumers { |
| origins[origin] = true |
| } |
| + // form-action governs a form's redirects too, so the directory's |
| + // sign-out, which 307s its POST on to the credential origin, is |
| + // blocked in the browser without this. See signoutPath. |
| + if a.runtime.role == "directory" { |
| + origins[a.origins.Credential] = true |
| + } |
| } |
| list := make([]string, 0, len(origins)) |
| for origin := range origins { |
diff --git a/internal/home/signout.go b/internal/home/signout.go| new file mode 100644 |
| index 0000000..410b216 |
| --- /dev/null |
| +++ b/internal/home/signout.go |
| @@ -0,0 +1,91 @@ |
| +package home |
| + |
| +import ( |
| + "net/http" |
| + "net/url" |
| + |
| + "github.com/carlosframework/rastrillo/csrf" |
| +) |
| + |
| +// signoutPath is the same path on both origins. The directory's POST |
| +// /signout ends the directory session and answers 307 to the credential |
| +// origin's POST /signout, which ends the id.* session and lands the |
| +// browser on the directory's /signed-out. |
| +// |
| +// Both halves are needed. The directory session is only the app list; |
| +// the credential session is what /authorize reads, and it lives 30 days. |
| +// Ending just the first (as sign-out did until polish-audit B2) made |
| +// "You are signed out" a promise the next click broke: Sign in, from the |
| +// directory or from any app's "Sign in with Home", passed straight back |
| +// through /authorize with no prompt. |
| +// |
| +// 307 rather than 303 so the hop stays a POST. A GET sign-out is one any |
| +// page can trigger with a link or an <img>; a POST is not. See |
| +// handleCredentialSignout for how the credential side tells the |
| +// directory's hand-off from anyone else's POST. |
| +// |
| +// What this does NOT end: sessions an app instance minted from an |
| +// earlier assertion. Those are the instance's own sessions on its own |
| +// host, and the home keeps no list of where it has handed an assertion |
| +// (a hibernating fleet would not hear a revocation anyway). Each app's |
| +// own Sign out ends its session; what this guarantees is that no app can |
| +// get a NEW assertion for this browser without the email or passkey step. |
| +const signoutPath = "/signout" |
| + |
| +// handleCredentialSignout ends the credential-origin session. |
| +// |
| +// It sits outside csrf.Protect because the directory's hand-off is |
| +// same-site but not same-origin (Sec-Fetch-Site: same-site), which |
| +// Protect refuses. In its place is a narrower check, signoutFromHome. |
| +func (a *App) handleCredentialSignout(w http.ResponseWriter, r *http.Request) { |
| + w.Header().Set("Cache-Control", "no-store") |
| + if !a.signoutFromHome(r) { |
| + http.Error(w, "Sign-out was refused because the request did not come from Oficina.", http.StatusForbidden) |
| + return |
| + } |
| + a.sessions.SignOut(w, r) |
| + http.Redirect(w, r, a.origins.Directory+"/signed-out", http.StatusSeeOther) |
| +} |
| + |
| +// signoutFromHome admits a sign-out POST from this origin's own pages or |
| +// from the directory origin, and nothing else: not a team instance on the |
| +// same site, not any other origin, not a request with no evidence. |
| +// |
| +// Origin alone cannot carry the directory's hand-off. Chromium sends |
| +// "Origin: null" on a POST that a 307 has carried across origins |
| +// (verified in a real browser, polish-audit B2; the Fetch spec's tainted- |
| +// origin rule would keep the directory, but the browser does not), so the |
| +// directory's own hop arrives with a null Origin. Referer survives the |
| +// redirect, and under rastrillo's Referrer-Policy of |
| +// strict-origin-when-cross-origin it is exactly the directory origin. So: |
| +// |
| +// - Origin, when it names an origin, must be the directory's (or pass |
| +// the ordinary same-origin check for this one); |
| +// - a null or absent Origin falls back to Referer, whose origin must be |
| +// exactly the directory's, and then only on a request the browser |
| +// marks same-site (or does not mark at all). |
| +// |
| +// A page elsewhere cannot set Referer to the directory origin, and the |
| +// only directory route that re-sends a POST here is its own |
| +// CSRF-protected /signout. A browser that strips both headers is refused |
| +// and sees the refusal: the failure is loud, not a silent survival of the |
| +// session. |
| +func (a *App) signoutFromHome(r *http.Request) bool { |
| + if csrf.SameOrigin(r, a.origins.Credential) { |
| + return true |
| + } |
| + switch origin := r.Header.Get("Origin"); origin { |
| + case a.origins.Directory: |
| + return true |
| + case "", "null": |
| + default: |
| + return false |
| + } |
| + switch r.Header.Get("Sec-Fetch-Site") { |
| + case "", "same-site": |
| + default: |
| + return false |
| + } |
| + ref, err := url.Parse(r.Header.Get("Referer")) |
| + return err == nil && ref.User == nil && ref.Scheme+"://"+ref.Host == a.origins.Directory |
| +} |
diff --git a/internal/home/signout_test.go b/internal/home/signout_test.go| new file mode 100644 |
| index 0000000..f9a0050 |
| --- /dev/null |
| +++ b/internal/home/signout_test.go |
| @@ -0,0 +1,200 @@ |
| +package home |
| + |
| +import ( |
| + "net/http" |
| + "net/http/httptest" |
| + "net/url" |
| + "strings" |
| + "testing" |
| + |
| + "amadan.net/oficina/home/internal/home/sealtest" |
| +) |
| + |
| +// signedInPair walks a full sign-in through both roles and returns the |
| +// credential-origin session and the directory session it produced. |
| +func signedInPair(t *testing.T, p exchangePair) (credential, directory *http.Cookie) { |
| + t.Helper() |
| + credential = signInFully(t, p.credential, sealtest.CanaryAddress) |
| + pending, intent := p.start(t) |
| + token, _ := p.mint(t, intent, credential) |
| + return credential, p.finish(t, token, pending) |
| +} |
| + |
| +// resumeMints reports whether the credential origin would mint an |
| +// assertion for this session without asking anything: the silent |
| +// re-entry that made "Sign out" a lie (polish audit B2). |
| +func resumeMints(t *testing.T, p exchangePair, credential *http.Cookie) bool { |
| + t.Helper() |
| + _, intent := p.start(t) |
| + rec := exchangeRequest(p.credential.App, "GET", Origin+"/authorize/resume", "", nil, intent, credential) |
| + switch { |
| + case rec.Code == 200 && strings.Contains(rec.Body.String(), `name="assertion"`): |
| + return true |
| + case rec.Code == 303 && strings.HasPrefix(rec.Header().Get("Location"), "/signin"): |
| + return false |
| + } |
| + t.Fatalf("resume answered %d %q", rec.Code, rec.Header().Get("Location")) |
| + return false |
| +} |
| + |
| +// signoutHop posts to the credential sign-out with the given evidence |
| +// headers; an empty value leaves that header out. |
| +func signoutHop(p exchangePair, origin, fetchSite, referer string, cookies ...*http.Cookie) *httptest.ResponseRecorder { |
| + r := httptest.NewRequest("POST", Origin+"/signout", nil) |
| + for k, v := range map[string]string{"Origin": origin, "Sec-Fetch-Site": fetchSite, "Referer": referer} { |
| + if v != "" { |
| + r.Header.Set(k, v) |
| + } |
| + } |
| + for _, c := range cookies { |
| + r.AddCookie(c) |
| + } |
| + rec := httptest.NewRecorder() |
| + p.credential.Router().ServeHTTP(rec, r) |
| + return rec |
| +} |
| + |
| +func clearsCookie(rec interface{ Result() *http.Response }, name string) bool { |
| + for _, c := range rec.Result().Cookies() { |
| + if c.Name == name && c.Value == "" && c.MaxAge < 0 { |
| + return true |
| + } |
| + } |
| + return false |
| +} |
| + |
| +// TestSignOutEndsTheCredentialSession is B2: signing out of the |
| +// directory must also end the id.* session, so the next Sign in asks for |
| +// the email (or passkey) step instead of bouncing straight back in. |
| +func TestSignOutEndsTheCredentialSession(t *testing.T) { |
| + p := newExchangePair(t) |
| + credential, directory := signedInPair(t, p) |
| + if !resumeMints(t, p, credential) { |
| + t.Fatal("precondition: a live credential session mints silently") |
| + } |
| + |
| + // The directory ends its own session, then hands the same POST on to |
| + // the credential origin. 307, not 303: the method has to survive so |
| + // the credential origin receives a POST it can origin-check, rather |
| + // than a GET anyone could link to. |
| + out := exchangeRequest(p.directory, "POST", directoryOrigin+"/signout", directoryOrigin, url.Values{}, directory) |
| + if out.Code != http.StatusTemporaryRedirect || out.Header().Get("Location") != Origin+"/signout" { |
| + t.Fatalf("directory sign-out: %d %q", out.Code, out.Header().Get("Location")) |
| + } |
| + if !clearsCookie(out, directory.Name) { |
| + t.Fatal("directory sign-out left its cookie") |
| + } |
| + if page := exchangeRequest(p.directory, "GET", directoryOrigin+"/", "", nil, directory); strings.Contains(page.Body.String(), "You are signed in.") { |
| + t.Fatal("directory session survived sign-out") |
| + } |
| + |
| + // The browser re-sends the POST to id.*. These are the headers |
| + // Chromium really sends on that hop (captured live, polish-audit B2): |
| + // the 307 nulls Origin, so the directory is named by Referer alone. |
| + end := signoutHop(p, "null", "same-site", directoryOrigin+"/", credential) |
| + if end.Code != http.StatusSeeOther || end.Header().Get("Location") != directoryOrigin+"/signed-out" { |
| + t.Fatalf("credential sign-out: %d %q", end.Code, end.Header().Get("Location")) |
| + } |
| + if !clearsCookie(end, credential.Name) { |
| + t.Fatal("credential sign-out left its cookie") |
| + } |
| + if end.Header().Get("Cache-Control") != "no-store" { |
| + t.Fatal("sign-out response cacheable") |
| + } |
| + |
| + // The cookie is gone from the browser, but a copy of it must not work |
| + // either: the row is revoked, not merely forgotten. |
| + if resumeMints(t, p, credential) { |
| + t.Fatal("credential session survived sign-out: Sign in re-enters without a prompt") |
| + } |
| + var rows int |
| + if err := p.credential.db.Writer().QueryRow(`SELECT count(*) FROM sessions`).Scan(&rows); err != nil || rows != 0 { |
| + t.Fatal("credential session row not revoked", rows, err) |
| + } |
| +} |
| + |
| +// TestCredentialSignOutRefusesOtherOrigins: the credential sign-out is |
| +// outside csrf.Protect (the directory's hand-off arrives same-site, not |
| +// same-origin), so it carries its own exact origin check. Anything other |
| +// than the directory or the credential origin itself is refused and the |
| +// session survives, and a GET does nothing at all. |
| +func TestCredentialSignOutRefusesOtherOrigins(t *testing.T) { |
| + p := newExchangePair(t) |
| + credential, _ := signedInPair(t, p) |
| + for _, c := range []struct{ origin, site, referer string }{ |
| + {"", "", ""}, |
| + {"null", "", ""}, |
| + {"null", "same-site", ""}, |
| + {"https://evil.invalid", "cross-site", directoryOrigin + "/"}, |
| + {"https://blue.docs.oficina.works", "same-site", ""}, |
| + {"https://blue.docs.oficina.works", "same-site", directoryOrigin + "/"}, |
| + {"http://oficina.works", "cross-site", ""}, |
| + {"https://oficina.works.evil.invalid", "cross-site", ""}, |
| + {"null", "same-site", "https://blue.docs.oficina.works/"}, |
| + {"null", "cross-site", directoryOrigin + "/"}, |
| + {"null", "same-site", "http://oficina.works/"}, |
| + {"null", "same-site", "https://oficina.works.evil.invalid/"}, |
| + {"null", "same-site", "https://user@oficina.works/"}, |
| + {"", "same-site", "https://evil.invalid/"}, |
| + } { |
| + rec := signoutHop(p, c.origin, c.site, c.referer, credential) |
| + if rec.Code != http.StatusForbidden || clearsCookie(rec, credential.Name) { |
| + t.Fatalf("%+v: %d", c, rec.Code) |
| + } |
| + } |
| + // Both shapes of the directory's own hand-off pass: Origin named (the |
| + // Fetch spec's reading), or nulled with Referer (Chromium's). |
| + for _, c := range []struct{ origin, site, referer string }{ |
| + {directoryOrigin, "same-site", ""}, |
| + {"null", "same-site", directoryOrigin + "/"}, |
| + {"", "", directoryOrigin + "/"}, |
| + } { |
| + fresh, _ := signedInPair(t, p) |
| + if rec := signoutHop(p, c.origin, c.site, c.referer, fresh); rec.Code != http.StatusSeeOther || resumeMints(t, p, fresh) { |
| + t.Fatalf("directory hand-off %+v refused or left the session: %d", c, rec.Code) |
| + } |
| + } |
| + if rec := exchangeRequest(p.credential.App, "GET", Origin+"/signout", "", nil, credential); rec.Code/100 == 3 || clearsCookie(rec, credential.Name) { |
| + t.Fatal("GET signed out", rec.Code) |
| + } |
| + if !resumeMints(t, p, credential) { |
| + t.Fatal("refused sign-out still ended the session") |
| + } |
| + // Its own origin may sign out too, for a future button on id.* pages. |
| + if rec := exchangeRequest(p.credential.App, "POST", Origin+"/signout", Origin, url.Values{}, credential); rec.Code != http.StatusSeeOther { |
| + t.Fatal("same-origin sign-out refused", rec.Code) |
| + } |
| + if resumeMints(t, p, credential) { |
| + t.Fatal("same-origin sign-out left the session") |
| + } |
| + // Neither role answers the other's host: the credential sign-out is |
| + // id.*-only, and the directory's is the directory's. |
| + if rec := exchangeRequest(p.directory, "POST", Origin+"/signout", directoryOrigin, url.Values{}); rec.Code != 404 { |
| + t.Fatal("directory served credential sign-out", rec.Code) |
| + } |
| + if rec := exchangeRequest(p.credential.App, "POST", directoryOrigin+"/signout", directoryOrigin, url.Values{}); rec.Code != 404 { |
| + t.Fatal("credential served directory sign-out", rec.Code) |
| + } |
| +} |
| + |
| +// TestDirectoryPolicyAdmitsTheSignOutHandOff: CSP form-action is enforced |
| +// across a form's redirects, so without the credential origin in the |
| +// directory's form-action the 307 is blocked in the browser and B2 comes |
| +// back with nothing on screen but a console error. |
| +func TestDirectoryPolicyAdmitsTheSignOutHandOff(t *testing.T) { |
| + p := newExchangePair(t) |
| + policy := p.directory.SecurityPolicy() |
| + var formAction string |
| + for _, d := range strings.Split(policy, ";") { |
| + if d = strings.TrimSpace(d); strings.HasPrefix(d, "form-action ") { |
| + formAction = d |
| + } |
| + } |
| + found := false |
| + for _, source := range strings.Fields(formAction) { |
| + found = found || source == Origin |
| + } |
| + if !found { |
| + t.Fatalf("directory form-action omits %s: %q", Origin, formAction) |
| + } |
| +} |