Linked accounts: docs, README line, proof-provider and no-store tests

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Heimdall 2026-11-13 19:43:59 +00:00
commit b65bec65ce
4 changed files with 68 additions and 1 deletions

View file

@ -11,6 +11,7 @@ This repository now includes a viable alpha foundation slice of the ASP.NET Core
- Razor Pages app under `src/Bifrost.Web`
- PostgreSQL-backed EF Core model with an initial migration under `src/Bifrost.Web/Data/Migrations`
- Bifrost-native identity registration plus GitHub OAuth and Heimdall-linked provider identities, with invite/approval-based membership gating
- a Linked accounts page where a member sees, links and unlinks their Heimdall provider logins, with unlink gated on a fresh Google step-up proof ([docs/linked-accounts.md](docs/linked-accounts.md))
- explicit member roles and admin-managed patron or contributor tier snapshots
- patron support event recording with derived patron point summaries and tier refresh for voting weight
- shared work board with estimates, actual time logs, review flow, and GitHub issue or PR links

View file

@ -105,6 +105,8 @@ This keeps Reddit useful without making subreddit mechanics the secret constitut
Heimdall authenticates; it does not issue GameCult identity. Bifrost owns the identity (`UserAccount.Id`, `NormalizedBifrostIdentity`) and issues it to chat as an OIDC provider (`docs/oidc-issuer.md`). `HeimdallAccountId` is an authenticator handle that links a Heimdall account to a Bifrost identity.
A member's provider logins are Heimdall's (`linked_identities`); Bifrost shows, links and unlinks them on `/Membership/LinkedAccounts` by calling Heimdall's member routes with a bearer from a fresh Heimdall sign-in, and keeps none (`docs/linked-accounts.md`).
Bifrost consumes Heimdall claims. It does not become the key vault. The split is bridge versus gate: Heimdall decides who may cross under which grant; Bifrost moves authorized work across public protocols and records where it landed.
### Patron Collection Crossing

49
docs/linked-accounts.md Normal file
View file

