OIDC: refuse reserved localparts at authorize; gap list in oidc-issuer.md #49
4 changed files with 95 additions and 0 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -139,6 +139,45 @@ public sealed class OidcIssuerTests : IClassFixture<OidcWebApplicationFactory>
|
|||
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)]
|
||||
|
|
|
|||
|
|
@ -231,6 +231,33 @@ public sealed class OidcNoIdentityFactory : OidcWebApplicationFactory
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public sealed class OidcFixedIdentityFactory(string identity) : OidcWebApplicationFactory
|
||||
{
|
||||
protected override void ConfigureActors(IServiceCollection services)
|
||||
{
|
||||
services.RemoveAll<ICurrentBifrostActorAccessor>();
|
||||
services.AddScoped<ICurrentBifrostActorAccessor>(_ => new FixedIdentity(identity));
|
||||
}
|
||||
|
||||
private sealed class FixedIdentity(string identity) : ICurrentBifrostActorAccessor
|
||||
{
|
||||
public Task<CurrentBifrostActor> 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 }
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>An OIDC host whose public base URL differs from the origin its requests arrive on, so the issuer cannot be request-derived.</summary>
|
||||
public sealed class OidcForeignIssuerFactory : OidcWebApplicationFactory
|
||||
{
|
||||
|
|
|
|||
Loading…
Reference in a new issue