From 0b8726f02bd9d2e9d6cbded5c2b6196644f51f77 Mon Sep 17 00:00:00 2001 From: Gabriel Hall Date: Tue, 28 Jul 2026 21:09:06 -0700 Subject: [PATCH] Document and test OAuth2 opaque token support The GrpcSecurity OAuth2 resource server configurer has supported opaque tokens since it was introduced, but the reference docs only mentioned OAuth2 in passing and neither token format had test coverage. The security package had no tests at all, so the extractors and the jwt()-over-opaqueToken() precedence rule were unverified. Add unit tests for the bearer and basic extractors and for the resource server configurer, an end-to-end sample test that authenticates an opaque token against a real authorization server by introspection, and expand the server docs with worked jwt() and opaqueToken() examples. Signed-off-by: Gabriel Hall --- .../OpaqueTokenServerApplicationTests.java | 120 ++++++++++++++++ ...arerTokenAuthenticationExtractorTests.java | 82 +++++++++++ ...HttpBasicAuthenticationExtractorTests.java | 95 +++++++++++++ .../OAuth2ResourceServerConfigurerTests.java | 129 ++++++++++++++++++ .../antora/modules/ROOT/pages/server.adoc | 44 ++++++ 5 files changed, 470 insertions(+) create mode 100644 samples/grpc-oauth2/src/test/java/org/springframework/grpc/sample/OpaqueTokenServerApplicationTests.java create mode 100644 spring-grpc-core/src/test/java/org/springframework/grpc/server/security/BearerTokenAuthenticationExtractorTests.java create mode 100644 spring-grpc-core/src/test/java/org/springframework/grpc/server/security/HttpBasicAuthenticationExtractorTests.java create mode 100644 spring-grpc-core/src/test/java/org/springframework/grpc/server/security/OAuth2ResourceServerConfigurerTests.java 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.