Expected Behavior

Configure OAuth 2.0 UserService like the following as described in Spring Security Docs

package net.jaggerwang.scip.gateway.adapter.api.config;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.convert.converter.Converter;
import org.springframework.security.authentication.ReactiveAuthenticationManager;
import org.springframework.security.authentication.UserDetailsRepositoryReactiveAuthenticationManager;
import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity;
import org.springframework.security.config.web.server.ServerHttpSecurity;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.authority.mapping.GrantedAuthoritiesMapper;
import org.springframework.security.core.userdetails.ReactiveUserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserRequest;
import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserService;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserService;
import org.springframework.security.oauth2.client.web.server.ServerOAuth2AuthorizedClientRepository;
import org.springframework.security.oauth2.client.web.server.WebSessionServerOAuth2AuthorizedClientRepository;
import org.springframework.security.oauth2.core.oidc.user.DefaultOidcUser;
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter;
import org.springframework.security.web.server.SecurityWebFilterChain;
import org.springframework.security.web.server.authentication.HttpStatusServerEntryPoint;
import reactor.core.publisher.Mono;

import java.util.Collection;
import java.util.Collections;
import java.util.HashSet;
import java.util.Map;
import java.util.stream.Collectors;
import java.util.stream.Stream;

/**
 * @author Jagger Wang
 */
@Configuration
@EnableWebFluxSecurity
public class SecurityConfig {
    @Value("${spring.security.oauth2.resourceserver.resourceId}")
    private String resourceId;

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    public ReactiveAuthenticationManager reactiveAuthenticationManager(
            ReactiveUserDetailsService userDetailsService) {
        var authenticationManager = new UserDetailsRepositoryReactiveAuthenticationManager(
                userDetailsService);
        authenticationManager.setPasswordEncoder(passwordEncoder());
        return authenticationManager;
    }

    /**
     * Store access token in session.
     */
    @Bean
    ServerOAuth2AuthorizedClientRepository authorizedClientRepository() {
        return new WebSessionServerOAuth2AuthorizedClientRepository();
    }

    /**
     * Map authorities in OAuth2User or OidcUser to authorities of OAuth2AuthenticationToken, this
     * is triggered when client got an access token.
     */
    @Bean
    public GrantedAuthoritiesMapper grantedAuthoritiesMapper() {
        return authorities -> {
            // Just for an example
            var mappedAuthorities = new HashSet<GrantedAuthority>();
            authorities.forEach(authoritie -> {
                mappedAuthorities.add(authoritie);
            });
            return mappedAuthorities;
        };
    }

    /**
     * Customize OAuth2User (or OidcUser if using OpendID Connect protocol), such as bind user from
     * OAuth2 provider to a client's inner user, and get authorities of this inner user.
     */
    private OAuth2UserService<OidcUserRequest, OidcUser> oAuth2UserService() {
        var delegate = new OidcUserService();
        return userRequest -> {
            var oidcUser = delegate.loadUser(userRequest);

            var accessToken = userRequest.getAccessToken();
            var mappedAuthorities = new HashSet<GrantedAuthority>();

            // TODO
            // 1) Bind user from OAuth2 provider to a client's inner user.
            // 2) Get authorities from this inner user.

            oidcUser = new DefaultOidcUser(mappedAuthorities, oidcUser.getIdToken(),
                    oidcUser.getUserInfo());

            return oidcUser;
        };
    }

    /**
     * Convert a JWT issued by an OAuth2 service to an Authentication, this is triggered when
     * resource server received an access token. here you can extract resource roles in JWT or get
     * roles from other service.
     */
    private Converter<Jwt, Mono<JwtAuthenticationToken>> jwtAuthenticationConverter() {
        return jwt -> {
            var authorities = new JwtGrantedAuthoritiesConverter().convert(jwt);
            var resourceAccess = (Map<String, Object>) jwt.getClaim("resource_access");
            if (resourceAccess != null) {
                var resource = (Map<String, Object>) resourceAccess.get(resourceId);
                if (resource != null) {
                    var roles = (Collection<String>) resource.get("roles");
                    if (roles != null) {
                        authorities = Stream.concat(authorities.stream(), roles.stream()
                                .map(role -> new SimpleGrantedAuthority("ROLE_" + role)))
                                .collect(Collectors.toList());
                    }
                }
            }
            return Mono.just(new JwtAuthenticationToken(jwt, authorities));
        };
    }

