Skip to content

Commit 34ce5a2

Browse files
committed
feat: Add support for Workforce Identity Pools external credentials.
1 parent be1b35d commit 34ce5a2

File tree

9 files changed

+280
-36
lines changed

9 files changed

+280
-36
lines changed

Src/Support/Google.Apis.Auth.Tests/OAuth2/DefaultCredentialProviderTests.cs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,20 @@ public class DefaultCredentialProviderTests
126126
""subject_token_type"": ""urn:ietf:params:oauth:token-type:jwt"",
127127
""service_account_impersonation_url"": ""https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/$EMAIL:generateAccessToken"",
128128
""token_url"": ""https://sts.googleapis.com/v1/token"",
129+
""credential_source"": {
130+
""headers"": {
131+
""Metadata"": ""True""
132+
},
133+
""url"": ""http://169.254.169.254/metadata/identity/oauth2/token?api-version=2018-02-01&resource=https://iam.googleapis.com/projects/$PROJECT_NUMBER/locations/global/workloadIdentityPools/$POOL_ID/providers/$PROVIDER_ID"",
134+
""format"": {
135+
""type"": ""json"",
136+
""subject_token_field_name"": ""access_token""}}}";
137+
private const string DummyUrlSourcedWorkforceExternalAccountCredentialFileContents = @"{
138+
""type"":""external_account"",
139+
""audience"":""//iam.googleapis.com/locations/global/workforcePools/pool/providers/oidc-google"",
140+
""subject_token_type"":""urn:ietf:params:oauth:token-type:id_token"",
141+
""token_url"":""https://sts.googleapis.com/v1/token"",
142+
""workforce_pool_user_project"": ""user_project"",
129143
""credential_source"": {
130144
""headers"": {
131145
""Metadata"": ""True""
@@ -267,6 +281,20 @@ public async Task GetDefaultCredential_UrlSourcedExternalAccountCredential_Imper
267281
Assert.IsType<ImpersonatedCredential>(impersonatedExternalCredential.ImplicitlyImpersonated.Value);
268282
}
269283

284+
[Fact]
285+
public async Task GetDefaultCredential_UrlSourcedExternalAccountCredential_WorkforceIdentity()
286+
{
287+
// Setup fake environment variables and credential file contents.
288+
var credentialFilepath = "TempFilePath.json";
289+
credentialProvider.SetEnvironmentVariable(CredentialEnvironmentVariable, credentialFilepath);
290+
credentialProvider.SetFileContents(credentialFilepath, DummyUrlSourcedWorkforceExternalAccountCredentialFileContents);
291+
292+
var credential = await credentialProvider.GetDefaultCredentialAsync();
293+
294+
var workforceCredntial = Assert.IsType<UrlSourcedExternalAccountCredential>(credential.UnderlyingCredential);
295+
Assert.Equal("user_project", workforceCredntial.WorkforcePoolUserProject);
296+
}
297+
270298
#endregion
271299

272300
#region Invalid Cases

Src/Support/Google.Apis.Auth.Tests/OAuth2/UrlSourcedExternalAccountCredentialTest.cs

Lines changed: 66 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ public class UrlSourcedExternalAccountCredentialsTests
4747
private const string ImpersonationScope = "https://www.googleapis.com/auth/iam";
4848
private const string ClientId = "dummy_client_ID";
4949
private const string ClientSecret = "dummy_client_secret";
50+
private const string WorkforcePoolUserProject = "dummy_workforce_project";
5051

5152
private const string AccessToken = "dummy_access_token";
5253
private const string RefreshedAccessToken = "dummy_refreshed_access_token";
@@ -67,16 +68,25 @@ private static Task<HttpResponseMessage> ValidateSubjectTokenRequest(HttpRequest
6768
});
6869
}
6970

