From 43eb2b7c901468132f546b0fa53b2e232528ec37 Mon Sep 17 00:00:00 2001 From: Takashi Norimatsu Date: Sun, 4 Jul 2021 11:40:53 +0900 Subject: [PATCH] KEYCLOAK-18123 Client Policy - Executor : Enforce Backchannel Authentication Request satisfying high security level --- ...ckchannelAuthenticationRequestContext.java | 2 +- .../SecureCibaSessionEnforceExecutor.java | 73 +++++ ...cureCibaSessionEnforceExecutorFactory.java | 69 ++++ ...baSignedAuthenticationRequestExecutor.java | 169 ++++++++++ ...dAuthenticationRequestExecutorFactory.java | 76 +++++ .../BackchannelAuthenticationEndpoint.java | 2 +- ...ecutor.ClientPolicyExecutorProviderFactory | 4 +- ...stingOIDCEndpointsApplicationResource.java | 11 +- .../keycloak/testsuite/client/CIBATest.java | 304 ++++++++++++++---- 9 files changed, 645 insertions(+), 65 deletions(-) rename services/src/main/java/org/keycloak/{services => protocol/oidc/grants/ciba}/clientpolicy/context/BackchannelAuthenticationRequestContext.java (96%) create mode 100644 services/src/main/java/org/keycloak/protocol/oidc/grants/ciba/clientpolicy/executor/SecureCibaSessionEnforceExecutor.java create mode 100644 services/src/main/java/org/keycloak/protocol/oidc/grants/ciba/clientpolicy/executor/SecureCibaSessionEnforceExecutorFactory.java create mode 100644 services/src/main/java/org/keycloak/protocol/oidc/grants/ciba/clientpolicy/executor/SecureCibaSignedAuthenticationRequestExecutor.java create mode 100644 services/src/main/java/org/keycloak/protocol/oidc/grants/ciba/clientpolicy/executor/SecureCibaSignedAuthenticationRequestExecutorFactory.java diff --git a/services/src/main/java/org/keycloak/services/clientpolicy/context/BackchannelAuthenticationRequestContext.java b/services/src/main/java/org/keycloak/protocol/oidc/grants/ciba/clientpolicy/context/BackchannelAuthenticationRequestContext.java similarity index 96% rename from services/src/main/java/org/keycloak/services/clientpolicy/context/BackchannelAuthenticationRequestContext.java rename to services/src/main/java/org/keycloak/protocol/oidc/grants/ciba/clientpolicy/context/BackchannelAuthenticationRequestContext.java index 97d888b1a9..b161ac79f5 100644 --- a/services/src/main/java/org/keycloak/services/clientpolicy/context/BackchannelAuthenticationRequestContext.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/grants/ciba/clientpolicy/context/BackchannelAuthenticationRequestContext.java @@ -15,7 +15,7 @@ * limitations under the License. */ -package org.keycloak.services.clientpolicy.context; +package org.keycloak.protocol.oidc.grants.ciba.clientpolicy.context; import javax.ws.rs.core.MultivaluedMap; diff --git a/services/src/main/java/org/keycloak/protocol/oidc/grants/ciba/clientpolicy/executor/SecureCibaSessionEnforceExecutor.java b/services/src/main/java/org/keycloak/protocol/oidc/grants/ciba/clientpolicy/executor/SecureCibaSessionEnforceExecutor.java new file mode 100644 index 0000000000..77b0a5b646 --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/oidc/grants/ciba/clientpolicy/executor/SecureCibaSessionEnforceExecutor.java @@ -0,0 +1,73 @@ +/* + * Copyright 2021 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.grants.ciba.clientpolicy.executor; + +import javax.ws.rs.core.MultivaluedMap; + +import org.jboss.logging.Logger; +import org.keycloak.OAuthErrorException; +import org.keycloak.models.KeycloakSession; +import org.keycloak.protocol.oidc.grants.ciba.clientpolicy.context.BackchannelAuthenticationRequestContext; +import org.keycloak.protocol.oidc.grants.ciba.endpoints.request.BackchannelAuthenticationEndpointRequest; +import org.keycloak.representations.idm.ClientPolicyExecutorConfigurationRepresentation; +import org.keycloak.services.clientpolicy.ClientPolicyContext; +import org.keycloak.services.clientpolicy.ClientPolicyException; +import org.keycloak.services.clientpolicy.executor.ClientPolicyExecutorProvider; + +/** + * @author Takashi Norimatsu + */ +public class SecureCibaSessionEnforceExecutor implements ClientPolicyExecutorProvider { + + private static final Logger logger = Logger.getLogger(SecureCibaSessionEnforceExecutor.class); + + private final KeycloakSession session; + + public SecureCibaSessionEnforceExecutor(KeycloakSession session) { + this.session = session; + } + + @Override + public String getProviderId() { + return SecureCibaSessionEnforceExecutorFactory.PROVIDER_ID; + } + + @Override + public void executeOnEvent(ClientPolicyContext context) throws ClientPolicyException { + switch (context.getEvent()) { + case BACKCHANNEL_AUTHENTICATION_REQUEST: + BackchannelAuthenticationRequestContext backchannelAuthenticationRequestContext = (BackchannelAuthenticationRequestContext)context; + executeOnBackchannelAuthenticationRequest(backchannelAuthenticationRequestContext.getRequest(), + backchannelAuthenticationRequestContext.getRequestParameters()); + return; + default: + return; + } + } + + private void executeOnBackchannelAuthenticationRequest( + BackchannelAuthenticationEndpointRequest request, + MultivaluedMap requestParameters) throws ClientPolicyException { + logger.trace("Backchannel Authentication Endpoint - authn request"); + if (request.getBindingMessage() == null) { + logger.trace("Missing parameter: binding_message"); + throw new ClientPolicyException(OAuthErrorException.INVALID_REQUEST, "Missing parameter: binding_message"); + } + logger.trace("Passed."); + } +} diff --git a/services/src/main/java/org/keycloak/protocol/oidc/grants/ciba/clientpolicy/executor/SecureCibaSessionEnforceExecutorFactory.java b/services/src/main/java/org/keycloak/protocol/oidc/grants/ciba/clientpolicy/executor/SecureCibaSessionEnforceExecutorFactory.java new file mode 100644 index 0000000000..f4923df48f --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/oidc/grants/ciba/clientpolicy/executor/SecureCibaSessionEnforceExecutorFactory.java @@ -0,0 +1,69 @@ +/* + * Copyright 2021 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.grants.ciba.clientpolicy.executor; + +import java.util.Collections; +import java.util.List; + +import org.keycloak.Config.Scope; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.provider.ProviderConfigProperty; +import org.keycloak.services.clientpolicy.executor.ClientPolicyExecutorProvider; +import org.keycloak.services.clientpolicy.executor.ClientPolicyExecutorProviderFactory; + +/** + * @author Takashi Norimatsu + */ +public class SecureCibaSessionEnforceExecutorFactory implements ClientPolicyExecutorProviderFactory { + + public static final String PROVIDER_ID = "secure-ciba-session"; + + @Override + public ClientPolicyExecutorProvider create(KeycloakSession session) { + return new SecureCibaSessionEnforceExecutor(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 "To distinguish which authentication belongs to which CIBA flow, it refuses backchannel authentication request which lacks 'binding_message' parameter."; + } + + @Override + public List getConfigProperties() { + return Collections.emptyList(); + } + +} diff --git a/services/src/main/java/org/keycloak/protocol/oidc/grants/ciba/clientpolicy/executor/SecureCibaSignedAuthenticationRequestExecutor.java b/services/src/main/java/org/keycloak/protocol/oidc/grants/ciba/clientpolicy/executor/SecureCibaSignedAuthenticationRequestExecutor.java new file mode 100644 index 0000000000..0695e89b4e --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/oidc/grants/ciba/clientpolicy/executor/SecureCibaSignedAuthenticationRequestExecutor.java @@ -0,0 +1,169 @@ +/* + * Copyright 2021 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.grants.ciba.clientpolicy.executor; + +import java.util.Optional; + +import javax.ws.rs.core.MultivaluedMap; + +import org.jboss.logging.Logger; +import org.keycloak.OAuthErrorException; +import org.keycloak.common.util.Time; +import org.keycloak.models.KeycloakSession; +import org.keycloak.protocol.oidc.OIDCLoginProtocol; +import org.keycloak.protocol.oidc.grants.ciba.clientpolicy.context.BackchannelAuthenticationRequestContext; +import org.keycloak.protocol.oidc.grants.ciba.endpoints.request.BackchannelAuthenticationEndpointRequest; +import org.keycloak.protocol.oidc.grants.ciba.endpoints.request.BackchannelAuthenticationEndpointRequestParser; +import org.keycloak.representations.idm.ClientPolicyExecutorConfigurationRepresentation; +import org.keycloak.services.clientpolicy.ClientPolicyContext; +import org.keycloak.services.clientpolicy.ClientPolicyException; +import org.keycloak.services.clientpolicy.executor.ClientPolicyExecutorProvider; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.JsonNode; + +/** + * @author Takashi Norimatsu + */ +public class SecureCibaSignedAuthenticationRequestExecutor implements ClientPolicyExecutorProvider { + + private static final Logger logger = Logger.getLogger(SecureCibaSignedAuthenticationRequestExecutor.class); + + public static final String INVALID_REQUEST_OBJECT = "invalid_request_object"; + public static final Integer DEFAULT_AVAILABLE_PERIOD = Integer.valueOf(3600); // (sec) from FAPI-CIBA requirement + + private final KeycloakSession session; + private Configuration configuration; + + public SecureCibaSignedAuthenticationRequestExecutor(KeycloakSession session) { + this.session = session; + } + + @Override + public void setupConfiguration(SecureCibaSignedAuthenticationRequestExecutor.Configuration config) { + if (config == null) { + configuration = new Configuration(); + configuration.setAvailablePeriod(DEFAULT_AVAILABLE_PERIOD); + } else { + configuration = config; + if (config.getAvailablePeriod() == null) { + configuration.setAvailablePeriod(DEFAULT_AVAILABLE_PERIOD); + } + } + } + + @Override + public Class getExecutorConfigurationClass() { + return Configuration.class; + } + + public static class Configuration extends ClientPolicyExecutorConfigurationRepresentation { + @JsonProperty("available-period") + protected Integer availablePeriod; + + public Integer getAvailablePeriod() { + return availablePeriod; + } + + public void setAvailablePeriod(Integer availablePeriod) { + this.availablePeriod = availablePeriod; + } + + } + + @Override + public String getProviderId() { + return SecureCibaSignedAuthenticationRequestExecutorFactory.PROVIDER_ID; + } + + @Override + public void executeOnEvent(ClientPolicyContext context) throws ClientPolicyException { + switch (context.getEvent()) { + case BACKCHANNEL_AUTHENTICATION_REQUEST: + BackchannelAuthenticationRequestContext backchannelAuthenticationRequestContext = (BackchannelAuthenticationRequestContext)context; + executeOnBackchannelAuthenticationRequest(backchannelAuthenticationRequestContext.getRequest(), + backchannelAuthenticationRequestContext.getRequestParameters()); + return; + default: + return; + } + } + + private void executeOnBackchannelAuthenticationRequest( + BackchannelAuthenticationEndpointRequest request, + MultivaluedMap params) throws ClientPolicyException { + logger.trace("Backchannel Authentication Endpoint - authn request"); + + if (params == null) { + logger.trace("request parameter not exist."); + throw new ClientPolicyException(OAuthErrorException.INVALID_REQUEST, "Missing parameters"); + } + + String requestParam = params.getFirst(OIDCLoginProtocol.REQUEST_PARAM); + String requestUriParam = params.getFirst(OIDCLoginProtocol.REQUEST_URI_PARAM); + + if (requestParam == null && requestUriParam == null) { + logger.trace("signed authentication request not exist."); + throw new ClientPolicyException(OAuthErrorException.INVALID_REQUEST, "Missing parameter: 'request' or 'request_uri'"); + } + + JsonNode signedAuthReq = (JsonNode)session.getAttribute(BackchannelAuthenticationEndpointRequestParser.CIBA_SIGNED_AUTHENTICATION_REQUEST); + + // check whether signed authentication request exists + if (signedAuthReq == null || signedAuthReq.isEmpty()) { + logger.trace("signed authentication request not exist."); + throw new ClientPolicyException(OAuthErrorException.INVALID_REQUEST, "Invalid parameter: : 'request' or 'request_uri'"); + } + + // check whether "exp" claim exists + if (signedAuthReq.get("exp") == null) { + logger.trace("exp claim not incuded."); + throw new ClientPolicyException(OAuthErrorException.INVALID_REQUEST, "Missing parameter in the signed authentication request: exp"); + } + + // check whether signed authentication request not expired + long exp = signedAuthReq.get("exp").asLong(); + if (Time.currentTime() > exp) { // TODO: Time.currentTime() is int while exp is long... + logger.trace("request object expired."); + throw new ClientPolicyException(OAuthErrorException.INVALID_REQUEST, "Request Expired"); + } + + // check whether "nbf" claim exists + if (signedAuthReq.get("nbf") == null) { + logger.trace("nbf claim not incuded."); + throw new ClientPolicyException(OAuthErrorException.INVALID_REQUEST, "Missing parameter in the signed authentication request: nbf"); + } + + // check whether signed authentication request not yet being processed + long nbf = signedAuthReq.get("nbf").asLong(); + if (Time.currentTime() < nbf) { // TODO: Time.currentTime() is int while nbf is long... + logger.trace("request object not yet being processed."); + throw new ClientPolicyException(OAuthErrorException.INVALID_REQUEST, "Request not yet being processed"); + } + + // check whether signed authentication request's available period is short + int availablePeriod = Optional.ofNullable(configuration.getAvailablePeriod()).orElse(DEFAULT_AVAILABLE_PERIOD).intValue(); + if (exp - nbf > availablePeriod) { + logger.trace("signed authentication request's available period is long."); + throw new ClientPolicyException(OAuthErrorException.INVALID_REQUEST, "signed authentication request's available period is long"); + } + + logger.trace("Passed."); + } + +} diff --git a/services/src/main/java/org/keycloak/protocol/oidc/grants/ciba/clientpolicy/executor/SecureCibaSignedAuthenticationRequestExecutorFactory.java b/services/src/main/java/org/keycloak/protocol/oidc/grants/ciba/clientpolicy/executor/SecureCibaSignedAuthenticationRequestExecutorFactory.java new file mode 100644 index 0000000000..561902f19a --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/oidc/grants/ciba/clientpolicy/executor/SecureCibaSignedAuthenticationRequestExecutorFactory.java @@ -0,0 +1,76 @@ +/* + * Copyright 2021 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.grants.ciba.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; +import org.keycloak.services.clientpolicy.executor.ClientPolicyExecutorProvider; +import org.keycloak.services.clientpolicy.executor.ClientPolicyExecutorProviderFactory; + +/** + * @author Takashi Norimatsu + */ +public class SecureCibaSignedAuthenticationRequestExecutorFactory implements ClientPolicyExecutorProviderFactory { + + public static final String PROVIDER_ID = "secure-ciba-signed-authn-req"; + + public static final String AVAILABLE_PERIOD = "available-period"; + + private static final ProviderConfigProperty AVAILABLE_PERIOD_PROPERTY = new ProviderConfigProperty( + AVAILABLE_PERIOD, "Available Period", "The maximum period in seconds for which the 'request' signed authentication request used in CIBA backchannel authentication request is considered valid.", + ProviderConfigProperty.STRING_TYPE, "3600"); + + @Override + public ClientPolicyExecutorProvider create(KeycloakSession session) { + return new SecureCibaSignedAuthenticationRequestExecutor(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 whether the client treats the signed authentication request in its CIBA backchannel authentication request by following Financial-grade API CIBA Security Profile."; + } + + @Override + public List getConfigProperties() { + return new ArrayList<>(Arrays.asList(AVAILABLE_PERIOD_PROPERTY)); + } + +} diff --git a/services/src/main/java/org/keycloak/protocol/oidc/grants/ciba/endpoints/BackchannelAuthenticationEndpoint.java b/services/src/main/java/org/keycloak/protocol/oidc/grants/ciba/endpoints/BackchannelAuthenticationEndpoint.java index b51a403f34..a3d0996213 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc/grants/ciba/endpoints/BackchannelAuthenticationEndpoint.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/grants/ciba/endpoints/BackchannelAuthenticationEndpoint.java @@ -34,12 +34,12 @@ import org.keycloak.models.UserModel; import org.keycloak.protocol.oidc.grants.ciba.CibaGrantType; import org.keycloak.protocol.oidc.grants.ciba.channel.AuthenticationChannelProvider; import org.keycloak.protocol.oidc.grants.ciba.channel.CIBAAuthenticationRequest; +import org.keycloak.protocol.oidc.grants.ciba.clientpolicy.context.BackchannelAuthenticationRequestContext; import org.keycloak.protocol.oidc.grants.ciba.endpoints.request.BackchannelAuthenticationEndpointRequest; import org.keycloak.protocol.oidc.grants.ciba.endpoints.request.BackchannelAuthenticationEndpointRequestParserProcessor; import org.keycloak.protocol.oidc.grants.ciba.resolvers.CIBALoginUserResolver; import org.keycloak.services.ErrorResponseException; import org.keycloak.services.clientpolicy.ClientPolicyException; -import org.keycloak.services.clientpolicy.context.BackchannelAuthenticationRequestContext; import org.keycloak.util.JsonSerialization; import javax.ws.rs.Consumes; 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 f05cd92015..d6a3ace246 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 @@ -9,4 +9,6 @@ org.keycloak.services.clientpolicy.executor.SecureSigningAlgorithmForSignedJwtEx org.keycloak.services.clientpolicy.executor.HolderOfKeyEnforcerExecutorFactory org.keycloak.services.clientpolicy.executor.ConfidentialClientAcceptExecutorFactory org.keycloak.services.clientpolicy.executor.ConsentRequiredExecutorFactory -org.keycloak.services.clientpolicy.executor.FullScopeDisabledExecutorFactory \ No newline at end of file +org.keycloak.services.clientpolicy.executor.FullScopeDisabledExecutorFactory +org.keycloak.protocol.oidc.grants.ciba.clientpolicy.executor.SecureCibaSessionEnforceExecutorFactory +org.keycloak.protocol.oidc.grants.ciba.clientpolicy.executor.SecureCibaSignedAuthenticationRequestExecutorFactory \ 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/resource/TestingOIDCEndpointsApplicationResource.java b/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/rest/resource/TestingOIDCEndpointsApplicationResource.java index 82ce2d4a24..92bd82d70b 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 @@ -662,9 +662,13 @@ public class TestingOIDCEndpointsApplicationResource { // optional // for testing purpose - if (request.getBindingMessage() != null && request.getBindingMessage().equals("GODOWN")) throw new BadRequestException("intentional error : GODOWN"); + String bindingMessage = request.getBindingMessage(); + if (bindingMessage != null && bindingMessage.equals("GODOWN")) throw new BadRequestException("intentional error : GODOWN"); - authenticationChannelRequests.put(request.getBindingMessage(), new TestAuthenticationChannelRequest(request, rawBearerToken)); + // binding_message is optional so that it can be null . + // only one CIBA flow without binding_message can be accepted per test method by this test mechanism. + if (bindingMessage == null) bindingMessage = ChannelRequestDummyKey; + authenticationChannelRequests.put(bindingMessage, new TestAuthenticationChannelRequest(request, rawBearerToken)); return Response.status(Status.CREATED).build(); } @@ -674,6 +678,9 @@ public class TestingOIDCEndpointsApplicationResource { @Produces(MediaType.APPLICATION_JSON) @NoCache public TestAuthenticationChannelRequest getAuthenticationChannel(@QueryParam("bindingMessage") String bindingMessage) { + if (bindingMessage == null) bindingMessage = ChannelRequestDummyKey; return authenticationChannelRequests.get(bindingMessage); } + + private static final String ChannelRequestDummyKey = "channel_request_dummy_key"; } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/client/CIBATest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/client/CIBATest.java index 04f91f0b4e..922420aa6a 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/client/CIBATest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/client/CIBATest.java @@ -32,8 +32,10 @@ import static org.keycloak.protocol.oidc.grants.ciba.channel.AuthenticationChann import static org.keycloak.protocol.oidc.grants.ciba.channel.AuthenticationChannelResponse.Status.SUCCEED; import static org.keycloak.protocol.oidc.grants.ciba.channel.AuthenticationChannelResponse.Status.UNAUTHORIZED; import static org.keycloak.testsuite.Assert.assertExpiration; +import static org.keycloak.testsuite.admin.AbstractAdminTest.loadJson; import static org.keycloak.testsuite.arquillian.annotation.AuthServerContainerExclude.AuthServer.QUARKUS; import static org.keycloak.testsuite.arquillian.annotation.AuthServerContainerExclude.AuthServer.REMOTE; +import static org.keycloak.testsuite.util.ClientPoliciesUtil.createAnyClientConditionConfig; import java.io.IOException; import java.net.URI; @@ -44,20 +46,16 @@ import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Optional; -import java.util.function.Consumer; import org.hamcrest.CoreMatchers; -import org.junit.After; import org.junit.Assert; -import org.junit.Before; import org.junit.Ignore; import org.junit.Rule; import org.junit.Test; import org.keycloak.OAuth2Constants; import org.keycloak.OAuthErrorException; import org.keycloak.admin.client.resource.ClientResource; -import org.keycloak.client.registration.Auth; -import org.keycloak.client.registration.ClientRegistration; +import org.keycloak.admin.client.resource.RealmResource; import org.keycloak.client.registration.ClientRegistrationException; import org.keycloak.common.Profile; import org.keycloak.common.util.Base64Url; @@ -71,12 +69,13 @@ import org.keycloak.protocol.oidc.OIDCAdvancedConfigWrapper; import org.keycloak.protocol.oidc.grants.ciba.CibaGrantType; import org.keycloak.protocol.oidc.grants.ciba.channel.AuthenticationChannelRequest; import org.keycloak.protocol.oidc.grants.ciba.channel.AuthenticationChannelResponse; +import org.keycloak.protocol.oidc.grants.ciba.clientpolicy.executor.SecureCibaSessionEnforceExecutorFactory; +import org.keycloak.protocol.oidc.grants.ciba.clientpolicy.executor.SecureCibaSignedAuthenticationRequestExecutor; +import org.keycloak.protocol.oidc.grants.ciba.clientpolicy.executor.SecureCibaSignedAuthenticationRequestExecutorFactory; import org.keycloak.representations.AccessToken; import org.keycloak.representations.IDToken; import org.keycloak.representations.JsonWebToken; import org.keycloak.representations.RefreshToken; -import org.keycloak.representations.idm.ClientInitialAccessCreatePresentation; -import org.keycloak.representations.idm.ClientInitialAccessPresentation; import org.keycloak.representations.idm.ClientRepresentation; import org.keycloak.representations.idm.EventRepresentation; import org.keycloak.representations.idm.RealmRepresentation; @@ -84,7 +83,7 @@ import org.keycloak.representations.idm.UserRepresentation; import org.keycloak.representations.oidc.OIDCClientRepresentation; import org.keycloak.representations.oidc.TokenMetadataRepresentation; import org.keycloak.services.Urls; -import org.keycloak.testsuite.AbstractTestRealmKeycloakTest; +import org.keycloak.services.clientpolicy.condition.AnyClientConditionFactory; import org.keycloak.testsuite.AssertEvents; import org.keycloak.testsuite.admin.ApiUtil; import org.keycloak.testsuite.arquillian.annotation.AuthServerContainerExclude; @@ -97,8 +96,11 @@ import org.keycloak.testsuite.util.InfinispanTestTimeServiceRule; import org.keycloak.testsuite.util.KeycloakModelUtils; import org.keycloak.testsuite.util.Matchers; import org.keycloak.testsuite.util.OAuthClient; -import org.keycloak.testsuite.util.ServerURLs; import org.keycloak.testsuite.util.UserBuilder; +import org.keycloak.testsuite.util.ClientPoliciesUtil.ClientPoliciesBuilder; +import org.keycloak.testsuite.util.ClientPoliciesUtil.ClientPolicyBuilder; +import org.keycloak.testsuite.util.ClientPoliciesUtil.ClientProfileBuilder; +import org.keycloak.testsuite.util.ClientPoliciesUtil.ClientProfilesBuilder; import org.keycloak.testsuite.util.OAuthClient.AuthenticationRequestAcknowledgement; import org.keycloak.util.JsonSerialization; @@ -110,14 +112,12 @@ import com.fasterxml.jackson.databind.ObjectMapper; */ @EnableFeature(value = Profile.Feature.CIBA, skipRestart = true) @AuthServerContainerExclude({REMOTE, QUARKUS}) -public class CIBATest extends AbstractTestRealmKeycloakTest { +public class CIBATest extends AbstractClientPoliciesTest { private final String SECOND_TEST_CLIENT_NAME = "test-second-client"; private final String SECOND_TEST_CLIENT_SECRET = "passwort-test-second-client"; private static final String ERR_MSG_CLIENT_REG_FAIL = "Failed to send request"; - private ClientRegistration reg; - @Rule public AssertEvents events = new AssertEvents(this); @@ -125,7 +125,8 @@ public class CIBATest extends AbstractTestRealmKeycloakTest { public InfinispanTestTimeServiceRule ispnTestTimeService = new InfinispanTestTimeServiceRule(this); @Override - public void configureTestRealm(RealmRepresentation testRealm) { + public void addTestRealms(List testRealms) { + RealmRepresentation realm = loadJson(getClass().getResourceAsStream("/testrealm.json"), RealmRepresentation.class); UserRepresentation user = UserBuilder.create() .username("nutzername-schwarz") @@ -134,7 +135,7 @@ public class CIBATest extends AbstractTestRealmKeycloakTest { .password("passwort-schwarz") .addRoles("user", "offline_access") .build(); - testRealm.getUsers().add(user); + realm.getUsers().add(user); user = UserBuilder.create() .username("nutzername-rot") @@ -143,7 +144,7 @@ public class CIBATest extends AbstractTestRealmKeycloakTest { .password("passwort-rot") .addRoles("user", "offline_access") .build(); - testRealm.getUsers().add(user); + realm.getUsers().add(user); user = UserBuilder.create() .username("nutzername-gelb") @@ -152,7 +153,7 @@ public class CIBATest extends AbstractTestRealmKeycloakTest { .password("passwort-gelb") .addRoles("user", "offline_access") .build(); - testRealm.getUsers().add(user); + realm.getUsers().add(user); user = UserBuilder.create() .username("nutzername-deaktiviert") @@ -161,24 +162,13 @@ public class CIBATest extends AbstractTestRealmKeycloakTest { .password("passwort-deaktiviert") .addRoles("user", "offline_access") .build(); - testRealm.getUsers().add(user); + realm.getUsers().add(user); - ClientRepresentation confApp = KeycloakModelUtils.createClient(testRealm, SECOND_TEST_CLIENT_NAME); + ClientRepresentation confApp = KeycloakModelUtils.createClient(realm, SECOND_TEST_CLIENT_NAME); confApp.setSecret(SECOND_TEST_CLIENT_SECRET); confApp.setServiceAccountsEnabled(Boolean.TRUE); - } - @Before - public void before() throws Exception { - // get initial access token for Dynamic Client Registration with authentication - reg = ClientRegistration.create().url(suiteContext.getAuthServerInfo().getContextRoot() + "/auth", TEST_REALM_NAME).build(); - ClientInitialAccessPresentation token = adminClient.realm(TEST_REALM_NAME).clientInitialAccess().create(new ClientInitialAccessCreatePresentation(0, 10)); - reg.auth(Auth.token(token)); - } - - @After - public void after() throws Exception { - reg.close(); + testRealms.add(realm); } private String cibaBackchannelTokenDeliveryMode; @@ -682,6 +672,16 @@ public class CIBATest extends AbstractTestRealmKeycloakTest { testBackchannelAuthenticationFlow(true); } + @Test + public void testBackchannelAuthenticationFlowWithoutBindingMessage() throws Exception { + testBackchannelAuthenticationFlow(false, null); + } + + @Test + public void testBackchannelAuthenticationFlowOfflineAccessWithoutBindingMessage() throws Exception { + testBackchannelAuthenticationFlow(true, null); + } + @Test public void testMultipleUsersBackchannelAuthenticationFlows() throws Exception { ClientResource clientResource = null; @@ -1132,13 +1132,10 @@ public class CIBATest extends AbstractTestRealmKeycloakTest { @Test public void testCibaGrantSettingByDynamicClientRegistration() throws Exception { - String clientId = createClientDynamically("valid-CIBA-CD", (OIDCClientRepresentation clientRep) -> { - }); - + String clientId = createClientDynamically(generateSuffixedName("valid-CIBA-CD"), (OIDCClientRepresentation clientRep) -> {}); OIDCClientRepresentation rep = getClientDynamically(clientId); Assert.assertFalse(rep.getGrantTypes().contains(OAuth2Constants.CIBA_GRANT_TYPE)); Assert.assertNull(rep.getBackchannelAuthenticationRequestSigningAlg()); - updateClientDynamically(clientId, (OIDCClientRepresentation clientRep) -> { List grantTypes = Optional.ofNullable(clientRep.getGrantTypes()).orElse(new ArrayList<>()); grantTypes.add(OAuth2Constants.CIBA_GRANT_TYPE); @@ -1171,6 +1168,215 @@ public class CIBATest extends AbstractTestRealmKeycloakTest { testBackchannelAuthenticationFlowWithInvalidSignedAuthenticationRequest(true, "none", 400, "None signed algorithm is not allowed"); } + @Test + public void testSecureCibaSessionEnforceExecutor() throws Exception { + String clientId = createClientDynamically(generateSuffixedName("valid-CIBA-CD"), (OIDCClientRepresentation clientRep) -> { + List grantTypes = Optional.ofNullable(clientRep.getGrantTypes()).orElse(new ArrayList<>()); + grantTypes.add(OAuth2Constants.CIBA_GRANT_TYPE); + clientRep.setGrantTypes(grantTypes); + }); + OIDCClientRepresentation rep = getClientDynamically(clientId); + String clientSecret = rep.getClientSecret(); + + String username = "nutzername-rot"; + Map additionalParameters = new HashMap<>(); + additionalParameters.put("user_device", "mobile"); + + // register profiles + String json = (new ClientProfilesBuilder()).addProfile( + (new ClientProfileBuilder()).createProfile(PROFILE_NAME, "Le Premier Profil") + .addExecutor(SecureCibaSessionEnforceExecutorFactory.PROVIDER_ID, null) + .toRepresentation() + ).toString(); + updateProfiles(json); + + // register policies + json = (new ClientPoliciesBuilder()).addPolicy( + (new ClientPolicyBuilder()).createPolicy(POLICY_NAME, "La Premiere Politique", Boolean.TRUE) + .addCondition(AnyClientConditionFactory.PROVIDER_ID, + createAnyClientConditionConfig()) + .addProfile(PROFILE_NAME) + .toRepresentation() + ).toString(); + updatePolicies(json); + + // user Backchannel Authentication Request + AuthenticationRequestAcknowledgement response = oauth.doBackchannelAuthenticationRequest(clientId, clientSecret, username, null, null, additionalParameters); + assertThat(response.getStatusCode(), is(equalTo(400))); + assertThat(response.getError(), is(OAuthErrorException.INVALID_REQUEST)); + assertThat(response.getErrorDescription(), is("Missing parameter: binding_message")); + } + + @Test + public void testSecureCibaSessionEnforceExecutorWithSignedAuthenticationRequestParam() throws Exception { + testSecureCibaSessionEnforceExecutor(false); + } + + @Test + public void testSecureCibaSessionEnforceExecutorWithSignedAuthenticationRequestUriParam() throws Exception { + testSecureCibaSessionEnforceExecutor(true); + } + + @Test + public void testBackchannelAuthenticationFlowWithInvalidSignedAuthenticationRequest() throws Exception { + ClientResource clientResource = null; + ClientRepresentation clientRep = null; + try { + boolean useRequestUri = false; + String sigAlg = Algorithm.PS256; + final String username = "nutzername-rot"; + String bindingMessage = "Flughafen-Frankfurt-am-Main"; + + // prepare CIBA settings + clientResource = ApiUtil.findClientByClientId(adminClient.realm(TEST_REALM_NAME), TEST_CLIENT_NAME); + clientRep = clientResource.toRepresentation(); + prepareCIBASettings(clientResource, clientRep); + + // register profiles + String json = (new ClientProfilesBuilder()).addProfile( + (new ClientProfileBuilder()).createProfile(PROFILE_NAME, "Le Premier Profil") + .addExecutor(SecureCibaSignedAuthenticationRequestExecutorFactory.PROVIDER_ID, null) + .toRepresentation() + ).toString(); + updateProfiles(json); + + // register policies + json = (new ClientPoliciesBuilder()).addPolicy( + (new ClientPolicyBuilder()).createPolicy(POLICY_NAME, "La Premiere Politique", Boolean.TRUE) + .addCondition(AnyClientConditionFactory.PROVIDER_ID, + createAnyClientConditionConfig()) + .addProfile(PROFILE_NAME) + .toRepresentation() + ).toString(); + updatePolicies(json); + + AuthorizationEndpointRequestObject requestObject = createPartialAuthorizationEndpointRequestObject(username, bindingMessage); + requestObject.nbf(requestObject.getIat()); + + registerSharedAuthenticationRequest(requestObject, TEST_CLIENT_NAME, sigAlg, useRequestUri); + + // user Backchannel Authentication Request + AuthenticationRequestAcknowledgement response = oauth.doBackchannelAuthenticationRequest(TEST_CLIENT_NAME, TEST_CLIENT_PASSWORD, username, bindingMessage, null, null); + assertThat(response.getStatusCode(), is(equalTo(400))); + assertThat(response.getError(), is(OAuthErrorException.INVALID_REQUEST)); + assertThat(response.getErrorDescription(), is("Missing parameter in the signed authentication request: exp")); + + useRequestUri = true; + bindingMessage = "Flughafen-Wien-Schwechat"; + requestObject = createPartialAuthorizationEndpointRequestObject(username, bindingMessage); + requestObject.exp(requestObject.getIat() + Long.valueOf(300)); + + registerSharedAuthenticationRequest(requestObject, TEST_CLIENT_NAME, sigAlg, useRequestUri); + + // user Backchannel Authentication Request + response = oauth.doBackchannelAuthenticationRequest(TEST_CLIENT_NAME, TEST_CLIENT_PASSWORD, username, bindingMessage, null, null); + assertThat(response.getStatusCode(), is(equalTo(400))); + assertThat(response.getError(), is(OAuthErrorException.INVALID_REQUEST)); + assertThat(response.getErrorDescription(), is("Missing parameter in the signed authentication request: nbf")); + + useRequestUri = false; + bindingMessage = "Stuttgart-Hauptbahnhof"; + requestObject = createPartialAuthorizationEndpointRequestObject(username, bindingMessage); + requestObject.exp(requestObject.getIat() + SecureCibaSignedAuthenticationRequestExecutor.DEFAULT_AVAILABLE_PERIOD + 10); + requestObject.nbf(requestObject.getIat()); + + registerSharedAuthenticationRequest(requestObject, TEST_CLIENT_NAME, sigAlg, useRequestUri); + + // user Backchannel Authentication Request + response = oauth.doBackchannelAuthenticationRequest(TEST_CLIENT_NAME, TEST_CLIENT_PASSWORD, username, bindingMessage, null, null); + assertThat(response.getStatusCode(), is(equalTo(400))); + assertThat(response.getError(), is(OAuthErrorException.INVALID_REQUEST)); + assertThat(response.getErrorDescription(), is("signed authentication request's available period is long")); + + useRequestUri = true; + bindingMessage = "Brno-hlavni-nadrazif"; + requestObject = createPartialAuthorizationEndpointRequestObject(username, bindingMessage); + requestObject.exp(requestObject.getIat() + Long.valueOf(300)); + requestObject.nbf(requestObject.getIat()); + + registerSharedAuthenticationRequest(requestObject, TEST_CLIENT_NAME, sigAlg, useRequestUri); + + // user Backchannel Authentication Request + response = doBackchannelAuthenticationRequest(TEST_CLIENT_NAME, TEST_CLIENT_PASSWORD, username, bindingMessage); + + // user Authentication Channel Request + TestAuthenticationChannelRequest testRequest = doAuthenticationChannelRequest(bindingMessage); + AuthenticationChannelRequest authenticationChannelReq = testRequest.getRequest(); + assertThat(authenticationChannelReq.getBindingMessage(), is(equalTo(bindingMessage))); + assertThat(authenticationChannelReq.getScope(), is(containsString(OAuth2Constants.SCOPE_OPENID))); + + // user Authentication Channel completed + doAuthenticationChannelCallback(testRequest); + + // user Token Request + doBackchannelAuthenticationTokenRequest(username, response.getAuthReqId()); + } finally { + revertCIBASettings(clientResource, clientRep); + } + } + + private AuthorizationEndpointRequestObject createPartialAuthorizationEndpointRequestObject(String username, String bindingMessage) throws Exception { + AuthorizationEndpointRequestObject requestObject = new AuthorizationEndpointRequestObject(); + requestObject.id(org.keycloak.models.utils.KeycloakModelUtils.generateId()); + requestObject.iat(Long.valueOf(Time.currentTime())); + requestObject.setScope("openid"); + requestObject.setMax_age(Integer.valueOf(600)); + requestObject.setOtherClaims("custom_claim_zwei", "gelb"); + requestObject.audience(Urls.realmIssuer(new URI(suiteContext.getAuthServerInfo().getContextRoot().toString() + "/auth"), TEST_REALM_NAME), "https://example.com"); + requestObject.setLoginHint(username); + requestObject.setBindingMessage(bindingMessage); + return requestObject; + } + + private void testSecureCibaSessionEnforceExecutor(boolean useRequestUri) throws Exception { + ClientResource clientResource = null; + ClientRepresentation clientRep = null; + try { + String sigAlg = Algorithm.PS256; + final String username = "nutzername-rot"; + + // prepare CIBA settings + clientResource = ApiUtil.findClientByClientId(adminClient.realm(TEST_REALM_NAME), TEST_CLIENT_NAME); + clientRep = clientResource.toRepresentation(); + prepareCIBASettings(clientResource, clientRep); + + AuthorizationEndpointRequestObject sharedAuthenticationRequest = createValidSharedAuthenticationRequest(); + sharedAuthenticationRequest.setLoginHint(username); + registerSharedAuthenticationRequest(sharedAuthenticationRequest, TEST_CLIENT_NAME, sigAlg, useRequestUri); + + // register profiles + String json = (new ClientProfilesBuilder()).addProfile( + (new ClientProfileBuilder()).createProfile(PROFILE_NAME, "Le Premier Profil") + .addExecutor(SecureCibaSessionEnforceExecutorFactory.PROVIDER_ID, null) + .toRepresentation() + ).toString(); + updateProfiles(json); + + // register policies + json = (new ClientPoliciesBuilder()).addPolicy( + (new ClientPolicyBuilder()).createPolicy(POLICY_NAME, "La Premiere Politique", Boolean.TRUE) + .addCondition(AnyClientConditionFactory.PROVIDER_ID, + createAnyClientConditionConfig()) + .addProfile(PROFILE_NAME) + .toRepresentation() + ).toString(); + updatePolicies(json); + + // user Backchannel Authentication Request + AuthenticationRequestAcknowledgement response = oauth.doBackchannelAuthenticationRequest(TEST_CLIENT_NAME, TEST_CLIENT_PASSWORD, username, null, null, null); + assertThat(response.getStatusCode(), is(equalTo(400))); + assertThat(response.getError(), is(OAuthErrorException.INVALID_REQUEST)); + assertThat(response.getErrorDescription(), is("Missing parameter: binding_message")); + + } finally { + revertCIBASettings(clientResource, clientRep); + } + } + + private RealmResource testRealm() { + return adminClient.realm(REALM_NAME); + } + @Test public void testBackchannelAuthenticationFlowRegisterDifferentSigAlgInAdvanceWithSignedAuthenticationRequestParam() throws Exception { testBackchannelAuthenticationFlowWithInvalidSignedAuthenticationRequest(false, Algorithm.ES256, Algorithm.PS256, 400, OAuthErrorException.INVALID_REQUEST, "Client requested algorithm not registered in advance or request signed with different algorithm other than client requested algorithm", TEST_CLIENT_NAME, TEST_CLIENT_PASSWORD); @@ -1383,31 +1589,6 @@ public class CIBATest extends AbstractTestRealmKeycloakTest { } } - private String createClientDynamically(String clientName, Consumer op) throws ClientRegistrationException { - OIDCClientRepresentation clientRep = new OIDCClientRepresentation(); - clientRep.setClientName(clientName); - clientRep.setClientUri(ServerURLs.getAuthServerContextRoot()); - clientRep.setRedirectUris(Collections.singletonList(ServerURLs.getAuthServerContextRoot() + "/auth/realms/master/app/auth")); - op.accept(clientRep); - OIDCClientRepresentation response = reg.oidc().create(clientRep); - reg.auth(Auth.token(response)); - // registered components will be removed automatically when a test method finishes regardless of its success or failure. - String clientId = response.getClientId(); - testContext.getOrCreateCleanup(TEST_REALM_NAME).addClientUuid(clientId); - return clientId; - } - - private OIDCClientRepresentation getClientDynamically(String clientId) throws ClientRegistrationException { - return reg.oidc().get(clientId); - } - - protected void updateClientDynamically(String clientId, Consumer op) throws ClientRegistrationException { - OIDCClientRepresentation clientRep = reg.oidc().get(clientId); - op.accept(clientRep); - OIDCClientRepresentation response = reg.oidc().update(clientRep); - reg.auth(Auth.token(response)); - } - private void testAuthenticationChannelErrorCase(Status statusCallback, Status statusTokenEndpont, AuthenticationChannelResponse.Status authStatus, String error, String errorEvent) throws Exception { ClientResource clientResource = null; ClientRepresentation clientRep = null; @@ -1650,11 +1831,14 @@ public class CIBATest extends AbstractTestRealmKeycloakTest { } private void testBackchannelAuthenticationFlow(boolean isOfflineAccess) throws Exception { + testBackchannelAuthenticationFlow(isOfflineAccess, "BASTION"); + } + + private void testBackchannelAuthenticationFlow(boolean isOfflineAccess, String bindingMessage) throws Exception { ClientResource clientResource = null; ClientRepresentation clientRep = null; try { final String username = "nutzername-rot"; - final String bindingMessage = "BASTION"; Map additionalParameters = new HashMap<>(); additionalParameters.put("user_device", "mobile");