Skip to content
Merged
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
104 changes: 90 additions & 14 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -586,12 +586,37 @@ ENTERPRISE_DB_NAME=postgres

# JWT configuration for GoTrue (supabase-auth) and PostgREST (supabase-rest).
# ENTERPRISE_JWT_SECRET is the HS256 signing secret. Minimum 32 characters.
# ENTERPRISE_JWT_ISSUER must match what GoTrue uses to issue tokens so that
# apps/edge-api/internal/auth/jwt_supabase.go validates them correctly.
# ENTERPRISE_AUTH_EXTERNAL_URL below must match what GoTrue stamps as the
# issuer, which apps/edge-api/internal/auth/jwt_supabase.go checks on every
# token. Both sides derive from it, so setting it once is enough.
ENTERPRISE_JWT_SECRET=<generate: openssl rand -base64 48>
ENTERPRISE_JWT_ISSUER=http://supabase-auth:9999
# The auth origin: one setting for API_EXTERNAL_URL, GoTrue's issuer and
# edge-api's expected issuer, so those three cannot drift apart. Replaces the
# former ENTERPRISE_JWT_ISSUER, which named only one of the three.
#
# The default (the in-stack gateway) is correct only while no browser talks to
# this stack. Set this to the public https auth origin before any deployment a
# browser reaches, and remember that changing it invalidates tokens carrying
# the old issuer, so it is a forced re-login.
ENTERPRISE_AUTH_EXTERNAL_URL=http://caddy-supabase/auth/v1
ENTERPRISE_JWT_EXP=3600

# Asymmetric signing material. NOT optional: with the symmetric secret alone,
# GoTrue signs HS256 and publishes an EMPTY JWKS (it excludes symmetric keys
# from that endpoint by design), and edge-api validates against the JWKS, so
# every token is rejected and the gateway does not come up.
#
# Generate both lines at once, using the secret above:
# ENTERPRISE_JWT_SECRET="<the value above>" python3 scripts/generate-enterprise-jwt-keys.py
#
# ENTERPRISE_JWT_KEYS is the private signing set and goes to GoTrue only.
# ENTERPRISE_JWT_VERIFY_KEYS is the verification set (EC public key plus the legacy
# symmetric key) and goes to PostgREST and Storage, so both keep accepting the
# existing anon and service_role keys while gaining the new ES256 user tokens.
# Both are secret material. Never commit either.
ENTERPRISE_JWT_KEYS=<generate: scripts/generate-enterprise-jwt-keys.py>
ENTERPRISE_JWT_VERIFY_KEYS=<generate: scripts/generate-enterprise-jwt-keys.py>

# Supabase API keys derived from ENTERPRISE_JWT_SECRET (see helper above).
ENTERPRISE_ANON_KEY=<sign anon JWT with ENTERPRISE_JWT_SECRET>
ENTERPRISE_SERVICE_ROLE_KEY=<sign service_role JWT with ENTERPRISE_JWT_SECRET>
Expand Down Expand Up @@ -627,6 +652,31 @@ ENTERPRISE_RATE_LIMIT_TOKEN_REFRESH=30
# Minimum password length. GoTrue default is 6; OSFI B-10 and PIPEDA require
# at least 12 characters for regulated workloads.
ENTERPRISE_PASSWORD_MIN_LENGTH=12
# Header GoTrue keys its rate limits on. Without it none of the four limits
# above is ever consulted: GoTrue's limiter returns immediately when the header
# name is empty, so they read as configured while doing nothing.
#
# Leave the value alone unless the gateway changes with it. caddy-supabase
# rewrites X-Forwarded-For specifically, so naming a different header here
# keys GoTrue's limits on whatever the caller sends. The guard test compares
# the two and fails if they drift.
ENTERPRISE_RATE_LIMIT_HEADER=X-Forwarded-For

