Resume expired team choices through fixed Home sign-in
3 files changed,
+89
−4
internal/home/directory_runtime_test.go+67 −0internal/home/docs_chooser.go+14 −1internal/home/docs_continue_test.go+8 −3
CI passed — run details
diff --git a/internal/home/directory_runtime_test.go b/internal/home/directory_runtime_test.go| index ec59f4a..98b466e 100644 |
| --- a/internal/home/directory_runtime_test.go |
| +++ b/internal/home/directory_runtime_test.go |
| @@ -130,3 +130,70 @@ func TestDirectoryRuntimeAnonymousReturnAndRoleSchemaSeparation(t *testing.T) { |
| t.Fatal("registry shared mutable backing") |
| } |
| } |
| + |
| +func TestDirectoryRuntimeExpiredChoiceReturnsToFreshTeamSelection(t *testing.T) { |
| + f := newMountedChooser(t) |
| + cookie := f.session(t, chooserPerson) |
| + f.team(t, "A1", "alpha", "Alpha", chooserPerson) |
| + f.team(t, "B2", "beta", "Beta", chooserPerson) |
| + for _, metadata := range []string{"same-origin", "none"} { |
| + req := httptest.NewRequest("POST", directoryOrigin+docsChoicePath, strings.NewReader("team=B2")) |
| + req.AddCookie(cookie) |
| + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| + 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") |
| + } |
| + if _, err := f.p.directory.db.Writer().Exec(`UPDATE sessions SET expires_at='2000-01-01T00:00:00Z'`); err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, c := range []*http.Cookie{cookie, nil} { |
| + r := f.request("POST", docsChoicePath, directoryOrigin, "team=SecretSubmittedTeamXYZ&return_to=https%3A%2F%2Fevil.example%2Fsecret-navigation", c) |
| + cleared := "" |
| + if c != nil { |
| + cleared = c.Name |
| + } |
| + assertChooserSigninContinuation(t, r, cleared) |
| + for _, forbidden := range []string{"SecretSubmittedTeamXYZ", "evil.example", "secret-navigation"} { |
| + if strings.Contains(r.Body.String(), forbidden) { |
| + t.Fatal("submitted selection reflected") |
| + } |
| + } |
| + } |
| + r := f.request("GET", docsEntryPath, "", "", cookie) |
| + if r.Code != 303 || r.Header().Get("Location") != "/auth/start?return_to=%2Fapps%2Fdocs" { |
| + t.Fatal("anonymous GET changed", r.Code, r.Header()) |
| + } |
| + invalid := f.session(t, "email@example.invalid") |
| + for _, method := range []string{"GET", "POST"} { |
| + path := docsEntryPath |
| + if method == "POST" { |
| + path = docsChoicePath |
| + } |
| + r = f.request(method, path, directoryOrigin, "team=A1", invalid) |
| + if r.Code != 404 || strings.Contains(r.Body.String(), "http-equiv") { |
| + t.Fatal("invalid subject entered authentication continuation", r.Code) |
| + } |
| + noStore(t, r) |
| + } |
| +} |
| + |
| +func assertChooserSigninContinuation(t *testing.T, r *httptest.ResponseRecorder, cleared string) { |
| + t.Helper() |
| + cookies := r.Result().Cookies() |
| + if cleared == "" { |
| + if len(cookies) != 0 { |
| + t.Fatal("anonymous continuation minted state") |
| + } |
| + } else { |
| + if len(cookies) != 1 { |
| + t.Fatal("expired continuation did not only clear its session") |
| + } |
| + c := cookies[0] |
| + if c.Name != cleared || c.Value != "" || c.MaxAge >= 0 || c.Path != "/" || !c.Secure || !c.HttpOnly || c.SameSite != http.SameSiteLaxMode { |
| + t.Fatal("expired continuation replaced session state") |
| + } |
| + } |
| + assertContinuationPage(t, r, directoryOrigin+"/auth/start?return_to=%2Fapps%2Fdocs") |
| +} |
diff --git a/internal/home/docs_chooser.go b/internal/home/docs_chooser.go| index b664a5f..53c4136 100644 |
| --- a/internal/home/docs_chooser.go |
| +++ b/internal/home/docs_chooser.go |
| @@ -116,7 +116,14 @@ func (c *docsChooser) choose(w http.ResponseWriter, r *http.Request) { |
| func (c *docsChooser) subject(w http.ResponseWriter, r *http.Request) (string, bool) { |
| session, ok := sessions.Current(r) |
| if !ok { |
| - http.Redirect(w, r, handoff.StartPath+"?"+url.Values{"return_to": {docsEntryPath}}.Encode(), http.StatusSeeOther) |
| + target := handoff.StartPath + "?" + url.Values{"return_to": {docsEntryPath}}.Encode() |
| + if r.Method == http.MethodPost { |
| + // Start a new GET navigation after the form response. The submitted |
| + // selection is discarded; sign-in returns to current team choices. |
| + c.renderContinuation(w, c.app.origins.Directory+target) |
| + } else { |
| + http.Redirect(w, r, target, http.StatusSeeOther) |
| + } |
| return "", false |
| } |
| if !ValidPersonRef(session.Subject) { |
| @@ -140,6 +147,12 @@ func (c *docsChooser) continueToDocs(w http.ResponseWriter, choice directorydata |
| return |
| } |
| destination := choice.Origin + handoff.StartPath + "?" + url.Values{"return_to": {"/"}}.Encode() |
| + c.renderContinuation(w, destination) |
| +} |
| + |
| +// Callers supply either a validated stored app origin plus the fixed app path, |
| +// or the validated directory origin plus the fixed sign-in continuation path. |
| +func (c *docsChooser) renderContinuation(w http.ResponseWriter, destination string) { |
| w.Header().Set("Cache-Control", "no-store") |
| w.Header().Set("Referrer-Policy", "no-referrer") |
| c.app.render(w, http.StatusOK, "docs_continue.html", struct{ Destination string }{destination}) |
diff --git a/internal/home/docs_continue_test.go b/internal/home/docs_continue_test.go| index 8b7b237..549afd2 100644 |
| --- a/internal/home/docs_continue_test.go |
| +++ b/internal/home/docs_continue_test.go |
| @@ -50,6 +50,13 @@ func continuationTargets(t *testing.T, body string) (refresh, links []string) { |
| func assertDocsContinuation(t *testing.T, r *httptest.ResponseRecorder, want string) { |
| t.Helper() |
| noStore(t, r) |
| + assertContinuationPage(t, r, want) |
| +} |
| +func assertContinuationPage(t *testing.T, r *httptest.ResponseRecorder, want string) { |
| + t.Helper() |
| + if r.Header().Get("Cache-Control") != "no-store" { |
| + t.Fatal("cacheable continuation") |
| + } |
| if r.Code != 200 || r.Header().Get("Location") != "" || r.Header().Get("Refresh") != "" || r.Header().Get("Content-Type") != "text/html; charset=utf-8" || r.Header().Get("Referrer-Policy") != "no-referrer" { |
| t.Fatal(r.Code, r.Header()) |
| } |
| @@ -138,7 +145,5 @@ func TestDocsContinueExactHostAndExpiredSession(t *testing.T) { |
| t.Fatal(err) |
| } |
| r := f.request(http.MethodPost, docsChoicePath, directoryOrigin, "team=A1", cookie) |
| - if r.Code != 303 || r.Header().Get("Location") != "/auth/start?return_to=%2Fapps%2Fdocs" { |
| - t.Fatal(r.Code, r.Header()) |
| - } |
| + assertChooserSigninContinuation(t, r, cookie.Name) |
| } |