From 22be6e80d4ead5e83e8c6d443dce1d71b12af47b Mon Sep 17 00:00:00 2001 From: Tran Ngoc Nhan Date: Fri, 20 Dec 2024 00:54:14 +0700 Subject: [PATCH] Encode clientId and clientSecret Closes gh-15988 --- .../SpringOpaqueTokenIntrospector.java | 95 +++++++++++++++++ ...SpringReactiveOpaqueTokenIntrospector.java | 100 ++++++++++++++++++ .../SpringOpaqueTokenIntrospectorTests.java | 43 ++++++++ ...gReactiveOpaqueTokenIntrospectorTests.java | 45 ++++++++ 4 files changed, 283 insertions(+) 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 index 4674ab78868..bbf14e1f017 100644 --- 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 @@ -18,6 +18,9 @@ import java.io.Serial; import java.net.URI; +import java.net.URLEncoder; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; import java.time.Instant; import java.util.ArrayList; import java.util.Arrays; @@ -79,7 +82,9 @@ public class SpringOpaqueTokenIntrospector implements OpaqueTokenIntrospector { * @param introspectionUri The introspection endpoint uri * @param clientId The client id authorized to introspect * @param clientSecret The client's secret + * @deprecated */ + @Deprecated(since = "6.5", forRemoval = true) public SpringOpaqueTokenIntrospector(String introspectionUri, String clientId, String clientSecret) { Assert.notNull(introspectionUri, "introspectionUri cannot be null"); Assert.notNull(clientId, "clientId cannot be null"); @@ -295,4 +300,94 @@ default List getScopes() { } + /** + * Used to build {@link SpringOpaqueTokenIntrospector}. + * + * @author Ngoc Nhan + * @since 6.5 + */ + public static class SpringOpaqueTokenIntrospectorBuilder { + + private final String introspectionUri; + + private SpringOpaqueTokenIntrospectorBuilder(String introspectionUri) { + this.introspectionUri = introspectionUri; + } + + /** + * Creates a {@code SpringOpaqueTokenIntrospectorBuilder} with the provided + * parameters + * @param introspectionUri The introspection endpoint uri + * @return the {@link SpringOpaqueTokenIntrospectorBuilder} + * @since 6.5 + */ + public static SpringOpaqueTokenIntrospectorBuilder withIntrospectionUri(String introspectionUri) { + Assert.notNull(introspectionUri, "introspectionUri cannot be null"); + return new SpringOpaqueTokenIntrospectorBuilder(introspectionUri); + } + + /** + * Creates a {@code SpringOpaqueTokenIntrospector} with the provided parameters + * @param clientId The client id authorized that should be encode + * @param clientSecret The client secret that should be encode for the authorized + * client + * @return the {@link SpringOpaqueTokenIntrospector} + * @since 6.5 + */ + public SpringOpaqueTokenIntrospector introspectionEncodeClientCredentials(String clientId, + String clientSecret) { + return this.introspectionEncodeClientCredentials(clientId, clientSecret, StandardCharsets.UTF_8); + } + + /** + * Creates a {@code SpringOpaqueTokenIntrospector} with the provided parameters + * @param clientId The client id authorized that should be encode + * @param clientSecret The client secret that should be encode for the authorized + * client + * @param charset the charset to use + * @return the {@link SpringOpaqueTokenIntrospector} + * @since 6.5 + */ + public SpringOpaqueTokenIntrospector introspectionEncodeClientCredentials(String clientId, String clientSecret, + Charset charset) { + Assert.notNull(clientId, "clientId cannot be null"); + Assert.notNull(clientSecret, "clientSecret cannot be null"); + Assert.notNull(charset, "charset cannot be null"); + String encodeClientId = URLEncoder.encode(clientId, charset); + String encodeClientSecret = URLEncoder.encode(clientSecret, charset); + RestTemplate restTemplate = new RestTemplate(); + restTemplate.getInterceptors().add(new BasicAuthenticationInterceptor(encodeClientId, encodeClientSecret)); + return new SpringOpaqueTokenIntrospector(this.introspectionUri, restTemplate); + } + + /** + * Creates a {@code SpringOpaqueTokenIntrospector} with the provided parameters + * @param clientId The client id authorized + * @param clientSecret The client secret for the authorized client + * @return the {@link SpringOpaqueTokenIntrospector} + * @since 6.5 + */ + public SpringOpaqueTokenIntrospector introspectionClientCredentials(String clientId, String clientSecret) { + Assert.notNull(clientId, "clientId cannot be null"); + Assert.notNull(clientSecret, "clientSecret cannot be null"); + RestTemplate restTemplate = new RestTemplate(); + restTemplate.getInterceptors().add(new BasicAuthenticationInterceptor(clientId, clientSecret)); + return new SpringOpaqueTokenIntrospector(this.introspectionUri, restTemplate); + } + + /** + * Creates a {@code SpringOpaqueTokenIntrospector} with the provided parameters + * The given {@link RestOperations} should perform its own client authentication + * against the introspection endpoint. + * @param restOperations The client for performing the introspection request + * @return the {@link SpringOpaqueTokenIntrospector} + * @since 6.5 + */ + public SpringOpaqueTokenIntrospector withRestOperations(RestOperations restOperations) { + Assert.notNull(restOperations, "restOperations cannot be null"); + return new SpringOpaqueTokenIntrospector(this.introspectionUri, restOperations); + } + + } + } 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 index 7c6bf8ecb05..d133e6274c3 100644 --- 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 @@ -18,6 +18,9 @@ import java.io.Serial; import java.net.URI; +import java.net.URLEncoder; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; import java.time.Instant; import java.util.ArrayList; import java.util.Arrays; @@ -74,7 +77,9 @@ public class SpringReactiveOpaqueTokenIntrospector implements ReactiveOpaqueToke * @param introspectionUri The introspection endpoint uri * @param clientId The client id authorized to introspect * @param clientSecret The client secret for the authorized client + * @deprecated */ + @Deprecated(since = "6.5", forRemoval = true) public SpringReactiveOpaqueTokenIntrospector(String introspectionUri, String clientId, String clientSecret) { Assert.hasText(introspectionUri, "introspectionUri cannot be empty"); Assert.hasText(clientId, "clientId cannot be empty"); @@ -249,4 +254,99 @@ default List getScopes() { } + /** + * Used to build {@link SpringReactiveOpaqueTokenIntrospector}. + * + * @author Ngoc Nhan + * @since 6.5 + */ + public static class SpringReactiveOpaqueTokenIntrospectorBuilder { + + private final String introspectionUri; + + private SpringReactiveOpaqueTokenIntrospectorBuilder(String introspectionUri) { + this.introspectionUri = introspectionUri; + } + + /** + * Creates a {@code SpringReactiveOpaqueTokenIntrospectorBuilder} with the + * provided parameters + * @param introspectionUri The introspection endpoint uri + * @return the {@link SpringReactiveOpaqueTokenIntrospectorBuilder} + * @since 6.5 + */ + public static SpringReactiveOpaqueTokenIntrospectorBuilder withIntrospectionUri(String introspectionUri) { + + return new SpringReactiveOpaqueTokenIntrospectorBuilder(introspectionUri); + } + + /** + * Creates a {@code SpringReactiveOpaqueTokenIntrospector} with the provided + * parameters + * @param clientId The client id authorized that should be encode + * @param clientSecret The client secret that should be encode for the authorized + * client + * @return the {@link SpringReactiveOpaqueTokenIntrospector} + * @since 6.5 + */ + public SpringReactiveOpaqueTokenIntrospector introspectionEncodeClientCredentials(String clientId, + String clientSecret) { + return this.introspectionEncodeClientCredentials(clientId, clientSecret, StandardCharsets.UTF_8); + } + + /** + * Creates a {@code SpringReactiveOpaqueTokenIntrospector} with the provided + * parameters + * @param clientId The client id authorized that should be encode + * @param clientSecret The client secret that should be encode for the authorized + * client + * @param charset the charset to use + * @return the {@link SpringReactiveOpaqueTokenIntrospector} + * @since 6.5 + */ + public SpringReactiveOpaqueTokenIntrospector introspectionEncodeClientCredentials(String clientId, + String clientSecret, Charset charset) { + Assert.notNull(clientId, "clientId cannot be null"); + Assert.notNull(clientSecret, "clientSecret cannot be null"); + Assert.notNull(charset, "charset cannot be null"); + String encodeClientId = URLEncoder.encode(clientId, charset); + String encodeClientSecret = URLEncoder.encode(clientSecret, charset); + WebClient webClient = WebClient.builder() + .defaultHeaders((h) -> h.setBasicAuth(encodeClientId, encodeClientSecret)) + .build(); + return new SpringReactiveOpaqueTokenIntrospector(this.introspectionUri, webClient); + } + + /** + * Creates a {@code SpringReactiveOpaqueTokenIntrospector} with the provided + * parameters + * @param clientId The client id authorized + * @param clientSecret The client secret for the authorized client + * @return the {@link SpringReactiveOpaqueTokenIntrospector} + * @since 6.5 + */ + public SpringReactiveOpaqueTokenIntrospector introspectionClientCredentials(String clientId, + String clientSecret) { + Assert.notNull(clientId, "clientId cannot be null"); + Assert.notNull(clientSecret, "clientSecret cannot be null"); + WebClient webClient = WebClient.builder() + .defaultHeaders((h) -> h.setBasicAuth(clientId, clientSecret)) + .build(); + return new SpringReactiveOpaqueTokenIntrospector(this.introspectionUri, webClient); + } + + /** + * Creates a {@code SpringReactiveOpaqueTokenIntrospector} with the provided + * parameters + * @param webClient The client for performing the introspection request + * @return the {@link SpringReactiveOpaqueTokenIntrospector} + * @since 6.5 + */ + public SpringReactiveOpaqueTokenIntrospector withRestOperations(WebClient webClient) { + Assert.notNull(webClient, "webClient cannot be null"); + return new SpringReactiveOpaqueTokenIntrospector(this.introspectionUri, webClient); + } + + } + } 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 index 01555f01fd4..1b9cfd242ff 100644 --- 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 @@ -43,6 +43,7 @@ import org.springframework.security.oauth2.core.OAuth2AuthenticatedPrincipal; import org.springframework.security.oauth2.core.OAuth2TokenIntrospectionClaimAccessor; import org.springframework.security.oauth2.core.OAuth2TokenIntrospectionClaimNames; +import org.springframework.security.oauth2.server.resource.introspection.SpringOpaqueTokenIntrospector.SpringOpaqueTokenIntrospectorBuilder; import org.springframework.web.client.RestOperations; import static org.assertj.core.api.Assertions.assertThat; @@ -339,6 +340,48 @@ public void setAuthenticationConverterWhenNonNullConverterGivenThenConverterUsed verify(authenticationConverter).convert(any()); } + @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(); + OpaqueTokenIntrospector introspectionClient = new SpringOpaqueTokenIntrospector(introspectUri, "client%&1", + "secret@$2"); + 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%25%261", "secret%40%242", response)); + String introspectUri = server.url("/introspect").toString(); + OpaqueTokenIntrospector introspectionClient = SpringOpaqueTokenIntrospectorBuilder + .withIntrospectionUri(introspectUri) + .introspectionEncodeClientCredentials("client%&1", "secret@$2"); + 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); 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 index ae0f01afd7f..5d8a91e4efc 100644 --- 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 @@ -31,6 +31,7 @@ import okhttp3.mockwebserver.MockWebServer; import okhttp3.mockwebserver.RecordedRequest; import org.junit.jupiter.api.Test; +import org.springframework.security.oauth2.server.resource.introspection.SpringReactiveOpaqueTokenIntrospector.SpringReactiveOpaqueTokenIntrospectorBuilder; import reactor.core.publisher.Mono; import org.springframework.core.ParameterizedTypeReference; @@ -261,6 +262,50 @@ public void constructorWhenRestOperationsIsNullThenIllegalArgumentException() { .isThrownBy(() -> new SpringReactiveOpaqueTokenIntrospector(INTROSPECTION_URL, 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(); + ReactiveOpaqueTokenIntrospector introspectionClient = new SpringReactiveOpaqueTokenIntrospector( + introspectUri, "client%&1", "secret@$2"); + // @formatter:off + assertThatExceptionOfType(OAuth2IntrospectionException.class) + .isThrownBy(() -> introspectionClient.introspect("token").block()); + // @formatter:on + } + } + + @Test + public void introspectWithEncodeClientCredentialsThenOk() 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(); + ReactiveOpaqueTokenIntrospector introspectionClient = SpringReactiveOpaqueTokenIntrospectorBuilder + .withIntrospectionUri(introspectUri) + .introspectionEncodeClientCredentials("client%&1", "secret@$2"); + OAuth2AuthenticatedPrincipal authority = introspectionClient.introspect("token").block(); + // @formatter:off + assertThat(authority.getAttributes()) + .isNotNull() + .containsEntry(OAuth2TokenIntrospectionClaimNames.ACTIVE, true) + .containsEntry(OAuth2TokenIntrospectionClaimNames.USERNAME, "client%&1"); + // @formatter:on + } + } + private WebClient mockResponse(String response) { return mockResponse(toMap(response)); }