diff --git a/docs/modules/ROOT/pages/features/integrations/rest/http-interface.adoc b/docs/modules/ROOT/pages/features/integrations/rest/http-interface.adoc index db69fd591d6..3ee8ef1520a 100644 --- a/docs/modules/ROOT/pages/features/integrations/rest/http-interface.adoc +++ b/docs/modules/ROOT/pages/features/integrations/rest/http-interface.adoc @@ -5,7 +5,7 @@ Spring Security's OAuth Support can integrate with `RestClient` and `WebClient` [[configuration]] == Configuration -After xref:features/integrations/rest/http-interface.adoc#configuration-restclient[RestClient] or xref:features/integrations/rest/http-interface.adoc#configuration-webclient[WebClient] specific configuration, usage of xref:features/integrations/rest/http-interface.adoc[] only requires adding a xref:features/integrations/rest/http-interface.adoc#client-registration-id[`@ClientRegistrationId`] to methods that require OAuth. +After xref:features/integrations/rest/http-interface.adoc#configuration-restclient[RestClient] or xref:features/integrations/rest/http-interface.adoc#configuration-webclient[WebClient] specific configuration, usage of xref:features/integrations/rest/http-interface.adoc[] only requires adding a xref:features/integrations/rest/http-interface.adoc#client-registration-id[`@ClientRegistrationId`] to methods that require OAuth or their declaring HTTP interface. Since the presence of xref:features/integrations/rest/http-interface.adoc#client-registration-id[`@ClientRegistrationId`] determines if and how the OAuth token will be resolved, it is safe to add Spring Security's OAuth support any configuration. @@ -51,6 +51,13 @@ include-code::./UserService[tag=getAuthenticatedUser] The xref:features/integrations/rest/http-interface.adoc#client-registration-id[`@ClientRegistrationId`] will be processed by xref:features/integrations/rest/http-interface.adoc#client-registration-id-processor[`ClientRegistrationIdProcessor`] +[[type]] +=== Type Level Declarations + +`@ClientRegistrationId` can also be added at the type level to avoid repeating the declaration on every method. + +include-code::./UserService[tag=type] + [[client-registration-id-processor]] == `ClientRegistrationIdProcessor` diff --git a/docs/modules/ROOT/pages/whats-new.adoc b/docs/modules/ROOT/pages/whats-new.adoc index be7c6daddf7..2955ebb821f 100644 --- a/docs/modules/ROOT/pages/whats-new.adoc +++ b/docs/modules/ROOT/pages/whats-new.adoc @@ -49,6 +49,7 @@ http.csrf((csrf) -> csrf.spa()); * Added OAuth2 Support for xref:features/integrations/rest/http-interface.adoc[HTTP Interface Integration] * Added support for custom `JwkSource` in `NimbusJwtDecoder`, allowing usage of Nimbus's `JwkSourceBuilder` API * Added builder for `NimbusJwtEncoder`, supports specifying an EC or RSA key pair or a secret key +* Added support for `@ClientRegistrationId` at the xref:features/integrations/rest/http-interface.adoc#type[type level], eliminating the need for method level repetition == SAML 2.0 diff --git a/docs/src/test/java/org/springframework/security/docs/features/integrations/rest/type/Hovercard.java b/docs/src/test/java/org/springframework/security/docs/features/integrations/rest/type/Hovercard.java new file mode 100644 index 00000000000..32dae93cedb --- /dev/null +++ b/docs/src/test/java/org/springframework/security/docs/features/integrations/rest/type/Hovercard.java @@ -0,0 +1,25 @@ +/* + * 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.docs.features.integrations.rest.type; + +/** + * Used to ensure {@link UserService} compiles, but not show in the documentation. + * + * @author Rob Winch + */ +public record Hovercard() { +} diff --git a/docs/src/test/java/org/springframework/security/docs/features/integrations/rest/type/UserService.java b/docs/src/test/java/org/springframework/security/docs/features/integrations/rest/type/UserService.java new file mode 100644 index 00000000000..7b0e527e1db --- /dev/null +++ b/docs/src/test/java/org/springframework/security/docs/features/integrations/rest/type/UserService.java @@ -0,0 +1,41 @@ +/* + * 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 clients 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.docs.features.integrations.rest.type; + +import org.springframework.security.docs.features.integrations.rest.clientregistrationid.User; +import org.springframework.security.oauth2.client.annotation.ClientRegistrationId; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.service.annotation.GetExchange; +import org.springframework.web.service.annotation.HttpExchange; + +/** + * Demonstrates a service for {@link ClientRegistrationId} at the type level. + * @author Rob Winch + */ +// tag::type[] +@HttpExchange +@ClientRegistrationId("github") +public interface UserService { + + @GetExchange("/user") + User getAuthenticatedUser(); + + @GetExchange("/users/{username}/hovercard") + Hovercard getHovercard(@PathVariable String username); + +} +// end::type[] diff --git a/docs/src/test/kotlin/org/springframework/security/kt/docs/features/integrations/rest/type/Hovercard.kt b/docs/src/test/kotlin/org/springframework/security/kt/docs/features/integrations/rest/type/Hovercard.kt new file mode 100644 index 00000000000..e4af07ffd2b --- /dev/null +++ b/docs/src/test/kotlin/org/springframework/security/kt/docs/features/integrations/rest/type/Hovercard.kt @@ -0,0 +1,24 @@ +/* + * 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.kt.docs.features.integrations.rest.type + +/** + * Used to ensure [UserService] compiles, but not show in the documentation. + * + * @author Rob Winch + */ +class Hovercard diff --git a/docs/src/test/kotlin/org/springframework/security/kt/docs/features/integrations/rest/type/UserService.kt b/docs/src/test/kotlin/org/springframework/security/kt/docs/features/integrations/rest/type/UserService.kt new file mode 100644 index 00000000000..531a8a50e72 --- /dev/null +++ b/docs/src/test/kotlin/org/springframework/security/kt/docs/features/integrations/rest/type/UserService.kt @@ -0,0 +1,39 @@ +/* + * 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 clients 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.kt.docs.features.integrations.rest.type + +import org.springframework.security.kt.docs.features.integrations.rest.clientregistrationid.User +import org.springframework.security.oauth2.client.annotation.ClientRegistrationId +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.service.annotation.GetExchange +import org.springframework.web.service.annotation.HttpExchange + +/** + * Demonstrates a service for [ClientRegistrationId] at the type level. + * @author Rob Winch + */ +// tag::type[] +@HttpExchange +@ClientRegistrationId("github") +interface UserService { + @GetExchange("/user") + fun getAuthenticatedUser(): User + + @GetExchange("/users/{username}/hovercard") + fun getHovercard(@PathVariable username: String): Hovercard +} +// end::type[] diff --git a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/annotation/ClientRegistrationId.java b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/annotation/ClientRegistrationId.java index c81dd92d3ff..d3ecf5045eb 100644 --- a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/annotation/ClientRegistrationId.java +++ b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/annotation/ClientRegistrationId.java @@ -33,7 +33,7 @@ * @since 7.0 * @see org.springframework.security.oauth2.client.web.client.ClientRegistrationIdProcessor */ -@Target({ ElementType.METHOD, ElementType.ANNOTATION_TYPE }) +@Target({ ElementType.METHOD, ElementType.TYPE }) @Retention(RetentionPolicy.RUNTIME) @Documented public @interface ClientRegistrationId { diff --git a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/web/client/ClientRegistrationIdProcessor.java b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/web/client/ClientRegistrationIdProcessor.java index 900a082b778..ba6e6786b95 100644 --- a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/web/client/ClientRegistrationIdProcessor.java +++ b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/web/client/ClientRegistrationIdProcessor.java @@ -21,7 +21,8 @@ import org.jspecify.annotations.Nullable; import org.springframework.core.MethodParameter; -import org.springframework.core.annotation.AnnotationUtils; +import org.springframework.security.core.annotation.SecurityAnnotationScanner; +import org.springframework.security.core.annotation.SecurityAnnotationScanners; import org.springframework.security.oauth2.client.annotation.ClientRegistrationId; import org.springframework.security.oauth2.client.web.ClientAttributes; import org.springframework.web.service.invoker.HttpRequestValues; @@ -37,10 +38,14 @@ public final class ClientRegistrationIdProcessor implements HttpRequestValues.Pr public static ClientRegistrationIdProcessor DEFAULT_INSTANCE = new ClientRegistrationIdProcessor(); + private SecurityAnnotationScanner securityAnnotationScanner = SecurityAnnotationScanners + .requireUnique(ClientRegistrationId.class); + @Override public void process(Method method, MethodParameter[] parameters, @Nullable Object[] arguments, HttpRequestValues.Builder builder) { - ClientRegistrationId registeredId = AnnotationUtils.findAnnotation(method, ClientRegistrationId.class); + ClientRegistrationId registeredId = this.securityAnnotationScanner.scan(method, method.getDeclaringClass()); + if (registeredId != null) { String registrationId = registeredId.registrationId(); builder.configureAttributes(ClientAttributes.clientRegistrationId(registrationId)); diff --git a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/web/client/ClientRegistrationIdProcessorTests.java b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/web/client/ClientRegistrationIdProcessorTests.java index 78f28e223ad..7f5f12e17e5 100644 --- a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/web/client/ClientRegistrationIdProcessorTests.java +++ b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/web/client/ClientRegistrationIdProcessorTests.java @@ -22,12 +22,14 @@ import org.junit.jupiter.api.Test; +import org.springframework.core.annotation.AnnotationConfigurationException; import org.springframework.security.oauth2.client.annotation.ClientRegistrationId; import org.springframework.security.oauth2.client.web.ClientAttributes; import org.springframework.util.ReflectionUtils; import org.springframework.web.service.invoker.HttpRequestValues; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; /** * Unit tests for {@link ClientRegistrationIdProcessor}. @@ -39,6 +41,8 @@ */ class ClientRegistrationIdProcessorTests { + private static final String REGISTRATION_ID = "registrationId"; + ClientRegistrationIdProcessor processor = ClientRegistrationIdProcessor.DEFAULT_INSTANCE; @Test @@ -48,7 +52,7 @@ void processWhenClientRegistrationIdPresentThenSet() { this.processor.process(hasClientRegistrationId, null, null, builder); String registrationId = ClientAttributes.resolveClientRegistrationId(builder.build().getAttributes()); - assertThat(registrationId).isEqualTo(RestService.REGISTRATION_ID); + assertThat(registrationId).isEqualTo(REGISTRATION_ID); } @Test @@ -58,7 +62,7 @@ void processWhenMetaClientRegistrationIdPresentThenSet() { this.processor.process(hasClientRegistrationId, null, null, builder); String registrationId = ClientAttributes.resolveClientRegistrationId(builder.build().getAttributes()); - assertThat(registrationId).isEqualTo(RestService.REGISTRATION_ID); + assertThat(registrationId).isEqualTo(REGISTRATION_ID); } @Test @@ -71,9 +75,28 @@ void processWhenNoClientRegistrationIdPresentThenNull() { assertThat(registrationId).isNull(); } - interface RestService { + @Test + void processWhenClientRegistrationIdPresentOnDeclaringClassThenSet() { + HttpRequestValues.Builder builder = HttpRequestValues.builder(); + Method declaringClassHasClientRegistrationId = ReflectionUtils.findMethod(TypeAnnotatedRestService.class, + "declaringClassHasClientRegistrationId"); + this.processor.process(declaringClassHasClientRegistrationId, null, null, builder); + + String registrationId = ClientAttributes.resolveClientRegistrationId(builder.build().getAttributes()); + assertThat(registrationId).isEqualTo(REGISTRATION_ID); + } - String REGISTRATION_ID = "registrationId"; + @Test + void processWhenDuplicateClientRegistrationIdPresentOnAggregateServiceThenException() { + HttpRequestValues.Builder builder = HttpRequestValues.builder(); + Method shouldFailDueToDuplicateClientRegistrationId = ReflectionUtils.findMethod(AggregateRestService.class, + "shouldFailDueToDuplicateClientRegistrationId"); + + assertThatExceptionOfType(AnnotationConfigurationException.class).isThrownBy( + () -> this.processor.process(shouldFailDueToDuplicateClientRegistrationId, null, null, builder)); + } + + interface RestService { @ClientRegistrationId(REGISTRATION_ID) void hasClientRegistrationId(); @@ -86,9 +109,32 @@ interface RestService { } @Retention(RetentionPolicy.RUNTIME) - @ClientRegistrationId(RestService.REGISTRATION_ID) + @ClientRegistrationId(REGISTRATION_ID) @interface MetaClientRegistrationId { } + @ClientRegistrationId(REGISTRATION_ID) + interface TypeAnnotatedRestService { + + void declaringClassHasClientRegistrationId(); + + } + + @ClientRegistrationId("a") + interface ARestService { + + } + + @ClientRegistrationId("b") + interface BRestService { + + } + + interface AggregateRestService extends ARestService, BRestService { + + void shouldFailDueToDuplicateClientRegistrationId(); + + } + }