Skip to content

Commit

Permalink
Add support for stubbing a specific authentication scheme (#168)
Browse files Browse the repository at this point in the history
* Add support for stubbing a specific authentication scheme

* Update docs
  • Loading branch information
Hawxy authored Jul 26, 2024
1 parent 846282a commit 1ae5e66
Show file tree
Hide file tree
Showing 13 changed files with 1,742 additions and 894 deletions.
6 changes: 0 additions & 6 deletions docs/.vitepress/config.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,4 @@
import { defineConfig } from 'vitepress'
import { BUNDLED_LANGUAGES } from 'shiki'

// Include `cs` as alias for csharp
BUNDLED_LANGUAGES
.find(lang => lang.id === 'csharp')!.aliases!.push('cs');


export default defineConfig({
title: 'Alba',
Expand Down
2 changes: 1 addition & 1 deletion docs/guide/extensions.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ public interface IAlbaExtension : IDisposable, IAsyncDisposable
/// <param name="host"></param>
/// <returns></returns>
Task Start(IAlbaHost host);

/// <summary>
/// Allow an extension to alter the application's
/// IHostBuilder prior to starting the application
Expand Down
19 changes: 19 additions & 0 deletions docs/guide/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,25 @@ the JWT tokens with a real Open Id Connect server **so you can test your service
The `JwtSecurityStub` will also honor the `WithClaim()` method to add additional claims on a scenario by scenario basis
as shown in the previous section.

## Override a specific scheme

Both `AuthenticationSecurityStub` and `JwtSecuritySnub` will replace all authentication schemes by default. If you only want a single scheme to be replaced,
you can pass the scheme name via the constructor:

<!-- snippet: sample_bootstrapping_with_stub_scheme_extension -->
<a id='snippet-sample_bootstrapping_with_stub_scheme_extension'></a>
```cs
// Stub out an individual scheme
var securityStub = new AuthenticationStub("custom")
.With("foo", "bar")
.With(JwtRegisteredClaimNames.Email, "[email protected]")
.WithName("jeremy");

await using var host = await AlbaHost.For<WebAppSecuredWithJwt.Program>(securityStub);
```
<sup><a href='https://github.com/JasperFx/alba/blob/master/src/Alba.Testing/Security/web_api_authentication_with_individual_stub.cs#L14-L22' title='Snippet source file'>snippet source</a> | <a href='#snippet-sample_bootstrapping_with_stub_scheme_extension' title='Start of snippet'>anchor</a></sup>
<!-- endSnippet -->

## Integration with JWT Authentication

::: tip
Expand Down
4 changes: 2 additions & 2 deletions docs/scenarios/assertions.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,15 @@ public interface IScenarioAssertion
void Assert(Scenario scenario, HttpContext context, ScenarioAssertionException ex);
}
```
<sup><a href='https://github.com/JasperFx/alba/blob/master/src/Alba/IScenarioAssertion.cs#L6-L11' title='Snippet source file'>snippet source</a> | <a href='#snippet-sample_iscenarioassertion' title='Start of snippet'>anchor</a></sup>
<sup><a href='https://github.com/JasperFx/alba/blob/master/src/Alba/IScenarioAssertion.cs#L5-L10' title='Snippet source file'>snippet source</a> | <a href='#snippet-sample_iscenarioassertion' title='Start of snippet'>anchor</a></sup>
<!-- endSnippet -->

As an example, here's the assertion from Alba that validates that the response body is supposed to

<!-- snippet: sample_BodyContainsAssertion -->
<a id='snippet-sample_bodycontainsassertion'></a>
```cs
internal class BodyContainsAssertion : IScenarioAssertion
internal sealed class BodyContainsAssertion : IScenarioAssertion
{
public string Text { get; set; }

Expand Down
2,353 changes: 1,541 additions & 812 deletions package-lock.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
"docs-build": "npm-run-all -s mdsnippets vitepress-build"
},
"dependencies": {
"vitepress": "1.0.0-rc.20"
"vitepress": "1.3.1"
},
"devDependencies": {
"npm-run-all": "^4.1.5"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
using System.Net;
using System.Threading.Tasks;
using Alba.Security;
using Microsoft.IdentityModel.JsonWebTokens;
using Xunit;

namespace Alba.Testing.Security;

public class web_api_authentication_with_individual_stub
{
[Fact]
public async Task can_stub_individual_scheme()
{
#region sample_bootstrapping_with_stub_scheme_extension
// Stub out an individual scheme
var securityStub = new AuthenticationStub("custom")
.With("foo", "bar")
.With(JwtRegisteredClaimNames.Email, "[email protected]")
.WithName("jeremy");

await using var host = await AlbaHost.For<WebAppSecuredWithJwt.Program>(securityStub);
#endregion

await host.Scenario(s =>
{
s.Get.Url("/identity2");
s.StatusCodeShouldBeOk();
});

await host.Scenario(s =>
{
s.Get.Url("/identity");
s.StatusCodeShouldBe(HttpStatusCode.Unauthorized);
});

}

[Fact]
public async Task can_stub_individual_scheme_jwt()
{
// This is a Alba extension that can "stub" out authentication
var securityStub = new JwtSecurityStub("custom")
.With("foo", "bar")
.With(JwtRegisteredClaimNames.Email, "[email protected]")
.WithName("jeremy");

// We're calling your real web service's configuration
await using var host = await AlbaHost.For<WebAppSecuredWithJwt.Program>(securityStub);

await host.Scenario(s =>
{
s.Get.Url("/identity2");
s.StatusCodeShouldBeOk();
});

await host.Scenario(s =>
{
s.Get.Url("/identity");
s.StatusCodeShouldBe(HttpStatusCode.Unauthorized);
});

}
}
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,6 @@ public async Task can_modify_claims_per_scenario()
}

#endregion


}
}
91 changes: 70 additions & 21 deletions src/Alba/Security/AuthenticationStub.cs
Original file line number Diff line number Diff line change
@@ -1,52 +1,101 @@
using System;
using System;
using System.Security.Claims;
using System.Text.Encodings.Web;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;

