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