From 166b3fda5c2c3f2c784a09f3c8fa3f2797e4bd0a Mon Sep 17 00:00:00 2001 From: Ankit Patial Date: Sun, 23 Nov 2025 13:03:09 +0530 Subject: [PATCH] Add ES256 (ECDSA P-256) JWT support for Apple Sign In - Added SignES256 function with issuer, audience, and subject parameters - Added ParseES256 function for validation with issuer and audience - Comprehensive test coverage for ES256 signing and parsing - Supports Apple Sign In requirements with ES256 algorithm --- jwt/jwt.go | 67 ++++++++++++++++++++++++ jwt/jwt_test.go | 133 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 200 insertions(+) diff --git a/jwt/jwt.go b/jwt/jwt.go index 2c37615..829a33c 100644 --- a/jwt/jwt.go +++ b/jwt/jwt.go @@ -6,6 +6,7 @@ package jwt import ( + "crypto/ecdsa" "crypto/ed25519" "errors" "fmt" @@ -94,3 +95,69 @@ func ParseHS256(secret []byte, tokenString string, issuer string) (jwt.MapClaims } return nil, errors.New("no claims found in token") } + +// SignES256 signs a JWT using ES256 (ECDSA P-256) algorithm. +// This is required for Apple Sign In which mandates ES256. +// Pass empty string for issuer, audience, or subject to omit them from the token. +func SignES256( + key *ecdsa.PrivateKey, issuer, audience, subject string, d time.Duration, claims map[string]any, +) (string, error) { + cl := jwt.MapClaims{ + "iat": jwt.NewNumericDate(time.Now().UTC()), + "exp": jwt.NewNumericDate(time.Now().Add(d)), + } + + if issuer != "" { + cl["iss"] = issuer + } + + if audience != "" { + cl["aud"] = audience + } + + if subject != "" { + cl["sub"] = subject + } + + maps.Copy(cl, claims) + + t := jwt.NewWithClaims(jwt.SigningMethodES256, cl) + return t.SignedString(key) +} + +// ParseES256 parses and validates a JWT signed with ES256 (ECDSA P-256) algorithm. +// This is required for Apple Sign In which mandates ES256. +// issuer and audience are typically validated, while subject is optional (pass empty string to skip). +func ParseES256(key *ecdsa.PublicKey, tokenString, issuer, audience string) (jwt.MapClaims, error) { + opts := []jwt.ParserOption{ + jwt.WithValidMethods([]string{jwt.SigningMethodES256.Alg()}), + jwt.WithIssuedAt(), + jwt.WithExpirationRequired(), + } + + if issuer != "" { + opts = append(opts, jwt.WithIssuer(issuer)) + } + if audience != "" { + opts = append(opts, jwt.WithAudience(audience)) + } + + token, err := jwt.Parse( + tokenString, + func(token *jwt.Token) (any, error) { + if _, ok := token.Method.(*jwt.SigningMethodECDSA); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) + } + return key, nil + }, + opts..., + ) + if err != nil { + return nil, fmt.Errorf("failed to parse JWT token: %w", err) + } + + if claims, ok := token.Claims.(jwt.MapClaims); ok { + return claims, nil + } + return nil, errors.New("no claims found in token") +} diff --git a/jwt/jwt_test.go b/jwt/jwt_test.go index 7d2fdca..27e39d9 100644 --- a/jwt/jwt_test.go +++ b/jwt/jwt_test.go @@ -6,6 +6,9 @@ package jwt import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" "fmt" "testing" "time" @@ -85,3 +88,133 @@ func TestHS256(t *testing.T) { return } } + +func TestES256(t *testing.T) { + // Generate ECDSA P-256 key pair for testing + privKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + + claims := map[string]any{ + "email": "user@example.com", + } + issuer := "https://appleid.apple.com" + audience := "com.example.app" + subject := "001234.56789abcdef.1234" + + // Sign with issuer, audience, and subject + token, err := SignES256(privKey, issuer, audience, subject, time.Hour, claims) + if err != nil { + t.Fatal(err) + } + + t.Log("ES256 token:", token) + + // Parse with issuer and audience validation + parsedClaims, err := ParseES256(&privKey.PublicKey, token, issuer, audience) + if err != nil { + t.Fatal(err) + } + + // Verify claims + if parsedClaims["sub"] != subject { + t.Errorf("expected sub to be '%s', got %v", subject, parsedClaims["sub"]) + } + if parsedClaims["aud"] != audience { + t.Errorf("expected aud to be '%s', got %v", audience, parsedClaims["aud"]) + } + if parsedClaims["email"] != "user@example.com" { + t.Errorf("expected email to be 'user@example.com', got %v", parsedClaims["email"]) + } + + t.Logf("Parsed claims: %v", parsedClaims) + + // Test parsing without validation (empty strings) + parsedClaims2, err := ParseES256(&privKey.PublicKey, token, "", "") + if err != nil { + t.Fatal(err) + } + if parsedClaims2["sub"] != subject { + t.Errorf("expected sub to be '%s', got %v", subject, parsedClaims2["sub"]) + } +} + +func TestES256_ExpiredToken(t *testing.T) { + // Generate ECDSA P-256 key pair for testing + privKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + + claims := map[string]any{} + issuer := "https://appleid.apple.com" + subject := "001234.56789abcdef.1234" + + // Sign with very short duration + token, err := SignES256(privKey, issuer, "", subject, time.Nanosecond, claims) + if err != nil { + t.Fatal(err) + } + + // Wait for token to expire + time.Sleep(10 * time.Millisecond) + + // Parse should fail due to expiration + _, err = ParseES256(&privKey.PublicKey, token, issuer, "") + if err == nil { + t.Error("expected error for expired token, got nil") + } + t.Logf("Expected error for expired token: %v", err) +} + +func TestES256_InvalidIssuer(t *testing.T) { + // Generate ECDSA P-256 key pair for testing + privKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + + claims := map[string]any{} + issuer := "https://appleid.apple.com" + subject := "001234.56789abcdef.1234" + + // Sign with one issuer + token, err := SignES256(privKey, issuer, "", subject, time.Hour, claims) + if err != nil { + t.Fatal(err) + } + + // Parse with different issuer should fail + _, err = ParseES256(&privKey.PublicKey, token, "https://wrong-issuer.com", "") + if err == nil { + t.Error("expected error for invalid issuer, got nil") + } + t.Logf("Expected error for invalid issuer: %v", err) +} + +func TestES256_InvalidAudience(t *testing.T) { + // Generate ECDSA P-256 key pair for testing + privKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + + claims := map[string]any{} + issuer := "https://appleid.apple.com" + audience := "com.example.app" + subject := "001234.56789abcdef.1234" + + // Sign with one audience + token, err := SignES256(privKey, issuer, audience, subject, time.Hour, claims) + if err != nil { + t.Fatal(err) + } + + // Parse with different audience should fail + _, err = ParseES256(&privKey.PublicKey, token, issuer, "com.wrong.app") + if err == nil { + t.Error("expected error for invalid audience, got nil") + } + t.Logf("Expected error for invalid audience: %v", err) +}