Skip to content
This repository has been archived by the owner on May 31, 2022. It is now read-only.

OAuth2 client flow with authorization code grant is not possible with anonymous auth #1842

Closed
mdindoffer opened this issue Apr 8, 2020 · 11 comments
Assignees

Comments

@mdindoffer
Copy link

mdindoffer commented Apr 8, 2020

Disclaimer

I know this library is deprecated, but I was forced to migrate to an older Springboot stack.

Summary

The OAuth2 client authorization flow with authorization code grant type does not work together with anonymous authentication.
Is a hack/workaround available?

Actual Behavior

I need to allow anonymous users (with anonymous HTTP session / authentication) to access remote OAuth2 resources protected by authorization code grant.
I can successfully redirect users to the remote Authorization/IDP login page, but get exceptions everytime I'm trying to complete the auth flow.
There are two issues I face:

  1. If I redirect the user back to the URL for OAuth2ClientAuthenticationProcessingFilter, the filter properly requests and obtains an access token. However, the next thing it tries to do is:
OAuth2Authentication result = tokenServices.loadAuthentication(accessToken.getValue());

Which fails on BadCredentialsException("Could not obtain user details from token"), because the DefaultTokenService tries to call tokenStore#readAccessToken, but the InMemoryTokenStore is entirely empty at this point.

  1. If I redirect the user back to a different URL, not protected by a OAuth2ClientAuthenticationProcessingFilter I cannot even obtain the access token. When trying to call restTemplate.getAccessToken() in a RestController, the call fails in AccessTokenProviderChain due to this exception:
if (auth instanceof AnonymousAuthenticationToken) {
    if (!resource.isClientOnly()) {
        throw new InsufficientAuthenticationException(
                "Authentication is required to obtain an access token (anonymous not allowed)");
    }
}

I've tried to use my own implementation of AccessTokenProviderChain, that simply omits this check, and at first look it seems to work, but I am not sure if this is a correct approach sufficient for multitenancy.

Expected Behavior

I expect there would be no checks in regards to anonymous authentication. Why should it matter if we have some user details or not? All I want to do is to call some protected resource on behalf of an anonymous user, that provided consent at the Authorization Server.

This is perfectly plausible and doable in Spring Security 5.2.x, because I've already done so, but I need to port the solution to spring security oauth2.

Why does the AccessTokenProviderChain check for anonymous auths?
What do I need to override and reimplement for this scenario to work?

Configuration

