diff --git a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/OAuth2ResourceServerConfigurerTests.java b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/OAuth2ResourceServerConfigurerTests.java index cae0e33423..d592f0664c 100644 --- a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/OAuth2ResourceServerConfigurerTests.java +++ b/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 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 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 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 diff --git a/config/src/test/resources/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/OAuth2ResourceServerConfigurerTests-ExpiresAt4687177990.token b/config/src/test/resources/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/OAuth2ResourceServerConfigurerTests-ExpiresAt4687177990.token new file mode 100644 index 0000000000..df5ab8ac23 --- /dev/null +++ b/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 diff --git a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/DelegatingOAuth2TokenValidator.java b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/DelegatingOAuth2TokenValidator.java new file mode 100644 index 0000000000..dd6ae3d8a5 --- /dev/null +++ b/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 the type of {@link AbstractOAuth2Token} this validator validates + * + * @author Josh Cummings + * @since 5.1 + */ +public final class DelegatingOAuth2TokenValidator + implements OAuth2TokenValidator { + + private final Collection> tokenValidators; + + public DelegatingOAuth2TokenValidator(Collection> tokenValidators) { + Assert.notNull(tokenValidators, "tokenValidators cannot be null"); + + this.tokenValidators = new ArrayList<>(tokenValidators); + } + + /** + * {@inheritDoc} + */ + @Override + public OAuth2TokenValidatorResult validate(T token) { + Collection errors = new ArrayList<>(); + + for ( OAuth2TokenValidator validator : this.tokenValidators) { + errors.addAll(validator.validate(token).getErrors()); + } + + return OAuth2TokenValidatorResult.failure(errors); + } +} diff --git a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/OAuth2TokenValidator.java b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/OAuth2TokenValidator.java new file mode 100644 index 0000000000..769f351a7b --- /dev/null +++ b/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 "verifying" + * 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 { + + /** + * 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); +} diff --git a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/OAuth2TokenValidatorResult.java b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/OAuth2TokenValidatorResult.java new file mode 100644 index 0000000000..247fbe391b --- /dev/null +++ b/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 errors; + + private OAuth2TokenValidatorResult(Collection 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 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 errors) { + if (errors.isEmpty()) { + return NO_ERRORS; + } + + return new OAuth2TokenValidatorResult(errors); + } +} diff --git a/oauth2/oauth2-core/src/test/java/org/springframework/security/oauth2/core/DelegatingOAuth2TokenValidatorTests.java b/oauth2/oauth2-core/src/test/java/org/springframework/security/oauth2/core/DelegatingOAuth2TokenValidatorTests.java new file mode 100644 index 0000000000..3203b66932 --- /dev/null +++ b/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 tokenValidator = + new DelegatingOAuth2TokenValidator<>(Collections.emptyList()); + AbstractOAuth2Token token = mock(AbstractOAuth2Token.class); + + assertThat(tokenValidator.validate(token).hasErrors()).isFalse(); + } + + @Test + public void validateWhenAnyValidatorFailsThenReturnsFailureResultContainingDetailFromFailingValidator() { + OAuth2TokenValidator success = mock(OAuth2TokenValidator.class); + OAuth2TokenValidator failure = mock(OAuth2TokenValidator.class); + + when(success.validate(any(AbstractOAuth2Token.class))) + .thenReturn(OAuth2TokenValidatorResult.success()); + when(failure.validate(any(AbstractOAuth2Token.class))) + .thenReturn(OAuth2TokenValidatorResult.failure(DETAIL)); + + DelegatingOAuth2TokenValidator 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 firstFailure = mock(OAuth2TokenValidator.class); + OAuth2TokenValidator 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 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 firstSuccess = mock(OAuth2TokenValidator.class); + OAuth2TokenValidator secondSuccess = mock(OAuth2TokenValidator.class); + + when(firstSuccess.validate(any(AbstractOAuth2Token.class))) + .thenReturn(OAuth2TokenValidatorResult.success()); + when(secondSuccess.validate(any(AbstractOAuth2Token.class))) + .thenReturn(OAuth2TokenValidatorResult.success()); + + DelegatingOAuth2TokenValidator 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); + } +} diff --git a/oauth2/oauth2-core/src/test/java/org/springframework/security/oauth2/core/OAuth2TokenValidatorResultTests.java b/oauth2/oauth2-core/src/test/java/org/springframework/security/oauth2/core/OAuth2TokenValidatorResultTests.java new file mode 100644 index 0000000000..dd43a31da3 --- /dev/null +++ b/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); + } +} diff --git a/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/JwtIssuerValidator.java b/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/JwtIssuerValidator.java new file mode 100644 index 0000000000..6abd9a4945 --- /dev/null +++ b/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 { + 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); + } + } +} diff --git a/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/JwtTimestampValidator.java b/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/JwtTimestampValidator.java new file mode 100644 index 0000000000..84ae6eb94d --- /dev/null +++ b/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 + * + *

+ * 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 JSON Web Token (JWT) + */ +public final class JwtTimestampValidator implements OAuth2TokenValidator { + 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; + } +} diff --git a/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/JwtValidationException.java b/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/JwtValidationException.java new file mode 100644 index 0000000000..cb19e2bc33 --- /dev/null +++ b/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 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: + * + *

