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
This commit is contained in:
2026-07-13 22:17:31 +02:00
parent 4c4afc4783
commit f6a699469d
7 changed files with 501 additions and 14 deletions

View File

@@ -13,6 +13,7 @@
import { connectionState } from '$lib/stores/events'
import { isConfigured } from '$lib/config'
import { onMount } from 'svelte'
import { processPendingCallback, initOIDC } from '$lib/oidc'
import * as Sidebar from '$lib/components/ui/sidebar'
import * as Sheet from '$lib/components/ui/sheet'
import { Button } from '$lib/components/ui/button'
@@ -37,7 +38,13 @@
const approvalsPending = $derived($summary?.approvals_pending ?? 0)
const openSignals = $derived(openSignalCount($summary))
onMount(() => {
onMount(async () => {
if (await processPendingCallback()) {
configured = true
} else if (!configured) {
if (await initOIDC()) configured = true
}
function sync() {
const path = location.hash.slice(2) || 'overview'
const [head, ...rest] = path.split('/')

View File

@@ -4,6 +4,8 @@
// 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
@@ -51,7 +53,7 @@ export function clearConfig() {
}
export function isConfigured(): boolean {
return !!getConfig().token
return !!getConfig().token || isOIDCConfigured()
}
// Relative paths are used in dev (Vite proxy) and when the SPA shares an
@@ -63,6 +65,15 @@ export function apiBase(path: string): string {
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
@@ -72,9 +83,9 @@ export async function fetchWithAuth(path: string, opts?: RequestInit): Promise<R
'Content-Type': 'application/json',
...(opts?.headers as Record<string, string> ?? {})
}
const c = getConfig()
if (c.token) {
headers['Authorization'] = `Bearer ${c.token}`
const authH = resolveAuthHeader()
if (authH) {
headers['Authorization'] = authH
}
return fetch(apiBase(path), { ...opts, headers })
@@ -84,9 +95,11 @@ export async function fetchWithAuth(path: string, opts?: RequestInit): Promise<R
// 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 c = getConfig()
const url = apiBase(path)
if (!c.token) return url
const c = getConfig()
const oidcToken = getToken()
const token = oidcToken ?? c.token
if (!token) return url
const sep = url.includes('?') ? '&' : '?'
return `${url}${sep}token=${encodeURIComponent(c.token)}`
return `${url}${sep}token=${encodeURIComponent(token)}`
}

272
web/src/lib/oidc.ts Normal file
View File

@@ -0,0 +1,272 @@
import { apiBase } from './config'
interface OIDCConfig {
issuer: string
client_id: string
authorization_endpoint: string
}
interface TokenResponse {
access_token: string
token_type: string
expires_in?: number
refresh_token?: string
id_token?: string
}
interface OIDCState {
config: OIDCConfig | null
accessToken: string | null
refreshToken: string | null
user: string | null
refreshing: Promise<string | null> | null
}
const SESSION_KEY = 'oidc_access_token'
const REFRESH_KEY = 'oidc_refresh_token'
const USER_KEY = 'oidc_user'
const PKCE_KEY = 'oidc_pkce_verifier'
const STATE_KEY = 'oidc_state'
let state: OIDCState = {
config: null,
accessToken: sessionStorage.getItem(SESSION_KEY),
refreshToken: localStorage.getItem(REFRESH_KEY),
user: localStorage.getItem(USER_KEY),
refreshing: null
}
async function fetchConfig(): Promise<OIDCConfig | null> {
try {
const resp = await fetch(apiBase('/api/v1/auth/oidc-config'))
if (!resp.ok) return null
const cfg: OIDCConfig = await resp.json()
state.config = cfg
return cfg
} catch {
return null
}
}
function base64URLEncode(buf: ArrayBuffer): string {
return btoa(String.fromCharCode(...new Uint8Array(buf)))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '')
}
function generateRandom(len: number): string {
const arr = new Uint8Array(len)
crypto.getRandomValues(arr)
return base64URLEncode(arr)
}
async function sha256(plain: string): Promise<ArrayBuffer> {
return crypto.subtle.digest('SHA-256', new TextEncoder().encode(plain))
}
export async function startLogin(): Promise<void> {
const cfg = state.config ?? await fetchConfig()
if (!cfg) throw new Error('OIDC not configured on server')
const codeVerifier = generateRandom(64)
const challengeBuf = await sha256(codeVerifier)
const codeChallenge = base64URLEncode(challengeBuf)
const oidcState = generateRandom(32)
sessionStorage.setItem(PKCE_KEY, codeVerifier)
sessionStorage.setItem(STATE_KEY, oidcState)
const redirectURI = location.origin + location.pathname
const params = new URLSearchParams({
response_type: 'code',
client_id: cfg.client_id,
redirect_uri: redirectURI,
code_challenge: codeChallenge,
code_challenge_method: 'S256',
state: oidcState,
scope: 'openid profile email'
})
location.href = `${cfg.authorization_endpoint.replace(/\/$/, '')}/?${params}`
}
export async function handleCallback(code: string, returnedState: string): Promise<boolean> {
const verifier = sessionStorage.getItem(PKCE_KEY)
const savedState = sessionStorage.getItem(STATE_KEY)
sessionStorage.removeItem(PKCE_KEY)
sessionStorage.removeItem(STATE_KEY)
if (!verifier || savedState !== returnedState) return false
const cfg = state.config ?? await fetchConfig()
if (!cfg) return false
const redirectURI = location.origin + location.pathname
try {
const resp = await fetch(apiBase('/api/v1/auth/oidc-token'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
grant_type: 'authorization_code',
code,
code_verifier: verifier,
redirect_uri: redirectURI
})
})
if (!resp.ok) return false
const tokens: TokenResponse = await resp.json()
if (!tokens.access_token) return false
storeTokens(tokens)
if (tokens.id_token) {
const user = parseIDTokenUser(tokens.id_token)
if (user) {
state.user = user
localStorage.setItem(USER_KEY, user)
}
}
return true
} catch {
return false
}
}
function parseIDTokenUser(idToken: string): string | null {
try {
const payload = idToken.split('.')[1]
const claims = JSON.parse(atob(payload))
return claims.preferred_username || claims.email || claims.sub || null
} catch {
return null
}
}
function storeTokens(tokens: TokenResponse) {
state.accessToken = tokens.access_token
sessionStorage.setItem(SESSION_KEY, tokens.access_token)
if (tokens.refresh_token) {
state.refreshToken = tokens.refresh_token
localStorage.setItem(REFRESH_KEY, tokens.refresh_token)
}
}
export function getToken(): string | null {
return state.accessToken
}
export function getUser(): string | null {
return state.user
}
export function isOIDCAvailable(): boolean {
return !!(state.accessToken || state.refreshToken)
}
export async function ensureToken(): Promise<string | null> {
if (state.accessToken) return state.accessToken
if (state.refreshToken) {
return refreshAccessToken()
}
return null
}
async function refreshAccessToken(): Promise<string | null> {
if (state.refreshing) return state.refreshing
const cfg = state.config ?? await fetchConfig()
if (!cfg || !state.refreshToken) return null
state.refreshing = (async () => {
try {
const resp = await fetch(apiBase('/api/v1/auth/oidc-token'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
grant_type: 'refresh_token',
refresh_token: state.refreshToken
})
})
if (!resp.ok) {
clearTokens()
return null
}
const tokens: TokenResponse = await resp.json()
if (!tokens.access_token) {
clearTokens()
return null
}
storeTokens(tokens)
return tokens.access_token
} catch {
clearTokens()
return null
} finally {
state.refreshing = null
}
})()
return state.refreshing
}
function clearTokens() {
state.accessToken = null
state.refreshToken = null
state.user = null
sessionStorage.removeItem(SESSION_KEY)
localStorage.removeItem(REFRESH_KEY)
localStorage.removeItem(USER_KEY)
}
export function logout(): void {
clearTokens()
}
export function isOIDCConfigured(): boolean {
return !!(state.accessToken || state.refreshToken)
}
export async function initOIDC(): Promise<boolean> {
if (state.accessToken) return true
if (state.refreshToken) {
const token = await refreshAccessToken()
return token !== null
}
return false
}
export function hasPendingCallback(): boolean {
const params = new URLSearchParams(location.search)
return params.has('code') && params.has('state')
}
export async function processPendingCallback(): Promise<boolean> {
const params = new URLSearchParams(location.search)
const code = params.get('code')
const oidcState = params.get('state')
if (!code || !oidcState) return false
const ok = await handleCallback(code, oidcState)
const url = new URL(location.href)
url.searchParams.delete('code')
url.searchParams.delete('state')
history.replaceState(null, '', url.toString())
return ok
}

