Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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 <a href=
* "https://datatracker.ietf.org/doc/html/rfc9470#name-authentication-requirements">RFC-9470
* - Section 3 - Authentication Requirements Challenge</a>
*/
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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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 <a target="_blank" href="https://tools.ietf.org/html/rfc7519">JSON Web Token
* (JWT)</a>
*/
public final class JwtAuthTimeValidator implements OAuth2TokenValidator<Jwt> {

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;
}

}
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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() {
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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));
}

}