Expected Behavior

It would be useful if a Spring client with oauth2Login used as a facade for a RESTful backend could delegate to the user agent the decision of when to initiate an OAuth2 flow (and what to use for it).

In the OAuth2AuthorizationRequestRedirectFilter, if the RedirectStrategy used in case of a ClientAuthorizationRequiredException differed from the one used in the nominal case, we could configure it to return a 401, letting the user agent decide what to do in that case.

Current Behavior

OAuth2AuthorizationRequestRedirectFilter always uses the same RedirectStrategy.

Let's consider the following use case: 1. The user was successfully identified with oauth2Login 2. The Spring client saved tokens in the session 3. The user went idle for long enough for the refresh token to expire, but not his session 4. The user performs an action that requires an access token

In this situation, the refresh token flow can't succeed (expired refresh token), and an exception is thrown. The OAuth2AuthorizationRequestRedirectFilter catches this exception to redirect to the authorization endpoint.

This is problematic when the Spring client with oauth2Login is an OAuth2 BFF for a user agent that isn't intended to interact with the authorization server (displaying login forms, handling remember-me cookies, etc.). If a 401 was returned, single-page and mobile applications could use a request interceptor to initiate the authorization code flow themselves, and in the way they like. For instance, instead of following with a cross-origin request using their internal HTTP client, SPAs could initiate a new navigation (set the window.location.href), and mobile apps could open the system browser.

Note that in the context of the authorization code flow initiation, frontends could expect the BFF to use a redirect strategy that responds with a different status than the case above (failed refresh token flow). Either: - 302, as configured by default, which works fine for SPAs: they set the window.location.href to the /oauth2/authorization/{registrationId} - 2XX, which works for both single-page and mobile apps: they send a GET /oauth2/authorization/{registrationId} and then follow to the response Location by either setting the window.location.href (SPA) or opening the system browser (mobile app)

Context A similar need was reported in 2019, at a time when the OAuth2 BFF pattern wasn't as widely used as it is now. The solution proposed by @jessym at that time looks more like a hack to me than a way to configure a clean security filter chain for the gateway to a RESTful API.

Comment From: ch4mpy

As a reference, here is how I currently workaround that (pretty dirty).

I use a slightly modified copy/paste of the OAuth2AuthorizationRequestRedirectFilter

public class RestfulOAuth2Filter extends OAuth2AuthorizationRequestRedirectFilter {

  private final ThrowableAnalyzer throwableAnalyzer;

  private final RedirectStrategy authorizationRedirectStrategy;

  // Second RedirectStrategy, to use in case of a  ClientAuthorizationRequiredException
  // Ideally, this would be configurable, as the authorizationRedirectStrategy is already
  private final RedirectStrategy clientAuthorizationRequiredStrategy =
      (HttpServletRequest request, HttpServletResponse response, String location) -> {
        response.setStatus(HttpStatus.UNAUTHORIZED.value());
        response.setHeader(HttpHeaders.LOCATION, location);
      };

  private final OAuth2AuthorizationRequestResolver authorizationRequestResolver;

  private final AuthorizationRequestRepository<OAuth2AuthorizationRequest> authorizationRequestRepository;

  private final AuthenticationFailureHandler authenticationFailureHandler;

  private RestfulOAuth2Filter(OAuth2AuthorizationRequestResolver authorizationRequestResolver,
      AuthorizationRequestRepository<OAuth2AuthorizationRequest> authorizationRequestRepository,
      RedirectStrategy authorizationRedirectStrategy,
      AuthenticationFailureHandler authenticationFailureHandler,
      ThrowableAnalyzer throwableAnalyzer) {
    super(authorizationRequestResolver);
    this.authorizationRequestResolver = authorizationRequestResolver;

    this.throwableAnalyzer = throwableAnalyzer;

    this.authorizationRedirectStrategy = authorizationRedirectStrategy;
    super.setAuthorizationRedirectStrategy(authorizationRedirectStrategy);


    this.authorizationRequestRepository = authorizationRequestRepository;
    super.setAuthorizationRequestRepository(authorizationRequestRepository);

    this.authenticationFailureHandler = authenticationFailureHandler;
    super.setAuthenticationFailureHandler(authenticationFailureHandler);
  }

