Skip to content

Commit d584dc7

Browse files
committed
feat: Add support for file-sourced external credentials.
Towards #2033.
1 parent 4b8285a commit d584dc7

9 files changed

+652
-171
lines changed

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

Lines changed: 64 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,14 @@ public class DefaultCredentialProviderTests
120120
""format"": {
121121
""type"": ""json"",
122122
""subject_token_field_name"": ""access_token""}}}";
123+
private const string DummyFileSourcedExternalAccountCredentialFileContents = @"{
124+
""type"": ""external_account"",
125+
""audience"": ""//iam.googleapis.com/projects/$PROJECT_NUMBER/locations/global/workloadIdentityPools/$POOL_ID/providers/$PROVIDER_ID"",
126+
""subject_token_type"": ""urn:ietf:params:oauth:token-type:saml2"",
127+
""token_url"": ""https://sts.googleapis.com/v1/token"",
128+
""credential_source"": {
129+
""file"": ""/var/run/saml/assertion/token""
130+
}}";
123131
private const string DummyUrlSourcedImpersonatedExternalAccountCredentialFileContents = @"{
124132
""type"": ""external_account"",
125133
""audience"": ""//iam.googleapis.com/projects/$PROJECT_NUMBER/locations/global/workloadIdentityPools/$POOL_ID/providers/$PROVIDER_ID"",
@@ -134,6 +142,15 @@ public class DefaultCredentialProviderTests
134142
""format"": {
135143
""type"": ""json"",
136144
""subject_token_field_name"": ""access_token""}}}";
145+
private const string DummyFileSourcedImpersonatedExternalAccountCredentialFileContents = @"{
146+
""type"": ""external_account"",
147+
""audience"": ""//iam.googleapis.com/projects/$PROJECT_NUMBER/locations/global/workloadIdentityPools/$POOL_ID/providers/$PROVIDER_ID"",
148+
""subject_token_type"": ""urn:ietf:params:oauth:token-type:saml2"",
149+
""service_account_impersonation_url"": ""https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/$EMAIL:generateAccessToken"",
150+
""token_url"": ""https://sts.googleapis.com/v1/token"",
151+
""credential_source"": {
152+
""file"": ""/var/run/saml/assertion/token""
153+
}}";
137154
private const string DummyUrlSourcedWorkforceExternalAccountCredentialFileContents = @"{
138155
""type"":""external_account"",
139156
""audience"":""//iam.googleapis.com/locations/global/workforcePools/pool/providers/oidc-google"",
@@ -148,6 +165,15 @@ public class DefaultCredentialProviderTests
148165
""format"": {
149166
""type"": ""json"",
150167
""subject_token_field_name"": ""access_token""}}}";
168+
private const string DummyFileSourcedWorkforceExternalAccountCredentialFileContents = @"{
169+
""type"": ""external_account"",
170+
""audience"": ""//iam.googleapis.com/projects/$PROJECT_NUMBER/locations/global/workloadIdentityPools/$POOL_ID/providers/$PROVIDER_ID"",
171+
""subject_token_type"": ""urn:ietf:params:oauth:token-type:saml2"",
172+
""token_url"": ""https://sts.googleapis.com/v1/token"",
173+
""workforce_pool_user_project"": ""user_project"",
174+
""credential_source"": {
175+
""file"": ""/var/run/saml/assertion/token""
176+
}}";
151177

152178
public DefaultCredentialProviderTests()
153179
{
@@ -254,45 +280,70 @@ public async Task GetDefaultCredential_ExternalAccountCredential_NoCredentialSou
254280
await Assert.ThrowsAsync<InvalidOperationException>(() => credentialProvider.GetDefaultCredentialAsync());
255281
}
256282

