Skip to content

Commit 7e68a15

Browse files
committed
feat: asymmetric signed api keys
1 parent dcf9c82 commit 7e68a15

File tree

2 files changed

+225
-17
lines changed

2 files changed

+225
-17
lines changed

pkg/config/apikeys.go

Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
package config
2+
3+
import (
4+
"crypto"
5+
"crypto/ecdsa"
6+
"crypto/elliptic"
7+
"crypto/rsa"
8+
"encoding/base64"
9+
"fmt"
10+
"io/fs"
11+
"math/big"
12+
"os"
13+
"time"
14+
15+
"github.com/go-errors/errors"
16+
"github.com/golang-jwt/jwt/v5"
17+
"github.com/google/uuid"
18+
"github.com/supabase/cli/pkg/fetcher"
19+
)
20+
21+
// generateAPIKeys generates JWT tokens using the appropriate signing method
22+
func (c *config) generateAPIKeys(fsys fs.FS) error {
23+
// Load signing keys if path is provided
24+
var signingKeys []JWK
25+
if len(c.Auth.SigningKeysPath) > 0 {
26+
f, err := fsys.Open(c.Auth.SigningKeysPath)
27+
if err != nil {
28+
// Ignore missing signing key path - will fall back to symmetric signing
29+
fmt.Fprintf(os.Stderr, "Warning: Failed to generate asymmetric keys, falling back to symmetric: %v\n", err)
30+
} else {
31+
parsedKeys, _ := fetcher.ParseJSON[[]JWK](f)
32+
signingKeys = parsedKeys
33+
c.Auth.SigningKeys = signingKeys // Store for later use
34+
}
35+
}
36+
37+
// Generate anon key if not provided
38+
if len(c.Auth.AnonKey.Value) == 0 {
39+
if len(signingKeys) > 0 {
40+
if signed, err := generateAsymmetricJWT(signingKeys[0], "anon"); err != nil {
41+
// Fall back to symmetric signing if asymmetric fails
42+
fmt.Fprintf(os.Stderr, "Warning: Failed to generate asymmetric anon key, falling back to symmetric: %v\n", err)
43+
c.Auth.AnonKey.Value = generateSymmetricJWT(c.Auth.JwtSecret.Value, "anon")
44+
} else {
45+
c.Auth.AnonKey.Value = signed
46+
}
47+
} else {
48+
c.Auth.AnonKey.Value = generateSymmetricJWT(c.Auth.JwtSecret.Value, "anon")
49+
}
50+
}
51+
52+
// Generate service_role key if not provided
53+
if len(c.Auth.ServiceRoleKey.Value) == 0 {
54+
if len(signingKeys) > 0 {
55+
if signed, err := generateAsymmetricJWT(signingKeys[0], "service_role"); err != nil {
56+
// Fall back to symmetric signing if asymmetric fails
57+
fmt.Fprintf(os.Stderr, "Warning: Failed to generate asymmetric service_role key, falling back to symmetric: %v\n", err)
58+
c.Auth.ServiceRoleKey.Value = generateSymmetricJWT(c.Auth.JwtSecret.Value, "service_role")
59+
} else {
60+
c.Auth.ServiceRoleKey.Value = signed
61+
}
62+
} else {
63+
c.Auth.ServiceRoleKey.Value = generateSymmetricJWT(c.Auth.JwtSecret.Value, "service_role")
64+
}
65+
}
66+
67+
return nil
68+
}
69+
70+
// createJWTClaims creates standardized JWT claims for API keys
71+
func createJWTClaims(role string) CustomClaims {
72+
now := time.Now()
73+
return CustomClaims{
74+
Issuer: "supabase-demo",
75+
Role: role,
76+
RegisteredClaims: jwt.RegisteredClaims{
77+
ExpiresAt: jwt.NewNumericDate(now.Add(time.Hour * 24 * 365 * 10)), // 10 years
78+
},
79+
}
80+
}
81+
82+
// generateSymmetricJWT generates a JWT using symmetric signing with jwt_secret
83+
func generateSymmetricJWT(jwtSecret, role string) string {
84+
claims := createJWTClaims(role)
85+
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
86+
87+
signed, err := token.SignedString([]byte(jwtSecret))
88+
if err != nil {
89+
// This should not happen if JWT secret is valid, but return empty string as fallback
90+
fmt.Fprintf(os.Stderr, "Error: Failed to generate %s key: %v\n", role, err)
91+
return ""
92+
}
93+
return signed
94+
}
95+
96+
// generateAsymmetricJWT generates a JWT token signed with the provided JWK private key
97+
func generateAsymmetricJWT(jwk JWK, role string) (string, error) {
98+
privateKey, err := jwkToPrivateKey(jwk)
99+
if err != nil {
100+
return "", errors.Errorf("failed to convert JWK to private key: %w", err)
101+
}
102+
103+
claims := createJWTClaims(role)
104+
105+
// Determine signing method based on algorithm
106+
var token *jwt.Token
107+
switch jwk.Algorithm {
108+
case "RS256":
109+
token = jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
110+
case "ES256":
111+
token = jwt.NewWithClaims(jwt.SigningMethodES256, claims)
112+
default:
113+
return "", errors.Errorf("unsupported algorithm: %s", jwk.Algorithm)
114+
}
115+
116+
if jwk.KeyID != uuid.Nil {
117+
token.Header["kid"] = jwk.KeyID.String()
118+
}
119+
120+
tokenString, err := token.SignedString(privateKey)
121+
if err != nil {
122+
return "", errors.Errorf("failed to sign JWT: %w", err)
123+
}
124+
125+
return tokenString, nil
126+
}
127+
128+
// jwkToPrivateKey converts a JWK to a crypto.PrivateKey
129+
func jwkToPrivateKey(jwk JWK) (crypto.PrivateKey, error) {
130+
switch jwk.KeyType {
131+
case "RSA":
132+
return jwkToRSAPrivateKey(jwk)
133+
case "EC":
134+
return jwkToECDSAPrivateKey(jwk)
135+
default:
136+
return nil, errors.Errorf("unsupported key type: %s", jwk.KeyType)
137+
}
138+
}
139+
140+
// jwkToRSAPrivateKey converts a JWK to an RSA private key
141+
func jwkToRSAPrivateKey(jwk JWK) (*rsa.PrivateKey, error) {
142+
nBytes, err := base64.RawURLEncoding.DecodeString(jwk.Modulus)
143+
if err != nil {
144+
return nil, errors.Errorf("failed to decode modulus: %w", err)
145+
}
146+
n := new(big.Int).SetBytes(nBytes)
147+
148+
eBytes, err := base64.RawURLEncoding.DecodeString(jwk.Exponent)
149+
if err != nil {
150+
return nil, errors.Errorf("failed to decode exponent: %w", err)
151+
}
152+
e := int(new(big.Int).SetBytes(eBytes).Int64())
153+
154+
dBytes, err := base64.RawURLEncoding.DecodeString(jwk.PrivateExponent)
155+
if err != nil {
156+
return nil, errors.Errorf("failed to decode private exponent: %w", err)
157+
}
158+
d := new(big.Int).SetBytes(dBytes)
159+
160+
pBytes, err := base64.RawURLEncoding.DecodeString(jwk.FirstPrimeFactor)
161+
if err != nil {
162+
return nil, errors.Errorf("failed to decode first prime factor: %w", err)
163+
}
164+
p := new(big.Int).SetBytes(pBytes)
165+
166+
qBytes, err := base64.RawURLEncoding.DecodeString(jwk.SecondPrimeFactor)
167+
if err != nil {
168+
return nil, errors.Errorf("failed to decode second prime factor: %w", err)
169+
}
170+
q := new(big.Int).SetBytes(qBytes)
171+
172+
return &rsa.PrivateKey{
173+
PublicKey: rsa.PublicKey{N: n, E: e},
174+
D: d,
175+
Primes: []*big.Int{p, q},
176+
}, nil
177+
}
178+
179+
// jwkToECDSAPrivateKey converts a JWK to an ECDSA private key
180+
func jwkToECDSAPrivateKey(jwk JWK) (*ecdsa.PrivateKey, error) {
181+
// Only support P-256 curve for ES256
182+
if jwk.Curve != "P-256" {
183+
return nil, errors.Errorf("unsupported curve: %s", jwk.Curve)
184+
}
185+
186+
xBytes, err := base64.RawURLEncoding.DecodeString(jwk.X)
187+
if err != nil {
188+
return nil, errors.Errorf("failed to decode x coordinate: %w", err)
189+
}
190+
x := new(big.Int).SetBytes(xBytes)
191+
192+
yBytes, err := base64.RawURLEncoding.DecodeString(jwk.Y)
193+
if err != nil {
194+
return nil, errors.Errorf("failed to decode y coordinate: %w", err)
195+
}
196+
y := new(big.Int).SetBytes(yBytes)
197+
198+
dBytes, err := base64.RawURLEncoding.DecodeString(jwk.PrivateExponent)
199+
if err != nil {
200+
return nil, errors.Errorf("failed to decode private key: %w", err)
201+
}
202+
d := new(big.Int).SetBytes(dBytes)
203+
204+
return &ecdsa.PrivateKey{
205+
PublicKey: ecdsa.PublicKey{
206+
Curve: elliptic.P256(),
207+
X: x,
208+
Y: y,
209+
},
210+
D: d,
211+
}, nil
212+
}