I'm using simple @EnableOAuth2Client, DefaultTokenServices (since I don't want to fetch user data from any remote service), InMemoryTokenStore (since that's the simplest one available out of the box).

Version

Springboot 1.5.13, Spring Security OAuth 2.0.15.RELEASE.

@jgrandja
Copy link
Contributor

jgrandja commented Apr 8, 2020

Thanks for getting in touch, but it feels like this is a question that would be better suited to Stack Overflow. As mentioned in the guidelines for contributing, we prefer to use GitHub issues only for bugs and enhancements. Feel free to update this issue with a link to the re-posted question (so that other people can find it) or add some more details if you feel this is a genuine bug.

@mdindoffer
Copy link
Author

Yes, I guess you could call this a bug - OAuth2 flow with authorization code grant is not working with anonymous auth.

But, that's just a matter of phrasing. I wrote the issue report this way, because I'd like to settle on a workaround, since waiting for a new bugfix release and upgrading is not an option for me.

@mdindoffer mdindoffer changed the title How to enable OAuth2 client for users with anonymous authentication? OAuth2 client flow with authorization code grant is not possible with anonymous auth Apr 9, 2020
@jgrandja
Copy link
Contributor

All I want to do is to call some protected resource on behalf of an anonymous user, that provided consent at the Authorization Server

This is not a valid use case. The authorization_code grant involves an authenticated user to grant access (consent) to the client to access it's protected resources. For further details, you can review the authorization_code grant flow.

@mdindoffer
Copy link
Author

All I want to do is to call some protected resource on behalf of an anonymous user, that provided consent at the Authorization Server

This is not a valid use case. The authorization_code grant involves an authenticated user to grant access (consent) to the client to access it's protected resources. For further details, you can review the authorization_code grant flow.

Respectfully sir, it is a valid use case.
Indeed, a user has to be authenticated, but not necessarily at a 3rd party Spring client application. A user is authenticated at the Authorization server. After authenticating at the Authorization server, he provides consent for the 3rd party app's access and is redirect back to a third party application (Spring app in this case), where his presence may be completely anonymous. The only thing to take care of is to properly map the returned auth_code and access tokens to the correct anonymous HTTP session, otherwise there could be no multitenancy.

This is perfectly doable in Spring Security 5.2.x. Each anonymous authentication has a separate HTTP session, and OAuth2 Authorized clients are bound to the specific session.

There is no need to have the user authenticated in a Spring client application (third-party in the spec). The OAuth2 standard does not require it - in fact the third-party's access management is out of scope of the OAuth2 spec.

@jgrandja
Copy link
Contributor

@mdindoffer Thanks for clarifying your issue further. Yes, you are correct, the user does not need to be authenticated with the client app, however, does need to be authenticated with the provider.

As the next step in helping you troubleshoot this issue, it would be helpful if you could provide a minimal sample that reproduces the issue. Then I could look at your sample and advise on a solution.

@mdindoffer
Copy link
Author

@jgrandja NP, I have created a tiny Springboot client app with OAuth credentials for testing provided.
Take a look at https://github.com/mdindoffer/oauth-client-poc
Just boot it up, and open http://localhost:8080/initNewAuth to initiate the authorization flow.

If you'd prefer to read through the code here, I will paste the contents below:

AuthController.java
package eu.dindoffer.example.oauth.client.impl;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.oauth2.client.OAuth2RestOperations;
import org.springframework.security.oauth2.common.OAuth2AccessToken;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestParam;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import java.io.IOException;

@Controller
public class AuthController {

  private static final Logger LOG = LoggerFactory.getLogger(AuthController.class);
  private OAuth2RestOperations exampleProviderRestTemplate;

  public AuthController(OAuth2RestOperations exampleProviderRestTemplate) {
      this.exampleProviderRestTemplate = exampleProviderRestTemplate;
  }

  @GetMapping("/initNewAuth")
  public void initNewAuth(HttpServletRequest request, HttpServletResponse response) throws IOException {
      LOG.info("Initiating new auth request");
      //clear the session
      clearCurrentAuthentication(request);
      //Create a new session and store any data if needed (omitted for brevity)
      HttpSession session = request.getSession();
      //Redirect to initiate OAuth flow with an example provider
      response.sendRedirect("/oauth2/authorization/example-provider");
  }

  @GetMapping(value = "/authorize/oauth2/code/{providerId}", params = "!error")
  public void handleSuccessfulAuth(@PathVariable String providerId) {
      LOG.info("OAuth grant successful for provider {}", providerId);
      //try to obtain access token
      OAuth2AccessToken accessToken = exampleProviderRestTemplate.getAccessToken();
      LOG.info("Obtained access token: {}", accessToken);
      //Now do stuff, e.g. try to call protected resources with an access token...
  }

  @GetMapping(value = "/authorize/oauth2/code/{providerId}", params = "error")
  public void handleFailedAuth(@PathVariable("providerId") String providerId,
                               @RequestParam("error") String error,
                               @RequestParam(value = "error_description", required = false) String errorDescription,
                               @RequestParam(value = "error_uri", required = false) String errorUri) {
      LOG.error("Caught an unsuccessful auth for provider {}, with error {}", providerId, error);
  }

  /**
   * Performs a manual logout by removing any current authentication present.
   * Invalidates the session of the provided request, removes the Authentication
   * object from security context and detaches the security context from the current thread.
   *
   * @param request current HTTP request
   */
  private void clearCurrentAuthentication(HttpServletRequest request) {
      HttpSession session = request.getSession(false);
      if (session != null) {
          LOG.debug("Invalidating session:{}", session.getId());
          session.invalidate();
      }
      SecurityContext context = SecurityContextHolder.getContext();
      context.setAuthentication(null);
      SecurityContextHolder.clearContext();
  }
}
application.yml
logging:
level:
  org.springframework:
    security: DEBUG

eu.dindoffer.example.oauth.client:
oauth:
  example-registration:
    clientId: b7d43f9b-a78a-4907-a6d2-a5e94c15dbc0
    clientSecret: 22f7c0f6-0218-4fd9-a4b7-500faaa2b2da
    accessTokenUri: https://webapi.developers.erstegroup.com/api/csas/sandbox/v1/sandbox-idp/token
    userAuthorizationUri: https://webapi.developers.erstegroup.com/api/csas/sandbox/v1/sandbox-idp/auth
    authenticationScheme: header
    clientAuthenticationScheme: form
    preEstablishedRedirectUri: http://localhost:8080/authorize/oauth2/code/example
    use-current-uri: false
    scope: kyc
AppConfig.java
package eu.dindoffer.example.oauth.client.config;

import eu.dindoffer.example.oauth.client.impl.MyAccessTokenProviderChain;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.oauth2.client.OAuth2ClientContext;
import org.springframework.security.oauth2.client.OAuth2RestOperations;
import org.springframework.security.oauth2.client.OAuth2RestTemplate;
import org.springframework.security.oauth2.client.filter.OAuth2ClientAuthenticationProcessingFilter;
import org.springframework.security.oauth2.client.filter.OAuth2ClientContextFilter;
import org.springframework.security.oauth2.client.token.AccessTokenProvider;
import org.springframework.security.oauth2.client.token.grant.code.AuthorizationCodeAccessTokenProvider;
import org.springframework.security.oauth2.client.token.grant.code.AuthorizationCodeResourceDetails;
import org.springframework.security.oauth2.config.annotation.web.configuration.EnableOAuth2Client;
import org.springframework.security.oauth2.provider.token.DefaultTokenServices;
import org.springframework.security.oauth2.provider.token.store.InMemoryTokenStore;
import org.springframework.web.filter.CompositeFilter;

import javax.servlet.Filter;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;

@Configuration
@EnableOAuth2Client
public class AppConfig extends WebSecurityConfigurerAdapter {

  @Autowired
  private OAuth2ClientContext oauth2ClientContext;

  @Override
  protected void configure(HttpSecurity http) throws Exception {
      http.authorizeRequests()
              .anyRequest().permitAll();
  }

  @Bean
  public OAuth2RestOperations exampleProviderRestTemplate(OAuth2ClientContext oauth2ClientContext) {
      OAuth2RestTemplate oAuth2RestTemplate = new OAuth2RestTemplate(exampleRegistrationResourceDetails(), oauth2ClientContext);
//TODO Uncomment this for an attempted workaround by avoiding anonymous auth checks
//        MyAccessTokenProviderChain myAccessTokenProviderChain = new MyAccessTokenProviderChain(Collections.<AccessTokenProvider>singletonList(
//                new AuthorizationCodeAccessTokenProvider()
//        ));
//        oAuth2RestTemplate.setAccessTokenProvider(myAccessTokenProviderChain);
      return oAuth2RestTemplate;
  }

  @Bean
  @ConfigurationProperties("eu.dindoffer.example.oauth.client.oauth.example-registration")
  public AuthorizationCodeResourceDetails exampleRegistrationResourceDetails() {
      return new AuthorizationCodeResourceDetails();
  }

  @Bean
  public FilterRegistrationBean oauth2ClientFilterRegistration(OAuth2ClientContextFilter filter) {
      FilterRegistrationBean registration = new FilterRegistrationBean();
      registration.setFilter(filter);
      registration.setOrder(-100);
      return registration;
  }

  @Bean
  public FilterRegistrationBean oauthLoginFilterRegistration() {
      FilterRegistrationBean registration = new FilterRegistrationBean();
      registration.setFilter(oauth2LoginFilter());
      registration.setOrder(-100);
      return registration;
  }

  private Filter oauth2LoginFilter() {
      CompositeFilter filter = new CompositeFilter();
      List<Filter> filters = new ArrayList<>();

      // add a new filter dedicated to an example OAuth2 provider
      OAuth2ClientAuthenticationProcessingFilter exampleOauthProviderFilter =
              new OAuth2ClientAuthenticationProcessingFilter("/oauth2/authorization/example-provider");
      OAuth2RestOperations exampleProviderRestTemplate = exampleProviderRestTemplate(oauth2ClientContext);
      exampleOauthProviderFilter.setRestTemplate(exampleProviderRestTemplate);

      DefaultTokenServices defaultTokenServices = new DefaultTokenServices();
      defaultTokenServices.setTokenStore(new InMemoryTokenStore());
      exampleOauthProviderFilter.setTokenServices(defaultTokenServices);

      filters.add(exampleOauthProviderFilter);

      // add filters for additional providers here...

      filter.setFilters(filters);
      return filter;
  }
}
MyAccessTokenProviderChain.java
package eu.dindoffer.example.oauth.client.impl;

import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.oauth2.client.resource.OAuth2ProtectedResourceDetails;
import org.springframework.security.oauth2.client.resource.UserRedirectRequiredException;
import org.springframework.security.oauth2.client.token.AccessTokenProvider;
import org.springframework.security.oauth2.client.token.AccessTokenProviderChain;
import org.springframework.security.oauth2.client.token.AccessTokenRequest;
import org.springframework.security.oauth2.common.OAuth2AccessToken;
import org.springframework.security.oauth2.common.OAuth2RefreshToken;

import java.util.List;

public class MyAccessTokenProviderChain extends AccessTokenProviderChain {

  public MyAccessTokenProviderChain(List<? extends AccessTokenProvider> chain) {
      super(chain);
  }

  /**
   * Basically the same implementation as parent, sans the:
   * 1. Anonymous auth check
   * 2. Interaction with the ClientTokenServices
   */
  @Override
  public OAuth2AccessToken obtainAccessToken(OAuth2ProtectedResourceDetails resource, AccessTokenRequest request) throws UserRedirectRequiredException, AccessDeniedException {
      OAuth2AccessToken accessToken = null;
      OAuth2AccessToken existingToken = null;
      Authentication auth = SecurityContextHolder.getContext().getAuthentication();

      if (resource.isClientOnly() || (auth != null && auth.isAuthenticated())) {
          existingToken = request.getExistingToken();
          if (existingToken != null) {
              if (existingToken.isExpired()) {
                  OAuth2RefreshToken refreshToken = existingToken.getRefreshToken();
                  if (refreshToken != null) {
                      accessToken = refreshAccessToken(resource, refreshToken, request);
                  }
              } else {
                  accessToken = existingToken;
              }
          }
      }
      // Give unauthenticated users a chance to get a token and be redirected

      if (accessToken == null) {
          // looks like we need to try to obtain a new token.
          accessToken = obtainNewAccessTokenInternal(resource, request);

          if (accessToken == null) {
              throw new IllegalStateException(
                      "An OAuth 2 access token must be obtained or an exception thrown.");
          }
      }
      return accessToken;
  }
}

@jgrandja
Copy link
Contributor

@mdindoffer I took a look at the sample and have a couple of things to point out.

First, if your application acts as a client only, then no need to register OAuth2ClientAuthenticationProcessingFilter as this is meant to be used in a resource server. Take notice of the member ResourceServerTokenServices tokenServices, which is intended to be used by a resource server.

Second, the workaround for MyAccessTokenProviderChain (omitting the AnonymousAuthenticationToken check) looks fine to me.

An alternative workaround is registering a custom Filter that is placed right after the AnonymousAuthenticationFilter, that simply swaps the AnonymousAuthenticationToken to a custom CustomAuthenticationToken in the SecurityContext so that it forces the AnonymousAuthenticationToken check to pass in the default AccessTokenProviderChain. It's a hack but IMO it's easier to swap in/out a Filter. NOTE: You could override AnonymousAuthenticationFilter.createAuthentication() to create a custom AbstractAuthenticationToken.

@mdindoffer
Copy link
Author

Thank you very much for the advice!
I have since then moved the stack to ScribeJava, however the custom Filter solution looks cleaner to me as well. I will definitely try to use it when/if I'll come back to spring-sec-oauth.

As far as I'm concerned, this issue can be closed, as a reasonable workaround exists, but I'll leave the decision to you.

@jgrandja
Copy link
Contributor

jgrandja commented May 4, 2020

@mdindoffer I'm sorry to hear you moved off of Spring Security OAuth. Is there a reason you moved to ScribeJava instead of Spring Security's 5.x OAuth 2.0 support? I'm more curious than anything as feedback is always welcome. Also, in case you are not aware, we just started the new project Spring Authorization Server.

Either way, I'll close this issue since a workaround has been provided.

@mdindoffer
Copy link
Author

Well, I currently work as a contractor for a large company. For reasons unknown, they have built their own "framework" for microservices on top of SpringBoot and Spring Cloud that is forced upon every project. I'm working on a new microservice in an older project built with an older version of the proprietary framework (with SpringBoot 1.5.13) and was told specifically to not upgrade. I guess because of compatibility or maintenance issues? The "microservices" share a common maven parent with common dependency management

TLDR: There's no technical reason why, other than bad architectural and management decisions. Spring Security 5 has good enough support for OAuth2.

@jgrandja
Copy link
Contributor

jgrandja commented May 5, 2020

Thanks for the feedback @mdindoffer !

Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Development

No branches or pull requests

3 participants