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());
+ }
+ }
+}