From a95a769a08728f7f15a833277c3ed013a5af75b1 Mon Sep 17 00:00:00 2001 From: pk910 Date: Wed, 22 Jul 2026 23:54:27 +0200 Subject: [PATCH] update authenticatoor client for shared session management & background token refresh --- pkg/web/types/config.go | 3 +- web-ui/src/api/ai.ts | 66 ++++++++------- web-ui/src/api/client.ts | 6 +- web-ui/src/context/AuthContext.tsx | 3 +- web-ui/src/hooks/useAuth.ts | 2 +- web-ui/src/hooks/useEventStream.ts | 95 +++++++++++---------- web-ui/src/stores/authStore.ts | 130 ++++++++++++++++++----------- web-ui/src/types/api.ts | 4 +- web-ui/src/utils/runtimeConfig.ts | 34 +++++--- 9 files changed, 200 insertions(+), 143 deletions(-) diff --git a/pkg/web/types/config.go b/pkg/web/types/config.go index c82c4c56..da7a85d2 100644 --- a/pkg/web/types/config.go +++ b/pkg/web/types/config.go @@ -14,7 +14,8 @@ type WebConfig struct { // AuthProviderURL is the canonical URL of a remote authenticatoor // service. When set, API requests must carry a JWT verified against - // the service's JWKS, and the SPA loads /client.js to drive + // the service's JWKS, and the SPA loads /client-v2.js (the + // shared-session client with automatic token refresh) to drive // login/logout. When empty, the API is unauthenticated. AuthProviderURL string `yaml:"authProviderUrl" envconfig:"WEB_AUTH_PROVIDER_URL"` diff --git a/web-ui/src/api/ai.ts b/web-ui/src/api/ai.ts index ea7ee284..dcce5012 100644 --- a/web-ui/src/api/ai.ts +++ b/web-ui/src/api/ai.ts @@ -76,8 +76,8 @@ interface ApiResponse { data: T; } -function getAuthHeaders(): Record { - const authHeader = authStore.getAuthHeader(); +async function getAuthHeaders(): Promise> { + const authHeader = await authStore.getAuthHeader(); const headers: Record = { 'Content-Type': 'application/json', }; @@ -90,7 +90,7 @@ function getAuthHeaders(): Record { } async function fetchAIApi(endpoint: string, options?: RequestInit): Promise { - const headers = getAuthHeaders(); + const headers = await getAuthHeaders(); const response = await fetch(`${API_BASE}${endpoint}`, { ...options, @@ -149,40 +149,48 @@ export function streamAISession( onError: (error: Error) => void, onComplete: () => void ): () => void { - const authHeader = authStore.getAuthHeader(); - let url = `${API_BASE}/ai/chat/${sessionId}/stream`; - - // Add auth token as query param for SSE (headers don't work well with EventSource) - if (authHeader) { - const token = authHeader.replace('Bearer ', ''); - url += `?token=${encodeURIComponent(token)}`; - } + let eventSource: EventSource | null = null; + let closed = false; + + // The fresh token comes from the auth client asynchronously; open the + // stream once it resolves (no-op if the caller already cleaned up). + void (async () => { + const token = await authStore.getAuthToken(); + if (closed) return; + + let url = `${API_BASE}/ai/chat/${sessionId}/stream`; + // Add auth token as query param for SSE (headers don't work well with EventSource) + if (token) { + url += `?token=${encodeURIComponent(token)}`; + } - const eventSource = new EventSource(url); + eventSource = new EventSource(url); - eventSource.addEventListener('update', (event) => { - try { - const session = JSON.parse(event.data) as AISession; - onUpdate(session); + eventSource.addEventListener('update', (event) => { + try { + const session = JSON.parse(event.data) as AISession; + onUpdate(session); - // Close connection when session is complete or errored - if (session.status === 'complete' || session.status === 'error') { - eventSource.close(); - onComplete(); + // Close connection when session is complete or errored + if (session.status === 'complete' || session.status === 'error') { + eventSource?.close(); + onComplete(); + } + } catch (err) { + onError(err instanceof Error ? err : new Error('Failed to parse session update')); } - } catch (err) { - onError(err instanceof Error ? err : new Error('Failed to parse session update')); - } - }); + }); - eventSource.onerror = () => { - eventSource.close(); - onError(new Error('Connection to session stream failed')); - }; + eventSource.onerror = () => { + eventSource?.close(); + onError(new Error('Connection to session stream failed')); + }; + })(); // Return cleanup function return () => { - eventSource.close(); + closed = true; + eventSource?.close(); }; } diff --git a/web-ui/src/api/client.ts b/web-ui/src/api/client.ts index e8ab8301..8ae572ba 100644 --- a/web-ui/src/api/client.ts +++ b/web-ui/src/api/client.ts @@ -47,7 +47,7 @@ async function fetchApi(endpoint: string, options?: RequestInit): Promise // Fetch API with Authorization header for protected endpoints async function fetchApiWithAuth(endpoint: string, options?: RequestInit): Promise { - const authHeader = authStore.getAuthHeader(); + const authHeader = await authStore.getAuthHeader(); const headers: Record = { 'Content-Type': 'application/json', ...(options?.headers as Record), @@ -225,7 +225,7 @@ export async function deleteTest(testId: string): Promise { export async function registerTest(yaml: string): Promise { // Send raw YAML with application/yaml content type // The backend expects either YAML body or JSON with test fields directly - const authHeader = authStore.getAuthHeader(); + const authHeader = await authStore.getAuthHeader(); const headers: Record = { 'Content-Type': 'application/yaml', }; @@ -282,7 +282,7 @@ export async function getDashboardConfig(): Promise { } export async function putDashboardConfig(cfg: unknown): Promise { - const authHeader = authStore.getAuthHeader(); + const authHeader = await authStore.getAuthHeader(); const headers: Record = { 'Content-Type': 'application/json', }; diff --git a/web-ui/src/context/AuthContext.tsx b/web-ui/src/context/AuthContext.tsx index 82244e0f..f8188c17 100644 --- a/web-ui/src/context/AuthContext.tsx +++ b/web-ui/src/context/AuthContext.tsx @@ -5,10 +5,9 @@ interface AuthContextValue { authEnabled: boolean; isLoggedIn: boolean; user: string | null; - token: string | null; expiresAt: number | null; loading: boolean; - getAuthHeader: () => string | null; + getAuthHeader: () => Promise; login: () => void; logout: () => void; refreshToken: () => Promise; diff --git a/web-ui/src/hooks/useAuth.ts b/web-ui/src/hooks/useAuth.ts index 6522db1a..b96f9e92 100644 --- a/web-ui/src/hooks/useAuth.ts +++ b/web-ui/src/hooks/useAuth.ts @@ -22,7 +22,7 @@ export function useAuth() { return unsubscribe; }, []); - const getAuthHeader = useCallback((): string | null => { + const getAuthHeader = useCallback((): Promise => { return authStore.getAuthHeader(); }, []); diff --git a/web-ui/src/hooks/useEventStream.ts b/web-ui/src/hooks/useEventStream.ts index 49665b77..2885cdf9 100644 --- a/web-ui/src/hooks/useEventStream.ts +++ b/web-ui/src/hooks/useEventStream.ts @@ -15,6 +15,8 @@ export function useEventStream(options: UseEventStreamOptions = {}) { const queryClient = useQueryClient(); const eventSourceRef = useRef(null); const reconnectTimeoutRef = useRef | null>(null); + // Invalidates in-flight async connects (token fetch) on reconnect/unmount. + const connectSeqRef = useRef(0); const handleEvent = useCallback( (event: SSEEvent) => { @@ -89,54 +91,62 @@ export function useEventStream(options: UseEventStreamOptions = {}) { reconnectTimeoutRef.current = null; } - // Build URL based on whether we're watching a specific run - const authHeader = authStore.getAuthHeader(); - const authToken = authHeader?.startsWith('Bearer ') ? authHeader.slice('Bearer '.length) : undefined; - const baseUrl = runId ? `/api/v1/test_run/${runId}/events` : '/api/v1/events/stream'; - const url = authToken ? `${baseUrl}?token=${encodeURIComponent(authToken)}` : baseUrl; - - const eventSource = new EventSource(url); - eventSourceRef.current = eventSource; - - const handleMessage = (event: MessageEvent) => { - try { - const data: SSEEvent = JSON.parse(event.data); - handleEvent(data); - } catch (error) { - console.error('Failed to parse SSE event:', error); - } - }; - - eventSource.onmessage = handleMessage; - - const eventTypes = [ - 'connected', - 'test.started', - 'test.completed', - 'test.failed', - 'task.created', - 'task.started', - 'task.progress', - 'task.completed', - 'task.failed', - 'task.log', - ]; - - eventTypes.forEach((eventType) => { - eventSource.addEventListener(eventType, handleMessage); - }); - - eventSource.onerror = () => { - eventSource.close(); - // Reconnect after 5 seconds - reconnectTimeoutRef.current = setTimeout(connect, 5000); - }; + const seq = ++connectSeqRef.current; + + void (async () => { + // Fresh token from the auth client for the SSE query param + // (EventSource can't send headers). + const authToken = (await authStore.getAuthToken()) ?? undefined; + if (seq !== connectSeqRef.current) return; // superseded meanwhile + + // Build URL based on whether we're watching a specific run + const baseUrl = runId ? `/api/v1/test_run/${runId}/events` : '/api/v1/events/stream'; + const url = authToken ? `${baseUrl}?token=${encodeURIComponent(authToken)}` : baseUrl; + + const eventSource = new EventSource(url); + eventSourceRef.current = eventSource; + + const handleMessage = (event: MessageEvent) => { + try { + const data: SSEEvent = JSON.parse(event.data); + handleEvent(data); + } catch (error) { + console.error('Failed to parse SSE event:', error); + } + }; + + eventSource.onmessage = handleMessage; + + const eventTypes = [ + 'connected', + 'test.started', + 'test.completed', + 'test.failed', + 'task.created', + 'task.started', + 'task.progress', + 'task.completed', + 'task.failed', + 'task.log', + ]; + + eventTypes.forEach((eventType) => { + eventSource.addEventListener(eventType, handleMessage); + }); + + eventSource.onerror = () => { + eventSource.close(); + // Reconnect after 5 seconds + reconnectTimeoutRef.current = setTimeout(connect, 5000); + }; + })(); }, [runId, handleEvent]); useEffect(() => { connect(); return () => { + connectSeqRef.current++; // cancel any in-flight async connect if (eventSourceRef.current) { eventSourceRef.current.close(); } @@ -147,6 +157,7 @@ export function useEventStream(options: UseEventStreamOptions = {}) { }, [connect]); const disconnect = useCallback(() => { + connectSeqRef.current++; // cancel any in-flight async connect if (eventSourceRef.current) { eventSourceRef.current.close(); eventSourceRef.current = null; diff --git a/web-ui/src/stores/authStore.ts b/web-ui/src/stores/authStore.ts index 97778ff5..48598bea 100644 --- a/web-ui/src/stores/authStore.ts +++ b/web-ui/src/stores/authStore.ts @@ -2,25 +2,38 @@ import type { AuthState } from '../types/api'; import { getRuntimeConfig, type AuthenticatoorLib, + type AuthTokenInfo, } from '../utils/runtimeConfig'; // Singleton auth store. Drives authentication via the centralized -// authenticatoor service when window.ethpandaops.assertoor.config -// .authProviderURL is set (injected by the backend into index.html); -// otherwise runs in "open" mode (no auth required). +// authenticatoor service (v2 shared-session client) when +// window.ethpandaops.assertoor.config.authProviderURL is set (injected by +// the backend into index.html); otherwise runs in "open" mode (no auth +// required). +// +// v2 model: the authenticatoor client mounts a hidden iframe on the auth +// service origin which owns the session — it refreshes tokens before +// expiry and keeps login state in sync across every ethpandaops app and +// tab. This store simply mirrors the client's "status" events into React +// state and asks the client for a fresh token on every API call. No token +// is ever cached in the app. // // Public surface: // authStore.initialize(): kicks off boot — must be called once at app start. // authStore.getState(): synchronous current state. // authStore.subscribe(fn): change subscription, returns unsubscribe. -// authStore.getAuthHeader(): "Bearer " for API calls, or null. -// authStore.login(): full-page redirect to authenticatoor /auth/login. -// authStore.logout(): clear local session. -// authStore.refreshToken(): re-attempt the iframe path; returns new state. +// authStore.getAuthHeader(): Promise<"Bearer " | null> — fresh +// header for API calls (fetched from the auth client every time). +// authStore.getAuthToken(): Promise — fresh raw token, +// for SSE query params where headers can't be used. +// authStore.login(): full-page redirect to authenticatoor /auth/login +// (resolves without navigating when already authenticated). +// authStore.logout(): global logout — all apps/tabs converge. +// authStore.refreshToken(): force a token fetch; returns new state. // // In open mode: // - authEnabled = false, isLoggedIn = true (UI treats user as authorized) -// - getAuthHeader returns null (no Authorization header on requests) +// - getAuthHeader/getAuthToken resolve null (no auth on requests) // - login()/logout() are no-ops (and the UI should hide login controls) const AUTH_STATE_CHANGE_EVENT = 'assertoor_auth_state_change'; @@ -31,7 +44,6 @@ const OPEN_STATE: AuthState = { authEnabled: false, isLoggedIn: true, // open mode: treat as authorized user: null, - token: null, expiresAt: null, }; @@ -39,7 +51,6 @@ const ANON_STATE: AuthState = { authEnabled: true, isLoggedIn: false, user: null, - token: null, expiresAt: null, }; @@ -74,6 +85,18 @@ class AuthStore { this.listeners.forEach((l) => l(this.state)); } + // Mirror a TokenInfo pushed by the auth client into our state. The + // "refreshing" status still carries authenticated=true while the old + // token is valid, so the UI doesn't flicker during background refreshes. + private applyTokenInfo = (info: AuthTokenInfo): void => { + this.setState({ + authEnabled: true, + isLoggedIn: info.authenticated, + user: info.authenticated ? info.user || null : null, + expiresAt: info.authenticated && info.exp ? info.exp * 1000 : null, + }); + }; + private loadAuthScript(authProviderURL: string): Promise { return new Promise((resolve, reject) => { if (window.ethpandaops?.authenticatoor) { @@ -81,7 +104,7 @@ class AuthStore { return; } const script = document.createElement('script'); - script.src = authProviderURL.replace(/\/+$/, '') + '/client.js'; + script.src = authProviderURL.replace(/\/+$/, '') + '/client-v2.js'; script.async = true; script.onload = () => { if (window.ethpandaops?.authenticatoor) resolve(); @@ -106,10 +129,9 @@ class AuthStore { return; } - // Remote mode — load the authenticatoor client library and run its - // checkLogin (fragment → cache → silent iframe). Treat the user as - // anonymous in the meantime; re-render when the promise resolves - // with authenticated=true. + // Remote mode — load the v2 authenticatoor client and mirror its + // status events. Treat the user as anonymous until the first status + // arrives (the client replays the current state on subscribe). this.setState({ ...ANON_STATE }); try { @@ -121,25 +143,22 @@ class AuthStore { } this.lib = window.ethpandaops?.authenticatoor ?? null; - if (!this.lib) { - console.error('authStore: ethpandaops.authenticatoor not available after load'); + if (!this.lib || typeof this.lib.addEventListener !== 'function') { + console.error('authStore: ethpandaops.authenticatoor (v2) not available after load'); + this.lib = null; this.initialized = true; return; } + // Every future session change (login/logout/refresh in ANY app or + // tab) lands here and re-renders the top bar. + this.lib.addEventListener('status', this.applyTokenInfo); + try { - const info = await this.lib.checkLogin(); - if (info.authenticated) { - this.setState({ - authEnabled: true, - isLoggedIn: true, - user: info.user || null, - token: info.token, - expiresAt: info.exp * 1000, - }); - } + // Settle the initial state before resolving initialization. + this.applyTokenInfo(await this.lib.getStatus()); } catch (e) { - console.error('authStore: checkLogin failed', e); + console.error('authStore: getStatus failed', e); } this.initialized = true; @@ -149,26 +168,16 @@ class AuthStore { } /** - * Re-attempt the silent token acquisition path. Useful when an API call - * comes back 401 — the token may have expired. + * Force a token fetch through the auth client (the client refreshes via + * its shared frame when needed). Useful when an API call comes back 401. */ async refreshToken(): Promise { if (!this.state.authEnabled || !this.lib) return this.state; try { - const info = await this.lib.checkLogin(); - if (info.authenticated) { - this.setState({ - authEnabled: true, - isLoggedIn: true, - user: info.user || null, - token: info.token, - expiresAt: info.exp * 1000, - }); - } else { - this.setState({ ...ANON_STATE }); - } + await this.lib.getToken(); + this.applyTokenInfo(await this.lib.getStatus()); } catch { - this.setState({ ...ANON_STATE }); + // Keep current state; the status listener reports the real outcome. } return this.state; } @@ -178,14 +187,29 @@ class AuthStore { } /** - * Returns "Bearer " to attach to API calls, or null when no auth - * is required (open mode) or the user isn't authenticated yet. + * Returns a fresh raw bearer token, or null when no auth is required + * (open mode) or the user isn't authenticated. Always asks the auth + * client — never cached here — so a token refreshed by the shared frame + * is picked up immediately. Use for SSE query params; API calls should + * use getAuthHeader(). */ - getAuthHeader(): string | null { + async getAuthToken(): Promise { if (!this.state.authEnabled) return null; - if (!this.state.token) return null; - if (this.state.expiresAt && this.state.expiresAt < Date.now()) return null; - return `Bearer ${this.state.token}`; + if (!this.lib) return null; + try { + return await this.lib.getToken(); + } catch { + return null; + } + } + + /** + * Returns "Bearer " to attach to API calls, or null when no auth + * is required (open mode) or the user isn't authenticated. + */ + async getAuthHeader(): Promise { + const token = await this.getAuthToken(); + return token ? `Bearer ${token}` : null; } subscribe(listener: AuthStateListener): () => void { @@ -197,17 +221,21 @@ class AuthStore { login(): void { if (!this.state.authEnabled) return; - this.lib?.login(); + void this.lib?.login(); } logout(): void { if (!this.state.authEnabled) return; - this.lib?.logout(); + // Global logout: the shared frame clears the session everywhere; our + // status listener flips the state when it lands. Set it eagerly too + // so the UI reacts instantly. + void this.lib?.logout(); this.setState({ ...ANON_STATE }); } destroy(): void { window.removeEventListener(AUTH_STATE_CHANGE_EVENT, this.handleExternalStateChange); + this.lib?.removeEventListener('status', this.applyTokenInfo); this.listeners.clear(); } } diff --git a/web-ui/src/types/api.ts b/web-ui/src/types/api.ts index ae636258..7fd33ce5 100644 --- a/web-ui/src/types/api.ts +++ b/web-ui/src/types/api.ts @@ -12,7 +12,9 @@ export interface AuthState { authEnabled: boolean; isLoggedIn: boolean; user: string | null; - token: string | null; + // Expiry of the current session's token (ms). Display-only: the token + // itself is never cached in the app — authStore.getAuthHeader() fetches + // a fresh one from the authenticatoor client per request. expiresAt: number | null; // Local timestamp } diff --git a/web-ui/src/utils/runtimeConfig.ts b/web-ui/src/utils/runtimeConfig.ts index 47e538c2..0e3df435 100644 --- a/web-ui/src/utils/runtimeConfig.ts +++ b/web-ui/src/utils/runtimeConfig.ts @@ -17,20 +17,28 @@ declare global { } } -// Minimal shape of window.ethpandaops.authenticatoor we rely on. Set by -// the authenticatoor's client.js script (loaded at runtime when an auth -// provider is configured). +// TokenInfo pushed by the v2 authenticatoor client on every session +// change ("status" events) and returned by getStatus(). +export interface AuthTokenInfo { + status: 'unauthenticated' | 'authenticated' | 'refreshing'; + authenticated: boolean; + user: string; + exp: number; +} + +// Minimal shape of the v2 window.ethpandaops.authenticatoor we rely on. +// Set by the authenticatoor's client.js?v=2 script (loaded at runtime +// when an auth provider is configured). The client owns the session — +// shared across all ethpandaops apps via a hidden iframe — and refreshes +// tokens before expiry; getToken() always resolves to a fresh token. export interface AuthenticatoorLib { - checkLogin: () => Promise<{ - authenticated: boolean; - token: string; - exp: number; - user: string; - }>; - login: () => void; - logout: () => void; - getToken: () => string | null; - isLoggedIn: () => boolean; + version?: number; + addEventListener: (type: 'status', cb: (info: AuthTokenInfo) => void) => void; + removeEventListener: (type: 'status', cb: (info: AuthTokenInfo) => void) => void; + getStatus: () => Promise; + getToken: () => Promise; + login: () => Promise; + logout: () => Promise; authServiceURL: () => string; }