TNG / keycloak-mock

A Java library to test REST endpoints secured by Keycloak via OpenID connect.
Apache License 2.0
122 stars 27 forks source link

Unable to authenticate using the Authorization header #79

Closed belgoros closed 3 years ago

belgoros commented 3 years ago

I'm trying without success to implement a simple test for the following controller end-points:

@RestController
@RequestMapping("/test")
public class SimpleController {
    @RequestMapping(value = "/anonymous", method = RequestMethod.GET)
    public ResponseEntity<String> getAnonymous() {
        return ResponseEntity.ok("Hello Anonymous");
    }

    @RequestMapping(value = "/user", method = RequestMethod.GET)
    public ResponseEntity<String> getUser() {
        return ResponseEntity.ok("Hello User");
    }
...
}

The test class:

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureMockMvc
class SimpleControllerTest {

    @Autowired
    private MockMvc mockMvc;

    static KeycloakMock keycloakMock = new KeycloakMock(ServerConfig.aServerConfig()
            .withPort(8080)
            .withRealm("Demo-Realm")
            .build());

    @BeforeAll
    static void setUp() {
        keycloakMock.start();
    }

    @AfterAll
    static void tearDown() {
        keycloakMock.stop();
    }

    @Test
    public void shouldBeAccessibleForAnybody() throws Exception {
        this.mockMvc.perform(get("/test/anonymous"))
                .andDo(print())
                .andExpect(MockMvcResultMatchers.content().string(containsString("Hello Anonymous")))
                .andExpect(status().isOk());
    }

    @Test
    public void shouldBeAccessibleWithUserRole() throws Exception {
        TokenConfig tokenConfig = aTokenConfig()
                .withRealmRole("app-user")
                .withClaim("password", "mypassword")
                .withPreferredUsername("employee1")
                .withResourceRole("springboot-microservice", "user")
                .build();
        String accessToken = keycloakMock.getAccessToken(tokenConfig);

        mockMvc.perform(get("/test/user")
                .header("Authorization", "Bearer " + accessToken))
                .andDo(print())
                .andExpect(MockMvcResultMatchers.content().string(containsString("Hello User")))
                .andExpect(status().isOk());
    }
}

The security config class looks like this:

@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(jsr250Enabled = true)
public class KeycloakSecurityConfig extends KeycloakWebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        super.configure(http);
        http.authorizeRequests()
                .antMatchers("/test/anonymous").permitAll()
                .antMatchers("/test/user").hasAnyRole("user")
                .antMatchers("/test/admin").hasAnyRole("admin")
                .antMatchers("/test/all-user").hasAnyRole("user", "admin")
                .anyRequest()
                .permitAll();
        http.csrf().disable();
    }
...

The first test, for anonymous, passes, but the second fails with:

MockHttpServletRequest:
      HTTP Method = GET
      Request URI = /test/user
       Parameters = {}
          Headers = [Authorization:"Bearer eyJraWQiOiJrZXlJZCIsImFsZyI6IlJTMjU2In0.eyJhdWQiOlsic2VydmVyIl0sImlhdCI6MTYyMTQxNjA5MSwiYXV0aF90aW1lIjoxNjIxNDE2MDkxLCJleHAiOjE2MjE0NTIwOTEsImlzcyI6Imh0dHA6Ly9sb2NhbGhvc3Q6ODA4MC9hdXRoL3JlYWxtcy9EZW1vLVJlYWxtIiwic3ViIjoidXNlciIsInNjb3BlIjoib3BlbmlkIiwidHlwIjoiQmVhcmVyIiwiYXpwIjoiY2xpZW50IiwicHJlZmVycmVkX3VzZXJuYW1lIjoiZW1wbG95ZWUxIiwicmVhbG1fYWNjZXNzIjp7InJvbGVzIjpbImFwcC11c2VyIl19LCJyZXNvdXJjZV9hY2Nlc3MiOnsic3ByaW5nYm9vdC1taWNyb3NlcnZpY2UiOnsicm9sZXMiOlsidXNlciJdfX0sInBhc3N3b3JkIjoibXlwYXNzd29yZCJ9.MPY4ynIWuZ0ZaPVh7zljhPSO9lE7i_XIY4NDX7Vb2qBbihRKIISEFqFp8yNG3SYV9UjvNNR2H0pvL9RwKJuHGWeQs-CP8uFgR-GuvSqdyGgBHlj2zs89gaZ07EhaD2cSdHDGzjlXTUZK__qUMM9fEIaGi39BWDJ3XfIiqyORkpRp4a79fLluZ5ip4WW5gShyqDgUcrB2sg9sTglYIuSyt37TlXkkXkH-qN5S28qm5mjiG9G_xZPenNkNrRbZ5zbzOgK2HsgflCsh6cc2bj6FAIdaqGaRxxPLpfWKs7j6gwQX7aTipfg5YhJ87SzPfzu5izby99SHTTfegOPcGEq-MA"]
