diff --git a/.env.example b/.env.example index 865de2a..5850c11 100644 --- a/.env.example +++ b/.env.example @@ -29,10 +29,6 @@ DATABASE_URL=sqlite:data/hypergoat.db # IMPORTANT: This MUST be persistent across restarts or sessions will be invalidated SECRET_KEY_BASE=CHANGE_ME_TO_A_RANDOM_64_CHARACTER_STRING_USE_OPENSSL_RAND -# Trust X-User-DID header from reverse proxy for authentication -# DANGEROUS: only enable when running behind a trusted reverse proxy -# TRUST_PROXY_HEADERS=false - # Allowed origins for CORS and WebSocket connections (comma-separated) # Empty or unset = allow all origins. Set explicit origins in production. # Examples: https://myapp.com,https://admin.myapp.com @@ -42,6 +38,12 @@ SECRET_KEY_BASE=CHANGE_ME_TO_A_RANDOM_64_CHARACTER_STRING_USE_OPENSSL_RAND # Example: did:plc:qc42fmqqlsmdq7jiypiiigww is daviddao.org ADMIN_DIDS=did:plc:qc42fmqqlsmdq7jiypiiigww +# Shared secret for admin API authentication. +# When set, the X-User-DID header is trusted only if the request also +# carries a matching Authorization: Bearer header. +# Generate with: openssl rand -base64 32 +# ADMIN_API_KEY= + # Domain DID for server identity (defaults to did:web:{HOST}) # DOMAIN_DID= diff --git a/README.md b/README.md index 3440244..1ef7f94 100644 --- a/README.md +++ b/README.md @@ -128,10 +128,11 @@ ADMIN_DIDS=did:plc:your-did-here # Security — required for session encryption (min 64 chars) SECRET_KEY_BASE=your-secret-key-at-least-64-characters-long-generate-with-openssl-rand -# Proxy auth — set to true when running behind a trusted reverse proxy -# (e.g. Next.js frontend on Vercel) that sets the X-User-DID header. -# WARNING: Never enable this when the server is directly exposed to the internet. -TRUST_PROXY_HEADERS=false +# Admin API key — shared secret for admin authentication. +# When set, the X-User-DID header is trusted only if the request +# also carries a matching Authorization: Bearer header. +# Generate with: openssl rand -base64 32 +# ADMIN_API_KEY=your-secret-key-here # WebSocket origins — comma-separated allowed origins for subscriptions. # Empty = same-origin only. Set to "*" for development. diff --git a/cmd/hypergoat/main.go b/cmd/hypergoat/main.go index 63c0151..a2a26ed 100644 --- a/cmd/hypergoat/main.go +++ b/cmd/hypergoat/main.go @@ -251,8 +251,8 @@ func setupRouter(cfg *config.Config, svc *services) *chi.Mux { } } r.Use(server.CORSMiddleware(server.CORSConfig{ - AllowedOrigins: allowedOrigins, - TrustProxyHeaders: cfg.TrustProxyHeaders, + AllowedOrigins: allowedOrigins, + AdminAPIKeySet: cfg.AdminAPIKey != "", })) // Health check @@ -415,7 +415,7 @@ func setupAdmin(r *chi.Mux, cfg *config.Config, svc *services) *admin.Handler { domainDID = "did:web:" + cfg.Host } - adminHandler, err := admin.NewHandler(adminRepos, authMiddleware, svc.config, domainDID, cfg.TrustProxyHeaders) + adminHandler, err := admin.NewHandler(adminRepos, authMiddleware, svc.config, domainDID, cfg.AdminAPIKey) if err != nil { slog.Error("Failed to create admin GraphQL handler", "error", err) return nil @@ -431,9 +431,9 @@ func setupAdmin(r *chi.Mux, cfg *config.Config, svc *services) *admin.Handler { // GraphiQL playgrounds r.Get("/graphiql", server.HandleGraphiQL(server.GraphiQLConfig{ - Endpoint: cfg.ExternalBaseURL + "/graphql", - SubscriptionEndpoint: strings.Replace(cfg.ExternalBaseURL, "http", "ws", 1) + "/graphql/ws", - Title: "Hypergoat GraphQL", + EndpointPath: "/graphql", + SubscriptionPath: "/graphql/ws", + Title: "Hypergoat GraphQL", DefaultQuery: `# Hypergoat GraphQL API # # Explore the AT Protocol data indexed by this AppView. @@ -451,12 +451,13 @@ func setupAdmin(r *chi.Mux, cfg *config.Config, svc *services) *admin.Handler { })) r.Get("/graphiql/admin", server.HandleGraphiQL(server.GraphiQLConfig{ - Endpoint: cfg.ExternalBaseURL + "/admin/graphql", - Title: "Hypergoat Admin", + EndpointPath: "/admin/graphql", + Title: "Hypergoat Admin", + AdminAuth: true, DefaultQuery: `# Hypergoat Admin API # # Administrative operations for managing the AppView. -# Note: Some operations require authentication. +# Enter your API Key and DID above to authenticate. # # Example: { diff --git a/internal/config/config.go b/internal/config/config.go index 6dc2ab8..20cedae 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -23,9 +23,8 @@ type Config struct { DatabaseURL string // Security - SecretKeyBase string - TrustProxyHeaders bool // Trust X-User-DID header from reverse proxy (default: false, DANGEROUS if true without proxy) - AllowedOrigins string // Comma-separated allowed WebSocket/CORS origins (empty = same-origin only, "*" = allow all) + SecretKeyBase string + AllowedOrigins string // Comma-separated allowed WebSocket/CORS origins (empty = same-origin only, "*" = allow all) // OAuth ExternalBaseURL string @@ -33,8 +32,9 @@ type Config struct { OAuthLoopbackMode bool // Admin - AdminDIDs string // Comma-separated list of admin DIDs - DomainDID string // Domain DID for identity + AdminDIDs string // Comma-separated list of admin DIDs + AdminAPIKey string // Shared secret; when set, X-User-DID header is trusted if accompanied by a valid Bearer token + DomainDID string // Domain DID for identity // Lexicons LexiconDir string // Directory to load lexicon JSON files from @@ -75,9 +75,8 @@ func Load() (*Config, error) { DatabaseURL: getEnv("DATABASE_URL", "sqlite:data/hypergoat.db"), // Security - SecretKeyBase: getEnv("SECRET_KEY_BASE", ""), - TrustProxyHeaders: getEnvBool("TRUST_PROXY_HEADERS", false), - AllowedOrigins: getEnv("ALLOWED_ORIGINS", ""), + SecretKeyBase: getEnv("SECRET_KEY_BASE", ""), + AllowedOrigins: getEnv("ALLOWED_ORIGINS", ""), // OAuth ExternalBaseURL: getEnv("EXTERNAL_BASE_URL", ""), @@ -85,8 +84,9 @@ func Load() (*Config, error) { OAuthLoopbackMode: getEnvBool("OAUTH_LOOPBACK_MODE", false), // Admin - AdminDIDs: getEnv("ADMIN_DIDS", ""), - DomainDID: getEnv("DOMAIN_DID", ""), + AdminDIDs: getEnv("ADMIN_DIDS", ""), + AdminAPIKey: getEnv("ADMIN_API_KEY", ""), + DomainDID: getEnv("DOMAIN_DID", ""), // Lexicons LexiconDir: getEnv("LEXICON_DIR", ""), @@ -154,18 +154,17 @@ func (c *Config) LogConfig() { "oauth_loopback_mode", c.OAuthLoopbackMode, "oauth_signing_key_set", c.OAuthSigningKey != "", "admin_dids_set", c.AdminDIDs != "", + "admin_api_key_set", c.AdminAPIKey != "", "lexicon_dir", c.LexiconDir, "jetstream_url", c.JetstreamURL, "jetstream_collections", c.JetstreamCollections, "jetstream_disable_cursor", c.JetstreamDisableCursor, "backfill_on_start", c.BackfillOnStart, - "trust_proxy_headers", c.TrustProxyHeaders, "allowed_origins", c.AllowedOrigins, ) - if c.TrustProxyHeaders { - slog.Warn("TRUST_PROXY_HEADERS is enabled: X-User-DID header will be trusted for authentication. " + - "Only enable this when running behind a trusted reverse proxy that sets this header.") + if c.AdminAPIKey != "" { + slog.Info("ADMIN_API_KEY is set: X-User-DID header will be trusted when accompanied by a valid Bearer token") } } diff --git a/internal/graphql/admin/handler.go b/internal/graphql/admin/handler.go index c7c20af..79dfd67 100644 --- a/internal/graphql/admin/handler.go +++ b/internal/graphql/admin/handler.go @@ -1,6 +1,7 @@ package admin import ( + "crypto/subtle" "encoding/json" "log/slog" "net/http" @@ -14,17 +15,17 @@ import ( // Handler handles admin GraphQL requests with authentication. type Handler struct { - schema *graphql.Schema - resolver *Resolver - middleware *oauth.AuthMiddleware - configRepo *repositories.ConfigRepository - trustProxyHeaders bool + schema *graphql.Schema + resolver *Resolver + middleware *oauth.AuthMiddleware + configRepo *repositories.ConfigRepository + adminAPIKey string // shared secret; when set, X-User-DID is trusted if Bearer token matches } // NewHandler creates a new admin GraphQL handler. -// trustProxyHeaders controls whether the X-User-DID header is trusted for authentication. -// This should only be true when running behind a trusted reverse proxy. -func NewHandler(repos *Repositories, middleware *oauth.AuthMiddleware, configRepo *repositories.ConfigRepository, domainDID string, trustProxyHeaders bool) (*Handler, error) { +// When adminAPIKey is non-empty, the X-User-DID header is trusted only if the +// request also carries a matching Authorization: Bearer header. +func NewHandler(repos *Repositories, middleware *oauth.AuthMiddleware, configRepo *repositories.ConfigRepository, domainDID string, adminAPIKey string) (*Handler, error) { resolver := NewResolver(repos, domainDID) builder := NewSchemaBuilder(resolver) @@ -34,11 +35,11 @@ func NewHandler(repos *Repositories, middleware *oauth.AuthMiddleware, configRep } return &Handler{ - schema: schema, - resolver: resolver, - middleware: middleware, - configRepo: configRepo, - trustProxyHeaders: trustProxyHeaders, + schema: schema, + resolver: resolver, + middleware: middleware, + configRepo: configRepo, + adminAPIKey: adminAPIKey, }, nil } @@ -71,14 +72,19 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { ctx := r.Context() userDID := oauth.UserIDFromContext(ctx) - // Only trust X-User-DID header when explicitly configured (TRUST_PROXY_HEADERS=true). - // This is intended for deployments behind a trusted reverse proxy (e.g., Next.js frontend). - // WARNING: Without a trusted proxy, this header can be spoofed by any client. - if userDID == "" && h.trustProxyHeaders { - userDID = r.Header.Get("X-User-DID") - if userDID != "" { - slog.Warn("[admin] Auth via X-User-DID proxy header", - "did", userDID, + // Trust X-User-DID header only when the request carries a valid admin API key. + // This allows frontends and CLI tools to authenticate as a specific user + // without requiring the full OAuth flow. + if userDID == "" && h.adminAPIKey != "" { + if h.validAPIKey(r) { + userDID = r.Header.Get("X-User-DID") + if userDID != "" { + slog.Info("[admin] Auth via X-User-DID + API key", + "did", userDID, + "remote_addr", r.RemoteAddr) + } + } else if r.Header.Get("X-User-DID") != "" { + slog.Warn("[admin] X-User-DID header rejected: missing or invalid API key", "remote_addr", r.RemoteAddr) } } @@ -156,3 +162,19 @@ func (h *Handler) RequireAuth() http.Handler { func (h *Handler) OptionalAuth() http.Handler { return h.middleware.OptionalAuth(h) } + +// validAPIKey checks whether the request carries a valid admin API key. +// Returns true if no API key is configured (backwards-compatible) or if the +// request's Authorization: Bearer token matches the configured key. +func (h *Handler) validAPIKey(r *http.Request) bool { + if h.adminAPIKey == "" { + return true // no key configured — allow (backwards-compatible) + } + + auth := r.Header.Get("Authorization") + if !strings.HasPrefix(auth, "Bearer ") { + return false + } + token := strings.TrimPrefix(auth, "Bearer ") + return subtle.ConstantTimeCompare([]byte(token), []byte(h.adminAPIKey)) == 1 +} diff --git a/internal/graphql/admin/handler_test.go b/internal/graphql/admin/handler_test.go index 50385a1..4299051 100644 --- a/internal/graphql/admin/handler_test.go +++ b/internal/graphql/admin/handler_test.go @@ -2,6 +2,7 @@ package admin import ( "context" + "net/http" "testing" "github.com/graphql-go/graphql" @@ -232,6 +233,65 @@ func TestRequireAdmin(t *testing.T) { } } +func TestValidAPIKey(t *testing.T) { + tests := []struct { + name string + adminAPIKey string + authHeader string + want bool + }{ + { + name: "no key configured allows all", + adminAPIKey: "", + authHeader: "", + want: true, + }, + { + name: "valid key matches", + adminAPIKey: "secret123", + authHeader: "Bearer secret123", + want: true, + }, + { + name: "wrong key rejected", + adminAPIKey: "secret123", + authHeader: "Bearer wrong", + want: false, + }, + { + name: "missing auth header rejected", + adminAPIKey: "secret123", + authHeader: "", + want: false, + }, + { + name: "non-Bearer scheme rejected", + adminAPIKey: "secret123", + authHeader: "Basic secret123", + want: false, + }, + { + name: "Bearer prefix only rejected", + adminAPIKey: "secret123", + authHeader: "Bearer ", + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + h := &Handler{adminAPIKey: tt.adminAPIKey} + req, _ := http.NewRequest("POST", "/admin/graphql", nil) + if tt.authHeader != "" { + req.Header.Set("Authorization", tt.authHeader) + } + if got := h.validAPIKey(req); got != tt.want { + t.Errorf("validAPIKey() = %v, want %v", got, tt.want) + } + }) + } +} + func TestContextKeysAreUnique(t *testing.T) { // Ensure context keys are unique keys := []contextKey{ diff --git a/internal/server/cors.go b/internal/server/cors.go index 50e0dda..3ab5ee3 100644 --- a/internal/server/cors.go +++ b/internal/server/cors.go @@ -16,8 +16,9 @@ type CORSConfig struct { // "Content-Type" and "Authorization" are always included. AllowedHeaders []string - // TrustProxyHeaders controls whether X-User-DID is included in allowed headers. - TrustProxyHeaders bool + // AdminAPIKeySet controls whether X-User-DID is included in allowed headers. + // When true, the admin API key mechanism is active and browsers need to send X-User-DID. + AdminAPIKeySet bool } // CORSMiddleware returns an HTTP middleware that handles CORS headers and preflight requests. @@ -33,7 +34,7 @@ func CORSMiddleware(cfg CORSConfig) func(http.Handler) http.Handler { // Build allowed headers headers := []string{"Content-Type", "Authorization", "DPoP"} headers = append(headers, cfg.AllowedHeaders...) - if cfg.TrustProxyHeaders { + if cfg.AdminAPIKeySet { headers = append(headers, "X-User-DID") } allowedHeaders := strings.Join(headers, ", ") diff --git a/internal/server/graphiql.go b/internal/server/graphiql.go index 0dba001..68f3b0a 100644 --- a/internal/server/graphiql.go +++ b/internal/server/graphiql.go @@ -9,14 +9,17 @@ import ( // GraphiQLConfig contains configuration for the GraphiQL handler. type GraphiQLConfig struct { - // Endpoint is the GraphQL endpoint URL. - Endpoint string - // SubscriptionEndpoint is the WebSocket endpoint for subscriptions (optional). - SubscriptionEndpoint string + // EndpointPath is the path to the GraphQL endpoint (e.g. "/graphql"). + // The full URL is derived at runtime from the browser's window.location. + EndpointPath string + // SubscriptionPath is the path for WebSocket subscriptions (optional, e.g. "/graphql/ws"). + SubscriptionPath string // Title is the page title. Title string // DefaultQuery is the initial query to display. DefaultQuery string + // AdminAuth enables the admin authentication bar (API key + DID inputs). + AdminAuth bool } // HandleGraphiQL creates an HTTP handler that serves the GraphiQL IDE. @@ -71,22 +74,98 @@ func generateGraphiQLHTML(cfg GraphiQLConfig) string { ` } - // Build fetcher config - fetcherConfig := `{ - url: '` + cfg.Endpoint + `', - headers: { - 'Content-Type': 'application/json', - }, - }` + // Build subscription URL JavaScript snippet. + // Uses window.location to derive the correct WebSocket URL at runtime, + // so the page works regardless of which domain it's accessed through. + subscriptionJS := "" + if cfg.SubscriptionPath != "" { + subscriptionJS = ` + const wsProto = location.protocol === 'https:' ? 'wss:' : 'ws:'; + fetcherOpts.subscriptionUrl = wsProto + '//' + location.host + '` + cfg.SubscriptionPath + `';` + } - if cfg.SubscriptionEndpoint != "" { - fetcherConfig = `{ - url: '` + cfg.Endpoint + `', - headers: { - 'Content-Type': 'application/json', - }, - subscriptionUrl: '` + cfg.SubscriptionEndpoint + `', + // Admin auth bar: CSS, HTML, and JS for the credential inputs. + adminAuthCSS := "" + adminAuthHTML := "" + adminAuthJS := "" + graphiqlHeight := "100vh" + + if cfg.AdminAuth { + graphiqlHeight = "calc(100vh - 44px)" + adminAuthCSS = ` + #admin-auth { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 12px; + background: #1e1e1e; + border-bottom: 1px solid #333; + font-family: system-ui, sans-serif; + font-size: 13px; + color: #ccc; + } + #admin-auth label { white-space: nowrap; } + #admin-auth input { + padding: 3px 6px; + border: 1px solid #555; + border-radius: 3px; + background: #2a2a2a; + color: #eee; + font-size: 13px; + font-family: monospace; + } + #admin-auth input[type="password"] { width: 220px; } + #admin-auth input[type="text"] { width: 280px; } + #admin-auth .status { + margin-left: auto; + font-size: 12px; + opacity: 0.7; }` + adminAuthHTML = ` +
+ + + + + +
` + adminAuthJS = ` + // Persist credentials in localStorage + const KEY_API = 'hypergoat_admin_api_key'; + const KEY_DID = 'hypergoat_admin_did'; + const apiKeyInput = document.getElementById('admin-api-key'); + const didInput = document.getElementById('admin-did'); + const authStatus = document.getElementById('auth-status'); + + apiKeyInput.value = localStorage.getItem(KEY_API) || ''; + didInput.value = localStorage.getItem(KEY_DID) || ''; + + apiKeyInput.addEventListener('input', () => localStorage.setItem(KEY_API, apiKeyInput.value)); + didInput.addEventListener('input', () => localStorage.setItem(KEY_DID, didInput.value)); + + function getAdminHeaders() { + const headers = { 'Content-Type': 'application/json' }; + const apiKey = apiKeyInput.value.trim(); + const did = didInput.value.trim(); + if (apiKey) headers['Authorization'] = 'Bearer ' + apiKey; + if (did) headers['X-User-DID'] = did; + return headers; + } + + function updateStatus() { + const apiKey = apiKeyInput.value.trim(); + const did = didInput.value.trim(); + if (apiKey && did) { + authStatus.textContent = 'Authenticated'; + authStatus.style.color = '#4caf50'; + } else { + authStatus.textContent = apiKey || did ? 'Incomplete' : 'Not authenticated'; + authStatus.style.color = apiKey || did ? '#ff9800' : '#999'; + } + } + apiKeyInput.addEventListener('input', updateStatus); + didInput.addEventListener('input', updateStatus); + updateStatus();` } return ` @@ -103,19 +182,29 @@ func generateGraphiQLHTML(cfg GraphiQLConfig) string { overflow: hidden; } #graphiql { - height: 100vh; - } + height: ` + graphiqlHeight + `; + }` + adminAuthCSS + ` - +` + adminAuthHTML + `
Loading...
-