From 4e3be62355f5fa01903b40c1eaf9706dc990a161 Mon Sep 17 00:00:00 2001 From: Dawid Szczepaniak Date: Fri, 3 Jul 2026 00:04:40 +0200 Subject: [PATCH] Add Clock support to OAuth2Authorization Closes gh-19413 Signed-off-by: Dawid Szczepaniak --- .../authorization/OAuth2Authorization.java | 50 ++++++- ...Auth2AuthorizationServerJacksonModule.java | 3 + ...uth2AuthorizationServerJackson2Module.java | 2 + .../OAuth2AuthorizationTokenMixin.java | 42 ++++++ .../OAuth2AuthorizationTests.java | 125 ++++++++++++++++++ 5 files changed, 218 insertions(+), 4 deletions(-) create mode 100644 oauth2/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/jackson2/OAuth2AuthorizationTokenMixin.java diff --git a/oauth2/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/OAuth2Authorization.java b/oauth2/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/OAuth2Authorization.java index 035c57a8fbb..6bbea0a69f4 100644 --- a/oauth2/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/OAuth2Authorization.java +++ b/oauth2/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/OAuth2Authorization.java @@ -18,6 +18,7 @@ import java.io.Serial; import java.io.Serializable; +import java.time.Clock; import java.time.Instant; import java.util.Collections; import java.util.HashMap; @@ -238,12 +239,16 @@ public static Builder withRegisteredClient(RegisteredClient registeredClient) { public static Builder from(OAuth2Authorization authorization) { Assert.notNull(authorization, "authorization cannot be null"); Assert.notNull(authorization.tokens, "tokens cannot be null"); - return new Builder(authorization.getRegisteredClientId()).id(authorization.getId()) + Builder builder = new Builder(authorization.getRegisteredClientId()).id(authorization.getId()) .principalName(authorization.getPrincipalName()) .authorizationGrantType(authorization.getAuthorizationGrantType()) .authorizedScopes(authorization.getAuthorizedScopes()) .tokens(authorization.tokens) .attributes((attrs) -> attrs.putAll(authorization.getAttributes())); + if (!authorization.tokens.isEmpty()) { + builder.clock(authorization.tokens.values().iterator().next().getClock()); + } + return builder; } /** @@ -273,6 +278,8 @@ public static class Token implements Serializable { private final Map metadata; + private transient @Nullable Clock clock; + protected Token(T token) { this(token, defaultMetadata()); } @@ -280,6 +287,7 @@ protected Token(T token) { protected Token(T token, Map metadata) { this.token = token; this.metadata = Collections.unmodifiableMap(metadata); + } /** @@ -304,7 +312,7 @@ public boolean isInvalidated() { * @return {@code true} if the token has expired, {@code false} otherwise */ public boolean isExpired() { - return getToken().getExpiresAt() != null && Instant.now().isAfter(getToken().getExpiresAt()); + return getToken().getExpiresAt() != null && this.getClock().instant().isAfter(getToken().getExpiresAt()); } /** @@ -317,7 +325,7 @@ public boolean isBeforeUse() { if (!CollectionUtils.isEmpty(getClaims())) { notBefore = (Instant) getClaims().get("nbf"); } - return notBefore != null && Instant.now().isBefore(notBefore); + return notBefore != null && this.getClock().instant().isBefore(notBefore); } /** @@ -362,6 +370,15 @@ protected static Map defaultMetadata() { return metadata; } + /** + * Returns the {@link Clock} used for computing time-based checks. + * @return the {@link Clock} to use, or {@link Clock#systemDefaultZone()} if not + * set + */ + protected Clock getClock() { + return (this.clock != null) ? this.clock : Clock.systemDefaultZone(); + } + @Override public boolean equals(Object obj) { if (this == obj) { @@ -396,6 +413,8 @@ public static class Builder { private @Nullable Set authorizedScopes; + private transient @Nullable Clock clock; + private Map, Token> tokens = new HashMap<>(); private final Map attributes = new HashMap<>(); @@ -491,7 +510,9 @@ public Builder token(T token, Consumer tokenClass = token.getClass(); - this.tokens.put(tokenClass, new Token<>(token, metadata)); + Token tokenWithClock = new Token<>(token, metadata); + tokenWithClock.clock = this.clock; + this.tokens.put(tokenClass, tokenWithClock); return this; } @@ -570,11 +591,32 @@ public OAuth2Authorization build() { authorization.authorizationGrantType = this.authorizationGrantType; authorization.authorizedScopes = Collections.unmodifiableSet(!CollectionUtils.isEmpty(this.authorizedScopes) ? new HashSet<>(this.authorizedScopes) : new HashSet<>()); + + if (this.clock != null && this.tokens != null) { + for (Token token : this.tokens.values()) { + token.clock = this.clock; + } + } + authorization.tokens = Collections.unmodifiableMap(this.tokens); authorization.attributes = Collections.unmodifiableMap(this.attributes); return authorization; } + /** + * Sets the {@link Clock} used for determining if a {@link Token} is + * {@link Token#isExpired() expired} or {@link Token#isBeforeUse() before use}. If + * not set, defaults to {@link Clock#systemDefaultZone()}. + * @param clock the {@link Clock} to use + * @return the {@link Builder} for further configuration + * @since 7.1.1 + */ + public Builder clock(Clock clock) { + Assert.notNull(clock, "clock cannot be null"); + this.clock = clock; + return this; + } + } } diff --git a/oauth2/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/jackson/OAuth2AuthorizationServerJacksonModule.java b/oauth2/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/jackson/OAuth2AuthorizationServerJacksonModule.java index ea23b1336ee..6239d231e26 100644 --- a/oauth2/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/jackson/OAuth2AuthorizationServerJacksonModule.java +++ b/oauth2/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/jackson/OAuth2AuthorizationServerJacksonModule.java @@ -26,8 +26,10 @@ import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest; import org.springframework.security.oauth2.jose.jws.MacAlgorithm; import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm; +import org.springframework.security.oauth2.server.authorization.OAuth2Authorization; import org.springframework.security.oauth2.server.authorization.authentication.OAuth2TokenExchangeActor; import org.springframework.security.oauth2.server.authorization.authentication.OAuth2TokenExchangeCompositeAuthenticationToken; +import org.springframework.security.oauth2.server.authorization.jackson2.OAuth2AuthorizationTokenMixin; import org.springframework.security.oauth2.server.authorization.settings.OAuth2TokenFormat; /** @@ -84,6 +86,7 @@ public void setupModule(SetupContext context) { context.setMixIn(SignatureAlgorithm.class, JwsAlgorithmMixin.class); context.setMixIn(MacAlgorithm.class, JwsAlgorithmMixin.class); context.setMixIn(OAuth2TokenFormat.class, OAuth2TokenFormatMixin.class); + context.setMixIn(OAuth2Authorization.Token.class, OAuth2AuthorizationTokenMixin.class); } } diff --git a/oauth2/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/jackson2/OAuth2AuthorizationServerJackson2Module.java b/oauth2/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/jackson2/OAuth2AuthorizationServerJackson2Module.java index 6e6358600b0..070757d7b2e 100644 --- a/oauth2/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/jackson2/OAuth2AuthorizationServerJackson2Module.java +++ b/oauth2/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/jackson2/OAuth2AuthorizationServerJackson2Module.java @@ -28,6 +28,7 @@ import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest; import org.springframework.security.oauth2.jose.jws.MacAlgorithm; import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm; +import org.springframework.security.oauth2.server.authorization.OAuth2Authorization; import org.springframework.security.oauth2.server.authorization.authentication.OAuth2TokenExchangeActor; import org.springframework.security.oauth2.server.authorization.authentication.OAuth2TokenExchangeCompositeAuthenticationToken; import org.springframework.security.oauth2.server.authorization.settings.OAuth2TokenFormat; @@ -97,6 +98,7 @@ public void setupModule(SetupContext context) { context.setMixInAnnotations(MacAlgorithm.class, JwsAlgorithmMixin.class); context.setMixInAnnotations(OAuth2TokenFormat.class, OAuth2TokenFormatMixin.class); context.setMixInAnnotations(String[].class, StringArrayMixin.class); + context.setMixInAnnotations(OAuth2Authorization.Token.class, OAuth2AuthorizationTokenMixin.class); } } diff --git a/oauth2/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/jackson2/OAuth2AuthorizationTokenMixin.java b/oauth2/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/jackson2/OAuth2AuthorizationTokenMixin.java new file mode 100644 index 00000000000..b1c3e14e567 --- /dev/null +++ b/oauth2/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/jackson2/OAuth2AuthorizationTokenMixin.java @@ -0,0 +1,42 @@ +/* + * Copyright 2004-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.security.oauth2.server.authorization.jackson2; + +import java.time.Clock; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import org.jspecify.annotations.Nullable; + +import org.springframework.security.oauth2.server.authorization.OAuth2Authorization; +import org.springframework.security.oauth2.server.authorization.jackson.OAuth2AuthorizationServerJacksonModule; + +/** + * This mixin class is used to serialize/deserialize {@link OAuth2Authorization.Token}. + * + * @author Dawid Szczepaniak + * @since 7.1.1 + * @see OAuth2Authorization.Token + * @see OAuth2AuthorizationServerJackson2Module + * @see OAuth2AuthorizationServerJacksonModule + */ +@SuppressWarnings("removal") +public abstract class OAuth2AuthorizationTokenMixin { + + @JsonIgnore + @Nullable private Clock clock; + +} diff --git a/oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/OAuth2AuthorizationTests.java b/oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/OAuth2AuthorizationTests.java index 1dff15d776d..93ebc15e8fc 100644 --- a/oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/OAuth2AuthorizationTests.java +++ b/oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/OAuth2AuthorizationTests.java @@ -16,8 +16,13 @@ package org.springframework.security.oauth2.server.authorization; +import java.time.Clock; import java.time.Instant; +import java.time.ZoneOffset; import java.time.temporal.ChronoUnit; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; import org.junit.jupiter.api.Test; @@ -138,4 +143,124 @@ public void buildWhenAllAttributesAreProvidedThenAllAttributesAreSet() { assertThat(authorization.getRefreshToken().getToken()).isEqualTo(REFRESH_TOKEN); } + @Test + public void clockWhenNullThenThrowIllegalArgumentException() { + assertThatExceptionOfType(IllegalArgumentException.class) + .isThrownBy(() -> OAuth2Authorization.withRegisteredClient(REGISTERED_CLIENT).clock(null)) + .withMessage("clock cannot be null"); + } + + @Test + public void isExpiredWhenClockBeforeExpiresAtThenActive() { + Instant fixedNow = Instant.parse("2024-01-01T12:00:00Z"); + OAuth2AccessToken accessToken = new OAuth2AccessToken(OAuth2AccessToken.TokenType.BEARER, "access-token", + fixedNow.minusSeconds(60), fixedNow.plusSeconds(3600)); + Clock clock = Clock.fixed(fixedNow, ZoneOffset.UTC); + + OAuth2Authorization authorization = OAuth2Authorization.withRegisteredClient(REGISTERED_CLIENT) + .principalName(PRINCIPAL_NAME) + .authorizationGrantType(AUTHORIZATION_GRANT_TYPE) + .clock(clock) + .accessToken(accessToken) + .build(); + + OAuth2Authorization.Token token = authorization.getAccessToken(); + assertThat(token.isExpired()).isFalse(); + assertThat(token.isActive()).isTrue(); + } + + @Test + public void isExpiredWhenClockAfterExpiresAtThenInactive() { + Instant fixedNow = Instant.parse("2024-01-01T12:00:00Z"); + OAuth2AccessToken accessToken = new OAuth2AccessToken(OAuth2AccessToken.TokenType.BEARER, "access-token", + fixedNow.minusSeconds(7200), fixedNow.minusSeconds(3600)); + Clock clock = Clock.fixed(fixedNow, ZoneOffset.UTC); + + OAuth2Authorization authorization = OAuth2Authorization.withRegisteredClient(REGISTERED_CLIENT) + .principalName(PRINCIPAL_NAME) + .authorizationGrantType(AUTHORIZATION_GRANT_TYPE) + .clock(clock) + .accessToken(accessToken) + .build(); + + OAuth2Authorization.Token token = authorization.getAccessToken(); + assertThat(token.isExpired()).isTrue(); + assertThat(token.isActive()).isFalse(); + } + + @Test + public void isBeforeUseWhenClockBeforeNbfThenInactive() { + Instant fixedNow = Instant.parse("2024-01-01T12:00:00Z"); + Instant notBefore = fixedNow.plusSeconds(600); + OAuth2AccessToken accessToken = new OAuth2AccessToken(OAuth2AccessToken.TokenType.BEARER, "access-token", + fixedNow, fixedNow.plusSeconds(3600)); + Map claims = new HashMap<>(); + claims.put("nbf", notBefore); + Clock clock = Clock.fixed(fixedNow, ZoneOffset.UTC); + + OAuth2Authorization authorization = OAuth2Authorization.withRegisteredClient(REGISTERED_CLIENT) + .principalName(PRINCIPAL_NAME) + .authorizationGrantType(AUTHORIZATION_GRANT_TYPE) + .clock(clock) + .token(accessToken, (metadata) -> metadata.put(OAuth2Authorization.Token.CLAIMS_METADATA_NAME, claims)) + .build(); + + OAuth2Authorization.Token token = authorization.getAccessToken(); + assertThat(token.isBeforeUse()).isTrue(); + assertThat(token.isActive()).isFalse(); + } + + @Test + public void isBeforeUseWhenClockAfterNbfThenActive() { + Instant fixedNow = Instant.parse("2024-01-01T12:00:00Z"); + Instant notBefore = fixedNow.minusSeconds(600); + OAuth2AccessToken accessToken = new OAuth2AccessToken(OAuth2AccessToken.TokenType.BEARER, "access-token", + fixedNow.minusSeconds(1200), fixedNow.plusSeconds(3600)); + Map claims = new HashMap<>(); + claims.put("nbf", notBefore); + Clock clock = Clock.fixed(fixedNow, ZoneOffset.UTC); + + OAuth2Authorization authorization = OAuth2Authorization.withRegisteredClient(REGISTERED_CLIENT) + .principalName(PRINCIPAL_NAME) + .authorizationGrantType(AUTHORIZATION_GRANT_TYPE) + .clock(clock) + .token(accessToken, (metadata) -> metadata.put(OAuth2Authorization.Token.CLAIMS_METADATA_NAME, claims)) + .build(); + + OAuth2Authorization.Token token = authorization.getAccessToken(); + assertThat(token.isBeforeUse()).isFalse(); + assertThat(token.isActive()).isTrue(); + } + + @Test + public void whenClockNotConfiguredThenSystemDefaultZoneUsed() { + OAuth2AccessToken accessToken = new OAuth2AccessToken(OAuth2AccessToken.TokenType.BEARER, "access-token", + Instant.now().minusSeconds(120), Instant.now().minusSeconds(60)); + + OAuth2Authorization authorization = OAuth2Authorization.withRegisteredClient(REGISTERED_CLIENT) + .principalName(PRINCIPAL_NAME) + .authorizationGrantType(AUTHORIZATION_GRANT_TYPE) + .accessToken(accessToken) + .build(); + + assertThat(Objects.requireNonNull(authorization.getAccessToken()).isExpired()).isTrue(); + } + + @Test + public void whenClockConfiguredAfterTokenAddedThenStillAppliedOnBuild() { + Instant fixedNow = Instant.parse("2024-01-01T12:00:00Z"); + OAuth2AccessToken accessToken = new OAuth2AccessToken(OAuth2AccessToken.TokenType.BEARER, "access-token", + fixedNow.minusSeconds(7200), fixedNow.minusSeconds(3600)); + Clock clock = Clock.fixed(fixedNow, ZoneOffset.UTC); + + OAuth2Authorization authorization = OAuth2Authorization.withRegisteredClient(REGISTERED_CLIENT) + .principalName(PRINCIPAL_NAME) + .authorizationGrantType(AUTHORIZATION_GRANT_TYPE) + .accessToken(accessToken) + .clock(clock) + .build(); + + assertThat(authorization.getAccessToken().isExpired()).isTrue(); + } + }