Last active
September 29, 2025 20:41
-
-
Save thomasdarimont/b6113c9b3477ec00b16f343e17acac23 to your computer and use it in GitHub Desktop.
Attestation based Client Authentication Test-Client
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | |
) { | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?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