Chat sign-in needs a live consent receipt; notice frozen as version 1 #59

Merged
heimdall merged 2 commits from heimdall/chat-consent-gate into main 2026-11-11 14:32:07 +00:00
19 changed files with 536 additions and 92 deletions

View file

@ -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=<this request's own /connect/authorize?request_uri=... path>`. 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 <A's access_token>`. 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

View file

@ -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

View file

@ -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.

View file

@ -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.
}
}
}

View file

@ -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<IResult> AuthorizeAsync(HttpContext http, ICurrentBifrostActorAccessor actors)
private static async Task<IResult> 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<IResult> 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<string, object?> { [Claims.Subject] = result.Principal.GetClaim(Claims.Subject) };
@ -135,19 +145,25 @@ public static class OidcEndpoints
/// <summary>
/// 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.
/// </summary>
internal static async Task<bool> IsStillChatEligibleAsync(BifrostDbContext db, string? subject, CancellationToken cancellationToken)
internal static async Task<string?> 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;
}
/// <summary>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.</summary>

View file

@ -11,7 +11,7 @@ using static OpenIddict.Abstractions.OpenIddictConstants;
namespace Bifrost.Web.Features.Oidc;
/// <summary>
/// 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<BifrostDbContext>();
if (await OidcEndpoints.IsStillChatEligibleAsync(db, subject, cancellationToken))
if (await OidcEndpoints.ChatRefusalAsync(db, subject, cancellationToken) is null)
{
_backoff.Remove(subject);
return;

View file

@ -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);
/// <summary>Where the consent page sends the member after agreeing: a local path (<see cref="LocalReturnPath"/>) the refused chat authorize names, else the page itself.</summary>
internal static string? ReturnPathOf(Microsoft.Extensions.Primitives.StringValues requested) =>
requested.ToString() is { Length: > 0 } text ? LocalReturnPath.Parse(text).Value : null;
/// <summary>The consent page that a refused chat sign-in lands on, carrying the request to resume once the member agrees.</summary>
public static string PathFor(string returnPath) => $"{Path}?returnUrl={Uri.EscapeDataString(returnPath)}";
private static IResult Back() => new SeeOther(Path);
private static Task<bool> HasLiveReceiptAsync(BifrostDbContext db, Guid accountId, CancellationToken ct) =>
/// <summary>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.</summary>
internal static Task<bool> 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<IFormCollection?> ReadFormAsync(HttpRequest request, CancellationToken ct)
@ -172,10 +180,10 @@ public static class ConsentEndpoints
}
}
private static async Task<IResult> PageAsync(BifrostDbContext db, Guid accountId, string? error, int status, CancellationToken ct)
private static async Task<IResult> 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

View file

@ -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 ? "" : $"""<div id="errors" role="alert" tabindex="-1"><strong>That did not go through.</strong><p>{E(error)}</p></div>""";
// 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 ? "" : $"""<input type="hidden" name="returnUrl" value="{E(returnPath)}" />""";
var withdrawButton = anyLive
? """<form method="post" action="/auth/bifrost/consent/withdraw"><button type="submit">Withdraw</button></form>"""
: "";
var agreeButton = state is ConsentState.Current
? ""
: $"""<form method="post" action="/auth/bifrost/consent/accept"><input type="hidden" name="version" value="{version}" /><button type="submit">Agree to version {version}</button></form>""";
: $"""<form method="post" action="/auth/bifrost/consent/accept"><input type="hidden" name="version" value="{version}" />{back}<button type="submit">Agree to version {version}</button></form>""";
var button = agreeButton + withdrawButton;
return $$"""
@ -91,11 +92,11 @@ public static class ConsentPage
<h1>Training consent</h1>
{{summary}}
<p id="state" aria-live="polite">{{E(message)}}</p>
{{button}}
<fieldset>
<legend>Training notice, version {{version}}</legend>
{{TrainingNotice.Html("notice")}}
</fieldset>
{{button}}
</main>
</body>
</html>

View file

@ -99,7 +99,7 @@ public static class RegistrationPage
var link = model.ErrorField switch
{
RegistrationField.Identity => """<ul><li><a href="#identity">Identity name</a></li></ul>""",
RegistrationField.Consent => """<ul><li><a href="#consent">I agree to this notice (optional)</a></li></ul>""",
RegistrationField.Consent => """<ul><li><a href="#consent">I agree to this notice, to speak in a recorded room</a></li></ul>""",
_ => ""
};
summary.Append($"""<div id="errors" role="alert" tabindex="-1"><strong>Registration did not go through.</strong><p>{E(model.Error)}</p>{link}</div>""");
@ -154,8 +154,8 @@ public static class RegistrationPage
<fieldset>
<legend>Training notice, version {{notice}}</legend>
{{TrainingNotice.Html("notice")}}
<label class="check" for="consent"><input type="checkbox" id="consent" name="consent" value="yes" aria-describedby="notice"{{Invalid(RegistrationField.Consent)}} />I agree to this notice (optional)</label>
<p class="hint">Optional. You can create an account without agreeing; then none of your messages are used. You can agree later on your consent page.</p>
<label class="check" for="consent"><input type="checkbox" id="consent" name="consent" value="yes" aria-describedby="notice"{{Invalid(RegistrationField.Consent)}} />I agree to this notice, to speak in a recorded room</label>
<p class="hint">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.</p>
<input type="hidden" name="acceptedNoticeVersion" value="{{notice}}" />
</fieldset>
<input type="hidden" name="challenge" value="{{E(model.Challenge)}}" />

View file

@ -5,21 +5,22 @@ namespace Bifrost.Web.Features.Registration;
/// <summary>
/// The training notice a person may agree to. Changing the wording in substance means a new <see cref="Version"/> (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.
/// </summary>
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.";
/// <summary>The text as HTML-encoded paragraphs inside a div with the given id.</summary>
public static string Html(string id)

View file

@ -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<string, string> { ["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<string, string> { ["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("")]

View file

@ -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.

View file

@ -224,6 +224,7 @@ public sealed class OidcIssuerTests : IClassFixture<OidcWebApplicationFactory>
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<OidcWebApplicationFactory>
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";
/// <summary>A form post as the consent page's own buttons send it: the site's own Origin, no antiforgery token.</summary>
private async Task<HttpResponseMessage> ConsentPostAsync(HttpClient client, string action, string? returnUrl = null)
{
var fields = new Dictionary<string, string> { ["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<Bifrost.Web.Data.BifrostDbContext>();
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<Bifrost.Web.Data.BifrostDbContext>();
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<OidcWebApplicationFactory>
private HttpClient NewClient() => _factory.CreateClient(ClientOptions());
private static async Task<string> RegisterAsync(HttpClient client, string identity)
private static async Task<string> 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);

View file

@ -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()
{

View file

@ -174,7 +174,8 @@ public sealed class RegistrationFormTests(RegistrationFormFactory factory) : ICl
Assert.Contains("<html lang=\"en\">", html);
Assert.Matches("<input type=\"checkbox\" id=\"consent\" name=\"consent\" value=\"yes\" [^>]*/>", html);
Assert.DoesNotMatch("<input type=\"checkbox\"[^>]*\\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("<input type=\"checkbox\"[^>]*\\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();

View file

@ -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<BifrostDbContext>();
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<JsonElement>("/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());
}

View file

@ -16,6 +16,15 @@ using Microsoft.Extensions.DependencyInjection.Extensions;
namespace Bifrost.Web.Tests.Support;
/// <summary>The consent receipt an account holds, from a consumer's side: current, none, withdrawn, or for a version other than the one in force.</summary>
public enum ReceiptState
{
Current,
None,
Withdrawn,
OtherVersion
}
/// <summary>What is wrong with the signing key file a test host is started with.</summary>
public enum OidcKeyFault
{
@ -225,6 +234,48 @@ public class OidcWebApplicationFactory : TestWebApplicationFactory
await db.SaveChangesAsync();
}
/// <summary>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.</summary>
public async Task SetReceiptAsync(string identity, ReceiptState state)
{
using var scope = Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
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<ConsentReceipt> ReceiptsOf(string identity)
{
using var scope = Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
return db.ConsentReceipts.AsNoTracking().Where(x => x.UserAccount!.NormalizedBifrostIdentity == identity).ToList();
}
/// <summary>A receipt for an account that exists only as an actor fixture.</summary>
public async Task AgreeAsync(Guid accountId)
{
using var scope = Services.CreateScope();
await AddReceiptAsync(scope.ServiceProvider.GetRequiredService<BifrostDbContext>(), 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
/// </summary>
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<ICurrentBifrostActorAccessor>();
services.AddScoped<ICurrentBifrostActorAccessor>(_ => new FixedIdentity(identity, status));
services.AddScoped<ICurrentBifrostActorAccessor>(_ => 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<CurrentBifrostActor> 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",

View file

@ -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);
}

View file

@ -0,0 +1,44 @@
using System.Security.Cryptography;
using System.Text;
using Bifrost.Web.Features.Registration;
namespace Bifrost.Web.Tests;
/// <summary>
/// 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 <see cref="TrainingNotice.Version"/> and ADD a line here; never edit one.
/// </summary>
public sealed class TrainingNoticeFrozenTests
{
private static readonly Dictionary<int, string> 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));
}
}