Skip to content

Commit

Permalink
Merge pull request #430 from microsoft/feat/auth-handler
Browse files Browse the repository at this point in the history
Adds Authorization handler
  • Loading branch information
Ndiritu authored Nov 6, 2024
2 parents 75f6bd3 + 52cecf0 commit 93672c8
Show file tree
Hide file tree
Showing 9 changed files with 384 additions and 44 deletions.
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions Directory.Build.props
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<Project>
<!-- Common default project properties for ALL projects-->
<PropertyGroup>
<VersionPrefix>1.13.2</VersionPrefix>
<VersionPrefix>1.14.0</VersionPrefix>
<VersionSuffix></VersionSuffix>
<!-- This is overidden in test projects by setting to true-->
<IsTestProject>false</IsTestProject>
Expand All @@ -17,4 +17,4 @@
<IsPackable>false</IsPackable>
<OutputType>Library</OutputType>
</PropertyGroup>
</Project>
</Project>
2 changes: 1 addition & 1 deletion src/generated/KiotaVersionGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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 = $@"// <auto-generated/>
Expand Down
73 changes: 73 additions & 0 deletions src/http/httpClient/ContinuousAccessEvaluation.cs
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// Process continuous access evaluation
/// </summary>
static internal class ContinuousAccessEvaluation
{
internal const string ClaimsKey = "claims";
internal const string BearerAuthenticationScheme = "Bearer";
private static readonly char[] ComaSplitSeparator = [','];
private static Func<AuthenticationHeaderValue, bool> filterAuthHeader = static x => x.Scheme.Equals(BearerAuthenticationScheme, StringComparison.OrdinalIgnoreCase);
private static readonly Regex caeValueRegex = new("\"([^\"]*)\"", RegexOptions.Compiled, TimeSpan.FromMilliseconds(100));

/// <summary>
/// Extracts claims header value from a response
/// </summary>
/// <param name="response"></param>
/// <returns></returns>
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;
}
}
}

49 changes: 8 additions & 41 deletions src/http/httpClient/HttpClientRequestAdapter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -494,7 +494,6 @@ private async Task ThrowIfFailedResponseAsync(HttpResponseMessage response, Dict
}
private const string ClaimsKey = "claims";
private const string BearerAuthenticationScheme = "Bearer";
private static Func<AuthenticationHeaderValue, bool> filterAuthHeader = static x => x.Scheme.Equals(BearerAuthenticationScheme, StringComparison.OrdinalIgnoreCase);
private async Task<HttpResponseMessage> GetHttpResponseMessageAsync(RequestInformation requestInfo, CancellationToken cancellationToken, Activity? activityForAttributes, string? claims = default, bool isStreamResponse = false)
{
using var span = activitySource?.StartActivity(nameof(GetHttpResponseMessageAsync));
Expand Down Expand Up @@ -536,13 +535,11 @@ private async Task<HttpResponseMessage> GetHttpResponseMessageAsync(RequestInfor
return await RetryCAEResponseIfRequiredAsync(response, requestInfo, cancellationToken, claims, activityForAttributes).ConfigureAwait(false);
}

private static readonly Regex caeValueRegex = new("\"([^\"]*)\"", RegexOptions.Compiled, TimeSpan.FromMilliseconds(100));

/// <summary>
/// The key for the event raised by tracing when an authentication challenge is received
/// </summary>
public const string AuthenticateChallengedEventKey = "com.microsoft.kiota.authenticate_challenge_received";
private static readonly char[] ComaSplitSeparator = [','];

private async Task<HttpResponseMessage> RetryCAEResponseIfRequiredAsync(HttpResponseMessage response, RequestInformation requestInfo, CancellationToken cancellationToken, string? claims, Activity? activityForAttributes)
{
Expand All @@ -551,46 +548,16 @@ private async Task<HttpResponseMessage> 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;
}
Expand Down
14 changes: 14 additions & 0 deletions src/http/httpClient/KiotaClientFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,20 @@ public static HttpClient Create(IList<DelegatingHandler> handlers, HttpMessageHa
return handler != null ? new HttpClient(handler) : new HttpClient();
}

/// <summary>
/// Initializes the <see cref="HttpClient"/> with the default configuration and authentication middleware using the <see cref="IAuthenticationProvider"/> if provided.
/// </summary>
/// <param name="authenticationProvider"></param>
/// <param name="optionsForHandlers"></param>
/// <param name="finalHandler"></param>
/// <returns></returns>
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);
}

/// <summary>
/// Creates a default set of middleware to be used by the <see cref="HttpClient"/>.
/// </summary>
Expand Down
104 changes: 104 additions & 0 deletions src/http/httpClient/Middleware/AuthorizationHandler.cs
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// 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
/// </summary>
public class AuthorizationHandler : DelegatingHandler
{

private const string AuthorizationHeader = "Authorization";
private readonly BaseBearerTokenAuthenticationProvider authenticationProvider;

/// <summary>
/// Constructs an <see cref="AuthorizationHandler"/>
/// </summary>
/// <param name="authenticationProvider"></param>
/// <exception cref="ArgumentNullException"></exception>
public AuthorizationHandler(BaseBearerTokenAuthenticationProvider authenticationProvider)
{
this.authenticationProvider = authenticationProvider ?? throw new ArgumentNullException(nameof(authenticationProvider));
}

/// <summary>
/// Adds an Authorization header if not already provided
/// </summary>
/// <param name="request"></param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request,
CancellationToken cancellationToken)
{
if(request == null) throw new ArgumentNullException(nameof(request));
Activity? activity = null;
if(request.GetRequestOption<ObservabilityOptions>() 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<string, object> additionalAuthenticationContext = new Dictionary<string, object>();
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<string, object> 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);
}
}
}
9 changes: 9 additions & 0 deletions tests/http/httpClient/KiotaClientFactoryTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -138,5 +140,12 @@ public void CreateWithCustomMiddlewarePipelineReturnsHttpClient()
var client = KiotaClientFactory.Create(handlers);
Assert.IsType<HttpClient>(client);
}

[Fact]
public void CreateWithAuthenticationProvider()
{
var client = KiotaClientFactory.Create(new BaseBearerTokenAuthenticationProvider(new Mock<IAccessTokenProvider>().Object));
Assert.IsType<HttpClient>(client);
}
}
}
Loading

0 comments on commit 93672c8

Please sign in to comment.