oficina / home Public

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>
Paul Campbell pushed by paul@keymail.dev 6083b70584290f3cc80c1238a8bd6561afc2ebf4 parent ce2a5a4
4 files changed, +290 −51
  • internal/home/app.go +4 −2
  • internal/home/signout.go +114 −44
  • internal/home/signout_test.go +162 −5
  • 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:
	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}}