Skip to content

Commit

Permalink
feat: add impersonation credentials to ADC (#613)
Browse files Browse the repository at this point in the history
* ADC can load impersonation credentials

* Add tests for new features in ImpersonationCredentials

* Add tests for GoogleCredentials

* Fix linter errors

* Fix linter errors in ImpersonatedCredentialsTest

* Fix issues after receiving comments

* Fix lint errors

* Handle ClassCastException in fromJson

* Fix lint errors

* minor refactoring

* fix doc strings

* fix lint errors

* delegates can be missing from the json file

* Mark test using @test()

* Remove redundant methods and handle exceptions

* add an empty file

* remove an empty file

* Fix docstring and move one variable to inner scope.

* Refactor ImpersonatedCredentialsTest

* Reformat the ImpersonatedCredentialsTest

* Remove redundant checks in tests

* Use VisibleForTesting annotation to limit visibility

Co-authored-by: Elliotte Rusty Harold <elharo@users.noreply.github.com>
  • Loading branch information
liuchaoren and elharo committed May 21, 2021
1 parent 3f90c81 commit b9823f7
Show file tree
Hide file tree
Showing 5 changed files with 644 additions and 181 deletions.
Expand Up @@ -200,7 +200,8 @@ private ImpersonatedCredentials initializeImpersonatedCredentials() {
.build();
}

String targetPrincipal = extractTargetPrincipal(serviceAccountImpersonationUrl);
String targetPrincipal =
ImpersonatedCredentials.extractTargetPrincipal(serviceAccountImpersonationUrl);
return ImpersonatedCredentials.newBuilder()
.setSourceCredentials(sourceCredentials)
.setHttpTransportFactory(transportFactory)
Expand Down Expand Up @@ -359,19 +360,6 @@ protected AccessToken exchangeExternalCredentialForAccessToken(
return response.getAccessToken();
}

private static String extractTargetPrincipal(String serviceAccountImpersonationUrl) {
// Extract the target principal.
int startIndex = serviceAccountImpersonationUrl.lastIndexOf('/');
int endIndex = serviceAccountImpersonationUrl.indexOf(":generateAccessToken");

if (startIndex != -1 && endIndex != -1 && startIndex < endIndex) {
return serviceAccountImpersonationUrl.substring(startIndex + 1, endIndex);
} else {
throw new IllegalArgumentException(
"Unable to determine target principal from service account impersonation URL.");
}
}

/**
* Retrieves the external subject token to be exchanged for a GCP access token.
*
Expand Down
11 changes: 9 additions & 2 deletions oauth2_http/java/com/google/auth/oauth2/GoogleCredentials.java
Expand Up @@ -78,8 +78,12 @@ public static GoogleCredentials create(AccessToken accessToken) {
* <ol>
* <li>Credentials file pointed to by the {@code GOOGLE_APPLICATION_CREDENTIALS} environment
* variable
* <li>Credentials provided by the Google Cloud SDK {@code gcloud auth application-default
* login} command
* <li>Credentials provided by the Google Cloud SDK.
* <ol>
* <li>{@code gcloud auth application-default login} for user account credentials.
* <li>{@code gcloud auth application-default login --impersonate-service-account} for
* impersonated service account credentials.
* </ol>
* <li>Google App Engine built-in credentials
* <li>Google Cloud Shell built-in credentials
* <li>Google Compute Engine built-in credentials
Expand Down Expand Up @@ -169,6 +173,9 @@ public static GoogleCredentials fromStream(
if (ExternalAccountCredentials.EXTERNAL_ACCOUNT_FILE_TYPE.equals(fileType)) {
return ExternalAccountCredentials.fromJson(fileContents, transportFactory);
}
if ("impersonated_service_account".equals(fileType)) {
return ImpersonatedCredentials.fromJson(fileContents, transportFactory);
}
throw new IOException(
String.format(
"Error reading credentials from stream, 'type' value '%s' not recognized."
Expand Down
189 changes: 184 additions & 5 deletions oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java
Expand Up @@ -32,6 +32,7 @@
package com.google.auth.oauth2;

import static com.google.common.base.MoreObjects.firstNonNull;
import static com.google.common.base.Preconditions.checkNotNull;

import com.google.api.client.http.GenericUrl;
import com.google.api.client.http.HttpContent;
Expand All @@ -45,6 +46,7 @@
import com.google.auth.ServiceAccountSigner;
import com.google.auth.http.HttpCredentialsAdapter;
import com.google.auth.http.HttpTransportFactory;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.MoreObjects;
import com.google.common.collect.ImmutableMap;
import java.io.IOException;
Expand All @@ -53,6 +55,7 @@
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Date;
import java.util.List;
import java.util.Map;
Expand Down Expand Up @@ -85,7 +88,7 @@
* </pre>
*/
public class ImpersonatedCredentials extends GoogleCredentials
implements ServiceAccountSigner, IdTokenProvider {
implements ServiceAccountSigner, IdTokenProvider, QuotaProjectIdProvider {

private static final long serialVersionUID = -2133257318957488431L;
private static final String RFC3339 = "yyyy-MM-dd'T'HH:mm:ss'Z'";
Expand All @@ -101,12 +104,14 @@ public class ImpersonatedCredentials extends GoogleCredentials
private List<String> delegates;
private List<String> scopes;
private int lifetime;
private String quotaProjectId;
private final String transportFactoryClassName;

private transient HttpTransportFactory transportFactory;

/**
* @param sourceCredentials the source credential used as to acquire the impersonated credentials
* @param sourceCredentials the source credential used to acquire the impersonated credentials. It
* should be either a user account credential or a service account credential.
* @param targetPrincipal the service account to impersonate
* @param delegates the chained list of delegates required to grant the final access_token. If
* set, the sequence of identities must have "Service Account Token Creator" capability
Expand Down Expand Up @@ -144,7 +149,52 @@ public static ImpersonatedCredentials create(
}

/**
* @param sourceCredentials the source credential used as to acquire the impersonated credentials
* @param sourceCredentials the source credential used to acquire the impersonated credentials. It
* should be either a user account credential or a service account credential.
* @param targetPrincipal the service account to impersonate
* @param delegates the chained list of delegates required to grant the final access_token. If
* set, the sequence of identities must have "Service Account Token Creator" capability
* granted to the preceding identity. For example, if set to [serviceAccountB,
* serviceAccountC], the sourceCredential must have the Token Creator role on serviceAccountB.
* serviceAccountB must have the Token Creator on serviceAccountC. Finally, C must have Token
* Creator on target_principal. If unset, sourceCredential must have that role on
* targetPrincipal.
* @param scopes scopes to request during the authorization grant
* @param lifetime number of seconds the delegated credential should be valid. By default this
* value should be at most 3600. However, you can follow <a
* href='https://cloud.google.com/iam/docs/creating-short-lived-service-account-credentials#sa-credentials-oauth'>these
* instructions</a> to set up the service account and extend the maximum lifetime to 43200 (12
* hours). If the given lifetime is 0, default value 3600 will be used instead when creating
* the credentials.
* @param transportFactory HTTP transport factory that creates the transport used to get access
* tokens.
* @param quotaProjectId the project used for quota and billing purposes. Should be null unless
* the caller wants to use a project different from the one that owns the impersonated
* credential for billing/quota purposes.
* @return new credentials
*/
public static ImpersonatedCredentials create(
GoogleCredentials sourceCredentials,
String targetPrincipal,
List<String> delegates,
List<String> scopes,
int lifetime,
HttpTransportFactory transportFactory,
String quotaProjectId) {
return ImpersonatedCredentials.newBuilder()
.setSourceCredentials(sourceCredentials)
.setTargetPrincipal(targetPrincipal)
.setDelegates(delegates)
.setScopes(scopes)
.setLifetime(lifetime)
.setHttpTransportFactory(transportFactory)
.setQuotaProjectId(quotaProjectId)
.build();
}

/**
* @param sourceCredentials the source credential used to acquire the impersonated credentials. It
* should be either a user account credential or a service account credential.
* @param targetPrincipal the service account to impersonate
* @param delegates the chained list of delegates required to grant the final access_token. If
* set, the sequence of identities must have "Service Account Token Creator" capability
Expand Down Expand Up @@ -179,6 +229,19 @@ public static ImpersonatedCredentials create(
.build();
}

static String extractTargetPrincipal(String serviceAccountImpersonationUrl) {
// Extract the target principal.
int startIndex = serviceAccountImpersonationUrl.lastIndexOf('/');
int endIndex = serviceAccountImpersonationUrl.indexOf(":generateAccessToken");

if (startIndex != -1 && endIndex != -1 && startIndex < endIndex) {
return serviceAccountImpersonationUrl.substring(startIndex + 1, endIndex);
} else {
throw new IllegalArgumentException(
"Unable to determine target principal from service account impersonation URL.");
}
}

/**
* Returns the email field of the serviceAccount that is being impersonated.
*
Expand All @@ -189,10 +252,33 @@ public String getAccount() {
return this.targetPrincipal;
}

@Override
public String getQuotaProjectId() {
return this.quotaProjectId;
}

@VisibleForTesting
List<String> getDelegates() {
return delegates;
}

@VisibleForTesting
List<String> getScopes() {
return scopes;
}

public GoogleCredentials getSourceCredentials() {
return sourceCredentials;
}

int getLifetime() {
return this.lifetime;
}

public void setTransportFactory(HttpTransportFactory httpTransportFactory) {
this.transportFactory = httpTransportFactory;
}

/**
* Signs the provided bytes using the private key associated with the impersonated service account
*
Expand All @@ -213,6 +299,89 @@ public byte[] sign(byte[] toSign) {
ImmutableMap.of("delegates", this.delegates));
}

/**
* Returns impersonation account credentials defined by JSON using the format generated by gCloud.
* The source credentials in the JSON should be either user account credentials or service account
* credentials.
*
* @param json a map from the JSON representing the credentials
* @param transportFactory HTTP transport factory, creates the transport used to get access tokens
* @return the credentials defined by the JSON
* @throws IOException if the credential cannot be created from the JSON.
*/
static ImpersonatedCredentials fromJson(
Map<String, Object> json, HttpTransportFactory transportFactory) throws IOException {

checkNotNull(json);
checkNotNull(transportFactory);

List<String> delegates = null;
Map<String, Object> sourceCredentialsJson;
String sourceCredentialsType;
String quotaProjectId;
String targetPrincipal;
try {
String serviceAccountImpersonationUrl =
(String) json.get("service_account_impersonation_url");
if (json.containsKey("delegates")) {
delegates = (List<String>) json.get("delegates");
}
sourceCredentialsJson = (Map<String, Object>) json.get("source_credentials");
sourceCredentialsType = (String) sourceCredentialsJson.get("type");
quotaProjectId = (String) json.get("quota_project_id");
targetPrincipal = extractTargetPrincipal(serviceAccountImpersonationUrl);
} catch (ClassCastException | NullPointerException | IllegalArgumentException e) {
throw new CredentialFormatException("An invalid input stream was provided.", e);
}

GoogleCredentials sourceCredentials;
if (GoogleCredentials.USER_FILE_TYPE.equals(sourceCredentialsType)) {
sourceCredentials = UserCredentials.fromJson(sourceCredentialsJson, transportFactory);
} else if (GoogleCredentials.SERVICE_ACCOUNT_FILE_TYPE.equals(sourceCredentialsType)) {
sourceCredentials =
ServiceAccountCredentials.fromJson(sourceCredentialsJson, transportFactory);
} else {
throw new IOException(
String.format(
"A credential of type %s is not supported as source credential for impersonation.",
sourceCredentialsType));
}
return ImpersonatedCredentials.newBuilder()
.setSourceCredentials(sourceCredentials)
.setTargetPrincipal(targetPrincipal)
.setDelegates(delegates)
.setScopes(new ArrayList<String>())
.setLifetime(DEFAULT_LIFETIME_IN_SECONDS)
.setHttpTransportFactory(transportFactory)
.setQuotaProjectId(quotaProjectId)
.build();
}

@Override
public boolean createScopedRequired() {
return this.scopes == null || this.scopes.isEmpty();
}

@Override
public GoogleCredentials createScoped(Collection<String> scopes) {
return toBuilder()
.setScopes((List<String>) scopes)
.setLifetime(this.lifetime)
.setDelegates(this.delegates)
.setHttpTransportFactory(this.transportFactory)
.setQuotaProjectId(this.quotaProjectId)
.build();
}

@Override
protected Map<String, List<String>> getAdditionalHeaders() {
Map<String, List<String>> headers = super.getAdditionalHeaders();
if (quotaProjectId != null) {
return addQuotaProjectIdToRequestMetadata(quotaProjectId, headers);
}
return headers;
}

private ImpersonatedCredentials(Builder builder) {
this.sourceCredentials = builder.getSourceCredentials();
this.targetPrincipal = builder.getTargetPrincipal();
Expand All @@ -223,6 +392,7 @@ private ImpersonatedCredentials(Builder builder) {
firstNonNull(
builder.getHttpTransportFactory(),
getFromServiceLoader(HttpTransportFactory.class, OAuth2Utils.HTTP_TRANSPORT_FACTORY));
this.quotaProjectId = builder.quotaProjectId;
this.transportFactoryClassName = this.transportFactory.getClass().getName();
if (this.delegates == null) {
this.delegates = new ArrayList<String>();
Expand Down Expand Up @@ -318,7 +488,8 @@ public IdToken idTokenWithAudience(String targetAudience, List<IdTokenProvider.O

@Override
public int hashCode() {
return Objects.hash(sourceCredentials, targetPrincipal, delegates, scopes, lifetime);
return Objects.hash(
sourceCredentials, targetPrincipal, delegates, scopes, lifetime, quotaProjectId);
}

@Override
Expand All @@ -330,6 +501,7 @@ public String toString() {
.add("scopes", scopes)
.add("lifetime", lifetime)
.add("transportFactoryClassName", transportFactoryClassName)
.add("quotaProjectId", quotaProjectId)
.toString();
}

Expand All @@ -344,7 +516,8 @@ public boolean equals(Object obj) {
&& Objects.equals(this.delegates, other.delegates)
&& Objects.equals(this.scopes, other.scopes)
&& Objects.equals(this.lifetime, other.lifetime)
&& Objects.equals(this.transportFactoryClassName, other.transportFactoryClassName);
&& Objects.equals(this.transportFactoryClassName, other.transportFactoryClassName)
&& Objects.equals(this.quotaProjectId, other.quotaProjectId);
}

public Builder toBuilder() {
Expand All @@ -363,6 +536,7 @@ public static class Builder extends GoogleCredentials.Builder {
private List<String> scopes;
private int lifetime = DEFAULT_LIFETIME_IN_SECONDS;
private HttpTransportFactory transportFactory;
private String quotaProjectId;

protected Builder() {}

Expand Down Expand Up @@ -425,6 +599,11 @@ public HttpTransportFactory getHttpTransportFactory() {
return transportFactory;
}

public Builder setQuotaProjectId(String quotaProjectId) {
this.quotaProjectId = quotaProjectId;
return this;
}

public ImpersonatedCredentials build() {
return new ImpersonatedCredentials(this);
}
Expand Down

0 comments on commit b9823f7

Please sign in to comment.