diff --git a/Directory.Build.props b/Directory.Build.props index 87ad1fedb..6753f582a 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -4,7 +4,7 @@ - 1.4.23 + 1.4.24 See CHANGELOG.md WireMock.Net-Logo.png https://github.com/WireMock-Net/WireMock.Net diff --git a/examples/WireMock.Net.Console.Net452.Classic/MainApp.cs b/examples/WireMock.Net.Console.Net452.Classic/MainApp.cs index 7b0e4679f..d1975f65a 100644 --- a/examples/WireMock.Net.Console.Net452.Classic/MainApp.cs +++ b/examples/WireMock.Net.Console.Net452.Classic/MainApp.cs @@ -77,9 +77,17 @@ public static void Run() 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.AllowPartialMapping(); + server.Given(Request.Create().WithPath("/mypath").UsingPost()) + .RespondWith(Response.Create() + .WithHeader("Content-Type", "application/json") + .WithBodyAsJson("{{JsonPath.SelectToken request.body \"..name\"}}") + .WithTransformer() + ); + server .Given(Request.Create().WithPath(p => p.Contains("x")).UsingGet()) .AtPriority(4) diff --git a/src/WireMock.Net.Abstractions/Server/IWireMockServer.cs b/src/WireMock.Net.Abstractions/Server/IWireMockServer.cs index b7f3a480e..e0e2db5f5 100644 --- a/src/WireMock.Net.Abstractions/Server/IWireMockServer.cs +++ b/src/WireMock.Net.Abstractions/Server/IWireMockServer.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Collections.Specialized; using JetBrains.Annotations; @@ -104,9 +104,9 @@ public interface IWireMockServer : IDisposable void ReadStaticMappings([CanBeNull] string folder = null); /// - /// Removes the basic authentication. + /// Removes the authentication. /// - void RemoveBasicAuthentication(); + void RemoveAuthentication(); /// /// Resets LogEntries and Mappings. @@ -134,6 +134,13 @@ public interface IWireMockServer : IDisposable /// The optional folder. If not defined, use {CurrentFolder}/__admin/mappings void SaveStaticMappings([CanBeNull] string folder = null); + /// + /// Sets the basic authentication. + /// + /// The Tenant. + /// The Audience or Resource. + void SetAzureADAuthentication([NotNull] string tenant, [NotNull] string audience); + /// /// Sets the basic authentication. /// diff --git a/src/WireMock.Net/Authentication/AzureADAuthenticationMatcher.cs b/src/WireMock.Net/Authentication/AzureADAuthenticationMatcher.cs new file mode 100644 index 000000000..db4eba46d --- /dev/null +++ b/src/WireMock.Net/Authentication/AzureADAuthenticationMatcher.cs @@ -0,0 +1,71 @@ +#if !NETSTANDARD1_3 +using System.Globalization; +using System.IdentityModel.Tokens.Jwt; +using System.Text.RegularExpressions; +using AnyOfTypes; +using Microsoft.IdentityModel.Protocols; +using Microsoft.IdentityModel.Protocols.OpenIdConnect; +using Microsoft.IdentityModel.Tokens; +using WireMock.Matchers; +using WireMock.Models; + +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 + /// + internal class AzureADAuthenticationMatcher : IStringMatcher + { + private const string BearerPrefix = "Bearer "; + + private readonly string _audience; + private readonly string _stsDiscoveryEndpoint; + + public AzureADAuthenticationMatcher(string tenant, string audience) + { + _audience = audience; + _stsDiscoveryEndpoint = string.Format(CultureInfo.InvariantCulture, "https://login.microsoftonline.com/{0}/.well-known/openid-configuration", tenant); + } + + public string Name => nameof(AzureADAuthenticationMatcher); + + public MatchBehaviour MatchBehaviour => MatchBehaviour.AcceptOnMatch; + + public bool ThrowException => false; + + public AnyOf[] GetPatterns() + { + return new AnyOf[0]; + } + + public double IsMatch(string input) + { + var token = Regex.Replace(input, BearerPrefix, string.Empty, RegexOptions.IgnoreCase); + + try + { + var configManager = new ConfigurationManager(_stsDiscoveryEndpoint, new OpenIdConnectConfigurationRetriever()); + var config = configManager.GetConfigurationAsync().GetAwaiter().GetResult(); + + var validationParameters = new TokenValidationParameters + { + ValidAudience = _audience, + ValidIssuer = config.Issuer, + IssuerSigningKeys = config.SigningKeys, + ValidateLifetime = true + }; + + // Throws an Exception as the token is invalid (expired, invalid-formatted, etc.) + new JwtSecurityTokenHandler().ValidateToken(token, validationParameters, out var _); + + return MatchScores.Perfect; + } + catch + { + return MatchScores.Mismatch; + } + } + } +} +#endif \ No newline at end of file diff --git a/src/WireMock.Net/Authentication/BasicAuthenticationMatcher.cs b/src/WireMock.Net/Authentication/BasicAuthenticationMatcher.cs new file mode 100644 index 000000000..93db02954 --- /dev/null +++ b/src/WireMock.Net/Authentication/BasicAuthenticationMatcher.cs @@ -0,0 +1,20 @@ +using System; +using System.Text; +using WireMock.Matchers; + +namespace WireMock.Authentication +{ + internal class BasicAuthenticationMatcher : RegexMatcher + { + public BasicAuthenticationMatcher(string username, string password) : base(BuildPattern(username, password)) + { + } + + public override string Name => nameof(BasicAuthenticationMatcher); + + private static string BuildPattern(string username, string password) + { + return "^(?i)BASIC " + Convert.ToBase64String(Encoding.GetEncoding("ISO-8859-1").GetBytes(username + ":" + password)) + "$"; + } + } +} \ No newline at end of file diff --git a/src/WireMock.Net/Owin/IWireMockMiddlewareOptions.cs b/src/WireMock.Net/Owin/IWireMockMiddlewareOptions.cs index 4586ae8e3..7a3166b81 100644 --- a/src/WireMock.Net/Owin/IWireMockMiddlewareOptions.cs +++ b/src/WireMock.Net/Owin/IWireMockMiddlewareOptions.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Concurrent; using WireMock.Handlers; using WireMock.Logging; @@ -19,7 +19,7 @@ internal interface IWireMockMiddlewareOptions TimeSpan? RequestProcessingDelay { get; set; } - IStringMatcher AuthorizationMatcher { get; set; } + IStringMatcher AuthenticationMatcher { get; set; } bool? AllowPartialMapping { get; set; } diff --git a/src/WireMock.Net/Owin/WireMockMiddleware.cs b/src/WireMock.Net/Owin/WireMockMiddleware.cs index 8a9d8c9ed..8e70eda91 100644 --- a/src/WireMock.Net/Owin/WireMockMiddleware.cs +++ b/src/WireMock.Net/Owin/WireMockMiddleware.cs @@ -113,10 +113,10 @@ private async Task InvokeInternal(IContext ctx) logRequest = targetMapping.LogMapping; - if (targetMapping.IsAdminInterface && _options.AuthorizationMatcher != null) + if (targetMapping.IsAdminInterface && _options.AuthenticationMatcher != null) { bool present = request.Headers.TryGetValue(HttpKnownHeaderNames.Authorization, out WireMockList authorization); - if (!present || _options.AuthorizationMatcher.IsMatch(authorization.ToString()) < MatchScores.Perfect) + if (!present || _options.AuthenticationMatcher.IsMatch(authorization.ToString()) < MatchScores.Perfect) { _options.Logger.Error("HttpStatusCode set to 401"); response = ResponseMessageBuilder.Create(null, 401); diff --git a/src/WireMock.Net/Owin/WireMockMiddlewareOptions.cs b/src/WireMock.Net/Owin/WireMockMiddlewareOptions.cs index 49c9aa92b..6e491d7b5 100644 --- a/src/WireMock.Net/Owin/WireMockMiddlewareOptions.cs +++ b/src/WireMock.Net/Owin/WireMockMiddlewareOptions.cs @@ -19,7 +19,7 @@ internal class WireMockMiddlewareOptions : IWireMockMiddlewareOptions public TimeSpan? RequestProcessingDelay { get; set; } - public IStringMatcher AuthorizationMatcher { get; set; } + public IStringMatcher AuthenticationMatcher { get; set; } public bool? AllowPartialMapping { get; set; } diff --git a/src/WireMock.Net/Server/WireMockServer.cs b/src/WireMock.Net/Server/WireMockServer.cs index b9c1daea0..07a0a3d24 100644 --- a/src/WireMock.Net/Server/WireMockServer.cs +++ b/src/WireMock.Net/Server/WireMockServer.cs @@ -9,6 +9,7 @@ using JetBrains.Annotations; using Newtonsoft.Json; using WireMock.Admin.Mappings; +using WireMock.Authentication; using WireMock.Exceptions; using WireMock.Handlers; using WireMock.Logging; @@ -302,6 +303,11 @@ protected WireMockServer(IWireMockServerSettings settings) SetBasicAuthentication(settings.AdminUsername, settings.AdminPassword); } + if (!string.IsNullOrEmpty(settings.AdminAzureADTenant) && !string.IsNullOrEmpty(settings.AdminAzureADAudience)) + { + SetAzureADAuthentication(settings.AdminAzureADTenant, settings.AdminAzureADAudience); + } + InitAdmin(); } @@ -404,22 +410,35 @@ public void AllowPartialMapping(bool allow = true) _options.AllowPartialMapping = allow; } - /// + /// + [PublicAPI] + public void SetAzureADAuthentication([NotNull] string tenant, [NotNull] string audience) + { + Check.NotNull(tenant, nameof(tenant)); + Check.NotNull(audience, nameof(audience)); + +#if NETSTANDARD1_3 + throw new NotSupportedException("AzureADAuthentication is not supported for NETStandard 1.3"); +#else + _options.AuthenticationMatcher = new AzureADAuthenticationMatcher(tenant, audience); +#endif + } + + /// [PublicAPI] public void SetBasicAuthentication([NotNull] string username, [NotNull] string password) { Check.NotNull(username, nameof(username)); Check.NotNull(password, nameof(password)); - string authorization = Convert.ToBase64String(Encoding.GetEncoding("ISO-8859-1").GetBytes(username + ":" + password)); - _options.AuthorizationMatcher = new RegexMatcher(MatchBehaviour.AcceptOnMatch, "^(?i)BASIC " + authorization + "$"); + _options.AuthenticationMatcher = new BasicAuthenticationMatcher(username, password); } - /// + /// [PublicAPI] - public void RemoveBasicAuthentication() + public void RemoveAuthentication() { - _options.AuthorizationMatcher = null; + _options.AuthenticationMatcher = null; } /// diff --git a/src/WireMock.Net/Settings/IWireMockServerSettings.cs b/src/WireMock.Net/Settings/IWireMockServerSettings.cs index 3de866432..ea3060ca8 100644 --- a/src/WireMock.Net/Settings/IWireMockServerSettings.cs +++ b/src/WireMock.Net/Settings/IWireMockServerSettings.cs @@ -1,4 +1,4 @@ -using System; +using System; using HandlebarsDotNet; using JetBrains.Annotations; using WireMock.Handlers; @@ -88,6 +88,18 @@ public interface IWireMockServerSettings [PublicAPI] string AdminPassword { get; set; } + /// + /// The AzureAD Tenant needed for __admin access. + /// + [PublicAPI] + string AdminAzureADTenant { get; set; } + + /// + /// The AzureAD Audience / Resource for __admin access. + /// + [PublicAPI] + string AdminAzureADAudience { get; set; } + /// /// The RequestLog expiration in hours (optional). /// diff --git a/src/WireMock.Net/Settings/WireMockServerSettings.cs b/src/WireMock.Net/Settings/WireMockServerSettings.cs index 04ac657cf..8f9630730 100644 --- a/src/WireMock.Net/Settings/WireMockServerSettings.cs +++ b/src/WireMock.Net/Settings/WireMockServerSettings.cs @@ -1,4 +1,4 @@ -using System; +using System; using HandlebarsDotNet; using JetBrains.Annotations; using Newtonsoft.Json; @@ -64,6 +64,14 @@ public class WireMockServerSettings : IWireMockServerSettings [PublicAPI] public string AdminPassword { get; set; } + /// + [PublicAPI] + public string AdminAzureADTenant { get; set; } + + /// + [PublicAPI] + public string AdminAzureADAudience { get; set; } + /// [PublicAPI] public int? RequestLogExpirationDuration { get; set; } diff --git a/src/WireMock.Net/Settings/WireMockServerSettingsParser.cs b/src/WireMock.Net/Settings/WireMockServerSettingsParser.cs index 67a0f0df1..147806c9e 100644 --- a/src/WireMock.Net/Settings/WireMockServerSettingsParser.cs +++ b/src/WireMock.Net/Settings/WireMockServerSettingsParser.cs @@ -1,4 +1,4 @@ -using JetBrains.Annotations; +using JetBrains.Annotations; using WireMock.Logging; using WireMock.Validation; @@ -39,6 +39,8 @@ public static bool TryParseArguments([NotNull] string[] args, out IWireMockServe WatchStaticMappingsInSubdirectories = parser.GetBoolValue("WatchStaticMappingsInSubdirectories"), AdminUsername = parser.GetStringValue("AdminUsername"), AdminPassword = parser.GetStringValue("AdminPassword"), + AdminAzureADTenant = parser.GetStringValue(nameof(IWireMockServerSettings.AdminAzureADTenant)), + AdminAzureADAudience = parser.GetStringValue(nameof(IWireMockServerSettings.AdminAzureADAudience)), MaxRequestLogCount = parser.GetIntValue("MaxRequestLogCount"), RequestLogExpirationDuration = parser.GetIntValue("RequestLogExpirationDuration"), AllowCSharpCodeMatcher = parser.GetBoolValue("AllowCSharpCodeMatcher"), diff --git a/src/WireMock.Net/WireMock.Net.csproj b/src/WireMock.Net/WireMock.Net.csproj index 6b8bf25c0..48c02ef27 100644 --- a/src/WireMock.Net/WireMock.Net.csproj +++ b/src/WireMock.Net/WireMock.Net.csproj @@ -1,4 +1,4 @@ - + Lightweight Http Mocking Server for .Net, inspired by WireMock from the Java landscape. WireMock.Net @@ -50,16 +50,17 @@ USE_ASPNETCORE;NET46 - - - - - - - - - - + + + + + + + + + + + @@ -68,9 +69,10 @@ - - - + + + + diff --git a/test/WireMock.Net.Tests/Owin/WireMockMiddlewareTests.cs b/test/WireMock.Net.Tests/Owin/WireMockMiddlewareTests.cs index 298664a2e..353b890ee 100644 --- a/test/WireMock.Net.Tests/Owin/WireMockMiddlewareTests.cs +++ b/test/WireMock.Net.Tests/Owin/WireMockMiddlewareTests.cs @@ -96,7 +96,7 @@ public async Task WireMockMiddleware_Invoke_IsAdminInterface_EmptyHeaders_401() var request = new RequestMessage(new UrlDetails("http://localhost/foo"), "GET", "::1", null, new Dictionary()); _requestMapperMock.Setup(m => m.MapAsync(It.IsAny(), It.IsAny())).ReturnsAsync(request); - _optionsMock.SetupGet(o => o.AuthorizationMatcher).Returns(new ExactMatcher()); + _optionsMock.SetupGet(o => o.AuthenticationMatcher).Returns(new ExactMatcher()); _mappingMock.SetupGet(m => m.IsAdminInterface).Returns(true); var result = new MappingMatcherResult { Mapping = _mappingMock.Object }; @@ -119,7 +119,7 @@ public async Task WireMockMiddleware_Invoke_IsAdminInterface_MissingHeader_401() var request = new RequestMessage(new UrlDetails("http://localhost/foo"), "GET", "::1", null, new Dictionary { { "h", new[] { "x" } } }); _requestMapperMock.Setup(m => m.MapAsync(It.IsAny(), It.IsAny())).ReturnsAsync(request); - _optionsMock.SetupGet(o => o.AuthorizationMatcher).Returns(new ExactMatcher()); + _optionsMock.SetupGet(o => o.AuthenticationMatcher).Returns(new ExactMatcher()); _mappingMock.SetupGet(m => m.IsAdminInterface).Returns(true); var result = new MappingMatcherResult { Mapping = _mappingMock.Object }; @@ -152,7 +152,7 @@ public async Task WireMockMiddleware_Invoke_Mapping_Has_ProxyAndRecordSettings_A var request = new RequestMessage(new UrlDetails("http://localhost/foo"), "GET", "::1", null, new Dictionary()); _requestMapperMock.Setup(m => m.MapAsync(It.IsAny(), It.IsAny())).ReturnsAsync(request); - _optionsMock.SetupGet(o => o.AuthorizationMatcher).Returns(new ExactMatcher()); + _optionsMock.SetupGet(o => o.AuthenticationMatcher).Returns(new ExactMatcher()); var fileSystemHandlerMock = new Mock(); fileSystemHandlerMock.Setup(f => f.GetMappingFolder()).Returns("m"); @@ -201,7 +201,7 @@ public async Task WireMockMiddleware_Invoke_Mapping_Has_ProxyAndRecordSettings_A var request = new RequestMessage(new UrlDetails("http://localhost/foo"), "GET", "::1", null, new Dictionary()); _requestMapperMock.Setup(m => m.MapAsync(It.IsAny(), It.IsAny())).ReturnsAsync(request); - _optionsMock.SetupGet(o => o.AuthorizationMatcher).Returns(new ExactMatcher()); + _optionsMock.SetupGet(o => o.AuthenticationMatcher).Returns(new ExactMatcher()); var fileSystemHandlerMock = new Mock(); fileSystemHandlerMock.Setup(f => f.GetMappingFolder()).Returns("m"); diff --git a/test/WireMock.Net.Tests/WireMockServer.Authentication.cs b/test/WireMock.Net.Tests/WireMockServer.Authentication.cs index 2dc023f1f..9dc90007e 100644 --- a/test/WireMock.Net.Tests/WireMockServer.Authentication.cs +++ b/test/WireMock.Net.Tests/WireMockServer.Authentication.cs @@ -1,3 +1,4 @@ +using FluentAssertions; using NFluent; using WireMock.Matchers; using WireMock.Owin; @@ -19,26 +20,43 @@ public void WireMockServer_Authentication_SetBasicAuthentication() // Assert var options = server.GetPrivateFieldValue("_options"); - Check.That(options.AuthorizationMatcher.Name).IsEqualTo("RegexMatcher"); - Check.That(options.AuthorizationMatcher.MatchBehaviour).IsEqualTo(MatchBehaviour.AcceptOnMatch); - Check.That(options.AuthorizationMatcher.GetPatterns()).ContainsExactly("^(?i)BASIC eDp5$"); + Check.That(options.AuthenticationMatcher.Name).IsEqualTo("BasicAuthenticationMatcher"); + Check.That(options.AuthenticationMatcher.MatchBehaviour).IsEqualTo(MatchBehaviour.AcceptOnMatch); + Check.That(options.AuthenticationMatcher.GetPatterns()).ContainsExactly("^(?i)BASIC eDp5$"); server.Stop(); } [Fact] - public void WireMockServer_Authentication_RemoveBasicAuthentication() + public void WireMockServer_Authentication_SetSetAzureADAuthentication() + { + // Assign + var server = WireMockServer.Start(); + + // Act + server.SetAzureADAuthentication("x", "y"); + + // Assert + var options = server.GetPrivateFieldValue("_options"); + options.AuthenticationMatcher.Name.Should().Be("AzureADAuthenticationMatcher"); + options.AuthenticationMatcher.MatchBehaviour.Should().Be(MatchBehaviour.AcceptOnMatch); + + server.Stop(); + } + + [Fact] + public void WireMockServer_Authentication_RemoveAuthentication() { // Assign var server = WireMockServer.Start(); server.SetBasicAuthentication("x", "y"); // Act - server.RemoveBasicAuthentication(); + server.RemoveAuthentication(); // Assert var options = server.GetPrivateFieldValue("_options"); - Check.That(options.AuthorizationMatcher).IsNull(); + Check.That(options.AuthenticationMatcher).IsNull(); server.Stop(); } diff --git a/test/WireMock.Net.Tests/WireMockServer.Settings.cs b/test/WireMock.Net.Tests/WireMockServer.Settings.cs index f14c06f52..c5b9573fa 100644 --- a/test/WireMock.Net.Tests/WireMockServer.Settings.cs +++ b/test/WireMock.Net.Tests/WireMockServer.Settings.cs @@ -1,6 +1,8 @@ +using System.Linq; +using FluentAssertions; using Moq; using NFluent; -using System.Linq; +using WireMock.Authentication; using WireMock.Logging; using WireMock.Owin; using WireMock.Server; @@ -32,7 +34,23 @@ public void WireMockServer_WireMockServerSettings_StartAdminInterfaceTrue_BasicA // Assert var options = server.GetPrivateFieldValue("_options"); - Check.That(options.AuthorizationMatcher).IsNotNull(); + options.AuthenticationMatcher.Should().NotBeNull().And.BeOfType(); + } + + [Fact] + public void WireMockServer_WireMockServerSettings_StartAdminInterfaceTrue_AzureADAuthenticationIsSet() + { + // Assign and Act + var server = WireMockServer.Start(new WireMockServerSettings + { + StartAdminInterface = true, + AdminAzureADTenant = "t", + AdminAzureADAudience = "a" + }); + + // Assert + var options = server.GetPrivateFieldValue("_options"); + options.AuthenticationMatcher.Should().NotBeNull().And.BeOfType(); } [Fact] @@ -48,7 +66,7 @@ public void WireMockServer_WireMockServerSettings_StartAdminInterfaceFalse_Basic // Assert var options = server.GetPrivateFieldValue("_options"); - Check.That(options.AuthorizationMatcher).IsNull(); + Check.That(options.AuthenticationMatcher).IsNull(); } [Fact]