mirror of
https://github.com/wiremock/WireMock.Net.git
synced 2026-01-11 22:30:41 +01:00
Update AzureADAuthenticationMatcher to support V2 Azure AAD tokens (#1288)
* Update AzureADAuthenticationMatcher to support V2 Azure AAD tokens * fix ;-) * add tests * Update test/WireMock.Net.Tests/Authentication/MockJwtSecurityTokenHandler.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * . * WIREMOCK_AAD_TENANT * update logging * throw new SecurityTokenInvalidIssuerException($"tenant {extractedTenant} does not match {_tenant}."); --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -176,8 +176,8 @@ message HelloReply {
|
||||
|
||||
public static void Run()
|
||||
{
|
||||
RunSse();
|
||||
RunOnLocal();
|
||||
//RunSse();
|
||||
//RunOnLocal();
|
||||
|
||||
var mappingBuilder = new MappingBuilder();
|
||||
mappingBuilder
|
||||
@@ -268,8 +268,8 @@ message HelloReply {
|
||||
});
|
||||
System.Console.WriteLine("WireMockServer listening at {0}", string.Join(",", server.Urls));
|
||||
|
||||
server.SetBasicAuthentication("a", "b");
|
||||
//server.SetAzureADAuthentication("6c2a4722-f3b9-4970-b8fc-fac41e29stef", "8587fde1-7824-42c7-8592-faf92b04stef");
|
||||
//server.SetBasicAuthentication("a", "b");
|
||||
server.SetAzureADAuthentication(Environment.GetEnvironmentVariable("WIREMOCK_AAD_TENANT")!, "api://e083d51a-01a6-446c-8ad5-0c5c7f002208");
|
||||
|
||||
//var http = new HttpClient();
|
||||
//var response = await http.GetAsync($"{_wireMockServer.Url}/pricing");
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
#if !NETSTANDARD1_3
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Text.RegularExpressions;
|
||||
using AnyOfTypes;
|
||||
@@ -19,18 +19,24 @@ namespace WireMock.Authentication;
|
||||
/// <summary>
|
||||
/// https://www.c-sharpcorner.com/article/how-to-validate-azure-ad-token-using-console-application/
|
||||
/// https://stackoverflow.com/questions/38684865/validation-of-an-azure-ad-bearer-token-in-a-console-application
|
||||
/// https://github.com/AzureAD/microsoft-identity-web/blob/36fb5f555638787823a89e89c67f17d6a10006ed/tools/CrossPlatformValidator/CrossPlatformValidation/CrossPlatformValidation/RequestValidator.cs#L42
|
||||
/// </summary>
|
||||
internal class AzureADAuthenticationMatcher : IStringMatcher
|
||||
{
|
||||
private const string BearerPrefix = "Bearer ";
|
||||
private static readonly Regex ExtractTenantIdRegex = new(@"https:\/\/(?:sts\.windows\.net|login\.microsoftonline\.com)\/([a-z0-9-]{36}|[a-zA-Z0-9\.]+)/", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
|
||||
private readonly JwtSecurityTokenHandler _jwtSecurityTokenHandler;
|
||||
private readonly IConfigurationManager<OpenIdConnectConfiguration> _configurationManager;
|
||||
private readonly string _tenant;
|
||||
private readonly string _audience;
|
||||
private readonly string _stsDiscoveryEndpoint;
|
||||
|
||||
public AzureADAuthenticationMatcher(string tenant, string audience)
|
||||
public AzureADAuthenticationMatcher(JwtSecurityTokenHandler jwtSecurityTokenHandler, IConfigurationManager<OpenIdConnectConfiguration> configurationManager, string tenant, string audience)
|
||||
{
|
||||
_jwtSecurityTokenHandler = Guard.NotNull(jwtSecurityTokenHandler);
|
||||
_configurationManager = Guard.NotNull(configurationManager);
|
||||
_audience = Guard.NotNullOrEmpty(audience);
|
||||
_stsDiscoveryEndpoint = string.Format(CultureInfo.InvariantCulture, "https://login.microsoftonline.com/{0}/.well-known/openid-configuration", Guard.NotNullOrEmpty(tenant));
|
||||
_tenant = Guard.NotNullOrEmpty(tenant);
|
||||
}
|
||||
|
||||
public string Name => nameof(AzureADAuthenticationMatcher);
|
||||
@@ -55,19 +61,27 @@ internal class AzureADAuthenticationMatcher : IStringMatcher
|
||||
|
||||
try
|
||||
{
|
||||
var configManager = new ConfigurationManager<OpenIdConnectConfiguration>(_stsDiscoveryEndpoint, new OpenIdConnectConfigurationRetriever());
|
||||
var config = configManager.GetConfigurationAsync().ConfigureAwait(false).GetAwaiter().GetResult();
|
||||
var config = _configurationManager.GetConfigurationAsync(default).ConfigureAwait(false).GetAwaiter().GetResult();
|
||||
|
||||
var validationParameters = new TokenValidationParameters
|
||||
{
|
||||
ValidAudience = _audience,
|
||||
ValidIssuer = config.Issuer,
|
||||
IssuerValidator = (issuer, _, _) =>
|
||||
{
|
||||
if (TryExtractTenantId(issuer, out var extractedTenant) && string.Equals(extractedTenant, _tenant, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return issuer;
|
||||
}
|
||||
|
||||
throw new SecurityTokenInvalidIssuerException($"tenant {extractedTenant} does not match {_tenant}.");
|
||||
},
|
||||
IssuerSigningKeys = config.SigningKeys,
|
||||
ValidateLifetime = true
|
||||
};
|
||||
|
||||
// Throws an Exception as the token is invalid (expired, invalid-formatted, etc.)
|
||||
new JwtSecurityTokenHandler().ValidateToken(token, validationParameters, out var _);
|
||||
// Throws an Exception as the token is invalid (expired, invalid-formatted, tenant mismatch, etc.)
|
||||
_jwtSecurityTokenHandler.ValidateToken(token, validationParameters, out _);
|
||||
|
||||
return MatchScores.Perfect;
|
||||
}
|
||||
@@ -82,5 +96,20 @@ internal class AzureADAuthenticationMatcher : IStringMatcher
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
// Handles: https://sts.windows.net/{tenant}/, https://login.microsoftonline.com/{tenant}/ or /v2.0
|
||||
private static bool TryExtractTenantId(string issuer, [NotNullWhen(true)] out string? tenant)
|
||||
{
|
||||
var match = ExtractTenantIdRegex.Match(issuer);
|
||||
|
||||
if (match is { Success: true, Groups.Count: > 1 })
|
||||
{
|
||||
tenant = match.Groups[1].Value;
|
||||
return !string.IsNullOrEmpty(tenant);
|
||||
}
|
||||
|
||||
tenant = null;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -10,11 +10,11 @@ using WireMock.Matchers;
|
||||
using WireMock.Http;
|
||||
using WireMock.Owin.Mappers;
|
||||
using WireMock.Serialization;
|
||||
using WireMock.Types;
|
||||
using WireMock.ResponseBuilders;
|
||||
using WireMock.Settings;
|
||||
using System.Collections.Generic;
|
||||
using WireMock.Constants;
|
||||
using WireMock.Exceptions;
|
||||
using WireMock.Util;
|
||||
#if !USE_ASPNETCORE
|
||||
using IContext = Microsoft.Owin.IOwinContext;
|
||||
@@ -126,7 +126,7 @@ namespace WireMock.Owin
|
||||
if (targetMapping == null)
|
||||
{
|
||||
logRequest = true;
|
||||
_options.Logger.Warn("HttpStatusCode set to 404 : No matching mapping found", ctx.Request);
|
||||
_options.Logger.Warn("HttpStatusCode set to 404 : No matching mapping found");
|
||||
response = ResponseMessageBuilder.Create(HttpStatusCode.NotFound, WireMockConstants.NoMatchingFound);
|
||||
return;
|
||||
}
|
||||
@@ -135,10 +135,18 @@ namespace WireMock.Owin
|
||||
|
||||
if (targetMapping.IsAdminInterface && _options.AuthenticationMatcher != null && request.Headers != null)
|
||||
{
|
||||
bool present = request.Headers.TryGetValue(HttpKnownHeaderNames.Authorization, out WireMockList<string>? authorization);
|
||||
if (!present || _options.AuthenticationMatcher.IsMatch(authorization!.ToString()).Score < MatchScores.Perfect)
|
||||
var authorizationHeaderPresent = request.Headers.TryGetValue(HttpKnownHeaderNames.Authorization, out var authorization);
|
||||
if (!authorizationHeaderPresent)
|
||||
{
|
||||
_options.Logger.Error("HttpStatusCode set to 401");
|
||||
_options.Logger.Error("HttpStatusCode set to 401, authorization header is missing.");
|
||||
response = ResponseMessageBuilder.Create(HttpStatusCode.Unauthorized, null);
|
||||
return;
|
||||
}
|
||||
|
||||
var authorizationHeaderMatchResult = _options.AuthenticationMatcher.IsMatch(authorization!.ToString());
|
||||
if (!MatchScores.IsPerfect(authorizationHeaderMatchResult.Score))
|
||||
{
|
||||
_options.Logger.Error("HttpStatusCode set to 401, authentication failed.", authorizationHeaderMatchResult.Exception ?? throw new WireMockException("Authentication failed"));
|
||||
response = ResponseMessageBuilder.Create(HttpStatusCode.Unauthorized, null);
|
||||
return;
|
||||
}
|
||||
@@ -156,12 +164,12 @@ namespace WireMock.Owin
|
||||
|
||||
if (!targetMapping.IsAdminInterface && theOptionalNewMapping != null)
|
||||
{
|
||||
if (responseBuilder?.ProxyAndRecordSettings?.SaveMapping == true || targetMapping.Settings?.ProxyAndRecordSettings?.SaveMapping == true)
|
||||
if (responseBuilder?.ProxyAndRecordSettings?.SaveMapping == true || targetMapping.Settings.ProxyAndRecordSettings?.SaveMapping == true)
|
||||
{
|
||||
_options.Mappings.TryAdd(theOptionalNewMapping.Guid, theOptionalNewMapping);
|
||||
}
|
||||
|
||||
if (responseBuilder?.ProxyAndRecordSettings?.SaveMappingToFile == true || targetMapping.Settings?.ProxyAndRecordSettings?.SaveMappingToFile == true)
|
||||
if (responseBuilder?.ProxyAndRecordSettings?.SaveMappingToFile == true || targetMapping.Settings.ProxyAndRecordSettings?.SaveMappingToFile == true)
|
||||
{
|
||||
var matcherMapper = new MatcherMapper(targetMapping.Settings);
|
||||
var mappingConverter = new MappingConverter(matcherMapper);
|
||||
|
||||
@@ -514,7 +514,11 @@ public partial class WireMockServer : IWireMockServer
|
||||
#if NETSTANDARD1_3
|
||||
throw new NotSupportedException("AzureADAuthentication is not supported for NETStandard 1.3");
|
||||
#else
|
||||
_options.AuthenticationMatcher = new AzureADAuthenticationMatcher(tenant, audience);
|
||||
_options.AuthenticationMatcher = new AzureADAuthenticationMatcher(
|
||||
new System.IdentityModel.Tokens.Jwt.JwtSecurityTokenHandler(),
|
||||
new Microsoft.IdentityModel.Protocols.ConfigurationManager<Microsoft.IdentityModel.Protocols.OpenIdConnect.OpenIdConnectConfiguration>($"https://login.microsoftonline.com/{tenant}/.well-known/openid-configuration", new Microsoft.IdentityModel.Protocols.OpenIdConnect.OpenIdConnectConfigurationRetriever()),
|
||||
tenant,
|
||||
audience);
|
||||
#endif
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
// Copyright © WireMock.Net
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using Microsoft.IdentityModel.Protocols;
|
||||
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using Moq;
|
||||
using WireMock.Authentication;
|
||||
using Xunit;
|
||||
|
||||
namespace WireMock.Net.Tests.Authentication;
|
||||
|
||||
public class AzureADAuthenticationMatcherTests
|
||||
{
|
||||
public enum AzureADTokenVersion
|
||||
{
|
||||
V1,
|
||||
V2
|
||||
}
|
||||
|
||||
private const string Tenant = "test-tenant-id";
|
||||
private const string Audience = "test-audience";
|
||||
private static readonly Dictionary<AzureADTokenVersion, string> IssuerUrlTemplates = new()
|
||||
{
|
||||
{ AzureADTokenVersion.V1, "https://sts.windows.net/{0}/" },
|
||||
{ AzureADTokenVersion.V2, "https://login.microsoftonline.com/{0}/v2.0" }
|
||||
};
|
||||
private readonly Mock<IConfigurationManager<OpenIdConnectConfiguration>> _openIdConnectConfigurationManagerMock = new();
|
||||
|
||||
private readonly AzureADAuthenticationMatcher _sut;
|
||||
|
||||
public AzureADAuthenticationMatcherTests()
|
||||
{
|
||||
var jwtSecurityTokenHandler = new MockJwtSecurityTokenHandler();
|
||||
_openIdConnectConfigurationManagerMock.Setup(c => c.GetConfigurationAsync(It.IsAny<CancellationToken>())).ReturnsAsync(new OpenIdConnectConfiguration());
|
||||
|
||||
_sut = new(jwtSecurityTokenHandler, _openIdConnectConfigurationManagerMock.Object, Tenant, Audience);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AzureADAuthenticationMatcher_Name_ShouldReturnCorrectName()
|
||||
{
|
||||
// Act
|
||||
var name = _sut.Name;
|
||||
|
||||
// Assert
|
||||
Assert.Equal("AzureADAuthenticationMatcher", name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AzureADAuthenticationMatcher_GetPatterns_ShouldReturnEmptyPatterns()
|
||||
{
|
||||
// Act
|
||||
var patterns = _sut.GetPatterns();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(patterns);
|
||||
Assert.Empty(patterns);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AzureADAuthenticationMatcher_IsMatch_ShouldReturnMismatch_WhenTokenIsInvalid()
|
||||
{
|
||||
// Arrange
|
||||
var sut = new AzureADAuthenticationMatcher(new JwtSecurityTokenHandler(), _openIdConnectConfigurationManagerMock.Object, Tenant, Audience);
|
||||
var invalidToken = "invalid-token";
|
||||
|
||||
// Act
|
||||
var result = sut.IsMatch($"Bearer {invalidToken}");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(0.0, result.Score);
|
||||
Assert.NotNull(result.Exception);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AzureADAuthenticationMatcher_IsMatch_ShouldReturnMismatch_WhenTokenIsNullOrEmpty()
|
||||
{
|
||||
// Act
|
||||
var result = _sut.IsMatch(null);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(0.0, result.Score);
|
||||
Assert.Null(result.Exception);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(AzureADTokenVersion.V1)]
|
||||
[InlineData(AzureADTokenVersion.V2)]
|
||||
public void AzureADAuthenticationMatcher_IsMatch_ShouldReturnPerfect_WhenTokenIsValid(AzureADTokenVersion version)
|
||||
{
|
||||
// Arrange
|
||||
var token = GenerateValidToken(Tenant, Audience, version);
|
||||
|
||||
// Act
|
||||
var result = _sut.IsMatch($"Bearer {token}");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(1.0, result.Score);
|
||||
Assert.Null(result.Exception);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(AzureADTokenVersion.V1)]
|
||||
[InlineData(AzureADTokenVersion.V2)]
|
||||
public void AzureADAuthenticationMatcher_IsMatch_ShouldReturnMismatch_WhenTenantMismatch(AzureADTokenVersion version)
|
||||
{
|
||||
// Arrange
|
||||
var sut = new AzureADAuthenticationMatcher(new JwtSecurityTokenHandler(), _openIdConnectConfigurationManagerMock.Object, Tenant, Audience);
|
||||
var token = GenerateValidToken("different-tenant", Audience, version);
|
||||
|
||||
// Act
|
||||
var result = sut.IsMatch($"Bearer {token}");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(0.0, result.Score);
|
||||
Assert.NotNull(result.Exception);
|
||||
}
|
||||
|
||||
private static string GenerateValidToken(string tenant, string audience, AzureADTokenVersion version)
|
||||
{
|
||||
var securityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes($"test-signing-key-{Guid.NewGuid()}"));
|
||||
var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256);
|
||||
|
||||
var claims = new[]
|
||||
{
|
||||
new Claim(JwtRegisteredClaimNames.Sub, "test-user"),
|
||||
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
||||
new Claim("tid", tenant)
|
||||
};
|
||||
|
||||
var issuer = string.Format(IssuerUrlTemplates[version], tenant);
|
||||
|
||||
var token = new JwtSecurityToken(
|
||||
issuer: issuer,
|
||||
audience: audience,
|
||||
claims: claims,
|
||||
expires: DateTime.UtcNow.AddMinutes(30),
|
||||
signingCredentials: credentials);
|
||||
|
||||
return new JwtSecurityTokenHandler().WriteToken(token);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// Copyright © WireMock.Net
|
||||
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
namespace WireMock.Net.Tests.Authentication;
|
||||
|
||||
internal class MockJwtSecurityTokenHandler : JwtSecurityTokenHandler
|
||||
{
|
||||
public override ClaimsPrincipal ValidateToken(string token, TokenValidationParameters validationParameters, out SecurityToken validatedToken)
|
||||
{
|
||||
validatedToken = new JwtSecurityToken();
|
||||
var claims = new[] { new Claim(ClaimTypes.Name, "TestUser") };
|
||||
var identity = new ClaimsIdentity(claims, "TestAuthType");
|
||||
return new ClaimsPrincipal(identity);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user