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 + 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/src/http/httpClient/ContinuousAccessEvaluation.cs b/src/http/httpClient/ContinuousAccessEvaluation.cs new file mode 100644 index 0000000..4f84066 --- /dev/null +++ b/src/http/httpClient/ContinuousAccessEvaluation.cs @@ -0,0 +1,73 @@ +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 + /// + static 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..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)); @@ -536,13 +535,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 +548,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/KiotaClientFactory.cs b/src/http/httpClient/KiotaClientFactory.cs index 5a89c01..2f647c5 100644 --- a/src/http/httpClient/KiotaClientFactory.cs +++ b/src/http/httpClient/KiotaClientFactory.cs @@ -61,6 +61,20 @@ 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)); + return Create(defaultHandlersEnumerable, finalHandler); + } + /// /// 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 new file mode 100644 index 0000000..45950b4 --- /dev/null +++ b/src/http/httpClient/Middleware/AuthorizationHandler.cs @@ -0,0 +1,104 @@ +// ------------------------------------------------------------------------------ +// 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 readonly BaseBearerTokenAuthenticationProvider authenticationProvider; + + /// + /// Constructs an + /// + /// + /// + public AuthorizationHandler(BaseBearerTokenAuthenticationProvider authenticationProvider) + { + this.authenticationProvider = authenticationProvider ?? throw new ArgumentNullException(nameof(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, 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; + // 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; + var retryRequest = await response.RequestMessage.CloneAsync(cancellationToken); + await AuthenticateRequestAsync(retryRequest, additionalAuthenticationContext, activity, cancellationToken).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, + Activity? activityForAttributes, + CancellationToken cancellationToken) + { + 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); + activityForAttributes?.SetTag("com.microsoft.kiota.handler.authorization.token_obtained", true); + if(string.IsNullOrEmpty(accessToken)) return; + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); + } + } +} 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 new file mode 100644 index 0000000..e4c5e8c --- /dev/null +++ b/tests/http/httpClient/Middleware/AuthorizationHandlerTests.cs @@ -0,0 +1,166 @@ +using System.ComponentModel; +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; +using Moq; +using Xunit; + +namespace Microsoft.Kiota.Http.HttpClientLibrary.Tests.Middleware +{ + public class AuthorizationHandlerTests : IDisposable + { + private readonly MockRedirectHandler _testHttpMessageHandler; + private const string _expectedAccessToken = "token"; + + private const string _expectedAccessTokenAfterCAE = "token2"; + private AuthorizationHandler _authorizationHandler; + private readonly BaseBearerTokenAuthenticationProvider _authenticationProvider; + private readonly HttpMessageInvoker _invoker; + + private const 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() + ).Result).Returns(_expectedAccessToken) + .Returns(_expectedAccessTokenAfterCAE); + + mockAccessTokenProvider.Setup(x => x.AllowedHostsValidator).Returns( + new AllowedHostsValidator(new List { "graph.microsoft.com" }) + ); + var mockAuthenticationProvider = new Mock(mockAccessTokenProvider.Object); + this._authenticationProvider = mockAuthenticationProvider.Object; + 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 void AuthorizationHandlerConstructor() + { + // Arrange + BaseBearerTokenAuthenticationProvider? authenticationProvider = null; + + // Assert + Assert.Throws(() => new AuthorizationHandler(authenticationProvider!)); + } + + [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.NotNull(response.RequestMessage); + Assert.True(response.RequestMessage.Headers.Contains("Authorization")); + Assert.Single(response.RequestMessage.Headers.GetValues("Authorization")); + 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.NotNull(response.RequestMessage); + Assert.True(response.RequestMessage.Headers.Contains("Authorization")); + Assert.Single(response.RequestMessage.Headers.GetValues("Authorization")); + 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() + { + // 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, new HttpResponseMessage(HttpStatusCode.OK));// set the mock response + + // Act + HttpResponseMessage response = await this._invoker.SendAsync(httpRequestMessage, new CancellationToken()); + + // Assert + Assert.NotNull(response.RequestMessage); + Assert.True(response.RequestMessage.Headers.Contains("Authorization")); + 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()); + } + + [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()); + } + } +}