...

MockHttpServletResponse:
           Status = 401
    Error message = Unable to authenticate using the Authorization header
          Headers = [WWW-Authenticate:"Bearer realm="Demo-Realm", error="invalid_token", error_description="Didn't find publicKey for specified kid"", X-Content-Type-Options:"nosniff", X-XSS-Protection:"1; mode=block", Cache-Control:"no-cache, no-store, max-age=0, must-revalidate", Pragma:"no-cache", Expires:"0", X-Frame-Options:"DENY"]
...
java.lang.AssertionError: Response content
Expected: a string containing "Hello User"
     but: was ""
Expected :a string containing "Hello User"
Actual   :""

What am I missing here?

Used versions:

mbreevoort commented 3 years ago

Which spring-boot version? No errors in the log? See https://github.com/TNG/keycloak-mock/issues/74

belgoros commented 3 years ago

@mbreevoort The spring-boot version is 2.4.5 but I have no NPE, just wonder if I set all the needed properties in the test example. Because if I use the token generated in the test and use in the Postman, it also fails with 401 error.

mbreevoort commented 3 years ago

Does it work with spring-boot 2.4.4? Then temporary downgrade to netty 4.1.60.Final on the test scope...

belgoros commented 3 years ago

When testing the same end-points from the Postman it works fine with 2.4.5 version (I get the token first, then test /test/user end-point and others). But the test for /test/user using the token fails. Downgrading to the 2.4.4 version had no effect. That's why I think it is rather the test values setup issue.

belgoros commented 3 years ago

If I replace the token value with the one I get from Postman, it works:

    @Test
    public void shouldBeAccessibleWithUserRole() throws Exception {
        TokenConfig tokenConfig = aTokenConfig()
                .withRealmRole("app-user")
                .withPreferredUsername("employee1")
                .withResourceRole("springboot-microservice", "user")
                .build();
        String accessToken = "eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJJUDVES0ZsdU5GUTV1Tml4SmlvXzBvczdTeFFMMTdXakE3MVhSbkRtOTkwIn0.eyJleHAiOjE2MjE0MjEzMDMsImlhdCI6MTYyMTQyMTAwMywianRpIjoiZWNkMWI5YWUtNjNmMy00Mzk3LWI3MTQtNWY5NzA2YWYxNDRiIiwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdDo4MDgwL2F1dGgvcmVhbG1zL0RlbW8tUmVhbG0iLCJhdWQiOiJhY2NvdW50Iiwic3ViIjoiYWVmYzIyMWMtMTQwMi00MWI0LThmYjAtNGJlMmVhYTAwM2YyIiwidHlwIjoiQmVhcmVyIiwiYXpwIjoic3ByaW5nYm9vdC1taWNyb3NlcnZpY2UiLCJzZXNzaW9uX3N0YXRlIjoiYzc0ODcwMjUtM2I1ZS00ZjQ0LTk1MDktZTJjZGRlNjRjODkwIiwiYWNyIjoiMSIsImFsbG93ZWQtb3JpZ2lucyI6WyJodHRwOi8vbG9jYWxob3N0OjgwODAiXSwicmVhbG1fYWNjZXNzIjp7InJvbGVzIjpbIm9mZmxpbmVfYWNjZXNzIiwidW1hX2F1dGhvcml6YXRpb24iLCJhcHAtdXNlciJdfSwicmVzb3VyY2VfYWNjZXNzIjp7InNwcmluZ2Jvb3QtbWljcm9zZXJ2aWNlIjp7InJvbGVzIjpbInVzZXIiXX0sImFjY291bnQiOnsicm9sZXMiOlsibWFuYWdlLWFjY291bnQiLCJtYW5hZ2UtYWNjb3VudC1saW5rcyIsInZpZXctcHJvZmlsZSJdfX0sInNjb3BlIjoiZW1haWwgcHJvZmlsZSIsImVtYWlsX3ZlcmlmaWVkIjp0cnVlLCJwcmVmZXJyZWRfdXNlcm5hbWUiOiJlbXBsb3llZTEifQ.oliBcu07D_kwUJixHcGEzUV2n8PcGiCY9fgkJty_4z_RclKp5x8IimF8T50rDBX0iQwe7j-NiPGa92qLxtMvllCXI355MXY3ty4btK2vmvZQ0okMsVNFV84LKD2fu4P1pjsfcvH0kWaP3UVd7OOakaDpTxnN6HfXu5-wo-nESWqnWN0XixN2t2Zqj5Du24FzjqaskyjE-UIYYRzfiSE27pPELHgfllqoBAOprOmaB8EWZhGLP0Rg3R9SdLKqpF4v2lFiokzBR67YDLt0iHTik-rBwTc8CWH9mc0R92qE_vuzlkwcaspMK_WAte8WcCUBrsNQA9TSsKLv4JgmFqWgIQ";//keycloakMock.getAccessToken(tokenConfig);

        mockMvc.perform(get("/test/user")
                .header("Authorization", "Bearer " + accessToken))
                .andDo(print())
                .andExpect(MockMvcResultMatchers.content().string(containsString("Hello User")))
                .andExpect(status().isOk());
    }

So it looks like setting thetokenConfig is wrong in the above test example.

mbreevoort commented 3 years ago

I use the ClassRule

    @ClassRule
    public static KeycloakMockRule keycloakMock = new KeycloakMockRule(
        ServerConfig.aServerConfig()..withPort(8080)
            .withRealm("Demo-Realm")
            .build())
    );

