From c76cc67a7181cecc847760092403004e1457f1d2 Mon Sep 17 00:00:00 2001
From: Thomas Diesler
Date: Fri, 10 Apr 2026 18:23:52 +0200
Subject: [PATCH 1/2] Add support for OAuth 2.0 Attestation-based client
authentication
Signed-off-by: Thomas Diesler
---
.../org/keycloak/OAuthErrorException.java | 1 +
.../keycloak/jose/jws/crypto/RSAProvider.java | 8 +-
.../AuthenticationFlowError.java | 2 +-
.../utils/DefaultAuthenticationFlows.java | 13 +
.../ClientAuthenticationFlow.java | 13 +-
.../AttestationBasedClientAuthenticator.java | 319 +++++++++++++++++-
.../oidc/endpoints/TokenEndpoint.java | 8 +-
.../org/keycloak/services/util/DPoPUtil.java | 22 +-
8 files changed, 365 insertions(+), 21 deletions(-)
diff --git a/core/src/main/java/org/keycloak/OAuthErrorException.java b/core/src/main/java/org/keycloak/OAuthErrorException.java
index b5e5f36cca0a..08deaa0913a5 100755
--- a/core/src/main/java/org/keycloak/OAuthErrorException.java
+++ b/core/src/main/java/org/keycloak/OAuthErrorException.java
@@ -64,6 +64,7 @@ public class OAuthErrorException extends Exception {
// Others
public static final String INVALID_CLIENT = "invalid_client";
+ public static final String INVALID_CLIENT_ATTESTATION = "invalid_client_attestation";
public static final String INVALID_GRANT = "invalid_grant";
public static final String UNSUPPORTED_GRANT_TYPE = "unsupported_grant_type";
public static final String UNSUPPORTED_TOKEN_TYPE = "unsupported_token_type";
diff --git a/core/src/main/java/org/keycloak/jose/jws/crypto/RSAProvider.java b/core/src/main/java/org/keycloak/jose/jws/crypto/RSAProvider.java
index a31eeb7ef338..1f34ad2d00be 100755
--- a/core/src/main/java/org/keycloak/jose/jws/crypto/RSAProvider.java
+++ b/core/src/main/java/org/keycloak/jose/jws/crypto/RSAProvider.java
@@ -41,8 +41,14 @@ public static String getJavaAlgorithm(Algorithm alg) {
return "SHA384withRSA";
case RS512:
return "SHA512withRSA";
+ case PS256:
+ return "SHA256withRSAandMGF1";
+ case PS384:
+ return "SHA384withRSAandMGF1";
+ case PS512:
+ return "SHA512withRSAandMGF1";
default:
- throw new IllegalArgumentException("Not an RSA Algorithm");
+ throw new IllegalArgumentException("Not a supported RSA Algorithm: " + alg);
}
}
diff --git a/server-spi-private/src/main/java/org/keycloak/authentication/AuthenticationFlowError.java b/server-spi-private/src/main/java/org/keycloak/authentication/AuthenticationFlowError.java
index 869f3e7b1e58..9f39f17b5b1c 100755
--- a/server-spi-private/src/main/java/org/keycloak/authentication/AuthenticationFlowError.java
+++ b/server-spi-private/src/main/java/org/keycloak/authentication/AuthenticationFlowError.java
@@ -40,6 +40,7 @@ public enum AuthenticationFlowError {
CLIENT_DISABLED,
CLIENT_CREDENTIALS_SETUP_REQUIRED,
INVALID_CLIENT_CREDENTIALS,
+ INVALID_CLIENT_ATTESTATION,
IDENTITY_PROVIDER_NOT_FOUND,
IDENTITY_PROVIDER_DISABLED,
@@ -47,6 +48,5 @@ public enum AuthenticationFlowError {
DISPLAY_NOT_SUPPORTED,
ACCESS_DENIED,
- UNAUTHORIZED_CLIENT,
GENERIC_AUTHENTICATION_ERROR
}
diff --git a/server-spi-private/src/main/java/org/keycloak/models/utils/DefaultAuthenticationFlows.java b/server-spi-private/src/main/java/org/keycloak/models/utils/DefaultAuthenticationFlows.java
index 58d5fbcc4b45..d6d94e6809a2 100755
--- a/server-spi-private/src/main/java/org/keycloak/models/utils/DefaultAuthenticationFlows.java
+++ b/server-spi-private/src/main/java/org/keycloak/models/utils/DefaultAuthenticationFlows.java
@@ -465,6 +465,7 @@ public static void addIdentityProviderAuthenticator(RealmModel realm, String def
}
public static void clientAuthFlow(RealmModel realm) {
+
AuthenticationFlowModel clients = new AuthenticationFlowModel();
clients.setAlias(CLIENT_AUTHENTICATION_FLOW);
clients.setDescription("Base authentication for clients");
@@ -474,6 +475,18 @@ public static void clientAuthFlow(RealmModel realm) {
clients = realm.addAuthenticationFlow(clients);
realm.setClientAuthenticationFlow(clients);
+ // Attestation-Based Client Authentication is a stronger authentication method
+ //
+ if (Profile.isFeatureEnabled(Feature.CLIENT_AUTH_ABCA)) {
+ AuthenticationExecutionModel execution = new AuthenticationExecutionModel();
+ execution.setParentFlow(clients.getId());
+ execution.setRequirement(AuthenticationExecutionModel.Requirement.ALTERNATIVE);
+ execution.setAuthenticator("attestation-based");
+ execution.setPriority(5);
+ execution.setAuthenticatorFlow(false);
+ realm.addAuthenticatorExecution(execution);
+ }
+
AuthenticationExecutionModel execution = new AuthenticationExecutionModel();
execution.setParentFlow(clients.getId());
execution.setRequirement(AuthenticationExecutionModel.Requirement.ALTERNATIVE);
diff --git a/services/src/main/java/org/keycloak/authentication/ClientAuthenticationFlow.java b/services/src/main/java/org/keycloak/authentication/ClientAuthenticationFlow.java
index f672cc5973db..61b29f8a59cc 100755
--- a/services/src/main/java/org/keycloak/authentication/ClientAuthenticationFlow.java
+++ b/services/src/main/java/org/keycloak/authentication/ClientAuthenticationFlow.java
@@ -25,6 +25,8 @@
import jakarta.ws.rs.core.Response;
+import org.keycloak.authentication.authenticators.client.AttestationBasedClientAuthenticator;
+import org.keycloak.common.Profile;
import org.keycloak.events.Details;
import org.keycloak.events.Errors;
import org.keycloak.models.AuthenticationExecutionModel;
@@ -77,7 +79,8 @@ public Response processFlow() {
if (client != null) {
String expectedClientAuthType = client.getClientAuthenticatorType();
- // Fallback to secret just in case (for backwards compatibility). Also for public clients, ignore the "clientAuthenticatorType", which is set to them and stick to the
+ // Fallback to secret just in case (for backwards compatibility).
+ // Also for public clients, ignore the "clientAuthenticatorType", which is set to them and stick to the
// default, which set the client just based on "client_id" parameter
if (expectedClientAuthType == null || client.isPublicClient()) {
if (expectedClientAuthType == null) {
@@ -86,6 +89,14 @@ public Response processFlow() {
expectedClientAuthType = KeycloakModelUtils.getDefaultClientAuthenticatorType();
}
+ // Use expectedClientAuthType=attestation-based for public client
+ // when AttestationBasedClientAuthenticator is processed
+ //
+ String abcaAuthType = AttestationBasedClientAuthenticator.PROVIDER_ID;
+ if (client.isPublicClient() && factory.getId().equals(abcaAuthType) && Profile.isFeatureEnabled(Profile.Feature.CLIENT_AUTH_ABCA)) {
+ expectedClientAuthType = abcaAuthType;
+ }
+
// Check if client authentication matches
if (factory.getId().equals(expectedClientAuthType)) {
Response response = processResult(context);
diff --git a/services/src/main/java/org/keycloak/authentication/authenticators/client/AttestationBasedClientAuthenticator.java b/services/src/main/java/org/keycloak/authentication/authenticators/client/AttestationBasedClientAuthenticator.java
index 1738d4d8b082..ac1b1a3c0cc3 100644
--- a/services/src/main/java/org/keycloak/authentication/authenticators/client/AttestationBasedClientAuthenticator.java
+++ b/services/src/main/java/org/keycloak/authentication/authenticators/client/AttestationBasedClientAuthenticator.java
@@ -18,35 +18,86 @@
package org.keycloak.authentication.authenticators.client;
+import java.math.BigInteger;
+import java.security.GeneralSecurityException;
+import java.security.KeyFactory;
+import java.security.PublicKey;
+import java.security.spec.RSAPublicKeySpec;
import java.util.List;
import java.util.Map;
+import java.util.Optional;
import java.util.Set;
+import jakarta.ws.rs.core.HttpHeaders;
+import jakarta.ws.rs.core.MultivaluedMap;
import jakarta.ws.rs.core.Response;
import org.keycloak.Config;
import org.keycloak.OAuthErrorException;
+import org.keycloak.TokenVerifier;
import org.keycloak.authentication.AuthenticationFlowError;
import org.keycloak.authentication.ClientAuthenticationFlowContext;
import org.keycloak.common.Profile;
+import org.keycloak.common.util.Base64Url;
+import org.keycloak.exceptions.TokenVerificationException;
+import org.keycloak.http.HttpRequest;
+import org.keycloak.jose.jwk.JWK;
+import org.keycloak.jose.jwk.JWKParser;
+import org.keycloak.jose.jws.JWSInput;
import org.keycloak.models.AuthenticationExecutionModel;
+import org.keycloak.models.AuthenticatorConfigModel;
import org.keycloak.models.ClientModel;
import org.keycloak.models.KeycloakSession;
+import org.keycloak.models.RealmModel;
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.provider.EnvironmentDependentProviderFactory;
import org.keycloak.provider.ProviderConfigProperty;
+import org.keycloak.representations.JsonWebToken;
+import org.keycloak.services.ServicesLogger;
+import org.keycloak.util.JsonSerialization;
+import org.keycloak.util.Strings;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.databind.JsonNode;
+
+import static org.keycloak.OAuth2Constants.CLIENT_ID;
-import static jakarta.ws.rs.core.Response.Status.NOT_IMPLEMENTED;
/**
* Attestation-Based Client Authentication based on Client Attestation JWT and PoP.
* See specs for more details.
*
+ * The current implementation aligns with HAIP Profile 1.0
+ * specifically Attestation-Based Client Authentication - Draft07
+ *
* @author Thomas Diesler
*/
public class AttestationBasedClientAuthenticator extends AbstractClientAuthenticator implements EnvironmentDependentProviderFactory {
- public static final String PROVIDER_ID = "client-attestation";
+ public static final String PROVIDER_ID = "attestation-based";
+ public static final String OAUTH_CLIENT_ATTESTATION_HEADER = "OAuth-Client-Attestation";
+ public static final String OAUTH_CLIENT_ATTESTATION_POP_HEADER = "OAuth-Client-Attestation-PoP";
+
+ public static final String OAUTH_CLIENT_ATTESTATION_JWT_TYPE = "oauth-client-attestation+jwt";
+ public static final String OAUTH_CLIENT_ATTESTATION_POP_JWT_TYPE = "oauth-client-attestation-pop+jwt";
+
+ /**
+ * The ClientAuthenticator needs to be aware of the public keys from the various Attesters it can trust.
+ *
+ * [
+ * {
+ * "kty": "RSA",
+ * "kid": "openid-abca-attester-key",
+ * "use": "sig",
+ * "alg": "PS256",
+ * "n": "uVd8mEqXMp...aaVZNQ",
+ * "e": "AQAB"
+ * }
+ * ]
+ */
+ public static final String OAUTH_CLIENT_ATTESTATION_CONFIG_ATTESTER_JWKS = "attester_jwks";
@Override
public String getId() {
@@ -55,9 +106,30 @@ public String getId() {
@Override
public void authenticateClient(ClientAuthenticationFlowContext context) {
- Response errorResponse = ClientAuthUtil.errorResponse(NOT_IMPLEMENTED.getStatusCode(), OAuthErrorException.UNAUTHORIZED_CLIENT,
- "Attestation-Based Client Authentication not (yet) supported");
- context.failure(AuthenticationFlowError.UNAUTHORIZED_CLIENT, errorResponse);
+
+ HttpHeaders headers = context.getHttpRequest().getHttpHeaders();
+ String attestationValue = headers.getHeaderString(OAUTH_CLIENT_ATTESTATION_HEADER);
+ String attestationPoPValue = headers.getHeaderString(OAUTH_CLIENT_ATTESTATION_POP_HEADER);
+
+ // At least one of the header must be present
+ //
+ if (attestationValue == null && attestationPoPValue == null) {
+ return;
+ }
+
+ context.attempted();
+
+ try {
+ ClientAttestationJwt attesterJwt = validateClientAttestationJwt(context);
+ validateClientAttestationPoPJwt(context, attesterJwt);
+
+ context.success();
+
+ } catch (Exception ex) {
+ ServicesLogger.LOGGER.errorValidatingAssertion(ex);
+ Response challengeResponse = ClientAuthUtil.errorResponse(Response.Status.BAD_REQUEST.getStatusCode(), OAuthErrorException.INVALID_CLIENT_ATTESTATION, ex.getMessage());
+ context.failure(AuthenticationFlowError.INVALID_CLIENT_ATTESTATION, challengeResponse);
+ }
}
@Override
@@ -66,8 +138,8 @@ public String getDisplayType() {
}
@Override
- public boolean isConfigurable() {
- return false;
+ public String getHelpText() {
+ return "Validates client based on a Client Attestation JWT and a PoP JWT which proves possession of the private key";
}
@Override
@@ -76,13 +148,18 @@ public AuthenticationExecutionModel.Requirement[] getRequirementChoices() {
}
@Override
- public String getHelpText() {
- return "Validates client based on a Client Attestation JWT and a PoP JWT which proves possession of the private key";
+ public boolean isConfigurable() {
+ return true;
}
@Override
public List getConfigProperties() {
- return List.of();
+ ProviderConfigProperty jwks = new ProviderConfigProperty();
+ jwks.setName(OAUTH_CLIENT_ATTESTATION_CONFIG_ATTESTER_JWKS);
+ jwks.setLabel("Attester JWKS");
+ jwks.setType(ProviderConfigProperty.TEXT_TYPE);
+ jwks.setHelpText("JWKS containing trusted attester public keys");
+ return List.of(jwks);
}
@Override
@@ -108,4 +185,226 @@ public Set getProtocolAuthenticatorMethods(String loginProtocol) {
return Set.of();
}
}
+
+ @JsonInclude(JsonInclude.Include.NON_NULL)
+ @JsonIgnoreProperties(ignoreUnknown = true)
+ public static class ClientAttestationJwt extends JsonWebToken {
+
+ @JsonProperty("cnf")
+ private Confirmation cnf;
+
+ public Confirmation getConfirmation() {
+ return cnf;
+ }
+
+ public void setConfirmation(Confirmation cnf) {
+ this.cnf = cnf;
+ }
+ }
+
+ @JsonInclude(JsonInclude.Include.NON_NULL)
+ @JsonIgnoreProperties(ignoreUnknown = true)
+ public static class Confirmation {
+
+ @JsonProperty("jwk")
+ private JWK jwk;
+
+ public JWK getJwk() {
+ return jwk;
+ }
+
+ public void setJwk(JWK jwk) {
+ this.jwk = jwk;
+ }
+ }
+
+ @JsonInclude(JsonInclude.Include.NON_NULL)
+ @JsonIgnoreProperties(ignoreUnknown = true)
+ public static class ClientAttestationPoPJwt extends JsonWebToken {
+
+ @JsonProperty("challenge")
+ private String challenge;
+
+ public String getChallenge() {
+ return challenge;
+ }
+
+ public void setChallenge(String challenge) {
+ this.challenge = challenge;
+ }
+ }
+
+ // Private ---------------------------------------------------------------------------------------------------------
+
+ private PublicKey loadAttesterPublicKey(ClientAuthenticationFlowContext context, String kid) throws GeneralSecurityException {
+
+ String configName = OAUTH_CLIENT_ATTESTATION_CONFIG_ATTESTER_JWKS;
+ String authenticatorConfig = Optional.ofNullable(context.getAuthenticatorConfig())
+ .map(AuthenticatorConfigModel::getConfig).orElse(Map.of()).get(configName);
+ if (authenticatorConfig == null)
+ throw new IllegalStateException("Cannot load ABCA config from: " + configName);
+
+ JsonNode attesterKeys = JsonSerialization.valueFromString(authenticatorConfig, JsonNode.class).get("keys");
+ if (attesterKeys == null || !attesterKeys.isArray())
+ throw new IllegalStateException("Cannot load Attester public keys");
+
+ for (JsonNode key : attesterKeys) {
+ String currentKid = key.get("kid").asText();
+
+ if (kid == null || kid.equals(currentKid)) {
+ String kty = key.get("kty").asText();
+
+ if (!"RSA".equals(kty)) {
+ throw new IllegalStateException("Unsupported key type: " + kty);
+ }
+
+ String n = key.get("n").asText();
+ String e = key.get("e").asText();
+
+ BigInteger modulus = new BigInteger(1, Base64Url.decode(n));
+ BigInteger exponent = new BigInteger(1, Base64Url.decode(e));
+
+ RSAPublicKeySpec spec = new RSAPublicKeySpec(modulus, exponent);
+ return KeyFactory.getInstance("RSA").generatePublic(spec);
+ }
+ }
+
+ throw new IllegalStateException("No matching key found for kid: " + kid);
+ }
+
+ // Validate the Client Attestation JWT
+ // https://www.ietf.org/archive/id/draft-ietf-oauth-attestation-based-client-auth-07.html#section-5.1
+ private ClientAttestationJwt validateClientAttestationJwt(ClientAuthenticationFlowContext context) throws Exception {
+ RealmModel realmModel = context.getSession().getContext().getRealm();
+
+ HttpRequest httpRequest = context.getHttpRequest();
+ MultivaluedMap formParams = httpRequest.getDecodedFormParameters();
+
+ HttpHeaders headers = httpRequest.getHttpHeaders();
+ String headerValue = headers.getHeaderString(OAUTH_CLIENT_ATTESTATION_HEADER);
+ if (headerValue == null)
+ throw new IllegalStateException("Required header " + OAUTH_CLIENT_ATTESTATION_HEADER + " for is missing");
+
+ JWSInput jws = new JWSInput(headerValue);
+ String jwsType = jws.getHeader().getType();
+ if (!OAUTH_CLIENT_ATTESTATION_JWT_TYPE.equals(jwsType))
+ throw new IllegalStateException("The JWS type MUST be " + OAUTH_CLIENT_ATTESTATION_JWT_TYPE + " instead of " + jwsType);
+
+ ClientAttestationJwt attesterJwt = jws.readJsonContent(ClientAttestationJwt.class);
+ ClientModel clientModel = Optional.ofNullable(attesterJwt.getSubject())
+ .map(realmModel::getClientByClientId)
+ .orElseThrow(() -> new TokenVerificationException(attesterJwt, "The sub (subject) claim MUST identify a known client_id"));
+
+ // Set the target client in the context before we attempt signature verification
+ context.setClient(clientModel);
+
+ // Define a few Client Attestation JWT checks
+ //
+
+ TokenVerifier.Predicate subCheck = (t) -> {
+ String clientIdParam = formParams.getFirst(CLIENT_ID);
+ if (Strings.isEmpty(t.getSubject()) || clientIdParam != null && !clientIdParam.equals(t.getSubject()))
+ throw new TokenVerificationException(t, "The sub claim (subject) MUST match the client_id parameter");
+ return true;
+ };
+
+ TokenVerifier.Predicate issCheck = (t) -> {
+ if (Strings.isEmpty(t.getIssuer()))
+ throw new TokenVerificationException(t, "The iss (issuer) claim MUST contains a unique identifier for the entity that issued the JWT");
+ return true;
+ };
+
+ TokenVerifier.Predicate cnfCheck = (t) -> {
+ var jwt = (ClientAttestationJwt) t;
+ if (jwt.getConfirmation() == null || jwt.getConfirmation().getJwk() == null)
+ throw new TokenVerificationException(t, "The cnf (confirmation) claim MUST specify a key that is used by the Client Instance to generate the Client Attestation PoP JWT");
+ return true;
+ };
+
+ // The signature of the Client Attestation JWT verifies with the public key of a known and trusted Attester
+ //
+ PublicKey publicKey = loadAttesterPublicKey(context, jws.getHeader().getKeyId());
+
+ // Verification and Processing
+ //
+ TokenVerifier.create(headerValue, ClientAttestationJwt.class)
+ .publicKey(publicKey)
+ .withChecks(subCheck, issCheck, cnfCheck, TokenVerifier.IS_ACTIVE)
+ .verify().getToken();
+
+ // [TODO] The alg JOSE Header Parameter for both JWTs indicates a registered asymmetric digital signature algorithm
+ // [TODO] The key contained in the cnf claim of the Client Attestation JWT is not a private key
+
+ return attesterJwt;
+ }
+
+ // Validate the Client Attestation PoP JWT
+ // https://www.ietf.org/archive/id/draft-ietf-oauth-attestation-based-client-auth-07.html#section-5.2
+ private void validateClientAttestationPoPJwt(ClientAuthenticationFlowContext context, ClientAttestationJwt attesterJwt) throws Exception {
+
+ HttpHeaders headers = context.getHttpRequest().getHttpHeaders();
+ String headerValue = headers.getHeaderString(OAUTH_CLIENT_ATTESTATION_POP_HEADER);
+ if (headerValue == null)
+ throw new IllegalStateException("Required header " + OAUTH_CLIENT_ATTESTATION_POP_HEADER + " for is missing");
+
+ JWSInput jws = new JWSInput(headerValue);
+ String jwsType = jws.getHeader().getType();
+ if (!OAUTH_CLIENT_ATTESTATION_POP_JWT_TYPE.equals(jwsType))
+ throw new IllegalStateException("The JWS type MUST be " + OAUTH_CLIENT_ATTESTATION_POP_JWT_TYPE + " instead of " + jwsType);
+
+ // Define a few Client Attestation JWT checks
+ //
+
+ TokenVerifier.Predicate jtiCheck = (t) -> {
+ if (Strings.isEmpty(t.getId()))
+ throw new TokenVerificationException(t, "The jti (JWT identifier) claim MUST specify a unique identifier for the Client Attestation PoP.");
+ return true;
+ };
+
+ TokenVerifier.Predicate iatCheck = (t) -> {
+ if (t.getIat() == 0)
+ throw new TokenVerificationException(t, "The iat (issued at) claim MUST specify the time at which the Client Attestation PoP was issued.");
+ return true;
+ };
+
+ TokenVerifier.Predicate issCheck = (t) -> {
+ if (Strings.isEmpty(t.getIssuer()) || !t.getIssuer().equals(attesterJwt.getSubject()))
+ throw new TokenVerificationException(t, "The value of the iss (issuer) claim, representing the client_id MUST match the value of the sub (subject) claim in the Client Attestation");
+ return true;
+ };
+
+ TokenVerifier.Predicate audCheck = (t) -> {
+ if (t.getAudience() == null || t.getAudience().length == 0)
+ throw new TokenVerificationException(t, "The aud (audience) claim MUST specify a value that identifies the authorization server as an intended audience.");
+ return true;
+ };
+
+ // The public key used to verify the ClientAttestationPoP JWT MUST be the key located in the "cnf" claim of the corresponding ClientAttestation JWT
+ //
+ JWK jwk = attesterJwt.getConfirmation().getJwk();
+ PublicKey publicKey = JWKParser.create()
+ .parse(JsonSerialization.valueAsString(jwk))
+ .toPublicKey();
+
+ // Verification and Processing
+ //
+ TokenVerifier.create(headerValue, ClientAttestationPoPJwt.class)
+ .publicKey(publicKey)
+ .withChecks(jtiCheck, iatCheck, issCheck, audCheck)
+ .verify().getToken();
+
+
+ // [TODO] The aud (audience) claim MUST specify a value that identifies the authorization server as an intended audience
+ // [TODO] The authorization server can utilize the jti value for replay attack detection
+ // [TODO] The authorization server may reject JWTs with an "iat" claim value that is unreasonably far in the past
+
+ // [TODO] If the server provided a challenge value to the client, the challenge claim is present in the Client Attestation PoP JWT and matches the server-provided challenge value.
+ // [TODO] Additional checks to guarantee replay protection for the Client Attestation PoP JWT might need to be applied
+ }
+
+ // Error Message specifically related to the use of client attestations
+ // [TODO] use_attestation_challenge MUST be used when the Client Attestation PoP JWT is not using an expected server-provided challenge.
+ // [TODO] use_fresh_attestation MUST be used when the Client Attestation JWT is deemed to be not fresh enough to be acceptable by the server.
+ // [TODO] invalid_client_attestation MAY be used in addition to the more general invalid_client error code as defined in [RFC6749] if the attestation or its proof of possession could not be successfully verified
+
}
diff --git a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/TokenEndpoint.java b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/TokenEndpoint.java
index 38149c00680c..36f0f51e20c1 100644
--- a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/TokenEndpoint.java
+++ b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/TokenEndpoint.java
@@ -76,7 +76,7 @@
*/
public class TokenEndpoint {
- private static final Logger logger = Logger.getLogger(TokenEndpoint.class);
+ private static final Logger LOGGER = Logger.getLogger(TokenEndpoint.class);
private MultivaluedMap formParams;
private ClientModel client;
private Map clientAuthAttributes;
@@ -171,8 +171,8 @@ public Object introspect() {
@OPTIONS
public Response preflight() {
- if (logger.isDebugEnabled()) {
- logger.debugv("CORS preflight from: {0}", headers.getRequestHeaders().getFirst("Origin"));
+ if (LOGGER.isDebugEnabled()) {
+ LOGGER.debugv("CORS preflight from: {0}", headers.getRequestHeaders().getFirst("Origin"));
}
return Cors.builder().auth().preflight().allowedMethods("POST", "OPTIONS").add(Response.ok());
}
@@ -244,7 +244,7 @@ protected void checkParameters() {
.reduce(0, Integer::sum);
int maxLength = config.getMaxLengthForTheParameter(paramName);
if (totalLengthOfParamValues > maxLength) {
- logger.warnf("The size of OIDC parameter '%s' is longer (%d) than allowed (%d). %s", paramName, totalLengthOfParamValues, maxLength, config.isAdditionalReqParamsFailFast() ? "Request not allowed." : "Ignoring the parameter.");
+ LOGGER.warnf("The size of OIDC parameter '%s' is longer (%d) than allowed (%d). %s", paramName, totalLengthOfParamValues, maxLength, config.isAdditionalReqParamsFailFast() ? "Request not allowed." : "Ignoring the parameter.");
if (config.isAdditionalReqParamsFailFast()) {
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_REQUEST, "The size of OIDC parameter '" + paramName + "' is longer than allowed.",
Response.Status.BAD_REQUEST);
diff --git a/services/src/main/java/org/keycloak/services/util/DPoPUtil.java b/services/src/main/java/org/keycloak/services/util/DPoPUtil.java
index 67bf60720f4f..3f77289a98b2 100644
--- a/services/src/main/java/org/keycloak/services/util/DPoPUtil.java
+++ b/services/src/main/java/org/keycloak/services/util/DPoPUtil.java
@@ -213,7 +213,7 @@ private static DPoP validateDPoP(KeycloakSession session, URI uri, String method
verifier.verifierContext(signatureVerifier);
verifier.withChecks(
DPoPClaimsCheck.INSTANCE,
- new DPoPHTTPCheck(uri, method),
+ new DPoPHTTPCheck(session, uri, method),
new DPoPIsActiveCheck(session, lifetime, clockSkew),
new DPoPReplayCheck(session, lifetime + clockSkew));
@@ -354,10 +354,12 @@ public static List getDPoPSupportedAlgorithms(KeycloakSession session) {
private static class DPoPHTTPCheck implements TokenVerifier.Predicate {
+ private final KeycloakSession session;
private final URI uri;
private final String method;
- DPoPHTTPCheck(URI uri, String method) {
+ DPoPHTTPCheck(KeycloakSession session, URI uri, String method) {
+ this.session = session;
this.uri = uri;
this.method = method;
}
@@ -365,8 +367,20 @@ private static class DPoPHTTPCheck implements TokenVerifier.Predicate {
@Override
public boolean test(DPoP t) throws DPoPVerificationException {
try {
- if (!normalize(new URI(t.getHttpUri())).equals(normalize(uri)))
- throw new DPoPVerificationException(t, "DPoP HTTP URL mismatch");
+ URI dpopUri = new URI(t.getHttpUri());
+ if (!normalize(dpopUri).equals(normalize(uri))) {
+ // When Keycloak runs behind a reverse proxy, it may not be possible to reconstruct
+ // the expected uri from the request alone - port information may get lost.
+ // We also accept a DPoP Uri that matches the configured KC_HOSTNAME
+ UriInfo uriInfo = session.getContext().getHttpRequest().getUri();
+ URI expectedUri = session.getContext()
+ .getUri()
+ .getBaseUriBuilder()
+ .replacePath(uriInfo.getPath())
+ .build();
+ if (!normalize(dpopUri).equals(normalize(expectedUri)))
+ throw new DPoPVerificationException(t, "DPoP HTTP URL mismatch");
+ }
if (!method.equals(t.getHttpMethod()))
throw new DPoPVerificationException(t, "DPoP HTTP method mismatch");
From ce3194009c9822672a8a03676face8df14f8c841 Mon Sep 17 00:00:00 2001
From: Thomas Diesler
Date: Mon, 23 Mar 2026 10:17:08 +0100
Subject: [PATCH 2/2] [OID4VCI-HAIP] Pass oid4vci-1_0-issuer-happy-flow
Signed-off-by: Thomas Diesler
---
.../issuance/OID4VCAuthorizationDetailsProcessor.java | 5 ++++-
.../protocol/oid4vc/issuance/OID4VCIssuerEndpoint.java | 6 +++---
2 files changed, 7 insertions(+), 4 deletions(-)
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 85cd79d5b5c1..eec186736c5d 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);