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.
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
@MockBeantheJwtDecoderinstances.This is pretty bad. You should expose the
AuthManagerResolverProvideras a@Beanto be mocked in tests (currently theAuthManagerResolverProvideris built inside yourSecurityFilterChainbuilder).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:preferred_usernameclaimpreferred_usernameand it is quite likely that different providers have the samepreferred_usernameThird, 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:
With your current authorities mapping, such a user will be granted the same
adminauthority as would an actual admin from your internal issuer...Last it's curious to use
@EnableMethodSecurityand define fine grained access-control to your controllers endpoints in conf.Here is what I'd use as SecurityConf:
with:
and:
This tests pass:
In my opinion, the last 3 tests decorated
@WithJwtare easier to use and more readable than those with@WithMockJwtAuth. Here are the required test resources for the last 3 test methods:external_admin.jsoninternal_admin.jsoninternal_tester.json