1

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.

Saketh
  • 35
  • 7
  • if you wish to map a specific claim to a collection of `GrantedAuthorities` you should be using a `JwtGrantedAuthoritiesConverter` here is the docs for it https://docs.spring.io/spring-security/reference/servlet/oauth2/resource-server/jwt.html#oauth2resourceserver-jwt-authorization-extraction also, you need to enable spring security DEBUG logs and show us them because it says there the exact reason for the 403 – Toerktumlare Apr 27 '23 at 14:19
  • The link you shared is for the Servlet stack. I'm using the Reactive stack (WebFlux). However, the mapping works fine. I was able to get the Authorization working when the OAuth2 Client and OAuth2 Resource Server ran as separate applications. – Saketh Apr 27 '23 at 14:53
  • the interface is used for both as it is stack independent otherwise i wouldn't have linked it. Just because it is documented in the servlet side of the documentation doesnt mean it isnt usable for reactive. – Toerktumlare Apr 27 '23 at 14:54
  • 1
    No, the interfaces are not the same: servlet JWT configurer expects `Converter` when the reactive one expects a `Converter>`. Plus the issue here is because of `oauth2Login`, the exchange is secured with sessions (not JWTs) and authorities would be mapped with a `GrantedAuthoritiesMapper` or a `ReactiveOAuth2UserService` – ch4mp Apr 27 '23 at 18:11

2 Answers2

1

You are facing the usual issues when trying to mix OAuth2 client and OAuth2 resource server configuration in the same security filter-chain: oauth2Login is a client matter and it requires sessions. Because you are using it, your filter-chain is a "client" filter-chain and the security is based on sessions, not on access tokens. This is the reason why the authentication converter you configured with your JWT resource server is not called.

Two options:

  • if your Spring application is just a REST API, then it should not have the responsibility of login which, again, is client business (Postman, mobile app, a BFF like spring-cloud-gateway, JS application configured as OAuth2 public client, ...). Remove oauth2login, disable sessions (STATELESS session management), and probably disable CSRF protection too (CSRF is an attack using sessions).
  • if your application is both a REST API and the client consuming it (with Thymeleaf templates or alike) then define 2 distinct security filter-chains (with @Order and a securityMatcher on the first in order to limit which endpoints it applies to and give a chance to second to serve as fallback for all other endpoints):

You'll find more theory and implementation details in my tutorials (do not skip OAuth2 essentials)

ch4mp
  • 6,622
  • 6
  • 29
  • 49
  • I've done exactly the same , combining oauth2login with oauth2ResourceServer similar to the post. But for servlet based spring application. It is working as expected including converter. I can login with OAuth2 and JWT. – Vlad Ulshin Apr 27 '23 at 19:49
  • What do you mean exactly? That when you login with `oauth2Login` with your browser, following requests are auto-magically decorated with a JWT access token and the JWT authentication converter called? I'd be curious to see this working. – ch4mp Apr 27 '23 at 21:05
  • it looks like this : http.oauth2Login().oauth2ResourceServer().jwt().jwtAuthenticationConverter(JwtAuthenticationConverter); browser get oauth2Login (to azure ad) and api use JWT in authentication header request to login into the same system. – Vlad Ulshin Apr 27 '23 at 23:30
  • I can bet beer that, in your application, requests from browsers using Spring `oauth2Login` are secured with sessions (not JWTs) and that this requests don't hit your `jwtAuthenticationConverter`. Just inspect your browser headers (no Authorization header with a JWT) and set a break-point in your authentication converter, it won't be hit when your server receives a request from a browser using `oauth2Login` (it is hit only for requests by an OAuth2 client, which have their own mechanism to get access tokens). – ch4mp Apr 27 '23 at 23:55
  • I am arguing against your statement that " You are facing the usual issues when trying to mix OAuth2 client and OAuth2 resource server configuration in the same security filter-chain:" you can combine both on the same filter-chain. – Vlad Ulshin Apr 28 '23 at 00:05
  • The issue reported here is that the configured authentication converter is not called (and Keycloak roles mapped to Spring authorities) after oauth2Login. Your configuration does not solve that at all. – ch4mp Apr 28 '23 at 00:31
  • Thank you for pointing out the issue. I am currently going through your tutorials repo. The reason behind merging the OAuth2 Client and the OAuth2 Resource Server was to have a single application in which I can add a WebSocket server. My frontend will have a WebSocket client that will connect to my Spring Boot backend. I started with a REST API poc and encountered the above problem. Do you think I can have the OAuth2 Client and the OAuth2 Resource Server as separate applications when securing a WebSocket endpoint? Could you please shed some light on this. – Saketh Apr 28 '23 at 05:59
1

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.

Vlad Ulshin
  • 482
  • 2
  • 5
  • Thank you so much. You're a savior. Using `hasAnyAuthority("SCOPE_library_admin","ROLE_library_admin")` got the authorization working. However, the JwtAuthenticationConverter still doesn't get called. So, how are the roles getting mapped in this case? – Saketh Apr 29 '23 at 02:59
  • So, is this session based now? `Found SecurityContext 'SecurityContextImpl [Authentication=OAuth2AuthenticationToken [Principal=Name: [Clark Kent], Granted Authorities: [[ROLE_USER, SCOPE_email, SCOPE_library_admin, SCOPE_openid, SCOPE_profile]], User Attributes: [...], Credentials=[PROTECTED], Authenticated=true, Details=null, Granted Authorities=[ROLE_USER, SCOPE_email, SCOPE_library_admin, SCOPE_openid, SCOPE_profile]]]' in WebSession: 'org.springframework.web.server.session.InMemoryWebSessionStore$InMemoryWebSession@7f2cc6d5'` – Saketh Apr 29 '23 at 03:04
  • I commented out the `LibraryUserJwtAuthenticationConverter` bean and removed it from the SecurityConfiguration and the authorization was still working when I used `hasAnyAuthority("SCOPE_library_admin","ROLE_library_admin")` – Saketh Apr 29 '23 at 03:10
  • @Saketh How do you access your application? by browser or httpclient? JWT filter expect request with Bearer Authorization token (not present in browser request). hasRole(Role.LIBRARY_ADMIN.name()) would match "ROLE_" + Role.LIBRARY_ADMIN.name() against what you see in Granted Authorities:[] list. hasAnyAuthority(authorities ) matched authorities verbatim, therefore I have "SCOPE_library_admin" - for browser (Oauth2Login) and "ROLE_library_admin" for JWT with converter. – Vlad Ulshin Apr 29 '23 at 03:57
  • Okay, thank you for the explanation. I'm accessing the application through the browser. My next step would be to add an Angular frontend and communicate with the backend. – Saketh Apr 29 '23 at 10:19