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));