فهرست منبع

Jwt Claim Validation

This introduces OAuth2TokenValidator which allows the customization of
validation steps that need to be performing when decoding a string
token to a Jwt.

At this point, two validators, JwtTimestampValidator and
JwtIssuerValidator, are available for use.

Fixes: gh-5133
Josh Cummings 7 سال پیش
والد
کامیت
7c524aa0c8
16فایلهای تغییر یافته به همراه1225 افزوده شده و 25 حذف شده
  1. 133 0
      config/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/OAuth2ResourceServerConfigurerTests.java
  2. 1 0
      config/src/test/resources/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/OAuth2ResourceServerConfigurerTests-ExpiresAt4687177990.token
  3. 56 0
      oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/DelegatingOAuth2TokenValidator.java
  4. 35 0
      oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/OAuth2TokenValidator.java
  5. 92 0
      oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/OAuth2TokenValidatorResult.java
  6. 123 0
      oauth2/oauth2-core/src/test/java/org/springframework/security/oauth2/core/DelegatingOAuth2TokenValidatorTests.java
  7. 55 0
      oauth2/oauth2-core/src/test/java/org/springframework/security/oauth2/core/OAuth2TokenValidatorResultTests.java
  8. 72 0
      oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/JwtIssuerValidator.java
  9. 109 0
      oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/JwtTimestampValidator.java
  10. 68 0
      oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/JwtValidationException.java
  11. 46 0
      oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/JwtValidators.java
  12. 41 10
      oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/NimbusJwtDecoderJwkSupport.java
  13. 92 0
      oauth2/oauth2-jose/src/test/java/org/springframework/security/oauth2/jwt/JwtIssuerValidatorTests.java
  14. 230 0
      oauth2/oauth2-jose/src/test/java/org/springframework/security/oauth2/jwt/JwtTimestampValidatorTests.java
  15. 56 2
      oauth2/oauth2-jose/src/test/java/org/springframework/security/oauth2/jwt/NimbusJwtDecoderJwkSupportTests.java
  16. 16 13
      oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/authentication/JwtAuthenticationProvider.java

+ 133 - 0
config/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/OAuth2ResourceServerConfigurerTests.java

@@ -20,7 +20,10 @@ import java.io.BufferedReader;
 import java.io.FileReader;
 import java.io.IOException;
 import java.lang.reflect.Field;
+import java.time.Clock;
+import java.time.Duration;
 import java.time.Instant;
+import java.time.ZoneId;
 import java.util.Collections;
 import java.util.Map;
 import java.util.stream.Collectors;
@@ -60,12 +63,16 @@ import org.springframework.security.core.Authentication;
 import org.springframework.security.core.GrantedAuthority;
 import org.springframework.security.core.annotation.AuthenticationPrincipal;
 import org.springframework.security.core.userdetails.UserDetailsService;
+import org.springframework.security.oauth2.core.OAuth2Error;
+import org.springframework.security.oauth2.core.OAuth2TokenValidator;
+import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;
 import org.springframework.security.oauth2.jose.jws.JwsAlgorithms;
 import org.springframework.security.oauth2.jwt.Jwt;
 import org.springframework.security.oauth2.jwt.JwtClaimNames;
 import org.springframework.security.oauth2.jwt.JwtDecoder;
 import org.springframework.security.oauth2.jwt.JwtException;
 import org.springframework.security.oauth2.jwt.NimbusJwtDecoderJwkSupport;
+import org.springframework.security.oauth2.jwt.JwtTimestampValidator;
 import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
 import org.springframework.security.oauth2.server.resource.web.BearerTokenAuthenticationEntryPoint;
 import org.springframework.security.oauth2.server.resource.web.BearerTokenResolver;
@@ -92,6 +99,7 @@ import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.Assertions.assertThatCode;
 import static org.hamcrest.CoreMatchers.containsString;
 import static org.hamcrest.core.StringStartsWith.startsWith;
+import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.anyString;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.when;
@@ -839,6 +847,57 @@ public class OAuth2ResourceServerConfigurerTests {
 				.isInstanceOf(IllegalArgumentException.class);
 	}
 
+	// -- token validator
+
+	@Test
+	public void requestWhenCustomJwtValidatorFailsThenCorrespondingErrorMessage()
+		throws Exception {
+
+		this.spring.register(WebServerConfig.class, CustomJwtValidatorConfig.class).autowire();
+		this.authz.enqueue(this.jwks("Default"));
+		String token = this.token("ValidNoScopes");
+
+		OAuth2TokenValidator<Jwt> jwtValidator =
+				this.spring.getContext().getBean(CustomJwtValidatorConfig.class)
+						.getJwtValidator();
+
+		OAuth2Error error = new OAuth2Error("custom-error", "custom-description", "custom-uri");
+
+		when(jwtValidator.validate(any(Jwt.class))).thenReturn(OAuth2TokenValidatorResult.failure(error));
+
+		this.mvc.perform(get("/")
+				.with(bearerToken(token)))
+				.andExpect(status().isUnauthorized())
+				.andExpect(header().string(HttpHeaders.WWW_AUTHENTICATE, containsString("custom-description")));
+	}
+
+	@Test
+	public void requestWhenClockSkewSetThenTimestampWindowRelaxedAccordingly()
+		throws Exception {
+
+		this.spring.register(WebServerConfig.class, UnexpiredJwtClockSkewConfig.class, BasicController.class).autowire();
+		this.authz.enqueue(this.jwks("Default"));
+		String token = this.token("ExpiresAt4687177990");
+
+		this.mvc.perform(get("/")
+				.with(bearerToken(token)))
+				.andExpect(status().isOk());
+	}
+
+	@Test
+	public void requestWhenClockSkewSetButJwtStillTooLateThenReportsExpired()
+			throws Exception {
+
+		this.spring.register(WebServerConfig.class, ExpiredJwtClockSkewConfig.class, BasicController.class).autowire();
+		this.authz.enqueue(this.jwks("Default"));
+		String token = this.token("ExpiresAt4687177990");
+
+		this.mvc.perform(get("/")
+				.with(bearerToken(token)))
+				.andExpect(status().isUnauthorized())
+				.andExpect(invalidTokenHeader("Jwt expired at"));
+	}
+
 	// -- In combination with other authentication providers
 
 	@Test
@@ -1266,6 +1325,80 @@ public class OAuth2ResourceServerConfigurerTests {
 		}
 	}
 