70-
private static async Task<HttpResponseMessage> ValidateAccessTokenRequest(HttpRequestMessage accessTokenRequest, string scope)
71+
private static async Task<HttpResponseMessage> ValidateAccessTokenRequest(HttpRequestMessage accessTokenRequest, string scope, bool isWorkforce = false)
7172
{
7273
Assert.Equal(TokenUrl, accessTokenRequest.RequestUri.ToString());
7374
Assert.Equal(HttpMethod.Post, accessTokenRequest.Method);
7475

75-
Assert.Equal("Basic", accessTokenRequest.Headers.Authorization.Scheme);
76-
Assert.Equal(Convert.ToBase64String(Encoding.UTF8.GetBytes($"{ClientId}:{ClientSecret}")), accessTokenRequest.Headers.Authorization.Parameter);
77-
7876
string contentText = WebUtility.UrlDecode(await accessTokenRequest.Content.ReadAsStringAsync());
7977

78+
if (isWorkforce)
79+
{
80+
Assert.Null(accessTokenRequest.Headers.Authorization);
81+
Assert.Contains($"options={{\"userProject\":\"{WorkforcePoolUserProject}\"}}", contentText);
82+
}
83+
else
84+
{
85+
Assert.Equal("Basic", accessTokenRequest.Headers.Authorization.Scheme);
86+
Assert.Equal(Convert.ToBase64String(Encoding.UTF8.GetBytes($"{ClientId}:{ClientSecret}")), accessTokenRequest.Headers.Authorization.Parameter);
87+
Assert.DoesNotContain("options=", contentText);
88+
}
89+
8090
Assert.Contains(GrantTypeClaim, contentText);
8191
Assert.Contains(RequestedTokenTypeClaim, contentText);
8292
Assert.Contains($"audience={Audience}", contentText);
@@ -179,6 +189,58 @@ static async Task<HttpResponseMessage> ValidateImpersonatedAccessTokenRequest(Ht
179189
}
180190
}
181191

