diff --git a/docker/.env.example b/docker/.env.example index 5845ba13273..2bcf16edbd0 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -15,11 +15,31 @@ # Postgres POSTGRES_PASSWORD=your-super-secret-and-long-postgres-password -# Symmetric encryption key and JWT API keys +# Legacy symmetric HS256 key JWT_SECRET=your-super-secret-jwt-token-with-at-least-32-characters-long +# Legacy API keys (HS256-signed JWTs) ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJhbm9uIiwKICAgICJpc3MiOiAic3VwYWJhc2UtZGVtbyIsCiAgICAiaWF0IjogMTY0MTc2OTIwMCwKICAgICJleHAiOiAxNzk5NTM1NjAwCn0.dc_X5iR_VP_qT0zsiyj_I_OZ2T9FtRU2BBNWN8Bu4GE SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJzZXJ2aWNlX3JvbGUiLAogICAgImlzcyI6ICJzdXBhYmFzZS1kZW1vIiwKICAgICJpYXQiOiAxNjQxNzY5MjAwLAogICAgImV4cCI6IDE3OTk1MzU2MDAKfQ.DaYlNEoUrrEn2Ig7tqibS-PHK5vgusbcbo7X36XVt4Q +# Asymmetric key pair (ES256) and opaque API keys +# +# Documentation: +# https://supabase.com/docs/guides/self-hosting/self-hosted-auth-keys +# +# To generate: +# sh ./utils/add-new-auth-keys.sh +# +# Opaque API key for client-side use (anon role). +SUPABASE_PUBLISHABLE_KEY= +# Opaque API key for server-side use (service_role). Never expose in client code. +SUPABASE_SECRET_KEY= +# JSON array of signing JWKs (EC private + legacy symmetric). +# Used by Auth. +JWT_KEYS= +# JWKS for token verification (EC public + legacy symmetric). +# Used by PostgREST, Realtime, Storage to verify tokens. +JWT_JWKS= + # Access to Dashboard DASHBOARD_USERNAME=supabase DASHBOARD_PASSWORD=this_password_is_insecure_and_should_be_updated @@ -260,12 +280,19 @@ GOOGLE_PROJECT_NUMBER=GOOGLE_PROJECT_NUMBER ############ -# API Proxy - Configuration for the Kong API gateway +# API gateway ############ +# Kong configuration variables KONG_HTTP_PORT=8000 KONG_HTTPS_PORT=8443 +# Used internally by the API gateway - DO NOT use in any client or server code. +# Pre-signed ES256 JWT "API key" for anon role. +ANON_KEY_ASYMMETRIC= +# Pre-signed ES256 JWT "API key" for service_role. +SERVICE_ROLE_KEY_ASYMMETRIC= + ############ # imgproxy diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index e1132126811..82440321daf 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -27,6 +27,8 @@ services: depends_on: analytics: condition: service_healthy + kong: + condition: service_healthy environment: # Binds nestjs listener to both IPv4 and IPv6 network interfaces HOSTNAME: "::" @@ -70,35 +72,45 @@ services: kong: container_name: supabase-kong - image: kong:2.8.1 + image: kong/kong:3.9.1 restart: unless-stopped + networks: + default: + aliases: + - api-gw + healthcheck: + test: ["CMD", "kong", "health"] + interval: 5s + timeout: 5s + retries: 5 ports: - ${KONG_HTTP_PORT}:8000/tcp - ${KONG_HTTPS_PORT}:8443/tcp volumes: # https://github.com/supabase/supabase/issues/12661 - ./volumes/api/kong.yml:/home/kong/temp.yml:ro,z + - ./volumes/api/kong-entrypoint.sh:/home/kong/kong-entrypoint.sh:ro,z #- ./volumes/api/server.crt:/home/kong/server.crt:ro #- ./volumes/api/server.key:/home/kong/server.key:ro - depends_on: - analytics: - condition: service_healthy environment: KONG_DATABASE: "off" - KONG_DECLARATIVE_CONFIG: /home/kong/kong.yml + KONG_DECLARATIVE_CONFIG: /usr/local/kong/kong.yml # https://github.com/supabase/cli/issues/14 KONG_DNS_ORDER: LAST,A,CNAME - KONG_PLUGINS: request-transformer,cors,key-auth,acl,basic-auth,request-termination,ip-restriction + KONG_PLUGINS: request-transformer,cors,key-auth,acl,basic-auth,request-termination,ip-restriction,post-function KONG_NGINX_PROXY_PROXY_BUFFER_SIZE: 160k KONG_NGINX_PROXY_PROXY_BUFFERS: 64 160k #KONG_SSL_CERT: /home/kong/server.crt #KONG_SSL_CERT_KEY: /home/kong/server.key SUPABASE_ANON_KEY: ${ANON_KEY} SUPABASE_SERVICE_KEY: ${SERVICE_ROLE_KEY} + SUPABASE_PUBLISHABLE_KEY: ${SUPABASE_PUBLISHABLE_KEY:-} + SUPABASE_SECRET_KEY: ${SUPABASE_SECRET_KEY:-} + ANON_KEY_ASYMMETRIC: ${ANON_KEY_ASYMMETRIC:-} + SERVICE_ROLE_KEY_ASYMMETRIC: ${SERVICE_ROLE_KEY_ASYMMETRIC:-} DASHBOARD_USERNAME: ${DASHBOARD_USERNAME} DASHBOARD_PASSWORD: ${DASHBOARD_PASSWORD} - # https://unix.stackexchange.com/a/294837 - entrypoint: bash -c 'eval "echo \"$$(cat ~/temp.yml)\"" > ~/kong.yml && /docker-entrypoint.sh kong docker-start' + entrypoint: /home/kong/kong-entrypoint.sh auth: container_name: supabase-auth @@ -121,8 +133,6 @@ services: db: # Disable this if you are using an external Postgres database condition: service_healthy - analytics: - condition: service_healthy environment: GOTRUE_API_HOST: 0.0.0.0 GOTRUE_API_PORT: 9999 @@ -139,8 +149,13 @@ services: GOTRUE_JWT_AUD: authenticated GOTRUE_JWT_DEFAULT_GROUP_NAME: authenticated GOTRUE_JWT_EXP: ${JWT_EXPIRY} + + # Legacy symmetric HS256 key GOTRUE_JWT_SECRET: ${JWT_SECRET} + # JSON array of signing JWKs (EC private + legacy symmetric) + #GOTRUE_JWT_KEYS: ${JWT_KEYS:-[]} + GOTRUE_EXTERNAL_EMAIL_ENABLED: ${ENABLE_EMAIL_SIGNUP} GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED: ${ENABLE_ANONYMOUS_USERS} GOTRUE_MAILER_AUTOCONFIRM: ${ENABLE_EMAIL_AUTOCONFIRM} @@ -232,15 +247,14 @@ services: db: # Disable this if you are using an external Postgres database condition: service_healthy - analytics: - condition: service_healthy environment: PGRST_DB_URI: postgres://authenticator:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB} PGRST_DB_SCHEMAS: ${PGRST_DB_SCHEMAS} PGRST_DB_MAX_ROWS: ${PGRST_DB_MAX_ROWS:-1000} PGRST_DB_EXTRA_SEARCH_PATH: ${PGRST_DB_EXTRA_SEARCH_PATH:-public} PGRST_DB_ANON_ROLE: anon - PGRST_JWT_SECRET: ${JWT_SECRET} + # PostgREST accepts a plain-text symmetric secret, a single JWK, or a JWKS + PGRST_JWT_SECRET: ${JWT_JWKS:-${JWT_SECRET}} PGRST_DB_USE_LEGACY_GUCS: "false" PGRST_APP_SETTINGS_JWT_SECRET: ${JWT_SECRET} PGRST_APP_SETTINGS_JWT_EXP: ${JWT_EXPIRY} @@ -258,8 +272,6 @@ services: db: # Disable this if you are using an external Postgres database condition: service_healthy - analytics: - condition: service_healthy healthcheck: test: [ @@ -279,7 +291,13 @@ services: DB_NAME: ${POSTGRES_DB} DB_AFTER_CONNECT_QUERY: 'SET search_path TO _realtime' DB_ENC_KEY: supabaserealtime + + # Legacy symmetric HS256 key API_JWT_SECRET: ${JWT_SECRET} + + # JWKS for token verification (EC public + legacy symmetric) + #API_JWT_JWKS: ${JWT_JWKS:-{"keys":[]}} + SECRET_KEY_BASE: ${SECRET_KEY_BASE} ERL_AFLAGS: -proto_dist inet_tcp DNS_NODES: "''" @@ -319,7 +337,13 @@ services: ANON_KEY: ${ANON_KEY} SERVICE_KEY: ${SERVICE_ROLE_KEY} POSTGREST_URL: http://rest:3000 - PGRST_JWT_SECRET: ${JWT_SECRET} + + # Legacy symmetric HS256 key + AUTH_JWT_SECRET: ${JWT_SECRET} + + # JWKS for token verification (EC public + legacy symmetric) + #JWT_JWKS: ${JWT_JWKS:-{"keys":[]}} + DATABASE_URL: postgres://supabase_storage_admin:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB} REQUEST_ALLOW_X_FORWARDED_PATH: "true" FILE_SIZE_LIMIT: 52428800 @@ -375,8 +399,6 @@ services: db: # Disable this if you are using an external Postgres database condition: service_healthy - analytics: - condition: service_healthy environment: PG_META_PORT: 8080 PG_META_DB_HOST: ${POSTGRES_HOST} @@ -394,16 +416,21 @@ services: - ./volumes/functions:/home/deno/functions:Z - deno-cache:/root/.cache/deno depends_on: - analytics: + kong: condition: service_healthy environment: + # Legacy symmetric HS256 key JWT_SECRET: ${JWT_SECRET} SUPABASE_URL: http://kong:8000 SUPABASE_PUBLIC_URL: ${SUPABASE_PUBLIC_URL} + # Legacy API keys (HS256-signed JWTs) SUPABASE_ANON_KEY: ${ANON_KEY} SUPABASE_SERVICE_ROLE_KEY: ${SERVICE_ROLE_KEY} + # New opaque API keys + SUPABASE_PUBLISHABLE_KEYS: "{\"default\":\"${SUPABASE_PUBLISHABLE_KEY:-}\"}" + SUPABASE_SECRET_KEYS: "{\"default\":\"${SUPABASE_SECRET_KEY:-}\"}" SUPABASE_DB_URL: postgresql://postgres:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB} - # TODO: Allow configuring VERIFY_JWT per function. This PR might help: https://github.com/supabase/cli/pull/786 + # TODO: Allow configuring VERIFY_JWT per function. VERIFY_JWT: "${FUNCTIONS_VERIFY_JWT}" command: [ @@ -495,9 +522,6 @@ services: interval: 5s timeout: 5s retries: 10 - depends_on: - vector: - condition: service_healthy environment: POSTGRES_HOST: /var/run/postgresql PGPORT: ${POSTGRES_PORT} @@ -574,8 +598,6 @@ services: depends_on: db: condition: service_healthy - analytics: - condition: service_healthy environment: PORT: 4000 POSTGRES_PORT: ${POSTGRES_PORT} diff --git a/docker/utils/add-new-auth-keys.sh b/docker/utils/add-new-auth-keys.sh new file mode 100644 index 00000000000..298bfc70ca3 --- /dev/null +++ b/docker/utils/add-new-auth-keys.sh @@ -0,0 +1,188 @@ +#!/bin/sh +# +# Add asymmetric key pair and opaque API keys to a self-hosted Supabase installation. +# +# Reads JWT_SECRET from .env and generates: +# - EC P-256 key pair (JWT_KEYS, JWT_JWKS) +# - Opaque API keys (SUPABASE_PUBLISHABLE_KEY, SUPABASE_SECRET_KEY) +# - Internal: ES256 JWT API keys (ANON_KEY_ASYMMETRIC, SERVICE_ROLE_KEY_ASYMMETRIC) +# +# Usage: +# sh add-new-auth-keys.sh # Interactive: prints keys, prompts to update .env +# sh add-new-auth-keys.sh --update-env # Prints keys and writes them to .env +# sh add-new-auth-keys.sh | tee keys # Non-interactive: prints keys only +# +# Prerequisites: +# - .env file with JWT_SECRET set (run generate-keys.sh first) +# - openssl +# - node >= 16 +# + +set -e + +if ! command -v openssl >/dev/null 2>&1; then + echo "Error: openssl is required but not found." + exit 1 +fi + +if ! command -v node >/dev/null 2>&1; then + echo "Error: node (>= 16) is required but not found." + exit 1 +fi + +# Read JWT_SECRET from .env +if [ ! -f .env ]; then + echo "Error: .env file not found. Run generate-keys.sh first." + exit 1 +fi + +jwt_secret=$(grep '^JWT_SECRET=' .env | cut -d= -f2-) +if [ -z "$jwt_secret" ]; then + echo "Error: JWT_SECRET not found in .env. Run generate-keys.sh first." + exit 1 +fi + +tmpdir=$(mktemp -d) +trap 'rm -rf "$tmpdir"' EXIT + +# Generate EC P-256 private key +openssl ecparam -name prime256v1 -genkey -noout -out "$tmpdir/ec_private.pem" 2>/dev/null + +# Node.js does the crypto-heavy work: +# - PEM -> JWK conversion +# - JWKS construction (with symmetric key included) +# - ES256 JWT signing +# - Opaque API key generation with checksum +node -e ' +const crypto = require("crypto"); +const fs = require("fs"); + +const pem = fs.readFileSync(process.argv[1]); +const jwtSecret = process.argv[2]; + +// EC key -> JWK +const privateKey = crypto.createPrivateKey(pem); +const jwkPrivate = privateKey.export({ format: "jwk" }); + +const kid = crypto.randomUUID(); + +// Symmetric key as JWK (base64url-encoded) +const octKey = { + kty: "oct", + k: Buffer.from(jwtSecret).toString("base64url"), + alg: "HS256" +}; + +// JWKS with private key (for Auth to sign tokens) +const jwksKeypair = { keys: [ + { kty: "EC", kid, use: "sig", key_ops: ["sign", "verify"], alg: "ES256", ext: true, + crv: jwkPrivate.crv, x: jwkPrivate.x, y: jwkPrivate.y, d: jwkPrivate.d }, + octKey +]}; + +// JWKS with public key only (for PostgREST, Realtime, Storage to verify) +const jwksPublic = { keys: [ + { kty: "EC", kid, use: "sig", key_ops: ["verify"], alg: "ES256", ext: true, + crv: jwkPrivate.crv, x: jwkPrivate.x, y: jwkPrivate.y }, + octKey +]}; + +// Sign ES256 JWT +function signES256(payload) { + const header = { alg: "ES256", typ: "JWT", kid }; + const b64Header = Buffer.from(JSON.stringify(header)).toString("base64url"); + const b64Payload = Buffer.from(JSON.stringify(payload)).toString("base64url"); + const data = b64Header + "." + b64Payload; + const sig = crypto.sign("SHA256", Buffer.from(data), { + key: privateKey, + dsaEncoding: "ieee-p1363" + }).toString("base64url"); + return data + "." + sig; +} + +const iat = Math.floor(Date.now() / 1000); +const exp = iat + 5 * 365 * 24 * 3600; // 5 years + +const anonJwt = signES256({ role: "anon", iss: "supabase", iat, exp }); +const serviceJwt = signES256({ role: "service_role", iss: "supabase", iat, exp }); + +// Generate opaque API keys with checksum +const PROJECT_REF = "supabase-self-hosted"; + +function generateOpaqueKey(prefix) { + const random = crypto.randomBytes(17).toString("base64url").slice(0, 22); + const intermediate = prefix + random; + const checksum = crypto.createHash("sha256") + .update(PROJECT_REF + "|" + intermediate) + .digest("base64url") + .slice(0, 8); + return intermediate + "_" + checksum; +} + +const publishableKey = generateOpaqueKey("sb_publishable_"); +const secretKey = generateOpaqueKey("sb_secret_"); + +// Output as KEY=value lines for shell to parse +console.log("SUPABASE_PUBLISHABLE_KEY=" + publishableKey); +console.log("SUPABASE_SECRET_KEY=" + secretKey); +console.log("ANON_KEY_ASYMMETRIC=" + anonJwt); +console.log("SERVICE_ROLE_KEY_ASYMMETRIC=" + serviceJwt); +console.log("JWT_KEYS=" + JSON.stringify(jwksKeypair.keys)); +console.log("JWT_JWKS=" + JSON.stringify(jwksPublic)); +' "$tmpdir/ec_private.pem" "$jwt_secret" > "$tmpdir/output" + +# Read generated values +SUPABASE_PUBLISHABLE_KEY=$(grep '^SUPABASE_PUBLISHABLE_KEY=' "$tmpdir/output" | cut -d= -f2-) +SUPABASE_SECRET_KEY=$(grep '^SUPABASE_SECRET_KEY=' "$tmpdir/output" | cut -d= -f2-) +ANON_KEY_ASYMMETRIC=$(grep '^ANON_KEY_ASYMMETRIC=' "$tmpdir/output" | cut -d= -f2-) +SERVICE_ROLE_KEY_ASYMMETRIC=$(grep '^SERVICE_ROLE_KEY_ASYMMETRIC=' "$tmpdir/output" | cut -d= -f2-) +JWT_KEYS=$(grep '^JWT_KEYS=' "$tmpdir/output" | cut -d= -f2-) +JWT_JWKS=$(grep '^JWT_JWKS=' "$tmpdir/output" | cut -d= -f2-) + +echo "" +echo "SUPABASE_PUBLISHABLE_KEY=${SUPABASE_PUBLISHABLE_KEY}" +echo "SUPABASE_SECRET_KEY=${SUPABASE_SECRET_KEY}" +echo "" +echo "ANON_KEY_ASYMMETRIC=${ANON_KEY_ASYMMETRIC}" +echo "SERVICE_ROLE_KEY_ASYMMETRIC=${SERVICE_ROLE_KEY_ASYMMETRIC}" +echo "" +echo "JWT_KEYS=${JWT_KEYS}" +echo "" +echo "JWT_JWKS=${JWT_JWKS}" +echo "" +echo "To enable asymmetric key support, uncomment these lines in docker-compose.yml:" +echo "" +echo " Auth: GOTRUE_JWT_KEYS: \${JWT_KEYS:-[]}" +echo " Realtime: API_JWT_JWKS: \${JWT_JWKS:-{\"keys\":[]}}" +echo " Storage: JWT_JWKS: \${JWT_JWKS:-{\"keys\":[]}}" +echo "" + +if [ "$1" = "--update-env" ]; then + update_env=true +elif test -t 0; then + printf "Update .env file? (y/N) " + read -r REPLY + case "$REPLY" in + [Yy]) update_env=true ;; + *) update_env=false ;; + esac +else + echo "Running non-interactively. Pass --update-env to write to .env." + update_env=false +fi + +if [ "$update_env" != "true" ]; then + exit 0 +fi + +echo "Updating .env..." + +# Append new variables if they don't exist, or update them if they do +for var in SUPABASE_PUBLISHABLE_KEY SUPABASE_SECRET_KEY ANON_KEY_ASYMMETRIC SERVICE_ROLE_KEY_ASYMMETRIC JWT_KEYS JWT_JWKS; do + eval "val=\$$var" + if grep -q "^${var}=" .env; then + sed -i.old -e "s|^${var}=.*$|${var}=${val}|" .env + else + echo "${var}=${val}" >> .env + fi +done diff --git a/docker/utils/generate-keys.sh b/docker/utils/generate-keys.sh index d9a16395bf6..23b0b1a4d3e 100644 --- a/docker/utils/generate-keys.sh +++ b/docker/utils/generate-keys.sh @@ -1,5 +1,15 @@ #!/bin/sh # +# Generate secrets and legacy symmetric JWT API keys for self-hosted Supabase. +# +# Generates: JWT_SECRET, ANON_KEY, SERVICE_ROLE_KEY, and other secrets +# needed for a fresh installation. +# +# Usage: +# sh generate-keys.sh # Interactive: prints keys, prompts to update .env +# sh generate-keys.sh --update-env # Prints keys and writes them to .env +# sh generate-keys.sh | tee keys # Non-interactive: prints keys only +# # Portions of this code are derived from Inder Singh's setup.sh shell script. # Copyright 2025 Inder Singh. Licensed under Apache License 2.0. # Original source: https://github.com/singh-inder/supabase-automated-self-host/blob/main/setup.sh @@ -35,7 +45,7 @@ fi jwt_secret="$(gen_base64 30)" -# Used in get_token() +# Used in gen_token() header='{"alg":"HS256","typ":"JWT"}' iat=$(date +%s) exp=$((iat + 5 * 3600 * 24 * 365)) # 5 years @@ -87,21 +97,23 @@ echo "POSTGRES_PASSWORD=${postgres_password}" echo "DASHBOARD_PASSWORD=${dashboard_password}" echo "" -if ! test -t 0; then - echo "Running non-interactively. Skipping .env update." - exit 0 +if [ "$1" = "--update-env" ]; then + update_env=true +elif test -t 0; then + printf "Update .env file? (y/N) " + read -r REPLY + case "$REPLY" in + [Yy]) update_env=true ;; + *) update_env=false ;; + esac +else + echo "Running non-interactively. Pass --update-env to write to .env." + update_env=false fi -printf "Update .env file? (y/N) " -read -r REPLY -case "$REPLY" in - [Yy]) - ;; - *) - echo "Not updating .env" - exit 0 - ;; -esac +if [ "$update_env" != "true" ]; then + exit 0 +fi echo "Updating .env..." diff --git a/docker/utils/rotate-new-api-keys.sh b/docker/utils/rotate-new-api-keys.sh new file mode 100644 index 00000000000..79fba406bc0 --- /dev/null +++ b/docker/utils/rotate-new-api-keys.sh @@ -0,0 +1,90 @@ +#!/bin/sh +# +# Rotate opaque API keys for a self-hosted Supabase installation. +# +# Regenerates SUPABASE_PUBLISHABLE_KEY and SUPABASE_SECRET_KEY +# without touching the asymmetric key pair (JWKS) or JWT tokens. +# +# Usage: +# sh rotate-new-api-keys.sh # Interactive: prints keys, prompts to update .env +# sh rotate-new-api-keys.sh --update-env # Prints keys and writes them to .env +# sh rotate-new-api-keys.sh | tee keys # Non-interactive: prints keys only +# +# Prerequisites: +# - .env file (run generate-keys.sh and add-new-auth-keys.sh first) +# - node >= 16 +# + +set -e + +if ! command -v node >/dev/null 2>&1; then + echo "Error: node (>= 16) is required but not found." + exit 1 +fi + +if [ ! -f .env ]; then + echo "Error: .env file not found. Run generate-keys.sh first." + exit 1 +fi + +tmpdir=$(mktemp -d) +trap 'rm -rf "$tmpdir"' EXIT + +node -e ' +const crypto = require("crypto"); + +const PROJECT_REF = "supabase-self-hosted"; + +function generateOpaqueKey(prefix) { + const random = crypto.randomBytes(17).toString("base64url").slice(0, 22); + const intermediate = prefix + random; + const checksum = crypto.createHash("sha256") + .update(PROJECT_REF + "|" + intermediate) + .digest("base64url") + .slice(0, 8); + return intermediate + "_" + checksum; +} + +const publishableKey = generateOpaqueKey("sb_publishable_"); +const secretKey = generateOpaqueKey("sb_secret_"); + +console.log("SUPABASE_PUBLISHABLE_KEY=" + publishableKey); +console.log("SUPABASE_SECRET_KEY=" + secretKey); +' > "$tmpdir/output" + +SUPABASE_PUBLISHABLE_KEY=$(grep '^SUPABASE_PUBLISHABLE_KEY=' "$tmpdir/output" | cut -d= -f2-) +SUPABASE_SECRET_KEY=$(grep '^SUPABASE_SECRET_KEY=' "$tmpdir/output" | cut -d= -f2-) + +echo "" +echo "SUPABASE_PUBLISHABLE_KEY=${SUPABASE_PUBLISHABLE_KEY}" +echo "SUPABASE_SECRET_KEY=${SUPABASE_SECRET_KEY}" +echo "" + +if [ "$1" = "--update-env" ]; then + update_env=true +elif test -t 0; then + printf "Update .env file? (y/N) " + read -r REPLY + case "$REPLY" in + [Yy]) update_env=true ;; + *) update_env=false ;; + esac +else + echo "Running non-interactively. Pass --update-env to write to .env." + update_env=false +fi + +if [ "$update_env" != "true" ]; then + exit 0 +fi + +echo "Updating .env..." + +for var in SUPABASE_PUBLISHABLE_KEY SUPABASE_SECRET_KEY; do + eval "val=\$$var" + if grep -q "^${var}=" .env; then + sed -i.old -e "s|^${var}=.*$|${var}=${val}|" .env + else + echo "${var}=${val}" >> .env + fi +done diff --git a/docker/volumes/api/kong-entrypoint.sh b/docker/volumes/api/kong-entrypoint.sh new file mode 100755 index 00000000000..d5eee9332d9 --- /dev/null +++ b/docker/volumes/api/kong-entrypoint.sh @@ -0,0 +1,49 @@ +#!/bin/bash +# Custom entrypoint for Kong that builds Lua expressions for request-transformer +# and performs environment variable substitution in the declarative config. + +# Build Lua expressions for translating opaque API keys to asymmetric JWTs. +# When opaque keys are not configured (empty env vars), expressions fall through +# to legacy-only behavior - just passing apikey as-is. +# +# Full expression logic (when opaque keys are configured): +# 1. If Authorization header exists and is NOT an sb_ key -> pass through (user session JWT) +# 2. If apikey matches secret key -> set service_role asymmetric JWT internal "API key" +# 3. If apikey matches publishable key -> set anon asymmetric JWT internal "API key" +# 4. Fallback: pass apikey as-is (legacy HS256 JWT) + +if [ -n "$SUPABASE_SECRET_KEY" ] && [ -n "$SUPABASE_PUBLISHABLE_KEY" ]; then + # Opaque keys configured -> full translation expressions + export LUA_AUTH_EXPR="\$((headers.authorization ~= nil and headers.authorization:sub(1, 10) ~= 'Bearer sb_' and headers.authorization) or (headers.apikey == '$SUPABASE_SECRET_KEY' and 'Bearer $SERVICE_ROLE_KEY_ASYMMETRIC') or (headers.apikey == '$SUPABASE_PUBLISHABLE_KEY' and 'Bearer $ANON_KEY_ASYMMETRIC') or headers.apikey)" + + # Realtime WebSocket: reads from query_params.apikey (supabase-js sends apikey + # via query string), outputs to x-api-key header which Realtime checks first. + export LUA_RT_WS_EXPR="\$((query_params.apikey == '$SUPABASE_SECRET_KEY' and '$SERVICE_ROLE_KEY_ASYMMETRIC') or (query_params.apikey == '$SUPABASE_PUBLISHABLE_KEY' and '$ANON_KEY_ASYMMETRIC') or query_params.apikey)" +else + # Legacy API keys, not sb_ API keys -> pass apikey through unchanged + export LUA_AUTH_EXPR="\$((headers.authorization ~= nil and headers.authorization:sub(1, 10) ~= 'Bearer sb_' and headers.authorization) or headers.apikey)" + export LUA_RT_WS_EXPR="\$(query_params.apikey)" +fi + +# Substitute environment variables in the Kong declarative config. +# Uses awk instead of eval/echo to preserve YAML quoting (eval strips double +# quotes, breaking "Header: value" patterns that YAML parses as mappings). +awk '{ + result = "" + rest = $0 + while (match(rest, /\$[A-Za-z_][A-Za-z_0-9]*/)) { + varname = substr(rest, RSTART + 1, RLENGTH - 1) + if (varname in ENVIRON) { + result = result substr(rest, 1, RSTART - 1) ENVIRON[varname] + } else { + result = result substr(rest, 1, RSTART + RLENGTH - 1) + } + rest = substr(rest, RSTART + RLENGTH) + } + print result rest +}' /home/kong/temp.yml > "$KONG_DECLARATIVE_CONFIG" + +# Remove empty key-auth credentials (unconfigured opaque keys) +sed -i '/^[[:space:]]*- key:[[:space:]]*$/d' "$KONG_DECLARATIVE_CONFIG" + +exec /entrypoint.sh kong docker-start diff --git a/docker/volumes/api/kong.yml b/docker/volumes/api/kong.yml index ae69cec2824..ef830713234 100644 --- a/docker/volumes/api/kong.yml +++ b/docker/volumes/api/kong.yml @@ -9,9 +9,11 @@ consumers: - username: anon keyauth_credentials: - key: $SUPABASE_ANON_KEY + - key: $SUPABASE_PUBLISHABLE_KEY - username: service_role keyauth_credentials: - key: $SUPABASE_SERVICE_KEY + - key: $SUPABASE_SECRET_KEY ### ### Access Control List @@ -36,6 +38,7 @@ basicauth_credentials: services: ## Open Auth routes - name: auth-v1-open + _comment: 'Auth: /auth/v1/verify* -> http://auth:9999/verify*' url: http://auth:9999/verify routes: - name: auth-v1-open @@ -45,6 +48,7 @@ services: plugins: - name: cors - name: auth-v1-open-callback + _comment: 'Auth: /auth/v1/callback* -> http://auth:9999/callback*' url: http://auth:9999/callback routes: - name: auth-v1-open-callback @@ -54,6 +58,7 @@ services: plugins: - name: cors - name: auth-v1-open-authorize + _comment: 'Auth: /auth/v1/authorize* -> http://auth:9999/authorize*' url: http://auth:9999/authorize routes: - name: auth-v1-open-authorize @@ -62,10 +67,20 @@ services: - /auth/v1/authorize plugins: - name: cors + - name: auth-v1-open-jwks + _comment: 'Auth: /auth/v1/.well-known/jwks.json -> http://auth:9999/.well-known/jwks.json' + url: http://auth:9999/.well-known/jwks.json + routes: + - name: auth-v1-open-jwks + strip_path: true + paths: + - /auth/v1/.well-known/jwks.json + plugins: + - name: cors ## Secure Auth routes - name: auth-v1 - _comment: 'GoTrue: /auth/v1/* -> http://auth:9999/*' + _comment: 'Auth: /auth/v1/* -> http://auth:9999/*' url: http://auth:9999/ routes: - name: auth-v1-all @@ -77,6 +92,14 @@ services: - name: key-auth config: hide_credentials: false + - name: request-transformer + config: + add: + headers: + - "Authorization: $LUA_AUTH_EXPR" + replace: + headers: + - "Authorization: $LUA_AUTH_EXPR" - name: acl config: hide_groups_header: true @@ -84,7 +107,7 @@ services: - admin - anon - ## Secure REST routes + ## Secure PostgREST routes - name: rest-v1 _comment: 'PostgREST: /rest/v1/* -> http://rest:3000/*' url: http://rest:3000/ @@ -97,7 +120,15 @@ services: - name: cors - name: key-auth config: - hide_credentials: true + hide_credentials: false + - name: request-transformer + config: + add: + headers: + - "Authorization: $LUA_AUTH_EXPR" + replace: + headers: + - "Authorization: $LUA_AUTH_EXPR" - name: acl config: hide_groups_header: true @@ -118,12 +149,16 @@ services: - name: cors - name: key-auth config: - hide_credentials: true + hide_credentials: false - name: request-transformer config: add: headers: - - Content-Profile:graphql_public + - "Content-Profile: graphql_public" + - "Authorization: $LUA_AUTH_EXPR" + replace: + headers: + - "Authorization: $LUA_AUTH_EXPR" - name: acl config: hide_groups_header: true @@ -146,14 +181,23 @@ services: - name: key-auth config: hide_credentials: false + - name: request-transformer + config: + add: + headers: + - "x-api-key:$LUA_RT_WS_EXPR" + replace: + querystring: + - "apikey:$LUA_RT_WS_EXPR" - name: acl config: hide_groups_header: true allow: - admin - anon + - name: realtime-v1-rest - _comment: 'Realtime: /realtime/v1/* -> ws://realtime:4000/socket/*' + _comment: 'Realtime: /realtime/v1/api/* -> http://realtime:4000/api/*' url: http://realtime-dev.supabase-realtime:4000/api protocol: http routes: @@ -166,13 +210,30 @@ services: - name: key-auth config: hide_credentials: false + - name: request-transformer + config: + add: + headers: + - "Authorization: $LUA_AUTH_EXPR" + replace: + headers: + - "Authorization: $LUA_AUTH_EXPR" - name: acl config: hide_groups_header: true allow: - admin - anon - ## Storage routes: the storage server manages its own auth + + ## Storage API endpoint (with Authorization header transformation). + ## No key-auth — S3 protocol requests don't carry an apikey header. + ## + ## The request-transformer translates opaque API keys to asymmetric JWTs + ## and passes through existing Authorization headers (user JWTs, AWS SigV4). + ## When no Authorization or apikey header is present (S3 presigned URLs), + ## the Lua expression evaluates to nil which Kong renders as empty string. + ## The post-function strips this empty header so Storage's S3 signature + ## verification falls through to query-parameter parsing. - name: storage-v1 _comment: 'Storage: /storage/v1/* -> http://storage:5000/*' url: http://storage:5000/ @@ -183,11 +244,28 @@ services: - /storage/v1/ plugins: - name: cors + - name: request-transformer + config: + add: + headers: + - "Authorization: $LUA_AUTH_EXPR" + replace: + headers: + - "Authorization: $LUA_AUTH_EXPR" + - name: post-function + config: + access: + - | + local auth = kong.request.get_header("authorization") + if auth == nil or auth == "" or auth:find("^%s*$") then + kong.service.request.clear_header("authorization") + end ## Edge Functions routes - name: functions-v1 _comment: 'Edge Functions: /functions/v1/* -> http://functions:9000/*' url: http://functions:9000/ + read_timeout: 150000 routes: - name: functions-v1-all strip_path: true @@ -196,6 +274,18 @@ services: plugins: - name: cors + ## OAuth 2.0 Authorization Server Metadata (RFC 8414) + - name: well-known-oauth + _comment: 'Auth: /.well-known/oauth-authorization-server -> http://auth:9999/.well-known/oauth-authorization-server' + url: http://auth:9999/.well-known/oauth-authorization-server + routes: + - name: well-known-oauth + strip_path: true + paths: + - /.well-known/oauth-authorization-server + plugins: + - name: cors + ## Analytics routes ## Not used - Studio and Vector talk directly to analytics via Docker networking. ## If external access is needed, add routes with key-auth matching Logflare's x-api-key auth.