diff --git a/config/src/test/java/org/springframework/security/SerializationSamples.java b/config/src/test/java/org/springframework/security/SerializationSamples.java index 4bf96f0ccdc..d2c8c09f230 100644 --- a/config/src/test/java/org/springframework/security/SerializationSamples.java +++ b/config/src/test/java/org/springframework/security/SerializationSamples.java @@ -95,6 +95,7 @@ import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.AuthorityUtils; import org.springframework.security.core.authority.FactorGrantedAuthority; +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; @@ -587,6 +588,11 @@ final class SerializationSamples { }); generatorByClassName.put(FactorGrantedAuthority.class, (r) -> FactorGrantedAuthority.withAuthority("profile:read").issuedAt(Instant.now()).build()); + 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 00000000000..0503594cb64 Binary files /dev/null and b/config/src/test/resources/serialized/7.0.x/org.springframework.security.core.authority.TimestampedGrantedAuthority.serialized differ diff --git a/core/src/main/java/org/springframework/security/core/authority/TimestampedGrantedAuthority.java b/core/src/main/java/org/springframework/security/core/authority/TimestampedGrantedAuthority.java new file mode 100644 index 00000000000..6c8b56a3ddb --- /dev/null +++ b/core/src/main/java/org/springframework/security/core/authority/TimestampedGrantedAuthority.java @@ -0,0 +1,227 @@ +/* + * 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 java.util.Objects; + +import org.jspecify.annotations.Nullable; + +import org.springframework.security.core.GrantedAuthority; +import org.springframework.util.Assert; + +/** + * Time-based implementation of {@link GrantedAuthority}. + * + *
+ * Represents an authority granted to the + * {@link org.springframework.security.core.Authentication Authentication} object with + * temporal constraints. This implementation allows authorities to have: + *
+ * This is particularly useful for: + *
+ * 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"); + } + +}