-
Notifications
You must be signed in to change notification settings - Fork 34
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
4 changed files
with
302 additions
and
40 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 GitHub Actions / Build
|
||
{ | ||
|
||
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; | ||
} | ||
} | ||
} | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
|
||
/// <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
114
tests/http/httpClient/Middleware/AuthorizationHandlerTests.cs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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()); | ||
} | ||
} | ||
} |