8000 Reject blank usernames created by identity provider mappers by ItsRoy69 · Pull Request #53485 · keycloak/keycloak · GitHub
[go: up one dir, main page]

Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions docs/documentation/upgrading/topics/changes/changes-26_8_1.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,24 @@ bin/kc.[sh|bat] start --spi-storage--ldap--disable-kerberos-authentication-round
--spi-storage--kerberos--disable-kerberos-authentication-round-trip true
----

=== Identity provider mappers no longer create users with a blank username
// https://github.com/keycloak/keycloak/issues/53164

When the username template of an identity provider mapper could not be fully resolved, the mapper set a blank username and the federated account was created with it. Such an account cannot be found by username, and could not be edited or deleted, because invalidating the user in the cache required a username.

A username template that does not resolve to a non-blank username now fails the login with an error, and `addUser` rejects a blank username. Accounts that were already created with a blank username can be edited and deleted again.

Review the username template of any identity provider mapper that does not resolve for every user, before upgrading.

No database constraint was added, so existing accounts are not rejected. Use the following query to find accounts that are affected, and assign them a username, for example by resetting their federated identity:

[source,sql]
----
SELECT id, email, realm_id
FROM user_entity
WHERE username IS NULL OR TRIM(username) = '';
----

=== Registration access tokens are rejected by client registration endpoints of a different protocol

Updating or deleting a client through a client registration endpoint whose protocol does not match the client, such as using a SAML client's registration access token against the `openid-connect` endpoint, is now rejected with `400 invalid_client`, matching the behavior that already applied to read requests. Use the protocol-agnostic `clients-registrations/default` endpoint or the Admin REST API to manage clients of other protocols.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ protected Logger getLogger() {
public void userUpdatedInvalidations(String userId, String username, String email, String realmId, Set<String> invalidations) {
invalidations.add(userId);
if (email != null) invalidations.add(UserCacheSession.getUserByEmailCacheKey(realmId, email));
invalidations.add(UserCacheSession.getUserByUsernameCacheKey(realmId, username));
if (username != null) invalidations.add(UserCacheSession.getUserByUsernameCacheKey(realmId, username));
}

// Fully invalidate user including consents and federatedIdentity links.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ public class UserFullInvalidationEvent extends InvalidationEvent implements User