# ── Enterprise bring-up escape hatch, and two OAuth toggles ────────────────
#
# ENTERPRISE_CUSTOM_ACCESS_TOKEN_HOOK_ENABLED: leave true. It is the source of
# the tenant_id, tenants and role claims that every authorisation check reads.
# Set false ONLY to stand a stack up before its schema is loaded, because
# GoTrue fails every token issuance when the hook function does not exist yet.
# A stack serving traffic with it false issues tenant-less tokens; edge-api
# refuses those (401, "missing principal claims"), so the cost is an outage
# rather than an isolation failure, but it is still not a setting to leave off.
ENTERPRISE_CUSTOM_ACCESS_TOKEN_HOOK_ENABLED=true
# OAuth 2.1 authorization server. Open WebUI signs in through it, so turning it
# off removes chat login. The consent path is a route on the web-console origin
# (GOTRUE_SITE_URL), where the consent page lives; GoTrue has no built-in one.
ENTERPRISE_OAUTH_SERVER_ENABLED=true
ENTERPRISE_OAUTH_CONSENT_PATH=/oauth/consent

# ── EnterpriseEdge SSO: SAML 2.0 and OIDC providers (issue #237) ──────────
# These variables are ONLY needed when running --profile enterprise with SSO.
Expand Down Expand Up @@ -666,25 +716,51 @@ ENTERPRISE_SSO_MICROSOFT_TENANT_URL=https://login.microsoftonline.com/<tenant-id
#
# Uncomment and set these values for the enterprise profile:
#
# SUPABASE_URL must point at GoTrue (not PostgREST): control-plane calls
# GET /auth/v1/user on this URL. PostgREST cannot serve auth/v1 routes.
# SUPABASE_URL=http://supabase-auth:9999
# SUPABASE_URL must point at the caddy-supabase GATEWAY, never at GoTrue or
# PostgREST directly. Every consumer appends hosted-style prefixes to this one
# base (control-plane calls /auth/v1/user, @supabase/ssr appends /auth/v1 and
# /rest/v1, the seeding scripts derive both), and standalone GoTrue serves
# /user with no prefix while PostgREST is a different host entirely. The
# gateway is what makes one base URL correct for all of them.
# SUPABASE_URL=http://caddy-supabase
# SUPABASE_ANON_KEY=<ENTERPRISE_ANON_KEY>
# SUPABASE_SERVICE_ROLE_KEY=<ENTERPRISE_SERVICE_ROLE_KEY>
# SUPABASE_DB_URL=postgres://postgres:<ENTERPRISE_DB_PASSWORD>@supabase-db:5432/postgres
# SUPABASE_JWT_ISSUER=http://supabase-auth:9999
#
# JWKS NOTE: edge-api/cmd/server/main.go rejects http:// JWKS URLs as insecure.
# For a production enterprise box, terminate TLS in Caddy and set:
# SUPABASE_JWKS_URL=https://<your-domain>/auth/v1/.well-known/jwks.json
# For a LAN-only or air-gapped dev box where TLS is not available, set
# SUPABASE_JWKS_URL to the internal http URL only after confirming the edge-api
# HTTPS guard is relaxed in config or the service is behind an internal TLS proxy.
# SUPABASE_JWKS_URL=http://supabase-auth:9999/.well-known/jwks.json
# JWKS NOTE: edge-api/cmd/server/main.go rejects http:// JWKS URLs as insecure,
# and that guard is not relaxed for self-hosting. An attacker on the path
# between edge-api and an http JWKS can swap the key set and mint tokens this
# gateway would accept, so there is no LAN-only or air-gapped exception.
#
# The enterprise profile answers this with the in-stack caddy-supabase gateway,
# which terminates TLS using Caddy's local certificate authority. Both
# variables below already default to that, so nothing needs to be set here for
# the enterprise profile to work:
# SUPABASE_JWKS_URL=https://caddy-supabase/auth/v1/.well-known/jwks.json
# SUPABASE_JWKS_CA_FILE=/etc/hive/supabase-ca/caddy/pki/authorities/local/root.crt
# SUPABASE_JWKS_CA_FILE names an EXTRA authority to trust for that one fetch.
# It does not disable verification: the chain and the hostname are still
# checked, and an unreadable or certificate-free file is a boot failure.
#
# Standalone GoTrue serves its routes at the root, with no /auth/v1 prefix.
# The gateway restores the hosted prefixes, so the URL above keeps the shape
# every consumer already expects.
#
# SUPABASE_DOMAIN is the browser-facing hostname for the gateway, served over
# plain HTTP for an external TLS terminator (Cloudflare Tunnel on the demo
# box) to front. Only needed once browsers talk to the self-hosted stack.
#
# That public site listens on port 8080, and the in-network sites listen on 80
# and 443. Publish or tunnel 8080 ONLY. The public listener serves /auth/v1
# alone and refuses the admin routes; ports 80 and 443 carry the full internal
# route set including /rest/v1 and /storage/v1, and exposing either of them
# undoes the split entirely.
# SUPABASE_DOMAIN=supabase-hive.example.com
#
# Storage: Supabase Storage API with local filesystem backend (no external S3).
# The S3-compatible endpoint is at /storage/v1/s3 (matches hosted Supabase path).
# S3_ENDPOINT=http://supabase-storage:5000/storage/v1/s3
# S3_ENDPOINT=http://caddy-supabase/storage/v1/s3
# S3_ACCESS_KEY=<ENTERPRISE_SERVICE_ROLE_KEY>
# S3_SECRET_KEY=<ENTERPRISE_SERVICE_ROLE_KEY>
# S3_REGION=local
Expand Down
3 changes: 3 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,6 @@ test-scripts:
python3 scripts/test_owui_ui_surfaces.py
python3 scripts/test_caddy_owui_blocklist.py
python3 scripts/test_owui_model_picker_filter.py
python3 scripts/generate-enterprise-jwt-keys.py --self-check
python3 scripts/register-owui-oauth-client.py --self-check
python3 scripts/test_caddy_supabase_routes.py
56 changes: 56 additions & 0 deletions apps/edge-api/cmd/server/jwt_env_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package main

