oficina / home Public

connect: chain Sign out to id.*, skip sign-in when signed in, carry Directory

Routes.Signout cleared only the app session, so the signed-out page's
Sign in passed silently through the live id.* session. With an issuer it
now 307s the POST on to {Issuer}/signout, setting Referrer-Policy
strict-origin-when-cross-origin on the hop because Referer is the
evidence id.* admits it by (spec shared-chrome §9.5).

Routes.Signin with a live session now 303s to the validated return_to or
/ rather than starting another exchange (§9.4).

Credentials.Directory carries the optional hub directory origin from
HOME_DIRECTORY_ORIGIN, validated by Home's originHost rules, for the app
switcher (§6.2).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 60dd5e5565c1d34e2aa97f1b83e2d66fb46d8680 parent 385b858
5 files changed, +237 −2
  • connect/README.md +12 −0
  • connect/config.go +42 −1
  • connect/config_test.go +36 −0
  • connect/routes.go +45 −1
  • connect/routes_test.go +102 −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/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..9e95ebe 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,16 @@ 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 app session: exactly one session cookie, which
+// ReadSession resolves. An ambiguous pair or a revoked cookie is not one.
+func (h *Routes) signedIn(r *http.Request) bool {
+ if cookieCount(r, h.cfg.SessionCookie) != 1 {
+ return false
+ }
+ s, err := h.cfg.ReadSession(r)
+ return err == nil && s != nil && s.Subject != ""
+}
+
func (h *Routes) SignedOut(w http.ResponseWriter, r *http.Request) {
h.cfg.Render(w, r, "signedout", "", http.StatusOK)
}
@@ -153,11 +171,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..48899d9 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,64 @@ 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) {
+ if c, err := r.Cookie("__Host-rastrillo_session"); err != nil || c.Value != "verified" {
+ return nil, nil
+ }
+ return &Session{Subject: "pr_live", Method: HomeMethod, 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"},
+ // 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"))