From cdb8ffa613fb82fc42baa01fd1b39a7cda361bdc Mon Sep 17 00:00:00 2001 From: Philip Gichuhi Date: Wed, 9 Oct 2024 15:48:07 +0300 Subject: [PATCH] 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 4ec4906..bc95afb 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()); + } + } +}