pkg/config/config.go

Lines changed: 13 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -589,22 +589,6 @@ func (c *config) Load(path string, fsys fs.FS) error {
589589
if len(c.Auth.JwtSecret.Value) < 16 {
590590
return errors.Errorf("Invalid config for auth.jwt_secret. Must be at least 16 characters")
591591
}
592-
if len(c.Auth.AnonKey.Value) == 0 {
593-
anonToken := CustomClaims{Role: "anon"}.NewToken()
594-
if signed, err := anonToken.SignedString([]byte(c.Auth.JwtSecret.Value)); err != nil {
595-
return errors.Errorf("failed to generate anon key: %w", err)
596-
} else {
597-
c.Auth.AnonKey.Value = signed
598-
}
599-
}
600-
if len(c.Auth.ServiceRoleKey.Value) == 0 {
601-
anonToken := CustomClaims{Role: "service_role"}.NewToken()
602-
if signed, err := anonToken.SignedString([]byte(c.Auth.JwtSecret.Value)); err != nil {
603-
return errors.Errorf("failed to generate service_role key: %w", err)
604-
} else {
605-
c.Auth.ServiceRoleKey.Value = signed
606-
}
607-
}
608592
// TODO: move linked pooler connection string elsewhere
609593
if connString, err := fs.ReadFile(fsys, builder.PoolerUrlPath); err == nil && len(connString) > 0 {
610594
c.Db.Pooler.ConnectionString = string(connString)
@@ -669,7 +653,19 @@ func (c *config) Load(path string, fsys fs.FS) error {
669653
if err := c.resolve(builder, fsys); err != nil {
670654
return err
671655
}
672-
return c.Validate(fsys)
656+
657+
validateErr := c.Validate(fsys)
658+
if validateErr != nil {
659+
return validateErr
660+
}
661+
662+
// Generate API keys (anon/service role keys) after paths are resolved & validated
663+
// as we might need to use user-provided signing keys
664+
if err := c.generateAPIKeys(fsys); err != nil {
665+
return err
666+
}
667+
668+
return nil
673669
}
674670

675671
func VersionCompare(a, b string) int {

0 commit comments

Comments
 (0)