From b8a8be33aa578789cd5c11a4fed046356f85b776 Mon Sep 17 00:00:00 2001 From: mposolda Date: Wed, 5 Nov 2025 18:03:19 +0100 Subject: [PATCH] Audience validation according to latest specs proposal closes #43984 Signed-off-by: mposolda --- .../JWTAuthorizationGrantProvider.java | 7 +++++ .../broker/oidc/OIDCIdentityProvider.java | 12 +++++++ .../grants/JWTAuthorizationGrantType.java | 6 ++-- .../main/java/org/keycloak/services/Urls.java | 4 +++ .../oauth/JWTAuthorizationGrantTest.java | 31 ++++++++++++++++++- 5 files changed, 56 insertions(+), 4 deletions(-) diff --git a/server-spi-private/src/main/java/org/keycloak/broker/provider/JWTAuthorizationGrantProvider.java b/server-spi-private/src/main/java/org/keycloak/broker/provider/JWTAuthorizationGrantProvider.java index 89da73a80f1..7c632b8548b 100644 --- a/server-spi-private/src/main/java/org/keycloak/broker/provider/JWTAuthorizationGrantProvider.java +++ b/server-spi-private/src/main/java/org/keycloak/broker/provider/JWTAuthorizationGrantProvider.java @@ -15,6 +15,8 @@ * limitations under the License. */ package org.keycloak.broker.provider; +import java.util.List; + import org.keycloak.protocol.oidc.JWTAuthorizationGrantValidationContext; public interface JWTAuthorizationGrantProvider { @@ -25,4 +27,9 @@ public interface JWTAuthorizationGrantProvider { boolean isAssertionReuseAllowed(); + /** + * @return list of allowed audience values. JWT assertion is considered valid if it's audience is one of the audiences returned from this method + */ + List getAllowedAudienceForJWTGrant(); + } diff --git a/services/src/main/java/org/keycloak/broker/oidc/OIDCIdentityProvider.java b/services/src/main/java/org/keycloak/broker/oidc/OIDCIdentityProvider.java index bb220ec5a23..cf2777a4492 100755 --- a/services/src/main/java/org/keycloak/broker/oidc/OIDCIdentityProvider.java +++ b/services/src/main/java/org/keycloak/broker/oidc/OIDCIdentityProvider.java @@ -75,6 +75,7 @@ import org.keycloak.representations.IDToken; import org.keycloak.representations.JsonWebToken; import org.keycloak.services.ErrorPage; import org.keycloak.services.ErrorResponseException; +import org.keycloak.services.Urls; import org.keycloak.services.managers.AuthenticationManager; import org.keycloak.services.messages.Messages; import org.keycloak.services.resources.IdentityBrokerService; @@ -86,6 +87,7 @@ import org.keycloak.util.TokenUtil; import org.keycloak.vault.VaultStringSecret; import java.io.IOException; +import java.net.URI; import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Arrays; @@ -1096,4 +1098,14 @@ public class OIDCIdentityProvider extends AbstractOAuth2IdentityProvider getAllowedAudienceForJWTGrant() { + RealmModel realm = session.getContext().getRealm(); + + URI baseUri = session.getContext().getUri().getBaseUri(); + String issuer = Urls.realmIssuer(baseUri, realm.getName()); + String tokenEndpoint = Urls.tokenEndpoint(baseUri, realm.getName()).toString(); + return List.of(issuer, tokenEndpoint); + } } diff --git a/services/src/main/java/org/keycloak/protocol/oidc/grants/JWTAuthorizationGrantType.java b/services/src/main/java/org/keycloak/protocol/oidc/grants/JWTAuthorizationGrantType.java index 79507d11d1e..d8e9654980d 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc/grants/JWTAuthorizationGrantType.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/grants/JWTAuthorizationGrantType.java @@ -44,7 +44,6 @@ import org.keycloak.sessions.AuthenticationSessionModel; import org.keycloak.sessions.RootAuthenticationSessionModel; import jakarta.ws.rs.core.Response; -import java.util.List; public class JWTAuthorizationGrantType extends OAuth2GrantTypeBase { @@ -53,7 +52,6 @@ public class JWTAuthorizationGrantType extends OAuth2GrantTypeBase { setContext(context); String assertion = formParams.getFirst(OAuth2Constants.ASSERTION); - String expectedAudience = Urls.realmIssuer(session.getContext().getUri().getBaseUri(), realm.getName()); try { @@ -64,7 +62,6 @@ public class JWTAuthorizationGrantType extends OAuth2GrantTypeBase { authorizationGrantContext.validateClient(); //mandatory claims - authorizationGrantContext.validateTokenAudience(List.of(expectedAudience), false); authorizationGrantContext.validateIssuer(); authorizationGrantContext.validateSubject(); @@ -88,6 +85,9 @@ public class JWTAuthorizationGrantType extends OAuth2GrantTypeBase { // assign the provider and perform validations associated to the jwt grant provider authorizationGrantContext.validateTokenActive(jwtAuthorizationGrantProvider.getAllowedClockSkew(), 300, jwtAuthorizationGrantProvider.isAssertionReuseAllowed()); + // Validate audience + authorizationGrantContext.validateTokenAudience(jwtAuthorizationGrantProvider.getAllowedAudienceForJWTGrant(), false); + //validate the JWT assertion and get the brokered identity from the idp BrokeredIdentityContext brokeredIdentityContext = jwtAuthorizationGrantProvider.validateAuthorizationGrantAssertion(authorizationGrantContext); if (brokeredIdentityContext == null) { diff --git a/services/src/main/java/org/keycloak/services/Urls.java b/services/src/main/java/org/keycloak/services/Urls.java index 00b3d0ea06d..b4ada717beb 100755 --- a/services/src/main/java/org/keycloak/services/Urls.java +++ b/services/src/main/java/org/keycloak/services/Urls.java @@ -184,6 +184,10 @@ public class Urls { return tokenBase(baseUri).path(OIDCLoginProtocolService.class, "logout"); } + public static URI tokenEndpoint(URI baseUri, String realmName) { + return tokenBase(baseUri).path(OIDCLoginProtocolService.class, "token").build(realmName); + } + public static URI realmRegisterAction(URI baseUri, String realmName) { return loginActionsBase(baseUri).path(LoginActionsService.class, "processRegister").build(realmName); } diff --git a/tests/base/src/test/java/org/keycloak/tests/oauth/JWTAuthorizationGrantTest.java b/tests/base/src/test/java/org/keycloak/tests/oauth/JWTAuthorizationGrantTest.java index d9d9bd785ed..563899d6913 100644 --- a/tests/base/src/test/java/org/keycloak/tests/oauth/JWTAuthorizationGrantTest.java +++ b/tests/base/src/test/java/org/keycloak/tests/oauth/JWTAuthorizationGrantTest.java @@ -125,7 +125,7 @@ public class JWTAuthorizationGrantTest { } @Test - public void testBadAudience() { + public void testAudience() { String jwt = getIdentityProvider().encodeToken(createAuthorizationGrantToken("basic-user-id", null, IDP_ISSUER, Time.currentTime() + 300L)); AccessTokenResponse response = oAuthClient.jwtAuthorizationGrantRequest(jwt).send(); assertFailure("Invalid token audience", response, events.poll()); @@ -133,6 +133,35 @@ public class JWTAuthorizationGrantTest { jwt = getIdentityProvider().encodeToken(createAuthorizationGrantToken("basic-user-id", "fake-audience", IDP_ISSUER)); response = oAuthClient.jwtAuthorizationGrantRequest(jwt).send(); assertFailure("Invalid token audience", response, events.poll()); + + // Issuer as audience works + jwt = getIdentityProvider().encodeToken(createAuthorizationGrantToken("basic-user-id", oAuthClient.getEndpoints().getIssuer(), IDP_ISSUER)); + response = oAuthClient.jwtAuthorizationGrantRequest(jwt).send(); + assertSuccess("test-app", "basic-user", response); + + // Token endpoint as audience works + jwt = getIdentityProvider().encodeToken(createAuthorizationGrantToken("basic-user-id", oAuthClient.getEndpoints().getToken(), IDP_ISSUER)); + response = oAuthClient.jwtAuthorizationGrantRequest(jwt).send(); + assertSuccess("test-app", "basic-user", response); + + // Introspection endpoint as audience does not work + jwt = getIdentityProvider().encodeToken(createAuthorizationGrantToken("basic-user-id", oAuthClient.getEndpoints().getIntrospection(), IDP_ISSUER)); + response = oAuthClient.jwtAuthorizationGrantRequest(jwt).send(); + assertFailure("Invalid token audience", response, events.poll()); + + // Multiple audiences does not work + JsonWebToken jwtToken = createAuthorizationGrantToken("basic-user-id", oAuthClient.getEndpoints().getIssuer(), IDP_ISSUER); + jwtToken.addAudience("fake"); + jwt = getIdentityProvider().encodeToken(jwtToken); + response = oAuthClient.jwtAuthorizationGrantRequest(jwt).send(); + assertFailure("Multiple audiences not allowed", response, events.poll()); + + // Multiple audiences does not work (even if both are valid) + jwtToken = createAuthorizationGrantToken("basic-user-id", oAuthClient.getEndpoints().getIssuer(), IDP_ISSUER); + jwtToken.addAudience(oAuthClient.getEndpoints().getToken()); + jwt = getIdentityProvider().encodeToken(jwtToken); + response = oAuthClient.jwtAuthorizationGrantRequest(jwt).send(); + assertFailure("Multiple audiences not allowed", response, events.poll()); } @Test