diff --git a/docs/documentation/server_admin/topics/identity-broker/configuration.adoc b/docs/documentation/server_admin/topics/identity-broker/configuration.adoc index d86e9a433e..155d828ed2 100644 --- a/docs/documentation/server_admin/topics/identity-broker/configuration.adoc +++ b/docs/documentation/server_admin/topics/identity-broker/configuration.adoc @@ -76,4 +76,7 @@ Although each type of identity provider has its configuration options, all share |Sync Mode |Strategy to update user information from the identity provider through mappers. When choosing *legacy*, {project_name} used the current behavior. *Import* does not update user data and *force* updates user data when possible. See <<_mappers, Identity Provider Mappers>> for more information. + +|Case-sensitive username +|If enabled, the original username from the identity provider is kept as is when federating users. Otherwise, the username from the identity provider is lower-cased and might not match the original value if it is case-sensitive. This setting only affects the username associated with the federated identity as usernames in the server are always in lower-case. |=== diff --git a/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties b/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties index d19b5b5dc5..8c3df86aec 100644 --- a/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties +++ b/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties @@ -3136,3 +3136,5 @@ logo=Logo avatarImage=Avatar image organizationsEnabled=Organizations organizationsEnabledHelp=If enabled, allows managing organizations. Otherwise, existing organizations are still kept but you will not be able to manage them anymore or authenticate their members. +caseSensitiveOriginalUsername=Case-sensitive username +caseSensitiveOriginalUsernameHelp=If enabled, the original username from the identity provider is kept as is when federating users. Otherwise, the username from the identity provider is lower-cased and might not match the original value if it is case-sensitive. This setting only affects the username associated with the federated identity as usernames in the server are always in lower-case. \ No newline at end of file diff --git a/js/apps/admin-ui/src/identity-providers/add/AdvancedSettings.tsx b/js/apps/admin-ui/src/identity-providers/add/AdvancedSettings.tsx index 5c7248cc3e..81023fb454 100644 --- a/js/apps/admin-ui/src/identity-providers/add/AdvancedSettings.tsx +++ b/js/apps/admin-ui/src/identity-providers/add/AdvancedSettings.tsx @@ -282,6 +282,10 @@ export const AdvancedSettings = ({ isOIDC, isSAML }: AdvancedSettingsProps) => { }} /> )} + ); }; diff --git a/model/jpa/src/main/java/org/keycloak/models/jpa/JpaUserProvider.java b/model/jpa/src/main/java/org/keycloak/models/jpa/JpaUserProvider.java index af31858983..0722234001 100755 --- a/model/jpa/src/main/java/org/keycloak/models/jpa/JpaUserProvider.java +++ b/model/jpa/src/main/java/org/keycloak/models/jpa/JpaUserProvider.java @@ -168,7 +168,7 @@ public class JpaUserProvider implements UserProvider, UserCredentialStore { entity.setRealmId(realm.getId()); entity.setIdentityProvider(identity.getIdentityProvider()); entity.setUserId(identity.getUserId()); - entity.setUserName(identity.getUserName().toLowerCase()); + entity.setUserName(identity.getUserName()); entity.setToken(identity.getToken()); UserEntity userEntity = em.getReference(UserEntity.class, user.getId()); entity.setUser(userEntity); diff --git a/server-spi-private/src/main/java/org/keycloak/broker/provider/BrokeredIdentityContext.java b/server-spi-private/src/main/java/org/keycloak/broker/provider/BrokeredIdentityContext.java index dc18b6dee3..61e02ee10a 100755 --- a/server-spi-private/src/main/java/org/keycloak/broker/provider/BrokeredIdentityContext.java +++ b/server-spi-private/src/main/java/org/keycloak/broker/provider/BrokeredIdentityContext.java @@ -16,6 +16,8 @@ */ package org.keycloak.broker.provider; +import static java.util.Optional.ofNullable; + import org.keycloak.models.Constants; import org.keycloak.models.IdentityProviderModel; import org.keycloak.models.UserSessionModel; @@ -51,12 +53,13 @@ public class BrokeredIdentityContext { private Map contextData = new HashMap<>(); private AuthenticationSessionModel authenticationSession; - public BrokeredIdentityContext(String id) { + public BrokeredIdentityContext(String id, IdentityProviderModel idpConfig) { if (id == null) { throw new RuntimeException("No identifier provider for identity."); } this.id = id; + this.idpConfig = idpConfig; } public String getId() { @@ -86,7 +89,11 @@ public class BrokeredIdentityContext { * @return */ public String getUsername() { - return username; + if (getIdpConfig().isCaseSensitiveOriginalUsername()) { + return username; + } + + return username == null ? null : username.toLowerCase(); } public void setUsername(String username) { @@ -142,10 +149,6 @@ public class BrokeredIdentityContext { return idpConfig; } - public void setIdpConfig(IdentityProviderModel idpConfig) { - this.idpConfig = idpConfig; - } - public IdentityProvider getIdp() { return idp; } diff --git a/server-spi/src/main/java/org/keycloak/models/IdentityProviderModel.java b/server-spi/src/main/java/org/keycloak/models/IdentityProviderModel.java index a7692d6a9f..56ff725fa2 100755 --- a/server-spi/src/main/java/org/keycloak/models/IdentityProviderModel.java +++ b/server-spi/src/main/java/org/keycloak/models/IdentityProviderModel.java @@ -44,6 +44,7 @@ public class IdentityProviderModel implements Serializable { public static final String CLAIM_FILTER_VALUE = "claimFilterValue"; public static final String DO_NOT_STORE_USERS = "doNotStoreUsers"; public static final String METADATA_DESCRIPTOR_URL = "metadataDescriptorUrl"; + public static final String CASE_SENSITIVE_ORIGINAL_USERNAME = "caseSensitiveOriginalUsername"; private String internalId; @@ -321,6 +322,14 @@ public class IdentityProviderModel implements Serializable { getConfig().put(METADATA_DESCRIPTOR_URL, metadataDescriptorUrl); } + public boolean isCaseSensitiveOriginalUsername() { + return Boolean.parseBoolean(getConfig().getOrDefault(CASE_SENSITIVE_ORIGINAL_USERNAME, Boolean.FALSE.toString())); + } + + public void setCaseSensitiveOriginalUsername(boolean caseSensitive) { + getConfig().put(CASE_SENSITIVE_ORIGINAL_USERNAME, Boolean.valueOf(caseSensitive).toString()); + } + @Override public int hashCode() { int hash = 5; diff --git a/services/src/main/java/org/keycloak/authentication/authenticators/broker/util/SerializedBrokeredIdentityContext.java b/services/src/main/java/org/keycloak/authentication/authenticators/broker/util/SerializedBrokeredIdentityContext.java index e8c77941be..aaf5d0e936 100755 --- a/services/src/main/java/org/keycloak/authentication/authenticators/broker/util/SerializedBrokeredIdentityContext.java +++ b/services/src/main/java/org/keycloak/authentication/authenticators/broker/util/SerializedBrokeredIdentityContext.java @@ -264,7 +264,14 @@ public class SerializedBrokeredIdentityContext implements UpdateProfileContext { } public BrokeredIdentityContext deserialize(KeycloakSession session, AuthenticationSessionModel authSession) { - BrokeredIdentityContext ctx = new BrokeredIdentityContext(getId()); + RealmModel realm = authSession.getRealm(); + IdentityProviderModel idpConfig = realm.getIdentityProviderByAlias(getIdentityProviderId()); + + if (idpConfig == null) { + throw new ModelException("Can't find identity provider with ID " + getIdentityProviderId() + " in realm " + realm.getName()); + } + + BrokeredIdentityContext ctx = new BrokeredIdentityContext(getId(), idpConfig); ctx.setUsername(getBrokerUsername()); ctx.setModelUsername(getModelUsername()); @@ -275,13 +282,7 @@ public class SerializedBrokeredIdentityContext implements UpdateProfileContext { ctx.setBrokerUserId(getBrokerUserId()); ctx.setToken(getToken()); - RealmModel realm = authSession.getRealm(); - IdentityProviderModel idpConfig = realm.getIdentityProviderByAlias(getIdentityProviderId()); - if (idpConfig == null) { - throw new ModelException("Can't find identity provider with ID " + getIdentityProviderId() + " in realm " + realm.getName()); - } IdentityProvider idp = IdentityBrokerService.getIdentityProvider(session, realm, idpConfig.getAlias()); - ctx.setIdpConfig(idpConfig); ctx.setIdp(idp); IdentityProviderDataMarshaller serializer = idp.getMarshaller(); diff --git a/services/src/main/java/org/keycloak/broker/oidc/AbstractOAuth2IdentityProvider.java b/services/src/main/java/org/keycloak/broker/oidc/AbstractOAuth2IdentityProvider.java index 1ac07b445e..d4307717e9 100755 --- a/services/src/main/java/org/keycloak/broker/oidc/AbstractOAuth2IdentityProvider.java +++ b/services/src/main/java/org/keycloak/broker/oidc/AbstractOAuth2IdentityProvider.java @@ -562,7 +562,6 @@ public abstract class AbstractOAuth2IdentityProvider notes = new HashMap<>(); - BrokeredIdentityContext identity = new BrokeredIdentityContext(principal); + BrokeredIdentityContext identity = new BrokeredIdentityContext(principal, config); identity.getContextData().put(SAML_LOGIN_RESPONSE, responseType); identity.getContextData().put(SAML_ASSERTION, assertion); identity.setAuthenticationSession(authSession); @@ -601,7 +601,6 @@ public class SAMLEndpoint { String brokerUserId = config.getAlias() + "." + principal; identity.setBrokerUserId(brokerUserId); - identity.setIdpConfig(config); identity.setIdp(provider); if (authn != null && authn.getSessionIndex() != null) { identity.setBrokerSessionId(config.getAlias() + "." + authn.getSessionIndex()); diff --git a/services/src/main/java/org/keycloak/social/bitbucket/BitbucketIdentityProvider.java b/services/src/main/java/org/keycloak/social/bitbucket/BitbucketIdentityProvider.java index 9da0dc31d6..964363b784 100755 --- a/services/src/main/java/org/keycloak/social/bitbucket/BitbucketIdentityProvider.java +++ b/services/src/main/java/org/keycloak/social/bitbucket/BitbucketIdentityProvider.java @@ -132,13 +132,10 @@ public class BitbucketIdentityProvider extends AbstractOAuth2IdentityProvider im } private BrokeredIdentityContext extractUserInfo(String subjectToken, JsonNode profile) { - BrokeredIdentityContext user = new BrokeredIdentityContext(getJsonProperty(profile, "account_id")); - - + BrokeredIdentityContext user = new BrokeredIdentityContext(getJsonProperty(profile, "account_id"), getConfig()); String username = getJsonProperty(profile, "username"); user.setUsername(username); user.setName(getJsonProperty(profile, "display_name")); - user.setIdpConfig(getConfig()); user.setIdp(this); AbstractJsonUserAttributeMapper.storeUserProfileForMapper(user, profile, getConfig().getAlias()); diff --git a/services/src/main/java/org/keycloak/social/facebook/FacebookIdentityProvider.java b/services/src/main/java/org/keycloak/social/facebook/FacebookIdentityProvider.java index 762b615f2b..fec20aa69c 100755 --- a/services/src/main/java/org/keycloak/social/facebook/FacebookIdentityProvider.java +++ b/services/src/main/java/org/keycloak/social/facebook/FacebookIdentityProvider.java @@ -74,7 +74,7 @@ public class FacebookIdentityProvider extends AbstractOAuth2IdentityProviderMarek Posolda */ @@ -805,6 +809,59 @@ public class KcOidcFirstBrokerLoginTest extends AbstractFirstBrokerLoginTest { assertEquals(null, user.firstAttribute(ATTRIBUTE_DEPARTMENT)); } + @Test + public void testFederatedIdentityCaseSensitiveOriginalUsername() { + String expectedBrokeredUserName = "camelCase"; + IdentityProviderResource idp = realmsResouce().realm(bc.consumerRealmName()).identityProviders().get(bc.getIDPAlias()); + IdentityProviderRepresentation representation = idp.toRepresentation(); + representation.getConfig().put(TestKeycloakOidcIdentityProviderFactory.PREFERRED_USERNAME, expectedBrokeredUserName); + representation.getConfig().put(IdentityProviderModel.CASE_SENSITIVE_ORIGINAL_USERNAME, Boolean.TRUE.toString()); + idp.update(representation); + createUser(bc.providerRealmName(), expectedBrokeredUserName, BrokerTestConstants.USER_PASSWORD, "f", "l", "fl@example.org"); + + oauth.clientId("broker-app"); + loginPage.open(bc.consumerRealmName()); + // the username is stored as lower-case in the provider realm local database + logInWithIdp(bc.getIDPAlias(), expectedBrokeredUserName.toLowerCase(), BrokerTestConstants.USER_PASSWORD); + + RealmResource realm = adminClient.realm(bc.consumerRealmName()); + UserRepresentation userRepresentation = AccountHelper.getUserRepresentation(realm, expectedBrokeredUserName.toLowerCase()); + // the username is in lower case in the local database + assertEquals(userRepresentation.getUsername(), expectedBrokeredUserName.toLowerCase()); + + // the original username is preserved + List federatedIdentities = realm.users().get(userRepresentation.getId()).getFederatedIdentity(); + assertFalse(federatedIdentities.isEmpty()); + FederatedIdentityRepresentation federatedIdentity = federatedIdentities.get(0); + assertEquals(expectedBrokeredUserName, federatedIdentity.getUserName()); + } + + @Test + public void testFederatedIdentityCaseInsensitiveOriginalUsername() { + String expectedBrokeredUserName = "camelCase"; + IdentityProviderResource idp = realmsResouce().realm(bc.consumerRealmName()).identityProviders().get(bc.getIDPAlias()); + IdentityProviderRepresentation representation = idp.toRepresentation(); + representation.getConfig().put(TestKeycloakOidcIdentityProviderFactory.PREFERRED_USERNAME, expectedBrokeredUserName); + idp.update(representation); + createUser(bc.providerRealmName(), expectedBrokeredUserName, BrokerTestConstants.USER_PASSWORD, "f", "l", "fl@example.org"); + + oauth.clientId("broker-app"); + loginPage.open(bc.consumerRealmName()); + // the username is stored as lower-case in the provider realm local database + logInWithIdp(bc.getIDPAlias(), expectedBrokeredUserName.toLowerCase(), BrokerTestConstants.USER_PASSWORD); + + RealmResource realm = adminClient.realm(bc.consumerRealmName()); + UserRepresentation userRepresentation = AccountHelper.getUserRepresentation(realm, expectedBrokeredUserName.toLowerCase()); + // the username is in lower case in the local database + assertEquals(userRepresentation.getUsername(), expectedBrokeredUserName.toLowerCase()); + + // the original username is preserved + List federatedIdentities = realm.users().get(userRepresentation.getId()).getFederatedIdentity(); + assertFalse(federatedIdentities.isEmpty()); + FederatedIdentityRepresentation federatedIdentity = federatedIdentities.get(0); + assertEquals(expectedBrokeredUserName.toLowerCase(), federatedIdentity.getUserName()); + } + public void addDepartmentScopeIntoRealm() { testRealm().clientScopes().create(ClientScopeBuilder.create().name("department").protocol("openid-connect").build()); } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/exportimport/ExportImportUtil.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/exportimport/ExportImportUtil.java index 380f29d9db..8ce1c71096 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/exportimport/ExportImportUtil.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/exportimport/ExportImportUtil.java @@ -251,7 +251,7 @@ public class ExportImportUtil { } else if ("google1".equals(federatedIdentityRep.getIdentityProvider())) { googleFound = true; Assert.assertEquals("google1", federatedIdentityRep.getUserId()); - Assert.assertEquals("mysocialuser@gmail.com", federatedIdentityRep.getUserName()); + Assert.assertEquals("mySocialUser@gmail.com", federatedIdentityRep.getUserName()); } else if ("twitter1".equals(federatedIdentityRep.getIdentityProvider())) { twitterFound = true; Assert.assertEquals("twitter1", federatedIdentityRep.getUserId()); @@ -260,6 +260,12 @@ public class ExportImportUtil { } Assert.assertTrue(facebookFound && twitterFound && googleFound); + // make sure the username format is the same when importing + UserResource socialUserLowercase = realmRsc.users().get(findByUsername(realmRsc, "lowercasesocialuser").getId()); + List socialLowercaseLinks = socialUserLowercase.getFederatedIdentity(); + Assert.assertEquals(1, socialLowercaseLinks.size()); + Assert.assertEquals("lowercasesocialuser@gmail.com", socialLowercaseLinks.get(0).getUserName()); + UserRepresentation foundSocialUser = testingClient.testing().getUserByFederatedIdentity(realm.getRealm(), "facebook1", "facebook1", "fbuser1"); Assert.assertEquals(foundSocialUser.getUsername(), socialUser.toRepresentation().getUsername()); Assert.assertNull(testingClient.testing().getUserByFederatedIdentity(realm.getRealm(), "facebook", "not-existing", "not-existing")); @@ -283,7 +289,7 @@ public class ExportImportUtil { // Test identity providers List identityProviders = realm.getIdentityProviders(); - Assert.assertEquals(3, identityProviders.size()); + Assert.assertEquals(4, identityProviders.size()); IdentityProviderRepresentation google = null; for (IdentityProviderRepresentation idpRep : identityProviders) { if (idpRep.getAlias().equals("google1")) google = idpRep; diff --git a/testsuite/integration-arquillian/tests/base/src/test/resources/model/testrealm.json b/testsuite/integration-arquillian/tests/base/src/test/resources/model/testrealm.json index 4f7e24a476..beb01c42f2 100755 --- a/testsuite/integration-arquillian/tests/base/src/test/resources/model/testrealm.json +++ b/testsuite/integration-arquillian/tests/base/src/test/resources/model/testrealm.json @@ -35,6 +35,16 @@ "clientSecret": "googleSecret" } }, + { + "providerId" : "github", + "alias" : "github1", + "enabled": true, + "config": { + "syncMode": "IMPORT", + "clientId": "googleId", + "clientSecret": "googleSecret" + } + }, { "providerId" : "facebook", "alias" : "facebook1", @@ -239,6 +249,17 @@ } ] }, + { + "username": "lowercasesocialuser", + "enabled": true, + "federatedIdentities": [ + { + "identityProvider": "github1", + "userId": "github1", + "userName": "lowercasesocialuser@gmail.com" + } + ] + }, { "username": "service-account-otherapp", "enabled": true,