Send /apps/<slug> to the app root, and follow preview origins there
The single-team redirect and the chooser's continuation went to the team's /auth/start, so every switch re-ran the assertion exchange and its interstitial even with a live app session. They now go to the team's app root and let the app's own guard decide (spec shared-chrome §6.2(a)). In a preview deployment /apps/<slug> ignored the preview override the catalog honours and ended on "No teams available". It now redirects to the preview's root, without needing a directory session (§6.2(b)). TestDirectoryCatalogPreviewKeepsDocsChooserPolicy pinned the old entry and is updated on purpose.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
6 files changed,
+83
−15
internal/home/app_chooser_test.go+2 −2internal/home/directory_catalog_test.go+46 −2internal/home/directory_runtime_test.go+2 −2internal/home/docs_chooser.go+28 −4internal/home/docs_chooser_test.go+3 −3internal/home/docs_continue_test.go+2 −2
diff --git a/internal/home/app_chooser_test.go b/internal/home/app_chooser_test.go| index 89d82d0..fc2bf1d 100644 |
| --- a/internal/home/app_chooser_test.go |
| +++ b/internal/home/app_chooser_test.go |
| @@ -39,7 +39,7 @@ func TestNativeAppChoicesKeepTeamAndAppBoundaries(t *testing.T) { |
| t.Fatal("choice boundaries", w.Code, w.Body.String()) |
| } |
| picked := f.request("POST", path+"/team", directoryOrigin, "team=A1", cookie) |
| - assertContinuationPage(t, picked, "https://alpha."+app+".oficina.works/auth/start?return_to=%2F") |
| + assertContinuationPage(t, picked, "https://alpha."+app+".oficina.works/") |
| if _, err := f.p.directory.db.Writer().Exec(`DELETE FROM memberships WHERE team_id=? AND person_ref=?`, a, chooserPerson); err != nil { |
| t.Fatal(err) |
| } |
| @@ -47,7 +47,7 @@ func TestNativeAppChoicesKeepTeamAndAppBoundaries(t *testing.T) { |
| t.Fatal("stale team selection accepted", w.Code) |
| } |
| single := f.request("GET", path, "", "", cookie) |
| - if single.Code != 303 || single.Header().Get("Location") != "https://beta."+app+".oficina.works/auth/start?return_to=%2F" { |
| + if single.Code != 303 || single.Header().Get("Location") != "https://beta."+app+".oficina.works/" { |
| t.Fatal("one-team shortcut", single.Code, single.Header()) |
| } |
| if w := f.request("POST", path+"/team", "https://attacker.example", "team=B2", cookie); w.Code != 403 { |
diff --git a/internal/home/directory_catalog_test.go b/internal/home/directory_catalog_test.go| index 99c6693..02c17c8 100644 |
| --- a/internal/home/directory_catalog_test.go |
| +++ b/internal/home/directory_catalog_test.go |
| @@ -78,9 +78,12 @@ func TestDirectoryCatalogPreviewKeepsDocsChooserPolicy(t *testing.T) { |
| t.Fatal("preview navigation incorrect", page.Code, body) |
| } |
| noStore(t, page) |
| + // Spec shared-chrome §6.2(b): /apps/docs follows the preview too, to |
| + // its root, rather than ending on "No teams available" (this test |
| + // asserted the chooser until round 2). |
| entry := f.request("GET", docsEntryPath, "", "", cookie) |
| - if entry.Code != 200 || entry.Header().Get("Location") != "" || !strings.Contains(entry.Body.String(), "This account has no team with Docs available.") { |
| - t.Fatal("preview changed team entry", entry.Code) |
| + if entry.Code != 303 || entry.Header().Get("Location") != "https://overnight-docs.bds.oncarlos.com/" { |
| + t.Fatal("preview entry", entry.Code, entry.Header().Get("Location")) |
| } |
| cfg.docsPreviewOrigin = "https://evil.example/path" |
| if _, err := NewRuntime(cfg, f.p.directory.db, quietLogger()); err != errExchangeConfig { |
| @@ -177,3 +180,44 @@ func TestConfiguredAppsLaunchOnlyOperatorDestinations(t *testing.T) { |
| t.Fatal("constructor bypassed app manifest validation", err) |
| } |
| } |
| + |
| +// TestAppEntryFollowsPreviewOrigins is spec shared-chrome §6.2(b): the |
| +// switcher's tiles link to /apps/<slug>, so in a preview deployment that |
| +// entry must reach the preview, as the catalog's own link does, instead |
| +// of "No teams available". It goes to the preview's root, like the |
| +// single-team redirect: the app's own guard decides whether to sign in. |
| +// A preview is an operator choice, not a team route, so no directory |
| +// session or team is needed to follow it. |
| +func TestAppEntryFollowsPreviewOrigins(t *testing.T) { |
| + f := newMountedChooser(t) |
| + cfg := *f.p.directory.runtime |
| + cfg.docsPreviewOrigin = "https://overnight-docs.bds.oncarlos.com" |
| + cfg.appPreviewOrigins = map[string]string{"sheets": "https://sheets.example", "calls": "https://calls-preview.example"} |
| + app, err := NewRuntime(cfg, f.p.directory.db, quietLogger()) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + f.handler = app.Router() |
| + cookie := f.session(t, chooserPerson) |
| + for path, want := range map[string]string{ |
| + "/apps/docs": "https://overnight-docs.bds.oncarlos.com/", |
| + "/apps/sheets": "https://sheets.example/", |
| + "/apps/calls": "https://calls-preview.example/", |
| + } { |
| + for _, c := range []*http.Cookie{cookie, nil} { |
| + r := f.request("GET", path, "", "", c) |
| + noStore(t, r) |
| + if r.Code != 303 || r.Header().Get("Location") != want { |
| + t.Fatal(path, c != nil, r.Code, r.Header().Get("Location")) |
| + } |
| + } |
| + // The entry still refuses any query, so nothing rides the hop. |
| + if r := f.request("GET", path+"?return_to=/secret", "", "", cookie); r.Code != 404 { |
| + t.Fatal(path, "query accepted", r.Code) |
| + } |
| + } |
| + // An app without a preview keeps its team entry. |
| + if r := f.request("GET", "/apps/slides", "", "", cookie); r.Code != 200 || !strings.Contains(r.Body.String(), "No teams available") { |
| + t.Fatal("non-preview app changed", r.Code, r.Header().Get("Location")) |
| + } |
| +} |
diff --git a/internal/home/directory_runtime_test.go b/internal/home/directory_runtime_test.go| index ff240d7..f4e4d20 100644 |
| --- a/internal/home/directory_runtime_test.go |
| +++ b/internal/home/directory_runtime_test.go |
| @@ -25,7 +25,7 @@ func TestDirectoryRuntimeMountUsesCanonicalRegistryAndCurrentMembership(t *testi |
| id := f.team(t, "A1", "alpha", "Alpha", chooserPerson) |
| f.team(t, "B2", "beta", "Foreign team", chooserOther) |
| r = f.request("GET", docsEntryPath, "", "", cookie) |
| - if r.Code != 303 || r.Header().Get("Location") != "https://alpha.docs.oficina.works/auth/start?return_to=%2F" { |
| + if r.Code != 303 || r.Header().Get("Location") != "https://alpha.docs.oficina.works/" { |
| t.Fatal(r.Code, r.Header()) |
| } |
| noStore(t, r) |
| @@ -148,7 +148,7 @@ func TestDirectoryRuntimeExpiredChoiceReturnsToFreshTeamSelection(t *testing.T) |
| 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") |
| + assertDocsContinuation(t, rec, "https://beta.docs.oficina.works/") |
| } |
| if _, err := f.p.directory.db.Writer().Exec(`UPDATE sessions SET expires_at='2000-01-01T00:00:00Z'`); err != nil { |
| t.Fatal(err) |
diff --git a/internal/home/docs_chooser.go b/internal/home/docs_chooser.go| index bfaf180..06672bd 100644 |
| --- a/internal/home/docs_chooser.go |
| +++ b/internal/home/docs_chooser.go |
| @@ -64,6 +64,16 @@ func (c *docsChooser) Entry(w http.ResponseWriter, r *http.Request) { |
| http.NotFound(w, r) |
| return |
| } |
| + // A preview deployment replaces the team routes for this app, as it |
| + // does on the catalog page, so the entry follows it too (spec |
| + // shared-chrome §6.2(b)): otherwise a link here from an app's switcher |
| + // ends on "No teams available". A preview is an operator choice, not a |
| + // team, so it needs no directory session; it goes to the preview's |
| + // root, like the single-team redirect below. |
| + if preview := c.previewOrigin(); preview != "" { |
| + http.Redirect(w, r, preview+"/", http.StatusSeeOther) |
| + return |
| + } |
| subject, ok := c.subject(w, r) |
| if !ok { |
| return |
| @@ -150,10 +160,25 @@ func (c *docsChooser) subject(w http.ResponseWriter, r *http.Request) (string, b |
| func (c *docsChooser) unavailable(w http.ResponseWriter) { |
| c.app.render(w, http.StatusServiceUnavailable, "docs_unavailable.html", nil) |
| } |
| + |
| +// previewOrigin is the operator's preview deployment for this app, or "". |
| +// Both settings were validated at construction (validAppPreviews). |
| +func (c *docsChooser) previewOrigin() string { |
| + if c.appID == "docs" && c.app.runtime.docsPreviewOrigin != "" { |
| + return c.app.runtime.docsPreviewOrigin |
| + } |
| + return c.app.runtime.appPreviewOrigins[c.appID] |
| +} |
| + |
| +// redirect and continueToDocs send the browser to the chosen team's app |
| +// root, not to its /auth/start. The app's own guard starts an assertion |
| +// exchange only when it has no session of its own, so a switch into an |
| +// app you are already signed in to costs no exchange and no interstitial |
| +// (spec shared-chrome §6.2(a)). |
| func (c *docsChooser) redirect(w http.ResponseWriter, r *http.Request, choice directorydata.Choice) { |
| // The directorydata store has already applied the configured destination |
| // policy. Generic entry carries only the app root, never a document path. |
| - http.Redirect(w, r, choice.Origin+handoff.StartPath+"?"+url.Values{"return_to": {"/"}}.Encode(), http.StatusSeeOther) |
| + http.Redirect(w, r, choice.Origin+"/", http.StatusSeeOther) |
| } |
| func (c *docsChooser) continueToDocs(w http.ResponseWriter, choice directorydata.Choice) { |
| @@ -161,11 +186,10 @@ func (c *docsChooser) continueToDocs(w http.ResponseWriter, choice directorydata |
| c.unavailable(w) |
| return |
| } |
| - destination := choice.Origin + handoff.StartPath + "?" + url.Values{"return_to": {"/"}}.Encode() |
| - c.renderContinuation(w, destination) |
| + c.renderContinuation(w, choice.Origin+"/") |
| } |
| -// Callers supply either a validated stored app origin plus the fixed app path, |
| +// Callers supply either a validated stored app origin plus the app root, |
| // or the validated directory origin plus the fixed sign-in continuation path. |
| func (c *docsChooser) renderContinuation(w http.ResponseWriter, destination string) { |
| c.app.renderContinuation(w, destination, "Continue to "+c.appName) |
diff --git a/internal/home/docs_chooser_test.go b/internal/home/docs_chooser_test.go| index 3bfc673..f9e4b94 100644 |
| --- a/internal/home/docs_chooser_test.go |
| +++ b/internal/home/docs_chooser_test.go |
| @@ -127,7 +127,7 @@ func TestDocsChooserZeroOneManyAndRemovedChoice(t *testing.T) { |
| a := f.team(t, "A1", "alpha", "Alpha", chooserPerson) |
| r = f.request("GET", docsEntryPath, "", "", cookie) |
| noStore(t, r) |
| - if r.Code != 303 || r.Header().Get("Location") != "https://alpha.docs.oficina.works/auth/start?return_to=%2F" { |
| + if r.Code != 303 || r.Header().Get("Location") != "https://alpha.docs.oficina.works/" { |
| t.Fatal(r.Code, r.Header()) |
| } |
| f.team(t, "B2", "beta", `<script>alert("team")</script>`, chooserPerson) |
| @@ -144,7 +144,7 @@ func TestDocsChooserZeroOneManyAndRemovedChoice(t *testing.T) { |
| } |
| r = f.request("POST", docsChoicePath, directoryOrigin, "team=B2", cookie) |
| noStore(t, r) |
| - assertDocsContinuation(t, r, "https://beta.docs.oficina.works/auth/start?return_to=%2F") |
| + assertDocsContinuation(t, r, "https://beta.docs.oficina.works/") |
| if _, err := f.p.directory.db.Writer().Exec(`DELETE FROM memberships WHERE team_id=? AND person_ref=?`, a, chooserPerson); err != nil { |
| t.Fatal(err) |
| } |
| @@ -156,7 +156,7 @@ func TestDocsChooserZeroOneManyAndRemovedChoice(t *testing.T) { |
| } |
| } |
| r = f.request("GET", docsEntryPath, "", "", cookie) |
| - if r.Header().Get("Location") != "https://beta.docs.oficina.works/auth/start?return_to=%2F" { |
| + if r.Header().Get("Location") != "https://beta.docs.oficina.works/" { |
| t.Fatal(r.Header()) |
| } |
| } |
diff --git a/internal/home/docs_continue_test.go b/internal/home/docs_continue_test.go| index 17aaae3..e8f5589 100644 |
| --- a/internal/home/docs_continue_test.go |
| +++ b/internal/home/docs_continue_test.go |
| @@ -74,7 +74,7 @@ func TestDocsContinueCanonicalResponseBoundary(t *testing.T) { |
| for _, origin := range []string{"https://alpha.docs.oficina.works", "https://approved.fixture.example"} { |
| r := httptest.NewRecorder() |
| c.continueToDocs(r, directorydata.Choice{Origin: origin}) |
| - assertDocsContinuation(t, r, origin+"/auth/start?return_to=%2F") |
| + assertDocsContinuation(t, r, origin+"/") |
| } |
| for _, origin := range []string{"", "http://alpha.example", "https://Alpha.example", "https://alpha.example/", "https://alpha.example:443", "https://user@alpha.example", "https://alpha.example?x=1", "https://alpha.example#f", "https://alpha.example?", "https://alpha.example\n", " https://alpha.example", "https://alpha.example\";url=https://bad.example", "javascript:alert(1)", "//alpha.example", "https://[::1]", "https://alpha.example/%22"} { |
| r := httptest.NewRecorder() |
| @@ -119,7 +119,7 @@ func TestDocsContinueRequeriesCurrentOrigin(t *testing.T) { |
| if _, err := f.p.directory.db.Writer().Exec(`UPDATE team_instances SET origin='https://changed.docs.oficina.works' WHERE team_id=?`, id); err != nil { |
| t.Fatal(err) |
| } |
| - assertDocsContinuation(t, f.request("POST", docsChoicePath, directoryOrigin, "team=A1", cookie), "https://changed.docs.oficina.works/auth/start?return_to=%2F") |
| + assertDocsContinuation(t, f.request("POST", docsChoicePath, directoryOrigin, "team=A1", cookie), "https://changed.docs.oficina.works/") |
| } |
| func TestDocsContinueExactHostAndExpiredSession(t *testing.T) { |
| f := newChooserFixture(t) |