    @Bean
    public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http) {
        return http
                .csrf().disable()
                .authorizeExchange(authorizeExchangeSpec -> authorizeExchangeSpec
                        .pathMatchers("/", "/actuator/**", "/login", "/logout", "/auth/**",
                                "/user/user/register", "/file/files/**").permitAll()
                        .pathMatchers("/user/**").hasRole("user")
                        .pathMatchers("/post/**").hasRole("post")
                        .pathMatchers("/file/**").hasRole("file")
                        .pathMatchers("/stat/**").hasRole("stat")
                        .anyExchange().authenticated())
                .oauth2Login(oAuth2LoginSpec -> {})
                .oauth2ResourceServer(oAuth2ResourceServerSpec -> oAuth2ResourceServerSpec
                        .jwt(jwtSpec -> jwtSpec
                                .jwtAuthenticationConverter(jwtAuthenticationConverter())))
                .build();
    }
}

Current Behavior

There is no userInfoEndpoint method in class ServerHttpSecurity.OAuth2LoginSpec, this method existed in class HttpSecurity.OAuth2LoginSpec. There are a few other methods like loginPage are also not implemented in class ServerHttpSecurity.OAuth2LoginSpec.

Context

I'm using spring cloud gateway both as a OAuth2 client and resource server, the full code can be found at Spring Cloud in Practice.

Comment From: sjohnr

@jaggerwang, thanks for the enhancement request! The two DSLs (Servlet, Reactive) are slightly different, and the reactive version is simpler in many respects, because these configuration points are available by other means.

For example, you can configure the user info endpoint via provider configuration in yaml/properties, e.g. spring.security.oauth2.client.provider.[providerId].user-info-uri.

In your example, it looks like you're using an OAuth2UserService. The reactive interface is ReactiveOAuth2UserService, and you can provide a custom one by exposing an @Bean of type ReactiveOAuth2UserService<OidcUserRequest, OidcUser>. The default is OidcReactiveOAuth2UserService. Can your example be changed to use a reactive implementation?

Comment From: jaggerwang

@sjohnr Thanks. I changed my example, but it seems not working.

package net.jaggerwang.scip.gateway.adapter.api.config;

import net.jaggerwang.scip.gateway.adapter.api.security.BindedOidcUser;
import net.jaggerwang.scip.gateway.adapter.api.security.LoggedUser;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.ReactiveAuthenticationManager;
import org.springframework.security.authentication.UserDetailsRepositoryReactiveAuthenticationManager;
import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity;
import org.springframework.security.config.web.server.ServerHttpSecurity;
import org.springframework.security.core.userdetails.ReactiveUserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.oauth2.client.oidc.userinfo.OidcReactiveOAuth2UserService;
import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserRequest;
import org.springframework.security.oauth2.client.userinfo.ReactiveOAuth2UserService;
import org.springframework.security.oauth2.client.web.server.ServerOAuth2AuthorizedClientRepository;
import org.springframework.security.oauth2.client.web.server.WebSessionServerOAuth2AuthorizedClientRepository;
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
import org.springframework.security.web.server.SecurityWebFilterChain;

/**
 * @author Jagger Wang
 */
@Configuration
@EnableWebFluxSecurity
public class SecurityConfig {
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    public ReactiveAuthenticationManager reactiveAuthenticationManager(
            ReactiveUserDetailsService userDetailsService) {
        var authenticationManager = new UserDetailsRepositoryReactiveAuthenticationManager(
                userDetailsService);
        authenticationManager.setPasswordEncoder(passwordEncoder());
        return authenticationManager;
    }

    /**
     * Store access token in session.
     */
    @Bean
    ServerOAuth2AuthorizedClientRepository authorizedClientRepository() {
        return new WebSessionServerOAuth2AuthorizedClientRepository();
    }

