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:
parent
23558f90a7
commit
369b60ec5c
@ -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
|
||||
|
||||
@ -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 ""
|
||||
|
||||
147
products/catalyst/bootstrap/api/internal/auth/session_test.go
Normal file
147
products/catalyst/bootstrap/api/internal/auth/session_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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=')
|
||||
})
|
||||
})
|
||||
|
||||
@ -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 : ''}`
|
||||
}
|
||||
|
||||
@ -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 = () => {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user