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:
Stef Heyenrath
2025-05-06 18:49:21 +02:00
committed by GitHub
parent cfc13b2449
commit 249b3562ab
6 changed files with 227 additions and 20 deletions

View File

@@ -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");

View File

@@ -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

View File

@@ -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);

View File

@@ -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
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}