How to test Keycloak authentication in Spring Boot application?

radistao picture radistao · Aug 6, 2018 · Viewed 11k times · Source

In a Spring Boot project we enabled Spring Security and applied Keycloak authentication with bearer token like described in the following articles:

https://www.keycloak.org/docs/3.2/securing_apps/topics/oidc/java/spring-security-adapter.html

https://www.keycloak.org/docs/3.2/securing_apps/topics/oidc/java/spring-boot-adapter.html

But i can't find any recommendations how to make automation tests so that the Keycloak config is applied.

So, how to test/mock/verify the Keycloak configuration when Spring security is enabled? One really annoying thing: by default Spring activates csrf security filter, but how to avoid testing it?

(Note: we use bearer tokens, so looks like @WithMockUser is not applicable in this case)

A bonus question: basically we don't want to verify security on each controller integration test, so is it possible to verify security separately from the controllers integration tests (those which use @SpringBootTest, @WebAppConfiguration, @AutoConfigureMockMvc and so on?

Answer

Tamim picture Tamim · Feb 25, 2020

One solution is using WireMock for stubbing the keycloak authorisation server. Therefore you can use the library spring-cloud-contract-wiremock(see https://cloud.spring.io/spring-cloud-contract/1.1.x/multi/multi__spring_cloud_contract_wiremock.html), which offers an easy spring boot integration. You can simply add the dependency as described. Furthermore i use jose4j for creating mocked access tokens the same way as Keycloak does as JWTs. All you have to do is stubbing the endpoints for Keycloak OpenId Configuration and the JSON Web Key Storage, since the Keycloak Adapter does only request those for validation of access tokens in the Authorization Header.

A minimal working standalone example, that needs to be customized at one place though (see Important Notes), with a few explanations is listed in the following:

KeycloakTest.java:

@ExtendWith(SpringExtension.class)
@WebMvcTest(KeycloakTest.TestController.class)
@EnableConfigurationProperties(KeycloakSpringBootProperties.class)
@ContextConfiguration(classes= {KeycloakTest.TestController.class, SecurityConfig.class, CustomKeycloakSpringBootConfigResolver.class})
@AutoConfigureMockMvc
@AutoConfigureWireMock(port = 0) //random port, that is wired into properties with key wiremock.server.port
@TestPropertySource(locations = "classpath:wiremock.properties")
public class KeycloakTest {

    private static RsaJsonWebKey rsaJsonWebKey;

    private static boolean testSetupIsCompleted = false;

    @Value("${wiremock.server.baseUrl}")
    private String keycloakBaseUrl;

    @Value("${keycloak.realm}")
    private String keycloakRealm;

    @Autowired
    private MockMvc mockMvc;

    @BeforeEach
    public void setUp() throws IOException, JoseException {
        if(!testSetupIsCompleted) {
            // Generate an RSA key pair, which will be used for signing and verification of the JWT, wrapped in a JWK
            rsaJsonWebKey = RsaJwkGenerator.generateJwk(2048);
            rsaJsonWebKey.setKeyId("k1");
            rsaJsonWebKey.setAlgorithm(AlgorithmIdentifiers.RSA_USING_SHA256);
            rsaJsonWebKey.setUse("sig");

            String openidConfig = "{\n" +
                    "  \"issuer\": \"" + keycloakBaseUrl + "/auth/realms/" + keycloakRealm + "\",\n" +
                    "  \"authorization_endpoint\": \"" + keycloakBaseUrl + "/auth/realms/" + keycloakRealm + "/protocol/openid-connect/auth\",\n" +
                    "  \"token_endpoint\": \"" + keycloakBaseUrl + "/auth/realms/" + keycloakRealm + "/protocol/openid-connect/token\",\n" +
                    "  \"token_introspection_endpoint\": \"" + keycloakBaseUrl + "/auth/realms/" + keycloakRealm + "/protocol/openid-connect/token/introspect\",\n" +
                    "  \"userinfo_endpoint\": \"" + keycloakBaseUrl + "/auth/realms/" + keycloakRealm + "/protocol/openid-connect/userinfo\",\n" +
                    "  \"end_session_endpoint\": \"" + keycloakBaseUrl + "/auth/realms/" + keycloakRealm + "/protocol/openid-connect/logout\",\n" +
                    "  \"jwks_uri\": \"" + keycloakBaseUrl + "/auth/realms/" + keycloakRealm + "/protocol/openid-connect/certs\",\n" +
                    "  \"check_session_iframe\": \"" + keycloakBaseUrl + "/auth/realms/" + keycloakRealm + "/protocol/openid-connect/login-status-iframe.html\",\n" +
                    "  \"registration_endpoint\": \"" + keycloakBaseUrl + "/auth/realms/" + keycloakRealm + "/clients-registrations/openid-connect\",\n" +
                    "  \"introspection_endpoint\": \"" + keycloakBaseUrl + "/auth/realms/" + keycloakRealm + "/protocol/openid-connect/token/introspect\"\n" +
                    "}";
            stubFor(WireMock.get(urlEqualTo(String.format("/auth/realms/%s/.well-known/openid-configuration", keycloakRealm)))
                    .willReturn(aResponse()
                            .withHeader("Content-Type", "application/json")
                            .withBody(openidConfig)
                    )
            );
            stubFor(WireMock.get(urlEqualTo(String.format("/auth/realms/%s/protocol/openid-connect/certs", keycloakRealm)))
                    .willReturn(aResponse()
                            .withHeader("Content-Type", "application/json")
                            .withBody(new JsonWebKeySet(rsaJsonWebKey).toJson())
                    )
            );
            testSetupIsCompleted = true;
        }
    }

    @Test
    public void When_access_token_is_in_header_Then_process_request_with_Ok() throws Exception {
        ResultActions resultActions = this.mockMvc
                .perform(get("/test")
                        .header("Authorization",String.format("Bearer %s", generateJWT(true)))
                );
        resultActions
                .andDo(print())
                .andExpect(status().isOk())
                .andExpect(content().string("hello"));
    }

    @Test
    public void When_access_token_is_missing_Then_redirect_to_login() throws Exception {
        ResultActions resultActions = this.mockMvc
                .perform(get("/test"));
        resultActions
                .andDo(print())
                .andExpect(status().isFound())
                .andExpect(redirectedUrl("/sso/login"));
    }

    private String generateJWT(boolean withTenantClaim) throws JoseException {

        // Create the Claims, which will be the content of the JWT
        JwtClaims claims = new JwtClaims();
        claims.setJwtId(UUID.randomUUID().toString()); // a unique identifier for the token
        claims.setExpirationTimeMinutesInTheFuture(10); // time when the token will expire (10 minutes from now)
        claims.setNotBeforeMinutesInThePast(0); // time before which the token is not yet valid (2 minutes ago)
        claims.setIssuedAtToNow(); // when the token was issued/created (now)
        claims.setAudience("account"); // to whom this token is intended to be sent
        claims.setIssuer(String.format("%s/auth/realms/%s",keycloakBaseUrl,keycloakRealm)); // who creates the token and signs it
        claims.setSubject(UUID.randomUUID().toString()); // the subject/principal is whom the token is about
        claims.setClaim("typ","Bearer"); // set type of token
        claims.setClaim("azp","example-client-id"); // Authorized party  (the party to which this token was issued)
        claims.setClaim("auth_time", NumericDate.fromMilliseconds(Instant.now().minus(11, ChronoUnit.SECONDS).toEpochMilli()).getValue()); // time when authentication occured
        claims.setClaim("session_state", UUID.randomUUID().toString()); // keycloak specific ???
        claims.setClaim("acr", "0"); //Authentication context class
        claims.setClaim("realm_access", Map.of("roles",List.of("offline_access","uma_authorization","user"))); //keycloak roles
        claims.setClaim("resource_access", Map.of("account",
                    Map.of("roles", List.of("manage-account","manage-account-links","view-profile"))
                )
        ); //keycloak roles
        claims.setClaim("scope","profile email");
        claims.setClaim("name", "John Doe"); // additional claims/attributes about the subject can be added
        claims.setClaim("email_verified",true);
        claims.setClaim("preferred_username", "doe.john");
        claims.setClaim("given_name", "John");
        claims.setClaim("family_name", "Doe");

        // A JWT is a JWS and/or a JWE with JSON claims as the payload.
        // In this example it is a JWS so we create a JsonWebSignature object.
        JsonWebSignature jws = new JsonWebSignature();

        // The payload of the JWS is JSON content of the JWT Claims
        jws.setPayload(claims.toJson());

        // The JWT is signed using the private key
        jws.setKey(rsaJsonWebKey.getPrivateKey());

        // Set the Key ID (kid) header because it's just the polite thing to do.
        // We only have one key in this example but a using a Key ID helps
        // facilitate a smooth key rollover process
        jws.setKeyIdHeaderValue(rsaJsonWebKey.getKeyId());

        // Set the signature algorithm on the JWT/JWS that will integrity protect the claims
        jws.setAlgorithmHeaderValue(AlgorithmIdentifiers.RSA_USING_SHA256);

        // set the type header
        jws.setHeader("typ","JWT");

        // Sign the JWS and produce the compact serialization or the complete JWT/JWS
        // representation, which is a string consisting of three dot ('.') separated
        // base64url-encoded parts in the form Header.Payload.Signature
        return jws.getCompactSerialization();
    }

    @RestController
    public static class TestController {
        @GetMapping("/test")
        public String test() {
            return "hello";
        }
    }

}

wiremock.properties:

wiremock.server.baseUrl=http://localhost:${wiremock.server.port}
keycloak.auth-server-url=${wiremock.server.baseUrl}/auth

Test Setup

The annotation @AutoConfigureWireMock(port = 0) will start a WireMock server at a random port, which is set to the property wiremock.server.port automatically, so it can be used to override the keycloak.auth-server-url property for the Spring Boot Keycloak Adapter accordingly (see wiremock.properties)

For generating the JWT, that is used as a Access Token, i do create a RSA key pair with jose4j, that is declared as a test class attribute, since i do need to initialize it during test setup alongside the WireMock Server.

private static RsaJsonWebKey rsaJsonWebKey;

It is then initialized during test setup as following:

rsaJsonWebKey = RsaJwkGenerator.generateJwk(2048);
            rsaJsonWebKey.setKeyId("k1");
            rsaJsonWebKey.setAlgorithm(AlgorithmIdentifiers.RSA_USING_SHA256);
            rsaJsonWebKey.setUse("sig");

The choice for the keyId does not matter. You can choose whatever you want, as long as it is set. The chosen algorithm and the use do matter though and must be adapted exactly as in the example.

With this the JSON Web Key Storage endpoint of the Keycloak Stub can be set accordingly as follows:

stubFor(WireMock.get(urlEqualTo(String.format("/auth/realms/%s/protocol/openid-connect/certs", keycloakRealm)))
                    .willReturn(aResponse()
                            .withHeader("Content-Type", "application/json")
                            .withBody(new JsonWebKeySet(rsaJsonWebKey).toJson())
                    )
            );

Except this another endpoint needs to be stubbed for keycloak as mentioned earlier. If not cached, the keycloak adapter needs to request the openid configuration. For a minimal working example all endpoints need to be defined in the config, that is returned from the OpenId Configuration Endpoint:

String openidConfig = "{\n" +
                    "  \"issuer\": \"" + keycloakBaseUrl + "/auth/realms/" + keycloakRealm + "\",\n" +
                    "  \"authorization_endpoint\": \"" + keycloakBaseUrl + "/auth/realms/" + keycloakRealm + "/protocol/openid-connect/auth\",\n" +
                    "  \"token_endpoint\": \"" + keycloakBaseUrl + "/auth/realms/" + keycloakRealm + "/protocol/openid-connect/token\",\n" +
                    "  \"token_introspection_endpoint\": \"" + keycloakBaseUrl + "/auth/realms/" + keycloakRealm + "/protocol/openid-connect/token/introspect\",\n" +
                    "  \"userinfo_endpoint\": \"" + keycloakBaseUrl + "/auth/realms/" + keycloakRealm + "/protocol/openid-connect/userinfo\",\n" +
                    "  \"end_session_endpoint\": \"" + keycloakBaseUrl + "/auth/realms/" + keycloakRealm + "/protocol/openid-connect/logout\",\n" +
                    "  \"jwks_uri\": \"" + keycloakBaseUrl + "/auth/realms/" + keycloakRealm + "/protocol/openid-connect/certs\",\n" +
                    "  \"check_session_iframe\": \"" + keycloakBaseUrl + "/auth/realms/" + keycloakRealm + "/protocol/openid-connect/login-status-iframe.html\",\n" +
                    "  \"registration_endpoint\": \"" + keycloakBaseUrl + "/auth/realms/" + keycloakRealm + "/clients-registrations/openid-connect\",\n" +
                    "  \"introspection_endpoint\": \"" + keycloakBaseUrl + "/auth/realms/" + keycloakRealm + "/protocol/openid-connect/token/introspect\"\n" +
                    "}";
stubFor(WireMock.get(urlEqualTo(String.format("/auth/realms/%s/.well-known/openid-configuration", keycloakRealm)))
                    .willReturn(aResponse()
                            .withHeader("Content-Type", "application/json")
                            .withBody(openidConfig)
                    )
            );

Token Generation

Generation of the token is implemented in generateJWT() with heavy use of jose4j . The most important point to note here is, that the private key of the same generated JWK as the one initialized during the test setup for wiremock has to be used.

jws.setKey(rsaJsonWebKey.getPrivateKey());

Except this the the code is adapted mainly from the example at https://bitbucket.org/b_c/jose4j/wiki/JWT%20Examples.
One can now adjust or extend the claims as seen fit for one's own specific test setup. The minimal example in the posted snippet represents a typical example for a JWT produced by Keycloak.

Test Execution

The generated JWT can be used as usual in the Authorization Header to send a request to a REST endpoint:

ResultActions resultActions = this.mockMvc
                .perform(get("/test")
                        .header("Authorization",String.format("Bearer %s", generateJWT(true)))
                );

For representing a standalone example the test class does have a simple Restcontroller defined as an inner class, that is used for the test.

@RestController
public static class TestController {
    @GetMapping("/test")
    public String test() {
        return "hello";
    }
}

Important Notes

I did introduce a custom TestController for testing purposes, so it had been neccessary to define a custom ContextConfiguration to load it in a WebMvcTest as follows:

@ContextConfiguration(classes= {KeycloakTest.TestController.class, SecurityConfig.class, CustomKeycloakSpringBootConfigResolver.class})

Apart from the TestController itself a bunch of Configuration Beans regarding Spring Security and the Keycloak Adapter are included like SecurityConfig.class and CustomKeycloakSpringBootConfigResolver.class to have it work. These need to be replaced by your own Configuration of course. For the sake of completeness those classes will be listed too in the following:

SecurityConfig.java:

@Configuration
@EnableWebSecurity
@ComponentScan(basePackageClasses = KeycloakSecurityComponents.class)
public class SecurityConfig extends KeycloakWebSecurityConfigurerAdapter {

    @Autowired
    public void configureGlobal(AuthenticationManagerBuilder auth) {
        SimpleAuthorityMapper grantedAuthorityMapper = new SimpleAuthorityMapper();
        grantedAuthorityMapper.setPrefix("ROLE_");

        KeycloakAuthenticationProvider keycloakAuthenticationProvider = keycloakAuthenticationProvider();
        keycloakAuthenticationProvider.setGrantedAuthoritiesMapper(grantedAuthorityMapper);
        auth.authenticationProvider(keycloakAuthenticationProvider);
    }

    /*
     * Workaround for reading the properties for the keycloak adapter (see https://stackoverflow.com/questions/57787768/issues-running-example-keycloak-spring-boot-app)
     */
    @Bean
    @Primary
    public KeycloakConfigResolver keycloakConfigResolver(KeycloakSpringBootProperties properties) {
        return new CustomKeycloakSpringBootConfigResolver(properties);
    }

    @Bean
    @Override
    protected SessionAuthenticationStrategy sessionAuthenticationStrategy() {
        return new RegisterSessionAuthenticationStrategy(new SessionRegistryImpl());
    }

    @Bean
    @Override
    @ConditionalOnMissingBean(HttpSessionManager.class)
    protected HttpSessionManager httpSessionManager() {
        return new HttpSessionManager();
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        super.configure(http);
        http
                .authorizeRequests()
                .antMatchers("/**").hasRole("user")
                .anyRequest().authenticated()
                .and().csrf().disable();
    }
}

CustomKeycloakSpringBootConfigResolver.java:

 /*
  * Workaround for reading the properties for the keycloak adapter (see https://stackoverflow.com/questions/57787768/issues-running-example-keycloak-spring-boot-app)
  */
@Configuration
public class CustomKeycloakSpringBootConfigResolver extends KeycloakSpringBootConfigResolver {
    private final KeycloakDeployment keycloakDeployment;

    public CustomKeycloakSpringBootConfigResolver(KeycloakSpringBootProperties properties) {
        keycloakDeployment = KeycloakDeploymentBuilder.build(properties);
    }

    @Override
    public KeycloakDeployment resolve(HttpFacade.Request facade) {
        return keycloakDeployment;
    }
}