From 0a832fc7446c60292944e0239734b6f3a6b05fbf Mon Sep 17 00:00:00 2001 From: Takashi Norimatsu Date: Wed, 22 Jun 2022 07:00:22 +0900 Subject: [PATCH] Intent support before issuing tokens (UK OpenBanking) Closes #12883 --- ...ClaimsParameterWithValueIdTokenMapper.java | 141 +++++++++++ .../IntentClientBindCheckExecutor.java | 220 ++++++++++++++++++ .../IntentClientBindCheckExecutorFactory.java | 71 ++++++ .../org.keycloak.protocol.ProtocolMapper | 3 +- ...ecutor.ClientPolicyExecutorProviderFactory | 1 + .../rest/TestApplicationResourceProvider.java | 7 +- ...estApplicationResourceProviderFactory.java | 3 +- ...stingOIDCEndpointsApplicationResource.java | 30 ++- .../TestApplicationResourceUrls.java | 7 + .../TestOIDCEndpointsApplicationResource.java | 13 ++ .../testsuite/client/ClientPoliciesTest.java | 140 +++++++++++ .../testsuite/util/ClientPoliciesUtil.java | 8 + 12 files changed, 638 insertions(+), 6 deletions(-) create mode 100644 services/src/main/java/org/keycloak/protocol/oidc/mappers/ClaimsParameterWithValueIdTokenMapper.java create mode 100644 services/src/main/java/org/keycloak/services/clientpolicy/executor/IntentClientBindCheckExecutor.java create mode 100644 services/src/main/java/org/keycloak/services/clientpolicy/executor/IntentClientBindCheckExecutorFactory.java diff --git a/services/src/main/java/org/keycloak/protocol/oidc/mappers/ClaimsParameterWithValueIdTokenMapper.java b/services/src/main/java/org/keycloak/protocol/oidc/mappers/ClaimsParameterWithValueIdTokenMapper.java new file mode 100644 index 0000000000..fa07c5ab6e --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/oidc/mappers/ClaimsParameterWithValueIdTokenMapper.java @@ -0,0 +1,141 @@ +/* + * Copyright 2022 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.protocol.oidc.mappers; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.jboss.logging.Logger; +import org.keycloak.models.ClientSessionContext; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.ProtocolMapperModel; +import org.keycloak.models.UserSessionModel; +import org.keycloak.protocol.oidc.OIDCLoginProtocol; +import org.keycloak.provider.ProviderConfigProperty; +import org.keycloak.representations.ClaimsRepresentation; +import org.keycloak.representations.IDToken; +import org.keycloak.services.clientpolicy.executor.IntentClientBindCheckExecutor; +import org.keycloak.util.JsonSerialization; +import org.keycloak.util.TokenUtil; + +/** + * @author Takashi Norimatsu + */ +public class ClaimsParameterWithValueIdTokenMapper extends AbstractOIDCProtocolMapper implements OIDCIDTokenMapper { + + private static final Logger LOGGER = Logger.getLogger(ClaimsParameterWithValueIdTokenMapper.class); + + public static final String PROVIDER_ID = "oidc-claims-param-value-idtoken-mapper"; + + private static final List configProperties = new ArrayList<>(); + + public static final String CLAIM_NAME = "claim.name"; + + static { + ProviderConfigProperty property = new ProviderConfigProperty(); + property.setName(CLAIM_NAME); + property.setLabel("Claim name"); + property.setType(ProviderConfigProperty.STRING_TYPE); + property.setHelpText("Name of the claim you want to set its value. 'true' and 'false can be used for boolean values."); + configProperties.add(property); + + OIDCAttributeMapperHelper.addIncludeInTokensConfig(configProperties, ClaimsParameterWithValueIdTokenMapper.class); + } + + @Override + public String getDisplayCategory() { + return TOKEN_MAPPER_CATEGORY; + } + + @Override + public String getDisplayType() { + return "Claims parameter with value ID Token"; + } + + @Override + public String getId() { + return PROVIDER_ID; + } + + @Override + public String getHelpText() { + return "Claims specified by Claims parameter with value are put into an ID token."; + } + + @Override + public List getConfigProperties() { + return configProperties; + } + + @Override + protected void setClaim(IDToken token, ProtocolMapperModel mappingModel, UserSessionModel userSession, KeycloakSession keycloakSession, ClientSessionContext clientSessionCtx) { + String claims = clientSessionCtx.getClientSession().getNote(OIDCLoginProtocol.CLAIMS_PARAM); + if (claims == null) return; + + if (TokenUtil.TOKEN_TYPE_ID.equals(token.getType())) { + putClaims(ClaimsRepresentation.ClaimContext.ID_TOKEN, claims, token, mappingModel, userSession); + } + } + + private void putClaims(ClaimsRepresentation.ClaimContext tokenType, String claims, IDToken token, ProtocolMapperModel mappingModel, UserSessionModel userSession) { + String claimName = mappingModel.getConfig().get(CLAIM_NAME); + if (claimName == null) return; + + ClaimsRepresentation claimsRep = null; + + try { + claimsRep = JsonSerialization.readValue(claims, ClaimsRepresentation.class); + } catch (IOException e) { + LOGGER.warn("Invalid claims parameter", e); + return; + } + + if (!claimsRep.isPresent(claimName, tokenType) || claimsRep.isPresentAsNullClaim(claimName, tokenType)) { + return; + } + + ClaimsRepresentation.ClaimValue claimValue = claimsRep.getClaimValue(claimName, tokenType, String.class); + if (!claimValue.isEssential()) { + return; + } + + String claim = claimValue.getValue(); + if (claim == null) { + return; + } + + HardcodedClaim hardcodedClaimMapper = new HardcodedClaim(); + hardcodedClaimMapper.setClaim(token, HardcodedClaim.create("hard", claimName, claim, "String", false, true), userSession); + } + + public static ProtocolMapperModel createMapper(String name, String attributeValue, boolean idToken) { + ProtocolMapperModel mapper = new ProtocolMapperModel(); + mapper.setName(name); + mapper.setProtocolMapper(PROVIDER_ID); + mapper.setProtocol(OIDCLoginProtocol.LOGIN_PROTOCOL); + Map config = new HashMap(); + config.put(CLAIM_NAME, attributeValue); + if (idToken) config.put(OIDCAttributeMapperHelper.INCLUDE_IN_ID_TOKEN, "true"); + mapper.setConfig(config); + return mapper; + } + +} diff --git a/services/src/main/java/org/keycloak/services/clientpolicy/executor/IntentClientBindCheckExecutor.java b/services/src/main/java/org/keycloak/services/clientpolicy/executor/IntentClientBindCheckExecutor.java new file mode 100644 index 0000000000..2966481010 --- /dev/null +++ b/services/src/main/java/org/keycloak/services/clientpolicy/executor/IntentClientBindCheckExecutor.java @@ -0,0 +1,220 @@ +/* + * Copyright 2022 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 java.io.IOException; +import java.io.Serializable; +import java.util.Optional; + +import javax.ws.rs.core.HttpHeaders; +import javax.ws.rs.core.MediaType; + +import org.jboss.logging.Logger; +import org.keycloak.OAuthErrorException; +import org.keycloak.broker.provider.util.SimpleHttp; +import org.keycloak.models.ClientModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.protocol.oidc.endpoints.request.AuthorizationEndpointRequest; +import org.keycloak.representations.ClaimsRepresentation; +import org.keycloak.representations.idm.ClientPolicyExecutorConfigurationRepresentation; +import org.keycloak.services.clientpolicy.ClientPolicyContext; +import org.keycloak.services.clientpolicy.ClientPolicyException; +import org.keycloak.services.clientpolicy.context.AuthorizationRequestContext; +import org.keycloak.util.JsonSerialization; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * @author Takashi Norimatsu + */ +public class IntentClientBindCheckExecutor implements ClientPolicyExecutorProvider { + + private static final Logger logger = Logger.getLogger(IntentClientBindCheckExecutor.class); + + private final KeycloakSession session; + private Configuration configuration; + + public IntentClientBindCheckExecutor(KeycloakSession session) { + this.session = session; + } + + @Override + public String getProviderId() { + return IntentClientBindCheckExecutorFactory.PROVIDER_ID; + } + + @Override + public void setupConfiguration(IntentClientBindCheckExecutor.Configuration config) { + this.configuration = Optional.ofNullable(config).orElse(createDefaultConfiguration()); + } + + @Override + public Class getExecutorConfigurationClass() { + return Configuration.class; + } + + public static class Configuration extends ClientPolicyExecutorConfigurationRepresentation { + + @JsonProperty("intent-client-bind-check-endpoint") + protected String intentClientBindCheckEndpoint; + + @JsonProperty("intent-name") + protected String intentName; + + public String getIntentClientBindCheckEndpoint() { + return intentClientBindCheckEndpoint; + } + + public void setIntentClientBindCheckEndpoint(String intentClientBindCheckEndpoint) { + this.intentClientBindCheckEndpoint = intentClientBindCheckEndpoint; + } + + public String getIntentName() { + return intentName; + } + + public void setIntentName(String intentName) { + this.intentName = intentName; + } + } + + @Override + public void executeOnEvent(ClientPolicyContext context) throws ClientPolicyException { + switch (context.getEvent()) { + case AUTHORIZATION_REQUEST: + checkIntentClientBind((AuthorizationRequestContext)context); + break; + default: + return; + } + } + + private Configuration createDefaultConfiguration() { + Configuration conf = new Configuration(); + return conf; + } + + private void checkIntentClientBind(AuthorizationRequestContext context) throws ClientPolicyException { + if (!isValidIntentClientBindCheckEndpoint()) { + throw new ClientPolicyException(OAuthErrorException.SERVER_ERROR, "invalid Intent Client Bind Check Endpoint setting"); + } + ClientModel client = session.getContext().getClient(); + String clientId = client.getClientId(); + String intentId = retrieveIntentId(context.getAuthorizationEndpointRequest()); + IntentBindCheckRequest request = new IntentBindCheckRequest(); + request.setClientId(clientId); + request.setIntentId(intentId); + SimpleHttp simpleHttp = SimpleHttp.doPost(configuration.getIntentClientBindCheckEndpoint(), session) + .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON) + .json(request); + IntentBindCheckResponse response = null; + try { + response = simpleHttp.asJson(IntentBindCheckResponse.class); + } catch (IOException e) { + logger.warnv("HTTP connection failure: {0}", e); + throw new ClientPolicyException(OAuthErrorException.INVALID_REQUEST, "checking intent bound with client failed"); + } + if (!response.isBound.booleanValue()) { + logger.tracev("Not Bound: intentName = {0}, intentId = {1}, clientId = {2}", configuration.getIntentName(), intentId, clientId); + throw new ClientPolicyException(OAuthErrorException.INVALID_REQUEST, "The intent is not bound with the client"); + } + logger.tracev("Bound: intentName = {0}, intentId = {1}, clientId = {2}", configuration.getIntentName(), intentId, clientId); + } + + private String retrieveIntentId(AuthorizationEndpointRequest request) throws ClientPolicyException { + String claims = request.getClaims(); + if (claims == null || claims.isEmpty()) { + throw new ClientPolicyException(OAuthErrorException.INVALID_REQUEST, "no claim for an intent value in an authorization request"); + } + + String intentName = configuration.getIntentName(); + if (intentName == null || intentName.isEmpty()) { + throw new ClientPolicyException(OAuthErrorException.INVALID_REQUEST, "invalid intent name setting"); + } + + ClaimsRepresentation claimsRep = null; + + try { + claimsRep = JsonSerialization.readValue(claims, ClaimsRepresentation.class); + } catch (IOException e) { + throw new ClientPolicyException(OAuthErrorException.INVALID_REQUEST, "invalid claim for an intent value"); + } + + if(!claimsRep.isPresent(intentName, ClaimsRepresentation.ClaimContext.ID_TOKEN) || claimsRep.isPresentAsNullClaim(intentName, ClaimsRepresentation.ClaimContext.ID_TOKEN)) { + throw new ClientPolicyException(OAuthErrorException.INVALID_REQUEST, "no claim for an intent value for ID token"); + } + + ClaimsRepresentation.ClaimValue claimValue = claimsRep.getClaimValue(intentName, ClaimsRepresentation.ClaimContext.ID_TOKEN, String.class); + if (!claimValue.isEssential()) { + throw new ClientPolicyException(OAuthErrorException.INVALID_REQUEST, "not specifying a claim for an intent as essential claim"); + } + + String value = claimValue.getValue(); + if (value == null) { + throw new ClientPolicyException(OAuthErrorException.INVALID_REQUEST, "invalid intent value"); + } + + return value; + } + + private boolean isValidIntentClientBindCheckEndpoint() { + String endpoint = configuration.getIntentClientBindCheckEndpoint(); + if (endpoint == null) return false; + if (!endpoint.startsWith("http://") && !endpoint.startsWith("https://")) return false; + return true; + } + + public static class IntentBindCheckRequest implements Serializable { + + @JsonProperty("intent_id") + private String intentId; + + @JsonProperty("client_id") + private String clientId; + + public String getIntentId() { + return intentId; + } + + public void setIntentId(String intentId) { + this.intentId = intentId; + } + + public String getClientId() { + return clientId; + } + + public void setClientId(String clientId) { + this.clientId = clientId; + } + } + + public static class IntentBindCheckResponse implements Serializable { + + @JsonProperty("is_bound") + private Boolean isBound; + + public Boolean getIsBound() { + return isBound; + } + + public void setIsBound(Boolean isBound) { + this.isBound = isBound; + } + } +} diff --git a/services/src/main/java/org/keycloak/services/clientpolicy/executor/IntentClientBindCheckExecutorFactory.java b/services/src/main/java/org/keycloak/services/clientpolicy/executor/IntentClientBindCheckExecutorFactory.java new file mode 100644 index 0000000000..2e5afb5c44 --- /dev/null +++ b/services/src/main/java/org/keycloak/services/clientpolicy/executor/IntentClientBindCheckExecutorFactory.java @@ -0,0 +1,71 @@ +/* + * Copyright 2022 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 java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import org.keycloak.Config.Scope; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.provider.ProviderConfigProperty; + +public class IntentClientBindCheckExecutorFactory implements ClientPolicyExecutorProviderFactory { + + public static final String PROVIDER_ID = "intent-client-bind-checker"; + + public static final String INTENT_CLIENT_BIND_CHECK_ENDPOINT = "intent-client-bind-check-endpoint"; + + private static final ProviderConfigProperty INTENT_CLIENT_BIND_CHECK_ENDPOINT_PROPERTY = new ProviderConfigProperty( + INTENT_CLIENT_BIND_CHECK_ENDPOINT, "Intent Client Bind Check Endpoint", "Endpoint for checking if openbanking_intent_id is bound with a client.", + ProviderConfigProperty.STRING_TYPE, "https://rs.keycloak-fapi.org/check-intent-client-bound"); + + @Override + public ClientPolicyExecutorProvider create(KeycloakSession session) { + return new IntentClientBindCheckExecutor(session); + } + + @Override + public void init(Scope config) { + } + + @Override + public void postInit(KeycloakSessionFactory factory) { + } + + @Override + public void close() { + } + + @Override + public String getId() { + return PROVIDER_ID; + } + + @Override + public String getHelpText() { + return "The executor checks if openbanking_intent_id is bound with a client."; + } + + @Override + public List getConfigProperties() { + return new ArrayList<>(Arrays.asList(INTENT_CLIENT_BIND_CHECK_ENDPOINT_PROPERTY)); + } + +} diff --git a/services/src/main/resources/META-INF/services/org.keycloak.protocol.ProtocolMapper b/services/src/main/resources/META-INF/services/org.keycloak.protocol.ProtocolMapper index 31dab442c4..8daf82bbd1 100755 --- a/services/src/main/resources/META-INF/services/org.keycloak.protocol.ProtocolMapper +++ b/services/src/main/resources/META-INF/services/org.keycloak.protocol.ProtocolMapper @@ -43,4 +43,5 @@ org.keycloak.protocol.docker.mapper.AllowAllDockerProtocolMapper org.keycloak.protocol.saml.mappers.SAMLAudienceProtocolMapper org.keycloak.protocol.saml.mappers.SAMLAudienceResolveProtocolMapper org.keycloak.protocol.oidc.mappers.ClaimsParameterTokenMapper -org.keycloak.protocol.saml.mappers.UserAttributeNameIdMapper \ No newline at end of file +org.keycloak.protocol.saml.mappers.UserAttributeNameIdMapper +org.keycloak.protocol.oidc.mappers.ClaimsParameterWithValueIdTokenMapper \ No newline at end of file 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 4e39c85116..67aa86849d 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 @@ -17,3 +17,4 @@ org.keycloak.services.clientpolicy.executor.SecureLogoutExecutorFactory org.keycloak.services.clientpolicy.executor.RejectResourceOwnerPasswordCredentialsGrantExecutorFactory org.keycloak.services.clientpolicy.executor.ClientSecretRotationExecutorFactory org.keycloak.services.clientpolicy.executor.RejectRequestExecutorFactory +org.keycloak.services.clientpolicy.executor.IntentClientBindCheckExecutorFactory \ No newline at end of file diff --git a/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/rest/TestApplicationResourceProvider.java b/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/rest/TestApplicationResourceProvider.java index 77406746e7..c1a119155c 100644 --- a/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/rest/TestApplicationResourceProvider.java +++ b/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/rest/TestApplicationResourceProvider.java @@ -67,6 +67,7 @@ public class TestApplicationResourceProvider implements RealmResourceProvider { private final ConcurrentMap authenticationChannelRequests; private final ConcurrentMap cibaClientNotifications; + private final ConcurrentMap intentClientBindings; @Context HttpRequest request; @@ -78,7 +79,8 @@ public class TestApplicationResourceProvider implements RealmResourceProvider { BlockingQueue adminTestAvailabilityAction, TestApplicationResourceProviderFactory.OIDCClientData oidcClientData, ConcurrentMap authenticationChannelRequests, - ConcurrentMap cibaClientNotifications) { + ConcurrentMap cibaClientNotifications, + ConcurrentMap intentClientBindings) { this.session = session; this.adminLogoutActions = adminLogoutActions; this.backChannelLogoutTokens = backChannelLogoutTokens; @@ -88,6 +90,7 @@ public class TestApplicationResourceProvider implements RealmResourceProvider { this.oidcClientData = oidcClientData; this.authenticationChannelRequests = authenticationChannelRequests; this.cibaClientNotifications = cibaClientNotifications; + this.intentClientBindings = intentClientBindings; } @POST @@ -256,7 +259,7 @@ public class TestApplicationResourceProvider implements RealmResourceProvider { @Path("/oidc-client-endpoints") public TestingOIDCEndpointsApplicationResource getTestingOIDCClientEndpoints() { - return new TestingOIDCEndpointsApplicationResource(oidcClientData, authenticationChannelRequests, cibaClientNotifications); + return new TestingOIDCEndpointsApplicationResource(oidcClientData, authenticationChannelRequests, cibaClientNotifications, intentClientBindings); } @Override diff --git a/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/rest/TestApplicationResourceProviderFactory.java b/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/rest/TestApplicationResourceProviderFactory.java index 071541522b..5a44a2f9c1 100644 --- a/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/rest/TestApplicationResourceProviderFactory.java +++ b/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/rest/TestApplicationResourceProviderFactory.java @@ -54,11 +54,12 @@ public class TestApplicationResourceProviderFactory implements RealmResourceProv private final OIDCClientData oidcClientData = new OIDCClientData(); private ConcurrentMap authenticationChannelRequests = new ConcurrentHashMap<>(); private ConcurrentMap cibaClientNotifications = new ConcurrentHashMap<>(); + private ConcurrentMap intentClientBindings = new ConcurrentHashMap<>(); @Override public RealmResourceProvider create(KeycloakSession session) { TestApplicationResourceProvider provider = new TestApplicationResourceProvider(session, adminLogoutActions, - backChannelLogoutTokens, frontChannelLogoutTokens, pushNotBeforeActions, testAvailabilityActions, oidcClientData, authenticationChannelRequests, cibaClientNotifications); + backChannelLogoutTokens, frontChannelLogoutTokens, pushNotBeforeActions, testAvailabilityActions, oidcClientData, authenticationChannelRequests, cibaClientNotifications, intentClientBindings); ResteasyProviderFactory.getInstance().injectProperties(provider); diff --git a/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/rest/resource/TestingOIDCEndpointsApplicationResource.java b/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/rest/resource/TestingOIDCEndpointsApplicationResource.java index 3bedfe4fee..25d78e45d6 100644 --- a/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/rest/resource/TestingOIDCEndpointsApplicationResource.java +++ b/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/rest/resource/TestingOIDCEndpointsApplicationResource.java @@ -56,6 +56,7 @@ import org.keycloak.protocol.oidc.grants.ciba.endpoints.ClientNotificationEndpoi import org.keycloak.representations.AccessToken; import org.keycloak.representations.JsonWebToken; import org.keycloak.services.ErrorResponseException; +import org.keycloak.services.clientpolicy.executor.IntentClientBindCheckExecutor; import org.keycloak.services.managers.AppAuthManager; import org.keycloak.testsuite.rest.TestApplicationResourceProviderFactory; import org.keycloak.testsuite.rest.representation.TestAuthenticationChannelRequest; @@ -103,13 +104,15 @@ public class TestingOIDCEndpointsApplicationResource { private final TestApplicationResourceProviderFactory.OIDCClientData clientData; private final ConcurrentMap authenticationChannelRequests; private final ConcurrentMap cibaClientNotifications; - + private final ConcurrentMap intentClientBindings; public TestingOIDCEndpointsApplicationResource(TestApplicationResourceProviderFactory.OIDCClientData oidcClientData, - ConcurrentMap authenticationChannelRequests, ConcurrentMap cibaClientNotifications) { + ConcurrentMap authenticationChannelRequests, ConcurrentMap cibaClientNotifications, + ConcurrentMap intentClientBindings) { this.clientData = oidcClientData; this.authenticationChannelRequests = authenticationChannelRequests; this.cibaClientNotifications = cibaClientNotifications; + this.intentClientBindings = intentClientBindings; } @GET @@ -728,4 +731,27 @@ public class TestingOIDCEndpointsApplicationResource { } return request; } + + @GET + @Path("/bind-intent-with-client") + @Produces(MediaType.APPLICATION_JSON) + @NoCache + public Response bindIntentWithClient(@QueryParam("intentId") String intentId, @QueryParam("clientId") String clientId) { + intentClientBindings.put(intentId, clientId); + return Response.noContent().build(); + } + + @POST + @Path("/check-intent-client-bound") + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + @NoCache + public IntentClientBindCheckExecutor.IntentBindCheckResponse checkIntentClientBound(IntentClientBindCheckExecutor.IntentBindCheckRequest request) { + IntentClientBindCheckExecutor.IntentBindCheckResponse response = new IntentClientBindCheckExecutor.IntentBindCheckResponse(); + response.setIsBound(Boolean.FALSE); + if (intentClientBindings.containsKey(request.getIntentId()) && intentClientBindings.get(request.getIntentId()).equals(request.getClientId())) { + response.setIsBound(Boolean.TRUE); + } + return response; + } } diff --git a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/client/resources/TestApplicationResourceUrls.java b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/client/resources/TestApplicationResourceUrls.java index 080f543a1e..66011f85f0 100644 --- a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/client/resources/TestApplicationResourceUrls.java +++ b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/client/resources/TestApplicationResourceUrls.java @@ -58,4 +58,11 @@ public class TestApplicationResourceUrls { return builder.build().toString(); } + + public static String checkIntentClientBoundUri() { + UriBuilder builder = oidcClientEndpoints() + .path(TestOIDCEndpointsApplicationResource.class, "checkIntentClientBound"); + + return builder.build().toString(); + } } diff --git a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/client/resources/TestOIDCEndpointsApplicationResource.java b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/client/resources/TestOIDCEndpointsApplicationResource.java index 00ec66be5e..88d9084d42 100644 --- a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/client/resources/TestOIDCEndpointsApplicationResource.java +++ b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/client/resources/TestOIDCEndpointsApplicationResource.java @@ -20,6 +20,7 @@ package org.keycloak.testsuite.client.resources; import org.jboss.resteasy.annotations.cache.NoCache; import org.keycloak.jose.jwk.JSONWebKeySet; import org.keycloak.protocol.oidc.grants.ciba.endpoints.ClientNotificationEndpointRequest; +import org.keycloak.services.clientpolicy.executor.IntentClientBindCheckExecutor; import org.keycloak.testsuite.rest.representation.TestAuthenticationChannelRequest; import javax.ws.rs.Consumes; @@ -145,4 +146,16 @@ public interface TestOIDCEndpointsApplicationResource { @NoCache ClientNotificationEndpointRequest getPushedCibaClientNotification(@QueryParam("clientNotificationToken") String clientNotificationToken); + @GET + @Path("/bind-intent-with-client") + @Produces(MediaType.APPLICATION_JSON) + @NoCache + Response bindIntentWithClient(@QueryParam("intentId") String intentId, @QueryParam("clientId") String clientId); + + @POST + @Path("/check-intent-client-bound") + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + @NoCache + IntentClientBindCheckExecutor.IntentBindCheckResponse checkIntentClientBound(IntentClientBindCheckExecutor.IntentBindCheckRequest request); } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/client/ClientPoliciesTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/client/ClientPoliciesTest.java index af5d9c0269..d31a380bf7 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/client/ClientPoliciesTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/client/ClientPoliciesTest.java @@ -32,7 +32,13 @@ import java.util.Optional; import javax.ws.rs.BadRequestException; import javax.ws.rs.core.Response; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.TreeNode; import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.TextNode; +import com.google.common.collect.ImmutableMap; + import org.apache.http.HttpResponse; import org.apache.http.client.methods.CloseableHttpResponse; import org.apache.http.impl.client.CloseableHttpClient; @@ -46,6 +52,7 @@ 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.ProtocolMappersResource; import org.keycloak.admin.client.resource.RolesResource; import org.keycloak.authentication.authenticators.client.ClientIdAndSecretAuthenticator; import org.keycloak.authentication.authenticators.client.JWTClientAuthenticator; @@ -59,18 +66,23 @@ import org.keycloak.crypto.Algorithm; 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.models.utils.ModelToRepresentation; import org.keycloak.protocol.oidc.OIDCAdvancedConfigWrapper; import org.keycloak.protocol.oidc.OIDCConfigAttributes; import org.keycloak.protocol.oidc.OIDCLoginProtocol; +import org.keycloak.protocol.oidc.mappers.ClaimsParameterTokenMapper; +import org.keycloak.protocol.oidc.mappers.ClaimsParameterWithValueIdTokenMapper; import org.keycloak.protocol.oidc.utils.OIDCResponseType; import org.keycloak.representations.AccessToken; import org.keycloak.representations.AuthorizationResponseToken; +import org.keycloak.representations.ClaimsRepresentation; import org.keycloak.representations.IDToken; import org.keycloak.representations.RefreshToken; import org.keycloak.representations.idm.ClientPolicyExecutorConfigurationRepresentation; @@ -99,6 +111,7 @@ import org.keycloak.services.clientpolicy.executor.ConfidentialClientAcceptExecu import org.keycloak.services.clientpolicy.executor.ConsentRequiredExecutorFactory; import org.keycloak.services.clientpolicy.executor.FullScopeDisabledExecutorFactory; import org.keycloak.services.clientpolicy.executor.HolderOfKeyEnforcerExecutorFactory; +import org.keycloak.services.clientpolicy.executor.IntentClientBindCheckExecutorFactory; import org.keycloak.services.clientpolicy.executor.PKCEEnforcerExecutorFactory; import org.keycloak.services.clientpolicy.executor.RejectRequestExecutorFactory; import org.keycloak.services.clientpolicy.executor.RejectResourceOwnerPasswordCredentialsGrantExecutorFactory; @@ -115,6 +128,7 @@ import org.keycloak.testsuite.admin.ApiUtil; import org.keycloak.testsuite.arquillian.annotation.EnableFeature; import org.keycloak.testsuite.client.resources.TestApplicationResourceUrls; import org.keycloak.testsuite.client.resources.TestOIDCEndpointsApplicationResource; +import org.keycloak.testsuite.pages.AppPage; import org.keycloak.testsuite.pages.ErrorPage; import org.keycloak.testsuite.pages.LogoutConfirmPage; import org.keycloak.testsuite.pages.OAuth2DeviceVerificationPage; @@ -124,9 +138,11 @@ import org.keycloak.testsuite.services.clientpolicy.condition.TestRaiseException import org.keycloak.testsuite.services.clientpolicy.executor.TestRaiseExceptionExecutorFactory; import org.keycloak.testsuite.updaters.ClientAttributeUpdater; import org.keycloak.testsuite.util.ClientBuilder; +import org.keycloak.testsuite.util.ClientManager; import org.keycloak.testsuite.util.ClientPoliciesUtil; import org.keycloak.testsuite.util.MutualTLSUtils; import org.keycloak.testsuite.util.OAuthClient; +import org.keycloak.testsuite.util.OAuthClient.AuthorizationEndpointResponse; import org.keycloak.testsuite.util.RoleBuilder; import org.keycloak.testsuite.util.ServerURLs; import org.keycloak.testsuite.util.UserBuilder; @@ -143,6 +159,7 @@ import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertTrue; import static org.junit.Assert.fail; import static org.keycloak.testsuite.admin.AbstractAdminTest.loadJson; +import static org.keycloak.testsuite.admin.ApiUtil.findClientResourceByClientId; import static org.keycloak.testsuite.admin.ApiUtil.findUserByUsername; import static org.keycloak.testsuite.util.ClientPoliciesUtil.ClientPoliciesBuilder; import static org.keycloak.testsuite.util.ClientPoliciesUtil.ClientPolicyBuilder; @@ -159,6 +176,7 @@ import static org.keycloak.testsuite.util.ClientPoliciesUtil.createClientUpdateS import static org.keycloak.testsuite.util.ClientPoliciesUtil.createConsentRequiredExecutorConfig; import static org.keycloak.testsuite.util.ClientPoliciesUtil.createFullScopeDisabledExecutorConfig; import static org.keycloak.testsuite.util.ClientPoliciesUtil.createHolderOfKeyEnforceExecutorConfig; +import static org.keycloak.testsuite.util.ClientPoliciesUtil.createIntentClientBindCheckExecutorConfig; import static org.keycloak.testsuite.util.ClientPoliciesUtil.createPKCEEnforceExecutorConfig; import static org.keycloak.testsuite.util.ClientPoliciesUtil.createRejectisResourceOwnerPasswordCredentialsGrantExecutorConfig; import static org.keycloak.testsuite.util.ClientPoliciesUtil.createSecureClientAuthenticatorExecutorConfig; @@ -2990,6 +3008,128 @@ public class ClientPoliciesTest extends AbstractClientPoliciesTest { } + @Test + public void testIntentClientBindCheck() throws Exception { + final String intentName = "openbanking_intent_id"; + + // register profiles + String json = (new ClientProfilesBuilder()).addProfile( + (new ClientProfileBuilder()).createProfile(PROFILE_NAME, "Het Eerste Profiel") + .addExecutor(IntentClientBindCheckExecutorFactory.PROVIDER_ID, + createIntentClientBindCheckExecutorConfig(intentName, TestApplicationResourceUrls.checkIntentClientBoundUri())) + .toRepresentation() + ).toString(); + updateProfiles(json); + + // register policies + json = (new ClientPoliciesBuilder()).addPolicy( + (new ClientPolicyBuilder()).createPolicy(POLICY_NAME, "Het Eerste Beleid", Boolean.TRUE) + .addCondition(ClientScopesConditionFactory.PROVIDER_ID, + createClientScopesConditionConfig(ClientScopesConditionFactory.OPTIONAL, Arrays.asList("microprofile-jwt"))) + .addProfile(PROFILE_NAME) + .toRepresentation() + ).toString(); + updatePolicies(json); + + // create a client + String clientId = generateSuffixedName(CLIENT_NAME); + String clientSecret = "secret"; + createClientByAdmin(clientId, (ClientRepresentation clientRep) -> { + clientRep.setSecret(clientSecret); + clientRep.setStandardFlowEnabled(Boolean.TRUE); + clientRep.setImplicitFlowEnabled(Boolean.TRUE); + }); + ClientResource app = findClientResourceByClientId(adminClient.realm("test"), clientId); + ProtocolMappersResource res = app.getProtocolMappers(); + res.createMapper(ModelToRepresentation.toRepresentation(ClaimsParameterWithValueIdTokenMapper.createMapper("claimsParameterWithValueIdTokenMapper", "openbanking_intent_id", true))).close(); + + // register a binding of an intent with different client + String intentId = "123abc456xyz"; + String differentClientId = "test-app"; + Response r = testingClient.testApp().oidcClientEndpoints().bindIntentWithClient(intentId, differentClientId); + assertEquals(204, r.getStatus()); + + // create a request object with claims + String nonce = "naodfejawi37d"; + + ClaimsRepresentation claimsRep = new ClaimsRepresentation(); + ClaimsRepresentation.ClaimValue claimValue = new ClaimsRepresentation.ClaimValue<>(); + claimValue.setEssential(Boolean.TRUE); + claimValue.setValue(intentId); + claimsRep.setIdTokenClaims(Collections.singletonMap(intentName, claimValue)); + + Map oidcRequest = new HashMap<>(); + oidcRequest.put(OIDCLoginProtocol.CLIENT_ID_PARAM, clientId); + oidcRequest.put(OIDCLoginProtocol.NONCE_PARAM, nonce); + oidcRequest.put(OIDCLoginProtocol.RESPONSE_TYPE_PARAM, OIDCResponseType.CODE + " " + OIDCResponseType.ID_TOKEN); + oidcRequest.put(OIDCLoginProtocol.REDIRECT_URI_PARAM, oauth.getRedirectUri()); + oidcRequest.put(OIDCLoginProtocol.CLAIMS_PARAM, claimsRep); + oidcRequest.put(OIDCLoginProtocol.SCOPE_PARAM, "openid" + " " + "microprofile-jwt"); + String request = new JWSBuilder().jsonContent(oidcRequest).none(); + + // send an authorization request + oauth.scope("openid" + " " + "microprofile-jwt"); + oauth.request(request); + oauth.clientId(clientId); + oauth.nonce(nonce); + oauth.responseType(OIDCResponseType.CODE + " " + OIDCResponseType.ID_TOKEN); + oauth.openLoginForm(); + assertEquals(OAuthErrorException.INVALID_REQUEST, oauth.getCurrentFragment().get(OAuth2Constants.ERROR)); + assertEquals("The intent is not bound with the client", oauth.getCurrentFragment().get(OAuth2Constants.ERROR_DESCRIPTION)); + + // register a binding of an intent with a valid client + r = testingClient.testApp().oidcClientEndpoints().bindIntentWithClient(intentId, clientId); + assertEquals(204, r.getStatus()); + + // send an authorization request + oauth.doLogin(TEST_USER_NAME, TEST_USER_PASSWORD); + + // check an authorization response + EventRepresentation loginEvent = events.expectLogin().client(clientId).assertEvent(); + String sessionId = loginEvent.getSessionId(); + String codeId = loginEvent.getDetails().get(Details.CODE_ID); + String code = oauth.getCurrentFragment().get(OAuth2Constants.CODE); + OAuthClient.AuthorizationEndpointResponse authzResponse = new OAuthClient.AuthorizationEndpointResponse(oauth, true); + JWSInput idToken = new JWSInput(authzResponse.getIdToken()); + ObjectMapper mapper = JsonSerialization.mapper; + JsonParser parser = mapper.getFactory().createParser(idToken.readContentAsString()); + TreeNode treeNode = mapper.readTree(parser); + String clientBoundIntentId = ((TextNode) treeNode.get(intentName)).asText(); + assertEquals(intentId, clientBoundIntentId); + + // send a token request + OAuthClient.AccessTokenResponse response = oauth.doAccessTokenRequest(code, clientSecret); + + // check a token response + assertEquals(200, response.getStatusCode()); + events.expectCodeToToken(codeId, sessionId).client(clientId).assertEvent(); + idToken = new JWSInput(response.getIdToken()); + mapper = JsonSerialization.mapper; + parser = mapper.getFactory().createParser(idToken.readContentAsString()); + treeNode = mapper.readTree(parser); + clientBoundIntentId = ((TextNode) treeNode.get(intentName)).asText(); + assertEquals(intentId, clientBoundIntentId); + + // logout + oauth.doLogout(response.getRefreshToken(), clientSecret); + events.expectLogout(response.getSessionState()).client(clientId).clearDetails().assertEvent(); + + // create a request object with invalid claims + claimsRep = new ClaimsRepresentation(); + claimValue = new ClaimsRepresentation.ClaimValue<>(); + claimValue.setEssential(Boolean.TRUE); + claimValue.setValue(intentId); + claimsRep.setIdTokenClaims(Collections.singletonMap("other_intent_id", claimValue)); + oidcRequest.put(OIDCLoginProtocol.CLAIMS_PARAM, claimsRep); + request = new JWSBuilder().jsonContent(oidcRequest).none(); + + // send an authorization request + oauth.request(request); + oauth.openLoginForm(); + assertEquals(OAuthErrorException.INVALID_REQUEST, oauth.getCurrentFragment().get(OAuth2Constants.ERROR)); + assertEquals("no claim for an intent value for ID token" , oauth.getCurrentFragment().get(OAuth2Constants.ERROR_DESCRIPTION)); + } + private void openVerificationPage(String verificationUri) { driver.navigate().to(verificationUri); } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/util/ClientPoliciesUtil.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/util/ClientPoliciesUtil.java index 53ce8e4821..0400954ce4 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/util/ClientPoliciesUtil.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/util/ClientPoliciesUtil.java @@ -41,6 +41,7 @@ import org.keycloak.services.clientpolicy.condition.ClientUpdaterSourceRolesCond import org.keycloak.services.clientpolicy.executor.ConsentRequiredExecutor; import org.keycloak.services.clientpolicy.executor.FullScopeDisabledExecutor; import org.keycloak.services.clientpolicy.executor.HolderOfKeyEnforcerExecutor; +import org.keycloak.services.clientpolicy.executor.IntentClientBindCheckExecutor; import org.keycloak.services.clientpolicy.executor.PKCEEnforcerExecutor; import org.keycloak.services.clientpolicy.executor.RejectResourceOwnerPasswordCredentialsGrantExecutor; import org.keycloak.services.clientpolicy.executor.SecureClientAuthenticatorExecutor; @@ -220,6 +221,13 @@ public final class ClientPoliciesUtil { return config; } + public static IntentClientBindCheckExecutor.Configuration createIntentClientBindCheckExecutorConfig(String intentName, String endpoint) { + IntentClientBindCheckExecutor.Configuration config = new IntentClientBindCheckExecutor.Configuration(); + config.setIntentName(intentName); + config.setIntentClientBindCheckEndpoint(endpoint); + return config; + } + public static class ClientPoliciesBuilder { private final ClientPoliciesRepresentation policiesRep;