Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions core/src/main/java/org/keycloak/OAuthErrorException.java
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
17 changes: 13 additions & 4 deletions core/src/main/java/org/keycloak/TokenVerifier.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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");
Expand All @@ -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");
Expand Down
162 changes: 162 additions & 0 deletions core/src/main/java/org/keycloak/jose/jws/crypto/ECDSAProvider.java
Original file line number Diff line number Diff line change
@@ -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 <a href="mailto:[email protected]">Thomas Diesler</a>
*/
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);
Comment on lines +69 to +74
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This code is very critical. We never know what slight error may be overlooked in the implementation of transcodeSignatureToDER below. I think it is preferable you reuse the ECDSASignatureVerifierContext class or at least the methods it reuses.

public boolean verify(byte[] data, byte[] signature) throws VerificationException {
try {
int expectedSize = ECDSAAlgorithm.getSignatureLength(getAlgorithm());
byte[] derSignature = ECDSAAlgorithm.concatenatedRSToASN1DER(signature, expectedSize);
return super.verify(data, derSignature);
} catch (Exception e) {

} 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]);
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}

Expand All @@ -66,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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,13 +40,13 @@ public enum AuthenticationFlowError {
CLIENT_DISABLED,
CLIENT_CREDENTIALS_SETUP_REQUIRED,
INVALID_CLIENT_CREDENTIALS,
INVALID_CLIENT_ATTESTATION,

IDENTITY_PROVIDER_NOT_FOUND,
IDENTITY_PROVIDER_DISABLED,
IDENTITY_PROVIDER_ERROR,
DISPLAY_NOT_SUPPORTED,

ACCESS_DENIED,
UNAUTHORIZED_CLIENT,
GENERIC_AUTHENTICATION_ERROR
}
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand All @@ -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);
Expand Down
Loading
Loading