257-
[Fact]
258-
public async Task GetDefaultCredential_UrlSourcedExternalAccountCredential()
283+
public static TheoryData<string, Type> ExternalAccountCredentialTestData => new TheoryData<string, Type>
284+
{
285+
{ DummyUrlSourcedExternalAccountCredentialFileContents, typeof(UrlSourcedExternalAccountCredential) },
286+
{ DummyFileSourcedExternalAccountCredentialFileContents, typeof (FileSourcedExternalAccountCredential) },
287+
};
288+
289+
[Theory]
290+
[MemberData(nameof(ExternalAccountCredentialTestData))]
291+
public async Task GetDefaultCredential_ExternalAccountCredential(string credentialFileContent, Type expectedCredentialType)
259292
{
260293
// Setup fake environment variables and credential file contents.
261294
var credentialFilepath = "TempFilePath.json";
262295
credentialProvider.SetEnvironmentVariable(CredentialEnvironmentVariable, credentialFilepath);
263-
credentialProvider.SetFileContents(credentialFilepath, DummyUrlSourcedExternalAccountCredentialFileContents);
296+
credentialProvider.SetFileContents(credentialFilepath, credentialFileContent);
264297

265298
var credential = await credentialProvider.GetDefaultCredentialAsync();
266299

267-
Assert.IsType<UrlSourcedExternalAccountCredential>(credential.UnderlyingCredential);
300+
Assert.IsType(expectedCredentialType, credential.UnderlyingCredential);
268301
}
269302

270-
[Fact]
271-
public async Task GetDefaultCredential_UrlSourcedExternalAccountCredential_Impersonated()
303+
public static TheoryData<string, Type> ExternalImpersonatedAccountCredentialTestData => new TheoryData<string, Type>
304+
{
305+
{ DummyUrlSourcedImpersonatedExternalAccountCredentialFileContents, typeof(UrlSourcedExternalAccountCredential) },
306+
{ DummyFileSourcedImpersonatedExternalAccountCredentialFileContents, typeof (FileSourcedExternalAccountCredential) },
307+
};
308+
309+
[Theory]
310+
[MemberData(nameof(ExternalImpersonatedAccountCredentialTestData))]
311+
public async Task GetDefaultCredential_UrlSourcedExternalAccountCredential_Impersonated(string credentialFileContent, Type expectedCredentialType)
272312
{
273313
// Setup fake environment variables and credential file contents.
274314
var credentialFilepath = "TempFilePath.json";
275315
credentialProvider.SetEnvironmentVariable(CredentialEnvironmentVariable, credentialFilepath);
276-
credentialProvider.SetFileContents(credentialFilepath, DummyUrlSourcedImpersonatedExternalAccountCredentialFileContents);
316+
credentialProvider.SetFileContents(credentialFilepath, credentialFileContent);
277317

278318
var credential = await credentialProvider.GetDefaultCredentialAsync();
279319

280-
var impersonatedExternalCredential = Assert.IsType<UrlSourcedExternalAccountCredential>(credential.UnderlyingCredential);
320+
Assert.IsType(expectedCredentialType, credential.UnderlyingCredential);
321+
322+
var impersonatedExternalCredential = (ExternalAccountCredential)credential.UnderlyingCredential;
281323
Assert.IsType<ImpersonatedCredential>(impersonatedExternalCredential.ImplicitlyImpersonated.Value);
282324
}
283325

284-
[Fact]
285-
public async Task GetDefaultCredential_UrlSourcedExternalAccountCredential_WorkforceIdentity()
326+
public static TheoryData<string, Type> ExternalWorkforceAccountCredentialTestData => new TheoryData<string, Type>
327+
{
328+
{ DummyUrlSourcedWorkforceExternalAccountCredentialFileContents, typeof(UrlSourcedExternalAccountCredential) },
329+
{ DummyFileSourcedWorkforceExternalAccountCredentialFileContents, typeof (FileSourcedExternalAccountCredential) },
330+
};
331+
332+
[Theory]
333+
[MemberData(nameof(ExternalWorkforceAccountCredentialTestData))]
334+
public async Task GetDefaultCredential_UrlSourcedExternalAccountCredential_WorkforceIdentity(string credentialFileContent, Type expectedCredentialType)
286335
{
287336
// Setup fake environment variables and credential file contents.
288337
var credentialFilepath = "TempFilePath.json";
289338
credentialProvider.SetEnvironmentVariable(CredentialEnvironmentVariable, credentialFilepath);
290-
credentialProvider.SetFileContents(credentialFilepath, DummyUrlSourcedWorkforceExternalAccountCredentialFileContents);
339+
credentialProvider.SetFileContents(credentialFilepath, credentialFileContent);
291340

292341
var credential = await credentialProvider.GetDefaultCredentialAsync();
293342

294-
var workforceCredntial = Assert.IsType<UrlSourcedExternalAccountCredential>(credential.UnderlyingCredential);
295-
Assert.Equal("user_project", workforceCredntial.WorkforcePoolUserProject);
343+
Assert.IsType(expectedCredentialType, credential.UnderlyingCredential);
344+
345+
var workforceCredential = (ExternalAccountCredential)credential.UnderlyingCredential;
346+
Assert.Equal("user_project", workforceCredential.WorkforcePoolUserProject);
296347
}
297348

