Skip to content

Commit 63a8891

Browse files
feat: support jwt signing keys for local auth (#3841)
* feat: add `generate-key` cmd for generating jwt signing keys * chore: default formatting as json * feat: add jwt_keys env variable to auth if configured * chore: `go generate` * chore: separate block for jwt config * chore: rename cmd to `signing-key` * feat: include precomputed fields for rsa key * chore: gofmt * feat: use file-based approach for signing keys * feat: use file-based approach for JWT signing keys * chore: address PR comments * chore: update command short text * fix: unit tests * fix: allow appending to new key * chore: confirm before overwriting existing key * chore: show suggestion only for stdout * chore: skip warning if file is already ignored * chore: minor refactor * chore: add config to test template --------- Co-authored-by: Qiao Han <[email protected]>
1 parent cfe8808 commit 63a8891

File tree

9 files changed

+459
-27
lines changed

9 files changed

+459
-27
lines changed

cmd/gen.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"github.com/spf13/afero"
1010
"github.com/spf13/cobra"
1111
"github.com/supabase/cli/internal/gen/keys"
12+
"github.com/supabase/cli/internal/gen/signingkeys"
1213
"github.com/supabase/cli/internal/gen/types"
1314
"github.com/supabase/cli/internal/utils"
1415
"github.com/supabase/cli/internal/utils/flags"
@@ -93,6 +94,26 @@ var (
9394
supabase gen types --project-id abc-def-123 --schema public --schema private
9495
supabase gen types --db-url 'postgresql://...' --schema public --schema auth`,
9596
}
97+
98+
algorithm = utils.EnumFlag{
99+
Allowed: signingkeys.GetSupportedAlgorithms(),
100+
Value: string(signingkeys.AlgES256),
101+
}
102+
appendKeys bool
103+
104+
genSigningKeyCmd = &cobra.Command{
105+
Use: "signing-key",
106+
Short: "Generate a JWT signing key",
107+
Long: `Securely generate a private JWT signing key for use in the CLI or to import in the dashboard.
108+
109+
Supported algorithms:
110+
ES256 - ECDSA with P-256 curve and SHA-256 (recommended)
111+
RS256 - RSA with SHA-256
112+
`,
113+
RunE: func(cmd *cobra.Command, args []string) error {
114+
return signingkeys.Run(cmd.Context(), algorithm.Value, appendKeys, afero.NewOsFs())
115+
},
116+
}
96117
)
97118

98119
func init() {
@@ -111,5 +132,9 @@ func init() {
111132
keyFlags.StringVar(&flags.ProjectRef, "project-ref", "", "Project ref of the Supabase project.")
112133
keyFlags.StringSliceVar(&override, "override-name", []string{}, "Override specific variable names.")
113134
genCmd.AddCommand(genKeysCmd)
135+
signingKeyFlags := genSigningKeyCmd.Flags()
136+
signingKeyFlags.Var(&algorithm, "algorithm", "Algorithm for signing key generation.")
137+
signingKeyFlags.BoolVar(&appendKeys, "append", false, "Append new key to existing keys file instead of overwriting.")
138+
genCmd.AddCommand(genSigningKeyCmd)
114139
rootCmd.AddCommand(genCmd)
115140
}
Lines changed: 249 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,249 @@
1+
package signingkeys
2+
3+
import (
4+
"context"
5+
"crypto/ecdsa"
6+
"crypto/elliptic"
7+
"crypto/rand"
8+
"crypto/rsa"
9+
"encoding/base64"
10+
"encoding/json"
11+
"fmt"
12+
"io"
13+
"math/big"
14+
"os"
15+
"path/filepath"
16+
17+
"github.com/go-errors/errors"
18+
"github.com/google/uuid"
19+
"github.com/spf13/afero"
20+
"github.com/supabase/cli/internal/utils"
21+
"github.com/supabase/cli/internal/utils/flags"
22+
"github.com/supabase/cli/pkg/cast"
23+
)
24+
25+
type Algorithm string
26+
27+
const (
28+
AlgRS256 Algorithm = "RS256"
29+
AlgES256 Algorithm = "ES256"
30+
)
31+
32+
type JWK struct {
33+
KeyType string `json:"kty"`
34+
KeyID string `json:"kid,omitempty"`
35+
Use string `json:"use,omitempty"`
36+
KeyOps []string `json:"key_ops,omitempty"`
37+
Algorithm string `json:"alg,omitempty"`
38+
Extractable *bool `json:"ext,omitempty"`
39+
// RSA specific fields
40+
Modulus string `json:"n,omitempty"`
41+
Exponent string `json:"e,omitempty"`
42+
// RSA private key fields
43+
PrivateExponent string `json:"d,omitempty"`
44+
FirstPrimeFactor string `json:"p,omitempty"`
45+
SecondPrimeFactor string `json:"q,omitempty"`
46+
FirstFactorCRTExponent string `json:"dp,omitempty"`
47+
SecondFactorCRTExponent string `json:"dq,omitempty"`
48+
FirstCRTCoefficient string `json:"qi,omitempty"`
49+
// EC specific fields
50+
Curve string `json:"crv,omitempty"`
51+
X string `json:"x,omitempty"`
52+
Y string `json:"y,omitempty"`
53+
}
54+
55+
type KeyPair struct {
56+
PublicKey JWK
57+
PrivateKey JWK
58+
}
59+
60+
// GenerateKeyPair generates a new key pair for the specified algorithm
61+
func GenerateKeyPair(alg Algorithm) (*KeyPair, error) {
62+
keyID := uuid.New().String()
63+
64+
switch alg {
65+
case AlgRS256:
66+
return generateRSAKeyPair(keyID)
67+
case AlgES256:
68+
return generateECDSAKeyPair(keyID)
69+
default:
70+
return nil, errors.Errorf("unsupported algorithm: %s", alg)
71+
}
72+
}
73+
74+
func generateRSAKeyPair(keyID string) (*KeyPair, error) {
75+
// Generate RSA key pair (2048 bits for RS256)
76+
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
77+
if err != nil {
78+
return nil, errors.Errorf("failed to generate RSA key: %w", err)
79+
}
80+
81+
publicKey := &privateKey.PublicKey
82+
83+
// Precompute CRT values for completeness
84+
privateKey.Precompute()
85+
86+
// Convert to JWK format
87+
privateJWK := JWK{
88+
KeyType: "RSA",
89+
KeyID: keyID,
90+
Use: "sig",
91+
KeyOps: []string{"sign", "verify"},
92+
Algorithm: "RS256",
93+
Extractable: cast.Ptr(true),
94+
Modulus: base64.RawURLEncoding.EncodeToString(publicKey.N.Bytes()),
95+
Exponent: base64.RawURLEncoding.EncodeToString(big.NewInt(int64(publicKey.E)).Bytes()),
96+
PrivateExponent: base64.RawURLEncoding.EncodeToString(privateKey.D.Bytes()),
97+
FirstPrimeFactor: base64.RawURLEncoding.EncodeToString(privateKey.Primes[0].Bytes()),
98+
SecondPrimeFactor: base64.RawURLEncoding.EncodeToString(privateKey.Primes[1].Bytes()),
99+
FirstFactorCRTExponent: base64.RawURLEncoding.EncodeToString(privateKey.Precomputed.Dp.Bytes()),
100+
SecondFactorCRTExponent: base64.RawURLEncoding.EncodeToString(privateKey.Precomputed.Dq.Bytes()),
101+
FirstCRTCoefficient: base64.RawURLEncoding.EncodeToString(privateKey.Precomputed.Qinv.Bytes()),
102+
}
103+
104+
publicJWK := JWK{
105+
KeyType: "RSA",
106+
KeyID: keyID,
107+
Use: "sig",
108+
KeyOps: []string{"verify"},
109+
Algorithm: "RS256",
110+
Extractable: cast.Ptr(true),
111+
Modulus: privateJWK.Modulus,
112+
Exponent: privateJWK.Exponent,
113+
}
114+
115+
return &KeyPair{
116+
PublicKey: publicJWK,
117+
PrivateKey: privateJWK,
118+
}, nil
119+
}
120+
121+
func generateECDSAKeyPair(keyID string) (*KeyPair, error) {
122+
// Generate ECDSA key pair (P-256 curve for ES256)
123+
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
124+
if err != nil {
125+
return nil, errors.Errorf("failed to generate ECDSA key: %w", err)
126+
}
127+
128+
publicKey := &privateKey.PublicKey
129+
130+
// Convert to JWK format
131+
privateJWK := JWK{
132+
KeyType: "EC",
133+
KeyID: keyID,
134+
Use: "sig",
135+
KeyOps: []string{"sign", "verify"},
136+
Algorithm: "ES256",
137+
Extractable: cast.Ptr(true),
138+
Curve: "P-256",
139+
X: base64.RawURLEncoding.EncodeToString(publicKey.X.Bytes()),
140+
Y: base64.RawURLEncoding.EncodeToString(publicKey.Y.Bytes()),
141+
PrivateExponent: base64.RawURLEncoding.EncodeToString(privateKey.D.Bytes()),
142+
}
143+
144+
publicJWK := JWK{
145+
KeyType: "EC",
146+
KeyID: keyID,
147+
Use: "sig",
148+
KeyOps: []string{"verify"},
149+
Algorithm: "ES256",
150+
Extractable: cast.Ptr(true),
151+
Curve: "P-256",
152+
X: privateJWK.X,
153+
Y: privateJWK.Y,
154+
}
155+
156+
return &KeyPair{
157+
PublicKey: publicJWK,
158+
PrivateKey: privateJWK,
159+
}, nil
160+
}
161+
162+
// Run generates a key pair and writes it to the specified file path
163+
func Run(ctx context.Context, algorithm string, appendMode bool, fsys afero.Fs) error {
164+
err := flags.LoadConfig(fsys)
165+
if err != nil {
166+
return err
167+
}
168+
outputPath := utils.Config.Auth.SigningKeysPath
169+
170+
// Generate key pair
171+
keyPair, err := GenerateKeyPair(Algorithm(algorithm))
172+
if err != nil {
173+
return err
174+
}
175+
176+
out := io.Writer(os.Stdout)
177+
var jwkArray []JWK
178+
if len(outputPath) > 0 {
179+
if err := utils.MkdirIfNotExistFS(fsys, filepath.Dir(outputPath)); err != nil {
180+
return err
181+
}
182+
f, err := fsys.OpenFile(outputPath, os.O_RDWR|os.O_CREATE, 0600)
183+
if err != nil {
184+
return errors.Errorf("failed to open signing key: %w", err)
185+
}
186+
defer f.Close()
187+
if appendMode {
188+
// Load existing key and reset file
189+
dec := json.NewDecoder(f)
190+
// Since a new file is empty, we must ignore EOF error
191+
if err := dec.Decode(&jwkArray); err != nil && !errors.Is(err, io.EOF) {
192+
return errors.Errorf("failed to decode signing key: %w", err)
193+
}
194+
if _, err = f.Seek(0, io.SeekStart); err != nil {
195+
return errors.Errorf("failed to seek signing key: %w", err)
196+
}
197+
} else if fi, err := f.Stat(); fi.Size() > 0 {
198+
if err != nil {
199+
fmt.Fprintln(utils.GetDebugLogger(), err)
200+
}
201+
label := fmt.Sprintf("Do you want to overwrite the existing %s file?", utils.Bold(outputPath))
202+
if shouldOverwrite, err := utils.NewConsole().PromptYesNo(ctx, label, true); err != nil {
203+
return err
204+
} else if !shouldOverwrite {
205+
return errors.New(context.Canceled)
206+
}
207+
if err := f.Truncate(0); err != nil {
208+
return errors.Errorf("failed to truncate signing key: %w", err)
209+
}
210+
}
211+
out = f
212+
}
213+
jwkArray = append(jwkArray, keyPair.PrivateKey)
214+
215+
// Write to file
216+
enc := json.NewEncoder(out)
217+
enc.SetIndent("", " ")
218+
if err := enc.Encode(jwkArray); err != nil {
219+
return errors.Errorf("failed to encode signing key: %w", err)
220+
}
221+
222+
if len(outputPath) == 0 {
223+
utils.CmdSuggestion = fmt.Sprintf(`
224+
To enable JWT signing keys in your local project:
225+
1. Save the generated key to %s
226+
2. Update your %s with the new keys path
227+
228+
[auth]
229+
signing_keys_path = "./signing_key.json"
230+
`, utils.Bold(filepath.Join(utils.SupabaseDirPath, "signing_key.json")), utils.Bold(utils.ConfigPath))
231+
return nil
232+
}
233+
234+
fmt.Fprintf(os.Stderr, "JWT signing key appended to: %s (now contains %d keys)\n", utils.Bold(outputPath), len(jwkArray))
235+
if len(jwkArray) == 1 {
236+
if ignored, err := utils.IsGitIgnored(outputPath); err != nil {
237+
fmt.Fprintln(utils.GetDebugLogger(), err)
238+
} else if !ignored {
239+
// Since the output path is user defined, we can't update the managed .gitignore file.
240+
fmt.Fprintln(os.Stderr, utils.Yellow("IMPORTANT:"), "Add your signing key path to .gitignore to prevent committing to version control.")
241+
}
242+
}
243+
return nil
244+
}
245+
246+
// GetSupportedAlgorithms returns a list of supported algorithms
247+
func GetSupportedAlgorithms() []string {
248+
return []string{string(AlgRS256), string(AlgES256)}
249+
}

0 commit comments

Comments
 (0)