From 34acfc0bc783cd36eabc84fc7922a5ef3e9257cf Mon Sep 17 00:00:00 2001 From: Thomas Diesler Date: Fri, 10 Apr 2026 18:23:52 +0200 Subject: [PATCH 1/7] Add support for OAuth 2.0 Attestation-based client authentication Signed-off-by: Thomas Diesler --- .../org/keycloak/OAuthErrorException.java | 1 + .../keycloak/jose/jws/crypto/RSAProvider.java | 8 +- .../AuthenticationFlowError.java | 2 +- .../utils/DefaultAuthenticationFlows.java | 13 + .../ClientAuthenticationFlow.java | 13 +- .../AttestationBasedClientAuthenticator.java | 319 +++++++++++++++++- .../oidc/endpoints/TokenEndpoint.java | 8 +- .../org/keycloak/services/util/DPoPUtil.java | 22 +- 8 files changed, 365 insertions(+), 21 deletions(-) diff --git a/core/src/main/java/org/keycloak/OAuthErrorException.java b/core/src/main/java/org/keycloak/OAuthErrorException.java index b5e5f36cca0a..08deaa0913a5 100755 --- a/core/src/main/java/org/keycloak/OAuthErrorException.java +++ b/core/src/main/java/org/keycloak/OAuthErrorException.java @@ -64,6 +64,7 @@ public class OAuthErrorException extends Exception { // Others public static final String INVALID_CLIENT = "invalid_client"; + public static final String INVALID_CLIENT_ATTESTATION = "invalid_client_attestation"; public static final String INVALID_GRANT = "invalid_grant"; public static final String UNSUPPORTED_GRANT_TYPE = "unsupported_grant_type"; public static final String UNSUPPORTED_TOKEN_TYPE = "unsupported_token_type"; diff --git a/core/src/main/java/org/keycloak/jose/jws/crypto/RSAProvider.java b/core/src/main/java/org/keycloak/jose/jws/crypto/RSAProvider.java index a31eeb7ef338..1f34ad2d00be 100755 --- a/core/src/main/java/org/keycloak/jose/jws/crypto/RSAProvider.java +++ b/core/src/main/java/org/keycloak/jose/jws/crypto/RSAProvider.java @@ -41,8 +41,14 @@ public static String getJavaAlgorithm(Algorithm alg) { return "SHA384withRSA"; case RS512: return "SHA512withRSA"; + case PS256: + return "SHA256withRSAandMGF1"; + case PS384: + return "SHA384withRSAandMGF1"; + case PS512: + return "SHA512withRSAandMGF1"; default: - throw new IllegalArgumentException("Not an RSA Algorithm"); + throw new IllegalArgumentException("Not a supported RSA Algorithm: " + alg); } } diff --git a/server-spi-private/src/main/java/org/keycloak/authentication/AuthenticationFlowError.java b/server-spi-private/src/main/java/org/keycloak/authentication/AuthenticationFlowError.java index 869f3e7b1e58..9f39f17b5b1c 100755 --- a/server-spi-private/src/main/java/org/keycloak/authentication/AuthenticationFlowError.java +++ b/server-spi-private/src/main/java/org/keycloak/authentication/AuthenticationFlowError.java @@ -40,6 +40,7 @@ public enum AuthenticationFlowError { CLIENT_DISABLED, CLIENT_CREDENTIALS_SETUP_REQUIRED, INVALID_CLIENT_CREDENTIALS, + INVALID_CLIENT_ATTESTATION, IDENTITY_PROVIDER_NOT_FOUND, IDENTITY_PROVIDER_DISABLED, @@ -47,6 +48,5 @@ public enum AuthenticationFlowError { DISPLAY_NOT_SUPPORTED, ACCESS_DENIED, - UNAUTHORIZED_CLIENT, GENERIC_AUTHENTICATION_ERROR } diff --git a/server-spi-private/src/main/java/org/keycloak/models/utils/DefaultAuthenticationFlows.java b/server-spi-private/src/main/java/org/keycloak/models/utils/DefaultAuthenticationFlows.java index 58d5fbcc4b45..d6d94e6809a2 100755 --- a/server-spi-private/src/main/java/org/keycloak/models/utils/DefaultAuthenticationFlows.java +++ b/server-spi-private/src/main/java/org/keycloak/models/utils/DefaultAuthenticationFlows.java @@ -465,6 +465,7 @@ public static void addIdentityProviderAuthenticator(RealmModel realm, String def } public static void clientAuthFlow(RealmModel realm) { + AuthenticationFlowModel clients = new AuthenticationFlowModel(); clients.setAlias(CLIENT_AUTHENTICATION_FLOW); clients.setDescription("Base authentication for clients"); @@ -474,6 +475,18 @@ public static void clientAuthFlow(RealmModel realm) { clients = realm.addAuthenticationFlow(clients); realm.setClientAuthenticationFlow(clients); + // Attestation-Based Client Authentication is a stronger authentication method + // + if (Profile.isFeatureEnabled(Feature.CLIENT_AUTH_ABCA)) { + AuthenticationExecutionModel execution = new AuthenticationExecutionModel(); + execution.setParentFlow(clients.getId()); + execution.setRequirement(AuthenticationExecutionModel.Requirement.ALTERNATIVE); + execution.setAuthenticator("attestation-based"); + execution.setPriority(5); + execution.setAuthenticatorFlow(false); + realm.addAuthenticatorExecution(execution); + } + AuthenticationExecutionModel execution = new AuthenticationExecutionModel(); execution.setParentFlow(clients.getId()); execution.setRequirement(AuthenticationExecutionModel.Requirement.ALTERNATIVE); diff --git a/services/src/main/java/org/keycloak/authentication/ClientAuthenticationFlow.java b/services/src/main/java/org/keycloak/authentication/ClientAuthenticationFlow.java index f672cc5973db..61b29f8a59cc 100755 --- a/services/src/main/java/org/keycloak/authentication/ClientAuthenticationFlow.java +++ b/services/src/main/java/org/keycloak/authentication/ClientAuthenticationFlow.java @@ -25,6 +25,8 @@ import jakarta.ws.rs.core.Response; +import org.keycloak.authentication.authenticators.client.AttestationBasedClientAuthenticator; +import org.keycloak.common.Profile; import org.keycloak.events.Details; import org.keycloak.events.Errors; import org.keycloak.models.AuthenticationExecutionModel; @@ -77,7 +79,8 @@ public Response processFlow() { if (client != null) { String expectedClientAuthType = client.getClientAuthenticatorType(); - // Fallback to secret just in case (for backwards compatibility). Also for public clients, ignore the "clientAuthenticatorType", which is set to them and stick to the + // Fallback to secret just in case (for backwards compatibility). + // Also for public clients, ignore the "clientAuthenticatorType", which is set to them and stick to the // default, which set the client just based on "client_id" parameter if (expectedClientAuthType == null || client.isPublicClient()) { if (expectedClientAuthType == null) { @@ -86,6 +89,14 @@ public Response processFlow() { expectedClientAuthType = KeycloakModelUtils.getDefaultClientAuthenticatorType(); } + // Use expectedClientAuthType=attestation-based for public client + // when AttestationBasedClientAuthenticator is processed + // + String abcaAuthType = AttestationBasedClientAuthenticator.PROVIDER_ID; + if (client.isPublicClient() && factory.getId().equals(abcaAuthType) && Profile.isFeatureEnabled(Profile.Feature.CLIENT_AUTH_ABCA)) { + expectedClientAuthType = abcaAuthType; + } + // Check if client authentication matches if (factory.getId().equals(expectedClientAuthType)) { Response response = processResult(context); 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 1738d4d8b082..ac1b1a3c0cc3 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 @@ -18,35 +18,86 @@ package org.keycloak.authentication.authenticators.client; +import java.math.BigInteger; +import java.security.GeneralSecurityException; +import java.security.KeyFactory; +import java.security.PublicKey; +import java.security.spec.RSAPublicKeySpec; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.Set; +import jakarta.ws.rs.core.HttpHeaders; +import jakarta.ws.rs.core.MultivaluedMap; import jakarta.ws.rs.core.Response; import org.keycloak.Config; import org.keycloak.OAuthErrorException; +import org.keycloak.TokenVerifier; import org.keycloak.authentication.AuthenticationFlowError; import org.keycloak.authentication.ClientAuthenticationFlowContext; import org.keycloak.common.Profile; +import org.keycloak.common.util.Base64Url; +import org.keycloak.exceptions.TokenVerificationException; +import org.keycloak.http.HttpRequest; +import org.keycloak.jose.jwk.JWK; +import org.keycloak.jose.jwk.JWKParser; +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; import org.keycloak.protocol.oidc.OIDCLoginProtocol; import org.keycloak.provider.EnvironmentDependentProviderFactory; import org.keycloak.provider.ProviderConfigProperty; +import org.keycloak.representations.JsonWebToken; +import org.keycloak.services.ServicesLogger; +import org.keycloak.util.JsonSerialization; +import org.keycloak.util.Strings; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.JsonNode; + +import static org.keycloak.OAuth2Constants.CLIENT_ID; -import static jakarta.ws.rs.core.Response.Status.NOT_IMPLEMENTED; /** * Attestation-Based Client Authentication based on Client Attestation JWT and PoP. * See specs for more details. * + * The current implementation aligns with HAIP Profile 1.0 + * specifically Attestation-Based Client Authentication - Draft07 + * * @author Thomas Diesler */ public class AttestationBasedClientAuthenticator extends AbstractClientAuthenticator implements EnvironmentDependentProviderFactory { - public static final String PROVIDER_ID = "client-attestation"; + public static final String PROVIDER_ID = "attestation-based"; + public static final String OAUTH_CLIENT_ATTESTATION_HEADER = "OAuth-Client-Attestation"; + public static final String OAUTH_CLIENT_ATTESTATION_POP_HEADER = "OAuth-Client-Attestation-PoP"; + + public static final String OAUTH_CLIENT_ATTESTATION_JWT_TYPE = "oauth-client-attestation+jwt"; + 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" + * } + * ] + */ + public static final String OAUTH_CLIENT_ATTESTATION_CONFIG_ATTESTER_JWKS = "attester_jwks"; @Override public String getId() { @@ -55,9 +106,30 @@ public String getId() { @Override public void authenticateClient(ClientAuthenticationFlowContext context) { - Response errorResponse = ClientAuthUtil.errorResponse(NOT_IMPLEMENTED.getStatusCode(), OAuthErrorException.UNAUTHORIZED_CLIENT, - "Attestation-Based Client Authentication not (yet) supported"); - context.failure(AuthenticationFlowError.UNAUTHORIZED_CLIENT, errorResponse); + + HttpHeaders headers = context.getHttpRequest().getHttpHeaders(); + String attestationValue = headers.getHeaderString(OAUTH_CLIENT_ATTESTATION_HEADER); + String attestationPoPValue = headers.getHeaderString(OAUTH_CLIENT_ATTESTATION_POP_HEADER); + + // At least one of the header must be present + // + if (attestationValue == null && attestationPoPValue == null) { + return; + } + + context.attempted(); + + try { + ClientAttestationJwt attesterJwt = validateClientAttestationJwt(context); + validateClientAttestationPoPJwt(context, attesterJwt); + + context.success(); + + } catch (Exception ex) { + ServicesLogger.LOGGER.errorValidatingAssertion(ex); + Response challengeResponse = ClientAuthUtil.errorResponse(Response.Status.BAD_REQUEST.getStatusCode(), OAuthErrorException.INVALID_CLIENT_ATTESTATION, ex.getMessage()); + context.failure(AuthenticationFlowError.INVALID_CLIENT_ATTESTATION, challengeResponse); + } } @Override @@ -66,8 +138,8 @@ public String getDisplayType() { } @Override - public boolean isConfigurable() { - return false; + public String getHelpText() { + return "Validates client based on a Client Attestation JWT and a PoP JWT which proves possession of the private key"; } @Override @@ -76,13 +148,18 @@ public AuthenticationExecutionModel.Requirement[] getRequirementChoices() { } @Override - public String getHelpText() { - return "Validates client based on a Client Attestation JWT and a PoP JWT which proves possession of the private key"; + public boolean isConfigurable() { + return true; } @Override public List getConfigProperties() { - return List.of(); + 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); } @Override @@ -108,4 +185,226 @@ public Set getProtocolAuthenticatorMethods(String loginProtocol) { return Set.of(); } } + + @JsonInclude(JsonInclude.Include.NON_NULL) + @JsonIgnoreProperties(ignoreUnknown = true) + public static class ClientAttestationJwt extends JsonWebToken { + + @JsonProperty("cnf") + private Confirmation cnf; + + public Confirmation getConfirmation() { + return cnf; + } + + public void setConfirmation(Confirmation cnf) { + this.cnf = cnf; + } + } + + @JsonInclude(JsonInclude.Include.NON_NULL) + @JsonIgnoreProperties(ignoreUnknown = true) + public static class Confirmation { + + @JsonProperty("jwk") + private JWK jwk; + + public JWK getJwk() { + return jwk; + } + + public void setJwk(JWK jwk) { + this.jwk = jwk; + } + } + + @JsonInclude(JsonInclude.Include.NON_NULL) + @JsonIgnoreProperties(ignoreUnknown = true) + public static class ClientAttestationPoPJwt extends JsonWebToken { + + @JsonProperty("challenge") + private String challenge; + + public String getChallenge() { + return challenge; + } + + public void setChallenge(String challenge) { + this.challenge = challenge; + } + } + + // Private --------------------------------------------------------------------------------------------------------- + + private PublicKey loadAttesterPublicKey(ClientAuthenticationFlowContext context, String kid) throws GeneralSecurityException { + + String configName = OAUTH_CLIENT_ATTESTATION_CONFIG_ATTESTER_JWKS; + String authenticatorConfig = Optional.ofNullable(context.getAuthenticatorConfig()) + .map(AuthenticatorConfigModel::getConfig).orElse(Map.of()).get(configName); + if (authenticatorConfig == null) + throw new IllegalStateException("Cannot load ABCA config from: " + configName); + + JsonNode attesterKeys = JsonSerialization.valueFromString(authenticatorConfig, JsonNode.class).get("keys"); + if (attesterKeys == null || !attesterKeys.isArray()) + throw new IllegalStateException("Cannot load Attester public keys"); + + for (JsonNode key : attesterKeys) { + String currentKid = key.get("kid").asText(); + + if (kid == null || kid.equals(currentKid)) { + String kty = key.get("kty").asText(); + + if (!"RSA".equals(kty)) { + throw new IllegalStateException("Unsupported key type: " + kty); + } + + String n = key.get("n").asText(); + String e = key.get("e").asText(); + + BigInteger modulus = new BigInteger(1, Base64Url.decode(n)); + BigInteger exponent = new BigInteger(1, Base64Url.decode(e)); + + RSAPublicKeySpec spec = new RSAPublicKeySpec(modulus, exponent); + return KeyFactory.getInstance("RSA").generatePublic(spec); + } + } + + throw new IllegalStateException("No matching key found for kid: " + kid); + } + + // Validate the Client Attestation JWT + // https://www.ietf.org/archive/id/draft-ietf-oauth-attestation-based-client-auth-07.html#section-5.1 + private ClientAttestationJwt validateClientAttestationJwt(ClientAuthenticationFlowContext context) throws Exception { + RealmModel realmModel = context.getSession().getContext().getRealm(); + + HttpRequest httpRequest = context.getHttpRequest(); + MultivaluedMap formParams = httpRequest.getDecodedFormParameters(); + + HttpHeaders headers = httpRequest.getHttpHeaders(); + String headerValue = headers.getHeaderString(OAUTH_CLIENT_ATTESTATION_HEADER); + if (headerValue == null) + throw new IllegalStateException("Required header " + OAUTH_CLIENT_ATTESTATION_HEADER + " for is missing"); + + JWSInput jws = new JWSInput(headerValue); + String jwsType = jws.getHeader().getType(); + if (!OAUTH_CLIENT_ATTESTATION_JWT_TYPE.equals(jwsType)) + throw new IllegalStateException("The JWS type MUST be " + OAUTH_CLIENT_ATTESTATION_JWT_TYPE + " instead of " + jwsType); + + ClientAttestationJwt attesterJwt = jws.readJsonContent(ClientAttestationJwt.class); + ClientModel clientModel = Optional.ofNullable(attesterJwt.getSubject()) + .map(realmModel::getClientByClientId) + .orElseThrow(() -> new TokenVerificationException(attesterJwt, "The sub (subject) claim MUST identify a known client_id")); + + // Set the target client in the context before we attempt signature verification + context.setClient(clientModel); + + // Define a few Client Attestation JWT checks + // + + TokenVerifier.Predicate subCheck = (t) -> { + String clientIdParam = formParams.getFirst(CLIENT_ID); + if (Strings.isEmpty(t.getSubject()) || clientIdParam != null && !clientIdParam.equals(t.getSubject())) + throw new TokenVerificationException(t, "The sub claim (subject) MUST match the client_id parameter"); + return true; + }; + + TokenVerifier.Predicate issCheck = (t) -> { + if (Strings.isEmpty(t.getIssuer())) + throw new TokenVerificationException(t, "The iss (issuer) claim MUST contains a unique identifier for the entity that issued the JWT"); + return true; + }; + + TokenVerifier.Predicate cnfCheck = (t) -> { + var jwt = (ClientAttestationJwt) t; + if (jwt.getConfirmation() == null || jwt.getConfirmation().getJwk() == null) + throw new TokenVerificationException(t, "The cnf (confirmation) claim MUST specify a key that is used by the Client Instance to generate the Client Attestation PoP JWT"); + return true; + }; + + // The signature of the Client Attestation JWT verifies with the public key of a known and trusted Attester + // + PublicKey publicKey = loadAttesterPublicKey(context, jws.getHeader().getKeyId()); + + // Verification and Processing + // + TokenVerifier.create(headerValue, ClientAttestationJwt.class) + .publicKey(publicKey) + .withChecks(subCheck, issCheck, cnfCheck, TokenVerifier.IS_ACTIVE) + .verify().getToken(); + + // [TODO] The alg JOSE Header Parameter for both JWTs indicates a registered asymmetric digital signature algorithm + // [TODO] The key contained in the cnf claim of the Client Attestation JWT is not a private key + + return attesterJwt; + } + + // Validate the Client Attestation PoP JWT + // https://www.ietf.org/archive/id/draft-ietf-oauth-attestation-based-client-auth-07.html#section-5.2 + private void validateClientAttestationPoPJwt(ClientAuthenticationFlowContext context, ClientAttestationJwt attesterJwt) throws Exception { + + HttpHeaders headers = context.getHttpRequest().getHttpHeaders(); + String headerValue = headers.getHeaderString(OAUTH_CLIENT_ATTESTATION_POP_HEADER); + if (headerValue == null) + throw new IllegalStateException("Required header " + OAUTH_CLIENT_ATTESTATION_POP_HEADER + " for is missing"); + + JWSInput jws = new JWSInput(headerValue); + String jwsType = jws.getHeader().getType(); + if (!OAUTH_CLIENT_ATTESTATION_POP_JWT_TYPE.equals(jwsType)) + throw new IllegalStateException("The JWS type MUST be " + OAUTH_CLIENT_ATTESTATION_POP_JWT_TYPE + " instead of " + jwsType); + + // Define a few Client Attestation JWT checks + // + + TokenVerifier.Predicate jtiCheck = (t) -> { + if (Strings.isEmpty(t.getId())) + throw new TokenVerificationException(t, "The jti (JWT identifier) claim MUST specify a unique identifier for the Client Attestation PoP."); + return true; + }; + + TokenVerifier.Predicate iatCheck = (t) -> { + if (t.getIat() == 0) + throw new TokenVerificationException(t, "The iat (issued at) claim MUST specify the time at which the Client Attestation PoP was issued."); + return true; + }; + + TokenVerifier.Predicate issCheck = (t) -> { + if (Strings.isEmpty(t.getIssuer()) || !t.getIssuer().equals(attesterJwt.getSubject())) + throw new TokenVerificationException(t, "The value of the iss (issuer) claim, representing the client_id MUST match the value of the sub (subject) claim in the Client Attestation"); + return true; + }; + + TokenVerifier.Predicate audCheck = (t) -> { + if (t.getAudience() == null || t.getAudience().length == 0) + throw new TokenVerificationException(t, "The aud (audience) claim MUST specify a value that identifies the authorization server as an intended audience."); + return true; + }; + + // The public key used to verify the ClientAttestationPoP JWT MUST be the key located in the "cnf" claim of the corresponding ClientAttestation JWT + // + JWK jwk = attesterJwt.getConfirmation().getJwk(); + PublicKey publicKey = JWKParser.create() + .parse(JsonSerialization.valueAsString(jwk)) + .toPublicKey(); + + // Verification and Processing + // + TokenVerifier.create(headerValue, ClientAttestationPoPJwt.class) + .publicKey(publicKey) + .withChecks(jtiCheck, iatCheck, issCheck, audCheck) + .verify().getToken(); + + + // [TODO] The aud (audience) claim MUST specify a value that identifies the authorization server as an intended audience + // [TODO] The authorization server can utilize the jti value for replay attack detection + // [TODO] The authorization server may reject JWTs with an "iat" claim value that is unreasonably far in the past + + // [TODO] If the server provided a challenge value to the client, the challenge claim is present in the Client Attestation PoP JWT and matches the server-provided challenge value. + // [TODO] Additional checks to guarantee replay protection for the Client Attestation PoP JWT might need to be applied + } + + // Error Message specifically related to the use of client attestations + // [TODO] use_attestation_challenge MUST be used when the Client Attestation PoP JWT is not using an expected server-provided challenge. + // [TODO] use_fresh_attestation MUST be used when the Client Attestation JWT is deemed to be not fresh enough to be acceptable by the server. + // [TODO] invalid_client_attestation MAY be used in addition to the more general invalid_client error code as defined in [RFC6749] if the attestation or its proof of possession could not be successfully verified + } diff --git a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/TokenEndpoint.java b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/TokenEndpoint.java index 38149c00680c..36f0f51e20c1 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/TokenEndpoint.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/TokenEndpoint.java @@ -76,7 +76,7 @@ */ public class TokenEndpoint { - private static final Logger logger = Logger.getLogger(TokenEndpoint.class); + private static final Logger LOGGER = Logger.getLogger(TokenEndpoint.class); private MultivaluedMap formParams; private ClientModel client; private Map clientAuthAttributes; @@ -171,8 +171,8 @@ public Object introspect() { @OPTIONS public Response preflight() { - if (logger.isDebugEnabled()) { - logger.debugv("CORS preflight from: {0}", headers.getRequestHeaders().getFirst("Origin")); + if (LOGGER.isDebugEnabled()) { + LOGGER.debugv("CORS preflight from: {0}", headers.getRequestHeaders().getFirst("Origin")); } return Cors.builder().auth().preflight().allowedMethods("POST", "OPTIONS").add(Response.ok()); } @@ -244,7 +244,7 @@ protected void checkParameters() { .reduce(0, Integer::sum); int maxLength = config.getMaxLengthForTheParameter(paramName); if (totalLengthOfParamValues > maxLength) { - logger.warnf("The size of OIDC parameter '%s' is longer (%d) than allowed (%d). %s", paramName, totalLengthOfParamValues, maxLength, config.isAdditionalReqParamsFailFast() ? "Request not allowed." : "Ignoring the parameter."); + LOGGER.warnf("The size of OIDC parameter '%s' is longer (%d) than allowed (%d). %s", paramName, totalLengthOfParamValues, maxLength, config.isAdditionalReqParamsFailFast() ? "Request not allowed." : "Ignoring the parameter."); if (config.isAdditionalReqParamsFailFast()) { throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_REQUEST, "The size of OIDC parameter '" + paramName + "' is longer than allowed.", Response.Status.BAD_REQUEST); diff --git a/services/src/main/java/org/keycloak/services/util/DPoPUtil.java b/services/src/main/java/org/keycloak/services/util/DPoPUtil.java index 67bf60720f4f..3f77289a98b2 100644 --- a/services/src/main/java/org/keycloak/services/util/DPoPUtil.java +++ b/services/src/main/java/org/keycloak/services/util/DPoPUtil.java @@ -213,7 +213,7 @@ private static DPoP validateDPoP(KeycloakSession session, URI uri, String method verifier.verifierContext(signatureVerifier); verifier.withChecks( DPoPClaimsCheck.INSTANCE, - new DPoPHTTPCheck(uri, method), + new DPoPHTTPCheck(session, uri, method), new DPoPIsActiveCheck(session, lifetime, clockSkew), new DPoPReplayCheck(session, lifetime + clockSkew)); @@ -354,10 +354,12 @@ public static List getDPoPSupportedAlgorithms(KeycloakSession session) { private static class DPoPHTTPCheck implements TokenVerifier.Predicate { + private final KeycloakSession session; private final URI uri; private final String method; - DPoPHTTPCheck(URI uri, String method) { + DPoPHTTPCheck(KeycloakSession session, URI uri, String method) { + this.session = session; this.uri = uri; this.method = method; } @@ -365,8 +367,20 @@ private static class DPoPHTTPCheck implements TokenVerifier.Predicate { @Override public boolean test(DPoP t) throws DPoPVerificationException { try { - if (!normalize(new URI(t.getHttpUri())).equals(normalize(uri))) - throw new DPoPVerificationException(t, "DPoP HTTP URL mismatch"); + URI dpopUri = new URI(t.getHttpUri()); + if (!normalize(dpopUri).equals(normalize(uri))) { + // When Keycloak runs behind a reverse proxy, it may not be possible to reconstruct + // the expected uri from the request alone - port information may get lost. + // We also accept a DPoP Uri that matches the configured KC_HOSTNAME + UriInfo uriInfo = session.getContext().getHttpRequest().getUri(); + URI expectedUri = session.getContext() + .getUri() + .getBaseUriBuilder() + .replacePath(uriInfo.getPath()) + .build(); + if (!normalize(dpopUri).equals(normalize(expectedUri))) + throw new DPoPVerificationException(t, "DPoP HTTP URL mismatch"); + } if (!method.equals(t.getHttpMethod())) throw new DPoPVerificationException(t, "DPoP HTTP method mismatch"); From c8a8a2678c1d65953127e405f9593504e0a1fb08 Mon Sep 17 00:00:00 2001 From: Thomas Diesler Date: Sat, 11 Apr 2026 07:50:34 +0200 Subject: [PATCH 2/7] -- address review comments from @IngridPuppet Signed-off-by: Thomas Diesler --- .../AttestationBasedClientAuthenticator.java | 74 ++++++++----------- .../OID4VCAuthorizationDetailsProcessor.java | 5 +- .../org/keycloak/services/util/DPoPUtil.java | 2 +- 3 files changed, 36 insertions(+), 45 deletions(-) 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 ac1b1a3c0cc3..009670b07ebd 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 @@ -18,11 +18,7 @@ package org.keycloak.authentication.authenticators.client; -import java.math.BigInteger; -import java.security.GeneralSecurityException; -import java.security.KeyFactory; import java.security.PublicKey; -import java.security.spec.RSAPublicKeySpec; import java.util.List; import java.util.Map; import java.util.Optional; @@ -38,9 +34,7 @@ import org.keycloak.authentication.AuthenticationFlowError; import org.keycloak.authentication.ClientAuthenticationFlowContext; import org.keycloak.common.Profile; -import org.keycloak.common.util.Base64Url; import org.keycloak.exceptions.TokenVerificationException; -import org.keycloak.http.HttpRequest; import org.keycloak.jose.jwk.JWK; import org.keycloak.jose.jwk.JWKParser; import org.keycloak.jose.jws.JWSInput; @@ -60,7 +54,6 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; -import com.fasterxml.jackson.databind.JsonNode; import static org.keycloak.OAuth2Constants.CLIENT_ID; @@ -236,36 +229,25 @@ public void setChallenge(String challenge) { // Private --------------------------------------------------------------------------------------------------------- - private PublicKey loadAttesterPublicKey(ClientAuthenticationFlowContext context, String kid) throws GeneralSecurityException { + private PublicKey findAttesterPublicKey(ClientAuthenticationFlowContext context, String kid) { + + if (Strings.isEmpty(kid)) + throw new IllegalArgumentException("Invalid attester kid: " + kid); String configName = OAUTH_CLIENT_ATTESTATION_CONFIG_ATTESTER_JWKS; - String authenticatorConfig = Optional.ofNullable(context.getAuthenticatorConfig()) + String abcaConfigValue = Optional.ofNullable(context.getAuthenticatorConfig()) .map(AuthenticatorConfigModel::getConfig).orElse(Map.of()).get(configName); - if (authenticatorConfig == null) + if (abcaConfigValue == null) throw new IllegalStateException("Cannot load ABCA config from: " + configName); - JsonNode attesterKeys = JsonSerialization.valueFromString(authenticatorConfig, JsonNode.class).get("keys"); - if (attesterKeys == null || !attesterKeys.isArray()) - throw new IllegalStateException("Cannot load Attester public keys"); - - for (JsonNode key : attesterKeys) { - String currentKid = key.get("kid").asText(); - - if (kid == null || kid.equals(currentKid)) { - String kty = key.get("kty").asText(); - + ABCAConfig attesterKeys = JsonSerialization.valueFromString(abcaConfigValue, ABCAConfig.class); + for (JWK key : attesterKeys.keys) { + if (kid.equals(key.getKeyId())) { + String kty = key.getKeyType(); if (!"RSA".equals(kty)) { throw new IllegalStateException("Unsupported key type: " + kty); } - - String n = key.get("n").asText(); - String e = key.get("e").asText(); - - BigInteger modulus = new BigInteger(1, Base64Url.decode(n)); - BigInteger exponent = new BigInteger(1, Base64Url.decode(e)); - - RSAPublicKeySpec spec = new RSAPublicKeySpec(modulus, exponent); - return KeyFactory.getInstance("RSA").generatePublic(spec); + return new JWKParser(key).toPublicKey(); } } @@ -277,19 +259,18 @@ private PublicKey loadAttesterPublicKey(ClientAuthenticationFlowContext context, private ClientAttestationJwt validateClientAttestationJwt(ClientAuthenticationFlowContext context) throws Exception { RealmModel realmModel = context.getSession().getContext().getRealm(); - HttpRequest httpRequest = context.getHttpRequest(); - MultivaluedMap formParams = httpRequest.getDecodedFormParameters(); + HttpHeaders headers = context.getHttpRequest().getHttpHeaders(); + String headerValue = Optional.ofNullable(headers.getHeaderString(OAUTH_CLIENT_ATTESTATION_HEADER)) + .orElseThrow(() -> new IllegalArgumentException("Required header " + OAUTH_CLIENT_ATTESTATION_HEADER + " is missing")); - HttpHeaders headers = httpRequest.getHttpHeaders(); - String headerValue = headers.getHeaderString(OAUTH_CLIENT_ATTESTATION_HEADER); - if (headerValue == null) - throw new IllegalStateException("Required header " + OAUTH_CLIENT_ATTESTATION_HEADER + " for is missing"); + MultivaluedMap formParams = context.getHttpRequest().getDecodedFormParameters(); JWSInput jws = new JWSInput(headerValue); String jwsType = jws.getHeader().getType(); if (!OAUTH_CLIENT_ATTESTATION_JWT_TYPE.equals(jwsType)) - throw new IllegalStateException("The JWS type MUST be " + OAUTH_CLIENT_ATTESTATION_JWT_TYPE + " instead of " + jwsType); + throw new IllegalArgumentException("The JWS type MUST be " + OAUTH_CLIENT_ATTESTATION_JWT_TYPE + " instead of " + jwsType); + // Get the client model from the JWT subject ClientAttestationJwt attesterJwt = jws.readJsonContent(ClientAttestationJwt.class); ClientModel clientModel = Optional.ofNullable(attesterJwt.getSubject()) .map(realmModel::getClientByClientId) @@ -303,14 +284,14 @@ private ClientAttestationJwt validateClientAttestationJwt(ClientAuthenticationFl TokenVerifier.Predicate subCheck = (t) -> { String clientIdParam = formParams.getFirst(CLIENT_ID); - if (Strings.isEmpty(t.getSubject()) || clientIdParam != null && !clientIdParam.equals(t.getSubject())) + if (clientIdParam != null && !clientIdParam.equals(t.getSubject())) throw new TokenVerificationException(t, "The sub claim (subject) MUST match the client_id parameter"); return true; }; TokenVerifier.Predicate issCheck = (t) -> { if (Strings.isEmpty(t.getIssuer())) - throw new TokenVerificationException(t, "The iss (issuer) claim MUST contains a unique identifier for the entity that issued the JWT"); + throw new TokenVerificationException(t, "The iss (issuer) claim MUST contain a unique identifier for the entity that issued the JWT"); return true; }; @@ -323,7 +304,7 @@ private ClientAttestationJwt validateClientAttestationJwt(ClientAuthenticationFl // The signature of the Client Attestation JWT verifies with the public key of a known and trusted Attester // - PublicKey publicKey = loadAttesterPublicKey(context, jws.getHeader().getKeyId()); + PublicKey publicKey = findAttesterPublicKey(context, jws.getHeader().getKeyId()); // Verification and Processing // @@ -343,14 +324,13 @@ private ClientAttestationJwt validateClientAttestationJwt(ClientAuthenticationFl private void validateClientAttestationPoPJwt(ClientAuthenticationFlowContext context, ClientAttestationJwt attesterJwt) throws Exception { HttpHeaders headers = context.getHttpRequest().getHttpHeaders(); - String headerValue = headers.getHeaderString(OAUTH_CLIENT_ATTESTATION_POP_HEADER); - if (headerValue == null) - throw new IllegalStateException("Required header " + OAUTH_CLIENT_ATTESTATION_POP_HEADER + " for is missing"); + String headerValue = Optional.ofNullable(headers.getHeaderString(OAUTH_CLIENT_ATTESTATION_POP_HEADER)) + .orElseThrow(() -> new IllegalArgumentException("Required header " + OAUTH_CLIENT_ATTESTATION_POP_HEADER + " is missing")); JWSInput jws = new JWSInput(headerValue); String jwsType = jws.getHeader().getType(); if (!OAUTH_CLIENT_ATTESTATION_POP_JWT_TYPE.equals(jwsType)) - throw new IllegalStateException("The JWS type MUST be " + OAUTH_CLIENT_ATTESTATION_POP_JWT_TYPE + " instead of " + jwsType); + throw new IllegalArgumentException("The JWS type MUST be " + OAUTH_CLIENT_ATTESTATION_POP_JWT_TYPE + " instead of " + jwsType); // Define a few Client Attestation JWT checks // @@ -407,4 +387,12 @@ private void validateClientAttestationPoPJwt(ClientAuthenticationFlowContext con // [TODO] use_fresh_attestation MUST be used when the Client Attestation JWT is deemed to be not fresh enough to be acceptable by the server. // [TODO] invalid_client_attestation MAY be used in addition to the more general invalid_client error code as defined in [RFC6749] if the attestation or its proof of possession could not be successfully verified + /** + * The AttestationBasedClientAuthenticator config + */ + static class ABCAConfig { + + @JsonProperty + List keys; + } } diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/OID4VCAuthorizationDetailsProcessor.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/OID4VCAuthorizationDetailsProcessor.java index 7c22f599ee44..0bc194a070f8 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/OID4VCAuthorizationDetailsProcessor.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/OID4VCAuthorizationDetailsProcessor.java @@ -41,6 +41,7 @@ import org.keycloak.protocol.oidc.rar.AuthorizationDetailsProcessor; import org.keycloak.protocol.oidc.rar.InvalidAuthorizationDetailsException; import org.keycloak.representations.AuthorizationDetailsJSONRepresentation; +import org.keycloak.util.JsonSerialization; import org.keycloak.util.Strings; import org.jboss.logging.Logger; @@ -265,7 +266,9 @@ public List handleMissingAuthorizationDetails(UserSes } } - if (authorizationDetails.isEmpty()) { + if (!authorizationDetails.isEmpty()) { + logger.debugf("Generated authorization_details: %s", JsonSerialization.valueAsString(authorizationDetails)); + } else { logger.debug("No generated authorization_details"); } return authorizationDetails; diff --git a/services/src/main/java/org/keycloak/services/util/DPoPUtil.java b/services/src/main/java/org/keycloak/services/util/DPoPUtil.java index 3f77289a98b2..5c53643fe2c6 100644 --- a/services/src/main/java/org/keycloak/services/util/DPoPUtil.java +++ b/services/src/main/java/org/keycloak/services/util/DPoPUtil.java @@ -354,9 +354,9 @@ public static List getDPoPSupportedAlgorithms(KeycloakSession session) { private static class DPoPHTTPCheck implements TokenVerifier.Predicate { - private final KeycloakSession session; private final URI uri; private final String method; + private final KeycloakSession session; DPoPHTTPCheck(KeycloakSession session, URI uri, String method) { this.session = session; From 6c46eb6e7cde382df5f9612396946c89fb25c044 Mon Sep 17 00:00:00 2001 From: Thomas Diesler Date: Thu, 16 Apr 2026 10:50:48 +0200 Subject: [PATCH 3/7] -- add OIDCMockClientAttester and ECDSAProvider Signed-off-by: Thomas Diesler --- .../main/java/org/keycloak/TokenVerifier.java | 13 +- .../jose/jws/crypto/ECDSAProvider.java | 83 +++++++++++ .../keycloak/jose/jws/crypto/RSAProvider.java | 2 +- .../representations/JsonWebToken.java | 11 ++ .../AttestationBasedClientAuthenticator.java | 41 +++++- .../tests/oid4vc/OID4VCBasicWallet.java | 18 ++- .../tests/oid4vc/OID4VCProofTestUtils.java | 79 +++++++--- ...estationBasedClientAuthenticationTest.java | 136 ++++++++++++++++++ .../OIDCAuthorizationMetadataABCATest.java | 40 ------ .../signing/OID4VCKeyAttestationTest.java | 2 +- 10 files changed, 358 insertions(+), 67 deletions(-) create mode 100755 core/src/main/java/org/keycloak/jose/jws/crypto/ECDSAProvider.java create mode 100644 tests/base/src/test/java/org/keycloak/tests/oid4vc/abca/OIDCAttestationBasedClientAuthenticationTest.java delete mode 100644 tests/base/src/test/java/org/keycloak/tests/oid4vc/abca/OIDCAuthorizationMetadataABCATest.java diff --git a/core/src/main/java/org/keycloak/TokenVerifier.java b/core/src/main/java/org/keycloak/TokenVerifier.java index 90b6e3e10535..a9cb4b792eac 100755 --- a/core/src/main/java/org/keycloak/TokenVerifier.java +++ b/core/src/main/java/org/keycloak/TokenVerifier.java @@ -35,6 +35,7 @@ import org.keycloak.jose.jws.JWSHeader; import org.keycloak.jose.jws.JWSInput; import org.keycloak.jose.jws.JWSInputException; +import org.keycloak.jose.jws.crypto.ECDSAProvider; import org.keycloak.jose.jws.crypto.HMACProvider; import org.keycloak.jose.jws.crypto.RSAProvider; import org.keycloak.representations.JsonWebToken; @@ -440,10 +441,10 @@ public void verifySignature() throws VerificationException { } } else { AlgorithmType algorithmType = getHeader().getAlgorithm().getType(); - if (null == algorithmType) { throw new VerificationException("Unknown or unsupported token algorithm"); - } else switch (algorithmType) { + } + switch (algorithmType) { case RSA: if (publicKey == null) { throw new VerificationException("Public key not set"); @@ -452,6 +453,14 @@ public void verifySignature() throws VerificationException { throw new TokenSignatureInvalidException(token, "Invalid token signature"); } break; + case ECDSA: + if (publicKey == null) { + throw new VerificationException("Public key not set"); + } + if (!ECDSAProvider.verify(jws, publicKey)) { + throw new TokenSignatureInvalidException(token, "Invalid token signature"); + } + break; case HMAC: if (secretKey == null) { throw new VerificationException("Secret key not set"); diff --git a/core/src/main/java/org/keycloak/jose/jws/crypto/ECDSAProvider.java b/core/src/main/java/org/keycloak/jose/jws/crypto/ECDSAProvider.java new file mode 100755 index 000000000000..7e4d9c46a43b --- /dev/null +++ b/core/src/main/java/org/keycloak/jose/jws/crypto/ECDSAProvider.java @@ -0,0 +1,83 @@ +/* + * 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.jose.jws.crypto; + + +import java.nio.charset.StandardCharsets; +import java.security.PublicKey; +import java.security.cert.X509Certificate; + +import org.keycloak.common.util.PemUtils; +import org.keycloak.crypto.ECDSASignatureVerifierContext; +import org.keycloak.crypto.KeyUse; +import org.keycloak.crypto.KeyWrapper; +import org.keycloak.jose.jws.Algorithm; +import org.keycloak.jose.jws.JWSInput; + +/** + * @author Thomas Diesler + */ +public class ECDSAProvider implements SignatureProvider { + + public static String getJavaAlgorithm(Algorithm alg) { + switch (alg) { + case ES256: + return "SHA256withECDSA"; + case ES384: + return "SHA384withECDSA"; + case ES512: + return "SHA512withECDSA"; + default: + throw new IllegalArgumentException("Not a supported ECDSA Algorithm: " + alg); + } + } + + public static boolean verify(JWSInput jws, PublicKey publicKey) { + String alg = jws.getHeader().getAlgorithm().name(); + try { + KeyWrapper kw = new KeyWrapper(); + kw.setPublicKey(publicKey); + kw.setUse(KeyUse.SIG); + kw.setType("EC"); + kw.setAlgorithm(alg); + byte[] data = jws.getEncodedSignatureInput().getBytes(StandardCharsets.UTF_8); + byte[] signature = jws.getSignature(); + return new ECDSASignatureVerifierContext(kw).verify(data, signature); + } catch (Exception e) { + return false; + } + + } + + @Override + public boolean verify(JWSInput input, String cert) { + return verifyViaCertificate(input, cert); + } + + // Private --------------------------------------------------------------------------------------------------------- + + private static boolean verifyViaCertificate(JWSInput input, String cert) { + X509Certificate certificate; + try { + certificate = PemUtils.decodeCertificate(cert); + } catch (Exception e) { + throw new RuntimeException(e); + } + return verify(input, certificate.getPublicKey()); + } +} diff --git a/core/src/main/java/org/keycloak/jose/jws/crypto/RSAProvider.java b/core/src/main/java/org/keycloak/jose/jws/crypto/RSAProvider.java index 1f34ad2d00be..435066fa6fe7 100755 --- a/core/src/main/java/org/keycloak/jose/jws/crypto/RSAProvider.java +++ b/core/src/main/java/org/keycloak/jose/jws/crypto/RSAProvider.java @@ -72,7 +72,7 @@ public static byte[] sign(byte[] data, Algorithm algorithm, PrivateKey privateKe } public static boolean verifyViaCertificate(JWSInput input, String cert) { - X509Certificate certificate = null; + X509Certificate certificate; try { certificate = PemUtils.decodeCertificate(cert); } catch (Exception e) { diff --git a/core/src/main/java/org/keycloak/representations/JsonWebToken.java b/core/src/main/java/org/keycloak/representations/JsonWebToken.java index aa25ab535cae..6add6e71fe29 100755 --- a/core/src/main/java/org/keycloak/representations/JsonWebToken.java +++ b/core/src/main/java/org/keycloak/representations/JsonWebToken.java @@ -152,6 +152,17 @@ public JsonWebToken issuedNow() { return this; } + /** + * Set issuedAt to the current time and expireAt = issuedAt + ttl + * Also set notBefore to issuedAt + */ + @JsonIgnore + public JsonWebToken issuedNowWithTTL(int ttl) { + iat = nbf = (long) Time.currentTime(); + exp = iat + ttl; + return this; + } + public JsonWebToken iat(Long iat) { this.iat = iat; return this; 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 009670b07ebd..7eb31c8514f6 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 @@ -51,6 +51,7 @@ import org.keycloak.util.JsonSerialization; import org.keycloak.util.Strings; +import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; @@ -110,6 +111,9 @@ public void authenticateClient(ClientAuthenticationFlowContext context) { return; } + logger.debugf(OAUTH_CLIENT_ATTESTATION_HEADER + ": " + attestationValue); + logger.debugf(OAUTH_CLIENT_ATTESTATION_POP_HEADER + ": " + attestationPoPValue); + context.attempted(); try { @@ -193,6 +197,27 @@ public Confirmation getConfirmation() { public void setConfirmation(Confirmation cnf) { this.cnf = cnf; } + + @Override + public ClientAttestationJwt issuedFor(String issuedFor) { + return (ClientAttestationJwt) super.issuedFor(issuedFor); + } + + @Override + public ClientAttestationJwt subject(String subject) { + return (ClientAttestationJwt) super.subject(subject); + } + + @Override + public ClientAttestationJwt issuedNowWithTTL(int ttl) { + return (ClientAttestationJwt) super.issuedNowWithTTL(ttl); + } + + @JsonIgnore + public ClientAttestationJwt confirmation(JWK jwk) { + cnf = new Confirmation().setJwk(jwk); + return this; + } } @JsonInclude(JsonInclude.Include.NON_NULL) @@ -206,8 +231,9 @@ public JWK getJwk() { return jwk; } - public void setJwk(JWK jwk) { + public Confirmation setJwk(JWK jwk) { this.jwk = jwk; + return this; } } @@ -390,9 +416,18 @@ private void validateClientAttestationPoPJwt(ClientAuthenticationFlowContext con /** * The AttestationBasedClientAuthenticator config */ - static class ABCAConfig { + public static class ABCAConfig { @JsonProperty - List keys; + private List keys; + + public List getKeys() { + return keys; + } + + public ABCAConfig setKeys(List keys) { + this.keys = keys; + return this; + } } } diff --git a/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCBasicWallet.java b/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCBasicWallet.java index 288493a6ad85..bcc907e999f9 100644 --- a/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCBasicWallet.java +++ b/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCBasicWallet.java @@ -186,17 +186,27 @@ public Proofs generateAttestationProof(OID4VCTestContext ctx, Consumer attachmentKey = new AttachmentKey<>(cacheKey, KeyWrapper.class); KeyWrapper kw = ctx.getAttachment(attachmentKey); if (kw == null) { - kw = OID4VCProofTestUtils.createEcKeyPair(); - if (keyId != null) kw.setKid(keyId); + kw = OID4VCProofTestUtils.createEcKeyPair(keyId); + ctx.putAttachment(attachmentKey, kw); + } + return kw; + } + + public KeyWrapper getRSAKeyPair(OID4VCTestContext ctx, String keyId) { + String cacheKey = keyId != null ? keyId : ctx.getHolder() + "_rsa_key"; + AttachmentKey attachmentKey = new AttachmentKey<>(cacheKey, KeyWrapper.class); + KeyWrapper kw = ctx.getAttachment(attachmentKey); + if (kw == null) { + kw = OID4VCProofTestUtils.createRsaKeyPair(keyId); ctx.putAttachment(attachmentKey, kw); } return kw; diff --git a/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCProofTestUtils.java b/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCProofTestUtils.java index ae058d761c78..043c2b11ea5a 100644 --- a/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCProofTestUtils.java +++ b/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCProofTestUtils.java @@ -1,13 +1,18 @@ package org.keycloak.tests.oid4vc; +import java.math.BigInteger; import java.security.KeyPairGenerator; import java.security.NoSuchAlgorithmException; import java.security.NoSuchProviderException; +import java.security.cert.CertificateException; +import java.security.cert.X509Certificate; +import java.security.interfaces.RSAPrivateCrtKey; +import java.security.interfaces.RSAPublicKey; +import java.util.Date; import java.util.List; import java.util.Map; import org.keycloak.common.util.BouncyIntegration; -import org.keycloak.common.util.KeyUtils; import org.keycloak.crypto.ECDSASignatureSignerContext; import org.keycloak.crypto.KeyUse; import org.keycloak.crypto.KeyWrapper; @@ -20,6 +25,14 @@ import org.keycloak.protocol.oid4vc.model.Proofs; import org.keycloak.representations.AccessToken; +import org.bouncycastle.asn1.x500.X500Name; +import org.bouncycastle.cert.X509v3CertificateBuilder; +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; +import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder; +import org.bouncycastle.operator.ContentSigner; +import org.bouncycastle.operator.OperatorCreationException; +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; + public final class OID4VCProofTestUtils { private OID4VCProofTestUtils() { @@ -30,7 +43,7 @@ public static Proofs jwtProofs(String audience, String nonce) { } public static String generateJwtProof(String audience, String nonce) { - return generateJwtProof(audience, createEcKeyPair(), nonce); + return generateJwtProof(audience, createEcKeyPair("proof-key"), nonce); } public static String generateJwtProof(String audience, KeyWrapper keyWrapper, String nonce) { @@ -59,7 +72,7 @@ public static String generateAttestationProof( List keyStorage, List userAuthentication ) { - KeyWrapper attestationKey = newEcSigningKey("attestation-key"); + KeyWrapper attestationKey = createEcKeyPair("attestation-key"); return generateAttestationProof(attestationKey, nonce, attestedKeys, keyStorage, userAuthentication, null); } @@ -87,30 +100,64 @@ public static String generateAttestationProof( .sign(new ECDSASignatureSignerContext(attestationKey)); } - public static KeyWrapper newEcSigningKey(String keyId) { - KeyWrapper kw = createEcKeyPair(); - if (keyId != null && !keyId.isBlank()) { - kw.setKid(keyId); - } - return kw; - } - - public static KeyWrapper createEcKeyPair() { + public static KeyWrapper createEcKeyPair(String keyId) { try { KeyPairGenerator kpg = KeyPairGenerator.getInstance("EC", BouncyIntegration.PROVIDER); kpg.initialize(256); var keyPair = kpg.generateKeyPair(); KeyWrapper kw = new KeyWrapper(); - kw.setPrivateKey(keyPair.getPrivate()); - kw.setPublicKey(keyPair.getPublic()); + kw.setKid(keyId); kw.setUse(KeyUse.SIG); - kw.setKid(KeyUtils.createKeyId(keyPair.getPublic())); - kw.setType("EC"); kw.setAlgorithm("ES256"); + kw.setType("EC"); + kw.setPublicKey(keyPair.getPublic()); + kw.setPrivateKey(keyPair.getPrivate()); return kw; } catch (NoSuchAlgorithmException | NoSuchProviderException e) { throw new RuntimeException(e); } } + + public static KeyWrapper createRsaKeyPair(String keyId) { + try { + KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA", BouncyIntegration.PROVIDER); + kpg.initialize(2048); + var keyPair = kpg.generateKeyPair(); + + RSAPublicKey pub = (RSAPublicKey) keyPair.getPublic(); + RSAPrivateCrtKey priv = (RSAPrivateCrtKey) keyPair.getPrivate(); + + // Generate self-signed cert + X500Name subject = new X500Name("CN=example.com"); + + long now = System.currentTimeMillis(); + BigInteger serial = BigInteger.valueOf(now); + Date notBefore = new Date(now); + Date notAfter = new Date(now + 3600000); // 1h + + ContentSigner signer = new JcaContentSignerBuilder("SHA256withRSAandMGF1") + .setProvider("BC") + .build(priv); + X509v3CertificateBuilder certBuilder = new JcaX509v3CertificateBuilder( + subject, serial, notBefore, notAfter, subject, pub); + X509Certificate cert = new JcaX509CertificateConverter() + .setProvider("BC") + .getCertificate(certBuilder.build(signer)); + + KeyWrapper kw = new KeyWrapper(); + kw.setKid(keyId); + kw.setUse(KeyUse.SIG); + kw.setAlgorithm("PS256"); + kw.setType("RSA"); + + kw.setPublicKey(pub); + kw.setPrivateKey(priv); + kw.setCertificate(cert); + kw.setCertificateChain(List.of(cert)); + return kw; + } catch (NoSuchAlgorithmException | OperatorCreationException | CertificateException | NoSuchProviderException e) { + throw new RuntimeException(e); + } + } } 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 new file mode 100644 index 000000000000..48b0aada552b --- /dev/null +++ b/tests/base/src/test/java/org/keycloak/tests/oid4vc/abca/OIDCAttestationBasedClientAuthenticationTest.java @@ -0,0 +1,136 @@ +/* + * Copyright 2024 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.oid4vc.abca; + +import java.security.Key; +import java.security.PublicKey; +import java.util.List; +import java.util.Map; + +import org.keycloak.TokenVerifier; +import org.keycloak.authentication.authenticators.client.AttestationBasedClientAuthenticator.ABCAConfig; +import org.keycloak.authentication.authenticators.client.AttestationBasedClientAuthenticator.ClientAttestationJwt; +import org.keycloak.common.VerificationException; +import org.keycloak.crypto.AsymmetricSignatureSignerContext; +import org.keycloak.crypto.KeyWrapper; +import org.keycloak.jose.jwk.JWK; +import org.keycloak.jose.jwk.JWKBuilder; +import org.keycloak.jose.jws.JWSBuilder; +import org.keycloak.models.AuthenticatorConfigModel; +import org.keycloak.models.RealmModel; +import org.keycloak.protocol.oid4vc.issuance.keybinding.AttestationValidatorUtil; +import org.keycloak.protocol.oidc.representations.OIDCConfigurationRepresentation; +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.tests.oid4vc.OID4VCIssuerTestBase; +import org.keycloak.tests.oid4vc.OID4VCTestContext; +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.protocol.oidc.OIDCLoginProtocol.ATTEST_JWT_CLIENT_AUTH; +import static org.keycloak.tests.oid4vc.OID4VCProofTestUtils.createRsaKeyPair; + +import static org.junit.jupiter.api.Assertions.assertTrue; + + +@KeycloakIntegrationTest(config = OID4VCIssuerTestBase.VCTestServerWithABCAEnabled.class) +public class OIDCAttestationBasedClientAuthenticationTest extends OID4VCIssuerTestBase { + + @InjectRunOnServer + private RunOnServerClient runOnServer; + + private static OIDCMockClientAttester attester; + private static ABCAConfig abcaConfig; + + @TestSetup + public void configure() { + var kw = createRsaKeyPair("openid-abca-attester-key"); + JWK jwk = JWKBuilder.create().rsa(kw.getPublicKey(), kw.getCertificate()); + abcaConfig = new ABCAConfig().setKeys(List.of(jwk)); + attester = new OIDCMockClientAttester(kw); + } + + @BeforeEach + void beforeEach() { + String abcaConfigValue = JsonSerialization.valueAsString(abcaConfig); + runOnServer.run(session -> { + RealmModel realm = session.getContext().getRealm(); + AuthenticatorConfigModel abcaConfig = new AuthenticatorConfigModel(); + abcaConfig.setAlias(OAUTH_CLIENT_ATTESTATION_CONFIG_ATTESTER_JWKS); + abcaConfig.setConfig(Map.of(OAUTH_CLIENT_ATTESTATION_CONFIG_ATTESTER_JWKS, abcaConfigValue)); + realm.addAuthenticatorConfig(abcaConfig); + }); + } + + @Test + public void testTokenEndpointAuthMethods() { + OIDCConfigurationRepresentation oidcConfiguration = oauth.doWellKnownRequest(); + List tokenAuthMethodsSupported = oidcConfiguration.getTokenEndpointAuthMethodsSupported(); + assertTrue(tokenAuthMethodsSupported.contains(ATTEST_JWT_CLIENT_AUTH), "Should contain: " + ATTEST_JWT_CLIENT_AUTH); + } + + @Test + public void testClientAttestationJWT() throws VerificationException { + var ctx = new OID4VCTestContext(client, sdJwtTypeCredentialScope); + + // Call the Attester to get the Client Attestation JWT + // + var kw = wallet.getRSAKeyPair(ctx, "holderKey"); + String attestationJwt = attester.attestWalletKey(client.getClientId(), kw.getPublicKey()); + + // Verification and Processing + // + TokenVerifier.create(attestationJwt, ClientAttestationJwt.class) + .publicKey(attester.getPublicKey()) + .withChecks(TokenVerifier.IS_ACTIVE) + .verify().getToken(); + } + + static class OIDCMockClientAttester { + + private final String issuer; + private final KeyWrapper attesterKey; + + public OIDCMockClientAttester(KeyWrapper attesterKey) { + this.issuer = "https://example.com/mock-attester"; + this.attesterKey = attesterKey; + } + + public PublicKey getPublicKey() { + return (PublicKey) attesterKey.getPublicKey(); + } + + public String attestWalletKey(String clientId, Key pubKey) { + JWK jwk = JWKBuilder.create().rsa(pubKey); + var body = new ClientAttestationJwt() + .issuedFor(issuer) + .subject(clientId) + .confirmation(jwk) + .issuedNowWithTTL(300); // 5min + return new JWSBuilder() + .type(AttestationValidatorUtil.ATTESTATION_JWT_TYP) + .kid(attesterKey.getKid()) + .jsonContent(body) + .sign(new AsymmetricSignatureSignerContext(attesterKey)); + } + } +} diff --git a/tests/base/src/test/java/org/keycloak/tests/oid4vc/abca/OIDCAuthorizationMetadataABCATest.java b/tests/base/src/test/java/org/keycloak/tests/oid4vc/abca/OIDCAuthorizationMetadataABCATest.java deleted file mode 100644 index 99f6b745b484..000000000000 --- a/tests/base/src/test/java/org/keycloak/tests/oid4vc/abca/OIDCAuthorizationMetadataABCATest.java +++ /dev/null @@ -1,40 +0,0 @@ -/* - * Copyright 2024 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.oid4vc.abca; - -import java.util.List; - -import org.keycloak.protocol.oidc.representations.OIDCConfigurationRepresentation; -import org.keycloak.testframework.annotations.KeycloakIntegrationTest; -import org.keycloak.tests.oid4vc.OID4VCIssuerTestBase; - -import org.junit.jupiter.api.Test; - -import static org.keycloak.protocol.oidc.OIDCLoginProtocol.ATTEST_JWT_CLIENT_AUTH; - -import static org.junit.jupiter.api.Assertions.assertTrue; - -@KeycloakIntegrationTest(config = OID4VCIssuerTestBase.VCTestServerWithABCAEnabled.class) -public class OIDCAuthorizationMetadataABCATest extends OID4VCIssuerTestBase { - - @Test - public void testTokenEndpointAuthMethods() { - OIDCConfigurationRepresentation oidcConfiguration = oauth.doWellKnownRequest(); - List tokenAuthMethodsSupported = oidcConfiguration.getTokenEndpointAuthMethodsSupported(); - assertTrue(tokenAuthMethodsSupported.contains(ATTEST_JWT_CLIENT_AUTH), "Should contain: " + ATTEST_JWT_CLIENT_AUTH); - } -} diff --git a/tests/base/src/test/java/org/keycloak/tests/oid4vc/issuance/signing/OID4VCKeyAttestationTest.java b/tests/base/src/test/java/org/keycloak/tests/oid4vc/issuance/signing/OID4VCKeyAttestationTest.java index 8eaeb88d4919..8109cd4680df 100644 --- a/tests/base/src/test/java/org/keycloak/tests/oid4vc/issuance/signing/OID4VCKeyAttestationTest.java +++ b/tests/base/src/test/java/org/keycloak/tests/oid4vc/issuance/signing/OID4VCKeyAttestationTest.java @@ -196,7 +196,7 @@ private String getCNonce() { } private static KeyWrapper getECKey(String keyId) { - return OID4VCProofTestUtils.newEcSigningKey(keyId); + return OID4VCProofTestUtils.createEcKeyPair(keyId); } private static VCIssuanceContext createVCIssuanceContext(KeycloakSession session) { From 04adf4c55d7a40fb430b3a0c0e3f885a6365132b Mon Sep 17 00:00:00 2001 From: Thomas Diesler Date: Thu, 16 Apr 2026 15:23:45 +0200 Subject: [PATCH 4/7] -- test client attestation happy flow Signed-off-by: Thomas Diesler --- .../AttestationBasedClientAuthenticator.java | 99 ++++++++++------ .../tests/oid4vc/OID4VCBasicWallet.java | 55 +++++++++ .../tests/oid4vc/OID4VCTestContext.java | 2 + ...estationBasedClientAuthenticationTest.java | 110 +++++++++++------- .../tests/oid4vc/abca/OIDCClientAttester.java | 17 +++ .../oid4vc/abca/OIDCMockClientAttester.java | 55 +++++++++ .../util/oauth/AbstractHttpPostRequest.java | 3 +- 7 files changed, 265 insertions(+), 76 deletions(-) create mode 100644 tests/base/src/test/java/org/keycloak/tests/oid4vc/abca/OIDCClientAttester.java create mode 100644 tests/base/src/test/java/org/keycloak/tests/oid4vc/abca/OIDCMockClientAttester.java 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 7eb31c8514f6..04feec2ab579 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 @@ -29,11 +29,11 @@ import jakarta.ws.rs.core.Response; import org.keycloak.Config; -import org.keycloak.OAuthErrorException; import org.keycloak.TokenVerifier; import org.keycloak.authentication.AuthenticationFlowError; import org.keycloak.authentication.ClientAuthenticationFlowContext; import org.keycloak.common.Profile; +import org.keycloak.common.util.Base64Url; import org.keycloak.exceptions.TokenVerificationException; import org.keycloak.jose.jwk.JWK; import org.keycloak.jose.jwk.JWKParser; @@ -47,16 +47,19 @@ import org.keycloak.provider.EnvironmentDependentProviderFactory; 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 com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; +import static jakarta.ws.rs.core.Response.Status.BAD_REQUEST; + import static org.keycloak.OAuth2Constants.CLIENT_ID; +import static org.keycloak.OAuthErrorException.INVALID_CLIENT_ATTESTATION; /** @@ -124,8 +127,8 @@ public void authenticateClient(ClientAuthenticationFlowContext context) { } catch (Exception ex) { ServicesLogger.LOGGER.errorValidatingAssertion(ex); - Response challengeResponse = ClientAuthUtil.errorResponse(Response.Status.BAD_REQUEST.getStatusCode(), OAuthErrorException.INVALID_CLIENT_ATTESTATION, ex.getMessage()); - context.failure(AuthenticationFlowError.INVALID_CLIENT_ATTESTATION, challengeResponse); + Response response = ClientAuthUtil.errorResponse(BAD_REQUEST.getStatusCode(), INVALID_CLIENT_ATTESTATION, ex.getMessage()); + context.failure(AuthenticationFlowError.INVALID_CLIENT_ATTESTATION, response); } } @@ -194,13 +197,19 @@ public Confirmation getConfirmation() { return cnf; } - public void setConfirmation(Confirmation cnf) { - this.cnf = cnf; + public ClientAttestationJwt confirmation(JWK jwk) { + cnf = new Confirmation().setJwk(jwk); + return this; } @Override - public ClientAttestationJwt issuedFor(String issuedFor) { - return (ClientAttestationJwt) super.issuedFor(issuedFor); + public ClientAttestationJwt id(String id) { + return (ClientAttestationJwt) super.id(id); + } + + @Override + public ClientAttestationJwt issuer(String issuer) { + return (ClientAttestationJwt) super.issuer(issuer); } @Override @@ -212,12 +221,6 @@ public ClientAttestationJwt subject(String subject) { public ClientAttestationJwt issuedNowWithTTL(int ttl) { return (ClientAttestationJwt) super.issuedNowWithTTL(ttl); } - - @JsonIgnore - public ClientAttestationJwt confirmation(JWK jwk) { - cnf = new Confirmation().setJwk(jwk); - return this; - } } @JsonInclude(JsonInclude.Include.NON_NULL) @@ -248,8 +251,40 @@ public String getChallenge() { return challenge; } - public void setChallenge(String challenge) { + public ClientAttestationPoPJwt challenge(String challenge) { this.challenge = challenge; + return this; + } + + public ClientAttestationPoPJwt randomId() { + id = Base64Url.encode(RandomSecret.createRandomSecret(64)); + return this; + } + + @Override + public ClientAttestationPoPJwt id(String id) { + Base64Url.encode(RandomSecret.createRandomSecret(64)); + return (ClientAttestationPoPJwt) super.id(id); + } + + @Override + public ClientAttestationPoPJwt audience(String... audience) { + return (ClientAttestationPoPJwt) super.audience(audience); + } + + @Override + public ClientAttestationPoPJwt issuer(String issuer) { + return (ClientAttestationPoPJwt) super.issuer(issuer); + } + + @Override + public ClientAttestationPoPJwt subject(String subject) { + return (ClientAttestationPoPJwt) super.subject(subject); + } + + @Override + public ClientAttestationPoPJwt issuedNowWithTTL(int ttl) { + return (ClientAttestationPoPJwt) super.issuedNowWithTTL(ttl); } } @@ -260,24 +295,22 @@ private PublicKey findAttesterPublicKey(ClientAuthenticationFlowContext context, if (Strings.isEmpty(kid)) throw new IllegalArgumentException("Invalid attester kid: " + kid); - String configName = OAUTH_CLIENT_ATTESTATION_CONFIG_ATTESTER_JWKS; - String abcaConfigValue = Optional.ofNullable(context.getAuthenticatorConfig()) - .map(AuthenticatorConfigModel::getConfig).orElse(Map.of()).get(configName); - if (abcaConfigValue == null) - throw new IllegalStateException("Cannot load ABCA config from: " + configName); - - ABCAConfig attesterKeys = JsonSerialization.valueFromString(abcaConfigValue, ABCAConfig.class); - for (JWK key : attesterKeys.keys) { - if (kid.equals(key.getKeyId())) { - String kty = key.getKeyType(); - if (!"RSA".equals(kty)) { - throw new IllegalStateException("Unsupported key type: " + kty); - } - return new JWKParser(key).toPublicKey(); - } - } - - throw new IllegalStateException("No matching key found for kid: " + kid); + String configAlias = OAUTH_CLIENT_ATTESTATION_CONFIG_ATTESTER_JWKS; + AuthenticatorConfigModel configModel = context.getRealm().getAuthenticatorConfigByAlias(configAlias); + String configValue = Optional.ofNullable(configModel) + .map(AuthenticatorConfigModel::getConfig) + .orElse(Map.of()) + .get(configAlias); + if (configValue == null) + throw new IllegalStateException("Cannot load ABCA config from: " + configAlias); + + ABCAConfig attesterKeys = JsonSerialization.valueFromString(configValue, ABCAConfig.class); + PublicKey foundKey = attesterKeys.getKeys().stream() + .filter(k -> kid.equals(k.getKeyId())) + .map(k -> new JWKParser(k).toPublicKey()) + .findAny() + .orElseThrow(() -> new IllegalStateException("No matching key found for kid: " + kid)); + return foundKey; } // Validate the Client Attestation JWT diff --git a/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCBasicWallet.java b/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCBasicWallet.java index bcc907e999f9..2a8d7778aec6 100644 --- a/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCBasicWallet.java +++ b/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCBasicWallet.java @@ -1,6 +1,7 @@ package org.keycloak.tests.oid4vc; import java.io.IOException; +import java.security.Key; import java.util.ArrayList; import java.util.Arrays; import java.util.HashSet; @@ -14,9 +15,12 @@ import org.keycloak.admin.client.Keycloak; import org.keycloak.admin.client.resource.RealmResource; import org.keycloak.admin.client.resource.UserResource; +import org.keycloak.authentication.authenticators.client.AttestationBasedClientAuthenticator.ClientAttestationPoPJwt; +import org.keycloak.crypto.AsymmetricSignatureSignerContext; import org.keycloak.crypto.KeyWrapper; import org.keycloak.jose.jwk.JWK; import org.keycloak.jose.jwk.JWKBuilder; +import org.keycloak.jose.jws.JWSBuilder; import org.keycloak.jose.jws.JWSInput; import org.keycloak.jose.jws.JWSInputException; import org.keycloak.protocol.oid4vc.model.CredentialIssuer; @@ -33,6 +37,8 @@ import org.keycloak.representations.JsonWebToken; import org.keycloak.representations.idm.UserRepresentation; import org.keycloak.testframework.oauth.OAuthClient; +import org.keycloak.tests.oid4vc.abca.OIDCClientAttester; +import org.keycloak.tests.oid4vc.abca.OIDCMockClientAttester; import org.keycloak.testsuite.util.oauth.AccessTokenRequest; import org.keycloak.testsuite.util.oauth.AccessTokenResponse; import org.keycloak.testsuite.util.oauth.AuthorizationEndpointResponse; @@ -48,12 +54,15 @@ import org.keycloak.util.JsonSerialization; import static org.keycloak.OAuth2Constants.AUTHORIZATION_DETAILS; +import static org.keycloak.authentication.authenticators.client.AttestationBasedClientAuthenticator.OAUTH_CLIENT_ATTESTATION_POP_JWT_TYPE; import static org.keycloak.constants.OID4VCIConstants.CREDENTIAL_OFFER_CREATE; import static org.keycloak.tests.oid4vc.OID4VCIssuerTestBase.TEST_PASSWORD; import static org.keycloak.tests.oid4vc.OID4VCIssuerTestBase.VCTestRealmConfig.TEST_REALM_NAME; +import static org.keycloak.tests.oid4vc.OID4VCProofTestUtils.createRsaKeyPair; import static org.keycloak.tests.oid4vc.OID4VCTestContext.ACCESS_TOKEN_RESPONSE_ATTACHMENT_KEY; import static org.keycloak.tests.oid4vc.OID4VCTestContext.AUTHORIZATION_SERVER_METADATA_ATTACHMENT_KEY; import static org.keycloak.tests.oid4vc.OID4VCTestContext.AttachmentKey; +import static org.keycloak.tests.oid4vc.OID4VCTestContext.CLIENT_ATTESTER_ATTACHMENT_KEY; import static org.keycloak.tests.oid4vc.OID4VCTestContext.CREDENTIALS_OFFER_RESPONSE_ATTACHMENT_KEY; import static org.keycloak.tests.oid4vc.OID4VCTestContext.CREDENTIALS_OFFER_URI_RESPONSE_ATTACHMENT_KEY; import static org.keycloak.tests.oid4vc.OID4VCTestContext.CREDENTIALS_RESPONSE_ATTACHMENT_KEY; @@ -162,6 +171,44 @@ public OIDCConfigurationRepresentation getAuthorizationServerMetadata(OID4VCTest return authServerMetadata; } + public OIDCClientAttester getClientAttester(OID4VCTestContext ctx) { + OIDCClientAttester attester = ctx.getAttachment(CLIENT_ATTESTER_ATTACHMENT_KEY); + if (attester == null) { + var kw = createRsaKeyPair("openid-abca-attester-key"); + attester = new OIDCMockClientAttester(kw); + ctx.putAttachment(CLIENT_ATTESTER_ATTACHMENT_KEY, attester); + } + return attester; + } + + public String buildClientAttestationJWT(OID4VCTestContext ctx, Key pubKey) { + String clientId = ctx.getClient().getClientId(); + OIDCClientAttester attester = getClientAttester(ctx); + String attestationJwt = attester.attestWalletKey(clientId, pubKey); + return attestationJwt; + } + + public String buildClientAttestationPoPJWT(OID4VCTestContext ctx, KeyWrapper walletKey) { + var issuer = getIssuerMetadata(ctx).getCredentialIssuer(); + + // Build Client Attestation PoP JWT + // + String clientId = ctx.getClient().getClientId(); + ClientAttestationPoPJwt body = new ClientAttestationPoPJwt() + .audience(issuer) + .issuer(clientId) + .issuedNowWithTTL(300) // 5min + .randomId(); + + String attestationPoPJwt = new JWSBuilder() + .type(OAUTH_CLIENT_ATTESTATION_POP_JWT_TYPE) + .kid(walletKey.getKid()) + .jsonContent(body) + .sign(new AsymmetricSignatureSignerContext(walletKey)); + + return attestationPoPJwt; + } + public Proofs generateAttestationProof(OID4VCTestContext ctx, Consumer attestationKeyConsumer) { KeyWrapper attestationKey = getECKeyPair(ctx, "attestationKey"); KeyWrapper proofKey = getECKeyPair(ctx, "proofKey"); @@ -190,6 +237,10 @@ public Proofs generateJwtProof(OID4VCTestContext ctx) { return Proofs.create(ProofType.JWT, OID4VCProofTestUtils.generateJwtProof(aud, kw, nonce)); } + public KeyWrapper getECKeyPair(OID4VCTestContext ctx) { + return getECKeyPair(ctx, null); + } + public KeyWrapper getECKeyPair(OID4VCTestContext ctx, String keyId) { String cacheKey = keyId != null ? keyId : ctx.getHolder() + "_ec_key"; AttachmentKey attachmentKey = new AttachmentKey<>(cacheKey, KeyWrapper.class); @@ -201,6 +252,10 @@ public KeyWrapper getECKeyPair(OID4VCTestContext ctx, String keyId) { return kw; } + public KeyWrapper getRSAKeyPair(OID4VCTestContext ctx) { + return getRSAKeyPair(ctx, null); + } + public KeyWrapper getRSAKeyPair(OID4VCTestContext ctx, String keyId) { String cacheKey = keyId != null ? keyId : ctx.getHolder() + "_rsa_key"; AttachmentKey attachmentKey = new AttachmentKey<>(cacheKey, KeyWrapper.class); diff --git a/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCTestContext.java b/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCTestContext.java index 88cc326b873b..b610c84690bf 100644 --- a/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCTestContext.java +++ b/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCTestContext.java @@ -15,6 +15,7 @@ import org.keycloak.protocol.oid4vc.model.OID4VCAuthorizationDetail; import org.keycloak.protocol.oidc.representations.OIDCConfigurationRepresentation; import org.keycloak.representations.idm.ClientRepresentation; +import org.keycloak.tests.oid4vc.abca.OIDCClientAttester; import org.keycloak.testsuite.util.oauth.AccessTokenResponse; import org.keycloak.testsuite.util.oauth.oid4vc.CredentialOfferResponse; import org.keycloak.testsuite.util.oauth.oid4vc.CredentialOfferUriResponse; @@ -33,6 +34,7 @@ public class OID4VCTestContext { public static final AttachmentKey AUTHORIZATION_SERVER_METADATA_ATTACHMENT_KEY = new AttachmentKey<>(OIDCConfigurationRepresentation.class); public static final AttachmentKey ISSUER_METADATA_ATTACHMENT_KEY = new AttachmentKey<>(CredentialIssuer.class); + public static final AttachmentKey CLIENT_ATTESTER_ATTACHMENT_KEY = new AttachmentKey<>(OIDCClientAttester.class); public static final AttachmentKey CREDENTIALS_OFFER_URI_RESPONSE_ATTACHMENT_KEY = new AttachmentKey<>(CredentialOfferUriResponse.class); public static final AttachmentKey CREDENTIALS_OFFER_RESPONSE_ATTACHMENT_KEY = new AttachmentKey<>(CredentialOfferResponse.class); public static final AttachmentKey ACCESS_TOKEN_RESPONSE_ATTACHMENT_KEY = new AttachmentKey<>(AccessTokenResponse.class); 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 48b0aada552b..372f636c8bee 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 @@ -16,7 +16,6 @@ */ package org.keycloak.tests.oid4vc.abca; -import java.security.Key; import java.security.PublicKey; import java.util.List; import java.util.Map; @@ -24,15 +23,13 @@ import org.keycloak.TokenVerifier; 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.common.VerificationException; -import org.keycloak.crypto.AsymmetricSignatureSignerContext; -import org.keycloak.crypto.KeyWrapper; import org.keycloak.jose.jwk.JWK; import org.keycloak.jose.jwk.JWKBuilder; -import org.keycloak.jose.jws.JWSBuilder; import org.keycloak.models.AuthenticatorConfigModel; import org.keycloak.models.RealmModel; -import org.keycloak.protocol.oid4vc.issuance.keybinding.AttestationValidatorUtil; +import org.keycloak.protocol.oid4vc.model.CredentialResponse; import org.keycloak.protocol.oidc.representations.OIDCConfigurationRepresentation; import org.keycloak.testframework.annotations.KeycloakIntegrationTest; import org.keycloak.testframework.annotations.TestSetup; @@ -40,15 +37,21 @@ import org.keycloak.testframework.remote.runonserver.RunOnServerClient; import org.keycloak.tests.oid4vc.OID4VCIssuerTestBase; import org.keycloak.tests.oid4vc.OID4VCTestContext; +import org.keycloak.testsuite.util.oauth.AccessTokenResponse; +import org.keycloak.testsuite.util.oauth.PkceGenerator; 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_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; import static org.keycloak.tests.oid4vc.OID4VCProofTestUtils.createRsaKeyPair; +import static org.keycloak.tests.oid4vc.OID4VCTestContext.CLIENT_ATTESTER_ATTACHMENT_KEY; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -58,13 +61,15 @@ public class OIDCAttestationBasedClientAuthenticationTest extends OID4VCIssuerTe @InjectRunOnServer private RunOnServerClient runOnServer; - private static OIDCMockClientAttester attester; + private static OIDCClientAttester attester; private static ABCAConfig abcaConfig; + OID4VCTestContext ctx; + @TestSetup public void configure() { var kw = createRsaKeyPair("openid-abca-attester-key"); - JWK jwk = JWKBuilder.create().rsa(kw.getPublicKey(), kw.getCertificate()); + JWK jwk = JWKBuilder.create().kid(kw.getKid()).rsa(kw.getPublicKey(), kw.getCertificate()); abcaConfig = new ABCAConfig().setKeys(List.of(jwk)); attester = new OIDCMockClientAttester(kw); } @@ -74,11 +79,14 @@ void beforeEach() { String abcaConfigValue = JsonSerialization.valueAsString(abcaConfig); runOnServer.run(session -> { RealmModel realm = session.getContext().getRealm(); - AuthenticatorConfigModel abcaConfig = new AuthenticatorConfigModel(); - abcaConfig.setAlias(OAUTH_CLIENT_ATTESTATION_CONFIG_ATTESTER_JWKS); - abcaConfig.setConfig(Map.of(OAUTH_CLIENT_ATTESTATION_CONFIG_ATTESTER_JWKS, abcaConfigValue)); - realm.addAuthenticatorConfig(abcaConfig); + AuthenticatorConfigModel configModel = new AuthenticatorConfigModel(); + configModel.setAlias(OAUTH_CLIENT_ATTESTATION_CONFIG_ATTESTER_JWKS); + configModel.setConfig(Map.of(OAUTH_CLIENT_ATTESTATION_CONFIG_ATTESTER_JWKS, abcaConfigValue)); + realm.addAuthenticatorConfig(configModel); }); + oauth.client(pubClient.getClientId()); + ctx = new OID4VCTestContext(pubClient, sdJwtTypeCredentialScope); + ctx.putAttachment(CLIENT_ATTESTER_ATTACHMENT_KEY, attester); } @Test @@ -90,14 +98,13 @@ public void testTokenEndpointAuthMethods() { @Test public void testClientAttestationJWT() throws VerificationException { - var ctx = new OID4VCTestContext(client, sdJwtTypeCredentialScope); // Call the Attester to get the Client Attestation JWT // - var kw = wallet.getRSAKeyPair(ctx, "holderKey"); - String attestationJwt = attester.attestWalletKey(client.getClientId(), kw.getPublicKey()); + var walletKey = wallet.getRSAKeyPair(ctx); + String attestationJwt = wallet.buildClientAttestationJWT(ctx, walletKey.getPublicKey()); - // Verification and Processing + // Verify the Client Attestation JWT // TokenVerifier.create(attestationJwt, ClientAttestationJwt.class) .publicKey(attester.getPublicKey()) @@ -105,32 +112,51 @@ public void testClientAttestationJWT() throws VerificationException { .verify().getToken(); } - static class OIDCMockClientAttester { - - private final String issuer; - private final KeyWrapper attesterKey; - - public OIDCMockClientAttester(KeyWrapper attesterKey) { - this.issuer = "https://example.com/mock-attester"; - this.attesterKey = attesterKey; - } - - public PublicKey getPublicKey() { - return (PublicKey) attesterKey.getPublicKey(); - } - - public String attestWalletKey(String clientId, Key pubKey) { - JWK jwk = JWKBuilder.create().rsa(pubKey); - var body = new ClientAttestationJwt() - .issuedFor(issuer) - .subject(clientId) - .confirmation(jwk) - .issuedNowWithTTL(300); // 5min - return new JWSBuilder() - .type(AttestationValidatorUtil.ATTESTATION_JWT_TYP) - .kid(attesterKey.getKid()) - .jsonContent(body) - .sign(new AsymmetricSignatureSignerContext(attesterKey)); - } + @Test + public void testClientAttestationPoPJWT() throws VerificationException { + + // Build Client Attestation PoP JWT + // + var walletKey = wallet.getRSAKeyPair(ctx); + String attestationPoPJwt = wallet.buildClientAttestationPoPJWT(ctx, walletKey); + + // Verify the Client Attestation PoP JWT + // + TokenVerifier.create(attestationPoPJwt, ClientAttestationPoPJwt.class) + .publicKey((PublicKey) walletKey.getPublicKey()) + .withChecks(TokenVerifier.IS_ACTIVE) + .verify().getToken(); + } + + @Test + public void testClientAttestationHappyFlow() { + + var key = wallet.getRSAKeyPair(ctx); + String attestationJwt = wallet.buildClientAttestationJWT(ctx, key.getPublicKey()); + String attestationPoPJwt = wallet.buildClientAttestationPoPJWT(ctx, key); + + PkceGenerator pkce = PkceGenerator.s256(); + + String authCode = wallet.authorizationRequest() + .scope(ctx.getScope()) + .codeChallenge(pkce) + .send(ctx.getHolder(), TEST_PASSWORD) + .getCode(); + + AccessTokenResponse tokenResponse = wallet.accessTokenRequest(ctx, authCode) + .header(OAUTH_CLIENT_ATTESTATION_HEADER, attestationJwt) + .header(OAUTH_CLIENT_ATTESTATION_POP_HEADER, attestationPoPJwt) + .codeVerifier(pkce) + .send(); + String accessToken = tokenResponse.getAccessToken(); + + String credIdentifier = ctx.getAuthorizedCredentialIdentifier(); + CredentialResponse credResponse = wallet.credentialRequest(ctx, accessToken) + .credentialIdentifier(credIdentifier) + .proofs(wallet.generateJwtProof(ctx)) + .send().getCredentialResponse(); + + assertFalse(credResponse.getCredentials().isEmpty(), "No credential"); + } } diff --git a/tests/base/src/test/java/org/keycloak/tests/oid4vc/abca/OIDCClientAttester.java b/tests/base/src/test/java/org/keycloak/tests/oid4vc/abca/OIDCClientAttester.java new file mode 100644 index 000000000000..0118d5e4f2a6 --- /dev/null +++ b/tests/base/src/test/java/org/keycloak/tests/oid4vc/abca/OIDCClientAttester.java @@ -0,0 +1,17 @@ +package org.keycloak.tests.oid4vc.abca; + +import java.security.Key; +import java.security.PublicKey; +import java.security.cert.X509Certificate; + +public interface OIDCClientAttester { + + String getIssuer(); + + PublicKey getPublicKey(); + + X509Certificate getCertificate(); + + String attestWalletKey(String clientId, Key pubKey); + +} diff --git a/tests/base/src/test/java/org/keycloak/tests/oid4vc/abca/OIDCMockClientAttester.java b/tests/base/src/test/java/org/keycloak/tests/oid4vc/abca/OIDCMockClientAttester.java new file mode 100644 index 000000000000..aac91339b19b --- /dev/null +++ b/tests/base/src/test/java/org/keycloak/tests/oid4vc/abca/OIDCMockClientAttester.java @@ -0,0 +1,55 @@ +package org.keycloak.tests.oid4vc.abca; + +import java.security.Key; +import java.security.PublicKey; +import java.security.cert.X509Certificate; + +import org.keycloak.authentication.authenticators.client.AttestationBasedClientAuthenticator.ClientAttestationJwt; +import org.keycloak.crypto.AsymmetricSignatureSignerContext; +import org.keycloak.crypto.KeyWrapper; +import org.keycloak.jose.jwk.JWK; +import org.keycloak.jose.jwk.JWKBuilder; +import org.keycloak.jose.jws.JWSBuilder; + +import static org.keycloak.authentication.authenticators.client.AttestationBasedClientAuthenticator.OAUTH_CLIENT_ATTESTATION_JWT_TYPE; + +public class OIDCMockClientAttester implements OIDCClientAttester { + + private final String issuer; + private final KeyWrapper attesterKey; + + public OIDCMockClientAttester(KeyWrapper attesterKey) { + this.issuer = "https://example.com/mock-attester"; + this.attesterKey = attesterKey; + } + + @Override + public String getIssuer() { + return issuer; + } + + @Override + public PublicKey getPublicKey() { + return (PublicKey) attesterKey.getPublicKey(); + } + + @Override + public X509Certificate getCertificate() { + return attesterKey.getCertificate(); + } + + @Override + public String attestWalletKey(String clientId, Key pubKey) { + JWK jwk = JWKBuilder.create().rsa(pubKey); + ClientAttestationJwt body = new ClientAttestationJwt() + .issuer(issuer) + .subject(clientId) + .confirmation(jwk) + .issuedNowWithTTL(300); // 5min + return new JWSBuilder() + .type(OAUTH_CLIENT_ATTESTATION_JWT_TYPE) + .kid(attesterKey.getKid()) + .jsonContent(body) + .sign(new AsymmetricSignatureSignerContext(attesterKey)); + } +} diff --git a/tests/utils-shared/src/main/java/org/keycloak/testsuite/util/oauth/AbstractHttpPostRequest.java b/tests/utils-shared/src/main/java/org/keycloak/testsuite/util/oauth/AbstractHttpPostRequest.java index 5d0fbb9de58f..460863f4c0b4 100644 --- a/tests/utils-shared/src/main/java/org/keycloak/testsuite/util/oauth/AbstractHttpPostRequest.java +++ b/tests/utils-shared/src/main/java/org/keycloak/testsuite/util/oauth/AbstractHttpPostRequest.java @@ -114,10 +114,11 @@ public T clientJwt(String clientAssertion, String clientAssertionType) { return request(); } - protected void header(String name, String value) { + public T header(String name, String value) { if (value != null) { headers.put(name, value); } + return request(); } protected void parameter(String name, String value) { From f5be1530f437dc7d519deb381ff131900db5b9f0 Mon Sep 17 00:00:00 2001 From: Thomas Diesler Date: Fri, 17 Apr 2026 08:25:17 +0200 Subject: [PATCH 5/7] -- address review comments from @mposolda (thanks) Signed-off-by: Thomas Diesler --- .../AttestationBasedClientAuthenticator.java | 101 +++++++++++++----- ...estationBasedClientAuthenticationTest.java | 47 +++++++- 2 files changed, 114 insertions(+), 34 deletions(-) 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 04feec2ab579..13d940ffb289 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 @@ -18,6 +18,7 @@ package org.keycloak.authentication.authenticators.client; +import java.nio.charset.StandardCharsets; import java.security.PublicKey; import java.util.List; import java.util.Map; @@ -34,9 +35,14 @@ import org.keycloak.authentication.ClientAuthenticationFlowContext; import org.keycloak.common.Profile; import org.keycloak.common.util.Base64Url; +import org.keycloak.crypto.KeyUse; +import org.keycloak.crypto.KeyWrapper; +import org.keycloak.crypto.SignatureProvider; +import org.keycloak.exceptions.TokenSignatureInvalidException; import org.keycloak.exceptions.TokenVerificationException; import org.keycloak.jose.jwk.JWK; 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; @@ -290,33 +296,45 @@ public ClientAttestationPoPJwt issuedNowWithTTL(int ttl) { // Private --------------------------------------------------------------------------------------------------------- - private PublicKey findAttesterPublicKey(ClientAuthenticationFlowContext context, String kid) { + private KeyWrapper findAttesterKey(ClientAuthenticationFlowContext context, String kid) { if (Strings.isEmpty(kid)) throw new IllegalArgumentException("Invalid attester kid: " + kid); - String configAlias = OAUTH_CLIENT_ATTESTATION_CONFIG_ATTESTER_JWKS; - AuthenticatorConfigModel configModel = context.getRealm().getAuthenticatorConfigByAlias(configAlias); - String configValue = Optional.ofNullable(configModel) - .map(AuthenticatorConfigModel::getConfig) - .orElse(Map.of()) - .get(configAlias); + 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 ABCA config from: " + configAlias); + throw new IllegalStateException("Cannot load attester keys: " + OAUTH_CLIENT_ATTESTATION_CONFIG_ATTESTER_JWKS); ABCAConfig attesterKeys = JsonSerialization.valueFromString(configValue, ABCAConfig.class); - PublicKey foundKey = attesterKeys.getKeys().stream() + JWK jwk = attesterKeys.getKeys().stream() .filter(k -> kid.equals(k.getKeyId())) - .map(k -> new JWKParser(k).toPublicKey()) .findAny() .orElseThrow(() -> new IllegalStateException("No matching key found for kid: " + kid)); - return foundKey; + + return toPublicKeyWrapper(jwk); + } + + private KeyWrapper toPublicKeyWrapper(JWK jwk) { + PublicKey publicKey = new JWKParser(jwk).toPublicKey(); + KeyWrapper kw = new KeyWrapper(); + kw.setPublicKey(publicKey); + kw.setUse(KeyUse.SIG); + kw.setType(jwk.getKeyType()); + kw.setAlgorithm(jwk.getAlgorithm()); + return kw; } // Validate the Client Attestation JWT // https://www.ietf.org/archive/id/draft-ietf-oauth-attestation-based-client-auth-07.html#section-5.1 private ClientAttestationJwt validateClientAttestationJwt(ClientAuthenticationFlowContext context) throws Exception { - RealmModel realmModel = context.getSession().getContext().getRealm(); + + KeycloakSession session = context.getSession(); + RealmModel realmModel = session.getContext().getRealm(); HttpHeaders headers = context.getHttpRequest().getHttpHeaders(); String headerValue = Optional.ofNullable(headers.getHeaderString(OAUTH_CLIENT_ATTESTATION_HEADER)) @@ -330,10 +348,10 @@ private ClientAttestationJwt validateClientAttestationJwt(ClientAuthenticationFl throw new IllegalArgumentException("The JWS type MUST be " + OAUTH_CLIENT_ATTESTATION_JWT_TYPE + " instead of " + jwsType); // Get the client model from the JWT subject - ClientAttestationJwt attesterJwt = jws.readJsonContent(ClientAttestationJwt.class); - ClientModel clientModel = Optional.ofNullable(attesterJwt.getSubject()) + ClientAttestationJwt attestationJwt = jws.readJsonContent(ClientAttestationJwt.class); + ClientModel clientModel = Optional.ofNullable(attestationJwt.getSubject()) .map(realmModel::getClientByClientId) - .orElseThrow(() -> new TokenVerificationException(attesterJwt, "The sub (subject) claim MUST identify a known client_id")); + .orElseThrow(() -> new TokenVerificationException(attestationJwt, "The sub (subject) claim MUST identify a known client_id")); // Set the target client in the context before we attempt signature verification context.setClient(clientModel); @@ -363,24 +381,37 @@ private ClientAttestationJwt validateClientAttestationJwt(ClientAuthenticationFl // The signature of the Client Attestation JWT verifies with the public key of a known and trusted Attester // - PublicKey publicKey = findAttesterPublicKey(context, jws.getHeader().getKeyId()); + KeyWrapper attesterKey = findAttesterKey(context, jws.getHeader().getKeyId()); - // Verification and Processing + // Client Attestation JWT verification without signature check // - TokenVerifier.create(headerValue, ClientAttestationJwt.class) - .publicKey(publicKey) + TokenVerifier.createWithoutSignature(attestationJwt) .withChecks(subCheck, issCheck, cnfCheck, TokenVerifier.IS_ACTIVE) - .verify().getToken(); + .verify(); + + // Client Attestation JWT signature check + // + Algorithm algorithm = jws.getHeader().getAlgorithm(); + SignatureProvider signatureProvider = session.getProvider(SignatureProvider.class, algorithm.name()); + if (signatureProvider == null) { + throw new TokenVerificationException(attestationJwt, "Signature provider not found for algorithm: " + algorithm); + } + byte[] data = jws.getEncodedSignatureInput().getBytes(StandardCharsets.UTF_8); + if (!signatureProvider.verifier(attesterKey).verify(data, jws.getSignature())) { + throw new TokenSignatureInvalidException(attestationJwt, "Invalid token signature"); + } // [TODO] The alg JOSE Header Parameter for both JWTs indicates a registered asymmetric digital signature algorithm // [TODO] The key contained in the cnf claim of the Client Attestation JWT is not a private key - return attesterJwt; + return attestationJwt; } // Validate the Client Attestation PoP JWT // https://www.ietf.org/archive/id/draft-ietf-oauth-attestation-based-client-auth-07.html#section-5.2 - private void validateClientAttestationPoPJwt(ClientAuthenticationFlowContext context, ClientAttestationJwt attesterJwt) throws Exception { + private ClientAttestationPoPJwt validateClientAttestationPoPJwt(ClientAuthenticationFlowContext context, ClientAttestationJwt attesterJwt) throws Exception { + + KeycloakSession session = context.getSession(); HttpHeaders headers = context.getHttpRequest().getHttpHeaders(); String headerValue = Optional.ofNullable(headers.getHeaderString(OAUTH_CLIENT_ATTESTATION_POP_HEADER)) @@ -391,6 +422,8 @@ private void validateClientAttestationPoPJwt(ClientAuthenticationFlowContext con if (!OAUTH_CLIENT_ATTESTATION_POP_JWT_TYPE.equals(jwsType)) throw new IllegalArgumentException("The JWS type MUST be " + OAUTH_CLIENT_ATTESTATION_POP_JWT_TYPE + " instead of " + jwsType); + ClientAttestationPoPJwt attestationPoPJwt = jws.readJsonContent(ClientAttestationPoPJwt.class); + // Define a few Client Attestation JWT checks // @@ -421,17 +454,25 @@ private void validateClientAttestationPoPJwt(ClientAuthenticationFlowContext con // The public key used to verify the ClientAttestationPoP JWT MUST be the key located in the "cnf" claim of the corresponding ClientAttestation JWT // JWK jwk = attesterJwt.getConfirmation().getJwk(); - PublicKey publicKey = JWKParser.create() - .parse(JsonSerialization.valueAsString(jwk)) - .toPublicKey(); + KeyWrapper clientKey = toPublicKeyWrapper(jwk); - // Verification and Processing + // Client Attestation PoP JWT verification without signature check // - TokenVerifier.create(headerValue, ClientAttestationPoPJwt.class) - .publicKey(publicKey) + TokenVerifier.createWithoutSignature(attestationPoPJwt) .withChecks(jtiCheck, iatCheck, issCheck, audCheck) - .verify().getToken(); + .verify(); + // Client Attestation PoP JWT signature check + // + Algorithm algorithm = jws.getHeader().getAlgorithm(); + SignatureProvider signatureProvider = session.getProvider(SignatureProvider.class, algorithm.name()); + if (signatureProvider == null) { + throw new TokenVerificationException(attestationPoPJwt, "Signature provider not found for algorithm: " + algorithm); + } + byte[] data = jws.getEncodedSignatureInput().getBytes(StandardCharsets.UTF_8); + if (!signatureProvider.verifier(clientKey).verify(data, jws.getSignature())) { + throw new TokenSignatureInvalidException(attestationPoPJwt, "Invalid token signature"); + } // [TODO] The aud (audience) claim MUST specify a value that identifies the authorization server as an intended audience // [TODO] The authorization server can utilize the jti value for replay attack detection @@ -439,6 +480,8 @@ private void validateClientAttestationPoPJwt(ClientAuthenticationFlowContext con // [TODO] If the server provided a challenge value to the client, the challenge claim is present in the Client Attestation PoP JWT and matches the server-provided challenge value. // [TODO] Additional checks to guarantee replay protection for the Client Attestation PoP JWT might need to be applied + + return attestationPoPJwt; } // Error Message specifically related to the use of client attestations 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 372f636c8bee..591246ba54df 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 @@ -21,6 +21,7 @@ import java.util.Map; 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; @@ -38,6 +39,7 @@ import org.keycloak.tests.oid4vc.OID4VCIssuerTestBase; import org.keycloak.tests.oid4vc.OID4VCTestContext; import org.keycloak.testsuite.util.oauth.AccessTokenResponse; +import org.keycloak.testsuite.util.oauth.AuthorizationEndpointResponse; import org.keycloak.testsuite.util.oauth.PkceGenerator; import org.keycloak.util.JsonSerialization; @@ -52,6 +54,7 @@ import static org.keycloak.tests.oid4vc.OID4VCTestContext.CLIENT_ATTESTER_ATTACHMENT_KEY; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -80,7 +83,7 @@ void beforeEach() { runOnServer.run(session -> { RealmModel realm = session.getContext().getRealm(); AuthenticatorConfigModel configModel = new AuthenticatorConfigModel(); - configModel.setAlias(OAUTH_CLIENT_ATTESTATION_CONFIG_ATTESTER_JWKS); + configModel.setAlias(AttestationBasedClientAuthenticator.PROVIDER_ID); configModel.setConfig(Map.of(OAUTH_CLIENT_ATTESTATION_CONFIG_ATTESTER_JWKS, abcaConfigValue)); realm.addAuthenticatorConfig(configModel); }); @@ -129,7 +132,7 @@ public void testClientAttestationPoPJWT() throws VerificationException { } @Test - public void testClientAttestationHappyFlow() { + public void testClientAttestationHappyFlow_PublicClient() { var key = wallet.getRSAKeyPair(ctx); String attestationJwt = wallet.buildClientAttestationJWT(ctx, key.getPublicKey()); @@ -137,11 +140,12 @@ public void testClientAttestationHappyFlow() { PkceGenerator pkce = PkceGenerator.s256(); - String authCode = wallet.authorizationRequest() + AuthorizationEndpointResponse authResponse = wallet.authorizationRequest() .scope(ctx.getScope()) .codeChallenge(pkce) - .send(ctx.getHolder(), TEST_PASSWORD) - .getCode(); + .send(ctx.getHolder(), TEST_PASSWORD); + String authCode = authResponse.getCode(); + assertNotNull(authCode, "No auth code"); AccessTokenResponse tokenResponse = wallet.accessTokenRequest(ctx, authCode) .header(OAUTH_CLIENT_ATTESTATION_HEADER, attestationJwt) @@ -149,6 +153,7 @@ public void testClientAttestationHappyFlow() { .codeVerifier(pkce) .send(); String accessToken = tokenResponse.getAccessToken(); + assertNotNull(authCode, "No accessToken"); String credIdentifier = ctx.getAuthorizedCredentialIdentifier(); CredentialResponse credResponse = wallet.credentialRequest(ctx, accessToken) @@ -157,6 +162,38 @@ public void testClientAttestationHappyFlow() { .send().getCredentialResponse(); assertFalse(credResponse.getCredentials().isEmpty(), "No credential"); + } + + @Test + public void testClientAttestationHappyFlow_ConfidentialClient() { + + ctx = new OID4VCTestContext(client, sdJwtTypeCredentialScope); + ctx.putAttachment(CLIENT_ATTESTER_ATTACHMENT_KEY, attester); + oauth.client(ctx.getClient().getClientId(), ctx.getClient().getSecret()); + + var key = wallet.getRSAKeyPair(ctx); + String attestationJwt = wallet.buildClientAttestationJWT(ctx, key.getPublicKey()); + String attestationPoPJwt = wallet.buildClientAttestationPoPJWT(ctx, key); + + AuthorizationEndpointResponse authResponse = wallet.authorizationRequest() + .scope(ctx.getScope()) + .send(ctx.getHolder(), TEST_PASSWORD); + String authCode = authResponse.getCode(); + assertNotNull(authCode, "No auth code"); + + AccessTokenResponse tokenResponse = wallet.accessTokenRequest(ctx, authCode) + .header(OAUTH_CLIENT_ATTESTATION_HEADER, attestationJwt) + .header(OAUTH_CLIENT_ATTESTATION_POP_HEADER, attestationPoPJwt) + .send(); + String accessToken = tokenResponse.getAccessToken(); + assertNotNull(authCode, "No accessToken"); + + String credIdentifier = ctx.getAuthorizedCredentialIdentifier(); + CredentialResponse credResponse = wallet.credentialRequest(ctx, accessToken) + .credentialIdentifier(credIdentifier) + .proofs(wallet.generateJwtProof(ctx)) + .send().getCredentialResponse(); + assertFalse(credResponse.getCredentials().isEmpty(), "No credential"); } } From 0fb38612b5e01057c77e8c5c75a057cbe596a43b Mon Sep 17 00:00:00 2001 From: Thomas Diesler Date: Fri, 17 Apr 2026 11:53:04 +0200 Subject: [PATCH 6/7] -- revisit key algorithm handling Signed-off-by: Thomas Diesler --- .../tests/oid4vc/OID4VCBasicWallet.java | 10 +++---- ...estationBasedClientAuthenticationTest.java | 27 ++++++++++--------- .../tests/oid4vc/abca/OIDCClientAttester.java | 5 ++-- .../oid4vc/abca/OIDCMockClientAttester.java | 9 ++++--- 4 files changed, 29 insertions(+), 22 deletions(-) diff --git a/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCBasicWallet.java b/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCBasicWallet.java index 2a8d7778aec6..4c86bbe769a4 100644 --- a/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCBasicWallet.java +++ b/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCBasicWallet.java @@ -1,7 +1,6 @@ package org.keycloak.tests.oid4vc; import java.io.IOException; -import java.security.Key; import java.util.ArrayList; import java.util.Arrays; import java.util.HashSet; @@ -181,10 +180,12 @@ public OIDCClientAttester getClientAttester(OID4VCTestContext ctx) { return attester; } - public String buildClientAttestationJWT(OID4VCTestContext ctx, Key pubKey) { + public String buildClientAttestationJWT(OID4VCTestContext ctx, KeyWrapper walletKey) { + KeyWrapper pubKeyWrapper = walletKey.cloneKey(); + pubKeyWrapper.setPrivateKey(null); String clientId = ctx.getClient().getClientId(); OIDCClientAttester attester = getClientAttester(ctx); - String attestationJwt = attester.attestWalletKey(clientId, pubKey); + String attestationJwt = attester.attestWalletKey(clientId, pubKeyWrapper); return attestationJwt; } @@ -205,7 +206,6 @@ public String buildClientAttestationPoPJWT(OID4VCTestContext ctx, KeyWrapper wal .kid(walletKey.getKid()) .jsonContent(body) .sign(new AsymmetricSignatureSignerContext(walletKey)); - return attestationPoPJwt; } @@ -233,7 +233,7 @@ public Proofs generateAttestationProof(OID4VCTestContext ctx, Consumer Date: Fri, 17 Apr 2026 18:03:49 +0200 Subject: [PATCH 7/7] -- reverse unrelated DPoP changes Signed-off-by: Thomas Diesler --- .../org/keycloak/services/util/DPoPUtil.java | 22 ++++--------------- 1 file changed, 4 insertions(+), 18 deletions(-) diff --git a/services/src/main/java/org/keycloak/services/util/DPoPUtil.java b/services/src/main/java/org/keycloak/services/util/DPoPUtil.java index 5c53643fe2c6..67bf60720f4f 100644 --- a/services/src/main/java/org/keycloak/services/util/DPoPUtil.java +++ b/services/src/main/java/org/keycloak/services/util/DPoPUtil.java @@ -213,7 +213,7 @@ private static DPoP validateDPoP(KeycloakSession session, URI uri, String method verifier.verifierContext(signatureVerifier); verifier.withChecks( DPoPClaimsCheck.INSTANCE, - new DPoPHTTPCheck(session, uri, method), + new DPoPHTTPCheck(uri, method), new DPoPIsActiveCheck(session, lifetime, clockSkew), new DPoPReplayCheck(session, lifetime + clockSkew)); @@ -356,10 +356,8 @@ private static class DPoPHTTPCheck implements TokenVerifier.Predicate { private final URI uri; private final String method; - private final KeycloakSession session; - DPoPHTTPCheck(KeycloakSession session, URI uri, String method) { - this.session = session; + DPoPHTTPCheck(URI uri, String method) { this.uri = uri; this.method = method; } @@ -367,20 +365,8 @@ private static class DPoPHTTPCheck implements TokenVerifier.Predicate { @Override public boolean test(DPoP t) throws DPoPVerificationException { try { - URI dpopUri = new URI(t.getHttpUri()); - if (!normalize(dpopUri).equals(normalize(uri))) { - // When Keycloak runs behind a reverse proxy, it may not be possible to reconstruct - // the expected uri from the request alone - port information may get lost. - // We also accept a DPoP Uri that matches the configured KC_HOSTNAME - UriInfo uriInfo = session.getContext().getHttpRequest().getUri(); - URI expectedUri = session.getContext() - .getUri() - .getBaseUriBuilder() - .replacePath(uriInfo.getPath()) - .build(); - if (!normalize(dpopUri).equals(normalize(expectedUri))) - throw new DPoPVerificationException(t, "DPoP HTTP URL mismatch"); - } + if (!normalize(new URI(t.getHttpUri())).equals(normalize(uri))) + throw new DPoPVerificationException(t, "DPoP HTTP URL mismatch"); if (!method.equals(t.getHttpMethod())) throw new DPoPVerificationException(t, "DPoP HTTP method mismatch");