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 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