8000 Introduce mechanism for different trust material sources by dominikschlosser · Pull Request #48869 · keycloak/keycloak · GitHub
[go: up one dir, main page]

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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<C extends IdentityProviderModel> extends IdentityProvider<C> {

Stream<JWK> resolveKeys(TrustMaterialRequest request);

}
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
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;
Expand Down Expand Up @@ -80,6 +81,7 @@ private static Class<?> toTypeClass(IdentityProviderType type) {
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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@
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;
Expand All @@ -46,7 +48,6 @@
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;
Expand All @@ -58,7 +59,6 @@
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;

Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -172,22 +161,23 @@ public AuthenticationExecutionModel.Requirement[] getRequirementChoices() {

@Override
public boolean isConfigurable() {
return true;
return false;
}

@Override
public List<ProviderConfigProperty> 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<ProviderConfigProperty> getConfigPropertiesPerClient() {
return List.of();
ProviderConfigProperty trustIdps = new ProviderConfigProperty();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it possible to use the per-client configuration instead of using "global" configuration at the authenticator level?

As I think that in production, realm will have different clients/wallets, but each of them may need different attestation key. For example client my-wallet-1 and my-wallet-11 may need attestations from IDP idp1, when my-wallet2 will need attestations from IDP idp2 .

It should be possible to do this just by moving this config option to the method getConfigPropertiesPerClient() instead of getConfigProperties(). Then in the admin console, it will appear in the client credentials tab. See screenshot

Image

Also you can see for example FederatedJWTClientAuthenticator for some "inspiration" .

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. I will look into it, good suggestion
  2. The goal of this PR is just to illustrate the mechanism. I agree that there should be only one IdP-implementation for external JWKS and Cert configured in Keycloak itself. But this mechanism should also be extensible for other trust sources like ETSI Trust Lists in the future. Thats why i did it this way here.
  3. Yes i agree but that would be out-of-scope here

If you agree with the general direction i will rework this PR to be mergeable

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
Expand Down Expand Up @@ -314,24 +304,23 @@ public ClientAttestationPoPJwt issuedNowWithTTL(int ttl) {

// 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(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()
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);

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);
Expand Down Expand Up @@ -401,7 +390,7 @@ private void validateClientAttestationJwt(ClientAuthenticationFlowContext contex

// 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
//
Expand Down Expand Up @@ -507,24 +496,6 @@ private void validateClientAttestationPoPJwt(ClientAuthenticationFlowContext con
// [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<JWK> keys;

public List<JWK> getKeys() {
return keys;
}

public ABCAConfig setKeys(List<JWK> keys) {
this.keys = keys;
return this;
}
}

public static class ABCAResult {

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
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;
Expand All @@ -47,6 +48,9 @@
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;
Expand All @@ -65,6 +69,7 @@
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;
Expand All @@ -80,6 +85,7 @@
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;
Expand All @@ -92,6 +98,7 @@
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;

Expand All @@ -101,7 +108,7 @@
/**
* @author Pedro Igor
*/
public class OIDCIdentityProvider extends AbstractOAuth2IdentityProvider<OIDCIdentityProviderConfig> implements ExchangeExternalToken, ClientAssertionIdentityProvider<OIDCIdentityProviderConfig>, JWTAuthorizationGrantProvider<OIDCIdentityProviderConfig> {
public class OIDCIdentityProvider extends AbstractOAuth2IdentityProvider<OIDCIdentityProviderConfig> implements ExchangeExternalToken, ClientAssertionIdentityProvider<OIDCIdentityProviderConfig>, JWTAuthorizationGrantProvider<OIDCIdentityProviderConfig>, TrustMaterialIdentityProvider<OIDCIdentityProviderConfig> {
protected static final Logger logger = Logger.getLogger(OIDCIdentityProvider.class);

public static final String SCOPE_OPENID = "openid";
Expand Down Expand Up @@ -1086,6 +1093,18 @@ public boolean reloadKeys() {
return false;
}

@Override
public Stream<JWK> resolveKeys(TrustMaterialRequest request) {
if (!matchesTrustMaterialIssuer(request)) {
return Stream.empty();
}

Stream<KeyWrapper> 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();
Expand All @@ -1103,6 +1122,14 @@ protected void setEmailVerified(UserModel user, BrokeredIdentityContext context)
user.setEmailVerified(emailVerified);
}

private boolean matchesTrustMaterialIssuer(TrustMaterialRequest request) {
if (Strings.isEmpty(request.getIssuer()) || Strings.isEmpty(getConfig().getIssuer())) {
return true;
}

return Objects.equals(request.getIssuer(), getConfig().getIssuer());
}

private Boolean getEmailVerifiedClaim(JsonWebToken token) {
if (token == null) {
return null;
Expand Down
Loading
Loading
0