From 49e9625022e6b896de720bc564017f9283ebed00 Mon Sep 17 00:00:00 2001 From: Philip Gichuhi Date: Wed, 9 Oct 2024 15:48:07 +0300 Subject: [PATCH 1/9] feat: Adds Authorization handler --- .../httpClient/ContinuousAccessEvaluation.cs | 74 ++++++++++++ .../httpClient/HttpClientRequestAdapter.cs | 48 ++------ .../Middleware/AuthorizationHandler.cs | 106 ++++++++++++++++ .../Middleware/AuthorizationHandlerTests.cs | 114 ++++++++++++++++++ 4 files changed, 302 insertions(+), 40 deletions(-) create mode 100644 src/http/httpClient/ContinuousAccessEvaluation.cs create mode 100644 src/http/httpClient/Middleware/AuthorizationHandler.cs create mode 100644 tests/http/httpClient/Middleware/AuthorizationHandlerTests.cs diff --git a/src/http/httpClient/ContinuousAccessEvaluation.cs b/src/http/httpClient/ContinuousAccessEvaluation.cs new file mode 100644 index 0000000..8bdc4c1 --- /dev/null +++ b/src/http/httpClient/ContinuousAccessEvaluation.cs @@ -0,0 +1,74 @@ +using System; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text.RegularExpressions; + +namespace Microsoft.Kiota.Http.HttpClientLibrary +{ + /// + /// Process continuous access evaluation + /// + internal class ContinuousAccessEvaluation + { + + internal const string ClaimsKey = "claims"; + internal const string BearerAuthenticationScheme = "Bearer"; + private static readonly char[] ComaSplitSeparator = [',']; + private static Func filterAuthHeader = static x => x.Scheme.Equals(BearerAuthenticationScheme, StringComparison.OrdinalIgnoreCase); + private static readonly Regex caeValueRegex = new("\"([^\"]*)\"", RegexOptions.Compiled, TimeSpan.FromMilliseconds(100)); + + /// + /// Extracts claims header value from a response + /// + /// + /// + public static string GetClaims(HttpResponseMessage response) + { + if(response == null) throw new ArgumentNullException(nameof(response)); + if(response.StatusCode != HttpStatusCode.Unauthorized + || response.Headers.WwwAuthenticate.Count == 0) + { + return string.Empty; + } + AuthenticationHeaderValue? authHeader = null; + foreach(var header in response.Headers.WwwAuthenticate) + { + if(filterAuthHeader(header)) + { + authHeader = header; + break; + } + } + if(authHeader is not null) + { + var authHeaderParameters = authHeader.Parameter?.Split(ComaSplitSeparator, StringSplitOptions.RemoveEmptyEntries); + + string? rawResponseClaims = null; + if(authHeaderParameters != null) + { + foreach(var parameter in authHeaderParameters) + { + var trimmedParameter = parameter.Trim(); + if(trimmedParameter.StartsWith(ClaimsKey, StringComparison.OrdinalIgnoreCase)) + { + rawResponseClaims = trimmedParameter; + break; + } + } + } + + if(rawResponseClaims != null && + caeValueRegex.Match(rawResponseClaims) is Match claimsMatch && + claimsMatch.Groups.Count > 1 && + claimsMatch.Groups[1].Value is string responseClaims) + { + return responseClaims; + } + + } + return string.Empty; + } + } +} + diff --git a/src/http/httpClient/HttpClientRequestAdapter.cs b/src/http/httpClient/HttpClientRequestAdapter.cs index 05314d5..8e05650 100644 --- a/src/http/httpClient/HttpClientRequestAdapter.cs +++ b/src/http/httpClient/HttpClientRequestAdapter.cs @@ -536,13 +536,11 @@ private async Task GetHttpResponseMessageAsync(RequestInfor return await RetryCAEResponseIfRequiredAsync(response, requestInfo, cancellationToken, claims, activityForAttributes).ConfigureAwait(false); } - private static readonly Regex caeValueRegex = new("\"([^\"]*)\"", RegexOptions.Compiled, TimeSpan.FromMilliseconds(100)); /// /// The key for the event raised by tracing when an authentication challenge is received /// public const string AuthenticateChallengedEventKey = "com.microsoft.kiota.authenticate_challenge_received"; - private static readonly char[] ComaSplitSeparator = [',']; private async Task RetryCAEResponseIfRequiredAsync(HttpResponseMessage response, RequestInformation requestInfo, CancellationToken cancellationToken, string? claims, Activity? activityForAttributes) { @@ -551,46 +549,16 @@ private async Task RetryCAEResponseIfRequiredAsync(HttpResp string.IsNullOrEmpty(claims) && // avoid infinite loop, we only retry once (requestInfo.Content?.CanSeek ?? true)) { - AuthenticationHeaderValue? authHeader = null; - foreach(var header in response.Headers.WwwAuthenticate) + var responseClaims = ContinuousAccessEvaluation.GetClaims(response); + if(string.IsNullOrEmpty(responseClaims)) { - if(filterAuthHeader(header)) - { - authHeader = header; - break; - } - } - - if(authHeader is not null) - { - var authHeaderParameters = authHeader.Parameter?.Split(ComaSplitSeparator, StringSplitOptions.RemoveEmptyEntries); - - string? rawResponseClaims = null; - if(authHeaderParameters != null) - { - foreach(var parameter in authHeaderParameters) - { - var trimmedParameter = parameter.Trim(); - if(trimmedParameter.StartsWith(ClaimsKey, StringComparison.OrdinalIgnoreCase)) - { - rawResponseClaims = trimmedParameter; - break; - } - } - } - - if(rawResponseClaims != null && - caeValueRegex.Match(rawResponseClaims) is Match claimsMatch && - claimsMatch.Groups.Count > 1 && - claimsMatch.Groups[1].Value is string responseClaims) - { - span?.AddEvent(new ActivityEvent(AuthenticateChallengedEventKey)); - activityForAttributes?.SetTag("http.retry_count", 1); - requestInfo.Content?.Seek(0, SeekOrigin.Begin); - await DrainAsync(response, cancellationToken).ConfigureAwait(false); - return await GetHttpResponseMessageAsync(requestInfo, cancellationToken, activityForAttributes, responseClaims).ConfigureAwait(false); - } + return response; } + span?.AddEvent(new ActivityEvent(AuthenticateChallengedEventKey)); + activityForAttributes?.SetTag("http.retry_count", 1); + requestInfo.Content?.Seek(0, SeekOrigin.Begin); + await DrainAsync(response, cancellationToken).ConfigureAwait(false); + return await GetHttpResponseMessageAsync(requestInfo, cancellationToken, activityForAttributes, responseClaims).ConfigureAwait(false); } return response; } diff --git a/src/http/httpClient/Middleware/AuthorizationHandler.cs b/src/http/httpClient/Middleware/AuthorizationHandler.cs new file mode 100644 index 0000000..795115d --- /dev/null +++ b/src/http/httpClient/Middleware/AuthorizationHandler.cs @@ -0,0 +1,106 @@ +// ------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All Rights Reserved. Licensed under the MIT License. See License in the project root for license information. +// ------------------------------------------------------------------------------ + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Kiota.Abstractions.Authentication; +using Microsoft.Kiota.Http.HttpClientLibrary.Extensions; + +namespace Microsoft.Kiota.Http.HttpClientLibrary.Middleware +{ + /// + /// Adds an Authorization header to the request if the header is not already present. + /// Also handles Continuous Access Evaluation (CAE) claims challenges if the initial + /// token request was made using this handler + /// + public class AuthorizationHandler : DelegatingHandler + { + + private const string AuthorizationHeader = "Authorization"; + private BaseBearerTokenAuthenticationProvider authenticationProvider; + + /// + /// Constructs an + /// + /// + /// + public AuthorizationHandler(BaseBearerTokenAuthenticationProvider authenticationProvider) + { + if(authenticationProvider == null) throw new ArgumentNullException(nameof(authenticationProvider)); + this.authenticationProvider = authenticationProvider; + } + + /// + /// Adds an Authorization header if not already provided + /// + /// + /// + /// + protected override async Task SendAsync(HttpRequestMessage request, + CancellationToken cancellationToken) + { + if(request == null) throw new ArgumentNullException(nameof(request)); + + Activity? activity = null; + if(request.GetRequestOption() is { } obsOptions) + { + var activitySource = ActivitySourceRegistry.DefaultInstance.GetOrCreateActivitySource(obsOptions.TracerInstrumentationName); + activity = activitySource?.StartActivity($"{nameof(AuthorizationHandler)}_{nameof(SendAsync)}"); + activity?.SetTag("com.microsoft.kiota.handler.authorization.enable", true); + } + try + { + if(request.Headers.Contains(AuthorizationHeader)) + { + activity?.SetTag("com.microsoft.kiota.handler.authorization.token_present", true); + return await base.SendAsync(request, cancellationToken).ConfigureAwait(false); + } + Dictionary additionalAuthenticationContext = new Dictionary(); + await AuthenticateRequestAsync(request, additionalAuthenticationContext, cancellationToken, activity).ConfigureAwait(false); + var response = await base.SendAsync(request, cancellationToken).ConfigureAwait(false); + if(response.StatusCode != HttpStatusCode.Unauthorized || response.RequestMessage == null || !response.RequestMessage.IsBuffered()) + return response; + // Attempt CAE claims challenge + var claims = ContinuousAccessEvaluation.GetClaims(response); + if(string.IsNullOrEmpty(claims)) + return response; + activity?.AddEvent(new ActivityEvent("com.microsoft.kiota.handler.authorization.challenge_received")); + additionalAuthenticationContext[ContinuousAccessEvaluation.ClaimsKey] = claims; + HttpRequestMessage retryRequest = response.RequestMessage; + await AuthenticateRequestAsync(retryRequest, additionalAuthenticationContext, cancellationToken, activity).ConfigureAwait(false); + activity?.SetTag("http.request.resend_count", 1); + return await base.SendAsync(retryRequest, cancellationToken).ConfigureAwait(false); + } + finally + { + activity?.Dispose(); + } + } + + private async Task AuthenticateRequestAsync(HttpRequestMessage request, + Dictionary? additionalAuthenticationContext, + CancellationToken cancellationToken, + Activity? activityForAttributes) + { + var accessTokenProvider = authenticationProvider.AccessTokenProvider; + if(request.RequestUri == null || !accessTokenProvider.AllowedHostsValidator.IsUrlHostValid( + request.RequestUri)) + { + return; + } + var accessToken = await accessTokenProvider.GetAuthorizationTokenAsync( + request.RequestUri, + additionalAuthenticationContext, cancellationToken).ConfigureAwait(false); + if(string.IsNullOrEmpty(accessToken)) return; + activityForAttributes?.SetTag("com.microsoft.kiota.handler.authorization.token_obtained", true); + request.Headers.TryAddWithoutValidation(AuthorizationHeader, $"Bearer {accessToken}"); + } + } +} diff --git a/tests/http/httpClient/Middleware/AuthorizationHandlerTests.cs b/tests/http/httpClient/Middleware/AuthorizationHandlerTests.cs new file mode 100644 index 0000000..aae728b --- /dev/null +++ b/tests/http/httpClient/Middleware/AuthorizationHandlerTests.cs @@ -0,0 +1,114 @@ +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using Microsoft.Kiota.Abstractions.Authentication; +using Microsoft.Kiota.Http.HttpClientLibrary.Middleware; +using Microsoft.Kiota.Http.HttpClientLibrary.Tests.Mocks; +using Moq; +using Xunit; + +namespace Microsoft.Kiota.Http.HttpClientLibrary.Tests.Middleware +{ + public class AuthorizationHandlerTests : IDisposable + { + private readonly MockRedirectHandler _testHttpMessageHandler; + + private IAccessTokenProvider _mockAccessTokenProvider; + + private readonly string _expectedAccessToken = "token"; + + private readonly string _expectedAccessTokenAfterCAE = "token2"; + private AuthorizationHandler _authorizationHandler; + private readonly BaseBearerTokenAuthenticationProvider _authenticationProvider; + private readonly HttpMessageInvoker _invoker; + + private readonly string _claimsChallengeHeaderValue = "authorization_uri=\"https://login.windows.net/common/oauth2/authorize\"," + + "error=\"insufficient_claims\"," + + "claims=\"eyJhY2Nlc3NfdG9rZW4iOnsibmJmIjp7ImVzc2VudGlhbCI6dHJ1ZSwgInZhbHVlIjoiMTYwNDEwNjY1MSJ9fX0=\""; + + public AuthorizationHandlerTests() + { + this._testHttpMessageHandler = new MockRedirectHandler(); + var mockAccessTokenProvider = new Mock(); + mockAccessTokenProvider.SetupSequence(x => x.GetAuthorizationTokenAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny() + )).Returns(new Task(() => _expectedAccessToken)) + .Returns(new Task(() => _expectedAccessTokenAfterCAE)); + + mockAccessTokenProvider.Setup(x => x.AllowedHostsValidator).Returns( + new AllowedHostsValidator(new List { "https://graph.microsoft.com" }) + ); + this._mockAccessTokenProvider = mockAccessTokenProvider.Object; + this._authenticationProvider = new BaseBearerTokenAuthenticationProvider(_mockAccessTokenProvider!); + this._authorizationHandler = new AuthorizationHandler(_authenticationProvider) + { + InnerHandler = this._testHttpMessageHandler + }; + + this._invoker = new HttpMessageInvoker(this._authorizationHandler); + } + + public void Dispose() + { + this._invoker.Dispose(); + GC.SuppressFinalize(this); + } + + [Fact] + public async Task AuthorizationHandlerShouldAddAuthHeaderIfNotPresent() + { + // Arrange + HttpRequestMessage httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, "https://graph.microsoft.com/me"); + + HttpResponseMessage httpResponse = new HttpResponseMessage(HttpStatusCode.OK); + this._testHttpMessageHandler.SetHttpResponse(httpResponse);// set the mock response + // Act + HttpResponseMessage response = await this._invoker.SendAsync(httpRequestMessage, new CancellationToken()); + // Assert + Assert.True(response.RequestMessage.Headers.Contains("Authorization")); + Assert.True(response.RequestMessage.Headers.GetValues("Authorization").Count() == 1); + Assert.Equal($"Bearer {_expectedAccessToken}", response.RequestMessage.Headers.GetValues("Authorization").First()); + } + + [Fact] + public async Task AuthorizationHandlerShouldNotAddAuthHeaderIfPresent() + { + // Arrange + HttpRequestMessage httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, "https://graph.microsoft.com/me"); + httpRequestMessage.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "existing"); + + HttpResponseMessage httpResponse = new HttpResponseMessage(HttpStatusCode.OK); + this._testHttpMessageHandler.SetHttpResponse(httpResponse);// set the mock response + + // Act + HttpResponseMessage response = await this._invoker.SendAsync(httpRequestMessage, new CancellationToken()); + + // Assert + Assert.True(response.RequestMessage.Headers.Contains("Authorization")); + Assert.True(response.RequestMessage.Headers.GetValues("Authorization").Count() == 1); + Assert.Equal($"Bearer existing", response.RequestMessage.Headers.GetValues("Authorization").First()); + } + + [Fact] + public async Task AuthorizationHandlerShouldAttemptCAEClaimsChallenge() + { + // Arrange + HttpRequestMessage httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, "https://graph.microsoft.com"); + + HttpResponseMessage httpResponse = new HttpResponseMessage(HttpStatusCode.Unauthorized); + httpResponse.Headers.WwwAuthenticate.Add(new AuthenticationHeaderValue("Bearer", _claimsChallengeHeaderValue)); + + this._testHttpMessageHandler.SetHttpResponse(httpResponse);// set the mock response + + // Act + HttpResponseMessage response = await this._invoker.SendAsync(httpRequestMessage, new CancellationToken()); + + // Assert + Assert.True(response.RequestMessage.Headers.Contains("Authorization")); + Assert.True(response.RequestMessage.Headers.GetValues("Authorization").Count() == 1); + Assert.Equal($"Bearer {_expectedAccessTokenAfterCAE}", response.RequestMessage.Headers.GetValues("Authorization").First()); + } + } +} From 6db569fb24c4275e9aa1afed329bd5ec9d8ec1ec Mon Sep 17 00:00:00 2001 From: Philip Gichuhi Date: Thu, 10 Oct 2024 04:00:18 +0300 Subject: [PATCH 2/9] feat: Add create() overload in KiotaClientFactory to add authentication middleware to the chain --- .../httpClient/ContinuousAccessEvaluation.cs | 3 +-- src/http/httpClient/KiotaClientFactory.cs | 24 +++++++++++++++++++ .../Middleware/AuthorizationHandler.cs | 2 +- 3 files changed, 26 insertions(+), 3 deletions(-) diff --git a/src/http/httpClient/ContinuousAccessEvaluation.cs b/src/http/httpClient/ContinuousAccessEvaluation.cs index 8bdc4c1..4f84066 100644 --- a/src/http/httpClient/ContinuousAccessEvaluation.cs +++ b/src/http/httpClient/ContinuousAccessEvaluation.cs @@ -9,9 +9,8 @@ namespace Microsoft.Kiota.Http.HttpClientLibrary /// /// Process continuous access evaluation /// - internal class ContinuousAccessEvaluation + static internal class ContinuousAccessEvaluation { - internal const string ClaimsKey = "claims"; internal const string BearerAuthenticationScheme = "Bearer"; private static readonly char[] ComaSplitSeparator = [',']; diff --git a/src/http/httpClient/KiotaClientFactory.cs b/src/http/httpClient/KiotaClientFactory.cs index 5a89c01..c13f8b7 100644 --- a/src/http/httpClient/KiotaClientFactory.cs +++ b/src/http/httpClient/KiotaClientFactory.cs @@ -61,6 +61,30 @@ public static HttpClient Create(IList handlers, HttpMessageHa return handler != null ? new HttpClient(handler) : new HttpClient(); } + /// + /// Initializes the with the default configuration and authentication middleware using the if provided. + /// + /// + /// + /// + /// + public static HttpClient Create(BaseBearerTokenAuthenticationProvider authenticationProvider, IRequestOption[]? optionsForHandlers = null, HttpMessageHandler? finalHandler = null) + { + var defaultHandlersEnumerable = CreateDefaultHandlers(optionsForHandlers); + defaultHandlersEnumerable.Add(new AuthorizationHandler(authenticationProvider)); + int count = 0; + foreach(var _ in defaultHandlersEnumerable) count++; + + var defaultHandlersArray = new DelegatingHandler[count]; + int index = 0; + foreach(var handler2 in defaultHandlersEnumerable) + { + defaultHandlersArray[index++] = handler2; + } + var handler = ChainHandlersCollectionAndGetFirstLink(finalHandler ?? GetDefaultHttpMessageHandler(), defaultHandlersArray); + return handler != null ? new HttpClient(handler) : new HttpClient(); + } + /// /// Creates a default set of middleware to be used by the . /// diff --git a/src/http/httpClient/Middleware/AuthorizationHandler.cs b/src/http/httpClient/Middleware/AuthorizationHandler.cs index 795115d..6eb3ef9 100644 --- a/src/http/httpClient/Middleware/AuthorizationHandler.cs +++ b/src/http/httpClient/Middleware/AuthorizationHandler.cs @@ -24,7 +24,7 @@ public class AuthorizationHandler : DelegatingHandler { private const string AuthorizationHeader = "Authorization"; - private BaseBearerTokenAuthenticationProvider authenticationProvider; + private readonly BaseBearerTokenAuthenticationProvider authenticationProvider; /// /// Constructs an From 4c1f70cc35e3a87c72bd6d27d61b37fdefd5aadd Mon Sep 17 00:00:00 2001 From: Philip Gichuhi Date: Wed, 30 Oct 2024 10:40:41 +0200 Subject: [PATCH 3/9] Change additional auth context type and log at appropriate step --- src/http/httpClient/Middleware/AuthorizationHandler.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/http/httpClient/Middleware/AuthorizationHandler.cs b/src/http/httpClient/Middleware/AuthorizationHandler.cs index 6eb3ef9..4716b3c 100644 --- a/src/http/httpClient/Middleware/AuthorizationHandler.cs +++ b/src/http/httpClient/Middleware/AuthorizationHandler.cs @@ -85,7 +85,7 @@ protected override async Task SendAsync(HttpRequestMessage } private async Task AuthenticateRequestAsync(HttpRequestMessage request, - Dictionary? additionalAuthenticationContext, + Dictionary additionalAuthenticationContext, CancellationToken cancellationToken, Activity? activityForAttributes) { @@ -98,8 +98,8 @@ private async Task AuthenticateRequestAsync(HttpRequestMessage request, var accessToken = await accessTokenProvider.GetAuthorizationTokenAsync( request.RequestUri, additionalAuthenticationContext, cancellationToken).ConfigureAwait(false); - if(string.IsNullOrEmpty(accessToken)) return; activityForAttributes?.SetTag("com.microsoft.kiota.handler.authorization.token_obtained", true); + if(string.IsNullOrEmpty(accessToken)) return; request.Headers.TryAddWithoutValidation(AuthorizationHeader, $"Bearer {accessToken}"); } } From 70077758f1bf55ea04cddc0d23364ca772a1218b Mon Sep 17 00:00:00 2001 From: Andrew Omondi Date: Wed, 30 Oct 2024 13:54:30 +0300 Subject: [PATCH 4/9] fix ci failure.... --- src/generated/KiotaVersionGenerator.cs | 2 +- tests/http/httpClient/Middleware/AuthorizationHandlerTests.cs | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/generated/KiotaVersionGenerator.cs b/src/generated/KiotaVersionGenerator.cs index 3bf9f92..04f2580 100644 --- a/src/generated/KiotaVersionGenerator.cs +++ b/src/generated/KiotaVersionGenerator.cs @@ -20,7 +20,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context) try { XmlDocument csproj = new XmlDocument(); - projectDirectory = Path.Combine(projectDirectory, "..", "..", "..", "..", "Directory.Build.props"); + projectDirectory = Path.Combine(projectDirectory, "..", "..", "..", "Directory.Build.props"); csproj.Load(projectDirectory); var version = csproj.GetElementsByTagName("VersionPrefix")[0].InnerText; string source = $@"// diff --git a/tests/http/httpClient/Middleware/AuthorizationHandlerTests.cs b/tests/http/httpClient/Middleware/AuthorizationHandlerTests.cs index aae728b..a1ae87b 100644 --- a/tests/http/httpClient/Middleware/AuthorizationHandlerTests.cs +++ b/tests/http/httpClient/Middleware/AuthorizationHandlerTests.cs @@ -67,6 +67,7 @@ public async Task AuthorizationHandlerShouldAddAuthHeaderIfNotPresent() // Act HttpResponseMessage response = await this._invoker.SendAsync(httpRequestMessage, new CancellationToken()); // Assert + Assert.NotNull(response.RequestMessage); Assert.True(response.RequestMessage.Headers.Contains("Authorization")); Assert.True(response.RequestMessage.Headers.GetValues("Authorization").Count() == 1); Assert.Equal($"Bearer {_expectedAccessToken}", response.RequestMessage.Headers.GetValues("Authorization").First()); @@ -86,6 +87,7 @@ public async Task AuthorizationHandlerShouldNotAddAuthHeaderIfPresent() HttpResponseMessage response = await this._invoker.SendAsync(httpRequestMessage, new CancellationToken()); // Assert + Assert.NotNull(response.RequestMessage); Assert.True(response.RequestMessage.Headers.Contains("Authorization")); Assert.True(response.RequestMessage.Headers.GetValues("Authorization").Count() == 1); Assert.Equal($"Bearer existing", response.RequestMessage.Headers.GetValues("Authorization").First()); @@ -106,6 +108,7 @@ public async Task AuthorizationHandlerShouldAttemptCAEClaimsChallenge() HttpResponseMessage response = await this._invoker.SendAsync(httpRequestMessage, new CancellationToken()); // Assert + Assert.NotNull(response.RequestMessage); Assert.True(response.RequestMessage.Headers.Contains("Authorization")); Assert.True(response.RequestMessage.Headers.GetValues("Authorization").Count() == 1); Assert.Equal($"Bearer {_expectedAccessTokenAfterCAE}", response.RequestMessage.Headers.GetValues("Authorization").First()); From d5882b39947615c34c25b3fe46ddbaab00e3fdc2 Mon Sep 17 00:00:00 2001 From: Philip Gichuhi Date: Wed, 30 Oct 2024 14:17:03 +0200 Subject: [PATCH 5/9] Fix failing tests --- .../Middleware/AuthorizationHandler.cs | 16 ++++++++-------- .../Middleware/AuthorizationHandlerTests.cs | 18 +++++++++--------- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/src/http/httpClient/Middleware/AuthorizationHandler.cs b/src/http/httpClient/Middleware/AuthorizationHandler.cs index 4716b3c..9b42cf2 100644 --- a/src/http/httpClient/Middleware/AuthorizationHandler.cs +++ b/src/http/httpClient/Middleware/AuthorizationHandler.cs @@ -44,7 +44,7 @@ public AuthorizationHandler(BaseBearerTokenAuthenticationProvider authentication /// /// protected override async Task SendAsync(HttpRequestMessage request, - CancellationToken cancellationToken) + CancellationToken cancellationToken) { if(request == null) throw new ArgumentNullException(nameof(request)); @@ -85,22 +85,22 @@ protected override async Task SendAsync(HttpRequestMessage } private async Task AuthenticateRequestAsync(HttpRequestMessage request, - Dictionary additionalAuthenticationContext, - CancellationToken cancellationToken, - Activity? activityForAttributes) + Dictionary additionalAuthenticationContext, + CancellationToken cancellationToken, + Activity? activityForAttributes) { var accessTokenProvider = authenticationProvider.AccessTokenProvider; if(request.RequestUri == null || !accessTokenProvider.AllowedHostsValidator.IsUrlHostValid( - request.RequestUri)) + request.RequestUri)) { return; } var accessToken = await accessTokenProvider.GetAuthorizationTokenAsync( - request.RequestUri, - additionalAuthenticationContext, cancellationToken).ConfigureAwait(false); + request.RequestUri, + additionalAuthenticationContext, cancellationToken).ConfigureAwait(false); activityForAttributes?.SetTag("com.microsoft.kiota.handler.authorization.token_obtained", true); if(string.IsNullOrEmpty(accessToken)) return; - request.Headers.TryAddWithoutValidation(AuthorizationHeader, $"Bearer {accessToken}"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); } } } diff --git a/tests/http/httpClient/Middleware/AuthorizationHandlerTests.cs b/tests/http/httpClient/Middleware/AuthorizationHandlerTests.cs index a1ae87b..ba6ca84 100644 --- a/tests/http/httpClient/Middleware/AuthorizationHandlerTests.cs +++ b/tests/http/httpClient/Middleware/AuthorizationHandlerTests.cs @@ -1,6 +1,7 @@ using System.Net; using System.Net.Http; using System.Net.Http.Headers; +using System.Text; using Microsoft.Kiota.Abstractions.Authentication; using Microsoft.Kiota.Http.HttpClientLibrary.Middleware; using Microsoft.Kiota.Http.HttpClientLibrary.Tests.Mocks; @@ -12,9 +13,6 @@ namespace Microsoft.Kiota.Http.HttpClientLibrary.Tests.Middleware public class AuthorizationHandlerTests : IDisposable { private readonly MockRedirectHandler _testHttpMessageHandler; - - private IAccessTokenProvider _mockAccessTokenProvider; - private readonly string _expectedAccessToken = "token"; private readonly string _expectedAccessTokenAfterCAE = "token2"; @@ -34,14 +32,14 @@ public AuthorizationHandlerTests() It.IsAny(), It.IsAny>(), It.IsAny() - )).Returns(new Task(() => _expectedAccessToken)) - .Returns(new Task(() => _expectedAccessTokenAfterCAE)); + ).Result).Returns(_expectedAccessToken) + .Returns(_expectedAccessTokenAfterCAE); mockAccessTokenProvider.Setup(x => x.AllowedHostsValidator).Returns( - new AllowedHostsValidator(new List { "https://graph.microsoft.com" }) + new AllowedHostsValidator(new List { "graph.microsoft.com" }) ); - this._mockAccessTokenProvider = mockAccessTokenProvider.Object; - this._authenticationProvider = new BaseBearerTokenAuthenticationProvider(_mockAccessTokenProvider!); + var mockAuthenticationProvider = new Mock(mockAccessTokenProvider.Object); + this._authenticationProvider = mockAuthenticationProvider.Object; this._authorizationHandler = new AuthorizationHandler(_authenticationProvider) { InnerHandler = this._testHttpMessageHandler @@ -98,11 +96,12 @@ public async Task AuthorizationHandlerShouldAttemptCAEClaimsChallenge() { // Arrange HttpRequestMessage httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, "https://graph.microsoft.com"); + httpRequestMessage.Content = new ByteArrayContent(Encoding.UTF8.GetBytes("test")); HttpResponseMessage httpResponse = new HttpResponseMessage(HttpStatusCode.Unauthorized); httpResponse.Headers.WwwAuthenticate.Add(new AuthenticationHeaderValue("Bearer", _claimsChallengeHeaderValue)); - this._testHttpMessageHandler.SetHttpResponse(httpResponse);// set the mock response + this._testHttpMessageHandler.SetHttpResponse(httpResponse, new HttpResponseMessage(HttpStatusCode.OK));// set the mock response // Act HttpResponseMessage response = await this._invoker.SendAsync(httpRequestMessage, new CancellationToken()); @@ -112,6 +111,7 @@ public async Task AuthorizationHandlerShouldAttemptCAEClaimsChallenge() Assert.True(response.RequestMessage.Headers.Contains("Authorization")); Assert.True(response.RequestMessage.Headers.GetValues("Authorization").Count() == 1); Assert.Equal($"Bearer {_expectedAccessTokenAfterCAE}", response.RequestMessage.Headers.GetValues("Authorization").First()); + Assert.Equal("test", await response.RequestMessage.Content!.ReadAsStringAsync()); } } } From 2c77cceff20bae1986a0d7d4e8ff759db7f6cfa6 Mon Sep 17 00:00:00 2001 From: Philip Gichuhi Date: Tue, 5 Nov 2024 08:47:22 +0200 Subject: [PATCH 6/9] Apply suggestions from code review Co-authored-by: Andrew Omondi --- src/http/httpClient/KiotaClientFactory.cs | 12 +----------- .../httpClient/Middleware/AuthorizationHandler.cs | 5 ++--- .../Middleware/AuthorizationHandlerTests.cs | 6 +++--- 3 files changed, 6 insertions(+), 17 deletions(-) diff --git a/src/http/httpClient/KiotaClientFactory.cs b/src/http/httpClient/KiotaClientFactory.cs index c13f8b7..2f647c5 100644 --- a/src/http/httpClient/KiotaClientFactory.cs +++ b/src/http/httpClient/KiotaClientFactory.cs @@ -72,17 +72,7 @@ public static HttpClient Create(BaseBearerTokenAuthenticationProvider authentica { var defaultHandlersEnumerable = CreateDefaultHandlers(optionsForHandlers); defaultHandlersEnumerable.Add(new AuthorizationHandler(authenticationProvider)); - int count = 0; - foreach(var _ in defaultHandlersEnumerable) count++; - - var defaultHandlersArray = new DelegatingHandler[count]; - int index = 0; - foreach(var handler2 in defaultHandlersEnumerable) - { - defaultHandlersArray[index++] = handler2; - } - var handler = ChainHandlersCollectionAndGetFirstLink(finalHandler ?? GetDefaultHttpMessageHandler(), defaultHandlersArray); - return handler != null ? new HttpClient(handler) : new HttpClient(); + return Create(defaultHandlersEnumerable, finalHandler); } /// diff --git a/src/http/httpClient/Middleware/AuthorizationHandler.cs b/src/http/httpClient/Middleware/AuthorizationHandler.cs index 9b42cf2..27c9a96 100644 --- a/src/http/httpClient/Middleware/AuthorizationHandler.cs +++ b/src/http/httpClient/Middleware/AuthorizationHandler.cs @@ -33,8 +33,7 @@ public class AuthorizationHandler : DelegatingHandler /// public AuthorizationHandler(BaseBearerTokenAuthenticationProvider authenticationProvider) { - if(authenticationProvider == null) throw new ArgumentNullException(nameof(authenticationProvider)); - this.authenticationProvider = authenticationProvider; + this.authenticationProvider = authenticationProvider ?? throw new ArgumentNullException(nameof(authenticationProvider)); } /// @@ -73,7 +72,7 @@ protected override async Task SendAsync(HttpRequestMessage return response; activity?.AddEvent(new ActivityEvent("com.microsoft.kiota.handler.authorization.challenge_received")); additionalAuthenticationContext[ContinuousAccessEvaluation.ClaimsKey] = claims; - HttpRequestMessage retryRequest = response.RequestMessage; + var retryRequest = await response.RequestMessage.CloneAsync(cancellationToken); await AuthenticateRequestAsync(retryRequest, additionalAuthenticationContext, cancellationToken, activity).ConfigureAwait(false); activity?.SetTag("http.request.resend_count", 1); return await base.SendAsync(retryRequest, cancellationToken).ConfigureAwait(false); diff --git a/tests/http/httpClient/Middleware/AuthorizationHandlerTests.cs b/tests/http/httpClient/Middleware/AuthorizationHandlerTests.cs index ba6ca84..1082e9a 100644 --- a/tests/http/httpClient/Middleware/AuthorizationHandlerTests.cs +++ b/tests/http/httpClient/Middleware/AuthorizationHandlerTests.cs @@ -13,14 +13,14 @@ namespace Microsoft.Kiota.Http.HttpClientLibrary.Tests.Middleware public class AuthorizationHandlerTests : IDisposable { private readonly MockRedirectHandler _testHttpMessageHandler; - private readonly string _expectedAccessToken = "token"; + private const string _expectedAccessToken = "token"; - private readonly string _expectedAccessTokenAfterCAE = "token2"; + private const string _expectedAccessTokenAfterCAE = "token2"; private AuthorizationHandler _authorizationHandler; private readonly BaseBearerTokenAuthenticationProvider _authenticationProvider; private readonly HttpMessageInvoker _invoker; - private readonly string _claimsChallengeHeaderValue = "authorization_uri=\"https://login.windows.net/common/oauth2/authorize\"," + private const string _claimsChallengeHeaderValue = "authorization_uri=\"https://login.windows.net/common/oauth2/authorize\"," + "error=\"insufficient_claims\"," + "claims=\"eyJhY2Nlc3NfdG9rZW4iOnsibmJmIjp7ImVzc2VudGlhbCI6dHJ1ZSwgInZhbHVlIjoiMTYwNDEwNjY1MSJ9fX0=\""; From 77f946fc2219d87c926f2c7d94ff5acee81dba32 Mon Sep 17 00:00:00 2001 From: Philip Gichuhi Date: Tue, 5 Nov 2024 09:11:28 +0200 Subject: [PATCH 7/9] Increase test coverage --- .../httpClient/KiotaClientFactoryTests.cs | 9 ++++ .../Middleware/AuthorizationHandlerTests.cs | 49 +++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/tests/http/httpClient/KiotaClientFactoryTests.cs b/tests/http/httpClient/KiotaClientFactoryTests.cs index 675acf0..9b28cbc 100644 --- a/tests/http/httpClient/KiotaClientFactoryTests.cs +++ b/tests/http/httpClient/KiotaClientFactoryTests.cs @@ -2,9 +2,11 @@ using System.Linq; using System.Net; using System.Net.Http; +using Microsoft.Kiota.Abstractions.Authentication; using Microsoft.Kiota.Http.HttpClientLibrary.Middleware; using Microsoft.Kiota.Http.HttpClientLibrary.Middleware.Options; using Microsoft.Kiota.Http.HttpClientLibrary.Tests.Mocks; +using Moq; using Xunit; namespace Microsoft.Kiota.Http.HttpClientLibrary.Tests @@ -138,5 +140,12 @@ public void CreateWithCustomMiddlewarePipelineReturnsHttpClient() var client = KiotaClientFactory.Create(handlers); Assert.IsType(client); } + + [Fact] + public void CreateWithAuthenticationProvider() + { + var client = KiotaClientFactory.Create(new BaseBearerTokenAuthenticationProvider(new Mock().Object)); + Assert.IsType(client); + } } } diff --git a/tests/http/httpClient/Middleware/AuthorizationHandlerTests.cs b/tests/http/httpClient/Middleware/AuthorizationHandlerTests.cs index 1082e9a..f8947f2 100644 --- a/tests/http/httpClient/Middleware/AuthorizationHandlerTests.cs +++ b/tests/http/httpClient/Middleware/AuthorizationHandlerTests.cs @@ -1,3 +1,4 @@ +using System.ComponentModel; using System.Net; using System.Net.Http; using System.Net.Http.Headers; @@ -54,6 +55,16 @@ public void Dispose() GC.SuppressFinalize(this); } + [Fact] + public void AuthorizationHandlerConstructor() + { + // Arrange + BaseBearerTokenAuthenticationProvider? authenticationProvider = null; + + // Assert + Assert.Throws(() => new AuthorizationHandler(authenticationProvider!)); + } + [Fact] public async Task AuthorizationHandlerShouldAddAuthHeaderIfNotPresent() { @@ -91,6 +102,24 @@ public async Task AuthorizationHandlerShouldNotAddAuthHeaderIfPresent() Assert.Equal($"Bearer existing", response.RequestMessage.Headers.GetValues("Authorization").First()); } + [Fact] + public async Task AuthorizationHandlerShouldNotAddAuthHeaderIfHostIsNotValid() + { + // Arrange + HttpRequestMessage httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, "https://example.com"); + + HttpResponseMessage httpResponse = new HttpResponseMessage(HttpStatusCode.OK); + + this._testHttpMessageHandler.SetHttpResponse(httpResponse);// set the mock response + + // Act + HttpResponseMessage response = await this._invoker.SendAsync(httpRequestMessage, new CancellationToken()); + + // Assert + Assert.NotNull(response.RequestMessage); + Assert.False(response.RequestMessage.Headers.Contains("Authorization")); + } + [Fact] public async Task AuthorizationHandlerShouldAttemptCAEClaimsChallenge() { @@ -113,5 +142,25 @@ public async Task AuthorizationHandlerShouldAttemptCAEClaimsChallenge() Assert.Equal($"Bearer {_expectedAccessTokenAfterCAE}", response.RequestMessage.Headers.GetValues("Authorization").First()); Assert.Equal("test", await response.RequestMessage.Content!.ReadAsStringAsync()); } + + [Fact] + public async Task AuthorizationHandlerShouldReturnInitialResponseIfClaimsHeaderIsEmpty() + { + // Arrange + HttpRequestMessage httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, "https://graph.microsoft.com"); + httpRequestMessage.Content = new ByteArrayContent(Encoding.UTF8.GetBytes("test")); + + HttpResponseMessage httpResponse = new HttpResponseMessage(HttpStatusCode.Unauthorized); + httpResponse.Headers.WwwAuthenticate.Add(new AuthenticationHeaderValue("Bearer", "authorization_uri=\"https://login.windows.net/common/oauth2/authorize\"")); + + this._testHttpMessageHandler.SetHttpResponse(httpResponse, new HttpResponseMessage(HttpStatusCode.OK));// set the mock response + + // Act + HttpResponseMessage response = await this._invoker.SendAsync(httpRequestMessage, new CancellationToken()); + + // Assert + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + Assert.Equal("test", await response.RequestMessage!.Content!.ReadAsStringAsync()); + } } } From d45b7d4ded3baedb8be4b3b95702feca22d5744c Mon Sep 17 00:00:00 2001 From: Philip Gichuhi Date: Tue, 5 Nov 2024 12:38:17 +0200 Subject: [PATCH 8/9] Fix SonarCloud issues --- src/http/httpClient/HttpClientRequestAdapter.cs | 1 - src/http/httpClient/Middleware/AuthorizationHandler.cs | 9 ++++----- .../httpClient/Middleware/AuthorizationHandlerTests.cs | 6 +++--- 3 files changed, 7 insertions(+), 9 deletions(-) diff --git a/src/http/httpClient/HttpClientRequestAdapter.cs b/src/http/httpClient/HttpClientRequestAdapter.cs index 8e05650..3e6f5de 100644 --- a/src/http/httpClient/HttpClientRequestAdapter.cs +++ b/src/http/httpClient/HttpClientRequestAdapter.cs @@ -494,7 +494,6 @@ private async Task ThrowIfFailedResponseAsync(HttpResponseMessage response, Dict } private const string ClaimsKey = "claims"; private const string BearerAuthenticationScheme = "Bearer"; - private static Func filterAuthHeader = static x => x.Scheme.Equals(BearerAuthenticationScheme, StringComparison.OrdinalIgnoreCase); private async Task GetHttpResponseMessageAsync(RequestInformation requestInfo, CancellationToken cancellationToken, Activity? activityForAttributes, string? claims = default, bool isStreamResponse = false) { using var span = activitySource?.StartActivity(nameof(GetHttpResponseMessageAsync)); diff --git a/src/http/httpClient/Middleware/AuthorizationHandler.cs b/src/http/httpClient/Middleware/AuthorizationHandler.cs index 27c9a96..45950b4 100644 --- a/src/http/httpClient/Middleware/AuthorizationHandler.cs +++ b/src/http/httpClient/Middleware/AuthorizationHandler.cs @@ -46,7 +46,6 @@ protected override async Task SendAsync(HttpRequestMessage CancellationToken cancellationToken) { if(request == null) throw new ArgumentNullException(nameof(request)); - Activity? activity = null; if(request.GetRequestOption() is { } obsOptions) { @@ -62,7 +61,7 @@ protected override async Task SendAsync(HttpRequestMessage return await base.SendAsync(request, cancellationToken).ConfigureAwait(false); } Dictionary additionalAuthenticationContext = new Dictionary(); - await AuthenticateRequestAsync(request, additionalAuthenticationContext, cancellationToken, activity).ConfigureAwait(false); + await AuthenticateRequestAsync(request, additionalAuthenticationContext, activity, cancellationToken).ConfigureAwait(false); var response = await base.SendAsync(request, cancellationToken).ConfigureAwait(false); if(response.StatusCode != HttpStatusCode.Unauthorized || response.RequestMessage == null || !response.RequestMessage.IsBuffered()) return response; @@ -73,7 +72,7 @@ protected override async Task SendAsync(HttpRequestMessage activity?.AddEvent(new ActivityEvent("com.microsoft.kiota.handler.authorization.challenge_received")); additionalAuthenticationContext[ContinuousAccessEvaluation.ClaimsKey] = claims; var retryRequest = await response.RequestMessage.CloneAsync(cancellationToken); - await AuthenticateRequestAsync(retryRequest, additionalAuthenticationContext, cancellationToken, activity).ConfigureAwait(false); + await AuthenticateRequestAsync(retryRequest, additionalAuthenticationContext, activity, cancellationToken).ConfigureAwait(false); activity?.SetTag("http.request.resend_count", 1); return await base.SendAsync(retryRequest, cancellationToken).ConfigureAwait(false); } @@ -85,8 +84,8 @@ protected override async Task SendAsync(HttpRequestMessage private async Task AuthenticateRequestAsync(HttpRequestMessage request, Dictionary additionalAuthenticationContext, - CancellationToken cancellationToken, - Activity? activityForAttributes) + Activity? activityForAttributes, + CancellationToken cancellationToken) { var accessTokenProvider = authenticationProvider.AccessTokenProvider; if(request.RequestUri == null || !accessTokenProvider.AllowedHostsValidator.IsUrlHostValid( diff --git a/tests/http/httpClient/Middleware/AuthorizationHandlerTests.cs b/tests/http/httpClient/Middleware/AuthorizationHandlerTests.cs index f8947f2..e4c5e8c 100644 --- a/tests/http/httpClient/Middleware/AuthorizationHandlerTests.cs +++ b/tests/http/httpClient/Middleware/AuthorizationHandlerTests.cs @@ -78,7 +78,7 @@ public async Task AuthorizationHandlerShouldAddAuthHeaderIfNotPresent() // Assert Assert.NotNull(response.RequestMessage); Assert.True(response.RequestMessage.Headers.Contains("Authorization")); - Assert.True(response.RequestMessage.Headers.GetValues("Authorization").Count() == 1); + Assert.Single(response.RequestMessage.Headers.GetValues("Authorization")); Assert.Equal($"Bearer {_expectedAccessToken}", response.RequestMessage.Headers.GetValues("Authorization").First()); } @@ -98,7 +98,7 @@ public async Task AuthorizationHandlerShouldNotAddAuthHeaderIfPresent() // Assert Assert.NotNull(response.RequestMessage); Assert.True(response.RequestMessage.Headers.Contains("Authorization")); - Assert.True(response.RequestMessage.Headers.GetValues("Authorization").Count() == 1); + Assert.Single(response.RequestMessage.Headers.GetValues("Authorization")); Assert.Equal($"Bearer existing", response.RequestMessage.Headers.GetValues("Authorization").First()); } @@ -138,7 +138,7 @@ public async Task AuthorizationHandlerShouldAttemptCAEClaimsChallenge() // Assert Assert.NotNull(response.RequestMessage); Assert.True(response.RequestMessage.Headers.Contains("Authorization")); - Assert.True(response.RequestMessage.Headers.GetValues("Authorization").Count() == 1); + Assert.Single(response.RequestMessage.Headers.GetValues("Authorization")); Assert.Equal($"Bearer {_expectedAccessTokenAfterCAE}", response.RequestMessage.Headers.GetValues("Authorization").First()); Assert.Equal("test", await response.RequestMessage.Content!.ReadAsStringAsync()); } From 52cecf05d11d43edbeaad68c7625c9e35e230c7e Mon Sep 17 00:00:00 2001 From: Philip Gichuhi Date: Tue, 5 Nov 2024 17:06:03 +0200 Subject: [PATCH 9/9] Bump version and update CHANGELOG --- CHANGELOG.md | 7 +++++++ Directory.Build.props | 4 ++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a52b7fa..2f5f00d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.14.0] - 2024-11-06 + +### Added + +- Added `AuthorizationHandler` to authenticate requests and `GraphClientFactory.create(authProvider)` to instantiate +an HttpClient with the built-in Authorization Handler. + ## [1.13.2] - 2024-10-28 ### Changed diff --git a/Directory.Build.props b/Directory.Build.props index 9988389..a0dfeef 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,7 +1,7 @@ - 1.13.2 + 1.14.0 false @@ -17,4 +17,4 @@ false Library - \ No newline at end of file +