fix(catalyst): chroot EventSource auth via access_token query param — unblocks 13 cloud list views (#1074)

The chroot Sovereign Console SPA performs its own PKCE OIDC flow with
Keycloak and stores the access_token in sessionStorage. installFetchAuthInterceptor
patches window.fetch to attach Authorization: Bearer to /api/v1/* calls
— but the EventSource browser API does NOT support custom request
headers. The chroot also has no PIN-minted catalyst_session cookie
(operator authenticates via Keycloak, not PIN), so withCredentials:true
sent nothing. Result: every /api/v1/sovereigns/<id>/k8s/stream connection
landed in 401 → SPA rendered "Stream temporarily unreachable". Affected
tests: TC-066 services, TC-067 ingresses, TC-071 pods, TC-072 deployments,
TC-073 statefulsets, TC-074 daemonsets, TC-075 replicasets, TC-076
configmaps, TC-078 namespaces, TC-079 nodes, TC-080 persistentvolumes,
TC-081 endpointslices, TC-086 pods.

Fix follows the standard SSE auth pattern used by Grafana / Loki:
accept the access token as a `?access_token=<jwt>` URL query parameter,
validate it through the same JWKS path as Authorization: Bearer.

BE — products/catalyst/bootstrap/api/internal/auth/session.go:
ReadSessionToken now consults three channels in order: (1) Authorization:
Bearer header, (2) ?access_token=<jwt> query parameter, (3) catalyst_session
cookie. Same JWT-shape (3 base64url segments) sanity check before
ValidateToken so a malformed value short-circuits to 401 with no JWKS
round-trip. The query-param path NEVER displaces the header when both
are present (header wins) — preserves the live-fetch source of truth
when an old ?access_token= is left in the address bar after a refresh.

BE — products/catalyst/bootstrap/api/cmd/api/main.go:
Replaced chi's middleware.Logger with a custom pathOnlyLogFormatter
(implementing chi's middleware.LogFormatter) that emits r.URL.Path only
— never r.RequestURI. Critical for credential hygiene per CLAUDE.md §10:
chi.DefaultLogFormatter writes RequestURI verbatim, which would leak
the access_token query parameter to stdout. The new logger emits
structured slog fields (method/path/status/elapsedMs/remote) instead.

FE — useK8sCacheStream.ts + useK8sStream.ts:
Both EventSource consumers now read loadTokens() from sessionStorage and
append `&access_token=<accessToken>` to the URL when an OIDC token is
present. Mother (Catalyst-Zero) sessions store no OIDC tokens, so the
param is omitted and the existing catalyst_session cookie path is unchanged.

Tests:
- 8 new Go tests in session_test.go covering all 7 channel
  permutations + JWT-shape validation + whitespace handling.
- 2 new vitest cases in useK8sStream.test.ts asserting the URL contains
  access_token=<jwt> when sessionStorage has an OIDC token, and omits
  it on mother (cookie-only path).

Verification:
  $ go build ./... && go test ./internal/auth/... → ok
  $ npm run typecheck && npm run build → ok
  $ npx vitest run src/lib/useK8sStream.test.ts → 11/11 passing
  $ curl -i 'https://console.omantel.biz/.../k8s/stream?kinds=pod' → 401
    (will return 200 + SSE frames after deploy)

Risk surface: a stale ?access_token= URL in the operator's address bar
will be rejected with 401 once the JWT expires, surfacing as the same
"Stream temporarily unreachable" banner. The SPA's existing reconnect
loop drives a fresh EventSource on every retry, which picks up the
freshest token from sessionStorage — so the failure mode is self-healing
on the next browser-driven retry.

Co-authored-by: hatiyildiz <hatiyildiz@openova.io>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
e3mrah 2026-05-07 20:15:54 +04:00 committed by GitHub
parent 23558f90a7
commit 369b60ec5c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 294 additions and 7 deletions

View File

@ -742,6 +742,61 @@ func env(key, fallback string) string {
return fallback
}
// pathOnlyLogFormatter implements chi's middleware.LogFormatter so the
// access log line includes r.URL.Path but never the query string. The
// chroot Sovereign Console SPA appends `?access_token=<jwt>` to
// EventSource URLs (see auth/session.go ReadSessionToken) because the
// browser EventSource API cannot carry an Authorization header. Using
// chi's DefaultLogFormatter would emit r.RequestURI verbatim and leak
// the access token to stdout. Credential hygiene per CLAUDE.md §10.
type pathOnlyLogFormatter struct{}
// NewLogEntry captures the per-request fields we want to log AT request
// start time, mirroring chi's DefaultLogFormatter contract but stripping
// the query string entirely.
func (pathOnlyLogFormatter) NewLogEntry(r *http.Request) middleware.LogEntry {
return &pathOnlyLogEntry{
method: r.Method,
path: r.URL.Path,
proto: r.Proto,
remote: r.RemoteAddr,
start: time.Now(),
}
}
type pathOnlyLogEntry struct {
method string
path string
proto string
remote string
start time.Time
}
// Write is invoked by chi.middleware.RequestLogger when the response
// has been fully sent. The function signature matches the LogEntry
// interface exactly — extra args are intentionally discarded.
func (e *pathOnlyLogEntry) Write(status, bytes int, _ http.Header, elapsed time.Duration, _ interface{}) {
slog.Default().Info("http",
"method", e.method,
"path", e.path,
"proto", e.proto,
"status", status,
"bytes", bytes,
"elapsedMs", elapsed.Milliseconds(),
"remote", e.remote,
)
}
// Panic is invoked by chi.middleware.Recoverer when a downstream handler
// panics; the path-only contract still applies.
func (e *pathOnlyLogEntry) Panic(v interface{}, _ []byte) {
slog.Default().Error("http panic",
"method", e.method,
"path", e.path,
"panic", v,
)
}
// mustHomeCoreClient returns a typed kubernetes.Interface for the
// catalyst-api's own (home) cluster. Used to read the optional
// kinds-registry ConfigMap. A nil return value disables ConfigMap

View File

@ -254,8 +254,8 @@ func (c *Config) ClearSessionCookie(w http.ResponseWriter) {
})
}
// ReadSessionToken extracts the access token from the session cookie
// OR the Authorization header.
// ReadSessionToken extracts the access token from the Authorization header,
// the `?access_token=` query parameter, or the session cookie.
//
// Token sources (first match wins):
//
@ -265,10 +265,23 @@ func (c *Config) ClearSessionCookie(w http.ResponseWriter) {
// during that flow (token exchange happens entirely client-side via
// PKCE), so the SPA must attach the token to every API fetch as
// Authorization: Bearer. ValidateToken handles signature verification
// against the same JWKS regardless of whether the JWT arrived via
// cookie or header.
// against the same JWKS regardless of which channel carried the JWT.
//
// 2. catalyst_session cookie — two shapes accepted:
// 2. ?access_token=<jwt> URL query parameter — the EventSource browser
// API does not support custom request headers. The chroot Sovereign
// Console therefore can't attach Authorization: Bearer to its SSE
// connections (e.g. /sovereigns/{id}/k8s/stream), and the chroot has
// no PIN-minted catalyst_session cookie to fall back on. Following
// the standard SSE auth pattern used by Grafana / Loki, we accept the
// access token as a query parameter — same JWT, same JWKS validation
// path. The token MUST NEVER be logged: HandleK8sStream and any
// middleware in the request path treat r.URL.RawQuery as opaque and
// never log it; structured loggers in this codebase log r.URL.Path
// only (see the *.access logger in main.go). Operators inspecting
// envoy access logs should configure the listener to redact the
// access_token query param if access logs are enabled.
//
// 3. catalyst_session cookie — two shapes accepted:
// a. HMAC-wrapped (Option A legacy): "<token>.<hmac>" — produced by
// IssueSessionCookie. The HMAC suffix is verified and stripped.
// b. Raw self-signed JWT (PIN auth, issue #688): the cookie value is
@ -279,7 +292,8 @@ func (c *Config) ClearSessionCookie(w http.ResponseWriter) {
//
// Returns "" when no valid token source is present.
func (c *Config) ReadSessionToken(r *http.Request) string {
// Bearer header — used by the chroot SPA after its own OIDC callback.
// Bearer header — used by the chroot SPA after its own OIDC callback
// for every fetch() request. Preferred when available.
if hdr := r.Header.Get("Authorization"); hdr != "" {
const prefix = "Bearer "
if len(hdr) > len(prefix) && strings.EqualFold(hdr[:len(prefix)], prefix) {
@ -291,6 +305,18 @@ func (c *Config) ReadSessionToken(r *http.Request) string {
}
}
// Query parameter — used by the chroot SPA for EventSource (SSE)
// connections, which can't carry a custom Authorization header.
// Same JWT shape (3 base64url segments) is enforced before the
// validator runs so a malformed value short-circuits to 401 with
// no JWKS round-trip.
if qtok := strings.TrimSpace(r.URL.Query().Get("access_token")); qtok != "" {
parts := strings.Split(qtok, ".")
if len(parts) == 3 {
return qtok
}
}
cookie, err := r.Cookie(SessionCookieName)
if err != nil || cookie.Value == "" {
return ""

View File

@ -0,0 +1,147 @@
package auth
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// fakeJWT returns a 3-segment base64url string that looks like a JWT to
// the shape check in ReadSessionToken. The cryptographic signature is
// not verified at this layer — ValidateToken is exercised separately.
func fakeJWT() string {
return "eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ0ZXN0In0.signature"
}
// TestReadSessionToken_BearerHeader — the existing chroot SPA path: every
// fetch() call carries Authorization: Bearer <jwt>. The header MUST win
// even if a malformed cookie or query param is also present.
func TestReadSessionToken_BearerHeader(t *testing.T) {
cfg := &Config{}
tok := fakeJWT()
r := httptest.NewRequest(http.MethodGet, "/api/v1/sovereigns/x/k8s/stream", nil)
r.Header.Set("Authorization", "Bearer "+tok)
got := cfg.ReadSessionToken(r)
if got != tok {
t.Fatalf("expected token from Authorization header, got %q", got)
}
}
// TestReadSessionToken_QueryParam — the new SSE path. EventSource cannot
// set custom headers, so the chroot SPA appends ?access_token=<jwt> to
// the stream URL. This is the regression test for the 13-page chroot
// SSE 401 incident (TC-066/067/071-076/078-081/086).
func TestReadSessionToken_QueryParam(t *testing.T) {
cfg := &Config{}
tok := fakeJWT()
r := httptest.NewRequest(http.MethodGet,
"/api/v1/sovereigns/sov/k8s/stream?kinds=pod&access_token="+tok, nil)
got := cfg.ReadSessionToken(r)
if got != tok {
t.Fatalf("expected token from access_token query param, got %q", got)
}
}
// TestReadSessionToken_QueryParam_BadShape — defence in depth: a value
// in ?access_token= that doesn't look like a JWT must NOT be returned,
// otherwise a malformed value would reach ValidateToken with a JWKS
// round-trip cost we don't want to pay on the 401 hot path.
func TestReadSessionToken_QueryParam_BadShape(t *testing.T) {
cfg := &Config{}
for _, bad := range []string{"", "not.a.jwt.here", "no-dots", "two.parts"} {
r := httptest.NewRequest(http.MethodGet,
"/api/v1/x?access_token="+bad, nil)
if got := cfg.ReadSessionToken(r); got != "" {
t.Fatalf("bad-shape %q: expected empty, got %q", bad, got)
}
}
}
// TestReadSessionToken_HeaderWinsOverQueryParam — when both channels
// carry a token, the header wins. Operators occasionally land on a
// chroot URL with a stale ?access_token= still in the address bar after
// a refresh; the live header from the fetch interceptor is the source
// of truth.
func TestReadSessionToken_HeaderWinsOverQueryParam(t *testing.T) {
cfg := &Config{}
headerTok := "header.payload.sig"
queryTok := fakeJWT()
r := httptest.NewRequest(http.MethodGet,
"/api/v1/x?access_token="+queryTok, nil)
r.Header.Set("Authorization", "Bearer "+headerTok)
got := cfg.ReadSessionToken(r)
if got != headerTok {
t.Fatalf("expected header token to win, got %q", got)
}
}
// TestReadSessionToken_QueryParamThenCookie — query-param wins over
// cookie when both are present (preserves the SSE path's intent: the
// SPA explicitly opted-in to the query-param channel for this
// connection).
func TestReadSessionToken_QueryParamThenCookie(t *testing.T) {
cfg := &Config{}
queryTok := fakeJWT()
cookieTok := "cookie.payload.sig"
r := httptest.NewRequest(http.MethodGet,
"/api/v1/x?access_token="+queryTok, nil)
r.AddCookie(&http.Cookie{Name: SessionCookieName, Value: cookieTok})
got := cfg.ReadSessionToken(r)
if got != queryTok {
t.Fatalf("expected query-param token to win over cookie, got %q", got)
}
}
// TestReadSessionToken_CookieFallback — when no header and no query
// param are present, the catalyst_session cookie still works (mother
// PIN-auth path is unchanged).
func TestReadSessionToken_CookieFallback(t *testing.T) {
cfg := &Config{}
tok := fakeJWT()
r := httptest.NewRequest(http.MethodGet, "/api/v1/x", nil)
r.AddCookie(&http.Cookie{Name: SessionCookieName, Value: tok})
got := cfg.ReadSessionToken(r)
if got != tok {
t.Fatalf("expected cookie fallback, got %q", got)
}
}
// TestReadSessionToken_NoToken — no header, no query, no cookie → empty.
func TestReadSessionToken_NoToken(t *testing.T) {
cfg := &Config{}
r := httptest.NewRequest(http.MethodGet, "/api/v1/x", nil)
if got := cfg.ReadSessionToken(r); got != "" {
t.Fatalf("expected empty, got %q", got)
}
}
// TestReadSessionToken_QueryParamWhitespace — trim leading/trailing
// whitespace on the query param value (some clients URL-decode "+" to
// space). A whitespace-only value is rejected.
func TestReadSessionToken_QueryParamWhitespace(t *testing.T) {
cfg := &Config{}
tok := fakeJWT()
r := httptest.NewRequest(http.MethodGet,
"/api/v1/x?access_token="+strings.Repeat(" ", 0)+tok, nil)
if got := cfg.ReadSessionToken(r); got != tok {
t.Fatalf("expected token, got %q", got)
}
r2 := httptest.NewRequest(http.MethodGet, "/api/v1/x?access_token=%20%20%20", nil)
if got := cfg.ReadSessionToken(r2); got != "" {
t.Fatalf("expected empty for whitespace-only token, got %q", got)
}
}

View File

@ -198,4 +198,35 @@ describe('useK8sStream', () => {
renderHook(() => useK8sStream({ sovereignId: 'alpha', kinds: [] }))
expect(activeES?.url).not.toContain('kinds=')
})
// Regression: chroot Sovereign Console SSE auth — EventSource cannot
// attach Authorization: Bearer, so the hook must append the OIDC
// access token from sessionStorage as a `?access_token=<jwt>` query
// parameter. catalyst-api ReadSessionToken accepts the param. Closes
// the 13-page chroot list-view 401 incident (TC-066/067/071-076/etc).
it('appends access_token query param when an OIDC token is in sessionStorage', () => {
sessionStorage.setItem('oidc:id_token', 'fake.id.tok')
sessionStorage.setItem('oidc:access_token', 'fake.access.tok')
sessionStorage.setItem('oidc:expires_at', String(Date.now() + 60_000))
try {
renderHook(() =>
useK8sStream({ sovereignId: 'alpha', kinds: ['pod'] }),
)
expect(activeES?.url).toContain('access_token=fake.access.tok')
} finally {
sessionStorage.removeItem('oidc:id_token')
sessionStorage.removeItem('oidc:access_token')
sessionStorage.removeItem('oidc:expires_at')
}
})
it('omits access_token query param when no OIDC token is stored (mother / cookie path)', () => {
sessionStorage.removeItem('oidc:id_token')
sessionStorage.removeItem('oidc:access_token')
sessionStorage.removeItem('oidc:expires_at')
renderHook(() =>
useK8sStream({ sovereignId: 'alpha', kinds: ['pod'] }),
)
expect(activeES?.url ?? '').not.toContain('access_token=')
})
})

View File

@ -35,6 +35,7 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { API_BASE } from '@/shared/config/urls'
import { loadTokens } from '@/shared/lib/oidc'
/* ── Wire types ──────────────────────────────────────────────────── */
@ -143,6 +144,18 @@ function buildStreamURL(sovereignId: string, kinds: readonly string[], initialSt
if (initialState) {
params.set('initialState', '1')
}
// EventSource cannot carry custom request headers, so the chroot SPA
// must attach the OIDC access token as a `?access_token=<jwt>` query
// parameter. catalyst-api's ReadSessionToken treats the param as a
// fallback channel after Authorization: Bearer and validates it via
// the same JWKS path. Mother (Catalyst-Zero) sessions store no OIDC
// tokens, so the param is omitted and the catalyst_session cookie
// continues to carry auth via withCredentials. Aligns with Grafana /
// Loki SSE auth conventions.
const tokens = loadTokens()
if (tokens?.accessToken) {
params.set('access_token', tokens.accessToken)
}
const qs = params.toString()
return `${API_BASE}/v1/sovereigns/${safeId}/k8s/stream${qs ? '?' + qs : ''}`
}

View File

@ -29,6 +29,7 @@
import { useEffect, useRef, useState } from 'react'
import { API_BASE } from '@/shared/config/urls'
import { loadTokens } from '@/shared/lib/oidc'
/**
* Kinds the architecture graph subscribes to. The list lives at the
@ -174,9 +175,23 @@ export function useK8sCacheStream(
snapshotRef.current = new Map()
setState({ snapshot: snapshotRef.current, status: 'connecting', revision: 0 })
// EventSource cannot carry custom headers (no Authorization: Bearer
// path), so on Sovereign mode the chroot SPA must attach the OIDC
// access token as a query parameter. The catalyst-api ReadSessionToken
// accepts `?access_token=<jwt>` as a fallback channel and validates
// it through the same JWKS path as the header. Mother (Catalyst-Zero)
// sessions have no OIDC tokens in sessionStorage, so the param is
// omitted and the existing catalyst_session cookie carries auth.
const params = new URLSearchParams()
params.set('kinds', kinds.join(','))
params.set('initialState', '1')
const tokens = loadTokens()
if (tokens?.accessToken) {
params.set('access_token', tokens.accessToken)
}
const url = `${API_BASE}/v1/sovereigns/${encodeURIComponent(
deploymentId,
)}/k8s/stream?kinds=${kinds.join(',')}&initialState=1`
)}/k8s/stream?${params.toString()}`
const es = new EventSource(url, { withCredentials: true })
es.onopen = () => {