fix(web): refresh expired OIDC tokens before API calls
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled

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; the resulting 401 made fetchGraph return null and
both graphs drew nothing, with no error surfaced.

- oidc.ts: track expiresAt from expires_in; getToken() returns null within
  30s of expiry; ensureToken/initOIDC refresh instead of returning stale
  tokens; isOIDCConfigured no longer claims configured on expired-only state
- config.ts: fetchWithAuth awaits ensureToken (refresh on demand), falls
  back to static token if OIDC can't yield one, flushes OIDC session on 401;
  sseUrl is async + refreshes before constructing the EventSource
- stores/events.ts: connect() awaits the now-async sseUrl
This commit is contained in:
2026-07-14 20:42:07 +02:00
parent 3de359b85f
commit 3b98097f58
6 changed files with 98 additions and 21 deletions

View File

@@ -1 +1 @@
0.5.1 0.5.2

View File

@@ -6,6 +6,22 @@ e2e-validated, committed (`337d577`), pushed to `main`, and deployed to
next blocker** (refuse `complete_task` without writeback — the knowledge next blocker** (refuse `complete_task` without writeback — the knowledge
loop is still drifting). 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) ## Shipped (2026-07-14, v0.5.0 — commit 337d577, deployed)
| Fix | File(s) | Validation | | Fix | File(s) | Validation |

View File

