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>
5 files changed,
+237
−2
connect/README.md+12 −0connect/config.go+42 −1connect/config_test.go+36 −0connect/routes.go+45 −1connect/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")) |