    /**
     * Customize OAuth2User (or OidcUser if using OpendID Connect protocol), such as bind user from
     * OAuth2 provider to a client's inner user, and get authorities of this inner user.
     */
    @Bean
    public ReactiveOAuth2UserService<OidcUserRequest, OidcUser> reactiveOAuth2UserService() {
        var delegate = new OidcReactiveOAuth2UserService();
        return userRequest -> delegate
                .loadUser(userRequest)
                .map(oidcUser -> {
                    // TODO
                    // 1) Bind user from OAuth2 provider to a client's inner user.
                    // 2) Get authorities of this inner user.
                    var authorities = oidcUser.getAuthorities();
                    var loggedUser = new LoggedUser(0L, "", "", authorities);

                    return new BindedOidcUser(loggedUser, authorities, oidcUser.getIdToken(),
                            oidcUser.getUserInfo());
                });
    }

    @Bean
    public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http) {
        return http
                .csrf().disable()
                .authorizeExchange(authorizeExchangeSpec -> authorizeExchangeSpec
                        .pathMatchers("/", "/actuator/**", "/login", "/logout", "/auth/**",
                                "/user/user/register", "/file/files/**").permitAll()
                        .pathMatchers("/user/**").hasRole("user")
                        .pathMatchers("/post/**").hasRole("post")
                        .pathMatchers("/file/**").hasRole("file")
                        .pathMatchers("/stat/**").hasRole("stat")
                        .anyExchange().authenticated())
                .oauth2Login(oAuth2LoginSpec -> {})
                .build();
    }
}

package net.jaggerwang.scip.gateway.adapter.api.security;

import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.oauth2.core.oidc.OidcIdToken;
import org.springframework.security.oauth2.core.oidc.OidcUserInfo;
import org.springframework.security.oauth2.core.oidc.user.DefaultOidcUser;

import java.util.Collection;

/**
 * @author Jagger Wang
 */
public class BindedOidcUser extends DefaultOidcUser {
    private final LoggedUser loggedUser;

    public BindedOidcUser(LoggedUser loggedUser, Collection<? extends GrantedAuthority> authorities,
                          OidcIdToken idToken, OidcUserInfo userInfo) {
        super(authorities, idToken, userInfo);

        this.loggedUser = loggedUser;
    }

    public LoggedUser getLoggedUser() {
        return loggedUser;
    }
}

I set a breakpoint at line return new DefaultOidcUser, but it not be triggered.

Comment From: jaggerwang

