Admit every registered consumer to id.* sign-out, with a branded refusal
POST id.*/signout admitted only the directory's hand-off, so an app's own Sign out could not end the id.* session (spec shared-chrome §9.5). It now admits any origin registered in HOME_CONSUMERS, named by Origin or, when the 307 nulls Origin, by Referer; Sec-Fetch-Site must agree with where that origin sits (same-site on the suite's site, cross-site for a preview off it, or absent). The browser returns to the matched origin's /signed-out, never to anything the request supplies. A refused POST (403) and GET /signout (200) render the same "Finish signing out" page, whose Sign out button is a same-origin POST that needs no Referer, instead of bare error text (B2 review, finding 1).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
4 files changed,
+290
−51
internal/home/app.go+4 −2internal/home/signout.go+114 −44internal/home/signout_test.go+162 −5internal/home/templates/signout_confirm.html+10 −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: error: RPC failed; HTTP 429 curl 22 The requested URL returned error: 429 fatal: expected flush after ref listing 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: error: RPC failed; HTTP 429 curl 22 The requested URL returned error: 429 fatal: expected flush after ref listing 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 85bb3e9..5f9b3e5 100644 |
| --- a/internal/home/app.go |
| +++ b/internal/home/app.go |
| @@ -176,9 +176,11 @@ func (a *App) Router() http.Handler { |
| 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. |
| + // check: a consumer's sign-out arrives same-site or cross-site, |
| + // never same-origin. GET only renders the "Finish signing out" |
| + // page. See signoutPath. |
| r.With(a.credentialOnly).Post(signoutPath, a.handleCredentialSignout) |
| + r.With(a.credentialOnly).Get(signoutPath, a.handleCredentialSignoutPage) |
| } |
| r.Group(func(r chi.Router) { |
diff --git a/internal/home/signout.go b/internal/home/signout.go| index 410b216..1c50306 100644 |
| --- a/internal/home/signout.go |
| +++ b/internal/home/signout.go |
| @@ -3,26 +3,29 @@ package home |
| import ( |
| "net/http" |
| "net/url" |
| + "strings" |
| "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. |
| +// signoutPath is the same path on the credential origin, the directory |
| +// and every connected app. A consumer's POST /signout (the directory's, |
| +// or an app's through home/connect's Routes.Signout) ends its own session |
| +// and answers 307 to the credential origin's POST /signout, which ends |
| +// the id.* session and lands the browser on that consumer'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. |
| +// directory or from any app, passed straight back through /authorize |
| +// with no prompt. Round 2 chains each app's own Sign out the same way |
| +// (spec shared-chrome §9.5). |
| // |
| // 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. |
| +// signoutReturn for how the credential side tells a consumer'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 |
| @@ -34,58 +37,125 @@ 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. |
| +// It sits outside csrf.Protect because a consumer's hand-off is same-site |
| +// (a team instance, or the directory) or cross-site (a preview), never |
| +// same-origin, and Protect refuses both. In its place is a narrower |
| +// check, signoutReturn, which also names the only place the browser may |
| +// go next. |
| +// |
| +// A refusal is the branded "Finish signing out" page, status 403, never |
| +// bare error text: by then the consumer has already ended its own |
| +// session, so a plain error would leave the browser half signed out with |
| +// no way on. The page's Sign out button is a same-origin POST, which |
| +// needs no Referer, so the browsers and extensions that strip one still |
| +// have a way through. |
| 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) |
| + back, ok := a.signoutReturn(r) |
| + if !ok { |
| + a.render(w, http.StatusForbidden, "signout_confirm.html", nil) |
| return |
| } |
| a.sessions.SignOut(w, r) |
| - http.Redirect(w, r, a.origins.Directory+"/signed-out", http.StatusSeeOther) |
| + http.Redirect(w, r, back+"/signed-out", http.StatusSeeOther) |
| +} |
| + |
| +// handleCredentialSignoutPage is GET /signout: the same page, doing |
| +// nothing until its button is pressed. Sign-out is never a GET, because |
| +// a GET is one any link or <img> can trigger. |
| +func (a *App) handleCredentialSignoutPage(w http.ResponseWriter, r *http.Request) { |
| + w.Header().Set("Cache-Control", "no-store") |
| + a.render(w, http.StatusOK, "signout_confirm.html", nil) |
| } |
| -// 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. |
| +// signoutReturn admits a sign-out POST from this origin's own pages or |
| +// from an origin registered in HOME_CONSUMERS (the directory is one), and |
| +// nothing else: not an unregistered team instance on the same site, not |
| +// any other origin, not a request with no evidence. It returns the origin |
| +// the browser goes back to, which is always the matched registration |
| +// (the directory, for this origin's own button), and never anything the |
| +// request supplied. So there is no open redirect to close. |
| // |
| -// Origin alone cannot carry the directory's hand-off. Chromium sends |
| +// HOME_CONSUMERS is the allowlist that already decides who may receive an |
| +// assertion, so admitting its origins here adds no party that could not |
| +// already take part in sign-in. |
| +// |
| +// Origin alone cannot carry the hand-off. Chromium and WebKit send |
| // "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: |
| +// (verified in real browsers, polish-audit B2; the Fetch spec's tainted- |
| +// origin rule would keep the consumer's, but the browsers do not), so the |
| +// hop that matters arrives with a null Origin. Referer survives the |
| +// redirect, and under Referrer-Policy strict-origin-when-cross-origin |
| +// (rastrillo's default, and what connect's 307 sets) it is exactly the |
| +// consumer's origin. So: |
| // |
| -// - Origin, when it names an origin, must be the directory's (or pass |
| -// the ordinary same-origin check for this one); |
| +// - Origin, when it names an origin, must be a registered one (or pass |
| +// the ordinary same-origin check for this one); a Referer cannot |
| +// overrule it; |
| // - 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). |
| +// exactly a registered one; |
| +// - either way, Sec-Fetch-Site must agree with where that origin is: |
| +// same-site for one on the suite's site, cross-site for one off it (a |
| +// preview), or absent. A suite origin arriving cross-site passed |
| +// through another site on the way, and a preview is never same-site. |
| // |
| -// 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 { |
| +// A page elsewhere cannot set Referer to a consumer's origin. What is |
| +// left, and accepted (spec §9.5), is logout CSRF from a page ON a |
| +// consumer origin: any of them can post here. That needs a user-injected |
| +// form on a page the victim visits, and a consumer that renders one must |
| +// not do it on a registered origin. |
| +func (a *App) signoutReturn(r *http.Request) (string, bool) { |
| if csrf.SameOrigin(r, a.origins.Credential) { |
| - return true |
| + return a.origins.Directory, true |
| } |
| - switch origin := r.Header.Get("Origin"); origin { |
| - case a.origins.Directory: |
| - return true |
| - case "", "null": |
| - default: |
| - return false |
| + claimed := r.Header.Get("Origin") |
| + if claimed == "" || claimed == "null" { |
| + ref, err := url.Parse(r.Header.Get("Referer")) |
| + if err != nil || ref.User != nil || ref.Scheme == "" || ref.Host == "" { |
| + return "", false |
| + } |
| + claimed = ref.Scheme + "://" + ref.Host |
| + } |
| + matched, ok := a.consumerOrigin(claimed) |
| + if !ok { |
| + return "", false |
| } |
| switch r.Header.Get("Sec-Fetch-Site") { |
| - case "", "same-site": |
| + case "": |
| + case "same-site": |
| + if !a.onSuiteSite(matched) { |
| + return "", false |
| + } |
| + case "cross-site": |
| + if a.onSuiteSite(matched) { |
| + return "", false |
| + } |
| default: |
| - return false |
| + return "", false |
| } |
| - ref, err := url.Parse(r.Header.Get("Referer")) |
| - return err == nil && ref.User == nil && ref.Scheme+"://"+ref.Host == a.origins.Directory |
| + return matched, true |
| +} |
| + |
| +// consumerOrigin returns the registered consumer origin exactly equal to |
| +// origin. Registrations are validated at boot (assertion.NewVerifier), so |
| +// an exact string match is a match on scheme, host and port. |
| +func (a *App) consumerOrigin(origin string) (string, bool) { |
| + if a.runtime == nil { |
| + return "", false |
| + } |
| + for _, registered := range a.runtime.consumers { |
| + if registered == origin { |
| + return registered, true |
| + } |
| + } |
| + return "", false |
| +} |
| + |
| +// onSuiteSite reports whether origin is the directory or under its host. |
| +// The directory is the suite's apex (oficina.works), and the credential |
| +// origin and every team instance sit under it, so this is the site a |
| +// browser's Sec-Fetch-Site compares against. |
| +func (a *App) onSuiteSite(origin string) bool { |
| + host := strings.TrimPrefix(origin, "https://") |
| + return origin == a.origins.Directory || strings.HasSuffix(host, "."+a.directoryHost) |
| } |
diff --git a/internal/home/signout_test.go b/internal/home/signout_test.go| index f9a0050..3aad60d 100644 |
| --- a/internal/home/signout_test.go |
| +++ b/internal/home/signout_test.go |
| @@ -116,8 +116,10 @@ func TestSignOutEndsTheCredentialSession(t *testing.T) { |
| // 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. |
| +// than a registered consumer (the directory is one) or the credential |
| +// origin itself is refused and the session survives, and a GET does |
| +// nothing at all. blue.docs is registered in newExchangePair; red.docs is |
| +// the same site but is not. |
| func TestCredentialSignOutRefusesOtherOrigins(t *testing.T) { |
| p := newExchangePair(t) |
| credential, _ := signedInPair(t, p) |
| @@ -126,11 +128,11 @@ func TestCredentialSignOutRefusesOtherOrigins(t *testing.T) { |
| {"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 + "/"}, |
| + {"https://red.docs.oficina.works", "same-site", ""}, |
| + {"https://red.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", "same-site", "https://red.docs.oficina.works/"}, |
| {"null", "cross-site", directoryOrigin + "/"}, |
| {"null", "same-site", "http://oficina.works/"}, |
| {"null", "same-site", "https://oficina.works.evil.invalid/"}, |
| @@ -198,3 +200,158 @@ func TestDirectoryPolicyAdmitsTheSignOutHandOff(t *testing.T) { |
| t.Fatalf("directory form-action omits %s: %q", Origin, formAction) |
| } |
| } |
| + |
| +const ( |
| + teamOrigin = "https://blue.docs.oficina.works" |
| + previewOrigin = "https://sheets-preview.example.net" |
| +) |
| + |
| +// withPreviewConsumer registers a preview deployment beside the pair's |
| +// directory and team consumers: an origin that is registered but is not |
| +// on the suite's site, so the browser marks its hop cross-site. |
| +func withPreviewConsumer(p exchangePair) exchangePair { |
| + p.credential.runtime.consumers["preview_sheets"] = previewOrigin |
| + return p |
| +} |
| + |
| +// isFinishSigningOut reports whether rec is the branded refusal page: the |
| +// way out for a hop that arrived without evidence, a same-origin form |
| +// that csrf.SameOrigin admits without any Referer. |
| +func isFinishSigningOut(rec *httptest.ResponseRecorder) bool { |
| + body := rec.Body.String() |
| + return strings.HasPrefix(rec.Header().Get("Content-Type"), "text/html") && |
| + rec.Header().Get("Cache-Control") == "no-store" && |
| + strings.Contains(body, "<title>Finish signing out · Oficina</title>") && |
| + strings.Contains(body, "<h1>Finish signing out</h1>") && |
| + strings.Contains(body, "Select Sign out to finish signing out of Oficina on this device.") && |
| + strings.Contains(body, `<form method="post" action="/signout"`) && |
| + strings.Contains(body, `<button type="submit" rst-btn="primary">Sign out</button>`) |
| +} |
| + |
| +// TestCredentialSignOutAdmitsRegisteredConsumers is round 2's widening |
| +// (spec §9.5): an app's own Sign out 307s to id.*/signout, so every |
| +// origin in HOME_CONSUMERS is admitted, by Origin or (Origin nulled by |
| +// the redirect, as Chromium and WebKit do) by Referer, and lands on THAT |
| +// origin's /signed-out. The destination comes from the matched |
| +// registration and never from the request. |
| +func TestCredentialSignOutAdmitsRegisteredConsumers(t *testing.T) { |
| + for _, c := range []struct{ origin, site, referer, want string }{ |
| + {teamOrigin, "same-site", "", teamOrigin}, |
| + {"null", "same-site", teamOrigin + "/", teamOrigin}, |
| + {"null", "same-site", teamOrigin + "/d/42?view=compact", teamOrigin}, |
| + {"", "", teamOrigin + "/", teamOrigin}, |
| + {teamOrigin, "", "", teamOrigin}, |
| + {previewOrigin, "cross-site", "", previewOrigin}, |
| + {"null", "cross-site", previewOrigin + "/", previewOrigin}, |
| + {"", "", previewOrigin + "/", previewOrigin}, |
| + {"null", "same-site", directoryOrigin + "/", directoryOrigin}, |
| + {directoryOrigin, "same-site", directoryOrigin + "/", directoryOrigin}, |
| + } { |
| + // A pair per case: sign-in's rate limiter would refuse ten in a row. |
| + p := withPreviewConsumer(newExchangePair(t)) |
| + fresh, _ := signedInPair(t, p) |
| + rec := signoutHop(p, c.origin, c.site, c.referer, fresh) |
| + if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != c.want+"/signed-out" { |
| + t.Fatalf("%+v: %d %q", c, rec.Code, rec.Header().Get("Location")) |
| + } |
| + if !clearsCookie(rec, fresh.Name) || rec.Header().Get("Cache-Control") != "no-store" { |
| + t.Fatalf("%+v: cookie kept or response cacheable", c) |
| + } |
| + if resumeMints(t, p, fresh) { |
| + t.Fatalf("%+v: session survived an admitted sign-out", c) |
| + } |
| + } |
| +} |
| + |
| +// TestCredentialSignOutNeverTakesADestination: nothing a request carries |
| +// chooses where it lands. A query or a form field naming somewhere else |
| +// is ignored and the browser goes to the matched origin's /signed-out. |
| +func TestCredentialSignOutNeverTakesADestination(t *testing.T) { |
| + p := newExchangePair(t) |
| + credential, _ := signedInPair(t, p) |
| + r := httptest.NewRequest("POST", Origin+"/signout?return_to=https://evil.invalid/&next=https://evil.invalid/", strings.NewReader(url.Values{"return_to": {"https://evil.invalid/"}, "redirect": {"//evil.invalid"}}.Encode())) |
| + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| + r.Header.Set("Origin", "null") |
| + r.Header.Set("Sec-Fetch-Site", "same-site") |
| + r.Header.Set("Referer", teamOrigin+"/") |
| + r.AddCookie(credential) |
| + rec := httptest.NewRecorder() |
| + p.credential.Router().ServeHTTP(rec, r) |
| + if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != teamOrigin+"/signed-out" { |
| + t.Fatalf("destination taken from the request: %d %q", rec.Code, rec.Header().Get("Location")) |
| + } |
| +} |
| + |
| +// TestCredentialSignOutRefusalIsBrandedAndRecoverable: a refused POST is |
| +// never bare error text (the B2 review's finding 1). It is the same |
| +// branded page a plain GET shows, with status 403, and its Sign out |
| +// button is a same-origin POST that then succeeds without any Referer: |
| +// the recovery for a browser that strips it. |
| +func TestCredentialSignOutRefusalIsBrandedAndRecoverable(t *testing.T) { |
| + p := withPreviewConsumer(newExchangePair(t)) |
| + credential, _ := signedInPair(t, p) |
| + for _, c := range []struct{ origin, site, referer string }{ |
| + // No evidence at all, or a nulled Origin with the Referer stripped. |
| + {"", "", ""}, |
| + {"null", "same-site", ""}, |
| + {"null", "cross-site", ""}, |
| + // Unregistered: another site, and a team host on the same site. |
| + {"https://evil.invalid", "cross-site", ""}, |
| + {"null", "cross-site", "https://evil.invalid/"}, |
| + {"https://red.docs.oficina.works", "same-site", ""}, |
| + {"null", "same-site", "https://red.docs.oficina.works/"}, |
| + // A named Origin is the evidence; a Referer cannot overrule it. |
| + {"https://evil.invalid", "cross-site", teamOrigin + "/"}, |
| + {"https://red.docs.oficina.works", "same-site", teamOrigin + "/"}, |
| + // Registered, but the browser's site marking contradicts it: a |
| + // suite origin arriving cross-site went through another site on |
| + // the way, and a preview is never same-site. |
| + {teamOrigin, "cross-site", ""}, |
| + {"null", "cross-site", teamOrigin + "/"}, |
| + {"null", "cross-site", directoryOrigin + "/"}, |
| + {previewOrigin, "same-site", ""}, |
| + {"null", "same-site", previewOrigin + "/"}, |
| + {teamOrigin, "bogus", ""}, |
| + // Near misses on the Referer: scheme, userinfo, suffix, port. |
| + {"null", "same-site", "http://blue.docs.oficina.works/"}, |
| + {"null", "same-site", "https://user@blue.docs.oficina.works/"}, |
| + {"null", "same-site", "https://blue.docs.oficina.works.evil.invalid/"}, |
| + {"null", "same-site", "https://blue.docs.oficina.works:8443/"}, |
| + {"null", "same-site", "https://BLUE.docs.oficina.works/"}, |
| + {"null", "same-site", "not a url\x7f"}, |
| + } { |
| + rec := signoutHop(p, c.origin, c.site, c.referer, credential) |
| + if rec.Code != http.StatusForbidden || !isFinishSigningOut(rec) || clearsCookie(rec, credential.Name) { |
| + t.Fatalf("%+v: %d, branded=%v\n%s", c, rec.Code, isFinishSigningOut(rec), rec.Body.String()) |
| + } |
| + } |
| + if !resumeMints(t, p, credential) { |
| + t.Fatal("a refused sign-out ended the session") |
| + } |
| + |
| + page := exchangeRequest(p.credential.App, "GET", Origin+"/signout", "", nil, credential) |
| + if page.Code != http.StatusOK || !isFinishSigningOut(page) { |
| + t.Fatalf("GET /signout: %d\n%s", page.Code, page.Body.String()) |
| + } |
| + if !resumeMints(t, p, credential) { |
| + t.Fatal("GET /signout ended the session") |
| + } |
| + |
| + // The button: a same-origin form POST, which is what the browser |
| + // sends from that page, with no Referer at all. |
| + r := httptest.NewRequest("POST", Origin+"/signout", nil) |
| + r.Header.Set("Origin", Origin) |
| + r.Header.Set("Sec-Fetch-Site", "same-origin") |
| + r.AddCookie(credential) |
| + rec := httptest.NewRecorder() |
| + p.credential.Router().ServeHTTP(rec, r) |
| + if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != directoryOrigin+"/signed-out" { |
| + t.Fatalf("Finish signing out button: %d %q", rec.Code, rec.Header().Get("Location")) |
| + } |
| + if resumeMints(t, p, credential) { |
| + t.Fatal("Finish signing out left the session") |
| + } |
| + if rec := exchangeRequest(p.directory, "GET", directoryOrigin+"/signout", "", nil); rec.Code != http.StatusNotFound && rec.Code != http.StatusMethodNotAllowed { |
| + t.Fatal("directory served the credential's sign-out page", rec.Code) |
| + } |
| +} |
diff --git a/internal/home/templates/signout_confirm.html b/internal/home/templates/signout_confirm.html| new file mode 100644 |
| index 0000000..a9df84c |
| --- /dev/null |
| +++ b/internal/home/templates/signout_confirm.html |
| @@ -0,0 +1,10 @@ |
| +{{define "title"}}Finish signing out · Oficina{{end}} |
| +{{define "content"}} |
| +<h1>Finish signing out</h1> |
| +<p>Select Sign out to finish signing out of Oficina on this device.</p> |
| +<form method="post" action="/signout" rst-form> |
| + <div rst-form-foot> |
| + <button type="submit" rst-btn="primary">Sign out</button> |
| + </div> |
| +</form> |
| +{{end}} |