From 971b3f76173498f572f29013babd913c664410ab Mon Sep 17 00:00:00 2001 From: Heimdall Date: Wed, 11 Nov 2026 07:59:50 +0000 Subject: [PATCH 1/2] OIDC issuer: chat tier for accounts without Active membership Co-Authored-By: Claude Sonnet 5.5 --- docs/oidc-issuer.md | 28 +++---- .../Features/Oidc/OidcEndpoints.cs | 43 +++++++---- .../Features/Oidc/OidcLogoutReconciler.cs | 4 +- tests/Bifrost.Web.Tests/OidcIssuerTests.cs | 76 +++++++++++++++---- tests/Bifrost.Web.Tests/OidcLogoutTests.cs | 21 ++++- .../Bifrost.Web.Tests/Support/OidcTestKit.cs | 26 ++++++- 6 files changed, 149 insertions(+), 49 deletions(-) diff --git a/docs/oidc-issuer.md b/docs/oidc-issuer.md index cb2ee08..06dff5f 100644 --- a/docs/oidc-issuer.md +++ b/docs/oidc-issuer.md @@ -68,25 +68,29 @@ The authorize endpoint reads the cookie session and the same actor facts the `/A - No session: challenge to `/auth/sign-in`, which resumes the original request after sign-in. OpenIddict parks the full request (its request token, in the OpenIddict token table, expiring after ten minutes, spent when used) and the return path carries only a short `request_uri`, so `LocalReturnPath.MaxLength` stays 256. -- Signed in, membership not `Active`: redirect to the client's `redirect_uri` with `error=access_denied` and - `error_description=The account is not an active GameCult member.` -- Signed in, `Active`, but `NormalizedBifrostIdentity` empty: `error=access_denied` with +- Signed in, membership `Suspended`: redirect to the client's `redirect_uri` with `error=access_denied` and + `error_description=The account is suspended.` +- Signed in with no membership, `Authenticated` or `PendingApproval` (the chat tier): treated like an active member for + sign-in to chat and nothing else. The token carries the same `openid profile` claims; the issuer writes no membership, + role or vote, and every member or governance surface (`/App`, `/eve/governance`, `/api/sealed-motions`, the fork desk) + still asks for `Active` itself, so neither the session nor the token opens them. +- Signed in, with an account that can chat, 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 +- Signed in, with an account that can chat, 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. The refusal is at authorize only: a code or access token issued for such a row before this check deployed stays redeemable until it expires (minutes), and token and userinfo re-read membership, not the name. Whether any such row exists is not known from the code; one query of `UserAccounts` against the 28 names would say. -- 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. +- Otherwise: a code is issued. +- Token endpoint and userinfo: suspension is read again; an account suspended or deleted since gets `invalid_grant` (token) or 403 (userinfo). The logout reconciler ends the sessions of those same accounts; a chat-tier account is never logged out for lacking membership. ## Back-channel logout When `BackchannelLogoutUri` is set, a hosted reconciler (`OidcLogoutReconciler`) runs every 30 seconds. It lists the -subjects holding valid authorizations of the MAS client; for each subject whose membership is not `Active` (the same -reading the token and userinfo endpoints use; a deleted account or a subject that is not an account id counts as not -`Active`) it POSTs one OIDC back-channel logout token to MAS, and only after a 2xx revokes that subject's authorizations, -which also invalidates their tokens. Membership is never written by this path: any way of leaving `Active` (admin -action, sync, a database edit, a future suspend) is covered because the work is derived from membership state. +subjects holding valid authorizations of the MAS client; for each subject who may no longer chat (the same +reading the token and userinfo endpoints use: membership `Suspended`, a deleted account, or a subject that is not an account id; an +account with no membership or a pending one may chat and is left alone) it POSTs one OIDC back-channel logout token to MAS, and only after a 2xx revokes that subject's authorizations, +which also invalidates their tokens. Membership is never written by this path: any way of being suspended or deleted (admin +action, sync, a database edit) is covered because the work is derived from membership state. The token is an ES256 JWT signed with the issuer key, header `typ` `logout+jwt`, with `iss` as the id_token carries it, `aud` the MAS client id, `sub` the account id, `iat`, `exp` two minutes later, a fresh `jti` and the @@ -142,8 +146,6 @@ Checked against the Window B list (2026-11-11). Present and tested: discovery, J 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: -- **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. diff --git a/src/Bifrost.Web/Features/Oidc/OidcEndpoints.cs b/src/Bifrost.Web/Features/Oidc/OidcEndpoints.cs index 7fce895..7583111 100644 --- a/src/Bifrost.Web/Features/Oidc/OidcEndpoints.cs +++ b/src/Bifrost.Web/Features/Oidc/OidcEndpoints.cs @@ -20,7 +20,7 @@ namespace Bifrost.Web.Features.Oidc; /// public static class OidcEndpoints { - public const string NotActiveDescription = "The account is not an active GameCult member."; + public const string SuspendedDescription = "The account is suspended."; public const string NoIdentityDescription = "The account has no Bifrost identity."; public const string ReservedIdentityDescription = "The account's Bifrost identity is reserved."; @@ -49,9 +49,11 @@ public static class OidcEndpoints } var actor = await actors.GetAsync(http.RequestAborted); - if (actor.UserAccount is null || !actor.IsActiveMember) + // Chat tier: any account that is not suspended may sign in to chat. Membership is neither required nor written; + // every member or governance surface keeps asking for Active itself (ActiveMemberIdAsync). + if (actor.UserAccount is null || actor.Membership?.Status == MembershipStatus.Suspended) { - return Deny(Errors.AccessDenied, NotActiveDescription); + return Deny(Errors.AccessDenied, SuspendedDescription); } var account = actor.UserAccount; @@ -90,13 +92,13 @@ public static class OidcEndpoints private static async Task ExchangeAsync(HttpContext http, BifrostDbContext db) { // Only the authorization code grant is enabled, so OpenIddict has refused every other grant already. - // The code's own principal carries the claims chosen at authorize time; membership is read again here, - // because the code outlives the authorize decision (a member suspended since gets no tokens). + // The code's own principal carries the claims chosen at authorize time; suspension is read again here, + // because the code outlives the authorize decision (an account suspended since gets no tokens). var result = await http.AuthenticateAsync(OpenIddictScheme); var subject = result.Principal?.GetClaim(Claims.Subject); - if (!await IsStillActiveMemberAsync(db, subject, http.RequestAborted)) + if (!await IsStillChatEligibleAsync(db, subject, http.RequestAborted)) { - return Deny(Errors.InvalidGrant, NotActiveDescription); + return Deny(Errors.InvalidGrant, SuspendedDescription); } return Results.SignIn(result.Principal!, properties: null, OpenIddictScheme); @@ -110,10 +112,10 @@ public static class OidcEndpoints return Results.Challenge(authenticationSchemes: [OpenIddictScheme]); } - // An access token outlives the authorize decision too: a member suspended since is no longer answered. - if (!await IsStillActiveMemberAsync(db, result.Principal.GetClaim(Claims.Subject), http.RequestAborted)) + // An access token outlives the authorize decision too: an account suspended since is no longer answered. + if (!await IsStillChatEligibleAsync(db, result.Principal.GetClaim(Claims.Subject), http.RequestAborted)) { - return Deny(Errors.InsufficientAccess, NotActiveDescription); + return Deny(Errors.InsufficientAccess, SuspendedDescription); } var claims = new Dictionary { [Claims.Subject] = result.Principal.GetClaim(Claims.Subject) }; @@ -126,11 +128,24 @@ public static class OidcEndpoints return Results.Json(claims); } - /// The one reading of "this subject is still an Active member", shared by the token and userinfo endpoints, the logout reconciler and the fork desk. - internal static async Task IsStillActiveMemberAsync(BifrostDbContext db, string? subject, CancellationToken cancellationToken) => - await ActiveMemberIdAsync(db, subject, cancellationToken) is not null; + /// + /// The one reading of "this subject may still chat", shared by the token and userinfo endpoints and the logout reconciler: + /// the account exists and its membership is not Suspended. Membership may be absent, pending or Active. Re-read on every call. + /// + internal static async Task IsStillChatEligibleAsync(BifrostDbContext db, string? subject, CancellationToken cancellationToken) + { + if (!Guid.TryParse(subject, out var accountId)) + { + return false; + } - /// The account id of the subject when it is an Active member now, else null. Re-read on every call. + var account = await db.UserAccounts.AsNoTracking() + .Include(x => x.Membership) + .SingleOrDefaultAsync(x => x.Id == accountId, cancellationToken); + return account is not null && account.Membership?.Status != MembershipStatus.Suspended; + } + + /// The account id of the subject when it is an Active member now, else null. The only door to member surfaces (the fork desk); a chat-tier token does not pass it. Re-read on every call. internal static async Task ActiveMemberIdAsync(BifrostDbContext db, string? subject, CancellationToken cancellationToken) { if (!Guid.TryParse(subject, out var accountId)) diff --git a/src/Bifrost.Web/Features/Oidc/OidcLogoutReconciler.cs b/src/Bifrost.Web/Features/Oidc/OidcLogoutReconciler.cs index ac6fa72..a43cd9c 100644 --- a/src/Bifrost.Web/Features/Oidc/OidcLogoutReconciler.cs +++ b/src/Bifrost.Web/Features/Oidc/OidcLogoutReconciler.cs @@ -11,7 +11,7 @@ using static OpenIddict.Abstractions.OpenIddictConstants; namespace Bifrost.Web.Features.Oidc; /// -/// Ends the chat sessions of a subject who is no longer an Active member. Each tick it lists the subjects holding valid +/// Ends the chat sessions of a subject who is no longer allowed to chat (suspended, or the account gone). Each tick it lists the subjects holding valid /// authorizations of the configured client; for each one the shared active-member reading refuses, it POSTs one signed /// OIDC back-channel logout token to MAS and, only after a 2xx, revokes that subject's authorizations. The /// authorizations table is the queue: nothing is stored, so a restart, a MAS outage or a later-set URI resumes from it. @@ -127,7 +127,7 @@ public sealed class OidcLogoutReconciler( { await using var scope = scopeFactory.CreateAsyncScope(); var db = scope.ServiceProvider.GetRequiredService(); - if (await OidcEndpoints.IsStillActiveMemberAsync(db, subject, cancellationToken)) + if (await OidcEndpoints.IsStillChatEligibleAsync(db, subject, cancellationToken)) { _backoff.Remove(subject); return; diff --git a/tests/Bifrost.Web.Tests/OidcIssuerTests.cs b/tests/Bifrost.Web.Tests/OidcIssuerTests.cs index 56f2b9a..68617b8 100644 --- a/tests/Bifrost.Web.Tests/OidcIssuerTests.cs +++ b/tests/Bifrost.Web.Tests/OidcIssuerTests.cs @@ -107,20 +107,74 @@ public sealed class OidcIssuerTests : IClassFixture Assert.Equal("Second Name", second.Claim("name")); } - [Theory] - [InlineData(MembershipStatus.Authenticated)] - [InlineData(MembershipStatus.PendingApproval)] - [InlineData(MembershipStatus.Suspended)] - public async Task A_member_who_is_not_active_is_refused_with_access_denied_and_no_code(MembershipStatus status) + [Fact] + public async Task A_suspended_account_is_refused_with_access_denied_and_no_code() { - var (client, _) = await SignedInAsync(status, "Not Active"); + var (client, _) = await SignedInAsync(MembershipStatus.Suspended, "Suspended One"); 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.NotActiveDescription, redirect["error_description"]); + Assert.Equal(Bifrost.Web.Features.Oidc.OidcEndpoints.SuspendedDescription, redirect["error_description"]); + Assert.False(redirect.ContainsKey("code")); + } + + [Theory] + [InlineData(MembershipStatus.Authenticated)] + [InlineData(MembershipStatus.PendingApproval)] + public async Task A_non_member_gets_a_chat_only_token_and_writes_no_membership_or_vote(MembershipStatus status) + { + var (client, identity) = await SignedInAsync(status, "Chat Only"); + var accountId = _factory.AccountId(identity); + var before = await _factory.RowCountsAsync(); + + var tokens = await CompleteFlowAsync(client); + + var verified = await ValidateIdTokenAsync(tokens.IdToken, tokens.Request.Nonce); + Assert.Equal(accountId.ToString("D"), verified.Claim("sub")); + Assert.Equal(identity, verified.Claim("preferred_username")); + + // The issuer wrote nothing about membership or votes, and the status is exactly what it was. + Assert.Equal(before, await _factory.RowCountsAsync()); + Assert.Equal(status, await _factory.MembershipStatusAsync(identity)); + Assert.Equal(0, (await _factory.RowCountsAsync()).Votes); + + // Neither the session that signed in to chat nor its token opens a member or governance surface. + foreach (var path in new[] { "/eve/governance/surface", "/api/sealed-motions", "/App" }) + { + using var viaSession = await client.GetAsync(path); + Assert.NotEqual(HttpStatusCode.OK, viaSession.StatusCode); + + using var viaToken = new HttpRequestMessage(HttpMethod.Get, path); + viaToken.Headers.Authorization = new AuthenticationHeaderValue("Bearer", tokens.AccessToken); + using var bearer = await NewClient().SendAsync(viaToken); + Assert.NotEqual(HttpStatusCode.OK, bearer.StatusCode); + } + + using var desk = new HttpRequestMessage(HttpMethod.Get, "/api/desk/forks"); + desk.Headers.Authorization = new AuthenticationHeaderValue("Bearer", tokens.AccessToken); + using var deskResponse = await NewClient().SendAsync(desk); + Assert.NotEqual(HttpStatusCode.OK, deskResponse.StatusCode); + + // Last, because userinfo answers 500 on a .NET 10 runtime (the six known failures), and the rest must still run there. + using var userinfo = await UserInfoAsync(tokens.AccessToken); + Assert.Equal(HttpStatusCode.OK, userinfo.StatusCode); + var info = JsonDocument.Parse(await userinfo.Content.ReadAsStringAsync()).RootElement; + Assert.Equal(["name", "preferred_username", "sub"], info.EnumerateObject().Select(x => x.Name).Order().ToArray()); + } + + [Fact] + public async Task A_chat_tier_account_with_a_reserved_identity_is_still_refused() + { + using var factory = new OidcFixedIdentityFactory("eureka", MembershipStatus.Authenticated); + using var client = factory.CreateClient(ClientOptions()); + await RegisterAsync(client, $"cookie-{Guid.NewGuid():N}"); + var request = NewRequest(); + + var redirect = AssertRedirectToClient(await DriveAsync(client, request), request.State); + Assert.Equal("access_denied", redirect["error"]); Assert.False(redirect.ContainsKey("code")); } @@ -619,11 +673,9 @@ public sealed class OidcIssuerTests : IClassFixture } [Theory] - [InlineData(MembershipStatus.Authenticated)] - [InlineData(MembershipStatus.PendingApproval)] [InlineData(MembershipStatus.Suspended)] [InlineData(null)] - public async Task A_member_no_longer_active_after_authorize_gets_no_tokens(MembershipStatus? status) + public async Task An_account_suspended_or_gone_after_authorize_gets_no_tokens(MembershipStatus? status) { var (client, identity) = await SignedInAsync(MembershipStatus.Active, "Soon Gone"); var request = NewRequest(); @@ -640,11 +692,9 @@ public sealed class OidcIssuerTests : IClassFixture } [Theory] - [InlineData(MembershipStatus.Authenticated)] - [InlineData(MembershipStatus.PendingApproval)] [InlineData(MembershipStatus.Suspended)] [InlineData(null)] - public async Task Userinfo_refuses_an_access_token_whose_member_is_no_longer_active(MembershipStatus? status) + public async Task Userinfo_refuses_an_access_token_whose_account_is_suspended_or_gone(MembershipStatus? status) { var (client, identity) = await SignedInAsync(MembershipStatus.Active, "Soon Gone"); var tokens = await CompleteFlowAsync(client); diff --git a/tests/Bifrost.Web.Tests/OidcLogoutTests.cs b/tests/Bifrost.Web.Tests/OidcLogoutTests.cs index 8aa5d15..c167fc2 100644 --- a/tests/Bifrost.Web.Tests/OidcLogoutTests.cs +++ b/tests/Bifrost.Web.Tests/OidcLogoutTests.cs @@ -37,9 +37,7 @@ public sealed class OidcLogoutTests [Theory] [InlineData(MembershipStatus.Suspended)] - [InlineData(MembershipStatus.PendingApproval)] - [InlineData(MembershipStatus.Authenticated)] - public async Task A_member_who_is_not_active_gets_one_signed_logout_and_loses_the_authorizations(MembershipStatus status) + public async Task A_suspended_account_gets_one_signed_logout_and_loses_the_authorizations(MembershipStatus status) { using var factory = new OidcLogoutFactory(); var (identity, idToken) = await factory.GrantAsync(status); @@ -112,6 +110,23 @@ public sealed class OidcLogoutTests Assert.Equal(before, factory.ValidAuthorizations(activeSubject)); } + [Theory] + [InlineData(MembershipStatus.Authenticated)] + [InlineData(MembershipStatus.PendingApproval)] + public async Task A_chat_tier_non_member_is_never_sent_a_logout_and_keeps_the_authorizations(MembershipStatus status) + { + using var factory = new OidcLogoutFactory(); + var (chat, _) = await factory.GrantAsync(status); + var subject = factory.AccountId(chat).ToString("D"); + var before = factory.ValidAuthorizations(subject); + Assert.True(before > 0); + + await factory.Reconciler.ReconcileOnceAsync(CancellationToken.None); + + Assert.Empty(factory.Mas.LogoutTokens()); + Assert.Equal(before, factory.ValidAuthorizations(subject)); + } + [Fact] public async Task One_subject_that_cannot_be_delivered_does_not_block_another() { diff --git a/tests/Bifrost.Web.Tests/Support/OidcTestKit.cs b/tests/Bifrost.Web.Tests/Support/OidcTestKit.cs index 351ac79..a9b304f 100644 --- a/tests/Bifrost.Web.Tests/Support/OidcTestKit.cs +++ b/tests/Bifrost.Web.Tests/Support/OidcTestKit.cs @@ -179,6 +179,24 @@ public class OidcWebApplicationFactory : TestWebApplicationFactory } } + public async Task MembershipStatusAsync(string identity) + { + using var scope = Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var account = db.UserAccounts.Single(x => x.NormalizedBifrostIdentity == identity); + return (await db.Memberships.AsNoTracking().SingleOrDefaultAsync(x => x.UserAccountId == account.Id))?.Status; + } + + public sealed record RowCounts(int Accounts, int Memberships, int Votes, int RoleAssignments); + + /// What the issuer must leave alone: accounts, memberships, role grants and votes. + public async Task RowCountsAsync() + { + using var scope = Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + return new RowCounts(await db.UserAccounts.CountAsync(), await db.Memberships.CountAsync(), await db.Votes.CountAsync(), await db.RoleAssignments.CountAsync()); + } + /// Sets the membership of the account registered under . public async Task SetMembershipAsync(string identity, MembershipStatus status) { @@ -244,15 +262,15 @@ public sealed class OidcNoIdentityFactory : OidcWebApplicationFactory /// 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 +public sealed class OidcFixedIdentityFactory(string identity, MembershipStatus status = MembershipStatus.Active) : OidcWebApplicationFactory { protected override void ConfigureActors(IServiceCollection services) { services.RemoveAll(); - services.AddScoped(_ => new FixedIdentity(identity)); + services.AddScoped(_ => new FixedIdentity(identity, status)); } - private sealed class FixedIdentity(string identity) : ICurrentBifrostActorAccessor + private sealed class FixedIdentity(string identity, MembershipStatus status) : ICurrentBifrostActorAccessor { public Task GetAsync(CancellationToken cancellationToken) => Task.FromResult(new CurrentBifrostActor(true, new UserAccount @@ -261,7 +279,7 @@ public sealed class OidcFixedIdentityFactory(string identity) : OidcWebApplicati BifrostIdentity = identity, NormalizedBifrostIdentity = identity, // as given: the guard, not the fixture, must fold case DisplayName = "Fixed Identity", - Membership = new Bifrost.Web.Domain.Membership { Status = MembershipStatus.Active } + Membership = new Bifrost.Web.Domain.Membership { Status = status } })); } } -- 2.47.3 From 65823b3d65f6814ba187e74baffda045d4ec0201 Mon Sep 17 00:00:00 2001 From: Heimdall Date: Wed, 11 Nov 2026 08:09:50 +0000 Subject: [PATCH 2/2] OIDC chat tier: honest refusal text for a missing account, comments name the real doors Co-Authored-By: Claude Sonnet 5.5 --- src/Bifrost.Web/Features/Oidc/OidcEndpoints.cs | 11 ++++++++--- src/Bifrost.Web/Features/Oidc/OidcLogoutReconciler.cs | 2 +- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/Bifrost.Web/Features/Oidc/OidcEndpoints.cs b/src/Bifrost.Web/Features/Oidc/OidcEndpoints.cs index 7583111..1a90c23 100644 --- a/src/Bifrost.Web/Features/Oidc/OidcEndpoints.cs +++ b/src/Bifrost.Web/Features/Oidc/OidcEndpoints.cs @@ -50,8 +50,13 @@ public static class OidcEndpoints var actor = await actors.GetAsync(http.RequestAborted); // Chat tier: any account that is not suspended may sign in to chat. Membership is neither required nor written; - // every member or governance surface keeps asking for Active itself (ActiveMemberIdAsync). - if (actor.UserAccount is null || actor.Membership?.Status == MembershipStatus.Suspended) + // every member or governance surface keeps asking for Active itself (the Active-member policy, IsActiveMember, ActiveMemberIdAsync). + if (actor.UserAccount is null) + { + return Deny(Errors.AccessDenied, NoIdentityDescription); + } + + if (actor.Membership?.Status == MembershipStatus.Suspended) { return Deny(Errors.AccessDenied, SuspendedDescription); } @@ -145,7 +150,7 @@ public static class OidcEndpoints return account is not null && account.Membership?.Status != MembershipStatus.Suspended; } - /// The account id of the subject when it is an Active member now, else null. The only door to member surfaces (the fork desk); a chat-tier token does not pass it. Re-read on every call. + /// The account id of the subject when it is an Active member now, else null. The door to the fork desk (cookie surfaces check IsActiveMember); a chat-tier token does not pass it. Re-read on every call. internal static async Task ActiveMemberIdAsync(BifrostDbContext db, string? subject, CancellationToken cancellationToken) { if (!Guid.TryParse(subject, out var accountId)) diff --git a/src/Bifrost.Web/Features/Oidc/OidcLogoutReconciler.cs b/src/Bifrost.Web/Features/Oidc/OidcLogoutReconciler.cs index a43cd9c..63df719 100644 --- a/src/Bifrost.Web/Features/Oidc/OidcLogoutReconciler.cs +++ b/src/Bifrost.Web/Features/Oidc/OidcLogoutReconciler.cs @@ -12,7 +12,7 @@ namespace Bifrost.Web.Features.Oidc; /// /// Ends the chat sessions of a subject who is no longer allowed to chat (suspended, or the account gone). Each tick it lists the subjects holding valid -/// authorizations of the configured client; for each one the shared active-member reading refuses, it POSTs one signed +/// authorizations of the configured client; for each one the shared chat-eligibility reading refuses, it POSTs one signed /// OIDC back-channel logout token to MAS and, only after a 2xx, revokes that subject's authorizations. The /// authorizations table is the queue: nothing is stored, so a restart, a MAS outage or a later-set URI resumes from it. /// Per-subject backoff lives in memory only. One subject's unexpected exception is logged and skipped, never aborting the -- 2.47.3