This is an automated email from the ASF dual-hosted git repository. jamesbognar pushed a commit to branch master in repository https://gitbox.apache.org/repos/asf/juneau.git
commit d45900b20c4e205b125a7fc15336c1bf307d36dd Author: James Bognar <[email protected]> AuthorDate: Sun Aug 16 18:49:17 2026 -0400 READY-381: Bind OAuth introspection results to the expected audience/resource OAuthIntrospectionValidator now rejects an otherwise-valid introspection response if it isn't scoped to the resource server this validator is protecting, closing a token-confusion gap where a token minted for one resource could be accepted by another. --- .../auth/oauth/OAuthIntrospectionValidator.java | 71 +++++++++++ .../OAuthIntrospectionValidator_Audience_Test.java | 141 +++++++++++++++++++++ .../OAuthIntrospectionValidator_Builder_Test.java | 20 +++ 3 files changed, 232 insertions(+) diff --git a/juneau-rest/juneau-rest-server-auth-oauth/src/main/java/org/apache/juneau/rest/server/auth/oauth/OAuthIntrospectionValidator.java b/juneau-rest/juneau-rest-server-auth-oauth/src/main/java/org/apache/juneau/rest/server/auth/oauth/OAuthIntrospectionValidator.java index f3eb74de76..fb5a75f65e 100644 --- a/juneau-rest/juneau-rest-server-auth-oauth/src/main/java/org/apache/juneau/rest/server/auth/oauth/OAuthIntrospectionValidator.java +++ b/juneau-rest/juneau-rest-server-auth-oauth/src/main/java/org/apache/juneau/rest/server/auth/oauth/OAuthIntrospectionValidator.java @@ -102,6 +102,7 @@ public class OAuthIntrospectionValidator implements TokenValidator { private TokenCache tokenCache; private Duration cacheTtl = DEFAULT_CACHE_TTL; private Set<String> requiredScopes = st(); + private Set<String> expectedAudiences = st(); private Clock clock = Clock.systemUTC(); private Consumer<HTTPRequest> httpRequestConfigurator; @@ -209,6 +210,47 @@ public class OAuthIntrospectionValidator implements TokenValidator { return this; } + /** + * Adds expected audience values (RFC 7662 {@code aud}) that a token must carry to be accepted. + * + * <p> + * When at least one value is configured here (via this method or {@link #resource(String...)}), the + * introspection response's {@code aud} claim must contain <b>at least one</b> matching value, or the + * token is rejected as {@code invalid_token} — even when {@code active=true} and required scopes + * match. This binds the token to the resource server it was minted for, so a token issued for a + * different API cannot be replayed against this one. + * + * <p> + * When left unset (the default), no audience/resource check is performed: today's + * {@code active} + optional-required-scopes behavior is preserved unchanged. + * + * @param values The expected audience values. Must contain at least one non-blank entry. + * @return This object. + */ + public Builder audience(String... values) { + assertArgNotNull("values", values); + for (var v : values) { + assertArgNotNullOrBlank("audience", v); + expectedAudiences.add(v); + } + return this; + } + + /** + * Adds expected resource-indicator values (RFC 8707). + * + * <p> + * Alias for {@link #audience(String...)}: RFC 8707 resource indicators are echoed back by + * conformant authorization servers in the same introspection {@code aud} claim as RFC 7662 + * audiences, so both methods add to the same expected-value set. + * + * @param values The expected resource values. Must contain at least one non-blank entry. + * @return This object. + */ + public Builder resource(String... values) { + return audience(values); + } + /** * Overrides the {@link Clock} used for cache expiry. Useful in tests. * @@ -259,6 +301,7 @@ public class OAuthIntrospectionValidator implements TokenValidator { private final TokenCache tokenCache; private final Duration cacheTtl; private final Set<String> requiredScopes; + private final Set<String> expectedAudiences; private final Clock clock; private final Consumer<HTTPRequest> httpRequestConfigurator; @@ -274,6 +317,7 @@ public class OAuthIntrospectionValidator implements TokenValidator { this.tokenCache = b.tokenCache; this.cacheTtl = b.cacheTtl; this.requiredScopes = u(cp(b.requiredScopes)); + this.expectedAudiences = u(cp(b.expectedAudiences)); this.clock = b.clock; this.httpRequestConfigurator = b.httpRequestConfigurator; } @@ -314,6 +358,15 @@ public class OAuthIntrospectionValidator implements TokenValidator { return requiredScopes; } + /** + * Returns the configured expected audience / resource-indicator set. + * + * @return An unmodifiable view. Empty when no audience/resource binding is configured (default). + */ + public Set<String> getExpectedAudiences() { + return expectedAudiences; + } + /** * Returns the underlying token cache. * @@ -362,6 +415,7 @@ public class OAuthIntrospectionValidator implements TokenValidator { if (!success.isActive()) throw new AuthenticationException("OAuth token inactive") .wwwAuthenticate(bearerError("invalid_token", "token inactive")); + enforceAudience(success); var scopes = extractScopes(success); enforceRequiredScopes(scopes); var claims = buildClaims(success, scopes); @@ -383,6 +437,23 @@ public class OAuthIntrospectionValidator implements TokenValidator { return out; } + /** + * Rejects the token when an expected audience/resource set is configured and the introspection + * response's {@code aud} claim contains none of the expected values. No-op when + * {@link #expectedAudiences} is empty (no bind configured). + */ + private void enforceAudience(TokenIntrospectionSuccessResponse success) throws AuthenticationException { + if (expectedAudiences.isEmpty()) + return; + var tokenAudiences = success.getAudience(); + var matched = tokenAudiences != null && tokenAudiences.stream() + .map(Audience::getValue) + .anyMatch(expectedAudiences::contains); + if (!matched) + throw new AuthenticationException("OAuth token audience/resource mismatch") + .wwwAuthenticate(bearerError("invalid_token", "audience mismatch")); + } + private void enforceRequiredScopes(Set<String> tokenScopes) throws AuthenticationException { for (var req : requiredScopes) { if (!tokenScopes.contains(req)) diff --git a/juneau-rest/juneau-rest-server-auth-oauth/src/test/java/org/apache/juneau/rest/server/auth/oauth/OAuthIntrospectionValidator_Audience_Test.java b/juneau-rest/juneau-rest-server-auth-oauth/src/test/java/org/apache/juneau/rest/server/auth/oauth/OAuthIntrospectionValidator_Audience_Test.java new file mode 100644 index 0000000000..c1e0f923e8 --- /dev/null +++ b/juneau-rest/juneau-rest-server-auth-oauth/src/test/java/org/apache/juneau/rest/server/auth/oauth/OAuthIntrospectionValidator_Audience_Test.java @@ -0,0 +1,141 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.juneau.rest.server.auth.oauth; + +import static org.junit.jupiter.api.Assertions.*; + +import java.io.*; +import java.net.*; +import java.nio.charset.*; + +import org.apache.juneau.*; +import org.apache.juneau.rest.server.auth.*; +import org.junit.jupiter.api.*; + +import com.sun.net.httpserver.*; + +/** + * Tests that {@link OAuthIntrospectionValidator} binds introspected tokens to an expected audience/resource + * when one is configured via {@link OAuthIntrospectionValidator.Builder#audience(String...)} / + * {@link OAuthIntrospectionValidator.Builder#resource(String...)}, and leaves today's active/scope-only + * behavior unchanged when no audience is configured. + * + * @since 10.0.0 + */ +@SuppressWarnings({ + "resource" // HttpServer held as test fixture; lifecycle managed by @AfterEach +}) +class OAuthIntrospectionValidator_Audience_Test extends TestBase { + + private HttpServer server; + private volatile String nextResponse; + + @BeforeEach + void startServer() throws IOException { + server = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0); + server.createContext("/introspect", ex -> { + var body = nextResponse.getBytes(StandardCharsets.UTF_8); + ex.getResponseHeaders().add("Content-Type", "application/json"); + ex.sendResponseHeaders(200, body.length); + try (var os = ex.getResponseBody()) { + os.write(body); + } + }); + server.start(); + } + + @AfterEach + void stopServer() { + server.stop(0); + } + + private URI endpoint() { + return URI.create("http://127.0.0.1:" + server.getAddress().getPort() + "/introspect"); + } + + private OAuthIntrospectionValidator.Builder validatorBuilder() { + return OAuthIntrospectionValidator.create() + .introspectionEndpoint(endpoint()) + .clientId("client") + .clientSecret("secret"); + } + + // ----------------------------------------------------------------------------------------------------------------- + // A: expected audience configured — mismatch rejected even though active=true and scopes match. + // ----------------------------------------------------------------------------------------------------------------- + + @Test void a01_audienceConfigured_mismatchedSingleAud_rejected() { + nextResponse = "{\"active\":true,\"sub\":\"alice\",\"scope\":\"read\",\"aud\":\"https://other.example\"}"; + var v = validatorBuilder().audience("https://api.example").requiredScopes("read").build(); + var ex = assertThrows(AuthenticationException.class, () -> v.validate("tok-wrong-aud")); + assertTrue(ex.getMessage().toLowerCase().contains("audience"), "Expected an audience-mismatch message, got: " + ex.getMessage()); + } + + @Test void a02_audienceConfigured_noAudInResponse_rejected() { + // Token is active with matching scopes but the AS returned no aud claim at all. + nextResponse = "{\"active\":true,\"sub\":\"alice\",\"scope\":\"read\"}"; + var v = validatorBuilder().audience("https://api.example").build(); + var ex = assertThrows(AuthenticationException.class, () -> v.validate("tok-no-aud")); + assertTrue(ex.getMessage().toLowerCase().contains("audience"), "Expected an audience-mismatch message, got: " + ex.getMessage()); + } + + // ----------------------------------------------------------------------------------------------------------------- + // B: expected audience configured — matching audience accepted. + // ----------------------------------------------------------------------------------------------------------------- + + @Test void b01_audienceConfigured_matchingSingleAud_accepted() throws Exception { + nextResponse = "{\"active\":true,\"sub\":\"alice\",\"scope\":\"read\",\"aud\":\"https://api.example\"}"; + var v = validatorBuilder().audience("https://api.example").build(); + var p = v.validate("tok-right-aud"); + assertEquals("alice", p.getName()); + } + + @Test void b02_multiValuedAud_containingExpectedValue_accepted() throws Exception { + // aud as a JSON array with multiple values; the expected value is one of several. + nextResponse = "{\"active\":true,\"sub\":\"alice\",\"scope\":\"read\",\"aud\":[\"https://other.example\",\"https://api.example\"]}"; + var v = validatorBuilder().audience("https://api.example").build(); + var p = v.validate("tok-multi-aud"); + assertEquals("alice", p.getName()); + } + + @Test void b03_resourceAlias_matchesAudClaim() throws Exception { + // resource(...) is an alias for audience(...) — validated against the same aud claim. + nextResponse = "{\"active\":true,\"sub\":\"alice\",\"scope\":\"read\",\"aud\":\"https://api.example\"}"; + var v = validatorBuilder().resource("https://api.example").build(); + var p = v.validate("tok-resource-alias"); + assertEquals("alice", p.getName()); + } + + // ----------------------------------------------------------------------------------------------------------------- + // C: no audience configured — today's active/scope-only behavior is unchanged (regression guard). + // ----------------------------------------------------------------------------------------------------------------- + + @Test void c01_noAudienceConfigured_mismatchedAudStillAccepted() throws Exception { + // No audience bind configured — an "aud" for a completely different RS must NOT cause rejection. + nextResponse = "{\"active\":true,\"sub\":\"alice\",\"scope\":\"read\",\"aud\":\"https://other.example\"}"; + var v = validatorBuilder().build(); + var p = v.validate("tok-no-bind"); + assertEquals("alice", p.getName()); + } + + @Test void c02_noAudienceConfigured_noAudClaimAtAll_stillAccepted() throws Exception { + nextResponse = "{\"active\":true,\"sub\":\"alice\",\"scope\":\"read\"}"; + var v = validatorBuilder().build(); + var p = v.validate("tok-no-bind-no-aud"); + assertEquals("alice", p.getName()); + } +} diff --git a/juneau-rest/juneau-rest-server-auth-oauth/src/test/java/org/apache/juneau/rest/server/auth/oauth/OAuthIntrospectionValidator_Builder_Test.java b/juneau-rest/juneau-rest-server-auth-oauth/src/test/java/org/apache/juneau/rest/server/auth/oauth/OAuthIntrospectionValidator_Builder_Test.java index 4deadd84d9..67258d4808 100644 --- a/juneau-rest/juneau-rest-server-auth-oauth/src/test/java/org/apache/juneau/rest/server/auth/oauth/OAuthIntrospectionValidator_Builder_Test.java +++ b/juneau-rest/juneau-rest-server-auth-oauth/src/test/java/org/apache/juneau/rest/server/auth/oauth/OAuthIntrospectionValidator_Builder_Test.java @@ -88,6 +88,26 @@ class OAuthIntrospectionValidator_Builder_Test extends TestBase { assertThrows(IllegalArgumentException.class, () -> fresh().requiredScopes("ok", " ")); } + @Test void c03_audience_accumulate() { + var v = fresh().audience("api1").audience("api2", "api3").build(); + assertEquals(java.util.Set.of("api1", "api2", "api3"), v.getExpectedAudiences()); + } + + @Test void c04_audience_blankRejected() { + assertThrows(IllegalArgumentException.class, () -> fresh().audience("")); + assertThrows(IllegalArgumentException.class, () -> fresh().audience("ok", " ")); + } + + @Test void c05_resource_isAliasForAudience() { + var v = fresh().resource("api1").audience("api2").build(); + assertEquals(java.util.Set.of("api1", "api2"), v.getExpectedAudiences()); + } + + @Test void c06_noAudienceConfigured_emptyByDefault() { + var v = fresh().build(); + assertTrue(v.getExpectedAudiences().isEmpty()); + } + @Test void d01_clientSecret_blankRejected() { assertThrows(IllegalArgumentException.class, () -> OAuthIntrospectionValidator.create().clientSecret("")); }
