oficina / home Public

Chain in-app sign-out to id.*; direct app switching; hide the hand-off flash

id.* POST /signout accepts every HOME_CONSUMERS origin (exact Origin, or
the Referer origin when Origin is null, with Sec-Fetch-Site agreement)
and returns only to {matched origin}/signed-out. Previews get a branded
'Finish signing out' page, whose same-origin POST revokes the session;
refused POSTs get it at 403. connect: Signout 307s to {Issuer}/signout,
Signin skips the exchange for a live signed-in session (never for Calls
guests), and Credentials.Directory reads HOME_DIRECTORY_ORIGIN.
/apps/<slug> lands on choice.Origin + '/' and is preview-aware. The id.*
hand-off copy stays hidden for 1.5 s, with a noscript fallback.
Reviewed by GPT-6-Astra via Codex on #74 (Fable at its limit), on Paul's
instruction; merged without green hub CI (runner 429s).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Squashed from feat/r2-signout-chain into main.
Paul Campbell pushed by claude agent, operated by paul@keymail.dev 01166fcfc0a9dd6bfb974749f4d2dd47bb69fcf9 parent 701a379
19 files changed, +794 −69
  • connect/README.md +12 −0
  • connect/config.go +42 −1
  • connect/config_test.go +36 −0
  • connect/routes.go +58 −1
  • connect/routes_test.go +112 −0
  • implementation/directory-runtime.md +1 −1
  • implementation/docs-team-chooser.md +1 −1
  • internal/home/app.go +4 −2
  • internal/home/app_chooser_test.go +2 −2
  • internal/home/directory_catalog_test.go +46 −2
  • internal/home/directory_runtime_test.go +2 −2
  • internal/home/docs_chooser.go +28 −4
  • internal/home/docs_chooser_test.go +3 −3
  • internal/home/docs_continue_test.go +2 −2
  • internal/home/handoff_reveal_test.go +47 −0
  • internal/home/signout.go +147 −43
  • internal/home/signout_test.go +227 −5
  • internal/home/templates/assertion.html +14 −0
  • internal/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:
	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/connect/README.md b/connect/README.md
