diff --git a/oauth2_http/java/com/google/auth/oauth2/ExternalAccountCredentials.java b/oauth2_http/java/com/google/auth/oauth2/ExternalAccountCredentials.java index 4dd02861c..379e2a1cf 100644 --- a/oauth2_http/java/com/google/auth/oauth2/ExternalAccountCredentials.java +++ b/oauth2_http/java/com/google/auth/oauth2/ExternalAccountCredentials.java @@ -265,6 +265,7 @@ private ImpersonatedCredentials initializeImpersonatedCredentials() { .setTargetPrincipal(targetPrincipal) .setScopes(new ArrayList<>(scopes)) .setLifetime(3600) // 1 hour in seconds + .setIamEndpointOverride(serviceAccountImpersonationUrl) .build(); } diff --git a/oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java b/oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java index 962c105cd..9694619df 100644 --- a/oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java +++ b/oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java @@ -105,6 +105,7 @@ public class ImpersonatedCredentials extends GoogleCredentials private List scopes; private int lifetime; private String quotaProjectId; + private String iamEndpointOverride; private final String transportFactoryClassName; private transient HttpTransportFactory transportFactory; @@ -192,6 +193,54 @@ public static ImpersonatedCredentials create( .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 + * 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 these + * instructions 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. + * @param iamEndpointOverride The full IAM endpoint override with the target_principal embedded. + * This is useful when supporting impersonation with regional endpoints. + * @return new credentials + */ + public static ImpersonatedCredentials create( + GoogleCredentials sourceCredentials, + String targetPrincipal, + List delegates, + List scopes, + int lifetime, + HttpTransportFactory transportFactory, + String quotaProjectId, + String iamEndpointOverride) { + return ImpersonatedCredentials.newBuilder() + .setSourceCredentials(sourceCredentials) + .setTargetPrincipal(targetPrincipal) + .setDelegates(delegates) + .setScopes(scopes) + .setLifetime(lifetime) + .setHttpTransportFactory(transportFactory) + .setQuotaProjectId(quotaProjectId) + .setIamEndpointOverride(iamEndpointOverride) + .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. @@ -257,6 +306,11 @@ public String getQuotaProjectId() { return this.quotaProjectId; } + @VisibleForTesting + String getIamEndpointOverride() { + return this.iamEndpointOverride; + } + @VisibleForTesting List getDelegates() { return delegates; @@ -320,9 +374,9 @@ static ImpersonatedCredentials fromJson( String sourceCredentialsType; String quotaProjectId; String targetPrincipal; + String serviceAccountImpersonationUrl; try { - String serviceAccountImpersonationUrl = - (String) json.get("service_account_impersonation_url"); + serviceAccountImpersonationUrl = (String) json.get("service_account_impersonation_url"); if (json.containsKey("delegates")) { delegates = (List) json.get("delegates"); } @@ -354,6 +408,7 @@ static ImpersonatedCredentials fromJson( .setLifetime(DEFAULT_LIFETIME_IN_SECONDS) .setHttpTransportFactory(transportFactory) .setQuotaProjectId(quotaProjectId) + .setIamEndpointOverride(serviceAccountImpersonationUrl) .build(); } @@ -370,6 +425,7 @@ public GoogleCredentials createScoped(Collection scopes) { .setDelegates(this.delegates) .setHttpTransportFactory(this.transportFactory) .setQuotaProjectId(this.quotaProjectId) + .setIamEndpointOverride(this.iamEndpointOverride) .build(); } @@ -393,6 +449,7 @@ private ImpersonatedCredentials(Builder builder) { builder.getHttpTransportFactory(), getFromServiceLoader(HttpTransportFactory.class, OAuth2Utils.HTTP_TRANSPORT_FACTORY)); this.quotaProjectId = builder.quotaProjectId; + this.iamEndpointOverride = builder.iamEndpointOverride; this.transportFactoryClassName = this.transportFactory.getClass().getName(); if (this.delegates == null) { this.delegates = new ArrayList(); @@ -424,7 +481,10 @@ public AccessToken refreshAccessToken() throws IOException { HttpCredentialsAdapter adapter = new HttpCredentialsAdapter(sourceCredentials); HttpRequestFactory requestFactory = httpTransport.createRequestFactory(); - String endpointUrl = String.format(IAM_ACCESS_TOKEN_ENDPOINT, this.targetPrincipal); + String endpointUrl = + this.iamEndpointOverride != null + ? this.iamEndpointOverride + : String.format(IAM_ACCESS_TOKEN_ENDPOINT, this.targetPrincipal); GenericUrl url = new GenericUrl(endpointUrl); Map body = @@ -489,7 +549,13 @@ public IdToken idTokenWithAudience(String targetAudience, List(), credentials.getScopes()); @@ -201,6 +206,7 @@ void fromJson_userAsSource_WithoutQuotaProjectId() throws IOException { ImpersonatedCredentials credentials = ImpersonatedCredentials.fromJson(json, mockTransportFactory); assertEquals(IMPERSONATED_CLIENT_EMAIL, credentials.getAccount()); + assertEquals(IMPERSONATION_URL, credentials.getIamEndpointOverride()); assertNull(credentials.getQuotaProjectId()); assertEquals(DELEGATES, credentials.getDelegates()); assertEquals(new ArrayList(), credentials.getScopes()); @@ -223,6 +229,7 @@ void fromJson_userAsSource_MissingDelegatesField() throws IOException { ImpersonatedCredentials credentials = ImpersonatedCredentials.fromJson(json, mockTransportFactory); assertEquals(IMPERSONATED_CLIENT_EMAIL, credentials.getAccount()); + assertEquals(IMPERSONATION_URL, credentials.getIamEndpointOverride()); assertNull(credentials.getQuotaProjectId()); assertEquals(new ArrayList(), credentials.getDelegates()); assertEquals(new ArrayList(), credentials.getScopes()); @@ -238,6 +245,7 @@ void fromJson_ServiceAccountAsSource() throws IOException { ImpersonatedCredentials credentials = ImpersonatedCredentials.fromJson(json, mockTransportFactory); assertEquals(IMPERSONATED_CLIENT_EMAIL, credentials.getAccount()); + assertEquals(IMPERSONATION_URL, credentials.getIamEndpointOverride()); assertEquals(QUOTA_PROJECT_ID, credentials.getQuotaProjectId()); assertEquals(DELEGATES, credentials.getDelegates()); assertEquals(new ArrayList(), credentials.getScopes()); @@ -329,6 +337,25 @@ void createScopedWithImmutableScopes() { assertEquals(Arrays.asList("scope1", "scope2"), scoped_credentials.getScopes()); } + @Test + void createScopedWithIamEndpointOverride() { + ImpersonatedCredentials targetCredentials = + ImpersonatedCredentials.create( + sourceCredentials, + IMPERSONATED_CLIENT_EMAIL, + DELEGATES, + IMMUTABLE_SCOPES_LIST, + VALID_LIFETIME, + mockTransportFactory, + QUOTA_PROJECT_ID, + IMPERSONATION_URL); + + ImpersonatedCredentials scoped_credentials = + (ImpersonatedCredentials) targetCredentials.createScoped(IMMUTABLE_SCOPES_SET); + assertEquals( + targetCredentials.getIamEndpointOverride(), scoped_credentials.getIamEndpointOverride()); + } + @Test void refreshAccessToken_unauthorized() throws IOException { @@ -449,6 +476,29 @@ void refreshAccessToken_success() throws IOException, IllegalStateException { mockTransportFactory); assertEquals(ACCESS_TOKEN, targetCredentials.refreshAccessToken().getTokenValue()); + assertEquals(DEFAULT_IMPERSONATION_URL, mockTransportFactory.transport.getRequest().getUrl()); + } + + @Test + void refreshAccessToken_endpointOverride() throws IOException, IllegalStateException { + mockTransportFactory.transport.setTargetPrincipal(IMPERSONATED_CLIENT_EMAIL); + mockTransportFactory.transport.setAccessToken(ACCESS_TOKEN); + mockTransportFactory.transport.setExpireTime(getDefaultExpireTime()); + mockTransportFactory.transport.setAccessTokenEndpoint(IMPERSONATION_URL); + + ImpersonatedCredentials targetCredentials = + ImpersonatedCredentials.create( + sourceCredentials, + IMPERSONATED_CLIENT_EMAIL, + null, + IMMUTABLE_SCOPES_LIST, + VALID_LIFETIME, + mockTransportFactory, + QUOTA_PROJECT_ID, + IMPERSONATION_URL); + + assertEquals(ACCESS_TOKEN, targetCredentials.refreshAccessToken().getTokenValue()); + assertEquals(IMPERSONATION_URL, mockTransportFactory.transport.getRequest().getUrl()); } @Test diff --git a/oauth2_http/javatests/com/google/auth/oauth2/MockIAMCredentialsServiceTransport.java b/oauth2_http/javatests/com/google/auth/oauth2/MockIAMCredentialsServiceTransport.java index dcf1ef950..d3da05aa2 100644 --- a/oauth2_http/javatests/com/google/auth/oauth2/MockIAMCredentialsServiceTransport.java +++ b/oauth2_http/javatests/com/google/auth/oauth2/MockIAMCredentialsServiceTransport.java @@ -46,7 +46,7 @@ /** Transport that simulates the IAMCredentials server for access tokens. */ public class MockIAMCredentialsServiceTransport extends MockHttpTransport { - private static final String IAM_ACCESS_TOKEN_ENDPOINT = + private static final String DEFAULT_IAM_ACCESS_TOKEN_ENDPOINT = "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/%s:generateAccessToken"; private static final String IAM_ID_TOKEN_ENDPOINT = "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/%s:generateIdToken"; @@ -58,6 +58,7 @@ public class MockIAMCredentialsServiceTransport extends MockHttpTransport { private byte[] signedBlob; private int responseCode = HttpStatusCodes.STATUS_CODE_OK; private String errorMessage; + private String iamAccessTokenEndpoint; private String accessToken; private String expireTime; @@ -101,6 +102,10 @@ public void setIdToken(String idToken) { this.idToken = idToken; } + public void setAccessTokenEndpoint(String accessTokenEndpoint) { + this.iamAccessTokenEndpoint = accessTokenEndpoint; + } + public MockLowLevelHttpRequest getRequest() { return request; } @@ -109,7 +114,9 @@ public MockLowLevelHttpRequest getRequest() { public LowLevelHttpRequest buildRequest(String method, String url) throws IOException { String iamAccesssTokenformattedUrl = - String.format(IAM_ACCESS_TOKEN_ENDPOINT, this.targetPrincipal); + iamAccessTokenEndpoint != null + ? iamAccessTokenEndpoint + : String.format(DEFAULT_IAM_ACCESS_TOKEN_ENDPOINT, this.targetPrincipal); String iamSignBlobformattedUrl = String.format(IAM_SIGN_ENDPOINT, this.targetPrincipal); String iamIdTokenformattedUrl = String.format(IAM_ID_TOKEN_ENDPOINT, this.targetPrincipal); if (url.equals(iamAccesssTokenformattedUrl)) {