View File

@@ -5,6 +5,7 @@
import { Label } from '$lib/components/ui/label'
import { Button } from '$lib/components/ui/button'
import { fetchWithAuth, setConfig, initConfig, getConfig, clearConfig } from '$lib/config'
import { startLogin, logout as oidcLogout, getUser, isOIDCConfigured } from '$lib/oidc'
let { onConnected, onCancel }: { onConnected: () => void; onCancel?: () => void } = $props()
@@ -13,12 +14,18 @@
let token = $state(existing.token ?? '')
let connecting = $state(false)
let error = $state('')
let oidcLoggingIn = $state(false)
let oidcUser = $state(getUser())
let oidcConfigured = $state(isOIDCConfigured())
function disconnect() {
clearConfig()
oidcLogout()
apiUrl = ''
token = ''
error = ''
oidcUser = null
oidcConfigured = false
}
async function connect() {
@@ -43,6 +50,23 @@
connecting = false
}
}
async function loginWithAuthentik() {
error = ''
oidcLoggingIn = true
try {
await startLogin()
} catch (e: any) {
error = e.message || 'OIDC login failed'
oidcLoggingIn = false
}
}
function logoutOIDC() {
oidcLogout()
oidcUser = null
oidcConfigured = false
}
</script>
<div class="flex h-svh items-center justify-center p-6">
@@ -52,10 +76,10 @@
<Card.Description>Enter the server URL and your access token.</Card.Description>
</Card.Header>
<Card.Content>
<Tabs.Root value="token">
<Tabs.Root value={oidcConfigured ? 'oidc' : 'token'}>
<Tabs.List class="mb-4 grid w-full grid-cols-2">
<Tabs.Trigger value="token">Token</Tabs.Trigger>
<Tabs.Trigger value="oidc" disabled>Login with Authentik (coming soon)</Tabs.Trigger>
<Tabs.Trigger value="oidc">Login with Authentik</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="token">
<form class="flex flex-col gap-4" onsubmit={(e) => { e.preventDefault(); connect() }}>
@@ -90,6 +114,36 @@
{/if}
</form>
</Tabs.Content>
<Tabs.Content value="oidc">
<div class="flex flex-col gap-4">
{#if oidcConfigured && oidcUser}
<p class="text-sm text-muted-foreground">
Logged in as <span class="font-medium text-foreground">{oidcUser}</span>
</p>
<Button type="button" variant="default" onclick={() => onConnected()}>
Continue to Dashboard
</Button>
<Button type="button" variant="ghost" size="sm" onclick={logoutOIDC}>
Log out
</Button>
{:else}
<p class="text-sm text-muted-foreground">
Sign in with your Authentik account to access the control room.
</p>
{#if error}
<p class="text-sm text-destructive">{error}</p>
{/if}
<Button type="button" disabled={oidcLoggingIn} onclick={loginWithAuthentik}>
{oidcLoggingIn ? 'Redirecting to Authentik…' : 'Login with Authentik'}
</Button>
{/if}
{#if existing.token}
<Button type="button" variant="ghost" size="sm" onclick={disconnect}>
Forget saved connection
</Button>
{/if}
</div>
</Tabs.Content>
</Tabs.Root>
</Card.Content>
</Card.Root>