diff --git a/docker-compose.yml b/docker-compose.yml index 7989440..55ca1b5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,6 +11,7 @@ services: postgres: image: timescale/timescaledb:2.17.2-pg16 + restart: unless-stopped environment: POSTGRES_DB: oikos POSTGRES_USER: oikos @@ -57,6 +58,7 @@ services: build: context: . dockerfile: compose/oikos/Dockerfile + restart: unless-stopped profiles: ["dev", "full"] depends_on: seed: @@ -70,6 +72,8 @@ services: # every request needs this token. nomos uses the same value to call # back into api's /mcp and /api/v1/approvals/*/decision. OIKOS_MCP_BEARER_TOKEN: ${OIKOS_MCP_BEARER_TOKEN:-dev-token} + OIKOS_OIDC_ISSUER: ${OIKOS_OIDC_ISSUER:-https://auth.hubris.network/application/o/oikos/} + OIKOS_OIDC_CLIENT_ID: ${OIKOS_OIDC_CLIENT_ID:-otkHBSueHJsYtOHstL6rn5izeGgyOsavp1qA1hod} OIKOS_NOMOS_AGENT_SLUG: ${OIKOS_NOMOS_AGENT_SLUG:-agent:nomos} NOMOS_PROXY_URL: http://nomos:8092 volumes: @@ -85,6 +89,7 @@ services: build: context: . dockerfile: compose/oikos/Dockerfile + restart: unless-stopped profiles: ["dev", "full"] depends_on: seed: @@ -108,6 +113,7 @@ services: build: context: . dockerfile: compose/oikos/Dockerfile + restart: unless-stopped profiles: ["dev", "full"] depends_on: seed: @@ -129,6 +135,7 @@ services: build: context: . dockerfile: compose/nomos/Dockerfile + restart: unless-stopped profiles: ["full"] depends_on: api: @@ -155,6 +162,7 @@ services: build: context: . dockerfile: compose/web/Dockerfile + restart: unless-stopped profiles: ["dev", "full"] ports: - "8091:80" @@ -163,6 +171,7 @@ services: # Redis (required by Infisical — Phase 5) redis: image: redis:7-alpine + restart: unless-stopped profiles: ["infisical", "full"] volumes: - redis-data:/data @@ -175,6 +184,7 @@ services: # Infisical self-hosted (Phase 5 secrets management) infisical: image: infisical/infisical:latest + restart: unless-stopped profiles: ["infisical", "full"] depends_on: postgres: diff --git a/internal/config/config.go b/internal/config/config.go index bc9d56e..db15550 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -19,10 +19,11 @@ type Config struct { APIEnv string // dev, prod // Auth (Phase 2: static bearer tokens + OIDC JWT) - APIToken string // operator/CI bearer token for the REST API - MCPBearerToken string // shared secret for Nomos→API MCP calls - OIDCIssuer string // OIDC issuer URL for JWT validation (e.g. https://authentik.example.com/application/o/oikos/) - OIDCClientID string // OIDC client ID (aud claim expected in JWT) + APIToken string // operator/CI bearer token for the REST API + MCPBearerToken string // shared secret for Nomos→API MCP calls + OIDCIssuer string // OIDC issuer URL for JWT validation (e.g. https://authentik.example.com/application/o/oikos/) + OIDCClientID string // OIDC client ID (aud claim expected in JWT) + OIDCClientSecret string // optional client secret for token endpoint proxy (confidential clients) // CORS (client/server split — see plans/2026-07-12-wails-desktop-app.md // 0.3). Needed for the Wails webview and local dev (Vite on a different @@ -108,6 +109,9 @@ func FromEnv() Config { if v := os.Getenv("OIKOS_OIDC_CLIENT_ID"); v != "" { c.OIDCClientID = v } + if v := os.Getenv("OIKOS_OIDC_CLIENT_SECRET"); v != "" { + c.OIDCClientSecret = v + } if v := os.Getenv("OIKOS_API_TOKEN"); v != "" { c.APIToken = v } diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index 4301d52..107303d 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -12,6 +12,7 @@ import ( "encoding/base64" "encoding/json" "fmt" + "io" "log/slog" "math/big" "net/http" @@ -127,6 +128,16 @@ func NewHandler(ctx context.Context, pool *db.Pool, cfg config.Config) http.Hand } }) + // OIDC endpoints — unauthenticated. The SPA needs the issuer + client_id + // to build the authorization URL, and uses the token proxy to exchange + // authorization codes and refresh tokens without CORS issues. + r.Get("/api/v1/auth/oidc-config", func(w http.ResponseWriter, req *http.Request) { + s.serveOIDCConfig(w, req, cfg) + }) + r.Post("/api/v1/auth/oidc-token", func(w http.ResponseWriter, req *http.Request) { + s.serveOIDCToken(w, req, cfg) + }) + strict := gen.NewStrictHandlerWithOptions(s, nil, gen.StrictHTTPServerOptions{ RequestErrorHandlerFunc: func(w http.ResponseWriter, req *http.Request, err error) { writeProblem(w, req, http.StatusBadRequest, "bad request", err.Error()) @@ -530,6 +541,122 @@ func requestLogger(next http.Handler) http.Handler { }) } +// resolveOIDCEndpointURL derives an endpoint URL from the issuer by walking +// up one path segment. Authentik's issuer is per-provider +// (e.g. .../application/o/oikos/) but shared endpoints live at the parent +// path (.../application/o/). +func resolveOIDCEndpointURL(issuer, suffix string) string { + u, err := url.Parse(issuer) + if err != nil { + return strings.TrimRight(issuer, "/") + suffix + } + u.Path = strings.TrimRight(u.Path, "/") + if idx := strings.LastIndex(u.Path, "/"); idx >= 0 { + u.Path = u.Path[:idx] + } + u.Path += suffix + return u.String() +} + +// resolveOIDCTokenURL derives the token endpoint URL from the issuer. +func resolveOIDCTokenURL(issuer string) string { + return resolveOIDCEndpointURL(issuer, "/token/") +} + +// serveOIDCConfig returns the OIDC issuer and client_id so the SPA can build +// authorization URLs without hardcoding them. +func (s *Server) serveOIDCConfig(w http.ResponseWriter, _ *http.Request, cfg config.Config) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{ + "issuer": cfg.OIDCIssuer, + "client_id": cfg.OIDCClientID, + "authorization_endpoint": resolveOIDCEndpointURL(cfg.OIDCIssuer, "/authorize/"), + }) +} + +// tokenExchangeBody mirrors the JSON the SPA sends to the token proxy. +type tokenExchangeBody struct { + GrantType string `json:"grant_type"` + Code string `json:"code,omitempty"` + CodeVerifier string `json:"code_verifier,omitempty"` + RedirectURI string `json:"redirect_uri,omitempty"` + RefreshToken string `json:"refresh_token,omitempty"` +} + +// serveOIDCToken proxies authorization_code and refresh_token grants to the +// OIDC provider's token endpoint. The SPA can't POST directly to Authentik +// because of CORS; this proxy avoids the cross-origin problem entirely. +func (s *Server) serveOIDCToken(w http.ResponseWriter, req *http.Request, cfg config.Config) { + if cfg.OIDCIssuer == "" || cfg.OIDCClientID == "" { + writeProblem(w, req, http.StatusServiceUnavailable, "oidc not configured", "") + return + } + + body, err := io.ReadAll(req.Body) + if err != nil { + writeProblem(w, req, http.StatusBadRequest, "invalid body", err.Error()) + return + } + + var tb tokenExchangeBody + if err := json.Unmarshal(body, &tb); err != nil { + writeProblem(w, req, http.StatusBadRequest, "invalid token request", err.Error()) + return + } + + // Build the form-encoded body for Authentik's token endpoint + form := url.Values{} + form.Set("client_id", cfg.OIDCClientID) + if cfg.OIDCClientSecret != "" { + form.Set("client_secret", cfg.OIDCClientSecret) + } + + switch tb.GrantType { + case "authorization_code": + form.Set("grant_type", "authorization_code") + form.Set("code", tb.Code) + form.Set("code_verifier", tb.CodeVerifier) + form.Set("redirect_uri", tb.RedirectURI) + case "refresh_token": + form.Set("grant_type", "refresh_token") + form.Set("refresh_token", tb.RefreshToken) + default: + writeProblem(w, req, http.StatusBadRequest, "unsupported grant_type", tb.GrantType) + return + } + + tokenURL := resolveOIDCTokenURL(cfg.OIDCIssuer) + client := &http.Client{Timeout: 15 * time.Second, Transport: &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: false}, + }} + resp, err := client.Post(tokenURL, "application/x-www-form-urlencoded", strings.NewReader(form.Encode())) + if err != nil { + slog.Error("oidc token proxy failed", "error", err) + writeProblem(w, req, http.StatusBadGateway, "token endpoint unreachable", err.Error()) + return + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + writeProblem(w, req, http.StatusInternalServerError, "read token response failed", err.Error()) + return + } + + if resp.StatusCode >= 400 { + slog.Warn("oidc token endpoint returned error", "status", resp.StatusCode, "body", string(respBody)) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(resp.StatusCode) + w.Write(respBody) + return + } + + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Pragma", "no-cache") + w.Write(respBody) +} + // ListenAndServe runs the API server with graceful shutdown on ctx cancel // (SG4): stop accepting, drain in-flight for up to 30s, then exit. func ListenAndServe(ctx context.Context, pool *db.Pool, cfg config.Config) error { diff --git a/web/src/App.svelte b/web/src/App.svelte index 2d491aa..8ee1e5c 100644 --- a/web/src/App.svelte +++ b/web/src/App.svelte @@ -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('/') diff --git a/web/src/lib/config.ts b/web/src/lib/config.ts index fe80e54..4b9097f 100644 --- a/web/src/lib/config.ts +++ b/web/src/lib/config.ts @@ -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 ?? {}) } - 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 | 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 { + 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 { + return crypto.subtle.digest('SHA-256', new TextEncoder().encode(plain)) +} + +export async function startLogin(): Promise { + 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 { + 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 { + if (state.accessToken) return state.accessToken + + if (state.refreshToken) { + return refreshAccessToken() + } + + return null +} + +async function refreshAccessToken(): Promise { + 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 { + 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 { + 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 +} diff --git a/web/src/pages/Config.svelte b/web/src/pages/Config.svelte index 14e9a59..30116ab 100644 --- a/web/src/pages/Config.svelte +++ b/web/src/pages/Config.svelte @@ -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 + }
@@ -52,10 +76,10 @@ Enter the server URL and your access token. - + Token - Login with Authentik (coming soon) + Login with Authentik
{ e.preventDefault(); connect() }}> @@ -90,6 +114,36 @@ {/if}
+ +
+ {#if oidcConfigured && oidcUser} +

+ Logged in as {oidcUser} +

+ + + {:else} +

+ Sign in with your Authentik account to access the control room. +

+ {#if error} +

{error}

+ {/if} + + {/if} + {#if existing.token} + + {/if} +
+