Add federated subject configuration option to federated-jwt authenticator (#42610)

Closes #42608

Signed-off-by: stianst <stianst@gmail.com>
This commit is contained in:
Stian Thorgersen
2025-09-17 13:39:50 +02:00
committed by GitHub
parent f7ff7e55d8
commit f9ee040ef0
35 changed files with 504 additions and 225 deletions

View File

@@ -16,7 +16,7 @@ export const SpiffeSettings = () => {
/>
<TextControl
name="config.trustDomain"
name="config.issuer"
label={t("spiffeTrustDomain")}
rules={{
required: t("required"),

View File

@@ -50,7 +50,7 @@ export async function createSPIFFEProvider(
bundleEndpoint: string,
) {
await clickProviderCard(page, providerName);
await page.getByTestId("config.trustDomain").fill(trustDomain);
await page.getByTestId("config.issuer").fill(trustDomain);
await page.getByTestId("config.bundleEndpoint").fill(bundleEndpoint);
await clickAddButton(page);
}

View File

@@ -15,7 +15,13 @@ test.afterAll(() => adminClient.deleteIdentityProvider("spiffe"));
test.describe.serial("SPIFFE identity provider test", () => {
test("should create a SPIFFE provider", async ({ page }) => {
await createSPIFFEProvider(page, "spiffe", "mytrust", "https://mytrust");
await createSPIFFEProvider(
page,
"spiffe",
"spiffe://mytrust2",
"https://mytrust",
);
await assertNotificationMessage(
page,
"Identity provider successfully created",
@@ -24,7 +30,7 @@ test.describe.serial("SPIFFE identity provider test", () => {
await goToIdentityProviders(page);
await clickTableRowItem(page, "Spiffe");
await page.getByTestId("config.trustDomain").fill("mytrust2");
await page.getByTestId("config.issuer").fill("spiffe://mytrust2");
await page.getByTestId("config.bundleEndpoint").fill("https://mytrust2");
await clickSaveButton(page);

View File

@@ -18,7 +18,6 @@
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;
@@ -34,7 +33,6 @@ 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>
@@ -129,13 +127,6 @@ 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,7 +41,6 @@ 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;
@@ -61,7 +60,6 @@ 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;
@@ -1312,16 +1310,6 @@ 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

@@ -46,6 +46,7 @@ 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,
"jwt.credential.issuer",
"jwt.credential.sub"
);

View File

@@ -979,16 +979,6 @@ 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,19 +157,6 @@ 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

@@ -0,0 +1,16 @@
package org.keycloak.cache;
import org.keycloak.models.ClientModel;
import org.keycloak.models.IdentityProviderModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.provider.Provider;
import java.util.Map;
public interface AlternativeLookupProvider extends Provider {
IdentityProviderModel lookupIdentityProviderFromIssuer(KeycloakSession session, String issuerUrl);
ClientModel lookupClientFromClientAttributes(KeycloakSession session, Map<String, String> attributes);
}

View File

@@ -0,0 +1,6 @@
package org.keycloak.cache;
import org.keycloak.provider.ProviderFactory;
public interface AlternativeLookupProviderFactory extends ProviderFactory<AlternativeLookupProvider> {
}

View File

@@ -0,0 +1,28 @@
package org.keycloak.cache;
import org.keycloak.provider.Provider;
import org.keycloak.provider.ProviderFactory;
import org.keycloak.provider.Spi;
public class AlternativeLookupSPI implements Spi {
@Override
public boolean isInternal() {
return true;
}
@Override
public String getName() {
return "alternativeLookup";
}
@Override
public Class<? extends Provider> getProviderClass() {
return AlternativeLookupProvider.class;
}
@Override
public Class<? extends ProviderFactory> getProviderFactoryClass() {
return AlternativeLookupProviderFactory.class;
}
}

View File

@@ -109,3 +109,4 @@ org.keycloak.models.workflow.WorkflowStepSpi
org.keycloak.models.workflow.WorkflowSpi
org.keycloak.models.workflow.WorkflowConditionSpi
org.keycloak.protocol.oidc.rar.AuthorizationDetailsProcessorSpi
org.keycloak.cache.AlternativeLookupSPI

View File

@@ -34,6 +34,7 @@ public class IdentityProviderModel implements Serializable {
public static final String ALIAS = "alias";
public static final String ALIAS_NOT_IN = "aliasNotIn";
public static final String ISSUER = "issuer";
public static final String ALLOWED_CLOCK_SKEW = "allowedClockSkew";
public static final String AUTHENTICATE_BY_DEFAULT = "authenticateByDefault";
public static final String CASE_SENSITIVE_ORIGINAL_USERNAME = "caseSensitiveOriginalUsername";

View File

@@ -48,17 +48,6 @@ 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

@@ -112,8 +112,15 @@ public abstract class AbstractJWTClientValidator {
return failure("client_id parameter does not match sub claim");
}
String expectedTokenIssuer = getExpectedTokenIssuer();
if (expectedTokenIssuer != null && !expectedTokenIssuer.equals(token.getIssuer())) {
return false;
}
context.getEvent().client(clientId);
client = realm.getClientByClientId(clientId);
client = clientAssertionState.getClient();
if (client == null) {
return failure(AuthenticationFlowError.CLIENT_NOT_FOUND);
} else {
@@ -129,11 +136,6 @@ public abstract class AbstractJWTClientValidator {
return false;
}
String expectedTokenIssuer = getExpectedTokenIssuer();
if (expectedTokenIssuer != null && !expectedTokenIssuer.equals(token.getIssuer())) {
return failure("Invalid token issuer", Response.Status.UNAUTHORIZED.getStatusCode());
}
return true;
}

View File

@@ -7,24 +7,31 @@ import org.keycloak.authentication.ClientAuthenticationFlowContextSupplier;
import org.keycloak.events.Details;
import org.keycloak.jose.jws.JWSInput;
import org.keycloak.jose.jws.JWSInputException;
import org.keycloak.models.ClientModel;
import org.keycloak.representations.JsonWebToken;
public class ClientAssertionState {
private static final Supplier SUPPLIER = new Supplier();
private ClientModel client;
private final String clientAssertionType;
private final String clientAssertion;
private final JWSInput jws;
private final JsonWebToken token;
public ClientAssertionState(String clientAssertionType, String clientAssertion, JWSInput jws, JsonWebToken token) {
public ClientAssertionState(ClientModel client, String clientAssertionType, String clientAssertion, JWSInput jws, JsonWebToken token) {
this.client = client;
this.clientAssertionType = clientAssertionType;
this.clientAssertion = clientAssertion;
this.jws = jws;
this.token = token;
}
public void setClient(ClientModel client) {
this.client = client;
}
public String getClientAssertionType() {
return clientAssertionType;
}
@@ -41,6 +48,10 @@ public class ClientAssertionState {
return token;
}
public ClientModel getClient() {
return client;
}
public static ClientAuthenticationFlowContextSupplier<ClientAssertionState> supplier() {
return SUPPLIER;
}
@@ -56,6 +67,9 @@ public class ClientAssertionState {
JWSInput jws = null;
JsonWebToken token = null;
ClientModel client = null;
if (clientAssertion != null) {
jws = new JWSInput(clientAssertion);
token = jws.readJsonContent(JsonWebToken.class);
@@ -64,9 +78,13 @@ public class ClientAssertionState {
event.detail(Details.CLIENT_ASSERTION_ID, token.getId());
event.detail(Details.CLIENT_ASSERTION_ISSUER, token.getIssuer());
event.detail(Details.CLIENT_ASSERTION_SUB, token.getSubject());
if (token.getSubject() != null) {
client = context.getRealm().getClientByClientId(token.getSubject());
}
}
return new ClientAssertionState(clientAssertionType, clientAssertion, jws, token);
return new ClientAssertionState(client, clientAssertionType, clientAssertion, jws, token);
}
}

View File

@@ -9,14 +9,18 @@ import org.keycloak.authentication.ConfigurableAuthenticatorFactory;
import org.keycloak.broker.provider.ClientAssertionIdentityProvider;
import org.keycloak.broker.provider.IdentityProvider;
import org.keycloak.broker.spiffe.SpiffeConstants;
import org.keycloak.cache.AlternativeLookupProvider;
import org.keycloak.common.Profile;
import org.keycloak.models.AuthenticationExecutionModel;
import org.keycloak.models.ClientModel;
import org.keycloak.models.IdentityProviderModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.provider.EnvironmentDependentProviderFactory;
import org.keycloak.provider.ProviderConfigProperty;
import org.keycloak.representations.JsonWebToken;
import org.keycloak.services.resources.IdentityBrokerService;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.Collections;
import java.util.List;
import java.util.Map;
@@ -29,9 +33,11 @@ public class FederatedJWTClientAuthenticator extends AbstractClientAuthenticator
public static final String PROVIDER_ID = "federated-jwt";
public static final String JWT_CREDENTIAL_ISSUER_KEY = "jwt.credential.issuer";
public static final String JWT_CREDENTIAL_SUBJECT_KEY = "jwt.credential.sub";
private static final List<ProviderConfigProperty> CONFIG = List.of(
new ProviderConfigProperty(JWT_CREDENTIAL_ISSUER_KEY, "Identity provider", "Issuer of the client assertion", ProviderConfigProperty.STRING_TYPE, null)
private static final List<ProviderConfigProperty> CLIENT_CONFIG = List.of(
new ProviderConfigProperty(JWT_CREDENTIAL_ISSUER_KEY, "Identity provider", "Issuer of the client assertion", ProviderConfigProperty.STRING_TYPE, null),
new ProviderConfigProperty(JWT_CREDENTIAL_SUBJECT_KEY, "Federated subject", "External clientId (subject)", ProviderConfigProperty.STRING_TYPE, null)
);
private static final Set<String> SUPPORTED_ASSERTION_TYPES = Set.of(OAuth2Constants.CLIENT_ASSERTION_TYPE_JWT, SpiffeConstants.CLIENT_ASSERTION_TYPE);
@@ -42,24 +48,36 @@ public class FederatedJWTClientAuthenticator extends AbstractClientAuthenticator
}
@Override
// TODO Should share code with JWTClientAuthenticator/JWTClientValidator rather than duplicating, but that requires quite a bit of refactoring
public void authenticateClient(ClientAuthenticationFlowContext context) {
try {
ClientAssertionState clientAssertionState = context.getState(ClientAssertionState.class, ClientAssertionState.supplier());
JsonWebToken token = clientAssertionState.getToken();
if (!SUPPORTED_ASSERTION_TYPES.contains(clientAssertionState.getClientAssertionType())) {
return;
}
ClientModel client = lookupClient(context, token.getSubject());
final String issuer = clientAssertionState.getToken().getIssuer() != null ?
clientAssertionState.getToken().getIssuer() :
toIssuer(clientAssertionState.getToken().getSubject());
if (issuer == null) return;
AlternativeLookupProvider lookupProvider = context.getSession().getProvider(AlternativeLookupProvider.class);
IdentityProviderModel identityProviderModel = lookupProvider.lookupIdentityProviderFromIssuer(context.getSession(), issuer);
ClientAssertionIdentityProvider identityProvider = getClientAssertionIdentityProvider(context.getSession(), identityProviderModel);
if (identityProvider == null) return;
String federatedClientId = clientAssertionState.getToken().getSubject();
ClientModel client = lookupProvider.lookupClientFromClientAttributes(
context.getSession(),
Map.of(FederatedJWTClientAuthenticator.JWT_CREDENTIAL_ISSUER_KEY, identityProviderModel.getAlias(), FederatedJWTClientAuthenticator.JWT_CREDENTIAL_SUBJECT_KEY, federatedClientId));
if (client == null) return;
if (!PROVIDER_ID.equals(client.getClientAuthenticatorType())) {
return;
}
clientAssertionState.setClient(client);
if (!PROVIDER_ID.equals(client.getClientAuthenticatorType())) return;
ClientAssertionIdentityProvider identityProvider = lookupIdentityProvider(context, client);
if (identityProvider.verifyClientAssertion(context)) {
context.success();
} else {
@@ -71,22 +89,11 @@ public class FederatedJWTClientAuthenticator extends AbstractClientAuthenticator
}
}
private ClientModel lookupClient(ClientAuthenticationFlowContext context, String subject) {
ClientModel client = context.getRealm().getClientByClientId(subject);
if (client == null) {
context.failure(AuthenticationFlowError.CLIENT_NOT_FOUND);
private ClientAssertionIdentityProvider getClientAssertionIdentityProvider(KeycloakSession session, IdentityProviderModel identityProviderModel) {
if (identityProviderModel == null) {
return null;
}
if (!client.isEnabled()) {
context.failure(AuthenticationFlowError.CLIENT_DISABLED);
return null;
}
return client;
}
private ClientAssertionIdentityProvider lookupIdentityProvider(ClientAuthenticationFlowContext context, ClientModel client) {
String idpAlias = client.getAttribute(FederatedJWTClientAuthenticator.JWT_CREDENTIAL_ISSUER_KEY);
IdentityProvider<?> identityProvider = IdentityBrokerService.getIdentityProvider(context.getSession(), idpAlias);
IdentityProvider<?> identityProvider = IdentityBrokerService.getIdentityProvider(session, identityProviderModel);
if (identityProvider instanceof ClientAssertionIdentityProvider clientAssertionProvider) {
return clientAssertionProvider;
} else {
@@ -121,7 +128,7 @@ public class FederatedJWTClientAuthenticator extends AbstractClientAuthenticator
@Override
public List<ProviderConfigProperty> getConfigPropertiesPerClient() {
return CONFIG;
return CLIENT_CONFIG;
}
@Override
@@ -139,4 +146,15 @@ public class FederatedJWTClientAuthenticator extends AbstractClientAuthenticator
return Profile.isFeatureEnabled(Profile.Feature.CLIENT_AUTH_FEDERATED);
}
protected static String toIssuer(String subject) {
try {
URI uri = new URI(subject);
String scheme = uri.getScheme();
String authority = uri.getRawAuthority();
return scheme != null && authority != null ? scheme + "://" + authority : null;
} catch (URISyntaxException e) {
return null;
}
}
}

View File

@@ -22,7 +22,7 @@ public class JWTClientValidator extends AbstractJWTClientValidator {
@Override
protected String getExpectedTokenIssuer() {
return client.getClientId();
return clientAssertionState.getToken().getSubject();
}
@Override

View File

@@ -32,7 +32,6 @@ public class OIDCIdentityProviderConfig extends OAuth2IdentityProviderConfig {
public static final String USE_JWKS_URL = "useJwksUrl";
public static final String VALIDATE_SIGNATURE = "validateSignature";
public static final String IS_ACCESS_TOKEN_JWT = "isAccessTokenJWT";
public static final String ISSUER = "issuer";
public static final String SUPPORTS_CLIENT_ASSERTIONS = "supportsClientAssertions";
public static final String SUPPORTS_CLIENT_ASSERTION_REUSE = "supportsClientAssertionReuse";

View File

@@ -67,13 +67,7 @@ public class SpiffeIdentityProvider implements IdentityProvider<SpiffeIdentityPr
String trustedDomain = config.getTrustDomain();
JsonWebToken token = validator.getState().getToken();
URI uri = URI.create(token.getSubject());
if (!uri.getScheme().equals("spiffe")) {
throw new RuntimeException("Not a SPIFFE ID");
}
if (!uri.getRawAuthority().equals(trustedDomain)) {
if (!token.getSubject().startsWith(trustedDomain + "/")) {
throw new RuntimeException("Invalid trust-domain");
}

View File

@@ -10,10 +10,9 @@ import static org.keycloak.common.util.UriUtils.checkUrl;
public class SpiffeIdentityProviderConfig extends IdentityProviderModel {
public static final String TRUST_DOMAIN_KEY = "trustDomain";
public static final String BUNDLE_ENDPOINT_KEY = "bundleEndpoint";
private static final Pattern TRUST_DOMAIN_PATTERN = Pattern.compile("[a-z0-9.\\-_]*");
private static final Pattern TRUST_DOMAIN_PATTERN = Pattern.compile("spiffe://[a-z0-9.\\-_]*");
public SpiffeIdentityProviderConfig() {
getConfig().put(IdentityProviderModel.SHOW_IN_ACCOUNT_CONSOLE, IdentityProviderShowInAccountConsole.NEVER.name());
@@ -42,7 +41,7 @@ public class SpiffeIdentityProviderConfig extends IdentityProviderModel {
}
public String getTrustDomain() {
return getConfig().get(TRUST_DOMAIN_KEY);
return getConfig().get(ISSUER);
}
public String getBundleEndpoint() {

View File

@@ -0,0 +1,40 @@
package org.keycloak.cache;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Map;
class ComputedKey {
private ComputedKey() {
}
public static String computeKey(String realm, String type, String alternativeKey) {
MessageDigest md = getMessageDigest();
md.update(realm.getBytes(StandardCharsets.UTF_8));
md.update(type.getBytes(StandardCharsets.UTF_8));
md.update(alternativeKey.getBytes(StandardCharsets.UTF_8));
return new String(md.digest(), StandardCharsets.UTF_8);
}
public static String computeKey(String realm, String type, Map<String, String> attributes) {
MessageDigest md = getMessageDigest();
md.update(realm.getBytes(StandardCharsets.UTF_8));
md.update(type.getBytes(StandardCharsets.UTF_8));
attributes.entrySet().stream().sorted(Map.Entry.comparingByKey()).forEach(e -> {
md.update(e.getKey().getBytes(StandardCharsets.UTF_8));
md.update(e.getValue().getBytes(StandardCharsets.UTF_8));
});
return new String(md.digest(), StandardCharsets.UTF_8);
}
private static MessageDigest getMessageDigest() {
try {
return MessageDigest.getInstance("MD5");
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException(e);
}
}
}

View File

@@ -0,0 +1,78 @@
package org.keycloak.cache;
import com.github.benmanes.caffeine.cache.Cache;
import org.keycloak.models.ClientModel;
import org.keycloak.models.IdentityProviderModel;
import org.keycloak.models.KeycloakSession;
import java.util.List;
import java.util.Map;
public class DefaultAlternativeLookupProvider implements AlternativeLookupProvider {
private final Cache<String, String> lookupCache;
public DefaultAlternativeLookupProvider(Cache<String, String> lookupCache) {
this.lookupCache = lookupCache;
}
public IdentityProviderModel lookupIdentityProviderFromIssuer(KeycloakSession session, String issuerUrl) {
String alternativeKey = ComputedKey.computeKey(session.getContext().getRealm().getId(), "idp", issuerUrl);
String cachedIdpAlias = lookupCache.getIfPresent(alternativeKey);
if (cachedIdpAlias != null) {
IdentityProviderModel idp = session.identityProviders().getByAlias(cachedIdpAlias);
if (idp != null && issuerUrl.equals(idp.getConfig().get(IdentityProviderModel.ISSUER))) {
return idp;
} else {
lookupCache.invalidate(alternativeKey);
}
}
IdentityProviderModel idp = session.identityProviders().getAllStream()
.filter(i -> issuerUrl.equals(i.getConfig().get(IdentityProviderModel.ISSUER)))
.findFirst().orElse(null);
if (idp != null && idp.getAlias() != null) {
lookupCache.put(alternativeKey, idp.getAlias());
}
return idp;
}
public ClientModel lookupClientFromClientAttributes(KeycloakSession session, Map<String, String> attributes) {
String alternativeKey = ComputedKey.computeKey(session.getContext().getRealm().getId(), "client", attributes);
String cachedClientId = lookupCache.getIfPresent(alternativeKey);
if (cachedClientId != null) {
ClientModel client = session.clients().getClientByClientId(session.getContext().getRealm(), cachedClientId);
boolean match = client != null;
if (match) {
for (Map.Entry<String, String> e : attributes.entrySet()) {
if (!e.getValue().equals(client.getAttribute(e.getKey()))) {
match = false;
break;
}
}
}
if (match) {
return client;
} else {
lookupCache.invalidate(alternativeKey);
}
}
ClientModel client = null;
List<ClientModel> clients = session.clients().searchClientsByAttributes(session.getContext().getRealm(), attributes, 0, 2).toList();
if (clients.size() == 1) {
client = clients.get(0);
lookupCache.put(alternativeKey, client.getClientId());
} else if (clients.size() > 1) {
throw new RuntimeException("Multiple clients matches attributes");
}
return client;
}
@Override
public void close() {
}
}

View File

@@ -0,0 +1,43 @@
package org.keycloak.cache;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import org.keycloak.Config;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
import java.util.concurrent.TimeUnit;
public class DefaultAlternativeLookupProviderFactory implements AlternativeLookupProviderFactory {
private Cache<String, String> lookupCache;
@Override
public String getId() {
return "default";
}
@Override
public AlternativeLookupProvider create(KeycloakSession session) {
return new DefaultAlternativeLookupProvider(lookupCache);
}
@Override
public void init(Config.Scope config) {
Integer maximumSize = config.getInt("maximumSize", 1000);
Integer expireAfter = config.getInt("expireAfter", 60);
this.lookupCache = Caffeine.newBuilder().maximumSize(maximumSize).expireAfterAccess(expireAfter, TimeUnit.MINUTES).build();
}
@Override
public void postInit(KeycloakSessionFactory factory) {
}
@Override
public void close() {
lookupCache.cleanUp();
lookupCache = null;
}
}

View File

@@ -1344,18 +1344,19 @@ public class IdentityBrokerService implements IdentityProvider.AuthenticationCal
public static IdentityProvider<?> getIdentityProvider(KeycloakSession session, String alias) {
IdentityProviderModel identityProviderModel = session.identityProviders().getByAlias(alias);
IdentityProvider<?> identityProvider = getIdentityProvider(session, identityProviderModel);
if (identityProvider == null) {
throw new IdentityBrokerException("Identity Provider [" + alias + "] not found.");
}
return identityProvider;
}
public static IdentityProvider<?> getIdentityProvider(KeycloakSession session, IdentityProviderModel identityProviderModel) {
if (identityProviderModel != null) {
IdentityProviderFactory<?> providerFactory = getIdentityProviderFactory(session, identityProviderModel);
if (providerFactory == null) {
throw new IdentityBrokerException("Could not find factory for identity provider [" + alias + "].");
}
return providerFactory.create(session, identityProviderModel);
return providerFactory != null ? providerFactory.create(session, identityProviderModel) : null;
}
throw new IdentityBrokerException("Identity Provider [" + alias + "] not found.");
return null;
}
private static IdentityProviderFactory<?> getIdentityProviderFactory(KeycloakSession session, IdentityProviderModel model) {

View File

@@ -0,0 +1 @@
org.keycloak.cache.DefaultAlternativeLookupProviderFactory

View File

@@ -0,0 +1,18 @@
package org.keycloak.authentication.authenticators.client;
import org.junit.Test;
import org.junit.jupiter.api.Assertions;
public class FederatedJWTClientAuthenticatorTest {
@Test
public void testToIssuer() {
Assertions.assertEquals("https://something", FederatedJWTClientAuthenticator.toIssuer("https://something/client"));
Assertions.assertEquals("spiffe://trust-domain", FederatedJWTClientAuthenticator.toIssuer("spiffe://trust-domain/the"));
Assertions.assertEquals("spiffe://trust-domain", FederatedJWTClientAuthenticator.toIssuer("spiffe://trust-domain/the/client"));
Assertions.assertNull(FederatedJWTClientAuthenticator.toIssuer("client"));
Assertions.assertNull(FederatedJWTClientAuthenticator.toIssuer("the/client"));
Assertions.assertNull(FederatedJWTClientAuthenticator.toIssuer("spiffe:/the/client"));
}
}

View File

@@ -0,0 +1,32 @@
package org.keycloak.cache;
import org.junit.Test;
import org.junit.jupiter.api.Assertions;
import java.util.Map;
public class ComputedKeyTest {
@Test
public void testComputedKeyWithStrings() {
String k1 = ComputedKey.computeKey("realm", "type", "key1");
Assertions.assertEquals(k1, ComputedKey.computeKey("realm", "type", "key1"));
Assertions.assertNotEquals(k1, ComputedKey.computeKey("realm2", "type", "key"));
Assertions.assertNotEquals(k1, ComputedKey.computeKey("realm", "type2", "key"));
Assertions.assertNotEquals(k1, ComputedKey.computeKey("realm", "type", "key2"));
}
@Test
public void testComputedKeyWithAttributes() {
String k1 = ComputedKey.computeKey("realm", "type", Map.of("one", "one", "two", "two"));
Assertions.assertEquals(k1, ComputedKey.computeKey("realm", "type", Map.of("one", "one", "two", "two")));
Assertions.assertEquals(k1, ComputedKey.computeKey("realm", "type", Map.of("two", "two", "one", "one")));
Assertions.assertNotEquals(k1, ComputedKey.computeKey("realm2", "type", Map.of("one", "one", "two", "two")));
Assertions.assertNotEquals(k1, ComputedKey.computeKey("realm", "type2", Map.of("one", "one", "two", "two")));
Assertions.assertNotEquals(k1, ComputedKey.computeKey("realm", "type", Map.of("one2", "one", "two", "two")));
Assertions.assertNotEquals(k1, ComputedKey.computeKey("realm", "type", Map.of("one", "one2", "two", "two")));
Assertions.assertNotEquals(k1, ComputedKey.computeKey("realm", "type", Map.of("one", "one", "two2", "two")));
Assertions.assertNotEquals(k1, ComputedKey.computeKey("realm", "type", Map.of("one", "one", "two", "two2")));
}
}

View File

@@ -1,90 +0,0 @@
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

@@ -69,7 +69,8 @@ public class FederatedClientAuthFromKeycloakTest {
return client.clientId("myclient")
.serviceAccountsEnabled(true)
.authenticatorType(FederatedJWTClientAuthenticator.PROVIDER_ID)
.attribute(FederatedJWTClientAuthenticator.JWT_CREDENTIAL_ISSUER_KEY, IDP_ALIAS);
.attribute(FederatedJWTClientAuthenticator.JWT_CREDENTIAL_ISSUER_KEY, IDP_ALIAS)
.attribute(FederatedJWTClientAuthenticator.JWT_CREDENTIAL_SUBJECT_KEY, "myclient");
}
}

View File

@@ -0,0 +1,116 @@
package org.keycloak.tests.client.authentication.external;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.keycloak.authentication.authenticators.client.FederatedJWTClientAuthenticator;
import org.keycloak.broker.oidc.OIDCIdentityProviderConfig;
import org.keycloak.broker.oidc.OIDCIdentityProviderFactory;
import org.keycloak.common.util.Time;
import org.keycloak.representations.AccessToken;
import org.keycloak.representations.JsonWebToken;
import org.keycloak.testframework.annotations.InjectRealm;
import org.keycloak.testframework.annotations.KeycloakIntegrationTest;
import org.keycloak.testframework.oauth.OAuthClient;
import org.keycloak.testframework.oauth.OAuthIdentityProvider;
import org.keycloak.testframework.oauth.annotations.InjectOAuthClient;
import org.keycloak.testframework.oauth.annotations.InjectOAuthIdentityProvider;
import org.keycloak.testframework.realm.ClientConfigBuilder;
import org.keycloak.testframework.realm.ManagedRealm;
import org.keycloak.testframework.realm.RealmConfig;
import org.keycloak.testframework.realm.RealmConfigBuilder;
import org.keycloak.testsuite.util.IdentityProviderBuilder;
import org.keycloak.testsuite.util.oauth.AccessTokenResponse;
import java.util.UUID;
@KeycloakIntegrationTest(config = ClientAuthIdpServerConfig.class)
public class FederatedClientAuthMappingTest {
private static final String IDP_ALIAS = "external-idp";
@InjectRealm(config = ExernalClientAuthRealmConfig.class)
protected ManagedRealm realm;
@InjectOAuthClient
OAuthClient oAuthClient;
@InjectOAuthIdentityProvider
OAuthIdentityProvider identityProvider;
@Test
public void testSimple() {
Assertions.assertTrue(doClientGrant(createDefaultToken("external-simple-1"), "internal-simple-1"));
Assertions.assertTrue(doClientGrant(createDefaultToken("external-simple-2"), "internal-simple-2"));
}
@Test
public void testUrn() {
Assertions.assertTrue(doClientGrant(createDefaultToken("spiffe://client/urn"), "internal-urn"));
}
@Test
public void testUri() {
Assertions.assertTrue(doClientGrant(createDefaultToken("bf4c696e-89dc-4e40-a833-90fa5f8786e0"), "internal-uuid"));
}
@Test
public void testDuplicatedExternal() {
Assertions.assertFalse(doClientGrant(createDefaultToken("external-duplicated"), null));
}
private boolean doClientGrant(JsonWebToken token, String expectedInternalClientId) {
String jws = identityProvider.encodeToken(token);
AccessTokenResponse response = oAuthClient.clientCredentialsGrantRequest().clientJwt(jws).send();
if (response.isSuccess()) {
AccessToken accessToken = oAuthClient.parseToken(response.getAccessToken(), AccessToken.class);
Assertions.assertEquals(expectedInternalClientId, accessToken.getIssuedFor());
}
return response.isSuccess();
}
private JsonWebToken createDefaultToken(String externalClientId) {
JsonWebToken token = new JsonWebToken();
token.id(UUID.randomUUID().toString());
token.issuer("http://127.0.0.1:8500");
token.audience(oAuthClient.getEndpoints().getIssuer());
token.iat((long) Time.currentTime());
token.exp((long) (Time.currentTime() + 300));
token.subject(externalClientId);
return token;
}
public static class ExernalClientAuthRealmConfig implements RealmConfig {
@Override
public RealmConfigBuilder configure(RealmConfigBuilder realm) {
realm.identityProvider(
IdentityProviderBuilder.create()
.providerId(OIDCIdentityProviderFactory.PROVIDER_ID)
.alias(IDP_ALIAS)
.setAttribute("issuer", "http://127.0.0.1:8500")
.setAttribute(OIDCIdentityProviderConfig.USE_JWKS_URL, "true")
.setAttribute(OIDCIdentityProviderConfig.JWKS_URL, "http://127.0.0.1:8500/idp/jwks")
.setAttribute(OIDCIdentityProviderConfig.VALIDATE_SIGNATURE, "true")
.setAttribute(OIDCIdentityProviderConfig.SUPPORTS_CLIENT_ASSERTIONS, "true")
.build());
createClient(realm.addClient("internal-simple-1"), "external-simple-1");
createClient(realm.addClient("internal-simple-2"), "external-simple-2");
createClient(realm.addClient("internal-urn"), "spiffe://client/urn");
createClient(realm.addClient("internal-uuid"), "bf4c696e-89dc-4e40-a833-90fa5f8786e0");
createClient(realm.addClient("internal-duplicated-1"), "external-duplicated");
createClient(realm.addClient("internal-duplicated-2"), "external-duplicated");
return realm;
}
private static void createClient(ClientConfigBuilder client, String externalId) {
client.serviceAccountsEnabled(true)
.authenticatorType(FederatedJWTClientAuthenticator.PROVIDER_ID)
.attribute(FederatedJWTClientAuthenticator.JWT_CREDENTIAL_ISSUER_KEY, IDP_ALIAS)
.attribute(FederatedJWTClientAuthenticator.JWT_CREDENTIAL_SUBJECT_KEY, externalId);
}
}
}

View File

@@ -7,6 +7,7 @@ import org.keycloak.authentication.authenticators.client.FederatedJWTClientAuthe
import org.keycloak.broker.oidc.OIDCIdentityProviderConfig;
import org.keycloak.broker.oidc.OIDCIdentityProviderFactory;
import org.keycloak.common.util.Time;
import org.keycloak.representations.AccessToken;
import org.keycloak.representations.JsonWebToken;
import org.keycloak.representations.idm.IdentityProviderRepresentation;
import org.keycloak.testframework.annotations.InjectClient;
@@ -32,7 +33,8 @@ public class FederatedClientAuthTest {
private static final String IDP_ALIAS = "external-idp";
private static final String CLIENT_ID = "myclient";
private static final String INTERNAL_CLIENT_ID = "internal-myclient";
private static final String EXTERNAL_CLIENT_ID = "external-myclient";
@InjectRealm(config = ExernalClientAuthRealmConfig.class)
protected ManagedRealm realm;
@@ -142,12 +144,13 @@ public class FederatedClientAuthTest {
rep.getConfig().put(OIDCIdentityProviderConfig.SUPPORTS_CLIENT_ASSERTION_REUSE, "true");
idp.update(rep);
JsonWebToken token = createDefaultToken();
Assertions.assertTrue(doClientGrant(token));
Assertions.assertTrue(doClientGrant(token));
rep.getConfig().remove(OIDCIdentityProviderConfig.SUPPORTS_CLIENT_ASSERTION_REUSE);
idp.update(rep);
try {
JsonWebToken token = createDefaultToken();
Assertions.assertTrue(doClientGrant(token));
} finally {
rep.getConfig().remove(OIDCIdentityProviderConfig.SUPPORTS_CLIENT_ASSERTION_REUSE);
idp.update(rep);
}
}
@Test
@@ -170,6 +173,10 @@ public class FederatedClientAuthTest {
private boolean doClientGrant(String jws) {
AccessTokenResponse response = oAuthClient.clientCredentialsGrantRequest().clientJwt(jws).send();
if (response.isSuccess()) {
AccessToken accessToken = oAuthClient.parseToken(response.getAccessToken(), AccessToken.class);
Assertions.assertEquals(INTERNAL_CLIENT_ID, accessToken.getIssuedFor());
}
return response.isSuccess();
}
@@ -180,7 +187,7 @@ public class FederatedClientAuthTest {
token.audience(oAuthClient.getEndpoints().getIssuer());
token.iat((long) Time.currentTime());
token.exp((long) (Time.currentTime() + 300));
token.subject(CLIENT_ID);
token.subject(EXTERNAL_CLIENT_ID);
return token;
}
@@ -205,10 +212,11 @@ public class FederatedClientAuthTest {
@Override
public ClientConfigBuilder configure(ClientConfigBuilder client) {
return client.clientId(CLIENT_ID)
return client.clientId(INTERNAL_CLIENT_ID)
.serviceAccountsEnabled(true)
.authenticatorType(FederatedJWTClientAuthenticator.PROVIDER_ID)
.attribute(FederatedJWTClientAuthenticator.JWT_CREDENTIAL_ISSUER_KEY, IDP_ALIAS);
.attribute(FederatedJWTClientAuthenticator.JWT_CREDENTIAL_ISSUER_KEY, IDP_ALIAS)
.attribute(FederatedJWTClientAuthenticator.JWT_CREDENTIAL_SUBJECT_KEY, EXTERNAL_CLIENT_ID);
}
}

View File

@@ -12,6 +12,7 @@ import org.keycloak.broker.spiffe.SpiffeIdentityProviderConfig;
import org.keycloak.broker.spiffe.SpiffeIdentityProviderFactory;
import org.keycloak.common.Profile;
import org.keycloak.common.util.Time;
import org.keycloak.models.IdentityProviderModel;
import org.keycloak.representations.JsonWebToken;
import org.keycloak.representations.idm.IdentityProviderRepresentation;
import org.keycloak.testframework.annotations.InjectClient;
@@ -70,14 +71,14 @@ public class SpiffeClientAuthTest {
@Test
public void testInvalidConfig() {
testInvalidConfig("with-port:8080", "https://localhost");
testInvalidConfig("spiffe://with-spiffe-scheme", "https://localhost");
testInvalidConfig("with-spiffe-scheme", "https://localhost");
testInvalidConfig("valid", "invalid-url");
}
@Test
public void testInvalidTrustDomain() {
IdentityProviderUpdater.updateWithRollback(realm, IDP_ALIAS, rep -> {
rep.getConfig().put(SpiffeIdentityProviderConfig.TRUST_DOMAIN_KEY, "different-domain");
rep.getConfig().put(IdentityProviderModel.ISSUER, "spiffe://different-domain");
});
Assertions.assertFalse(doClientGrant(createDefaultToken()));
@@ -155,7 +156,7 @@ public class SpiffeClientAuthTest {
private void testInvalidConfig(String trustDomain, String bundleEndpoint) {
IdentityProviderRepresentation idp = IdentityProviderBuilder.create().providerId(SpiffeIdentityProviderFactory.PROVIDER_ID)
.alias("another")
.setAttribute(SpiffeIdentityProviderConfig.TRUST_DOMAIN_KEY, trustDomain)
.setAttribute(IdentityProviderModel.ISSUER, trustDomain)
.setAttribute(SpiffeIdentityProviderConfig.BUNDLE_ENDPOINT_KEY, bundleEndpoint).build();
try (Response r = realm.admin().identityProviders().create(idp)) {
@@ -187,7 +188,7 @@ public class SpiffeClientAuthTest {
IdentityProviderBuilder.create()
.providerId(SpiffeIdentityProviderFactory.PROVIDER_ID)
.alias(IDP_ALIAS)
.setAttribute(SpiffeIdentityProviderConfig.TRUST_DOMAIN_KEY, "mytrust-domain")
.setAttribute(IdentityProviderModel.ISSUER, "spiffe://mytrust-domain")
.setAttribute(SpiffeIdentityProviderConfig.BUNDLE_ENDPOINT_KEY, "http://127.0.0.1:8500/idp/jwks")
.build());
}
@@ -197,10 +198,11 @@ public class SpiffeClientAuthTest {
@Override
public ClientConfigBuilder configure(ClientConfigBuilder client) {
return client.clientId(CLIENT_ID)
return client.clientId("myclient")
.serviceAccountsEnabled(true)
.authenticatorType(FederatedJWTClientAuthenticator.PROVIDER_ID)
.attribute(FederatedJWTClientAuthenticator.JWT_CREDENTIAL_ISSUER_KEY, IDP_ALIAS);
.attribute(FederatedJWTClientAuthenticator.JWT_CREDENTIAL_ISSUER_KEY, IDP_ALIAS)
.attribute(FederatedJWTClientAuthenticator.JWT_CREDENTIAL_SUBJECT_KEY, CLIENT_ID);
}
}

View File

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

View File

@@ -748,7 +748,7 @@ public class ClientAuthSignedJWTTest extends AbstractClientAuthSignedJWTTest {
@Test
public void testMissingIssuerClaim() throws Exception {
AccessTokenResponse response = testMissingClaim("issuer");
assertError(response,401, "client1", OAuthErrorException.INVALID_CLIENT, Errors.INVALID_CLIENT_CREDENTIALS);
assertError(response,401, null, "invalid_client", Errors.CLIENT_NOT_FOUND);
}
@Test