Files
oikos/internal/httpapi/server.go
dtoro 1540f74342 feat(audit): read-only knowledge-graph drift report + skill
Adds audit_knowledge_graph (MCP tool) and GET /api/v1/audit/drift (endpoint)
backed by a shared internal/audit package. One pass surfaces the structural
gaps an operator otherwise finds by accident: orphan check entities, checks
targeting deprecated/destroyed entities, probes stuck down/unknown, unmonitored
declared types, and live edges pointing at destroyed targets. Each finding
carries a suggested remediation runbook. Read-only and safe to run unattended.

Ships the knowledge-graph-audit skill (SKILL.md + seeded runbook) that
interprets the report and routes findings to the lifecycle runbooks.
2026-07-29 13:37:16 +02:00

912 lines
33 KiB
Go

// Package httpapi implements the Oikos REST API. The contract is
// api/openapi.yaml (contract-first, ADR-0004); handlers implement the
// oapi-codegen strict-server interface in gen/. Errors map to RFC 9457
// problem+json via domain sentinels.
package httpapi
import (
"context"
"crypto/rsa"
"crypto/subtle"
"crypto/tls"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"log/slog"
"math/big"
"net/http"
"net/http/httputil"
"net/url"
"os"
"strings"
"sync"
"time"
"github.com/dtoro/oikos/internal/config"
"github.com/dtoro/oikos/internal/db"
"github.com/dtoro/oikos/internal/httpapi/gen"
mcphandler "github.com/dtoro/oikos/internal/mcp"
"github.com/dtoro/oikos/internal/safego"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/cors"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
)
// contextKey for storing actor identity in request context.
type contextKey string
const actorKey contextKey = "oikos_actor"
// actor holds the resolved identity of the API caller.
type actor struct {
Type string // "operator", "agent", "system"
Label string // human-readable label
ID string // OIDC sub or static token identifier
TokenType string // "static" or "oidc"
}
// Server implements gen.StrictServerInterface over the DB layer.
type Server struct {
pool *db.Pool
cfg config.Config
secretsManager secretsBackend
sseBroker *sseBroker
sseSubs map[*sseSubscriber]struct{}
sseMu sync.Mutex
}
// secretsBackend is a minimal interface for secrets operations used by the
// HTTP API (enrollment key storage, listing). Compatible with internal/secrets.
type secretsBackend interface {
Set(ctx context.Context, key string, value string) error
List(ctx context.Context) ([]string, error)
}
// NewHandler builds the full HTTP handler: /healthz (unauthenticated,
// SG18) + the OpenAPI surface under /api/v1 behind bearer auth.
//
// ctx governs the lifetime of the background SSE listener goroutine, which
// holds a dedicated pooled connection for LISTEN. Callers MUST cancel ctx
// before closing the pool — otherwise the held connection never releases
// and pool.Close() deadlocks.
func NewHandler(ctx context.Context, pool *db.Pool, cfg config.Config) http.Handler {
s := &Server{
pool: pool,
cfg: cfg,
sseBroker: newSSEBroker(10000),
sseSubs: make(map[*sseSubscriber]struct{}),
}
// Start background SSE listener, tied to ctx for clean shutdown.
// handleNotification (called per-message inside sseListener's loop) has
// its own recover for the common case; this outer one covers the
// connection-setup/reconnect code around it.
safego.Go("httpapi:sse-listener", func() { s.sseListener(ctx) })
r := chi.NewRouter()
r.Use(middleware.Recoverer)
r.Use(middleware.RequestID)
r.Use(requestLogger)
r.Use(cors.Handler(cors.Options{
AllowedOrigins: []string{cfg.CORSAllowedOrigin},
AllowedMethods: []string{"GET", "POST", "PATCH", "DELETE", "OPTIONS"},
AllowedHeaders: []string{"Authorization", "Content-Type", "If-Match"},
MaxAge: 86400,
}))
// ─── Non-OpenAPI routes (carve-out) ────────────────────────────────
//
// These routes are registered manually on the chi router rather than
// generated from api/openapi.yaml. Each has a structural reason it
// can't go through the strict-server codegen:
//
// /healthz — infra liveness probe, no auth, no /api/v1 prefix
// /api/v1/auth/oidc-* — auth flow, must run before auth middleware
// /oidc-callback — standalone HTML page, not a JSON API
// /api/v1/events/stream — in OpenAPI but re-registered for SSE Flush()
// /api/v1/knowledge/recent — ad-hoc aggregation, no schema type yet
// /api/v1/knowledge/content/{id} — returns raw markdown, not a gen type
// /api/v1/knowledge/list — full tree listing, ad-hoc aggregate
// /api/v1/knowledge (POST) — markdown in, no gen type
// /api/v1/knowledge/content/{id} (PUT/DELETE) — markdown in, soft delete
// /api/v1/knowledge/trash — soft-deleted notes, ad-hoc
// /api/v1/knowledge/restore/{id} — undo a soft delete, no gen type
// /api/v1/knowledge/revisions/{id} — version history, no schema type
// /api/v1/knowledge/tags{,/rename} — tag index + bulk rewrite
// /api/v1/knowledge/duplicates — trigram clustering, ad-hoc
// /api/v1/knowledge/orphans — derived maintenance view
// /api/v1/knowledge/merge — bulk fold-in, ad-hoc
// /api/v1/activity/recent — recency-ordered, not paginated
// /api/v1/activity/session/{id} — session-scoped aggregation
// /api/v1/executions/{id}/logs — streamed command output, no schema type
// /api/v1/learning/timeline — derived view, no backing schema type
// /api/v1/learning/trend — derived view, no backing schema type
//
// See .agents/dev/CONTRIBUTING.md §OpenAPI codegen for the policy.
// Liveness — no auth, no audit (plan SG18). Not exposed via Caddy.
r.Get("/healthz", func(w http.ResponseWriter, req *http.Request) {
ctx, cancel := context.WithTimeout(req.Context(), 2*time.Second)
defer cancel()
if err := pool.Ping(ctx); err != nil {
writeProblem(w, req, http.StatusServiceUnavailable, "database unreachable", "")
return
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"status":"ok"}`))
})
// Client enrollment — unauthenticated (IP-gated in handler).
// Registered on base router BEFORE HandlerWithOptions so it
// bypasses the combinedAuth middleware applied to all /api/v1/*.
r.Post("/api/v1/clients/enroll", func(w http.ResponseWriter, req *http.Request) {
var body gen.EnrollRequest
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
writeProblem(w, req, http.StatusBadRequest, "invalid request body", err.Error())
return
}
resp, err := s.EnrollClient(req.Context(), gen.EnrollClientRequestObject{Body: &body})
if err != nil {
writeProblemFromErr(w, req, err)
return
}
if err := resp.VisitEnrollClientResponse(w); err != nil {
writeProblem(w, req, http.StatusInternalServerError, "response encoding failed", err.Error())
}
})
// 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)
})
// Desktop OIDC callback — standalone HTML page that exchanges the
// authorization code for tokens and displays the access token to copy
// into the desktop app's Config screen.
r.Get("/oidc-callback", func(w http.ResponseWriter, req *http.Request) {
s.serveOIDCCallback(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())
},
ResponseErrorHandlerFunc: writeProblemFromErr,
})
gen.HandlerWithOptions(strict, gen.ChiServerOptions{
BaseURL: "/api/v1",
BaseRouter: r,
Middlewares: []gen.MiddlewareFunc{combinedAuth(cfg, false)},
ErrorHandlerFunc: func(w http.ResponseWriter, req *http.Request, err error) {
writeProblem(w, req, http.StatusBadRequest, "bad request", err.Error())
},
})
// SSE stream: override the generated /events/stream route with a raw
// flushing handler (registered AFTER HandlerWithOptions so chi's last
// registration wins). The strict-server path can't Flush() per event;
// this one uses the real ResponseWriter for real-time delivery. It
// inherits the router's base middleware and applies auth via With().
// allowQueryToken=true: EventSource can't set custom headers, so the
// SPA passes the token as ?token=... instead of Authorization.
r.With(combinedAuth(cfg, true)).Get("/api/v1/events/stream", s.serveSSE)
// Custom (non-OpenAPI) route: recency-ordered knowledge + stats for the
// Knowledge page's "what the system has learned" view. Registered after
// HandlerWithOptions so it wins over any generated catch-all.
// (See "Non-OpenAPI routes" carve-out block above.)
r.With(combinedAuth(cfg, false)).Get("/api/v1/knowledge/recent", s.serveRecentKnowledge)
// Custom (non-OpenAPI) route: full markdown content for a knowledge
// entity (document/investigation/runbook) by its own id or slug — the
// generated /api/v1/knowledge/{id} route (GetEntityKnowledge) answers a
// different question (knowledge referencing this entity), not this one.
// (See "Non-OpenAPI routes" carve-out block above.)
r.With(combinedAuth(cfg, false)).Get("/api/v1/knowledge/content/{id}", s.serveKnowledgeContent)
// Custom (non-OpenAPI) routes: the operator-facing knowledge CRUD surface
// (see internal/httpapi/knowledge_write.go) and the drift tooling (see
// knowledge_drift.go). Before these, knowledge could only be written by
// the agent through the MCP upsert_knowledge tool — the web UI had no way
// to create, correct or retire a note.
//
// Registered on the base router rather than through the OpenAPI codegen
// for the same reason as the read routes above: they trade in raw
// markdown and ad-hoc aggregates, not generated schema types.
// (See "Non-OpenAPI routes" carve-out block above.)
r.With(combinedAuth(cfg, false)).Get("/api/v1/knowledge/list", s.serveKnowledgeList)
r.With(combinedAuth(cfg, false)).Post("/api/v1/knowledge", s.serveCreateKnowledge)
r.With(combinedAuth(cfg, false)).Put("/api/v1/knowledge/content/{id}", s.serveUpdateKnowledge)
r.With(combinedAuth(cfg, false)).Delete("/api/v1/knowledge/content/{id}", s.serveDeleteKnowledge)
r.With(combinedAuth(cfg, false)).Get("/api/v1/knowledge/trash", s.serveKnowledgeTrash)
r.With(combinedAuth(cfg, false)).Post("/api/v1/knowledge/restore/{id}", s.serveRestoreKnowledge)
r.With(combinedAuth(cfg, false)).Get("/api/v1/knowledge/revisions/{id}", s.serveKnowledgeRevisions)
r.With(combinedAuth(cfg, false)).Get("/api/v1/knowledge/tags", s.serveKnowledgeTags)
r.With(combinedAuth(cfg, false)).Post("/api/v1/knowledge/tags/rename", s.serveRenameKnowledgeTag)
r.With(combinedAuth(cfg, false)).Get("/api/v1/knowledge/duplicates", s.serveKnowledgeDuplicates)
r.With(combinedAuth(cfg, false)).Get("/api/v1/knowledge/orphans", s.serveKnowledgeOrphans)
r.With(combinedAuth(cfg, false)).Post("/api/v1/knowledge/merge", s.serveMergeKnowledge)
// Drift audit: read-only DB-side report of orphan checks, checks on
// retired targets, stuck down/unknown probes, unmonitored declared types,
// and dangling edges. Companion to the knowledge-graph-audit skill.
r.With(combinedAuth(cfg, false)).Get("/api/v1/audit/drift", s.serveAuditDrift)
// Custom (non-OpenAPI) routes: the global activity feed (recency-ordered,
// unlike ListExecutions which sorts by target for pagination) and the
// per-session "what did this session do" digest.
// (See "Non-OpenAPI routes" carve-out block above.)
r.With(combinedAuth(cfg, false)).Get("/api/v1/activity/recent", s.serveRecentActivity)
// Streamed command output for one execution — a projection over
// execution_logs with no schema type yet (same carve-out rationale as
// /activity/recent above). Nests cleanly under the generated
// /executions/{id} subtree: chi accepts sibling children on a param node.
r.With(combinedAuth(cfg, false)).Get("/api/v1/executions/{id}/logs", s.serveExecutionLogs)
r.With(combinedAuth(cfg, false)).Get("/api/v1/activity/session/{id}", s.serveSessionDigest)
// Learning view: capability timeline + success trend, both derived from
// executions (real, growing data) rather than the patterns/skills tables,
// which are correctly modeled but have no writers anywhere yet.
// (See "Non-OpenAPI routes" carve-out block above.)
r.With(combinedAuth(cfg, false)).Get("/api/v1/learning/timeline", s.serveLearningTimeline)
r.With(combinedAuth(cfg, false)).Get("/api/v1/learning/trend", s.serveLearningTrend)
// Mount MCP at /mcp (plan R3-10)
nomosAgentID := uuid.Nil
if cfg.NomosAgentID != "" {
if id, err := uuid.Parse(cfg.NomosAgentID); err == nil {
nomosAgentID = id
}
}
if nomosAgentID == uuid.Nil && cfg.NomosAgentSlug != "" {
_ = pool.QueryRow(ctx, "SELECT id FROM entities WHERE slug = $1", cfg.NomosAgentSlug).Scan(&nomosAgentID)
}
r.With(combinedAuth(cfg, false)).Handle("/mcp", mcphandler.NewHandler(pool, cfg.MCPBearerToken, nomosAgentID))
if nomosURL := os.Getenv("NOMOS_PROXY_URL"); nomosURL != "" {
target, _ := url.Parse(nomosURL)
proxy := httputil.NewSingleHostReverseProxy(target)
// Was unauthenticated (pre-existing gap, predates the client/server
// split — this mount was never wrapped in combinedAuth, unlike every
// other custom route below). Harmless while dev-open was in effect;
// a real hole now that every route needs a real credential.
r.Mount("/agent", combinedAuth(cfg, false)(http.StripPrefix("/agent", proxy)))
}
return r
}
// combinedAuth tries OIDC JWT validation first (if configured), then falls
// back to static bearer token validation. Every request needs a valid
// credential — there is no dev-open bypass (closed as part of the
// client/server split, plans/2026-07-12-wails-desktop-app.md 0.4: once the
// SPA is a separate client, a dev-open API is reachable from any origin).
// When allowQueryToken is set, a missing Authorization header falls back to
// a `?token=` query param — only used for the SSE route, since EventSource
// can't set custom headers.
func combinedAuth(cfg config.Config, allowQueryToken bool) func(http.Handler) http.Handler {
hasOIDC := cfg.OIDCIssuer != "" && cfg.OIDCClientID != ""
hasStatic := cfg.APIToken != "" || cfg.MCPBearerToken != ""
// Cache JWKS for OIDC
var jwksURL string
var jwksCache []jwtVerificationKey
var jwksMu sync.RWMutex
if hasOIDC {
// Fetch JWKS URI from OIDC discovery
jwksURL = discoverJWKSURI(cfg.OIDCIssuer)
if jwksURL != "" {
keys, err := fetchJWKS(jwksURL)
if err != nil {
slog.Warn("oidc initial jwks fetch failed, will retry on demand", "error", err)
} else {
jwksCache = keys
}
}
}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
auth := r.Header.Get("Authorization")
raw, ok := strings.CutPrefix(auth, "Bearer ")
if (!ok || raw == "") && allowQueryToken {
raw = r.URL.Query().Get("token")
ok = raw != ""
}
if !ok || raw == "" {
writeProblem(w, r, http.StatusUnauthorized, "unauthorized",
"missing bearer token")
return
}
// Try OIDC first if configured
if hasOIDC {
jwksMu.RLock()
keys := jwksCache
jwksMu.RUnlock()
// If cache is empty, try to refresh
if len(keys) == 0 && jwksURL != "" {
if freshKeys, err := fetchJWKS(jwksURL); err == nil {
jwksMu.Lock()
jwksCache = freshKeys
keys = freshKeys
jwksMu.Unlock()
}
}
if act, err := validateOIDCToken(raw, cfg, keys); err == nil {
ctx := context.WithValue(r.Context(), actorKey, act)
next.ServeHTTP(w, r.WithContext(ctx))
return
} else {
slog.Debug("oidc validation failed", "error", err)
}
}
// Fall back to static tokens
if hasStatic {
if act, ok := staticTokenActor(cfg, raw); ok {
ctx := context.WithValue(r.Context(), actorKey, act)
next.ServeHTTP(w, r.WithContext(ctx))
return
}
}
writeProblem(w, r, http.StatusUnauthorized, "unauthorized",
"invalid or expired bearer token")
})
}
}
// staticTokenActor validates raw against the configured static bearer
// tokens (API token, MCP token) in constant time and returns the resolved
// actor. Shared between combinedAuth's header-based check and serveSSE's
// query-param check (EventSource can't set custom headers, so the SSE
// stream takes the token as ?token=...).
func staticTokenActor(cfg config.Config, raw string) (actor, bool) {
if raw == "" {
return actor{}, false
}
idPrefix := raw
if len(idPrefix) > 8 {
idPrefix = idPrefix[:8]
}
if cfg.MCPBearerToken != "" && subtle.ConstantTimeCompare([]byte(raw), []byte(cfg.MCPBearerToken)) == 1 {
return actor{Type: "agent", Label: "agent:mcp", ID: idPrefix + "...", TokenType: "static"}, true
}
if cfg.APIToken != "" && subtle.ConstantTimeCompare([]byte(raw), []byte(cfg.APIToken)) == 1 {
return actor{Type: "operator", Label: "operator:api", ID: idPrefix + "...", TokenType: "static"}, true
}
return actor{}, false
}
// jwtVerificationKey holds a parsed RSA public key or HMAC secret for JWT
// verification, identified by its key ID (kid).
type jwtVerificationKey struct {
Kid string
Alg string
Key any // *rsa.PublicKey or []byte for HMAC
IsHMAC bool
}
// discoverJWKSURI fetches the OIDC discovery document and extracts the
// jwks_uri field.
func discoverJWKSURI(issuerURL string) string {
discURL := strings.TrimRight(issuerURL, "/") + "/.well-known/openid-configuration"
client := &http.Client{Timeout: 10 * time.Second, Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: false},
}}
resp, err := client.Get(discURL)
if err != nil {
slog.Warn("oidc discovery failed", "url", discURL, "error", err)
return ""
}
defer resp.Body.Close()
var disc struct {
JWKSURI string `json:"jwks_uri"`
}
if err := json.NewDecoder(resp.Body).Decode(&disc); err != nil {
slog.Warn("oidc discovery decode failed", "error", err)
return ""
}
if disc.JWKSURI == "" {
slog.Warn("oidc discovery missing jwks_uri")
return ""
}
return disc.JWKSURI
}
// fetchJWKS retrieves the JWK set from a URL and returns the parsed keys.
func fetchJWKS(jwksURL string) ([]jwtVerificationKey, error) {
client := &http.Client{Timeout: 10 * time.Second, Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: false},
}}
resp, err := client.Get(jwksURL)
if err != nil {
return nil, fmt.Errorf("fetch jwks: %w", err)
}
defer resp.Body.Close()
var jwks struct {
Keys []json.RawMessage `json:"keys"`
}
if err := json.NewDecoder(resp.Body).Decode(&jwks); err != nil {
return nil, fmt.Errorf("decode jwks: %w", err)
}
var keys []jwtVerificationKey
for _, raw := range jwks.Keys {
var header struct {
Kty string `json:"kty"`
Kid string `json:"kid,omitempty"`
Alg string `json:"alg,omitempty"`
Use string `json:"use,omitempty"`
}
if err := json.Unmarshal(raw, &header); err != nil {
continue
}
// Skip keys not intended for signature verification
if header.Use != "" && header.Use != "sig" {
continue
}
var key jwtVerificationKey
key.Kid = header.Kid
key.Alg = header.Alg
switch header.Kty {
case "RSA":
var rsaKey struct {
N string `json:"n"`
E string `json:"e"`
}
if err := json.Unmarshal(raw, &rsaKey); err != nil {
continue
}
pubKey, err := parseRSAPublicKey(rsaKey.N, rsaKey.E)
if err != nil {
slog.Debug("oidc parse rsa key failed", "kid", header.Kid, "error", err)
continue
}
key.Key = pubKey
case "oct":
// HMAC keys not expected for OIDC but handle gracefully
key.IsHMAC = true
default:
continue
}
if key.Key != nil || key.IsHMAC {
keys = append(keys, key)
}
}
if len(keys) == 0 {
return nil, fmt.Errorf("no usable keys in jwks")
}
return keys, nil
}
// parseRSAPublicKey decodes a base64url-encoded RSA modulus and exponent into
// an *rsa.PublicKey.
func parseRSAPublicKey(nB64, eB64 string) (any, error) {
// Decode base64url modulus
nBytes, err := base64.RawURLEncoding.DecodeString(nB64)
if err != nil {
return nil, fmt.Errorf("decode modulus: %w", err)
}
// Decode base64url exponent
eBytes, err := base64.RawURLEncoding.DecodeString(eB64)
if err != nil {
return nil, fmt.Errorf("decode exponent: %w", err)
}
// Build RSA public key
n := new(big.Int).SetBytes(nBytes)
e := 0
for _, b := range eBytes {
e = (e << 8) | int(b)
}
return &rsa.PublicKey{N: n, E: e}, nil
}
// validateOIDCToken parses and validates a JWT Bearer token against the OIDC
// configuration. Returns the resolved actor on success.
func validateOIDCToken(rawToken string, cfg config.Config, keys []jwtVerificationKey) (actor, error) {
keyFunc := func(token *jwt.Token) (any, error) {
kid, ok := token.Header["kid"].(string)
if !ok {
return nil, fmt.Errorf("no kid in token header")
}
// Find matching key
for _, k := range keys {
// If kid is empty in JWK, try algorithm match
if k.Kid == kid || (k.Kid == "" && k.Alg == token.Header["alg"]) {
return k.Key, nil
}
}
// Fall back to any RSA key if no kid match (some providers don't set kid)
if !ok {
for _, k := range keys {
if k.Key != nil {
return k.Key, nil
}
}
}
return nil, fmt.Errorf("no matching key for kid: %s", kid)
}
token, err := jwt.Parse(rawToken, keyFunc,
jwt.WithIssuer(cfg.OIDCIssuer),
jwt.WithAudience(cfg.OIDCClientID),
jwt.WithValidMethods([]string{"RS256", "RS384", "RS512", "ES256", "ES384", "ES512"}),
)
if err != nil {
return actor{}, fmt.Errorf("jwt validation: %w", err)
}
claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
return actor{}, fmt.Errorf("invalid claims")
}
sub, _ := claims.GetSubject()
if sub == "" {
// Try the Azure/Entra ID oid claim fallback
if oid, ok := claims["oid"].(string); ok {
sub = oid
}
}
preferredUsername, _ := claims["preferred_username"].(string)
email, _ := claims["email"].(string)
label := sub
if preferredUsername != "" {
label = preferredUsername
} else if email != "" {
label = email
}
return actor{
Type: "operator",
Label: label,
ID: sub,
TokenType: "oidc",
}, nil
}
// GetActor extracts the actor identity from the context. Returns nil if not
// set (should not happen for authenticated routes).
func GetActor(ctx context.Context) *actor {
a, ok := ctx.Value(actorKey).(actor)
if !ok {
return nil
}
return &a
}
// requestLogger logs one line per request with method, path, status,
// duration, and the chi request id.
func requestLogger(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
next.ServeHTTP(ww, r)
slog.Info("http",
"method", r.Method,
"path", r.URL.Path,
"status", ww.Status(),
"duration_ms", time.Since(start).Milliseconds(),
"request_id", middleware.GetReqID(r.Context()),
)
})
}
// 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)
}
// serveOIDCCallback serves a standalone HTML page that completes the
// desktop OIDC login flow. Authentik redirects here with ?code=...&state=...
// after the user authorizes. The state carries the PKCE verifier
// (base64url-encoded, joined with "."). The page exchanges the code for
// tokens via the token proxy, then displays the access token for the user
// to copy into the desktop app.
func (s *Server) serveOIDCCallback(w http.ResponseWriter, req *http.Request, cfg config.Config) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprint(w, `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Oikos — Connect Desktop App</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #0a0a0a; color: #e0e0e0;
display: flex; align-items: center; justify-content: center;
min-height: 100vh; padding: 24px;
}
.card {
background: #1a1a1a; border: 1px solid #2a2a2a;
border-radius: 12px; padding: 32px; max-width: 480px; width: 100%;
}
h1 { font-size: 20px; margin-bottom: 8px; }
p { font-size: 14px; color: #888; margin-bottom: 20px; }
.spinner { margin: 24px auto; width: 32px; height: 32px; border: 3px solid #2a2a2a; border-top-color: #3b82f6; border-radius: 50%; animation: spin 0.8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.token-box {
background: #111; border: 1px solid #2a2a2a; border-radius: 8px;
padding: 16px; font-family: monospace; font-size: 13px;
word-break: break-all; margin-bottom: 16px; position: relative;
max-height: 160px; overflow-y: auto;
}
.btn {
display: block; width: 100%; padding: 12px; border: none; border-radius: 8px;
font-size: 14px; font-weight: 600; cursor: pointer; text-align: center;
}
.btn-primary { background: #3b82f6; color: #fff; }
.btn-primary:hover { background: #2563eb; }
.btn-secondary { background: #1a1a1a; color: #e0e0e0; border: 1px solid #2a2a2a; margin-top: 8px; }
.btn-secondary:hover { background: #222; }
.success { color: #22c55e; margin-bottom: 8px; font-weight: 600; }
.error { color: #ef4444; margin-bottom: 12px; }
.copied { color: #22c55e; font-size: 13px; text-align: center; margin-top: 8px; }
</style>
</head>
<body>
<div class="card">
<h1>Connect Desktop App</h1>
<div id="loading">
<p>Exchanging authorization code...</p>
<div class="spinner"></div>
</div>
<div id="result" style="display:none"></div>
</div>
<script>
async function main() {
const params = new URLSearchParams(location.search);
const code = params.get('code');
const state = params.get('state');
if (!code || !state) {
showError('Missing code or state parameter from Authentik redirect.');
return;
}
const parts = state.split('.');
if (parts.length !== 2) {
showError('Invalid state format.');
return;
}
const [csrf, verifier] = parts;
const redirectURI = location.origin + '/oidc-callback';
try {
const resp = await fetch('/api/v1/auth/oidc-token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
grant_type: 'authorization_code',
code: code,
code_verifier: verifier,
redirect_uri: redirectURI
})
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({ error: resp.statusText }));
showError(err.error || err.message || 'Token exchange failed (' + resp.status + ')');
return;
}
const tokens = await resp.json();
if (!tokens.access_token) {
showError('No access token in response.');
return;
}
document.getElementById('loading').style.display = 'none';
const result = document.getElementById('result');
result.style.display = 'block';
result.innerHTML = '<div class="success">Authentication successful</div>' +
'<p style="margin-bottom:8px">Copy this token into the Oikos desktop app Token tab:</p>' +
'<div class="token-box" id="token">' + escapeHtml(tokens.access_token) + '</div>' +
'<button class="btn btn-primary" id="copyBtn">Copy Token</button>' +
'<button class="btn btn-secondary" onclick="location.reload()">Try Again</button>' +
'<div class="copied" id="copied" style="display:none">Copied!</div>';
document.getElementById('copyBtn').addEventListener('click', () => {
navigator.clipboard.writeText(tokens.access_token).then(() => {
const el = document.getElementById('copied');
el.style.display = 'block';
setTimeout(() => el.style.display = 'none', 2000);
});
});
} catch(e) {
showError('Network error: ' + e.message);
}
}
function showError(msg) {
document.getElementById('loading').style.display = 'none';
const result = document.getElementById('result');
result.style.display = 'block';
result.innerHTML = '<div class="error">' + escapeHtml(msg) + '</div>' +
'<button class="btn btn-secondary" onclick="location.reload()">Try Again</button>';
}
function escapeHtml(s) {
return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
main();
</script>
</body>
</html>`)
}
// 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 {
srv := &http.Server{
Addr: cfg.APIListen,
Handler: NewHandler(ctx, pool, cfg),
ReadHeaderTimeout: 10 * time.Second,
}
errCh := make(chan error, 1)
go func() {
// Recovers a panic in ListenAndServe (stdlib, so extremely unlikely,
// but an unrecovered panic here would crash the whole process rather
// than surfacing as a normal startup error) and reports it through
// errCh instead — the select below would otherwise just hang waiting
// for a value that never arrives.
defer func() {
if r := recover(); r != nil {
errCh <- fmt.Errorf("panic in ListenAndServe: %v", r)
}
}()
slog.Info("api listening", "addr", cfg.APIListen)
errCh <- srv.ListenAndServe()
}()
select {
case err := <-errCh:
return err
case <-ctx.Done():
slog.Info("api shutting down")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
return srv.Shutdown(shutdownCtx)
}
}