Testing multi tenenancy resource server with claim based role assignment

76 Views Asked by At

Problem: my integration tests fail with response code 403 instead of expected 200 or 401.

Environment: I have a resource server running with Spring Boot and Kotlin.

Security: Role based authorization of each endpoint. Accepting different JWTs from own hosted Keycloak instance (containing realm roles) and multiple other IDPs (without roles). In case of external IDPs (and tokens without roles) I map the issuer to a role in my custom converter.

@Component
class JwtAuthConverter(
    private val properties: JwtAuthConverterProperties,
    private val externalIssuers: ExternalIssuers,
) : Converter<Jwt?, AbstractAuthenticationToken?> {
    override fun convert(jwt: Jwt): AbstractAuthenticationToken {
        var authorities = extractRealmRoles(jwt)
        if (authorities.isEmpty()) authorities = getRoleForExternalIssuer(jwt)

        return JwtAuthenticationToken(jwt, authorities, getPrincipalClaimName(jwt))
    }

    private fun getPrincipalClaimName(jwt: Jwt): String {
        val claimName = properties.principalAttribute ?: JwtClaimNames.SUB

        return jwt.getClaim(claimName)
    }

    private fun extractRealmRoles(jwt: Jwt): Collection<GrantedAuthority> {
        val realmAccess: RealmAccess? = jwt.getClaim("realm_access")
        val resourceRoles =
            realmAccess?.let {
                realmAccess["roles"]
            }

        return resourceRoles?.map { role: String ->
            SimpleGrantedAuthority("ROLE_$role")
        } ?: emptySet()
    }

    private fun getRoleForExternalIssuer(jwt: Jwt): Collection<GrantedAuthority> {
        val iss: String = jwt.getClaim("iss")
        val roles: MutableCollection<GrantedAuthority> = mutableListOf()
        externalIssuers.issuers.forEach { issuer: IssuerDetails ->
            if (iss == issuer.uri) roles.add(SimpleGrantedAuthority("ROLE_${issuer.role}"))
        }

        return roles
    }
}
@Component
class AuthManagerResolverProvider(
    private val externalIssuers: ExternalIssuers,
    private var jwtAuthConverter: JwtAuthConverter?
) {
    @Bean
    fun getAuthenticationManagerResolver(): JwtIssuerAuthenticationManagerResolver {
        val authenticationManagers: MutableMap<String, AuthenticationManager> = HashMap()
        val authenticationManagerResolver = JwtIssuerAuthenticationManagerResolver { key: String? ->
            authenticationManagers[key]
        }

        externalIssuers.issuers.forEach { issuer: IssuerDetails ->
            addManager(authenticationManagers, issuer.uri)
        }

        return authenticationManagerResolver
    }
    private fun addManager(authManagers: MutableMap<String, AuthenticationManager>, issuer: String) {
        val authProvider = JwtAuthenticationProvider(JwtDecoders.fromOidcIssuerLocation(issuer))
        authProvider.setJwtAuthenticationConverter(jwtAuthConverter)
        authManagers[issuer] = AuthenticationManager {
            authProvider.authenticate(it)
        }
    }
}
@Configuration
@EnableWebSecurity
@EnableMethodSecurity(securedEnabled = true, prePostEnabled = true)
class SecurityConfig(
    jwtAuthConverter: JwtAuthConverter,
    externalIssuers: ExternalIssuers
) {
    private val authManagerResolverProvider = AuthManagerResolverProvider(externalIssuers, jwtAuthConverter)
    val authenticationManagerResolver = authManagerResolverProvider.getAuthenticationManagerResolver()

    @Bean
    @Throws(Exception::class)
    fun securityFilterChain(http: HttpSecurity): SecurityFilterChain? {
        http
            .authorizeHttpRequests { auth ->
                auth
                    .requestMatchers("/api/test/**").hasRole("tester")
                    // ... all the requestMatchers for each endpoint
                    .requestMatchers(HttpMethod.GET, "/v3/api-docs.yaml").permitAll()
                    .anyRequest().authenticated()
            }
            .oauth2ResourceServer { oauth2 ->
                oauth2.authenticationManagerResolver(authenticationManagerResolver)
            }
            .sessionManagement { session ->
                session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            }
            .cors(Customizer.withDefaults())
            .csrf { csrf -> csrf.disable() }

        return http.build()
    }
}
jwt:
  auth:
    converter:
      principal-attribute: preferred_username
    issuers:
      - uri: http://known.issuer/realms/test
        role: tester
      - uri: http://localhost:8080/realms/test // my local keycloak
        role: tester

