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 8 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
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)

Check warning on line 25 in src/http/httpClient/ContinuousAccessEvaluation.cs

View workflow job for this annotation

GitHub Actions / Build

Refactor this method to reduce its Cognitive Complexity from 19 to the 15 allowed. (https://rules.sonarsource.com/csharp/RSPEC-3776)

Check warning on line 25 in src/http/httpClient/ContinuousAccessEvaluation.cs

View workflow job for this annotation

GitHub Actions / Build

Refactor this method to reduce its Cognitive Complexity from 19 to the 15 allowed. (https://rules.sonarsource.com/csharp/RSPEC-3776)
{
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)

Check warning on line 34 in src/http/httpClient/ContinuousAccessEvaluation.cs

View workflow job for this annotation

GitHub Actions / Build

Loops should be simplified using the "Where" LINQ method (https://rules.sonarsource.com/csharp/RSPEC-3267)
{
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 @@ -225,7 +225,7 @@
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to use for cancelling the request.</param>
/// <returns>The deserialized primitive response model.</returns>
#if NET5_0_OR_GREATER
public async Task<ModelType?> SendPrimitiveAsync<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicFields)] ModelType>(RequestInformation requestInfo, Dictionary<string, ParsableFactory<IParsable>>? errorMapping = default, CancellationToken cancellationToken = default)

Check warning on line 228 in src/http/httpClient/HttpClientRequestAdapter.cs

View workflow job for this annotation

GitHub Actions / Build

Refactor this method to reduce its Cognitive Complexity from 32 to the 15 allowed. (https://rules.sonarsource.com/csharp/RSPEC-3776)
#else
public async Task<ModelType?> SendPrimitiveAsync<ModelType>(RequestInformation requestInfo, Dictionary<string, ParsableFactory<IParsable>>? errorMapping = default, CancellationToken cancellationToken = default)
#endif
Expand Down Expand Up @@ -487,14 +487,13 @@
if(contentStream == Stream.Null || (contentStream.CanSeek && contentStream.Length == 0))
return null;// ensure a useful stream is passed to the factory
#pragma warning disable CS0618 // Type or member is obsolete
//TODO remove with v2

Check warning on line 490 in src/http/httpClient/HttpClientRequestAdapter.cs

View workflow job for this annotation

GitHub Actions / Build

Complete the task associated to this 'TODO' comment. (https://rules.sonarsource.com/csharp/RSPEC-1135)

Check warning on line 490 in src/http/httpClient/HttpClientRequestAdapter.cs

View workflow job for this annotation

GitHub Actions / Build

Complete the task associated to this 'TODO' comment. (https://rules.sonarsource.com/csharp/RSPEC-1135)
var rootNode = pNodeFactory is IAsyncParseNodeFactory asyncParseNodeFactory ? await asyncParseNodeFactory.GetRootParseNodeAsync(responseContentType!, contentStream, cancellationToken).ConfigureAwait(false) : pNodeFactory.GetRootParseNode(responseContentType!, contentStream);
#pragma warning restore CS0618 // Type or member is obsolete
return rootNode;
}
private const string ClaimsKey = "claims";
private const string BearerAuthenticationScheme = "Bearer";

Check warning on line 496 in src/http/httpClient/HttpClientRequestAdapter.cs

View workflow job for this annotation

GitHub Actions / Build

Remove the unused private field 'BearerAuthenticationScheme'. (https://rules.sonarsource.com/csharp/RSPEC-1144)

Check warning on line 496 in src/http/httpClient/HttpClientRequestAdapter.cs

View workflow job for this annotation

GitHub Actions / Build

Remove the unused private field 'BearerAuthenticationScheme'. (https://rules.sonarsource.com/csharp/RSPEC-1144)
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 @@
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 @@
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 All @@ -608,7 +575,7 @@
else throw new InvalidOperationException($"Could not convert the request information to a {typeof(T).Name}");
}

private HttpRequestMessage GetRequestMessageFromRequestInformation(RequestInformation requestInfo, Activity? activityForAttributes)

Check warning on line 578 in src/http/httpClient/HttpClientRequestAdapter.cs

View workflow job for this annotation

GitHub Actions / Build

Refactor this method to reduce its Cognitive Complexity from 25 to the 15 allowed. (https://rules.sonarsource.com/csharp/RSPEC-3776)
{
using var span = activitySource?.StartActivity(nameof(GetRequestMessageFromRequestInformation));
SetBaseUrlForRequestInformation(requestInfo);// this method can also be called from a different context so ensure the baseUrl is added.
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,11 +61,25 @@
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>
/// <returns>A list of the default handlers used by the client.</returns>
public static IList<DelegatingHandler> CreateDefaultHandlers(IRequestOption[]? optionsForHandlers = null)

Check warning on line 82 in src/http/httpClient/KiotaClientFactory.cs

View workflow job for this annotation

GitHub Actions / Build

Refactor this method to reduce its Cognitive Complexity from 20 to the 15 allowed. (https://rules.sonarsource.com/csharp/RSPEC-3776)
{
optionsForHandlers ??= Array.Empty<IRequestOption>();

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