192+
[Fact]
193+
public async Task FetchesAccessToken_Workforce()
194+
{
195+
var messageHandler = new DelegatedMessageHandler(ValidateSubjectTokenRequest, request => ValidateAccessTokenRequest(request, Scope, isWorkforce: true));
196+
197+
var credential = new UrlSourcedExternalAccountCredential(
198+
new UrlSourcedExternalAccountCredential.Initializer(TokenUrl, Audience, SubjectTokenType, SubjectTokenUrl)
199+
{
200+
HttpClientFactory = new MockHttpClientFactory(messageHandler),
201+
Headers = { { SubjectTokenServiceHeader } },
202+
WorkforcePoolUserProject = WorkforcePoolUserProject,
203+
Scopes = new string[] { Scope },
204+
QuotaProject = QuotaProject
205+
});
206+
207+
var token = await credential.GetAccessTokenWithHeadersForRequestAsync();
208+
209+
Assert.Equal(AccessToken, token.AccessToken);
210+
var header = Assert.Single(token.Headers);
211+
Assert.Equal(QuotaProjectHeaderName, header.Key);
212+
var headerValue = Assert.Single(header.Value);
213+
Assert.Equal(QuotaProject, headerValue);
214+
Assert.Equal(2, messageHandler.Calls);
215+
}
216+
217+
[Fact]
218+
public async Task FetchesAccessToken_ClientIdAndSecret_IgnoresWorkforce()
219+
{
220+
var messageHandler = new DelegatedMessageHandler(ValidateSubjectTokenRequest, request => ValidateAccessTokenRequest(request, Scope, isWorkforce: false));
221+
222+
var credential = new UrlSourcedExternalAccountCredential(
223+
new UrlSourcedExternalAccountCredential.Initializer(TokenUrl, Audience, SubjectTokenType, SubjectTokenUrl)
224+
{
225+
HttpClientFactory = new MockHttpClientFactory(messageHandler),
226+
Headers = { { SubjectTokenServiceHeader } },
227+
WorkforcePoolUserProject = WorkforcePoolUserProject,
228+
ClientId = ClientId,
229+
ClientSecret = ClientSecret,
230+
Scopes = new string[] { Scope },
231+
QuotaProject = QuotaProject
232+
});
233+
234+
var token = await credential.GetAccessTokenWithHeadersForRequestAsync();
235+
236+
Assert.Equal(AccessToken, token.AccessToken);
237+
var header = Assert.Single(token.Headers);
238+
Assert.Equal(QuotaProjectHeaderName, header.Key);
239+
var headerValue = Assert.Single(header.Value);
240+
Assert.Equal(QuotaProject, headerValue);
241+
Assert.Equal(2, messageHandler.Calls);
242+
}
243+
182244
[Fact]
183245
public async Task FetchesAccessToken_JsonSubjectToken()
184246
{

Src/Support/Google.Apis.Auth/OAuth2/DefaultCredentialProvider.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -288,6 +288,7 @@ private static IGoogleCredential CreateExternalCredentialFromParameters(JsonCred
288288
{
289289
QuotaProject = parameters.QuotaProject,
290290
ServiceAccountImpersonationUrl = parameters.ServiceAccountImpersonationUrl,
291+
WorkforcePoolUserProject = parameters.WorkforcePoolUserProject,
291292
ClientId = parameters.ClientId,
292293
ClientSecret = parameters.ClientSecret,
293294
SubjectTokenJsonFieldName = parameters.CredentialSourceConfig.Format?.Type?.Equals("json", StringComparison.OrdinalIgnoreCase) == true

Src/Support/Google.Apis.Auth/OAuth2/ExternalAccountCredential.cs

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,17 @@ public abstract class ExternalAccountCredential : ServiceCredential
5454
/// </summary>
5555
internal string ServiceAccountImpersonationUrl { get; set; }
5656

57+
/// <summary>
58+
/// The GCP project number to be used for Workforce Identity Pools
59+
/// external credentials.
60+
/// </summary>
61+
/// <remarks>
62+
/// If this external account credential represents a Workforce Identity Pool
63+
/// enabled identity and this values is not specified, then an API key needs to be
64+
/// used alongside this credential to call Google APIs.
65+
/// </remarks>
66+
public string WorkforcePoolUserProject { get; set; }
67+
5768
/// <summary>
5869
/// The Client ID.
5970
/// </summary>
@@ -87,6 +98,7 @@ internal Initializer(Initializer other) : base(other)
8798
Audience = other.Audience;
8899
SubjectTokenType = other.SubjectTokenType;
89100
ServiceAccountImpersonationUrl = other.ServiceAccountImpersonationUrl;
101+
WorkforcePoolUserProject = other.WorkforcePoolUserProject;
90102
ClientId = other.ClientId;
91103
ClientSecret = other.ClientSecret;
92104
}
@@ -96,6 +108,7 @@ internal Initializer(ExternalAccountCredential other) : base(other)
96108
Audience = other.Audience;
97109
SubjectTokenType = other.SubjectTokenType;
98110
ServiceAccountImpersonationUrl= other.ServiceAccountImpersonationUrl;
111+
WorkforcePoolUserProject= other.WorkforcePoolUserProject;
99112
ClientId = other.ClientId;
100113
ClientSecret = other.ClientSecret;
101114
}
@@ -120,6 +133,18 @@ internal Initializer(ExternalAccountCredential other) : base(other)
120133
/// </summary>
121134
public string ServiceAccountImpersonationUrl { get; }
122135

136+
/// <summary>
137+
/// The GCP project number to be used for Workforce Pools
138+
/// external credentials.
139+
/// </summary>
140+
/// <remarks>
141+
/// If this external account credential represents a Workforce Pool
142+
/// enabled identity and this values is not specified, then an API key needs to be
143+
/// used alongside this credential to call Google APIs.
144+
/// </remarks>
145+
public string WorkforcePoolUserProject { get; }
146+
147+
123148
/// <summary>
124149
/// The Client ID.
125150
/// </summary>
@@ -168,6 +193,7 @@ internal ExternalAccountCredential(Initializer initializer) : base(initializer)
168193
Audience = initializer.Audience;
169194
SubjectTokenType = initializer.SubjectTokenType;
170195
ServiceAccountImpersonationUrl = initializer.ServiceAccountImpersonationUrl;
196+
WorkforcePoolUserProject = initializer.WorkforcePoolUserProject;
171197
ClientId = initializer.ClientId;
172198
ClientSecret = initializer.ClientSecret;
173199
WithoutImpersonationConfiguration = new Lazy<GoogleCredential>(WithoutImpersonationConfigurationImpl, LazyThreadSafetyMode.ExecutionAndPublication);
@@ -240,17 +266,18 @@ static string ExtractTargetPrincipal(string url)
240266

241267
private protected async Task<TokenResponse> RequestStsAccessTokenAsync(CancellationToken taskCancellationToken)
242268
{
243-
StsTokenRequest request = new StsTokenRequest
269+
StsTokenRequest request = new StsTokenRequestBuilder
244270
{
245271
Audience = Audience,
246272
GrantType = GrantType,
247273
RequestedTokenType = RequestedTokenType,
248-
Scope = HasExplicitScopes ? string.Join(" ", Scopes) : null,
274+
Scopes = Scopes,
249275
SubjectToken = await GetSubjectTokenAsync(taskCancellationToken).ConfigureAwait(false),
250276
SubjectTokenType = SubjectTokenType,
277+
WorkforcePoolUserProject = WorkforcePoolUserProject,
251278
ClientId = ClientId,
252279
ClientSecret = ClientSecret,
253-
};
280+
}.Build();
254281

255282
return await request
256283
.ExecuteAsync(HttpClient, TokenServerUrl, Clock, Logger, taskCancellationToken)

Src/Support/Google.Apis.Auth/OAuth2/JsonCredentialParameters.cs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,18 @@ public class JsonCredentialParameters
131131
[JsonProperty("service_account_impersonation_url")]
132132
public string ServiceAccountImpersonationUrl { get; set; }
133133

134+
/// <summary>
135+
/// The GCP project number to be used for Workforce Pools
136+
/// external credentials.
137+
/// </summary>
138+
/// <remarks>
139+
/// If this external account credential represents a Workforce Pool
140+
/// enabled identity and this values is not specified, then an API key needs to be
141+
/// used alongside this credential to call Google APIs.
142+
/// </remarks>
143+
[JsonProperty("workforce_pool_user_project")]
144+
public string WorkforcePoolUserProject { get; set; }
145+
134146
/// <summary>
135147
/// The credential source associated with an external account credential.
136148
/// </summary>
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
/*
2+
Copyright 2022 Google LLC
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
https://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
using Google.Apis.Json;
18+
using System;
19+
using System.Collections.Generic;
20+
using System.Linq;
21+
using System.Net.Http.Headers;
22+
using System.Text;
23+
24+
namespace Google.Apis.Auth.OAuth2.Requests
25+
{
26+
/// <summary>
27+
/// Builder for <see cref="StsTokenRequest"/>.
28+
/// </summary>
29+
internal class StsTokenRequestBuilder
30+
{
31+
/// <summary>
32+
/// Gets the grant type for this request.
33+
/// Only <code>urn:ietf:params:oauth:grant-type:token-exchange</code> is currently supported.
34+
/// </summary>
35+
public string GrantType { get; set; }
36+
37+
/// <summary>
38+
/// The audience for which the requested token is intended. For instance:
39+
/// "//iam.googleapis.com/projects/my-project-id/locations/global/workloadIdentityPools/my-pool-id/providers/my-provider-id"
40+
/// </summary>
41+
public string Audience { get; set; }
42+
43+
/// <summary>
44+
/// The list of desired scopes for the requested token.
45+
/// </summary>
46+
public IEnumerable<string> Scopes { get; set; }
47+
48+
/// <summary>
49+
/// The type of the requested security token.
50+
/// Only <code>urn:ietf:params:oauth:token-type:access_token</code> is currently supported.
51+
/// </summary>
52+
public string RequestedTokenType { get; set; }
53+
54+
/// <summary>
55+
/// In terms of Google 3PI support, this is the 3PI credential.
56+
/// </summary>
57+
public string SubjectToken { get; set; }
58+
59+
/// <summary>
60+
/// The subject token type.
61+
/// </summary>
62+
public string SubjectTokenType { get; set; }
63+
64+
/// <summary>
65+
/// Client ID and client secret are not part of STS token exchange spec.
66+
/// But in the context of Google 3PI they are used to perform basic authorization
67+
/// for token exchange.
68+
/// </summary>
69+
public string ClientId { get; set; }
70+
71+
/// <summary>
72+
/// Client ID and client secret are not part of STS token exchange spec.
73+
/// But in the context of Google 3PI they are used to perform basic authorization
74+
/// for token exchange.
75+
/// </summary>
76+
public string ClientSecret { get; set; }
77+
78+
/// <summary>
79+
/// The GCP project number to be used for Workforce Pools
80+
/// external credentials. To be included in the request as part of options.
81+
/// </summary>
82+
public string WorkforcePoolUserProject { get; set; }
83+
84+
public StsTokenRequest Build()
85+
{
86+
var authenticationHeader = (ClientId is string && ClientSecret is string)
87+
? new AuthenticationHeaderValue("Basic", Convert.ToBase64String(Encoding.UTF8.GetBytes($"{ClientId}:{ClientSecret}")))
88+
: null;
89+
90+
var options = (authenticationHeader is null && WorkforcePoolUserProject is string)
91+
? NewtonsoftJsonSerializer.Instance.Serialize(new { userProject = WorkforcePoolUserProject })
92+
: null;
93+
94+
var scope = Scopes?.Any() == true ? string.Join(" ", Scopes) : null;
95+
96+
return new StsTokenRequest(GrantType, Audience, scope, RequestedTokenType, SubjectToken, SubjectTokenType, options, authenticationHeader);
97+
}
98+
}
99+
}

0 commit comments

Comments
 (0)