/**
 * @property issuers
 */
@Configuration
@ConfigurationProperties(prefix = "jwt.auth")
class ExternalIssuers(var issuers: List<IssuerDetails> = ArrayList())
/**
 * @property uri
 * @property role
 */
data class IssuerDetails(
    var uri: String,
    var role: String,
)
/**
 * @property principalAttribute
 */
@Configuration
@ConfigurationProperties(prefix = "jwt.auth.converter")
data class JwtAuthConverterProperties(var principalAttribute: String? = null)
@Test
@WithMockJwtAuth(
    claims =
    OpenIdClaims(
        name = "example-name",
        iss = "http://known.issuer/realms/test",
    ),
)
fun `should succeed`() {
    mockMvc.get("/api/test/123abc")
        .andExpect {
            status { isOk() }
        }
}

In this test I don't provide any authorities as usual. For axample: @WithMockJwtAuth(authorities = ["ROLE_user"]) I expect the issuer to be recognized as known issuer and the role be assigned based on it. When I test my implementation with Postman, everything works fine, but the test fails.

MockHttpServletResponse:
           Status = 403
    Error message = null
          Headers = [Vary:"Origin", "Access-Control-Request-Method", "Access-Control-Request-Headers", WWW-Authenticate:"Bearer error="insufficient_scope", error_description="The request requires higher privileges than provided by the access token.", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1"", X-Content-Type-Options:"nosniff", X-XSS-Protection:"0", Cache-Control:"no-cache, no-store, max-age=0, must-revalidate", Pragma:"no-cache", Expires:"0", X-Frame-Options:"DENY"]
     Content type = null
             Body = 
    Forwarded URL = null
   Redirected URL = null
          Cookies = []

Any kind of help is highly apperciated.

2

There are 2 best solutions below

0
ch4mp On

First, note that your tests run probably only because the issuers which you configured are reachable on the network. With your current conf, there is no way to @MockBean the JwtDecoder instances.

This is pretty bad. You should expose the AuthManagerResolverProvider as a @Bean to be mocked in tests (currently the AuthManagerResolverProvider is built inside your SecurityFilterChain builder).

Second, you'd much better keep the default claim for authentication name (sub, optionally prefixed with something uniquely identifying the issuer if this issuer doesn't use UUIDs as subject). With your implementation, you can have:

  • null name for users: not all providers set the preferred_username claim
  • collisions between names: some providers have non unique preferred_username and it is quite likely that different providers have the same preferred_username

Third, with your implementation, a user having enough rights on any of the external issuers can grant himself with elevated privileges in your system. Let's consider a token with the following payload:

{
  "iss": "https://trusted.external.issuer",
  "realm_access": {
    "roles": [
      "admin"
    ]
  }
}

With your current authorities mapping, such a user will be granted the same admin authority as would an actual admin from your internal issuer...

Last it's curious to use @EnableMethodSecurity and define fine grained access-control to your controllers endpoints in conf.

Here is what I'd use as SecurityConf:

