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/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..2108840 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,29 @@ 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();
+ // 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();
+ }
+
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.
@@ -167,7 +189,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 +209,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 +275,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: 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/Desk/DeskRedaction.cs b/src/Bifrost.Web/Features/Desk/DeskRedaction.cs
new file mode 100644
index 0000000..434ea2a
--- /dev/null
+++ b/src/Bifrost.Web/Features/Desk/DeskRedaction.cs
@@ -0,0 +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: 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 static ForkEntry ForNonDecider(ForkEntry entry) =>
+ 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/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..a789882 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],
@@ -93,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));
}
@@ -104,6 +113,10 @@ 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.
+ // 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)
{
// Only the authorization code grant is enabled, so OpenIddict has refused every other grant already.
@@ -111,7 +124,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);
}
@@ -128,7 +141,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);
}
@@ -146,9 +159,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))
{
@@ -163,7 +176,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/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..8a10318
--- /dev/null
+++ b/tests/Bifrost.Web.Tests/DeskCredentialTests.cs
@@ -0,0 +1,420 @@
+using Bifrost.Web.Data;
+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_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()
+ {
+ 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 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 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()
+ {
+ 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_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
+ 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.Contains(secretAnswerer, reply.Text); // Bifrost masks no id; Huginn's door withholds them from non-deciders
+ 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);
+ }
+
+ // 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();
+ 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 + Scopes.OpenId,
+ 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/DeskReadTests.cs b/tests/Bifrost.Web.Tests/DeskReadTests.cs
index 14e4af9..e32410d 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,6 +430,9 @@ public sealed class DeskReadTests
Assert.DoesNotContain("decider's own words", list);
Assert.False(AnyNote(JsonNode.Parse(read)));
Assert.False(AnyNote(JsonNode.Parse(list)));
+ // 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
{
@@ -436,6 +445,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 +470,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)));
}
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, )"
}
}