+	 * 	if ( result.hasErrors() ) {
+	 *  	Collection errors = result.getErrors();
+	 *  	throw new JwtValidationException(errors.iterator().next().getDescription(), errors);
+	 * 	}
+	 * 
+ * + * @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 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 getErrors() { + return this.errors; + } +} diff --git a/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/JwtValidators.java b/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/JwtValidators.java new file mode 100644 index 0000000000..3e8782130a --- /dev/null +++ b/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 createDelegatingJwtValidator(OAuth2TokenValidator... jwtValidators) { + Collection> validators = new ArrayList<>(); + validators.add(new JwtTimestampValidator()); + validators.addAll(Arrays.asList(jwtValidators)); + return new DelegatingOAuth2TokenValidator<>(validators); + } + + private JwtValidators() {} +} diff --git a/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/NimbusJwtDecoderJwkSupport.java b/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/NimbusJwtDecoderJwkSupport.java index dff7d9b95a..1bbc126fa9 100644 --- a/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/NimbusJwtDecoderJwkSupport.java +++ b/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 jwtProcessor; private final RestOperationsResourceRetriever jwkSetRetriever = new RestOperationsResourceRetriever(); + private OAuth2TokenValidator 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 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. * diff --git a/oauth2/oauth2-jose/src/test/java/org/springframework/security/oauth2/jwt/JwtIssuerValidatorTests.java b/oauth2/oauth2-jose/src/test/java/org/springframework/security/oauth2/jwt/JwtIssuerValidatorTests.java new file mode 100644 index 0000000000..7a01da149e --- /dev/null +++ b/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 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); + } +} diff --git a/oauth2/oauth2-jose/src/test/java/org/springframework/security/oauth2/jwt/JwtTimestampValidatorTests.java b/oauth2/oauth2-jose/src/test/java/org/springframework/security/oauth2/jwt/JwtTimestampValidatorTests.java new file mode 100644 index 0000000000..57fe14b125 --- /dev/null +++ b/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 MOCK_HEADER = Collections.singletonMap("alg", JwsAlgorithms.RS256); + private static final Map 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 details = jwtValidator.validate(jwt).getErrors(); + Collection 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 details = jwtValidator.validate(jwt).getErrors(); + Collection 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 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); + } +} diff --git a/oauth2/oauth2-jose/src/test/java/org/springframework/security/oauth2/jwt/NimbusJwtDecoderJwkSupportTests.java b/oauth2/oauth2-jose/src/test/java/org/springframework/security/oauth2/jwt/NimbusJwtDecoderJwkSupportTests.java index ecd648f513..bc3f36932e 100644 --- a/oauth2/oauth2-jose/src/test/java/org/springframework/security/oauth2/jwt/NimbusJwtDecoderJwkSupportTests.java +++ b/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 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 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)); + } + } } diff --git a/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/authentication/JwtAuthenticationProvider.java b/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/authentication/JwtAuthenticationProvider.java index b70bd2accd..fafb111422 100644 --- a/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/authentication/JwtAuthenticationProvider.java +++ b/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; + } } }