oficina / home Public

The directory role answers the front page to anonymous visitors

oficina.works is about to run the directory role, whose "/" was gated
behind sessions.Require. That would have replaced the front page with a
sign-in redirect: every stranger who typed the domain would meet a form
with no explanation of what they were signing in to.

"/" now reads the session rather than requiring it. No session gets the
front page; a session gets the directory. An expired cookie degrades to
the front page instead of a redirect loop.

The two tests that covered this asserted the mechanism (a 303 to
/auth/start) alongside the property (the catalogue never reaches an
anonymous request). The property is what matters and is now checked
directly, against the response body: an anonymous request must contain
the front page and must not contain a team chooser, an /apps/ link or
the Docs entry path. Same for the credential-cookie separation in
exchange_test.go, which asserted 303 for the same reason.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 410adc4b7b0bd4d65c535e8ba4647fa4b8dcb8ba parent 25c6449
3 files changed, +39 −5
  • internal/home/directory.go +14 −1
  • internal/home/directory_catalog_test.go +16 −2
  • internal/home/exchange_test.go +9 −2
diff --git a/internal/home/directory.go b/internal/home/directory.go
index b9b04a1..7b38318 100644
--- a/internal/home/directory.go
+++ b/internal/home/directory.go
@@ -80,7 +80,19 @@ func (a *App) directoryRouter() http.Handler {
r.Get("/signin", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, handoff.StartPath, http.StatusSeeOther)
})
- r.With(a.sessions.Require).Get("/", func(w http.ResponseWriter, r *http.Request) {
+ // One URL, two audiences. A visitor with no session gets the front
+ // page — what Oficina is, the apps, and the way in. A signed-in
+ // person gets their directory. Gating this behind sessions.Require
+ // instead, as it was, sends every stranger who types oficina.works
+ // to a sign-in form: a door with no building attached to it.
+ //
+ // The session is READ here, never required, so an expired cookie
+ // degrades to the front page rather than to a redirect loop.
+ r.Get("/", func(w http.ResponseWriter, r *http.Request) {
+ if _, ok := a.sessions.From(r); !ok {
+ a.handleIndex(w, r)
+ return
+ }
w.Header().Set("Cache-Control", "no-store")
a.render(w, 200, "directory.html", map[string]any{"Apps": directoryCatalog(*a.runtime)})
})
@@ -96,6 +108,7 @@ func (a *App) directoryRouter() http.Handler {
r.Get("/static/tokens.css", serveAsset(staticAsset("tokens.css"), "text/css; charset=utf-8"))
r.Get("/static/theme.css", serveAsset(theme.CSS(), "text/css; charset=utf-8"))
mountBrand(r)
+ mountSite(r)
})
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
if req.URL.Path == rosterPath {
diff --git a/internal/home/directory_catalog_test.go b/internal/home/directory_catalog_test.go
index 7727bc8..02ed273 100644
--- a/internal/home/directory_catalog_test.go
+++ b/internal/home/directory_catalog_test.go
@@ -7,9 +7,23 @@ import (
func TestDirectoryCatalogRequiresSigninAndDoesNotInventLaunchDestinations(t *testing.T) {
f := newMountedChooser(t)
+ // "/" answers the front page to a visitor with no session, and the
+ // catalogue only to a session that exists. The property under test is
+ // unchanged and is the second condition: the catalogue — team choosers,
+ // per-app entry paths — must never reach an anonymous request. What
+ // changed is the mechanism. This used to 303 to /auth/start, which sent
+ // every stranger who typed oficina.works to a sign-in form; it now
+ // renders the front page, so the assertion is on CONTENT rather than on
+ // a status code, which is the thing that actually matters.
anonymous := f.request("GET", "/", "", "", nil)
- if anonymous.Code != 303 || !strings.HasPrefix(anonymous.Header().Get("Location"), "/auth/start") || strings.Contains(anonymous.Body.String(), "Choose a team") {
- t.Fatal("anonymous directory exposed catalog", anonymous.Code, anonymous.Header())
+ anonymousBody := anonymous.Body.String()
+ if anonymous.Code != 200 || !strings.Contains(anonymousBody, "office suite") {
+ t.Fatal("anonymous directory did not serve the front page", anonymous.Code, anonymous.Header())
+ }
+ for _, leak := range []string{"Choose a team", `href="/apps/`, docsEntryPath} {
+ if strings.Contains(anonymousBody, leak) {
+ t.Fatal("anonymous front page exposed catalogue", leak)
+ }
}
cookie := f.session(t, chooserPerson)
page := f.request("GET", "/", "", "", cookie)
diff --git a/internal/home/exchange_test.go b/internal/home/exchange_test.go
index d87aee9..f20cf79 100644
--- a/internal/home/exchange_test.go
+++ b/internal/home/exchange_test.go
@@ -164,8 +164,15 @@ func TestHomeDirectoryExchangeAndPrivacy(t *testing.T) {
if subject == "" || strings.Contains(subject, "@") || authTime != old.Format(time.RFC3339) {
t.Fatal("identity or freshness changed")
}
- if rec := exchangeRequest(p.directory, "GET", directoryOrigin+"/", "", nil, credentialSession); rec.Code != 303 {
- t.Fatal("credential cookie opened directory")
+ // A cookie minted on the credential origin must not carry a directory
+ // session. "/" no longer redirects an unauthenticated caller — it
+ // renders the front page — so the check is that the credential cookie
+ // gets that anonymous page and none of the signed-in directory, which
+ // asserts the same separation against the response body instead of
+ // against a redirect that is no longer how the route reports it.
+ if rec := exchangeRequest(p.directory, "GET", directoryOrigin+"/", "", nil, credentialSession); rec.Code != 200 ||
+ strings.Contains(rec.Body.String(), "You are signed in.") || strings.Contains(rec.Body.String(), "Choose a team") {
+ t.Fatal("credential cookie opened directory", rec.Code)
}
if rec := exchangeRequest(p.credential.App, "GET", Origin+"/authorize/resume", "", nil, intent, directorySession); rec.Code != 303 {
t.Fatal("directory cookie opened credentials")