Skip to content
Merged
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
13 changes: 11 additions & 2 deletions core/src/main/java/org/keycloak/TokenVerifier.java
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,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 @@ -440,10 +441,10 @@ public void verifySignature() throws VerificationException {
}
} else {
AlgorithmType algorithmType = getHeader().getAlgorithm().getType();

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
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
/*
* 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.nio.charset.StandardCharsets;
import java.security.PublicKey;
import java.security.cert.X509Certificate;

import org.keycloak.common.util.PemUtils;
import org.keycloak.crypto.ECDSASignatureVerifierContext;
import org.keycloak.crypto.KeyUse;
import org.keycloak.crypto.KeyWrapper;
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 boolean verify(JWSInput jws, PublicKey publicKey) {
String alg = jws.getHeader().getAlgorithm().name();
try {
KeyWrapper kw = new KeyWrapper();
kw.setPublicKey(publicKey);
kw.setUse(KeyUse.SIG);
kw.setType("EC");
kw.setAlgorithm(alg);
byte[] data = jws.getEncodedSignatureInput().getBytes(StandardCharsets.UTF_8);
byte[] signature = jws.getSignature();
return new ECDSASignatureVerifierContext(kw).verify(data, signature);
} catch (Exception e) {
return false;
}

}

@Override
public boolean verify(JWSInput input, String cert) {
return verifyViaCertificate(input, cert);
}

// Private ---------------------------------------------------------------------------------------------------------

private 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());
}
}
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 @@ -152,6 +152,17 @@ public JsonWebToken issuedNow() {
return this;
}

/**
* Set issuedAt to the current time and expireAt = issuedAt + ttl
* Also set notBefore to issuedAt
*/
@JsonIgnore
public JsonWebToken issuedNowWithTTL(int ttl) {
iat = nbf = (long) Time.currentTime();
exp = iat + ttl;
return this;
}

public JsonWebToken iat(Long iat) {
this.iat = iat;
return this;
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)) {
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.

I wonder if this is really needed? I assume that if client is authenticating with ABCA, it should be always confidential client? Is it possible to make sure that only confidential clients authenticate with ABCA?

Public clients are not supposed to authenticate with any client authentication methods. They usually use just client_id parameter to "identify" themselves in the request andfor this purpose, we just use expectedClientAuthType set to KeycloakModelUtils.getDefaultClientAuthenticatorType() .

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

There was indeed a change in the spec from “mechanism for public clients to authenticate” to “general client authentication mechanism”. The draft decoupled itself from OAuth’s client-type distinction entirely - it effectively introduces a third model: attested clients (orthogonal to public/confidential).

This shift aligns with the underlying idea: attestation provides hardware/software-backed proof which can substitute:

  • secrets (confidential clients)
  • or lack thereof (public clients)

Without this change, the AttestationBasedClientAuthenticator is not called for confidential clients. I added a test thereof.

Note, an invalid abca request or missing attester pub key currently fails in the AttestationBasedClientAuthenticator but is silently ignored because some other ClientAuthenticator succeeds - this might be a TODO

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.

Yes, more details would be good. Maybe we can discuss on one of our next meetings?

Just to doublecheck: Is it this specification draft, which you talk about https://datatracker.ietf.org/doc/html/draft-ietf-oauth-attestation-based-client-auth-08 ?

This change is OK in this PR, but we possibly need to figure this as a follow-up to avoid directly referencing the concrete authenticator from ClientAuthenticationFlow .

Copy link
Copy Markdown
Contributor Author

@tdiesler tdiesler Apr 17, 2026

Choose a reason for hiding this comment

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

It is actually https://www.ietf.org/archive/id/draft-ietf-oauth-attestation-based-client-auth-07.html
which I have bookmarked. I could however not find a reference to it in https://openid.net/specs/openid4vc-high-assurance-interoperability-profile-1_0-final.html @thomasdarimont could you perhaps confirm the exact abca spec version that we ought to use?

expectedClientAuthType = abcaAuthType;
}

// Check if client authentication matches
if (factory.getId().equals(expectedClientAuthType)) {
Response response = processResult(context);
Expand Down
Loading
Loading