diff --git a/services/src/main/java/org/keycloak/services/clientpolicy/executor/SecureClientAuthenticationAssertionExecutor.java b/services/src/main/java/org/keycloak/services/clientpolicy/executor/SecureClientAuthenticationAssertionExecutor.java
new file mode 100644
index 000000000000..2f4125dadb5d
--- /dev/null
+++ b/services/src/main/java/org/keycloak/services/clientpolicy/executor/SecureClientAuthenticationAssertionExecutor.java
@@ -0,0 +1,117 @@
+/*
+ * Copyright 2025 Red Hat, Inc. and/or its affiliates
+ * and other contributors as indicated by the @author tags.
+ *
+ * Licensed 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.keycloak.services.clientpolicy.executor;
+
+import jakarta.ws.rs.core.MultivaluedMap;
+import org.jboss.logging.Logger;
+import org.keycloak.OAuth2Constants;
+import org.keycloak.OAuthErrorException;
+import org.keycloak.common.util.Base64Url;
+import org.keycloak.models.ClientModel;
+import org.keycloak.models.KeycloakContext;
+import org.keycloak.models.KeycloakSession;
+import org.keycloak.representations.JsonWebToken;
+import org.keycloak.representations.idm.ClientPolicyExecutorConfigurationRepresentation;
+import org.keycloak.services.Urls;
+import org.keycloak.services.clientpolicy.ClientPolicyContext;
+import org.keycloak.services.clientpolicy.ClientPolicyException;
+import org.keycloak.util.JsonSerialization;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * @author Takashi Norimatsu
+ */
+public class SecureClientAuthenticationAssertionExecutor implements ClientPolicyExecutorProvider {
+
+ private static final Logger logger = Logger.getLogger(SecureClientAuthenticationAssertionExecutor.class);
+
+ private final KeycloakSession session;
+
+ public SecureClientAuthenticationAssertionExecutor(KeycloakSession session) {
+ this.session = session;
+ }
+
+ @Override
+ public String getProviderId() {
+ return SecureClientAuthenticationAssertionExecutorFactory.PROVIDER_ID;
+ }
+
+ @Override
+ public void executeOnEvent(ClientPolicyContext context) throws ClientPolicyException {
+ switch (context.getEvent()) {
+ case PUSHED_AUTHORIZATION_REQUEST:
+ case TOKEN_REQUEST:
+ case TOKEN_REFRESH:
+ case TOKEN_INTROSPECT:
+ case TOKEN_REVOKE:
+ validateClientAssertion();
+ default:
+ }
+ }
+
+ private void validateClientAssertion() throws ClientPolicyException {
+ KeycloakContext context = session.getContext();
+ ClientModel client = context.getClient();
+
+ // a public client does not need to send its credential for client authentication
+ if (client.isPublicClient()) return;
+
+ MultivaluedMap params = context.getHttpRequest().getDecodedFormParameters();
+ String clientAssertionType = params.getFirst(OAuth2Constants.CLIENT_ASSERTION_TYPE);
+ String clientAssertion = params.getFirst(OAuth2Constants.CLIENT_ASSERTION);
+
+ if (clientAssertionType == null || clientAssertion == null || !clientAssertionType.equals(OAuth2Constants.CLIENT_ASSERTION_TYPE_JWT)) {
+ // if the client did not send client assertion, then skit the further validation.
+ return;
+ }
+
+ // Validate the client assertion format
+ String[] parts = clientAssertion.split("\\.");
+ if (parts.length < 2 || parts.length > 3) {
+ logger.warn("client assertion format error");
+ throw new ClientPolicyException(OAuthErrorException.INVALID_REQUEST, "invalid client assertion format");
+ }
+
+ // Decode the client assertion
+ String encodedContent = parts[1];
+ byte[] content = Base64Url.decode(encodedContent);
+ JsonWebToken token;
+ try {
+ token = JsonSerialization.readValue(content, JsonWebToken.class);
+ } catch (IOException e) {
+ logger.warnf("client assertion parse error: %s", e.getMessage());
+ throw new ClientPolicyException(OAuthErrorException.INVALID_REQUEST, "invalid client assertion");
+ }
+
+ // Validate the client assertion audience
+ String issuerUrl = Urls.realmIssuer(session.getContext().getUri().getBaseUri(), session.getContext().getRealm().getName());
+ List expectedAudiences = new ArrayList<>(Collections.singletonList(issuerUrl));
+ if (token.getAudience() == null || token.getAudience().length != 1) {
+ logger.warnf("invalid audience in client assertion - no audience or multiple audiences found");
+ throw new ClientPolicyException(OAuthErrorException.INVALID_REQUEST, "invalid audience in client assertion");
+ }
+ if (!token.hasAnyAudience(expectedAudiences)) {
+ logger.warnf("invalid audience in client assertion - audience not issuer URL");
+ throw new ClientPolicyException(OAuthErrorException.INVALID_REQUEST, "invalid audience in client assertion");
+ }
+ }
+}
diff --git a/services/src/main/java/org/keycloak/services/clientpolicy/executor/SecureClientAuthenticationAssertionExecutorFactory.java b/services/src/main/java/org/keycloak/services/clientpolicy/executor/SecureClientAuthenticationAssertionExecutorFactory.java
new file mode 100644
index 000000000000..8916f82cd923
--- /dev/null
+++ b/services/src/main/java/org/keycloak/services/clientpolicy/executor/SecureClientAuthenticationAssertionExecutorFactory.java
@@ -0,0 +1,65 @@
+/*
+ * Copyright 2025 Red Hat, Inc. and/or its affiliates
+ * and other contributors as indicated by the @author tags.
+ *
+ * Licensed 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.keycloak.services.clientpolicy.executor;
+
+import org.keycloak.Config;
+import org.keycloak.models.KeycloakSession;
+import org.keycloak.models.KeycloakSessionFactory;
+import org.keycloak.provider.ProviderConfigProperty;
+
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * @author Takashi Norimatsu
+ */
+public class SecureClientAuthenticationAssertionExecutorFactory implements ClientPolicyExecutorProviderFactory {
+ public static final String PROVIDER_ID = "secure-client-authentication-assertion";
+
+ @Override
+ public ClientPolicyExecutorProvider create(KeycloakSession session) {
+ return new SecureClientAuthenticationAssertionExecutor(session);
+ }
+
+ @Override
+ public void init(Config.Scope config) {
+ }
+
+ @Override
+ public void postInit(KeycloakSessionFactory factory) {
+ }
+
+ @Override
+ public void close() {
+ }
+
+ @Override
+ public String getId() {
+ return PROVIDER_ID;
+ }
+
+ @Override
+ public String getHelpText() {
+ return "This executor only accepts Keycloak's issuer URI as the aud claim value in a client authentication assertion ";
+ }
+
+ @Override
+ public List getConfigProperties() {
+ return Collections.emptyList();
+ }
+}
diff --git a/services/src/main/resources/META-INF/services/org.keycloak.services.clientpolicy.executor.ClientPolicyExecutorProviderFactory b/services/src/main/resources/META-INF/services/org.keycloak.services.clientpolicy.executor.ClientPolicyExecutorProviderFactory
index cdc5651e9723..8234a38bcd07 100644
--- a/services/src/main/resources/META-INF/services/org.keycloak.services.clientpolicy.executor.ClientPolicyExecutorProviderFactory
+++ b/services/src/main/resources/META-INF/services/org.keycloak.services.clientpolicy.executor.ClientPolicyExecutorProviderFactory
@@ -29,3 +29,4 @@ org.keycloak.services.clientpolicy.executor.SamlSecureClientUrisExecutorFactory
org.keycloak.services.clientpolicy.executor.SamlAvoidRedirectBindingExecutorFactory
org.keycloak.services.clientpolicy.executor.SamlSignatureEnforcerExecutorFactory
org.keycloak.services.clientpolicy.executor.AuthenticationFlowSelectorExecutorFactory
+org.keycloak.services.clientpolicy.executor.SecureClientAuthenticationAssertionExecutorFactory
diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/client/policies/AbstractClientPoliciesTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/client/policies/AbstractClientPoliciesTest.java
index 0fb9b848520f..4cf48076b883 100644
--- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/client/policies/AbstractClientPoliciesTest.java
+++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/client/policies/AbstractClientPoliciesTest.java
@@ -107,8 +107,10 @@
import org.keycloak.models.AdminRoles;
import org.keycloak.models.Constants;
import org.keycloak.models.utils.KeycloakModelUtils;
+import org.keycloak.protocol.LoginProtocol;
import org.keycloak.protocol.oidc.OIDCAdvancedConfigWrapper;
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
+import org.keycloak.protocol.oidc.OIDCLoginProtocolFactory;
import org.keycloak.representations.AccessToken;
import org.keycloak.representations.JsonWebToken;
import org.keycloak.representations.RefreshToken;
@@ -483,6 +485,11 @@ private String getKeyAlgorithmFromJwaAlgorithm(String jwaAlgorithm) {
// Signed JWT for client authentication utility
+ protected void allowMultipleAudiencesForClientJWTOnServer(boolean allowMultipleAudiences) {
+ getTestingClient().testing().setSystemPropertyOnServer("oidc." + OIDCLoginProtocolFactory.CONFIG_OIDC_ALLOW_MULTIPLE_AUDIENCES_FOR_JWT_CLIENT_AUTHENTICATION, String.valueOf(allowMultipleAudiences));
+ getTestingClient().testing().reinitializeProviderFactoryWithSystemPropertiesScope(LoginProtocol.class.getName(), OIDCLoginProtocol.LOGIN_PROTOCOL, "oidc.");
+ }
+
protected String createSignedRequestToken(String clientId, PrivateKey privateKey, PublicKey publicKey, String algorithm) {
JsonWebToken jwt = createRequestToken(clientId, getRealmInfoUrl());
String kid = KeyUtils.createKeyId(publicKey);
@@ -495,12 +502,26 @@ private String getRealmInfoUrl() {
return KeycloakUriBuilder.fromUri(authServerBaseUrl).path(ServiceUrlConstants.REALM_INFO_PATH).build(REALM_NAME).toString();
}
- private JsonWebToken createRequestToken(String clientId, String realmInfoUrl) {
+ protected JsonWebToken createRequestToken(String clientId, String realmInfoUrl) {
JsonWebToken reqToken = new JsonWebToken();
+ if (realmInfoUrl != null && !realmInfoUrl.isEmpty()) {
+ reqToken.audience(realmInfoUrl);
+ }
+ return createRequestToken(reqToken, clientId);
+ }
+
+ protected JsonWebToken createRequestToken(String clientId, String[] audienceUrls) {
+ JsonWebToken reqToken = new JsonWebToken();
+ if (audienceUrls != null && audienceUrls.length > 0) {
+ reqToken.audience(audienceUrls);
+ }
+ return createRequestToken(reqToken, clientId);
+ }
+
+ private JsonWebToken createRequestToken(JsonWebToken reqToken, String clientId) {
reqToken.id(KeycloakModelUtils.generateId());
reqToken.issuer(clientId);
reqToken.subject(clientId);
- reqToken.audience(realmInfoUrl);
int now = Time.currentTime();
reqToken.iat((long) now);
diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/client/policies/ClientPoliciesExecutorTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/client/policies/ClientPoliciesExecutorTest.java
index f5419fade099..0cd418aef4a1 100644
--- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/client/policies/ClientPoliciesExecutorTest.java
+++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/client/policies/ClientPoliciesExecutorTest.java
@@ -33,6 +33,7 @@
import static org.keycloak.testsuite.util.ClientPoliciesUtil.createSecureSigningAlgorithmEnforceExecutorConfig;
import static org.keycloak.testsuite.util.ClientPoliciesUtil.createSecureSigningAlgorithmForSignedJwtEnforceExecutorConfig;
+import java.nio.charset.StandardCharsets;
import java.security.KeyPair;
import java.security.PrivateKey;
import java.security.PublicKey;
@@ -44,38 +45,58 @@
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
+import java.util.function.Supplier;
import jakarta.ws.rs.BadRequestException;
import org.apache.http.HttpResponse;
+import org.apache.http.NameValuePair;
+import org.apache.http.client.entity.UrlEncodedFormEntity;
+import org.apache.http.client.methods.CloseableHttpResponse;
+import org.apache.http.client.methods.HttpPost;
+import org.apache.http.impl.client.CloseableHttpClient;
+import org.apache.http.impl.client.DefaultHttpClient;
+import org.apache.http.message.BasicNameValuePair;
+import org.hamcrest.MatcherAssert;
+import org.hamcrest.Matchers;
import org.jboss.arquillian.graphene.page.Page;
import org.junit.Assert;
import org.junit.Test;
import org.keycloak.OAuth2Constants;
import org.keycloak.OAuthErrorException;
import org.keycloak.admin.client.resource.ClientResource;
+import org.keycloak.admin.client.resource.UserResource;
import org.keycloak.authentication.authenticators.client.ClientIdAndSecretAuthenticator;
import org.keycloak.authentication.authenticators.client.JWTClientAuthenticator;
import org.keycloak.authentication.authenticators.client.JWTClientSecretAuthenticator;
import org.keycloak.authentication.authenticators.client.X509ClientAuthenticator;
import org.keycloak.client.registration.ClientRegistrationException;
import org.keycloak.common.Profile;
+import org.keycloak.common.util.KeyUtils;
+import org.keycloak.common.util.KeycloakUriBuilder;
+import org.keycloak.common.util.UriUtils;
+import org.keycloak.constants.ServiceUrlConstants;
import org.keycloak.crypto.Algorithm;
+import org.keycloak.crypto.SignatureSignerContext;
import org.keycloak.events.Details;
import org.keycloak.events.Errors;
import org.keycloak.events.EventType;
+import org.keycloak.jose.jws.JWSBuilder;
import org.keycloak.jose.jws.JWSInput;
import org.keycloak.models.AdminRoles;
import org.keycloak.models.CibaConfig;
import org.keycloak.models.Constants;
import org.keycloak.models.OAuth2DeviceConfig;
import org.keycloak.models.utils.KeycloakModelUtils;
+import org.keycloak.protocol.LoginProtocol;
import org.keycloak.protocol.oidc.OIDCAdvancedConfigWrapper;
import org.keycloak.protocol.oidc.OIDCConfigAttributes;
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
+import org.keycloak.protocol.oidc.OIDCLoginProtocolFactory;
import org.keycloak.protocol.oidc.utils.OIDCResponseType;
import org.keycloak.representations.AuthorizationResponseToken;
import org.keycloak.representations.IDToken;
+import org.keycloak.representations.JsonWebToken;
import org.keycloak.representations.RefreshToken;
import org.keycloak.representations.idm.ClientPolicyExecutorConfigurationRepresentation;
import org.keycloak.representations.idm.ClientRepresentation;
@@ -89,6 +110,7 @@
import org.keycloak.services.clientpolicy.condition.AnyClientConditionFactory;
import org.keycloak.services.clientpolicy.condition.ClientRolesConditionFactory;
import org.keycloak.services.clientpolicy.condition.ClientUpdaterContextConditionFactory;
+import org.keycloak.services.clientpolicy.executor.SecureClientAuthenticationAssertionExecutorFactory;
import org.keycloak.services.clientpolicy.executor.SecureClientAuthenticatorExecutorFactory;
import org.keycloak.services.clientpolicy.executor.SecureClientUrisExecutorFactory;
import org.keycloak.services.clientpolicy.executor.SecureLogoutExecutorFactory;
@@ -113,6 +135,7 @@
import org.keycloak.testsuite.util.ClientPoliciesUtil.ClientPolicyBuilder;
import org.keycloak.testsuite.util.ClientPoliciesUtil.ClientProfileBuilder;
import org.keycloak.testsuite.util.ClientPoliciesUtil.ClientProfilesBuilder;
+import org.keycloak.testsuite.util.SignatureSignerUtil;
import org.keycloak.testsuite.util.oauth.AccessTokenResponse;
import org.keycloak.testsuite.util.oauth.AuthorizationEndpointResponse;
import org.keycloak.testsuite.util.oauth.ParResponse;
@@ -1647,4 +1670,162 @@ public void testSecureParContentsExecutorWithRequestObject() throws Exception {
successfulLoginAndLogout(TEST_CLIENT, TEST_CLIENT_SECRET);
}
+
+ @Test
+ public void testSecureClientAuthenticationAssertionExecutor() throws Exception {
+ // register profiles
+ String json = (new ClientProfilesBuilder()).addProfile(
+ (new ClientProfileBuilder()).createProfile(PROFILE_NAME, "Le Premier Profil")
+ .addExecutor(SecureClientAuthenticationAssertionExecutorFactory.PROVIDER_ID, null)
+ .toRepresentation()
+ ).toString();
+ updateProfiles(json);
+
+ // register policies
+ json = (new ClientPoliciesBuilder()).addPolicy(
+ (new ClientPolicyBuilder()).createPolicy(POLICY_NAME, "La Premiere Politique", Boolean.TRUE)
+ .addCondition(AnyClientConditionFactory.PROVIDER_ID,
+ createAnyClientConditionConfig())
+ .addProfile(PROFILE_NAME)
+ .toRepresentation()
+ ).toString();
+ updatePolicies(json);
+
+ // Register client with private-key-jwt
+ String clientId = generateSuffixedName(CLIENT_NAME);
+ createClientByAdmin(clientId, (ClientRepresentation clientRep) -> clientRep.setClientAuthenticatorType(JWTClientAuthenticator.PROVIDER_ID));
+
+ ClientResource clientResource = ApiUtil.findClientByClientId(adminClient.realm(REALM_NAME), clientId);
+ assert clientResource != null;
+ ClientRepresentation clientRep = clientResource.toRepresentation();
+
+ oauth.client(clientId);
+
+ // Get keys of client. Will be used for client authentication and signing of request object
+ KeyPair keyPair = setupJwksUrl(Algorithm.RS256, clientRep, clientResource);
+ PublicKey publicKey = keyPair.getPublic();
+ PrivateKey privateKey = keyPair.getPrivate();
+
+ // Send a push authorization request with invalid 'aud' . Should fail
+ String[] audienceUrls = {getRealmInfoUrl(), getRealmInfoUrl() + "/protocol/openid-connect/ext/par/request"};
+ String signedJwt = createSignedRequestToken(clientId, privateKey, publicKey, Algorithm.RS256, audienceUrls);
+ // for the test, allow multiple audiences for JWT client authentication temporarily.
+ allowMultipleAudiencesForClientJWTOnServer(true);
+ ParResponse pResp = null;
+ try {
+ pResp = oauth.pushedAuthorizationRequest().request(request).signedJwt(signedJwt).send();
+ } finally {
+ allowMultipleAudiencesForClientJWTOnServer(false);
+ }
+ assertEquals(400, pResp.getStatusCode());
+ assertEquals(OAuthErrorException.INVALID_REQUEST, pResp.getError());
+
+ signedJwt = createSignedRequestToken(clientId, privateKey, publicKey, Algorithm.RS256, getRealmInfoUrl() + "/protocol/openid-connect/ext/par/request");
+ pResp = oauth.pushedAuthorizationRequest().request(request).signedJwt(signedJwt).send();
+ assertEquals(400, pResp.getStatusCode());
+ assertEquals(OAuthErrorException.INVALID_REQUEST, pResp.getError());
+
+ // Send a push authorization request with valid 'aud' . Should succeed
+ signedJwt = createSignedRequestToken(clientId, privateKey, publicKey, Algorithm.RS256, getRealmInfoUrl());
+ pResp = oauth.pushedAuthorizationRequest().request(request).signedJwt(signedJwt).send();
+ assertEquals(201, pResp.getStatusCode());
+ requestUri = pResp.getRequestUri();
+
+ // Send an authorization request . Should succeed
+ AuthorizationEndpointResponse loginResponse = oauth.loginForm().requestUri(requestUri).doLogin(TEST_USER_NAME, TEST_USER_PASSWORD);
+ String code = loginResponse.getCode();
+ assertNotNull(code);
+
+ // Send a token request with invalid 'aud' . Should fail
+ signedJwt = createSignedRequestToken(clientId, privateKey, publicKey, Algorithm.RS256, getRealmInfoUrl() + "/protocol/openid-connect/token");
+ AccessTokenResponse tokenResponse = doAccessTokenRequestWithClientSignedJWT(code, signedJwt, DefaultHttpClient::new);
+ assertEquals(400, tokenResponse.getStatusCode());
+
+ // Send a token request with valid 'aud' . Should succeed
+ UserResource user = ApiUtil.findUserByUsernameId(adminClient.realm(REALM_NAME), TEST_USER_NAME);
+ user.logout();
+
+ loginResponse = oauth.loginForm().doLogin(TEST_USER_NAME, TEST_USER_PASSWORD);
+ code = loginResponse.getCode();
+
+ signedJwt = createSignedRequestToken(clientId, privateKey, publicKey, Algorithm.RS256, getRealmInfoUrl());
+
+ tokenResponse = doAccessTokenRequestWithClientSignedJWT(code, signedJwt, DefaultHttpClient::new);
+ assertEquals(200, tokenResponse.getStatusCode());
+ MatcherAssert.assertThat(tokenResponse.getAccessToken(), Matchers.notNullValue());
+
+ // Send a token refresh request with invalid 'aud' . Should fail
+ signedJwt = createSignedRequestToken(clientId, privateKey, publicKey, Algorithm.RS256, getRealmInfoUrl() + "/protocol/openid-connect/token");
+ AccessTokenResponse refreshedResponse = doRefreshTokenRequestWithSignedJWT(tokenResponse.getRefreshToken(), signedJwt);
+ assertEquals(400, refreshedResponse.getStatusCode());
+
+ // Send a token refresh request with valid 'aud' . Should succeed
+ signedJwt = createSignedRequestToken(clientId, privateKey, publicKey, Algorithm.RS256, getRealmInfoUrl());
+ refreshedResponse = doRefreshTokenRequestWithSignedJWT(tokenResponse.getRefreshToken(), signedJwt);
+ assertEquals(200, refreshedResponse.getStatusCode());
+
+ // Send a token introspection request with invalid 'aud' . Should fail
+ signedJwt = createSignedRequestToken(clientId, privateKey, publicKey, Algorithm.RS256, getRealmInfoUrl() + "/protocol/openid-connect/introspect");
+ HttpResponse tokenIntrospectionResponse = doTokenIntrospectionWithSignedJWT("access_token", refreshedResponse.getAccessToken(), signedJwt);
+ assertEquals(401, tokenIntrospectionResponse.getStatusLine().getStatusCode());
+
+ // Send a token introspection request with valid 'aud' . Should succeed
+ signedJwt = createSignedRequestToken(clientId, privateKey, publicKey, Algorithm.RS256, getRealmInfoUrl());
+ tokenIntrospectionResponse = doTokenIntrospectionWithSignedJWT("access_token", refreshedResponse.getAccessToken(), signedJwt);
+ assertEquals(200, tokenIntrospectionResponse.getStatusLine().getStatusCode());
+
+ // Send a token revoke request with invalid 'aud' . Should fail
+ signedJwt = createSignedRequestToken(clientId, privateKey, publicKey, Algorithm.RS256, getRealmInfoUrl() + "/protocol/openid-connect/revoke");
+ HttpResponse revokeTokenResponse = doTokenRevokeWithSignedJWT("refresh_token", refreshedResponse.getRefreshToken(), signedJwt);
+ assertEquals(400, revokeTokenResponse.getStatusLine().getStatusCode());
+
+ // Send a token revoke request with valid 'aud' . Should succeed
+ signedJwt = createSignedRequestToken(clientId, privateKey, publicKey, Algorithm.RS256, getRealmInfoUrl());
+ revokeTokenResponse = doTokenRevokeWithSignedJWT("refresh_token", refreshedResponse.getRefreshToken(), signedJwt);
+ assertEquals(200, revokeTokenResponse.getStatusLine().getStatusCode());
+ }
+
+ private String createSignedRequestToken(String clientId, PrivateKey privateKey, PublicKey publicKey, String algorithm, String audUrl) {
+ JsonWebToken jwt = createRequestToken(clientId, audUrl);
+ String kid = KeyUtils.createKeyId(publicKey);
+ SignatureSignerContext signer = SignatureSignerUtil.createSigner(privateKey, kid, algorithm);
+ return new JWSBuilder().kid(kid).jsonContent(jwt).sign(signer);
+ }
+
+ private String createSignedRequestToken(String clientId, PrivateKey privateKey, PublicKey publicKey, String algorithm, String[] audienceUrls) {
+ JsonWebToken jwt = createRequestToken(clientId, audienceUrls);
+ String kid = KeyUtils.createKeyId(publicKey);
+ SignatureSignerContext signer = SignatureSignerUtil.createSigner(privateKey, kid, algorithm);
+ return new JWSBuilder().kid(kid).jsonContent(jwt).sign(signer);
+ }
+
+ private String getRealmInfoUrl() {
+ return KeycloakUriBuilder.fromUri(UriUtils.getOrigin(oauth.getRedirectUri()) + "/auth").path(ServiceUrlConstants.REALM_INFO_PATH).build(REALM_NAME).toString();
+ }
+
+ private AccessTokenResponse doAccessTokenRequestWithClientSignedJWT(String code, String signedJwt, Supplier httpClientSupplier) {
+ try {
+ List parameters = new LinkedList<>();
+ parameters.add(new BasicNameValuePair(OAuth2Constants.GRANT_TYPE, OAuth2Constants.AUTHORIZATION_CODE));
+ parameters.add(new BasicNameValuePair(OAuth2Constants.CODE, code));
+
+ parameters.add(new BasicNameValuePair(OAuth2Constants.REDIRECT_URI, oauth.getRedirectUri()));
+ parameters.add(new BasicNameValuePair(OAuth2Constants.CLIENT_ASSERTION_TYPE, OAuth2Constants.CLIENT_ASSERTION_TYPE_JWT));
+ parameters.add(new BasicNameValuePair(OAuth2Constants.CLIENT_ASSERTION, signedJwt));
+
+ CloseableHttpResponse response = sendRequest(oauth.getEndpoints().getToken(), parameters, httpClientSupplier);
+ return new AccessTokenResponse(response);
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ private CloseableHttpResponse sendRequest(String requestUrl, List parameters, Supplier httpClientSupplier) throws Exception {
+ try (CloseableHttpClient client = httpClientSupplier.get()) {
+ HttpPost post = new HttpPost(requestUrl);
+ UrlEncodedFormEntity formEntity = new UrlEncodedFormEntity(parameters, StandardCharsets.UTF_8);
+ post.setEntity(formEntity);
+ return client.execute(post);
+ }
+ }
}