Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement refresh token rotated feature for public clients gh-297 #335

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -64,6 +65,7 @@
* @see JwtEncodingContext
* @see <a target="_blank" href="https://tools.ietf.org/html/rfc6749#section-1.5">Section 1.5 Refresh Token Grant</a>
* @see <a target="_blank" href="https://tools.ietf.org/html/rfc6749#section-6">Section 6 Refreshing an Access Token</a>
* @see <a target="_blank" href="https://datatracker.ietf.org/doc/html/draft-ietf-oauth-browser-based-apps-07#section-8">Section 8 Refresh Tokens</a>
*/
public class OAuth2RefreshTokenAuthenticationProvider implements AuthenticationProvider {
private static final StringKeyGenerator TOKEN_GENERATOR = new Base64StringKeyGenerator(Base64.getUrlEncoder().withoutPadding(), 96);
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<String> scope) {
Instant issuedAt = Instant.now();
Instant expiresAt = issuedAt.plus(1, ChronoUnit.HOURS);
Expand Down