Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 28 additions & 5 deletions internal/infrastructure/auth0/jwt_parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,12 @@ type JWTVerificationConfig struct {
PublicKey *rsa.PublicKey
// ExpectedIssuer is the expected JWT issuer (e.g., "https://your-domain.auth0.com/")
ExpectedIssuer string
// ExpectedAudience is the expected JWT audience
// ExpectedAudience is the Auth0 Management API audience. Tokens carrying it
// may be forwarded to the Management API on the user's behalf.
ExpectedAudience string
// ExpectedAudiences is the full audience allow-list for JWT verification
// (Management API plus, when configured, the LFX v2 API audience)
ExpectedAudiences []string
// JWKSURL is the URL to fetch JSON Web Key Set (optional, alternative to PublicKey)
JWKSURL string
}
Expand All @@ -49,10 +53,18 @@ func (j *JWTVerificationConfig) JWTVerify(ctx context.Context, token string, req
SigningKey: j.PublicKey,
ExpectedIssuer: j.ExpectedIssuer,
ExpectedAudience: j.ExpectedAudience,
ExpectedAudiences: j.ExpectedAudiences,
Comment thread
mlehotskylf marked this conversation as resolved.
}

if len(requiredScope) > 0 {
opts.RequiredScopes = requiredScope
// Required scopes are Auth0 Management API scopes, which are only
// meaningful on Management-API-audience tokens. Restricting scope-gated
// verifications (the write-authorizing paths) to that audience keeps
// broader-audience tokens — accepted for read-only lookups via
// ExpectedAudiences — from ever authorizing a write, even if another
// resource server were to define an identically named scope.
opts.ExpectedAudiences = nil
}

// Parse and validate the JWT token with signature verification
Expand Down Expand Up @@ -129,16 +141,27 @@ func NewJWTVerificationConfig(ctx context.Context, domain string, httpClient *ht
expectedAudience = override
}

// User-facing access tokens and impersonation tokens carry the LFX v2
// API audience rather than the Management API audience; accept it for
// verification when configured. Such tokens are never forwarded to the
// Management API (see MetadataLookup).
expectedAudiences := []string{expectedAudience}
if lfxAPIAudience := strings.TrimSpace(os.Getenv(constants.Auth0LFXv2APIAudienceEnvKey)); lfxAPIAudience != "" && lfxAPIAudience != expectedAudience {
expectedAudiences = append(expectedAudiences, lfxAPIAudience)
}

slog.InfoContext(ctx, "JWT signature verification enabled",
"issuer", expectedIssuer,
"audience", expectedAudience,
"audiences", expectedAudiences,
"key_id", key.Kid)

return &JWTVerificationConfig{
PublicKey: publicKey,
ExpectedIssuer: expectedIssuer,
ExpectedAudience: expectedAudience,
JWKSURL: jwksURL,
PublicKey: publicKey,
ExpectedIssuer: expectedIssuer,
ExpectedAudience: expectedAudience,
ExpectedAudiences: expectedAudiences,
JWKSURL: jwksURL,
}, nil
}
}
Expand Down
15 changes: 14 additions & 1 deletion internal/infrastructure/auth0/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -216,10 +216,23 @@ func (u *userReaderWriter) MetadataLookup(ctx context.Context, input string, req
}

// Successfully verified JWT token
user.Token = cleanToken
user.UserID = claims.Subject
user.Sub = claims.Subject

// Only tokens minted for the Auth0 Management API may be forwarded to it
// on the user's behalf. Tokens verified via another allow-listed audience
// (e.g. the LFX v2 API audience used by user-facing access and impersonation
// tokens) leave user.Token empty, so read lookups fall back to the service's
// M2M credentials while write flows — which require the user's own
// management-scoped token — fail closed.
if claims.HasAudience(u.config.JWTVerificationConfig.ExpectedAudience) {
user.Token = cleanToken
} else {
slog.DebugContext(ctx, "non-management-audience token verified; using M2M credentials for lookup",
"sub", redaction.Redact(user.Sub),
)
}

slog.DebugContext(ctx, "JWT signature verification successful for metadata lookup",
"sub", user.Sub,
)
Expand Down
78 changes: 78 additions & 0 deletions internal/infrastructure/auth0/user_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -762,6 +762,84 @@ func TestUserReaderWriter_MetadataLookup(t *testing.T) {
}
}

func TestUserReaderWriter_MetadataLookup_AudienceTokenRetention(t *testing.T) {
ctx := context.Background()

jwtConfig, privateKey := createTestJWTVerificationConfig(t)
jwtConfig.ExpectedAudiences = []string{"https://test.auth0.com/api/v2/", "https://lfx-api.example.org/"}

writer := &userReaderWriter{
config: Config{
JWTVerificationConfig: jwtConfig,
},
}

signToken := func(audience string) string {
claims := jwt.MapClaims{
"sub": "auth0|123456789",
"exp": time.Now().Add(time.Hour).Unix(),
"iat": time.Now().Unix(),
"iss": "https://test.auth0.com/",
"aud": audience,
}
tokenString, err := jwt.NewWithClaims(jwt.SigningMethodRS256, claims).SignedString(privateKey)
require.NoError(t, err)
return tokenString
}

t.Run("management-audience token is retained for Management API calls", func(t *testing.T) {
tokenString := signToken("https://test.auth0.com/api/v2/")
user, err := writer.MetadataLookup(ctx, tokenString)
require.NoError(t, err)
assert.Equal(t, "auth0|123456789", user.Sub)
assert.Equal(t, tokenString, user.Token, "management-audience token should be kept as bearer")
})

t.Run("LFX v2 API audience token verifies but is not forwarded", func(t *testing.T) {
tokenString := signToken("https://lfx-api.example.org/")
user, err := writer.MetadataLookup(ctx, tokenString)
require.NoError(t, err)
assert.Equal(t, "auth0|123456789", user.Sub)
assert.Equal(t, "auth0|123456789", user.UserID)
assert.Empty(t, user.Token, "non-management token must not be forwarded to the Management API")
})

t.Run("un-allow-listed audience is rejected", func(t *testing.T) {
tokenString := signToken("https://evil.example.org/")
_, err := writer.MetadataLookup(ctx, tokenString)
require.Error(t, err)
assert.Contains(t, err.Error(), "invalid audience")
})

signScopedToken := func(audience, scope string) string {
claims := jwt.MapClaims{
"sub": "auth0|123456789",
"exp": time.Now().Add(time.Hour).Unix(),
"iat": time.Now().Unix(),
"iss": "https://test.auth0.com/",
"aud": audience,
"scope": scope,
}
tokenString, err := jwt.NewWithClaims(jwt.SigningMethodRS256, claims).SignedString(privateKey)
require.NoError(t, err)
return tokenString
}

t.Run("scope-gated lookup rejects LFX v2 API audience even with matching scope", func(t *testing.T) {
tokenString := signScopedToken("https://lfx-api.example.org/", "update:current_user_identities")
_, err := writer.MetadataLookup(ctx, tokenString, "update:current_user_identities")
require.Error(t, err)
assert.Contains(t, err.Error(), "invalid audience")
})

t.Run("scope-gated lookup accepts management-audience token with scope", func(t *testing.T) {
tokenString := signScopedToken("https://test.auth0.com/api/v2/", "update:current_user_identities")
user, err := writer.MetadataLookup(ctx, tokenString, "update:current_user_identities")
require.NoError(t, err)
assert.Equal(t, tokenString, user.Token)
})
}

func TestUserReaderWriter_AddSystemManagedEmail_Validation(t *testing.T) {
ctx := context.Background()

Expand Down
40 changes: 34 additions & 6 deletions pkg/jwt/parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ type Claims struct {
NotBefore *time.Time `json:"nbf,omitempty"`
Issuer string `json:"iss,omitempty"`
Audience string `json:"aud,omitempty"`
Audiences []string `json:"-"` // All 'aud' values; Audience keeps the first for compatibility
Scope string `json:"scope,omitempty"`
Raw map[string]any `json:"-"` // Raw claims for additional fields
}
Expand All @@ -51,6 +52,9 @@ type ParseOptions struct {
ExpectedIssuer string
// ExpectedAudience validates the 'aud' claim matches this value
ExpectedAudience string
// ExpectedAudiences validates the 'aud' claim matches any of these values.
// When set, it takes precedence over ExpectedAudience.
ExpectedAudiences []string
}

// DefaultParseOptions returns sensible default options
Expand Down Expand Up @@ -163,8 +167,8 @@ func ParseVerified(ctx context.Context, tokenString string, opts *ParseOptions)
}

