Skip to content

Commit 8f64ab9

Browse files
committed
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 <[email protected]>
1 parent 5da2121 commit 8f64ab9

File tree

4 files changed

+345
-0
lines changed

4 files changed

+345
-0
lines changed

config/src/test/java/org/springframework/security/SerializationSamples.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@
9393
import org.springframework.security.core.Authentication;
9494
import org.springframework.security.core.GrantedAuthority;
9595
import org.springframework.security.core.authority.AuthorityUtils;
96+
import org.springframework.security.core.authority.TimestampedGrantedAuthority;
9697
import org.springframework.security.core.context.SecurityContext;
9798
import org.springframework.security.core.context.SecurityContextImpl;
9899
import org.springframework.security.core.context.TransientSecurityContext;
@@ -390,6 +391,11 @@ final class SerializationSamples {
390391
token.setDetails(details);
391392
return token;
392393
});
394+
generatorByClassName.put(TimestampedGrantedAuthority.class,
395+
(r) -> TimestampedGrantedAuthority.withAuthority("profile:read")
396+
.issuedAt(Instant.now())
397+
.expiresAt(Instant.now().plusSeconds(300))
398+
.build());
393399
generatorByClassName.put(UsernamePasswordAuthenticationToken.class, (r) -> {
394400
var token = UsernamePasswordAuthenticationToken.unauthenticated(user, "creds");
395401
token.setDetails(details);
297 Bytes
Binary file not shown.
Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
/*
2+
* Copyright 2004-present the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.security.core.authority;
18+
19+
import java.time.Instant;
20+
import java.util.Objects;
21+
22+
import org.jspecify.annotations.Nullable;
23+
24+
import org.springframework.security.core.GrantedAuthority;
25+
import org.springframework.util.Assert;
26+
27+
/**
28+
* Time-based implementation of {@link GrantedAuthority}.
29+
*
30+
* <p>
31+
* Represents an authority granted to the
32+
* {@link org.springframework.security.core.Authentication Authentication} object with
33+
* temporal constraints. This implementation allows authorities to have:
34+
* <ul>
35+
* <li>An issued-at timestamp indicating when the authority was granted</li>
36+
* <li>An optional not-before timestamp indicating when the authority becomes valid</li>
37+
* <li>An optional expires-at timestamp indicating when the authority expires</li>
38+
* </ul>
39+
*
40+
* <p>
41+
* This is particularly useful for:
42+
* <ul>
43+
* <li>Time-based authorization rules</li>
44+
* <li>OAuth 2.0 scopes with expiration</li>
45+
* <li>Temporary elevated privileges</li>
46+
* </ul>
47+
*
48+
* <p>
49+
* Example usage: <pre>
50+
* GrantedAuthority authority = TimestampedGrantedAuthority.withAuthority("profile:read")
51+
* .issuedAt(Instant.now())
52+
* .expiresAt(Instant.now().plusSeconds(300))
53+
* .build();
54+
* </pre>
55+
*
56+
* @author Yoobin Yoon
57+
* @since 7.0
58+
*/
59+
public final class TimestampedGrantedAuthority implements GrantedAuthority {
60+
61+
private static final long serialVersionUID = 1998010439847123984L;
62+
63+
private final String authority;
64+
65+
private final Instant issuedAt;
66+
67+
private final @Nullable Instant notBefore;
68+
69+
private final @Nullable Instant expiresAt;
70+
71+
@SuppressWarnings("NullAway")
72+
private TimestampedGrantedAuthority(Builder builder) {
73+
this.authority = builder.authority;
74+
this.issuedAt = builder.issuedAt;
75+
this.notBefore = builder.notBefore;
76+
this.expiresAt = builder.expiresAt;
77+
}
78+
79+
/**
80+
* Creates a new {@link Builder} with the specified authority.
81+
* @param authority the authority value (must not be null or empty)
82+
* @return a new {@link Builder}
83+
*/
84+
public static Builder withAuthority(String authority) {
85+
return new Builder(authority);
86+
}
87+
88+
@Override
89+
public String getAuthority() {
90+
return this.authority;
91+
}
92+
93+
/**
94+
* Returns the instant when this authority was issued.
95+
* @return the issued-at instant
96+
*/
97+
public Instant getIssuedAt() {
98+
return this.issuedAt;
99+
}
100+
101+
/**
102+
* Returns the instant before which this authority is not valid.
103+
* @return the not-before instant, or {@code null} if not specified
104+
*/
105+
public @Nullable Instant getNotBefore() {
106+
return this.notBefore;
107+
}
108+
109+
/**
110+
* Returns the instant when this authority expires.
111+
* @return the expires-at instant, or {@code null} if not specified
112+
*/
113+
public @Nullable Instant getExpiresAt() {
114+
return this.expiresAt;
115+
}
116+
117+
@Override
118+
public boolean equals(Object obj) {
119+
if (this == obj) {
120+
return true;
121+
}
122+
if (obj instanceof TimestampedGrantedAuthority tga) {
123+
return this.authority.equals(tga.authority) && this.issuedAt.equals(tga.issuedAt)
124+
&& Objects.equals(this.notBefore, tga.notBefore) && Objects.equals(this.expiresAt, tga.expiresAt);
125+
}
126+
return false;
127+
}
128+
129+
@Override
130+
public int hashCode() {
131+
return Objects.hash(this.authority, this.issuedAt, this.notBefore, this.expiresAt);
132+
}
133+
134+
@Override
135+
public String toString() {
136+
StringBuilder sb = new StringBuilder();
137+
sb.append("TimestampedGrantedAuthority [");
138+
sb.append("authority=").append(this.authority);
139+
sb.append(", issuedAt=").append(this.issuedAt);
140+
if (this.notBefore != null) {
141+
sb.append(", notBefore=").append(this.notBefore);
142+
}
143+
if (this.expiresAt != null) {
144+
sb.append(", expiresAt=").append(this.expiresAt);
145+
}
146+
sb.append("]");
147+
return sb.toString();
148+
}
149+
150+
/**
151+
* Builder for {@link TimestampedGrantedAuthority}.
152+
*/
153+
public static final class Builder {
154+
155+
private final String authority;
156+
157+
private @Nullable Instant issuedAt;
158+
159+
private @Nullable Instant notBefore;
160+
161+
private @Nullable Instant expiresAt;
162+
163+
private Builder(String authority) {
164+
Assert.hasText(authority, "A granted authority textual representation is required");
165+
this.authority = authority;
166+
}
167+
168+
/**
169+
* Sets the instant when this authority was issued.
170+
* @param issuedAt the issued-at instant
171+
* @return this builder
172+
*/
173+
public Builder issuedAt(Instant issuedAt) {
174+
Assert.notNull(issuedAt, "issuedAt cannot be null");
175+
this.issuedAt = issuedAt;
176+
return this;
177+
}
178+
179+
/**
180+
* Sets the instant before which this authority is not valid.
181+
* @param notBefore the not-before instant
182+
* @return this builder
183+
*/
184+
public Builder notBefore(Instant notBefore) {
185+
Assert.notNull(notBefore, "notBefore cannot be null");
186+
this.notBefore = notBefore;
187+
return this;
188+
}
189+
190+
/**
191+
* Sets the instant when this authority expires.
192+
* @param expiresAt the expires-at instant
193+
* @return this builder
194+
*/
195+
public Builder expiresAt(Instant expiresAt) {
196+
Assert.notNull(expiresAt, "expiresAt cannot be null");
197+
this.expiresAt = expiresAt;
198+
return this;
199+
}
200+
201+
/**
202+
* Builds a new {@link TimestampedGrantedAuthority}.
203+
* <p>
204+
* If {@code issuedAt} is not set, it defaults to {@link Instant#now()}.
205+
* @return a new {@link TimestampedGrantedAuthority}
206+
* @throws IllegalArgumentException if temporal constraints are invalid
207+
*/
208+
public TimestampedGrantedAuthority build() {
209+
if (this.issuedAt == null) {
210+
this.issuedAt = Instant.now();
211+
}
212+
if (this.notBefore != null && this.notBefore.isBefore(this.issuedAt)) {
213+
throw new IllegalArgumentException("notBefore must not be before issuedAt");
214+
}
215+
if (this.expiresAt != null && this.expiresAt.isBefore(this.issuedAt)) {
216+
throw new IllegalArgumentException("expiresAt must not be before issuedAt");
217+
}
218+
if (this.notBefore != null && this.expiresAt != null && this.expiresAt.isBefore(this.notBefore)) {
219+
throw new IllegalArgumentException("expiresAt must not be before notBefore");
220+
}
221+
222+
return new TimestampedGrantedAuthority(this);
223+
}
224+
225+
}
226+
227+
}
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
/*
2+
* Copyright 2004-present the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.security.core.authority;
18+
19+
import java.time.Instant;
20+
21+
import org.junit.jupiter.api.Test;
22+
23+
import static org.assertj.core.api.Assertions.assertThat;
24+
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
25+
26+
/**
27+
* Tests {@link TimestampedGrantedAuthority}.
28+
*
29+
* @author Yoobin Yoon
30+
*/
31+
public class TimestampedGrantedAuthorityTests {
32+
33+
@Test
34+
public void buildWhenOnlyAuthorityThenDefaultsIssuedAtToNow() {
35+
Instant before = Instant.now();
36+
37+
TimestampedGrantedAuthority authority = TimestampedGrantedAuthority.withAuthority("profile:read").build();
38+
39+
Instant after = Instant.now();
40+
41+
assertThat(authority.getAuthority()).isEqualTo("profile:read");
42+
assertThat(authority.getIssuedAt()).isBetween(before, after);
43+
assertThat(authority.getNotBefore()).isNull();
44+
assertThat(authority.getExpiresAt()).isNull();
45+
}
46+
47+
@Test
48+
public void buildWhenAllFieldsSetThenCreatesCorrectly() {
49+
Instant issuedAt = Instant.now();
50+
Instant notBefore = issuedAt.plusSeconds(60);
51+
Instant expiresAt = issuedAt.plusSeconds(300);
52+
53+
TimestampedGrantedAuthority authority = TimestampedGrantedAuthority.withAuthority("admin:write")
54+
.issuedAt(issuedAt)
55+
.notBefore(notBefore)
56+
.expiresAt(expiresAt)
57+
.build();
58+
59+
assertThat(authority.getAuthority()).isEqualTo("admin:write");
60+
assertThat(authority.getIssuedAt()).isEqualTo(issuedAt);
61+
assertThat(authority.getNotBefore()).isEqualTo(notBefore);
62+
assertThat(authority.getExpiresAt()).isEqualTo(expiresAt);
63+
}
64+
65+
@Test
66+
public void buildWhenNullAuthorityThenThrowsException() {
67+
assertThatIllegalArgumentException().isThrownBy(() -> TimestampedGrantedAuthority.withAuthority(null))
68+
.withMessage("A granted authority textual representation is required");
69+
}
70+
71+
@Test
72+
public void buildWhenEmptyAuthorityThenThrowsException() {
73+
assertThatIllegalArgumentException().isThrownBy(() -> TimestampedGrantedAuthority.withAuthority(""))
74+
.withMessage("A granted authority textual representation is required");
75+
}
76+
77+
@Test
78+
public void buildWhenNotBeforeBeforeIssuedAtThenThrowsException() {
79+
Instant issuedAt = Instant.now();
80+
Instant notBefore = issuedAt.minusSeconds(60);
81+
82+
assertThatIllegalArgumentException().isThrownBy(
83+
() -> TimestampedGrantedAuthority.withAuthority("test").issuedAt(issuedAt).notBefore(notBefore).build())
84+
.withMessage("notBefore must not be before issuedAt");
85+
}
86+
87+
@Test
88+
public void buildWhenExpiresAtBeforeIssuedAtThenThrowsException() {
89+
Instant issuedAt = Instant.now();
90+
Instant expiresAt = issuedAt.minusSeconds(60);
91+
92+
assertThatIllegalArgumentException().isThrownBy(
93+
() -> TimestampedGrantedAuthority.withAuthority("test").issuedAt(issuedAt).expiresAt(expiresAt).build())
94+
.withMessage("expiresAt must not be before issuedAt");
95+
}
96+
97+
@Test
98+
public void buildWhenExpiresAtBeforeNotBeforeThenThrowsException() {
99+
Instant issuedAt = Instant.now();
100+
Instant notBefore = issuedAt.plusSeconds(60);
101+
Instant expiresAt = issuedAt.plusSeconds(30);
102+
103+
assertThatIllegalArgumentException()
104+
.isThrownBy(() -> TimestampedGrantedAuthority.withAuthority("test")
105+
.issuedAt(issuedAt)
106+
.notBefore(notBefore)
107+
.expiresAt(expiresAt)
108+
.build())
109+
.withMessage("expiresAt must not be before notBefore");
110+
}
111+
112+
}

0 commit comments

Comments
 (0)