Skip to content

Instantly share code, notes, and snippets.

@thomasdarimont
Last active September 29, 2025 20:41
Show Gist options
  • Save thomasdarimont/b6113c9b3477ec00b16f343e17acac23 to your computer and use it in GitHub Desktop.
Save thomasdarimont/b6113c9b3477ec00b16f343e17acac23 to your computer and use it in GitHub Desktop.
Attestation based Client Authentication Test-Client
package com.thomasdarimont.keycloak.labs.keycloakatbca;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.nimbusds.jose.Algorithm;
import com.nimbusds.jose.JOSEException;
import com.nimbusds.jose.JOSEObjectType;
import com.nimbusds.jose.JWSAlgorithm;
import com.nimbusds.jose.JWSHeader;
import com.nimbusds.jose.JWSSigner;
import com.nimbusds.jose.JWSVerifier;
import com.nimbusds.jose.crypto.Ed25519Verifier;
import com.nimbusds.jose.crypto.factories.DefaultJWSSignerFactory;
import com.nimbusds.jose.crypto.factories.DefaultJWSVerifierFactory;
import com.nimbusds.jose.jwk.AsymmetricJWK;
import com.nimbusds.jose.jwk.Curve;
import com.nimbusds.jose.jwk.JWK;
import com.nimbusds.jose.jwk.JWKSet;
import com.nimbusds.jose.jwk.OctetKeyPair;
import com.nimbusds.jose.jwk.SecretJWK;
import com.nimbusds.jose.proc.JWSVerifierFactory;
import com.nimbusds.jose.produce.JWSSignerFactory;
import com.nimbusds.jwt.JWTClaimsSet;
import com.nimbusds.jwt.SignedJWT;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.WebApplicationType;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.http.HttpRequest;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.http.client.ClientHttpRequestExecution;
import org.springframework.http.client.ClientHttpRequestInterceptor;
import org.springframework.http.client.ClientHttpResponse;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.web.client.RestClient;
import java.io.IOException;
import java.security.KeyPair;
import java.text.ParseException;
import java.time.Instant;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
@SpringBootApplication
public class KeycloakAtbcaApplication {
public static void main(String[] args) {
new SpringApplicationBuilder(KeycloakAtbcaApplication.class)
.web(WebApplicationType.NONE) // .REACTIVE, .SERVLET
.run(args);
}
@Bean
public CommandLineRunner commandLineRunner(ApplicationContext ctx) {
return args -> {
System.out.println("Running Keycloak Atbca");
String attestationIssuer = "https://client-attester.issuer.test";
String issuer = "https://localhost:18443/realms/abca-demo";
String clientId = "abca-client";
RestClient client = RestClient.builder().requestInterceptor(new ClientHttpRequestInterceptor() {
@Override
public ClientHttpResponse intercept(HttpRequest request, byte[] body, ClientHttpRequestExecution execution) throws IOException {
System.out.printf("%s %s %s%n", request.getMethod(), request.getURI(), "HTTP/1.1");
for(var header : request.getHeaders().entrySet()) {
System.out.println(header.getKey() + ": " + request.getHeaders().getFirst(header.getKey()));
}
System.out.println();
if (body != null) {
System.out.println(new String(body));
}
return execution.execute(request, body);
}
}).build();
ResponseEntity<AttestationChallengeResponse> challengeResponse = client.post().uri(issuer + "/protocol/openid-connect/challenge").retrieve().toEntity(AttestationChallengeResponse.class);
String attestationChallenge = challengeResponse.getBody().attestationChallenge();
// https://mkjwk.org/ EC, P-256, Signature, ES256: ECDSA
JWKSet clientAttesterKeysJwks = JWKSet.parse("""
{
"keys": [
{
"kty": "EC",
"d": "OEHe87WBkwhMLrjWDvqfFXWalpdh2yFatFjt9OB8W1U",
"use": "sig",
"crv": "P-256",
"kid": "client-attester-key-1",
"x": "Worwep-tsHxdE3b4EKztjReDNwn_VlGqFWUa2lFZW-8",
"y": "cTZspK4U0KdkvDBcaJ3Gkn4uRwA_UYMYXM4NlWUDQCI",
"alg": "ES256",
"x5c": [
"MIIBMTCB2KADAgECAgYBmYiFPYkwCgYIKoZIzj0EAwIwIDEeMBwGA1UEAwwVY2xpZW50LWF0dGVzdGVyLWtleS0xMB4XDTI1MDkyNzAwMTQxN1oXDTI2MDcyNDAwMTQxN1owIDEeMBwGA1UEAwwVY2xpZW50LWF0dGVzdGVyLWtleS0xMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEWorwep+tsHxdE3b4EKztjReDNwn/VlGqFWUa2lFZW+9xNmykrhTQp2S8MFxoncaSfi5HAD9Rgxhczg2VZQNAIjAKBggqhkjOPQQDAgNIADBFAiEA+1J2yCQye9pZ1QU39HnnjAlcSo/RE3O/nKhjRLf7nnYCIAdbcDxB3PFY58JqwMOiAE/yQMEwm3Cp0RNki1DMaOzc"
]
}
]
}
""");
String clientAttesterKeysPublicKeyPEM = """
"-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEWorwep+tsHxdE3b4EKztjReDNwn/
VlGqFWUa2lFZW+9xNmykrhTQp2S8MFxoncaSfi5HAD9Rgxhczg2VZQNAIg==
-----END PUBLIC KEY-----"
""";
String clientAttesterKeysPrivateKeyPEM = """
"-----BEGIN PRIVATE KEY-----
MEECAQAwEwYHKoZIzj0CAQYIKoZIzj0DAQcEJzAlAgEBBCA4Qd7ztYGTCEwuuNYO
+p8VdZqWl2HbIVq0WO304HxbVQ==
-----END PRIVATE KEY-----"
""";
String clientAttesterSelfSignedCert = """
-----BEGIN CERTIFICATE-----
MIIBMTCB2KADAgECAgYBmYiFPYkwCgYIKoZIzj0EAwIwIDEeMBwGA1UEAwwVY2xp
ZW50LWF0dGVzdGVyLWtleS0xMB4XDTI1MDkyNzAwMTQxN1oXDTI2MDcyNDAwMTQx
N1owIDEeMBwGA1UEAwwVY2xpZW50LWF0dGVzdGVyLWtleS0xMFkwEwYHKoZIzj0C
AQYIKoZIzj0DAQcDQgAEWorwep+tsHxdE3b4EKztjReDNwn/VlGqFWUa2lFZW+9x
NmykrhTQp2S8MFxoncaSfi5HAD9Rgxhczg2VZQNAIjAKBggqhkjOPQQDAgNIADBF
AiEA+1J2yCQye9pZ1QU39HnnjAlcSo/RE3O/nKhjRLf7nnYCIAdbcDxB3PFY58Jq
wMOiAE/yQMEwm3Cp0RNki1DMaOzc
-----END CERTIFICATE-----
""";
JWKSet clientInstanceJwks = JWKSet.parse("""
{
"keys": [
{
"kty": "EC",
"d": "MZuFA9RwcmNPT1T3cgKVWpftiYRtFWzmXHQkmjKTclI",
"use": "sig",
"crv": "P-256",
"kid": "client-instance-key-1",
"x": "0fCT-Uq-QCk_HO1pAd8-k1vyUNOI22RqSPZYQreyqTI",
"y": "xB4gZYqTfVkcgFzNE3StdjPjAZYb94IuUIyvbN9SQJ8",
"alg": "ES256"
}
]
}
""");
JWK clientInstanceKey = clientInstanceJwks.getKeyByKeyId("client-instance-key-1");
JWK clientInstancePublicKey = clientInstanceKey.toPublicJWK();
JWK clientAttesterSigningKey = clientAttesterKeysJwks.getKeyByKeyId("client-attester-key-1");
// include type, include X5c, includeX5tS256:false
String clientAttestationJwt = generateClientAttestationJwt(attestationIssuer, clientId, clientInstancePublicKey, clientAttesterSigningKey);
System.out.println(clientAttestationJwt);
String clientAttestationJwtProof = generateClientAttestationJwtProof(attestationIssuer, clientId, clientInstanceKey, attestationChallenge);
System.out.println(clientAttestationJwtProof);
// OAuth-Client-Attestation: ...
String oauthClientAttestation = clientAttestationJwt;
// OAuth-Client-Attestation-PoP: ...
String oauthClientAttestationPoP = clientAttestationJwtProof;
SignedJWT oauthClientAttestationJwt = SignedJWT.parse(oauthClientAttestation);
SignedJWT oauthClientAttestationPoPJwt = SignedJWT.parse(oauthClientAttestationPoP);
JWK cnfJwk = JWK.parse((Map<String, Object>) oauthClientAttestationJwt.getJWTClaimsSet().getJSONObjectClaim("cnf").get("jwk"));
JWKSet cnfJwks = new JWKSet(cnfJwk);
JWSHeader header = oauthClientAttestationJwt.getHeader();
boolean valid = verifySignature(oauthClientAttestationPoPJwt, cnfJwks);
System.out.println(valid);
var formData = new LinkedMultiValueMap<String, String>();
formData.add("grant_type", "password");
formData.add("client_id", clientId);
formData.add("username", "tester");
formData.add("password", "test");
ResponseEntity<Map> tokenResponse = client.post().uri(issuer+ "/protocol/openid-connect/token")
.body(formData)
.header("OAuth-Client-Attestation", oauthClientAttestation)
.header("OAuth-Client-Attestation-PoP", clientAttestationJwtProof)
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.retrieve()
.toEntity(Map.class);
Map tokenResponseBody = tokenResponse.getBody();
System.out.println(tokenResponseBody);
};
}
protected boolean verifySignature(SignedJWT jwt, JWKSet jwkSet) throws JOSEException {
JWSVerifierFactory factory = new DefaultJWSVerifierFactory();
for (JWK jwkKey : jwkSet.getKeys()) {
JWSVerifier verifier = null;
try {
if (jwkKey instanceof OctetKeyPair) {
OctetKeyPair publicKey = OctetKeyPair.parse(jwkKey.toPublicJWK().toString());
if (Curve.Ed25519.equals(publicKey.getCurve())) {
verifier = new Ed25519Verifier(publicKey);
} // else Unsupported Curve, throw exception?
} else if (jwkKey instanceof AsymmetricJWK asyncJwkKey) {
KeyPair keyPair = asyncJwkKey.toKeyPair();
verifier = factory.createJWSVerifier(jwt.getHeader(), keyPair.getPublic());
} else if (jwkKey instanceof SecretJWK secretJwkKey) {
verifier = factory.createJWSVerifier(jwt.getHeader(), secretJwkKey.toSecretKey());
}
} catch (JOSEException | ParseException e) {
}
if (verifier != null) {
if (jwt.verify(verifier)) {
return true;
} else {
// failed to verify with this key, moving on
// not a failure yet as it might pass a different key
}
}
}
// if we got here, it hasn't been verified on any key
return false;
}
protected String generateClientAttestationJwtProof(String issuer, String clientId, JWK clientInstanceKey, String nonce) throws ParseException, JOSEException {
var claims = new HashMap<String, Object>();
claims.put("iss", clientId);
Instant iat = Instant.now();
Instant exp = iat.plusSeconds(5 * 60);
claims.put("iat", iat.getEpochSecond());
claims.put("nbf", iat.getEpochSecond());
claims.put("exp", exp.getEpochSecond());
claims.put("aud", issuer);
claims.put("jti", UUID.randomUUID().toString());
// TODO add support for nonce retrieval https://datatracker.ietf.org/doc/html/draft-ietf-oauth-attestation-based-client-auth-07#section-8
claims.put("nonce", nonce);
Algorithm algorithm = clientInstanceKey.getAlgorithm();
JWSAlgorithm alg = JWSAlgorithm.parse(algorithm.getName());
JWSHeader.Builder builder = new JWSHeader.Builder(alg);
builder.type(new JOSEObjectType("oauth-client-attestation-pop+jwt"));
JWSHeader header = builder.build();
JWSSignerFactory jwsSignerFactory = new DefaultJWSSignerFactory();
JWSSigner signer = jwsSignerFactory.createJWSSigner(clientInstanceKey, alg);
JWTClaimsSet claimSet = JWTClaimsSet.parse(claims);
String clientAttestationProofJwt = signJwt(header, claimSet, signer);
return clientAttestationProofJwt;
}
protected String generateClientAttestationJwt(String issuer, String clientId, JWK clientInstancePublicKey, JWK clientAttesterSigningKey) throws ParseException, JOSEException {
var claims = new HashMap<String, Object>();
claims.put("iss", issuer);
claims.put("sub", clientId);
Instant iat = Instant.now();
Instant exp = iat.plusSeconds(5 * 60);
claims.put("iat", iat.getEpochSecond());
claims.put("nbf", iat.getEpochSecond());
claims.put("exp", exp.getEpochSecond());
var cnf = new HashMap<String, Object>();
cnf.put("jwk", clientInstancePublicKey.toJSONObject());
claims.put("cnf", cnf);
JWTClaimsSet claimSet = JWTClaimsSet.parse(claims);
Algorithm algorithm = clientAttesterSigningKey.getAlgorithm();
JWSAlgorithm alg = JWSAlgorithm.parse(algorithm.getName());
JWSSignerFactory jwsSignerFactory = new DefaultJWSSignerFactory();
JWSSigner signer = jwsSignerFactory.createJWSSigner(clientAttesterSigningKey, alg);
JWSHeader.Builder builder = new JWSHeader.Builder(alg);
builder.type(new JOSEObjectType("oauth-client-attestation+jwt"));
builder.x509CertChain(clientAttesterSigningKey.getX509CertChain());
builder.keyID(clientAttesterSigningKey.getKeyID());
JWSHeader header = builder.build();
String clientAttestationJwt = signJwt(header, claimSet, signer);
return clientAttestationJwt;
}
protected String signJwt(JWSHeader header, JWTClaimsSet claims, JWSSigner signer) throws JOSEException, ParseException {
SignedJWT signJWT = new SignedJWT(header, claims);
signJWT.sign(signer);
return signJWT.serialize();
}
record AttestationChallengeResponse(
@JsonProperty("attestation_challenge")
String attestationChallenge
) {
}
}
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.5.6</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.thomasdarimont.keycloak.labs</groupId>
<artifactId>keycloak-atbca</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>keycloak-atbca</name>
<description>keycloak-atbca</description>
<url/>
<licenses>
<license/>
</licenses>
<developers>
<developer/>
</developers>
<scm>
<connection/>
<developerConnection/>
<tag/>
<url/>
</scm>
<properties>
<java.version>21</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>com.nimbusds</groupId>
<artifactId>nimbus-jose-jwt</artifactId>
<version>10.4.2</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment