Search code examples
spring-bootkotlinoauth-2.0keycloak

Oauth2 resource server with Keycloack - cannot map roles


I try to write auth application using Keycloack and Oauth2 resource server and still cannot recognize mapped roles:

plugins {
    id("org.springframework.boot") version "2.7.8-SNAPSHOT"
}

implementation("org.springframework.boot:spring-boot-starter-oauth2-resource-server")
implementation("com.c4-soft.springaddons:spring-addons-webmvc-jwt-resource-server:5.3.2")

part of JWT:

 "allowed-origins": [],
  "realm_access": {
    "roles": [
      "user"
    ]
  },
  "resource_access": {
    "myapp": {
      "roles": [
        "user"
      ]
    },
    "account": {
      "roles": [
        "manage-account",
        "manage-account-links",
        "view-profile"
      ]
    }
  },

Config class:

@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true, jsr250Enabled = true)
class JWTSecurityConfig {

    @Bean
    fun filterChain(http: HttpSecurity): SecurityFilterChain =
        http
            .cors()
            .and()
            .authorizeRequests { auth ->
                auth.antMatchers(HttpMethod.GET, "/user")
                    .hasRole("user")
                    .antMatchers(HttpMethod.GET, "/admin")
                    .hasRole("admin")
                    .anyRequest()
                    .authenticated()
            }
            .oauth2ResourceServer(OAuth2ResourceServerConfigurer<HttpSecurity>::jwt)
            .build()

    @Bean
    fun jwtAuthenticationConverterForKeycloak(): JwtAuthenticationConverter? {
        val jwtGrantedAuthoritiesConverter =
            Converter<Jwt, Collection<GrantedAuthority>> { jwt: Jwt ->
                val realmAccess = jwt.getClaim<Map<String, Collection<String>>>("realm_access")
                val roles = realmAccess["roles"]!!
                roles.stream()
                    .map { role -> SimpleGrantedAuthority("ROLE_$role") }
                    .collect(Collectors.toList())
            }
        
        val jwtAuthenticationConverter = JwtAuthenticationConverter()
        jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(jwtGrantedAuthoritiesConverter)
        return jwtAuthenticationConverter
    }
}

application.yml:

spring:
  security:
    oauth2:
      resource-server:
        jwt:
          issuer-uri: http://localhost:8181/auth/realms/myapp
          jwk-set-uri: http://localhost:8181/auth/realms/myapp/protocol/openid-connect/certs

com:
  c4-soft:
    springaddons:
      security:
        issuers[0]:
          location: http://localhost:8181/auth/realms/myapp
          authorities:
            claims: realm_access.roles,resource_access.my-app.roles,resource_access.account.roles
            prefix: ROLE_
        cors[0]:
          path: /user
        cors[1]:
          path: /admin

RestController:

@RestController
class TestController {

    @GetMapping("/user")
    @PreAuthorize("hasRole('user')")
    fun helloUser(): ResponseEntity<Foo> =  ResponseEntity.ok().body(Foo("Hello User"))

    @GetMapping("/admin")
    @PreAuthorize("hasRole('admin')")
    fun helloAdmin(): ResponseEntity<Foo> =  ResponseEntity.ok().body(Foo("Hello Admin"))
}

data class Foo(val value: String)

in this config my

@GetMapping("/auth")
    fun auth(jwt : JwtAuthenticationToken) = jwt.authorities.map(GrantedAuthority::getAuthority);

generates:

[
    "ROLE_USER",
    "ROLE_USER",
    "ROLE_MANAGE-ACCOUNT",
    "ROLE_MANAGE-ACCOUNT-LINKS",
    "ROLE_VIEW-PROFILE"
]

I have no idea what's going on with this code. I found this "working' solution in another question but is not working for me. Has someone idea what can be wrong ? Users with role "admin" can get correctly response (200) from /user


Solution

  • You have a case issue: your roles are lowercase in the access token and you expect uppercase roles in your resource-server.

    Two solutions:

    • change @PreAuthorize SpEL and authorizeRequests from hasRole("USER") and hasRole("ADMIN") to hasRole("user") and hasRole("admin")
    • update jwtAuthenticationConverter to transform roles to uppercase

    P.S. Have a look at the starters I maintain. Your resource-server config could be as simple as (I remove the authorizeRequests to keep only @PreAuthorize):

    package com.c4soft
    
    import org.springframework.context.annotation.Configuration
    import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity
    
    @Configuration
    @EnableMethodSecurity
    class JWTSecurityConfig {
    }
    
    com:
      c4-soft:
        springaddons:
          security:
            issuers:
              - location: http://localhost:8181/auth/realms/myapp
                authorities:
                  claims:
                    - realm_access.roles
                    - resource_access.myapp.roles
                    - resource_access.account.roles
                  caze: upper
                  prefix: ROLE_
            cors:
              - path: /user
              - path: /admin
              - path: /auth
    

    And now the rest for a complete YAML / Kotlin / Gradle project (only the boot app class is skipped):

    import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
    
    plugins {
        id("org.springframework.boot") version "3.0.1"
        id("io.spring.dependency-management") version "1.1.0"
        kotlin("jvm") version "1.7.22"
        kotlin("plugin.spring") version "1.7.22"
    }
    
    group = "com.example"
    version = "0.0.1-SNAPSHOT"
    java.sourceCompatibility = JavaVersion.VERSION_17
    
    repositories {
        mavenCentral()
    }
    
    dependencies {
        implementation("org.springframework.boot:spring-boot-starter-web")
        implementation("com.c4-soft.springaddons:spring-addons-webmvc-jwt-resource-server:6.0.10")
        implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
        implementation("org.jetbrains.kotlin:kotlin-reflect")
        implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8")
        testImplementation("org.springframework.boot:spring-boot-starter-test")
    }
    
    tasks.withType<KotlinCompile> {
        kotlinOptions {
            freeCompilerArgs = listOf("-Xjsr305=strict")
            jvmTarget = "17"
        }
    }
    
    tasks.withType<Test> {
        useJUnitPlatform()
    }
    
    package com.c4soft
    
    import org.springframework.http.ResponseEntity
    import org.springframework.security.access.prepost.PreAuthorize
    import org.springframework.security.core.GrantedAuthority
    import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken
    import org.springframework.web.bind.annotation.GetMapping
    import org.springframework.web.bind.annotation.RestController
    
    @RestController
    class TestController {
    
        @GetMapping("/user")
        @PreAuthorize("hasRole('USER')")
        fun helloUser(): ResponseEntity<Foo> =  ResponseEntity.ok().body(Foo("Hello User"))
    
        @GetMapping("/admin")
        @PreAuthorize("hasRole('ADMIN')")
        fun helloAdmin(): ResponseEntity<Foo> =  ResponseEntity.ok().body(Foo("Hello Admin"))
    
        @GetMapping("/auth")
        fun auth(jwt : JwtAuthenticationToken) = jwt.authorities.map(GrantedAuthority::getAuthority);
    }
    
    data class Foo(val value: String)
    

    Other thing, with hasAuthority('user') instead of hasRole('USER'), you wouldn't have to force case and prefix.