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} &mdash; 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(""));
        }

Reply via email to