feat(web): split SPA from oikos binary, require auth on every route
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled

Phase 0 of plans/2026-07-12-wails-desktop-app.md. The control-room SPA
is no longer embedded (web/embed.go deleted); it's a standalone static
build served separately (make ui / make deploy-ui). The api process
adds CORS and drops the dev-open auth bypass — every route now needs a
real bearer token, including SSE (?token= query param, EventSource
can't set headers) and api's own /agent proxy to nomos (previously
unauthenticated by omission).

nomos was an unauthenticated client of api's /mcp and approval-decision
endpoints; closing dev-open would have broken it, so it now sends
Authorization: Bearer $OIKOS_MCP_BEARER_TOKEN on every call back to api.

SPA gets a runtime config module (config.ts) and a Config.svelte
first-launch/reconfigure page, reachable afterwards via a "Connection"
entry in the sidebar footer. Every fetch() in api.ts routes through
fetchWithAuth so the same build works same-origin (browser prod, Vite
dev proxy) or cross-origin (future Wails webview, remote access).

Six gaps found against the plan and the live Caddy topology while
implementing — documented in the plan's "Plan review" section, most
notably: api's own /agent mount was never behind combinedAuth (fixed),
and production's Authentik forward-auth needs a bearer-token bypass for
API routes that this repo's Caddyfile.oikos reference copy now has, but
the real dtoro/caddy-conf deploy does not yet.

Verified live: cross-origin static SPA + API, CORS, bearer auth, SSE
query-token auth, and localStorage persistence all confirmed working
in-browser. Full Go test suite and npm run build pass with no
regressions against the pre-change baseline.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-12 15:49:42 +02:00
parent 346eb2f144
commit 0c0f35a3a9
32 changed files with 661 additions and 248 deletions

View File

@@ -93,15 +93,34 @@ func newTestHandler(t *testing.T, cfg config.Config) http.Handler {
}
}
return NewHandler(handlerCtx, pool, cfg, nil)
return NewHandler(handlerCtx, pool, cfg)
}
// testAuthToken is the static bearer token devConfig() configures. There is
// no dev-open bypass (removed — plans/2026-07-12-wails-desktop-app.md 0.4),
// so every test handler needs a real credential; get/postJSON/do inject it
// by default. Pass an explicit "" value for "Authorization" in headers to
// test the no-credential path.
const testAuthToken = "test-dev-token"
// applyHeaders sets req's default Authorization header, then layers headers
// on top. A "" value deletes the header instead of setting it, so tests can
// exercise the missing-credential case.
func applyHeaders(req *http.Request, headers map[string]string) {
req.Header.Set("Authorization", "Bearer "+testAuthToken)
for k, v := range headers {
if v == "" {
req.Header.Del(k)
} else {
req.Header.Set(k, v)
}
}
}
func get(t *testing.T, h http.Handler, path string, headers map[string]string) (*httptest.ResponseRecorder, map[string]any) {
t.Helper()
req := httptest.NewRequest("GET", path, nil)
for k, v := range headers {
req.Header.Set(k, v)
}
applyHeaders(req, headers)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
var body map[string]any
@@ -113,6 +132,7 @@ func postJSON(t *testing.T, h http.Handler, path string, payload string) (*httpt
t.Helper()
req := httptest.NewRequest("POST", path, strings.NewReader(payload))
req.Header.Set("Content-Type", "application/json")
applyHeaders(req, nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
var body map[string]any
@@ -122,7 +142,8 @@ func postJSON(t *testing.T, h http.Handler, path string, payload string) (*httpt
func devConfig() config.Config {
c := config.Default()
c.APIEnv = "dev" // no tokens → dev-open auth
c.APIEnv = "dev"
c.APIToken = testAuthToken
return c
}
@@ -295,7 +316,7 @@ func TestAPIBearerAuth(t *testing.T) {
}
// API requires the token
rec, body := get(t, h, "/api/v1/entities", nil)
rec, body := get(t, h, "/api/v1/entities", map[string]string{"Authorization": ""})
if rec.Code != 401 {
t.Errorf("no token = %d, want 401 (%v)", rec.Code, body)
}

View File

@@ -24,9 +24,7 @@ func do(t *testing.T, h http.Handler, method, path string, body any, headers map
}
req := httptest.NewRequest(method, path, rdr)
req.Header.Set("Content-Type", "application/json")
for k, v := range headers {
req.Header.Set(k, v)
}
applyHeaders(req, headers)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
var decoded map[string]any

View File

@@ -154,6 +154,7 @@ func TestPhase4MCPEndpointAlive(t *testing.T) {
req := httptest.NewRequest("POST", "/mcp", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json, text/event-stream")
req.Header.Set("Authorization", "Bearer "+testAuthToken)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)

View File

@@ -29,6 +29,7 @@ import (
"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"
)
@@ -70,7 +71,7 @@ type secretsBackend interface {
// 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, uiHandler http.Handler) http.Handler {
func NewHandler(ctx context.Context, pool *db.Pool, cfg config.Config) http.Handler {
s := &Server{
pool: pool,
cfg: cfg,
@@ -88,6 +89,12 @@ func NewHandler(ctx context.Context, pool *db.Pool, cfg config.Config, uiHandler
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,
}))
// Liveness — no auth, no audit (plan SG18). Not exposed via Caddy.
r.Get("/healthz", func(w http.ResponseWriter, req *http.Request) {
@@ -130,7 +137,7 @@ func NewHandler(ctx context.Context, pool *db.Pool, cfg config.Config, uiHandler
gen.HandlerWithOptions(strict, gen.ChiServerOptions{
BaseURL: "/api/v1",
BaseRouter: r,
Middlewares: []gen.MiddlewareFunc{combinedAuth(cfg)},
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())
},
@@ -141,24 +148,26 @@ func NewHandler(ctx context.Context, pool *db.Pool, cfg config.Config, uiHandler
// 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().
r.With(combinedAuth(cfg)).Get("/api/v1/events/stream", s.serveSSE)
// 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.
r.With(combinedAuth(cfg)).Get("/api/v1/knowledge/recent", s.serveRecentKnowledge)
r.With(combinedAuth(cfg, false)).Get("/api/v1/knowledge/recent", s.serveRecentKnowledge)
// 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.
r.With(combinedAuth(cfg)).Get("/api/v1/activity/recent", s.serveRecentActivity)
r.With(combinedAuth(cfg)).Get("/api/v1/activity/session/{id}", s.serveSessionDigest)
r.With(combinedAuth(cfg, false)).Get("/api/v1/activity/recent", s.serveRecentActivity)
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.
r.With(combinedAuth(cfg)).Get("/api/v1/learning/timeline", s.serveLearningTimeline)
r.With(combinedAuth(cfg)).Get("/api/v1/learning/trend", s.serveLearningTrend)
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
@@ -170,33 +179,30 @@ func NewHandler(ctx context.Context, pool *db.Pool, cfg config.Config, uiHandler
if nomosAgentID == uuid.Nil && cfg.NomosAgentSlug != "" {
_ = pool.QueryRow(ctx, "SELECT id FROM entities WHERE slug = $1", cfg.NomosAgentSlug).Scan(&nomosAgentID)
}
r.With(combinedAuth(cfg)).Handle("/mcp", mcphandler.NewHandler(pool, cfg.MCPBearerToken, nomosAgentID))
r.Get("/ui/*", func(w http.ResponseWriter, req *http.Request) {
if uiHandler != nil {
uiHandler.ServeHTTP(w, req)
}
})
r.Get("/ui", func(w http.ResponseWriter, req *http.Request) {
http.Redirect(w, req, "/ui/", http.StatusMovedPermanently)
})
r.Get("/", func(w http.ResponseWriter, req *http.Request) {
http.Redirect(w, req, "/ui/", http.StatusMovedPermanently)
})
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)
r.Mount("/agent", http.StripPrefix("/agent", proxy))
// 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), falls back to
// static bearer token validation, and opens the gate in dev mode when no
// credentials are configured.
func combinedAuth(cfg config.Config) func(http.Handler) http.Handler {
// 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 != ""
@@ -217,31 +223,14 @@ func combinedAuth(cfg config.Config) func(http.Handler) http.Handler {
}
}
var staticTokens [][]byte
if cfg.APIToken != "" {
staticTokens = append(staticTokens, []byte(cfg.APIToken))
}
if cfg.MCPBearerToken != "" {
staticTokens = append(staticTokens, []byte(cfg.MCPBearerToken))
}
devOpen := cfg.APIEnv == "dev" && !hasStatic && !hasOIDC
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if devOpen {
ctx := context.WithValue(r.Context(), actorKey, actor{
Type: "system",
Label: "dev:anonymous",
ID: "dev",
TokenType: "none",
})
next.ServeHTTP(w, r.WithContext(ctx))
return
}
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")
@@ -275,21 +264,10 @@ func combinedAuth(cfg config.Config) func(http.Handler) http.Handler {
// Fall back to static tokens
if hasStatic {
for _, t := range staticTokens {
if subtle.ConstantTimeCompare([]byte(raw), t) == 1 {
label := "operator:api"
if cfg.MCPBearerToken != "" && subtle.ConstantTimeCompare([]byte(raw), []byte(cfg.MCPBearerToken)) == 1 {
label = "agent:mcp"
}
ctx := context.WithValue(r.Context(), actorKey, actor{
Type: label[:strings.IndexByte(label, ':')],
Label: label,
ID: raw[:8] + "...",
TokenType: "static",
})
next.ServeHTTP(w, r.WithContext(ctx))
return
}
if act, ok := staticTokenActor(cfg, raw); ok {
ctx := context.WithValue(r.Context(), actorKey, act)
next.ServeHTTP(w, r.WithContext(ctx))
return
}
}
@@ -299,6 +277,28 @@ func combinedAuth(cfg config.Config) func(http.Handler) http.Handler {
}
}
// 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 {
@@ -526,10 +526,10 @@ func requestLogger(next http.Handler) http.Handler {
// 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, uiHandler http.Handler) error {
func ListenAndServe(ctx context.Context, pool *db.Pool, cfg config.Config) error {
srv := &http.Server{
Addr: cfg.APIListen,
Handler: NewHandler(ctx, pool, cfg, uiHandler),
Handler: NewHandler(ctx, pool, cfg),
ReadHeaderTimeout: 10 * time.Second,
}

View File

@@ -29,6 +29,7 @@ func TestSSEStreamRealtimeDelivery(t *testing.T) {
// Connect to the stream.
req, _ := http.NewRequestWithContext(ctx, "GET", srv.URL+"/api/v1/events/stream", nil)
req.Header.Set("Authorization", "Bearer "+testAuthToken)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("connect stream: %v", err)
@@ -60,7 +61,10 @@ func TestSSEStreamRealtimeDelivery(t *testing.T) {
// POSTing to the SAME live server (same DB → NOTIFY the listener sees).
time.Sleep(300 * time.Millisecond)
payload, _ := json.Marshal(map[string]any{"slug": "service:sse-rt", "type": "service", "name": "sse-rt"})
cResp, err := http.Post(srv.URL+"/api/v1/entities", "application/json", bytes.NewReader(payload))
createReq, _ := http.NewRequestWithContext(ctx, "POST", srv.URL+"/api/v1/entities", bytes.NewReader(payload))
createReq.Header.Set("Content-Type", "application/json")
createReq.Header.Set("Authorization", "Bearer "+testAuthToken)
cResp, err := http.DefaultClient.Do(createReq)
if err != nil {
t.Fatalf("trigger create: %v", err)
}