Search code examples
javaspring-bootspring-securityoauth-2.0

Spring Security OAuth2 Re-Authorizing for Every Request


This is my first post, sorry if I miss any standard SOF practices. Please let me know if I need to provide any additional info or if I provided too much.

I've been following the Springboot 3 migration documentation as well as a blog post here: https://davidagood.com/oauth-client-credentials-auto-refresh-spring/

After upgrading from Springboot 2.6 to 3.2, I found that RestTemplate is now deprecated and I switched over to WebClient in hopes of getting my Springboot Application to behave how I want it to.

For context, I'm on Springboot 3.2 Spring Security 6 Java 21 . And my app is a proxy that has a bunch of API endpoints that make calls to the Salesforce API. Before upgrading to WebClient from RestTemplate, the requests were working fine however, now that I upgraded, I'm getting multiple authentication calls for each API call instead of it storing the session and reusing the one access token, only refreshing it when it expires. And when I log these requests it looks like they're not authenticating therefore not storing the authentication? I gather this from the "AnonymousRole" that I'm seeing on my requests.

I don't think the resource server on the Salesforce side is the issue as when I make a postman request to the same URL, I get back the proper access token:

Request

curl -X POST -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=client_credentials&client_id=secret-placeholder&client_secret=secret-placeholder" \ "https://my.salesforce.com/services/oauth2/token"

Response

{"access_token":"{TOKEN_VALUE}","signature":"{SIGNATURE_VALUE}","scope":"api id","instance_url":"https://my.salesforce.com","id":"https://test.salesforce.com/id/{ID_VALUE}","token_type":"Bearer","issued_at":"{EPOCH_VALUE}"}

I believe a possible cause of this "AnonymousUser" issue was addressed in the blog "depends on an HTTP session to maintain a context across multiple requests". So I used the "AuthorizedClientServiceOAuth2AuthorizedClientManager" as suggested to get around this issue however, unfortunately I'm still getting the same issue! I've been looking around a lot and every resource seems to be pointing to the same steps that I've followed so I'm really lost at this point and my senior engineer wasn't able to figure it out either.

Hoping to get some help on this. Thanks a lot!

Here's a bit of the log when using ".permitAll()":

2024-01-29T15:59:11.615-08:00  INFO 53077 --- [crm-proxy] [oundedElastic-1] [                                                 ] c.s.c.c.s.c.SalesforceWebClientFactory   : Authorization successful for clientRegistrationId=salesforce, tokenUri=https://my.salesforce.com/services/oauth2/token
2024-01-29T15:59:12.610-08:00 DEBUG 53077 --- [crm-proxy] [nio-8080-exec-1] [65b83bcea50539ad048191ae13ee2d26-a244a0c27369d291] c.s.c.c.s.client.SalesforceClient        : took 2096ms to successfully GET to 'https://my.salesforce.com/services/data/v52.0/query?
2024-01-29T15:59:12.615-08:00 DEBUG 53077 --- [crm-proxy] [nio-8080-exec-1] [65b83bcea50539ad048191ae13ee2d26-a244a0c27369d291] c.s.c.c.s.client.SalesforceClient        : Security Context: SecurityContextImpl [Authentication=AnonymousAuthenticationToken [Principal=anonymousUser, Credentials=[PROTECTED], Authenticated=true, Details=WebAuthenticationDetails [RemoteIpAddress=0:0:0:0:0:0:0:1, SessionId=null], Granted Authorities=[ROLE_ANONYMOUS]]]

This is the old 2.6 code:

