From cc9534b32ca22d17a9672667bdc63fea3fb22833 Mon Sep 17 00:00:00 2001 From: Ogenbertrand Date: Mon, 10 Aug 2026 12:11:40 +0100 Subject: [PATCH 01/18] [ABCA] Add attestation challenge error code Signed-off-by: Ogenbertrand --- core/src/main/java/org/keycloak/OAuthErrorException.java | 1 + 1 file changed, 1 insertion(+) diff --git a/core/src/main/java/org/keycloak/OAuthErrorException.java b/core/src/main/java/org/keycloak/OAuthErrorException.java index 08deaa0913a5..e407a24d7b54 100755 --- a/core/src/main/java/org/keycloak/OAuthErrorException.java +++ b/core/src/main/java/org/keycloak/OAuthErrorException.java @@ -65,6 +65,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 USE_ATTESTATION_CHALLENGE = "use_attestation_challenge"; 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"; From b776562894f83b2fe8cfc2ea77874ee68d52ac5e Mon Sep 17 00:00:00 2001 From: Ogenbertrand Date: Mon, 10 Aug 2026 12:11:45 +0100 Subject: [PATCH 02/18] [ABCA] Add challenge endpoint metadata field Signed-off-by: Ogenbertrand --- .../OIDCConfigurationRepresentation.java | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/core/src/main/java/org/keycloak/protocol/oidc/representations/OIDCConfigurationRepresentation.java b/core/src/main/java/org/keycloak/protocol/oidc/representations/OIDCConfigurationRepresentation.java index 8678abdcc508..13fa214067d7 100755 --- a/core/src/main/java/org/keycloak/protocol/oidc/representations/OIDCConfigurationRepresentation.java +++ b/core/src/main/java/org/keycloak/protocol/oidc/representations/OIDCConfigurationRepresentation.java @@ -121,6 +121,9 @@ public class OIDCConfigurationRepresentation { @JsonProperty("client_attestation_pop_signing_alg_values_supported") private List clientAttestationPopSigningAlgValuesSupported; + @JsonProperty("challenge_endpoint") + private String challengeEndpoint; + @JsonProperty("introspection_endpoint_auth_methods_supported") private List introspectionEndpointAuthMethodsSupported; @@ -432,6 +435,14 @@ public void setClientAttestationPopSigningAlgValuesSupported(List client this.clientAttestationPopSigningAlgValuesSupported = clientAttestationPopSigningAlgValuesSupported; } + public String getChallengeEndpoint() { + return challengeEndpoint; + } + + public void setChallengeEndpoint(String challengeEndpoint) { + this.challengeEndpoint = challengeEndpoint; + } + public List getIntrospectionEndpointAuthMethodsSupported() { return introspectionEndpointAuthMethodsSupported; } From efbc7e69378e6fbf653dbdf9379f78fdd414d614 Mon Sep 17 00:00:00 2001 From: Ogenbertrand Date: Mon, 10 Aug 2026 12:11:48 +0100 Subject: [PATCH 03/18] [ABCA] Add challenge response representation Signed-off-by: Ogenbertrand --- .../ClientAttestationChallengeResponse.java | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 services/src/main/java/org/keycloak/protocol/oidc/ClientAttestationChallengeResponse.java diff --git a/services/src/main/java/org/keycloak/protocol/oidc/ClientAttestationChallengeResponse.java b/services/src/main/java/org/keycloak/protocol/oidc/ClientAttestationChallengeResponse.java new file mode 100644 index 000000000000..34dc2b003ffa --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/oidc/ClientAttestationChallengeResponse.java @@ -0,0 +1,25 @@ +package org.keycloak.protocol.oidc; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Challenge response for OAuth 2.0 Attestation-Based Client Authentication. + * + * @see Challenge endpoint + * @author Bertrand Ogen + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public class ClientAttestationChallengeResponse { + + @JsonProperty("attestation_challenge") + private String attestationChallenge; + + public String getAttestationChallenge() { + return attestationChallenge; + } + + public void setAttestationChallenge(String attestationChallenge) { + this.attestationChallenge = attestationChallenge; + } +} From 9ae9c852570c05573a645cd6a884311c2c426de5 Mon Sep 17 00:00:00 2001 From: Ogenbertrand Date: Mon, 10 Aug 2026 12:11:50 +0100 Subject: [PATCH 04/18] [ABCA] Add client attestation challenge endpoint Signed-off-by: Ogenbertrand --- .../ClientAttestationChallengeEndpoint.java | 108 ++++++++++++++++++ 1 file changed, 108 insertions(+) create mode 100644 services/src/main/java/org/keycloak/protocol/oidc/endpoints/ClientAttestationChallengeEndpoint.java diff --git a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/ClientAttestationChallengeEndpoint.java b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/ClientAttestationChallengeEndpoint.java new file mode 100644 index 000000000000..72f0c56ceca0 --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/ClientAttestationChallengeEndpoint.java @@ -0,0 +1,108 @@ +package org.keycloak.protocol.oidc.endpoints; + +import java.time.ZoneOffset; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.Map; + +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.HttpHeaders; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.UriBuilder; + +import org.keycloak.OAuthErrorException; +import org.keycloak.authentication.authenticators.client.AttestationBasedClientAuthenticator; +import org.keycloak.common.ClientConnection; +import org.keycloak.common.Profile; +import org.keycloak.http.HttpRequest; +import org.keycloak.models.KeycloakContext; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.protocol.oid4vc.issuance.keybinding.CNonceHandler; +import org.keycloak.protocol.oid4vc.issuance.keybinding.JwtCNonceHandler; +import org.keycloak.protocol.oidc.ClientAttestationChallengeResponse; +import org.keycloak.protocol.oidc.OIDCLoginProtocol; +import org.keycloak.protocol.oidc.OIDCLoginProtocolService; +import org.keycloak.services.CorsErrorResponseException; +import org.keycloak.services.cors.Cors; +import org.keycloak.services.Urls; +import org.keycloak.urls.UrlType; +import org.keycloak.utils.ProfileHelper; + +/** + * OAuth 2.0 Attestation-Based Client Authentication challenge endpoint. + * + * @author Bertrand Ogen + */ +public class ClientAttestationChallengeEndpoint { + + public static final String PATH = "attestation/challenge"; + + private final KeycloakSession session; + private final RealmModel realm; + private final HttpRequest request; + private final ClientConnection clientConnection; + + public ClientAttestationChallengeEndpoint(KeycloakSession session) { + this.session = session; + this.realm = session.getContext().getRealm(); + this.request = session.getContext().getHttpRequest(); + this.clientConnection = session.getContext().getConnection(); + } + + public static UriBuilder challengeUrl(UriBuilder baseUriBuilder) { + return OIDCLoginProtocolService.tokenServiceBaseUrl(baseUriBuilder) + .path(OIDCLoginProtocolService.class, "clientAttestationChallenge"); + } + + public static String getChallengeEndpoint(KeycloakContext context) { + return challengeUrl(context.getUri(UrlType.BACKEND).getBaseUriBuilder()) + .build(context.getRealm().getName(), OIDCLoginProtocol.LOGIN_PROTOCOL) + .toString(); + } + + public static String buildChallenge(KeycloakSession session) { + CNonceHandler cNonceHandler = session.getProvider(CNonceHandler.class); + if (cNonceHandler == null) { + throw new IllegalStateException("Client attestation challenge handler is not configured"); + } + + String issuer = Urls.realmIssuer(session.getContext().getUri(UrlType.FRONTEND).getBaseUri(), + session.getContext().getRealm().getName()); + return cNonceHandler.buildCNonce( + List.of(issuer), + Map.of(JwtCNonceHandler.SOURCE_ENDPOINT, getChallengeEndpoint(session.getContext()))); + } + + @POST + @Produces(MediaType.APPLICATION_JSON) + public Response requestChallenge() { + ProfileHelper.requireFeature(Profile.Feature.CLIENT_AUTH_ABCA); + checkSsl(); + + String challenge = buildChallenge(session); + ClientAttestationChallengeResponse challengeResponse = new ClientAttestationChallengeResponse(); + challengeResponse.setAttestationChallenge(challenge); + + return Response.ok(challengeResponse) + .header(HttpHeaders.CACHE_CONTROL, "no-store") + .header("Pragma", "no-cache") + .header(HttpHeaders.DATE, DateTimeFormatter.RFC_1123_DATE_TIME.format(ZonedDateTime.now(ZoneOffset.UTC))) + .header(AttestationBasedClientAuthenticator.OAUTH_CLIENT_ATTESTATION_CHALLENGE_HEADER, challenge) + .build(); + } + + private void checkSsl() { + if (!session.getContext().getUri().getBaseUri().getScheme().equals("https") + && realm.getSslRequired().isRequired(clientConnection)) { + Cors cors = Cors.builder().auth().allowedMethods(request.getHttpMethod()).auth() + .exposedHeaders(Cors.ACCESS_CONTROL_ALLOW_METHODS, + AttestationBasedClientAuthenticator.OAUTH_CLIENT_ATTESTATION_CHALLENGE_HEADER); + throw new CorsErrorResponseException(cors.allowAllOrigins(), OAuthErrorException.INVALID_REQUEST, + "HTTPS required", Response.Status.FORBIDDEN); + } + } +} From e84f4a29d93854674585fb2ba3dbc01b82fdbcf7 Mon Sep 17 00:00:00 2001 From: Ogenbertrand Date: Mon, 10 Aug 2026 12:11:53 +0100 Subject: [PATCH 05/18] [ABCA] Wire challenge endpoint route Signed-off-by: Ogenbertrand --- .../protocol/oidc/OIDCLoginProtocolService.java | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/services/src/main/java/org/keycloak/protocol/oidc/OIDCLoginProtocolService.java b/services/src/main/java/org/keycloak/protocol/oidc/OIDCLoginProtocolService.java index a328116ea05b..9473c043ff77 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc/OIDCLoginProtocolService.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/OIDCLoginProtocolService.java @@ -40,6 +40,7 @@ import org.keycloak.models.KeycloakSession; import org.keycloak.models.RealmModel; import org.keycloak.protocol.oidc.endpoints.AuthorizationEndpoint; +import org.keycloak.protocol.oidc.endpoints.ClientAttestationChallengeEndpoint; import org.keycloak.protocol.oidc.endpoints.LoginStatusIframeEndpoint; import org.keycloak.protocol.oidc.endpoints.LogoutEndpoint; import org.keycloak.protocol.oidc.endpoints.ThirdPartyCookiesIframeEndpoint; @@ -116,6 +117,10 @@ public static UriBuilder tokenUrl(UriBuilder baseUriBuilder) { return uriBuilder.path(OIDCLoginProtocolService.class, "token"); } + public static UriBuilder clientAttestationChallengeUrl(UriBuilder baseUriBuilder) { + return ClientAttestationChallengeEndpoint.challengeUrl(baseUriBuilder); + } + public static UriBuilder certsUrl(UriBuilder baseUriBuilder) { UriBuilder uriBuilder = tokenServiceBaseUrl(baseUriBuilder); return uriBuilder.path(OIDCLoginProtocolService.class, "certs"); @@ -179,6 +184,14 @@ public Object token() { return new TokenEndpoint(session, tokenManager, event); } + /** + * Attestation-Based Client Authentication challenge endpoint + */ + @Path(ClientAttestationChallengeEndpoint.PATH) + public Object clientAttestationChallenge() { + return new ClientAttestationChallengeEndpoint(session); + } + @Path("login-status-iframe.html") public Object getLoginStatusIframe() { return new LoginStatusIframeEndpoint(session); From d4cfe2167b7d0999cec42491179beab8f5d51a18 Mon Sep 17 00:00:00 2001 From: Ogenbertrand Date: Mon, 10 Aug 2026 12:11:56 +0100 Subject: [PATCH 06/18] [ABCA] Advertise challenge endpoint metadata Signed-off-by: Ogenbertrand --- .../java/org/keycloak/protocol/oidc/OIDCWellKnownProvider.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/services/src/main/java/org/keycloak/protocol/oidc/OIDCWellKnownProvider.java b/services/src/main/java/org/keycloak/protocol/oidc/OIDCWellKnownProvider.java index 0e15673b1a68..1cc32bfb445f 100755 --- a/services/src/main/java/org/keycloak/protocol/oidc/OIDCWellKnownProvider.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/OIDCWellKnownProvider.java @@ -47,6 +47,7 @@ import org.keycloak.models.KeycloakSessionFactory; import org.keycloak.models.RealmModel; import org.keycloak.protocol.oidc.endpoints.AuthorizationEndpoint; +import org.keycloak.protocol.oidc.endpoints.ClientAttestationChallengeEndpoint; import org.keycloak.protocol.oidc.endpoints.TokenEndpoint; import org.keycloak.protocol.oidc.grants.OAuth2GrantType; import org.keycloak.protocol.oidc.grants.ciba.CibaGrantType; @@ -166,6 +167,8 @@ public Object getConfig() { if (clientAuthMethodsSupported.contains(ATTEST_JWT_CLIENT_AUTH)) { config.setClientAttestationSigningAlgValuesSupported(getSupportedSigningAlgorithms(false)); config.setClientAttestationPopSigningAlgValuesSupported(getSupportedSigningAlgorithms(false)); + config.setChallengeEndpoint(ClientAttestationChallengeEndpoint.challengeUrl(backendUriInfo.getBaseUriBuilder()) + .build(realm.getName(), OIDCLoginProtocol.LOGIN_PROTOCOL).toString()); } config.setAuthorizationSigningAlgValuesSupported(getSupportedSigningAlgorithms(false)); From 1b19aac433d0f138c8801ee7d346f84fa3fb03e7 Mon Sep 17 00:00:00 2001 From: Ogenbertrand Date: Mon, 10 Aug 2026 12:11:59 +0100 Subject: [PATCH 07/18] [ABCA] Validate client attestation challenges Signed-off-by: Ogenbertrand --- .../AttestationBasedClientAuthenticator.java | 65 ++++++++++++++++++- 1 file changed, 63 insertions(+), 2 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 78de00396288..cc7def8390d6 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,8 +51,11 @@ import org.keycloak.models.ClientModel; import org.keycloak.models.KeycloakSession; import org.keycloak.models.RealmModel; +import org.keycloak.protocol.oid4vc.issuance.keybinding.CNonceHandler; +import org.keycloak.protocol.oid4vc.issuance.keybinding.JwtCNonceHandler; import org.keycloak.protocol.oidc.OIDCLoginProtocol; import org.keycloak.protocol.oidc.OIDCWellKnownProviderFactory; +import org.keycloak.protocol.oidc.endpoints.ClientAttestationChallengeEndpoint; import org.keycloak.protocol.oidc.representations.OIDCConfigurationRepresentation; import org.keycloak.provider.EnvironmentDependentProviderFactory; import org.keycloak.provider.ProviderConfigProperty; @@ -71,6 +74,7 @@ import static org.keycloak.OAuth2Constants.CLIENT_ID; import static org.keycloak.OAuthErrorException.INVALID_CLIENT_ATTESTATION; +import static org.keycloak.OAuthErrorException.USE_ATTESTATION_CHALLENGE; /** @@ -87,6 +91,7 @@ public class AttestationBasedClientAuthenticator extends AbstractClientAuthentic 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_CHALLENGE_HEADER = "OAuth-Client-Attestation-Challenge"; 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"; @@ -135,6 +140,12 @@ public void authenticateClient(ClientAuthenticationFlowContext context) { ClientModel clientModel = context.getClient(); abcaResult.setAttestedClient(clientModel); + } catch (ClientAttestationChallengeException ex) { + ServicesLogger.LOGGER.errorValidatingAssertion(ex); + Response response = Response.fromResponse(ClientAuthUtil.errorResponse(BAD_REQUEST.getStatusCode(), USE_ATTESTATION_CHALLENGE, ex.getMessage())) + .header(OAUTH_CLIENT_ATTESTATION_CHALLENGE_HEADER, ex.getChallenge()) + .build(); + context.failure(AuthenticationFlowError.INVALID_CLIENT_ATTESTATION, response); } catch (Exception ex) { ServicesLogger.LOGGER.errorValidatingAssertion(ex); Response response = ClientAuthUtil.errorResponse(BAD_REQUEST.getStatusCode(), INVALID_CLIENT_ATTESTATION, ex.getMessage()); @@ -142,7 +153,6 @@ public void authenticateClient(ClientAuthenticationFlowContext context) { } // 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 } @@ -490,15 +500,66 @@ private void validateClientAttestationPoPJwt(ClientAuthenticationFlowContext con throw new TokenSignatureInvalidException(attestationPoPJwt, "Invalid token signature"); } + validateClientAttestationChallenge(session, attestationPoPJwt); + abcaResult.setAttestationPoPJwt(attestationPoPJwt); // [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 } + private void validateClientAttestationChallenge(KeycloakSession session, ClientAttestationPoPJwt attestationPoPJwt) + throws ClientAttestationChallengeException, TokenVerificationException { + String challenge = attestationPoPJwt.getChallenge(); + if (Strings.isEmpty(challenge)) { + return; + } + + CNonceHandler cNonceHandler = session.getProvider(CNonceHandler.class); + if (cNonceHandler == null) { + throw new TokenVerificationException(attestationPoPJwt, "Client attestation challenge validation is not available"); + } + + WellKnownProvider oidcProvider = session.getProvider(WellKnownProvider.class, OIDCWellKnownProviderFactory.PROVIDER_ID); + OIDCConfigurationRepresentation oidcConfig = (OIDCConfigurationRepresentation) oidcProvider.getConfig(); + + try { + Map challengeDetails = Map.of(JwtCNonceHandler.SOURCE_ENDPOINT, + ClientAttestationChallengeEndpoint.getChallengeEndpoint(session.getContext())); + List challengeAudiences = List.of(oidcConfig.getIssuer()); + if (cNonceHandler.supportsCNonceTokenRetrieval()) { + JsonWebToken challengeToken = cNonceHandler.verifyCNonceAndGetToken(challenge, + challengeAudiences, challengeDetails); + if (cNonceHandler.supportsCNonceConsumption()) { + cNonceHandler.consumeCNonce(challenge, challengeToken); + } + } else { + cNonceHandler.verifyCNonce(challenge, challengeAudiences, challengeDetails); + } + } catch (Exception ex) { + throw new ClientAttestationChallengeException( + "Client Attestation PoP JWT challenge is invalid: " + ex.getMessage(), + ClientAttestationChallengeEndpoint.buildChallenge(session), + ex); + } + } + + private static class ClientAttestationChallengeException extends Exception { + + private final String challenge; + + private ClientAttestationChallengeException(String message, String challenge, Throwable cause) { + super(message, cause); + this.challenge = challenge; + } + + private String getChallenge() { + return challenge; + } + } + public static class ABCAResult { /** From cc1aefc74f1cd1c9b5e748865f1aadb42ae33a94 Mon Sep 17 00:00:00 2001 From: Ogenbertrand Date: Mon, 10 Aug 2026 12:12:02 +0100 Subject: [PATCH 08/18] [ABCA] Support challenge in wallet PoP helper Signed-off-by: Ogenbertrand --- .../java/org/keycloak/tests/oid4vc/OID4VCBasicWallet.java | 7 +++++++ 1 file changed, 7 insertions(+) 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 e8cd0d433ebc..a1ee34a43204 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 @@ -227,6 +227,10 @@ public String buildClientAttestationJWT(OID4VCTestContext ctx, KeyWrapper wallet } public String buildClientAttestationPoPJWT(OID4VCTestContext ctx, KeyWrapper walletKey) { + return buildClientAttestationPoPJWT(ctx, walletKey, null); + } + + public String buildClientAttestationPoPJWT(OID4VCTestContext ctx, KeyWrapper walletKey, String challenge) { var issuer = getIssuerMetadata(ctx).getCredentialIssuer(); // Build Client Attestation PoP JWT @@ -237,6 +241,9 @@ public String buildClientAttestationPoPJWT(OID4VCTestContext ctx, KeyWrapper wal .issuer(clientId) .issuedNowWithTTL(300) // 5min .randomId(); + if (challenge != null) { + body.challenge(challenge); + } String attestationPoPJwt = new JWSBuilder() .type(OAUTH_CLIENT_ATTESTATION_POP_JWT_TYPE) From 32342e776960e8c2da57b5147e0a8b3d89bfa7d7 Mon Sep 17 00:00:00 2001 From: Ogenbertrand Date: Mon, 10 Aug 2026 12:12:04 +0100 Subject: [PATCH 09/18] [ABCA] Add challenge endpoint test URL Signed-off-by: Ogenbertrand --- .../java/org/keycloak/testsuite/util/oauth/Endpoints.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/utils-shared/src/main/java/org/keycloak/testsuite/util/oauth/Endpoints.java b/tests/utils-shared/src/main/java/org/keycloak/testsuite/util/oauth/Endpoints.java index fb1db4c0b8bf..afe2ae3ef60a 100644 --- a/tests/utils-shared/src/main/java/org/keycloak/testsuite/util/oauth/Endpoints.java +++ b/tests/utils-shared/src/main/java/org/keycloak/testsuite/util/oauth/Endpoints.java @@ -38,6 +38,10 @@ public String getToken() { return asString(OIDCLoginProtocolService.tokenUrl(getBase())); } + public String getClientAttestationChallenge() { + return asString(getBase().path(RealmsResource.class).path("{realm}/protocol/openid-connect/attestation/challenge")); + } + public String getIntrospection() { return asString(OIDCLoginProtocolService.tokenIntrospectionUrl(getBase())); } From 662f0df5869ba20c975b35b08965f71be47dbe7b Mon Sep 17 00:00:00 2001 From: Ogenbertrand Date: Mon, 10 Aug 2026 12:12:07 +0100 Subject: [PATCH 10/18] [ABCA] Add challenge request test helper Signed-off-by: Ogenbertrand --- .../ClientAttestationChallengeRequest.java | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 tests/utils-shared/src/main/java/org/keycloak/testsuite/util/oauth/ClientAttestationChallengeRequest.java diff --git a/tests/utils-shared/src/main/java/org/keycloak/testsuite/util/oauth/ClientAttestationChallengeRequest.java b/tests/utils-shared/src/main/java/org/keycloak/testsuite/util/oauth/ClientAttestationChallengeRequest.java new file mode 100644 index 000000000000..bebedc34a3bc --- /dev/null +++ b/tests/utils-shared/src/main/java/org/keycloak/testsuite/util/oauth/ClientAttestationChallengeRequest.java @@ -0,0 +1,29 @@ +package org.keycloak.testsuite.util.oauth; + +import java.io.IOException; + +import org.apache.http.client.methods.CloseableHttpResponse; + +/** + * @author Bertrand Ogen + */ +public class ClientAttestationChallengeRequest extends AbstractHttpPostRequest { + + public ClientAttestationChallengeRequest(AbstractOAuthClient client) { + super(client); + } + + @Override + protected String getEndpoint() { + return client.getEndpoints().getClientAttestationChallenge(); + } + + @Override + protected void initRequest() { + } + + @Override + protected ClientAttestationChallengeResponse toResponse(CloseableHttpResponse response) throws IOException { + return new ClientAttestationChallengeResponse(response); + } +} From 6d01e2812d8bb7f208a5f40c99dac3205ebcd5d5 Mon Sep 17 00:00:00 2001 From: Ogenbertrand Date: Mon, 10 Aug 2026 12:12:09 +0100 Subject: [PATCH 11/18] [ABCA] Add challenge response test helper Signed-off-by: Ogenbertrand --- .../ClientAttestationChallengeResponse.java | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 tests/utils-shared/src/main/java/org/keycloak/testsuite/util/oauth/ClientAttestationChallengeResponse.java diff --git a/tests/utils-shared/src/main/java/org/keycloak/testsuite/util/oauth/ClientAttestationChallengeResponse.java b/tests/utils-shared/src/main/java/org/keycloak/testsuite/util/oauth/ClientAttestationChallengeResponse.java new file mode 100644 index 000000000000..ced3fd9d4498 --- /dev/null +++ b/tests/utils-shared/src/main/java/org/keycloak/testsuite/util/oauth/ClientAttestationChallengeResponse.java @@ -0,0 +1,35 @@ +package org.keycloak.testsuite.util.oauth; + +import java.io.IOException; +import java.util.Optional; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.apache.http.client.methods.CloseableHttpResponse; + +/** + * @author Bertrand Ogen + */ +public class ClientAttestationChallengeResponse extends AbstractHttpResponse { + + private ObjectNode challengeResponse; + + public ClientAttestationChallengeResponse(CloseableHttpResponse response) throws IOException { + super(response); + } + + @Override + protected void parseContent() throws IOException { + challengeResponse = asJson(); + } + + public String getAttestationChallenge() { + return Optional.ofNullable(challengeResponse) + .filter(json -> json.hasNonNull("attestation_challenge")) + .map(json -> json.get("attestation_challenge").asText()) + .orElseThrow(() -> new IllegalStateException(String.format("[%s] %s", getError(), getErrorDescription()))); + } + + public ObjectNode getChallengeResponse() { + return challengeResponse; + } +} From 8c522e5febf14800b6b6abcbe2d35501f8b6753e Mon Sep 17 00:00:00 2001 From: Ogenbertrand Date: Mon, 10 Aug 2026 12:12:11 +0100 Subject: [PATCH 12/18] [ABCA] Expose challenge request helper Signed-off-by: Ogenbertrand --- .../keycloak/testsuite/util/oauth/AbstractOAuthClient.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/utils-shared/src/main/java/org/keycloak/testsuite/util/oauth/AbstractOAuthClient.java b/tests/utils-shared/src/main/java/org/keycloak/testsuite/util/oauth/AbstractOAuthClient.java index de0ef01c4824..f060f558c5f7 100644 --- a/tests/utils-shared/src/main/java/org/keycloak/testsuite/util/oauth/AbstractOAuthClient.java +++ b/tests/utils-shared/src/main/java/org/keycloak/testsuite/util/oauth/AbstractOAuthClient.java @@ -182,6 +182,10 @@ public OIDCConfigurationRepresentation doWellKnownRequest() { return wellknownRequest().send().getOidcConfiguration(); } + public ClientAttestationChallengeRequest clientAttestationChallengeRequest() { + return new ClientAttestationChallengeRequest(this); + } + public UserInfoRequest userInfoRequest(String accessToken) { return new UserInfoRequest(accessToken, this); } From 9bdb2a070ee5ae896f22eea96bbdc58529bd9f6e Mon Sep 17 00:00:00 2001 From: Ogenbertrand Date: Mon, 10 Aug 2026 12:12:13 +0100 Subject: [PATCH 13/18] [ABCA] Test client attestation challenge flow Signed-off-by: Ogenbertrand --- ...estationBasedClientAuthenticationTest.java | 132 ++++++++++++++++++ 1 file changed, 132 insertions(+) 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 552a86e5dade..3344f86a97d0 100644 --- a/tests/base/src/test/java/org/keycloak/tests/oid4vc/abca/OIDCAttestationBasedClientAuthenticationTest.java +++ b/tests/base/src/test/java/org/keycloak/tests/oid4vc/abca/OIDCAttestationBasedClientAuthenticationTest.java @@ -17,10 +17,16 @@ package org.keycloak.tests.oid4vc.abca; import java.security.PublicKey; +import java.util.Arrays; import java.util.List; import java.util.Map; +import jakarta.ws.rs.core.HttpHeaders; +import jakarta.ws.rs.core.Response; + +import org.keycloak.OAuthErrorException; import org.keycloak.TokenVerifier; +import org.keycloak.authentication.authenticators.client.AttestationBasedClientAuthenticator; import org.keycloak.authentication.authenticators.client.AttestationBasedClientAuthenticator.ClientAttestationJwt; import org.keycloak.authentication.authenticators.client.AttestationBasedClientAuthenticator.ClientAttestationPoPJwt; import org.keycloak.broker.trust.DefaultTrustIdentityProviderConfig; @@ -31,9 +37,11 @@ import org.keycloak.jose.jwk.JWK; import org.keycloak.jose.jwk.JWKBuilder; import org.keycloak.models.RealmModel; +import org.keycloak.protocol.oid4vc.issuance.keybinding.JwtCNonceHandler; import org.keycloak.protocol.oid4vc.model.CredentialResponse; import org.keycloak.protocol.oid4vc.model.Proofs; import org.keycloak.protocol.oidc.representations.OIDCConfigurationRepresentation; +import org.keycloak.representations.JsonWebToken; import org.keycloak.testframework.annotations.KeycloakIntegrationTest; import org.keycloak.testframework.annotations.TestSetup; import org.keycloak.tests.oid4vc.OID4VCIssuerTestBase; @@ -51,6 +59,7 @@ 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.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; @@ -93,6 +102,25 @@ public void testTokenEndpointAuthMethods() { OIDCConfigurationRepresentation oidcConfiguration = oauth.doWellKnownRequest(); List tokenAuthMethodsSupported = oidcConfiguration.getTokenEndpointAuthMethodsSupported(); assertTrue(tokenAuthMethodsSupported.contains(ATTEST_JWT_CLIENT_AUTH), "Should contain: " + ATTEST_JWT_CLIENT_AUTH); + assertEquals(oauth.getEndpoints().getClientAttestationChallenge(), oidcConfiguration.getChallengeEndpoint()); + } + + @Test + public void testClientAttestationChallengeEndpoint() { + var response = oauth.clientAttestationChallengeRequest().send(); + assertEquals(Response.Status.OK.getStatusCode(), response.getStatusCode()); + + String challenge = response.getAttestationChallenge(); + assertNotNull(challenge); + assertEquals(challenge, response.getHeader(AttestationBasedClientAuthenticator.OAUTH_CLIENT_ATTESTATION_CHALLENGE_HEADER)); + assertEquals("no-store", response.getHeader(HttpHeaders.CACHE_CONTROL)); + + TokenVerifier verifier = TokenVerifier.create(challenge, JsonWebToken.class); + JsonWebToken challengeToken = verifier.getToken(); + assertEquals(testRealm.getBaseUrl(), challengeToken.getIssuer()); + assertEquals(List.of(testRealm.getBaseUrl()), Arrays.asList(challengeToken.getAudience())); + assertEquals(oauth.getEndpoints().getClientAttestationChallenge(), + challengeToken.getOtherClaims().get(JwtCNonceHandler.SOURCE_ENDPOINT)); } @Test @@ -194,4 +222,108 @@ public void testClientAttestationHappyFlow() { assertFalse(credResponse.getCredentials().isEmpty(), "No credential"); } + + @Test + public void testClientAttestationChallengeHappyFlow() { + + var ctx = new OID4VCTestContext(abcaClient, sdJwtTypeCredentialScope); + ctx.putAttachment(CLIENT_ATTESTER_ATTACHMENT_KEY, attester); + + var kw = wallet.getRSAKeyPair(ctx); + String attestationJwt = wallet.buildClientAttestationJWT(ctx, kw); + String challenge = oauth.clientAttestationChallengeRequest().send().getAttestationChallenge(); + String attestationPoPJwt = wallet.buildClientAttestationPoPJWT(ctx, kw, challenge); + + AuthorizationEndpointResponse authResponse = wallet.authorizationRequest() + .scope(ctx.getScope()) + .send(ctx.getHolder(), TEST_PASSWORD); + + assertNull(authResponse.getErrorDescription(), "Authorization error: " + authResponse.getErrorDescription()); + assertNotNull(authResponse.getCode(), "No auth code"); + + KeyWrapper ecKey = wallet.getECKeyPair(ctx); + String tokenEndpoint = oauth.getEndpoints().getToken(); + AccessTokenResponse tokenResponse = wallet.accessTokenRequest(ctx, authResponse.getCode()) + .header(OAUTH_CLIENT_ATTESTATION_HEADER, attestationJwt) + .header(OAUTH_CLIENT_ATTESTATION_POP_HEADER, attestationPoPJwt) + .dpopProof(wallet.generateSignedDPoPProof(tokenEndpoint, ecKey, null)) + .send(); + + assertTrue(tokenResponse.isSuccess(), "Token request error: " + tokenResponse.getErrorDescription()); + assertNotNull(tokenResponse.getAccessToken(), "No access token"); + } + + @Test + public void testInvalidClientAttestationChallengeReturnsFreshChallenge() { + + var ctx = new OID4VCTestContext(abcaClient, sdJwtTypeCredentialScope); + ctx.putAttachment(CLIENT_ATTESTER_ATTACHMENT_KEY, attester); + + var kw = wallet.getRSAKeyPair(ctx); + String attestationJwt = wallet.buildClientAttestationJWT(ctx, kw); + String attestationPoPJwt = wallet.buildClientAttestationPoPJWT(ctx, kw, "invalid-challenge"); + + AuthorizationEndpointResponse authResponse = wallet.authorizationRequest() + .scope(ctx.getScope()) + .send(ctx.getHolder(), TEST_PASSWORD); + + assertNull(authResponse.getErrorDescription(), "Authorization error: " + authResponse.getErrorDescription()); + assertNotNull(authResponse.getCode(), "No auth code"); + + KeyWrapper ecKey = wallet.getECKeyPair(ctx); + String tokenEndpoint = oauth.getEndpoints().getToken(); + AccessTokenResponse tokenResponse = wallet.accessTokenRequest(ctx, authResponse.getCode()) + .header(OAUTH_CLIENT_ATTESTATION_HEADER, attestationJwt) + .header(OAUTH_CLIENT_ATTESTATION_POP_HEADER, attestationPoPJwt) + .dpopProof(wallet.generateSignedDPoPProof(tokenEndpoint, ecKey, null)) + .send(); + + assertFalse(tokenResponse.isSuccess()); + assertEquals(OAuthErrorException.USE_ATTESTATION_CHALLENGE, tokenResponse.getError()); + assertNotNull(tokenResponse.getHeader(AttestationBasedClientAuthenticator.OAUTH_CLIENT_ATTESTATION_CHALLENGE_HEADER)); + } + + @Test + public void testClientAttestationChallengeCannotBeReused() { + + var ctx = new OID4VCTestContext(abcaClient, sdJwtTypeCredentialScope); + ctx.putAttachment(CLIENT_ATTESTER_ATTACHMENT_KEY, attester); + + var kw = wallet.getRSAKeyPair(ctx); + String attestationJwt = wallet.buildClientAttestationJWT(ctx, kw); + String challenge = oauth.clientAttestationChallengeRequest().send().getAttestationChallenge(); + String attestationPoPJwt = wallet.buildClientAttestationPoPJWT(ctx, kw, challenge); + + KeyWrapper ecKey = wallet.getECKeyPair(ctx); + String tokenEndpoint = oauth.getEndpoints().getToken(); + + AuthorizationEndpointResponse firstAuthResponse = wallet.authorizationRequest() + .scope(ctx.getScope()) + .send(ctx.getHolder(), TEST_PASSWORD); + assertNull(firstAuthResponse.getErrorDescription(), "Authorization error: " + firstAuthResponse.getErrorDescription()); + assertNotNull(firstAuthResponse.getCode(), "No auth code"); + + AccessTokenResponse firstTokenResponse = wallet.accessTokenRequest(ctx, firstAuthResponse.getCode()) + .header(OAUTH_CLIENT_ATTESTATION_HEADER, attestationJwt) + .header(OAUTH_CLIENT_ATTESTATION_POP_HEADER, attestationPoPJwt) + .dpopProof(wallet.generateSignedDPoPProof(tokenEndpoint, ecKey, null)) + .send(); + assertTrue(firstTokenResponse.isSuccess(), "Token request error: " + firstTokenResponse.getErrorDescription()); + + AuthorizationEndpointResponse secondAuthResponse = wallet.authorizationRequest() + .scope(ctx.getScope()) + .send(ctx.getHolder(), TEST_PASSWORD); + assertNull(secondAuthResponse.getErrorDescription(), "Authorization error: " + secondAuthResponse.getErrorDescription()); + assertNotNull(secondAuthResponse.getCode(), "No auth code"); + + AccessTokenResponse secondTokenResponse = wallet.accessTokenRequest(ctx, secondAuthResponse.getCode()) + .header(OAUTH_CLIENT_ATTESTATION_HEADER, attestationJwt) + .header(OAUTH_CLIENT_ATTESTATION_POP_HEADER, attestationPoPJwt) + .dpopProof(wallet.generateSignedDPoPProof(tokenEndpoint, ecKey, null)) + .send(); + + assertFalse(secondTokenResponse.isSuccess()); + assertEquals(OAuthErrorException.USE_ATTESTATION_CHALLENGE, secondTokenResponse.getError()); + assertNotNull(secondTokenResponse.getHeader(AttestationBasedClientAuthenticator.OAUTH_CLIENT_ATTESTATION_CHALLENGE_HEADER)); + } } From 6a5b9c38febd8e0f4062b158ab9b8dd7f106d44f Mon Sep 17 00:00:00 2001 From: Ogenbertrand Date: Mon, 10 Aug 2026 12:37:19 +0100 Subject: [PATCH 14/18] [ABCA] Address challenge lifecycle review Signed-off-by: Ogenbertrand --- .../client/AttestationBasedClientAuthenticator.java | 8 +++++--- .../OIDCAttestationBasedClientAuthenticationTest.java | 2 +- 2 files changed, 6 insertions(+), 4 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 cc7def8390d6..d2e6daa96fa0 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 @@ -521,6 +521,9 @@ private void validateClientAttestationChallenge(KeycloakSession session, ClientA if (cNonceHandler == null) { throw new TokenVerificationException(attestationPoPJwt, "Client attestation challenge validation is not available"); } + if (!cNonceHandler.supportsCNonceConsumption()) { + throw new TokenVerificationException(attestationPoPJwt, "Client attestation challenge consumption is not available"); + } WellKnownProvider oidcProvider = session.getProvider(WellKnownProvider.class, OIDCWellKnownProviderFactory.PROVIDER_ID); OIDCConfigurationRepresentation oidcConfig = (OIDCConfigurationRepresentation) oidcProvider.getConfig(); @@ -532,11 +535,10 @@ private void validateClientAttestationChallenge(KeycloakSession session, ClientA if (cNonceHandler.supportsCNonceTokenRetrieval()) { JsonWebToken challengeToken = cNonceHandler.verifyCNonceAndGetToken(challenge, challengeAudiences, challengeDetails); - if (cNonceHandler.supportsCNonceConsumption()) { - cNonceHandler.consumeCNonce(challenge, challengeToken); - } + cNonceHandler.consumeCNonce(challenge, challengeToken); } else { cNonceHandler.verifyCNonce(challenge, challengeAudiences, challengeDetails); + cNonceHandler.consumeCNonce(challenge); } } catch (Exception ex) { throw new ClientAttestationChallengeException( 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 3344f86a97d0..083d2c3c2aad 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 @@ -106,7 +106,7 @@ public void testTokenEndpointAuthMethods() { } @Test - public void testClientAttestationChallengeEndpoint() { + public void testClientAttestationChallengeEndpoint() throws VerificationException { var response = oauth.clientAttestationChallengeRequest().send(); assertEquals(Response.Status.OK.getStatusCode(), response.getStatusCode()); From e07e6ae6d745d518d672e7f2616de3c9337df1ae Mon Sep 17 00:00:00 2001 From: Ogenbertrand Date: Tue, 11 Aug 2026 12:47:15 +0100 Subject: [PATCH 15/18] fix failing CI due to maven spotless Signed-off-by: Ogenbertrand --- .../oidc/endpoints/ClientAttestationChallengeEndpoint.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/ClientAttestationChallengeEndpoint.java b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/ClientAttestationChallengeEndpoint.java index 72f0c56ceca0..2fffc447b91b 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/ClientAttestationChallengeEndpoint.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/ClientAttestationChallengeEndpoint.java @@ -27,8 +27,8 @@ import org.keycloak.protocol.oidc.OIDCLoginProtocol; import org.keycloak.protocol.oidc.OIDCLoginProtocolService; import org.keycloak.services.CorsErrorResponseException; -import org.keycloak.services.cors.Cors; import org.keycloak.services.Urls; +import org.keycloak.services.cors.Cors; import org.keycloak.urls.UrlType; import org.keycloak.utils.ProfileHelper; From 095da8da917fccfef7663746db28445ba638291b Mon Sep 17 00:00:00 2001 From: Ogenbertrand Date: Tue, 11 Aug 2026 13:41:33 +0100 Subject: [PATCH 16/18] Add ABCA PoP replay protection Signed-off-by: Ogenbertrand --- .../AttestationBasedClientAuthenticator.java | 63 +++++++++++++++++-- 1 file changed, 59 insertions(+), 4 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 d2e6daa96fa0..484dc0be1305 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 @@ -22,6 +22,7 @@ import java.security.PublicKey; import java.util.Arrays; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Optional; import java.util.Set; @@ -38,6 +39,7 @@ import org.keycloak.broker.provider.TrustMaterialResolver; import org.keycloak.common.Profile; import org.keycloak.common.util.Base64Url; +import org.keycloak.common.util.Time; import org.keycloak.crypto.KeyUse; import org.keycloak.crypto.KeyWrapper; import org.keycloak.crypto.SignatureProvider; @@ -47,10 +49,12 @@ import org.keycloak.jose.jwk.JWKParser; import org.keycloak.jose.jws.Algorithm; import org.keycloak.jose.jws.JWSInput; +import org.keycloak.jose.jws.crypto.HashUtils; import org.keycloak.models.AuthenticationExecutionModel; import org.keycloak.models.ClientModel; import org.keycloak.models.KeycloakSession; import org.keycloak.models.RealmModel; +import org.keycloak.models.SingleUseObjectProvider; import org.keycloak.protocol.oid4vc.issuance.keybinding.CNonceHandler; import org.keycloak.protocol.oid4vc.issuance.keybinding.JwtCNonceHandler; import org.keycloak.protocol.oidc.OIDCLoginProtocol; @@ -95,6 +99,8 @@ public class AttestationBasedClientAuthenticator extends AbstractClientAuthentic 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"; + private static final int CLIENT_ATTESTATION_POP_REPLAY_WINDOW_SECONDS = 300; + private static final int CLIENT_ATTESTATION_POP_ALLOWED_CLOCK_SKEW_SECONDS = 15; /** * Comma-separated aliases of trust-material identity providers that expose the trusted attester keys. @@ -457,7 +463,7 @@ private void validateClientAttestationPoPJwt(ClientAuthenticationFlowContext con }; TokenVerifier.Predicate iatCheck = (t) -> { - if (t.getIat() == 0) + if (t.getIat() == null || 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; }; @@ -500,14 +506,63 @@ private void validateClientAttestationPoPJwt(ClientAuthenticationFlowContext con throw new TokenSignatureInvalidException(attestationPoPJwt, "Invalid token signature"); } + ensureClientAttestationPoPNotReplayed(session, attestationJwt, attestationPoPJwt, clientKey); validateClientAttestationChallenge(session, attestationPoPJwt); + markClientAttestationPoPAsUsed(session, attestationJwt, attestationPoPJwt, clientKey); abcaResult.setAttestationPoPJwt(attestationPoPJwt); + } + + private void ensureClientAttestationPoPNotReplayed(KeycloakSession session, ClientAttestationJwt attestationJwt, + ClientAttestationPoPJwt attestationPoPJwt, KeyWrapper clientKey) throws TokenVerificationException { + getClientAttestationPoPReplayEntryLifespan(attestationPoPJwt); + + String cacheKey = getClientAttestationPoPReplayCacheKey(attestationJwt, attestationPoPJwt, clientKey); + if (session.singleUseObjects().contains(cacheKey)) { + throw new TokenVerificationException(attestationPoPJwt, "Client Attestation PoP JWT has already been used"); + } + } + + private void markClientAttestationPoPAsUsed(KeycloakSession session, ClientAttestationJwt attestationJwt, + ClientAttestationPoPJwt attestationPoPJwt, KeyWrapper clientKey) throws TokenVerificationException { + long lifespan = getClientAttestationPoPReplayEntryLifespan(attestationPoPJwt); + String cacheKey = getClientAttestationPoPReplayCacheKey(attestationJwt, attestationPoPJwt, clientKey); + + SingleUseObjectProvider singleUseStore = session.singleUseObjects(); + if (!singleUseStore.putIfAbsent(cacheKey, lifespan)) { + throw new TokenVerificationException(attestationPoPJwt, "Client Attestation PoP JWT has already been used"); + } + } + + private long getClientAttestationPoPReplayEntryLifespan(ClientAttestationPoPJwt attestationPoPJwt) + throws TokenVerificationException { + long now = Time.currentTime(); + Long issuedAt = attestationPoPJwt.getIat(); + if (issuedAt == null || issuedAt == 0) { + throw new TokenVerificationException(attestationPoPJwt, "The iat (issued at) claim MUST specify the time at which the Client Attestation PoP was issued."); + } + if (issuedAt > now + CLIENT_ATTESTATION_POP_ALLOWED_CLOCK_SKEW_SECONDS) { + throw new TokenVerificationException(attestationPoPJwt, "Client Attestation PoP JWT was issued in the future"); + } + + long lifespan = issuedAt + CLIENT_ATTESTATION_POP_REPLAY_WINDOW_SECONDS + + CLIENT_ATTESTATION_POP_ALLOWED_CLOCK_SKEW_SECONDS - now; + if (lifespan <= 0) { + throw new TokenVerificationException(attestationPoPJwt, "Client Attestation PoP JWT was issued too far in the past"); + } + return lifespan; + } - // [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 + private String getClientAttestationPoPReplayCacheKey(ClientAttestationJwt attestationJwt, + ClientAttestationPoPJwt attestationPoPJwt, KeyWrapper clientKey) { + String clientInstanceKeyHash = HashUtils.sha256UrlEncodedHash( + Base64Url.encode(clientKey.getPublicKey().getEncoded()), StandardCharsets.UTF_8); + String replayKeyMaterial = String.join("\n", attestationJwt.getSubject(), clientInstanceKeyHash, + attestationPoPJwt.getId()); + String replayKeyHash = HashUtils.sha256UrlEncodedHash(replayKeyMaterial, StandardCharsets.UTF_8); - // [TODO] Additional checks to guarantee replay protection for the Client Attestation PoP JWT might need to be applied + // Scope the jti cache key to the attested client instance key, so unrelated instances can choose the same jti. + return AttestationBasedClientAuthenticator.class.getName().toLowerCase(Locale.ROOT) + ".pop-replay." + replayKeyHash; } private void validateClientAttestationChallenge(KeycloakSession session, ClientAttestationPoPJwt attestationPoPJwt) From 411802b21bfeda29f0a42e689d661c83ad1f08a9 Mon Sep 17 00:00:00 2001 From: Ogenbertrand Date: Tue, 11 Aug 2026 13:41:34 +0100 Subject: [PATCH 17/18] Support custom ABCA PoP JWT test claims Signed-off-by: Ogenbertrand --- .../tests/oid4vc/OID4VCBasicWallet.java | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 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 a1ee34a43204..674c4b4a0899 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 @@ -231,6 +231,11 @@ public String buildClientAttestationPoPJWT(OID4VCTestContext ctx, KeyWrapper wal } public String buildClientAttestationPoPJWT(OID4VCTestContext ctx, KeyWrapper walletKey, String challenge) { + return buildClientAttestationPoPJWT(ctx, walletKey, challenge, null, Time.currentTime()); + } + + public String buildClientAttestationPoPJWT(OID4VCTestContext ctx, KeyWrapper walletKey, String challenge, + String jwtId, long issuedAt) { var issuer = getIssuerMetadata(ctx).getCredentialIssuer(); // Build Client Attestation PoP JWT @@ -238,9 +243,15 @@ public String buildClientAttestationPoPJWT(OID4VCTestContext ctx, KeyWrapper wal String clientId = ctx.getClient().getClientId(); ClientAttestationPoPJwt body = new ClientAttestationPoPJwt() .audience(issuer) - .issuer(clientId) - .issuedNowWithTTL(300) // 5min - .randomId(); + .issuer(clientId); + body.iat(issuedAt) + .nbf(issuedAt) + .exp(issuedAt + 300); // 5min + if (jwtId == null) { + body.randomId(); + } else { + body.id(jwtId); + } if (challenge != null) { body.challenge(challenge); } From 25f14b435d778ff951d63627ff25a0e1f22a78da Mon Sep 17 00:00:00 2001 From: Ogenbertrand Date: Tue, 11 Aug 2026 13:41:35 +0100 Subject: [PATCH 18/18] Add ABCA replay protection tests Signed-off-by: Ogenbertrand --- ...estationBasedClientAuthenticationTest.java | 64 ++++++++++++++++++- 1 file changed, 61 insertions(+), 3 deletions(-) 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 083d2c3c2aad..da832ec9beed 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 @@ -32,6 +32,7 @@ import org.keycloak.broker.trust.DefaultTrustIdentityProviderConfig; import org.keycloak.broker.trust.DefaultTrustIdentityProviderFactory; import org.keycloak.common.VerificationException; +import org.keycloak.common.util.Time; import org.keycloak.crypto.KeyWrapper; import org.keycloak.jose.jwk.JSONWebKeySet; import org.keycloak.jose.jwk.JWK; @@ -292,7 +293,8 @@ public void testClientAttestationChallengeCannotBeReused() { var kw = wallet.getRSAKeyPair(ctx); String attestationJwt = wallet.buildClientAttestationJWT(ctx, kw); String challenge = oauth.clientAttestationChallengeRequest().send().getAttestationChallenge(); - String attestationPoPJwt = wallet.buildClientAttestationPoPJWT(ctx, kw, challenge); + String firstAttestationPoPJwt = wallet.buildClientAttestationPoPJWT(ctx, kw, challenge); + String secondAttestationPoPJwt = wallet.buildClientAttestationPoPJWT(ctx, kw, challenge); KeyWrapper ecKey = wallet.getECKeyPair(ctx); String tokenEndpoint = oauth.getEndpoints().getToken(); @@ -305,7 +307,7 @@ public void testClientAttestationChallengeCannotBeReused() { AccessTokenResponse firstTokenResponse = wallet.accessTokenRequest(ctx, firstAuthResponse.getCode()) .header(OAUTH_CLIENT_ATTESTATION_HEADER, attestationJwt) - .header(OAUTH_CLIENT_ATTESTATION_POP_HEADER, attestationPoPJwt) + .header(OAUTH_CLIENT_ATTESTATION_POP_HEADER, firstAttestationPoPJwt) .dpopProof(wallet.generateSignedDPoPProof(tokenEndpoint, ecKey, null)) .send(); assertTrue(firstTokenResponse.isSuccess(), "Token request error: " + firstTokenResponse.getErrorDescription()); @@ -318,7 +320,7 @@ public void testClientAttestationChallengeCannotBeReused() { AccessTokenResponse secondTokenResponse = wallet.accessTokenRequest(ctx, secondAuthResponse.getCode()) .header(OAUTH_CLIENT_ATTESTATION_HEADER, attestationJwt) - .header(OAUTH_CLIENT_ATTESTATION_POP_HEADER, attestationPoPJwt) + .header(OAUTH_CLIENT_ATTESTATION_POP_HEADER, secondAttestationPoPJwt) .dpopProof(wallet.generateSignedDPoPProof(tokenEndpoint, ecKey, null)) .send(); @@ -326,4 +328,60 @@ public void testClientAttestationChallengeCannotBeReused() { assertEquals(OAuthErrorException.USE_ATTESTATION_CHALLENGE, secondTokenResponse.getError()); assertNotNull(secondTokenResponse.getHeader(AttestationBasedClientAuthenticator.OAUTH_CLIENT_ATTESTATION_CHALLENGE_HEADER)); } + + @Test + public void testClientAttestationPoPJWTCannotBeReused() { + + var ctx = new OID4VCTestContext(abcaClient, sdJwtTypeCredentialScope); + ctx.putAttachment(CLIENT_ATTESTER_ATTACHMENT_KEY, attester); + + var kw = wallet.getRSAKeyPair(ctx); + String attestationJwt = wallet.buildClientAttestationJWT(ctx, kw); + String attestationPoPJwt = wallet.buildClientAttestationPoPJWT(ctx, kw); + + KeyWrapper ecKey = wallet.getECKeyPair(ctx); + String tokenEndpoint = oauth.getEndpoints().getToken(); + + AccessTokenResponse firstTokenResponse = requestToken(ctx, attestationJwt, attestationPoPJwt, ecKey, tokenEndpoint); + assertTrue(firstTokenResponse.isSuccess(), "Token request error: " + firstTokenResponse.getErrorDescription()); + + AccessTokenResponse secondTokenResponse = requestToken(ctx, attestationJwt, attestationPoPJwt, ecKey, tokenEndpoint); + assertFalse(secondTokenResponse.isSuccess()); + assertEquals(OAuthErrorException.INVALID_CLIENT_ATTESTATION, secondTokenResponse.getError()); + } + + @Test + public void testClientAttestationPoPJWTIssuedTooFarInPastIsRejected() { + + var ctx = new OID4VCTestContext(abcaClient, sdJwtTypeCredentialScope); + ctx.putAttachment(CLIENT_ATTESTER_ATTACHMENT_KEY, attester); + + var kw = wallet.getRSAKeyPair(ctx); + String attestationJwt = wallet.buildClientAttestationJWT(ctx, kw); + String attestationPoPJwt = wallet.buildClientAttestationPoPJWT(ctx, kw, null, "old-pop-jti", + Time.currentTime() - 360); + + KeyWrapper ecKey = wallet.getECKeyPair(ctx); + String tokenEndpoint = oauth.getEndpoints().getToken(); + + AccessTokenResponse tokenResponse = requestToken(ctx, attestationJwt, attestationPoPJwt, ecKey, tokenEndpoint); + assertFalse(tokenResponse.isSuccess()); + assertEquals(OAuthErrorException.INVALID_CLIENT_ATTESTATION, tokenResponse.getError()); + } + + private AccessTokenResponse requestToken(OID4VCTestContext ctx, String attestationJwt, String attestationPoPJwt, + KeyWrapper ecKey, String tokenEndpoint) { + AuthorizationEndpointResponse authResponse = wallet.authorizationRequest() + .scope(ctx.getScope()) + .send(ctx.getHolder(), TEST_PASSWORD); + + assertNull(authResponse.getErrorDescription(), "Authorization error: " + authResponse.getErrorDescription()); + assertNotNull(authResponse.getCode(), "No auth code"); + + return wallet.accessTokenRequest(ctx, authResponse.getCode()) + .header(OAUTH_CLIENT_ATTESTATION_HEADER, attestationJwt) + .header(OAUTH_CLIENT_ATTESTATION_POP_HEADER, attestationPoPJwt) + .dpopProof(wallet.generateSignedDPoPProof(tokenEndpoint, ecKey, null)) + .send(); + } }