@ -0,0 +1,49 @@
# Linked accounts
`/Membership/LinkedAccounts` (`src/Bifrost.Web/Pages/Membership/LinkedAccounts.cshtml[.cs]`) is where a signed-in member sees the provider logins attached to their Heimdall account, links another one, and unlinks one. Heimdall owns the logins and every rule about them; this page starts the flows and shows what Heimdall answered. A member reaches it from the membership status page. A session with no Heimdall account (a Bifrost-native GitHub sign-in) gets a note instead of controls.
## What it does
| Action | What Bifrost does | Heimdall call |
|---|---|---|
| Show my linked logins | A fresh ordinary Heimdall sign-in as the session's account; inside its completion, the list | `GET /v1/me/identities` with that sign-in's bearer |
| Link Discord, Patreon or Google | The same fresh sign-in as proof, then the link start for the chosen provider with its bearer. Discord and Patreon attach in Heimdall's callback and return a link completion; Google writes a pending link, and a second fresh sign-in confirms it | `POST /v1/oauth/{provider}/start` `mode: "link"`, then for Google `POST /v1/apps/bifrost/links/confirm` |
| Unlink | A sign-in through Google with `stepUp: true` (app secret); inside its completion, the unlink | `POST /v1/me/identities/{id}/unlink` with that sign-in's bearer |
The linkable providers are the ones Heimdall's `bifrost` profile admits (`identityProviders` in Heimdall `src/app-profiles.ts`): Discord, Patreon, Google. Twitch is offered for sign-in elsewhere in Bifrost but the profile refuses it, so it is not offered here.
## How the bearer is kept: it is not
Bifrost keeps no Heimdall token. Each action is an attempt: a Data Protection cookie (`__Host-bifrost_heimdall_<slot>`, HttpOnly, Secure, ten minutes) holding a purpose (`ListProof`, `UnlinkProof`, `LinkStart`, `LinkAwaitGoogle`, `LinkConfirm`, `LinkAwaitDirect`), the account the session must be, and a target (the provider to link, or the identity to unlink). The browser returns from Heimdall to `/auth/heimdall/complete`; Bifrost redeems the code, verifies the token, requires that the proven account is the attempt's account and that the browser's session is that same account, calls Heimdall with the bearer inside that one request, and drops it. The bearer is never stored, logged, put in a cookie or a redirect, or rendered. Heimdall's member routes go through an HTTP client that carries no app secret (`HeimdallMemberClient`), so a Bifrost fault cannot call them without a member present.
The list is kept for five minutes in this process only (`LinkedAccountsSnapshots`), keyed by the Heimdall account id, so the page can render after the completion's redirect: identity id, provider, display name (or username when there is none) and link date. Never the bearer, the email, the provider user id or the scopes. It is not given to the browser. A restart or the five minutes empties it and the page offers "Show my linked logins" again.
Every action is a POST with antiforgery; a GET starts nothing. The page and every redirect carry only a code from a closed set (`HeimdallLinkOutcomes`, `?result=`); the wording is Bifrost's, never Heimdall's text or the query's.
## The proof limit
Unlinking needs fresh proof: Heimdall refuses `403 step_up_required` unless the access token carries `step_up_at` from a step-up sign-in within 600 seconds (`docs/service-contract.md`, `POST /v1/me/identities/{identityId}/unlink`, in the Heimdall repository). Only Google can give that to a `bifrost` sign-in: Discord and Patreon have no re-authentication parameter (an open provider session completes without credentials), and Twitch and YouTube, which do, are not in the `bifrost` profile. So:
- An account with no Google login linked cannot unlink anything from here. The page says so and shows no Unlink button; a posted unlink starts nothing.
- Heimdall answers `503 provider_not_configured` for Google until a Google OAuth client is configured on the Heimdall host (follow-up `heimdall-body:follow_up:google-console-client-registration`; not set live as of 2026-11-13). The step-up start is then refused and the page says fresh proof could not be started. Nothing loops: the refusal sets no attempt cookie.
- Linking Google needs the same client. Until then the Google link button ends in "That could not be completed. Nothing was changed."
- A Google step-up proof is as strong as Google's `prompt=login`; Heimdall says "asked", not "verified" (`stepUpRequested`).
Heimdall's other answers are shown as plain outcomes with nothing changed: `409 last_identity` (the last login is never removed), `404 identity_not_found` (the list is dropped as stale), `401 sign_in_required`, `403 step_up_required`, and any failure to reach Heimdall.
A link refused because the login already belongs to another account (`409 identity_linked_elsewhere` from `links/confirm`; for Discord and Patreon the callback's `link_initiator_mismatch`, carried on by the complete page as a fixed result) is shown as "That login belongs to another account." The other account is never named.
## Not here
- Merging two accounts. A refused link ends there; nothing is moved or merged.
- Step-up through Discord, Patreon or Twitch. Heimdall does not offer it to the `bifrost` profile.
- Linking Twitch or GitHub: not admitted by the `bifrost` profile, and Heimdall's GitHub runtime is not implemented.
- A list of other apps' logins, grants or consent receipts (Heimdall's grants page).
## Where the code is
- `Pages/Membership/LinkedAccounts.cshtml[.cs]`: the page and its three POST handlers.
- `Features/Heimdall/HeimdallAuthService.cs`: the attempt machinery (`StartListAsync`, `StartLinkAsync`, `StartUnlinkAsync`, `ContinueLinkAsync`), shared with sign-in.
- `Features/Heimdall/HeimdallMemberClient.cs`: the two member calls and their outcome mapping.
- `Features/Heimdall/LinkedAccountsSnapshots.cs`: the five-minute list.
- Tests: `tests/Bifrost.Web.Tests/LinkedAccountsTests.cs`, `HeimdallGoogleLinkTests.cs`, with the fake Heimdall in `Support/HeimdallTestKit.cs`.

View file

@ -95,9 +95,11 @@ public sealed class LinkedAccountsTests
{
using var host = new HeimdallHost();
var (_, html) = await host.GetLinkedAccountsAsync(session: null);
var (response, html) = await host.GetLinkedAccountsAsync(session: null);
var start = await host.StartListAsync(session: null);
// No form, so no antiforgery token to make the response uncacheable: the page itself must say no-store.
Assert.Contains("no-store", response.Headers.CacheControl?.ToString());
Assert.Contains("not a Heimdall account", html);
Assert.DoesNotContain("Link Discord", html);
Assert.Empty(host.Heimdall.StartBodies);
@ -558,6 +560,19 @@ public sealed class LinkedAccountsTests
Assert.Equal($"https://heimdall.test/oauth/{provider}/authorize?state=link-fixture", Location(step));
}
[Fact]
public async Task A_proof_sign_in_goes_through_a_provider_the_account_still_holds()
{
using var host = new HeimdallHost(realSessions: true);
// Signed in through Discord, which the account has since unlinked: signing in through it again would mint another account.
var member = await ListedMember(host, "patreon", "google");
var start = await host.StartLinkAsync(member.Session, "discord");
Assert.Equal("/v1/oauth/patreon/start", host.Heimdall.StartPaths[^1]);
Assert.NotNull(start.Cookie);
}
[Theory]
[InlineData("twitch")]
[InlineData("github")]