简体   繁体   中英

Why Spring Boot WebClient OAuth2 (client_credentials) asks for a new token for each request?

I'm trying to create a Spring Boot REST application that has to make a remote REST call to another application protected by OAuth2.

The first application is using the Reactive WebClient to make the call to the second OAuth2 REST application. I've configured the WebClient with grant_type "client_credentials".

application.yml

spring:
  security:
    oauth2:
      client:
        provider:
          client-registration-id:
            token-uri: http://localhost:8080/oauth/token
        registration:
          client-registration-id:
            authorization-grant-type: client_credentials
            client-id: public
            client-secret: test
            client-authentication-method: post
            scope: myscope
@Configuration
public class WebClientConfig {

    @Bean
    WebClient webClient(ReactiveClientRegistrationRepository clientRegistrations) {
        ServerOAuth2AuthorizedClientExchangeFilterFunction oauth = new ServerOAuth2AuthorizedClientExchangeFilterFunction(
                clientRegistrations, new UnAuthenticatedServerOAuth2AuthorizedClientRepository());
        oauth.setDefaultClientRegistrationId("client-registration-id");
        return WebClient.builder().filter(oauth).build();
    }

}
@Component
public class WebClientChronJob {

    Logger logger = LoggerFactory.getLogger(WebClientChronJob.class);

    @Autowired
    private WebClient webClient;

    @Scheduled(fixedRate = 5000)
    public void logResourceServiceResponse() {

        webClient.get()
                .uri("http://localhost:8080/test")
                .retrieve()
                .bodyToMono(String.class)
                .map(string -> "RESPONSE: " + string)
                .subscribe(logger::info);
    }

}

According to the article at this link Baeldung Spring Webclient Oauth2 , the second time that WebClientChronJob runs, the application should request the resource without asking for a token first since the last one hasn't expired. Unfortunately, enabling the debug logs, I noticed the opposite: each time the job requests the resource it's asking for a new token. Please let me know if something is missing in configuration or code.

