Using and customizing spring-security-oauth2-resource-server with Spring Boot

As you might know, the old spring-security-oauth2-autoconfigure was deprecated and replaced by spring-security-oauth2-resource-server. Here I will try to demonstrate how to use it in a typical setup: web server (as a client, using authorization_code grant type) - authorization server - resource server, with some customization (loading additional properties from the check_token and user info endpoints).

The Authorization Server

A lot has been going on in the spring framework about this, so I am not going to put much details here. You can use any authorization server implementation, including the deprecated one from spring-security-oauth2 or the new one spring-security-oauth2-authorization-server or any other non-spring implementation, that follows the oauth 2.0/2.1 specification.

All of the following examples are based on spring-boot and using spring-security.

The Resource Server

Dependencies

<dependency>
    <groupId>org.springframework.security</groupId>
    <artifactId>spring-security-oauth2-resource-server</artifactId>
</dependency>
<dependency>
    <groupId>com.nimbusds</groupId>
    <artifactId>oauth2-oidc-sdk</artifactId>
</dependency>

Configuration Code

I am going to use the opaqueToken with a extended version of the NimbusOpaqueTokenIntrospector

@Configuration
public class ResourceServerConfig {

    @Value("${app.auth_server.url_check_token}")
    String introspectionUrl;

    @Value("${app.auth_server.client_id}")
    String clientId;

    @Value("${app.auth_server.client_secret}")
    String clientSecret;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        return http
                .authorizeRequests(authorize -> authorize
                                .antMatchers("/actuator/**").permitAll()
                                .antMatchers("/v1/my-res-server/api/public/**").permitAll()
                                .antMatchers("/v1/my-res-server/api/**").authenticated()
                                .anyRequest().denyAll()
                )
                .oauth2ResourceServer(OAuth2ResourceServerConfigurer::opaqueToken)
                .build();
    }

    @Bean
    public ExtendedNimbusIntrospector extendedNimbusIntrospector() {
        return new ExtendedNimbusIntrospector(
                new NimbusOpaqueTokenIntrospector(this.introspectionUrl, this.clientId, this.clientSecret));
    }
}

Above I am also configuring the access to the endpoints, including the access to the actuator endpoints and the access to the public and restricted endpoints of the resource server.

Because I want to extract more data about an access token's owner, I am extending the NimbusOpaqueTokenIntrospector. While it is of course possible to do so, by just extending the class, I prefer injecting the instance into a wrapper, which allows me to mock it in unit tests.

public class ExtendedNimbusIntrospector implements OpaqueTokenIntrospector {

    private final NimbusOpaqueTokenIntrospector baseIntrospector;

    public ExtendedNimbusIntrospector(NimbusOpaqueTokenIntrospector baseIntrospector) {
        this.baseIntrospector = baseIntrospector;
    }

    @Override
    public OAuth2AuthenticatedPrincipal introspect(String token) {
        OAuth2AuthenticatedPrincipal principal = baseIntrospector.introspect(token);
        JSONArray authorities = (JSONArray) principal.getAttributes().get("authorities");
        JSONArray scopes = (JSONArray) principal.getAttributes().get("scope");
        List<GrantedAuthority> allAuthorities = new ArrayList<>();
        List<GrantedAuthority> extractedAuthorities = getAuthorities(authorities);
        List<GrantedAuthority> extractedScopes = getScopes(scopes);
        allAuthorities.addAll(extractedAuthorities);
        allAuthorities.addAll(extractedScopes);
        return new DefaultOAuth2AuthenticatedPrincipal((String) principal.getAttributes().get("user_name"),
                principal.getAttributes(), allAuthorities);
    }

    private List<GrantedAuthority> getScopes(JSONArray scopes) {
        if (scopes == null) {
            return Collections.emptyList();
        }
        return scopes.stream().map(a -> new SimpleGrantedAuthority("SCOPE_" + a))
                .collect(Collectors.toList());
    }

    private List<GrantedAuthority> getAuthorities(JSONArray authorities) {
        if (authorities == null) {
            return Collections.emptyList();
        }
        return authorities.stream().map(a -> new SimpleGrantedAuthority((String) a))
                .collect(Collectors.toList());
    }
}

The above introspector will extract authorities and scopes from the access token's attributes and add them to the Authentication.

YAML config

All we need to configure here are the client id and scret and the introspection URL:
app:
  auth_server:
    url_check_token: https://localhost/auth/check_token
    client_id: my_client
    client_secret: my_secret

The Client (web server)

Dependencies

<dependency>
    <groupId>org.springframework.security</groupId>
    <artifactId>spring-security-oauth2-client</artifactId>
</dependency>

Configuration Code

Similar to the resource server, below I am configuring the session, the access to the endpoints and the oauth2 login and logout, again with an extended OAuth2UserService for the user info endpoint:

@Configuration
public class SecurityConfig {

    private static final String LOGIN_PATH = "/login";

    @Value("${app.auth-service-url}")
    private String authServiceHost;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        return http
            .sessionManagement()
                .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
                .sessionFixation().migrateSession()
                .invalidSessionUrl("/")
                .sessionAuthenticationErrorUrl("/")
                .and()
            .authorizeRequests()
                .antMatchers("/actuator/**").permitAll()
                .antMatchers("/login**", "/error").permitAll()
                .antMatchers("/register/**", "/reset-password/**").permitAll()
                .antMatchers("/**/*.png", "/**/*.svg", "/**/*.jpg",
                        "/**/*.css", "/**/*.js",
                        "/**/*.ico").permitAll()
                .anyRequest().authenticated()
                .and()
            .oauth2Login()
                .loginProcessingUrl(LOGIN_PATH)
                .userInfoEndpoint()
                    .userService(new ExtendedDefaultOAuth2UserService(new DefaultOAuth2UserService()))
                    .and()
                .and()
            .logout()
                .logoutRequestMatcher(new AntPathRequestMatcher("/logout"))
                .logoutSuccessUrl(authServiceHost + "/auth/logout")
                .invalidateHttpSession(true)
                .deleteCookies(COOKIE_NAME)
                .and()
            .build();
    }
}

