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:
parent
fb9fa6c91f
commit
c7edcd4f58
12 changed files with 576 additions and 9 deletions
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
28
src/Bifrost.Web/Features/Desk/OidcDeskCredential.cs
Normal file
28
src/Bifrost.Web/Features/Desk/OidcDeskCredential.cs
Normal 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()]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
346
tests/Bifrost.Web.Tests/DeskCredentialTests.cs
Normal file
346
tests/Bifrost.Web.Tests/DeskCredentialTests.cs
Normal 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()!;
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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, )"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue