From 975ad2441866ca1839e9838d36049b3bb59e636c Mon Sep 17 00:00:00 2001 From: Takashi Norimatsu Date: Mon, 14 Jul 2025 13:55:00 +0900 Subject: [PATCH] FAPI 2.0 Final - only accept its issuer identifier value as a string in the aud claim received in client authentication assertions closes #41119 Signed-off-by: Takashi Norimatsu --- ...ClientAuthenticationAssertionExecutor.java | 117 +++++++++++ ...uthenticationAssertionExecutorFactory.java | 65 +++++++ ...ecutor.ClientPolicyExecutorProviderFactory | 1 + .../policies/AbstractClientPoliciesTest.java | 25 ++- .../policies/ClientPoliciesExecutorTest.java | 181 ++++++++++++++++++ 5 files changed, 387 insertions(+), 2 deletions(-) create mode 100644 services/src/main/java/org/keycloak/services/clientpolicy/executor/SecureClientAuthenticationAssertionExecutor.java create mode 100644 services/src/main/java/org/keycloak/services/clientpolicy/executor/SecureClientAuthenticationAssertionExecutorFactory.java 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); + } + } }