Search code examples
reactjsspringoauth-2.0spring-oauth2spring-authorization-server

Spring Boot Oauth Client & Authorization Server + React implementation


Currently i started implementing a BFF (backend for frotnend - a spring oauth 2 client ) with the purpose of serving my frontend ( react ) in order to authenticate with an authorization server.

I'm trying to figure out how can i use the spring oauth 2 client exactly in order to implement a frontend - authorization workflow.

So far i have a simple oauth2-client on a spring boot project:

@Configuration
public class Security {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        return   http.cors(cors -> cors.configurationSource(request -> {
                    var corsConfiguration = new CorsConfiguration();
                    corsConfiguration.addAllowedOrigin("http://127.0.0.1:3000");
                    corsConfiguration.setAllowCredentials(true);
                    corsConfiguration.addAllowedMethod("*");
                    corsConfiguration.addAllowedHeader("*");
                    return corsConfiguration;
                }))
                .csrf()
                .disable()
                .authorizeHttpRequests()
                .anyRequest().authenticated()
                .and()
                .oauth2Login( oauth2Login -> oauth2Login.loginPage("/oauth2/authorization/securio"))
                .oauth2Client(Customizer.withDefaults())
                .build();

    }

}

I thought at having an get /userinfo endpoint that will retrieve the role for a user ( frontend) everytime a page needs to be loaded in order to check if it has the necesarry permissions.

@Controller
@RequiredArgsConstructor
@RequestMapping("/auth")
public class AuthenticationController {

    private final RestTemplate restTemplate;
    private final OAuth2AuthorizedClientService authorizedClientService;


     @GetMapping("/userinfo")
public ResponseEntity<UserInfo> getUserInfo() throws ParseException {

    Authentication authentication = SecurityContextHolder.getContext().getAuthentication();

    var client = authorizedClientService.loadAuthorizedClient(
            ((OAuth2AuthenticationToken) authentication).getAuthorizedClientRegistrationId(),
            authentication.getName());
    var accessToken = client.getAccessToken().getTokenValue();

    JWT jwt = JWTParser.parse(accessToken);

    List<String> authorities = jwt.getJWTClaimsSet().getStringListClaim("authorities");
    String userRole = null;
    for (String authority : authorities) {
        if (authority.startsWith("ROLE_")) {
            userRole = authority;
            break;
        }
    }
    if (userRole == null) {
        return new ResponseEntity<>(HttpStatus.UNAUTHORIZED);
    }

    String username = jwt.getJWTClaimsSet().getSubject();
    

    return new ResponseEntity<>(UserInfo.builder()
            .username(username)
            .role(userRole)
            .build(), HttpStatus.OK);

}

    @PostMapping("/logout")
    @ResponseStatus(HttpStatus.OK)
    public void logout(HttpServletRequest request, HttpServletResponse response) {

        HttpSession session = request.getSession(false);
        if (session != null) {

            ResponseEntity<Void> responseEntity = restTemplate.exchange(
                    "http://127.0.0.1:8082/auth/logout", HttpMethod.POST, null, Void.class);
            if (responseEntity.getStatusCode() != HttpStatus.NO_CONTENT) {
                throw new RuntimeException("Logout failed");
            }

            session.invalidate();

            Cookie cookie = new Cookie("JSESSIONID", "");
            cookie.setMaxAge(0);
            cookie.setPath("/");
            response.addCookie(cookie);
        } else {
            throw new RuntimeException("User already logged out");
        }

    }

}

This is the application.yml for oauth2-client:

server:
  port: 8081

logging:
  level:
    org.springframework:
      security: trace

spring:
  security:
    oauth2:
      client:
        registration:
          securio:
            client-id: securio
            client-secret: securio-secret
            authorization-grant-type: authorization_code
            redirect-uri: http://127.0.0.1:8081/login/oauth2/code/securio
            scope: openid
            provider: securio
        provider:
          securio:
            issuer-uri: http://localhost:8082

This is how i'm fetching the userinfo

useEffect(() => {
    axios
      .get('http://127.0.0.1:8081/auth/userinfo', {
      })
      .then((response) => {
        switch (response.data.role) {
          case 'ROLE_STANDARD_USER':
            setRole('ROLE_STANDARD_USER');
            setMenuItems(standardMenuItems);
            break;
          case 'ROLE_ADMIN':
            setRole('ROLE_ADMIN');
            setMenuItems(adminMenuItems);
            break;
          default:
            setRole(null);
            setMenuItems([]);
            break;
        }
      })
      .catch((error) => {
        console.log(error); // handle error
      });

So i expected the workflow to be like this:

  1. user requests /userinfo from the BFF server ( backend for front end oauth2 client )
  2. user is not authenticated so the BFF will trigger a request to the /authorize endpoint of the authorization server by redirecting the frontend to the authorization server
  3. user enters credentials and auth server redirects back to the bff with the authorization code
  4. bff goes further and retrieve access , refresh token, etc and stores them alongisde user credentials with the session
  5. userinfo is returned to the frontend

However there are 2 big problems with this approach:

  1. CORS settings
  • Both servers ( BFF Oauth client and Authorization server ) has cors enabled alongside all the settings ( allow header , allow origin , etc )

We have 3 servers ( domains ) : Server A ( frontend ) , Server B ( BFF ) , Server C( auth server). So Server B is redirecting Server A to Server C . On Server C , the request arrives with origin set to null because of a browser setting, something that is related to privacy concerns. Because of this the cors will always fail cause it cannot validate an allowed origin with null. I didn't find any solution to this

  1. Frontend issue on processing the response

A workaround to the CORS issue is to set allowed origins on the auth server to all ( * ) so in this case the null origin won't matter anymore, but now there is another problem. The BFF should redirect the frontend to the auth server , meaning that a login page should appear for the frontend in order to enter the credentials but what is happening is that on the response of the axios request, this redirect is coming as an html form and i don't know how to process it further in order to be let the user enter the credentials.

I'm trying to figure out a workflow between the frontend and the BFF in order to retrieve somehow the user role or a proper authentication way.


Solution

  • I have written a tutorial on Baeldung to configure spring-cloud-gateway as BFF: as OAuth2 client and with TokenRelay as well as DedupeResponseHeader=Access-Control-Allow-Credentials Access-Control-Allow-Origin filters.

    The responsibilities of the BFF are not solely to authenticate users and store OAuth2 tokens, it is also to replace the session cookie with access token before forwarding the request from the browser to the resource server. This is where the TokenRelay jumps in. In your conf, the API being an OAuth2 client, it is secured with sessions (and not an OAuth2 access token like resource servers). This comes with serious limitations...

    Also, in my configuration, both the JS frontend and the OAuth2 REST API are served via the BFF => requests have the same origin (which surely makes cross origin configuration simpler...).

    If you don't use spring-cloud-gateway, you'll have to implement equivalents for those two filters by yourself. If on the oposit, you choose to use it like I do in my tutorial, be aware that spring-cloud-gateway is a reactive application and that you must provide with WebFlux security conf for it (SecurityWebFilterChain and not SecurityFilterChain).

    Also, it is a very bad idea to disable CSRF protection on a BFF: requests being secured with session between the browser and the BFF, it is exposed to both CSRF and BREACH attacks. Instructions here for setting up a reactive OAuth2 client with CSRF and BREACH protection with CSRF cookie accessible to JS applications (like your React frontend).