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

@@ -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)}`