Skip to content

Commit

Permalink
feat: Add universe domain support for Discovery based libraries.
Browse files Browse the repository at this point in the history
- The universe domain may be specified through the client service initializer.
- A helper method to calculate effective URIs that takes into account the universe domain. This method is to be used by generated code, see googleapis/gapic-generator-csharp#746.
- The universe domain is passed as a request option for the credential to validate against when intercepting and before setting the token.
  • Loading branch information
amanda-tarafa committed Feb 22, 2024
1 parent ae6f8fa commit ec8fdef
Show file tree
Hide file tree
Showing 12 changed files with 337 additions and 14 deletions.
86 changes: 86 additions & 0 deletions Src/Support/Google.Apis.Auth.Tests/OAuth2/GoogleCredentialTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -713,6 +713,92 @@ public async Task SignBlobAsync_UnsupportedCredential()
await Assert.ThrowsAsync<InvalidOperationException>(() => googleCredential.SignBlobAsync(Encoding.ASCII.GetBytes("toSign")));
}

[Fact]
public async Task UniverseDomain_RequestAndCredentialSame()
{
string universeDomain = "custom.domain";

HttpRequestMessage request = new HttpRequestMessage();
request.SetOption(GoogleAuthConsts.UniverseDomainKey, universeDomain);

IHttpExecuteInterceptor credential = GoogleCredential.FromAccessToken("fake_token")
.CreateWithUniverseDomain(universeDomain);

await credential.InterceptAsync(request, default);

Assert.Equal("fake_token", request.Headers.Authorization.Parameter);
}

[Fact]
public async Task UniverseDomain_NoneInRequestDefaultInCredential()
{
HttpRequestMessage request = new HttpRequestMessage();

IHttpExecuteInterceptor credential = GoogleCredential.FromAccessToken("fake_token");

await credential.InterceptAsync(request, default);

Assert.Equal("fake_token", request.Headers.Authorization.Parameter);
}

[Fact]
public async Task UniverseDomain_NoneInRequestCustomInCredential()
{
HttpRequestMessage request = new HttpRequestMessage();

IHttpExecuteInterceptor credential = GoogleCredential.FromAccessToken("fake_token")
.CreateWithUniverseDomain("custom.domain");

await credential.InterceptAsync(request, default);

// If the request has no universe domain information we don't validate,
// even if the credential has a custom domain. The request is not defaulted to googleapis.com,
// defaulting should happen at request origin. Basically, the credential can make a decision
// on defaults for itself but not for any and all requests.
Assert.Equal("fake_token", request.Headers.Authorization.Parameter);
}

[Fact]
public async Task UniverseDomain_DefaultInRequestNoneInCredential()
{
HttpRequestMessage request = new HttpRequestMessage();
request.SetOption(GoogleAuthConsts.UniverseDomainKey, "googleapis.com");

IHttpExecuteInterceptor credential = GoogleCredential.FromAccessToken("fake_token");

await credential.InterceptAsync(request, default);

// The credential defaults to googleapis.com.
Assert.Equal("fake_token", request.Headers.Authorization.Parameter);
}

[Fact]
public async Task UniverseDomain_CustomInRequestNoneInCredential()
{
HttpRequestMessage request = new HttpRequestMessage();
request.SetOption(GoogleAuthConsts.UniverseDomainKey, "custom.domain");

IHttpExecuteInterceptor credential = GoogleCredential.FromAccessToken("fake_token");

// The credential defaults to googleapis.com which is not the same as the custom domain
// specified in the credential.
await Assert.ThrowsAsync<InvalidOperationException>(() => credential.InterceptAsync(request, default));
Assert.Null(request.Headers.Authorization?.Parameter);
}

