Get client by client attribute

Closes #42543

Signed-off-by: stianst <stianst@gmail.com>
This commit is contained in:
Stian Thorgersen 2025-09-11 14:07:13 +02:00 committed by GitHub
parent d98c474cdc
commit 51465f52a3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 152 additions and 1 deletions

View File

@ -18,6 +18,7 @@
package org.keycloak.models.cache.infinispan;
import org.infinispan.Cache;
import org.infinispan.CacheStream;
import org.jboss.logging.Logger;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.cache.infinispan.events.InvalidationEvent;
@ -33,6 +34,7 @@ import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.locks.ReentrantLock;
import java.util.function.BiFunction;
import java.util.function.Predicate;
/**
* @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a>
@ -127,6 +129,13 @@ public class RealmCacheManager extends CacheManager {
((RealmCacheInvalidationEvent) event).addInvalidations(this, invalidations);
}
public <T> CacheStream<T> searchWithPredicate(Predicate<T> predicate, Class<T> tClass) {
return cache.values().stream()
.filter(tClass::isInstance)
.map(tClass::cast)
.filter(predicate);
}
/**
* Compute a cached realm and ensure that this happens only once with the current Keycloak instance.
* Use this to avoid concurrent preparations of a realm in parallel threads. This helps to break the load on

View File

@ -41,6 +41,7 @@ import org.keycloak.models.GroupModel.Type;
import org.keycloak.models.GroupProvider;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakTransaction;
import org.keycloak.models.ModelException;
import org.keycloak.models.RealmModel;
import org.keycloak.models.RealmProvider;
import org.keycloak.models.RoleModel;
@ -60,6 +61,7 @@ import org.keycloak.models.cache.infinispan.entities.ClientScopeListQuery;
import org.keycloak.models.cache.infinispan.entities.GroupListQuery;
import org.keycloak.models.cache.infinispan.entities.GroupNameQuery;
import org.keycloak.models.cache.infinispan.entities.RealmListQuery;
import org.keycloak.models.cache.infinispan.entities.Revisioned;
import org.keycloak.models.cache.infinispan.entities.RoleListQuery;
import org.keycloak.models.cache.infinispan.entities.RoleByNameQuery;
import org.keycloak.models.cache.infinispan.events.ClientAddedEvent;
@ -1310,6 +1312,16 @@ public class RealmCacheSession implements CacheRealmProvider {
}
}
@Override
public ClientModel getClientByAttribute(RealmModel realm, String name, String value) {
List<CachedClient> clients = cache.searchWithPredicate(c -> value.equals(c.getAttributes().get(name)), CachedClient.class).limit(2).toList();
return switch (clients.size()) {
case 0 -> getClientDelegate().getClientByAttribute(realm, name, value);
case 1 -> getClientById(realm, clients.get(0).getId());
default -> throw new ModelException("Multiple clients found with the same attribute name and value");
};
}
private ClientModel prepareCachedClientByClientId(RealmModel realm, String clientId, String cacheKey) {
ClientListQuery query = cache.get(cacheKey, ClientListQuery.class);
String id;

View File

@ -45,7 +45,8 @@ public class JpaClientProviderFactory implements ClientProviderFactory {
private static final List<String> REQUIRED_SEARCHABLE_ATTRIBUTES = Arrays.asList(
"saml_idp_initiated_sso_url_name",
SamlConfigAttributes.SAML_ARTIFACT_BINDING_IDENTIFIER
SamlConfigAttributes.SAML_ARTIFACT_BINDING_IDENTIFIER,
"jwt.credential.sub"
);
@Override

View File

@ -979,6 +979,16 @@ public class JpaRealmProvider implements RealmProvider, ClientProvider, ClientSc
return session.clients().getClientById(realm, id);
}
@Override
public ClientModel getClientByAttribute(RealmModel realm, String name, String value) {
List<ClientModel> clients = searchClientsByAttributes(realm, Map.of(name, value), 0, 2).toList();
return switch (clients.size()) {
case 0 -> null;
case 1 -> clients.get(0);
default -> throw new ModelException("Multiple clients found with the same attribute name and value");
};
}
@Override
public Stream<ClientModel> searchClientsByClientIdStream(RealmModel realm, String clientId, Integer firstResult, Integer maxResults) {
CriteriaBuilder builder = em.getCriteriaBuilder();

View File

@ -157,6 +157,19 @@ public class ClientStorageManager implements ClientProvider {
.orElse(null);
}
@Override
public ClientModel getClientByAttribute(RealmModel realm, String name, String value) {
ClientModel client = localStorage().getClientByAttribute(realm, name, value);
if (client != null) {
return client;
}
return getEnabledStorageProviders(session, realm, ClientLookupProvider.class)
.map(provider -> provider.getClientByAttribute(realm, name, value))
.filter(Objects::nonNull)
.findFirst()
.orElse(null);
}
@Override
public Stream<ClientModel> searchClientsByClientIdStream(RealmModel realm, String clientId, Integer firstResult, Integer maxResults) {
return query((p, f, m) -> p.searchClientsByClientIdStream(realm, clientId, f, m), realm, firstResult, maxResults);

View File

@ -48,6 +48,17 @@ public interface ClientLookupProvider {
*/
ClientModel getClientByClientId(RealmModel realm, String clientId);
/**
* Exact search for a client by an attribute. Throws an exception if
* multi clients are found.
*
* @param realm Realm to limit the search for clients.
* @param name The name of the client attribute
* @param value The value of the client attribute
* @return
*/
ClientModel getClientByAttribute(RealmModel realm, String name, String value);
/**
* Case-insensitive search for clients that contain the given string in their public client identifier.
* @param realm Realm to limit the search for clients.

View File

@ -0,0 +1,90 @@
package org.keycloak.tests.admin.client;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.keycloak.admin.client.Keycloak;
import org.keycloak.models.ClientModel;
import org.keycloak.models.cache.infinispan.ClientAdapter;
import org.keycloak.testframework.annotations.InjectAdminClient;
import org.keycloak.testframework.annotations.InjectRealm;
import org.keycloak.testframework.annotations.KeycloakIntegrationTest;
import org.keycloak.testframework.realm.ManagedRealm;
import org.keycloak.testframework.realm.RealmConfig;
import org.keycloak.testframework.realm.RealmConfigBuilder;
import org.keycloak.testframework.remote.providers.runonserver.FetchOnServer;
import org.keycloak.testframework.remote.providers.runonserver.FetchOnServerWrapper;
import org.keycloak.testframework.remote.runonserver.InjectRunOnServer;
import org.keycloak.testframework.remote.runonserver.RunOnServerClient;
import java.io.Serializable;
@KeycloakIntegrationTest
public class ClientByAttributesTest {
@InjectRealm(config = ClientByAttributesRealm.class)
ManagedRealm realm;
@InjectRunOnServer
RunOnServerClient runOnServer;
@InjectAdminClient
Keycloak adminClient;
@Test
public void lookupByAttribute() {
runOnServer.run(s -> {
ClientModel c = s.clients().getClientByAttribute(s.getContext().getRealm(), "jwt.credential.sub", "value1");
Assertions.assertEquals("client1", c.getClientId());
});
}
@Test
public void lookupByAttributeMultipleMatches() {
runOnServer.run(s -> {
try {
s.clients().getClientByAttribute(s.getContext().getRealm(), "jwt.credential.sub", "value2");
Assertions.fail("Expected exception");
} catch (Exception e) {
Assertions.assertEquals("Multiple clients found with the same attribute name and value", e.getMessage());
}
});
}
@Test
public void lookupByAttributeTestCached() {
CachedTimeStamp cachedTimeStamp = new CachedTimeStamp("value1");
Long cachedTimeStamp1 = runOnServer.fetch(cachedTimeStamp);
Assertions.assertEquals(cachedTimeStamp1, runOnServer.fetch(cachedTimeStamp));
realm.admin().clearRealmCache();
Assertions.assertNotEquals(cachedTimeStamp1, runOnServer.fetch(cachedTimeStamp));
}
public static class ClientByAttributesRealm implements RealmConfig {
@Override
public RealmConfigBuilder configure(RealmConfigBuilder realm) {
realm.addClient("client1").attribute("jwt.credential.sub", "value1");
realm.addClient("client2").attribute("jwt.credential.sub", "value2");
realm.addClient("client3").attribute("jwt.credential.sub", "value2");
return realm;
}
}
private record CachedTimeStamp(String jwtCredentialSub) implements FetchOnServerWrapper<Long>, Serializable {
@Override
public FetchOnServer getRunOnServer() {
return s -> {
ClientModel client = s.clients().getClientByAttribute(s.getContext().getRealm(), "jwt.credential.sub", jwtCredentialSub);
return ((ClientAdapter) client).getCacheTimestamp();
};
}
@Override
public Class<Long> getResultClass() {
return Long.class;
}
}
}

View File

@ -74,6 +74,11 @@ public class HardcodedClientStorageProvider implements ClientStorageProvider, Cl
return null;
}
@Override
public ClientModel getClientByAttribute(RealmModel realm, String name, String value) {
return null;
}
@Override
public void close() {