  public RestfulOAuth2Filter(OAuth2AuthorizationRequestRedirectFilter other) {
    this(getField("authorizationRequestResolver", other),
        getField("authorizationRequestRepository", other),
        getField("authorizationRedirectStrategy", other),
        getField("authenticationFailureHandler", other), getField("throwableAnalyzer", other));
  }

  @SuppressWarnings("unchecked")
  private static <T> T getField(String fieldName, Object target) {
    try {
      final var field = target.getClass().getDeclaredField(fieldName);
      field.setAccessible(true);
      return (T) field.get(target);
    } catch (NoSuchFieldException | IllegalAccessException e) {
      throw new RuntimeException(e);
    }
  }

  @Override
  protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
      FilterChain filterChain) throws ServletException, IOException {
    try {
      OAuth2AuthorizationRequest authorizationRequest =
          this.authorizationRequestResolver.resolve(request);
      if (authorizationRequest != null) {
        this.sendRedirectForAuthorization(request, response, authorizationRequest);
        return;
      }
    } catch (Exception ex) {
      AuthenticationException wrappedException = new OAuth2AuthorizationRequestException(ex);
      this.authenticationFailureHandler.onAuthenticationFailure(request, response,
          wrappedException);
      return;
    }
    try {
      filterChain.doFilter(request, response);
    } catch (IOException ex) {
      throw ex;
    } catch (Exception ex) {
      Throwable[] causeChain = this.throwableAnalyzer.determineCauseChain(ex);
      ClientAuthorizationRequiredException authzEx =
          (ClientAuthorizationRequiredException) this.throwableAnalyzer
              .getFirstThrowableOfType(ClientAuthorizationRequiredException.class, causeChain);
      if (authzEx != null) {
        try {
          OAuth2AuthorizationRequest authorizationRequest =
              this.authorizationRequestResolver.resolve(request, authzEx.getClientRegistrationId());
          if (authorizationRequest == null) {
            throw authzEx;
          }
          // Answer with a 401 instead of redirecting to login
          this.sendUnauthorized(request, response, authorizationRequest);
        } catch (Exception failed) {
          AuthenticationException wrappedException = new OAuth2AuthorizationRequestException(ex);
          this.authenticationFailureHandler.onAuthenticationFailure(request, response,
              wrappedException);
        }
        return;
      }
      if (ex instanceof ServletException) {
        throw (ServletException) ex;
      }
      if (ex instanceof RuntimeException) {
        throw (RuntimeException) ex;
      }
      throw new RuntimeException(ex);
    }
  }

  private void sendUnauthorized(HttpServletRequest request, HttpServletResponse response,
      OAuth2AuthorizationRequest authorizationRequest) throws IOException {
    this.clientAuthorizationRequiredStrategy.sendRedirect(request, response,
        authorizationRequest.getAuthorizationRequestUri());
  }

  private void sendRedirectForAuthorization(HttpServletRequest request,
      HttpServletResponse response, OAuth2AuthorizationRequest authorizationRequest)
      throws IOException {
    if (AuthorizationGrantType.AUTHORIZATION_CODE.equals(authorizationRequest.getGrantType())) {
      this.authorizationRequestRepository.saveAuthorizationRequest(authorizationRequest, request,
          response);
    }
    this.authorizationRedirectStrategy.sendRedirect(request, response,
        authorizationRequest.getAuthorizationRequestUri());
  }

  private static final class OAuth2AuthorizationRequestException extends AuthenticationException {

    OAuth2AuthorizationRequestException(Throwable cause) {
      super(cause.getMessage(), cause);
    }

  }

}

Note the added clientAuthorizationRequiredStrategy property, which sets a 401 status. This strategy is applied only in the case of a ClientAuthorizationRequiredException (instead of redirecting to login).

I then replace the OAuth2AuthorizationRequestRedirectFilter with a BeanPostProcessor to keep the existing auto-configuration:

@Component
public class CustomBeanPostProcessor implements BeanPostProcessor {

  @Override
  public Object postProcessBeforeInitialization(@NonNull Object bean, @NonNull String beanName)
      throws BeansException {
    if (bean instanceof OAuth2AuthorizationRequestRedirectFilter oauth2AuthorizationRequestRedirectFilter) {
      return new RestfulOAuth2Filter(oauth2AuthorizationRequestRedirectFilter);
    }
    return bean;
  }
}

Certainly not future-proof, but it does the trick.