Skip to content

Commit

Permalink
feat: service sccount to service account impersonation to support uni…
Browse files Browse the repository at this point in the history
…verse domain (#1528)

for context: b/340602527

Changes in this pr:
- Override `getUniverseDomain()` to grab source credentials’s universe domain (UD) by default. Always use source credentials UD, not explicit provided UD. (In current design, impersonated credentials may not have universe domain in the outer layer. relay on UD from source credential. This may change in future)
- Fix `isDefaultUniverseDomain()` in `GoogleCredentials` to account for `getUniverseDomain()` overrides in child classes.
- In refreshAccessToken(), use endpoint url pattern to account for TPC case.
  - note that I choose to bypass this refreshIfExpired step because it wrongly steps into code path meant only for OAuth2 token request (GDU flow). Filed #1534 to address this separately. But for GDU flow here, this refresh step is redundant because the SSJ will get re-generated at [initialize request](https://github.com/googleapis/google-auth-library-java/blob/a987ecd06fd25a0048cdb3da6d1df4d029d85d79/oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java#L558). Also skip this step for SA GDU with SSJ flow.
- Throw IllegalStateException if UD is explicitly set (with parent class setter) and not matching source credential's UD

- Fix toBuilder() to invoke super, and fix related issue with createScoped. (see #1489, #1428); Also fix equals() to compare super first.


Not in this pr: 
- idtoken and signBlob endpoint changes are out-of-scope for this pr, will raise separate pr for it.

sa-to-sa impersonation is successfully E2E tested for TPC usage according to [go/prptst-testing-service-account-impersonation](http://goto.google.com/prptst-testing-service-account-impersonation).



---------

Co-authored-by: Blake Li <blakeli@google.com>
  • Loading branch information
zhumin8 and blakeli0 authored Oct 18, 2024
1 parent f154edb commit c498ccf
Show file tree
Hide file tree
Showing 6 changed files with 286 additions and 52 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -263,11 +263,11 @@ protected boolean isExplicitUniverseDomain() {
/**
* Checks if universe domain equals to {@link Credentials#GOOGLE_DEFAULT_UNIVERSE}.
*
* @return true if universeDomain equals to {@link Credentials#GOOGLE_DEFAULT_UNIVERSE}, false
* @return true if universe domain equals to {@link Credentials#GOOGLE_DEFAULT_UNIVERSE}, false
* otherwise
*/
boolean isDefaultUniverseDomain() {
return this.universeDomain.equals(Credentials.GOOGLE_DEFAULT_UNIVERSE);
boolean isDefaultUniverseDomain() throws IOException {
return getUniverseDomain().equals(Credentials.GOOGLE_DEFAULT_UNIVERSE);
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103,9 +103,6 @@ public class ImpersonatedCredentials extends GoogleCredentials
private static final int DEFAULT_LIFETIME_IN_SECONDS = 3600;
private static final String CLOUD_PLATFORM_SCOPE =
"https://www.googleapis.com/auth/cloud-platform";
private static final String IAM_ACCESS_TOKEN_ENDPOINT =
"https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/%s:generateAccessToken";

private GoogleCredentials sourceCredentials;
private String targetPrincipal;
private List<String> delegates;
Expand Down Expand Up @@ -423,14 +420,7 @@ public boolean createScopedRequired() {

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

@Override
Expand All @@ -457,7 +447,7 @@ public ImpersonatedCredentials createWithCustomCalendar(Calendar calendar) {
.build();
}

private ImpersonatedCredentials(Builder builder) {
private ImpersonatedCredentials(Builder builder) throws IOException {
super(builder);
this.sourceCredentials = builder.getSourceCredentials();
this.targetPrincipal = builder.getTargetPrincipal();
Expand All @@ -472,14 +462,36 @@ private ImpersonatedCredentials(Builder builder) {
this.transportFactoryClassName = this.transportFactory.getClass().getName();
this.calendar = builder.getCalendar();
if (this.delegates == null) {
this.delegates = new ArrayList<String>();
this.delegates = new ArrayList<>();
}
if (this.scopes == null) {
throw new IllegalStateException("Scopes cannot be null");
}
if (this.lifetime > TWELVE_HOURS_IN_SECONDS) {
throw new IllegalStateException("lifetime must be less than or equal to 43200");
}

// Do not expect explicit universe domain, throw exception if the explicit universe domain
// does not match the source credential.
// Do nothing if it matches the source credential
if (isExplicitUniverseDomain()
&& !this.sourceCredentials.getUniverseDomain().equals(builder.getUniverseDomain())) {
throw new IllegalStateException(
String.format(
"Universe domain %s in source credentials "
+ "does not match %s universe domain set for impersonated credentials.",
this.sourceCredentials.getUniverseDomain(), builder.getUniverseDomain()));
}
}

/**
* Gets the universe domain for the credential.
*
* @return the universe domain from source credentials
*/
@Override
public String getUniverseDomain() throws IOException {
return this.sourceCredentials.getUniverseDomain();
}

@Override
Expand All @@ -489,10 +501,18 @@ public AccessToken refreshAccessToken() throws IOException {
this.sourceCredentials.createScoped(Arrays.asList(CLOUD_PLATFORM_SCOPE));
}

try {
this.sourceCredentials.refreshIfExpired();
} catch (IOException e) {
throw new IOException("Unable to refresh sourceCredentials", e);
// skip for SA with SSJ flow because it uses self-signed JWT
// and will get refreshed at initialize request step
// run for other source credential types or SA with GDU assert flow
if (!(this.sourceCredentials instanceof ServiceAccountCredentials)
|| (isDefaultUniverseDomain()
&& ((ServiceAccountCredentials) this.sourceCredentials)
.shouldUseAssertionFlowForGdu())) {
try {
this.sourceCredentials.refreshIfExpired();
} catch (IOException e) {
throw new IOException("Unable to refresh sourceCredentials", e);
}
}

HttpTransport httpTransport = this.transportFactory.create();
Expand All @@ -504,7 +524,11 @@ public AccessToken refreshAccessToken() throws IOException {
String endpointUrl =
this.iamEndpointOverride != null
? this.iamEndpointOverride
: String.format(IAM_ACCESS_TOKEN_ENDPOINT, this.targetPrincipal);
: String.format(
OAuth2Utils.IAM_ACCESS_TOKEN_ENDPOINT_FORMAT,
getUniverseDomain(),
this.targetPrincipal);

GenericUrl url = new GenericUrl(endpointUrl);

Map<String, Object> body =
Expand Down Expand Up @@ -603,6 +627,9 @@ public boolean equals(Object obj) {
if (!(obj instanceof ImpersonatedCredentials)) {
return false;
}
if (!super.equals(obj)) {
return false;
}
ImpersonatedCredentials other = (ImpersonatedCredentials) obj;
return Objects.equals(this.sourceCredentials, other.sourceCredentials)
&& Objects.equals(this.targetPrincipal, other.targetPrincipal)
Expand All @@ -616,7 +643,7 @@ public boolean equals(Object obj) {

@Override
public Builder toBuilder() {
return new Builder(this.sourceCredentials, this.targetPrincipal);
return new Builder(this);
}

public static Builder newBuilder() {
Expand All @@ -636,11 +663,29 @@ public static class Builder extends GoogleCredentials.Builder {

protected Builder() {}

/**
* @param sourceCredentials The source credentials to use for impersonation.
* @param targetPrincipal The service account to impersonate.
* @deprecated Use {@link #Builder(ImpersonatedCredentials)} instead. This constructor will be
* removed in a future release.
*/
@Deprecated
protected Builder(GoogleCredentials sourceCredentials, String targetPrincipal) {
this.sourceCredentials = sourceCredentials;
this.targetPrincipal = targetPrincipal;
}

protected Builder(ImpersonatedCredentials credentials) {
super(credentials);
this.sourceCredentials = credentials.sourceCredentials;
this.targetPrincipal = credentials.targetPrincipal;
this.delegates = credentials.delegates;
this.scopes = credentials.scopes;
this.lifetime = credentials.lifetime;
this.transportFactory = credentials.transportFactory;
this.iamEndpointOverride = credentials.iamEndpointOverride;
}

@CanIgnoreReturnValue
public Builder setSourceCredentials(GoogleCredentials sourceCredentials) {
this.sourceCredentials = sourceCredentials;
Expand Down Expand Up @@ -726,7 +771,13 @@ public Calendar getCalendar() {

@Override
public ImpersonatedCredentials build() {
return new ImpersonatedCredentials(this);
try {
return new ImpersonatedCredentials(this);
} catch (IOException e) {
// throwing exception would be breaking change. catching instead.
// this should never happen.
throw new IllegalStateException(e);
}
}
}

Expand Down
5 changes: 5 additions & 0 deletions oauth2_http/java/com/google/auth/oauth2/OAuth2Utils.java
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,11 @@ class OAuth2Utils {
static final String IAM_ID_TOKEN_ENDPOINT_FORMAT =
"https://iamcredentials.%s/v1/projects/-/serviceAccounts/%s:generateIdToken";

static final String IAM_ACCESS_TOKEN_ENDPOINT_FORMAT =
"https://iamcredentials.%s/v1/projects/-/serviceAccounts/%s:generateAccessToken";
static final String SIGN_BLOB_ENDPOINT_FORMAT =
"https://iamcredentials.%s/v1/projects/-/serviceAccounts/%s:signBlob";

static final URI TOKEN_SERVER_URI = URI.create("https://oauth2.googleapis.com/token");

static final URI TOKEN_REVOKE_URI = URI.create("https://oauth2.googleapis.com/revoke");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -992,13 +992,19 @@ public void getRequestMetadata(
// For default universe Self-signed JWT could be explicitly disabled with
// {@code ServiceAccountCredentials.useJwtAccessWithScope} flag.
// If universe is non-default, it only supports self-signed JWT, and it is always allowed.
if (this.useJwtAccessWithScope || !isDefaultUniverseDomain()) {
// This will call getRequestMetadata(URI uri), which handles self-signed JWT logic.
// Self-signed JWT doesn't use network, so here we do a blocking call to improve
// efficiency. executor will be ignored since it is intended for async operation.
blockingGetToCallback(uri, callback);
} else {
super.getRequestMetadata(uri, executor, callback);
try {
if (this.useJwtAccessWithScope || !isDefaultUniverseDomain()) {
// This will call getRequestMetadata(URI uri), which handles self-signed JWT logic.
// Self-signed JWT doesn't use network, so here we do a blocking call to improve
// efficiency. executor will be ignored since it is intended for async operation.
blockingGetToCallback(uri, callback);
} else {
super.getRequestMetadata(uri, executor, callback);
}
} catch (IOException e) {
// Wrap here because throwing exception would be breaking change.
// This should not happen for this credential type.
throw new IllegalStateException(e);
}
}

Expand All @@ -1021,20 +1027,20 @@ public Map<String, List<String>> getRequestMetadata(URI uri) throws IOException

@Override
public CredentialTypeForMetrics getMetricsCredentialType() {
return shouldUseAssertionFlow()
return shouldUseAssertionFlowForGdu()
? CredentialTypeForMetrics.SERVICE_ACCOUNT_CREDENTIALS_AT
: CredentialTypeForMetrics.SERVICE_ACCOUNT_CREDENTIALS_JWT;
}

private boolean shouldUseAssertionFlow() {
boolean shouldUseAssertionFlowForGdu() {
// If scopes are provided, but we cannot use self-signed JWT or domain-wide delegation is
// configured then use scopes to get access token.
return ((!createScopedRequired() && !useJwtAccessWithScope)
|| isConfiguredForDomainWideDelegation());
}

private Map<String, List<String>> getRequestMetadataForGdu(URI uri) throws IOException {
return shouldUseAssertionFlow()
return shouldUseAssertionFlowForGdu()
? super.getRequestMetadata(uri)
: getRequestMetadataWithSelfSignedJwt(uri);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -612,12 +612,12 @@ public void fromStream_Impersonation_providesToken_WithQuotaProject() throws IOE
.setExpireTime(ImpersonatedCredentialsTest.getDefaultExpireTime());
transportFactory
.getTransport()
.setAccessTokenEndpoint(ImpersonatedCredentialsTest.IMPERSONATION_URL);
.setAccessTokenEndpoint(ImpersonatedCredentialsTest.IMPERSONATION_OVERRIDE_URL);
transportFactory.getTransport().addStatusCodeAndMessage(HttpStatusCodes.STATUS_CODE_OK, "");

InputStream impersonationCredentialsStream =
ImpersonatedCredentialsTest.writeImpersonationCredentialsStream(
ImpersonatedCredentialsTest.IMPERSONATION_URL,
ImpersonatedCredentialsTest.IMPERSONATION_OVERRIDE_URL,
ImpersonatedCredentialsTest.DELEGATES,
ImpersonatedCredentialsTest.QUOTA_PROJECT_ID);

Expand Down Expand Up @@ -647,7 +647,7 @@ public void fromStream_Impersonation_defaultUniverse() throws IOException {

InputStream impersonationCredentialsStream =
ImpersonatedCredentialsTest.writeImpersonationCredentialsStream(
ImpersonatedCredentialsTest.IMPERSONATION_URL,
ImpersonatedCredentialsTest.IMPERSONATION_OVERRIDE_URL,
ImpersonatedCredentialsTest.DELEGATES,
ImpersonatedCredentialsTest.QUOTA_PROJECT_ID);

Expand Down Expand Up @@ -677,12 +677,12 @@ public void fromStream_Impersonation_providesToken_WithoutQuotaProject() throws
.setExpireTime(ImpersonatedCredentialsTest.getDefaultExpireTime());
transportFactory
.getTransport()
.setAccessTokenEndpoint(ImpersonatedCredentialsTest.IMPERSONATION_URL);
.setAccessTokenEndpoint(ImpersonatedCredentialsTest.IMPERSONATION_OVERRIDE_URL);
transportFactory.getTransport().addStatusCodeAndMessage(HttpStatusCodes.STATUS_CODE_OK, "");

InputStream impersonationCredentialsStream =
ImpersonatedCredentialsTest.writeImpersonationCredentialsStream(
ImpersonatedCredentialsTest.IMPERSONATION_URL,
ImpersonatedCredentialsTest.IMPERSONATION_OVERRIDE_URL,
ImpersonatedCredentialsTest.DELEGATES,
null);

Expand Down
Loading

0 comments on commit c498ccf

Please sign in to comment.