Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adds Authorization handler #430

Merged
merged 9 commits into from
Nov 6, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading