This is an automated email from the ASF dual-hosted git repository.
roryqi pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/gravitino.git
The following commit(s) were added to refs/heads/main by this push:
new 6d6d0083cd [#9733]feat(oauth): Support tokens with multiple audiences
in JWKS validator (#9734)
6d6d0083cd is described below
commit 6d6d0083cd460d150915e299c3796350806213cd
Author: Bharath Krishna <[email protected]>
AuthorDate: Fri Jan 16 21:31:23 2026 -0800
[#9733]feat(oauth): Support tokens with multiple audiences in JWKS
validator (#9734)
### What changes were proposed in this pull request?
Modified `JwksTokenValidator` to use Nimbus's `acceptedAudiences` Set
parameter instead of `exactMatchClaims` for audience validation. This
enables proper RFC 7519 compliant multi-audience token support.
StaticSignKeyValidator already supports multiple audiences.
### Why are the changes needed?
JwksTokenValidator currently rejects valid JWT tokens that contain
multiple audiences (e.g., `["service-a", "service-b", "service-c"]`),
even when the configured service audience is present in the list. This
is because the validator uses `exactMatchClaims` which requires exact
array equality rather than "at-least-one match" semantics defined in RFC
7519.
Fix: #9733
### Does this PR introduce _any_ user-facing change?
Yes - JWT tokens with multiple audiences in the `aud` claim are now
properly validated when the configured service audience is present in
the token's audience list. Previously only single-audience tokens were
supported.
### How was this patch tested?
Added `testValidateTokenWithMultipleAudiences()` that verifies:
1. Token with `["other-service", "test-service", "another-service"]`
validates successfully against `"test-service"`
2. Same token fails validation against `"incorrect-service"`
All existing OAuth authentication tests pass.
---
.../server/authentication/JwksTokenValidator.java | 24 +++++-----
.../authentication/TestJwksTokenValidator.java | 54 ++++++++++++++++++++++
2 files changed, 67 insertions(+), 11 deletions(-)
diff --git
a/server-common/src/main/java/org/apache/gravitino/server/authentication/JwksTokenValidator.java
b/server-common/src/main/java/org/apache/gravitino/server/authentication/JwksTokenValidator.java
index cb47d61848..5bd0681d47 100644
---
a/server-common/src/main/java/org/apache/gravitino/server/authentication/JwksTokenValidator.java
+++
b/server-common/src/main/java/org/apache/gravitino/server/authentication/JwksTokenValidator.java
@@ -31,7 +31,9 @@ import com.nimbusds.jwt.proc.DefaultJWTClaimsVerifier;
import com.nimbusds.jwt.proc.DefaultJWTProcessor;
import java.net.URL;
import java.security.Principal;
+import java.util.Collections;
import java.util.List;
+import java.util.Set;
import org.apache.commons.lang3.StringUtils;
import org.apache.gravitino.Config;
import org.apache.gravitino.UserPrincipal;
@@ -101,27 +103,27 @@ public class JwksTokenValidator implements
OAuthTokenValidator {
DefaultJWTProcessor<SecurityContext> jwtProcessor = new
DefaultJWTProcessor<>();
jwtProcessor.setJWSKeySelector(keySelector);
- // Configure claims verification
- JWTClaimsSet.Builder expectedClaimsBuilder = new JWTClaimsSet.Builder();
-
- // Set expected issuer if configured
- if (StringUtils.isNotBlank(expectedIssuer)) {
- expectedClaimsBuilder.issuer(expectedIssuer);
+ // Audience validation per RFC 7519 (at-least-one match)
+ Set<String> acceptedAudiences = null;
+ if (StringUtils.isNotBlank(serviceAudience)) {
+ acceptedAudiences = Collections.singleton(serviceAudience);
}
- // Set expected audience if provided
- if (StringUtils.isNotBlank(serviceAudience)) {
- expectedClaimsBuilder.audience(serviceAudience);
+ // Build exact match claims for issuer validation
+ JWTClaimsSet.Builder exactMatchBuilder = new JWTClaimsSet.Builder();
+ if (StringUtils.isNotBlank(expectedIssuer)) {
+ exactMatchBuilder.issuer(expectedIssuer);
}
+ JWTClaimsSet exactMatchClaims = exactMatchBuilder.build();
DefaultJWTClaimsVerifier<SecurityContext> claimsVerifier =
- new
DefaultJWTClaimsVerifier<SecurityContext>(expectedClaimsBuilder.build(), null);
+ new DefaultJWTClaimsVerifier<>(acceptedAudiences, exactMatchClaims,
null, null);
// Set clock skew tolerance
claimsVerifier.setMaxClockSkew((int) allowSkewSeconds);
jwtProcessor.setJWTClaimsSetVerifier(claimsVerifier);
- // Process and validate the token
+ // Validate token signature and claims
JWTClaimsSet validatedClaims = jwtProcessor.process(signedJWT, null);
String principal = extractPrincipal(validatedClaims);
diff --git
a/server-common/src/test/java/org/apache/gravitino/server/authentication/TestJwksTokenValidator.java
b/server-common/src/test/java/org/apache/gravitino/server/authentication/TestJwksTokenValidator.java
index 2eaf800415..a2f2362d87 100644
---
a/server-common/src/test/java/org/apache/gravitino/server/authentication/TestJwksTokenValidator.java
+++
b/server-common/src/test/java/org/apache/gravitino/server/authentication/TestJwksTokenValidator.java
@@ -291,6 +291,60 @@ public class TestJwksTokenValidator {
}
}
+ @Test
+ public void testValidateTokenWithMultipleAudiences() throws Exception {
+ // Generate a test RSA key pair
+ RSAKey rsaKey =
+ new
RSAKeyGenerator(2048).keyID("test-key-id").algorithm(JWSAlgorithm.RS256).generate();
+
+ // Mock the JWKSourceBuilder to return our test key
+ try (MockedStatic<JWKSourceBuilder> mockedBuilder =
mockStatic(JWKSourceBuilder.class)) {
+ @SuppressWarnings("unchecked")
+ JWKSource<SecurityContext> mockJwkSource = mock(JWKSource.class);
+ @SuppressWarnings("unchecked")
+ JWKSourceBuilder<SecurityContext> mockBuilder =
mock(JWKSourceBuilder.class);
+
+ mockedBuilder.when(() ->
JWKSourceBuilder.create(any(URL.class))).thenReturn(mockBuilder);
+ when(mockBuilder.build()).thenReturn(mockJwkSource);
+ when(mockJwkSource.get(any(), any())).thenReturn(Arrays.asList(rsaKey));
+
+ // Initialize validator
+ Map<String, String> config = new HashMap<>();
+ config.put(
+ "gravitino.authenticator.oauth.jwksUri",
"https://test-jwks.com/.well-known/jwks.json");
+ config.put("gravitino.authenticator.oauth.authority",
"https://test-issuer.com");
+ config.put("gravitino.authenticator.oauth.principalFields", "sub");
+ config.put("gravitino.authenticator.oauth.allowSkewSecs", "60");
+
+ validator.initialize(createConfig(config));
+
+ // Test 1: Token with multiple audiences including our service - should
succeed
+ JWTClaimsSet validClaimsSet =
+ new JWTClaimsSet.Builder()
+ .subject("test-user")
+ .audience(Arrays.asList("other-service", "test-service",
"another-service"))
+ .issuer("https://test-issuer.com")
+ .expirationTime(Date.from(Instant.now().plusSeconds(3600)))
+ .issueTime(Date.from(Instant.now()))
+ .build();
+
+ SignedJWT validToken =
+ new SignedJWT(
+ new
JWSHeader.Builder(JWSAlgorithm.RS256).keyID("test-key-id").build(),
+ validClaimsSet);
+ validToken.sign(new RSASSASigner(rsaKey));
+
+ Principal result = validator.validateToken(validToken.serialize(),
"test-service");
+ assertNotNull(result);
+ assertEquals("test-user", result.getName());
+
+ // Test 2: Same token, different service name - should fail
+ assertThrows(
+ UnauthorizedException.class,
+ () -> validator.validateToken(validToken.serialize(),
"incorrect-service"));
+ }
+ }
+
@Test
public void testValidateTokenWithInvalidToken() {
Map<String, String> config = new HashMap<>();