import (
"testing"
)

// The https-only rule on SUPABASE_JWKS_URL is the reason the self-hosted
// profile needs a TLS front for GoTrue at all. It is load bearing: an http
// JWKS URL lets anything on the path swap the key set and mint tokens this
// service would accept. These tests exist so a future "just make enterprise
// work" change cannot quietly relax it.

func TestLoadJWTAuthEnv_RejectsPlainHTTPJWKS(t *testing.T) {
t.Setenv("SUPABASE_JWT_ISSUER", "http://supabase-auth:9999")
t.Setenv("SUPABASE_JWT_AUDIENCE", "authenticated")
t.Setenv("SUPABASE_JWKS_URL", "http://supabase-auth:9999/.well-known/jwks.json")

if _, err := loadJWTAuthEnv(); err == nil {
t.Fatal("expected a plain http JWKS URL to be rejected")
}
}

func TestLoadJWTAuthEnv_CAFileIsOptionalAndCarried(t *testing.T) {
t.Setenv("SUPABASE_JWT_ISSUER", "https://auth.example/auth/v1")
t.Setenv("SUPABASE_JWT_AUDIENCE", "authenticated")
t.Setenv("SUPABASE_JWKS_URL", "https://auth.example/auth/v1/.well-known/jwks.json")
t.Setenv("SUPABASE_JWKS_CA_FILE", "")

cfg, err := loadJWTAuthEnv()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if cfg.CAFile != "" {
t.Fatalf("expected no CA file, got %q", cfg.CAFile)
}

t.Setenv("SUPABASE_JWKS_CA_FILE", " /etc/hive/jwks-ca.pem ")
cfg, err = loadJWTAuthEnv()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if cfg.CAFile != "/etc/hive/jwks-ca.pem" {
t.Fatalf("expected the trimmed CA path, got %q", cfg.CAFile)
}
}

