From aced3bcf1668df5833de8984e5a321b67c8f2aad Mon Sep 17 00:00:00 2001 From: Tran Ngoc Nhan Date: Fri, 20 Dec 2024 00:54:14 +0700 Subject: [PATCH] Encode Introspection clientId and clientSecret Closes gh-15988 Signed-off-by: Tran Ngoc Nhan --- .../SpringOpaqueTokenIntrospector.java | 79 +++++++++++++++++- ...SpringReactiveOpaqueTokenIntrospector.java | 80 ++++++++++++++++++- .../SpringOpaqueTokenIntrospectorTests.java | 46 ++++++++++- ...gReactiveOpaqueTokenIntrospectorTests.java | 48 ++++++++++- 4 files changed, 245 insertions(+), 8 deletions(-) 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 4674ab7886..ef9b4f3309 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 @@ -1,5 +1,5 @@ /* - * Copyright 2002-2024 the original author or authors. + * Copyright 2002-2025 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. @@ -18,6 +18,8 @@ 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; @@ -77,9 +79,11 @@ public class SpringOpaqueTokenIntrospector implements OpaqueTokenIntrospector { /** * 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 + * @param clientId The URL-encoded client id authorized to introspect + * @param clientSecret The URL-encoded client secret authorized to introspect + * @deprecated Please use {@link SpringOpaqueTokenIntrospector.Builder} */ + @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"); @@ -269,6 +273,18 @@ public class SpringOpaqueTokenIntrospector implements OpaqueTokenIntrospector { return authorities; } + /** + * Creates a {@code SpringOpaqueTokenIntrospector.Builder} with the given + * introspection endpoint uri + * @param introspectionUri The introspection endpoint uri + * @return the {@link SpringOpaqueTokenIntrospector.Builder} + * @since 6.5 + */ + public static Builder withIntrospectionUri(String introspectionUri) { + Assert.notNull(introspectionUri, "introspectionUri cannot be null"); + return new Builder(introspectionUri); + } + // gh-7563 private static final class ArrayListFromString extends ArrayList { @@ -295,4 +311,61 @@ public class SpringOpaqueTokenIntrospector implements OpaqueTokenIntrospector { } + /** + * Used to build {@link SpringOpaqueTokenIntrospector}. + * + * @author Ngoc Nhan + * @since 6.5 + */ + 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 SpringOpaqueTokenIntrospector.Builder} + * @since 6.5 + */ + public 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 SpringOpaqueTokenIntrospector.Builder} + * @since 6.5 + */ + public Builder clientSecret(String clientSecret) { + Assert.notNull(clientSecret, "clientSecret cannot be null"); + this.clientSecret = URLEncoder.encode(clientSecret, StandardCharsets.UTF_8); + return this; + } + + /** + * Creates a {@code SpringOpaqueTokenIntrospector} + * @return the {@link SpringOpaqueTokenIntrospector} + * @since 6.5 + */ + public SpringOpaqueTokenIntrospector build() { + RestTemplate restTemplate = new RestTemplate(); + restTemplate.getInterceptors().add(new BasicAuthenticationInterceptor(this.clientId, this.clientSecret)); + return new SpringOpaqueTokenIntrospector(this.introspectionUri, restTemplate); + } + + } + } 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 7c6bf8ecb0..283317f95e 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 @@ -1,5 +1,5 @@ /* - * Copyright 2002-2024 the original author or authors. + * Copyright 2002-2025 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. @@ -18,6 +18,8 @@ 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; @@ -72,9 +74,11 @@ public class SpringReactiveOpaqueTokenIntrospector implements ReactiveOpaqueToke * 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 + * @param clientId The URL-encoded client id authorized to introspect + * @param clientSecret The URL-encoded client secret authorized to introspect + * @deprecated Please use {@link SpringReactiveOpaqueTokenIntrospector.Builder} */ + @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"); @@ -223,6 +227,18 @@ public class SpringReactiveOpaqueTokenIntrospector implements ReactiveOpaqueToke return authorities; } + /** + * Creates a {@code SpringReactiveOpaqueTokenIntrospector.Builder} with the given + * introspection endpoint uri + * @param introspectionUri The introspection endpoint uri + * @return the {@link SpringReactiveOpaqueTokenIntrospector.Builder} + * @since 6.5 + */ + public static Builder withIntrospectionUri(String introspectionUri) { + + return new Builder(introspectionUri); + } + // gh-7563 private static final class ArrayListFromString extends ArrayList { @@ -249,4 +265,62 @@ public class SpringReactiveOpaqueTokenIntrospector implements ReactiveOpaqueToke } + /** + * Used to build {@link SpringReactiveOpaqueTokenIntrospector}. + * + * @author Ngoc Nhan + * @since 6.5 + */ + 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 SpringReactiveOpaqueTokenIntrospector.Builder} + * @since 6.5 + */ + public 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 SpringReactiveOpaqueTokenIntrospector.Builder} + * @since 6.5 + */ + public Builder clientSecret(String clientSecret) { + Assert.notNull(clientSecret, "clientSecret cannot be null"); + this.clientSecret = URLEncoder.encode(clientSecret, StandardCharsets.UTF_8); + return this; + } + + /** + * Creates a {@code SpringReactiveOpaqueTokenIntrospector} + * @return the {@link SpringReactiveOpaqueTokenIntrospector} + * @since 6.5 + */ + public SpringReactiveOpaqueTokenIntrospector build() { + WebClient webClient = WebClient.builder() + .defaultHeaders((h) -> h.setBasicAuth(this.clientId, this.clientSecret)) + .build(); + 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 01555f01fd..32afbf6798 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 @@ -1,5 +1,5 @@ /* - * Copyright 2002-2024 the original author or authors. + * Copyright 2002-2025 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. @@ -339,6 +339,50 @@ public class SpringOpaqueTokenIntrospectorTests { 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%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); 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 ae0f01afd7..8fe1298360 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 @@ -1,5 +1,5 @@ /* - * Copyright 2002-2024 the original author or authors. + * Copyright 2002-2025 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. @@ -261,6 +261,52 @@ public class SpringReactiveOpaqueTokenIntrospectorTests { .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%261", "secret%40%242", response)); + String introspectUri = server.url("/introspect").toString(); + ReactiveOpaqueTokenIntrospector introspectionClient = SpringReactiveOpaqueTokenIntrospector + .withIntrospectionUri(introspectUri) + .clientId("client&1") + .clientSecret("secret@$2") + .build(); + 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)); }