diff --git a/docs/oidc-issuer.md b/docs/oidc-issuer.md index b3c4121..e6a2eeb 100644 --- a/docs/oidc-issuer.md +++ b/docs/oidc-issuer.md @@ -5,7 +5,8 @@ Authentication Service (MAS) can sign chat users in with their Bifrost account. Bifrost; it does not issue the identity chat sees. The provider is OpenIddict (`OpenIddict.AspNetCore` and `OpenIddict.EntityFrameworkCore`, 7.7.1). Bifrost owns two -decisions at the authorize endpoint: who the chat user is, and whether they may get a session. +decisions at the authorize endpoint: who the chat user is, and whether they may get a session (not suspended, and agreed to +the training notice in force; see "Chat needs a receipt"). ## Endpoints @@ -80,17 +81,76 @@ The authorize endpoint reads the cookie session and the same actor facts the `/A 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, not refused above, but no live consent receipt for the training notice version in force + (`TrainingNotice.Version`; none ever, withdrawn, or for another version): **not an OIDC error.** A 302 to + `/auth/bifrost/consent?returnUrl=`. No code is issued and the + client's `redirect_uri` is not called. The consent page shows the notice and an Agree button; agreeing is a 303 back to + the `returnUrl`, which resumes the same parked request and issues the code. The parked request is single use and lives ten + minutes (it is spent only by an OpenIddict response, which the redirect is not), so a member who agrees after it has expired + gets MAS's error and starts the sign-in again, now with a receipt. This check comes last, so a suspended, identity-less or + reserved account is refused as before and never invited to agree. - 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. +- Token endpoint and userinfo: suspension and the receipt are read again, on every call. An account suspended or deleted + since, or without a live current receipt since (withdrawn, or the notice version bumped), gets `invalid_grant` (token) or + 403 `insufficient_access` (userinfo), with the description of the reason, and no claim. So an access token already in a + chat client's hands stops answering userinfo the moment the receipt goes, and answers again if the member agrees again + (this path does not revoke the token; the back-channel reconciler below does, when it is configured). There is no refresh token, so refresh needs no gate. + The logout reconciler ends the sessions of those same accounts; a chat-tier account is never logged out for lacking membership. + +## Chat needs a receipt + +The issuer is the chat tier: every token it issues is a chat token. Ruled by Meta, 2026-11-11: speaking in chat needs agreement +to the training notice, and nothing else does. A live `ConsentReceipt` for `TrainingNotice.Version` is required (the same +reading as the consent page's "agreed", `ConsentEndpoints.HasLiveReceiptAsync`); account sign-in, the members page and votes need no +receipt. A member with no receipt keeps all of those and loses only the chat token until they agree on the consent page. + +Where they learn it: the refused authorize lands them on the consent page, which states what agreement is for ("to speak in a +recorded room"), shows the notice, and returns them to chat sign-in when they agree. Nothing else prompts them: the +members page and the sign-in form do not nag, and an existing member who never opens chat is never asked. + +What a Persona or Eureka sees: + +| Step | Receipt live and current | No receipt, withdrawn, or another version | +| --- | --- | --- | +| Authorize | 302 to the client with `code` | 302 to `/auth/bifrost/consent?returnUrl=...`; no `code`, no `error` | +| Token | 200 with tokens | 400 `invalid_grant`, description "Agreement to the training notice in force is needed to speak in a recorded room." | +| Userinfo | 200 with `sub`, `preferred_username`, `name` | 403 `insufficient_access`; no claim in the body | +| Back-channel logout | none | Only when `BackchannelLogoutUri` is set (the reconciler runs): MAS is sent a logout for the subject and its authorizations are revoked, within one reconciler tick. Otherwise an already-open session is not ended; only new logins and userinfo calls are refused | + +Deploy note: on the first reconciler tick, every subject holding a MAS authorization and no receipt (all existing chat users, admins included) is sent a back-channel logout and loses its authorizations. That is the ruling working as designed, not a fault; they return through the consent page. + +The gate is on the token, so as built it covers every room the MAS login reaches. Which rooms are "recorded", and whether any +room is not, is the chat side's to say; the issuer does not know. + +### Userinfo smoke notes for Eureka + +Run against a deployed instance with two throwaway accounts that hold a membership of any kind: A has agreed on its consent +page, B has not (register without ticking the box, or click Withdraw). `$ISS` is the issuer, `$MAS` the registered client with +its secret, sessions are the cookie jars of a browser signed in as A or as B. Use the MAS client's own redirect URI. + +1. Sign in as A through chat (MAS), or by hand (the first authorize answer is a 302 to `?request_uri=...`; follow that hop, the next answer is the code redirect): `GET $ISS/connect/authorize?response_type=code&client_id=...&redirect_uri=...&scope=openid%20profile&state=s&nonce=n&code_challenge=...&code_challenge_method=S256` with A's cookie. + Expect 302 to the redirect URI with `code` and `state`. Exchange it: `POST $ISS/connect/token` with `client_secret_basic` + and `grant_type=authorization_code`, `code`, `redirect_uri`, `code_verifier`. Expect 200 with `access_token` and `id_token`. +2. `GET $ISS/connect/userinfo` with `Authorization: Bearer `. Expect 200 and exactly `sub`, `preferred_username`, `name`. +3. Same authorize request with B's cookie. Expect 302 whose `Location` starts `/auth/bifrost/consent?returnUrl=%2Fconnect%2Fauthorize%3Frequest_uri`, + and no `code` anywhere. (OpenIddict first parks the request and redirects to the same URL with a `request_uri`; follow + that one hop, the consent redirect is the next answer. A missing receipt ignores `prompt=none`: still a 302 to the consent page, never `consent_required`.) `GET` that Location with B's cookie: expect 200 and a page that contains + "to speak in a recorded room" and a form posting to `/auth/bifrost/consent/accept`. +4. Withdraw A: on A's consent page click Withdraw (a form `POST /auth/bifrost/consent/withdraw`, 303 back). Repeat step 2 with + A's old access token: expect 403, `WWW-Authenticate` with `error="insufficient_access"`, and no `sub` or `preferred_username` + in the body. Repeat step 1's authorize for A: expect the consent redirect of step 3, not a code. +5. A clicks Agree. Repeat step 1's authorize and token, then step 2: expect 302 with `code`, 200 tokens, 200 userinfo. +6. A code obtained before step 4 and exchanged after it: expect 400 `invalid_grant` and no `access_token`. +7. Control: with the receipt gone, B can still sign in to Bifrost, open `/App/Members` and vote; only chat is refused. ## 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 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. +reading the token and userinfo endpoints use: membership `Suspended`, a deleted account, a subject that is not an account id, or +no live receipt for the notice version in force; an account with no membership or a pending one that has agreed 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 and receipts are never written by this path: any way of being suspended, deleted or withdrawn (admin +action, sync, a database edit, the consent page) is covered because the work is derived from stored 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 diff --git a/docs/registration-gates.md b/docs/registration-gates.md index 5564b17..e9896f7 100644 --- a/docs/registration-gates.md +++ b/docs/registration-gates.md @@ -80,10 +80,11 @@ raise the wait or lower the budget; do not add a visual or audio puzzle, which e Lives in Bifrost: the account, the receipt and the registration are one transaction, and chat admission is Bifrost's decision (`admission-is-bifrost-membership`). -- Consent is optional. Registration succeeds with or without it; a receipt exists only if the person agreed. See - `training-notice.md` for why and for what the notice says. +- Consent is optional at registration. Registration succeeds with or without it; a receipt exists only if the person + agreed. It is not optional for chat: the OIDC chat sign-in needs a live receipt for the version in force + (`oidc-issuer.md`, Who may sign in). See `training-notice.md` for why and for what the notice says. - Notice: `TrainingNotice.Version` (positive integer, no leading zeros, per the version rule in Heimdall's - `answer-collector-consent.md`) and `TrainingNotice.Text`, served at `GET /auth/bifrost/notice`. Version 2 is in force. + `answer-collector-consent.md`) and `TrainingNotice.Text`, served at `GET /auth/bifrost/notice`. Version 1 is in force, and its text is frozen (a test pins its hash). Changing the text is a version bump, never an edit in place. The text states that nothing from Discord is used. - Receipt: `ConsentReceipt` (account, notice version, `AcceptedAtUtc`, `WithdrawnAtUtc`). One live (not withdrawn) receipt per account and version; withdrawn rows stay as history. At registration an `acceptedNoticeVersion` equal to @@ -91,7 +92,8 @@ decision (`admission-is-bifrost-membership`). nothing; absent means no receipt. - Consent page: `GET /auth/bifrost/consent`, for any signed-in account (native, provider, existing). It shows the notice, the account's state (no agreement, agreed to the current version, agreed to an older one, withdrawn) and the buttons - that apply: Withdraw whenever any receipt is live, Agree whenever none is live for the version in force (so an account + that apply (with a `returnUrl` query a refused chat sign-in sends, a local path only, Agree returns the member there): + Withdraw whenever any receipt is live, Agree whenever none is live for the version in force (so an account on an older version sees both). `POST /auth/bifrost/consent/accept` and `POST /auth/bifrost/consent/withdraw` are form posts refused with 403 unless they came from the site itself (the strict `RequestOriginGuard`), before anything is read or written. Agree is idempotent; withdraw stamps `WithdrawnAtUtc` once and deletes nothing; agreeing again after a withdrawal writes diff --git a/docs/training-notice.md b/docs/training-notice.md index 3cc0be8..a4d1287 100644 --- a/docs/training-notice.md +++ b/docs/training-notice.md @@ -1,20 +1,32 @@ -# Training notice, version 2 +# Training notice, version 1 -Eureka proposed a tighter notice and listed five gaps in the receipt flow (PR #55). The steward (Heimdall) ruled on -2026-11-11. The served text is `TrainingNotice.Text` and nothing else: this page records why it says what it says and -what each gap became, and does not repeat the wording. Contract for the receipt and the export is in -`registration-gates.md`. +The served text is `TrainingNotice.Text` and nothing else: this page records why it says what it says and what each gap in +the receipt flow became, and does not repeat the wording. Contract for the receipt and the export is in +`registration-gates.md`. What the receipt gates in chat is in `oidc-issuer.md`. -## What version 2 changed from version 1 +Version 1 is the first notice that will be live under that number, but the number has been used before: main carried a +version 1 with different words from #54 onward, and #58 labelled its drafts version 2. Receipts therefore do not migrate. +**The text is frozen as version 1**: a test pins its hash, so editing the words without bumping `TrainingNotice.Version` +fails a test. A reworded notice is version 2, with its own pinned hash. -| Version 1 | Version 2 | +## Before deploy + +Migration `20261111120000_ClearLegacyConsentReceipts` deletes every row from `ConsentReceipts` when it runs, because +version 1 receipts were given to different words and must not count as agreement to these. Manual emptying is therefore only +a belt-and-braces check: confirm the table is empty after the migrate on any database that ever ran main at or after +`33be966`. Meta/ops must still confirm whether anything was deployed with receipts in it; if so, the people who agreed are +asked again on their next chat sign-in. + +## What the notice says and why + +| The notice says | Why | | --- | --- | -| "Chat messages and activity" | Names what counts: the text of messages with time and room, tied to the account name. Not the email address, sign-in details or anything not posted. | -| "train and evaluate AI models" | "that GameCult builds and runs". Whether a dataset or model may leave GameCult is Meta's call, still open (below); the notice makes no claim either way beyond what it says. | -| "No Discord material is used" | "Nothing that came from Discord is used, in any form." Kept: `gamecult-chat:ruling:discord-taints-training`. Whether any path carries Discord content into a chat room is for the chat steward to confirm (not established here), and any that exists must be excluded from every set for the sentence to stay true. | -| Consent required to register | Optional. See below. | -| "withdraw at any time" with no door | Names the door: the consent page, while signed in. | -| Silent on deletion | Says plainly that withdrawing deletes nothing and what it does and does not undo. | +| What counts: the text of messages with time and room, tied to the account name. Not the email address, sign-in details or anything not posted. | Names the data instead of "messages and activity". | +| "that GameCult builds and runs" | Whether a dataset or model may leave GameCult is Meta's call, still open (below); the notice claims nothing either way. | +| "Nothing that came from Discord is used, in any form." | `gamecult-chat:ruling:discord-taints-training`. Whether any path carries Discord content into a chat room is for the chat steward to confirm (not established here), and any that exists must be excluded from every set for the sentence to stay true. | +| "Agreement is needed to speak in a recorded room. You can have a GameCult account, see the members page and vote without agreeing." | Meta's ruling, 2026-11-11: the chat token needs a live receipt for the version in force; nothing else does. "to speak in a recorded room" is settled wording. | +| "Lines you have already spoken stay: withdrawing deletes nothing." | Withdrawing ends future speaking and future training use; it recalls nothing. The sentence is on the page where the Agree button is (the consent page and the registration form both render the notice beside it), so nobody agrees believing they can unsay a line. | +| Names the door: the consent page, while signed in. | "Withdraw at any time" with no door is no promise. | ## Rulings on the five gaps @@ -29,24 +41,27 @@ what each gap became, and does not repeat the wording. Contract for the receipt (not withdrawn) receipt for the version in force exists. After a bump, everyone is out until they agree again. The notice says so ("if the notice changes, its version changes and we ask again; until you agree to the new version, your messages are not used"), and the consent page shows the person that their agreement is to an older version and - offers the new one. Nobody is emailed or prompted elsewhere; asking is the page's job, and silence means no. + offers the new one. Since the chat gate, a bump also ends everyone's chat token until they agree (their next chat sign-in lands on that page). Nobody is emailed or prompted elsewhere; asking is the page's job, and silence means no. 3. **Withdrawal had no CSRF check.** True. The session cookie's SameSite default is Lax, which already keeps a cross-site POST from carrying it, but nothing in the handler relied on that. Both state changes (agree, withdraw) are now form POSTs refused with 403 unless they came from the site itself (`RequestOriginGuard`, the strict form), before anything is read or written. 4. **Provider-created and existing accounts have no receipt.** Correct, and now stated as the rule: no receipt, no use. - They can opt in on the consent page like anyone else, which is also the only way an existing member is asked. + Since the 2026-11-11 ruling it is also: no receipt, no chat token. They keep their account, the members page and their + votes, and learn it where it bites: the refused chat sign-in lands them on the consent page (`oidc-issuer.md`, Chat needs a + receipt). Nobody is emailed or prompted elsewhere. 5. **No export exists.** True. Every promise about use is a promise about code not written. The notice must not be treated as operative for real people until the export enforces the contract with a test that a withdrawn or - never-agreed account's rows are absent (`registration-gates.md`, Consent receipt, Consumer). No deploy has put - version 2 in front of anyone. + never-agreed account's rows are absent (`registration-gates.md`, Consent receipt, Consumer). Confirm (Before deploy) that no + deploy has put an earlier version 1 in front of anyone. ## Consent is optional -Registration succeeds with or without agreement. A receipt exists only if the person agreed. The contract already -excluded accounts without a receipt, so optional costs nothing in correctness; it costs training data, and it removes -the objection that agreement obtained as a condition of an account is not a free choice. This is the steward's ruling -on Eureka's recommendation; Meta can overrule it. One consequence: the registration form no longer enforces the notice. +Registration succeeds with or without agreement. A receipt exists only if the person agreed. The account is not +conditioned on agreement, which removes the objection that agreement obtained as a condition of an account is not a free +choice; what agreement buys is speaking in a recorded room, and the form says so beside the checkbox. This is the +steward's ruling on Eureka's recommendation, and Meta's ruling of 2026-11-11 added the chat gate. One consequence: the +registration form does not enforce the notice. ## Still open for Meta @@ -54,11 +69,10 @@ on Eureka's recommendation; Meta can overrule it. One consequence: the registrat messages to answer them is covered. The notice is silent on both. - Deletion of messages on request, who does it, and the contact words. The notice offers none. - Controller and contact, and minimum age. Nothing here addresses either. -- Whether existing members should be asked to opt in beyond the consent page existing. - Counsel's reading of consent under the law that applies to the people registering. Not researched here. ## What remains - A signed-out native account cannot withdraw until sign-in recovery exists. - No training export, so no operative use. -- Wording changes are a version bump with a new text; never edit version 2's text in place after it has been served. +- Wording changes are a version bump with a new text and a new pinned hash in `TrainingNoticeFrozenTests`; never edit a version's text in place; the frozen-hash test fails if you do. diff --git a/src/Bifrost.Web/Data/Migrations/20261111120000_ClearLegacyConsentReceipts.cs b/src/Bifrost.Web/Data/Migrations/20261111120000_ClearLegacyConsentReceipts.cs new file mode 100644 index 0000000..985be6a --- /dev/null +++ b/src/Bifrost.Web/Data/Migrations/20261111120000_ClearLegacyConsentReceipts.cs @@ -0,0 +1,26 @@ +using Bifrost.Web.Data; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Bifrost.Web.Data.Migrations +{ + // Hand-written like AddMembershipSuspensionReason: data only, so no schema change and the snapshot is untouched. + // Notice v1 was reused for new words, so any receipt written against the earlier v1 text must not count as agreement + // to these. Every row goes; people who agreed are asked again on their next chat sign-in. + [DbContext(typeof(BifrostDbContext))] + [Migration("20261111120000_ClearLegacyConsentReceipts")] + public partial class ClearLegacyConsentReceipts : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql("DELETE FROM \"ConsentReceipts\";"); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + // Deleted agreements cannot be restored, and must not be. + } + } +} diff --git a/src/Bifrost.Web/Features/Oidc/OidcEndpoints.cs b/src/Bifrost.Web/Features/Oidc/OidcEndpoints.cs index 1a90c23..41ae175 100644 --- a/src/Bifrost.Web/Features/Oidc/OidcEndpoints.cs +++ b/src/Bifrost.Web/Features/Oidc/OidcEndpoints.cs @@ -3,6 +3,7 @@ using Microsoft.AspNetCore; using Bifrost.Web.Data; using Bifrost.Web.Domain; using Bifrost.Web.Features.Membership; +using Bifrost.Web.Features.Registration; using Bifrost.Web.Features.Shared; using Microsoft.EntityFrameworkCore; using Microsoft.AspNetCore.Authentication; @@ -23,6 +24,7 @@ public static class OidcEndpoints 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."; + public const string ConsentRequiredDescription = "Agreement to the training notice in force is needed to speak in a recorded room."; private const string OpenIddictScheme = OpenIddictServerAspNetCoreDefaults.AuthenticationScheme; private const string IdentityAuthenticationType = "Bifrost.Oidc"; @@ -34,7 +36,7 @@ public static class OidcEndpoints app.MapMethods(OidcServiceCollectionExtensions.UserInfoPath, ["GET", "POST"], (Delegate)UserInfoAsync).AllowAnonymous(); } - private static async Task AuthorizeAsync(HttpContext http, ICurrentBifrostActorAccessor actors) + private static async Task AuthorizeAsync(HttpContext http, ICurrentBifrostActorAccessor actors, BifrostDbContext db) { var request = http.GetOpenIddictServerRequest() ?? throw new InvalidOperationException("The OpenIddict request could not be retrieved."); @@ -91,19 +93,27 @@ public static class OidcEndpoints _ => [Destinations.AccessToken] }); + // Last, so a refusal that no agreement can cure (suspended, no identity, reserved) is never answered with an invitation to agree. + // Not an OIDC error: the member is sent to the consent page with this request's resume path, and the parked request + // (single use, ten minutes) is still there when they come back, because only an OpenIddict response spends it. + if (!await ConsentEndpoints.HasLiveReceiptAsync(db, account.Id, http.RequestAborted)) + { + return Results.Redirect(ConsentEndpoints.PathFor(http.Request.PathBase + http.Request.Path + http.Request.QueryString)); + } + return Results.SignIn(principal, properties: null, OpenIddictScheme); } 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; suspension is read again here, - // because the code outlives the authorize decision (an account suspended since gets no tokens). + // The code's own principal carries the claims chosen at authorize time; suspension and agreement are read again here, + // because the code outlives the authorize decision (an account suspended or withdrawn since gets no tokens). var result = await http.AuthenticateAsync(OpenIddictScheme); var subject = result.Principal?.GetClaim(Claims.Subject); - if (!await IsStillChatEligibleAsync(db, subject, http.RequestAborted)) + if (await ChatRefusalAsync(db, subject, http.RequestAborted) is { } refusal) { - return Deny(Errors.InvalidGrant, SuspendedDescription); + return Deny(Errors.InvalidGrant, refusal); } return Results.SignIn(result.Principal!, properties: null, OpenIddictScheme); @@ -117,10 +127,10 @@ public static class OidcEndpoints return Results.Challenge(authenticationSchemes: [OpenIddictScheme]); } - // 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)) + // An access token outlives the authorize decision too: an account suspended since, or one whose agreement has gone, is no longer answered. + if (await ChatRefusalAsync(db, result.Principal.GetClaim(Claims.Subject), http.RequestAborted) is { } refusal) { - return Deny(Errors.InsufficientAccess, SuspendedDescription); + return Deny(Errors.InsufficientAccess, refusal); } var claims = new Dictionary { [Claims.Subject] = result.Principal.GetClaim(Claims.Subject) }; @@ -135,19 +145,25 @@ public static class OidcEndpoints /// /// 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. + /// null when they may, else the description of why not. The account exists, its membership is not Suspended (absent, pending + /// or Active all chat), and it holds a live receipt for the notice version in force. Re-read on every call. /// - internal static async Task IsStillChatEligibleAsync(BifrostDbContext db, string? subject, CancellationToken cancellationToken) + internal static async Task ChatRefusalAsync(BifrostDbContext db, string? subject, CancellationToken cancellationToken) { if (!Guid.TryParse(subject, out var accountId)) { - return false; + return SuspendedDescription; } 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; + if (account is null || account.Membership?.Status == MembershipStatus.Suspended) + { + return SuspendedDescription; + } + + return await ConsentEndpoints.HasLiveReceiptAsync(db, accountId, cancellationToken) ? null : ConsentRequiredDescription; } /// 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. diff --git a/src/Bifrost.Web/Features/Oidc/OidcLogoutReconciler.cs b/src/Bifrost.Web/Features/Oidc/OidcLogoutReconciler.cs index 63df719..273acd9 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 allowed to chat (suspended, or the account gone). Each tick it lists the subjects holding valid +/// Ends the chat sessions of a subject who is no longer allowed to chat (suspended, the account gone, or no live receipt for the training notice in force). Each tick it lists the subjects holding valid /// 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. @@ -127,7 +127,7 @@ public sealed class OidcLogoutReconciler( { await using var scope = scopeFactory.CreateAsyncScope(); var db = scope.ServiceProvider.GetRequiredService(); - if (await OidcEndpoints.IsStillChatEligibleAsync(db, subject, cancellationToken)) + if (await OidcEndpoints.ChatRefusalAsync(db, subject, cancellationToken) is null) { _backoff.Remove(subject); return; diff --git a/src/Bifrost.Web/Features/Registration/ConsentEndpoints.cs b/src/Bifrost.Web/Features/Registration/ConsentEndpoints.cs index d4e9d86..6682d14 100644 --- a/src/Bifrost.Web/Features/Registration/ConsentEndpoints.cs +++ b/src/Bifrost.Web/Features/Registration/ConsentEndpoints.cs @@ -20,7 +20,7 @@ public static class ConsentEndpoints public static void MapConsent(this IEndpointRouteBuilder app) { - app.MapGet(Path, async (ICurrentBifrostActorAccessor actors, BifrostDbContext db, CancellationToken ct) => + app.MapGet(Path, async (HttpContext http, ICurrentBifrostActorAccessor actors, BifrostDbContext db, CancellationToken ct) => { var actor = await actors.GetAsync(ct); if (!actor.IsAuthenticated || actor.UserAccount is null) @@ -28,7 +28,7 @@ public static class ConsentEndpoints return Results.Redirect($"/auth/sign-in?returnUrl={Uri.EscapeDataString(LocalReturnPath.Parse(Path).Value)}"); } - return await PageAsync(db, actor.UserAccount.Id, null, StatusCodes.Status200OK, ct); + return await PageAsync(db, actor.UserAccount.Id, null, StatusCodes.Status200OK, ct, ReturnPathOf(http.Request.Query["returnUrl"])); }).AllowAnonymous(); app.MapPost(Path + "/accept", async ( @@ -53,7 +53,7 @@ public static class ConsentEndpoints var form = await ReadFormAsync(http.Request, ct); if (form is null || form["version"].ToString() != TrainingNotice.Version.ToString(System.Globalization.CultureInfo.InvariantCulture)) { - return await PageAsync(db, actor.UserAccount.Id, $"Send the version in force, {TrainingNotice.Version}, to agree.", StatusCodes.Status400BadRequest, ct); + return await PageAsync(db, actor.UserAccount.Id, $"Send the version in force, {TrainingNotice.Version}, to agree.", StatusCodes.Status400BadRequest, ct, ReturnPathOf(form?["returnUrl"] ?? default)); } var accountId = actor.UserAccount.Id; @@ -77,7 +77,7 @@ public static class ConsentEndpoints } } - return Back(); + return new SeeOther(ReturnPathOf(form["returnUrl"]) ?? Path); }).AllowAnonymous(); app.MapPost(Path + "/withdraw", async ( @@ -133,9 +133,17 @@ public static class ConsentEndpoints private static IResult Refused() => Results.Text("Cross-site consent changes are refused.", statusCode: StatusCodes.Status403Forbidden); + /// Where the consent page sends the member after agreeing: a local path () the refused chat authorize names, else the page itself. + internal static string? ReturnPathOf(Microsoft.Extensions.Primitives.StringValues requested) => + requested.ToString() is { Length: > 0 } text ? LocalReturnPath.Parse(text).Value : null; + + /// The consent page that a refused chat sign-in lands on, carrying the request to resume once the member agrees. + public static string PathFor(string returnPath) => $"{Path}?returnUrl={Uri.EscapeDataString(returnPath)}"; + private static IResult Back() => new SeeOther(Path); - private static Task HasLiveReceiptAsync(BifrostDbContext db, Guid accountId, CancellationToken ct) => + /// The one reading of "may speak": a live receipt for the version in force. A receipt for any other version, or a withdrawn one, does not count. + internal static Task HasLiveReceiptAsync(BifrostDbContext db, Guid accountId, CancellationToken ct) => db.ConsentReceipts.AnyAsync(x => x.UserAccountId == accountId && x.NoticeVersion == TrainingNotice.Version && x.WithdrawnAtUtc == null, ct); private static async Task ReadFormAsync(HttpRequest request, CancellationToken ct) @@ -172,10 +180,10 @@ public static class ConsentEndpoints } } - private static async Task PageAsync(BifrostDbContext db, Guid accountId, string? error, int status, CancellationToken ct) + private static async Task PageAsync(BifrostDbContext db, Guid accountId, string? error, int status, CancellationToken ct, string? returnPath = null) { var receipts = await db.ConsentReceipts.AsNoTracking().Where(x => x.UserAccountId == accountId).ToListAsync(ct); - return new HtmlPage(ConsentPage.Render(ConsentPage.StateOf(receipts), error), status); + return new HtmlPage(ConsentPage.Render(ConsentPage.StateOf(receipts), error, returnPath), status); } private sealed class HtmlPage(string html, int status) : IResult diff --git a/src/Bifrost.Web/Features/Registration/ConsentPage.cs b/src/Bifrost.Web/Features/Registration/ConsentPage.cs index 2e7595c..f34719f 100644 --- a/src/Bifrost.Web/Features/Registration/ConsentPage.cs +++ b/src/Bifrost.Web/Features/Registration/ConsentPage.cs @@ -42,25 +42,26 @@ public static class ConsentPage private static string Iso(DateTimeOffset time) => time.UtcDateTime.ToString("yyyy-MM-dd'T'HH:mm:ss'Z'", System.Globalization.CultureInfo.InvariantCulture); - public static string Render(ConsentState state, string? error) + public static string Render(ConsentState state, string? error, string? returnPath = null) { static string E(string? text) => WebUtility.HtmlEncode(text ?? ""); var version = TrainingNotice.Version.ToString(System.Globalization.CultureInfo.InvariantCulture); var (message, anyLive) = state switch { - ConsentState.Current current => ($"You agreed to version {version} on {Iso(current.AgreedAtUtc)}. Your messages may be used as the notice says.", true), - ConsentState.Older older => ($"You agreed to version {older.Version} on {Iso(older.AgreedAtUtc)}; version {version} is in force and your messages are not used until you agree to it.", true), - ConsentState.Withdrawn withdrawn => ($"You withdrew on {Iso(withdrawn.WithdrawnAtUtc)}. Your messages are not used.", false), - _ => ("You have not agreed. Your messages are not used.", false) + ConsentState.Current current => ($"You agreed to version {version} on {Iso(current.AgreedAtUtc)}. You may speak in a recorded room, and your messages may be used as the notice says.", true), + ConsentState.Older older => ($"You agreed to version {older.Version} on {Iso(older.AgreedAtUtc)}; version {version} is in force; you cannot speak in a recorded room and your messages are not used until you agree to it.", true), + ConsentState.Withdrawn withdrawn => ($"You withdrew on {Iso(withdrawn.WithdrawnAtUtc)}. You cannot speak in a recorded room, and your messages are not used. Lines you already spoke stay.", false), + _ => ("You have not agreed. You cannot speak in a recorded room, and your messages are not used.", false) }; var summary = error is null ? "" : $""""""; // Withdraw whenever any live receipt exists (an older one included); Agree whenever none is live for the version in force. + var back = returnPath is null ? "" : $""""""; var withdrawButton = anyLive ? """
""" : ""; var agreeButton = state is ConsentState.Current ? "" - : $"""
"""; + : $"""
{back}
"""; var button = agreeButton + withdrawButton; return $$""" @@ -91,11 +92,11 @@ public static class ConsentPage

