diff --git a/cmd/branches.go b/cmd/branches.go index 45f88167d..a9a8ce392 100644 --- a/cmd/branches.go +++ b/cmd/branches.go @@ -32,6 +32,7 @@ var ( Allowed: awsRegions(), } persistent bool + withData bool branchCreateCmd = &cobra.Command{ Use: "create [name]", @@ -53,6 +54,9 @@ var ( if cmdFlags.Changed("persistent") { body.Persistent = &persistent } + if cmdFlags.Changed("with-data") { + body.WithData = &withData + } return create.Run(cmd.Context(), body, afero.NewOsFs()) }, } @@ -157,6 +161,7 @@ func init() { createFlags.Var(&branchRegion, "region", "Select a region to deploy the branch database.") createFlags.Var(&size, "size", "Select a desired instance size for the branch database.") createFlags.BoolVar(&persistent, "persistent", false, "Whether to create a persistent branch.") + createFlags.BoolVar(&withData, "with-data", false, "Whether to clone production data to the branch database.") branchesCmd.AddCommand(branchCreateCmd) branchesCmd.AddCommand(branchListCmd) branchesCmd.AddCommand(branchGetCmd) diff --git a/cmd/gen.go b/cmd/gen.go index 5849a5ce0..48a800f17 100644 --- a/cmd/gen.go +++ b/cmd/gen.go @@ -9,6 +9,7 @@ import ( "github.com/spf13/afero" "github.com/spf13/cobra" "github.com/supabase/cli/internal/gen/keys" + "github.com/supabase/cli/internal/gen/signingkeys" "github.com/supabase/cli/internal/gen/types" "github.com/supabase/cli/internal/utils" "github.com/supabase/cli/internal/utils/flags" @@ -93,6 +94,26 @@ var ( supabase gen types --project-id abc-def-123 --schema public --schema private supabase gen types --db-url 'postgresql://...' --schema public --schema auth`, } + + algorithm = utils.EnumFlag{ + Allowed: signingkeys.GetSupportedAlgorithms(), + Value: string(signingkeys.AlgES256), + } + appendKeys bool + + genSigningKeyCmd = &cobra.Command{ + Use: "signing-key", + Short: "Generate a JWT signing key", + Long: `Securely generate a private JWT signing key for use in the CLI or to import in the dashboard. + +Supported algorithms: + ES256 - ECDSA with P-256 curve and SHA-256 (recommended) + RS256 - RSA with SHA-256 +`, + RunE: func(cmd *cobra.Command, args []string) error { + return signingkeys.Run(cmd.Context(), algorithm.Value, appendKeys, afero.NewOsFs()) + }, + } ) func init() { @@ -111,5 +132,9 @@ func init() { keyFlags.StringVar(&flags.ProjectRef, "project-ref", "", "Project ref of the Supabase project.") keyFlags.StringSliceVar(&override, "override-name", []string{}, "Override specific variable names.") genCmd.AddCommand(genKeysCmd) + signingKeyFlags := genSigningKeyCmd.Flags() + signingKeyFlags.Var(&algorithm, "algorithm", "Algorithm for signing key generation.") + signingKeyFlags.BoolVar(&appendKeys, "append", false, "Append new key to existing keys file instead of overwriting.") + genCmd.AddCommand(genSigningKeyCmd) rootCmd.AddCommand(genCmd) } diff --git a/cmd/root.go b/cmd/root.go index d053045e8..24aad18d3 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -111,7 +111,7 @@ var ( } } } - if err := flags.ParseDatabaseConfig(cmd.Flags(), fsys); err != nil { + if err := flags.ParseDatabaseConfig(ctx, cmd.Flags(), fsys); err != nil { return err } // Prepare context diff --git a/go.mod b/go.mod index 73a360fc5..3631c1fdd 100644 --- a/go.mod +++ b/go.mod @@ -48,7 +48,7 @@ require ( golang.org/x/mod v0.26.0 golang.org/x/oauth2 v0.30.0 golang.org/x/term v0.33.0 - google.golang.org/grpc v1.74.0 + google.golang.org/grpc v1.74.2 gopkg.in/yaml.v3 v3.0.1 ) diff --git a/go.sum b/go.sum index 1cce30855..3ec3a0f04 100644 --- a/go.sum +++ b/go.sum @@ -1472,8 +1472,8 @@ google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKa google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk= google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= -google.golang.org/grpc v1.74.0 h1:sxRSkyLxlceWQiqDofxDot3d4u7DyoHPc7SBXMj8gGY= -google.golang.org/grpc v1.74.0/go.mod h1:NZUaK8dAMUfzhK6uxZ+9511LtOrk73UGWOFoNvz7z+s= +google.golang.org/grpc v1.74.2 h1:WoosgB65DlWVC9FqI82dGsZhWFNBSLjQ84bjROOpMu4= +google.golang.org/grpc v1.74.2/go.mod h1:CtQ+BGjaAIXHs/5YS3i473GqwBBa1zGQNevxdeBEXrM= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= diff --git a/internal/bootstrap/bootstrap.go b/internal/bootstrap/bootstrap.go index 311ad5534..70aa6305b 100644 --- a/internal/bootstrap/bootstrap.go +++ b/internal/bootstrap/bootstrap.go @@ -113,7 +113,7 @@ func Run(ctx context.Context, starter StarterTemplate, fsys afero.Fs, options .. return err } // 6. Push migrations - config := flags.NewDbConfigWithPassword(flags.ProjectRef) + config := flags.NewDbConfigWithPassword(ctx, flags.ProjectRef) if err := writeDotEnv(keys, config, fsys); err != nil { fmt.Fprintln(os.Stderr, "Failed to create .env file:", err) } diff --git a/internal/gen/signingkeys/signingkeys.go b/internal/gen/signingkeys/signingkeys.go new file mode 100644 index 000000000..7394a99c4 --- /dev/null +++ b/internal/gen/signingkeys/signingkeys.go @@ -0,0 +1,249 @@ +package signingkeys + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/rsa" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "math/big" + "os" + "path/filepath" + + "github.com/go-errors/errors" + "github.com/google/uuid" + "github.com/spf13/afero" + "github.com/supabase/cli/internal/utils" + "github.com/supabase/cli/internal/utils/flags" + "github.com/supabase/cli/pkg/cast" +) + +type Algorithm string + +const ( + AlgRS256 Algorithm = "RS256" + AlgES256 Algorithm = "ES256" +) + +type JWK struct { + KeyType string `json:"kty"` + KeyID string `json:"kid,omitempty"` + Use string `json:"use,omitempty"` + KeyOps []string `json:"key_ops,omitempty"` + Algorithm string `json:"alg,omitempty"` + Extractable *bool `json:"ext,omitempty"` + // RSA specific fields + Modulus string `json:"n,omitempty"` + Exponent string `json:"e,omitempty"` + // RSA private key fields + PrivateExponent string `json:"d,omitempty"` + FirstPrimeFactor string `json:"p,omitempty"` + SecondPrimeFactor string `json:"q,omitempty"` + FirstFactorCRTExponent string `json:"dp,omitempty"` + SecondFactorCRTExponent string `json:"dq,omitempty"` + FirstCRTCoefficient string `json:"qi,omitempty"` + // EC specific fields + Curve string `json:"crv,omitempty"` + X string `json:"x,omitempty"` + Y string `json:"y,omitempty"` +} + +type KeyPair struct { + PublicKey JWK + PrivateKey JWK +} + +// GenerateKeyPair generates a new key pair for the specified algorithm +func GenerateKeyPair(alg Algorithm) (*KeyPair, error) { + keyID := uuid.New().String() + + switch alg { + case AlgRS256: + return generateRSAKeyPair(keyID) + case AlgES256: + return generateECDSAKeyPair(keyID) + default: + return nil, errors.Errorf("unsupported algorithm: %s", alg) + } +} + +func generateRSAKeyPair(keyID string) (*KeyPair, error) { + // Generate RSA key pair (2048 bits for RS256) + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + return nil, errors.Errorf("failed to generate RSA key: %w", err) + } + + publicKey := &privateKey.PublicKey + + // Precompute CRT values for completeness + privateKey.Precompute() + + // Convert to JWK format + privateJWK := JWK{ + KeyType: "RSA", + KeyID: keyID, + Use: "sig", + KeyOps: []string{"sign", "verify"}, + Algorithm: "RS256", + Extractable: cast.Ptr(true), + Modulus: base64.RawURLEncoding.EncodeToString(publicKey.N.Bytes()), + Exponent: base64.RawURLEncoding.EncodeToString(big.NewInt(int64(publicKey.E)).Bytes()), + PrivateExponent: base64.RawURLEncoding.EncodeToString(privateKey.D.Bytes()), + FirstPrimeFactor: base64.RawURLEncoding.EncodeToString(privateKey.Primes[0].Bytes()), + SecondPrimeFactor: base64.RawURLEncoding.EncodeToString(privateKey.Primes[1].Bytes()), + FirstFactorCRTExponent: base64.RawURLEncoding.EncodeToString(privateKey.Precomputed.Dp.Bytes()), + SecondFactorCRTExponent: base64.RawURLEncoding.EncodeToString(privateKey.Precomputed.Dq.Bytes()), + FirstCRTCoefficient: base64.RawURLEncoding.EncodeToString(privateKey.Precomputed.Qinv.Bytes()), + } + + publicJWK := JWK{ + KeyType: "RSA", + KeyID: keyID, + Use: "sig", + KeyOps: []string{"verify"}, + Algorithm: "RS256", + Extractable: cast.Ptr(true), + Modulus: privateJWK.Modulus, + Exponent: privateJWK.Exponent, + } + + return &KeyPair{ + PublicKey: publicJWK, + PrivateKey: privateJWK, + }, nil +} + +func generateECDSAKeyPair(keyID string) (*KeyPair, error) { + // Generate ECDSA key pair (P-256 curve for ES256) + privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return nil, errors.Errorf("failed to generate ECDSA key: %w", err) + } + + publicKey := &privateKey.PublicKey + + // Convert to JWK format + privateJWK := JWK{ + KeyType: "EC", + KeyID: keyID, + Use: "sig", + KeyOps: []string{"sign", "verify"}, + Algorithm: "ES256", + Extractable: cast.Ptr(true), + Curve: "P-256", + X: base64.RawURLEncoding.EncodeToString(publicKey.X.Bytes()), + Y: base64.RawURLEncoding.EncodeToString(publicKey.Y.Bytes()), + PrivateExponent: base64.RawURLEncoding.EncodeToString(privateKey.D.Bytes()), + } + + publicJWK := JWK{ + KeyType: "EC", + KeyID: keyID, + Use: "sig", + KeyOps: []string{"verify"}, + Algorithm: "ES256", + Extractable: cast.Ptr(true), + Curve: "P-256", + X: privateJWK.X, + Y: privateJWK.Y, + } + + return &KeyPair{ + PublicKey: publicJWK, + PrivateKey: privateJWK, + }, nil +} + +// Run generates a key pair and writes it to the specified file path +func Run(ctx context.Context, algorithm string, appendMode bool, fsys afero.Fs) error { + err := flags.LoadConfig(fsys) + if err != nil { + return err + } + outputPath := utils.Config.Auth.SigningKeysPath + + // Generate key pair + keyPair, err := GenerateKeyPair(Algorithm(algorithm)) + if err != nil { + return err + } + + out := io.Writer(os.Stdout) + var jwkArray []JWK + if len(outputPath) > 0 { + if err := utils.MkdirIfNotExistFS(fsys, filepath.Dir(outputPath)); err != nil { + return err + } + f, err := fsys.OpenFile(outputPath, os.O_RDWR|os.O_CREATE, 0600) + if err != nil { + return errors.Errorf("failed to open signing key: %w", err) + } + defer f.Close() + if appendMode { + // Load existing key and reset file + dec := json.NewDecoder(f) + // Since a new file is empty, we must ignore EOF error + if err := dec.Decode(&jwkArray); err != nil && !errors.Is(err, io.EOF) { + return errors.Errorf("failed to decode signing key: %w", err) + } + if _, err = f.Seek(0, io.SeekStart); err != nil { + return errors.Errorf("failed to seek signing key: %w", err) + } + } else if fi, err := f.Stat(); fi.Size() > 0 { + if err != nil { + fmt.Fprintln(utils.GetDebugLogger(), err) + } + label := fmt.Sprintf("Do you want to overwrite the existing %s file?", utils.Bold(outputPath)) + if shouldOverwrite, err := utils.NewConsole().PromptYesNo(ctx, label, true); err != nil { + return err + } else if !shouldOverwrite { + return errors.New(context.Canceled) + } + if err := f.Truncate(0); err != nil { + return errors.Errorf("failed to truncate signing key: %w", err) + } + } + out = f + } + jwkArray = append(jwkArray, keyPair.PrivateKey) + + // Write to file + enc := json.NewEncoder(out) + enc.SetIndent("", " ") + if err := enc.Encode(jwkArray); err != nil { + return errors.Errorf("failed to encode signing key: %w", err) + } + + if len(outputPath) == 0 { + utils.CmdSuggestion = fmt.Sprintf(` +To enable JWT signing keys in your local project: +1. Save the generated key to %s +2. Update your %s with the new keys path + +[auth] +signing_keys_path = "./signing_key.json" +`, utils.Bold(filepath.Join(utils.SupabaseDirPath, "signing_key.json")), utils.Bold(utils.ConfigPath)) + return nil + } + + fmt.Fprintf(os.Stderr, "JWT signing key appended to: %s (now contains %d keys)\n", utils.Bold(outputPath), len(jwkArray)) + if len(jwkArray) == 1 { + if ignored, err := utils.IsGitIgnored(outputPath); err != nil { + fmt.Fprintln(utils.GetDebugLogger(), err) + } else if !ignored { + // Since the output path is user defined, we can't update the managed .gitignore file. + fmt.Fprintln(os.Stderr, utils.Yellow("IMPORTANT:"), "Add your signing key path to .gitignore to prevent committing to version control.") + } + } + return nil +} + +// GetSupportedAlgorithms returns a list of supported algorithms +func GetSupportedAlgorithms() []string { + return []string{string(AlgRS256), string(AlgES256)} +} diff --git a/internal/gen/signingkeys/signingkeys_test.go b/internal/gen/signingkeys/signingkeys_test.go new file mode 100644 index 000000000..51333887d --- /dev/null +++ b/internal/gen/signingkeys/signingkeys_test.go @@ -0,0 +1,109 @@ +package signingkeys + +import ( + "testing" +) + +func TestGenerateKeyPair(t *testing.T) { + tests := []struct { + name string + algorithm Algorithm + wantErr bool + }{ + { + name: "RSA key generation", + algorithm: AlgRS256, + wantErr: false, + }, + { + name: "ECDSA key generation", + algorithm: AlgES256, + wantErr: false, + }, + { + name: "unsupported algorithm", + algorithm: "UNSUPPORTED", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + keyPair, err := GenerateKeyPair(tt.algorithm) + if (err != nil) != tt.wantErr { + t.Errorf("GenerateKeyPair(%s) error = %v, wantErr %v", tt.algorithm, err, tt.wantErr) + return + } + if !tt.wantErr { + if keyPair == nil { + t.Error("GenerateKeyPair() returned nil key pair") + return + } + + // Check that both public and private keys are generated + if keyPair.PublicKey.KeyType == "" { + t.Error("Public key type is empty") + } + if keyPair.PrivateKey.KeyType == "" { + t.Error("Private key type is empty") + } + + // Check that key IDs match + if keyPair.PublicKey.KeyID != keyPair.PrivateKey.KeyID { + t.Error("Public and private key IDs don't match") + } + + // Algorithm-specific checks + switch tt.algorithm { + case AlgRS256: + if keyPair.PublicKey.KeyType != "RSA" { + t.Errorf("Expected RSA key type, got %s", keyPair.PublicKey.KeyType) + } + if keyPair.PrivateKey.Algorithm != "RS256" { + t.Errorf("Expected RS256 algorithm, got %s", keyPair.PrivateKey.Algorithm) + } + // Check that RSA-specific fields are present + if keyPair.PrivateKey.Modulus == "" { + t.Error("RSA private key missing modulus") + } + if keyPair.PrivateKey.PrivateExponent == "" { + t.Error("RSA private key missing private exponent") + } + case AlgES256: + if keyPair.PublicKey.KeyType != "EC" { + t.Errorf("Expected EC key type, got %s", keyPair.PublicKey.KeyType) + } + if keyPair.PrivateKey.Algorithm != "ES256" { + t.Errorf("Expected ES256 algorithm, got %s", keyPair.PrivateKey.Algorithm) + } + // Check that EC-specific fields are present + if keyPair.PrivateKey.Curve != "P-256" { + t.Errorf("Expected P-256 curve, got %s", keyPair.PrivateKey.Curve) + } + if keyPair.PrivateKey.X == "" { + t.Error("EC private key missing X coordinate") + } + if keyPair.PrivateKey.Y == "" { + t.Error("EC private key missing Y coordinate") + } + } + } + }) + } +} + +func TestGetSupportedAlgorithms(t *testing.T) { + algorithms := GetSupportedAlgorithms() + expected := []string{"RS256", "ES256"} + + if len(algorithms) != len(expected) { + t.Errorf("GetSupportedAlgorithms() length = %d, expected %d", len(algorithms), len(expected)) + return + } + + for i, alg := range algorithms { + if alg != expected[i] { + t.Errorf("GetSupportedAlgorithms()[%d] = %s, expected %s", i, alg, expected[i]) + } + } +} diff --git a/internal/link/link.go b/internal/link/link.go index b7a49fe85..471d469d9 100644 --- a/internal/link/link.go +++ b/internal/link/link.go @@ -14,7 +14,6 @@ import ( "github.com/spf13/afero" "github.com/spf13/viper" "github.com/supabase/cli/internal/utils" - "github.com/supabase/cli/internal/utils/credentials" "github.com/supabase/cli/internal/utils/flags" "github.com/supabase/cli/internal/utils/tenant" "github.com/supabase/cli/pkg/api" @@ -43,15 +42,9 @@ func Run(ctx context.Context, projectRef string, fsys afero.Fs, options ...func( LinkServices(ctx, projectRef, keys.Anon, fsys) // 2. Check database connection - config := flags.GetDbConfigOptionalPassword(projectRef) - if len(config.Password) > 0 { - if err := linkDatabase(ctx, config, fsys, options...); err != nil { - return err - } - // Save database password - if err := credentials.StoreProvider.Set(projectRef, config.Password); err != nil { - fmt.Fprintln(os.Stderr, "Failed to save database password:", err) - } + config := flags.NewDbConfigWithPassword(ctx, projectRef) + if err := linkDatabase(ctx, config, fsys, options...); err != nil { + return err } // 3. Save project ref diff --git a/internal/link/link_test.go b/internal/link/link_test.go index c1e869cca..15ff82962 100644 --- a/internal/link/link_test.go +++ b/internal/link/link_test.go @@ -21,6 +21,7 @@ import ( "github.com/supabase/cli/pkg/api" "github.com/supabase/cli/pkg/migration" "github.com/supabase/cli/pkg/pgtest" + "github.com/supabase/cli/pkg/pgxv5" "github.com/zalando/go-keyring" ) @@ -47,7 +48,9 @@ func TestLinkCommand(t *testing.T) { // Setup mock postgres conn := pgtest.NewConn() defer conn.Close(t) - conn.Query(GET_LATEST_STORAGE_MIGRATION). + conn.Query(pgxv5.SET_SESSION_ROLE). + Reply("SET ROLE"). + Query(GET_LATEST_STORAGE_MIGRATION). Reply("SELECT 1", []interface{}{"custom-metadata"}) helper.MockMigrationHistory(conn) helper.MockSeedHistory(conn) @@ -92,6 +95,9 @@ func TestLinkCommand(t *testing.T) { Get("/v1/projects/" + project + "/network-restrictions"). Reply(200). JSON(api.NetworkRestrictionsResponse{}) + gock.New(utils.DefaultApiHost). + Post("/v1/projects/" + project + "/database/query"). + Reply(http.StatusCreated) // Link versions auth := tenant.HealthResponse{Version: "v2.74.2"} gock.New("https://" + utils.GetSupabaseHost(project)). @@ -158,8 +164,11 @@ func TestLinkCommand(t *testing.T) { ReplyError(errors.New("network error")) gock.New(utils.DefaultApiHost). Get("/v1/projects/" + project + "/network-restrictions"). - Reply(200). + Reply(http.StatusOK). JSON(api.NetworkRestrictionsResponse{}) + gock.New(utils.DefaultApiHost). + Post("/v1/projects/" + project + "/database/query"). + Reply(http.StatusServiceUnavailable) // Link versions gock.New("https://" + utils.GetSupabaseHost(project)). Get("/auth/v1/health"). @@ -181,6 +190,15 @@ func TestLinkCommand(t *testing.T) { t.Run("throws error on write failure", func(t *testing.T) { // Setup in-memory fs fsys := afero.NewReadOnlyFs(afero.NewMemMapFs()) + // Setup mock postgres + conn := pgtest.NewConn() + defer conn.Close(t) + conn.Query(pgxv5.SET_SESSION_ROLE). + Reply("SET ROLE"). + Query(GET_LATEST_STORAGE_MIGRATION). + Reply("SELECT 1", []interface{}{"custom-metadata"}) + helper.MockMigrationHistory(conn) + helper.MockSeedHistory(conn) // Flush pending mocks after test execution defer gock.OffAll() // Mock project status @@ -212,8 +230,11 @@ func TestLinkCommand(t *testing.T) { ReplyError(errors.New("network error")) gock.New(utils.DefaultApiHost). Get("/v1/projects/" + project + "/network-restrictions"). - Reply(200). + Reply(http.StatusOK). JSON(api.NetworkRestrictionsResponse{}) + gock.New(utils.DefaultApiHost). + Post("/v1/projects/" + project + "/database/query"). + Reply(http.StatusCreated) // Link versions gock.New("https://" + utils.GetSupabaseHost(project)). Get("/auth/v1/health"). @@ -225,7 +246,7 @@ func TestLinkCommand(t *testing.T) { Get("/v1/projects"). ReplyError(errors.New("network error")) // Run test - err := Run(context.Background(), project, fsys) + err := Run(context.Background(), project, fsys, conn.Intercept) // Check error assert.ErrorContains(t, err, "operation not permitted") assert.Empty(t, apitest.ListUnmatchedRequests()) diff --git a/internal/start/start.go b/internal/start/start.go index fcf654fe6..f922addf3 100644 --- a/internal/start/start.go +++ b/internal/start/start.go @@ -145,7 +145,7 @@ func run(p utils.Program, ctx context.Context, fsys afero.Fs, excludedContainers excluded[name] = true } - jwks, err := utils.Config.Auth.ResolveJWKS(ctx) + jwks, err := utils.Config.Auth.ResolveJWKS(ctx, fsys) if err != nil { return err } @@ -508,6 +508,11 @@ EOF fmt.Sprintf("GOTRUE_RATE_LIMIT_WEB3=%v", utils.Config.Auth.RateLimit.Web3), } + // Since signing key is validated by ResolveJWKS, simply read the key file. + if keys, err := afero.ReadFile(fsys, utils.Config.Auth.SigningKeysPath); err == nil && len(keys) > 0 { + env = append(env, "GOTRUE_JWT_KEYS="+string(keys)) + } + if utils.Config.Auth.Email.Smtp != nil && utils.Config.Auth.Email.Smtp.Enabled { env = append(env, fmt.Sprintf("GOTRUE_RATE_LIMIT_EMAIL_SENT=%v", utils.Config.Auth.RateLimit.EmailSent), @@ -906,6 +911,8 @@ EOF "IMGPROXY_MAX_SRC_FILE_SIZE=25000000", "IMGPROXY_MAX_ANIMATION_FRAMES=60", "IMGPROXY_ENABLE_WEBP_DETECTION=true", + "IMGPROXY_PRESETS=default=width:3000/height:8192", + "IMGPROXY_FORMAT_QUALITY=jpeg=80,avif=62,webp=80", }, Healthcheck: &container.HealthConfig{ Test: []string{"CMD", "imgproxy", "health"}, diff --git a/internal/status/status.go b/internal/status/status.go index ef5d42c25..7dd64ecd0 100644 --- a/internal/status/status.go +++ b/internal/status/status.go @@ -176,16 +176,11 @@ var ( func checkHTTPHead(ctx context.Context, path string) error { healthOnce.Do(func() { - server := utils.Config.Api.ExternalUrl - header := func(req *http.Request) { - req.Header.Add("apikey", utils.Config.Auth.AnonKey.Value) - } - client := NewKongClient() - healthClient = fetcher.NewFetcher( - server, - fetcher.WithHTTPClient(client), - fetcher.WithRequestEditor(header), - fetcher.WithExpectedStatus(http.StatusOK), + healthClient = fetcher.NewServiceGateway( + utils.Config.Api.ExternalUrl, + utils.Config.Auth.AnonKey.Value, + fetcher.WithHTTPClient(NewKongClient()), + fetcher.WithUserAgent("SupabaseCLI/"+utils.Version), ) }) // HEAD method does not return response body diff --git a/internal/storage/client/api.go b/internal/storage/client/api.go index a05eaffeb..33b5d6711 100644 --- a/internal/storage/client/api.go +++ b/internal/storage/client/api.go @@ -28,21 +28,19 @@ func NewStorageAPI(ctx context.Context, projectRef string) (storage.StorageAPI, } func newLocalClient() *fetcher.Fetcher { - client := status.NewKongClient() - return fetcher.NewFetcher( + return fetcher.NewServiceGateway( utils.Config.Api.ExternalUrl, - fetcher.WithHTTPClient(client), - fetcher.WithBearerToken(utils.Config.Auth.ServiceRoleKey.Value), + utils.Config.Auth.ServiceRoleKey.Value, + fetcher.WithHTTPClient(status.NewKongClient()), fetcher.WithUserAgent("SupabaseCLI/"+utils.Version), - fetcher.WithExpectedStatus(http.StatusOK), ) } func newRemoteClient(projectRef, token string) *fetcher.Fetcher { - return fetcher.NewFetcher( + return fetcher.NewServiceGateway( "https://"+utils.GetSupabaseHost(projectRef), - fetcher.WithBearerToken(token), + token, + fetcher.WithHTTPClient(http.DefaultClient), fetcher.WithUserAgent("SupabaseCLI/"+utils.Version), - fetcher.WithExpectedStatus(http.StatusOK), ) } diff --git a/internal/utils/flags/db_url.go b/internal/utils/flags/db_url.go index 46aad3734..0b666d6e1 100644 --- a/internal/utils/flags/db_url.go +++ b/internal/utils/flags/db_url.go @@ -1,11 +1,16 @@ package flags import ( + "bytes" + "context" "crypto/rand" + _ "embed" "fmt" "math/big" + "net/http" "os" "strings" + "text/template" "github.com/go-errors/errors" "github.com/jackc/pgconn" @@ -14,7 +19,9 @@ import ( "github.com/spf13/viper" "github.com/supabase/cli/internal/utils" "github.com/supabase/cli/internal/utils/credentials" + "github.com/supabase/cli/pkg/api" "github.com/supabase/cli/pkg/config" + "github.com/supabase/cli/pkg/pgxv5" ) type connection int @@ -29,7 +36,7 @@ const ( var DbConfig pgconn.Config -func ParseDatabaseConfig(flagSet *pflag.FlagSet, fsys afero.Fs) error { +func ParseDatabaseConfig(ctx context.Context, flagSet *pflag.FlagSet, fsys afero.Fs) error { // Changed flags take precedence over default values var connType connection if flag := flagSet.Lookup("db-url"); flag != nil && flag.Changed { @@ -77,7 +84,7 @@ func ParseDatabaseConfig(flagSet *pflag.FlagSet, fsys afero.Fs) error { if err := LoadConfig(fsys); err != nil { return err } - DbConfig = NewDbConfigWithPassword(ProjectRef) + DbConfig = NewDbConfigWithPassword(ctx, ProjectRef) case proxy: token, err := utils.LoadAccessTokenFS(fsys) if err != nil { @@ -95,23 +102,71 @@ func ParseDatabaseConfig(flagSet *pflag.FlagSet, fsys afero.Fs) error { return nil } -func NewDbConfigWithPassword(projectRef string) pgconn.Config { - config := getDbConfig(projectRef) - config.Password = getPassword(projectRef) - return config +const letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" + +func RandomString(size int) (string, error) { + data := make([]byte, size) + _, err := rand.Read(data) + if err != nil { + return "", errors.Errorf("failed to read random: %w", err) + } + for i := range data { + n := int(data[i]) % len(letters) + data[i] = letters[n] + } + return string(data), nil } -func getPassword(projectRef string) string { - if password := viper.GetString("DB_PASSWORD"); len(password) > 0 { - return password +func NewDbConfigWithPassword(ctx context.Context, projectRef string) pgconn.Config { + config := getDbConfig(projectRef) + config.Password = viper.GetString("DB_PASSWORD") + if len(config.Password) > 0 { + return config } - if password, err := credentials.StoreProvider.Get(projectRef); err == nil { - return password + var err error + if config.Password, err = RandomString(32); err == nil { + newRole := pgconn.Config{ + User: pgxv5.CLI_LOGIN_ROLE, + Password: config.Password, + } + if err := initLoginRole(ctx, projectRef, newRole); err == nil { + // Special handling for pooler username + if suffix := "." + projectRef; strings.HasSuffix(config.User, suffix) { + newRole.User += suffix + } + config.User = newRole.User + return config + } + } + if config.Password, err = credentials.StoreProvider.Get(projectRef); err == nil { + return config } resetUrl := fmt.Sprintf("%s/project/%s/settings/database", utils.GetSupabaseDashboardURL(), projectRef) fmt.Fprintln(os.Stderr, "Forgot your password? Reset it from the Dashboard:", utils.Bold(resetUrl)) fmt.Fprint(os.Stderr, "Enter your database password: ") - return credentials.PromptMasked(os.Stdin) + config.Password = credentials.PromptMasked(os.Stdin) + return config +} + +var ( + //go:embed queries/role.sql + initRoleEmbed string + initRoleTemplate = template.Must(template.New("initRole").Parse(initRoleEmbed)) +) + +func initLoginRole(ctx context.Context, projectRef string, config pgconn.Config) error { + fmt.Fprintf(os.Stderr, "Initialising %s role...\n", config.User) + var initRoleBuf bytes.Buffer + if err := initRoleTemplate.Option("missingkey=error").Execute(&initRoleBuf, config); err != nil { + return errors.Errorf("failed to exec template: %w", err) + } + body := api.V1RunQueryBody{Query: initRoleBuf.String()} + if resp, err := utils.GetSupabase().V1RunAQueryWithResponse(ctx, projectRef, body); err != nil { + return errors.Errorf("failed to initialise login role: %w", err) + } else if resp.StatusCode() != http.StatusCreated { + return errors.Errorf("unexpected query status %d: %s", resp.StatusCode(), string(resp.Body)) + } + return nil } const PASSWORD_LENGTH = 16 @@ -148,13 +203,3 @@ func getDbConfig(projectRef string) pgconn.Config { Database: "postgres", } } - -func GetDbConfigOptionalPassword(projectRef string) pgconn.Config { - config := getDbConfig(projectRef) - config.Password = viper.GetString("DB_PASSWORD") - if config.Password == "" { - fmt.Fprint(os.Stderr, "Enter your database password (or leave blank to skip): ") - config.Password = credentials.PromptMasked(os.Stdin) - } - return config -} diff --git a/internal/utils/flags/db_url_test.go b/internal/utils/flags/db_url_test.go index ba9676b5f..1bb3fcc79 100644 --- a/internal/utils/flags/db_url_test.go +++ b/internal/utils/flags/db_url_test.go @@ -1,12 +1,12 @@ package flags import ( + "context" "os" "testing" "github.com/spf13/afero" "github.com/spf13/pflag" - "github.com/spf13/viper" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/supabase/cli/internal/testing/apitest" @@ -14,6 +14,10 @@ import ( ) func TestParseDatabaseConfig(t *testing.T) { + // Setup valid access token + token := apitest.RandomAccessToken(t) + t.Setenv("SUPABASE_ACCESS_TOKEN", string(token)) + t.Run("parses direct connection from db-url flag", func(t *testing.T) { flagSet := pflag.NewFlagSet("test", pflag.ContinueOnError) flagSet.String("db-url", "postgres://postgres:password@localhost:5432/postgres", "") @@ -22,7 +26,7 @@ func TestParseDatabaseConfig(t *testing.T) { fsys := afero.NewMemMapFs() - err = ParseDatabaseConfig(flagSet, fsys) + err = ParseDatabaseConfig(context.Background(), flagSet, fsys) assert.NoError(t, err) assert.Equal(t, "db.example.com", DbConfig.Host) @@ -44,7 +48,7 @@ func TestParseDatabaseConfig(t *testing.T) { utils.Config.Db.Port = 54322 utils.Config.Db.Password = "local-password" - err = ParseDatabaseConfig(flagSet, fsys) + err = ParseDatabaseConfig(context.Background(), flagSet, fsys) assert.NoError(t, err) assert.Equal(t, "localhost", DbConfig.Host) @@ -66,7 +70,7 @@ func TestParseDatabaseConfig(t *testing.T) { err = afero.WriteFile(fsys, utils.ProjectRefPath, []byte(project), 0644) require.NoError(t, err) - err = ParseDatabaseConfig(flagSet, fsys) + err = ParseDatabaseConfig(context.Background(), flagSet, fsys) assert.NoError(t, err) assert.Equal(t, utils.GetSupabaseDbHost(project), DbConfig.Host) @@ -105,14 +109,3 @@ func TestPromptPassword(t *testing.T) { assert.NotEqual(t, "", password) }) } - -func TestGetDbConfigOptionalPassword(t *testing.T) { - t.Run("uses environment variable when available", func(t *testing.T) { - viper.Set("DB_PASSWORD", "env-password") - projectRef := apitest.RandomProjectRef() - - config := GetDbConfigOptionalPassword(projectRef) - - assert.Equal(t, "env-password", config.Password) - }) -} diff --git a/internal/utils/flags/queries/role.sql b/internal/utils/flags/queries/role.sql new file mode 100644 index 000000000..2e10035ae --- /dev/null +++ b/internal/utils/flags/queries/role.sql @@ -0,0 +1,16 @@ +do $func$ +begin + if not exists ( + select 1 + from pg_roles + where rolname = '{{ .User }}' + ) + then + create role "{{ .User }}" noinherit login noreplication in role postgres; + end if; + execute format( + $$alter role "{{ .User }}" with password '{{ .Password }}' valid until %L$$, + now() + interval '5 minutes' + ); +end +$func$ language plpgsql; diff --git a/internal/utils/misc.go b/internal/utils/misc.go index 46a9d3d12..80fb13b27 100644 --- a/internal/utils/misc.go +++ b/internal/utils/misc.go @@ -14,6 +14,7 @@ import ( "github.com/docker/docker/client" "github.com/go-errors/errors" "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/plumbing/format/gitignore" "github.com/spf13/afero" "github.com/spf13/viper" "github.com/supabase/cli/pkg/migration" @@ -125,6 +126,24 @@ func IsGitRepo() bool { return err == nil } +func IsGitIgnored(fp ...string) (bool, error) { + opts := &git.PlainOpenOptions{DetectDotGit: true} + repo, err := git.PlainOpenWithOptions(".", opts) + if err != nil { + return false, err + } + wt, err := repo.Worktree() + if err != nil { + return false, err + } + ps, err := gitignore.ReadPatterns(wt.Filesystem, nil) + if err != nil { + return false, err + } + m := gitignore.NewMatcher(ps) + return m.Match(fp, false), nil +} + // If the `os.Getwd()` is within a supabase project, this will return // the root of the given project as the current working directory. // Otherwise, the `os.Getwd()` is kept as is. diff --git a/internal/utils/tenant/client.go b/internal/utils/tenant/client.go index c94696701..3868e7555 100644 --- a/internal/utils/tenant/client.go +++ b/internal/utils/tenant/client.go @@ -2,8 +2,7 @@ package tenant import ( "context" - "net/http" - "time" + "strings" "github.com/go-errors/errors" "github.com/supabase/cli/internal/utils" @@ -32,16 +31,41 @@ func NewApiKey(resp []api.ApiKeyResponse) ApiKey { if err != nil { continue } + if t, err := key.Type.Get(); err == nil { + switch t { + case api.ApiKeyResponseTypePublishable: + result.Anon = value + continue + case api.ApiKeyResponseTypeSecret: + if isServiceRole(key) { + result.ServiceRole = value + } + continue + } + } switch key.Name { case "anon": - result.Anon = value + if len(result.Anon) == 0 { + result.Anon = value + } case "service_role": - result.ServiceRole = value + if len(result.ServiceRole) == 0 { + result.ServiceRole = value + } } } return result } +func isServiceRole(key api.ApiKeyResponse) bool { + if tmpl, err := key.SecretJwtTemplate.Get(); err == nil { + if role, ok := tmpl["role"].(string); ok { + return strings.EqualFold(role, "service_role") + } + } + return false +} + func GetApiKeys(ctx context.Context, projectRef string) (ApiKey, error) { resp, err := utils.GetSupabase().V1GetProjectApiKeysWithResponse(ctx, projectRef, &api.V1GetProjectApiKeysParams{}) if err != nil { @@ -62,19 +86,9 @@ type TenantAPI struct { } func NewTenantAPI(ctx context.Context, projectRef, anonKey string) TenantAPI { - server := "https://" + utils.GetSupabaseHost(projectRef) - client := &http.Client{ - Timeout: 10 * time.Second, - } - header := func(req *http.Request) { - req.Header.Add("apikey", anonKey) - } - api := TenantAPI{Fetcher: fetcher.NewFetcher( - server, - fetcher.WithHTTPClient(client), - fetcher.WithRequestEditor(header), + return TenantAPI{Fetcher: fetcher.NewServiceGateway( + "https://"+utils.GetSupabaseHost(projectRef), + anonKey, fetcher.WithUserAgent("SupabaseCLI/"+utils.Version), - fetcher.WithExpectedStatus(http.StatusOK), )} - return api } diff --git a/internal/utils/tenant/client_test.go b/internal/utils/tenant/client_test.go index 183485fbe..849629284 100644 --- a/internal/utils/tenant/client_test.go +++ b/internal/utils/tenant/client_test.go @@ -131,24 +131,21 @@ func TestGetApiKeys(t *testing.T) { } func TestNewTenantAPI(t *testing.T) { - t.Run("creates tenant api client", func(t *testing.T) { - projectRef := apitest.RandomProjectRef() - anonKey := "test-key" - - api := NewTenantAPI(context.Background(), projectRef, anonKey) + projectRef := apitest.RandomProjectRef() + anonKey := "test-key" - assert.NotNil(t, api.Fetcher) + api := NewTenantAPI(context.Background(), projectRef, anonKey) + assert.NotNil(t, api.Fetcher) - defer gock.OffAll() - gock.New("https://"+utils.GetSupabaseHost(projectRef)). - Get("/test"). - MatchHeader("apikey", anonKey). - MatchHeader("User-Agent", "SupabaseCLI/"+utils.Version). - Reply(http.StatusOK) + defer gock.OffAll() + gock.New("https://"+utils.GetSupabaseHost(projectRef)). + Get("/test"). + MatchHeader("Authorization", "Bearer "+anonKey). + MatchHeader("User-Agent", "SupabaseCLI/"+utils.Version). + Reply(http.StatusOK) - _, err := api.Send(context.Background(), http.MethodGet, "/test", nil) + _, err := api.Send(context.Background(), http.MethodGet, "/test", nil) - assert.NoError(t, err) - assert.Empty(t, apitest.ListUnmatchedRequests()) - }) + assert.NoError(t, err) + assert.Empty(t, apitest.ListUnmatchedRequests()) } diff --git a/pkg/api/client.gen.go b/pkg/api/client.gen.go index f236f9a7a..45e92c6a4 100644 --- a/pkg/api/client.gen.go +++ b/pkg/api/client.gen.go @@ -3033,6 +3033,38 @@ func NewV1AuthorizeUserRequest(server string, params *V1AuthorizeUserParams) (*h } + if params.OrganizationSlug != nil { + + if queryFrag, err := runtime.StyleParamWithLocation("form", true, "organization_slug", runtime.ParamLocationQuery, *params.OrganizationSlug); err != nil { + return nil, err + } else if parsed, err := url.ParseQuery(queryFrag); err != nil { + return nil, err + } else { + for k, v := range parsed { + for _, v2 := range v { + queryValues.Add(k, v2) + } + } + } + + } + + if params.Resource != nil { + + if queryFrag, err := runtime.StyleParamWithLocation("form", true, "resource", runtime.ParamLocationQuery, *params.Resource); err != nil { + return nil, err + } else if parsed, err := url.ParseQuery(queryFrag); err != nil { + return nil, err + } else { + for k, v := range parsed { + for _, v2 := range v { + queryValues.Add(k, v2) + } + } + } + + } + queryURL.RawQuery = queryValues.Encode() } diff --git a/pkg/api/types.gen.go b/pkg/api/types.gen.go index 894c96463..ca0fd71c5 100644 --- a/pkg/api/types.gen.go +++ b/pkg/api/types.gen.go @@ -492,6 +492,11 @@ const ( RefreshToken OAuthTokenBodyGrantType = "refresh_token" ) +// Defines values for OAuthTokenBodyResource. +const ( + OAuthTokenBodyResourceHttpsapiSupabaseGreenmcp OAuthTokenBodyResource = "https://api.supabase.green/mcp" +) + // Defines values for OAuthTokenResponseTokenType. const ( Bearer OAuthTokenResponseTokenType = "Bearer" @@ -954,6 +959,11 @@ const ( V1AuthorizeUserParamsCodeChallengeMethodSha256 V1AuthorizeUserParamsCodeChallengeMethod = "sha256" ) +// Defines values for V1AuthorizeUserParamsResource. +const ( + V1AuthorizeUserParamsResourceHttpsapiSupabaseGreenmcp V1AuthorizeUserParamsResource = "https://api.supabase.green/mcp" +) + // Defines values for V1OauthAuthorizeProjectClaimParamsResponseType. const ( V1OauthAuthorizeProjectClaimParamsResponseTypeCode V1OauthAuthorizeProjectClaimParamsResponseType = "code" @@ -1041,18 +1051,16 @@ type AnalyticsResponse_Error struct { // ApiKeyResponse defines model for ApiKeyResponse. type ApiKeyResponse struct { - ApiKey nullable.Nullable[string] `json:"api_key,omitempty"` - Description nullable.Nullable[string] `json:"description,omitempty"` - Hash nullable.Nullable[string] `json:"hash,omitempty"` - Id nullable.Nullable[string] `json:"id,omitempty"` - InsertedAt nullable.Nullable[time.Time] `json:"inserted_at,omitempty"` - Name string `json:"name"` - Prefix nullable.Nullable[string] `json:"prefix,omitempty"` - SecretJwtTemplate nullable.Nullable[struct { - Role string `json:"role"` - }] `json:"secret_jwt_template,omitempty"` - Type nullable.Nullable[ApiKeyResponseType] `json:"type,omitempty"` - UpdatedAt nullable.Nullable[time.Time] `json:"updated_at,omitempty"` + ApiKey nullable.Nullable[string] `json:"api_key,omitempty"` + Description nullable.Nullable[string] `json:"description,omitempty"` + Hash nullable.Nullable[string] `json:"hash,omitempty"` + Id nullable.Nullable[string] `json:"id,omitempty"` + InsertedAt nullable.Nullable[time.Time] `json:"inserted_at,omitempty"` + Name string `json:"name"` + Prefix nullable.Nullable[string] `json:"prefix,omitempty"` + SecretJwtTemplate nullable.Nullable[map[string]interface{}] `json:"secret_jwt_template,omitempty"` + Type nullable.Nullable[ApiKeyResponseType] `json:"type,omitempty"` + UpdatedAt nullable.Nullable[time.Time] `json:"updated_at,omitempty"` } // ApiKeyResponseType defines model for ApiKeyResponse.Type. @@ -1375,12 +1383,10 @@ type BulkUpdateFunctionResponseFunctionsStatus string // CreateApiKeyBody defines model for CreateApiKeyBody. type CreateApiKeyBody struct { - Description nullable.Nullable[string] `json:"description,omitempty"` - Name string `json:"name"` - SecretJwtTemplate nullable.Nullable[struct { - Role string `json:"role"` - }] `json:"secret_jwt_template,omitempty"` - Type CreateApiKeyBodyType `json:"type"` + Description nullable.Nullable[string] `json:"description,omitempty"` + Name string `json:"name"` + SecretJwtTemplate nullable.Nullable[map[string]interface{}] `json:"secret_jwt_template,omitempty"` + Type CreateApiKeyBodyType `json:"type"` } // CreateApiKeyBodyType defines model for CreateApiKeyBody.Type. @@ -1401,6 +1407,7 @@ type CreateBranchBody struct { // ReleaseChannel Release channel. If not provided, GA will be used. ReleaseChannel *CreateBranchBodyReleaseChannel `json:"release_channel,omitempty"` Secrets *map[string]string `json:"secrets,omitempty"` + WithData *bool `json:"with_data,omitempty"` } // CreateBranchBodyDesiredInstanceSize defines model for CreateBranchBody.DesiredInstanceSize. @@ -1937,11 +1944,17 @@ type OAuthTokenBody struct { GrantType *OAuthTokenBodyGrantType `json:"grant_type,omitempty"` RedirectUri *string `json:"redirect_uri,omitempty"` RefreshToken *string `json:"refresh_token,omitempty"` + + // Resource Resource indicator for MCP (Model Context Protocol) clients + Resource *OAuthTokenBodyResource `json:"resource,omitempty"` } // OAuthTokenBodyGrantType defines model for OAuthTokenBody.GrantType. type OAuthTokenBodyGrantType string +// OAuthTokenBodyResource Resource indicator for MCP (Model Context Protocol) clients +type OAuthTokenBodyResource string + // OAuthTokenResponse defines model for OAuthTokenResponse. type OAuthTokenResponse struct { AccessToken string `json:"access_token"` @@ -2299,11 +2312,9 @@ type TypescriptResponse struct { // UpdateApiKeyBody defines model for UpdateApiKeyBody. type UpdateApiKeyBody struct { - Description nullable.Nullable[string] `json:"description,omitempty"` - Name *string `json:"name,omitempty"` - SecretJwtTemplate nullable.Nullable[struct { - Role string `json:"role"` - }] `json:"secret_jwt_template,omitempty"` + Description nullable.Nullable[string] `json:"description,omitempty"` + Name *string `json:"name,omitempty"` + SecretJwtTemplate nullable.Nullable[map[string]interface{}] `json:"secret_jwt_template,omitempty"` } // UpdateAuthConfigBody defines model for UpdateAuthConfigBody. @@ -3035,6 +3046,12 @@ type V1AuthorizeUserParams struct { ResponseMode *string `form:"response_mode,omitempty" json:"response_mode,omitempty"` CodeChallenge *string `form:"code_challenge,omitempty" json:"code_challenge,omitempty"` CodeChallengeMethod *V1AuthorizeUserParamsCodeChallengeMethod `form:"code_challenge_method,omitempty" json:"code_challenge_method,omitempty"` + + // OrganizationSlug Organization slug + OrganizationSlug *string `form:"organization_slug,omitempty" json:"organization_slug,omitempty"` + + // Resource Resource indicator for MCP (Model Context Protocol) clients + Resource *V1AuthorizeUserParamsResource `form:"resource,omitempty" json:"resource,omitempty"` } // V1AuthorizeUserParamsResponseType defines parameters for V1AuthorizeUser. @@ -3043,6 +3060,9 @@ type V1AuthorizeUserParamsResponseType string // V1AuthorizeUserParamsCodeChallengeMethod defines parameters for V1AuthorizeUser. type V1AuthorizeUserParamsCodeChallengeMethod string +// V1AuthorizeUserParamsResource defines parameters for V1AuthorizeUser. +type V1AuthorizeUserParamsResource string + // V1OauthAuthorizeProjectClaimParams defines parameters for V1OauthAuthorizeProjectClaim. type V1OauthAuthorizeProjectClaimParams struct { // ProjectRef Project ref diff --git a/pkg/config/auth.go b/pkg/config/auth.go index eff9d8070..2b0e155d7 100644 --- a/pkg/config/auth.go +++ b/pkg/config/auth.go @@ -84,6 +84,7 @@ type ( EnableAnonymousSignIns bool `toml:"enable_anonymous_sign_ins"` MinimumPasswordLength uint `toml:"minimum_password_length"` PasswordRequirements PasswordRequirements `toml:"password_requirements"` + SigningKeysPath string `toml:"signing_keys_path"` RateLimit rateLimit `toml:"rate_limit"` Captcha *captcha `toml:"captcha"` diff --git a/pkg/config/config.go b/pkg/config/config.go index 83f8f22a3..d96cc3952 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -29,6 +29,7 @@ import ( "github.com/go-viper/mapstructure/v2" "github.com/golang-jwt/jwt/v5" "github.com/joho/godotenv" + "github.com/spf13/afero" "github.com/spf13/viper" "github.com/supabase/cli/pkg/cast" "github.com/supabase/cli/pkg/fetcher" @@ -707,6 +708,10 @@ func (c *baseConfig) resolve(builder pathBuilder, fsys fs.FS) error { } c.Storage.Buckets[name] = bucket } + // Resolve signing keys path for cross-platform compatibility + if len(c.Auth.SigningKeysPath) > 0 && !filepath.IsAbs(c.Auth.SigningKeysPath) { + c.Auth.SigningKeysPath = filepath.Join(builder.SupabaseDirPath, c.Auth.SigningKeysPath) + } // Resolve functions config for slug, function := range c.Functions { if len(function.Entrypoint) == 0 { @@ -1376,16 +1381,28 @@ func (tpa *thirdParty) IssuerURL() string { return "" } +type ( + remoteJWKS struct { + Keys []json.RawMessage `json:"keys"` + } + + oidcConfiguration struct { + JWKSURI string `json:"jwks_uri"` + } + + secretJWK struct { + KeyType string `json:"kty"` + KeyBase64URL string `json:"k"` + } +) + // 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"` - } +func (a *auth) ResolveJWKS(ctx context.Context, fsys afero.Fs) (string, error) { + var jwks remoteJWKS - issuerURL := a.ThirdParty.IssuerURL() - if issuerURL != "" { + if issuerURL := a.ThirdParty.IssuerURL(); issuerURL != "" { discoveryURL := issuerURL + "/.well-known/openid-configuration" t := &http.Client{Timeout: 10 * time.Second} @@ -1400,10 +1417,6 @@ func (a *auth) ResolveJWKS(ctx context.Context) (string, error) { return "", err } - type oidcConfiguration struct { - JWKSURI string `json:"jwks_uri"` - } - oidcConfig, err := fetcher.ParseJSON[oidcConfiguration](resp.Body) if err != nil { return "", err @@ -1424,10 +1437,6 @@ func (a *auth) ResolveJWKS(ctx context.Context) (string, error) { return "", err } - type remoteJWKS struct { - Keys []json.RawMessage `json:"keys"` - } - rJWKS, err := fetcher.ParseJSON[remoteJWKS](resp.Body) if err != nil { return "", err @@ -1437,24 +1446,35 @@ func (a *auth) ResolveJWKS(ctx context.Context) (string, error) { 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 + jwks.Keys = append(jwks.Keys, rJWKS.Keys...) } - var secretJWK struct { - KeyType string `json:"kty"` - KeyBase64URL string `json:"k"` - } + // If SIGNING_KEYS_PATH is provided, read from file + if len(a.SigningKeysPath) > 0 { + f, err := fsys.Open(a.SigningKeysPath) + if err != nil { + return "", errors.Errorf("failed to read signing key: %w", err) + } + jwtKeysArray, err := fetcher.ParseJSON[[]json.RawMessage](f) + if err != nil { + return "", err + } + jwks.Keys = append(jwks.Keys, jwtKeysArray...) + } else { + // Fallback to JWT_SECRET for backward compatibility + jwtSecret := secretJWK{ + KeyType: "oct", + KeyBase64URL: base64.RawURLEncoding.EncodeToString([]byte(a.JwtSecret.Value)), + } - secretJWK.KeyType = "oct" - secretJWK.KeyBase64URL = base64.RawURLEncoding.EncodeToString([]byte(a.JwtSecret.Value)) + secretJWKEncoded, err := json.Marshal(&jwtSecret) + if err != nil { + return "", errors.Errorf("failed to marshal secret jwk: %w", err) + } - 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)) } - 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) diff --git a/pkg/config/templates/Dockerfile b/pkg/config/templates/Dockerfile index ae8bbfcf8..12c6968f4 100644 --- a/pkg/config/templates/Dockerfile +++ b/pkg/config/templates/Dockerfile @@ -1,18 +1,18 @@ # Exposed for updates by .github/dependabot.yml -FROM supabase/postgres:17.4.1.059 AS pg +FROM supabase/postgres:17.4.1.067 AS pg # Append to ServiceImages when adding new dependencies below FROM library/kong:2.8.1 AS kong FROM axllent/mailpit:v1.22.3 AS mailpit FROM postgrest/postgrest:v12.2.12 AS postgrest -FROM supabase/postgres-meta:v0.91.1 AS pgmeta -FROM supabase/studio:2025.07.21-sha-88dca02 AS studio +FROM supabase/postgres-meta:v0.91.3 AS pgmeta +FROM supabase/studio:2025.07.28-sha-578b707 AS studio FROM darthsim/imgproxy:v3.8.0 AS imgproxy -FROM supabase/edge-runtime:v1.68.0 AS edgeruntime +FROM supabase/edge-runtime:v1.68.2 AS edgeruntime FROM timberio/vector:0.28.1-alpine AS vector FROM supabase/supavisor:2.5.7 AS supavisor FROM supabase/gotrue:v2.177.0 AS gotrue -FROM supabase/realtime:v2.41.3 AS realtime -FROM supabase/storage-api:v1.25.9 AS storage +FROM supabase/realtime:v2.41.9 AS realtime +FROM supabase/storage-api:v1.25.12 AS storage FROM supabase/logflare:1.14.2 AS logflare # Append to JobImages when adding new dependencies below FROM supabase/pgadmin-schema-diff:cli-0.0.5 AS differ diff --git a/pkg/config/templates/config.toml b/pkg/config/templates/config.toml index 429cbd689..fea0c284d 100644 --- a/pkg/config/templates/config.toml +++ b/pkg/config/templates/config.toml @@ -122,6 +122,8 @@ site_url = "http://127.0.0.1:3000" additional_redirect_urls = ["https://127.0.0.1:3000"] # How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 (1 week). jwt_expiry = 3600 +# Path to JWT signing key. DO NOT commit your signing keys file to git. +# signing_keys_path = "./signing_keys.json" # If disabled, the refresh token will never expire. enable_refresh_token_rotation = true # Allows refresh tokens to be reused after expiry, up to the specified interval in seconds. diff --git a/pkg/config/testdata/config.toml b/pkg/config/testdata/config.toml index 65ed7cdca..37b515865 100644 --- a/pkg/config/testdata/config.toml +++ b/pkg/config/testdata/config.toml @@ -122,6 +122,8 @@ site_url = "http://127.0.0.1:3000" additional_redirect_urls = ["https://127.0.0.1:3000", "env(AUTH_CALLBACK_URL)"] # How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 (1 week). jwt_expiry = 3600 +# Path to JWT signing key. DO NOT commit your signing keys file to git. +signing_keys_path = "./signing_keys.json" # If disabled, the refresh token will never expire. enable_refresh_token_rotation = true # Allows refresh tokens to be reused after expiry, up to the specified interval in seconds. diff --git a/pkg/fetcher/gateway.go b/pkg/fetcher/gateway.go new file mode 100644 index 000000000..86c6735d4 --- /dev/null +++ b/pkg/fetcher/gateway.go @@ -0,0 +1,28 @@ +package fetcher + +import ( + "net/http" + "strings" + "time" +) + +func NewServiceGateway(server, token string, overrides ...FetcherOption) *Fetcher { + opts := append([]FetcherOption{ + WithHTTPClient(&http.Client{ + Timeout: 10 * time.Second, + }), + withAuthToken(token), + WithExpectedStatus(http.StatusOK), + }, overrides...) + return NewFetcher(server, opts...) +} + +func withAuthToken(token string) FetcherOption { + if strings.HasPrefix(token, "sb_") { + header := func(req *http.Request) { + req.Header.Add("apikey", token) + } + return WithRequestEditor(header) + } + return WithBearerToken(token) +} diff --git a/pkg/go.mod b/pkg/go.mod index 1b44d056c..6af4557b6 100644 --- a/pkg/go.mod +++ b/pkg/go.mod @@ -26,7 +26,7 @@ require ( github.com/stretchr/testify v1.10.0 github.com/tidwall/jsonc v0.3.2 golang.org/x/mod v0.26.0 - google.golang.org/grpc v1.74.0 + google.golang.org/grpc v1.74.2 ) require ( diff --git a/pkg/go.sum b/pkg/go.sum index 981620239..f347a7ff0 100644 --- a/pkg/go.sum +++ b/pkg/go.sum @@ -292,8 +292,8 @@ golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/grpc v1.74.0 h1:sxRSkyLxlceWQiqDofxDot3d4u7DyoHPc7SBXMj8gGY= -google.golang.org/grpc v1.74.0/go.mod h1:NZUaK8dAMUfzhK6uxZ+9511LtOrk73UGWOFoNvz7z+s= +google.golang.org/grpc v1.74.2 h1:WoosgB65DlWVC9FqI82dGsZhWFNBSLjQ84bjROOpMu4= +google.golang.org/grpc v1.74.2/go.mod h1:CtQ+BGjaAIXHs/5YS3i473GqwBBa1zGQNevxdeBEXrM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= diff --git a/pkg/pgxv5/connect.go b/pkg/pgxv5/connect.go index 218b1b775..daacb5fc0 100644 --- a/pkg/pgxv5/connect.go +++ b/pkg/pgxv5/connect.go @@ -4,12 +4,18 @@ import ( "context" "fmt" "os" + "strings" "github.com/go-errors/errors" "github.com/jackc/pgconn" "github.com/jackc/pgx/v4" ) +const ( + CLI_LOGIN_ROLE = "cli_login_postgres" + SET_SESSION_ROLE = "SET SESSION ROLE postgres" +) + // Extends pgx.Connect with support for programmatically overriding parsed config func Connect(ctx context.Context, connString string, options ...func(*pgx.ConnConfig)) (*pgx.Conn, error) { // Parse connection url @@ -20,6 +26,11 @@ func Connect(ctx context.Context, connString string, options ...func(*pgx.ConnCo config.OnNotice = func(pc *pgconn.PgConn, n *pgconn.Notice) { fmt.Fprintf(os.Stderr, "%s (%s): %s\n", n.Severity, n.Code, n.Message) } + if strings.HasPrefix(config.User, CLI_LOGIN_ROLE) { + config.AfterConnect = func(ctx context.Context, pgconn *pgconn.PgConn) error { + return pgconn.Exec(ctx, SET_SESSION_ROLE).Close() + } + } // Apply config overrides for _, op := range options { op(config)