From c7edcd4f586b692b5ab2518a4e349bdb689af682 Mon Sep 17 00:00:00 2001 From: Heimdall Date: Wed, 11 Nov 2026 17:44:38 +0000 Subject: [PATCH] Desk: production IDeskCredential on OpenIddict validation against the local issuer OidcDeskCredential authenticates a bearer through the validation scheme only (never the default scheme; the cookie authenticates nothing on /api/desk and a bearer authenticates no cookie page) and returns sub, audience and scopes. The desk keeps judging audience, scope and per-request Active membership. The issuer registers desk.read/desk.answer and stamps audience bifrost-desk on tokens that carry one; client permissions are what limit scopes, so the MAS client can neither ask for them nor use its chat token on the desk. A Desk section without Bifrost:Oidc now fails startup in StartupConfigurationValidator. Packages: OpenIddict.Validation.AspNetCore and .ServerIntegration 7.7.1. No production Ratatoskr client is seeded. Co-Authored-By: Claude Sonnet 5.5 --- src/Bifrost.Web/Bifrost.Web.csproj | 2 + .../Features/Desk/DeskEndpoints.cs | 19 +- .../Features/Desk/OidcDeskCredential.cs | 28 ++ .../Features/Oidc/OidcEndpoints.cs | 8 + .../Shared/StartupConfigurationValidator.cs | 9 +- src/Bifrost.Web/Program.cs | 2 +- src/Bifrost.Web/packages.lock.json | 38 ++ .../Bifrost.Web.Tests/DeskCredentialTests.cs | 346 ++++++++++++++++++ tests/Bifrost.Web.Tests/DeskOptionsTests.cs | 14 +- tests/Bifrost.Web.Tests/DeskSurfaceTests.cs | 5 +- .../Support/DeskWebApplicationFactory.cs | 76 +++- tests/Bifrost.Web.Tests/packages.lock.json | 38 ++ 12 files changed, 576 insertions(+), 9 deletions(-) create mode 100644 src/Bifrost.Web/Features/Desk/OidcDeskCredential.cs create mode 100644 tests/Bifrost.Web.Tests/DeskCredentialTests.cs diff --git a/src/Bifrost.Web/Bifrost.Web.csproj b/src/Bifrost.Web/Bifrost.Web.csproj index 7cd14b3..f69b885 100644 --- a/src/Bifrost.Web/Bifrost.Web.csproj +++ b/src/Bifrost.Web/Bifrost.Web.csproj @@ -18,6 +18,8 @@ + + diff --git a/src/Bifrost.Web/Features/Desk/DeskEndpoints.cs b/src/Bifrost.Web/Features/Desk/DeskEndpoints.cs index 2f0fbf4..6632d58 100644 --- a/src/Bifrost.Web/Features/Desk/DeskEndpoints.cs +++ b/src/Bifrost.Web/Features/Desk/DeskEndpoints.cs @@ -4,6 +4,7 @@ using Bifrost.Web.Data; using Bifrost.Web.Features.Oidc; using Microsoft.AspNetCore.Cors.Infrastructure; using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Options; using Microsoft.Net.Http.Headers; @@ -22,8 +23,24 @@ public static class DeskEndpoints public const string AnswerScope = "desk.answer"; public const string CorsPolicy = "DeskCors"; - public static IServiceCollection AddBifrostDesk(this IServiceCollection services) + public static bool IsDeskScope(string scope) => scope is ReadScope or AnswerScope; + + // The desk's credential is OpenIddict validation against the local server, so it exists only where Bifrost:Oidc is + // configured. Without it nothing is registered here and startup fails in the validator, with its own message. + public static IServiceCollection AddBifrostDesk(this IServiceCollection services, bool oidcConfigured) { + if (oidcConfigured) + { + services.AddOpenIddict() + .AddServer(server => server.RegisterScopes(ReadScope, AnswerScope)) + .AddValidation(validation => + { + validation.UseLocalServer(); + validation.UseAspNetCore(); + }); + services.TryAddSingleton(); + } + services.AddSingleton(provider => new DeskMindRoutes(provider.GetRequiredService>().Value.Minds)); services.AddCors(); // Exactly the configured origin, no credentials, and only on the routes that require this policy. diff --git a/src/Bifrost.Web/Features/Desk/OidcDeskCredential.cs b/src/Bifrost.Web/Features/Desk/OidcDeskCredential.cs new file mode 100644 index 0000000..15c7805 --- /dev/null +++ b/src/Bifrost.Web/Features/Desk/OidcDeskCredential.cs @@ -0,0 +1,28 @@ +using Microsoft.AspNetCore.Authentication; +using OpenIddict.Abstractions; +using OpenIddict.Validation.AspNetCore; +using static OpenIddict.Abstractions.OpenIddictConstants; + +namespace Bifrost.Web.Features.Desk; + +// The production IDeskCredential: OpenIddict's validation handler against the local server, asked for by its own +// scheme name and no other. It authenticates only. The default scheme stays the cookie, so a bearer token reaches no +// cookie surface, and this credential never reads the cookie, so the session cookie authenticates nothing on the +// desk. Audience, scope and Active membership are the desk's judgments (DeskEndpoints), not this port's. +public sealed class OidcDeskCredential : IDeskCredential +{ + public async ValueTask AuthenticateAsync(HttpContext context, CancellationToken cancellationToken) + { + var result = await context.AuthenticateAsync(OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme); + if (!result.Succeeded || result.Principal is null) + { + return null; + } + + // A token may name several audiences; the desk's own is reported when present so the desk judges it, else the + // first (or none). + var audiences = result.Principal.GetAudiences(); + var audience = audiences.Contains(DeskEndpoints.Audience) ? DeskEndpoints.Audience : audiences.FirstOrDefault(); + return new DeskToken(result.Principal.GetClaim(Claims.Subject), audience, [.. result.Principal.GetScopes()]); + } +} diff --git a/src/Bifrost.Web/Features/Oidc/OidcEndpoints.cs b/src/Bifrost.Web/Features/Oidc/OidcEndpoints.cs index 41ae175..ba67d8c 100644 --- a/src/Bifrost.Web/Features/Oidc/OidcEndpoints.cs +++ b/src/Bifrost.Web/Features/Oidc/OidcEndpoints.cs @@ -2,6 +2,7 @@ using System.Security.Claims; using Microsoft.AspNetCore; using Bifrost.Web.Data; using Bifrost.Web.Domain; +using Bifrost.Web.Features.Desk; using Bifrost.Web.Features.Membership; using Bifrost.Web.Features.Registration; using Bifrost.Web.Features.Shared; @@ -85,6 +86,13 @@ public static class OidcEndpoints var principal = new ClaimsPrincipal(identity); principal.SetScopes(request.GetScopes()); + // A desk scope is what makes a token a desk token: its audience is the desk's. The client's permissions already + // decided whether it may ask for the scope (the MAS client may not), so this branch is reached only by one that may. + if (request.GetScopes().Any(DeskEndpoints.IsDeskScope)) + { + principal.SetResources(DeskEndpoints.Audience); + } + principal.SetDestinations(claim => claim.Type switch { Claims.Subject => [Destinations.AccessToken, Destinations.IdentityToken], diff --git a/src/Bifrost.Web/Features/Shared/StartupConfigurationValidator.cs b/src/Bifrost.Web/Features/Shared/StartupConfigurationValidator.cs index 3181450..a5d0752 100644 --- a/src/Bifrost.Web/Features/Shared/StartupConfigurationValidator.cs +++ b/src/Bifrost.Web/Features/Shared/StartupConfigurationValidator.cs @@ -15,7 +15,8 @@ public sealed class StartupConfigurationValidator( IOptions hostOptions, IOptions conceptVoteOptions, IOptions deskOptions, - IOptions registrationOptions) + IOptions registrationOptions, + OidcIssuerOptions? oidcOptions = null) { public void Validate() { @@ -149,6 +150,12 @@ public sealed class StartupConfigurationValidator( // mis-attribute answers. Account existence is a request-time question, not a startup one. private void ValidateDesk(DeskOptions desk, List failures) { + // The desk's bearer is validated against Bifrost's own issuer: without Bifrost:Oidc there is no credential to serve with. + if (oidcOptions is null && configuration.GetSection(DeskOptions.SectionName).Exists()) + { + failures.Add("Bifrost:Oidc must be configured when the Desk section exists: the desk validates its bearer tokens against Bifrost's own issuer."); + } + if (!desk.IsConfigured) { if (configuration.GetSection(DeskOptions.SectionName).Exists()) diff --git a/src/Bifrost.Web/Program.cs b/src/Bifrost.Web/Program.cs index 83eb76e..c7e4587 100644 --- a/src/Bifrost.Web/Program.cs +++ b/src/Bifrost.Web/Program.cs @@ -267,7 +267,7 @@ if (oidcOptions is not null) var deskConfigured = builder.Configuration.GetSection(DeskOptions.SectionName).Exists(); if (deskConfigured) { - builder.Services.AddBifrostDesk(); + builder.Services.AddBifrostDesk(oidcConfigured: oidcOptions is not null); } var app = builder.Build(); diff --git a/src/Bifrost.Web/packages.lock.json b/src/Bifrost.Web/packages.lock.json index bd15130..fc903ba 100644 --- a/src/Bifrost.Web/packages.lock.json +++ b/src/Bifrost.Web/packages.lock.json @@ -75,6 +75,25 @@ "OpenIddict.Server": "7.7.1" } }, + "OpenIddict.Validation.AspNetCore": { + "type": "Direct", + "requested": "[7.7.1, )", + "resolved": "7.7.1", + "contentHash": "9Gpw13k6KRif6LCeIfzS5cfDPxzEvg5I4l8JKwMxNL4mqal8eT00T069RnuXuDY8CwOO43FLD55JY7Wh3an5cg==", + "dependencies": { + "OpenIddict.Validation": "7.7.1" + } + }, + "OpenIddict.Validation.ServerIntegration": { + "type": "Direct", + "requested": "[7.7.1, )", + "resolved": "7.7.1", + "contentHash": "tZd5oqlVHftWjo3gthwXRvDE09NGmOjo5yjyRH8W2q4Yw9Hntkfi70+T2ESFKJ++oqueQT4YG5ilyot/o0OBMA==", + "dependencies": { + "OpenIddict.Server": "7.7.1", + "OpenIddict.Validation": "7.7.1" + } + }, "System.Text.Json": { "type": "Direct", "requested": "[8.0.5, )", @@ -269,6 +288,14 @@ "Microsoft.IdentityModel.Abstractions": "8.19.2" } }, + "Microsoft.IdentityModel.Protocols": { + "type": "Transitive", + "resolved": "8.19.2", + "contentHash": "sGxSsSrZXNmca6D+jHH2rVRyo2nNRd/g4H9CFbPmLLq0xgoH1U0orLWE5minfijw7+zq49tBs7txenbfAErRoQ==", + "dependencies": { + "Microsoft.IdentityModel.Tokens": "8.19.2" + } + }, "Microsoft.IdentityModel.Tokens": { "type": "Transitive", "resolved": "8.19.2", @@ -323,6 +350,17 @@ "OpenIddict.Abstractions": "7.7.1" } }, + "OpenIddict.Validation": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "09FOsvek+iSKrNbEehH2c1uBWSFurZk1U8vyTKk5aiaCV2bbj+j/68KRA/iHOgSe8qV3+0ZrquhmjFMpcojVSw==", + "dependencies": { + "Microsoft.Extensions.Logging": "8.0.1", + "Microsoft.IdentityModel.JsonWebTokens": "8.19.2", + "Microsoft.IdentityModel.Protocols": "8.19.2", + "OpenIddict.Abstractions": "7.7.1" + } + }, "System.CodeDom": { "type": "Transitive", "resolved": "4.4.0", diff --git a/tests/Bifrost.Web.Tests/DeskCredentialTests.cs b/tests/Bifrost.Web.Tests/DeskCredentialTests.cs new file mode 100644 index 0000000..3807278 --- /dev/null +++ b/tests/Bifrost.Web.Tests/DeskCredentialTests.cs @@ -0,0 +1,346 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using Bifrost.Web.Domain; +using Bifrost.Web.Features.Desk; +using Bifrost.Web.Tests.Support; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.IdentityModel.Tokens; +using OpenIddict.Abstractions; +using static OpenIddict.Abstractions.OpenIddictConstants; + +namespace Bifrost.Web.Tests; + +// The production desk credential, on real tokens: the test host's own issuer mints them through the code flow, and the +// OpenIddict validation handler (the production IDeskCredential) judges them. Only the door is faked. +public sealed class DeskCredentialTests : IDisposable +{ + private const string DeskClient = "ratatoskr-test"; + private const string DeskRedirect = "https://ratatoskr.example.test/callback"; + private const string DeskSecret = "ratatoskr-test-secret"; + private static readonly Uri Origin = new("https://localhost"); + private static readonly string ListTarget = "/api/desk/gamecult-chat/forks"; + + private readonly DeskWebApplicationFactory factory = new(realCredential: true); + + public void Dispose() => factory.Dispose(); + + [Fact] + public async Task A_valid_desk_read_token_lists_through_the_door_and_the_door_is_asked_once() + { + var (_, token) = await MemberWithTokenAsync("desk.read"); + factory.Door.ListScript = (_, r) => new DeskListOutcome.Listed(new ForkList("gamecult.fork.list.v0", r.Mind, "s1", 0, 0, [], null)); + + var reply = await GetAsync(token); + + Assert.Equal(200, reply.Status); + Assert.Equal("list", Assert.Single(factory.Door.Calls).Kind); + } + + [Fact] + public async Task No_token_is_401_and_the_door_is_never_asked() + { + await MemberWithTokenAsync("desk.read"); + + var reply = await GetAsync(null); + + Assert.Equal(401, reply.Status); + Assert.Equal("unauthenticated", Code(reply)); + Assert.Empty(factory.Door.Calls); + } + + [Fact] + public async Task A_tampered_token_is_401() + { + var (_, token) = await MemberWithTokenAsync("desk.read"); + var middle = token.Length / 2; + var tampered = token[..middle] + (token[middle] == 'A' ? 'B' : 'A') + token[(middle + 1)..]; + + Assert.Equal(401, (await GetAsync(tampered)).Status); + Assert.Equal(401, (await GetAsync(token + "x")).Status); + Assert.Equal(401, (await GetAsync("not-a-token")).Status); + Assert.Empty(factory.Door.Calls); + } + + [Fact] + public async Task An_expired_token_is_401() + { + var (_, token) = await MemberWithTokenAsync("desk.read"); + Assert.Equal(200, (await GetAsync(token)).Status); + + factory.Clock.Advance(TimeSpan.FromHours(3)); + + Assert.Equal(401, (await GetAsync(token)).Status); + } + + [Fact] + public async Task A_token_for_another_audience_is_401() + { + var (_, token) = await MemberWithTokenAsync($"desk.read {DeskWebApplicationFactory.OtherAudienceScope}"); + + var reply = await GetAsync(token); + + Assert.Equal(401, reply.Status); + Assert.Empty(factory.Door.Calls); + } + + [Fact] + public async Task A_desk_read_token_cannot_answer_and_the_door_is_never_called() + { + var (_, token) = await MemberWithTokenAsync("desk.read"); + + var reply = await factory.SendAsync( + "POST", DeskReplay.Target("answer", "gamecult-chat", null), DeskReplay.AnswerBody(DeskReplay.ChatFork), + configure: Bearer(token)); + + Assert.Equal(403, reply.Status); + Assert.Equal("insufficient_scope", Code(reply)); + Assert.Empty(factory.Door.Calls); + } + + [Fact] + public async Task A_desk_answer_token_reaches_the_answer_door_but_cannot_list() + { + var (_, token) = await MemberWithTokenAsync("desk.answer"); + DeskReplay.HoldVectorForks(factory.Door); + + var list = await GetAsync(token); + var answer = await factory.SendAsync( + "POST", DeskReplay.Target("answer", "gamecult-chat", null), DeskReplay.AnswerBody(DeskReplay.ChatFork), configure: Bearer(token)); + + Assert.Equal(403, list.Status); + Assert.Equal(200, answer.Status); + Assert.Equal("answer", Assert.Single(factory.Door.Calls).Kind); + } + + [Fact] + public async Task The_chat_client_can_neither_ask_for_a_desk_scope_nor_use_its_own_token_on_the_desk() + { + var (client, _) = await SignedInAsync(); + + // Asking for the scope: the issuer refuses, so no code and no token exist for the chat client to carry. + var request = NewRequest(OidcTestKit.ClientId, OidcTestKit.RedirectUri, "openid profile desk.read"); + var refused = await DriveAsync(client, request); + Assert.True(refused.StatusCode is HttpStatusCode.Redirect or HttpStatusCode.BadRequest, $"got {(int)refused.StatusCode}"); + var back = refused.Headers.Location?.ToString() ?? await refused.Content.ReadAsStringAsync(); + Assert.Contains("not allowed to use the specified scope", back); + Assert.DoesNotContain("code=", back); + + // The token chat does hold: validly signed by the same issuer, and no use at the desk. + var chat = NewRequest(OidcTestKit.ClientId, OidcTestKit.RedirectUri, "openid profile"); + var chatToken = await ExchangeAsync(client, chat, await CodeAsync(client, chat), OidcTestKit.ClientId, factory.OidcSecret); + Assert.Equal(401, (await GetAsync(chatToken)).Status); + var post = await factory.SendAsync( + "POST", DeskReplay.Target("answer", "gamecult-chat", null), DeskReplay.AnswerBody(DeskReplay.ChatFork), configure: Bearer(chatToken)); + Assert.Equal(401, post.Status); + Assert.Empty(factory.Door.Calls); + } + + [Fact] + public async Task The_session_cookie_alone_authenticates_nothing_on_the_desk() + { + // A signed-in Active member's real session cookie, sent by hand to the desk: it is no credential there. + using var bare = factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false, HandleCookies = false, BaseAddress = Origin }); + var identity = $"member-{Guid.NewGuid():N}"; + using var registered = await RegistrationKit.RegisterAsync(bare, identity); + Assert.Equal(HttpStatusCode.OK, registered.StatusCode); + await factory.SetMembershipAsync(identity, MembershipStatus.Active); + var cookie = string.Join("; ", registered.Headers.GetValues("Set-Cookie").Select(c => c.Split(';')[0])); + Assert.Contains("bifrost", cookie, StringComparison.OrdinalIgnoreCase); + + var list = await factory.SendAsync("GET", ListTarget, configure: http => http.Request.Headers.Cookie = cookie); + var answer = await factory.SendAsync( + "POST", DeskReplay.Target("answer", "gamecult-chat", null), DeskReplay.AnswerBody(DeskReplay.ChatFork), + configure: http => http.Request.Headers.Cookie = cookie); + + Assert.Equal(401, list.Status); + Assert.Equal(401, answer.Status); + Assert.Empty(factory.Door.Calls); + } + + [Fact] + public async Task A_bearer_token_does_not_authenticate_a_cookie_page() + { + var (_, token) = await MemberWithTokenAsync("desk.read desk.answer"); + using var bare = factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false, HandleCookies = false, BaseAddress = Origin }); + + foreach (var path in new[] { "/App", "/App/Members", "/api/sealed-motions" }) + { + using var message = new HttpRequestMessage(HttpMethod.Get, path); + message.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + using var response = await bare.SendAsync(message); + + Assert.True(response.StatusCode is HttpStatusCode.Redirect or HttpStatusCode.Found or HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden or HttpStatusCode.NotFound or HttpStatusCode.MethodNotAllowed, + $"{path} answered {(int)response.StatusCode} to a bearer"); + Assert.NotEqual(HttpStatusCode.OK, response.StatusCode); + } + } + + [Fact] + public async Task A_valid_token_whose_account_turns_non_active_is_refused_on_its_next_request_without_reissue() + { + var (identity, token) = await MemberWithTokenAsync("desk.read"); + Assert.Equal(200, (await GetAsync(token)).Status); + + await factory.SetMembershipAsync(identity, MembershipStatus.Suspended); + + var reply = await GetAsync(token); + Assert.Equal(403, reply.Status); + Assert.Equal("forbidden", Code(reply)); + Assert.Single(factory.Door.Calls); // only the first request got to the door + + await factory.SetMembershipAsync(identity, MembershipStatus.Active); + Assert.Equal(200, (await GetAsync(token)).Status); + } + + [Fact] + public async Task A_member_who_is_not_a_decider_reads_masked_answerers_through_a_real_token() + { + var (_, token) = await MemberWithTokenAsync("desk.read"); + const string secretAnswerer = "0badc0de-0000-4000-8000-00000000000d"; // not on any decider list, so no card carries it + var entry = DeskReplay.Entry(DeskReplay.Card(DeskReplay.ChatFork)); + var answer = new DeskAnswerDocument( + DeskSchemas.DeskAnswer, "n1", DeskReplay.ChatFork, "Counted", null, null, null, entry.Card.OptionsHash, "a", "private words", + "2026-11-12T19:00:00Z", "2026-11-12T19:00:01Z", false, secretAnswerer, "Steward", "r-1"); + entry = entry with { Record = entry.Record with { Answers = [answer] } }; + factory.Door.ListScript = (_, r) => new DeskListOutcome.Listed(new ForkList("gamecult.fork.list.v0", r.Mind, "s1", 1, 0, [entry], null)); + + var reply = await GetAsync(token); + + Assert.Equal(200, reply.Status); + Assert.DoesNotContain(secretAnswerer, reply.Text); + Assert.DoesNotContain("private words", reply.Text); + } + + // ---- helpers ---- + + private static Action Bearer(string token) => + http => http.Request.Headers.Authorization = "Bearer " + token; + + private Task GetAsync(string? token) => + factory.SendAsync("GET", ListTarget, configure: token is null ? null : Bearer(token)); + + private static string? Code(DeskReply reply) => (string?)JsonNode.Parse(reply.Body)!["code"]; + + private HttpClient NewClient() => factory.CreateClient(new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false, + HandleCookies = true, + BaseAddress = Origin, + }); + + // A registered, Active, agreed member holding the session cookie. + private async Task<(HttpClient Client, string Identity)> SignedInAsync() + { + var client = NewClient(); + var identity = $"member-{Guid.NewGuid():N}"; + using var registered = await RegistrationKit.RegisterAsync(client, identity); + Assert.Equal(HttpStatusCode.OK, registered.StatusCode); + await factory.SetMembershipAsync(identity, MembershipStatus.Active); + await SeedDeskClientAsync(); + return (client, identity); + } + + private async Task<(string Identity, string Token)> MemberWithTokenAsync(string scope) + { + var (client, identity) = await SignedInAsync(); + var request = NewRequest(DeskClient, DeskRedirect, scope); + var token = await ExchangeAsync(client, request, await CodeAsync(client, request), DeskClient, DeskSecret); + return (identity, token); + } + + // The test-only desk client: the production seeder creates none (Ratatoskr's redirect and host are a separate decision). + private async Task SeedDeskClientAsync() + { + using var scope = factory.Services.CreateScope(); + var manager = scope.ServiceProvider.GetRequiredService(); + if (await manager.FindByClientIdAsync(DeskClient) is not null) + { + return; + } + + await manager.CreateAsync(new OpenIddictApplicationDescriptor + { + ClientId = DeskClient, + ClientSecret = DeskSecret, + ClientType = ClientTypes.Confidential, + RedirectUris = { new Uri(DeskRedirect) }, + Permissions = + { + Permissions.Endpoints.Authorization, + Permissions.Endpoints.Token, + Permissions.GrantTypes.AuthorizationCode, + Permissions.ResponseTypes.Code, + Permissions.Prefixes.Scope + DeskEndpoints.ReadScope, + Permissions.Prefixes.Scope + DeskEndpoints.AnswerScope, + Permissions.Prefixes.Scope + DeskWebApplicationFactory.OtherAudienceScope, + }, + }); + } + + private sealed record AuthRequest(string ClientId, string RedirectUri, string Verifier, string State, string Path); + + private static AuthRequest NewRequest(string clientId, string redirectUri, string scope) + { + var verifier = Base64UrlEncoder.Encode(RandomNumberGenerator.GetBytes(48)); + var state = Base64UrlEncoder.Encode(RandomNumberGenerator.GetBytes(24)); + var challenge = Base64UrlEncoder.Encode(SHA256.HashData(Encoding.ASCII.GetBytes(verifier))); + var path = "/connect/authorize?response_type=code" + + $"&client_id={Uri.EscapeDataString(clientId)}&redirect_uri={Uri.EscapeDataString(redirectUri)}" + + $"&scope={Uri.EscapeDataString(scope)}&state={state}&code_challenge={challenge}&code_challenge_method=S256"; + return new AuthRequest(clientId, redirectUri, verifier, state, path); + } + + // Starts an authorize request and follows the provider's own redirects until the first response that is not one of them. + private static async Task DriveAsync(HttpClient client, AuthRequest request) + { + var response = await client.GetAsync(request.Path); + for (var hops = 0; hops < 4 && IsAuthorizeRedirect(response); hops++) + { + var next = response.Headers.Location!; + response.Dispose(); + response = await client.GetAsync(next.IsAbsoluteUri ? next.PathAndQuery : next.OriginalString); + } + + return response; + } + + private static bool IsAuthorizeRedirect(HttpResponseMessage response) => + response.StatusCode is HttpStatusCode.Redirect or HttpStatusCode.Found && + response.Headers.Location is { } location && + (location.IsAbsoluteUri ? location.AbsolutePath : location.OriginalString).StartsWith("/connect/authorize", StringComparison.Ordinal); + + private static async Task CodeAsync(HttpClient client, AuthRequest request) + { + var final = await DriveAsync(client, request); + Assert.True(final.StatusCode is HttpStatusCode.Redirect or HttpStatusCode.Found, $"expected a redirect to the client, got {(int)final.StatusCode}"); + var query = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(final.Headers.Location!.Query); + Assert.True(query.ContainsKey("code"), final.Headers.Location.ToString()); + return query["code"].ToString(); + } + + private async Task ExchangeAsync(HttpClient client, AuthRequest request, string code, string clientId, string secret) + { + var message = new HttpRequestMessage(HttpMethod.Post, "/connect/token") + { + Content = new FormUrlEncodedContent(new Dictionary + { + ["grant_type"] = "authorization_code", + ["code"] = code, + ["redirect_uri"] = request.RedirectUri, + ["code_verifier"] = request.Verifier, + }), + }; + message.Headers.Authorization = new AuthenticationHeaderValue("Basic", Convert.ToBase64String(Encoding.ASCII.GetBytes($"{clientId}:{secret}"))); + using var bare = NewClient(); + using var response = await bare.SendAsync(message); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync(); + return body.GetProperty("access_token").GetString()!; + } +} diff --git a/tests/Bifrost.Web.Tests/DeskOptionsTests.cs b/tests/Bifrost.Web.Tests/DeskOptionsTests.cs index 693950a..53863b6 100644 --- a/tests/Bifrost.Web.Tests/DeskOptionsTests.cs +++ b/tests/Bifrost.Web.Tests/DeskOptionsTests.cs @@ -32,7 +32,7 @@ public sealed class DeskOptionsTests return options; } - private static void Validate(Dictionary values, string environment = "Testing") + private static void Validate(Dictionary values, string environment = "Testing", bool oidc = true) { var configuration = new ConfigurationBuilder().AddInMemoryCollection(values).Build(); new StartupConfigurationValidator( @@ -45,7 +45,8 @@ public sealed class DeskOptionsTests Options.Create(new BifrostHostOptions()), Options.Create(new ConceptVoteOptions()), Options.Create(Bind(values)), - Options.Create(new RegistrationOptions())).Validate(); + Options.Create(new RegistrationOptions()), + oidc ? new OidcIssuerOptions() : null).Validate(); } private static void Refused(Dictionary values, string expectedFragment) @@ -65,6 +66,15 @@ public sealed class DeskOptionsTests Validate(Good()); } + [Fact] + public void A_desk_without_the_oidc_issuer_stops_startup_and_says_why() + { + var failure = Assert.Throws(() => Validate(Good(), oidc: false)); + Assert.Contains("Bifrost:Oidc must be configured when the Desk section exists", failure.Message); + + Validate([], oidc: false); // no desk, no need of an issuer + } + [Fact] public void No_desk_section_is_not_configured_and_passes() { diff --git a/tests/Bifrost.Web.Tests/DeskSurfaceTests.cs b/tests/Bifrost.Web.Tests/DeskSurfaceTests.cs index e1e5364..b4dbc56 100644 --- a/tests/Bifrost.Web.Tests/DeskSurfaceTests.cs +++ b/tests/Bifrost.Web.Tests/DeskSurfaceTests.cs @@ -36,13 +36,14 @@ public sealed class DeskSurfaceTests } [Fact] - public void A_configured_desk_without_its_ports_stops_startup() + public void A_configured_desk_without_its_door_stops_startup() { using var factory = new DeskWebApplicationFactory(registerPorts: false); var failure = Assert.ThrowsAny(() => factory.CreateClient()); - Assert.Contains("IDeskCredential", failure.ToString()); + // The credential is production wiring (OpenIddict validation); the door is the port a test or Huginn supplies. + Assert.Contains("IDeskDoor", failure.ToString()); } [Fact] diff --git a/tests/Bifrost.Web.Tests/Support/DeskWebApplicationFactory.cs b/tests/Bifrost.Web.Tests/Support/DeskWebApplicationFactory.cs index 450be51..c5c81ea 100644 --- a/tests/Bifrost.Web.Tests/Support/DeskWebApplicationFactory.cs +++ b/tests/Bifrost.Web.Tests/Support/DeskWebApplicationFactory.cs @@ -7,6 +7,8 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Features; using Microsoft.AspNetCore.TestHost; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using OpenIddict.Abstractions; namespace Bifrost.Web.Tests.Support; @@ -26,9 +28,17 @@ public sealed class DeskWebApplicationFactory : TestWebApplicationFactory private readonly Dictionary? configuration; private readonly bool registerPorts; + private readonly bool realCredential; - public DeskWebApplicationFactory(Dictionary? configuration = null, bool configured = true, bool registerPorts = true) + // A configured desk needs Bifrost:Oidc (its credential validates against the local issuer): generated keys per factory. + private readonly OidcTestKit oidcKit = new(); + + // The chat client's secret, for a test that holds the chat client's own token. + public string OidcSecret => oidcKit.ClientSecret; + + public DeskWebApplicationFactory(Dictionary? configuration = null, bool configured = true, bool registerPorts = true, bool realCredential = false) { + this.realCredential = realCredential; this.configuration = configured ? configuration ?? DefaultConfiguration() : null; this.registerPorts = registerPorts; } @@ -37,6 +47,14 @@ public sealed class DeskWebApplicationFactory : TestWebApplicationFactory public FakeDeskCredential Credential { get; } = new(); + // Moves time for a real-credential host, so an access token can be aged past its lifetime. + public OidcTestClock Clock { get; } = new(); + + // A test-only scope: a token issued with it is stamped for this audience instead of the desk's, by the test host's + // own issuer, so "a valid token for another audience" is a real token. + public const string OtherAudienceScope = "test.other_audience"; + public const string OtherAudience = "bifrost-votes"; + public static Dictionary DefaultConfiguration() { @@ -77,13 +95,48 @@ public sealed class DeskWebApplicationFactory : TestWebApplicationFactory base.ConfigureWebHost(builder); if (configuration is not null) { + foreach (var (key, value) in oidcKit.Configuration) + { + builder.UseSetting(key, value); + } + foreach (var (key, value) in configuration) { builder.UseSetting(key, value); } } - if (registerPorts) + if (realCredential) + { + // The production credential stays registered; the host signs in by cookie as the real site does. + builder.ConfigureServices(services => + { + services.Configure(options => + { + options.DefaultScheme = Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationDefaults.AuthenticationScheme; + options.DefaultAuthenticateScheme = Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationDefaults.AuthenticationScheme; + }); + services.RemoveAll(); + services.AddSingleton(Clock); + services.AddOpenIddict().AddServer(server => + { + server.RegisterScopes(OtherAudienceScope); + // After the issuer has prepared the access token's own principal, so the audience it carries is the one changed. + server.AddEventHandler(handler => + handler.UseInlineHandler(context => + { + if (context.AccessTokenPrincipal is { } principal && principal.HasScope(OtherAudienceScope)) + { + principal.SetAudiences(OtherAudience); + } + + return default; + }).SetOrder(OpenIddict.Server.OpenIddictServerHandlers.PrepareAccessTokenPrincipal.Descriptor.Order + 1)); + }); + services.AddSingleton(Door); + }); + } + else if (registerPorts) { builder.ConfigureServices(services => { @@ -115,6 +168,25 @@ public sealed class DeskWebApplicationFactory : TestWebApplicationFactory await db.SaveChangesAsync(); } + // Sets the membership of a member who registered through the real gate. + public async Task SetMembershipAsync(string identity, MembershipStatus status) + { + using var scope = Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var account = db.UserAccounts.Single(x => x.NormalizedBifrostIdentity == identity); + db.Memberships.Single(x => x.UserAccountId == account.Id).Status = status; + await db.SaveChangesAsync(); + } + + protected override void Dispose(bool disposing) + { + base.Dispose(disposing); + if (disposing) + { + oidcKit.Dispose(); + } + } + public string Token(string? subject, string? audience, params string[] scopes) => FakeDeskCredential.Encode(subject, audience, scopes); diff --git a/tests/Bifrost.Web.Tests/packages.lock.json b/tests/Bifrost.Web.Tests/packages.lock.json index e20d6e5..14c75c5 100644 --- a/tests/Bifrost.Web.Tests/packages.lock.json +++ b/tests/Bifrost.Web.Tests/packages.lock.json @@ -443,6 +443,14 @@ "Microsoft.IdentityModel.Abstractions": "8.19.2" } }, + "Microsoft.IdentityModel.Protocols": { + "type": "Transitive", + "resolved": "8.19.2", + "contentHash": "sGxSsSrZXNmca6D+jHH2rVRyo2nNRd/g4H9CFbPmLLq0xgoH1U0orLWE5minfijw7+zq49tBs7txenbfAErRoQ==", + "dependencies": { + "Microsoft.IdentityModel.Tokens": "8.19.2" + } + }, "Microsoft.IdentityModel.Tokens": { "type": "Transitive", "resolved": "8.19.2", @@ -548,6 +556,34 @@ "OpenIddict.Server": "7.7.1" } }, + "OpenIddict.Validation": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "09FOsvek+iSKrNbEehH2c1uBWSFurZk1U8vyTKk5aiaCV2bbj+j/68KRA/iHOgSe8qV3+0ZrquhmjFMpcojVSw==", + "dependencies": { + "Microsoft.Extensions.Logging": "8.0.1", + "Microsoft.IdentityModel.JsonWebTokens": "8.19.2", + "Microsoft.IdentityModel.Protocols": "8.19.2", + "OpenIddict.Abstractions": "7.7.1" + } + }, + "OpenIddict.Validation.AspNetCore": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "9Gpw13k6KRif6LCeIfzS5cfDPxzEvg5I4l8JKwMxNL4mqal8eT00T069RnuXuDY8CwOO43FLD55JY7Wh3an5cg==", + "dependencies": { + "OpenIddict.Validation": "7.7.1" + } + }, + "OpenIddict.Validation.ServerIntegration": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "tZd5oqlVHftWjo3gthwXRvDE09NGmOjo5yjyRH8W2q4Yw9Hntkfi70+T2ESFKJ++oqueQT4YG5ilyot/o0OBMA==", + "dependencies": { + "OpenIddict.Server": "7.7.1", + "OpenIddict.Validation": "7.7.1" + } + }, "System.Collections.Immutable": { "type": "Transitive", "resolved": "8.0.0", @@ -640,6 +676,8 @@ "Npgsql.EntityFrameworkCore.PostgreSQL": "[8.0.0, )", "OpenIddict.EntityFrameworkCore": "[7.7.1, )", "OpenIddict.Server.AspNetCore": "[7.7.1, )", + "OpenIddict.Validation.AspNetCore": "[7.7.1, )", + "OpenIddict.Validation.ServerIntegration": "[7.7.1, )", "System.Text.Json": "[8.0.5, )" } }