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.

Revisions

  1. thomasdarimont revised this gist Sep 29, 2025. 1 changed file with 1 addition and 0 deletions.
    1 change: 1 addition & 0 deletions KeycloakAtbcaApplication.java
    Original 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();

  2. thomasdarimont revised this gist Sep 29, 2025. 1 changed file with 60 additions and 0 deletions.
    60 changes: 60 additions & 0 deletions pom.xml
    Original 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>
  3. thomasdarimont revised this gist Sep 29, 2025. No changes.
  4. thomasdarimont created this gist Sep 29, 2025.
    302 changes: 302 additions & 0 deletions KeycloakAtbcaApplication.java
    Original 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
    ) {
    }

    }