Merge remote-tracking branch 'origin/main' into chore/door-copy
# Conflicts: # internal/docs/render.go
6 files changed,
+88
−8
internal/docs/home_router_integration_test.go+1 −1internal/docs/home_routes.go+20 −4internal/docs/home_signout_chain_test.go+62 −0internal/docs/native_stream_test.go+3 −1internal/docs/team_router_test.go+1 −1internal/docs/team_stream_test.go+1 −1
Intent
diff --git a/internal/docs/home_router_integration_test.go b/internal/docs/home_router_integration_test.go| index 3d8fb31..6f83458 100644 |
| --- a/internal/docs/home_router_integration_test.go |
| +++ b/internal/docs/home_router_integration_test.go |
| @@ -279,7 +279,7 @@ func TestHomeRouterExistingAccountRoundTripPreservesOwnedAndSharedContent(t *tes |
| if rec := b.do("GET", resource, ""); rec.Code != 200 { |
| t.Fatal("linked local account cannot read original document", rec.Code) |
| } |
| - routerRedirect(t, b.do("POST", "/signout", ""), "/signed-out") |
| + signoutChained(t, b.do("POST", "/signout", "")) |
| signedOut := b.do("GET", "/signed-out", "") |
| if signedOut.Code != http.StatusOK || signedOut.Header().Get("Location") != "" || !strings.Contains(signedOut.Body.String(), `href="/signin"`) { |
| t.Fatal("signout automatically resumed Home authentication") |
diff --git a/internal/docs/home_routes.go b/internal/docs/home_routes.go| index 188a490..496c626 100644 |
| --- a/internal/docs/home_routes.go |
| +++ b/internal/docs/home_routes.go |
| @@ -21,6 +21,7 @@ type homeRoutes struct { |
| navigation *localLinkNavigation |
| handlers *handlers |
| formPolicy string |
| + issuer string |
| } |
| func newHomeRoutes(settings *homeSettings, origin string, db *sql.DB, auth *rauth.Auth, h *handlers) (*homeRoutes, error) { |
| @@ -37,7 +38,7 @@ func newHomeRoutes(settings *homeSettings, origin string, db *sql.DB, auth *raut |
| return nil, errHomeConfig |
| } |
| b.returnPath = navigation.returnPath |
| - return &homeRoutes{bridge: b, navigation: navigation, handlers: h, formPolicy: homeFormPolicy(settings.issuer)}, nil |
| + return &homeRoutes{bridge: b, navigation: navigation, handlers: h, formPolicy: homeFormPolicy(settings.issuer), issuer: settings.issuer}, nil |
| } |
| func cookieCount(r *http.Request, name string) int { |
| @@ -174,13 +175,28 @@ func (h *handlers) homeLinkLabel(r *http.Request) string { |
| return rastrillo.T(r, "docs.home.menu_action") |
| } |
| -// Docs sign-out stops here even while Home retains its own session. Returning |
| -// directly to /signin would immediately start another Home exchange. |
| +// signout ends the Docs session and clears the hand-off cookies, then hands |
| +// the same POST on to {issuer}/signout, which ends the id.* session too and |
| +// returns the browser to this origin's /signed-out (spec shared-chrome §9.5; |
| +// connect.Routes.Signout does the same for every other app). Ending only the |
| +// Docs 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. id.* ignores a GET sign-out, |
| +// because any link or <img> can trigger one. |
| +// - The browser sends "Origin: null" on the redirected POST, so id.* admits |
| +// it by Referer. strict-origin-when-cross-origin makes the Referer |
| +// exactly this origin, which id.* checks against HOME_CONSUMERS. |
| +// - homeFormPolicy already names the issuer in form-action, which browsers |
| +// enforce on the redirect target of a form submission. |
| +// |
| +// /signed-out itself still never starts another exchange. |
| func (h *homeRoutes) signout(w http.ResponseWriter, r *http.Request) { |
| h.bridge.sessions.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) |
| + w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin") |
| + http.Redirect(w, r, h.issuer+"/signout", http.StatusTemporaryRedirect) |
| } |
diff --git a/internal/docs/home_signout_chain_test.go b/internal/docs/home_signout_chain_test.go| new file mode 100644 |
| index 0000000..7485f0e |
| --- /dev/null |
| +++ b/internal/docs/home_signout_chain_test.go |
| @@ -0,0 +1,62 @@ |
| +package docs |
| + |
| +import ( |
| + "net/http" |
| + "net/http/httptest" |
| + "testing" |
| +) |
| + |
| +// TestHomeSignoutChainsToIssuer: in Home mode, Docs' sign-out ends the Docs |
| +// session and then hands the same POST on to {issuer}/signout, so the id.* |
| +// session ends too and the next "Sign in" asks again (spec shared-chrome |
| +// §9.5; the same chain connect.Routes.Signout gives every other app). |
| +// Ending only the Docs session left "Sign in" on the signed-out page |
| +// passing silently through the live id.* session. |
| +// |
| +// 307, not 303, so the hop stays a POST: id.* ignores a GET sign-out. The |
| +// browser sends "Origin: null" on the redirected POST, so id.* admits it by |
| +// Referer, which strict-origin-when-cross-origin makes exactly this origin. |
| +func TestHomeSignoutChainsToIssuer(t *testing.T) { |
| + f := newRouterHomeFixture(t) |
| + b := f.browser() |
| + b.localSignin(t, "alice@example.com") |
| + cookie := b.cookies[f.sessions.CookieName()] |
| + if cookie == nil { |
| + t.Fatal("local sign-in set no session") |
| + } |
| + rec := b.do("POST", "/signout", "") |
| + if rec.Code != http.StatusTemporaryRedirect || rec.Header().Get("Location") != bridgeIssuer+"/signout" { |
| + t.Fatalf("POST /signout: status %d Location %q, want 307 to %s/signout", rec.Code, rec.Header().Get("Location"), bridgeIssuer) |
| + } |
| + if got := rec.Header().Get("Referrer-Policy"); got != "strict-origin-when-cross-origin" { |
| + t.Fatalf("Referrer-Policy %q: id.* admits the null-Origin hop by Referer", got) |
| + } |
| + if got := rec.Header().Get("Cache-Control"); got != "no-store" { |
| + t.Fatalf("Cache-Control %q, want no-store", got) |
| + } |
| + if b.cookies[f.sessions.CookieName()] != nil { |
| + t.Fatal("sign-out kept the Docs session cookie") |
| + } |
| + old := httptest.NewRequest("GET", bridgeOrigin+"/", nil) |
| + old.AddCookie(cookie) |
| + oldRec := httptest.NewRecorder() |
| + f.app.Handler().ServeHTTP(oldRec, old) |
| + if oldRec.Code != http.StatusSeeOther { |
| + t.Fatalf("revoked session still served / (status %d)", oldRec.Code) |
| + } |
| + // id.* returns the browser to /signed-out, which must still render and |
| + // must not start another exchange by itself. |
| + signedOut := b.do("GET", "/signed-out", "") |
| + if signedOut.Code != http.StatusOK || signedOut.Header().Get("Location") != "" { |
| + t.Fatalf("GET /signed-out: status %d Location %q", signedOut.Code, signedOut.Header().Get("Location")) |
| + } |
| +} |
| + |
| +// signoutChained asserts the Home-mode sign-out response: a 307 to the |
| +// issuer's /signout. The browser then returns to /signed-out. |
| +func signoutChained(t *testing.T, rec *httptest.ResponseRecorder) { |
| + t.Helper() |
| + if rec.Code != http.StatusTemporaryRedirect || rec.Header().Get("Location") != bridgeIssuer+"/signout" { |
| + t.Fatalf("POST /signout: status %d Location %q, want 307 to %s/signout", rec.Code, rec.Header().Get("Location"), bridgeIssuer) |
| + } |
| +} |
diff --git a/internal/docs/native_stream_test.go b/internal/docs/native_stream_test.go| index d65e355..b8cfad6 100644 |
| --- a/internal/docs/native_stream_test.go |
| +++ b/internal/docs/native_stream_test.go |
| @@ -62,7 +62,9 @@ func assertNativeStreamRevoked(t *testing.T, app *App, address string, authentic |
| } |
| signedOut := httptest.NewRecorder() |
| app.Handler().ServeHTTP(signedOut, signout) |
| - if signedOut.Code != http.StatusSeeOther { |
| + // 303 to /signed-out standalone; 307 on to the issuer's /signout in |
| + // Home mode (homeRoutes.signout). Both have ended the Docs session. |
| + if signedOut.Code != http.StatusSeeOther && signedOut.Code != http.StatusTemporaryRedirect { |
| t.Fatalf("signout failed: %d", signedOut.Code) |
| } |
| closed := make(chan error, 1) |
diff --git a/internal/docs/team_router_test.go b/internal/docs/team_router_test.go| index 851939e..89d88ab 100644 |
| --- a/internal/docs/team_router_test.go |
| +++ b/internal/docs/team_router_test.go |
| @@ -243,7 +243,7 @@ func TestTeamRouterLocalLinkMembershipRemovalAndIndependentGrant(t *testing.T) { |
| if code := b.update(f.shared, validUpdate(t)); code != 204 { |
| t.Fatal("writer grant depended on roster availability", code) |
| } |
| - routerRedirect(t, b.do("POST", "/signout", ""), "/signed-out") |
| + signoutChained(t, b.do("POST", "/signout", "")) |
| nonce := b.start(t, "/auth/start?return_to=%2F", "") |
| authTime := time.Now().Add(-2 * time.Hour).Truncate(time.Second) |
| routerRedirect(t, b.exchange(t, nonce, teamSubject('a'), authTime), "/") |
diff --git a/internal/docs/team_stream_test.go b/internal/docs/team_stream_test.go| index 13bfea1..86d678a 100644 |
| --- a/internal/docs/team_stream_test.go |
| +++ b/internal/docs/team_stream_test.go |
| @@ -139,7 +139,7 @@ func TestTeamStreamGuestGrantAndActualSessionRevocation(t *testing.T) { |
| if reason == "grant" { |
| routerRedirect(t, owner.do("POST", "/d/"+f.shared.Sqid+"/share/revoke", url.Values{"address": {"alice@example.com"}}.Encode()), "/d/"+f.shared.Sqid+"/share") |
| } else { |
| - routerRedirect(t, guest.do("POST", "/signout", ""), "/signed-out") |
| + signoutChained(t, guest.do("POST", "/signout", "")) |
| } |
| done := make(chan struct{}) |
| go func() { io.Copy(io.Discard, res.Body); close(done) }() |