@sjohnr I found out the reason. It need to add openid in OAuth2 client registration's scope, and also specify jwkSetUri for the related provider.

  security:
    oauth2:
      client:
        registration:
          keycloak:
            clientId: scip
            clientSecret: 42f9a259-5b5e-40b4-ad84-dfa2e18e2df4
            scope: openid
            authorizationGrantType: authorization_code
            redirectUri: '{baseUrl}/login/oauth2/code/{registrationId}'
        provider:
          keycloak:
            authorizationUri: ${SCIP_SPRING_SECURITY_OAUTH2_CLIENT_PROVIDER_KEYCLOAK_AUTHORIZATION_URI:http://localhost:8180/auth/realms/JW/protocol/openid-connect/auth}
            tokenUri: ${SCIP_SPRING_SECURITY_OAUTH2_CLIENT_PROVIDER_KEYCLOAK_TOKEN_URI:http://localhost:8180/auth/realms/JW/protocol/openid-connect/token}
            userInfoUri: ${SCIP_SPRING_SECURITY_OAUTH2_CLIENT_PROVIDER_KEYCLOAK_USER_INFO_URI:http://localhost:8180/auth/realms/JW/protocol/openid-connect/userinfo}
            jwkSetUri: ${SCIP_SPRING_SECURITY_OAUTH2_CLIENT_PROVIDER_KEYCLOAK_JWT_JWK_SET_URI:http://localhost:8180/auth/realms/JW/protocol/openid-connect/certs}
            userNameAttribute: sub
package net.jaggerwang.scip.gateway.adapter.api.config;

import net.jaggerwang.scip.gateway.adapter.api.security.BindedOidcUser;
import net.jaggerwang.scip.gateway.adapter.api.security.LoggedUser;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.ReactiveAuthenticationManager;
import org.springframework.security.authentication.UserDetailsRepositoryReactiveAuthenticationManager;
import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity;
import org.springframework.security.config.web.server.ServerHttpSecurity;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.ReactiveUserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.oauth2.client.oidc.userinfo.OidcReactiveOAuth2UserService;
import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserRequest;
import org.springframework.security.oauth2.client.userinfo.ReactiveOAuth2UserService;
import org.springframework.security.oauth2.client.web.server.ServerOAuth2AuthorizedClientRepository;
import org.springframework.security.oauth2.client.web.server.WebSessionServerOAuth2AuthorizedClientRepository;
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
import org.springframework.security.web.server.SecurityWebFilterChain;

import java.util.Collection;
import java.util.Map;
import java.util.stream.Collectors;
import java.util.stream.Stream;

/**
 * @author Jagger Wang
 */
@Configuration
@EnableWebFluxSecurity
public class SecurityConfig {
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    public ReactiveAuthenticationManager reactiveAuthenticationManager(
            ReactiveUserDetailsService userDetailsService) {
        var authenticationManager = new UserDetailsRepositoryReactiveAuthenticationManager(
                userDetailsService);
        authenticationManager.setPasswordEncoder(passwordEncoder());
        return authenticationManager;
    }

    /**
     * Store access token in session.
     */
    @Bean
    ServerOAuth2AuthorizedClientRepository serverOAuth2AuthorizedClientRepository() {
        return new WebSessionServerOAuth2AuthorizedClientRepository();
    }

    /**
     * Customize OAuth2User (or OidcUser if using OpendID Connect protocol), such as bind user from
     * OAuth2 provider to a client's inner user, and get authorities of this inner user.
     */
    @Bean
    public ReactiveOAuth2UserService<OidcUserRequest, OidcUser> reactiveOAuth2UserService() {
        var delegate = new OidcReactiveOAuth2UserService();
        return userRequest -> delegate
                .loadUser(userRequest)
                .map(oidcUser -> {
                    // TODO
                    // Bind OAuth2 provider's user to client's inner user

                    // Extract client roles in access token
                    var authorities = oidcUser.getAuthorities();
                    var clientRegistration = userRequest.getClientRegistration();
                    var jwtDecoder = NimbusJwtDecoder.withJwkSetUri(
                            clientRegistration.getProviderDetails().getJwkSetUri()).build();
                    var jwt = jwtDecoder.decode(userRequest.getAccessToken().getTokenValue());
                    var resourceAccess = jwt.getClaimAsMap("resource_access");
                    if (resourceAccess != null) {
                        var resource = (Map<String, Object>) resourceAccess.get(
                                clientRegistration.getClientId());
                        if (resource != null) {
                            var roles = (Collection<String>) resource.get("roles");
                            if (roles != null) {
                                authorities = Stream.concat(authorities.stream(), roles.stream()
                                        .map(role -> new SimpleGrantedAuthority("ROLE_" + role)))
                                        .collect(Collectors.toList());
                            }
                        }
                    }

                    var loggedUser = new LoggedUser(0L, oidcUser.getName(), "", authorities);
                    return new BindedOidcUser(loggedUser, authorities, oidcUser.getIdToken(),
                            oidcUser.getUserInfo());
                });
    }

    @Bean
    public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http) {
        return http
                .csrf().disable()
                .authorizeExchange(authorizeExchangeSpec -> authorizeExchangeSpec
                        .pathMatchers("/", "/actuator/**", "/login", "/logout", "/auth/**",
                                "/user/user/register", "/file/files/**").permitAll()
                        .pathMatchers("/user/**").hasRole("user")
                        .pathMatchers("/post/**").hasRole("post")
                        .pathMatchers("/file/**").hasRole("file")
                        .pathMatchers("/stat/**").hasRole("stat")
                        .anyExchange().authenticated())
                .oauth2Login(oAuth2LoginSpec -> {})
                .build();
    }
}