Skip to content

Commit

Permalink
Add C3D.Extensions.Playwright.AspNetCore.Authentication and tests
Browse files Browse the repository at this point in the history
  • Loading branch information
CZEMacLeod committed Sep 13, 2023
1 parent cdbb86e commit 1f5ed7f
Show file tree
Hide file tree
Showing 15 changed files with 556 additions and 1 deletion.
7 changes: 7 additions & 0 deletions C3D.Extensions.Playwright.AspNetCore.sln
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "github", "github", "{372E98
.github\dependabot.yml = .github\dependabot.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
Expand All @@ -76,6 +78,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
Expand All @@ -86,6 +92,7 @@ Global
{BBA1AE89-9A65-4482-AF8F-9A491B93A3B2} = {79DBA4F1-9703-4A06-A219-C0E03D99633F}
{C9D19B9D-B61C-435A-8E6C-B32A56A76F27} = {87AD0A87-358B-4C6B-832B-04269C7D9AB0}
{DBF13B24-28DF-4B97-8040-2832108C0209} = {7257F2A8-EE70-4224-9D5D-1EE29EAA0338}
{A865812B-765F-4B0A-89DB-E5F5FBDDD920} = {79DBA4F1-9703-4A06-A219-C0E03D99633F}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {F8A03877-9554-4F94-B4B5-0513AAB4A1B8}
Expand Down
1 change: 1 addition & 0 deletions samples/Sample.WebApp/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ public static void Main(string[] args)

app.UseRouting();

app.UseAuthentication();
app.UseAuthorization();

app.MapRazorPages();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFrameworks>net6.0;net7.0</TargetFrameworks>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AssemblyTitle>$(AssemblyTitle) Authentication</AssemblyTitle>
</PropertyGroup>

<ItemGroup>
<None Include="version.json" />
</ItemGroup>

<ItemGroup>
<None Include="README.md" />
</ItemGroup>

<ItemGroup>
<PackageTag Include="Authentication" />
<PackageTag Include="Basic" />
</ItemGroup>

<ItemGroup>
<None Include="README.md" />
<None Include="version.json" />
</ItemGroup>

<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="6.0.21" Condition="'$(TargetFramework)'=='net6.0'" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="7.0.10" Condition="'$(TargetFramework)'=='net7.0'" />
<PackageReference Include="idunno.Authentication.Basic" Version="2.3.1" />
<ProjectReference Include="..\AspNetCore\C3D.Extensions.Playwright.AspNetCore.csproj" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
using System.Security.Claims;

namespace Microsoft.AspNetCore.Authentication;

public static class CredentialValidationExtensions {
public static Claim DefaultRoleClaim<TOptions>(this ResultContext<TOptions> context, string roleName)
where TOptions : AuthenticationSchemeOptions
=> new(ClaimTypes.Role,
roleName,
ClaimValueTypes.String,
context.Options.ClaimsIssuer);
}
Original file line number Diff line number Diff line change
@@ -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<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
request.Headers.Authorization = BasicAuthHeaderUtilities.BasicAuthHeader(username, password);
return base.SendAsync(request, cancellationToken);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
using idunno.Authentication.Basic;
using Microsoft.Extensions.DependencyInjection;
using System.Security.Claims;

namespace Microsoft.Extensions.Hosting;

public static class HostBuilderBasicAuthenticationExtensions
{
/// <summary>
/// Registers a basic authentication scheme that succeeds for password==username and assigns the role of the username
/// </summary>
public static IHostBuilder AddBasicAuthentication(this IHostBuilder builder,
Func<ValidateCredentialsContext, string, Task<IEnumerable<Claim>?>>? roleClaimsFunc = null) =>
builder.ConfigureServices(services => services.AddBasicAuthentication(roleClaimsFunc));

/// <summary>
/// Uses a registered RoleManager from Microsoft.AspNetCore.Identity to lookup the role and add any role specific claims.
/// </summary>
/// <typeparam name="TRole">Class used for the Role</typeparam>
/// <param name="services">The main service collection</param>
/// <returns></returns>
public static IHostBuilder AddBasicAuthentication<TRole>(this IHostBuilder builder)
where TRole : class => builder.ConfigureServices(services => services.AddBasicAuthentication<TRole>());
}
Original file line number Diff line number Diff line change
@@ -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<DelegatingHandler> Handlers { get; set; } = Enumerable.Empty<DelegatingHandler>();

internal protected virtual DelegatingHandler[] CreateHandlers()
{
return CreateHandlersCore().Concat(Handlers).ToArray();

IEnumerable<DelegatingHandler> 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();
}
}
}
}
92 changes: 92 additions & 0 deletions src/C3D/Extensions/Playwright/AspNetCore.Authentication/README.md
Original file line number Diff line number Diff line change
@@ -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<IEnumerable<Claim>?>`. 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<AppRole>();
```

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<TProgram>` 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<Program>
{
public PlaywrightAuthenticationFixture(IMessageSink output) : base(output) { }

protected override IHost CreateHost(IHostBuilder builder)
{
builder.AddBasicAuthentication();
return base.CreateHost(builder);
}
}
```

```cs
public class AuthenticationTests : IClassFixture<PlaywrightAuthenticationFixture>
{
private readonly PlaywrightFixture<Program> 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<TProgram>`.
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<TProgram>.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.

Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// Registers a basic authentication scheme that succeeds for password==username and assigns the role of the username
/// </summary>
public static IServiceCollection AddBasicAuthentication(this IServiceCollection services,
Func<ValidateCredentialsContext, string, Task<IEnumerable<Claim>?>>? 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<ILoggerFactory>();
var logger = loggerFactory.CreateLogger<BasicAuthenticationEvents>();
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<Claim>());

context.Principal = new ClaimsPrincipal(
new ClaimsIdentity(userClaims.Concat(roleClaims), context.Scheme.Name));
context.Success();
}
}
};
})
.Services;

/// <summary>
/// Uses a registered RoleManager from Microsoft.AspNetCore.Identity to lookup the role and add any role specific claims.
/// </summary>
/// <typeparam name="TRole">Class used for the Role</typeparam>
/// <param name="services">The main service collection</param>
/// <returns></returns>
public static IServiceCollection AddBasicAuthentication<TRole>(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<RoleManager<IdentityRole>>();
var role = await roleManager.FindByNameAsync(roleName);
IList<Claim> roleClaims = (role is not null ? await roleManager.GetClaimsAsync(role) : null) ?? Enumerable.Empty<Claim>().ToList();
if (role is not null)
{
roleClaims.Add(context.DefaultRoleClaim(roleName));
}
return roleClaims;
});

}
Loading

0 comments on commit 1f5ed7f

Please sign in to comment.