diff --git a/oauth2_http/java/com/google/auth/oauth2/ExternalAccountCredentials.java b/oauth2_http/java/com/google/auth/oauth2/ExternalAccountCredentials.java index 162a5a554..38b4d37ff 100644 --- a/oauth2_http/java/com/google/auth/oauth2/ExternalAccountCredentials.java +++ b/oauth2_http/java/com/google/auth/oauth2/ExternalAccountCredentials.java @@ -280,7 +280,7 @@ public static ExternalAccountCredentials fromStream( parser.parseAndClose(credentialsStream, StandardCharsets.UTF_8, GenericJson.class); try { return fromJson(fileContents, transportFactory); - } catch (ClassCastException e) { + } catch (ClassCastException | IllegalArgumentException e) { throw new CredentialFormatException("An invalid input stream was provided.", e); } } @@ -300,15 +300,16 @@ static ExternalAccountCredentials fromJson( String audience = (String) json.get("audience"); String subjectTokenType = (String) json.get("subject_token_type"); String tokenUrl = (String) json.get("token_url"); - String serviceAccountImpersonationUrl = (String) json.get("service_account_impersonation_url"); Map credentialSourceMap = (Map) json.get("credential_source"); // Optional params. + String serviceAccountImpersonationUrl = (String) json.get("service_account_impersonation_url"); String tokenInfoUrl = (String) json.get("token_info_url"); String clientId = (String) json.get("client_id"); String clientSecret = (String) json.get("client_secret"); String quotaProjectId = (String) json.get("quota_project_id"); + String userProject = (String) json.get("workforce_pool_user_project"); if (isAwsCredential(credentialSourceMap)) { return new AwsCredentials( @@ -325,19 +326,20 @@ static ExternalAccountCredentials fromJson( /* scopes= */ null, /* environmentProvider= */ null); } - return new IdentityPoolCredentials( - transportFactory, - audience, - subjectTokenType, - tokenUrl, - new IdentityPoolCredentialSource(credentialSourceMap), - tokenInfoUrl, - serviceAccountImpersonationUrl, - quotaProjectId, - clientId, - clientSecret, - /* scopes= */ null, - /* environmentProvider= */ null); + + return IdentityPoolCredentials.newBuilder() + .setWorkforcePoolUserProject(userProject) + .setHttpTransportFactory(transportFactory) + .setAudience(audience) + .setSubjectTokenType(subjectTokenType) + .setTokenUrl(tokenUrl) + .setTokenInfoUrl(tokenInfoUrl) + .setCredentialSource(new IdentityPoolCredentialSource(credentialSourceMap)) + .setServiceAccountImpersonationUrl(serviceAccountImpersonationUrl) + .setQuotaProjectId(quotaProjectId) + .setClientId(clientId) + .setClientSecret(clientSecret) + .build(); } private static boolean isAwsCredential(Map credentialSource) { @@ -362,6 +364,7 @@ protected AccessToken exchangeExternalCredentialForAccessToken( StsRequestHandler requestHandler = StsRequestHandler.newBuilder( tokenUrl, stsTokenExchangeRequest, transportFactory.create().createRequestFactory()) + .setInternalOptions(stsTokenExchangeRequest.getInternalOptions()) .build(); StsTokenExchangeResponse response = requestHandler.exchangeToken(); diff --git a/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentials.java b/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentials.java index 1227f24ed..d19b2a0dc 100644 --- a/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentials.java +++ b/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentials.java @@ -37,7 +37,6 @@ import com.google.api.client.http.HttpResponse; import com.google.api.client.json.GenericJson; import com.google.api.client.json.JsonObjectParser; -import com.google.auth.http.HttpTransportFactory; import com.google.auth.oauth2.IdentityPoolCredentials.IdentityPoolCredentialSource.CredentialFormatType; import com.google.common.io.CharStreams; import java.io.BufferedReader; @@ -54,6 +53,7 @@ import java.util.Collection; import java.util.HashMap; import java.util.Map; +import java.util.regex.Pattern; import javax.annotation.Nullable; /** @@ -155,39 +155,37 @@ private boolean hasHeaders() { private final IdentityPoolCredentialSource identityPoolCredentialSource; - /** - * Internal constructor. See {@link - * ExternalAccountCredentials#ExternalAccountCredentials(HttpTransportFactory, String, String, - * String, CredentialSource, String, String, String, String, String, Collection, - * EnvironmentProvider)} - */ - IdentityPoolCredentials( - HttpTransportFactory transportFactory, - String audience, - String subjectTokenType, - String tokenUrl, - IdentityPoolCredentialSource credentialSource, - @Nullable String tokenInfoUrl, - @Nullable String serviceAccountImpersonationUrl, - @Nullable String quotaProjectId, - @Nullable String clientId, - @Nullable String clientSecret, - @Nullable Collection scopes, - @Nullable EnvironmentProvider environmentProvider) { + // This is used for Workforce Pools. It is passed to STS during token exchange in the + // `options` param and will be embedded in the token by STS. + @Nullable private String workforcePoolUserProject; + + /** Internal constructor. See {@link Builder}. */ + IdentityPoolCredentials(Builder builder) { super( - transportFactory, - audience, - subjectTokenType, - tokenUrl, - credentialSource, - tokenInfoUrl, - serviceAccountImpersonationUrl, - quotaProjectId, - clientId, - clientSecret, - scopes, - environmentProvider); - this.identityPoolCredentialSource = credentialSource; + builder.transportFactory, + builder.audience, + builder.subjectTokenType, + builder.tokenUrl, + builder.credentialSource, + builder.tokenInfoUrl, + builder.serviceAccountImpersonationUrl, + builder.quotaProjectId, + builder.clientId, + builder.clientSecret, + builder.scopes, + builder.environmentProvider); + this.identityPoolCredentialSource = (IdentityPoolCredentialSource) builder.credentialSource; + this.workforcePoolUserProject = builder.workforcePoolUserProject; + + if (workforcePoolUserProject != null && !isWorkforcePoolConfiguration()) { + throw new IllegalArgumentException( + "The workforce_pool_user_project parameter should only be provided for a Workforce Pool configuration."); + } + } + + @Nullable + public String getWorkforcePoolUserProject() { + return workforcePoolUserProject; } @Override @@ -202,6 +200,15 @@ public AccessToken refreshAccessToken() throws IOException { stsTokenExchangeRequest.setScopes(new ArrayList<>(scopes)); } + // If this credential was initialized with a Workforce configuration then the + // workforcePoolUserProject must passed to STS via the the internal options param. + if (isWorkforcePoolConfiguration()) { + GenericJson options = new GenericJson(); + options.setFactory(OAuth2Utils.JSON_FACTORY); + options.put("userProject", workforcePoolUserProject); + stsTokenExchangeRequest.setInternalOptions(options.toString()); + } + return exchangeExternalCredentialForAccessToken(stsTokenExchangeRequest.build()); } @@ -269,22 +276,24 @@ private String getSubjectTokenFromMetadataServer() throws IOException { } } + /** + * Returns whether or not the current configuration is for Workforce Pools (which enable 3p user + * identities, rather than workloads). + */ + public boolean isWorkforcePoolConfiguration() { + Pattern workforceAudiencePattern = + Pattern.compile( + "^//iam.googleapis.com/projects/.+/locations/.+/workforcePools/.+/providers/.+$"); + return workforcePoolUserProject != null + && !workforcePoolUserProject.isEmpty() + && workforceAudiencePattern.matcher(getAudience()).matches(); + } + /** Clones the IdentityPoolCredentials with the specified scopes. */ @Override public IdentityPoolCredentials createScoped(Collection newScopes) { return new IdentityPoolCredentials( - transportFactory, - getAudience(), - getSubjectTokenType(), - getTokenUrl(), - identityPoolCredentialSource, - getTokenInfoUrl(), - getServiceAccountImpersonationUrl(), - getQuotaProjectId(), - getClientId(), - getClientSecret(), - newScopes, - getEnvironmentProvider()); + (IdentityPoolCredentials.Builder) newBuilder(this).setScopes(newScopes)); } public static Builder newBuilder() { @@ -297,27 +306,23 @@ public static Builder newBuilder(IdentityPoolCredentials identityPoolCredentials public static class Builder extends ExternalAccountCredentials.Builder { + @Nullable private String workforcePoolUserProject; + Builder() {} Builder(IdentityPoolCredentials credentials) { super(credentials); + setWorkforcePoolUserProject(credentials.getWorkforcePoolUserProject()); + } + + public Builder setWorkforcePoolUserProject(String workforcePoolUserProject) { + this.workforcePoolUserProject = workforcePoolUserProject; + return this; } @Override public IdentityPoolCredentials build() { - return new IdentityPoolCredentials( - transportFactory, - audience, - subjectTokenType, - tokenUrl, - (IdentityPoolCredentialSource) credentialSource, - tokenInfoUrl, - serviceAccountImpersonationUrl, - quotaProjectId, - clientId, - clientSecret, - scopes, - environmentProvider); + return new IdentityPoolCredentials(this); } } } diff --git a/oauth2_http/java/com/google/auth/oauth2/StsTokenExchangeRequest.java b/oauth2_http/java/com/google/auth/oauth2/StsTokenExchangeRequest.java index b9525bd68..b5d0055ab 100644 --- a/oauth2_http/java/com/google/auth/oauth2/StsTokenExchangeRequest.java +++ b/oauth2_http/java/com/google/auth/oauth2/StsTokenExchangeRequest.java @@ -51,6 +51,7 @@ final class StsTokenExchangeRequest { @Nullable private final String resource; @Nullable private final String audience; @Nullable private final String requestedTokenType; + @Nullable private final String internalOptions; private StsTokenExchangeRequest( String subjectToken, @@ -59,7 +60,8 @@ private StsTokenExchangeRequest( @Nullable List scopes, @Nullable String resource, @Nullable String audience, - @Nullable String requestedTokenType) { + @Nullable String requestedTokenType, + @Nullable String internalOptions) { this.subjectToken = checkNotNull(subjectToken); this.subjectTokenType = checkNotNull(subjectTokenType); this.actingParty = actingParty; @@ -67,6 +69,7 @@ private StsTokenExchangeRequest( this.resource = resource; this.audience = audience; this.requestedTokenType = requestedTokenType; + this.internalOptions = internalOptions; } public static Builder newBuilder(String subjectToken, String subjectTokenType) { @@ -110,6 +113,11 @@ public ActingParty getActingParty() { return actingParty; } + @Nullable + public String getInternalOptions() { + return internalOptions; + } + public boolean hasResource() { return resource != null && !resource.isEmpty(); } @@ -139,6 +147,7 @@ public static class Builder { @Nullable private String requestedTokenType; @Nullable private List scopes; @Nullable private ActingParty actingParty; + @Nullable private String internalOptions; private Builder(String subjectToken, String subjectTokenType) { this.subjectToken = subjectToken; @@ -170,6 +179,11 @@ public StsTokenExchangeRequest.Builder setActingParty(ActingParty actingParty) { return this; } + public StsTokenExchangeRequest.Builder setInternalOptions(String internalOptions) { + this.internalOptions = internalOptions; + return this; + } + public StsTokenExchangeRequest build() { return new StsTokenExchangeRequest( subjectToken, @@ -178,7 +192,8 @@ public StsTokenExchangeRequest build() { scopes, resource, audience, - requestedTokenType); + requestedTokenType, + internalOptions); } } } diff --git a/oauth2_http/javatests/com/google/auth/oauth2/ExternalAccountCredentialsTest.java b/oauth2_http/javatests/com/google/auth/oauth2/ExternalAccountCredentialsTest.java index 9a0b4e180..e53377e13 100644 --- a/oauth2_http/javatests/com/google/auth/oauth2/ExternalAccountCredentialsTest.java +++ b/oauth2_http/javatests/com/google/auth/oauth2/ExternalAccountCredentialsTest.java @@ -33,6 +33,7 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; import static org.junit.Assert.fail; @@ -138,13 +139,28 @@ public void fromStream_nullStream_throws() throws IOException { } @Test - public void fromJson_identityPoolCredentials() { + public void fromStream_invalidWorkloadAudience_throws() throws IOException { + try { + GenericJson json = buildJsonIdentityPoolWorkforceCredential(); + json.put("audience", "invalidAudience"); + ExternalAccountCredentials credential = + ExternalAccountCredentials.fromStream(TestUtils.jsonToInputStream(json)); + fail("CredentialFormatException should be thrown."); + } catch (CredentialFormatException e) { + assertEquals("An invalid input stream was provided.", e.getMessage()); + } + } + + @Test + public void fromJson_identityPoolCredentialsWorkload() { ExternalAccountCredentials credential = ExternalAccountCredentials.fromJson( buildJsonIdentityPoolCredential(), OAuth2Utils.HTTP_TRANSPORT_FACTORY); assertTrue(credential instanceof IdentityPoolCredentials); - assertEquals("audience", credential.getAudience()); + assertEquals( + "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider", + credential.getAudience()); assertEquals("subjectTokenType", credential.getSubjectTokenType()); assertEquals(STS_URL, credential.getTokenUrl()); assertEquals("tokenInfoUrl", credential.getTokenInfoUrl()); @@ -152,7 +168,25 @@ public void fromJson_identityPoolCredentials() { } @Test - public void fromJson_awsCredentials() { + public void fromJson_identityPoolCredentialsWorkforce() { + ExternalAccountCredentials credential = + ExternalAccountCredentials.fromJson( + buildJsonIdentityPoolWorkforceCredential(), OAuth2Utils.HTTP_TRANSPORT_FACTORY); + + assertTrue(credential instanceof IdentityPoolCredentials); + assertEquals( + "//iam.googleapis.com/projects/123/locations/global/workforcePools/pool/providers/provider", + credential.getAudience()); + assertEquals("subjectTokenType", credential.getSubjectTokenType()); + assertEquals(STS_URL, credential.getTokenUrl()); + assertEquals("tokenInfoUrl", credential.getTokenInfoUrl()); + assertEquals( + "userProject", ((IdentityPoolCredentials) credential).getWorkforcePoolUserProject()); + assertNotNull(credential.getCredentialSource()); + } + + @Test + public void fromJson_awsCredentials() throws IOException { ExternalAccountCredentials credential = ExternalAccountCredentials.fromJson( buildJsonAwsCredential(), OAuth2Utils.HTTP_TRANSPORT_FACTORY); @@ -201,6 +235,35 @@ public void fromJson_nullTransport_throws() { } } + @Test + public void fromJson_invalidWorkloadAudiences_throws() { + List invalidAudiences = + Arrays.asList( + "//iam.googleapis.com/projects/x23/locations/global/workloadIdentityPools/pool/providers/provider", + "//iam.googleapis.com/projects/y16/locations/global/workforcepools/pool/providers/provider", + "//iam.googleapis.com/projects/z6/locations/global/workforcePools/providers/provider", + "//iam.googleapis.com/projects/aa4/locations/global/workforcePools/providers", + "//iam.googleapis.com/projects/b5/locations/global/workforcePools/", + "//iam.googleapis.com/projects/6c/locations//workforcePools/providers", + "//iam.googleapis.com/projects/df7/notlocations/global/workforcePools/providers", + "//iam.googleapis.com/projects/e6/locations/global/workforce/providers"); + + for (String audience : invalidAudiences) { + try { + GenericJson json = buildJsonIdentityPoolCredential(); + json.put("audience", audience); + json.put("workforce_pool_user_project", "userProject"); + + ExternalAccountCredentials.fromJson(json, OAuth2Utils.HTTP_TRANSPORT_FACTORY); + fail("Exception should be thrown."); + } catch (IllegalArgumentException e) { + assertEquals( + "The workforce_pool_user_project parameter should only be provided for a Workforce Pool configuration.", + e.getMessage()); + } + } + } + @Test public void constructor_invalidTokenUrl() { try { @@ -255,6 +318,35 @@ public void exchangeExternalCredentialForAccessToken() throws IOException { credential.exchangeExternalCredentialForAccessToken(stsTokenExchangeRequest); assertEquals(transportFactory.transport.getAccessToken(), accessToken.getTokenValue()); + + // Validate no internal options set. + Map query = + TestUtils.parseQuery(transportFactory.transport.getRequest().getContentAsString()); + assertNull(query.get("options")); + } + + @Test + public void exchangeExternalCredentialForAccessToken_withInternalOptions() throws IOException { + ExternalAccountCredentials credential = + ExternalAccountCredentials.fromJson(buildJsonIdentityPoolCredential(), transportFactory); + + GenericJson internalOptions = new GenericJson(); + internalOptions.put("key", "value"); + StsTokenExchangeRequest stsTokenExchangeRequest = + StsTokenExchangeRequest.newBuilder("credential", "subjectTokenType") + .setInternalOptions(internalOptions.toString()) + .build(); + + AccessToken accessToken = + credential.exchangeExternalCredentialForAccessToken(stsTokenExchangeRequest); + + assertEquals(transportFactory.transport.getAccessToken(), accessToken.getTokenValue()); + + // Validate internal options set. + Map query = + TestUtils.parseQuery(transportFactory.transport.getRequest().getContentAsString()); + assertNotNull(query.get("options")); + assertEquals(internalOptions.toString(), query.get("options")); } @Test @@ -435,7 +527,9 @@ public void validateServiceAccountImpersonationUrls_invalidUrls() { private GenericJson buildJsonIdentityPoolCredential() { GenericJson json = new GenericJson(); - json.put("audience", "audience"); + json.put( + "audience", + "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider"); json.put("subject_token_type", "subjectTokenType"); json.put("token_url", STS_URL); json.put("token_info_url", "tokenInfoUrl"); @@ -446,6 +540,15 @@ private GenericJson buildJsonIdentityPoolCredential() { return json; } + private GenericJson buildJsonIdentityPoolWorkforceCredential() { + GenericJson json = buildJsonIdentityPoolCredential(); + json.put( + "audience", + "//iam.googleapis.com/projects/123/locations/global/workforcePools/pool/providers/provider"); + json.put("workforce_pool_user_project", "userProject"); + return json; + } + private GenericJson buildJsonAwsCredential() { GenericJson json = new GenericJson(); json.put("audience", "audience"); diff --git a/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java b/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java index d937ed1f6..a1dd115e1 100644 --- a/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java +++ b/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java @@ -34,6 +34,7 @@ import static com.google.auth.oauth2.MockExternalAccountCredentialsTransport.SERVICE_ACCOUNT_IMPERSONATION_URL; import static com.google.auth.oauth2.OAuth2Utils.JSON_FACTORY; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; import static org.junit.Assert.fail; import com.google.api.client.http.HttpTransport; @@ -75,7 +76,8 @@ public class IdentityPoolCredentialsTest { (IdentityPoolCredentials) IdentityPoolCredentials.newBuilder() .setHttpTransportFactory(OAuth2Utils.HTTP_TRANSPORT_FACTORY) - .setAudience("audience") + .setAudience( + "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider") .setSubjectTokenType("subjectTokenType") .setTokenUrl(STS_URL) .setTokenInfoUrl("tokenInfoUrl") @@ -326,6 +328,40 @@ public void refreshAccessToken_withoutServiceAccountImpersonation() throws IOExc assertEquals(transportFactory.transport.getAccessToken(), accessToken.getTokenValue()); } + @Test + public void refreshAccessToken_internalOptionsSet() throws IOException { + MockExternalAccountCredentialsTransportFactory transportFactory = + new MockExternalAccountCredentialsTransportFactory(); + + IdentityPoolCredentials credential = + (IdentityPoolCredentials) + IdentityPoolCredentials.newBuilder(FILE_SOURCED_CREDENTIAL) + .setWorkforcePoolUserProject("userProject") + .setAudience( + "//iam.googleapis.com/projects/123/locations/global/workforcePools/pool/providers/provider") + .setTokenUrl(transportFactory.transport.getStsUrl()) + .setHttpTransportFactory(transportFactory) + .setCredentialSource( + buildUrlBasedCredentialSource(transportFactory.transport.getMetadataUrl())) + .build(); + + AccessToken accessToken = credential.refreshAccessToken(); + + assertEquals(transportFactory.transport.getAccessToken(), accessToken.getTokenValue()); + + // If the IdentityPoolCredential is initialized with a userProject, it must be passed + // to STS via internal options. + Map query = + TestUtils.parseQuery(transportFactory.transport.getRequest().getContentAsString()); + assertNotNull(query.get("options")); + + GenericJson expectedInternalOptions = new GenericJson(); + expectedInternalOptions.setFactory(OAuth2Utils.JSON_FACTORY); + expectedInternalOptions.put("userProject", "userProject"); + + assertEquals(expectedInternalOptions.toString(), query.get("options")); + } + @Test public void refreshAccessToken_withServiceAccountImpersonation() throws IOException { MockExternalAccountCredentialsTransportFactory transportFactory = @@ -448,6 +484,63 @@ public void builder() { assertEquals(credentials.getEnvironmentProvider(), SystemEnvironmentProvider.getInstance()); } + @Test + public void builder_invalidWorkforceAudiences_throws() { + List invalidAudiences = + Arrays.asList( + "", + "//iam.googleapis.com/projects/x23/locations/global/workloadIdentityPools/pool/providers/provider", + "//iam.googleapis.com/projects/y16/locations/global/workforcepools/pool/providers/provider", + "//iam.googleapis.com/projects/z6/locations/global/workforcePools/providers/provider", + "//iam.googleapis.com/projects/aa4/locations/global/workforcePools/providers", + "//iam.googleapis.com/projects/b5/locations/global/workforcePools/", + "//iam.googleapis.com/projects/6c/locations//workforcePools/providers", + "//iam.googleapis.com/projects/df7/notlocations/global/workforcePools/providers", + "//iam.googleapis.com/projects/e6/locations/global/workforce/providers"); + + for (String audience : invalidAudiences) { + try { + IdentityPoolCredentials.newBuilder() + .setWorkforcePoolUserProject("workforcePoolUserProject") + .setHttpTransportFactory(OAuth2Utils.HTTP_TRANSPORT_FACTORY) + .setAudience(audience) + .setSubjectTokenType("subjectTokenType") + .setTokenUrl(STS_URL) + .setTokenInfoUrl("tokenInfoUrl") + .setCredentialSource(FILE_CREDENTIAL_SOURCE) + .setQuotaProjectId("quotaProjectId") + .build(); + fail("Exception should be thrown."); + } catch (IllegalArgumentException e) { + assertEquals( + "The workforce_pool_user_project parameter should only be provided for a Workforce Pool configuration.", + e.getMessage()); + } + } + } + + @Test + public void builder_emptyWorkforceUserProjectWithWorkforceAudience_throws() { + try { + IdentityPoolCredentials.newBuilder() + .setWorkforcePoolUserProject("") + .setHttpTransportFactory(OAuth2Utils.HTTP_TRANSPORT_FACTORY) + .setAudience( + "//iam.googleapis.com/projects/123/locations/global/workforcePools/providers/provider") + .setSubjectTokenType("subjectTokenType") + .setTokenUrl(STS_URL) + .setTokenInfoUrl("tokenInfoUrl") + .setCredentialSource(FILE_CREDENTIAL_SOURCE) + .setQuotaProjectId("quotaProjectId") + .build(); + fail("Exception should be thrown."); + } catch (IllegalArgumentException e) { + assertEquals( + "The workforce_pool_user_project parameter should only be provided for a Workforce Pool configuration.", + e.getMessage()); + } + } + static InputStream writeIdentityPoolCredentialsStream( String tokenUrl, String url, @Nullable String serviceAccountImpersonationUrl) throws IOException {