From 153b4e523849196a3593ed204ef83c9c318a7414 Mon Sep 17 00:00:00 2001 From: Heimdall Date: Wed, 11 Nov 2026 06:35:49 +0000 Subject: [PATCH] OIDC: authorize refuses an account whose identity is a reserved localpart; docs list what the issuer still lacks Co-Authored-By: Claude Sonnet 5.5 --- docs/oidc-issuer.md | 20 ++++++++++ .../Features/Oidc/OidcEndpoints.cs | 9 +++++ tests/Bifrost.Web.Tests/OidcIssuerTests.cs | 39 +++++++++++++++++++ .../Bifrost.Web.Tests/Support/OidcTestKit.cs | 27 +++++++++++++ 4 files changed, 95 insertions(+) diff --git a/docs/oidc-issuer.md b/docs/oidc-issuer.md index a7f59a4..8abc2e5 100644 --- a/docs/oidc-issuer.md +++ b/docs/oidc-issuer.md @@ -70,6 +70,10 @@ The authorize endpoint reads the cookie session and the same actor facts the `/A `error_description=The account is not an active GameCult member.` - Signed in, `Active`, but `NormalizedBifrostIdentity` empty: `error=access_denied` with `error_description=The account has no Bifrost identity.` +- Signed in, `Active`, but the identity is a reserved localpart (`BifrostIdentityService.IsReserved`: the 13 Synapse + accounts and the system names, the same 28 as MAS `banned_usernames`): `error=access_denied` with + `error_description=The account's Bifrost identity is reserved.` Creation refuses these names, but a row minted before + that refusal may hold one and there is no rename path, so the issuer never vouches for a Mind's or a system's localpart. - Signed in and `Active`: a code is issued. - Token endpoint: membership is read again; an account no longer `Active` gets `invalid_grant` and no tokens. @@ -107,6 +111,22 @@ Configure MAS's upstream provider with the issuer above, the client id and secre from `preferred_username`. MAS mints the Matrix localpart on first sign-in, so the claim must never change for an account. +## Not built + +Checked against the Window B list (2026-11-11). Present and tested: discovery, JWKS, authorize/token/userinfo, PKCE S256, +the one client, claims to a Matrix-legal localpart (`[a-z0-9._-]`, 3-80, lower case), reserved names at creation and now at +issuance, back-channel logout. Not present: + +- **Rate limits and pruning.** No limiter sits on `/connect/*`, and expired OpenIddict rows (parked authorize requests, + tokens, authorizations) are never deleted. The authorize endpoint is anonymous and its client id and redirect URI are + public, so a caller can park rows without bound. Needs a budget (`FixedWindowBudget`) and a prune. +- **A brake.** The Bifrost process has none; the deploy brake belongs to Idunn. The only off switch is removing + `Bifrost:Oidc` and restarting, or membership leaving `Active` (the reconciler then logs the person out). +- **Non-member sign-in.** The gate is `Active` membership, so an account with no membership gets `access_denied`. A "Users" + tier for chat registration, with no vote, is a policy choice not yet made; nothing here writes membership or votes. +- **MAS upstream block.** `gamecult-ops` `chat/matrix-auth/config.yaml` has no `upstream_oauth2` (provider ULID, + `on_conflict: fail`, `on_backchannel_logout: logout_all`, the redirect URI above); that lives in the ops repo. + ## Remaining scar Heimdall still mints its own account id in code (`src/app.ts` `createAccount`). That id is an authenticator handle diff --git a/src/Bifrost.Web/Features/Oidc/OidcEndpoints.cs b/src/Bifrost.Web/Features/Oidc/OidcEndpoints.cs index 35f0297..7fce895 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.Membership; using Bifrost.Web.Features.Shared; using Microsoft.EntityFrameworkCore; using Microsoft.AspNetCore.Authentication; @@ -21,6 +22,7 @@ public static class OidcEndpoints { public const string NotActiveDescription = "The account is not an active GameCult member."; public const string NoIdentityDescription = "The account has no Bifrost identity."; + public const string ReservedIdentityDescription = "The account's Bifrost identity is reserved."; private const string OpenIddictScheme = OpenIddictServerAspNetCoreDefaults.AuthenticationScheme; private const string IdentityAuthenticationType = "Bifrost.Oidc"; @@ -58,6 +60,13 @@ public static class OidcEndpoints return Deny(Errors.AccessDenied, NoIdentityDescription); } + // The identity becomes the Matrix localpart. Creation refuses reserved names (BifrostIdentityService), but a row minted + // before that refusal may hold one, and there is no rename path: the issuer never vouches for a Mind's or a system's localpart. + if (BifrostIdentityService.IsReserved(account.NormalizedBifrostIdentity)) + { + return Deny(Errors.AccessDenied, ReservedIdentityDescription); + } + var identity = new ClaimsIdentity(IdentityAuthenticationType, Claims.Name, Claims.Role); identity.SetClaim(Claims.Subject, account.Id.ToString("D")); identity.SetClaim(Claims.PreferredUsername, account.NormalizedBifrostIdentity); diff --git a/tests/Bifrost.Web.Tests/OidcIssuerTests.cs b/tests/Bifrost.Web.Tests/OidcIssuerTests.cs index 08e8595..30098f5 100644 --- a/tests/Bifrost.Web.Tests/OidcIssuerTests.cs +++ b/tests/Bifrost.Web.Tests/OidcIssuerTests.cs @@ -139,6 +139,45 @@ public sealed class OidcIssuerTests : IClassFixture Assert.False(redirect.ContainsKey("code")); } + [Theory] + [InlineData("meta")] // an operator's or a Mind's Matrix localpart + [InlineData("eureka")] + [InlineData("admin")] + [InlineData("system")] // a system name + [InlineData("Heimdall")] // the row's casing does not matter + public async Task An_active_member_whose_identity_is_a_reserved_localpart_gets_no_code(string reserved) + { + using var factory = new OidcFixedIdentityFactory(reserved); + using var client = factory.CreateClient(ClientOptions()); + await RegisterAsync(client, $"cookie-{Guid.NewGuid():N}"); // any session: the actor is the fixed account + var request = NewRequest(); + + var final = await DriveAsync(client, request); + + var redirect = AssertRedirectToClient(final, request.State); + Assert.Equal("access_denied", redirect["error"]); + Assert.Equal(Bifrost.Web.Features.Oidc.OidcEndpoints.ReservedIdentityDescription, redirect["error_description"]); + Assert.False(redirect.ContainsKey("code")); + } + + [Theory] + [InlineData("meta2")] // look-alikes are ordinary names + [InlineData("admins")] + [InlineData("kiko-fan")] + public async Task A_name_that_only_resembles_a_reserved_localpart_is_given_a_code(string ordinary) + { + using var factory = new OidcFixedIdentityFactory(ordinary); + using var client = factory.CreateClient(ClientOptions()); + await RegisterAsync(client, $"cookie-{Guid.NewGuid():N}"); + var request = NewRequest(); + + var final = await DriveAsync(client, request); + + var redirect = AssertRedirectToClient(final, request.State); + Assert.True(redirect.ContainsKey("code")); + Assert.False(redirect.ContainsKey("error")); + } + [Theory] [InlineData("unknown-client", OidcTestKit.RedirectUri, true)] [InlineData(OidcTestKit.ClientId, "https://evil.example.test/callback", true)] diff --git a/tests/Bifrost.Web.Tests/Support/OidcTestKit.cs b/tests/Bifrost.Web.Tests/Support/OidcTestKit.cs index 231cd33..0808d93 100644 --- a/tests/Bifrost.Web.Tests/Support/OidcTestKit.cs +++ b/tests/Bifrost.Web.Tests/Support/OidcTestKit.cs @@ -231,6 +231,33 @@ public sealed class OidcNoIdentityFactory : OidcWebApplicationFactory } } +/// +/// An OIDC host whose actor accessor reports an Active account holding the given Bifrost identity: how a row minted before +/// reserved names were refused at creation (or any identity the test needs) reaches the authorize endpoint, since +/// registration itself now refuses a reserved name. +/// +public sealed class OidcFixedIdentityFactory(string identity) : OidcWebApplicationFactory +{ + protected override void ConfigureActors(IServiceCollection services) + { + services.RemoveAll(); + services.AddScoped(_ => new FixedIdentity(identity)); + } + + private sealed class FixedIdentity(string identity) : ICurrentBifrostActorAccessor + { + public Task GetAsync(CancellationToken cancellationToken) => + Task.FromResult(new CurrentBifrostActor(true, new UserAccount + { + Id = Guid.NewGuid(), + BifrostIdentity = identity, + NormalizedBifrostIdentity = identity.ToLowerInvariant(), + DisplayName = "Fixed Identity", + Membership = new Bifrost.Web.Domain.Membership { Status = MembershipStatus.Active } + })); + } +} + /// An OIDC host whose public base URL differs from the origin its requests arrive on, so the issuer cannot be request-derived. public sealed class OidcForeignIssuerFactory : OidcWebApplicationFactory { -- 2.47.3