diff --git a/docs/documentation/release_notes/topics/26_2_0.adoc b/docs/documentation/release_notes/topics/26_2_0.adoc index c13486fb4672..0a2953eb467b 100644 --- a/docs/documentation/release_notes/topics/26_2_0.adoc +++ b/docs/documentation/release_notes/topics/26_2_0.adoc @@ -10,10 +10,10 @@ For information on how to upgrade from the legacy token exchange used in previou = Fine-grained admin permissions supported -This release introduces support for a new version of fine-grained admin permissions. Version 2 (V2) provides enhanced flexibility and control over administrative access within realms. +This release introduces support for a new version of fine-grained admin permissions. Version 2 (V2) provides enhanced flexibility and control over administrative access within realms. With this feature, administrators can define permissions for administering users, groups, clients, and roles without relying on broad administrative roles. V2 offers the same level of access control over realm resources as the previous version, with plans to extend its capabilities in future versions. Some key points follow: -* *Centralized Admin Console Management* - New *Permissions* section was introduced to allow management from a single place without having to navigate to different places in the Admin Console. +* *Centralized Admin Console Management* - New *Permissions* section was introduced to allow management from a single place without having to navigate to different places in the Admin Console. * *Improved manageability* - Administrators can more easily search and evaluate permissions when building a permission model for realm resources. * *Resource-Specific and Global Permissions* – Permissions can be defined for individual resources (such as specific users or groups), or entire resource types (such as all users or all groups). * *Explicit Operation Scoping* – Permissions are now independent, removing hidden dependencies between operations. Administrators must assign each scope explicitly, making it easier to see what is granted without needing prior knowledge of implicit relationships. @@ -96,6 +96,18 @@ For more information, check the link:https://www.keycloak.org/server/management- Introduced the ability to dynamically select authentication flows based on conditions such as requested scopes, ACR (Authentication Context Class Reference) and others. This can be achieved using link:{adminguide_link}#_client_policies[Client Policies] by combining the new `AuthenticationFlowSelectorExecutor` with conditions like the new `ACRCondition`. For more details, see the link:{adminguide_link}#_client-policy-auth-flow[{adminguide_name}]. += JWT Client authentication aligned with the latest OIDC specification + +The latest version of the link:https://openid.net/specs/openid-connect-core-1_0-36.html#rfc.section.9[OpenID Connect Core Specification] tightened the rules for +audience validation in JWT client assertions for the Client Authentication methods `private_key_jwt` and `client_secret_jwt` . {project_name} now enforces by default that there is single audience +in the JWT token used for client authentication. + +For information on the changed audience validation in JWT Client authentication {project_name} versions, see the link:{upgradingguide_link}[{upgradingguide_name}]. + +ifeval::[{project_community}==true] +Many thanks to https://github.com/thomasdarimont[Thomas Darimont] for the contribution. +endif::[] + = Federated credentials are available now when fetching user credentials Until now, querying user credentials using the User API will not return credentials managed by user storage providers and, as a consequence, diff --git a/docs/documentation/upgrading/topics/changes/changes-26_2_0.adoc b/docs/documentation/upgrading/topics/changes/changes-26_2_0.adoc index 328b50c3cc8a..a2941469f2a5 100644 --- a/docs/documentation/upgrading/topics/changes/changes-26_2_0.adoc +++ b/docs/documentation/upgrading/topics/changes/changes-26_2_0.adoc @@ -19,6 +19,26 @@ the `X-Forwarded-Port` header with the desired port. The required JAR for the Oracle JDBC driver that needs to be explicitly added to the distribution has changed. Instead of providing `ojdbc11` JAR, use `ojdbc17` JAR as stated in the https://www.keycloak.org/server/db#_installing_the_oracle_database_driver[Installing the Oracle Database driver] guide. +=== JWT Client authentication aligned with the latest OIDC specification + +The latest draft version of the link:https://openid.net/specs/openid-connect-core-1_0-36.html#rfc.section.9[OpenID Connect core specification] changed the rules for +audience validation in JWT client assertions for the Client Authentication methods `private_key_jwt` and `client_secret_jwt`. + +Previously, the `aud` claim of a JWT client assertion was loosely defined as `The Audience SHOULD be the URL of the Authorization Server's Token Endpoint`, which did not exclude the usage of other URLs. + +The revised OIDC Core specification uses a stricter audience check: `The Audience value MUST be the OP's Issuer Identifier passed as a string, and not a single-element array.`. + +We adapted the JWT client authentication authenticators of both `private_key_jwt` and `client_secret_jwt` to allow only a single audience in the token by default. For now, the audience can be +issuer, token endpoint, introspection endpoint or some other OAuth/OIDC endpoint, which is used by client JWT authentication. However since there is single audience allowed now, it means that it is not possible +to use other unrelated audience values, which is to make sure that JWT token is really only useful by the {project_name} for client authentication. + +This strict audience check can be reverted to the previous more lenient check with a new option of OIDC login protocol SPI. It will be still allowed to use multiple audiences in JWT if server is started with the option: + +`--spi-login-protocol-openid-connect-allow-multiple-audiences-for-jwt-client-authentication=true` + +Note that this option might be removed in the future. Possibly in {project_name} 27. So it is highly recommended to update your clients to use single audience instead of using this option. It is also +recommended that your clients use the issuer URL for the audience when sending JWT for client authentication as that is going to be compatible with the future version of OIDC specification. + == Notable changes Notable changes where an internal behavior changed to prevent common misconfigurations, fix bugs or simplify running {project_name}. diff --git a/services/src/main/java/org/keycloak/authentication/authenticators/client/JWTClientAuthenticator.java b/services/src/main/java/org/keycloak/authentication/authenticators/client/JWTClientAuthenticator.java index 72d4becc9332..37ff40620776 100644 --- a/services/src/main/java/org/keycloak/authentication/authenticators/client/JWTClientAuthenticator.java +++ b/services/src/main/java/org/keycloak/authentication/authenticators/client/JWTClientAuthenticator.java @@ -18,19 +18,7 @@ package org.keycloak.authentication.authenticators.client; -import java.security.PublicKey; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; -import java.util.HashMap; -import java.util.HashSet; -import java.util.LinkedList; -import java.util.List; -import java.util.Map; -import java.util.Set; - import jakarta.ws.rs.core.Response; - import org.keycloak.OAuthErrorException; import org.keycloak.authentication.AuthenticationFlowError; import org.keycloak.authentication.ClientAuthenticationFlowContext; @@ -42,13 +30,18 @@ import org.keycloak.models.RealmModel; import org.keycloak.protocol.oidc.OIDCConfigAttributes; import org.keycloak.protocol.oidc.OIDCLoginProtocol; -import org.keycloak.protocol.oidc.OIDCLoginProtocolService; -import org.keycloak.protocol.oidc.grants.ciba.CibaGrantType; -import org.keycloak.protocol.oidc.par.endpoints.ParEndpoint; import org.keycloak.provider.ProviderConfigProperty; import org.keycloak.representations.JsonWebToken; import org.keycloak.services.ServicesLogger; -import org.keycloak.services.Urls; + +import java.security.PublicKey; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.Set; import static org.keycloak.models.TokenManager.DEFAULT_VALIDATOR; @@ -113,13 +106,7 @@ public void authenticateClient(ClientAuthenticationFlowContext context) { throw new RuntimeException("Signature on JWT token failed validation"); } - // Allow both "issuer" or "token-endpoint" as audience - List expectedAudiences = getExpectedAudiences(context, realm); - - if (!token.hasAnyAudience(expectedAudiences)) { - throw new RuntimeException("Token audience doesn't match domain. Expected audiences are any of " + expectedAudiences - + " but audience from token is '" + Arrays.asList(token.getAudience()) + "'"); - } + validator.validateTokenAudience(context, realm, token); validator.validateToken(); validator.validateTokenReuse(); @@ -208,16 +195,4 @@ public Set getProtocolAuthenticatorMethods(String loginProtocol) { return Collections.emptySet(); } } - - private List getExpectedAudiences(ClientAuthenticationFlowContext context, RealmModel realm) { - String issuerUrl = Urls.realmIssuer(context.getUriInfo().getBaseUri(), realm.getName()); - String tokenUrl = OIDCLoginProtocolService.tokenUrl(context.getUriInfo().getBaseUriBuilder()).build(realm.getName()).toString(); - String tokenIntrospectUrl = OIDCLoginProtocolService.tokenIntrospectionUrl(context.getUriInfo().getBaseUriBuilder()).build(realm.getName()).toString(); - String parEndpointUrl = ParEndpoint.parUrl(context.getUriInfo().getBaseUriBuilder()).build(realm.getName()).toString(); - List expectedAudiences = new ArrayList<>(Arrays.asList(issuerUrl, tokenUrl, tokenIntrospectUrl, parEndpointUrl)); - String backchannelAuthenticationUrl = CibaGrantType.authorizationUrl(context.getUriInfo().getBaseUriBuilder()).build(realm.getName()).toString(); - expectedAudiences.add(backchannelAuthenticationUrl); - - return expectedAudiences; - } } diff --git a/services/src/main/java/org/keycloak/authentication/authenticators/client/JWTClientSecretAuthenticator.java b/services/src/main/java/org/keycloak/authentication/authenticators/client/JWTClientSecretAuthenticator.java index 070688440570..45d1f8d72fd1 100644 --- a/services/src/main/java/org/keycloak/authentication/authenticators/client/JWTClientSecretAuthenticator.java +++ b/services/src/main/java/org/keycloak/authentication/authenticators/client/JWTClientSecretAuthenticator.java @@ -16,6 +16,7 @@ */ package org.keycloak.authentication.authenticators.client; +import jakarta.ws.rs.core.Response; import org.keycloak.authentication.AuthenticationFlowError; import org.keycloak.authentication.ClientAuthenticationFlowContext; import org.keycloak.crypto.ClientSignatureVerifierProvider; @@ -26,14 +27,10 @@ import org.keycloak.protocol.oidc.OIDCClientSecretConfigWrapper; import org.keycloak.protocol.oidc.OIDCConfigAttributes; import org.keycloak.protocol.oidc.OIDCLoginProtocol; -import org.keycloak.protocol.oidc.OIDCLoginProtocolService; import org.keycloak.provider.ProviderConfigProperty; import org.keycloak.representations.JsonWebToken; import org.keycloak.services.ServicesLogger; -import org.keycloak.services.Urls; -import jakarta.ws.rs.core.Response; -import java.util.Arrays; import java.util.Collections; import java.util.HashMap; import java.util.HashSet; @@ -114,13 +111,7 @@ public void authenticateClient(ClientAuthenticationFlowContext context) { } // According to OIDC's client authentication spec, // JWT contents and verification in client_secret_jwt is the same as in private_key_jwt - - // Allow both "issuer" or "token-endpoint" as audience - String issuerUrl = Urls.realmIssuer(context.getUriInfo().getBaseUri(), realm.getName()); - String tokenUrl = OIDCLoginProtocolService.tokenUrl(context.getUriInfo().getBaseUriBuilder()).build(realm.getName()).toString(); - if (!token.hasAudience(issuerUrl) && !token.hasAudience(tokenUrl)) { - throw new RuntimeException("Token audience doesn't match domain. Realm issuer is '" + issuerUrl + "' but audience from token is '" + Arrays.asList(token.getAudience()).toString() + "'"); - } + validator.validateTokenAudience(context, realm, token); validator.validateToken(); validator.validateTokenReuse(); @@ -199,7 +190,6 @@ public Requirement[] getRequirementChoices() { @Override public String getHelpText() { return "Validates client based on signed JWT issued by client and signed with the Client Secret"; - } @Override diff --git a/services/src/main/java/org/keycloak/authentication/authenticators/client/JWTClientValidator.java b/services/src/main/java/org/keycloak/authentication/authenticators/client/JWTClientValidator.java index 5fe5dde9fb51..dd821fa1fbf3 100644 --- a/services/src/main/java/org/keycloak/authentication/authenticators/client/JWTClientValidator.java +++ b/services/src/main/java/org/keycloak/authentication/authenticators/client/JWTClientValidator.java @@ -19,6 +19,10 @@ package org.keycloak.authentication.authenticators.client; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.MultivaluedMap; import jakarta.ws.rs.core.Response; @@ -33,8 +37,15 @@ import org.keycloak.models.ClientModel; import org.keycloak.models.RealmModel; import org.keycloak.models.SingleUseObjectProvider; +import org.keycloak.protocol.LoginProtocol; import org.keycloak.protocol.oidc.OIDCAdvancedConfigWrapper; +import org.keycloak.protocol.oidc.OIDCLoginProtocol; +import org.keycloak.protocol.oidc.OIDCLoginProtocolService; +import org.keycloak.protocol.oidc.OIDCProviderConfig; +import org.keycloak.protocol.oidc.grants.ciba.CibaGrantType; +import org.keycloak.protocol.oidc.par.endpoints.ParEndpoint; import org.keycloak.representations.JsonWebToken; +import org.keycloak.services.Urls; /** * Common validation for JWT client authentication with private_key_jwt or with client_secret @@ -247,6 +258,37 @@ public void validateTokenReuse() { } } + public void validateTokenAudience(ClientAuthenticationFlowContext context, RealmModel realm, JsonWebToken token) { + List expectedAudiences = getExpectedAudiences(context, realm); + if (!token.hasAnyAudience(expectedAudiences)) { + throw new RuntimeException("Token audience doesn't match domain. Expected audiences are any of " + expectedAudiences + + " but audience from token is '" + Arrays.asList(token.getAudience()) + "'"); + } + + if (!isAllowMultipleAudiencesForJwtClientAuthentication(context) && token.getAudience().length > 1) { + throw new RuntimeException("Multiple audiences not allowed in the JWT token for client authentication"); + } + } + + private boolean isAllowMultipleAudiencesForJwtClientAuthentication(ClientAuthenticationFlowContext context) { + OIDCLoginProtocol loginProtocol = (OIDCLoginProtocol) context.getSession().getProvider(LoginProtocol.class, OIDCLoginProtocol.LOGIN_PROTOCOL); + OIDCProviderConfig config = loginProtocol.getConfig(); + return config.isAllowMultipleAudiencesForJwtClientAuthentication(); + } + + private List getExpectedAudiences(ClientAuthenticationFlowContext context, RealmModel realm) { + + String issuerUrl = Urls.realmIssuer(context.getUriInfo().getBaseUri(), realm.getName()); + String tokenUrl = OIDCLoginProtocolService.tokenUrl(context.getUriInfo().getBaseUriBuilder()).build(realm.getName()).toString(); + String tokenIntrospectUrl = OIDCLoginProtocolService.tokenIntrospectionUrl(context.getUriInfo().getBaseUriBuilder()).build(realm.getName()).toString(); + String parEndpointUrl = ParEndpoint.parUrl(context.getUriInfo().getBaseUriBuilder()).build(realm.getName()).toString(); + List expectedAudiences = new ArrayList<>(Arrays.asList(issuerUrl, tokenUrl, tokenIntrospectUrl, parEndpointUrl)); + String backchannelAuthenticationUrl = CibaGrantType.authorizationUrl(context.getUriInfo().getBaseUriBuilder()).build(realm.getName()).toString(); + expectedAudiences.add(backchannelAuthenticationUrl); + + return expectedAudiences; + } + public ClientAuthenticationFlowContext getContext() { return context; } diff --git a/services/src/main/java/org/keycloak/protocol/oidc/OIDCLoginProtocolFactory.java b/services/src/main/java/org/keycloak/protocol/oidc/OIDCLoginProtocolFactory.java index f535ec63321e..177557c5af03 100755 --- a/services/src/main/java/org/keycloak/protocol/oidc/OIDCLoginProtocolFactory.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/OIDCLoginProtocolFactory.java @@ -115,11 +115,21 @@ public class OIDCLoginProtocolFactory extends AbstractLoginProtocolFactory { public static final String CONFIG_OIDC_REQ_PARAMS_MAX_OVERALL_SIZE = "add-req-params-max-overall-size"; public static final String CONFIG_OIDC_REQ_PARAMS_FAIL_FAST = "add-req-params-fail-fast"; + /** + * @deprecated To be removed in Keycloak 27 + */ + public static final String CONFIG_OIDC_ALLOW_MULTIPLE_AUDIENCES_FOR_JWT_CLIENT_AUTHENTICATION = "allow-multiple-audiences-for-jwt-client-authentication"; + private OIDCProviderConfig providerConfig; @Override public void init(Config.Scope config) { this.providerConfig = new OIDCProviderConfig(config); + if (this.providerConfig.isAllowMultipleAudiencesForJwtClientAuthentication()) { + logger.warnf("It is allowed to have multiple audiences for the JWT client authentication. This option is not recommended and will be removed in one of the future releases." + + " It is recommended to update your OAuth/OIDC clients to rather use single audience in the JWT token used for the client authentication."); + } + initBuiltIns(); } diff --git a/services/src/main/java/org/keycloak/protocol/oidc/OIDCProviderConfig.java b/services/src/main/java/org/keycloak/protocol/oidc/OIDCProviderConfig.java index 3f542c13a730..b2009dd43d38 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc/OIDCProviderConfig.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/OIDCProviderConfig.java @@ -48,12 +48,26 @@ public class OIDCProviderConfig { */ private final int additionalReqParamsMaxOverallSize; + /** + * @deprecated to be removed in Keycloak 27 + */ + public static final boolean DEFAULT_ALLOW_MULTIPLE_AUDIENCES_FOR_JWT_CLIENT_AUTHENTICATION = false; + + /** + * Whether to allow multiple audiences for JWT client authentication + * @deprecated To be removed in Keycloak 27 + */ + private final boolean allowMultipleAudiencesForJwtClientAuthentication; + + public OIDCProviderConfig(Config.Scope config) { this.additionalReqParamsMaxNumber = config.getInt(OIDCLoginProtocolFactory.CONFIG_OIDC_REQ_PARAMS_MAX_NUMBER, DEFAULT_ADDITIONAL_REQ_PARAMS_MAX_NUMBER); this.additionalReqParamsMaxSize = config.getInt(OIDCLoginProtocolFactory.CONFIG_OIDC_REQ_PARAMS_MAX_SIZE, DEFAULT_ADDITIONAL_REQ_PARAMS_MAX_SIZE); this.additionalReqParamsMaxOverallSize = config.getInt(OIDCLoginProtocolFactory.CONFIG_OIDC_REQ_PARAMS_MAX_OVERALL_SIZE, DEFAULT_ADDITIONAL_REQ_PARAMS_MAX_OVERALL_SIZE); this.additionalReqParamsFailFast = config.getBoolean(OIDCLoginProtocolFactory.CONFIG_OIDC_REQ_PARAMS_FAIL_FAST, DEFAULT_ADDITIONAL_REQ_PARAMS_FAIL_FAST); + + this.allowMultipleAudiencesForJwtClientAuthentication = config.getBoolean(OIDCLoginProtocolFactory.CONFIG_OIDC_ALLOW_MULTIPLE_AUDIENCES_FOR_JWT_CLIENT_AUTHENTICATION, DEFAULT_ALLOW_MULTIPLE_AUDIENCES_FOR_JWT_CLIENT_AUTHENTICATION); } public int getAdditionalReqParamsMaxNumber() { @@ -71,4 +85,8 @@ public boolean isAdditionalReqParamsFailFast() { public int getAdditionalReqParamsMaxOverallSize() { return additionalReqParamsMaxOverallSize; } + + public boolean isAllowMultipleAudiencesForJwtClientAuthentication() { + return allowMultipleAudiencesForJwtClientAuthentication; + } } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/client/OIDCJwksClientRegistrationTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/client/OIDCJwksClientRegistrationTest.java index 80a131d070f3..249ff8d88fca 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/client/OIDCJwksClientRegistrationTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/client/OIDCJwksClientRegistrationTest.java @@ -345,15 +345,6 @@ public String createSignedRequestToken(String clientId, String realmInfoUrl) { .rsa256(keyPair.getPrivate()); } } - - @Override - protected JsonWebToken createRequestToken(String clientId, String realmInfoUrl) { - JsonWebToken jwt = super.createRequestToken(clientId, realmInfoUrl); - String tokenEndpointUrl = OIDCLoginProtocolService.tokenUrl(UriBuilder.fromUri(getAuthServerRoot())).build(REALM_NAME).toString(); - jwt.audience(tokenEndpointUrl); - return jwt; - } - }; jwtProvider.setupKeyPair(keyPair); jwtProvider.setTokenTimeout(10); diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/AbstractClientAuthSignedJWTTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/AbstractClientAuthSignedJWTTest.java index b3dafbd6ed41..d4ddba5eb42b 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/AbstractClientAuthSignedJWTTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/AbstractClientAuthSignedJWTTest.java @@ -93,8 +93,11 @@ import org.keycloak.jose.jwk.JSONWebKeySet; import org.keycloak.jose.jws.JWSBuilder; 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.client.authentication.JWTClientCredentialsProvider; import org.keycloak.representations.AccessToken; import org.keycloak.representations.JsonWebToken; @@ -154,6 +157,11 @@ public void beforeAbstractKeycloakTest() throws Exception { super.beforeAbstractKeycloakTest(); } + 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."); + } + @Override public void addTestRealms(List testRealms) { RealmBuilder realmBuilder = RealmBuilder.create().name("test") @@ -407,8 +415,8 @@ protected void testClientWithGeneratedKeys(String format) throws Exception { KeyPair keyPair = new KeyPair(x509Cert.getPublicKey(), privateKey); AccessTokenResponse response = doGrantAccessTokenRequest(user.getUsername(), - user.getCredentials().get(0).getValue(), - getClientSignedJWT(keyPair, client.getClientId())); + user.getCredentials().get(0).getValue(), + getClientSignedJWT(keyPair, client.getClientId())); assertEquals(200, response.getStatusCode()); @@ -500,24 +508,29 @@ protected void testUploadKeystore(String keystoreFormat, String filePath, String } } + protected List createTokenWithSpecifiedAudience(ClientResource clientResource, ClientRepresentation clientRepresentation, String... audience) throws Exception { + KeyPair keyPair = setupJwksUrl(Algorithm.PS256, clientRepresentation, clientResource); + PublicKey publicKey = keyPair.getPublic(); + PrivateKey privateKey = keyPair.getPrivate(); + JsonWebToken assertion = createRequestToken(app2.getClientId(), getRealmInfoUrl()); + + assertion.audience(audience); + + List parameters = new LinkedList(); + parameters.add(new BasicNameValuePair(OAuth2Constants.GRANT_TYPE, OAuth2Constants.CLIENT_CREDENTIALS)); + parameters + .add(new BasicNameValuePair(OAuth2Constants.CLIENT_ASSERTION_TYPE, OAuth2Constants.CLIENT_ASSERTION_TYPE_JWT)); + parameters.add(new BasicNameValuePair(OAuth2Constants.CLIENT_ASSERTION, + createSignedRequestToken(privateKey, publicKey, Algorithm.PS256, null, assertion, null))); + return parameters; + } + protected void testEndpointAsAudience(String endpointUrl) throws Exception { ClientRepresentation clientRepresentation = app2; ClientResource clientResource = getClient(testRealm.getRealm(), clientRepresentation.getId()); clientRepresentation = clientResource.toRepresentation(); try { - KeyPair keyPair = setupJwksUrl(Algorithm.PS256, clientRepresentation, clientResource); - PublicKey publicKey = keyPair.getPublic(); - PrivateKey privateKey = keyPair.getPrivate(); - JsonWebToken assertion = createRequestToken(app2.getClientId(), getRealmInfoUrl()); - - assertion.audience(endpointUrl); - - List parameters = new LinkedList(); - parameters.add(new BasicNameValuePair(OAuth2Constants.GRANT_TYPE, OAuth2Constants.CLIENT_CREDENTIALS)); - parameters - .add(new BasicNameValuePair(OAuth2Constants.CLIENT_ASSERTION_TYPE, OAuth2Constants.CLIENT_ASSERTION_TYPE_JWT)); - parameters.add(new BasicNameValuePair(OAuth2Constants.CLIENT_ASSERTION, - createSignedRequestToken(privateKey, publicKey, Algorithm.PS256, null, assertion, null))); + List parameters = createTokenWithSpecifiedAudience(clientResource, clientRepresentation, endpointUrl); try (CloseableHttpResponse resp = sendRequest(oauth.getEndpoints().getToken(), parameters)) { AccessTokenResponse response = new AccessTokenResponse(resp); @@ -830,12 +843,12 @@ protected KeyPair setupJwksUrl(String algorithm, ClientRepresentation clientRepr } protected KeyPair setupJwksUrl(String algorithm, boolean advertiseJWKAlgorithm, boolean keepExistingKeys, String kid, - ClientRepresentation clientRepresentation, ClientResource clientResource) throws Exception { + ClientRepresentation clientRepresentation, ClientResource clientResource) throws Exception { return setupJwksUrl(algorithm, null, advertiseJWKAlgorithm, keepExistingKeys, kid, clientRepresentation, clientResource); } protected KeyPair setupJwksUrl(String algorithm, String curve, boolean advertiseJWKAlgorithm, boolean keepExistingKeys, String kid, - ClientRepresentation clientRepresentation, ClientResource clientResource) throws Exception { + ClientRepresentation clientRepresentation, ClientResource clientResource) throws Exception { // generate and register client keypair TestOIDCEndpointsApplicationResource oidcClientEndpointsResource = testingClient.testApp().oidcClientEndpoints(); oidcClientEndpointsResource.generateKeys(algorithm, curve, advertiseJWKAlgorithm, keepExistingKeys, kid); @@ -855,7 +868,7 @@ protected KeyPair setupJwksUrl(String algorithm, String curve, boolean advertise } private KeyPair setupJwks(String algorithm, String curve, ClientRepresentation clientRepresentation, ClientResource clientResource) - throws Exception { + throws Exception { // generate and register client keypair TestOIDCEndpointsApplicationResource oidcClientEndpointsResource = testingClient.testApp().oidcClientEndpoints(); oidcClientEndpointsResource.generateKeys(algorithm, curve); @@ -866,7 +879,7 @@ private KeyPair setupJwks(String algorithm, String curve, ClientRepresentation c OIDCAdvancedConfigWrapper.fromClientRepresentation(clientRepresentation).setUseJwksString(true); JSONWebKeySet keySet = oidcClientEndpointsResource.getJwks(); OIDCAdvancedConfigWrapper.fromClientRepresentation(clientRepresentation) - .setJwksString(JsonSerialization.writeValueAsString(keySet)); + .setJwksString(JsonSerialization.writeValueAsString(keySet)); clientResource.update(clientRepresentation); // set time offset, so that new keys are downloaded diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/ClientAuthSignedJWTTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/ClientAuthSignedJWTTest.java index 1e9a2ea91ee2..40be26d198f1 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/ClientAuthSignedJWTTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/ClientAuthSignedJWTTest.java @@ -588,27 +588,60 @@ public void testInvalidAudience() throws Exception { clientRepresentation = clientResource.toRepresentation(); try { - KeyPair keyPair = setupJwksUrl(Algorithm.PS256, clientRepresentation, clientResource); - PublicKey publicKey = keyPair.getPublic(); - PrivateKey privateKey = keyPair.getPrivate(); - JsonWebToken assertion = createRequestToken(app2.getClientId(), getRealmInfoUrl()); + List parameters = createTokenWithSpecifiedAudience(clientResource, clientRepresentation, "https://as.other.org"); - assertion.audience("https://as.other.org"); + try (CloseableHttpResponse resp = sendRequest(oauth.getEndpoints().getToken(), parameters)) { + AccessTokenResponse response = new AccessTokenResponse(resp); + assertNull(response.getAccessToken()); + assertEquals(OAuthErrorException.INVALID_CLIENT, response.getError()); + } + } finally { + revertJwksUriSettings(clientRepresentation, clientResource); + } + } - List parameters = new LinkedList(); - parameters.add(new BasicNameValuePair(OAuth2Constants.GRANT_TYPE, OAuth2Constants.CLIENT_CREDENTIALS)); - parameters - .add(new BasicNameValuePair(OAuth2Constants.CLIENT_ASSERTION_TYPE, OAuth2Constants.CLIENT_ASSERTION_TYPE_JWT)); - parameters.add(new BasicNameValuePair(OAuth2Constants.CLIENT_ASSERTION, - createSignledRequestToken(privateKey, publicKey, Algorithm.PS256, null, assertion))); + @Test + public void testMultipleAudiencesRejected() throws Exception { + ClientRepresentation clientRepresentation = app2; + ClientResource clientResource = getClient(testRealm.getRealm(), clientRepresentation.getId()); + clientRepresentation = clientResource.toRepresentation(); + + try { + List parameters = createTokenWithSpecifiedAudience(clientResource, clientRepresentation, getRealmInfoUrl(), oauth.getEndpoints().getToken()); try (CloseableHttpResponse resp = sendRequest(oauth.getEndpoints().getToken(), parameters)) { AccessTokenResponse response = new AccessTokenResponse(resp); assertNull(response.getAccessToken()); + assertEquals(OAuthErrorException.INVALID_CLIENT, response.getError()); } } finally { revertJwksUriSettings(clientRepresentation, clientResource); } + + } + + @Test + public void testMultipleAudiencesAllowed() throws Exception { + // TODO: The test might be removed once we remove the option of allow-multiple-audiences-for-jwt-client-authentication + ClientRepresentation clientRepresentation = app2; + ClientResource clientResource = getClient(testRealm.getRealm(), clientRepresentation.getId()); + clientRepresentation = clientResource.toRepresentation(); + + allowMultipleAudiencesForClientJWTOnServer(true); + + try { + List parameters = createTokenWithSpecifiedAudience(clientResource, clientRepresentation, getRealmInfoUrl(), "https://as.other.org"); + + try (CloseableHttpResponse resp = sendRequest(oauth.getEndpoints().getToken(), parameters)) { + AccessTokenResponse response = new AccessTokenResponse(resp); + assertNotNull(response.getAccessToken()); + assertNull(response.getError()); + } + } finally { + revertJwksUriSettings(clientRepresentation, clientResource); + allowMultipleAudiencesForClientJWTOnServer(false); + } + } @Test