@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConf {

    @Bean
    SecurityFilterChain securityFilterChain(HttpSecurity http, AuthenticationManagerResolver<HttpServletRequest> authenticationManagerResolver)
            throws Exception {
        http
            .authorizeHttpRequests(
                auth -> auth
                    .requestMatchers(HttpMethod.GET, "/v3/api-docs.yaml")
                    .permitAll()
                    .anyRequest()
                    .authenticated())
            .oauth2ResourceServer(oauth2 -> oauth2.authenticationManagerResolver(authenticationManagerResolver))
            .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .cors(Customizer.withDefaults())
            .csrf(csrf -> csrf.disable());

        return http.build();
    }

    AuthenticationManagerResolver<HttpServletRequest> authenticationManagerResolver(
            JwtAuthConverterProperties props,
            Converter<Jwt, ? extends AbstractAuthenticationToken> jwtAuthenticationConverter) {

        final Map<String, AuthenticationManager> jwtManagers = props.issuers
            .stream()
            .collect(Collectors.toMap(issuerProps -> issuerProps.getUrl().toString(), issuerProps -> {
                final var decoder = NimbusJwtDecoder.withIssuerLocation(issuerProps.getUrl().toString()).build();
                decoder.setJwtValidator(JwtValidators.createDefaultWithIssuer(issuerProps.getUrl().toString()));
                final var provider = new JwtAuthenticationProvider(decoder);
                provider.setJwtAuthenticationConverter(jwtAuthenticationConverter);
                return provider::authenticate;
            }));

        return new JwtIssuerAuthenticationManagerResolver((AuthenticationManagerResolver<String>) jwtManagers::get);
    }

    @Component
    public static class JwtAuthConverter implements Converter<Jwt, JwtAuthenticationToken> {
        private final Map<URL, JwtAuthConverterProperties.IssuerProperties> props;

        public JwtAuthConverter(JwtAuthConverterProperties props) {
            this.props = props.getIssuerPropertiesByUrl();
        }

        @Override
        public JwtAuthenticationToken convert(Jwt jwt) {
            final var issuerProps = props.get(jwt.getIssuer());
            if (issuerProps == null) {
                throw new UnsupportedIssuerException(jwt.getIssuer());
            }
            final List<String> pathRoles = issuerProps.getRolesPath().map(p -> {
                final List<String> roles = JsonPath.read(jwt.getClaims(), p);
                return roles;
            }).orElse(List.of());

            final List<GrantedAuthority> authorities = Stream
                .concat(issuerProps.getDefaultRole().stream(), pathRoles.stream())
                .map(r -> (GrantedAuthority) new SimpleGrantedAuthority("ROLE_%s".formatted(r)))
                .toList();
            return new JwtAuthenticationToken(jwt, authorities);
        }
    }

    @Configuration
    @ConfigurationProperties(prefix = "jwt.auth")
    @Data
    public static class JwtAuthConverterProperties {
        List<JwtAuthConverterProperties.IssuerProperties> issuers = List.of();

        @Data
        static class IssuerProperties {
            private URL url;
            private Optional<String> defaultRole = Optional.empty();
            private Optional<String> rolesPath = Optional.empty();
        }

        public Map<URL, IssuerProperties> getIssuerPropertiesByUrl() {
            return issuers.stream().collect(Collectors.toMap(IssuerProperties::getUrl, p -> p));
        }
    }

    public static class UnsupportedIssuerException extends RuntimeException {
        private static final long serialVersionUID = -3202752256943326716L;

        UnsupportedIssuerException(URL issuer) {
            super("\"%s\" is not listed in jwt.auth.issuers properties".formatted(issuer == null ? "" : issuer.toString()));
        }
    }

    @ControllerAdvice
    public static class ExceptionHandlers {

        @ResponseStatus(code = HttpStatus.UNAUTHORIZED, reason = "Not a trusted issuer")
        @ExceptionHandler(UnsupportedIssuerException.class)
        public void handleUnsupportedIssuerException(UnsupportedIssuerException ex) {}

    }

}

with:

jwt:
  auth:
    issuers:
    - url: http://known.issuer/realms/test
      default-role: tester
    - url: http://localhost:8080/realms/test
      roles-path: $.realm_access.roles

and:

@RestController
@RequestMapping("/api/test")
public class TestController {

    @GetMapping("/greet")
    @PreAuthorize("hasRole('tester')")
    public String getGreet() {
        return "Hello!";
    }

}

This tests pass:

@WebMvcTest(controllers = TestController.class)
@Import(SecurityConf.class)
class TestControllerTest {
    @Autowired
    MockMvc mockMvc;

    @MockBean
    AuthenticationManagerResolver<HttpServletRequest> authenticationManagerResolver;

