From 56ac997de03253b80ef279cf10fd2e939ad821fb Mon Sep 17 00:00:00 2001 From: amalsaidov Date: Fri, 13 Nov 2026 11:18:13 +0700 Subject: [PATCH] Use requested scope when refresh response omits it When a refresh token response omits the scope parameter, RFC 6749 section 5.1 defines the granted scope as identical to the scope requested by the client. Both refresh token response clients fell back to the previously granted scope instead, so a request that narrowed the scope kept the wider scope in the resulting access token. Fall back to the requested scope when it is present, and otherwise to the previously granted scope. Closes gh-20063 Signed-off-by: amalsaidov --- .../RestClientRefreshTokenTokenResponseClient.java | 10 +++++++++- ...ientReactiveRefreshTokenTokenResponseClient.java | 10 +++++++++- ...tClientRefreshTokenTokenResponseClientTests.java | 11 +++++++++++ ...eactiveRefreshTokenTokenResponseClientTests.java | 13 +++++++++++++ 4 files changed, 42 insertions(+), 2 deletions(-) diff --git a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/endpoint/RestClientRefreshTokenTokenResponseClient.java b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/endpoint/RestClientRefreshTokenTokenResponseClient.java index 6d4fa6dfcac..1938329aac5 100644 --- a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/endpoint/RestClientRefreshTokenTokenResponseClient.java +++ b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/endpoint/RestClientRefreshTokenTokenResponseClient.java @@ -49,7 +49,15 @@ private OAuth2AccessTokenResponse populateTokenResponse(OAuth2RefreshTokenGrantR OAuth2AccessTokenResponse.Builder tokenResponseBuilder = OAuth2AccessTokenResponse .withResponse(accessTokenResponse); if (CollectionUtils.isEmpty(accessTokenResponse.getAccessToken().getScopes())) { - tokenResponseBuilder.scopes(grantRequest.getAccessToken().getScopes()); + // RFC 6749 section 5.1 allows the response to omit the scope when it is + // identical to the scope requested by the client. Fall back to the requested + // scope, if present, and otherwise to the previously granted scope. + if (!CollectionUtils.isEmpty(grantRequest.getScopes())) { + tokenResponseBuilder.scopes(grantRequest.getScopes()); + } + else { + tokenResponseBuilder.scopes(grantRequest.getAccessToken().getScopes()); + } } if (accessTokenResponse.getRefreshToken() == null) { // Reuse existing refresh token diff --git a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/endpoint/WebClientReactiveRefreshTokenTokenResponseClient.java b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/endpoint/WebClientReactiveRefreshTokenTokenResponseClient.java index fe96939298c..4027de33723 100644 --- a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/endpoint/WebClientReactiveRefreshTokenTokenResponseClient.java +++ b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/endpoint/WebClientReactiveRefreshTokenTokenResponseClient.java @@ -55,7 +55,15 @@ private OAuth2AccessTokenResponse populateTokenResponse(OAuth2RefreshTokenGrantR OAuth2AccessTokenResponse.Builder tokenResponseBuilder = OAuth2AccessTokenResponse .withResponse(accessTokenResponse); if (CollectionUtils.isEmpty(accessTokenResponse.getAccessToken().getScopes())) { - tokenResponseBuilder.scopes(grantRequest.getAccessToken().getScopes()); + // RFC 6749 section 5.1 allows the response to omit the scope when it is + // identical to the scope requested by the client. Fall back to the requested + // scope, if present, and otherwise to the previously granted scope. + if (!CollectionUtils.isEmpty(grantRequest.getScopes())) { + tokenResponseBuilder.scopes(grantRequest.getScopes()); + } + else { + tokenResponseBuilder.scopes(grantRequest.getAccessToken().getScopes()); + } } if (accessTokenResponse.getRefreshToken() == null) { // Reuse existing refresh token diff --git a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/endpoint/RestClientRefreshTokenTokenResponseClientTests.java b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/endpoint/RestClientRefreshTokenTokenResponseClientTests.java index bad6b3fa4f2..46269a12d1b 100644 --- a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/endpoint/RestClientRefreshTokenTokenResponseClientTests.java +++ b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/endpoint/RestClientRefreshTokenTokenResponseClientTests.java @@ -264,6 +264,17 @@ public void getTokenResponseWhenSuccessResponseDoesNotIncludeScopeThenAccessToke assertThat(accessTokenResponse.getAccessToken().getScopes()).containsExactlyInAnyOrder("read", "write"); } + @Test + public void getTokenResponseWhenResponseOmitsScopeAndRequestNarrowsScopeThenAccessTokenHasRequestedScope() { + this.server.enqueue(MockResponses.json("access-token-response.json")); + ClientRegistration clientRegistration = this.clientRegistration.build(); + OAuth2RefreshTokenGrantRequest grantRequest = new OAuth2RefreshTokenGrantRequest(clientRegistration, + this.accessToken, this.refreshToken, Collections.singleton("read")); + OAuth2AccessTokenResponse accessTokenResponse = this.tokenResponseClient.getTokenResponse(grantRequest); + assertThat(accessTokenResponse).isNotNull(); + assertThat(accessTokenResponse.getAccessToken().getScopes()).containsExactly("read"); + } + @Test public void getTokenResponseWhenRequestDoesNotIncludeScopeThenAccessTokenHasResponseScope() throws Exception { this.server.enqueue(MockResponses.json("access-token-response-read.json")); diff --git a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/endpoint/WebClientReactiveRefreshTokenTokenResponseClientTests.java b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/endpoint/WebClientReactiveRefreshTokenTokenResponseClientTests.java index 0ab0f4e3487..c81e3150b25 100644 --- a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/endpoint/WebClientReactiveRefreshTokenTokenResponseClientTests.java +++ b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/endpoint/WebClientReactiveRefreshTokenTokenResponseClientTests.java @@ -236,6 +236,19 @@ public void getTokenResponseWhenSuccessResponseIncludesScopeThenAccessTokenHasRe assertThat(accessTokenResponse.getAccessToken().getScopes()).containsExactly("read"); } + @Test + public void getTokenResponseWhenResponseOmitsScopeAndRequestNarrowsScopeThenAccessTokenHasRequestedScope() { + this.server.enqueue(MockResponses.json("access-token-response.json")); + OAuth2RefreshTokenGrantRequest refreshTokenGrantRequest = new OAuth2RefreshTokenGrantRequest( + this.clientRegistrationBuilder.build(), this.accessToken, this.refreshToken, + Collections.singleton("read")); + OAuth2AccessTokenResponse accessTokenResponse = this.tokenResponseClient + .getTokenResponse(refreshTokenGrantRequest) + .block(); + assertThat(accessTokenResponse).isNotNull(); + assertThat(accessTokenResponse.getAccessToken().getScopes()).containsExactly("read"); + } + @Test public void getTokenResponseWhenErrorResponseThenThrowOAuth2AuthorizationException() { this.server.enqueue(MockResponses.json("unauthorized-client-response.json").setResponseCode(400));