From 4360a47b0b67a628d40aad08b47b1d16df6125bd Mon Sep 17 00:00:00 2001 From: Qiao Han Date: Sat, 2 Aug 2025 01:23:30 +0800 Subject: [PATCH 1/8] feat: push signing keys to remote --- cmd/gen.go | 3 +- internal/gen/signingkeys/signingkeys.go | 61 +++++----------- internal/gen/signingkeys/signingkeys_test.go | 12 ++-- pkg/config/auth.go | 40 +++++++++++ pkg/config/config.go | 9 +++ pkg/config/updater.go | 75 ++++++++++++++++++++ 6 files changed, 149 insertions(+), 51 deletions(-) diff --git a/cmd/gen.go b/cmd/gen.go index 48a800f17..8893988d4 100644 --- a/cmd/gen.go +++ b/cmd/gen.go @@ -13,6 +13,7 @@ import ( "github.com/supabase/cli/internal/gen/types" "github.com/supabase/cli/internal/utils" "github.com/supabase/cli/internal/utils/flags" + "github.com/supabase/cli/pkg/config" ) var ( @@ -97,7 +98,7 @@ var ( algorithm = utils.EnumFlag{ Allowed: signingkeys.GetSupportedAlgorithms(), - Value: string(signingkeys.AlgES256), + Value: string(config.AlgES256), } appendKeys bool diff --git a/internal/gen/signingkeys/signingkeys.go b/internal/gen/signingkeys/signingkeys.go index 7394a99c4..10bdd646d 100644 --- a/internal/gen/signingkeys/signingkeys.go +++ b/internal/gen/signingkeys/signingkeys.go @@ -20,58 +20,29 @@ import ( "github.com/supabase/cli/internal/utils" "github.com/supabase/cli/internal/utils/flags" "github.com/supabase/cli/pkg/cast" + "github.com/supabase/cli/pkg/config" ) -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 + PublicKey config.JWK + PrivateKey config.JWK } // GenerateKeyPair generates a new key pair for the specified algorithm -func GenerateKeyPair(alg Algorithm) (*KeyPair, error) { - keyID := uuid.New().String() +func GenerateKeyPair(alg config.Algorithm) (*KeyPair, error) { + keyID := uuid.New() switch alg { - case AlgRS256: + case config.AlgRS256: return generateRSAKeyPair(keyID) - case AlgES256: + case config.AlgES256: return generateECDSAKeyPair(keyID) default: return nil, errors.Errorf("unsupported algorithm: %s", alg) } } -func generateRSAKeyPair(keyID string) (*KeyPair, error) { +func generateRSAKeyPair(keyID uuid.UUID) (*KeyPair, error) { // Generate RSA key pair (2048 bits for RS256) privateKey, err := rsa.GenerateKey(rand.Reader, 2048) if err != nil { @@ -84,7 +55,7 @@ func generateRSAKeyPair(keyID string) (*KeyPair, error) { privateKey.Precompute() // Convert to JWK format - privateJWK := JWK{ + privateJWK := config.JWK{ KeyType: "RSA", KeyID: keyID, Use: "sig", @@ -101,7 +72,7 @@ func generateRSAKeyPair(keyID string) (*KeyPair, error) { FirstCRTCoefficient: base64.RawURLEncoding.EncodeToString(privateKey.Precomputed.Qinv.Bytes()), } - publicJWK := JWK{ + publicJWK := config.JWK{ KeyType: "RSA", KeyID: keyID, Use: "sig", @@ -118,7 +89,7 @@ func generateRSAKeyPair(keyID string) (*KeyPair, error) { }, nil } -func generateECDSAKeyPair(keyID string) (*KeyPair, error) { +func generateECDSAKeyPair(keyID uuid.UUID) (*KeyPair, error) { // Generate ECDSA key pair (P-256 curve for ES256) privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) if err != nil { @@ -128,7 +99,7 @@ func generateECDSAKeyPair(keyID string) (*KeyPair, error) { publicKey := &privateKey.PublicKey // Convert to JWK format - privateJWK := JWK{ + privateJWK := config.JWK{ KeyType: "EC", KeyID: keyID, Use: "sig", @@ -141,7 +112,7 @@ func generateECDSAKeyPair(keyID string) (*KeyPair, error) { PrivateExponent: base64.RawURLEncoding.EncodeToString(privateKey.D.Bytes()), } - publicJWK := JWK{ + publicJWK := config.JWK{ KeyType: "EC", KeyID: keyID, Use: "sig", @@ -168,13 +139,13 @@ func Run(ctx context.Context, algorithm string, appendMode bool, fsys afero.Fs) outputPath := utils.Config.Auth.SigningKeysPath // Generate key pair - keyPair, err := GenerateKeyPair(Algorithm(algorithm)) + keyPair, err := GenerateKeyPair(config.Algorithm(algorithm)) if err != nil { return err } out := io.Writer(os.Stdout) - var jwkArray []JWK + var jwkArray []config.JWK if len(outputPath) > 0 { if err := utils.MkdirIfNotExistFS(fsys, filepath.Dir(outputPath)); err != nil { return err @@ -245,5 +216,5 @@ signing_keys_path = "./signing_key.json" // GetSupportedAlgorithms returns a list of supported algorithms func GetSupportedAlgorithms() []string { - return []string{string(AlgRS256), string(AlgES256)} + return []string{string(config.AlgRS256), string(config.AlgES256)} } diff --git a/internal/gen/signingkeys/signingkeys_test.go b/internal/gen/signingkeys/signingkeys_test.go index 51333887d..369811c07 100644 --- a/internal/gen/signingkeys/signingkeys_test.go +++ b/internal/gen/signingkeys/signingkeys_test.go @@ -2,22 +2,24 @@ package signingkeys import ( "testing" + + "github.com/supabase/cli/pkg/config" ) func TestGenerateKeyPair(t *testing.T) { tests := []struct { name string - algorithm Algorithm + algorithm config.Algorithm wantErr bool }{ { name: "RSA key generation", - algorithm: AlgRS256, + algorithm: config.AlgRS256, wantErr: false, }, { name: "ECDSA key generation", - algorithm: AlgES256, + algorithm: config.AlgES256, wantErr: false, }, { @@ -55,7 +57,7 @@ func TestGenerateKeyPair(t *testing.T) { // Algorithm-specific checks switch tt.algorithm { - case AlgRS256: + case config.AlgRS256: if keyPair.PublicKey.KeyType != "RSA" { t.Errorf("Expected RSA key type, got %s", keyPair.PublicKey.KeyType) } @@ -69,7 +71,7 @@ func TestGenerateKeyPair(t *testing.T) { if keyPair.PrivateKey.PrivateExponent == "" { t.Error("RSA private key missing private exponent") } - case AlgES256: + case config.AlgES256: if keyPair.PublicKey.KeyType != "EC" { t.Errorf("Expected EC key type, got %s", keyPair.PublicKey.KeyType) } diff --git a/pkg/config/auth.go b/pkg/config/auth.go index 2b0e155d7..ed0345e8a 100644 --- a/pkg/config/auth.go +++ b/pkg/config/auth.go @@ -6,6 +6,7 @@ import ( "time" "github.com/go-errors/errors" + "github.com/google/uuid" "github.com/oapi-codegen/nullable" openapi_types "github.com/oapi-codegen/runtime/types" v1API "github.com/supabase/cli/pkg/api" @@ -69,6 +70,44 @@ func (p *CaptchaProvider) UnmarshalText(text []byte) error { return nil } +type Algorithm string + +const ( + AlgRS256 Algorithm = "RS256" + AlgES256 Algorithm = "ES256" +) + +func (p *Algorithm) UnmarshalText(text []byte) error { + allowed := []Algorithm{AlgRS256, AlgES256} + if *p = Algorithm(text); !sliceContains(allowed, *p) { + return errors.Errorf("must be one of %v", allowed) + } + return nil +} + +type JWK struct { + KeyType string `json:"kty"` + KeyID uuid.UUID `json:"kid,omitempty"` + Use string `json:"use,omitempty"` + KeyOps []string `json:"key_ops,omitempty"` + Algorithm Algorithm `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 ( auth struct { Enabled bool `toml:"enabled"` @@ -85,6 +124,7 @@ type ( MinimumPasswordLength uint `toml:"minimum_password_length"` PasswordRequirements PasswordRequirements `toml:"password_requirements"` SigningKeysPath string `toml:"signing_keys_path"` + SigningKeys []JWK `toml:"-"` RateLimit rateLimit `toml:"rate_limit"` Captcha *captcha `toml:"captcha"` diff --git a/pkg/config/config.go b/pkg/config/config.go index d96cc3952..70c5ad571 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -851,6 +851,15 @@ func (c *config) Validate(fsys fs.FS) error { return err } } + if len(c.Auth.SigningKeysPath) > 0 { + if f, err := fsys.Open(c.Auth.SigningKeysPath); errors.Is(err, os.ErrNotExist) { + // Ignore missing signing key path on CI + } else if 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 signign keys: %w", err) + } + } if err := c.Auth.Hook.validate(); err != nil { return err } diff --git a/pkg/config/updater.go b/pkg/config/updater.go index 96b73efd2..9f6f43ac9 100644 --- a/pkg/config/updater.go +++ b/pkg/config/updater.go @@ -6,6 +6,7 @@ import ( "os" "github.com/go-errors/errors" + "github.com/google/uuid" v1API "github.com/supabase/cli/pkg/api" ) @@ -27,6 +28,9 @@ func (u *ConfigUpdater) UpdateRemoteConfig(ctx context.Context, remote baseConfi if err := u.UpdateAuthConfig(ctx, remote.ProjectId, remote.Auth, filter...); err != nil { return err } + if err := u.UpdateSigningKeys(ctx, remote.ProjectId, remote.Auth.SigningKeys, filter...); err != nil { + return err + } if err := u.UpdateStorageConfig(ctx, remote.ProjectId, remote.Storage, filter...); err != nil { return err } @@ -163,6 +167,77 @@ func (u *ConfigUpdater) UpdateAuthConfig(ctx context.Context, projectRef string, return nil } +func (u *ConfigUpdater) UpdateSigningKeys(ctx context.Context, projectRef string, signingKeys []JWK, filter ...func(string) bool) error { + if len(signingKeys) == 0 { + return nil + } + resp, err := u.client.V1GetProjectSigningKeysWithResponse(ctx, projectRef) + if err != nil { + return errors.Errorf("failed to fetch signing keys: %w", err) + } else if resp.JSON200 == nil { + return errors.Errorf("unexpected status %d: %s", resp.StatusCode(), string(resp.Body)) + } + exists := map[uuid.UUID]struct{}{} + for _, k := range resp.JSON200.Keys { + if k.PublicJwk != nil { + exists[k.Id] = struct{}{} + } + } + var toInsert []JWK + for _, k := range signingKeys { + if _, ok := exists[k.KeyID]; !ok { + toInsert = append(toInsert, k) + } + } + if len(toInsert) == 0 { + fmt.Fprintln(os.Stderr, "Remote JWT signing keys are up to date.") + return nil + } + fmt.Fprintln(os.Stderr, "JWT signing keys to insert:") + for _, k := range toInsert { + fmt.Fprintln(os.Stderr, " -", k.KeyID) + } + for _, keep := range filter { + if !keep("signing keys") { + return nil + } + } + for _, k := range toInsert { + body := v1API.CreateSigningKeyBody{ + Algorithm: v1API.CreateSigningKeyBodyAlgorithm(k.Algorithm), + PrivateJwk: &v1API.CreateSigningKeyBody_PrivateJwk{}, + } + switch k.Algorithm { + case AlgRS256: + body.PrivateJwk.FromCreateSigningKeyBodyPrivateJwk0(v1API.CreateSigningKeyBodyPrivateJwk0{ + D: k.PrivateExponent, + Dp: k.FirstFactorCRTExponent, + Dq: k.SecondFactorCRTExponent, + E: v1API.CreateSigningKeyBodyPrivateJwk0E(k.Exponent), + Kty: v1API.CreateSigningKeyBodyPrivateJwk0Kty(k.KeyType), + N: k.Modulus, + P: k.FirstPrimeFactor, + Q: k.SecondPrimeFactor, + Qi: k.FirstCRTCoefficient, + }) + case AlgES256: + body.PrivateJwk.FromCreateSigningKeyBodyPrivateJwk1(v1API.CreateSigningKeyBodyPrivateJwk1{ + Crv: v1API.CreateSigningKeyBodyPrivateJwk1Crv(k.Curve), + D: k.PrivateExponent, + Kty: v1API.CreateSigningKeyBodyPrivateJwk1Kty(k.KeyType), + X: k.X, + Y: k.Y, + }) + } + if resp, err := u.client.V1CreateProjectSigningKeyWithResponse(ctx, projectRef, body); err != nil { + return errors.Errorf("failed to add signing key: %w", err) + } else if status := resp.StatusCode(); status < 200 || status >= 300 { + return errors.Errorf("unexpected status %d: %s", status, string(resp.Body)) + } + } + return nil +} + func (u *ConfigUpdater) UpdateStorageConfig(ctx context.Context, projectRef string, c storage, filter ...func(string) bool) error { if !c.Enabled { return nil From dcf9c82225a14a51a55772990e210b8d3f27d91c Mon Sep 17 00:00:00 2001 From: Qiao Han Date: Tue, 12 Aug 2025 13:32:35 +0800 Subject: [PATCH 2/8] chore: disable signing key update for now --- pkg/config/updater.go | 3 --- 1 file changed, 3 deletions(-) diff --git a/pkg/config/updater.go b/pkg/config/updater.go index 9f6f43ac9..4e2159488 100644 --- a/pkg/config/updater.go +++ b/pkg/config/updater.go @@ -28,9 +28,6 @@ func (u *ConfigUpdater) UpdateRemoteConfig(ctx context.Context, remote baseConfi if err := u.UpdateAuthConfig(ctx, remote.ProjectId, remote.Auth, filter...); err != nil { return err } - if err := u.UpdateSigningKeys(ctx, remote.ProjectId, remote.Auth.SigningKeys, filter...); err != nil { - return err - } if err := u.UpdateStorageConfig(ctx, remote.ProjectId, remote.Storage, filter...); err != nil { return err } From 7e68a154f9af1ac2cd12f3d36f4aa55107bb8538 Mon Sep 17 00:00:00 2001 From: Cemal Kilic Date: Tue, 12 Aug 2025 08:51:18 +0200 Subject: [PATCH 3/8] feat: asymmetric signed api keys --- pkg/config/apikeys.go | 212 ++++++++++++++++++++++++++++++++++++++++++ pkg/config/config.go | 30 +++--- 2 files changed, 225 insertions(+), 17 deletions(-) create mode 100644 pkg/config/apikeys.go diff --git a/pkg/config/apikeys.go b/pkg/config/apikeys.go new file mode 100644 index 000000000..7da6e0a67 --- /dev/null +++ b/pkg/config/apikeys.go @@ -0,0 +1,212 @@ +package config + +import ( + "crypto" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rsa" + "encoding/base64" + "fmt" + "io/fs" + "math/big" + "os" + "time" + + "github.com/go-errors/errors" + "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" + "github.com/supabase/cli/pkg/fetcher" +) + +// generateAPIKeys generates JWT tokens using the appropriate signing method +func (c *config) generateAPIKeys(fsys fs.FS) error { + // Load signing keys if path is provided + var signingKeys []JWK + if len(c.Auth.SigningKeysPath) > 0 { + f, err := fsys.Open(c.Auth.SigningKeysPath) + if err != nil { + // Ignore missing signing key path - will fall back to symmetric signing + fmt.Fprintf(os.Stderr, "Warning: Failed to generate asymmetric keys, falling back to symmetric: %v\n", err) + } else { + parsedKeys, _ := fetcher.ParseJSON[[]JWK](f) + signingKeys = parsedKeys + c.Auth.SigningKeys = signingKeys // Store for later use + } + } + + // Generate anon key if not provided + if len(c.Auth.AnonKey.Value) == 0 { + if len(signingKeys) > 0 { + if signed, err := generateAsymmetricJWT(signingKeys[0], "anon"); err != nil { + // Fall back to symmetric signing if asymmetric fails + fmt.Fprintf(os.Stderr, "Warning: Failed to generate asymmetric anon key, falling back to symmetric: %v\n", err) + c.Auth.AnonKey.Value = generateSymmetricJWT(c.Auth.JwtSecret.Value, "anon") + } else { + c.Auth.AnonKey.Value = signed + } + } else { + c.Auth.AnonKey.Value = generateSymmetricJWT(c.Auth.JwtSecret.Value, "anon") + } + } + + // Generate service_role key if not provided + if len(c.Auth.ServiceRoleKey.Value) == 0 { + if len(signingKeys) > 0 { + if signed, err := generateAsymmetricJWT(signingKeys[0], "service_role"); err != nil { + // Fall back to symmetric signing if asymmetric fails + fmt.Fprintf(os.Stderr, "Warning: Failed to generate asymmetric service_role key, falling back to symmetric: %v\n", err) + c.Auth.ServiceRoleKey.Value = generateSymmetricJWT(c.Auth.JwtSecret.Value, "service_role") + } else { + c.Auth.ServiceRoleKey.Value = signed + } + } else { + c.Auth.ServiceRoleKey.Value = generateSymmetricJWT(c.Auth.JwtSecret.Value, "service_role") + } + } + + return nil +} + +// createJWTClaims creates standardized JWT claims for API keys +func createJWTClaims(role string) CustomClaims { + now := time.Now() + return CustomClaims{ + Issuer: "supabase-demo", + Role: role, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(now.Add(time.Hour * 24 * 365 * 10)), // 10 years + }, + } +} + +// generateSymmetricJWT generates a JWT using symmetric signing with jwt_secret +func generateSymmetricJWT(jwtSecret, role string) string { + claims := createJWTClaims(role) + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + + signed, err := token.SignedString([]byte(jwtSecret)) + if err != nil { + // This should not happen if JWT secret is valid, but return empty string as fallback + fmt.Fprintf(os.Stderr, "Error: Failed to generate %s key: %v\n", role, err) + return "" + } + return signed +} + +// generateAsymmetricJWT generates a JWT token signed with the provided JWK private key +func generateAsymmetricJWT(jwk JWK, role string) (string, error) { + privateKey, err := jwkToPrivateKey(jwk) + if err != nil { + return "", errors.Errorf("failed to convert JWK to private key: %w", err) + } + + claims := createJWTClaims(role) + + // Determine signing method based on algorithm + var token *jwt.Token + switch jwk.Algorithm { + case "RS256": + token = jwt.NewWithClaims(jwt.SigningMethodRS256, claims) + case "ES256": + token = jwt.NewWithClaims(jwt.SigningMethodES256, claims) + default: + return "", errors.Errorf("unsupported algorithm: %s", jwk.Algorithm) + } + + if jwk.KeyID != uuid.Nil { + token.Header["kid"] = jwk.KeyID.String() + } + + tokenString, err := token.SignedString(privateKey) + if err != nil { + return "", errors.Errorf("failed to sign JWT: %w", err) + } + + return tokenString, nil +} + +// jwkToPrivateKey converts a JWK to a crypto.PrivateKey +func jwkToPrivateKey(jwk JWK) (crypto.PrivateKey, error) { + switch jwk.KeyType { + case "RSA": + return jwkToRSAPrivateKey(jwk) + case "EC": + return jwkToECDSAPrivateKey(jwk) + default: + return nil, errors.Errorf("unsupported key type: %s", jwk.KeyType) + } +} + +// jwkToRSAPrivateKey converts a JWK to an RSA private key +func jwkToRSAPrivateKey(jwk JWK) (*rsa.PrivateKey, error) { + nBytes, err := base64.RawURLEncoding.DecodeString(jwk.Modulus) + if err != nil { + return nil, errors.Errorf("failed to decode modulus: %w", err) + } + n := new(big.Int).SetBytes(nBytes) + + eBytes, err := base64.RawURLEncoding.DecodeString(jwk.Exponent) + if err != nil { + return nil, errors.Errorf("failed to decode exponent: %w", err) + } + e := int(new(big.Int).SetBytes(eBytes).Int64()) + + dBytes, err := base64.RawURLEncoding.DecodeString(jwk.PrivateExponent) + if err != nil { + return nil, errors.Errorf("failed to decode private exponent: %w", err) + } + d := new(big.Int).SetBytes(dBytes) + + pBytes, err := base64.RawURLEncoding.DecodeString(jwk.FirstPrimeFactor) + if err != nil { + return nil, errors.Errorf("failed to decode first prime factor: %w", err) + } + p := new(big.Int).SetBytes(pBytes) + + qBytes, err := base64.RawURLEncoding.DecodeString(jwk.SecondPrimeFactor) + if err != nil { + return nil, errors.Errorf("failed to decode second prime factor: %w", err) + } + q := new(big.Int).SetBytes(qBytes) + + return &rsa.PrivateKey{ + PublicKey: rsa.PublicKey{N: n, E: e}, + D: d, + Primes: []*big.Int{p, q}, + }, nil +} + +// jwkToECDSAPrivateKey converts a JWK to an ECDSA private key +func jwkToECDSAPrivateKey(jwk JWK) (*ecdsa.PrivateKey, error) { + // Only support P-256 curve for ES256 + if jwk.Curve != "P-256" { + return nil, errors.Errorf("unsupported curve: %s", jwk.Curve) + } + + xBytes, err := base64.RawURLEncoding.DecodeString(jwk.X) + if err != nil { + return nil, errors.Errorf("failed to decode x coordinate: %w", err) + } + x := new(big.Int).SetBytes(xBytes) + + yBytes, err := base64.RawURLEncoding.DecodeString(jwk.Y) + if err != nil { + return nil, errors.Errorf("failed to decode y coordinate: %w", err) + } + y := new(big.Int).SetBytes(yBytes) + + dBytes, err := base64.RawURLEncoding.DecodeString(jwk.PrivateExponent) + if err != nil { + return nil, errors.Errorf("failed to decode private key: %w", err) + } + d := new(big.Int).SetBytes(dBytes) + + return &ecdsa.PrivateKey{ + PublicKey: ecdsa.PublicKey{ + Curve: elliptic.P256(), + X: x, + Y: y, + }, + D: d, + }, nil +} diff --git a/pkg/config/config.go b/pkg/config/config.go index 70c5ad571..e7011d6b8 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -589,22 +589,6 @@ func (c *config) Load(path string, fsys fs.FS) error { if len(c.Auth.JwtSecret.Value) < 16 { return errors.Errorf("Invalid config for auth.jwt_secret. Must be at least 16 characters") } - if len(c.Auth.AnonKey.Value) == 0 { - anonToken := CustomClaims{Role: "anon"}.NewToken() - if signed, err := anonToken.SignedString([]byte(c.Auth.JwtSecret.Value)); err != nil { - return errors.Errorf("failed to generate anon key: %w", err) - } else { - c.Auth.AnonKey.Value = signed - } - } - if len(c.Auth.ServiceRoleKey.Value) == 0 { - anonToken := CustomClaims{Role: "service_role"}.NewToken() - if signed, err := anonToken.SignedString([]byte(c.Auth.JwtSecret.Value)); err != nil { - return errors.Errorf("failed to generate service_role key: %w", err) - } else { - c.Auth.ServiceRoleKey.Value = signed - } - } // TODO: move linked pooler connection string elsewhere if connString, err := fs.ReadFile(fsys, builder.PoolerUrlPath); err == nil && len(connString) > 0 { c.Db.Pooler.ConnectionString = string(connString) @@ -669,7 +653,19 @@ func (c *config) Load(path string, fsys fs.FS) error { if err := c.resolve(builder, fsys); err != nil { return err } - return c.Validate(fsys) + + validateErr := c.Validate(fsys) + if validateErr != nil { + return validateErr + } + + // Generate API keys (anon/service role keys) after paths are resolved & validated + // as we might need to use user-provided signing keys + if err := c.generateAPIKeys(fsys); err != nil { + return err + } + + return nil } func VersionCompare(a, b string) int { From 980b37098abb3a6243868cbc0edc70d06eaa7565 Mon Sep 17 00:00:00 2001 From: Qiao Han Date: Tue, 12 Aug 2025 18:59:04 +0800 Subject: [PATCH 4/8] chore: scope down key generation to validate method --- pkg/config/apikeys.go | 87 +++++++++++-------------------------------- pkg/config/config.go | 19 +++------- 2 files changed, 26 insertions(+), 80 deletions(-) diff --git a/pkg/config/apikeys.go b/pkg/config/apikeys.go index 7da6e0a67..44f77e100 100644 --- a/pkg/config/apikeys.go +++ b/pkg/config/apikeys.go @@ -6,108 +6,63 @@ import ( "crypto/elliptic" "crypto/rsa" "encoding/base64" - "fmt" "io/fs" "math/big" - "os" "time" "github.com/go-errors/errors" "github.com/golang-jwt/jwt/v5" "github.com/google/uuid" - "github.com/supabase/cli/pkg/fetcher" ) // generateAPIKeys generates JWT tokens using the appropriate signing method -func (c *config) generateAPIKeys(fsys fs.FS) error { - // Load signing keys if path is provided - var signingKeys []JWK - if len(c.Auth.SigningKeysPath) > 0 { - f, err := fsys.Open(c.Auth.SigningKeysPath) - if err != nil { - // Ignore missing signing key path - will fall back to symmetric signing - fmt.Fprintf(os.Stderr, "Warning: Failed to generate asymmetric keys, falling back to symmetric: %v\n", err) - } else { - parsedKeys, _ := fetcher.ParseJSON[[]JWK](f) - signingKeys = parsedKeys - c.Auth.SigningKeys = signingKeys // Store for later use - } - } - +func (a *auth) generateAPIKeys(fsys fs.FS) error { // Generate anon key if not provided - if len(c.Auth.AnonKey.Value) == 0 { - if len(signingKeys) > 0 { - if signed, err := generateAsymmetricJWT(signingKeys[0], "anon"); err != nil { - // Fall back to symmetric signing if asymmetric fails - fmt.Fprintf(os.Stderr, "Warning: Failed to generate asymmetric anon key, falling back to symmetric: %v\n", err) - c.Auth.AnonKey.Value = generateSymmetricJWT(c.Auth.JwtSecret.Value, "anon") - } else { - c.Auth.AnonKey.Value = signed - } + if len(a.AnonKey.Value) == 0 { + if signed, err := a.generateJWT("anon"); err != nil { + return err } else { - c.Auth.AnonKey.Value = generateSymmetricJWT(c.Auth.JwtSecret.Value, "anon") + a.AnonKey.Value = signed } } - // Generate service_role key if not provided - if len(c.Auth.ServiceRoleKey.Value) == 0 { - if len(signingKeys) > 0 { - if signed, err := generateAsymmetricJWT(signingKeys[0], "service_role"); err != nil { - // Fall back to symmetric signing if asymmetric fails - fmt.Fprintf(os.Stderr, "Warning: Failed to generate asymmetric service_role key, falling back to symmetric: %v\n", err) - c.Auth.ServiceRoleKey.Value = generateSymmetricJWT(c.Auth.JwtSecret.Value, "service_role") - } else { - c.Auth.ServiceRoleKey.Value = signed - } + if len(a.ServiceRoleKey.Value) == 0 { + if signed, err := a.generateJWT("service_role"); err != nil { + return err } else { - c.Auth.ServiceRoleKey.Value = generateSymmetricJWT(c.Auth.JwtSecret.Value, "service_role") + a.ServiceRoleKey.Value = signed } } - return nil } -// createJWTClaims creates standardized JWT claims for API keys -func createJWTClaims(role string) CustomClaims { - now := time.Now() - return CustomClaims{ - Issuer: "supabase-demo", - Role: role, - RegisteredClaims: jwt.RegisteredClaims{ - ExpiresAt: jwt.NewNumericDate(now.Add(time.Hour * 24 * 365 * 10)), // 10 years - }, +func (a auth) generateJWT(role string) (string, error) { + claims := CustomClaims{Issuer: "supabase-demo", Role: role} + if len(a.SigningKeys) > 0 { + claims.ExpiresAt = jwt.NewNumericDate(time.Now().Add(time.Hour * 24 * 365 * 10)) // 10 years + return generateAsymmetricJWT(a.SigningKeys[0], claims) } -} - -// generateSymmetricJWT generates a JWT using symmetric signing with jwt_secret -func generateSymmetricJWT(jwtSecret, role string) string { - claims := createJWTClaims(role) - token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) - - signed, err := token.SignedString([]byte(jwtSecret)) + // Fallback to generating symmetric keys + signed, err := claims.NewToken().SignedString([]byte(a.JwtSecret.Value)) if err != nil { - // This should not happen if JWT secret is valid, but return empty string as fallback - fmt.Fprintf(os.Stderr, "Error: Failed to generate %s key: %v\n", role, err) - return "" + return "", errors.Errorf("failed to generate JWT: %w", err) } - return signed + return signed, nil } // generateAsymmetricJWT generates a JWT token signed with the provided JWK private key -func generateAsymmetricJWT(jwk JWK, role string) (string, error) { +func generateAsymmetricJWT(jwk JWK, claims CustomClaims) (string, error) { privateKey, err := jwkToPrivateKey(jwk) if err != nil { return "", errors.Errorf("failed to convert JWK to private key: %w", err) } - claims := createJWTClaims(role) - // Determine signing method based on algorithm var token *jwt.Token switch jwk.Algorithm { - case "RS256": + case AlgRS256: token = jwt.NewWithClaims(jwt.SigningMethodRS256, claims) - case "ES256": + case AlgES256: token = jwt.NewWithClaims(jwt.SigningMethodES256, claims) default: return "", errors.Errorf("unsupported algorithm: %s", jwk.Algorithm) diff --git a/pkg/config/config.go b/pkg/config/config.go index e7011d6b8..66cc5f890 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -653,19 +653,7 @@ func (c *config) Load(path string, fsys fs.FS) error { if err := c.resolve(builder, fsys); err != nil { return err } - - validateErr := c.Validate(fsys) - if validateErr != nil { - return validateErr - } - - // Generate API keys (anon/service role keys) after paths are resolved & validated - // as we might need to use user-provided signing keys - if err := c.generateAPIKeys(fsys); err != nil { - return err - } - - return nil + return c.Validate(fsys) } func VersionCompare(a, b string) int { @@ -853,9 +841,12 @@ func (c *config) Validate(fsys fs.FS) error { } else if 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 signign keys: %w", err) + return errors.Errorf("failed to decode signing keys: %w", err) } } + if err := c.Auth.generateAPIKeys(fsys); err != nil { + return err + } if err := c.Auth.Hook.validate(); err != nil { return err } From 2ed57b5d6541dd6bd42b8aedb49592ed8f872d50 Mon Sep 17 00:00:00 2001 From: Qiao Han Date: Tue, 12 Aug 2025 19:03:36 +0800 Subject: [PATCH 5/8] chore: conditionally validate jwt secret --- pkg/config/apikeys.go | 18 ++++++++++-------- pkg/config/config.go | 6 +----- 2 files changed, 11 insertions(+), 13 deletions(-) diff --git a/pkg/config/apikeys.go b/pkg/config/apikeys.go index 44f77e100..35ed815d1 100644 --- a/pkg/config/apikeys.go +++ b/pkg/config/apikeys.go @@ -6,7 +6,6 @@ import ( "crypto/elliptic" "crypto/rsa" "encoding/base64" - "io/fs" "math/big" "time" @@ -16,22 +15,22 @@ import ( ) // generateAPIKeys generates JWT tokens using the appropriate signing method -func (a *auth) generateAPIKeys(fsys fs.FS) error { +func (a *auth) generateAPIKeys() error { // Generate anon key if not provided if len(a.AnonKey.Value) == 0 { - if signed, err := a.generateJWT("anon"); err != nil { + signed, err := a.generateJWT("anon") + if err != nil { return err - } else { - a.AnonKey.Value = signed } + a.AnonKey.Value = signed } // Generate service_role key if not provided if len(a.ServiceRoleKey.Value) == 0 { - if signed, err := a.generateJWT("service_role"); err != nil { + signed, err := a.generateJWT("service_role") + if err != nil { return err - } else { - a.ServiceRoleKey.Value = signed } + a.ServiceRoleKey.Value = signed } return nil } @@ -43,6 +42,9 @@ func (a auth) generateJWT(role string) (string, error) { return generateAsymmetricJWT(a.SigningKeys[0], claims) } // Fallback to generating symmetric keys + if len(a.JwtSecret.Value) < 16 { + return "", errors.Errorf("Invalid config for auth.jwt_secret. Must be at least 16 characters") + } signed, err := claims.NewToken().SignedString([]byte(a.JwtSecret.Value)) if err != nil { return "", errors.Errorf("failed to generate JWT: %w", err) diff --git a/pkg/config/config.go b/pkg/config/config.go index 66cc5f890..ddaace033 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -585,10 +585,6 @@ func (c *config) Load(path string, fsys fs.FS) error { if err := c.loadFromFile(builder.ConfigPath, fsys); err != nil { return err } - // Generate JWT tokens - if len(c.Auth.JwtSecret.Value) < 16 { - return errors.Errorf("Invalid config for auth.jwt_secret. Must be at least 16 characters") - } // TODO: move linked pooler connection string elsewhere if connString, err := fs.ReadFile(fsys, builder.PoolerUrlPath); err == nil && len(connString) > 0 { c.Db.Pooler.ConnectionString = string(connString) @@ -844,7 +840,7 @@ func (c *config) Validate(fsys fs.FS) error { return errors.Errorf("failed to decode signing keys: %w", err) } } - if err := c.Auth.generateAPIKeys(fsys); err != nil { + if err := c.Auth.generateAPIKeys(); err != nil { return err } if err := c.Auth.Hook.validate(); err != nil { From 419dd46ad4ef92aab3ed3906acc75b32f04877f9 Mon Sep 17 00:00:00 2001 From: Cemal Kilic Date: Thu, 14 Aug 2025 22:57:22 +0200 Subject: [PATCH 6/8] feat: publish only public key to JWKS --- pkg/config/auth.go | 38 ++++++++++++++++++++++++++++++++++++++ pkg/config/config.go | 14 +++++++++++--- 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/pkg/config/auth.go b/pkg/config/auth.go index ed0345e8a..793ef1ac8 100644 --- a/pkg/config/auth.go +++ b/pkg/config/auth.go @@ -108,6 +108,44 @@ type JWK struct { Y string `json:"y,omitempty"` } +// ToPublicJWK converts a JWK to a public-only version by removing private key components +func (j JWK) ToPublicJWK() JWK { + publicJWK := JWK{ + KeyType: j.KeyType, + KeyID: j.KeyID, + Use: j.Use, + Algorithm: j.Algorithm, + Extractable: j.Extractable, + } + + // Only include key_ops for verification (not signing) for public keys + if len(j.KeyOps) > 0 { + var publicOps []string + for _, op := range j.KeyOps { + if op == "verify" { + publicOps = append(publicOps, op) + } + } + if len(publicOps) > 0 { + publicJWK.KeyOps = publicOps + } + } + + switch j.KeyType { + case "RSA": + // Include only public key components for RSA + publicJWK.Modulus = j.Modulus + publicJWK.Exponent = j.Exponent + case "EC": + // Include only public key components for ECDSA + publicJWK.Curve = j.Curve + publicJWK.X = j.X + publicJWK.Y = j.Y + } + + return publicJWK +} + type ( auth struct { Enabled bool `toml:"enabled"` diff --git a/pkg/config/config.go b/pkg/config/config.go index ddaace033..9885cde67 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -1441,17 +1441,25 @@ func (a *auth) ResolveJWKS(ctx context.Context, fsys afero.Fs) (string, error) { jwks.Keys = append(jwks.Keys, rJWKS.Keys...) } - // If SIGNING_KEYS_PATH is provided, read from file + // If SIGNING_KEYS_PATH is provided, read from file and convert to public keys 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) + jwtKeysArray, err := fetcher.ParseJSON[[]JWK](f) if err != nil { return "", err } - jwks.Keys = append(jwks.Keys, jwtKeysArray...) + // Convert each signing key to public-only version + for _, key := range jwtKeysArray { + publicKey := key.ToPublicJWK() + publicKeyEncoded, err := json.Marshal(publicKey) + if err != nil { + return "", errors.Errorf("failed to marshal public key: %w", err) + } + jwks.Keys = append(jwks.Keys, json.RawMessage(publicKeyEncoded)) + } } else { // Fallback to JWT_SECRET for backward compatibility jwtSecret := secretJWK{ From a26a8a865bd4f0968f1c5bc96f99cd1d482a8a63 Mon Sep 17 00:00:00 2001 From: Qiao Han Date: Fri, 15 Aug 2025 10:51:29 +0800 Subject: [PATCH 7/8] chore: copy pointer type --- pkg/config/auth.go | 32 +++++++++++++++----------------- pkg/config/config.go | 27 ++++++++------------------- 2 files changed, 23 insertions(+), 36 deletions(-) diff --git a/pkg/config/auth.go b/pkg/config/auth.go index 793ef1ac8..bbcb1fdb9 100644 --- a/pkg/config/auth.go +++ b/pkg/config/auth.go @@ -111,26 +111,24 @@ type JWK struct { // ToPublicJWK converts a JWK to a public-only version by removing private key components func (j JWK) ToPublicJWK() JWK { publicJWK := JWK{ - KeyType: j.KeyType, - KeyID: j.KeyID, - Use: j.Use, - Algorithm: j.Algorithm, - Extractable: j.Extractable, + KeyType: j.KeyType, + KeyID: j.KeyID, + Use: j.Use, + Algorithm: j.Algorithm, } - + + // Copy the underlying type instead of the pointer + if j.Extractable != nil { + publicJWK.Extractable = cast.Ptr(*j.Extractable) + } + // Only include key_ops for verification (not signing) for public keys - if len(j.KeyOps) > 0 { - var publicOps []string - for _, op := range j.KeyOps { - if op == "verify" { - publicOps = append(publicOps, op) - } - } - if len(publicOps) > 0 { - publicJWK.KeyOps = publicOps + for _, op := range j.KeyOps { + if op == "verify" { + publicJWK.KeyOps = append(publicJWK.KeyOps, op) } } - + switch j.KeyType { case "RSA": // Include only public key components for RSA @@ -142,7 +140,7 @@ func (j JWK) ToPublicJWK() JWK { publicJWK.X = j.X publicJWK.Y = j.Y } - + return publicJWK } diff --git a/pkg/config/config.go b/pkg/config/config.go index 9885cde67..8eb484fb5 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -1441,27 +1441,16 @@ func (a *auth) ResolveJWKS(ctx context.Context, fsys afero.Fs) (string, error) { jwks.Keys = append(jwks.Keys, rJWKS.Keys...) } - // If SIGNING_KEYS_PATH is provided, read from file and convert to public keys - if len(a.SigningKeysPath) > 0 { - f, err := fsys.Open(a.SigningKeysPath) + // Convert each signing key to public-only version + for _, key := range a.SigningKeys { + publicKeyEncoded, err := json.Marshal(key.ToPublicJWK()) if err != nil { - return "", errors.Errorf("failed to read signing key: %w", err) + return "", errors.Errorf("failed to marshal public key: %w", err) } - jwtKeysArray, err := fetcher.ParseJSON[[]JWK](f) - if err != nil { - return "", err - } - // Convert each signing key to public-only version - for _, key := range jwtKeysArray { - publicKey := key.ToPublicJWK() - publicKeyEncoded, err := json.Marshal(publicKey) - if err != nil { - return "", errors.Errorf("failed to marshal public key: %w", err) - } - jwks.Keys = append(jwks.Keys, json.RawMessage(publicKeyEncoded)) - } - } else { - // Fallback to JWT_SECRET for backward compatibility + jwks.Keys = append(jwks.Keys, json.RawMessage(publicKeyEncoded)) + } + // Fallback to JWT_SECRET for backward compatibility + if len(a.SigningKeys) == 0 { jwtSecret := secretJWK{ KeyType: "oct", KeyBase64URL: base64.RawURLEncoding.EncodeToString([]byte(a.JwtSecret.Value)), From cd013f53a24e93656e16375833a52144f337426e Mon Sep 17 00:00:00 2001 From: Qiao Han Date: Fri, 15 Aug 2025 10:56:46 +0800 Subject: [PATCH 8/8] chore: simplify key generation --- internal/gen/signingkeys/signingkeys.go | 50 ++++---------------- internal/gen/signingkeys/signingkeys_test.go | 39 +++++++-------- 2 files changed, 28 insertions(+), 61 deletions(-) diff --git a/internal/gen/signingkeys/signingkeys.go b/internal/gen/signingkeys/signingkeys.go index 10bdd646d..22946df67 100644 --- a/internal/gen/signingkeys/signingkeys.go +++ b/internal/gen/signingkeys/signingkeys.go @@ -23,13 +23,8 @@ import ( "github.com/supabase/cli/pkg/config" ) -type KeyPair struct { - PublicKey config.JWK - PrivateKey config.JWK -} - -// GenerateKeyPair generates a new key pair for the specified algorithm -func GenerateKeyPair(alg config.Algorithm) (*KeyPair, error) { +// GeneratePrivateKey generates a new private key for the specified algorithm +func GeneratePrivateKey(alg config.Algorithm) (*config.JWK, error) { keyID := uuid.New() switch alg { @@ -42,7 +37,7 @@ func GenerateKeyPair(alg config.Algorithm) (*KeyPair, error) { } } -func generateRSAKeyPair(keyID uuid.UUID) (*KeyPair, error) { +func generateRSAKeyPair(keyID uuid.UUID) (*config.JWK, error) { // Generate RSA key pair (2048 bits for RS256) privateKey, err := rsa.GenerateKey(rand.Reader, 2048) if err != nil { @@ -72,24 +67,10 @@ func generateRSAKeyPair(keyID uuid.UUID) (*KeyPair, error) { FirstCRTCoefficient: base64.RawURLEncoding.EncodeToString(privateKey.Precomputed.Qinv.Bytes()), } - publicJWK := config.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 + return &privateJWK, nil } -func generateECDSAKeyPair(keyID uuid.UUID) (*KeyPair, error) { +func generateECDSAKeyPair(keyID uuid.UUID) (*config.JWK, error) { // Generate ECDSA key pair (P-256 curve for ES256) privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) if err != nil { @@ -112,22 +93,7 @@ func generateECDSAKeyPair(keyID uuid.UUID) (*KeyPair, error) { PrivateExponent: base64.RawURLEncoding.EncodeToString(privateKey.D.Bytes()), } - publicJWK := config.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 + return &privateJWK, nil } // Run generates a key pair and writes it to the specified file path @@ -139,7 +105,7 @@ func Run(ctx context.Context, algorithm string, appendMode bool, fsys afero.Fs) outputPath := utils.Config.Auth.SigningKeysPath // Generate key pair - keyPair, err := GenerateKeyPair(config.Algorithm(algorithm)) + privateJWK, err := GeneratePrivateKey(config.Algorithm(algorithm)) if err != nil { return err } @@ -181,7 +147,7 @@ func Run(ctx context.Context, algorithm string, appendMode bool, fsys afero.Fs) } out = f } - jwkArray = append(jwkArray, keyPair.PrivateKey) + jwkArray = append(jwkArray, *privateJWK) // Write to file enc := json.NewEncoder(out) diff --git a/internal/gen/signingkeys/signingkeys_test.go b/internal/gen/signingkeys/signingkeys_test.go index 369811c07..6c3a7c63d 100644 --- a/internal/gen/signingkeys/signingkeys_test.go +++ b/internal/gen/signingkeys/signingkeys_test.go @@ -31,61 +31,62 @@ func TestGenerateKeyPair(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - keyPair, err := GenerateKeyPair(tt.algorithm) + privateJWK, err := GeneratePrivateKey(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 { + if privateJWK == nil { t.Error("GenerateKeyPair() returned nil key pair") return } // Check that both public and private keys are generated - if keyPair.PublicKey.KeyType == "" { + publicJWK := privateJWK.ToPublicJWK() + if publicJWK.KeyType == "" { t.Error("Public key type is empty") } - if keyPair.PrivateKey.KeyType == "" { + if privateJWK.KeyType == "" { t.Error("Private key type is empty") } // Check that key IDs match - if keyPair.PublicKey.KeyID != keyPair.PrivateKey.KeyID { + if publicJWK.KeyID != privateJWK.KeyID { t.Error("Public and private key IDs don't match") } // Algorithm-specific checks switch tt.algorithm { case config.AlgRS256: - if keyPair.PublicKey.KeyType != "RSA" { - t.Errorf("Expected RSA key type, got %s", keyPair.PublicKey.KeyType) + if publicJWK.KeyType != "RSA" { + t.Errorf("Expected RSA key type, got %s", publicJWK.KeyType) } - if keyPair.PrivateKey.Algorithm != "RS256" { - t.Errorf("Expected RS256 algorithm, got %s", keyPair.PrivateKey.Algorithm) + if privateJWK.Algorithm != "RS256" { + t.Errorf("Expected RS256 algorithm, got %s", privateJWK.Algorithm) } // Check that RSA-specific fields are present - if keyPair.PrivateKey.Modulus == "" { + if privateJWK.Modulus == "" { t.Error("RSA private key missing modulus") } - if keyPair.PrivateKey.PrivateExponent == "" { + if privateJWK.PrivateExponent == "" { t.Error("RSA private key missing private exponent") } case config.AlgES256: - if keyPair.PublicKey.KeyType != "EC" { - t.Errorf("Expected EC key type, got %s", keyPair.PublicKey.KeyType) + if publicJWK.KeyType != "EC" { + t.Errorf("Expected EC key type, got %s", publicJWK.KeyType) } - if keyPair.PrivateKey.Algorithm != "ES256" { - t.Errorf("Expected ES256 algorithm, got %s", keyPair.PrivateKey.Algorithm) + if privateJWK.Algorithm != "ES256" { + t.Errorf("Expected ES256 algorithm, got %s", privateJWK.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 privateJWK.Curve != "P-256" { + t.Errorf("Expected P-256 curve, got %s", privateJWK.Curve) } - if keyPair.PrivateKey.X == "" { + if privateJWK.X == "" { t.Error("EC private key missing X coordinate") } - if keyPair.PrivateKey.Y == "" { + if privateJWK.Y == "" { t.Error("EC private key missing Y coordinate") } }