diff --git a/examples/AUTHENTICATION_EXAMPLE.md b/examples/AUTHENTICATION_EXAMPLE.md new file mode 100644 index 0000000000..31cc294a50 --- /dev/null +++ b/examples/AUTHENTICATION_EXAMPLE.md @@ -0,0 +1,78 @@ +Authentication Example +============================================== + +This example illustrates a simple JWT-like credential based authentication implementation in gRPC using +client and server interceptors. For simplicity a simple string value is used instead of an actual +string-encoded JWT. + +The example requires grpc-java to be pre-built. Using a release tag will download the relevant binaries +from a maven repository. But if you need the latest SNAPSHOT binaries you will need to follow +[COMPILING](../COMPILING.md) to build these. + +The source code is [here](src/main/java/io/grpc/examples/authentication). Please follow the +[steps](./README.md#to-build-the-examples) to build the examples. The build creates scripts +`auth-server` and `auth-client` in the `build/install/examples/bin/` directory which can be +used to run this example. The example requires the server to be running before starting the +client. + +Running auth-server is similar to the normal hello world example and there are no arguments to supply: + +**auth-server**: + +```text +USAGE: AuthServer +``` + +The auth-client accepts optional arguments for user-name and token-value: + +**auth-client**: + +```text +USAGE: AuthClient [user-name] [token-value] +``` + +The `user-name` value is simply passed in the `HelloRequest` message as payload and the value of +`payload` is passed in the metadata header as a string value signifying an authentication token. + + +#### How to run the example: + +```bash +# Run the server: +./build/install/examples/bin/auth-server +# In another terminal run the client +./build/install/examples/bin/auth-client client-userA token-valueB +``` + +That's it! The client will show the user-name reflected back in the message from the server as follows: +``` +INFO: Greeting: Hello Authenticated client-userA +``` + +And on the server side you will see the token value sent by the client: +``` +Token: token-valueB +``` + +## Maven + +If you prefer to use Maven follow these [steps](./README.md#maven). You can run the example as follows: + +``` +$ # Run the server +$ mvn exec:java -Dexec.mainClass=io.grpc.examples.authentication.AuthServer +$ # In another terminal run the client +$ mvn exec:java -Dexec.mainClass=io.grpc.examples.authentication.AuthClient -Dexec.args="client-userA token-valueB" +``` + +## Bazel + +If you prefer to use Bazel: +``` +(With Bazel v0.8.0 or above.) +$ bazel build :auth-server :auth-client +$ # Run the server +$ bazel-bin/auth-server +$ # In another terminal run the client +$ bazel-bin/auth-client client-userA token-valueB +``` diff --git a/examples/BUILD.bazel b/examples/BUILD.bazel index ac8925b979..36971da41b 100644 --- a/examples/BUILD.bazel +++ b/examples/BUILD.bazel @@ -98,6 +98,24 @@ java_binary( ], ) +java_binary( + name = "auth-client", + testonly = 1, + main_class = "io.grpc.examples.authentication.AuthClient", + runtime_deps = [ + ":examples", + ], +) + +java_binary( + name = "auth-server", + testonly = 1, + main_class = "io.grpc.examples.authentication.AuthServer", + runtime_deps = [ + ":examples", + ], +) + java_binary( name = "route-guide-client", testonly = 1, diff --git a/examples/README.md b/examples/README.md index b25f3b89a6..f2ea363289 100644 --- a/examples/README.md +++ b/examples/README.md @@ -25,6 +25,9 @@ before trying out the examples. - [Json serialization](src/main/java/io/grpc/examples/advanced) +- [Authentication](AUTHENTICATION_EXAMPLE.md) + + ### To build the examples 1. **[Install gRPC Java library SNAPSHOT locally, including code generation plugin](../COMPILING.md) (Only need this step for non-released versions, e.g. master HEAD).** diff --git a/examples/build.gradle b/examples/build.gradle index 327ff9314b..385d391922 100644 --- a/examples/build.gradle +++ b/examples/build.gradle @@ -91,6 +91,20 @@ task helloWorldClient(type: CreateStartScripts) { classpath = startScripts.classpath } +task authServer(type: CreateStartScripts) { + mainClassName = 'io.grpc.examples.authentication.AuthServer' + applicationName = 'auth-server' + outputDir = new File(project.buildDir, 'tmp') + classpath = startScripts.classpath +} + +task authClient(type: CreateStartScripts) { + mainClassName = 'io.grpc.examples.authentication.AuthClient' + applicationName = 'auth-client' + outputDir = new File(project.buildDir, 'tmp') + classpath = startScripts.classpath +} + task compressingHelloWorldClient(type: CreateStartScripts) { mainClassName = 'io.grpc.examples.experimental.CompressingHelloWorldClient' applicationName = 'compressing-hello-world-client' @@ -103,6 +117,8 @@ applicationDistribution.into('bin') { from(routeGuideClient) from(helloWorldServer) from(helloWorldClient) + from(authServer) + from(authClient) from(compressingHelloWorldClient) fileMode = 0755 } diff --git a/examples/src/main/java/io/grpc/examples/authentication/AuthClient.java b/examples/src/main/java/io/grpc/examples/authentication/AuthClient.java new file mode 100644 index 0000000000..5c7c7c7cca --- /dev/null +++ b/examples/src/main/java/io/grpc/examples/authentication/AuthClient.java @@ -0,0 +1,109 @@ +/* + * Copyright 2018 The gRPC 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 + * + * http://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 io.grpc.examples.authentication; + +import io.grpc.ManagedChannel; +import io.grpc.ManagedChannelBuilder; +import io.grpc.StatusRuntimeException; +import io.grpc.examples.helloworld.GreeterGrpc; +import io.grpc.examples.helloworld.HelloReply; +import io.grpc.examples.helloworld.HelloRequest; + +import java.util.concurrent.TimeUnit; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * An authenticating client that requests a greeting from the {@link AuthServer}. + * It uses a {@link JwtClientInterceptor} to inject a JWT credential. + */ +public class AuthClient { + private static final Logger logger = Logger.getLogger(AuthClient.class.getName()); + + private final ManagedChannel channel; + private final GreeterGrpc.GreeterBlockingStub blockingStub; + private final JwtClientInterceptor jwtClientInterceptor = new JwtClientInterceptor(); + + /** Construct client connecting to AuthServer at {@code host:port} using a client-interceptor + * to inject the JWT credentials + */ + public AuthClient(String host, int port) { + this(ManagedChannelBuilder.forAddress(host, port) + // Channels are secure by default (via SSL/TLS). For this example we disable TLS to avoid + // needing certificates, but it is recommended to use a secure channel while passing + // credentials. + .usePlaintext()); + } + + /** Construct client for accessing GreeterGrpc server using the existing channel. */ + AuthClient(ManagedChannelBuilder builder) { + this.channel = builder + .intercept(jwtClientInterceptor) + .build(); + blockingStub = GreeterGrpc.newBlockingStub(channel); + } + + public void shutdown() throws InterruptedException { + channel.shutdown().awaitTermination(5, TimeUnit.SECONDS); + } + + public void setTokenValue(String tokenValue) { + jwtClientInterceptor.setTokenValue(tokenValue); + } + + /** + * Say hello to server. + * + * @param name name to set in HelloRequest + * @return the message in the HelloReply from the server + */ + public String greet(String name) { + logger.info("Will try to greet " + name + " ..."); + HelloRequest request = HelloRequest.newBuilder().setName(name).build(); + HelloReply response; + try { + response = blockingStub.sayHello(request); + } catch (StatusRuntimeException e) { + logger.log(Level.WARNING, "RPC failed: {0}", e.getStatus()); + return e.toString(); + } + logger.info("Greeting: " + response.getMessage()); + return response.getMessage(); + } + + /** + * Greet server. If provided, the first element of {@code args} is the name to use in the + * greeting. + */ + public static void main(String[] args) throws Exception { + + AuthClient client = new AuthClient("localhost", 50051); + try { + /* Access a service running on the local machine on port 50051 */ + String user = "world"; + if (args.length > 0) { + user = args[0]; /* Use the arg as the name to greet if provided */ + } + if (args.length > 1) { + client.setTokenValue(args[1]); + } + client.greet(user); + } finally { + client.shutdown(); + } + } +} diff --git a/examples/src/main/java/io/grpc/examples/authentication/AuthServer.java b/examples/src/main/java/io/grpc/examples/authentication/AuthServer.java new file mode 100644 index 0000000000..43a9ff8e63 --- /dev/null +++ b/examples/src/main/java/io/grpc/examples/authentication/AuthServer.java @@ -0,0 +1,91 @@ +/* + * Copyright 2018 The gRPC 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 + * + * http://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 io.grpc.examples.authentication; + +import io.grpc.Server; +import io.grpc.ServerBuilder; +import io.grpc.examples.helloworld.GreeterGrpc; +import io.grpc.examples.helloworld.HelloReply; +import io.grpc.examples.helloworld.HelloRequest; +import io.grpc.stub.StreamObserver; + +import java.io.IOException; +import java.util.logging.Logger; + +/** + * Server that manages startup/shutdown of a {@code Greeter} server. + * This also uses a {@link JwtServerInterceptor} to intercept the JWT token passed + */ +public class AuthServer { + private static final Logger logger = Logger.getLogger(AuthServer.class.getName()); + + private Server server; + + private void start() throws IOException { + /* The port on which the server should run */ + int port = 50051; + server = ServerBuilder.forPort(port) + .addService(new GreeterImpl()) + .intercept(new JwtServerInterceptor()) // add the JwtServerInterceptor + .build() + .start(); + logger.info("Server started, listening on " + port); + Runtime.getRuntime().addShutdownHook(new Thread() { + @Override + public void run() { + // Use stderr here since the logger may have been reset by its JVM shutdown hook. + System.err.println("*** shutting down gRPC server since JVM is shutting down"); + AuthServer.this.stop(); + System.err.println("*** server shut down"); + } + }); + } + + private void stop() { + if (server != null) { + server.shutdown(); + } + } + + /** + * Await termination on the main thread since the grpc library uses daemon threads. + */ + private void blockUntilShutdown() throws InterruptedException { + if (server != null) { + server.awaitTermination(); + } + } + + /** + * Main launches the server from the command line. + */ + public static void main(String[] args) throws IOException, InterruptedException { + final AuthServer server = new AuthServer(); + server.start(); + server.blockUntilShutdown(); + } + + static class GreeterImpl extends GreeterGrpc.GreeterImplBase { + + @Override + public void sayHello(HelloRequest req, StreamObserver responseObserver) { + HelloReply reply = HelloReply.newBuilder().setMessage("Hello Authenticated " + req.getName()).build(); + responseObserver.onNext(reply); + responseObserver.onCompleted(); + } + } +} diff --git a/examples/src/main/java/io/grpc/examples/authentication/Constant.java b/examples/src/main/java/io/grpc/examples/authentication/Constant.java new file mode 100644 index 0000000000..feed373388 --- /dev/null +++ b/examples/src/main/java/io/grpc/examples/authentication/Constant.java @@ -0,0 +1,32 @@ +/* + * Copyright 2018 The gRPC 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 + * + * http://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 io.grpc.examples.authentication; + +import io.grpc.Metadata; + +import static io.grpc.Metadata.ASCII_STRING_MARSHALLER; + +/** + * Constants definition + */ +public final class Constant { + private Constant() { + } + + public static final Metadata.Key JWT_METADATA_KEY = Metadata.Key.of("jwt", ASCII_STRING_MARSHALLER); + +} diff --git a/examples/src/main/java/io/grpc/examples/authentication/JwtClientInterceptor.java b/examples/src/main/java/io/grpc/examples/authentication/JwtClientInterceptor.java new file mode 100644 index 0000000000..f2eec7b5a4 --- /dev/null +++ b/examples/src/main/java/io/grpc/examples/authentication/JwtClientInterceptor.java @@ -0,0 +1,58 @@ +/* + * Copyright 2018 The gRPC 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 + * + * http://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 io.grpc.examples.authentication; + +import io.grpc.CallOptions; +import io.grpc.Channel; +import io.grpc.ClientCall; +import io.grpc.ClientInterceptor; +import io.grpc.ForwardingClientCall; +import io.grpc.Metadata; +import io.grpc.MethodDescriptor; + +/** + * Use a {@link ClientInterceptor} to insert a JWT in the client to outgoing calls, that's designed + * specifically to append credentials information. A token might expire, so for each call, if the + * token has expired, you can proactively refresh it. + * + * Every time a call takes place, the handler will execute, and the + * {@link io.grpc.CallCredentials2.MetadataApplier} + * is applied to add a header. The method should not block: to refresh or fetch a token from the + * network, it should be done asynchronously. + */ +public class JwtClientInterceptor implements ClientInterceptor { + + private String tokenValue = "my-default-token"; + + public void setTokenValue(String tokenValue) { + this.tokenValue = tokenValue; + } + + @Override + public ClientCall interceptCall( + MethodDescriptor methodDescriptor, CallOptions callOptions, + Channel channel) { + return new ForwardingClientCall.SimpleForwardingClientCall( + channel.newCall(methodDescriptor, callOptions)) { + @Override + public void start(Listener responseListener, Metadata headers) { + headers.put(Constant.JWT_METADATA_KEY, tokenValue); + super.start(responseListener, headers); + } + }; + } +} diff --git a/examples/src/main/java/io/grpc/examples/authentication/JwtServerInterceptor.java b/examples/src/main/java/io/grpc/examples/authentication/JwtServerInterceptor.java new file mode 100644 index 0000000000..84d00bb0d3 --- /dev/null +++ b/examples/src/main/java/io/grpc/examples/authentication/JwtServerInterceptor.java @@ -0,0 +1,42 @@ +/* + * Copyright 2018 The gRPC 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 + * + * http://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 io.grpc.examples.authentication; + +import io.grpc.Metadata; +import io.grpc.ServerCall; +import io.grpc.ServerCallHandler; +import io.grpc.ServerInterceptor; + +/** + * Use a {@link ServerInterceptor} to capture metadata and retrieve any JWT token. + * + * This interceptor only captures the JWT token and prints it out. + * Normally the token will need to be validated against an identity provider. + */ +public class JwtServerInterceptor implements ServerInterceptor { + + @Override + public ServerCall.Listener interceptCall(ServerCall serverCall, Metadata metadata, ServerCallHandler serverCallHandler) { + // Get token from Metadata + // Capture the JWT token and just print it out. + String token = metadata.get(Constant.JWT_METADATA_KEY); + System.out.println("Token: " + token); + + return serverCallHandler.startCall(serverCall, metadata); + } + +} diff --git a/examples/src/test/java/io/grpc/examples/authentication/AuthClientTest.java b/examples/src/test/java/io/grpc/examples/authentication/AuthClientTest.java new file mode 100644 index 0000000000..c887ea23b2 --- /dev/null +++ b/examples/src/test/java/io/grpc/examples/authentication/AuthClientTest.java @@ -0,0 +1,136 @@ +/* + * Copyright 2015 The gRPC 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 + * + * http://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 io.grpc.examples.authentication; + +import io.grpc.*; +import io.grpc.examples.helloworld.GreeterGrpc; +import io.grpc.examples.helloworld.HelloReply; +import io.grpc.examples.helloworld.HelloRequest; +import io.grpc.inprocess.InProcessChannelBuilder; +import io.grpc.inprocess.InProcessServerBuilder; +import io.grpc.ServerCall.Listener; +import io.grpc.ServerInterceptor; +import io.grpc.testing.GrpcCleanupRule; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; +import org.mockito.ArgumentCaptor; +import org.mockito.Matchers; + +import java.io.IOException; + +import static org.junit.Assert.assertEquals; +import static org.mockito.AdditionalAnswers.delegatesTo; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +/** + * Unit tests for {@link AuthClient} testing the default and non-default tokens + * + * + */ +@RunWith(JUnit4.class) +public class AuthClientTest { + /** + * This rule manages automatic graceful shutdown for the registered servers and channels at the + * end of test. + */ + @Rule + public final GrpcCleanupRule grpcCleanup = new GrpcCleanupRule(); + + private final ServerInterceptor mockServerInterceptor = mock(ServerInterceptor.class, delegatesTo( + new ServerInterceptor() { + @Override + public Listener interceptCall( + ServerCall call, Metadata headers, ServerCallHandler next) { + return next.startCall(call, headers); + } + })); + + private AuthClient client; + + + @Before + public void setUp() throws IOException { + // Generate a unique in-process server name. + String serverName = InProcessServerBuilder.generateName(); + + // Create a server, add service, start, and register for automatic graceful shutdown. + grpcCleanup.register(InProcessServerBuilder.forName(serverName).directExecutor() + .addService(ServerInterceptors.intercept(new GreeterGrpc.GreeterImplBase() { + + @Override + public void sayHello(io.grpc.examples.helloworld.HelloRequest request, + io.grpc.stub.StreamObserver responseObserver) { + HelloReply reply = HelloReply.newBuilder().setMessage("AuthClientTest user=" + request.getName()).build(); + responseObserver.onNext(reply); + responseObserver.onCompleted(); + } + }, mockServerInterceptor)) + .build().start()); + + // Create an AuthClient using the in-process channel; + client = new AuthClient(InProcessChannelBuilder.forName(serverName).directExecutor()); + } + + /** + * Test default JWT token used + * + * @throws Exception + */ + @Test + public void defaultTokenDeliveredToServer() throws Exception { + ArgumentCaptor metadataCaptor = ArgumentCaptor.forClass(Metadata.class); + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(HelloRequest.class); + + String retVal = client.greet("default token test"); + + verify(mockServerInterceptor).interceptCall( + Matchers.>any(), + metadataCaptor.capture(), + Matchers.>any()); + assertEquals( + "my-default-token", + metadataCaptor.getValue().get(Constant.JWT_METADATA_KEY)); + assertEquals("AuthClientTest user=default token test", retVal); + } + + /** + * Test non-default JWT token used + * + * @throws Exception + */ + @Test + public void nonDefaultTokenDeliveredToServer() throws Exception { + ArgumentCaptor metadataCaptor = ArgumentCaptor.forClass(Metadata.class); + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(HelloRequest.class); + + client.setTokenValue("non-default-token"); + String retVal = client.greet("non default token test"); + + verify(mockServerInterceptor).interceptCall( + Matchers.>any(), + metadataCaptor.capture(), + Matchers.>any()); + assertEquals( + "non-default-token", + metadataCaptor.getValue().get(Constant.JWT_METADATA_KEY)); + assertEquals("AuthClientTest user=non default token test", retVal); + } +}