index 6cc18bf..02d4491 100644
--- a/connect/README.md
+++ b/connect/README.md
@@ -8,6 +8,9 @@ Load optional settings with `CredentialsFromEnv("SHEETS")`, then validate them
with `DecodeCredentials(settings, origin)` once you know the app's origin.
The variables end in `_HOME_ISSUER`, `_HOME_CONSUMER_ID`, `_HOME_KEYS_BASE64`
and `_HOME_DEK_BASE64`. All empty means disabled; a partial configuration fails.
+`HOME_DIRECTORY_ORIGIN` (unprefixed, the name Home itself reads) optionally
+supplies the hub directory origin as `Credentials.Directory`, for an app switcher
+and "All apps" link. It enables nothing on its own and must be a bare https origin.
Use a dedicated random 32-byte storage key and a distinct namespace per app.
Keep this key stable: it protects proof state and keyed local-person bindings.
Replacing it without a migration makes existing links unusable; rotation requires
@@ -57,6 +60,15 @@ inside the app's existing Estilo layout; all paths work without JavaScript.
Apps with additional content-security directives must supply their existing
policy with the validated Home issuer added to `form-action`.
+`Routes.Signin` sends a person who already has a live session straight to the
+validated `return_to` (or `/`) instead of starting another exchange. `Routes.Signout`
+ends the app session and then answers `307` to `{Issuer}/signout`, which ends the
+Home sign-in session too and returns the browser to the app's `/signed-out`. Home
+admits that hop only from an origin registered in its `HOME_CONSUMERS`, by
+`Referer` when the browser nulls `Origin`, so a page showing Sign out must not send
+`Referrer-Policy: no-referrer` (use `strict-origin`), and a custom policy must keep
+the issuer in `form-action`.
+
Wrap the trusted local plugin's Begin handler with `OAuthContinuation` when it
can redirect to Keymail. Render the supplied destination with `OAuthPage` and the
shared `home-oauth-head` and `home-oauth` templates inside your existing layout.
diff --git a/connect/config.go b/connect/config.go
index 0ae04bc..6816578 100644
--- a/connect/config.go
+++ b/connect/config.go
@@ -3,7 +3,10 @@ package connect
import (
"encoding/base64"
"errors"
+ "net"
+ "net/url"
"os"
+ "strings"
"amadan.net/rastrillo/rastrillo/assertion"
json "github.com/go-json-experiment/json"
@@ -12,11 +15,16 @@ import (
// Credentials explicitly enables Home authentication. The public key manifest
// is StdBase64-encoded JSON mapping key IDs to StdBase64 P-256 public keys.
// DEKBase64 is a dedicated handoff/storage key, never the document content key.
+// Directory is the optional hub directory origin (HOME_DIRECTORY_ORIGIN), which
+// an app's switcher and "All apps" link to. It enables nothing on its own, and
+// it is configured rather than derived from Issuer: stripping "id." from the
+// credential origin is not a derivation anyone has promised.
type Credentials struct {
Issuer string
ConsumerID string
KeysBase64 string
DEKBase64 string
+ Directory string
}
type DecodedCredentials struct {
@@ -24,6 +32,7 @@ type DecodedCredentials struct {
ConsumerID string
Keys map[string][]byte
DEK []byte
+ Directory string
}
var ErrConfig = errors.New("home connect: invalid configuration")
@@ -65,7 +74,35 @@ func DecodeCredentials(cfg *Credentials, origin string) (*DecodedCredentials, er
if _, err := assertion.NewVerifier(cfg.Issuer, origin, keys); err != nil {
return nil, ErrConfig
}
- return &DecodedCredentials{Issuer: cfg.Issuer, ConsumerID: cfg.ConsumerID, Keys: keys, DEK: dek}, nil
+ if cfg.Directory != "" && (!bareOrigin(cfg.Directory) || cfg.Directory == cfg.Issuer) {
+ return nil, ErrConfig
+ }
+ return &DecodedCredentials{Issuer: cfg.Issuer, ConsumerID: cfg.ConsumerID, Keys: keys, DEK: dek, Directory: cfg.Directory}, nil
+}
+
+// bareOrigin is Home's originHost rule (internal/home/origins.go), which
+// this module cannot import: https, a lower-case DNS host of at least two
+// labels, no IP literal, no port, no userinfo, path, query or fragment.
+func bareOrigin(origin string) bool {
+ 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 || len(u.Host) > 253 {
+ return false
+ }
+ labels := strings.Split(u.Host, ".")
+ if len(labels) < 2 {
+ return false
+ }
+ for _, label := range labels {
+ if len(label) == 0 || len(label) > 63 || label[0] == '-' || label[len(label)-1] == '-' {
+ return false
+ }
+ for _, c := range label {
+ if !(c >= 'a' && c <= 'z' || c >= '0' && c <= '9' || c == '-') {
+ return false
+ }
+ }
+ }
+ return true
}
func homeConsumerID(v string) bool {
@@ -97,10 +134,14 @@ func LoadEnv(prefix, origin string) (*DecodedCredentials, error) {
return DecodeCredentials(CredentialsFromEnv(prefix), origin)
}
+// The directory origin is read from HOME_DIRECTORY_ORIGIN, unprefixed: it is
+// the same name, and the same value, Home itself reads. It is optional, so it
+// does not count toward "every setting is absent".
func CredentialsFromEnv(prefix string) *Credentials {
c := Credentials{Issuer: os.Getenv(prefix + "_HOME_ISSUER"), ConsumerID: os.Getenv(prefix + "_HOME_CONSUMER_ID"), KeysBase64: os.Getenv(prefix + "_HOME_KEYS_BASE64"), DEKBase64: os.Getenv(prefix + "_HOME_DEK_BASE64")}
if c.Issuer == "" && c.ConsumerID == "" && c.KeysBase64 == "" && c.DEKBase64 == "" {
return nil
}
+ c.Directory = os.Getenv("HOME_DIRECTORY_ORIGIN")
return &c
}
diff --git a/connect/config_test.go b/connect/config_test.go
index 59ab078..637c994 100644
--- a/connect/config_test.go
+++ b/connect/config_test.go
@@ -189,3 +189,39 @@ func TestEnvironmentCredentialsRefusePartialAndPreserveLiteralValues(t *testing.
t.Fatal(err)
}
}
+
+// TestCredentialsDirectoryOriginIsOptionalAndStrict is spec shared-chrome
+// §6.2: apps need the hub directory's origin to link their switcher to
+// it. It is optional (no switcher without it), and when set it must be a
+// bare https origin by Home's own originHost rules, never the issuer.
+func TestCredentialsDirectoryOriginIsOptionalAndStrict(t *testing.T) {
+ cfg, _, _ := homeConfigFixture(t)
+ settings, err := DecodeCredentials(&cfg, configOrigin)
+ if err != nil || settings.Directory != "" {
+ t.Fatal("an absent directory origin changed the configuration", err)
+ }
+ cfg.Directory = "https://oficina.works"
+ settings, err = DecodeCredentials(&cfg, configOrigin)
+ if err != nil || settings.Directory != "https://oficina.works" {
+ t.Fatal("valid directory origin refused", err)
+ }
+ for _, bad := range []string{"http://oficina.works", "https://oficina.works/", "https://Oficina.works", "https://user@oficina.works", "https://oficina.works:443", "https://oficina.works?x", "https://127.0.0.1", "https://localhost", "https://-bad.example", "oficina.works", "https://id.oficina.works", " https://oficina.works"} {
+ c := cfg
+ c.Directory = bad
+ requireCredentialsRefused(t, &c, configOrigin)
+ }
+ t.Setenv("CAL_HOME_ISSUER", cfg.Issuer)
+ t.Setenv("CAL_HOME_CONSUMER_ID", cfg.ConsumerID)
+ t.Setenv("CAL_HOME_KEYS_BASE64", cfg.KeysBase64)
+ t.Setenv("CAL_HOME_DEK_BASE64", cfg.DEKBase64)
+ t.Setenv("HOME_DIRECTORY_ORIGIN", "https://oficina.works")
+ if got := CredentialsFromEnv("CAL"); got == nil || got.Directory != "https://oficina.works" {
+ t.Fatal("HOME_DIRECTORY_ORIGIN not read", got)
+ }
+ for _, k := range []string{"CAL_HOME_ISSUER", "CAL_HOME_CONSUMER_ID", "CAL_HOME_KEYS_BASE64", "CAL_HOME_DEK_BASE64"} {
+ t.Setenv(k, "")
+ }
+ if got := CredentialsFromEnv("CAL"); got != nil {
+ t.Fatal("a directory origin alone switched Home on")
+ }
+}
diff --git a/connect/routes.go b/connect/routes.go
index 86baacf..038b21f 100644
--- a/connect/routes.go
+++ b/connect/routes.go
@@ -111,6 +111,14 @@ func (h *Routes) Signin(w http.ResponseWriter, r *http.Request) {
if values, ok := q["return_to"]; ok && len(values) == 1 && handoff.SafePath(values[0]) {
path = values[0]
}
+ // Already signed in: go where the person was headed, with no form and
+ // no second exchange (spec shared-chrome §9.4). SafePath refuses
+ // /signin and /auth/*, so this cannot loop.
+ if h.signedIn(r) {
+ w.Header().Set("Cache-Control", "no-store")
+ http.Redirect(w, r, path, http.StatusSeeOther)
+ return
+ }
if !h.navigation.rememberReturn(w, path) {
h.bridge.fail(w, r, http.StatusServiceUnavailable)
return
@@ -119,6 +127,29 @@ func (h *Routes) Signin(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, handoff.StartPath+"?"+url.Values{"return_to": {path}}.Encode(), http.StatusSeeOther)
}
+// signedIn reports a live, signed-in app session: exactly one session
+// cookie, which ReadSession resolves to a person who signed in, through
+// Home (HomeMethod) or a local link (magiclink, keymail). An ambiguous
+// pair or a revoked cookie is not one, and neither is any other method:
+// Calls' anonymous per-meeting guests ("guest") are sessions but not
+// sign-ins, and sending one to the root instead of the exchange ends on a
+// 404 (the #74 review, finding 3). An unknown method falls back to the
+// exchange, which is what /signin did before this check existed.
+func (h *Routes) signedIn(r *http.Request) bool {
+ if cookieCount(r, h.cfg.SessionCookie) != 1 {
+ return false
+ }
+ s, err := h.cfg.ReadSession(r)
+ if err != nil || s == nil || s.Subject == "" {
+ return false
+ }
+ switch s.Method {
+ case HomeMethod, "magiclink", "keymail":
+ return true
+ }
+ return false
+}
+
func (h *Routes) SignedOut(w http.ResponseWriter, r *http.Request) {
h.cfg.Render(w, r, "signedout", "", http.StatusOK)
}
@@ -153,11 +184,37 @@ func (h *Routes) ConfirmPost(w http.ResponseWriter, r *http.Request) {
localNavigationCookie(w, homeReturnCookie, "", -1)
h.bridge.ConfirmPost(w, r)
}
+
+// Signout ends the app session and clears the hand-off cookies. With Home
+// configured it then hands the same POST on to {Issuer}/signout, which ends
+// the id.* session and returns the browser to this origin's /signed-out (spec
+// shared-chrome §9.5). Ending only the app session left "Sign in" on the
+// signed-out page passing silently through the live 30-day id.* session.
+//
+// - 307, not 303, so the hop stays a POST. A GET sign-out is one any link
+// or <img> can trigger; id.* ignores GET.
+// - The browser sends "Origin: null" on the redirected POST, so id.* admits
+// it by Referer. This response sets the policy that makes Referer exactly
+// this origin. A page that itself sends Referrer-Policy: no-referrer still
+// strips it (no redirect can restore it), and its sign-out lands on id.*'s
+// "Finish signing out" page: recoverable, but a page with the account menu
+// should use strict-origin instead.
+// - FormPolicy already names the issuer in form-action, which browsers
+// enforce on the redirect target of a form submission. An app that
+// supplies its own policy must keep the issuer there.
+//
+// Without an issuer (never the case for Routes built by NewRoutes) it ends
+// here, as before.
func (h *Routes) Signout(w http.ResponseWriter, r *http.Request) {
h.cfg.SignOut(w, r)
for _, name := range []string{homeInitCookie, homeProofCookie, localLinkNavigationCookie, homeReturnCookie, "__Host-oficina-assertion-pending", "__Host-oficina-assertion-inbox"} {
localNavigationCookie(w, name, "", -1)
}
w.Header().Set("Cache-Control", "no-store")
- http.Redirect(w, r, "/signed-out", http.StatusSeeOther)
+ if h.cfg.Issuer == "" {
+ http.Redirect(w, r, "/signed-out", http.StatusSeeOther)
+ return
+ }
+ w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
+ http.Redirect(w, r, h.cfg.Issuer+"/signout", http.StatusTemporaryRedirect)
}
diff --git a/connect/routes_test.go b/connect/routes_test.go
index cbfbef7..2c7fc26 100644
--- a/connect/routes_test.go
+++ b/connect/routes_test.go
@@ -7,6 +7,7 @@ import (
"net/http/httptest"
"strings"
"testing"
+ "time"
)
func TestRouteGuardExemptsOnlyCallbackAndPreservesPolicy(t *testing.T) {
@@ -55,6 +56,9 @@ func TestRouteGuardExemptsOnlyCallbackAndPreservesPolicy(t *testing.T) {
t.Fatal("ambiguous local pending reached plugin")
}
}
+
+// TestSignoutEndsLocallyAndClearsHandoffPointers: with no issuer (the
+// standalone shape) sign-out ends here, as it always did.
func TestSignoutEndsLocallyAndClearsHandoffPointers(t *testing.T) {
revoked := false
h := &Routes{cfg: RoutesConfig{SignOut: func(http.ResponseWriter, *http.Request) { revoked = true }}}
@@ -63,6 +67,46 @@ func TestSignoutEndsLocallyAndClearsHandoffPointers(t *testing.T) {
if !revoked || w.Code != 303 || w.Header().Get("Location") != "/signed-out" {
t.Fatal("signout restarted Home exchange")
}
+ assertSignoutCleared(t, w)
+}
+
+// TestSignoutChainsToTheIssuer is spec shared-chrome §9.5: an app's Sign
+// out cleared only the app's session, so its "Sign in" passed silently
+// through the live 30-day id.* session. With Home configured it now ends
+// the app session and hands the same POST on to {Issuer}/signout, which
+// ends the id.* session and returns to this origin's /signed-out.
+//
+// 307, not 303, so the hop stays a POST: a GET sign-out is one any <img>
+// can trigger. The Referrer-Policy is set on the 307 itself because the
+// browser nulls Origin on the redirected POST and Referer, which this
+// policy makes exactly the app's origin, is the evidence id.* admits it
+// by. FormPolicy already names the issuer in form-action, which browsers
+// enforce on a form's redirect target.
+func TestSignoutChainsToTheIssuer(t *testing.T) {
+ revoked := false
+ h := &Routes{cfg: RoutesConfig{Origin: "https://blue.slides.oficina.works", Issuer: "https://id.oficina.works", SignOut: func(http.ResponseWriter, *http.Request) { revoked = true }}}
+ w := httptest.NewRecorder()
+ h.Signout(w, httptest.NewRequest("POST", "https://blue.slides.oficina.works/signout?next=https://evil.example", nil))
+ if !revoked {
+ t.Fatal("app session survived")
+ }
+ if w.Code != http.StatusTemporaryRedirect || w.Header().Get("Location") != "https://id.oficina.works/signout" {
+ t.Fatalf("sign-out not chained: %d %q", w.Code, w.Header().Get("Location"))
+ }
+ if w.Header().Get("Referrer-Policy") != "strict-origin-when-cross-origin" {
+ t.Fatal("chained hop may lose its only evidence", w.Header().Get("Referrer-Policy"))
+ }
+ assertSignoutCleared(t, w)
+ if !strings.Contains(FormPolicy(h.cfg.Issuer), "form-action 'self' https://id.oficina.works") {
+ t.Fatal("form-action would block the chained hop")
+ }
+}
+
+func assertSignoutCleared(t *testing.T, w *httptest.ResponseRecorder) {
+ t.Helper()
+ if w.Header().Get("Cache-Control") != "no-store" {
+ t.Fatal("sign-out cacheable")
+ }
if len(w.Result().Cookies()) != 6 {
t.Fatal("handoff pointers left behind")
}
@@ -73,6 +117,74 @@ func TestSignoutEndsLocallyAndClearsHandoffPointers(t *testing.T) {
}
}
+// TestSigninWhenAlreadySignedIn is spec shared-chrome §9.4: GET /signin
+// with a live session goes straight to the validated return_to, or /,
+// instead of starting another assertion exchange. Without a session, or
+// with an explicit local state (sent, err, force), nothing changes.
+func TestSigninWhenAlreadySignedIn(t *testing.T) {
+ _, n := navigationFixture(t)
+ locals := 0
+ h := &Routes{bridge: &Bridge{render: func(w http.ResponseWriter, r *http.Request, state, label string, status int) { w.WriteHeader(status) }}, navigation: n, cfg: RoutesConfig{Origin: navigationOrigin, SessionCookie: "__Host-rastrillo_session", ReadSession: func(r *http.Request) (*Session, error) {
+ c, err := r.Cookie("__Host-rastrillo_session")
+ if err != nil || (c.Value != "verified" && c.Value != "guest") {
+ return nil, nil
+ }
+ method := HomeMethod
+ if c.Value == "guest" {
+ method = "guest"
+ }
+ return &Session{Subject: "pr_live", Method: method, At: time.Now()}, nil
+ }}}
+ h.cfg.LocalSignin = func(w http.ResponseWriter, r *http.Request) { locals++; w.WriteHeader(299) }
+ live := &http.Cookie{Name: "__Host-rastrillo_session", Value: "verified"}
+ stale := &http.Cookie{Name: "__Host-rastrillo_session", Value: "revoked"}
+ for _, tc := range []struct {
+ query string
+ cookies []*http.Cookie
+ code int
+ want string
+ }{
+ {"?return_to=%2Fd%2F42%3Fview%3Dcompact", []*http.Cookie{live}, 303, "/d/42?view=compact"},
+ {"", []*http.Cookie{live}, 303, "/"},
+ {"?return_to=https%3A%2F%2Fevil.example%2F", []*http.Cookie{live}, 303, "/"},
+ {"?return_to=%2F%2Fevil.example", []*http.Cookie{live}, 303, "/"},
+ {"?return_to=%2Fsignin", []*http.Cookie{live}, 303, "/"},
+ {"?return_to=%2Fa&return_to=%2Fb", []*http.Cookie{live}, 303, "/"},
+ // No session, a stale one, or an ambiguous pair: start the exchange.
+ {"?return_to=%2Fd%2F42", nil, 303, handoff.StartPath + "?return_to=%2Fd%2F42"},
+ {"?return_to=%2Fd%2F42", []*http.Cookie{stale}, 303, handoff.StartPath + "?return_to=%2Fd%2F42"},
+ {"?return_to=%2Fd%2F42", []*http.Cookie{live, live}, 303, handoff.StartPath + "?return_to=%2Fd%2F42"},
+ // A Calls guest (an anonymous, per-meeting session) is not signed
+ // in for this purpose: it still starts the exchange, as before,
+ // rather than bouncing to a root that 404s for guests.
+ {"?return_to=%2Fd%2F42", []*http.Cookie{{Name: "__Host-rastrillo_session", Value: "guest"}}, 303, handoff.StartPath + "?return_to=%2Fd%2F42"},
+ {"", []*http.Cookie{{Name: "__Host-rastrillo_session", Value: "guest"}}, 303, handoff.StartPath + "?return_to=%2F"},
+ // Explicit local states still reach the local page.
+ {"?force=1", []*http.Cookie{live}, 299, ""},
+ {"?err=removed", []*http.Cookie{live}, 299, ""},
+ {"?sent=1", []*http.Cookie{live}, 299, ""},
+ } {
+ r := httptest.NewRequest("GET", navigationOrigin+"/signin"+tc.query, nil)
+ for _, c := range tc.cookies {
+ r.AddCookie(c)
+ }
+ w := httptest.NewRecorder()
+ h.Signin(w, r)
+ if w.Code != tc.code || w.Header().Get("Location") != tc.want {
+ t.Fatalf("%q with %d cookies: %d %q", tc.query, len(tc.cookies), w.Code, w.Header().Get("Location"))
+ }
+ if tc.code == 303 && w.Header().Get("Cache-Control") != "no-store" {
+ t.Fatalf("%q: cacheable", tc.query)
+ }
+ if tc.code == 303 && !strings.HasPrefix(tc.want, handoff.StartPath) && len(w.Result().Cookies()) != 0 {
+ t.Fatalf("%q: a signed-in redirect set a cookie", tc.query)
+ }
+ }
+ if locals != 3 {
+ t.Fatal("local states", locals)
+ }
+}
+
func TestStaleSessionStillRefusesAndOffersExplicitLocalRecovery(t *testing.T) {
f := newBridgeFixture(t)
templates := template.Must(template.ParseFS(Templates(), "templates/*.html"))
diff --git a/implementation/directory-runtime.md b/implementation/directory-runtime.md
index f97acd8..b8638cc 100644
--- a/implementation/directory-runtime.md
+++ b/implementation/directory-runtime.md
@@ -26,7 +26,7 @@ Mount GET /apps/docs and POST /apps/docs/team under the directory's existing exa
Before chooser handlers consult the session, reject duplicate actual session cookies for these two chooser routes. Do not change the existing assertion callback or global session flow in this slice. Constructor tests exercise the actual App.Handler and role routing so credential and legacy hosts cannot reach the chooser. Every chooser response, including errors and unknown-host refusal at the app boundary, remains no-store; use the narrow route boundary as needed without weakening shared host checks.
-Zero choices renders the reviewed empty page; one choice uses the existing303 GET redirect to the registered app's /auth/start with local return /. Multiple choices render the reviewed form. The successful POST response depends on separately reviewed continuation amendment95dcd682ac106ab725d9a85f141285709ab3659c: return200, immediate meta-refresh plus a plain fallback link to the revalidated stored destination. Preserve CSRF and the post-selection membership re-query. This avoids an unbounded outgoing CSP list; no wildcard or global form-action change. Do not implement or deploy the successful POST integration before that amendment is approved and its browser/fallback tests pass.
+Zero choices renders the reviewed empty page; one choice uses the existing303 GET redirect to the registered app's root / (amended 2026-09-23 from /auth/start with local return /; the successful POST continuation targets the same root). Multiple choices render the reviewed form. The successful POST response depends on separately reviewed continuation amendment95dcd682ac106ab725d9a85f141285709ab3659c: return200, immediate meta-refresh plus a plain fallback link to the revalidated stored destination. Preserve CSRF and the post-selection membership re-query. This avoids an unbounded outgoing CSP list; no wildcard or global form-action change. Do not implement or deploy the successful POST integration before that amendment is approved and its browser/fallback tests pass.
A source-backed browser check exposed a second redirect-chain case: an expired directory session on an otherwise same-origin chooser POST currently returns303 to /auth/start and then to the credential origin, where form-action self blocks it. Handle this inside the chooser subject helper after Select has applied its existing method/path checks and CSRF protection. When that helper finds no current session on POST, render the same200 continuation template to the fixed validated directory origin plus /auth/start?return_to=%2Fapps%2Fdocs. An anonymous GET still uses the existing303. Refactor only the common final rendering/header step; the successful-choice caller validates its stored Docs origin, while the reauthentication caller constructs its destination entirely from validated runtime origin and fixed constants. No submitted team, arbitrary URL, query parameter or resource path enters that destination. Both set no-store and no-referrer and keep a real plain fallback link. This starts authentication only; it does not retain the stale submitted selection or create a membership/session/proof. After sign-in the chooser re-queries current membership and the person selects again if several teams remain. Test real expired-session POST in Chromium/WebKit with scripts on/off, fallback navigation, fixed Home return and unchanged CSRF/unknown-host refusal; do not treat the old blocked behavior as accepted integration.
diff --git a/implementation/docs-team-chooser.md b/implementation/docs-team-chooser.md
index 3d370f3..cd69ccd 100644
--- a/implementation/docs-team-chooser.md
+++ b/implementation/docs-team-chooser.md
@@ -6,7 +6,7 @@ A directory App constructs the chooser with `newDocsChooser(store)`. Credential
Entry accepts no query parameters. It reads identity exclusively from sessions.Current and requires Home's opaque person-ref shape. Signed-out navigation redirects to the shared consumer with fixed return_to=/apps/docs; a test follows the actual credential issuer, callback, inbox and finish back to that route. No email, team selection, arbitrary return URL or document path is accepted as identity/navigation state.
-Zero live choices renders an honest empty state. One redirects to that validated stored origin's /auth/start with local return /. Several render ordinary POST buttons carrying only team sqid. Selection re-queries current memberships and compares the requested sqid; foreign, removed and unknown teams receive the same 404. Redirecting changes no session/cookie and grants no item access. Apps must still validate roster standing and individual grants after their own authentication. Two simultaneous tabs carry independent explicit POST selections.
+Zero live choices renders an honest empty state. One redirects to that validated stored origin's root, / (amended 2026-09-23, shared-chrome spec §6.2(a): it was /auth/start with local return /, which re-ran the exchange even with a live app session; the app's own guard now decides). A configured preview origin for the app takes precedence over team choices here too, redirecting to the preview's root without a directory session (§6.2(b)). Several render ordinary POST buttons carrying only team sqid. Selection re-queries current memberships and compares the requested sqid; foreign, removed and unknown teams receive the same 404. Redirecting changes no session/cookie and grants no item access. Apps must still validate roster standing and individual grants after their own authentication. Two simultaneous tabs carry independent explicit POST selections.
The existing Home renderer, layout, Estilo theme assets and Rastrillo attribute components are reused. Team names stay template-escaped; no script is added. Every controller response including redirects, method/query errors, CSRF rejection and storage failure is no-store. A failed read renders fixed text without stored names or origins. Runtime routing remains unchanged.
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/app_chooser_test.go b/internal/home/app_chooser_test.go
index 89d82d0..fc2bf1d 100644
--- a/internal/home/app_chooser_test.go
+++ b/internal/home/app_chooser_test.go
@@ -39,7 +39,7 @@ func TestNativeAppChoicesKeepTeamAndAppBoundaries(t *testing.T) {
t.Fatal("choice boundaries", w.Code, w.Body.String())
}
picked := f.request("POST", path+"/team", directoryOrigin, "team=A1", cookie)
- assertContinuationPage(t, picked, "https://alpha."+app+".oficina.works/auth/start?return_to=%2F")
+ assertContinuationPage(t, picked, "https://alpha."+app+".oficina.works/")
if _, err := f.p.directory.db.Writer().Exec(`DELETE FROM memberships WHERE team_id=? AND person_ref=?`, a, chooserPerson); err != nil {
t.Fatal(err)
}
@@ -47,7 +47,7 @@ func TestNativeAppChoicesKeepTeamAndAppBoundaries(t *testing.T) {
t.Fatal("stale team selection accepted", w.Code)
}
single := f.request("GET", path, "", "", cookie)
- if single.Code != 303 || single.Header().Get("Location") != "https://beta."+app+".oficina.works/auth/start?return_to=%2F" {
+ if single.Code != 303 || single.Header().Get("Location") != "https://beta."+app+".oficina.works/" {
t.Fatal("one-team shortcut", single.Code, single.Header())
}
if w := f.request("POST", path+"/team", "https://attacker.example", "team=B2", cookie); w.Code != 403 {
diff --git a/internal/home/directory_catalog_test.go b/internal/home/directory_catalog_test.go
index 99c6693..02c17c8 100644
--- a/internal/home/directory_catalog_test.go
+++ b/internal/home/directory_catalog_test.go
@@ -78,9 +78,12 @@ func TestDirectoryCatalogPreviewKeepsDocsChooserPolicy(t *testing.T) {
t.Fatal("preview navigation incorrect", page.Code, body)
}
noStore(t, page)
+ // Spec shared-chrome §6.2(b): /apps/docs follows the preview too, to
+ // its root, rather than ending on "No teams available" (this test
+ // asserted the chooser until round 2).
entry := f.request("GET", docsEntryPath, "", "", cookie)
- if entry.Code != 200 || entry.Header().Get("Location") != "" || !strings.Contains(entry.Body.String(), "This account has no team with Docs available.") {
- t.Fatal("preview changed team entry", entry.Code)
+ if entry.Code != 303 || entry.Header().Get("Location") != "https://overnight-docs.bds.oncarlos.com/" {
+ t.Fatal("preview entry", entry.Code, entry.Header().Get("Location"))
}
cfg.docsPreviewOrigin = "https://evil.example/path"
if _, err := NewRuntime(cfg, f.p.directory.db, quietLogger()); err != errExchangeConfig {
@@ -177,3 +180,44 @@ func TestConfiguredAppsLaunchOnlyOperatorDestinations(t *testing.T) {
t.Fatal("constructor bypassed app manifest validation", err)
}
}
+
+// TestAppEntryFollowsPreviewOrigins is spec shared-chrome §6.2(b): the
+// switcher's tiles link to /apps/<slug>, so in a preview deployment that
+// entry must reach the preview, as the catalog's own link does, instead
+// of "No teams available". It goes to the preview's root, like the
+// single-team redirect: the app's own guard decides whether to sign in.
+// A preview is an operator choice, not a team route, so no directory
+// session or team is needed to follow it.
+func TestAppEntryFollowsPreviewOrigins(t *testing.T) {
+ f := newMountedChooser(t)
+ cfg := *f.p.directory.runtime
+ cfg.docsPreviewOrigin = "https://overnight-docs.bds.oncarlos.com"
+ cfg.appPreviewOrigins = map[string]string{"sheets": "https://sheets.example", "calls": "https://calls-preview.example"}
+ app, err := NewRuntime(cfg, f.p.directory.db, quietLogger())
+ if err != nil {
+ t.Fatal(err)
+ }
+ f.handler = app.Router()
+ cookie := f.session(t, chooserPerson)
+ for path, want := range map[string]string{
+ "/apps/docs": "https://overnight-docs.bds.oncarlos.com/",
+ "/apps/sheets": "https://sheets.example/",
+ "/apps/calls": "https://calls-preview.example/",
+ } {
+ for _, c := range []*http.Cookie{cookie, nil} {
+ r := f.request("GET", path, "", "", c)
+ noStore(t, r)
+ if r.Code != 303 || r.Header().Get("Location") != want {
+ t.Fatal(path, c != nil, r.Code, r.Header().Get("Location"))
+ }
+ }
+ // The entry still refuses any query, so nothing rides the hop.
+ if r := f.request("GET", path+"?return_to=/secret", "", "", cookie); r.Code != 404 {
+ t.Fatal(path, "query accepted", r.Code)
+ }
+ }
+ // An app without a preview keeps its team entry.
+ if r := f.request("GET", "/apps/slides", "", "", cookie); r.Code != 200 || !strings.Contains(r.Body.String(), "No teams available") {
+ t.Fatal("non-preview app changed", r.Code, r.Header().Get("Location"))
+ }
+}
diff --git a/internal/home/directory_runtime_test.go b/internal/home/directory_runtime_test.go
index ff240d7..f4e4d20 100644
--- a/internal/home/directory_runtime_test.go
+++ b/internal/home/directory_runtime_test.go
@@ -25,7 +25,7 @@ func TestDirectoryRuntimeMountUsesCanonicalRegistryAndCurrentMembership(t *testi
id := f.team(t, "A1", "alpha", "Alpha", chooserPerson)
f.team(t, "B2", "beta", "Foreign team", chooserOther)
r = f.request("GET", docsEntryPath, "", "", cookie)
- if r.Code != 303 || r.Header().Get("Location") != "https://alpha.docs.oficina.works/auth/start?return_to=%2F" {
+ if r.Code != 303 || r.Header().Get("Location") != "https://alpha.docs.oficina.works/" {
t.Fatal(r.Code, r.Header())
}
noStore(t, r)
@@ -148,7 +148,7 @@ func TestDirectoryRuntimeExpiredChoiceReturnsToFreshTeamSelection(t *testing.T)
req.Header.Set("Sec-Fetch-Site", metadata)
rec := httptest.NewRecorder()
f.handler.ServeHTTP(rec, req)
- assertDocsContinuation(t, rec, "https://beta.docs.oficina.works/auth/start?return_to=%2F")
+ assertDocsContinuation(t, rec, "https://beta.docs.oficina.works/")
}
if _, err := f.p.directory.db.Writer().Exec(`UPDATE sessions SET expires_at='2000-01-01T00:00:00Z'`); err != nil {
t.Fatal(err)
diff --git a/internal/home/docs_chooser.go b/internal/home/docs_chooser.go
index bfaf180..06672bd 100644
--- a/internal/home/docs_chooser.go
+++ b/internal/home/docs_chooser.go
@@ -64,6 +64,16 @@ func (c *docsChooser) Entry(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
return
}
+ // A preview deployment replaces the team routes for this app, as it
+ // does on the catalog page, so the entry follows it too (spec
+ // shared-chrome §6.2(b)): otherwise a link here from an app's switcher
+ // ends on "No teams available". A preview is an operator choice, not a
+ // team, so it needs no directory session; it goes to the preview's
+ // root, like the single-team redirect below.
+ if preview := c.previewOrigin(); preview != "" {
+ http.Redirect(w, r, preview+"/", http.StatusSeeOther)
+ return
+ }
subject, ok := c.subject(w, r)
if !ok {
return
@@ -150,10 +160,25 @@ func (c *docsChooser) subject(w http.ResponseWriter, r *http.Request) (string, b
func (c *docsChooser) unavailable(w http.ResponseWriter) {
c.app.render(w, http.StatusServiceUnavailable, "docs_unavailable.html", nil)
}
+
+// previewOrigin is the operator's preview deployment for this app, or "".
+// Both settings were validated at construction (validAppPreviews).
+func (c *docsChooser) previewOrigin() string {
+ if c.appID == "docs" && c.app.runtime.docsPreviewOrigin != "" {
+ return c.app.runtime.docsPreviewOrigin
+ }
+ return c.app.runtime.appPreviewOrigins[c.appID]
+}
+
+// redirect and continueToDocs send the browser to the chosen team's app
+// root, not to its /auth/start. The app's own guard starts an assertion
+// exchange only when it has no session of its own, so a switch into an
+// app you are already signed in to costs no exchange and no interstitial
+// (spec shared-chrome §6.2(a)).
func (c *docsChooser) redirect(w http.ResponseWriter, r *http.Request, choice directorydata.Choice) {
// The directorydata store has already applied the configured destination
// policy. Generic entry carries only the app root, never a document path.
- http.Redirect(w, r, choice.Origin+handoff.StartPath+"?"+url.Values{"return_to": {"/"}}.Encode(), http.StatusSeeOther)
+ http.Redirect(w, r, choice.Origin+"/", http.StatusSeeOther)
}
func (c *docsChooser) continueToDocs(w http.ResponseWriter, choice directorydata.Choice) {
@@ -161,11 +186,10 @@ func (c *docsChooser) continueToDocs(w http.ResponseWriter, choice directorydata
c.unavailable(w)
return
}
- destination := choice.Origin + handoff.StartPath + "?" + url.Values{"return_to": {"/"}}.Encode()
- c.renderContinuation(w, destination)
+ c.renderContinuation(w, choice.Origin+"/")
}
-// Callers supply either a validated stored app origin plus the fixed app path,
+// Callers supply either a validated stored app origin plus the app root,
// or the validated directory origin plus the fixed sign-in continuation path.
func (c *docsChooser) renderContinuation(w http.ResponseWriter, destination string) {
c.app.renderContinuation(w, destination, "Continue to "+c.appName)
diff --git a/internal/home/docs_chooser_test.go b/internal/home/docs_chooser_test.go
index 3bfc673..f9e4b94 100644
--- a/internal/home/docs_chooser_test.go
+++ b/internal/home/docs_chooser_test.go
@@ -127,7 +127,7 @@ func TestDocsChooserZeroOneManyAndRemovedChoice(t *testing.T) {
a := f.team(t, "A1", "alpha", "Alpha", chooserPerson)
r = f.request("GET", docsEntryPath, "", "", cookie)
noStore(t, r)
- if r.Code != 303 || r.Header().Get("Location") != "https://alpha.docs.oficina.works/auth/start?return_to=%2F" {
+ if r.Code != 303 || r.Header().Get("Location") != "https://alpha.docs.oficina.works/" {
t.Fatal(r.Code, r.Header())
}
f.team(t, "B2", "beta", `<script>alert("team")</script>`, chooserPerson)
@@ -144,7 +144,7 @@ func TestDocsChooserZeroOneManyAndRemovedChoice(t *testing.T) {
}
r = f.request("POST", docsChoicePath, directoryOrigin, "team=B2", cookie)
noStore(t, r)
- assertDocsContinuation(t, r, "https://beta.docs.oficina.works/auth/start?return_to=%2F")
+ assertDocsContinuation(t, r, "https://beta.docs.oficina.works/")
if _, err := f.p.directory.db.Writer().Exec(`DELETE FROM memberships WHERE team_id=? AND person_ref=?`, a, chooserPerson); err != nil {
t.Fatal(err)
}
@@ -156,7 +156,7 @@ func TestDocsChooserZeroOneManyAndRemovedChoice(t *testing.T) {
}
}
r = f.request("GET", docsEntryPath, "", "", cookie)
- if r.Header().Get("Location") != "https://beta.docs.oficina.works/auth/start?return_to=%2F" {
+ if r.Header().Get("Location") != "https://beta.docs.oficina.works/" {
t.Fatal(r.Header())
}
}
diff --git a/internal/home/docs_continue_test.go b/internal/home/docs_continue_test.go
index 17aaae3..e8f5589 100644
--- a/internal/home/docs_continue_test.go
+++ b/internal/home/docs_continue_test.go
@@ -74,7 +74,7 @@ func TestDocsContinueCanonicalResponseBoundary(t *testing.T) {
for _, origin := range []string{"https://alpha.docs.oficina.works", "https://approved.fixture.example"} {
r := httptest.NewRecorder()
c.continueToDocs(r, directorydata.Choice{Origin: origin})
- assertDocsContinuation(t, r, origin+"/auth/start?return_to=%2F")
+ assertDocsContinuation(t, r, origin+"/")
}
for _, origin := range []string{"", "http://alpha.example", "https://Alpha.example", "https://alpha.example/", "https://alpha.example:443", "https://user@alpha.example", "https://alpha.example?x=1", "https://alpha.example#f", "https://alpha.example?", "https://alpha.example\n", " https://alpha.example", "https://alpha.example\";url=https://bad.example", "javascript:alert(1)", "//alpha.example", "https://[::1]", "https://alpha.example/%22"} {
r := httptest.NewRecorder()
@@ -119,7 +119,7 @@ func TestDocsContinueRequeriesCurrentOrigin(t *testing.T) {
if _, err := f.p.directory.db.Writer().Exec(`UPDATE team_instances SET origin='https://changed.docs.oficina.works' WHERE team_id=?`, id); err != nil {
t.Fatal(err)
}
- assertDocsContinuation(t, f.request("POST", docsChoicePath, directoryOrigin, "team=A1", cookie), "https://changed.docs.oficina.works/auth/start?return_to=%2F")
+ assertDocsContinuation(t, f.request("POST", docsChoicePath, directoryOrigin, "team=A1", cookie), "https://changed.docs.oficina.works/")
}
func TestDocsContinueExactHostAndExpiredSession(t *testing.T) {
f := newChooserFixture(t)
diff --git a/internal/home/handoff_reveal_test.go b/internal/home/handoff_reveal_test.go
new file mode 100644
index 0000000..28bf21c
--- /dev/null
+++ b/internal/home/handoff_reveal_test.go
@@ -0,0 +1,47 @@
+package home
+
+import (
+ "bytes"
+ "regexp"
+ "strings"
+ "testing"
+)
+
+// TestHandoffInterstitialStaysHiddenWhileItAutoSubmits is spec
+// shared-chrome §9.3: the assertion hand-off page submits itself, so its
+// fallback copy is hidden for 1.5 s and appears only if the submit
+// stalls. Without it every sign-in, and every app switch that needs an
+// exchange, flashes "Continue to Oficina" mid-hop. With scripts off
+// nothing submits, so a <noscript> rule shows the fallback at once.
+func TestHandoffInterstitialStaysHiddenWhileItAutoSubmits(t *testing.T) {
+ app := newTestApp(t)
+ var buf bytes.Buffer
+ if err := app.renderer.pages["assertion.html"].ExecuteTemplate(&buf, "layout", map[string]any{"Action": "https://blue.docs.oficina.works/auth/assertion", "Token": "sample-token"}); err != nil {
+ t.Fatal(err)
+ }
+ page := buf.String()
+ head, body, ok := strings.Cut(page, "</head>")
+ if !ok {
+ t.Fatal("no head")
+ }
+ compact := regexp.MustCompile(`\s+`).ReplaceAllString(head, "")
+ for _, want := range []string{
+ "[estilo-handoff]{animation:estilo-reveal0s1.5sboth;}",
+ "@keyframesestilo-reveal{from{visibility:hidden}to{visibility:visible}}",
+ "<noscript><style>[estilo-handoff]{animation:none;}</style></noscript>",
+ } {
+ if !strings.Contains(compact, want) {
+ t.Fatalf("head lacks %q:\n%s", want, head)
+ }
+ }
+ // Everything the person could read or press is inside the hidden
+ // wrapper, and the form the script submits is still the one there.
+ main := body[strings.Index(body, "<main"):strings.Index(body, "</main>")]
+ inner := strings.TrimSpace(main[strings.Index(main, ">")+1:])
+ if !strings.HasPrefix(inner, "<div estilo-handoff>") || !strings.HasSuffix(inner, "</div>") {
+ t.Fatalf("content not wrapped:\n%s", main)
+ }
+ if !strings.Contains(inner, `data-assertion-handoff`) || !strings.Contains(inner, `<button type="submit" rst-btn="primary">Continue</button>`) {
+ t.Fatalf("hand-off form changed:\n%s", inner)
+ }
+}
diff --git a/internal/home/signout.go b/internal/home/signout.go
index 410b216..63bf16c 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,159 @@ 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, verdict := a.signoutReturn(r)
+ switch verdict {
+ case signoutRefused:
+ a.render(w, http.StatusForbidden, "signout_confirm.html", nil)
+ return
+ case signoutConfirm:
+ // Admitted, but the id.* cookie did not ride this hop, so there is
+ // no row to revoke yet. Clearing the cookie now would leave the
+ // row alive behind it; keep it, and let the same-origin button
+ // (which does carry the cookie) end the session.
+ a.render(w, http.StatusOK, "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.
+//
+// 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 directory's hand-off. Chromium sends
+// 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 preview is admitted but not signed out on the spot (signoutConfirm).
+// Its hop is cross-site, and the id.* session cookie is SameSite=Lax, so
+// the POST arrives without it: there is no row to revoke, and clearing
+// the cookie in the browser would leave a live row behind a copy of it.
+// It gets the "Finish signing out" page, whose same-origin POST carries
+// the cookie and revokes the row (the #74 review, finding 1).
+//
+// 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.
+type signoutVerdict int
+
+const (
+ signoutRefused signoutVerdict = iota
+ signoutAdmitted
+ signoutConfirm
+)
+
+func (a *App) signoutReturn(r *http.Request) (string, signoutVerdict) {
if csrf.SameOrigin(r, a.origins.Credential) {
- return true
+ return a.origins.Directory, signoutAdmitted
}
- 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 "", signoutRefused
+ }
+ claimed = ref.Scheme + "://" + ref.Host
+ }
+ matched, ok := a.consumerOrigin(claimed)
+ if !ok {
+ return "", signoutRefused
}
+ suite := a.onSuiteSite(matched)
switch r.Header.Get("Sec-Fetch-Site") {
- case "", "same-site":
+ case "":
+ case "same-site":
+ if !suite {
+ return "", signoutRefused
+ }
+ case "cross-site":
+ if suite {
+ return "", signoutRefused
+ }
default:
+ return "", signoutRefused
+ }
+ if !suite {
+ return matched, signoutConfirm
+ }
+ return matched, signoutAdmitted
+}
+
+// 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's hostname is the directory's host
+// or under it. 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. A site has no port,
+// so the hostname is compared, never the host: a registered team on :8443
+// is still same-site (the #74 review, finding 2). The caller keeps the
+// full origin, port included, for the redirect.
+func (a *App) onSuiteSite(origin string) bool {
+ u, err := url.Parse(origin)
+ if err != nil {
return false
}
- ref, err := url.Parse(r.Header.Get("Referer"))
- return err == nil && ref.User == nil && ref.Scheme+"://"+ref.Host == a.origins.Directory
+ host := u.Hostname()
+ return host == a.directoryHost || strings.HasSuffix(host, "."+a.directoryHost)
}
diff --git a/internal/home/signout_test.go b/internal/home/signout_test.go
index f9a0050..434724d 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,223 @@ 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},
+ {"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)
+ }
+}
+
+// TestCredentialSignOutFromAPreviewConfirmsFirst: a preview is registered
+// but off the suite's site, so its hop is cross-site, and the id.* cookie
+// (SameSite=Lax) does not ride a cross-site POST. Answering it with a
+// sign-out would clear the cookie in the browser but leave the session
+// row alive, so a copy of the cookie would still sign in. So the hop gets
+// the "Finish signing out" page, the cookie is kept, and its same-origin
+// button, which does carry the cookie, revokes the row.
+func TestCredentialSignOutFromAPreviewConfirmsFirst(t *testing.T) {
+ for _, c := range []struct{ origin, site, referer string }{
+ {previewOrigin, "cross-site", ""},
+ {"null", "cross-site", previewOrigin + "/"},
+ {"", "", previewOrigin + "/"},
+ } {
+ p := withPreviewConsumer(newExchangePair(t))
+ credential, _ := signedInPair(t, p)
+ // The browser sends no cookie on this hop; send none.
+ rec := signoutHop(p, c.origin, c.site, c.referer)
+ if rec.Code != http.StatusOK || !isFinishSigningOut(rec) || clearsCookie(rec, credential.Name) {
+ t.Fatalf("%+v: %d, branded=%v, cleared=%v", c, rec.Code, isFinishSigningOut(rec), clearsCookie(rec, credential.Name))
+ }
+ if !resumeMints(t, p, credential) {
+ t.Fatalf("%+v: the session ended before it was confirmed", c)
+ }
+ r := httptest.NewRequest("POST", Origin+"/signout", nil)
+ r.Header.Set("Origin", Origin)
+ r.Header.Set("Sec-Fetch-Site", "same-origin")
+ r.AddCookie(credential)
+ confirm := httptest.NewRecorder()
+ p.credential.Router().ServeHTTP(confirm, r)
+ if confirm.Code != http.StatusSeeOther || confirm.Header().Get("Location") != directoryOrigin+"/signed-out" || !clearsCookie(confirm, credential.Name) {
+ t.Fatalf("%+v: confirm %d %q", c, confirm.Code, confirm.Header().Get("Location"))
+ }
+ // The row is revoked, so a copy of the old cookie is refused.
+ if resumeMints(t, p, credential) {
+ t.Fatalf("%+v: a copy of the cookie still signs in after confirming", c)
+ }
+ }
+}
+
+// TestCredentialSignOutKeepsASuiteOriginsPort: a registered suite origin
+// on a non-default port is same-site. The site is judged by hostname,
+// and the browser goes back to the full origin, port included.
+func TestCredentialSignOutKeepsASuiteOriginsPort(t *testing.T) {
+ const ported = "https://blue.sheets.oficina.works:8443"
+ for _, c := range []struct{ origin, site, referer string }{
+ {ported, "same-site", ""},
+ {"null", "same-site", ported + "/"},
+ } {
+ p := newExchangePair(t)
+ p.credential.runtime.consumers["team_sheets_ported"] = ported
+ credential, _ := signedInPair(t, p)
+ rec := signoutHop(p, c.origin, c.site, c.referer, credential)
+ if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != ported+"/signed-out" {
+ t.Fatalf("%+v: %d %q", c, rec.Code, rec.Header().Get("Location"))
+ }
+ if resumeMints(t, p, credential) {
+ t.Fatalf("%+v: session survived", c)
+ }
+ }
+ // Still the site rule: that ported suite origin arriving cross-site is refused.
+ p := newExchangePair(t)
+ p.credential.runtime.consumers["team_sheets_ported"] = ported
+ credential, _ := signedInPair(t, p)
+ if rec := signoutHop(p, ported, "cross-site", "", credential); rec.Code != http.StatusForbidden {
+ t.Fatal("ported suite origin admitted cross-site", rec.Code)
+ }
+}
diff --git a/internal/home/templates/assertion.html b/internal/home/templates/assertion.html
index c70e695..6b1e5e6 100644
--- a/internal/home/templates/assertion.html
+++ b/internal/home/templates/assertion.html
@@ -1,10 +1,24 @@
{{define "title"}}Continue to Oficina · Oficina{{end}}
+{{define "head"}}
+{{/* The page submits itself (static/assertion.js), so its fallback copy
+ stays hidden for 1.5 s and shows only if the submit stalls. With
+ scripts off nothing submits, so noscript shows it at once. Spec
+ shared-chrome §9.3; the rule moves to estilo chrome.css when the
+ id.* pages adopt the shared chrome. */}}
+<style>
+[estilo-handoff] { animation: estilo-reveal 0s 1.5s both; }
+@keyframes estilo-reveal { from { visibility: hidden } to { visibility: visible } }
+</style>
+<noscript><style>[estilo-handoff] { animation: none; }</style></noscript>
+{{end}}
{{define "content"}}
+<div estilo-handoff>
<h1>Continue to Oficina</h1>
<p>Select Continue if this page does not move on.</p>
<form method="post" action="{{.Action}}" rst-form data-assertion-handoff>
<input type="hidden" name="assertion" value="{{.Token}}">
<button type="submit" rst-btn="primary">Continue</button>
</form>
+</div>
{{end}}
{{define "scripts"}}<script src="/static/assertion.js" defer></script>{{end}}
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}}