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();