func TestLoadJWTAuthEnv_CAFileDoesNotExcuseHTTP(t *testing.T) {
t.Setenv("SUPABASE_JWT_ISSUER", "http://supabase-auth:9999")
t.Setenv("SUPABASE_JWT_AUDIENCE", "authenticated")
t.Setenv("SUPABASE_JWKS_URL", "http://supabase-auth:9999/.well-known/jwks.json")
t.Setenv("SUPABASE_JWKS_CA_FILE", "/etc/hive/jwks-ca.pem")

if _, err := loadJWTAuthEnv(); err == nil {
t.Fatal("a CA file must not make a plain http JWKS URL acceptable")
}
}
17 changes: 16 additions & 1 deletion apps/edge-api/cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,14 @@ type jwtAuthEnv struct {
Issuer string
Audience string
JWKSURL string
// CAFile is optional and names the PEM certificate authority to trust
// for the JWKS fetch. When set it REPLACES the system roots for that
// one fetch, so it must not be set on a JWKS host whose certificate is
// publicly trusted: that would break the fetch rather than harden it.
// The self-hosted (enterprise) profile serves its JWKS through an
// in-stack TLS terminator using a private authority, which is the case
// this exists for.
CAFile string
}

type storageConfig struct {
Expand Down Expand Up @@ -486,6 +494,7 @@ func main() {
Issuer: jwtCfg.Issuer,
JWKSURL: jwtCfg.JWKSURL,
JWTAudience: jwtCfg.Audience,
CAFile: jwtCfg.CAFile,
})
if err != nil {
log.Fatalf("failed to initialize Supabase JWT validator: %v", err)
Expand Down Expand Up @@ -989,7 +998,13 @@ func loadJWTAuthEnv() (jwtAuthEnv, error) {
return jwtAuthEnv{}, fmt.Errorf("SUPABASE_JWKS_URL must be https (got %q)", jwksURL)
}

return jwtAuthEnv{Issuer: issuer, Audience: audience, JWKSURL: jwksURL}, nil
// Optional. The https requirement above still holds, and chain and
// hostname verification still happen: this narrows WHICH authority is
// acceptable for that one fetch, replacing the system roots rather
// than extending them. It never turns verification off.
caFile := strings.TrimSpace(os.Getenv("SUPABASE_JWKS_CA_FILE"))

return jwtAuthEnv{Issuer: issuer, Audience: audience, JWKSURL: jwksURL, CAFile: caFile}, nil
}

// jwtAuditLogger returns the audit hook handed to the JWT middleware. For
Expand Down
84 changes: 82 additions & 2 deletions apps/edge-api/internal/auth/jwt_supabase.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,13 @@ package auth

