|
| 1 | +// Copyright The Linux Foundation and each contributor to LFX. |
| 2 | +// SPDX-License-Identifier: MIT |
| 3 | + |
| 4 | +package auth0 |
| 5 | + |
| 6 | +import ( |
| 7 | + "context" |
| 8 | + "crypto/rsa" |
| 9 | + "encoding/json" |
| 10 | + "fmt" |
| 11 | + "log/slog" |
| 12 | + "net/http" |
| 13 | + |
| 14 | + "github.com/linuxfoundation/lfx-v2-auth-service/pkg/errors" |
| 15 | + "github.com/linuxfoundation/lfx-v2-auth-service/pkg/httpclient" |
| 16 | + jwtparser "github.com/linuxfoundation/lfx-v2-auth-service/pkg/jwt" |
| 17 | +) |
| 18 | + |
| 19 | +// JWTVerificationConfig holds configuration for JWT signature verification |
| 20 | +type JWTVerificationConfig struct { |
| 21 | + // PublicKey is the RSA public key for signature verification |
| 22 | + PublicKey *rsa.PublicKey |
| 23 | + // ExpectedIssuer is the expected JWT issuer (e.g., "https://your-domain.auth0.com/") |
| 24 | + ExpectedIssuer string |
| 25 | + // ExpectedAudience is the expected JWT audience |
| 26 | + ExpectedAudience string |
| 27 | + // JWKSURL is the URL to fetch JSON Web Key Set (optional, alternative to PublicKey) |
| 28 | + JWKSURL string |
| 29 | +} |
| 30 | + |
| 31 | +// JWTVerify verifies a JWT token with the specified required scope |
| 32 | +// https://auth0.com/docs/secure/tokens/json-web-tokens/validate-json-web-tokens |
| 33 | +func (j *JWTVerificationConfig) JWTVerify(ctx context.Context, token string, requiredScope ...string) (*jwtparser.Claims, error) { |
| 34 | + // JWT verification config is required |
| 35 | + if j == nil { |
| 36 | + return nil, errors.NewValidation("JWT verification configuration is required") |
| 37 | + } |
| 38 | + |
| 39 | + // Configure JWT parsing options with signature verification |
| 40 | + opts := &jwtparser.ParseOptions{ |
| 41 | + RequireExpiration: true, |
| 42 | + AllowBearerPrefix: true, |
| 43 | + RequireSubject: true, |
| 44 | + VerifySignature: true, |
| 45 | + SigningKey: j.PublicKey, |
| 46 | + ExpectedIssuer: j.ExpectedIssuer, |
| 47 | + ExpectedAudience: j.ExpectedAudience, |
| 48 | + } |
| 49 | + |
| 50 | + if len(requiredScope) > 0 { |
| 51 | + opts.RequiredScopes = requiredScope |
| 52 | + } |
| 53 | + |
| 54 | + // Parse and validate the JWT token with signature verification |
| 55 | + claims, err := jwtparser.ParseVerified(ctx, token, opts) |
| 56 | + if err != nil { |
| 57 | + slog.ErrorContext(ctx, "JWT signature verification failed", |
| 58 | + "error", err, |
| 59 | + "required_scope", requiredScope) |
| 60 | + return nil, err |
| 61 | + } |
| 62 | + |
| 63 | + slog.DebugContext(ctx, "JWT signature verification successful", |
| 64 | + "user_id", claims.Subject, |
| 65 | + "issuer", claims.Issuer, |
| 66 | + "audience", claims.Audience, |
| 67 | + "expires_at", claims.ExpiresAt, |
| 68 | + "scope", claims.Scope, |
| 69 | + "required_scope", requiredScope) |
| 70 | + |
| 71 | + return claims, nil |
| 72 | +} |
| 73 | + |
| 74 | +// NewJWTVerificationConfig creates a JWT verification configuration |
| 75 | +func NewJWTVerificationConfig(ctx context.Context, domain string, httpClient *httpclient.Client) (*JWTVerificationConfig, error) { |
| 76 | + // Try to load from JWKS URL first (recommended for Auth0) |
| 77 | + jwksURL := fmt.Sprintf("https://%s/.well-known/jwks.json", domain) |
| 78 | + |
| 79 | + // Fetch JWKS from Auth0 using the existing httpclient |
| 80 | + apiRequest := httpclient.NewAPIRequest( |
| 81 | + httpClient, |
| 82 | + httpclient.WithMethod(http.MethodGet), |
| 83 | + httpclient.WithURL(jwksURL), |
| 84 | + httpclient.WithDescription("fetch Auth0 JWKS"), |
| 85 | + ) |
| 86 | + |
| 87 | + // Parse JWKS and extract the first RSA key |
| 88 | + var jwks struct { |
| 89 | + Keys []struct { |
| 90 | + Kty string `json:"kty"` |
| 91 | + Use string `json:"use,omitempty"` |
| 92 | + Kid string `json:"kid,omitempty"` |
| 93 | + Alg string `json:"alg,omitempty"` |
| 94 | + N string `json:"n"` |
| 95 | + E string `json:"e"` |
| 96 | + } `json:"keys"` |
| 97 | + } |
| 98 | + |
| 99 | + statusCode, err := apiRequest.Call(ctx, &jwks) |
| 100 | + if err != nil { |
| 101 | + return nil, errors.NewUnexpected("failed to fetch JWKS", err) |
| 102 | + } |
| 103 | + |
| 104 | + if statusCode != http.StatusOK { |
| 105 | + return nil, errors.NewUnexpected(fmt.Sprintf("JWKS endpoint returned status %d", statusCode)) |
| 106 | + } |
| 107 | + |
| 108 | + // Find the first RSA key suitable for signature verification |
| 109 | + for _, key := range jwks.Keys { |
| 110 | + if key.Kty == "RSA" && (key.Use == "sig" || key.Use == "") { |
| 111 | + // Convert JWK to RSA public key |
| 112 | + jwkData, err := json.Marshal(key) |
| 113 | + if err != nil { |
| 114 | + continue |
| 115 | + } |
| 116 | + |
| 117 | + publicKey, err := jwtparser.LoadRSAPublicKeyFromJWK(jwkData) |
| 118 | + if err != nil { |
| 119 | + return nil, errors.NewUnexpected("failed to load RSA public key from JWK", err) |
| 120 | + } |
| 121 | + |
| 122 | + expectedIssuer := fmt.Sprintf("https://%s/", domain) |
| 123 | + expectedAudience := fmt.Sprintf("https://%s/api/v2/", domain) |
| 124 | + |
| 125 | + slog.InfoContext(ctx, "JWT signature verification enabled", |
| 126 | + "issuer", expectedIssuer, |
| 127 | + "audience", expectedAudience, |
| 128 | + "key_id", key.Kid) |
| 129 | + |
| 130 | + return &JWTVerificationConfig{ |
| 131 | + PublicKey: publicKey, |
| 132 | + ExpectedIssuer: expectedIssuer, |
| 133 | + ExpectedAudience: expectedAudience, |
| 134 | + JWKSURL: jwksURL, |
| 135 | + }, nil |
| 136 | + } |
| 137 | + } |
| 138 | + |
| 139 | + return nil, errors.NewUnexpected("no suitable RSA key found in JWKS for signature verification") |
| 140 | +} |
0 commit comments