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