Search code examples
javaspring-securitykeycloakspring-webfluxrole-based-access-control

JwtAuthenticationConverter not invoked post authentication [Spring WebFlux + Spring Security + Keycloak]


My JwtAuthenticationConverter is not invoked and I get a 403 Error post login.

2023-04-27 20:59:15.780 DEBUG 11448 --- [     parallel-1] athPatternParserServerWebExchangeMatcher : Checking match of request : '/users'; against '/users/**'
2023-04-27 20:59:15.780 DEBUG 11448 --- [     parallel-1] o.s.s.w.s.u.m.OrServerWebExchangeMatcher : matched
2023-04-27 20:59:15.780 DEBUG 11448 --- [     parallel-1] a.DelegatingReactiveAuthorizationManager : Checking authorization on '/users' using org.springframework.security.authorization.AuthorityReactiveAuthorizationManager@4e0f321f
2023-04-27 20:59:15.785 DEBUG 11448 --- [     parallel-1] ebSessionServerSecurityContextRepository : No SecurityContext found in WebSession: 'org.springframework.web.server.session.InMemoryWebSessionStore$InMemoryWebSession@1e8ad729'
2023-04-27 20:59:15.785 DEBUG 11448 --- [     parallel-1] o.s.s.w.s.a.AuthorizationWebFilter       : Authorization failed: Access Denied

  1. LibraryUserJwtAuthenticationConverter
package com.example.oidc.client.config;

import org.springframework.core.convert.converter.Converter;
import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter;
import reactor.core.publisher.Mono;

import java.util.Collection;
import java.util.Collections;
import java.util.Optional;
import java.util.stream.Collectors;

import static java.util.Collections.emptySet;

/**
 * JWT converter that takes the roles from 'groups' claim of JWT token.
 */
public class LibraryUserJwtAuthenticationConverter
        implements Converter<Jwt, Mono<AbstractAuthenticationToken>> {
    private static final String GROUPS_CLAIM = "groups";
    private static final String ROLE_PREFIX = "ROLE_";
    private static final String USERNAME_CLAIM = "preferred_username";

    private final Converter<Jwt, Collection<GrantedAuthority>> defaultAuthoritiesConverter = new JwtGrantedAuthoritiesConverter();

    @Override
    public Mono<AbstractAuthenticationToken> convert(Jwt jwt) {
        return Mono.just(extractAuthorities(jwt))
                .map((authorities) -> new JwtAuthenticationToken(jwt, authorities, extractUsername(jwt)));

    }

    private Collection<GrantedAuthority> extractAuthorities(Jwt jwt) {
        Collection<GrantedAuthority> authorities = this.getScopes(jwt).stream()
                .map(authority -> ROLE_PREFIX + authority.toUpperCase())
                .map(SimpleGrantedAuthority::new)
                .collect(Collectors.toSet());

        authorities.addAll(defaultGrantedAuthorities(jwt));

        return authorities;
    }

    private Collection<GrantedAuthority> defaultGrantedAuthorities(Jwt jwt) {
        return Optional.ofNullable(defaultAuthoritiesConverter.convert(jwt))
                .orElse(emptySet());
    }

    private String extractUsername(Jwt jwt) {
        return jwt.hasClaim(USERNAME_CLAIM) ? jwt.getClaimAsString(USERNAME_CLAIM) : jwt.getSubject();
    }

    @SuppressWarnings("unchecked")
    private Collection<String> getScopes(Jwt jwt) {
        Object scopes = jwt.getClaims().get(GROUPS_CLAIM);
        if (scopes instanceof Collection) {
            return (Collection<String>) scopes;
        }

        return Collections.emptyList();
    }
}

  1. SecurityConfiguration
package com.example.oidc.client.config;

import com.example.oidc.client.common.Role;
import org.springframework.boot.actuate.autoconfigure.security.reactive.EndpointRequest;
import org.springframework.boot.autoconfigure.security.reactive.PathRequest;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.configuration.EnableReactiveMethodSecurity;
import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity;
import org.springframework.security.config.web.server.ServerHttpSecurity;
import org.springframework.security.web.server.SecurityWebFilterChain;

@EnableWebFluxSecurity
@EnableReactiveMethodSecurity
@Configuration
public class SecurityConfiguration {

