Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 14 additions & 4 deletions internal/gen/bearerjwt/bearerjwt_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)))
Expand All @@ -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)
Expand Down
49 changes: 46 additions & 3 deletions internal/init/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand All @@ -34,22 +36,27 @@ 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.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since supabase init is no longer a prerequisite for supabase start, we cannot assume users will always run init before start. Let's handle signing key generation as part of config loading instead.

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"))
}
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
Expand Down Expand Up @@ -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
}
82 changes: 82 additions & 0 deletions internal/init/init_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@ import (
"context"
"encoding/json"
"os"
"path/filepath"
"testing"

"github.com/spf13/afero"
"github.com/stretchr/testify/assert"
"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) {
Expand All @@ -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)
Expand Down Expand Up @@ -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")
})
}
1 change: 1 addition & 0 deletions internal/init/templates/.gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# Supabase
.branches
.temp
signing_keys.json

# dotenvx
.env.keys
Expand Down
26 changes: 22 additions & 4 deletions pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this refactor is unnecessary because ParseJSON closes the reader automatically

return err
}
}
if err := c.Auth.Hook.validate(); err != nil {
Expand Down Expand Up @@ -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])
Expand Down
2 changes: 1 addition & 1 deletion pkg/config/templates/config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ jwt_expiry = 3600
# JWT issuer URL. If not set, defaults to the local API URL (http://127.0.0.1:<port>/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.
Expand Down
Loading