diff --git a/internal/gen/bearerjwt/bearerjwt_test.go b/internal/gen/bearerjwt/bearerjwt_test.go index fe329b649..f6a5d4e1d 100644 --- a/internal/gen/bearerjwt/bearerjwt_test.go +++ b/internal/gen/bearerjwt/bearerjwt_test.go @@ -96,13 +96,17 @@ func TestGenerateToken(t *testing.T) { }) t.Run("accepts signing key from stdin", func(t *testing.T) { - utils.Config.Auth.SigningKeysPath = "" - utils.Config.Auth.SigningKeys = nil claims := config.CustomClaims{ Role: "service_role", } - // Setup in-memory fs + // Setup in-memory fs with minimal config (explicitly set signing_keys_path to empty to override template default) fsys := afero.NewMemMapFs() + require.NoError(t, utils.WriteFile("supabase/config.toml", []byte(` +project_id = "test" + +[auth] +signing_keys_path = "" +`), fsys)) testKey, err := json.Marshal(privateKeyRSA) require.NoError(t, err) t.Cleanup(fstest.MockStdin(t, string(testKey))) @@ -128,8 +132,14 @@ func TestGenerateToken(t *testing.T) { t.Run("throws error on invalid key", func(t *testing.T) { claims := jwt.MapClaims{} - // Setup in-memory fs + // Setup in-memory fs with minimal config (explicitly set signing_keys_path to empty to override template default) fsys := afero.NewMemMapFs() + require.NoError(t, utils.WriteFile("supabase/config.toml", []byte(` +project_id = "test" + +[auth] +signing_keys_path = "" +`), fsys)) t.Cleanup(fstest.MockStdin(t, "")) // Run test err = Run(context.Background(), claims, io.Discard, fsys) diff --git a/internal/init/init.go b/internal/init/init.go index 01c70bcaf..ff22ade1b 100644 --- a/internal/init/init.go +++ b/internal/init/init.go @@ -12,7 +12,9 @@ import ( "github.com/go-errors/errors" "github.com/spf13/afero" + "github.com/supabase/cli/internal/gen/signingkeys" "github.com/supabase/cli/internal/utils" + "github.com/supabase/cli/pkg/config" "github.com/tidwall/jsonc" ) @@ -34,7 +36,12 @@ var ( ) func Run(ctx context.Context, fsys afero.Fs, interactive bool, params utils.InitParams) error { - // 1. Write `config.toml`. + // 1. Generate default signing key if it doesn't exist. + if err := generateDefaultSigningKey(fsys); err != nil { + fmt.Fprintln(os.Stderr, utils.Yellow("Warning:"), "Failed to generate signing key:", err) + } + + // 2. Write `config.toml`. if err := utils.InitConfig(params, fsys); err != nil { if errors.Is(err, os.ErrExist) { utils.CmdSuggestion = fmt.Sprintf("Run %s to overwrite existing config file.", utils.Aqua("supabase init --force")) @@ -42,14 +49,14 @@ func Run(ctx context.Context, fsys afero.Fs, interactive bool, params utils.Init return err } - // 2. Append to `.gitignore`. + // 3. Append to `.gitignore`. if utils.IsGitRepo() { if err := updateGitIgnore(utils.GitIgnorePath, fsys); err != nil { return err } } - // 3. Prompt for IDE settings in interactive mode. + // 4. Prompt for IDE settings in interactive mode. if interactive { if err := PromptForIDESettings(ctx, fsys); err != nil { return err @@ -170,3 +177,39 @@ func WriteIntelliJConfig(fsys afero.Fs) error { fmt.Println("Please install the Deno plugin for IntelliJ: " + utils.Bold("https://plugins.jetbrains.com/plugin/14382-deno")) return nil } + +func generateDefaultSigningKey(fsys afero.Fs) error { + signingKeysPath := filepath.Join(utils.SupabaseDirPath, "signing_keys.json") + + exists, err := afero.Exists(fsys, signingKeysPath) + if err != nil { + return errors.Errorf("failed to check signing key file: %w", err) + } + if exists { + return nil + } + + privateJWK, err := signingkeys.GeneratePrivateKey(config.AlgRS256) + if err != nil { + return errors.Errorf("failed to generate signing key: %w", err) + } + + if err := utils.MkdirIfNotExistFS(fsys, utils.SupabaseDirPath); err != nil { + return errors.Errorf("failed to create supabase directory: %w", err) + } + + jwkArray := []config.JWK{*privateJWK} + f, err := fsys.OpenFile(signingKeysPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600) + if err != nil { + return errors.Errorf("failed to create signing key file: %w", err) + } + defer f.Close() + + enc := json.NewEncoder(f) + enc.SetIndent("", " ") + if err := enc.Encode(jwkArray); err != nil { + return errors.Errorf("failed to encode signing key: %w", err) + } + + return nil +} diff --git a/internal/init/init_test.go b/internal/init/init_test.go index 3d54feb58..56b4f208a 100644 --- a/internal/init/init_test.go +++ b/internal/init/init_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "os" + "path/filepath" "testing" "github.com/spf13/afero" @@ -11,6 +12,7 @@ import ( "github.com/stretchr/testify/require" "github.com/supabase/cli/internal/testing/fstest" "github.com/supabase/cli/internal/utils" + "github.com/supabase/cli/pkg/config" ) func TestInitCommand(t *testing.T) { @@ -24,6 +26,11 @@ func TestInitCommand(t *testing.T) { exists, err := afero.Exists(fsys, utils.ConfigPath) assert.NoError(t, err) assert.True(t, exists) + // Validate generated signing key + signingKeysPath := filepath.Join(utils.SupabaseDirPath, "signing_keys.json") + exists, err = afero.Exists(fsys, signingKeysPath) + assert.NoError(t, err) + assert.True(t, exists) // Validate generated .gitignore exists, err = afero.Exists(fsys, utils.GitIgnorePath) assert.NoError(t, err) @@ -197,3 +204,78 @@ func TestUpdateJsonFile(t *testing.T) { assert.ErrorContains(t, err, "operation not permitted") }) } + +func TestGenerateDefaultSigningKey(t *testing.T) { + signingKeysPath := filepath.Join(utils.SupabaseDirPath, "signing_keys.json") + + t.Run("generates signing key when file doesn't exist", func(t *testing.T) { + // Setup in-memory fs + fsys := afero.NewMemMapFs() + // Run test + assert.NoError(t, generateDefaultSigningKey(fsys)) + // Validate file exists + exists, err := afero.Exists(fsys, signingKeysPath) + assert.NoError(t, err) + assert.True(t, exists) + // Validate file contents + content, err := afero.ReadFile(fsys, signingKeysPath) + assert.NoError(t, err) + var jwkArray []config.JWK + assert.NoError(t, json.Unmarshal(content, &jwkArray)) + assert.Len(t, jwkArray, 1) + // Validate key structure + key := jwkArray[0] + assert.Equal(t, "RSA", key.KeyType) + assert.Equal(t, config.Algorithm("RS256"), key.Algorithm) + assert.NotEmpty(t, key.KeyID) + assert.NotEmpty(t, key.Modulus) + assert.NotEmpty(t, key.Exponent) + assert.NotEmpty(t, key.PrivateExponent) + }) + + t.Run("skips generation when file already exists", func(t *testing.T) { + // Setup in-memory fs with existing key file + fsys := afero.NewMemMapFs() + existingKey := []config.JWK{ + { + KeyType: "RSA", + KeyID: "existing-key-id", + Algorithm: config.AlgRS256, + }, + } + existingContent, err := json.Marshal(existingKey) + require.NoError(t, err) + require.NoError(t, utils.MkdirIfNotExistFS(fsys, utils.SupabaseDirPath)) + require.NoError(t, afero.WriteFile(fsys, signingKeysPath, existingContent, 0600)) + // Run test + assert.NoError(t, generateDefaultSigningKey(fsys)) + // Validate file wasn't modified + content, err := afero.ReadFile(fsys, signingKeysPath) + assert.NoError(t, err) + var jwkArray []config.JWK + assert.NoError(t, json.Unmarshal(content, &jwkArray)) + assert.Len(t, jwkArray, 1) + assert.Equal(t, "existing-key-id", jwkArray[0].KeyID) + }) + + t.Run("throws error on failure to create directory", func(t *testing.T) { + // Setup read-only fs + fsys := afero.NewReadOnlyFs(afero.NewMemMapFs()) + // Run test + err := generateDefaultSigningKey(fsys) + // Check error + assert.Error(t, err) + assert.ErrorContains(t, err, "failed to create supabase directory") + }) + + t.Run("throws error on failure to create file", func(t *testing.T) { + // Setup fs that denies file creation + // OpenErrorFs will fail when trying to open/create the file + fsys := &fstest.OpenErrorFs{DenyPath: signingKeysPath} + // Run test + err := generateDefaultSigningKey(fsys) + // Check error - OpenErrorFs will fail on OpenFile call + assert.Error(t, err) + assert.ErrorContains(t, err, "failed to create signing key file") + }) +} diff --git a/internal/init/templates/.gitignore b/internal/init/templates/.gitignore index ad9264f0b..435708ab0 100644 --- a/internal/init/templates/.gitignore +++ b/internal/init/templates/.gitignore @@ -1,6 +1,7 @@ # Supabase .branches .temp +signing_keys.json # dotenvx .env.keys diff --git a/pkg/config/config.go b/pkg/config/config.go index 6e9ef96bb..f02e6b223 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -882,10 +882,8 @@ func (c *config) Validate(fsys fs.FS) error { } } if len(c.Auth.SigningKeysPath) > 0 { - if f, err := fsys.Open(c.Auth.SigningKeysPath); err != nil { - return errors.Errorf("failed to read signing keys: %w", err) - } else if c.Auth.SigningKeys, err = fetcher.ParseJSON[[]JWK](f); err != nil { - return errors.Errorf("failed to decode signing keys: %w", err) + if err := c.loadSigningKeys(fsys); err != nil { + return err } } if err := c.Auth.Hook.validate(); err != nil { @@ -946,6 +944,26 @@ func (c *config) Validate(fsys fs.FS) error { return nil } +func (c *config) loadSigningKeys(fsys fs.FS) error { + f, err := fsys.Open(c.Auth.SigningKeysPath) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + fmt.Fprintf(os.Stderr, "WARN: signing keys file not found: %s - will be created during init\n", c.Auth.SigningKeysPath) + return nil + } + return fmt.Errorf("failed to read signing keys: %w", err) + } + defer f.Close() + + signingKeys, err := fetcher.ParseJSON[[]JWK](f) + if err != nil { + return fmt.Errorf("failed to decode signing keys: %w", err) + } + + c.Auth.SigningKeys = signingKeys + return nil +} + func assertEnvLoaded(s string) error { if matches := envPattern.FindStringSubmatch(s); len(matches) > 1 { fmt.Fprintln(os.Stderr, "WARN: environment variable is unset:", matches[1]) diff --git a/pkg/config/templates/config.toml b/pkg/config/templates/config.toml index f27e6064d..bd03eb745 100644 --- a/pkg/config/templates/config.toml +++ b/pkg/config/templates/config.toml @@ -153,7 +153,7 @@ jwt_expiry = 3600 # JWT issuer URL. If not set, defaults to the local API URL (http://127.0.0.1:/auth/v1). # jwt_issuer = "" # Path to JWT signing key. DO NOT commit your signing keys file to git. -# signing_keys_path = "./signing_keys.json" +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.