@@ -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 | [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 | [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 | [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 ## Done

View File

@@ -4,7 +4,7 @@
// or cross-origin (Wails webview, remote access). See // or cross-origin (Wails webview, remote access). See
// plans/2026-07-12-wails-desktop-app.md 0.2. // 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 { export interface OikosConfig {
apiUrl: string // e.g. "https://oikos.hubris.network", or "" for same-origin 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. // Resolves the auth token for a request: OIDC takes precedence, then static.
function resolveAuthHeader(): string | null { // OIDC is async (may need to refresh an expired access_token); the static
const oidcToken = getToken() // fallback is synchronous. Returns the header value or null.
if (oidcToken) return `Bearer ${oidcToken}` async function resolveAuthHeader(): Promise<string | null> {
// 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() const c = getConfig()
if (c.token) return `Bearer ${c.token}` if (c.token) return `Bearer ${c.token}`
return null return null
@@ -79,27 +88,47 @@ function resolveAuthHeader(): string | null {
// Prepends the API base URL (absolute when configured, relative when unset // Prepends the API base URL (absolute when configured, relative when unset
// for the Vite dev proxy / same-origin prod) and adds the Authorization // for the Vite dev proxy / same-origin prod) and adds the Authorization
// header. Used by every fetch call in api.ts. // 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<Response> { export async function fetchWithAuth(path: string, opts?: RequestInit): Promise<Response> {
const headers: Record<string, string> = { const headers: Record<string, string> = {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
...(opts?.headers as Record<string, string> ?? {}) ...(opts?.headers as Record<string, string> ?? {})
} }
const authH = resolveAuthHeader() const authH = await resolveAuthHeader()
if (authH) { if (authH) {
headers['Authorization'] = 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 // 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 // a query parameter (the SSE handler's combinedAuth checks it alongside the
// Authorization header, only for this route). // Authorization header, only for this route). Async so the OIDC access token
export function sseUrl(path: string): string { // can be refreshed before the EventSource is constructed.
export async function sseUrl(path: string): Promise<string> {
const url = apiBase(path) const url = apiBase(path)
const c = getConfig() const c = getConfig()
const oidcToken = getToken() // Prefer a fresh OIDC token (refreshes if expired); fall back to the static token.
const token = oidcToken ?? c.token let token: string | null = null
if (isOIDCConfigured()) {
token = await ensureToken()
}
if (!token) token = c.token ?? null
if (!token) return url if (!token) return url
const sep = url.includes('?') ? '&' : '?' const sep = url.includes('?') ? '&' : '?'
return `${url}${sep}token=${encodeURIComponent(token)}` return `${url}${sep}token=${encodeURIComponent(token)}`

View File

@@ -17,20 +17,27 @@ interface TokenResponse {
interface OIDCState { interface OIDCState {
config: OIDCConfig | null config: OIDCConfig | null
accessToken: string | null accessToken: string | null
expiresAt: number | null // epoch ms when access_token expires, or null if unknown
refreshToken: string | null refreshToken: string | null
user: string | null user: string | null
refreshing: Promise<string | null> | null refreshing: Promise<string | null> | null
} }
const SESSION_KEY = 'oidc_access_token' const SESSION_KEY = 'oidc_access_token'
const EXPIRES_KEY = 'oidc_expires_at'
const REFRESH_KEY = 'oidc_refresh_token' const REFRESH_KEY = 'oidc_refresh_token'
const USER_KEY = 'oidc_user' const USER_KEY = 'oidc_user'
const PKCE_KEY = 'oidc_pkce_verifier' const PKCE_KEY = 'oidc_pkce_verifier'
const STATE_KEY = 'oidc_state' 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 = { let state: OIDCState = {
config: null, config: null,
accessToken: sessionStorage.getItem(SESSION_KEY), accessToken: sessionStorage.getItem(SESSION_KEY),
expiresAt: Number(sessionStorage.getItem(EXPIRES_KEY)) || null,
refreshToken: localStorage.getItem(REFRESH_KEY), refreshToken: localStorage.getItem(REFRESH_KEY),
user: localStorage.getItem(USER_KEY), user: localStorage.getItem(USER_KEY),
refreshing: null refreshing: null
@@ -172,7 +179,15 @@ function parseIDTokenUser(idToken: string): string | null {
function storeTokens(tokens: TokenResponse) { function storeTokens(tokens: TokenResponse) {
state.accessToken = tokens.access_token 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(SESSION_KEY, tokens.access_token)
sessionStorage.setItem(EXPIRES_KEY, String(state.expiresAt))
if (tokens.refresh_token) { if (tokens.refresh_token) {
state.refreshToken = tokens.refresh_token state.refreshToken = tokens.refresh_token
@@ -181,6 +196,12 @@ function storeTokens(tokens: TokenResponse) {
} }
export function getToken(): string | null { 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 return state.accessToken
} }
@@ -193,7 +214,10 @@ export function isOIDCAvailable(): boolean {
} }
export async function ensureToken(): Promise<string | null> { export async function ensureToken(): Promise<string | null> {
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) { if (state.refreshToken) {
return refreshAccessToken() return refreshAccessToken()
@@ -245,9 +269,11 @@ async function refreshAccessToken(): Promise<string | null> {
function clearTokens() { function clearTokens() {
state.accessToken = null state.accessToken = null
state.expiresAt = null
state.refreshToken = null state.refreshToken = null
state.user = null state.user = null
sessionStorage.removeItem(SESSION_KEY) sessionStorage.removeItem(SESSION_KEY)
sessionStorage.removeItem(EXPIRES_KEY)
localStorage.removeItem(REFRESH_KEY) localStorage.removeItem(REFRESH_KEY)
localStorage.removeItem(USER_KEY) localStorage.removeItem(USER_KEY)
} }
@@ -257,14 +283,19 @@ export function logout(): void {
} }
export function isOIDCConfigured(): boolean { 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<boolean> { export async function initOIDC(): Promise<boolean> {
if (state.accessToken) return true // 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) { if (state.refreshToken || state.accessToken) {
const token = await refreshAccessToken() const token = await ensureToken()
return token !== null return token !== null
} }

View File

@@ -20,11 +20,12 @@ export const connectionState = writable<'connecting' | 'open' | 'closed'>('conne
let source: EventSource | null = null let source: EventSource | null = null
let subscriberCount = 0 let subscriberCount = 0
function connect() { async function connect() {
if (source) return if (source) return
connectionState.set('connecting') connectionState.set('connecting')
// The browser's EventSource sends Last-Event-ID automatically on reconnect. // The browser's EventSource sends Last-event-ID automatically on reconnect.
source = new EventSource(sseUrl('/api/v1/events/stream')) // 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') source.onopen = () => connectionState.set('open')