fix(web): refresh expired OIDC tokens before API calls
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:
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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<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()
|
||||
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<Response> {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...(opts?.headers as Record<string, string> ?? {})
|
||||
}
|
||||
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<string> {
|
||||
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)}`
|
||||
|
||||
@@ -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<string | null> | 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<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) {
|
||||
return refreshAccessToken()
|
||||
@@ -245,9 +269,11 @@ async function refreshAccessToken(): Promise<string | null> {
|
||||
|
||||
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<boolean> {
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
Reference in New Issue
Block a user