From 236b0fb25a46a2e5ba1f3c8f3aa265bb96281e34 Mon Sep 17 00:00:00 2001
From: Thomas Diesler
Date: Mon, 16 Mar 2026 14:30:47 +0100
Subject: [PATCH 1/6] [OID4VCI-HAIP] Pass oid4vci-1_0-issuer-metadata_test
Signed-off-by: Thomas Diesler
---
.../java/org/keycloak/common/Profile.java | 1 +
.../OIDCConfigurationRepresentation.java | 22 ++++
.../AuthenticationFlowError.java | 1 +
.../client/AbstractClientAuthenticator.java | 4 +
.../AttestationBasedClientAuthenticator.java | 111 ++++++++++++++++++
.../FederatedJWTClientAuthenticator.java | 5 +-
.../client/X509ClientAuthenticator.java | 4 -
.../protocol/oidc/OIDCLoginProtocol.java | 1 +
.../protocol/oidc/OIDCWellKnownProvider.java | 25 ++--
....authentication.ClientAuthenticatorFactory | 3 +-
.../tests/oid4vc/OID4VCIssuerTestBase.java | 7 ++
.../oid4vc/OIDCAuthorizationMetadataTest.java | 40 +++++++
.../OIDCAuthorizationMetadataABCATest.java | 40 +++++++
13 files changed, 248 insertions(+), 16 deletions(-)
create mode 100644 services/src/main/java/org/keycloak/authentication/authenticators/client/AttestationBasedClientAuthenticator.java
create mode 100644 tests/base/src/test/java/org/keycloak/tests/oid4vc/OIDCAuthorizationMetadataTest.java
create mode 100644 tests/base/src/test/java/org/keycloak/tests/oid4vc/abca/OIDCAuthorizationMetadataABCATest.java
diff --git a/common/src/main/java/org/keycloak/common/Profile.java b/common/src/main/java/org/keycloak/common/Profile.java
index d42a6145fcce..085b24925d32 100755
--- a/common/src/main/java/org/keycloak/common/Profile.java
+++ b/common/src/main/java/org/keycloak/common/Profile.java
@@ -98,6 +98,7 @@ public enum Feature {
STEP_UP_AUTHENTICATION_SAML("Step-up Authentication Saml", Type.PREVIEW, Feature.STEP_UP_AUTHENTICATION),
CLIENT_AUTH_FEDERATED("Authenticates client based on assertions issued by identity provider", Type.DEFAULT),
+ CLIENT_AUTH_ABCA("Attestation-Based Client Authentication", Type.EXPERIMENTAL),
SPIFFE("SPIFFE trust relationship provider", Type.PREVIEW),
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 074b9a708026..8678abdcc508 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
@@ -115,6 +115,12 @@ public class OIDCConfigurationRepresentation {
@JsonProperty("token_endpoint_auth_signing_alg_values_supported")
private List tokenEndpointAuthSigningAlgValuesSupported;
+ @JsonProperty("client_attestation_signing_alg_values_supported")
+ private List clientAttestationSigningAlgValuesSupported;
+
+ @JsonProperty("client_attestation_pop_signing_alg_values_supported")
+ private List clientAttestationPopSigningAlgValuesSupported;
+
@JsonProperty("introspection_endpoint_auth_methods_supported")
private List introspectionEndpointAuthMethodsSupported;
@@ -410,6 +416,22 @@ public void setTokenEndpointAuthSigningAlgValuesSupported(List tokenEndp
this.tokenEndpointAuthSigningAlgValuesSupported = tokenEndpointAuthSigningAlgValuesSupported;
}
+ public List getClientAttestationSigningAlgValuesSupported() {
+ return clientAttestationSigningAlgValuesSupported;
+ }
+
+ public void setClientAttestationSigningAlgValuesSupported(List clientAttestationSigningAlgValuesSupported) {
+ this.clientAttestationSigningAlgValuesSupported = clientAttestationSigningAlgValuesSupported;
+ }
+
+ public List getClientAttestationPopSigningAlgValuesSupported() {
+ return clientAttestationPopSigningAlgValuesSupported;
+ }
+
+ public void setClientAttestationPopSigningAlgValuesSupported(List clientAttestationPopSigningAlgValuesSupported) {
+ this.clientAttestationPopSigningAlgValuesSupported = clientAttestationPopSigningAlgValuesSupported;
+ }
+
public List getIntrospectionEndpointAuthMethodsSupported() {
return introspectionEndpointAuthMethodsSupported;
}
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 612b859e563a..869f3e7b1e58 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
@@ -47,5 +47,6 @@ public enum AuthenticationFlowError {
DISPLAY_NOT_SUPPORTED,
ACCESS_DENIED,
+ UNAUTHORIZED_CLIENT,
GENERIC_AUTHENTICATION_ERROR
}
diff --git a/services/src/main/java/org/keycloak/authentication/authenticators/client/AbstractClientAuthenticator.java b/services/src/main/java/org/keycloak/authentication/authenticators/client/AbstractClientAuthenticator.java
index 516df6e00eaf..238b80587880 100644
--- a/services/src/main/java/org/keycloak/authentication/authenticators/client/AbstractClientAuthenticator.java
+++ b/services/src/main/java/org/keycloak/authentication/authenticators/client/AbstractClientAuthenticator.java
@@ -23,11 +23,15 @@
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
+import org.jboss.logging.Logger;
+
/**
* @author Marek Posolda
*/
public abstract class AbstractClientAuthenticator implements ClientAuthenticator, ClientAuthenticatorFactory {
+ protected final Logger logger = Logger.getLogger(getClass());
+
@Override
public ClientAuthenticator create() {
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
new file mode 100644
index 000000000000..1738d4d8b082
--- /dev/null
+++ b/services/src/main/java/org/keycloak/authentication/authenticators/client/AttestationBasedClientAuthenticator.java
@@ -0,0 +1,111 @@
+/*
+ * 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.authentication.authenticators.client;
+
+
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import jakarta.ws.rs.core.Response;
+
+import org.keycloak.Config;
+import org.keycloak.OAuthErrorException;
+import org.keycloak.authentication.AuthenticationFlowError;
+import org.keycloak.authentication.ClientAuthenticationFlowContext;
+import org.keycloak.common.Profile;
+import org.keycloak.models.AuthenticationExecutionModel;
+import org.keycloak.models.ClientModel;
+import org.keycloak.models.KeycloakSession;
+import org.keycloak.protocol.oidc.OIDCLoginProtocol;
+import org.keycloak.provider.EnvironmentDependentProviderFactory;
+import org.keycloak.provider.ProviderConfigProperty;
+
+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.
+ *
+ * @author Thomas Diesler
+ */
+public class AttestationBasedClientAuthenticator extends AbstractClientAuthenticator implements EnvironmentDependentProviderFactory {
+
+ public static final String PROVIDER_ID = "client-attestation";
+
+ @Override
+ public String getId() {
+ return PROVIDER_ID;
+ }
+
+ @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);
+ }
+
+ @Override
+ public String getDisplayType() {
+ return "Attestation-Based";
+ }
+
+ @Override
+ public boolean isConfigurable() {
+ return false;
+ }
+
+ @Override
+ public AuthenticationExecutionModel.Requirement[] getRequirementChoices() {
+ return REQUIREMENT_CHOICES;
+ }
+
+ @Override
+ public String getHelpText() {
+ return "Validates client based on a Client Attestation JWT and a PoP JWT which proves possession of the private key";
+ }
+
+ @Override
+ public List getConfigProperties() {
+ return List.of();
+ }
+
+ @Override
+ public List getConfigPropertiesPerClient() {
+ return List.of();
+ }
+
+ @Override
+ public Map getAdapterConfiguration(KeycloakSession session, ClientModel client) {
+ return Map.of();
+ }
+
+ @Override
+ public boolean isSupported(Config.Scope config) {
+ return Profile.isFeatureEnabled(Profile.Feature.CLIENT_AUTH_ABCA);
+ }
+
+ @Override
+ public Set getProtocolAuthenticatorMethods(String loginProtocol) {
+ if (loginProtocol.equals(OIDCLoginProtocol.LOGIN_PROTOCOL)) {
+ return Set.of(OIDCLoginProtocol.ATTEST_JWT_CLIENT_AUTH);
+ } else {
+ return Set.of();
+ }
+ }
+}
diff --git a/services/src/main/java/org/keycloak/authentication/authenticators/client/FederatedJWTClientAuthenticator.java b/services/src/main/java/org/keycloak/authentication/authenticators/client/FederatedJWTClientAuthenticator.java
index fad9283cfbf8..f2a94dae0bac 100644
--- a/services/src/main/java/org/keycloak/authentication/authenticators/client/FederatedJWTClientAuthenticator.java
+++ b/services/src/main/java/org/keycloak/authentication/authenticators/client/FederatedJWTClientAuthenticator.java
@@ -26,12 +26,9 @@
import org.keycloak.representations.JsonWebToken;
import org.keycloak.services.resources.IdentityBrokerService;
-import org.jboss.logging.Logger;
public class FederatedJWTClientAuthenticator extends AbstractClientAuthenticator implements EnvironmentDependentProviderFactory {
- private static final Logger LOGGER = Logger.getLogger(FederatedJWTClientAuthenticator.class);
-
public static final String PROVIDER_ID = "federated-jwt";
public static final String JWT_CREDENTIAL_ISSUER_KEY = "jwt.credential.issuer";
@@ -113,7 +110,7 @@ public void authenticateClient(ClientAuthenticationFlowContext context) {
context.failure(AuthenticationFlowError.INVALID_CLIENT_CREDENTIALS);
}
} catch (Exception e) {
- LOGGER.warn("Authentication failed", e);
+ logger.warn("Authentication failed", e);
context.failure(AuthenticationFlowError.INVALID_CLIENT_CREDENTIALS);
}
}
diff --git a/services/src/main/java/org/keycloak/authentication/authenticators/client/X509ClientAuthenticator.java b/services/src/main/java/org/keycloak/authentication/authenticators/client/X509ClientAuthenticator.java
index 661469429e8d..dc80b859bbba 100644
--- a/services/src/main/java/org/keycloak/authentication/authenticators/client/X509ClientAuthenticator.java
+++ b/services/src/main/java/org/keycloak/authentication/authenticators/client/X509ClientAuthenticator.java
@@ -28,8 +28,6 @@
import org.keycloak.provider.ProviderConfigProperty;
import org.keycloak.services.x509.X509ClientCertificateLookup;
-import org.jboss.logging.Logger;
-
public class X509ClientAuthenticator extends AbstractClientAuthenticator {
public static final String PROVIDER_ID = "client-x509";
@@ -56,8 +54,6 @@ public class X509ClientAuthenticator extends AbstractClientAuthenticator {
CUSTOM_OIDS_REVERSED.put("E", "1.2.840.113549.1.9.1"); // Another synonym for "EMAILADDRESS"
}
- private final static Logger logger = Logger.getLogger(X509ClientAuthenticator.class);
-
@Override
public void authenticateClient(ClientAuthenticationFlowContext context) {
diff --git a/services/src/main/java/org/keycloak/protocol/oidc/OIDCLoginProtocol.java b/services/src/main/java/org/keycloak/protocol/oidc/OIDCLoginProtocol.java
index 286296fcdfe6..37dc613d10e9 100755
--- a/services/src/main/java/org/keycloak/protocol/oidc/OIDCLoginProtocol.java
+++ b/services/src/main/java/org/keycloak/protocol/oidc/OIDCLoginProtocol.java
@@ -121,6 +121,7 @@ public class OIDCLoginProtocol implements LoginProtocol {
public static final String CLIENT_SECRET_JWT = "client_secret_jwt";
public static final String PRIVATE_KEY_JWT = "private_key_jwt";
public static final String TLS_CLIENT_AUTH = "tls_client_auth";
+ public static final String ATTEST_JWT_CLIENT_AUTH = "attest_jwt_client_auth";
/**
* This is just for legacy setups which expect an unencoded, non-RFC6749 compliant client secret send from Keycloak to an IdP.
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 ba196502d547..b9af5336f014 100755
--- a/services/src/main/java/org/keycloak/protocol/oidc/OIDCWellKnownProvider.java
+++ b/services/src/main/java/org/keycloak/protocol/oidc/OIDCWellKnownProvider.java
@@ -69,6 +69,8 @@
import org.keycloak.util.JsonSerialization;
import org.keycloak.wellknown.WellKnownProvider;
+import static org.keycloak.protocol.oidc.OIDCLoginProtocol.ATTEST_JWT_CLIENT_AUTH;
+
/**
* @author Stian Thorgersen
*/
@@ -153,10 +155,18 @@ public Object getConfig() {
config.setPromptValuesSupported(getPromptValuesSupported(realm));
- config.setTokenEndpointAuthMethodsSupported(getClientAuthMethodsSupported());
- config.setTokenEndpointAuthSigningAlgValuesSupported(getSupportedClientSigningAlgorithms(false));
- config.setIntrospectionEndpointAuthMethodsSupported(getClientAuthMethodsSupported());
- config.setIntrospectionEndpointAuthSigningAlgValuesSupported(getSupportedClientSigningAlgorithms(false));
+ List clientAuthMethodsSupported = getClientAuthMethodsSupported();
+ List supportedClientSigningAlgorithms = getSupportedClientSigningAlgorithms(false);
+
+ config.setTokenEndpointAuthMethodsSupported(clientAuthMethodsSupported);
+ config.setTokenEndpointAuthSigningAlgValuesSupported(supportedClientSigningAlgorithms);
+ config.setIntrospectionEndpointAuthMethodsSupported(clientAuthMethodsSupported);
+ config.setIntrospectionEndpointAuthSigningAlgValuesSupported(supportedClientSigningAlgorithms);
+
+ if (clientAuthMethodsSupported.contains(ATTEST_JWT_CLIENT_AUTH)) {
+ config.setClientAttestationSigningAlgValuesSupported(getSupportedSigningAlgorithms(false));
+ config.setClientAttestationPopSigningAlgValuesSupported(getSupportedSigningAlgorithms(false));
+ }
config.setAuthorizationSigningAlgValuesSupported(getSupportedSigningAlgorithms(false));
config.setAuthorizationEncryptionAlgValuesSupported(getSupportedEncryptionAlg(false));
@@ -199,8 +209,8 @@ public Object getConfig() {
// NOTE: Don't hardcode HTTPS checks here. JWKS URI is exposed just in the development/testing environment. For the production environment, the OIDCWellKnownProvider
// is not exposed over "http" at all.
config.setRevocationEndpoint(revocationEndpoint.toString());
- config.setRevocationEndpointAuthMethodsSupported(getClientAuthMethodsSupported());
- config.setRevocationEndpointAuthSigningAlgValuesSupported(getSupportedClientSigningAlgorithms(false));
+ config.setRevocationEndpointAuthMethodsSupported(clientAuthMethodsSupported);
+ config.setRevocationEndpointAuthSigningAlgValuesSupported(supportedClientSigningAlgorithms);
config.setBackchannelLogoutSupported(true);
config.setBackchannelLogoutSessionSupported(true);
@@ -249,11 +259,12 @@ private static List list(String... values) {
}
private List getClientAuthMethodsSupported() {
- return session.getKeycloakSessionFactory().getProviderFactoriesStream(ClientAuthenticator.class)
+ List clientAuthMethods = session.getKeycloakSessionFactory().getProviderFactoriesStream(ClientAuthenticator.class)
.map(ClientAuthenticatorFactory.class::cast)
.map(caf -> caf.getProtocolAuthenticatorMethods(OIDCLoginProtocol.LOGIN_PROTOCOL))
.flatMap(Collection::stream)
.collect(Collectors.toList());
+ return clientAuthMethods;
}
private List getSupportedAlgorithms(Class extends Provider> clazz, boolean includeNone) {
diff --git a/services/src/main/resources/META-INF/services/org.keycloak.authentication.ClientAuthenticatorFactory b/services/src/main/resources/META-INF/services/org.keycloak.authentication.ClientAuthenticatorFactory
index c0b35f485d72..192fd787b294 100644
--- a/services/src/main/resources/META-INF/services/org.keycloak.authentication.ClientAuthenticatorFactory
+++ b/services/src/main/resources/META-INF/services/org.keycloak.authentication.ClientAuthenticatorFactory
@@ -19,4 +19,5 @@ org.keycloak.authentication.authenticators.client.ClientIdAndSecretAuthenticator
org.keycloak.authentication.authenticators.client.JWTClientAuthenticator
org.keycloak.authentication.authenticators.client.JWTClientSecretAuthenticator
org.keycloak.authentication.authenticators.client.X509ClientAuthenticator
-org.keycloak.authentication.authenticators.client.FederatedJWTClientAuthenticator
\ No newline at end of file
+org.keycloak.authentication.authenticators.client.FederatedJWTClientAuthenticator
+org.keycloak.authentication.authenticators.client.AttestationBasedClientAuthenticator
diff --git a/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCIssuerTestBase.java b/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCIssuerTestBase.java
index 9846a42bf09b..833aca0b2ae3 100644
--- a/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCIssuerTestBase.java
+++ b/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCIssuerTestBase.java
@@ -404,6 +404,13 @@ public KeycloakServerConfigBuilder configure(KeycloakServerConfigBuilder config)
}
}
+ public static class VCTestServerWithABCAEnabled implements KeycloakServerConfig {
+ @Override
+ public KeycloakServerConfigBuilder configure(KeycloakServerConfigBuilder config) {
+ return config.features(Profile.Feature.OID4VC_VCI, Profile.Feature.CLIENT_AUTH_ABCA);
+ }
+ }
+
public static class VCTestRealmConfig implements RealmConfig {
public static final String TEST_REALM_NAME = "test";
diff --git a/tests/base/src/test/java/org/keycloak/tests/oid4vc/OIDCAuthorizationMetadataTest.java b/tests/base/src/test/java/org/keycloak/tests/oid4vc/OIDCAuthorizationMetadataTest.java
new file mode 100644
index 000000000000..ec1e669549ed
--- /dev/null
+++ b/tests/base/src/test/java/org/keycloak/tests/oid4vc/OIDCAuthorizationMetadataTest.java
@@ -0,0 +1,40 @@
+/*
+ * 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;
+
+import java.util.List;
+
+import org.keycloak.protocol.oidc.representations.OIDCConfigurationRepresentation;
+import org.keycloak.testframework.annotations.KeycloakIntegrationTest;
+
+import org.junit.jupiter.api.Test;
+
+import static org.keycloak.protocol.oidc.OIDCLoginProtocol.ATTEST_JWT_CLIENT_AUTH;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+
+
+@KeycloakIntegrationTest(config = OID4VCIssuerTestBase.VCTestServerConfig.class)
+public class OIDCAuthorizationMetadataTest extends OID4VCIssuerTestBase {
+
+ @Test
+ public void testTokenEndpointAuthMethods() {
+ OIDCConfigurationRepresentation oidcConfiguration = oauth.doWellKnownRequest();
+ List tokenAuthMethodsSupported = oidcConfiguration.getTokenEndpointAuthMethodsSupported();
+ assertFalse(tokenAuthMethodsSupported.contains(ATTEST_JWT_CLIENT_AUTH), "Should not contain: " + ATTEST_JWT_CLIENT_AUTH);
+ }
+}
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
new file mode 100644
index 000000000000..99f6b745b484
--- /dev/null
+++ b/tests/base/src/test/java/org/keycloak/tests/oid4vc/abca/OIDCAuthorizationMetadataABCATest.java
@@ -0,0 +1,40 @@
+/*
+ * 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);
+ }
+}
From 337dfd727b9db8070ac7a159d577b446adcb08aa Mon Sep 17 00:00:00 2001
From: Thomas Diesler
Date: Mon, 23 Mar 2026 10:17:08 +0100
Subject: [PATCH 2/6] [OID4VCI-HAIP] Pass oid4vci-1_0-issuer-happy-flow
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 | 280 +++++++++++++++++-
.../OID4VCAuthorizationDetailsProcessor.java | 5 +-
.../oid4vc/issuance/OID4VCIssuerEndpoint.java | 6 +-
.../oidc/endpoints/TokenEndpoint.java | 8 +-
.../org/keycloak/services/util/DPoPUtil.java | 22 +-
10 files changed, 333 insertions(+), 25 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..5d7f76532c02 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,84 @@
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.http.HttpRequest;
+import org.keycloak.jose.jwk.JWK;
+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": "keycloak-abca-sig-rsa",
+ * "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 +104,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 {
+ validateClientAttestationJwt(context);
+ validateClientAttestationPoPJwt(context);
+
+ 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 +136,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 +146,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 +183,189 @@ 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 attesterKeysConfig = Optional.ofNullable(context.getAuthenticatorConfig())
+ .map(AuthenticatorConfigModel::getConfig).orElse(Map.of()).get(configName);
+ if (attesterKeysConfig == null)
+ throw new IllegalStateException("Cannot load Attester public keys from: " + configName);
+
+ JsonNode attesterKeys = JsonSerialization.valueFromString(attesterKeysConfig, JsonNode.class);
+ if (attesterKeys == null)
+ 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 void validateClientAttestationJwt(ClientAuthenticationFlowContext context) throws Exception {
+
+ 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");
+
+ // Parse the Client Attestation JWT without signature verification
+ //
+ TokenVerifier tokenVerifier = TokenVerifier.create(headerValue, ClientAttestationJwt.class);
+ ClientAttestationJwt jwt = tokenVerifier
+ .withChecks(TokenVerifier.IS_ACTIVE)
+ .getToken();
+
+ // [TODO] Do these checks with predicates
+
+ if (OAUTH_CLIENT_ATTESTATION_JWT_TYPE.equals(jwt.getType()))
+ throw new IllegalStateException("The JWT type MUST be " + OAUTH_CLIENT_ATTESTATION_JWT_TYPE + " instead of " + jwt.getType());
+ if (Strings.isEmpty(jwt.getIssuer()))
+ throw new IllegalStateException("The iss (issuer) claim MUST contains a unique identifier for the entity that issued the JWT");
+ if (Strings.isEmpty(jwt.getSubject()))
+ throw new IllegalStateException("The sub (subject) claim MUST specify client_id value of the OAuth Client");
+ if (jwt.getExp() == 0)
+ throw new IllegalStateException("The exp (expiration time) claim MUST specify the time at which the Client Attestation is considered expired by its issuer.");
+ if (jwt.getConfirmation() == null || jwt.getConfirmation().getJwk() == null)
+ throw new IllegalStateException("The cnf (confirmation) claim MUST specify a key that is used by the Client Instance to generate the Client Attestation PoP JWT");
+
+ // The Authorization Server MUST verify that the value of client_id parameter is the same as the client_id value in the sub claim
+ //
+ String clientIdParam = formParams.getFirst(CLIENT_ID);
+ if (clientIdParam != null && !clientIdParam.equals(jwt.getSubject()))
+ throw new IllegalStateException("The client attestation subject does not match the client_id parameter");
+
+ // We set the target client in the context before we attempt signature verification
+ //
+ RealmModel realmModel = context.getSession().getContext().getRealm();
+ ClientModel clientModel = realmModel.getClientByClientId(jwt.getSubject());
+ context.setClient(clientModel);
+
+ // Verification and Processing
+
+ // [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
+ }
+
+ // 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) 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);
+ ClientAttestationPoPJwt jwt = jws.readJsonContent(ClientAttestationPoPJwt.class);
+
+ if (OAUTH_CLIENT_ATTESTATION_POP_JWT_TYPE.equals(jwt.getType()))
+ throw new IllegalStateException("The JWT type MUST be " + OAUTH_CLIENT_ATTESTATION_POP_JWT_TYPE + " instead of " + jwt.getType());
+ if (Strings.isEmpty(jwt.getIssuer()))
+ throw new IllegalStateException("The iss (issuer) claim MUST specify client_id value of the OAuth Client");
+ if (jwt.getAudience() == null || jwt.getAudience().length == 0)
+ throw new IllegalStateException("The aud (audience) claim MUST specify a value that identifies the authorization server as an intended audience.");
+ if (Strings.isEmpty(jwt.getId()))
+ throw new IllegalStateException("The jti (JWT identifier) claim MUST specify a unique identifier for the Client Attestation PoP.");
+ if (jwt.getIat() == 0)
+ throw new IllegalStateException("The iat (issued at) claim MUST specify the time at which the Client Attestation PoP was issued.");
+
+ // [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] The authorization server MUST reject JWTs with an invalid signature.
+ // [TODO] The value of the iss claim, representing the client_id MUST match the value of the sub claim in the corresponding Client Attestation JWT
+
+// if (!clientModel.getClientId().equals(clientAttestationPoPJwt.getIssuer())) {
+// throw new IllegalStateException("The client attestation PoP issuer does not match the authorized client_id");
+// }
+
+
+ // Verification and Processing
+
+ // [TODO] The signature of the Client Attestation PoP JWT verifies with the public key contained in the cnf claim of the Client Attestation JWT.
+ // [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/oid4vc/issuance/OID4VCAuthorizationDetailsProcessor.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/OID4VCAuthorizationDetailsProcessor.java
index 7c22f599ee44..11e9657cb134 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.infof("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/protocol/oid4vc/issuance/OID4VCIssuerEndpoint.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/OID4VCIssuerEndpoint.java
index 4c2fa5fb324f..c64c33b66b19 100644
--- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/OID4VCIssuerEndpoint.java
+++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/OID4VCIssuerEndpoint.java
@@ -673,8 +673,8 @@ private void checkScope(CredentialScopeModel requestedCredential) {
@Produces({MediaType.APPLICATION_JSON, MediaType.APPLICATION_JWT})
@Path(CREDENTIAL_PATH)
public Response requestCredential(String requestPayload) {
- RealmModel realmModel = session.getContext().getRealm();
- EventBuilder eventBuilder = new EventBuilder(realmModel, session, session.getContext().getConnection());
+ RealmModel realm = session.getContext().getRealm();
+ EventBuilder eventBuilder = new EventBuilder(realm, session, session.getContext().getConnection());
eventBuilder.event(EventType.VERIFIABLE_CREDENTIAL_REQUEST);
checkIsOid4vciEnabled(eventBuilder);
@@ -905,7 +905,7 @@ public Response requestCredential(String requestPayload) {
// Find credential client scope by requested/authorized credential_configuration_id
//
CredentialScopeModel authorizedCredentialScope = CredentialScopeModelUtils.findCredentialScopeModelByConfigurationId(
- realmModel, () -> clientModel.getClientScopes(false).values().stream(), authorizedCredentialConfigurationId);
+ realm, () -> clientModel.getClientScopes(false).values().stream(), authorizedCredentialConfigurationId);
if (authorizedCredentialScope == null) {
var errorMessage = String.format("Credential client scope not found: %s", authorizedCredentialConfigurationId);
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 8404d9690fc4a29762e25cea5ffa97962406125c Mon Sep 17 00:00:00 2001
From: Thomas Diesler
Date: Mon, 30 Mar 2026 13:50:30 +0200
Subject: [PATCH 3/6] [OID4VCI-HAIP] Pass
oid4vci-1_0-issuer-fail-invalid-client-attestation-signature
Signed-off-by: Thomas Diesler
---
.../client/AttestationBasedClientAuthenticator.java | 6 ++++++
1 file changed, 6 insertions(+)
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 5d7f76532c02..f4dec7bcb36d 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
@@ -314,6 +314,12 @@ private void validateClientAttestationJwt(ClientAuthenticationFlowContext contex
ClientModel clientModel = realmModel.getClientByClientId(jwt.getSubject());
context.setClient(clientModel);
+ // The signature of the Client Attestation JWT verifies with the public key of a known and trusted Attester
+ //
+ JWSInput jws = new JWSInput(headerValue);
+ PublicKey publicKey = loadAttesterPublicKey(context, jws.getHeader().getKeyId());
+ tokenVerifier.publicKey(publicKey).verifySignature();
+
// Verification and Processing
// [TODO] The alg JOSE Header Parameter for both JWTs indicates a registered asymmetric digital signature algorithm
From 51bbe31adee2a9407ef6e7deafeb3d821152640d Mon Sep 17 00:00:00 2001
From: Thomas Diesler
Date: Mon, 30 Mar 2026 16:11:28 +0200
Subject: [PATCH 4/6] [OID4VCI-HAIP] Pass
oid4vci-1_0-issuer-fail-invalid-client-attestation-pop-signature
Signed-off-by: Thomas Diesler
---
.../main/java/org/keycloak/TokenVerifier.java | 17 +-
.../jose/jws/crypto/ECDSAProvider.java | 162 ++++++++++++++++++
.../keycloak/jose/jws/crypto/RSAProvider.java | 2 +-
.../AttestationBasedClientAuthenticator.java | 153 ++++++++++-------
4 files changed, 269 insertions(+), 65 deletions(-)
create mode 100755 core/src/main/java/org/keycloak/jose/jws/crypto/ECDSAProvider.java
diff --git a/core/src/main/java/org/keycloak/TokenVerifier.java b/core/src/main/java/org/keycloak/TokenVerifier.java
index 90b6e3e10535..20c61ce58c86 100755
--- a/core/src/main/java/org/keycloak/TokenVerifier.java
+++ b/core/src/main/java/org/keycloak/TokenVerifier.java
@@ -23,6 +23,7 @@
import java.util.Iterator;
import java.util.LinkedList;
import java.util.List;
+import java.util.Optional;
import java.util.logging.Level;
import java.util.logging.Logger;
import javax.crypto.SecretKey;
@@ -35,6 +36,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;
@@ -439,11 +441,10 @@ public void verifySignature() throws VerificationException {
throw new VerificationException(e);
}
} else {
- AlgorithmType algorithmType = getHeader().getAlgorithm().getType();
+ AlgorithmType algorithmType = Optional.ofNullable(getHeader().getAlgorithm().getType())
+ .orElseThrow(() -> new VerificationException("No token algorithm"));
- 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..e7877d119383
--- /dev/null
+++ b/core/src/main/java/org/keycloak/jose/jws/crypto/ECDSAProvider.java
@@ -0,0 +1,162 @@
+/*
+ * 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.io.ByteArrayOutputStream;
+import java.nio.charset.StandardCharsets;
+import java.security.PublicKey;
+import java.security.Signature;
+import java.security.cert.X509Certificate;
+import java.util.Arrays;
+
+import org.keycloak.common.util.PemUtils;
+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 Signature getSignature(Algorithm alg) {
+ try {
+ return Signature.getInstance(getJavaAlgorithm(alg));
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ public 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());
+ }
+
+ public static boolean verify(JWSInput input, PublicKey publicKey) {
+ try {
+ Signature verifier = getSignature(input.getHeader().getAlgorithm());
+ verifier.initVerify(publicKey);
+ verifier.update(input.getEncodedSignatureInput().getBytes(StandardCharsets.UTF_8));
+ byte[] derSignature = transcodeSignatureToDER(input.getSignature());
+ return verifier.verify(derSignature);
+ } catch (Exception e) {
+ return false;
+ }
+
+ }
+
+ @Override
+ public boolean verify(JWSInput input, String key) {
+ return verifyViaCertificate(input, key);
+ }
+
+ private static byte[] transcodeSignatureToDER(byte[] jwsSignature) {
+ if (jwsSignature.length % 2 != 0) {
+ throw new IllegalArgumentException("Invalid ECDSA signature format");
+ }
+
+ int len = jwsSignature.length / 2;
+
+ byte[] r = Arrays.copyOfRange(jwsSignature, 0, len);
+ byte[] s = Arrays.copyOfRange(jwsSignature, len, jwsSignature.length);
+
+ byte[] derR = derEncodeInteger(r);
+ byte[] derS = derEncodeInteger(s);
+
+ int totalLength = derR.length + derS.length;
+
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+
+ out.write(0x30); // SEQUENCE
+ writeLength(out, totalLength);
+ out.write(derR, 0, derR.length);
+ out.write(derS, 0, derS.length);
+
+ return out.toByteArray();
+ }
+ private static byte[] derEncodeInteger(byte[] value) {
+ // remove leading zeros
+ int offset = 0;
+ while (offset < value.length - 1 && value[offset] == 0) {
+ offset++;
+ }
+
+ int length = value.length - offset;
+
+ // if highest bit is set, prepend 0x00
+ boolean needsPadding = (value[offset] & 0x80) != 0;
+
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+
+ out.write(0x02); // INTEGER
+
+ int contentLength = length + (needsPadding ? 1 : 0);
+ writeLength(out, contentLength);
+
+ if (needsPadding) {
+ out.write(0x00);
+ }
+
+ out.write(value, offset, length);
+
+ return out.toByteArray();
+ }
+
+ // In DER (Distinguished Encoding Rules), every element is encoded as TAG | LENGTH | VALUE
+ // This method writes the LENGTH part
+ private static void writeLength(ByteArrayOutputStream out, int length) {
+ if (length < 128) {
+ out.write(length);
+ } else {
+ int temp = length;
+ int numBytes = 0;
+
+ byte[] buffer = new byte[4]; // enough for int
+
+ while (temp > 0) {
+ buffer[buffer.length - 1 - numBytes] = (byte) (temp & 0xFF);
+ temp >>= 8;
+ numBytes++;
+ }
+
+ out.write(0x80 | numBytes);
+
+ for (int i = buffer.length - numBytes; i < buffer.length; i++) {
+ out.write(buffer[i]);
+ }
+ }
+ }
+}
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/services/src/main/java/org/keycloak/authentication/authenticators/client/AttestationBasedClientAuthenticator.java b/services/src/main/java/org/keycloak/authentication/authenticators/client/AttestationBasedClientAuthenticator.java
index f4dec7bcb36d..f782bca59c50 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
@@ -39,8 +39,10 @@
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;
@@ -87,7 +89,7 @@ public class AttestationBasedClientAuthenticator extends AbstractClientAuthentic
* [
* {
* "kty": "RSA",
- * "kid": "keycloak-abca-sig-rsa",
+ * "kid": "openid-abca-attester-key",
* "use": "sig",
* "alg": "PS256",
* "n": "uVd8mEqXMp...aaVZNQ",
@@ -95,7 +97,7 @@ public class AttestationBasedClientAuthenticator extends AbstractClientAuthentic
* }
* ]
*/
- public static final String OAUTH_CLIENT_ATTESTATION_CONFIG_ATTESTER_JWKS = "attester.jwks";
+ public static final String OAUTH_CLIENT_ATTESTATION_CONFIG_ATTESTER_JWKS = "attester_jwks";
@Override
public String getId() {
@@ -118,8 +120,8 @@ public void authenticateClient(ClientAuthenticationFlowContext context) {
context.attempted();
try {
- validateClientAttestationJwt(context);
- validateClientAttestationPoPJwt(context);
+ ClientAttestationJwt attesterJwt = validateClientAttestationJwt(context);
+ validateClientAttestationPoPJwt(context, attesterJwt);
context.success();
@@ -272,7 +274,8 @@ private PublicKey loadAttesterPublicKey(ClientAuthenticationFlowContext context,
// Validate the Client Attestation JWT
// https://www.ietf.org/archive/id/draft-ietf-oauth-attestation-based-client-auth-07.html#section-5.1
- private void validateClientAttestationJwt(ClientAuthenticationFlowContext context) throws Exception {
+ private ClientAttestationJwt validateClientAttestationJwt(ClientAuthenticationFlowContext context) throws Exception {
+ RealmModel realmModel = context.getSession().getContext().getRealm();
HttpRequest httpRequest = context.getHttpRequest();
MultivaluedMap formParams = httpRequest.getDecodedFormParameters();
@@ -282,53 +285,62 @@ private void validateClientAttestationJwt(ClientAuthenticationFlowContext contex
if (headerValue == null)
throw new IllegalStateException("Required header " + OAUTH_CLIENT_ATTESTATION_HEADER + " for is missing");
- // Parse the Client Attestation JWT without signature verification
- //
- TokenVerifier tokenVerifier = TokenVerifier.create(headerValue, ClientAttestationJwt.class);
- ClientAttestationJwt jwt = tokenVerifier
- .withChecks(TokenVerifier.IS_ACTIVE)
- .getToken();
-
- // [TODO] Do these checks with predicates
-
- if (OAUTH_CLIENT_ATTESTATION_JWT_TYPE.equals(jwt.getType()))
- throw new IllegalStateException("The JWT type MUST be " + OAUTH_CLIENT_ATTESTATION_JWT_TYPE + " instead of " + jwt.getType());
- if (Strings.isEmpty(jwt.getIssuer()))
- throw new IllegalStateException("The iss (issuer) claim MUST contains a unique identifier for the entity that issued the JWT");
- if (Strings.isEmpty(jwt.getSubject()))
- throw new IllegalStateException("The sub (subject) claim MUST specify client_id value of the OAuth Client");
- if (jwt.getExp() == 0)
- throw new IllegalStateException("The exp (expiration time) claim MUST specify the time at which the Client Attestation is considered expired by its issuer.");
- if (jwt.getConfirmation() == null || jwt.getConfirmation().getJwk() == null)
- throw new IllegalStateException("The cnf (confirmation) claim MUST specify a key that is used by the Client Instance to generate the Client Attestation PoP JWT");
-
- // The Authorization Server MUST verify that the value of client_id parameter is the same as the client_id value in the sub claim
- //
- String clientIdParam = formParams.getFirst(CLIENT_ID);
- if (clientIdParam != null && !clientIdParam.equals(jwt.getSubject()))
- throw new IllegalStateException("The client attestation subject does not match the client_id parameter");
+ 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);
- // We set the target client in the context before we attempt signature verification
- //
- RealmModel realmModel = context.getSession().getContext().getRealm();
- ClientModel clientModel = realmModel.getClientByClientId(jwt.getSubject());
+ 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
//
- JWSInput jws = new JWSInput(headerValue);
PublicKey publicKey = loadAttesterPublicKey(context, jws.getHeader().getKeyId());
- tokenVerifier.publicKey(publicKey).verifySignature();
// 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) throws Exception {
+ private void validateClientAttestationPoPJwt(ClientAuthenticationFlowContext context, ClientAttestationJwt attesterJwt) throws Exception {
HttpHeaders headers = context.getHttpRequest().getHttpHeaders();
String headerValue = headers.getHeaderString(OAUTH_CLIENT_ATTESTATION_POP_HEADER);
@@ -336,37 +348,58 @@ private void validateClientAttestationPoPJwt(ClientAuthenticationFlowContext con
throw new IllegalStateException("Required header " + OAUTH_CLIENT_ATTESTATION_POP_HEADER + " for is missing");
JWSInput jws = new JWSInput(headerValue);
- ClientAttestationPoPJwt jwt = jws.readJsonContent(ClientAttestationPoPJwt.class);
-
- if (OAUTH_CLIENT_ATTESTATION_POP_JWT_TYPE.equals(jwt.getType()))
- throw new IllegalStateException("The JWT type MUST be " + OAUTH_CLIENT_ATTESTATION_POP_JWT_TYPE + " instead of " + jwt.getType());
- if (Strings.isEmpty(jwt.getIssuer()))
- throw new IllegalStateException("The iss (issuer) claim MUST specify client_id value of the OAuth Client");
- if (jwt.getAudience() == null || jwt.getAudience().length == 0)
- throw new IllegalStateException("The aud (audience) claim MUST specify a value that identifies the authorization server as an intended audience.");
- if (Strings.isEmpty(jwt.getId()))
- throw new IllegalStateException("The jti (JWT identifier) claim MUST specify a unique identifier for the Client Attestation PoP.");
- if (jwt.getIat() == 0)
- throw new IllegalStateException("The iat (issued at) claim MUST specify the time at which the Client Attestation PoP was issued.");
+ 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);
- // [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
+ // Define a few Client Attestation JWT checks
+ //
- // [TODO] The authorization server MUST reject JWTs with an invalid signature.
- // [TODO] The value of the iss claim, representing the client_id MUST match the value of the sub claim in the corresponding Client Attestation JWT
+ 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();
-// if (!clientModel.getClientId().equals(clientAttestationPoPJwt.getIssuer())) {
-// throw new IllegalStateException("The client attestation PoP issuer does not match the authorized client_id");
-// }
+ // Verification and Processing
+ //
+ TokenVerifier.create(headerValue, ClientAttestationPoPJwt.class)
+ .publicKey(publicKey)
+ .withChecks(jtiCheck, iatCheck, issCheck, audCheck)
+ .verify().getToken();
- // Verification and Processing
+ // [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] The signature of the Client Attestation PoP JWT verifies with the public key contained in the cnf claim of the Client Attestation JWT.
// [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
From 8f251e4a89d7ce8742ea095ab4f8df819171337e Mon Sep 17 00:00:00 2001
From: Thomas Diesler
Date: Wed, 1 Apr 2026 16:49:24 +0200
Subject: [PATCH 5/6] [OID4VCI-HAIP] Pass oid4vci-1_0-issuer-fail-missing-proof
Signed-off-by: Thomas Diesler
---
.../protocol/oid4vc/OID4VCLoginProtocolFactory.java | 6 +++++-
.../org/keycloak/tests/oid4vc/OID4VCIssuerTestBase.java | 7 +++----
2 files changed, 8 insertions(+), 5 deletions(-)
diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/OID4VCLoginProtocolFactory.java b/services/src/main/java/org/keycloak/protocol/oid4vc/OID4VCLoginProtocolFactory.java
index a718c3ea385e..95eb0f0831de 100644
--- a/services/src/main/java/org/keycloak/protocol/oid4vc/OID4VCLoginProtocolFactory.java
+++ b/services/src/main/java/org/keycloak/protocol/oid4vc/OID4VCLoginProtocolFactory.java
@@ -55,6 +55,8 @@
import static org.keycloak.models.ClientScopeModel.INCLUDE_IN_TOKEN_SCOPE;
import static org.keycloak.models.oid4vci.CredentialScopeModel.CRYPTOGRAPHIC_BINDING_METHODS_DEFAULT;
import static org.keycloak.models.oid4vci.CredentialScopeModel.VCT;
+import static org.keycloak.models.oid4vci.CredentialScopeModel.VC_BINDING_REQUIRED;
+import static org.keycloak.models.oid4vci.CredentialScopeModel.VC_BINDING_REQUIRED_PROOF_TYPES;
import static org.keycloak.models.oid4vci.CredentialScopeModel.VC_BUILD_CONFIG_HASH_ALGORITHM;
import static org.keycloak.models.oid4vci.CredentialScopeModel.VC_BUILD_CONFIG_HASH_ALGORITHM_DEFAULT;
import static org.keycloak.models.oid4vci.CredentialScopeModel.VC_BUILD_CONFIG_SD_JWT_VISIBLE_CLAIMS;
@@ -139,6 +141,9 @@ public void createDefaultClientScopes(RealmModel newRealm, boolean addScopesToEx
clientScope.addProtocolMapper(builtins.get(EMAIL_MAPPER));
clientScope.addProtocolMapper(builtins.get(FIRST_NAME_MAPPER));
clientScope.addProtocolMapper(builtins.get(LAST_NAME_MAPPER));
+ clientScope.getAttributes().put(VC_BINDING_REQUIRED, "true");
+ clientScope.getAttributes().put(VC_BINDING_REQUIRED_PROOF_TYPES, "jwt");
+ clientScope.getAttributes().put(VC_CRYPTOGRAPHIC_BINDING_METHODS, CRYPTOGRAPHIC_BINDING_METHODS_DEFAULT);
ClientScopeRepresentation clientScopeRep = ModelToRepresentation.toRepresentation(clientScope);
addClientScopeDefaults(clientScopeRep);
@@ -172,7 +177,6 @@ public void addClientScopeDefaults(ClientScopeRepresentation clientScope) {
clientScope.getAttributes().putIfAbsent(VC_SUPPORTED_TYPES, credentialType);
clientScope.getAttributes().putIfAbsent(VC_CONTEXTS, credentialType);
clientScope.getAttributes().putIfAbsent(VCT, credentialType);
- clientScope.getAttributes().putIfAbsent(VC_CRYPTOGRAPHIC_BINDING_METHODS, CRYPTOGRAPHIC_BINDING_METHODS_DEFAULT);
clientScope.getAttributes().putIfAbsent(VC_BUILD_CONFIG_HASH_ALGORITHM, VC_BUILD_CONFIG_HASH_ALGORITHM_DEFAULT);
clientScope.getAttributes().putIfAbsent(VC_BUILD_CONFIG_TOKEN_JWS_TYPE, getDefaultTokenJwsTypeForFormat(format));
clientScope.getAttributes().putIfAbsent(VC_EXPIRY_IN_SECONDS, String.valueOf(VC_EXPIRY_IN_SECONDS_DEFAULT));
diff --git a/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCIssuerTestBase.java b/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCIssuerTestBase.java
index 833aca0b2ae3..3f3901558630 100644
--- a/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCIssuerTestBase.java
+++ b/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCIssuerTestBase.java
@@ -95,6 +95,7 @@
import static org.keycloak.OID4VCConstants.OPENID_CREDENTIAL;
import static org.keycloak.constants.OID4VCIConstants.CREDENTIAL_OFFER_CREATE;
import static org.keycloak.models.Constants.CREATE_DEFAULT_CLIENT_SCOPES;
+import static org.keycloak.models.oid4vci.CredentialScopeModel.CRYPTOGRAPHIC_BINDING_METHODS_DEFAULT;
import static org.keycloak.models.oid4vci.CredentialScopeModel.VC_FORMAT_DEFAULT;
/**
@@ -444,8 +445,7 @@ public RealmConfigBuilder configure(RealmConfigBuilder realm) {
Map sdJwtAttrs = Optional.ofNullable(sdJwtScope.getAttributes()).orElseGet(HashMap::new);
sdJwtAttrs.put(CredentialScopeModel.VC_BINDING_REQUIRED, "true");
sdJwtAttrs.put(CredentialScopeModel.VC_BINDING_REQUIRED_PROOF_TYPES, "jwt");
- sdJwtAttrs.put(CredentialScopeModel.VC_CRYPTOGRAPHIC_BINDING_METHODS,
- CredentialScopeModel.CRYPTOGRAPHIC_BINDING_METHODS_DEFAULT);
+ sdJwtAttrs.put(CredentialScopeModel.VC_CRYPTOGRAPHIC_BINDING_METHODS, CRYPTOGRAPHIC_BINDING_METHODS_DEFAULT);
sdJwtScope.setAttributes(sdJwtAttrs);
realm.addClientScope(sdJwtScope);
@@ -462,8 +462,7 @@ public RealmConfigBuilder configure(RealmConfigBuilder realm) {
Map jwtVcAttrs = Optional.ofNullable(jwtVcScope.getAttributes()).orElseGet(HashMap::new);
jwtVcAttrs.put(CredentialScopeModel.VC_BINDING_REQUIRED, "true");
jwtVcAttrs.put(CredentialScopeModel.VC_BINDING_REQUIRED_PROOF_TYPES, "jwt,attestation");
- jwtVcAttrs.put(CredentialScopeModel.VC_CRYPTOGRAPHIC_BINDING_METHODS,
- CredentialScopeModel.CRYPTOGRAPHIC_BINDING_METHODS_DEFAULT);
+ jwtVcAttrs.put(CredentialScopeModel.VC_CRYPTOGRAPHIC_BINDING_METHODS, CRYPTOGRAPHIC_BINDING_METHODS_DEFAULT);
jwtVcScope.setAttributes(jwtVcAttrs);
realm.addClientScope(jwtVcScope);
From d745c4719373551eb2514520c51f1441d72209c1 Mon Sep 17 00:00:00 2001
From: Thomas Diesler
Date: Tue, 31 Mar 2026 20:13:18 +0200
Subject: [PATCH 6/6] [OID4VCI-HAIP] Pass
oid4vci-1_0-issuer-fail-unknown-credential-configuration
Signed-off-by: Thomas Diesler
---
.../org/keycloak/tests/oid4vc/OID4VCJWTIssuerEndpointTest.java | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCJWTIssuerEndpointTest.java b/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCJWTIssuerEndpointTest.java
index 2c75baeb0bac..09f1dad8a254 100644
--- a/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCJWTIssuerEndpointTest.java
+++ b/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCJWTIssuerEndpointTest.java
@@ -942,7 +942,7 @@ public void testRequestCredentialWithCredentialConfigurationId() {
.credentialConfigurationId(authDetails.get(0).getCredentialConfigurationId())
.bearerToken(tokenResponse.getAccessToken())
.send().getCredentialResponse());
- assertTrue(ex.getMessage().contains("Credential must be requested by credential identifier from authorization_details"), "Unexpected - " + ex.getMessage());
+ assertTrue(ex.getMessage().contains("Credential must be requested by credential identifier from authorization_details"), ex.getMessage());
}
@Test