spring-cloud / spring-cloud/spring-cloud-gateway

Error in Spring Cloud GatewayFilter for async calls, on token renewal, using refresh token that gets revoked after single use

Open
#3,656 2 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

waiting-for-triage
Dominant language
Java
Stars
4.9k
Forks
3.5k
Avg merge
20h 57m
Merged PRs (30d)
8

Description

I am using Spring Cloud Gateway in authorization code flow and have a filter to authorize every request and add a custom jwt thereafter. The oAuth server provides refresh and access token on login, but the refresh token can be used only once as it's revoked after providing a new refresh and access token.
My api gateway is working well when the web client sends requests at a normal pace. However, there is now a race condition when access token has expired and multiple async calls happen. If two or more requests are made from the browser simultaneously, request 1 triggers a refresh with the authorization server. Before request 1 completes, request 2 reaches the gateway and triggers another refresh. The first request will succeed, but the second one will fail because the old refresh token has been revoked.
I am using spring-boot v3.3.3 and spring-cloud-gateway v4.1.5
Please note that I can't change the single use refresh token policy in my oauth server

Is there any configuration that can address this issue, or can I make any changes in the filter? Below is my filter, and it gets the error in the authorizeClient method.

import org.springframework.cloud.gateway.filter.GatewayFilter;
import org.springframework.cloud.gateway.filter.factory.AbstractGatewayFilterFactory;
import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.client.ClientAuthorizationException;
import org.springframework.security.oauth2.client.OAuth2AuthorizeRequest;
import org.springframework.security.oauth2.client.ReactiveOAuth2AuthorizedClientManager;
import org.springframework.security.oauth2.client.ReactiveOAuth2AuthorizedClientProvider;
import org.springframework.security.oauth2.client.ReactiveOAuth2AuthorizedClientProviderBuilder;
import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken;
import org.springframework.security.oauth2.client.registration.ReactiveClientRegistrationRepository;
import org.springframework.security.oauth2.client.web.DefaultReactiveOAuth2AuthorizedClientManager;
import org.springframework.security.oauth2.client.web.server.ServerOAuth2AuthorizedClientRepository;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import org.springframework.web.server.WebSession;
import reactor.core.publisher.Mono;
import com.abc.service.UserService;
import java.time.Duration;

@Component
@Slf4j
public class CustomTokenRelayGatewayFilterFactory extends AbstractGatewayFilterFactory<Object> {
    private final ReactiveOAuth2AuthorizedClientManager authorizedClientManager;
    
    private final UserService userService;

    public CustomTokenRelayGatewayFilterFactory(final ServerOAuth2AuthorizedClientRepository authorizedClientRepository,
                                                final ReactiveClientRegistrationRepository clientRegistrationRepository,
                                                final UserService userService) {
        super(Object.class);
        userService = userService;
        authorizedClientManager = generateDefaultAuthorizedClientManager(clientRegistrationRepository,
                authorizedClientRepository);
    }

    private ReactiveOAuth2AuthorizedClientManager generateDefaultAuthorizedClientManager(
            final ReactiveClientRegistrationRepository clientRegistrationRepository,
            final ServerOAuth2AuthorizedClientRepository authorizedClientRepository) {
        final Duration tokenClockSkewDuration = Duration.ofSeconds(5);
        final ReactiveOAuth2AuthorizedClientProvider authorizedClientProvider
                = ReactiveOAuth2AuthorizedClientProviderBuilder.builder().authorizationCode()
                .refreshToken(configurer -> configurer.clockSkew(tokenClockSkewDuration))
                .clientCredentials(configurer -> configurer.clockSkew(tokenClockSkewDuration))
                .password(configurer -> configurer.clockSkew(tokenClockSkewDuration)).build();
        final DefaultReactiveOAuth2AuthorizedClientManager defaultAuthorizedClientManager
                = new DefaultReactiveOAuth2AuthorizedClientManager(clientRegistrationRepository,
                authorizedClientRepository);
        defaultAuthorizedClientManager.setAuthorizedClientProvider(authorizedClientProvider);

        return defaultAuthorizedClientManager;
    }

    @Override
    public GatewayFilter apply(final Object config) {
        return (exchange, chain) ->
                exchange.getSession().flatMap(mapSession ->
                        exchange.getPrincipal().log("token-relay-filter")
                                .filter(principal -> principal instanceof OAuth2AuthenticationToken)
                                .cast(OAuth2AuthenticationToken.class)
                                .flatMap(this::authorizeClient)
                                .map(auth2AuthenticationToken -> auth2AuthenticationToken.getName())
                                .flatMap(userService::getRoles)
                                .flatMap(userRoles -> this.withBearerAuth(exchange, userRoles))
                                .onErrorResume(ClientAuthorizationException.class,
                                        e -> Mono.defer(() -> exchange.getSession()
                                                .map(WebSession::invalidate))
                                                .map(a -> exchange))
                                .defaultIfEmpty(exchange)
                                .flatMap(chain::filter)

                );
    }

    Mono<ServerWebExchange> withBearerAuth(final ServerWebExchange exchange, final UserRoles userRoles) {
        // add custom jwt token in auth header
    }

    Mono<OAuth2AuthenticationToken> authorizeClient(final OAuth2AuthenticationToken oAuth2AuthenticationToken) {
        final String clientRegistrationId = oAuth2AuthenticationToken.getAuthorizedClientRegistrationId();

        return Mono.defer(() -> authorizedClientManager.authorize(
                        createOAuth2AuthorizeRequest(clientRegistrationId, oAuth2AuthenticationToken)))
                .map(oAuth2AuthorizedClient -> oAuth2AuthenticationToken);
    }
    
    private OAuth2AuthorizeRequest createOAuth2AuthorizeRequest(final String clientRegistrationId,
                                                                final Authentication principal) {
        return OAuth2AuthorizeRequest.withClientRegistrationId(clientRegistrationId).principal(principal).build();
    }
}

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start with CustomTokenRelayGatewayFilterFactory.apply and authorizeClient, then trace ReactiveOAuth2AuthorizedClientManager.authorize in the configured refresh-token provider. Reproduce concurrent gateway requests after access-token expiry with a single-use refresh token. Done means concurrent requests no longer cause a refresh-token reuse failure while preserving token relay behavior.

Written by the indexing model from the issue text.

Assessment

Tech stack
java, spring-boot
Domain
api, authentication, backend
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Stale
Clarity
Mostly clear
Newbie friendliness
45/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.