    @Bean
    SecurityWebFilterChain configure(ServerHttpSecurity http) {
        http
                .csrf()
                .disable()
                .authorizeExchange()
                .matchers(PathRequest.toStaticResources().atCommonLocations())
                .permitAll()
                .matchers(EndpointRequest.to("health"))
                .permitAll()
                .matchers(EndpointRequest.to("info"))
                .permitAll()
                .pathMatchers("/users/**")
                .hasRole(Role.LIBRARY_ADMIN.name())
                .anyExchange()
                .authenticated()
                .and()
                .oauth2Login()
                .and()
                .oauth2ResourceServer()
                .jwt()
                .jwtAuthenticationConverter(libraryUserJwtAuthenticationConverter());

        return http.build();
    }

    @Bean
    public LibraryUserJwtAuthenticationConverter libraryUserJwtAuthenticationConverter() {
        return new LibraryUserJwtAuthenticationConverter();
    }
}

  1. Application Settings
server:
  port: 9090
  error:
    include-stacktrace: never

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: http://localhost:8081/auth/realms/workshop
      client:
        registration:
          keycloak:
            client-id: 'library-client'
            client-secret: 'XXXX640c-XXXX-4dcd-997b-XXXXcfb9ea7'
            authorization-grant-type: authorization_code
            redirect-uri: 'http://localhost:9090/login/oauth2/code/keycloak'
            scope: 'openid'
        provider:
          keycloak:
            issuer-uri: http://localhost:8081/auth/realms/workshop
            user-name-attribute: name

  1. UserRestController
@RestController
public class UserRestController {

    @GetMapping("/users")
    public String users() {
        return "Only the admin should be able to see this";
    }
}

  1. Role
package com.example.oidc.client.common;

public enum Role {
  LIBRARY_USER,

  LIBRARY_CURATOR,

  LIBRARY_ADMIN
}

enter image description here

enter image description here

enter image description here

I tried an example where the OAuth2 Client and the OAuth2 Resource Server are separate projects. The OAuth2 Client makes a request using a configured WebClient to the protected resource on the OAuth2 Resource Server. In this case the JwtAuthenticationConverter is invoked and authorization works as expected.

Here's a link to the example with OAuth2 Client and OAuth2 Resource as separate projects: https://github.com/sakethsusarla/webflux-keycloak-struggle

However, when I merged the OAuth2 Client and the OAuth2 Resource Server into one application, the JwtAuthenticationConverter doesn't get invoked at all and access to a protected resource results in a 403. Above are all the files from a simple example I've written. Could someone please tell me if something's missing here?

I've gone through multiple GitHub repositories to verify my configuration but haven't been able to find the missing piece. There's very limited documentation regarding configuring Keycloak with Spring Security for Reactive applications.

The Keycloak realm json file is present here: https://github.com/sakethsusarla/webflux-keycloak-struggle/blob/main/keycloak_realm_workshop.json

I'm using the same client, user and roles for this example. No changes there.


Solution

  • To hit JWT converter on your "merge-oauth2-client-resource-server" branch, you shall use some kind of http client. IntelliJ HttpClient tool use the following just change Keycloack URL:port and password:

    POST http://localhost:8080/realms/workshop/protocol/openid-connect/token

    Accept: application/x-www-form-urlencoded

    grant_type=password&username=ckent&password=xxxxx&client_id=library-client&client_secret=9584640c-3804-4dcd-997b-93593cfb9ea7

    you should get back something similar to:

    { "access_token":"eyJhbGciOiJSUzI ....XXX",
      "expires_in": 300,
      "refresh_expires_in": 1800,
      "refresh_token":"eyJhbGciOiJIUzI1NiI.....DlaezRxzyuNxfhve8StULE",
      "token_type": "Bearer",
      "not-before-policy": 1571836504,
      "session_state": "1169f9c3-5f90-41f6-a89c-23abe78491c4",
      "scope": "library_admin email profile"
    }
    

    Grab the access_token and use it as Berarer authorization token

    GET http://localhost:9090/users
    Accept: application/json
    Authorization: Bearer eyJhbGciOiJSUzI ....XXX
    

    you shall get a response from your rest controller "Only the admin should be able to see this"

    If you wish to use browser to test oauth2Login change SecurityConfiguration

    .hasRole(Role.LIBRARY_ADMIN.name()) to .hasAnyAuthority("SCOPE_library_admin","ROLE_library_admin")

    Now JWT and browser login (http://localhost:9090/users) shall work at the same time.