[Fact]
public async Task UniverseDomain_DifferentCustomInRequestAndCredential()
{
HttpRequestMessage request = new HttpRequestMessage();
request.SetOption(GoogleAuthConsts.UniverseDomainKey, "custom1.domain");

IHttpExecuteInterceptor credential = GoogleCredential.FromAccessToken("fake_token")
.CreateWithUniverseDomain("custom2.domain");

await Assert.ThrowsAsync<InvalidOperationException>(() => credential.InterceptAsync(request, default));
Assert.Null(request.Headers.Authorization?.Parameter);
}

/// <summary>
/// Fake implementation of <see cref="IAuthorizationCodeFlow"/> which only supports fetching the
/// clock and the access method.
Expand Down
6 changes: 6 additions & 0 deletions Src/Support/Google.Apis.Auth/OAuth2/GoogleAuthConsts.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ limitations under the License.
*/

using System;
using System.Net.Http;

namespace Google.Apis.Auth.OAuth2
{
Expand Down Expand Up @@ -105,6 +106,11 @@ public static class GoogleAuthConsts
/// </summary>
internal const string DefaultUniverseDomain = "googleapis.com";

/// <summary>
/// Key for a universe domain in a <see cref="HttpRequestMessage"/> options.
/// </summary>
internal const string UniverseDomainKey = "__UniverseDomainKey";

/// <summary>
/// The non empty value set on <see cref="QuotaProjectEnvironmentVariable"/>, if any;
/// null otherwise.
Expand Down
16 changes: 14 additions & 2 deletions Src/Support/Google.Apis.Auth/OAuth2/GoogleCredential.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ limitations under the License.
*/

using Google.Apis.Http;
using Google.Apis.Util;
using System;
using System.Collections.Generic;
using System.IO;
Expand Down Expand Up @@ -426,7 +427,18 @@ public static GoogleCredential FromServiceAccountCredential(ServiceAccountCreden
}

// Proxy IHttpExecuteInterceptor's only method.
Task IHttpExecuteInterceptor.InterceptAsync(HttpRequestMessage request, CancellationToken cancellationToken) =>
credential.InterceptAsync(request, cancellationToken);
async Task IHttpExecuteInterceptor.InterceptAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
if (request?.TryGetOption(GoogleAuthConsts.UniverseDomainKey, out string targetUniverseDomain) == true)
{
string credentialUniverseDomain = await credential.GetUniverseDomainAsync(cancellationToken).ConfigureAwait(false);
if (targetUniverseDomain != credentialUniverseDomain)
{
throw new InvalidOperationException(
$"The service client universe domain {targetUniverseDomain} does not match the credential universe domain {credentialUniverseDomain}.");
}
}
await credential.InterceptAsync(request, cancellationToken).ConfigureAwait(false);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/*
Copyright 2024 Google Inc
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
http://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 System.Net.Http;

namespace Google.Apis.Auth.OAuth2;

/// <summary>
/// Extension methods for <see cref="HttpRequestMessage"/>.
/// </summary>
// Note: This class exists in Google.Apis.Core, Google.Apis and Google.Apis.Auth so that we don't expose it
// publicly. Changes need to be made on the three classes at the same time.
// Only the one in Google.Apis is tested.
internal static class HttpRequestMessageExtensions
{
/// <summary>
/// Sets the given key/value pair as a request option.
/// </summary>
/// <remarks>
/// This method exist mostly to handle the fact that HttpRequestMessage.Options are only available
/// from .NET 5 and up.
/// </remarks>
public static void SetOption<TValue>(this HttpRequestMessage request, string key, TValue value)
{
#if NET6_0_OR_GREATER
request.Options.Set(new HttpRequestOptionsKey<TValue>(key), value);
#else
request.Properties[key] = value;
#endif
}

/// <summary>
/// Gets the value associated with the given key on the request options.
/// </summary>
/// <remarks>
/// This method exist mostly to handle the fact that HttpRequestMessage.Options are only available
/// from .NET 5 and up.
/// </remarks>
public static bool TryGetOption<TValue>(this HttpRequestMessage request, string key, out TValue value)
{
#if NET6_0_OR_GREATER
return request.Options.TryGetValue(new HttpRequestOptionsKey<TValue>(key), out value);
#else
object foundValue = null;
if (request.Properties.TryGetValue(key, out foundValue) && foundValue is TValue tempValue)
{
value = tempValue;
return true;
}
value = default;
return false;
#endif
}
}
19 changes: 19 additions & 0 deletions Src/Support/Google.Apis.Core/Http/ConfigurableMessageHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,11 @@ public class ConfigurableMessageHandler : DelegatingHandler
/// </summary>
public const string CredentialKey = "__CredentialKey";

/// <summary>
/// Key for a universe domain in a <see cref="HttpRequestMessage"/> options.
/// </summary>
internal const string UniverseDomainKey = "__UniverseDomainKey";

/// <summary>
/// Key for request specific max retries.
/// </summary>
Expand Down Expand Up @@ -345,6 +350,13 @@ public enum LogEventType
/// </summary>
public IHttpExecuteInterceptor Credential { get; set; }

/// <summary>
/// The universe domain to include as an option in the request.
/// This may be used by the <see cref="Credential"/> to validate against its own universe domain.
/// May be null, in which case no universe domain will be included in the request.
/// </summary>
public string UniverseDomain { get; set; }

/// <summary>Constructs a new configurable message handler.</summary>
public ConfigurableMessageHandler(HttpMessageHandler httpMessageHandler)
: base(httpMessageHandler)
Expand Down Expand Up @@ -418,6 +430,13 @@ protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage
request.Headers.Add("x-goog-api-client", apiClientHeader);
}

// Set the universe domain as a request option. Credentials may use it to validate it
// against their own universe domain.
if (UniverseDomain is not null)
{
request.SetOption(UniverseDomainKey, UniverseDomain);
}

HttpResponseMessage response = null;
do // While (triesRemaining > 0)
{
Expand Down
9 changes: 5 additions & 4 deletions Src/Support/Google.Apis.Core/Http/HttpClientFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -58,10 +58,11 @@ public ConfigurableHttpClient CreateHttpClient(CreateHttpClientArgs args)
// Create the handler.
var handler = CreateHandler(args);
var configurableHandler = new ConfigurableMessageHandler(handler)
{
ApplicationName = args.ApplicationName,
GoogleApiClientHeader = args.GoogleApiClientHeader
};
{
ApplicationName = args.ApplicationName,
GoogleApiClientHeader = args.GoogleApiClientHeader,
UniverseDomain = args.UniverseDomain,
};

// Create the client.
var client = new ConfigurableHttpClient(configurableHandler);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,8 @@ public ConfigurableHttpClient CreateHttpClient(CreateHttpClientArgs args)
var configurableHandler = new ConfigurableMessageHandler(handler)
{
ApplicationName = args.ApplicationName,
GoogleApiClientHeader = args.GoogleApiClientHeader
GoogleApiClientHeader = args.GoogleApiClientHeader,
UniverseDomain = args.UniverseDomain,
};

// We always set not to dispose the inner handler, it's not created
Expand Down
8 changes: 8 additions & 0 deletions Src/Support/Google.Apis.Core/Http/IHttpClientFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ limitations under the License.
*/

using System.Collections.Generic;
using System.Net.Http;

namespace Google.Apis.Http
{
Expand All @@ -27,6 +28,13 @@ public class CreateHttpClientArgs
/// <summary>Gets or sets the application name that is sent in the User-Agent header.</summary>
public string ApplicationName { get; set; }

/// <summary>
/// The universe domain that will be included as part of <see cref="HttpRequestMessage"/> options
/// that may be used by the credential, if any, to validate against its own universe domain.
/// May be null in which case no universe domain will be included in the request.
/// </summary>
public string UniverseDomain { get; set; }

/// <summary>Gets a list of initializers to initialize the HTTP client instance.</summary>
public IList<IConfigurableHttpClientInitializer> Initializers { get; private set; }

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1129,16 +1129,53 @@ public async Task AcceptsQuotaProjectFromCredential()
Assert.Single(fakeHandler.LatestRequestHeaders, h => h.Key == "x-goog-user-project");
}

[Theory]
// googleapis.com is no different here than any other domain,
// we still test for it explicitly to document behaviour.
[InlineData("googleapis.com")]
[InlineData("custom.domain")]
public async Task PropagatesUniverseDomain(string expectedUniverseDomain)
{
var fakeHandler = new FakeHandler();
var configurableHandler = new ConfigurableMessageHandler(fakeHandler)
{
UniverseDomain = expectedUniverseDomain
};

using (var client = new HttpClient(configurableHandler))
{
await client.GetAsync("http://will.be.ignored");
}

Assert.True(fakeHandler.LatestRequest.TryGetOption(ConfigurableMessageHandler.UniverseDomainKey, out string universeDomain));
Assert.Equal(expectedUniverseDomain, universeDomain);
}

[Fact]
public async Task NoUniverseDomain()
{
var fakeHandler = new FakeHandler();
var configurableHandler = new ConfigurableMessageHandler(fakeHandler);

using (var client = new HttpClient(configurableHandler))
{
await client.GetAsync("http://will.be.ignored");
}

Assert.False(fakeHandler.LatestRequest.TryGetOption(ConfigurableMessageHandler.UniverseDomainKey, out string universeDomain));
}

/// <summary>
/// Handler for intercepting all authenticated requests.
/// </summary>
private class FakeHandler : HttpMessageHandler
{
public HttpRequestHeaders LatestRequestHeaders { get; private set; }
public HttpRequestHeaders LatestRequestHeaders => LatestRequest?.Headers;
public HttpRequestMessage LatestRequest { get; private set; }

protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
LatestRequestHeaders = request.Headers;
LatestRequest = request;
return Task.FromResult(new HttpResponseMessage());
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,39 @@ public void InitializerBaseUriIsUsedByGeneratedServices(string initializerUri, s
Assert.Equal(expectedServiceUri, service.BaseUri);
}

[Theory]
[InlineData(null, null, null, null)]
[InlineData(null, "custom.domain", null, null)]
[InlineData(null, "custom.domain", "https://service.googleapis.com", "https://service.custom.domain")]
[InlineData(null, "custom.domain", "https://service.another.domain", "https://service.another.domain")]
[InlineData("https://service.explicit.domain", "custom.domain", "https://service.googleapis.com", "https://service.explicit.domain")]
[InlineData("https://service.explicit.domain", "custom.domain", "https://service.another.domain", "https://service.explicit.domain")]
public void UniverseDomain_GetEffectiveUri(string explicitUri, string universeDomain, string defaultUri, string expectedUri)
{
var service = new MockClientService(new BaseClientService.Initializer
{
// We don't need to initialize BaseUri here as we are testing the GetEffectiveUri method directly.
UniverseDomain = universeDomain
});

var effectiveUri = service.GetEffectiveUri(explicitUri, defaultUri);
Assert.Equal(expectedUri, effectiveUri);
}

[Theory]
[InlineData(null, "googleapis.com")]
[InlineData("googleapis.com", "googleapis.com")]
[InlineData("custom.domain", "custom.domain")]
public void UniverseDomain_PropagatesToMessageHandler(string explicitUniverseDomain, string expectedUnirseDomain)
{
var service = new MockClientService(new BaseClientService.Initializer
{
UniverseDomain = explicitUniverseDomain
});

Assert.Equal(service.HttpClient.MessageHandler.UniverseDomain, expectedUnirseDomain);
}

/// <summary>
/// Tests the default values of <seealso cref="Google.Apis.Services.BaseClientService"/>
/// </summary>
Expand Down
Loading

0 comments on commit ec8fdef

Please sign in to comment.