Intent support before issuing tokens (UK OpenBanking)

Closes #12883
This commit is contained in:
Takashi Norimatsu 2022-06-22 07:00:22 +09:00 committed by Marek Posolda
parent 603a55796d
commit 0a832fc744
12 changed files with 638 additions and 6 deletions

View file

@ -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 <a href="mailto:takashi.norimatsu.ws@hitachi.com">Takashi Norimatsu</a>
*/
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<ProviderConfigProperty> 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<ProviderConfigProperty> 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<String> 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<String, String> config = new HashMap<String, String>();
config.put(CLAIM_NAME, attributeValue);
if (idToken) config.put(OIDCAttributeMapperHelper.INCLUDE_IN_ID_TOKEN, "true");
mapper.setConfig(config);
return mapper;
}
}

View file

@ -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 <a href="mailto:takashi.norimatsu.ws@hitachi.com">Takashi Norimatsu</a>
*/
public class IntentClientBindCheckExecutor implements ClientPolicyExecutorProvider<IntentClientBindCheckExecutor.Configuration> {
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<Configuration> 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<String> 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;
}
}
}

View file

@ -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<ProviderConfigProperty> getConfigProperties() {
return new ArrayList<>(Arrays.asList(INTENT_CLIENT_BIND_CHECK_ENDPOINT_PROPERTY));
}
}

View file

@ -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
org.keycloak.protocol.saml.mappers.UserAttributeNameIdMapper
org.keycloak.protocol.oidc.mappers.ClaimsParameterWithValueIdTokenMapper

View file

@ -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

View file

@ -67,6 +67,7 @@ public class TestApplicationResourceProvider implements RealmResourceProvider {
private final ConcurrentMap<String, TestAuthenticationChannelRequest> authenticationChannelRequests;
private final ConcurrentMap<String, ClientNotificationEndpointRequest> cibaClientNotifications;
private final ConcurrentMap<String, String> intentClientBindings;
@Context
HttpRequest request;
@ -78,7 +79,8 @@ public class TestApplicationResourceProvider implements RealmResourceProvider {
BlockingQueue<TestAvailabilityAction> adminTestAvailabilityAction,
TestApplicationResourceProviderFactory.OIDCClientData oidcClientData,
ConcurrentMap<String, TestAuthenticationChannelRequest> authenticationChannelRequests,
ConcurrentMap<String, ClientNotificationEndpointRequest> cibaClientNotifications) {
ConcurrentMap<String, ClientNotificationEndpointRequest> cibaClientNotifications,
ConcurrentMap<String, String> 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

View file

@ -54,11 +54,12 @@ public class TestApplicationResourceProviderFactory implements RealmResourceProv
private final OIDCClientData oidcClientData = new OIDCClientData();
private ConcurrentMap<String, TestAuthenticationChannelRequest> authenticationChannelRequests = new ConcurrentHashMap<>();
private ConcurrentMap<String, ClientNotificationEndpointRequest> cibaClientNotifications = new ConcurrentHashMap<>();
private ConcurrentMap<String, String> 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);

View file

@ -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<String, TestAuthenticationChannelRequest> authenticationChannelRequests;
private final ConcurrentMap<String, ClientNotificationEndpointRequest> cibaClientNotifications;
private final ConcurrentMap<String, String> intentClientBindings;
public TestingOIDCEndpointsApplicationResource(TestApplicationResourceProviderFactory.OIDCClientData oidcClientData,
ConcurrentMap<String, TestAuthenticationChannelRequest> authenticationChannelRequests, ConcurrentMap<String, ClientNotificationEndpointRequest> cibaClientNotifications) {
ConcurrentMap<String, TestAuthenticationChannelRequest> authenticationChannelRequests, ConcurrentMap<String, ClientNotificationEndpointRequest> cibaClientNotifications,
ConcurrentMap<String, String> 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;
}
}

View file

@ -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();
}
}

View file

@ -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);
}

View file

@ -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<String> claimValue = new ClaimsRepresentation.ClaimValue<>();
claimValue.setEssential(Boolean.TRUE);
claimValue.setValue(intentId);
claimsRep.setIdTokenClaims(Collections.singletonMap(intentName, claimValue));
Map<String, Object> 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);
}

View file

@ -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;