diff --git a/auth/build.gradle b/auth/build.gradle index 30cd3226f5..14af19ec57 100644 --- a/auth/build.gradle +++ b/auth/build.gradle @@ -5,7 +5,8 @@ plugins { description = "gRpc: Auth" dependencies { compile project(':grpc-core'), - libraries.oauth_client + libraries.google_auth_credentials + testCompile libraries.oauth_client } // Configure the animal sniffer plugin diff --git a/auth/src/main/java/io/grpc/auth/ClientAuthInterceptor.java b/auth/src/main/java/io/grpc/auth/ClientAuthInterceptor.java index e636b3996b..aa2df86f88 100644 --- a/auth/src/main/java/io/grpc/auth/ClientAuthInterceptor.java +++ b/auth/src/main/java/io/grpc/auth/ClientAuthInterceptor.java @@ -38,17 +38,8 @@ import io.grpc.CallOptions; import io.grpc.Channel; import io.grpc.ClientCall; import io.grpc.ClientInterceptor; -import io.grpc.ClientInterceptors.CheckedForwardingClientCall; -import io.grpc.Metadata; import io.grpc.MethodDescriptor; -import io.grpc.Status; -import io.grpc.StatusException; -import java.io.IOException; -import java.net.URI; -import java.net.URISyntaxException; -import java.util.List; -import java.util.Map; import java.util.concurrent.Executor; /** @@ -57,109 +48,22 @@ import java.util.concurrent.Executor; * *

Uses the new and simplified Google auth library: * https://github.com/google/google-auth-library-java + * + * @deprecated use {@link GoogleAuthLibraryCallCredentials} instead. */ +@Deprecated public final class ClientAuthInterceptor implements ClientInterceptor { private final Credentials credentials; - private Metadata cached; - private Map> lastMetadata; - public ClientAuthInterceptor( Credentials credentials, @SuppressWarnings("unused") Executor executor) { this.credentials = Preconditions.checkNotNull(credentials); - // TODO(louiscryan): refresh token asynchronously with this executor. } @Override public ClientCall interceptCall( final MethodDescriptor method, CallOptions callOptions, final Channel next) { - // TODO(ejona86): If the call fails for Auth reasons, this does not properly propagate info that - // would be in WWW-Authenticate, because it does not yet have access to the header. - return new CheckedForwardingClientCall(next.newCall(method, callOptions)) { - @Override - protected void checkedStart(Listener responseListener, Metadata headers) - throws StatusException { - Metadata cachedSaved; - URI uri = serviceUri(next, method); - synchronized (ClientAuthInterceptor.this) { - // TODO(louiscryan): This is icky but the current auth library stores the same - // metadata map until the next refresh cycle. This will be fixed once - // https://github.com/google/google-auth-library-java/issues/3 - // is resolved. - // getRequestMetadata() may return a different map based on the provided URI, i.e., for - // JWT. However, today it does not cache JWT and so we won't bother tring to cache its - // return value based on the URI. - Map> latestMetadata = getRequestMetadata(uri); - if (lastMetadata == null || lastMetadata != latestMetadata) { - lastMetadata = latestMetadata; - cached = toHeaders(lastMetadata); - } - cachedSaved = cached; - } - headers.merge(cachedSaved); - delegate().start(responseListener, headers); - } - }; - } - - /** - * Generate a JWT-specific service URI. The URI is simply an identifier with enough information - * for a service to know that the JWT was intended for it. The URI will commonly be verified with - * a simple string equality check. - */ - private URI serviceUri(Channel channel, MethodDescriptor method) throws StatusException { - String authority = channel.authority(); - if (authority == null) { - throw Status.UNAUTHENTICATED.withDescription("Channel has no authority").asException(); - } - // Always use HTTPS, by definition. - final String scheme = "https"; - final int defaultPort = 443; - String path = "/" + MethodDescriptor.extractFullServiceName(method.getFullMethodName()); - URI uri; - try { - uri = new URI(scheme, authority, path, null, null); - } catch (URISyntaxException e) { - throw Status.UNAUTHENTICATED.withDescription("Unable to construct service URI for auth") - .withCause(e).asException(); - } - // The default port must not be present. Alternative ports should be present. - if (uri.getPort() == defaultPort) { - uri = removePort(uri); - } - return uri; - } - - private URI removePort(URI uri) throws StatusException { - try { - return new URI(uri.getScheme(), uri.getUserInfo(), uri.getHost(), -1 /* port */, - uri.getPath(), uri.getQuery(), uri.getFragment()); - } catch (URISyntaxException e) { - throw Status.UNAUTHENTICATED.withDescription( - "Unable to construct service URI after removing port") - .withCause(e).asException(); - } - } - - private Map> getRequestMetadata(URI uri) throws StatusException { - try { - return credentials.getRequestMetadata(uri); - } catch (IOException e) { - throw Status.UNAUTHENTICATED.withCause(e).asException(); - } - } - - private static final Metadata toHeaders(Map> metadata) { - Metadata headers = new Metadata(); - if (metadata != null) { - for (String key : metadata.keySet()) { - Metadata.Key headerKey = Metadata.Key.of(key, Metadata.ASCII_STRING_MARSHALLER); - for (String value : metadata.get(key)) { - headers.put(headerKey, value); - } - } - } - return headers; + return next.newCall(method, callOptions.withCredentials(MoreCallCredentials.from(credentials))); } } diff --git a/auth/src/main/java/io/grpc/auth/GoogleAuthLibraryCallCredentials.java b/auth/src/main/java/io/grpc/auth/GoogleAuthLibraryCallCredentials.java new file mode 100644 index 0000000000..e5b5cd7316 --- /dev/null +++ b/auth/src/main/java/io/grpc/auth/GoogleAuthLibraryCallCredentials.java @@ -0,0 +1,168 @@ +/* + * Copyright 2016, Google Inc. All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * + * * Neither the name of Google Inc. nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package io.grpc.auth; + +import static com.google.common.base.Preconditions.checkNotNull; + +import com.google.auth.Credentials; +import com.google.common.annotations.VisibleForTesting; +import com.google.common.io.BaseEncoding; + +import io.grpc.Attributes; +import io.grpc.CallCredentials.MetadataApplier; +import io.grpc.CallCredentials; +import io.grpc.Metadata; +import io.grpc.MethodDescriptor; +import io.grpc.Status; +import io.grpc.StatusException; + +import java.net.URI; +import java.net.URISyntaxException; +import java.util.List; +import java.util.Map; +import java.util.concurrent.Executor; + +/** + * Wraps {@link Credentials} as a {@link CallCredentials}. + */ +final class GoogleAuthLibraryCallCredentials implements CallCredentials { + + @VisibleForTesting + final Credentials creds; + + private Metadata lastHeaders; + private Map> lastMetadata; + + public GoogleAuthLibraryCallCredentials(Credentials creds) { + this.creds = checkNotNull(creds, "creds"); + } + + @Override + public void applyRequestMetadata(MethodDescriptor method, Attributes attrs, + Executor appExecutor, final MetadataApplier applier) { + Metadata cachedSaved; + String authority = checkNotNull(attrs.get(ATTR_AUTHORITY), "authority"); + final URI uri; + try { + uri = serviceUri(authority, method); + } catch (StatusException e) { + applier.fail(e.getStatus()); + return; + } + appExecutor.execute(new Runnable() { + @Override + public void run() { + try { + // Credentials is expected to manage caching internally if the metadata is fetched over + // the network. + // TODO(zhangkun83): we don't know whether there is valid cache data. If there is, we + // would waste a context switch by always scheduling in executor. However, we have to + // do so because we can't risk blocking the network thread. This can be resolved after + // https://github.com/google/google-auth-library-java/issues/3 is resolved. + Map> metadata = creds.getRequestMetadata(uri); + // Re-use the headers if getRequestMetadata() returns the same map. It may return a + // different map based on the provided URI, i.e., for JWT. However, today it does not + // cache JWT and so we won't bother tring to save its return value based on the URI. + Metadata headers; + synchronized (GoogleAuthLibraryCallCredentials.this) { + if (lastMetadata != metadata) { + lastMetadata = metadata; + lastHeaders = toHeaders(metadata); + } + headers = lastHeaders; + } + applier.apply(headers); + } catch (Throwable e) { + applier.fail(Status.UNAUTHENTICATED.withCause(e)); + } + } + }); + } + + /** + * Generate a JWT-specific service URI. The URI is simply an identifier with enough information + * for a service to know that the JWT was intended for it. The URI will commonly be verified with + * a simple string equality check. + */ + private static URI serviceUri(String authority, MethodDescriptor method) + throws StatusException { + if (authority == null) { + throw Status.UNAUTHENTICATED.withDescription("Channel has no authority").asException(); + } + // Always use HTTPS, by definition. + final String scheme = "https"; + final int defaultPort = 443; + String path = "/" + MethodDescriptor.extractFullServiceName(method.getFullMethodName()); + URI uri; + try { + uri = new URI(scheme, authority, path, null, null); + } catch (URISyntaxException e) { + throw Status.UNAUTHENTICATED.withDescription("Unable to construct service URI for auth") + .withCause(e).asException(); + } + // The default port must not be present. Alternative ports should be present. + if (uri.getPort() == defaultPort) { + uri = removePort(uri); + } + return uri; + } + + private static URI removePort(URI uri) throws StatusException { + try { + return new URI(uri.getScheme(), uri.getUserInfo(), uri.getHost(), -1 /* port */, + uri.getPath(), uri.getQuery(), uri.getFragment()); + } catch (URISyntaxException e) { + throw Status.UNAUTHENTICATED.withDescription( + "Unable to construct service URI after removing port").withCause(e).asException(); + } + } + + private static Metadata toHeaders(Map> metadata) { + Metadata headers = new Metadata(); + if (metadata != null) { + for (String key : metadata.keySet()) { + if (key.endsWith("-bin")) { + Metadata.Key headerKey = Metadata.Key.of(key, Metadata.BINARY_BYTE_MARSHALLER); + for (String value : metadata.get(key)) { + headers.put(headerKey, BaseEncoding.base64().decode(value)); + } + } else { + Metadata.Key headerKey = Metadata.Key.of(key, Metadata.ASCII_STRING_MARSHALLER); + for (String value : metadata.get(key)) { + headers.put(headerKey, value); + } + } + } + } + return headers; + } +} diff --git a/auth/src/main/java/io/grpc/auth/MoreCallCredentials.java b/auth/src/main/java/io/grpc/auth/MoreCallCredentials.java new file mode 100644 index 0000000000..b8e8ca52a2 --- /dev/null +++ b/auth/src/main/java/io/grpc/auth/MoreCallCredentials.java @@ -0,0 +1,53 @@ +/* + * Copyright 2016, Google Inc. All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * + * * Neither the name of Google Inc. nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package io.grpc.auth; + +import com.google.auth.Credentials; + +import io.grpc.CallCredentials; +import io.grpc.ExperimentalApi; + +/** + * A utility class that converts other types of credentials to {@link CallCredentials}. + */ +@ExperimentalApi("https//github.com/grpc/grpc-java/issues/1914") +public final class MoreCallCredentials { + /** + * Converts a Google Auth Library {@link Credentials} to {@link CallCredentials}. + */ + public static CallCredentials from(Credentials creds) { + return new GoogleAuthLibraryCallCredentials(creds); + } + + private MoreCallCredentials() { + } +} diff --git a/auth/src/test/java/io/grpc/auth/ClientAuthInterceptorTests.java b/auth/src/test/java/io/grpc/auth/ClientAuthInterceptorTests.java index 29fc20100b..ce478a561d 100644 --- a/auth/src/test/java/io/grpc/auth/ClientAuthInterceptorTests.java +++ b/auth/src/test/java/io/grpc/auth/ClientAuthInterceptorTests.java @@ -31,58 +31,37 @@ package io.grpc.auth; -import static org.mockito.Matchers.any; -import static org.mockito.Matchers.isA; +import static org.junit.Assert.assertSame; import static org.mockito.Matchers.same; -import static org.mockito.Mockito.doReturn; -import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; import com.google.auth.Credentials; -import com.google.auth.oauth2.AccessToken; -import com.google.auth.oauth2.OAuth2Credentials; -import com.google.common.collect.Iterables; -import com.google.common.collect.LinkedListMultimap; -import com.google.common.collect.ListMultimap; -import com.google.common.collect.Multimaps; -import com.google.common.util.concurrent.MoreExecutors; import io.grpc.CallOptions; import io.grpc.Channel; import io.grpc.ClientCall; -import io.grpc.Metadata; import io.grpc.MethodDescriptor; import io.grpc.MethodDescriptor.Marshaller; -import io.grpc.Status; -import org.junit.Assert; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; import org.junit.runners.JUnit4; import org.mockito.ArgumentCaptor; +import org.mockito.Captor; import org.mockito.Mock; -import org.mockito.Mockito; import org.mockito.MockitoAnnotations; -import java.io.IOException; -import java.net.URI; -import java.util.Date; import java.util.concurrent.Executor; /** * Tests for {@link ClientAuthInterceptor}. */ @RunWith(JUnit4.class) +@Deprecated public class ClientAuthInterceptorTests { - - private static final Metadata.Key AUTHORIZATION = Metadata.Key.of("Authorization", - Metadata.ASCII_STRING_MARSHALLER); - private static final Metadata.Key EXTRA_AUTHORIZATION = Metadata.Key.of( - "Extra-Authorization", Metadata.ASCII_STRING_MARSHALLER); - - private final Executor executor = MoreExecutors.directExecutor(); + @Mock + Executor executor; @Mock Credentials credentials; @@ -95,14 +74,11 @@ public class ClientAuthInterceptorTests { MethodDescriptor descriptor; - @Mock - ClientCall.Listener listener; - @Mock Channel channel; - @Mock - ClientCall call; + @Captor + ArgumentCaptor callOptionsCaptor; ClientAuthInterceptor interceptor; @@ -112,82 +88,17 @@ public class ClientAuthInterceptorTests { MockitoAnnotations.initMocks(this); descriptor = MethodDescriptor.create( MethodDescriptor.MethodType.UNKNOWN, "a.service/method", stringMarshaller, intMarshaller); - when(channel.newCall(same(descriptor), any(CallOptions.class))).thenReturn(call); - doReturn("localhost:443").when(channel).authority(); interceptor = new ClientAuthInterceptor(credentials, executor); } @Test - public void testCopyCredentialToHeaders() throws IOException { - ListMultimap values = LinkedListMultimap.create(); - values.put("Authorization", "token1"); - values.put("Authorization", "token2"); - values.put("Extra-Authorization", "token3"); - values.put("Extra-Authorization", "token4"); - when(credentials.getRequestMetadata(any(URI.class))).thenReturn(Multimaps.asMap(values)); + public void callCredentialsSet() throws Exception { ClientCall interceptedCall = interceptor.interceptCall(descriptor, CallOptions.DEFAULT, channel); - Metadata headers = new Metadata(); - interceptedCall.start(listener, headers); - verify(call).start(listener, headers); - Iterable authorization = headers.getAll(AUTHORIZATION); - Assert.assertArrayEquals(new String[]{"token1", "token2"}, - Iterables.toArray(authorization, String.class)); - Iterable extraAuthorization = headers.getAll(EXTRA_AUTHORIZATION); - Assert.assertArrayEquals(new String[]{"token3", "token4"}, - Iterables.toArray(extraAuthorization, String.class)); - } - - @Test - public void testCredentialsThrows() throws IOException { - when(credentials.getRequestMetadata(any(URI.class))).thenThrow(new IOException("Broken")); - ClientCall interceptedCall = - interceptor.interceptCall(descriptor, CallOptions.DEFAULT, channel); - Metadata headers = new Metadata(); - interceptedCall.start(listener, headers); - ArgumentCaptor statusCaptor = ArgumentCaptor.forClass(Status.class); - Mockito.verify(listener).onClose(statusCaptor.capture(), isA(Metadata.class)); - Assert.assertNull(headers.getAll(AUTHORIZATION)); - Mockito.verify(call, never()).start(listener, headers); - Assert.assertEquals(Status.Code.UNAUTHENTICATED, statusCaptor.getValue().getCode()); - Assert.assertNotNull(statusCaptor.getValue().getCause()); - } - - @Test - public void testWithOAuth2Credential() { - final AccessToken token = new AccessToken("allyourbase", new Date(Long.MAX_VALUE)); - final OAuth2Credentials oAuth2Credentials = new OAuth2Credentials() { - @Override - public AccessToken refreshAccessToken() throws IOException { - return token; - } - }; - interceptor = new ClientAuthInterceptor(oAuth2Credentials, executor); - ClientCall interceptedCall = - interceptor.interceptCall(descriptor, CallOptions.DEFAULT, channel); - Metadata headers = new Metadata(); - interceptedCall.start(listener, headers); - verify(call).start(listener, headers); - Iterable authorization = headers.getAll(AUTHORIZATION); - Assert.assertArrayEquals(new String[]{"Bearer allyourbase"}, - Iterables.toArray(authorization, String.class)); - } - - @Test - public void verifyServiceUri() throws IOException { - ClientCall interceptedCall; - - doReturn("example.com:443").when(channel).authority(); - interceptedCall = interceptor.interceptCall(descriptor, CallOptions.DEFAULT, channel); - interceptedCall.start(listener, new Metadata()); - verify(credentials).getRequestMetadata(URI.create("https://example.com/a.service")); - interceptedCall.cancel("Cancel for test", null); - - doReturn("example.com:123").when(channel).authority(); - interceptedCall = interceptor.interceptCall(descriptor, CallOptions.DEFAULT, channel); - interceptedCall.start(listener, new Metadata()); - verify(credentials).getRequestMetadata(URI.create("https://example.com:123/a.service")); - interceptedCall.cancel("Cancel for test", null); + verify(channel).newCall(same(descriptor), callOptionsCaptor.capture()); + GoogleAuthLibraryCallCredentials callCredentials = + (GoogleAuthLibraryCallCredentials) callOptionsCaptor.getValue().getCredentials(); + assertSame(credentials, callCredentials.creds); } } diff --git a/auth/src/test/java/io/grpc/auth/GoogleAuthLibraryCallCredentialsTests.java b/auth/src/test/java/io/grpc/auth/GoogleAuthLibraryCallCredentialsTests.java new file mode 100644 index 0000000000..d99812f3db --- /dev/null +++ b/auth/src/test/java/io/grpc/auth/GoogleAuthLibraryCallCredentialsTests.java @@ -0,0 +1,249 @@ +/* + * Copyright 2016, Google Inc. All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * + * * Neither the name of Google Inc. nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package io.grpc.auth; + +import static com.google.common.base.Charsets.US_ASCII; +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.mockito.Matchers.any; +import static org.mockito.Matchers.eq; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.google.auth.Credentials; +import com.google.auth.oauth2.AccessToken; +import com.google.auth.oauth2.OAuth2Credentials; +import com.google.common.collect.Iterables; +import com.google.common.collect.LinkedListMultimap; +import com.google.common.collect.ListMultimap; +import com.google.common.collect.Multimaps; + +import io.grpc.Attributes; +import io.grpc.CallCredentials.MetadataApplier; +import io.grpc.CallCredentials; +import io.grpc.Metadata; +import io.grpc.MethodDescriptor; +import io.grpc.SecurityLevel; +import io.grpc.Status; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.mockito.invocation.InvocationOnMock; +import org.mockito.stubbing.Answer; + +import java.io.IOException; +import java.net.URI; +import java.util.ArrayList; +import java.util.Date; +import java.util.concurrent.Executor; + +/** + * Tests for {@link GoogleAuthLibraryCallCredentials}. + */ +@RunWith(JUnit4.class) +public class GoogleAuthLibraryCallCredentialsTests { + + private static final Metadata.Key AUTHORIZATION = Metadata.Key.of("Authorization", + Metadata.ASCII_STRING_MARSHALLER); + private static final Metadata.Key EXTRA_AUTHORIZATION = Metadata.Key.of( + "Extra-Authorization-bin", Metadata.BINARY_BYTE_MARSHALLER); + + @Mock + private MethodDescriptor.Marshaller stringMarshaller; + + @Mock + private MethodDescriptor.Marshaller intMarshaller; + + @Mock + private Credentials credentials; + + @Mock + private MetadataApplier applier; + + @Mock + private Executor executor; + + @Captor + private ArgumentCaptor headersCaptor; + + @Captor + private ArgumentCaptor statusCaptor; + + private MethodDescriptor method; + private URI expectedUri; + + private final String authority = "testauthority"; + private final Attributes attrs = Attributes.newBuilder() + .set(CallCredentials.ATTR_AUTHORITY, authority) + .set(CallCredentials.ATTR_SECURITY_LEVEL, SecurityLevel.PRIVACY_AND_INTEGRITY) + .build(); + + private ArrayList pendingRunnables = new ArrayList(); + + @Before + public void setUp() throws Exception { + MockitoAnnotations.initMocks(this); + method = MethodDescriptor.create( + MethodDescriptor.MethodType.UNKNOWN, "a.service/method", stringMarshaller, intMarshaller); + expectedUri = new URI("https://testauthority/a.service"); + doAnswer(new Answer() { + @Override + public Void answer(InvocationOnMock invocation) { + Runnable r = (Runnable) invocation.getArguments()[0]; + pendingRunnables.add(r); + return null; + } + }).when(executor).execute(any(Runnable.class)); + } + + @Test + public void copyCredentialsToHeaders() throws Exception { + ListMultimap values = LinkedListMultimap.create(); + values.put("Authorization", "token1"); + values.put("Authorization", "token2"); + values.put("Extra-Authorization-bin", "dG9rZW4z"); // bytes "token3" in base64 + values.put("Extra-Authorization-bin", "dG9rZW40"); // bytes "token4" in base64 + when(credentials.getRequestMetadata(eq(expectedUri))).thenReturn(Multimaps.asMap(values)); + + GoogleAuthLibraryCallCredentials callCredentials = + new GoogleAuthLibraryCallCredentials(credentials); + callCredentials.applyRequestMetadata(method, attrs, executor, applier); + assertEquals(1, runPendingRunnables()); + + verify(credentials).getRequestMetadata(eq(expectedUri)); + verify(applier).apply(headersCaptor.capture()); + Metadata headers = headersCaptor.getValue(); + Iterable authorization = headers.getAll(AUTHORIZATION); + assertArrayEquals(new String[]{"token1", "token2"}, + Iterables.toArray(authorization, String.class)); + Iterable extraAuthorization = headers.getAll(EXTRA_AUTHORIZATION); + assertEquals(2, Iterables.size(extraAuthorization)); + assertArrayEquals("token3".getBytes(US_ASCII), Iterables.get(extraAuthorization, 0)); + assertArrayEquals("token4".getBytes(US_ASCII), Iterables.get(extraAuthorization, 1)); + } + + @Test + public void invalidBase64() throws Exception { + ListMultimap values = LinkedListMultimap.create(); + values.put("Extra-Authorization-bin", "dG9rZW4z1"); // invalid base64 + when(credentials.getRequestMetadata(eq(expectedUri))).thenReturn(Multimaps.asMap(values)); + + GoogleAuthLibraryCallCredentials callCredentials = + new GoogleAuthLibraryCallCredentials(credentials); + callCredentials.applyRequestMetadata(method, attrs, executor, applier); + assertEquals(1, runPendingRunnables()); + + verify(credentials).getRequestMetadata(eq(expectedUri)); + verify(applier).fail(statusCaptor.capture()); + Status status = statusCaptor.getValue(); + assertEquals(Status.Code.UNAUTHENTICATED, status.getCode()); + assertEquals(IllegalArgumentException.class, status.getCause().getClass()); + } + + @Test + public void credentialsThrows() throws Exception { + IOException exception = new IOException("Broken"); + when(credentials.getRequestMetadata(eq(expectedUri))).thenThrow(exception); + + GoogleAuthLibraryCallCredentials callCredentials = + new GoogleAuthLibraryCallCredentials(credentials); + callCredentials.applyRequestMetadata(method, attrs, executor, applier); + assertEquals(1, runPendingRunnables()); + + verify(credentials).getRequestMetadata(eq(expectedUri)); + verify(applier).fail(statusCaptor.capture()); + Status status = statusCaptor.getValue(); + assertEquals(Status.Code.UNAUTHENTICATED, status.getCode()); + assertEquals(exception, status.getCause()); + } + + @Test + public void oauth2Credential() { + final AccessToken token = new AccessToken("allyourbase", new Date(Long.MAX_VALUE)); + final OAuth2Credentials credentials = new OAuth2Credentials() { + @Override + public AccessToken refreshAccessToken() throws IOException { + return token; + } + }; + + GoogleAuthLibraryCallCredentials callCredentials = + new GoogleAuthLibraryCallCredentials(credentials); + callCredentials.applyRequestMetadata(method, attrs, executor, applier); + assertEquals(1, runPendingRunnables()); + + verify(applier).apply(headersCaptor.capture()); + Metadata headers = headersCaptor.getValue(); + Iterable authorization = headers.getAll(AUTHORIZATION); + assertArrayEquals(new String[]{"Bearer allyourbase"}, + Iterables.toArray(authorization, String.class)); + } + + @Test + public void serviceUri() throws Exception { + GoogleAuthLibraryCallCredentials callCredentials = + new GoogleAuthLibraryCallCredentials(credentials); + callCredentials.applyRequestMetadata(method, + Attributes.newBuilder() + .setAll(attrs) + .set(CallCredentials.ATTR_AUTHORITY, "example.com:443") + .build(), + executor, applier); + assertEquals(1, runPendingRunnables()); + verify(credentials).getRequestMetadata(eq(new URI("https://example.com/a.service"))); + + callCredentials.applyRequestMetadata(method, + Attributes.newBuilder() + .setAll(attrs) + .set(CallCredentials.ATTR_AUTHORITY, "example.com:123") + .build(), + executor, applier); + assertEquals(1, runPendingRunnables()); + verify(credentials).getRequestMetadata(eq(new URI("https://example.com:123/a.service"))); + } + + private int runPendingRunnables() { + ArrayList savedPendingRunnables = pendingRunnables; + pendingRunnables = new ArrayList(); + for (Runnable r : savedPendingRunnables) { + r.run(); + } + return savedPendingRunnables.size(); + } +} diff --git a/build.gradle b/build.gradle index 6d9e36ec94..62af69b251 100644 --- a/build.gradle +++ b/build.gradle @@ -134,7 +134,8 @@ subprojects { guava: "com.google.guava:guava:${guavaVersion}", hpack: 'com.twitter:hpack:0.10.1', jsr305: 'com.google.code.findbugs:jsr305:3.0.0', - oauth_client: 'com.google.auth:google-auth-library-oauth2-http:0.3.0', + oauth_client: 'com.google.auth:google-auth-library-oauth2-http:0.4.0', + google_auth_credentials: 'com.google.auth:google-auth-library-credentials:0.4.0', okhttp: 'com.squareup.okhttp:okhttp:2.5.0', okio: 'com.squareup.okio:okio:1.6.0', protobuf: "com.google.protobuf:protobuf-java:${protobufVersion}", diff --git a/interop-testing/src/main/java/io/grpc/testing/integration/AbstractInteropTest.java b/interop-testing/src/main/java/io/grpc/testing/integration/AbstractInteropTest.java index b3fc2ca0a7..62b1476eb3 100644 --- a/interop-testing/src/main/java/io/grpc/testing/integration/AbstractInteropTest.java +++ b/interop-testing/src/main/java/io/grpc/testing/integration/AbstractInteropTest.java @@ -66,7 +66,7 @@ import io.grpc.ServerInterceptor; import io.grpc.ServerInterceptors; import io.grpc.Status; import io.grpc.StatusRuntimeException; -import io.grpc.auth.ClientAuthInterceptor; +import io.grpc.auth.MoreCallCredentials; import io.grpc.internal.GrpcUtil; import io.grpc.protobuf.ProtoUtils; import io.grpc.stub.MetadataUtils; @@ -802,8 +802,7 @@ public abstract class AbstractInteropTest { ServiceAccountCredentials.class.cast(GoogleCredentials.fromStream(credentialsStream)); credentials = credentials.createScoped(Arrays.asList(authScope)); TestServiceGrpc.TestServiceBlockingStub stub = blockingStub - .withInterceptors(new ClientAuthInterceptor(credentials, - Executors.newSingleThreadExecutor())); + .withCallCredentials(MoreCallCredentials.from(credentials)); final SimpleRequest request = SimpleRequest.newBuilder() .setFillUsername(true) .setFillOauthScope(true) @@ -835,8 +834,7 @@ public abstract class AbstractInteropTest { public void computeEngineCreds(String serviceAccount, String oauthScope) throws Exception { ComputeEngineCredentials credentials = new ComputeEngineCredentials(); TestServiceGrpc.TestServiceBlockingStub stub = blockingStub - .withInterceptors(new ClientAuthInterceptor(credentials, - Executors.newSingleThreadExecutor())); + .withCallCredentials(MoreCallCredentials.from(credentials)); final SimpleRequest request = SimpleRequest.newBuilder() .setFillUsername(true) .setFillOauthScope(true) @@ -878,8 +876,7 @@ public abstract class AbstractInteropTest { origCreds.getClientId(), origCreds.getClientEmail(), origCreds.getPrivateKey(), origCreds.getPrivateKeyId()); TestServiceGrpc.TestServiceBlockingStub stub = blockingStub - .withInterceptors(new ClientAuthInterceptor(credentials, - Executors.newSingleThreadExecutor())); + .withCallCredentials(MoreCallCredentials.from(credentials)); SimpleResponse response = stub.unaryCall(request); assertEquals(origCreds.getClientEmail(), response.getUsername()); assertEquals(314159, response.getPayload().getBody().size()); @@ -904,8 +901,7 @@ public abstract class AbstractInteropTest { }; TestServiceGrpc.TestServiceBlockingStub stub = blockingStub - .withInterceptors(new ClientAuthInterceptor(credentials, - Executors.newSingleThreadExecutor())); + .withCallCredentials(MoreCallCredentials.from(credentials)); final SimpleRequest request = SimpleRequest.newBuilder() .setFillUsername(true) .setFillOauthScope(true)