Skip to content

Commit 5fb800a

Browse files
CrazyParanoidCrazyParanoid
authored andcommitted
Add support auth time claim validation
Closes gh-15091
1 parent e2f98db commit 5fb800a

File tree

4 files changed

+241
-2
lines changed

4 files changed

+241
-2
lines changed

oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/OAuth2ErrorCodes.java

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2002-2022 the original author or authors.
2+
* Copyright 2002-2024 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -85,6 +85,18 @@ public final class OAuth2ErrorCodes {
8585
*/
8686
public static final String SERVER_ERROR = "server_error";
8787

88+
/**
89+
* {@code insufficient_user_authentication} - The authentication event associated with
90+
* the access token presented with the request does not meet the authentication
91+
* requirements of the protected resource.
92+
*
93+
* @since 6.4
94+
* @see <a href=
95+
* "https://datatracker.ietf.org/doc/html/rfc9470#name-authentication-requirements">RFC-9470
96+
* - Section 3 - Authentication Requirements Challenge</a>
97+
*/
98+
public static final String INSUFFICIENT_USER_AUTHENTICATION = "insufficient_user_authentication";
99+
88100
/**
89101
* {@code temporarily_unavailable} - The authorization server is currently unable to
90102
* handle the request due to a temporary overloading or maintenance of the server.
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
/*
2+
* Copyright 2002-2024 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.oauth2.jwt;
18+
19+
import java.time.Clock;
20+
import java.time.Duration;
21+
import java.time.Instant;
22+
import java.time.temporal.ChronoUnit;
23+
24+
import org.apache.commons.logging.Log;
25+
import org.apache.commons.logging.LogFactory;
26+
27+
import org.springframework.security.oauth2.core.OAuth2Error;
28+
import org.springframework.security.oauth2.core.OAuth2ErrorCodes;
29+
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
30+
import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;
31+
import org.springframework.util.Assert;
32+
33+
/**
34+
* An implementation of {@link OAuth2TokenValidator} for verifying "auth_time" claim in a
35+
* {@link Jwt}.
36+
*
37+
* @author Max Batischev
38+
* @since 6.4
39+
* @see Jwt
40+
* @see OAuth2TokenValidator
41+
* @see <a target="_blank" href="https://tools.ietf.org/html/rfc7519">JSON Web Token
42+
* (JWT)</a>
43+
*/
44+
public final class JwtAuthTimeValidator implements OAuth2TokenValidator<Jwt> {
45+
46+
private final Log logger = LogFactory.getLog(getClass());
47+
48+
private final long maxAge;
49+
50+
private static final Duration DEFAULT_MAX_CLOCK_SKEW = Duration.of(60, ChronoUnit.SECONDS);
51+
52+
private final Duration clockSkew;
53+
54+
private Clock clock = Clock.systemUTC();
55+
56+
public JwtAuthTimeValidator(long maxAge) {
57+
Assert.isTrue(maxAge > 0, "maxAge must be > 0");
58+
this.maxAge = maxAge;
59+
this.clockSkew = DEFAULT_MAX_CLOCK_SKEW;
60+
}
61+
62+
public JwtAuthTimeValidator(long maxAge, Duration clockSkew) {
63+
Assert.isTrue(maxAge > 0, "maxAge must be > 0");
64+
Assert.notNull(clockSkew, "clockSkew cannot be null");
65+
this.maxAge = maxAge;
66+
this.clockSkew = clockSkew;
67+
}
68+
69+
@Override
70+
public OAuth2TokenValidatorResult validate(Jwt token) {
71+
Assert.notNull(token, "token cannot be null");
72+
long authTime = token.getClaim(JwtClaimNames.AUTH_TIME);
73+
Instant authTimeInstant = Instant.ofEpochSecond(authTime);
74+
Instant currentInstant = Instant.now(this.clock).minus(this.clockSkew);
75+
76+
Duration duration = Duration.between(authTimeInstant, currentInstant);
77+
if (duration.toSeconds() <= this.maxAge) {
78+
return OAuth2TokenValidatorResult.success();
79+
}
80+
81+
return OAuth2TokenValidatorResult.failure(createOAuth2Error());
82+
}
83+
84+
private OAuth2Error createOAuth2Error() {
85+
String reason = String.format("\"More recent authentication is required\", max_age=\"%s\"", this.maxAge);
86+
this.logger.debug(reason);
87+
return new OAuth2Error(OAuth2ErrorCodes.INSUFFICIENT_USER_AUTHENTICATION, reason,
88+
"https://datatracker.ietf.org/doc/html/rfc9470#name-authentication-requirements");
89+
}
90+
91+
/**
92+
* Use this {@link Clock} with {@link Instant#now()}
93+
* @param clock
94+
*/
95+
public void setClock(Clock clock) {
96+
Assert.notNull(clock, "clock cannot be null");
97+
this.clock = clock;
98+
}
99+
100+
}

oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/JwtClaimNames.java

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2002-2022 the original author or authors.
2+
* Copyright 2002-2024 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -67,6 +67,18 @@ public final class JwtClaimNames {
6767
*/
6868
public static final String JTI = "jti";
6969

70+
/**
71+
* {@code acr} - The authentication context class reference claim that identifies the
72+
* authentication context class that was satisfied by the user-authentication event
73+
* performed
74+
*/
75+
public static final String ACR = "acr";
76+
77+
/**
78+
* {@code auth_time} - Time when the user authentication occurred
79+
*/
80+
public static final String AUTH_TIME = "auth_time";
81+
7082
private JwtClaimNames() {
7183
}
7284

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
/*
2+
* Copyright 2002-2024 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.oauth2.jwt;
18+
19+
import java.time.Clock;
20+
import java.time.Duration;
21+
import java.time.Instant;
22+
import java.time.ZoneId;
23+
24+
import org.junit.jupiter.api.Test;
25+
26+
import org.springframework.security.oauth2.core.OAuth2Error;
27+
import org.springframework.security.oauth2.core.OAuth2ErrorCodes;
28+
import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;
29+
30+
import static org.assertj.core.api.Assertions.assertThat;
31+
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
32+
33+
/**
34+
* Tests for {@link JwtAuthTimeValidator}
35+
*
36+
* @author Max Batischev
37+
*/
38+
public class JwtAuthTimeValidatorTests {
39+
40+
private static final Clock MOCK_NOW = Clock.fixed(Instant.ofEpochMilli(0), ZoneId.systemDefault());
41+
42+
private static final long MAX_AGE_FIVE_MINS = 300L;
43+
44+
private static final String ERROR_DESCRIPTION = "\"More recent authentication is required\", max_age=\"300\"";
45+
46+
private static final String ERROR_URI = "https://datatracker.ietf.org/doc/html/rfc9470#name-authentication-requirements";
47+
48+
@Test
49+
public void validateWhenDifferenceBetweenCurrentTimeAndAuthTimeLessThanMaxAgeThenReturnsSuccess() {
50+
Instant authTime = Instant.now().minusSeconds(240);
51+
JwtAuthTimeValidator jwtValidator = new JwtAuthTimeValidator(MAX_AGE_FIVE_MINS);
52+
Jwt jwt = TestJwts.jwt().claim(JwtClaimNames.AUTH_TIME, authTime.getEpochSecond()).build();
53+
54+
OAuth2TokenValidatorResult result = jwtValidator.validate(jwt);
55+
56+
assertThat(result.hasErrors()).isFalse();
57+
assertThat(result).isEqualTo(OAuth2TokenValidatorResult.success());
58+
}
59+
60+
@Test
61+
public void validateWhenDifferenceBetweenCurrentTimeAndAuthTimeGreaterThanMaxAgeThenReturnsError() {
62+
Instant authTime = Instant.now().minusSeconds(720);
63+
64+
JwtAuthTimeValidator jwtValidator = new JwtAuthTimeValidator(MAX_AGE_FIVE_MINS);
65+
Jwt jwt = TestJwts.jwt().claim(JwtClaimNames.AUTH_TIME, authTime.getEpochSecond()).build();
66+
67+
OAuth2TokenValidatorResult result = jwtValidator.validate(jwt);
68+
69+
assertThat(result.hasErrors()).isTrue();
70+
// @formatter:off
71+
OAuth2Error error = result.getErrors().stream()
72+
.findAny()
73+
.get();
74+
// @formatter:on
75+
assertThat(error.getUri()).isEqualTo(ERROR_URI);
76+
assertThat(error.getDescription()).isEqualTo(ERROR_DESCRIPTION);
77+
assertThat(error.getErrorCode()).isEqualTo(OAuth2ErrorCodes.INSUFFICIENT_USER_AUTHENTICATION);
78+
}
79+
80+
@Test
81+
public void validateWhenConfiguredWithFixedClockThenValidatesUsingFixedTime() {
82+
Instant authTime = Instant.now(MOCK_NOW).minusSeconds(240);
83+
Jwt jwt = TestJwts.jwt().claim(JwtClaimNames.AUTH_TIME, authTime.getEpochSecond()).build();
84+
JwtAuthTimeValidator jwtValidator = new JwtAuthTimeValidator(MAX_AGE_FIVE_MINS);
85+
jwtValidator.setClock(MOCK_NOW);
86+
87+
OAuth2TokenValidatorResult result = jwtValidator.validate(jwt);
88+
89+
assertThat(result.hasErrors()).isFalse();
90+
}
91+
92+
@Test
93+
public void validateWhenJwtIsNullThenThrowsIllegalArgumentException() {
94+
JwtAuthTimeValidator jwtValidator = new JwtAuthTimeValidator(MAX_AGE_FIVE_MINS);
95+
96+
assertThatIllegalArgumentException().isThrownBy(() -> jwtValidator.validate(null));
97+
}
98+
99+
@Test
100+
public void setClockWhenInvokedWithNullThenThrowsIllegalArgumentException() {
101+
JwtAuthTimeValidator jwtValidator = new JwtAuthTimeValidator(MAX_AGE_FIVE_MINS);
102+
assertThatIllegalArgumentException().isThrownBy(() -> jwtValidator.setClock(null));
103+
}
104+
105+
@Test
106+
public void constructorWhenInvokedWithNullDurationThenThrowsIllegalArgumentException() {
107+
assertThatIllegalArgumentException().isThrownBy(() -> new JwtAuthTimeValidator(MAX_AGE_FIVE_MINS, null));
108+
}
109+
110+
@Test
111+
public void constructorWhenInvokedWithZeroMaxAgeThenThrowsIllegalArgumentException() {
112+
assertThatIllegalArgumentException().isThrownBy(() -> new JwtAuthTimeValidator(0, Duration.ZERO));
113+
}
114+
115+
}

0 commit comments

Comments
 (0)