namespace Alba.Security;

/// <summary>
/// Stubs out security in all Alba scenarios to always authenticate
/// a user on each request with the configured claims
/// </summary>
public class AuthenticationStub : AuthenticationExtensionBase, IAlbaExtension
public sealed class AuthenticationStub : AuthenticationExtensionBase, IAlbaExtension
{
private const string TestSchemaName = "Test";

internal string? OverrideSchemeTargetName { get; }

/// <summary>
/// Creates a new authentication stub. Will override all implementations by default.
/// </summary>
/// <param name="overrideSchemeTargetName">Override a specific authentication schema.</param>
public AuthenticationStub(string? overrideSchemeTargetName = null)
=> OverrideSchemeTargetName = overrideSchemeTargetName;

void IDisposable.Dispose()
{
// nothing to dispose
}

ValueTask IAsyncDisposable.DisposeAsync()
{
return ValueTask.CompletedTask;
}
ValueTask IAsyncDisposable.DisposeAsync() => ValueTask.CompletedTask;

Task IAlbaExtension.Start(IAlbaHost host)
{
return Task.CompletedTask;
}
Task IAlbaExtension.Start(IAlbaHost host) => Task.CompletedTask;

IHostBuilder IAlbaExtension.Configure(IHostBuilder builder)
{
return builder.ConfigureServices(services =>
{
services.AddHttpContextAccessor();
services.AddSingleton(this);
services.AddAuthentication("Test")
.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(
"Test", _ => {});
services.AddTransient<IAuthenticationSchemeProvider, MockSchemeProvider>();
});
}

internal ClaimsPrincipal BuildPrincipal(HttpContext context)
{
var claims = allClaims(context);
var identity = new ClaimsIdentity(claims, "Test");

var identity = new ClaimsIdentity(claims, TestSchemaName);
var principal = new ClaimsPrincipal(identity);

return principal;
}

private sealed class MockSchemeProvider : AuthenticationSchemeProvider
{
private readonly string? _overrideSchemaTarget;

public MockSchemeProvider(AuthenticationStub authSchemaStub, IOptions<AuthenticationOptions> options)
: base(options)
{
_overrideSchemaTarget = authSchemaStub.OverrideSchemeTargetName;
}

public override Task<AuthenticationScheme?> GetSchemeAsync(string name)
{
if(_overrideSchemaTarget == null)
return Task.FromResult(new AuthenticationScheme(
TestSchemaName,
TestSchemaName,
typeof(MockAuthenticationHandler)))!;
if (name.Equals(_overrideSchemaTarget, StringComparison.OrdinalIgnoreCase))
{
var scheme = new AuthenticationScheme(
TestSchemaName,
TestSchemaName,
typeof(MockAuthenticationHandler));

return Task.FromResult(scheme)!;
}

return base.GetSchemeAsync(name);
}

private sealed class MockAuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
private readonly AuthenticationStub _authenticationSchemaStub;


public MockAuthenticationHandler(IOptionsMonitor<AuthenticationSchemeOptions> options, ILoggerFactory logger, UrlEncoder encoder, AuthenticationStub authenticationSchemaStub) : base(options, logger, encoder)
{
_authenticationSchemaStub = authenticationSchemaStub;
}

protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
var principal = _authenticationSchemaStub.BuildPrincipal(Context);
var ticket = new AuthenticationTicket(principal, TestSchemaName);
return Task.FromResult(AuthenticateResult.Success(ticket));
}
}
}
}
20 changes: 16 additions & 4 deletions src/Alba/Security/JwtSecurityStub.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,14 @@ namespace Alba.Security;
/// Use this extension to generate and apply JWT tokens to scenario requests using
/// a set of baseline claims
/// </summary>
public class JwtSecurityStub : AuthenticationExtensionBase, IAlbaExtension, IPostConfigureOptions<JwtBearerOptions>
public class JwtSecurityStub : AuthenticationExtensionBase, IAlbaExtension
{
private JwtBearerOptions? _options;

private readonly string? _overrideSchemaTargetName;
public JwtSecurityStub(string? overrideSchemaTargetName = null)
=> _overrideSchemaTargetName = overrideSchemaTargetName;

void IDisposable.Dispose()
{
// Nothing
Expand All @@ -38,7 +42,7 @@ Task IAlbaExtension.Start(IAlbaHost host)
{
// This seems to be necessary to "bake" in the JwtBearerOptions modifications
var options = host.Services.GetRequiredService<IOptionsMonitor<JwtBearerOptions>>()
.Get("Bearer");
.Get(_overrideSchemaTargetName ?? JwtBearerDefaults.AuthenticationScheme);


host.BeforeEach(ConfigureJwt);
Expand All @@ -57,7 +61,15 @@ IHostBuilder IAlbaExtension.Configure(IHostBuilder builder)
{
return builder.ConfigureServices(services =>
{
services.AddSingleton<IPostConfigureOptions<JwtBearerOptions>>(this);
if (_overrideSchemaTargetName != null)
{
services.PostConfigure<JwtBearerOptions>(_overrideSchemaTargetName, PostConfigure);
}
else
{
services.PostConfigureAll<JwtBearerOptions>(PostConfigure);
}
});
}

Expand Down Expand Up @@ -103,7 +115,7 @@ protected override IEnumerable<Claim> stubTypeSpecificClaims()
}
}

void IPostConfigureOptions<JwtBearerOptions>.PostConfigure(string? name, JwtBearerOptions options)
void PostConfigure(JwtBearerOptions options)
{
// This will deactivate the callout to the OIDC server
options.ConfigurationManager =
Expand Down
44 changes: 0 additions & 44 deletions src/Alba/Security/TestAuthHandler.cs

This file was deleted.

11 changes: 11 additions & 0 deletions src/WebAppSecuredWithJwt/IdentityController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,15 @@ public IActionResult Get()
return new JsonResult(from c in User.Claims select new { c.Type, c.Value });
}
}

[Route("identity2")]
[Authorize(AuthenticationSchemes = "custom")]
public class Identity2Controller : ControllerBase
{
[HttpGet]
public IActionResult Get()
{
return new JsonResult(from c in User.Claims select new { c.Type, c.Value });
}
}
}
Loading

0 comments on commit 1ae5e66

Please sign in to comment.