diff --git a/Src/Support/Google.Apis.Auth.Tests/OAuth2/AccessTokenWithHeadersTests.cs b/Src/Support/Google.Apis.Auth.Tests/OAuth2/AccessTokenWithHeadersTests.cs index 6cc12176294..b1c0186c94f 100644 --- a/Src/Support/Google.Apis.Auth.Tests/OAuth2/AccessTokenWithHeadersTests.cs +++ b/Src/Support/Google.Apis.Auth.Tests/OAuth2/AccessTokenWithHeadersTests.cs @@ -54,5 +54,72 @@ public void AccessTokenWithHeaders_WithQuotaProject() Assert.Single(values); Assert.Contains("FAKE_QUOTA_PROJECT", values); } + + [Fact] + public void AddHeaders_SucceedsOnRetry() + { + var token = new AccessTokenWithHeaders.Builder { QuotaProject = "RETRY_QUOTA_PROJECT" }.Build("FAKE_TOKEN"); + var request = new System.Net.Http.HttpRequestMessage(); + + // First call (first try) + token.AddHeaders(request); + + // Second call (retry) - should not throw and should not duplicate the header + token.AddHeaders(request); + + var values = request.Headers.GetValues("x-goog-user-project"); + Assert.Single(values); + Assert.Contains("RETRY_QUOTA_PROJECT", values); + } + + [Fact] + public void AddHeaders_ConflictThrows() + { + var token = new AccessTokenWithHeaders.Builder { QuotaProject = "NEW_QUOTA_PROJECT" }.Build("FAKE_TOKEN"); + var request = new System.Net.Http.HttpRequestMessage(); + request.Headers.Add("x-goog-user-project", "OLD_QUOTA_PROJECT"); + + Assert.Throws(() => token.AddHeaders(request)); + } + + [Fact] + public void AddHeaders_MatchingSucceeds() + { + var token = new AccessTokenWithHeaders.Builder { QuotaProject = "SAME_QUOTA_PROJECT" }.Build("FAKE_TOKEN"); + var request = new System.Net.Http.HttpRequestMessage(); + request.Headers.Add("x-goog-user-project", "SAME_QUOTA_PROJECT"); + + // Should not throw + token.AddHeaders(request); + + var values = request.Headers.GetValues("x-goog-user-project"); + Assert.Single(values); + Assert.Contains("SAME_QUOTA_PROJECT", values); + } + + [Fact] + public void AddHeaders_MultipleExistingValuesThrows() + { + var token = new AccessTokenWithHeaders.Builder { QuotaProject = "NEW_PROJECT" }.Build("TOKEN"); + var request = new System.Net.Http.HttpRequestMessage(); + request.Headers.Add("x-goog-user-project", "VAL1"); + request.Headers.Add("x-goog-user-project", "VAL2"); + + Assert.Throws(() => token.AddHeaders(request)); + } + + [Fact] + public void AddHeaders_MultipleIncomingValuesThrows() + { + var headers = new System.Collections.Generic.Dictionary> + { + { "x-goog-user-project", new System.Collections.Generic.List { "VAL1", "VAL2" }.AsReadOnly() } + }; + var token = new AccessTokenWithHeaders("TOKEN", new System.Collections.ObjectModel.ReadOnlyDictionary>(headers)); + var request = new System.Net.Http.HttpRequestMessage(); + request.Headers.Add("x-goog-user-project", "VAL1"); + + Assert.Throws(() => token.AddHeaders(request)); + } } } diff --git a/Src/Support/Google.Apis.Auth/OAuth2/AccessTokenWithHeaders.cs b/Src/Support/Google.Apis.Auth/OAuth2/AccessTokenWithHeaders.cs index 948d32f6198..73f0a486336 100644 --- a/Src/Support/Google.Apis.Auth/OAuth2/AccessTokenWithHeaders.cs +++ b/Src/Support/Google.Apis.Auth/OAuth2/AccessTokenWithHeaders.cs @@ -15,8 +15,11 @@ limitations under the License. */ using Google.Apis.Util; +using System; using System.Collections.Generic; using System.Collections.ObjectModel; +using System.Linq; +using System.Net; using System.Net.Http; using System.Net.Http.Headers; @@ -75,10 +78,31 @@ private AccessTokenWithHeaders(string token, string quotaProject = null) public void AddHeaders(HttpRequestHeaders requestHeaders) { requestHeaders.ThrowIfNull(nameof(requestHeaders)); - foreach (var header in Headers) { - requestHeaders.Add(header.Key, header.Value); + // In the case it's a single value header we will not add it if already present, just validate we match + // what's already there. + if (IsSingleValueHeader(header.Key) && requestHeaders.TryGetValues(header.Key, out var existingValues)) + { + ValidateSingleValueHeader(header.Key, existingValues, header.Value); + continue; + } + else + { + requestHeaders.Add(header.Key, header.Value); + } + } + + bool IsSingleValueHeader(string key) => key == QuotaProjectHeaderName; + + void ValidateSingleValueHeader(string key, IEnumerable existing, IEnumerable incoming) + { + bool isValidInitialization = !existing.Any() && incoming.Count() == 1; + bool isNoChange = existing.Count() == 1 && incoming.Count() == 1 && existing.First() == incoming.First(); + if (!(isValidInitialization || isNoChange)) + { + throw new InvalidOperationException($"Only a single header value may be specified for key {key}."); + } } } diff --git a/Src/Support/Google.Apis.Core/Http/ConfigurableMessageHandler.cs b/Src/Support/Google.Apis.Core/Http/ConfigurableMessageHandler.cs index 172b378c7a9..ec981124fac 100644 --- a/Src/Support/Google.Apis.Core/Http/ConfigurableMessageHandler.cs +++ b/Src/Support/Google.Apis.Core/Http/ConfigurableMessageHandler.cs @@ -38,8 +38,6 @@ namespace Google.Apis.Http /// public class ConfigurableMessageHandler : DelegatingHandler { - private const string QuotaProjectHeaderName = "x-goog-user-project"; - /// The class logger. private static readonly ILogger Logger = ApplicationContext.Logger.ForType(); @@ -464,10 +462,6 @@ protected override async Task SendAsync(HttpRequestMessage await interceptor.InterceptAsync(request, cancellationToken).ConfigureAwait(false); } - // Before having the credential intercept the call, check that quota project hasn't - // been added as a header. Quota project cannot be added except through the credential. - CheckValidAfterInterceptors(request); - await CredentialInterceptAsync(request, cancellationToken).ConfigureAwait(false); if (loggable) @@ -657,14 +651,6 @@ bool DisposeAndReturnFalse(IDisposable disposable) return response; } - private void CheckValidAfterInterceptors(HttpRequestMessage request) - { - if (request.Headers.Contains(QuotaProjectHeaderName)) - { - throw new InvalidOperationException($"{QuotaProjectHeaderName} header can only be added through the credential or through the ClientBuilder."); - } - } - private async Task CredentialInterceptAsync(HttpRequestMessage request, CancellationToken cancellationToken) { var effectiveCredential = GetEffectiveCredential(request); diff --git a/Src/Support/Google.Apis.Tests/Apis/Http/ConfigurableMessageHandlerTest.cs b/Src/Support/Google.Apis.Tests/Apis/Http/ConfigurableMessageHandlerTest.cs index 7ec4ca80280..16e6e653c3c 100644 --- a/Src/Support/Google.Apis.Tests/Apis/Http/ConfigurableMessageHandlerTest.cs +++ b/Src/Support/Google.Apis.Tests/Apis/Http/ConfigurableMessageHandlerTest.cs @@ -1100,18 +1100,6 @@ public Task InterceptAsync(HttpRequestMessage request, CancellationToken cancell } } - [Fact] - public async Task FailsIfQuotaProjectSetWithInterceptors() - { - var configurableHandler = new ConfigurableMessageHandler(new HttpClientHandler()); - configurableHandler.AddExecuteInterceptor(new AddsQuotaProject()); - - using (var client = new HttpClient(configurableHandler)) - { - await Assert.ThrowsAsync(() => client.GetAsync("http://will.be.ignored")); - } - } - [Fact] public async Task AcceptsQuotaProjectFromCredential() {