From a1bd1ab85536f0071dfa48786741267b928711b4 Mon Sep 17 00:00:00 2001 From: Dominik Schlosser Date: Mon, 25 May 2026 08:12:28 +0200 Subject: [PATCH] Introduce mechanism for different trust material sources (#48869) closes #48269 Signed-off-by: Dominik Schlosser Signed-off-by: mposolda Co-authored-by: mposolda --- .../TrustMaterialIdentityProvider.java | 33 +++ .../broker/provider/TrustMaterialRequest.java | 73 +++++ .../util/IdentityProviderTypeUtil.java | 2 + .../keycloak/models/IdentityProviderType.java | 1 + .../AttestationBasedClientAuthenticator.java | 81 ++--- .../broker/oidc/OIDCIdentityProvider.java | 29 +- .../provider/TrustMaterialResolver.java | 77 +++++ .../trust/DefaultTrustIdentityProvider.java | 75 +++++ .../DefaultTrustIdentityProviderConfig.java | 79 +++++ .../DefaultTrustIdentityProviderFactory.java | 81 +++++ .../DefaultTrustMaterialPublicKeyLoader.java | 54 ++++ .../keycloak/broker/trust/TrustKeyUtil.java | 23 ++ .../keys/loader/PublicKeyStorageManager.java | 3 + .../TrustedAttestationKeysLoader.java | 22 +- .../protocol/oidc/utils/JWKSServerUtils.java | 37 ++- ...ak.broker.provider.IdentityProviderFactory | 3 +- .../TrustMaterialIdentityProviderTest.java | 280 ++++++++++++++++++ ...estationBasedClientAuthenticationTest.java | 60 +++- 18 files changed, 907 insertions(+), 106 deletions(-) create mode 100644 server-spi-private/src/main/java/org/keycloak/broker/provider/TrustMaterialIdentityProvider.java create mode 100644 server-spi-private/src/main/java/org/keycloak/broker/provider/TrustMaterialRequest.java create mode 100644 services/src/main/java/org/keycloak/broker/provider/TrustMaterialResolver.java create mode 100644 services/src/main/java/org/keycloak/broker/trust/DefaultTrustIdentityProvider.java create mode 100644 services/src/main/java/org/keycloak/broker/trust/DefaultTrustIdentityProviderConfig.java create mode 100644 services/src/main/java/org/keycloak/broker/trust/DefaultTrustIdentityProviderFactory.java create mode 100644 services/src/main/java/org/keycloak/broker/trust/DefaultTrustMaterialPublicKeyLoader.java create mode 100644 services/src/main/java/org/keycloak/broker/trust/TrustKeyUtil.java create mode 100644 tests/base/src/test/java/org/keycloak/tests/broker/trust/TrustMaterialIdentityProviderTest.java diff --git a/server-spi-private/src/main/java/org/keycloak/broker/provider/TrustMaterialIdentityProvider.java b/server-spi-private/src/main/java/org/keycloak/broker/provider/TrustMaterialIdentityProvider.java new file mode 100644 index 00000000000..66313ffc4e9 --- /dev/null +++ b/server-spi-private/src/main/java/org/keycloak/broker/provider/TrustMaterialIdentityProvider.java @@ -0,0 +1,33 @@ +/* + * 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.broker.provider; + +import java.util.stream.Stream; + +import org.keycloak.jose.jwk.JWK; +import org.keycloak.models.IdentityProviderModel; + +/** + * Identity providers that expose reusable trust material for flows such as + * client attestation or OID4VCI key attestation. + */ +public interface TrustMaterialIdentityProvider extends IdentityProvider { + + Stream resolveKeys(TrustMaterialRequest request); + +} diff --git a/server-spi-private/src/main/java/org/keycloak/broker/provider/TrustMaterialRequest.java b/server-spi-private/src/main/java/org/keycloak/broker/provider/TrustMaterialRequest.java new file mode 100644 index 00000000000..fbb2e647b9d --- /dev/null +++ b/server-spi-private/src/main/java/org/keycloak/broker/provider/TrustMaterialRequest.java @@ -0,0 +1,73 @@ +/* + * 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.broker.provider; + +public class TrustMaterialRequest { + + private final String kid; + private final String algorithm; + private final String issuer; + + private TrustMaterialRequest(Builder builder) { + this.kid = builder.kid; + this.algorithm = builder.algorithm; + this.issuer = builder.issuer; + } + + public String getKid() { + return kid; + } + + public String getAlgorithm() { + return algorithm; + } + + public String getIssuer() { + return issuer; + } + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + + private String kid; + private String algorithm; + private String issuer; + + public Builder kid(String kid) { + this.kid = kid; + return this; + } + + public Builder algorithm(String algorithm) { + this.algorithm = algorithm; + return this; + } + + public Builder issuer(String issuer) { + this.issuer = issuer; + return this; + } + + public TrustMaterialRequest build() { + return new TrustMaterialRequest(this); + } + } +} diff --git a/server-spi-private/src/main/java/org/keycloak/broker/provider/util/IdentityProviderTypeUtil.java b/server-spi-private/src/main/java/org/keycloak/broker/provider/util/IdentityProviderTypeUtil.java index 32c8783f15b..d02ca899783 100644 --- a/server-spi-private/src/main/java/org/keycloak/broker/provider/util/IdentityProviderTypeUtil.java +++ b/server-spi-private/src/main/java/org/keycloak/broker/provider/util/IdentityProviderTypeUtil.java @@ -10,6 +10,7 @@ import org.keycloak.broker.provider.ClientAssertionIdentityProvider; import org.keycloak.broker.provider.ExchangeExternalToken; import org.keycloak.broker.provider.IdentityProvider; import org.keycloak.broker.provider.JWTAuthorizationGrantProvider; +import org.keycloak.broker.provider.TrustMaterialIdentityProvider; import org.keycloak.broker.provider.UserAuthenticationIdentityProvider; import org.keycloak.broker.social.SocialIdentityProvider; import org.keycloak.models.IdentityProviderCapability; @@ -80,6 +81,7 @@ public class IdentityProviderTypeUtil { return switch (type) { case USER_AUTHENTICATION -> UserAuthenticationIdentityProvider.class; case CLIENT_ASSERTION -> ClientAssertionIdentityProvider.class; + case TRUST_MATERIAL -> TrustMaterialIdentityProvider.class; case EXCHANGE_EXTERNAL_TOKEN -> ExchangeExternalToken.class; case JWT_AUTHORIZATION_GRANT -> JWTAuthorizationGrantProvider.class; case ANY -> IdentityProvider.class; diff --git a/server-spi/src/main/java/org/keycloak/models/IdentityProviderType.java b/server-spi/src/main/java/org/keycloak/models/IdentityProviderType.java index 9a4f3177448..1e3ce4ac86b 100644 --- a/server-spi/src/main/java/org/keycloak/models/IdentityProviderType.java +++ b/server-spi/src/main/java/org/keycloak/models/IdentityProviderType.java @@ -12,6 +12,7 @@ public enum IdentityProviderType { ANY, USER_AUTHENTICATION(USER_LINKING), CLIENT_ASSERTION, + TRUST_MATERIAL, EXCHANGE_EXTERNAL_TOKEN(USER_LINKING), JWT_AUTHORIZATION_GRANT(USER_LINKING); diff --git a/services/src/main/java/org/keycloak/authentication/authenticators/client/AttestationBasedClientAuthenticator.java b/services/src/main/java/org/keycloak/authentication/authenticators/client/AttestationBasedClientAuthenticator.java index 981a627d6a9..3feba75c6d9 100644 --- a/services/src/main/java/org/keycloak/authentication/authenticators/client/AttestationBasedClientAuthenticator.java +++ b/services/src/main/java/org/keycloak/authentication/authenticators/client/AttestationBasedClientAuthenticator.java @@ -34,6 +34,8 @@ import org.keycloak.Config; import org.keycloak.TokenVerifier; import org.keycloak.authentication.AuthenticationFlowError; import org.keycloak.authentication.ClientAuthenticationFlowContext; +import org.keycloak.broker.provider.TrustMaterialRequest; +import org.keycloak.broker.provider.TrustMaterialResolver; import org.keycloak.common.Profile; import org.keycloak.common.util.Base64Url; import org.keycloak.crypto.KeyUse; @@ -46,7 +48,6 @@ import org.keycloak.jose.jwk.JWKParser; import org.keycloak.jose.jws.Algorithm; import org.keycloak.jose.jws.JWSInput; import org.keycloak.models.AuthenticationExecutionModel; -import org.keycloak.models.AuthenticatorConfigModel; import org.keycloak.models.ClientModel; import org.keycloak.models.KeycloakSession; import org.keycloak.models.RealmModel; @@ -58,7 +59,6 @@ import org.keycloak.provider.ProviderConfigProperty; import org.keycloak.representations.JsonWebToken; import org.keycloak.saml.RandomSecret; import org.keycloak.services.ServicesLogger; -import org.keycloak.util.JsonSerialization; import org.keycloak.util.Strings; import org.keycloak.wellknown.WellKnownProvider; @@ -92,20 +92,9 @@ public class AttestationBasedClientAuthenticator extends AbstractClientAuthentic public static final String OAUTH_CLIENT_ATTESTATION_POP_JWT_TYPE = "oauth-client-attestation-pop+jwt"; /** - * The ClientAuthenticator needs to be aware of the public keys from the various Attesters it can trust. - * - * [ - * { - * "kty": "RSA", - * "kid": "openid-abca-attester-key", - * "use": "sig", - * "alg": "PS256", - * "n": "uVd8mEqXMp...aaVZNQ", - * "e": "AQAB" - * } - * ] + * Comma-separated aliases of trust-material identity providers that expose the trusted attester keys. */ - public static final String OAUTH_CLIENT_ATTESTATION_CONFIG_ATTESTER_JWKS = "attester_jwks"; + public static final String OAUTH_CLIENT_ATTESTATION_CONFIG_TRUST_IDPS = "attester_trust_idps"; @Override public String getId() { @@ -172,22 +161,23 @@ public class AttestationBasedClientAuthenticator extends AbstractClientAuthentic @Override public boolean isConfigurable() { - return true; + return false; } @Override public List getConfigProperties() { - ProviderConfigProperty jwks = new ProviderConfigProperty(); - jwks.setName(OAUTH_CLIENT_ATTESTATION_CONFIG_ATTESTER_JWKS); - jwks.setLabel("Attester JWKS"); - jwks.setType(ProviderConfigProperty.TEXT_TYPE); - jwks.setHelpText("JWKS containing trusted attester public keys"); - return List.of(jwks); + return List.of(); } @Override public List getConfigPropertiesPerClient() { - return List.of(); + ProviderConfigProperty trustIdps = new ProviderConfigProperty(); + trustIdps.setName(OAUTH_CLIENT_ATTESTATION_CONFIG_TRUST_IDPS); + trustIdps.setLabel("Attester trust identity providers"); + trustIdps.setType(ProviderConfigProperty.STRING_TYPE); + trustIdps.setRequired(true); + trustIdps.setHelpText("Comma-separated aliases of trust-material identity providers containing trusted attester public keys"); + return List.of(trustIdps); } @Override @@ -314,24 +304,23 @@ public class AttestationBasedClientAuthenticator extends AbstractClientAuthentic // Private --------------------------------------------------------------------------------------------------------- - private KeyWrapper findAttesterKey(ClientAuthenticationFlowContext context, String kid) { + private KeyWrapper findAttesterKey(ClientAuthenticationFlowContext context, String kid, String algorithm, String issuer) { if (Strings.isEmpty(kid)) throw new IllegalArgumentException("Invalid attester kid: " + kid); - AuthenticatorConfigModel configModel = context.getRealm().getAuthenticatorConfigByAlias(PROVIDER_ID); - if (configModel == null) - throw new IllegalStateException("No config for client authenticator: " + PROVIDER_ID); + String configValue = Optional.ofNullable(context.getClient()) + .map(client -> client.getAttribute(OAUTH_CLIENT_ATTESTATION_CONFIG_TRUST_IDPS)) + .orElse(null); + if (Strings.isEmpty(configValue)) + throw new IllegalStateException("Cannot load attester keys: " + OAUTH_CLIENT_ATTESTATION_CONFIG_TRUST_IDPS); - String configValue = Optional.ofNullable(configModel.getConfig()).orElse(Map.of()) - .get(OAUTH_CLIENT_ATTESTATION_CONFIG_ATTESTER_JWKS); - if (configValue == null) - throw new IllegalStateException("Cannot load attester keys: " + OAUTH_CLIENT_ATTESTATION_CONFIG_ATTESTER_JWKS); - - ABCAConfig attesterKeys = JsonSerialization.valueFromString(configValue, ABCAConfig.class); - JWK jwk = attesterKeys.getKeys().stream() - .filter(k -> kid.equals(k.getKeyId())) - .findAny() + TrustMaterialRequest request = TrustMaterialRequest.builder() + .kid(kid) + .algorithm(algorithm) + .issuer(issuer) + .build(); + JWK jwk = new TrustMaterialResolver().resolveKey(context.getSession(), configValue, request) .orElseThrow(() -> new IllegalStateException("No matching key found for kid: " + kid)); return toPublicKeyWrapper(jwk); @@ -401,7 +390,7 @@ public class AttestationBasedClientAuthenticator extends AbstractClientAuthentic // The signature of the Client Attestation JWT verifies with the public key of a known and trusted Attester // - KeyWrapper attesterKey = findAttesterKey(context, jws.getHeader().getKeyId()); + KeyWrapper attesterKey = findAttesterKey(context, jws.getHeader().getKeyId(), jws.getHeader().getRawAlgorithm(), attestationJwt.getIssuer()); // Client Attestation JWT verification without signature check // @@ -507,24 +496,6 @@ public class AttestationBasedClientAuthenticator extends AbstractClientAuthentic // [TODO] Additional checks to guarantee replay protection for the Client Attestation PoP JWT might need to be applied } - /** - * The AttestationBasedClientAuthenticator config - */ - public static class ABCAConfig { - - @JsonProperty - private List keys; - - public List getKeys() { - return keys; - } - - public ABCAConfig setKeys(List keys) { - this.keys = keys; - return this; - } - } - public static class ABCAResult { /** diff --git a/services/src/main/java/org/keycloak/broker/oidc/OIDCIdentityProvider.java b/services/src/main/java/org/keycloak/broker/oidc/OIDCIdentityProvider.java index d839f477f3f..91062fd26a4 100755 --- a/services/src/main/java/org/keycloak/broker/oidc/OIDCIdentityProvider.java +++ b/services/src/main/java/org/keycloak/broker/oidc/OIDCIdentityProvider.java @@ -24,6 +24,7 @@ import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Optional; +import java.util.stream.Stream; import jakarta.ws.rs.GET; import jakarta.ws.rs.Path; @@ -47,6 +48,9 @@ import org.keycloak.broker.provider.ClientAssertionIdentityProvider; import org.keycloak.broker.provider.ExchangeExternalToken; import org.keycloak.broker.provider.IdentityBrokerException; import org.keycloak.broker.provider.JWTAuthorizationGrantProvider; +import org.keycloak.broker.provider.TrustMaterialIdentityProvider; +import org.keycloak.broker.provider.TrustMaterialRequest; +import org.keycloak.broker.trust.TrustKeyUtil; import org.keycloak.common.Profile; import org.keycloak.common.util.Base64Url; import org.keycloak.common.util.SecretGenerator; @@ -65,6 +69,7 @@ import org.keycloak.jose.JOSE; import org.keycloak.jose.JOSEParser; import org.keycloak.jose.jwe.JWE; import org.keycloak.jose.jwe.JWEException; +import org.keycloak.jose.jwk.JWK; import org.keycloak.jose.jws.JWSInput; import org.keycloak.keys.PublicKeyStorageProvider; import org.keycloak.keys.PublicKeyStorageUtils; @@ -80,6 +85,7 @@ import org.keycloak.models.UserSessionModel; import org.keycloak.protocol.oidc.JWTAuthorizationGrantValidationContext; import org.keycloak.protocol.oidc.OIDCLoginProtocol; import org.keycloak.protocol.oidc.TokenExchangeContext; +import org.keycloak.protocol.oidc.utils.JWKSServerUtils; import org.keycloak.representations.AccessTokenResponse; import org.keycloak.representations.IDToken; import org.keycloak.representations.JsonWebToken; @@ -92,6 +98,7 @@ import org.keycloak.services.resources.RealmsResource; import org.keycloak.sessions.AuthenticationSessionModel; import org.keycloak.util.Booleans; import org.keycloak.util.JsonSerialization; +import org.keycloak.util.Strings; import org.keycloak.util.TokenUtil; import org.keycloak.vault.VaultStringSecret; @@ -101,7 +108,7 @@ import org.jboss.logging.Logger; /** * @author Pedro Igor */ -public class OIDCIdentityProvider extends AbstractOAuth2IdentityProvider implements ExchangeExternalToken, ClientAssertionIdentityProvider, JWTAuthorizationGrantProvider { +public class OIDCIdentityProvider extends AbstractOAuth2IdentityProvider implements ExchangeExternalToken, ClientAssertionIdentityProvider, JWTAuthorizationGrantProvider, TrustMaterialIdentityProvider { protected static final Logger logger = Logger.getLogger(OIDCIdentityProvider.class); public static final String SCOPE_OPENID = "openid"; @@ -1086,6 +1093,18 @@ public class OIDCIdentityProvider extends AbstractOAuth2IdentityProvider resolveKeys(TrustMaterialRequest request) { + if (!matchesTrustMaterialIssuer(request)) { + return Stream.empty(); + } + + Stream keys = Stream.ofNullable(PublicKeyStorageManager.getIdentityProviderKeyWrapper(session, + session.getContext().getRealm(), getConfig(), request.getKid(), request.getAlgorithm())); + + return TrustKeyUtil.filterKeys(keys.map(JWKSServerUtils::toJwk), request); + } + @Override protected void setEmailVerified(UserModel user, BrokeredIdentityContext context) { OIDCIdentityProviderConfig config = getConfig(); @@ -1103,6 +1122,14 @@ public class OIDCIdentityProvider extends AbstractOAuth2IdentityProvider resolveKeys(KeycloakSession session, String aliases, TrustMaterialRequest request) { + if (Strings.isEmpty(aliases)) { + return Stream.empty(); + } + return resolveKeys(session, splitAliases(aliases), request); + } + + public Stream resolveKeys(KeycloakSession session, Collection aliases, TrustMaterialRequest request) { + if (aliases == null || aliases.isEmpty()) { + return Stream.empty(); + } + + return aliases.stream() + .filter(Objects::nonNull) + .map(String::trim) + .filter(alias -> !alias.isEmpty()) + .map(alias -> resolveProvider(session, alias)) + .flatMap(Optional::stream) + .flatMap(provider -> provider.resolveKeys(request)); + } + + public Optional resolveKey(KeycloakSession session, String aliases, TrustMaterialRequest request) { + return resolveKeys(session, aliases, request).findFirst(); + } + + private Optional> resolveProvider(KeycloakSession session, String alias) { + IdentityProviderModel model = session.identityProviders().getByAlias(alias); + if (model == null || !model.isEnabled()) { + return Optional.empty(); + } + + TrustMaterialIdentityProvider provider = IdentityBrokerService.getIdentityProvider(session, model, TrustMaterialIdentityProvider.class); + return Optional.ofNullable(provider); + } + + private List splitAliases(String aliases) { + return Arrays.stream(aliases.split(",")) + .filter(Objects::nonNull) + .map(String::trim) + .filter(alias -> !alias.isEmpty()) + .toList(); + } +} diff --git a/services/src/main/java/org/keycloak/broker/trust/DefaultTrustIdentityProvider.java b/services/src/main/java/org/keycloak/broker/trust/DefaultTrustIdentityProvider.java new file mode 100644 index 00000000000..7c8b41ee7ce --- /dev/null +++ b/services/src/main/java/org/keycloak/broker/trust/DefaultTrustIdentityProvider.java @@ -0,0 +1,75 @@ +/* + * 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.broker.trust; + +import java.util.stream.Stream; + +import org.keycloak.broker.provider.TrustMaterialIdentityProvider; +import org.keycloak.broker.provider.TrustMaterialRequest; +import org.keycloak.crypto.KeyWrapper; +import org.keycloak.jose.jwk.JWK; +import org.keycloak.keys.PublicKeyLoader; +import org.keycloak.keys.PublicKeyStorageProvider; +import org.keycloak.keys.PublicKeyStorageUtils; +import org.keycloak.models.KeycloakSession; +import org.keycloak.protocol.oidc.utils.JWKSServerUtils; +import org.keycloak.util.Strings; +import org.keycloak.utils.StringUtil; + +public class DefaultTrustIdentityProvider implements TrustMaterialIdentityProvider { + + private final KeycloakSession session; + private final DefaultTrustIdentityProviderConfig config; + + public DefaultTrustIdentityProvider(KeycloakSession session, DefaultTrustIdentityProviderConfig config) { + this.session = session; + this.config = config; + } + + @Override + public DefaultTrustIdentityProviderConfig getConfig() { + return config; + } + + @Override + public Stream resolveKeys(TrustMaterialRequest request) { + PublicKeyLoader loader = new DefaultTrustMaterialPublicKeyLoader(session, config); + PublicKeyStorageProvider keyStorage = session.getProvider(PublicKeyStorageProvider.class); + String modelKey = PublicKeyStorageUtils.getIdpModelCacheKey(session.getContext().getRealm().getId(), config.getInternalId()); + Stream keys = Strings.isEmpty(request.getKid()) + ? keyStorage.getKeys(modelKey, loader).stream() + : Stream.of(keyStorage.getPublicKey(modelKey, request.getKid(), request.getAlgorithm(), loader)); + + return TrustKeyUtil.filterKeys(keys.map(JWKSServerUtils::toJwk), request); + } + + @Override + public boolean reloadKeys() { + if (!config.isEnabled() || StringUtil.isBlank(config.getTrustedJwksUrl())) { + return false; + } + + PublicKeyStorageProvider keyStorage = session.getProvider(PublicKeyStorageProvider.class); + String modelKey = PublicKeyStorageUtils.getIdpModelCacheKey(session.getContext().getRealm().getId(), config.getInternalId()); + return keyStorage.reloadKeys(modelKey, new DefaultTrustMaterialPublicKeyLoader(session, config)); + } + + @Override + public void close() { + } +} diff --git a/services/src/main/java/org/keycloak/broker/trust/DefaultTrustIdentityProviderConfig.java b/services/src/main/java/org/keycloak/broker/trust/DefaultTrustIdentityProviderConfig.java new file mode 100644 index 00000000000..cd2283d1192 --- /dev/null +++ b/services/src/main/java/org/keycloak/broker/trust/DefaultTrustIdentityProviderConfig.java @@ -0,0 +1,79 @@ +/* + * 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.broker.trust; + +import org.keycloak.models.IdentityProviderModel; +import org.keycloak.models.RealmModel; +import org.keycloak.util.Strings; + +import static org.keycloak.common.util.UriUtils.checkUrl; + +public class DefaultTrustIdentityProviderConfig extends IdentityProviderModel { + + public static final String TRUSTED_JWKS_URL = "trustedJwksUrl"; + public static final String TRUSTED_JWKS = "trustedJwks"; + + public DefaultTrustIdentityProviderConfig() { + } + + public DefaultTrustIdentityProviderConfig(IdentityProviderModel model) { + super(model); + } + + @Override + public Boolean isHideOnLogin() { + return true; + } + + @Override + public void validate(RealmModel realm) { + super.validate(realm); + boolean hasTrustedJwksUrl = !Strings.isEmpty(getTrustedJwksUrl()); + boolean hasTrustedJwks = !Strings.isEmpty(getTrustedJwks()); + if (hasTrustedJwksUrl == hasTrustedJwks) { + throw new IllegalArgumentException("Configure exactly one of trusted JWKS URL or trusted JWKS"); + } + if (hasTrustedJwksUrl) { + checkUrl(realm.getSslRequired(), getTrustedJwksUrl(), TRUSTED_JWKS_URL); + } + } + + public String getTrustedJwksUrl() { + return getConfig().get(TRUSTED_JWKS_URL); + } + + public void setTrustedJwksUrl(String trustedJwksUrl) { + if (trustedJwksUrl == null) { + getConfig().remove(TRUSTED_JWKS_URL); + } else { + getConfig().put(TRUSTED_JWKS_URL, trustedJwksUrl); + } + } + + public String getTrustedJwks() { + return getConfig().get(TRUSTED_JWKS); + } + + public void setTrustedJwks(String trustedJwks) { + if (trustedJwks == null) { + getConfig().remove(TRUSTED_JWKS); + } else { + getConfig().put(TRUSTED_JWKS, trustedJwks); + } + } +} diff --git a/services/src/main/java/org/keycloak/broker/trust/DefaultTrustIdentityProviderFactory.java b/services/src/main/java/org/keycloak/broker/trust/DefaultTrustIdentityProviderFactory.java new file mode 100644 index 00000000000..16b01ebd665 --- /dev/null +++ b/services/src/main/java/org/keycloak/broker/trust/DefaultTrustIdentityProviderFactory.java @@ -0,0 +1,81 @@ +/* + * 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.broker.trust; + +import java.util.List; +import java.util.Map; + +import org.keycloak.Config; +import org.keycloak.broker.provider.AbstractIdentityProviderFactory; +import org.keycloak.common.Profile; +import org.keycloak.models.IdentityProviderModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.provider.EnvironmentDependentProviderFactory; +import org.keycloak.provider.ProviderConfigProperty; + +public class DefaultTrustIdentityProviderFactory extends AbstractIdentityProviderFactory implements EnvironmentDependentProviderFactory { + + public static final String PROVIDER_ID = "default-trust"; + + @Override + public String getName() { + return "Default Trust"; + } + + @Override + public DefaultTrustIdentityProvider create(KeycloakSession session, IdentityProviderModel model) { + return new DefaultTrustIdentityProvider(session, new DefaultTrustIdentityProviderConfig(model)); + } + + @Override + public Map parseConfig(KeycloakSession session, String config) { + throw new UnsupportedOperationException(); + } + + @Override + public IdentityProviderModel createConfig() { + return new DefaultTrustIdentityProviderConfig(); + } + + @Override + public List getConfigProperties() { + ProviderConfigProperty trustedJwksUrl = new ProviderConfigProperty(); + trustedJwksUrl.setName(DefaultTrustIdentityProviderConfig.TRUSTED_JWKS_URL); + trustedJwksUrl.setLabel("Trusted JWKS URL"); + trustedJwksUrl.setHelpText("External JWKS URL containing trusted signing keys."); + trustedJwksUrl.setType(ProviderConfigProperty.STRING_TYPE); + + ProviderConfigProperty trustedJwks = new ProviderConfigProperty(); + trustedJwks.setName(DefaultTrustIdentityProviderConfig.TRUSTED_JWKS); + trustedJwks.setLabel("Trusted JWKS"); + trustedJwks.setHelpText("Hardcoded JWKS containing trusted signing keys."); + trustedJwks.setType(ProviderConfigProperty.TEXT_TYPE); + + return List.of(trustedJwksUrl, trustedJwks); + } + + @Override + public String getId() { + return PROVIDER_ID; + } + + @Override + public boolean isSupported(Config.Scope config) { + return Profile.isFeatureEnabled(Profile.Feature.OID4VC_VCI) || Profile.isFeatureEnabled(Profile.Feature.CLIENT_AUTH_ABCA); + } +} diff --git a/services/src/main/java/org/keycloak/broker/trust/DefaultTrustMaterialPublicKeyLoader.java b/services/src/main/java/org/keycloak/broker/trust/DefaultTrustMaterialPublicKeyLoader.java new file mode 100644 index 00000000000..901f92f806e --- /dev/null +++ b/services/src/main/java/org/keycloak/broker/trust/DefaultTrustMaterialPublicKeyLoader.java @@ -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.broker.trust; + +import org.keycloak.crypto.PublicKeysWrapper; +import org.keycloak.jose.jwk.JSONWebKeySet; +import org.keycloak.jose.jwk.JWK; +import org.keycloak.keys.PublicKeyLoader; +import org.keycloak.models.KeycloakSession; +import org.keycloak.protocol.oidc.utils.JWKSHttpUtils; +import org.keycloak.util.JWKSUtils; +import org.keycloak.util.JsonSerialization; +import org.keycloak.utils.StringUtil; + +public class DefaultTrustMaterialPublicKeyLoader implements PublicKeyLoader { + + private final KeycloakSession session; + private final DefaultTrustIdentityProviderConfig config; + + public DefaultTrustMaterialPublicKeyLoader(KeycloakSession session, DefaultTrustIdentityProviderConfig config) { + this.session = session; + this.config = config; + } + + @Override + public PublicKeysWrapper loadKeys() throws Exception { + if (StringUtil.isNotBlank(config.getTrustedJwksUrl())) { + JSONWebKeySet jwks = JWKSHttpUtils.sendJwksRequest(session, config.getTrustedJwksUrl()); + return JWKSUtils.getKeyWrappersForUse(jwks, JWK.Use.SIG, true); + } + + if (StringUtil.isNotBlank(config.getTrustedJwks())) { + JSONWebKeySet jwks = JsonSerialization.readValue(config.getTrustedJwks(), JSONWebKeySet.class); + return JWKSUtils.getKeyWrappersForUse(jwks, JWK.Use.SIG, true); + } + + return PublicKeysWrapper.EMPTY; + } +} diff --git a/services/src/main/java/org/keycloak/broker/trust/TrustKeyUtil.java b/services/src/main/java/org/keycloak/broker/trust/TrustKeyUtil.java new file mode 100644 index 00000000000..030603ef9f4 --- /dev/null +++ b/services/src/main/java/org/keycloak/broker/trust/TrustKeyUtil.java @@ -0,0 +1,23 @@ +package org.keycloak.broker.trust; + +import java.util.Objects; +import java.util.stream.Stream; + +import org.keycloak.broker.provider.TrustMaterialRequest; +import org.keycloak.jose.jwk.JWK; +import org.keycloak.util.Strings; + +public class TrustKeyUtil { + + private TrustKeyUtil() { + } + + public static Stream filterKeys(Stream keys, TrustMaterialRequest request) { + return keys + .filter(Objects::nonNull) + .filter(key -> Strings.isEmpty(request.getKid()) || Objects.equals(request.getKid(), key.getKeyId())) + .filter(key -> Strings.isEmpty(request.getAlgorithm()) || Strings.isEmpty(key.getAlgorithm()) + || Objects.equals(request.getAlgorithm(), key.getAlgorithm())) + .filter(key -> Strings.isEmpty(key.getPublicKeyUse()) || Objects.equals(JWK.Use.SIG.asString(), key.getPublicKeyUse())); + } +} diff --git a/services/src/main/java/org/keycloak/keys/loader/PublicKeyStorageManager.java b/services/src/main/java/org/keycloak/keys/loader/PublicKeyStorageManager.java index dcf9e02e878..33e7d7c43a8 100644 --- a/services/src/main/java/org/keycloak/keys/loader/PublicKeyStorageManager.java +++ b/services/src/main/java/org/keycloak/keys/loader/PublicKeyStorageManager.java @@ -68,7 +68,10 @@ public class PublicKeyStorageManager { public static KeyWrapper getIdentityProviderKeyWrapper(KeycloakSession session, RealmModel realm, JWTAuthorizationGrantConfig idpConfig, JWSInput input) { String kid = input.getHeader().getKeyId(); String alg = input.getHeader().getRawAlgorithm(); + return getIdentityProviderKeyWrapper(session, realm, idpConfig, kid, alg); + } + public static KeyWrapper getIdentityProviderKeyWrapper(KeycloakSession session, RealmModel realm, JWTAuthorizationGrantConfig idpConfig, String kid, String alg) { PublicKeyStorageProvider keyStorage = session.getProvider(PublicKeyStorageProvider.class); String modelKey = PublicKeyStorageUtils.getIdpModelCacheKey(realm.getId(), idpConfig.getInternalId()); diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/keybinding/TrustedAttestationKeysLoader.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/keybinding/TrustedAttestationKeysLoader.java index b71cb4b948d..96db7ed9850 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/keybinding/TrustedAttestationKeysLoader.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/keybinding/TrustedAttestationKeysLoader.java @@ -1,23 +1,18 @@ package org.keycloak.protocol.oid4vc.issuance.keybinding; import java.io.IOException; -import java.security.cert.X509Certificate; import java.util.Arrays; import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; -import java.util.Objects; -import java.util.Optional; import java.util.Set; import java.util.stream.Collectors; import java.util.stream.Stream; import org.keycloak.constants.OID4VCIConstants; -import org.keycloak.crypto.KeyType; import org.keycloak.jose.jwk.JSONWebKeySet; import org.keycloak.jose.jwk.JWK; -import org.keycloak.jose.jwk.JWKBuilder; import org.keycloak.models.KeycloakSession; import org.keycloak.models.RealmModel; import org.keycloak.protocol.oidc.utils.JWKSServerUtils; @@ -132,22 +127,7 @@ public final class TrustedAttestationKeysLoader { .filter(key -> keyIds.contains(key.getKid()) && key.getPublicKey() != null) .forEach(key -> { try { - JWKBuilder builder = JWKBuilder.create() - .kid(key.getKid()) - .algorithm(key.getAlgorithmOrDefault()); - List certificates = Optional.ofNullable(key.getCertificateChain()) - .filter(certs -> !certs.isEmpty()) - .orElseGet(() -> Optional.ofNullable(key.getCertificate()) - .map(Collections::singletonList) - .orElseGet(Collections::emptyList)); - JWK jwk = null; - if (Objects.equals(key.getType(), KeyType.RSA)) { - jwk = builder.rsa(key.getPublicKey(), certificates, key.getUse()); - } else if (Objects.equals(key.getType(), KeyType.EC)) { - jwk = builder.ec(key.getPublicKey(), certificates, key.getUse()); - } else if (Objects.equals(key.getType(), KeyType.OKP)) { - jwk = builder.okp(key.getPublicKey(), key.getUse()); - } + JWK jwk = JWKSServerUtils.toJwk(key); if (jwk != null) { keyMap.put(key.getKid(), jwk); } else { diff --git a/services/src/main/java/org/keycloak/protocol/oidc/utils/JWKSServerUtils.java b/services/src/main/java/org/keycloak/protocol/oidc/utils/JWKSServerUtils.java index bbcdd38bce0..ffdef3db490 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc/utils/JWKSServerUtils.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/utils/JWKSServerUtils.java @@ -23,6 +23,7 @@ import java.util.Objects; import java.util.Optional; import org.keycloak.crypto.KeyType; +import org.keycloak.crypto.KeyWrapper; import org.keycloak.jose.jwk.JSONWebKeySet; import org.keycloak.jose.jwk.JWK; import org.keycloak.jose.jwk.JWKBuilder; @@ -36,21 +37,7 @@ import org.keycloak.models.RealmModel; public static JSONWebKeySet getRealmJwks(KeycloakSession session, RealmModel realm){ JWK[] jwks = session.keys().getKeysStream(realm) .filter(k -> k.getStatus().isEnabled() && k.getPublicKey() != null) - .map(k -> { - JWKBuilder b = JWKBuilder.create().kid(k.getKid()).algorithm(k.getAlgorithmOrDefault()); - List certificates = Optional.ofNullable(k.getCertificateChain()) - .filter(certs -> !certs.isEmpty()) - .orElseGet(() -> Optional.ofNullable(k.getCertificate()).map(Collections::singletonList) - .orElseGet(Collections::emptyList)); - if (k.getType().equals(KeyType.RSA)) { - return b.rsa(k.getPublicKey(), certificates, k.getUse()); - } else if (k.getType().equals(KeyType.EC)) { - return b.ec(k.getPublicKey(), certificates, k.getUse()); - } else if (k.getType().equals(KeyType.OKP)) { - return b.okp(k.getPublicKey(), k.getUse()); - } - return null; - }) + .map(JWKSServerUtils::toJwk) .filter(Objects::nonNull) .toArray(JWK[]::new); @@ -58,4 +45,24 @@ import org.keycloak.models.RealmModel; keySet.setKeys(jwks); return keySet; } + + + public static JWK toJwk(KeyWrapper key) { + JWKBuilder b = JWKBuilder.create() + .kid(key.getKid()) + .algorithm(key.getAlgorithmOrDefault()); + List certificates = Optional.ofNullable(key.getCertificateChain()) + .filter(certs -> !certs.isEmpty()) + .orElseGet(() -> Optional.ofNullable(key.getCertificate()) + .map(Collections::singletonList) + .orElseGet(Collections::emptyList)); + if (key.getType().equals(KeyType.RSA)) { + return b.rsa(key.getPublicKey(), certificates, key.getUse()); + } else if (key.getType().equals(KeyType.EC)) { + return b.ec(key.getPublicKey(), certificates, key.getUse()); + } else if (key.getType().equals(KeyType.OKP)) { + return b.okp(key.getPublicKey(), key.getUse()); + } + return null; + } } diff --git a/services/src/main/resources/META-INF/services/org.keycloak.broker.provider.IdentityProviderFactory b/services/src/main/resources/META-INF/services/org.keycloak.broker.provider.IdentityProviderFactory index b8cea42fac3..fcff135eef7 100755 --- a/services/src/main/resources/META-INF/services/org.keycloak.broker.provider.IdentityProviderFactory +++ b/services/src/main/resources/META-INF/services/org.keycloak.broker.provider.IdentityProviderFactory @@ -21,4 +21,5 @@ org.keycloak.broker.saml.SAMLIdentityProviderFactory org.keycloak.broker.oauth.OAuth2IdentityProviderFactory org.keycloak.broker.spiffe.SpiffeIdentityProviderFactory org.keycloak.broker.kubernetes.KubernetesIdentityProviderFactory -org.keycloak.broker.jwtauthorizationgrant.JWTAuthorizationGrantIdentityProviderFactory \ No newline at end of file +org.keycloak.broker.jwtauthorizationgrant.JWTAuthorizationGrantIdentityProviderFactory +org.keycloak.broker.trust.DefaultTrustIdentityProviderFactory diff --git a/tests/base/src/test/java/org/keycloak/tests/broker/trust/TrustMaterialIdentityProviderTest.java b/tests/base/src/test/java/org/keycloak/tests/broker/trust/TrustMaterialIdentityProviderTest.java new file mode 100644 index 00000000000..151411bcaf2 --- /dev/null +++ b/tests/base/src/test/java/org/keycloak/tests/broker/trust/TrustMaterialIdentityProviderTest.java @@ -0,0 +1,280 @@ +/* + * 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.tests.broker.trust; + +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import org.keycloak.broker.jwtauthorizationgrant.JWTAuthorizationGrantConfig; +import org.keycloak.broker.oidc.OIDCIdentityProvider; +import org.keycloak.broker.oidc.OIDCIdentityProviderConfig; +import org.keycloak.broker.oidc.OIDCIdentityProviderFactory; +import org.keycloak.broker.provider.TrustMaterialIdentityProvider; +import org.keycloak.broker.provider.TrustMaterialRequest; +import org.keycloak.broker.provider.TrustMaterialResolver; +import org.keycloak.broker.trust.DefaultTrustIdentityProvider; +import org.keycloak.broker.trust.DefaultTrustIdentityProviderConfig; +import org.keycloak.broker.trust.DefaultTrustIdentityProviderFactory; +import org.keycloak.common.Profile; +import org.keycloak.common.util.PemUtils; +import org.keycloak.jose.jwk.JSONWebKeySet; +import org.keycloak.jose.jwk.JWK; +import org.keycloak.jose.jwk.JWKBuilder; +import org.keycloak.models.IdentityProviderModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.services.resources.IdentityBrokerService; +import org.keycloak.testframework.annotations.InjectHttpServer; +import org.keycloak.testframework.annotations.KeycloakIntegrationTest; +import org.keycloak.testframework.annotations.TestSetup; +import org.keycloak.testframework.remote.runonserver.InjectRunOnServer; +import org.keycloak.testframework.remote.runonserver.RunOnServerClient; +import org.keycloak.testframework.server.KeycloakServerConfig; +import org.keycloak.testframework.server.KeycloakServerConfigBuilder; +import org.keycloak.testframework.util.HttpServerUtil; +import org.keycloak.util.JsonSerialization; + +import com.sun.net.httpserver.HttpServer; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@KeycloakIntegrationTest(config = TrustMaterialIdentityProviderTest.TrustMaterialServerConfig.class) +public class TrustMaterialIdentityProviderTest { + + private static final String DEFAULT_INLINE_ALIAS = "trust-material-default-inline"; + private static final String DEFAULT_URL_ALIAS = "trust-material-default-url"; + private static final String DEFAULT_DISABLED_ALIAS = "trust-material-default-disabled"; + private static final String OIDC_ALIAS = "trust-material-oidc"; + private static final String KEY_ID = "trust-material-key"; + private static final String ALGORITHM = "PS256"; + private static final String ISSUER = "https://issuer.example.test"; + + private static String trustedJwks; + private static String trustedPublicKey; + + @InjectRunOnServer + RunOnServerClient runOnServer; + + @InjectHttpServer + HttpServer httpServer; + + @TestSetup + public void setup() throws Exception { + KeyPair key = createRsaKeyPair(); + JWK jwk = JWKBuilder.create() + .kid(KEY_ID) + .algorithm(ALGORITHM) + .rsa(key.getPublic()); + JSONWebKeySet jwks = new JSONWebKeySet(); + jwks.setKeys(new JWK[] { jwk }); + trustedJwks = JsonSerialization.writeValueAsString(jwks); + trustedPublicKey = PemUtils.encodeKey(key.getPublic()); + } + + @BeforeEach + public void configureIdentityProviders() { + String jwks = trustedJwks; + String publicKey = trustedPublicKey; + runOnServer.run(session -> { + RealmModel realm = session.getContext().getRealm(); + configureTrustIdentityProvider(realm, DEFAULT_INLINE_ALIAS, DefaultTrustIdentityProviderFactory.PROVIDER_ID, true, + Map.of(DefaultTrustIdentityProviderConfig.TRUSTED_JWKS, jwks)); + configureTrustIdentityProvider(realm, DEFAULT_DISABLED_ALIAS, DefaultTrustIdentityProviderFactory.PROVIDER_ID, false, + Map.of(DefaultTrustIdentityProviderConfig.TRUSTED_JWKS, jwks)); + configureTrustIdentityProvider(realm, OIDC_ALIAS, OIDCIdentityProviderFactory.PROVIDER_ID, true, + Map.of( + OIDCIdentityProviderConfig.USE_JWKS_URL, Boolean.FALSE.toString(), + JWTAuthorizationGrantConfig.PUBLIC_KEY_SIGNATURE_VERIFIER, publicKey, + JWTAuthorizationGrantConfig.PUBLIC_KEY_SIGNATURE_VERIFIER_KEY_ID, KEY_ID, + IdentityProviderModel.ISSUER, ISSUER)); + }); + } + + @Test + public void defaultTrustIdentityProviderResolvesInlineTrustedJwks() { + runOnServer.run(session -> { + TrustMaterialIdentityProvider provider = getTrustMaterialProvider(session.getContext().getRealm(), session, DEFAULT_INLINE_ALIAS); + assertInstanceOf(DefaultTrustIdentityProvider.class, provider); + + JWK jwk = provider.resolveKeys(matchingRequest()).findFirst().orElseThrow(); + + assertEquals(KEY_ID, jwk.getKeyId()); + assertEquals(ALGORITHM, jwk.getAlgorithm()); + }); + } + + @Test + public void defaultTrustIdentityProviderResolvesTrustedJwksUrl() { + String path = "/trust-material-jwks"; + httpServer.createContext(path, exchange -> HttpServerUtil.sendResponse(exchange, 200, + Map.of("Content-Type", List.of("application/json")), trustedJwks)); + + try { + String jwksUrl = "http://" + httpServer.getAddress().getHostString() + ":" + httpServer.getAddress().getPort() + path; + runOnServer.run(session -> configureTrustIdentityProvider(session.getContext().getRealm(), DEFAULT_URL_ALIAS, + DefaultTrustIdentityProviderFactory.PROVIDER_ID, true, + Map.of(DefaultTrustIdentityProviderConfig.TRUSTED_JWKS_URL, jwksUrl))); + + runOnServer.run(session -> { + TrustMaterialIdentityProvider provider = getTrustMaterialProvider(session.getContext().getRealm(), session, DEFAULT_URL_ALIAS); + assertInstanceOf(DefaultTrustIdentityProvider.class, provider); + + JWK jwk = provider.resolveKeys(matchingRequest()).findFirst().orElseThrow(); + + assertEquals(KEY_ID, jwk.getKeyId()); + assertEquals(ALGORITHM, jwk.getAlgorithm()); + }); + } finally { + httpServer.removeContext(path); + } + } + + @Test + public void trustMaterialResolverUsesEnabledProviderFromAliasList() { + runOnServer.run(session -> { + Optional jwk = new TrustMaterialResolver().resolveKey(session, + "missing-alias, " + DEFAULT_DISABLED_ALIAS + ", " + DEFAULT_INLINE_ALIAS, matchingRequest()); + + assertTrue(jwk.isPresent()); + assertEquals(KEY_ID, jwk.get().getKeyId()); + assertEquals(ALGORITHM, jwk.get().getAlgorithm()); + }); + } + + @Test + public void trustMaterialResolverReturnsEmptyForDisabledProvider() { + runOnServer.run(session -> assertTrue(new TrustMaterialResolver() + .resolveKey(session, DEFAULT_DISABLED_ALIAS, matchingRequest()).isEmpty())); + } + + @Test + public void oidcIdentityProviderResolvesConfiguredPublicKey() { + runOnServer.run(session -> { + TrustMaterialIdentityProvider provider = getTrustMaterialProvider(session.getContext().getRealm(), session, OIDC_ALIAS); + assertInstanceOf(OIDCIdentityProvider.class, provider); + + JWK jwk = provider.resolveKeys(TrustMaterialRequest.builder() + .kid(KEY_ID) + .algorithm(ALGORITHM) + .issuer(ISSUER) + .build()).findFirst().orElseThrow(); + + assertEquals(KEY_ID, jwk.getKeyId()); + assertEquals(ALGORITHM, jwk.getAlgorithm()); + }); + } + + @Test + public void oidcIdentityProviderResolvesConfiguredPublicKeyWithoutKid() { + runOnServer.run(session -> { + TrustMaterialIdentityProvider provider = getTrustMaterialProvider(session.getContext().getRealm(), session, OIDC_ALIAS); + + JWK jwk = provider.resolveKeys(TrustMaterialRequest.builder() + .algorithm(ALGORITHM) + .issuer(ISSUER) + .build()).findFirst().orElseThrow(); + + assertEquals(KEY_ID, jwk.getKeyId()); + assertEquals(ALGORITHM, jwk.getAlgorithm()); + }); + } + + @Test + public void oidcIdentityProviderRejectsIssuerMismatch() { + runOnServer.run(session -> { + TrustMaterialIdentityProvider provider = getTrustMaterialProvider(session.getContext().getRealm(), session, OIDC_ALIAS); + + assertTrue(provider.resolveKeys(TrustMaterialRequest.builder() + .kid(KEY_ID) + .algorithm(ALGORITHM) + .issuer("https://issuer.invalid") + .build()).findAny().isEmpty()); + }); + } + + @Test + public void oidcIdentityProviderDoesNotSplitConfiguredIssuer() { + runOnServer.run(session -> { + RealmModel realm = session.getContext().getRealm(); + IdentityProviderModel model = realm.getIdentityProviderByAlias(OIDC_ALIAS); + Map config = new HashMap<>(model.getConfig()); + config.put(IdentityProviderModel.ISSUER, ISSUER + ", https://issuer2.example.test"); + model.setConfig(config); + realm.updateIdentityProvider(model); + + TrustMaterialIdentityProvider provider = getTrustMaterialProvider(realm, session, OIDC_ALIAS); + + assertTrue(provider.resolveKeys(TrustMaterialRequest.builder() + .kid(KEY_ID) + .algorithm(ALGORITHM) + .issuer(ISSUER) + .build()).findAny().isEmpty()); + }); + } + + private static void configureTrustIdentityProvider(RealmModel realm, String alias, String providerId, boolean enabled, + Map config) { + IdentityProviderModel trustIdp = realm.getIdentityProviderByAlias(alias); + if (trustIdp == null) { + trustIdp = new IdentityProviderModel(); + trustIdp.setAlias(alias); + trustIdp.setProviderId(providerId); + trustIdp.setEnabled(enabled); + trustIdp.setConfig(config); + realm.addIdentityProvider(trustIdp); + } else { + trustIdp.setProviderId(providerId); + trustIdp.setEnabled(enabled); + trustIdp.setConfig(config); + realm.updateIdentityProvider(trustIdp); + } + } + + private static TrustMaterialIdentityProvider getTrustMaterialProvider(RealmModel realm, KeycloakSession session, String alias) { + IdentityProviderModel model = realm.getIdentityProviderByAlias(alias); + return IdentityBrokerService.getIdentityProvider(session, model, TrustMaterialIdentityProvider.class); + } + + private static TrustMaterialRequest matchingRequest() { + return TrustMaterialRequest.builder() + .kid(KEY_ID) + .algorithm(ALGORITHM) + .build(); + } + + private static KeyPair createRsaKeyPair() throws Exception { + KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA"); + generator.initialize(2048); + return generator.generateKeyPair(); + } + + public static class TrustMaterialServerConfig implements KeycloakServerConfig { + + @Override + public KeycloakServerConfigBuilder configure(KeycloakServerConfigBuilder config) { + return config.features(Profile.Feature.CLIENT_AUTH_ABCA); + } + } +} diff --git a/tests/base/src/test/java/org/keycloak/tests/oid4vc/abca/OIDCAttestationBasedClientAuthenticationTest.java b/tests/base/src/test/java/org/keycloak/tests/oid4vc/abca/OIDCAttestationBasedClientAuthenticationTest.java index b2598fe0cf1..6684386c127 100644 --- a/tests/base/src/test/java/org/keycloak/tests/oid4vc/abca/OIDCAttestationBasedClientAuthenticationTest.java +++ b/tests/base/src/test/java/org/keycloak/tests/oid4vc/abca/OIDCAttestationBasedClientAuthenticationTest.java @@ -17,19 +17,22 @@ package org.keycloak.tests.oid4vc.abca; import java.security.PublicKey; +import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Optional; import org.keycloak.TokenVerifier; -import org.keycloak.authentication.authenticators.client.AttestationBasedClientAuthenticator; -import org.keycloak.authentication.authenticators.client.AttestationBasedClientAuthenticator.ABCAConfig; import org.keycloak.authentication.authenticators.client.AttestationBasedClientAuthenticator.ClientAttestationJwt; import org.keycloak.authentication.authenticators.client.AttestationBasedClientAuthenticator.ClientAttestationPoPJwt; +import org.keycloak.broker.trust.DefaultTrustIdentityProviderConfig; +import org.keycloak.broker.trust.DefaultTrustIdentityProviderFactory; import org.keycloak.common.VerificationException; import org.keycloak.crypto.KeyWrapper; +import org.keycloak.jose.jwk.JSONWebKeySet; import org.keycloak.jose.jwk.JWK; import org.keycloak.jose.jwk.JWKBuilder; -import org.keycloak.models.AuthenticatorConfigModel; +import org.keycloak.models.IdentityProviderModel; import org.keycloak.models.RealmModel; import org.keycloak.protocol.oid4vc.model.CredentialResponse; import org.keycloak.protocol.oid4vc.model.Proofs; @@ -47,7 +50,7 @@ import org.keycloak.util.JsonSerialization; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import static org.keycloak.authentication.authenticators.client.AttestationBasedClientAuthenticator.OAUTH_CLIENT_ATTESTATION_CONFIG_ATTESTER_JWKS; +import static org.keycloak.authentication.authenticators.client.AttestationBasedClientAuthenticator.OAUTH_CLIENT_ATTESTATION_CONFIG_TRUST_IDPS; import static org.keycloak.authentication.authenticators.client.AttestationBasedClientAuthenticator.OAUTH_CLIENT_ATTESTATION_HEADER; import static org.keycloak.authentication.authenticators.client.AttestationBasedClientAuthenticator.OAUTH_CLIENT_ATTESTATION_POP_HEADER; import static org.keycloak.protocol.oidc.OIDCLoginProtocol.ATTEST_JWT_CLIENT_AUTH; @@ -63,33 +66,63 @@ import static org.junit.jupiter.api.Assertions.assertTrue; @KeycloakIntegrationTest(config = OID4VCIssuerTestBase.VCTestServerWithABCAEnabled.class) public class OIDCAttestationBasedClientAuthenticationTest extends OID4VCIssuerTestBase { + private static final String ATTESTER_DEFAULT_TRUST_IDP_ALIAS = "abca-attester-default-trust"; + private static OIDCClientAttester attester; - private static ABCAConfig abcaConfig; + private static String attesterJwks; @TestSetup - public void configure() { + public void configure() throws Exception { var kw = createRsaKeyPair("openid-abca-attester-key"); JWK jwk = JWKBuilder.create() .kid(kw.getKid()) .algorithm(kw.getAlgorithm()) - .rsa(kw.getPublicKey(), kw.getCertificate()); - abcaConfig = new ABCAConfig().setKeys(List.of(jwk)); + .rsa(kw.getPublicKey()); + JSONWebKeySet jwks = new JSONWebKeySet(); + jwks.setKeys(new JWK[] { jwk }); + attesterJwks = JsonSerialization.writeValueAsString(jwks); attester = new OIDCMockClientAttester(kw); } @BeforeEach void beforeEach() { - String abcaConfigValue = JsonSerialization.valueAsString(abcaConfig); + String jwks = attesterJwks; runOnServer.run(session -> { RealmModel realm = session.getContext().getRealm(); - AuthenticatorConfigModel configModel = new AuthenticatorConfigModel(); - configModel.setAlias(AttestationBasedClientAuthenticator.PROVIDER_ID); - configModel.setConfig(Map.of(OAUTH_CLIENT_ATTESTATION_CONFIG_ATTESTER_JWKS, abcaConfigValue)); - realm.addAuthenticatorConfig(configModel); + + configureTrustIdentityProvider(realm, ATTESTER_DEFAULT_TRUST_IDP_ALIAS, + DefaultTrustIdentityProviderFactory.PROVIDER_ID, + Map.of(DefaultTrustIdentityProviderConfig.TRUSTED_JWKS, jwks)); }); + setClientTrustSource(ATTESTER_DEFAULT_TRUST_IDP_ALIAS); oauth.client(abcaClient.getClientId(), null); } + private static void configureTrustIdentityProvider(RealmModel realm, String alias, String providerId, Map config) { + IdentityProviderModel trustIdp = realm.getIdentityProviderByAlias(alias); + if (trustIdp == null) { + trustIdp = new IdentityProviderModel(); + trustIdp.setAlias(alias); + trustIdp.setProviderId(providerId); + trustIdp.setEnabled(true); + trustIdp.setConfig(config); + realm.addIdentityProvider(trustIdp); + } else { + trustIdp.setProviderId(providerId); + trustIdp.setEnabled(true); + trustIdp.setConfig(config); + realm.updateIdentityProvider(trustIdp); + } + } + + private void setClientTrustSource(String alias) { + Map attributes = new HashMap<>(Optional.ofNullable(abcaClient.getAttributes()).orElse(Map.of())); + attributes.put(OAUTH_CLIENT_ATTESTATION_CONFIG_TRUST_IDPS, alias); + abcaClient.setAttributes(attributes); + testRealm.admin().clients().get(abcaClient.getId()).update(abcaClient); + abcaClient = testRealm.admin().clients().get(abcaClient.getId()).toRepresentation(); + } + @Test public void testTokenEndpointAuthMethods() { OIDCConfigurationRepresentation oidcConfiguration = oauth.doWellKnownRequest(); @@ -137,6 +170,7 @@ public class OIDCAttestationBasedClientAuthenticationTest extends OID4VCIssuerTe @Test public void testClientAttestationHappyFlow() { + setClientTrustSource(ATTESTER_DEFAULT_TRUST_IDP_ALIAS); var ctx = new OID4VCTestContext(abcaClient, sdJwtTypeCredentialScope); ctx.putAttachment(CLIENT_ATTESTER_ATTACHMENT_KEY, attester);