From fb9fa6c91fed31c5e99b7eb2db879090601b0030 Mon Sep 17 00:00:00 2001 From: Heimdall Date: Wed, 11 Nov 2026 17:44:38 +0000 Subject: [PATCH 1/5] Desk: mask answerer ids for non-deciders where notes are already nilled DeskNotes becomes DeskRedaction: one function over entry and list that nils every note and replaces every answerer with the fixed sentinel "masked" for a caller who is not a decider of the Mind (X3). A decider keeps both. The sentinel is not derived from the account id and is not a UUID. Co-Authored-By: Claude Sonnet 5.5 --- .../Features/Desk/DeskDocuments.cs | 11 ------ .../Features/Desk/DeskEndpoints.cs | 7 ++-- .../Features/Desk/DeskRedaction.cs | 25 ++++++++++++++ tests/Bifrost.Web.Tests/DeskReadTests.cs | 34 +++++++++++++++++-- 4 files changed, 61 insertions(+), 16 deletions(-) create mode 100644 src/Bifrost.Web/Features/Desk/DeskRedaction.cs diff --git a/src/Bifrost.Web/Features/Desk/DeskDocuments.cs b/src/Bifrost.Web/Features/Desk/DeskDocuments.cs index f8658f4..915b3e4 100644 --- a/src/Bifrost.Web/Features/Desk/DeskDocuments.cs +++ b/src/Bifrost.Web/Features/Desk/DeskDocuments.cs @@ -183,14 +183,3 @@ public static class DeskUnavailables "unavailable: the outcome is not confirmed, try again with the same request_id", retryAfterSeconds); } - -// Notes are returned only to deciders. For everyone else Bifrost nils every note at the door: the key stays written -// (every key written, nil where absent), so the stripped body is still the contract's shape. Cards, outcomes, codes -// and counts are untouched; Huginn returns the full record. -public static class DeskNotes -{ - public static ForkEntry Strip(ForkEntry entry) => - entry with { Record = entry.Record with { Answers = [.. entry.Record.Answers.Select(a => a with { Note = null })] } }; - - public static ForkList Strip(ForkList list) => list with { Forks = [.. list.Forks.Select(Strip)] }; -} diff --git a/src/Bifrost.Web/Features/Desk/DeskEndpoints.cs b/src/Bifrost.Web/Features/Desk/DeskEndpoints.cs index 83cd6c1..2f0fbf4 100644 --- a/src/Bifrost.Web/Features/Desk/DeskEndpoints.cs +++ b/src/Bifrost.Web/Features/Desk/DeskEndpoints.cs @@ -167,7 +167,7 @@ public static class DeskEndpoints var listed = await door.ListAsync(mind.Socket, context, listRequest! with { Mind = mind.Id }, cancellationToken); if (listed is DeskListOutcome.Listed(var list)) { - await WriteJsonAsync(http, 200, IsDecider(context) ? list : DeskNotes.Strip(list)); + await WriteJsonAsync(http, 200, IsDecider(context) ? list : DeskRedaction.ForNonDecider(list)); } else if (listed is DeskListOutcome.ForkTooLarge(var largeId, var largeOrder)) { @@ -187,7 +187,7 @@ public static class DeskEndpoints var found = await door.ReadAsync(mind.Socket, context, forkId, cancellationToken); if (found is DeskReadOutcome.Found(var entry)) { - await WriteJsonAsync(http, 200, IsDecider(context) ? entry : DeskNotes.Strip(entry)); + await WriteJsonAsync(http, 200, IsDecider(context) ? entry : DeskRedaction.ForNonDecider(entry)); } else if (found is DeskReadOutcome.ForkTooLarge(var largeFork)) { @@ -253,7 +253,8 @@ public static class DeskEndpoints } } - // A note is a decider's own words: only a caller on the admin-set list reads them (visibility ruling c). + // A note is a decider's own words, and an answerer id names an account: only a caller on the admin-set list reads + // either (visibility ruling c; X3). private static bool IsDecider(DeskDoorContext context) => context.Deciders.Contains(context.Subject, StringComparer.Ordinal); diff --git a/src/Bifrost.Web/Features/Desk/DeskRedaction.cs b/src/Bifrost.Web/Features/Desk/DeskRedaction.cs new file mode 100644 index 0000000..1a41f71 --- /dev/null +++ b/src/Bifrost.Web/Features/Desk/DeskRedaction.cs @@ -0,0 +1,25 @@ +namespace Bifrost.Web.Features.Desk; + +// What a caller who is not a decider of the Mind does not read, taken out at the door and nowhere else. For them +// every note is nil and every answerer is the sentinel; every key stays written (nil where absent, a string where a +// string is required), so the redacted body is still the contract's shape. Cards, outcomes, codes, counts and the +// number of answers are untouched; Huginn returns the full record and Bifrost drops and adds nothing. +// +// The sentinel is one fixed string for every answer: nothing derived from an account id (a hash or a per-reader +// alias would still let a reader link two answers to one person, or confirm a guess), and it is not a D-format +// UUID, so it cannot be mistaken for an account. The contract's `answerer` is a non-empty string and nothing more. +public static class DeskRedaction +{ + public const string MaskedAnswerer = "masked"; + + public static ForkEntry ForNonDecider(ForkEntry entry) => + entry with + { + Record = entry.Record with + { + Answers = [.. entry.Record.Answers.Select(a => a with { Note = null, Answerer = MaskedAnswerer })], + }, + }; + + public static ForkList ForNonDecider(ForkList list) => list with { Forks = [.. list.Forks.Select(ForNonDecider)] }; +} diff --git a/tests/Bifrost.Web.Tests/DeskReadTests.cs b/tests/Bifrost.Web.Tests/DeskReadTests.cs index 14e4af9..9688051 100644 --- a/tests/Bifrost.Web.Tests/DeskReadTests.cs +++ b/tests/Bifrost.Web.Tests/DeskReadTests.cs @@ -374,7 +374,13 @@ public sealed class DeskReadTests private static DeskAnswerDocument Doc(string id, string outcome, string? of, string? retracts, string? note) => new( DeskSchemas.DeskAnswer, id, DeskReplay.ChatFork, outcome, of, retracts, null, "h", note == null ? null : "a", note, - "2026-11-12T19:00:00Z", "2026-11-12T19:00:01Z", false, DeskWebApplicationFactory.Alice, "Steward", "r-" + id); + "2026-11-12T19:00:00Z", "2026-11-12T19:00:01Z", false, AnswererOf(id), "Steward", "r-" + id); + + // Four different accounts, so a mask that kept any one of them, or told two apart, is seen. + private static readonly string[] Answerers = + [DeskWebApplicationFactory.Alice, DeskWebApplicationFactory.Steward, DeskWebApplicationFactory.Member, "0badc0de-0000-4000-8000-00000000000d"]; + + private static string AnswererOf(string answerId) => Answerers[int.Parse(answerId[1..]) - 1]; // Counted, Revision, Retraction and Refused documents, every one carrying the decider's note. private static ForkEntry NotedEntry() @@ -424,10 +430,17 @@ public sealed class DeskReadTests Assert.DoesNotContain("decider's own words", list); Assert.False(AnyNote(JsonNode.Parse(read))); Assert.False(AnyNote(JsonNode.Parse(list))); + // No answerer id of any answer reaches a non-decider, and every answer says the same thing instead. + var readMasked = JsonNode.Parse(read)!["record"]!["answers"]!.AsArray().Select(a => (string?)a!["answerer"]); + Assert.Equal(4, readMasked.Count()); + Assert.All(readMasked, answerer => Assert.Equal("masked", answerer)); + var masked = JsonNode.Parse(list)!["forks"]!.AsArray().SelectMany(f => f!["record"]!["answers"]!.AsArray()).Select(a => (string?)a!["answerer"]); + Assert.Equal(8, masked.Count()); + Assert.All(masked, answerer => Assert.Equal("masked", answerer)); // Outcomes and cards stay; the key stays written as nil, so the body is still the contract's shape. var expected = JsonSerializer.SerializeToNode(NotedEntry() with { - Record = NotedEntry().Record with { Answers = [.. NotedEntry().Record.Answers.Select(a => a with { Note = null })] }, + Record = NotedEntry().Record with { Answers = [.. NotedEntry().Record.Answers.Select(a => a with { Note = null, Answerer = DeskRedaction.MaskedAnswerer })] }, }, DeskJson.Options); Assert.True(DeskVectors.SameShape(JsonNode.Parse(read), expected)); var answers = JsonNode.Parse(read)!["record"]!["answers"]!.AsArray(); @@ -436,6 +449,22 @@ public sealed class DeskReadTests Assert.Equal(2, JsonNode.Parse(list)!["forks"]!.AsArray().Count); } + [Fact] + public async Task Bifrost_relays_exactly_the_entries_the_door_returns_to_any_reader() + { + using var factory = new DeskWebApplicationFactory(); + await factory.EnsureAccountAsync(DeskWebApplicationFactory.Member, true); + await factory.EnsureAccountAsync(DeskWebApplicationFactory.Alice, true); + var forks = Enumerable.Range(1, 7).Select(i => DeskReplay.Entry(DeskReplay.Card("gamecult-chat:question:q" + i))).ToArray(); + factory.Door.ListScript = (_, r) => new DeskListOutcome.Listed(new ForkList("gamecult.fork.list.v0", r.Mind, "s1", 7, 0, forks, null)); + + foreach (var account in new[] { DeskWebApplicationFactory.Member, DeskWebApplicationFactory.Alice }) + { + var reply = await factory.SendAsync("GET", "/api/desk/gamecult-chat/forks", null, factory.Token(account, "bifrost-desk", "desk.read")); + Assert.Equal(forks.Select(f => f.Card.ForkId), JsonNode.Parse(reply.Body)!["forks"]!.AsArray().Select(f => (string?)f!["card"]!["fork_id"])); + } + } + [Fact] public async Task A_decider_reads_and_lists_the_notes() { @@ -445,6 +474,7 @@ public sealed class DeskReadTests var (read, list) = await BodiesAsync(factory, DeskWebApplicationFactory.Alice); Assert.Contains(Secret + " 3", read); + Assert.Equal(Answerers, JsonNode.Parse(read)!["record"]!["answers"]!.AsArray().Select(a => (string?)a!["answerer"])); Assert.Equal(8, JsonNode.Parse(list)!["forks"]!.AsArray().Sum(f => f!["record"]!["answers"]!.AsArray().Count(a => a!["note"] is not null))); Assert.True(DeskVectors.SameShape(JsonNode.Parse(read), JsonSerializer.SerializeToNode(NotedEntry(), DeskJson.Options))); } -- 2.47.3 From c7edcd4f586b692b5ab2518a4e349bdb689af682 Mon Sep 17 00:00:00 2001 From: Heimdall Date: Wed, 11 Nov 2026 17:44:38 +0000 Subject: [PATCH 2/5] Desk: production IDeskCredential on OpenIddict validation against the local issuer OidcDeskCredential authenticates a bearer through the validation scheme only (never the default scheme; the cookie authenticates nothing on /api/desk and a bearer authenticates no cookie page) and returns sub, audience and scopes. The desk keeps judging audience, scope and per-request Active membership. The issuer registers desk.read/desk.answer and stamps audience bifrost-desk on tokens that carry one; client permissions are what limit scopes, so the MAS client can neither ask for them nor use its chat token on the desk. A Desk section without Bifrost:Oidc now fails startup in StartupConfigurationValidator. Packages: OpenIddict.Validation.AspNetCore and .ServerIntegration 7.7.1. No production Ratatoskr client is seeded. Co-Authored-By: Claude Sonnet 5.5 --- src/Bifrost.Web/Bifrost.Web.csproj | 2 + .../Features/Desk/DeskEndpoints.cs | 19 +- .../Features/Desk/OidcDeskCredential.cs | 28 ++ .../Features/Oidc/OidcEndpoints.cs | 8 + .../Shared/StartupConfigurationValidator.cs | 9 +- src/Bifrost.Web/Program.cs | 2 +- src/Bifrost.Web/packages.lock.json | 38 ++ .../Bifrost.Web.Tests/DeskCredentialTests.cs | 346 ++++++++++++++++++ tests/Bifrost.Web.Tests/DeskOptionsTests.cs | 14 +- tests/Bifrost.Web.Tests/DeskSurfaceTests.cs | 5 +- .../Support/DeskWebApplicationFactory.cs | 76 +++- tests/Bifrost.Web.Tests/packages.lock.json | 38 ++ 12 files changed, 576 insertions(+), 9 deletions(-) create mode 100644 src/Bifrost.Web/Features/Desk/OidcDeskCredential.cs create mode 100644 tests/Bifrost.Web.Tests/DeskCredentialTests.cs diff --git a/src/Bifrost.Web/Bifrost.Web.csproj b/src/Bifrost.Web/Bifrost.Web.csproj index 7cd14b3..f69b885 100644 --- a/src/Bifrost.Web/Bifrost.Web.csproj +++ b/src/Bifrost.Web/Bifrost.Web.csproj @@ -18,6 +18,8 @@ + + diff --git a/src/Bifrost.Web/Features/Desk/DeskEndpoints.cs b/src/Bifrost.Web/Features/Desk/DeskEndpoints.cs index 2f0fbf4..6632d58 100644 --- a/src/Bifrost.Web/Features/Desk/DeskEndpoints.cs +++ b/src/Bifrost.Web/Features/Desk/DeskEndpoints.cs @@ -4,6 +4,7 @@ using Bifrost.Web.Data; using Bifrost.Web.Features.Oidc; using Microsoft.AspNetCore.Cors.Infrastructure; using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Options; using Microsoft.Net.Http.Headers; @@ -22,8 +23,24 @@ public static class DeskEndpoints public const string AnswerScope = "desk.answer"; public const string CorsPolicy = "DeskCors"; - public static IServiceCollection AddBifrostDesk(this IServiceCollection services) + public static bool IsDeskScope(string scope) => scope is ReadScope or AnswerScope; + + // The desk's credential is OpenIddict validation against the local server, so it exists only where Bifrost:Oidc is + // configured. Without it nothing is registered here and startup fails in the validator, with its own message. + public static IServiceCollection AddBifrostDesk(this IServiceCollection services, bool oidcConfigured) { + if (oidcConfigured) + { + services.AddOpenIddict() + .AddServer(server => server.RegisterScopes(ReadScope, AnswerScope)) + .AddValidation(validation => + { + validation.UseLocalServer(); + validation.UseAspNetCore(); + }); + services.TryAddSingleton(); + } + services.AddSingleton(provider => new DeskMindRoutes(provider.GetRequiredService>().Value.Minds)); services.AddCors(); // Exactly the configured origin, no credentials, and only on the routes that require this policy. diff --git a/src/Bifrost.Web/Features/Desk/OidcDeskCredential.cs b/src/Bifrost.Web/Features/Desk/OidcDeskCredential.cs new file mode 100644 index 0000000..15c7805 --- /dev/null +++ b/src/Bifrost.Web/Features/Desk/OidcDeskCredential.cs @@ -0,0 +1,28 @@ +using Microsoft.AspNetCore.Authentication; +using OpenIddict.Abstractions; +using OpenIddict.Validation.AspNetCore; +using static OpenIddict.Abstractions.OpenIddictConstants; + +namespace Bifrost.Web.Features.Desk; + +// The production IDeskCredential: OpenIddict's validation handler against the local server, asked for by its own +// scheme name and no other. It authenticates only. The default scheme stays the cookie, so a bearer token reaches no +// cookie surface, and this credential never reads the cookie, so the session cookie authenticates nothing on the +// desk. Audience, scope and Active membership are the desk's judgments (DeskEndpoints), not this port's. +public sealed class OidcDeskCredential : IDeskCredential +{ + public async ValueTask AuthenticateAsync(HttpContext context, CancellationToken cancellationToken) + { + var result = await context.AuthenticateAsync(OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme); + if (!result.Succeeded || result.Principal is null) + { + return null; + } + + // A token may name several audiences; the desk's own is reported when present so the desk judges it, else the + // first (or none). + var audiences = result.Principal.GetAudiences(); + var audience = audiences.Contains(DeskEndpoints.Audience) ? DeskEndpoints.Audience : audiences.FirstOrDefault(); + return new DeskToken(result.Principal.GetClaim(Claims.Subject), audience, [.. result.Principal.GetScopes()]); + } +} diff --git a/src/Bifrost.Web/Features/Oidc/OidcEndpoints.cs b/src/Bifrost.Web/Features/Oidc/OidcEndpoints.cs index 41ae175..ba67d8c 100644 --- a/src/Bifrost.Web/Features/Oidc/OidcEndpoints.cs +++ b/src/Bifrost.Web/Features/Oidc/OidcEndpoints.cs @@ -2,6 +2,7 @@ using System.Security.Claims; using Microsoft.AspNetCore; using Bifrost.Web.Data; using Bifrost.Web.Domain; +using Bifrost.Web.Features.Desk; using Bifrost.Web.Features.Membership; using Bifrost.Web.Features.Registration; using Bifrost.Web.Features.Shared; @@ -85,6 +86,13 @@ public static class OidcEndpoints var principal = new ClaimsPrincipal(identity); principal.SetScopes(request.GetScopes()); + // A desk scope is what makes a token a desk token: its audience is the desk's. The client's permissions already + // decided whether it may ask for the scope (the MAS client may not), so this branch is reached only by one that may. + if (request.GetScopes().Any(DeskEndpoints.IsDeskScope)) + { + principal.SetResources(DeskEndpoints.Audience); + } + principal.SetDestinations(claim => claim.Type switch { Claims.Subject => [Destinations.AccessToken, Destinations.IdentityToken], diff --git a/src/Bifrost.Web/Features/Shared/StartupConfigurationValidator.cs b/src/Bifrost.Web/Features/Shared/StartupConfigurationValidator.cs index 3181450..a5d0752 100644 --- a/src/Bifrost.Web/Features/Shared/StartupConfigurationValidator.cs +++ b/src/Bifrost.Web/Features/Shared/StartupConfigurationValidator.cs @@ -15,7 +15,8 @@ public sealed class StartupConfigurationValidator( IOptions hostOptions, IOptions conceptVoteOptions, IOptions deskOptions, - IOptions registrationOptions) + IOptions registrationOptions, + OidcIssuerOptions? oidcOptions = null) { public void Validate() { @@ -149,6 +150,12 @@ public sealed class StartupConfigurationValidator( // mis-attribute answers. Account existence is a request-time question, not a startup one. private void ValidateDesk(DeskOptions desk, List failures) { + // The desk's bearer is validated against Bifrost's own issuer: without Bifrost:Oidc there is no credential to serve with. + if (oidcOptions is null && configuration.GetSection(DeskOptions.SectionName).Exists()) + { + failures.Add("Bifrost:Oidc must be configured when the Desk section exists: the desk validates its bearer tokens against Bifrost's own issuer."); + } + if (!desk.IsConfigured) { if (configuration.GetSection(DeskOptions.SectionName).Exists()) diff --git a/src/Bifrost.Web/Program.cs b/src/Bifrost.Web/Program.cs index 83eb76e..c7e4587 100644 --- a/src/Bifrost.Web/Program.cs +++ b/src/Bifrost.Web/Program.cs @@ -267,7 +267,7 @@ if (oidcOptions is not null) var deskConfigured = builder.Configuration.GetSection(DeskOptions.SectionName).Exists(); if (deskConfigured) { - builder.Services.AddBifrostDesk(); + builder.Services.AddBifrostDesk(oidcConfigured: oidcOptions is not null); } var app = builder.Build(); diff --git a/src/Bifrost.Web/packages.lock.json b/src/Bifrost.Web/packages.lock.json index bd15130..fc903ba 100644 --- a/src/Bifrost.Web/packages.lock.json +++ b/src/Bifrost.Web/packages.lock.json @@ -75,6 +75,25 @@ "OpenIddict.Server": "7.7.1" } }, + "OpenIddict.Validation.AspNetCore": { + "type": "Direct", + "requested": "[7.7.1, )", + "resolved": "7.7.1", + "contentHash": "9Gpw13k6KRif6LCeIfzS5cfDPxzEvg5I4l8JKwMxNL4mqal8eT00T069RnuXuDY8CwOO43FLD55JY7Wh3an5cg==", + "dependencies": { + "OpenIddict.Validation": "7.7.1" + } + }, + "OpenIddict.Validation.ServerIntegration": { + "type": "Direct", + "requested": "[7.7.1, )", + "resolved": "7.7.1", + "contentHash": "tZd5oqlVHftWjo3gthwXRvDE09NGmOjo5yjyRH8W2q4Yw9Hntkfi70+T2ESFKJ++oqueQT4YG5ilyot/o0OBMA==", + "dependencies": { + "OpenIddict.Server": "7.7.1", + "OpenIddict.Validation": "7.7.1" + } + }, "System.Text.Json": { "type": "Direct", "requested": "[8.0.5, )", @@ -269,6 +288,14 @@ "Microsoft.IdentityModel.Abstractions": "8.19.2" } }, + "Microsoft.IdentityModel.Protocols": { + "type": "Transitive", + "resolved": "8.19.2", + "contentHash": "sGxSsSrZXNmca6D+jHH2rVRyo2nNRd/g4H9CFbPmLLq0xgoH1U0orLWE5minfijw7+zq49tBs7txenbfAErRoQ==", + "dependencies": { + "Microsoft.IdentityModel.Tokens": "8.19.2" + } + }, "Microsoft.IdentityModel.Tokens": { "type": "Transitive", "resolved": "8.19.2", @@ -323,6 +350,17 @@ "OpenIddict.Abstractions": "7.7.1" } }, + "OpenIddict.Validation": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "09FOsvek+iSKrNbEehH2c1uBWSFurZk1U8vyTKk5aiaCV2bbj+j/68KRA/iHOgSe8qV3+0ZrquhmjFMpcojVSw==", + "dependencies": { + "Microsoft.Extensions.Logging": "8.0.1", + "Microsoft.IdentityModel.JsonWebTokens": "8.19.2", + "Microsoft.IdentityModel.Protocols": "8.19.2", + "OpenIddict.Abstractions": "7.7.1" + } + }, "System.CodeDom": { "type": "Transitive", "resolved": "4.4.0", diff --git a/tests/Bifrost.Web.Tests/DeskCredentialTests.cs b/tests/Bifrost.Web.Tests/DeskCredentialTests.cs new file mode 100644 index 0000000..3807278 --- /dev/null +++ b/tests/Bifrost.Web.Tests/DeskCredentialTests.cs @@ -0,0 +1,346 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using Bifrost.Web.Domain; +using Bifrost.Web.Features.Desk; +using Bifrost.Web.Tests.Support; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.IdentityModel.Tokens; +using OpenIddict.Abstractions; +using static OpenIddict.Abstractions.OpenIddictConstants; + +namespace Bifrost.Web.Tests; + +// The production desk credential, on real tokens: the test host's own issuer mints them through the code flow, and the +// OpenIddict validation handler (the production IDeskCredential) judges them. Only the door is faked. +public sealed class DeskCredentialTests : IDisposable +{ + private const string DeskClient = "ratatoskr-test"; + private const string DeskRedirect = "https://ratatoskr.example.test/callback"; + private const string DeskSecret = "ratatoskr-test-secret"; + private static readonly Uri Origin = new("https://localhost"); + private static readonly string ListTarget = "/api/desk/gamecult-chat/forks"; + + private readonly DeskWebApplicationFactory factory = new(realCredential: true); + + public void Dispose() => factory.Dispose(); + + [Fact] + public async Task A_valid_desk_read_token_lists_through_the_door_and_the_door_is_asked_once() + { + var (_, token) = await MemberWithTokenAsync("desk.read"); + factory.Door.ListScript = (_, r) => new DeskListOutcome.Listed(new ForkList("gamecult.fork.list.v0", r.Mind, "s1", 0, 0, [], null)); + + var reply = await GetAsync(token); + + Assert.Equal(200, reply.Status); + Assert.Equal("list", Assert.Single(factory.Door.Calls).Kind); + } + + [Fact] + public async Task No_token_is_401_and_the_door_is_never_asked() + { + await MemberWithTokenAsync("desk.read"); + + var reply = await GetAsync(null); + + Assert.Equal(401, reply.Status); + Assert.Equal("unauthenticated", Code(reply)); + Assert.Empty(factory.Door.Calls); + } + + [Fact] + public async Task A_tampered_token_is_401() + { + var (_, token) = await MemberWithTokenAsync("desk.read"); + var middle = token.Length / 2; + var tampered = token[..middle] + (token[middle] == 'A' ? 'B' : 'A') + token[(middle + 1)..]; + + Assert.Equal(401, (await GetAsync(tampered)).Status); + Assert.Equal(401, (await GetAsync(token + "x")).Status); + Assert.Equal(401, (await GetAsync("not-a-token")).Status); + Assert.Empty(factory.Door.Calls); + } + + [Fact] + public async Task An_expired_token_is_401() + { + var (_, token) = await MemberWithTokenAsync("desk.read"); + Assert.Equal(200, (await GetAsync(token)).Status); + + factory.Clock.Advance(TimeSpan.FromHours(3)); + + Assert.Equal(401, (await GetAsync(token)).Status); + } + + [Fact] + public async Task A_token_for_another_audience_is_401() + { + var (_, token) = await MemberWithTokenAsync($"desk.read {DeskWebApplicationFactory.OtherAudienceScope}"); + + var reply = await GetAsync(token); + + Assert.Equal(401, reply.Status); + Assert.Empty(factory.Door.Calls); + } + + [Fact] + public async Task A_desk_read_token_cannot_answer_and_the_door_is_never_called() + { + var (_, token) = await MemberWithTokenAsync("desk.read"); + + var reply = await factory.SendAsync( + "POST", DeskReplay.Target("answer", "gamecult-chat", null), DeskReplay.AnswerBody(DeskReplay.ChatFork), + configure: Bearer(token)); + + Assert.Equal(403, reply.Status); + Assert.Equal("insufficient_scope", Code(reply)); + Assert.Empty(factory.Door.Calls); + } + + [Fact] + public async Task A_desk_answer_token_reaches_the_answer_door_but_cannot_list() + { + var (_, token) = await MemberWithTokenAsync("desk.answer"); + DeskReplay.HoldVectorForks(factory.Door); + + var list = await GetAsync(token); + var answer = await factory.SendAsync( + "POST", DeskReplay.Target("answer", "gamecult-chat", null), DeskReplay.AnswerBody(DeskReplay.ChatFork), configure: Bearer(token)); + + Assert.Equal(403, list.Status); + Assert.Equal(200, answer.Status); + Assert.Equal("answer", Assert.Single(factory.Door.Calls).Kind); + } + + [Fact] + public async Task The_chat_client_can_neither_ask_for_a_desk_scope_nor_use_its_own_token_on_the_desk() + { + var (client, _) = await SignedInAsync(); + + // Asking for the scope: the issuer refuses, so no code and no token exist for the chat client to carry. + var request = NewRequest(OidcTestKit.ClientId, OidcTestKit.RedirectUri, "openid profile desk.read"); + var refused = await DriveAsync(client, request); + Assert.True(refused.StatusCode is HttpStatusCode.Redirect or HttpStatusCode.BadRequest, $"got {(int)refused.StatusCode}"); + var back = refused.Headers.Location?.ToString() ?? await refused.Content.ReadAsStringAsync(); + Assert.Contains("not allowed to use the specified scope", back); + Assert.DoesNotContain("code=", back); + + // The token chat does hold: validly signed by the same issuer, and no use at the desk. + var chat = NewRequest(OidcTestKit.ClientId, OidcTestKit.RedirectUri, "openid profile"); + var chatToken = await ExchangeAsync(client, chat, await CodeAsync(client, chat), OidcTestKit.ClientId, factory.OidcSecret); + Assert.Equal(401, (await GetAsync(chatToken)).Status); + var post = await factory.SendAsync( + "POST", DeskReplay.Target("answer", "gamecult-chat", null), DeskReplay.AnswerBody(DeskReplay.ChatFork), configure: Bearer(chatToken)); + Assert.Equal(401, post.Status); + Assert.Empty(factory.Door.Calls); + } + + [Fact] + public async Task The_session_cookie_alone_authenticates_nothing_on_the_desk() + { + // A signed-in Active member's real session cookie, sent by hand to the desk: it is no credential there. + using var bare = factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false, HandleCookies = false, BaseAddress = Origin }); + var identity = $"member-{Guid.NewGuid():N}"; + using var registered = await RegistrationKit.RegisterAsync(bare, identity); + Assert.Equal(HttpStatusCode.OK, registered.StatusCode); + await factory.SetMembershipAsync(identity, MembershipStatus.Active); + var cookie = string.Join("; ", registered.Headers.GetValues("Set-Cookie").Select(c => c.Split(';')[0])); + Assert.Contains("bifrost", cookie, StringComparison.OrdinalIgnoreCase); + + var list = await factory.SendAsync("GET", ListTarget, configure: http => http.Request.Headers.Cookie = cookie); + var answer = await factory.SendAsync( + "POST", DeskReplay.Target("answer", "gamecult-chat", null), DeskReplay.AnswerBody(DeskReplay.ChatFork), + configure: http => http.Request.Headers.Cookie = cookie); + + Assert.Equal(401, list.Status); + Assert.Equal(401, answer.Status); + Assert.Empty(factory.Door.Calls); + } + + [Fact] + public async Task A_bearer_token_does_not_authenticate_a_cookie_page() + { + var (_, token) = await MemberWithTokenAsync("desk.read desk.answer"); + using var bare = factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false, HandleCookies = false, BaseAddress = Origin }); + + foreach (var path in new[] { "/App", "/App/Members", "/api/sealed-motions" }) + { + using var message = new HttpRequestMessage(HttpMethod.Get, path); + message.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + using var response = await bare.SendAsync(message); + + Assert.True(response.StatusCode is HttpStatusCode.Redirect or HttpStatusCode.Found or HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden or HttpStatusCode.NotFound or HttpStatusCode.MethodNotAllowed, + $"{path} answered {(int)response.StatusCode} to a bearer"); + Assert.NotEqual(HttpStatusCode.OK, response.StatusCode); + } + } + + [Fact] + public async Task A_valid_token_whose_account_turns_non_active_is_refused_on_its_next_request_without_reissue() + { + var (identity, token) = await MemberWithTokenAsync("desk.read"); + Assert.Equal(200, (await GetAsync(token)).Status); + + await factory.SetMembershipAsync(identity, MembershipStatus.Suspended); + + var reply = await GetAsync(token); + Assert.Equal(403, reply.Status); + Assert.Equal("forbidden", Code(reply)); + Assert.Single(factory.Door.Calls); // only the first request got to the door + + await factory.SetMembershipAsync(identity, MembershipStatus.Active); + Assert.Equal(200, (await GetAsync(token)).Status); + } + + [Fact] + public async Task A_member_who_is_not_a_decider_reads_masked_answerers_through_a_real_token() + { + var (_, token) = await MemberWithTokenAsync("desk.read"); + const string secretAnswerer = "0badc0de-0000-4000-8000-00000000000d"; // not on any decider list, so no card carries it + var entry = DeskReplay.Entry(DeskReplay.Card(DeskReplay.ChatFork)); + var answer = new DeskAnswerDocument( + DeskSchemas.DeskAnswer, "n1", DeskReplay.ChatFork, "Counted", null, null, null, entry.Card.OptionsHash, "a", "private words", + "2026-11-12T19:00:00Z", "2026-11-12T19:00:01Z", false, secretAnswerer, "Steward", "r-1"); + entry = entry with { Record = entry.Record with { Answers = [answer] } }; + factory.Door.ListScript = (_, r) => new DeskListOutcome.Listed(new ForkList("gamecult.fork.list.v0", r.Mind, "s1", 1, 0, [entry], null)); + + var reply = await GetAsync(token); + + Assert.Equal(200, reply.Status); + Assert.DoesNotContain(secretAnswerer, reply.Text); + Assert.DoesNotContain("private words", reply.Text); + } + + // ---- helpers ---- + + private static Action Bearer(string token) => + http => http.Request.Headers.Authorization = "Bearer " + token; + + private Task GetAsync(string? token) => + factory.SendAsync("GET", ListTarget, configure: token is null ? null : Bearer(token)); + + private static string? Code(DeskReply reply) => (string?)JsonNode.Parse(reply.Body)!["code"]; + + private HttpClient NewClient() => factory.CreateClient(new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false, + HandleCookies = true, + BaseAddress = Origin, + }); + + // A registered, Active, agreed member holding the session cookie. + private async Task<(HttpClient Client, string Identity)> SignedInAsync() + { + var client = NewClient(); + var identity = $"member-{Guid.NewGuid():N}"; + using var registered = await RegistrationKit.RegisterAsync(client, identity); + Assert.Equal(HttpStatusCode.OK, registered.StatusCode); + await factory.SetMembershipAsync(identity, MembershipStatus.Active); + await SeedDeskClientAsync(); + return (client, identity); + } + + private async Task<(string Identity, string Token)> MemberWithTokenAsync(string scope) + { + var (client, identity) = await SignedInAsync(); + var request = NewRequest(DeskClient, DeskRedirect, scope); + var token = await ExchangeAsync(client, request, await CodeAsync(client, request), DeskClient, DeskSecret); + return (identity, token); + } + + // The test-only desk client: the production seeder creates none (Ratatoskr's redirect and host are a separate decision). + private async Task SeedDeskClientAsync() + { + using var scope = factory.Services.CreateScope(); + var manager = scope.ServiceProvider.GetRequiredService(); + if (await manager.FindByClientIdAsync(DeskClient) is not null) + { + return; + } + + await manager.CreateAsync(new OpenIddictApplicationDescriptor + { + ClientId = DeskClient, + ClientSecret = DeskSecret, + ClientType = ClientTypes.Confidential, + RedirectUris = { new Uri(DeskRedirect) }, + Permissions = + { + Permissions.Endpoints.Authorization, + Permissions.Endpoints.Token, + Permissions.GrantTypes.AuthorizationCode, + Permissions.ResponseTypes.Code, + Permissions.Prefixes.Scope + DeskEndpoints.ReadScope, + Permissions.Prefixes.Scope + DeskEndpoints.AnswerScope, + Permissions.Prefixes.Scope + DeskWebApplicationFactory.OtherAudienceScope, + }, + }); + } + + private sealed record AuthRequest(string ClientId, string RedirectUri, string Verifier, string State, string Path); + + private static AuthRequest NewRequest(string clientId, string redirectUri, string scope) + { + var verifier = Base64UrlEncoder.Encode(RandomNumberGenerator.GetBytes(48)); + var state = Base64UrlEncoder.Encode(RandomNumberGenerator.GetBytes(24)); + var challenge = Base64UrlEncoder.Encode(SHA256.HashData(Encoding.ASCII.GetBytes(verifier))); + var path = "/connect/authorize?response_type=code" + + $"&client_id={Uri.EscapeDataString(clientId)}&redirect_uri={Uri.EscapeDataString(redirectUri)}" + + $"&scope={Uri.EscapeDataString(scope)}&state={state}&code_challenge={challenge}&code_challenge_method=S256"; + return new AuthRequest(clientId, redirectUri, verifier, state, path); + } + + // Starts an authorize request and follows the provider's own redirects until the first response that is not one of them. + private static async Task DriveAsync(HttpClient client, AuthRequest request) + { + var response = await client.GetAsync(request.Path); + for (var hops = 0; hops < 4 && IsAuthorizeRedirect(response); hops++) + { + var next = response.Headers.Location!; + response.Dispose(); + response = await client.GetAsync(next.IsAbsoluteUri ? next.PathAndQuery : next.OriginalString); + } + + return response; + } + + private static bool IsAuthorizeRedirect(HttpResponseMessage response) => + response.StatusCode is HttpStatusCode.Redirect or HttpStatusCode.Found && + response.Headers.Location is { } location && + (location.IsAbsoluteUri ? location.AbsolutePath : location.OriginalString).StartsWith("/connect/authorize", StringComparison.Ordinal); + + private static async Task CodeAsync(HttpClient client, AuthRequest request) + { + var final = await DriveAsync(client, request); + Assert.True(final.StatusCode is HttpStatusCode.Redirect or HttpStatusCode.Found, $"expected a redirect to the client, got {(int)final.StatusCode}"); + var query = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(final.Headers.Location!.Query); + Assert.True(query.ContainsKey("code"), final.Headers.Location.ToString()); + return query["code"].ToString(); + } + + private async Task ExchangeAsync(HttpClient client, AuthRequest request, string code, string clientId, string secret) + { + var message = new HttpRequestMessage(HttpMethod.Post, "/connect/token") + { + Content = new FormUrlEncodedContent(new Dictionary + { + ["grant_type"] = "authorization_code", + ["code"] = code, + ["redirect_uri"] = request.RedirectUri, + ["code_verifier"] = request.Verifier, + }), + }; + message.Headers.Authorization = new AuthenticationHeaderValue("Basic", Convert.ToBase64String(Encoding.ASCII.GetBytes($"{clientId}:{secret}"))); + using var bare = NewClient(); + using var response = await bare.SendAsync(message); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync(); + return body.GetProperty("access_token").GetString()!; + } +} diff --git a/tests/Bifrost.Web.Tests/DeskOptionsTests.cs b/tests/Bifrost.Web.Tests/DeskOptionsTests.cs index 693950a..53863b6 100644 --- a/tests/Bifrost.Web.Tests/DeskOptionsTests.cs +++ b/tests/Bifrost.Web.Tests/DeskOptionsTests.cs @@ -32,7 +32,7 @@ public sealed class DeskOptionsTests return options; } - private static void Validate(Dictionary values, string environment = "Testing") + private static void Validate(Dictionary values, string environment = "Testing", bool oidc = true) { var configuration = new ConfigurationBuilder().AddInMemoryCollection(values).Build(); new StartupConfigurationValidator( @@ -45,7 +45,8 @@ public sealed class DeskOptionsTests Options.Create(new BifrostHostOptions()), Options.Create(new ConceptVoteOptions()), Options.Create(Bind(values)), - Options.Create(new RegistrationOptions())).Validate(); + Options.Create(new RegistrationOptions()), + oidc ? new OidcIssuerOptions() : null).Validate(); } private static void Refused(Dictionary values, string expectedFragment) @@ -65,6 +66,15 @@ public sealed class DeskOptionsTests Validate(Good()); } + [Fact] + public void A_desk_without_the_oidc_issuer_stops_startup_and_says_why() + { + var failure = Assert.Throws(() => Validate(Good(), oidc: false)); + Assert.Contains("Bifrost:Oidc must be configured when the Desk section exists", failure.Message); + + Validate([], oidc: false); // no desk, no need of an issuer + } + [Fact] public void No_desk_section_is_not_configured_and_passes() { diff --git a/tests/Bifrost.Web.Tests/DeskSurfaceTests.cs b/tests/Bifrost.Web.Tests/DeskSurfaceTests.cs index e1e5364..b4dbc56 100644 --- a/tests/Bifrost.Web.Tests/DeskSurfaceTests.cs +++ b/tests/Bifrost.Web.Tests/DeskSurfaceTests.cs @@ -36,13 +36,14 @@ public sealed class DeskSurfaceTests } [Fact] - public void A_configured_desk_without_its_ports_stops_startup() + public void A_configured_desk_without_its_door_stops_startup() { using var factory = new DeskWebApplicationFactory(registerPorts: false); var failure = Assert.ThrowsAny(() => factory.CreateClient()); - Assert.Contains("IDeskCredential", failure.ToString()); + // The credential is production wiring (OpenIddict validation); the door is the port a test or Huginn supplies. + Assert.Contains("IDeskDoor", failure.ToString()); } [Fact] diff --git a/tests/Bifrost.Web.Tests/Support/DeskWebApplicationFactory.cs b/tests/Bifrost.Web.Tests/Support/DeskWebApplicationFactory.cs index 450be51..c5c81ea 100644 --- a/tests/Bifrost.Web.Tests/Support/DeskWebApplicationFactory.cs +++ b/tests/Bifrost.Web.Tests/Support/DeskWebApplicationFactory.cs @@ -7,6 +7,8 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Features; using Microsoft.AspNetCore.TestHost; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using OpenIddict.Abstractions; namespace Bifrost.Web.Tests.Support; @@ -26,9 +28,17 @@ public sealed class DeskWebApplicationFactory : TestWebApplicationFactory private readonly Dictionary? configuration; private readonly bool registerPorts; + private readonly bool realCredential; - public DeskWebApplicationFactory(Dictionary? configuration = null, bool configured = true, bool registerPorts = true) + // A configured desk needs Bifrost:Oidc (its credential validates against the local issuer): generated keys per factory. + private readonly OidcTestKit oidcKit = new(); + + // The chat client's secret, for a test that holds the chat client's own token. + public string OidcSecret => oidcKit.ClientSecret; + + public DeskWebApplicationFactory(Dictionary? configuration = null, bool configured = true, bool registerPorts = true, bool realCredential = false) { + this.realCredential = realCredential; this.configuration = configured ? configuration ?? DefaultConfiguration() : null; this.registerPorts = registerPorts; } @@ -37,6 +47,14 @@ public sealed class DeskWebApplicationFactory : TestWebApplicationFactory public FakeDeskCredential Credential { get; } = new(); + // Moves time for a real-credential host, so an access token can be aged past its lifetime. + public OidcTestClock Clock { get; } = new(); + + // A test-only scope: a token issued with it is stamped for this audience instead of the desk's, by the test host's + // own issuer, so "a valid token for another audience" is a real token. + public const string OtherAudienceScope = "test.other_audience"; + public const string OtherAudience = "bifrost-votes"; + public static Dictionary DefaultConfiguration() { @@ -77,13 +95,48 @@ public sealed class DeskWebApplicationFactory : TestWebApplicationFactory base.ConfigureWebHost(builder); if (configuration is not null) { + foreach (var (key, value) in oidcKit.Configuration) + { + builder.UseSetting(key, value); + } + foreach (var (key, value) in configuration) { builder.UseSetting(key, value); } } - if (registerPorts) + if (realCredential) + { + // The production credential stays registered; the host signs in by cookie as the real site does. + builder.ConfigureServices(services => + { + services.Configure(options => + { + options.DefaultScheme = Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationDefaults.AuthenticationScheme; + options.DefaultAuthenticateScheme = Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationDefaults.AuthenticationScheme; + }); + services.RemoveAll(); + services.AddSingleton(Clock); + services.AddOpenIddict().AddServer(server => + { + server.RegisterScopes(OtherAudienceScope); + // After the issuer has prepared the access token's own principal, so the audience it carries is the one changed. + server.AddEventHandler(handler => + handler.UseInlineHandler(context => + { + if (context.AccessTokenPrincipal is { } principal && principal.HasScope(OtherAudienceScope)) + { + principal.SetAudiences(OtherAudience); + } + + return default; + }).SetOrder(OpenIddict.Server.OpenIddictServerHandlers.PrepareAccessTokenPrincipal.Descriptor.Order + 1)); + }); + services.AddSingleton(Door); + }); + } + else if (registerPorts) { builder.ConfigureServices(services => { @@ -115,6 +168,25 @@ public sealed class DeskWebApplicationFactory : TestWebApplicationFactory await db.SaveChangesAsync(); } + // Sets the membership of a member who registered through the real gate. + public async Task SetMembershipAsync(string identity, MembershipStatus status) + { + using var scope = Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var account = db.UserAccounts.Single(x => x.NormalizedBifrostIdentity == identity); + db.Memberships.Single(x => x.UserAccountId == account.Id).Status = status; + await db.SaveChangesAsync(); + } + + protected override void Dispose(bool disposing) + { + base.Dispose(disposing); + if (disposing) + { + oidcKit.Dispose(); + } + } + public string Token(string? subject, string? audience, params string[] scopes) => FakeDeskCredential.Encode(subject, audience, scopes); diff --git a/tests/Bifrost.Web.Tests/packages.lock.json b/tests/Bifrost.Web.Tests/packages.lock.json index e20d6e5..14c75c5 100644 --- a/tests/Bifrost.Web.Tests/packages.lock.json +++ b/tests/Bifrost.Web.Tests/packages.lock.json @@ -443,6 +443,14 @@ "Microsoft.IdentityModel.Abstractions": "8.19.2" } }, + "Microsoft.IdentityModel.Protocols": { + "type": "Transitive", + "resolved": "8.19.2", + "contentHash": "sGxSsSrZXNmca6D+jHH2rVRyo2nNRd/g4H9CFbPmLLq0xgoH1U0orLWE5minfijw7+zq49tBs7txenbfAErRoQ==", + "dependencies": { + "Microsoft.IdentityModel.Tokens": "8.19.2" + } + }, "Microsoft.IdentityModel.Tokens": { "type": "Transitive", "resolved": "8.19.2", @@ -548,6 +556,34 @@ "OpenIddict.Server": "7.7.1" } }, + "OpenIddict.Validation": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "09FOsvek+iSKrNbEehH2c1uBWSFurZk1U8vyTKk5aiaCV2bbj+j/68KRA/iHOgSe8qV3+0ZrquhmjFMpcojVSw==", + "dependencies": { + "Microsoft.Extensions.Logging": "8.0.1", + "Microsoft.IdentityModel.JsonWebTokens": "8.19.2", + "Microsoft.IdentityModel.Protocols": "8.19.2", + "OpenIddict.Abstractions": "7.7.1" + } + }, + "OpenIddict.Validation.AspNetCore": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "9Gpw13k6KRif6LCeIfzS5cfDPxzEvg5I4l8JKwMxNL4mqal8eT00T069RnuXuDY8CwOO43FLD55JY7Wh3an5cg==", + "dependencies": { + "OpenIddict.Validation": "7.7.1" + } + }, + "OpenIddict.Validation.ServerIntegration": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "tZd5oqlVHftWjo3gthwXRvDE09NGmOjo5yjyRH8W2q4Yw9Hntkfi70+T2ESFKJ++oqueQT4YG5ilyot/o0OBMA==", + "dependencies": { + "OpenIddict.Server": "7.7.1", + "OpenIddict.Validation": "7.7.1" + } + }, "System.Collections.Immutable": { "type": "Transitive", "resolved": "8.0.0", @@ -640,6 +676,8 @@ "Npgsql.EntityFrameworkCore.PostgreSQL": "[8.0.0, )", "OpenIddict.EntityFrameworkCore": "[7.7.1, )", "OpenIddict.Server.AspNetCore": "[7.7.1, )", + "OpenIddict.Validation.AspNetCore": "[7.7.1, )", + "OpenIddict.Validation.ServerIntegration": "[7.7.1, )", "System.Text.Json": "[8.0.5, )" } } -- 2.47.3 From 17e46df6903e76bdd68fbc7bae651a8a05f4cc61 Mon Sep 17 00:00:00 2001 From: Heimdall Date: Wed, 11 Nov 2026 17:53:33 +0000 Subject: [PATCH 3/5] Desk: the training-notice receipt gates the chat token only; Bifrost no longer masks answerer ids (Huginn's door withholds them), notes stay stripped Co-Authored-By: Claude Sonnet 5.5 --- .../Features/Desk/DeskRedaction.cs | 23 +++------ .../Features/Oidc/OidcEndpoints.cs | 16 +++--- .../Bifrost.Web.Tests/DeskCredentialTests.cs | 50 ++++++++++++++++++- tests/Bifrost.Web.Tests/DeskReadTests.cs | 12 ++--- 4 files changed, 68 insertions(+), 33 deletions(-) diff --git a/src/Bifrost.Web/Features/Desk/DeskRedaction.cs b/src/Bifrost.Web/Features/Desk/DeskRedaction.cs index 1a41f71..434ea2a 100644 --- a/src/Bifrost.Web/Features/Desk/DeskRedaction.cs +++ b/src/Bifrost.Web/Features/Desk/DeskRedaction.cs @@ -1,25 +1,14 @@ namespace Bifrost.Web.Features.Desk; -// What a caller who is not a decider of the Mind does not read, taken out at the door and nowhere else. For them -// every note is nil and every answerer is the sentinel; every key stays written (nil where absent, a string where a -// string is required), so the redacted body is still the contract's shape. Cards, outcomes, codes, counts and the -// number of answers are untouched; Huginn returns the full record and Bifrost drops and adds nothing. -// -// The sentinel is one fixed string for every answer: nothing derived from an account id (a hash or a per-reader -// alias would still let a reader link two answers to one person, or confirm a guess), and it is not a D-format -// UUID, so it cannot be mistaken for an account. The contract's `answerer` is a non-empty string and nothing more. +// What a caller who is not a decider of the Mind does not read, taken out at the door and nowhere else: every note is +// nil, with every key still written, so the body is still the contract's shape. Account ids are not Bifrost's to hide: +// Huginn's door withholds the decider ids and answerer ids from a non-decider and returns counts +// (eureka-substrate:ruling:desk-read-visibility-withheld-ids), and Bifrost cannot mask what it cannot read (the card is +// opaque to it). Cards, outcomes, codes, counts and the number of answers are untouched. public static class DeskRedaction { - public const string MaskedAnswerer = "masked"; - public static ForkEntry ForNonDecider(ForkEntry entry) => - entry with - { - Record = entry.Record with - { - Answers = [.. entry.Record.Answers.Select(a => a with { Note = null, Answerer = MaskedAnswerer })], - }, - }; + entry with { Record = entry.Record with { Answers = [.. entry.Record.Answers.Select(a => a with { Note = null })] } }; public static ForkList ForNonDecider(ForkList list) => list with { Forks = [.. list.Forks.Select(ForNonDecider)] }; } diff --git a/src/Bifrost.Web/Features/Oidc/OidcEndpoints.cs b/src/Bifrost.Web/Features/Oidc/OidcEndpoints.cs index ba67d8c..782f5f2 100644 --- a/src/Bifrost.Web/Features/Oidc/OidcEndpoints.cs +++ b/src/Bifrost.Web/Features/Oidc/OidcEndpoints.cs @@ -101,10 +101,11 @@ public static class OidcEndpoints _ => [Destinations.AccessToken] }); + // The training notice is the chat's: a desk token is not a chat token and needs no receipt (Meta's ruling, 2026-11-11). // Last, so a refusal that no agreement can cure (suspended, no identity, reserved) is never answered with an invitation to agree. // Not an OIDC error: the member is sent to the consent page with this request's resume path, and the parked request // (single use, ten minutes) is still there when they come back, because only an OpenIddict response spends it. - if (!await ConsentEndpoints.HasLiveReceiptAsync(db, account.Id, http.RequestAborted)) + if (!IsDeskToken(principal) && !await ConsentEndpoints.HasLiveReceiptAsync(db, account.Id, http.RequestAborted)) { return Results.Redirect(ConsentEndpoints.PathFor(http.Request.PathBase + http.Request.Path + http.Request.QueryString)); } @@ -112,6 +113,9 @@ public static class OidcEndpoints return Results.SignIn(principal, properties: null, OpenIddictScheme); } + /// A token whose scopes include a desk scope is a desk token. Only the chat token requires the training-notice receipt. + private static bool IsDeskToken(ClaimsPrincipal? principal) => principal is not null && principal.GetScopes().Any(DeskEndpoints.IsDeskScope); + private static async Task ExchangeAsync(HttpContext http, BifrostDbContext db) { // Only the authorization code grant is enabled, so OpenIddict has refused every other grant already. @@ -119,7 +123,7 @@ public static class OidcEndpoints // because the code outlives the authorize decision (an account suspended or withdrawn since gets no tokens). var result = await http.AuthenticateAsync(OpenIddictScheme); var subject = result.Principal?.GetClaim(Claims.Subject); - if (await ChatRefusalAsync(db, subject, http.RequestAborted) is { } refusal) + if (await ChatRefusalAsync(db, subject, http.RequestAborted, IsDeskToken(result.Principal)) is { } refusal) { return Deny(Errors.InvalidGrant, refusal); } @@ -136,7 +140,7 @@ public static class OidcEndpoints } // An access token outlives the authorize decision too: an account suspended since, or one whose agreement has gone, is no longer answered. - if (await ChatRefusalAsync(db, result.Principal.GetClaim(Claims.Subject), http.RequestAborted) is { } refusal) + if (await ChatRefusalAsync(db, result.Principal.GetClaim(Claims.Subject), http.RequestAborted, IsDeskToken(result.Principal)) is { } refusal) { return Deny(Errors.InsufficientAccess, refusal); } @@ -154,9 +158,9 @@ public static class OidcEndpoints /// /// The one reading of "this subject may still chat", shared by the token and userinfo endpoints and the logout reconciler: /// null when they may, else the description of why not. The account exists, its membership is not Suspended (absent, pending - /// or Active all chat), and it holds a live receipt for the notice version in force. Re-read on every call. + /// or Active all chat), and, for a chat token (not a desk token), it holds a live receipt for the notice version in force. Re-read on every call. /// - internal static async Task ChatRefusalAsync(BifrostDbContext db, string? subject, CancellationToken cancellationToken) + internal static async Task ChatRefusalAsync(BifrostDbContext db, string? subject, CancellationToken cancellationToken, bool deskToken = false) { if (!Guid.TryParse(subject, out var accountId)) { @@ -171,7 +175,7 @@ public static class OidcEndpoints return SuspendedDescription; } - return await ConsentEndpoints.HasLiveReceiptAsync(db, accountId, cancellationToken) ? null : ConsentRequiredDescription; + return deskToken || await ConsentEndpoints.HasLiveReceiptAsync(db, accountId, cancellationToken) ? null : ConsentRequiredDescription; } /// The account id of the subject when it is an Active member now, else null. The door to the fork desk (cookie surfaces check IsActiveMember); a chat-tier token does not pass it. Re-read on every call. diff --git a/tests/Bifrost.Web.Tests/DeskCredentialTests.cs b/tests/Bifrost.Web.Tests/DeskCredentialTests.cs index 3807278..ea45409 100644 --- a/tests/Bifrost.Web.Tests/DeskCredentialTests.cs +++ b/tests/Bifrost.Web.Tests/DeskCredentialTests.cs @@ -1,3 +1,4 @@ +using Bifrost.Web.Data; using System.Net; using System.Net.Http.Headers; using System.Net.Http.Json; @@ -118,6 +119,32 @@ public sealed class DeskCredentialTests : IDisposable Assert.Equal("answer", Assert.Single(factory.Door.Calls).Kind); } + [Fact] + public async Task A_desk_token_issues_for_a_member_with_no_training_notice_receipt_and_a_chat_token_does_not() + { + var (client, identity) = await SignedInAsync(); + await RemoveReceiptsAsync(identity, withdrawOnly: false); + + // Chat: sent to the consent page, no code. + var chat = NewRequest(OidcTestKit.ClientId, OidcTestKit.RedirectUri, "openid profile"); + var chatFinal = await DriveAsync(client, chat); + Assert.Equal(HttpStatusCode.Redirect, chatFinal.StatusCode); + Assert.DoesNotContain("code=", chatFinal.Headers.Location!.OriginalString); + + // Desk: a code, a token, and the desk answers it. + var request = NewRequest(DeskClient, DeskRedirect, "desk.read"); + var code = await CodeAsync(client, request); + var token = await ExchangeAsync(client, request, code, DeskClient, DeskSecret); + factory.Door.ListScript = (_, r) => new DeskListOutcome.Listed(new ForkList("gamecult.fork.list.v0", r.Mind, "s1", 0, 0, [], null)); + Assert.Equal(200, (await GetAsync(token)).Status); + + // A desk code survives the receipt going between authorize and the exchange; a suspended account still does not. + var later = NewRequest(DeskClient, DeskRedirect, "desk.read"); + var laterCode = await CodeAsync(client, later); + await RemoveReceiptsAsync(identity, withdrawOnly: true); + Assert.False(string.IsNullOrEmpty(await ExchangeAsync(client, later, laterCode, DeskClient, DeskSecret))); + } + [Fact] public async Task The_chat_client_can_neither_ask_for_a_desk_scope_nor_use_its_own_token_on_the_desk() { @@ -199,7 +226,7 @@ public sealed class DeskCredentialTests : IDisposable } [Fact] - public async Task A_member_who_is_not_a_decider_reads_masked_answerers_through_a_real_token() + public async Task A_member_who_is_not_a_decider_reads_no_notes_through_a_real_token_and_ids_pass_as_the_door_sent_them() { var (_, token) = await MemberWithTokenAsync("desk.read"); const string secretAnswerer = "0badc0de-0000-4000-8000-00000000000d"; // not on any decider list, so no card carries it @@ -213,7 +240,7 @@ public sealed class DeskCredentialTests : IDisposable var reply = await GetAsync(token); Assert.Equal(200, reply.Status); - Assert.DoesNotContain(secretAnswerer, reply.Text); + Assert.Contains(secretAnswerer, reply.Text); // Bifrost masks no id; Huginn's door withholds them from non-deciders Assert.DoesNotContain("private words", reply.Text); } @@ -246,6 +273,25 @@ public sealed class DeskCredentialTests : IDisposable return (client, identity); } + // Leaves the account with no live training-notice receipt: none at all, or each one withdrawn. + private async Task RemoveReceiptsAsync(string identity, bool withdrawOnly) + { + using var scope = factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var id = db.UserAccounts.Single(x => x.NormalizedBifrostIdentity == identity).Id; + var receipts = db.ConsentReceipts.Where(x => x.UserAccountId == id).ToList(); + if (withdrawOnly) + { + receipts.ForEach(r => r.WithdrawnAtUtc = DateTimeOffset.UtcNow); + } + else + { + db.ConsentReceipts.RemoveRange(receipts); + } + + await db.SaveChangesAsync(); + } + private async Task<(string Identity, string Token)> MemberWithTokenAsync(string scope) { var (client, identity) = await SignedInAsync(); diff --git a/tests/Bifrost.Web.Tests/DeskReadTests.cs b/tests/Bifrost.Web.Tests/DeskReadTests.cs index 9688051..e32410d 100644 --- a/tests/Bifrost.Web.Tests/DeskReadTests.cs +++ b/tests/Bifrost.Web.Tests/DeskReadTests.cs @@ -430,17 +430,13 @@ public sealed class DeskReadTests Assert.DoesNotContain("decider's own words", list); Assert.False(AnyNote(JsonNode.Parse(read))); Assert.False(AnyNote(JsonNode.Parse(list))); - // No answerer id of any answer reaches a non-decider, and every answer says the same thing instead. - var readMasked = JsonNode.Parse(read)!["record"]!["answers"]!.AsArray().Select(a => (string?)a!["answerer"]); - Assert.Equal(4, readMasked.Count()); - Assert.All(readMasked, answerer => Assert.Equal("masked", answerer)); - var masked = JsonNode.Parse(list)!["forks"]!.AsArray().SelectMany(f => f!["record"]!["answers"]!.AsArray()).Select(a => (string?)a!["answerer"]); - Assert.Equal(8, masked.Count()); - Assert.All(masked, answerer => Assert.Equal("masked", answerer)); + // Bifrost masks no id: what the door sent for the answerers is what the reader gets (the door withholds ids from non-deciders). + var readAnswerers = JsonNode.Parse(read)!["record"]!["answers"]!.AsArray().Select(a => (string?)a!["answerer"]); + Assert.Equal(NotedEntry().Record.Answers.Select(x => x.Answerer), readAnswerers); // Outcomes and cards stay; the key stays written as nil, so the body is still the contract's shape. var expected = JsonSerializer.SerializeToNode(NotedEntry() with { - Record = NotedEntry().Record with { Answers = [.. NotedEntry().Record.Answers.Select(a => a with { Note = null, Answerer = DeskRedaction.MaskedAnswerer })] }, + Record = NotedEntry().Record with { Answers = [.. NotedEntry().Record.Answers.Select(a => a with { Note = null })] }, }, DeskJson.Options); Assert.True(DeskVectors.SameShape(JsonNode.Parse(read), expected)); var answers = JsonNode.Parse(read)!["record"]!["answers"]!.AsArray(); -- 2.47.3 From 80e1ae125e6e2f4e148805b6d50afba55c6114a2 Mon Sep 17 00:00:00 2001 From: Heimdall Date: Wed, 11 Nov 2026 18:17:57 +0000 Subject: [PATCH 4/5] Desk: a bearer in the query string or a form body authenticates nothing (Soul) Co-Authored-By: Claude Sonnet 5.5 --- src/Bifrost.Web/Features/Desk/DeskEndpoints.cs | 7 ++++++- tests/Bifrost.Web.Tests/DeskCredentialTests.cs | 15 +++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/Bifrost.Web/Features/Desk/DeskEndpoints.cs b/src/Bifrost.Web/Features/Desk/DeskEndpoints.cs index 6632d58..3c275ce 100644 --- a/src/Bifrost.Web/Features/Desk/DeskEndpoints.cs +++ b/src/Bifrost.Web/Features/Desk/DeskEndpoints.cs @@ -36,7 +36,12 @@ public static class DeskEndpoints .AddValidation(validation => { validation.UseLocalServer(); - validation.UseAspNetCore(); + // The bearer travels in the Authorization header only: a token in a URL lands in access logs. + validation.UseAspNetCore().Configure(options => + { + options.DisableAccessTokenExtractionFromQueryString = true; + options.DisableAccessTokenExtractionFromBodyForm = true; + }); }); services.TryAddSingleton(); } diff --git a/tests/Bifrost.Web.Tests/DeskCredentialTests.cs b/tests/Bifrost.Web.Tests/DeskCredentialTests.cs index ea45409..f6205e4 100644 --- a/tests/Bifrost.Web.Tests/DeskCredentialTests.cs +++ b/tests/Bifrost.Web.Tests/DeskCredentialTests.cs @@ -104,6 +104,21 @@ public sealed class DeskCredentialTests : IDisposable Assert.Empty(factory.Door.Calls); } + [Fact] + public async Task A_bearer_in_the_query_string_or_a_form_body_authenticates_nothing() + { + var (_, token) = await MemberWithTokenAsync("desk.answer"); + DeskReplay.HoldVectorForks(factory.Door); + + var query = await factory.SendAsync( + "POST", DeskReplay.Target("answer", "gamecult-chat", null) + "?access_token=" + token, DeskReplay.AnswerBody(DeskReplay.ChatFork)); + var list = await factory.SendAsync("GET", DeskReplay.Target("list", "gamecult-chat", null) + "?access_token=" + token, null); + + Assert.Equal(401, query.Status); + Assert.Equal(401, list.Status); + Assert.Empty(factory.Door.Calls); + } + [Fact] public async Task A_desk_answer_token_reaches_the_answer_door_but_cannot_list() { -- 2.47.3 From 59f0915c03350d1ac23a00213cf691c0c52090e8 Mon Sep 17 00:00:00 2001 From: Heimdall Date: Thu, 12 Nov 2026 10:17:21 +0000 Subject: [PATCH 5/5] Desk credential: only an all-desk-scope token skips the consent receipt; comment says the door withholds ids Co-Authored-By: Claude Sonnet 5.5 --- src/Bifrost.Web/Features/Desk/DeskEndpoints.cs | 4 ++-- src/Bifrost.Web/Features/Oidc/OidcEndpoints.cs | 3 ++- tests/Bifrost.Web.Tests/DeskCredentialTests.cs | 13 +++++++++++++ 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/src/Bifrost.Web/Features/Desk/DeskEndpoints.cs b/src/Bifrost.Web/Features/Desk/DeskEndpoints.cs index 3c275ce..2108840 100644 --- a/src/Bifrost.Web/Features/Desk/DeskEndpoints.cs +++ b/src/Bifrost.Web/Features/Desk/DeskEndpoints.cs @@ -275,8 +275,8 @@ public static class DeskEndpoints } } - // A note is a decider's own words, and an answerer id names an account: only a caller on the admin-set list reads - // either (visibility ruling c; X3). + // A note is a decider's own words: only a caller on the admin-set list reads one. Bifrost strips notes itself; answerer + // and decider ids are withheld by Huginn's door (desk-read-visibility-withheld-ids), not here. private static bool IsDecider(DeskDoorContext context) => context.Deciders.Contains(context.Subject, StringComparer.Ordinal); diff --git a/src/Bifrost.Web/Features/Oidc/OidcEndpoints.cs b/src/Bifrost.Web/Features/Oidc/OidcEndpoints.cs index 782f5f2..a789882 100644 --- a/src/Bifrost.Web/Features/Oidc/OidcEndpoints.cs +++ b/src/Bifrost.Web/Features/Oidc/OidcEndpoints.cs @@ -114,7 +114,8 @@ public static class OidcEndpoints } /// A token whose scopes include a desk scope is a desk token. Only the chat token requires the training-notice receipt. - private static bool IsDeskToken(ClaimsPrincipal? principal) => principal is not null && principal.GetScopes().Any(DeskEndpoints.IsDeskScope); + // Only a token whose every scope is a desk scope skips the consent gate: a client that may hold chat scopes too cannot ride a desk scope past the receipt. + private static bool IsDeskToken(ClaimsPrincipal? principal) => principal is not null && principal.GetScopes() is { Length: > 0 } scopes && scopes.All(DeskEndpoints.IsDeskScope); private static async Task ExchangeAsync(HttpContext http, BifrostDbContext db) { diff --git a/tests/Bifrost.Web.Tests/DeskCredentialTests.cs b/tests/Bifrost.Web.Tests/DeskCredentialTests.cs index f6205e4..8a10318 100644 --- a/tests/Bifrost.Web.Tests/DeskCredentialTests.cs +++ b/tests/Bifrost.Web.Tests/DeskCredentialTests.cs @@ -160,6 +160,18 @@ public sealed class DeskCredentialTests : IDisposable Assert.False(string.IsNullOrEmpty(await ExchangeAsync(client, later, laterCode, DeskClient, DeskSecret))); } + [Fact] + public async Task A_desk_scope_mixed_with_a_chat_scope_does_not_skip_the_training_notice_receipt() + { + var (client, identity) = await SignedInAsync(); + await RemoveReceiptsAsync(identity, withdrawOnly: false); + + var mixed = NewRequest(DeskClient, DeskRedirect, "openid desk.read"); + var final = await DriveAsync(client, mixed); + Assert.Equal(HttpStatusCode.Redirect, final.StatusCode); + Assert.DoesNotContain("code=", final.Headers.Location!.OriginalString); + } + [Fact] public async Task The_chat_client_can_neither_ask_for_a_desk_scope_nor_use_its_own_token_on_the_desk() { @@ -337,6 +349,7 @@ public sealed class DeskCredentialTests : IDisposable Permissions.Endpoints.Token, Permissions.GrantTypes.AuthorizationCode, Permissions.ResponseTypes.Code, + Permissions.Prefixes.Scope + Scopes.OpenId, Permissions.Prefixes.Scope + DeskEndpoints.ReadScope, Permissions.Prefixes.Scope + DeskEndpoints.AnswerScope, Permissions.Prefixes.Scope + DeskWebApplicationFactory.OtherAudienceScope, -- 2.47.3