diff --git a/examples/WireMock.Net.Console.Net452.Classic/MainApp.cs b/examples/WireMock.Net.Console.Net452.Classic/MainApp.cs
index 13046fd9..3ae54c13 100644
--- a/examples/WireMock.Net.Console.Net452.Classic/MainApp.cs
+++ b/examples/WireMock.Net.Console.Net452.Classic/MainApp.cs
@@ -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");
diff --git a/src/WireMock.Net/Authentication/AzureADAuthenticationMatcher.cs b/src/WireMock.Net/Authentication/AzureADAuthenticationMatcher.cs
index cc152478..4622052d 100644
--- a/src/WireMock.Net/Authentication/AzureADAuthenticationMatcher.cs
+++ b/src/WireMock.Net/Authentication/AzureADAuthenticationMatcher.cs
@@ -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;
///
/// 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
///
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 _configurationManager;
+ private readonly string _tenant;
private readonly string _audience;
- private readonly string _stsDiscoveryEndpoint;
- public AzureADAuthenticationMatcher(string tenant, string audience)
+ public AzureADAuthenticationMatcher(JwtSecurityTokenHandler jwtSecurityTokenHandler, IConfigurationManager 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(_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
\ No newline at end of file
diff --git a/src/WireMock.Net/Owin/WireMockMiddleware.cs b/src/WireMock.Net/Owin/WireMockMiddleware.cs
index a5445344..9788caf4 100644
--- a/src/WireMock.Net/Owin/WireMockMiddleware.cs
+++ b/src/WireMock.Net/Owin/WireMockMiddleware.cs
@@ -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? 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);
diff --git a/src/WireMock.Net/Server/WireMockServer.cs b/src/WireMock.Net/Server/WireMockServer.cs
index c99d6ab5..9da5a4be 100644
--- a/src/WireMock.Net/Server/WireMockServer.cs
+++ b/src/WireMock.Net/Server/WireMockServer.cs
@@ -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($"https://login.microsoftonline.com/{tenant}/.well-known/openid-configuration", new Microsoft.IdentityModel.Protocols.OpenIdConnect.OpenIdConnectConfigurationRetriever()),
+ tenant,
+ audience);
#endif
}
diff --git a/test/WireMock.Net.Tests/Authentication/AzureADAuthenticationMatcherTests.cs b/test/WireMock.Net.Tests/Authentication/AzureADAuthenticationMatcherTests.cs
new file mode 100644
index 00000000..d8b0c456
--- /dev/null
+++ b/test/WireMock.Net.Tests/Authentication/AzureADAuthenticationMatcherTests.cs
@@ -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 IssuerUrlTemplates = new()
+ {
+ { AzureADTokenVersion.V1, "https://sts.windows.net/{0}/" },
+ { AzureADTokenVersion.V2, "https://login.microsoftonline.com/{0}/v2.0" }
+ };
+ private readonly Mock> _openIdConnectConfigurationManagerMock = new();
+
+ private readonly AzureADAuthenticationMatcher _sut;
+
+ public AzureADAuthenticationMatcherTests()
+ {
+ var jwtSecurityTokenHandler = new MockJwtSecurityTokenHandler();
+ _openIdConnectConfigurationManagerMock.Setup(c => c.GetConfigurationAsync(It.IsAny())).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);
+ }
+}
\ No newline at end of file
diff --git a/test/WireMock.Net.Tests/Authentication/MockJwtSecurityTokenHandler.cs b/test/WireMock.Net.Tests/Authentication/MockJwtSecurityTokenHandler.cs
new file mode 100644
index 00000000..9cec9241
--- /dev/null
+++ b/test/WireMock.Net.Tests/Authentication/MockJwtSecurityTokenHandler.cs
@@ -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);
+ }
+}
\ No newline at end of file