907 lines
32 KiB
Go
907 lines
32 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)
|
|
|
|
// 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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|