The extended OAuth2UserService looks like this:

class ExtendedDefaultOAuth2UserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {

    private final DefaultOAuth2UserService baseService;

    public ExtendedDefaultOAuth2UserService(DefaultOAuth2UserService baseService) {
        this.baseService = baseService;
    }

    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
        final OAuth2User oAuth2User = baseService.loadUser(userRequest);

        Set<GrantedAuthority> mappedAuthorities = new HashSet<>();

        final List<String> authoritiesArray = oAuth2User.getAttribute("authorities");
        if (authoritiesArray == null) {
            return oAuth2User;
        }

        mappedAuthorities.addAll(
                authoritiesArray.stream()
                        .map(SimpleGrantedAuthority::new)
                        .toList());
        mappedAuthorities.addAll(oAuth2User.getAuthorities());

        return new DefaultOAuth2User(mappedAuthorities, oAuth2User.getAttributes(), "user");
    }
}

and it also adds the authorities to the Authentication, extracting them from the attributes.

Now, that we have secured the client, it must also be configured to delegate the access token, stored in the session, to any resource server calls. This can be achieved by configuring a WebClient bean with a filter, that would insert it on each call:

@Configuration
public class WebClientConfig {

    @Autowired
    private SecurityConfig securityConfig;

    @Autowired
    private OAuth2AuthorizedClientRepository authorizedClientRepository;

    @Bean
    public WebClient rest() {
        return WebClient.builder()
                .filter(new WebClientAccessTokenExchangeFilterFunction(authorizedClientRepository))
                .build();
    }
}

where the filter function looks like this:

public class WebClientAccessTokenExchangeFilterFunction implements ExchangeFilterFunction {

    private final OAuth2AuthorizedClientRepository authorizedClientRepository;

    public WebClientAccessTokenExchangeFilterFunction(OAuth2AuthorizedClientRepository authorizedClientRepository) {
        this.authorizedClientRepository = authorizedClientRepository;
    }

    @Override
    public Mono<ClientResponse> filter(ClientRequest request, ExchangeFunction next) {

        RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes();
        if (requestAttributes != null) {
            HttpServletRequest servletRequest = ((ServletRequestAttributes) requestAttributes).getRequest();

            OAuth2AuthenticationToken oauth2Token = (OAuth2AuthenticationToken)
                    SecurityContextHolder.getContext().getAuthentication();

            OAuth2AuthorizedClient client = authorizedClientRepository
                    .loadAuthorizedClient(oauth2Token.getAuthorizedClientRegistrationId(), oauth2Token, servletRequest);

            return next.exchange(ClientRequest.from(request).headers(headers ->
                    headers.setBearerAuth(client.getAccessToken().getTokenValue())).build());
        }

        return next.exchange(request);
    }
}

and the repository bean looks like this:

@Configuration
public class ClientRepositoryConfig {

    @Bean
    public OAuth2AuthorizedClientRepository authorizedClientRepository() {
        return new HttpSessionOAuth2AuthorizedClientRepository();
    }
}

What happens above is that the access token and the authorized client are loaded from the session and the settings and the access token is inserted in the Authorization header.

YAML config

app:
  auth-service-url: https://localhost

spring:
  security:
    oauth2:
      client:
        registration:
          SomeName:
            client-name: SomeName
            client-id: myClient
            client-secret: mySecret
            client-authentication-method: client_secret_basic
            authorization-grant-type: authorization_code
            redirect-uri: https://localhost/login
            scope: user-actions
        provider:
          SomeName:
            authorization-uri: https://localhost/auth/authorize
            token-uri: https://localhost/auth/token
            user-info-uri: https://localhost/v1/my-res-server/api/me
            user-info-authentication-method: header
            user-name-attribute: user

The User Info Endpoint

This endpoint must be in a resource-server (like the one described above) and protected by oauth authentication. The client (the web-server) would call this endpoint using the access token of the logging-in user in order to obtain information about the user.

It could look like this:

@RestController
public class UserInfoEndpointController {

    @RequestMapping(value = { "/v1/my-res-server/api/me" }, produces = "application/json")
    @ResponseStatus(HttpStatus.OK)
    public UserInfoDto me(@AuthenticationPrincipal OAuth2AuthenticatedPrincipal principal) {
        return new UserInfoDto(
                principal.getName(),
                AuthorityUtils.authorityListToSet(
                        principal.getAuthorities()));
    }
}

public class UserInfoDto {
    private final String user;
    private final Set<String> authorities;

    @JsonCreator
    public UserInfoDto(@JsonProperty("user") String user, @JsonProperty("authorities") Set<String> authorities) {
        this.user = user;
        this.authorities = authorities;
    }

    public String getUser() {
        return user;
    }

    public Set<String> getAuthorities() {
        return authorities;
    }
}

When opened, the web-server's endpoints would redirect the browser to the authorization-server for the authorization_code login. After the user is successfully logged-in, the web-server's endpoints can call and get information from the resource-server using the logged-in user's authentication.