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; /// What is wrong with the signing key file a test host is started with. public enum OidcKeyFault { None, MissingFile, RsaKey, PublicKeyOnly, NotPem } /// Key files and client settings for an OIDC test host. Everything is generated per kit; nothing is shared or committed. 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 Configuration => new Dictionary { ["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) { } } } /// A clock the test moves, so a parked authorize request can be aged past its lifetime. public sealed class OidcTestClock : TimeProvider { private DateTimeOffset _now = DateTimeOffset.UtcNow; public override DateTimeOffset GetUtcNow() => _now; public void Advance(TimeSpan by) => _now += by; } /// /// 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). /// 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(options => { options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme; options.DefaultAuthenticateScheme = CookieAuthenticationDefaults.AuthenticationScheme; }); services.RemoveAll(); services.AddSingleton(Clock); ConfigureActors(services); }); } /// A hook for tests that replace the seam Bifrost reads the signed-in account through. protected virtual void ConfigureActors(IServiceCollection services) { } protected override void Dispose(bool disposing) { base.Dispose(disposing); if (disposing) { Kit.Dispose(); } } /// Sets the membership of the account registered under . public async Task SetMembershipAsync(string identity, MembershipStatus status) { using var scope = Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var account = db.UserAccounts.Single(x => x.NormalizedBifrostIdentity == identity); db.Memberships.Single(x => x.UserAccountId == account.Id).Status = status; await db.SaveChangesAsync(); } /// Renames the account's display name, as the owner would between two sign-ins. public async Task SetDisplayNameAsync(string identity, string displayName) { using var scope = Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); db.UserAccounts.Single(x => x.NormalizedBifrostIdentity == identity).DisplayName = displayName; await db.SaveChangesAsync(); } /// Removes the account (and with it its membership), as an owner deleting themselves would. public async Task DeleteAccountAsync(string identity) { using var scope = Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); 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() .UserAccounts.Single(x => x.NormalizedBifrostIdentity == identity).Id; } } /// An OIDC host whose signing key file is unusable, so startup must refuse it. public sealed class OidcKeyFaultFactory(OidcKeyFault fault) : OidcWebApplicationFactory(fault); /// An OIDC host whose actor accessor reports an Active account that has no Bifrost identity, which sign-in itself never produces. public sealed class OidcNoIdentityFactory : OidcWebApplicationFactory { protected override void ConfigureActors(IServiceCollection services) { services.RemoveAll(); services.AddScoped(); } private sealed class ActiveWithoutIdentity : ICurrentBifrostActorAccessor { public Task 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 } })); } } /// An OIDC host whose public base URL differs from the origin its requests arrive on, so the issuer cannot be request-derived. 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 { ["Host:PublicBaseUrl"] = PublicBaseUrl })); } } /// /// 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. /// public sealed class OidcCascadeFactory : OidcWebApplicationFactory { protected override void ConfigureWebHost(IWebHostBuilder builder) { base.ConfigureWebHost(builder); builder.ConfigureServices(services => services.Configure(options => options.DisableBulkOperations = true)); } }