// Package auth holds the two middlewares Dialectic v2 uses: // // - AgentAPIKey: validates `Authorization: Bearer ` against // the `agent_keys` table (hashed with the configured pepper). // Used by Dialectic.OpenclawPlugin → backend calls. // // - OIDCBrowser: validates a Keycloak-issued JWT in the // `dialectic_session` cookie. Used by the React frontend. // Phase 2C ships a stub that accepts a dev-mode bypass token; the // real JWKS verification + claim mapping lands with Phase 4. // // Both middlewares attach a typed Caller to the request context so // downstream handlers can read identity uniformly. package auth import ( "context" "crypto/sha256" "crypto/subtle" "database/sql" "encoding/hex" "errors" "net/http" "strings" "github.com/jmoiron/sqlx" ) type CallerKind string const ( CallerAgent CallerKind = "agent" CallerUser CallerKind = "user" CallerSystem CallerKind = "system" ) type Caller struct { Kind CallerKind ID string // agentId for CallerAgent; userId for CallerUser; key-name for CallerSystem Roles []string // populated for CallerUser (from JWT claims); empty otherwise } type ctxKey struct{} func WithCaller(ctx context.Context, c Caller) context.Context { return context.WithValue(ctx, ctxKey{}, c) } // FromContext returns the caller attached by an auth middleware. The // zero Caller (Kind == "") indicates an unauthenticated request reached // a public route. func FromContext(ctx context.Context) Caller { c, _ := ctx.Value(ctxKey{}).(Caller) return c } // HashKey peppers + sha256-hashes a raw API key. Constant pepper; same // raw key always produces the same hash so lookups can equal-match on // the key_hash column. func HashKey(pepper, raw string) string { h := sha256.Sum256([]byte(pepper + ":" + raw)) return hex.EncodeToString(h[:]) } // AgentAPIKey middleware: extracts Bearer token, looks up agent_keys, // 401 on miss. Updates last_used_at lazily (best-effort; failure here // doesn't block the request). func AgentAPIKey(db *sqlx.DB, pepper string) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { raw := bearerToken(r) if raw == "" { http.Error(w, "missing bearer token", http.StatusUnauthorized) return } hash := HashKey(pepper, raw) var agentID string err := db.GetContext(r.Context(), &agentID, `SELECT agent_id FROM agent_keys WHERE key_hash = ? AND revoked_at IS NULL`, hash) if errors.Is(err, sql.ErrNoRows) { http.Error(w, "invalid agent key", http.StatusUnauthorized) return } if err != nil { http.Error(w, "auth lookup failed", http.StatusInternalServerError) return } go func(h string) { // best-effort touch — independent ctx so it survives // even if the request was cancelled mid-handler. _, _ = db.Exec( `UPDATE agent_keys SET last_used_at = CURRENT_TIMESTAMP WHERE key_hash = ?`, h) }(hash) ctx := WithCaller(r.Context(), Caller{Kind: CallerAgent, ID: agentID}) next.ServeHTTP(w, r.WithContext(ctx)) }) } } // OIDCBrowser middleware (Phase 2C stub): // - Dev mode + `x-dev-bypass: ` header → admit as a fake user. // - Otherwise: 401 with a hint pointing to the (not-yet-wired) // Keycloak redirect path. The real JWKS-verifying middleware lands // when the frontend is wired up; until then, browser callers can // only reach the API via the dev bypass. func OIDCBrowser(devMode bool, devBypassToken string) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if devMode && devBypassToken != "" { if subtleEqual(r.Header.Get("x-dev-bypass"), devBypassToken) { ctx := WithCaller(r.Context(), Caller{ Kind: CallerUser, ID: "dev-operator", Roles: []string{"dialectic-admin"}, }) next.ServeHTTP(w, r.WithContext(ctx)) return } } // Production path goes through Keycloak — Phase 4. http.Error(w, "oidc login required (Phase 4: not yet wired)", http.StatusUnauthorized) }) } } func bearerToken(r *http.Request) string { h := r.Header.Get("authorization") const prefix = "Bearer " if strings.HasPrefix(h, prefix) { return strings.TrimSpace(h[len(prefix):]) } if strings.HasPrefix(h, "bearer ") { return strings.TrimSpace(h[len("bearer "):]) } return "" } func subtleEqual(a, b string) bool { if len(a) != len(b) { return false } return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1 }