import (
"context"
"crypto/tls"
"crypto/x509"
"errors"
"fmt"
"log"
"net/http"
"os"
"strings"
"time"

Expand Down Expand Up @@ -37,6 +42,20 @@ type SupabaseJWTConfig struct {
// ClockSkew tolerates small clock drift between this process and the
// token issuer. Defaults to 30s when zero.
ClockSkew time.Duration
// CAFile optionally names a PEM file holding the certificate authority
// to trust when fetching JWKSURL. When set it REPLACES the system
// roots for that one fetch, so the named authority is the only one
// that can vouch for the JWKS host. It exists for the self-hosted
// (enterprise) deployment, where the JWKS is served by an in-stack TLS
// terminator on a compose service name, holding a private CA's
// certificate that no public authority could issue. Leave it empty on
// deployments whose JWKS host presents a publicly trusted certificate.
//
// This never weakens the https-only rule enforced at the caller: the
// transport still requires TLS and still verifies the chain and the
// hostname. It narrows which authority is acceptable, it does not skip
// verification.
CAFile string
}

// Claims holds the subset of token claims the edge-api consumes downstream.
Expand Down Expand Up @@ -76,8 +95,24 @@ func NewSupabaseJWTValidator(ctx context.Context, cfg SupabaseJWTConfig) (*Supab
if cfg.ClockSkew == 0 {
cfg.ClockSkew = 30 * time.Second
}
cache := jwk.NewCache(ctx)
if err := cache.Register(cfg.JWKSURL, jwk.WithRefreshInterval(cfg.JWKSTTL)); err != nil {
// Without an error sink the refresh loop swallows every failure: httprc
// keeps serving the last good key set and Cache.Get returns it with no
// error, so a JWKS that stopped being fetchable stays trusted until the
// process restarts, silently. Recreating caddy-supabase's volume does
// exactly that, since Caddy then mints a fresh authority that the
// boot-time pool does not know. Trust cannot widen this way, because the
// pool is fixed at boot, so it is fail-stale rather than fail-open. It
// should still be visible rather than silent.
cache := jwk.NewCache(ctx, jwk.WithErrSink(jwksRefreshLogger{url: cfg.JWKSURL}))
Comment thread
sakibsadmanshajib marked this conversation as resolved.
registerOpts := []jwk.RegisterOption{jwk.WithRefreshInterval(cfg.JWKSTTL)}
if cfg.CAFile != "" {
client, err := httpClientTrusting(cfg.CAFile)
if err != nil {
return nil, err
}
registerOpts = append(registerOpts, jwk.WithHTTPClient(client))
}
if err := cache.Register(cfg.JWKSURL, registerOpts...); err != nil {
return nil, fmt.Errorf("auth: jwks register: %w", err)
}
if _, err := cache.Refresh(ctx, cfg.JWKSURL); err != nil {
Expand All @@ -86,6 +121,51 @@ func NewSupabaseJWTValidator(ctx context.Context, cfg SupabaseJWTConfig) (*Supab
return &SupabaseJWTValidator{cfg: cfg, cache: cache}, nil
}

// jwksRefreshLogger reports background JWKS refresh failures. The refresh loop
// requires a sink that does not block, so this only logs.
type jwksRefreshLogger struct{ url string }

func (l jwksRefreshLogger) Error(err error) {
log.Printf("auth.jwks.refresh_failed url=%s err=%v (still serving the last good key set)", l.url, err)
}

// httpClientTrusting returns an HTTP client that trusts exactly one
// certificate authority: whatever the PEM file at path holds. The system
// roots are deliberately NOT included.
//
// Naming a CA file is a statement that the JWKS is served by a specific,
// operator-controlled authority, which for this deployment is an in-stack TLS
// terminator on a compose service name that no public authority could ever
// issue for. Keeping the public roots in the pool alongside it would leave
// every public CA able to vouch for that fetch for no benefit, so the file
// replaces the trust set rather than extending it. A deployment whose JWKS
// host presents a publicly trusted certificate simply leaves the variable
// unset and gets the system pool, which is the default path.
//
// An unreadable file, or one carrying no certificate, is fatal rather than a
// silent fall back to the system pool: a deployment that asked for a private
// CA and quietly did not get one would fail later, at the first token, with a
// far worse error.
func httpClientTrusting(path string) (*http.Client, error) {
pemBytes, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("auth: read jwks ca file: %w", err)
}
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM(pemBytes) {
return nil, fmt.Errorf("auth: jwks ca file %q holds no certificate", path)
}
return &http.Client{
Timeout: 15 * time.Second,
Transport: &http.Transport{
TLSClientConfig: &tls.Config{
RootCAs: pool,
MinVersion: tls.VersionTLS12,
},
},
}, nil
}

// Parse validates the token signature, issuer, audience, and expiration,
// then extracts edge-api claims into a Claims struct.
func (v *SupabaseJWTValidator) Parse(ctx context.Context, raw string) (Claims, error) {
Expand Down
Loading
Loading