From d7939a72afc46d27a80f19595aeabc80e3454143 Mon Sep 17 00:00:00 2001 From: boggard Date: Thu, 1 Jul 2021 11:37:01 +0300 Subject: [PATCH] Implement refresh token rotated feature for public clients gh-297 --- ...th2RefreshTokenAuthenticationProvider.java | 30 +++++++- ...freshTokenAuthenticationProviderTests.java | 68 +++++++++++++++++++ 2 files changed, 97 insertions(+), 1 deletion(-) 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);