diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2RefreshTokenAuthenticationProvider.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2RefreshTokenAuthenticationProvider.java
index 56db12a04..8f75e53c2 100644
--- a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2RefreshTokenAuthenticationProvider.java
+++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2RefreshTokenAuthenticationProvider.java
@@ -47,6 +47,7 @@
import org.springframework.security.oauth2.server.authorization.JwtEncodingContext;
import org.springframework.security.oauth2.server.authorization.OAuth2TokenCustomizer;
import org.springframework.util.Assert;
+import org.springframework.util.StringUtils;
import static org.springframework.security.oauth2.server.authorization.authentication.OAuth2AuthenticationProviderUtils.getAuthenticatedClientElseThrowInvalidClient;
@@ -64,6 +65,7 @@
* @see JwtEncodingContext
* @see Section 1.5 Refresh Token Grant
* @see Section 6 Refreshing an Access Token
+ * @see Section 8 Refresh Tokens
*/
public class OAuth2RefreshTokenAuthenticationProvider implements AuthenticationProvider {
private static final StringKeyGenerator TOKEN_GENERATOR = new Base64StringKeyGenerator(Base64.getUrlEncoder().withoutPadding(), 96);
@@ -171,7 +173,20 @@ public Authentication authenticate(Authentication authentication) throws Authent
OAuth2RefreshToken currentRefreshToken = refreshToken.getToken();
if (!tokenSettings.reuseRefreshTokens()) {
- currentRefreshToken = generateRefreshToken(tokenSettings.refreshTokenTimeToLive());
+ Duration refreshTokenTimeToLive = tokenSettings.refreshTokenTimeToLive();
+ boolean isPublicClient = !StringUtils.hasText(registeredClient.getClientSecret());
+ if (isPublicClient) {
+ // As per https://datatracker.ietf.org/doc/html/draft-ietf-oauth-browser-based-apps-07#section-8
+ // - SHOULD rotate refresh tokens on each use, in order to be able to
+ // detect a stolen refresh token if one is replayed
+ // - upon issuing a rotated refresh token, MUST NOT extend the lifetime
+ // of the new refresh token beyond the lifetime of the initial
+ // refresh token if the refresh token has a preestablished expiration time
+ currentRefreshToken = generateReducedRefreshToken(refreshTokenTimeToLive,
+ currentRefreshToken.getIssuedAt());
+ } else {
+ currentRefreshToken = generateRefreshToken(refreshTokenTimeToLive);
+ }
}
// @formatter:off
@@ -199,4 +214,17 @@ static OAuth2RefreshToken generateRefreshToken(Duration tokenTimeToLive) {
Instant expiresAt = issuedAt.plus(tokenTimeToLive);
return new OAuth2RefreshToken2(TOKEN_GENERATOR.generateKey(), issuedAt, expiresAt);
}
+
+ private static OAuth2RefreshToken generateReducedRefreshToken(Duration tokenTimeToLive,
+ Instant currentRefreshTokenIssuedAt) {
+ Duration reducedTimeToLife;
+ if (currentRefreshTokenIssuedAt != null) {
+ Duration currentTokenDisuseDuration = Duration.between(currentRefreshTokenIssuedAt, Instant.now());
+ reducedTimeToLife = tokenTimeToLive.minus(currentTokenDisuseDuration);
+ } else {
+ reducedTimeToLife = tokenTimeToLive;
+ }
+
+ return generateRefreshToken(reducedTimeToLife);
+ }
}
diff --git a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2RefreshTokenAuthenticationProviderTests.java b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2RefreshTokenAuthenticationProviderTests.java
index 65a1fc1ea..ed6c19fea 100644
--- a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2RefreshTokenAuthenticationProviderTests.java
+++ b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2RefreshTokenAuthenticationProviderTests.java
@@ -16,6 +16,7 @@
package org.springframework.security.oauth2.server.authorization.authentication;
import java.security.Principal;
+import java.time.Duration;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Collections;
@@ -364,6 +365,73 @@ public void authenticateWhenRevokedRefreshTokenThenThrowOAuth2AuthenticationExce
.isEqualTo(OAuth2ErrorCodes.INVALID_GRANT);
}
+ @Test
+ public void authenticateWhenClientIsPublicThenIssueReducedRefreshToken() {
+ Duration refreshTokenTimeToLive = Duration.ofHours(24);
+ Duration currentTokenDisuseDuration = Duration.ofHours(1);
+ RegisteredClient registeredClient = TestRegisteredClients.registeredPublicClient()
+ .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
+ .tokenSettings(tokenSettings -> {
+ tokenSettings.reuseRefreshTokens(false);
+ tokenSettings.refreshTokenTimeToLive(refreshTokenTimeToLive);
+ })
+ .build();
+ OAuth2RefreshToken refreshToken = new OAuth2RefreshToken2("refresh-token",
+ Instant.now().minus(currentTokenDisuseDuration), Instant.now().plus(refreshTokenTimeToLive));
+ OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient)
+ .token(refreshToken)
+ .build();
+ when(this.authorizationService.findByToken(
+ eq(authorization.getRefreshToken().getToken().getTokenValue()),
+ eq(OAuth2TokenType.REFRESH_TOKEN)))
+ .thenReturn(authorization);
+
+ OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken(registeredClient);
+ OAuth2RefreshTokenAuthenticationToken authentication = new OAuth2RefreshTokenAuthenticationToken(
+ authorization.getRefreshToken().getToken().getTokenValue(), clientPrincipal, null, null);
+
+ OAuth2AccessTokenAuthenticationToken authenticationToken = (OAuth2AccessTokenAuthenticationToken)
+ this.authenticationProvider.authenticate(authentication);
+
+ assertThat(authenticationToken.getRefreshToken()).isNotNull();
+ assertThat(authenticationToken.getRefreshToken().getExpiresAt())
+ .isNotNull()
+ .isBeforeOrEqualTo(Instant.now().plus(refreshTokenTimeToLive.minus(currentTokenDisuseDuration)));
+ }
+
+ @Test
+ public void authenticateWhenClientIsPublicAndCurrentTokenHasNotIssuedAtThenGenerateRefreshToken() {
+ Duration refreshTokenTimeToLive = Duration.ofHours(24);
+ RegisteredClient registeredClient = TestRegisteredClients.registeredPublicClient()
+ .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
+ .tokenSettings(tokenSettings -> {
+ tokenSettings.reuseRefreshTokens(false);
+ tokenSettings.refreshTokenTimeToLive(refreshTokenTimeToLive);
+ })
+ .build();
+ OAuth2RefreshToken refreshToken = new OAuth2RefreshToken2("refresh-token",
+ null, Instant.now().plus(refreshTokenTimeToLive));
+ OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient)
+ .token(refreshToken)
+ .build();
+ when(this.authorizationService.findByToken(
+ eq(authorization.getRefreshToken().getToken().getTokenValue()),
+ eq(OAuth2TokenType.REFRESH_TOKEN)))
+ .thenReturn(authorization);
+
+ OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken(registeredClient);
+ OAuth2RefreshTokenAuthenticationToken authentication = new OAuth2RefreshTokenAuthenticationToken(
+ authorization.getRefreshToken().getToken().getTokenValue(), clientPrincipal, null, null);
+
+ OAuth2AccessTokenAuthenticationToken authenticationToken = (OAuth2AccessTokenAuthenticationToken)
+ this.authenticationProvider.authenticate(authentication);
+
+ assertThat(authenticationToken.getRefreshToken()).isNotNull();
+ assertThat(authenticationToken.getRefreshToken().getExpiresAt())
+ .isNotNull()
+ .isBeforeOrEqualTo(Instant.now().plus(refreshTokenTimeToLive));
+ }
+
private static Jwt createJwt(Set scope) {
Instant issuedAt = Instant.now();
Instant expiresAt = issuedAt.plus(1, ChronoUnit.HOURS);