From 0ffbdf87de84b24ccaa25ed6879990f9098b88e7 Mon Sep 17 00:00:00 2001 From: Thomas Diesler Date: Fri, 10 Apr 2026 18:23:52 +0200 Subject: [PATCH 1/3] 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 8e94e04ef9fcc9e8bca31f47570a0c7586be4105 Mon Sep 17 00:00:00 2001 From: Thomas Diesler Date: Sat, 11 Apr 2026 07:50:34 +0200 Subject: [PATCH 2/3] -- address review comments from @IngridPuppet Signed-off-by: Thomas Diesler --- .../AttestationBasedClientAuthenticator.java | 74 ++++++++----------- .../OID4VCAuthorizationDetailsProcessor.java | 5 +- .../org/keycloak/services/util/DPoPUtil.java | 2 +- 3 files changed, 36 insertions(+), 45 deletions(-) diff --git a/services/src/main/java/org/keycloak/authentication/authenticators/client/AttestationBasedClientAuthenticator.java b/services/src/main/java/org/keycloak/authentication/authenticators/client/AttestationBasedClientAuthenticator.java index ac1b1a3c0cc3..009670b07ebd 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,11 +18,7 @@ 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; @@ -38,9 +34,7 @@ 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; @@ -60,7 +54,6 @@ 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; @@ -236,36 +229,25 @@ public void setChallenge(String challenge) { // Private --------------------------------------------------------------------------------------------------------- - private PublicKey loadAttesterPublicKey(ClientAuthenticationFlowContext context, String kid) throws GeneralSecurityException { + private PublicKey findAttesterPublicKey(ClientAuthenticationFlowContext context, String kid) { + + if (Strings.isEmpty(kid)) + throw new IllegalArgumentException("Invalid attester kid: " + kid); String configName = OAUTH_CLIENT_ATTESTATION_CONFIG_ATTESTER_JWKS; - String authenticatorConfig = Optional.ofNullable(context.getAuthenticatorConfig()) + String abcaConfigValue = Optional.ofNullable(context.getAuthenticatorConfig()) .map(AuthenticatorConfigModel::getConfig).orElse(Map.of()).get(configName); - if (authenticatorConfig == null) + if (abcaConfigValue == 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(); - + ABCAConfig attesterKeys = JsonSerialization.valueFromString(abcaConfigValue, ABCAConfig.class); + for (JWK key : attesterKeys.keys) { + if (kid.equals(key.getKeyId())) { + String kty = key.getKeyType(); 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); + return new JWKParser(key).toPublicKey(); } } @@ -277,19 +259,18 @@ private PublicKey loadAttesterPublicKey(ClientAuthenticationFlowContext context, private ClientAttestationJwt validateClientAttestationJwt(ClientAuthenticationFlowContext context) throws Exception { RealmModel realmModel = context.getSession().getContext().getRealm(); - HttpRequest httpRequest = context.getHttpRequest(); - MultivaluedMap formParams = httpRequest.getDecodedFormParameters(); + HttpHeaders headers = context.getHttpRequest().getHttpHeaders(); + String headerValue = Optional.ofNullable(headers.getHeaderString(OAUTH_CLIENT_ATTESTATION_HEADER)) + .orElseThrow(() -> new IllegalArgumentException("Required header " + OAUTH_CLIENT_ATTESTATION_HEADER + " is missing")); - 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"); + MultivaluedMap formParams = context.getHttpRequest().getDecodedFormParameters(); 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); + throw new IllegalArgumentException("The JWS type MUST be " + OAUTH_CLIENT_ATTESTATION_JWT_TYPE + " instead of " + jwsType); + // Get the client model from the JWT subject ClientAttestationJwt attesterJwt = jws.readJsonContent(ClientAttestationJwt.class); ClientModel clientModel = Optional.ofNullable(attesterJwt.getSubject()) .map(realmModel::getClientByClientId) @@ -303,14 +284,14 @@ private ClientAttestationJwt validateClientAttestationJwt(ClientAuthenticationFl TokenVerifier.Predicate subCheck = (t) -> { String clientIdParam = formParams.getFirst(CLIENT_ID); - if (Strings.isEmpty(t.getSubject()) || clientIdParam != null && !clientIdParam.equals(t.getSubject())) + if (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"); + throw new TokenVerificationException(t, "The iss (issuer) claim MUST contain a unique identifier for the entity that issued the JWT"); return true; }; @@ -323,7 +304,7 @@ private ClientAttestationJwt validateClientAttestationJwt(ClientAuthenticationFl // 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()); + PublicKey publicKey = findAttesterPublicKey(context, jws.getHeader().getKeyId()); // Verification and Processing // @@ -343,14 +324,13 @@ private ClientAttestationJwt validateClientAttestationJwt(ClientAuthenticationFl 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"); + String headerValue = Optional.ofNullable(headers.getHeaderString(OAUTH_CLIENT_ATTESTATION_POP_HEADER)) + .orElseThrow(() -> new IllegalArgumentException("Required header " + OAUTH_CLIENT_ATTESTATION_POP_HEADER + " 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); + throw new IllegalArgumentException("The JWS type MUST be " + OAUTH_CLIENT_ATTESTATION_POP_JWT_TYPE + " instead of " + jwsType); // Define a few Client Attestation JWT checks // @@ -407,4 +387,12 @@ private void validateClientAttestationPoPJwt(ClientAuthenticationFlowContext con // [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 + /** + * The AttestationBasedClientAuthenticator config + */ + static class ABCAConfig { + + @JsonProperty + List keys; + } } 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..0bc194a070f8 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.debugf("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/services/util/DPoPUtil.java b/services/src/main/java/org/keycloak/services/util/DPoPUtil.java index 3f77289a98b2..5c53643fe2c6 100644 --- a/services/src/main/java/org/keycloak/services/util/DPoPUtil.java +++ b/services/src/main/java/org/keycloak/services/util/DPoPUtil.java @@ -354,9 +354,9 @@ 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; + private final KeycloakSession session; DPoPHTTPCheck(KeycloakSession session, URI uri, String method) { this.session = session; From af23bb59362f2ae58d342c55fefc263de1e3b759 Mon Sep 17 00:00:00 2001 From: Thomas Diesler Date: Mon, 30 Mar 2026 16:11:28 +0200 Subject: [PATCH 3/3] [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 +- 3 files changed, 176 insertions(+), 5 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) {