From 5769c59f8b340649f078d6dfa8bcc13fbce4de47 Mon Sep 17 00:00:00 2001 From: Qiao Han Date: Wed, 17 Sep 2025 10:03:52 +0800 Subject: [PATCH 1/3] feat: support publishable and secret key locally --- internal/start/start.go | 9 ++++ internal/start/templates/kong.yml | 71 ++++++++++++++++++++++++++++++- internal/status/status.go | 28 ++++++++++-- pkg/config/apikeys.go | 7 +++ pkg/config/auth.go | 2 + 5 files changed, 112 insertions(+), 5 deletions(-) diff --git a/internal/start/start.go b/internal/start/start.go index 13625e0d4..28161aede 100644 --- a/internal/start/start.go +++ b/internal/start/start.go @@ -85,6 +85,7 @@ type kongConfig struct { PoolerId string ApiHost string ApiPort uint16 + BearerToken string } var ( @@ -345,6 +346,14 @@ EOF PoolerId: utils.PoolerId, ApiHost: utils.Config.Hostname, ApiPort: utils.Config.Api.Port, + BearerToken: fmt.Sprintf( + // Pass down apikey as Authorization header for backwards compatibility with legacy JWT + `$((function() return (headers.apikey == '%s' and 'Bearer %s') or (headers.apikey == '%s' and 'Bearer %s') or headers.apikey end)())`, + utils.Config.Auth.SecretKey.Value, + utils.Config.Auth.ServiceRoleKey.Value, + utils.Config.Auth.PublishableKey.Value, + utils.Config.Auth.AnonKey.Value, + ), }); err != nil { return errors.Errorf("failed to exec template: %w", err) } diff --git a/internal/start/templates/kong.yml b/internal/start/templates/kong.yml index 71f045344..fc9525f8f 100644 --- a/internal/start/templates/kong.yml +++ b/internal/start/templates/kong.yml @@ -10,6 +10,11 @@ services: - /auth/v1/verify plugins: - name: cors + - name: request-transformer + config: + add: + headers: + - "Authorization: {{ .BearerToken }}" - name: auth-v1-open-callback _comment: "GoTrue: /auth/v1/callback* -> http://auth:9999/callback*" url: http://{{ .GotrueId }}:9999/callback @@ -20,6 +25,11 @@ services: - /auth/v1/callback plugins: - name: cors + - name: request-transformer + config: + add: + headers: + - "Authorization: {{ .BearerToken }}" - name: auth-v1-open-authorize _comment: "GoTrue: /auth/v1/authorize* -> http://auth:9999/authorize*" url: http://{{ .GotrueId }}:9999/authorize @@ -30,6 +40,11 @@ services: - /auth/v1/authorize plugins: - name: cors + - name: request-transformer + config: + add: + headers: + - "Authorization: {{ .BearerToken }}" - name: auth-v1 _comment: "GoTrue: /auth/v1/* -> http://auth:9999/*" url: http://{{ .GotrueId }}:9999/ @@ -40,6 +55,11 @@ services: - /auth/v1/ plugins: - name: cors + - name: request-transformer + config: + add: + headers: + - "Authorization: {{ .BearerToken }}" - name: rest-v1 _comment: "PostgREST: /rest/v1/* -> http://rest:3000/*" url: http://{{ .RestId }}:3000/ @@ -50,6 +70,11 @@ services: - /rest/v1/ plugins: - name: cors + - name: request-transformer + config: + add: + headers: + - "Authorization: {{ .BearerToken }}" - name: rest-admin-v1 _comment: "PostgREST: /rest-admin/v1/* -> http://rest:3001/*" url: http://{{ .RestId }}:3001/ @@ -60,6 +85,11 @@ services: - /rest-admin/v1/ plugins: - name: cors + - name: request-transformer + config: + add: + headers: + - "Authorization: {{ .BearerToken }}" - name: graphql-v1 _comment: "PostgREST: /graphql/v1 -> http://rest:3000/rpc/graphql" url: http://{{ .RestId }}:3000/rpc/graphql @@ -75,6 +105,7 @@ services: add: headers: - "Content-Profile: graphql_public" + - "Authorization: {{ .BearerToken }}" - name: realtime-v1-ws _comment: "Realtime: /realtime/v1/* -> ws://realtime:4000/socket/websocket" url: http://{{ .RealtimeId }}:4000/socket @@ -86,6 +117,11 @@ services: - /realtime/v1/ plugins: - name: cors + - name: request-transformer + config: + add: + headers: + - "Authorization: {{ .BearerToken }}" - name: realtime-v1-longpoll _comment: "Realtime: /realtime/v1/* -> ws://realtime:4000/socket/longpoll" url: http://{{ .RealtimeId }}:4000/socket @@ -97,6 +133,11 @@ services: - /realtime/v1/ plugins: - name: cors + - name: request-transformer + config: + add: + headers: + - "Authorization: {{ .BearerToken }}" - name: realtime-v1-rest _comment: "Realtime: /realtime/v1/* -> http://realtime:4000/api/*" url: http://{{ .RealtimeId }}:4000/api @@ -108,7 +149,11 @@ services: - /realtime/v1/api plugins: - name: cors - + - name: request-transformer + config: + add: + headers: + - "Authorization: {{ .BearerToken }}" - name: storage-v1 _comment: "Storage: /storage/v1/* -> http://storage-api:5000/*" url: http://{{ .StorageId }}:5000/ @@ -119,6 +164,11 @@ services: - /storage/v1/ plugins: - name: cors + - name: request-transformer + config: + add: + headers: + - "Authorization: {{ .BearerToken }}" - name: pg-meta _comment: "pg-meta: /pg/* -> http://pg-meta:8080/*" url: http://{{ .PgmetaId }}:8080/ @@ -138,6 +188,13 @@ services: strip_path: true paths: - /functions/v1/ + plugins: + - name: cors + - name: request-transformer + config: + add: + headers: + - "Authorization: {{ .BearerToken }}" - name: analytics-v1 _comment: "Analytics: /analytics/v1/* -> http://logflare:4000/*" url: http://{{ .LogflareId }}:4000/ @@ -146,6 +203,13 @@ services: strip_path: true paths: - /analytics/v1/ + plugins: + - name: cors + - name: request-transformer + config: + add: + headers: + - "Authorization: {{ .BearerToken }}" - name: pooler-v2-ws _comment: "Pooler: /pooler/v2/* -> ws://pooler:4000/v2/*" url: http://{{ .PoolerId }}:4000/v2 @@ -157,3 +221,8 @@ services: - /pooler/v2/ plugins: - name: cors + - name: request-transformer + config: + add: + headers: + - "Authorization: {{ .BearerToken }}" diff --git a/internal/status/status.go b/internal/status/status.go index 1ef52dc1f..83b3ad865 100644 --- a/internal/status/status.go +++ b/internal/status/status.go @@ -11,6 +11,7 @@ import ( "net/url" "os" "reflect" + "strings" "sync" "time" @@ -30,9 +31,11 @@ type CustomName struct { DbURL string `env:"db.url,default=DB_URL"` StudioURL string `env:"studio.url,default=STUDIO_URL"` InbucketURL string `env:"inbucket.url,default=INBUCKET_URL"` - JWTSecret string `env:"auth.jwt_secret,default=JWT_SECRET"` - AnonKey string `env:"auth.anon_key,default=ANON_KEY"` - ServiceRoleKey string `env:"auth.service_role_key,default=SERVICE_ROLE_KEY"` + PublishableKey string `env:"auth.publishable_key,default=PUBLISHABLE_KEY"` + SecretKey string `env:"auth.secret_key,default=SECRET_KEY"` + JWTSecret string `env:"auth.jwt_secret,default=JWT_SECRET,deprecated"` + AnonKey string `env:"auth.anon_key,default=ANON_KEY,deprecated"` + ServiceRoleKey string `env:"auth.service_role_key,default=SERVICE_ROLE_KEY,deprecated"` StorageS3AccessKeyId string `env:"storage.s3_access_key_id,default=S3_PROTOCOL_ACCESS_KEY_ID"` StorageS3SecretAccessKey string `env:"storage.s3_secret_access_key,default=S3_PROTOCOL_ACCESS_KEY_SECRET"` StorageS3Region string `env:"storage.s3_region,default=S3_PROTOCOL_REGION"` @@ -50,6 +53,8 @@ func (c *CustomName) toValues(exclude ...string) map[string]string { values[c.StudioURL] = fmt.Sprintf("http://%s:%d", utils.Config.Hostname, utils.Config.Studio.Port) } if utils.Config.Auth.Enabled && !utils.SliceContains(exclude, utils.GotrueId) && !utils.SliceContains(exclude, utils.ShortContainerImageName(utils.Config.Auth.Image)) { + values[c.PublishableKey] = utils.Config.Auth.PublishableKey.Value + values[c.SecretKey] = utils.Config.Auth.SecretKey.Value values[c.JWTSecret] = utils.Config.Auth.JwtSecret.Value values[c.AnonKey] = utils.Config.Auth.AnonKey.Value values[c.ServiceRoleKey] = utils.Config.Auth.ServiceRoleKey.Value @@ -171,7 +176,7 @@ func checkHTTPHead(ctx context.Context, path string) error { healthOnce.Do(func() { healthClient = fetcher.NewServiceGateway( utils.Config.Api.ExternalUrl, - utils.Config.Auth.AnonKey.Value, + utils.Config.Auth.SecretKey.Value, fetcher.WithHTTPClient(NewKongClient()), fetcher.WithUserAgent("SupabaseCLI/"+utils.Version), ) @@ -198,6 +203,8 @@ func PrettyPrint(w io.Writer, exclude ...string) { DbURL: " " + utils.Aqua("DB URL"), StudioURL: " " + utils.Aqua("Studio URL"), InbucketURL: " " + utils.Aqua("Inbucket URL"), + PublishableKey: " " + utils.Aqua("Publishable key"), + SecretKey: " " + utils.Aqua("Secret key"), JWTSecret: " " + utils.Aqua("JWT secret"), AnonKey: " " + utils.Aqua("anon key"), ServiceRoleKey: "" + utils.Aqua("service_role key"), @@ -207,11 +214,24 @@ func PrettyPrint(w io.Writer, exclude ...string) { } values := names.toValues(exclude...) // Iterate through map in order of declared struct fields + t := reflect.TypeOf(names) val := reflect.ValueOf(names) for i := 0; i < val.NumField(); i++ { k := val.Field(i).String() + if tag := t.Field(i).Tag.Get("env"); isDeprecated(tag) { + continue + } if v, ok := values[k]; ok { fmt.Fprintf(w, "%s: %s\n", k, v) } } } + +func isDeprecated(tag string) bool { + for part := range strings.SplitSeq(tag, ",") { + if strings.EqualFold(part, "deprecated") { + return true + } + } + return false +} diff --git a/pkg/config/apikeys.go b/pkg/config/apikeys.go index d45bec560..9f69500eb 100644 --- a/pkg/config/apikeys.go +++ b/pkg/config/apikeys.go @@ -32,6 +32,13 @@ func (a *auth) generateAPIKeys() error { } a.ServiceRoleKey.Value = signed } + // Set hardcoded opaque keys + if len(a.SecretKey.Value) == 0 { + a.SecretKey.Value = "sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz" + } + if len(a.PublishableKey.Value) == 0 { + a.PublishableKey.Value = "sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH" + } return nil } diff --git a/pkg/config/auth.go b/pkg/config/auth.go index 932ddfa9f..fbc918a24 100644 --- a/pkg/config/auth.go +++ b/pkg/config/auth.go @@ -173,6 +173,8 @@ type ( Web3 web3 `toml:"web3"` // Custom secrets can be injected from .env file + PublishableKey Secret `toml:"publishable_key"` + SecretKey Secret `toml:"secret_key"` JwtSecret Secret `toml:"jwt_secret"` AnonKey Secret `toml:"anon_key"` ServiceRoleKey Secret `toml:"service_role_key"` From c513740e88d38193a20137a0e477b79a6f0be9ef Mon Sep 17 00:00:00 2001 From: Qiao Han Date: Wed, 17 Sep 2025 11:50:33 +0800 Subject: [PATCH 2/3] chore: deprecate inbucket url for mailpit --- internal/status/status.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/internal/status/status.go b/internal/status/status.go index 83b3ad865..cb648b2c3 100644 --- a/internal/status/status.go +++ b/internal/status/status.go @@ -30,7 +30,8 @@ type CustomName struct { StorageS3URL string `env:"api.storage_s3_url,default=STORAGE_S3_URL"` DbURL string `env:"db.url,default=DB_URL"` StudioURL string `env:"studio.url,default=STUDIO_URL"` - InbucketURL string `env:"inbucket.url,default=INBUCKET_URL"` + InbucketURL string `env:"inbucket.url,default=INBUCKET_URL,deprecated"` + MailpitURL string `env:"mailpit.url,default=MAILPIT_URL"` PublishableKey string `env:"auth.publishable_key,default=PUBLISHABLE_KEY"` SecretKey string `env:"auth.secret_key,default=SECRET_KEY"` JWTSecret string `env:"auth.jwt_secret,default=JWT_SECRET,deprecated"` @@ -60,6 +61,7 @@ func (c *CustomName) toValues(exclude ...string) map[string]string { values[c.ServiceRoleKey] = utils.Config.Auth.ServiceRoleKey.Value } if utils.Config.Inbucket.Enabled && !utils.SliceContains(exclude, utils.InbucketId) && !utils.SliceContains(exclude, utils.ShortContainerImageName(utils.Config.Inbucket.Image)) { + values[c.MailpitURL] = fmt.Sprintf("http://%s:%d", utils.Config.Hostname, utils.Config.Inbucket.Port) values[c.InbucketURL] = fmt.Sprintf("http://%s:%d", utils.Config.Hostname, utils.Config.Inbucket.Port) } if utils.Config.Storage.Enabled && !utils.SliceContains(exclude, utils.StorageId) && !utils.SliceContains(exclude, utils.ShortContainerImageName(utils.Config.Storage.Image)) { @@ -200,9 +202,10 @@ func PrettyPrint(w io.Writer, exclude ...string) { ApiURL: " " + utils.Aqua("API URL"), GraphqlURL: " " + utils.Aqua("GraphQL URL"), StorageS3URL: " " + utils.Aqua("S3 Storage URL"), - DbURL: " " + utils.Aqua("DB URL"), + DbURL: " " + utils.Aqua("Database URL"), StudioURL: " " + utils.Aqua("Studio URL"), InbucketURL: " " + utils.Aqua("Inbucket URL"), + MailpitURL: " " + utils.Aqua("Mailpit URL"), PublishableKey: " " + utils.Aqua("Publishable key"), SecretKey: " " + utils.Aqua("Secret key"), JWTSecret: " " + utils.Aqua("JWT secret"), From 6828278712b10650dafd52f7c9f882c49398a441 Mon Sep 17 00:00:00 2001 From: Qiao Han Date: Wed, 17 Sep 2025 16:40:11 +0800 Subject: [PATCH 3/3] chore: move apikey constants to one place --- internal/start/start.go | 3 ++- pkg/config/apikeys.go | 42 ++++++++++++++++++++++++++++++++++------- pkg/config/config.go | 28 --------------------------- 3 files changed, 37 insertions(+), 36 deletions(-) diff --git a/internal/start/start.go b/internal/start/start.go index 28161aede..15b3209f3 100644 --- a/internal/start/start.go +++ b/internal/start/start.go @@ -347,7 +347,8 @@ EOF ApiHost: utils.Config.Hostname, ApiPort: utils.Config.Api.Port, BearerToken: fmt.Sprintf( - // Pass down apikey as Authorization header for backwards compatibility with legacy JWT + // Pass down apikey as Authorization header for backwards compatibility with legacy JWT. + // If Authorization header is already set, Kong simply skips evaluating this Lua script. `$((function() return (headers.apikey == '%s' and 'Bearer %s') or (headers.apikey == '%s' and 'Bearer %s') or headers.apikey end)())`, utils.Config.Auth.SecretKey.Value, utils.Config.Auth.ServiceRoleKey.Value, diff --git a/pkg/config/apikeys.go b/pkg/config/apikeys.go index 9f69500eb..879ef9fea 100644 --- a/pkg/config/apikeys.go +++ b/pkg/config/apikeys.go @@ -14,8 +14,39 @@ import ( "github.com/google/uuid" ) +const ( + defaultJwtSecret = "super-secret-jwt-token-with-at-least-32-characters-long" + defaultJwtExpiry = 1983812996 + defaultPublishableKey = "sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH" + defaultSecretKey = "sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz" +) + +type CustomClaims struct { + // Overrides Issuer to maintain json order when marshalling + Issuer string `json:"iss,omitempty"` + Ref string `json:"ref,omitempty"` + Role string `json:"role"` + IsAnon bool `json:"is_anonymous,omitempty"` + jwt.RegisteredClaims +} + +func (c CustomClaims) NewToken() *jwt.Token { + if c.ExpiresAt == nil { + c.ExpiresAt = jwt.NewNumericDate(time.Unix(defaultJwtExpiry, 0)) + } + if len(c.Issuer) == 0 { + c.Issuer = "supabase-demo" + } + return jwt.NewWithClaims(jwt.SigningMethodHS256, c) +} + // generateAPIKeys generates JWT tokens using the appropriate signing method func (a *auth) generateAPIKeys() error { + if len(a.JwtSecret.Value) == 0 { + a.JwtSecret.Value = defaultJwtSecret + } else if len(a.JwtSecret.Value) < 16 { + return errors.Errorf("Invalid config for auth.jwt_secret. Must be at least 16 characters") + } // Generate anon key if not provided if len(a.AnonKey.Value) == 0 { signed, err := a.generateJWT("anon") @@ -33,11 +64,11 @@ func (a *auth) generateAPIKeys() error { a.ServiceRoleKey.Value = signed } // Set hardcoded opaque keys - if len(a.SecretKey.Value) == 0 { - a.SecretKey.Value = "sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz" - } if len(a.PublishableKey.Value) == 0 { - a.PublishableKey.Value = "sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH" + a.PublishableKey.Value = defaultPublishableKey + } + if len(a.SecretKey.Value) == 0 { + a.SecretKey.Value = defaultSecretKey } return nil } @@ -49,9 +80,6 @@ func (a auth) generateJWT(role string) (string, error) { return GenerateAsymmetricJWT(a.SigningKeys[0], claims) } // Fallback to generating symmetric keys - if len(a.JwtSecret.Value) < 16 { - return "", errors.Errorf("Invalid config for auth.jwt_secret. Must be at least 16 characters") - } signed, err := claims.NewToken().SignedString([]byte(a.JwtSecret.Value)) if err != nil { return "", errors.Errorf("failed to generate JWT: %w", err) diff --git a/pkg/config/config.go b/pkg/config/config.go index fb81c9cc4..89a5bb38e 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -27,7 +27,6 @@ import ( "github.com/docker/go-units" "github.com/go-errors/errors" "github.com/go-viper/mapstructure/v2" - "github.com/golang-jwt/jwt/v5" "github.com/joho/godotenv" "github.com/spf13/afero" "github.com/spf13/viper" @@ -125,30 +124,6 @@ func (g Glob) Files(fsys fs.FS) ([]string, error) { return result, errors.Join(allErrors...) } -type CustomClaims struct { - // Overrides Issuer to maintain json order when marshalling - Issuer string `json:"iss,omitempty"` - Ref string `json:"ref,omitempty"` - Role string `json:"role"` - IsAnon bool `json:"is_anonymous,omitempty"` - jwt.RegisteredClaims -} - -const ( - defaultJwtSecret = "super-secret-jwt-token-with-at-least-32-characters-long" - defaultJwtExpiry = 1983812996 -) - -func (c CustomClaims) NewToken() *jwt.Token { - if c.ExpiresAt == nil { - c.ExpiresAt = jwt.NewNumericDate(time.Unix(defaultJwtExpiry, 0)) - } - if len(c.Issuer) == 0 { - c.Issuer = "supabase-demo" - } - return jwt.NewWithClaims(jwt.SigningMethodHS256, c) -} - // We follow these rules when adding new config: // 1. Update init_config.toml (and init_config.test.toml) with the new key, default value, and comments to explain usage. // 2. Update config struct with new field and toml tag (spelled in snake_case). @@ -403,9 +378,6 @@ func NewConfig(editors ...ConfigEditor) config { TestOTP: map[string]string{}, }, External: map[string]provider{}, - JwtSecret: Secret{ - Value: defaultJwtSecret, - }, }, Inbucket: inbucket{ Image: Images.Inbucket,