Last active
September 29, 2025 20:41
-
-
Save thomasdarimont/b6113c9b3477ec00b16f343e17acac23 to your computer and use it in GitHub Desktop.
Revisions
-
thomasdarimont revised this gist
Sep 29, 2025 . 1 changed file with 1 addition and 0 deletions.There are no files selected for viewing
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 charactersOriginal file line number Diff line number Diff line change @@ -77,6 +77,7 @@ public ClientHttpResponse intercept(HttpRequest request, byte[] body, ClientHttp 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(); -
thomasdarimont revised this gist
Sep 29, 2025 . 1 changed file with 60 additions and 0 deletions.There are no files selected for viewing
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 charactersOriginal file line number Diff line number Diff line change @@ -0,0 +1,60 @@ <?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> -
thomasdarimont revised this gist
Sep 29, 2025 . No changes.There are no files selected for viewing
-
thomasdarimont created this gist
Sep 29, 2025 .There are no files selected for viewing
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 charactersOriginal file line number Diff line number Diff line change @@ -0,0 +1,302 @@ 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 ) { } }