mirror of
https://github.com/wiremock/WireMock.Net.git
synced 2026-04-17 14:40:00 +02: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()
|
public static void Run()
|
||||||
{
|
{
|
||||||
RunSse();
|
//RunSse();
|
||||||
RunOnLocal();
|
//RunOnLocal();
|
||||||
|
|
||||||
var mappingBuilder = new MappingBuilder();
|
var mappingBuilder = new MappingBuilder();
|
||||||
mappingBuilder
|
mappingBuilder
|
||||||
@@ -268,8 +268,8 @@ message HelloReply {
|
|||||||
});
|
});
|
||||||
System.Console.WriteLine("WireMockServer listening at {0}", string.Join(",", server.Urls));
|
System.Console.WriteLine("WireMockServer listening at {0}", string.Join(",", server.Urls));
|
||||||
|
|
||||||
server.SetBasicAuthentication("a", "b");
|
//server.SetBasicAuthentication("a", "b");
|
||||||
//server.SetAzureADAuthentication("6c2a4722-f3b9-4970-b8fc-fac41e29stef", "8587fde1-7824-42c7-8592-faf92b04stef");
|
server.SetAzureADAuthentication(Environment.GetEnvironmentVariable("WIREMOCK_AAD_TENANT")!, "api://e083d51a-01a6-446c-8ad5-0c5c7f002208");
|
||||||
|
|
||||||
//var http = new HttpClient();
|
//var http = new HttpClient();
|
||||||
//var response = await http.GetAsync($"{_wireMockServer.Url}/pricing");
|
//var response = await http.GetAsync($"{_wireMockServer.Url}/pricing");
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
#if !NETSTANDARD1_3
|
#if !NETSTANDARD1_3
|
||||||
using System;
|
using System;
|
||||||
using System.Globalization;
|
using System.Diagnostics.CodeAnalysis;
|
||||||
using System.IdentityModel.Tokens.Jwt;
|
using System.IdentityModel.Tokens.Jwt;
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
using AnyOfTypes;
|
using AnyOfTypes;
|
||||||
@@ -19,18 +19,24 @@ namespace WireMock.Authentication;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// https://www.c-sharpcorner.com/article/how-to-validate-azure-ad-token-using-console-application/
|
/// 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://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>
|
/// </summary>
|
||||||
internal class AzureADAuthenticationMatcher : IStringMatcher
|
internal class AzureADAuthenticationMatcher : IStringMatcher
|
||||||
{
|
{
|
||||||
private const string BearerPrefix = "Bearer ";
|
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 _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);
|
_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);
|
public string Name => nameof(AzureADAuthenticationMatcher);
|
||||||
@@ -55,19 +61,27 @@ internal class AzureADAuthenticationMatcher : IStringMatcher
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var configManager = new ConfigurationManager<OpenIdConnectConfiguration>(_stsDiscoveryEndpoint, new OpenIdConnectConfigurationRetriever());
|
var config = _configurationManager.GetConfigurationAsync(default).ConfigureAwait(false).GetAwaiter().GetResult();
|
||||||
var config = configManager.GetConfigurationAsync().ConfigureAwait(false).GetAwaiter().GetResult();
|
|
||||||
|
|
||||||
var validationParameters = new TokenValidationParameters
|
var validationParameters = new TokenValidationParameters
|
||||||
{
|
{
|
||||||
ValidAudience = _audience,
|
ValidAudience = _audience,
|
||||||
ValidIssuer = config.Issuer,
|
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,
|
IssuerSigningKeys = config.SigningKeys,
|
||||||
ValidateLifetime = true
|
ValidateLifetime = true
|
||||||
};
|
};
|
||||||
|
|
||||||
// Throws an Exception as the token is invalid (expired, invalid-formatted, etc.)
|
// Throws an Exception as the token is invalid (expired, invalid-formatted, tenant mismatch, etc.)
|
||||||
new JwtSecurityTokenHandler().ValidateToken(token, validationParameters, out var _);
|
_jwtSecurityTokenHandler.ValidateToken(token, validationParameters, out _);
|
||||||
|
|
||||||
return MatchScores.Perfect;
|
return MatchScores.Perfect;
|
||||||
}
|
}
|
||||||
@@ -82,5 +96,20 @@ internal class AzureADAuthenticationMatcher : IStringMatcher
|
|||||||
{
|
{
|
||||||
throw new NotImplementedException();
|
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
|
#endif
|
||||||
@@ -10,11 +10,11 @@ using WireMock.Matchers;
|
|||||||
using WireMock.Http;
|
using WireMock.Http;
|
||||||
using WireMock.Owin.Mappers;
|
using WireMock.Owin.Mappers;
|
||||||
using WireMock.Serialization;
|
using WireMock.Serialization;
|
||||||
using WireMock.Types;
|
|
||||||
using WireMock.ResponseBuilders;
|
using WireMock.ResponseBuilders;
|
||||||
using WireMock.Settings;
|
using WireMock.Settings;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using WireMock.Constants;
|
using WireMock.Constants;
|
||||||
|
using WireMock.Exceptions;
|
||||||
using WireMock.Util;
|
using WireMock.Util;
|
||||||
#if !USE_ASPNETCORE
|
#if !USE_ASPNETCORE
|
||||||
using IContext = Microsoft.Owin.IOwinContext;
|
using IContext = Microsoft.Owin.IOwinContext;
|
||||||
@@ -126,7 +126,7 @@ namespace WireMock.Owin
|
|||||||
if (targetMapping == null)
|
if (targetMapping == null)
|
||||||
{
|
{
|
||||||
logRequest = true;
|
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);
|
response = ResponseMessageBuilder.Create(HttpStatusCode.NotFound, WireMockConstants.NoMatchingFound);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -135,10 +135,18 @@ namespace WireMock.Owin
|
|||||||
|
|
||||||
if (targetMapping.IsAdminInterface && _options.AuthenticationMatcher != null && request.Headers != null)
|
if (targetMapping.IsAdminInterface && _options.AuthenticationMatcher != null && request.Headers != null)
|
||||||
{
|
{
|
||||||
bool present = request.Headers.TryGetValue(HttpKnownHeaderNames.Authorization, out WireMockList<string>? authorization);
|
var authorizationHeaderPresent = request.Headers.TryGetValue(HttpKnownHeaderNames.Authorization, out var authorization);
|
||||||
if (!present || _options.AuthenticationMatcher.IsMatch(authorization!.ToString()).Score < MatchScores.Perfect)
|
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);
|
response = ResponseMessageBuilder.Create(HttpStatusCode.Unauthorized, null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -156,12 +164,12 @@ namespace WireMock.Owin
|
|||||||
|
|
||||||
if (!targetMapping.IsAdminInterface && theOptionalNewMapping != null)
|
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);
|
_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 matcherMapper = new MatcherMapper(targetMapping.Settings);
|
||||||
var mappingConverter = new MappingConverter(matcherMapper);
|
var mappingConverter = new MappingConverter(matcherMapper);
|
||||||
|
|||||||
@@ -514,7 +514,11 @@ public partial class WireMockServer : IWireMockServer
|
|||||||
#if NETSTANDARD1_3
|
#if NETSTANDARD1_3
|
||||||
throw new NotSupportedException("AzureADAuthentication is not supported for NETStandard 1.3");
|
throw new NotSupportedException("AzureADAuthentication is not supported for NETStandard 1.3");
|
||||||
#else
|
#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
|
#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