diff --git a/services/src/main/resources/keycloak-default-client-profiles.json b/services/src/main/resources/keycloak-default-client-profiles.json
index eb131ff2bf..fc70a00f3c 100644
--- a/services/src/main/resources/keycloak-default-client-profiles.json
+++ b/services/src/main/resources/keycloak-default-client-profiles.json
@@ -110,6 +110,28 @@
}
}
]
+ },
+ {
+ "name" : "fapi-ciba",
+ "description" : "Client profile, which enforce clients to conform 'Financial-grade API: Client Initiated Backchannel Authentication Profile' specification (Implementer's Draft ver1'). To satisfy FAPI-CIBA, both this profile and fapi-1-advanced global profile need to be used.",
+ "executors" : [
+ {
+ "executor": "secure-ciba-req-sig-algorithm",
+ "configuration": {
+ "default-algorithm": "PS256"
+ }
+ },
+ {
+ "executor" : "secure-ciba-session",
+ "configuration" : {}
+ },
+ {
+ "executor" : "secure-ciba-signed-authn-req",
+ "configuration" : {
+ "available-period" : "3600"
+ }
+ }
+ ]
}
]
}
\ No newline at end of file
diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/client/AbstractClientPoliciesTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/client/AbstractClientPoliciesTest.java
index 23859731c7..f19beb9699 100644
--- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/client/AbstractClientPoliciesTest.java
+++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/client/AbstractClientPoliciesTest.java
@@ -176,6 +176,7 @@ public abstract class AbstractClientPoliciesTest extends AbstractKeycloakTest {
protected static final String FAPI1_BASELINE_PROFILE_NAME = "fapi-1-baseline";
protected static final String FAPI1_ADVANCED_PROFILE_NAME = "fapi-1-advanced";
+ protected static final String FAPI_CIBA_PROFILE_NAME = "fapi-ciba";
protected static final String ERR_MSG_MISSING_NONCE = "Missing parameter: nonce";
protected static final String ERR_MSG_MISSING_STATE = "Missing parameter: state";
@@ -291,7 +292,7 @@ public abstract class AbstractClientPoliciesTest extends AbstractKeycloakTest {
ClientProfilesRepresentation actualProfilesRep = getProfilesWithGlobals();
// same profiles
- assertExpectedProfiles(actualProfilesRep, Arrays.asList(FAPI1_BASELINE_PROFILE_NAME, FAPI1_ADVANCED_PROFILE_NAME), Arrays.asList("ordinal-test-profile", "lack-of-builtin-field-test-profile"));
+ assertExpectedProfiles(actualProfilesRep, Arrays.asList(FAPI1_BASELINE_PROFILE_NAME, FAPI1_ADVANCED_PROFILE_NAME, FAPI_CIBA_PROFILE_NAME), Arrays.asList("ordinal-test-profile", "lack-of-builtin-field-test-profile"));
// each profile - fapi-1-baseline
ClientProfileRepresentation actualProfileRep = getProfileRepresentation(actualProfilesRep, FAPI1_BASELINE_PROFILE_NAME, true);
diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/client/ClientPoliciesLoadUpdateTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/client/ClientPoliciesLoadUpdateTest.java
index 8b6b511d01..ed1cd62298 100644
--- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/client/ClientPoliciesLoadUpdateTest.java
+++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/client/ClientPoliciesLoadUpdateTest.java
@@ -85,7 +85,7 @@ public class ClientPoliciesLoadUpdateTest extends AbstractClientPoliciesTest {
ClientProfilesRepresentation actualProfilesRep = getProfilesWithGlobals();
// same profiles
- assertExpectedProfiles(actualProfilesRep, Arrays.asList(FAPI1_BASELINE_PROFILE_NAME, FAPI1_ADVANCED_PROFILE_NAME), Collections.emptyList());
+ assertExpectedProfiles(actualProfilesRep, Arrays.asList(FAPI1_BASELINE_PROFILE_NAME, FAPI1_ADVANCED_PROFILE_NAME, FAPI_CIBA_PROFILE_NAME), Collections.emptyList());
// each profile - fapi-1-baseline
ClientProfileRepresentation actualProfileRep = getProfileRepresentation(actualProfilesRep, FAPI1_BASELINE_PROFILE_NAME, true);
diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/client/FAPICIBATest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/client/FAPICIBATest.java
new file mode 100644
index 0000000000..0bc4617a8a
--- /dev/null
+++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/client/FAPICIBATest.java
@@ -0,0 +1,659 @@
+/*
+ * 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.testsuite.client;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.containsString;
+import static org.hamcrest.Matchers.equalTo;
+import static org.hamcrest.Matchers.is;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.fail;
+import static org.keycloak.protocol.oidc.OIDCLoginProtocol.LOGIN_HINT_PARAM;
+import static org.keycloak.protocol.oidc.grants.ciba.CibaGrantType.AUTH_REQ_ID;
+import static org.keycloak.protocol.oidc.grants.ciba.CibaGrantType.BINDING_MESSAGE;
+import static org.keycloak.protocol.oidc.grants.ciba.channel.AuthenticationChannelResponse.Status.SUCCEED;
+import static org.keycloak.protocol.oidc.grants.ciba.channel.AuthenticationChannelResponse.Status.CANCELLED;
+import static org.keycloak.testsuite.admin.AbstractAdminTest.loadJson;
+import static org.keycloak.testsuite.util.ClientPoliciesUtil.createAnyClientConditionConfig;
+
+import java.io.IOException;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.security.KeyPair;
+import java.security.PrivateKey;
+import java.security.PublicKey;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.function.Supplier;
+
+import org.apache.http.NameValuePair;
+import org.apache.http.client.entity.UrlEncodedFormEntity;
+import org.apache.http.client.methods.CloseableHttpResponse;
+import org.apache.http.client.methods.HttpPost;
+import org.apache.http.impl.client.CloseableHttpClient;
+import org.apache.http.message.BasicNameValuePair;
+import org.junit.Assume;
+import org.junit.BeforeClass;
+import org.junit.Test;
+import org.keycloak.OAuth2Constants;
+import org.keycloak.OAuthErrorException;
+import org.keycloak.admin.client.resource.ClientResource;
+import org.keycloak.admin.client.resource.UserResource;
+import org.keycloak.authentication.authenticators.client.ClientIdAndSecretAuthenticator;
+import org.keycloak.authentication.authenticators.client.JWTClientAuthenticator;
+import org.keycloak.authentication.authenticators.client.JWTClientSecretAuthenticator;
+import org.keycloak.authentication.authenticators.client.X509ClientAuthenticator;
+import org.keycloak.common.util.Base64Url;
+import org.keycloak.common.util.Time;
+import org.keycloak.crypto.Algorithm;
+import org.keycloak.models.AdminRoles;
+import org.keycloak.models.CibaConfig;
+import org.keycloak.models.Constants;
+import org.keycloak.protocol.oidc.OIDCAdvancedConfigWrapper;
+import org.keycloak.protocol.oidc.OIDCConfigAttributes;
+import org.keycloak.protocol.oidc.OIDCLoginProtocol;
+import org.keycloak.protocol.oidc.grants.ciba.channel.AuthenticationChannelRequest;
+import org.keycloak.representations.AccessToken;
+import org.keycloak.representations.IDToken;
+import org.keycloak.representations.RefreshToken;
+import org.keycloak.representations.idm.ClientRepresentation;
+import org.keycloak.representations.idm.CredentialRepresentation;
+import org.keycloak.representations.idm.EventRepresentation;
+import org.keycloak.representations.idm.RealmRepresentation;
+import org.keycloak.representations.idm.UserRepresentation;
+import org.keycloak.services.Urls;
+import org.keycloak.services.clientpolicy.ClientPolicyException;
+import org.keycloak.services.clientpolicy.condition.AnyClientConditionFactory;
+import org.keycloak.testsuite.Assert;
+import org.keycloak.testsuite.AssertEvents;
+import org.keycloak.testsuite.admin.ApiUtil;
+import org.keycloak.testsuite.arquillian.annotation.AuthServerContainerExclude;
+import org.keycloak.testsuite.client.resources.TestApplicationResourceUrls;
+import org.keycloak.testsuite.client.resources.TestOIDCEndpointsApplicationResource;
+import org.keycloak.testsuite.rest.representation.TestAuthenticationChannelRequest;
+import org.keycloak.testsuite.rest.resource.TestingOIDCEndpointsApplicationResource.AuthorizationEndpointRequestObject;
+import org.keycloak.testsuite.util.MutualTLSUtils;
+import org.keycloak.testsuite.util.OAuthClient;
+import org.keycloak.testsuite.util.ServerURLs;
+import org.keycloak.testsuite.util.ClientPoliciesUtil.ClientPoliciesBuilder;
+import org.keycloak.testsuite.util.ClientPoliciesUtil.ClientPolicyBuilder;
+import org.keycloak.testsuite.util.OAuthClient.AuthenticationRequestAcknowledgement;
+import org.keycloak.util.JsonSerialization;
+
+/**
+ * Test for the FAPI CIBA specifications (still implementer's draft):
+ * - Financial-grade API: Client Initiated Backchannel Authentication Profile - https://bitbucket.org/openid/fapi/src/master/Financial_API_WD_CIBA.md
+ *
+ * Mostly tests the global FAPI policies work as expected
+ * This class only tests FAPI CIBA related requirements. OIDC CIBA related requirements has been tested by CIBATest.
+ *
+ * @author Takashi Norimatsu
+ */
+@AuthServerContainerExclude(AuthServerContainerExclude.AuthServer.REMOTE)
+public class FAPICIBATest extends AbstractClientPoliciesTest {
+
+ private final String clientId = "foo";
+ private final String bindingMessage = "bbbbmmmm";
+ private final String username = "john";
+
+ @BeforeClass
+ public static void verifySSL() {
+ // FAPI requires SSL and does not makes sense to test it with disabled SSL
+ Assume.assumeTrue("The FAPI test requires SSL to be enabled.", ServerURLs.AUTH_SERVER_SSL_REQUIRED);
+ }
+
+ @Override
+ public void addTestRealms(List testRealms) {
+ RealmRepresentation realm = loadJson(getClass().getResourceAsStream("/testrealm.json"), RealmRepresentation.class);
+
+ List users = realm.getUsers();
+
+ LinkedList credentials = new LinkedList<>();
+ CredentialRepresentation password = new CredentialRepresentation();
+ password.setType(CredentialRepresentation.PASSWORD);
+ password.setValue("password");
+ credentials.add(password);
+
+ UserRepresentation user = new UserRepresentation();
+ user.setEnabled(true);
+ user.setUsername("john");
+ user.setEmail("john@keycloak.org");
+ user.setFirstName("Johny");
+ user.setCredentials(credentials);
+ user.setClientRoles(Collections.singletonMap(Constants.REALM_MANAGEMENT_CLIENT_ID, Arrays.asList(AdminRoles.CREATE_CLIENT, AdminRoles.MANAGE_CLIENTS)));
+ users.add(user);
+
+ realm.setUsers(users);
+
+ testRealms.add(realm);
+ }
+
+ @Test
+ public void testFAPIAdvancedClientRegistration() throws Exception {
+ setupPolicyFAPICIBAForAllClient();
+
+ // Register client with clientIdAndSecret - should fail
+ try {
+ createClientByAdmin("invalid", (ClientRepresentation clientRep) -> {
+ clientRep.setClientAuthenticatorType(ClientIdAndSecretAuthenticator.PROVIDER_ID);
+ });
+ fail();
+ } catch (ClientPolicyException e) {
+ assertEquals(OAuthErrorException.INVALID_CLIENT_METADATA, e.getMessage());
+ }
+
+ // Register client with signedJWT - should fail
+ try {
+ createClientByAdmin("invalid", (ClientRepresentation clientRep) -> {
+ clientRep.setClientAuthenticatorType(JWTClientSecretAuthenticator.PROVIDER_ID);
+ });
+ fail();
+ } catch (ClientPolicyException e) {
+ assertEquals(OAuthErrorException.INVALID_CLIENT_METADATA, e.getMessage());
+ }
+
+ // Register client with privateKeyJWT, but unsecured requestUri - should fail
+ try {
+ createClientByAdmin("invalid", (ClientRepresentation clientRep) -> {
+ clientRep.setClientAuthenticatorType(JWTClientAuthenticator.PROVIDER_ID);
+ OIDCAdvancedConfigWrapper.fromClientRepresentation(clientRep).setRequestUris(Collections.singletonList("http://foo"));
+ });
+ fail();
+ } catch (ClientPolicyException e) {
+ assertEquals(OAuthErrorException.INVALID_CLIENT_METADATA, e.getMessage());
+ }
+
+ // Try to register client with "client-jwt" - should pass
+ String clientUUID = createClientByAdmin("client-jwt", (ClientRepresentation clientRep) -> {
+ clientRep.setClientAuthenticatorType(JWTClientAuthenticator.PROVIDER_ID);
+ });
+ ClientRepresentation client = getClientByAdmin(clientUUID);
+ Assert.assertEquals(JWTClientAuthenticator.PROVIDER_ID, client.getClientAuthenticatorType());
+
+ // Try to register client with "client-x509" - should pass
+ clientUUID = createClientByAdmin("client-x509", (ClientRepresentation clientRep) -> {
+ clientRep.setClientAuthenticatorType(X509ClientAuthenticator.PROVIDER_ID);
+ });
+ client = getClientByAdmin(clientUUID);
+ Assert.assertEquals(X509ClientAuthenticator.PROVIDER_ID, client.getClientAuthenticatorType());
+
+ // Try to register client with default authenticator - should pass. Client authenticator should be "client-jwt"
+ clientUUID = createClientByAdmin("client-jwt-2", (ClientRepresentation clientRep) -> {
+ });
+ client = getClientByAdmin(clientUUID);
+ Assert.assertEquals(JWTClientAuthenticator.PROVIDER_ID, client.getClientAuthenticatorType());
+
+ // Check the Consent is enabled, Holder-of-key is enabled, fullScopeAllowed disabled and default signature algorithm.
+ Assert.assertTrue(client.isConsentRequired());
+ OIDCAdvancedConfigWrapper clientConfig = OIDCAdvancedConfigWrapper.fromClientRepresentation(client);
+ Assert.assertTrue(clientConfig.isUseMtlsHokToken());
+ Assert.assertEquals(Algorithm.PS256, clientConfig.getIdTokenSignedResponseAlg());
+ Assert.assertEquals(Algorithm.PS256, clientConfig.getRequestObjectSignatureAlg().toString());
+ Assert.assertFalse(client.isFullScopeAllowed());
+ }
+
+ @Test
+ public void testFAPICIBASignatureAlgorithms() throws Exception {
+ setupPolicyFAPICIBAForAllClient();
+
+ // Test that unsecured algorithm (RS256) is not possible
+ try {
+ createClientByAdmin("invalid", (ClientRepresentation clientRep) -> {
+ clientRep.setClientAuthenticatorType(JWTClientAuthenticator.PROVIDER_ID);
+ OIDCAdvancedConfigWrapper clientConfig = OIDCAdvancedConfigWrapper.fromClientRepresentation(clientRep);
+ clientConfig.setIdTokenSignedResponseAlg(Algorithm.RS256);
+ });
+ fail();
+ } catch (ClientPolicyException e) {
+ assertEquals(OAuthErrorException.INVALID_REQUEST, e.getMessage());
+ }
+
+ // Test that secured algorithm is possible to explicitly set
+ String clientUUID = createClientByAdmin("client-jwt", (ClientRepresentation clientRep) -> {
+ clientRep.setClientAuthenticatorType(JWTClientAuthenticator.PROVIDER_ID);
+ OIDCAdvancedConfigWrapper clientCfg = OIDCAdvancedConfigWrapper.fromClientRepresentation(clientRep);
+ clientCfg.setIdTokenSignedResponseAlg(Algorithm.ES256);
+ Map attr = Optional.ofNullable(clientRep.getAttributes()).orElse(new HashMap<>());
+ attr.put(CibaConfig.CIBA_BACKCHANNEL_AUTH_REQUEST_SIGNING_ALG, Algorithm.ES256);
+ clientRep.setAttributes(attr);
+ });
+ ClientRepresentation client = getClientByAdmin(clientUUID);
+ OIDCAdvancedConfigWrapper clientConfig = OIDCAdvancedConfigWrapper.fromClientRepresentation(client);
+ Assert.assertEquals(Algorithm.ES256, clientConfig.getIdTokenSignedResponseAlg());
+ Assert.assertEquals(Algorithm.PS256, clientConfig.getRequestObjectSignatureAlg().toString());
+ Assert.assertEquals(Algorithm.ES256, client.getAttributes().get(CibaConfig.CIBA_BACKCHANNEL_AUTH_REQUEST_SIGNING_ALG));
+
+ // Test default algorithms set everywhere
+ clientUUID = createClientByAdmin("client-jwt-default-alg", (ClientRepresentation clientRep) -> {
+ clientRep.setClientAuthenticatorType(JWTClientAuthenticator.PROVIDER_ID);
+ });
+ client = getClientByAdmin(clientUUID);
+ clientConfig = OIDCAdvancedConfigWrapper.fromClientRepresentation(client);
+ Assert.assertEquals(Algorithm.PS256, clientConfig.getIdTokenSignedResponseAlg());
+ Assert.assertEquals(Algorithm.PS256, clientConfig.getRequestObjectSignatureAlg().toString());
+ Assert.assertEquals(Algorithm.PS256, clientConfig.getUserInfoSignedResponseAlg().toString());
+ Assert.assertEquals(Algorithm.PS256, clientConfig.getTokenEndpointAuthSigningAlg());
+ Assert.assertEquals(Algorithm.PS256, client.getAttributes().get(OIDCConfigAttributes.ACCESS_TOKEN_SIGNED_RESPONSE_ALG));
+ Assert.assertEquals(Algorithm.PS256, client.getAttributes().get(CibaConfig.CIBA_BACKCHANNEL_AUTH_REQUEST_SIGNING_ALG));
+
+ }
+
+ @Test
+ public void testFAPICIBALoginWithPrivateKeyJWT() throws Exception {
+ setupPolicyFAPICIBAForAllClient();
+
+ // Register client with private-key-jwt
+ String clientUUID = createClientByAdmin(clientId, (ClientRepresentation clientRep) -> {
+ clientRep.setClientAuthenticatorType(JWTClientAuthenticator.PROVIDER_ID);
+ setClientAuthMethodNeutralSettings(clientRep);
+ });
+ ClientResource clientResource = adminClient.realm(REALM_NAME).clients().get(clientUUID);
+ ClientRepresentation client = clientResource.toRepresentation();
+ assertEquals(JWTClientAuthenticator.PROVIDER_ID, client.getClientAuthenticatorType());
+
+ // prepare valid signed authentication request
+ AuthorizationEndpointRequestObject requestObject = createFAPIValidAuthorizationEndpointRequestObject(username, bindingMessage);
+ String encodedRequestObject = registerSharedAuthenticationRequest(requestObject, clientId, Algorithm.PS256);
+
+ // Get keys of client. Will be used for client authentication and signing of authentication request
+ TestOIDCEndpointsApplicationResource oidcClientEndpointsResource = testingClient.testApp().oidcClientEndpoints();
+ Map generatedKeys = oidcClientEndpointsResource.getKeysAsBase64();
+ KeyPair keyPair = getKeyPairFromGeneratedBase64(generatedKeys, Algorithm.PS256);
+ PrivateKey privateKey = keyPair.getPrivate();
+ PublicKey publicKey = keyPair.getPublic();
+
+ String signedJwt = createSignedRequestToken(clientId, privateKey, publicKey, org.keycloak.crypto.Algorithm.PS256);
+
+ // user Backchannel Authentication Request
+ AuthenticationRequestAcknowledgement response = doBackchannelAuthenticationRequestWithClientSignedJWT(
+ signedJwt, encodedRequestObject, () -> MutualTLSUtils.newCloseableHttpClientWithDefaultKeyStoreAndTrustStore());
+ assertThat(response.getStatusCode(), is(equalTo(200)));
+
+ // 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);
+
+ String signedJwt2 = createSignedRequestToken(clientId, privateKey, publicKey, org.keycloak.crypto.Algorithm.PS256);
+
+ // user Token Request
+ OAuthClient.AccessTokenResponse tokenRes = doBackchannelAuthenticationTokenRequestWithClientSignedJWT(
+ signedJwt2, response.getAuthReqId(), () -> MutualTLSUtils.newCloseableHttpClientWithDefaultKeyStoreAndTrustStore());
+ verifyBackchannelAuthenticationTokenRequest(tokenRes, clientId, username);
+
+ // Logout and remove consent of the user for next logins
+ logoutUserAndRevokeConsent(clientId, username);
+ }
+
+ @Test
+ public void testFAPICIBAUserAuthenticationCancelled() throws Exception {
+ // this test is the same as conformance suite's "fapi-ciba-id1-user-rejects-authentication" test that can only be checked manually
+ // by kc-sig-fapi's automated conformance testing environment.
+ setupPolicyFAPICIBAForAllClient();
+
+ // Register client with private-key-jwt
+ String clientUUID = createClientByAdmin(clientId, (ClientRepresentation clientRep) -> {
+ clientRep.setClientAuthenticatorType(JWTClientAuthenticator.PROVIDER_ID);
+ setClientAuthMethodNeutralSettings(clientRep);
+ });
+ ClientResource clientResource = adminClient.realm(REALM_NAME).clients().get(clientUUID);
+ ClientRepresentation client = clientResource.toRepresentation();
+ assertEquals(JWTClientAuthenticator.PROVIDER_ID, client.getClientAuthenticatorType());
+
+ // prepare valid signed authentication request
+ AuthorizationEndpointRequestObject requestObject = createFAPIValidAuthorizationEndpointRequestObject(username, bindingMessage);
+ String encodedRequestObject = registerSharedAuthenticationRequest(requestObject, clientId, Algorithm.PS256);
+
+ // Get keys of client. Will be used for client authentication and signing of authentication request
+ TestOIDCEndpointsApplicationResource oidcClientEndpointsResource = testingClient.testApp().oidcClientEndpoints();
+ Map generatedKeys = oidcClientEndpointsResource.getKeysAsBase64();
+ KeyPair keyPair = getKeyPairFromGeneratedBase64(generatedKeys, Algorithm.PS256);
+ PrivateKey privateKey = keyPair.getPrivate();
+ PublicKey publicKey = keyPair.getPublic();
+
+ String signedJwt = createSignedRequestToken(clientId, privateKey, publicKey, org.keycloak.crypto.Algorithm.PS256);
+
+ // user Backchannel Authentication Request
+ AuthenticationRequestAcknowledgement response = doBackchannelAuthenticationRequestWithClientSignedJWT(
+ signedJwt, encodedRequestObject, () -> MutualTLSUtils.newCloseableHttpClientWithDefaultKeyStoreAndTrustStore());
+ assertThat(response.getStatusCode(), is(equalTo(200)));
+
+ // 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
+ doAuthenticationChannelCallbackCancelled(testRequest);
+
+ String signedJwt2 = createSignedRequestToken(clientId, privateKey, publicKey, org.keycloak.crypto.Algorithm.PS256);
+
+ // user Token Request
+ OAuthClient.AccessTokenResponse tokenRes = doBackchannelAuthenticationTokenRequestWithClientSignedJWT(
+ signedJwt2, response.getAuthReqId(), () -> MutualTLSUtils.newCloseableHttpClientWithDefaultKeyStoreAndTrustStore());
+ assertThat(tokenRes.getStatusCode(), is(equalTo(400)));
+ assertThat(tokenRes.getError(), is(equalTo(OAuthErrorException.ACCESS_DENIED)));
+ assertThat(tokenRes.getErrorDescription(), is(equalTo("not authorized")));
+ }
+
+ @Test
+ public void testFAPICIBALoginWithMTLS() throws Exception {
+ setupPolicyFAPICIBAForAllClient();
+
+ // Register client with X509
+ String clientUUID = createClientByAdmin("foo", (ClientRepresentation clientRep) -> {
+ clientRep.setClientAuthenticatorType(X509ClientAuthenticator.PROVIDER_ID);
+ OIDCAdvancedConfigWrapper clientConfig = OIDCAdvancedConfigWrapper.fromClientRepresentation(clientRep);
+ clientConfig.setRequestUris(Collections.singletonList(TestApplicationResourceUrls.clientRequestUri()));
+ clientConfig.setTlsClientAuthSubjectDn("EMAILADDRESS=contact@keycloak.org, CN=Keycloak Intermediate CA, OU=Keycloak, O=Red Hat, ST=MA, C=US");
+ setClientAuthMethodNeutralSettings(clientRep);
+ });
+ ClientResource clientResource = adminClient.realm(REALM_NAME).clients().get(clientUUID);
+ ClientRepresentation client = clientResource.toRepresentation();
+ assertEquals(X509ClientAuthenticator.PROVIDER_ID, client.getClientAuthenticatorType());
+
+ // prepare valid signed authentication request
+ AuthorizationEndpointRequestObject requestObject = createFAPIValidAuthorizationEndpointRequestObject(username, bindingMessage);
+ String encodedRequestObject = registerSharedAuthenticationRequest(requestObject, clientId, Algorithm.PS256);
+
+ // user Backchannel Authentication Request
+ AuthenticationRequestAcknowledgement response = doBackchannelAuthenticationRequestWithMTLS(
+ clientId, encodedRequestObject, () -> MutualTLSUtils.newCloseableHttpClientWithDefaultKeyStoreAndTrustStore());
+ assertThat(response.getStatusCode(), is(equalTo(200)));
+
+ // 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
+ OAuthClient.AccessTokenResponse tokenRes = doBackchannelAuthenticationTokenRequestWithMTLS(
+ clientId, response.getAuthReqId(), () -> MutualTLSUtils.newCloseableHttpClientWithDefaultKeyStoreAndTrustStore());
+ verifyBackchannelAuthenticationTokenRequest(tokenRes, clientId, username);
+
+ // Logout and remove consent of the user for next logins
+ logoutUserAndRevokeConsent(clientId, username);
+ }
+
+ @Test
+ public void testFAPICIBAWithoutBindingMessage() throws Exception {
+ setupPolicyFAPICIBAForAllClient();
+
+ // Register client with X509
+ String clientUUID = createClientByAdmin("foo", (ClientRepresentation clientRep) -> {
+ clientRep.setClientAuthenticatorType(X509ClientAuthenticator.PROVIDER_ID);
+ OIDCAdvancedConfigWrapper clientConfig = OIDCAdvancedConfigWrapper.fromClientRepresentation(clientRep);
+ clientConfig.setRequestUris(Collections.singletonList(TestApplicationResourceUrls.clientRequestUri()));
+ clientConfig.setTlsClientAuthSubjectDn("EMAILADDRESS=contact@keycloak.org, CN=Keycloak Intermediate CA, OU=Keycloak, O=Red Hat, ST=MA, C=US");
+ setClientAuthMethodNeutralSettings(clientRep);
+ });
+ ClientResource clientResource = adminClient.realm(REALM_NAME).clients().get(clientUUID);
+ ClientRepresentation client = clientResource.toRepresentation();
+ assertEquals(X509ClientAuthenticator.PROVIDER_ID, client.getClientAuthenticatorType());
+
+ // prepare invalid signed authentication request lacking binding message
+ AuthorizationEndpointRequestObject requestObject = createFAPIValidAuthorizationEndpointRequestObject(username, null);
+
+ String encodedRequestObject = registerSharedAuthenticationRequest(requestObject, clientId, Algorithm.PS256);
+
+ // user Backchannel Authentication Request
+ AuthenticationRequestAcknowledgement response = doBackchannelAuthenticationRequestWithMTLS(
+ clientId, encodedRequestObject, () -> MutualTLSUtils.newCloseableHttpClientWithDefaultKeyStoreAndTrustStore());
+ assertThat(response.getStatusCode(), is(equalTo(400)));
+ assertThat(response.getError(), is(equalTo(OAuthErrorException.INVALID_REQUEST)));
+ assertThat(response.getErrorDescription(), is(equalTo("Missing parameter: binding_message")));
+ }
+
+ @Test
+ public void testFAPICIBAWithoutSignedAuthenticationRequest() throws Exception {
+ setupPolicyFAPICIBAForAllClient();
+
+ // Register client with X509
+ String clientUUID = createClientByAdmin("foo", (ClientRepresentation clientRep) -> {
+ clientRep.setClientAuthenticatorType(X509ClientAuthenticator.PROVIDER_ID);
+ OIDCAdvancedConfigWrapper clientConfig = OIDCAdvancedConfigWrapper.fromClientRepresentation(clientRep);
+ clientConfig.setRequestUris(Collections.singletonList(TestApplicationResourceUrls.clientRequestUri()));
+ clientConfig.setTlsClientAuthSubjectDn("EMAILADDRESS=contact@keycloak.org, CN=Keycloak Intermediate CA, OU=Keycloak, O=Red Hat, ST=MA, C=US");
+ setClientAuthMethodNeutralSettings(clientRep);
+ });
+ ClientResource clientResource = adminClient.realm(REALM_NAME).clients().get(clientUUID);
+ ClientRepresentation client = clientResource.toRepresentation();
+ assertEquals(X509ClientAuthenticator.PROVIDER_ID, client.getClientAuthenticatorType());
+
+ AuthenticationRequestAcknowledgement response = doInvalidBackchannelAuthenticationRequestWithMTLS(clientId, username, bindingMessage, () -> MutualTLSUtils.newCloseableHttpClientWithDefaultKeyStoreAndTrustStore());
+ assertThat(response.getStatusCode(), is(equalTo(400)));
+ assertThat(response.getError(), is(equalTo(OAuthErrorException.INVALID_REQUEST)));
+ assertThat(response.getErrorDescription(), is(equalTo("Missing parameter: 'request' or 'request_uri'")));
+ }
+
+ private void setupPolicyFAPICIBAForAllClient() throws Exception {
+ String json = (new ClientPoliciesBuilder()).addPolicy(
+ (new ClientPolicyBuilder()).createPolicy("MyPolicy", "Policy for enable FAPI CIBA for all clients", Boolean.TRUE)
+ .addCondition(AnyClientConditionFactory.PROVIDER_ID,
+ createAnyClientConditionConfig())
+ .addProfile(FAPI_CIBA_PROFILE_NAME)
+ .addProfile(FAPI1_ADVANCED_PROFILE_NAME)
+ .toRepresentation()
+ ).toString();
+ updatePolicies(json);
+ }
+
+ private void setClientAuthMethodNeutralSettings(ClientRepresentation clientRep) {
+ // for keycloak to get client key to verify signed authentication request by client
+ OIDCAdvancedConfigWrapper.fromClientRepresentation(clientRep).setUseJwksUrl(true);
+ String jwksUrl = TestApplicationResourceUrls.clientJwksUri();
+ OIDCAdvancedConfigWrapper.fromClientRepresentation(clientRep).setJwksUrl(jwksUrl);
+ // activate CIBA grant for client
+ Map attributes = Optional.ofNullable(clientRep.getAttributes()).orElse(new HashMap<>());
+ attributes.put(CibaConfig.CIBA_BACKCHANNEL_TOKEN_DELIVERY_MODE_PER_CLIENT, "poll");
+ attributes.put(CibaConfig.OIDC_CIBA_GRANT_ENABLED, Boolean.TRUE.toString());
+ clientRep.setAttributes(attributes);
+ }
+
+ private AuthorizationEndpointRequestObject createValidAuthorizationEndpointRequestObject(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.audience(Urls.realmIssuer(new URI(suiteContext.getAuthServerInfo().getContextRoot().toString() + "/auth"), REALM_NAME), "https://example.com");
+ requestObject.setLoginHint(username);
+ requestObject.setBindingMessage(bindingMessage);
+ return requestObject;
+ }
+
+ private AuthorizationEndpointRequestObject createFAPIValidAuthorizationEndpointRequestObject(String username, String bindingMessage) throws Exception {
+ AuthorizationEndpointRequestObject requestObject = createValidAuthorizationEndpointRequestObject(username, bindingMessage);
+ requestObject.exp(requestObject.getIat() + Long.valueOf(300));
+ requestObject.nbf(requestObject.getIat());
+ requestObject.audience(Urls.realmIssuer(new URI(suiteContext.getAuthServerInfo().getContextRoot().toString() + "/auth"), REALM_NAME));
+ requestObject.issuer(clientId);
+ requestObject.id(org.keycloak.models.utils.KeycloakModelUtils.generateId());
+ requestObject.iat(Long.valueOf(Time.currentTime()));
+ return requestObject;
+ }
+
+ private String registerSharedAuthenticationRequest(AuthorizationEndpointRequestObject requestObject, String clientId, String sigAlg) throws URISyntaxException, IOException {
+ TestOIDCEndpointsApplicationResource oidcClientEndpointsResource = testingClient.testApp().oidcClientEndpoints();
+
+ // register request object
+ byte[] contentBytes = JsonSerialization.writeValueAsBytes(requestObject);
+ String encodedRequestObject = Base64Url.encode(contentBytes);
+ oidcClientEndpointsResource.generateKeys(sigAlg);
+ oidcClientEndpointsResource.registerOIDCRequest(encodedRequestObject, sigAlg);
+
+ return oidcClientEndpointsResource.getOIDCRequest();
+ }
+
+ private AuthenticationRequestAcknowledgement doBackchannelAuthenticationRequestWithClientSignedJWT(
+ String signedJwt, String request, Supplier httpClientSupplier) {
+ try {
+ List parameters = new LinkedList<>();
+ parameters.add(new BasicNameValuePair(OAuth2Constants.GRANT_TYPE, OAuth2Constants.CIBA_GRANT_TYPE));
+ parameters.add(new BasicNameValuePair(OAuth2Constants.CLIENT_ASSERTION_TYPE, OAuth2Constants.CLIENT_ASSERTION_TYPE_JWT));
+ parameters.add(new BasicNameValuePair(OAuth2Constants.CLIENT_ASSERTION, signedJwt));
+ parameters.add(new BasicNameValuePair(OIDCLoginProtocol.REQUEST_PARAM, request));
+ CloseableHttpResponse response = sendRequest(oauth.getBackchannelAuthenticationUrl(), parameters, httpClientSupplier);
+ return new AuthenticationRequestAcknowledgement(response);
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ private AuthenticationRequestAcknowledgement doBackchannelAuthenticationRequestWithMTLS(
+ String clientId, String request, Supplier httpClientSupplier) {
+ try {
+ List parameters = new LinkedList<>();
+ parameters.add(new BasicNameValuePair(OAuth2Constants.GRANT_TYPE, OAuth2Constants.CIBA_GRANT_TYPE));
+ parameters.add(new BasicNameValuePair(OIDCLoginProtocol.REQUEST_PARAM, request));
+ parameters.add(new BasicNameValuePair(OIDCLoginProtocol.CLIENT_ID_PARAM, clientId));
+ CloseableHttpResponse response = sendRequest(oauth.getBackchannelAuthenticationUrl(), parameters, httpClientSupplier);
+ return new AuthenticationRequestAcknowledgement(response);
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ private AuthenticationRequestAcknowledgement doInvalidBackchannelAuthenticationRequestWithMTLS(
+ String clientId, String username, String bindingMessage, Supplier httpClientSupplier) throws Exception {
+ try {
+ List parameters = new LinkedList<>();
+ parameters.add(new BasicNameValuePair(OAuth2Constants.GRANT_TYPE, OAuth2Constants.CIBA_GRANT_TYPE));
+ parameters.add(new BasicNameValuePair(OIDCLoginProtocol.CLIENT_ID_PARAM, clientId));
+ parameters.add(new BasicNameValuePair(LOGIN_HINT_PARAM, username));
+ parameters.add(new BasicNameValuePair(BINDING_MESSAGE, bindingMessage));
+ parameters.add(new BasicNameValuePair(OAuth2Constants.SCOPE, OAuth2Constants.SCOPE_OPENID));
+ CloseableHttpResponse response = sendRequest(oauth.getBackchannelAuthenticationUrl(), parameters, httpClientSupplier);
+ return new AuthenticationRequestAcknowledgement(response);
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ private TestAuthenticationChannelRequest doAuthenticationChannelRequest(String bindingMessage) {
+ // get Authentication Channel Request keycloak has done on Backchannel Authentication Endpoint from the FIFO queue of testing Authentication Channel Request API
+ TestOIDCEndpointsApplicationResource oidcClientEndpointsResource = testingClient.testApp().oidcClientEndpoints();
+ TestAuthenticationChannelRequest authenticationChannelReq = oidcClientEndpointsResource.getAuthenticationChannel(bindingMessage);
+ return authenticationChannelReq;
+ }
+
+ private EventRepresentation doAuthenticationChannelCallback(TestAuthenticationChannelRequest request) throws Exception {
+ int statusCode = oauth.doAuthenticationChannelCallback(request.getBearerToken(), SUCCEED);
+ assertThat(statusCode, is(equalTo(200)));
+ // check login event : ignore user id and other details except for username
+ EventRepresentation representation = new EventRepresentation();
+
+ representation.setDetails(Collections.emptyMap());
+
+ return representation;
+ }
+
+ private EventRepresentation doAuthenticationChannelCallbackCancelled(TestAuthenticationChannelRequest request) throws Exception {
+ int statusCode = oauth.doAuthenticationChannelCallback(request.getBearerToken(), CANCELLED);
+ assertThat(statusCode, is(equalTo(200)));
+ // check login event : ignore user id and other details except for username
+ EventRepresentation representation = new EventRepresentation();
+
+ representation.setDetails(Collections.emptyMap());
+
+ return representation;
+ }
+
+ private OAuthClient.AccessTokenResponse doBackchannelAuthenticationTokenRequestWithClientSignedJWT(
+ String signedJwt, String authReqId, Supplier httpClientSupplier) {
+ try {
+ List parameters = new LinkedList<>();
+ parameters.add(new BasicNameValuePair(OAuth2Constants.GRANT_TYPE, OAuth2Constants.CIBA_GRANT_TYPE));
+ parameters.add(new BasicNameValuePair(AUTH_REQ_ID, authReqId));
+ parameters.add(new BasicNameValuePair(OAuth2Constants.CLIENT_ASSERTION_TYPE, OAuth2Constants.CLIENT_ASSERTION_TYPE_JWT));
+ parameters.add(new BasicNameValuePair(OAuth2Constants.CLIENT_ASSERTION, signedJwt));
+ CloseableHttpResponse response = sendRequest(oauth.getBackchannelAuthenticationTokenRequestUrl(), parameters, httpClientSupplier);
+ return new OAuthClient.AccessTokenResponse(response);
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ private OAuthClient.AccessTokenResponse doBackchannelAuthenticationTokenRequestWithMTLS(
+ String clientId, String authReqId, Supplier httpClientSupplier) {
+ try {
+ List parameters = new LinkedList<>();
+ parameters.add(new BasicNameValuePair(OAuth2Constants.GRANT_TYPE, OAuth2Constants.CIBA_GRANT_TYPE));
+ parameters.add(new BasicNameValuePair(AUTH_REQ_ID, authReqId));
+ parameters.add(new BasicNameValuePair(OIDCLoginProtocol.CLIENT_ID_PARAM, clientId));
+ CloseableHttpResponse response = sendRequest(oauth.getBackchannelAuthenticationTokenRequestUrl(), parameters, httpClientSupplier);
+ return new OAuthClient.AccessTokenResponse(response);
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ private void verifyBackchannelAuthenticationTokenRequest(OAuthClient.AccessTokenResponse tokenRes, String clientId, String username) {
+ assertThat(tokenRes.getStatusCode(), is(equalTo(200)));
+ events.expectAuthReqIdToToken(null, null).clearDetails().user(AssertEvents.isUUID()).client(clientId).assertEvent();
+
+ AccessToken accessToken = oauth.verifyToken(tokenRes.getAccessToken());
+ assertThat(accessToken.getIssuedFor(), is(equalTo(clientId)));
+ Assert.assertNotNull(accessToken.getCertConf().getCertThumbprint());
+
+
+ RefreshToken refreshToken = oauth.parseRefreshToken(tokenRes.getRefreshToken());
+ assertThat(refreshToken.getIssuedFor(), is(equalTo(clientId)));
+ assertThat(refreshToken.getAudience()[0], is(equalTo(refreshToken.getIssuer())));
+
+ IDToken idToken = oauth.verifyIDToken(tokenRes.getIdToken());
+ assertThat(idToken.getPreferredUsername(), is(equalTo(username)));
+ assertThat(idToken.getIssuedFor(), is(equalTo(clientId)));
+ assertThat(idToken.getAudience()[0], is(equalTo(idToken.getIssuedFor())));
+ }
+
+ private void logoutUserAndRevokeConsent(String clientId, String username) {
+ UserResource user = ApiUtil.findUserByUsernameId(adminClient.realm(REALM_NAME), username);
+ user.logout();
+ List