diff --git a/VERSION b/VERSION index 4b9fcbe..cb0c939 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.5.1 +0.5.2 diff --git a/plans/2026-07-14-post-fix-session-remainders.md b/plans/2026-07-14-post-fix-session-remainders.md index 0fbcf97..2c47fb4 100644 --- a/plans/2026-07-14-post-fix-session-remainders.md +++ b/plans/2026-07-14-post-fix-session-remainders.md @@ -6,6 +6,22 @@ e2e-validated, committed (`337d577`), pushed to `main`, and deployed to next blocker** (refuse `complete_task` without writeback — the knowledge loop is still drifting). +**2026-07-14 (PM) — OIDC token-refresh fix (unplanned, root-cause for the +empty graph symptom):** the overview background graph and the Knowledge Base +graph both rendered empty because the SPA's OIDC access token expired +(~5 min TTL) and was never refreshed. `fetchWithAuth` called `getToken()` +synchronously (no refresh); `ensureToken` returned the stale token without +refreshing; `storeTokens` discarded `expires_in`; and the resulting 401 +made `fetchGraph` return `null` → both graphs drew nothing, with no error +surfaced. Fixed structurally in `web/src/lib/oidc.ts` + +`web/src/lib/config.ts` + `web/src/lib/stores/events.ts`: tokens now carry +`expiresAt`, `getToken()` returns null within 30s of expiry, `fetchWithAuth` +awaits `ensureToken()` (refreshes on demand), `sseUrl` is async + refreshes +before constructing the EventSource, and a 401 flushes the OIDC session so +the static token fallback takes over. Build passes. Not yet committed or +deployed (pending operator verification). Not part of any numbered phase +above — filed here because it was the highest-impact surface symptom. + ## Shipped (2026-07-14, v0.5.0 — commit 337d577, deployed) | Fix | File(s) | Validation | diff --git a/plans/index.md b/plans/index.md index 1773474..e32aa6c 100644 --- a/plans/index.md +++ b/plans/index.md @@ -17,7 +17,7 @@ went sideways, open an investigation. | 2026-07-14 | [Session reliability & UX audit](2026-07-14-session-reliability-and-ux-audit.md) | Done — all 21 fixes deployed | | 2026-07-14 | [Tool timeline in sidebar](2026-07-14-tool-timeline-sidebar.md) | Done — deployed v0.3.2 | | 2026-07-14 | [Unified agent activity indicator](2026-07-14-unified-agent-indicator.md) | Done — deployed v0.3.3 | -| 2026-07-14 | [Post-fix session remainders: empty responses & plan drift](2026-07-14-post-fix-session-remainders.md) | In Progress — Phases A + B.1-B.3 + F.3 shipped, e2e-validated, committed (337d577), deployed v0.5.0; Phases C, D, E, F.1-F.2 remain (D.1 is the next blocker) | +| 2026-07-14 | [Post-fix session remainders: empty responses & plan drift](2026-07-14-post-fix-session-remainders.md) | In Progress — Phases A + B.1-B.3 + F.3 shipped, e2e-validated, committed (337d577), deployed v0.5.0; Phases C, D, E, F.1-F.2 remain (D.1 next blocker). **PM:** OIDC token-refresh fix (root-cause for empty graphs) implemented, not yet committed/deployed | ## Done diff --git a/web/src/lib/config.ts b/web/src/lib/config.ts index 7361fa2..de60d1c 100644 --- a/web/src/lib/config.ts +++ b/web/src/lib/config.ts @@ -4,7 +4,7 @@ // or cross-origin (Wails webview, remote access). See // plans/2026-07-12-wails-desktop-app.md 0.2. -import { getToken, isOIDCConfigured } from './oidc' +import { isOIDCConfigured, ensureToken, logout as oidcLogout } from './oidc' export interface OikosConfig { apiUrl: string // e.g. "https://oikos.hubris.network", or "" for same-origin @@ -67,9 +67,18 @@ export function apiBase(path: string): string { } // Resolves the auth token for a request: OIDC takes precedence, then static. -function resolveAuthHeader(): string | null { - const oidcToken = getToken() - if (oidcToken) return `Bearer ${oidcToken}` +// OIDC is async (may need to refresh an expired access_token); the static +// fallback is synchronous. Returns the header value or null. +async function resolveAuthHeader(): Promise { + // Try OIDC first. ensureToken() refreshes if the cached token is expired or + // missing; if it returns a token we use it. + if (isOIDCConfigured()) { + const tok = await ensureToken() + if (tok) return `Bearer ${tok}` + // OIDC session exists but couldn't yield a usable token (e.g. expired + // access_token with no refresh_token). Fall through to the static token + // if one was configured — better than a blanket 401. + } const c = getConfig() if (c.token) return `Bearer ${c.token}` return null @@ -79,27 +88,47 @@ function resolveAuthHeader(): string | null { // Prepends the API base URL (absolute when configured, relative when unset // for the Vite dev proxy / same-origin prod) and adds the Authorization // header. Used by every fetch call in api.ts. +// +// OIDC tokens are short-lived; this wrapper awaits ensureToken() so an +// expired access_token is refreshed before the request goes out, rather than +// 401ing on the wire. On a 401 we flush the OIDC session once so the next +// request can fall back to the static token (or re-prompt the operator). export async function fetchWithAuth(path: string, opts?: RequestInit): Promise { const headers: Record = { 'Content-Type': 'application/json', ...(opts?.headers as Record ?? {}) } - const authH = resolveAuthHeader() + const authH = await resolveAuthHeader() if (authH) { headers['Authorization'] = authH } - return fetch(apiBase(path), { ...opts, headers }) + const res = await fetch(apiBase(path), { ...opts, headers }) + + // A 401 on a request we sent an Authorization header for means the token + // the server just rejected is no longer valid. If OIDC is in use, clear it + // so resolveAuthHeader() falls back to the static token next time (or the + // operator gets re-prompted to log in). Don't loop: only one flush, and + // only when we actually sent an Authorization header. + if (res.status === 401 && authH && isOIDCConfigured()) { + oidcLogout() + } + return res } // SSE path builder — EventSource doesn't take headers, so pass the token as // a query parameter (the SSE handler's combinedAuth checks it alongside the -// Authorization header, only for this route). -export function sseUrl(path: string): string { +// Authorization header, only for this route). Async so the OIDC access token +// can be refreshed before the EventSource is constructed. +export async function sseUrl(path: string): Promise { const url = apiBase(path) const c = getConfig() - const oidcToken = getToken() - const token = oidcToken ?? c.token + // Prefer a fresh OIDC token (refreshes if expired); fall back to the static token. + let token: string | null = null + if (isOIDCConfigured()) { + token = await ensureToken() + } + if (!token) token = c.token ?? null if (!token) return url const sep = url.includes('?') ? '&' : '?' return `${url}${sep}token=${encodeURIComponent(token)}` diff --git a/web/src/lib/oidc.ts b/web/src/lib/oidc.ts index c5bbed1..c432b73 100644 --- a/web/src/lib/oidc.ts +++ b/web/src/lib/oidc.ts @@ -17,20 +17,27 @@ interface TokenResponse { interface OIDCState { config: OIDCConfig | null accessToken: string | null + expiresAt: number | null // epoch ms when access_token expires, or null if unknown refreshToken: string | null user: string | null refreshing: Promise | null } const SESSION_KEY = 'oidc_access_token' +const EXPIRES_KEY = 'oidc_expires_at' const REFRESH_KEY = 'oidc_refresh_token' const USER_KEY = 'oidc_user' const PKCE_KEY = 'oidc_pkce_verifier' const STATE_KEY = 'oidc_state' +// Skew margin: treat a token as expired this many ms before its real exp, +// so a refresh kicks in before a request races the wire and 401s. +const EXPIRY_SKEW_MS = 30_000 + let state: OIDCState = { config: null, accessToken: sessionStorage.getItem(SESSION_KEY), + expiresAt: Number(sessionStorage.getItem(EXPIRES_KEY)) || null, refreshToken: localStorage.getItem(REFRESH_KEY), user: localStorage.getItem(USER_KEY), refreshing: null @@ -172,7 +179,15 @@ function parseIDTokenUser(idToken: string): string | null { function storeTokens(tokens: TokenResponse) { state.accessToken = tokens.access_token + + // Track expiry so getToken()/ensureToken() can refresh proactively. Default + // to 5 min if the provider omits expires_in — a safe lower bound that keeps + // refresh on a sane cadence rather than treating the token as never-expiring. + const ttl = tokens.expires_in ?? 300 + state.expiresAt = Date.now() + ttl * 1000 + sessionStorage.setItem(SESSION_KEY, tokens.access_token) + sessionStorage.setItem(EXPIRES_KEY, String(state.expiresAt)) if (tokens.refresh_token) { state.refreshToken = tokens.refresh_token @@ -181,6 +196,12 @@ function storeTokens(tokens: TokenResponse) { } export function getToken(): string | null { + // Treat a token whose exp we never recorded (e.g. a pre-fix login) as + // usable once: if it's still valid server-side it'll pass; if not, the + // 401 handler in fetchWithAuth will flush it and trigger refresh. + if (state.expiresAt !== null && Date.now() >= state.expiresAt - EXPIRY_SKEW_MS) { + return null + } return state.accessToken } @@ -193,7 +214,10 @@ export function isOIDCAvailable(): boolean { } export async function ensureToken(): Promise { - if (state.accessToken) return state.accessToken + // getToken() returns null when the token is missing OR expired-but-present. + // Both cases should trigger a refresh if we have a refresh_token. + const tok = getToken() + if (tok) return tok if (state.refreshToken) { return refreshAccessToken() @@ -245,9 +269,11 @@ async function refreshAccessToken(): Promise { function clearTokens() { state.accessToken = null + state.expiresAt = null state.refreshToken = null state.user = null sessionStorage.removeItem(SESSION_KEY) + sessionStorage.removeItem(EXPIRES_KEY) localStorage.removeItem(REFRESH_KEY) localStorage.removeItem(USER_KEY) } @@ -257,14 +283,19 @@ export function logout(): void { } export function isOIDCConfigured(): boolean { - return !!(state.accessToken || state.refreshToken) + // A session counts as configured if there's a refresh_token (can recover an + // expired access_token) or a still-valid access_token. An expired access + // token with no refresh_token means we'd have to re-login, so don't claim + // OIDC is configured in that state — let the static token (if any) take over. + if (state.refreshToken) return true + return getToken() !== null } export async function initOIDC(): Promise { - if (state.accessToken) return true - - if (state.refreshToken) { - const token = await refreshAccessToken() + // Use ensureToken so an expired-but-present access_token (e.g. page reload + // mid-session) triggers a refresh instead of being returned as-is. + if (state.refreshToken || state.accessToken) { + const token = await ensureToken() return token !== null } diff --git a/web/src/lib/stores/events.ts b/web/src/lib/stores/events.ts index eb80481..482b2e0 100644 --- a/web/src/lib/stores/events.ts +++ b/web/src/lib/stores/events.ts @@ -20,11 +20,12 @@ export const connectionState = writable<'connecting' | 'open' | 'closed'>('conne let source: EventSource | null = null let subscriberCount = 0 -function connect() { +async function connect() { if (source) return connectionState.set('connecting') - // The browser's EventSource sends Last-Event-ID automatically on reconnect. - source = new EventSource(sseUrl('/api/v1/events/stream')) + // The browser's EventSource sends Last-event-ID automatically on reconnect. + // sseUrl is async so the OIDC access token is refreshed if expired. + source = new EventSource(await sseUrl('/api/v1/events/stream')) source.onopen = () => connectionState.set('open')