Test JUnit Avec Un Mock De Keycloak

J’ai récemment dû implémenter une bibliothèque permettant de valider un JWT dans le cadre d’une authentification OIDC avec Keycloak. Afin de tester que ma bibliothèque fonctionnait correctement au niveau de la partie validation du token, j’ai dû générer un token signé. Ma bibliothèque utilise la bibliothèque de Keycloak pour la vérification. Celle de keycloak fait appel au well-known pour récupérer les informations sur la clé RSA qui à servi à signer les tokens. Voyons un peu comment faire tout cela sans avoir à démarrer et à configurer un Keycloak entier juste pour des tests.

Mock de keycloak

Pour démarrer, nous allons devoir mock l’appel au endpoint well-known de Keycloak réalisé par la bibliothèque. Pour cela, nous allons utiliser MockServer. Commençons par ajouter la dépendance à notre pom.xml.

1
2
3
4
5
6
<dependency>
    <groupId>org.mock-server</groupId>
    <artifactID>mockserver-netty</artifactId>
    <version>5.15.0</version>
    <scope>test</scope>
</dependency>

Une fois, ceci fait, on va configurer le mock server pour exposer un endpoint well-known à la place de keycloak.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
Class OIDCTest {

    private static ClientAndServer mockServer;
    private static final int KEYCLOAK_PORT=8080

    @BeforeAll
    public static void beforeAll() {
        mockServer = ClientAndServer.startClientAndServer(KEYCLOAK_PORT);
        MockServerClient mockServerClient = new MockServerClient("localhost", KEYCLOAK_PORT);
        mockServerClient.when(HttpRequest.request()
                .withMethod("GET")
                .withPath("/realms/exemple/.well-known/openid-configuration")
                .withHeader("accept", "application/json"))
            .respond(HttpResponse.response()
                .withStatusCode(200)
                .withBody("{ \"jwks_uri\": \"http://localhost:8080/realms/example/protocol/openid-connect/certs\","
                + "\"authorization_endpoint\": \"http://localhost:8080/realms/example/protocol/openid-connect/auth\","
                + "\"issuer\": \"http://localhost:8080/realms/example\","
                + "\"token_endpoint\": \"http://localhost:8080/realms/example/protocol/openid-connect/token\","
                + "\"userinfo_endpoint\": \"http://localhost:8080/realms/example/protocol/openid-connect/userinfo\","
                + "\"end_session_endpoint\": \"http://localhost:8080/realms/example/protocol/openid-connect/logout\""
                + "}"));
    }

    @AfterAll
    public static void afterAll() {
        mockServer.stop();
    }
}

On commence par instancier le mock server sur le port utilisé par défaut par Keycloak. Ensuite, nous utilisons le client de ce mock pour configurer le serveur. En cas de requète sur l’endpoint well-known de Keycloak, nous demandons au mock de répondre avec un JSON contenant les principaux endpoints de la norme OIDC. Même si au final nous n’utilisons que l’endpoint jwks_uri, la bibliothèque Keycloak vérifie l’existence des autres endpoints.

Il nous faut maintenant mock l’appel à l’endpoint jwks_uri afin de permettre à la bibliothèque de Keycloak de récupérer les informations de la signature utilisé pour les tokens. Pour cela on ajouter dans le beforeAll:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16

    RSAKey rsaJWK = getRsaKey();

    X509Certificate cert = getX509Certificate(rsaJWK);

    getPrivateKey(rsaJWK, cert);

    JWK jwk = getJwk(rsaJWK, cert);

    mockServerClient
        .when(HttpRequest.request()
            .withMethod("GET")
            .withPath("/realms/example/protocol/openid-connect/certs"))
        .respond(HttpResponse.response()
            .withStatusCode(200)
            .withBody(generateKCCertsJson(jwk.toJSONString())));

Génération du certificat et des clés

Voyons maintenant comment sont implémenter les méthodes getRsaKey(), getX509Certificate(rsaJWK), getPrivateKey(rsaJWK, cert) et getJwk(rsaJWK, cert). Pour cela, nous allons utiliser la bibliothèque nimbus-jose :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
<dependency>
    <groupId>com.nimbusds</groupId>
    <artifactID>nimbus-jose-jwt</artifactId>
    <version>9.39.1</version>
    <scope>test</scope>
</dependency>

<dependency>
    <groupId>com.nimbusds</groupId>
    <artifactID>oauth2-oidc-sdk</artifactId>
    <version>11.12</version>
    <scope>test</scope>
