|
@@ -0,0 +1,319 @@
|
|
|
|
+/*
|
|
|
|
+ * Copyright 2002-2023 the original author or authors.
|
|
|
|
+ *
|
|
|
|
+ * Licensed under the Apache License, Version 2.0 (the "License");
|
|
|
|
+ * you may not use this file except in compliance with the License.
|
|
|
|
+ * You may obtain a copy of the License at
|
|
|
|
+ *
|
|
|
|
+ * https://www.apache.org/licenses/LICENSE-2.0
|
|
|
|
+ *
|
|
|
|
+ * Unless required by applicable law or agreed to in writing, software
|
|
|
|
+ * distributed under the License is distributed on an "AS IS" BASIS,
|
|
|
|
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
|
|
+ * See the License for the specific language governing permissions and
|
|
|
|
+ * limitations under the License.
|
|
|
|
+ */
|
|
|
|
+
|
|
|
|
+package org.springframework.security.oauth2.jwt;
|
|
|
|
+
|
|
|
|
+import java.net.URI;
|
|
|
|
+import java.util.HashMap;
|
|
|
|
+import java.util.Map;
|
|
|
|
+import java.util.Optional;
|
|
|
|
+
|
|
|
|
+import com.fasterxml.jackson.core.JsonProcessingException;
|
|
|
|
+import com.fasterxml.jackson.core.type.TypeReference;
|
|
|
|
+import com.fasterxml.jackson.databind.JsonMappingException;
|
|
|
|
+import com.fasterxml.jackson.databind.ObjectMapper;
|
|
|
|
+import okhttp3.HttpUrl;
|
|
|
|
+import okhttp3.mockwebserver.Dispatcher;
|
|
|
|
+import okhttp3.mockwebserver.MockResponse;
|
|
|
|
+import okhttp3.mockwebserver.MockWebServer;
|
|
|
|
+import okhttp3.mockwebserver.RecordedRequest;
|
|
|
|
+import org.junit.jupiter.api.AfterEach;
|
|
|
|
+import org.junit.jupiter.api.BeforeEach;
|
|
|
|
+import org.junit.jupiter.api.Test;
|
|
|
|
+
|
|
|
|
+import org.springframework.http.HttpHeaders;
|
|
|
|
+import org.springframework.http.MediaType;
|
|
|
|
+import org.springframework.web.reactive.function.client.WebClient;
|
|
|
|
+import org.springframework.web.util.UriComponentsBuilder;
|
|
|
|
+
|
|
|
|
+import static org.assertj.core.api.Assertions.assertThat;
|
|
|
|
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
|
|
|
|
+import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
|
|
|
|
+import static org.assertj.core.api.Assertions.assertThatIllegalStateException;
|
|
|
|
+
|
|
|
|
+/**
|
|
|
|
+ * Tests for {@link ReactiveJwtDecoderProviderConfigurationUtils}
|
|
|
|
+ *
|
|
|
|
+ * @author Josh Cummings
|
|
|
|
+ */
|
|
|
|
+public class ReactiveJwtDecoderProviderConfigurationUtilsTests {
|
|
|
|
+
|
|
|
|
+ /**
|
|
|
|
+ * Contains those parameters required to construct a ReactiveJwtDecoder as well as any
|
|
|
|
+ * required parameters
|
|
|
|
+ */
|
|
|
|
+ // @formatter:off
|
|
|
|
+ private static final String DEFAULT_RESPONSE_TEMPLATE = "{\n"
|
|
|
|
+ + " \"authorization_endpoint\": \"https://example.com/o/oauth2/v2/auth\", \n"
|
|
|
|
+ + " \"id_token_signing_alg_values_supported\": [\n"
|
|
|
|
+ + " \"RS256\"\n"
|
|
|
|
+ + " ], \n"
|
|
|
|
+ + " \"issuer\": \"%s\", \n"
|
|
|
|
+ + " \"jwks_uri\": \"%s/.well-known/jwks.json\", \n"
|
|
|
|
+ + " \"response_types_supported\": [\n"
|
|
|
|
+ + " \"code\", \n"
|
|
|
|
+ + " \"token\", \n"
|
|
|
|
+ + " \"id_token\", \n"
|
|
|
|
+ + " \"code token\", \n"
|
|
|
|
+ + " \"code id_token\", \n"
|
|
|
|
+ + " \"token id_token\", \n"
|
|
|
|
+ + " \"code token id_token\", \n"
|
|
|
|
+ + " \"none\"\n"
|
|
|
|
+ + " ], \n"
|
|
|
|
+ + " \"subject_types_supported\": [\n"
|
|
|
|
+ + " \"public\"\n"
|
|
|
|
+ + " ], \n"
|
|
|
|
+ + " \"token_endpoint\": \"https://example.com/oauth2/v4/token\"\n"
|
|
|
|
+ + "}";
|
|
|
|
+ // @formatter:on
|
|
|
|
+
|
|
|
|
+ private static final String JWK_SET = "{\"keys\":[{\"kty\":\"RSA\",\"e\":\"AQAB\",\"use\":\"sig\",\"kid\":\"one\",\"n\":\"oXJ8OyOv_eRnce4akdanR4KYRfnC2zLV4uYNQpcFn6oHL0dj7D6kxQmsXoYgJV8ZVDn71KGmuLvolxsDncc2UrhyMBY6DVQVgMSVYaPCTgW76iYEKGgzTEw5IBRQL9w3SRJWd3VJTZZQjkXef48Ocz06PGF3lhbz4t5UEZtdF4rIe7u-977QwHuh7yRPBQ3sII-cVoOUMgaXB9SHcGF2iZCtPzL_IffDUcfhLQteGebhW8A6eUHgpD5A1PQ-JCw_G7UOzZAjjDjtNM2eqm8j-Ms_gqnm4MiCZ4E-9pDN77CAAPVN7kuX6ejs9KBXpk01z48i9fORYk9u7rAkh1HuQw\"}]}";
|
|
|
|
+
|
|
|
|
+ private static final String OIDC_METADATA_PATH = "/.well-known/openid-configuration";
|
|
|
|
+
|
|
|
|
+ private static final String OAUTH_METADATA_PATH = "/.well-known/oauth-authorization-server";
|
|
|
|
+
|
|
|
|
+ private final WebClient web = WebClient.builder().build();
|
|
|
|
+
|
|
|
|
+ private MockWebServer server;
|
|
|
|
+
|
|
|
|
+ private String issuer;
|
|
|
|
+
|
|
|
|
+ @BeforeEach
|
|
|
|
+ public void setup() throws Exception {
|
|
|
|
+ this.server = new MockWebServer();
|
|
|
|
+ this.server.start();
|
|
|
|
+ this.issuer = createIssuerFromServer();
|
|
|
|
+ this.issuer += "path";
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ @AfterEach
|
|
|
|
+ public void cleanup() throws Exception {
|
|
|
|
+ this.server.shutdown();
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ @Test
|
|
|
|
+ public void issuerWhenResponseIsTypicalThenReturnedConfigurationContainsJwksUri() {
|
|
|
|
+ prepareConfigurationResponse();
|
|
|
|
+ Map<String, Object> configuration = ReactiveJwtDecoderProviderConfigurationUtils
|
|
|
|
+ .getConfigurationForIssuerLocation(this.issuer, this.web).block();
|
|
|
|
+ assertThat(configuration).containsKey("jwks_uri");
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ @Test
|
|
|
|
+ public void issuerWhenOidcFallbackResponseIsTypicalThenReturnedConfigurationContainsJwksUri() {
|
|
|
|
+ prepareConfigurationResponseOidc();
|
|
|
|
+ Map<String, Object> configuration = ReactiveJwtDecoderProviderConfigurationUtils
|
|
|
|
+ .getConfigurationForIssuerLocation(this.issuer, this.web).block();
|
|
|
|
+ assertThat(configuration).containsKey("jwks_uri");
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ @Test
|
|
|
|
+ public void issuerWhenOAuth2ResponseIsTypicalThenReturnedConfigurationContainsJwksUri() {
|
|
|
|
+ prepareConfigurationResponseOAuth2();
|
|
|
|
+ Map<String, Object> configuration = ReactiveJwtDecoderProviderConfigurationUtils
|
|
|
|
+ .getConfigurationForIssuerLocation(this.issuer, this.web).block();
|
|
|
|
+ assertThat(configuration).containsKey("jwks_uri");
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ @Test
|
|
|
|
+ public void issuerWhenOidcFallbackResponseIsNonCompliantThenThrowsRuntimeException() {
|
|
|
|
+ prepareConfigurationResponseOidc("{ \"missing_required_keys\" : \"and_values\" }");
|
|
|
|
+ // @formatter:off
|
|
|
|
+ assertThatExceptionOfType(RuntimeException.class)
|
|
|
|
+ .isThrownBy(() -> ReactiveJwtDecoderProviderConfigurationUtils.getConfigurationForIssuerLocation(this.issuer, this.web).block());
|
|
|
|
+ // @formatter:on
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ @Test
|
|
|
|
+ public void issuerWhenOAuth2ResponseIsNonCompliantThenThrowsRuntimeException() {
|
|
|
|
+ prepareConfigurationResponseOAuth2("{ \"missing_required_keys\" : \"and_values\" }");
|
|
|
|
+ // @formatter:off
|
|
|
|
+ assertThatExceptionOfType(RuntimeException.class)
|
|
|
|
+ .isThrownBy(() -> ReactiveJwtDecoderProviderConfigurationUtils.getConfigurationForIssuerLocation(this.issuer, this.web).block());
|
|
|
|
+ // @formatter:on
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ // gh-7512
|
|
|
|
+ @Test
|
|
|
|
+ public void issuerWhenOidcFallbackResponseDoesNotContainJwksUriThenThrowsIllegalArgumentException()
|
|
|
|
+ throws JsonMappingException, JsonProcessingException {
|
|
|
|
+ prepareConfigurationResponseOidc(this.buildResponseWithMissingJwksUri());
|
|
|
|
+ // @formatter:off
|
|
|
|
+ assertThatIllegalArgumentException()
|
|
|
|
+ .isThrownBy(() -> ReactiveJwtDecoderProviderConfigurationUtils.getConfigurationForIssuerLocation(this.issuer, this.web).block())
|
|
|
|
+ .withMessage("The public JWK set URI must not be null");
|
|
|
|
+ // @formatter:on
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ // gh-7512
|
|
|
|
+ @Test
|
|
|
|
+ public void issuerWhenOAuth2ResponseDoesNotContainJwksUriThenThrowsIllegalArgumentException()
|
|
|
|
+ throws JsonMappingException, JsonProcessingException {
|
|
|
|
+ prepareConfigurationResponseOAuth2(this.buildResponseWithMissingJwksUri());
|
|
|
|
+ // @formatter:off
|
|
|
|
+ assertThatIllegalArgumentException()
|
|
|
|
+ .isThrownBy(() -> ReactiveJwtDecoderProviderConfigurationUtils.getConfigurationForIssuerLocation(this.issuer, this.web).block())
|
|
|
|
+ .withMessage("The public JWK set URI must not be null");
|
|
|
|
+ // @formatter:on
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ @Test
|
|
|
|
+ public void issuerWhenOidcFallbackResponseIsMalformedThenThrowsRuntimeException() {
|
|
|
|
+ prepareConfigurationResponseOidc("malformed");
|
|
|
|
+ // @formatter:off
|
|
|
|
+ assertThatExceptionOfType(RuntimeException.class)
|
|
|
|
+ .isThrownBy(() -> ReactiveJwtDecoderProviderConfigurationUtils.getConfigurationForIssuerLocation(this.issuer, this.web).block());
|
|
|
|
+ // @formatter:on
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ @Test
|
|
|
|
+ public void issuerWhenOAuth2ResponseIsMalformedThenThrowsRuntimeException() {
|
|
|
|
+ prepareConfigurationResponseOAuth2("malformed");
|
|
|
|
+ // @formatter:off
|
|
|
|
+ assertThatExceptionOfType(RuntimeException.class)
|
|
|
|
+ .isThrownBy(() -> ReactiveJwtDecoderProviderConfigurationUtils.getConfigurationForIssuerLocation(this.issuer, this.web).block());
|
|
|
|
+ // @formatter:on
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ @Test
|
|
|
|
+ public void issuerWhenOidcFallbackRespondingIssuerMismatchesRequestedIssuerThenThrowsIllegalStateException() {
|
|
|
|
+ prepareConfigurationResponseOidc(String.format(DEFAULT_RESPONSE_TEMPLATE, this.issuer + "/wrong", this.issuer));
|
|
|
|
+ // @formatter:off
|
|
|
|
+ assertThatIllegalStateException()
|
|
|
|
+ .isThrownBy(() -> {
|
|
|
|
+ Map<String, Object> configuration = ReactiveJwtDecoderProviderConfigurationUtils.getConfigurationForIssuerLocation(this.issuer, this.web).block();
|
|
|
|
+ JwtDecoderProviderConfigurationUtils.validateIssuer(configuration, this.issuer);
|
|
|
|
+ });
|
|
|
|
+ // @formatter:on
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ @Test
|
|
|
|
+ public void issuerWhenOAuth2RespondingIssuerMismatchesRequestedIssuerThenThrowsIllegalStateException() {
|
|
|
|
+ prepareConfigurationResponseOAuth2(
|
|
|
|
+ String.format(DEFAULT_RESPONSE_TEMPLATE, this.issuer + "/wrong", this.issuer));
|
|
|
|
+ // @formatter:off
|
|
|
|
+ assertThatIllegalStateException()
|
|
|
|
+ .isThrownBy(() -> {
|
|
|
|
+ Map<String, Object> configuration = ReactiveJwtDecoderProviderConfigurationUtils.getConfigurationForIssuerLocation(this.issuer, this.web).block();
|
|
|
|
+ JwtDecoderProviderConfigurationUtils.validateIssuer(configuration, this.issuer);
|
|
|
|
+ });
|
|
|
|
+ // @formatter:on
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ @Test
|
|
|
|
+ public void issuerWhenOidcFallbackRequestedIssuerIsUnresponsiveThenThrowsIllegalArgumentException()
|
|
|
|
+ throws Exception {
|
|
|
|
+ this.server.shutdown();
|
|
|
|
+ // @formatter:off
|
|
|
|
+ assertThatIllegalArgumentException()
|
|
|
|
+ .isThrownBy(() -> ReactiveJwtDecoderProviderConfigurationUtils.getConfigurationForIssuerLocation("https://issuer", this.web).block());
|
|
|
|
+ // @formatter:on
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ private void prepareConfigurationResponse() {
|
|
|
|
+ String body = String.format(DEFAULT_RESPONSE_TEMPLATE, this.issuer, this.issuer);
|
|
|
|
+ prepareConfigurationResponse(body);
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ private void prepareConfigurationResponse(String body) {
|
|
|
|
+ this.server.enqueue(response(body));
|
|
|
|
+ this.server.enqueue(response(JWK_SET));
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ private void prepareConfigurationResponseOidc() {
|
|
|
|
+ String body = String.format(DEFAULT_RESPONSE_TEMPLATE, this.issuer, this.issuer);
|
|
|
|
+ prepareConfigurationResponseOidc(body);
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ private void prepareConfigurationResponseOidc(String body) {
|
|
|
|
+ Map<String, MockResponse> responses = new HashMap<>();
|
|
|
|
+ responses.put(oidc(), response(body));
|
|
|
|
+ responses.put(jwks(), response(JWK_SET));
|
|
|
|
+ prepareConfigurationResponses(responses);
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ private void prepareConfigurationResponseOAuth2() {
|
|
|
|
+ String body = String.format(DEFAULT_RESPONSE_TEMPLATE, this.issuer, this.issuer);
|
|
|
|
+ prepareConfigurationResponseOAuth2(body);
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ private void prepareConfigurationResponseOAuth2(String body) {
|
|
|
|
+ Map<String, MockResponse> responses = new HashMap<>();
|
|
|
|
+ responses.put(oauth(), response(body));
|
|
|
|
+ responses.put(jwks(), response(JWK_SET));
|
|
|
|
+ prepareConfigurationResponses(responses);
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ private void prepareConfigurationResponses(Map<String, MockResponse> responses) {
|
|
|
|
+ Dispatcher dispatcher = new Dispatcher() {
|
|
|
|
+ @Override
|
|
|
|
+ public MockResponse dispatch(RecordedRequest request) {
|
|
|
|
+ // @formatter:off
|
|
|
|
+ return Optional.of(request)
|
|
|
|
+ .map(RecordedRequest::getRequestUrl)
|
|
|
|
+ .map(HttpUrl::toString)
|
|
|
|
+ .map(responses::get)
|
|
|
|
+ .orElse(new MockResponse().setResponseCode(404));
|
|
|
|
+ // @formatter:on
|
|
|
|
+ }
|
|
|
|
+ };
|
|
|
|
+ this.server.setDispatcher(dispatcher);
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ private String createIssuerFromServer() {
|
|
|
|
+ return this.server.url("").toString();
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ private String oidc() {
|
|
|
|
+ URI uri = URI.create(this.issuer);
|
|
|
|
+ // @formatter:off
|
|
|
|
+ return UriComponentsBuilder.fromUri(uri)
|
|
|
|
+ .replacePath(uri.getPath() + OIDC_METADATA_PATH)
|
|
|
|
+ .toUriString();
|
|
|
|
+ // @formatter:on
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ private String oauth() {
|
|
|
|
+ URI uri = URI.create(this.issuer);
|
|
|
|
+ // @formatter:off
|
|
|
|
+ return UriComponentsBuilder.fromUri(uri)
|
|
|
|
+ .replacePath(OAUTH_METADATA_PATH + uri.getPath())
|
|
|
|
+ .toUriString();
|
|
|
|
+ // @formatter:on
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ private String jwks() {
|
|
|
|
+ return this.issuer + "/.well-known/jwks.json";
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ private MockResponse response(String body) {
|
|
|
|
+ // @formatter:off
|
|
|
|
+ return new MockResponse().setBody(body)
|
|
|
|
+ .setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE);
|
|
|
|
+ // @formatter:on
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ public String buildResponseWithMissingJwksUri() throws JsonMappingException, JsonProcessingException {
|
|
|
|
+ ObjectMapper mapper = new ObjectMapper();
|
|
|
|
+ Map<String, Object> response = mapper.readValue(DEFAULT_RESPONSE_TEMPLATE,
|
|
|
|
+ new TypeReference<Map<String, Object>>() {
|
|
|
|
+ });
|
|
|
|
+ response.remove("jwks_uri");
|
|
|
|
+ return mapper.writeValueAsString(response);
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+}
|