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 <noreply@anthropic.com>
This commit is contained in:
Heimdall 2026-11-11 17:44:38 +00:00
commit c7edcd4f58
12 changed files with 576 additions and 9 deletions

View file

@ -18,6 +18,8 @@
<PackageReference Include="System.Text.Json" Version="8.0.5" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="8.0.1" />
<PackageReference Include="OpenIddict.Server.AspNetCore" Version="7.7.1" />
<PackageReference Include="OpenIddict.Validation.AspNetCore" Version="7.7.1" />
<PackageReference Include="OpenIddict.Validation.ServerIntegration" Version="7.7.1" />
<PackageReference Include="OpenIddict.EntityFrameworkCore" Version="7.7.1" />
</ItemGroup>

View file

@ -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<IDeskCredential, OidcDeskCredential>();
}
services.AddSingleton(provider => new DeskMindRoutes(provider.GetRequiredService<IOptions<DeskOptions>>().Value.Minds));
services.AddCors();
// Exactly the configured origin, no credentials, and only on the routes that require this policy.

View file

@ -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<DeskToken?> 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()]);
}
}

View file

@ -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],

View file

@ -15,7 +15,8 @@ public sealed class StartupConfigurationValidator(
IOptions<BifrostHostOptions> hostOptions,
IOptions<ConceptVoteOptions> conceptVoteOptions,
IOptions<DeskOptions> deskOptions,
IOptions<RegistrationOptions> registrationOptions)
IOptions<RegistrationOptions> 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<string> 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())

View file

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

View file

@ -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",

View file

@ -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<Microsoft.AspNetCore.Http.HttpContext> Bearer(string token) =>
http => http.Request.Headers.Authorization = "Bearer " + token;
private Task<DeskReply> 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<IOpenIddictApplicationManager>();
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<HttpResponseMessage> 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<string> 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<string> ExchangeAsync(HttpClient client, AuthRequest request, string code, string clientId, string secret)
{
var message = new HttpRequestMessage(HttpMethod.Post, "/connect/token")
{
Content = new FormUrlEncodedContent(new Dictionary<string, string>
{
["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<JsonElement>();
return body.GetProperty("access_token").GetString()!;
}
}

View file

@ -32,7 +32,7 @@ public sealed class DeskOptionsTests
return options;
}
private static void Validate(Dictionary<string, string?> values, string environment = "Testing")
private static void Validate(Dictionary<string, string?> 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<string, string?> 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<InvalidOperationException>(() => 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()
{

View file

@ -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<Exception>(() => 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]

View file

@ -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<string, string?>? configuration;
private readonly bool registerPorts;
private readonly bool realCredential;
public DeskWebApplicationFactory(Dictionary<string, string?>? 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<string, string?>? 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<string, string?> 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<Microsoft.AspNetCore.Authentication.AuthenticationOptions>(options =>
{
options.DefaultScheme = Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationDefaults.AuthenticationScheme;
options.DefaultAuthenticateScheme = Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationDefaults.AuthenticationScheme;
});
services.RemoveAll<TimeProvider>();
services.AddSingleton<TimeProvider>(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<OpenIddict.Server.OpenIddictServerEvents.ProcessSignInContext>(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<IDeskDoor>(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<BifrostDbContext>();
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);

View file

@ -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, )"
}
}