One configured client (stale clients removed with their tokens and authorizations), Active membership re-checked at token and userinfo through IsStillActiveMemberAsync, PKCE required by the server option, OpenIddict.Server.AspNetCore + EntityFrameworkCore 7.7.1 with no vulnerable transitives. Soul s2, s3 and s4 hold; Low conformance gaps in gamecult-chat:follow_up:oidc-issuer-spec-conformance. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
260 lines
9.9 KiB
C#
260 lines
9.9 KiB
C#
using System.Security.Cryptography;
|
|
using Bifrost.Web.Data;
|
|
using Bifrost.Web.Domain;
|
|
using Bifrost.Web.Features.Shared;
|
|
using Microsoft.AspNetCore.Authentication;
|
|
using Microsoft.AspNetCore.Authentication.Cookies;
|
|
using Microsoft.AspNetCore.Hosting;
|
|
using Microsoft.Extensions.Caching.Memory;
|
|
using Microsoft.Extensions.Configuration;
|
|
using Microsoft.Extensions.Options;
|
|
using OpenIddict.EntityFrameworkCore;
|
|
using OpenIddict.EntityFrameworkCore.Models;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.DependencyInjection.Extensions;
|
|
|
|
namespace Bifrost.Web.Tests.Support;
|
|
|
|
/// <summary>What is wrong with the signing key file a test host is started with.</summary>
|
|
public enum OidcKeyFault
|
|
{
|
|
None,
|
|
MissingFile,
|
|
RsaKey,
|
|
PublicKeyOnly,
|
|
NotPem
|
|
}
|
|
|
|
/// <summary>Key files and client settings for an OIDC test host. Everything is generated per kit; nothing is shared or committed.</summary>
|
|
public sealed class OidcTestKit : IDisposable
|
|
{
|
|
public const string ClientId = "mas-test";
|
|
public const string RedirectUri = "https://chat.example.test/_synapse/client/oidc/callback";
|
|
|
|
public OidcTestKit(OidcKeyFault fault = OidcKeyFault.None)
|
|
{
|
|
// The directory name is a canary: no error may echo any part of a configured path.
|
|
Directory = Path.Combine(Path.GetTempPath(), "canary-" + Guid.NewGuid().ToString("N"));
|
|
System.IO.Directory.CreateDirectory(Directory);
|
|
|
|
using var ec = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
|
SigningKeyPem = ec.ExportPkcs8PrivateKeyPem();
|
|
SigningPublicKeyPem = ec.ExportSubjectPublicKeyInfoPem();
|
|
ClientSecret = Convert.ToHexString(RandomNumberGenerator.GetBytes(24));
|
|
|
|
SigningKeyPath = Path.Combine(Directory, "signing.pem");
|
|
EncryptionKeyPath = Path.Combine(Directory, "encryption.key");
|
|
ClientSecretPath = Path.Combine(Directory, "mas.secret");
|
|
|
|
switch (fault)
|
|
{
|
|
case OidcKeyFault.None:
|
|
File.WriteAllText(SigningKeyPath, SigningKeyPem);
|
|
break;
|
|
case OidcKeyFault.RsaKey:
|
|
using (var rsa = RSA.Create(2048))
|
|
{
|
|
File.WriteAllText(SigningKeyPath, rsa.ExportPkcs8PrivateKeyPem());
|
|
}
|
|
|
|
break;
|
|
case OidcKeyFault.PublicKeyOnly:
|
|
File.WriteAllText(SigningKeyPath, SigningPublicKeyPem);
|
|
break;
|
|
case OidcKeyFault.NotPem:
|
|
File.WriteAllText(SigningKeyPath, "not a key");
|
|
break;
|
|
case OidcKeyFault.MissingFile:
|
|
break;
|
|
}
|
|
|
|
File.WriteAllText(EncryptionKeyPath, Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)) + "\n");
|
|
File.WriteAllText(ClientSecretPath, ClientSecret + "\n");
|
|
}
|
|
|
|
public string Directory { get; }
|
|
|
|
public string SigningKeyPath { get; }
|
|
|
|
public string EncryptionKeyPath { get; }
|
|
|
|
public string ClientSecretPath { get; }
|
|
|
|
public string ClientSecret { get; }
|
|
|
|
public string SigningKeyPem { get; }
|
|
|
|
public string SigningPublicKeyPem { get; }
|
|
|
|
public IReadOnlyDictionary<string, string?> Configuration => new Dictionary<string, string?>
|
|
{
|
|
["Bifrost:Oidc:SigningKeyPath"] = SigningKeyPath,
|
|
["Bifrost:Oidc:EncryptionKeyPath"] = EncryptionKeyPath,
|
|
["Bifrost:Oidc:Clients:Mas:ClientId"] = ClientId,
|
|
["Bifrost:Oidc:Clients:Mas:ClientSecretPath"] = ClientSecretPath,
|
|
["Bifrost:Oidc:Clients:Mas:RedirectUri"] = RedirectUri
|
|
};
|
|
|
|
public void Dispose()
|
|
{
|
|
try
|
|
{
|
|
System.IO.Directory.Delete(Directory, recursive: true);
|
|
}
|
|
catch (IOException)
|
|
{
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>A clock the test moves, so a parked authorize request can be aged past its lifetime.</summary>
|
|
public sealed class OidcTestClock : TimeProvider
|
|
{
|
|
private DateTimeOffset _now = DateTimeOffset.UtcNow;
|
|
|
|
public override DateTimeOffset GetUtcNow() => _now;
|
|
|
|
public void Advance(TimeSpan by) => _now += by;
|
|
}
|
|
|
|
/// <summary>
|
|
/// A test host with the OIDC provider configured from generated key files and cookie authentication as the real
|
|
/// sign-in state (the shared test host signs everyone in as an admin through a header-driven handler instead).
|
|
/// </summary>
|
|
public class OidcWebApplicationFactory : TestWebApplicationFactory
|
|
{
|
|
public OidcWebApplicationFactory() : this(OidcKeyFault.None)
|
|
{
|
|
}
|
|
|
|
protected OidcWebApplicationFactory(OidcKeyFault fault) => Kit = new(fault);
|
|
|
|
public OidcTestKit Kit { get; }
|
|
|
|
public OidcTestClock Clock { get; } = new();
|
|
|
|
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
|
{
|
|
base.ConfigureWebHost(builder);
|
|
|
|
// Program decides at registration time whether the provider exists, so these must be host settings:
|
|
// configuration added with ConfigureAppConfiguration reaches the app only at Build().
|
|
foreach (var (key, value) in Kit.Configuration)
|
|
{
|
|
builder.UseSetting(key, value);
|
|
}
|
|
|
|
builder.ConfigureServices(services =>
|
|
{
|
|
services.Configure<AuthenticationOptions>(options =>
|
|
{
|
|
options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
|
|
options.DefaultAuthenticateScheme = CookieAuthenticationDefaults.AuthenticationScheme;
|
|
});
|
|
services.RemoveAll<TimeProvider>();
|
|
services.AddSingleton<TimeProvider>(Clock);
|
|
ConfigureActors(services);
|
|
});
|
|
}
|
|
|
|
/// <summary>A hook for tests that replace the seam Bifrost reads the signed-in account through.</summary>
|
|
protected virtual void ConfigureActors(IServiceCollection services)
|
|
{
|
|
}
|
|
|
|
protected override void Dispose(bool disposing)
|
|
{
|
|
base.Dispose(disposing);
|
|
if (disposing)
|
|
{
|
|
Kit.Dispose();
|
|
}
|
|
}
|
|
|
|
/// <summary>Sets the membership of the account registered under <paramref name="identity"/>.</summary>
|
|
public async Task SetMembershipAsync(string identity, MembershipStatus status)
|
|
{
|
|
using var scope = Services.CreateScope();
|
|
var db = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
|
|
var account = db.UserAccounts.Single(x => x.NormalizedBifrostIdentity == identity);
|
|
db.Memberships.Single(x => x.UserAccountId == account.Id).Status = status;
|
|
await db.SaveChangesAsync();
|
|
}
|
|
|
|
/// <summary>Renames the account's display name, as the owner would between two sign-ins.</summary>
|
|
public async Task SetDisplayNameAsync(string identity, string displayName)
|
|
{
|
|
using var scope = Services.CreateScope();
|
|
var db = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
|
|
db.UserAccounts.Single(x => x.NormalizedBifrostIdentity == identity).DisplayName = displayName;
|
|
await db.SaveChangesAsync();
|
|
}
|
|
|
|
/// <summary>Removes the account (and with it its membership), as an owner deleting themselves would.</summary>
|
|
public async Task DeleteAccountAsync(string identity)
|
|
{
|
|
using var scope = Services.CreateScope();
|
|
var db = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
|
|
db.UserAccounts.Remove(db.UserAccounts.Single(x => x.NormalizedBifrostIdentity == identity));
|
|
await db.SaveChangesAsync();
|
|
}
|
|
|
|
public Guid AccountId(string identity)
|
|
{
|
|
using var scope = Services.CreateScope();
|
|
return scope.ServiceProvider.GetRequiredService<BifrostDbContext>()
|
|
.UserAccounts.Single(x => x.NormalizedBifrostIdentity == identity).Id;
|
|
}
|
|
}
|
|
|
|
/// <summary>An OIDC host whose signing key file is unusable, so startup must refuse it.</summary>
|
|
public sealed class OidcKeyFaultFactory(OidcKeyFault fault) : OidcWebApplicationFactory(fault);
|
|
|
|
/// <summary>An OIDC host whose actor accessor reports an Active account that has no Bifrost identity, which sign-in itself never produces.</summary>
|
|
public sealed class OidcNoIdentityFactory : OidcWebApplicationFactory
|
|
{
|
|
protected override void ConfigureActors(IServiceCollection services)
|
|
{
|
|
services.RemoveAll<ICurrentBifrostActorAccessor>();
|
|
services.AddScoped<ICurrentBifrostActorAccessor, ActiveWithoutIdentity>();
|
|
}
|
|
|
|
private sealed class ActiveWithoutIdentity : ICurrentBifrostActorAccessor
|
|
{
|
|
public Task<CurrentBifrostActor> GetAsync(CancellationToken cancellationToken) =>
|
|
Task.FromResult(new CurrentBifrostActor(true, new UserAccount
|
|
{
|
|
NormalizedBifrostIdentity = string.Empty,
|
|
DisplayName = "No Identity",
|
|
Membership = new Bifrost.Web.Domain.Membership { Status = MembershipStatus.Active }
|
|
}));
|
|
}
|
|
}
|
|
|
|
/// <summary>An OIDC host whose public base URL differs from the origin its requests arrive on, so the issuer cannot be request-derived.</summary>
|
|
public sealed class OidcForeignIssuerFactory : OidcWebApplicationFactory
|
|
{
|
|
public const string PublicBaseUrl = "https://id.example.test/";
|
|
|
|
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
|
{
|
|
base.ConfigureWebHost(builder);
|
|
builder.ConfigureAppConfiguration((_, configuration) =>
|
|
configuration.AddInMemoryCollection(new Dictionary<string, string?> { ["Host:PublicBaseUrl"] = PublicBaseUrl }));
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// An OIDC host on OpenIddict's own non-bulk deletion path. The in-memory test database cannot run the bulk
|
|
/// ExecuteDelete the library otherwise uses to remove an application's tokens and authorizations, so this switches
|
|
/// that option and the library's cascade runs unchanged.
|
|
/// </summary>
|
|
public sealed class OidcCascadeFactory : OidcWebApplicationFactory
|
|
{
|
|
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
|
{
|
|
base.ConfigureWebHost(builder);
|
|
builder.ConfigureServices(services =>
|
|
services.Configure<OpenIddictEntityFrameworkCoreOptions>(options => options.DisableBulkOperations = true));
|
|
}
|
|
}
|