Training consent

{{summary}}

{{E(message)}}

- {{button}}
Training notice, version {{version}} {{TrainingNotice.Html("notice")}}
+ {{button}} diff --git a/src/Bifrost.Web/Features/Registration/RegistrationPage.cs b/src/Bifrost.Web/Features/Registration/RegistrationPage.cs index 5fcb252..ef0e232 100644 --- a/src/Bifrost.Web/Features/Registration/RegistrationPage.cs +++ b/src/Bifrost.Web/Features/Registration/RegistrationPage.cs @@ -99,7 +99,7 @@ public static class RegistrationPage var link = model.ErrorField switch { RegistrationField.Identity => """""", - RegistrationField.Consent => """""", + RegistrationField.Consent => """""", _ => "" }; summary.Append($""""""); @@ -154,8 +154,8 @@ public static class RegistrationPage
Training notice, version {{notice}} {{TrainingNotice.Html("notice")}} - -

Optional. You can create an account without agreeing; then none of your messages are used. You can agree later on your consent page.

+ +

You can create an account without agreeing. Agreement is needed to speak in a recorded room, and you can give it later on your consent page. Withdrawing ends future speaking there; lines you have already spoken stay.

diff --git a/src/Bifrost.Web/Features/Registration/TrainingNotice.cs b/src/Bifrost.Web/Features/Registration/TrainingNotice.cs index 770919f..a823426 100644 --- a/src/Bifrost.Web/Features/Registration/TrainingNotice.cs +++ b/src/Bifrost.Web/Features/Registration/TrainingNotice.cs @@ -5,21 +5,22 @@ namespace Bifrost.Web.Features.Registration; /// /// The training notice a person may agree to. Changing the wording in substance means a new (a -/// positive integer, no leading zeros), because a receipt records the version the person agreed to; a receipt for an -/// older version no longer counts and the person is asked again on the consent page. +/// positive integer, no leading zeros), because a receipt records the version the person agreed to; a receipt for any +/// other version no longer counts, the chat sign-in is refused, and the person is asked again on the consent page. +/// The text is frozen once served: a test pins its hash per version, so an edit without a version bump fails. /// public static class TrainingNotice { - public const int Version = 2; + public const int Version = 1; public const string Text = - "GameCult chat training notice, version 2\n\n" + + "GameCult chat training notice, version 1\n\n" + "GameCult may use messages you post in GameCult chat to train and evaluate AI models that GameCult builds and runs.\n\n" + "What counts. The text of your messages, with their time and room, tied to your account name. Not your email address, your sign-in details or anything you did not post. Nothing that came from Discord is used, in any form.\n\n" + - "It is your choice. You can use GameCult chat without agreeing. If you do not agree, none of your messages are used.\n\n" + + "It is your choice. Agreement is needed to speak in a recorded room. You can have a GameCult account, see the members page and vote without agreeing. If you do not agree, you cannot speak in a recorded room and none of your messages are used.\n\n" + "If you agree, we record your account, this notice's version number and the time. Nothing else is stored with it.\n\n" + - "If you withdraw, we leave all your messages out of every training set made after that moment. Withdrawing deletes nothing: your messages stay in chat and on our servers, anything already used to train a model stays used, and a dataset already built is not recalled. We keep the record that you agreed and when you withdrew.\n\n" + - "You can agree or withdraw at any time on your consent page while signed in. If the notice changes, its version changes and we ask again; until you agree to the new version, your messages are not used."; + "If you withdraw, you can no longer speak in a recorded room, and we leave all your messages out of every training set made after that moment. Lines you have already spoken stay: withdrawing deletes nothing. Your messages stay in chat and on our servers, anything already used to train a model stays used, and a dataset already built is not recalled. We keep the record that you agreed and when you withdrew.\n\n" + + "You can agree or withdraw at any time on your consent page while signed in. If the notice changes, its version changes and we ask again; until you agree to the new version, you cannot speak in a recorded room and your messages are not used."; /// The text as HTML-encoded paragraphs inside a div with the given id. public static string Html(string id) diff --git a/tests/Bifrost.Web.Tests/ConsentTests.cs b/tests/Bifrost.Web.Tests/ConsentTests.cs index ff3a627..e4e088e 100644 --- a/tests/Bifrost.Web.Tests/ConsentTests.cs +++ b/tests/Bifrost.Web.Tests/ConsentTests.cs @@ -135,6 +135,39 @@ public sealed class ConsentTests(RegistrationGateFactory factory) : IClassFixtur Assert.Contains($"name=\"version\" value=\"{TrainingNotice.Version}\"", html); } + [Fact] + public async Task The_page_states_what_agreement_is_for_before_the_agree_button() + { + var identity = NewIdentity(); + Seed(identity); + var html = await PageHtmlAsync(identity); + + var notice = html.IndexOf("to speak in a recorded room", StringComparison.Ordinal); + Assert.True(notice >= 0 && html.Contains("Lines you have already spoken stay"), "the notice says what agreement is for and that spoken lines stay"); + Assert.True(notice < html.IndexOf("action=\"/auth/bifrost/consent/accept\"", StringComparison.Ordinal), "the notice comes before the button that agrees to it"); + } + + [Fact] + public async Task Agreeing_from_a_refused_chat_sign_in_returns_to_the_request_and_a_foreign_path_does_not_survive() + { + var identity = NewIdentity(); + Seed(identity); + using var client = Client(); + + using var request = new HttpRequestMessage(HttpMethod.Get, "/auth/bifrost/consent?returnUrl=" + Uri.EscapeDataString("/connect/authorize?request_uri=urn%3Aabc")); + Sign(request, identity); + using var page = await client.SendAsync(request); + Assert.Contains("name=\"returnUrl\" value=\"/connect/authorize?request_uri=urn%3Aabc\"", await page.Content.ReadAsStringAsync()); + + using var agreed = await PostAsync(client, "accept", identity, new Dictionary { ["version"] = TrainingNotice.Version.ToString(), ["returnUrl"] = "/connect/authorize?request_uri=urn%3Aabc" }); + Assert.Equal(HttpStatusCode.SeeOther, agreed.StatusCode); + Assert.Equal("/connect/authorize?request_uri=urn%3Aabc", agreed.Headers.Location!.OriginalString); + + using var foreign = await PostAsync(client, "accept", identity, new Dictionary { ["version"] = TrainingNotice.Version.ToString(), ["returnUrl"] = "//evil.example/x" }); + Assert.Equal(HttpStatusCode.SeeOther, foreign.StatusCode); + Assert.Equal("/App", foreign.Headers.Location!.OriginalString); + } + [Fact] public async Task Agreement_to_the_current_version_shows_its_time_and_only_the_withdraw_button() { @@ -151,10 +184,10 @@ public sealed class ConsentTests(RegistrationGateFactory factory) : IClassFixtur public async Task Agreement_to_an_older_version_says_it_no_longer_counts_and_offers_both_agree_and_withdraw() { var identity = NewIdentity(); - Seed(identity, (1, Agreed, null)); + Seed(identity, (TrainingNotice.Version - 1, Agreed, null)); var html = await PageHtmlAsync(identity); - Assert.Contains($"You agreed to version 1 on 2026-11-01T12:30:05Z; version {TrainingNotice.Version} is in force and your messages are not used until you agree to it", html); + Assert.Contains($"You agreed to version {TrainingNotice.Version - 1} on 2026-11-01T12:30:05Z; version {TrainingNotice.Version} is in force; you cannot speak in a recorded room and your messages are not used until you agree to it", html); Assert.Contains("action=\"/auth/bifrost/consent/accept\"", html); Assert.Contains("action=\"/auth/bifrost/consent/withdraw\"", html); } @@ -163,7 +196,7 @@ public sealed class ConsentTests(RegistrationGateFactory factory) : IClassFixtur public async Task A_live_receipt_for_the_version_in_force_outranks_an_older_live_one() { var identity = NewIdentity(); - Seed(identity, (1, Agreed, null), (TrainingNotice.Version, Agreed.AddDays(1), null)); + Seed(identity, (TrainingNotice.Version - 1, Agreed, null), (TrainingNotice.Version, Agreed.AddDays(1), null)); var html = await PageHtmlAsync(identity); Assert.Contains($"You agreed to version {TrainingNotice.Version} on 2026-11-02T12:30:05Z", html); @@ -225,7 +258,7 @@ public sealed class ConsentTests(RegistrationGateFactory factory) : IClassFixtur public async Task Withdraw_stamps_every_live_receipt_including_an_older_version() { var identity = NewIdentity(); - Seed(identity, (1, Agreed, null), (TrainingNotice.Version, Agreed.AddDays(1), null)); + Seed(identity, (TrainingNotice.Version - 1, Agreed, null), (TrainingNotice.Version, Agreed.AddDays(1), null)); using var client = Client(); using var response = await PostAsync(client, "withdraw", identity); @@ -267,17 +300,18 @@ public sealed class ConsentTests(RegistrationGateFactory factory) : IClassFixtur public async Task Accept_next_to_an_older_live_receipt_adds_one_for_the_version_in_force() { var identity = NewIdentity(); - Seed(identity, (1, Agreed, null)); + Seed(identity, (TrainingNotice.Version - 1, Agreed, null)); using var client = Client(); using var response = await PostAsync(client, "accept", identity); Assert.Equal(HttpStatusCode.SeeOther, response.StatusCode); - Assert.Equal([1, TrainingNotice.Version], Receipts(identity).Select(x => x.NoticeVersion).Order().ToArray()); + Assert.Equal([TrainingNotice.Version - 1, TrainingNotice.Version], Receipts(identity).Select(x => x.NoticeVersion).Order().ToArray()); } [Theory] - [InlineData("1")] + [InlineData("0")] + [InlineData("2")] [InlineData("3")] [InlineData("abc")] [InlineData("")] diff --git a/tests/Bifrost.Web.Tests/MigrationDiscoveryTests.cs b/tests/Bifrost.Web.Tests/MigrationDiscoveryTests.cs index 012b061..6d902cb 100644 --- a/tests/Bifrost.Web.Tests/MigrationDiscoveryTests.cs +++ b/tests/Bifrost.Web.Tests/MigrationDiscoveryTests.cs @@ -27,6 +27,7 @@ public sealed class MigrationDiscoveryTests "20261111090000_AddConsentReceipts", "20261111100000_ConsentReceiptsOneLivePerVersion", "20261111110000_AddMembershipSuspensionReason", + "20261111120000_ClearLegacyConsentReceipts", ]; // Database.GetMigrations() reads assembly metadata only; no connection is opened. diff --git a/tests/Bifrost.Web.Tests/OidcIssuerTests.cs b/tests/Bifrost.Web.Tests/OidcIssuerTests.cs index 2b698f0..2c3b9a5 100644 --- a/tests/Bifrost.Web.Tests/OidcIssuerTests.cs +++ b/tests/Bifrost.Web.Tests/OidcIssuerTests.cs @@ -224,6 +224,7 @@ public sealed class OidcIssuerTests : IClassFixture using var factory = new OidcFixedIdentityFactory(ordinary); using var client = factory.CreateClient(ClientOptions()); await RegisterAsync(client, $"cookie-{Guid.NewGuid():N}"); + await factory.AgreeAsync(factory.FixedAccountId); var request = NewRequest(); var final = await DriveAsync(client, request); @@ -825,6 +826,164 @@ public sealed class OidcIssuerTests : IClassFixture Assert.Equal(OidcForeignIssuerFactory.PublicBaseUrl, discovery.GetProperty("issuer").GetString()); } + // ---- chat needs a live receipt for the notice version in force; nothing else does ---- + + private const string ConsentPath = "/auth/bifrost/consent"; + + /// A form post as the consent page's own buttons send it: the site's own Origin, no antiforgery token. + private async Task ConsentPostAsync(HttpClient client, string action, string? returnUrl = null) + { + var fields = new Dictionary { ["version"] = Bifrost.Web.Features.Registration.TrainingNotice.Version.ToString() }; + if (returnUrl is not null) + { + fields["returnUrl"] = returnUrl; + } + + using var message = new HttpRequestMessage(HttpMethod.Post, $"{ConsentPath}/{action}") { Content = new FormUrlEncodedContent(fields) }; + message.Headers.TryAddWithoutValidation("Origin", Origin.GetLeftPart(UriPartial.Authority)); + return await client.SendAsync(message); + } + + [Theory] + [InlineData(ReceiptState.None)] + [InlineData(ReceiptState.Withdrawn)] + [InlineData(ReceiptState.OtherVersion)] + public async Task Chat_authorize_without_a_live_current_receipt_sends_the_member_to_the_consent_page_with_the_request_to_resume(ReceiptState receipt) + { + var (client, _) = await SignedInAsync(MembershipStatus.Active, "No Agreement", receipt); + var request = NewRequest(); + + var final = await DriveAsync(client, request); + + Assert.Equal(HttpStatusCode.Redirect, final.StatusCode); + var location = final.Headers.Location!.OriginalString; + Assert.DoesNotContain("code=", location); + Assert.DoesNotContain("error=", location); + Assert.StartsWith(ConsentPath + "?returnUrl=", location); + var resume = ReturnUrl(final); + Assert.StartsWith("/connect/authorize?", resume); + Assert.Equal(resume, LocalReturnPath.Parse(resume).Value); // short enough to survive as a local return path + + using var page = await client.GetAsync(location); + var html = await page.Content.ReadAsStringAsync(); + Assert.Equal(HttpStatusCode.OK, page.StatusCode); + Assert.Contains("action=\"/auth/bifrost/consent/accept\"", html); + Assert.Contains("to speak in a recorded room", html); + } + + [Fact] + public async Task Agreeing_on_the_consent_page_resumes_the_refused_request_and_chat_works() + { + var (client, identity) = await SignedInAsync(MembershipStatus.Authenticated, "Late Agreer", ReceiptState.None); + var request = NewRequest(); + var refused = await DriveAsync(client, request); + var resume = ReturnUrl(refused); + + using var agreed = await ConsentPostAsync(client, "accept", resume); + Assert.Equal(HttpStatusCode.SeeOther, agreed.StatusCode); + Assert.Equal(resume, agreed.Headers.Location!.OriginalString); + + using var resumed = await client.GetAsync(resume); + var code = AssertRedirectToClient(resumed, request.State)["code"]; + Assert.NotNull(code); + var tokens = await ExchangeAsync(client, request, code!); + using var userinfo = await UserInfoAsync(tokens.AccessToken); + Assert.Equal(HttpStatusCode.OK, userinfo.StatusCode); + Assert.Equal(identity, JsonDocument.Parse(await userinfo.Content.ReadAsStringAsync()).RootElement.GetProperty("preferred_username").GetString()); + } + + [Theory] + [InlineData(ReceiptState.Withdrawn)] + [InlineData(ReceiptState.OtherVersion)] + public async Task A_code_and_a_token_issued_before_the_agreement_went_stop_working(ReceiptState gone) + { + var (client, identity) = await SignedInAsync(MembershipStatus.Active, "Soon Silent"); + var pendingRequest = NewRequest(); + var pendingCode = await CodeAsync(client, pendingRequest); + var tokens = await CompleteFlowAsync(client); + using (var before = await UserInfoAsync(tokens.AccessToken)) + { + Assert.Equal(HttpStatusCode.OK, before.StatusCode); + } + + await _factory.SetReceiptAsync(identity, gone); + + using var code = await TokenAsync(client, pendingRequest, pendingCode); + var codeBody = await code.Content.ReadAsStringAsync(); + Assert.Equal(HttpStatusCode.BadRequest, code.StatusCode); + Assert.Contains("invalid_grant", codeBody); + Assert.DoesNotContain("access_token", codeBody); + + using var after = await UserInfoAsync(tokens.AccessToken); + var afterBody = await after.Content.ReadAsStringAsync(); + Assert.Equal(HttpStatusCode.Forbidden, after.StatusCode); + Assert.Contains("insufficient_access", string.Join(" ", after.Headers.WwwAuthenticate.Select(x => x.ToString()))); + Assert.DoesNotContain(identity, afterBody); + Assert.DoesNotContain("preferred_username", afterBody); + } + + [Fact] + public async Task Withdrawing_on_the_consent_page_ends_chat_and_agreeing_again_restores_it() + { + var (client, _) = await SignedInAsync(MembershipStatus.Active, "Changes Mind"); + var tokens = await CompleteFlowAsync(client); + + using (var withdrawn = await ConsentPostAsync(client, "withdraw")) + { + Assert.Equal(HttpStatusCode.SeeOther, withdrawn.StatusCode); + } + + using (var silenced = await UserInfoAsync(tokens.AccessToken)) + { + Assert.Equal(HttpStatusCode.Forbidden, silenced.StatusCode); + } + + var request = NewRequest(); + Assert.StartsWith(ConsentPath, (await DriveAsync(client, request)).Headers.Location!.OriginalString); + + using (var again = await ConsentPostAsync(client, "accept")) + { + Assert.Equal(HttpStatusCode.SeeOther, again.StatusCode); + } + + var fresh = await CompleteFlowAsync(client); + using var restored = await UserInfoAsync(fresh.AccessToken); + Assert.Equal(HttpStatusCode.OK, restored.StatusCode); + } + + [Fact] + public async Task Without_a_receipt_account_sign_in_the_members_page_and_votes_still_work() + { + var (client, identity) = await SignedInAsync(MembershipStatus.Active, "Member Only", ReceiptState.None); + var accountId = _factory.AccountId(identity); + + foreach (var path in new[] { "/App", "/App/Members", ConsentPath }) + { + using var page = await client.GetAsync(path); + Assert.Equal(HttpStatusCode.OK, page.StatusCode); + } + + var collection = "c" + Guid.NewGuid().ToString("N"); + using (var scope = _factory.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + db.ConceptSubjects.Add(new ConceptSubject { Collection = collection, Key = "alpha", Title = "alpha", RegisteredAtUtc = DateTimeOffset.UtcNow }); + await db.SaveChangesAsync(); + } + + using var vote = new HttpRequestMessage(HttpMethod.Post, $"/mill/{collection}/alpha?handler=Up") { Content = new FormUrlEncodedContent([]) }; + vote.Headers.TryAddWithoutValidation("Origin", Origin.GetLeftPart(UriPartial.Authority)); + using var voted = await client.SendAsync(vote); + Assert.Equal(HttpStatusCode.Redirect, voted.StatusCode); + using (var scope = _factory.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + Assert.Single(db.ConceptAppraisals.AsNoTracking().Where(x => x.Subject!.Collection == collection && x.UserAccountId == accountId)); + } + + Assert.Empty(_factory.ReceiptsOf(identity)); // none of that wrote an agreement + } + // ---- helpers ---- private static WebApplicationFactoryClientOptions ClientOptions() => new() @@ -836,17 +995,22 @@ public sealed class OidcIssuerTests : IClassFixture private HttpClient NewClient() => _factory.CreateClient(ClientOptions()); - private static async Task RegisterAsync(HttpClient client, string identity) + private static async Task RegisterAsync(HttpClient client, string identity, bool agree = true) { - using var response = await RegistrationKit.RegisterAsync(client, identity); + using var response = await RegistrationKit.RegisterAsync(client, identity, agree: agree); Assert.Equal(HttpStatusCode.OK, response.StatusCode); return identity.ToLowerInvariant(); } - private async Task<(HttpClient Client, string Identity)> SignedInAsync(MembershipStatus status, string displayName) + private async Task<(HttpClient Client, string Identity)> SignedInAsync(MembershipStatus status, string displayName, ReceiptState receipt = ReceiptState.Current) { var client = NewClient(); - var identity = await RegisterAsync(client, $"member-{Guid.NewGuid():N}"); + var identity = await RegisterAsync(client, $"member-{Guid.NewGuid():N}", agree: receipt != ReceiptState.None); + if (receipt is ReceiptState.Withdrawn or ReceiptState.OtherVersion) + { + await _factory.SetReceiptAsync(identity, receipt); + } + await _factory.SetMembershipAsync(identity, status); await _factory.SetDisplayNameAsync(identity, displayName); return (client, identity); diff --git a/tests/Bifrost.Web.Tests/OidcLogoutTests.cs b/tests/Bifrost.Web.Tests/OidcLogoutTests.cs index c167fc2..c2e5b0f 100644 --- a/tests/Bifrost.Web.Tests/OidcLogoutTests.cs +++ b/tests/Bifrost.Web.Tests/OidcLogoutTests.cs @@ -76,6 +76,25 @@ public sealed class OidcLogoutTests Assert.Single(factory.Mas.Bodies); } + [Theory] + [InlineData(ReceiptState.Withdrawn)] + [InlineData(ReceiptState.OtherVersion)] + [InlineData(ReceiptState.None)] + public async Task An_account_whose_agreement_has_gone_is_logged_out_while_one_that_agreed_is_left_alone(ReceiptState gone) + { + using var factory = new OidcLogoutFactory(); + var (silenced, _) = await factory.GrantAsync(MembershipStatus.Active); + var (agreed, _) = await factory.GrantAsync(MembershipStatus.Active); + await factory.SetReceiptAsync(silenced, gone); + + await factory.Reconciler.ReconcileOnceAsync(CancellationToken.None); + + var token = new JsonWebToken(Assert.Single(factory.Mas.LogoutTokens())); + Assert.Equal(factory.AccountId(silenced).ToString("D"), token.Subject); + Assert.Equal(0, factory.ValidAuthorizations(factory.AccountId(silenced).ToString("D"))); + Assert.True(factory.ValidAuthorizations(factory.AccountId(agreed).ToString("D")) > 0); + } + [Fact] public async Task Two_logouts_for_one_subject_carry_different_jti() { diff --git a/tests/Bifrost.Web.Tests/RegistrationFormTests.cs b/tests/Bifrost.Web.Tests/RegistrationFormTests.cs index 260e865..9f49010 100644 --- a/tests/Bifrost.Web.Tests/RegistrationFormTests.cs +++ b/tests/Bifrost.Web.Tests/RegistrationFormTests.cs @@ -174,7 +174,8 @@ public sealed class RegistrationFormTests(RegistrationFormFactory factory) : ICl Assert.Contains("", html); Assert.Matches("]*/>", html); Assert.DoesNotMatch("]*\\bchecked\\b", html); - Assert.Contains("I agree to this notice (optional)", html); + Assert.Contains("I agree to this notice, to speak in a recorded room", html); + Assert.Contains("Withdrawing ends future speaking there; lines you have already spoken stay.", html); Assert.DoesNotMatch("]*\\brequired\\b", html); foreach (var id in new[] { "identity", "displayName", "consent" }) { @@ -283,7 +284,7 @@ public sealed class RegistrationFormTests(RegistrationFormFactory factory) : ICl var identity = NewIdentity(); var before = Counts(); var fields = FormKit.Fields(html, identity, nonce: FormKit.Solved(html)); - fields["acceptedNoticeVersion"] = "1"; + fields["acceptedNoticeVersion"] = (TrainingNotice.Version + 1).ToString(); using var response = await FormKit.PostAsync(client, fields); var again = await response.Content.ReadAsStringAsync(); diff --git a/tests/Bifrost.Web.Tests/RegistrationGateTests.cs b/tests/Bifrost.Web.Tests/RegistrationGateTests.cs index 49d2e78..2356f60 100644 --- a/tests/Bifrost.Web.Tests/RegistrationGateTests.cs +++ b/tests/Bifrost.Web.Tests/RegistrationGateTests.cs @@ -84,7 +84,7 @@ public sealed class RegistrationGateTests(RegistrationGateFactory factory) : ICl [Theory] [InlineData(0)] [InlineData(-1)] - [InlineData(1)] + [InlineData(2)] [InlineData(3)] public async Task A_wrong_notice_version_is_refused_and_creates_nothing(int version) { @@ -234,7 +234,6 @@ public sealed class RegistrationGateTests(RegistrationGateFactory factory) : ICl var db = scope.ServiceProvider.GetRequiredService(); var account = db.UserAccounts.Single(x => x.NormalizedBifrostIdentity == identity); var receipt = db.ConsentReceipts.Single(x => x.UserAccountId == account.Id); - Assert.Equal(2, receipt.NoticeVersion); Assert.Equal(TrainingNotice.Version, receipt.NoticeVersion); Assert.Null(receipt.WithdrawnAtUtc); Assert.InRange((factory.Time.GetUtcNow() - receipt.AcceptedAtUtc).Duration(), TimeSpan.Zero, TimeSpan.FromSeconds(5)); @@ -247,7 +246,7 @@ public sealed class RegistrationGateTests(RegistrationGateFactory factory) : ICl var notice = await client.GetFromJsonAsync("/auth/bifrost/notice"); - Assert.Equal(2, notice.GetProperty("version").GetInt32()); + Assert.Equal(TrainingNotice.Version, notice.GetProperty("version").GetInt32()); Assert.Contains("Discord", notice.GetProperty("text").GetString()); } diff --git a/tests/Bifrost.Web.Tests/Support/OidcTestKit.cs b/tests/Bifrost.Web.Tests/Support/OidcTestKit.cs index a9b304f..2a6aed1 100644 --- a/tests/Bifrost.Web.Tests/Support/OidcTestKit.cs +++ b/tests/Bifrost.Web.Tests/Support/OidcTestKit.cs @@ -16,6 +16,15 @@ using Microsoft.Extensions.DependencyInjection.Extensions; namespace Bifrost.Web.Tests.Support; +/// The consent receipt an account holds, from a consumer's side: current, none, withdrawn, or for a version other than the one in force. +public enum ReceiptState +{ + Current, + None, + Withdrawn, + OtherVersion +} + /// What is wrong with the signing key file a test host is started with. public enum OidcKeyFault { @@ -225,6 +234,48 @@ public class OidcWebApplicationFactory : TestWebApplicationFactory await db.SaveChangesAsync(); } + /// Leaves the account with exactly the receipt state a test names: none, or one receipt for the version in force, a withdrawn one, or one for another version. + public async Task SetReceiptAsync(string identity, ReceiptState state) + { + using var scope = Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var id = db.UserAccounts.Single(x => x.NormalizedBifrostIdentity == identity).Id; + db.ConsentReceipts.RemoveRange(db.ConsentReceipts.Where(x => x.UserAccountId == id)); + await db.SaveChangesAsync(); + await AddReceiptAsync(db, id, state); + } + + public List ReceiptsOf(string identity) + { + using var scope = Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + return db.ConsentReceipts.AsNoTracking().Where(x => x.UserAccount!.NormalizedBifrostIdentity == identity).ToList(); + } + + /// A receipt for an account that exists only as an actor fixture. + public async Task AgreeAsync(Guid accountId) + { + using var scope = Services.CreateScope(); + await AddReceiptAsync(scope.ServiceProvider.GetRequiredService(), accountId, ReceiptState.Current); + } + + private static async Task AddReceiptAsync(BifrostDbContext db, Guid accountId, ReceiptState state) + { + if (state != ReceiptState.None) + { + var now = DateTimeOffset.UtcNow; + db.ConsentReceipts.Add(new ConsentReceipt + { + UserAccountId = accountId, + // Another version stands for a notice that has since been bumped (or, in a future bump, the one before it). + NoticeVersion = state == ReceiptState.OtherVersion ? Bifrost.Web.Features.Registration.TrainingNotice.Version - 1 : Bifrost.Web.Features.Registration.TrainingNotice.Version, + AcceptedAtUtc = now, + WithdrawnAtUtc = state == ReceiptState.Withdrawn ? now : null + }); + await db.SaveChangesAsync(); + } + } + public Guid AccountId(string identity) { using var scope = Services.CreateScope(); @@ -264,18 +315,20 @@ public sealed class OidcNoIdentityFactory : OidcWebApplicationFactory ///
public sealed class OidcFixedIdentityFactory(string identity, MembershipStatus status = MembershipStatus.Active) : OidcWebApplicationFactory { + public Guid FixedAccountId { get; } = Guid.NewGuid(); + protected override void ConfigureActors(IServiceCollection services) { services.RemoveAll(); - services.AddScoped(_ => new FixedIdentity(identity, status)); + services.AddScoped(_ => new FixedIdentity(FixedAccountId, identity, status)); } - private sealed class FixedIdentity(string identity, MembershipStatus status) : ICurrentBifrostActorAccessor + private sealed class FixedIdentity(Guid id, string identity, MembershipStatus status) : ICurrentBifrostActorAccessor { public Task GetAsync(CancellationToken cancellationToken) => Task.FromResult(new CurrentBifrostActor(true, new UserAccount { - Id = Guid.NewGuid(), + Id = id, BifrostIdentity = identity, NormalizedBifrostIdentity = identity, // as given: the guard, not the fixture, must fold case DisplayName = "Fixed Identity", diff --git a/tests/Bifrost.Web.Tests/Support/RegistrationKit.cs b/tests/Bifrost.Web.Tests/Support/RegistrationKit.cs index 7f15ff3..72654cb 100644 --- a/tests/Bifrost.Web.Tests/Support/RegistrationKit.cs +++ b/tests/Bifrost.Web.Tests/Support/RegistrationKit.cs @@ -33,7 +33,8 @@ public static class RegistrationKit string identity, string? displayName = null, string? returnUrl = null, - bool anonymous = false) + bool anonymous = false, + bool agree = true) { var (challenge, nonce) = await SolveAsync(client); return await PostAsync(client, new @@ -43,7 +44,7 @@ public static class RegistrationKit returnUrl, challenge, nonce, - acceptedNoticeVersion = TrainingNotice.Version + acceptedNoticeVersion = agree ? TrainingNotice.Version : (int?)null }, anonymous); } diff --git a/tests/Bifrost.Web.Tests/TrainingNoticeFrozenTests.cs b/tests/Bifrost.Web.Tests/TrainingNoticeFrozenTests.cs new file mode 100644 index 0000000..c829bd6 --- /dev/null +++ b/tests/Bifrost.Web.Tests/TrainingNoticeFrozenTests.cs @@ -0,0 +1,44 @@ +using System.Security.Cryptography; +using System.Text; +using Bifrost.Web.Features.Registration; + +namespace Bifrost.Web.Tests; + +/// +/// A receipt records agreement to specific words, so served words never change under a version: text change requires a +/// version bump. When the notice is reworded, bump and ADD a line here; never edit one. +/// +public sealed class TrainingNoticeFrozenTests +{ + private static readonly Dictionary Frozen = new() + { + [1] = "9ba9be747a7387d70f2f3ee75fd7460a2e6cbe40a8c7e80aae7d7efd9b33a314" + }; + + [Fact] + public void The_text_of_the_version_in_force_is_the_text_that_was_frozen_for_it() + { + Assert.True(Frozen.TryGetValue(TrainingNotice.Version, out var pinned), $"Version {TrainingNotice.Version} has no frozen hash: add one when you bump the version."); + var actual = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(TrainingNotice.Text))).ToLowerInvariant(); + Assert.True(pinned == actual, $"The notice text changed but the version is still {TrainingNotice.Version}. Bump TrainingNotice.Version and add its hash ({actual}) here; do not edit the old line."); + } + + [Fact] + public void The_notice_names_what_agreement_is_for_and_what_withdrawal_leaves_standing() + { + Assert.Contains("to speak in a recorded room", TrainingNotice.Text); + Assert.Contains("Lines you have already spoken stay", TrainingNotice.Text); + Assert.StartsWith($"GameCult chat training notice, version {TrainingNotice.Version}\n", TrainingNotice.Text); + } + + [Fact] + public void The_consent_page_status_lines_keep_their_exact_words() + { + var at = new DateTimeOffset(2026, 11, 1, 12, 30, 5, TimeSpan.Zero); + var v = TrainingNotice.Version; + Assert.Contains($"You agreed to version {v} on 2026-11-01T12:30:05Z. You may speak in a recorded room, and your messages may be used as the notice says.", ConsentPage.Render(new ConsentState.Current(at), null)); + Assert.Contains($"You agreed to version 0 on 2026-11-01T12:30:05Z; version {v} is in force; you cannot speak in a recorded room and your messages are not used until you agree to it.", ConsentPage.Render(new ConsentState.Older(0, at), null)); + Assert.Contains("You withdrew on 2026-11-01T12:30:05Z. You cannot speak in a recorded room, and your messages are not used. Lines you already spoke stay.", ConsentPage.Render(new ConsentState.Withdrawn(at), null)); + Assert.Contains("You have not agreed. You cannot speak in a recorded room, and your messages are not used.", ConsentPage.Render(new ConsentState.None(), null)); + } +}