</dependency>
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50

    /**
   * Create a JWK with a random ID
   *
   * @return The new JSON Web Key with 2048 RSA key
   * @throws JOSEException Something wrong happen during RSA key generation
   */
  private static RSAKey getRsaKey() throws JOSEException {

    return new RSAKeyGenerator(2048)
        .keyID(OIDCFilterTest.KEY_ID)
        .keyUse(KeyUse.SIGNATURE)
        .generate();
  }

  /**
   * Generate a self signed certificate
   * @param rsaJWK The Json Web Key
   * @return A self-signed certificate.
   */
  private static X509Certificate getX509Certificate(RSAKey rsaJWK) throws OperatorCreationException, IOException, JOSEException {
    return X509CertificateUtils.generateSelfSigned(
        new Issuer("http://localhost:8080"),
        Date.from(Instant.now().minus(1, ChronoUnit.DAYS)),
        Date.from(Instant.now().plus(1, ChronoUnit.DAYS)),
        rsaJWK.toRSAPublicKey(),
        rsaJWK.toRSAPrivateKey());
  }

  @SuppressWarnings("deprecation")
  private static void getPrivateKey(RSAKey rsaJWK, X509Certificate cert) throws CertificateEncodingException, JOSEException {
    // Create private key from the certificate
    privateJWK = new RSAKey.Builder(rsaJWK)
        .x509CertChain(Collections.singletonList(Base64.encode(cert.getEncoded())))
        .x509CertThumbprint(rsaJWK.computeThumbprint())
        .build();
  }

  @SuppressWarnings("deprecation")
  private static JWK getJwk(RSAKey rsaJWK, X509Certificate cert) throws CertificateEncodingException, JOSEException {
    return new RSAKey.Builder(rsaJWK)
        .keyUse(KeyUse.SIGNATURE)
        .keyID(KEY_ID)
        .issueTime(new Date())
        .algorithm(algorithm)
        .x509CertChain(Collections.singletonList(Base64.encode(cert.getEncoded())))
        .x509CertThumbprint(rsaJWK.computeThumbprint())
        .x509CertSHA256Thumbprint(rsaJWK.computeThumbprint())
        .build();
  }

Le code est assez parlant de lui-même. On commence par générer un combo clé privée/publique en RSA 2048. Nous utilisons ces clés pour générer un certificat X509 auto signé. On utilise ensuite ce certificat pour générer des clés asymétriques pour la signature des tokens.

À noter que le certificat X509 à une validité de 1 jour ici. Pour des tests, cela est amplement suffisant. Nous ajoutons le paramètre x509CertThumbprint qui est déprécié, car keycloak le génère également.

Il nous reste à exposer ces informations au format JSON. La méthode generateKCCErtsJSON s’en charge :

1
2
3
4
5
6
7
8
9
private static String generateKCCertsJson(String key) {
    JSONObject keyObject = new JSONObject(key);

    JSONObject root = new JSONObject();
    JSONArray keys = new JSONArray();
    keys.put(keyObject);
    root.put("keys", keys);
    return root.toString();
  }

Keycloak pouvant exposer plusieurs clés, la méthode se contente de créer un tableau keys dans lequel est inséré les informations générées précédemment.

Génération du token

Il ne nous reste plus qu’à générer un JWT signé par notre clé issue du certificat X509 exposé par notre mockServer.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
    // Create RSA-signer with the private key
    JWSSigner signer = new RSASSASigner(privateJWK);

    JWSHeader header = new Builder(JWSAlgorithm.RS256)
        .type(JOSEObjectType.JWT)
        .keyID(KEY_ID)
        .build();
    JWTClaimsSet payload = new JWTClaimsSet.Builder()
        .issuer("http://localhost:8080/realms/example")
        .audience("myService")
        .subject("myApp")
        .claim("typ", "Bearer")
        .expirationTime(Date.from(Instant.now().plusSeconds(300)))
        .notBeforeTime(Date.from(Instant.now().minusSeconds(120)))
        .build();

    JWSObject jwsObject = new JWSObject(header, payload.toPayload());

    // Compute the RSA signature
    jwsObject.sign(signer);
    String token = jwsObject.serialize();

Le fichier de configuration Keycloak keycloak.json permettant de valider ce token est le suivant :

1
2
3
4
5
6
7
8
{
    "realms": "example",
    "auth-server-url": "http://localhost:8080",
    "ssl-required": "external",
    "resource": "myService",
    "verify-token-audience": true,
    ...
}

Conclusion

C’est terminer. Cela fait pas mal de code pour juste signer et validé un jeton. La bibliothèque nimbus nous aide fortement dans cette tâche. Les tests de sécurité sont maintenant beaucoup plus rapides à exécuter. Même si nous démarrons un serveur netty, cela n’est rien comparé à un serveur keycloak dans une image docker. Petit bonus, le serveur mock permet en plus de voir l’ensemble des requêtes exécutées avec leurs paramètres. Pratique pour débugguer

0%