From a16f32003891f55464027b34e5859e42c68b8052 Mon Sep 17 00:00:00 2001 From: Amanda Tarafa Mas Date: Fri, 9 Sep 2022 23:43:45 +0100 Subject: [PATCH] feat: Add support for file-sourced external credentials. Towards #2033. --- .../OAuth2/DefaultCredentialProviderTests.cs | 77 +++++-- .../ExternalAccountCredentialTestsBase.cs | 154 +++++++++++++ ...ileSourcedExternalAccountCredentialTest.cs | 203 ++++++++++++++++++ ...UrlSourcedExternalAccountCredentialTest.cs | 194 ++++------------- .../OAuth2/DefaultCredentialProvider.cs | 15 +- .../OAuth2/ExternalAccountCredential.cs | 27 ++- .../FileSourcedExternalAccountCredential.cs | 141 ++++++++++++ .../OAuth2/JsonCredentialParameters.cs | 9 + .../UrlSourcedExternalAccountCredential.cs | 3 +- 9 files changed, 652 insertions(+), 171 deletions(-) create mode 100644 Src/Support/Google.Apis.Auth.Tests/OAuth2/ExternalAccountCredentialTestsBase.cs create mode 100644 Src/Support/Google.Apis.Auth.Tests/OAuth2/FileSourcedExternalAccountCredentialTest.cs create mode 100644 Src/Support/Google.Apis.Auth/OAuth2/FileSourcedExternalAccountCredential.cs diff --git a/Src/Support/Google.Apis.Auth.Tests/OAuth2/DefaultCredentialProviderTests.cs b/Src/Support/Google.Apis.Auth.Tests/OAuth2/DefaultCredentialProviderTests.cs index d730b49f05e..35bd4b7d19e 100644 --- a/Src/Support/Google.Apis.Auth.Tests/OAuth2/DefaultCredentialProviderTests.cs +++ b/Src/Support/Google.Apis.Auth.Tests/OAuth2/DefaultCredentialProviderTests.cs @@ -120,6 +120,14 @@ public class DefaultCredentialProviderTests ""format"": { ""type"": ""json"", ""subject_token_field_name"": ""access_token""}}}"; + private const string DummyFileSourcedExternalAccountCredentialFileContents = @"{ + ""type"": ""external_account"", + ""audience"": ""//iam.googleapis.com/projects/$PROJECT_NUMBER/locations/global/workloadIdentityPools/$POOL_ID/providers/$PROVIDER_ID"", + ""subject_token_type"": ""urn:ietf:params:oauth:token-type:saml2"", + ""token_url"": ""https://sts.googleapis.com/v1/token"", + ""credential_source"": { + ""file"": ""/var/run/saml/assertion/token"" + }}"; private const string DummyUrlSourcedImpersonatedExternalAccountCredentialFileContents = @"{ ""type"": ""external_account"", ""audience"": ""//iam.googleapis.com/projects/$PROJECT_NUMBER/locations/global/workloadIdentityPools/$POOL_ID/providers/$PROVIDER_ID"", @@ -134,6 +142,15 @@ public class DefaultCredentialProviderTests ""format"": { ""type"": ""json"", ""subject_token_field_name"": ""access_token""}}}"; + private const string DummyFileSourcedImpersonatedExternalAccountCredentialFileContents = @"{ + ""type"": ""external_account"", + ""audience"": ""//iam.googleapis.com/projects/$PROJECT_NUMBER/locations/global/workloadIdentityPools/$POOL_ID/providers/$PROVIDER_ID"", + ""subject_token_type"": ""urn:ietf:params:oauth:token-type:saml2"", + ""service_account_impersonation_url"": ""https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/$EMAIL:generateAccessToken"", + ""token_url"": ""https://sts.googleapis.com/v1/token"", + ""credential_source"": { + ""file"": ""/var/run/saml/assertion/token"" + }}"; private const string DummyUrlSourcedWorkforceExternalAccountCredentialFileContents = @"{ ""type"":""external_account"", ""audience"":""//iam.googleapis.com/locations/global/workforcePools/pool/providers/oidc-google"", @@ -148,6 +165,15 @@ public class DefaultCredentialProviderTests ""format"": { ""type"": ""json"", ""subject_token_field_name"": ""access_token""}}}"; + private const string DummyFileSourcedWorkforceExternalAccountCredentialFileContents = @"{ + ""type"": ""external_account"", + ""audience"": ""//iam.googleapis.com/projects/$PROJECT_NUMBER/locations/global/workloadIdentityPools/$POOL_ID/providers/$PROVIDER_ID"", + ""subject_token_type"": ""urn:ietf:params:oauth:token-type:saml2"", + ""token_url"": ""https://sts.googleapis.com/v1/token"", + ""workforce_pool_user_project"": ""user_project"", + ""credential_source"": { + ""file"": ""/var/run/saml/assertion/token"" + }}"; public DefaultCredentialProviderTests() { @@ -254,45 +280,70 @@ public async Task GetDefaultCredential_ExternalAccountCredential_NoCredentialSou await Assert.ThrowsAsync(() => credentialProvider.GetDefaultCredentialAsync()); } - [Fact] - public async Task GetDefaultCredential_UrlSourcedExternalAccountCredential() + public static TheoryData ExternalAccountCredentialTestData => new TheoryData + { + { DummyUrlSourcedExternalAccountCredentialFileContents, typeof(UrlSourcedExternalAccountCredential) }, + { DummyFileSourcedExternalAccountCredentialFileContents, typeof (FileSourcedExternalAccountCredential) }, + }; + + [Theory] + [MemberData(nameof(ExternalAccountCredentialTestData))] + public async Task GetDefaultCredential_ExternalAccountCredential(string credentialFileContent, Type expectedCredentialType) { // Setup fake environment variables and credential file contents. var credentialFilepath = "TempFilePath.json"; credentialProvider.SetEnvironmentVariable(CredentialEnvironmentVariable, credentialFilepath); - credentialProvider.SetFileContents(credentialFilepath, DummyUrlSourcedExternalAccountCredentialFileContents); + credentialProvider.SetFileContents(credentialFilepath, credentialFileContent); var credential = await credentialProvider.GetDefaultCredentialAsync(); - Assert.IsType(credential.UnderlyingCredential); + Assert.IsType(expectedCredentialType, credential.UnderlyingCredential); } - [Fact] - public async Task GetDefaultCredential_UrlSourcedExternalAccountCredential_Impersonated() + public static TheoryData ExternalImpersonatedAccountCredentialTestData => new TheoryData + { + { DummyUrlSourcedImpersonatedExternalAccountCredentialFileContents, typeof(UrlSourcedExternalAccountCredential) }, + { DummyFileSourcedImpersonatedExternalAccountCredentialFileContents, typeof (FileSourcedExternalAccountCredential) }, + }; + + [Theory] + [MemberData(nameof(ExternalImpersonatedAccountCredentialTestData))] + public async Task GetDefaultCredential_UrlSourcedExternalAccountCredential_Impersonated(string credentialFileContent, Type expectedCredentialType) { // Setup fake environment variables and credential file contents. var credentialFilepath = "TempFilePath.json"; credentialProvider.SetEnvironmentVariable(CredentialEnvironmentVariable, credentialFilepath); - credentialProvider.SetFileContents(credentialFilepath, DummyUrlSourcedImpersonatedExternalAccountCredentialFileContents); + credentialProvider.SetFileContents(credentialFilepath, credentialFileContent); var credential = await credentialProvider.GetDefaultCredentialAsync(); - var impersonatedExternalCredential = Assert.IsType(credential.UnderlyingCredential); + Assert.IsType(expectedCredentialType, credential.UnderlyingCredential); + + var impersonatedExternalCredential = (ExternalAccountCredential)credential.UnderlyingCredential; Assert.IsType(impersonatedExternalCredential.ImplicitlyImpersonated.Value); } - [Fact] - public async Task GetDefaultCredential_UrlSourcedExternalAccountCredential_WorkforceIdentity() + public static TheoryData ExternalWorkforceAccountCredentialTestData => new TheoryData + { + { DummyUrlSourcedWorkforceExternalAccountCredentialFileContents, typeof(UrlSourcedExternalAccountCredential) }, + { DummyFileSourcedWorkforceExternalAccountCredentialFileContents, typeof (FileSourcedExternalAccountCredential) }, + }; + + [Theory] + [MemberData(nameof(ExternalWorkforceAccountCredentialTestData))] + public async Task GetDefaultCredential_UrlSourcedExternalAccountCredential_WorkforceIdentity(string credentialFileContent, Type expectedCredentialType) { // Setup fake environment variables and credential file contents. var credentialFilepath = "TempFilePath.json"; credentialProvider.SetEnvironmentVariable(CredentialEnvironmentVariable, credentialFilepath); - credentialProvider.SetFileContents(credentialFilepath, DummyUrlSourcedWorkforceExternalAccountCredentialFileContents); + credentialProvider.SetFileContents(credentialFilepath, credentialFileContent); var credential = await credentialProvider.GetDefaultCredentialAsync(); - var workforceCredntial = Assert.IsType(credential.UnderlyingCredential); - Assert.Equal("user_project", workforceCredntial.WorkforcePoolUserProject); + Assert.IsType(expectedCredentialType, credential.UnderlyingCredential); + + var workforceCredential = (ExternalAccountCredential)credential.UnderlyingCredential; + Assert.Equal("user_project", workforceCredential.WorkforcePoolUserProject); } #endregion diff --git a/Src/Support/Google.Apis.Auth.Tests/OAuth2/ExternalAccountCredentialTestsBase.cs b/Src/Support/Google.Apis.Auth.Tests/OAuth2/ExternalAccountCredentialTestsBase.cs new file mode 100644 index 00000000000..ee914054b1d --- /dev/null +++ b/Src/Support/Google.Apis.Auth.Tests/OAuth2/ExternalAccountCredentialTestsBase.cs @@ -0,0 +1,154 @@ +/* +Copyright 2022 Google LLC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +using Google.Apis.Auth.OAuth2; +using Google.Apis.Auth.OAuth2.Responses; +using Google.Apis.Json; +using System; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Threading.Tasks; +using Xunit; + +namespace Google.Apis.Auth.Tests.OAuth2 +{ + public abstract class ExternalAccountCredentialTestsBase + { + protected const string SubjectTokenText = "dummy_subject_token"; + protected const string SubjectTokenJsonField = "subject_token_field"; + protected static readonly string SubjectTokenJson = $@"{{""{SubjectTokenJsonField}"": ""{SubjectTokenText}""}}"; + + protected const string TokenUrl = "https://dummy.token.url/"; + protected const string GrantTypeClaim = "grant_type=urn:ietf:params:oauth:grant-type:token-exchange"; + protected const string RequestedTokenTypeClaim = "requested_token_type=urn:ietf:params:oauth:token-type:access_token"; + protected const string Audience = "dummy_audience"; + protected const string SubjectTokenType = "dummy_token_type"; + protected const string Scope = "dummy_scope"; + protected const string ImpersonationScope = "https://www.googleapis.com/auth/iam"; + protected const string ClientId = "dummy_client_ID"; + protected const string ClientSecret = "dummy_client_secret"; + protected const string WorkforcePoolUserProject = "dummy_workforce_project"; + protected const string ImpersonationUrl = "https://dummy.impersonation.url/"; + + protected const string AccessToken = "dummy_access_token"; + protected const string RefreshedAccessToken = "dummy_refreshed_access_token"; + protected const string ImpersonatedAccessToken = "dummy_impersonated_access_token"; + protected const string QuotaProject = "dummy_project_id"; + protected const string QuotaProjectHeaderName = "x-goog-user-project"; + + protected static async Task ValidateAccessTokenRequest(HttpRequestMessage accessTokenRequest, string scope, bool isWorkforce = false) + { + Assert.Equal(TokenUrl, accessTokenRequest.RequestUri.ToString()); + Assert.Equal(HttpMethod.Post, accessTokenRequest.Method); + + string contentText = WebUtility.UrlDecode(await accessTokenRequest.Content.ReadAsStringAsync()); + + if (isWorkforce) + { + Assert.Null(accessTokenRequest.Headers.Authorization); + Assert.Contains($"options={{\"userProject\":\"{WorkforcePoolUserProject}\"}}", contentText); + } + else + { + Assert.Equal("Basic", accessTokenRequest.Headers.Authorization.Scheme); + Assert.Equal(Convert.ToBase64String(Encoding.UTF8.GetBytes($"{ClientId}:{ClientSecret}")), accessTokenRequest.Headers.Authorization.Parameter); + Assert.DoesNotContain("options=", contentText); + } + + Assert.Contains(GrantTypeClaim, contentText); + Assert.Contains(RequestedTokenTypeClaim, contentText); + Assert.Contains($"audience={Audience}", contentText); + Assert.Contains($"subject_token_type={SubjectTokenType}", contentText); + Assert.Contains($"subject_token={SubjectTokenText}", contentText); + Assert.Contains($"scope={scope}", contentText); + + return await BuildAccessTokenResponse(AccessToken); + } + + protected static async Task ValidateImpersonatedAccessTokenRequest(HttpRequestMessage accessTokenRequest) + { + Assert.Equal(ImpersonationUrl, accessTokenRequest.RequestUri.ToString()); + Assert.Equal(HttpMethod.Post, accessTokenRequest.Method); + + Assert.Contains(accessTokenRequest.Headers, header => header.Key == QuotaProjectHeaderName && header.Value.Single() == QuotaProject); + + Assert.Equal("Bearer", accessTokenRequest.Headers.Authorization.Scheme); + Assert.Equal(AccessToken, accessTokenRequest.Headers.Authorization.Parameter); + + string contentText = WebUtility.UrlDecode(await accessTokenRequest.Content.ReadAsStringAsync()); + + Assert.Contains(Scope, contentText); + + return await BuildAccessTokenResponse(new + { + accessToken = ImpersonatedAccessToken, + expireTime = "2020-05-13T16:00:00.045123456Z" + }); + } + + protected static async Task ValidateAccessTokenFromJsonSubjectTokenRequest(HttpRequestMessage accessTokenRequest) + { + string contentText = WebUtility.UrlDecode(await accessTokenRequest.Content.ReadAsStringAsync()); + + // Even if the subject token was returned as a JSON, the access token request should receive the token value only. + Assert.Contains($"subject_token={SubjectTokenText}", contentText); + + return await BuildAccessTokenResponse(AccessToken); + } + + protected static Task AccessTokenRequest(HttpRequestMessage accessTokenRequest) => + BuildAccessTokenResponse(AccessToken); + + protected static Task RefreshTokenRequest(HttpRequestMessage accessTokenRequest) => + BuildAccessTokenResponse(RefreshedAccessToken); + + protected static Task BuildAccessTokenResponse(string accessToken) => + BuildAccessTokenResponse(new TokenResponse + { + AccessToken = accessToken, + ExpiresInSeconds = 24 * 60 * 60 + }); + + protected static Task BuildAccessTokenResponse(object accessToken) + { + string content = NewtonsoftJsonSerializer.Instance.Serialize(accessToken); + return Task.FromResult(new HttpResponseMessage + { + Content = new StringContent(content, Encoding.UTF8) + }); + } + + protected static void AssertAccessTokenWithHeaders(AccessTokenWithHeaders token) + { + Assert.Equal(AccessToken, token.AccessToken); + var header = Assert.Single(token.Headers); + Assert.Equal(QuotaProjectHeaderName, header.Key); + var headerValue = Assert.Single(header.Value); + Assert.Equal(QuotaProject, headerValue); + } + + protected static void AssertImpersonatedAccessTokenWithHeaders(AccessTokenWithHeaders token) + { + Assert.Equal(ImpersonatedAccessToken, token.AccessToken); + var header = Assert.Single(token.Headers); + Assert.Equal(QuotaProjectHeaderName, header.Key); + var headerValue = Assert.Single(header.Value); + Assert.Equal(QuotaProject, headerValue); + } + } +} diff --git a/Src/Support/Google.Apis.Auth.Tests/OAuth2/FileSourcedExternalAccountCredentialTest.cs b/Src/Support/Google.Apis.Auth.Tests/OAuth2/FileSourcedExternalAccountCredentialTest.cs new file mode 100644 index 00000000000..724cf78e2c0 --- /dev/null +++ b/Src/Support/Google.Apis.Auth.Tests/OAuth2/FileSourcedExternalAccountCredentialTest.cs @@ -0,0 +1,203 @@ +/* +Copyright 2022 Google LLC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +using Google.Apis.Auth.OAuth2; +using Google.Apis.Tests.Mocks; +using Newtonsoft.Json; +using System; +using System.IO; +using System.Threading.Tasks; +using Xunit; + +namespace Google.Apis.Auth.Tests.OAuth2 +{ + public class FileSourcedExternalAccountCredentialsTests : ExternalAccountCredentialTestsBase + { + private static string WriteSubjectTokenToTempFile(string subjectToken) + { + string path = Path.GetTempFileName(); + File.WriteAllText(path, subjectToken); + return path; + } + + [Fact] + public async Task FetchesAccessToken() + { + var subjectTokenPath = WriteSubjectTokenToTempFile(SubjectTokenText); + var messageHandler = new DelegatedMessageHandler(request => ValidateAccessTokenRequest(request, Scope)); + + var credential = new FileSourcedExternalAccountCredential( + new FileSourcedExternalAccountCredential.Initializer(TokenUrl, Audience, SubjectTokenType, subjectTokenPath) + { + HttpClientFactory = new MockHttpClientFactory(messageHandler), + ClientId = ClientId, + ClientSecret = ClientSecret, + Scopes = new string[] { Scope }, + QuotaProject = QuotaProject + }); + + var token = await credential.GetAccessTokenWithHeadersForRequestAsync(); + + AssertAccessTokenWithHeaders(token); + Assert.Equal(1, messageHandler.Calls); + } + + [Fact] + public async Task FetchesAccessToken_Impersonated() + { + var subjectTokenPath = WriteSubjectTokenToTempFile(SubjectTokenText); + var messageHandler = new DelegatedMessageHandler( + request => ValidateAccessTokenRequest(request, ImpersonationScope), + ValidateImpersonatedAccessTokenRequest); + + var credential = new FileSourcedExternalAccountCredential( + new FileSourcedExternalAccountCredential.Initializer(TokenUrl, Audience, SubjectTokenType, subjectTokenPath) + { + HttpClientFactory = new MockHttpClientFactory(messageHandler), + ClientId = ClientId, + ClientSecret = ClientSecret, + Scopes = new string[] { Scope }, + QuotaProject = QuotaProject, + ServiceAccountImpersonationUrl = ImpersonationUrl + }); + + var token = await credential.GetAccessTokenWithHeadersForRequestAsync(); + + AssertImpersonatedAccessTokenWithHeaders(token); + Assert.Equal(2, messageHandler.Calls); + } + + [Fact] + public async Task FetchesAccessToken_Workforce() + { + var subjectTokenPath = WriteSubjectTokenToTempFile(SubjectTokenText); + var messageHandler = new DelegatedMessageHandler(request => ValidateAccessTokenRequest(request, Scope, isWorkforce: true)); + + var credential = new FileSourcedExternalAccountCredential( + new FileSourcedExternalAccountCredential.Initializer(TokenUrl, Audience, SubjectTokenType, subjectTokenPath) + { + HttpClientFactory = new MockHttpClientFactory(messageHandler), + WorkforcePoolUserProject = WorkforcePoolUserProject, + Scopes = new string[] { Scope }, + QuotaProject = QuotaProject + }); + + var token = await credential.GetAccessTokenWithHeadersForRequestAsync(); + + AssertAccessTokenWithHeaders(token); + Assert.Equal(1, messageHandler.Calls); + } + + [Fact] + public async Task FetchesAccessToken_ClientIdAndSecret_IgnoresWorkforce() + { + var subjectTokenPath = WriteSubjectTokenToTempFile(SubjectTokenText); + var messageHandler = new DelegatedMessageHandler(request => ValidateAccessTokenRequest(request, Scope, isWorkforce: false)); + + var credential = new FileSourcedExternalAccountCredential( + new FileSourcedExternalAccountCredential.Initializer(TokenUrl, Audience, SubjectTokenType, subjectTokenPath) + { + HttpClientFactory = new MockHttpClientFactory(messageHandler), + WorkforcePoolUserProject = WorkforcePoolUserProject, + ClientId = ClientId, + ClientSecret = ClientSecret, + Scopes = new string[] { Scope }, + QuotaProject = QuotaProject + }); + + var token = await credential.GetAccessTokenWithHeadersForRequestAsync(); + + AssertAccessTokenWithHeaders(token); + Assert.Equal(1, messageHandler.Calls); + } + + [Fact] + public async Task FetchesAccessToken_JsonSubjectToken() + { + var subjectTokenPath = WriteSubjectTokenToTempFile(SubjectTokenJson); + var messageHandler = new DelegatedMessageHandler(ValidateAccessTokenFromJsonSubjectTokenRequest); + + var credential = new FileSourcedExternalAccountCredential( + new FileSourcedExternalAccountCredential.Initializer(TokenUrl, Audience, SubjectTokenType, subjectTokenPath) + { + HttpClientFactory = new MockHttpClientFactory(messageHandler), + SubjectTokenJsonFieldName = SubjectTokenJsonField + }); + + Assert.Equal(AccessToken, await credential.GetAccessTokenForRequestAsync()); + + Assert.Equal(1, messageHandler.Calls); + } + + [Fact] + public async Task RefreshesAccessToken() + { + var subjectTokenPath = WriteSubjectTokenToTempFile(SubjectTokenText); + var messageHandler = new DelegatedMessageHandler(AccessTokenRequest, RefreshTokenRequest); + var clock = new MockClock(DateTime.UtcNow); + + var credential = new FileSourcedExternalAccountCredential( + new FileSourcedExternalAccountCredential.Initializer(TokenUrl, Audience, SubjectTokenType, subjectTokenPath) + { + HttpClientFactory = new MockHttpClientFactory(messageHandler), + Clock = clock + }); + + Assert.Equal(AccessToken, await credential.GetAccessTokenForRequestAsync()); + + clock.UtcNow = clock.UtcNow.AddDays(2); + + Assert.Equal(RefreshedAccessToken, await credential.GetAccessTokenForRequestAsync()); + + Assert.Equal(2, messageHandler.Calls); + } + + public static TheoryData SubjectTokenExceptionData + { + get + { + var subjectTokenPath = WriteSubjectTokenToTempFile(SubjectTokenText); + + var data = new TheoryData + { + { + new FileSourcedExternalAccountCredential( + new FileSourcedExternalAccountCredential.Initializer(TokenUrl, Audience, SubjectTokenType, "unknownPath")), + typeof(FileNotFoundException) + }, + { + new FileSourcedExternalAccountCredential( + new FileSourcedExternalAccountCredential.Initializer(TokenUrl, Audience, SubjectTokenType, subjectTokenPath) + { + SubjectTokenJsonFieldName = "unknownField" + }), + typeof(JsonReaderException) + } + }; + + return data; + } + } + + [Theory] + [MemberData(nameof(SubjectTokenExceptionData))] + public async Task SubjectTokenException(FileSourcedExternalAccountCredential credential, Type innerExceptionType) + { + var exception = await Assert.ThrowsAsync(async () => await credential.GetAccessTokenForRequestAsync()); + Assert.IsType(innerExceptionType, exception.InnerException); + } + } +} \ No newline at end of file diff --git a/Src/Support/Google.Apis.Auth.Tests/OAuth2/UrlSourcedExternalAccountCredentialTest.cs b/Src/Support/Google.Apis.Auth.Tests/OAuth2/UrlSourcedExternalAccountCredentialTest.cs index c30a059ece2..fe8319f637d 100644 --- a/Src/Support/Google.Apis.Auth.Tests/OAuth2/UrlSourcedExternalAccountCredentialTest.cs +++ b/Src/Support/Google.Apis.Auth.Tests/OAuth2/UrlSourcedExternalAccountCredentialTest.cs @@ -15,46 +15,24 @@ limitations under the License. */ using Google.Apis.Auth.OAuth2; -using Google.Apis.Auth.OAuth2.Responses; -using Google.Apis.Json; using Google.Apis.Tests.Mocks; +using Newtonsoft.Json; using System; using System.Collections.Generic; +using System.IO; using System.Linq; using System.Net; using System.Net.Http; -using System.Text; using System.Threading.Tasks; using Xunit; namespace Google.Apis.Auth.Tests.OAuth2 { - public class UrlSourcedExternalAccountCredentialsTests + public class UrlSourcedExternalAccountCredentialsTests : ExternalAccountCredentialTestsBase { private const string SubjectTokenUrl = "https://dummy.subject.token.url/"; private static readonly KeyValuePair SubjectTokenServiceHeader = new KeyValuePair("key1", "value1"); - private const string SubjectTokenText = "dummy_subject_token"; - private const string SubjectTokenJsonField = "subject_token_field"; - private static readonly string SubjectTokenJson = $@"{{""{SubjectTokenJsonField}"": ""{SubjectTokenText}""}}"; - - private const string TokenUrl = "https://dummy.token.url/"; - private const string GrantTypeClaim = "grant_type=urn:ietf:params:oauth:grant-type:token-exchange"; - private const string RequestedTokenTypeClaim = "requested_token_type=urn:ietf:params:oauth:token-type:access_token"; - private const string Audience = "dummy_audience"; - private const string SubjectTokenType = "dummy_token_type"; - private const string Scope = "dummy_scope"; - private const string ImpersonationScope = "https://www.googleapis.com/auth/iam"; - private const string ClientId = "dummy_client_ID"; - private const string ClientSecret = "dummy_client_secret"; - private const string WorkforcePoolUserProject = "dummy_workforce_project"; - - private const string AccessToken = "dummy_access_token"; - private const string RefreshedAccessToken = "dummy_refreshed_access_token"; - private const string ImpersonatedAccessToken = "dummy_impersonated_access_token"; - private const string QuotaProject = "dummy_project_id"; - private const string QuotaProjectHeaderName = "x-goog-user-project"; - private static Task ValidateSubjectTokenRequest(HttpRequestMessage subjectTokenRequest) { Assert.Equal(SubjectTokenUrl, subjectTokenRequest.RequestUri.ToString()); @@ -68,42 +46,14 @@ private static Task ValidateSubjectTokenRequest(HttpRequest }); } - private static async Task ValidateAccessTokenRequest(HttpRequestMessage accessTokenRequest, string scope, bool isWorkforce = false) - { - Assert.Equal(TokenUrl, accessTokenRequest.RequestUri.ToString()); - Assert.Equal(HttpMethod.Post, accessTokenRequest.Method); - - string contentText = WebUtility.UrlDecode(await accessTokenRequest.Content.ReadAsStringAsync()); - - if (isWorkforce) - { - Assert.Null(accessTokenRequest.Headers.Authorization); - Assert.Contains($"options={{\"userProject\":\"{WorkforcePoolUserProject}\"}}", contentText); - } - else + private static Task SubjectTokenRequest(HttpRequestMessage subjectTokenRequest) => + Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { - Assert.Equal("Basic", accessTokenRequest.Headers.Authorization.Scheme); - Assert.Equal(Convert.ToBase64String(Encoding.UTF8.GetBytes($"{ClientId}:{ClientSecret}")), accessTokenRequest.Headers.Authorization.Parameter); - Assert.DoesNotContain("options=", contentText); - } - - Assert.Contains(GrantTypeClaim, contentText); - Assert.Contains(RequestedTokenTypeClaim, contentText); - Assert.Contains($"audience={Audience}", contentText); - Assert.Contains($"subject_token_type={SubjectTokenType}", contentText); - Assert.Contains($"subject_token={SubjectTokenText}", contentText); - Assert.Contains($"scope={scope}", contentText); + Content = new StringContent(SubjectTokenText) + }); - return new HttpResponseMessage - { - Content = new StringContent( - NewtonsoftJsonSerializer.Instance.Serialize(new TokenResponse - { - AccessToken = AccessToken, - ExpiresInSeconds = 24 * 60 * 60, - }), Encoding.UTF8) - }; - } + private static Task SubjectTokenRequestFailure(HttpRequestMessage subjectTokenRequest) => + Task.FromResult(new HttpResponseMessage(HttpStatusCode.ServiceUnavailable)); [Fact] public async Task FetchesAccessToken() @@ -123,19 +73,13 @@ public async Task FetchesAccessToken() var token = await credential.GetAccessTokenWithHeadersForRequestAsync(); - Assert.Equal(AccessToken, token.AccessToken); - var header = Assert.Single(token.Headers); - Assert.Equal(QuotaProjectHeaderName, header.Key); - var headerValue = Assert.Single(header.Value); - Assert.Equal(QuotaProject, headerValue); + AssertAccessTokenWithHeaders(token); Assert.Equal(2, messageHandler.Calls); } [Fact] public async Task FetchesAccessToken_Impersonated() { - const string impersonationUrl = "https://dummy.impersonation.url/"; - var messageHandler = new DelegatedMessageHandler( ValidateSubjectTokenRequest, request => ValidateAccessTokenRequest(request, ImpersonationScope), @@ -150,43 +94,13 @@ public async Task FetchesAccessToken_Impersonated() ClientSecret = ClientSecret, Scopes = new string[] { Scope }, QuotaProject = QuotaProject, - ServiceAccountImpersonationUrl = impersonationUrl + ServiceAccountImpersonationUrl = ImpersonationUrl }); var token = await credential.GetAccessTokenWithHeadersForRequestAsync(); - Assert.Equal(ImpersonatedAccessToken, token.AccessToken); - var header = Assert.Single(token.Headers); - Assert.Equal(QuotaProjectHeaderName, header.Key); - var headerValue = Assert.Single(header.Value); - Assert.Equal(QuotaProject, headerValue); - + AssertImpersonatedAccessTokenWithHeaders(token); Assert.Equal(3, messageHandler.Calls); - - static async Task ValidateImpersonatedAccessTokenRequest(HttpRequestMessage accessTokenRequest) - { - Assert.Equal(impersonationUrl, accessTokenRequest.RequestUri.ToString()); - Assert.Equal(HttpMethod.Post, accessTokenRequest.Method); - - Assert.Contains(accessTokenRequest.Headers, header => header.Key == QuotaProjectHeaderName && header.Value.Single() == QuotaProject); - - Assert.Equal("Bearer", accessTokenRequest.Headers.Authorization.Scheme); - Assert.Equal(AccessToken, accessTokenRequest.Headers.Authorization.Parameter); - - string contentText = WebUtility.UrlDecode(await accessTokenRequest.Content.ReadAsStringAsync()); - - Assert.Contains(Scope, contentText); - - return new HttpResponseMessage() - { - Content = new StringContent( - NewtonsoftJsonSerializer.Instance.Serialize(new - { - accessToken = ImpersonatedAccessToken, - expireTime = "2020-05-13T16:00:00.045123456Z" - })) - }; - } } [Fact] @@ -206,11 +120,7 @@ public async Task FetchesAccessToken_Workforce() var token = await credential.GetAccessTokenWithHeadersForRequestAsync(); - Assert.Equal(AccessToken, token.AccessToken); - var header = Assert.Single(token.Headers); - Assert.Equal(QuotaProjectHeaderName, header.Key); - var headerValue = Assert.Single(header.Value); - Assert.Equal(QuotaProject, headerValue); + AssertAccessTokenWithHeaders(token); Assert.Equal(2, messageHandler.Calls); } @@ -233,11 +143,7 @@ public async Task FetchesAccessToken_ClientIdAndSecret_IgnoresWorkforce() var token = await credential.GetAccessTokenWithHeadersForRequestAsync(); - Assert.Equal(AccessToken, token.AccessToken); - var header = Assert.Single(token.Headers); - Assert.Equal(QuotaProjectHeaderName, header.Key); - var headerValue = Assert.Single(header.Value); - Assert.Equal(QuotaProject, headerValue); + AssertAccessTokenWithHeaders(token); Assert.Equal(2, messageHandler.Calls); } @@ -264,24 +170,6 @@ static Task SubjectTokenAsJson(HttpRequestMessage subjectTo Content = new StringContent(SubjectTokenJson) }); } - - static async Task ValidateAccessTokenFromJsonSubjectTokenRequest(HttpRequestMessage accessTokenRequest) - { - string contentText = WebUtility.UrlDecode(await accessTokenRequest.Content.ReadAsStringAsync()); - - // Even if the subject token was returned as a JSON, the access token request should receive the token value only. - Assert.Contains($"subject_token={SubjectTokenText}", contentText); - - return new HttpResponseMessage() - { - Content = new StringContent( - NewtonsoftJsonSerializer.Instance.Serialize(new TokenResponse - { - AccessToken = AccessToken, - ExpiresInSeconds = 24 * 60 * 60, - }), Encoding.UTF8) - }; - } } [Fact] @@ -304,34 +192,36 @@ public async Task RefreshesAccessToken() Assert.Equal(RefreshedAccessToken, await credential.GetAccessTokenForRequestAsync()); Assert.Equal(4, messageHandler.Calls); + } - static Task SubjectTokenRequest(HttpRequestMessage subjectTokenRequest) => - Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new StringContent(SubjectTokenText) - }); - - static Task AccessTokenRequest(HttpRequestMessage accessTokenRequest) => - Task.FromResult(new HttpResponseMessage() - { - Content = new StringContent( - NewtonsoftJsonSerializer.Instance.Serialize(new TokenResponse - { - AccessToken = AccessToken, - ExpiresInSeconds = 24 * 60 * 60, - }), Encoding.UTF8) - }); + public static TheoryData SubjectTokenExceptionData => new TheoryData + { + { + new UrlSourcedExternalAccountCredential( + new UrlSourcedExternalAccountCredential.Initializer(TokenUrl, Audience, SubjectTokenType, SubjectTokenUrl) + { + // We retry 3 times so let's fail three times so the error is truly surfaced. + HttpClientFactory = new MockHttpClientFactory(new DelegatedMessageHandler(SubjectTokenRequestFailure, SubjectTokenRequestFailure, SubjectTokenRequestFailure)) + }), + typeof(HttpRequestException) + }, + { + new UrlSourcedExternalAccountCredential( + new UrlSourcedExternalAccountCredential.Initializer(TokenUrl, Audience, SubjectTokenType, SubjectTokenUrl) + { + HttpClientFactory = new MockHttpClientFactory(new DelegatedMessageHandler(SubjectTokenRequest)), + SubjectTokenJsonFieldName = "unknownField" + }), + typeof(JsonReaderException) + } + }; - static Task RefreshTokenRequest(HttpRequestMessage accessTokenRequest) => - Task.FromResult(new HttpResponseMessage() - { - Content = new StringContent( - NewtonsoftJsonSerializer.Instance.Serialize(new TokenResponse - { - AccessToken = RefreshedAccessToken, - ExpiresInSeconds = 24 * 60 * 60, - }), Encoding.UTF8) - }); + [Theory] + [MemberData(nameof(SubjectTokenExceptionData))] + public async Task SubjectTokenException(UrlSourcedExternalAccountCredential credential, Type innerExceptionType) + { + var exception = await Assert.ThrowsAsync(async () => await credential.GetAccessTokenForRequestAsync()); + Assert.IsType(innerExceptionType, exception.InnerException); } } } \ No newline at end of file diff --git a/Src/Support/Google.Apis.Auth/OAuth2/DefaultCredentialProvider.cs b/Src/Support/Google.Apis.Auth/OAuth2/DefaultCredentialProvider.cs index 1ed2914bf6e..ceda810eeec 100644 --- a/Src/Support/Google.Apis.Auth/OAuth2/DefaultCredentialProvider.cs +++ b/Src/Support/Google.Apis.Auth/OAuth2/DefaultCredentialProvider.cs @@ -279,7 +279,16 @@ private static IGoogleCredential CreateExternalCredentialFromParameters(JsonCred } else if (!string.IsNullOrEmpty(parameters.CredentialSourceConfig.File)) { - throw new NotImplementedException("File-sourced credentials not yet supported."); + return new FileSourcedExternalAccountCredential(new FileSourcedExternalAccountCredential.Initializer( + parameters.TokenUrl, parameters.Audience, parameters.SubjectTokenType, parameters.CredentialSourceConfig.File) + { + QuotaProject = parameters.QuotaProject, + ServiceAccountImpersonationUrl = parameters.ServiceAccountImpersonationUrl, + WorkforcePoolUserProject = parameters.WorkforcePoolUserProject, + ClientId = parameters.ClientId, + ClientSecret = parameters.ClientSecret, + SubjectTokenJsonFieldName = parameters.ExtractSubjectTokenFieldName(), + }); } else if (!string.IsNullOrEmpty(parameters.CredentialSourceConfig.Url)) { @@ -291,9 +300,7 @@ private static IGoogleCredential CreateExternalCredentialFromParameters(JsonCred WorkforcePoolUserProject = parameters.WorkforcePoolUserProject, ClientId = parameters.ClientId, ClientSecret = parameters.ClientSecret, - SubjectTokenJsonFieldName = parameters.CredentialSourceConfig.Format?.Type?.Equals("json", StringComparison.OrdinalIgnoreCase) == true - ? parameters.CredentialSourceConfig.Format.SubjectTokenFieldName - : null + SubjectTokenJsonFieldName = parameters.ExtractSubjectTokenFieldName(), }; if (parameters.CredentialSourceConfig.Headers is object) { diff --git a/Src/Support/Google.Apis.Auth/OAuth2/ExternalAccountCredential.cs b/Src/Support/Google.Apis.Auth/OAuth2/ExternalAccountCredential.cs index 362d1491f95..4cffa63908b 100644 --- a/Src/Support/Google.Apis.Auth/OAuth2/ExternalAccountCredential.cs +++ b/Src/Support/Google.Apis.Auth/OAuth2/ExternalAccountCredential.cs @@ -16,12 +16,25 @@ limitations under the License. using Google.Apis.Auth.OAuth2.Requests; using Google.Apis.Auth.OAuth2.Responses; +using Google.Apis.Util; using System; using System.Threading; using System.Threading.Tasks; namespace Google.Apis.Auth.OAuth2 { + /// + /// Exception thrown when the subject token cannot be obtained for a given + /// external account credential. + /// + public class SubjectTokenException : Exception + { + internal SubjectTokenException(ExternalAccountCredential credential, Exception innerException) : base( + $"An error occurred while attempting to obtain the subject token for {credential.ThrowIfNull(nameof(credential)).GetType().Name}", + innerException) + { } + } + /// /// Base class for external account credentials. /// @@ -262,7 +275,19 @@ static string ExtractTargetPrincipal(string url) /// /// Gets the subject token to be exchanged for the access token. /// - protected abstract Task GetSubjectTokenAsync(CancellationToken taskCancellationToken); + protected abstract Task GetSubjectTokenAsyncImpl(CancellationToken taskCancellationToken); + + private async Task GetSubjectTokenAsync(CancellationToken taskCancellationTokne) + { + try + { + return await GetSubjectTokenAsyncImpl(taskCancellationTokne).ConfigureAwait(false); + } + catch (Exception ex) + { + throw new SubjectTokenException(this, ex); + } + } private protected async Task RequestStsAccessTokenAsync(CancellationToken taskCancellationToken) { diff --git a/Src/Support/Google.Apis.Auth/OAuth2/FileSourcedExternalAccountCredential.cs b/Src/Support/Google.Apis.Auth/OAuth2/FileSourcedExternalAccountCredential.cs new file mode 100644 index 00000000000..ede0c08b657 --- /dev/null +++ b/Src/Support/Google.Apis.Auth/OAuth2/FileSourcedExternalAccountCredential.cs @@ -0,0 +1,141 @@ +/* +Copyright 2022 Google LLC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +using Google.Apis.Http; +using Google.Apis.Json; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace Google.Apis.Auth.OAuth2 +{ + /// + /// File-sourced credentials as described in the + /// "Determining the subject token in file-sourced credentials" section of + /// https://google.aip.dev/auth/4117. + /// + public sealed class FileSourcedExternalAccountCredential : ExternalAccountCredential, IGoogleCredential + { + new internal class Initializer : ExternalAccountCredential.Initializer + { + /// + /// The file from which to obtain the subject token. + /// + internal string SubjectTokenFilePath { get; } + + /// + /// If set, the subject token file content will be parsed as JSON and the + /// value in the field with name + /// will be returned as the subject token. + /// + internal string SubjectTokenJsonFieldName { get; set; } + + internal Initializer(string tokenUrl, string audience, string subjectTokenType, string subjectTokenFilePath) + : base(tokenUrl, audience, subjectTokenType) => SubjectTokenFilePath = subjectTokenFilePath; + + internal Initializer(Initializer other) : base(other) + { + SubjectTokenFilePath = other.SubjectTokenFilePath; + SubjectTokenJsonFieldName = other.SubjectTokenJsonFieldName; + } + + internal Initializer(FileSourcedExternalAccountCredential other) : base(other) + { + SubjectTokenFilePath = other.SubjectTokenFilePath; + SubjectTokenJsonFieldName = other.SubjectTokenJsonFieldName; + } + } + + /// + /// The file path from which to obtain the subject token. + /// + public string SubjectTokenFilePath { get; } + + /// + /// If set, the subject token file content will be parsed as JSON and the + /// value in the field with name + /// will be returned as the subject token. + /// + public string SubjectTokenJsonFieldName { get; } + + internal FileSourcedExternalAccountCredential(Initializer initializer) : base(initializer) + { + SubjectTokenFilePath = initializer.SubjectTokenFilePath; + SubjectTokenJsonFieldName = initializer.SubjectTokenJsonFieldName; + } + + /// + private protected override GoogleCredential WithoutImpersonationConfigurationImpl() => + ServiceAccountImpersonationUrl is null + ? new GoogleCredential(this) + : new GoogleCredential(new FileSourcedExternalAccountCredential(new Initializer(this) + { + ServiceAccountImpersonationUrl = null + })); + + /// + protected override async Task GetSubjectTokenAsyncImpl(CancellationToken taskCancellationToken) + { + var fileContent = await ReadSubjectTokenFileContentAsync().ConfigureAwait(false); + string subjectToken; + + if (string.IsNullOrEmpty(SubjectTokenJsonFieldName)) + { + subjectToken = fileContent; + } + else + { + var jsonResponse = NewtonsoftJsonSerializer.Instance.Deserialize>(fileContent); + + subjectToken = jsonResponse[SubjectTokenJsonFieldName]; + } + + return subjectToken; + + async Task ReadSubjectTokenFileContentAsync() + { + using var reader = File.OpenText(SubjectTokenFilePath); + return await reader.ReadToEndAsync().ConfigureAwait(false); + } + } + + /// + string IGoogleCredential.QuotaProject => QuotaProject; + + /// + bool IGoogleCredential.HasExplicitScopes => HasExplicitScopes; + + /// + bool IGoogleCredential.SupportsExplicitScopes => SupportsExplicitScopes; + + /// + IGoogleCredential IGoogleCredential.WithQuotaProject(string quotaProject) => + new FileSourcedExternalAccountCredential(new Initializer(this) { QuotaProject = quotaProject }); + + /// + IGoogleCredential IGoogleCredential.MaybeWithScopes(IEnumerable scopes) => + new FileSourcedExternalAccountCredential(new Initializer(this) { Scopes = scopes }); + + /// + IGoogleCredential IGoogleCredential.WithUserForDomainWideDelegation(string user) => + WithUserForDomainWideDelegation(user); + + /// + IGoogleCredential IGoogleCredential.WithHttpClientFactory(IHttpClientFactory httpClientFactory) => + new FileSourcedExternalAccountCredential(new Initializer(this) { HttpClientFactory = httpClientFactory }); + } +} diff --git a/Src/Support/Google.Apis.Auth/OAuth2/JsonCredentialParameters.cs b/Src/Support/Google.Apis.Auth/OAuth2/JsonCredentialParameters.cs index 90561b728ee..05cb22e4ff5 100644 --- a/Src/Support/Google.Apis.Auth/OAuth2/JsonCredentialParameters.cs +++ b/Src/Support/Google.Apis.Auth/OAuth2/JsonCredentialParameters.cs @@ -15,6 +15,7 @@ limitations under the License. */ using Newtonsoft.Json; +using System; using System.Collections.Generic; namespace Google.Apis.Auth.OAuth2 @@ -207,4 +208,12 @@ public class SubjectTokenFormat } } } + + internal static class JsonCredentialParametersExtensions + { + public static string ExtractSubjectTokenFieldName(this JsonCredentialParameters parameters) => + parameters.CredentialSourceConfig.Format?.Type?.Equals("json", StringComparison.OrdinalIgnoreCase) == true + ? parameters.CredentialSourceConfig.Format.SubjectTokenFieldName + : null; + } } diff --git a/Src/Support/Google.Apis.Auth/OAuth2/UrlSourcedExternalAccountCredential.cs b/Src/Support/Google.Apis.Auth/OAuth2/UrlSourcedExternalAccountCredential.cs index 16d878f1fd3..eb0d6de3f7f 100644 --- a/Src/Support/Google.Apis.Auth/OAuth2/UrlSourcedExternalAccountCredential.cs +++ b/Src/Support/Google.Apis.Auth/OAuth2/UrlSourcedExternalAccountCredential.cs @@ -108,7 +108,7 @@ ServiceAccountImpersonationUrl is null })); /// - protected async override Task GetSubjectTokenAsync(CancellationToken taskCancellationToken) + protected async override Task GetSubjectTokenAsyncImpl(CancellationToken taskCancellationToken) { var httpRequest = new HttpRequestMessage(HttpMethod.Get, SubjectTokenUrl); foreach (var headerPair in Headers) @@ -117,6 +117,7 @@ protected async override Task GetSubjectTokenAsync(CancellationToken tas } var response = await HttpClient.SendAsync(httpRequest, taskCancellationToken).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); var responseText = await response.Content.ReadAsStringAsync().ConfigureAwait(false); if (string.IsNullOrEmpty(SubjectTokenJsonFieldName))