diff --git a/samples/grpc-oauth2/src/test/java/org/springframework/grpc/sample/OpaqueTokenServerApplicationTests.java b/samples/grpc-oauth2/src/test/java/org/springframework/grpc/sample/OpaqueTokenServerApplicationTests.java new file mode 100644 index 00000000..a97edf7a --- /dev/null +++ b/samples/grpc-oauth2/src/test/java/org/springframework/grpc/sample/OpaqueTokenServerApplicationTests.java @@ -0,0 +1,120 @@ +package org.springframework.grpc.sample; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.junit.jupiter.api.Test; + +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.experimental.boot.server.exec.CommonsExecWebServerFactoryBean; +import org.springframework.experimental.boot.server.exec.MavenClasspathEntry; +import org.springframework.experimental.boot.test.context.EnableDynamicProperty; +import org.springframework.experimental.boot.test.context.OAuth2ClientProviderIssuerUri; +import org.springframework.grpc.client.GrpcChannelBuilderCustomizer; +import org.springframework.grpc.client.ImportGrpcClients; +import org.springframework.grpc.client.interceptor.security.BearerTokenAuthenticationInterceptor; +import org.springframework.grpc.client.interceptor.security.ClientCredentialsTokenSupplier; +import org.springframework.grpc.sample.proto.HelloReply; +import org.springframework.grpc.sample.proto.HelloRequest; +import org.springframework.grpc.sample.proto.SimpleGrpc; +import org.springframework.grpc.server.GlobalServerInterceptor; +import org.springframework.grpc.server.security.AuthenticationProcessInterceptor; +import org.springframework.grpc.server.security.GrpcSecurity; +import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; +import org.springframework.test.annotation.DirtiesContext; + +import io.grpc.Status.Code; +import io.grpc.StatusRuntimeException; + +/** + * Verifies that {@link GrpcSecurity} can authenticate opaque tokens by introspection, + * rather than the JWTs used by the main sample application. + */ +@SpringBootTest(properties = { "spring.grpc.server.port=0", + "spring.grpc.client.channel.default.target=0.0.0.0:${local.grpc.server.port}", + "spring.grpc.client.channel.stub.target=0.0.0.0:${local.grpc.server.port}", + "spring.grpc.client.channel.secure.target=0.0.0.0:${local.grpc.server.port}", + "spring.main.allow-bean-definition-overriding=true" }) +@DirtiesContext +public class OpaqueTokenServerApplicationTests { + + @Autowired + @Qualifier("simpleBlockingStub") + private SimpleGrpc.SimpleBlockingStub stub; + + @Autowired + @Qualifier("secureSimpleBlockingStub") + private SimpleGrpc.SimpleBlockingStub secure; + + @Test + void unauthenticated() { + StatusRuntimeException exception = assertThrows(StatusRuntimeException.class, + () -> this.stub.sayHello(HelloRequest.newBuilder().setName("Alien").build())); + assertEquals(Code.UNAUTHENTICATED, exception.getStatus().getCode()); + } + + @Test + void authenticatedByIntrospection() { + HelloReply response = this.secure.sayHello(HelloRequest.newBuilder().setName("Alien").build()); + assertEquals("Hello ==> Alien", response.getMessage()); + } + + @Test + void unauthorizedWhenScopeMissing() { + // The token has no scopes and scope=profile is required + StatusRuntimeException exception = assertThrows(StatusRuntimeException.class, + () -> this.secure.streamHello(HelloRequest.newBuilder().setName("Alien").build()).next()); + assertEquals(Code.PERMISSION_DENIED, exception.getStatus().getCode()); + } + + @TestConfiguration(proxyBeanMethods = false) + @EnableDynamicProperty + @ImportGrpcClients(target = "stub", types = { SimpleGrpc.SimpleBlockingStub.class }) + @ImportGrpcClients(target = "secure", prefix = "secure", types = { SimpleGrpc.SimpleBlockingStub.class }) + static class ExtraConfiguration { + + @Bean + @OAuth2ClientProviderIssuerUri + static CommonsExecWebServerFactoryBean authServer() { + return CommonsExecWebServerFactoryBean.builder() + .useGenericSpringBootMain() + .classpath(classpath -> classpath + .entries(MavenClasspathEntry.springBootStarter("oauth2-authorization-server"))); + } + + @Bean + @GlobalServerInterceptor + AuthenticationProcessInterceptor jwtSecurityFilterChain(GrpcSecurity grpc, + @Value("${spring.security.oauth2.client.provider.spring.issuer-uri}") String issuerUri) + throws Exception { + return grpc + .authorizeRequests(requests -> requests.methods("Simple/StreamHello") + .hasAuthority("SCOPE_profile") + .methods("Simple/SayHello") + .authenticated() + .methods("grpc.*/*") + .permitAll() + .allRequests() + .denyAll()) + .oauth2ResourceServer(resourceServer -> resourceServer + .opaqueToken(opaqueToken -> opaqueToken.introspectionUri(issuerUri + "/oauth2/introspect") + .introspectionClientCredentials("spring", "secret"))) + .build(); + } + + @Bean + GrpcChannelBuilderCustomizer stubs(ObjectProvider context) { + return GrpcChannelBuilderCustomizer.matching("secure", + builder -> builder.intercept(new BearerTokenAuthenticationInterceptor( + new ClientCredentialsTokenSupplier(context.getObject(), () -> "spring")))); + } + + } + +} diff --git a/spring-grpc-core/src/test/java/org/springframework/grpc/server/security/BearerTokenAuthenticationExtractorTests.java b/spring-grpc-core/src/test/java/org/springframework/grpc/server/security/BearerTokenAuthenticationExtractorTests.java new file mode 100644 index 00000000..678abc58 --- /dev/null +++ b/spring-grpc-core/src/test/java/org/springframework/grpc/server/security/BearerTokenAuthenticationExtractorTests.java @@ -0,0 +1,82 @@ +/* + * Copyright 2024-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.grpc.server.security; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +import org.springframework.grpc.internal.GrpcHeaders; +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.server.resource.authentication.BearerTokenAuthenticationToken; + +import com.google.protobuf.Empty; +import io.grpc.Attributes; +import io.grpc.Metadata; +import io.grpc.MethodDescriptor; +import io.grpc.protobuf.ProtoUtils; + +/** + * Tests for {@link BearerTokenAuthenticationExtractor}. + */ +class BearerTokenAuthenticationExtractorTests { + + private static final MethodDescriptor METHOD = MethodDescriptor.newBuilder() + .setType(MethodDescriptor.MethodType.UNARY) + .setFullMethodName("Simple/SayHello") + .setRequestMarshaller(ProtoUtils.marshaller(Empty.getDefaultInstance())) + .setResponseMarshaller(ProtoUtils.marshaller(Empty.getDefaultInstance())) + .build(); + + private final BearerTokenAuthenticationExtractor extractor = new BearerTokenAuthenticationExtractor(); + + @Test + void extractsBearerToken() { + Metadata headers = new Metadata(); + headers.put(GrpcHeaders.AUTHORIZATION_KEY, "Bearer token-value"); + + Authentication authentication = this.extractor.extract(headers, Attributes.EMPTY, METHOD); + + assertThat(authentication).isInstanceOf(BearerTokenAuthenticationToken.class); + assertThat(((BearerTokenAuthenticationToken) authentication).getToken()).isEqualTo("token-value"); + } + + @Test + void extractsBearerTokenIgnoringSchemeCase() { + Metadata headers = new Metadata(); + headers.put(GrpcHeaders.AUTHORIZATION_KEY, "bEaReR token-value"); + + Authentication authentication = this.extractor.extract(headers, Attributes.EMPTY, METHOD); + + assertThat(authentication).isNotNull(); + assertThat(((BearerTokenAuthenticationToken) authentication).getToken()).isEqualTo("token-value"); + } + + @Test + void returnsNullWhenHeaderMissing() { + assertThat(this.extractor.extract(new Metadata(), Attributes.EMPTY, METHOD)).isNull(); + } + + @Test + void returnsNullWhenSchemeIsNotBearer() { + Metadata headers = new Metadata(); + headers.put(GrpcHeaders.AUTHORIZATION_KEY, "Basic dXNlcjpwYXNz"); + + assertThat(this.extractor.extract(headers, Attributes.EMPTY, METHOD)).isNull(); + } + +} diff --git a/spring-grpc-core/src/test/java/org/springframework/grpc/server/security/HttpBasicAuthenticationExtractorTests.java b/spring-grpc-core/src/test/java/org/springframework/grpc/server/security/HttpBasicAuthenticationExtractorTests.java new file mode 100644 index 00000000..c89de44f --- /dev/null +++ b/spring-grpc-core/src/test/java/org/springframework/grpc/server/security/HttpBasicAuthenticationExtractorTests.java @@ -0,0 +1,95 @@ +/* + * Copyright 2024-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.grpc.server.security; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +import org.junit.jupiter.api.Test; + +import org.springframework.grpc.internal.GrpcHeaders; +import org.springframework.security.core.Authentication; + +import com.google.protobuf.Empty; +import io.grpc.Attributes; +import io.grpc.Metadata; +import io.grpc.MethodDescriptor; +import io.grpc.protobuf.ProtoUtils; + +/** + * Tests for {@link HttpBasicAuthenticationExtractor}. + */ +class HttpBasicAuthenticationExtractorTests { + + private static final MethodDescriptor METHOD = MethodDescriptor.newBuilder() + .setType(MethodDescriptor.MethodType.UNARY) + .setFullMethodName("Simple/SayHello") + .setRequestMarshaller(ProtoUtils.marshaller(Empty.getDefaultInstance())) + .setResponseMarshaller(ProtoUtils.marshaller(Empty.getDefaultInstance())) + .build(); + + private final HttpBasicAuthenticationExtractor extractor = new HttpBasicAuthenticationExtractor(); + + @Test + void extractsUsernameAndPassword() { + Authentication authentication = this.extractor.extract(basicHeaders("user:secret"), Attributes.EMPTY, METHOD); + + assertThat(authentication).isNotNull(); + assertThat(authentication.getName()).isEqualTo("user"); + assertThat(authentication.getCredentials()).isEqualTo("secret"); + } + + @Test + void extractsIgnoringSchemeCase() { + Metadata headers = new Metadata(); + headers.put(GrpcHeaders.AUTHORIZATION_KEY, + "BaSiC " + Base64.getEncoder().encodeToString("user:secret".getBytes(StandardCharsets.UTF_8))); + + Authentication authentication = this.extractor.extract(headers, Attributes.EMPTY, METHOD); + + assertThat(authentication).isNotNull(); + assertThat(authentication.getName()).isEqualTo("user"); + } + + @Test + void returnsNullWhenHeaderMissing() { + assertThat(this.extractor.extract(new Metadata(), Attributes.EMPTY, METHOD)).isNull(); + } + + @Test + void returnsNullWhenSchemeIsNotBasic() { + Metadata headers = new Metadata(); + headers.put(GrpcHeaders.AUTHORIZATION_KEY, "Bearer token-value"); + + assertThat(this.extractor.extract(headers, Attributes.EMPTY, METHOD)).isNull(); + } + + @Test + void returnsNullWhenCredentialsHaveNoSeparator() { + assertThat(this.extractor.extract(basicHeaders("nocolon"), Attributes.EMPTY, METHOD)).isNull(); + } + + private static Metadata basicHeaders(String credentials) { + Metadata headers = new Metadata(); + headers.put(GrpcHeaders.AUTHORIZATION_KEY, + "Basic " + Base64.getEncoder().encodeToString(credentials.getBytes(StandardCharsets.UTF_8))); + return headers; + } + +} diff --git a/spring-grpc-core/src/test/java/org/springframework/grpc/server/security/OAuth2ResourceServerConfigurerTests.java b/spring-grpc-core/src/test/java/org/springframework/grpc/server/security/OAuth2ResourceServerConfigurerTests.java new file mode 100644 index 00000000..350d324d --- /dev/null +++ b/spring-grpc-core/src/test/java/org/springframework/grpc/server/security/OAuth2ResourceServerConfigurerTests.java @@ -0,0 +1,129 @@ +/* + * Copyright 2024-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.grpc.server.security; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +import java.util.Map; + +import org.junit.jupiter.api.Test; + +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.core.authority.AuthorityUtils; +import org.springframework.security.oauth2.core.DefaultOAuth2AuthenticatedPrincipal; +import org.springframework.security.oauth2.core.OAuth2AccessToken; +import org.springframework.security.oauth2.core.OAuth2AuthenticatedPrincipal; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.server.resource.authentication.BearerTokenAuthentication; +import org.springframework.security.oauth2.server.resource.authentication.BearerTokenAuthenticationToken; +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.OpaqueTokenIntrospector; + +/** + * Tests for {@link OAuth2ResourceServerConfigurer}. + */ +class OAuth2ResourceServerConfigurerTests { + + @Test + void jwtConfigurerCreatesJwtAuthenticationProvider() { + try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext()) { + context.refresh(); + OAuth2ResourceServerConfigurer configurer = new OAuth2ResourceServerConfigurer(context); + + configurer.jwt((jwt) -> jwt.decoder(mock(JwtDecoder.class))); + + assertThat(configurer.getAuthenticationProvider()).isInstanceOf(JwtAuthenticationProvider.class); + } + } + + @Test + void opaqueTokenConfigurerCreatesOpaqueTokenAuthenticationProvider() { + try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext()) { + context.refresh(); + OAuth2ResourceServerConfigurer configurer = new OAuth2ResourceServerConfigurer(context); + + configurer.opaqueToken((opaqueToken) -> opaqueToken.introspector(mock(OpaqueTokenIntrospector.class))); + + assertThat(configurer.getAuthenticationProvider()).isInstanceOf(OpaqueTokenAuthenticationProvider.class); + } + } + + @Test + void opaqueTokenConfigurerResolvesIntrospectorFromContext() { + try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext()) { + OpaqueTokenIntrospector introspector = (token) -> principal("alice"); + context.registerBean(OpaqueTokenIntrospector.class, () -> introspector); + context.refresh(); + OAuth2ResourceServerConfigurer configurer = new OAuth2ResourceServerConfigurer(context); + + configurer.opaqueToken((opaqueToken) -> { + }); + + AuthenticationProvider provider = configurer.getAuthenticationProvider(); + assertThat(provider).isNotNull(); + BearerTokenAuthentication authentication = (BearerTokenAuthentication) provider + .authenticate(new BearerTokenAuthenticationToken("opaque-token")); + assertThat(authentication.getName()).isEqualTo("alice"); + } + } + + @Test + void opaqueTokenConfigurerAppliesIntrospectionUri() { + try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext()) { + context.refresh(); + OAuth2ResourceServerConfigurer configurer = new OAuth2ResourceServerConfigurer(context); + + configurer.opaqueToken((opaqueToken) -> opaqueToken.introspectionUri("https://example.com/introspect") + .introspectionClientCredentials("client", "secret")); + + assertThat(configurer.getAuthenticationProvider()).isInstanceOf(OpaqueTokenAuthenticationProvider.class); + } + } + + @Test + void jwtConfigurerTakesPrecedenceOverOpaqueToken() { + try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext()) { + context.refresh(); + OAuth2ResourceServerConfigurer configurer = new OAuth2ResourceServerConfigurer(context); + + configurer.jwt((jwt) -> jwt.decoder(mock(JwtDecoder.class))); + configurer.opaqueToken((opaqueToken) -> opaqueToken.introspector(mock(OpaqueTokenIntrospector.class))); + + assertThat(configurer.getAuthenticationProvider()).isInstanceOf(JwtAuthenticationProvider.class); + } + } + + @Test + void noAuthenticationProviderWhenNothingConfigured() { + try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext()) { + context.refresh(); + OAuth2ResourceServerConfigurer configurer = new OAuth2ResourceServerConfigurer(context); + + assertThat(configurer.getAuthenticationProvider()).isNull(); + } + } + + private static OAuth2AuthenticatedPrincipal principal(String name) { + return new DefaultOAuth2AuthenticatedPrincipal(name, + Map.of(OAuth2AccessToken.TokenType.BEARER.getValue(), name, "sub", name), + AuthorityUtils.createAuthorityList("SCOPE_profile")); + } + +} diff --git a/spring-grpc-docs/src/main/antora/modules/ROOT/pages/server.adoc b/spring-grpc-docs/src/main/antora/modules/ROOT/pages/server.adoc index f9a580a0..1358a3fb 100644 --- a/spring-grpc-docs/src/main/antora/modules/ROOT/pages/server.adoc +++ b/spring-grpc-docs/src/main/antora/modules/ROOT/pages/server.adoc @@ -225,6 +225,50 @@ We also enable HTTP Basic authentication and preauthentication (mTLS) (`withDefa Similar to the way Spring Boot works https://docs.spring.io/spring-boot/reference/web/spring-security.html#web.security.oauth2.server[with normal web applications], if you have the `spring-security-oauth2-resource-server` dependency on the classpath, Spring gRPC will be able to configure an OAuth2 resource server through the javadoc:org.springframework.grpc.server.security.GrpcSecurity[] configurer. +Both token formats are supported, and in either case the token is read from the `Authorization` metadata entry of the gRPC call by the javadoc:org.springframework.grpc.server.security.BearerTokenAuthenticationExtractor[]. + +For JWTs, use `jwt()` and supply a `JwtDecoder` (or rely on one in the application context, e.g. from the `spring.security.oauth2.resourceserver.jwt.*` properties): + +[source,java] +---- +@Bean +@GlobalServerInterceptor +AuthenticationProcessInterceptor jwtSecurityFilterChain(GrpcSecurity grpc) throws Exception { + return grpc + .authorizeRequests(requests -> requests + .methods("Simple/StreamHello").hasAuthority("SCOPE_profile") + .methods("grpc.*/*").permitAll() + .allRequests().authenticated()) + .oauth2ResourceServer(resourceServer -> resourceServer.jwt(withDefaults())) + .build(); +} +---- + +For opaque tokens, use `opaqueToken()` and point it at an introspection endpoint (https://datatracker.ietf.org/doc/html/rfc7662[RFC 7662]). +The token is validated on each call by an `OpaqueTokenIntrospector`: + +[source,java] +---- +@Bean +@GlobalServerInterceptor +AuthenticationProcessInterceptor opaqueTokenSecurityFilterChain(GrpcSecurity grpc) throws Exception { + return grpc + .authorizeRequests(requests -> requests + .methods("grpc.*/*").permitAll() + .allRequests().authenticated()) + .oauth2ResourceServer(resourceServer -> resourceServer + .opaqueToken(opaqueToken -> opaqueToken + .introspectionUri("https://authserver.example.com/oauth2/introspect") + .introspectionClientCredentials("client", "secret"))) + .build(); +} +---- + +Instead of an introspection URI you can supply your own `OpaqueTokenIntrospector` via `introspector()`, or leave the configurer empty and expose one as a bean. +An `OpaqueTokenAuthenticationConverter` (set with `authenticationConverter()`, or a bean of that type) controls how the introspection response is mapped to an `Authentication`. + +NOTE: `jwt()` takes precedence if both are configured, so pick one per interceptor. + === Servlet The servlet-based server supports any security configuration that the servlet container supports, including Spring Security.