From 3c135b8e6747c30f480609a08c449c65f846be96 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 06:31:43 +0200 Subject: [PATCH 01/97] Concept votes: subjects, appraisals, voter identity, network tag and their one writer ConceptAppraisalService is the only appraisal writer; tallies are derived from rows. VoterIdentity.For decides voter key and class; NetworkPrefix is the only prefix computation (HMAC tag). NetworkTagSecret is required in production. Co-Authored-By: Claude Opus 5.5 --- .../Configuration/ConceptVoteOptions.cs | 17 + src/Bifrost.Web/Data/BifrostDbContext.cs | 30 ++ src/Bifrost.Web/Domain/ConceptVoteModels.cs | 50 +++ .../ConceptVotes/ConceptAppraisalService.cs | 270 ++++++++++++ .../Features/ConceptVotes/NetworkPrefix.cs | 40 ++ .../Features/ConceptVotes/VoterIdentity.cs | 28 ++ .../Shared/StartupConfigurationValidator.cs | 5 + src/Bifrost.Web/Program.cs | 6 + .../ConceptAppraisalServiceTests.cs | 411 ++++++++++++++++++ tests/Bifrost.Web.Tests/HostTrustTests.cs | 22 +- tests/Bifrost.Web.Tests/NetworkPrefixTests.cs | 54 +++ 11 files changed, 929 insertions(+), 4 deletions(-) create mode 100644 src/Bifrost.Web/Configuration/ConceptVoteOptions.cs create mode 100644 src/Bifrost.Web/Domain/ConceptVoteModels.cs create mode 100644 src/Bifrost.Web/Features/ConceptVotes/ConceptAppraisalService.cs create mode 100644 src/Bifrost.Web/Features/ConceptVotes/NetworkPrefix.cs create mode 100644 src/Bifrost.Web/Features/ConceptVotes/VoterIdentity.cs create mode 100644 tests/Bifrost.Web.Tests/ConceptAppraisalServiceTests.cs create mode 100644 tests/Bifrost.Web.Tests/NetworkPrefixTests.cs diff --git a/src/Bifrost.Web/Configuration/ConceptVoteOptions.cs b/src/Bifrost.Web/Configuration/ConceptVoteOptions.cs new file mode 100644 index 0000000..2b73824 --- /dev/null +++ b/src/Bifrost.Web/Configuration/ConceptVoteOptions.cs @@ -0,0 +1,17 @@ +namespace Bifrost.Web.Configuration; + +public sealed class ConceptVoteOptions +{ + public const string SectionName = "ConceptVotes"; + + // Keys the network tag. Required outside Development and Testing (StartupConfigurationValidator). + public string NetworkTagSecret { get; init; } = string.Empty; + + // The research note (docs/research/anonymous-vote-stuffing-prior-art.md:57-61) finds no source for a + // coarser IPv4 partition than the address itself, so the default is the exact address. + public int IPv4PrefixLength { get; init; } = 32; + + // /64 is bypassed by RFC 8981 privacy rotation and households can hold a /56 or /48; /56 is the practitioner + // default weighed in the note, not a standard. Tune on observed abuse. + public int IPv6PrefixLength { get; init; } = 56; +} diff --git a/src/Bifrost.Web/Data/BifrostDbContext.cs b/src/Bifrost.Web/Data/BifrostDbContext.cs index 290aa08..9863cfd 100644 --- a/src/Bifrost.Web/Data/BifrostDbContext.cs +++ b/src/Bifrost.Web/Data/BifrostDbContext.cs @@ -63,6 +63,10 @@ public sealed class BifrostDbContext(DbContextOptions options) public DbSet AuditEvents => Set(); + public DbSet ConceptSubjects => Set(); + + public DbSet ConceptAppraisals => Set(); + protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.Entity(entity => @@ -523,5 +527,31 @@ public sealed class BifrostDbContext(DbContextOptions options) .HasForeignKey(x => x.ActorUserAccountId) .OnDelete(DeleteBehavior.SetNull); }); + + modelBuilder.Entity(entity => + { + entity.Property(x => x.Collection).HasMaxLength(80); + entity.Property(x => x.Key).HasMaxLength(80); + entity.Property(x => x.Title).HasMaxLength(240); + entity.HasIndex(x => new { x.Collection, x.Key }).IsUnique(); + }); + + modelBuilder.Entity(entity => + { + entity.Property(x => x.VoterKey).HasMaxLength(120); + entity.Property(x => x.NetworkTag).HasMaxLength(64); + entity.Property(x => x.Stance).HasConversion().HasMaxLength(16); + entity.Property(x => x.VoterClass).HasConversion().HasMaxLength(16); + entity.HasIndex(x => new { x.SubjectId, x.VoterKey }).IsUnique(); + entity.HasIndex(x => new { x.NetworkTag, x.CastAtUtc }); + entity.HasOne(x => x.Subject) + .WithMany() + .HasForeignKey(x => x.SubjectId) + .OnDelete(DeleteBehavior.Restrict); + entity.HasOne() + .WithMany() + .HasForeignKey(x => x.UserAccountId) + .OnDelete(DeleteBehavior.SetNull); + }); } } diff --git a/src/Bifrost.Web/Domain/ConceptVoteModels.cs b/src/Bifrost.Web/Domain/ConceptVoteModels.cs new file mode 100644 index 0000000..b86e051 --- /dev/null +++ b/src/Bifrost.Web/Domain/ConceptVoteModels.cs @@ -0,0 +1,50 @@ +namespace Bifrost.Web.Domain; + +public enum ConceptStance +{ + Up, + Down +} + +public enum VoterClass +{ + Anonymous, + Account, + Member +} + +public sealed class ConceptSubject +{ + public Guid Id { get; set; } = Guid.NewGuid(); + + public string Collection { get; set; } = string.Empty; + + public string Key { get; set; } = string.Empty; + + public string Title { get; set; } = string.Empty; + + public DateTimeOffset RegisteredAtUtc { get; set; } + + public DateTimeOffset? RetiredAtUtc { get; set; } +} + +public sealed class ConceptAppraisal +{ + public Guid Id { get; set; } = Guid.NewGuid(); + + public Guid SubjectId { get; set; } + + public string VoterKey { get; set; } = string.Empty; + + public ConceptStance Stance { get; set; } + + public VoterClass VoterClass { get; set; } + + public Guid? UserAccountId { get; set; } + + public string NetworkTag { get; set; } = string.Empty; + + public DateTimeOffset CastAtUtc { get; set; } + + public ConceptSubject? Subject { get; set; } +} diff --git a/src/Bifrost.Web/Features/ConceptVotes/ConceptAppraisalService.cs b/src/Bifrost.Web/Features/ConceptVotes/ConceptAppraisalService.cs new file mode 100644 index 0000000..398937a --- /dev/null +++ b/src/Bifrost.Web/Features/ConceptVotes/ConceptAppraisalService.cs @@ -0,0 +1,270 @@ +using System.Net; +using System.Text.RegularExpressions; +using Bifrost.Web.Configuration; +using Bifrost.Web.Data; +using Bifrost.Web.Domain; +using Bifrost.Web.Features.Shared; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using Npgsql; + +namespace Bifrost.Web.Features.ConceptVotes; + +public enum CastResult +{ + Recorded, + Changed, + Unchanged, + UnknownSubject, + RetiredSubject, + NoVoter +} + +public sealed record StanceCounts(int Up, int Down); + +public sealed record ConceptTally(int Up, int Down, StanceCounts Members); + +public sealed record ConceptSubjectView( + string Collection, + string Key, + string Title, + ConceptTally Tally, + ConceptStance? YourStance); + +// The only writer of ConceptAppraisal rows. Tallies are derived from rows on every read. +public sealed class ConceptAppraisalService( + BifrostDbContext dbContext, + AuditTrailService auditTrail, + TimeProvider timeProvider, + IOptions options) +{ + private const string UniqueViolation = "23505"; + + private static readonly Regex Slug = new("^[a-z0-9-]{1,80}$", RegexOptions.CultureInvariant); + + public async Task CastAsync( + string collection, + string key, + VoterIdentity? voter, + ConceptStance stance, + IPAddress? client, + CancellationToken cancellationToken) + { + if (voter is null) + { + return CastResult.NoVoter; + } + + var subject = await FindSubjectAsync(collection, key, cancellationToken); + if (subject is null) + { + return CastResult.UnknownSubject; + } + + if (subject.RetiredAtUtc is not null) + { + return CastResult.RetiredSubject; + } + + var opts = options.Value; + var tag = client is null + ? string.Empty + : NetworkPrefix.Tag(client, opts.IPv4PrefixLength, opts.IPv6PrefixLength, opts.NetworkTagSecret); + + var existing = await FindAppraisalAsync(subject.Id, voter.Key, cancellationToken); + if (existing is not null) + { + return await ApplyStanceAsync(existing, stance, tag, cancellationToken); + } + + var row = new ConceptAppraisal + { + SubjectId = subject.Id, + VoterKey = voter.Key, + Stance = stance, + VoterClass = voter.Class, + UserAccountId = voter.UserAccountId, + NetworkTag = tag, + CastAtUtc = timeProvider.GetUtcNow() + }; + dbContext.ConceptAppraisals.Add(row); + + try + { + await dbContext.SaveChangesAsync(cancellationToken); + return CastResult.Recorded; + } + catch (DbUpdateException ex) when (ex.InnerException is PostgresException { SqlState: UniqueViolation }) + { + // A concurrent cast by the same voter won the insert: apply this stance to their row, once. + dbContext.Entry(row).State = EntityState.Detached; + var winner = await FindAppraisalAsync(subject.Id, voter.Key, cancellationToken); + if (winner is null) + { + throw; + } + + return await ApplyStanceAsync(winner, stance, tag, cancellationToken); + } + } + + public async Task RetractAsync( + string collection, + string key, + VoterIdentity? voter, + CancellationToken cancellationToken) + { + if (voter is null) + { + return false; + } + + var subject = await FindSubjectAsync(collection, key, cancellationToken); + if (subject is null) + { + return false; + } + + var row = await FindAppraisalAsync(subject.Id, voter.Key, cancellationToken); + if (row is null) + { + return false; + } + + dbContext.ConceptAppraisals.Remove(row); + await dbContext.SaveChangesAsync(cancellationToken); + return true; + } + + public async Task PurgeAsync( + CurrentBifrostActor actor, + string networkTag, + DateTimeOffset fromUtc, + DateTimeOffset toUtc, + CancellationToken cancellationToken) + { + if (!actor.CanManageMembership || actor.UserAccount is null) + { + throw new UnauthorizedAccessException("Only a platform administrator can purge appraisals."); + } + + if (string.IsNullOrEmpty(networkTag)) + { + return 0; + } + + var rows = await dbContext.ConceptAppraisals + .Where(x => x.NetworkTag == networkTag && x.CastAtUtc >= fromUtc && x.CastAtUtc < toUtc) + .ToListAsync(cancellationToken); + dbContext.ConceptAppraisals.RemoveRange(rows); + + // The audit write saves the removals in the same SaveChanges. Tag, window and count only; no voter keys. + await auditTrail.RecordAsync( + actor.UserAccount.Id, + nameof(ConceptAppraisal), + Guid.Empty, + "Purge", + $"tag={networkTag} from={fromUtc:O} to={toUtc:O} count={rows.Count}", + cancellationToken); + return rows.Count; + } + + public async Task> GetTalliesAsync( + string collection, + CancellationToken cancellationToken) + { + var live = await dbContext.ConceptSubjects.AsNoTracking() + .Where(x => x.Collection == collection && x.RetiredAtUtc == null) + .Select(x => new { x.Id, x.Key }) + .ToListAsync(cancellationToken); + var ids = live.Select(x => x.Id).ToList(); + + var groups = await dbContext.ConceptAppraisals.AsNoTracking() + .Where(x => ids.Contains(x.SubjectId)) + .GroupBy(x => new { x.SubjectId, x.Stance, IsMember = x.VoterClass == VoterClass.Member }) + .Select(g => new { g.Key.SubjectId, g.Key.Stance, g.Key.IsMember, Count = g.Count() }) + .ToListAsync(cancellationToken); + + return live.ToDictionary( + s => s.Key, + s => Tally(groups.Where(g => g.SubjectId == s.Id).Select(g => (g.Stance, g.IsMember, g.Count))), + StringComparer.Ordinal); + } + + public async Task GetSubjectAsync( + string collection, + string key, + VoterIdentity? voter, + CancellationToken cancellationToken) + { + var subject = await FindSubjectAsync(collection, key, cancellationToken); + if (subject is null || subject.RetiredAtUtc is not null) + { + return null; + } + + var groups = await dbContext.ConceptAppraisals.AsNoTracking() + .Where(x => x.SubjectId == subject.Id) + .GroupBy(x => new { x.Stance, IsMember = x.VoterClass == VoterClass.Member }) + .Select(g => new { g.Key.Stance, g.Key.IsMember, Count = g.Count() }) + .ToListAsync(cancellationToken); + var yours = voter is null + ? null + : (await FindAppraisalAsync(subject.Id, voter.Key, cancellationToken))?.Stance; + + return new ConceptSubjectView( + subject.Collection, + subject.Key, + subject.Title, + Tally(groups.Select(g => (g.Stance, g.IsMember, g.Count))), + yours); + } + + private static ConceptTally Tally(IEnumerable<(ConceptStance Stance, bool IsMember, int Count)> groups) + { + int up = 0, down = 0, memberUp = 0, memberDown = 0; + foreach (var (stance, isMember, count) in groups) + { + if (stance == ConceptStance.Up) + { + up += count; + memberUp += isMember ? count : 0; + } + else + { + down += count; + memberDown += isMember ? count : 0; + } + } + + return new ConceptTally(up, down, new StanceCounts(memberUp, memberDown)); + } + + private async Task ApplyStanceAsync( + ConceptAppraisal row, + ConceptStance stance, + string tag, + CancellationToken cancellationToken) + { + if (row.Stance == stance) + { + return CastResult.Unchanged; + } + + row.Stance = stance; + row.NetworkTag = tag; + row.CastAtUtc = timeProvider.GetUtcNow(); + await dbContext.SaveChangesAsync(cancellationToken); + return CastResult.Changed; + } + + private Task FindSubjectAsync(string collection, string key, CancellationToken cancellationToken) => + Slug.IsMatch(collection) && Slug.IsMatch(key) + ? dbContext.ConceptSubjects + .SingleOrDefaultAsync(x => x.Collection == collection && x.Key == key, cancellationToken) + : Task.FromResult(null); + + private Task FindAppraisalAsync(Guid subjectId, string voterKey, CancellationToken cancellationToken) => + dbContext.ConceptAppraisals + .SingleOrDefaultAsync(x => x.SubjectId == subjectId && x.VoterKey == voterKey, cancellationToken); +} diff --git a/src/Bifrost.Web/Features/ConceptVotes/NetworkPrefix.cs b/src/Bifrost.Web/Features/ConceptVotes/NetworkPrefix.cs new file mode 100644 index 0000000..3c88f47 --- /dev/null +++ b/src/Bifrost.Web/Features/ConceptVotes/NetworkPrefix.cs @@ -0,0 +1,40 @@ +using System.Net; +using System.Security.Cryptography; +using System.Text; + +namespace Bifrost.Web.Features.ConceptVotes; + +public static class NetworkPrefix +{ + // The only prefix computation: IPv4-mapped IPv6 folds to IPv4, then the address is masked. Returns CIDR text. + public static string Of(IPAddress address, int ipv4Length, int ipv6Length) + { + if (address.IsIPv4MappedToIPv6) + { + address = address.MapToIPv4(); + } + + var bytes = address.GetAddressBytes(); + var length = bytes.Length == 4 ? ipv4Length : ipv6Length; + if (length < 0 || length > bytes.Length * 8) + { + throw new ArgumentOutOfRangeException(nameof(ipv4Length), "Prefix length is outside the address family."); + } + + for (var i = 0; i < bytes.Length; i++) + { + var kept = Math.Clamp(length - i * 8, 0, 8); + bytes[i] &= (byte)(0xFF << (8 - kept)); + } + + return new IPAddress(bytes) + "/" + length; + } + + // Lowercase hex HMAC-SHA256 over the prefix text. Never reversible to an address; audit and purge only. + public static string Tag(IPAddress address, int ipv4Length, int ipv6Length, string secret) + { + var prefix = Of(address, ipv4Length, ipv6Length); + var mac = HMACSHA256.HashData(Encoding.UTF8.GetBytes(secret), Encoding.UTF8.GetBytes(prefix)); + return Convert.ToHexString(mac).ToLowerInvariant(); + } +} diff --git a/src/Bifrost.Web/Features/ConceptVotes/VoterIdentity.cs b/src/Bifrost.Web/Features/ConceptVotes/VoterIdentity.cs new file mode 100644 index 0000000..5d60944 --- /dev/null +++ b/src/Bifrost.Web/Features/ConceptVotes/VoterIdentity.cs @@ -0,0 +1,28 @@ +using System.Text.RegularExpressions; +using Bifrost.Web.Domain; +using Bifrost.Web.Features.Shared; + +namespace Bifrost.Web.Features.ConceptVotes; + +public sealed record VoterIdentity(string Key, VoterClass Class, Guid? UserAccountId) +{ + public const int MaxKeyLength = 120; + + private static readonly Regex DeviceIdShape = new("^[A-Za-z0-9_-]{1,100}$", RegexOptions.CultureInvariant); + + // The one place a voter key and class are decided. Never reads vote weight. + public static VoterIdentity? For(CurrentBifrostActor actor, string? deviceId) + { + if (actor.IsAuthenticated && actor.UserAccount is { } account) + { + return new VoterIdentity( + "account:" + account.Id.ToString("N"), + actor.IsActiveMember ? VoterClass.Member : VoterClass.Account, + account.Id); + } + + return deviceId is not null && DeviceIdShape.IsMatch(deviceId) + ? new VoterIdentity("device:" + deviceId, VoterClass.Anonymous, null) + : null; + } +} diff --git a/src/Bifrost.Web/Features/Shared/StartupConfigurationValidator.cs b/src/Bifrost.Web/Features/Shared/StartupConfigurationValidator.cs index 0ba0e10..84bcda7 100644 --- a/src/Bifrost.Web/Features/Shared/StartupConfigurationValidator.cs +++ b/src/Bifrost.Web/Features/Shared/StartupConfigurationValidator.cs @@ -71,6 +71,11 @@ public sealed class StartupConfigurationValidator( failures.Add("DataProtection:KeysPath must be configured for production."); } + if (string.IsNullOrWhiteSpace(configuration["ConceptVotes:NetworkTagSecret"])) + { + failures.Add("ConceptVotes:NetworkTagSecret must be configured for production."); + } + if (failures.Count > 0) { throw new InvalidOperationException( diff --git a/src/Bifrost.Web/Program.cs b/src/Bifrost.Web/Program.cs index 218a7ff..bec0271 100644 --- a/src/Bifrost.Web/Program.cs +++ b/src/Bifrost.Web/Program.cs @@ -6,6 +6,7 @@ using Bifrost.Web.Configuration; using Bifrost.Web.Data; using Bifrost.Web.Domain; using Bifrost.Web.Features.Bridge; +using Bifrost.Web.Features.ConceptVotes; using Bifrost.Web.Features.GitHub; using Bifrost.Web.Features.Heimdall; using Bifrost.Web.Features.Membership; @@ -53,6 +54,10 @@ builder.Services .AddOptions() .BindConfiguration(BridgeOptions.SectionName); +builder.Services + .AddOptions() + .BindConfiguration(ConceptVoteOptions.SectionName); + builder.Services.AddRazorPages(options => { options.Conventions.AuthorizePage("/Membership/Status"); @@ -97,6 +102,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddSingleton(); diff --git a/tests/Bifrost.Web.Tests/ConceptAppraisalServiceTests.cs b/tests/Bifrost.Web.Tests/ConceptAppraisalServiceTests.cs new file mode 100644 index 0000000..9a667bd --- /dev/null +++ b/tests/Bifrost.Web.Tests/ConceptAppraisalServiceTests.cs @@ -0,0 +1,411 @@ +using System.Net; +using System.Reflection; +using System.Text.Json; +using Bifrost.Web.Configuration; +using Bifrost.Web.Data; +using Bifrost.Web.Domain; +using Bifrost.Web.Features.ConceptVotes; +using Bifrost.Web.Features.Shared; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.Extensions.Options; +using Npgsql; + +namespace Bifrost.Web.Tests; + +public sealed class ConceptAppraisalServiceTests +{ + private const string Collection = "aetheria-concept-mill"; + private static readonly IPAddress Client = IPAddress.Parse("203.0.113.7"); + + [Fact] + public async Task Anonymous_device_voter_can_cast_and_is_counted() + { + await using var f = new Fixture(); + await f.AddSubject("ship-a"); + + var result = await f.Cast("ship-a", f.Anonymous("device-one"), ConceptStance.Up); + + Assert.Equal(CastResult.Recorded, result); + var tally = (await f.Service.GetTalliesAsync(Collection, default))["ship-a"]; + Assert.Equal(new ConceptTally(1, 0, new StanceCounts(0, 0)), tally); + } + + [Fact] + public async Task Anonymous_without_device_id_gets_no_identity_and_cast_is_refused() + { + await using var f = new Fixture(); + await f.AddSubject("ship-a"); + + Assert.Null(VoterIdentity.For(CurrentBifrostActor.Anonymous, null)); + Assert.Null(VoterIdentity.For(CurrentBifrostActor.Anonymous, "has:colon")); + Assert.Equal(CastResult.NoVoter, await f.Cast("ship-a", null, ConceptStance.Up)); + Assert.Empty(f.Db.ConceptAppraisals); + } + + [Fact] + public async Task Voter_class_is_stamped_at_cast() + { + await using var f = new Fixture(); + await f.AddSubject("ship-a"); + var account = f.Account(MembershipStatus.Authenticated); + var member = f.Account(MembershipStatus.Active); + + await f.Cast("ship-a", f.Anonymous("device-one"), ConceptStance.Up); + await f.Cast("ship-a", VoterIdentity.For(account, null), ConceptStance.Up); + await f.Cast("ship-a", VoterIdentity.For(member, null), ConceptStance.Up); + + var rows = f.Db.ConceptAppraisals.ToList(); + Assert.Equal(VoterClass.Anonymous, rows.Single(x => x.VoterKey == "device:device-one").VoterClass); + Assert.Null(rows.Single(x => x.VoterKey == "device:device-one").UserAccountId); + var accountRow = rows.Single(x => x.UserAccountId == account.UserAccount!.Id); + var memberRow = rows.Single(x => x.UserAccountId == member.UserAccount!.Id); + Assert.Equal(VoterClass.Account, accountRow.VoterClass); + Assert.Equal(VoterClass.Member, memberRow.VoterClass); + + // Membership ends; the same account votes again with the opposite stance. The row keeps its stamp. + member.UserAccount!.Membership!.Status = MembershipStatus.Authenticated; + var recast = await f.Cast("ship-a", VoterIdentity.For(member, null), ConceptStance.Down); + + Assert.Equal(CastResult.Changed, recast); + Assert.Equal(VoterClass.Member, f.Db.ConceptAppraisals.Single(x => x.Id == memberRow.Id).VoterClass); + } + + [Fact] + public async Task Second_cast_by_the_same_key_changes_stance_without_a_new_row() + { + await using var f = new Fixture(); + await f.AddSubject("ship-a"); + var voter = f.Anonymous("device-one"); + + Assert.Equal(CastResult.Recorded, await f.Cast("ship-a", voter, ConceptStance.Up)); + Assert.Equal(CastResult.Changed, await f.Cast("ship-a", voter, ConceptStance.Down)); + Assert.Equal(CastResult.Unchanged, await f.Cast("ship-a", voter, ConceptStance.Down)); + + var row = Assert.Single(f.Db.ConceptAppraisals); + Assert.Equal(ConceptStance.Down, row.Stance); + } + + [Fact] + public async Task Member_weight_does_not_weight_counts() + { + await using var f = new Fixture(); + await f.AddSubject("ship-a"); + var member = f.Account(MembershipStatus.Active); + member.UserAccount!.Membership!.TierSnapshots.Add(new TierSnapshot { Weight = 5m, IsCurrent = true }); + Assert.Equal(5m, member.EffectiveVotingWeight); + + await f.Cast("ship-a", VoterIdentity.For(member, null), ConceptStance.Up); + + var tally = (await f.Service.GetTalliesAsync(Collection, default))["ship-a"]; + Assert.Equal(1, tally.Up); + Assert.Equal(1, tally.Members.Up); + } + + [Fact] + public async Task Member_tally_is_a_filter_over_the_same_rows() + { + await using var f = new Fixture(); + await f.AddSubject("ship-a"); + await f.Cast("ship-a", f.Anonymous("device-one"), ConceptStance.Up); + await f.Cast("ship-a", f.Anonymous("device-two"), ConceptStance.Down); + await f.Cast("ship-a", VoterIdentity.For(f.Account(MembershipStatus.Authenticated), null), ConceptStance.Up); + await f.Cast("ship-a", VoterIdentity.For(f.Account(MembershipStatus.Active), null), ConceptStance.Up); + await f.Cast("ship-a", VoterIdentity.For(f.Account(MembershipStatus.Active), null), ConceptStance.Down); + + var tally = (await f.Service.GetTalliesAsync(Collection, default))["ship-a"]; + var rows = f.Db.ConceptAppraisals.ToList(); + + Assert.Equal(rows.Count(x => x.Stance == ConceptStance.Up), tally.Up); + Assert.Equal(rows.Count(x => x.Stance == ConceptStance.Down), tally.Down); + Assert.Equal(rows.Count(x => x.Stance == ConceptStance.Up && x.VoterClass == VoterClass.Member), tally.Members.Up); + Assert.Equal(rows.Count(x => x.Stance == ConceptStance.Down && x.VoterClass == VoterClass.Member), tally.Members.Down); + Assert.Equal(new ConceptTally(3, 2, new StanceCounts(1, 1)), tally); + } + + [Fact] + public async Task Tally_carries_no_voter_identity() + { + await using var f = new Fixture(); + await f.AddSubject("ship-a"); + var account = f.Account(MembershipStatus.Authenticated); + var member = f.Account(MembershipStatus.Active); + await f.Cast("ship-a", f.Anonymous("device-secret-id"), ConceptStance.Up); + await f.Cast("ship-a", VoterIdentity.For(account, null), ConceptStance.Up); + await f.Cast("ship-a", VoterIdentity.For(member, null), ConceptStance.Down); + + Assert.Equal( + new[] { "Down", "Members", "Up" }, + typeof(ConceptTally).GetProperties(BindingFlags.Public | BindingFlags.Instance) + .Select(p => p.Name).Order().ToArray()); + Assert.Equal( + new[] { "Down", "Up" }, + typeof(StanceCounts).GetProperties(BindingFlags.Public | BindingFlags.Instance) + .Select(p => p.Name).Order().ToArray()); + + var json = JsonSerializer.Serialize(await f.Service.GetTalliesAsync(Collection, default)); + var view = JsonSerializer.Serialize( + await f.Service.GetSubjectAsync(Collection, "ship-a", VoterIdentity.For(account, null), default)); + foreach (var text in new[] { json, view }) + { + Assert.DoesNotContain("device:", text); + Assert.DoesNotContain("account:", text); + Assert.DoesNotContain("device-secret-id", text); + foreach (var id in new[] { account.UserAccount!.Id, member.UserAccount!.Id }) + { + Assert.DoesNotContain(id.ToString("D"), text); + Assert.DoesNotContain(id.ToString("N"), text); + } + + foreach (var tag in f.Db.ConceptAppraisals.Select(x => x.NetworkTag).Distinct()) + { + Assert.DoesNotContain(tag, text); + } + } + } + + [Fact] + public async Task Votes_on_unknown_or_retired_keys_are_refused_and_create_nothing() + { + await using var f = new Fixture(); + await f.AddSubject("ship-a"); + await f.AddSubject("ship-old", retired: true); + var subjectsBefore = f.Db.ConceptSubjects.Count(); + var voter = f.Anonymous("device-one"); + + Assert.Equal(CastResult.UnknownSubject, await f.Cast("ship-missing", voter, ConceptStance.Up)); + Assert.Equal(CastResult.UnknownSubject, await f.Cast("Ship_A", voter, ConceptStance.Up)); + Assert.Equal(CastResult.RetiredSubject, await f.Cast("ship-old", voter, ConceptStance.Up)); + + Assert.Equal(subjectsBefore, f.Db.ConceptSubjects.Count()); + Assert.Empty(f.Db.ConceptAppraisals); + Assert.DoesNotContain("ship-old", (await f.Service.GetTalliesAsync(Collection, default)).Keys); + Assert.Contains("ship-a", (await f.Service.GetTalliesAsync(Collection, default)).Keys); + } + + [Fact] + public async Task Retract_removes_the_row_and_the_count() + { + await using var f = new Fixture(); + await f.AddSubject("ship-a"); + var voter = f.Anonymous("device-one"); + await f.Cast("ship-a", voter, ConceptStance.Up); + await f.Cast("ship-a", f.Anonymous("device-two"), ConceptStance.Up); + + Assert.True(await f.Service.RetractAsync(Collection, "ship-a", voter, default)); + Assert.False(await f.Service.RetractAsync(Collection, "ship-a", voter, default)); + + Assert.Equal(1, (await f.Service.GetTalliesAsync(Collection, default))["ship-a"].Up); + Assert.Single(f.Db.ConceptAppraisals); + } + + [Fact] + public async Task Network_tag_is_an_hmac_never_a_raw_address() + { + await using var f = new Fixture(); + await f.AddSubject("ship-a"); + await f.Cast("ship-a", f.Anonymous("device-one"), ConceptStance.Up); + + var tag = f.Db.ConceptAppraisals.Single().NetworkTag; + + Assert.Matches("^[0-9a-f]{64}$", tag); + Assert.Equal(NetworkPrefix.Tag(Client, 32, 56, Fixture.Secret), tag); + } + + [Fact] + public async Task Unique_violation_at_insert_becomes_a_change() + { + var dbName = $"race-{Guid.NewGuid():N}"; + var racer = new RacingInterceptor(dbName); + await using var f = new Fixture(dbName, racer); + await f.AddSubject("ship-a"); + racer.Subject = f.Db.ConceptSubjects.Single(); + + var result = await f.Cast("ship-a", f.Anonymous("device-one"), ConceptStance.Down); + + Assert.Equal(CastResult.Changed, result); + Assert.Equal(1, racer.Fired); + var row = Assert.Single(f.Db.ConceptAppraisals); + Assert.Equal(ConceptStance.Down, row.Stance); + } + + [Fact] + public void Unique_indexes_are_declared_in_model_and_migration() + { + var options = new DbContextOptionsBuilder().UseNpgsql("Host=unused").Options; + using var db = new BifrostDbContext(options); + + Assert.Contains( + db.Model.FindEntityType(typeof(ConceptAppraisal))!.GetIndexes(), + i => i.IsUnique && i.Properties.Select(p => p.Name).SequenceEqual(["SubjectId", "VoterKey"])); + Assert.Contains( + db.Model.FindEntityType(typeof(ConceptSubject))!.GetIndexes(), + i => i.IsUnique && i.Properties.Select(p => p.Name).SequenceEqual(["Collection", "Key"])); + + var script = db.Database.GenerateCreateScript(); + Assert.Matches("CREATE UNIQUE INDEX \"IX_ConceptAppraisals_SubjectId_VoterKey\"", script); + Assert.Matches("CREATE UNIQUE INDEX \"IX_ConceptSubjects_Collection_Key\"", script); + + } + + [Fact] + public async Task Purge_removes_only_rows_in_tag_and_window_and_audits() + { + await using var f = new Fixture(); + await f.AddSubject("ship-a"); + var admin = f.Admin(); + var t0 = f.Clock.GetUtcNow(); + var other = IPAddress.Parse("198.51.100.9"); + + await f.Cast("ship-a", f.Anonymous("device-in"), ConceptStance.Up); + await f.Cast("ship-a", f.Anonymous("device-other-net"), ConceptStance.Up, other); + f.Clock.Advance(TimeSpan.FromHours(2)); + await f.Cast("ship-a", f.Anonymous("device-late"), ConceptStance.Up); + var tag = NetworkPrefix.Tag(Client, 32, 56, Fixture.Secret); + + var removed = await f.Service.PurgeAsync(admin, tag, t0, t0.AddHours(1), default); + + Assert.Equal(1, removed); + Assert.Equal( + ["device:device-late", "device:device-other-net"], + f.Db.ConceptAppraisals.Select(x => x.VoterKey).Order().ToArray()); + var audit = Assert.Single(f.Db.AuditEvents); + Assert.Equal("ConceptAppraisal", audit.EntityType); + Assert.Equal("Purge", audit.Action); + Assert.Contains(tag, audit.Detail); + Assert.Contains("count=1", audit.Detail); + Assert.DoesNotContain("device", audit.Detail); + + var refused = await Assert.ThrowsAsync( + () => f.Service.PurgeAsync(f.Account(MembershipStatus.Active), tag, t0, t0.AddDays(1), default)); + Assert.DoesNotContain(tag, refused.Message); + Assert.Equal(2, f.Db.ConceptAppraisals.Count()); + Assert.Single(f.Db.AuditEvents); + } + + private sealed class RacingInterceptor(string dbName) : SaveChangesInterceptor + { + public ConceptSubject? Subject { get; set; } + + public int Fired { get; private set; } + + public override async ValueTask> SavingChangesAsync( + DbContextEventData eventData, + InterceptionResult result, + CancellationToken cancellationToken = default) + { + var adding = eventData.Context!.ChangeTracker.Entries() + .Any(e => e.State == EntityState.Added); + if (Subject is null || !adding || Fired > 0) + { + return result; + } + + Fired++; + var options = new DbContextOptionsBuilder().UseInMemoryDatabase(dbName).Options; + await using (var other = new BifrostDbContext(options)) + { + other.ConceptAppraisals.Add(new ConceptAppraisal + { + SubjectId = Subject.Id, + VoterKey = "device:device-one", + Stance = ConceptStance.Up, + VoterClass = VoterClass.Anonymous, + CastAtUtc = DateTimeOffset.UtcNow + }); + await other.SaveChangesAsync(cancellationToken); + } + + throw new DbUpdateException( + "conflict", + new PostgresException("duplicate", "ERROR", "ERROR", "23505")); + } + } + + private sealed class Fixture : IAsyncDisposable + { + public const string Secret = "test-tag-secret"; + + public Fixture(string? dbName = null, IInterceptor? interceptor = null) + { + var builder = new DbContextOptionsBuilder() + .UseInMemoryDatabase(dbName ?? $"concept-{Guid.NewGuid():N}"); + if (interceptor is not null) + { + builder.AddInterceptors(interceptor); + } + + Db = new BifrostDbContext(builder.Options); + Clock = new ManualClock(new DateTimeOffset(2026, 10, 10, 12, 0, 0, TimeSpan.Zero)); + Service = new ConceptAppraisalService( + Db, + new AuditTrailService(Db, Clock), + Clock, + Options.Create(new ConceptVoteOptions { NetworkTagSecret = Secret })); + } + + public BifrostDbContext Db { get; } + + public ManualClock Clock { get; } + + public ConceptAppraisalService Service { get; } + + public async Task AddSubject(string key, bool retired = false) + { + Db.ConceptSubjects.Add(new ConceptSubject + { + Collection = Collection, + Key = key, + Title = key, + RegisteredAtUtc = Clock.GetUtcNow(), + RetiredAtUtc = retired ? Clock.GetUtcNow() : null + }); + await Db.SaveChangesAsync(); + } + + public Task Cast(string key, VoterIdentity? voter, ConceptStance stance, IPAddress? client = null) => + Service.CastAsync(Collection, key, voter, stance, client ?? Client, default); + + public VoterIdentity Anonymous(string deviceId) => + VoterIdentity.For(CurrentBifrostActor.Anonymous, deviceId)!; + + public CurrentBifrostActor Account(MembershipStatus status) + { + var user = new UserAccount + { + DisplayName = "voter", + GitHubLogin = "voter", + NormalizedGitHubLogin = "voter", + CreatedAtUtc = Clock.GetUtcNow(), + LastSeenAtUtc = Clock.GetUtcNow() + }; + user.Membership = new Membership + { + UserAccountId = user.Id, + Status = status, + CreatedAtUtc = Clock.GetUtcNow() + }; + Db.UserAccounts.Add(user); + Db.SaveChanges(); + return new CurrentBifrostActor(true, user); + } + + public CurrentBifrostActor Admin() + { + var actor = Account(MembershipStatus.Active); + actor.UserAccount!.Membership!.IsPlatformAdmin = true; + return actor; + } + + public ValueTask DisposeAsync() => Db.DisposeAsync(); + } + + private sealed class ManualClock(DateTimeOffset start) : TimeProvider + { + private DateTimeOffset _now = start; + + public override DateTimeOffset GetUtcNow() => _now; + + public void Advance(TimeSpan by) => _now += by; + } +} diff --git a/tests/Bifrost.Web.Tests/HostTrustTests.cs b/tests/Bifrost.Web.Tests/HostTrustTests.cs index 78f9057..7824f49 100644 --- a/tests/Bifrost.Web.Tests/HostTrustTests.cs +++ b/tests/Bifrost.Web.Tests/HostTrustTests.cs @@ -146,9 +146,21 @@ public sealed class HostTrustTests : IClassFixture } [Fact] - public void Development_does_not_require_keys_path() + public void Production_without_network_tag_secret_fails_and_never_echoes_values() { - Validate(keysPath: null, environment: Environments.Development); + var canary = "canary-keys-path-77aa"; + var refusal = Assert.Throws( + () => Validate(keysPath: canary, tagSecret: " ")); + + Assert.Contains("ConceptVotes:NetworkTagSecret", refusal.Message); + Assert.DoesNotContain(canary, refusal.Message); + Assert.DoesNotContain("DataProtection:KeysPath", refusal.Message); + } + + [Fact] + public void Development_does_not_require_keys_path_or_tag_secret() + { + Validate(keysPath: null, environment: Environments.Development, tagSecret: null); } private async Task RunForwardedHeaders(string peer, string forwardedFor, string? forwardedProto) @@ -199,13 +211,15 @@ public sealed class HostTrustTests : IClassFixture private static void Validate( string? keysPath, string environment = "Production", - string? connectionString = "Host=db") + string? connectionString = "Host=db", + string? tagSecret = "tag-secret") { var values = new Dictionary { ["AllowedHosts"] = "bifrost.example", ["ConnectionStrings:Bifrost"] = connectionString, - ["DataProtection:KeysPath"] = keysPath + ["DataProtection:KeysPath"] = keysPath, + ["ConceptVotes:NetworkTagSecret"] = tagSecret }; var configuration = new ConfigurationBuilder().AddInMemoryCollection(values).Build(); var hostEnvironment = new StubHostEnvironment(environment); diff --git a/tests/Bifrost.Web.Tests/NetworkPrefixTests.cs b/tests/Bifrost.Web.Tests/NetworkPrefixTests.cs new file mode 100644 index 0000000..8e0dc6c --- /dev/null +++ b/tests/Bifrost.Web.Tests/NetworkPrefixTests.cs @@ -0,0 +1,54 @@ +using System.Net; +using Bifrost.Web.Features.ConceptVotes; + +namespace Bifrost.Web.Tests; + +public sealed class NetworkPrefixTests +{ + private const string Secret = "prefix-secret"; + + private static string Tag(string address, int v4 = 24, int v6 = 56, string secret = Secret) => + NetworkPrefix.Tag(IPAddress.Parse(address), v4, v6, secret); + + [Fact] + public void Two_addresses_inside_one_prefix_share_a_tag_and_other_prefixes_do_not() + { + Assert.Equal(Tag("203.0.113.7"), Tag("203.0.113.200")); + Assert.NotEqual(Tag("203.0.113.7"), Tag("203.0.114.7")); + Assert.Equal(Tag("2001:db8:1:ff::1"), Tag("2001:db8:1:ff:aaaa::9")); + Assert.NotEqual(Tag("2001:db8:1:ff::1"), Tag("2001:db8:2:ff::1")); + Assert.Equal("203.0.113.0/24", NetworkPrefix.Of(IPAddress.Parse("203.0.113.7"), 24, 56)); + Assert.Equal("2001:db8:1:ff00::/56", NetworkPrefix.Of(IPAddress.Parse("2001:db8:1:ffee::1"), 24, 56)); + } + + [Fact] + public void Prefix_length_is_the_configured_one() + { + Assert.NotEqual(Tag("203.0.113.7", v4: 32), Tag("203.0.113.8", v4: 32)); + Assert.Equal(Tag("2001:db8:1:ff::1", v6: 48), Tag("2001:db8:1:aa::1", v6: 48)); + } + + [Fact] + public void IPv4_mapped_IPv6_equals_the_IPv4_address() + { + Assert.Equal(Tag("203.0.113.7"), Tag("::ffff:203.0.113.7")); + } + + [Fact] + public void A_different_secret_gives_a_different_tag_and_the_tag_is_lowercase_hex() + { + var tag = Tag("203.0.113.7"); + + Assert.NotEqual(tag, Tag("203.0.113.7", secret: "other-secret")); + Assert.Matches("^[0-9a-f]{64}$", tag); + } + + [Fact] + public void A_length_outside_the_family_is_refused_without_echoing_the_address() + { + var ex = Assert.Throws( + () => NetworkPrefix.Of(IPAddress.Parse("203.0.113.7"), 33, 56)); + + Assert.DoesNotContain("203.0.113.7", ex.Message); + } +} From 270ab95cc7361e3922b6409586eabfdd52e34520 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 06:39:59 +0200 Subject: [PATCH 02/97] Migration AddConceptVotes, generated by dotnet-ef 8.0.0 The snapshot is regenerated; EF also reordered two pre-existing properties (BridgeAction.TargetSurface, one ProjectId) into its canonical order. Migration discovery now lists thirteen ids. Co-Authored-By: Claude Opus 5.5 --- ...20261010043937_AddConceptVotes.Designer.cs | 2197 +++++++++++++++++ .../20261010043937_AddConceptVotes.cs | 93 + .../BifrostDbContextModelSnapshot.cs | 116 +- .../MigrationDiscoveryTests.cs | 3 +- 4 files changed, 2399 insertions(+), 10 deletions(-) create mode 100644 src/Bifrost.Web/Data/Migrations/20261010043937_AddConceptVotes.Designer.cs create mode 100644 src/Bifrost.Web/Data/Migrations/20261010043937_AddConceptVotes.cs diff --git a/src/Bifrost.Web/Data/Migrations/20261010043937_AddConceptVotes.Designer.cs b/src/Bifrost.Web/Data/Migrations/20261010043937_AddConceptVotes.Designer.cs new file mode 100644 index 0000000..6ffcfd9 --- /dev/null +++ b/src/Bifrost.Web/Data/Migrations/20261010043937_AddConceptVotes.Designer.cs @@ -0,0 +1,2197 @@ +// +using System; +using Bifrost.Web.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Bifrost.Web.Data.Migrations +{ + [DbContext(typeof(BifrostDbContext))] + [Migration("20261010043937_AddConceptVotes")] + partial class AddConceptVotes + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Bifrost.Web.Domain.AgentDispatchRun", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CompletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Error") + .IsRequired() + .HasColumnType("text"); + + b.Property("LaunchMode") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("LogPath") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Note") + .IsRequired() + .HasColumnType("text"); + + b.Property("RequestId") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("ResultPath") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("StartedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("StartedByUserAccountId") + .HasColumnType("uuid"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("TargetAgentIdentity") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)"); + + b.Property("TargetRepoName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("TargetRepositoryFullName") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("ThreadId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("TurnId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("WorkerProcessId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("RequestId"); + + b.HasIndex("StartedByUserAccountId"); + + b.HasIndex("Status"); + + b.HasIndex("TargetRepoName", "TargetAgentIdentity", "Status"); + + b.ToTable("AgentDispatchRuns"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.AgentTransportReceipt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActivityKind") + .IsRequired() + .HasColumnType("text"); + + b.Property("ActorName") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)"); + + b.Property("ActorUserAccountId") + .HasColumnType("uuid"); + + b.Property("Note") + .IsRequired() + .HasColumnType("text"); + + b.Property("OccurredAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RequestId") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("TargetAgentIdentity") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)"); + + b.Property("TargetRepoName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("TargetRepositoryFullName") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.HasKey("Id"); + + b.HasIndex("ActorUserAccountId"); + + b.HasIndex("OccurredAtUtc"); + + b.HasIndex("RequestId"); + + b.HasIndex("TargetRepoName", "TargetAgentIdentity", "ActivityKind"); + + b.ToTable("AgentTransportReceipts"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.Assignment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AssignedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("AssignedByUserAccountId") + .HasColumnType("uuid"); + + b.Property("Note") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.Property("WorkItemId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("AssignedByUserAccountId"); + + b.HasIndex("UserAccountId"); + + b.HasIndex("WorkItemId"); + + b.ToTable("Assignments"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.AuditEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("ActorUserAccountId") + .HasColumnType("uuid"); + + b.Property("Detail") + .IsRequired() + .HasColumnType("text"); + + b.Property("EntityId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("OccurredAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ActorUserAccountId"); + + b.ToTable("AuditEvents"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.BridgeAction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionKind") + .IsRequired() + .HasColumnType("text"); + + b.Property("ActorKind") + .IsRequired() + .HasColumnType("text"); + + b.Property("ActorName") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)"); + + b.Property("ActorUserAccountId") + .HasColumnType("uuid"); + + b.Property("AuthorityReference") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("BifrostIdentity") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)"); + + b.Property("CompletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EpiphanyAgentIdentity") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)"); + + b.Property("EpiphanyLaneId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("EpiphanyRunId") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)"); + + b.Property("ExternalReceiptId") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("FailureReason") + .IsRequired() + .HasColumnType("text"); + + b.Property("HeimdallCapabilityReference") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("MotionId") + .HasColumnType("uuid"); + + b.Property("PolicyDecision") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ReceiptPayload") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReceiptUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("RequestedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("SourceId") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("SourceKind") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("StartedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("Summary") + .IsRequired() + .HasColumnType("text"); + + b.Property("TargetLocator") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("TargetRepositoryFullName") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("TargetSurface") + .IsRequired() + .HasColumnType("text"); + + b.Property("TargetSurfaceName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("WorkItemId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ActorUserAccountId"); + + b.HasIndex("BifrostIdentity"); + + b.HasIndex("HeimdallCapabilityReference"); + + b.HasIndex("MotionId"); + + b.HasIndex("Status"); + + b.HasIndex("WorkItemId"); + + b.HasIndex("ActorKind", "ActorName"); + + b.HasIndex("SourceKind", "SourceId"); + + b.HasIndex("TargetSurface", "ActionKind", "Status"); + + b.HasIndex("TargetSurface", "TargetSurfaceName", "Status"); + + b.ToTable("BridgeActions"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.ConceptAppraisal", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CastAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("NetworkTag") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Stance") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("SubjectId") + .HasColumnType("uuid"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.Property("VoterClass") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("VoterKey") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.HasKey("Id"); + + b.HasIndex("UserAccountId"); + + b.HasIndex("NetworkTag", "CastAtUtc"); + + b.HasIndex("SubjectId", "VoterKey") + .IsUnique(); + + b.ToTable("ConceptAppraisals"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.ConceptSubject", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Collection") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("RegisteredAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RetiredAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.HasKey("Id"); + + b.HasIndex("Collection", "Key") + .IsUnique(); + + b.ToTable("ConceptSubjects"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.DecayRun", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("text"); + + b.Property("RanAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Rate") + .HasPrecision(8, 4) + .HasColumnType("numeric(8,4)"); + + b.HasKey("Id"); + + b.ToTable("DecayRuns"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.GitHubIssueLink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("IssueNumber") + .HasColumnType("integer"); + + b.Property("IssueUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("LastSynchronizedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RepositoryFullName") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("State") + .IsRequired() + .HasColumnType("text"); + + b.Property("TitleSnapshot") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("WorkItemId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("WorkItemId") + .IsUnique(); + + b.HasIndex("RepositoryFullName", "IssueNumber") + .IsUnique(); + + b.ToTable("GitHubIssueLinks"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.GitHubPullRequestLink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("IsMerged") + .HasColumnType("boolean"); + + b.Property("LastSynchronizedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("PullRequestNumber") + .HasColumnType("integer"); + + b.Property("PullRequestUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("RepositoryFullName") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("ReviewDecision") + .IsRequired() + .HasColumnType("text"); + + b.Property("State") + .IsRequired() + .HasColumnType("text"); + + b.Property("TitleSnapshot") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("WorkItemId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("WorkItemId"); + + b.HasIndex("RepositoryFullName", "PullRequestNumber") + .IsUnique(); + + b.ToTable("GitHubPullRequestLinks"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.GovernanceActivityReceipt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActivityKind") + .IsRequired() + .HasColumnType("text"); + + b.Property("ActorKind") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("ActorName") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)"); + + b.Property("ActorUserAccountId") + .HasColumnType("uuid"); + + b.Property("CommentId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("DispatchRequestId") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("JurisdictionAgentIdentity") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)"); + + b.Property("JurisdictionRepoName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Note") + .IsRequired() + .HasColumnType("text"); + + b.Property("OccurredAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("TopicId") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.HasKey("Id"); + + b.HasIndex("ActorUserAccountId"); + + b.HasIndex("OccurredAtUtc"); + + b.HasIndex("TopicId"); + + b.HasIndex("JurisdictionRepoName", "JurisdictionAgentIdentity", "ActivityKind"); + + b.ToTable("GovernanceActivityReceipts"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.LedgerEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedByUserAccountId") + .HasColumnType("uuid"); + + b.Property("EffectiveAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EntryType") + .IsRequired() + .HasColumnType("text"); + + b.Property("NominalAmount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("Note") + .IsRequired() + .HasColumnType("text"); + + b.Property("Points") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("ProjectId") + .HasColumnType("uuid"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.Property("WorkItemId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("CreatedByUserAccountId"); + + b.HasIndex("ProjectId"); + + b.HasIndex("UserAccountId"); + + b.HasIndex("WorkItemId"); + + b.ToTable("LedgerEntries"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.MemberProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Availability") + .IsRequired() + .HasColumnType("text"); + + b.Property("Headline") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("Nickname") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("text"); + + b.Property("PortfolioUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Skills") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserAccountId") + .IsUnique(); + + b.ToTable("MemberProfiles"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.Membership", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApprovedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CanManageLedger") + .HasColumnType("boolean"); + + b.Property("CanManageProjects") + .HasColumnType("boolean"); + + b.Property("CanModerateMotions") + .HasColumnType("boolean"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IsPlatformAdmin") + .HasColumnType("boolean"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserAccountId") + .IsUnique(); + + b.ToTable("Memberships"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.MembershipInvitation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AcceptedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("AcceptedByUserAccountId") + .HasColumnType("uuid"); + + b.Property("GitHubLogin") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("IssuedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IssuedByUserAccountId") + .HasColumnType("uuid"); + + b.Property("NormalizedGitHubLogin") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("text"); + + b.Property("RevokedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("AcceptedByUserAccountId"); + + b.HasIndex("IssuedByUserAccountId"); + + b.HasIndex("NormalizedGitHubLogin") + .IsUnique(); + + b.ToTable("MembershipInvitations"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.Motion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApprovalThreshold") + .HasPrecision(6, 3) + .HasColumnType("numeric(6,3)"); + + b.Property("Category") + .IsRequired() + .HasColumnType("text"); + + b.Property("ClosesAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedByUserAccountId") + .HasColumnType("uuid"); + + b.Property("OpensAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ProjectId") + .HasColumnType("uuid"); + + b.Property("ResolutionNote") + .IsRequired() + .HasColumnType("text"); + + b.Property("ResolvedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Scope") + .IsRequired() + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("Summary") + .IsRequired() + .HasColumnType("text"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("CreatedByUserAccountId"); + + b.HasIndex("ProjectId"); + + b.ToTable("Motions"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.PatronSupportEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Amount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("CurrencyCode") + .IsRequired() + .HasMaxLength(12) + .HasColumnType("character varying(12)"); + + b.Property("ExternalSupportId") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("IsCurrentRecurringSupport") + .HasColumnType("boolean"); + + b.Property("Kind") + .IsRequired() + .HasColumnType("text"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProjectId") + .HasColumnType("uuid"); + + b.Property("Provider") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProviderEventId") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("ProviderPayerId") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("ProviderSubscriptionId") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("RecordedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("SupportedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ProjectId"); + + b.HasIndex("Provider", "ProviderEventId") + .IsUnique() + .HasFilter("\"ProviderEventId\" <> ''"); + + b.HasIndex("UserAccountId", "Kind", "IsCurrentRecurringSupport"); + + b.ToTable("PatronSupportEvents"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.PayoutProposalBatch", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedByUserAccountId") + .HasColumnType("uuid"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("Summary") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("CreatedByUserAccountId"); + + b.ToTable("PayoutProposalBatches"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.PointTransaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Amount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IsDecaying") + .HasColumnType("boolean"); + + b.Property("Note") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProjectId") + .HasColumnType("uuid"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.Property("WorkItemId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ProjectId"); + + b.HasIndex("UserAccountId"); + + b.HasIndex("WorkItemId"); + + b.ToTable("PointTransactions"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.Project", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("GitHubRepository") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(180) + .HasColumnType("character varying(180)"); + + b.Property("OwnerUserAccountId") + .HasColumnType("uuid"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("Summary") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("GitHubRepository"); + + b.HasIndex("OwnerUserAccountId"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Projects"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.RevenueEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Amount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("CurrencyCode") + .IsRequired() + .HasMaxLength(12) + .HasColumnType("character varying(12)"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProjectId") + .HasColumnType("uuid"); + + b.Property("ReceivedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ProjectId"); + + b.ToTable("RevenueEvents"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.RevenueShareBatch", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedByUserAccountId") + .HasColumnType("uuid"); + + b.Property("RevenueEventId") + .HasColumnType("uuid"); + + b.Property("Summary") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("CreatedByUserAccountId"); + + b.HasIndex("RevenueEventId"); + + b.ToTable("RevenueShareBatches"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.RevenueShareLine", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Amount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("Basis") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProjectId") + .HasColumnType("uuid"); + + b.Property("RevenueShareBatchId") + .HasColumnType("uuid"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ProjectId"); + + b.HasIndex("RevenueShareBatchId"); + + b.HasIndex("UserAccountId"); + + b.ToTable("RevenueShareLines"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.RoleAssignment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AssignedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("AssignedByUserAccountId") + .HasColumnType("uuid"); + + b.Property("MembershipId") + .HasColumnType("uuid"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("text"); + + b.Property("Role") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("AssignedByUserAccountId"); + + b.HasIndex("MembershipId", "Role") + .IsUnique(); + + b.ToTable("RoleAssignments"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.TierSnapshot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CapturedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EffectiveFromUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IsCurrent") + .HasColumnType("boolean"); + + b.Property("Kind") + .IsRequired() + .HasColumnType("text"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("MembershipId") + .HasColumnType("uuid"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("text"); + + b.Property("Weight") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.HasKey("Id"); + + b.HasIndex("MembershipId", "Kind", "IsCurrent"); + + b.ToTable("TierSnapshots"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.UserAccount", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AvatarUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("BifrostIdentity") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("GitHubLogin") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("GitHubUserId") + .HasColumnType("bigint"); + + b.Property("HeimdallAccountId") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)"); + + b.Property("LastSeenAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedBifrostIdentity") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("NormalizedGitHubLogin") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.HasKey("Id"); + + b.HasIndex("GitHubUserId") + .IsUnique() + .HasFilter("\"GitHubUserId\" IS NOT NULL"); + + b.HasIndex("HeimdallAccountId") + .IsUnique() + .HasFilter("\"HeimdallAccountId\" <> ''"); + + b.HasIndex("NormalizedBifrostIdentity") + .IsUnique() + .HasFilter("\"NormalizedBifrostIdentity\" <> ''"); + + b.HasIndex("NormalizedGitHubLogin") + .IsUnique() + .HasFilter("\"NormalizedGitHubLogin\" <> ''"); + + b.ToTable("UserAccounts"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.VolunteerClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Note") + .IsRequired() + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.Property("WorkItemId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserAccountId"); + + b.HasIndex("WorkItemId", "UserAccountId") + .IsUnique(); + + b.ToTable("VolunteerClaims"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.Vote", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CastAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Choice") + .IsRequired() + .HasColumnType("text"); + + b.Property("Comment") + .IsRequired() + .HasColumnType("text"); + + b.Property("MotionId") + .HasColumnType("uuid"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.Property("Weight") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.HasKey("Id"); + + b.HasIndex("UserAccountId"); + + b.HasIndex("MotionId", "UserAccountId") + .IsUnique(); + + b.ToTable("Votes"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.WorkItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AssignedToUserAccountId") + .HasColumnType("uuid"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("CompletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ContributionPoints") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EstimatedHours") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("ExternalSourceId") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.Property("ProjectId") + .HasColumnType("uuid"); + + b.Property("RequestedByUserAccountId") + .HasColumnType("uuid"); + + b.Property("ReviewStatus") + .IsRequired() + .HasColumnType("text"); + + b.Property("SkillLevel") + .IsRequired() + .HasColumnType("text"); + + b.Property("SourceType") + .IsRequired() + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("SubmittedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Summary") + .IsRequired() + .HasColumnType("text"); + + b.Property("TargetDateUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(180) + .HasColumnType("character varying(180)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("AssignedToUserAccountId"); + + b.HasIndex("ProjectId"); + + b.HasIndex("RequestedByUserAccountId"); + + b.ToTable("WorkItems"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.WorkLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApprovalStatus") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Hours") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("Note") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReviewedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ReviewedByUserAccountId") + .HasColumnType("uuid"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.Property("WorkItemId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ReviewedByUserAccountId"); + + b.HasIndex("UserAccountId"); + + b.HasIndex("WorkItemId"); + + b.ToTable("WorkLogs"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.WorkReview", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Note") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReviewedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ReviewerName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ReviewerUserAccountId") + .HasColumnType("uuid"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("WorkItemId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ReviewerUserAccountId"); + + b.HasIndex("WorkItemId"); + + b.ToTable("WorkReviews"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.AgentDispatchRun", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "StartedByUserAccount") + .WithMany("AgentDispatchRuns") + .HasForeignKey("StartedByUserAccountId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("StartedByUserAccount"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.AgentTransportReceipt", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "ActorUserAccount") + .WithMany("AgentTransportReceipts") + .HasForeignKey("ActorUserAccountId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("ActorUserAccount"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.Assignment", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "AssignedByUserAccount") + .WithMany() + .HasForeignKey("AssignedByUserAccountId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Bifrost.Web.Domain.UserAccount", "UserAccount") + .WithMany("Assignments") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Bifrost.Web.Domain.WorkItem", "WorkItem") + .WithMany("Assignments") + .HasForeignKey("WorkItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AssignedByUserAccount"); + + b.Navigation("UserAccount"); + + b.Navigation("WorkItem"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.AuditEvent", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "ActorUserAccount") + .WithMany("AuditEvents") + .HasForeignKey("ActorUserAccountId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("ActorUserAccount"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.BridgeAction", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "ActorUserAccount") + .WithMany("BridgeActions") + .HasForeignKey("ActorUserAccountId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Bifrost.Web.Domain.Motion", "Motion") + .WithMany() + .HasForeignKey("MotionId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Bifrost.Web.Domain.WorkItem", "WorkItem") + .WithMany() + .HasForeignKey("WorkItemId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("ActorUserAccount"); + + b.Navigation("Motion"); + + b.Navigation("WorkItem"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.ConceptAppraisal", b => + { + b.HasOne("Bifrost.Web.Domain.ConceptSubject", "Subject") + .WithMany() + .HasForeignKey("SubjectId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Bifrost.Web.Domain.UserAccount", null) + .WithMany() + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Subject"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.GitHubIssueLink", b => + { + b.HasOne("Bifrost.Web.Domain.WorkItem", "WorkItem") + .WithOne("GitHubIssueLink") + .HasForeignKey("Bifrost.Web.Domain.GitHubIssueLink", "WorkItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("WorkItem"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.GitHubPullRequestLink", b => + { + b.HasOne("Bifrost.Web.Domain.WorkItem", "WorkItem") + .WithMany("GitHubPullRequestLinks") + .HasForeignKey("WorkItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("WorkItem"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.GovernanceActivityReceipt", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "ActorUserAccount") + .WithMany("GovernanceActivityReceipts") + .HasForeignKey("ActorUserAccountId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("ActorUserAccount"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.LedgerEntry", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "CreatedByUserAccount") + .WithMany() + .HasForeignKey("CreatedByUserAccountId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Bifrost.Web.Domain.Project", "Project") + .WithMany("LedgerEntries") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Bifrost.Web.Domain.UserAccount", "UserAccount") + .WithMany("LedgerEntries") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Bifrost.Web.Domain.WorkItem", "WorkItem") + .WithMany("LedgerEntries") + .HasForeignKey("WorkItemId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("CreatedByUserAccount"); + + b.Navigation("Project"); + + b.Navigation("UserAccount"); + + b.Navigation("WorkItem"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.MemberProfile", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "UserAccount") + .WithOne("MemberProfile") + .HasForeignKey("Bifrost.Web.Domain.MemberProfile", "UserAccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserAccount"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.Membership", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "UserAccount") + .WithOne("Membership") + .HasForeignKey("Bifrost.Web.Domain.Membership", "UserAccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserAccount"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.MembershipInvitation", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "AcceptedByUserAccount") + .WithMany() + .HasForeignKey("AcceptedByUserAccountId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Bifrost.Web.Domain.UserAccount", "IssuedByUserAccount") + .WithMany() + .HasForeignKey("IssuedByUserAccountId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("AcceptedByUserAccount"); + + b.Navigation("IssuedByUserAccount"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.Motion", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "CreatedByUserAccount") + .WithMany("CreatedMotions") + .HasForeignKey("CreatedByUserAccountId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Bifrost.Web.Domain.Project", "Project") + .WithMany("Motions") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("CreatedByUserAccount"); + + b.Navigation("Project"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.PatronSupportEvent", b => + { + b.HasOne("Bifrost.Web.Domain.Project", "Project") + .WithMany("PatronSupportEvents") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Bifrost.Web.Domain.UserAccount", "UserAccount") + .WithMany("PatronSupportEvents") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Project"); + + b.Navigation("UserAccount"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.PayoutProposalBatch", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "CreatedByUserAccount") + .WithMany() + .HasForeignKey("CreatedByUserAccountId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("CreatedByUserAccount"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.PointTransaction", b => + { + b.HasOne("Bifrost.Web.Domain.Project", "Project") + .WithMany("PointTransactions") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Bifrost.Web.Domain.UserAccount", "UserAccount") + .WithMany("PointTransactions") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Bifrost.Web.Domain.WorkItem", "WorkItem") + .WithMany("PointTransactions") + .HasForeignKey("WorkItemId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Project"); + + b.Navigation("UserAccount"); + + b.Navigation("WorkItem"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.Project", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "OwnerUserAccount") + .WithMany("OwnedProjects") + .HasForeignKey("OwnerUserAccountId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("OwnerUserAccount"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.RevenueEvent", b => + { + b.HasOne("Bifrost.Web.Domain.Project", "Project") + .WithMany("RevenueEvents") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Project"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.RevenueShareBatch", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "CreatedByUserAccount") + .WithMany() + .HasForeignKey("CreatedByUserAccountId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Bifrost.Web.Domain.RevenueEvent", "RevenueEvent") + .WithMany("RevenueShareBatches") + .HasForeignKey("RevenueEventId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("CreatedByUserAccount"); + + b.Navigation("RevenueEvent"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.RevenueShareLine", b => + { + b.HasOne("Bifrost.Web.Domain.Project", "Project") + .WithMany("RevenueShareLines") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Bifrost.Web.Domain.RevenueShareBatch", "RevenueShareBatch") + .WithMany("Lines") + .HasForeignKey("RevenueShareBatchId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bifrost.Web.Domain.UserAccount", "UserAccount") + .WithMany() + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Project"); + + b.Navigation("RevenueShareBatch"); + + b.Navigation("UserAccount"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.RoleAssignment", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "AssignedByUserAccount") + .WithMany() + .HasForeignKey("AssignedByUserAccountId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Bifrost.Web.Domain.Membership", "Membership") + .WithMany("RoleAssignments") + .HasForeignKey("MembershipId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AssignedByUserAccount"); + + b.Navigation("Membership"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.TierSnapshot", b => + { + b.HasOne("Bifrost.Web.Domain.Membership", "Membership") + .WithMany("TierSnapshots") + .HasForeignKey("MembershipId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Membership"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.VolunteerClaim", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "UserAccount") + .WithMany("VolunteerClaims") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Bifrost.Web.Domain.WorkItem", "WorkItem") + .WithMany("VolunteerClaims") + .HasForeignKey("WorkItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserAccount"); + + b.Navigation("WorkItem"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.Vote", b => + { + b.HasOne("Bifrost.Web.Domain.Motion", "Motion") + .WithMany("Votes") + .HasForeignKey("MotionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bifrost.Web.Domain.UserAccount", "UserAccount") + .WithMany("Votes") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Motion"); + + b.Navigation("UserAccount"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.WorkItem", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "AssignedToUserAccount") + .WithMany("AssignedWorkItems") + .HasForeignKey("AssignedToUserAccountId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Bifrost.Web.Domain.Project", "Project") + .WithMany("WorkItems") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bifrost.Web.Domain.UserAccount", "RequestedByUserAccount") + .WithMany("RequestedWorkItems") + .HasForeignKey("RequestedByUserAccountId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("AssignedToUserAccount"); + + b.Navigation("Project"); + + b.Navigation("RequestedByUserAccount"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.WorkLog", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "ReviewedByUserAccount") + .WithMany() + .HasForeignKey("ReviewedByUserAccountId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Bifrost.Web.Domain.UserAccount", "UserAccount") + .WithMany("WorkLogs") + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Bifrost.Web.Domain.WorkItem", "WorkItem") + .WithMany("WorkLogs") + .HasForeignKey("WorkItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ReviewedByUserAccount"); + + b.Navigation("UserAccount"); + + b.Navigation("WorkItem"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.WorkReview", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", "ReviewerUserAccount") + .WithMany("WorkReviews") + .HasForeignKey("ReviewerUserAccountId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Bifrost.Web.Domain.WorkItem", "WorkItem") + .WithMany("WorkReviews") + .HasForeignKey("WorkItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ReviewerUserAccount"); + + b.Navigation("WorkItem"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.Membership", b => + { + b.Navigation("RoleAssignments"); + + b.Navigation("TierSnapshots"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.Motion", b => + { + b.Navigation("Votes"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.Project", b => + { + b.Navigation("LedgerEntries"); + + b.Navigation("Motions"); + + b.Navigation("PatronSupportEvents"); + + b.Navigation("PointTransactions"); + + b.Navigation("RevenueEvents"); + + b.Navigation("RevenueShareLines"); + + b.Navigation("WorkItems"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.RevenueEvent", b => + { + b.Navigation("RevenueShareBatches"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.RevenueShareBatch", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.UserAccount", b => + { + b.Navigation("AgentDispatchRuns"); + + b.Navigation("AgentTransportReceipts"); + + b.Navigation("AssignedWorkItems"); + + b.Navigation("Assignments"); + + b.Navigation("AuditEvents"); + + b.Navigation("BridgeActions"); + + b.Navigation("CreatedMotions"); + + b.Navigation("GovernanceActivityReceipts"); + + b.Navigation("LedgerEntries"); + + b.Navigation("MemberProfile"); + + b.Navigation("Membership"); + + b.Navigation("OwnedProjects"); + + b.Navigation("PatronSupportEvents"); + + b.Navigation("PointTransactions"); + + b.Navigation("RequestedWorkItems"); + + b.Navigation("VolunteerClaims"); + + b.Navigation("Votes"); + + b.Navigation("WorkLogs"); + + b.Navigation("WorkReviews"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.WorkItem", b => + { + b.Navigation("Assignments"); + + b.Navigation("GitHubIssueLink"); + + b.Navigation("GitHubPullRequestLinks"); + + b.Navigation("LedgerEntries"); + + b.Navigation("PointTransactions"); + + b.Navigation("VolunteerClaims"); + + b.Navigation("WorkLogs"); + + b.Navigation("WorkReviews"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Bifrost.Web/Data/Migrations/20261010043937_AddConceptVotes.cs b/src/Bifrost.Web/Data/Migrations/20261010043937_AddConceptVotes.cs new file mode 100644 index 0000000..8f26567 --- /dev/null +++ b/src/Bifrost.Web/Data/Migrations/20261010043937_AddConceptVotes.cs @@ -0,0 +1,93 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Bifrost.Web.Data.Migrations +{ + /// + public partial class AddConceptVotes : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "ConceptSubjects", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Collection = table.Column(type: "character varying(80)", maxLength: 80, nullable: false), + Key = table.Column(type: "character varying(80)", maxLength: 80, nullable: false), + Title = table.Column(type: "character varying(240)", maxLength: 240, nullable: false), + RegisteredAtUtc = table.Column(type: "timestamp with time zone", nullable: false), + RetiredAtUtc = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_ConceptSubjects", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "ConceptAppraisals", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + SubjectId = table.Column(type: "uuid", nullable: false), + VoterKey = table.Column(type: "character varying(120)", maxLength: 120, nullable: false), + Stance = table.Column(type: "character varying(16)", maxLength: 16, nullable: false), + VoterClass = table.Column(type: "character varying(16)", maxLength: 16, nullable: false), + UserAccountId = table.Column(type: "uuid", nullable: true), + NetworkTag = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + CastAtUtc = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ConceptAppraisals", x => x.Id); + table.ForeignKey( + name: "FK_ConceptAppraisals_ConceptSubjects_SubjectId", + column: x => x.SubjectId, + principalTable: "ConceptSubjects", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_ConceptAppraisals_UserAccounts_UserAccountId", + column: x => x.UserAccountId, + principalTable: "UserAccounts", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + }); + + migrationBuilder.CreateIndex( + name: "IX_ConceptAppraisals_NetworkTag_CastAtUtc", + table: "ConceptAppraisals", + columns: new[] { "NetworkTag", "CastAtUtc" }); + + migrationBuilder.CreateIndex( + name: "IX_ConceptAppraisals_SubjectId_VoterKey", + table: "ConceptAppraisals", + columns: new[] { "SubjectId", "VoterKey" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_ConceptAppraisals_UserAccountId", + table: "ConceptAppraisals", + column: "UserAccountId"); + + migrationBuilder.CreateIndex( + name: "IX_ConceptSubjects_Collection_Key", + table: "ConceptSubjects", + columns: new[] { "Collection", "Key" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ConceptAppraisals"); + + migrationBuilder.DropTable( + name: "ConceptSubjects"); + } + } +} diff --git a/src/Bifrost.Web/Data/Migrations/BifrostDbContextModelSnapshot.cs b/src/Bifrost.Web/Data/Migrations/BifrostDbContextModelSnapshot.cs index b12e9dc..b8c50c5 100644 --- a/src/Bifrost.Web/Data/Migrations/BifrostDbContextModelSnapshot.cs +++ b/src/Bifrost.Web/Data/Migrations/BifrostDbContextModelSnapshot.cs @@ -367,15 +367,15 @@ namespace Bifrost.Web.Data.Migrations .HasMaxLength(240) .HasColumnType("character varying(240)"); + b.Property("TargetSurface") + .IsRequired() + .HasColumnType("text"); + b.Property("TargetSurfaceName") .IsRequired() .HasMaxLength(120) .HasColumnType("character varying(120)"); - b.Property("TargetSurface") - .IsRequired() - .HasColumnType("text"); - b.Property("Title") .IsRequired() .HasMaxLength(240) @@ -412,6 +412,88 @@ namespace Bifrost.Web.Data.Migrations b.ToTable("BridgeActions"); }); + modelBuilder.Entity("Bifrost.Web.Domain.ConceptAppraisal", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CastAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("NetworkTag") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Stance") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("SubjectId") + .HasColumnType("uuid"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.Property("VoterClass") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("VoterKey") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.HasKey("Id"); + + b.HasIndex("UserAccountId"); + + b.HasIndex("NetworkTag", "CastAtUtc"); + + b.HasIndex("SubjectId", "VoterKey") + .IsUnique(); + + b.ToTable("ConceptAppraisals"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.ConceptSubject", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Collection") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("RegisteredAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RetiredAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("character varying(240)"); + + b.HasKey("Id"); + + b.HasIndex("Collection", "Key") + .IsUnique(); + + b.ToTable("ConceptSubjects"); + }); + modelBuilder.Entity("Bifrost.Web.Domain.DecayRun", b => { b.Property("Id") @@ -889,6 +971,9 @@ namespace Bifrost.Web.Data.Migrations .IsRequired() .HasColumnType("text"); + b.Property("ProjectId") + .HasColumnType("uuid"); + b.Property("Provider") .IsRequired() .HasColumnType("text"); @@ -908,9 +993,6 @@ namespace Bifrost.Web.Data.Migrations .HasMaxLength(240) .HasColumnType("character varying(240)"); - b.Property("ProjectId") - .HasColumnType("uuid"); - b.Property("RecordedAtUtc") .HasColumnType("timestamp with time zone"); @@ -922,12 +1004,12 @@ namespace Bifrost.Web.Data.Migrations b.HasKey("Id"); + b.HasIndex("ProjectId"); + b.HasIndex("Provider", "ProviderEventId") .IsUnique() .HasFilter("\"ProviderEventId\" <> ''"); - b.HasIndex("ProjectId"); - b.HasIndex("UserAccountId", "Kind", "IsCurrentRecurringSupport"); b.ToTable("PatronSupportEvents"); @@ -1615,6 +1697,22 @@ namespace Bifrost.Web.Data.Migrations b.Navigation("WorkItem"); }); + modelBuilder.Entity("Bifrost.Web.Domain.ConceptAppraisal", b => + { + b.HasOne("Bifrost.Web.Domain.ConceptSubject", "Subject") + .WithMany() + .HasForeignKey("SubjectId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Bifrost.Web.Domain.UserAccount", null) + .WithMany() + .HasForeignKey("UserAccountId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Subject"); + }); + modelBuilder.Entity("Bifrost.Web.Domain.GitHubIssueLink", b => { b.HasOne("Bifrost.Web.Domain.WorkItem", "WorkItem") diff --git a/tests/Bifrost.Web.Tests/MigrationDiscoveryTests.cs b/tests/Bifrost.Web.Tests/MigrationDiscoveryTests.cs index 8d81199..f4b1077 100644 --- a/tests/Bifrost.Web.Tests/MigrationDiscoveryTests.cs +++ b/tests/Bifrost.Web.Tests/MigrationDiscoveryTests.cs @@ -20,6 +20,7 @@ public sealed class MigrationDiscoveryTests "20260622165000_AddBifrostNativeIdentity", "20260622202000_AddBridgeActionTargetSurfaceName", "20260705190000_AddPatronSupportProjectAttribution", + "20261010043937_AddConceptVotes", ]; // Database.GetMigrations() reads assembly metadata only; no connection is opened. @@ -49,7 +50,7 @@ public sealed class MigrationDiscoveryTests } [Fact] - public void All_twelve_migrations_are_listed_in_order() + public void All_thirteen_migrations_are_listed_in_order() { Assert.Equal(ExpectedMigrations, Discovered()); } From 214f555781b381bb318eccb5f737aedd91b8d1c9 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 07:59:42 +0200 Subject: [PATCH 03/97] ConceptAppraisalServiceTests: build actors fully before saving; order after ToList Co-Authored-By: Claude Opus 5.5 --- .../ConceptAppraisalServiceTests.cs | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/tests/Bifrost.Web.Tests/ConceptAppraisalServiceTests.cs b/tests/Bifrost.Web.Tests/ConceptAppraisalServiceTests.cs index 9a667bd..7d04a18 100644 --- a/tests/Bifrost.Web.Tests/ConceptAppraisalServiceTests.cs +++ b/tests/Bifrost.Web.Tests/ConceptAppraisalServiceTests.cs @@ -91,8 +91,7 @@ public sealed class ConceptAppraisalServiceTests { await using var f = new Fixture(); await f.AddSubject("ship-a"); - var member = f.Account(MembershipStatus.Active); - member.UserAccount!.Membership!.TierSnapshots.Add(new TierSnapshot { Weight = 5m, IsCurrent = true }); + var member = f.Account(MembershipStatus.Active, weight: 5m); Assert.Equal(5m, member.EffectiveVotingWeight); await f.Cast("ship-a", VoterIdentity.For(member, null), ConceptStance.Up); @@ -268,7 +267,7 @@ public sealed class ConceptAppraisalServiceTests Assert.Equal(1, removed); Assert.Equal( ["device:device-late", "device:device-other-net"], - f.Db.ConceptAppraisals.Select(x => x.VoterKey).Order().ToArray()); + f.Db.ConceptAppraisals.Select(x => x.VoterKey).ToList().Order().ToArray()); var audit = Assert.Single(f.Db.AuditEvents); Assert.Equal("ConceptAppraisal", audit.EntityType); Assert.Equal("Purge", audit.Action); @@ -369,7 +368,7 @@ public sealed class ConceptAppraisalServiceTests public VoterIdentity Anonymous(string deviceId) => VoterIdentity.For(CurrentBifrostActor.Anonymous, deviceId)!; - public CurrentBifrostActor Account(MembershipStatus status) + public CurrentBifrostActor Account(MembershipStatus status, decimal weight = 0m, bool admin = false) { var user = new UserAccount { @@ -383,8 +382,14 @@ public sealed class ConceptAppraisalServiceTests { UserAccountId = user.Id, Status = status, + IsPlatformAdmin = admin, CreatedAtUtc = Clock.GetUtcNow() }; + if (weight > 0m) + { + user.Membership.TierSnapshots.Add(new TierSnapshot { Weight = weight, IsCurrent = true }); + } + Db.UserAccounts.Add(user); Db.SaveChanges(); return new CurrentBifrostActor(true, user); @@ -392,9 +397,7 @@ public sealed class ConceptAppraisalServiceTests public CurrentBifrostActor Admin() { - var actor = Account(MembershipStatus.Active); - actor.UserAccount!.Membership!.IsPlatformAdmin = true; - return actor; + return Account(MembershipStatus.Active, admin: true); } public ValueTask DisposeAsync() => Db.DisposeAsync(); From d171713ab1b3670902be9159fa3785505f983f97 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 08:42:55 +0200 Subject: [PATCH 04/97] Concept vote tests: close the mutation survivors in view, purge window, slug and prefix masking Co-Authored-By: Claude Opus 5.5 --- .../ConceptAppraisalServiceTests.cs | 138 ++++++++++++++++++ tests/Bifrost.Web.Tests/NetworkPrefixTests.cs | 10 ++ 2 files changed, 148 insertions(+) diff --git a/tests/Bifrost.Web.Tests/ConceptAppraisalServiceTests.cs b/tests/Bifrost.Web.Tests/ConceptAppraisalServiceTests.cs index 7d04a18..04957e5 100644 --- a/tests/Bifrost.Web.Tests/ConceptAppraisalServiceTests.cs +++ b/tests/Bifrost.Web.Tests/ConceptAppraisalServiceTests.cs @@ -278,12 +278,145 @@ public sealed class ConceptAppraisalServiceTests var refused = await Assert.ThrowsAsync( () => f.Service.PurgeAsync(f.Account(MembershipStatus.Active), tag, t0, t0.AddDays(1), default)); Assert.DoesNotContain(tag, refused.Message); + Assert.Equal("Only a platform administrator can purge appraisals.", refused.Message); Assert.Equal(2, f.Db.ConceptAppraisals.Count()); Assert.Single(f.Db.AuditEvents); } + [Fact] + public async Task Unique_violation_with_no_surviving_row_is_not_swallowed() + { + var dbName = $"race-{Guid.NewGuid():N}"; + var racer = new RacingInterceptor(dbName) { InsertRival = false }; + await using var f = new Fixture(dbName, racer); + await f.AddSubject("ship-a"); + racer.Subject = f.Db.ConceptSubjects.Single(); + + await Assert.ThrowsAsync( + () => f.Cast("ship-a", f.Anonymous("device-one"), ConceptStance.Up)); + } + + [Fact] + public async Task A_changed_cast_restamps_tag_and_time() + { + await using var f = new Fixture(); + await f.AddSubject("ship-a"); + var voter = f.Anonymous("device-one"); + await f.Cast("ship-a", voter, ConceptStance.Up, IPAddress.Parse("198.51.100.9")); + var first = f.Db.ConceptAppraisals.Single().CastAtUtc; + f.Clock.Advance(TimeSpan.FromMinutes(5)); + + await f.Cast("ship-a", voter, ConceptStance.Down); + + var row = f.Db.ConceptAppraisals.Single(); + Assert.Equal(NetworkPrefix.Tag(Client, 32, 56, Fixture.Secret), row.NetworkTag); + Assert.Equal(first.AddMinutes(5), row.CastAtUtc); + } + + [Fact] + public async Task Unknown_client_address_leaves_no_tag_and_purge_never_matches_the_empty_tag() + { + await using var f = new Fixture(); + await f.AddSubject("ship-a"); + await f.Service.CastAsync(Collection, "ship-a", f.Anonymous("device-one"), ConceptStance.Up, null, default); + + Assert.Equal(string.Empty, f.Db.ConceptAppraisals.Single().NetworkTag); + var t0 = f.Clock.GetUtcNow(); + Assert.Equal(0, await f.Service.PurgeAsync(f.Admin(), string.Empty, t0.AddDays(-1), t0.AddDays(1), default)); + Assert.Single(f.Db.ConceptAppraisals); + Assert.Empty(f.Db.AuditEvents); + } + + [Fact] + public async Task Purge_window_excludes_its_end_and_includes_its_start() + { + await using var f = new Fixture(); + await f.AddSubject("ship-a"); + var t0 = f.Clock.GetUtcNow(); + await f.Cast("ship-a", f.Anonymous("device-one"), ConceptStance.Up); + var tag = NetworkPrefix.Tag(Client, 32, 56, Fixture.Secret); + + Assert.Equal(0, await f.Service.PurgeAsync(f.Admin(), tag, t0.AddDays(-1), t0, default)); + Assert.Equal(1, await f.Service.PurgeAsync(f.Admin(), tag, t0, t0.AddSeconds(1), default)); + } + + [Fact] + public async Task Retract_by_no_voter_or_on_unknown_subject_is_a_no_op() + { + await using var f = new Fixture(); + await f.AddSubject("ship-a"); + var voter = f.Anonymous("device-one"); + await f.Cast("ship-a", voter, ConceptStance.Up); + + Assert.False(await f.Service.RetractAsync(Collection, "ship-a", null, default)); + Assert.False(await f.Service.RetractAsync(Collection, "ship-missing", voter, default)); + Assert.False(await f.Service.RetractAsync("other-collection", "ship-a", voter, default)); + Assert.Single(f.Db.ConceptAppraisals); + } + + [Fact] + public async Task Subject_page_view_counts_only_its_own_subject_and_shows_the_voters_own_stance() + { + await using var f = new Fixture(); + await f.AddSubject("ship-a"); + await f.AddSubject("ship-b"); + await f.AddSubject("ship-old", retired: true); + var mine = f.Anonymous("device-one"); + await f.Cast("ship-a", mine, ConceptStance.Down); + await f.Cast("ship-a", VoterIdentity.For(f.Account(MembershipStatus.Active), null), ConceptStance.Up); + await f.Cast("ship-a", VoterIdentity.For(f.Account(MembershipStatus.Authenticated), null), ConceptStance.Up); + await f.Cast("ship-b", f.Anonymous("device-two"), ConceptStance.Up); + + var view = await f.Service.GetSubjectAsync(Collection, "ship-a", mine, default); + var stranger = await f.Service.GetSubjectAsync(Collection, "ship-a", f.Anonymous("device-nine"), default); + var anon = await f.Service.GetSubjectAsync(Collection, "ship-a", null, default); + + Assert.Equal(new ConceptTally(2, 1, new StanceCounts(1, 0)), view!.Tally); + Assert.Equal(ConceptStance.Down, view.YourStance); + Assert.Null(stranger!.YourStance); + Assert.Null(anon!.YourStance); + Assert.Null(await f.Service.GetSubjectAsync(Collection, "ship-old", mine, default)); + Assert.Null(await f.Service.GetSubjectAsync(Collection, "ship-missing", mine, default)); + } + + [Fact] + public async Task Collection_and_key_must_both_be_slugs_even_when_a_row_exists() + { + await using var f = new Fixture(); + await f.AddSubject("Ship_A"); + f.Db.ConceptSubjects.Add(new ConceptSubject + { + Collection = "Bad_Collection", + Key = "ship-a", + Title = "t", + RegisteredAtUtc = f.Clock.GetUtcNow() + }); + await f.Db.SaveChangesAsync(); + var voter = f.Anonymous("device-one"); + + Assert.Equal(CastResult.UnknownSubject, await f.Cast("Ship_A", voter, ConceptStance.Up)); + Assert.Equal( + CastResult.UnknownSubject, + await f.Service.CastAsync("Bad_Collection", "ship-a", voter, ConceptStance.Up, Client, default)); + Assert.Empty(f.Db.ConceptAppraisals); + } + + [Fact] + public async Task Voter_keys_have_their_documented_shape() + { + await using var f = new Fixture(); + var account = f.Account(MembershipStatus.Authenticated); + + Assert.Equal("device:abc-DEF_123", VoterIdentity.For(CurrentBifrostActor.Anonymous, "abc-DEF_123")!.Key); + Assert.Equal("account:" + account.UserAccount!.Id.ToString("N"), VoterIdentity.For(account, "ignored")!.Key); + Assert.Null(VoterIdentity.For(CurrentBifrostActor.Anonymous, new string('a', 101))); + Assert.NotNull(VoterIdentity.For(CurrentBifrostActor.Anonymous, new string('a', 100))); + } + private sealed class RacingInterceptor(string dbName) : SaveChangesInterceptor { + public bool InsertRival { get; init; } = true; + public ConceptSubject? Subject { get; set; } public int Fired { get; private set; } @@ -301,6 +434,11 @@ public sealed class ConceptAppraisalServiceTests } Fired++; + if (!InsertRival) + { + throw new DbUpdateException("conflict", new PostgresException("duplicate", "ERROR", "ERROR", "23505")); + } + var options = new DbContextOptionsBuilder().UseInMemoryDatabase(dbName).Options; await using (var other = new BifrostDbContext(options)) { diff --git a/tests/Bifrost.Web.Tests/NetworkPrefixTests.cs b/tests/Bifrost.Web.Tests/NetworkPrefixTests.cs index 8e0dc6c..79af76b 100644 --- a/tests/Bifrost.Web.Tests/NetworkPrefixTests.cs +++ b/tests/Bifrost.Web.Tests/NetworkPrefixTests.cs @@ -50,5 +50,15 @@ public sealed class NetworkPrefixTests () => NetworkPrefix.Of(IPAddress.Parse("203.0.113.7"), 33, 56)); Assert.DoesNotContain("203.0.113.7", ex.Message); + Assert.Contains("Prefix length", ex.Message); + } + + [Fact] + public void Masking_is_exact_inside_a_byte_and_a_zero_length_is_the_whole_family() + { + Assert.Equal("203.0.112.0/20", NetworkPrefix.Of(IPAddress.Parse("203.0.127.200"), 20, 56)); + Assert.Equal("2001:db8:1:f0::/60", NetworkPrefix.Of(IPAddress.Parse("2001:db8:1:ff::1"), 24, 60)); + Assert.Equal("0.0.0.0/0", NetworkPrefix.Of(IPAddress.Parse("203.0.113.7"), 0, 56)); + Assert.Equal("203.0.113.7/32", NetworkPrefix.Of(IPAddress.Parse("203.0.113.7"), 32, 56)); } } From abb0986508b8bc818d1e0caf915dfd687d145746 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 09:55:26 +0200 Subject: [PATCH 05/97] VoterIdentity: anchor the device id grammar at \z so a trailing newline or control char is no second identity Co-Authored-By: Claude Opus 5.5 --- .../Features/ConceptVotes/VoterIdentity.cs | 2 +- .../ConceptAppraisalServiceTests.cs | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/Bifrost.Web/Features/ConceptVotes/VoterIdentity.cs b/src/Bifrost.Web/Features/ConceptVotes/VoterIdentity.cs index 5d60944..2ed2729 100644 --- a/src/Bifrost.Web/Features/ConceptVotes/VoterIdentity.cs +++ b/src/Bifrost.Web/Features/ConceptVotes/VoterIdentity.cs @@ -8,7 +8,7 @@ public sealed record VoterIdentity(string Key, VoterClass Class, Guid? UserAccou { public const int MaxKeyLength = 120; - private static readonly Regex DeviceIdShape = new("^[A-Za-z0-9_-]{1,100}$", RegexOptions.CultureInvariant); + private static readonly Regex DeviceIdShape = new(@"^[A-Za-z0-9_-]{1,100}\z", RegexOptions.CultureInvariant); // The one place a voter key and class are decided. Never reads vote weight. public static VoterIdentity? For(CurrentBifrostActor actor, string? deviceId) diff --git a/tests/Bifrost.Web.Tests/ConceptAppraisalServiceTests.cs b/tests/Bifrost.Web.Tests/ConceptAppraisalServiceTests.cs index 04957e5..83e5df1 100644 --- a/tests/Bifrost.Web.Tests/ConceptAppraisalServiceTests.cs +++ b/tests/Bifrost.Web.Tests/ConceptAppraisalServiceTests.cs @@ -39,10 +39,29 @@ public sealed class ConceptAppraisalServiceTests Assert.Null(VoterIdentity.For(CurrentBifrostActor.Anonymous, null)); Assert.Null(VoterIdentity.For(CurrentBifrostActor.Anonymous, "has:colon")); + Assert.Null(VoterIdentity.For(CurrentBifrostActor.Anonymous, string.Empty)); Assert.Equal(CastResult.NoVoter, await f.Cast("ship-a", null, ConceptStance.Up)); Assert.Empty(f.Db.ConceptAppraisals); } + [Theory] + [InlineData("abc\n")] + [InlineData("abc\r\n")] + [InlineData("abc ")] + [InlineData("abc\t")] + [InlineData("abc\0")] + public async Task A_device_id_with_trailing_whitespace_or_control_char_cannot_cast_beside_its_bare_form(string forged) + { + await using var f = new Fixture(); + await f.AddSubject("ship-a"); + + Assert.Equal(CastResult.Recorded, await f.Cast("ship-a", f.Anonymous("abc"), ConceptStance.Up)); + + Assert.Null(VoterIdentity.For(CurrentBifrostActor.Anonymous, forged)); + Assert.Equal(CastResult.NoVoter, await f.Cast("ship-a", VoterIdentity.For(CurrentBifrostActor.Anonymous, forged), ConceptStance.Down)); + Assert.Single(f.Db.ConceptAppraisals); + } + [Fact] public async Task Voter_class_is_stamped_at_cast() { From 162d7b1f0d9c25818c9e18a9cba647aeb1798fff Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 09:56:08 +0200 Subject: [PATCH 06/97] Purge audit: Detail carries window and count, never the network tag the member dashboard would render Co-Authored-By: Claude Opus 5.5 --- .../ConceptVotes/ConceptAppraisalService.cs | 5 +- .../ConceptAppraisalServiceTests.cs | 5 +- tests/Bifrost.Web.Tests/MemberConsoleTests.cs | 52 +++++++++++++++++++ 3 files changed, 59 insertions(+), 3 deletions(-) diff --git a/src/Bifrost.Web/Features/ConceptVotes/ConceptAppraisalService.cs b/src/Bifrost.Web/Features/ConceptVotes/ConceptAppraisalService.cs index 398937a..11893ad 100644 --- a/src/Bifrost.Web/Features/ConceptVotes/ConceptAppraisalService.cs +++ b/src/Bifrost.Web/Features/ConceptVotes/ConceptAppraisalService.cs @@ -158,13 +158,14 @@ public sealed class ConceptAppraisalService( .ToListAsync(cancellationToken); dbContext.ConceptAppraisals.RemoveRange(rows); - // The audit write saves the removals in the same SaveChanges. Tag, window and count only; no voter keys. + // The audit write saves the removals in the same SaveChanges. The member dashboard renders Detail, so it + // carries the window and count only: no network tag and no voter keys. The actor and window audit the purge. await auditTrail.RecordAsync( actor.UserAccount.Id, nameof(ConceptAppraisal), Guid.Empty, "Purge", - $"tag={networkTag} from={fromUtc:O} to={toUtc:O} count={rows.Count}", + $"from={fromUtc:O} to={toUtc:O} count={rows.Count}", cancellationToken); return rows.Count; } diff --git a/tests/Bifrost.Web.Tests/ConceptAppraisalServiceTests.cs b/tests/Bifrost.Web.Tests/ConceptAppraisalServiceTests.cs index 83e5df1..51df879 100644 --- a/tests/Bifrost.Web.Tests/ConceptAppraisalServiceTests.cs +++ b/tests/Bifrost.Web.Tests/ConceptAppraisalServiceTests.cs @@ -290,7 +290,10 @@ public sealed class ConceptAppraisalServiceTests var audit = Assert.Single(f.Db.AuditEvents); Assert.Equal("ConceptAppraisal", audit.EntityType); Assert.Equal("Purge", audit.Action); - Assert.Contains(tag, audit.Detail); + Assert.DoesNotContain(tag, audit.Detail); + Assert.DoesNotContain("tag", audit.Detail); + Assert.Equal(admin.UserAccount!.Id, audit.ActorUserAccountId); + Assert.Contains("from=" + t0.ToString("O"), audit.Detail); Assert.Contains("count=1", audit.Detail); Assert.DoesNotContain("device", audit.Detail); diff --git a/tests/Bifrost.Web.Tests/MemberConsoleTests.cs b/tests/Bifrost.Web.Tests/MemberConsoleTests.cs index a3addd0..e4e1804 100644 --- a/tests/Bifrost.Web.Tests/MemberConsoleTests.cs +++ b/tests/Bifrost.Web.Tests/MemberConsoleTests.cs @@ -1,4 +1,9 @@ using System.Net; +using Bifrost.Web.Configuration; +using Bifrost.Web.Features.ConceptVotes; +using Bifrost.Web.Features.Shared; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; using Bifrost.Web.Data; using Bifrost.Web.Domain; using Bifrost.Web.Tests.Support; @@ -143,4 +148,51 @@ public sealed class MemberConsoleTests : IClassFixture(); + var admin = await dbContext.UserAccounts + .Include(x => x.Membership) + .SingleAsync(x => x.NormalizedGitHubLogin == "TEST-ADMIN"); + var now = DateTimeOffset.UtcNow; + dbContext.ConceptSubjects.Add(new ConceptSubject + { + Collection = "aetheria-concept-mill", + Key = "ship-a", + Title = "ship-a", + RegisteredAtUtc = now + }); + await dbContext.SaveChangesAsync(); + + var service = scope.ServiceProvider.GetRequiredService(); + var options = scope.ServiceProvider.GetRequiredService>().Value; + var client = System.Net.IPAddress.Parse("203.0.113.7"); + tag = NetworkPrefix.Tag(client, options.IPv4PrefixLength, options.IPv6PrefixLength, options.NetworkTagSecret); + var voter = VoterIdentity.For(CurrentBifrostActor.Anonymous, "member-view-device")!; + await service.CastAsync("aetheria-concept-mill", "ship-a", voter, ConceptStance.Up, client, default); + + var removed = await service.PurgeAsync( + new CurrentBifrostActor(true, admin), tag, now.AddHours(-1), now.AddHours(1), default); + Assert.Equal(1, removed); + } + + using var memberClient = _factory.CreateClient(); + using var response = await memberClient.GetAsync("/App"); + var html = await response.Content.ReadAsStringAsync(); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Contains("Purge", html); + Assert.Contains("count", html); + Assert.DoesNotContain(tag, html); + } } From eca71f589c738075251a5153a51ccf1b64df994b Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 09:56:34 +0200 Subject: [PATCH 07/97] Startup validation: tag secret minimum length and prefix lengths inside the address family, naming keys not values Co-Authored-By: Claude Opus 5.5 --- .../Configuration/ConceptVoteOptions.cs | 7 +++- .../Features/ConceptVotes/NetworkPrefix.cs | 7 +++- .../Shared/StartupConfigurationValidator.cs | 21 ++++++++-- tests/Bifrost.Web.Tests/HostTrustTests.cs | 41 ++++++++++++++++++- 4 files changed, 69 insertions(+), 7 deletions(-) diff --git a/src/Bifrost.Web/Configuration/ConceptVoteOptions.cs b/src/Bifrost.Web/Configuration/ConceptVoteOptions.cs index 2b73824..624832c 100644 --- a/src/Bifrost.Web/Configuration/ConceptVoteOptions.cs +++ b/src/Bifrost.Web/Configuration/ConceptVoteOptions.cs @@ -4,7 +4,12 @@ public sealed class ConceptVoteOptions { public const string SectionName = "ConceptVotes"; - // Keys the network tag. Required outside Development and Testing (StartupConfigurationValidator). + // HMAC-SHA256 output is 32 bytes (RFC 2104 advises a key at least as long as the hash output), so a shorter + // secret makes the tag brute-forceable over the IPv4 space if the table leaks. + public const int MinNetworkTagSecretLength = 32; + + // Keys the network tag. Required outside Development and Testing, at least MinNetworkTagSecretLength + // characters (StartupConfigurationValidator). public string NetworkTagSecret { get; init; } = string.Empty; // The research note (docs/research/anonymous-vote-stuffing-prior-art.md:57-61) finds no source for a diff --git a/src/Bifrost.Web/Features/ConceptVotes/NetworkPrefix.cs b/src/Bifrost.Web/Features/ConceptVotes/NetworkPrefix.cs index 3c88f47..6fd4213 100644 --- a/src/Bifrost.Web/Features/ConceptVotes/NetworkPrefix.cs +++ b/src/Bifrost.Web/Features/ConceptVotes/NetworkPrefix.cs @@ -6,6 +6,11 @@ namespace Bifrost.Web.Features.ConceptVotes; public static class NetworkPrefix { + public const int IPv4Bits = 32; + public const int IPv6Bits = 128; + + public static bool IsValidLength(int length, int addressBits) => length >= 0 && length <= addressBits; + // The only prefix computation: IPv4-mapped IPv6 folds to IPv4, then the address is masked. Returns CIDR text. public static string Of(IPAddress address, int ipv4Length, int ipv6Length) { @@ -16,7 +21,7 @@ public static class NetworkPrefix var bytes = address.GetAddressBytes(); var length = bytes.Length == 4 ? ipv4Length : ipv6Length; - if (length < 0 || length > bytes.Length * 8) + if (!IsValidLength(length, bytes.Length * 8)) { throw new ArgumentOutOfRangeException(nameof(ipv4Length), "Prefix length is outside the address family."); } diff --git a/src/Bifrost.Web/Features/Shared/StartupConfigurationValidator.cs b/src/Bifrost.Web/Features/Shared/StartupConfigurationValidator.cs index 84bcda7..eef6d84 100644 --- a/src/Bifrost.Web/Features/Shared/StartupConfigurationValidator.cs +++ b/src/Bifrost.Web/Features/Shared/StartupConfigurationValidator.cs @@ -1,4 +1,5 @@ using Bifrost.Web.Configuration; +using Bifrost.Web.Features.ConceptVotes; using Microsoft.Extensions.Options; namespace Bifrost.Web.Features.Shared; @@ -10,7 +11,8 @@ public sealed class StartupConfigurationValidator( IOptions gitHubAppOptions, IOptions heimdallOptions, IOptions stripeOptions, - IOptions hostOptions) + IOptions hostOptions, + IOptions conceptVoteOptions) { public void Validate() { @@ -71,9 +73,22 @@ public sealed class StartupConfigurationValidator( failures.Add("DataProtection:KeysPath must be configured for production."); } - if (string.IsNullOrWhiteSpace(configuration["ConceptVotes:NetworkTagSecret"])) + var conceptVotes = conceptVoteOptions.Value; + if (string.IsNullOrWhiteSpace(conceptVotes.NetworkTagSecret) || + conceptVotes.NetworkTagSecret.Length < ConceptVoteOptions.MinNetworkTagSecretLength) { - failures.Add("ConceptVotes:NetworkTagSecret must be configured for production."); + failures.Add( + $"ConceptVotes:NetworkTagSecret must be configured with at least {ConceptVoteOptions.MinNetworkTagSecretLength} characters for production."); + } + + if (!NetworkPrefix.IsValidLength(conceptVotes.IPv4PrefixLength, NetworkPrefix.IPv4Bits)) + { + failures.Add($"ConceptVotes:IPv4PrefixLength must be between 0 and {NetworkPrefix.IPv4Bits}."); + } + + if (!NetworkPrefix.IsValidLength(conceptVotes.IPv6PrefixLength, NetworkPrefix.IPv6Bits)) + { + failures.Add($"ConceptVotes:IPv6PrefixLength must be between 0 and {NetworkPrefix.IPv6Bits}."); } if (failures.Count > 0) diff --git a/tests/Bifrost.Web.Tests/HostTrustTests.cs b/tests/Bifrost.Web.Tests/HostTrustTests.cs index 7824f49..21ba350 100644 --- a/tests/Bifrost.Web.Tests/HostTrustTests.cs +++ b/tests/Bifrost.Web.Tests/HostTrustTests.cs @@ -157,6 +157,34 @@ public sealed class HostTrustTests : IClassFixture Assert.DoesNotContain("DataProtection:KeysPath", refusal.Message); } + [Theory] + [InlineData("short-secret")] + [InlineData("0123456789012345678901234567890")] + public void Production_refuses_a_short_network_tag_secret_without_echoing_it(string secret) + { + var refusal = Assert.Throws( + () => Validate(keysPath: "/keys", tagSecret: secret)); + + Assert.Contains("ConceptVotes:NetworkTagSecret", refusal.Message); + Assert.DoesNotContain(secret, refusal.Message); + Validate(keysPath: "/keys", tagSecret: "01234567890123456789012345678901"); + } + + [Theory] + [InlineData(33, 56, "ConceptVotes:IPv4PrefixLength")] + [InlineData(-1, 56, "ConceptVotes:IPv4PrefixLength")] + [InlineData(32, 129, "ConceptVotes:IPv6PrefixLength")] + [InlineData(32, -1, "ConceptVotes:IPv6PrefixLength")] + public void Production_refuses_prefix_lengths_outside_the_address_family(int v4, int v6, string key) + { + var refusal = Assert.Throws( + () => Validate(keysPath: "/keys", ipv4Length: v4, ipv6Length: v6)); + + Assert.Contains(key, refusal.Message); + Validate(keysPath: "/keys", ipv4Length: 32, ipv6Length: 128); + Validate(keysPath: "/keys", ipv4Length: 0, ipv6Length: 0); + } + [Fact] public void Development_does_not_require_keys_path_or_tag_secret() { @@ -208,18 +236,21 @@ public sealed class HostTrustTests : IClassFixture } } + private const string ValidTagSecret = "tag-secret-0123456789-0123456789-abcdef"; + private static void Validate( string? keysPath, string environment = "Production", string? connectionString = "Host=db", - string? tagSecret = "tag-secret") + string? tagSecret = ValidTagSecret, + int ipv4Length = 32, + int ipv6Length = 56) { var values = new Dictionary { ["AllowedHosts"] = "bifrost.example", ["ConnectionStrings:Bifrost"] = connectionString, ["DataProtection:KeysPath"] = keysPath, - ["ConceptVotes:NetworkTagSecret"] = tagSecret }; var configuration = new ConfigurationBuilder().AddInMemoryCollection(values).Build(); var hostEnvironment = new StubHostEnvironment(environment); @@ -235,6 +266,12 @@ public sealed class HostTrustTests : IClassFixture { PublicBaseUrl = "https://bifrost.example", ExpectedHost = "bifrost.example" + }), + Options.Create(new ConceptVoteOptions + { + NetworkTagSecret = tagSecret ?? string.Empty, + IPv4PrefixLength = ipv4Length, + IPv6PrefixLength = ipv6Length })).Validate(); } From bd06ef0c7465513cfe3c62e83b9f99f429a56491 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 09:56:51 +0200 Subject: [PATCH 08/97] ConceptAppraisalServiceTests: a recast through a fresh context persists the changed stance --- .../ConceptAppraisalServiceTests.cs | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/tests/Bifrost.Web.Tests/ConceptAppraisalServiceTests.cs b/tests/Bifrost.Web.Tests/ConceptAppraisalServiceTests.cs index 51df879..3bd6f8d 100644 --- a/tests/Bifrost.Web.Tests/ConceptAppraisalServiceTests.cs +++ b/tests/Bifrost.Web.Tests/ConceptAppraisalServiceTests.cs @@ -62,6 +62,32 @@ public sealed class ConceptAppraisalServiceTests Assert.Single(f.Db.ConceptAppraisals); } + [Fact] + public async Task A_changed_stance_is_saved_and_read_back_through_a_fresh_context() + { + var dbName = $"concept-{Guid.NewGuid():N}"; + await using var first = new Fixture(dbName); + await first.AddSubject("ship-a"); + Assert.Equal(CastResult.Recorded, await first.Cast("ship-a", first.Anonymous("device-one"), ConceptStance.Up)); + var other = IPAddress.Parse("198.51.100.9"); + + // Production scopes one DbContext per request: the recast gets a context that has never tracked the row. + await using (var second = new Fixture(dbName)) + { + second.Clock.Advance(TimeSpan.FromMinutes(5)); + Assert.Equal( + CastResult.Changed, + await second.Cast("ship-a", second.Anonymous("device-one"), ConceptStance.Down, other)); + } + + await using var reader = new Fixture(dbName); + var row = Assert.Single(reader.Db.ConceptAppraisals); + Assert.Equal(ConceptStance.Down, row.Stance); + Assert.Equal(NetworkPrefix.Tag(other, 32, 56, Fixture.Secret), row.NetworkTag); + Assert.Equal(new DateTimeOffset(2026, 10, 10, 12, 5, 0, TimeSpan.Zero), row.CastAtUtc); + Assert.Equal(new ConceptTally(0, 1, new StanceCounts(0, 0)), (await reader.Service.GetTalliesAsync(Collection, default))["ship-a"]); + } + [Fact] public async Task Voter_class_is_stamped_at_cast() { From b17436ce4f3e920077bba92dd3525bbad1fbad25 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 19:58:04 +0200 Subject: [PATCH 09/97] Concept subject registry and `concepts plan|apply` host command ConceptSubjectRegistry is the only writer of ConceptSubject. ConceptKey is the one key grammar (anchored with \z), shared with ConceptAppraisalService. Not yet built or tested at this commit. Co-Authored-By: Claude Opus 5.5 --- .../ConceptVotes/ConceptAppraisalService.cs | 5 +- .../Features/ConceptVotes/ConceptCommand.cs | 56 +++ .../Features/ConceptVotes/ConceptKey.cs | 13 + .../ConceptVotes/ConceptSubjectRegistry.cs | 225 +++++++++++ src/Bifrost.Web/Program.cs | 23 +- .../Bifrost.Web.Tests.csproj | 4 + .../ConceptSubjectRegistryTests.cs | 374 ++++++++++++++++++ .../Fixtures/mill-three-ships.json | 9 + 8 files changed, 701 insertions(+), 8 deletions(-) create mode 100644 src/Bifrost.Web/Features/ConceptVotes/ConceptCommand.cs create mode 100644 src/Bifrost.Web/Features/ConceptVotes/ConceptKey.cs create mode 100644 src/Bifrost.Web/Features/ConceptVotes/ConceptSubjectRegistry.cs create mode 100644 tests/Bifrost.Web.Tests/ConceptSubjectRegistryTests.cs create mode 100644 tests/Bifrost.Web.Tests/Fixtures/mill-three-ships.json diff --git a/src/Bifrost.Web/Features/ConceptVotes/ConceptAppraisalService.cs b/src/Bifrost.Web/Features/ConceptVotes/ConceptAppraisalService.cs index 11893ad..9e0132b 100644 --- a/src/Bifrost.Web/Features/ConceptVotes/ConceptAppraisalService.cs +++ b/src/Bifrost.Web/Features/ConceptVotes/ConceptAppraisalService.cs @@ -1,5 +1,4 @@ using System.Net; -using System.Text.RegularExpressions; using Bifrost.Web.Configuration; using Bifrost.Web.Data; using Bifrost.Web.Domain; @@ -40,8 +39,6 @@ public sealed class ConceptAppraisalService( { private const string UniqueViolation = "23505"; - private static readonly Regex Slug = new("^[a-z0-9-]{1,80}$", RegexOptions.CultureInvariant); - public async Task CastAsync( string collection, string key, @@ -260,7 +257,7 @@ public sealed class ConceptAppraisalService( } private Task FindSubjectAsync(string collection, string key, CancellationToken cancellationToken) => - Slug.IsMatch(collection) && Slug.IsMatch(key) + ConceptKey.IsValid(collection) && ConceptKey.IsValid(key) ? dbContext.ConceptSubjects .SingleOrDefaultAsync(x => x.Collection == collection && x.Key == key, cancellationToken) : Task.FromResult(null); diff --git a/src/Bifrost.Web/Features/ConceptVotes/ConceptCommand.cs b/src/Bifrost.Web/Features/ConceptVotes/ConceptCommand.cs new file mode 100644 index 0000000..6ff5567 --- /dev/null +++ b/src/Bifrost.Web/Features/ConceptVotes/ConceptCommand.cs @@ -0,0 +1,56 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace Bifrost.Web.Features.ConceptVotes; + +// Host command: Bifrost.Web.dll concepts plan|apply [--retire k1,k2] +public static class ConceptCommand +{ + public const int Ok = 0; + public const int Refused = 1; + public const int Usage = 2; + + // args are the words after "concepts". + public static async Task RunAsync(string[] args, IServiceProvider services, TextWriter output) + { + var known = args.Length > 0 && args[0] is "plan" or "apply"; + var hasRetire = args.Length == 5 && args[3] == "--retire"; + if (!known || !(args.Length == 3 || hasRetire)) + { + await output.WriteLineAsync("usage: concepts plan|apply [--retire k1,k2]"); + return Usage; + } + + string[] retire = hasRetire ? args[4].Split(',', StringSplitOptions.RemoveEmptyEntries) : []; + try + { + string json; + try + { + json = await File.ReadAllTextAsync(args[2]); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + throw new ManifestRefusedException("manifest", "Unreadable"); + } + + var ships = ConceptSubjectRegistry.ParseManifest(json); + await using var scope = services.CreateAsyncScope(); + var registry = scope.ServiceProvider.GetRequiredService(); + var plan = args[0] == "apply" + ? await registry.ApplyAsync(args[1], ships, retire, default) + : await registry.PlanAsync(args[1], ships, retire, default); + + foreach (var entry in plan.Entries) + { + await output.WriteLineAsync($"{entry.Change.ToString().ToLowerInvariant()} {entry.Key}"); + } + + return Ok; + } + catch (ManifestRefusedException ex) + { + await output.WriteLineAsync($"refused {ex.Field} {ex.Code}"); + return Refused; + } + } +} diff --git a/src/Bifrost.Web/Features/ConceptVotes/ConceptKey.cs b/src/Bifrost.Web/Features/ConceptVotes/ConceptKey.cs new file mode 100644 index 0000000..2be54e9 --- /dev/null +++ b/src/Bifrost.Web/Features/ConceptVotes/ConceptKey.cs @@ -0,0 +1,13 @@ +using System.Text.RegularExpressions; + +namespace Bifrost.Web.Features.ConceptVotes; + +// The one grammar for a concept collection or key. \z, not $: $ admits a trailing line feed. +public static class ConceptKey +{ + public const int MaxLength = 80; + + private static readonly Regex Shape = new(@"^[a-z0-9-]{1,80}\z", RegexOptions.CultureInvariant); + + public static bool IsValid(string? value) => value is not null && Shape.IsMatch(value); +} diff --git a/src/Bifrost.Web/Features/ConceptVotes/ConceptSubjectRegistry.cs b/src/Bifrost.Web/Features/ConceptVotes/ConceptSubjectRegistry.cs new file mode 100644 index 0000000..ba4d171 --- /dev/null +++ b/src/Bifrost.Web/Features/ConceptVotes/ConceptSubjectRegistry.cs @@ -0,0 +1,225 @@ +using System.Text.Json; +using Bifrost.Web.Data; +using Bifrost.Web.Domain; +using Bifrost.Web.Features.Shared; +using Microsoft.EntityFrameworkCore; + +namespace Bifrost.Web.Features.ConceptVotes; + +public enum SubjectChange +{ + Add, + Rename, + Unchanged, + Revive, + Absent, + Retire +} + +public sealed record ManifestShip(string Key, string Title); + +public sealed record SubjectPlanEntry(SubjectChange Change, string Key, string Title); + +public sealed record SubjectPlan(string Collection, IReadOnlyList Entries); + +// A refusal names the field and a code, never the offending value. +public sealed class ManifestRefusedException(string field, string code) + : Exception($"{field}: {code}") +{ + public string Field { get; } = field; + + public string Code { get; } = code; +} + +// The only writer of ConceptSubject: decides which concept keys exist, their titles and their retirement. +public sealed class ConceptSubjectRegistry( + BifrostDbContext dbContext, + AuditTrailService auditTrail, + TimeProvider timeProvider) +{ + public const int MaxTitleLength = 240; + + // The Mill manifest is JSON from outside the Verse. Only ships[].id and ships[].name are read. + public static IReadOnlyList ParseManifest(string json) + { + JsonDocument document; + try + { + document = JsonDocument.Parse(json); + } + catch (JsonException) + { + throw new ManifestRefusedException("manifest", "NotJson"); + } + + using (document) + { + if (document.RootElement.ValueKind != JsonValueKind.Object || + !document.RootElement.TryGetProperty("ships", out var ships) || + ships.ValueKind != JsonValueKind.Array) + { + throw new ManifestRefusedException("ships", "Missing"); + } + + var result = new List(); + var seen = new HashSet(StringComparer.Ordinal); + foreach (var ship in ships.EnumerateArray()) + { + var at = $"ships[{result.Count}]"; + var key = ReadString(ship, "id", at); + var title = ReadString(ship, "name", at); + if (!ConceptKey.IsValid(key)) + { + throw new ManifestRefusedException($"{at}.id", "InvalidKey"); + } + + if (!seen.Add(key)) + { + throw new ManifestRefusedException($"{at}.id", "Duplicate"); + } + + if (string.IsNullOrWhiteSpace(title) || title.Any(char.IsControl)) + { + throw new ManifestRefusedException($"{at}.name", "InvalidTitle"); + } + + if (title.Length > MaxTitleLength) + { + throw new ManifestRefusedException($"{at}.name", "TooLong"); + } + + result.Add(new ManifestShip(key, title)); + } + + return result; + } + } + + public async Task PlanAsync( + string collection, + IReadOnlyList ships, + IReadOnlyCollection retire, + CancellationToken cancellationToken) + { + if (!ConceptKey.IsValid(collection)) + { + throw new ManifestRefusedException("collection", "InvalidKey"); + } + + var named = new HashSet(StringComparer.Ordinal); + foreach (var key in retire) + { + if (!ConceptKey.IsValid(key)) + { + throw new ManifestRefusedException("retire", "InvalidKey"); + } + + named.Add(key); + } + + var registered = await dbContext.ConceptSubjects.AsNoTracking() + .Where(x => x.Collection == collection) + .ToDictionaryAsync(x => x.Key, StringComparer.Ordinal, cancellationToken); + + var entries = new List(); + foreach (var ship in ships) + { + var change = !registered.TryGetValue(ship.Key, out var row) + ? SubjectChange.Add + : row.RetiredAtUtc is not null + ? SubjectChange.Revive + : row.Title != ship.Title ? SubjectChange.Rename : SubjectChange.Unchanged; + entries.Add(new SubjectPlanEntry(change, ship.Key, ship.Title)); + } + + var inManifest = ships.Select(x => x.Key).ToHashSet(StringComparer.Ordinal); + var absent = registered.Values + .Where(x => x.RetiredAtUtc is null && !inManifest.Contains(x.Key)) + .OrderBy(x => x.Key, StringComparer.Ordinal) + .ToList(); + foreach (var subject in absent) + { + entries.Add(new SubjectPlanEntry( + named.Remove(subject.Key) ? SubjectChange.Retire : SubjectChange.Absent, + subject.Key, + subject.Title)); + } + + // Whatever is still named is not a registered, live key missing from the manifest. + if (named.Count > 0) + { + throw new ManifestRefusedException("retire", "NotAbsent"); + } + + return new SubjectPlan(collection, entries); + } + + public async Task ApplyAsync( + string collection, + IReadOnlyList ships, + IReadOnlyCollection retire, + CancellationToken cancellationToken) + { + var plan = await PlanAsync(collection, ships, retire, cancellationToken); + var rows = await dbContext.ConceptSubjects + .Where(x => x.Collection == collection) + .ToDictionaryAsync(x => x.Key, StringComparer.Ordinal, cancellationToken); + + foreach (var entry in plan.Entries) + { + if (entry.Change is SubjectChange.Unchanged or SubjectChange.Absent) + { + continue; + } + + ConceptSubject subject; + if (entry.Change == SubjectChange.Add) + { + subject = new ConceptSubject + { + Collection = collection, + Key = entry.Key, + Title = entry.Title, + RegisteredAtUtc = timeProvider.GetUtcNow() + }; + dbContext.ConceptSubjects.Add(subject); + } + else + { + subject = rows[entry.Key]; + if (entry.Change == SubjectChange.Retire) + { + subject.RetiredAtUtc = timeProvider.GetUtcNow(); + } + else + { + subject.Title = entry.Title; + subject.RetiredAtUtc = null; + } + } + + // The audit write saves the subject change in the same SaveChanges. + await auditTrail.RecordAsync( + null, + nameof(ConceptSubject), + subject.Id, + entry.Change.ToString(), + $"actor=host-command collection={collection} key={entry.Key}", + cancellationToken); + } + + return plan; + } + + private static string ReadString(JsonElement ship, string name, string at) + { + if (ship.ValueKind != JsonValueKind.Object || !ship.TryGetProperty(name, out var value)) + { + throw new ManifestRefusedException($"{at}.{name}", "Missing"); + } + + return value.ValueKind == JsonValueKind.String + ? value.GetString()! + : throw new ManifestRefusedException($"{at}.{name}", "NotString"); + } +} diff --git a/src/Bifrost.Web/Program.cs b/src/Bifrost.Web/Program.cs index bec0271..8376009 100644 --- a/src/Bifrost.Web/Program.cs +++ b/src/Bifrost.Web/Program.cs @@ -24,6 +24,23 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; using System.Net; +// Host command: registers Mill concept subjects with configuration and the database only; no web app, no migrations. +if (args.Length > 0 && args[0] == "concepts") +{ + var host = Host.CreateApplicationBuilder([]); + host.Services.AddDbContext(options => options.UseNpgsql(BifrostConnectionString(host.Configuration))); + host.Services.AddSingleton(TimeProvider.System); + host.Services.AddScoped(); + host.Services.AddScoped(); + using var commandHost = host.Build(); + Environment.ExitCode = await ConceptCommand.RunAsync(args[1..], commandHost.Services, Console.Out); + return; +} + +static string BifrostConnectionString(IConfiguration configuration) => + configuration.GetConnectionString("Bifrost") + ?? "Host=127.0.0.1;Database=bifrost;Username=postgres;Password=postgres"; + var builder = WebApplication.CreateBuilder(args); builder.Services @@ -70,10 +87,7 @@ builder.Services.ConfigureHttpJsonOptions(options => builder.Services.AddDbContext(options => { - var connectionString = builder.Configuration.GetConnectionString("Bifrost") - ?? "Host=127.0.0.1;Database=bifrost;Username=postgres;Password=postgres"; - - options.UseNpgsql(connectionString); + options.UseNpgsql(BifrostConnectionString(builder.Configuration)); }); builder.Services.AddHttpLogging(options => @@ -103,6 +117,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddSingleton(); diff --git a/tests/Bifrost.Web.Tests/Bifrost.Web.Tests.csproj b/tests/Bifrost.Web.Tests/Bifrost.Web.Tests.csproj index 355d11c..77670c1 100644 --- a/tests/Bifrost.Web.Tests/Bifrost.Web.Tests.csproj +++ b/tests/Bifrost.Web.Tests/Bifrost.Web.Tests.csproj @@ -20,6 +20,10 @@ + + + + diff --git a/tests/Bifrost.Web.Tests/ConceptSubjectRegistryTests.cs b/tests/Bifrost.Web.Tests/ConceptSubjectRegistryTests.cs new file mode 100644 index 0000000..cc26700 --- /dev/null +++ b/tests/Bifrost.Web.Tests/ConceptSubjectRegistryTests.cs @@ -0,0 +1,374 @@ +using System.Net; +using Bifrost.Web.Configuration; +using Bifrost.Web.Data; +using Bifrost.Web.Domain; +using Bifrost.Web.Features.ConceptVotes; +using Bifrost.Web.Features.Shared; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace Bifrost.Web.Tests; + +public sealed class ConceptSubjectRegistryTests +{ + private const string Collection = "aetheria-concept-mill"; + private static readonly string[] None = []; + + private static string FixturePath => Path.Combine(AppContext.BaseDirectory, "Fixtures", "mill-three-ships.json"); + + private static IReadOnlyList Fixture() => + ConceptSubjectRegistry.ParseManifest(File.ReadAllText(FixturePath)); + + [Fact] + public async Task Rerun_of_the_same_manifest_is_a_no_op() + { + using var world = new World(); + + var first = await world.Apply(Fixture()); + Assert.All(first.Entries, e => Assert.Equal(SubjectChange.Add, e.Change)); + Assert.Equal(3, await world.Fresh(db => db.AuditEvents.CountAsync(x => x.EntityType == nameof(ConceptSubject)))); + + var before = await world.Fresh(db => db.ConceptSubjects.AsNoTracking().OrderBy(x => x.Key).ToListAsync()); + world.Clock.Advance(TimeSpan.FromHours(1)); + var second = await world.Apply(Fixture()); + + Assert.All(second.Entries, e => Assert.Equal(SubjectChange.Unchanged, e.Change)); + Assert.Equal(3, await world.Fresh(db => db.AuditEvents.CountAsync())); + var after = await world.Fresh(db => db.ConceptSubjects.AsNoTracking().OrderBy(x => x.Key).ToListAsync()); + Assert.Equal(before.Select(x => (x.Id, x.Title, x.RegisteredAtUtc, x.RetiredAtUtc)), + after.Select(x => (x.Id, x.Title, x.RegisteredAtUtc, x.RetiredAtUtc))); + } + + [Fact] + public async Task Apply_writes_one_host_command_audit_event_per_change_and_none_for_a_plan() + { + using var world = new World(); + + var plan = await world.Plan(Fixture()); + Assert.All(plan.Entries, e => Assert.Equal(SubjectChange.Add, e.Change)); + Assert.Empty(await world.Fresh(db => db.ConceptSubjects.ToListAsync())); + Assert.Empty(await world.Fresh(db => db.AuditEvents.ToListAsync())); + + await world.Apply(Fixture()); + + var events = await world.Fresh(db => db.AuditEvents.ToListAsync()); + var subjectIds = (await world.Fresh(db => db.ConceptSubjects.ToListAsync())).Select(x => x.Id.ToString()).ToHashSet(); + Assert.Equal(3, events.Count); + Assert.All(events, e => + { + Assert.Equal(nameof(ConceptSubject), e.EntityType); + Assert.Equal("Add", e.Action); + Assert.Null(e.ActorUserAccountId); + Assert.Contains("actor=host-command", e.Detail); + Assert.Contains(e.EntityId, subjectIds); + }); + } + + [Fact] + public async Task Rename_changes_title_and_keeps_votes() + { + using var world = new World(); + await world.Apply(Fixture()); + await world.Cast("bison", ConceptStance.Up); + await world.Cast("kestrel", ConceptStance.Down); + + var renamed = Fixture().Select(s => s.Key == "bison" ? s with { Title = "Bison Prime" } : s).ToList(); + var plan = await world.Apply(renamed); + + Assert.Equal(SubjectChange.Rename, plan.Entries.Single(e => e.Key == "bison").Change); + Assert.Equal(2, plan.Entries.Count(e => e.Change == SubjectChange.Unchanged)); + var subject = await world.Fresh(db => db.ConceptSubjects.AsNoTracking().SingleAsync(x => x.Key == "bison")); + Assert.Equal("Bison Prime", subject.Title); + Assert.Null(subject.RetiredAtUtc); + Assert.Equal(2, await world.Fresh(db => db.ConceptAppraisals.CountAsync())); + var view = await world.Fresh(db => world.Appraisals(db).GetSubjectAsync(Collection, "bison", null, default)); + Assert.Equal("Bison Prime", view!.Title); + Assert.Equal(new ConceptTally(1, 0, new StanceCounts(0, 0)), view.Tally); + Assert.Equal(1, await world.Fresh(db => db.AuditEvents.CountAsync(x => x.Action == "Rename"))); + } + + [Fact] + public async Task Absent_key_is_retired_only_when_named() + { + using var world = new World(); + await world.Apply(Fixture()); + await world.Cast("kestrel", ConceptStance.Up); + var smaller = Fixture().Where(s => s.Key != "kestrel").ToList(); + + var unnamed = await world.Apply(smaller); + Assert.Equal(SubjectChange.Absent, unnamed.Entries.Single(e => e.Key == "kestrel").Change); + Assert.Null((await world.Fresh(db => db.ConceptSubjects.AsNoTracking().SingleAsync(x => x.Key == "kestrel"))).RetiredAtUtc); + Assert.Contains("kestrel", (await world.Fresh(db => world.Appraisals(db).GetTalliesAsync(Collection, default))).Keys); + + var named = await world.Apply(smaller, ["kestrel"]); + Assert.Equal(SubjectChange.Retire, named.Entries.Single(e => e.Key == "kestrel").Change); + var retired = await world.Fresh(db => db.ConceptSubjects.AsNoTracking().SingleAsync(x => x.Key == "kestrel")); + Assert.NotNull(retired.RetiredAtUtc); + Assert.Equal(1, await world.Fresh(db => db.ConceptAppraisals.CountAsync())); + Assert.DoesNotContain("kestrel", (await world.Fresh(db => world.Appraisals(db).GetTalliesAsync(Collection, default))).Keys); + Assert.Equal(CastResult.RetiredSubject, await world.Cast("kestrel", ConceptStance.Down)); + Assert.Equal(1, await world.Fresh(db => db.AuditEvents.CountAsync(x => x.Action == "Retire"))); + } + + [Theory] + [InlineData("bison")] + [InlineData("never-registered")] + public async Task Retire_naming_a_key_that_is_not_registered_live_and_absent_is_refused_whole(string named) + { + using var world = new World(); + await world.Apply(Fixture()); + var renamed = Fixture().Select(s => s.Key == "kestrel" ? s with { Title = "Kestrel II" } : s).ToList(); + + var refusal = await Assert.ThrowsAsync(() => world.Apply(renamed, [named])); + + Assert.Equal(("retire", "NotAbsent"), (refusal.Field, refusal.Code)); + Assert.DoesNotContain(named, refusal.Message); + Assert.Equal("Kestrel", (await world.Fresh(db => db.ConceptSubjects.AsNoTracking().SingleAsync(x => x.Key == "kestrel"))).Title); + Assert.Equal(3, await world.Fresh(db => db.AuditEvents.CountAsync())); + } + + [Fact] + public async Task Naming_an_already_retired_key_is_refused() + { + using var world = new World(); + await world.Apply(Fixture()); + var smaller = Fixture().Where(s => s.Key != "kestrel").ToList(); + await world.Apply(smaller, ["kestrel"]); + + await Assert.ThrowsAsync(() => world.Apply(smaller, ["kestrel"])); + } + + [Fact] + public async Task Re_registering_a_retired_key_revives_it() + { + using var world = new World(); + await world.Apply(Fixture()); + await world.Cast("kestrel", ConceptStance.Up); + await world.Apply(Fixture().Where(s => s.Key != "kestrel").ToList(), ["kestrel"]); + + var plan = await world.Apply(Fixture()); + + Assert.Equal(SubjectChange.Revive, plan.Entries.Single(e => e.Key == "kestrel").Change); + var subject = await world.Fresh(db => db.ConceptSubjects.AsNoTracking().SingleAsync(x => x.Key == "kestrel")); + Assert.Null(subject.RetiredAtUtc); + var tallies = await world.Fresh(db => world.Appraisals(db).GetTalliesAsync(Collection, default)); + Assert.Equal(new ConceptTally(1, 0, new StanceCounts(0, 0)), tallies["kestrel"]); + Assert.Equal(1, await world.Fresh(db => db.AuditEvents.CountAsync(x => x.Action == "Revive"))); + } + + [Theory] + [InlineData("""{"ships":[{"id":"a","name":"A"},{"id":"a","name":"B"}]}""", "ships[1].id", "Duplicate")] + [InlineData("""{"ships":[{"id":"Upper","name":"A"}]}""", "ships[0].id", "InvalidKey")] + [InlineData("""{"ships":[{"id":"a/b","name":"A"}]}""", "ships[0].id", "InvalidKey")] + [InlineData("""{"ships":[{"id":"ship-a\n","name":"A"}]}""", "ships[0].id", "InvalidKey")] + [InlineData("""{"ships":[{"id":"","name":"A"}]}""", "ships[0].id", "InvalidKey")] + [InlineData("""{"ships":[{"id":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","name":"A"}]}""", "ships[0].id", "InvalidKey")] + [InlineData("""{"ships":[{"id":"a"}]}""", "ships[0].name", "Missing")] + [InlineData("""{"ships":[{"id":"a","name":7}]}""", "ships[0].name", "NotString")] + [InlineData("""{"ships":[{"id":"a","name":" "}]}""", "ships[0].name", "InvalidTitle")] + [InlineData("""{"ships":[{"id":"a","name":"bad\ntitle"}]}""", "ships[0].name", "InvalidTitle")] + [InlineData("""{"ships":["a"]}""", "ships[0].id", "Missing")] + [InlineData("""{"fleet":[]}""", "ships", "Missing")] + [InlineData("not json", "manifest", "NotJson")] + public async Task Malformed_manifest_is_refused_whole(string json, string field, string code) + { + using var world = new World(); + var refusal = Assert.Throws(() => ConceptSubjectRegistry.ParseManifest(json)); + + Assert.Equal((field, code), (refusal.Field, refusal.Code)); + Assert.Empty(await world.Fresh(db => db.ConceptSubjects.ToListAsync())); + } + + [Fact] + public async Task A_bad_entry_after_good_ones_refuses_the_file_and_writes_no_row() + { + using var world = new World(); + var json = """{"ships":[{"id":"good","name":"Good"},{"id":"ship-a\n","name":"Bad"}]}"""; + + Assert.Throws(() => ConceptSubjectRegistry.ParseManifest(json)); + Assert.Empty(await world.Fresh(db => db.ConceptSubjects.ToListAsync())); + } + + [Fact] + public async Task Refusal_never_echoes_the_offending_value() + { + const string canary = "CANARY-secret/value"; + var json = $$"""{"ships":[{"id":"{{canary}}","name":"A"}]}"""; + + var refusal = Assert.Throws(() => ConceptSubjectRegistry.ParseManifest(json)); + Assert.DoesNotContain("CANARY", refusal.Message); + using var world = new World(); + var collection = await Assert.ThrowsAsync(() => world.Registry(world.NewDb()).PlanAsync(canary, [], None, default)); + Assert.DoesNotContain("CANARY", collection.Message); + var retire = await Assert.ThrowsAsync(() => world.Registry(world.NewDb()).PlanAsync(Collection, [], [canary], default)); + Assert.DoesNotContain("CANARY", retire.Message); + } + + [Theory] + [InlineData("ship-a\n")] + [InlineData("ship-a\r\n")] + [InlineData("ship-a ")] + public async Task The_key_grammar_rejects_a_trailing_line_feed_wherever_it_is_reused(string forged) + { + using var world = new World(); + await world.Apply(Fixture()); + await world.Registry(world.NewDb()).ApplyAsync(Collection, [new ManifestShip("ship-a", "A")], None, default); + + Assert.False(ConceptKey.IsValid(forged)); + Assert.False(ConceptKey.IsValid("key\n")); + Assert.True(ConceptKey.IsValid("ship-a")); + Assert.Equal(CastResult.UnknownSubject, await world.Cast(forged, ConceptStance.Up)); + Assert.Null(await world.Fresh(db => world.Appraisals(db).GetSubjectAsync(Collection, forged, null, default))); + await Assert.ThrowsAsync(() => + world.Registry(world.NewDb()).PlanAsync(Collection + "\n", [], None, default)); + await Assert.ThrowsAsync(() => + world.Registry(world.NewDb()).PlanAsync(Collection, [], ["ship-a\n"], default)); + Assert.Empty(await world.Fresh(db => db.ConceptAppraisals.ToListAsync())); + } + + [Fact] + public async Task Host_command_plans_and_applies_with_exit_codes() + { + using var world = new World(); + var services = world.Services(); + + var plan = new StringWriter(); + Assert.Equal(ConceptCommand.Ok, await ConceptCommand.RunAsync(["plan", Collection, FixturePath], services, plan)); + Assert.Contains("add bison", plan.ToString()); + Assert.Contains("add ibis-mk2", plan.ToString()); + Assert.Empty(await world.Fresh(db => db.ConceptSubjects.ToListAsync())); + Assert.Empty(await world.Fresh(db => db.AuditEvents.ToListAsync())); + + var apply = new StringWriter(); + Assert.Equal(ConceptCommand.Ok, await ConceptCommand.RunAsync(["apply", Collection, FixturePath], services, apply)); + Assert.Equal(3, await world.Fresh(db => db.ConceptSubjects.CountAsync())); + + var again = new StringWriter(); + Assert.Equal(ConceptCommand.Ok, await ConceptCommand.RunAsync(["apply", Collection, FixturePath], services, again)); + Assert.Contains("unchanged bison", again.ToString()); + Assert.Equal(3, await world.Fresh(db => db.AuditEvents.CountAsync())); + } + + [Fact] + public async Task Host_command_retire_flag_retires_the_named_absent_key() + { + using var world = new World(); + var services = world.Services(); + await ConceptCommand.RunAsync(["apply", Collection, FixturePath], services, new StringWriter()); + var smaller = Path.Combine(world.Dir, "two.json"); + await File.WriteAllTextAsync(smaller, """{"ships":[{"id":"bison","name":"Bison"},{"id":"ibis-mk2","name":"Ibis Mk II"}]}"""); + + var output = new StringWriter(); + Assert.Equal(ConceptCommand.Ok, await ConceptCommand.RunAsync(["apply", Collection, smaller, "--retire", "kestrel"], services, output)); + + Assert.Contains("retire kestrel", output.ToString()); + Assert.NotNull((await world.Fresh(db => db.ConceptSubjects.AsNoTracking().SingleAsync(x => x.Key == "kestrel"))).RetiredAtUtc); + } + + [Fact] + public async Task Host_command_exits_non_zero_on_refusal_and_never_echoes_input() + { + using var world = new World(); + var services = world.Services(); + var bad = Path.Combine(world.Dir, "bad.json"); + await File.WriteAllTextAsync(bad, """{"ships":[{"id":"CANARY/Id","name":"A"}]}"""); + + var refused = new StringWriter(); + Assert.Equal(ConceptCommand.Refused, await ConceptCommand.RunAsync(["apply", Collection, bad], services, refused)); + Assert.Contains("ships[0].id", refused.ToString()); + Assert.DoesNotContain("CANARY", refused.ToString()); + + var missing = new StringWriter(); + Assert.Equal(ConceptCommand.Refused, await ConceptCommand.RunAsync(["apply", Collection, Path.Combine(world.Dir, "CANARY-missing.json")], services, missing)); + Assert.DoesNotContain("CANARY", missing.ToString()); + Assert.Empty(await world.Fresh(db => db.ConceptSubjects.ToListAsync())); + } + + [Theory] + [InlineData("frobnicate", "c", "m")] + [InlineData("plan", "c")] + [InlineData("apply", "c", "m", "--bogus", "x")] + [InlineData("apply", "c", "m", "--retire")] + public async Task Host_command_unknown_subcommand_or_shape_exits_2(params string[] args) + { + using var world = new World(); + + var output = new StringWriter(); + Assert.Equal(ConceptCommand.Usage, await ConceptCommand.RunAsync(args, world.Services(), output)); + Assert.StartsWith("usage:", output.ToString()); + Assert.Empty(await world.Fresh(db => db.ConceptSubjects.ToListAsync())); + Assert.Equal(ConceptCommand.Usage, await ConceptCommand.RunAsync([], world.Services(), new StringWriter())); + } + + private sealed class World : IDisposable + { + private readonly string _dbName = $"registry-{Guid.NewGuid():N}"; + + public World() + { + Dir = Path.Combine(Path.GetTempPath(), $"registry-{Guid.NewGuid():N}"); + Directory.CreateDirectory(Dir); + } + + public string Dir { get; } + + public ManualClock Clock { get; } = new(new DateTimeOffset(2026, 10, 10, 12, 0, 0, TimeSpan.Zero)); + + public BifrostDbContext NewDb() => + new(new DbContextOptionsBuilder().UseInMemoryDatabase(_dbName).Options); + + public ConceptSubjectRegistry Registry(BifrostDbContext db) => + new(db, new AuditTrailService(db, Clock), Clock); + + public ConceptAppraisalService Appraisals(BifrostDbContext db) => + new(db, new AuditTrailService(db, Clock), Clock, + Options.Create(new ConceptVoteOptions { NetworkTagSecret = "test-tag-secret" })); + + public async Task Apply(IReadOnlyList ships, string[]? retire = null) + { + await using var db = NewDb(); + return await Registry(db).ApplyAsync(Collection, ships, retire ?? None, default); + } + + public async Task Plan(IReadOnlyList ships) + { + await using var db = NewDb(); + return await Registry(db).PlanAsync(Collection, ships, None, default); + } + + public async Task Cast(string key, ConceptStance stance) + { + await using var db = NewDb(); + var voter = VoterIdentity.For(CurrentBifrostActor.Anonymous, "device-" + key + stance)!; + return await Appraisals(db).CastAsync(Collection, key, voter, stance, IPAddress.Parse("203.0.113.7"), default); + } + + public async Task Fresh(Func> read) + { + await using var db = NewDb(); + return await read(db); + } + + public IServiceProvider Services() + { + var services = new ServiceCollection(); + services.AddDbContext(o => o.UseInMemoryDatabase(_dbName)); + services.AddSingleton(Clock); + services.AddScoped(); + services.AddScoped(); + return services.BuildServiceProvider(); + } + + public void Dispose() => Directory.Delete(Dir, true); + } + + private sealed class ManualClock(DateTimeOffset start) : TimeProvider + { + private DateTimeOffset _now = start; + + public override DateTimeOffset GetUtcNow() => _now; + + public void Advance(TimeSpan by) => _now += by; + } +} diff --git a/tests/Bifrost.Web.Tests/Fixtures/mill-three-ships.json b/tests/Bifrost.Web.Tests/Fixtures/mill-three-ships.json new file mode 100644 index 0000000..bde47d0 --- /dev/null +++ b/tests/Bifrost.Web.Tests/Fixtures/mill-three-ships.json @@ -0,0 +1,9 @@ +{ + "sections": [], + "votes": {}, + "ships": [ + { "id": "bison", "name": "Bison", "code": "AU", "role": "Swarm escort" }, + { "id": "ibis-mk2", "name": "Ibis Mk II", "code": "AU" }, + { "id": "kestrel", "name": "Kestrel" } + ] +} From d13b808471866a16af83d7dc3a227ee772e2349a Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 19:59:51 +0200 Subject: [PATCH 10/97] Public concept tally export with one-origin CORS GET /public/concepts/{collection}/tallies returns per-live-subject up, down and member counts only, anonymous, Cache-Control public max-age=60. CORS policy ConceptTallies is built from ConceptVotes:AllowedOrigins, GET only, no credentials, required on this route alone. Production startup refuses an allowed origin that is not an exact https origin. Not yet built or tested when committed. Co-Authored-By: Claude Opus 5.5 --- .../Configuration/ConceptVoteOptions.cs | 5 + .../Shared/StartupConfigurationValidator.cs | 12 + src/Bifrost.Web/Program.cs | 30 ++ .../ConceptTallyExportTests.cs | 318 ++++++++++++++++++ tests/Bifrost.Web.Tests/HostTrustTests.cs | 28 +- 5 files changed, 391 insertions(+), 2 deletions(-) create mode 100644 tests/Bifrost.Web.Tests/ConceptTallyExportTests.cs diff --git a/src/Bifrost.Web/Configuration/ConceptVoteOptions.cs b/src/Bifrost.Web/Configuration/ConceptVoteOptions.cs index 624832c..da13c4a 100644 --- a/src/Bifrost.Web/Configuration/ConceptVoteOptions.cs +++ b/src/Bifrost.Web/Configuration/ConceptVoteOptions.cs @@ -19,4 +19,9 @@ public sealed class ConceptVoteOptions // /64 is bypassed by RFC 8981 privacy rotation and households can hold a /56 or /48; /56 is the practitioner // default weighed in the note, not a standard. Tune on observed abuse. public int IPv6PrefixLength { get; init; } = 56; + + // The origins whose pages may read the public tally export from a browser (CORS). Each entry is exactly + // https://host[:non-default-port], lower case, no path; empty allows none. StartupConfigurationValidator + // refuses anything else outside Development and Testing, because "*" would allow every origin. + public string[] AllowedOrigins { get; init; } = []; } diff --git a/src/Bifrost.Web/Features/Shared/StartupConfigurationValidator.cs b/src/Bifrost.Web/Features/Shared/StartupConfigurationValidator.cs index eef6d84..4d7cab9 100644 --- a/src/Bifrost.Web/Features/Shared/StartupConfigurationValidator.cs +++ b/src/Bifrost.Web/Features/Shared/StartupConfigurationValidator.cs @@ -91,10 +91,22 @@ public sealed class StartupConfigurationValidator( failures.Add($"ConceptVotes:IPv6PrefixLength must be between 0 and {NetworkPrefix.IPv6Bits}."); } + if (!conceptVotes.AllowedOrigins.All(IsExactHttpsOrigin)) + { + failures.Add("ConceptVotes:AllowedOrigins entries must each be exactly https://host with no path, wildcard or trailing slash."); + } + if (failures.Count > 0) { throw new InvalidOperationException( "Bifrost startup configuration is incomplete: " + string.Join(" ", failures)); } } + + // The authority form is the canonical spelling, so a wildcard, path, query, user info, upper-case host or + // default port differs from it and is refused. + private static bool IsExactHttpsOrigin(string? origin) => + Uri.TryCreate(origin, UriKind.Absolute, out var uri) && + uri.Scheme == Uri.UriSchemeHttps && + string.Equals(uri.GetLeftPart(UriPartial.Authority), origin, StringComparison.Ordinal); } diff --git a/src/Bifrost.Web/Program.cs b/src/Bifrost.Web/Program.cs index bec0271..a210489 100644 --- a/src/Bifrost.Web/Program.cs +++ b/src/Bifrost.Web/Program.cs @@ -17,6 +17,7 @@ using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authentication.OAuth; using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Cors.Infrastructure; using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.HttpOverrides; using Microsoft.AspNetCore.HttpLogging; @@ -205,6 +206,17 @@ builder.Services.AddAuthorization(options => }); }); +// The one cross-origin surface: the public tally export, read by browsers on the configured origins only. +// No credentials, GET only; every other route carries no CORS metadata and gets no CORS headers. +const string ConceptTalliesCorsPolicy = "ConceptTallies"; +builder.Services.AddCors(); +builder.Services + .AddOptions() + .Configure>((cors, conceptVotes) => + cors.AddPolicy( + ConceptTalliesCorsPolicy, + policy => policy.WithOrigins(conceptVotes.Value.AllowedOrigins).WithMethods("GET"))); + var app = builder.Build(); var appJsonSerializerOptions = CreateAppJsonSerializerOptions(); @@ -228,6 +240,7 @@ app.UseHttpLogging(); app.UseHttpsRedirection(); app.UseStaticFiles(); app.UseRouting(); +app.UseCors(); app.UseAuthentication(); app.UseMiddleware(); app.UseAuthorization(); @@ -244,6 +257,23 @@ app.MapGet("/readyz", async (ReadinessService readinessService, CancellationToke statusCode: StatusCodes.Status503ServiceUnavailable); }).AllowAnonymous(); +// Counts only, per live subject: ConceptTally carries no voter key, account id or network tag. +app.MapGet("/public/concepts/{collection}/tallies", async ( + string collection, + ConceptAppraisalService appraisals, + HttpResponse response, + CancellationToken cancellationToken) => +{ + var tallies = await appraisals.GetTalliesAsync(collection, cancellationToken); + if (tallies.Count == 0) + { + return Results.NotFound(); + } + + response.Headers.CacheControl = "public, max-age=60"; + return Results.Json(tallies); +}).AllowAnonymous().RequireCors(ConceptTalliesCorsPolicy); + app.MapPost("/github/webhooks", async ( HttpRequest request, GitHubWebhookService gitHubWebhookService, diff --git a/tests/Bifrost.Web.Tests/ConceptTallyExportTests.cs b/tests/Bifrost.Web.Tests/ConceptTallyExportTests.cs new file mode 100644 index 0000000..cd7b40c --- /dev/null +++ b/tests/Bifrost.Web.Tests/ConceptTallyExportTests.cs @@ -0,0 +1,318 @@ +using System.Net; +using System.Text.Json; +using Bifrost.Web.Data; +using Bifrost.Web.Domain; +using Bifrost.Web.Features.ConceptVotes; +using Bifrost.Web.Features.Shared; +using Bifrost.Web.Tests.Support; +using Microsoft.AspNetCore.Hosting; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace Bifrost.Web.Tests; + +public sealed class ConceptTallyExportTests : IClassFixture +{ + private const string AllowedOrigin = "https://aetheria.gamecult.org"; + private const string Client = "203.0.113.7"; + + private readonly ExportFactory _factory; + + public ConceptTallyExportTests(ExportFactory factory) + { + _factory = factory; + } + + [Fact] + public async Task Anonymous_request_gets_the_public_counts() + { + var collection = NewCollection(); + await AddSubject(collection, "alpha"); + await AddSubject(collection, "beta"); + await AddSubject(collection, "untouched"); + await Cast(collection, "alpha", DeviceVoter("dev-one"), ConceptStance.Up); + await Cast(collection, "alpha", DeviceVoter("dev-two"), ConceptStance.Up); + await Cast(collection, "alpha", DeviceVoter("dev-three"), ConceptStance.Down); + await Cast(collection, "alpha", await AccountVoter(MembershipStatus.Active), ConceptStance.Up); + await Cast(collection, "alpha", await AccountVoter(MembershipStatus.Authenticated), ConceptStance.Down); + await Cast(collection, "beta", DeviceVoter("dev-one"), ConceptStance.Down); + + using var response = await Get(collection); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("application/json", response.Content.Headers.ContentType?.MediaType); + var body = JsonDocument.Parse(await response.Content.ReadAsStringAsync()).RootElement; + Assert.Equal(["alpha", "beta", "untouched"], body.EnumerateObject().Select(p => p.Name).Order().ToArray()); + AssertCounts(body.GetProperty("alpha"), up: 3, down: 2, memberUp: 1, memberDown: 0); + AssertCounts(body.GetProperty("beta"), up: 0, down: 1, memberUp: 0, memberDown: 0); + AssertCounts(body.GetProperty("untouched"), up: 0, down: 0, memberUp: 0, memberDown: 0); + } + + [Fact] + public async Task Payload_carries_no_voter_identity() + { + var collection = NewCollection(); + await AddSubject(collection, "alpha"); + var account = await AccountVoter(MembershipStatus.Authenticated); + var member = await AccountVoter(MembershipStatus.Active); + await Cast(collection, "alpha", DeviceVoter("canary-device-id-9c2e"), ConceptStance.Up); + await Cast(collection, "alpha", account, ConceptStance.Down); + await Cast(collection, "alpha", member, ConceptStance.Up); + + using var response = await Get(collection); + + var text = await response.Content.ReadAsStringAsync(); + var alpha = JsonDocument.Parse(text).RootElement.GetProperty("alpha"); + Assert.Equal(["down", "members", "up"], alpha.EnumerateObject().Select(p => p.Name).Order().ToArray()); + Assert.Equal(["down", "up"], alpha.GetProperty("members").EnumerateObject().Select(p => p.Name).Order().ToArray()); + + string[] tags; + using (var scope = _factory.Services.CreateScope()) + { + tags = await scope.ServiceProvider.GetRequiredService() + .ConceptAppraisals.Select(x => x.NetworkTag).Distinct().ToArrayAsync(); + } + + Assert.All(tags, tag => Assert.NotEmpty(tag)); + var forbidden = new List { "canary-device-id-9c2e", "device:", "account:", "voterkey", "networktag", "useraccount" }; + forbidden.AddRange(tags); + foreach (var voter in new[] { account, member }) + { + forbidden.Add(voter.UserAccountId!.Value.ToString("N")); + forbidden.Add(voter.UserAccountId!.Value.ToString("D")); + } + + foreach (var value in forbidden) + { + Assert.DoesNotContain(value, text, StringComparison.OrdinalIgnoreCase); + } + } + + [Fact] + public async Task Cors_allows_only_the_configured_origin_without_credentials() + { + var collection = NewCollection(); + await AddSubject(collection, "alpha"); + + using var allowed = await Get(collection, AllowedOrigin); + Assert.Equal(HttpStatusCode.OK, allowed.StatusCode); + Assert.Equal([AllowedOrigin], allowed.Headers.GetValues("Access-Control-Allow-Origin").ToArray()); + Assert.False(allowed.Headers.Contains("Access-Control-Allow-Credentials")); + + using var otherHost = await Get(collection, "https://evil.example"); + Assert.False(otherHost.Headers.Contains("Access-Control-Allow-Origin")); + Assert.False(otherHost.Headers.Contains("Access-Control-Allow-Credentials")); + + using var preflightGet = await Preflight(collection, AllowedOrigin, "GET"); + Assert.Equal([AllowedOrigin], preflightGet.Headers.GetValues("Access-Control-Allow-Origin").ToArray()); + Assert.False(preflightGet.Headers.Contains("Access-Control-Allow-Credentials")); + + using var preflightPost = await Preflight(collection, AllowedOrigin, "POST"); + Assert.False(preflightPost.Headers.Contains("Access-Control-Allow-Origin")); + Assert.False(preflightPost.Headers.Contains("Access-Control-Allow-Methods")); + } + + [Theory] + [InlineData("https://aetheria.gamecult.org.evil.example")] + [InlineData("https://aetheria.gamecult.orgx")] + [InlineData("https://evilaetheria.gamecult.org")] + [InlineData("https://www.aetheria.gamecult.org")] + [InlineData("HTTPS://AETHERIA.GAMECULT.ORG")] + [InlineData("https://Aetheria.gamecult.org")] + [InlineData("https://aetheria.gamecult.org/")] + [InlineData("https://aetheria.gamecult.org:8443")] + [InlineData("http://aetheria.gamecult.org")] + [InlineData("null")] + [InlineData("*")] + [InlineData("")] + public async Task Cors_refuses_origins_that_only_resemble_the_configured_one(string origin) + { + var collection = NewCollection(); + await AddSubject(collection, "alpha"); + + using var response = await Get(collection, origin); + using var preflight = await Preflight(collection, origin, "GET"); + + Assert.False(response.Headers.Contains("Access-Control-Allow-Origin")); + Assert.False(preflight.Headers.Contains("Access-Control-Allow-Origin")); + } + + [Fact] + public async Task Other_routes_carry_no_cors_headers() + { + using var client = _factory.CreateClient(); + using var request = new HttpRequestMessage(HttpMethod.Get, "/healthz"); + request.Headers.Add("Origin", AllowedOrigin); + + using var response = await client.SendAsync(request); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.False(response.Headers.Contains("Access-Control-Allow-Origin")); + } + + [Fact] + public async Task Retired_subject_leaves_the_export() + { + var collection = NewCollection(); + await AddSubject(collection, "alpha"); + await AddSubject(collection, "gone"); + await Cast(collection, "gone", DeviceVoter("dev-one"), ConceptStance.Up); + await Retire(collection, "gone"); + + using var response = await Get(collection); + + var body = JsonDocument.Parse(await response.Content.ReadAsStringAsync()).RootElement; + Assert.Equal(["alpha"], body.EnumerateObject().Select(p => p.Name).ToArray()); + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + Assert.Single(db.ConceptAppraisals.Where(x => x.Subject!.Key == "gone" && x.Subject.Collection == collection)); + } + + [Fact] + public async Task Cache_control_is_public_sixty_seconds() + { + var collection = NewCollection(); + await AddSubject(collection, "alpha"); + + using var response = await Get(collection); + + var cache = response.Headers.CacheControl; + Assert.NotNull(cache); + Assert.True(cache!.Public); + Assert.Equal(TimeSpan.FromSeconds(60), cache.MaxAge); + } + + [Fact] + public async Task Unknown_collection_is_not_found() + { + using var response = await Get(NewCollection()); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task The_export_only_reads() + { + var collection = NewCollection(); + await AddSubject(collection, "alpha"); + + int rowsBefore, auditBefore; + using (var scope = _factory.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + rowsBefore = db.ConceptAppraisals.Count(); + auditBefore = db.AuditEvents.Count(); + } + + using var response = await Get(collection); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.False(response.Headers.Contains("Set-Cookie")); + using var after = _factory.Services.CreateScope(); + var dbAfter = after.ServiceProvider.GetRequiredService(); + Assert.Equal(rowsBefore, dbAfter.ConceptAppraisals.Count()); + Assert.Equal(auditBefore, dbAfter.AuditEvents.Count()); + } + + private async Task Get(string collection, string? origin = null) + { + using var client = _factory.CreateClient(); + using var request = new HttpRequestMessage(HttpMethod.Get, $"/public/concepts/{collection}/tallies"); + request.Headers.Add("X-Test-Anonymous", "true"); + if (origin is not null) + { + request.Headers.TryAddWithoutValidation("Origin", origin); + } + + return await client.SendAsync(request); + } + + private async Task Preflight(string collection, string origin, string method) + { + using var client = _factory.CreateClient(); + using var request = new HttpRequestMessage(HttpMethod.Options, $"/public/concepts/{collection}/tallies"); + request.Headers.Add("X-Test-Anonymous", "true"); + request.Headers.TryAddWithoutValidation("Origin", origin); + request.Headers.Add("Access-Control-Request-Method", method); + return await client.SendAsync(request); + } + + private static void AssertCounts(JsonElement tally, int up, int down, int memberUp, int memberDown) + { + Assert.Equal(up, tally.GetProperty("up").GetInt32()); + Assert.Equal(down, tally.GetProperty("down").GetInt32()); + Assert.Equal(memberUp, tally.GetProperty("members").GetProperty("up").GetInt32()); + Assert.Equal(memberDown, tally.GetProperty("members").GetProperty("down").GetInt32()); + } + + private static string NewCollection() => "c" + Guid.NewGuid().ToString("N"); + + private static VoterIdentity DeviceVoter(string deviceId) => + VoterIdentity.For(CurrentBifrostActor.Anonymous, deviceId)!; + + private async Task AccountVoter(MembershipStatus status) + { + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var now = DateTimeOffset.UtcNow; + var user = new UserAccount + { + DisplayName = "voter", + GitHubLogin = "voter", + NormalizedGitHubLogin = "voter", + CreatedAtUtc = now, + LastSeenAtUtc = now, + Membership = new Membership { Status = status, CreatedAtUtc = now } + }; + user.Membership.UserAccountId = user.Id; + db.UserAccounts.Add(user); + await db.SaveChangesAsync(); + return VoterIdentity.For(new CurrentBifrostActor(true, user), null)!; + } + + private async Task AddSubject(string collection, string key) + { + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + db.ConceptSubjects.Add(new ConceptSubject + { + Collection = collection, + Key = key, + Title = key, + RegisteredAtUtc = DateTimeOffset.UtcNow + }); + await db.SaveChangesAsync(); + } + + private async Task Retire(string collection, string key) + { + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var subject = await db.ConceptSubjects.SingleAsync(x => x.Collection == collection && x.Key == key); + subject.RetiredAtUtc = DateTimeOffset.UtcNow; + await db.SaveChangesAsync(); + } + + private async Task Cast(string collection, string key, VoterIdentity voter, ConceptStance stance) + { + using var scope = _factory.Services.CreateScope(); + var result = await scope.ServiceProvider.GetRequiredService() + .CastAsync(collection, key, voter, stance, IPAddress.Parse(Client), default); + Assert.Equal(CastResult.Recorded, result); + } + + public sealed class ExportFactory : TestWebApplicationFactory + { + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + base.ConfigureWebHost(builder); + builder.ConfigureAppConfiguration((_, configuration) => + configuration.AddInMemoryCollection(new Dictionary + { + ["ConceptVotes:AllowedOrigins:0"] = AllowedOrigin, + ["ConceptVotes:NetworkTagSecret"] = "export-test-tag-secret-0123456789-abcdef" + })); + } + } +} diff --git a/tests/Bifrost.Web.Tests/HostTrustTests.cs b/tests/Bifrost.Web.Tests/HostTrustTests.cs index 21ba350..7d55439 100644 --- a/tests/Bifrost.Web.Tests/HostTrustTests.cs +++ b/tests/Bifrost.Web.Tests/HostTrustTests.cs @@ -185,6 +185,28 @@ public sealed class HostTrustTests : IClassFixture Validate(keysPath: "/keys", ipv4Length: 0, ipv6Length: 0); } + [Theory] + [InlineData("*")] + [InlineData("")] + [InlineData("null")] + [InlineData("https://canary-origin-5d1c.example/")] + [InlineData("https://canary-origin-5d1c.example/path")] + [InlineData("https://Canary-Origin-5d1c.example")] + [InlineData("http://canary-origin-5d1c.example")] + [InlineData("canary-origin-5d1c.example")] + [InlineData("https://canary-origin-5d1c.example:443")] + [InlineData("https://user@canary-origin-5d1c.example")] + public void Production_refuses_an_allowed_origin_that_is_not_an_exact_https_origin_without_echoing_it(string origin) + { + var refusal = Assert.Throws( + () => Validate(keysPath: "/keys", allowedOrigins: ["https://good.example", origin])); + + Assert.Contains("ConceptVotes:AllowedOrigins", refusal.Message); + Assert.DoesNotContain("canary-origin-5d1c", refusal.Message, StringComparison.OrdinalIgnoreCase); + Validate(keysPath: "/keys", allowedOrigins: ["https://good.example", "https://other.example:8443"]); + Validate(keysPath: "/keys", allowedOrigins: []); + } + [Fact] public void Development_does_not_require_keys_path_or_tag_secret() { @@ -244,7 +266,8 @@ public sealed class HostTrustTests : IClassFixture string? connectionString = "Host=db", string? tagSecret = ValidTagSecret, int ipv4Length = 32, - int ipv6Length = 56) + int ipv6Length = 56, + string[]? allowedOrigins = null) { var values = new Dictionary { @@ -271,7 +294,8 @@ public sealed class HostTrustTests : IClassFixture { NetworkTagSecret = tagSecret ?? string.Empty, IPv4PrefixLength = ipv4Length, - IPv6PrefixLength = ipv6Length + IPv6PrefixLength = ipv6Length, + AllowedOrigins = allowedOrigins ?? [] })).Validate(); } From 34325e0efda6b4ffbfa57e017585ba489aaf7bdd Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 20:00:04 +0200 Subject: [PATCH 11/97] Registry tests: the helper voter's device id no longer embeds the subject key Commit b17436c does not pass: the forged-key test failed on an invalid device id. Co-Authored-By: Claude Opus 5.5 --- tests/Bifrost.Web.Tests/ConceptSubjectRegistryTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Bifrost.Web.Tests/ConceptSubjectRegistryTests.cs b/tests/Bifrost.Web.Tests/ConceptSubjectRegistryTests.cs index cc26700..bbf8dd3 100644 --- a/tests/Bifrost.Web.Tests/ConceptSubjectRegistryTests.cs +++ b/tests/Bifrost.Web.Tests/ConceptSubjectRegistryTests.cs @@ -340,7 +340,7 @@ public sealed class ConceptSubjectRegistryTests public async Task Cast(string key, ConceptStance stance) { await using var db = NewDb(); - var voter = VoterIdentity.For(CurrentBifrostActor.Anonymous, "device-" + key + stance)!; + var voter = VoterIdentity.For(CurrentBifrostActor.Anonymous, "device-" + stance)!; return await Appraisals(db).CastAsync(Collection, key, voter, stance, IPAddress.Parse("203.0.113.7"), default); } From cc00c64c9ee41f5710f408487890d34a5d175ca1 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 20:07:47 +0200 Subject: [PATCH 12/97] Startup validation: an allowed origin with user info is refused Co-Authored-By: Claude Opus 5.5 --- .../Features/Shared/StartupConfigurationValidator.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/Bifrost.Web/Features/Shared/StartupConfigurationValidator.cs b/src/Bifrost.Web/Features/Shared/StartupConfigurationValidator.cs index 4d7cab9..c511413 100644 --- a/src/Bifrost.Web/Features/Shared/StartupConfigurationValidator.cs +++ b/src/Bifrost.Web/Features/Shared/StartupConfigurationValidator.cs @@ -103,10 +103,11 @@ public sealed class StartupConfigurationValidator( } } - // The authority form is the canonical spelling, so a wildcard, path, query, user info, upper-case host or - // default port differs from it and is refused. + // The authority form is the canonical spelling, so a wildcard, path, query, upper-case host or default port + // differs from it and is refused; user info is part of the authority, so it is refused on its own. private static bool IsExactHttpsOrigin(string? origin) => Uri.TryCreate(origin, UriKind.Absolute, out var uri) && uri.Scheme == Uri.UriSchemeHttps && + uri.UserInfo.Length == 0 && string.Equals(uri.GetLeftPart(UriPartial.Authority), origin, StringComparison.Ordinal); } From 624cfd15675d090da97406a031df6a0fc48016a1 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 20:20:13 +0200 Subject: [PATCH 13/97] Concept tally export and its CORS policy move out of Program.cs One owner for the route and the policy, so Stryker can mutate them: it flags every mutant of Program.cs as a compile error (safe mode), which left the export untested by mutation. Program.cs keeps UseCors and the two registration calls. Co-Authored-By: Claude Opus 5.5 --- .../ConceptVotes/ConceptTallyExport.cs | 44 +++++++++++++++++++ src/Bifrost.Web/Program.cs | 29 +----------- 2 files changed, 46 insertions(+), 27 deletions(-) create mode 100644 src/Bifrost.Web/Features/ConceptVotes/ConceptTallyExport.cs diff --git a/src/Bifrost.Web/Features/ConceptVotes/ConceptTallyExport.cs b/src/Bifrost.Web/Features/ConceptVotes/ConceptTallyExport.cs new file mode 100644 index 0000000..864bcab --- /dev/null +++ b/src/Bifrost.Web/Features/ConceptVotes/ConceptTallyExport.cs @@ -0,0 +1,44 @@ +using Bifrost.Web.Configuration; +using Microsoft.AspNetCore.Cors.Infrastructure; +using Microsoft.Extensions.Options; + +namespace Bifrost.Web.Features.ConceptVotes; + +// The public tally export and the one cross-origin policy in Bifrost. It owns nothing: counts are derived by +// ConceptAppraisalService, origins come from ConceptVoteOptions. +public static class ConceptTallyExport +{ + public const string CorsPolicy = "ConceptTallies"; + + // GET only, no credentials, and required by the export route alone: every other route carries no CORS + // metadata and gets no CORS headers. + public static IServiceCollection AddConceptTallyCors(this IServiceCollection services) + { + services.AddCors(); + services + .AddOptions() + .Configure>((cors, conceptVotes) => + cors.AddPolicy( + CorsPolicy, + policy => policy.WithOrigins(conceptVotes.Value.AllowedOrigins).WithMethods("GET"))); + return services; + } + + // Counts only, per live subject: ConceptTally carries no voter key, account id or network tag. + public static IEndpointConventionBuilder MapConceptTallyExport(this IEndpointRouteBuilder app) => + app.MapGet("/public/concepts/{collection}/tallies", async ( + string collection, + ConceptAppraisalService appraisals, + HttpResponse response, + CancellationToken cancellationToken) => + { + var tallies = await appraisals.GetTalliesAsync(collection, cancellationToken); + if (tallies.Count == 0) + { + return Results.NotFound(); + } + + response.Headers.CacheControl = "public, max-age=60"; + return Results.Json(tallies); + }).AllowAnonymous().RequireCors(CorsPolicy); +} diff --git a/src/Bifrost.Web/Program.cs b/src/Bifrost.Web/Program.cs index a210489..eac1bb0 100644 --- a/src/Bifrost.Web/Program.cs +++ b/src/Bifrost.Web/Program.cs @@ -17,7 +17,6 @@ using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authentication.OAuth; using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Cors.Infrastructure; using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.HttpOverrides; using Microsoft.AspNetCore.HttpLogging; @@ -206,16 +205,7 @@ builder.Services.AddAuthorization(options => }); }); -// The one cross-origin surface: the public tally export, read by browsers on the configured origins only. -// No credentials, GET only; every other route carries no CORS metadata and gets no CORS headers. -const string ConceptTalliesCorsPolicy = "ConceptTallies"; -builder.Services.AddCors(); -builder.Services - .AddOptions() - .Configure>((cors, conceptVotes) => - cors.AddPolicy( - ConceptTalliesCorsPolicy, - policy => policy.WithOrigins(conceptVotes.Value.AllowedOrigins).WithMethods("GET"))); +builder.Services.AddConceptTallyCors(); var app = builder.Build(); var appJsonSerializerOptions = CreateAppJsonSerializerOptions(); @@ -257,22 +247,7 @@ app.MapGet("/readyz", async (ReadinessService readinessService, CancellationToke statusCode: StatusCodes.Status503ServiceUnavailable); }).AllowAnonymous(); -// Counts only, per live subject: ConceptTally carries no voter key, account id or network tag. -app.MapGet("/public/concepts/{collection}/tallies", async ( - string collection, - ConceptAppraisalService appraisals, - HttpResponse response, - CancellationToken cancellationToken) => -{ - var tallies = await appraisals.GetTalliesAsync(collection, cancellationToken); - if (tallies.Count == 0) - { - return Results.NotFound(); - } - - response.Headers.CacheControl = "public, max-age=60"; - return Results.Json(tallies); -}).AllowAnonymous().RequireCors(ConceptTalliesCorsPolicy); +app.MapConceptTallyExport(); app.MapPost("/github/webhooks", async ( HttpRequest request, From 46d92200e5306369a82bf1bb9d65f72e28c987eb Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 20:34:03 +0200 Subject: [PATCH 14/97] Registry: parse and read through small helpers so definite assignment survives mutation Stryker flagged ParseManifest, ReadString and RunAsync as compile errors in safe mode (out vars and try-assigned locals), leaving them unmutated. Behaviour unchanged. Co-Authored-By: Claude Opus 5.5 --- .../Features/ConceptVotes/ConceptCommand.cs | 24 ++-- .../ConceptVotes/ConceptSubjectRegistry.cs | 103 ++++++++++-------- 2 files changed, 71 insertions(+), 56 deletions(-) diff --git a/src/Bifrost.Web/Features/ConceptVotes/ConceptCommand.cs b/src/Bifrost.Web/Features/ConceptVotes/ConceptCommand.cs index 6ff5567..9edab61 100644 --- a/src/Bifrost.Web/Features/ConceptVotes/ConceptCommand.cs +++ b/src/Bifrost.Web/Features/ConceptVotes/ConceptCommand.cs @@ -23,17 +23,7 @@ public static class ConceptCommand string[] retire = hasRetire ? args[4].Split(',', StringSplitOptions.RemoveEmptyEntries) : []; try { - string json; - try - { - json = await File.ReadAllTextAsync(args[2]); - } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) - { - throw new ManifestRefusedException("manifest", "Unreadable"); - } - - var ships = ConceptSubjectRegistry.ParseManifest(json); + var ships = ConceptSubjectRegistry.ParseManifest(await ReadManifestAsync(args[2])); await using var scope = services.CreateAsyncScope(); var registry = scope.ServiceProvider.GetRequiredService(); var plan = args[0] == "apply" @@ -53,4 +43,16 @@ public static class ConceptCommand return Refused; } } + + private static async Task ReadManifestAsync(string path) + { + try + { + return await File.ReadAllTextAsync(path); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + throw new ManifestRefusedException("manifest", "Unreadable"); + } + } } diff --git a/src/Bifrost.Web/Features/ConceptVotes/ConceptSubjectRegistry.cs b/src/Bifrost.Web/Features/ConceptVotes/ConceptSubjectRegistry.cs index ba4d171..846f0d8 100644 --- a/src/Bifrost.Web/Features/ConceptVotes/ConceptSubjectRegistry.cs +++ b/src/Bifrost.Web/Features/ConceptVotes/ConceptSubjectRegistry.cs @@ -42,57 +42,65 @@ public sealed class ConceptSubjectRegistry( // The Mill manifest is JSON from outside the Verse. Only ships[].id and ships[].name are read. public static IReadOnlyList ParseManifest(string json) { - JsonDocument document; + using var document = ParseDocument(json); + if (document.RootElement.ValueKind != JsonValueKind.Object) + { + throw new ManifestRefusedException("ships", "Missing"); + } + + if (!document.RootElement.TryGetProperty("ships", out var ships)) + { + throw new ManifestRefusedException("ships", "Missing"); + } + + if (ships.ValueKind != JsonValueKind.Array) + { + throw new ManifestRefusedException("ships", "Missing"); + } + + var result = new List(); + var seen = new HashSet(StringComparer.Ordinal); + foreach (var ship in ships.EnumerateArray()) + { + var at = $"ships[{result.Count}]"; + var key = ReadString(ship, "id", at); + var title = ReadString(ship, "name", at); + if (!ConceptKey.IsValid(key)) + { + throw new ManifestRefusedException($"{at}.id", "InvalidKey"); + } + + if (!seen.Add(key)) + { + throw new ManifestRefusedException($"{at}.id", "Duplicate"); + } + + if (string.IsNullOrWhiteSpace(title) || title.Any(char.IsControl)) + { + throw new ManifestRefusedException($"{at}.name", "InvalidTitle"); + } + + if (title.Length > MaxTitleLength) + { + throw new ManifestRefusedException($"{at}.name", "TooLong"); + } + + result.Add(new ManifestShip(key, title)); + } + + return result; + } + + private static JsonDocument ParseDocument(string json) + { try { - document = JsonDocument.Parse(json); + return JsonDocument.Parse(json); } catch (JsonException) { throw new ManifestRefusedException("manifest", "NotJson"); } - - using (document) - { - if (document.RootElement.ValueKind != JsonValueKind.Object || - !document.RootElement.TryGetProperty("ships", out var ships) || - ships.ValueKind != JsonValueKind.Array) - { - throw new ManifestRefusedException("ships", "Missing"); - } - - var result = new List(); - var seen = new HashSet(StringComparer.Ordinal); - foreach (var ship in ships.EnumerateArray()) - { - var at = $"ships[{result.Count}]"; - var key = ReadString(ship, "id", at); - var title = ReadString(ship, "name", at); - if (!ConceptKey.IsValid(key)) - { - throw new ManifestRefusedException($"{at}.id", "InvalidKey"); - } - - if (!seen.Add(key)) - { - throw new ManifestRefusedException($"{at}.id", "Duplicate"); - } - - if (string.IsNullOrWhiteSpace(title) || title.Any(char.IsControl)) - { - throw new ManifestRefusedException($"{at}.name", "InvalidTitle"); - } - - if (title.Length > MaxTitleLength) - { - throw new ManifestRefusedException($"{at}.name", "TooLong"); - } - - result.Add(new ManifestShip(key, title)); - } - - return result; - } } public async Task PlanAsync( @@ -213,7 +221,12 @@ public sealed class ConceptSubjectRegistry( private static string ReadString(JsonElement ship, string name, string at) { - if (ship.ValueKind != JsonValueKind.Object || !ship.TryGetProperty(name, out var value)) + if (ship.ValueKind != JsonValueKind.Object) + { + throw new ManifestRefusedException($"{at}.{name}", "Missing"); + } + + if (!ship.TryGetProperty(name, out var value)) { throw new ManifestRefusedException($"{at}.{name}", "Missing"); } From a43f90b48d6e4ec44e4ab9d4c9bcdf8df5f22767 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 21:39:04 +0200 Subject: [PATCH 15/97] Registry: one throw for a missing or non-array ships; tests pin refusal codes, title bound and plan order Co-Authored-By: Claude Opus 5.5 --- .../ConceptVotes/ConceptSubjectRegistry.cs | 21 ++++++++------- .../ConceptSubjectRegistryTests.cs | 27 +++++++++++++++++++ 2 files changed, 38 insertions(+), 10 deletions(-) diff --git a/src/Bifrost.Web/Features/ConceptVotes/ConceptSubjectRegistry.cs b/src/Bifrost.Web/Features/ConceptVotes/ConceptSubjectRegistry.cs index 846f0d8..9c9bd3e 100644 --- a/src/Bifrost.Web/Features/ConceptVotes/ConceptSubjectRegistry.cs +++ b/src/Bifrost.Web/Features/ConceptVotes/ConceptSubjectRegistry.cs @@ -43,16 +43,7 @@ public sealed class ConceptSubjectRegistry( public static IReadOnlyList ParseManifest(string json) { using var document = ParseDocument(json); - if (document.RootElement.ValueKind != JsonValueKind.Object) - { - throw new ManifestRefusedException("ships", "Missing"); - } - - if (!document.RootElement.TryGetProperty("ships", out var ships)) - { - throw new ManifestRefusedException("ships", "Missing"); - } - + var ships = ShipsOf(document.RootElement); if (ships.ValueKind != JsonValueKind.Array) { throw new ManifestRefusedException("ships", "Missing"); @@ -91,6 +82,16 @@ public sealed class ConceptSubjectRegistry( return result; } + private static JsonElement ShipsOf(JsonElement root) + { + if (root.ValueKind != JsonValueKind.Object) + { + return default; + } + + return root.TryGetProperty("ships", out var ships) ? ships : default; + } + private static JsonDocument ParseDocument(string json) { try diff --git a/tests/Bifrost.Web.Tests/ConceptSubjectRegistryTests.cs b/tests/Bifrost.Web.Tests/ConceptSubjectRegistryTests.cs index bbf8dd3..804b1ba 100644 --- a/tests/Bifrost.Web.Tests/ConceptSubjectRegistryTests.cs +++ b/tests/Bifrost.Web.Tests/ConceptSubjectRegistryTests.cs @@ -170,6 +170,8 @@ public sealed class ConceptSubjectRegistryTests [InlineData("""{"ships":[{"id":"a","name":"bad\ntitle"}]}""", "ships[0].name", "InvalidTitle")] [InlineData("""{"ships":["a"]}""", "ships[0].id", "Missing")] [InlineData("""{"fleet":[]}""", "ships", "Missing")] + [InlineData("[]", "ships", "Missing")] + [InlineData("""{"ships":5}""", "ships", "Missing")] [InlineData("not json", "manifest", "NotJson")] public async Task Malformed_manifest_is_refused_whole(string json, string field, string code) { @@ -180,6 +182,28 @@ public sealed class ConceptSubjectRegistryTests Assert.Empty(await world.Fresh(db => db.ConceptSubjects.ToListAsync())); } + [Fact] + public void A_title_of_exactly_the_column_width_is_accepted_and_one_more_is_refused() + { + var fits = new string('t', ConceptSubjectRegistry.MaxTitleLength); + + Assert.Equal(fits, ConceptSubjectRegistry.ParseManifest($$"""{"ships":[{"id":"a","name":"{{fits}}"}]}""").Single().Title); + var refusal = Assert.Throws(() => + ConceptSubjectRegistry.ParseManifest($$"""{"ships":[{"id":"a","name":"{{fits}}x"}]}""")); + Assert.Equal(("ships[0].name", "TooLong"), (refusal.Field, refusal.Code)); + } + + [Fact] + public async Task Absent_keys_are_planned_in_key_order() + { + using var world = new World(); + await world.Apply(Fixture()); + + var plan = await world.Plan([new ManifestShip("zzz-new", "New")]); + + Assert.Equal(["zzz-new", "bison", "ibis-mk2", "kestrel"], plan.Entries.Select(e => e.Key)); + } + [Fact] public async Task A_bad_entry_after_good_ones_refuses_the_file_and_writes_no_row() { @@ -201,8 +225,10 @@ public sealed class ConceptSubjectRegistryTests using var world = new World(); var collection = await Assert.ThrowsAsync(() => world.Registry(world.NewDb()).PlanAsync(canary, [], None, default)); Assert.DoesNotContain("CANARY", collection.Message); + Assert.Equal(("collection", "InvalidKey"), (collection.Field, collection.Code)); var retire = await Assert.ThrowsAsync(() => world.Registry(world.NewDb()).PlanAsync(Collection, [], [canary], default)); Assert.DoesNotContain("CANARY", retire.Message); + Assert.Equal(("retire", "InvalidKey"), (retire.Field, retire.Code)); } [Theory] @@ -282,6 +308,7 @@ public sealed class ConceptSubjectRegistryTests var missing = new StringWriter(); Assert.Equal(ConceptCommand.Refused, await ConceptCommand.RunAsync(["apply", Collection, Path.Combine(world.Dir, "CANARY-missing.json")], services, missing)); Assert.DoesNotContain("CANARY", missing.ToString()); + Assert.Equal("refused manifest Unreadable", missing.ToString().Trim()); Assert.Empty(await world.Fresh(db => db.ConceptSubjects.ToListAsync())); } From 464da6bd507e74e32ad9b02cd652234b3d6a1ef3 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 21:46:51 +0200 Subject: [PATCH 16/97] Tally export: every response under the route varies by Origin UseConceptTallyCors replaces UseCors and appends Vary: Origin to every response under the export route, preflights and 404s included, keeping any Vary already set. A single-origin ASP.NET policy never adds it itself. Co-Authored-By: Claude Opus 5.5 --- .../ConceptVotes/ConceptTallyExport.cs | 33 +++++++++- src/Bifrost.Web/Program.cs | 2 +- .../ConceptTallyExportTests.cs | 65 +++++++++++++++++++ 3 files changed, 98 insertions(+), 2 deletions(-) diff --git a/src/Bifrost.Web/Features/ConceptVotes/ConceptTallyExport.cs b/src/Bifrost.Web/Features/ConceptVotes/ConceptTallyExport.cs index 864bcab..b5ac5ed 100644 --- a/src/Bifrost.Web/Features/ConceptVotes/ConceptTallyExport.cs +++ b/src/Bifrost.Web/Features/ConceptVotes/ConceptTallyExport.cs @@ -1,6 +1,7 @@ using Bifrost.Web.Configuration; using Microsoft.AspNetCore.Cors.Infrastructure; using Microsoft.Extensions.Options; +using Microsoft.Extensions.Primitives; namespace Bifrost.Web.Features.ConceptVotes; @@ -9,6 +10,7 @@ namespace Bifrost.Web.Features.ConceptVotes; public static class ConceptTallyExport { public const string CorsPolicy = "ConceptTallies"; + private const string RoutePrefix = "/public/concepts"; // GET only, no credentials, and required by the export route alone: every other route carries no CORS // metadata and gets no CORS headers. @@ -24,9 +26,38 @@ public static class ConceptTallyExport return services; } + // Replaces app.UseCors(): the one place the origin decision is made. Every response under the export route + // (200, 404, 304, preflight, refusal) varies by Origin, appended to any Vary already present, because the CORS + // headers differ by request Origin and a URL-keyed cache must not serve one variant to another. ASP.NET Core + // adds Vary: Origin itself only for policies of more than one origin; this policy has one. + public static IApplicationBuilder UseConceptTallyCors(this IApplicationBuilder app) + { + app.Use((context, next) => + { + if (context.Request.Path.StartsWithSegments(RoutePrefix)) + { + context.Response.OnStarting(() => + { + var vary = context.Response.Headers.Vary; + var varies = vary.Any(v => v is not null + && v.Split(',').Any(t => t.Trim() is "*" || t.Trim().Equals("Origin", StringComparison.OrdinalIgnoreCase))); + if (!varies) + { + context.Response.Headers.Vary = StringValues.Concat(vary, "Origin"); + } + + return Task.CompletedTask; + }); + } + + return next(context); + }); + return app.UseCors(); + } + // Counts only, per live subject: ConceptTally carries no voter key, account id or network tag. public static IEndpointConventionBuilder MapConceptTallyExport(this IEndpointRouteBuilder app) => - app.MapGet("/public/concepts/{collection}/tallies", async ( + app.MapGet(RoutePrefix + "/{collection}/tallies", async ( string collection, ConceptAppraisalService appraisals, HttpResponse response, diff --git a/src/Bifrost.Web/Program.cs b/src/Bifrost.Web/Program.cs index eac1bb0..bea0beb 100644 --- a/src/Bifrost.Web/Program.cs +++ b/src/Bifrost.Web/Program.cs @@ -230,7 +230,7 @@ app.UseHttpLogging(); app.UseHttpsRedirection(); app.UseStaticFiles(); app.UseRouting(); -app.UseCors(); +app.UseConceptTallyCors(); app.UseAuthentication(); app.UseMiddleware(); app.UseAuthorization(); diff --git a/tests/Bifrost.Web.Tests/ConceptTallyExportTests.cs b/tests/Bifrost.Web.Tests/ConceptTallyExportTests.cs index cd7b40c..6109c1a 100644 --- a/tests/Bifrost.Web.Tests/ConceptTallyExportTests.cs +++ b/tests/Bifrost.Web.Tests/ConceptTallyExportTests.cs @@ -5,6 +5,7 @@ using Bifrost.Web.Domain; using Bifrost.Web.Features.ConceptVotes; using Bifrost.Web.Features.Shared; using Bifrost.Web.Tests.Support; +using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; @@ -138,6 +139,51 @@ public sealed class ConceptTallyExportTests : IClassFixture + response.Headers.Vary.ToArray(); + [Fact] public async Task Other_routes_carry_no_cors_headers() { @@ -302,11 +348,30 @@ public sealed class ConceptTallyExportTests : IClassFixture Configure(Action next) => app => + { + app.Use((context, nextMiddleware) => + { + if (context.Request.Headers.TryGetValue("X-Test-Vary", out var prior)) + { + context.Response.Headers.Vary = prior; + } + + return nextMiddleware(context); + }); + next(app); + }; + } + public sealed class ExportFactory : TestWebApplicationFactory { protected override void ConfigureWebHost(IWebHostBuilder builder) { base.ConfigureWebHost(builder); + builder.ConfigureServices(services => services.AddTransient()); builder.ConfigureAppConfiguration((_, configuration) => configuration.AddInMemoryCollection(new Dictionary { From dcc34255206a111864f4a296b77815ead967fa71 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 22:19:52 +0200 Subject: [PATCH 17/97] Tally export Vary: plain append, no dedupe The dedupe branch only added equivalent mutants; a repeated Origin token is harmless. Co-Authored-By: Claude Opus 5.5 --- .../Features/ConceptVotes/ConceptTallyExport.cs | 13 +++---------- tests/Bifrost.Web.Tests/ConceptTallyExportTests.cs | 2 +- 2 files changed, 4 insertions(+), 11 deletions(-) diff --git a/src/Bifrost.Web/Features/ConceptVotes/ConceptTallyExport.cs b/src/Bifrost.Web/Features/ConceptVotes/ConceptTallyExport.cs index b5ac5ed..fb1f87f 100644 --- a/src/Bifrost.Web/Features/ConceptVotes/ConceptTallyExport.cs +++ b/src/Bifrost.Web/Features/ConceptVotes/ConceptTallyExport.cs @@ -1,7 +1,7 @@ using Bifrost.Web.Configuration; using Microsoft.AspNetCore.Cors.Infrastructure; using Microsoft.Extensions.Options; -using Microsoft.Extensions.Primitives; +using Microsoft.Net.Http.Headers; namespace Bifrost.Web.Features.ConceptVotes; @@ -29,7 +29,7 @@ public static class ConceptTallyExport // Replaces app.UseCors(): the one place the origin decision is made. Every response under the export route // (200, 404, 304, preflight, refusal) varies by Origin, appended to any Vary already present, because the CORS // headers differ by request Origin and a URL-keyed cache must not serve one variant to another. ASP.NET Core - // adds Vary: Origin itself only for policies of more than one origin; this policy has one. + // adds Vary: Origin itself only for policies of more than one origin; this policy has one, and a repeated token is harmless. public static IApplicationBuilder UseConceptTallyCors(this IApplicationBuilder app) { app.Use((context, next) => @@ -38,14 +38,7 @@ public static class ConceptTallyExport { context.Response.OnStarting(() => { - var vary = context.Response.Headers.Vary; - var varies = vary.Any(v => v is not null - && v.Split(',').Any(t => t.Trim() is "*" || t.Trim().Equals("Origin", StringComparison.OrdinalIgnoreCase))); - if (!varies) - { - context.Response.Headers.Vary = StringValues.Concat(vary, "Origin"); - } - + context.Response.Headers.Append(HeaderNames.Vary, "Origin"); return Task.CompletedTask; }); } diff --git a/tests/Bifrost.Web.Tests/ConceptTallyExportTests.cs b/tests/Bifrost.Web.Tests/ConceptTallyExportTests.cs index 6109c1a..bd6a72f 100644 --- a/tests/Bifrost.Web.Tests/ConceptTallyExportTests.cs +++ b/tests/Bifrost.Web.Tests/ConceptTallyExportTests.cs @@ -166,7 +166,7 @@ public sealed class ConceptTallyExportTests : IClassFixture Date: Sat, 10 Oct 2026 22:35:46 +0200 Subject: [PATCH 18/97] Registry tests: pin the refusal message shape Co-Authored-By: Claude Opus 5.5 --- tests/Bifrost.Web.Tests/ConceptSubjectRegistryTests.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/Bifrost.Web.Tests/ConceptSubjectRegistryTests.cs b/tests/Bifrost.Web.Tests/ConceptSubjectRegistryTests.cs index 804b1ba..92ab196 100644 --- a/tests/Bifrost.Web.Tests/ConceptSubjectRegistryTests.cs +++ b/tests/Bifrost.Web.Tests/ConceptSubjectRegistryTests.cs @@ -179,6 +179,7 @@ public sealed class ConceptSubjectRegistryTests var refusal = Assert.Throws(() => ConceptSubjectRegistry.ParseManifest(json)); Assert.Equal((field, code), (refusal.Field, refusal.Code)); + Assert.Equal($"{field}: {code}", refusal.Message); Assert.Empty(await world.Fresh(db => db.ConceptSubjects.ToListAsync())); } From af3b712b266d981fc38cf079775a8e0d953e340b Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 22:48:04 +0200 Subject: [PATCH 19/97] VoterKeyResolver: the one reader and writer of the bifrost_voter device cookie Co-Authored-By: Claude Opus 5.5 --- .../Configuration/ConceptVoteOptions.cs | 4 ++ .../Features/ConceptVotes/VoterKeyResolver.cs | 55 +++++++++++++++++++ src/Bifrost.Web/Program.cs | 2 + 3 files changed, 61 insertions(+) create mode 100644 src/Bifrost.Web/Features/ConceptVotes/VoterKeyResolver.cs diff --git a/src/Bifrost.Web/Configuration/ConceptVoteOptions.cs b/src/Bifrost.Web/Configuration/ConceptVoteOptions.cs index da13c4a..a5e0781 100644 --- a/src/Bifrost.Web/Configuration/ConceptVoteOptions.cs +++ b/src/Bifrost.Web/Configuration/ConceptVoteOptions.cs @@ -24,4 +24,8 @@ public sealed class ConceptVoteOptions // https://host[:non-default-port], lower case, no path; empty allows none. StartupConfigurationValidator // refuses anything else outside Development and Testing, because "*" would allow every origin. public string[] AllowedOrigins { get; init; } = []; + + // The Mill page the vote page links back to; the concept's key is appended as the anchor. Production is the + // Aetheria site's page, so a deploy overrides nothing unless the page moves (ConceptVotes__MillPageUrl). + public string MillPageUrl { get; init; } = "https://aetheria.gamecult.org/Game-Design/Concept-Mill"; } diff --git a/src/Bifrost.Web/Features/ConceptVotes/VoterKeyResolver.cs b/src/Bifrost.Web/Features/ConceptVotes/VoterKeyResolver.cs new file mode 100644 index 0000000..ce9fbec --- /dev/null +++ b/src/Bifrost.Web/Features/ConceptVotes/VoterKeyResolver.cs @@ -0,0 +1,55 @@ +using System.Security.Cryptography; +using Microsoft.AspNetCore.DataProtection; + +namespace Bifrost.Web.Features.ConceptVotes; + +// The only reader and writer of the bifrost_voter cookie. It supplies a device id and nothing else: VoterIdentity.For +// decides the voter key and class. The cookie holds a protected random id, never a count, class or account. +public sealed class VoterKeyResolver(IDataProtectionProvider dataProtection) +{ + public const string CookieName = "bifrost_voter"; + public const string CookiePath = "/mill"; + public static readonly TimeSpan CookieLifetime = TimeSpan.FromDays(365); + + private const string Purpose = "Bifrost.ConceptVotes.DeviceToken.v1"; + + private readonly IDataProtector protector = dataProtection.CreateProtector(Purpose); + + // The device id, or null when there is no cookie or it does not unprotect under this key ring and purpose + // (tampered, truncated, a bare id, or minted elsewhere). Nothing about a refused value is kept or reported. + public string? TryRead(HttpContext context) + { + if (!context.Request.Cookies.TryGetValue(CookieName, out var token) || string.IsNullOrEmpty(token)) + { + return null; + } + + try + { + return protector.Unprotect(token); + } + catch (CryptographicException) + { + return null; + } + } + + // Sets a fresh device cookie on this response and returns its device id. Called only from a cast. + public string Mint(HttpContext context) + { + var deviceId = Guid.NewGuid().ToString("N"); + context.Response.Cookies.Append( + CookieName, + protector.Protect(deviceId), + new CookieOptions + { + HttpOnly = true, + Secure = true, + SameSite = SameSiteMode.Lax, + Path = CookiePath, + MaxAge = CookieLifetime, + IsEssential = true + }); + return deviceId; + } +} diff --git a/src/Bifrost.Web/Program.cs b/src/Bifrost.Web/Program.cs index bea0beb..2101abb 100644 --- a/src/Bifrost.Web/Program.cs +++ b/src/Bifrost.Web/Program.cs @@ -62,6 +62,7 @@ builder.Services.AddRazorPages(options => { options.Conventions.AuthorizePage("/Membership/Status"); options.Conventions.AuthorizeFolder("/App", ActiveMemberRequirement.PolicyName); + options.Conventions.AllowAnonymousToFolder("/Mill"); }); builder.Services.ConfigureHttpJsonOptions(options => { @@ -103,6 +104,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddSingleton(); From f5a1f387cae702a380f1ec8356606a677dd4861f Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 22:48:04 +0200 Subject: [PATCH 20/97] Mill vote page: anonymous cast, change and retract, cookie minted only by a cast Co-Authored-By: Claude Opus 5.5 --- src/Bifrost.Web/Pages/Mill/Vote.cshtml | 56 ++ src/Bifrost.Web/Pages/Mill/Vote.cshtml.cs | 96 +++ .../Bifrost.Web.Tests/ConceptVotePageTests.cs | 568 ++++++++++++++++++ 3 files changed, 720 insertions(+) create mode 100644 src/Bifrost.Web/Pages/Mill/Vote.cshtml create mode 100644 src/Bifrost.Web/Pages/Mill/Vote.cshtml.cs create mode 100644 tests/Bifrost.Web.Tests/ConceptVotePageTests.cs diff --git a/src/Bifrost.Web/Pages/Mill/Vote.cshtml b/src/Bifrost.Web/Pages/Mill/Vote.cshtml new file mode 100644 index 0000000..daa82b1 --- /dev/null +++ b/src/Bifrost.Web/Pages/Mill/Vote.cshtml @@ -0,0 +1,56 @@ +@page "/mill/{collection}/{key}" +@model Bifrost.Web.Pages.Mill.VoteModel +@{ + ViewData["Title"] = Model.Subject.Title; + var stance = Model.Subject.YourStance; +} + +
+ Back to the Concept Mill +

@Model.Subject.Title

+ +
+
Everyone
+
@Model.Subject.Tally.Up up, @Model.Subject.Tally.Down down
+
Members
+
@Model.Subject.Tally.Members.Up up, @Model.Subject.Tally.Members.Down down
+
+ +

+ @if (stance is null) + { + You have not voted on this concept. + } + else + { + Your vote: @(stance == Bifrost.Web.Domain.ConceptStance.Up ? "up" : "down"). + } +

+ +
+
+ +
+
+ +
+ @if (stance is not null) + { +
+ +
+ } +
+ + @if (Model.SignedIn) + { +

Your vote is kept against your Bifrost account. No cookie is set.

+ } + else + { +

+ Voting saves one small cookie on this device. Its only purpose is to keep your one vote yours so you can + change or retract it; it lasts a year and carries nothing else. +

+ } +
diff --git a/src/Bifrost.Web/Pages/Mill/Vote.cshtml.cs b/src/Bifrost.Web/Pages/Mill/Vote.cshtml.cs new file mode 100644 index 0000000..2182f65 --- /dev/null +++ b/src/Bifrost.Web/Pages/Mill/Vote.cshtml.cs @@ -0,0 +1,96 @@ +using Bifrost.Web.Configuration; +using Bifrost.Web.Domain; +using Bifrost.Web.Features.ConceptVotes; +using Bifrost.Web.Features.Shared; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.Extensions.Options; + +namespace Bifrost.Web.Pages.Mill; + +// The anonymous vote page. Counts and the visitor's own stance come from ConceptAppraisalService on every GET; the +// voter key is decided by VoterIdentity.For from the actor and the device id VoterKeyResolver reads. +public sealed class VoteModel( + ICurrentBifrostActorAccessor actorAccessor, + ConceptAppraisalService appraisals, + VoterKeyResolver deviceCookie, + IOptions options) : PageModel +{ + public ConceptSubjectView Subject { get; private set; } = null!; + + public bool SignedIn { get; private set; } + + public string MillPageLink => options.Value.MillPageUrl + "#" + Uri.EscapeDataString(Subject.Key); + + public async Task OnGetAsync(string collection, string key, CancellationToken cancellationToken) + { + var actor = await actorAccessor.GetAsync(cancellationToken); + var voter = ResolveVoter(actor, mint: false); + SignedIn = voter?.UserAccountId is not null; + return await LoadAsync(collection, key, voter, cancellationToken) ? Page() : NotFound(); + } + + public Task OnPostUpAsync(string collection, string key, CancellationToken cancellationToken) => + CastAsync(collection, key, ConceptStance.Up, cancellationToken); + + public Task OnPostDownAsync(string collection, string key, CancellationToken cancellationToken) => + CastAsync(collection, key, ConceptStance.Down, cancellationToken); + + public async Task OnPostRetractAsync(string collection, string key, CancellationToken cancellationToken) + { + var actor = await actorAccessor.GetAsync(cancellationToken); + if (await appraisals.GetSubjectAsync(collection, key, null, cancellationToken) is null) + { + return NotFound(); + } + + var voter = ResolveVoter(actor, mint: false); + await appraisals.RetractAsync(collection, key, voter, cancellationToken); + return RedirectToPage(new { collection, key }); + } + + private async Task CastAsync( + string collection, + string key, + ConceptStance stance, + CancellationToken cancellationToken) + { + var actor = await actorAccessor.GetAsync(cancellationToken); + if (await appraisals.GetSubjectAsync(collection, key, null, cancellationToken) is null) + { + return NotFound(); + } + + var voter = ResolveVoter(actor, mint: true); + var result = await appraisals.CastAsync( + collection, key, voter, stance, HttpContext.Connection.RemoteIpAddress, cancellationToken); + return result is CastResult.UnknownSubject or CastResult.RetiredSubject + ? NotFound() + : RedirectToPage(new { collection, key }); + } + + // Accounts key to the account and never touch the cookie. An anonymous visitor keys to a device cookie that + // unprotects to a well-formed id; a cast without one mints it, and no other path does. + private VoterIdentity? ResolveVoter(CurrentBifrostActor actor, bool mint) + { + var voter = VoterIdentity.For(actor, null) ?? VoterIdentity.For(actor, deviceCookie.TryRead(HttpContext)); + if (voter is null && mint) + { + voter = VoterIdentity.For(actor, deviceCookie.Mint(HttpContext)); + } + + return voter; + } + + private async Task LoadAsync(string collection, string key, VoterIdentity? voter, CancellationToken cancellationToken) + { + var view = await appraisals.GetSubjectAsync(collection, key, voter, cancellationToken); + if (view is null) + { + return false; + } + + Subject = view; + return true; + } +} diff --git a/tests/Bifrost.Web.Tests/ConceptVotePageTests.cs b/tests/Bifrost.Web.Tests/ConceptVotePageTests.cs new file mode 100644 index 0000000..25f63eb --- /dev/null +++ b/tests/Bifrost.Web.Tests/ConceptVotePageTests.cs @@ -0,0 +1,568 @@ +using System.Net; +using System.Text.RegularExpressions; +using Bifrost.Web.Data; +using Bifrost.Web.Domain; +using Bifrost.Web.Tests.Support; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Bifrost.Web.Tests; + +public sealed partial class ConceptVotePageTests : IClassFixture +{ + private const string CookieName = "bifrost_voter"; + private const string Purpose = "Bifrost.ConceptVotes.DeviceToken.v1"; + + private readonly TestWebApplicationFactory _factory; + + public ConceptVotePageTests(TestWebApplicationFactory factory) + { + _factory = factory; + } + + [Fact] + public async Task Anonymous_visitor_can_cast_change_and_retract() + { + var collection = NewCollection(); + await AddSubject(collection, "alpha"); + var visitor = NewVisitor(anonymous: true); + + using var cast = await visitor.Post(collection, "alpha", "Up"); + + Assert.Equal(HttpStatusCode.Redirect, cast.StatusCode); + var setCookie = SetCookieHeader(cast, CookieName); + Assert.NotNull(setCookie); + var attributes = setCookie!.Split(';').Select(x => x.Trim().ToLowerInvariant()).ToArray(); + Assert.Contains("httponly", attributes); + Assert.Contains("secure", attributes); + Assert.Contains("samesite=lax", attributes); + Assert.Contains("path=/mill", attributes); + Assert.Contains("max-age=31536000", attributes); + + using (var page = await visitor.Get(collection, "alpha")) + { + var (everyone, members) = Counts(await page.Content.ReadAsStringAsync()); + Assert.Equal("1 up, 0 down", everyone); + Assert.Equal("0 up, 0 down", members); + } + + var firstRow = Assert.Single(await Rows(collection, "alpha")); + Assert.Equal(ConceptStance.Up, firstRow.Stance); + Assert.Equal(VoterClass.Anonymous, firstRow.VoterClass); + + // The cookie from the first cast is the voter: casting again changes the row and mints nothing. + using var change = await visitor.Post(collection, "alpha", "Down"); + Assert.Equal(HttpStatusCode.Redirect, change.StatusCode); + Assert.Null(SetCookieHeader(change, CookieName)); + var changed = Assert.Single(await Rows(collection, "alpha")); + Assert.Equal(ConceptStance.Down, changed.Stance); + Assert.Equal(firstRow.VoterKey, changed.VoterKey); + + using var retract = await visitor.Post(collection, "alpha", "Retract"); + Assert.Equal(HttpStatusCode.Redirect, retract.StatusCode); + Assert.Null(SetCookieHeader(retract, CookieName)); + Assert.Empty(await Rows(collection, "alpha")); + } + + [Fact] + public async Task Get_never_sets_the_device_cookie() + { + var collection = NewCollection(); + await AddSubject(collection, "alpha"); + var visitor = NewVisitor(anonymous: true); + + using (var first = await visitor.Get(collection, "alpha")) + { + Assert.Equal(HttpStatusCode.OK, first.StatusCode); + Assert.Null(SetCookieHeader(first, CookieName)); + } + + using (var retract = await visitor.Post(collection, "alpha", "Retract")) + { + Assert.Equal(HttpStatusCode.Redirect, retract.StatusCode); + Assert.Null(SetCookieHeader(retract, CookieName)); + } + + Assert.False(visitor.Jar.ContainsKey(CookieName)); + Assert.Empty(await Rows(collection, "alpha")); + + using var cast = await visitor.Post(collection, "alpha", "Up"); + Assert.NotNull(SetCookieHeader(cast, CookieName)); + using var later = await visitor.Get(collection, "alpha"); + Assert.Null(SetCookieHeader(later, CookieName)); + } + + [Fact] + public async Task Signed_in_vote_is_keyed_to_the_account() + { + var collection = NewCollection(); + await AddSubject(collection, "alpha"); + var visitor = NewVisitor(anonymous: false); + + using var cast = await visitor.Post(collection, "alpha", "Up"); + + Assert.Equal(HttpStatusCode.Redirect, cast.StatusCode); + Assert.Null(SetCookieHeader(cast, CookieName)); + var row = Assert.Single(await Rows(collection, "alpha")); + Assert.StartsWith("account:", row.VoterKey, StringComparison.Ordinal); + Assert.Equal(VoterClass.Member, row.VoterClass); + Assert.NotNull(row.UserAccountId); + + using var page = await visitor.Get(collection, "alpha"); + var (everyone, members) = Counts(await page.Content.ReadAsStringAsync()); + Assert.Equal("1 up, 0 down", everyone); + Assert.Equal("1 up, 0 down", members); + Assert.Null(SetCookieHeader(page, CookieName)); + } + + [Fact] + public async Task Cast_without_antiforgery_token_is_refused() + { + var collection = NewCollection(); + await AddSubject(collection, "alpha"); + var visitor = NewVisitor(anonymous: true); + + using var noToken = await visitor.Post(collection, "alpha", "Up", token: Token.Omitted); + using var noCookie = await visitor.Post(collection, "alpha", "Up", token: Token.WithoutAntiforgeryCookie); + using var garbage = await visitor.Post(collection, "alpha", "Up", token: Token.Garbage); + + Assert.Equal(HttpStatusCode.BadRequest, noToken.StatusCode); + Assert.Equal(HttpStatusCode.BadRequest, noCookie.StatusCode); + Assert.Equal(HttpStatusCode.BadRequest, garbage.StatusCode); + Assert.Null(SetCookieHeader(noToken, CookieName)); + Assert.Empty(await Rows(collection, "alpha")); + } + + public enum Forgery + { + OneCharacterChanged, + BareDeviceId, + SameKeyRingOtherPurpose, + OtherKeyRingSamePurpose + } + + [Theory] + [InlineData(Forgery.OneCharacterChanged)] + [InlineData(Forgery.BareDeviceId)] + [InlineData(Forgery.SameKeyRingOtherPurpose)] + [InlineData(Forgery.OtherKeyRingSamePurpose)] + public async Task Tampered_or_foreign_cookie_is_ignored_and_a_new_one_minted(Forgery kind) + { + var collection = NewCollection(); + await AddSubject(collection, "alpha"); + var victim = NewVisitor(anonymous: true); + using (await victim.Post(collection, "alpha", "Up")) + { + } + + var genuine = victim.Jar[CookieName]; + var victimRow = Assert.Single(await Rows(collection, "alpha")); + var deviceId = victimRow.VoterKey["device:".Length..]; + var forged = Forge(kind, genuine, deviceId); + Assert.NotEqual(genuine, forged); + if (kind is not Forgery.BareDeviceId) + { + Assert.Equal(genuine.Length, forged.Length); + Assert.Matches("^[A-Za-z0-9_-]+$", forged); + } + + var attacker = NewVisitor(anonymous: true); + attacker.Jar[CookieName] = forged; + using var cast = await attacker.Post(collection, "alpha", "Down"); + + Assert.Equal(HttpStatusCode.Redirect, cast.StatusCode); + Assert.NotNull(SetCookieHeader(cast, CookieName)); + Assert.NotEqual(forged, attacker.Jar[CookieName]); + Assert.NotEqual(genuine, attacker.Jar[CookieName]); + var rows = await Rows(collection, "alpha"); + Assert.Equal(2, rows.Count); + var victimNow = Assert.Single(rows, x => x.VoterKey == victimRow.VoterKey); + Assert.Equal(ConceptStance.Up, victimNow.Stance); + var attackerRow = Assert.Single(rows, x => x.VoterKey != victimRow.VoterKey); + Assert.Equal(ConceptStance.Down, attackerRow.Stance); + Assert.Equal(VoterClass.Anonymous, attackerRow.VoterClass); + } + + [Fact] + public async Task A_forged_cookie_value_is_echoed_by_no_response_and_no_log_line() + { + const string canary = "CANARY-forged-voter-cookie-7f3a"; + var logs = new CapturingLoggerProvider(); + var factory = _factory.WithWebHostBuilder(b => b.ConfigureLogging(l => l.AddProvider(logs))); + var collection = NewCollection(); + await AddSubject(collection, "alpha"); + var visitor = NewVisitor(anonymous: true, factory); + visitor.Jar[CookieName] = canary; + + using var page = await visitor.Get(collection, "alpha"); + using var cast = await visitor.Post(collection, "alpha", "Up"); + + foreach (var response in new[] { page, cast }) + { + Assert.DoesNotContain(canary, await response.Content.ReadAsStringAsync(), StringComparison.Ordinal); + Assert.DoesNotContain( + canary, + string.Join("\n", response.Headers.Select(h => h.Key + ":" + string.Join(",", h.Value))), + StringComparison.Ordinal); + } + + Assert.NotEmpty(logs.Lines); + Assert.DoesNotContain(logs.Lines, line => line.Contains(canary, StringComparison.Ordinal)); + } + + [Fact] + public async Task Unknown_or_retired_key_is_404() + { + var collection = NewCollection(); + await AddSubject(collection, "alpha"); + await AddSubject(collection, "gone"); + await Retire(collection, "gone"); + var visitor = NewVisitor(anonymous: true); + + foreach (var (c, k) in new[] { (collection, "gone"), (collection, "never-registered"), (NewCollection(), "alpha") }) + { + using var get = await visitor.Get(c, k); + Assert.Equal(HttpStatusCode.NotFound, get.StatusCode); + foreach (var handler in new[] { "Up", "Down", "Retract" }) + { + using var post = await visitor.Post(c, k, handler, tokenFrom: (collection, "alpha")); + Assert.Equal(HttpStatusCode.NotFound, post.StatusCode); + Assert.Null(SetCookieHeader(post, CookieName)); + } + } + + Assert.Empty(await Rows(collection, "gone")); + Assert.Empty(await Rows(collection, "alpha")); + Assert.False(visitor.Jar.ContainsKey(CookieName)); + } + + [Fact] + public async Task Page_shows_counts_by_class_and_no_voter_identity() + { + var collection = NewCollection(); + await AddSubject(collection, "alpha"); + var memberId = Guid.NewGuid(); + var accountId = Guid.NewGuid(); + await AddRows(collection, "alpha", + Row("device:canary-device-id-4b1d", VoterClass.Anonymous, ConceptStance.Up, null, "canarytag-aaaa"), + Row("device:canary-device-id-5c2e", VoterClass.Anonymous, ConceptStance.Down, null, "canarytag-bbbb"), + Row("account:" + memberId.ToString("N"), VoterClass.Member, ConceptStance.Up, memberId, "canarytag-cccc"), + Row("account:" + accountId.ToString("N"), VoterClass.Account, ConceptStance.Down, accountId, "canarytag-dddd")); + var visitor = NewVisitor(anonymous: true); + using (await visitor.Post(collection, "alpha", "Up")) + { + } + + using var page = await visitor.Get(collection, "alpha"); + var html = await page.Content.ReadAsStringAsync(); + + var (everyone, members) = Counts(html); + Assert.Equal("3 up, 2 down", everyone); + Assert.Equal("1 up, 0 down", members); + Assert.Contains("Your vote: up.", html, StringComparison.Ordinal); + + var visitorRow = Assert.Single(await Rows(collection, "alpha"), x => !x.VoterKey.Contains("canary")); + var forbidden = new List + { + "device:", "account:", "canary-device-id", "canarytag", visitorRow.VoterKey, visitorRow.NetworkTag, + visitor.Jar[CookieName], + memberId.ToString("N"), memberId.ToString("D"), accountId.ToString("N"), accountId.ToString("D") + }; + foreach (var value in forbidden) + { + Assert.DoesNotContain(value, html, StringComparison.OrdinalIgnoreCase); + } + + Assert.True(page.Headers.CacheControl is { NoStore: true } or { NoCache: true }); + + var signedIn = NewVisitor(anonymous: false); + using var signedInPage = await signedIn.Get(collection, "alpha"); + var signedInHtml = await signedInPage.Content.ReadAsStringAsync(); + using var signedInCast = await signedIn.Post(collection, "alpha", "Down"); + var accountRow = Assert.Single( + await Rows(collection, "alpha"), + x => x.UserAccountId is { } id && id != memberId && id != accountId); + using var afterCast = await signedIn.Get(collection, "alpha"); + var afterHtml = await afterCast.Content.ReadAsStringAsync(); + Assert.Contains("Your vote: down.", afterHtml, StringComparison.Ordinal); + foreach (var text in new[] { signedInHtml, afterHtml }) + { + Assert.DoesNotContain("account:", text, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain(accountRow.UserAccountId!.Value.ToString("N"), text, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain(accountRow.UserAccountId!.Value.ToString("D"), text, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain(accountRow.NetworkTag, text, StringComparison.OrdinalIgnoreCase); + } + } + + [Fact] + public async Task Cookie_notice_is_shown_to_anonymous_visitors_and_the_page_links_back_to_the_mill() + { + var collection = NewCollection(); + await AddSubject(collection, "alpha"); + + using var anonymousPage = await NewVisitor(anonymous: true).Get(collection, "alpha"); + var html = await anonymousPage.Content.ReadAsStringAsync(); + using var signedInPage = await NewVisitor(anonymous: false).Get(collection, "alpha"); + var signedInHtml = await signedInPage.Content.ReadAsStringAsync(); + + Assert.Contains("only purpose is to keep your one vote yours", html, StringComparison.Ordinal); + Assert.Contains("lasts a year", html, StringComparison.Ordinal); + Assert.Contains("carries nothing else", html, StringComparison.Ordinal); + Assert.DoesNotContain("only purpose is to keep your one vote yours", signedInHtml, StringComparison.Ordinal); + Assert.Contains( + "href=\"https://aetheria.gamecult.org/Game-Design/Concept-Mill#alpha\"", + html, + StringComparison.Ordinal); + } + + [Fact] + public async Task Page_loads_no_script_and_posts_with_plain_forms() + { + var collection = NewCollection(); + await AddSubject(collection, "alpha"); + + using var page = await NewVisitor(anonymous: true).Get(collection, "alpha"); + var html = await page.Content.ReadAsStringAsync(); + var main = html[html.IndexOf("", StringComparison.Ordinal)]; + + Assert.DoesNotContain(" kind switch + { + Forgery.OneCharacterChanged => genuine[..(genuine.Length / 2)] + + (genuine[genuine.Length / 2] == 'A' ? 'B' : 'A') + + genuine[(genuine.Length / 2 + 1)..], + Forgery.BareDeviceId => deviceId, + Forgery.SameKeyRingOtherPurpose => _factory.Services.GetRequiredService() + .CreateProtector(Purpose + ".other").Protect(deviceId), + Forgery.OtherKeyRingSamePurpose => new EphemeralDataProtectionProvider() + .CreateProtector(Purpose).Protect(deviceId), + _ => throw new ArgumentOutOfRangeException(nameof(kind)) + }; + + private static string NewCollection() => "c" + Guid.NewGuid().ToString("N"); + + private Visitor NewVisitor(bool anonymous, WebApplicationFactory? factory = null) => + new((factory ?? _factory).CreateClient(new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false, + HandleCookies = false, + BaseAddress = new Uri("https://localhost") + }), anonymous); + + private static string? SetCookieHeader(HttpResponseMessage response, string name) => + response.Headers.TryGetValues("Set-Cookie", out var values) + ? values.FirstOrDefault(x => x.StartsWith(name + "=", StringComparison.Ordinal)) + : null; + + private static (string Everyone, string Members) Counts(string html) + { + var cells = DefinitionCell().Matches(html).Select(m => m.Groups[1].Value.Trim()).ToArray(); + Assert.Equal(2, cells.Length); + return (cells[0], cells[1]); + } + + [GeneratedRegex("
(.*?)
", RegexOptions.Singleline)] + private static partial Regex DefinitionCell(); + + [GeneratedRegex("name=\"__RequestVerificationToken\"[^>]*value=\"([^\"]+)\"")] + private static partial Regex RequestToken(); + + private async Task AddSubject(string collection, string key) + { + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + db.ConceptSubjects.Add(new ConceptSubject + { + Collection = collection, + Key = key, + Title = key, + RegisteredAtUtc = DateTimeOffset.UtcNow + }); + await db.SaveChangesAsync(); + } + + private async Task Retire(string collection, string key) + { + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var subject = await db.ConceptSubjects.SingleAsync(x => x.Collection == collection && x.Key == key); + subject.RetiredAtUtc = DateTimeOffset.UtcNow; + await db.SaveChangesAsync(); + } + + private static ConceptAppraisal Row(string key, VoterClass voterClass, ConceptStance stance, Guid? accountId, string tag) => + new() + { + VoterKey = key, + VoterClass = voterClass, + Stance = stance, + UserAccountId = accountId, + NetworkTag = tag, + CastAtUtc = DateTimeOffset.UtcNow + }; + + private async Task AddRows(string collection, string key, params ConceptAppraisal[] rows) + { + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var subject = await db.ConceptSubjects.SingleAsync(x => x.Collection == collection && x.Key == key); + foreach (var row in rows) + { + row.SubjectId = subject.Id; + db.ConceptAppraisals.Add(row); + } + + await db.SaveChangesAsync(); + } + + // A fresh DbContext per read: what a later request would see, not what this test's tracker remembers. + private async Task> Rows(string collection, string key) + { + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + return await db.ConceptAppraisals.AsNoTracking() + .Where(x => x.Subject!.Collection == collection && x.Subject.Key == key) + .ToListAsync(); + } + + public enum Token + { + Valid, + Omitted, + WithoutAntiforgeryCookie, + Garbage + } + + private sealed class Visitor(HttpClient client, bool anonymous) + { + public Dictionary Jar { get; } = new(StringComparer.Ordinal); + + public Task Get(string collection, string key) => + Send(new HttpRequestMessage(HttpMethod.Get, $"/mill/{collection}/{key}")); + + // Reads a token from the page of tokenFrom (default: the page being posted to), then posts the handler. + public async Task Post( + string collection, + string key, + string handler, + Token token = Token.Valid, + (string Collection, string Key)? tokenFrom = null) + { + var from = tokenFrom ?? (collection, key); + string value; + using (var page = await Get(from.Collection, from.Key)) + { + var match = RequestToken().Match(await page.Content.ReadAsStringAsync()); + Assert.True(match.Success); + value = match.Groups[1].Value; + } + + var antiforgery = Jar + .Where(x => x.Key.StartsWith(".AspNetCore.Antiforgery", StringComparison.Ordinal)) + .ToList(); + if (token is Token.WithoutAntiforgeryCookie) + { + foreach (var cookie in antiforgery) + { + Jar.Remove(cookie.Key); + } + } + + var form = new List>(); + if (token is not Token.Omitted) + { + form.Add(new("__RequestVerificationToken", token is Token.Garbage ? "garbage" : value)); + } + + var request = new HttpRequestMessage(HttpMethod.Post, $"/mill/{collection}/{key}?handler={handler}") + { + Content = new FormUrlEncodedContent(form) + }; + var response = await Send(request); + foreach (var cookie in antiforgery) + { + Jar.TryAdd(cookie.Key, cookie.Value); + } + + return response; + } + + private async Task Send(HttpRequestMessage request) + { + if (anonymous) + { + request.Headers.Add("X-Test-Anonymous", "true"); + } + + if (Jar.Count > 0) + { + request.Headers.TryAddWithoutValidation( + "Cookie", + string.Join("; ", Jar.Select(x => x.Key + "=" + x.Value))); + } + + var response = await client.SendAsync(request); + if (response.Headers.TryGetValues("Set-Cookie", out var values)) + { + foreach (var header in values) + { + var pair = header.Split(';')[0]; + var split = pair.IndexOf('='); + Jar[pair[..split]] = pair[(split + 1)..]; + } + } + + return response; + } + } + + private sealed class CapturingLoggerProvider : ILoggerProvider + { + private readonly List _lines = []; + + public IReadOnlyList Lines + { + get + { + lock (_lines) + { + return _lines.ToArray(); + } + } + } + + public ILogger CreateLogger(string categoryName) => new Capture(this); + + public void Dispose() + { + } + + private sealed class Capture(CapturingLoggerProvider owner) : ILogger + { + public IDisposable? BeginScope(TState state) where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter) + { + lock (owner._lines) + { + owner._lines.Add(formatter(state, exception) + (exception is null ? string.Empty : "\n" + exception)); + } + } + } + } +} From e05e1bed819b8e14575fe40850e91e869024a506 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 22:51:24 +0200 Subject: [PATCH 21/97] ConceptVotePageTests: attach the log capture through ConfigureServices Co-Authored-By: Claude Opus 5.5 --- tests/Bifrost.Web.Tests/ConceptVotePageTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Bifrost.Web.Tests/ConceptVotePageTests.cs b/tests/Bifrost.Web.Tests/ConceptVotePageTests.cs index 25f63eb..15fbcf6 100644 --- a/tests/Bifrost.Web.Tests/ConceptVotePageTests.cs +++ b/tests/Bifrost.Web.Tests/ConceptVotePageTests.cs @@ -191,7 +191,7 @@ public sealed partial class ConceptVotePageTests : IClassFixture b.ConfigureLogging(l => l.AddProvider(logs))); + var factory = _factory.WithWebHostBuilder(b => b.ConfigureServices(s => s.AddLogging(l => l.AddProvider(logs)))); var collection = NewCollection(); await AddSubject(collection, "alpha"); var visitor = NewVisitor(anonymous: true, factory); From f7ff76daa45feed0e4ac90536912a348ddb00c01 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 22:54:03 +0200 Subject: [PATCH 22/97] ConceptVotePageTests: find the visitor's own row by account-less key, drop the empty-tag assertion Co-Authored-By: Claude Opus 5.5 --- tests/Bifrost.Web.Tests/ConceptVotePageTests.cs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/Bifrost.Web.Tests/ConceptVotePageTests.cs b/tests/Bifrost.Web.Tests/ConceptVotePageTests.cs index 15fbcf6..2e081c8 100644 --- a/tests/Bifrost.Web.Tests/ConceptVotePageTests.cs +++ b/tests/Bifrost.Web.Tests/ConceptVotePageTests.cs @@ -264,10 +264,10 @@ public sealed partial class ConceptVotePageTests : IClassFixture !x.VoterKey.Contains("canary")); + var visitorRow = Assert.Single(await Rows(collection, "alpha"), x => x.UserAccountId is null && !x.VoterKey.Contains("canary")); var forbidden = new List { - "device:", "account:", "canary-device-id", "canarytag", visitorRow.VoterKey, visitorRow.NetworkTag, + "device:", "account:", "canary-device-id", "canarytag", visitorRow.VoterKey, visitor.Jar[CookieName], memberId.ToString("N"), memberId.ToString("D"), accountId.ToString("N"), accountId.ToString("D") }; @@ -293,7 +293,6 @@ public sealed partial class ConceptVotePageTests : IClassFixture Date: Sat, 10 Oct 2026 23:01:40 +0200 Subject: [PATCH 23/97] Registry: typed refusals for every input failure; strict manifest reader; retire rerun converges Empty, NUL and unreadable paths, malformed UTF-8 and lone surrogates refuse with the command's exit 1. The manifest is bounded (1 MiB, 1000 ships) and duplicate JSON properties are refused. Titles refuse format, line and paragraph separator characters and edge whitespace. Naming an already retired key is a recorded no-op, so a rerun converges. Co-Authored-By: Claude Opus 5.5 --- .../Features/ConceptVotes/ConceptCommand.cs | 18 +- .../ConceptVotes/ConceptSubjectRegistry.cs | 135 +++++++- .../ConceptSubjectRegistryTests.cs | 289 +++++++++++++++++- 3 files changed, 419 insertions(+), 23 deletions(-) diff --git a/src/Bifrost.Web/Features/ConceptVotes/ConceptCommand.cs b/src/Bifrost.Web/Features/ConceptVotes/ConceptCommand.cs index 9edab61..f81d1c0 100644 --- a/src/Bifrost.Web/Features/ConceptVotes/ConceptCommand.cs +++ b/src/Bifrost.Web/Features/ConceptVotes/ConceptCommand.cs @@ -44,13 +44,25 @@ public static class ConceptCommand } } - private static async Task ReadManifestAsync(string path) + // Reads at most one byte past the bound, so a wrong file is never read whole. + private static async Task ReadManifestAsync(string path) { try { - return await File.ReadAllTextAsync(path); + await using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, 1, true); + var buffer = new byte[ConceptSubjectRegistry.MaxManifestBytes + 1]; + var length = 0; + int read; + while (length < buffer.Length && (read = await stream.ReadAsync(buffer.AsMemory(length))) > 0) + { + length += read; + } + + return buffer[..length]; } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + // An empty or NUL path is an ArgumentException; a path the platform cannot name is NotSupported. + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException + or NotSupportedException or System.Security.SecurityException) { throw new ManifestRefusedException("manifest", "Unreadable"); } diff --git a/src/Bifrost.Web/Features/ConceptVotes/ConceptSubjectRegistry.cs b/src/Bifrost.Web/Features/ConceptVotes/ConceptSubjectRegistry.cs index 9c9bd3e..246926f 100644 --- a/src/Bifrost.Web/Features/ConceptVotes/ConceptSubjectRegistry.cs +++ b/src/Bifrost.Web/Features/ConceptVotes/ConceptSubjectRegistry.cs @@ -1,3 +1,5 @@ +using System.Globalization; +using System.Text; using System.Text.Json; using Bifrost.Web.Data; using Bifrost.Web.Domain; @@ -13,7 +15,8 @@ public enum SubjectChange Unchanged, Revive, Absent, - Retire + Retire, + Retired } public sealed record ManifestShip(string Key, string Title); @@ -39,16 +42,29 @@ public sealed class ConceptSubjectRegistry( { public const int MaxTitleLength = 240; + // Defaulted bounds (a Mill manifest is tens of ships): the whole file is refused above either. + public const int MaxManifestBytes = 1024 * 1024; + + public const int MaxShips = 1000; + + private static readonly UTF8Encoding StrictUtf8 = new(false, true); + // The Mill manifest is JSON from outside the Verse. Only ships[].id and ships[].name are read. - public static IReadOnlyList ParseManifest(string json) + // This is the one input boundary: every malformed input leaves as a ManifestRefusedException. + public static IReadOnlyList ParseManifest(ReadOnlyMemory utf8) { - using var document = ParseDocument(json); + using var document = ParseDocument(utf8); var ships = ShipsOf(document.RootElement); if (ships.ValueKind != JsonValueKind.Array) { throw new ManifestRefusedException("ships", "Missing"); } + if (ships.GetArrayLength() > MaxShips) + { + throw new ManifestRefusedException("ships", "TooMany"); + } + var result = new List(); var seen = new HashSet(StringComparer.Ordinal); foreach (var ship in ships.EnumerateArray()) @@ -66,7 +82,7 @@ public sealed class ConceptSubjectRegistry( throw new ManifestRefusedException($"{at}.id", "Duplicate"); } - if (string.IsNullOrWhiteSpace(title) || title.Any(char.IsControl)) + if (!IsValidTitle(title)) { throw new ManifestRefusedException($"{at}.name", "InvalidTitle"); } @@ -92,16 +108,92 @@ public sealed class ConceptSubjectRegistry( return root.TryGetProperty("ships", out var ships) ? ships : default; } - private static JsonDocument ParseDocument(string json) + // A displayed title: no control, format (bidi, zero-width, BOM), line or paragraph separator + // character, no leading or trailing whitespace, and at least one letter or digit. This is the + // character-class rule of the mind schema's Title type, kept here as the one copy in Bifrost. + private static bool IsValidTitle(string title) { + if (title.Length == 0 || title != title.Trim()) + { + return false; + } + + var hasLetterOrDigit = false; + foreach (var rune in title.EnumerateRunes()) + { + if (Rune.GetUnicodeCategory(rune) is UnicodeCategory.Control or UnicodeCategory.Format + or UnicodeCategory.LineSeparator or UnicodeCategory.ParagraphSeparator) + { + return false; + } + + hasLetterOrDigit |= Rune.IsLetterOrDigit(rune); + } + + return hasLetterOrDigit; + } + + private static JsonDocument ParseDocument(ReadOnlyMemory utf8) + { + if (utf8.Length > MaxManifestBytes) + { + throw new ManifestRefusedException("manifest", "TooLarge"); + } + try { - return JsonDocument.Parse(json); + StrictUtf8.GetCharCount(utf8.Span); + } + catch (DecoderFallbackException) + { + throw new ManifestRefusedException("manifest", "NotUtf8"); + } + + try + { + RejectDuplicateProperties(utf8.Span); + return JsonDocument.Parse(utf8); } catch (JsonException) { throw new ManifestRefusedException("manifest", "NotJson"); } + catch (InvalidOperationException) + { + // A property name holding a lone surrogate escape: not text. + throw new ManifestRefusedException("manifest", "BadText"); + } + } + + // System.Text.Json (net8.0) keeps the last of two equal property names; a manifest that says + // one thing twice is refused instead. + private static void RejectDuplicateProperties(ReadOnlySpan utf8) + { + var reader = new Utf8JsonReader(utf8); + var scopes = new Stack?>(); + while (reader.Read()) + { + switch (reader.TokenType) + { + case JsonTokenType.StartObject: + scopes.Push(new HashSet(StringComparer.Ordinal)); + break; + case JsonTokenType.StartArray: + scopes.Push(null); + break; + case JsonTokenType.EndObject: + case JsonTokenType.EndArray: + scopes.Pop(); + break; + case JsonTokenType.PropertyName: + if (!scopes.Peek()!.Add(reader.GetString()!)) + { + throw new ManifestRefusedException("manifest", "DuplicateProperty"); + } + + break; + } + } } public async Task PlanAsync( @@ -154,7 +246,17 @@ public sealed class ConceptSubjectRegistry( subject.Title)); } - // Whatever is still named is not a registered, live key missing from the manifest. + // A named key that an earlier run already retired is done: a rerun converges. + foreach (var key in named.OrderBy(x => x, StringComparer.Ordinal).ToList()) + { + if (registered.TryGetValue(key, out var done) && done.RetiredAtUtc is not null && !inManifest.Contains(key)) + { + entries.Add(new SubjectPlanEntry(SubjectChange.Retired, key, done.Title)); + named.Remove(key); + } + } + + // Whatever is still named is not a registered key missing from the manifest. if (named.Count > 0) { throw new ManifestRefusedException("retire", "NotAbsent"); @@ -176,7 +278,7 @@ public sealed class ConceptSubjectRegistry( foreach (var entry in plan.Entries) { - if (entry.Change is SubjectChange.Unchanged or SubjectChange.Absent) + if (entry.Change is SubjectChange.Unchanged or SubjectChange.Absent or SubjectChange.Retired) { continue; } @@ -232,8 +334,19 @@ public sealed class ConceptSubjectRegistry( throw new ManifestRefusedException($"{at}.{name}", "Missing"); } - return value.ValueKind == JsonValueKind.String - ? value.GetString()! - : throw new ManifestRefusedException($"{at}.{name}", "NotString"); + if (value.ValueKind != JsonValueKind.String) + { + throw new ManifestRefusedException($"{at}.{name}", "NotString"); + } + + try + { + return value.GetString()!; + } + catch (InvalidOperationException) + { + // A lone surrogate escape: not text. + throw new ManifestRefusedException($"{at}.{name}", "BadText"); + } } } diff --git a/tests/Bifrost.Web.Tests/ConceptSubjectRegistryTests.cs b/tests/Bifrost.Web.Tests/ConceptSubjectRegistryTests.cs index 92ab196..a67bf9a 100644 --- a/tests/Bifrost.Web.Tests/ConceptSubjectRegistryTests.cs +++ b/tests/Bifrost.Web.Tests/ConceptSubjectRegistryTests.cs @@ -1,4 +1,8 @@ +using System.Diagnostics; using System.Net; +using System.Text; +using System.Text.Encodings.Web; +using System.Text.Json; using Bifrost.Web.Configuration; using Bifrost.Web.Data; using Bifrost.Web.Domain; @@ -17,8 +21,11 @@ public sealed class ConceptSubjectRegistryTests private static string FixturePath => Path.Combine(AppContext.BaseDirectory, "Fixtures", "mill-three-ships.json"); + private static IReadOnlyList Parse(string json) => + ConceptSubjectRegistry.ParseManifest(Encoding.UTF8.GetBytes(json)); + private static IReadOnlyList Fixture() => - ConceptSubjectRegistry.ParseManifest(File.ReadAllText(FixturePath)); + ConceptSubjectRegistry.ParseManifest(File.ReadAllBytes(FixturePath)); [Fact] public async Task Rerun_of_the_same_manifest_is_a_no_op() @@ -129,14 +136,56 @@ public sealed class ConceptSubjectRegistryTests } [Fact] - public async Task Naming_an_already_retired_key_is_refused() + public async Task Rerunning_the_same_retire_list_converges_and_writes_nothing() { using var world = new World(); await world.Apply(Fixture()); - var smaller = Fixture().Where(s => s.Key != "kestrel").ToList(); + var smaller = Fixture().Where(s => s.Key == "bison").ToList(); + + var first = await world.Apply(smaller, ["kestrel", "ibis-mk2"]); + Assert.Equal([SubjectChange.Unchanged, SubjectChange.Retire, SubjectChange.Retire], first.Entries.Select(e => e.Change)); + var before = await world.Fresh(db => db.ConceptSubjects.AsNoTracking().OrderBy(x => x.Key).ToListAsync()); + var audits = await world.Fresh(db => db.AuditEvents.CountAsync()); + world.Clock.Advance(TimeSpan.FromHours(1)); + + var second = await world.Apply(smaller, ["kestrel", "ibis-mk2"]); + + Assert.Equal( + [("bison", SubjectChange.Unchanged), ("ibis-mk2", SubjectChange.Retired), ("kestrel", SubjectChange.Retired)], + second.Entries.OrderBy(e => e.Key, StringComparer.Ordinal).Select(e => (e.Key, e.Change))); + Assert.Equal(audits, await world.Fresh(db => db.AuditEvents.CountAsync())); + var after = await world.Fresh(db => db.ConceptSubjects.AsNoTracking().OrderBy(x => x.Key).ToListAsync()); + Assert.Equal(before.Select(x => (x.Id, x.Title, x.RetiredAtUtc)), after.Select(x => (x.Id, x.Title, x.RetiredAtUtc))); + } + + [Fact] + public async Task Rerunning_a_retire_list_after_a_partial_apply_retires_the_rest() + { + using var world = new World(); + await world.Apply(Fixture()); + var smaller = Fixture().Where(s => s.Key == "bison").ToList(); await world.Apply(smaller, ["kestrel"]); - await Assert.ThrowsAsync(() => world.Apply(smaller, ["kestrel"])); + var plan = await world.Apply(smaller, ["kestrel", "ibis-mk2"]); + + Assert.Equal(SubjectChange.Retired, plan.Entries.Single(e => e.Key == "kestrel").Change); + Assert.Equal(SubjectChange.Retire, plan.Entries.Single(e => e.Key == "ibis-mk2").Change); + var rows = await world.Fresh(db => db.ConceptSubjects.AsNoTracking().ToListAsync()); + Assert.Equal(["ibis-mk2", "kestrel"], rows.Where(r => r.RetiredAtUtc is not null).Select(r => r.Key).Order()); + Assert.Equal(2, await world.Fresh(db => db.AuditEvents.CountAsync(x => x.Action == "Retire"))); + } + + [Fact] + public async Task Naming_a_retired_key_that_the_manifest_lists_again_is_still_refused() + { + using var world = new World(); + await world.Apply(Fixture()); + await world.Apply(Fixture().Where(s => s.Key != "kestrel").ToList(), ["kestrel"]); + + var refusal = await Assert.ThrowsAsync(() => world.Apply(Fixture(), ["kestrel"])); + + Assert.Equal(("retire", "NotAbsent"), (refusal.Field, refusal.Code)); + Assert.NotNull((await world.Fresh(db => db.ConceptSubjects.AsNoTracking().SingleAsync(x => x.Key == "kestrel"))).RetiredAtUtc); } [Fact] @@ -176,7 +225,7 @@ public sealed class ConceptSubjectRegistryTests public async Task Malformed_manifest_is_refused_whole(string json, string field, string code) { using var world = new World(); - var refusal = Assert.Throws(() => ConceptSubjectRegistry.ParseManifest(json)); + var refusal = Assert.Throws(() => Parse(json)); Assert.Equal((field, code), (refusal.Field, refusal.Code)); Assert.Equal($"{field}: {code}", refusal.Message); @@ -188,12 +237,129 @@ public sealed class ConceptSubjectRegistryTests { var fits = new string('t', ConceptSubjectRegistry.MaxTitleLength); - Assert.Equal(fits, ConceptSubjectRegistry.ParseManifest($$"""{"ships":[{"id":"a","name":"{{fits}}"}]}""").Single().Title); + Assert.Equal(fits, Parse($$"""{"ships":[{"id":"a","name":"{{fits}}"}]}""").Single().Title); var refusal = Assert.Throws(() => - ConceptSubjectRegistry.ParseManifest($$"""{"ships":[{"id":"a","name":"{{fits}}x"}]}""")); + Parse($$"""{"ships":[{"id":"a","name":"{{fits}}x"}]}""")); Assert.Equal(("ships[0].name", "TooLong"), (refusal.Field, refusal.Code)); } + private static readonly JsonSerializerOptions Raw = new() { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }; + + [Theory] + [InlineData("‮evil")] + [InlineData("a‪b")] + [InlineData("a�b")] + [InlineData("a�b")] + [InlineData("a؜b")] + [InlineData("a‎b")] + [InlineData("a�b")] + [InlineData("a
b")] + [InlineData("a
b")] + [InlineData("a​b")] + [InlineData("a‌b")] + [InlineData("a�b")] + [InlineData("a�b")] + [InlineData("ab")] + [InlineData("a\u0085b")] + [InlineData("a\tb")] + [InlineData(" Ibis")] + [InlineData("Ibis ")] + [InlineData(" Ibis")] + [InlineData("Ibis ")] + [InlineData(" Ibis")] + [InlineData("!!!")] + public void A_title_with_a_format_character_edge_whitespace_or_no_letter_is_refused(string title) + { + var json = """{"ships":[{"id":"a","name":""" + JsonSerializer.Serialize(title, Raw) + "}]}"; + + var refusal = Assert.Throws(() => Parse(json)); + + Assert.Equal(("ships[0].name", "InvalidTitle"), (refusal.Field, refusal.Code)); + } + + [Theory] + [InlineData("Ibis Mk II")] + [InlineData("Ünïcode Ship")] + [InlineData("Ship ★ 7")] + [InlineData("7")] + [InlineData("Kestrel-2: the \"Bold\"")] + public void An_ordinary_title_is_accepted(string title) + { + var json = """{"ships":[{"id":"a","name":""" + JsonSerializer.Serialize(title, Raw) + "}]}"; + + Assert.Equal(title, Parse(json).Single().Title); + } + + [Theory] + [InlineData("""{"ships":[{"id":"a","name":"x\ud800y"}]}""", "ships[0].name", "BadText")] + [InlineData("""{"ships":[{"id":"a\udc00","name":"x"}]}""", "ships[0].id", "BadText")] + [InlineData("""{"ships":[{"id":"a","name":"x","na\ud800me":"y"}]}""", "manifest", "BadText")] + public void A_lone_surrogate_is_refused_not_thrown(string json, string field, string code) + { + var refusal = Assert.Throws(() => Parse(json)); + + Assert.Equal((field, code), (refusal.Field, refusal.Code)); + } + + [Fact] + public void Malformed_utf8_anywhere_in_the_file_is_refused() + { + byte[] inName = [.. "{\"ships\":[{\"id\":\"a\",\"name\":\"X"u8, 0xC3, 0x28, .. "\"}]}"u8]; + byte[] inUnreadValue = [.. "{\"ships\":[],\"x\":\"X"u8, 0xFF, .. "\"}"u8]; + + foreach (var bytes in new[] { inName, inUnreadValue }) + { + var refusal = Assert.Throws(() => ConceptSubjectRegistry.ParseManifest(bytes)); + + Assert.Equal(("manifest", "NotUtf8"), (refusal.Field, refusal.Code)); + } + } + + [Theory] + [InlineData("""{"ships":[{"id":"a","id":"b","name":"A"}]}""")] + [InlineData("""{"ships":[{"id":"a","name":"A","name":"B"}]}""")] + [InlineData("""{"ships":[],"ships":[]}""")] + [InlineData("""{"ships":[{"id":"a","name":"A"}],"votes":{"x":1,"x":2}}""")] + [InlineData("""{"ships":[{"id":"a","name":"A","meta":{"k":[{"z":1,"z":1}]}}]}""")] + [InlineData("""{"ships":[{"id":"a","name":"A","id":"b"}]}""")] + public void A_duplicate_json_property_is_refused_wherever_it_sits(string json) + { + var refusal = Assert.Throws(() => Parse(json)); + + Assert.Equal(("manifest", "DuplicateProperty"), (refusal.Field, refusal.Code)); + } + + [Fact] + public void The_same_property_name_in_sibling_and_nested_objects_is_not_a_duplicate() + { + var ships = Parse("""{"id":"top","ships":[{"id":"a","name":"A","meta":{"id":"x","name":"y"}},{"id":"b","name":"B"}]}"""); + + Assert.Equal(["a", "b"], ships.Select(s => s.Key)); + } + + [Fact] + public void A_manifest_of_exactly_the_byte_bound_is_accepted_and_one_more_byte_is_refused() + { + const string head = """{"ships":[{"id":"a","name":"A"}]}"""; + var fits = Encoding.UTF8.GetBytes(head + new string(' ', ConceptSubjectRegistry.MaxManifestBytes - head.Length)); + + Assert.Equal(ConceptSubjectRegistry.MaxManifestBytes, fits.Length); + Assert.Single(ConceptSubjectRegistry.ParseManifest(fits)); + var refusal = Assert.Throws(() => ConceptSubjectRegistry.ParseManifest([.. fits, (byte)' '])); + Assert.Equal(("manifest", "TooLarge"), (refusal.Field, refusal.Code)); + } + + [Fact] + public void A_manifest_of_exactly_the_ship_bound_is_accepted_and_one_more_ship_is_refused() + { + static string Fleet(int count) => + """{"ships":[""" + string.Join(",", Enumerable.Range(0, count).Select(i => $$"""{"id":"s{{i}}","name":"S{{i}}"}""")) + "]}"; + + Assert.Equal(ConceptSubjectRegistry.MaxShips, Parse(Fleet(ConceptSubjectRegistry.MaxShips)).Count); + var refusal = Assert.Throws(() => Parse(Fleet(ConceptSubjectRegistry.MaxShips + 1))); + Assert.Equal(("ships", "TooMany"), (refusal.Field, refusal.Code)); + } + [Fact] public async Task Absent_keys_are_planned_in_key_order() { @@ -211,7 +377,7 @@ public sealed class ConceptSubjectRegistryTests using var world = new World(); var json = """{"ships":[{"id":"good","name":"Good"},{"id":"ship-a\n","name":"Bad"}]}"""; - Assert.Throws(() => ConceptSubjectRegistry.ParseManifest(json)); + Assert.Throws(() => Parse(json)); Assert.Empty(await world.Fresh(db => db.ConceptSubjects.ToListAsync())); } @@ -221,7 +387,7 @@ public sealed class ConceptSubjectRegistryTests const string canary = "CANARY-secret/value"; var json = $$"""{"ships":[{"id":"{{canary}}","name":"A"}]}"""; - var refusal = Assert.Throws(() => ConceptSubjectRegistry.ParseManifest(json)); + var refusal = Assert.Throws(() => Parse(json)); Assert.DoesNotContain("CANARY", refusal.Message); using var world = new World(); var collection = await Assert.ThrowsAsync(() => world.Registry(world.NewDb()).PlanAsync(canary, [], None, default)); @@ -329,6 +495,111 @@ public sealed class ConceptSubjectRegistryTests Assert.Equal(ConceptCommand.Usage, await ConceptCommand.RunAsync([], world.Services(), new StringWriter())); } + [Theory] + [InlineData("")] + [InlineData("CANARY\0path.json")] + public async Task Host_command_refuses_an_empty_or_nul_path(string path) + { + using var world = new World(); + + var output = new StringWriter(); + Assert.Equal(ConceptCommand.Refused, await ConceptCommand.RunAsync(["plan", Collection, path], world.Services(), output)); + + Assert.Equal("refused manifest Unreadable", output.ToString().Trim()); + } + + [Fact] + public async Task Host_command_refuses_a_directory_and_an_oversized_file() + { + using var world = new World(); + var big = Path.Combine(world.Dir, "big.json"); + await File.WriteAllBytesAsync(big, new byte[ConceptSubjectRegistry.MaxManifestBytes + 1]); + + var dir = new StringWriter(); + Assert.Equal(ConceptCommand.Refused, await ConceptCommand.RunAsync(["plan", Collection, world.Dir], world.Services(), dir)); + Assert.Equal("refused manifest Unreadable", dir.ToString().Trim()); + var large = new StringWriter(); + Assert.Equal(ConceptCommand.Refused, await ConceptCommand.RunAsync(["plan", Collection, big], world.Services(), large)); + Assert.Equal("refused manifest TooLarge", large.ToString().Trim()); + } + + [Fact] + public async Task Host_command_refuses_a_manifest_that_is_not_utf8_text() + { + using var world = new World(); + var path = Path.Combine(world.Dir, "latin1.json"); + await File.WriteAllBytesAsync(path, [.. "{\"ships\":[{\"id\":\"a\",\"name\":\"CANARY-"u8, 0xE9, .. "\"}]}"u8]); + + var output = new StringWriter(); + Assert.Equal(ConceptCommand.Refused, await ConceptCommand.RunAsync(["plan", Collection, path], world.Services(), output)); + + Assert.Equal("refused manifest NotUtf8", output.ToString().Trim()); + } + + [Fact] + public async Task Built_command_exits_with_its_documented_codes_and_echoes_no_input() + { + using var world = new World(); + var big = Path.Combine(world.Dir, "CANARYBIG.json"); + await File.WriteAllBytesAsync(big, new byte[ConceptSubjectRegistry.MaxManifestBytes + 1]); + var bidi = Path.Combine(world.Dir, "CANARYBIDI.json"); + await File.WriteAllTextAsync(bidi, """{"ships":[{"id":"a","name":"CANARYTITLE‮"}]}"""); + var dup = Path.Combine(world.Dir, "CANARYDUP.json"); + await File.WriteAllTextAsync(dup, """{"ships":[{"id":"CANARYID","id":"b","name":"A"}]}"""); + var surrogate = Path.Combine(world.Dir, "CANARYSURR.json"); + await File.WriteAllTextAsync(surrogate, """{"ships":[{"id":"a","name":"CANARYNAME\ud800"}]}"""); + var latin1 = Path.Combine(world.Dir, "CANARYLATIN.json"); + await File.WriteAllBytesAsync(latin1, [.. "{\"ships\":[{\"id\":\"a\",\"name\":\"CANARYBYTES"u8, 0xE9, .. "\"}]}"u8]); + + var cases = new (string[] Args, int Code)[] + { + (["plan", Collection, ""], ConceptCommand.Refused), + (["plan", Collection, Path.Combine(world.Dir, "CANARYMISSING.json")], ConceptCommand.Refused), + (["plan", Collection, world.Dir], ConceptCommand.Refused), + (["plan", Collection, big], ConceptCommand.Refused), + (["plan", Collection, bidi], ConceptCommand.Refused), + (["apply", Collection, dup], ConceptCommand.Refused), + (["apply", Collection, surrogate], ConceptCommand.Refused), + (["plan", Collection, latin1], ConceptCommand.Refused), + (["plan", "CANARYCOLLECTION", bidi], ConceptCommand.Refused), + (["frob", "CANARYARG", "x"], ConceptCommand.Usage), + (["plan"], ConceptCommand.Usage), + }; + foreach (var (args, code) in cases) + { + var result = await RunBuiltCommandAsync(args); + + Assert.True(code == result.ExitCode, $"case {args[0]}/{args.Length}: exit {result.ExitCode}, stdout {result.StdOut}, stderr {result.StdErr}"); + Assert.DoesNotContain("CANARY", result.StdOut + result.StdErr); + Assert.DoesNotContain("Unhandled exception", result.StdOut + result.StdErr); + } + } + + private static async Task<(int ExitCode, string StdOut, string StdErr)> RunBuiltCommandAsync(string[] args) + { + var dll = Path.Combine(AppContext.BaseDirectory, "Bifrost.Web.dll"); + Assert.True(File.Exists(Path.ChangeExtension(dll, ".runtimeconfig.json")), "Bifrost.Web.runtimeconfig.json is not beside the test assembly"); + var start = new ProcessStartInfo("dotnet") + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false + }; + foreach (var part in new[] { dll, "concepts" }.Concat(args)) + { + start.ArgumentList.Add(part); + } + + // Every case refuses before the database is touched; the password is a canary for leaks. + start.Environment["ConnectionStrings__Bifrost"] = "Host=127.0.0.1;Port=1;Database=x;Username=u;Password=CANARYPW"; + using var process = Process.Start(start)!; + var stdout = process.StandardOutput.ReadToEndAsync(); + var stderr = process.StandardError.ReadToEndAsync(); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(120)); + await process.WaitForExitAsync(timeout.Token); + return (process.ExitCode, await stdout, await stderr); + } + private sealed class World : IDisposable { private readonly string _dbName = $"registry-{Guid.NewGuid():N}"; From 9c4124dd9f360bb6c720cc3d4819f1ac3e2c3243 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 23:04:00 +0200 Subject: [PATCH 24/97] Registry tests: spell the separator and format characters as escapes Co-Authored-By: Claude Opus 5.5 --- .../ConceptSubjectRegistryTests.cs | 36 +++++++++---------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/tests/Bifrost.Web.Tests/ConceptSubjectRegistryTests.cs b/tests/Bifrost.Web.Tests/ConceptSubjectRegistryTests.cs index a67bf9a..36a72d2 100644 --- a/tests/Bifrost.Web.Tests/ConceptSubjectRegistryTests.cs +++ b/tests/Bifrost.Web.Tests/ConceptSubjectRegistryTests.cs @@ -246,27 +246,27 @@ public sealed class ConceptSubjectRegistryTests private static readonly JsonSerializerOptions Raw = new() { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }; [Theory] - [InlineData("‮evil")] - [InlineData("a‪b")] - [InlineData("a�b")] - [InlineData("a�b")] - [InlineData("a؜b")] - [InlineData("a‎b")] - [InlineData("a�b")] - [InlineData("a
b")] - [InlineData("a
b")] - [InlineData("a​b")] - [InlineData("a‌b")] - [InlineData("a�b")] - [InlineData("a�b")] - [InlineData("ab")] + [InlineData("\u202Eevil")] + [InlineData("a\u202Ab")] + [InlineData("a\u2066b")] + [InlineData("a\u2069b")] + [InlineData("a\u061Cb")] + [InlineData("a\u200Eb")] + [InlineData("a\u200Fb")] + [InlineData("a\u2028b")] + [InlineData("a\u2029b")] + [InlineData("a\u200Bb")] + [InlineData("a\u200Cb")] + [InlineData("a\u200Db")] + [InlineData("a\u2060b")] + [InlineData("a\uFEFFb")] [InlineData("a\u0085b")] [InlineData("a\tb")] [InlineData(" Ibis")] [InlineData("Ibis ")] - [InlineData(" Ibis")] - [InlineData("Ibis ")] - [InlineData(" Ibis")] + [InlineData("\u00A0Ibis")] + [InlineData("Ibis\u00A0")] + [InlineData("\u3000Ibis")] [InlineData("!!!")] public void A_title_with_a_format_character_edge_whitespace_or_no_letter_is_refused(string title) { @@ -543,7 +543,7 @@ public sealed class ConceptSubjectRegistryTests var big = Path.Combine(world.Dir, "CANARYBIG.json"); await File.WriteAllBytesAsync(big, new byte[ConceptSubjectRegistry.MaxManifestBytes + 1]); var bidi = Path.Combine(world.Dir, "CANARYBIDI.json"); - await File.WriteAllTextAsync(bidi, """{"ships":[{"id":"a","name":"CANARYTITLE‮"}]}"""); + await File.WriteAllTextAsync(bidi, """{"ships":[{"id":"a","name":"CANARYTITLE\u202E"}]}"""); var dup = Path.Combine(world.Dir, "CANARYDUP.json"); await File.WriteAllTextAsync(dup, """{"ships":[{"id":"CANARYID","id":"b","name":"A"}]}"""); var surrogate = Path.Combine(world.Dir, "CANARYSURR.json"); From 747183078a5ecd9f00fc8f40bc57308879798d8d Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 23:08:11 +0200 Subject: [PATCH 25/97] Registry tests: build the over-bound manifest as an array Co-Authored-By: Claude Opus 5.5 --- tests/Bifrost.Web.Tests/ConceptSubjectRegistryTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Bifrost.Web.Tests/ConceptSubjectRegistryTests.cs b/tests/Bifrost.Web.Tests/ConceptSubjectRegistryTests.cs index 36a72d2..07243e5 100644 --- a/tests/Bifrost.Web.Tests/ConceptSubjectRegistryTests.cs +++ b/tests/Bifrost.Web.Tests/ConceptSubjectRegistryTests.cs @@ -345,7 +345,7 @@ public sealed class ConceptSubjectRegistryTests Assert.Equal(ConceptSubjectRegistry.MaxManifestBytes, fits.Length); Assert.Single(ConceptSubjectRegistry.ParseManifest(fits)); - var refusal = Assert.Throws(() => ConceptSubjectRegistry.ParseManifest([.. fits, (byte)' '])); + var refusal = Assert.Throws(() => ConceptSubjectRegistry.ParseManifest(fits.Append((byte)' ').ToArray())); Assert.Equal(("manifest", "TooLarge"), (refusal.Field, refusal.Code)); } From c9e0b7b0e113cc27a3198e73a54bf69e3bfb982b Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 23:26:43 +0200 Subject: [PATCH 26/97] Registry tests: pin the order of already-retired entries Co-Authored-By: Claude Opus 5.5 --- tests/Bifrost.Web.Tests/ConceptSubjectRegistryTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Bifrost.Web.Tests/ConceptSubjectRegistryTests.cs b/tests/Bifrost.Web.Tests/ConceptSubjectRegistryTests.cs index 07243e5..c942457 100644 --- a/tests/Bifrost.Web.Tests/ConceptSubjectRegistryTests.cs +++ b/tests/Bifrost.Web.Tests/ConceptSubjectRegistryTests.cs @@ -152,7 +152,7 @@ public sealed class ConceptSubjectRegistryTests Assert.Equal( [("bison", SubjectChange.Unchanged), ("ibis-mk2", SubjectChange.Retired), ("kestrel", SubjectChange.Retired)], - second.Entries.OrderBy(e => e.Key, StringComparer.Ordinal).Select(e => (e.Key, e.Change))); + second.Entries.Select(e => (e.Key, e.Change))); Assert.Equal(audits, await world.Fresh(db => db.AuditEvents.CountAsync())); var after = await world.Fresh(db => db.ConceptSubjects.AsNoTracking().OrderBy(x => x.Key).ToListAsync()); Assert.Equal(before.Select(x => (x.Id, x.Title, x.RetiredAtUtc)), after.Select(x => (x.Id, x.Title, x.RetiredAtUtc))); From e0b3b1a38a80cb60dcffc8ed8b665f948e4d59f6 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 11 Oct 2026 00:09:25 +0200 Subject: [PATCH 27/97] Vote page: cookieless CSRF (Origin / Sec-Fetch-Site) and a defined error for handler-less requests Drops antiforgery from the anonymous vote flow: no antiforgery cookie, no hidden token. CastRequestGuard is the one decider; VoteModel.OnPageHandlerSelected is the one gate and answers 4xx when no handler is selected (was a NullReferenceException 500). Co-Authored-By: Claude Opus 5.5 --- .../Features/ConceptVotes/CastRequestGuard.cs | 29 +++ src/Bifrost.Web/Pages/Mill/Vote.cshtml | 6 +- src/Bifrost.Web/Pages/Mill/Vote.cshtml.cs | 26 ++- .../Bifrost.Web.Tests/ConceptVotePageTests.cs | 210 +++++++++++++----- 4 files changed, 209 insertions(+), 62 deletions(-) create mode 100644 src/Bifrost.Web/Features/ConceptVotes/CastRequestGuard.cs diff --git a/src/Bifrost.Web/Features/ConceptVotes/CastRequestGuard.cs b/src/Bifrost.Web/Features/ConceptVotes/CastRequestGuard.cs new file mode 100644 index 0000000..6af4a6b --- /dev/null +++ b/src/Bifrost.Web/Features/ConceptVotes/CastRequestGuard.cs @@ -0,0 +1,29 @@ +using Bifrost.Web.Configuration; +using Microsoft.AspNetCore.Http; + +namespace Bifrost.Web.Features.ConceptVotes; + +// The one place that decides whether a state-changing request to the vote page came from the site itself. The anonymous +// vote flow carries no antiforgery token and no antiforgery cookie, so a cross-site form post is refused here instead +// (OWASP Fetch Metadata): the Origin header must equal the configured site origin byte for byte, or, when the browser +// sent no Origin, Sec-Fetch-Site must be exactly same-origin. Same-site, cross-site, none, absent and a repeated header +// are all refused. Header values are compared, never echoed. +public static class CastRequestGuard +{ + public static bool Allows(HttpRequest request, BifrostHostOptions host) + { + if (!Uri.TryCreate(host.PublicBaseUrl, UriKind.Absolute, out var site)) + { + return false; + } + + var origin = request.Headers.Origin; + if (origin.Count > 0) + { + return origin.Count == 1 && string.Equals(origin[0], site.GetLeftPart(UriPartial.Authority), StringComparison.Ordinal); + } + + var fetchSite = request.Headers["Sec-Fetch-Site"]; + return fetchSite.Count == 1 && string.Equals(fetchSite[0], "same-origin", StringComparison.Ordinal); + } +} diff --git a/src/Bifrost.Web/Pages/Mill/Vote.cshtml b/src/Bifrost.Web/Pages/Mill/Vote.cshtml index daa82b1..fe75338 100644 --- a/src/Bifrost.Web/Pages/Mill/Vote.cshtml +++ b/src/Bifrost.Web/Pages/Mill/Vote.cshtml @@ -28,15 +28,15 @@

-
+
-
+
@if (stance is not null) { -
+
} diff --git a/src/Bifrost.Web/Pages/Mill/Vote.cshtml.cs b/src/Bifrost.Web/Pages/Mill/Vote.cshtml.cs index 2182f65..b25eec9 100644 --- a/src/Bifrost.Web/Pages/Mill/Vote.cshtml.cs +++ b/src/Bifrost.Web/Pages/Mill/Vote.cshtml.cs @@ -3,6 +3,7 @@ using Bifrost.Web.Domain; using Bifrost.Web.Features.ConceptVotes; using Bifrost.Web.Features.Shared; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.AspNetCore.Mvc.RazorPages; using Microsoft.Extensions.Options; @@ -10,11 +11,13 @@ namespace Bifrost.Web.Pages.Mill; // The anonymous vote page. Counts and the visitor's own stance come from ConceptAppraisalService on every GET; the // voter key is decided by VoterIdentity.For from the actor and the device id VoterKeyResolver reads. +[IgnoreAntiforgeryToken] public sealed class VoteModel( ICurrentBifrostActorAccessor actorAccessor, ConceptAppraisalService appraisals, VoterKeyResolver deviceCookie, - IOptions options) : PageModel + IOptions options, + IOptions host) : PageModel { public ConceptSubjectView Subject { get; private set; } = null!; @@ -22,6 +25,27 @@ public sealed class VoteModel( public string MillPageLink => options.Value.MillPageUrl + "#" + Uri.EscapeDataString(Subject.Key); + // The single gate before any handler runs. A request that selects no handler (a POST with no or an unknown handler, + // any other method) is a client error, never a view over a null subject. A cast is accepted only from the site's own + // origin (CastRequestGuard); no antiforgery cookie or token exists on this flow. + public override void OnPageHandlerSelected(PageHandlerSelectedContext context) + { + var method = context.HttpContext.Request.Method; + if (context.HandlerMethod is null) + { + context.Result = new StatusCodeResult( + HttpMethods.IsGet(method) || HttpMethods.IsHead(method) || HttpMethods.IsPost(method) + ? StatusCodes.Status400BadRequest + : StatusCodes.Status405MethodNotAllowed); + return; + } + + if (HttpMethods.IsPost(method) && !CastRequestGuard.Allows(context.HttpContext.Request, host.Value)) + { + context.Result = new StatusCodeResult(StatusCodes.Status403Forbidden); + } + } + public async Task OnGetAsync(string collection, string key, CancellationToken cancellationToken) { var actor = await actorAccessor.GetAsync(cancellationToken); diff --git a/tests/Bifrost.Web.Tests/ConceptVotePageTests.cs b/tests/Bifrost.Web.Tests/ConceptVotePageTests.cs index 2e081c8..bf719b7 100644 --- a/tests/Bifrost.Web.Tests/ConceptVotePageTests.cs +++ b/tests/Bifrost.Web.Tests/ConceptVotePageTests.cs @@ -118,21 +118,144 @@ public sealed partial class ConceptVotePageTests : IClassFixture h.Key == "Set-Cookie")); + Assert.Equal(string.Empty, await response.Content.ReadAsStringAsync()); + } + + Assert.Empty(await Rows(collection, "alpha")); + } + + [Theory] + [InlineData("same-origin", true)] + [InlineData("same-site", false)] + [InlineData("cross-site", false)] + [InlineData("none", false)] + [InlineData("Same-Origin", false)] + [InlineData("same-origin, cross-site", false)] + [InlineData(null, false)] + public async Task Cast_without_an_origin_header_needs_fetch_site_same_origin(string? fetchSite, bool accepted) + { + var collection = NewCollection(); + await AddSubject(collection, "alpha"); + var visitor = NewVisitor(anonymous: true); + + using var response = await visitor.Post(collection, "alpha", "Up", origin: null, fetchSite: fetchSite); + + Assert.Equal(accepted ? HttpStatusCode.Redirect : HttpStatusCode.Forbidden, response.StatusCode); + Assert.Equal(accepted ? 1 : 0, (await Rows(collection, "alpha")).Count); + } + + [Fact] + public async Task Refusal_never_echoes_the_origin_or_fetch_site_values() + { + const string canary = "https://CANARY-origin-5e1c.example"; + var logs = new CapturingLoggerProvider(); + var factory = _factory.WithWebHostBuilder(b => b.ConfigureServices(s => s.AddLogging(l => l.AddProvider(logs)))); + var collection = NewCollection(); + await AddSubject(collection, "alpha"); + var visitor = NewVisitor(anonymous: true, factory); + + using var byOrigin = await visitor.Post(collection, "alpha", "Up", origin: canary); + using var byFetchSite = await visitor.Post(collection, "alpha", "Up", origin: null, fetchSite: "CANARY-site-5e1c"); + + foreach (var response in new[] { byOrigin, byFetchSite }) + { + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + var seen = await response.Content.ReadAsStringAsync() + + string.Join("\n", response.Headers.Select(h => h.Key + ":" + string.Join(",", h.Value))); + Assert.DoesNotContain("CANARY", seen, StringComparison.OrdinalIgnoreCase); + } + + Assert.DoesNotContain(logs.Lines, line => line.Contains("CANARY", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public async Task A_page_view_sets_no_cookie_at_all_and_a_cast_sets_only_the_device_cookie() + { + var collection = NewCollection(); + await AddSubject(collection, "alpha"); + var visitor = NewVisitor(anonymous: true); + + using (var view = await visitor.Get(collection, "alpha")) + { + Assert.Equal(HttpStatusCode.OK, view.StatusCode); + Assert.False(view.Headers.Contains("Set-Cookie")); + var html = await view.Content.ReadAsStringAsync(); + Assert.DoesNotContain("__RequestVerificationToken", html, StringComparison.Ordinal); + } + + using var cast = await visitor.Post(collection, "alpha", "Up"); + var cookies = cast.Headers.GetValues("Set-Cookie").ToArray(); + Assert.Single(cookies); + Assert.StartsWith(CookieName + "=", cookies[0], StringComparison.Ordinal); + + using var later = await visitor.Get(collection, "alpha"); + Assert.False(later.Headers.Contains("Set-Cookie")); + } + + [Fact] + public async Task A_request_that_selects_no_handler_is_a_client_error_and_never_a_500() + { + var collection = NewCollection(); + await AddSubject(collection, "alpha"); + var visitor = NewVisitor(anonymous: true); + + using var noHandler = await visitor.Post(collection, "alpha", handler: null); + using var bogus = await visitor.Post(collection, "alpha", "CANARYQ9"); + using var put = await visitor.Post(collection, "alpha", "Up", method: HttpMethod.Put); + using var delete = await visitor.Post(collection, "alpha", null, method: HttpMethod.Delete); + using var getBogus = await visitor.Send(new HttpRequestMessage(HttpMethod.Get, $"/mill/{collection}/alpha?handler=CANARYQ9")); + using var getUp = await visitor.Send(new HttpRequestMessage(HttpMethod.Get, $"/mill/{collection}/alpha?handler=Up")); + + foreach (var response in new[] { noHandler, bogus, put, delete, getBogus, getUp }) + { + var status = (int)response.StatusCode; + Assert.InRange(status, 400, 499); + Assert.DoesNotContain("CANARYQ9", await response.Content.ReadAsStringAsync(), StringComparison.Ordinal); + Assert.False(response.Headers.Contains("Set-Cookie")); + } - Assert.Equal(HttpStatusCode.BadRequest, noToken.StatusCode); - Assert.Equal(HttpStatusCode.BadRequest, noCookie.StatusCode); - Assert.Equal(HttpStatusCode.BadRequest, garbage.StatusCode); - Assert.Null(SetCookieHeader(noToken, CookieName)); Assert.Empty(await Rows(collection, "alpha")); } @@ -228,7 +351,7 @@ public sealed partial class ConceptVotePageTests : IClassFixture(.*?)", RegexOptions.Singleline)] private static partial Regex DefinitionCell(); - [GeneratedRegex("name=\"__RequestVerificationToken\"[^>]*value=\"([^\"]+)\"")] - private static partial Regex RequestToken(); - private async Task AddSubject(string collection, string key) { using var scope = _factory.Services.CreateScope(); @@ -432,14 +552,6 @@ public sealed partial class ConceptVotePageTests : IClassFixture Jar { get; } = new(StringComparer.Ordinal); @@ -447,54 +559,36 @@ public sealed partial class ConceptVotePageTests : IClassFixture Get(string collection, string key) => Send(new HttpRequestMessage(HttpMethod.Get, $"/mill/{collection}/{key}")); - // Reads a token from the page of tokenFrom (default: the page being posted to), then posts the handler. - public async Task Post( + // A cookieless POST as a browser form would send it: Origin the site's own by default, null to omit it. + public Task Post( string collection, string key, - string handler, - Token token = Token.Valid, - (string Collection, string Key)? tokenFrom = null) + string? handler, + string? origin = SiteOrigin, + string? fetchSite = null, + HttpMethod? method = null) { - var from = tokenFrom ?? (collection, key); - string value; - using (var page = await Get(from.Collection, from.Key)) + var request = new HttpRequestMessage( + method ?? HttpMethod.Post, + $"/mill/{collection}/{key}" + (handler is null ? string.Empty : "?handler=" + handler)) { - var match = RequestToken().Match(await page.Content.ReadAsStringAsync()); - Assert.True(match.Success); - value = match.Groups[1].Value; - } - - var antiforgery = Jar - .Where(x => x.Key.StartsWith(".AspNetCore.Antiforgery", StringComparison.Ordinal)) - .ToList(); - if (token is Token.WithoutAntiforgeryCookie) - { - foreach (var cookie in antiforgery) - { - Jar.Remove(cookie.Key); - } - } - - var form = new List>(); - if (token is not Token.Omitted) - { - form.Add(new("__RequestVerificationToken", token is Token.Garbage ? "garbage" : value)); - } - - var request = new HttpRequestMessage(HttpMethod.Post, $"/mill/{collection}/{key}?handler={handler}") - { - Content = new FormUrlEncodedContent(form) + Content = new FormUrlEncodedContent([]) }; - var response = await Send(request); - foreach (var cookie in antiforgery) + if (origin is not null) { - Jar.TryAdd(cookie.Key, cookie.Value); + request.Headers.TryAddWithoutValidation("Origin", origin); } - return response; + if (fetchSite is not null) + { + request.Headers.TryAddWithoutValidation("Sec-Fetch-Site", fetchSite); + } + + return Send(request); + } } - private async Task Send(HttpRequestMessage request) + public async Task Send(HttpRequestMessage request) { if (anonymous) { From 59d5dbd0688dad1b56184f389ccbffe56032ad80 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 11 Oct 2026 00:11:01 +0200 Subject: [PATCH 28/97] Vote page: gate in the page filter's execution stage (the selected stage has no Result) Co-Authored-By: Claude Opus 5.5 --- src/Bifrost.Web/Pages/Mill/Vote.cshtml.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/Bifrost.Web/Pages/Mill/Vote.cshtml.cs b/src/Bifrost.Web/Pages/Mill/Vote.cshtml.cs index b25eec9..5912225 100644 --- a/src/Bifrost.Web/Pages/Mill/Vote.cshtml.cs +++ b/src/Bifrost.Web/Pages/Mill/Vote.cshtml.cs @@ -28,7 +28,7 @@ public sealed class VoteModel( // The single gate before any handler runs. A request that selects no handler (a POST with no or an unknown handler, // any other method) is a client error, never a view over a null subject. A cast is accepted only from the site's own // origin (CastRequestGuard); no antiforgery cookie or token exists on this flow. - public override void OnPageHandlerSelected(PageHandlerSelectedContext context) + public override async Task OnPageHandlerExecutionAsync(PageHandlerExecutingContext context, PageHandlerExecutionDelegate next) { var method = context.HttpContext.Request.Method; if (context.HandlerMethod is null) @@ -43,7 +43,10 @@ public sealed class VoteModel( if (HttpMethods.IsPost(method) && !CastRequestGuard.Allows(context.HttpContext.Request, host.Value)) { context.Result = new StatusCodeResult(StatusCodes.Status403Forbidden); + return; } + + await next(); } public async Task OnGetAsync(string collection, string key, CancellationToken cancellationToken) From 6554a1b0170ae21d47e370f66bb7ae5524a09919 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 11 Oct 2026 00:11:50 +0200 Subject: [PATCH 29/97] ConceptVotePageTests: fix a stray brace Co-Authored-By: Claude Opus 5.5 --- tests/Bifrost.Web.Tests/ConceptVotePageTests.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/Bifrost.Web.Tests/ConceptVotePageTests.cs b/tests/Bifrost.Web.Tests/ConceptVotePageTests.cs index bf719b7..1cfb902 100644 --- a/tests/Bifrost.Web.Tests/ConceptVotePageTests.cs +++ b/tests/Bifrost.Web.Tests/ConceptVotePageTests.cs @@ -583,7 +583,6 @@ public sealed partial class ConceptVotePageTests : IClassFixture Date: Sun, 11 Oct 2026 00:13:42 +0200 Subject: [PATCH 30/97] ConceptVotePageTests: close the Visitor class correctly Co-Authored-By: Claude Opus 5.5 --- tests/Bifrost.Web.Tests/ConceptVotePageTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Bifrost.Web.Tests/ConceptVotePageTests.cs b/tests/Bifrost.Web.Tests/ConceptVotePageTests.cs index 1cfb902..79d3645 100644 --- a/tests/Bifrost.Web.Tests/ConceptVotePageTests.cs +++ b/tests/Bifrost.Web.Tests/ConceptVotePageTests.cs @@ -583,9 +583,9 @@ public sealed partial class ConceptVotePageTests : IClassFixture Send(HttpRequestMessage request) { From d3a7611f2a3eda597d363b963cb2e73f45c88a07 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 11 Oct 2026 00:15:13 +0200 Subject: [PATCH 31/97] Vote page: explicit no-store (the antiforgery write no longer supplies it), handler must match the request, empty Origin counts as present Co-Authored-By: Claude Opus 5.5 --- .../Features/ConceptVotes/CastRequestGuard.cs | 2 +- src/Bifrost.Web/Pages/Mill/Vote.cshtml.cs | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/Bifrost.Web/Features/ConceptVotes/CastRequestGuard.cs b/src/Bifrost.Web/Features/ConceptVotes/CastRequestGuard.cs index 6af4a6b..ffd1b47 100644 --- a/src/Bifrost.Web/Features/ConceptVotes/CastRequestGuard.cs +++ b/src/Bifrost.Web/Features/ConceptVotes/CastRequestGuard.cs @@ -18,7 +18,7 @@ public static class CastRequestGuard } var origin = request.Headers.Origin; - if (origin.Count > 0) + if (request.Headers.ContainsKey("Origin")) { return origin.Count == 1 && string.Equals(origin[0], site.GetLeftPart(UriPartial.Authority), StringComparison.Ordinal); } diff --git a/src/Bifrost.Web/Pages/Mill/Vote.cshtml.cs b/src/Bifrost.Web/Pages/Mill/Vote.cshtml.cs index 5912225..525811d 100644 --- a/src/Bifrost.Web/Pages/Mill/Vote.cshtml.cs +++ b/src/Bifrost.Web/Pages/Mill/Vote.cshtml.cs @@ -12,6 +12,7 @@ namespace Bifrost.Web.Pages.Mill; // The anonymous vote page. Counts and the visitor's own stance come from ConceptAppraisalService on every GET; the // voter key is decided by VoterIdentity.For from the actor and the device id VoterKeyResolver reads. [IgnoreAntiforgeryToken] +[ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)] public sealed class VoteModel( ICurrentBifrostActorAccessor actorAccessor, ConceptAppraisalService appraisals, @@ -40,6 +41,14 @@ public sealed class VoteModel( return; } + // The handler the request names must be the handler selected: GET ?handler=Up has no OnGetUp to run. + var named = context.HttpContext.Request.Query["handler"].ToString(); + if (string.IsNullOrEmpty(named) != string.IsNullOrEmpty(context.HandlerMethod.Name)) + { + context.Result = new StatusCodeResult(StatusCodes.Status400BadRequest); + return; + } + if (HttpMethods.IsPost(method) && !CastRequestGuard.Allows(context.HttpContext.Request, host.Value)) { context.Result = new StatusCodeResult(StatusCodes.Status403Forbidden); From 75d2cedaa288c2323dbb654946db0ee3462612e1 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 11 Oct 2026 00:18:06 +0200 Subject: [PATCH 32/97] ConceptVotePageTests: drop the empty-Origin case (HttpClient never sends an empty header) Co-Authored-By: Claude Opus 5.5 --- tests/Bifrost.Web.Tests/ConceptVotePageTests.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/Bifrost.Web.Tests/ConceptVotePageTests.cs b/tests/Bifrost.Web.Tests/ConceptVotePageTests.cs index 79d3645..dd4b280 100644 --- a/tests/Bifrost.Web.Tests/ConceptVotePageTests.cs +++ b/tests/Bifrost.Web.Tests/ConceptVotePageTests.cs @@ -145,7 +145,6 @@ public sealed partial class ConceptVotePageTests : IClassFixture h.Key == "Set-Cookie")); + Assert.False(response.Headers.Contains("Set-Cookie")); Assert.Equal(string.Empty, await response.Content.ReadAsStringAsync()); } From 526102ec8a996a778699c775dbe91e4dc5be7c4d Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 11 Oct 2026 00:26:21 +0200 Subject: [PATCH 33/97] ConceptVotePageTests: pin a repeated Origin header and the exact client-error codes Co-Authored-By: Claude Opus 5.5 --- .../Bifrost.Web.Tests/ConceptVotePageTests.cs | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/tests/Bifrost.Web.Tests/ConceptVotePageTests.cs b/tests/Bifrost.Web.Tests/ConceptVotePageTests.cs index dd4b280..1870b6f 100644 --- a/tests/Bifrost.Web.Tests/ConceptVotePageTests.cs +++ b/tests/Bifrost.Web.Tests/ConceptVotePageTests.cs @@ -165,6 +165,24 @@ public sealed partial class ConceptVotePageTests : IClassFixture Date: Sun, 11 Oct 2026 02:54:13 +0200 Subject: [PATCH 34/97] Heimdall sign-in: accept only a Heimdall-signed token for an attempt Start issued, in the browser that started it The callback verifies the EdDSA access token against Heimdall's JWKS and takes the account, display name and member access from its claims; attempts expire, are bounded, are completed only if issued, and are redeemed once with a per-attempt binding cookie. Not yet built or tested; the first verification run follows. Co-Authored-By: Claude Opus 5.5 --- src/Bifrost.Web/Bifrost.Web.csproj | 1 + .../Features/Heimdall/HeimdallAuthService.cs | 196 +++-- .../Heimdall/HeimdallTokenVerifier.cs | 249 ++++++ src/Bifrost.Web/Program.cs | 23 +- .../HeimdallCallbackTests.cs | 785 ++++++++++++++++++ .../Support/HeimdallTestKit.cs | 362 ++++++++ 6 files changed, 1535 insertions(+), 81 deletions(-) create mode 100644 src/Bifrost.Web/Features/Heimdall/HeimdallTokenVerifier.cs create mode 100644 tests/Bifrost.Web.Tests/HeimdallCallbackTests.cs create mode 100644 tests/Bifrost.Web.Tests/Support/HeimdallTestKit.cs diff --git a/src/Bifrost.Web/Bifrost.Web.csproj b/src/Bifrost.Web/Bifrost.Web.csproj index 2b21de6..09a3ea4 100644 --- a/src/Bifrost.Web/Bifrost.Web.csproj +++ b/src/Bifrost.Web/Bifrost.Web.csproj @@ -11,6 +11,7 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/src/Bifrost.Web/Features/Heimdall/HeimdallAuthService.cs b/src/Bifrost.Web/Features/Heimdall/HeimdallAuthService.cs index 88575f2..293aa3c 100644 --- a/src/Bifrost.Web/Features/Heimdall/HeimdallAuthService.cs +++ b/src/Bifrost.Web/Features/Heimdall/HeimdallAuthService.cs @@ -17,6 +17,7 @@ namespace Bifrost.Web.Features.Heimdall; public sealed class HeimdallAuthService( HttpClient httpClient, HeimdallAuthAttemptStore attemptStore, + HeimdallTokenVerifier tokenVerifier, BifrostDbContext dbContext, BifrostIdentityService bifrostIdentityService, IOptions heimdallOptions, @@ -38,7 +39,7 @@ public sealed class HeimdallAuthService( { if (!AllowedProviders.Contains(provider)) { - throw new HeimdallAuthException($"Unsupported Heimdall provider '{provider}'."); + throw new HeimdallAuthException("Unsupported Heimdall provider."); } var options = heimdallOptions.Value; @@ -67,8 +68,7 @@ public sealed class HeimdallAuthService( if (!response.IsSuccessStatusCode) { - var detail = await response.Content.ReadAsStringAsync(cancellationToken); - throw new HeimdallAuthException($"Heimdall rejected {provider} auth start: {detail}"); + throw new HeimdallAuthException("Heimdall rejected the auth start."); } var payload = await response.Content.ReadFromJsonAsync(cancellationToken); @@ -77,59 +77,59 @@ public sealed class HeimdallAuthService( throw new HeimdallAuthException("Heimdall auth start returned no authorization URL."); } - attemptStore.Create(attemptId, returnUrl); - attemptStore.SetRequiresMemberAccess(attemptId, requireMemberAccess); - return new HeimdallAuthStartResult(new Uri(payload.AuthorizationUrl, UriKind.Absolute), attemptId); + var binding = attemptStore.Create(attemptId, provider, returnUrl, requireMemberAccess); + return new HeimdallAuthStartResult(new Uri(payload.AuthorizationUrl, UriKind.Absolute), attemptId, binding); } - public void StoreBackendCallback(HeimdallBackendCallbackPayload payload) + public async Task StoreBackendCallbackAsync( + HeimdallBackendCallbackPayload payload, + CancellationToken cancellationToken) { - if (!string.Equals(payload.AppSlug, heimdallOptions.Value.AppSlug, StringComparison.OrdinalIgnoreCase)) - { - throw new HeimdallAuthException("Heimdall callback targeted a different app."); - } - if (string.IsNullOrWhiteSpace(payload.AttemptId)) { throw new HeimdallAuthException("Heimdall callback omitted attempt id."); } - attemptStore.Complete(payload.AttemptId, payload); + if (string.IsNullOrWhiteSpace(payload.AccessToken)) + { + throw new HeimdallAuthException("Heimdall callback carried no access token."); + } + + var identity = await tokenVerifier.VerifyAsync(payload.AccessToken, payload.Account?.Id, cancellationToken); + attemptStore.Complete(payload.AttemptId, identity); } public async Task CompleteAttemptAsync( string attemptId, + string? binding, CancellationToken cancellationToken) { - var attempt = attemptStore.TryTake(attemptId) - ?? throw new HeimdallAuthPendingException("Heimdall has not delivered this auth attempt yet."); - var payload = attempt.Payload - ?? throw new HeimdallAuthPendingException("Heimdall has not delivered this auth attempt yet."); - - if (!payload.IsSuccessful) - { - throw new HeimdallAuthException(payload.ErrorDescription ?? payload.Error ?? "Heimdall auth did not succeed."); - } - - if (attempt.RequiresMemberAccess && !payload.HasMemberAccess) + var attempt = attemptStore.TryTake(attemptId, binding); + var identity = attempt.Identity; + if (attempt.RequiresMemberAccess && !identity.HasMemberAccess) { throw new HeimdallAuthException("Heimdall did not grant Bifrost member access."); } - var userAccount = await UpsertUserAccountAsync(payload, attempt.RequiresMemberAccess, cancellationToken); + var userAccount = await UpsertUserAccountAsync( + identity, + attempt.Provider, + attempt.RequiresMemberAccess, + cancellationToken); return new HeimdallCompletedSignIn( - bifrostIdentityService.BuildPrincipal(userAccount, payload.Provider ?? "heimdall"), + bifrostIdentityService.BuildPrincipal(userAccount, attempt.Provider), string.IsNullOrWhiteSpace(attempt.ReturnUrl) ? "/App" : attempt.ReturnUrl); } private async Task UpsertUserAccountAsync( - HeimdallBackendCallbackPayload payload, + HeimdallVerifiedIdentity identity, + string provider, bool grantMemberAccess, CancellationToken cancellationToken) { var now = timeProvider.GetUtcNow(); - var accountId = payload.Account?.Id ?? throw new HeimdallAuthException("Heimdall response omitted account id."); - var displayName = payload.Account.DisplayName ?? accountId; + var accountId = identity.AccountId; + var displayName = identity.DisplayName ?? accountId; var userAccount = await dbContext.UserAccounts .Include(x => x.MemberProfile) @@ -150,7 +150,7 @@ public sealed class HeimdallAuthService( MemberProfile = new MemberProfile { Nickname = displayName, - Headline = $"Verified through Heimdall {payload.Provider}", + Headline = $"Verified through Heimdall {provider}", UpdatedAtUtc = now }, Membership = new Bifrost.Web.Domain.Membership @@ -160,7 +160,7 @@ public sealed class HeimdallAuthService( ApprovedAtUtc = grantMemberAccess ? now : null, Notes = grantMemberAccess ? "Activated by Heimdall member access claim" - : $"Identity verified through Heimdall {payload.Provider}" + : $"Identity verified through Heimdall {provider}" } }; dbContext.UserAccounts.Add(userAccount); @@ -173,7 +173,7 @@ public sealed class HeimdallAuthService( { UserAccountId = userAccount.Id, Nickname = displayName, - Headline = $"Verified through Heimdall {payload.Provider}", + Headline = $"Verified through Heimdall {provider}", UpdatedAtUtc = now }; userAccount.Membership ??= new Bifrost.Web.Domain.Membership @@ -264,45 +264,96 @@ public class HeimdallAuthException(string message) : InvalidOperationException(m public sealed class HeimdallAuthPendingException(string message) : HeimdallAuthException(message); -public sealed record HeimdallAuthStartResult(Uri AuthorizationUrl, string AttemptId); +public sealed record HeimdallAuthStartResult(Uri AuthorizationUrl, string AttemptId, string Binding); public sealed record HeimdallCompletedSignIn(ClaimsPrincipal Principal, string RedirectUri); -public sealed class HeimdallAuthAttemptStore +/// +/// Attempts Start issued. An attempt lives for , is completed only by a verified +/// Heimdall identity, and is redeemed once by the browser that holds its binding value. The store holds at +/// most attempts; a full store drops the one that expires first. +/// +public sealed class HeimdallAuthAttemptStore(TimeProvider timeProvider) { - private readonly ConcurrentDictionary _attempts = new(StringComparer.Ordinal); + public static readonly TimeSpan Lifetime = TimeSpan.FromMinutes(10); + public const int Capacity = 1024; - public void Create(string attemptId, string returnUrl) + private readonly object _gate = new(); + private readonly Dictionary _attempts = new(StringComparer.Ordinal); + + public string Create(string attemptId, string provider, string returnUrl, bool requiresMemberAccess) { - _attempts[attemptId] = new HeimdallAuthAttempt(returnUrl, null); + var now = timeProvider.GetUtcNow(); + var binding = Microsoft.AspNetCore.WebUtilities.WebEncoders.Base64UrlEncode( + System.Security.Cryptography.RandomNumberGenerator.GetBytes(32)); + lock (_gate) + { + while (_attempts.Count >= Capacity) + { + _attempts.Remove(_attempts.MinBy(x => x.Value.ExpiresAt).Key); + } + + _attempts[attemptId] = new HeimdallAuthAttempt( + provider, returnUrl, requiresMemberAccess, binding, now + Lifetime, null); + } + + return binding; } - public void SetRequiresMemberAccess(string attemptId, bool requiresMemberAccess) + public void Complete(string attemptId, HeimdallVerifiedIdentity identity) { - _attempts.AddOrUpdate( - attemptId, - _ => new HeimdallAuthAttempt("/App", null, requiresMemberAccess), - (_, existing) => existing with { RequiresMemberAccess = requiresMemberAccess }); + lock (_gate) + { + if (!_attempts.TryGetValue(attemptId, out var attempt) || + attempt.ExpiresAt <= timeProvider.GetUtcNow() || + attempt.Identity is not null) + { + throw new HeimdallAuthException("Heimdall callback did not match a pending attempt."); + } + + _attempts[attemptId] = attempt with { Identity = identity }; + } } - public void Complete(string attemptId, HeimdallBackendCallbackPayload payload) + public HeimdallRedeemedAttempt TryTake(string attemptId, string? binding) { - _attempts.AddOrUpdate( - attemptId, - _ => new HeimdallAuthAttempt(payload.ReturnTo ?? "/App", payload), - (_, existing) => existing with { Payload = payload }); - } + lock (_gate) + { + if (!_attempts.TryGetValue(attemptId, out var attempt) || + attempt.ExpiresAt <= timeProvider.GetUtcNow() || + binding is null || + !System.Security.Cryptography.CryptographicOperations.FixedTimeEquals( + System.Text.Encoding.UTF8.GetBytes(binding), + System.Text.Encoding.UTF8.GetBytes(attempt.Binding))) + { + throw new HeimdallAuthException("Heimdall auth attempt is not valid for this browser."); + } - public HeimdallAuthAttempt? TryTake(string attemptId) - { - return _attempts.TryRemove(attemptId, out var attempt) ? attempt : null; + if (attempt.Identity is null) + { + throw new HeimdallAuthPendingException("Heimdall has not delivered this auth attempt yet."); + } + + _attempts.Remove(attemptId); + return new HeimdallRedeemedAttempt( + attempt.Provider, attempt.ReturnUrl, attempt.RequiresMemberAccess, attempt.Identity); + } } } public sealed record HeimdallAuthAttempt( + string Provider, string ReturnUrl, - HeimdallBackendCallbackPayload? Payload, - bool RequiresMemberAccess = false); + bool RequiresMemberAccess, + string Binding, + DateTimeOffset ExpiresAt, + HeimdallVerifiedIdentity? Identity); + +public sealed record HeimdallRedeemedAttempt( + string Provider, + string ReturnUrl, + bool RequiresMemberAccess, + HeimdallVerifiedIdentity Identity); public sealed record HeimdallStartRequest( [property: JsonPropertyName("appSlug")] string AppSlug, @@ -333,29 +384,22 @@ public sealed record HeimdallStartResponse( public sealed record HeimdallBackendCallbackPayload( [property: JsonPropertyName("attemptId")] string AttemptId, - [property: JsonPropertyName("appSlug")] string? AppSlug, - [property: JsonPropertyName("status")] string? Status, - [property: JsonPropertyName("provider")] string? Provider, - [property: JsonPropertyName("returnTo")] string? ReturnTo, - [property: JsonPropertyName("error")] string? Error, - [property: JsonPropertyName("errorDescription")] string? ErrorDescription, - [property: JsonPropertyName("account")] HeimdallAccount? Account, - [property: JsonPropertyName("sharedCapabilities")] string[]? SharedCapabilities, - [property: JsonPropertyName("entitlements")] HeimdallEntitlements? Entitlements) -{ - public bool IsSuccessful => string.Equals(Status, "success", StringComparison.OrdinalIgnoreCase); - - public bool HasMemberAccess => - SharedCapabilities?.Any(capability => - string.Equals(capability, "member_access", StringComparison.OrdinalIgnoreCase) || - string.Equals(capability, "app_access", StringComparison.OrdinalIgnoreCase)) == true || - Entitlements?.Facts?.Any(fact => - string.Equals(fact, "entitlement.app_access", StringComparison.OrdinalIgnoreCase)) == true; -} + [property: JsonPropertyName("accessToken")] string? AccessToken, + [property: JsonPropertyName("account")] HeimdallAccount? Account); public sealed record HeimdallAccount( - [property: JsonPropertyName("id")] string Id, - [property: JsonPropertyName("displayName")] string? DisplayName); + [property: JsonPropertyName("id")] string Id); -public sealed record HeimdallEntitlements( - [property: JsonPropertyName("facts")] string[]? Facts); +public static class HeimdallBindingCookie +{ + public const string Name = "bifrost_heimdall_binding"; + + public static CookieOptions Options() => new() + { + HttpOnly = true, + Secure = true, + SameSite = SameSiteMode.Lax, + Path = "/auth/heimdall", + MaxAge = HeimdallAuthAttemptStore.Lifetime + }; +} diff --git a/src/Bifrost.Web/Features/Heimdall/HeimdallTokenVerifier.cs b/src/Bifrost.Web/Features/Heimdall/HeimdallTokenVerifier.cs new file mode 100644 index 0000000..dab753b --- /dev/null +++ b/src/Bifrost.Web/Features/Heimdall/HeimdallTokenVerifier.cs @@ -0,0 +1,249 @@ +using System.Text.Json; +using Bifrost.Web.Configuration; +using Microsoft.AspNetCore.WebUtilities; +using Microsoft.Extensions.Options; +using Org.BouncyCastle.Math.EC.Rfc8032; + +namespace Bifrost.Web.Features.Heimdall; + +/// +/// The identity a Heimdall-signed access token asserts. Everything here comes from verified claims. +/// +public sealed record HeimdallVerifiedIdentity(string AccountId, string? DisplayName, bool HasMemberAccess); + +/// +/// Verifies Heimdall's access tokens (EdDSA) against Heimdall's published JWKS. Heimdall is the issuer +/// at ; the audience is this app's slug. Failures name a rule and +/// never echo the token or any of its contents. +/// +public sealed class HeimdallTokenVerifier( + HttpClient httpClient, + IOptions heimdallOptions, + TimeProvider timeProvider) +{ + public const string TokenType = "heimdall_access"; + + internal static readonly TimeSpan ClockSkew = TimeSpan.FromSeconds(60); + internal static readonly TimeSpan KeyRefreshInterval = TimeSpan.FromSeconds(30); + internal static readonly TimeSpan KeyMaxAge = TimeSpan.FromHours(1); + + private static readonly string[] MemberCapabilities = ["member_access", "app_access"]; + + private readonly SemaphoreSlim _fetchGate = new(1, 1); + private Dictionary _keys = new(StringComparer.Ordinal); + private DateTimeOffset _fetchedAt = DateTimeOffset.MinValue; + private DateTimeOffset _attemptedAt = DateTimeOffset.MinValue; + + public async Task VerifyAsync( + string token, + string? payloadAccountId, + CancellationToken cancellationToken) + { + var options = heimdallOptions.Value; + var parts = token.Split('.'); + if (parts.Length != 3) + { + throw Refuse("token is not a compact JWS"); + } + + byte[] headerBytes, payloadBytes, signature; + try + { + headerBytes = WebEncoders.Base64UrlDecode(parts[0]); + payloadBytes = WebEncoders.Base64UrlDecode(parts[1]); + signature = WebEncoders.Base64UrlDecode(parts[2]); + } + catch (FormatException) + { + throw Refuse("token is not base64url"); + } + + string kid; + try + { + using var header = JsonDocument.Parse(headerBytes); + if (header.RootElement.ValueKind != JsonValueKind.Object || + StringClaim(header.RootElement, "alg") != "EdDSA") + { + throw Refuse("token algorithm is not EdDSA"); + } + + kid = StringClaim(header.RootElement, "kid") is { Length: > 0 } found + ? found + : throw Refuse("token has no key id"); + } + catch (JsonException) + { + throw Refuse("token header is not JSON"); + } + + var publicKey = await FindKeyAsync(kid, cancellationToken); + if (publicKey is null) + { + throw Refuse("token key is not published"); + } + + var signingInput = System.Text.Encoding.ASCII.GetBytes($"{parts[0]}.{parts[1]}"); + if (signature.Length != Ed25519.SignatureSize || + !Ed25519.Verify(signature, 0, publicKey, 0, signingInput, 0, signingInput.Length)) + { + throw Refuse("token signature is invalid"); + } + + try + { + using var payload = JsonDocument.Parse(payloadBytes); + return CheckClaims(payload.RootElement, options, payloadAccountId); + } + catch (JsonException) + { + throw Refuse("token payload is not JSON"); + } + } + + private HeimdallVerifiedIdentity CheckClaims(JsonElement claims, HeimdallOptions options, string? payloadAccountId) + { + if (claims.ValueKind != JsonValueKind.Object) + { + throw Refuse("token payload is not an object"); + } + + if (StringClaim(claims, "iss") != options.BaseUrl.TrimEnd('/')) + { + throw Refuse("token issuer is not Heimdall"); + } + + if (StringClaim(claims, "aud") != options.AppSlug) + { + throw Refuse("token audience is not this app"); + } + + if (StringClaim(claims, "typ") != TokenType) + { + throw Refuse("token type is not an access token"); + } + + if (!claims.TryGetProperty("app", out var app) || + app.ValueKind != JsonValueKind.Object || + StringClaim(app, "slug") != options.AppSlug) + { + throw Refuse("token app is not this app"); + } + + var now = timeProvider.GetUtcNow(); + if (NumericDate(claims, "nbf") is not { } notBefore || now < notBefore - ClockSkew) + { + throw Refuse("token is not yet valid"); + } + + if (NumericDate(claims, "exp") is not { } expires || now >= expires + ClockSkew) + { + throw Refuse("token has expired"); + } + + var subject = StringClaim(claims, "sub"); + if (string.IsNullOrEmpty(subject) || + subject != StringClaim(claims, "account_id") || + subject != payloadAccountId) + { + throw Refuse("token subject does not match the account"); + } + + var capabilities = new List(); + if (claims.TryGetProperty("capabilities", out var list) && list.ValueKind == JsonValueKind.Array) + { + foreach (var capability in list.EnumerateArray()) + { + if (capability.ValueKind == JsonValueKind.String) + { + capabilities.Add(capability.GetString()!); + } + } + } + + return new HeimdallVerifiedIdentity( + subject, + StringClaim(claims, "display_name"), + capabilities.Any(capability => MemberCapabilities.Contains(capability, StringComparer.OrdinalIgnoreCase))); + } + + private async Task FindKeyAsync(string kid, CancellationToken cancellationToken) + { + await _fetchGate.WaitAsync(cancellationToken); + try + { + var now = timeProvider.GetUtcNow(); + var stale = now - _fetchedAt >= KeyMaxAge; + if ((stale || !_keys.ContainsKey(kid)) && now - _attemptedAt >= KeyRefreshInterval) + { + _attemptedAt = now; + _keys = await FetchKeysAsync(cancellationToken); + _fetchedAt = now; + } + + return _keys.GetValueOrDefault(kid); + } + finally + { + _fetchGate.Release(); + } + } + + private async Task> FetchKeysAsync(CancellationToken cancellationToken) + { + try + { + using var response = await httpClient.GetAsync("/.well-known/jwks.json", cancellationToken); + response.EnsureSuccessStatusCode(); + await using var body = await response.Content.ReadAsStreamAsync(cancellationToken); + using var document = await JsonDocument.ParseAsync(body, cancellationToken: cancellationToken); + var keys = new Dictionary(StringComparer.Ordinal); + if (document.RootElement.TryGetProperty("keys", out var list) && list.ValueKind == JsonValueKind.Array) + { + foreach (var jwk in list.EnumerateArray()) + { + if (jwk.ValueKind != JsonValueKind.Object || + StringClaim(jwk, "kty") != "OKP" || + StringClaim(jwk, "crv") != "Ed25519" || + StringClaim(jwk, "kid") is not { Length: > 0 } id || + StringClaim(jwk, "x") is not { } x) + { + continue; + } + + var key = WebEncoders.Base64UrlDecode(x); + if (key.Length == Ed25519.PublicKeySize) + { + keys[id] = key; + } + } + } + + return keys; + } + catch (Exception error) when (error is HttpRequestException or JsonException or FormatException) + { + throw Refuse("Heimdall keys could not be fetched"); + } + } + + private static string? StringClaim(JsonElement element, string name) => + element.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.String + ? value.GetString() + : null; + + private static DateTimeOffset? NumericDate(JsonElement element, string name) + { + if (!element.TryGetProperty(name, out var value) || + value.ValueKind != JsonValueKind.Number || + !value.TryGetInt64(out var seconds) || + seconds is < 0 or > 253402300799) + { + return null; + } + + return DateTimeOffset.FromUnixTimeSeconds(seconds); + } + + private static HeimdallAuthException Refuse(string rule) => new($"Heimdall token refused: {rule}."); +} diff --git a/src/Bifrost.Web/Program.cs b/src/Bifrost.Web/Program.cs index 94276e1..3eb149d 100644 --- a/src/Bifrost.Web/Program.cs +++ b/src/Bifrost.Web/Program.cs @@ -130,6 +130,11 @@ builder.Services.AddHttpClient((serviceProvider, client) => var options = serviceProvider.GetRequiredService>().Value; client.BaseAddress = new Uri(options.BaseUrl.TrimEnd('/')); }); +builder.Services.AddHttpClient((serviceProvider, client) => +{ + var options = serviceProvider.GetRequiredService>().Value; + client.BaseAddress = new Uri(options.BaseUrl.TrimEnd('/')); +}); builder.Services.AddHttpClient(client => { client.BaseAddress = new Uri("https://api.stripe.com/"); @@ -388,6 +393,7 @@ app.MapGet("/auth/heimdall/{provider}", async ( requireMemberAccess, cancellationToken); + httpContext.Response.Cookies.Append(HeimdallBindingCookie.Name, start.Binding, HeimdallBindingCookie.Options()); return Results.Redirect(start.AuthorizationUrl.ToString()); } catch (HeimdallAuthException error) @@ -396,18 +402,21 @@ app.MapGet("/auth/heimdall/{provider}", async ( } }).AllowAnonymous(); -app.MapPost("/auth/heimdall/callback", ( +app.MapPost("/auth/heimdall/callback", async ( HeimdallBackendCallbackPayload payload, - HeimdallAuthService heimdallAuthService) => + HeimdallAuthService heimdallAuthService, + ILogger logger, + CancellationToken cancellationToken) => { try { - heimdallAuthService.StoreBackendCallback(payload); + await heimdallAuthService.StoreBackendCallbackAsync(payload, cancellationToken); return Results.Accepted(); } catch (HeimdallAuthException error) { - return Results.BadRequest(error.Message); + logger.LogWarning("Heimdall callback refused: {Reason}", error.Message); + return Results.BadRequest("Heimdall callback refused."); } }).AllowAnonymous(); @@ -546,8 +555,12 @@ app.MapGet("/auth/heimdall/wait", async ( try { - var signIn = await heimdallAuthService.CompleteAttemptAsync(attemptId, cancellationToken); + var signIn = await heimdallAuthService.CompleteAttemptAsync( + attemptId, + httpContext.Request.Cookies[HeimdallBindingCookie.Name], + cancellationToken); await httpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, signIn.Principal); + httpContext.Response.Cookies.Delete(HeimdallBindingCookie.Name, HeimdallBindingCookie.Options()); return Results.Redirect(signIn.RedirectUri); } catch (HeimdallAuthPendingException) diff --git a/tests/Bifrost.Web.Tests/HeimdallCallbackTests.cs b/tests/Bifrost.Web.Tests/HeimdallCallbackTests.cs new file mode 100644 index 0000000..619688b --- /dev/null +++ b/tests/Bifrost.Web.Tests/HeimdallCallbackTests.cs @@ -0,0 +1,785 @@ +using System.Net; +using System.Text; +using System.Text.Json; +using Bifrost.Web.Domain; +using Bifrost.Web.Features.Heimdall; +using Bifrost.Web.Tests.Support; +using Microsoft.AspNetCore.WebUtilities; + +namespace Bifrost.Web.Tests; + +/// +/// Sign-in through the Heimdall callback, against a local Bifrost with a test JWKS. Deliveries are shaped +/// like Heimdall's backend-handoff.ts payload and claims.ts claim set; forgeries differ from them in the +/// one property under test. +/// +public sealed class HeimdallCallbackTests +{ + private static string Flip(string value) => value[..^1] + (value[^1] == '0' ? '1' : '0'); + + private static async Task DeliverGenuine( + HeimdallHost host, StartedAttempt attempt, string accountId, string[]? capabilities = null, string? displayName = "Genuine Name") + { + return await host.DeliverAsync(HeimdallShapes.Delivery( + attempt.AttemptId, accountId, host.Token(accountId, capabilities, displayName))); + } + + private static async Task AssertRefused(HeimdallHost host, StartedAttempt attempt, string accountId, HttpResponseMessage delivery) + { + Assert.Equal(HttpStatusCode.BadRequest, delivery.StatusCode); + var wait = await host.WaitAsync(attempt.AttemptId, attempt.Binding); + Assert.False(wait.SignedIn); + Assert.Equal(HttpStatusCode.OK, wait.Response.StatusCode); + Assert.Null(await host.Account(accountId)); + } + + [Fact] + public async Task Genuine_delivery_signs_in_the_account_the_token_names() + { + using var host = new HeimdallHost(); + var accountId = HeimdallShapes.NewAccountId(); + var attempt = await host.StartAsync(returnUrl: "/App/Mill"); + + using var delivery = await DeliverGenuine(host, attempt, accountId); + Assert.Equal(HttpStatusCode.Accepted, delivery.StatusCode); + + var wait = await host.WaitAsync(attempt.AttemptId, attempt.Binding); + Assert.True(wait.SignedIn); + Assert.Equal(HttpStatusCode.Redirect, wait.Response.StatusCode); + Assert.Equal("/App/Mill", wait.Response.Headers.Location!.OriginalString); + + var account = await host.Account(accountId); + Assert.NotNull(account); + Assert.Equal("Genuine Name", account!.DisplayName); + Assert.Equal(MembershipStatus.Authenticated, account.Membership!.Status); + Assert.Empty(account.Membership.RoleAssignments); + + var cleared = HeimdallHost.SetCookieHeader(wait.Response, HeimdallHost.BindingCookie); + Assert.NotNull(cleared); + Assert.Contains("1970", cleared); + } + + [Fact] + public async Task The_display_name_is_the_claim_and_falls_back_to_the_account_id() + { + using var host = new HeimdallHost(); + var named = HeimdallShapes.NewAccountId(); + var unnamed = HeimdallShapes.NewAccountId(); + + var first = await host.StartAsync(); + await DeliverGenuine(host, first, named, displayName: "Claimed Name"); + Assert.True((await host.WaitAsync(first.AttemptId, first.Binding)).SignedIn); + + var second = await host.StartAsync(); + await DeliverGenuine(host, second, unnamed, displayName: null); + Assert.True((await host.WaitAsync(second.AttemptId, second.Binding)).SignedIn); + + Assert.Equal("Claimed Name", (await host.Account(named))!.DisplayName); + Assert.Equal(unnamed, (await host.Account(unnamed))!.DisplayName); + } + + [Fact] + public async Task A_callback_without_an_access_token_signs_nobody_in() + { + using var host = new HeimdallHost(); + var accountId = HeimdallShapes.NewAccountId(); + var attempt = await host.StartAsync(); + var payload = HeimdallShapes.Delivery(attempt.AttemptId, accountId, "unused"); + payload.Remove("accessToken"); + + using var delivery = await host.DeliverAsync(payload); + + await AssertRefused(host, attempt, accountId, delivery); + } + + [Fact] + public async Task An_empty_access_token_signs_nobody_in() + { + using var host = new HeimdallHost(); + var accountId = HeimdallShapes.NewAccountId(); + var attempt = await host.StartAsync(); + + using var delivery = await host.DeliverAsync(HeimdallShapes.Delivery(attempt.AttemptId, accountId, " ")); + + await AssertRefused(host, attempt, accountId, delivery); + } + + [Theory] + [InlineData("other-key-same-kid")] + [InlineData("other-key-other-kid")] + [InlineData("tampered-payload")] + [InlineData("alg-none")] + [InlineData("alg-hs256")] + [InlineData("truncated-signature")] + [InlineData("no-kid")] + [InlineData("unpublished-kid")] + [InlineData("four-segments")] + [InlineData("two-segments")] + [InlineData("not-base64url")] + [InlineData("payload-not-json")] + public async Task A_forged_token_signs_nobody_in(string forgery) + { + using var host = new HeimdallHost(); + var accountId = HeimdallShapes.NewAccountId(); + var attempt = await host.StartAsync(); + var claims = HeimdallShapes.AccessClaims(accountId, host.Clock.Now, ["member_access"]); + var genuine = host.Key.Sign(claims); + var parts = genuine.Split('.'); + + var token = forgery switch + { + "other-key-same-kid" => HeimdallKey.Generate(host.Key.Kid).Sign(claims), + "other-key-other-kid" => HeimdallKey.Generate().Sign(claims), + "tampered-payload" => parts[0] + "." + WebEncoders.Base64UrlEncode(JsonSerializer.SerializeToUtf8Bytes( + HeimdallShapes.AccessClaims(accountId, host.Clock.Now, ["member_access", "x"]))) + "." + parts[2], + "alg-none" => WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes("{\"alg\":\"none\",\"typ\":\"JWT\"}")) + "." + parts[1] + ".", + "alg-hs256" => host.Key.Sign(claims, alg: "HS256"), + "truncated-signature" => parts[0] + "." + parts[1] + "." + + WebEncoders.Base64UrlEncode(WebEncoders.Base64UrlDecode(parts[2])[..63]), + "no-kid" => host.Key.Sign(claims, kid: null), + "unpublished-kid" => host.Key.Sign(claims, kid: "ed25519-" + new string('0', 12)), + "four-segments" => genuine + ".AAAA", + "two-segments" => parts[0] + "." + parts[1], + "not-base64url" => parts[0] + "." + parts[1] + ".!!" + parts[2][2..], + "payload-not-json" => parts[0] + "." + WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes("not json")) + "." + parts[2], + _ => throw new InvalidOperationException(forgery) + }; + Assert.NotEqual(genuine, token); + + using var delivery = await host.DeliverAsync(HeimdallShapes.Delivery(attempt.AttemptId, accountId, token)); + + await AssertRefused(host, attempt, accountId, delivery); + } + + [Fact] + public async Task A_forged_token_cannot_sign_in_as_an_existing_account() + { + using var host = new HeimdallHost(); + var victim = HeimdallShapes.NewAccountId(); + var real = await host.StartAsync(); + await DeliverGenuine(host, real, victim, displayName: "Victim Name"); + Assert.True((await host.WaitAsync(real.AttemptId, real.Binding)).SignedIn); + + var attempt = await host.StartAsync(); + var forged = HeimdallKey.Generate(host.Key.Kid).Sign(HeimdallShapes.AccessClaims(victim, host.Clock.Now, displayName: "Mallory")); + using var delivery = await host.DeliverAsync(HeimdallShapes.Delivery(attempt.AttemptId, victim, forged)); + + Assert.Equal(HttpStatusCode.BadRequest, delivery.StatusCode); + Assert.False((await host.WaitAsync(attempt.AttemptId, attempt.Binding)).SignedIn); + Assert.Equal("Victim Name", (await host.Account(victim))!.DisplayName); + } + + [Theory] + [InlineData("aud-other-app")] + [InlineData("aud-case")] + [InlineData("aud-array")] + [InlineData("aud-missing")] + [InlineData("app-slug-other")] + [InlineData("app-missing")] + [InlineData("typ-refresh")] + [InlineData("typ-missing")] + [InlineData("iss-other")] + [InlineData("iss-trailing-slash")] + [InlineData("expired")] + [InlineData("not-yet-valid")] + [InlineData("nbf-missing")] + [InlineData("exp-missing")] + [InlineData("exp-string")] + [InlineData("exp-out-of-range")] + [InlineData("sub-differs")] + [InlineData("account-id-differs")] + [InlineData("account-id-missing")] + [InlineData("sub-empty")] + public async Task A_signed_token_with_a_wrong_claim_signs_nobody_in(string wrong) + { + using var host = new HeimdallHost(); + var accountId = HeimdallShapes.NewAccountId(); + var attempt = await host.StartAsync(); + var now = host.Clock.Now.ToUnixTimeSeconds(); + var token = host.Token(accountId, ["member_access"], mutate: claims => + { + switch (wrong) + { + case "aud-other-app": claims["aud"] = "ghostlight"; break; + case "aud-case": claims["aud"] = "Bifrost"; break; + case "aud-array": claims["aud"] = new[] { "bifrost" }; break; + case "aud-missing": claims.Remove("aud"); break; + case "app-slug-other": claims["app"] = new Dictionary { ["slug"] = "ghostlight" }; break; + case "app-missing": claims.Remove("app"); break; + case "typ-refresh": claims["typ"] = "heimdall_refresh"; break; + case "typ-missing": claims.Remove("typ"); break; + case "iss-other": claims["iss"] = "https://heimdall.example"; break; + case "iss-trailing-slash": claims["iss"] = HeimdallShapes.Issuer + "/"; break; + case "expired": claims["exp"] = now - 3600; break; + case "not-yet-valid": claims["nbf"] = now + 3600; claims["exp"] = now + 7200; break; + case "nbf-missing": claims.Remove("nbf"); break; + case "exp-missing": claims.Remove("exp"); break; + case "exp-string": claims["exp"] = (now + 900).ToString(); break; + case "exp-out-of-range": claims["exp"] = long.MaxValue; break; + case "sub-differs": claims["sub"] = Flip(accountId); break; + case "account-id-differs": claims["account_id"] = Flip(accountId); break; + case "account-id-missing": claims.Remove("account_id"); break; + case "sub-empty": claims["sub"] = string.Empty; claims["account_id"] = string.Empty; break; + default: throw new InvalidOperationException(wrong); + } + }); + + using var delivery = await host.DeliverAsync(HeimdallShapes.Delivery(attempt.AttemptId, accountId, token)); + + await AssertRefused(host, attempt, accountId, delivery); + } + + [Fact] + public async Task A_token_for_a_different_account_than_the_payload_names_is_refused_by_one_character() + { + using var host = new HeimdallHost(); + var tokenAccount = HeimdallShapes.NewAccountId(); + var payloadAccount = Flip(tokenAccount); + var attempt = await host.StartAsync(); + + using var delivery = await host.DeliverAsync(HeimdallShapes.Delivery( + attempt.AttemptId, payloadAccount, host.Token(tokenAccount))); + + Assert.Equal(HttpStatusCode.BadRequest, delivery.StatusCode); + Assert.False((await host.WaitAsync(attempt.AttemptId, attempt.Binding)).SignedIn); + Assert.Null(await host.Account(tokenAccount)); + Assert.Null(await host.Account(payloadAccount)); + } + + [Fact] + public async Task A_payload_with_no_account_is_refused() + { + using var host = new HeimdallHost(); + var accountId = HeimdallShapes.NewAccountId(); + var attempt = await host.StartAsync(); + var payload = HeimdallShapes.Delivery(attempt.AttemptId, accountId, host.Token(accountId)); + payload["account"] = null; + + using var delivery = await host.DeliverAsync(payload); + + await AssertRefused(host, attempt, accountId, delivery); + } + + [Theory] + [InlineData(60, 900, true)] + [InlineData(61, 900, false)] + [InlineData(-1000, -59, true)] + [InlineData(-1000, -60, false)] + public async Task Token_validity_allows_a_minute_of_clock_skew(int notBeforeOffset, int expiresOffset, bool accepted) + { + using var host = new HeimdallHost(); + var accountId = HeimdallShapes.NewAccountId(); + var attempt = await host.StartAsync(); + var now = host.Clock.Now.ToUnixTimeSeconds(); + var token = host.Token(accountId, mutate: claims => + { + claims["nbf"] = now + notBeforeOffset; + claims["exp"] = now + expiresOffset; + }); + + using var delivery = await host.DeliverAsync(HeimdallShapes.Delivery(attempt.AttemptId, accountId, token)); + + Assert.Equal(accepted ? HttpStatusCode.Accepted : HttpStatusCode.BadRequest, delivery.StatusCode); + } + + [Fact] + public async Task A_well_signed_token_for_an_attempt_start_never_issued_creates_nothing() + { + using var host = new HeimdallHost(); + var accountId = HeimdallShapes.NewAccountId(); + var unissued = Guid.NewGuid().ToString("N"); + + using var delivery = await host.DeliverAsync(HeimdallShapes.Delivery(unissued, accountId, host.Token(accountId))); + + Assert.Equal(HttpStatusCode.BadRequest, delivery.StatusCode); + var wait = await host.WaitAsync(unissued, "anything"); + Assert.False(wait.SignedIn); + Assert.StartsWith("/?auth=heimdall-error", wait.Response.Headers.Location!.OriginalString); + Assert.Null(await host.Account(accountId)); + + // The same id arriving at wait before and after the refused delivery is equally unknown: no pending page. + var again = await host.WaitAsync(unissued, "anything"); + Assert.Equal(HttpStatusCode.Redirect, again.Response.StatusCode); + } + + [Fact] + public async Task A_second_delivery_does_not_replace_the_first() + { + using var host = new HeimdallHost(); + var first = HeimdallShapes.NewAccountId(); + var second = HeimdallShapes.NewAccountId(); + var attempt = await host.StartAsync(); + + using var accepted = await DeliverGenuine(host, attempt, first); + using var replaced = await DeliverGenuine(host, attempt, second); + + Assert.Equal(HttpStatusCode.Accepted, accepted.StatusCode); + Assert.Equal(HttpStatusCode.BadRequest, replaced.StatusCode); + Assert.True((await host.WaitAsync(attempt.AttemptId, attempt.Binding)).SignedIn); + Assert.NotNull(await host.Account(first)); + Assert.Null(await host.Account(second)); + } + + [Fact] + public async Task An_attempt_is_deliverable_until_ten_minutes_after_start() + { + using var host = new HeimdallHost(); + var accountId = HeimdallShapes.NewAccountId(); + var attempt = await host.StartAsync(); + host.Clock.Advance(HeimdallAuthAttemptStore.Lifetime - TimeSpan.FromSeconds(1)); + + using var delivery = await DeliverGenuine(host, attempt, accountId); + + Assert.Equal(HttpStatusCode.Accepted, delivery.StatusCode); + Assert.True((await host.WaitAsync(attempt.AttemptId, attempt.Binding)).SignedIn); + } + + [Fact] + public async Task An_expired_attempt_is_refused_at_callback() + { + using var host = new HeimdallHost(); + var accountId = HeimdallShapes.NewAccountId(); + var attempt = await host.StartAsync(); + host.Clock.Advance(HeimdallAuthAttemptStore.Lifetime); + + using var delivery = await DeliverGenuine(host, attempt, accountId); + + Assert.Equal(HttpStatusCode.BadRequest, delivery.StatusCode); + var wait = await host.WaitAsync(attempt.AttemptId, attempt.Binding); + Assert.False(wait.SignedIn); + Assert.StartsWith("/?auth=heimdall-error", wait.Response.Headers.Location!.OriginalString); + } + + [Fact] + public async Task A_delivered_attempt_expires_at_wait() + { + using var host = new HeimdallHost(); + var accountId = HeimdallShapes.NewAccountId(); + var attempt = await host.StartAsync(); + await DeliverGenuine(host, attempt, accountId); + host.Clock.Advance(HeimdallAuthAttemptStore.Lifetime); + + var wait = await host.WaitAsync(attempt.AttemptId, attempt.Binding); + + Assert.False(wait.SignedIn); + Assert.StartsWith("/?auth=heimdall-error", wait.Response.Headers.Location!.OriginalString); + Assert.Null(await host.Account(accountId)); + } + + [Fact] + public void A_full_store_drops_the_attempt_that_expires_first() + { + var clock = new TestClock(); + var store = new HeimdallAuthAttemptStore(clock); + var identity = new HeimdallVerifiedIdentity("account", "Name", false); + for (var i = 0; i < HeimdallAuthAttemptStore.Capacity; i++) + { + store.Create($"attempt-{i}", "discord", "/App", false); + clock.Advance(TimeSpan.FromMilliseconds(1)); + } + + // Exactly at capacity nothing has been dropped yet. + store.Complete("attempt-0", identity); + store.Create("attempt-extra", "discord", "/App", false); + + Assert.Throws(() => store.Complete("attempt-0", identity)); + store.Complete("attempt-1", identity); + store.Complete("attempt-extra", identity); + store.Complete($"attempt-{HeimdallAuthAttemptStore.Capacity - 1}", identity); + } + + [Fact] + public async Task Start_sets_a_private_per_attempt_binding_cookie() + { + using var host = new HeimdallHost(); + var first = await host.StartAsync(); + var second = await host.StartAsync(); + + Assert.Equal(HttpStatusCode.Redirect, first.Response.StatusCode); + var header = HeimdallHost.SetCookieHeader(first.Response, HeimdallHost.BindingCookie); + Assert.NotNull(header); + var attributes = header!.Split(';').Select(x => x.Trim().ToLowerInvariant()).ToArray(); + Assert.Contains("httponly", attributes); + Assert.Contains("secure", attributes); + Assert.Contains("samesite=lax", attributes); + Assert.Contains("path=/auth/heimdall", attributes); + Assert.Contains("max-age=600", attributes); + + Assert.Equal(43, first.Binding!.Length); + Assert.NotEqual(first.Binding, second.Binding); + Assert.DoesNotContain(first.AttemptId, header); + Assert.NotEqual(first.AttemptId, first.Binding); + } + + [Fact] + public async Task Wait_signs_in_only_the_browser_that_holds_the_binding_and_only_once() + { + using var host = new HeimdallHost(); + var accountId = HeimdallShapes.NewAccountId(); + var attempt = await host.StartAsync(); + var other = await host.StartAsync(); + await DeliverGenuine(host, attempt, accountId); + + var wrong = new Dictionary + { + ["no cookie"] = null, + ["empty cookie"] = string.Empty, + ["another attempt's cookie"] = other.Binding, + ["same-shape random cookie"] = WebEncoders.Base64UrlEncode(new byte[32]), + ["the attempt id as cookie"] = attempt.AttemptId, + ["a prefix of the cookie"] = attempt.Binding![..^1], + ["the cookie with a suffix"] = attempt.Binding + "A" + }; + foreach (var (name, binding) in wrong) + { + var refused = await host.WaitAsync(attempt.AttemptId, binding); + Assert.False(refused.SignedIn, name); + Assert.StartsWith("/?auth=heimdall-error", refused.Response.Headers.Location!.OriginalString); + } + + Assert.Null(await host.Account(accountId)); + + var granted = await host.WaitAsync(attempt.AttemptId, attempt.Binding); + Assert.True(granted.SignedIn); + + var replay = await host.WaitAsync(attempt.AttemptId, attempt.Binding); + Assert.False(replay.SignedIn); + } + + [Fact] + public async Task Wait_before_delivery_is_pending_and_does_not_consume_the_attempt() + { + using var host = new HeimdallHost(); + var accountId = HeimdallShapes.NewAccountId(); + var attempt = await host.StartAsync(); + + var early = await host.WaitAsync(attempt.AttemptId, attempt.Binding); + Assert.False(early.SignedIn); + Assert.Equal(HttpStatusCode.OK, early.Response.StatusCode); + Assert.Equal("2", Assert.Single(early.Response.Headers.GetValues("Refresh"))); + + await DeliverGenuine(host, attempt, accountId); + + Assert.True((await host.WaitAsync(attempt.AttemptId, attempt.Binding)).SignedIn); + } + + [Fact] + public async Task Wait_without_an_attempt_id_signs_nobody_in() + { + using var host = new HeimdallHost(); + using var client = host.NewClient(); + + using var response = await client.GetAsync("/auth/heimdall/wait"); + + Assert.Equal(HttpStatusCode.Redirect, response.StatusCode); + Assert.Null(HeimdallHost.CookieValue(response, ".AspNetCore.Cookies")); + } + + [Theory] + [InlineData("member_access", true)] + [InlineData("app_access", true)] + [InlineData("MEMBER_ACCESS", true)] + [InlineData("billing_access", false)] + public async Task Member_access_comes_from_the_token_capabilities_alone(string capability, bool grants) + { + using var host = new HeimdallHost(); + var accountId = HeimdallShapes.NewAccountId(); + var attempt = await host.StartAsync(member: true); + var payload = HeimdallShapes.Delivery( + attempt.AttemptId, + accountId, + host.Token(accountId, [capability]), + looseCapabilities: grants ? [] : ["member_access", "app_access"], + looseFacts: grants ? [] : ["entitlement.app_access"]); + + using var delivery = await host.DeliverAsync(payload); + Assert.Equal(HttpStatusCode.Accepted, delivery.StatusCode); + var wait = await host.WaitAsync(attempt.AttemptId, attempt.Binding); + + var account = await host.Account(accountId); + if (grants) + { + Assert.True(wait.SignedIn); + Assert.Equal(MembershipStatus.Active, account!.Membership!.Status); + Assert.Contains(account.Membership.RoleAssignments, x => x.Role == MemberRole.StandardMember); + } + else + { + Assert.False(wait.SignedIn); + Assert.StartsWith("/?auth=heimdall-error", wait.Response.Headers.Location!.OriginalString); + Assert.Null(account); + } + } + + [Fact] + public async Task A_token_capability_grants_no_role_to_a_sign_in_that_did_not_ask_for_member_access() + { + using var host = new HeimdallHost(); + var accountId = HeimdallShapes.NewAccountId(); + var attempt = await host.StartAsync(member: false); + + await DeliverGenuine(host, attempt, accountId, ["member_access"]); + Assert.True((await host.WaitAsync(attempt.AttemptId, attempt.Binding)).SignedIn); + + var account = await host.Account(accountId); + Assert.Equal(MembershipStatus.Authenticated, account!.Membership!.Status); + Assert.Empty(account.Membership.RoleAssignments); + } + + [Fact] + public async Task Loose_capabilities_cannot_turn_a_plain_token_into_member_access() + { + using var host = new HeimdallHost(); + var accountId = HeimdallShapes.NewAccountId(); + var attempt = await host.StartAsync(member: false); + + using var delivery = await host.DeliverAsync(HeimdallShapes.Delivery( + attempt.AttemptId, + accountId, + host.Token(accountId), + looseCapabilities: ["member_access"], + looseFacts: ["entitlement.app_access"])); + Assert.Equal(HttpStatusCode.Accepted, delivery.StatusCode); + Assert.True((await host.WaitAsync(attempt.AttemptId, attempt.Binding)).SignedIn); + + var account = await host.Account(accountId); + Assert.Equal(MembershipStatus.Authenticated, account!.Membership!.Status); + Assert.Empty(account.Membership.RoleAssignments); + } + + [Fact] + public async Task The_loose_return_to_and_name_decide_nothing() + { + using var host = new HeimdallHost(); + var accountId = HeimdallShapes.NewAccountId(); + var attempt = await host.StartAsync(returnUrl: "/App/Mill"); + + await host.DeliverAsync(HeimdallShapes.Delivery( + attempt.AttemptId, accountId, host.Token(accountId), looseName: "Loose Name", returnTo: "https://evil.example/landing")); + var wait = await host.WaitAsync(attempt.AttemptId, attempt.Binding); + + Assert.Equal("/App/Mill", wait.Response.Headers.Location!.OriginalString); + Assert.Equal("Genuine Name", (await host.Account(accountId))!.DisplayName); + } + + [Fact] + public async Task Keys_are_fetched_once_and_cached() + { + using var host = new HeimdallHost(); + for (var i = 0; i < 3; i++) + { + var accountId = HeimdallShapes.NewAccountId(); + var attempt = await host.StartAsync(); + using var delivery = await DeliverGenuine(host, attempt, accountId); + Assert.Equal(HttpStatusCode.Accepted, delivery.StatusCode); + } + + Assert.Equal(1, host.Heimdall.JwksFetches); + } + + [Fact] + public async Task An_unknown_key_id_refetches_at_most_every_thirty_seconds() + { + using var host = new HeimdallHost(); + var unknown = HeimdallKey.Generate(); + async Task DeliverUnknown() + { + var accountId = HeimdallShapes.NewAccountId(); + var attempt = await host.StartAsync(); + using var delivery = await host.DeliverAsync(HeimdallShapes.Delivery( + attempt.AttemptId, accountId, host.Token(accountId, signer: unknown))); + return delivery.StatusCode; + } + + Assert.Equal(HttpStatusCode.BadRequest, await DeliverUnknown()); + Assert.Equal(1, host.Heimdall.JwksFetches); + + host.Clock.Advance(TimeSpan.FromSeconds(29)); + Assert.Equal(HttpStatusCode.BadRequest, await DeliverUnknown()); + Assert.Equal(1, host.Heimdall.JwksFetches); + + host.Clock.Advance(TimeSpan.FromSeconds(1)); + Assert.Equal(HttpStatusCode.BadRequest, await DeliverUnknown()); + Assert.Equal(2, host.Heimdall.JwksFetches); + Assert.Equal(HttpStatusCode.BadRequest, await DeliverUnknown()); + Assert.Equal(2, host.Heimdall.JwksFetches); + } + + [Fact] + public async Task A_rotated_in_key_is_accepted_once_the_refetch_interval_has_passed() + { + using var host = new HeimdallHost(); + var rotated = HeimdallKey.Generate(); + var accountId = HeimdallShapes.NewAccountId(); + + var early = await host.StartAsync(); + using var refused = await host.DeliverAsync(HeimdallShapes.Delivery( + early.AttemptId, accountId, host.Token(accountId, signer: rotated))); + Assert.Equal(HttpStatusCode.BadRequest, refused.StatusCode); + + host.Heimdall.Published.Add(rotated); + host.Clock.Advance(TimeSpan.FromSeconds(30)); + var late = await host.StartAsync(); + using var accepted = await host.DeliverAsync(HeimdallShapes.Delivery( + late.AttemptId, accountId, host.Token(accountId, signer: rotated))); + + Assert.Equal(HttpStatusCode.Accepted, accepted.StatusCode); + } + + [Fact] + public async Task Cached_keys_are_refetched_after_an_hour_and_a_withdrawn_key_stops_working() + { + using var host = new HeimdallHost(); + var warm = await host.StartAsync(); + var warmAccount = HeimdallShapes.NewAccountId(); + using (await DeliverGenuine(host, warm, warmAccount)) + { + } + + host.Heimdall.Published.Clear(); + host.Clock.Advance(TimeSpan.FromHours(1) - TimeSpan.FromSeconds(1)); + var inside = await host.StartAsync(); + using (var stillCached = await DeliverGenuine(host, inside, HeimdallShapes.NewAccountId())) + { + Assert.Equal(HttpStatusCode.Accepted, stillCached.StatusCode); + } + + Assert.Equal(1, host.Heimdall.JwksFetches); + + host.Clock.Advance(TimeSpan.FromSeconds(1)); + var outside = await host.StartAsync(); + using var withdrawn = await DeliverGenuine(host, outside, HeimdallShapes.NewAccountId()); + + Assert.Equal(HttpStatusCode.BadRequest, withdrawn.StatusCode); + Assert.Equal(2, host.Heimdall.JwksFetches); + } + + [Fact] + public async Task A_failing_key_fetch_refuses_without_hammering_and_recovers() + { + using var host = new HeimdallHost(); + host.Heimdall.JwksStatus = HttpStatusCode.InternalServerError; + var accountId = HeimdallShapes.NewAccountId(); + + var first = await host.StartAsync(); + using (var down = await DeliverGenuine(host, first, accountId)) + { + Assert.Equal(HttpStatusCode.BadRequest, down.StatusCode); + } + + var second = await host.StartAsync(); + using (var stillDown = await DeliverGenuine(host, second, accountId)) + { + Assert.Equal(HttpStatusCode.BadRequest, stillDown.StatusCode); + } + + Assert.Equal(1, host.Heimdall.JwksFetches); + + host.Heimdall.JwksStatus = HttpStatusCode.OK; + host.Clock.Advance(TimeSpan.FromSeconds(30)); + var third = await host.StartAsync(); + using var recovered = await DeliverGenuine(host, third, accountId); + + Assert.Equal(HttpStatusCode.Accepted, recovered.StatusCode); + } + + [Theory] + [InlineData("rsa-kty")] + [InlineData("x25519-crv")] + [InlineData("short-x")] + public async Task A_published_key_that_is_not_a_full_ed25519_key_verifies_nothing(string junk) + { + using var host = new HeimdallHost(); + var kid = "ed25519-" + new string('a', 12); + var x = WebEncoders.Base64UrlEncode(host.Key.PublicKey); + host.Heimdall.ExtraJwks.Add(42); + host.Heimdall.ExtraJwks.Add(junk switch + { + "rsa-kty" => new Dictionary { ["kty"] = "RSA", ["crv"] = "Ed25519", ["kid"] = kid, ["x"] = x }, + "x25519-crv" => new Dictionary { ["kty"] = "OKP", ["crv"] = "X25519", ["kid"] = kid, ["x"] = x }, + _ => new Dictionary { ["kty"] = "OKP", ["crv"] = "Ed25519", ["kid"] = kid, ["x"] = x[..20] } + }); + var accountId = HeimdallShapes.NewAccountId(); + var attempt = await host.StartAsync(); + var token = host.Key.Sign(HeimdallShapes.AccessClaims(accountId, host.Clock.Now), kid: kid); + + using var delivery = await host.DeliverAsync(HeimdallShapes.Delivery(attempt.AttemptId, accountId, token)); + + Assert.Equal(HttpStatusCode.BadRequest, delivery.StatusCode); + var genuine = await host.StartAsync(); + using var accepted = await DeliverGenuine(host, genuine, accountId); + Assert.Equal(HttpStatusCode.Accepted, accepted.StatusCode); + } + + [Fact] + public async Task Refusals_echo_no_input_in_responses_or_logs() + { + using var host = new HeimdallHost(); + var suffix = Guid.NewGuid().ToString("N"); + var attemptCanary = "CANARYATTEMPT" + suffix; + var accountCanary = "CANARYACCOUNT" + suffix; + var kidCanary = "CANARYKID" + suffix; + var claimCanary = "CANARYCLAIM" + suffix; + var providerCanary = "CANARYPROVIDER" + suffix; + var bindingCanary = "CANARYBINDING" + suffix; + var tokens = new List(); + + var canaryClaims = HeimdallShapes.AccessClaims(accountCanary, host.Clock.Now, mutate: claims => + { + claims["iss"] = claimCanary; + claims["aud"] = claimCanary; + claims["typ"] = claimCanary; + claims["display_name"] = claimCanary; + }); + var issued = await host.StartAsync(); + var wrongSubject = host.Token(HeimdallShapes.NewAccountId()); + tokens.Add(wrongSubject); + tokens.Add(HeimdallKey.Generate(kidCanary).Sign(canaryClaims, kid: kidCanary)); + tokens.Add(host.Key.Sign(canaryClaims)); + tokens.Add($"{claimCanary}.{claimCanary}.{claimCanary}"); + + var responses = new List(); + responses.Add(await host.DeliverAsync(HeimdallShapes.Delivery(attemptCanary, accountCanary, tokens[1]))); + responses.Add(await host.DeliverAsync(HeimdallShapes.Delivery(attemptCanary, accountCanary, tokens[2]))); + responses.Add(await host.DeliverAsync(HeimdallShapes.Delivery(attemptCanary, accountCanary, tokens[3]))); + responses.Add(await host.DeliverAsync(HeimdallShapes.Delivery(issued.AttemptId, accountCanary, wrongSubject))); + responses.Add((await host.WaitAsync(attemptCanary, bindingCanary)).Response); + responses.Add((await host.WaitAsync(issued.AttemptId, bindingCanary)).Response); + using (var client = host.NewClient()) + { + responses.Add(await client.GetAsync($"/auth/heimdall/{providerCanary}")); + responses.Add(await client.PostAsync( + "/auth/heimdall/callback", + new StringContent("{\"attemptId\":\"" + attemptCanary + "\",", Encoding.UTF8, "application/json"))); + } + + var forbidden = new List { attemptCanary, accountCanary, kidCanary, claimCanary, providerCanary, bindingCanary }; + forbidden.AddRange(tokens); + forbidden.AddRange(tokens.SelectMany(x => x.Split('.')).Where(x => x.Length > 8)); + + foreach (var response in responses) + { + var observed = new StringBuilder() + .Append(await response.Content.ReadAsStringAsync()) + .Append(response.ToString()); + foreach (var cookie in response.Headers.Where(x => x.Key == "Set-Cookie").SelectMany(x => x.Value)) + { + observed.Append(cookie); + } + + foreach (var canary in forbidden) + { + Assert.DoesNotContain(canary, observed.ToString()); + } + + response.Dispose(); + } + + var logs = string.Join("\n", host.Logs.Lines); + Assert.Contains("Heimdall callback refused", logs); + foreach (var canary in forbidden) + { + Assert.DoesNotContain(canary, logs); + } + } +} diff --git a/tests/Bifrost.Web.Tests/Support/HeimdallTestKit.cs b/tests/Bifrost.Web.Tests/Support/HeimdallTestKit.cs new file mode 100644 index 0000000..6744e1c --- /dev/null +++ b/tests/Bifrost.Web.Tests/Support/HeimdallTestKit.cs @@ -0,0 +1,362 @@ +using System.Net; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Bifrost.Web.Data; +using Bifrost.Web.Features.Heimdall; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.WebUtilities; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Logging; +using Org.BouncyCastle.Math.EC.Rfc8032; + +namespace Bifrost.Web.Tests.Support; + +/// An Ed25519 key shaped like Heimdall's signing key: a "ed25519-" kid over a thumbprint prefix. +public sealed class HeimdallKey +{ + private readonly byte[] _seed; + + private HeimdallKey(byte[] seed, byte[] publicKey, string kid) + { + _seed = seed; + PublicKey = publicKey; + Kid = kid; + } + + public string Kid { get; } + + public byte[] PublicKey { get; } + + public static HeimdallKey Generate(string? kid = null) + { + var seed = RandomNumberGenerator.GetBytes(Ed25519.SecretKeySize); + var publicKey = new byte[Ed25519.PublicKeySize]; + Ed25519.GeneratePublicKey(seed, 0, publicKey, 0); + kid ??= "ed25519-" + Convert.ToHexString(SHA256.HashData(publicKey)).ToLowerInvariant()[..12]; + return new HeimdallKey(seed, publicKey, kid); + } + + public object Jwk() => new Dictionary + { + ["kty"] = "OKP", + ["crv"] = "Ed25519", + ["x"] = WebEncoders.Base64UrlEncode(PublicKey), + ["alg"] = "EdDSA", + ["kid"] = Kid, + ["use"] = "sig" + }; + + /// Signs like Heimdall's signJwt: header {alg, kid, typ}, compact JWS. + public string Sign(IDictionary claims, string alg = "EdDSA", string? kid = "") + { + var header = new Dictionary { ["alg"] = alg, ["typ"] = "JWT" }; + if (kid is not null) + { + header["kid"] = kid.Length == 0 ? Kid : kid; + } + + var signingInput = + WebEncoders.Base64UrlEncode(JsonSerializer.SerializeToUtf8Bytes(header)) + "." + + WebEncoders.Base64UrlEncode(JsonSerializer.SerializeToUtf8Bytes(claims)); + var message = Encoding.ASCII.GetBytes(signingInput); + var signature = new byte[Ed25519.SignatureSize]; + Ed25519.Sign(_seed, 0, message, 0, message.Length, signature, 0); + return signingInput + "." + WebEncoders.Base64UrlEncode(signature); + } +} + +public static class HeimdallShapes +{ + public const string Issuer = "https://heimdall.test"; + + public static string NewAccountId() => Guid.NewGuid().ToString(); + + /// The claim set of Heimdall src/claims.ts issueAccessClaim. + public static Dictionary AccessClaims( + string accountId, + DateTimeOffset now, + string[]? capabilities = null, + string? displayName = "Genuine Name", + Action>? mutate = null) + { + var issuedAt = now.ToUnixTimeSeconds(); + var claims = new Dictionary + { + ["iss"] = Issuer, + ["aud"] = "bifrost", + ["sub"] = accountId, + ["sid"] = Guid.NewGuid().ToString(), + ["jti"] = Guid.NewGuid().ToString(), + ["iat"] = issuedAt, + ["nbf"] = issuedAt, + ["exp"] = issuedAt + 900, + ["typ"] = "heimdall_access", + ["account_id"] = accountId, + ["access_revision"] = 1, + ["app"] = new Dictionary { ["slug"] = "bifrost", ["profile_version"] = 1 }, + ["facts"] = Array.Empty(), + ["capabilities"] = capabilities ?? [], + ["identities"] = new[] + { + new Dictionary { ["provider"] = "discord", ["providerUserId"] = "1234567890" } + } + }; + if (displayName is not null) + { + claims["display_name"] = displayName; + } + + mutate?.Invoke(claims); + return claims; + } + + /// The success payload of Heimdall src/backend-handoff.ts, with the unsigned fields Bifrost used to trust. + public static Dictionary Delivery( + string attemptId, + string accountId, + string accessToken, + string[]? looseCapabilities = null, + string[]? looseFacts = null, + string looseName = "Loose Name", + string returnTo = "/App/Elsewhere") => new() + { + ["source"] = "heimdall", + ["kind"] = "oauth_result", + ["handoffKind"] = "backend_callback", + ["attemptId"] = attemptId, + ["provider"] = "discord", + ["appSlug"] = "bifrost", + ["returnTo"] = returnTo, + ["connection"] = null, + ["status"] = "success", + ["mode"] = "sign_in", + ["account"] = new Dictionary { ["id"] = accountId, ["displayName"] = looseName }, + ["entitlements"] = new Dictionary + { + ["facts"] = looseFacts ?? [], + ["snapshots"] = Array.Empty() + }, + ["session"] = new Dictionary { ["accountId"] = accountId, ["appSlug"] = "bifrost" }, + ["accessToken"] = accessToken, + ["refreshToken"] = "refresh-token-not-read", + ["refresh"] = new Dictionary { ["expiresAt"] = "2026-11-11T00:00:00.000Z" }, + ["verification"] = new Dictionary { ["issuer"] = Issuer }, + ["sharedCapabilities"] = looseCapabilities ?? [], + ["hybridCapabilities"] = Array.Empty() + }; +} + +public sealed class TestClock : TimeProvider +{ + private DateTimeOffset _now = new(2026, 10, 11, 12, 0, 0, TimeSpan.Zero); + + public DateTimeOffset Now => _now; + + public void Advance(TimeSpan by) => _now += by; + + public override DateTimeOffset GetUtcNow() => _now; +} + +/// Stands in for Heimdall's HTTP surface: the JWKS and the oauth start endpoint. +public sealed class FakeHeimdall : HttpMessageHandler +{ + public List Published { get; } = []; + + public List ExtraJwks { get; } = []; + + public int JwksFetches { get; private set; } + + public HttpStatusCode JwksStatus { get; set; } = HttpStatusCode.OK; + + public List StartBodies { get; } = []; + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var path = request.RequestUri!.AbsolutePath; + if (request.Method == HttpMethod.Get && path == "/.well-known/jwks.json") + { + JwksFetches++; + var keys = Published.Select(x => x.Jwk()).Concat(ExtraJwks).ToArray(); + return new HttpResponseMessage(JwksStatus) + { + Content = new StringContent(JsonSerializer.Serialize(new { keys }), Encoding.UTF8, "application/json") + }; + } + + if (request.Method == HttpMethod.Post && path.StartsWith("/v1/oauth/", StringComparison.Ordinal) && path.EndsWith("/start", StringComparison.Ordinal)) + { + StartBodies.Add(await request.Content!.ReadAsStringAsync(cancellationToken)); + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent( + """{"authorizationUrl":"https://heimdall.test/oauth/discord/authorize?state=fixture"}""", + Encoding.UTF8, + "application/json") + }; + } + + return new HttpResponseMessage(HttpStatusCode.NotFound); + } +} + +public sealed class CapturedLogs : ILoggerProvider +{ + private readonly List _lines = []; + + public IReadOnlyList Lines + { + get + { + lock (_lines) + { + return _lines.ToArray(); + } + } + } + + public ILogger CreateLogger(string categoryName) => new Capture(this); + + public void Dispose() + { + } + + private sealed class Capture(CapturedLogs owner) : ILogger + { + public IDisposable? BeginScope(TState state) where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + { + lock (owner._lines) + { + owner._lines.Add(formatter(state, exception) + (exception is null ? string.Empty : "\n" + exception)); + } + } + } +} + +public sealed record StartedAttempt(string AttemptId, string? Binding, HttpResponseMessage Response); + +public sealed record WaitResult(HttpResponseMessage Response, bool SignedIn); + +/// A local Bifrost with Heimdall faked in-process: a test JWKS, a test clock, captured logs. +public sealed class HeimdallHost : TestWebApplicationFactory +{ + public const string BindingCookie = "bifrost_heimdall_binding"; + + public TestClock Clock { get; } = new(); + + public FakeHeimdall Heimdall { get; } = new(); + + public CapturedLogs Logs { get; } = new(); + + public HeimdallKey Key { get; } = HeimdallKey.Generate(); + + public HeimdallHost() + { + Heimdall.Published.Add(Key); + } + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + base.ConfigureWebHost(builder); + builder.ConfigureAppConfiguration((_, configuration) => + { + configuration.AddInMemoryCollection(new Dictionary + { + ["Heimdall:BaseUrl"] = HeimdallShapes.Issuer, + ["Heimdall:DiscordGuildId"] = "guild-1", + ["Heimdall:DiscordAllowedRoleIds:0"] = "role-1" + }); + }); + builder.ConfigureLogging(logging => logging.AddProvider(Logs)); + builder.ConfigureServices(services => + { + services.RemoveAll(); + services.AddSingleton(Clock); + services.AddHttpClient().ConfigurePrimaryHttpMessageHandler(() => Heimdall); + services.AddHttpClient().ConfigurePrimaryHttpMessageHandler(() => Heimdall); + }); + } + + public HttpClient NewClient() => CreateClient(new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false, + HandleCookies = false, + BaseAddress = new Uri("https://localhost") + }); + + public string Token(string accountId, string[]? capabilities = null, string? displayName = "Genuine Name", + Action>? mutate = null, HeimdallKey? signer = null) => + (signer ?? Key).Sign(HeimdallShapes.AccessClaims(accountId, Clock.Now, capabilities, displayName, mutate)); + + public async Task StartAsync(bool member = false, string provider = "discord", string returnUrl = "/App/Mill") + { + using var client = NewClient(); + var before = Heimdall.StartBodies.Count; + var response = await client.GetAsync( + $"/auth/heimdall/{provider}?returnUrl={Uri.EscapeDataString(returnUrl)}" + (member ? "&access=member" : string.Empty)); + var attemptId = Heimdall.StartBodies.Count > before + ? JsonDocument.Parse(Heimdall.StartBodies[^1]).RootElement.GetProperty("handoff").GetProperty("attemptId").GetString()! + : string.Empty; + return new StartedAttempt(attemptId, CookieValue(response, BindingCookie), response); + } + + public async Task DeliverAsync(object payload) + { + using var client = NewClient(); + return await client.PostAsync( + "/auth/heimdall/callback", + new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json")); + } + + public async Task WaitAsync(string attemptId, string? binding) + { + using var client = NewClient(); + using var request = new HttpRequestMessage(HttpMethod.Get, $"/auth/heimdall/wait?attemptId={Uri.EscapeDataString(attemptId)}"); + if (binding is not null) + { + request.Headers.Add("Cookie", $"{BindingCookie}={binding}"); + } + + var response = await client.SendAsync(request); + var session = CookieValue(response, ".AspNetCore.Cookies"); + return new WaitResult(response, !string.IsNullOrEmpty(session)); + } + + public static string? CookieValue(HttpResponseMessage response, string name) + { + if (!response.Headers.TryGetValues("Set-Cookie", out var headers)) + { + return null; + } + + var prefix = name + "="; + var header = headers.FirstOrDefault(x => x.StartsWith(prefix, StringComparison.Ordinal)); + return header?[prefix.Length..].Split(';')[0]; + } + + public static string? SetCookieHeader(HttpResponseMessage response, string name) => + response.Headers.TryGetValues("Set-Cookie", out var headers) + ? headers.FirstOrDefault(x => x.StartsWith(name + "=", StringComparison.Ordinal)) + : null; + + public async Task Db(Func> query) + { + using var scope = Services.CreateScope(); + return await query(scope.ServiceProvider.GetRequiredService()); + } + + public Task Account(string heimdallAccountId) => + Db(db => db.UserAccounts + .Include(x => x.Membership!).ThenInclude(x => x.RoleAssignments) + .SingleOrDefaultAsync(x => x.HeimdallAccountId == heimdallAccountId)); + + public Task AccountCount() => Db(db => db.UserAccounts.CountAsync()); +} From e99eea8769d6aebb673dd889e5e62630f524277d Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 11 Oct 2026 02:55:35 +0200 Subject: [PATCH 35/97] Heimdall token verifier: one process-wide instance owns the key cache The verifier was registered as a typed client, which is transient, so each request would have started with an empty key cache. It is now a singleton that takes its HTTP client from the factory per fetch. Test hosts share one in-memory database root so EF keeps a single service provider. Co-Authored-By: Claude Opus 5.5 --- src/Bifrost.Web/Features/Heimdall/HeimdallTokenVerifier.cs | 6 ++++-- src/Bifrost.Web/Program.cs | 3 ++- tests/Bifrost.Web.Tests/Support/HeimdallTestKit.cs | 2 +- .../Bifrost.Web.Tests/Support/TestWebApplicationFactory.cs | 3 ++- 4 files changed, 9 insertions(+), 5 deletions(-) diff --git a/src/Bifrost.Web/Features/Heimdall/HeimdallTokenVerifier.cs b/src/Bifrost.Web/Features/Heimdall/HeimdallTokenVerifier.cs index dab753b..e79a813 100644 --- a/src/Bifrost.Web/Features/Heimdall/HeimdallTokenVerifier.cs +++ b/src/Bifrost.Web/Features/Heimdall/HeimdallTokenVerifier.cs @@ -14,14 +14,15 @@ public sealed record HeimdallVerifiedIdentity(string AccountId, string? DisplayN /// /// Verifies Heimdall's access tokens (EdDSA) against Heimdall's published JWKS. Heimdall is the issuer /// at ; the audience is this app's slug. Failures name a rule and -/// never echo the token or any of its contents. +/// never echo the token or any of its contents. One instance serves the process: it owns the key cache. /// public sealed class HeimdallTokenVerifier( - HttpClient httpClient, + IHttpClientFactory httpClientFactory, IOptions heimdallOptions, TimeProvider timeProvider) { public const string TokenType = "heimdall_access"; + public const string ClientName = "heimdall-jwks"; internal static readonly TimeSpan ClockSkew = TimeSpan.FromSeconds(60); internal static readonly TimeSpan KeyRefreshInterval = TimeSpan.FromSeconds(30); @@ -193,6 +194,7 @@ public sealed class HeimdallTokenVerifier( { try { + var httpClient = httpClientFactory.CreateClient(ClientName); using var response = await httpClient.GetAsync("/.well-known/jwks.json", cancellationToken); response.EnsureSuccessStatusCode(); await using var body = await response.Content.ReadAsStreamAsync(cancellationToken); diff --git a/src/Bifrost.Web/Program.cs b/src/Bifrost.Web/Program.cs index 3eb149d..49e70b6 100644 --- a/src/Bifrost.Web/Program.cs +++ b/src/Bifrost.Web/Program.cs @@ -130,7 +130,8 @@ builder.Services.AddHttpClient((serviceProvider, client) => var options = serviceProvider.GetRequiredService>().Value; client.BaseAddress = new Uri(options.BaseUrl.TrimEnd('/')); }); -builder.Services.AddHttpClient((serviceProvider, client) => +builder.Services.AddSingleton(); +builder.Services.AddHttpClient(HeimdallTokenVerifier.ClientName, (serviceProvider, client) => { var options = serviceProvider.GetRequiredService>().Value; client.BaseAddress = new Uri(options.BaseUrl.TrimEnd('/')); diff --git a/tests/Bifrost.Web.Tests/Support/HeimdallTestKit.cs b/tests/Bifrost.Web.Tests/Support/HeimdallTestKit.cs index 6744e1c..477a4ad 100644 --- a/tests/Bifrost.Web.Tests/Support/HeimdallTestKit.cs +++ b/tests/Bifrost.Web.Tests/Support/HeimdallTestKit.cs @@ -280,7 +280,7 @@ public sealed class HeimdallHost : TestWebApplicationFactory { services.RemoveAll(); services.AddSingleton(Clock); - services.AddHttpClient().ConfigurePrimaryHttpMessageHandler(() => Heimdall); + services.AddHttpClient(HeimdallTokenVerifier.ClientName).ConfigurePrimaryHttpMessageHandler(() => Heimdall); services.AddHttpClient().ConfigurePrimaryHttpMessageHandler(() => Heimdall); }); } diff --git a/tests/Bifrost.Web.Tests/Support/TestWebApplicationFactory.cs b/tests/Bifrost.Web.Tests/Support/TestWebApplicationFactory.cs index ed3c4ed..b6e356d 100644 --- a/tests/Bifrost.Web.Tests/Support/TestWebApplicationFactory.cs +++ b/tests/Bifrost.Web.Tests/Support/TestWebApplicationFactory.cs @@ -16,7 +16,8 @@ namespace Bifrost.Web.Tests.Support; public class TestWebApplicationFactory : WebApplicationFactory { - private readonly InMemoryDatabaseRoot _databaseRoot = new(); + // One root for every factory: EF caches a service provider per root, and warns past twenty. + private static readonly InMemoryDatabaseRoot _databaseRoot = new(); private readonly string _databaseName = $"bifrost-tests-{Guid.NewGuid():N}"; protected override void ConfigureWebHost(IWebHostBuilder builder) From 650bf6bcc6648b5826b4449379496fae80d62472 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 11 Oct 2026 03:05:07 +0200 Subject: [PATCH 36/97] Heimdall token verifier: split decoding and key reading into small steps Behaviour is unchanged. The catch-and-refuse blocks sit in their own small methods so the mutation tool can compile mutants of the rest of the verifier. Co-Authored-By: Claude Opus 5.5 --- .../Heimdall/HeimdallTokenVerifier.cs | 141 ++++++++++-------- 1 file changed, 78 insertions(+), 63 deletions(-) diff --git a/src/Bifrost.Web/Features/Heimdall/HeimdallTokenVerifier.cs b/src/Bifrost.Web/Features/Heimdall/HeimdallTokenVerifier.cs index e79a813..c344d05 100644 --- a/src/Bifrost.Web/Features/Heimdall/HeimdallTokenVerifier.cs +++ b/src/Bifrost.Web/Features/Heimdall/HeimdallTokenVerifier.cs @@ -40,44 +40,18 @@ public sealed class HeimdallTokenVerifier( string? payloadAccountId, CancellationToken cancellationToken) { - var options = heimdallOptions.Value; var parts = token.Split('.'); if (parts.Length != 3) { throw Refuse("token is not a compact JWS"); } - byte[] headerBytes, payloadBytes, signature; - try - { - headerBytes = WebEncoders.Base64UrlDecode(parts[0]); - payloadBytes = WebEncoders.Base64UrlDecode(parts[1]); - signature = WebEncoders.Base64UrlDecode(parts[2]); - } - catch (FormatException) - { - throw Refuse("token is not base64url"); - } - - string kid; - try - { - using var header = JsonDocument.Parse(headerBytes); - if (header.RootElement.ValueKind != JsonValueKind.Object || - StringClaim(header.RootElement, "alg") != "EdDSA") - { - throw Refuse("token algorithm is not EdDSA"); - } - - kid = StringClaim(header.RootElement, "kid") is { Length: > 0 } found - ? found - : throw Refuse("token has no key id"); - } - catch (JsonException) - { - throw Refuse("token header is not JSON"); - } + var headerBytes = Decode(parts[0]); + var payloadBytes = Decode(parts[1]); + var signature = Decode(parts[2]); + using var header = ParseJson(headerBytes, "token header is not JSON"); + var kid = ReadKeyId(header.RootElement); var publicKey = await FindKeyAsync(kid, cancellationToken); if (publicKey is null) { @@ -91,17 +65,50 @@ public sealed class HeimdallTokenVerifier( throw Refuse("token signature is invalid"); } + using var payload = ParseJson(payloadBytes, "token payload is not JSON"); + return CheckClaims(payload.RootElement, heimdallOptions.Value, payloadAccountId); + } + + private static byte[] Decode(string segment) + { try { - using var payload = JsonDocument.Parse(payloadBytes); - return CheckClaims(payload.RootElement, options, payloadAccountId); + return WebEncoders.Base64UrlDecode(segment); + } + catch (FormatException) + { + throw Refuse("token is not base64url"); + } + } + + private static JsonDocument ParseJson(byte[] bytes, string rule) + { + try + { + return JsonDocument.Parse(bytes); } catch (JsonException) { - throw Refuse("token payload is not JSON"); + throw Refuse(rule); } } + private static string ReadKeyId(JsonElement header) + { + if (header.ValueKind != JsonValueKind.Object || StringClaim(header, "alg") != "EdDSA") + { + throw Refuse("token algorithm is not EdDSA"); + } + + var kid = StringClaim(header, "kid"); + if (string.IsNullOrEmpty(kid)) + { + throw Refuse("token has no key id"); + } + + return kid; + } + private HeimdallVerifiedIdentity CheckClaims(JsonElement claims, HeimdallOptions options, string? payloadAccountId) { if (claims.ValueKind != JsonValueKind.Object) @@ -194,41 +201,49 @@ public sealed class HeimdallTokenVerifier( { try { - var httpClient = httpClientFactory.CreateClient(ClientName); - using var response = await httpClient.GetAsync("/.well-known/jwks.json", cancellationToken); - response.EnsureSuccessStatusCode(); - await using var body = await response.Content.ReadAsStreamAsync(cancellationToken); - using var document = await JsonDocument.ParseAsync(body, cancellationToken: cancellationToken); - var keys = new Dictionary(StringComparer.Ordinal); - if (document.RootElement.TryGetProperty("keys", out var list) && list.ValueKind == JsonValueKind.Array) - { - foreach (var jwk in list.EnumerateArray()) - { - if (jwk.ValueKind != JsonValueKind.Object || - StringClaim(jwk, "kty") != "OKP" || - StringClaim(jwk, "crv") != "Ed25519" || - StringClaim(jwk, "kid") is not { Length: > 0 } id || - StringClaim(jwk, "x") is not { } x) - { - continue; - } - - var key = WebEncoders.Base64UrlDecode(x); - if (key.Length == Ed25519.PublicKeySize) - { - keys[id] = key; - } - } - } - - return keys; + return await ReadKeysAsync(cancellationToken); } - catch (Exception error) when (error is HttpRequestException or JsonException or FormatException) + catch (Exception error) when (error is not OperationCanceledException) { throw Refuse("Heimdall keys could not be fetched"); } } + private async Task> ReadKeysAsync(CancellationToken cancellationToken) + { + var httpClient = httpClientFactory.CreateClient(ClientName); + using var response = await httpClient.GetAsync("/.well-known/jwks.json", cancellationToken); + response.EnsureSuccessStatusCode(); + await using var body = await response.Content.ReadAsStreamAsync(cancellationToken); + using var document = await JsonDocument.ParseAsync(body, cancellationToken: cancellationToken); + var keys = new Dictionary(StringComparer.Ordinal); + foreach (var jwk in document.RootElement.GetProperty("keys").EnumerateArray()) + { + if (jwk.ValueKind != JsonValueKind.Object) + { + continue; + } + + var id = StringClaim(jwk, "kid"); + var x = StringClaim(jwk, "x"); + if (StringClaim(jwk, "kty") != "OKP" || + StringClaim(jwk, "crv") != "Ed25519" || + string.IsNullOrEmpty(id) || + x is null) + { + continue; + } + + var key = WebEncoders.Base64UrlDecode(x); + if (key.Length == Ed25519.PublicKeySize) + { + keys[id] = key; + } + } + + return keys; + } + private static string? StringClaim(JsonElement element, string name) => element.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.String ? value.GetString() From 1ab67da47b53395e5ce09ac6ee0ca46170eee3be Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 11 Oct 2026 03:26:48 +0200 Subject: [PATCH 37/97] Heimdall sign-in tests: pin each refusal rule by its reason; token dates cannot overflow The forgery and wrong-claim tests now assert which rule refused, start failures, blank attempt ids, capability shapes and date extremes are covered. A token whose exp is the largest representable date no longer overflows the clock-skew sum, and the unreachable blank-return-url fallback is removed (Program defaults it). Co-Authored-By: Claude Opus 5.5 --- .../Features/Heimdall/HeimdallAuthService.cs | 2 +- .../Heimdall/HeimdallTokenVerifier.cs | 2 +- .../HeimdallCallbackTests.cs | 204 +++++++++++++----- .../Support/HeimdallTestKit.cs | 27 ++- 4 files changed, 176 insertions(+), 59 deletions(-) diff --git a/src/Bifrost.Web/Features/Heimdall/HeimdallAuthService.cs b/src/Bifrost.Web/Features/Heimdall/HeimdallAuthService.cs index 293aa3c..d27a3fe 100644 --- a/src/Bifrost.Web/Features/Heimdall/HeimdallAuthService.cs +++ b/src/Bifrost.Web/Features/Heimdall/HeimdallAuthService.cs @@ -118,7 +118,7 @@ public sealed class HeimdallAuthService( cancellationToken); return new HeimdallCompletedSignIn( bifrostIdentityService.BuildPrincipal(userAccount, attempt.Provider), - string.IsNullOrWhiteSpace(attempt.ReturnUrl) ? "/App" : attempt.ReturnUrl); + attempt.ReturnUrl); } private async Task UpsertUserAccountAsync( diff --git a/src/Bifrost.Web/Features/Heimdall/HeimdallTokenVerifier.cs b/src/Bifrost.Web/Features/Heimdall/HeimdallTokenVerifier.cs index c344d05..58d67bf 100644 --- a/src/Bifrost.Web/Features/Heimdall/HeimdallTokenVerifier.cs +++ b/src/Bifrost.Web/Features/Heimdall/HeimdallTokenVerifier.cs @@ -144,7 +144,7 @@ public sealed class HeimdallTokenVerifier( throw Refuse("token is not yet valid"); } - if (NumericDate(claims, "exp") is not { } expires || now >= expires + ClockSkew) + if (NumericDate(claims, "exp") is not { } expires || now - ClockSkew >= expires) { throw Refuse("token has expired"); } diff --git a/tests/Bifrost.Web.Tests/HeimdallCallbackTests.cs b/tests/Bifrost.Web.Tests/HeimdallCallbackTests.cs index 619688b..8837c2a 100644 --- a/tests/Bifrost.Web.Tests/HeimdallCallbackTests.cs +++ b/tests/Bifrost.Web.Tests/HeimdallCallbackTests.cs @@ -24,6 +24,14 @@ public sealed class HeimdallCallbackTests attempt.AttemptId, accountId, host.Token(accountId, capabilities, displayName))); } + private static void AssertLogged(HeimdallHost host, string reason) => + Assert.Contains("Heimdall callback refused: " + reason, string.Join("\n", host.Logs.Lines)); + + private static void AssertTokenRefused(HeimdallHost host, string rule) => + AssertLogged(host, $"Heimdall token refused: {rule}."); + + private static string ErrorLocation(string message) => "/?auth=heimdall-error&detail=" + Uri.EscapeDataString(message); + private static async Task AssertRefused(HeimdallHost host, StartedAttempt attempt, string accountId, HttpResponseMessage delivery) { Assert.Equal(HttpStatusCode.BadRequest, delivery.StatusCode); @@ -90,6 +98,19 @@ public sealed class HeimdallCallbackTests using var delivery = await host.DeliverAsync(payload); await AssertRefused(host, attempt, accountId, delivery); + AssertLogged(host, "Heimdall callback carried no access token."); + } + + [Fact] + public async Task A_callback_without_an_attempt_id_signs_nobody_in() + { + using var host = new HeimdallHost(); + var accountId = HeimdallShapes.NewAccountId(); + + using var delivery = await host.DeliverAsync(HeimdallShapes.Delivery(" ", accountId, host.Token(accountId))); + + Assert.Equal(HttpStatusCode.BadRequest, delivery.StatusCode); + AssertLogged(host, "Heimdall callback omitted attempt id."); } [Fact] @@ -102,27 +123,32 @@ public sealed class HeimdallCallbackTests using var delivery = await host.DeliverAsync(HeimdallShapes.Delivery(attempt.AttemptId, accountId, " ")); await AssertRefused(host, attempt, accountId, delivery); + AssertLogged(host, "Heimdall callback carried no access token."); } [Theory] - [InlineData("other-key-same-kid")] - [InlineData("other-key-other-kid")] - [InlineData("tampered-payload")] - [InlineData("alg-none")] - [InlineData("alg-hs256")] - [InlineData("truncated-signature")] - [InlineData("no-kid")] - [InlineData("unpublished-kid")] - [InlineData("four-segments")] - [InlineData("two-segments")] - [InlineData("not-base64url")] - [InlineData("payload-not-json")] - public async Task A_forged_token_signs_nobody_in(string forgery) + [InlineData("other-key-same-kid", "token signature is invalid")] + [InlineData("other-key-other-kid", "token key is not published")] + [InlineData("tampered-payload", "token signature is invalid")] + [InlineData("alg-none", "token algorithm is not EdDSA")] + [InlineData("alg-hs256", "token algorithm is not EdDSA")] + [InlineData("header-array", "token algorithm is not EdDSA")] + [InlineData("header-not-json", "token header is not JSON")] + [InlineData("truncated-signature", "token signature is invalid")] + [InlineData("no-kid", "token has no key id")] + [InlineData("unpublished-kid", "token key is not published")] + [InlineData("four-segments", "token is not a compact JWS")] + [InlineData("two-segments", "token is not a compact JWS")] + [InlineData("not-base64url", "token is not base64url")] + [InlineData("payload-not-json", "token payload is not JSON")] + [InlineData("payload-not-object", "token payload is not an object")] + public async Task A_forged_token_signs_nobody_in(string forgery, string rule) { using var host = new HeimdallHost(); var accountId = HeimdallShapes.NewAccountId(); var attempt = await host.StartAsync(); var claims = HeimdallShapes.AccessClaims(accountId, host.Clock.Now, ["member_access"]); + var claimBytes = JsonSerializer.SerializeToUtf8Bytes(claims); var genuine = host.Key.Sign(claims); var parts = genuine.Split('.'); @@ -134,6 +160,8 @@ public sealed class HeimdallCallbackTests HeimdallShapes.AccessClaims(accountId, host.Clock.Now, ["member_access", "x"]))) + "." + parts[2], "alg-none" => WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes("{\"alg\":\"none\",\"typ\":\"JWT\"}")) + "." + parts[1] + ".", "alg-hs256" => host.Key.Sign(claims, alg: "HS256"), + "header-array" => host.Key.SignParts(Encoding.UTF8.GetBytes("[1]"), claimBytes), + "header-not-json" => host.Key.SignParts(Encoding.UTF8.GetBytes("not json"), claimBytes), "truncated-signature" => parts[0] + "." + parts[1] + "." + WebEncoders.Base64UrlEncode(WebEncoders.Base64UrlDecode(parts[2])[..63]), "no-kid" => host.Key.Sign(claims, kid: null), @@ -141,7 +169,8 @@ public sealed class HeimdallCallbackTests "four-segments" => genuine + ".AAAA", "two-segments" => parts[0] + "." + parts[1], "not-base64url" => parts[0] + "." + parts[1] + ".!!" + parts[2][2..], - "payload-not-json" => parts[0] + "." + WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes("not json")) + "." + parts[2], + "payload-not-json" => host.Key.SignParts(host.Key.HeaderBytes(), Encoding.UTF8.GetBytes("not json")), + "payload-not-object" => host.Key.SignParts(host.Key.HeaderBytes(), Encoding.UTF8.GetBytes("[1]")), _ => throw new InvalidOperationException(forgery) }; Assert.NotEqual(genuine, token); @@ -149,6 +178,7 @@ public sealed class HeimdallCallbackTests using var delivery = await host.DeliverAsync(HeimdallShapes.Delivery(attempt.AttemptId, accountId, token)); await AssertRefused(host, attempt, accountId, delivery); + AssertTokenRefused(host, rule); } [Fact] @@ -170,27 +200,30 @@ public sealed class HeimdallCallbackTests } [Theory] - [InlineData("aud-other-app")] - [InlineData("aud-case")] - [InlineData("aud-array")] - [InlineData("aud-missing")] - [InlineData("app-slug-other")] - [InlineData("app-missing")] - [InlineData("typ-refresh")] - [InlineData("typ-missing")] - [InlineData("iss-other")] - [InlineData("iss-trailing-slash")] - [InlineData("expired")] - [InlineData("not-yet-valid")] - [InlineData("nbf-missing")] - [InlineData("exp-missing")] - [InlineData("exp-string")] - [InlineData("exp-out-of-range")] - [InlineData("sub-differs")] - [InlineData("account-id-differs")] - [InlineData("account-id-missing")] - [InlineData("sub-empty")] - public async Task A_signed_token_with_a_wrong_claim_signs_nobody_in(string wrong) + [InlineData("aud-other-app", "token audience is not this app")] + [InlineData("aud-case", "token audience is not this app")] + [InlineData("aud-array", "token audience is not this app")] + [InlineData("aud-missing", "token audience is not this app")] + [InlineData("app-slug-other", "token app is not this app")] + [InlineData("app-missing", "token app is not this app")] + [InlineData("app-not-object", "token app is not this app")] + [InlineData("typ-refresh", "token type is not an access token")] + [InlineData("typ-missing", "token type is not an access token")] + [InlineData("iss-other", "token issuer is not Heimdall")] + [InlineData("iss-trailing-slash", "token issuer is not Heimdall")] + [InlineData("expired", "token has expired")] + [InlineData("exp-missing", "token has expired")] + [InlineData("exp-string", "token has expired")] + [InlineData("exp-out-of-range", "token has expired")] + [InlineData("exp-negative", "token has expired")] + [InlineData("not-yet-valid", "token is not yet valid")] + [InlineData("nbf-missing", "token is not yet valid")] + [InlineData("nbf-negative", "token is not yet valid")] + [InlineData("sub-differs", "token subject does not match the account")] + [InlineData("account-id-differs", "token subject does not match the account")] + [InlineData("account-id-missing", "token subject does not match the account")] + [InlineData("sub-empty", "token subject does not match the account")] + public async Task A_signed_token_with_a_wrong_claim_signs_nobody_in(string wrong, string rule) { using var host = new HeimdallHost(); var accountId = HeimdallShapes.NewAccountId(); @@ -206,16 +239,19 @@ public sealed class HeimdallCallbackTests case "aud-missing": claims.Remove("aud"); break; case "app-slug-other": claims["app"] = new Dictionary { ["slug"] = "ghostlight" }; break; case "app-missing": claims.Remove("app"); break; + case "app-not-object": claims["app"] = "bifrost"; break; case "typ-refresh": claims["typ"] = "heimdall_refresh"; break; case "typ-missing": claims.Remove("typ"); break; case "iss-other": claims["iss"] = "https://heimdall.example"; break; case "iss-trailing-slash": claims["iss"] = HeimdallShapes.Issuer + "/"; break; case "expired": claims["exp"] = now - 3600; break; - case "not-yet-valid": claims["nbf"] = now + 3600; claims["exp"] = now + 7200; break; - case "nbf-missing": claims.Remove("nbf"); break; case "exp-missing": claims.Remove("exp"); break; case "exp-string": claims["exp"] = (now + 900).ToString(); break; case "exp-out-of-range": claims["exp"] = long.MaxValue; break; + case "exp-negative": claims["exp"] = -1; break; + case "not-yet-valid": claims["nbf"] = now + 3600; claims["exp"] = now + 7200; break; + case "nbf-missing": claims.Remove("nbf"); break; + case "nbf-negative": claims["nbf"] = -1; break; case "sub-differs": claims["sub"] = Flip(accountId); break; case "account-id-differs": claims["account_id"] = Flip(accountId); break; case "account-id-missing": claims.Remove("account_id"); break; @@ -227,6 +263,7 @@ public sealed class HeimdallCallbackTests using var delivery = await host.DeliverAsync(HeimdallShapes.Delivery(attempt.AttemptId, accountId, token)); await AssertRefused(host, attempt, accountId, delivery); + AssertTokenRefused(host, rule); } [Fact] @@ -244,6 +281,7 @@ public sealed class HeimdallCallbackTests Assert.False((await host.WaitAsync(attempt.AttemptId, attempt.Binding)).SignedIn); Assert.Null(await host.Account(tokenAccount)); Assert.Null(await host.Account(payloadAccount)); + AssertTokenRefused(host, "token subject does not match the account"); } [Fact] @@ -258,6 +296,7 @@ public sealed class HeimdallCallbackTests using var delivery = await host.DeliverAsync(payload); await AssertRefused(host, attempt, accountId, delivery); + AssertTokenRefused(host, "token subject does not match the account"); } [Theory] @@ -294,12 +333,9 @@ public sealed class HeimdallCallbackTests Assert.Equal(HttpStatusCode.BadRequest, delivery.StatusCode); var wait = await host.WaitAsync(unissued, "anything"); Assert.False(wait.SignedIn); - Assert.StartsWith("/?auth=heimdall-error", wait.Response.Headers.Location!.OriginalString); + Assert.Equal(ErrorLocation("Heimdall auth attempt is not valid for this browser."), wait.Response.Headers.Location!.OriginalString); Assert.Null(await host.Account(accountId)); - - // The same id arriving at wait before and after the refused delivery is equally unknown: no pending page. - var again = await host.WaitAsync(unissued, "anything"); - Assert.Equal(HttpStatusCode.Redirect, again.Response.StatusCode); + AssertLogged(host, "Heimdall callback did not match a pending attempt."); } [Fact] @@ -315,6 +351,7 @@ public sealed class HeimdallCallbackTests Assert.Equal(HttpStatusCode.Accepted, accepted.StatusCode); Assert.Equal(HttpStatusCode.BadRequest, replaced.StatusCode); + AssertLogged(host, "Heimdall callback did not match a pending attempt."); Assert.True((await host.WaitAsync(attempt.AttemptId, attempt.Binding)).SignedIn); Assert.NotNull(await host.Account(first)); Assert.Null(await host.Account(second)); @@ -345,9 +382,10 @@ public sealed class HeimdallCallbackTests using var delivery = await DeliverGenuine(host, attempt, accountId); Assert.Equal(HttpStatusCode.BadRequest, delivery.StatusCode); + AssertLogged(host, "Heimdall callback did not match a pending attempt."); var wait = await host.WaitAsync(attempt.AttemptId, attempt.Binding); Assert.False(wait.SignedIn); - Assert.StartsWith("/?auth=heimdall-error", wait.Response.Headers.Location!.OriginalString); + Assert.Equal(ErrorLocation("Heimdall auth attempt is not valid for this browser."), wait.Response.Headers.Location!.OriginalString); } [Fact] @@ -362,7 +400,7 @@ public sealed class HeimdallCallbackTests var wait = await host.WaitAsync(attempt.AttemptId, attempt.Binding); Assert.False(wait.SignedIn); - Assert.StartsWith("/?auth=heimdall-error", wait.Response.Headers.Location!.OriginalString); + Assert.Equal(ErrorLocation("Heimdall auth attempt is not valid for this browser."), wait.Response.Headers.Location!.OriginalString); Assert.Null(await host.Account(accountId)); } @@ -379,11 +417,11 @@ public sealed class HeimdallCallbackTests } // Exactly at capacity nothing has been dropped yet. - store.Complete("attempt-0", identity); + store.Complete("attempt-1", identity); store.Create("attempt-extra", "discord", "/App", false); Assert.Throws(() => store.Complete("attempt-0", identity)); - store.Complete("attempt-1", identity); + store.Complete("attempt-2", identity); store.Complete("attempt-extra", identity); store.Complete($"attempt-{HeimdallAuthAttemptStore.Capacity - 1}", identity); } @@ -434,7 +472,7 @@ public sealed class HeimdallCallbackTests { var refused = await host.WaitAsync(attempt.AttemptId, binding); Assert.False(refused.SignedIn, name); - Assert.StartsWith("/?auth=heimdall-error", refused.Response.Headers.Location!.OriginalString); + Assert.Equal(ErrorLocation("Heimdall auth attempt is not valid for this browser."), refused.Response.Headers.Location!.OriginalString); } Assert.Null(await host.Account(accountId)); @@ -506,7 +544,7 @@ public sealed class HeimdallCallbackTests else { Assert.False(wait.SignedIn); - Assert.StartsWith("/?auth=heimdall-error", wait.Response.Headers.Location!.OriginalString); + Assert.Equal(ErrorLocation("Heimdall did not grant Bifrost member access."), wait.Response.Headers.Location!.OriginalString); Assert.Null(account); } } @@ -665,6 +703,7 @@ public sealed class HeimdallCallbackTests using (var down = await DeliverGenuine(host, first, accountId)) { Assert.Equal(HttpStatusCode.BadRequest, down.StatusCode); + AssertTokenRefused(host, "Heimdall keys could not be fetched"); } var second = await host.StartAsync(); @@ -711,6 +750,75 @@ public sealed class HeimdallCallbackTests Assert.Equal(HttpStatusCode.Accepted, accepted.StatusCode); } + [Fact] + public async Task Capabilities_that_are_not_a_list_of_names_grant_nothing_and_do_not_crash() + { + using var host = new HeimdallHost(); + var accountId = HeimdallShapes.NewAccountId(); + var attempt = await host.StartAsync(); + var token = host.Token(accountId, mutate: claims => claims["capabilities"] = "member_access"); + + using var delivery = await host.DeliverAsync(HeimdallShapes.Delivery(attempt.AttemptId, accountId, token)); + + Assert.Equal(HttpStatusCode.Accepted, delivery.StatusCode); + Assert.True((await host.WaitAsync(attempt.AttemptId, attempt.Binding)).SignedIn); + Assert.Empty((await host.Account(accountId))!.Membership!.RoleAssignments); + } + + [Fact] + public async Task Member_access_is_found_among_other_and_non_string_capabilities() + { + using var host = new HeimdallHost(); + var accountId = HeimdallShapes.NewAccountId(); + var attempt = await host.StartAsync(member: true); + var token = host.Token(accountId, mutate: claims => + claims["capabilities"] = new object?[] { 7, null, "billing_access", "member_access" }); + + using var delivery = await host.DeliverAsync(HeimdallShapes.Delivery(attempt.AttemptId, accountId, token)); + + Assert.Equal(HttpStatusCode.Accepted, delivery.StatusCode); + Assert.True((await host.WaitAsync(attempt.AttemptId, attempt.Binding)).SignedIn); + Assert.Contains((await host.Account(accountId))!.Membership!.RoleAssignments, x => x.Role == MemberRole.StandardMember); + } + + [Theory] + [InlineData(0L, 900L)] + [InlineData(-1L, 253402300799L)] + public async Task Numeric_dates_at_the_ends_of_the_valid_range_are_accepted(long notBefore, long expires) + { + using var host = new HeimdallHost(); + var accountId = HeimdallShapes.NewAccountId(); + var attempt = await host.StartAsync(); + var now = host.Clock.Now.ToUnixTimeSeconds(); + var token = host.Token(accountId, mutate: claims => + { + claims["nbf"] = notBefore < 0 ? now : notBefore; + claims["exp"] = expires == 900 ? now + 900 : expires; + }); + + using var delivery = await host.DeliverAsync(HeimdallShapes.Delivery(attempt.AttemptId, accountId, token)); + + Assert.Equal(HttpStatusCode.Accepted, delivery.StatusCode); + } + + [Theory] + [InlineData(HttpStatusCode.InternalServerError, "{}", "Heimdall rejected the auth start.")] + [InlineData(HttpStatusCode.OK, "{}", "Heimdall auth start returned no authorization URL.")] + [InlineData(HttpStatusCode.OK, "{\"authorizationUrl\":\" \"}", "Heimdall auth start returned no authorization URL.")] + public async Task A_start_Heimdall_cannot_serve_issues_no_attempt_and_no_cookie(HttpStatusCode status, string body, string message) + { + using var host = new HeimdallHost(); + host.Heimdall.StartStatus = status; + host.Heimdall.StartBody = body; + + var attempt = await host.StartAsync(); + + Assert.Equal(ErrorLocation(message), attempt.Response.Headers.Location!.OriginalString); + Assert.Null(attempt.Binding); + var wait = await host.WaitAsync(attempt.AttemptId, "anything"); + Assert.False(wait.SignedIn); + } + [Fact] public async Task Refusals_echo_no_input_in_responses_or_logs() { diff --git a/tests/Bifrost.Web.Tests/Support/HeimdallTestKit.cs b/tests/Bifrost.Web.Tests/Support/HeimdallTestKit.cs index 477a4ad..22efa5b 100644 --- a/tests/Bifrost.Web.Tests/Support/HeimdallTestKit.cs +++ b/tests/Bifrost.Web.Tests/Support/HeimdallTestKit.cs @@ -52,7 +52,10 @@ public sealed class HeimdallKey }; /// Signs like Heimdall's signJwt: header {alg, kid, typ}, compact JWS. - public string Sign(IDictionary claims, string alg = "EdDSA", string? kid = "") + public string Sign(IDictionary claims, string alg = "EdDSA", string? kid = "") => + SignParts(HeaderBytes(alg, kid), JsonSerializer.SerializeToUtf8Bytes(claims)); + + public byte[] HeaderBytes(string alg = "EdDSA", string? kid = "") { var header = new Dictionary { ["alg"] = alg, ["typ"] = "JWT" }; if (kid is not null) @@ -60,9 +63,13 @@ public sealed class HeimdallKey header["kid"] = kid.Length == 0 ? Kid : kid; } - var signingInput = - WebEncoders.Base64UrlEncode(JsonSerializer.SerializeToUtf8Bytes(header)) + "." + - WebEncoders.Base64UrlEncode(JsonSerializer.SerializeToUtf8Bytes(claims)); + return JsonSerializer.SerializeToUtf8Bytes(header); + } + + /// Signs exactly these header and payload bytes, whatever they hold. + public string SignParts(byte[] headerBytes, byte[] payloadBytes) + { + var signingInput = WebEncoders.Base64UrlEncode(headerBytes) + "." + WebEncoders.Base64UrlEncode(payloadBytes); var message = Encoding.ASCII.GetBytes(signingInput); var signature = new byte[Ed25519.SignatureSize]; Ed25519.Sign(_seed, 0, message, 0, message.Length, signature, 0); @@ -175,6 +182,11 @@ public sealed class FakeHeimdall : HttpMessageHandler public List StartBodies { get; } = []; + public HttpStatusCode StartStatus { get; set; } = HttpStatusCode.OK; + + public string StartBody { get; set; } = + """{"authorizationUrl":"https://heimdall.test/oauth/discord/authorize?state=fixture"}"""; + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { var path = request.RequestUri!.AbsolutePath; @@ -191,12 +203,9 @@ public sealed class FakeHeimdall : HttpMessageHandler if (request.Method == HttpMethod.Post && path.StartsWith("/v1/oauth/", StringComparison.Ordinal) && path.EndsWith("/start", StringComparison.Ordinal)) { StartBodies.Add(await request.Content!.ReadAsStringAsync(cancellationToken)); - return new HttpResponseMessage(HttpStatusCode.OK) + return new HttpResponseMessage(StartStatus) { - Content = new StringContent( - """{"authorizationUrl":"https://heimdall.test/oauth/discord/authorize?state=fixture"}""", - Encoding.UTF8, - "application/json") + Content = new StringContent(StartBody, Encoding.UTF8, "application/json") }; } From bad2117ea8646951a44615e4ab0cadece6cfb473 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 11 Oct 2026 04:40:47 +0200 Subject: [PATCH 38/97] Heimdall sign-in: the attempt is a cookie, and a code the browser presents is redeemed at Heimdall Start puts a per-attempt nonce in the returnTo it sends Heimdall and keeps the attempt (provider, member flag, local return path, expiry) in a Data Protection cookie named for that nonce. Heimdall's completion page sends the browser to /auth/heimdall/complete with a code in the URL fragment; the page posts it back, Bifrost redeems it at Heimdall, requires the redeemed returnTo to name the cookie's nonce, verifies the access token for the redeemed account, and signs that browser in. Deleted: the callback route, the wait route, the server-side attempt store, the pending state and the backend_callback handoff. Added: LocalReturnPath for return targets, Heimdall:AppSecret sent as x-heimdall-app-secret (member starts are refused without it), a short timeout on the key fetch, and a key lookup that does not hold the fetch gate for cached keys. Refusals at the completion route all answer one redirect and log a fixed reason. Tests are rewritten against a fake Heimdall with start and redeem routes. Not compile-checked by this commit alone; later commits record the verification. Co-Authored-By: Claude Opus 5.5 --- .../Configuration/HeimdallOptions.cs | 4 + .../Features/Heimdall/HeimdallAuthService.cs | 277 ++-- .../Features/Heimdall/HeimdallCompletePage.cs | 46 + .../Heimdall/HeimdallTokenVerifier.cs | 43 +- .../Features/Shared/LocalReturnPath.cs | 30 + src/Bifrost.Web/Program.cs | 123 +- src/Bifrost.Web/appsettings.json | 1 + .../HeimdallCallbackTests.cs | 893 ------------- .../HeimdallCompletionTests.cs | 1109 +++++++++++++++++ .../Support/HeimdallTestKit.cs | 205 ++- 10 files changed, 1590 insertions(+), 1141 deletions(-) create mode 100644 src/Bifrost.Web/Features/Heimdall/HeimdallCompletePage.cs create mode 100644 src/Bifrost.Web/Features/Shared/LocalReturnPath.cs delete mode 100644 tests/Bifrost.Web.Tests/HeimdallCallbackTests.cs create mode 100644 tests/Bifrost.Web.Tests/HeimdallCompletionTests.cs diff --git a/src/Bifrost.Web/Configuration/HeimdallOptions.cs b/src/Bifrost.Web/Configuration/HeimdallOptions.cs index adddfdc..01eaec9 100644 --- a/src/Bifrost.Web/Configuration/HeimdallOptions.cs +++ b/src/Bifrost.Web/Configuration/HeimdallOptions.cs @@ -14,6 +14,8 @@ public sealed class HeimdallOptions public string PatreonTierTitle { get; init; } = "Inner Sanctum"; + public string AppSecret { get; init; } = string.Empty; + public string PatronSupportIntakeSecret { get; init; } = string.Empty; public bool EnablePatronSupportIntake { get; init; } = true; @@ -24,11 +26,13 @@ public sealed class HeimdallOptions public bool IsDiscordConfigured => IsConfigured && + !string.IsNullOrWhiteSpace(AppSecret) && !string.IsNullOrWhiteSpace(DiscordGuildId) && DiscordAllowedRoleIds.Length > 0; public bool IsPatreonConfigured => IsConfigured && + !string.IsNullOrWhiteSpace(AppSecret) && !string.IsNullOrWhiteSpace(PatreonTierTitle); public bool IsPatronSupportIntakeConfigured => diff --git a/src/Bifrost.Web/Features/Heimdall/HeimdallAuthService.cs b/src/Bifrost.Web/Features/Heimdall/HeimdallAuthService.cs index d27a3fe..2491b8c 100644 --- a/src/Bifrost.Web/Features/Heimdall/HeimdallAuthService.cs +++ b/src/Bifrost.Web/Features/Heimdall/HeimdallAuthService.cs @@ -1,28 +1,39 @@ +using System.Net; using System.Net.Http.Json; -using System.Collections.Concurrent; using System.Security.Claims; +using System.Security.Cryptography; +using System.Text.Json; using System.Text.Json.Serialization; using Bifrost.Web.Configuration; using Bifrost.Web.Data; using Bifrost.Web.Domain; using Bifrost.Web.Features.Membership; using Bifrost.Web.Features.Shared; -using Microsoft.AspNetCore.Authentication; -using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.WebUtilities; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; namespace Bifrost.Web.Features.Heimdall; +/// +/// Heimdall sign-in. Start issues an attempt that lives only in a Data Protection-protected cookie in the +/// browser that began it. The browser that Heimdall authenticated presents the completion code from its +/// URL fragment; Bifrost redeems it at Heimdall itself and signs in only when that browser holds the +/// attempt's cookie and the redeemed return target names the same attempt. +/// public sealed class HeimdallAuthService( HttpClient httpClient, - HeimdallAuthAttemptStore attemptStore, HeimdallTokenVerifier tokenVerifier, BifrostDbContext dbContext, BifrostIdentityService bifrostIdentityService, IOptions heimdallOptions, + IDataProtectionProvider dataProtectionProvider, TimeProvider timeProvider) { + public const string ErrorRedirect = "/?auth=heimdall-error"; + public const string AppSecretHeader = "x-heimdall-app-secret"; + private static readonly HashSet AllowedProviders = new(StringComparer.OrdinalIgnoreCase) { "discord", @@ -30,10 +41,22 @@ public sealed class HeimdallAuthService( "twitch" }; + private readonly IDataProtector _protector = dataProtectionProvider.CreateProtector("Bifrost.Heimdall.Attempt.v1"); + + /// Points the client at Heimdall and, when the app has a secret, identifies Bifrost as the app. + public static void ConfigureClient(HttpClient client, HeimdallOptions options) + { + client.BaseAddress = new Uri(options.BaseUrl.TrimEnd('/')); + if (!string.IsNullOrWhiteSpace(options.AppSecret)) + { + client.DefaultRequestHeaders.Add(AppSecretHeader, options.AppSecret); + } + } + public async Task StartAsync( string provider, string callbackBaseUrl, - string returnUrl, + LocalReturnPath returnPath, bool requireMemberAccess, CancellationToken cancellationToken) { @@ -51,14 +74,13 @@ public sealed class HeimdallAuthService( var entitlementPolicy = requireMemberAccess ? BuildMemberAccessPolicy(provider, options) : null; - var attemptId = Guid.NewGuid().ToString("N"); - var callbackUrl = new Uri(new Uri(callbackBaseUrl.TrimEnd('/') + "/", UriKind.Absolute), "auth/heimdall/callback"); + var nonce = WebEncoders.Base64UrlEncode(RandomNumberGenerator.GetBytes(32)); var request = new HeimdallStartRequest( options.AppSlug, "sign_in", - ToAttemptReturnUrl(callbackBaseUrl, attemptId, returnUrl), - new HeimdallHandoff("backend_callback", attemptId, callbackUrl.ToString()), + ToAttemptReturnUrl(callbackBaseUrl, nonce), + new HeimdallHandoff("browser_completion"), entitlementPolicy); var response = await httpClient.PostAsJsonAsync( @@ -77,35 +99,41 @@ public sealed class HeimdallAuthService( throw new HeimdallAuthException("Heimdall auth start returned no authorization URL."); } - var binding = attemptStore.Create(attemptId, provider, returnUrl, requireMemberAccess); - return new HeimdallAuthStartResult(new Uri(payload.AuthorizationUrl, UriKind.Absolute), attemptId, binding); + var attempt = new HeimdallAttempt( + nonce, + provider, + requireMemberAccess, + returnPath, + timeProvider.GetUtcNow() + HeimdallAttemptCookie.Lifetime); + return new HeimdallAuthStartResult(new Uri(payload.AuthorizationUrl, UriKind.Absolute), nonce, Protect(attempt)); } - public async Task StoreBackendCallbackAsync( - HeimdallBackendCallbackPayload payload, + public async Task CompleteAsync( + string callbackBaseUrl, + string? nonce, + string? cookieValue, + string? code, CancellationToken cancellationToken) { - if (string.IsNullOrWhiteSpace(payload.AttemptId)) + var attempt = Unprotect(cookieValue, nonce) + ?? throw new HeimdallAuthException("Heimdall completion has no attempt for this browser."); + if (string.IsNullOrWhiteSpace(code)) { - throw new HeimdallAuthException("Heimdall callback omitted attempt id."); + throw new HeimdallAuthException("Heimdall completion carried no code."); } - if (string.IsNullOrWhiteSpace(payload.AccessToken)) + var redeemed = await RedeemAsync(code, cancellationToken); + if (!string.Equals(redeemed.ReturnTo, ToAttemptReturnUrl(callbackBaseUrl, attempt.Nonce), StringComparison.Ordinal)) { - throw new HeimdallAuthException("Heimdall callback carried no access token."); + throw new HeimdallAuthException("Heimdall completion belongs to another attempt."); } - var identity = await tokenVerifier.VerifyAsync(payload.AccessToken, payload.Account?.Id, cancellationToken); - attemptStore.Complete(payload.AttemptId, identity); - } + if (string.IsNullOrWhiteSpace(redeemed.AccessToken)) + { + throw new HeimdallAuthException("Heimdall completion carried no access token."); + } - public async Task CompleteAttemptAsync( - string attemptId, - string? binding, - CancellationToken cancellationToken) - { - var attempt = attemptStore.TryTake(attemptId, binding); - var identity = attempt.Identity; + var identity = await tokenVerifier.VerifyAsync(redeemed.AccessToken, redeemed.Account?.Id, cancellationToken); if (attempt.RequiresMemberAccess && !identity.HasMemberAccess) { throw new HeimdallAuthException("Heimdall did not grant Bifrost member access."); @@ -118,9 +146,68 @@ public sealed class HeimdallAuthService( cancellationToken); return new HeimdallCompletedSignIn( bifrostIdentityService.BuildPrincipal(userAccount, attempt.Provider), - attempt.ReturnUrl); + attempt.ReturnPath); } + private async Task RedeemAsync(string code, CancellationToken cancellationToken) + { + try + { + using var response = await httpClient.PostAsJsonAsync( + $"/v1/apps/{Uri.EscapeDataString(heimdallOptions.Value.AppSlug)}/auth-completions/redeem", + new HeimdallRedeemRequest(code), + cancellationToken); + if (response.StatusCode != HttpStatusCode.Created) + { + throw new HeimdallAuthException("Heimdall did not redeem the completion code."); + } + + return await response.Content.ReadFromJsonAsync(cancellationToken) + ?? throw new HeimdallAuthException("Heimdall redeem returned no body."); + } + catch (Exception error) when (error is not HeimdallAuthException && !cancellationToken.IsCancellationRequested) + { + throw new HeimdallAuthException("Heimdall redeem could not be read."); + } + } + + private string Protect(HeimdallAttempt attempt) => + _protector.Protect(JsonSerializer.Serialize(new AttemptWire( + attempt.Nonce, attempt.Provider, attempt.RequiresMemberAccess, attempt.ReturnPath.Value, attempt.ExpiresAt))); + + private HeimdallAttempt? Unprotect(string? cookieValue, string? nonce) + { + if (string.IsNullOrEmpty(cookieValue) || string.IsNullOrEmpty(nonce)) + { + return null; + } + + try + { + var wire = JsonSerializer.Deserialize(_protector.Unprotect(cookieValue)); + if (wire is null || + !string.Equals(wire.Nonce, nonce, StringComparison.Ordinal) || + wire.ExpiresAt <= timeProvider.GetUtcNow()) + { + return null; + } + + return new HeimdallAttempt( + wire.Nonce, wire.Provider, wire.RequiresMemberAccess, LocalReturnPath.Parse(wire.ReturnPath), wire.ExpiresAt); + } + catch (Exception error) when (error is CryptographicException or JsonException or FormatException) + { + return null; + } + } + + private sealed record AttemptWire( + string Nonce, + string Provider, + bool RequiresMemberAccess, + string ReturnPath, + DateTimeOffset ExpiresAt); + private async Task UpsertUserAccountAsync( HeimdallVerifiedIdentity identity, string provider, @@ -248,112 +335,29 @@ public sealed class HeimdallAuthService( return BuildPatreonPolicy(options); } - throw new HeimdallAuthException($"Heimdall provider '{provider}' does not support Bifrost member access."); + throw new HeimdallAuthException("Heimdall provider does not support Bifrost member access."); } - private static string ToAttemptReturnUrl(string callbackBaseUrl, string attemptId, string returnUrl) + private static string ToAttemptReturnUrl(string callbackBaseUrl, string nonce) { var baseUri = new Uri(callbackBaseUrl.TrimEnd('/') + "/", UriKind.Absolute); - var target = string.IsNullOrWhiteSpace(returnUrl) ? "/App" : returnUrl; - var waitUri = new Uri(baseUri, "auth/heimdall/wait"); - return $"{waitUri}?attemptId={Uri.EscapeDataString(attemptId)}&returnTo={Uri.EscapeDataString(target)}"; + return $"{new Uri(baseUri, "auth/heimdall/complete")}?attempt={Uri.EscapeDataString(nonce)}"; } } public class HeimdallAuthException(string message) : InvalidOperationException(message); -public sealed class HeimdallAuthPendingException(string message) : HeimdallAuthException(message); +public sealed record HeimdallAuthStartResult(Uri AuthorizationUrl, string Nonce, string CookieValue); -public sealed record HeimdallAuthStartResult(Uri AuthorizationUrl, string AttemptId, string Binding); +public sealed record HeimdallCompletedSignIn(ClaimsPrincipal Principal, LocalReturnPath ReturnPath); -public sealed record HeimdallCompletedSignIn(ClaimsPrincipal Principal, string RedirectUri); - -/// -/// Attempts Start issued. An attempt lives for , is completed only by a verified -/// Heimdall identity, and is redeemed once by the browser that holds its binding value. The store holds at -/// most attempts; a full store drops the one that expires first. -/// -public sealed class HeimdallAuthAttemptStore(TimeProvider timeProvider) -{ - public static readonly TimeSpan Lifetime = TimeSpan.FromMinutes(10); - public const int Capacity = 1024; - - private readonly object _gate = new(); - private readonly Dictionary _attempts = new(StringComparer.Ordinal); - - public string Create(string attemptId, string provider, string returnUrl, bool requiresMemberAccess) - { - var now = timeProvider.GetUtcNow(); - var binding = Microsoft.AspNetCore.WebUtilities.WebEncoders.Base64UrlEncode( - System.Security.Cryptography.RandomNumberGenerator.GetBytes(32)); - lock (_gate) - { - while (_attempts.Count >= Capacity) - { - _attempts.Remove(_attempts.MinBy(x => x.Value.ExpiresAt).Key); - } - - _attempts[attemptId] = new HeimdallAuthAttempt( - provider, returnUrl, requiresMemberAccess, binding, now + Lifetime, null); - } - - return binding; - } - - public void Complete(string attemptId, HeimdallVerifiedIdentity identity) - { - lock (_gate) - { - if (!_attempts.TryGetValue(attemptId, out var attempt) || - attempt.ExpiresAt <= timeProvider.GetUtcNow() || - attempt.Identity is not null) - { - throw new HeimdallAuthException("Heimdall callback did not match a pending attempt."); - } - - _attempts[attemptId] = attempt with { Identity = identity }; - } - } - - public HeimdallRedeemedAttempt TryTake(string attemptId, string? binding) - { - lock (_gate) - { - if (!_attempts.TryGetValue(attemptId, out var attempt) || - attempt.ExpiresAt <= timeProvider.GetUtcNow() || - binding is null || - !System.Security.Cryptography.CryptographicOperations.FixedTimeEquals( - System.Text.Encoding.UTF8.GetBytes(binding), - System.Text.Encoding.UTF8.GetBytes(attempt.Binding))) - { - throw new HeimdallAuthException("Heimdall auth attempt is not valid for this browser."); - } - - if (attempt.Identity is null) - { - throw new HeimdallAuthPendingException("Heimdall has not delivered this auth attempt yet."); - } - - _attempts.Remove(attemptId); - return new HeimdallRedeemedAttempt( - attempt.Provider, attempt.ReturnUrl, attempt.RequiresMemberAccess, attempt.Identity); - } - } -} - -public sealed record HeimdallAuthAttempt( +/// An attempt Start issued. It exists only inside its protected cookie. +public sealed record HeimdallAttempt( + string Nonce, string Provider, - string ReturnUrl, bool RequiresMemberAccess, - string Binding, - DateTimeOffset ExpiresAt, - HeimdallVerifiedIdentity? Identity); - -public sealed record HeimdallRedeemedAttempt( - string Provider, - string ReturnUrl, - bool RequiresMemberAccess, - HeimdallVerifiedIdentity Identity); + LocalReturnPath ReturnPath, + DateTimeOffset ExpiresAt); public sealed record HeimdallStartRequest( [property: JsonPropertyName("appSlug")] string AppSlug, @@ -364,11 +368,7 @@ public sealed record HeimdallStartRequest( [property: JsonPropertyName("entitlementPolicy")] HeimdallEntitlementPolicy? EntitlementPolicy); public sealed record HeimdallHandoff( - [property: JsonPropertyName("kind")] string Kind, - [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - [property: JsonPropertyName("attemptId")] string? AttemptId = null, - [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - [property: JsonPropertyName("callbackUrl")] string? CallbackUrl = null); + [property: JsonPropertyName("kind")] string Kind); public sealed record HeimdallEntitlementPolicy( [property: JsonPropertyName("kind")] string Kind, @@ -382,17 +382,32 @@ public sealed record HeimdallEntitlementPolicy( public sealed record HeimdallStartResponse( [property: JsonPropertyName("authorizationUrl")] string AuthorizationUrl); -public sealed record HeimdallBackendCallbackPayload( - [property: JsonPropertyName("attemptId")] string AttemptId, +public sealed record HeimdallRedeemRequest( + [property: JsonPropertyName("completionCode")] string CompletionCode); + +public sealed record HeimdallRedeemResponse( + [property: JsonPropertyName("returnTo")] string? ReturnTo, [property: JsonPropertyName("accessToken")] string? AccessToken, [property: JsonPropertyName("account")] HeimdallAccount? Account); public sealed record HeimdallAccount( [property: JsonPropertyName("id")] string Id); -public static class HeimdallBindingCookie +/// +/// The per-attempt cookie: named for the attempt's nonce so two attempts in one browser both complete. +/// A name is only built from a well-formed nonce, so request input never becomes an arbitrary cookie name. +/// +public static class HeimdallAttemptCookie { - public const string Name = "bifrost_heimdall_binding"; + public const string Prefix = "bifrost_heimdall_"; + public const int NonceLength = 43; + + public static readonly TimeSpan Lifetime = TimeSpan.FromMinutes(10); + + public static string? NameFor(string? nonce) => + nonce is { Length: NonceLength } && nonce.All(c => char.IsAsciiLetterOrDigit(c) || c is '-' or '_') + ? Prefix + nonce + : null; public static CookieOptions Options() => new() { @@ -400,6 +415,6 @@ public static class HeimdallBindingCookie Secure = true, SameSite = SameSiteMode.Lax, Path = "/auth/heimdall", - MaxAge = HeimdallAuthAttemptStore.Lifetime + MaxAge = Lifetime }; } diff --git a/src/Bifrost.Web/Features/Heimdall/HeimdallCompletePage.cs b/src/Bifrost.Web/Features/Heimdall/HeimdallCompletePage.cs new file mode 100644 index 0000000..662fb16 --- /dev/null +++ b/src/Bifrost.Web/Features/Heimdall/HeimdallCompletePage.cs @@ -0,0 +1,46 @@ +namespace Bifrost.Web.Features.Heimdall; + +/// +/// The page Heimdall's "Return to the app" lands on. The completion code arrives in the URL fragment, which +/// the server never sees, so the page's script reads it, drops it from the address bar, and posts it with +/// the attempt to the same path. The page is static: it never contains a code. +/// +public static class HeimdallCompletePage +{ + public const string Html = """ + + + + + + + Signing in - Bifrost + + +

Finishing sign-in...

+ +
+ + +
+ + + + """; +} diff --git a/src/Bifrost.Web/Features/Heimdall/HeimdallTokenVerifier.cs b/src/Bifrost.Web/Features/Heimdall/HeimdallTokenVerifier.cs index 58d67bf..38f71f1 100644 --- a/src/Bifrost.Web/Features/Heimdall/HeimdallTokenVerifier.cs +++ b/src/Bifrost.Web/Features/Heimdall/HeimdallTokenVerifier.cs @@ -31,8 +31,7 @@ public sealed class HeimdallTokenVerifier( private static readonly string[] MemberCapabilities = ["member_access", "app_access"]; private readonly SemaphoreSlim _fetchGate = new(1, 1); - private Dictionary _keys = new(StringComparer.Ordinal); - private DateTimeOffset _fetchedAt = DateTimeOffset.MinValue; + private volatile KeySnapshot _keys = new(new Dictionary(StringComparer.Ordinal), DateTimeOffset.MinValue); private DateTimeOffset _attemptedAt = DateTimeOffset.MinValue; public async Task VerifyAsync( @@ -69,6 +68,8 @@ public sealed class HeimdallTokenVerifier( return CheckClaims(payload.RootElement, heimdallOptions.Value, payloadAccountId); } + private sealed record KeySnapshot(Dictionary Keys, DateTimeOffset FetchedAt); + private static byte[] Decode(string segment) { try @@ -175,21 +176,36 @@ public sealed class HeimdallTokenVerifier( capabilities.Any(capability => MemberCapabilities.Contains(capability, StringComparer.OrdinalIgnoreCase))); } + /// The JWKS client waits a short, bounded time: a slow fetch must not hold sign-ins for long. + public static void ConfigureClient(HttpClient client, HeimdallOptions options) + { + client.BaseAddress = new Uri(options.BaseUrl.TrimEnd('/')); + client.Timeout = TimeSpan.FromSeconds(10); + } + private async Task FindKeyAsync(string kid, CancellationToken cancellationToken) { + if (TryFreshKey(kid, out var cached)) + { + return cached; + } + await _fetchGate.WaitAsync(cancellationToken); try { - var now = timeProvider.GetUtcNow(); - var stale = now - _fetchedAt >= KeyMaxAge; - if ((stale || !_keys.ContainsKey(kid)) && now - _attemptedAt >= KeyRefreshInterval) + if (TryFreshKey(kid, out cached)) { - _attemptedAt = now; - _keys = await FetchKeysAsync(cancellationToken); - _fetchedAt = now; + return cached; } - return _keys.GetValueOrDefault(kid); + var now = timeProvider.GetUtcNow(); + if (now - _attemptedAt >= KeyRefreshInterval) + { + _attemptedAt = now; + _keys = new KeySnapshot(await FetchKeysAsync(cancellationToken), now); + } + + return _keys.Keys.GetValueOrDefault(kid); } finally { @@ -197,13 +213,20 @@ public sealed class HeimdallTokenVerifier( } } + private bool TryFreshKey(string kid, out byte[]? key) + { + var snapshot = _keys; + key = null; + return timeProvider.GetUtcNow() - snapshot.FetchedAt < KeyMaxAge && snapshot.Keys.TryGetValue(kid, out key); + } + private async Task> FetchKeysAsync(CancellationToken cancellationToken) { try { return await ReadKeysAsync(cancellationToken); } - catch (Exception error) when (error is not OperationCanceledException) + catch (Exception) when (!cancellationToken.IsCancellationRequested) { throw Refuse("Heimdall keys could not be fetched"); } diff --git a/src/Bifrost.Web/Features/Shared/LocalReturnPath.cs b/src/Bifrost.Web/Features/Shared/LocalReturnPath.cs new file mode 100644 index 0000000..e9c97d1 --- /dev/null +++ b/src/Bifrost.Web/Features/Shared/LocalReturnPath.cs @@ -0,0 +1,30 @@ +namespace Bifrost.Web.Features.Shared; + +/// +/// A sign-in return target that stays on this site. The only way to make one is , which +/// accepts a path that starts with a single slash and holds no control character, and otherwise yields +/// . Callers parse once at entry and carry the typed value from there. +/// +public sealed record LocalReturnPath +{ + private LocalReturnPath(string value) => Value = value; + + public static LocalReturnPath Default { get; } = new("/App"); + + public string Value { get; } + + public static LocalReturnPath Parse(string? candidate) + { + if (string.IsNullOrEmpty(candidate) || + candidate[0] != '/' || + candidate.Any(char.IsControl) || + (candidate.Length > 1 && (candidate[1] == '/' || candidate[1] == '\\'))) + { + return Default; + } + + return new LocalReturnPath(candidate); + } + + public override string ToString() => Value; +} diff --git a/src/Bifrost.Web/Program.cs b/src/Bifrost.Web/Program.cs index 49e70b6..0123ce5 100644 --- a/src/Bifrost.Web/Program.cs +++ b/src/Bifrost.Web/Program.cs @@ -124,18 +124,11 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddSingleton(); builder.Services.AddScoped(); -builder.Services.AddSingleton(); builder.Services.AddHttpClient((serviceProvider, client) => -{ - var options = serviceProvider.GetRequiredService>().Value; - client.BaseAddress = new Uri(options.BaseUrl.TrimEnd('/')); -}); + HeimdallAuthService.ConfigureClient(client, serviceProvider.GetRequiredService>().Value)); builder.Services.AddSingleton(); builder.Services.AddHttpClient(HeimdallTokenVerifier.ClientName, (serviceProvider, client) => -{ - var options = serviceProvider.GetRequiredService>().Value; - client.BaseAddress = new Uri(options.BaseUrl.TrimEnd('/')); -}); + HeimdallTokenVerifier.ConfigureClient(client, serviceProvider.GetRequiredService>().Value)); builder.Services.AddHttpClient(client => { client.BaseAddress = new Uri("https://api.stripe.com/"); @@ -305,22 +298,18 @@ app.MapPost("/github/webhooks", async ( app.MapGet("/auth/sign-in", async (HttpContext httpContext, IOptions gitHubOptions) => { - var returnUrl = httpContext.Request.Query["returnUrl"].ToString(); - if (string.IsNullOrWhiteSpace(returnUrl)) - { - returnUrl = "/App"; - } + var returnPath = LocalReturnPath.Parse(httpContext.Request.Query["returnUrl"].ToString()); var provider = httpContext.Request.Query["provider"].ToString(); if (string.IsNullOrWhiteSpace(provider)) { - await WriteSignInChooserAsync(httpContext, returnUrl); + await WriteSignInChooserAsync(httpContext, returnPath.Value); return; } if (!provider.Equals("github", StringComparison.OrdinalIgnoreCase)) { - httpContext.Response.Redirect($"/auth/heimdall/{Uri.EscapeDataString(provider)}?returnUrl={Uri.EscapeDataString(returnUrl)}"); + httpContext.Response.Redirect($"/auth/heimdall/{Uri.EscapeDataString(provider)}?returnUrl={Uri.EscapeDataString(returnPath.Value)}"); return; } @@ -332,7 +321,7 @@ app.MapGet("/auth/sign-in", async (HttpContext httpContext, IOptions hostOptions, + ILogger logger, CancellationToken cancellationToken) => { try { - var returnUrl = httpContext.Request.Query["returnUrl"].ToString(); - if (string.IsNullOrWhiteSpace(returnUrl)) - { - returnUrl = "/App"; - } - + var returnPath = LocalReturnPath.Parse(httpContext.Request.Query["returnUrl"].ToString()); var requireMemberAccess = string.Equals( httpContext.Request.Query["access"].ToString(), "member", @@ -390,34 +375,20 @@ app.MapGet("/auth/heimdall/{provider}", async ( var start = await heimdallAuthService.StartAsync( provider, hostOptions.Value.PublicBaseUrl, - returnUrl, + returnPath, requireMemberAccess, cancellationToken); - httpContext.Response.Cookies.Append(HeimdallBindingCookie.Name, start.Binding, HeimdallBindingCookie.Options()); + httpContext.Response.Cookies.Append( + HeimdallAttemptCookie.NameFor(start.Nonce), + start.CookieValue, + HeimdallAttemptCookie.Options()); return Results.Redirect(start.AuthorizationUrl.ToString()); } catch (HeimdallAuthException error) { - return Results.Redirect($"/?auth=heimdall-error&detail={Uri.EscapeDataString(error.Message)}"); - } -}).AllowAnonymous(); - -app.MapPost("/auth/heimdall/callback", async ( - HeimdallBackendCallbackPayload payload, - HeimdallAuthService heimdallAuthService, - ILogger logger, - CancellationToken cancellationToken) => -{ - try - { - await heimdallAuthService.StoreBackendCallbackAsync(payload, cancellationToken); - return Results.Accepted(); - } - catch (HeimdallAuthException error) - { - logger.LogWarning("Heimdall callback refused: {Reason}", error.Message); - return Results.BadRequest("Heimdall callback refused."); + logger.LogWarning("Heimdall start refused: {Reason}", error.Message); + return Results.Redirect(HeimdallAuthService.ErrorRedirect); } }).AllowAnonymous(); @@ -543,37 +514,46 @@ eveGovernance.MapGet("/surface", async ( "application/json"); }); -app.MapGet("/auth/heimdall/wait", async ( +app.MapGet("/auth/heimdall/complete", (HttpContext httpContext) => +{ + httpContext.Response.Headers.CacheControl = "no-store"; + httpContext.Response.Headers["Referrer-Policy"] = "no-referrer"; + return Results.Content(HeimdallCompletePage.Html, "text/html", System.Text.Encoding.UTF8); +}).AllowAnonymous(); + +app.MapPost("/auth/heimdall/complete", async ( HttpContext httpContext, HeimdallAuthService heimdallAuthService, + IOptions hostOptions, + ILogger logger, CancellationToken cancellationToken) => { - var attemptId = httpContext.Request.Query["attemptId"].ToString(); - if (string.IsNullOrWhiteSpace(attemptId)) - { - return Results.Redirect("/?auth=heimdall-error&detail=missing-attempt-id"); - } - + var form = await ReadFormOrNullAsync(httpContext.Request, cancellationToken); + var attempt = form?["attempt"].ToString(); + var cookieName = HeimdallAttemptCookie.NameFor(attempt); try { - var signIn = await heimdallAuthService.CompleteAttemptAsync( - attemptId, - httpContext.Request.Cookies[HeimdallBindingCookie.Name], + var signIn = await heimdallAuthService.CompleteAsync( + hostOptions.Value.PublicBaseUrl, + attempt, + cookieName is null ? null : httpContext.Request.Cookies[cookieName], + form?["code"].ToString(), cancellationToken); await httpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, signIn.Principal); - httpContext.Response.Cookies.Delete(HeimdallBindingCookie.Name, HeimdallBindingCookie.Options()); - return Results.Redirect(signIn.RedirectUri); - } - catch (HeimdallAuthPendingException) - { - httpContext.Response.Headers.Append("Refresh", "2"); - return Results.Text("Waiting for Heimdall sign-in to complete.", "text/plain"); + httpContext.Response.Cookies.Delete(cookieName!, HeimdallAttemptCookie.Options()); + return Results.LocalRedirect(signIn.ReturnPath.Value); } catch (HeimdallAuthException error) { - return Results.Redirect($"/?auth=heimdall-error&detail={Uri.EscapeDataString(error.Message)}"); + logger.LogWarning("Heimdall completion refused: {Reason}", error.Message); + if (cookieName is not null) + { + httpContext.Response.Cookies.Delete(cookieName, HeimdallAttemptCookie.Options()); + } + + return Results.Redirect(HeimdallAuthService.ErrorRedirect); } -}).AllowAnonymous(); +}).AllowAnonymous().DisableAntiforgery(); app.MapPost("/auth/sign-out", async (HttpContext httpContext) => { @@ -591,6 +571,23 @@ static IResult JsonResult(T value, JsonSerializerOptions options, int statusC "application/json", statusCode: statusCode); +static async Task ReadFormOrNullAsync(HttpRequest request, CancellationToken cancellationToken) +{ + if (!request.HasFormContentType) + { + return null; + } + + try + { + return await request.ReadFormAsync(cancellationToken); + } + catch (Exception error) when (error is InvalidDataException or BadHttpRequestException) + { + return null; + } +} + static async Task WriteSignInChooserAsync(HttpContext httpContext, string returnUrl) { httpContext.Response.ContentType = "text/html; charset=utf-8"; diff --git a/src/Bifrost.Web/appsettings.json b/src/Bifrost.Web/appsettings.json index b24dd83..47bbb7b 100644 --- a/src/Bifrost.Web/appsettings.json +++ b/src/Bifrost.Web/appsettings.json @@ -27,6 +27,7 @@ "Heimdall": { "BaseUrl": "https://heimdall.gamecult.org", "AppSlug": "bifrost", + "AppSecret": "", "DiscordGuildId": "", "DiscordAllowedRoleIds": [], "PatreonTierTitle": "Inner Sanctum", diff --git a/tests/Bifrost.Web.Tests/HeimdallCallbackTests.cs b/tests/Bifrost.Web.Tests/HeimdallCallbackTests.cs deleted file mode 100644 index 8837c2a..0000000 --- a/tests/Bifrost.Web.Tests/HeimdallCallbackTests.cs +++ /dev/null @@ -1,893 +0,0 @@ -using System.Net; -using System.Text; -using System.Text.Json; -using Bifrost.Web.Domain; -using Bifrost.Web.Features.Heimdall; -using Bifrost.Web.Tests.Support; -using Microsoft.AspNetCore.WebUtilities; - -namespace Bifrost.Web.Tests; - -/// -/// Sign-in through the Heimdall callback, against a local Bifrost with a test JWKS. Deliveries are shaped -/// like Heimdall's backend-handoff.ts payload and claims.ts claim set; forgeries differ from them in the -/// one property under test. -/// -public sealed class HeimdallCallbackTests -{ - private static string Flip(string value) => value[..^1] + (value[^1] == '0' ? '1' : '0'); - - private static async Task DeliverGenuine( - HeimdallHost host, StartedAttempt attempt, string accountId, string[]? capabilities = null, string? displayName = "Genuine Name") - { - return await host.DeliverAsync(HeimdallShapes.Delivery( - attempt.AttemptId, accountId, host.Token(accountId, capabilities, displayName))); - } - - private static void AssertLogged(HeimdallHost host, string reason) => - Assert.Contains("Heimdall callback refused: " + reason, string.Join("\n", host.Logs.Lines)); - - private static void AssertTokenRefused(HeimdallHost host, string rule) => - AssertLogged(host, $"Heimdall token refused: {rule}."); - - private static string ErrorLocation(string message) => "/?auth=heimdall-error&detail=" + Uri.EscapeDataString(message); - - private static async Task AssertRefused(HeimdallHost host, StartedAttempt attempt, string accountId, HttpResponseMessage delivery) - { - Assert.Equal(HttpStatusCode.BadRequest, delivery.StatusCode); - var wait = await host.WaitAsync(attempt.AttemptId, attempt.Binding); - Assert.False(wait.SignedIn); - Assert.Equal(HttpStatusCode.OK, wait.Response.StatusCode); - Assert.Null(await host.Account(accountId)); - } - - [Fact] - public async Task Genuine_delivery_signs_in_the_account_the_token_names() - { - using var host = new HeimdallHost(); - var accountId = HeimdallShapes.NewAccountId(); - var attempt = await host.StartAsync(returnUrl: "/App/Mill"); - - using var delivery = await DeliverGenuine(host, attempt, accountId); - Assert.Equal(HttpStatusCode.Accepted, delivery.StatusCode); - - var wait = await host.WaitAsync(attempt.AttemptId, attempt.Binding); - Assert.True(wait.SignedIn); - Assert.Equal(HttpStatusCode.Redirect, wait.Response.StatusCode); - Assert.Equal("/App/Mill", wait.Response.Headers.Location!.OriginalString); - - var account = await host.Account(accountId); - Assert.NotNull(account); - Assert.Equal("Genuine Name", account!.DisplayName); - Assert.Equal(MembershipStatus.Authenticated, account.Membership!.Status); - Assert.Empty(account.Membership.RoleAssignments); - - var cleared = HeimdallHost.SetCookieHeader(wait.Response, HeimdallHost.BindingCookie); - Assert.NotNull(cleared); - Assert.Contains("1970", cleared); - } - - [Fact] - public async Task The_display_name_is_the_claim_and_falls_back_to_the_account_id() - { - using var host = new HeimdallHost(); - var named = HeimdallShapes.NewAccountId(); - var unnamed = HeimdallShapes.NewAccountId(); - - var first = await host.StartAsync(); - await DeliverGenuine(host, first, named, displayName: "Claimed Name"); - Assert.True((await host.WaitAsync(first.AttemptId, first.Binding)).SignedIn); - - var second = await host.StartAsync(); - await DeliverGenuine(host, second, unnamed, displayName: null); - Assert.True((await host.WaitAsync(second.AttemptId, second.Binding)).SignedIn); - - Assert.Equal("Claimed Name", (await host.Account(named))!.DisplayName); - Assert.Equal(unnamed, (await host.Account(unnamed))!.DisplayName); - } - - [Fact] - public async Task A_callback_without_an_access_token_signs_nobody_in() - { - using var host = new HeimdallHost(); - var accountId = HeimdallShapes.NewAccountId(); - var attempt = await host.StartAsync(); - var payload = HeimdallShapes.Delivery(attempt.AttemptId, accountId, "unused"); - payload.Remove("accessToken"); - - using var delivery = await host.DeliverAsync(payload); - - await AssertRefused(host, attempt, accountId, delivery); - AssertLogged(host, "Heimdall callback carried no access token."); - } - - [Fact] - public async Task A_callback_without_an_attempt_id_signs_nobody_in() - { - using var host = new HeimdallHost(); - var accountId = HeimdallShapes.NewAccountId(); - - using var delivery = await host.DeliverAsync(HeimdallShapes.Delivery(" ", accountId, host.Token(accountId))); - - Assert.Equal(HttpStatusCode.BadRequest, delivery.StatusCode); - AssertLogged(host, "Heimdall callback omitted attempt id."); - } - - [Fact] - public async Task An_empty_access_token_signs_nobody_in() - { - using var host = new HeimdallHost(); - var accountId = HeimdallShapes.NewAccountId(); - var attempt = await host.StartAsync(); - - using var delivery = await host.DeliverAsync(HeimdallShapes.Delivery(attempt.AttemptId, accountId, " ")); - - await AssertRefused(host, attempt, accountId, delivery); - AssertLogged(host, "Heimdall callback carried no access token."); - } - - [Theory] - [InlineData("other-key-same-kid", "token signature is invalid")] - [InlineData("other-key-other-kid", "token key is not published")] - [InlineData("tampered-payload", "token signature is invalid")] - [InlineData("alg-none", "token algorithm is not EdDSA")] - [InlineData("alg-hs256", "token algorithm is not EdDSA")] - [InlineData("header-array", "token algorithm is not EdDSA")] - [InlineData("header-not-json", "token header is not JSON")] - [InlineData("truncated-signature", "token signature is invalid")] - [InlineData("no-kid", "token has no key id")] - [InlineData("unpublished-kid", "token key is not published")] - [InlineData("four-segments", "token is not a compact JWS")] - [InlineData("two-segments", "token is not a compact JWS")] - [InlineData("not-base64url", "token is not base64url")] - [InlineData("payload-not-json", "token payload is not JSON")] - [InlineData("payload-not-object", "token payload is not an object")] - public async Task A_forged_token_signs_nobody_in(string forgery, string rule) - { - using var host = new HeimdallHost(); - var accountId = HeimdallShapes.NewAccountId(); - var attempt = await host.StartAsync(); - var claims = HeimdallShapes.AccessClaims(accountId, host.Clock.Now, ["member_access"]); - var claimBytes = JsonSerializer.SerializeToUtf8Bytes(claims); - var genuine = host.Key.Sign(claims); - var parts = genuine.Split('.'); - - var token = forgery switch - { - "other-key-same-kid" => HeimdallKey.Generate(host.Key.Kid).Sign(claims), - "other-key-other-kid" => HeimdallKey.Generate().Sign(claims), - "tampered-payload" => parts[0] + "." + WebEncoders.Base64UrlEncode(JsonSerializer.SerializeToUtf8Bytes( - HeimdallShapes.AccessClaims(accountId, host.Clock.Now, ["member_access", "x"]))) + "." + parts[2], - "alg-none" => WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes("{\"alg\":\"none\",\"typ\":\"JWT\"}")) + "." + parts[1] + ".", - "alg-hs256" => host.Key.Sign(claims, alg: "HS256"), - "header-array" => host.Key.SignParts(Encoding.UTF8.GetBytes("[1]"), claimBytes), - "header-not-json" => host.Key.SignParts(Encoding.UTF8.GetBytes("not json"), claimBytes), - "truncated-signature" => parts[0] + "." + parts[1] + "." + - WebEncoders.Base64UrlEncode(WebEncoders.Base64UrlDecode(parts[2])[..63]), - "no-kid" => host.Key.Sign(claims, kid: null), - "unpublished-kid" => host.Key.Sign(claims, kid: "ed25519-" + new string('0', 12)), - "four-segments" => genuine + ".AAAA", - "two-segments" => parts[0] + "." + parts[1], - "not-base64url" => parts[0] + "." + parts[1] + ".!!" + parts[2][2..], - "payload-not-json" => host.Key.SignParts(host.Key.HeaderBytes(), Encoding.UTF8.GetBytes("not json")), - "payload-not-object" => host.Key.SignParts(host.Key.HeaderBytes(), Encoding.UTF8.GetBytes("[1]")), - _ => throw new InvalidOperationException(forgery) - }; - Assert.NotEqual(genuine, token); - - using var delivery = await host.DeliverAsync(HeimdallShapes.Delivery(attempt.AttemptId, accountId, token)); - - await AssertRefused(host, attempt, accountId, delivery); - AssertTokenRefused(host, rule); - } - - [Fact] - public async Task A_forged_token_cannot_sign_in_as_an_existing_account() - { - using var host = new HeimdallHost(); - var victim = HeimdallShapes.NewAccountId(); - var real = await host.StartAsync(); - await DeliverGenuine(host, real, victim, displayName: "Victim Name"); - Assert.True((await host.WaitAsync(real.AttemptId, real.Binding)).SignedIn); - - var attempt = await host.StartAsync(); - var forged = HeimdallKey.Generate(host.Key.Kid).Sign(HeimdallShapes.AccessClaims(victim, host.Clock.Now, displayName: "Mallory")); - using var delivery = await host.DeliverAsync(HeimdallShapes.Delivery(attempt.AttemptId, victim, forged)); - - Assert.Equal(HttpStatusCode.BadRequest, delivery.StatusCode); - Assert.False((await host.WaitAsync(attempt.AttemptId, attempt.Binding)).SignedIn); - Assert.Equal("Victim Name", (await host.Account(victim))!.DisplayName); - } - - [Theory] - [InlineData("aud-other-app", "token audience is not this app")] - [InlineData("aud-case", "token audience is not this app")] - [InlineData("aud-array", "token audience is not this app")] - [InlineData("aud-missing", "token audience is not this app")] - [InlineData("app-slug-other", "token app is not this app")] - [InlineData("app-missing", "token app is not this app")] - [InlineData("app-not-object", "token app is not this app")] - [InlineData("typ-refresh", "token type is not an access token")] - [InlineData("typ-missing", "token type is not an access token")] - [InlineData("iss-other", "token issuer is not Heimdall")] - [InlineData("iss-trailing-slash", "token issuer is not Heimdall")] - [InlineData("expired", "token has expired")] - [InlineData("exp-missing", "token has expired")] - [InlineData("exp-string", "token has expired")] - [InlineData("exp-out-of-range", "token has expired")] - [InlineData("exp-negative", "token has expired")] - [InlineData("not-yet-valid", "token is not yet valid")] - [InlineData("nbf-missing", "token is not yet valid")] - [InlineData("nbf-negative", "token is not yet valid")] - [InlineData("sub-differs", "token subject does not match the account")] - [InlineData("account-id-differs", "token subject does not match the account")] - [InlineData("account-id-missing", "token subject does not match the account")] - [InlineData("sub-empty", "token subject does not match the account")] - public async Task A_signed_token_with_a_wrong_claim_signs_nobody_in(string wrong, string rule) - { - using var host = new HeimdallHost(); - var accountId = HeimdallShapes.NewAccountId(); - var attempt = await host.StartAsync(); - var now = host.Clock.Now.ToUnixTimeSeconds(); - var token = host.Token(accountId, ["member_access"], mutate: claims => - { - switch (wrong) - { - case "aud-other-app": claims["aud"] = "ghostlight"; break; - case "aud-case": claims["aud"] = "Bifrost"; break; - case "aud-array": claims["aud"] = new[] { "bifrost" }; break; - case "aud-missing": claims.Remove("aud"); break; - case "app-slug-other": claims["app"] = new Dictionary { ["slug"] = "ghostlight" }; break; - case "app-missing": claims.Remove("app"); break; - case "app-not-object": claims["app"] = "bifrost"; break; - case "typ-refresh": claims["typ"] = "heimdall_refresh"; break; - case "typ-missing": claims.Remove("typ"); break; - case "iss-other": claims["iss"] = "https://heimdall.example"; break; - case "iss-trailing-slash": claims["iss"] = HeimdallShapes.Issuer + "/"; break; - case "expired": claims["exp"] = now - 3600; break; - case "exp-missing": claims.Remove("exp"); break; - case "exp-string": claims["exp"] = (now + 900).ToString(); break; - case "exp-out-of-range": claims["exp"] = long.MaxValue; break; - case "exp-negative": claims["exp"] = -1; break; - case "not-yet-valid": claims["nbf"] = now + 3600; claims["exp"] = now + 7200; break; - case "nbf-missing": claims.Remove("nbf"); break; - case "nbf-negative": claims["nbf"] = -1; break; - case "sub-differs": claims["sub"] = Flip(accountId); break; - case "account-id-differs": claims["account_id"] = Flip(accountId); break; - case "account-id-missing": claims.Remove("account_id"); break; - case "sub-empty": claims["sub"] = string.Empty; claims["account_id"] = string.Empty; break; - default: throw new InvalidOperationException(wrong); - } - }); - - using var delivery = await host.DeliverAsync(HeimdallShapes.Delivery(attempt.AttemptId, accountId, token)); - - await AssertRefused(host, attempt, accountId, delivery); - AssertTokenRefused(host, rule); - } - - [Fact] - public async Task A_token_for_a_different_account_than_the_payload_names_is_refused_by_one_character() - { - using var host = new HeimdallHost(); - var tokenAccount = HeimdallShapes.NewAccountId(); - var payloadAccount = Flip(tokenAccount); - var attempt = await host.StartAsync(); - - using var delivery = await host.DeliverAsync(HeimdallShapes.Delivery( - attempt.AttemptId, payloadAccount, host.Token(tokenAccount))); - - Assert.Equal(HttpStatusCode.BadRequest, delivery.StatusCode); - Assert.False((await host.WaitAsync(attempt.AttemptId, attempt.Binding)).SignedIn); - Assert.Null(await host.Account(tokenAccount)); - Assert.Null(await host.Account(payloadAccount)); - AssertTokenRefused(host, "token subject does not match the account"); - } - - [Fact] - public async Task A_payload_with_no_account_is_refused() - { - using var host = new HeimdallHost(); - var accountId = HeimdallShapes.NewAccountId(); - var attempt = await host.StartAsync(); - var payload = HeimdallShapes.Delivery(attempt.AttemptId, accountId, host.Token(accountId)); - payload["account"] = null; - - using var delivery = await host.DeliverAsync(payload); - - await AssertRefused(host, attempt, accountId, delivery); - AssertTokenRefused(host, "token subject does not match the account"); - } - - [Theory] - [InlineData(60, 900, true)] - [InlineData(61, 900, false)] - [InlineData(-1000, -59, true)] - [InlineData(-1000, -60, false)] - public async Task Token_validity_allows_a_minute_of_clock_skew(int notBeforeOffset, int expiresOffset, bool accepted) - { - using var host = new HeimdallHost(); - var accountId = HeimdallShapes.NewAccountId(); - var attempt = await host.StartAsync(); - var now = host.Clock.Now.ToUnixTimeSeconds(); - var token = host.Token(accountId, mutate: claims => - { - claims["nbf"] = now + notBeforeOffset; - claims["exp"] = now + expiresOffset; - }); - - using var delivery = await host.DeliverAsync(HeimdallShapes.Delivery(attempt.AttemptId, accountId, token)); - - Assert.Equal(accepted ? HttpStatusCode.Accepted : HttpStatusCode.BadRequest, delivery.StatusCode); - } - - [Fact] - public async Task A_well_signed_token_for_an_attempt_start_never_issued_creates_nothing() - { - using var host = new HeimdallHost(); - var accountId = HeimdallShapes.NewAccountId(); - var unissued = Guid.NewGuid().ToString("N"); - - using var delivery = await host.DeliverAsync(HeimdallShapes.Delivery(unissued, accountId, host.Token(accountId))); - - Assert.Equal(HttpStatusCode.BadRequest, delivery.StatusCode); - var wait = await host.WaitAsync(unissued, "anything"); - Assert.False(wait.SignedIn); - Assert.Equal(ErrorLocation("Heimdall auth attempt is not valid for this browser."), wait.Response.Headers.Location!.OriginalString); - Assert.Null(await host.Account(accountId)); - AssertLogged(host, "Heimdall callback did not match a pending attempt."); - } - - [Fact] - public async Task A_second_delivery_does_not_replace_the_first() - { - using var host = new HeimdallHost(); - var first = HeimdallShapes.NewAccountId(); - var second = HeimdallShapes.NewAccountId(); - var attempt = await host.StartAsync(); - - using var accepted = await DeliverGenuine(host, attempt, first); - using var replaced = await DeliverGenuine(host, attempt, second); - - Assert.Equal(HttpStatusCode.Accepted, accepted.StatusCode); - Assert.Equal(HttpStatusCode.BadRequest, replaced.StatusCode); - AssertLogged(host, "Heimdall callback did not match a pending attempt."); - Assert.True((await host.WaitAsync(attempt.AttemptId, attempt.Binding)).SignedIn); - Assert.NotNull(await host.Account(first)); - Assert.Null(await host.Account(second)); - } - - [Fact] - public async Task An_attempt_is_deliverable_until_ten_minutes_after_start() - { - using var host = new HeimdallHost(); - var accountId = HeimdallShapes.NewAccountId(); - var attempt = await host.StartAsync(); - host.Clock.Advance(HeimdallAuthAttemptStore.Lifetime - TimeSpan.FromSeconds(1)); - - using var delivery = await DeliverGenuine(host, attempt, accountId); - - Assert.Equal(HttpStatusCode.Accepted, delivery.StatusCode); - Assert.True((await host.WaitAsync(attempt.AttemptId, attempt.Binding)).SignedIn); - } - - [Fact] - public async Task An_expired_attempt_is_refused_at_callback() - { - using var host = new HeimdallHost(); - var accountId = HeimdallShapes.NewAccountId(); - var attempt = await host.StartAsync(); - host.Clock.Advance(HeimdallAuthAttemptStore.Lifetime); - - using var delivery = await DeliverGenuine(host, attempt, accountId); - - Assert.Equal(HttpStatusCode.BadRequest, delivery.StatusCode); - AssertLogged(host, "Heimdall callback did not match a pending attempt."); - var wait = await host.WaitAsync(attempt.AttemptId, attempt.Binding); - Assert.False(wait.SignedIn); - Assert.Equal(ErrorLocation("Heimdall auth attempt is not valid for this browser."), wait.Response.Headers.Location!.OriginalString); - } - - [Fact] - public async Task A_delivered_attempt_expires_at_wait() - { - using var host = new HeimdallHost(); - var accountId = HeimdallShapes.NewAccountId(); - var attempt = await host.StartAsync(); - await DeliverGenuine(host, attempt, accountId); - host.Clock.Advance(HeimdallAuthAttemptStore.Lifetime); - - var wait = await host.WaitAsync(attempt.AttemptId, attempt.Binding); - - Assert.False(wait.SignedIn); - Assert.Equal(ErrorLocation("Heimdall auth attempt is not valid for this browser."), wait.Response.Headers.Location!.OriginalString); - Assert.Null(await host.Account(accountId)); - } - - [Fact] - public void A_full_store_drops_the_attempt_that_expires_first() - { - var clock = new TestClock(); - var store = new HeimdallAuthAttemptStore(clock); - var identity = new HeimdallVerifiedIdentity("account", "Name", false); - for (var i = 0; i < HeimdallAuthAttemptStore.Capacity; i++) - { - store.Create($"attempt-{i}", "discord", "/App", false); - clock.Advance(TimeSpan.FromMilliseconds(1)); - } - - // Exactly at capacity nothing has been dropped yet. - store.Complete("attempt-1", identity); - store.Create("attempt-extra", "discord", "/App", false); - - Assert.Throws(() => store.Complete("attempt-0", identity)); - store.Complete("attempt-2", identity); - store.Complete("attempt-extra", identity); - store.Complete($"attempt-{HeimdallAuthAttemptStore.Capacity - 1}", identity); - } - - [Fact] - public async Task Start_sets_a_private_per_attempt_binding_cookie() - { - using var host = new HeimdallHost(); - var first = await host.StartAsync(); - var second = await host.StartAsync(); - - Assert.Equal(HttpStatusCode.Redirect, first.Response.StatusCode); - var header = HeimdallHost.SetCookieHeader(first.Response, HeimdallHost.BindingCookie); - Assert.NotNull(header); - var attributes = header!.Split(';').Select(x => x.Trim().ToLowerInvariant()).ToArray(); - Assert.Contains("httponly", attributes); - Assert.Contains("secure", attributes); - Assert.Contains("samesite=lax", attributes); - Assert.Contains("path=/auth/heimdall", attributes); - Assert.Contains("max-age=600", attributes); - - Assert.Equal(43, first.Binding!.Length); - Assert.NotEqual(first.Binding, second.Binding); - Assert.DoesNotContain(first.AttemptId, header); - Assert.NotEqual(first.AttemptId, first.Binding); - } - - [Fact] - public async Task Wait_signs_in_only_the_browser_that_holds_the_binding_and_only_once() - { - using var host = new HeimdallHost(); - var accountId = HeimdallShapes.NewAccountId(); - var attempt = await host.StartAsync(); - var other = await host.StartAsync(); - await DeliverGenuine(host, attempt, accountId); - - var wrong = new Dictionary - { - ["no cookie"] = null, - ["empty cookie"] = string.Empty, - ["another attempt's cookie"] = other.Binding, - ["same-shape random cookie"] = WebEncoders.Base64UrlEncode(new byte[32]), - ["the attempt id as cookie"] = attempt.AttemptId, - ["a prefix of the cookie"] = attempt.Binding![..^1], - ["the cookie with a suffix"] = attempt.Binding + "A" - }; - foreach (var (name, binding) in wrong) - { - var refused = await host.WaitAsync(attempt.AttemptId, binding); - Assert.False(refused.SignedIn, name); - Assert.Equal(ErrorLocation("Heimdall auth attempt is not valid for this browser."), refused.Response.Headers.Location!.OriginalString); - } - - Assert.Null(await host.Account(accountId)); - - var granted = await host.WaitAsync(attempt.AttemptId, attempt.Binding); - Assert.True(granted.SignedIn); - - var replay = await host.WaitAsync(attempt.AttemptId, attempt.Binding); - Assert.False(replay.SignedIn); - } - - [Fact] - public async Task Wait_before_delivery_is_pending_and_does_not_consume_the_attempt() - { - using var host = new HeimdallHost(); - var accountId = HeimdallShapes.NewAccountId(); - var attempt = await host.StartAsync(); - - var early = await host.WaitAsync(attempt.AttemptId, attempt.Binding); - Assert.False(early.SignedIn); - Assert.Equal(HttpStatusCode.OK, early.Response.StatusCode); - Assert.Equal("2", Assert.Single(early.Response.Headers.GetValues("Refresh"))); - - await DeliverGenuine(host, attempt, accountId); - - Assert.True((await host.WaitAsync(attempt.AttemptId, attempt.Binding)).SignedIn); - } - - [Fact] - public async Task Wait_without_an_attempt_id_signs_nobody_in() - { - using var host = new HeimdallHost(); - using var client = host.NewClient(); - - using var response = await client.GetAsync("/auth/heimdall/wait"); - - Assert.Equal(HttpStatusCode.Redirect, response.StatusCode); - Assert.Null(HeimdallHost.CookieValue(response, ".AspNetCore.Cookies")); - } - - [Theory] - [InlineData("member_access", true)] - [InlineData("app_access", true)] - [InlineData("MEMBER_ACCESS", true)] - [InlineData("billing_access", false)] - public async Task Member_access_comes_from_the_token_capabilities_alone(string capability, bool grants) - { - using var host = new HeimdallHost(); - var accountId = HeimdallShapes.NewAccountId(); - var attempt = await host.StartAsync(member: true); - var payload = HeimdallShapes.Delivery( - attempt.AttemptId, - accountId, - host.Token(accountId, [capability]), - looseCapabilities: grants ? [] : ["member_access", "app_access"], - looseFacts: grants ? [] : ["entitlement.app_access"]); - - using var delivery = await host.DeliverAsync(payload); - Assert.Equal(HttpStatusCode.Accepted, delivery.StatusCode); - var wait = await host.WaitAsync(attempt.AttemptId, attempt.Binding); - - var account = await host.Account(accountId); - if (grants) - { - Assert.True(wait.SignedIn); - Assert.Equal(MembershipStatus.Active, account!.Membership!.Status); - Assert.Contains(account.Membership.RoleAssignments, x => x.Role == MemberRole.StandardMember); - } - else - { - Assert.False(wait.SignedIn); - Assert.Equal(ErrorLocation("Heimdall did not grant Bifrost member access."), wait.Response.Headers.Location!.OriginalString); - Assert.Null(account); - } - } - - [Fact] - public async Task A_token_capability_grants_no_role_to_a_sign_in_that_did_not_ask_for_member_access() - { - using var host = new HeimdallHost(); - var accountId = HeimdallShapes.NewAccountId(); - var attempt = await host.StartAsync(member: false); - - await DeliverGenuine(host, attempt, accountId, ["member_access"]); - Assert.True((await host.WaitAsync(attempt.AttemptId, attempt.Binding)).SignedIn); - - var account = await host.Account(accountId); - Assert.Equal(MembershipStatus.Authenticated, account!.Membership!.Status); - Assert.Empty(account.Membership.RoleAssignments); - } - - [Fact] - public async Task Loose_capabilities_cannot_turn_a_plain_token_into_member_access() - { - using var host = new HeimdallHost(); - var accountId = HeimdallShapes.NewAccountId(); - var attempt = await host.StartAsync(member: false); - - using var delivery = await host.DeliverAsync(HeimdallShapes.Delivery( - attempt.AttemptId, - accountId, - host.Token(accountId), - looseCapabilities: ["member_access"], - looseFacts: ["entitlement.app_access"])); - Assert.Equal(HttpStatusCode.Accepted, delivery.StatusCode); - Assert.True((await host.WaitAsync(attempt.AttemptId, attempt.Binding)).SignedIn); - - var account = await host.Account(accountId); - Assert.Equal(MembershipStatus.Authenticated, account!.Membership!.Status); - Assert.Empty(account.Membership.RoleAssignments); - } - - [Fact] - public async Task The_loose_return_to_and_name_decide_nothing() - { - using var host = new HeimdallHost(); - var accountId = HeimdallShapes.NewAccountId(); - var attempt = await host.StartAsync(returnUrl: "/App/Mill"); - - await host.DeliverAsync(HeimdallShapes.Delivery( - attempt.AttemptId, accountId, host.Token(accountId), looseName: "Loose Name", returnTo: "https://evil.example/landing")); - var wait = await host.WaitAsync(attempt.AttemptId, attempt.Binding); - - Assert.Equal("/App/Mill", wait.Response.Headers.Location!.OriginalString); - Assert.Equal("Genuine Name", (await host.Account(accountId))!.DisplayName); - } - - [Fact] - public async Task Keys_are_fetched_once_and_cached() - { - using var host = new HeimdallHost(); - for (var i = 0; i < 3; i++) - { - var accountId = HeimdallShapes.NewAccountId(); - var attempt = await host.StartAsync(); - using var delivery = await DeliverGenuine(host, attempt, accountId); - Assert.Equal(HttpStatusCode.Accepted, delivery.StatusCode); - } - - Assert.Equal(1, host.Heimdall.JwksFetches); - } - - [Fact] - public async Task An_unknown_key_id_refetches_at_most_every_thirty_seconds() - { - using var host = new HeimdallHost(); - var unknown = HeimdallKey.Generate(); - async Task DeliverUnknown() - { - var accountId = HeimdallShapes.NewAccountId(); - var attempt = await host.StartAsync(); - using var delivery = await host.DeliverAsync(HeimdallShapes.Delivery( - attempt.AttemptId, accountId, host.Token(accountId, signer: unknown))); - return delivery.StatusCode; - } - - Assert.Equal(HttpStatusCode.BadRequest, await DeliverUnknown()); - Assert.Equal(1, host.Heimdall.JwksFetches); - - host.Clock.Advance(TimeSpan.FromSeconds(29)); - Assert.Equal(HttpStatusCode.BadRequest, await DeliverUnknown()); - Assert.Equal(1, host.Heimdall.JwksFetches); - - host.Clock.Advance(TimeSpan.FromSeconds(1)); - Assert.Equal(HttpStatusCode.BadRequest, await DeliverUnknown()); - Assert.Equal(2, host.Heimdall.JwksFetches); - Assert.Equal(HttpStatusCode.BadRequest, await DeliverUnknown()); - Assert.Equal(2, host.Heimdall.JwksFetches); - } - - [Fact] - public async Task A_rotated_in_key_is_accepted_once_the_refetch_interval_has_passed() - { - using var host = new HeimdallHost(); - var rotated = HeimdallKey.Generate(); - var accountId = HeimdallShapes.NewAccountId(); - - var early = await host.StartAsync(); - using var refused = await host.DeliverAsync(HeimdallShapes.Delivery( - early.AttemptId, accountId, host.Token(accountId, signer: rotated))); - Assert.Equal(HttpStatusCode.BadRequest, refused.StatusCode); - - host.Heimdall.Published.Add(rotated); - host.Clock.Advance(TimeSpan.FromSeconds(30)); - var late = await host.StartAsync(); - using var accepted = await host.DeliverAsync(HeimdallShapes.Delivery( - late.AttemptId, accountId, host.Token(accountId, signer: rotated))); - - Assert.Equal(HttpStatusCode.Accepted, accepted.StatusCode); - } - - [Fact] - public async Task Cached_keys_are_refetched_after_an_hour_and_a_withdrawn_key_stops_working() - { - using var host = new HeimdallHost(); - var warm = await host.StartAsync(); - var warmAccount = HeimdallShapes.NewAccountId(); - using (await DeliverGenuine(host, warm, warmAccount)) - { - } - - host.Heimdall.Published.Clear(); - host.Clock.Advance(TimeSpan.FromHours(1) - TimeSpan.FromSeconds(1)); - var inside = await host.StartAsync(); - using (var stillCached = await DeliverGenuine(host, inside, HeimdallShapes.NewAccountId())) - { - Assert.Equal(HttpStatusCode.Accepted, stillCached.StatusCode); - } - - Assert.Equal(1, host.Heimdall.JwksFetches); - - host.Clock.Advance(TimeSpan.FromSeconds(1)); - var outside = await host.StartAsync(); - using var withdrawn = await DeliverGenuine(host, outside, HeimdallShapes.NewAccountId()); - - Assert.Equal(HttpStatusCode.BadRequest, withdrawn.StatusCode); - Assert.Equal(2, host.Heimdall.JwksFetches); - } - - [Fact] - public async Task A_failing_key_fetch_refuses_without_hammering_and_recovers() - { - using var host = new HeimdallHost(); - host.Heimdall.JwksStatus = HttpStatusCode.InternalServerError; - var accountId = HeimdallShapes.NewAccountId(); - - var first = await host.StartAsync(); - using (var down = await DeliverGenuine(host, first, accountId)) - { - Assert.Equal(HttpStatusCode.BadRequest, down.StatusCode); - AssertTokenRefused(host, "Heimdall keys could not be fetched"); - } - - var second = await host.StartAsync(); - using (var stillDown = await DeliverGenuine(host, second, accountId)) - { - Assert.Equal(HttpStatusCode.BadRequest, stillDown.StatusCode); - } - - Assert.Equal(1, host.Heimdall.JwksFetches); - - host.Heimdall.JwksStatus = HttpStatusCode.OK; - host.Clock.Advance(TimeSpan.FromSeconds(30)); - var third = await host.StartAsync(); - using var recovered = await DeliverGenuine(host, third, accountId); - - Assert.Equal(HttpStatusCode.Accepted, recovered.StatusCode); - } - - [Theory] - [InlineData("rsa-kty")] - [InlineData("x25519-crv")] - [InlineData("short-x")] - public async Task A_published_key_that_is_not_a_full_ed25519_key_verifies_nothing(string junk) - { - using var host = new HeimdallHost(); - var kid = "ed25519-" + new string('a', 12); - var x = WebEncoders.Base64UrlEncode(host.Key.PublicKey); - host.Heimdall.ExtraJwks.Add(42); - host.Heimdall.ExtraJwks.Add(junk switch - { - "rsa-kty" => new Dictionary { ["kty"] = "RSA", ["crv"] = "Ed25519", ["kid"] = kid, ["x"] = x }, - "x25519-crv" => new Dictionary { ["kty"] = "OKP", ["crv"] = "X25519", ["kid"] = kid, ["x"] = x }, - _ => new Dictionary { ["kty"] = "OKP", ["crv"] = "Ed25519", ["kid"] = kid, ["x"] = x[..20] } - }); - var accountId = HeimdallShapes.NewAccountId(); - var attempt = await host.StartAsync(); - var token = host.Key.Sign(HeimdallShapes.AccessClaims(accountId, host.Clock.Now), kid: kid); - - using var delivery = await host.DeliverAsync(HeimdallShapes.Delivery(attempt.AttemptId, accountId, token)); - - Assert.Equal(HttpStatusCode.BadRequest, delivery.StatusCode); - var genuine = await host.StartAsync(); - using var accepted = await DeliverGenuine(host, genuine, accountId); - Assert.Equal(HttpStatusCode.Accepted, accepted.StatusCode); - } - - [Fact] - public async Task Capabilities_that_are_not_a_list_of_names_grant_nothing_and_do_not_crash() - { - using var host = new HeimdallHost(); - var accountId = HeimdallShapes.NewAccountId(); - var attempt = await host.StartAsync(); - var token = host.Token(accountId, mutate: claims => claims["capabilities"] = "member_access"); - - using var delivery = await host.DeliverAsync(HeimdallShapes.Delivery(attempt.AttemptId, accountId, token)); - - Assert.Equal(HttpStatusCode.Accepted, delivery.StatusCode); - Assert.True((await host.WaitAsync(attempt.AttemptId, attempt.Binding)).SignedIn); - Assert.Empty((await host.Account(accountId))!.Membership!.RoleAssignments); - } - - [Fact] - public async Task Member_access_is_found_among_other_and_non_string_capabilities() - { - using var host = new HeimdallHost(); - var accountId = HeimdallShapes.NewAccountId(); - var attempt = await host.StartAsync(member: true); - var token = host.Token(accountId, mutate: claims => - claims["capabilities"] = new object?[] { 7, null, "billing_access", "member_access" }); - - using var delivery = await host.DeliverAsync(HeimdallShapes.Delivery(attempt.AttemptId, accountId, token)); - - Assert.Equal(HttpStatusCode.Accepted, delivery.StatusCode); - Assert.True((await host.WaitAsync(attempt.AttemptId, attempt.Binding)).SignedIn); - Assert.Contains((await host.Account(accountId))!.Membership!.RoleAssignments, x => x.Role == MemberRole.StandardMember); - } - - [Theory] - [InlineData(0L, 900L)] - [InlineData(-1L, 253402300799L)] - public async Task Numeric_dates_at_the_ends_of_the_valid_range_are_accepted(long notBefore, long expires) - { - using var host = new HeimdallHost(); - var accountId = HeimdallShapes.NewAccountId(); - var attempt = await host.StartAsync(); - var now = host.Clock.Now.ToUnixTimeSeconds(); - var token = host.Token(accountId, mutate: claims => - { - claims["nbf"] = notBefore < 0 ? now : notBefore; - claims["exp"] = expires == 900 ? now + 900 : expires; - }); - - using var delivery = await host.DeliverAsync(HeimdallShapes.Delivery(attempt.AttemptId, accountId, token)); - - Assert.Equal(HttpStatusCode.Accepted, delivery.StatusCode); - } - - [Theory] - [InlineData(HttpStatusCode.InternalServerError, "{}", "Heimdall rejected the auth start.")] - [InlineData(HttpStatusCode.OK, "{}", "Heimdall auth start returned no authorization URL.")] - [InlineData(HttpStatusCode.OK, "{\"authorizationUrl\":\" \"}", "Heimdall auth start returned no authorization URL.")] - public async Task A_start_Heimdall_cannot_serve_issues_no_attempt_and_no_cookie(HttpStatusCode status, string body, string message) - { - using var host = new HeimdallHost(); - host.Heimdall.StartStatus = status; - host.Heimdall.StartBody = body; - - var attempt = await host.StartAsync(); - - Assert.Equal(ErrorLocation(message), attempt.Response.Headers.Location!.OriginalString); - Assert.Null(attempt.Binding); - var wait = await host.WaitAsync(attempt.AttemptId, "anything"); - Assert.False(wait.SignedIn); - } - - [Fact] - public async Task Refusals_echo_no_input_in_responses_or_logs() - { - using var host = new HeimdallHost(); - var suffix = Guid.NewGuid().ToString("N"); - var attemptCanary = "CANARYATTEMPT" + suffix; - var accountCanary = "CANARYACCOUNT" + suffix; - var kidCanary = "CANARYKID" + suffix; - var claimCanary = "CANARYCLAIM" + suffix; - var providerCanary = "CANARYPROVIDER" + suffix; - var bindingCanary = "CANARYBINDING" + suffix; - var tokens = new List(); - - var canaryClaims = HeimdallShapes.AccessClaims(accountCanary, host.Clock.Now, mutate: claims => - { - claims["iss"] = claimCanary; - claims["aud"] = claimCanary; - claims["typ"] = claimCanary; - claims["display_name"] = claimCanary; - }); - var issued = await host.StartAsync(); - var wrongSubject = host.Token(HeimdallShapes.NewAccountId()); - tokens.Add(wrongSubject); - tokens.Add(HeimdallKey.Generate(kidCanary).Sign(canaryClaims, kid: kidCanary)); - tokens.Add(host.Key.Sign(canaryClaims)); - tokens.Add($"{claimCanary}.{claimCanary}.{claimCanary}"); - - var responses = new List(); - responses.Add(await host.DeliverAsync(HeimdallShapes.Delivery(attemptCanary, accountCanary, tokens[1]))); - responses.Add(await host.DeliverAsync(HeimdallShapes.Delivery(attemptCanary, accountCanary, tokens[2]))); - responses.Add(await host.DeliverAsync(HeimdallShapes.Delivery(attemptCanary, accountCanary, tokens[3]))); - responses.Add(await host.DeliverAsync(HeimdallShapes.Delivery(issued.AttemptId, accountCanary, wrongSubject))); - responses.Add((await host.WaitAsync(attemptCanary, bindingCanary)).Response); - responses.Add((await host.WaitAsync(issued.AttemptId, bindingCanary)).Response); - using (var client = host.NewClient()) - { - responses.Add(await client.GetAsync($"/auth/heimdall/{providerCanary}")); - responses.Add(await client.PostAsync( - "/auth/heimdall/callback", - new StringContent("{\"attemptId\":\"" + attemptCanary + "\",", Encoding.UTF8, "application/json"))); - } - - var forbidden = new List { attemptCanary, accountCanary, kidCanary, claimCanary, providerCanary, bindingCanary }; - forbidden.AddRange(tokens); - forbidden.AddRange(tokens.SelectMany(x => x.Split('.')).Where(x => x.Length > 8)); - - foreach (var response in responses) - { - var observed = new StringBuilder() - .Append(await response.Content.ReadAsStringAsync()) - .Append(response.ToString()); - foreach (var cookie in response.Headers.Where(x => x.Key == "Set-Cookie").SelectMany(x => x.Value)) - { - observed.Append(cookie); - } - - foreach (var canary in forbidden) - { - Assert.DoesNotContain(canary, observed.ToString()); - } - - response.Dispose(); - } - - var logs = string.Join("\n", host.Logs.Lines); - Assert.Contains("Heimdall callback refused", logs); - foreach (var canary in forbidden) - { - Assert.DoesNotContain(canary, logs); - } - } -} diff --git a/tests/Bifrost.Web.Tests/HeimdallCompletionTests.cs b/tests/Bifrost.Web.Tests/HeimdallCompletionTests.cs new file mode 100644 index 0000000..853bd1f --- /dev/null +++ b/tests/Bifrost.Web.Tests/HeimdallCompletionTests.cs @@ -0,0 +1,1109 @@ +using System.Net; +using System.Text; +using System.Text.Json; +using Bifrost.Web.Domain; +using Bifrost.Web.Features.Heimdall; +using Bifrost.Web.Features.Membership; +using Bifrost.Web.Tests.Support; +using Microsoft.AspNetCore.Authentication.OAuth; +using Microsoft.AspNetCore.WebUtilities; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace Bifrost.Web.Tests; + +/// +/// Sign-in through Heimdall's browser completion, against a local Bifrost with a test JWKS and a fake +/// Heimdall shaped like src/app.ts (start, redeem) and src/claims.ts. Forgeries differ from the genuine +/// shape in the one property under test. +/// +public sealed class HeimdallCompletionTests +{ + private const string ErrorRedirect = "/?auth=heimdall-error"; + + private static string Flip(string value) => value[..^1] + (value[^1] == '0' ? '1' : '0'); + + /// Changes one character in the middle of a protected value, which its authentication tag must notice. + private static string Corrupt(string value) => value[..20] + (value[20] == 'A' ? 'B' : 'A') + value[21..]; + + private static async Task Genuine( + HeimdallHost host, StartedAttempt attempt, string accountId, string[]? capabilities = null, string? displayName = "Genuine Name") => + await host.CompleteAsync(attempt, host.CodeFor(attempt, accountId, host.Token(accountId, capabilities, displayName))); + + private static string CodeWith( + HeimdallHost host, StartedAttempt attempt, string accountId, Action> edit, string? token = null) + { + var completion = HeimdallShapes.Completion(attempt.ReturnTo, accountId, token ?? host.Token(accountId)); + edit(completion); + return host.Heimdall.RegisterCode(completion); + } + + private static void AssertLogged(HeimdallHost host, string reason) => + Assert.Contains("Heimdall completion refused: " + reason, string.Join("\n", host.Logs.Lines)); + + private static void AssertTokenRefused(HeimdallHost host, string rule) => + AssertLogged(host, $"Heimdall token refused: {rule}."); + + private static void AssertRefusedRedirect(CompletionResult result) + { + Assert.Equal(HttpStatusCode.Redirect, result.Response.StatusCode); + Assert.Equal(ErrorRedirect, result.Response.Headers.Location!.OriginalString); + Assert.False(result.SignedIn); + } + + private static async Task AssertRefused(HeimdallHost host, string accountId, CompletionResult result) + { + AssertRefusedRedirect(result); + Assert.Null(await host.Account(accountId)); + } + + [Fact] + public async Task Genuine_completion_signs_in_once_in_the_starting_browser() + { + using var host = new HeimdallHost(); + var accountId = HeimdallShapes.NewAccountId(); + var attempt = await host.StartAsync(returnUrl: "/App/Mill"); + var code = host.CodeFor(attempt, accountId); + + var done = await host.CompleteAsync(attempt, code); + + Assert.True(done.SignedIn); + Assert.Equal(HttpStatusCode.Redirect, done.Response.StatusCode); + Assert.Equal("/App/Mill", done.Response.Headers.Location!.OriginalString); + var account = await host.Account(accountId); + Assert.NotNull(account); + Assert.Equal("Genuine Name", account!.DisplayName); + Assert.Equal(MembershipStatus.Authenticated, account.Membership!.Status); + Assert.Empty(account.Membership.RoleAssignments); + var cleared = HeimdallHost.SetCookieHeader(done.Response, HeimdallAttemptCookie.Prefix + attempt.Nonce); + Assert.NotNull(cleared); + Assert.Contains("1970", cleared); + + var again = await host.CompleteAsync(attempt, code); + + AssertRefusedRedirect(again); + Assert.Equal(2, host.Heimdall.RedeemCalls); + AssertLogged(host, "Heimdall did not redeem the completion code."); + } + + [Fact] + public async Task The_display_name_is_the_claim_and_falls_back_to_the_account_id() + { + using var host = new HeimdallHost(); + var named = HeimdallShapes.NewAccountId(); + var unnamed = HeimdallShapes.NewAccountId(); + + Assert.True((await Genuine(host, await host.StartAsync(), named, displayName: "Claimed Name")).SignedIn); + Assert.True((await Genuine(host, await host.StartAsync(), unnamed, displayName: null)).SignedIn); + + Assert.Equal("Claimed Name", (await host.Account(named))!.DisplayName); + Assert.Equal(unnamed, (await host.Account(unnamed))!.DisplayName); + } + + [Theory] + [InlineData("absent")] + [InlineData("")] + [InlineData(" ")] + public async Task A_completion_without_an_access_token_signs_nobody_in(string token) + { + using var host = new HeimdallHost(); + var accountId = HeimdallShapes.NewAccountId(); + var attempt = await host.StartAsync(); + var code = CodeWith(host, attempt, accountId, completion => + { + if (token == "absent") + { + completion.Remove("accessToken"); + } + else + { + completion["accessToken"] = token; + } + }); + + var result = await host.CompleteAsync(attempt, code); + + await AssertRefused(host, accountId, result); + AssertLogged(host, "Heimdall completion carried no access token."); + } + + [Theory] + [InlineData(HttpStatusCode.Gone, "{}", "Heimdall did not redeem the completion code.")] + [InlineData(HttpStatusCode.InternalServerError, "{}", "Heimdall did not redeem the completion code.")] + [InlineData(HttpStatusCode.OK, "valid", "Heimdall did not redeem the completion code.")] + [InlineData(HttpStatusCode.Created, "not json", "Heimdall redeem could not be read.")] + [InlineData(HttpStatusCode.Created, "[1]", "Heimdall redeem could not be read.")] + [InlineData(HttpStatusCode.Created, "null", "Heimdall redeem returned no body.")] + public async Task A_redeem_that_is_not_a_201_with_a_readable_body_signs_nobody_in(HttpStatusCode status, string body, string reason) + { + using var host = new HeimdallHost(); + var accountId = HeimdallShapes.NewAccountId(); + var attempt = await host.StartAsync(); + host.Heimdall.RedeemStatus = status; + host.Heimdall.RedeemBody = body == "valid" + ? JsonSerializer.Serialize(HeimdallShapes.Completion(attempt.ReturnTo, accountId, host.Token(accountId))) + : body; + + var result = await host.CompleteAsync(attempt, "a-code"); + + await AssertRefused(host, accountId, result); + AssertLogged(host, reason); + } + + [Theory] + [InlineData("other-key-same-kid", "token signature is invalid")] + [InlineData("other-key-other-kid", "token key is not published")] + [InlineData("tampered-payload", "token signature is invalid")] + [InlineData("alg-none", "token algorithm is not EdDSA")] + [InlineData("alg-hs256", "token algorithm is not EdDSA")] + [InlineData("header-array", "token algorithm is not EdDSA")] + [InlineData("header-not-json", "token header is not JSON")] + [InlineData("truncated-signature", "token signature is invalid")] + [InlineData("no-kid", "token has no key id")] + [InlineData("unpublished-kid", "token key is not published")] + [InlineData("four-segments", "token is not a compact JWS")] + [InlineData("two-segments", "token is not a compact JWS")] + [InlineData("not-base64url", "token is not base64url")] + [InlineData("payload-not-json", "token payload is not JSON")] + [InlineData("payload-not-object", "token payload is not an object")] + public async Task A_forged_token_signs_nobody_in(string forgery, string rule) + { + using var host = new HeimdallHost(); + var accountId = HeimdallShapes.NewAccountId(); + var attempt = await host.StartAsync(); + var claims = HeimdallShapes.AccessClaims(accountId, host.Clock.Now, ["member_access"]); + var claimBytes = JsonSerializer.SerializeToUtf8Bytes(claims); + var genuine = host.Key.Sign(claims); + var parts = genuine.Split('.'); + + var token = forgery switch + { + "other-key-same-kid" => HeimdallKey.Generate(host.Key.Kid).Sign(claims), + "other-key-other-kid" => HeimdallKey.Generate().Sign(claims), + "tampered-payload" => parts[0] + "." + WebEncoders.Base64UrlEncode(JsonSerializer.SerializeToUtf8Bytes( + HeimdallShapes.AccessClaims(accountId, host.Clock.Now, ["member_access", "x"]))) + "." + parts[2], + "alg-none" => WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes("{\"alg\":\"none\",\"typ\":\"JWT\"}")) + "." + parts[1] + ".", + "alg-hs256" => host.Key.Sign(claims, alg: "HS256"), + "header-array" => host.Key.SignParts(Encoding.UTF8.GetBytes("[1]"), claimBytes), + "header-not-json" => host.Key.SignParts(Encoding.UTF8.GetBytes("not json"), claimBytes), + "truncated-signature" => parts[0] + "." + parts[1] + "." + + WebEncoders.Base64UrlEncode(WebEncoders.Base64UrlDecode(parts[2])[..63]), + "no-kid" => host.Key.Sign(claims, kid: null), + "unpublished-kid" => host.Key.Sign(claims, kid: "ed25519-" + new string('0', 12)), + "four-segments" => genuine + ".AAAA", + "two-segments" => parts[0] + "." + parts[1], + "not-base64url" => parts[0] + "." + parts[1] + ".!!" + parts[2][2..], + "payload-not-json" => host.Key.SignParts(host.Key.HeaderBytes(), Encoding.UTF8.GetBytes("not json")), + "payload-not-object" => host.Key.SignParts(host.Key.HeaderBytes(), Encoding.UTF8.GetBytes("[1]")), + _ => throw new InvalidOperationException(forgery) + }; + Assert.NotEqual(genuine, token); + + var result = await host.CompleteAsync(attempt, host.CodeFor(attempt, accountId, token)); + + await AssertRefused(host, accountId, result); + AssertTokenRefused(host, rule); + } + + [Fact] + public async Task A_forged_token_cannot_sign_in_as_an_existing_account() + { + using var host = new HeimdallHost(); + var victim = HeimdallShapes.NewAccountId(); + Assert.True((await Genuine(host, await host.StartAsync(), victim, displayName: "Victim Name")).SignedIn); + + var attempt = await host.StartAsync(); + var forged = HeimdallKey.Generate(host.Key.Kid).Sign(HeimdallShapes.AccessClaims(victim, host.Clock.Now, displayName: "Mallory")); + var result = await host.CompleteAsync(attempt, host.CodeFor(attempt, victim, forged)); + + AssertRefusedRedirect(result); + Assert.Equal("Victim Name", (await host.Account(victim))!.DisplayName); + } + + [Theory] + [InlineData("aud-other-app", "token audience is not this app")] + [InlineData("aud-case", "token audience is not this app")] + [InlineData("aud-array", "token audience is not this app")] + [InlineData("aud-missing", "token audience is not this app")] + [InlineData("app-slug-other", "token app is not this app")] + [InlineData("app-missing", "token app is not this app")] + [InlineData("app-not-object", "token app is not this app")] + [InlineData("typ-refresh", "token type is not an access token")] + [InlineData("typ-missing", "token type is not an access token")] + [InlineData("iss-other", "token issuer is not Heimdall")] + [InlineData("iss-trailing-slash", "token issuer is not Heimdall")] + [InlineData("expired", "token has expired")] + [InlineData("exp-missing", "token has expired")] + [InlineData("exp-string", "token has expired")] + [InlineData("exp-out-of-range", "token has expired")] + [InlineData("exp-negative", "token has expired")] + [InlineData("not-yet-valid", "token is not yet valid")] + [InlineData("nbf-missing", "token is not yet valid")] + [InlineData("nbf-negative", "token is not yet valid")] + [InlineData("sub-differs", "token subject does not match the account")] + [InlineData("account-id-differs", "token subject does not match the account")] + [InlineData("account-id-missing", "token subject does not match the account")] + [InlineData("sub-empty", "token subject does not match the account")] + public async Task A_signed_token_with_a_wrong_claim_signs_nobody_in(string wrong, string rule) + { + using var host = new HeimdallHost(); + var accountId = HeimdallShapes.NewAccountId(); + var attempt = await host.StartAsync(); + var now = host.Clock.Now.ToUnixTimeSeconds(); + var token = host.Token(accountId, ["member_access"], mutate: claims => + { + switch (wrong) + { + case "aud-other-app": claims["aud"] = "ghostlight"; break; + case "aud-case": claims["aud"] = "Bifrost"; break; + case "aud-array": claims["aud"] = new[] { "bifrost" }; break; + case "aud-missing": claims.Remove("aud"); break; + case "app-slug-other": claims["app"] = new Dictionary { ["slug"] = "ghostlight" }; break; + case "app-missing": claims.Remove("app"); break; + case "app-not-object": claims["app"] = "bifrost"; break; + case "typ-refresh": claims["typ"] = "heimdall_refresh"; break; + case "typ-missing": claims.Remove("typ"); break; + case "iss-other": claims["iss"] = "https://heimdall.example"; break; + case "iss-trailing-slash": claims["iss"] = HeimdallShapes.Issuer + "/"; break; + case "expired": claims["exp"] = now - 3600; break; + case "exp-missing": claims.Remove("exp"); break; + case "exp-string": claims["exp"] = (now + 900).ToString(); break; + case "exp-out-of-range": claims["exp"] = long.MaxValue; break; + case "exp-negative": claims["exp"] = -1; break; + case "not-yet-valid": claims["nbf"] = now + 3600; claims["exp"] = now + 7200; break; + case "nbf-missing": claims.Remove("nbf"); break; + case "nbf-negative": claims["nbf"] = -1; break; + case "sub-differs": claims["sub"] = Flip(accountId); break; + case "account-id-differs": claims["account_id"] = Flip(accountId); break; + case "account-id-missing": claims.Remove("account_id"); break; + case "sub-empty": claims["sub"] = string.Empty; claims["account_id"] = string.Empty; break; + default: throw new InvalidOperationException(wrong); + } + }); + + var result = await host.CompleteAsync(attempt, host.CodeFor(attempt, accountId, token)); + + await AssertRefused(host, accountId, result); + AssertTokenRefused(host, rule); + } + + [Fact] + public async Task A_token_for_a_different_account_than_the_redeemed_one_is_refused_by_one_character() + { + using var host = new HeimdallHost(); + var tokenAccount = HeimdallShapes.NewAccountId(); + var redeemedAccount = Flip(tokenAccount); + var attempt = await host.StartAsync(); + + var result = await host.CompleteAsync(attempt, host.CodeFor(attempt, redeemedAccount, host.Token(tokenAccount))); + + AssertRefusedRedirect(result); + Assert.Null(await host.Account(tokenAccount)); + Assert.Null(await host.Account(redeemedAccount)); + AssertTokenRefused(host, "token subject does not match the account"); + } + + [Fact] + public async Task A_redeem_response_with_no_account_is_refused() + { + using var host = new HeimdallHost(); + var accountId = HeimdallShapes.NewAccountId(); + var attempt = await host.StartAsync(); + var code = CodeWith(host, attempt, accountId, completion => completion["account"] = null); + + var result = await host.CompleteAsync(attempt, code); + + await AssertRefused(host, accountId, result); + AssertTokenRefused(host, "token subject does not match the account"); + } + + [Theory] + [InlineData(60, 900, true)] + [InlineData(61, 900, false)] + [InlineData(-1000, -59, true)] + [InlineData(-1000, -60, false)] + public async Task Token_validity_allows_a_minute_of_clock_skew(int notBeforeOffset, int expiresOffset, bool accepted) + { + using var host = new HeimdallHost(); + var accountId = HeimdallShapes.NewAccountId(); + var attempt = await host.StartAsync(); + var now = host.Clock.Now.ToUnixTimeSeconds(); + var token = host.Token(accountId, mutate: claims => + { + claims["nbf"] = now + notBeforeOffset; + claims["exp"] = now + expiresOffset; + }); + + var result = await host.CompleteAsync(attempt, host.CodeFor(attempt, accountId, token)); + + Assert.Equal(accepted, result.SignedIn); + } + + [Fact] + public async Task A_consent_link_completed_in_another_browser_signs_nobody_in() + { + using var host = new HeimdallHost(); + var victim = HeimdallShapes.NewAccountId(); + var attacker = await host.StartAsync(); + var victimsCode = host.CodeFor(attacker, victim); + + var swap = await host.CompleteAsync(attacker.Nonce, victimsCode, null); + + AssertRefusedRedirect(swap); + Assert.Null(await host.Account(victim)); + Assert.Equal(0, host.Heimdall.RedeemCalls); + AssertLogged(host, "Heimdall completion has no attempt for this browser."); + + var guess = await host.CompleteAsync(attacker.Nonce, "0123456789abcdef0123456789abcdef", attacker.Cookie); + + AssertRefusedRedirect(guess); + Assert.Equal(1, host.Heimdall.RedeemCalls); + Assert.Null(await host.Account(victim)); + } + + [Fact] + public async Task An_attackers_code_in_a_victims_attempt_signs_nobody_in() + { + using var host = new HeimdallHost(); + var attackerAccount = HeimdallShapes.NewAccountId(); + var victimAttempt = await host.StartAsync(); + var attackerAttempt = await host.StartAsync(); + var attackersCode = host.CodeFor(attackerAttempt, attackerAccount); + + var forced = await host.CompleteAsync(victimAttempt.Nonce, attackersCode, victimAttempt.Cookie); + + AssertRefusedRedirect(forced); + Assert.Null(await host.Account(attackerAccount)); + Assert.Equal(1, host.Heimdall.RedeemCalls); + AssertLogged(host, "Heimdall completion belongs to another attempt."); + + var crossed = await host.CompleteAsync(attackerAttempt.Nonce, attackersCode, victimAttempt.Cookie); + + AssertRefusedRedirect(crossed); + Assert.Equal(1, host.Heimdall.RedeemCalls); + } + + [Theory] + [InlineData("nonce-flipped")] + [InlineData("trailing-slash")] + [InlineData("other-host")] + [InlineData("other-scheme")] + [InlineData("other-path")] + [InlineData("extra-query")] + [InlineData("empty")] + public async Task A_redeemed_return_to_that_differs_by_one_character_is_refused(string difference) + { + using var host = new HeimdallHost(); + var accountId = HeimdallShapes.NewAccountId(); + var attempt = await host.StartAsync(); + var returnTo = difference switch + { + "nonce-flipped" => Flip(attempt.ReturnTo), + "trailing-slash" => attempt.ReturnTo + "/", + "other-host" => attempt.ReturnTo.Replace("https://localhost", "https://localhost.evil.example"), + "other-scheme" => attempt.ReturnTo.Replace("https://", "http://"), + "other-path" => attempt.ReturnTo.Replace("/complete", "/completed"), + "extra-query" => attempt.ReturnTo + "&x=1", + _ => string.Empty + }; + Assert.NotEqual(attempt.ReturnTo, returnTo); + + var result = await host.CompleteAsync(attempt, host.CodeFor(attempt, accountId, returnTo: returnTo)); + + await AssertRefused(host, accountId, result); + Assert.Equal(1, host.Heimdall.RedeemCalls); + AssertLogged(host, "Heimdall completion belongs to another attempt."); + } + + [Fact] + public async Task An_attempt_cookie_that_is_tampered_renamed_or_expired_is_no_attempt() + { + using var host = new HeimdallHost(); + var accountId = HeimdallShapes.NewAccountId(); + var attempt = await host.StartAsync(); + var other = await host.StartAsync(); + var onTime = await host.StartAsync(); + var atExpiry = await host.StartAsync(); + var afterExpiry = await host.StartAsync(); + var code = host.CodeFor(attempt, accountId); + var wrong = new Dictionary + { + ["empty cookie"] = string.Empty, + ["another attempt's cookie under this name"] = other.Cookie, + ["a corrupted byte"] = Corrupt(attempt.Cookie!), + ["a truncated value"] = attempt.Cookie![..^8], + ["a value with a suffix"] = attempt.Cookie + "A", + ["the nonce as the value"] = attempt.Nonce, + ["a same-shape random value"] = WebEncoders.Base64UrlEncode(new byte[attempt.Cookie.Length * 3 / 4]) + }; + + foreach (var (name, cookie) in wrong) + { + var refused = await host.CompleteAsync(attempt.Nonce, code, cookie); + AssertRefusedRedirect(refused); + Assert.True(refused.Response.Headers.Contains("Set-Cookie"), name); + } + + Assert.Equal(0, host.Heimdall.RedeemCalls); + Assert.Null(await host.Account(accountId)); + + host.Clock.Advance(HeimdallAttemptCookie.Lifetime - TimeSpan.FromSeconds(1)); + var accepted = await host.CompleteAsync(onTime, host.CodeFor(onTime, accountId)); + Assert.True(accepted.SignedIn); + Assert.Equal(1, host.Heimdall.RedeemCalls); + + host.Clock.Advance(TimeSpan.FromSeconds(1)); + var expiring = await host.CompleteAsync(atExpiry, host.CodeFor(atExpiry, accountId)); + AssertRefusedRedirect(expiring); + + host.Clock.Advance(TimeSpan.FromSeconds(1)); + var expired = await host.CompleteAsync(afterExpiry, host.CodeFor(afterExpiry, accountId)); + AssertRefusedRedirect(expired); + Assert.Equal(1, host.Heimdall.RedeemCalls); + } + + [Fact] + public async Task Anonymous_starts_cannot_displace_a_pending_sign_in() + { + using var host = new HeimdallHost(); + var accountId = HeimdallShapes.NewAccountId(); + var genuine = await host.StartAsync(); + + using (var client = host.NewClient()) + { + for (var i = 0; i < 5000; i++) + { + using var response = await client.GetAsync("/auth/heimdall/discord"); + Assert.Equal(HttpStatusCode.Redirect, response.StatusCode); + } + } + + Assert.True((await Genuine(host, genuine, accountId)).SignedIn); + } + + [Fact] + public async Task Two_attempts_in_one_browser_both_complete() + { + using var host = new HeimdallHost(); + var first = HeimdallShapes.NewAccountId(); + var second = HeimdallShapes.NewAccountId(); + var attemptOne = await host.StartAsync(returnUrl: "/App/One"); + var attemptTwo = await host.StartAsync(returnUrl: "/App/Two"); + + var doneTwo = await Genuine(host, attemptTwo, second); + var doneOne = await Genuine(host, attemptOne, first); + + Assert.True(doneTwo.SignedIn); + Assert.Equal("/App/Two", doneTwo.Response.Headers.Location!.OriginalString); + Assert.True(doneOne.SignedIn); + Assert.Equal("/App/One", doneOne.Response.Headers.Location!.OriginalString); + } + + [Fact] + public async Task Start_sets_a_private_per_attempt_cookie_that_reveals_nothing() + { + using var host = new HeimdallHost(); + var first = await host.StartAsync(returnUrl: "/App/Mill"); + var second = await host.StartAsync(returnUrl: "/App/Mill"); + + Assert.Equal(HttpStatusCode.Redirect, first.Response.StatusCode); + Assert.Equal("https://heimdall.test/oauth/discord/authorize?state=fixture", first.Response.Headers.Location!.OriginalString); + Assert.Equal(43, first.Nonce.Length); + Assert.NotEqual(first.Nonce, second.Nonce); + var header = HeimdallHost.SetCookieHeader(first.Response, "bifrost_heimdall_" + first.Nonce); + Assert.NotNull(header); + var attributes = header!.Split(';').Select(x => x.Trim().ToLowerInvariant()).ToArray(); + Assert.Contains("httponly", attributes); + Assert.Contains("secure", attributes); + Assert.Contains("samesite=lax", attributes); + Assert.Contains("path=/auth/heimdall", attributes); + Assert.Contains("max-age=600", attributes); + Assert.NotEqual(first.Cookie, second.Cookie); + Assert.DoesNotContain(first.Nonce, first.Cookie!); + Assert.DoesNotContain("Mill", first.Cookie!); + Assert.DoesNotContain("Mill", Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(first.Cookie!))); + + using var body = JsonDocument.Parse(host.Heimdall.StartBodies[0]); + var handoff = body.RootElement.GetProperty("handoff"); + Assert.Equal("browser_completion", handoff.GetProperty("kind").GetString()); + Assert.Single(handoff.EnumerateObject()); + Assert.Equal($"https://localhost/auth/heimdall/complete?attempt={first.Nonce}", body.RootElement.GetProperty("returnTo").GetString()); + Assert.Equal("bifrost", body.RootElement.GetProperty("appSlug").GetString()); + Assert.Equal("sign_in", body.RootElement.GetProperty("mode").GetString()); + Assert.False(body.RootElement.TryGetProperty("entitlementPolicy", out _)); + } + + [Fact] + public async Task The_complete_page_holds_no_code_and_leaks_no_referrer() + { + using var host = new HeimdallHost(); + using var client = host.NewClient(); + + using var response = await client.GetAsync("/auth/heimdall/complete?attempt=CANARYATTEMPT&code=CANARYCODE"); + var page = await response.Content.ReadAsStringAsync(); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("text/html; charset=utf-8", response.Content.Headers.ContentType!.ToString()); + Assert.Equal("no-store", response.Headers.CacheControl!.ToString()); + Assert.Equal("no-referrer", Assert.Single(response.Headers.GetValues("Referrer-Policy"))); + Assert.Contains("location.hash", page); + Assert.Contains("heimdall_completion_code", page); + Assert.Contains("history.replaceState", page); + Assert.Contains("form.submit()", page); + Assert.Contains("method=\"post\" action=\"/auth/heimdall/complete\"", page); + Assert.Contains("