Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 <key> header.
# Generate with: openssl rand -base64 32
# ADMIN_API_KEY=

# Domain DID for server identity (defaults to did:web:{HOST})
# DOMAIN_DID=

Expand Down
9 changes: 5 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <key> 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.
Expand Down
19 changes: 10 additions & 9 deletions cmd/hypergoat/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand All @@ -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:
{
Expand Down
27 changes: 13 additions & 14 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,18 +23,18 @@ 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
OAuthSigningKey string
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
Expand Down Expand Up @@ -75,18 +75,18 @@ 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", ""),
OAuthSigningKey: getEnv("OAUTH_SIGNING_KEY", ""),
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", ""),
Expand Down Expand Up @@ -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")
}
}

Expand Down
64 changes: 43 additions & 21 deletions internal/graphql/admin/handler.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package admin

import (
"crypto/subtle"
"encoding/json"
"log/slog"
"net/http"
Expand All @@ -14,17 +15,17 @@

// 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 <key> header.
func NewHandler(repos *Repositories, middleware *oauth.AuthMiddleware, configRepo *repositories.ConfigRepository, domainDID string, adminAPIKey string) (*Handler, error) {

Check failure on line 28 in internal/graphql/admin/handler.go

View workflow job for this annotation

GitHub Actions / lint

paramTypeCombine: func(repos *Repositories, middleware *oauth.AuthMiddleware, configRepo *repositories.ConfigRepository, domainDID string, adminAPIKey string) (*Handler, error) could be replaced with func(repos *Repositories, middleware *oauth.AuthMiddleware, configRepo *repositories.ConfigRepository, domainDID, adminAPIKey string) (*Handler, error) (gocritic)
resolver := NewResolver(repos, domainDID)

builder := NewSchemaBuilder(resolver)
Expand All @@ -34,11 +35,11 @@
}

return &Handler{
schema: schema,
resolver: resolver,
middleware: middleware,
configRepo: configRepo,
trustProxyHeaders: trustProxyHeaders,
schema: schema,
resolver: resolver,
middleware: middleware,
configRepo: configRepo,
adminAPIKey: adminAPIKey,
}, nil
}

Expand Down Expand Up @@ -71,14 +72,19 @@
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)
}
}
Expand Down Expand Up @@ -156,3 +162,19 @@
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
}
60 changes: 60 additions & 0 deletions internal/graphql/admin/handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package admin

import (
"context"
"net/http"
"testing"

"github.com/graphql-go/graphql"
Expand Down Expand Up @@ -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{
Expand Down
7 changes: 4 additions & 3 deletions internal/server/cors.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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, ", ")
Expand Down
Loading
Loading