private UserFullInvalidationEvent(String id, String username, String email, String realmId, boolean identityFederationEnabled, Map<String, String> federatedIdentities) {
super(id);
this.username = Objects.requireNonNull(username);
this.username = username;
this.email = email;
this.realmId = Objects.requireNonNull(realmId);
this.federatedIdentities = federatedIdentities;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/*
* Copyright 2026 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.models.cache.infinispan;

import java.util.HashSet;
import java.util.Set;

import org.junit.Test;

import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;

public class UserCacheManagerTest {

private static final String USER_ID = "user-id";
private static final String REALM_ID = "realm-id";

private final UserCacheManager manager = new UserCacheManager(null, null);

@Test
public void skipsUsernameLookupKeyWhenUsernameIsMissing() {
Set<String> invalidations = invalidationsFor(null, null);

assertEquals(Set.of(USER_ID), invalidations);
assertFalse(invalidations.contains(REALM_ID + ".username.null"));
}

@Test
public void invalidatesUsernameAndEmailLookupsWhenPresent() {
Set<String> invalidations = invalidationsFor("jdoe", "jdoe@example.com");

assertEquals(Set.of(USER_ID, REALM_ID + ".username.jdoe", REALM_ID + ".email.jdoe@example.com"), invalidations);
}

@Test
public void skipsEmailLookupKeyWhenEmailIsMissing() {
assertTrue(invalidationsFor("jdoe", null).contains(REALM_ID + ".username.jdoe"));
}

private Set<String> invalidationsFor(String username, String email) {
Set<String> invalidations = new HashSet<>();
manager.userUpdatedInvalidations(USER_ID, username, email, REALM_ID, invalidations);
return invalidations;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
/*
* Copyright 2026 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.models.cache.infinispan.events;

import java.util.HashSet;
import java.util.Set;
import java.util.stream.Stream;

import org.keycloak.models.cache.infinispan.UserCacheManager;

import org.junit.Test;

import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;

public class UserFullInvalidationEventTest {

private static final String USER_ID = "user-id";
private static final String REALM_ID = "realm-id";
private static final String BOGUS_USERNAME_KEY = REALM_ID + ".username.null";

@Test
public void accountWithoutUsernameCanBeInvalidated() {
UserFullInvalidationEvent event = UserFullInvalidationEvent.create(USER_ID, null, null, REALM_ID, false, Stream.empty());

Set<String> invalidations = invalidate(event);

assertTrue("the user must still be invalidated by id", invalidations.contains(USER_ID));
assertFalse("no lookup key may be built for a missing username", invalidations.contains(BOGUS_USERNAME_KEY));
}

@Test
public void accountWithUsernameStillInvalidatesUsernameLookup() {
UserFullInvalidationEvent event = UserFullInvalidationEvent.create(USER_ID, "jdoe", null, REALM_ID, false, Stream.empty());

Set<String> invalidations = invalidate(event);

assertTrue(invalidations.contains(USER_ID));
assertTrue(invalidations.contains(REALM_ID + ".username.jdoe"));
}

@Test
public void realmIdIsStillMandatory() {
assertThrows(NullPointerException.class,
() -> UserFullInvalidationEvent.create(USER_ID, null, null, null, false, Stream.empty()));
}

private Set<String> invalidate(UserFullInvalidationEvent event) {
Set<String> invalidations = new HashSet<>();
// the caches are not touched while collecting the invalidation keys
event.addInvalidations(new UserCacheManager(null, null), invalidations);
return invalidations;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,10 @@ public JpaUserProvider(KeycloakSession session, EntityManager em) {

@Override
public UserModel addUser(RealmModel realm, String id, String username, boolean addDefaultRoles, boolean addDefaultRequiredActions) {
if (StringUtil.isBlank(username)) {
throw new ModelException("Cannot create a user without a username");
}
Comment on lines +124 to +126

if (id == null) {
id = KeycloakModelUtils.generateId();
}
Expand Down Expand Up @@ -158,6 +162,10 @@ public UserModel addUser(RealmModel realm, String id, String username, boolean a

@Override
public UserModel addUser(RealmModel realm, String username) {
if (StringUtil.isBlank(username)) {
throw new ModelException("Cannot create a user without a username");
}

return addUser(realm, KeycloakModelUtils.generateId(), username.toLowerCase(), true, true);
}

Expand Down Expand Up @@ -717,6 +725,10 @@ public UserModel getUserById(RealmModel realm, String id) {

@Override
public UserModel getUserByUsername(RealmModel realm, String username) {
if (username == null) {
return null;
}

TypedQuery<UserEntity> query = em.createNamedQuery("getRealmUserByUsername", UserEntity.class);
query.setParameter("username", username.toLowerCase());
query.setParameter("realmId", realm.getId());
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/*
* Copyright 2026 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.models.jpa;

import org.keycloak.models.ModelException;

import org.junit.Test;

import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertThrows;

public class JpaUserProviderTest {

private final JpaUserProvider provider = new JpaUserProvider(null, null);

@Test
public void rejectsMissingUsername() {
assertThrows(ModelException.class, () -> provider.addUser(null, null, null, true, true));
}

@Test
public void rejectsMissingUsernameThroughAddUser() {
assertThrows(ModelException.class, () -> provider.addUser(null, null));
}

@Test
public void rejectsEmptyUsername() {
assertThrows(ModelException.class, () -> provider.addUser(null, null, "", true, true));
}

@Test
public void rejectsWhitespaceUsername() {
assertThrows(ModelException.class, () -> provider.addUser(null, null, " ", true, true));
}

@Test
public void lookupByMissingUsernameFindsNobody() {
assertNull(provider.getUserByUsername(null, null));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -522,6 +522,10 @@ private static Stream<UserModel> removeDuplicates(Stream<UserModel> withDuplicat

@Override
public UserModel addUser(RealmModel realm, String username) {
if (StringUtil.isBlank(username)) {
throw new ModelException("Cannot create a user without a username");
}
Comment thread
Copilot marked this conversation as resolved.
Resolved

if (username.startsWith(ServiceAccountConstants.SERVICE_ACCOUNT_USER_PREFIX)) {
// Don't use federation for service account user
return localStorage().addUser(realm, username);
Expand Down Expand Up @@ -831,6 +835,10 @@ public void preRemove(RealmModel realm, RoleModel role) {

@Override
public UserModel addUser(RealmModel realm, String id, String username, boolean addDefaultRoles, boolean addDefaultRequiredActions) {
if (StringUtil.isBlank(username)) {
throw new ModelException("Cannot create a user without a username");
}

return localStorage().addUser(realm, id, username.toLowerCase(), addDefaultRoles, addDefaultRequiredActions);
}

Expand Down
Loading
0