From e02fb599a211c0bee5a657fb245ae315155912ab Mon Sep 17 00:00:00 2001 From: Amanda Tarafa Mas Date: Thu, 14 Dec 2023 14:35:57 -0800 Subject: [PATCH] feat: ImpersonatedCredential supports universe domain. --- .../OAuth2/DefaultCredentialProviderTests.cs | 2 +- .../OAuth2/ImpersonatedCredentialTests.cs | 102 +++++++++- .../OAuth2/ImpersonatedCredential.cs | 192 ++++++++++++------ .../OAuth2/ServiceCredential.cs | 9 +- 4 files changed, 238 insertions(+), 67 deletions(-) diff --git a/Src/Support/Google.Apis.Auth.Tests/OAuth2/DefaultCredentialProviderTests.cs b/Src/Support/Google.Apis.Auth.Tests/OAuth2/DefaultCredentialProviderTests.cs index 0fdcc645ed..92561b6d3f 100644 --- a/Src/Support/Google.Apis.Auth.Tests/OAuth2/DefaultCredentialProviderTests.cs +++ b/Src/Support/Google.Apis.Auth.Tests/OAuth2/DefaultCredentialProviderTests.cs @@ -273,7 +273,7 @@ public async Task GetDefaultCredential_ImpersonatedCredential_FromEnvironmentVar var impersonatedCredential = Assert.IsType(credential.UnderlyingCredential); Assert.Equal("service-account-email", impersonatedCredential.TargetPrincipal); - Assert.False(impersonatedCredential.HasCustomTokenUrl); + Assert.False(await impersonatedCredential.HasCustomTokenUrlCache.Value); Assert.Collection(impersonatedCredential.DelegateAccounts, account => Assert.Equal("delegate-email-1", account), account => Assert.Equal("delegate-email-2", account)); diff --git a/Src/Support/Google.Apis.Auth.Tests/OAuth2/ImpersonatedCredentialTests.cs b/Src/Support/Google.Apis.Auth.Tests/OAuth2/ImpersonatedCredentialTests.cs index 5e59737f8c..ddafeb5b40 100644 --- a/Src/Support/Google.Apis.Auth.Tests/OAuth2/ImpersonatedCredentialTests.cs +++ b/Src/Support/Google.Apis.Auth.Tests/OAuth2/ImpersonatedCredentialTests.cs @@ -218,7 +218,7 @@ public async Task CreateWithCustomTokenUrl() Assert.Equal(customTokenUrl, impersonatedCredential.TokenServerUrl); Assert.Equal("principal", impersonatedCredential.TargetPrincipal); - Assert.True(impersonatedCredential.HasCustomTokenUrl); + Assert.True(await impersonatedCredential.HasCustomTokenUrlCache.Value); var success = await impersonatedCredential.RequestAccessTokenAsync(default); Assert.True(success); @@ -237,7 +237,7 @@ public async Task CreateWithCustomTokenUrl_NullPrincipal() Assert.Equal(customTokenUrl, impersonatedCredential.TokenServerUrl); Assert.Null(impersonatedCredential.TargetPrincipal); - Assert.True(impersonatedCredential.HasCustomTokenUrl); + Assert.True(await impersonatedCredential.HasCustomTokenUrlCache.Value); var success = await impersonatedCredential.RequestAccessTokenAsync(default); Assert.True(success); @@ -252,17 +252,111 @@ public async Task CreateWithCustomTokenUrl_NullPrincipal() public async Task CreateWithCustomTokenUrl_SameAsDefaultUrl() { string principal = "principal"; - string customTokenUrl = string.Format(GoogleAuthConsts.IamAccessTokenEndpointFormatString, principal); + string customTokenUrl = string.Format(GoogleAuthConsts.IamAccessTokenEndpointFormatString, GoogleAuthConsts.DefaultUniverseDomain, principal); var impersonatedCredential = CreateImpersonatedCredentialWithAccessTokenResponse(customTokenUrl: customTokenUrl); Assert.Equal(customTokenUrl, impersonatedCredential.TokenServerUrl); Assert.Equal(principal, impersonatedCredential.TargetPrincipal); - Assert.False(impersonatedCredential.HasCustomTokenUrl); + Assert.False(await impersonatedCredential.HasCustomTokenUrlCache.Value); var success = await impersonatedCredential.RequestAccessTokenAsync(default); Assert.True(success); Assert.Equal(3600, impersonatedCredential.Token.ExpiresInSeconds); Assert.Equal("access_token", impersonatedCredential.Token.AccessToken); } + + [Fact] + public async Task UniverseDomain_FromSourceCredential_Default() + { + string principal = "principal"; + + var credential = ImpersonatedCredential.Create( + CreateSourceCredential(), + new ImpersonatedCredential.Initializer(principal)); + var googleCredential = credential as IGoogleCredential; + + Assert.Equal(GoogleAuthConsts.DefaultUniverseDomain, await googleCredential.GetUniverseDomainAsync(default)); + Assert.Equal(GoogleAuthConsts.DefaultUniverseDomain, googleCredential.GetUniverseDomain()); + + string expectedTokenUrl = string.Format(GoogleAuthConsts.IamAccessTokenEndpointFormatString, GoogleAuthConsts.DefaultUniverseDomain, principal); + + Assert.Null(credential.TokenServerUrl); + Assert.False(await credential.HasCustomTokenUrlCache.Value); + Assert.Equal(expectedTokenUrl, await credential.EffectiveTokenUrlCache.Value); + } + + [Fact] + public async Task UniverseDomain_FromSourceCredential_Custom() + { + string principal = "principal"; + string universeDomain = "universe.domain.com"; + + var sourceCredential = GoogleCredential.FromComputeCredential(new ComputeCredential(new ComputeCredential.Initializer() + { + UniverseDomain = universeDomain + })); + + var credential = ImpersonatedCredential.Create( + sourceCredential, + new ImpersonatedCredential.Initializer(principal)); + var googleCredential = credential as IGoogleCredential; + + Assert.Equal(universeDomain, await googleCredential.GetUniverseDomainAsync(default)); + Assert.Equal(universeDomain, googleCredential.GetUniverseDomain()); + + string expectedTokenUrl = string.Format(GoogleAuthConsts.IamAccessTokenEndpointFormatString, universeDomain, principal); + + Assert.Null(credential.TokenServerUrl); + Assert.False(await credential.HasCustomTokenUrlCache.Value); + Assert.Equal(expectedTokenUrl, await credential.EffectiveTokenUrlCache.Value); + } + + [Fact] + public async Task WithUniverseDomain() + { + string principal = "principal"; + string universeDomain1 = "universe1.domain.com"; + string universeDomain2 = "universe2.domain.com"; + + var sourceCredential = GoogleCredential.FromComputeCredential(new ComputeCredential(new ComputeCredential.Initializer() + { + UniverseDomain = universeDomain1 + })); + + var credential = ImpersonatedCredential.Create( + sourceCredential, + new ImpersonatedCredential.Initializer(principal)); + var googleCredential = credential as IGoogleCredential; + + var newGoogleCredential = googleCredential.WithUniverseDomain(universeDomain2) ; + + var newCredential = Assert.IsType(newGoogleCredential); + Assert.NotSame(credential, newCredential); + + Assert.NotSame(credential.SourceCredential, newCredential.SourceCredential); + var newSourceCredential = Assert.IsType(newCredential.SourceCredential); + Assert.IsType(newSourceCredential.UnderlyingCredential); + + Assert.Equal(universeDomain1, await credential.SourceCredential.GetUniverseDomainAsync(default)); + Assert.Equal(universeDomain2, await newCredential.SourceCredential.GetUniverseDomainAsync(default)); + + Assert.Equal(universeDomain1, await googleCredential.GetUniverseDomainAsync(default)); + Assert.Equal(universeDomain1, googleCredential.GetUniverseDomain()); + + Assert.Equal(universeDomain2, await newGoogleCredential.GetUniverseDomainAsync(default)); + Assert.Equal(universeDomain2, newGoogleCredential.GetUniverseDomain()); + + string expectedTokenUrl = string.Format(GoogleAuthConsts.IamAccessTokenEndpointFormatString, universeDomain1, principal); + + Assert.Null(credential.TokenServerUrl); + Assert.False(await credential.HasCustomTokenUrlCache.Value); + Assert.Equal(expectedTokenUrl, await credential.EffectiveTokenUrlCache.Value); + + string newExpectedTokenUrl = string.Format(GoogleAuthConsts.IamAccessTokenEndpointFormatString, universeDomain2, principal); + + Assert.Null(newCredential.TokenServerUrl); + Assert.False(await newCredential.HasCustomTokenUrlCache.Value); + Assert.Equal(newExpectedTokenUrl, await newCredential.EffectiveTokenUrlCache.Value); + } } } diff --git a/Src/Support/Google.Apis.Auth/OAuth2/ImpersonatedCredential.cs b/Src/Support/Google.Apis.Auth/OAuth2/ImpersonatedCredential.cs index bda4627025..7c01c12ed4 100644 --- a/Src/Support/Google.Apis.Auth/OAuth2/ImpersonatedCredential.cs +++ b/Src/Support/Google.Apis.Auth/OAuth2/ImpersonatedCredential.cs @@ -57,27 +57,14 @@ public sealed class ImpersonatedCredential : ServiceCredential, IOidcTokenProvid /// public TimeSpan Lifetime { get; set; } = DefaultLifetime; - /// - /// Indicates whether the credential has a custom access token URL instead of - /// the one built by using - /// and the target principal. - /// - /// - /// This is useful for bundled or implicit impersonation scenarios in which the access token - /// URL may be specified on its own as part of the credential configuration. In those cases, - /// some operations are not supported, for instance operations from - /// or . - /// Note that we keep this property internal as no - /// instance will be exposed that has been built with a custom token URL. - /// - internal bool HasCustomTokenUrl { get; } - /// Constructs a new initializer. /// The principal that will be impersonated. Must not be null, as it will be used /// to build the URL to obtaing the impersonated access token from. public Initializer(string targetPrincipal) - : base(GetDefaultTokenUrl(targetPrincipal.ThrowIfNull(nameof(targetPrincipal)))) => - TargetPrincipal = targetPrincipal; + // We cannot build the token URL just yet, we need the universe domain and that comes from the source credential. + // In this case, we'll defer creating the token URL to the first access token request. + : base(tokenServerUrl: null) => + TargetPrincipal = targetPrincipal.ThrowIfNull(nameof(targetPrincipal)); /// /// Constructus a new initializer. @@ -88,19 +75,14 @@ public Initializer(string targetPrincipal) /// access token, is just informational when the /// constructor overload is used. internal Initializer(string customTokenUrl, string maybeTargetPrincipal) - : base(customTokenUrl.ThrowIfNullOrEmpty(nameof(customTokenUrl))) - { + : base(customTokenUrl.ThrowIfNullOrEmpty(nameof(customTokenUrl))) => TargetPrincipal = maybeTargetPrincipal; - HasCustomTokenUrl = maybeTargetPrincipal is null - || GetDefaultTokenUrl(maybeTargetPrincipal) != customTokenUrl; - } internal Initializer(ImpersonatedCredential other) : base(other) { TargetPrincipal = other.TargetPrincipal; DelegateAccounts = other.DelegateAccounts; Lifetime = other.Lifetime; - HasCustomTokenUrl = other.HasCustomTokenUrl; } internal Initializer(Initializer other) : base (other) @@ -108,13 +90,23 @@ internal Initializer(Initializer other) : base (other) TargetPrincipal = other.TargetPrincipal; DelegateAccounts = other.DelegateAccounts?.ToList().AsReadOnly() ?? Enumerable.Empty(); Lifetime = other.Lifetime; - HasCustomTokenUrl = other.HasCustomTokenUrl; } - - private static string GetDefaultTokenUrl(string targetPrincipal) => - string.Format(GoogleAuthConsts.IamAccessTokenEndpointFormatString, GoogleAuthConsts.DefaultUniverseDomain, targetPrincipal); } + /// + /// The id token URL. + /// If this credential does not have a custom access token URL, the id token is supported through the IAM API. + /// The id token URL is built using the universe domain and the target principal. + /// + private readonly Lazy> _oidcTokenUrlCache; + + /// + /// The blob signing URL. + /// If this credential does not have a custom access token URL, blob signing is supported through the IAM API. + /// The blob signing URL is built using the universe domain and the target principal. + /// + private readonly Lazy> _signBlobUrlCache; + /// /// Gets the source credential used to acquire the impersonated credentials. /// @@ -137,30 +129,26 @@ private static string GetDefaultTokenUrl(string targetPrincipal) => /// public TimeSpan Lifetime { get; } + /// + /// Whether the effective access token URL is custom or not. + /// If the impersonated credential has a custom access token URL we don't know how the OIDC URL and blob signing + /// URL may look like, so we cannot support those operations. + /// + internal Lazy> HasCustomTokenUrlCache { get; } + + /// + /// The effective token URL to be used by this credential, which may be a custom token URL + /// or the IAM API access token endpoint URL which is built using the universe domain and the + /// target principal of this credential. + /// + internal Lazy> EffectiveTokenUrlCache { get; } + /// bool IGoogleCredential.HasExplicitScopes => Scopes?.Any() == true; /// bool IGoogleCredential.SupportsExplicitScopes => true; - /// - /// Indicates whether the credential has a custom access token URL instead of - /// the one built by using - /// and the target principal. - /// - /// - /// This is useful for bundled or implicit impersonation scenarios in which the access token - /// URL may be specified on its own as part of the credential configuration. In those cases, - /// some operations are not supported, for instance operations from - /// or . - /// Note that we keep this property internal as no - /// instance will be exposed that has been built with a custom token URL. We only build s - /// with custom token URLs when s are configured with bundled or implicit impersonation, - /// where is only internal. - /// - /// - internal bool HasCustomTokenUrl { get; } - internal static ImpersonatedCredential Create(GoogleCredential sourceCredential, Initializer initializer) { initializer.ThrowIfNull(nameof(initializer)); @@ -200,14 +188,19 @@ private ImpersonatedCredential(Initializer initializer) : base(initializer) // to be our own local copy. We can avoid copying these collections here. DelegateAccounts = initializer.DelegateAccounts; Lifetime = initializer.Lifetime; - HasCustomTokenUrl = initializer.HasCustomTokenUrl; + + EffectiveTokenUrlCache = new Lazy>(GetEffectiveTokenUrlUncachedAsync); + HasCustomTokenUrlCache = new Lazy>(HasCustomTokenUrlUncachedAsync); + _oidcTokenUrlCache = new Lazy>(GetIdTokenUrlUncachedAsync); + _signBlobUrlCache = new Lazy>(GetSignBlobUrlUncachedAsync); } /// - Task IGoogleCredential.GetUniverseDomainAsync(CancellationToken cancellationToken) => throw new NotImplementedException(); + Task IGoogleCredential.GetUniverseDomainAsync(CancellationToken cancellationToken) => + SourceCredential.GetUniverseDomainAsync(cancellationToken); /// - string IGoogleCredential.GetUniverseDomain() => throw new NotImplementedException(); + string IGoogleCredential.GetUniverseDomain() => SourceCredential.GetUniverseDomain(); /// IGoogleCredential IGoogleCredential.WithQuotaProject(string quotaProject) => @@ -226,7 +219,16 @@ IGoogleCredential IGoogleCredential.WithHttpClientFactory(IHttpClientFactory htt new ImpersonatedCredential(new Initializer(this) { HttpClientFactory = httpClientFactory }); /// - IGoogleCredential IGoogleCredential.WithUniverseDomain(string universeDomain) => throw new NotImplementedException(); + IGoogleCredential IGoogleCredential.WithUniverseDomain(string universeDomain) + { + var newCredential = SourceCredential.CreateWithUniverseDomain(universeDomain); + var newInitializer = new Initializer(this); + + // Now we detach the new initializer from this instance's source credential by removing it from HTPP client initializers. + newInitializer.HttpClientInitializers.Remove(SourceCredential); + + return Create(newCredential, newInitializer); + } /// public override async Task RequestAccessTokenAsync(CancellationToken taskCancellationToken) @@ -238,34 +240,38 @@ public override async Task RequestAccessTokenAsync(CancellationToken taskC Lifetime = $"{(int)Lifetime.TotalSeconds}s" }; - Token = await request.PostJsonAsync(HttpClient, TokenServerUrl, Clock, Logger, taskCancellationToken) + string effectiveTokenUrl = await EffectiveTokenUrlCache.Value.WithCancellationToken(taskCancellationToken).ConfigureAwait(false); + + Token = await request.PostJsonAsync(HttpClient, effectiveTokenUrl, Clock, Logger, taskCancellationToken) .ConfigureAwait(false); return true; } /// - public Task GetOidcTokenAsync(OidcTokenOptions options, CancellationToken cancellationToken = default) + public async Task GetOidcTokenAsync(OidcTokenOptions options, CancellationToken cancellationToken = default) { - ThrowIfCustomTokenUrl(); + await ThrowIfCustomTokenUrlAsync(cancellationToken).ConfigureAwait(false); + options.ThrowIfNull(nameof(options)); // If at some point some properties are added to OidcToken that depend on the token having been fetched // then initialize the token here. TokenRefreshManager tokenRefreshManager = null; tokenRefreshManager = new TokenRefreshManager(ct => RefreshOidcTokenAsync(tokenRefreshManager, options, ct), Clock, Logger); - return Task.FromResult(new OidcToken(tokenRefreshManager)); + return new OidcToken(tokenRefreshManager); } private async Task RefreshOidcTokenAsync(TokenRefreshManager caller, OidcTokenOptions oidcTokenOptions, CancellationToken cancellationToken) { - ThrowIfCustomTokenUrl(); + await ThrowIfCustomTokenUrlAsync(cancellationToken).ConfigureAwait(false); + var request = new ImpersonationOIdCTokenRequest { DelegateAccounts = DelegateAccounts, Audience = oidcTokenOptions.TargetAudience, IncludeEmail = true }; - var oidcTokenUrl = string.Format(GoogleAuthConsts.IamIdTokenEndpointFormatString, GoogleAuthConsts.DefaultUniverseDomain, TargetPrincipal); + var oidcTokenUrl = await _oidcTokenUrlCache.Value.WithCancellationToken(cancellationToken).ConfigureAwait(false); caller.Token = await request.PostJsonAsync(HttpClient, oidcTokenUrl, Clock, Logger, cancellationToken) .ConfigureAwait(false); @@ -283,13 +289,14 @@ private async Task RefreshOidcTokenAsync(TokenRefreshManager caller, OidcT /// When signing response is not a valid JSON. public async Task SignBlobAsync(byte[] blob, CancellationToken cancellationToken = default) { - ThrowIfCustomTokenUrl(); + await ThrowIfCustomTokenUrlAsync(cancellationToken).ConfigureAwait(false); + var request = new IamSignBlobRequest { DelegateAccounts = DelegateAccounts, Payload = blob }; - var signBlobUrl = string.Format(GoogleAuthConsts.IamSignEndpointFormatString, GoogleAuthConsts.DefaultUniverseDomain, TargetPrincipal); + var signBlobUrl = await _signBlobUrlCache.Value.WithCancellationToken(cancellationToken).ConfigureAwait(false); var response = await request.PostJsonAsync(HttpClient, signBlobUrl, cancellationToken) .ConfigureAwait(false); @@ -297,18 +304,81 @@ public async Task SignBlobAsync(byte[] blob, CancellationToken cancellat return response.SignedBlob; } - private void ThrowIfCustomTokenUrl() + /// + /// Returns the token URL to be used by this credential, which may be a custom token URL + /// or the IAM API access tokne endpoint URL which is built using the universe domain and the + /// target principal of this credential. + /// A custom access token URL could be present in external credentials configuration. + /// + private async Task GetEffectiveTokenUrlUncachedAsync() { - if (HasCustomTokenUrl) + if (TokenServerUrl is not null) + { + // This is either a custom token URL or a default token URL for a credential other than ComputeCredential, + // i.e. we could calculate the endpoint synchronusly on credential creation. + return TokenServerUrl; + } + string univerDomain = await (this as IGoogleCredential).GetUniverseDomainAsync(cancellationToken: default).ConfigureAwait(false); + return GetDefaultTokenUrl(univerDomain, TargetPrincipal); + } + + /// + /// Determines whether the effective access token URL is custom or not. + /// If the impersonated credential has a custom access token URL we don't know how the OIDC URL and blob signing + /// URL may look like, so we cannot support those operations. + /// + private async Task HasCustomTokenUrlUncachedAsync() + { + string effectiveTokenUrl = await EffectiveTokenUrlCache.Value.ConfigureAwait(false); + string universeDomain = await (this as IGoogleCredential).GetUniverseDomainAsync(cancellationToken: default).ConfigureAwait(false); + + return effectiveTokenUrl != GetDefaultTokenUrl(universeDomain, TargetPrincipal); + } + + /// + /// Get's the id token URL if this credential supports id token emission. + /// Throws otherwise. + /// + private async Task GetIdTokenUrlUncachedAsync() + { + await ThrowIfCustomTokenUrlAsync(cancellationToken: default).ConfigureAwait(false); + + string universeDomain = await (this as IGoogleCredential).GetUniverseDomainAsync(cancellationToken: default).ConfigureAwait(false); + return string.Format(GoogleAuthConsts.IamIdTokenEndpointFormatString, universeDomain, TargetPrincipal); + } + + /// + /// Get's the blob signing URL if this credential supports blob signing. + /// Throws otherwise. + /// + private async Task GetSignBlobUrlUncachedAsync() + { + await ThrowIfCustomTokenUrlAsync(cancellationToken: default).ConfigureAwait(false); + + string universeDomain = await (this as IGoogleCredential).GetUniverseDomainAsync(cancellationToken: default).ConfigureAwait(false); + return string.Format(GoogleAuthConsts.IamSignEndpointFormatString, universeDomain, TargetPrincipal); + } + + /// + /// If the impersonated credential has a custom access token URL we don't know how the OIDC URL and blob signing + /// URL may look like, so we cannot support those operations. + /// A custom access token URL could be present in external credentials configuration. + /// + private async Task ThrowIfCustomTokenUrlAsync(CancellationToken cancellationToken) + { + bool hasCustomTokenUrl = await HasCustomTokenUrlCache.Value.WithCancellationToken(cancellationToken).ConfigureAwait(false); + + if (hasCustomTokenUrl) { // If the impersonated credential has a custom access token URL we don't know how the OIDC URL and blob signing // URL may look like, so we cannot support those operations. - // For supporting TPC, regional endpoints, etc. we only need to change the definition of custom, which at the moment is: - // everything different of GoogleAuthConsts.IamAccessTokenEndpointFormatString. throw new InvalidOperationException("Operation not supported when a custom access token URL has been specified."); } } + private static string GetDefaultTokenUrl(string universeDomain, string targetPrincipal) => + string.Format(GoogleAuthConsts.IamAccessTokenEndpointFormatString, universeDomain, targetPrincipal); + /// /// Attempts to extract the target principal ID from the impersonation URL which is possible if the URL looks like /// https://host/segment-1/.../segment-n/target-principal-ID:generateAccessToken. diff --git a/Src/Support/Google.Apis.Auth/OAuth2/ServiceCredential.cs b/Src/Support/Google.Apis.Auth/OAuth2/ServiceCredential.cs index bd0d8a925e..de23a482a9 100644 --- a/Src/Support/Google.Apis.Auth/OAuth2/ServiceCredential.cs +++ b/Src/Support/Google.Apis.Auth/OAuth2/ServiceCredential.cs @@ -150,7 +150,14 @@ internal Initializer(Initializer other) } } - /// Gets the token server URL. + /// + /// Gets the token server URL. + /// + /// + /// May be null for credential types that resolve token endpoints just before obtaining an access token. + /// This is the case for where the + /// is a . + /// public string TokenServerUrl { get; } /// Gets the clock used to refresh the token if it expires.