auth: Promote OAuth2 service accounts to JWT

JWT needs less configuration and zero round-trips to initialize.

Fixes #785
This commit is contained in:
Eric Anderson 2016-06-24 12:09:04 -07:00
parent c5733742ce
commit 31651f369f
3 changed files with 167 additions and 7 deletions

View File

@ -45,17 +45,27 @@ import io.grpc.MethodDescriptor;
import io.grpc.Status;
import io.grpc.StatusException;
import java.lang.reflect.Constructor;
import java.lang.reflect.Method;
import java.net.URI;
import java.net.URISyntaxException;
import java.security.PrivateKey;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.concurrent.Executor;
import java.util.logging.Level;
import java.util.logging.Logger;
import javax.annotation.Nullable;
/**
* Wraps {@link Credentials} as a {@link CallCredentials}.
*/
final class GoogleAuthLibraryCallCredentials implements CallCredentials {
private static final Logger log
= Logger.getLogger(GoogleAuthLibraryCallCredentials.class.getName());
private static final JwtHelper jwtHelper
= createJwtHelperOrNull(GoogleAuthLibraryCallCredentials.class.getClassLoader());
@VisibleForTesting
final Credentials creds;
@ -64,7 +74,16 @@ final class GoogleAuthLibraryCallCredentials implements CallCredentials {
private Map<String, List<String>> lastMetadata;
public GoogleAuthLibraryCallCredentials(Credentials creds) {
this.creds = checkNotNull(creds, "creds");
this(creds, jwtHelper);
}
@VisibleForTesting
GoogleAuthLibraryCallCredentials(Credentials creds, JwtHelper jwtHelper) {
checkNotNull(creds, "creds");
if (jwtHelper != null) {
creds = jwtHelper.tryServiceAccountToJwt(creds);
}
this.creds = creds;
}
@Override
@ -168,4 +187,74 @@ final class GoogleAuthLibraryCallCredentials implements CallCredentials {
}
return headers;
}
@VisibleForTesting
@Nullable
static JwtHelper createJwtHelperOrNull(ClassLoader loader) {
Class<?> rawServiceAccountClass;
try {
// Specify loader so it can be overriden in tests
rawServiceAccountClass
= Class.forName("com.google.auth.oauth2.ServiceAccountCredentials", false, loader);
} catch (ClassNotFoundException ex) {
return null;
}
try {
return new JwtHelper(rawServiceAccountClass, loader);
} catch (ReflectiveOperationException ex) {
// Failure is a bug in this class, but we still choose to gracefully recover
log.log(Level.WARNING, "Failed to create JWT helper. This is unexpected", ex);
return null;
}
}
@VisibleForTesting
static class JwtHelper {
private final Class<? extends Credentials> serviceAccountClass;
private final Constructor<? extends Credentials> jwtConstructor;
private final Method getScopes;
private final Method getClientId;
private final Method getClientEmail;
private final Method getPrivateKey;
private final Method getPrivateKeyId;
public JwtHelper(Class<?> rawServiceAccountClass, ClassLoader loader)
throws ReflectiveOperationException {
serviceAccountClass = rawServiceAccountClass.asSubclass(Credentials.class);
getScopes = serviceAccountClass.getMethod("getScopes");
getClientId = serviceAccountClass.getMethod("getClientId");
getClientEmail = serviceAccountClass.getMethod("getClientEmail");
getPrivateKey = serviceAccountClass.getMethod("getPrivateKey");
getPrivateKeyId = serviceAccountClass.getMethod("getPrivateKeyId");
Class<? extends Credentials> jwtClass = Class.forName(
"com.google.auth.oauth2.ServiceAccountJwtAccessCredentials", false, loader)
.asSubclass(Credentials.class);
jwtConstructor
= jwtClass.getConstructor(String.class, String.class, PrivateKey.class, String.class);
}
public Credentials tryServiceAccountToJwt(Credentials creds) {
if (!serviceAccountClass.isInstance(creds)) {
return creds;
}
try {
creds = serviceAccountClass.cast(creds);
Collection<?> scopes = (Collection<?>) getScopes.invoke(creds);
if (scopes.size() != 0) {
// Leave as-is, since the scopes may limit access within the service.
return creds;
}
return jwtConstructor.newInstance(
getClientId.invoke(creds),
getClientEmail.invoke(creds),
getPrivateKey.invoke(creds),
getPrivateKeyId.invoke(creds));
} catch (ReflectiveOperationException ex) {
// Failure is a bug in this class, but we still choose to gracefully recover
log.log(Level.WARNING,
"Failed converting service account credential to JWT. This is unexpected", ex);
return creds;
}
}
}
}

View File

