Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Makes ComputeCredential support scopes #2103

Merged
merged 2 commits into from
Apr 19, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -153,5 +155,95 @@ public async Task FetchesOidcToken_WithOptions(OidcTokenFormat format, string ta

Assert.Equal(expectedQueryString, messageHandler.LatestRequest.RequestUri.Query);
}

public static TheoryData<string[], string> Scoped_WithDefaultTokenUrl_Data =>
new TheoryData<string[], string>
{
// explicit scopes, expected token URL
{ null, GoogleAuthConsts.EffectiveComputeTokenUrl },
{ new string[] { "scope1", "scope2"}, $"{GoogleAuthConsts.EffectiveComputeTokenUrl}?scopes=scope1,scope2" }
};

public static TheoryData<string[], string, string> Scoped_WithCustomTokenUrl_Data =>
new TheoryData<string[], string, string>
{
// 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<string>(),
(scopes?.Select<string, Action<string>>(expectedScope => actualScope => Assert.Equal(expectedScope, actualScope)) ?? Enumerable.Empty<Action<string>>()).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);
}
}
}
36 changes: 31 additions & 5 deletions Src/Support/Google.Apis.Auth/OAuth2/ComputeCredential.cs
Original file line number Diff line number Diff line change
Expand Up @@ -68,10 +68,12 @@ public class ComputeCredential : ServiceCredential, IOidcTokenProvider, IGoogleC
public string OidcTokenUrl { get; }

/// <inheritdoc/>
bool IGoogleCredential.HasExplicitScopes => false;
bool IGoogleCredential.HasExplicitScopes => HasExplicitScopes;

/// <inheritdoc/>
bool IGoogleCredential.SupportsExplicitScopes => false;
bool IGoogleCredential.SupportsExplicitScopes => true;
jskeet marked this conversation as resolved.
Show resolved Hide resolved

internal string EffectiveTokenServerUrl { get; }

/// <summary>
/// An initializer class for the Compute credential. It uses <see cref="GoogleAuthConsts.ComputeTokenUrl"/>
Expand Down Expand Up @@ -107,14 +109,38 @@ internal Initializer(ComputeCredential other)
public ComputeCredential() : this(new Initializer()) { }

/// <summary>Constructs a new Compute credential instance.</summary>
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}";
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So setting the query you don't want the leading "?" (prior to .NET 5), but fetching it you receive it? That sucks so much.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, it's all very "weird", some tests failed and some tests didn't when I first just appended the scopes with the potential & only, until I found those docs with the example. Anyway...

}
EffectiveTokenServerUrl = uriBuilder.Uri.AbsoluteUri;
}
else
{
EffectiveTokenServerUrl = TokenServerUrl;
}
}

/// <inheritdoc/>
IGoogleCredential IGoogleCredential.WithQuotaProject(string quotaProject) =>
new ComputeCredential(new Initializer(this) { QuotaProject = quotaProject });

/// <inheritdoc/>
IGoogleCredential IGoogleCredential.MaybeWithScopes(IEnumerable<string> scopes) => this;
IGoogleCredential IGoogleCredential.MaybeWithScopes(IEnumerable<string> scopes) =>
new ComputeCredential(new Initializer(this) { Scopes = scopes });

/// <inheritdoc/>
IGoogleCredential IGoogleCredential.WithUserForDomainWideDelegation(string user) =>
Expand All @@ -130,7 +156,7 @@ IGoogleCredential IGoogleCredential.WithHttpClientFactory(IHttpClientFactory htt
public override async Task<bool> 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);
Expand Down
8 changes: 6 additions & 2 deletions Src/Support/Google.Apis.Auth/OAuth2/GoogleCredential.cs
Original file line number Diff line number Diff line change
Expand Up @@ -218,8 +218,12 @@ public static GoogleCredential FromComputeCredential(ComputeCredential computeCr
/// <list type="number">
/// <item>
/// <description>
/// <see cref="ComputeCredential"/> is scoped by default. This library doesn't currently
/// support explicit scopes to be set on a <see cref="ComputeCredential"/>.
/// <see cref="ComputeCredential"/> 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 <see cref="ComputeCredential"/> with explicit scopes set by calling
/// <see cref="CreateScoped(IEnumerable{string})"/>. 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.
/// </description>
/// </item>
/// <item>
Expand Down
13 changes: 0 additions & 13 deletions Src/Support/Google.Apis.Auth/OAuth2/ImpersonatedCredential.cs
Original file line number Diff line number Diff line change
Expand Up @@ -49,11 +49,6 @@ public sealed class ImpersonatedCredential : ServiceCredential, IOidcTokenProvid
/// </summary>
public IEnumerable<string> DelegateAccounts { get; set; }

/// <summary>
/// Gets the scopes to request during the authorization grant. May be null or empty.
/// </summary>
public IEnumerable<string> Scopes { get; set; }
jskeet marked this conversation as resolved.
Show resolved Hide resolved

/// <summary>
/// Gets or sets for how long the delegated credential should be valid.
/// Defaults to 1 hour or 3600 seconds.
Expand All @@ -73,15 +68,13 @@ internal Initializer(ImpersonatedCredential other) : base(other)
{
TargetPrincipal = other.TargetPrincipal;
DelegateAccounts = other.DelegateAccounts;
Scopes = other.Scopes;
Lifetime = other.Lifetime;
}

internal Initializer(Initializer other) : base (other)
{
TargetPrincipal = other.TargetPrincipal;
DelegateAccounts = other.DelegateAccounts?.ToList().AsReadOnly() ?? Enumerable.Empty<string>();
Scopes = other.Scopes?.ToList().AsReadOnly() ?? Enumerable.Empty<string>();
Lifetime = other.Lifetime;
}
}
Expand All @@ -101,11 +94,6 @@ internal Initializer(Initializer other) : base (other)
/// </summary>
public IEnumerable<string> DelegateAccounts { get; }