Did you set the url of keycloak to http://localhost:8000/auth we use a config like this:

http.authorizeRequests()
.anyRequest().authenticated()
.and()
.oauth2ResourceServer()
.jwt()
.jwkSetUri(String.format("%s/realms/%s/protocol/openid-connect/certs", serverUrl, realmName))
belgoros commented 3 years ago

I don't know how you can use KeycloakMockRule, - it is not available in keycloakmock 0.7.0 version, where does come from? Second point, the current security configuration class is defined as follows:

@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(jsr250Enabled = true)
public class KeycloakSecurityConfig extends KeycloakWebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        super.configure(http);
        http.authorizeRequests()
                .antMatchers("/test/anonymous").permitAll()
                .antMatchers("/test/user").hasAnyRole("user")
                .antMatchers("/test/admin").hasAnyRole("admin")
                .antMatchers("/test/all-user").hasAnyRole("user", "admin")
                .anyRequest()
                .permitAll();
        http.csrf().disable();
    }

    @Autowired
    public void configureGlobal(AuthenticationManagerBuilder auth) {
        KeycloakAuthenticationProvider keycloakAuthenticationProvider = keycloakAuthenticationProvider();
        keycloakAuthenticationProvider.setGrantedAuthoritiesMapper(new SimpleAuthorityMapper());
        auth.authenticationProvider(keycloakAuthenticationProvider);
    }

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

    @Bean
    public KeycloakConfigResolver KeycloakConfigResolver() {
        return new KeycloakSpringBootConfigResolver();
    }
mbreevoort commented 3 years ago

It's from keycloakmock-junit a convenient class. Forget my config

Try to configure it like https://github.com/TNG/keycloak-mock/tree/master/example-backend/src/ Also check the application.yaml

ostrya commented 3 years ago

Hi @belgoros, can you perhaps share your keycloak.json or the Keycloak-specific part in your application.(yaml|properties) where you tell your Spring Boot application what Keycloak server to use and which realm to connect to?

belgoros commented 3 years ago

It is not the first project where we are using Keycloak and keycloak-mock almost the same way, - everything works pretty well. That's why I'm rather convinced that there is something wrong/different with either the realm or test settings.

Here are the steps to reproduce the issue I followed:

When testing the same scenario, it fails:

package com.altran.software.factory.Keycloakspringbootmicroservice.controllers;

import com.tngtech.keycloakmock.api.KeycloakMock;
import com.tngtech.keycloakmock.api.ServerConfig;
import com.tngtech.keycloakmock.api.TokenConfig;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.result.MockMvcResultMatchers;

import static com.tngtech.keycloakmock.api.TokenConfig.aTokenConfig;
import static org.hamcrest.Matchers.containsString;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureMockMvc
class SimpleControllerTest {

    @Autowired
    private MockMvc mockMvc;

    static KeycloakMock keycloakMock = new KeycloakMock(ServerConfig.aServerConfig()
            .withPort(8080)
            .withRealm("Demo-Realm")
            .build());

    @BeforeAll
    static void setUp() {
        keycloakMock.start();
    }

    @AfterAll
    static void tearDown() {
        keycloakMock.stop();
    }

    @Test
    public void shouldBeAccessibleForAnybody() throws Exception {
        this.mockMvc.perform(get("/test/anonymous"))
                .andDo(print())
                .andExpect(MockMvcResultMatchers.content().string(containsString("Hello Anonymous")))
                .andExpect(status().isOk());
    }

    @Test
    public void shouldBeAccessibleWithUserRole() throws Exception {
        TokenConfig tokenConfig = aTokenConfig()
                .withRealmRole("app-user")
                .withPreferredUsername("employee1")
                .withResourceRole("springboot-microservice", "user")
                .withClaim("password", "mypassword")
                .build();
        String accessToken = keycloakMock.getAccessToken(tokenConfig);

        mockMvc.perform(get("/test/user")
                .header("Authorization", "Bearer " + accessToken))
                .andDo(print())
                .andExpect(MockMvcResultMatchers.content().string(containsString("Hello User")))
                .andExpect(status().isOk());
    }
}