+	@EnableWebSecurity
+	static class CustomJwtValidatorConfig extends WebSecurityConfigurerAdapter {
+		@Value("${mock.jwk-set-uri}") String uri;
+
+		private final OAuth2TokenValidator<Jwt> jwtValidator = mock(OAuth2TokenValidator.class);
+
+		@Override
+		protected void configure(HttpSecurity http) throws Exception {
+			NimbusJwtDecoderJwkSupport jwtDecoder =
+					new NimbusJwtDecoderJwkSupport(this.uri);
+			jwtDecoder.setJwtValidator(this.jwtValidator);
+
+			// @formatter:off
+			http
+				.oauth2()
+					.resourceServer()
+						.jwt()
+							.decoder(jwtDecoder);
+			// @formatter:on
+		}
+
+		public OAuth2TokenValidator<Jwt> getJwtValidator() {
+			return this.jwtValidator;
+		}
+	}
+
+	@EnableWebSecurity
+	static class UnexpiredJwtClockSkewConfig extends WebSecurityConfigurerAdapter {
+		@Value("${mock.jwk-set-uri}") String uri;
+
+		@Override
+		protected void configure(HttpSecurity http) throws Exception {
+			Clock nearlyAnHourFromTokenExpiry =
+					Clock.fixed(Instant.ofEpochMilli(4687181540000L), ZoneId.systemDefault());
+			JwtTimestampValidator jwtValidator = new JwtTimestampValidator(Duration.ofHours(1));
+			jwtValidator.setClock(nearlyAnHourFromTokenExpiry);
+
+			NimbusJwtDecoderJwkSupport jwtDecoder = new NimbusJwtDecoderJwkSupport(this.uri);
+			jwtDecoder.setJwtValidator(jwtValidator);
+
+			// @formatter:off
+			http
+				.oauth2()
+					.resourceServer()
+						.jwt()
+							.decoder(jwtDecoder);
+			// @formatter:on
+		}
+	}
+
+	@EnableWebSecurity
+	static class ExpiredJwtClockSkewConfig extends WebSecurityConfigurerAdapter {
+		@Value("${mock.jwk-set-uri}") String uri;
+
+		@Override
+		protected void configure(HttpSecurity http) throws Exception {
+			Clock justOverOneHourAfterExpiry =
+					Clock.fixed(Instant.ofEpochMilli(4687181595000L), ZoneId.systemDefault());
+			JwtTimestampValidator jwtValidator = new JwtTimestampValidator(Duration.ofHours(1));
+			jwtValidator.setClock(justOverOneHourAfterExpiry);
+
+			NimbusJwtDecoderJwkSupport jwtDecoder = new NimbusJwtDecoderJwkSupport(this.uri);
+			jwtDecoder.setJwtValidator(jwtValidator);
+
+			// @formatter:off
+			http
+				.oauth2()
+					.resourceServer()
+						.jwt()
+							.decoder(jwtDecoder);
+			// @formatter:on
+		}
+	}
+
 	@Configuration
 	static class JwtDecoderConfig {
 		@Bean

+ 1 - 0
config/src/test/resources/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/OAuth2ResourceServerConfigurerTests-ExpiresAt4687177990.token

@@ -0,0 +1 @@
+eyJhbGciOiJSUzI1NiJ9.eyJleHAiOjQ2ODcxNzc5OTB9.RRQvqIZzLweq0iwWUZk1Dpiz6iUmT4bAVhGWqvWNWK3UwJ6aBIYsCRhdVeKQp-g1TxXovMALeAu_2oPmV0wOEEanesAKxjKYcJZQIe8HnVqgug6Ibs04uQ1mJ4RgfntPM-ebsJs-2tjFFkLEYJSkpq2o6SEFW9jBJyW8b8C5UJJahqynonA-Dw5GH1nin5bhhliLuFOmu0Ityt0uJ1Y_vuGsSA-ltVcY52jE4x6GH9NQxLX4ceO1bHSOmdspBoGsE_yo9-zsQw0g1_Iy7uqEjos3xrrboH6Z_u7pRL7AQJ7GNzZlinjYYPANQbYknieZD6beddTK7lvr4DYiPBmXzA

+ 56 - 0
oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/DelegatingOAuth2TokenValidator.java

@@ -0,0 +1,56 @@
+/*
+ * Copyright 2002-2018 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
+ *
+ *      http://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.core;
+
+import java.util.ArrayList;
+import java.util.Collection;
+
+import org.springframework.util.Assert;
+
+/**
+ * A composite validator
+ *
+ * @param <T> the type of {@link AbstractOAuth2Token} this validator validates
+ *
+ * @author Josh Cummings
+ * @since 5.1
+ */
+public final class DelegatingOAuth2TokenValidator<T extends AbstractOAuth2Token>
+		implements OAuth2TokenValidator<T> {
+
+	private final Collection<OAuth2TokenValidator<T>> tokenValidators;
+
+	public DelegatingOAuth2TokenValidator(Collection<OAuth2TokenValidator<T>> tokenValidators) {
+		Assert.notNull(tokenValidators, "tokenValidators cannot be null");
+
+		this.tokenValidators = new ArrayList<>(tokenValidators);
+	}
+
+	/**
+	 * {@inheritDoc}
+	 */
+	@Override
+	public OAuth2TokenValidatorResult validate(T token) {
+		Collection<OAuth2Error> errors = new ArrayList<>();
+
+		for ( OAuth2TokenValidator<T> validator : this.tokenValidators) {
+			errors.addAll(validator.validate(token).getErrors());
+		}
+
+		return OAuth2TokenValidatorResult.failure(errors);
+	}
+}

+ 35 - 0
oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/OAuth2TokenValidator.java

@@ -0,0 +1,35 @@
+/*
+ * Copyright 2002-2018 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
+ *
+ *      http://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.core;
+
+/**
+ * Implementations of this interface are responsible for &quot;verifying&quot;
+ * the validity and/or constraints of the attributes contained in an OAuth 2.0 Token.
+ *
+ * @author Joe Grandja
+ * @author Josh Cummings
+ * @since 5.1
+ */
+public interface OAuth2TokenValidator<T extends AbstractOAuth2Token> {
+
+	/**
+	 * Verify the validity and/or constraints of the provided OAuth 2.0 Token.
+	 *
+	 * @param token an OAuth 2.0 token
+	 * @return OAuth2TokenValidationResult the success or failure detail of the validation
+	 */
+	OAuth2TokenValidatorResult validate(T token);
+}

+ 92 - 0
oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/OAuth2TokenValidatorResult.java

@@ -0,0 +1,92 @@
+/*
+ * Copyright 2002-2018 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
+ *
+ *      http://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.core;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+
+import org.springframework.util.Assert;
+
+/**
+ * A result emitted from an {@link OAuth2TokenValidator} validation attempt
+ *
+ * @author Josh Cummings
+ * @since 5.1
+ */
+public final class OAuth2TokenValidatorResult {
+	static final OAuth2TokenValidatorResult NO_ERRORS = new OAuth2TokenValidatorResult(Collections.emptyList());
+
+	private final Collection<OAuth2Error> errors;
+
+	private OAuth2TokenValidatorResult(Collection<OAuth2Error> errors) {
+		Assert.notNull(errors, "errors cannot be null");
+		this.errors = new ArrayList<>(errors);
+	}
+
+	/**
+	 * Say whether this result indicates success
+	 *
+	 * @return whether this result has errors
+	 */
+	public boolean hasErrors() {
+		return !this.errors.isEmpty();
+	}
+
+	/**
+	 * Return error details regarding the validation attempt
+	 *
+	 * @return the collection of results in this result, if any; returns an empty list otherwise
+	 */
+	public Collection<OAuth2Error> getErrors() {
+		return this.errors;
+	}
+
+	/**
+	 * Construct a successful {@link OAuth2TokenValidatorResult}
+	 *
+	 * @return an {@link OAuth2TokenValidatorResult} with no errors
+	 */
+	public static OAuth2TokenValidatorResult success() {
+		return NO_ERRORS;
+	}
+
+	/**
+	 * Construct a failure {@link OAuth2TokenValidatorResult} with the provided detail
+	 *
+	 * @param errors the list of errors
+	 * @return an {@link OAuth2TokenValidatorResult} with the errors specified
+	 */
+	public static OAuth2TokenValidatorResult failure(OAuth2Error... errors) {
+		return failure(Arrays.asList(errors));
+	}
+
+	/**
+	 * Construct a failure {@link OAuth2TokenValidatorResult} with the provided detail
+	 *
+	 * @param errors the list of errors
+	 * @return an {@link OAuth2TokenValidatorResult} with the errors specified
+	 */
+	public static OAuth2TokenValidatorResult failure(Collection<OAuth2Error> errors) {
+		if (errors.isEmpty()) {
+			return NO_ERRORS;
+		}
+
+		return new OAuth2TokenValidatorResult(errors);
+	}
+}

+ 123 - 0
oauth2/oauth2-core/src/test/java/org/springframework/security/oauth2/core/DelegatingOAuth2TokenValidatorTests.java

@@ -0,0 +1,123 @@
+/*
+ * Copyright 2002-2018 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
+ *
+ *      http://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.core;
+
+import java.util.Arrays;
+import java.util.Collections;
+
+import org.junit.Test;
+
+import org.springframework.security.oauth2.core.AbstractOAuth2Token;
+import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator;
+import org.springframework.security.oauth2.core.OAuth2Error;
+import org.springframework.security.oauth2.core.OAuth2TokenValidator;
+import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatCode;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * Tests for verifying {@link DelegatingOAuth2TokenValidator}
+ *
+ * @author Josh Cummings
+ */
+public class DelegatingOAuth2TokenValidatorTests {
+	private static final OAuth2Error DETAIL = new OAuth2Error(
+			"error", "description", "uri");
+
+	@Test
+	public void validateWhenNoValidatorsConfiguredThenReturnsSuccessfulResult() {
+		DelegatingOAuth2TokenValidator<AbstractOAuth2Token> tokenValidator =
+				new DelegatingOAuth2TokenValidator<>(Collections.emptyList());
+		AbstractOAuth2Token token = mock(AbstractOAuth2Token.class);
+
+		assertThat(tokenValidator.validate(token).hasErrors()).isFalse();
+	}
+
+	@Test
+	public void validateWhenAnyValidatorFailsThenReturnsFailureResultContainingDetailFromFailingValidator() {
+		OAuth2TokenValidator<AbstractOAuth2Token> success = mock(OAuth2TokenValidator.class);
+		OAuth2TokenValidator<AbstractOAuth2Token> failure = mock(OAuth2TokenValidator.class);
+
+		when(success.validate(any(AbstractOAuth2Token.class)))
+				.thenReturn(OAuth2TokenValidatorResult.success());
+		when(failure.validate(any(AbstractOAuth2Token.class)))
+				.thenReturn(OAuth2TokenValidatorResult.failure(DETAIL));
+
+		DelegatingOAuth2TokenValidator<AbstractOAuth2Token> tokenValidator =
+				new DelegatingOAuth2TokenValidator<>(Arrays.asList(success, failure));
+		AbstractOAuth2Token token = mock(AbstractOAuth2Token.class);
+
+		OAuth2TokenValidatorResult result =
+				tokenValidator.validate(token);
+
+		assertThat(result.hasErrors()).isTrue();
+		assertThat(result.getErrors()).containsExactly(DETAIL);
+	}
+
+	@Test
+	public void validateWhenMultipleValidatorsFailThenReturnsFailureResultContainingAllDetails() {
+		OAuth2TokenValidator<AbstractOAuth2Token> firstFailure = mock(OAuth2TokenValidator.class);
+		OAuth2TokenValidator<AbstractOAuth2Token> secondFailure = mock(OAuth2TokenValidator.class);
+
+		OAuth2Error otherDetail = new OAuth2Error("another-error");
+
+		when(firstFailure.validate(any(AbstractOAuth2Token.class)))
+				.thenReturn(OAuth2TokenValidatorResult.failure(DETAIL));
+		when(secondFailure.validate(any(AbstractOAuth2Token.class)))
+				.thenReturn(OAuth2TokenValidatorResult.failure(otherDetail));
+
+		DelegatingOAuth2TokenValidator<AbstractOAuth2Token> tokenValidator =
+				new DelegatingOAuth2TokenValidator<>(Arrays.asList(firstFailure, secondFailure));
+		AbstractOAuth2Token token = mock(AbstractOAuth2Token.class);
+
+		OAuth2TokenValidatorResult result =
+				tokenValidator.validate(token);
+
+		assertThat(result.hasErrors()).isTrue();
+		assertThat(result.getErrors()).containsExactly(DETAIL, otherDetail);
+	}
+
+	@Test
+	public void validateWhenAllValidatorsSucceedThenReturnsSuccessfulResult() {
+		OAuth2TokenValidator<AbstractOAuth2Token> firstSuccess = mock(OAuth2TokenValidator.class);
+		OAuth2TokenValidator<AbstractOAuth2Token> secondSuccess = mock(OAuth2TokenValidator.class);
+
+		when(firstSuccess.validate(any(AbstractOAuth2Token.class)))
+				.thenReturn(OAuth2TokenValidatorResult.success());
+		when(secondSuccess.validate(any(AbstractOAuth2Token.class)))
+				.thenReturn(OAuth2TokenValidatorResult.success());
+
+		DelegatingOAuth2TokenValidator<AbstractOAuth2Token> tokenValidator =
+				new DelegatingOAuth2TokenValidator<>(Arrays.asList(firstSuccess, secondSuccess));
+		AbstractOAuth2Token token = mock(AbstractOAuth2Token.class);
+
+		OAuth2TokenValidatorResult result =
+				tokenValidator.validate(token);
+
+		assertThat(result.hasErrors()).isFalse();
+		assertThat(result.getErrors()).isEmpty();
+	}
+
+	@Test
+	public void constructorWhenInvokedWithNullValidatorListThenThrowsIllegalArgumentException() {
+		assertThatCode(() -> new DelegatingOAuth2TokenValidator<>(null))
+				.isInstanceOf(IllegalArgumentException.class);
+	}
+}

+ 55 - 0
oauth2/oauth2-core/src/test/java/org/springframework/security/oauth2/core/OAuth2TokenValidatorResultTests.java

@@ -0,0 +1,55 @@
+/*
+ * Copyright 2002-2018 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
+ *
+ *      http://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.core;
+
+import org.junit.Test;
+
+import org.springframework.security.oauth2.core.OAuth2Error;
+import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Tests for verifying {@link OAuth2TokenValidatorResult}
+ *
+ * @author Josh Cummings
+ */
+public class OAuth2TokenValidatorResultTests {
+	private static final OAuth2Error DETAIL = new OAuth2Error(
+			"error", "description", "uri");
+
+	@Test
+	public void successWhenInvokedThenReturnsSuccessfulResult() {
+		OAuth2TokenValidatorResult success = OAuth2TokenValidatorResult.success();
+		assertThat(success.hasErrors()).isFalse();
+	}
+
+	@Test
+	public void failureWhenInvokedWithDetailReturnsFailureResultIncludingDetail() {
+		OAuth2TokenValidatorResult failure = OAuth2TokenValidatorResult.failure(DETAIL);
+
+		assertThat(failure.hasErrors()).isTrue();
+		assertThat(failure.getErrors()).containsExactly(DETAIL);
+	}
+
+	@Test
+	public void failureWhenInvokedWithMultipleDetailsReturnsFailureResultIncludingAll() {
+		OAuth2TokenValidatorResult failure = OAuth2TokenValidatorResult.failure(DETAIL, DETAIL);
+
+		assertThat(failure.hasErrors()).isTrue();
+		assertThat(failure.getErrors()).containsExactly(DETAIL, DETAIL);
+	}
+}

+ 72 - 0
oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/JwtIssuerValidator.java

@@ -0,0 +1,72 @@
+/*
+ * Copyright 2002-2018 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
+ *
+ *      http://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.net.MalformedURLException;
+import java.net.URL;
+
+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;
+
+/**
+ * Validates the "iss" claim in a {@link Jwt}, that is matches a configured value
+ *
+ * @author Josh Cummings
+ * @since 5.1
+ */
+public final class JwtIssuerValidator implements OAuth2TokenValidator<Jwt> {
+	private static OAuth2Error INVALID_ISSUER =
+			new OAuth2Error(
+					OAuth2ErrorCodes.INVALID_REQUEST,
+					"This iss claim is not equal to the configured issuer",
+					"https://tools.ietf.org/html/rfc6750#section-3.1");
+
+	private final URL issuer;
+
+	/**
+	 * Constructs a {@link JwtIssuerValidator} using the provided parameters
+	 *
+	 * @param issuer - The issuer that each {@link Jwt} should have.
+	 */
+	public JwtIssuerValidator(String issuer) {
+		Assert.notNull(issuer, "issuer cannot be null");
+
+		try {
+			this.issuer = new URL(issuer);
+		} catch (MalformedURLException ex) {
+			throw new IllegalArgumentException(
+					"Invalid Issuer URL " + issuer + " : " + ex.getMessage(),
+					ex);
+		}
+	}
+
+	/**
+	 * {@inheritDoc}
+	 */
+	@Override
+	public OAuth2TokenValidatorResult validate(Jwt token) {
+		Assert.notNull(token, "token cannot be null");
+
+		if (this.issuer.equals(token.getIssuer())) {
+			return OAuth2TokenValidatorResult.success();
+		} else {
+			return OAuth2TokenValidatorResult.failure(INVALID_ISSUER);
+		}
+	}
+}

+ 109 - 0
oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/JwtTimestampValidator.java

@@ -0,0 +1,109 @@
+/*
+ * Copyright 2002-2018 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
+ *
+ *      http://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.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.security.oauth2.jwt.Jwt;
+import org.springframework.util.Assert;
+
+/**
+ * An implementation of {@see OAuth2TokenValidator} for verifying claims in a Jwt-based access token
+ *
+ * <p>
+ * Because clocks can differ between the Jwt source, say the Authorization Server, and its destination, say the
+ * Resource Server, there is a default clock leeway exercised when deciding if the current time is within the Jwt's
+ * specified operating window
+ *
+ * @author Josh Cummings
+ * @since 5.1
+ * @see Jwt
+ * @see OAuth2TokenValidator
+ * @see <a target="_blank" href="https://tools.ietf.org/html/rfc7519">JSON Web Token (JWT)</a>
+ */
+public final class JwtTimestampValidator implements OAuth2TokenValidator<Jwt> {
+	private static final Duration DEFAULT_MAX_CLOCK_SKEW = Duration.of(60, ChronoUnit.SECONDS);
+
+	private final Duration maxClockSkew;
+
+	private Clock clock = Clock.systemUTC();
+
+	/**
+	 * A basic instance with no custom verification and the default max clock skew
+	 */
+	public JwtTimestampValidator() {
+		this(DEFAULT_MAX_CLOCK_SKEW);
+	}
+
+	public JwtTimestampValidator(Duration maxClockSkew) {
+		Assert.notNull(maxClockSkew, "maxClockSkew cannot be null");
+
+		this.maxClockSkew = maxClockSkew;
+	}
+
+	/**
+	 * {@inheritDoc}
+	 */
+	@Override
+	public OAuth2TokenValidatorResult validate(Jwt jwt) {
+		Assert.notNull(jwt, "jwt cannot be null");
+
+		Instant expiry = jwt.getExpiresAt();
+
+		if (expiry != null) {
+			if (Instant.now(this.clock).minus(maxClockSkew).isAfter(expiry)) {
+				OAuth2Error error = new OAuth2Error(
+						OAuth2ErrorCodes.INVALID_REQUEST,
+						String.format("Jwt expired at %s", jwt.getExpiresAt()),
+						"https://tools.ietf.org/html/rfc6750#section-3.1");
+				return OAuth2TokenValidatorResult.failure(error);
+			}
+		}
+
+		Instant notBefore = jwt.getNotBefore();
+
+		if (notBefore != null) {
+			if (Instant.now(this.clock).plus(maxClockSkew).isBefore(notBefore)) {
+				OAuth2Error error = new OAuth2Error(
+						OAuth2ErrorCodes.INVALID_REQUEST,
+						String.format("Jwt used before %s", jwt.getNotBefore()),
+						"https://tools.ietf.org/html/rfc6750#section-3.1");
+				return OAuth2TokenValidatorResult.failure(error);
+			}
+		}
+
+		return OAuth2TokenValidatorResult.success();
+	}
+
+	/**
+	 * '
+	 * Use this {@link Clock} with {@link Instant#now()} for assessing
+	 * timestamp validity
+	 *
+	 * @param clock
+	 */
+	public void setClock(Clock clock) {
+		Assert.notNull(clock, "clock cannot be null");
+		this.clock = clock;
+	}
+}

+ 68 - 0
oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/JwtValidationException.java

@@ -0,0 +1,68 @@
+/*
+ * Copyright 2002-2018 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
+ *
+ *      http://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.util.ArrayList;
+import java.util.Collection;
+
+import org.springframework.security.oauth2.core.OAuth2Error;
+import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;
+import org.springframework.util.Assert;
+
+/**
+ * An exception that results from an unsuccessful
+ * {@link OAuth2TokenValidatorResult}
+ *
+ * @author Josh Cummings
+ * @since 5.1
+ */
+public class JwtValidationException extends JwtException {
+	private final Collection<OAuth2Error> errors;
+
+	/**
+	 * Constructs a {@link JwtValidationException} using the provided parameters
+	 *
+	 * While each {@link OAuth2Error} does contain an error description, this constructor
+	 * can take an overarching description that encapsulates the composition of failures
+	 *
+	 * That said, it is appropriate to pass one of the messages from the error list in as
+	 * the exception description, for example:
+	 *
+	 * <pre>
+	 * 	if ( result.hasErrors() ) {
+	 *  	Collection<OAuth2Error> errors = result.getErrors();
+	 *  	throw new JwtValidationException(errors.iterator().next().getDescription(), errors);
+	 * 	}
+	 * </pre>
+	 *
+	 * @param message - the exception message
+	 * @param errors - a list of {@link OAuth2Error}s with extra detail about the validation result
+	 */
+	public JwtValidationException(String message, Collection<OAuth2Error> errors) {
+		super(message);
+
+		Assert.notEmpty(errors, "errors cannot be empty");
+		this.errors = new ArrayList<>(errors);
+	}
+
+	/**
+	 * Return the list of {@link OAuth2Error}s associated with this exception
+	 * @return the list of {@link OAuth2Error}s associated with this exception
+	 */
+	public Collection<OAuth2Error> getErrors() {
+		return this.errors;
+	}
+}

+ 46 - 0
oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/JwtValidators.java

@@ -0,0 +1,46 @@
+/*
+ * Copyright 2002-2018 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
+ *
+ *      http://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.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+
+import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator;
+import org.springframework.security.oauth2.core.OAuth2TokenValidator;
+
+/**
+ * @author Josh Cummings
+ * @since 5.1
+ */
+public final class JwtValidators {
+
+	/**
+	 * Create a {@link Jwt} Validator that contains all standard validators as well as
+	 * any supplied in the parameter list.
+	 *
+	 * @param jwtValidators - additional validators to include in the delegating validator
+	 * @return - a delegating validator containing all standard validators as well as any supplied
+	 */
+	public static OAuth2TokenValidator<Jwt> createDelegatingJwtValidator(OAuth2TokenValidator<Jwt>... jwtValidators) {
+		Collection<OAuth2TokenValidator<Jwt>> validators = new ArrayList<>();
+		validators.add(new JwtTimestampValidator());
+		validators.addAll(Arrays.asList(jwtValidators));
+		return new DelegatingOAuth2TokenValidator<>(validators);
+	}
+
+	private JwtValidators() {}
+}

+ 41 - 10
oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/NimbusJwtDecoderJwkSupport.java

@@ -15,6 +15,15 @@
  */
 package org.springframework.security.oauth2.jwt;
 
+import java.io.IOException;
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.text.ParseException;
+import java.time.Instant;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
 import com.nimbusds.jose.JWSAlgorithm;
 import com.nimbusds.jose.RemoteKeySourceException;
 import com.nimbusds.jose.jwk.source.JWKSource;
@@ -30,25 +39,19 @@ import com.nimbusds.jwt.JWTParser;
 import com.nimbusds.jwt.SignedJWT;
 import com.nimbusds.jwt.proc.ConfigurableJWTProcessor;
 import com.nimbusds.jwt.proc.DefaultJWTProcessor;
+
 import org.springframework.http.HttpHeaders;
 import org.springframework.http.HttpMethod;
 import org.springframework.http.MediaType;
 import org.springframework.http.RequestEntity;
 import org.springframework.http.ResponseEntity;
+import org.springframework.security.oauth2.core.OAuth2TokenValidator;
+import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;
 import org.springframework.security.oauth2.jose.jws.JwsAlgorithms;
 import org.springframework.util.Assert;
 import org.springframework.web.client.RestOperations;
 import org.springframework.web.client.RestTemplate;
 
-import java.io.IOException;
-import java.net.MalformedURLException;
-import java.net.URL;
-import java.text.ParseException;
-import java.time.Instant;
-import java.util.Collections;
-import java.util.LinkedHashMap;
-import java.util.Map;
-
 /**
  * An implementation of a {@link JwtDecoder} that "decodes" a
  * JSON Web Token (JWT) and additionally verifies it's digital signature if the JWT is a
@@ -75,6 +78,8 @@ public final class NimbusJwtDecoderJwkSupport implements JwtDecoder {
 	private final ConfigurableJWTProcessor<SecurityContext> jwtProcessor;
 	private final RestOperationsResourceRetriever jwkSetRetriever = new RestOperationsResourceRetriever();
 
+	private OAuth2TokenValidator<Jwt> jwtValidator = JwtValidators.createDelegatingJwtValidator();
+
 	/**
 	 * Constructs a {@code NimbusJwtDecoderJwkSupport} using the provided parameters.
 	 *
@@ -104,17 +109,31 @@ public final class NimbusJwtDecoderJwkSupport implements JwtDecoder {
 			new JWSVerificationKeySelector<>(this.jwsAlgorithm, jwkSource);
 		this.jwtProcessor = new DefaultJWTProcessor<>();
 		this.jwtProcessor.setJWSKeySelector(jwsKeySelector);
+
+		// Spring Security validates the claim set independent from Nimbus
+		this.jwtProcessor.setJWTClaimsSetVerifier((claims, context) -> {});
 	}
 
 	@Override
 	public Jwt decode(String token) throws JwtException {
 		JWT jwt = this.parse(token);
 		if (jwt instanceof SignedJWT) {
-			return this.createJwt(token, jwt);
+			Jwt createdJwt = this.createJwt(token, jwt);
+			return this.validateJwt(createdJwt);
 		}
 		throw new JwtException("Unsupported algorithm of " + jwt.getHeader().getAlgorithm());
 	}
 
+	/**
+	 * Use this {@link Jwt} Validator
+	 *
+	 * @param jwtValidator - the Jwt Validator to use
+	 */
+	public void setJwtValidator(OAuth2TokenValidator<Jwt> jwtValidator) {
+		Assert.notNull(jwtValidator, "jwtValidator cannot be null");
+		this.jwtValidator = jwtValidator;
+	}
+
 	private JWT parse(String token) {
 		try {
 			return JWTParser.parse(token);
@@ -163,6 +182,18 @@ public final class NimbusJwtDecoderJwkSupport implements JwtDecoder {
 		return jwt;
 	}
 
+	private Jwt validateJwt(Jwt jwt){
+		OAuth2TokenValidatorResult result = this.jwtValidator.validate(jwt);
+		if (result.hasErrors()) {
+			String description = result.getErrors().iterator().next().getDescription();
+			throw new JwtValidationException(
+					String.format(DECODING_ERROR_MESSAGE_TEMPLATE, description),
+					result.getErrors());
+		}
+
+		return jwt;
+	}
+
 	/**
 	 * Sets the {@link RestOperations} used when requesting the JSON Web Key (JWK) Set.
 	 *

+ 92 - 0
oauth2/oauth2-jose/src/test/java/org/springframework/security/oauth2/jwt/JwtIssuerValidatorTests.java

@@ -0,0 +1,92 @@
+/*
+ * Copyright 2002-2018 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
+ *
+ *      http://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.Instant;
+import java.util.Collections;
+import java.util.Map;
+
+import org.junit.Test;
+
+import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;
+import org.springframework.security.oauth2.jose.jws.JwsAlgorithms;
+import org.springframework.security.oauth2.jwt.Jwt;
+import org.springframework.security.oauth2.jwt.JwtClaimNames;
+import org.springframework.security.oauth2.jwt.JwtIssuerValidator;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatCode;
+
+/**
+ * @author Josh Cummings
+ * @since 5.1
+ */
+public class JwtIssuerValidatorTests {
+	private static final String MOCK_TOKEN = "token";
+	private static final Instant MOCK_ISSUED_AT = Instant.MIN;
+	private static final Instant MOCK_EXPIRES_AT = Instant.MAX;
+	private static final Map<String, Object> MOCK_HEADERS =
+			Collections.singletonMap("alg", JwsAlgorithms.RS256);
+
+	private static final String ISSUER = "https://issuer";
+
+	private final JwtIssuerValidator validator = new JwtIssuerValidator(ISSUER);
+
+	@Test
+	public void validateWhenIssuerMatchesThenReturnsSuccess() {
+		Jwt jwt = new Jwt(
+				MOCK_TOKEN,
+				MOCK_ISSUED_AT,
+				MOCK_EXPIRES_AT,
+				MOCK_HEADERS,
+				Collections.singletonMap("iss", ISSUER));
+
+		assertThat(this.validator.validate(jwt))
+				.isEqualTo(OAuth2TokenValidatorResult.success());
+	}
+
+	@Test
+	public void validateWhenIssuerMismatchesThenReturnsError() {
+		Jwt jwt = new Jwt(
+				MOCK_TOKEN,
+				MOCK_ISSUED_AT,
+				MOCK_EXPIRES_AT,
+				MOCK_HEADERS,
+				Collections.singletonMap(JwtClaimNames.ISS, "https://other"));
+
+		OAuth2TokenValidatorResult result = this.validator.validate(jwt);
+
+		assertThat(result.getErrors()).isNotEmpty();
+	}
+
+	@Test
+	public void validateWhenJwtIsNullThenThrowsIllegalArgumentException() {
+		assertThatCode(() -> this.validator.validate(null))
+				.isInstanceOf(IllegalArgumentException.class);
+	}
+
+	@Test
+	public void constructorWhenMalformedIssuerIsGivenThenThrowsIllegalArgumentException() {
+		assertThatCode(() -> new JwtIssuerValidator("issuer"))
+				.isInstanceOf(IllegalArgumentException.class);
+	}
+
+	@Test
+	public void constructorWhenNullIssuerIsGivenThenThrowsIllegalArgumentException() {
+		assertThatCode(() -> new JwtIssuerValidator(null))
+				.isInstanceOf(IllegalArgumentException.class);
+	}
+}

+ 230 - 0
oauth2/oauth2-jose/src/test/java/org/springframework/security/oauth2/jwt/JwtTimestampValidatorTests.java

@@ -0,0 +1,230 @@
+/*
+ * Copyright 2002-2018 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
+ *
+ *      http://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 java.util.Collection;
+import java.util.Collections;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+import org.junit.Test;
+
+import org.springframework.security.oauth2.core.OAuth2Error;
+import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;
+import org.springframework.security.oauth2.jose.jws.JwsAlgorithms;
+import org.springframework.security.oauth2.jwt.Jwt;
+import org.springframework.security.oauth2.jwt.JwtClaimNames;
+import org.springframework.security.oauth2.jwt.JwtTimestampValidator;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatCode;
+
+/**
+ * Tests verifying {@link JwtTimestampValidator}
+ *
+ * @author Josh Cummings
+ */
+public class JwtTimestampValidatorTests {
+	private static final Clock MOCK_NOW = Clock.fixed(Instant.ofEpochMilli(0), ZoneId.systemDefault());
+	private static final String MOCK_TOKEN_VALUE = "token";
+	private static final Instant MOCK_ISSUED_AT = Instant.MIN;
+	private static final Map<String, Object> MOCK_HEADER = Collections.singletonMap("alg", JwsAlgorithms.RS256);
+	private static final Map<String, Object> MOCK_CLAIM_SET = Collections.singletonMap("some", "claim");
+
+	@Test
+	public void validateWhenJwtIsExpiredThenErrorMessageIndicatesExpirationTime() {
+		Instant oneHourAgo = Instant.now().minusSeconds(3600);
+
+		Jwt jwt = new Jwt(
+				MOCK_TOKEN_VALUE,
+				MOCK_ISSUED_AT,
+				oneHourAgo,
+				MOCK_HEADER,
+				MOCK_CLAIM_SET);
+
+		JwtTimestampValidator jwtValidator = new JwtTimestampValidator();
+
+		Collection<OAuth2Error> details = jwtValidator.validate(jwt).getErrors();
+		Collection<String> messages = details.stream().map(OAuth2Error::getDescription).collect(Collectors.toList());
+
+		assertThat(messages).contains("Jwt expired at " + oneHourAgo);
+	}
+
+	@Test
+	public void validateWhenJwtIsTooEarlyThenErrorMessageIndicatesNotBeforeTime() {
+		Instant oneHourFromNow = Instant.now().plusSeconds(3600);
+
+		Jwt jwt = new Jwt(
+				MOCK_TOKEN_VALUE,
+				MOCK_ISSUED_AT,
+				null,
+				MOCK_HEADER,
+				Collections.singletonMap(JwtClaimNames.NBF, oneHourFromNow));
+
+		JwtTimestampValidator jwtValidator = new JwtTimestampValidator();
+
+		Collection<OAuth2Error> details = jwtValidator.validate(jwt).getErrors();
+		Collection<String> messages = details.stream().map(OAuth2Error::getDescription).collect(Collectors.toList());
+
+		assertThat(messages).contains("Jwt used before " + oneHourFromNow);
+	}
+
+	@Test
+	public void validateWhenConfiguredWithClockSkewThenValidatesUsingThatSkew() {
+		Duration oneDayOff = Duration.ofDays(1);
+		JwtTimestampValidator jwtValidator = new JwtTimestampValidator(oneDayOff);
+
+		Instant now = Instant.now();
+		Instant almostOneDayAgo = now.minus(oneDayOff).plusSeconds(10);
+		Instant almostOneDayFromNow = now.plus(oneDayOff).minusSeconds(10);
+		Instant justOverOneDayAgo = now.minus(oneDayOff).minusSeconds(10);
+		Instant justOverOneDayFromNow = now.plus(oneDayOff).plusSeconds(10);
+
+		Jwt jwt = new Jwt(
+				MOCK_TOKEN_VALUE,
+				MOCK_ISSUED_AT,
+				almostOneDayAgo,
+				MOCK_HEADER,
+				Collections.singletonMap(JwtClaimNames.NBF, almostOneDayFromNow));
+
+		assertThat(jwtValidator.validate(jwt).hasErrors()).isFalse();
+
+		jwt = new Jwt(
+				MOCK_TOKEN_VALUE,
+				MOCK_ISSUED_AT,
+				justOverOneDayAgo,
+				MOCK_HEADER,
+				MOCK_CLAIM_SET);
+
+		OAuth2TokenValidatorResult result = jwtValidator.validate(jwt);
+		Collection<String> messages =
+				result.getErrors().stream().map(OAuth2Error::getDescription).collect(Collectors.toList());
+
+		assertThat(result.hasErrors()).isTrue();
+		assertThat(messages).contains("Jwt expired at " + justOverOneDayAgo);
+
+		jwt = new Jwt(
+				MOCK_TOKEN_VALUE,
+				MOCK_ISSUED_AT,
+				null,
+				MOCK_HEADER,
+				Collections.singletonMap(JwtClaimNames.NBF, justOverOneDayFromNow));
+
+		result = jwtValidator.validate(jwt);
+		messages =
+				result.getErrors().stream().map(OAuth2Error::getDescription).collect(Collectors.toList());
+
+		assertThat(result.hasErrors()).isTrue();
+		assertThat(messages).contains("Jwt used before " + justOverOneDayFromNow);
+
+	}
+
+	@Test
+	public void validateWhenConfiguredWithFixedClockThenValidatesUsingFixedTime() {
+		Jwt jwt = new Jwt(
+				MOCK_TOKEN_VALUE,
+				MOCK_ISSUED_AT,
+				Instant.now(MOCK_NOW),
+				MOCK_HEADER,
+				Collections.singletonMap("some", "claim"));
+
+		JwtTimestampValidator jwtValidator = new JwtTimestampValidator(Duration.ofNanos(0));
+		jwtValidator.setClock(MOCK_NOW);
+
+		assertThat(jwtValidator.validate(jwt).hasErrors()).isFalse();
+
+		jwt = new Jwt(
+				MOCK_TOKEN_VALUE,
+				MOCK_ISSUED_AT,
+				null,
+				MOCK_HEADER,
+				Collections.singletonMap(JwtClaimNames.NBF, Instant.now(MOCK_NOW)));
+
+		assertThat(jwtValidator.validate(jwt).hasErrors()).isFalse();
+	}
+
+	@Test
+	public void validateWhenNeitherExpiryNorNotBeforeIsSpecifiedThenReturnsSuccessfulResult() {
+		Jwt jwt = new Jwt(
+				MOCK_TOKEN_VALUE,
+				MOCK_ISSUED_AT,
+				null,
+				MOCK_HEADER,
+				MOCK_CLAIM_SET);
+
+		JwtTimestampValidator jwtValidator = new JwtTimestampValidator();
+		assertThat(jwtValidator.validate(jwt).hasErrors()).isFalse();
+	}
+
+	@Test
+	public void validateWhenNotBeforeIsValidAndExpiryIsNotSpecifiedThenReturnsSuccessfulResult() {
+		Jwt jwt = new Jwt(
+				MOCK_TOKEN_VALUE,
+				MOCK_ISSUED_AT,
+				null,
+				MOCK_HEADER,
+				Collections.singletonMap(JwtClaimNames.NBF, Instant.MIN));
+
+		JwtTimestampValidator jwtValidator = new JwtTimestampValidator();
+		assertThat(jwtValidator.validate(jwt).hasErrors()).isFalse();
+	}
+
+	@Test
+	public void validateWhenExpiryIsValidAndNotBeforeIsNotSpecifiedThenReturnsSuccessfulResult() {
+		Jwt jwt = new Jwt(
+				MOCK_TOKEN_VALUE,
+				MOCK_ISSUED_AT,
+				Instant.MAX,
+				MOCK_HEADER,
+				MOCK_CLAIM_SET);
+
+		JwtTimestampValidator jwtValidator = new JwtTimestampValidator();
+		assertThat(jwtValidator.validate(jwt).hasErrors()).isFalse();
+	}
+
+	@Test
+	public void validateWhenBothExpiryAndNotBeforeAreValidThenReturnsSuccessfulResult() {
+		Jwt jwt = new Jwt(
+				MOCK_TOKEN_VALUE,
+				MOCK_ISSUED_AT,
+				Instant.now(MOCK_NOW),
+				MOCK_HEADER,
+				Collections.singletonMap(JwtClaimNames.NBF, Instant.now(MOCK_NOW)));
+
+		JwtTimestampValidator jwtValidator = new JwtTimestampValidator(Duration.ofNanos(0));
+		jwtValidator.setClock(MOCK_NOW);
+
+		assertThat(jwtValidator.validate(jwt).hasErrors()).isFalse();
+	}
+
+	@Test
+	public void setClockWhenInvokedWithNullThenThrowsIllegalArgumentException() {
+		JwtTimestampValidator jwtValidator = new JwtTimestampValidator();
+
+		assertThatCode(() -> jwtValidator.setClock(null))
+				.isInstanceOf(IllegalArgumentException.class);
+	}
+
+	@Test
+	public void constructorWhenInvokedWithNullDurationThenThrowsIllegalArgumentException() {
+		assertThatCode(() -> new JwtTimestampValidator(null))
+				.isInstanceOf(IllegalArgumentException.class);
+	}
+}

+ 56 - 2
oauth2/oauth2-jose/src/test/java/org/springframework/security/oauth2/jwt/NimbusJwtDecoderJwkSupportTests.java

@@ -15,6 +15,8 @@
  */
 package org.springframework.security.oauth2.jwt;
 
+import java.util.Arrays;
+
 import com.nimbusds.jose.JWSAlgorithm;
 import com.nimbusds.jose.JWSHeader;
 import com.nimbusds.jwt.JWT;
@@ -30,16 +32,25 @@ import org.junit.runner.RunWith;
 import org.powermock.core.classloader.annotations.PowerMockIgnore;
 import org.powermock.core.classloader.annotations.PrepareForTest;
 import org.powermock.modules.junit4.PowerMockRunner;
+
 import org.springframework.http.RequestEntity;
+import org.springframework.security.oauth2.core.OAuth2Error;
+import org.springframework.security.oauth2.core.OAuth2TokenValidator;
+import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;
 import org.springframework.security.oauth2.jose.jws.JwsAlgorithms;
 import org.springframework.web.client.RestTemplate;
 
 import static org.assertj.core.api.AssertionsForClassTypes.assertThatCode;
 import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy;
-import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.verify;
-import static org.powermock.api.mockito.PowerMockito.*;
+import static org.powermock.api.mockito.PowerMockito.mockStatic;
+import static org.powermock.api.mockito.PowerMockito.spy;
+import static org.powermock.api.mockito.PowerMockito.when;
+import static org.powermock.api.mockito.PowerMockito.whenNew;
 
 /**
  * Tests for {@link NimbusJwtDecoderJwkSupport}.
@@ -174,4 +185,47 @@ public class NimbusJwtDecoderJwkSupportTests {
 			server.shutdown();
 		}
 	}
+
+	@Test
+	public void decodeWhenJwtFailsValidationThenReturnsCorrespondingErrorMessage() throws Exception {
+		try ( MockWebServer server = new MockWebServer() ) {
+			server.enqueue(new MockResponse().setBody(JWK_SET));
+			String jwkSetUrl = server.url("/.well-known/jwks.json").toString();
+
+			NimbusJwtDecoderJwkSupport decoder = new NimbusJwtDecoderJwkSupport(jwkSetUrl);
+
+			OAuth2Error failure = new OAuth2Error("mock-error", "mock-description", "mock-uri");
+
+			OAuth2TokenValidator<Jwt> jwtValidator = mock(OAuth2TokenValidator.class);
+			when(jwtValidator.validate(any(Jwt.class))).thenReturn(OAuth2TokenValidatorResult.failure(failure));
+			decoder.setJwtValidator(jwtValidator);
+
+			assertThatCode(() -> decoder.decode(SIGNED_JWT))
+					.isInstanceOf(JwtValidationException.class)
+					.hasMessageContaining("mock-description");
+		}
+	}
+
+	@Test
+	public void decodeWhenJwtValidationHasTwoErrorsThenJwtExceptionMessageShowsFirstError() throws Exception {
+		try ( MockWebServer server = new MockWebServer() ) {
+			server.enqueue(new MockResponse().setBody(JWK_SET));
+			String jwkSetUrl = server.url("/.well-known/jwks.json").toString();
+
+			NimbusJwtDecoderJwkSupport decoder = new NimbusJwtDecoderJwkSupport(jwkSetUrl);
+
+			OAuth2Error firstFailure = new OAuth2Error("mock-error", "mock-description", "mock-uri");
+			OAuth2Error secondFailure = new OAuth2Error("another-error", "another-description", "another-uri");
+			OAuth2TokenValidatorResult result = OAuth2TokenValidatorResult.failure(firstFailure, secondFailure);
+
+			OAuth2TokenValidator<Jwt> jwtValidator = mock(OAuth2TokenValidator.class);
+			when(jwtValidator.validate(any(Jwt.class))).thenReturn(result);
+			decoder.setJwtValidator(jwtValidator);
+
+			assertThatCode(() -> decoder.decode(SIGNED_JWT))
+					.isInstanceOf(JwtValidationException.class)
+					.hasMessageContaining("mock-description")
+					.hasFieldOrPropertyWithValue("errors", Arrays.asList(firstFailure, secondFailure));
+		}
+	}
 }

+ 16 - 13
oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/authentication/JwtAuthenticationProvider.java

@@ -61,6 +61,9 @@ public final class JwtAuthenticationProvider implements AuthenticationProvider {
 
 	private final JwtConverter jwtConverter = new JwtConverter();
 
+	private static final OAuth2Error DEFAULT_INVALID_TOKEN =
+			invalidToken("An error occurred while attempting to decode the Jwt: Invalid token");
+
 	public JwtAuthenticationProvider(JwtDecoder jwtDecoder) {
 		Assert.notNull(jwtDecoder, "jwtDecoder cannot be null");
 
@@ -84,15 +87,10 @@ public final class JwtAuthenticationProvider implements AuthenticationProvider {
 		try {
 			jwt = this.jwtDecoder.decode(bearer.getToken());
 		} catch (JwtException failed) {
-			OAuth2Error invalidToken;
-			try {
-				invalidToken = invalidToken(failed.getMessage());
-			} catch ( IllegalArgumentException malformed ) {
-				// some third-party library error messages are not suitable for RFC 6750's error message charset
-				invalidToken = invalidToken("An error occurred while attempting to decode the Jwt: Invalid token");
-			}
-			throw new OAuth2AuthenticationException(invalidToken, failed);
+			OAuth2Error invalidToken = invalidToken(failed.getMessage());
+			throw new OAuth2AuthenticationException(invalidToken, invalidToken.getDescription(), failed);
 		}
+
 		JwtAuthenticationToken token = this.jwtConverter.convert(jwt);
 		token.setDetails(bearer.getDetails());
 
@@ -108,10 +106,15 @@ public final class JwtAuthenticationProvider implements AuthenticationProvider {
 	}
 
 	private static OAuth2Error invalidToken(String message) {
-		return new BearerTokenError(
-				BearerTokenErrorCodes.INVALID_TOKEN,
-				HttpStatus.UNAUTHORIZED,
-				message,
-				"https://tools.ietf.org/html/rfc6750#section-3.1");
+		try {
+			return new BearerTokenError(
+					BearerTokenErrorCodes.INVALID_TOKEN,
+					HttpStatus.UNAUTHORIZED,
+					message,
+					"https://tools.ietf.org/html/rfc6750#section-3.1");
+		} catch (IllegalArgumentException malformed) {
+			// some third-party library error messages are not suitable for RFC 6750's error message charset
+			return DEFAULT_INVALID_TOKEN;
+		}
 	}
 }