298349
#endregion
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
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.Auth.OAuth2;
18+
using Google.Apis.Auth.OAuth2.Responses;
19+
using Google.Apis.Json;
20+
using System;
21+
using System.Linq;
22+
using System.Net;
23+
using System.Net.Http;
24+
using System.Text;
25+
using System.Threading.Tasks;
26+
using Xunit;
27+
28+
namespace Google.Apis.Auth.Tests.OAuth2
29+
{
30+
public abstract class ExternalAccountCredentialTestsBase
31+
{
32+
protected const string SubjectTokenText = "dummy_subject_token";
33+
protected const string SubjectTokenJsonField = "subject_token_field";
34+
protected static readonly string SubjectTokenJson = $@"{{""{SubjectTokenJsonField}"": ""{SubjectTokenText}""}}";
35+
36+
protected const string TokenUrl = "https://dummy.token.url/";
37+
protected const string GrantTypeClaim = "grant_type=urn:ietf:params:oauth:grant-type:token-exchange";
38+
protected const string RequestedTokenTypeClaim = "requested_token_type=urn:ietf:params:oauth:token-type:access_token";
39+
protected const string Audience = "dummy_audience";
40+
protected const string SubjectTokenType = "dummy_token_type";
41+
protected const string Scope = "dummy_scope";
42+
protected const string ImpersonationScope = "https://www.googleapis.com/auth/iam";
43+
protected const string ClientId = "dummy_client_ID";
44+
protected const string ClientSecret = "dummy_client_secret";
45+
protected const string WorkforcePoolUserProject = "dummy_workforce_project";
46+
protected const string ImpersonationUrl = "https://dummy.impersonation.url/";
47+
48+
protected const string AccessToken = "dummy_access_token";
49+
protected const string RefreshedAccessToken = "dummy_refreshed_access_token";
50+
protected const string ImpersonatedAccessToken = "dummy_impersonated_access_token";
51+
protected const string QuotaProject = "dummy_project_id";
52+
protected const string QuotaProjectHeaderName = "x-goog-user-project";
53+
54+
protected static async Task<HttpResponseMessage> ValidateAccessTokenRequest(HttpRequestMessage accessTokenRequest, string scope, bool isWorkforce = false)
55+
{
56+
Assert.Equal(TokenUrl, accessTokenRequest.RequestUri.ToString());
57+
Assert.Equal(HttpMethod.Post, accessTokenRequest.Method);
58+
59+
string contentText = WebUtility.UrlDecode(await accessTokenRequest.Content.ReadAsStringAsync());
60+
61+
if (isWorkforce)
62+
{
63+
Assert.Null(accessTokenRequest.Headers.Authorization);
64+
Assert.Contains($"options={{\"userProject\":\"{WorkforcePoolUserProject}\"}}", contentText);
65+
}
66+
else
67+
{
68+
Assert.Equal("Basic", accessTokenRequest.Headers.Authorization.Scheme);
69+
Assert.Equal(Convert.ToBase64String(Encoding.UTF8.GetBytes($"{ClientId}:{ClientSecret}")), accessTokenRequest.Headers.Authorization.Parameter);
70+
Assert.DoesNotContain("options=", contentText);
71+
}
72+
73+
Assert.Contains(GrantTypeClaim, contentText);
74+
Assert.Contains(RequestedTokenTypeClaim, contentText);
75+
Assert.Contains($"audience={Audience}", contentText);
76+
Assert.Contains($"subject_token_type={SubjectTokenType}", contentText);
77+
Assert.Contains($"subject_token={SubjectTokenText}", contentText);
78+
Assert.Contains($"scope={scope}", contentText);
79+
80+
return await BuildAccessTokenResponse(AccessToken);
81+
}
82+
83+
protected static async Task<HttpResponseMessage> ValidateImpersonatedAccessTokenRequest(HttpRequestMessage accessTokenRequest)
84+
{
85+
Assert.Equal(ImpersonationUrl, accessTokenRequest.RequestUri.ToString());
86+
Assert.Equal(HttpMethod.Post, accessTokenRequest.Method);
87+
88+
Assert.Contains(accessTokenRequest.Headers, header => header.Key == QuotaProjectHeaderName && header.Value.Single() == QuotaProject);
89+
90+
Assert.Equal("Bearer", accessTokenRequest.Headers.Authorization.Scheme);
91+
Assert.Equal(AccessToken, accessTokenRequest.Headers.Authorization.Parameter);
92+
93+
string contentText = WebUtility.UrlDecode(await accessTokenRequest.Content.ReadAsStringAsync());
94+
95+
Assert.Contains(Scope, contentText);
96+
97+
return await BuildAccessTokenResponse(new
98+
{
99+
accessToken = ImpersonatedAccessToken,
100+
expireTime = "2020-05-13T16:00:00.045123456Z"
101+
});
102+
}
103+
104+
protected static async Task<HttpResponseMessage> ValidateAccessTokenFromJsonSubjectTokenRequest(HttpRequestMessage accessTokenRequest)
105+
{
106+
string contentText = WebUtility.UrlDecode(await accessTokenRequest.Content.ReadAsStringAsync());
107+
108+
// Even if the subject token was returned as a JSON, the access token request should receive the token value only.
109+
Assert.Contains($"subject_token={SubjectTokenText}", contentText);
110+
111+
return await BuildAccessTokenResponse(AccessToken);
112+
}
113+
114+
protected static Task<HttpResponseMessage> AccessTokenRequest(HttpRequestMessage accessTokenRequest) =>
115+
BuildAccessTokenResponse(AccessToken);
116+
117+
protected static Task<HttpResponseMessage> RefreshTokenRequest(HttpRequestMessage accessTokenRequest) =>
118+
BuildAccessTokenResponse(RefreshedAccessToken);
119+
120+
protected static Task<HttpResponseMessage> BuildAccessTokenResponse(string accessToken) =>
121+
BuildAccessTokenResponse(new TokenResponse
122+
{
123+
AccessToken = accessToken,
124+
ExpiresInSeconds = 24 * 60 * 60
125+
});
126+
127+
protected static Task<HttpResponseMessage> BuildAccessTokenResponse(object accessToken)
128+
{
129+
string content = NewtonsoftJsonSerializer.Instance.Serialize(accessToken);
130+
return Task.FromResult(new HttpResponseMessage
131+
{
132+
Content = new StringContent(content, Encoding.UTF8)
133+
});
134+
}
135+
136+
protected static void AssertAccessTokenWithHeaders(AccessTokenWithHeaders token)
137+
{
138+
Assert.Equal(AccessToken, token.AccessToken);
139+
var header = Assert.Single(token.Headers);
140+
Assert.Equal(QuotaProjectHeaderName, header.Key);
141+
var headerValue = Assert.Single(header.Value);
142+
Assert.Equal(QuotaProject, headerValue);
143+
}
144+
145+
protected static void AssertImpersonatedAccessTokenWithHeaders(AccessTokenWithHeaders token)
146+
{
147+
Assert.Equal(ImpersonatedAccessToken, token.AccessToken);
148+
var header = Assert.Single(token.Headers);
149+
Assert.Equal(QuotaProjectHeaderName, header.Key);
150+
var headerValue = Assert.Single(header.Value);
151+
Assert.Equal(QuotaProject, headerValue);
152+
}
153+
}
154+
}

0 commit comments

Comments
 (0)