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

View File

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

View File

@@ -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')