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 = []
任何类型的帮助都得到了高度评价。