diff --git a/C3D.Extensions.Playwright.AspNetCore.sln b/C3D.Extensions.Playwright.AspNetCore.sln
index 49a5aa1..a326e23 100644
--- a/C3D.Extensions.Playwright.AspNetCore.sln
+++ b/C3D.Extensions.Playwright.AspNetCore.sln
@@ -62,6 +62,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "github", "github", "{AC8C5C
.github\workflows\dotnet.yml = .github\workflows\dotnet.yml
EndProjectSection
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "C3D.Extensions.Playwright.AspNetCore.Authentication", "src\C3D\Extensions\Playwright\AspNetCore.Authentication\C3D.Extensions.Playwright.AspNetCore.Authentication.csproj", "{A865812B-765F-4B0A-89DB-E5F5FBDDD920}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -84,6 +86,10 @@ Global
{DBF13B24-28DF-4B97-8040-2832108C0209}.Debug|Any CPU.Build.0 = Debug|Any CPU
{DBF13B24-28DF-4B97-8040-2832108C0209}.Release|Any CPU.ActiveCfg = Release|Any CPU
{DBF13B24-28DF-4B97-8040-2832108C0209}.Release|Any CPU.Build.0 = Release|Any CPU
+ {A865812B-765F-4B0A-89DB-E5F5FBDDD920}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {A865812B-765F-4B0A-89DB-E5F5FBDDD920}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {A865812B-765F-4B0A-89DB-E5F5FBDDD920}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {A865812B-765F-4B0A-89DB-E5F5FBDDD920}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -95,6 +101,7 @@ Global
{C9D19B9D-B61C-435A-8E6C-B32A56A76F27} = {87AD0A87-358B-4C6B-832B-04269C7D9AB0}
{DBF13B24-28DF-4B97-8040-2832108C0209} = {7257F2A8-EE70-4224-9D5D-1EE29EAA0338}
{AC8C5C5D-E914-4919-AB9F-02BA2FE67EC5} = {DA726315-C6FB-4BCB-A766-4BD32A9643A2}
+ {A865812B-765F-4B0A-89DB-E5F5FBDDD920} = {79DBA4F1-9703-4A06-A219-C0E03D99633F}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {F8A03877-9554-4F94-B4B5-0513AAB4A1B8}
diff --git a/README.md b/README.md
index d2c352e..ce2dfe6 100644
--- a/README.md
+++ b/README.md
@@ -11,6 +11,13 @@ A set of Playwright related packages designed to help unit testing of AspNetCore
An extension to `Microsoft.AspNetCore.Mvc.Testing` which adds `Microsoft.Playwright` support to the `WebApplicationFactory` (and keeps the existing HttpClient infrastucture).
+## C3D.Extensions.Playwright.AspNetCore.Authentication
+[](https://nuget.org/packages/C3D.Extensions.Playwright.AspNetCore.Authentication)
+[](https://nuget.org/packages/C3D.Extensions.Playwright.AspNetCore.Authentication)
+
+Adds basic authentication support to `C3D.Extensions.Playwright.AspNetCore` to allow easy unit testing of secure AspNetCore web applications.
+
+
## C3D.Extensions.Playwright.AspNetCore.Xunit
[](https://nuget.org/packages/C3D.Extensions.Playwright.AspNetCore.Xunit)
[](https://nuget.org/packages/C3D.Extensions.Playwright.AspNetCore.Xunit)
diff --git a/samples/Sample.WebApp/Pages/Admin.cshtml b/samples/Sample.WebApp/Pages/Admin.cshtml
new file mode 100644
index 0000000..0ad9101
--- /dev/null
+++ b/samples/Sample.WebApp/Pages/Admin.cshtml
@@ -0,0 +1,8 @@
+@page
+@model Sample.WebApp.Pages.AdminModel
+@{
+ ViewData["Title"] = "Administration";
+}
+
@ViewData["Title"]
+
+This page is protected by authentication.
diff --git a/samples/Sample.WebApp/Pages/Admin.cshtml.cs b/samples/Sample.WebApp/Pages/Admin.cshtml.cs
new file mode 100644
index 0000000..2fde8a8
--- /dev/null
+++ b/samples/Sample.WebApp/Pages/Admin.cshtml.cs
@@ -0,0 +1,12 @@
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+
+namespace Sample.WebApp.Pages;
+
+[Authorize(Security.Policy.AdminPolicy)]
+public class AdminModel : PageModel
+{
+ public void OnGet()
+ {
+ }
+}
diff --git a/samples/Sample.WebApp/Pages/Shared/_Layout.cshtml b/samples/Sample.WebApp/Pages/Shared/_Layout.cshtml
index 614f8d0..2a2f14c 100644
--- a/samples/Sample.WebApp/Pages/Shared/_Layout.cshtml
+++ b/samples/Sample.WebApp/Pages/Shared/_Layout.cshtml
@@ -21,6 +21,9 @@
Home
+
+ Administration
+
Privacy
diff --git a/samples/Sample.WebApp/Program.cs b/samples/Sample.WebApp/Program.cs
index 3ecbd4a..e421af0 100644
--- a/samples/Sample.WebApp/Program.cs
+++ b/samples/Sample.WebApp/Program.cs
@@ -12,6 +12,13 @@ public static void Main(string[] args)
// Add services to the container.
builder.Services.AddRazorPages();
+ builder.Services.AddAuthorization(config =>
+ {
+ config.AddPolicy(Security.Policy.AdminPolicy, policy => { policy.RequireRole(Security.Role.Admin); });
+ });
+
+ builder.Services.AddSingleton(builder.Configuration);
+
builder.Services.AddHttpLogging(_ => { }); // Required by app.UseHttpLogging for Net 8.0
var app = builder.Build();
@@ -40,10 +47,9 @@ public static void Main(string[] args)
app.UseRouting();
+ app.UseAuthentication();
app.UseAuthorization();
-
-
app.MapRazorPages();
app.MapGet("/BadRequest", ctx => throw new BadHttpRequestException("Bad Request"));
diff --git a/samples/Sample.WebApp/Security.cs b/samples/Sample.WebApp/Security.cs
new file mode 100644
index 0000000..0135270
--- /dev/null
+++ b/samples/Sample.WebApp/Security.cs
@@ -0,0 +1,18 @@
+namespace Sample.WebApp;
+
+public static class Security
+{
+
+ public static class Role
+ {
+ public const string Admin = "Admin";
+ }
+
+ public static class Policy
+ {
+
+ public const string AdminPolicy = "AdminPolicy";
+
+ }
+
+}
\ No newline at end of file
diff --git a/src/C3D/Extensions/Playwright/AspNetCore.Authentication/C3D.Extensions.Playwright.AspNetCore.Authentication.csproj b/src/C3D/Extensions/Playwright/AspNetCore.Authentication/C3D.Extensions.Playwright.AspNetCore.Authentication.csproj
new file mode 100644
index 0000000..b786d71
--- /dev/null
+++ b/src/C3D/Extensions/Playwright/AspNetCore.Authentication/C3D.Extensions.Playwright.AspNetCore.Authentication.csproj
@@ -0,0 +1,33 @@
+
+
+
+ net6.0;net7.0;net8.0
+ enable
+ enable
+ $(AssemblyTitle) Authentication
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/C3D/Extensions/Playwright/AspNetCore.Authentication/CredentialValidationExtensions.cs b/src/C3D/Extensions/Playwright/AspNetCore.Authentication/CredentialValidationExtensions.cs
new file mode 100644
index 0000000..e7687bb
--- /dev/null
+++ b/src/C3D/Extensions/Playwright/AspNetCore.Authentication/CredentialValidationExtensions.cs
@@ -0,0 +1,12 @@
+using System.Security.Claims;
+
+namespace Microsoft.AspNetCore.Authentication;
+
+public static class CredentialValidationExtensions {
+ public static Claim DefaultRoleClaim(this ResultContext context, string roleName)
+ where TOptions : AuthenticationSchemeOptions
+ => new(ClaimTypes.Role,
+ roleName,
+ ClaimValueTypes.String,
+ context.Options.ClaimsIssuer);
+}
\ No newline at end of file
diff --git a/src/C3D/Extensions/Playwright/AspNetCore.Authentication/Handlers/BasicAuthHandler.cs b/src/C3D/Extensions/Playwright/AspNetCore.Authentication/Handlers/BasicAuthHandler.cs
new file mode 100644
index 0000000..70429ec
--- /dev/null
+++ b/src/C3D/Extensions/Playwright/AspNetCore.Authentication/Handlers/BasicAuthHandler.cs
@@ -0,0 +1,19 @@
+namespace C3D.Extensions.Playwright.AspNetCore.Authentication.Handlers;
+
+public class BasicAuthHandler : DelegatingHandler
+{
+ private readonly string? username;
+ private readonly string? password;
+
+ public BasicAuthHandler(string? username, string? password)
+ {
+ this.username = username;
+ this.password = password;
+ }
+
+ protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
+ {
+ request.Headers.Authorization = BasicAuthHeaderUtilities.BasicAuthHeader(username, password);
+ return base.SendAsync(request, cancellationToken);
+ }
+}
\ No newline at end of file
diff --git a/src/C3D/Extensions/Playwright/AspNetCore.Authentication/HostBuilderBasicAuthenticationExtensions.cs b/src/C3D/Extensions/Playwright/AspNetCore.Authentication/HostBuilderBasicAuthenticationExtensions.cs
new file mode 100644
index 0000000..4b2ac9e
--- /dev/null
+++ b/src/C3D/Extensions/Playwright/AspNetCore.Authentication/HostBuilderBasicAuthenticationExtensions.cs
@@ -0,0 +1,24 @@
+using idunno.Authentication.Basic;
+using Microsoft.Extensions.DependencyInjection;
+using System.Security.Claims;
+
+namespace Microsoft.Extensions.Hosting;
+
+public static class HostBuilderBasicAuthenticationExtensions
+{
+ ///
+ /// Registers a basic authentication scheme that succeeds for password==username and assigns the role of the username
+ ///
+ public static IHostBuilder AddBasicAuthentication(this IHostBuilder builder,
+ Func?>>? roleClaimsFunc = null) =>
+ builder.ConfigureServices(services => services.AddBasicAuthentication(roleClaimsFunc));
+
+ ///
+ /// Uses a registered RoleManager from Microsoft.AspNetCore.Identity to lookup the role and add any role specific claims.
+ ///
+ /// Class used for the Role
+ /// The main service collection
+ ///
+ public static IHostBuilder AddBasicAuthentication(this IHostBuilder builder)
+ where TRole : class => builder.ConfigureServices(services => services.AddBasicAuthentication());
+}
diff --git a/src/C3D/Extensions/Playwright/AspNetCore.Authentication/Options/WebApplicationFactoryAuthenticatedClientOptions.cs b/src/C3D/Extensions/Playwright/AspNetCore.Authentication/Options/WebApplicationFactoryAuthenticatedClientOptions.cs
new file mode 100644
index 0000000..4bf57ce
--- /dev/null
+++ b/src/C3D/Extensions/Playwright/AspNetCore.Authentication/Options/WebApplicationFactoryAuthenticatedClientOptions.cs
@@ -0,0 +1,54 @@
+using C3D.Extensions.Playwright.AspNetCore.Authentication.Handlers;
+using Microsoft.AspNetCore.Mvc.Testing;
+using Microsoft.AspNetCore.Mvc.Testing.Handlers;
+
+namespace C3D.Extensions.Playwright.AspNetCore.Authentication.Options;
+
+public class WebApplicationFactoryAuthenticatedClientOptions : WebApplicationFactoryClientOptions
+{
+ public WebApplicationFactoryAuthenticatedClientOptions()
+ {
+ }
+
+ // Copy constructor
+ internal WebApplicationFactoryAuthenticatedClientOptions(WebApplicationFactoryClientOptions clientOptions)
+ {
+ BaseAddress = clientOptions.BaseAddress;
+ AllowAutoRedirect = clientOptions.AllowAutoRedirect;
+ MaxAutomaticRedirections = clientOptions.MaxAutomaticRedirections;
+ HandleCookies = clientOptions.HandleCookies;
+
+ if (clientOptions is WebApplicationFactoryAuthenticatedClientOptions authOptions)
+ {
+ UserName = authOptions.UserName;
+ Password = authOptions.Password;
+ Handlers = authOptions.Handlers;
+ }
+ }
+
+ public string? UserName { get; set; }
+ public string? Password { get; set; }
+
+ public IEnumerable Handlers { get; set; } = Enumerable.Empty();
+
+ internal protected virtual DelegatingHandler[] CreateHandlers()
+ {
+ return CreateHandlersCore().Concat(Handlers).ToArray();
+
+ IEnumerable CreateHandlersCore()
+ {
+ if (!string.IsNullOrEmpty(UserName) || !string.IsNullOrEmpty(Password))
+ {
+ yield return new BasicAuthHandler(UserName, Password);
+ }
+ if (AllowAutoRedirect)
+ {
+ yield return new RedirectHandler(MaxAutomaticRedirections);
+ }
+ if (HandleCookies)
+ {
+ yield return new CookieContainerHandler();
+ }
+ }
+ }
+}
diff --git a/src/C3D/Extensions/Playwright/AspNetCore.Authentication/README.md b/src/C3D/Extensions/Playwright/AspNetCore.Authentication/README.md
new file mode 100644
index 0000000..67064d5
--- /dev/null
+++ b/src/C3D/Extensions/Playwright/AspNetCore.Authentication/README.md
@@ -0,0 +1,92 @@
+# C3D.Extensions.Playwright.AspNetCore.Authentication
+
+An extension to `Microsoft.AspNetCore.Mvc.Testing` and `C3D.Extensions.Playwright.AspNetCore` which adds authentication support to the `WebApplicationFactory`.
+
+This allows you to write Playwright browser based tests that use and test authentication.
+
+The authentication uses the [`idunno.Authentication.Basic`](https://github.com/blowdart/idunno.Authentication) package to provide 'Basic Authentication'.
+This should not (normally) be used in a production environement, but provides an easy to use mechansim to generate authentication tokens on the server side,
+and matching credentials on the client side.
+
+## Setup
+
+When creating a 'test' host using `IHostBuilder`, you can use the `AddBasicAuthentication` extension method to enable the embedded `idunno.Authentication.Basic` authentication system.
+```cs
+ builder.AddBasicAuthentication();
+```
+
+This will generate a claims user when the username == the password.
+The claims will include the username, displayname and role (which will all be the same).
+
+You can add an optional function to add additional claims as a parameter to the `AddBasicAuthentication` call.
+The function takes `ValidateCredentialsContext` and string parameters representing the context and the username/role (which are equal).
+It is an async function that returns `Task?>`. This allows you to not return any additional claims.
+
+There is an overload that takes the `TRole` type of the registered RoleManager from Microsoft.AspNetCore.Identity to lookup the role and add any role specific claims.
+This can be called as
+
+```cs
+ builder.AddBasicAuthentication();
+```
+
+Obviously this is not secure in any way, and should only be used in a test scenario, e.g. during Playwright testing.
+
+## Usage
+
+When you have a host that is setup to support BasicAuthention, you can then create a Playwright browser context (effectively an in-private isolated session), which will include the appropriate authentication header.
+There is an extension method to the `PlaywrightFixture` called `CreateAuthorisedPlaywrightContextPageAsync` which takes the rolename to use.
+This creates a new context and page (which should be disposed at the end of the test), with a Basic Authentication header with the username and password equal to the passed in role.
+
+
+### Sample
+
+An example of using this with `XUnit` is available in the github repository.
+
+```cs
+public class PlaywrightAuthenticationFixture : PlaywrightFixture
+{
+ public PlaywrightAuthenticationFixture(IMessageSink output) : base(output) { }
+
+ protected override IHost CreateHost(IHostBuilder builder)
+ {
+ builder.AddBasicAuthentication();
+ return base.CreateHost(builder);
+ }
+}
+```
+
+```cs
+public class AuthenticationTests : IClassFixture
+{
+ private readonly PlaywrightFixture webApplication;
+
+ public AuthenticationTests(PlaywrightAuthenticationFixture webApplication, ITestOutputHelper outputHelper)
+ {
+ this.webApplication = webApplication;
+ }
+
+ [Fact]
+ public async Task RandomTest()
+ {
+ await using var context = await webApplication.CreateAuthorisedPlaywrightContextPageAsync("SomeRole");
+ var page = context.Page;
+
+ await page.GotoAsync("/Somewhere");
+ }
+}
+```
+
+## HttpClient
+
+While this package is primarliy designed for use with Playwright, you may also require to use the `HttpClient` features of `Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactory`.
+
+This package provides a number of overloads of the basic `CreateClient` method on `WebApplicationFactory`.
+The first takes a function to configure a `WebApplicationFactoryAuthenticatedClientOptions` which is an augmented version of `WebApplicationFactoryClientOptions` and defaults to (a copy of) the settings from `WebApplicationFactory.ClientOptions`.
+The additional properties `UserName`, `Password` and `Handlers` are available. Setting either (or both) of the authentication properties results in an `AuthenticationHeaderValue` being added to each request made.
+
+`Handlers` allows you to add additional middleware handlers into the configuration.
+This allows you to use the Authentication, Redirection, and Cookie handlers at the same time as custom ones without having to manually add them all.
+
+There are 2 additional overloads of `CreateClient`, one which takes `username` and `password` as parameters, and another which takes a single string `role` which is used as both `username` and `password`.
+These are syntactic sugar over the configuration method mentioned previously.
+
diff --git a/src/C3D/Extensions/Playwright/AspNetCore.Authentication/ServiceCollectionBasicAuthenticationExtensions.cs b/src/C3D/Extensions/Playwright/AspNetCore.Authentication/ServiceCollectionBasicAuthenticationExtensions.cs
new file mode 100644
index 0000000..2470c71
--- /dev/null
+++ b/src/C3D/Extensions/Playwright/AspNetCore.Authentication/ServiceCollectionBasicAuthenticationExtensions.cs
@@ -0,0 +1,89 @@
+using idunno.Authentication.Basic;
+using Microsoft.AspNetCore.Authentication;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.Extensions.Logging;
+using System.Security.Claims;
+
+namespace Microsoft.Extensions.DependencyInjection;
+
+public static class ServiceCollectionBasicAuthenticationExtensions
+{
+ ///
+ /// Registers a basic authentication scheme that succeeds for password==username and assigns the role of the username
+ ///
+ public static IServiceCollection AddBasicAuthentication(this IServiceCollection services,
+ Func?>>? roleClaimsFunc = null)
+ => services
+ .AddAuthentication(BasicAuthenticationDefaults.AuthenticationScheme)
+ .AddBasic(options =>
+ {
+ options.Realm = "Test Realm";
+ options.AllowInsecureProtocol = true;
+ options.Events = new BasicAuthenticationEvents
+ {
+ OnAuthenticationFailed = context =>
+ {
+ var loggerFactory = context.HttpContext.RequestServices.GetRequiredService();
+ var logger = loggerFactory.CreateLogger();
+ logger.LogError(context.Exception, "Authentication failed");
+ return Task.CompletedTask;
+ },
+ OnValidateCredentials = async context =>
+ {
+ if (context.Username == context.Password)
+ {
+
+ var userClaims = new[]
+ {
+ // Set UserName
+ new Claim(
+ ClaimTypes.NameIdentifier,
+ context.Username,
+ ClaimValueTypes.String,
+ context.Options.ClaimsIssuer),
+ // Set DisplayName
+ new Claim(
+ ClaimTypes.Name,
+ context.Username,
+ ClaimValueTypes.String,
+ context.Options.ClaimsIssuer)
+ };
+
+
+ var roleClaims = roleClaimsFunc is null ?
+ Enumerable.Repeat(context.DefaultRoleClaim(context.Username), 1) :
+ (await roleClaimsFunc.Invoke(context, context.Username) ?? Enumerable.Empty());
+
+ context.Principal = new ClaimsPrincipal(
+ new ClaimsIdentity(userClaims.Concat(roleClaims), context.Scheme.Name));
+ context.Success();
+ }
+ }
+ };
+ })
+ .Services;
+
+ ///
+ /// Uses a registered RoleManager from Microsoft.AspNetCore.Identity to lookup the role and add any role specific claims.
+ ///
+ /// Class used for the Role
+ /// The main service collection
+ ///
+ public static IServiceCollection AddBasicAuthentication(this IServiceCollection services)
+ where TRole : class => services.AddBasicAuthentication(async (context, roleName) =>
+ {
+ // This bit is probably overkill for most testing needs.
+ // Simply adding the role, regardless of whether it exists, to the claim is enough for most scenarios.
+ // But, in case there is anything custom added to the Role under RoleManager, we lookup the role and any custom claims.
+ var roleManager = context.HttpContext.RequestServices.GetRequiredService>();
+ var role = await roleManager.FindByNameAsync(roleName);
+ IList roleClaims = (role is not null ? await roleManager.GetClaimsAsync(role) : null) ?? Enumerable.Empty().ToList();
+ if (role is not null)
+ {
+ roleClaims.Add(context.DefaultRoleClaim(roleName));
+ }
+ return roleClaims;
+ });
+
+}
diff --git a/src/C3D/Extensions/Playwright/AspNetCore.Authentication/Utilities/BasicAuthHeaderUtilities.cs b/src/C3D/Extensions/Playwright/AspNetCore.Authentication/Utilities/BasicAuthHeaderUtilities.cs
new file mode 100644
index 0000000..3a2e5f3
--- /dev/null
+++ b/src/C3D/Extensions/Playwright/AspNetCore.Authentication/Utilities/BasicAuthHeaderUtilities.cs
@@ -0,0 +1,30 @@
+using idunno.Authentication.Basic;
+using System.Net.Http.Headers;
+using System.Text;
+
+namespace C3D.Extensions.Playwright.AspNetCore.Authentication;
+
+public static class BasicAuthHeaderUtilities
+{
+ public static AuthenticationHeaderValue BasicAuthHeader(string? username, string? password)
+ {
+ var rawUserPassword = Encoding.UTF8.GetBytes($"{username ?? string.Empty}:{password ?? string.Empty}");
+ var base64UserPassword = Convert.ToBase64String(rawUserPassword);
+ return new AuthenticationHeaderValue(scheme: BasicAuthenticationDefaults.AuthenticationScheme, base64UserPassword);
+ }
+
+ private const string Authorization = "Authorization";
+ private static KeyValuePair BasicAuthHeaderPair(string? username, string? password) =>
+ new(Authorization, BasicAuthHeader(username, password).ToString());
+
+ public static IEnumerable> BasicAuthHeaders(IEnumerable> headers,
+ string username, string password)
+ => headers
+ .Where(x => x.Key != Authorization)
+ .Append(BasicAuthHeaderPair(username, password));
+
+ public static IEnumerable> BasicAuthHeaders(string username, string password)
+ {
+ yield return BasicAuthHeaderPair(username, password);
+ }
+}
diff --git a/src/C3D/Extensions/Playwright/AspNetCore.Authentication/WebApplicationFactoryExtensions.cs b/src/C3D/Extensions/Playwright/AspNetCore.Authentication/WebApplicationFactoryExtensions.cs
new file mode 100644
index 0000000..c0d3600
--- /dev/null
+++ b/src/C3D/Extensions/Playwright/AspNetCore.Authentication/WebApplicationFactoryExtensions.cs
@@ -0,0 +1,48 @@
+using C3D.Extensions.Playwright.AspNetCore.Authentication;
+using C3D.Extensions.Playwright.AspNetCore.Authentication.Options;
+using Microsoft.AspNetCore.Mvc.Testing;
+using Microsoft.Playwright;
+using System.Reflection.PortableExecutable;
+
+namespace C3D.Extensions.Playwright.AspNetCore;
+
+public static class WebApplicationFactoryExtensions
+{
+ public static BrowserNewContextOptions WithBasicAuthentication(this BrowserNewContextOptions options, string username, string password)
+ {
+ options.ExtraHTTPHeaders = options.ExtraHTTPHeaders is null
+ ? BasicAuthHeaderUtilities.BasicAuthHeaders(username, password)
+ : BasicAuthHeaderUtilities.BasicAuthHeaders(options.ExtraHTTPHeaders, username, password);
+ return options;
+ }
+
+ public static async Task CreateAuthorisedPlaywrightContextPageAsync(
+ this PlaywrightWebApplicationFactory fixture, string username, string password, Action? contextOptions = null)
+ where TProgram : class =>
+ await fixture.CreatePlaywrightContextPageAsync(contextOptions: options => {
+ contextOptions?.Invoke(options);
+ options.WithBasicAuthentication(username,password);
+ });
+
+ public static Task CreateAuthorisedPlaywrightContextPageAsync(
+ this PlaywrightWebApplicationFactory fixture, string role, Action? contextOptions = null)
+ where TProgram : class => fixture.CreateAuthorisedPlaywrightContextPageAsync(role, role, contextOptions);
+
+ public static HttpClient CreateClient(this WebApplicationFactory fixture, Action options)
+ where TProgram : class
+ {
+ var clientOptions = new WebApplicationFactoryAuthenticatedClientOptions(fixture.ClientOptions);
+ options?.Invoke(clientOptions);
+ return fixture.CreateDefaultClient(clientOptions.BaseAddress, clientOptions.CreateHandlers());
+ }
+
+ public static HttpClient CreateClient(this WebApplicationFactory fixture, string username, string password)
+ where TProgram : class => fixture.CreateClient(options =>
+ {
+ options.UserName = username;
+ options.Password = password;
+ });
+
+ public static HttpClient CreateClient(this WebApplicationFactory fixture, string role)
+ where TProgram : class => fixture.CreateClient(role, role);
+}
diff --git a/src/C3D/Extensions/Playwright/AspNetCore.Authentication/version.json b/src/C3D/Extensions/Playwright/AspNetCore.Authentication/version.json
new file mode 100644
index 0000000..1b9d97f
--- /dev/null
+++ b/src/C3D/Extensions/Playwright/AspNetCore.Authentication/version.json
@@ -0,0 +1,18 @@
+{
+ "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/v3.3.37/src/NerdBank.GitVersioning/version.schema.json",
+ "version": "0.1",
+ "pathFilters": [ ".", "../Directory.Build.props", "../Directory.Build.targets" ],
+ "publicReleaseRefSpec": [
+ "^refs/heads/main$", // we release out of main
+ "^refs/heads/rel/v\\d+\\.\\d+" // we also release tags starting with rel/N.N
+ ],
+ "nugetPackageVersion": {
+ "semVer": 2
+ },
+ "cloudBuild": {
+ "buildNumber": {
+ "enabled": true
+ }
+ },
+ "buildNumberOffset": 1
+}
\ No newline at end of file
diff --git a/src/C3D/Extensions/Playwright/AspNetCore/Factory/PlaywrightWebApplicationFactory.cs b/src/C3D/Extensions/Playwright/AspNetCore/Factory/PlaywrightWebApplicationFactory.cs
index e4589f4..e21c565 100644
--- a/src/C3D/Extensions/Playwright/AspNetCore/Factory/PlaywrightWebApplicationFactory.cs
+++ b/src/C3D/Extensions/Playwright/AspNetCore/Factory/PlaywrightWebApplicationFactory.cs
@@ -17,7 +17,7 @@ public class PlaywrightWebApplicationFactory : WebApplicationFactory
+{
+ public PlaywrightAuthenticationFixture(IMessageSink output) : base(output) { }
+
+ protected override IHost CreateHost(IHostBuilder builder)
+ {
+ builder.AddBasicAuthentication();
+ return base.CreateHost(builder);
+ }
+}
+
+public class AuthenticationTests : IClassFixture
+{
+ private readonly PlaywrightFixture webApplication;
+ private readonly ITestOutputHelper outputHelper;
+
+ public AuthenticationTests(PlaywrightAuthenticationFixture webApplication, ITestOutputHelper outputHelper)
+ {
+ this.webApplication = webApplication;
+ this.outputHelper = outputHelper;
+ }
+
+ private void WriteFunctionName([CallerMemberName] string? caller = null) => outputHelper.WriteLine(caller);
+
+ [Fact]
+ public async Task AnonymousCantAccessAdmin()
+ {
+ WriteFunctionName();
+
+ await using var context = await webApplication.CreatePlaywrightContextPageAsync();
+ var page = context.Page;
+
+ // N.B. These tests work differently if the browser is not headless!
+ IResponse? response = await page.GotoAsync("/Admin");
+
+ Assert.NotNull(response);
+ Assert.Equal((int)HttpStatusCode.Unauthorized, response.Status);
+ }
+
+ [Fact]
+ public async Task NonAdminCantAdmin()
+ {
+ WriteFunctionName();
+
+ await using var context = await webApplication.CreateAuthorisedPlaywrightContextPageAsync("user");
+ var page = context.Page;
+
+ IResponse? response = await page.GotoAsync("/Admin");
+
+ Assert.NotNull(response);
+ Assert.Equal((int)HttpStatusCode.Forbidden, response.Status);
+ }
+
+ [Fact]
+ public async Task AdminCanAdmin()
+ {
+ WriteFunctionName();
+
+ await using var context = await webApplication.CreateAuthorisedPlaywrightContextPageAsync(Security.Role.Admin);
+ var page = context.Page;
+
+ await page.GotoAsync("/Admin");
+
+ Assert.Equal("Administration", await page.TitleAsync());
+ Assert.Equal(webApplication.Uri + "/Admin", page.Url);
+ }
+
+ [Fact]
+ public async Task AnonymousClientCantAccessAdmin()
+ {
+ WriteFunctionName();
+
+ using var client = webApplication.CreateClient();
+
+ var response = await client.GetAsync("/Admin");
+
+ Assert.NotNull(response);
+ Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
+ }
+
+ [Fact]
+ public async Task NonAdminClientCantAdmin()
+ {
+ WriteFunctionName();
+
+ using var client = webApplication.CreateClient("user");
+
+ var response = await client.GetAsync("/Admin");
+
+ Assert.NotNull(response);
+ Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
+ }
+
+ [Fact]
+ public async Task AdminClientCanAdmin()
+ {
+ WriteFunctionName();
+
+ using var client = webApplication.CreateClient(Security.Role.Admin);
+
+ var response = await client.GetAsync("/Admin");
+ Assert.NotNull(response);
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+
+ var ct = response.Content.Headers.ContentType;
+ Assert.NotNull(ct);
+ Assert.Equal("text/html", ct.MediaType);
+ Assert.Equal("utf-8", ct.CharSet);
+
+ var body = await response.Content.ReadAsStringAsync();
+ Assert.NotNull(body);
+ Assert.Contains("Administration", body);
+
+ }
+}
diff --git a/test/Sample.WebApp.Tests/PageTests.cs b/test/Sample.WebApp.Tests/PageTests.cs
index 91c1738..4939afe 100644
--- a/test/Sample.WebApp.Tests/PageTests.cs
+++ b/test/Sample.WebApp.Tests/PageTests.cs
@@ -49,9 +49,9 @@ public async Task NavigatePrivacyPage()
Assert.NotNull(navItems);
- Assert.Equal(2, await navItems.CountAsync());
+ Assert.Equal(3, await navItems.CountAsync());
- var link = navItems.Nth(1);
+ var link = navItems.Nth(2);
Assert.NotNull(link);
diff --git a/test/Sample.WebApp.Tests/Sample.WebApp.Tests.csproj b/test/Sample.WebApp.Tests/Sample.WebApp.Tests.csproj
index aff766e..2d67d2b 100644
--- a/test/Sample.WebApp.Tests/Sample.WebApp.Tests.csproj
+++ b/test/Sample.WebApp.Tests/Sample.WebApp.Tests.csproj
@@ -38,6 +38,7 @@
+