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:
@@ -11,6 +11,7 @@
|
|||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
image: timescale/timescaledb:2.17.2-pg16
|
image: timescale/timescaledb:2.17.2-pg16
|
||||||
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: oikos
|
POSTGRES_DB: oikos
|
||||||
POSTGRES_USER: oikos
|
POSTGRES_USER: oikos
|
||||||
@@ -57,6 +58,7 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: compose/oikos/Dockerfile
|
dockerfile: compose/oikos/Dockerfile
|
||||||
|
restart: unless-stopped
|
||||||
profiles: ["dev", "full"]
|
profiles: ["dev", "full"]
|
||||||
depends_on:
|
depends_on:
|
||||||
seed:
|
seed:
|
||||||
@@ -70,6 +72,8 @@ services:
|
|||||||
# every request needs this token. nomos uses the same value to call
|
# every request needs this token. nomos uses the same value to call
|
||||||
# back into api's /mcp and /api/v1/approvals/*/decision.
|
# back into api's /mcp and /api/v1/approvals/*/decision.
|
||||||
OIKOS_MCP_BEARER_TOKEN: ${OIKOS_MCP_BEARER_TOKEN:-dev-token}
|
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}
|
OIKOS_NOMOS_AGENT_SLUG: ${OIKOS_NOMOS_AGENT_SLUG:-agent:nomos}
|
||||||
NOMOS_PROXY_URL: http://nomos:8092
|
NOMOS_PROXY_URL: http://nomos:8092
|
||||||
volumes:
|
volumes:
|
||||||
@@ -85,6 +89,7 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: compose/oikos/Dockerfile
|
dockerfile: compose/oikos/Dockerfile
|
||||||
|
restart: unless-stopped
|
||||||
profiles: ["dev", "full"]
|
profiles: ["dev", "full"]
|
||||||
depends_on:
|
depends_on:
|
||||||
seed:
|
seed:
|
||||||
@@ -108,6 +113,7 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: compose/oikos/Dockerfile
|
dockerfile: compose/oikos/Dockerfile
|
||||||
|
restart: unless-stopped
|
||||||
profiles: ["dev", "full"]
|
profiles: ["dev", "full"]
|
||||||
depends_on:
|
depends_on:
|
||||||
seed:
|
seed:
|
||||||
@@ -129,6 +135,7 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: compose/nomos/Dockerfile
|
dockerfile: compose/nomos/Dockerfile
|
||||||
|
restart: unless-stopped
|
||||||
profiles: ["full"]
|
profiles: ["full"]
|
||||||
depends_on:
|
depends_on:
|
||||||
api:
|
api:
|
||||||
@@ -155,6 +162,7 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: compose/web/Dockerfile
|
dockerfile: compose/web/Dockerfile
|
||||||
|
restart: unless-stopped
|
||||||
profiles: ["dev", "full"]
|
profiles: ["dev", "full"]
|
||||||
ports:
|
ports:
|
||||||
- "8091:80"
|
- "8091:80"
|
||||||
@@ -163,6 +171,7 @@ services:
|
|||||||
# Redis (required by Infisical — Phase 5)
|
# Redis (required by Infisical — Phase 5)
|
||||||
redis:
|
redis:
|
||||||
image: redis:7-alpine
|
image: redis:7-alpine
|
||||||
|
restart: unless-stopped
|
||||||
profiles: ["infisical", "full"]
|
profiles: ["infisical", "full"]
|
||||||
volumes:
|
volumes:
|
||||||
- redis-data:/data
|
- redis-data:/data
|
||||||
@@ -175,6 +184,7 @@ services:
|
|||||||
# Infisical self-hosted (Phase 5 secrets management)
|
# Infisical self-hosted (Phase 5 secrets management)
|
||||||
infisical:
|
infisical:
|
||||||
image: infisical/infisical:latest
|
image: infisical/infisical:latest
|
||||||
|
restart: unless-stopped
|
||||||
profiles: ["infisical", "full"]
|
profiles: ["infisical", "full"]
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
|
|||||||
@@ -19,10 +19,11 @@ type Config struct {
|
|||||||
APIEnv string // dev, prod
|
APIEnv string // dev, prod
|
||||||
|
|
||||||
// Auth (Phase 2: static bearer tokens + OIDC JWT)
|
// Auth (Phase 2: static bearer tokens + OIDC JWT)
|
||||||
APIToken string // operator/CI bearer token for the REST API
|
APIToken string // operator/CI bearer token for the REST API
|
||||||
MCPBearerToken string // shared secret for Nomos→API MCP calls
|
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/)
|
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)
|
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
|
// 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
|
// 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 != "" {
|
if v := os.Getenv("OIKOS_OIDC_CLIENT_ID"); v != "" {
|
||||||
c.OIDCClientID = v
|
c.OIDCClientID = v
|
||||||
}
|
}
|
||||||
|
if v := os.Getenv("OIKOS_OIDC_CLIENT_SECRET"); v != "" {
|
||||||
|
c.OIDCClientSecret = v
|
||||||
|
}
|
||||||
if v := os.Getenv("OIKOS_API_TOKEN"); v != "" {
|
if v := os.Getenv("OIKOS_API_TOKEN"); v != "" {
|
||||||
c.APIToken = v
|
c.APIToken = v
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"math/big"
|
"math/big"
|
||||||
"net/http"
|
"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{
|
strict := gen.NewStrictHandlerWithOptions(s, nil, gen.StrictHTTPServerOptions{
|
||||||
RequestErrorHandlerFunc: func(w http.ResponseWriter, req *http.Request, err error) {
|
RequestErrorHandlerFunc: func(w http.ResponseWriter, req *http.Request, err error) {
|
||||||
writeProblem(w, req, http.StatusBadRequest, "bad 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/<suffix>).
|
||||||
|
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
|
// ListenAndServe runs the API server with graceful shutdown on ctx cancel
|
||||||
// (SG4): stop accepting, drain in-flight for up to 30s, then exit.
|
// (SG4): stop accepting, drain in-flight for up to 30s, then exit.
|
||||||
func ListenAndServe(ctx context.Context, pool *db.Pool, cfg config.Config) error {
|
func ListenAndServe(ctx context.Context, pool *db.Pool, cfg config.Config) error {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
import { connectionState } from '$lib/stores/events'
|
import { connectionState } from '$lib/stores/events'
|
||||||
import { isConfigured } from '$lib/config'
|
import { isConfigured } from '$lib/config'
|
||||||
import { onMount } from 'svelte'
|
import { onMount } from 'svelte'
|
||||||
|
import { processPendingCallback, initOIDC } from '$lib/oidc'
|
||||||
import * as Sidebar from '$lib/components/ui/sidebar'
|
import * as Sidebar from '$lib/components/ui/sidebar'
|
||||||
import * as Sheet from '$lib/components/ui/sheet'
|
import * as Sheet from '$lib/components/ui/sheet'
|
||||||
import { Button } from '$lib/components/ui/button'
|
import { Button } from '$lib/components/ui/button'
|
||||||
@@ -37,7 +38,13 @@
|
|||||||
const approvalsPending = $derived($summary?.approvals_pending ?? 0)
|
const approvalsPending = $derived($summary?.approvals_pending ?? 0)
|
||||||
const openSignals = $derived(openSignalCount($summary))
|
const openSignals = $derived(openSignalCount($summary))
|
||||||
|
|
||||||
onMount(() => {
|
onMount(async () => {
|
||||||
|
if (await processPendingCallback()) {
|
||||||
|
configured = true
|
||||||
|
} else if (!configured) {
|
||||||
|
if (await initOIDC()) configured = true
|
||||||
|
}
|
||||||
|
|
||||||
function sync() {
|
function sync() {
|
||||||
const path = location.hash.slice(2) || 'overview'
|
const path = location.hash.slice(2) || 'overview'
|
||||||
const [head, ...rest] = path.split('/')
|
const [head, ...rest] = path.split('/')
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
// or cross-origin (Wails webview, remote access). See
|
// or cross-origin (Wails webview, remote access). See
|
||||||
// plans/2026-07-12-wails-desktop-app.md 0.2.
|
// plans/2026-07-12-wails-desktop-app.md 0.2.
|
||||||
|
|
||||||
|
import { getToken, isOIDCConfigured } from './oidc'
|
||||||
|
|
||||||
export interface OikosConfig {
|
export interface OikosConfig {
|
||||||
apiUrl: string // e.g. "https://oikos.hubris.network", or "" for same-origin
|
apiUrl: string // e.g. "https://oikos.hubris.network", or "" for same-origin
|
||||||
token?: string // bearer token for auth
|
token?: string // bearer token for auth
|
||||||
@@ -51,7 +53,7 @@ export function clearConfig() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function isConfigured(): boolean {
|
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
|
// 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}`
|
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 ----
|
// ---- Auth fetch wrapper ----
|
||||||
// Prepends the API base URL (absolute when configured, relative when unset
|
// Prepends the API base URL (absolute when configured, relative when unset
|
||||||
// for the Vite dev proxy / same-origin prod) and adds the Authorization
|
// 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',
|
'Content-Type': 'application/json',
|
||||||
...(opts?.headers as Record<string, string> ?? {})
|
...(opts?.headers as Record<string, string> ?? {})
|
||||||
}
|
}
|
||||||
const c = getConfig()
|
const authH = resolveAuthHeader()
|
||||||
if (c.token) {
|
if (authH) {
|
||||||
headers['Authorization'] = `Bearer ${c.token}`
|
headers['Authorization'] = authH
|
||||||
}
|
}
|
||||||
|
|
||||||
return fetch(apiBase(path), { ...opts, headers })
|
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
|
// a query parameter (the SSE handler's combinedAuth checks it alongside the
|
||||||
// Authorization header, only for this route).
|
// Authorization header, only for this route).
|
||||||
export function sseUrl(path: string): string {
|
export function sseUrl(path: string): string {
|
||||||
const c = getConfig()
|
|
||||||
const url = apiBase(path)
|
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('?') ? '&' : '?'
|
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
272
web/src/lib/oidc.ts
Normal 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
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
import { Label } from '$lib/components/ui/label'
|
import { Label } from '$lib/components/ui/label'
|
||||||
import { Button } from '$lib/components/ui/button'
|
import { Button } from '$lib/components/ui/button'
|
||||||
import { fetchWithAuth, setConfig, initConfig, getConfig, clearConfig } from '$lib/config'
|
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()
|
let { onConnected, onCancel }: { onConnected: () => void; onCancel?: () => void } = $props()
|
||||||
|
|
||||||
@@ -13,12 +14,18 @@
|
|||||||
let token = $state(existing.token ?? '')
|
let token = $state(existing.token ?? '')
|
||||||
let connecting = $state(false)
|
let connecting = $state(false)
|
||||||
let error = $state('')
|
let error = $state('')
|
||||||
|
let oidcLoggingIn = $state(false)
|
||||||
|
let oidcUser = $state(getUser())
|
||||||
|
let oidcConfigured = $state(isOIDCConfigured())
|
||||||
|
|
||||||
function disconnect() {
|
function disconnect() {
|
||||||
clearConfig()
|
clearConfig()
|
||||||
|
oidcLogout()
|
||||||
apiUrl = ''
|
apiUrl = ''
|
||||||
token = ''
|
token = ''
|
||||||
error = ''
|
error = ''
|
||||||
|
oidcUser = null
|
||||||
|
oidcConfigured = false
|
||||||
}
|
}
|
||||||
|
|
||||||
async function connect() {
|
async function connect() {
|
||||||
@@ -43,6 +50,23 @@
|
|||||||
connecting = false
|
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>
|
</script>
|
||||||
|
|
||||||
<div class="flex h-svh items-center justify-center p-6">
|
<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.Description>Enter the server URL and your access token.</Card.Description>
|
||||||
</Card.Header>
|
</Card.Header>
|
||||||
<Card.Content>
|
<Card.Content>
|
||||||
<Tabs.Root value="token">
|
<Tabs.Root value={oidcConfigured ? 'oidc' : 'token'}>
|
||||||
<Tabs.List class="mb-4 grid w-full grid-cols-2">
|
<Tabs.List class="mb-4 grid w-full grid-cols-2">
|
||||||
<Tabs.Trigger value="token">Token</Tabs.Trigger>
|
<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.List>
|
||||||
<Tabs.Content value="token">
|
<Tabs.Content value="token">
|
||||||
<form class="flex flex-col gap-4" onsubmit={(e) => { e.preventDefault(); connect() }}>
|
<form class="flex flex-col gap-4" onsubmit={(e) => { e.preventDefault(); connect() }}>
|
||||||
@@ -90,6 +114,36 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</form>
|
</form>
|
||||||
</Tabs.Content>
|
</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>
|
</Tabs.Root>
|
||||||
</Card.Content>
|
</Card.Content>
|
||||||
</Card.Root>
|
</Card.Root>
|
||||||
|
|||||||
Reference in New Issue
Block a user