From 8f64ab92a209052f4d15b5fb5254f440e5ddb604 Mon Sep 17 00:00:00 2001 From: yybmion Date: Wed, 10 Sep 2025 20:20:45 +0900 Subject: [PATCH] Add TimestampedGrantedAuthority for time-based authorization - Implement GrantedAuthority with temporal constraints (issuedAt, notBefore, expiresAt) - Use Builder pattern for flexible construction - Default issuedAt to Instant.now() when not specified - Add serialization sample and generated .serialized file - Add comprehensive tests Closes gh-17864 Signed-off-by: yybmion --- .../security/SerializationSamples.java | 6 + ...ity.TimestampedGrantedAuthority.serialized | Bin 0 -> 297 bytes .../TimestampedGrantedAuthority.java | 227 ++++++++++++++++++ .../TimestampedGrantedAuthorityTests.java | 112 +++++++++ 4 files changed, 345 insertions(+) create mode 100644 config/src/test/resources/serialized/7.0.x/org.springframework.security.core.authority.TimestampedGrantedAuthority.serialized create mode 100644 core/src/main/java/org/springframework/security/core/authority/TimestampedGrantedAuthority.java create mode 100644 core/src/test/java/org/springframework/security/core/authority/TimestampedGrantedAuthorityTests.java diff --git a/config/src/test/java/org/springframework/security/SerializationSamples.java b/config/src/test/java/org/springframework/security/SerializationSamples.java index cbd42c9453f..e37ac957d44 100644 --- a/config/src/test/java/org/springframework/security/SerializationSamples.java +++ b/config/src/test/java/org/springframework/security/SerializationSamples.java @@ -93,6 +93,7 @@ import org.springframework.security.core.Authentication; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.AuthorityUtils; +import org.springframework.security.core.authority.TimestampedGrantedAuthority; import org.springframework.security.core.context.SecurityContext; import org.springframework.security.core.context.SecurityContextImpl; import org.springframework.security.core.context.TransientSecurityContext; @@ -390,6 +391,11 @@ final class SerializationSamples { token.setDetails(details); return token; }); + generatorByClassName.put(TimestampedGrantedAuthority.class, + (r) -> TimestampedGrantedAuthority.withAuthority("profile:read") + .issuedAt(Instant.now()) + .expiresAt(Instant.now().plusSeconds(300)) + .build()); generatorByClassName.put(UsernamePasswordAuthenticationToken.class, (r) -> { var token = UsernamePasswordAuthenticationToken.unauthenticated(user, "creds"); token.setDetails(details); diff --git a/config/src/test/resources/serialized/7.0.x/org.springframework.security.core.authority.TimestampedGrantedAuthority.serialized b/config/src/test/resources/serialized/7.0.x/org.springframework.security.core.authority.TimestampedGrantedAuthority.serialized new file mode 100644 index 0000000000000000000000000000000000000000..0503594cb64d71f019b3934e1c67885eb53ae26e GIT binary patch literal 297 zcmZ4UmVvdnh`~L-C|$3(peQphJ*_A)H?=&!C|j>MHMz7Xv!qflIlm}XFR`>FBOlBS z$;?eHE=kNSNKJ7sO3W)sO>u6f)E?FadSs<(D|6 zrUBgp7N{sFVc;n!%1_J8Nwq3UO-uoLg%@Oz9>^TM;MAh2u`Ro#l{_}_FfddUl=Ct% j071r~sGGdsa$i&wgA8GXi$<=+6cu+jPtlC + * Represents an authority granted to the + * {@link org.springframework.security.core.Authentication Authentication} object with + * temporal constraints. This implementation allows authorities to have: + *
    + *
  • An issued-at timestamp indicating when the authority was granted
  • + *
  • An optional not-before timestamp indicating when the authority becomes valid
  • + *
  • An optional expires-at timestamp indicating when the authority expires
  • + *
+ * + *

+ * This is particularly useful for: + *

    + *
  • Time-based authorization rules
  • + *
  • OAuth 2.0 scopes with expiration
  • + *
  • Temporary elevated privileges
  • + *
+ * + *

+ * Example usage:

+ * GrantedAuthority authority = TimestampedGrantedAuthority.withAuthority("profile:read")
+ *     .issuedAt(Instant.now())
+ *     .expiresAt(Instant.now().plusSeconds(300))
+ *     .build();
+ * 
+ * + * @author Yoobin Yoon + * @since 7.0 + */ +public final class TimestampedGrantedAuthority implements GrantedAuthority { + + private static final long serialVersionUID = 1998010439847123984L; + + private final String authority; + + private final Instant issuedAt; + + private final @Nullable Instant notBefore; + + private final @Nullable Instant expiresAt; + + @SuppressWarnings("NullAway") + private TimestampedGrantedAuthority(Builder builder) { + this.authority = builder.authority; + this.issuedAt = builder.issuedAt; + this.notBefore = builder.notBefore; + this.expiresAt = builder.expiresAt; + } + + /** + * Creates a new {@link Builder} with the specified authority. + * @param authority the authority value (must not be null or empty) + * @return a new {@link Builder} + */ + public static Builder withAuthority(String authority) { + return new Builder(authority); + } + + @Override + public String getAuthority() { + return this.authority; + } + + /** + * Returns the instant when this authority was issued. + * @return the issued-at instant + */ + public Instant getIssuedAt() { + return this.issuedAt; + } + + /** + * Returns the instant before which this authority is not valid. + * @return the not-before instant, or {@code null} if not specified + */ + public @Nullable Instant getNotBefore() { + return this.notBefore; + } + + /** + * Returns the instant when this authority expires. + * @return the expires-at instant, or {@code null} if not specified + */ + public @Nullable Instant getExpiresAt() { + return this.expiresAt; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj instanceof TimestampedGrantedAuthority tga) { + return this.authority.equals(tga.authority) && this.issuedAt.equals(tga.issuedAt) + && Objects.equals(this.notBefore, tga.notBefore) && Objects.equals(this.expiresAt, tga.expiresAt); + } + return false; + } + + @Override + public int hashCode() { + return Objects.hash(this.authority, this.issuedAt, this.notBefore, this.expiresAt); + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder(); + sb.append("TimestampedGrantedAuthority ["); + sb.append("authority=").append(this.authority); + sb.append(", issuedAt=").append(this.issuedAt); + if (this.notBefore != null) { + sb.append(", notBefore=").append(this.notBefore); + } + if (this.expiresAt != null) { + sb.append(", expiresAt=").append(this.expiresAt); + } + sb.append("]"); + return sb.toString(); + } + + /** + * Builder for {@link TimestampedGrantedAuthority}. + */ + public static final class Builder { + + private final String authority; + + private @Nullable Instant issuedAt; + + private @Nullable Instant notBefore; + + private @Nullable Instant expiresAt; + + private Builder(String authority) { + Assert.hasText(authority, "A granted authority textual representation is required"); + this.authority = authority; + } + + /** + * Sets the instant when this authority was issued. + * @param issuedAt the issued-at instant + * @return this builder + */ + public Builder issuedAt(Instant issuedAt) { + Assert.notNull(issuedAt, "issuedAt cannot be null"); + this.issuedAt = issuedAt; + return this; + } + + /** + * Sets the instant before which this authority is not valid. + * @param notBefore the not-before instant + * @return this builder + */ + public Builder notBefore(Instant notBefore) { + Assert.notNull(notBefore, "notBefore cannot be null"); + this.notBefore = notBefore; + return this; + } + + /** + * Sets the instant when this authority expires. + * @param expiresAt the expires-at instant + * @return this builder + */ + public Builder expiresAt(Instant expiresAt) { + Assert.notNull(expiresAt, "expiresAt cannot be null"); + this.expiresAt = expiresAt; + return this; + } + + /** + * Builds a new {@link TimestampedGrantedAuthority}. + *

+ * If {@code issuedAt} is not set, it defaults to {@link Instant#now()}. + * @return a new {@link TimestampedGrantedAuthority} + * @throws IllegalArgumentException if temporal constraints are invalid + */ + public TimestampedGrantedAuthority build() { + if (this.issuedAt == null) { + this.issuedAt = Instant.now(); + } + if (this.notBefore != null && this.notBefore.isBefore(this.issuedAt)) { + throw new IllegalArgumentException("notBefore must not be before issuedAt"); + } + if (this.expiresAt != null && this.expiresAt.isBefore(this.issuedAt)) { + throw new IllegalArgumentException("expiresAt must not be before issuedAt"); + } + if (this.notBefore != null && this.expiresAt != null && this.expiresAt.isBefore(this.notBefore)) { + throw new IllegalArgumentException("expiresAt must not be before notBefore"); + } + + return new TimestampedGrantedAuthority(this); + } + + } + +} diff --git a/core/src/test/java/org/springframework/security/core/authority/TimestampedGrantedAuthorityTests.java b/core/src/test/java/org/springframework/security/core/authority/TimestampedGrantedAuthorityTests.java new file mode 100644 index 00000000000..70f463b1721 --- /dev/null +++ b/core/src/test/java/org/springframework/security/core/authority/TimestampedGrantedAuthorityTests.java @@ -0,0 +1,112 @@ +/* + * 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.core.authority; + +import java.time.Instant; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +/** + * Tests {@link TimestampedGrantedAuthority}. + * + * @author Yoobin Yoon + */ +public class TimestampedGrantedAuthorityTests { + + @Test + public void buildWhenOnlyAuthorityThenDefaultsIssuedAtToNow() { + Instant before = Instant.now(); + + TimestampedGrantedAuthority authority = TimestampedGrantedAuthority.withAuthority("profile:read").build(); + + Instant after = Instant.now(); + + assertThat(authority.getAuthority()).isEqualTo("profile:read"); + assertThat(authority.getIssuedAt()).isBetween(before, after); + assertThat(authority.getNotBefore()).isNull(); + assertThat(authority.getExpiresAt()).isNull(); + } + + @Test + public void buildWhenAllFieldsSetThenCreatesCorrectly() { + Instant issuedAt = Instant.now(); + Instant notBefore = issuedAt.plusSeconds(60); + Instant expiresAt = issuedAt.plusSeconds(300); + + TimestampedGrantedAuthority authority = TimestampedGrantedAuthority.withAuthority("admin:write") + .issuedAt(issuedAt) + .notBefore(notBefore) + .expiresAt(expiresAt) + .build(); + + assertThat(authority.getAuthority()).isEqualTo("admin:write"); + assertThat(authority.getIssuedAt()).isEqualTo(issuedAt); + assertThat(authority.getNotBefore()).isEqualTo(notBefore); + assertThat(authority.getExpiresAt()).isEqualTo(expiresAt); + } + + @Test + public void buildWhenNullAuthorityThenThrowsException() { + assertThatIllegalArgumentException().isThrownBy(() -> TimestampedGrantedAuthority.withAuthority(null)) + .withMessage("A granted authority textual representation is required"); + } + + @Test + public void buildWhenEmptyAuthorityThenThrowsException() { + assertThatIllegalArgumentException().isThrownBy(() -> TimestampedGrantedAuthority.withAuthority("")) + .withMessage("A granted authority textual representation is required"); + } + + @Test + public void buildWhenNotBeforeBeforeIssuedAtThenThrowsException() { + Instant issuedAt = Instant.now(); + Instant notBefore = issuedAt.minusSeconds(60); + + assertThatIllegalArgumentException().isThrownBy( + () -> TimestampedGrantedAuthority.withAuthority("test").issuedAt(issuedAt).notBefore(notBefore).build()) + .withMessage("notBefore must not be before issuedAt"); + } + + @Test + public void buildWhenExpiresAtBeforeIssuedAtThenThrowsException() { + Instant issuedAt = Instant.now(); + Instant expiresAt = issuedAt.minusSeconds(60); + + assertThatIllegalArgumentException().isThrownBy( + () -> TimestampedGrantedAuthority.withAuthority("test").issuedAt(issuedAt).expiresAt(expiresAt).build()) + .withMessage("expiresAt must not be before issuedAt"); + } + + @Test + public void buildWhenExpiresAtBeforeNotBeforeThenThrowsException() { + Instant issuedAt = Instant.now(); + Instant notBefore = issuedAt.plusSeconds(60); + Instant expiresAt = issuedAt.plusSeconds(30); + + assertThatIllegalArgumentException() + .isThrownBy(() -> TimestampedGrantedAuthority.withAuthority("test") + .issuedAt(issuedAt) + .notBefore(notBefore) + .expiresAt(expiresAt) + .build()) + .withMessage("expiresAt must not be before notBefore"); + } + +}