Build and browser-verify the tenant-picker frontend page
web/src/routes/select-tenant now calls enterprise-auth's existing
GET /auth/memberships / POST /auth/select-tenant protocol (built earlier
this phase, previously called only from Go tests) via
fetch(..., {credentials: 'include'}) -- new listMemberships/selectTenant
functions in $lib/api.ts, using a dedicated request helper that reads
plain-text error bodies (loginhandler's http.Error responses), unlike
every other request helper in that file which expects JSON.
Credentialed cross-origin fetch needed CORS enterprise-auth didn't have:
api/httpserver.WithCORS's wildcard-friendly default can't be combined
with a credentialed request at all (browsers refuse to honor
Access-Control-Allow-Origin: "*" on one) -- added WithCredentialedCORS
(literal origin, Access-Control-Allow-Credentials: true) alongside it,
wired into enterprise-auth via a new CORS_ALLOWED_ORIGIN config var
defaulting to POST_LOGIN_REDIRECT_URL (web's own origin, the same
default pattern SELECT_TENANT_REDIRECT_URL already used).
adapter-static's route crawler doesn't discover a page nothing links to
(this one is only ever reached via enterprise-auth's redirect) -- fixed
with select-tenant/+page.ts's `export const prerender = true`, the same
declaration every other route already has.
Genuinely verified in a real browser in this environment, not just
type-checked: a throwaway Node server standing in for enterprise-auth's
exact wire contract (including its plain-text error bodies), driven
through the full flow via mcp__claude-in-chrome -- cross-origin
pending-login cookie set, credentialed preflight + GET/POST round trip,
a real click choosing a tenant, the post-selection redirect, and the
missing/expired-cookie error path rendering the backend's actual
message. No Docker or live Postgres/IdP needed, since the point was
exercising web's own fetch/CORS/cookie wiring, not enterprise-auth's
internals (already covered by loginhandler's own tests).
This closes the tenant-picker as the last named gap in Phase 4. What's
left is the already-disclosed live-verification caveat shared by every
Postgres/ClickHouse-backed piece and both SSO protocols: none of this
has run against a real database, external IdP, or multi-container
deployment in this environment.
This commit is contained in:
@@ -22,3 +22,29 @@ func WithCORS(next http.Handler, allowedOrigin string) http.Handler {
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// WithCredentialedCORS is WithCORS's sibling for endpoints a browser must
|
||||
// call with cookies attached (Phase 4: enterprise-auth's
|
||||
// GET /auth/memberships / POST /auth/select-tenant, called from web's
|
||||
// tenant-picker page via `fetch(..., {credentials: 'include'})`).
|
||||
// Browsers categorically refuse to combine a credentialed request with
|
||||
// Access-Control-Allow-Origin: "*" -- allowedOrigin must be a real,
|
||||
// literal origin (e.g. "http://localhost:3000"), not the wildcard
|
||||
// WithCORS's own zero-config default relies on. Callers of this function
|
||||
// don't get that convenient zero-config default: an empty/wildcard
|
||||
// allowedOrigin here is a configuration bug, not a permissive default,
|
||||
// so it's deliberately not special-cased into something that "just
|
||||
// works" the way plain WithCORS's "*" does.
|
||||
func WithCredentialedCORS(next http.Handler, allowedOrigin string) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", allowedOrigin)
|
||||
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
||||
if r.Method == http.MethodOptions {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -40,3 +40,44 @@ func TestWithCORSPassesThroughNonPreflight(t *testing.T) {
|
||||
t.Fatalf("status = %d, want 200", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithCredentialedCORSSetsLiteralOriginAndCredentialsHeader(t *testing.T) {
|
||||
inner := http.NewServeMux()
|
||||
inner.HandleFunc("GET /auth/memberships", func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
h := WithCredentialedCORS(inner, "http://localhost:3000")
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/auth/memberships", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rec.Code)
|
||||
}
|
||||
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "http://localhost:3000" {
|
||||
t.Fatalf("Access-Control-Allow-Origin = %q, want http://localhost:3000", got)
|
||||
}
|
||||
if got := rec.Header().Get("Access-Control-Allow-Credentials"); got != "true" {
|
||||
t.Fatalf("Access-Control-Allow-Credentials = %q, want true -- a credentialed fetch() needs this header present or the browser discards the response", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithCredentialedCORSPreflight(t *testing.T) {
|
||||
inner := http.NewServeMux()
|
||||
inner.HandleFunc("POST /auth/select-tenant", func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
h := WithCredentialedCORS(inner, "http://localhost:3000")
|
||||
|
||||
req := httptest.NewRequest(http.MethodOptions, "/auth/select-tenant", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("status = %d, want 204", rec.Code)
|
||||
}
|
||||
if got := rec.Header().Get("Access-Control-Allow-Credentials"); got != "true" {
|
||||
t.Fatalf("Access-Control-Allow-Credentials = %q, want true on the preflight response too", got)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user