/// <summary>
/// Gets the scopes to request during the authorization grant. May be empty.
/// </summary>
public IEnumerable<string> Scopes { get; }

/// <summary>
/// Gets the lifetime of the delegated credential.
/// This is how long the delegated credential should be valid from the time
Expand Down Expand Up @@ -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;
}

Expand Down
27 changes: 4 additions & 23 deletions Src/Support/Google.Apis.Auth/OAuth2/ServiceAccountCredential.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -76,9 +75,6 @@ public class ServiceAccountCredential : ServiceCredential, IOidcTokenProvider, I
/// </summary>
public string User { get; set; }

/// <summary>Gets the scopes which indicate API access your application is requesting.</summary>
public IEnumerable<string> Scopes { get; set; }

/// <summary>
/// Gets or sets the key which is used to sign the request, as specified in
/// https://developers.google.com/accounts/docs/OAuth2ServiceAccount#computingsignature.
Expand All @@ -100,18 +96,13 @@ public Initializer(string id)
: this(id, GoogleAuthConsts.OidcTokenUrl) { }

/// <summary>Constructs a new initializer using the given id and the token server URL.</summary>
public Initializer(string id, string tokenServerUrl) : base(tokenServerUrl)
{
Id = id;
Scopes = new List<string>();
}
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;
Expand Down Expand Up @@ -161,9 +152,6 @@ public Initializer FromCertificate(X509Certificate2 certificate)
/// </summary>
public string User { get; }

/// <summary>Gets the service account scopes.</summary>
public IEnumerable<string> Scopes { get; }

/// <summary>
/// Gets the key which is used to sign the request, as specified in
/// https://developers.google.com/accounts/docs/OAuth2ServiceAccount#computingsignature.
Expand All @@ -182,12 +170,6 @@ public Initializer FromCertificate(X509Certificate2 certificate)
/// </summary>
public bool UseJwtAccessWithScopes { get; }

/// <summary>
/// Returns true if this credential scopes have been explicitly set via this library.
/// Returns false otherwise.
/// </summary>
internal bool HasExplicitScopes => Scopes?.Any() == true;

/// <inheritdoc/>
bool IGoogleCredential.HasExplicitScopes => HasExplicitScopes;

Expand All @@ -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<string>();
Key = initializer.Key.ThrowIfNull("initializer.Key");
KeyId = initializer.KeyId;
UseJwtAccessWithScopes = initializer.UseJwtAccessWithScopes;
Expand Down Expand Up @@ -282,16 +263,16 @@ public override async Task<bool> RequestAccessTokenAsync(CancellationToken taskC
/// Gets an access token to authorize a request.
/// An OAuth2 access token obtained from <see cref="ServiceCredential.TokenServerUrl"/> will be returned
/// in the following two cases:
/// 1. If this credential has <see cref="Scopes"/> associated, but <see cref="UseJwtAccessWithScopes"/>
/// 1. If this credential has <see cref="ServiceCredential.Scopes"/> associated, but <see cref="UseJwtAccessWithScopes"/>
/// is false;
/// 2. If this credential is used with domain-wide delegation, that is, the <see cref="User"/> is set;
/// Otherwise, a locally signed JWT will be returned.
/// The signed JWT will contain a "scope" claim with the scopes in <see cref="Scopes"/> if there are any,
/// The signed JWT will contain a "scope" claim with the scopes in <see cref="ServiceCredential.Scopes"/> if there are any,
/// otherwise it will contain an "aud" claim with <paramref name="authUri"/>.
/// A cached token is used if possible and the token is only refreshed once it's close to its expiry.
/// </summary>
/// <param name="authUri">The URI the returned token will grant access to.
/// Should be specified if no <see cref="Scopes"/> have been specified for the credential.</param>
/// Should be specified if no <see cref="ServiceCredential.Scopes"/> have been specified for the credential.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The access token.</returns>
public override async Task<string> GetAccessTokenForRequestAsync(string authUri = null, CancellationToken cancellationToken = default)
Expand Down
Loading