Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add authentication package #21

Open
wants to merge 8 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions C3D.Extensions.Playwright.AspNetCore.sln
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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}
Expand Down
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
[![NuGet package](https://img.shields.io/nuget/v/C3D.Extensions.Playwright.AspNetCore.Authentication.svg)](https://nuget.org/packages/C3D.Extensions.Playwright.AspNetCore.Authentication)
[![NuGet downloads](https://img.shields.io/nuget/dt/C3D.Extensions.Playwright.AspNetCore.Authentication.svg)](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
[![NuGet package](https://img.shields.io/nuget/v/C3D.Extensions.Playwright.AspNetCore.Xunit.svg)](https://nuget.org/packages/C3D.Extensions.Playwright.AspNetCore.Xunit)
[![NuGet downloads](https://img.shields.io/nuget/dt/C3D.Extensions.Playwright.AspNetCore.Xunit.svg)](https://nuget.org/packages/C3D.Extensions.Playwright.AspNetCore.Xunit)
Expand Down
8 changes: 8 additions & 0 deletions samples/Sample.WebApp/Pages/Admin.cshtml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
@page
@model Sample.WebApp.Pages.AdminModel
@{
ViewData["Title"] = "Administration";
}
<h1>@ViewData["Title"]</h1>

<p>This page is protected by authentication.</p>
12 changes: 12 additions & 0 deletions samples/Sample.WebApp/Pages/Admin.cshtml.cs
Original file line number Diff line number Diff line change
@@ -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()
{
}
}
3 changes: 3 additions & 0 deletions samples/Sample.WebApp/Pages/Shared/_Layout.cshtml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@
<li class="nav-item">
<a class="nav-link text-dark" asp-area="" asp-page="/Index">Home</a>
</li>
<li class="nav-item">
<a class="nav-link text-dark" asp-area="" asp-page="/Admin">Administration</a>
</li>
<li class="nav-item">
<a class="nav-link text-dark" asp-area="" asp-page="/Privacy">Privacy</a>
</li>
Expand Down
10 changes: 8 additions & 2 deletions samples/Sample.WebApp/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<IConfigurationRoot>(builder.Configuration);

builder.Services.AddHttpLogging(_ => { }); // Required by app.UseHttpLogging for Net 8.0

var app = builder.Build();
Expand Down Expand Up @@ -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"));
Expand Down
18 changes: 18 additions & 0 deletions samples/Sample.WebApp/Security.cs
Original file line number Diff line number Diff line change
@@ -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";

}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFrameworks>net6.0;net7.0;net8.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="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.

Loading
Loading