diff --git a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/OAuth2ErrorCodes.java b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/OAuth2ErrorCodes.java index 47587435bc..825ef2809b 100644 --- a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/OAuth2ErrorCodes.java +++ b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/OAuth2ErrorCodes.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2022 the original author or authors. + * Copyright 2002-2024 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. @@ -85,6 +85,18 @@ public final class OAuth2ErrorCodes { */ public static final String SERVER_ERROR = "server_error"; + /** + * {@code insufficient_user_authentication} - The authentication event associated with + * the access token presented with the request does not meet the authentication + * requirements of the protected resource. + * + * @since 6.4 + * @see RFC-9470 + * - Section 3 - Authentication Requirements Challenge + */ + public static final String INSUFFICIENT_USER_AUTHENTICATION = "insufficient_user_authentication"; + /** * {@code temporarily_unavailable} - The authorization server is currently unable to * handle the request due to a temporary overloading or maintenance of the server. diff --git a/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/JwtAuthTimeValidator.java b/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/JwtAuthTimeValidator.java new file mode 100644 index 0000000000..154955a861 --- /dev/null +++ b/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/JwtAuthTimeValidator.java @@ -0,0 +1,100 @@ +/* + * Copyright 2002-2024 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.jwt; + +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.temporal.ChronoUnit; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.core.OAuth2ErrorCodes; +import org.springframework.security.oauth2.core.OAuth2TokenValidator; +import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult; +import org.springframework.util.Assert; + +/** + * An implementation of {@link OAuth2TokenValidator} for verifying "auth_time" claim in a + * {@link Jwt}. + * + * @author Max Batischev + * @since 6.4 + * @see Jwt + * @see OAuth2TokenValidator + * @see JSON Web Token + * (JWT) + */ +public final class JwtAuthTimeValidator implements OAuth2TokenValidator { + + private final Log logger = LogFactory.getLog(getClass()); + + private final long maxAge; + + private static final Duration DEFAULT_MAX_CLOCK_SKEW = Duration.of(60, ChronoUnit.SECONDS); + + private final Duration clockSkew; + + private Clock clock = Clock.systemUTC(); + + public JwtAuthTimeValidator(long maxAge) { + Assert.isTrue(maxAge > 0, "maxAge must be > 0"); + this.maxAge = maxAge; + this.clockSkew = DEFAULT_MAX_CLOCK_SKEW; + } + + public JwtAuthTimeValidator(long maxAge, Duration clockSkew) { + Assert.isTrue(maxAge > 0, "maxAge must be > 0"); + Assert.notNull(clockSkew, "clockSkew cannot be null"); + this.maxAge = maxAge; + this.clockSkew = clockSkew; + } + + @Override + public OAuth2TokenValidatorResult validate(Jwt token) { + Assert.notNull(token, "token cannot be null"); + long authTime = token.getClaim(JwtClaimNames.AUTH_TIME); + Instant authTimeInstant = Instant.ofEpochSecond(authTime); + Instant currentInstant = Instant.now(this.clock).minus(this.clockSkew); + + Duration duration = Duration.between(authTimeInstant, currentInstant); + if (duration.toSeconds() <= this.maxAge) { + return OAuth2TokenValidatorResult.success(); + } + + return OAuth2TokenValidatorResult.failure(createOAuth2Error()); + } + + private OAuth2Error createOAuth2Error() { + String reason = String.format("\"More recent authentication is required\", max_age=\"%s\"", this.maxAge); + this.logger.debug(reason); + return new OAuth2Error(OAuth2ErrorCodes.INSUFFICIENT_USER_AUTHENTICATION, reason, + "https://datatracker.ietf.org/doc/html/rfc9470#name-authentication-requirements"); + } + + /** + * Use this {@link Clock} with {@link Instant#now()} + * @param clock + */ + public void setClock(Clock clock) { + Assert.notNull(clock, "clock cannot be null"); + this.clock = clock; + } + +} diff --git a/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/JwtClaimNames.java b/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/JwtClaimNames.java index 9d62aaa6b2..47b4de7d01 100644 --- a/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/JwtClaimNames.java +++ b/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/JwtClaimNames.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2022 the original author or authors. + * Copyright 2002-2024 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. @@ -67,6 +67,18 @@ public final class JwtClaimNames { */ public static final String JTI = "jti"; + /** + * {@code acr} - The authentication context class reference claim that identifies the + * authentication context class that was satisfied by the user-authentication event + * performed + */ + public static final String ACR = "acr"; + + /** + * {@code auth_time} - Time when the user authentication occurred + */ + public static final String AUTH_TIME = "auth_time"; + private JwtClaimNames() { } diff --git a/oauth2/oauth2-jose/src/test/java/org/springframework/security/oauth2/jwt/JwtAuthTimeValidatorTests.java b/oauth2/oauth2-jose/src/test/java/org/springframework/security/oauth2/jwt/JwtAuthTimeValidatorTests.java new file mode 100644 index 0000000000..6edd42f221 --- /dev/null +++ b/oauth2/oauth2-jose/src/test/java/org/springframework/security/oauth2/jwt/JwtAuthTimeValidatorTests.java @@ -0,0 +1,115 @@ +/* + * Copyright 2002-2024 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.jwt; + +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneId; + +import org.junit.jupiter.api.Test; + +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.core.OAuth2ErrorCodes; +import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +/** + * Tests for {@link JwtAuthTimeValidator} + * + * @author Max Batischev + */ +public class JwtAuthTimeValidatorTests { + + private static final Clock MOCK_NOW = Clock.fixed(Instant.ofEpochMilli(0), ZoneId.systemDefault()); + + private static final long MAX_AGE_FIVE_MINS = 300L; + + private static final String ERROR_DESCRIPTION = "\"More recent authentication is required\", max_age=\"300\""; + + private static final String ERROR_URI = "https://datatracker.ietf.org/doc/html/rfc9470#name-authentication-requirements"; + + @Test + public void validateWhenDifferenceBetweenCurrentTimeAndAuthTimeLessThanMaxAgeThenReturnsSuccess() { + Instant authTime = Instant.now().minusSeconds(240); + JwtAuthTimeValidator jwtValidator = new JwtAuthTimeValidator(MAX_AGE_FIVE_MINS); + Jwt jwt = TestJwts.jwt().claim(JwtClaimNames.AUTH_TIME, authTime.getEpochSecond()).build(); + + OAuth2TokenValidatorResult result = jwtValidator.validate(jwt); + + assertThat(result.hasErrors()).isFalse(); + assertThat(result).isEqualTo(OAuth2TokenValidatorResult.success()); + } + + @Test + public void validateWhenDifferenceBetweenCurrentTimeAndAuthTimeGreaterThanMaxAgeThenReturnsError() { + Instant authTime = Instant.now().minusSeconds(720); + + JwtAuthTimeValidator jwtValidator = new JwtAuthTimeValidator(MAX_AGE_FIVE_MINS); + Jwt jwt = TestJwts.jwt().claim(JwtClaimNames.AUTH_TIME, authTime.getEpochSecond()).build(); + + OAuth2TokenValidatorResult result = jwtValidator.validate(jwt); + + assertThat(result.hasErrors()).isTrue(); + // @formatter:off + OAuth2Error error = result.getErrors().stream() + .findAny() + .get(); + // @formatter:on + assertThat(error.getUri()).isEqualTo(ERROR_URI); + assertThat(error.getDescription()).isEqualTo(ERROR_DESCRIPTION); + assertThat(error.getErrorCode()).isEqualTo(OAuth2ErrorCodes.INSUFFICIENT_USER_AUTHENTICATION); + } + + @Test + public void validateWhenConfiguredWithFixedClockThenValidatesUsingFixedTime() { + Instant authTime = Instant.now(MOCK_NOW).minusSeconds(240); + Jwt jwt = TestJwts.jwt().claim(JwtClaimNames.AUTH_TIME, authTime.getEpochSecond()).build(); + JwtAuthTimeValidator jwtValidator = new JwtAuthTimeValidator(MAX_AGE_FIVE_MINS); + jwtValidator.setClock(MOCK_NOW); + + OAuth2TokenValidatorResult result = jwtValidator.validate(jwt); + + assertThat(result.hasErrors()).isFalse(); + } + + @Test + public void validateWhenJwtIsNullThenThrowsIllegalArgumentException() { + JwtAuthTimeValidator jwtValidator = new JwtAuthTimeValidator(MAX_AGE_FIVE_MINS); + + assertThatIllegalArgumentException().isThrownBy(() -> jwtValidator.validate(null)); + } + + @Test + public void setClockWhenInvokedWithNullThenThrowsIllegalArgumentException() { + JwtAuthTimeValidator jwtValidator = new JwtAuthTimeValidator(MAX_AGE_FIVE_MINS); + assertThatIllegalArgumentException().isThrownBy(() -> jwtValidator.setClock(null)); + } + + @Test + public void constructorWhenInvokedWithNullDurationThenThrowsIllegalArgumentException() { + assertThatIllegalArgumentException().isThrownBy(() -> new JwtAuthTimeValidator(MAX_AGE_FIVE_MINS, null)); + } + + @Test + public void constructorWhenInvokedWithZeroMaxAgeThenThrowsIllegalArgumentException() { + assertThatIllegalArgumentException().isThrownBy(() -> new JwtAuthTimeValidator(0, Duration.ZERO)); + } + +}