18 KiB
Bifrost OIDC issuer
Bifrost is the GameCult identity issuer. It runs an OpenID Connect provider on its public host so that Matrix Authentication Service (MAS) can sign chat users in with their Bifrost account. Heimdall authenticates people into 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 (not suspended, and agreed to
the training notice in force; see "Chat needs a receipt").
Endpoints
The issuer is Host:PublicBaseUrl, normalized by .NET into an absolute URI. For a bare host that adds a trailing
slash, so for https://bifrost.gamecult.org the issuer string is https://bifrost.gamecult.org/. Configure MAS with
that exact string.
| Endpoint | Path |
|---|---|
| Discovery | /.well-known/openid-configuration |
| JWKS | /.well-known/jwks |
| Authorization | /connect/authorize |
| Token | /connect/token |
| Userinfo | /connect/userinfo |
Only the authorization code flow exists, and PKCE (S256) is required. There is no implicit, hybrid, password,
client-credentials or device flow, no refresh token, and no dynamic client registration. The token endpoint takes
client_secret_basic only. Scopes are openid and profile.
The one client
Exactly one client is registered, from configuration, and seeded or updated at startup; every other application
row (with its authorizations and tokens) is deleted at startup. Any other client_id, an unregistered redirect_uri, or a request without PKCE is refused by the
provider before any sign-in redirect.
Configuration
The section is Bifrost:Oidc. Absent, the provider surface is not mapped at all and discovery answers 404. Present,
it must be complete: an unreadable or wrong-shaped file stops startup with a message that names the setting and never
echoes its value.
| Setting | Meaning |
|---|---|
Bifrost:Oidc:SigningKeyPath |
PEM file with an EC P-256 private key. Tokens and id_tokens are signed ES256; JWKS publishes exactly that key. A non-P-256 or public-only key stops startup. |
Bifrost:Oidc:EncryptionKeyPath |
File holding 32 random bytes as base64 (openssl rand -base64 32). Encrypts the tokens OpenIddict issues, the access token and the parked authorize request among them. |
Bifrost:Oidc:Clients:Mas:ClientId |
The client id MAS presents. |
Bifrost:Oidc:Clients:Mas:ClientSecretPath |
File holding the client secret. |
Bifrost:Oidc:Clients:Mas:RedirectUri |
MAS's callback URL, registered exactly. |
Bifrost:Oidc:BrakePath |
Optional. While a file exists at this path, /connect/authorize, /token and /userinfo answer 503 temporarily_unavailable and nothing is issued; delete the file to release. Checked per request, so no restart. Unset: no marker brake. |
Bifrost:Oidc:Limits:* |
PerAddressPermits (30), Window (1 min), MaxTrackedKeys (10000), MaxTokenRows (20000), PruneInterval (5 min). |
Bifrost:Oidc:Clients:Mas:BackchannelLogoutUri |
Optional. MAS's back-channel logout URL, https://<mas>/upstream/backchannel-logout/<provider ULID>; absolute http(s) or startup fails. Unset: no logout is sent and no authorization is revoked. |
There are no development or ephemeral keys in the host. Key files are provisioned like the other
[workload.secret_files] paths.
Claims
| Claim | Value |
|---|---|
sub |
UserAccount.Id, formatted D |
preferred_username |
NormalizedBifrostIdentity (minted once; there is no rename path) |
name |
DisplayName, else BifrostIdentity |
No e-mail, no Heimdall id, no GitHub id, and no membership detail leaves Bifrost. A native account's verified address, once that is built, stays inside Bifrost as sign-in recovery and is never a claim (registration-gates.md, Email verification).
Who may sign in
The authorize endpoint reads the cookie session and the same actor facts the /App policy reads.
- No session: challenge to
/auth/sign-in, which resumes the original request after sign-in. OpenIddict parks the full request (its request token, in the OpenIddict token table, expiring after ten minutes, spent when used) and the return path carries only a shortrequest_uri, soLocalReturnPath.MaxLengthstays 256. - Signed in, membership
Suspended: redirect to the client'sredirect_uriwitherror=access_deniedanderror_description=The account is suspended. - Signed in with no membership,
AuthenticatedorPendingApproval(the chat tier): treated like an active member for sign-in to chat and nothing else. The token carries the sameopenid profileclaims; the issuer writes no membership, role or vote, and every member or governance surface (/App,/eve/governance,/api/sealed-motions, the fork desk) still asks forActiveitself, so neither the session nor the token opens them. - Signed in, with an account that can chat, but
NormalizedBifrostIdentityempty:error=access_deniedwitherror_description=The account has no Bifrost identity. - Signed in, with an account that can chat, but the identity is a reserved localpart (
BifrostIdentityService.IsReserved: the 13 Synapse accounts and the system names, the same 28 as MASbanned_usernames):error=access_deniedwitherror_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 ofUserAccountsagainst 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'sredirect_uriis not called. The consent page shows the notice and an Agree button; agreeing is a 303 back to thereturnUrl, 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 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 403insufficient_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.
- 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=S256with A's cookie. Expect 302 to the redirect URI withcodeandstate. Exchange it:POST $ISS/connect/tokenwithclient_secret_basicandgrant_type=authorization_code,code,redirect_uri,code_verifier. Expect 200 withaccess_tokenandid_token. GET $ISS/connect/userinfowithAuthorization: Bearer <A's access_token>. Expect 200 and exactlysub,preferred_username,name.- Same authorize request with B's cookie. Expect 302 whose
Locationstarts/auth/bifrost/consent?returnUrl=%2Fconnect%2Fauthorize%3Frequest_uri, and nocodeanywhere. (OpenIddict first parks the request and redirects to the same URL with arequest_uri; follow that one hop, the consent redirect is the next answer. A missing receipt ignoresprompt=none: still a 302 to the consent page, neverconsent_required.)GETthat 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. - 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-Authenticatewitherror="insufficient_access", and nosuborpreferred_usernamein the body. Repeat step 1's authorize for A: expect the consent redirect of step 3, not a code. - A clicks Agree. Repeat step 1's authorize and token, then step 2: expect 302 with
code, 200 tokens, 200 userinfo. - A code obtained before step 4 and exchanged after it: expect 400
invalid_grantand noaccess_token. - Control: with the receipt gone, B can still sign in to Bifrost, open
/App/Membersand 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, 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
http://schemas.openid.net/event/backchannel-logout event. It carries no nonce and no sid; MAS finds the sessions
by sub. It is sent as application/x-www-form-urlencoded field logout_token with a 10 second timeout, and the
response body is never logged.
Failure handling is per subject, in memory: a 5xx, a timeout or a network fault backs off exponentially from 30 seconds to 15 minutes; a 400 (token refused) or 404 (unknown provider ULID) is a configuration fault, logged by status only, and retried at the 15 minute cap. Nothing is revoked until MAS answers 2xx. A restart loses the backoff and nothing else: the authorizations table is the queue, so a later-set URI also catches up on earlier demotions.
Without BackchannelLogoutUri none of this runs: suspension stops new sign-ins and token refresh (the authorize, token and userinfo
endpoints refuse a suspended account) but does not end a chat session that is already live, and the issuer logs one warning at
startup saying so. The other lever is MAS admin deactivation of the user (ops, not built here).
Tradeoff: delivery latency is up to one tick, and a member demoted and re-promoted inside one tick keeps their sessions.
MAS verified at v1.28.0 (crates/handlers/src/upstream_oauth2/backchannel_logout.rs): the token is checked like an
id_token (iss, aud, alg, JWKS signature), exp and iat are required, sub or sid is required, nonce must be
absent. Owed on the MAS side (gamecult-chat): the provider's backchannel_logout_uri and
on_backchannel_logout: logout_all. Re-check against the MAS version pinned there.
MAS configuration
Configure MAS's upstream provider with the issuer above, the client id and secret, the token endpoint auth method
client_secret_basic, the id_token signing algorithm ES256, scope openid profile, and a localpart claim import
from preferred_username. MAS mints the Matrix localpart on first sign-in, so the claim must never change for an
account.
Limits, prune, brake
OidcGuardMiddleware sits ahead of OpenIddict and guards the three endpoints, matched by path segment so a trailing slash
is no way around it. In order: the marker brake (503; a marker that cannot be read is treated as engaged); one per-address
budget over the three endpoints together (429 with Retry-After; the address is the connection's after forwarded-headers
handling that trusts only the local proxy, IPv6 keyed by /64); and a cap on token rows: a new authorize request (no
request_uri) is refused with 503 while the table holds MaxTokenRows. Any failure inside the guard is a 503, never a pass.
MAS's flow is a handful of requests per sign-in from one address, far under the defaults (30 a minute is about 15 sign-ins).
There is no per-client budget. The one client's id is public and what a caller names is unverified, so a budget keyed on it would let anyone spend the real client's permits and lock sign-in out. The bound on the anonymous endpoint is the address budget plus the row cap, and the cap is itself a switch an attacker with many addresses can flip: it refuses new sign-ins (never resumed ones) until the next prune frees rows, which is the bounded failure chosen over unbounded parked rows. Not parking state before the user authenticates (as pushed authorization requests avoid by authenticating the client first) would remove the switch, and OpenIddict's request caching does not offer that.
OidcPruner runs every PruneInterval and deletes tokens past expiry (parked authorize requests, codes, access tokens) or no
longer valid, then authorizations no longer valid or ad hoc with no token left (younger than five minutes are left alone).
Live rows are untouched. Plain EF queries rather than OpenIddict's own prune, which uses ExecuteDelete and cannot run on the
in-memory provider the tests use.
docs/oidc-reserved-accounts.sql is the read-only query for existing accounts that hold one of the 28 reserved names.
Not built
Checked against the Window B list (2026-11-11). Present and tested: discovery, JWKS, authorize/token/userinfo, PKCE S256,
the one client, claims to a Matrix-legal localpart ([a-z0-9._-], 3-80, lower case), reserved names at creation and now at
issuance, back-channel logout. Not present:
- MAS upstream block.
gamecult-opschat/matrix-auth/config.yamlhas noupstream_oauth2(provider ULID,on_conflict: fail,on_backchannel_logout: logout_all, the redirect URI above); that lives in the ops repo.
Remaining scar
Heimdall still mints its own account id in code (src/app.ts createAccount). That id is an authenticator handle
and chat does not need it to move.