Netty started on port(s): 8082
Started MyApp in 2.242 seconds (JVM running for 2.717)
HTTP POST http://localhost:8080/oauth/token
Writing form fields [grant_type, scope, client_id, client_secret] (content masked)
Response 200 OK
Decoded [{access_token=nrLr7bHpV0aqr5cQNhv0NjJYvVv3bv, token_type=Bearer, expires_in=86400, scope=rw:profile  (truncated)...]
Cancel signal (to close connection)
HTTP GET http://localhost:8080/test
Response 200 OK
Decoded "{"status":{"description":"ok","success":true},"result":[]}"
ESPONSE: {"status":{"description":"ok","success":true},"result":[]}
HTTP POST http://localhost:8080/oauth/token
Writing form fields [grant_type, scope, client_id, client_secret] (content masked)
Response 200 OK
Decoded [{access_token=CsOxziw6W6J7IoqA8EiF4clhiwVJ8m, token_type=Bearer, expires_in=86400, scope=rw:profile  (truncated)...]
Cancel signal (to close connection)
HTTP GET http://localhost:8080/test
Response 200 OK
Decoded "{"status":{"description":"ok","success":true},"result":[]}"
ESPONSE: {"status":{"description":"ok","success":true},"result":[]}

Below the only dependencies I have in the pom.xml

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>

I found out the solution to my problem. The current implementation of WebClient for the Spring Security version 5.1.x does not ask for a new token once the token expires and probably the Spring's developers decided to ask the token each time. The Spring's developers also decided to fix this bug only in the new version 5.2.0.M2 or (M1) without back-porting the fix to 5.1.x

pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.2.1.RELEASE</version>
    </parent>
    <groupId>net.tuxy</groupId>
    <artifactId>oauth2-client</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>MyApp</name>
    <description>Spring Boot WebClient OAuth2 client_credentials example</description>

    <repositories>
        <repository>
            <id>spring-snapshots</id>
            <name>Spring Snapshots</name>
            <url>https://repo.spring.io/snapshot</url>
            <snapshots>
                <enabled>true</enabled>
            </snapshots>
        </repository>
        <repository>
            <id>spring-milestones</id>
            <name>Spring Milestones</name>
            <url>https://repo.spring.io/milestone</url>
        </repository>
    </repositories>
    <pluginRepositories>
        <pluginRepository>
            <id>spring-snapshots</id>
            <name>Spring Snapshots</name>
            <url>https://repo.spring.io/snapshot</url>
            <snapshots>
                <enabled>true</enabled>
            </snapshots>
        </pluginRepository>
        <pluginRepository>
            <id>spring-milestones</id>
            <name>Spring Milestones</name>
            <url>https://repo.spring.io/milestone</url>
        </pluginRepository>
    </pluginRepositories>

    <properties>
        <java.version>11</java.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-webflux</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.security</groupId>
            <artifactId>spring-security-oauth2-client</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>

        <dependency>
            <groupId>org.springframework.security</groupId>
            <artifactId>spring-security-core</artifactId>
            <version>5.2.0.M2</version>
        </dependency>
        <dependency>
            <groupId>org.springframework.security</groupId>
            <artifactId>spring-security-web</artifactId>
            <version>5.2.0.M2</version>
        </dependency>
        <dependency>
            <groupId>org.springframework.security</groupId>
            <artifactId>spring-security-config</artifactId>
            <version>5.2.0.M2</version>
        </dependency>
        <dependency>
            <groupId>org.springframework.security</groupId>
            <artifactId>spring-security-oauth2-core</artifactId>
            <version>5.2.0.M2</version>
        </dependency>
        <dependency>
            <groupId>org.springframework.security</groupId>
            <artifactId>spring-security-oauth2-client</artifactId>
            <version>5.2.0.M2</version>
        </dependency>

    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>

</project>

That's right, the version 5.2.x.RELEASE doesn't fix this problem.

The version 5.2.0.M3 fix this problem and another problem about "client-authentication-method=POST" which doesn't work. So, you can use it instead of the 5.2.0.M2 .

Recently I had to change the UnAuthenticatedServerOAuth2AuthorizedClientRepository with WebSessionServerOAuth2AuthorizedClientRepository because I had to authenticate the URLs on the same App

@Override
protected void configure(HttpSecurity http) {
    http
        .requestMatchers()
        .antMatchers("/rest/**")
        .authorizeRequests()
        .anyRequest().authenticated();
}

and the token problem was occurring again.

Probably this happens because the token is stored in the client repository and the bug is not fixed yet in WebSessionServerOAuth2AuthorizedClientRepository . Anyway, to solve the issue I've just created a custom client repository extending UnAuthenticatedServerOAuth2AuthorizedClientRepository and overriding the 3 methods inside of it ( loadAuthorizedClient , removeAuthorizedClient and saveAuthorizedClient ) passing null to the Authentication and ServerWebExchange parameteres.

public class BypassAuthenticatedServerOAuth2AuthorizedClientRepository extends UnAuthenticatedServerOAuth2AuthorizedClientRepository {

    @Override
    public <T extends OAuth2AuthorizedClient> Mono<T> loadAuthorizedClient(String clientRegistrationId, Authentication authentication, ServerWebExchange serverWebExchange) {
        return super.loadAuthorizedClient(clientRegistrationId, null, null);
    }

    @Override
    public Mono<Void> saveAuthorizedClient(OAuth2AuthorizedClient authorizedClient, Authentication authentication, ServerWebExchange serverWebExchange) {
        return super.saveAuthorizedClient(authorizedClient, null, null);
    }

    @Override
    public Mono<Void> removeAuthorizedClient(String clientRegistrationId, Authentication authentication, ServerWebExchange serverWebExchange) {
        return super.removeAuthorizedClient(clientRegistrationId, null, null);
    }
}
@Configuration
public class WebClientConfig {

    @Bean
    WebClient webClient(ReactiveClientRegistrationRepository clientRegistrations) {
        ServerOAuth2AuthorizedClientExchangeFilterFunction oauth = new ServerOAuth2AuthorizedClientExchangeFilterFunction(
                clientRegistrations, new BypassAuthenticatedServerOAuth2AuthorizedClientRepository());
        oauth.setDefaultClientRegistrationId("client-registration-id");
        return WebClient.builder().filter(oauth).build();
    }

}

In this way, I was able to enable authentication and the token was requested only once until it was expired.

Spring moved on and since @angus asked for an alternative approach to the now deprecated UnAuthenticatedServerOAuth2AuthorizedClientRepository , I wanted to share my implementation. This is using Spring Boot 2.4.4 and Spring Security 5.4.5 respectively.

The recommended alternative to UnAuthenticatedServerOAuth2AuthorizedClientRepository is AuthorizedClientServiceReactiveOAuth2AuthorizedClientManager . Also, the recommended way to provide a WebClient to your beans is by injecting the WebClient.Builder . So, configure your WebClient.Builder like this:

@Configuration
public class OAuth2ClientConfiguration {

    @Bean
    public WebClientCustomizer oauth2WebClientCustomizer(
            ReactiveOAuth2AuthorizedClientManager reactiveOAuth2AuthorizedClientManager) {
        ServerOAuth2AuthorizedClientExchangeFilterFunction oAuth2AuthorizedClientExchangeFilterFunction =
                new ServerOAuth2AuthorizedClientExchangeFilterFunction(reactiveOAuth2AuthorizedClientManager);

        oAuth2AuthorizedClientExchangeFilterFunction.setDefaultClientRegistrationId("api-client");

        return webClientBuilder ->
                webClientBuilder
                        .filter(oAuth2AuthorizedClientExchangeFilterFunction);
    }

    @Bean
    public ReactiveOAuth2AuthorizedClientManager reactiveOAuth2AuthorizedClientManager(
            ReactiveClientRegistrationRepository registrationRepository,
            ReactiveOAuth2AuthorizedClientService authorizedClientService) {
        AuthorizedClientServiceReactiveOAuth2AuthorizedClientManager authorizedClientManager =
                new AuthorizedClientServiceReactiveOAuth2AuthorizedClientManager(
                        registrationRepository, authorizedClientService);

        authorizedClientManager.setAuthorizedClientProvider(
                new ClientCredentialsReactiveOAuth2AuthorizedClientProvider());

        return authorizedClientManager;
    }
}

And that's all there is to it, really. The Authorization Token will be fetched exactly once, as long as it's valid for a resource request.

Slightly off topic: If you want to prevent the Authorization request to the Token URI from happening altogether in your integration tests, you might be interested in this .


Testing

Following is a corresponding src/test/resources/application.yml and an integration test using MockServer to mock both the resource server and the authorization server to prove the Token URI is called exactly once for multiple resource requests.

spring:
  security:
    oauth2:
      client:
        registration:
          api-client:
            authorization-grant-type: client_credentials
            client-id: test-client
            client-secret: 6b30087f-65e2-4d89-a69e-08cb3c9f34d2
            provider: some-keycloak
        provider:
          some-keycloak:
            token-uri: http://localhost:1234/token/uri
api:
  base-url: http://localhost:1234/api/v1
@SpringBootTest
@ExtendWith(MockServerExtension.class)
@MockServerSettings(ports = 1234)
class TheRestClientImplIT {

    @Autowired
    TheRestClient theRestClient;

    @BeforeEach
    void setUpTest(MockServerClient mockServer) {
        mockServer
                .when(HttpRequest
                        .request("/token/uri"))
                .respond(HttpResponse
                        .response("{\n" +
                                "    \"access_token\": \"c29tZS10b2tlbg==\",\n" +
                                "    \"expires_in\": 300,\n" +
                                "    \"token_type\": \"bearer\",\n" +
                                "    \"not-before-policy\": 0,\n" +
                                "    \"session_state\": \"7502cf31-b210-4754-b919-07e1d8493fa3\"\n" +
                                "}")
                        .withContentType(MediaType.APPLICATION_JSON));
        mockServer
                .when(HttpRequest
                        .request("/api/v1/some-resource")
                        .withHeader("Authorization", "Bearer c29tZS10b2tlbg=="))
                .respond(HttpResponse
                        .response("Hello from resource!"));
    }

    @Test
    void should_access_protected_resource_more_than_once_but_request_a_token_exactly_once(MockServerClient mockServer) {
        int resourceRequestCount = 2; // how often should the resource be requested?

        Stream
                .iterate(1, i -> ++i)
                .limit(resourceRequestCount)
                .forEach(i -> {
                    LoggerFactory
                            .getLogger(TheRestClientImplIT.class)
                            .info("Performing request number: {}", i);

                    StepVerifier
                            .create(theRestClient.getResource())
                            .assertNext(response -> {
                                assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
                                assertThat(response.getBody()).isEqualTo("Hello from resource!");
                            })
                            .verifyComplete();
                });

        // verify token request happened exactly once
        mockServer.verify(HttpRequest
                        .request("/token/uri"),
                VerificationTimes.once());

        // verify resource request happened as often as defined
        mockServer.verify(HttpRequest
                        .request("/api/v1/some-resource")
                        .withHeader("Authorization", "Bearer c29tZS10b2tlbg=="),
                VerificationTimes.exactly(resourceRequestCount));
    }
}

For reference, this is TheRestClient implementation:

@Component
public class TheRestClientImpl implements TheRestClient {

    private final WebClient webClient;

    @Autowired
    public TheRestClientImpl(WebClient.Builder webClientBuilder,
                             @Value("${api.base-url}") String apiBaseUrl) {
        this.webClient = webClientBuilder
                .baseUrl(apiBaseUrl)
                .build();
    }

    @Override
    public Mono<ResponseEntity<String>> getResource() {
        return webClient
                .get()
                .uri("/some-resource")
                .retrieve()
                .toEntity(String.class);
    }
}

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM