diff --git a/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/introspection/RestClientSpringOpaqueTokenIntrospector.java b/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/introspection/RestClientSpringOpaqueTokenIntrospector.java new file mode 100644 index 0000000000..a3fe718dc0 --- /dev/null +++ b/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/introspection/RestClientSpringOpaqueTokenIntrospector.java @@ -0,0 +1,355 @@ +/* + * Copyright 2004-present 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.Serial; +import java.net.URI; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +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.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.oauth2.core.OAuth2AuthenticatedPrincipal; +import org.springframework.security.oauth2.core.OAuth2TokenIntrospectionClaimAccessor; +import org.springframework.security.oauth2.core.OAuth2TokenIntrospectionClaimNames; +import org.springframework.util.Assert; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestClient; + +/** + * A Spring implementation of {@link OpaqueTokenIntrospector} that verifies and + * introspects a token using the configured + * OAuth 2.0 Introspection + * Endpoint, using {@link RestClient} for HTTP communication. + * + * @author Andrey Litvitski + * @since 7.1 + */ +public class RestClientSpringOpaqueTokenIntrospector implements OpaqueTokenIntrospector { + + private static final String AUTHORITY_PREFIX = "SCOPE_"; + + private static final ParameterizedTypeReference> STRING_OBJECT_MAP = new ParameterizedTypeReference<>() { + }; + + private final Log logger = LogFactory.getLog(getClass()); + + private final RestClient restClient; + + private Converter> requestEntityConverter; + + private Converter authenticationConverter = this::defaultAuthenticationConverter; + + /** + * Creates a {@code OpaqueTokenAuthenticationProvider} with the provided parameters + * The given {@link RestClient} should perform its own client authentication against + * the introspection endpoint. + * @param introspectionUri The introspection endpoint uri + * @param restClient The client for performing the introspection request + */ + public RestClientSpringOpaqueTokenIntrospector(String introspectionUri, RestClient restClient) { + Assert.notNull(introspectionUri, "introspectionUri cannot be null"); + Assert.notNull(restClient, "restClient cannot be null"); + this.requestEntityConverter = this.defaultRequestEntityConverter(URI.create(introspectionUri)); + this.restClient = restClient; + } + + 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); + OAuth2TokenIntrospectionClaimAccessor accessor = convertClaimsSet(claims); + return this.authenticationConverter.convert(accessor); + } + + /** + * 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 { + RestClient.RequestBodySpec spec = this.restClient.method(requestEntity.getMethod()) + .uri(requestEntity.getUrl()) + .headers((headers) -> headers.addAll(requestEntity.getHeaders())); + return spec.body(requestEntity.getBody()).retrieve().toEntity(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) + if (claims == null) { + return Collections.emptyMap(); + } + + boolean active = (boolean) claims.compute(OAuth2TokenIntrospectionClaimNames.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 ArrayListFromStringClaimAccessor convertClaimsSet(Map claims) { + Map converted = new LinkedHashMap<>(claims); + converted.computeIfPresent(OAuth2TokenIntrospectionClaimNames.AUD, (k, v) -> { + if (v instanceof String) { + return Collections.singletonList(v); + } + return v; + }); + converted.computeIfPresent(OAuth2TokenIntrospectionClaimNames.CLIENT_ID, (k, v) -> v.toString()); + converted.computeIfPresent(OAuth2TokenIntrospectionClaimNames.EXP, + (k, v) -> Instant.ofEpochSecond(((Number) v).longValue())); + converted.computeIfPresent(OAuth2TokenIntrospectionClaimNames.IAT, + (k, v) -> Instant.ofEpochSecond(((Number) v).longValue())); + // RFC-7662 page 7 directs users to RFC-7519 for defining the values of these + // issuer fields. + // https://datatracker.ietf.org/doc/html/rfc7662#page-7 + // + // RFC-7519 page 9 defines issuer fields as being 'case-sensitive' strings + // containing + // a 'StringOrURI', which is defined on page 5 as being any string, but strings + // containing ':' + // should be treated as valid URIs. + // https://datatracker.ietf.org/doc/html/rfc7519#section-2 + // + // It is not defined however as to whether-or-not normalized URIs should be + // treated as the same literal + // value. It only defines validation itself, so to avoid potential ambiguity or + // unwanted side effects that + // may be awkward to debug, we do not want to manipulate this value. Previous + // versions of Spring Security + // would *only* allow valid URLs, which is not what we wish to achieve here. + converted.computeIfPresent(OAuth2TokenIntrospectionClaimNames.ISS, (k, v) -> v.toString()); + converted.computeIfPresent(OAuth2TokenIntrospectionClaimNames.NBF, + (k, v) -> Instant.ofEpochSecond(((Number) v).longValue())); + converted.computeIfPresent(OAuth2TokenIntrospectionClaimNames.SCOPE, + (k, v) -> (v instanceof String s) ? new ArrayListFromString(s.split(" ")) : v); + return () -> converted; + } + + /** + *

+ * Sets the {@link Converter Converter<OAuth2TokenIntrospectionClaimAccessor, + * OAuth2AuthenticatedPrincipal>} to use. Defaults to + * {@link RestClientSpringOpaqueTokenIntrospector#defaultAuthenticationConverter}. + *

+ *

+ * Use if you need a custom mapping of OAuth 2.0 token claims to the authenticated + * principal. + *

+ * @param authenticationConverter the converter + * @since 7.1 + */ + public void setAuthenticationConverter( + Converter authenticationConverter) { + Assert.notNull(authenticationConverter, "converter cannot be null"); + this.authenticationConverter = authenticationConverter; + } + + /** + * If {@link RestClientSpringOpaqueTokenIntrospector#authenticationConverter} is not + * explicitly set, this default converter will be used. transforms an + * {@link OAuth2TokenIntrospectionClaimAccessor} into an + * {@link OAuth2AuthenticatedPrincipal} by extracting claims, mapping scopes to + * authorities, and creating a principal. + * @return {@link Converter Converter<OAuth2TokenIntrospectionClaimAccessor, + * OAuth2AuthenticatedPrincipal>} + * @since 7.1 + */ + private OAuth2IntrospectionAuthenticatedPrincipal defaultAuthenticationConverter( + OAuth2TokenIntrospectionClaimAccessor accessor) { + Collection authorities = authorities(accessor.getScopes()); + return new OAuth2IntrospectionAuthenticatedPrincipal(accessor.getClaims(), authorities); + } + + private Collection authorities(List scopes) { + if (!(scopes instanceof ArrayListFromString)) { + return Collections.emptyList(); + } + Collection authorities = new ArrayList<>(); + for (String scope : scopes) { + authorities.add(new SimpleGrantedAuthority(AUTHORITY_PREFIX + scope)); + } + return authorities; + } + + /** + * Creates a {@code RestClientSpringOpaqueTokenIntrospector.Builder} with the given + * introspection endpoint uri + * @param introspectionUri The introspection endpoint uri + * @return the {@link RestClientSpringOpaqueTokenIntrospector.Builder} + * @since 7.1 + */ + public static RestClientSpringOpaqueTokenIntrospector.Builder withIntrospectionUri(String introspectionUri) { + Assert.notNull(introspectionUri, "introspectionUri cannot be null"); + return new RestClientSpringOpaqueTokenIntrospector.Builder(introspectionUri); + } + + // gh-7563 + private static final class ArrayListFromString extends ArrayList { + + @Serial + private static final long serialVersionUID = -1804103555781637109L; + + ArrayListFromString(String... elements) { + super(Arrays.asList(elements)); + } + + } + + // gh-15165 + private interface ArrayListFromStringClaimAccessor extends OAuth2TokenIntrospectionClaimAccessor { + + @Override + default List getScopes() { + Object value = getClaims().get(OAuth2TokenIntrospectionClaimNames.SCOPE); + if (value instanceof ArrayListFromString list) { + return list; + } + return OAuth2TokenIntrospectionClaimAccessor.super.getScopes(); + } + + } + + /** + * Used to build {@link RestClientSpringOpaqueTokenIntrospector}. + * + * @author Andrey Litvitski + * @since 7.1 + */ + public static final class Builder { + + private final String introspectionUri; + + private String clientId; + + private String clientSecret; + + private Builder(String introspectionUri) { + this.introspectionUri = introspectionUri; + } + + /** + * The builder will {@link URLEncoder encode} the client id that you provide, so + * please give the unencoded value. + * @param clientId The unencoded client id + * @return the {@link RestClientSpringOpaqueTokenIntrospector.Builder} + * @since 7.1 + */ + public RestClientSpringOpaqueTokenIntrospector.Builder clientId(String clientId) { + Assert.notNull(clientId, "clientId cannot be null"); + this.clientId = URLEncoder.encode(clientId, StandardCharsets.UTF_8); + return this; + } + + /** + * The builder will {@link URLEncoder encode} the client secret that you provide, + * so please give the unencoded value. + * @param clientSecret The unencoded client secret + * @return the {@link RestClientSpringOpaqueTokenIntrospector.Builder} + * @since 7.1 + */ + public RestClientSpringOpaqueTokenIntrospector.Builder clientSecret(String clientSecret) { + Assert.notNull(clientSecret, "clientSecret cannot be null"); + this.clientSecret = URLEncoder.encode(clientSecret, StandardCharsets.UTF_8); + return this; + } + + /** + * Creates a {@code RestClientSpringOpaqueTokenIntrospector} + * @return the {@link RestClientSpringOpaqueTokenIntrospector} + * @since 7.1 + */ + public RestClientSpringOpaqueTokenIntrospector build() { + RestClient restClient = RestClient.builder() + .defaultHeaders((headers) -> headers.setBasicAuth(this.clientId, this.clientSecret)) + .build(); + return new RestClientSpringOpaqueTokenIntrospector(this.introspectionUri, restClient); + } + + } + +} diff --git a/oauth2/oauth2-resource-server/src/test/java/org/springframework/security/oauth2/server/resource/introspection/RestClientSpringOpaqueTokenIntrospectorTests.java b/oauth2/oauth2-resource-server/src/test/java/org/springframework/security/oauth2/server/resource/introspection/RestClientSpringOpaqueTokenIntrospectorTests.java new file mode 100644 index 0000000000..8b3fefdaeb --- /dev/null +++ b/oauth2/oauth2-resource-server/src/test/java/org/springframework/security/oauth2/server/resource/introspection/RestClientSpringOpaqueTokenIntrospectorTests.java @@ -0,0 +1,400 @@ +/* + * Copyright 2004-present 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.time.Instant; +import java.util.Arrays; +import java.util.Base64; +import java.util.Collection; +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.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.authority.AuthorityUtils; +import org.springframework.security.oauth2.core.OAuth2AuthenticatedPrincipal; +import org.springframework.security.oauth2.core.OAuth2TokenIntrospectionClaimNames; +import org.springframework.web.client.RestClient; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link RestClientSpringOpaqueTokenIntrospector} + * + * @author Andrey Litvitski + */ +public class RestClientSpringOpaqueTokenIntrospectorTests { + + 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_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 + + @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 = RestClientSpringOpaqueTokenIntrospector + .withIntrospectionUri(introspectUri) + .clientId(CLIENT_ID) + .clientSecret(CLIENT_SECRET) + .build(); + OAuth2AuthenticatedPrincipal authority = introspectionClient.introspect("token"); + // @formatter:off + assertThat(authority.getAttributes()) + .isNotNull() + .containsEntry(OAuth2TokenIntrospectionClaimNames.ACTIVE, true) + .containsEntry(OAuth2TokenIntrospectionClaimNames.AUD, + Arrays.asList("https://protected.example.net/resource")) + .containsEntry(OAuth2TokenIntrospectionClaimNames.CLIENT_ID, "l238j323ds-23ij4") + .containsEntry(OAuth2TokenIntrospectionClaimNames.EXP, Instant.ofEpochSecond(1419356238)) + .containsEntry(OAuth2TokenIntrospectionClaimNames.ISS, "https://server.example.com/") + .containsEntry(OAuth2TokenIntrospectionClaimNames.SCOPE, Arrays.asList("read", "write", "dolphin")) + .containsEntry(OAuth2TokenIntrospectionClaimNames.SUB, "Z5O3upPC88QrAjx00dis") + .containsEntry(OAuth2TokenIntrospectionClaimNames.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 = RestClientSpringOpaqueTokenIntrospector + .withIntrospectionUri(introspectUri) + .clientId(CLIENT_ID) + .clientSecret("wrong") + .build(); + assertThatExceptionOfType(OAuth2IntrospectionException.class) + .isThrownBy(() -> introspectionClient.introspect("token")); + } + } + + @Test + public void introspectWhenInactiveTokenThenInvalidToken() throws Exception { + try (MockWebServer server = new MockWebServer()) { + server.setDispatcher(requiresAuth(CLIENT_ID, CLIENT_SECRET, INACTIVE_RESPONSE)); + String introspectUri = server.url("/introspect").toString(); + OpaqueTokenIntrospector introspectionClient = RestClientSpringOpaqueTokenIntrospector + .withIntrospectionUri(introspectUri) + .clientId(CLIENT_ID) + .clientSecret(CLIENT_SECRET) + .build(); + + assertThatExceptionOfType(OAuth2IntrospectionException.class) + .isThrownBy(() -> introspectionClient.introspect("token")) + .withMessage("Provided token isn't active"); + } + } + + @Test + public void introspectWhenActiveTokenThenParsesValuesInResponse() throws Exception { + try (MockWebServer server = new MockWebServer()) { + String response = """ + { + "active": true, + "aud": ["aud"], + "nbf": 29348723984 + } + """; + server.setDispatcher(requiresAuth(CLIENT_ID, CLIENT_SECRET, response)); + String introspectUri = server.url("/introspect").toString(); + OpaqueTokenIntrospector introspectionClient = RestClientSpringOpaqueTokenIntrospector + .withIntrospectionUri(introspectUri) + .clientId(CLIENT_ID) + .clientSecret(CLIENT_SECRET) + .build(); + OAuth2AuthenticatedPrincipal authority = introspectionClient.introspect("token"); + assertThat(authority.getAttributes()).isNotNull() + .containsEntry(OAuth2TokenIntrospectionClaimNames.ACTIVE, true) + .containsEntry(OAuth2TokenIntrospectionClaimNames.AUD, Arrays.asList("aud")) + .containsEntry(OAuth2TokenIntrospectionClaimNames.NBF, Instant.ofEpochSecond(29348723984L)) + .doesNotContainKey(OAuth2TokenIntrospectionClaimNames.CLIENT_ID) + .doesNotContainKey(OAuth2TokenIntrospectionClaimNames.SCOPE); + } + } + + @Test + public void introspectWhenIntrospectionEndpointThrowsExceptionThenInvalidToken() throws Exception { + try (MockWebServer server = new MockWebServer()) { + server.setDispatcher(requiresAuth(CLIENT_ID, CLIENT_SECRET, ACTIVE_RESPONSE)); + server.start(); + String introspectUri = server.url("/introspect").toString(); + server.shutdown(); + OpaqueTokenIntrospector introspectionClient = RestClientSpringOpaqueTokenIntrospector + .withIntrospectionUri(introspectUri) + .clientId(CLIENT_ID) + .clientSecret(CLIENT_SECRET) + .build(); + assertThatExceptionOfType(OAuth2IntrospectionException.class) + .isThrownBy(() -> introspectionClient.introspect("token")); + } + } + + @Test + public void introspectWhenIntrospectionEndpointReturnsMalformedResponseThenInvalidToken() throws Exception { + try (MockWebServer server = new MockWebServer()) { + server.setDispatcher(requiresAuth(CLIENT_ID, CLIENT_SECRET, "{}")); + String introspectUri = server.url("/introspect").toString(); + OpaqueTokenIntrospector introspectionClient = RestClientSpringOpaqueTokenIntrospector + .withIntrospectionUri(introspectUri) + .clientId(CLIENT_ID) + .clientSecret(CLIENT_SECRET) + .build(); + assertThatExceptionOfType(OAuth2IntrospectionException.class) + .isThrownBy(() -> introspectionClient.introspect("token")); + } + } + + @Test + public void introspectWhenIntrospectionTokenReturnsInvalidResponseThenInvalidToken() throws Exception { + try (MockWebServer server = new MockWebServer()) { + server.setDispatcher(requiresAuth(CLIENT_ID, CLIENT_SECRET, INVALID_RESPONSE)); + String introspectUri = server.url("/introspect").toString(); + OpaqueTokenIntrospector introspectionClient = RestClientSpringOpaqueTokenIntrospector + .withIntrospectionUri(introspectUri) + .clientId(CLIENT_ID) + .clientSecret(CLIENT_SECRET) + .build(); + assertThatExceptionOfType(OAuth2IntrospectionException.class) + .isThrownBy(() -> introspectionClient.introspect("token")); + } + } + + // gh-7563 + @Test + public void introspectWhenIntrospectionTokenReturnsMalformedScopeThenEmptyAuthorities() throws Exception { + try (MockWebServer server = new MockWebServer()) { + server.setDispatcher(requiresAuth(CLIENT_ID, CLIENT_SECRET, MALFORMED_SCOPE_RESPONSE)); + String introspectUri = server.url("/introspect").toString(); + OpaqueTokenIntrospector introspectionClient = RestClientSpringOpaqueTokenIntrospector + .withIntrospectionUri(introspectUri) + .clientId(CLIENT_ID) + .clientSecret(CLIENT_SECRET) + .build(); + OAuth2AuthenticatedPrincipal principal = introspectionClient.introspect("token"); + assertThat(principal.getAuthorities()).isEmpty(); + Collection scope = principal.getAttribute("scope"); + assertThat(scope).containsExactly("read", "write", "dolphin"); + } + } + + // gh-15165 + @Test + public void introspectWhenActiveThenMapsAuthorities() throws Exception { + try (MockWebServer server = new MockWebServer()) { + server.setDispatcher(requiresAuth(CLIENT_ID, CLIENT_SECRET, ACTIVE_RESPONSE)); + String introspectUri = server.url("/introspect").toString(); + OpaqueTokenIntrospector introspectionClient = RestClientSpringOpaqueTokenIntrospector + .withIntrospectionUri(introspectUri) + .clientId(CLIENT_ID) + .clientSecret(CLIENT_SECRET) + .build(); + OAuth2AuthenticatedPrincipal principal = introspectionClient.introspect("token"); + assertThat(principal.getAuthorities()).isNotEmpty(); + Collection scope = principal.getAttribute("scope"); + assertThat(scope).containsExactly("read", "write", "dolphin"); + Collection authorities = AuthorityUtils.authorityListToSet(principal.getAuthorities()); + assertThat(authorities).containsExactly("SCOPE_read", "SCOPE_write", "SCOPE_dolphin"); + } + } + + @Test + public void setRequestEntityConverterWhenConverterIsNullThenExceptionIsThrown() { + RestClient restClient = mock(RestClient.class); + RestClientSpringOpaqueTokenIntrospector introspectionClient = new RestClientSpringOpaqueTokenIntrospector( + INTROSPECTION_URL, restClient); + assertThatExceptionOfType(IllegalArgumentException.class) + .isThrownBy(() -> introspectionClient.setRequestEntityConverter(null)); + } + + @Test + public void setAuthenticationConverterWhenConverterIsNullThenExceptionIsThrown() { + RestClient restClient = mock(RestClient.class); + RestClientSpringOpaqueTokenIntrospector introspectionClient = new RestClientSpringOpaqueTokenIntrospector( + INTROSPECTION_URL, restClient); + assertThatExceptionOfType(IllegalArgumentException.class) + .isThrownBy(() -> introspectionClient.setAuthenticationConverter(null)); + } + + @Test + public void introspectWithoutEncodeClientCredentialsThenExceptionIsThrown() throws Exception { + try (MockWebServer server = new MockWebServer()) { + String response = """ + { + "active": true, + "username": "client%&1" + } + """; + server.setDispatcher(requiresAuth("client%25%261", "secret%40%242", response)); + String introspectUri = server.url("/introspect").toString(); + RestClient restClient = RestClient.builder() + .defaultHeaders((h) -> h.setBasicAuth("client%&1", "secret@$2")) + .build(); + OpaqueTokenIntrospector introspectionClient = new RestClientSpringOpaqueTokenIntrospector(introspectUri, + restClient); + assertThatExceptionOfType(OAuth2IntrospectionException.class) + .isThrownBy(() -> introspectionClient.introspect("token")); + } + } + + @Test + public void introspectWithEncodeClientCredentialsThenOk() throws Exception { + try (MockWebServer server = new MockWebServer()) { + String response = """ + { + "active": true, + "username": "client&1" + } + """; + server.setDispatcher(requiresAuth("client%261", "secret%40%242", response)); + String introspectUri = server.url("/introspect").toString(); + OpaqueTokenIntrospector introspectionClient = SpringOpaqueTokenIntrospector + .withIntrospectionUri(introspectUri) + .clientId("client&1") + .clientSecret("secret@$2") + .build(); + OAuth2AuthenticatedPrincipal authority = introspectionClient.introspect("token"); + // @formatter:off + assertThat(authority.getAttributes()) + .isNotNull() + .containsEntry(OAuth2TokenIntrospectionClaimNames.ACTIVE, true) + .containsEntry(OAuth2TokenIntrospectionClaimNames.USERNAME, "client&1"); + // @formatter:on + } + } + + 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); + } + +}