Skip to content

Commit

Permalink
feat: Adds Authorization handler
Browse files Browse the repository at this point in the history
  • Loading branch information
Ndiritu committed Oct 9, 2024
1 parent ec056c5 commit cdb8ffa
Show file tree
Hide file tree
Showing 4 changed files with 302 additions and 40 deletions.
74 changes: 74 additions & 0 deletions src/http/httpClient/ContinuousAccessEvaluation.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
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>
internal class ContinuousAccessEvaluation

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

View workflow job for this annotation

GitHub Actions / Build

Add a 'protected' constructor or the 'static' keyword to the class declaration. (https://rules.sonarsource.com/csharp/RSPEC-1118)

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

View workflow job for this annotation

GitHub Actions / Build

Add a 'protected' constructor or the 'static' keyword to the class declaration. (https://rules.sonarsource.com/csharp/RSPEC-1118)
{

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 26 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 35 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;
}
}
}

48 changes: 8 additions & 40 deletions src/http/httpClient/HttpClientRequestAdapter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -536,13 +536,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 +549,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
106 changes: 106 additions & 0 deletions src/http/httpClient/Middleware/AuthorizationHandler.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
// ------------------------------------------------------------------------------
// 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 BaseBearerTokenAuthenticationProvider authenticationProvider;

Check warning on line 27 in src/http/httpClient/Middleware/AuthorizationHandler.cs

View workflow job for this annotation

GitHub Actions / Build

Make 'authenticationProvider' 'readonly'. (https://rules.sonarsource.com/csharp/RSPEC-2933)

/// <summary>
/// Constructs an <see cref="AuthorizationHandler"/>
/// </summary>
/// <param name="authenticationProvider"></param>
/// <exception cref="ArgumentNullException"></exception>
public AuthorizationHandler(BaseBearerTokenAuthenticationProvider authenticationProvider)
{
if(authenticationProvider == null) throw new ArgumentNullException(nameof(authenticationProvider));
this.authenticationProvider = 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, cancellationToken, activity).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;
HttpRequestMessage retryRequest = response.RequestMessage;
await AuthenticateRequestAsync(retryRequest, additionalAuthenticationContext, cancellationToken, activity).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,
CancellationToken cancellationToken,
Activity? activityForAttributes)
{
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);
if(string.IsNullOrEmpty(accessToken)) return;
activityForAttributes?.SetTag("com.microsoft.kiota.handler.authorization.token_obtained", true);
request.Headers.TryAddWithoutValidation(AuthorizationHeader, $"Bearer {accessToken}");
}
}
}
114 changes: 114 additions & 0 deletions tests/http/httpClient/Middleware/AuthorizationHandlerTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
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 IAccessTokenProvider _mockAccessTokenProvider;

private readonly string _expectedAccessToken = "token";

private readonly string _expectedAccessTokenAfterCAE = "token2";
private AuthorizationHandler _authorizationHandler;
private readonly BaseBearerTokenAuthenticationProvider _authenticationProvider;
private readonly HttpMessageInvoker _invoker;

private readonly 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<IAccessTokenProvider>();
mockAccessTokenProvider.SetupSequence(x => x.GetAuthorizationTokenAsync(
It.IsAny<Uri>(),
It.IsAny<Dictionary<string, object>>(),
It.IsAny<CancellationToken>()
)).Returns(new Task<string>(() => _expectedAccessToken))
.Returns(new Task<string>(() => _expectedAccessTokenAfterCAE));

mockAccessTokenProvider.Setup(x => x.AllowedHostsValidator).Returns(
new AllowedHostsValidator(new List<string> { "https://graph.microsoft.com" })
);
this._mockAccessTokenProvider = mockAccessTokenProvider.Object;
this._authenticationProvider = new BaseBearerTokenAuthenticationProvider(_mockAccessTokenProvider!);
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 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.True(response.RequestMessage.Headers.Contains("Authorization"));
Assert.True(response.RequestMessage.Headers.GetValues("Authorization").Count() == 1);
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.True(response.RequestMessage.Headers.Contains("Authorization"));
Assert.True(response.RequestMessage.Headers.GetValues("Authorization").Count() == 1);
Assert.Equal($"Bearer existing", response.RequestMessage.Headers.GetValues("Authorization").First());
}

[Fact]
public async Task AuthorizationHandlerShouldAttemptCAEClaimsChallenge()
{
// Arrange
HttpRequestMessage httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, "https://graph.microsoft.com");

HttpResponseMessage httpResponse = new HttpResponseMessage(HttpStatusCode.Unauthorized);
httpResponse.Headers.WwwAuthenticate.Add(new AuthenticationHeaderValue("Bearer", _claimsChallengeHeaderValue));

this._testHttpMessageHandler.SetHttpResponse(httpResponse);// set the mock response

// Act
HttpResponseMessage response = await this._invoker.SendAsync(httpRequestMessage, new CancellationToken());

// Assert
Assert.True(response.RequestMessage.Headers.Contains("Authorization"));
Assert.True(response.RequestMessage.Headers.GetValues("Authorization").Count() == 1);
Assert.Equal($"Bearer {_expectedAccessTokenAfterCAE}", response.RequestMessage.Headers.GetValues("Authorization").First());
}
}
}

0 comments on commit cdb8ffa

Please sign in to comment.