I attach the keycloak config JSON fille and the Postman collection. You will have just import them into the Keycloak and Postman.

Once again, when replacing the accessToken value:

String accessToken = keycloakMock.getAccessToken(tokenConfig);

with the token got with Postman, the test passes without problems, so I think the problem is in the generated token:

MockHttpServletResponse:
           Status = 401
    Error message = Unable to authenticate using the Authorization header
          Headers = [WWW-Authenticate:"Bearer realm="Demo-Realm", error="invalid_token", error_description="Didn't find publicKey for specified kid"", X-Content-Type-Options:"nosniff", X-XSS-Protection:"1; mode=block", Cache-Control:"no-cache, no-store, max-age=0, must-revalidate", Pragma:"no-cache", Expires:"0", X-Frame-Options:"DENY"]

Here is my application.yml file (same for tests and dev):

spring:
  application:
    name: keycloak-springboot-microservice
    allowed-origin: "*"
server:
  port: 8000
keycloak:
  realm: Demo-Realm
  auth-server-url: http://localhost:8080/auth
  ssl-required: external
  resource: springboot-microservice
  credentials:
    secret: 4e4ca23d-2520-4b93-bede-07ab4933a26c
  use-resource-role-mappings: true
  bearer-only: true

When using Postman to get a token, I passed client_secret as well as username and password. How can I do it when building the TokenConfig instance? I can't see any methods like with*** there:

TokenConfig tokenConfig = aTokenConfig()
                .withRealmRole("app-user")
                .withPreferredUsername("employee1")
                .withResourceRole("springboot-microservice", "user")
                .withClaim("password", "mypassword")
                .build();
...

You can find and compare:

belgoros commented 3 years ago

After some debugging, it seems like the issue comes from AdapterTokenVerifier class of the keycloak-adapter-core in the #getPublicKey method:

Screenshot 2021-05-20 at 10 09 36

But it is still because of the wrong token it gets. When comparing the decoded token with jwt.io, the difference is in the header: Token generated with keycloak-mock:

{
  "kid": "keyId",
  "alg": "RS256"
}

Token generated by Keycloak server and fetched with Postman:

{
  "alg": "RS256",
  "typ": "JWT",
  "kid": "IP5DKFluNFQ5uNixJio_0os7SxQL17WjA71XRnDm990"
}

Is it normal "kid": "keyId" to be present in the keycloak-mock header compared to the real one: "kid": "IP5DKFluNFQ5uNixJio_0os7SxQL17WjA71XRnDm990" ? I can see that the value of the KEY_ID constant in the TokenGenerator class is just set to keyId:

public class TokenGenerator {
  private static final String KEY_ID = "keyId";
...

And to prove why so, let's just take a look at JWKPublicKeyLocator #lookupCachedKey method where we'll have to extract a value from the currentKeys map by kid value (which is keyId):

Screenshot 2021-05-20 at 10 47 59

And as you could see, the currentKeys map does not contain a key keyId, so it will return NULL:

Screenshot 2021-05-20 at 10 48 24
ostrya commented 3 years ago

It is intentional and wanted that the "kid" value is "keyId", because the mock server only has one key with exactly that ID. What confuses me is that you actually have different keys in your local storage. Are you sure you do not have an actual Keycloak instance running on localhost:8080?

belgoros commented 3 years ago

@ostrya Hmm, you were right. I stopped the Keycloak server running locally at localhost:8080 and now getting another error:

2021-05-20 13:37:50.163  WARN 16879 --- [           main] o.keycloak.adapters.KeycloakDeployment   : Failed to load URLs from http://localhost:8080/auth/realms/Demo-Realm/.well-known/openid-configuration

org.apache.http.NoHttpResponseException: localhost:8080 failed to respond
...
java.lang.NullPointerException
    at java.base/java.net.URI$Parser.parse(URI.java:3104)
    at java.base/java.net.URI.<init>(URI.java:600)
    at java.base/java.net.URI.create(URI.java:881)
    at org.apache.http.client.methods.HttpGet.<init>(HttpGet.java:66)
    at org.keycloak.adapters.rotation.JWKPublicKeyLocator.sendRequest(JWKPublicKeyLocator.java:97)
    at org.keycloak.adapters.rotation.JWKPublicKeyLocator.getPublicKey(JWKPublicKeyLocator.java:63)
belgoros commented 3 years ago

Arr, downgrading the spring-boot from 2.4.5 to 2.4.4 version fixed the problem, - all the tests pass now. Thank you guys!