diff --git a/Src/Support/Google.Apis.Auth.Tests/OAuth2/ComputeCredentialTests.cs b/Src/Support/Google.Apis.Auth.Tests/OAuth2/ComputeCredentialTests.cs index 8fa1879584..60ec2193b9 100644 --- a/Src/Support/Google.Apis.Auth.Tests/OAuth2/ComputeCredentialTests.cs +++ b/Src/Support/Google.Apis.Auth.Tests/OAuth2/ComputeCredentialTests.cs @@ -18,6 +18,8 @@ limitations under the License. using Google.Apis.Http; using Google.Apis.Tests.Mocks; using System; +using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; using Xunit; using static Google.Apis.Auth.JsonWebSignature; @@ -153,5 +155,95 @@ public async Task FetchesOidcToken_WithOptions(OidcTokenFormat format, string ta Assert.Equal(expectedQueryString, messageHandler.LatestRequest.RequestUri.Query); } + + public static TheoryData Scoped_WithDefaultTokenUrl_Data => + new TheoryData + { + // explicit scopes, expected token URL + { null, GoogleAuthConsts.EffectiveComputeTokenUrl }, + { new string[] { "scope1", "scope2"}, $"{GoogleAuthConsts.EffectiveComputeTokenUrl}?scopes=scope1,scope2" } + }; + + public static TheoryData Scoped_WithCustomTokenUrl_Data => + new TheoryData + { + // explicit scopes, custom token URL, expected token URL + { null, "https://custom.metadata.server/compute/token", "https://custom.metadata.server/compute/token" }, + { null, "https://custom.metadata.server/compute/token?parameter=value", "https://custom.metadata.server/compute/token?parameter=value" }, + { new string[] { "scope1", "scope2" }, "https://custom.metadata.server/compute/token", "https://custom.metadata.server/compute/token?scopes=scope1,scope2" }, + { new string[] { "scope1", "scope2" }, "https://custom.metadata.server/compute/token?parameter=value", "https://custom.metadata.server/compute/token?parameter=value&scopes=scope1,scope2" } + }; + + private void AssertScoped(ComputeCredential credential, string[] scopes, string expectedTokenUrl) + { + Assert.Collection(credential.Scopes ?? Enumerable.Empty(), + (scopes?.Select>(expectedScope => actualScope => Assert.Equal(expectedScope, actualScope)) ?? Enumerable.Empty>()).ToArray()); + Assert.Equal(expectedTokenUrl, credential.EffectiveTokenServerUrl); + } + + private async Task AssertUsesScopedUrl(ComputeCredential credential, FetchesTokenMessageHandler fakeMessageHandler, string expectedTokenUrl) + { + Assert.NotNull(await credential.GetAccessTokenForRequestAsync()); + Assert.Equal(1, fakeMessageHandler.Calls); + Assert.Equal(expectedTokenUrl, fakeMessageHandler.Requests.First().RequestUri.AbsoluteUri); + } + + [Theory] + [MemberData(nameof(Scoped_WithDefaultTokenUrl_Data))] + public async Task Scoped_Initializer_WithDefaultTokenUrl(string[] scopes, string expectedTokenUrl) + { + var fakeMessageHandler = new FetchesTokenMessageHandler(); + var credential = new ComputeCredential(new ComputeCredential.Initializer() + { + Scopes = scopes, + HttpClientFactory = new MockHttpClientFactory(fakeMessageHandler) + }); + + AssertScoped(credential, scopes, expectedTokenUrl); + await AssertUsesScopedUrl(credential, fakeMessageHandler, expectedTokenUrl); + } + + [Theory] + [MemberData(nameof(Scoped_WithDefaultTokenUrl_Data))] + public async Task Scoped_MaybeWithScopes_WithDefaultTokenUrl(string[] scopes, string expectedTokenUrl) + { + var fakeMessageHandler = new FetchesTokenMessageHandler(); + var credential = (new ComputeCredential(new ComputeCredential.Initializer() + { + HttpClientFactory = new MockHttpClientFactory(fakeMessageHandler) + }) as IGoogleCredential).MaybeWithScopes(scopes) as ComputeCredential; + + AssertScoped(credential, scopes, expectedTokenUrl); + await AssertUsesScopedUrl(credential, fakeMessageHandler, expectedTokenUrl); + } + + [Theory] + [MemberData(nameof(Scoped_WithCustomTokenUrl_Data))] + public async Task Scoped_Initializer_WithCustomTokenUrl(string[] scopes, string customTokenUrl, string expectedTokenUrl) + { + var fakeMessageHandler = new FetchesTokenMessageHandler(); + var credential = new ComputeCredential(new ComputeCredential.Initializer(customTokenUrl) + { + Scopes = scopes, + HttpClientFactory = new MockHttpClientFactory(fakeMessageHandler) + }); + + AssertScoped(credential, scopes, expectedTokenUrl); + await AssertUsesScopedUrl(credential, fakeMessageHandler, expectedTokenUrl); + } + + [Theory] + [MemberData(nameof(Scoped_WithCustomTokenUrl_Data))] + public async Task Scoped_MaybeWithScopes_WithCustomTokenUrl(string[] scopes, string customTokenUrl, string expectedTokenUrl) + { + var fakeMessageHandler = new FetchesTokenMessageHandler(); + var credential = (new ComputeCredential(new ComputeCredential.Initializer(customTokenUrl) + { + HttpClientFactory = new MockHttpClientFactory(fakeMessageHandler) + }) as IGoogleCredential).MaybeWithScopes(scopes) as ComputeCredential; + + AssertScoped(credential, scopes, expectedTokenUrl); + await AssertUsesScopedUrl(credential, fakeMessageHandler, expectedTokenUrl); + } } } diff --git a/Src/Support/Google.Apis.Auth/OAuth2/ComputeCredential.cs b/Src/Support/Google.Apis.Auth/OAuth2/ComputeCredential.cs index bf96859e9a..327b92874c 100644 --- a/Src/Support/Google.Apis.Auth/OAuth2/ComputeCredential.cs +++ b/Src/Support/Google.Apis.Auth/OAuth2/ComputeCredential.cs @@ -68,10 +68,12 @@ public class ComputeCredential : ServiceCredential, IOidcTokenProvider, IGoogleC public string OidcTokenUrl { get; } /// - bool IGoogleCredential.HasExplicitScopes => false; + bool IGoogleCredential.HasExplicitScopes => HasExplicitScopes; /// - bool IGoogleCredential.SupportsExplicitScopes => false; + bool IGoogleCredential.SupportsExplicitScopes => true; + + internal string EffectiveTokenServerUrl { get; } /// /// An initializer class for the Compute credential. It uses @@ -107,14 +109,38 @@ internal Initializer(ComputeCredential other) public ComputeCredential() : this(new Initializer()) { } /// Constructs a new Compute credential instance. - public ComputeCredential(Initializer initializer) : base(initializer) => OidcTokenUrl = initializer.OidcTokenUrl; + public ComputeCredential(Initializer initializer) : base(initializer) + { + OidcTokenUrl = initializer.OidcTokenUrl; + if (HasExplicitScopes) + { + var uriBuilder = new UriBuilder(TokenServerUrl); + string scopesQuery = $"scopes={string.Join(",", Scopes)}"; + + // As per https://docs.microsoft.com/en-us/dotnet/api/system.uribuilder.query?view=net-6.0#examples + if (uriBuilder.Query is null || uriBuilder.Query.Length <= 1) + { + uriBuilder.Query = scopesQuery; + } + else + { + uriBuilder.Query = $"{uriBuilder.Query.Substring(1)}&{scopesQuery}"; + } + EffectiveTokenServerUrl = uriBuilder.Uri.AbsoluteUri; + } + else + { + EffectiveTokenServerUrl = TokenServerUrl; + } + } /// IGoogleCredential IGoogleCredential.WithQuotaProject(string quotaProject) => new ComputeCredential(new Initializer(this) { QuotaProject = quotaProject }); /// - IGoogleCredential IGoogleCredential.MaybeWithScopes(IEnumerable scopes) => this; + IGoogleCredential IGoogleCredential.MaybeWithScopes(IEnumerable scopes) => + new ComputeCredential(new Initializer(this) { Scopes = scopes }); /// IGoogleCredential IGoogleCredential.WithUserForDomainWideDelegation(string user) => @@ -130,7 +156,7 @@ IGoogleCredential IGoogleCredential.WithHttpClientFactory(IHttpClientFactory htt public override async Task RequestAccessTokenAsync(CancellationToken taskCancellationToken) { // Create and send the HTTP request to compute server token URL. - var httpRequest = new HttpRequestMessage(HttpMethod.Get, TokenServerUrl); + var httpRequest = new HttpRequestMessage(HttpMethod.Get, EffectiveTokenServerUrl); httpRequest.Headers.Add(MetadataFlavor, GoogleMetadataHeader); var response = await HttpClient.SendAsync(httpRequest, taskCancellationToken).ConfigureAwait(false); Token = await TokenResponse.FromHttpResponseAsync(response, Clock, Logger).ConfigureAwait(false); diff --git a/Src/Support/Google.Apis.Auth/OAuth2/GoogleCredential.cs b/Src/Support/Google.Apis.Auth/OAuth2/GoogleCredential.cs index 5ec69a71f8..2a13555ca2 100644 --- a/Src/Support/Google.Apis.Auth/OAuth2/GoogleCredential.cs +++ b/Src/Support/Google.Apis.Auth/OAuth2/GoogleCredential.cs @@ -218,8 +218,12 @@ public static GoogleCredential FromComputeCredential(ComputeCredential computeCr /// /// /// - /// is scoped by default. This library doesn't currently - /// support explicit scopes to be set on a . + /// is scoped by default but in some environments it may be scoped + /// explicitly, for instance when running on GKE with Workload Identity or on AppEngine Flex. + /// It's possible to create a with explicit scopes set by calling + /// . If running on an environment that does not + /// accept explicit scoping, for instance GCE where scopes are set on the VM, explicit scopes + /// will be ignored. /// /// /// diff --git a/Src/Support/Google.Apis.Auth/OAuth2/ImpersonatedCredential.cs b/Src/Support/Google.Apis.Auth/OAuth2/ImpersonatedCredential.cs index 874ed67e51..5f5899a85b 100644 --- a/Src/Support/Google.Apis.Auth/OAuth2/ImpersonatedCredential.cs +++ b/Src/Support/Google.Apis.Auth/OAuth2/ImpersonatedCredential.cs @@ -49,11 +49,6 @@ public sealed class ImpersonatedCredential : ServiceCredential, IOidcTokenProvid /// public IEnumerable DelegateAccounts { get; set; } - /// - /// Gets the scopes to request during the authorization grant. May be null or empty. - /// - public IEnumerable Scopes { get; set; } - /// /// Gets or sets for how long the delegated credential should be valid. /// Defaults to 1 hour or 3600 seconds. @@ -73,7 +68,6 @@ internal Initializer(ImpersonatedCredential other) : base(other) { TargetPrincipal = other.TargetPrincipal; DelegateAccounts = other.DelegateAccounts; - Scopes = other.Scopes; Lifetime = other.Lifetime; } @@ -81,7 +75,6 @@ internal Initializer(Initializer other) : base (other) { TargetPrincipal = other.TargetPrincipal; DelegateAccounts = other.DelegateAccounts?.ToList().AsReadOnly() ?? Enumerable.Empty(); - Scopes = other.Scopes?.ToList().AsReadOnly() ?? Enumerable.Empty(); Lifetime = other.Lifetime; } } @@ -101,11 +94,6 @@ internal Initializer(Initializer other) : base (other) /// public IEnumerable DelegateAccounts { get; } - /// - /// Gets the scopes to request during the authorization grant. May be empty. - /// - public IEnumerable Scopes { get; } - /// /// Gets the lifetime of the delegated credential. /// This is how long the delegated credential should be valid from the time @@ -146,7 +134,6 @@ private ImpersonatedCredential(Initializer initializer) : base(initializer) // We just copied tha initializer on the Create method so we know this // to be our own local copy. We can avoid copying these collections here. DelegateAccounts = initializer.DelegateAccounts; - Scopes = initializer.Scopes; Lifetime = initializer.Lifetime; } diff --git a/Src/Support/Google.Apis.Auth/OAuth2/ServiceAccountCredential.cs b/Src/Support/Google.Apis.Auth/OAuth2/ServiceAccountCredential.cs index 1f75aca394..57bdfd46b0 100644 --- a/Src/Support/Google.Apis.Auth/OAuth2/ServiceAccountCredential.cs +++ b/Src/Support/Google.Apis.Auth/OAuth2/ServiceAccountCredential.cs @@ -16,7 +16,6 @@ limitations under the License. using System; using System.Collections.Generic; -using System.Linq; using System.IO; using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; @@ -76,9 +75,6 @@ public class ServiceAccountCredential : ServiceCredential, IOidcTokenProvider, I /// public string User { get; set; } - /// Gets the scopes which indicate API access your application is requesting. - public IEnumerable Scopes { get; set; } - /// /// Gets or sets the key which is used to sign the request, as specified in /// https://developers.google.com/accounts/docs/OAuth2ServiceAccount#computingsignature. @@ -100,18 +96,13 @@ public Initializer(string id) : this(id, GoogleAuthConsts.OidcTokenUrl) { } /// Constructs a new initializer using the given id and the token server URL. - public Initializer(string id, string tokenServerUrl) : base(tokenServerUrl) - { - Id = id; - Scopes = new List(); - } + public Initializer(string id, string tokenServerUrl) : base(tokenServerUrl) => Id = id; internal Initializer(ServiceAccountCredential other) : base(other) { Id = other.Id; ProjectId = other.ProjectId; User = other.User; - Scopes = other.Scopes; Key = other.Key; KeyId = other.KeyId; UseJwtAccessWithScopes = other.UseJwtAccessWithScopes; @@ -161,9 +152,6 @@ public Initializer FromCertificate(X509Certificate2 certificate) /// public string User { get; } - /// Gets the service account scopes. - public IEnumerable Scopes { get; } - /// /// Gets the key which is used to sign the request, as specified in /// https://developers.google.com/accounts/docs/OAuth2ServiceAccount#computingsignature. @@ -182,12 +170,6 @@ public Initializer FromCertificate(X509Certificate2 certificate) /// public bool UseJwtAccessWithScopes { get; } - /// - /// Returns true if this credential scopes have been explicitly set via this library. - /// Returns false otherwise. - /// - internal bool HasExplicitScopes => Scopes?.Any() == true; - /// bool IGoogleCredential.HasExplicitScopes => HasExplicitScopes; @@ -200,7 +182,6 @@ public ServiceAccountCredential(Initializer initializer) : base(initializer) Id = initializer.Id.ThrowIfNullOrEmpty("initializer.Id"); ProjectId = initializer.ProjectId; User = initializer.User; - Scopes = initializer.Scopes?.ToList().AsReadOnly() ?? Enumerable.Empty(); Key = initializer.Key.ThrowIfNull("initializer.Key"); KeyId = initializer.KeyId; UseJwtAccessWithScopes = initializer.UseJwtAccessWithScopes; @@ -282,16 +263,16 @@ public override async Task RequestAccessTokenAsync(CancellationToken taskC /// Gets an access token to authorize a request. /// An OAuth2 access token obtained from will be returned /// in the following two cases: - /// 1. If this credential has associated, but + /// 1. If this credential has associated, but /// is false; /// 2. If this credential is used with domain-wide delegation, that is, the is set; /// Otherwise, a locally signed JWT will be returned. - /// The signed JWT will contain a "scope" claim with the scopes in if there are any, + /// The signed JWT will contain a "scope" claim with the scopes in if there are any, /// otherwise it will contain an "aud" claim with . /// A cached token is used if possible and the token is only refreshed once it's close to its expiry. /// /// The URI the returned token will grant access to. - /// Should be specified if no have been specified for the credential. + /// Should be specified if no have been specified for the credential. /// The cancellation token. /// The access token. public override async Task GetAccessTokenForRequestAsync(string authUri = null, CancellationToken cancellationToken = default) diff --git a/Src/Support/Google.Apis.Auth/OAuth2/ServiceCredential.cs b/Src/Support/Google.Apis.Auth/OAuth2/ServiceCredential.cs index 8aa9aaa353..04017817f2 100644 --- a/Src/Support/Google.Apis.Auth/OAuth2/ServiceCredential.cs +++ b/Src/Support/Google.Apis.Auth/OAuth2/ServiceCredential.cs @@ -20,6 +20,7 @@ limitations under the License. using Google.Apis.Util; using System; using System.Collections.Generic; +using System.Linq; using System.Net; using System.Net.Http; using System.Threading; @@ -84,6 +85,15 @@ public class Initializer /// public string QuotaProject { get; set; } + /// + /// Scopes to request during the authorization grant. May be null or empty. + /// + /// + /// If the scopes are pre-granted through the environement, like in GCE where scopes are granted to the VM, + /// scopes set here will be ignored. + /// + public IEnumerable Scopes { get; set; } + /// /// Initializers to be sent to the to be set /// on the that will be used by the credential to perform @@ -112,6 +122,7 @@ internal Initializer(ServiceCredential other) DefaultExponentialBackOffPolicy = other.DefaultExponentialBackOffPolicy; QuotaProject = other.QuotaProject; HttpClientInitializers = new List(other.HttpClientInitializers); + Scopes = other.Scopes; } internal Initializer(Initializer other) @@ -123,6 +134,7 @@ internal Initializer(Initializer other) DefaultExponentialBackOffPolicy = other.DefaultExponentialBackOffPolicy; QuotaProject = other.QuotaProject; HttpClientInitializers = new List(other.HttpClientInitializers); + Scopes = other.Scopes; } } @@ -138,6 +150,21 @@ internal Initializer(Initializer other) /// Gets the HTTP client used to make authentication requests to the server. public ConfigurableHttpClient HttpClient { get; } + /// + /// Scopes to request during the authorization grant. May be null or empty. + /// + /// + /// If the scopes are pre-granted through the environment, like in GCE where scopes are granted to the VM, + /// scopes set here will be ignored. + /// + public IEnumerable Scopes { get; set; } + + /// + /// Returns true if this credential scopes have been explicitly set via this library. + /// Returns false otherwise. + /// + internal bool HasExplicitScopes => Scopes?.Any() == true; + internal IHttpClientFactory HttpClientFactory { get; } /// @@ -170,6 +197,8 @@ public ServiceCredential(Initializer initializer) TokenServerUrl = initializer.TokenServerUrl; AccessMethod = initializer.AccessMethod.ThrowIfNull("initializer.AccessMethod"); Clock = initializer.Clock.ThrowIfNull("initializer.Clock"); + Scopes = initializer.Scopes?.Where(scope => !string.IsNullOrWhiteSpace(scope)).ToList().AsReadOnly() + ?? Enumerable.Empty(); // Set the HTTP client. var httpArgs = new CreateHttpClientArgs(); diff --git a/Src/Support/Google.Apis.Tests/Mocks/CountableMessageHandler.cs b/Src/Support/Google.Apis.Tests/Mocks/CountableMessageHandler.cs index 42a9ebc3d4..1810e272ca 100644 --- a/Src/Support/Google.Apis.Tests/Mocks/CountableMessageHandler.cs +++ b/Src/Support/Google.Apis.Tests/Mocks/CountableMessageHandler.cs @@ -14,6 +14,8 @@ You may obtain a copy of the License at limitations under the License. */ +using System.Collections.Concurrent; +using System.Collections.Generic; using System.Net.Http; using System.Threading; @@ -22,19 +24,22 @@ namespace Google.Apis.Tests.Mocks /// Base mock message handler which counts the number of calls. public abstract class CountableMessageHandler : HttpMessageHandler { - private int calls; + private int _calls; + private readonly ConcurrentQueue _requests = new ConcurrentQueue(); /// Gets or sets the calls counter. public int Calls { - get { return calls; } - set { calls = value; } + get { return _calls; } } + public IEnumerable Requests => _requests; + sealed protected override System.Threading.Tasks.Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { - Interlocked.Increment(ref calls); + Interlocked.Increment(ref _calls); + _requests.Enqueue(request); return SendAsyncCore(request, cancellationToken); }