@Component
public class SalesforceOAuth2RestTemplateFactory {
  public RestOperations generateOAuth2RestTemplate(SalesforceProperties salesforceProperties) {
    ResourceOwnerPasswordResourceDetails resource = new ResourceOwnerPasswordResourceDetails();
    resource.setAccessTokenUri(salesforceProperties.getAccessTokenUri());
    resource.setClientId(salesforceProperties.getClientId());
    resource.setAuthenticationScheme(AuthenticationScheme.header);
    resource.setClientAuthenticationScheme(AuthenticationScheme.form);
    resource.setClientSecret(salesforceProperties.getClientSecret());
    resource.setGrantType("password");
    resource.setUsername(salesforceProperties.getUsername());
    resource.setPassword(salesforceProperties.getPassword());

    OAuth2RestTemplate oAuth2RestTemplate =
        new OAuth2RestTemplate(
            resource, new DefaultOAuth2ClientContext(new DefaultAccessTokenRequest()));
    oAuth2RestTemplate.setRetryBadAccessTokens(true);
    oAuth2RestTemplate.setErrorHandler(new SalesforceOAuth2ErrorHandler(resource));

    return oAuth2RestTemplate;
  }

This is my current 3.2 code:

(active profile: local) (environment name: dev01)

I've pasted the following classes in this online clipboard link because my post gets flagged as spam if I tried pasting the code in here sorry about that: SalesforceWebClientFactory.java, SalesforceClient.java, UserController.java, application.yaml, build.gradle.kts:

https://ctxt.io/2/AABwsvBOFw


Solution

  • I noticed your access token response from the Salesforce API does not provide an expires_in field only an issued_at field.

    This is expected behavior since expires_in is RECOMMENDED as per spec: https://datatracker.ietf.org/doc/html/rfc6749#section-5.1

    Digging through Spring Security code, it shows that if the expires_in field is missing, the expiration time would default to 1 second after the issued_at time. Therefore, causing the issue you see with the access token being continuously refreshed even when it’s not necessarily invalid.

    To resolve this issue, you’ll want to add custom token handling by overriding the getTokenResponse() implementing something like this:

    public class CustomClientCredentialsTokenResponseClient implements OAuth2AccessTokenResponseClient<OAuth2ClientCredentialsGrantRequest> {
    
    private final DefaultClientCredentialsTokenResponseClient delegate =
                new DefaultClientCredentialsTokenResponseClient();
    
        @Override
        public OAuth2AccessTokenResponse getTokenResponse(OAuth2ClientCredentialsGrantRequest clientCredentialsGrantRequest) throws OAuth2AuthenticationException {
    
            var response = delegate.getTokenResponse(clientCredentialsGrantRequest);
    
            // expiration time of 1 hour (3600 seconds)
            long defaultExpiresInSeconds = 3600L;
            Instant now = Instant.now();
    
            // here is where you update the expiration
            Instant expiresAt = now.plusSeconds(defaultExpiresInSeconds);
    
            // rebuild the OAuth2AccessToken with the new expiration time
            OAuth2AccessToken accessToken = new OAuth2AccessToken(OAuth2AccessToken.TokenType.BEARER,
                    response.getAccessToken().getTokenValue(), now, expiresAt, response.getAccessToken().getScopes());
    
            return OAuth2AccessTokenResponse.withToken(accessToken.getTokenValue())
                    .tokenType(accessToken.getTokenType())
                    .expiresIn(defaultExpiresInSeconds)
                    .scopes(accessToken.getScopes())
                    .additionalParameters(response.getAdditionalParameters())
                    .build();
        }
    
    }
    

    And then make sure to add this handler to your existing authorizedClientManager which would look something like this:

    public OAuth2AuthorizedClientManager authorizedClientManager(ClientRegistrationRepository clientRegistrationRepository, OAuth2AuthorizedClientService) {
    
        …
        
        OAuth2AuthorizedClientProvider authorizedClientProvider = OAuth2AuthorizedClientProviderBuilder.builder()
        .clientCredentials(clientCredentialsGrantBuilder ->
            clientCredentialsGrantBuilder.accessTokenResponseClient(
            // here is where you reference your custom token handling
            new CustomClientCredentialsTokenResponseClient()))
        .build();
    
    }
    

    And that should resolve it. Everything else you had looks fine and this approach would work with either WebClient or RestClient implementations as both utilize the same authorizedClientManager. Let me know if that works, hope this helps. :)