// Validate audience if specified
if opts.ExpectedAudience != "" {
if err := validateAudience(claims, opts.ExpectedAudience); err != nil {
if expected := opts.expectedAudiences(); len(expected) > 0 {
if err := validateAudience(claims, expected); err != nil {
return nil, err
}
}
Expand Down Expand Up @@ -215,6 +219,7 @@ func extractClaimsFromJWT(token jwt.Token) (*Claims, error) {
audience := token.Audience()
if len(audience) > 0 {
claims.Audience = audience[0] // Take the first audience
claims.Audiences = audience
}

// Extract email from private claims
Expand Down Expand Up @@ -346,19 +351,42 @@ func validateIssuer(claims *Claims, expectedIssuer string) error {
return nil
}

// validateAudience checks if the token audience matches the expected value
func validateAudience(claims *Claims, expectedAudience string) error {
// validateAudience checks if any token audience matches any expected value
func validateAudience(claims *Claims, expectedAudiences []string) error {
if claims.Audience == "" {
return errors.NewValidation("missing 'aud' claim in token")
}

if claims.Audience != expectedAudience {
return errors.NewValidation("invalid audience")
for _, expected := range expectedAudiences {
if claims.HasAudience(expected) {
return nil
}
}

return errors.NewValidation("invalid audience")
}

// expectedAudiences returns the effective audience allow-list for validation
func (o *ParseOptions) expectedAudiences() []string {
if len(o.ExpectedAudiences) > 0 {
return o.ExpectedAudiences
}
if o.ExpectedAudience != "" {
return []string{o.ExpectedAudience}
}
return nil
}

// HasAudience reports whether the token's 'aud' claim contains the given audience
func (c *Claims) HasAudience(audience string) bool {
for _, aud := range c.Audiences {
if aud == audience {
return true
}
}
return c.Audience != "" && c.Audience == audience
}

// GetClaim is a helper to extract a specific claim from the raw claims
func (c *Claims) GetClaim(key string) (interface{}, bool) {
if c.Raw == nil {
Expand Down
114 changes: 114 additions & 0 deletions pkg/jwt/parser_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -445,6 +445,46 @@ func TestParseVerified(t *testing.T) {
},
expectError: true,
},
{
name: "audience in ExpectedAudiences allow-list",
token: tokenString,
opts: &ParseOptions{
VerifySignature: true,
SigningKey: publicKey,
ExpectedIssuer: "https://test.auth0.com/",
ExpectedAudiences: []string{"https://other-api.example.org/", "https://test.auth0.com/api/v2/"},
RequireExpiration: true,
RequireSubject: true,
},
expectError: false,
},
{
name: "audience not in ExpectedAudiences allow-list",
token: tokenString,
opts: &ParseOptions{
VerifySignature: true,
SigningKey: publicKey,
ExpectedIssuer: "https://test.auth0.com/",
ExpectedAudiences: []string{"https://other-api.example.org/", "https://another.example.org/"},
RequireExpiration: true,
RequireSubject: true,
},
expectError: true,
},
{
name: "ExpectedAudiences takes precedence over mismatched ExpectedAudience",
token: tokenString,
opts: &ParseOptions{
VerifySignature: true,
SigningKey: publicKey,
ExpectedIssuer: "https://test.auth0.com/",
ExpectedAudience: "https://wrong.auth0.com/api/v2/",
ExpectedAudiences: []string{"https://test.auth0.com/api/v2/"},
RequireExpiration: true,
RequireSubject: true,
},
expectError: false,
},
{
name: "expired token",
token: createExpiredToken(t, privateKey),
Expand Down Expand Up @@ -659,3 +699,77 @@ func TestLooksLikeJWT(t *testing.T) {
})
}
}

func TestParseVerifiedMultipleTokenAudiences(t *testing.T) {
ctx := context.Background()

privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(t, err)
publicKey := &privateKey.PublicKey

// Auth0 access tokens commonly carry multiple audiences (API + /userinfo)
claims := jwt.MapClaims{
"sub": "test-user-123",
"iss": "https://test.auth0.com/",
"aud": []string{"https://lfx-api.example.org/", "https://test.auth0.com/userinfo"},
"exp": time.Now().Add(time.Hour).Unix(),
"iat": time.Now().Unix(),
}
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
tokenString, err := token.SignedString(privateKey)
require.NoError(t, err)

opts := &ParseOptions{
VerifySignature: true,
SigningKey: publicKey,
ExpectedIssuer: "https://test.auth0.com/",
ExpectedAudience: "https://test.auth0.com/userinfo", // matches the second audience
RequireExpiration: true,
RequireSubject: true,
}

parsed, err := ParseVerified(ctx, tokenString, opts)
require.NoError(t, err)
assert.Equal(t, "https://lfx-api.example.org/", parsed.Audience)
assert.Equal(t, []string{"https://lfx-api.example.org/", "https://test.auth0.com/userinfo"}, parsed.Audiences)
}

func TestClaimsHasAudience(t *testing.T) {
tests := []struct {
name string
claims *Claims
audience string
expected bool
}{
{
name: "match in Audiences list",
claims: &Claims{Audience: "a", Audiences: []string{"a", "b"}},
audience: "b",
expected: true,
},
{
name: "no match",
claims: &Claims{Audience: "a", Audiences: []string{"a", "b"}},
audience: "c",
expected: false,
},
{
name: "fallback to single Audience field",
claims: &Claims{Audience: "a"},
audience: "a",
expected: true,
},
{
name: "empty claims",
claims: &Claims{},
audience: "a",
expected: false,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.expected, tt.claims.HasAudience(tt.audience))
})
}
}
Loading