diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/OAuth2ResourceServerConfigurer.java b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/OAuth2ResourceServerConfigurer.java index 3b0b19fdc1..02a99fdb2a 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/OAuth2ResourceServerConfigurer.java +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/OAuth2ResourceServerConfigurer.java @@ -42,8 +42,8 @@ import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter; import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationProvider; import org.springframework.security.oauth2.server.resource.authentication.OpaqueTokenAuthenticationProvider; -import org.springframework.security.oauth2.server.resource.introspection.NimbusOpaqueTokenIntrospector; import org.springframework.security.oauth2.server.resource.introspection.OpaqueTokenIntrospector; +import org.springframework.security.oauth2.server.resource.introspection.SpringOpaqueTokenIntrospector; import org.springframework.security.oauth2.server.resource.web.BearerTokenAuthenticationEntryPoint; import org.springframework.security.oauth2.server.resource.web.BearerTokenAuthenticationFilter; import org.springframework.security.oauth2.server.resource.web.BearerTokenResolver; @@ -454,7 +454,7 @@ public final class OAuth2ResourceServerConfigurer new NimbusOpaqueTokenIntrospector(this.introspectionUri, this.clientId, + this.introspector = () -> new SpringOpaqueTokenIntrospector(this.introspectionUri, this.clientId, this.clientSecret); return this; } @@ -464,7 +464,7 @@ public final class OAuth2ResourceServerConfigurer new NimbusOpaqueTokenIntrospector(this.introspectionUri, this.clientId, + this.introspector = () -> new SpringOpaqueTokenIntrospector(this.introspectionUri, this.clientId, this.clientSecret); return this; } 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 d8b06e120e..30f4d5e5ea 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 @@ -1124,7 +1124,7 @@ public class OAuth2ResourceServerConfigurerTests { opaqueTokenConfigurer.introspector(client); opaqueTokenConfigurer.introspectionUri(INTROSPECTION_URI); opaqueTokenConfigurer.introspectionClientCredentials(CLIENT_ID, CLIENT_SECRET); - assertThat(opaqueTokenConfigurer.getIntrospector()).isInstanceOf(NimbusOpaqueTokenIntrospector.class); + assertThat(opaqueTokenConfigurer.getIntrospector()).isNotSameAs(client); } @Test diff --git a/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/introspection/SpringOpaqueTokenIntrospector.java b/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/introspection/SpringOpaqueTokenIntrospector.java new file mode 100644 index 0000000000..111af46596 --- /dev/null +++ b/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/introspection/SpringOpaqueTokenIntrospector.java @@ -0,0 +1,216 @@ +/* + * Copyright 2002-2021 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.server.resource.introspection; + +import java.net.URI; +import java.net.URL; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.Map; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.core.convert.converter.Converter; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.RequestEntity; +import org.springframework.http.ResponseEntity; +import org.springframework.http.client.support.BasicAuthenticationInterceptor; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.oauth2.core.OAuth2AuthenticatedPrincipal; +import org.springframework.util.Assert; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestOperations; +import org.springframework.web.client.RestTemplate; + +/** + * A Spring implementation of {@link OpaqueTokenIntrospector} that verifies and + * introspects a token using the configured + * OAuth 2.0 Introspection + * Endpoint. + * + * @author Josh Cummings + * @since 5.6 + */ +public class SpringOpaqueTokenIntrospector implements OpaqueTokenIntrospector { + + private final Log logger = LogFactory.getLog(getClass()); + + private static final ParameterizedTypeReference> STRING_OBJECT_MAP = new ParameterizedTypeReference>() { + }; + + private Converter> requestEntityConverter; + + private RestOperations restOperations; + + private final String authorityPrefix = "SCOPE_"; + + /** + * Creates a {@code OpaqueTokenAuthenticationProvider} with the provided parameters + * @param introspectionUri The introspection endpoint uri + * @param clientId The client id authorized to introspect + * @param clientSecret The client's secret + */ + public SpringOpaqueTokenIntrospector(String introspectionUri, String clientId, String clientSecret) { + Assert.notNull(introspectionUri, "introspectionUri cannot be null"); + Assert.notNull(clientId, "clientId cannot be null"); + Assert.notNull(clientSecret, "clientSecret cannot be null"); + this.requestEntityConverter = this.defaultRequestEntityConverter(URI.create(introspectionUri)); + RestTemplate restTemplate = new RestTemplate(); + restTemplate.getInterceptors().add(new BasicAuthenticationInterceptor(clientId, clientSecret)); + this.restOperations = restTemplate; + } + + /** + * Creates a {@code OpaqueTokenAuthenticationProvider} with the provided parameters + * + * The given {@link RestOperations} should perform its own client authentication + * against the introspection endpoint. + * @param introspectionUri The introspection endpoint uri + * @param restOperations The client for performing the introspection request + */ + public SpringOpaqueTokenIntrospector(String introspectionUri, RestOperations restOperations) { + Assert.notNull(introspectionUri, "introspectionUri cannot be null"); + Assert.notNull(restOperations, "restOperations cannot be null"); + this.requestEntityConverter = this.defaultRequestEntityConverter(URI.create(introspectionUri)); + this.restOperations = restOperations; + } + + private Converter> defaultRequestEntityConverter(URI introspectionUri) { + return (token) -> { + HttpHeaders headers = requestHeaders(); + MultiValueMap body = requestBody(token); + return new RequestEntity<>(body, headers, HttpMethod.POST, introspectionUri); + }; + } + + private HttpHeaders requestHeaders() { + HttpHeaders headers = new HttpHeaders(); + headers.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON)); + return headers; + } + + private MultiValueMap requestBody(String token) { + MultiValueMap body = new LinkedMultiValueMap<>(); + body.add("token", token); + return body; + } + + @Override + public OAuth2AuthenticatedPrincipal introspect(String token) { + RequestEntity requestEntity = this.requestEntityConverter.convert(token); + if (requestEntity == null) { + throw new OAuth2IntrospectionException("requestEntityConverter returned a null entity"); + } + ResponseEntity> responseEntity = makeRequest(requestEntity); + Map claims = adaptToNimbusResponse(responseEntity); + return convertClaimsSet(claims); + } + + /** + * Sets the {@link Converter} used for converting the OAuth 2.0 access token to a + * {@link RequestEntity} representation of the OAuth 2.0 token introspection request. + * @param requestEntityConverter the {@link Converter} used for converting to a + * {@link RequestEntity} representation of the token introspection request + */ + public void setRequestEntityConverter(Converter> requestEntityConverter) { + Assert.notNull(requestEntityConverter, "requestEntityConverter cannot be null"); + this.requestEntityConverter = requestEntityConverter; + } + + private ResponseEntity> makeRequest(RequestEntity requestEntity) { + try { + return this.restOperations.exchange(requestEntity, STRING_OBJECT_MAP); + } + catch (Exception ex) { + throw new OAuth2IntrospectionException(ex.getMessage(), ex); + } + } + + private Map adaptToNimbusResponse(ResponseEntity> responseEntity) { + if (responseEntity.getStatusCode() != HttpStatus.OK) { + throw new OAuth2IntrospectionException( + "Introspection endpoint responded with " + responseEntity.getStatusCode()); + } + Map claims = responseEntity.getBody(); + // relying solely on the authorization server to validate this token (not checking + // 'exp', for example) + boolean active = (boolean) claims.compute(OAuth2IntrospectionClaimNames.ACTIVE, (k, v) -> { + if (v instanceof String) { + return Boolean.parseBoolean((String) v); + } + if (v instanceof Boolean) { + return v; + } + return false; + }); + if (!active) { + this.logger.trace("Did not validate token since it is inactive"); + throw new BadOpaqueTokenException("Provided token isn't active"); + } + return claims; + } + + private OAuth2AuthenticatedPrincipal convertClaimsSet(Map claims) { + claims.computeIfPresent(OAuth2IntrospectionClaimNames.AUDIENCE, (k, v) -> { + if (v instanceof String) { + return Collections.singletonList(v); + } + return v; + }); + claims.computeIfPresent(OAuth2IntrospectionClaimNames.CLIENT_ID, (k, v) -> v.toString()); + claims.computeIfPresent(OAuth2IntrospectionClaimNames.EXPIRES_AT, + (k, v) -> Instant.ofEpochSecond(((Number) v).longValue())); + claims.computeIfPresent(OAuth2IntrospectionClaimNames.ISSUED_AT, + (k, v) -> Instant.ofEpochSecond(((Number) v).longValue())); + claims.computeIfPresent(OAuth2IntrospectionClaimNames.ISSUER, (k, v) -> issuer(v.toString())); + claims.computeIfPresent(OAuth2IntrospectionClaimNames.NOT_BEFORE, + (k, v) -> Instant.ofEpochSecond(((Number) v).longValue())); + Collection authorities = new ArrayList<>(); + claims.computeIfPresent(OAuth2IntrospectionClaimNames.SCOPE, (k, v) -> { + if (v instanceof String) { + Collection scopes = Arrays.asList(((String) v).split(" ")); + for (String scope : scopes) { + authorities.add(new SimpleGrantedAuthority(this.authorityPrefix + scope)); + } + return scopes; + } + return v; + }); + return new OAuth2IntrospectionAuthenticatedPrincipal(claims, authorities); + } + + private URL issuer(String uri) { + try { + return new URL(uri); + } + catch (Exception ex) { + throw new OAuth2IntrospectionException( + "Invalid " + OAuth2IntrospectionClaimNames.ISSUER + " value: " + uri); + } + } + +} diff --git a/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/introspection/SpringReactiveOpaqueTokenIntrospector.java b/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/introspection/SpringReactiveOpaqueTokenIntrospector.java new file mode 100644 index 0000000000..edc0965a6a --- /dev/null +++ b/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/introspection/SpringReactiveOpaqueTokenIntrospector.java @@ -0,0 +1,180 @@ +/* + * Copyright 2002-2021 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.server.resource.introspection; + +import java.net.URI; +import java.net.URL; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.Map; + +import reactor.core.publisher.Mono; + +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.io.buffer.DataBufferUtils; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.oauth2.core.OAuth2AuthenticatedPrincipal; +import org.springframework.util.Assert; +import org.springframework.web.reactive.function.BodyInserters; +import org.springframework.web.reactive.function.client.ClientResponse; +import org.springframework.web.reactive.function.client.WebClient; + +/** + * A Spring implementation of {@link ReactiveOpaqueTokenIntrospector} that verifies and + * introspects a token using the configured + * OAuth 2.0 Introspection + * Endpoint. + * + * @author Josh Cummings + * @since 5.6 + */ +public class SpringReactiveOpaqueTokenIntrospector implements ReactiveOpaqueTokenIntrospector { + + private static final ParameterizedTypeReference> STRING_OBJECT_MAP = new ParameterizedTypeReference>() { + }; + + private final URI introspectionUri; + + private final WebClient webClient; + + private String authorityPrefix = "SCOPE_"; + + /** + * Creates a {@code OpaqueTokenReactiveAuthenticationManager} with the provided + * parameters + * @param introspectionUri The introspection endpoint uri + * @param clientId The client id authorized to introspect + * @param clientSecret The client secret for the authorized client + */ + public SpringReactiveOpaqueTokenIntrospector(String introspectionUri, String clientId, String clientSecret) { + Assert.hasText(introspectionUri, "introspectionUri cannot be empty"); + Assert.hasText(clientId, "clientId cannot be empty"); + Assert.notNull(clientSecret, "clientSecret cannot be null"); + this.introspectionUri = URI.create(introspectionUri); + this.webClient = WebClient.builder().defaultHeaders((h) -> h.setBasicAuth(clientId, clientSecret)).build(); + } + + /** + * Creates a {@code OpaqueTokenReactiveAuthenticationManager} with the provided + * parameters + * @param introspectionUri The introspection endpoint uri + * @param webClient The client for performing the introspection request + */ + public SpringReactiveOpaqueTokenIntrospector(String introspectionUri, WebClient webClient) { + Assert.hasText(introspectionUri, "introspectionUri cannot be null"); + Assert.notNull(webClient, "webClient cannot be null"); + this.introspectionUri = URI.create(introspectionUri); + this.webClient = webClient; + } + + @Override + public Mono introspect(String token) { + // @formatter:off + return Mono.just(token) + .flatMap(this::makeRequest) + .flatMap(this::adaptToNimbusResponse) + .map(this::convertClaimsSet) + .onErrorMap((e) -> !(e instanceof OAuth2IntrospectionException), this::onError); + // @formatter:on + } + + private Mono makeRequest(String token) { + // @formatter:off + return this.webClient.post() + .uri(this.introspectionUri) + .header(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE) + .body(BodyInserters.fromFormData("token", token)) + .exchange(); + // @formatter:on + } + + private Mono> adaptToNimbusResponse(ClientResponse responseEntity) { + if (responseEntity.statusCode() != HttpStatus.OK) { + // @formatter:off + return responseEntity.bodyToFlux(DataBuffer.class) + .map(DataBufferUtils::release) + .then(Mono.error(new OAuth2IntrospectionException( + "Introspection endpoint responded with " + responseEntity.statusCode())) + ); + // @formatter:on + } + // relying solely on the authorization server to validate this token (not checking + // 'exp', for example) + return responseEntity.bodyToMono(STRING_OBJECT_MAP) + .filter((body) -> (boolean) body.compute(OAuth2IntrospectionClaimNames.ACTIVE, (k, v) -> { + if (v instanceof String) { + return Boolean.parseBoolean((String) v); + } + if (v instanceof Boolean) { + return v; + } + return false; + })).switchIfEmpty(Mono.error(() -> new BadOpaqueTokenException("Provided token isn't active"))); + } + + private OAuth2AuthenticatedPrincipal convertClaimsSet(Map claims) { + claims.computeIfPresent(OAuth2IntrospectionClaimNames.AUDIENCE, (k, v) -> { + if (v instanceof String) { + return Collections.singletonList(v); + } + return v; + }); + claims.computeIfPresent(OAuth2IntrospectionClaimNames.CLIENT_ID, (k, v) -> v.toString()); + claims.computeIfPresent(OAuth2IntrospectionClaimNames.EXPIRES_AT, + (k, v) -> Instant.ofEpochSecond(((Number) v).longValue())); + claims.computeIfPresent(OAuth2IntrospectionClaimNames.ISSUED_AT, + (k, v) -> Instant.ofEpochSecond(((Number) v).longValue())); + claims.computeIfPresent(OAuth2IntrospectionClaimNames.ISSUER, (k, v) -> issuer(v.toString())); + claims.computeIfPresent(OAuth2IntrospectionClaimNames.NOT_BEFORE, + (k, v) -> Instant.ofEpochSecond(((Number) v).longValue())); + Collection authorities = new ArrayList<>(); + claims.computeIfPresent(OAuth2IntrospectionClaimNames.SCOPE, (k, v) -> { + if (v instanceof String) { + Collection scopes = Arrays.asList(((String) v).split(" ")); + for (String scope : scopes) { + authorities.add(new SimpleGrantedAuthority(this.authorityPrefix + scope)); + } + return scopes; + } + return v; + }); + return new OAuth2IntrospectionAuthenticatedPrincipal(claims, authorities); + } + + private URL issuer(String uri) { + try { + return new URL(uri); + } + catch (Exception ex) { + throw new OAuth2IntrospectionException( + "Invalid " + OAuth2IntrospectionClaimNames.ISSUER + " value: " + uri); + } + } + + private OAuth2IntrospectionException onError(Throwable ex) { + return new OAuth2IntrospectionException(ex.getMessage(), ex); + } + +} diff --git a/oauth2/oauth2-resource-server/src/test/java/org/springframework/security/oauth2/server/resource/introspection/SpringOpaqueTokenIntrospectorTests.java b/oauth2/oauth2-resource-server/src/test/java/org/springframework/security/oauth2/server/resource/introspection/SpringOpaqueTokenIntrospectorTests.java new file mode 100644 index 0000000000..7a49839a8d --- /dev/null +++ b/oauth2/oauth2-resource-server/src/test/java/org/springframework/security/oauth2/server/resource/introspection/SpringOpaqueTokenIntrospectorTests.java @@ -0,0 +1,368 @@ +/* + * Copyright 2002-2021 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.server.resource.introspection; + +import java.io.IOException; +import java.net.URL; +import java.time.Instant; +import java.util.Arrays; +import java.util.Base64; +import java.util.Collection; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + +import com.nimbusds.jose.util.JSONObjectUtils; +import okhttp3.mockwebserver.Dispatcher; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; +import org.junit.jupiter.api.Test; + +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.core.convert.converter.Converter; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.RequestEntity; +import org.springframework.http.ResponseEntity; +import org.springframework.security.oauth2.core.OAuth2AuthenticatedPrincipal; +import org.springframework.web.client.RestOperations; + +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.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +/** + * Tests for {@link SpringOpaqueTokenIntrospector} + */ +public class SpringOpaqueTokenIntrospectorTests { + + private static final ParameterizedTypeReference> STRING_OBJECT_MAP = new ParameterizedTypeReference>() { + }; + + private static final String INTROSPECTION_URL = "https://server.example.com"; + + private static final String CLIENT_ID = "client"; + + private static final String CLIENT_SECRET = "secret"; + + // @formatter:off + private static final String ACTIVE_RESPONSE = "{\n" + + " \"active\": true,\n" + + " \"client_id\": \"l238j323ds-23ij4\",\n" + + " \"username\": \"jdoe\",\n" + + " \"scope\": \"read write dolphin\",\n" + + " \"sub\": \"Z5O3upPC88QrAjx00dis\",\n" + + " \"aud\": \"https://protected.example.net/resource\",\n" + + " \"iss\": \"https://server.example.com/\",\n" + + " \"exp\": 1419356238,\n" + + " \"iat\": 1419350238,\n" + + " \"extension_field\": \"twenty-seven\"\n" + + " }"; + // @formatter:on + + // @formatter:off + private static final String INACTIVE_RESPONSE = "{\n" + + " \"active\": false\n" + + " }"; + // @formatter:on + + // @formatter:off + private static final String INVALID_RESPONSE = "{\n" + + " \"client_id\": \"l238j323ds-23ij4\",\n" + + " \"username\": \"jdoe\",\n" + + " \"scope\": \"read write dolphin\",\n" + + " \"sub\": \"Z5O3upPC88QrAjx00dis\",\n" + + " \"aud\": \"https://protected.example.net/resource\",\n" + + " \"iss\": \"https://server.example.com/\",\n" + + " \"exp\": 1419356238,\n" + + " \"iat\": 1419350238,\n" + + " \"extension_field\": \"twenty-seven\"\n" + + " }"; + // @formatter:on + + // @formatter:off + private static final String MALFORMED_ISSUER_RESPONSE = "{\n" + + " \"active\" : \"true\",\n" + + " \"iss\" : \"badissuer\"\n" + + " }"; + // @formatter:on + + // @formatter:off + private static final String MALFORMED_SCOPE_RESPONSE = "{\n" + + " \"active\": true,\n" + + " \"client_id\": \"l238j323ds-23ij4\",\n" + + " \"username\": \"jdoe\",\n" + + " \"scope\": [ \"read\", \"write\", \"dolphin\" ],\n" + + " \"sub\": \"Z5O3upPC88QrAjx00dis\",\n" + + " \"aud\": \"https://protected.example.net/resource\",\n" + + " \"iss\": \"https://server.example.com/\",\n" + + " \"exp\": 1419356238,\n" + + " \"iat\": 1419350238,\n" + + " \"extension_field\": \"twenty-seven\"\n" + + " }"; + // @formatter:on + + private static final ResponseEntity> ACTIVE = response(ACTIVE_RESPONSE); + + private static final ResponseEntity> INACTIVE = response(INACTIVE_RESPONSE); + + private static final ResponseEntity> INVALID = response(INVALID_RESPONSE); + + private static final ResponseEntity> MALFORMED_ISSUER = response(MALFORMED_ISSUER_RESPONSE); + + private static final ResponseEntity> MALFORMED_SCOPE = response(MALFORMED_SCOPE_RESPONSE); + + @Test + public void introspectWhenActiveTokenThenOk() throws Exception { + try (MockWebServer server = new MockWebServer()) { + server.setDispatcher(requiresAuth(CLIENT_ID, CLIENT_SECRET, ACTIVE_RESPONSE)); + String introspectUri = server.url("/introspect").toString(); + OpaqueTokenIntrospector introspectionClient = new SpringOpaqueTokenIntrospector(introspectUri, CLIENT_ID, + CLIENT_SECRET); + OAuth2AuthenticatedPrincipal authority = introspectionClient.introspect("token"); + // @formatter:off + assertThat(authority.getAttributes()) + .isNotNull() + .containsEntry(OAuth2IntrospectionClaimNames.ACTIVE, true) + .containsEntry(OAuth2IntrospectionClaimNames.AUDIENCE, + Arrays.asList("https://protected.example.net/resource")) + .containsEntry(OAuth2IntrospectionClaimNames.CLIENT_ID, "l238j323ds-23ij4") + .containsEntry(OAuth2IntrospectionClaimNames.EXPIRES_AT, Instant.ofEpochSecond(1419356238)) + .containsEntry(OAuth2IntrospectionClaimNames.ISSUER, new URL("https://server.example.com/")) + .containsEntry(OAuth2IntrospectionClaimNames.SCOPE, Arrays.asList("read", "write", "dolphin")) + .containsEntry(OAuth2IntrospectionClaimNames.SUBJECT, "Z5O3upPC88QrAjx00dis") + .containsEntry(OAuth2IntrospectionClaimNames.USERNAME, "jdoe") + .containsEntry("extension_field", "twenty-seven"); + // @formatter:on + } + } + + @Test + public void introspectWhenBadClientCredentialsThenError() throws IOException { + try (MockWebServer server = new MockWebServer()) { + server.setDispatcher(requiresAuth(CLIENT_ID, CLIENT_SECRET, ACTIVE_RESPONSE)); + String introspectUri = server.url("/introspect").toString(); + OpaqueTokenIntrospector introspectionClient = new SpringOpaqueTokenIntrospector(introspectUri, CLIENT_ID, + "wrong"); + assertThatExceptionOfType(OAuth2IntrospectionException.class) + .isThrownBy(() -> introspectionClient.introspect("token")); + } + } + + @Test + public void introspectWhenInactiveTokenThenInvalidToken() { + RestOperations restOperations = mock(RestOperations.class); + OpaqueTokenIntrospector introspectionClient = new SpringOpaqueTokenIntrospector(INTROSPECTION_URL, + restOperations); + given(restOperations.exchange(any(RequestEntity.class), eq(STRING_OBJECT_MAP))).willReturn(INACTIVE); + // @formatter:off + assertThatExceptionOfType(OAuth2IntrospectionException.class) + .isThrownBy(() -> introspectionClient.introspect("token")) + .withMessage("Provided token isn't active"); + // @formatter:on + } + + @Test + public void introspectWhenActiveTokenThenParsesValuesInResponse() { + Map introspectedValues = new HashMap<>(); + introspectedValues.put(OAuth2IntrospectionClaimNames.ACTIVE, true); + introspectedValues.put(OAuth2IntrospectionClaimNames.AUDIENCE, Arrays.asList("aud")); + introspectedValues.put(OAuth2IntrospectionClaimNames.NOT_BEFORE, 29348723984L); + RestOperations restOperations = mock(RestOperations.class); + OpaqueTokenIntrospector introspectionClient = new SpringOpaqueTokenIntrospector(INTROSPECTION_URL, + restOperations); + given(restOperations.exchange(any(RequestEntity.class), eq(STRING_OBJECT_MAP))) + .willReturn(response(introspectedValues)); + OAuth2AuthenticatedPrincipal authority = introspectionClient.introspect("token"); + // @formatter:off + assertThat(authority.getAttributes()) + .isNotNull() + .containsEntry(OAuth2IntrospectionClaimNames.ACTIVE, true) + .containsEntry(OAuth2IntrospectionClaimNames.AUDIENCE, Arrays.asList("aud")) + .containsEntry(OAuth2IntrospectionClaimNames.NOT_BEFORE, Instant.ofEpochSecond(29348723984L)) + .doesNotContainKey(OAuth2IntrospectionClaimNames.CLIENT_ID) + .doesNotContainKey(OAuth2IntrospectionClaimNames.SCOPE); + // @formatter:on + } + + @Test + public void introspectWhenIntrospectionEndpointThrowsExceptionThenInvalidToken() { + RestOperations restOperations = mock(RestOperations.class); + OpaqueTokenIntrospector introspectionClient = new SpringOpaqueTokenIntrospector(INTROSPECTION_URL, + restOperations); + given(restOperations.exchange(any(RequestEntity.class), eq(STRING_OBJECT_MAP))) + .willThrow(new IllegalStateException("server was unresponsive")); + // @formatter:off + assertThatExceptionOfType(OAuth2IntrospectionException.class) + .isThrownBy(() -> introspectionClient.introspect("token")) + .withMessage("server was unresponsive"); + // @formatter:on + } + + @Test + public void introspectWhenIntrospectionEndpointReturnsMalformedResponseThenInvalidToken() { + RestOperations restOperations = mock(RestOperations.class); + OpaqueTokenIntrospector introspectionClient = new SpringOpaqueTokenIntrospector(INTROSPECTION_URL, + restOperations); + given(restOperations.exchange(any(RequestEntity.class), eq(STRING_OBJECT_MAP))).willReturn(response("{}")); + assertThatExceptionOfType(OAuth2IntrospectionException.class) + .isThrownBy(() -> introspectionClient.introspect("token")); + } + + @Test + public void introspectWhenIntrospectionTokenReturnsInvalidResponseThenInvalidToken() { + RestOperations restOperations = mock(RestOperations.class); + OpaqueTokenIntrospector introspectionClient = new SpringOpaqueTokenIntrospector(INTROSPECTION_URL, + restOperations); + given(restOperations.exchange(any(RequestEntity.class), eq(STRING_OBJECT_MAP))).willReturn(INVALID); + assertThatExceptionOfType(OAuth2IntrospectionException.class) + .isThrownBy(() -> introspectionClient.introspect("token")); + } + + @Test + public void introspectWhenIntrospectionTokenReturnsMalformedIssuerResponseThenInvalidToken() { + RestOperations restOperations = mock(RestOperations.class); + OpaqueTokenIntrospector introspectionClient = new SpringOpaqueTokenIntrospector(INTROSPECTION_URL, + restOperations); + given(restOperations.exchange(any(RequestEntity.class), eq(STRING_OBJECT_MAP))).willReturn(MALFORMED_ISSUER); + assertThatExceptionOfType(OAuth2IntrospectionException.class) + .isThrownBy(() -> introspectionClient.introspect("token")); + } + + // gh-7563 + @Test + public void introspectWhenIntrospectionTokenReturnsMalformedScopeThenEmptyAuthorities() { + RestOperations restOperations = mock(RestOperations.class); + OpaqueTokenIntrospector introspectionClient = new SpringOpaqueTokenIntrospector(INTROSPECTION_URL, + restOperations); + given(restOperations.exchange(any(RequestEntity.class), eq(STRING_OBJECT_MAP))).willReturn(MALFORMED_SCOPE); + OAuth2AuthenticatedPrincipal principal = introspectionClient.introspect("token"); + assertThat(principal.getAuthorities()).isEmpty(); + Collection scope = principal.getAttribute("scope"); + assertThat(scope).containsExactly("read", "write", "dolphin"); + } + + @Test + public void constructorWhenIntrospectionUriIsNullThenIllegalArgumentException() { + assertThatIllegalArgumentException() + .isThrownBy(() -> new SpringOpaqueTokenIntrospector(null, CLIENT_ID, CLIENT_SECRET)); + } + + @Test + public void constructorWhenClientIdIsNullThenIllegalArgumentException() { + assertThatIllegalArgumentException() + .isThrownBy(() -> new SpringOpaqueTokenIntrospector(INTROSPECTION_URL, null, CLIENT_SECRET)); + } + + @Test + public void constructorWhenClientSecretIsNullThenIllegalArgumentException() { + assertThatIllegalArgumentException() + .isThrownBy(() -> new SpringOpaqueTokenIntrospector(INTROSPECTION_URL, CLIENT_ID, null)); + } + + @Test + public void constructorWhenRestOperationsIsNullThenIllegalArgumentException() { + assertThatIllegalArgumentException() + .isThrownBy(() -> new SpringOpaqueTokenIntrospector(INTROSPECTION_URL, null)); + } + + @Test + public void setRequestEntityConverterWhenConverterIsNullThenExceptionIsThrown() { + RestOperations restOperations = mock(RestOperations.class); + SpringOpaqueTokenIntrospector introspectionClient = new SpringOpaqueTokenIntrospector(INTROSPECTION_URL, + restOperations); + assertThatExceptionOfType(IllegalArgumentException.class) + .isThrownBy(() -> introspectionClient.setRequestEntityConverter(null)); + } + + @SuppressWarnings("unchecked") + @Test + public void setRequestEntityConverterWhenNonNullConverterGivenThenConverterUsed() { + RestOperations restOperations = mock(RestOperations.class); + Converter> requestEntityConverter = mock(Converter.class); + RequestEntity requestEntity = mock(RequestEntity.class); + String tokenToIntrospect = "some token"; + given(requestEntityConverter.convert(tokenToIntrospect)).willReturn(requestEntity); + given(restOperations.exchange(requestEntity, STRING_OBJECT_MAP)).willReturn(ACTIVE); + SpringOpaqueTokenIntrospector introspectionClient = new SpringOpaqueTokenIntrospector(INTROSPECTION_URL, + restOperations); + introspectionClient.setRequestEntityConverter(requestEntityConverter); + introspectionClient.introspect(tokenToIntrospect); + verify(requestEntityConverter).convert(tokenToIntrospect); + } + + private static ResponseEntity> response(String content) { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + try { + return new ResponseEntity<>(JSONObjectUtils.parse(content), headers, HttpStatus.OK); + } + catch (Exception ex) { + throw new IllegalArgumentException(ex); + } + } + + private static ResponseEntity> response(Map content) { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + try { + return new ResponseEntity<>(content, headers, HttpStatus.OK); + } + catch (Exception ex) { + throw new IllegalArgumentException(ex); + } + } + + private static Dispatcher requiresAuth(String username, String password, String response) { + return new Dispatcher() { + @Override + public MockResponse dispatch(RecordedRequest request) { + String authorization = request.getHeader(HttpHeaders.AUTHORIZATION); + // @formatter:off + return Optional.ofNullable(authorization) + .filter((a) -> isAuthorized(authorization, username, password)) + .map((a) -> ok(response)) + .orElse(unauthorized()); + // @formatter:on + } + }; + } + + private static boolean isAuthorized(String authorization, String username, String password) { + String[] values = new String(Base64.getDecoder().decode(authorization.substring(6))).split(":"); + return username.equals(values[0]) && password.equals(values[1]); + } + + private static MockResponse ok(String response) { + // @formatter:off + return new MockResponse().setBody(response) + .setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE); + // @formatter:on + } + + private static MockResponse unauthorized() { + return new MockResponse().setResponseCode(401); + } + +} diff --git a/oauth2/oauth2-resource-server/src/test/java/org/springframework/security/oauth2/server/resource/introspection/SpringReactiveOpaqueTokenIntrospectorTests.java b/oauth2/oauth2-resource-server/src/test/java/org/springframework/security/oauth2/server/resource/introspection/SpringReactiveOpaqueTokenIntrospectorTests.java new file mode 100644 index 0000000000..7ff5b4ae5b --- /dev/null +++ b/oauth2/oauth2-resource-server/src/test/java/org/springframework/security/oauth2/server/resource/introspection/SpringReactiveOpaqueTokenIntrospectorTests.java @@ -0,0 +1,303 @@ +/* + * Copyright 2002-2021 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.server.resource.introspection; + +import java.io.IOException; +import java.net.URL; +import java.time.Instant; +import java.util.Arrays; +import java.util.Base64; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + +import com.fasterxml.jackson.databind.ObjectMapper; +import okhttp3.mockwebserver.Dispatcher; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; + +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.security.oauth2.core.OAuth2AuthenticatedPrincipal; +import org.springframework.web.reactive.function.client.ClientResponse; +import org.springframework.web.reactive.function.client.WebClient; + +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.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; + +/** + * Tests for {@link SpringReactiveOpaqueTokenIntrospector} + */ +public class SpringReactiveOpaqueTokenIntrospectorTests { + + private static final ParameterizedTypeReference> STRING_OBJECT_MAP = new ParameterizedTypeReference>() { + }; + + private static final String INTROSPECTION_URL = "https://server.example.com"; + + private static final String CLIENT_ID = "client"; + + private static final String CLIENT_SECRET = "secret"; + + // @formatter:off + private static final String ACTIVE_RESPONSE = "{\n" + + " \"active\": true,\n" + + " \"client_id\": \"l238j323ds-23ij4\",\n" + + " \"username\": \"jdoe\",\n" + + " \"scope\": \"read write dolphin\",\n" + + " \"sub\": \"Z5O3upPC88QrAjx00dis\",\n" + + " \"aud\": \"https://protected.example.net/resource\",\n" + + " \"iss\": \"https://server.example.com/\",\n" + + " \"exp\": 1419356238,\n" + + " \"iat\": 1419350238,\n" + + " \"extension_field\": \"twenty-seven\"\n" + + " }"; + // @formatter:on + + // @formatter:off + private static final String INACTIVE_RESPONSE = "{\n" + + " \"active\": false\n" + + " }"; + // @formatter:on + + // @formatter:off + private static final String INVALID_RESPONSE = "{\n" + + " \"client_id\": \"l238j323ds-23ij4\",\n" + + " \"username\": \"jdoe\",\n" + + " \"scope\": \"read write dolphin\",\n" + + " \"sub\": \"Z5O3upPC88QrAjx00dis\",\n" + + " \"aud\": \"https://protected.example.net/resource\",\n" + + " \"iss\": \"https://server.example.com/\",\n" + + " \"exp\": 1419356238,\n" + + " \"iat\": 1419350238,\n" + + " \"extension_field\": \"twenty-seven\"\n" + + " }"; + // @formatter:on + + // @formatter:off + private static final String MALFORMED_ISSUER_RESPONSE = "{\n" + + " \"active\" : \"true\",\n" + + " \"iss\" : \"badissuer\"\n" + + " }"; + // @formatter:on + + private final ObjectMapper mapper = new ObjectMapper(); + + @Test + public void authenticateWhenActiveTokenThenOk() throws Exception { + try (MockWebServer server = new MockWebServer()) { + server.setDispatcher(requiresAuth(CLIENT_ID, CLIENT_SECRET, ACTIVE_RESPONSE)); + String introspectUri = server.url("/introspect").toString(); + SpringReactiveOpaqueTokenIntrospector introspectionClient = new SpringReactiveOpaqueTokenIntrospector( + introspectUri, CLIENT_ID, CLIENT_SECRET); + OAuth2AuthenticatedPrincipal authority = introspectionClient.introspect("token").block(); + // @formatter:off + assertThat(authority.getAttributes()) + .isNotNull() + .containsEntry(OAuth2IntrospectionClaimNames.ACTIVE, true) + .containsEntry(OAuth2IntrospectionClaimNames.AUDIENCE, + Arrays.asList("https://protected.example.net/resource")) + .containsEntry(OAuth2IntrospectionClaimNames.CLIENT_ID, "l238j323ds-23ij4") + .containsEntry(OAuth2IntrospectionClaimNames.EXPIRES_AT, Instant.ofEpochSecond(1419356238)) + .containsEntry(OAuth2IntrospectionClaimNames.ISSUER, new URL("https://server.example.com/")) + .containsEntry(OAuth2IntrospectionClaimNames.SCOPE, Arrays.asList("read", "write", "dolphin")) + .containsEntry(OAuth2IntrospectionClaimNames.SUBJECT, "Z5O3upPC88QrAjx00dis") + .containsEntry(OAuth2IntrospectionClaimNames.USERNAME, "jdoe") + .containsEntry("extension_field", "twenty-seven"); + // @formatter:on + } + } + + @Test + public void authenticateWhenBadClientCredentialsThenAuthenticationException() throws IOException { + try (MockWebServer server = new MockWebServer()) { + server.setDispatcher(requiresAuth(CLIENT_ID, CLIENT_SECRET, ACTIVE_RESPONSE)); + String introspectUri = server.url("/introspect").toString(); + SpringReactiveOpaqueTokenIntrospector introspectionClient = new SpringReactiveOpaqueTokenIntrospector( + introspectUri, CLIENT_ID, "wrong"); + assertThatExceptionOfType(OAuth2IntrospectionException.class) + .isThrownBy(() -> introspectionClient.introspect("token").block()); + + } + } + + @Test + public void authenticateWhenInactiveTokenThenInvalidToken() { + WebClient webClient = mockResponse(INACTIVE_RESPONSE); + SpringReactiveOpaqueTokenIntrospector introspectionClient = new SpringReactiveOpaqueTokenIntrospector( + INTROSPECTION_URL, webClient); + assertThatExceptionOfType(BadOpaqueTokenException.class) + .isThrownBy(() -> introspectionClient.introspect("token").block()) + .withMessage("Provided token isn't active"); + } + + @Test + public void authenticateWhenActiveTokenThenParsesValuesInResponse() { + Map introspectedValues = new HashMap<>(); + introspectedValues.put(OAuth2IntrospectionClaimNames.ACTIVE, true); + introspectedValues.put(OAuth2IntrospectionClaimNames.AUDIENCE, Arrays.asList("aud")); + introspectedValues.put(OAuth2IntrospectionClaimNames.NOT_BEFORE, 29348723984L); + WebClient webClient = mockResponse(introspectedValues); + SpringReactiveOpaqueTokenIntrospector introspectionClient = new SpringReactiveOpaqueTokenIntrospector( + INTROSPECTION_URL, webClient); + OAuth2AuthenticatedPrincipal authority = introspectionClient.introspect("token").block(); + // @formatter:off + assertThat(authority.getAttributes()) + .isNotNull() + .containsEntry(OAuth2IntrospectionClaimNames.ACTIVE, true) + .containsEntry(OAuth2IntrospectionClaimNames.AUDIENCE, Arrays.asList("aud")) + .containsEntry(OAuth2IntrospectionClaimNames.NOT_BEFORE, Instant.ofEpochSecond(29348723984L)) + .doesNotContainKey(OAuth2IntrospectionClaimNames.CLIENT_ID) + .doesNotContainKey(OAuth2IntrospectionClaimNames.SCOPE); + // @formatter:on + } + + @Test + public void authenticateWhenIntrospectionEndpointThrowsExceptionThenInvalidToken() { + WebClient webClient = mockResponse(new IllegalStateException("server was unresponsive")); + SpringReactiveOpaqueTokenIntrospector introspectionClient = new SpringReactiveOpaqueTokenIntrospector( + INTROSPECTION_URL, webClient); + // @formatter:off + assertThatExceptionOfType(OAuth2IntrospectionException.class) + .isThrownBy(() -> introspectionClient.introspect("token").block()) + .withMessage("server was unresponsive"); + // @formatter:on + } + + @Test + public void authenticateWhenIntrospectionTokenReturnsInvalidResponseThenInvalidToken() { + WebClient webClient = mockResponse(INVALID_RESPONSE); + SpringReactiveOpaqueTokenIntrospector introspectionClient = new SpringReactiveOpaqueTokenIntrospector( + INTROSPECTION_URL, webClient); + // @formatter:off + assertThatExceptionOfType(OAuth2IntrospectionException.class) + .isThrownBy(() -> introspectionClient.introspect("token").block()); + // @formatter:on + } + + @Test + public void authenticateWhenIntrospectionTokenReturnsMalformedIssuerResponseThenInvalidToken() { + WebClient webClient = mockResponse(MALFORMED_ISSUER_RESPONSE); + SpringReactiveOpaqueTokenIntrospector introspectionClient = new SpringReactiveOpaqueTokenIntrospector( + INTROSPECTION_URL, webClient); + assertThatExceptionOfType(OAuth2IntrospectionException.class) + .isThrownBy(() -> introspectionClient.introspect("token").block()); + } + + @Test + public void constructorWhenIntrospectionUriIsEmptyThenIllegalArgumentException() { + assertThatIllegalArgumentException() + .isThrownBy(() -> new SpringReactiveOpaqueTokenIntrospector("", CLIENT_ID, CLIENT_SECRET)); + } + + @Test + public void constructorWhenClientIdIsEmptyThenIllegalArgumentException() { + assertThatIllegalArgumentException() + .isThrownBy(() -> new SpringReactiveOpaqueTokenIntrospector(INTROSPECTION_URL, "", CLIENT_SECRET)); + } + + @Test + public void constructorWhenClientSecretIsNullThenIllegalArgumentException() { + assertThatIllegalArgumentException() + .isThrownBy(() -> new SpringReactiveOpaqueTokenIntrospector(INTROSPECTION_URL, CLIENT_ID, null)); + } + + @Test + public void constructorWhenRestOperationsIsNullThenIllegalArgumentException() { + assertThatIllegalArgumentException() + .isThrownBy(() -> new SpringReactiveOpaqueTokenIntrospector(INTROSPECTION_URL, null)); + } + + private WebClient mockResponse(String response) { + return mockResponse(toMap(response)); + } + + private WebClient mockResponse(Map response) { + WebClient real = WebClient.builder().build(); + WebClient.RequestBodyUriSpec spec = spy(real.post()); + WebClient webClient = spy(WebClient.class); + given(webClient.post()).willReturn(spec); + ClientResponse clientResponse = mock(ClientResponse.class); + given(clientResponse.rawStatusCode()).willReturn(200); + given(clientResponse.statusCode()).willReturn(HttpStatus.OK); + given(clientResponse.bodyToMono(STRING_OBJECT_MAP)).willReturn(Mono.just(response)); + ClientResponse.Headers headers = mock(ClientResponse.Headers.class); + given(headers.contentType()).willReturn(Optional.of(MediaType.APPLICATION_JSON)); + given(clientResponse.headers()).willReturn(headers); + given(spec.exchange()).willReturn(Mono.just(clientResponse)); + return webClient; + } + + private Map toMap(String string) { + try { + return this.mapper.readValue(string, Map.class); + } + catch (Exception ex) { + throw new IllegalArgumentException(ex); + } + } + + private WebClient mockResponse(Throwable ex) { + WebClient real = WebClient.builder().build(); + WebClient.RequestBodyUriSpec spec = spy(real.post()); + WebClient webClient = spy(WebClient.class); + given(webClient.post()).willReturn(spec); + given(spec.exchange()).willThrow(ex); + return webClient; + } + + private static Dispatcher requiresAuth(String username, String password, String response) { + return new Dispatcher() { + @Override + public MockResponse dispatch(RecordedRequest request) { + String authorization = request.getHeader(HttpHeaders.AUTHORIZATION); + // @formatter:off + return Optional.ofNullable(authorization) + .filter((a) -> isAuthorized(authorization, username, password)) + .map((a) -> ok(response)) + .orElse(unauthorized()); + // @formatter:on + } + }; + } + + private static boolean isAuthorized(String authorization, String username, String password) { + String[] values = new String(Base64.getDecoder().decode(authorization.substring(6))).split(":"); + return username.equals(values[0]) && password.equals(values[1]); + } + + private static MockResponse ok(String response) { + // @formatter:off + return new MockResponse().setBody(response) + .setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE); + // @formatter:on + } + + private static MockResponse unauthorized() { + return new MockResponse().setResponseCode(401); + } + +}