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,