@ -34,6 +34,8 @@ 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.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
import static org.mockito.Matchers.any;
import static org.mockito.Matchers.eq;
import static org.mockito.Mockito.doAnswer;
@ -44,6 +46,7 @@ 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.auth.oauth2.ServiceAccountCredentials;
import com.google.common.collect.Iterables;
import com.google.common.collect.LinkedListMultimap;
import com.google.common.collect.ListMultimap;
@ -70,7 +73,10 @@ import org.mockito.stubbing.Answer;
import java.io.IOException;
import java.net.URI;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Date;
import java.util.List;
import java.util.concurrent.Executor;
@ -269,6 +275,75 @@ public class GoogleAuthLibraryCallCredentialsTests {
verify(credentials).getRequestMetadata(eq(new URI("https://example.com:123/a.service")));
}
@Test
public void serviceAccountToJwt() throws Exception {
KeyPair pair = KeyPairGenerator.getInstance("RSA").generateKeyPair();
ServiceAccountCredentials credentials = new ServiceAccountCredentials(
null, "email@example.com", pair.getPrivate(), null, null) {
@Override
public AccessToken refreshAccessToken() {
throw new AssertionError();
}
};
GoogleAuthLibraryCallCredentials callCredentials =
new GoogleAuthLibraryCallCredentials(credentials);
callCredentials.applyRequestMetadata(method, attrs, executor, applier);
assertEquals(1, runPendingRunnables());
verify(applier).apply(headersCaptor.capture());
Metadata headers = headersCaptor.getValue();
String[] authorization = Iterables.toArray(headers.getAll(AUTHORIZATION), String.class);
assertEquals(1, authorization.length);
assertTrue(authorization[0], authorization[0].startsWith("Bearer "));
// JWT is reasonably long. Normal tokens aren't.
assertTrue(authorization[0], authorization[0].length() > 300);
}
@Test
public void serviceAccountWithScopeNotToJwt() throws Exception {
final AccessToken token = new AccessToken("allyourbase", new Date(Long.MAX_VALUE));
KeyPair pair = KeyPairGenerator.getInstance("RSA").generateKeyPair();
ServiceAccountCredentials credentials = new ServiceAccountCredentials(
null, "email@example.com", pair.getPrivate(), null, Arrays.asList("somescope")) {
@Override
public AccessToken refreshAccessToken() {
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<String> authorization = headers.getAll(AUTHORIZATION);
assertArrayEquals(new String[]{"Bearer allyourbase"},
Iterables.toArray(authorization, String.class));
}
@Test
public void oauthClassesNotInClassPath() throws Exception {
ListMultimap<String, String> values = LinkedListMultimap.create();
values.put("Authorization", "token1");
when(credentials.getRequestMetadata(eq(expectedUri))).thenReturn(Multimaps.asMap(values));
assertNull(GoogleAuthLibraryCallCredentials.createJwtHelperOrNull(null));
GoogleAuthLibraryCallCredentials callCredentials =
new GoogleAuthLibraryCallCredentials(credentials, null);
callCredentials.applyRequestMetadata(method, attrs, executor, applier);
assertEquals(1, runPendingRunnables());
verify(credentials).getRequestMetadata(eq(expectedUri));
verify(applier).apply(headersCaptor.capture());
Metadata headers = headersCaptor.getValue();
Iterable<String> authorization = headers.getAll(AUTHORIZATION);
assertArrayEquals(new String[]{"token1"},
Iterables.toArray(authorization, String.class));
}
private int runPendingRunnables() {
ArrayList<Runnable> savedPendingRunnables = pendingRunnables;
pendingRunnables = new ArrayList<Runnable>();

View File

@ -48,7 +48,6 @@ import com.google.auth.oauth2.ComputeEngineCredentials;
import com.google.auth.oauth2.GoogleCredentials;
import com.google.auth.oauth2.OAuth2Credentials;
import com.google.auth.oauth2.ServiceAccountCredentials;
import com.google.auth.oauth2.ServiceAccountJwtAccessCredentials;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.Lists;
@ -876,15 +875,12 @@ public abstract class AbstractInteropTest {
.setFillUsername(true)
.build();
ServiceAccountCredentials origCreds = (ServiceAccountCredentials)
ServiceAccountCredentials credentials = (ServiceAccountCredentials)
GoogleCredentials.fromStream(serviceAccountJson);
ServiceAccountJwtAccessCredentials credentials = new ServiceAccountJwtAccessCredentials(
origCreds.getClientId(), origCreds.getClientEmail(), origCreds.getPrivateKey(),
origCreds.getPrivateKeyId());
TestServiceGrpc.TestServiceBlockingStub stub = blockingStub
.withCallCredentials(MoreCallCredentials.from(credentials));
SimpleResponse response = stub.unaryCall(request);
assertEquals(origCreds.getClientEmail(), response.getUsername());
assertEquals(credentials.getClientEmail(), response.getUsername());
assertEquals(314159, response.getPayload().getBody().size());
}