From 3e07ca3c220b32f9f14d63a4940b3ae2f706cd0b Mon Sep 17 00:00:00 2001 From: Pedro Igor Date: Wed, 30 Jun 2021 17:45:55 -0300 Subject: [PATCH] [KEYCLOAK-18425] - Allow mapping user profile attributes --- .../provider/ProviderConfigProperty.java | 2 + .../protocol/ProtocolMapperUtils.java | 1 + .../mappers/OIDCAttributeMapperHelper.java | 24 ++- .../oidc/mappers/UserAttributeMapper.java | 36 ++++- .../resources/admin/UserResource.java | 8 +- .../oauth/OIDCProtocolMappersTest.java | 2 +- .../OIDCProtocolMappersUserProfileTest.java | 139 ++++++++++++++++++ .../testsuite/util/ProtocolMapperUtil.java | 9 ++ .../theme/base/admin/resources/js/app.js | 47 +++++- .../theme/base/admin/resources/js/services.js | 33 +++++ .../templates/kc-provider-config.html | 7 +- 11 files changed, 300 insertions(+), 8 deletions(-) create mode 100644 testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/OIDCProtocolMappersUserProfileTest.java diff --git a/server-spi/src/main/java/org/keycloak/provider/ProviderConfigProperty.java b/server-spi/src/main/java/org/keycloak/provider/ProviderConfigProperty.java index 3abda27e47..ad52c48e14 100755 --- a/server-spi/src/main/java/org/keycloak/provider/ProviderConfigProperty.java +++ b/server-spi/src/main/java/org/keycloak/provider/ProviderConfigProperty.java @@ -62,6 +62,8 @@ public class ProviderConfigProperty { */ public static final String MAP_TYPE ="Map"; + public static final String USER_PROFILE_ATTRIBUTE_LIST_TYPE="UserProfileAttributeList"; + protected String name; protected String label; protected String helpText; diff --git a/services/src/main/java/org/keycloak/protocol/ProtocolMapperUtils.java b/services/src/main/java/org/keycloak/protocol/ProtocolMapperUtils.java index a9818b4bfb..7954ced341 100755 --- a/services/src/main/java/org/keycloak/protocol/ProtocolMapperUtils.java +++ b/services/src/main/java/org/keycloak/protocol/ProtocolMapperUtils.java @@ -48,6 +48,7 @@ public class ProtocolMapperUtils { public static final String USER_MODEL_PROPERTY_HELP_TEXT = "usermodel.prop.tooltip"; public static final String USER_MODEL_ATTRIBUTE_LABEL = "usermodel.attr.label"; public static final String USER_MODEL_ATTRIBUTE_HELP_TEXT = "usermodel.attr.tooltip"; + public static final String USER_PROFILE_ATTRIBUTE = "user.profile.attribute"; public static final String USER_MODEL_CLIENT_ROLE_MAPPING_CLIENT_ID = "usermodel.clientRoleMapping.clientId"; public static final String USER_MODEL_CLIENT_ROLE_MAPPING_CLIENT_ID_LABEL = "usermodel.clientRoleMapping.clientId.label"; diff --git a/services/src/main/java/org/keycloak/protocol/oidc/mappers/OIDCAttributeMapperHelper.java b/services/src/main/java/org/keycloak/protocol/oidc/mappers/OIDCAttributeMapperHelper.java index 0e7a5933e9..fe2ee036dc 100755 --- a/services/src/main/java/org/keycloak/protocol/oidc/mappers/OIDCAttributeMapperHelper.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/mappers/OIDCAttributeMapperHelper.java @@ -233,11 +233,29 @@ public class OIDCAttributeMapperHelper { String tokenClaimName, String claimType, boolean accessToken, boolean idToken, String mapperId) { - return createClaimMapper(name, userAttribute,tokenClaimName, claimType, accessToken, idToken, true, mapperId); + return createClaimMapper(name, userAttribute, null, tokenClaimName, claimType, accessToken, idToken, true, mapperId); + } + + public static ProtocolMapperModel createClaimMapper(String name, + String userAttribute, + String userProfileAttribute, + String tokenClaimName, String claimType, + boolean accessToken, boolean idToken, + String mapperId) { + return createClaimMapper(name, userAttribute, userProfileAttribute, tokenClaimName, claimType, accessToken, idToken, true, mapperId); + } + + public static ProtocolMapperModel createClaimMapper(String name, + String userAttribute, + String tokenClaimName, String claimType, + boolean accessToken, boolean idToken, boolean userinfo, + String mapperId) { + return createClaimMapper(name, userAttribute, null, tokenClaimName, claimType, accessToken, idToken, userinfo, mapperId); } public static ProtocolMapperModel createClaimMapper(String name, String userAttribute, + String userProfileAttribute, String tokenClaimName, String claimType, boolean accessToken, boolean idToken, boolean userinfo, String mapperId) { @@ -247,6 +265,10 @@ public class OIDCAttributeMapperHelper { mapper.setProtocol(OIDCLoginProtocol.LOGIN_PROTOCOL); Map config = new HashMap(); config.put(ProtocolMapperUtils.USER_ATTRIBUTE, userAttribute); + if (userProfileAttribute != null) { + config.put(ProtocolMapperUtils.USER_PROFILE_ATTRIBUTE, userProfileAttribute); + } + config.put(TOKEN_CLAIM_NAME, tokenClaimName); config.put(JSON_TYPE, claimType); if (accessToken) config.put(INCLUDE_IN_ACCESS_TOKEN, "true"); diff --git a/services/src/main/java/org/keycloak/protocol/oidc/mappers/UserAttributeMapper.java b/services/src/main/java/org/keycloak/protocol/oidc/mappers/UserAttributeMapper.java index 920059bf01..d93dd0d3b7 100755 --- a/services/src/main/java/org/keycloak/protocol/oidc/mappers/UserAttributeMapper.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/mappers/UserAttributeMapper.java @@ -18,16 +18,19 @@ package org.keycloak.protocol.oidc.mappers; import org.keycloak.models.ProtocolMapperModel; +import org.keycloak.models.RealmModel; import org.keycloak.models.UserModel; import org.keycloak.models.UserSessionModel; import org.keycloak.models.utils.KeycloakModelUtils; import org.keycloak.protocol.ProtocolMapperUtils; import org.keycloak.provider.ProviderConfigProperty; import org.keycloak.representations.IDToken; +import org.keycloak.userprofile.DeclarativeUserProfileProvider; import java.util.ArrayList; import java.util.Collection; import java.util.List; +import java.util.Map; /** * Mappings UserModel.attribute to an ID Token claim. Token claim name can be a full qualified nested object name, @@ -49,6 +52,14 @@ public class UserAttributeMapper extends AbstractOIDCProtocolMapper implements O property.setHelpText(ProtocolMapperUtils.USER_MODEL_ATTRIBUTE_HELP_TEXT); property.setType(ProviderConfigProperty.STRING_TYPE); configProperties.add(property); + + property = new ProviderConfigProperty(); + property.setName(ProtocolMapperUtils.USER_PROFILE_ATTRIBUTE); + property.setLabel(ProtocolMapperUtils.USER_MODEL_ATTRIBUTE_LABEL); + property.setHelpText(ProtocolMapperUtils.USER_MODEL_ATTRIBUTE_HELP_TEXT); + property.setType(ProviderConfigProperty.USER_PROFILE_ATTRIBUTE_LIST_TYPE); + configProperties.add(property); + OIDCAttributeMapperHelper.addAttributeConfig(configProperties, UserAttributeMapper.class); property = new ProviderConfigProperty(); @@ -96,13 +107,25 @@ public class UserAttributeMapper extends AbstractOIDCProtocolMapper implements O protected void setClaim(IDToken token, ProtocolMapperModel mappingModel, UserSessionModel userSession) { UserModel user = userSession.getUser(); - String attributeName = mappingModel.getConfig().get(ProtocolMapperUtils.USER_ATTRIBUTE); + String attributeName = getAttributeName(mappingModel, userSession.getRealm()); boolean aggregateAttrs = Boolean.valueOf(mappingModel.getConfig().get(ProtocolMapperUtils.AGGREGATE_ATTRS)); Collection attributeValue = KeycloakModelUtils.resolveAttribute(user, attributeName, aggregateAttrs); if (attributeValue == null) return; OIDCAttributeMapperHelper.mapClaim(token, mappingModel, attributeValue); } + private String getAttributeName(ProtocolMapperModel mappingModel, RealmModel realm) { + Map config = mappingModel.getConfig(); + String name = config.get(ProtocolMapperUtils.USER_ATTRIBUTE); + + if (realm.getAttribute(DeclarativeUserProfileProvider.REALM_USER_PROFILE_ENABLED, false)) { + // defaults to the default config property for backward compatibility + return config.getOrDefault(ProtocolMapperUtils.USER_PROFILE_ATTRIBUTE, name); + } + + return name; + } + public static ProtocolMapperModel createClaimMapper(String name, String userAttribute, String tokenClaimName, String claimType, @@ -111,12 +134,23 @@ public class UserAttributeMapper extends AbstractOIDCProtocolMapper implements O accessToken, idToken, multivalued, false); } + public static ProtocolMapperModel createClaimMapper(String name, + String userAttribute, + String tokenClaimName, String claimType, + boolean accessToken, boolean idToken, + boolean multivalued, boolean aggregateAttrs) { + return createClaimMapper(name, userAttribute, null, tokenClaimName, claimType, + accessToken, idToken, multivalued, aggregateAttrs); + } + public static ProtocolMapperModel createClaimMapper(String name, String userAttribute, + String userProfileAttribute, String tokenClaimName, String claimType, boolean accessToken, boolean idToken, boolean multivalued, boolean aggregateAttrs) { ProtocolMapperModel mapper = OIDCAttributeMapperHelper.createClaimMapper(name, userAttribute, + userProfileAttribute, tokenClaimName, claimType, accessToken, idToken, PROVIDER_ID); diff --git a/services/src/main/java/org/keycloak/services/resources/admin/UserResource.java b/services/src/main/java/org/keycloak/services/resources/admin/UserResource.java index 49f9c082b3..9692ad7eae 100755 --- a/services/src/main/java/org/keycloak/services/resources/admin/UserResource.java +++ b/services/src/main/java/org/keycloak/services/resources/admin/UserResource.java @@ -103,6 +103,7 @@ import java.text.MessageFormat; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; +import java.util.Iterator; import java.util.LinkedList; import java.util.List; import java.util.Map; @@ -288,10 +289,11 @@ public class UserResource { if (rep.getAttributes() != null) { Map> allowedAttributes = profile.getAttributes().getReadable(false); + Iterator iterator = rep.getAttributes().keySet().iterator(); - for (String attributeName : rep.getAttributes().keySet()) { - if (!allowedAttributes.containsKey(attributeName)) { - rep.getAttributes().remove(attributeName); + while (iterator.hasNext()) { + if (!allowedAttributes.containsKey(iterator.next())) { + iterator.remove(); } } } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/OIDCProtocolMappersTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/OIDCProtocolMappersTest.java index e1f3141d07..a23ecad751 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/OIDCProtocolMappersTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/OIDCProtocolMappersTest.java @@ -1218,7 +1218,7 @@ public class OIDCProtocolMappersTest extends AbstractKeycloakTest { return rep; } - private OAuthClient.AccessTokenResponse browserLogin(String clientSecret, String username, String password) { + protected OAuthClient.AccessTokenResponse browserLogin(String clientSecret, String username, String password) { OAuthClient.AuthorizationEndpointResponse authzEndpointResponse = oauth.doLogin(username, password); return oauth.doAccessTokenRequest(authzEndpointResponse.getCode(), clientSecret); } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/OIDCProtocolMappersUserProfileTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/OIDCProtocolMappersUserProfileTest.java new file mode 100644 index 0000000000..382ca6a310 --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/OIDCProtocolMappersUserProfileTest.java @@ -0,0 +1,139 @@ +/* + * Copyright 2016 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.oauth; + +import static org.hamcrest.Matchers.anyOf; +import static org.hamcrest.Matchers.arrayContainingInAnyOrder; +import static org.hamcrest.Matchers.containsInAnyOrder; +import static org.hamcrest.Matchers.hasItems; +import static org.hamcrest.Matchers.instanceOf; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.isEmptyOrNullString; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThat; +import static org.junit.Assert.assertTrue; +import static org.keycloak.testsuite.admin.AbstractAdminTest.loadJson; +import static org.keycloak.testsuite.admin.ApiUtil.findClientByClientId; +import static org.keycloak.testsuite.admin.ApiUtil.findClientResourceByClientId; +import static org.keycloak.testsuite.admin.ApiUtil.findUserByUsernameId; +import static org.keycloak.testsuite.util.ProtocolMapperUtil.createAddressMapper; +import static org.keycloak.testsuite.util.ProtocolMapperUtil.createClaimMapper; +import static org.keycloak.testsuite.util.ProtocolMapperUtil.createHardcodedClaim; +import static org.keycloak.testsuite.util.ProtocolMapperUtil.createHardcodedRole; +import static org.keycloak.testsuite.util.ProtocolMapperUtil.createRoleNameMapper; +import static org.keycloak.testsuite.util.ProtocolMapperUtil.createScriptMapper; +import static org.keycloak.userprofile.DeclarativeUserProfileProvider.REALM_USER_PROFILE_ENABLED; + +import javax.ws.rs.core.Response; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.hamcrest.CoreMatchers; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.keycloak.admin.client.resource.ClientResource; +import org.keycloak.admin.client.resource.ClientScopeResource; +import org.keycloak.admin.client.resource.ProtocolMappersResource; +import org.keycloak.admin.client.resource.RealmResource; +import org.keycloak.admin.client.resource.UserResource; +import org.keycloak.common.Profile; +import org.keycloak.common.util.UriUtils; +import org.keycloak.jose.jws.JWSInput; +import org.keycloak.models.AccountRoles; +import org.keycloak.protocol.oidc.OIDCLoginProtocol; +import org.keycloak.protocol.oidc.OIDCLoginProtocolFactory; +import org.keycloak.protocol.oidc.mappers.AddressMapper; +import org.keycloak.protocol.oidc.mappers.OIDCAttributeMapperHelper; +import org.keycloak.representations.AccessToken; +import org.keycloak.representations.AddressClaimSet; +import org.keycloak.representations.IDToken; +import org.keycloak.representations.idm.ClientRepresentation; +import org.keycloak.representations.idm.ClientScopeRepresentation; +import org.keycloak.representations.idm.GroupRepresentation; +import org.keycloak.representations.idm.ProtocolMapperRepresentation; +import org.keycloak.representations.idm.RealmRepresentation; +import org.keycloak.representations.idm.UserRepresentation; +import org.keycloak.testsuite.AbstractKeycloakTest; +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.arquillian.annotation.AuthServerContainerExclude.AuthServer; +import org.keycloak.testsuite.arquillian.annotation.EnableFeature; +import org.keycloak.testsuite.updaters.ClientAttributeUpdater; +import org.keycloak.testsuite.updaters.ProtocolMappersUpdater; +import org.keycloak.testsuite.util.ClientManager; +import org.keycloak.testsuite.util.OAuthClient; +import org.keycloak.testsuite.util.ProtocolMapperUtil; + +/** + * @author Marek Posolda + */ +public class OIDCProtocolMappersUserProfileTest extends OIDCProtocolMappersTest { + + @Override + public void addTestRealms(List testRealms) { + super.addTestRealms(testRealms); + final RealmRepresentation testRealm = testRealms.get(0); + if (testRealm.getAttributes() == null) { + testRealm.setAttributes(new HashMap<>()); + } + testRealm.getAttributes().put(REALM_USER_PROFILE_ENABLED, Boolean.TRUE.toString()); + } + + @Test + public void testMappingFromAttribute() { + ClientResource app = findClientResourceByClientId(adminClient.realm("test"), "test-app"); + + app.getProtocolMappers().createMapper(createClaimMapper("user profile attribute precedence", "lastName", "firstName", "c_fn", "String", true, true, false)).close(); + + OAuthClient.AccessTokenResponse response = browserLogin("password", "test-user@localhost", "password"); + + IDToken idToken = oauth.verifyIDToken(response.getIdToken()); + + Object firstName = idToken.getOtherClaims().get("c_fn"); + assertThat(firstName, instanceOf(String.class)); + assertThat(firstName, is("Tom")); + + oauth.openLogout(); + } + + @Test + public void testFallbackToDefaultConfigProperty() { + ClientResource app = findClientResourceByClientId(adminClient.realm("test"), "test-app"); + + app.getProtocolMappers().createMapper(createClaimMapper("user profile default config property", "lastName", null, "c_fn_from_default", "String", true, true, false)).close(); + + OAuthClient.AccessTokenResponse response = browserLogin("password", "test-user@localhost", "password"); + + IDToken idToken = oauth.verifyIDToken(response.getIdToken()); + + Object firstName = idToken.getOtherClaims().get("c_fn_from_default"); + assertThat(firstName, instanceOf(String.class)); + assertThat(firstName, is("Brady")); + + oauth.openLogout(); + } +} diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/util/ProtocolMapperUtil.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/util/ProtocolMapperUtil.java index b9f0d6863c..64fd0f5f90 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/util/ProtocolMapperUtil.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/util/ProtocolMapperUtil.java @@ -91,6 +91,15 @@ public class ProtocolMapperUtil { } + public static ProtocolMapperRepresentation createClaimMapper(String name, + String userAttribute, String userProfileAttribute, + String tokenClaimName, String claimType, + boolean accessToken, boolean idToken, boolean multivalued) { + return ModelToRepresentation.toRepresentation(UserAttributeMapper.createClaimMapper(name, userAttribute, userProfileAttribute, tokenClaimName, + claimType, accessToken, idToken, multivalued, false)); + + } + public static ProtocolMapperRepresentation createClaimMapper(String name, String userAttribute, String tokenClaimName, String claimType, diff --git a/themes/src/main/resources/theme/base/admin/resources/js/app.js b/themes/src/main/resources/theme/base/admin/resources/js/app.js index addb63a14c..1f396f7725 100755 --- a/themes/src/main/resources/theme/base/admin/resources/js/app.js +++ b/themes/src/main/resources/theme/base/admin/resources/js/app.js @@ -3048,8 +3048,9 @@ module.controller('RoleSelectorModalCtrl', function($scope, realm, config, confi }) }); -module.controller('ProviderConfigCtrl', function ($modal, $scope, $route, ComponentUtils, Client) { +module.controller('ProviderConfigCtrl', function ($modal, $scope, $route, ComponentUtils, Client, UserProfile, Current) { clientSelectControl($scope, $route.current.params.realm, Client); + userProfileAttributeSelectControl($scope, $route.current.params.realm, UserProfile); $scope.fileNames = {}; $scope.newMapEntries = {}; var cachedMaps = {}; @@ -3073,6 +3074,38 @@ module.controller('ProviderConfigCtrl', function ($modal, $scope, $route, Compon } } + $scope.initSelectedUserProfileAttributes = function(configName, config) { + $scope.selectedUserAttribute = {}; + if(config[configName]) { + UserProfile.get({realm: $route.current.params.realm}, function(data) { + if (!data.attributes) { + $scope.userProfileDisabled = true; + return; + } + for (var i = 0; i < data.attributes.length; i++) { + if (data.attributes[i].name == config[configName]) { + $scope.selectedUserAttribute = data.attributes[i]; + $scope.selectedUserAttribute.text = data.attributes[i].name; + } + } + }); + } + } + + $scope.isPropertyDisabled = function(configName) { + var userProfileEnabled = Current.realm.attributes['userProfileEnabled'] == 'true'; + + if (configName == 'user.profile.attribute' && !userProfileEnabled) { + return true; + } + + if (configName == 'user.attribute' && userProfileEnabled) { + return true; + } + + return false; + } + $scope.openRoleSelector = function (configName, config) { $modal.open({ templateUrl: resourceUrl + '/partials/modal/role-selector.html', @@ -3105,6 +3138,18 @@ module.controller('ProviderConfigCtrl', function ($modal, $scope, $route, Compon } }; + $scope.changeUserAttribute = function(configName, config, userAttribute, multivalued) { + if (!$scope.selectedUserAttribute) { + return; + } + $scope.selectedUserAttribute = userAttribute; + if (multivalued) { + config[configName][0] = userAttribute.name; + } else { + config[configName] = userAttribute.name; + } + }; + ComponentUtils.convertAllMultivaluedStringValuesToList($scope.properties, $scope.config); ComponentUtils.addLastEmptyValueToMultivaluedLists($scope.properties, $scope.config); diff --git a/themes/src/main/resources/theme/base/admin/resources/js/services.js b/themes/src/main/resources/theme/base/admin/resources/js/services.js index 95487d1114..7d93ac7491 100755 --- a/themes/src/main/resources/theme/base/admin/resources/js/services.js +++ b/themes/src/main/resources/theme/base/admin/resources/js/services.js @@ -962,6 +962,39 @@ function clientSelectControl($scope, realm, Client) { }; } +function userProfileAttributeSelectControl($scope, realm, UserProfile) { + $scope.userProfileAttributesUiSelect = { + minimumInputLength: 0, + delay: 500, + allowClear: true, + id: function(e) { return e.name; }, + query: function (query) { + var data = {results: []}; + UserProfile.get({realm: realm}, function(config) { + var attributes = []; + + if ('' == query.term.trim()) { + attributes = config.attributes; + } else { + for (var i = 0; i < config.attributes.length; i++) { + if (config.attributes[i].name.indexOf(query.term.trim()) != -1) { + attributes.push(config.attributes[i]); + } + } + } + query.callback({results: attributes}); + }); + }, + formatResult: function(object, container, query) { + object.text = object.name; + return object.name; + }, + formatSelection: function(object, container, query) { + return object.name; + } + }; +} + function roleControl($scope, $route, realm, role, roles, Client, ClientRole, RoleById, RoleRealmComposites, RoleClientComposites, $http, $location, Notifications, Dialog, ComponentUtils) { diff --git a/themes/src/main/resources/theme/base/admin/resources/templates/kc-provider-config.html b/themes/src/main/resources/theme/base/admin/resources/templates/kc-provider-config.html index 70225bcec5..62e734ea03 100755 --- a/themes/src/main/resources/theme/base/admin/resources/templates/kc-provider-config.html +++ b/themes/src/main/resources/theme/base/admin/resources/templates/kc-provider-config.html @@ -1,5 +1,6 @@
-
+
@@ -33,6 +34,10 @@
+
+ + +