Files
oikos/web/src/lib/config.ts
dtoro 7b0a0f01b5
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
oidc: authenticate SPA users via Authentik
- Add OIDC proxy endpoints (GET config, POST token) to API server
- Implement PKCE Authorization Code flow in SPA
- Enable Authentik login tab in Config page
- Handle callback + auto-refresh + session restore
- Add restart: unless-stopped to all persistent services
- Configure OIDC issuer + client_id in docker-compose
2026-07-13 22:17:40 +02:00

106 lines
3.5 KiB
TypeScript

// Runtime configuration for the SPA — server URL + auth token. Every fetch
// call goes through fetchWithAuth/apiBase (used by api.ts) so the SPA works
// identically whether it's served same-origin (browser prod, Vite dev proxy)
// or cross-origin (Wails webview, remote access). See
// plans/2026-07-12-wails-desktop-app.md 0.2.
import { getToken, isOIDCConfigured } from './oidc'
export interface OikosConfig {
apiUrl: string // e.g. "https://oikos.hubris.network", or "" for same-origin
token?: string // bearer token for auth
}
declare global {
interface Window {
__OIKOS_CONFIG__?: OikosConfig
}
}
let cfg: OikosConfig | undefined
export function initConfig(override?: OikosConfig) {
cfg = override ?? window.__OIKOS_CONFIG__
if (cfg?.token) {
localStorage.setItem('oikos_token', cfg.token)
if (cfg.apiUrl) localStorage.setItem('oikos_api_url', cfg.apiUrl)
}
}
export function getConfig(): OikosConfig {
if (!cfg) {
const token = localStorage.getItem('oikos_token')
const apiUrl = localStorage.getItem('oikos_api_url')
if (token || apiUrl) {
cfg = { apiUrl: apiUrl ?? '', token: token ?? undefined }
}
}
return cfg ?? { apiUrl: '' }
}
export function setConfig(next: OikosConfig) {
cfg = next
if (next.token) localStorage.setItem('oikos_token', next.token)
else localStorage.removeItem('oikos_token')
if (next.apiUrl) localStorage.setItem('oikos_api_url', next.apiUrl)
else localStorage.removeItem('oikos_api_url')
}
export function clearConfig() {
cfg = { apiUrl: '' }
localStorage.removeItem('oikos_token')
localStorage.removeItem('oikos_api_url')
}
export function isConfigured(): boolean {
return !!getConfig().token || isOIDCConfigured()
}
// Relative paths are used in dev (Vite proxy) and when the SPA shares an
// origin with the API server (Caddy reverse proxy). Absolute paths are used
// when the API server is on a different origin (Wails webview, remote access).
export function apiBase(path: string): string {
const c = getConfig()
if (!c.apiUrl) return path // relative — relies on same-origin or Vite proxy
return `${c.apiUrl}${path}`
}
// Resolves the auth token for a request: OIDC takes precedence, then static.
function resolveAuthHeader(): string | null {
const oidcToken = getToken()
if (oidcToken) return `Bearer ${oidcToken}`
const c = getConfig()
if (c.token) return `Bearer ${c.token}`
return null
}
// ---- Auth fetch wrapper ----
// 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.
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()
if (authH) {
headers['Authorization'] = authH
}
return fetch(apiBase(path), { ...opts, headers })
}
// 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 {
const url = apiBase(path)
const c = getConfig()
const oidcToken = getToken()
const token = oidcToken ?? c.token
if (!token) return url
const sep = url.includes('?') ? '&' : '?'
return `${url}${sep}token=${encodeURIComponent(token)}`
}