Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<ClientRegistrationRepository> context) {
return GrpcChannelBuilderCustomizer.matching("secure",
builder -> builder.intercept(new BearerTokenAuthenticationInterceptor(
new ClientCredentialsTokenSupplier(context.getObject(), () -> "spring"))));
}

}

}
Original file line number Diff line number Diff line change
@@ -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<Empty, Empty> METHOD = MethodDescriptor.<Empty, Empty>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();
}

}
Original file line number Diff line number Diff line change
@@ -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<Empty, Empty> METHOD = MethodDescriptor.<Empty, Empty>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;
}

}
Loading