From 21a0a5859dcc9e745ffb532e9bea0ae23aefef57 Mon Sep 17 00:00:00 2001 From: Stojan Dimitrovski Date: Tue, 13 Aug 2024 11:54:50 +0200 Subject: [PATCH] feat: add third-party auth support for local dev (#2595) * feat: add third-party auth support for local dev * fix: handle timeout and cancellation when resolving jwk --------- Co-authored-by: Qiao Han --- internal/start/start.go | 9 +- pkg/config/config.go | 219 +++++++++++++++++++++++++++++++ pkg/config/templates/config.toml | 17 +++ 3 files changed, 244 insertions(+), 1 deletion(-) diff --git a/internal/start/start.go b/internal/start/start.go index aa82c081a..a37dc8ba7 100644 --- a/internal/start/start.go +++ b/internal/start/start.go @@ -166,6 +166,11 @@ func run(p utils.Program, ctx context.Context, fsys afero.Fs, excludedContainers excluded[name] = true } + jwks, err := utils.Config.Auth.ResolveJWKS(ctx) + if err != nil { + return err + } + // Start Postgres. w := utils.StatusWriter{Program: p} if dbConfig.Host == utils.DbId { @@ -737,6 +742,7 @@ EOF "DB_AFTER_CONNECT_QUERY=SET search_path TO _realtime", "DB_ENC_KEY=" + utils.Config.Realtime.EncryptionKey, "API_JWT_SECRET=" + utils.Config.Auth.JwtSecret, + fmt.Sprintf("API_JWT_JWKS=%s", jwks), "METRICS_JWT_SECRET=" + utils.Config.Auth.JwtSecret, "APP_NAME=realtime", "SECRET_KEY_BASE=" + utils.Config.Realtime.SecretKeyBase, @@ -787,7 +793,7 @@ EOF "PGRST_DB_EXTRA_SEARCH_PATH=" + strings.Join(utils.Config.Api.ExtraSearchPath, ","), fmt.Sprintf("PGRST_DB_MAX_ROWS=%d", utils.Config.Api.MaxRows), "PGRST_DB_ANON_ROLE=anon", - "PGRST_JWT_SECRET=" + utils.Config.Auth.JwtSecret, + fmt.Sprintf("PGRST_JWT_SECRET=%s", jwks), "PGRST_ADMIN_SERVER_PORT=3001", }, // PostgREST does not expose a shell for health check @@ -820,6 +826,7 @@ EOF "ANON_KEY=" + utils.Config.Auth.AnonKey, "SERVICE_KEY=" + utils.Config.Auth.ServiceRoleKey, "AUTH_JWT_SECRET=" + utils.Config.Auth.JwtSecret, + fmt.Sprintf("AUTH_JWT_JWKS=%s", jwks), fmt.Sprintf("DATABASE_URL=postgresql://supabase_storage_admin:%s@%s:%d/%s", dbConfig.Password, dbConfig.Host, dbConfig.Port, dbConfig.Database), fmt.Sprintf("FILE_SIZE_LIMIT=%v", utils.Config.Storage.FileSizeLimit), "STORAGE_BACKEND=file", diff --git a/pkg/config/config.go b/pkg/config/config.go index fb0a0d186..984b9a587 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -2,11 +2,15 @@ package config import ( "bytes" + "context" _ "embed" + "encoding/base64" + "encoding/json" "fmt" "io" "io/fs" "net" + "net/http" "net/url" "os" "path/filepath" @@ -23,6 +27,8 @@ import ( "github.com/joho/godotenv" "github.com/spf13/viper" "golang.org/x/mod/semver" + + "github.com/supabase/cli/pkg/fetcher" ) // Type for turning human-friendly bytes string ("5MB", "32kB") into an int64 during toml decoding. @@ -250,6 +256,34 @@ type ( JwtSecret string `toml:"-" mapstructure:"jwt_secret"` AnonKey string `toml:"-" mapstructure:"anon_key"` ServiceRoleKey string `toml:"-" mapstructure:"service_role_key"` + + ThirdParty thirdParty `toml:"third_party"` + } + + thirdParty struct { + Firebase tpaFirebase `toml:"firebase"` + Auth0 tpaAuth0 `toml:"auth0"` + Cognito tpaCognito `toml:"aws_cognito"` + } + + tpaFirebase struct { + Enabled bool `toml:"enabled"` + + ProjectID string `toml:"project_id"` + } + + tpaAuth0 struct { + Enabled bool `toml:"enabled"` + + Tenant string `toml:"tenant"` + TenantRegion string `toml:"tenant_region"` + } + + tpaCognito struct { + Enabled bool `toml:"enabled"` + + UserPoolID string `toml:"user_pool_id"` + UserPoolRegion string `toml:"user_pool_region"` } email struct { @@ -855,6 +889,10 @@ func (c *config) Validate() error { c.Auth.External[ext] = provider } } + // Validate Third-Party Auth config + if err := c.Auth.ThirdParty.validate(); err != nil { + return err + } // Validate functions config if c.EdgeRuntime.Enabled { allowed := []RequestPolicy{PolicyPerWorker, PolicyOneshot} @@ -996,3 +1034,184 @@ func ValidateBucketName(name string) error { } return nil } + +func (f *tpaFirebase) issuerURL() string { + return fmt.Sprintf("https://securetoken.google.com/%s", f.ProjectID) +} + +func (f *tpaFirebase) validate() error { + if f.ProjectID == "" { + return errors.New("Invalid config: auth.third_party.firebase is enabled but without a project_id.") + } + + return nil +} + +func (a *tpaAuth0) issuerURL() string { + if a.TenantRegion != "" { + return fmt.Sprintf("https://%s.%s.auth0.com", a.Tenant, a.TenantRegion) + } + + return fmt.Sprintf("https://%s.auth0.com", a.Tenant) +} + +func (a *tpaAuth0) validate() error { + if a.Tenant == "" { + return errors.New("Invalid config: auth.third_party.auth0 is enabled but without a tenant.") + } + + return nil +} + +func (c *tpaCognito) issuerURL() string { + return fmt.Sprintf("https://cognito-idp.%s.amazonaws.com/%s", c.UserPoolRegion, c.UserPoolID) +} + +func (c *tpaCognito) validate() error { + if c.UserPoolID == "" { + return errors.New("Invalid config: auth.third_party.cognito is enabled but without a user_pool_id.") + } + + if c.UserPoolRegion == "" { + return errors.New("Invalid config: auth.third_party.cognito is enabled but without a user_pool_region.") + } + + return nil +} + +func (tpa *thirdParty) validate() error { + enabled := 0 + + if tpa.Firebase.Enabled { + enabled += 1 + + if err := tpa.Firebase.validate(); err != nil { + return err + } + } + + if tpa.Auth0.Enabled { + enabled += 1 + + if err := tpa.Auth0.validate(); err != nil { + return err + } + } + + if tpa.Cognito.Enabled { + enabled += 1 + + if err := tpa.Cognito.validate(); err != nil { + return err + } + } + + if enabled > 1 { + return errors.New("Invalid config: Only one third_party provider allowed to be enabled at a time.") + } + + return nil +} + +func (tpa *thirdParty) IssuerURL() string { + if tpa.Firebase.Enabled { + return tpa.Firebase.issuerURL() + } + + if tpa.Auth0.Enabled { + return tpa.Auth0.issuerURL() + } + + if tpa.Cognito.Enabled { + return tpa.Cognito.issuerURL() + } + + return "" +} + +// ResolveJWKS creates the JWKS from the JWT secret and Third-Party Auth +// configs by resolving the JWKS via the OIDC discovery URL. +// It always returns a JWKS string, except when there's an error fetching. +func (a *auth) ResolveJWKS(ctx context.Context) (string, error) { + var jwks struct { + Keys []json.RawMessage `json:"keys"` + } + + issuerURL := a.ThirdParty.IssuerURL() + if issuerURL != "" { + discoveryURL := issuerURL + "/.well-known/openid-configuration" + + t := &http.Client{Timeout: 10 * time.Second} + client := fetcher.NewFetcher( + issuerURL, + fetcher.WithHTTPClient(t), + fetcher.WithExpectedStatus(http.StatusOK), + ) + + resp, err := client.Send(ctx, http.MethodGet, "", nil) + if err != nil { + return "", err + } + + type oidcConfiguration struct { + JWKSURI string `json:"jwks_uri"` + } + + oidcConfig, err := fetcher.ParseJSON[oidcConfiguration](resp.Body) + if err != nil { + return "", err + } + + if oidcConfig.JWKSURI == "" { + return "", fmt.Errorf("auth.third_party: OIDC configuration at URL %q does not expose a jwks_uri property", discoveryURL) + } + + client = fetcher.NewFetcher( + oidcConfig.JWKSURI, + fetcher.WithHTTPClient(t), + fetcher.WithExpectedStatus(http.StatusOK), + ) + + resp, err = client.Send(ctx, http.MethodGet, "", nil) + if err != nil { + return "", err + } + + type remoteJWKS struct { + Keys []json.RawMessage `json:"keys"` + } + + rJWKS, err := fetcher.ParseJSON[remoteJWKS](resp.Body) + if err != nil { + return "", err + } + + if len(rJWKS.Keys) == 0 { + return "", fmt.Errorf("auth.third_party: JWKS at URL %q as discovered from %q does not contain any JWK keys", oidcConfig.JWKSURI, discoveryURL) + } + + jwks.Keys = rJWKS.Keys + } + + var secretJWK struct { + KeyType string `json:"kty"` + KeyBase64URL string `json:"k"` + } + + secretJWK.KeyType = "oct" + secretJWK.KeyBase64URL = base64.RawURLEncoding.EncodeToString([]byte(a.JwtSecret)) + + secretJWKEncoded, err := json.Marshal(&secretJWK) + if err != nil { + return "", errors.Errorf("failed to marshal secret jwk: %w", err) + } + + jwks.Keys = append(jwks.Keys, json.RawMessage(secretJWKEncoded)) + + jwksEncoded, err := json.Marshal(jwks) + if err != nil { + return "", errors.Errorf("failed to marshal jwks keys: %w", err) + } + + return string(jwksEncoded), nil +} diff --git a/pkg/config/templates/config.toml b/pkg/config/templates/config.toml index 909c12af0..456b48b91 100644 --- a/pkg/config/templates/config.toml +++ b/pkg/config/templates/config.toml @@ -193,6 +193,23 @@ url = "" # If enabled, the nonce check will be skipped. Required for local sign in with Google auth. skip_nonce_check = false +# Use Firebase Auth as a third-party provider alongside Supabase Auth. +[auth.third_party.firebase] +enabled = false +# project_id = "my-firebase-project" + +# Use Auth0 as a third-party provider alongside Supabase Auth. +[auth.third_party.auth0] +enabled = false +# tenant = "my-auth0-tenant" +# tenant_region = "us" + +# Use AWS Cognito (Amplify) as a third-party provider alongside Supabase Auth. +[auth.third_party.aws_cognito] +enabled = false +# user_pool_id = "my-user-pool-id" +# user_pool_region = "us-east-1" + [edge_runtime] enabled = true # Configure one of the supported request policies: `oneshot`, `per_worker`.