    @Test
    @WithAnonymousUser
    void givenUserIsAnonymous_whenGetGreet_thenUnauthorized() throws Exception {
        mockMvc.perform(get("/api/test/greet")).andExpect(status().isUnauthorized());
    }

    @Test
    @WithMockAuthentication()
    void givenUserHasMockedAuthenticationWithoutTesterRole_whenGetGreet_thenForbidden() throws Exception {
        mockMvc.perform(get("/api/test/greet")).andExpect(status().isForbidden());
    }

    @Test
    @WithMockAuthentication("ROLE_tester")
    void givenUserHasMockedAuthenticationWithTesterRole_whenGetGreet_thenOk() throws Exception {
        mockMvc.perform(get("/api/test/greet")).andExpect(status().isOk());
    }

    @Test
    @WithMockJwtAuth(claims = @OpenIdClaims(iss = "http://known.issuer/realms/test"))
    void givenUserHasMockedJwtAuthenticationWithForcedRole_whenGetGreet_thenOk() throws Exception {
        mockMvc.perform(get("/api/test/greet")).andExpect(status().isOk());
    }

    @Test
    @WithMockJwtAuth(claims = @OpenIdClaims(iss = "http://localhost:8080/realms/test", otherClaims = @Claims(jsonObjectClaims = @JsonObjectClaim(name = "realm_access", value = "{ \"roles\": [\"tester\"] }"))))
    void givenUserHasMockedJwtAuthenticationWithTesterRealmRole_whenGetGreet_thenOk() throws Exception {
        mockMvc.perform(get("/api/test/greet")).andExpect(status().isOk());
    }

    @Test
    @WithMockJwtAuth(claims = @OpenIdClaims(iss = "http://localhost:8080/realms/test", otherClaims = @Claims(jsonObjectClaims = @JsonObjectClaim(name = "realm_access", value = "{ \"roles\": [\"admin\"] }"))))
    void givenUserHasMockedJwtAuthenticationWithoutTesterRealmRole_whenGetGreet_thenForbidden() throws Exception {
        mockMvc.perform(get("/api/test/greet")).andExpect(status().isForbidden());
    }

    @Test
    @WithJwt("external_admin.json")
    void givenUserIsExternalAdmin_whenGetGreet_thenOk() throws Exception {
        mockMvc.perform(get("/api/test/greet")).andExpect(status().isOk());
    }

    @Test
    @WithJwt("internal_tester.json")
    void givenIsInternalTester_whenGetGreet_thenOk() throws Exception {
        mockMvc.perform(get("/api/test/greet")).andExpect(status().isOk());
    }

    @Test
    @WithJwt("internal_admin.json")
    void givenIsInternalAdmin_whenGetGreet_thenForbidden() throws Exception {
        mockMvc.perform(get("/api/test/greet")).andExpect(status().isForbidden());
    }

}

In my opinion, the last 3 tests decorated @WithJwt are easier to use and more readable than those with @WithMockJwtAuth. Here are the required test resources for the last 3 test methods:

  • external_admin.json
{
  "iss": "http://known.issuer/realms/test",
  "realm_access": {
    "roles": [
      "admin"
    ]
  }
}
  • internal_admin.json
{
  "iss": "http://localhost:8080/realms/test",
  "realm_access": {
    "roles": [
      "admin"
    ]
  }
}
  • internal_tester.json
{
  "iss": "http://localhost:8080/realms/test",
  "realm_access": {
    "roles": [
      "tester"
    ]
  }
}
0
naptoon On

Thank you very much @ch4mp! I just have to clarify some things ;) The provided code is only a first approach to implement the multi tennancy, not ready yet.

The case where we use prefered_username is only for users who are registered by ourselfs in our keycloak. We set this value on ourself. Tokens from the external IDPs are not relevant in this context and they are requesting totally different endpoints (as clients, not users) where the username doesn't matter at all. We also will use the audience (aud claim) in the external tokens to indeitify the clients more exclusively. All partners are using here a common aud value for the service.

Also the check for realm roles will be extended with a check for internal issuers. And we don't check for the admin role in this separated service.

I know now the mistakes I did and will test again. Thank you!