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