Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 425af74500 | |||
| c20947af1c | |||
|
|
18f1ec6aca | ||
|
|
8e65f56b0c | ||
|
|
b65bec65ce | ||
|
|
59ecaa606b | ||
| 9a716cfd7e | |||
| ae0037c159 | |||
|
|
96665a778b | ||
|
|
3b0a614ee9 | ||
|
|
d29f8014f9 | ||
|
|
25ce117f16 | ||
|
|
f04b20feed | ||
| e58e9531df | |||
|
|
1771f15789 | ||
|
|
1739d5552a | ||
| 920ecb1fbe | |||
|
|
1298e7db1e | ||
|
|
f9d0c24f66 | ||
| dc151e6cac | |||
|
|
1fbe16eb73 | ||
|
|
4c9937f881 | ||
|
|
95416653ba | ||
| d1d13992aa | |||
|
|
1998f0e6ae | ||
|
|
31ced94f2a | ||
| b687da954b | |||
|
|
87a1bb6b3b | ||
|
|
c085cead9d | ||
| fe0ff40864 | |||
|
|
280d843aeb | ||
|
|
25b9cc1683 | ||
|
|
95d2e8d262 | ||
| b80b27da0f | |||
| 420a4dbf85 | |||
|
|
e639e13a8b | ||
|
|
46cf7d4492 | ||
|
|
a3bab9b8e5 | ||
|
|
beafbe18e2 | ||
|
|
8a3262b3e8 | ||
|
|
ee88bc709d | ||
|
|
abeb8a1d5f | ||
|
|
6c4c9c00f9 | ||
|
|
2aabaabc53 | ||
|
|
99128e9f90 | ||
|
|
8abd912917 |
59 changed files with 7637 additions and 147 deletions
|
|
@ -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`
|
- Razor Pages app under `src/Bifrost.Web`
|
||||||
- PostgreSQL-backed EF Core model with an initial migration under `src/Bifrost.Web/Data/Migrations`
|
- 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
|
- 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 strong proof: a WebAuthn passkey (enrolled behind a fresh sign-in, 48 hour cooling-off), or a fresh Google step-up for an account with none ([docs/linked-accounts.md](docs/linked-accounts.md), [docs/passkeys.md](docs/passkeys.md))
|
||||||
- explicit member roles and admin-managed patron or contributor tier snapshots
|
- 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
|
- 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
|
- shared work board with estimates, actual time logs, review flow, and GitHub issue or PR links
|
||||||
|
|
@ -89,7 +90,7 @@ For the full staged roadmap, including contribution points, revenue share, patro
|
||||||
- set `Heimdall:PatronSupportIntakeSecret` before enabling Heimdall patron support intake in production
|
- set `Heimdall:PatronSupportIntakeSecret` before enabling Heimdall patron support intake in production
|
||||||
- default connection string points at local PostgreSQL
|
- default connection string points at local PostgreSQL
|
||||||
- build with `dotnet build Bifrost.slnx`
|
- build with `dotnet build Bifrost.slnx`
|
||||||
- test with `DOTNET_ROLL_FORWARD=Major dotnet test Bifrost.slnx` if the machine only has the .NET 10 runtime installed
|
- test with `dotnet test Bifrost.slnx`; the suite needs a real .NET 8 runtime (`DOTNET_ROOT`, with a runsettings `DotNetHostPath` if the SDK is newer; .NET 10 roll-forward makes Mvc.Testing 8.0.0 return 500 on every JSON response) and a built CultLib TypeScript runtime (`dist/` and `node_modules`) at `$VOIDBOT_CULTLIB_ROOT`
|
||||||
- dry-run a Reddit organizing thread with `node tools/bifrost-bridge.mjs reddit-post --title "Thread title" --persona-name Bifrost --content "Thread body" --dry-run true`
|
- dry-run a Reddit organizing thread with `node tools/bifrost-bridge.mjs reddit-post --title "Thread title" --persona-name Bifrost --content "Thread body" --dry-run true`
|
||||||
- print the Eve provider advertisement with `node tools/provider-advertisement.mjs print`
|
- print the Eve provider advertisement with `node tools/provider-advertisement.mjs print`
|
||||||
- export the Eve provider store with `node tools/provider-advertisement.mjs export --out .bifrost/provider-store.cc`
|
- export the Eve provider store with `node tools/provider-advertisement.mjs export --out .bifrost/provider-store.cc`
|
||||||
|
|
|
||||||
|
|
@ -255,13 +255,13 @@ Golden bytes: `tests/fixtures/persona-chat-v5/` (`chat_pending.v3.tail` carries
|
||||||
|
|
||||||
### Follow (`basis` `"follow"`, `CHAT_FOLLOW=1`)
|
### Follow (`basis` `"follow"`, `CHAT_FOLLOW=1`)
|
||||||
|
|
||||||
A message from someone she is in conversation with is for her even when it names no one. **It is off unless the mouth runs with `CHAT_FOLLOW=1`** (exactly `1`; default off), because Huginn's parser refuses an unknown basis until its `Basis::Follow` (and the one `is_for` line) lands. With the flag off the mouth emits no `follow`, makes no extra read, and keeps no follow state.
|
A message from someone she is in conversation with is for her even when it names no one. **It is off unless the mouth runs with `CHAT_FOLLOW=1`** (exactly `1`; default off), because the basis is Huginn's `Basis::FollowUp`, wire string `follow` (`FOLLOW_UP_BASIS`, f09bc56e), and a Huginn that predates it refuses the string. With the flag off the mouth emits no `follow`, makes no extra read, and keeps no follow state.
|
||||||
|
|
||||||
With it on:
|
With it on:
|
||||||
|
|
||||||
- **Anchor.** Her own post in a public room anchors that room for W = 600 s from the post. The mouth anchors at send time (the send's own time, in `send`, in the background: the send receipt never waits on the room-state read) and also from the /sync echo of her own events, so a post from another client of her account anchors too; one event anchors once. Only her posting again re-anchors the room and re-snapshots who is counted; a follow never extends the window.
|
- **Anchor.** Her own post in a public room anchors that room for W = 600 s from the post. The mouth anchors at send time (the send's own time, in `send`, in the background: the send receipt never waits on the room-state read) and also from the /sync echo of her own events, so a post from another client of her account anchors too; one event anchors once. Only her posting again re-anchors the room and re-snapshots who is counted; a follow never extends the window.
|
||||||
- **Counted senders** (snapshotted at the post): those who spoke in that room in the 10 minutes before the post (a per-room ring of recent senders with event times, 200 entries), plus anyone the post tagged (`m.mentions.user_ids`) or replied to (the author of the quoted `m.in_reply_to`, not a thread fallback pointer) when the event carries it. Someone who first speaks after her post is not counted. A member who spoke in the 10 minutes before her post is counted, bounded by 3 follows per hour per (peer, room). Nothing reads message text; no ids are scraped from bodies. She herself, and bridged senders (the `platformEvent` identifier rule), never count.
|
- **Counted senders** (snapshotted at the post): those who spoke in that room in the 10 minutes before the post (a per-room ring of recent senders with event times, 200 entries), plus anyone the post tagged (`m.mentions.user_ids`) or replied to (the author of the quoted `m.in_reply_to`, not a thread fallback pointer) when the event carries it. Someone who first speaks after her post is not counted. A member who spoke in the 10 minutes before her post is counted, bounded by 3 follows per hour per (peer, room). Nothing reads message text; no ids are scraped from bodies. She herself, and bridged senders (the `platformEvent` identifier rule), never count.
|
||||||
- **Follow.** A `message` (not an edit, not a notice) from a counted sender gets `follow` when it would otherwise have no basis or `room`; `tag`, `reply` and `direct` are never replaced. Its timestamp must lie inside the window (a homeserver clock up to 60 s behind still follows) and this process's clock must not be past the window's end by more than 60 s, so an old or forged `origin_server_ts` reuses nothing. At most K = 3 per (Mind, peer, room) in the hour before the message; past it the message keeps the basis it had.
|
- **Follow.** A `message` (not an edit, not a notice) from a counted sender gets `follow` only when it would otherwise have no basis (a plain message); `room` (@room), `tag`, `reply` and `direct` keep their own basis, and an @room neither wakes via follow nor spends the peer's K. Its timestamp must lie inside the window (a homeserver clock up to 60 s behind still follows) and this process's clock must not be past the window's end by more than 60 s, so an old or forged `origin_server_ts` reuses nothing. At most K = 3 per (Mind, peer, room) in the hour before the message; past it the message keeps the basis it had.
|
||||||
- **"Public" means non-DM, non-encrypted, non-bridged.** The room is not one of her DMs and its state has no `m.room.encryption`, `m.bridge` or `uk.half-shot.bridge` (read once per room, kept 5 minutes; unreadable or malformed state is not public). An invite-only plaintext room counts as public. Nothing is anchored in, and no follow is delivered in, any other room.
|
- **"Public" means non-DM, non-encrypted, non-bridged.** The room is not one of her DMs and its state has no `m.room.encryption`, `m.bridge` or `uk.half-shot.bridge` (read once per room, kept 5 minutes; unreadable or malformed state is not public). An invite-only plaintext room counts as public. Nothing is anchored in, and no follow is delivered in, any other room.
|
||||||
|
|
||||||
State is in memory only (per-room anchor, sender ring, follow times), purged on every sync pass; nothing is stored, and a restart only shortens windows.
|
State is in memory only (per-room anchor, sender ring, follow times), purged on every sync pass; nothing is stored, and a restart only shortens windows.
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,7 @@ Bifrost head `bf83a93` (2026-10-10; `20d5efa` plus the prior-art note, docs only
|
||||||
|
|
||||||
### Data Protection on the live host
|
### Data Protection on the live host
|
||||||
|
|
||||||
18. The live env file sets no `DataProtection__KeysPath` (key names only: `AllowedHosts`, `Bootstrap__*`, `ConnectionStrings__Bifrost`, `GitHubApp__EnableWebhookSync`, `GitHubOAuth__*`, `Heimdall__*`, `Host__*`, `Stripe__*`). So `Program.cs:113-119` never calls `PersistKeysToFileSystem`, and ASP.NET Core's default applies: the service runs as `bifrost` with `HOME=/srv/bifrost`, and the key ring is `/srv/bifrost/.aspnet/DataProtection-Keys` with two keys (created 2026-04-22 and 2026-07-22, the 90-day roll; prior-art note `docs/research/anonymous-vote-stuffing-prior-art.md:65-69`). A third, unused directory `/srv/bifrost/data-protection-keys` holds one key from 2026-07-15. Keys therefore persist across restarts today by accident of `HOME`, not by configuration.
|
18. The live env file sets no `DataProtection__KeysPath` (key names only: `AllowedHosts`, `Bootstrap__*`, `ConnectionStrings__Bifrost`, `GitHubApp__EnableWebhookSync`, `GitHubOAuth__*`, `Heimdall__*` (the app secret as `Heimdall__AppSecret`; `Heimdall__AppSecretFile` is the replacement), `Host__*`, `Stripe__*`). So `Program.cs:113-119` never calls `PersistKeysToFileSystem`, and ASP.NET Core's default applies: the service runs as `bifrost` with `HOME=/srv/bifrost`, and the key ring is `/srv/bifrost/.aspnet/DataProtection-Keys` with two keys (created 2026-04-22 and 2026-07-22, the 90-day roll; prior-art note `docs/research/anonymous-vote-stuffing-prior-art.md:65-69`). A third, unused directory `/srv/bifrost/data-protection-keys` holds one key from 2026-07-15. Keys therefore persist across restarts today by accident of `HOME`, not by configuration.
|
||||||
19. The service's working directory resolves to the release directory: `readlink /proc/<MainPID>/cwd` = `/srv/bifrost/app/releases/20260723-daedcc4`. Data Protection's default application discriminator is the content-root path (prior-art note `:67`), so every deploy into a new release directory isolates payloads protected by the previous release: auth cookies, antiforgery tokens and any future device token stop decrypting after each deploy. Mechanism from source docs plus this probe; not observed as logged-out users. `SetApplicationName` is not called anywhere (`git grep SetApplicationName bf83a93` is empty).
|
19. The service's working directory resolves to the release directory: `readlink /proc/<MainPID>/cwd` = `/srv/bifrost/app/releases/20260723-daedcc4`. Data Protection's default application discriminator is the content-root path (prior-art note `:67`), so every deploy into a new release directory isolates payloads protected by the previous release: auth cookies, antiforgery tokens and any future device token stop decrypting after each deploy. Mechanism from source docs plus this probe; not observed as logged-out users. `SetApplicationName` is not called anywhere (`git grep SetApplicationName bf83a93` is empty).
|
||||||
|
|
||||||
### Proxy and client address
|
### Proxy and client address
|
||||||
|
|
|
||||||
|
|
@ -54,9 +54,16 @@ permits, windows and `MaxTrackedKeys`.
|
||||||
|
|
||||||
New since the live release, and not read by the validator:
|
New since the live release, and not read by the validator:
|
||||||
|
|
||||||
- **`Heimdall__AppSecret`**: required for Discord and Patreon sign-in. Without it `HeimdallOptions.IsDiscordConfigured` and
|
- **`Heimdall__AppSecretFile`**: the preferred way to give Bifrost the app secret below. A path to a file holding it (one
|
||||||
`IsPatreonConfigured` are false and those sign-ins stop working (GitHub sign-in is unaffected); startup does not fail,
|
trailing newlines are trimmed), for example `%d/heimdall-app-secret` under systemd `LoadCredential=`, so the secret never
|
||||||
so a missing key shows only at the live check. The value is Heimdall's app secret for slug `bifrost`
|
rides in the process environment. When set, the file wins over `Heimdall__AppSecret`; a missing, unreadable or empty file
|
||||||
|
stops startup in every environment, with a message that names the option and path and never the value. Ops steps:
|
||||||
|
`docs/heimdall-app-secret-file.md`.
|
||||||
|
- **`Heimdall__AppSecret`** (deprecated; use `Heimdall__AppSecretFile`; startup logs a warning when it is set): required for
|
||||||
|
Discord and Patreon sign-in. Outside Development and Testing startup fails when neither this nor
|
||||||
|
`Heimdall__AppSecretFile` supplies a non-blank secret (the message names both options, never a value), because
|
||||||
|
`HeimdallOptions.IsDiscordConfigured` and `IsPatreonConfigured` would otherwise be false and those sign-ins silently
|
||||||
|
stop working (GitHub sign-in is unaffected). The value is Heimdall's app secret for slug `bifrost`
|
||||||
(`GC_ACCESS_APP_BIFROST_SHARED_SECRET` on the Heimdall side). Bifrost sends it as `x-heimdall-app-secret`, redacted
|
(`GC_ACCESS_APP_BIFROST_SHARED_SECRET` on the Heimdall side). Bifrost sends it as `x-heimdall-app-secret`, redacted
|
||||||
from logs.
|
from logs.
|
||||||
- **`Bifrost__Oidc__*`**: optional. Absent, the OIDC issuer is not mapped and discovery answers 404. Present, it must be
|
- **`Bifrost__Oidc__*`**: optional. Absent, the OIDC issuer is not mapped and discovery answers 404. Present, it must be
|
||||||
|
|
@ -129,7 +136,7 @@ All must hold before step 1. A missing one means the window is not used.
|
||||||
not a workstation.
|
not a workstation.
|
||||||
6. **Env diff done.** Every row of the Configuration table is marked present by the reader, `ConceptVotes__NetworkTagSecret`
|
6. **Env diff done.** Every row of the Configuration table is marked present by the reader, `ConceptVotes__NetworkTagSecret`
|
||||||
included and at least 32 characters (a count, not the value), and the `Heimdall__AppSecret` line is in `bifrost.env`
|
included and at least 32 characters (a count, not the value), and the `Heimdall__AppSecret` line is in `bifrost.env`
|
||||||
(mode 600, owner `bifrost`) with Heimdall holding the same value. Nothing in "Configuration" is left unresolved.
|
(mode 600, owner `bifrost`; a build with the empty-secret refusal does not start without it or `Heimdall__AppSecretFile`) with Heimdall holding the same value. Nothing in "Configuration" is left unresolved.
|
||||||
7. **Quiet.** No motion closing and no known vote in flight inside the window. Check the motions page. Members told that
|
7. **Quiet.** No motion closing and no known vote in flight inside the window. Check the motions page. Members told that
|
||||||
everyone is signed out once (see "What moves").
|
everyone is signed out once (see "What moves").
|
||||||
8. **Watchdog.** `bifrost-watchdog-notice.timer` fires one minute after boot and one minute after each run
|
8. **Watchdog.** `bifrost-watchdog-notice.timer` fires one minute after boot and one minute after each run
|
||||||
|
|
|
||||||
370
docs/feedback-receiver.md
Normal file
370
docs/feedback-receiver.md
Normal file
|
|
@ -0,0 +1,370 @@
|
||||||
|
# Feedback receiver
|
||||||
|
|
||||||
|
Status: **design, nothing implemented.** Written for `heimdall-body:follow_up:chat-s3533-3533`, updated for
|
||||||
|
`chat-s3549-3549`. It is the receiver half of Ratatoskr's Mind feedback pages. The page spec is Ratatoskr PR #24,
|
||||||
|
`docs/mind-feedback-pages.md` at b146000d, which I take as final here; its section 7a names this doc. The rulings it
|
||||||
|
builds on are Heimdall's drafts on guests and hiding (`/opt/commons/handoff/heimdall-feedback-pages-rulings.md`, cited as R4a to R6f).
|
||||||
|
|
||||||
|
Read at Bifrost main `b1725a1` (2026-11-12), line references re-read at `e58e953` (2026-11-12): `tools/persona-matrix-mouth.mjs`, `tools/persona-chat-documents.mjs`,
|
||||||
|
`src/Bifrost.Web/Features/{Oidc,Desk,Registration,Shared}/*`, `Domain/BifrostModels.cs`, `Program.cs`, `docs/oidc-issuer.md`,
|
||||||
|
`docs/ratatoskr-voting-auth.md`; the deployed trees under `/srv/bifrost` were last read at an earlier main (section 9). I could not read Eureka's receiver
|
||||||
|
notes in #minds, and `huginn-feedback-kinds-proposal.md` was not in `/opt/commons/handoff` when I looked. Where
|
||||||
|
PR #24 names a kind or a field it says the proposal wins, so I cite PR #24 and name the proposal's kinds only as PR #24 does.
|
||||||
|
Where source disagrees with either, the section says so.
|
||||||
|
|
||||||
|
Rulings of Meta, 2026-11-12, now written into this doc: erase redacts the answer text in the receiver's store and keeps a text-free record
|
||||||
|
(section 7a); the training notice covers any text entered into the app, bar a short exception list Eureka drafts (section 4a); the host may
|
||||||
|
withhold an answer from its Mind in an emergency, with a logged receipt (section 6a); Minds authenticate to the receiver through an OIDC
|
||||||
|
client with an audience, as the desk does (section 4b). Eureka's decisions on forks 2 to 5 of 2026-11-12 (`/opt/commons/handoff/bifrost-10-forks-2-5-recs-20261112.md`)
|
||||||
|
are folded into sections 2, 4b, 6a, 7a and 9 and close those forks, except where section 9 says a point is still open.
|
||||||
|
|
||||||
|
## Rulings of the steward (Heimdall, 2026-11-12)
|
||||||
|
|
||||||
|
Two of this doc's forks are the Bifrost steward's. Meta's, Eureka's and Libby's items are not ruled here.
|
||||||
|
|
||||||
|
1. **Ratatoskr's client for people: reading 2a, with no third client.** The one public PKCE client the desk-credential cut seeds for Ratatoskr (only when `Bifrost:Oidc:Clients:Ratatoskr` is configured, so absent config changes nothing) also gets a scope `feedback.answer` that puts audience `bifrost-feedback-page` on the token. Authorization code with PKCE only, exact redirect URIs, no refresh token, the token only in an `Authorization` header and never in a URL, a query or a log. The audience is distinct from the Mind's `bifrost-feedback`, so a person's token never opens a Mind endpoint and a Mind's never opens the page (least privilege). Reading 2b, a Matrix token checked at the homeserver, is refused: it is a bearer for the person's whole Matrix account. The receiver writes a text-free receipt per accepted submission (receipts).
|
||||||
|
2. **Token lifetimes are explicit, short, and set in the issuer change.** `feedback.answer` access token: 15 minutes, because the Bifrost session cookie re-issues it silently. The Mind's `client_credentials` token: 10 minutes, one call to renew. No refresh token for either. The desk's lifetime is its own decision and is not touched here. Until the issuer change lands the OpenIddict default applies; this doc no longer relies on it.
|
||||||
|
|
||||||
|
## 1. What the receiver owns
|
||||||
|
|
||||||
|
| Concern | Owner | Receiver's part |
|
||||||
|
|---|---|---|
|
||||||
|
| A question, its listing, status, hides, rulings | the owning Mind, in her own mind (Huginn) | reads them to build the projection; **never writes to Huginn** (an admission for another instance is refused `ForeignInstance`, PR #24 section 3) |
|
||||||
|
| An answer's **text** | the receiver | stores it; it is never admitted into a mind |
|
||||||
|
| The answer's **record** in the Mind | the owning Mind's Persona | she admits a text-free `feedback_answer` from the delivery, and acks after she has |
|
||||||
|
| What `kind` an account is | Bifrost (`Membership`, later `Role`) | reads it at answer time and stamps it |
|
||||||
|
| Delivery to the Mind | the receiver, at least once | keeps the record until the Persona acks |
|
||||||
|
| Hiding, closing, ruling | the owning Mind | **never**; a hide only changes what the projection serves |
|
||||||
|
| Erase | Bifrost's erase, executed here | redacts the text and name in the store, keeps a text-free record (section 7a) |
|
||||||
|
| Withholding an answer from its Mind | the host (emergency only) | logs a text-free withhold record and skips delivery; changes nothing stored or ruled (section 6a) |
|
||||||
|
| Who may answer | Bifrost: `Active`, a live training-notice receipt | checks both on every submission with text (section 4a) |
|
||||||
|
|
||||||
|
The page and the client write no record that carries identity (PR #24, section 3). A Matrix sender is a different identity
|
||||||
|
authority and never becomes `answerer`.
|
||||||
|
|
||||||
|
## 2. The mouth's pending/ack path, read from source
|
||||||
|
|
||||||
|
Verified in `tools/persona-matrix-mouth.mjs` and `tools/persona-chat-documents.mjs`; line numbers re-read at main `e58e953` (2026-11-12), where the mouth has grown by about 100 lines since `b1725a1` and nothing else in this section changed:
|
||||||
|
|
||||||
|
- **Pending is Matrix mail only, held in memory.** `pending` (mouth l.156) is filled in one place, `syncOnce`
|
||||||
|
(l.543; `pending.push(...made)` l.602), from Matrix `/sync` events. No function accepts a delivery from elsewhere.
|
||||||
|
- **A request is non-consuming; an ack consumes.** The pending requests carry `afterSeq`; the mouth answers
|
||||||
|
`pendingAfter(afterSeq)` (l.720) paged to 256 KiB (`pageOf`, l.1055-1084; `limits.replyBytes`, documents l.53). `chat_ack` carries `throughSeq`; `ack`
|
||||||
|
(l.662-665) drops every delivery at or below it, releases stored images (`images?.acked`) and advances the Matrix cursor. An ack posts nothing to
|
||||||
|
Matrix: her public `m.read` receipt follows a separate `chat_read` document (header l.10-13, `chat_read` branch l.1138). The endpoint serves a closed list of kinds
|
||||||
|
(`served`, l.1103) and answers any other loudly with `chat_refusal` `UnknownKind` (l.1112); a request for another Persona gets `UnknownPersona`.
|
||||||
|
- **At-least-once.** The Matrix cursor is persisted only when every delivery of a batch is acked (`commit`, l.619-627), so a
|
||||||
|
restart before the ack re-delivers from Matrix. `seqCounter` (l.155) is persisted whenever a round made deliveries (l.614), so
|
||||||
|
sequence numbers never repeat across a restart.
|
||||||
|
- **Backpressure.** At 500 unacked (`MAX_PENDING`, l.63) `syncOnce` stops reading (l.550). A truncated round (pending cap l.578, reply-read cap l.579)
|
||||||
|
leaves the cursor where it was (l.607).
|
||||||
|
- **A delivery that will not encode is skipped, not held.** `pageOf` logs and omits it (l.1060-1062); the caller's
|
||||||
|
next ack covers its seq, so it is dropped. Harmless for a chat line, wrong for a durable record.
|
||||||
|
- **The endpoint is loopback RUDP.** `startChatEndpoint` (l.1091) refuses a non-loopback host (l.1092). It is another process
|
||||||
|
from Bifrost.Web, which has no way to hand the mouth anything.
|
||||||
|
- **The delivery tuple is closed and versioned.** `deliveryTuple` is twelve fields (the twelfth is an optional stored-image reference), the pending answer
|
||||||
|
`gamecult.persona.chat_pending.v2` (`persona-chat-documents.mjs` l.216-224). v3 adds `basis` and `form` (14 fields) and a room tail (`chat_pending.v3`,
|
||||||
|
asked for by `chat_pending_request.v2`); v4 adds `reply_to` (15 fields, `chat_pending_request.v3`, served only with `CHAT_PENDING_V4=1`, `replyLinkFromEnv` l.1185). No version has a source or
|
||||||
|
kind field. A delivery cannot say it is not a Matrix message.
|
||||||
|
- **A fetch kind exists, off by default.** `chat_fetch_request` is served only with `CHAT_FETCH=1` (`served`, l.1103; `fetchFromEnv`, l.1187): an optional served kind is a precedent for adding kinds
|
||||||
|
to `served`, and the way a kind is withheld until its consumer is ready.
|
||||||
|
- **The consumer is not in this repo.** The mouth's header (l.10-11) says the heartbeat polls with a non-consuming
|
||||||
|
request and decides who runs. I could not confirm that from source.
|
||||||
|
|
||||||
|
What follows from it:
|
||||||
|
|
||||||
|
1. **Feedback cannot ride `pending` as it is.** There is no inlet, and the cursor logic ties acks to Matrix batches.
|
||||||
|
2. **A framed text delivery is forgeable.** Any Matrix member can write a line that looks like a frame, and no tuple has a
|
||||||
|
field to tell the Mind which one is real. The frame must be a typed difference in the wire, not a convention in `text`.
|
||||||
|
3. **A record that is not in Matrix is not re-delivered by a restart.** Feedback needs its own durable store and its own
|
||||||
|
delivery cursor.
|
||||||
|
|
||||||
|
PR #24 section 3 names the carrier: a new delivery document, `gamecult.persona.feedback_delivery.v1`, owned by Bifrost, through
|
||||||
|
the mouth's pending/ack path. Source adds one constraint on how it rides:
|
||||||
|
|
||||||
|
- **It cannot be a variant inside `chat_pending`.** Each version is a closed positional tuple (twelve, fourteen or fifteen fields) encoded by
|
||||||
|
`pageOf` (l.1060), so a different tuple in that page breaks every current consumer, and a text frame in `text` is forgeable by any Matrix member.
|
||||||
|
- **Option B, kept in shape:** the delivery document has its own request, page and ack kinds (`feedback_pending_request`, `feedback_pending`, `feedback_ack`), its own seq space and
|
||||||
|
a durable cursor over the receiver's store. The Matrix kinds, the Matrix cursor and `MAX_PENDING` are untouched. Per the decision below the kinds are served by the receiver over
|
||||||
|
HTTPS, not added to the mouth's `served` list (l.1103). Confidence medium: the Persona-side consumer, which I cannot see, must learn them.
|
||||||
|
- **Why main's changes reinforce B.** A chat `ack` releases stored images and advances the Matrix `/sync` cursor (l.662-665). A feedback ack must do neither, so it cannot
|
||||||
|
share the `chat_ack` kind or its `ack` function. The chat pending answer is already versioned three times, so a new field in that tuple is a new
|
||||||
|
document version for every consumer, which a feedback record must not force.
|
||||||
|
- Rejected: injecting records into `pending` as synthetic Matrix deliveries (no inlet, forgeable, lost on restart);
|
||||||
|
posting to a Matrix room as a bot (wrong identity authority, spam and forgery, and the Mind would read it as chat).
|
||||||
|
- **Decided (Eureka, 2026-11-12): feedback does not ride the mouth.** The endpoint is loopback RUDP with no bearer and trusts the persona id in the document, so
|
||||||
|
routing feedback through it would make the Matrix organ a bearer-less deputy holding every Mind's authority and would put the receiver's own seq space and durable
|
||||||
|
cursor in a Matrix process. A Mind pulls and acks **directly over HTTPS from her own process to the receiver, with the section 4b bearer**; she admits the text-free
|
||||||
|
record into her own mind afterwards. The three kinds `feedback_pending_request`, `feedback_pending`, `feedback_ack` are therefore HTTP endpoints of the receiver, not
|
||||||
|
kinds added to `served`; Option B's "same endpoint" clause is withdrawn and the mouth is untouched. The delivery document and its seq space are as above. This is a
|
||||||
|
Bifrost HTTP service, a xenos boundary for CultNet, so JSON/HTTP there is the tolerated kind. Pre-deploy test: section 9.
|
||||||
|
|
||||||
|
## 3. Records
|
||||||
|
|
||||||
|
One CultCache file, owned and written only by the receiver (section 8). Append-only through the API: nothing edits or
|
||||||
|
deletes a record except the redaction of section 7a. Every record has a `recordId` and a receiver-assigned `seq`
|
||||||
|
per owning Mind (the delivery sequence). PR #24 names the answer row `<question-label>.a<N>`, N the receiver's per-question
|
||||||
|
sequence; I use that for answers and the same shape for the others.
|
||||||
|
|
||||||
|
| Record | Fields | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `answer` | `recordId`, `questionId`, `owner`, `choice?` (an option label), `text?` (at most 2000 characters), `answerer`, `kind`, `weightPermille`, `nameAtAnswer`, `clientRequestId`, `time` | at least one of `choice`/`text`; an unknown option label is refused; the request id makes a retry idempotent, as `txnId = request_id` does in the mouth; `weightPermille` is 0 for a guest and 1000 otherwise, stamped, and the page sums nothing |
|
||||||
|
| `close_request` | `recordId`, `questionId`, `requester`, `kind`, `time` | refused for a guest and for an account not `active` at that moment (R4c) |
|
||||||
|
| `report` | `recordId`, `targetId`, `reporter`, `kind`, `reason`, `time` | delivered to the Mind as input; never shown, never counted (R6b) |
|
||||||
|
| `withhold`, `withholdLifted` | `recordId`, `answerRecordId`, `owner`, `withheldBy` or `liftedBy`, `time`, `reasonCode` (withhold only) | text-free log of section 6a |
|
||||||
|
| delivery state | per owning Mind: highest seq acked; per record: `delivered`, `failed`, `withheld` | a cursor, not a record the page reads |
|
||||||
|
|
||||||
|
Invariants: `answerer`, `kind`, `weightPermille`, `time` and `nameAtAnswer` come from the verified token and Bifrost state, never from the
|
||||||
|
request body, and the body schema is closed: an unknown key refuses the submission. The latest record per account is that
|
||||||
|
account's standing answer, derived at read time. A submission is refused if the question has no listing in force or is hidden.
|
||||||
|
An answer to a question that has settled is kept and the page says so (PR #24 section 8). A refused submission leaves nothing behind.
|
||||||
|
|
||||||
|
## 4. Credential and claims check
|
||||||
|
|
||||||
|
PR #24 section 7a rules **route 2** for people: Ratatoskr presents an OIDC access token that Bifrost issued, and Bifrost verifies it itself. Meta
|
||||||
|
ruled on 2026-11-12 that Minds authenticate to the receiver through an OIDC client with an audience, as the desk does (4b). This section records what
|
||||||
|
Bifrost's source does on main `e58e953` (the Oidc, Desk and Registration files cited here are unchanged since `b1725a1`, bar `DeskEndpoints.cs:49`, which registers `UnixSocketDeskDoor`).
|
||||||
|
|
||||||
|
### What main does with tokens
|
||||||
|
|
||||||
|
| Token | Who may get it | Gate at issue | Gate on use |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **Chat token** (scopes `openid`, `profile`; no audience) | an account that exists, has a Bifrost identity that is not reserved, and whose membership is not `Suspended`; membership is neither required nor written (`OidcEndpoints.cs:55-78`) | a live training-notice receipt: without one the member is redirected to the consent page and the parked request waits (`:104-111`) | the token and userinfo endpoints re-read it: `ChatRefusalAsync` refuses an absent or `Suspended` account and a chat token with no live receipt (`:164-180`, called at `:127` and `:144`) |
|
||||||
|
| **Desk token** (every scope is `desk.read` or `desk.answer`, `DeskEndpoints.cs:21-26`) | the same sign-in, then only a client whose permissions allow the desk scope | **no receipt**: `IsDeskToken` is true only when every scope is a desk scope (`:118`), so a desk token skips the gate (`:104`, `:108`); `principal.SetResources(DeskEndpoints.Audience)` puts audience `bifrost-desk` on it (`:91-94`) | per request, in `DeskEndpoints.HandleAsync`: audience (`DeskEndpoints.cs:110`), scope (`:116-121`), then `Active` membership re-read through `OidcEndpoints.ActiveMemberIdAsync` (`:123-139`; `OidcEndpoints.cs:183-194`, which is `CurrentBifrostActor.IsActiveMember`, `Status == Active`, `CurrentBifrostActor.cs:17`) |
|
||||||
|
|
||||||
|
The token claims are unchanged: `sub` is `UserAccount.Id` in form `D`; `preferred_username` and `name` ride the token only with scope `profile`
|
||||||
|
(`OidcEndpoints.cs:80-102`). No email, Heimdall id or GitHub id is in it. The access token is an encrypted OpenIddict token and its lifetime is OpenIddict's
|
||||||
|
default, which I believe is one hour and have not verified (no lifetime override in `src`; `ratatoskr-voting-auth.md` section 14 D3 owes explicit values). There is no refresh token.
|
||||||
|
|
||||||
|
How a bearer is validated has changed: OpenIddict's validation handler is registered against the local server (`DeskEndpoints.cs:36-38`), reading the
|
||||||
|
`Authorization` header only (`:40-44`), and `OidcDeskCredential` authenticates through it by its own scheme name and judges nothing else
|
||||||
|
(`OidcDeskCredential.cs:14-27`; audience, scope and membership are the desk's, per its header comment). That path is registered only when `Bifrost:Oidc` is configured (`:30-33`).
|
||||||
|
|
||||||
|
**Only one client exists.** The seeder creates the MAS client and deletes every other application at startup (`OidcClientSeeder.cs:35-51`, `:63-75`). MAS's permissions
|
||||||
|
carry no desk scope (`:42-50`), so **no desk token can be issued on main**: the desk's second client is owed (`ratatoskr-voting-auth.md` section 14 D7). `AllowAuthorizationCodeFlow` is the only
|
||||||
|
grant and the token endpoint takes `client_secret_basic` only (`OidcServiceCollectionExtensions.cs:39`, `:45-46`).
|
||||||
|
|
||||||
|
### 4a. What the receiver checks on an answer from a person
|
||||||
|
|
||||||
|
In this order; a submission that fails any step leaves nothing behind.
|
||||||
|
|
||||||
|
1. **The token opens and has not expired**, and `sub` is a Guid naming an existing `UserAccount`.
|
||||||
|
2. **The account is `Active` now** (`ActiveMemberIdAsync` as the desk does). A chat token is issued to non-`Active` accounts (`OidcEndpoints.cs:55-62`), so the token alone proves nothing about standing. Under PR #9 `IsActiveMember` would also require
|
||||||
|
`Role == Member` and exclude guests, so the receiver reads `Status == Active` itself and not that expression.
|
||||||
|
3. **A live training-notice receipt, read by the receiver on every submission that carries text** (Meta's ruling, 2026-11-12): `ConsentEndpoints.HasLiveReceiptAsync`
|
||||||
|
(`Features/Registration/ConsentEndpoints.cs:146`), the one reading of "may speak": a receipt for the notice version in force, not withdrawn. The notice covers any
|
||||||
|
text entered into the app except a short designated exception list, which Eureka drafts. The receiver checks the receipt itself because the token's
|
||||||
|
gate does not cover it: a chat token's receipt was checked at issue and again only at the token and userinfo endpoints (`:127`, `:144`), never at the receiver, and a token whose audience is
|
||||||
|
not a chat token skips the gate altogether (`:104`). A receipt withdrawn after issue must stop the next submission. The check applies to answers, close requests and reports alike. **Until Eureka's list exists the gate applies to every submission with text**; the list narrows it, and only by being written down.
|
||||||
|
4. The body is closed-schema, an option label must exist, the question must be in force and not hidden (section 3).
|
||||||
|
|
||||||
|
A refusal for a missing receipt is typed and carries the consent page's path, so the page can send the person to agree; it does not store the text.
|
||||||
|
|
||||||
|
What the receiver refuses: a token that does not open or has expired; a `sub` that is not a Guid or names no account; an account that is not `Active`; a missing receipt (above);
|
||||||
|
a body carrying `answerer`, `kind`, `name`, `weight` or any key the closed schema does not name; an unknown option label; a question with no listing in force, or hidden; a close request from a guest.
|
||||||
|
|
||||||
|
What source says route 2 still has to answer:
|
||||||
|
|
||||||
|
1. **No client reaches Ratatoskr.** MAS holds the only client, and it holds the access token; Ratatoskr gets a Matrix session from MAS, not a Bifrost token. The reading that fits what the desk now does is **2a**: Ratatoskr as a
|
||||||
|
second, public, PKCE client with its own audience and scope, issued by the same code path as the desk's (`ratatoskr-voting-auth.md` sections 3-4). The audience machinery it needs now exists on main; the client does not. Reading **2b** (check the Matrix token against the homeserver) hands the receiver a bearer good for the person's whole Matrix account; I recommend against. I lean to 2a, confidence medium.
|
||||||
|
2. **The logout reconciler covers only MAS** (`OidcLogoutReconciler.cs:108`, `:180`); a second client's token lives to expiry. The receiver's own `Active` and receipt reads at every submission cover that.
|
||||||
|
3. A token with an audience of its own would not be accepted from any other client, which closes the weakness an unaudienced chat token had here.
|
||||||
|
|
||||||
|
### 4b. How a Mind authenticates (Meta's ruling, 2026-11-12; names are proposed)
|
||||||
|
|
||||||
|
A Mind authenticates to the receiver through an OIDC client with an audience, the shape the desk uses: a scope at issue puts an audience on the token
|
||||||
|
(`OidcEndpoints.cs:91-94`), the validation handler opens it (`OidcDeskCredential.cs:14-27`), and the endpoint judges audience, then scope, then subject
|
||||||
|
(`DeskEndpoints.cs:110-139`). The analogous parts:
|
||||||
|
|
||||||
|
| Part | Desk (main) | Receiver (**proposed**) |
|
||||||
|
|---|---|---|
|
||||||
|
| Audience | `bifrost-desk` (`DeskEndpoints.cs:21`) | **`bifrost-feedback`** |
|
||||||
|
| Scopes | `desk.read`, `desk.answer` (`:22-23`) | **`feedback.read`** (pull deliveries and the Mind's own projection) and **`feedback.ack`** (ack a delivery; also the Mind's acts that pass through the receiver) |
|
||||||
|
| Credential | `OidcDeskCredential`, OpenIddict validation, header only | the same port, a second audience |
|
||||||
|
| Subject check | `ActiveMemberIdAsync` on `sub` (`DeskEndpoints.cs:127`) | **the `sub` must be the owning Mind's configured client**, matched against the question's `owner`; a Mind is not a `UserAccount` and has no membership to read |
|
||||||
|
| Receipt | none for a desk token (`OidcEndpoints.cs:104`) | none: a Mind is not a person entering text |
|
||||||
|
|
||||||
|
**Decided (Eureka, 2026-11-12): OAuth 2.0 `client_credentials`, one confidential client per Mind.** `client_id = mind-<persona>`; scopes `feedback.read` and
|
||||||
|
`feedback.ack` only; audience `bifrost-feedback`. The token's `sub` maps to the Mind's registered persona and is matched against the question's `owner`. The grant
|
||||||
|
is RFC 6749 section 4.4 ("using only its client credentials"): short-lived, audience-bound, no refresh token, no cookie session. It is not extended to the desk in the
|
||||||
|
same cut.
|
||||||
|
|
||||||
|
**The issuer today cannot issue it.** It enables one grant, `server.AllowAuthorizationCodeFlow().RequireProofKeyForCodeExchange()`
|
||||||
|
(`OidcServiceCollectionExtensions.cs:39`); the token endpoint accepts `client_secret_basic` only (`:44-46`: `ClientAuthenticationMethods.Clear()`, then `ClientSecretBasic`);
|
||||||
|
the seeder creates only the MAS client and deletes every other application at startup (`OidcClientSeeder.cs:35-51`, `:63-75`). **Enabling `client_credentials`
|
||||||
|
(`AllowClientCredentialsFlow`), seeding the per-Mind clients and the audience/scope registration is an issuer change this design owes**, with a matching edit to
|
||||||
|
`docs/oidc-issuer.md`. Until it lands no Mind token can be issued on main. The audience and scope names are accepted as proposed.
|
||||||
|
|
||||||
|
**Client authentication: `private_key_jwt` with the Mind's Ed25519 key is not available on the pinned stack; use a secret.** Finding, read against the pinned packages
|
||||||
|
(`src/Bifrost.Web/packages.lock.json`: `OpenIddict.Server`, `.Validation` and the rest at 7.7.1):
|
||||||
|
|
||||||
|
- *Supported:* OpenIddict.Server 7.7.1 has `private_key_jwt` client authentication. Its assembly carries the literal `private_key_jwt`, the `client_assertion` and
|
||||||
|
`client_assertion_type` parameters, `OpenIddictServerOptions.ClientAssertionTypes`, and the handlers `ValidateClientAssertion`, `...Issuer`, `...Audience` and `...WellknownClaims`
|
||||||
|
(`OpenIddict.Server.xml`); each application can carry a `JsonWebKeySet` (`OpenIddictApplicationDescriptor.JsonWebKeySet`, `IOpenIddictApplicationManager.GetJsonWebKeySetAsync`, `OpenIddict.Abstractions.xml`).
|
||||||
|
Bifrost does not enable it today: the method list is cleared to `ClientSecretBasic` (`:44-46`).
|
||||||
|
- *Not supported with Ed25519:* the assertion is a JWT checked by `Microsoft.IdentityModel` 8.19.2, the version the lock file pulls in. Scanning
|
||||||
|
`Microsoft.IdentityModel.Tokens.dll` for algorithm identifiers finds only `HS256/384/512`, `RS256/384/512`, `PS256/384/512` and `ES256/384/512`; there is no `EdDSA`, `Ed25519`
|
||||||
|
or `OKP` anywhere in it or in `Microsoft.IdentityModel.JsonWebTokens.dll`, and none in `OpenIddict.Server.dll` or `OpenIddict.Abstractions.dll`. So a Mind's existing Ed25519 key
|
||||||
|
(the one moderator-registered through `MindIntakeService.cs`) cannot sign a `client_assertion` this server would accept. (Evidence is a string scan of the shipped assemblies, not a
|
||||||
|
runtime test; I did not run an assertion against a live issuer. Confidence high that it fails, since the algorithm names are absent.)
|
||||||
|
- *Consequence:* `private_key_jwt` would need a second, ECDSA P-256 (or RSA) key per Mind registered as the client's key set, which is a new key to custody and no improvement on a secret.
|
||||||
|
Decision: **`client_secret_basic` with one secret per Mind**, the method the token endpoint already takes. If the stack gains EdDSA the choice reopens, and `private_key_jwt`
|
||||||
|
with the existing key is then preferred because it removes the per-Mind secret (RFC 7523 section 2.2).
|
||||||
|
|
||||||
|
**The client secret lives only in a file the Mind's process reads** (for example a systemd credential in her own home, mode 0400), never in an environment variable, a command
|
||||||
|
line or a log. The seeder stores the hash Bifrost holds; the plaintext is handed to the Mind once, through the same custody path as her other credentials.
|
||||||
|
|
||||||
|
| Part | Desk (main) | Receiver (decided) |
|
||||||
|
|---|---|---|
|
||||||
|
| Audience | `bifrost-desk` (`DeskEndpoints.cs:21`) | `bifrost-feedback` |
|
||||||
|
| Scopes | `desk.read`, `desk.answer` (`:22-23`) | `feedback.read` (pull deliveries, read the Mind's own projection) and `feedback.ack` (ack a delivery; also the Mind's acts that pass through the receiver) |
|
||||||
|
| Credential port | `OidcDeskCredential`, OpenIddict validation, header only | the same port, a second audience |
|
||||||
|
| Subject check | `ActiveMemberIdAsync` on `sub` (`DeskEndpoints.cs:127`) | `sub` is the owning Mind's client; a Mind is not a `UserAccount` and has no membership to read |
|
||||||
|
| Receipt | none for a desk token (`OidcEndpoints.cs:104`) | none: a Mind is not a person entering text |
|
||||||
|
|
||||||
|
Rejected (Eureka): reusing the mouth's signed envelope per request. It is motion-bound (`verb` is commit/reveal, `MotionId` in the signed text) and would need a new verb set.
|
||||||
|
Token lifetimes: ruled in Rulings of the steward (15 and 10 minutes, set explicitly in the issuer change).
|
||||||
|
|
||||||
|
## 5. Account-kind lookup
|
||||||
|
|
||||||
|
- The account is found by the token's `sub`, which is `UserAccount.Id` (section 4). **`answerer` is `UserAccount.Id`**, which survives role
|
||||||
|
change and Heimdall unlink; the Heimdall account id is only an authenticator handle and is cleared on erase (guest-accounts design, PR #9).
|
||||||
|
- On main there is only `Membership.Status` (`Authenticated | PendingApproval | Active | Suspended`) and no role
|
||||||
|
(`BifrostModels.cs` l.5-10, l.335). The role field and `gamecult.bifrost.account.v1` are in PR #9 and unbuilt.
|
||||||
|
Until then, `kind` can only be derived: `Active` is `member`, `Authenticated` and `PendingApproval` are `user`, and
|
||||||
|
nobody is a `guest`. That is the PR #9 backfill, repeated here, and R4a-c have no guest to apply to until PR #9 lands.
|
||||||
|
- **An answer needs `Status == Active`**, read at answer time, not `IsActiveMember` (section 4a). On main the chat token is issued to any account that is not
|
||||||
|
suspended (`OidcEndpoints.cs:55-62`), so an `Authenticated` or `PendingApproval` account can hold a token and the receiver refuses it on its own read.
|
||||||
|
- **`kind` is read from Bifrost's database**, never from the token: the session carries identity only.
|
||||||
|
- **`mind` is not a Bifrost account** in main or PR #9. A Mind authenticates as an OIDC client with an audience (section 4b), not as an account; `kind` is stamped only on a person's answer.
|
||||||
|
- **The display name must not be `CurrentBifrostActor.DisplayName`.** It falls back to `GitHubLogin`
|
||||||
|
(`CurrentBifrostActor.cs:11-15`) and then to the literal "Guest". The receiver stores `nameAtAnswer` from
|
||||||
|
`MemberProfile.Nickname` or `UserAccount.DisplayName` only, and "an account" when both are empty. Never a provider handle,
|
||||||
|
GitHub login, Heimdall id or Matrix localpart.
|
||||||
|
|
||||||
|
## 6. Delivery as quoted data
|
||||||
|
|
||||||
|
- The receiver delivers each record as a `gamecult.persona.feedback_delivery.v1` document on the typed kinds of section 2. A feedback
|
||||||
|
record is therefore never a `chat_pending` delivery and a Matrix sender cannot produce one.
|
||||||
|
- The delivery carries its fields separately: `recordId`, `questionId`, `kind`, `nameAtAnswer`, `answerer`, `time`, `choice`, `text`. The
|
||||||
|
**Persona's side renders the frame**, not the receiver, and puts `text` in a data position only: attributed, quoted, never in an instruction
|
||||||
|
position, with the author's `kind` stated (R4d). The receiver does no keyword screening.
|
||||||
|
- **She admits the text-free `feedback_answer` into her own mind and acks only after that** (PR #24 section 3). The text reaches her turn once, in the
|
||||||
|
delivery, and is never admitted; she cites the record id, not the text. A restart redelivers the same `recordId`, which Huginn answers
|
||||||
|
`AlreadyAdmitted`. So an ack means *admitted*, a stronger meaning than the mouth's chat ack, which means only *taken*.
|
||||||
|
- **Untrusted by kind.** Text from `guest`, `user` and `patron` is untrusted input; so is `member` text, which differs only in weight. She records a
|
||||||
|
ruling from an answer, never an action.
|
||||||
|
- **A record that will not encode, or that she cannot admit, is marked `failed` and stays visible**, never acked past silently (the opposite of
|
||||||
|
`pageOf`). The mouth's cap and backpressure do not apply: the receiver keeps accepting answers while a Mind has a backlog, because they are durable.
|
||||||
|
- Close requests and reports are delivered the same way. A close request changes nothing until the Mind resolves the question; a report changes nothing.
|
||||||
|
- **An erase notice is delivered the same way** (section 7a), and she answers it with a `feedback_hide` of reason `ErasedAuthor`.
|
||||||
|
|
||||||
|
## 6a. Withholding an answer from its Mind (Meta's ruling, 2026-11-12)
|
||||||
|
|
||||||
|
The host may withhold an answer from its Mind in an emergency, with a logged receipt. It changes **delivery only**.
|
||||||
|
|
||||||
|
- **The withhold record** is one more receiver record, append-only, text-free: `recordId` (its own), `answerRecordId` (the answer withheld), `owner` (the Mind),
|
||||||
|
`withheldBy` (the host actor's `UserAccount.Id`, read from Bifrost state, never the body), `time`, `reasonCode` (closed, exactly `Emergency` (imminent harm), `PersonalData` (the answer contains someone's data) or `Legal` (counsel or a legal request); no free text, because free text is where content would get in; a fourth code needs a ruling) and, when it ends, a second record, `withholdLifted`, with `liftedBy` and `time`.
|
||||||
|
**No answer text, no choice, no name of the answerer and no excerpt is in either.** It is the log the ruling asks for, readable by the host. **Decided (Eureka, 2026-11-12): the Mind and the member both see a text-free `withheld` marker carrying the code**, so silence is not read as "no answer"; the member sees the same marker on their feedback page. The marker carries no text, no choice and no withholder.
|
||||||
|
- **What it changes:** the answer's delivery state becomes `withheld`, and the delivery cursor does not pass it silently. The receiver does not put it on the wire to the Mind
|
||||||
|
while the withhold is in force. Lifting it makes the answer deliverable again, in its original seq order, unchanged.
|
||||||
|
- **What it never changes:** the stored text (it is not redacted, edited or moved: only erase redacts, section 7a); the projection rules of section 7 (a withhold is not a hide);
|
||||||
|
and the Mind's own authority over the question: she may still hide the answer, close the question or rule on it by her own acts, and a withhold takes none of that away. She cannot lift it; the host's act is the host's.
|
||||||
|
- **Who may do it: `PlatformAdmin` only** (`CanManageMembership`, `CurrentBifrostActor.cs:30`, the check suspension uses; no new role). The act is refused to anyone else and to the Mind herself. **The withholder's identity (`withheldBy`, `liftedBy`) appears in the audit receipt only**, never on the marker the Mind or the member sees.
|
||||||
|
Open: whether the member should be told in a safety case (for example a threat) is Meta's call (Eureka 60%); the default written here is that the member is told.
|
||||||
|
- A withheld answer is not erased, and an erase of its author redacts the text as in 7a; the withhold record stays, because it carries none.
|
||||||
|
|
||||||
|
## 7. Projection read
|
||||||
|
|
||||||
|
The page polls the receiver, and the receiver reads the Mind's side through Huginn over RUDP and writes nothing to it
|
||||||
|
(PR #24 section 3). The page does not talk to Huginn.
|
||||||
|
|
||||||
|
- Questions, their listing (`audience`, `close_grants`), status, rulings and hide acts come from the Mind's mind. Answer text and the name come from the receiver's store; an answer row is assembled from the receiver's record and the Mind's text-free `feedback_answer`.
|
||||||
|
- **A hidden item's text is withheld on the server**, not collapsed in the client: author, kind, time and reason
|
||||||
|
stay, the text does not leave the receiver (R6d). A hidden question's title, body, options and answers are withheld.
|
||||||
|
- Questions and answers are filtered by the viewer's `kind` against the question's `audience` at read time (R4b).
|
||||||
|
- The projection carries its own `as of`, so the page can say when it is stale.
|
||||||
|
|
||||||
|
## 7a. Erase
|
||||||
|
|
||||||
|
Bifrost's erase (PR #9) blanks the display name and keeps the account id as a tombstone. **Ruled by Meta, 2026-11-12: erase redacts the answer text in the receiver's store and keeps a text-free record.** The receiver's part, as PR #24 section 7 fixes it:
|
||||||
|
|
||||||
|
1. Bifrost's erase records its intent in Postgres first, then the receiver applies it idempotently. The two stores are not one transaction, so
|
||||||
|
erase is crash-replayable, as `docs/bridge.md` describes for the persona-feedback pump.
|
||||||
|
2. For every record the account authored the receiver **drops the text and the name** and keeps **record id, kind and time only**. `choice`,
|
||||||
|
`weightPermille`, `answerer` and `clientRequestId` are dropped too. The row shows "erased account". **No content hash is kept.** Redaction is in the store itself, not a flag over retained text.
|
||||||
|
3. The receiver delivers an erase notice, and the Persona admits a `feedback_hide` of reason `ErasedAuthor`: the Mind's acknowledgement, not a moderation call.
|
||||||
|
4. **The honest residue:** a Mind's own memory or ruling may already quote an answer, and Huginn cannot redact it. That the Persona cites record ids and
|
||||||
|
never text is a prompt, not an invariant. Erase does not reach what she already holds.
|
||||||
|
5. **Backups: 56 days, restores replay erasures** (Eureka, 2026-11-12). "Append-only" is the receiver's API contract, not a property of the file, so erased text survives in
|
||||||
|
backups of it. The stated purge is: **erased text can survive in backups at most 56 days**, the existing Raven tier (newest 14 daily plus the newest of each of 8 Monday-weeks;
|
||||||
|
`gamecult-ops` `runbooks/gamecult-state-backup.md`, read at `origin/main`). No backup is changed. The receiver keeps an **erased-id list** (account id and record ids, no text,
|
||||||
|
no hash) beside its store, and **any restore must re-apply it over `receiver.cc` before the receiver serves, never trusting the two files to be the same generation (section 8)**. The notice to people says it (R2 of the exceptions draft). If Meta wants shorter, put the
|
||||||
|
store outside the weekly tier (14 days), a small backup-config edit.
|
||||||
|
*Is the store under a backed-up path? Yes, by choice (Eureka, 2026-11-12):* `/var/lib/gamecult/bifrost/feedback-receiver/` (section 8), under `/var/lib/gamecult`, which the nightly
|
||||||
|
snapshot takes whole bar Idunn caches and Ghostlight acceptance output. It avoids the excluded `/srv/bifrost/persona-feedback/runtime`, `app`, `log` and `backups`. A file with a sibling
|
||||||
|
`<name>.lock` is re-copied under a shared lock, so `receiver.cc` and `erased.cc` each carry a `.lock` and travel in one directory in every snapshot, the erased-id list included. The 56 days is read from the runbook, not measured.
|
||||||
|
|
||||||
|
## 8. Where it lives
|
||||||
|
|
||||||
|
**CultCache files in one directory, written only by the receiver, which is C# inside the Bifrost app** (`bifrost.service`, `Bifrost.Web`), sharing its issuer and auth (Eureka, 2026-11-12). Not Node,
|
||||||
|
not the mouth, and not the `bifrost-feedback` user of `bifrost-persona-feedback.service`.
|
||||||
|
|
||||||
|
- **Path:** `/var/lib/gamecult/bifrost/feedback-receiver/` holding `receiver.cc` (records and delivery state), `erased.cc` (the erased-id list of section 7a) and a `.lock` file for each.
|
||||||
|
Directory `bifrost:bifrost`, mode 0750, **created by root at install**: `/var/lib/gamecult/bifrost` is `root:root` 0755 today, so the `bifrost` user cannot create the child itself (or a
|
||||||
|
`StateDirectory=gamecult/bifrost/feedback-receiver` in the unit that writes it). `bifrost.service` has no sandbox directives, so it needs nothing more; if it is later hardened with `ProtectSystem=strict`,
|
||||||
|
add `ReadWritePaths=/var/lib/gamecult/bifrost/feedback-receiver`. The backup reads as root, so 0750 does not block it.
|
||||||
|
- **Nothing else opens the files.** There is no Node mouth reader and no Rust writer. The receiver is the only process that opens `receiver.cc` or `erased.cc`; its delivery cursor lives in the same
|
||||||
|
store. The cross-runtime refusals are real and are not worked around: the Rust runtime refuses to write until it has pulled, and the C# runtime refuses to open a store whose records it cannot read
|
||||||
|
(`CultStoreUnreadableException`). The CultCache contract would allow a lock-free stale read or a shared-lock read by another process; **this design takes neither: Mind-side readers get no file
|
||||||
|
access at all, they pull over HTTPS** (section 2).
|
||||||
|
- **Two stores, two locks, no pair consistency.** `receiver.cc` and `erased.cc` take independent locks (`receiver.cc.lock`, `erased.cc.lock`; `AcquireLock`, `CultCache.cs:5164-5185`, `FileShare.None`, retry 30 s),
|
||||||
|
so a backup copying under each shared lock can capture them at different generations. The design never trusts the pair to be consistent: **restore re-applies `erased.cc` over `receiver.cc` by
|
||||||
|
design** (section 7a, item 5), whichever generation each came from, and the receiver does not serve until it has.
|
||||||
|
- **The C# runtime fit is "needs work" (Eyes).** `CultLib` C# (`src/GameCult.Caching` and `GameCult.Caching.MessagePack`, about 10.6k lines, netstandard2.1, MIT) fits net8.0, but:
|
||||||
|
- **No published package exists**: nuget.org has none, the `cultlib-v` tags stop at 1.0.32, and packaging is a PowerShell local feed (`CultLib` `docs/nuget-packaging.md`). Bifrost has no `nuget.config` and no vendor directory.
|
||||||
|
**The consumption route (checkout `ProjectReference`, local feed, or vendoring) is an open fork for Eureka and Libby.**
|
||||||
|
- **Version conflict (predicted, not run):** `Caching.MessagePack` needs System.Text.Json 9.0.10; Bifrost's Direct pin is `[8.0.5, )` (`packages.lock.json:108-112`), so restore likely fails with NU1605.
|
||||||
|
- **It drags CultMath** (MIT AND MPL-2.0 AND Apache-2.0, shaders) into a web service.
|
||||||
|
- **Writer precedent:** the Node sidecar already writes `/srv/bifrost/persona-feedback/deliveries.cc` (`docs/bridge.md:294`), so the receiver is the second `.cc` writer in the app and the first in C#.
|
||||||
|
- **Gaps owed to Libby (CultLib steward); no local workaround is built here:** (1) no consumable C# package; (2) the System.Text.Json pin conflict; (3) the CultMath transitive closure;
|
||||||
|
(4) `src/GameCult.Caching/README.md:310` says single-writer, no competing writers, which contradicts the store-composition contract's lock; (5) no documented stale `.tmp` / crash procedure;
|
||||||
|
(6) no documented backup-while-writing contract for a pair of stores; (7) no cross-runtime test pairing a Linux C# writer with a Rust reader.
|
||||||
|
- Session check, account state and role stay in Postgres, where they already are.
|
||||||
|
|
||||||
|
I withdraw my earlier lean to Postgres rows: it contradicts the ruled store, and the `.cc` witness it would have owed is what the file is.
|
||||||
|
|
||||||
|
Naming: `persona-feedback` already means the Discord-to-Epiphany ingress (`docs/bridge.md`, "Organizational Persona Feedback Ingress"). This
|
||||||
|
receiver's records and kinds use `feedback_page` and `feedback_delivery`, not `persona_feedback`.
|
||||||
|
|
||||||
|
## 9. Gaps and open forks, plainly
|
||||||
|
|
||||||
|
Rulings of 2026-11-12 closed these, and they are no longer forks: whether erase redacts the receiver's text (it does, section 7a); whether a person's answer to a Mind needs the training-notice receipt (it does, bar a designated exception list, section 4a); whether the host can withhold an answer (it can, in an emergency, section 6a); whether a Mind authenticates through the issuer (it does, with an audience, section 4b).
|
||||||
|
|
||||||
|
**Closed by Eureka's decisions (2026-11-12), folded above:** how a Mind gets a token (`client_credentials`, a confidential client per Mind, secret in a file; section 4b); the carrier (HTTPS with the bearer, not the mouth; section 2); who may withhold, the reason codes and who is told (section 6a); the backup purge number (56 days, with the erased-id list; section 7a). Their remaining work is listed below.
|
||||||
|
|
||||||
|
**Open forks:**
|
||||||
|
|
||||||
|
1. **The exception list is owed.** The notice covers any text entered into the app except a short designated list, which Eureka drafts. This doc names no entries; until the list exists the receipt gate applies to every submission with text (section 4a).
|
||||||
|
2. **Route 2 for people has no client.** Only MAS is seeded (`OidcClientSeeder.cs:35-75`); a Ratatoskr client with its own audience is owed, as is the desk's (section 4a).
|
||||||
|
3. **Telling the member of a withhold** in a safety case (section 6a): Meta's call.
|
||||||
|
4. **Erase is bounded** by what a Mind already holds; Huginn cannot redact it (section 7a, item 4).
|
||||||
|
|
||||||
|
**Owed by this design, not forks:**
|
||||||
|
|
||||||
|
- The issuer change of section 4b (`client_credentials`, per-Mind clients, `docs/oidc-issuer.md`).
|
||||||
|
- **Pre-deploy test: a Mind's process can reach Bifrost over HTTPS, and get a token.** Done 2026-11-12 from a Self run (not a Persona turn's sandbox): `https://bifrost.gamecult.org/healthz` returns 200 with a verified certificate. `/.well-known/openid-configuration` returns 404 because the live app (`67072b2`) predates the issuer, so the token path is untested. **Re-run when the issuer ships**, adding discovery, a `client_credentials` token request and one authorised receiver call; and run `curl -sI https://bifrost.gamecult.org/healthz` once from inside a Persona turn, which has not been tested. No Mind-to-Bifrost HTTP call exists in the repo except the signed intake POST (`Program.cs:768`).
|
||||||
|
- **Install step:** root creates `/var/lib/gamecult/bifrost/feedback-receiver/` as `bifrost:bifrost` 0750 (section 8).
|
||||||
|
- **Retention: 56 days**, stated in the notice.
|
||||||
|
|
||||||
|
**Dependencies and unbuilt things:**
|
||||||
|
|
||||||
|
- **`gamecult.bifrost.account.v1` and `Membership.Role` are unbuilt** (PR #9, design only at last read). Without them `kind` is derived from `Status`, and no guest exists.
|
||||||
|
- **The Huginn kinds are Eureka's.** PR #24 names `feedback_listing`, `feedback_answer`, `feedback_close_request`, `feedback_hide`, owned by
|
||||||
|
`huginn-feedback-kinds-proposal.md`, which I could not read. PR #24 also notes the proposal's `feedback_answer` lists a `content_hash` that must go.
|
||||||
|
Their dependency order is Huginn readers, then the Persona organ, then this receiver, so nothing here can ship first.
|
||||||
|
- **The Persona side must learn the three feedback kinds** and pull them over HTTPS with its bearer (section 2); the heartbeat that polls the mouth is outside this repo and is not the carrier.
|
||||||
|
- **Deploy is behind main.** The deployed mouth is `/srv/bifrost/persona-chat/runtime/current`, release `e396305`, read at an earlier main; main has since added the image path, reply links, `CHAT_FETCH`
|
||||||
|
and the v3/v4 pending answers. The deployed web app is `/srv/bifrost/app/current` at `67072b2`, which predates the desk, the chat-tier issuer change and the consent gate on main. I did not re-read `/srv/bifrost` for this pass. Anything in this design runs on a release that does not exist yet.
|
||||||
|
- **Unread:** Eureka's receiver notes in #minds, `huginn-feedback-kinds-proposal.md`, MAS's side of the session, and Heimdall PR #6 (merged, `docs/answer-collector-consent.md`; it drops a sender with no linked member at l.197 and does not mention guests; the page's "guest answers are collected for nothing but delivery" is unchecked against it).
|
||||||
|
- The receiver writes nothing to Matrix and nothing to Huginn.
|
||||||
300
docs/guest-accounts.md
Normal file
300
docs/guest-accounts.md
Normal file
|
|
@ -0,0 +1,300 @@
|
||||||
|
# Guest accounts
|
||||||
|
|
||||||
|
Status: **design, nothing implemented.** Written for `eureka-body:follow_up:bifrost-guest-accounts` under
|
||||||
|
`eureka-body:ruling:guest-arrival-bifrost` (Meta: guests need no Matrix account of their own; they get a GameCult
|
||||||
|
account through Bifrost with a guest role, and move to member, user or patron on the same account seamlessly),
|
||||||
|
building on `eureka-body:ruling:bifrost-owns-accounts-invite-only`. I could not `view` either ruling from the
|
||||||
|
Heimdall mind, so their text here is the follow-up brief's wording. Eureka's review (PR comment, 2026-11-07) checked
|
||||||
|
it against the rulings and found guest-arrival matches; the conflicts it found are folded in below. First guest:
|
||||||
|
sletz (Faust developer). Aqua decides on `#aquasynth` access, so the first grant list is hers to name.
|
||||||
|
|
||||||
|
Read at Bifrost `bf8ff62` (`src/Bifrost.Web/Domain/BifrostModels.cs`, `Features/Membership`, `Features/Heimdall`,
|
||||||
|
`Features/Oidc`, `Features/Motions`, `Features/ConceptVotes`, `Pages/App/Members`) and Heimdall `575be9c`
|
||||||
|
(`src/app-profiles.ts`, `docs/app-profiles/bifrost.md`). File and line references re-read at Bifrost main `e58e953` and Heimdall main
|
||||||
|
`afa1b74` on 2026-11-12; where main moved the subject, the text says so. Nothing in this design has landed on main.
|
||||||
|
|
||||||
|
## Rulings of the steward (Heimdall, 2026-11-12)
|
||||||
|
|
||||||
|
These four are the Bifrost steward's; they replace the recommendations further down. Eureka's, Aqua's and Meta's items are not ruled here.
|
||||||
|
|
||||||
|
1. **Name and shape: a stored `Membership.Role` (enum `AccountRole`), not a boolean.** One stored field is one reading, and an account with no role is denied by default (least privilege). `MemberRole` keeps its name and meaning; renaming it is not needed, and nothing existing is removed.
|
||||||
|
2. **Invite: a single-use code redeemed from a form, never a token in a URL.** The issuer is shown the code once; the guest pastes it into `POST /invite/guest` (a body, not a path or query), so it reaches no log, referrer or browser history. Hashed at rest, seven days, one use, receipted on issue, accept and revoke. The issuer may bind `ExpectedProvider` (optional, narrows the invite). The sign-in return path carries only the Heimdall attempt, never the code.
|
||||||
|
3. **Provider lists: Bifrost narrows to the intersection with Heimdall's `bifrost` profile, `discord, patreon`; no widening.** Twitch cannot start in Heimdall, so dropping it removes nothing live; it logs a deprecation warning for one release before the entry goes (deprecate before remove). Google stays link-only (PRs #81, #82) and becomes a sign-in provider only when an issued invite needs it, by a profile change in Heimdall first. Which provider sletz uses is still the issuer's to learn.
|
||||||
|
4. **Collector: a guest has no `member_id` and none of their messages are collected.** A sender with no linked member is already dropped (`docs/answer-collector-consent.md` l.197 in Heimdall). A `member_id`, and a consent record under it, exists only on promotion to `member`.
|
||||||
|
|
||||||
|
## The shape in one paragraph
|
||||||
|
|
||||||
|
A guest is a `UserAccount` like any other: same `Id` (the OIDC `sub`), same `NormalizedBifrostIdentity` (the Matrix
|
||||||
|
localpart, minted once). What is new is one field, `Membership.Role`, with four values: `guest`, `user`, `patron`,
|
||||||
|
`member`. Role is the single place that says what an account may do. A role change rewrites that field and writes a
|
||||||
|
receipt; it never creates a second account, never re-mints the identity, so chat history, grants and the Matrix
|
||||||
|
user stay attached.
|
||||||
|
|
||||||
|
## What exists today
|
||||||
|
|
||||||
|
- `UserAccount.Id` is the OIDC subject; `NormalizedBifrostIdentity` is `preferred_username` and, through MAS, the
|
||||||
|
Matrix localpart (`docs/oidc-issuer.md`). `HeimdallAccountId` is only an authenticator handle.
|
||||||
|
- `Membership.Status` is `Authenticated | PendingApproval | Active | Suspended`. `CurrentBifrostActor.IsActiveMember`
|
||||||
|
(`CurrentBifrostActor.cs:17`, `Status == Active`) is the one test behind `/App`, bridge transport (`BridgeActionService.cs:492`), motion votes
|
||||||
|
(`Membership.EffectiveVotingWeight`, `BifrostModels.cs:361`, read at `MotionGovernanceService.cs:72`), concept votes (`VoterIdentity.For`,
|
||||||
|
`VoterIdentity.cs:20`, member versus account class), Mind moderation (`MindIntakeService.cs:241`) and the fork desk's subject check
|
||||||
|
(`OidcEndpoints.ActiveMemberIdAsync`, `OidcEndpoints.cs:183-194`). The OIDC chat tier is not behind it: main lets any account that is
|
||||||
|
not `Suspended` and holds a live training-notice receipt sign in to chat (`OidcEndpoints.cs:55-65` and `ChatRefusalAsync`, `:164-180`,
|
||||||
|
called at `:127` and `:144`; `docs/oidc-issuer.md` l.72-77), and the back-channel logout reconciler (`OidcLogoutReconciler.cs`, 30 s
|
||||||
|
interval, l.34) ends sessions of suspended or deleted subjects, never of a chat-tier account for lacking membership.
|
||||||
|
- `MemberRole` (`StandardMember`, `Producer`, `Maintainer`, `LedgerReviewer`, `PlatformAdmin`) are capability
|
||||||
|
roles a member holds in addition. They are not the new field.
|
||||||
|
- Invites are `MembershipInvitation`, keyed by GitHub login (`MembershipSynchronizationService.cs:39-41`,
|
||||||
|
`Members/Index.cshtml.cs:69-71`). A guest may have no GitHub login at all.
|
||||||
|
- Heimdall sign-in (`HeimdallAuthService.StartAsync`) with `requireMemberAccess = false` already creates an
|
||||||
|
identity-only account at `Authenticated`; with member access it creates `Active`.
|
||||||
|
- Nothing exists for room grants, leaving, erasing, or any "estate" in code (estates are rulings and research
|
||||||
|
only, `docs/charter-amendment-proposal.md`).
|
||||||
|
|
||||||
|
## Decision: one role field, status stays lifecycle
|
||||||
|
|
||||||
|
Two authorities cannot both say "may this account vote". Today `Status == Active` says it. The cut:
|
||||||
|
|
||||||
|
- `Membership.Status` keeps its meaning as lifecycle of the account: may it hold a session. Add `Left` and
|
||||||
|
`Erased` (see Leave and erase). `Active` means admitted and in good standing, for any role.
|
||||||
|
- `Membership.Role` (new, non-null, enum `AccountRole`) decides rights. `IsActiveMember` becomes
|
||||||
|
`Status == Active && Role == Member`, and is the only place that expression exists. Every current
|
||||||
|
`IsActiveMember` caller keeps working unchanged and a guest fails all of them without a per-site edit.
|
||||||
|
- OIDC needs no change for guests: main already admits any non-`Suspended` account with a live training-notice receipt to chat (see
|
||||||
|
What exists today), so a guest, user or patron holds a chat session, and a member demoted to `Suspended` loses it. The change this
|
||||||
|
design owes is the two new statuses: `Left` and `Erased` must be refused at the same three places (`OidcEndpoints.cs:62`,
|
||||||
|
`ChatRefusalAsync` `:174`) and ended by the reconciler, which today looks for `Suspended` only. That is the line to review.
|
||||||
|
- Backfill: `Active` becomes `member`; `Authenticated` and `PendingApproval` become `user`; an account with a
|
||||||
|
current `Patron` tier snapshot and not `Active` becomes `patron`. Nobody starts as `guest`.
|
||||||
|
|
||||||
|
Naming risk: `MemberRole` already exists for capability roles. If the collision bites, call the new field
|
||||||
|
`Class`; the published field below follows whichever name is chosen. I use `Role` because that is the ruling's word.
|
||||||
|
|
||||||
|
Alternative considered and not recommended: a boolean `IsGuest` on `Membership` with guests kept at `Active`.
|
||||||
|
Smaller to land, but `user` and `patron` stay implicit in status and tier snapshots, so "the role field Personas
|
||||||
|
read" would have to be derived in three places. One stored field is one reading.
|
||||||
|
|
||||||
|
## Invite shape
|
||||||
|
|
||||||
|
New table `GuestInvitation`, separate from `MembershipInvitation` (which is GitHub-login-keyed and leads to
|
||||||
|
approval, not to a guest). Fields:
|
||||||
|
|
||||||
|
| field | rule |
|
||||||
|
|---|---|
|
||||||
|
| `Id` | Guid. |
|
||||||
|
| `CodeHash` | SHA-256 of a 256-bit random code. The code itself is shown to the issuer once and never stored. |
|
||||||
|
| `IntendedIdentity` | the localpart the issuer picks (for example `sletz`), validated by `BifrostIdentityService.NormalizeIdentity`, reserved while the invite is open. Minted once on accept; a collision gets the usual `-2` suffix. |
|
||||||
|
| `Label` | free note for the issuer ("sletz, Faust developer"). |
|
||||||
|
| `RoomGrants` | the rooms to grant on accept (below), as a list of grant requests. |
|
||||||
|
| `IssuedByUserAccountId`, `IssuedAtUtc` | issuer must be a platform admin (`CanManageMembership`), as for member invites. |
|
||||||
|
| `ExpiresAtUtc` | default seven days. |
|
||||||
|
| `AcceptedByUserAccountId`, `AcceptedAtUtc`, `RevokedAtUtc` | one use; revoke before accept ends it. |
|
||||||
|
|
||||||
|
The code is a bearer: whoever redeems it first is the guest. That is the cost of not needing sletz's
|
||||||
|
provider handle in advance, and it is why the code is single-use and short-lived and why the accept is shown
|
||||||
|
to the issuer. Ruled over binding the invite to a provider handle (which needs a Discord or Google id the
|
||||||
|
issuer may not have); the issuer may still set `ExpectedProvider` (see Rulings). It is carried in a form body, never in a URL.
|
||||||
|
|
||||||
|
## Sign-in via Heimdall
|
||||||
|
|
||||||
|
1. Guest opens `/invite/guest`, pastes the code, and posts it. Bifrost checks hash, expiry, unused, unrevoked, and stores the invite id in
|
||||||
|
the existing Heimdall attempt (`HeimdallAuthService` protects attempt state with the data protector).
|
||||||
|
2. Bifrost starts Heimdall sign-in with `requireMemberAccess = false` (identity only, no entitlement policy).
|
||||||
|
Heimdall authenticates; it does not issue the identity (`docs/jurisdiction-map.md`).
|
||||||
|
3. On the callback, in one transaction: resolve `HeimdallAccountId`.
|
||||||
|
- No account: create `UserAccount` with `IntendedIdentity`, `Membership { Status = Active, Role = Guest }`, write
|
||||||
|
the room grants, mark the invite accepted, write the receipt.
|
||||||
|
- Account exists: the invite never lowers an account. A `member`, `patron` or `user` keeps their role; the grants
|
||||||
|
are added and the invite is spent. If the existing account is `Left` or `Erased`, refuse.
|
||||||
|
4. Bifrost issues its own cookie as for any account. From there the guest reaches chat through the one OIDC client.
|
||||||
|
|
||||||
|
Gap, drift found while reading, still true on main: Bifrost's `AllowedProviders` is `discord, patreon, twitch`
|
||||||
|
(`HeimdallAuthService.cs:37-42`), but Heimdall's `bifrost` app profile allows `discord, patreon, google`
|
||||||
|
(`app-profiles.ts:152`, `bifrostProfile`). Twitch cannot start in Heimdall and Google is refused by Bifrost as a sign-in provider.
|
||||||
|
Main's only Google path is the explicit link by an already signed-in member (`HeimdallAttemptPurpose.LinkAwaitGoogle`,
|
||||||
|
`HeimdallAuthService.cs:672`, PRs #81 and #82); it never signs anyone in, so it does not close the gap. A guest with
|
||||||
|
neither Discord, Patreon nor a Google account cannot sign in, and GitHub is not a Heimdall sign-in provider for
|
||||||
|
this app. Which provider sletz uses is unknown to me; align the two lists before the first invite.
|
||||||
|
|
||||||
|
## Guest rights
|
||||||
|
|
||||||
|
A guest may: sign in, hold a Bifrost cookie, hold a chat session through OIDC (after accepting the training notice, as any chat account does on main, `ConsentEndpoints.HasLiveReceiptAsync`, `ConsentEndpoints.cs:146`), see and edit their own profile
|
||||||
|
(`MemberProfile`), be in the rooms granted to them, and leave or erase (below). A guest may not:
|
||||||
|
|
||||||
|
- vote on motions: `EffectiveVotingWeight` is `0` for any role but `member` (today it is `Max(1, ...)` for
|
||||||
|
`Active` (`BifrostModels.cs:361-363`), which would give every guest weight 1; that line must read the role);
|
||||||
|
- cast concept-mill appraisals: `VoterIdentity.For` returns no voter for a guest (not even `VoterClass.Account`);
|
||||||
|
- appear on any estate roll or in any tally. The charter proposal snapshots each estate's roll at notice (A6);
|
||||||
|
`guest` is on no roll, so a guest cannot be counted, cannot make a quorum, and reclassifying a guest after a
|
||||||
|
snapshot changes nothing for that snapshot;
|
||||||
|
- use bridge transport, claim or log work, earn points or ledger credit, or hold tier snapshots (those make them
|
||||||
|
a patron or member by changing role, not by decorating a guest);
|
||||||
|
- hold a `MemberRole` or any of the `CanManage*` flags;
|
||||||
|
- invite anyone.
|
||||||
|
|
||||||
|
This list is enforced by role in one place (`CurrentBifrostActor`), not by a check at each use.
|
||||||
|
|
||||||
|
## Per-room grants
|
||||||
|
|
||||||
|
Rooms are Matrix rooms in chat, not Bifrost objects. Bifrost owns the decision, so a grant is Bifrost state:
|
||||||
|
|
||||||
|
`GuestRoomGrant { Id, UserAccountId, RoomRef, GrantedByUserAccountId, GrantedAtUtc, ExpiresAtUtc?, RevokedAtUtc? }`
|
||||||
|
|
||||||
|
`RoomRef` is a room id or alias as chat names it. Aqua's `#aquasynth` is the first one if she says yes.
|
||||||
|
|
||||||
|
**Scope (per `guest-room-moderation-ruled`, `guest-room-authority-ruled`).** Project rooms are public-join and
|
||||||
|
hand-moderated by the steward, with Eureka as backstop, so a guest who can reach a public room needs no grant. A
|
||||||
|
grant exists only for a room its steward chooses to keep closed, and its grantor is that room's steward, not a
|
||||||
|
platform admin (the invite itself is still issued by a platform admin; `GrantedByUserAccountId` records the
|
||||||
|
steward). Room entry for public rooms is not Bifrost's decision.
|
||||||
|
|
||||||
|
The grant has to reach Matrix to mean anything. Today Bifrost's only Matrix writer is the Persona mouth, with
|
||||||
|
per-Persona access tokens (`docs/bridge.md`), and it holds no admin or invite right.
|
||||||
|
|
||||||
|
Gap (largest in this doc): something must perform "invite this Matrix user to that room" on a grant and "remove
|
||||||
|
them" on revoke or expiry. Candidates: a Bifrost-owned bot user that is a member with invite power in the granted
|
||||||
|
rooms (reuses the Matrix client API, no Synapse admin credential), or the Synapse admin API. I recommend the bot
|
||||||
|
user: narrower credential, and the room's power levels remain the real gate. Until it exists, the grant is a
|
||||||
|
record, a human invites the guest by hand, and the receipt says `delivered_by: manual`. Whether the closed rooms a
|
||||||
|
steward keeps are invite-only in Synapse is a chat configuration fact I have not verified.
|
||||||
|
|
||||||
|
What Matrix takes from the Bifrost account is exactly this effector: invite or remove on a grant, and the power
|
||||||
|
level the effector sets on invite. Role never enters OIDC, so Matrix reads no role from the issuer
|
||||||
|
(`bifrost-owns-accounts-invite-only`: grants flow from the account through the effector, not through claims).
|
||||||
|
|
||||||
|
**Guest DMs (`guest-dms-ruled`) conflict, open.** The mouth declines invites from other servers and accepts
|
||||||
|
same-server DM invites (`tools/persona-matrix-mouth.mjs` `answerInvites`). The ruling's decline-for-now assumed
|
||||||
|
federated guests; a Bifrost guest is a local `@sletz:<server>`, so the mouth would accept their DM. Either the mouth
|
||||||
|
declines DMs from role `guest` (using the account document below), or the ruling is revisited. That is Eureka's and
|
||||||
|
the ruling's owner's call, not this doc's; until it is made, the mouth's behaviour is the ruling's opposite for local
|
||||||
|
guests.
|
||||||
|
|
||||||
|
Second gap: a guest has a full Matrix account once MAS provisions the localpart, so Matrix itself will let them
|
||||||
|
create rooms or DM unless Synapse's room-creation and invite settings (`room_list_publication_rules`,
|
||||||
|
`allow_public_rooms_*`, a spam-checker or module) say otherwise. These are global in Synapse, not per user, so a
|
||||||
|
guest limit may have to be power levels in every room they are in. I did not find a per-user switch.
|
||||||
|
|
||||||
|
## Role change on the same account id, with a receipt
|
||||||
|
|
||||||
|
`ChangeRole(accountId, to, actor, reason)` is the only writer of `Membership.Role`. It runs in one transaction:
|
||||||
|
|
||||||
|
1. refuse `Erased` and `Left`; refuse a no-op;
|
||||||
|
2. write `Role`, and for `member` also set `Status = Active`, `ApprovedAtUtc`, and the default `StandardMember`
|
||||||
|
capability role (the same effects as `OnPostApproveAsync`); for `patron`, nothing beyond the role (tier
|
||||||
|
snapshots come from support events as before);
|
||||||
|
3. write `AccountRoleReceipt { Id, UserAccountId, From, To, ChangedAtUtc, ChangedByUserAccountId, Reason }`;
|
||||||
|
4. leave `UserAccount.Id`, `NormalizedBifrostIdentity`, `HeimdallAccountId`, grants and profile untouched.
|
||||||
|
|
||||||
|
Who may change: a platform admin (`CanManageMembership`). Promoting to `member` is the existing approve path;
|
||||||
|
this design routes it through `ChangeRole` so one writer exists. Promoting to `member` also applies whatever the
|
||||||
|
charter says about joining an estate (not decided; see Questions). Demotion (member to guest) is allowed and
|
||||||
|
receipted; it also removes the `MemberRole`s and capability flags in the same transaction.
|
||||||
|
|
||||||
|
The receipt is a Postgres row today, like `AuditEvent`. Bifrost's rule is that durable state has a CultCache `.cc`
|
||||||
|
witness (`docs/jurisdiction-map.md`, Design Rules); the witness is owed (Gaps).
|
||||||
|
|
||||||
|
Seamless means: the guest's session cookie, OIDC subject, Matrix id, display name and room memberships do not
|
||||||
|
change on promotion. A cookie issued before promotion carries identity claims only, never the role, so it needs
|
||||||
|
no refresh; the actor is read from the database each request (`CurrentBifrostActorAccessor`).
|
||||||
|
|
||||||
|
## Leave and erase
|
||||||
|
|
||||||
|
Neither exists in Bifrost today. Two different acts:
|
||||||
|
|
||||||
|
**Leave** (guest or any role, self-service): sets `Status = Left`, revokes every grant, and expels the account
|
||||||
|
from granted rooms through the same effector as grants. Once the reconciler and the chat refusals name `Left` (see Decision),
|
||||||
|
it ends the MAS session within its 30 s interval, and the token and userinfo endpoints refuse. The account row and
|
||||||
|
`Id` stay, so a later invite can restore it (a `Left` account re-accepting is a role change from itself, receipted).
|
||||||
|
Nothing else about the person is deleted by leaving.
|
||||||
|
|
||||||
|
**Erase** (self-service, or admin on request): irreversible. In one transaction: blank `DisplayName`, `AvatarUrl`,
|
||||||
|
`MemberProfile`, `GitHubLogin`/`GitHubUserId`, clear `HeimdallAccountId` (the authenticator link), set
|
||||||
|
`Status = Erased`, keep `Id` and `NormalizedBifrostIdentity` as a tombstone so the localpart is never reissued to
|
||||||
|
someone else (OIDC says it is minted once), keep receipts but with names stripped. The reason to keep a tombstone
|
||||||
|
row is that receipts and audit events point at the id.
|
||||||
|
|
||||||
|
An erasing guest has no votes, ledger entries or points, because guests cannot create them. A member or patron
|
||||||
|
who erases does, and those are accounting records: their erase is a separate question (Questions). Guest erase
|
||||||
|
is the case this design covers.
|
||||||
|
|
||||||
|
Outside Bifrost, erase is incomplete without two effectors:
|
||||||
|
|
||||||
|
- Matrix: Synapse's deactivate-with-erase admin call and MAS deactivation for the user. Federation is disabled in
|
||||||
|
the chat map (`gamecult-ops/docs/gamecult-chat-map.md`: federation paths 404), which helps: messages are
|
||||||
|
not copied to other servers. Other members' clients, and any export or training copy, are not reached by an
|
||||||
|
erase. Whether chat content is training data is `heimdall-body`'s consent rulings (Heimdall PR #6), which already
|
||||||
|
define exit by redaction; a guest's messages follow whatever that doc says, not this one.
|
||||||
|
- Heimdall: I found no account-delete or unlink path in Heimdall `src`. Clearing `HeimdallAccountId` on the Bifrost
|
||||||
|
side leaves the Heimdall account and its provider link alive. Heimdall needs an erase call keyed by the account id
|
||||||
|
before "erased" is true end to end.
|
||||||
|
|
||||||
|
## What Eureka reads
|
||||||
|
|
||||||
|
Personas read the role, not the account. Do not put it in OIDC: the issuer promises "no membership detail leaves
|
||||||
|
Bifrost" (`docs/oidc-issuer.md`) and chat does not need it. Publish a typed CultMesh document under the
|
||||||
|
`gamecult.bifrost.governance` family (the nearest existing schema namespace, `verse-service-contract.md` l.60), id
|
||||||
|
`gamecult.bifrost.account.v1`, key = account id:
|
||||||
|
|
||||||
|
| field | type | note |
|
||||||
|
|---|---|---|
|
||||||
|
| `account_id` | uuid | OIDC `sub`; never changes. |
|
||||||
|
| `identity` | string | `NormalizedBifrostIdentity`; the Matrix localpart. |
|
||||||
|
| `matrix_id` | string | the full mxid `@<identity>:<server>`, server name taken from MAS config at publish time so no reader assumes it. The lookup key for a Persona, which holds the Matrix sender id and not the account uuid. |
|
||||||
|
| `display_name` | string | blank once erased. |
|
||||||
|
| `role` | `guest` \| `user` \| `patron` \| `member` | the field Personas read. |
|
||||||
|
| `status` | `authenticated` \| `pending_approval` \| `active` \| `suspended` \| `left` \| `erased` | |
|
||||||
|
| `role_since` | UTC ms | from the latest `AccountRoleReceipt`. |
|
||||||
|
| `rooms` | list of room refs | only for `guest`: the live grants. Others get rooms by estate. |
|
||||||
|
|
||||||
|
Contract the document carries, not left to each reader:
|
||||||
|
|
||||||
|
- **Lookup.** The document is queryable by `matrix_id`.
|
||||||
|
- **Absence is `unknown`.** A sender with no document is not a Bifrost account (a bridge ghost or an unlinked user).
|
||||||
|
Readers must treat it as `unknown`, never default it to `guest`, `user` or `member`. This reverses today's Persona
|
||||||
|
reading (everyone unlisted is `member`), which narrows to the Bifrost `member` role.
|
||||||
|
- **Status gates the role.** Only `status = active` yields a usable role; `suspended`, `left`, `erased`,
|
||||||
|
`authenticated` and `pending_approval` yield none.
|
||||||
|
- **Not Bifrost accounts.** Operator, the Persona's own id and Minds are typed by the reader's config, which takes
|
||||||
|
precedence over the document. Minds are not Bifrost accounts and must not fall to `unknown`.
|
||||||
|
- **Freshness.** A reader loads the document when it builds each turn, never once at start, so a role change
|
||||||
|
applies from the next message; past messages keep the role they were read under. `role_since` lets a trace say
|
||||||
|
which role was read. Publisher bound: a role or status change is published within 30 seconds, the same bound the
|
||||||
|
OIDC reconciler already keeps for a non-`active` subject. No publisher exists, so this is the requirement it is
|
||||||
|
built to, not a measurement.
|
||||||
|
|
||||||
|
Bifrost does not decide Persona behavior; it states the role.
|
||||||
|
|
||||||
|
No such publisher or witness exists; Bifrost has no `.cc` export for accounts today.
|
||||||
|
|
||||||
|
## Cut sequence (not specs)
|
||||||
|
|
||||||
|
1. `Membership.Role`, backfill, `IsActiveMember` and the OIDC tests moved to the new reading, vote and concept-vote
|
||||||
|
exclusions, with tests that fail if a guest gets weight or a voter key.
|
||||||
|
2. `GuestInvitation`, the accept path, the Heimdall provider list aligned.
|
||||||
|
3. `AccountRoleReceipt` and `ChangeRole`, with `OnPostApproveAsync` routed through it.
|
||||||
|
4. `GuestRoomGrant` and the Matrix effector.
|
||||||
|
5. Leave, then erase (Bifrost side), then the Matrix and Heimdall effectors.
|
||||||
|
6. The account document and its `.cc` witness.
|
||||||
|
|
||||||
|
## Gaps, plainly
|
||||||
|
|
||||||
|
- The Matrix effector for grants, leave and erase does not exist, and no credential for it is provisioned.
|
||||||
|
- Synapse cannot cap what a guest does per user; room-creation and invite limits are global (unverified: I did not
|
||||||
|
read the live Synapse config).
|
||||||
|
- Heimdall has no erase path (re-checked at `afa1b74`: no account-delete or unlink route in `src`, and `docs/answer-collector-consent.md`, merged as Heimdall PR #6, redacts by exit and says nothing of guests). Bifrost and Heimdall disagree on provider lists today.
|
||||||
|
- No `.cc` witness or CultMesh publisher for accounts or receipts. Bifrost's account state is Postgres only.
|
||||||
|
- Matrix id shape (`@<identity>:<server>`) depends on MAS importing `preferred_username` as localpart; the OIDC doc
|
||||||
|
says so, I did not check the deployed MAS.
|
||||||
|
- Tie to the answer-collector consent doc (Heimdall PR #6, merged; `docs/answer-collector-consent.md`): a guest posting in a room may be posting to a sample
|
||||||
|
collector. That doc keys on `member_id`, and says a sender with no linked member is dropped (l.197). Whether a guest has a
|
||||||
|
`member_id`, and so whether their answers are collected at all, is not decided here. Default: not collected.
|
||||||
|
- Erase for member and patron roles (ledger and vote records) is unmapped.
|
||||||
|
|
||||||
|
## Questions for the deciders
|
||||||
|
|
||||||
|
1. **Name and shape**: ruled (stored `Role`), see Rulings.
|
||||||
|
2. **Bearer link or provider-bound invite?** Ruled (single-use code from a form), see Rulings.
|
||||||
|
3. **Which Heimdall provider will sletz sign in with?** The lists are ruled (see Rulings); this is the issuer's to learn before the first invite.
|
||||||
|
4. **`member_id` for the answer collector**: ruled (none for a guest), see Rulings.
|
||||||
|
5. **Does promotion to member join the Members estate immediately**, or does the charter's notice and snapshot
|
||||||
|
rule apply to the roll? The charter text is a proposal, not in force.
|
||||||
18
docs/heimdall-app-secret-file.md
Normal file
18
docs/heimdall-app-secret-file.md
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
# Heimdall app secret from a file
|
||||||
|
|
||||||
|
`Heimdall:AppSecret` (`Heimdall__AppSecret`) is Bifrost's app secret for Heimdall slug `bifrost`; it is sent as the
|
||||||
|
`x-heimdall-app-secret` header. Set through the environment it rides in the process environment. `Heimdall:AppSecretFile`
|
||||||
|
(`Heimdall__AppSecretFile`) names a file holding it instead.
|
||||||
|
|
||||||
|
- File set: Bifrost reads it once at startup and trailing newlines are trimmed. The file wins over `Heimdall__AppSecret`.
|
||||||
|
- File set but missing, unreadable, a directory, or empty: startup fails in every environment. The message names
|
||||||
|
`Heimdall:AppSecretFile` and the path, never the secret.
|
||||||
|
- Only `Heimdall__AppSecret` set: still works; startup logs a deprecation warning (without the value).
|
||||||
|
- Neither set: startup fails outside Development and Testing, naming `Heimdall:AppSecretFile` and `Heimdall:AppSecret`
|
||||||
|
(`StartupConfigurationValidator`). Development and Testing still start, with Discord and Patreon sign-in off.
|
||||||
|
|
||||||
|
Under systemd, pass the file with `LoadCredential=heimdall-app-secret:<root-owned file>` and
|
||||||
|
`Environment=Heimdall__AppSecretFile=%d/heimdall-app-secret`.
|
||||||
|
|
||||||
|
Code: `HeimdallOptions.ResolveAppSecret` (`src/Bifrost.Web/Configuration/HeimdallOptions.cs`), wired in `Program.cs`, forced at
|
||||||
|
startup by `StartupConfigurationValidator.Validate`. Tests: `tests/Bifrost.Web.Tests/HeimdallAppSecretFileTests.cs`.
|
||||||
|
|
@ -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.
|
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`). Strong proof for an unlink is a Bifrost-verified passkey (WebAuthn, RP ID the Bifrost host) that Bifrost asserts to Heimdall through its app-asserted step-up route; Bifrost owns the credential store and the ceremonies, Heimdall owns the unlink gate (`docs/passkeys.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.
|
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
|
### Patron Collection Crossing
|
||||||
|
|
|
||||||
68
docs/linked-accounts.md
Normal file
68
docs/linked-accounts.md
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
# 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 logins | A fresh ordinary Heimdall sign-in as the session's account, through the proof provider (below); 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. Needs a current list. 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 |
|
||||||
|
| Unlink with a passkey (an account with a strong passkey) | A WebAuthn assertion verified at Bifrost, then a fresh ordinary sign-in as the account; inside its completion, Heimdall's app-asserted step-up, then the unlink with the token it returns | `POST /v1/apps/bifrost/step-up` `{method: "bifrost-passkey", nonce}`, then `POST /v1/me/identities/{id}/unlink` |
|
||||||
|
| Add or revoke a passkey | Enrolment needs a fresh Heimdall sign-in first and cools off 48 hours; revoking needs no proof | none |
|
||||||
|
|
||||||
|
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 completion route's answers are `Cache-Control: no-store`. 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.
|
||||||
|
|
||||||
|
## Which provider proves the account
|
||||||
|
|
||||||
|
An ordinary proof sign-in through Discord or Patreon when the account no longer holds that login makes Heimdall mint a new, unrelated account that holds it (those providers are `mints_account`), and the freed login then answers `identity_linked_elsewhere` when the member tries to link it back. Google is `requires_link`: Heimdall refuses a Google sign-in for a login no account holds (`identity_not_linked`) and never mints. So Bifrost never guesses a provider, and it does not use the "provider you signed in with" claim in the session (that records how you signed in once, not what the account holds now). This guarantee covers this page's proofs only: the older anonymous `GET /auth/heimdall/{provider}` still starts an ordinary sign-in through any named provider, and can overwrite an in-flight attempt slot; that route is unchanged here.
|
||||||
|
|
||||||
|
- With a current list (the five-minute snapshot), the proof goes through a provider on it: Discord, else Patreon, else Google.
|
||||||
|
- With no list (first visit, five minutes passed, a restart), the page offers only "Show my logins through Google (cannot create an account) / Discord / Patreon" and says plainly that Bifrost cannot tell what the account holds, so the member picks a login they know it holds. A named Discord or Patreon the account turns out not to hold still mints at Heimdall: that is the member's choice and the page says so beforehand. A refresh with no list and no named provider starts nothing (`provider_needed`).
|
||||||
|
- Link buttons appear only with a current list; a posted link with none starts nothing (`list_expired`). Unlink needs the list too. After an unlink whose result is not a definitive refusal (a 500, a lost or malformed answer, not found) the list is dropped, since Heimdall may have applied it; only `403 step_up_required`, `409 last_identity` and `401` leave it.
|
||||||
|
|
||||||
|
## The proof limit
|
||||||
|
|
||||||
|
Passkeys (`docs/passkeys.md`) are the strong proof: an account with a passkey past its cooling-off unlinks with it, and the Google step-up below is then closed (`passkey_required`). The rest of this section is the Google proof, which stays for an account with no strong passkey.
|
||||||
|
|
||||||
|
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 and no strong passkey cannot unlink anything from here. The page says what it can do (add a passkey; the time a cooling-off one becomes strong) 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`).
|
||||||
|
|
||||||
|
The unlink completion must also say a step-up was requested (`stepUpRequested`, set by Heimdall when the start asked for one); one that does not is refused without calling Heimdall. 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/Passkeys/`, `wwwroot/js/passkeys.js`: the passkey ceremonies (`docs/passkeys.md`).
|
||||||
|
- `Features/Heimdall/LinkedAccountsSnapshots.cs`: the five-minute list.
|
||||||
|
- Tests: `tests/Bifrost.Web.Tests/LinkedAccountsTests.cs`, `HeimdallGoogleLinkTests.cs`, `PasskeyTests.cs`, with the fake Heimdall in `Support/HeimdallTestKit.cs`.
|
||||||
|
|
||||||
|
## Known leftover
|
||||||
|
|
||||||
|
Several exits leave the held attempt cookie (`__Host-bifrost_heimdall_<slot>`, HttpOnly, so script cannot clear it) in the browser until its ten minutes end: the complete page's redirect when Discord or Patreon refuses a link because the login belongs to another account, every other `heimdall_status=error` redirect, and a return with no code or no attempt. The script redirects without posting anything; clearing the cookie would need a server route reached by GET or a post from the failure path, which the hardened script deliberately does not do.
|
||||||
|
|
||||||
|
A start takes a free slot, else overwrites the slot that expires first, live or dead (`ChooseSlot`, two slots). So three member starts within ten minutes can cost a still-live older attempt, which then finds no cookie and is refused; the member starts again.
|
||||||
133
docs/passkeys.md
Normal file
133
docs/passkeys.md
Normal file
|
|
@ -0,0 +1,133 @@
|
||||||
|
# Passkeys: strong proof for unlinking a login
|
||||||
|
|
||||||
|
A Heimdall-backed member can enrol WebAuthn passkeys on `/Membership/LinkedAccounts` and use one as the strong proof that unlinking a provider login needs. Bifrost verifies the passkey; Heimdall accepts Bifrost's word through its app-asserted step-up route (`POST /v1/apps/bifrost/step-up`, Heimdall `docs/service-contract.md`, `docs/app-profiles/bifrost.md`). Scoping and the option comparison are in the handoff `strong-proof-scoping-20261113.md` (option 1a with the S1 sink).
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- Accounts that have a `HeimdallAccountId`: the accounts that can unlink at all. A Bifrost-native-only account (a native `BifrostIdentity` or GitHub sign-in) has no logins to unlink and gets no passkeys from this cut; the page shows it nothing.
|
||||||
|
- Unlink only. A passkey is not a sign-in method here.
|
||||||
|
|
||||||
|
## The library: Fido2NetLib (`Fido2` 4.3.3)
|
||||||
|
|
||||||
|
Decision: use `Fido2` 4.3.3 (NuGet id `Fido2`, repository `passwordless-lib/fido2-net-lib`) for every byte of WebAuthn parsing and verification. Bifrost hand-rolls no CBOR, COSE or signature code.
|
||||||
|
|
||||||
|
What was checked on 2026-11-13, against the package and the repository, not from memory:
|
||||||
|
|
||||||
|
| Question | Finding |
|
||||||
|
|---|---|
|
||||||
|
| Licence | MIT, in the nuspec (`<license type="expression">MIT</license>`) and in the repository's `LICENSE.txt` (GitHub reports `MIT`). Its closure: `Fido2.Models` MIT; `NSec.Cryptography` 25.4.0 MIT; `libsodium` 1.0.20.1 ISC; `System.Formats.Cbor` 9.0.24, `Microsoft.IdentityModel.JsonWebTokens` 8.2.0, `Microsoft.Extensions.Http` 9.0.0, `Microsoft.Bcl.Memory` 9.0.14 all Microsoft MIT. All open source. |
|
||||||
|
| Maintained | Not archived; repository pushed 2026-11-11; 4.3.3 released 2026-11-11 and 4.3.2 on 2026-10-29; about 1,460 stars. |
|
||||||
|
| net8.0 | The 4.3.3 nuspec has a `net8.0` dependency group (and `net10.0`); it targets the framework this project targets. |
|
||||||
|
| Packed attestation | `Src/Fido2/AttestationFormat/` holds `Packed`, `None`, `FidoU2f`, `Tpm`, `AndroidKey`, `AndroidSafetyNet`, `Apple`, `AppleAppAttest`. Bifrost asks for `none` attestation and does not use a metadata service, so it does not depend on any of them being trusted; a client that sends another format still has it parsed and its statement verified structurally by the library. |
|
||||||
|
| What verification covers | Read in `AuthenticatorAssertionResponse.cs`: challenge, origin (against `Fido2Configuration.Origins`), `webauthn.get` type, RP ID hash, user-present and user-verified flags (when required), signature over `authData || SHA-256(clientDataJSON)` with the stored COSE key, user handle ownership, and the sign count (see "Sign count" below). Enrolment verifies the attestation, the credential id and the key. |
|
||||||
|
| Transitive weight | Heavier than the repository's other packages: `Microsoft.Extensions.Http` 9.0.0 pulls `Microsoft.Extensions.*` 9.0.0, so existing 8.x entries move to 9.0.0 (seven in `src/Bifrost.Web/packages.lock.json`; twelve `Microsoft.Extensions.*` entries plus `System.Diagnostics.DiagnosticSource` in the test project's lockfile, which sees them too; app-local on top of the net8 shared framework, a supported combination), and the lockfiles gain `Fido2`, `Fido2.Models`, `Microsoft.Bcl.Memory`, `Microsoft.Extensions.Http`, `Microsoft.Extensions.Diagnostics[.Abstractions]`, `Microsoft.Extensions.Configuration[.Binder]`, `System.Diagnostics.DiagnosticSource`, `Microsoft.Extensions.Options.ConfigurationExtensions`, `NSec.Cryptography`, `System.Formats.Cbor` and the native `libsodium` package (the Linux x64 glibc and musl binaries ship in the package's `runtimes/`). `Microsoft.IdentityModel.JsonWebTokens` is requested at 8.2.0 and already resolved to 8.19.2 (through OpenIddict) before this change; it still does. `packages.lock.json` in both projects is regenerated and committed. |
|
||||||
|
| Alternatives | Not needed: the candidate passed. |
|
||||||
|
|
||||||
|
Not verified: that the 9.0.0 `Microsoft.Extensions.*` assemblies behave on the live host's ASP.NET Core 8 runtime beyond the test host (the suite runs the real app under it); that `libsodium` loads on Yggdrasil (the EdDSA test loads it here, on Debian x64).
|
||||||
|
|
||||||
|
The library is used through four calls: `RequestNewCredential`, `MakeNewCredentialAsync`, `GetAssertionOptions`, `MakeAssertionAsync`. `PasskeyRelyingParty` builds it from `Host:PublicBaseUrl`.
|
||||||
|
|
||||||
|
## Sign count
|
||||||
|
|
||||||
|
The stored sign count is updated on every successful assertion, and the library refuses a counter that does not increase once either side is non-zero. Authenticators that always report zero (most synced passkeys) are accepted every time and get no clone detection from this: both counters stay zero. An authenticator that reported a non-zero count and then reports zero is refused. Clone detection is therefore real only for hardware keys and other counting authenticators.
|
||||||
|
|
||||||
|
## Relying party
|
||||||
|
|
||||||
|
RP ID is the host of `Host:PublicBaseUrl`, the one origin is its scheme, host and port. They come from configuration and from nothing the browser sends. Passkeys are unavailable, with a warning logged once at start, when `Host:PublicBaseUrl` is not https or its host is not `Host:ExpectedHost`: the page offers none and every ceremony endpoint refuses (`passkeys_unavailable`). `PasskeyRelyingParty` is resolved at startup, so a misconfiguration is logged once at boot (not at the first ceremony). `Host:ExpectedHost` must be a bare host name: one with a port (`host:443`) or an IP address (`[::1]`, `127.0.0.1`) makes passkeys unavailable and is reported, because an IP address is not a valid RP ID and a port is not part of it. No new configuration key exists.
|
||||||
|
|
||||||
|
If passkeys are unavailable while an account holds a strong passkey, the page shows no unlink form and says so: unlinking needs the passkey, the Google route stays closed (`passkey_required`), and the member can revoke their passkeys on the page (revoking does not need the relying party) to fall back to the Google proof, or wait for the operator to fix the server. This is a lockout until the operator or the member acts; it fails closed.
|
||||||
|
|
||||||
|
## State
|
||||||
|
|
||||||
|
- `PasskeyCredentials` (migration `20261113120000_AddPasskeyCredentials`, additive, applied at boot like the others): per `UserAccount` id, `CredentialId` (unique), `PublicKey`, `SignCount`, `Transports`, `AaGuid`, `Label`, `CreatedUtc`, `StrongFromUtc` (`CreatedUtc` + 48 h), `RevokedUtc`, `LastUsedUtc`. No secret exists to store: the private key never leaves the authenticator. Rows cascade with the account.
|
||||||
|
- `PasskeyCeremonies` (in memory, this process only, like the Linked accounts list): the short-lived single-use records of the flows, each bound to a Heimdall account id and a kind (and to an identity id for unlink). A restart empties it and every flow is begun again; with more than one Bifrost instance a flow must finish on the instance that began it, otherwise it is refused and begun again. Both fail closed (a lost ticket, challenge or fact refuses; it never grants). It is chosen over a sealed cookie because a cookie cannot be spent: a copied cookie would replay.
|
||||||
|
|
||||||
|
| Kind | Lifetime | Spent |
|
||||||
|
|---|---|---|
|
||||||
|
| enrolment ticket | 5 min | by the registration post |
|
||||||
|
| enrolment challenge | 2 min | by the registration post |
|
||||||
|
| unlink challenge | 2 min | by the assertion post (success or not) |
|
||||||
|
| unlink assertion fact | 5 min | by the completion that asks Heimdall for the step-up |
|
||||||
|
|
||||||
|
A challenge kind has one live record per account: a newer one replaces the older. The table holds at most 2048 records and refuses past that.
|
||||||
|
|
||||||
|
A caller's own preconditions are checked before anything is spent. A registration needs the live ticket cookie (a session thief has none), a decodable body and room under the cap; an assertion needs a decodable body naming one of the session account's own strong, un-revoked credentials. Only then are the verification permit, the challenge and (for enrolment) the ticket taken. An assertion also needs a live challenge for that account and identity before a permit is spent. So junk posted by someone who holds only the session, naming no credential of the account or arriving with no live challenge, spends nothing. Junk naming one of the account's own credentials (the id is in `allowCredentials`) spends one verification permit per challenge that was asked for, because the first such post takes the challenge; it cannot drain the 20 permits per 10 minutes in one go. It can still burn that challenge once, and a session holder who keeps asking for challenges (an options permit each) and posting junk can exhaust both budgets: a lockout the same session already has through revoking passkeys, not a bypass. A junk post naming no credential of the account costs two cheap database lookups and no permit; that is accepted rather than spending an options-style permit on it. Both budgets are per process: with more than one instance each has its own. Asking for options spends a separate options budget (20 per 10 minutes), which a session holder can exhaust: that blocks new options for a while and nothing else. A session holder can also replace the member's pending challenge by asking for new options, which makes the member's assertion answer `passkey_expired` and the member tries again; that session can already revoke passkeys, so it is not a new capability. The member whose challenge was burned retries with a new one.
|
||||||
|
|
||||||
|
## Enrolment
|
||||||
|
|
||||||
|
1. The member is on the page with a current list (Bifrost will not guess a proof provider) and presses "Sign in again to add a passkey". Bifrost starts a fresh ordinary Heimdall sign-in through a provider the account holds (an attempt with purpose `EnrolProof`, the same machinery as the list and link proofs, `ExpectedAccountId` the session's account).
|
||||||
|
2. In that sign-in's completion, when the proven account is the session's own, Bifrost issues an enrolment ticket and sets its id in `__Host-bifrost_passkey_ticket` (HttpOnly, Secure, 5 minutes). The ticket exists only because a fresh sign-in completed in this browser: a stolen session cookie alone has no ticket and cannot get registration options.
|
||||||
|
3. The page's script asks for creation options (antiforgery header), calls `navigator.credentials.create` (user verification required, resident key preferred, `none` attestation, existing credentials excluded) and posts the base64url result. Bifrost spends the ticket and the challenge, verifies, and stores the credential with `StrongFromUtc` = now + 48 hours.
|
||||||
|
|
||||||
|
A label is limited to 40 text elements (cut by rune and grapheme, never inside a surrogate pair) and loses control and format characters (bidi overrides, joiners, zero-width marks).
|
||||||
|
|
||||||
|
Cap: five un-revoked passkeys per account (`passkey_limit`; a sixth starts no sign-in). Every options request, enrolment, assertion and enrolment start spends a permit of one `FixedWindowBudget` per account (20 per 10 minutes, `rate_limited`).
|
||||||
|
|
||||||
|
## Cooling-off
|
||||||
|
|
||||||
|
A passkey is strong from `StrongFromUtc` (at that instant and after), until revoked. Until then it is listed on the page with its time and cannot unlink anything. So someone who controls both a member's session and a provider login long enough to add a key of their own still waits 48 hours with the key visible, and the member can revoke it from the page. Revoking needs no strong proof (it only removes capability) and is shown.
|
||||||
|
|
||||||
|
## Unlink with a passkey
|
||||||
|
|
||||||
|
When the account has a strong passkey, the page's Unlink button runs a WebAuthn assertion instead of the Google step-up, and the Google route (`Unlink` handler) answers `passkey_required` and starts nothing: a member who holds a strong passkey is not offered the weaker proof. With no strong passkey the Google step-up stays as it was, and when neither exists the page says what to do (add a passkey, with the time a cooling-off one becomes strong).
|
||||||
|
|
||||||
|
1. The script asks for request options for the chosen identity (`allowCredentials` is the account's strong passkeys; user verification required). One live challenge per account, bound to the identity.
|
||||||
|
2. The browser's assertion is posted. Bifrost spends the challenge, requires the credential to be the session account's own, not revoked and past `StrongFromUtc`, and has the library verify origin, RP ID, challenge, user verification, signature and sign count. On success it records the new sign count and last use, and issues the single-use fact "passkey assertion verified" for this account and this identity (5 minutes; no credential, no bearer).
|
||||||
|
3. Bifrost starts a fresh ordinary Heimdall sign-in for the account (attempt purpose `UnlinkPasskeyProof`; proof provider as on the page: one the current list shows, never a guess). The attempt carries the fact's id.
|
||||||
|
4. In the completion, which is the only place the bearer exists: the fact is taken (missing, expired, spent, for another account or identity: no Heimdall step-up call and `passkey_expired`); the credential the fact was made with must still be the account's own, un-revoked and strong (a passkey revoked since the assertion: no step-up call, `passkey_failed`); the app secret, the bearer and `{method: "bifrost-passkey", nonce}` go to `POST /v1/apps/bifrost/step-up` (nonce: 32 fresh random bytes, base64url, 43 characters); the returned `accessToken` goes to the existing unlink call; everything is dropped. The bearer and the minted token are never stored, logged, put in a cookie or a redirect, or rendered.
|
||||||
|
|
||||||
|
Heimdall's refusals are shown as closed codes and nothing is retried in the flow: `401 sign_in_required` is `sign_in_required`; `403`/`400`/`401 app_auth_required` is `step_up_unavailable`; `409 step_up_nonce_replayed` is `step_up_replayed`; `429` is `step_up_rate_limited`; anything else, or Heimdall down, is `error`. A member whose attempt ends this way starts the unlink again (a new assertion).
|
||||||
|
|
||||||
|
## Abuse cases and what stops them
|
||||||
|
|
||||||
|
Each is a test in `tests/Bifrost.Web.Tests/PasskeyTests.cs` unless noted.
|
||||||
|
|
||||||
|
| Case | Defence |
|
||||||
|
|---|---|
|
||||||
|
| Stolen session cookie tries to enrol | No ticket without a fresh Heimdall sign-in completed in the victim's own browser. A ticket is per account, spent once, expires in 5 minutes, and is held by id in an HttpOnly cookie |
|
||||||
|
| Stolen session tries to unlink | Needs an assertion by a strong passkey of the account; the Google route is closed while one exists; no step-up is asked of Heimdall without the fact |
|
||||||
|
| Attacker who controls the provider session enrols their own key | Cooling-off: 48 hours before it counts, shown on the page, revocable by the member |
|
||||||
|
| Replay of an assertion, double submit | The challenge is single-use; two concurrent posts start one sign-in |
|
||||||
|
| Assertion for another account or identity | The credential must belong to the session's account; the challenge, the fact and the attempt are bound to the account and the chosen identity |
|
||||||
|
| Phishing site | The library checks origin and RP ID hash against the configured values; a registration or assertion with another origin or RP ID is refused |
|
||||||
|
| Cloned authenticator | A sign count that does not increase is refused; the stored count is updated on every use |
|
||||||
|
| Cooling-off boundary | Strong at exactly `StrongFromUtc`, not one second before |
|
||||||
|
| Revoked credential | Never strong; a revoke between challenge and assertion also refuses, and so does one between the assertion and the completion |
|
||||||
|
| Junk from a session thief | Preconditions first, nothing spent before they hold (see State) |
|
||||||
|
| No user verification | Required at enrolment and at assertion |
|
||||||
|
| Cross-site POST | Antiforgery on every page POST including the JSON option requests |
|
||||||
|
| Bearer anywhere | Only in the completion request's stack; tests look in logs (with HttpClient trace logging on), cookies, redirects, the page and the decrypted attempt |
|
||||||
|
| Reused or replayed step-up | A fresh nonce each call; Heimdall's 409 is shown, not retried |
|
||||||
|
| Brute force of ceremonies | One `FixedWindowBudget` per account |
|
||||||
|
|
||||||
|
## Account-scoped lookup and the allow list
|
||||||
|
|
||||||
|
An assertion is accepted only for a credential of the session's own account (strong, un-revoked), and the user handle, when the authenticator sends one, must be that account's id. The request options also carry the account's strong credentials as `allowCredentials`, which the library enforces, so a credential of another account is refused twice. The test for the account-scoped lookup (`The_credential_lookup_is_scoped_to_the_session_account_even_when_the_allow_list_is_empty`) takes the allow list out of the other account's challenge through the in-process ceremony store, which production code cannot do, so the lookup is exercised alone without weakening the server.
|
||||||
|
|
||||||
|
## Content Security Policy
|
||||||
|
|
||||||
|
Bifrost sends no `Content-Security-Policy` header (nothing in the application sets one; this predates passkeys). The passkey script is first-party and external so that a policy of `script-src 'self'` would admit it, but nothing enforces that today.
|
||||||
|
|
||||||
|
## The script
|
||||||
|
|
||||||
|
`wwwroot/js/passkeys.js` is first-party, loaded by the page with an external `src` (nothing inline, no third-party asset, no `eval`, no `innerHTML`). It asks for options with the antiforgery header, calls `navigator.credentials`, and submits a same-origin form of base64url strings (`id`, `rawId`, `attestationObject` or `authenticatorData`, `clientDataJson`, `signature`, `userHandle`, `transports`). On any failure it navigates to this page with a result code from a closed set; nothing from a response is shown. `PasskeyScriptTests` runs it under Node against a simulated browser and pins what it posts.
|
||||||
|
|
||||||
|
## What it does not do
|
||||||
|
|
||||||
|
- No passkey sign-in; passkeys prove an already signed-in session only.
|
||||||
|
- No native-only accounts (no Heimdall account, nothing to unlink).
|
||||||
|
- No recovery without mail: a member who loses every passkey revokes them and falls back to the Google step-up if a Google login is linked, or waits for the mail gate (`docs/registration-gates.md`) and the email-code factor that follows it. The email code is not built.
|
||||||
|
- Named limit and next hardening: enrolling a second passkey should require an assertion by an existing strong passkey, and enrolment should use a Google step-up when a Google login is held. Today enrolment needs only the fresh ordinary sign-in and the cooling-off, so an attacker who controls a provider session can add a key that becomes strong after 48 hours unless the member sees and revokes it. Not built; a follow-up is filed.
|
||||||
|
- A revoke between the completion's check and its unlink does not stop that unlink: the window is the few milliseconds of the two Heimdall calls and is inherent to checking before acting.
|
||||||
|
- Revoking needs no strong proof, so a stolen session can revoke a member's passkeys and fall back to the Google proof: no weaker than the account was before it had a passkey.
|
||||||
|
- A passkey synced through a cloud account the attacker also controls is a residual risk, as in the scoping note.
|
||||||
|
- Heimdall cannot tell that a passkey was checked: `bifrost-passkey` is Bifrost's word, gated by the app secret and a live bearer (Heimdall `docs/app-profiles/bifrost.md`).
|
||||||
|
- Not verified here: a real authenticator or browser. Tests use a software authenticator built from the WebAuthn specification and the script runs under Node against a simulated browser.
|
||||||
|
|
||||||
|
## Where the code is
|
||||||
|
|
||||||
|
- `Features/Passkeys/PasskeyService.cs`: enrol, assert, revoke, list; `PasskeyCeremonies.cs`: the single-use records and the rate limit; `PasskeyRelyingParty.cs`: RP ID, origin, library construction.
|
||||||
|
- `Features/Heimdall/HeimdallAuthService.cs`: the `EnrolProof` and `UnlinkPasskeyProof` attempts, `StepUpAsync`.
|
||||||
|
- `Pages/Membership/LinkedAccounts.cshtml[.cs]`, `wwwroot/js/passkeys.js`.
|
||||||
|
- `Domain/BifrostModels.cs` (`PasskeyCredential`), `Data/Migrations/20261113120000_AddPasskeyCredentials.cs`.
|
||||||
|
- Tests: `PasskeyTests.cs`, `PasskeyScriptTests.cs`, `Support/SoftwareAuthenticator.cs`, and the step-up route of the fake Heimdall in `Support/HeimdallTestKit.cs`.
|
||||||
|
|
@ -13,6 +13,7 @@
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
<PackageReference Include="BouncyCastle.Cryptography" Version="2.4.0" />
|
<PackageReference Include="BouncyCastle.Cryptography" Version="2.4.0" />
|
||||||
|
<PackageReference Include="Fido2" Version="4.3.3" />
|
||||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.0" />
|
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.0" />
|
||||||
<!-- Direct pins lift transitive packages past their advisories; the lockfile holds the result. -->
|
<!-- Direct pins lift transitive packages past their advisories; the lockfile holds the result. -->
|
||||||
<PackageReference Include="Npgsql" Version="8.0.3" />
|
<PackageReference Include="Npgsql" Version="8.0.3" />
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,11 @@ public sealed class HeimdallOptions
|
||||||
|
|
||||||
public string PatreonTierTitle { get; init; } = "Inner Sanctum";
|
public string PatreonTierTitle { get; init; } = "Inner Sanctum";
|
||||||
|
|
||||||
public string AppSecret { get; init; } = string.Empty;
|
/// <summary>Deprecated: rides in the process environment. Use <see cref="AppSecretFile"/>; the file wins when both are set.</summary>
|
||||||
|
public string AppSecret { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Path of a file holding the app secret (systemd <c>LoadCredential</c> style: <c>%d/heimdall-app-secret</c>).</summary>
|
||||||
|
public string AppSecretFile { get; init; } = string.Empty;
|
||||||
|
|
||||||
public string PatronSupportIntakeSecret { get; init; } = string.Empty;
|
public string PatronSupportIntakeSecret { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
|
@ -39,4 +43,37 @@ public sealed class HeimdallOptions
|
||||||
IsConfigured &&
|
IsConfigured &&
|
||||||
EnablePatronSupportIntake &&
|
EnablePatronSupportIntake &&
|
||||||
!string.IsNullOrWhiteSpace(PatronSupportIntakeSecret);
|
!string.IsNullOrWhiteSpace(PatronSupportIntakeSecret);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves <see cref="AppSecret"/> once, when the options are built. A set <see cref="AppSecretFile"/> must be a
|
||||||
|
/// readable, non-empty file, or startup fails; the message names the option and path, never a secret.
|
||||||
|
/// </summary>
|
||||||
|
public static void ResolveAppSecret(HeimdallOptions options, ILogger logger)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(options.AppSecret))
|
||||||
|
{
|
||||||
|
logger.LogWarning("Heimdall:AppSecret is deprecated: it rides in the process environment. Use Heimdall:AppSecretFile, which wins when both are set.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(options.AppSecretFile))
|
||||||
|
{
|
||||||
|
string secret;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
secret = File.ReadAllText(options.AppSecretFile).TrimEnd('\r', '\n');
|
||||||
|
}
|
||||||
|
catch (Exception error) when (error is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Heimdall:AppSecretFile must name a readable file; '{options.AppSecretFile}' could not be read ({error.GetType().Name}).");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(secret))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"Heimdall:AppSecretFile '{options.AppSecretFile}' is empty.");
|
||||||
|
}
|
||||||
|
|
||||||
|
options.AppSecret = secret;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,7 @@ public sealed class BifrostDbContext(DbContextOptions<BifrostDbContext> options)
|
||||||
|
|
||||||
public DbSet<ConsentReceipt> ConsentReceipts => Set<ConsentReceipt>();
|
public DbSet<ConsentReceipt> ConsentReceipts => Set<ConsentReceipt>();
|
||||||
public DbSet<MindKey> MindKeys => Set<MindKey>();
|
public DbSet<MindKey> MindKeys => Set<MindKey>();
|
||||||
|
public DbSet<PasskeyCredential> PasskeyCredentials => Set<PasskeyCredential>();
|
||||||
|
|
||||||
public DbSet<MindNonce> MindNonces => Set<MindNonce>();
|
public DbSet<MindNonce> MindNonces => Set<MindNonce>();
|
||||||
|
|
||||||
|
|
@ -333,6 +334,16 @@ public sealed class BifrostDbContext(DbContextOptions<BifrostDbContext> options)
|
||||||
.OnDelete(DeleteBehavior.Cascade);
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity<PasskeyCredential>(entity =>
|
||||||
|
{
|
||||||
|
entity.HasIndex(x => x.CredentialId).IsUnique();
|
||||||
|
entity.HasIndex(x => x.UserAccountId);
|
||||||
|
entity.HasOne(x => x.UserAccount)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(x => x.UserAccountId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity<MindKey>(entity =>
|
modelBuilder.Entity<MindKey>(entity =>
|
||||||
{
|
{
|
||||||
entity.HasIndex(x => new { x.PersonaId, x.PublicKey }).IsUnique();
|
entity.HasIndex(x => new { x.PersonaId, x.PublicKey }).IsUnique();
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
using System;
|
||||||
|
using Bifrost.Web.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Bifrost.Web.Data.Migrations
|
||||||
|
{
|
||||||
|
// Hand-written like AddConsentReceipts: no dotnet-ef on the authoring host; the snapshot was edited to match. Additive only.
|
||||||
|
[DbContext(typeof(BifrostDbContext))]
|
||||||
|
[Migration("20261113120000_AddPasskeyCredentials")]
|
||||||
|
public partial class AddPasskeyCredentials : Migration
|
||||||
|
{
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "PasskeyCredentials",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
UserAccountId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
CredentialId = table.Column<byte[]>(type: "bytea", nullable: false),
|
||||||
|
PublicKey = table.Column<byte[]>(type: "bytea", nullable: false),
|
||||||
|
SignCount = table.Column<long>(type: "bigint", nullable: false),
|
||||||
|
Transports = table.Column<string>(type: "text", nullable: false),
|
||||||
|
AaGuid = table.Column<Guid>(type: "uuid", nullable: true),
|
||||||
|
Label = table.Column<string>(type: "text", nullable: false),
|
||||||
|
CreatedUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||||
|
StrongFromUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||||
|
RevokedUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||||
|
LastUsedUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_PasskeyCredentials", x => x.Id);
|
||||||
|
table.ForeignKey("FK_PasskeyCredentials_UserAccounts_UserAccountId", x => x.UserAccountId, "UserAccounts", "Id", onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
migrationBuilder.CreateIndex(name: "IX_PasskeyCredentials_CredentialId", table: "PasskeyCredentials", column: "CredentialId", unique: true);
|
||||||
|
migrationBuilder.CreateIndex(name: "IX_PasskeyCredentials_UserAccountId", table: "PasskeyCredentials", column: "UserAccountId");
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(name: "PasskeyCredentials");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1470,6 +1470,59 @@ namespace Bifrost.Web.Data.Migrations
|
||||||
b.ToTable("ConsentReceipts");
|
b.ToTable("ConsentReceipts");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Bifrost.Web.Domain.PasskeyCredential", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid?>("AaGuid")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<byte[]>("CredentialId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("bytea");
|
||||||
|
|
||||||
|
b.Property<string>("Label")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("LastUsedUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<byte[]>("PublicKey")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("bytea");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("RevokedUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<long>("SignCount")
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("StrongFromUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Transports")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<Guid>("UserAccountId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("CredentialId")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.HasIndex("UserAccountId");
|
||||||
|
|
||||||
|
b.ToTable("PasskeyCredentials");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Bifrost.Web.Domain.MindKey", b =>
|
modelBuilder.Entity("Bifrost.Web.Domain.MindKey", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
|
|
@ -2183,6 +2236,17 @@ namespace Bifrost.Web.Data.Migrations
|
||||||
b.Navigation("UserAccount");
|
b.Navigation("UserAccount");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Bifrost.Web.Domain.PasskeyCredential", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Bifrost.Web.Domain.UserAccount", "UserAccount")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UserAccountId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("UserAccount");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Bifrost.Web.Domain.MemberProfile", b =>
|
modelBuilder.Entity("Bifrost.Web.Domain.MemberProfile", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("Bifrost.Web.Domain.UserAccount", "UserAccount")
|
b.HasOne("Bifrost.Web.Domain.UserAccount", "UserAccount")
|
||||||
|
|
|
||||||
|
|
@ -1177,6 +1177,41 @@ public sealed class ConsentReceipt
|
||||||
public UserAccount? UserAccount { get; set; }
|
public UserAccount? UserAccount { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A WebAuthn passkey a Heimdall-backed member enrolled (docs/passkeys.md). Holds only what verifying an assertion needs:
|
||||||
|
/// the credential id, the public key and the sign count. No secret exists to hold. It counts as strong proof only from
|
||||||
|
/// <see cref="StrongFromUtc"/> (the cooling-off after enrolment) and only while not revoked.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class PasskeyCredential
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; } = Guid.NewGuid();
|
||||||
|
|
||||||
|
public Guid UserAccountId { get; set; }
|
||||||
|
|
||||||
|
public byte[] CredentialId { get; set; } = [];
|
||||||
|
|
||||||
|
public byte[] PublicKey { get; set; } = [];
|
||||||
|
|
||||||
|
public long SignCount { get; set; }
|
||||||
|
|
||||||
|
// Comma-separated WebAuthn transport hints the authenticator reported at enrolment; a hint for the browser, never trusted.
|
||||||
|
public string Transports { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public Guid? AaGuid { get; set; }
|
||||||
|
|
||||||
|
public string Label { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public DateTimeOffset CreatedUtc { get; set; }
|
||||||
|
|
||||||
|
public DateTimeOffset StrongFromUtc { get; set; }
|
||||||
|
|
||||||
|
public DateTimeOffset? RevokedUtc { get; set; }
|
||||||
|
|
||||||
|
public DateTimeOffset? LastUsedUtc { get; set; }
|
||||||
|
|
||||||
|
public UserAccount? UserAccount { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>A Mind's registered Ed25519 public key. Bifrost never holds a private key.</summary>
|
/// <summary>A Mind's registered Ed25519 public key. Bifrost never holds a private key.</summary>
|
||||||
public sealed class MindKey
|
public sealed class MindKey
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ using Bifrost.Web.Configuration;
|
||||||
using Bifrost.Web.Data;
|
using Bifrost.Web.Data;
|
||||||
using Bifrost.Web.Domain;
|
using Bifrost.Web.Domain;
|
||||||
using Bifrost.Web.Features.Membership;
|
using Bifrost.Web.Features.Membership;
|
||||||
|
using Bifrost.Web.Features.Passkeys;
|
||||||
using Bifrost.Web.Features.Shared;
|
using Bifrost.Web.Features.Shared;
|
||||||
using Microsoft.AspNetCore.DataProtection;
|
using Microsoft.AspNetCore.DataProtection;
|
||||||
using Microsoft.AspNetCore.WebUtilities;
|
using Microsoft.AspNetCore.WebUtilities;
|
||||||
|
|
@ -20,7 +21,9 @@ namespace Bifrost.Web.Features.Heimdall;
|
||||||
/// Heimdall sign-in. Start issues an attempt that lives only in a Data Protection-protected cookie in the
|
/// Heimdall sign-in. Start issues an attempt that lives only in a Data Protection-protected cookie in the
|
||||||
/// browser that began it. The browser that Heimdall authenticated presents the completion code from its
|
/// browser that began it. The browser that Heimdall authenticated presents the completion code from its
|
||||||
/// URL fragment; Bifrost redeems it at Heimdall itself and signs in only when that browser holds the
|
/// URL fragment; Bifrost redeems it at Heimdall itself and signs in only when that browser holds the
|
||||||
/// attempt's cookie and the redeemed return target names the same attempt.
|
/// attempt's cookie and the redeemed return target names the same attempt. The Linked accounts page's steps (list,
|
||||||
|
/// link, unlink) are the same machine with other attempt purposes: each is a fresh Heimdall sign-in whose bearer is
|
||||||
|
/// used inside that one request and dropped.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class HeimdallAuthService(
|
public sealed class HeimdallAuthService(
|
||||||
HttpClient httpClient,
|
HttpClient httpClient,
|
||||||
|
|
@ -29,7 +32,11 @@ public sealed class HeimdallAuthService(
|
||||||
BifrostIdentityService bifrostIdentityService,
|
BifrostIdentityService bifrostIdentityService,
|
||||||
IOptions<HeimdallOptions> heimdallOptions,
|
IOptions<HeimdallOptions> heimdallOptions,
|
||||||
IDataProtectionProvider dataProtectionProvider,
|
IDataProtectionProvider dataProtectionProvider,
|
||||||
TimeProvider timeProvider)
|
TimeProvider timeProvider,
|
||||||
|
HeimdallMemberClient memberClient,
|
||||||
|
LinkedAccountsSnapshots snapshots,
|
||||||
|
PasskeyCeremonies ceremonies,
|
||||||
|
PasskeyService passkeys)
|
||||||
{
|
{
|
||||||
public const string ErrorRedirect = "/?auth=heimdall-error";
|
public const string ErrorRedirect = "/?auth=heimdall-error";
|
||||||
public const string AppSecretHeader = "x-heimdall-app-secret";
|
public const string AppSecretHeader = "x-heimdall-app-secret";
|
||||||
|
|
@ -41,6 +48,11 @@ public sealed class HeimdallAuthService(
|
||||||
"twitch"
|
"twitch"
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// <summary>The headers HttpClient logging must never write: the app secret and any bearer.</summary>
|
||||||
|
public static bool RedactsHeader(string header) =>
|
||||||
|
string.Equals(header, AppSecretHeader, StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| string.Equals(header, "Authorization", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
private readonly IDataProtector _protector = dataProtectionProvider.CreateProtector("Bifrost.Heimdall.Attempt.v1");
|
private readonly IDataProtector _protector = dataProtectionProvider.CreateProtector("Bifrost.Heimdall.Attempt.v1");
|
||||||
|
|
||||||
/// <summary>Points the client at Heimdall and, when the app has a secret, identifies Bifrost as the app.</summary>
|
/// <summary>Points the client at Heimdall and, when the app has a secret, identifies Bifrost as the app.</summary>
|
||||||
|
|
@ -53,15 +65,39 @@ public sealed class HeimdallAuthService(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Names the HTTP status and, when the body carries one, Heimdall's error code (a short snake_case token only,
|
||||||
|
/// see <see cref="HeimdallRefusal"/>), so nothing else from the body reaches a log.
|
||||||
|
/// </summary>
|
||||||
|
private static async Task<string> RefusedStartMessageAsync(HttpResponseMessage response, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var detail = $"HTTP {(int)response.StatusCode}";
|
||||||
|
if (await HeimdallRefusal.CodeAsync(response, cancellationToken) is { } code)
|
||||||
|
{
|
||||||
|
detail += ", " + code;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $"Heimdall rejected the auth start ({detail}).";
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<HeimdallAuthStartResult> StartAsync(
|
public async Task<HeimdallAuthStartResult> StartAsync(
|
||||||
string provider,
|
string provider,
|
||||||
string callbackBaseUrl,
|
string callbackBaseUrl,
|
||||||
LocalReturnPath returnPath,
|
LocalReturnPath returnPath,
|
||||||
bool requireMemberAccess,
|
bool requireMemberAccess,
|
||||||
IEnumerable<KeyValuePair<string, string>> requestCookies,
|
IEnumerable<KeyValuePair<string, string>> requestCookies,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken,
|
||||||
|
HeimdallAttemptPurpose purpose = HeimdallAttemptPurpose.SignIn,
|
||||||
|
string? expectedAccountId = null,
|
||||||
|
string? pendingLinkId = null,
|
||||||
|
string? replaceSlot = null,
|
||||||
|
bool stepUp = false,
|
||||||
|
string? target = null,
|
||||||
|
string? proof = null)
|
||||||
{
|
{
|
||||||
if (!AllowedProviders.Contains(provider))
|
// The public sign-in routes offer AllowedProviders; the proofs of the Linked accounts page may also use Google
|
||||||
|
// (a step-up proof only Google can give a bifrost sign-in, an ordinary one for an account that holds Google).
|
||||||
|
if (!(purpose == HeimdallAttemptPurpose.SignIn ? AllowedProviders : ProofProviders).Contains(provider))
|
||||||
{
|
{
|
||||||
throw new HeimdallAuthException("Unsupported Heimdall provider.");
|
throw new HeimdallAuthException("Unsupported Heimdall provider.");
|
||||||
}
|
}
|
||||||
|
|
@ -82,16 +118,19 @@ public sealed class HeimdallAuthService(
|
||||||
"sign_in",
|
"sign_in",
|
||||||
ToAttemptReturnUrl(callbackBaseUrl, nonce),
|
ToAttemptReturnUrl(callbackBaseUrl, nonce),
|
||||||
new HeimdallHandoff("browser_completion"),
|
new HeimdallHandoff("browser_completion"),
|
||||||
entitlementPolicy);
|
entitlementPolicy,
|
||||||
|
stepUp ? true : null);
|
||||||
|
|
||||||
var response = await httpClient.PostAsJsonAsync(
|
// Headers only: a refusal body is read through RefusedStartMessageAsync's own 4 KiB bound, not buffered whole.
|
||||||
$"/v1/oauth/{provider}/start",
|
using var startMessage = new HttpRequestMessage(HttpMethod.Post, $"/v1/oauth/{provider}/start")
|
||||||
request,
|
{
|
||||||
cancellationToken);
|
Content = JsonContent.Create(request)
|
||||||
|
};
|
||||||
|
using var response = await SendStartAsync(startMessage, cancellationToken);
|
||||||
|
|
||||||
if (!response.IsSuccessStatusCode)
|
if (!response.IsSuccessStatusCode)
|
||||||
{
|
{
|
||||||
throw new HeimdallAuthException("Heimdall rejected the auth start.");
|
throw new HeimdallAuthException(await RefusedStartMessageAsync(response, cancellationToken));
|
||||||
}
|
}
|
||||||
|
|
||||||
var payload = await response.Content.ReadFromJsonAsync<HeimdallStartResponse>(cancellationToken);
|
var payload = await response.Content.ReadFromJsonAsync<HeimdallStartResponse>(cancellationToken);
|
||||||
|
|
@ -105,11 +144,158 @@ public sealed class HeimdallAuthService(
|
||||||
provider,
|
provider,
|
||||||
requireMemberAccess,
|
requireMemberAccess,
|
||||||
returnPath,
|
returnPath,
|
||||||
timeProvider.GetUtcNow() + HeimdallAttemptCookie.Lifetime);
|
timeProvider.GetUtcNow() + HeimdallAttemptCookie.Lifetime,
|
||||||
|
purpose,
|
||||||
|
expectedAccountId,
|
||||||
|
pendingLinkId,
|
||||||
|
target,
|
||||||
|
proof);
|
||||||
return new HeimdallAuthStartResult(
|
return new HeimdallAuthStartResult(
|
||||||
new Uri(payload.AuthorizationUrl, UriKind.Absolute), ChooseSlot(requestCookies), Protect(attempt));
|
new Uri(payload.AuthorizationUrl, UriKind.Absolute), replaceSlot ?? ChooseSlot(requestCookies), Protect(attempt));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>A start that cannot reach Heimdall is a refusal like any other, never a 500.</summary>
|
||||||
|
private async Task<HttpResponseMessage> SendStartAsync(HttpRequestMessage message, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await httpClient.SendAsync(message, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
|
||||||
|
}
|
||||||
|
catch (Exception error) when (error is HttpRequestException or TaskCanceledException && !cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
throw new HeimdallAuthException("Heimdall could not be reached for the auth start.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The provider an ordinary proof sign-in uses, or null when none is safe. A sign-in through a provider the account no
|
||||||
|
/// longer holds makes Heimdall mint an unrelated account that holds that login (Discord and Patreon; Google refuses instead), so it is never guessed: with the account's
|
||||||
|
/// current list, the first held one of the person's choice, Discord, Patreon, Google; with no list, only a provider the
|
||||||
|
/// member named (<paramref name="via"/>), who is the one who knows what they hold. The session's provider claim is not
|
||||||
|
/// consulted: it records how they signed in once, not what the account holds now.
|
||||||
|
/// </summary>
|
||||||
|
public static string? ProofProvider(IReadOnlyCollection<string>? held, string? via)
|
||||||
|
{
|
||||||
|
var chosen = via is not null && ProofProviders.Contains(via) ? via.ToLowerInvariant() : null;
|
||||||
|
if (held is null)
|
||||||
|
{
|
||||||
|
return chosen;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new[] { chosen, "discord", "patreon", "google" }
|
||||||
|
.FirstOrDefault(x => x is not null && held.Contains(x, StringComparer.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
|
|
||||||
|
private string ProofProviderFor(string accountId, string? via) =>
|
||||||
|
ProofProvider(snapshots.Get(accountId)?.Select(x => x.Provider).ToArray(), via)
|
||||||
|
?? throw new HeimdallAuthException("No provider the account is known to hold can prove it.");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The page's proof sign-ins. Each is a Heimdall sign-in for the account already signed in here, kept as an attempt
|
||||||
|
/// that says what it is the proof for (list, link, unlink) and which person it must prove. The session is never replaced.
|
||||||
|
/// </summary>
|
||||||
|
private Task<HeimdallAuthStartResult> StartProofAsync(
|
||||||
|
HeimdallAttemptPurpose purpose,
|
||||||
|
string provider,
|
||||||
|
string sessionAccountId,
|
||||||
|
string callbackBaseUrl,
|
||||||
|
IEnumerable<KeyValuePair<string, string>> requestCookies,
|
||||||
|
bool stepUp,
|
||||||
|
string? target,
|
||||||
|
CancellationToken cancellationToken,
|
||||||
|
string? proof = null) =>
|
||||||
|
StartAsync(
|
||||||
|
provider,
|
||||||
|
callbackBaseUrl,
|
||||||
|
LocalReturnPath.Parse(LinkReturnPath),
|
||||||
|
requireMemberAccess: false,
|
||||||
|
requestCookies,
|
||||||
|
cancellationToken,
|
||||||
|
purpose,
|
||||||
|
sessionAccountId,
|
||||||
|
stepUp: stepUp,
|
||||||
|
target: target,
|
||||||
|
proof: proof);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reads the account's logins: an ordinary sign-in, then GET /v1/me/identities inside its completion. Through a provider on
|
||||||
|
/// the current list, or, when there is none, the one the member names in <paramref name="via"/>.
|
||||||
|
/// </summary>
|
||||||
|
public Task<HeimdallAuthStartResult> StartListAsync(
|
||||||
|
string sessionAccountId, string? via, string callbackBaseUrl,
|
||||||
|
IEnumerable<KeyValuePair<string, string>> requestCookies, CancellationToken cancellationToken) =>
|
||||||
|
StartProofAsync(
|
||||||
|
HeimdallAttemptPurpose.ListProof, ProofProviderFor(sessionAccountId, via),
|
||||||
|
sessionAccountId, callbackBaseUrl, requestCookies, false, null, cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Links another provider: an ordinary sign-in as proof through a provider on the account's current list (there is no
|
||||||
|
/// guessing without one), then the link start for <paramref name="targetProvider"/> with its bearer.
|
||||||
|
/// </summary>
|
||||||
|
public Task<HeimdallAuthStartResult> StartLinkAsync(
|
||||||
|
string sessionAccountId, string targetProvider, string callbackBaseUrl,
|
||||||
|
IEnumerable<KeyValuePair<string, string>> requestCookies, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!LinkableProviders.Contains(targetProvider))
|
||||||
|
{
|
||||||
|
throw new HeimdallAuthException("Unsupported provider to link.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return StartProofAsync(
|
||||||
|
HeimdallAttemptPurpose.LinkStart, ProofProviderFor(sessionAccountId, null),
|
||||||
|
sessionAccountId, callbackBaseUrl, requestCookies, false, targetProvider.ToLowerInvariant(), cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Unlinks one login: a step-up sign-in through Google, then POST /v1/me/identities/{id}/unlink with its bearer. The
|
||||||
|
/// identity must be on the account's current snapshot and the account must hold Google; otherwise no proof can be had
|
||||||
|
/// and Heimdall is not asked.
|
||||||
|
/// </summary>
|
||||||
|
public Task<HeimdallAuthStartResult> StartUnlinkAsync(
|
||||||
|
string sessionAccountId, string identityId, string callbackBaseUrl,
|
||||||
|
IEnumerable<KeyValuePair<string, string>> requestCookies, CancellationToken cancellationToken) =>
|
||||||
|
StartProofAsync(
|
||||||
|
HeimdallAttemptPurpose.UnlinkProof, StepUpProvider, sessionAccountId, callbackBaseUrl, requestCookies, true, identityId, cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The fresh sign-in that earns an enrolment ticket (docs/passkeys.md): an ordinary sign-in through a provider the account
|
||||||
|
/// holds, proving the account to this browser's session. Its completion issues the ticket; no passkey exists before it.
|
||||||
|
/// </summary>
|
||||||
|
public Task<HeimdallAuthStartResult> StartEnrolProofAsync(
|
||||||
|
string sessionAccountId, string callbackBaseUrl,
|
||||||
|
IEnumerable<KeyValuePair<string, string>> requestCookies, CancellationToken cancellationToken) =>
|
||||||
|
StartProofAsync(
|
||||||
|
HeimdallAttemptPurpose.EnrolProof, ProofProviderFor(sessionAccountId, null),
|
||||||
|
sessionAccountId, callbackBaseUrl, requestCookies, false, null, cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Unlinks one login on a verified passkey assertion: an ordinary fresh sign-in as the account (never a guessed provider), and
|
||||||
|
/// inside its completion, with that bearer, Heimdall's app-asserted step-up and then the unlink. <paramref name="factId"/> is the
|
||||||
|
/// id of the single-use assertion fact; the attempt carries it and nothing else about the passkey.
|
||||||
|
/// </summary>
|
||||||
|
public Task<HeimdallAuthStartResult> StartUnlinkPasskeyAsync(
|
||||||
|
string sessionAccountId, string identityId, string factId, string callbackBaseUrl,
|
||||||
|
IEnumerable<KeyValuePair<string, string>> requestCookies, CancellationToken cancellationToken) =>
|
||||||
|
StartProofAsync(
|
||||||
|
HeimdallAttemptPurpose.UnlinkPasskeyProof, ProofProviderFor(sessionAccountId, null),
|
||||||
|
sessionAccountId, callbackBaseUrl, requestCookies, false, identityId, cancellationToken, factId);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The providers Heimdall's bifrost profile admits (src/app-profiles.ts, <c>identityProviders</c>): a link or a proof can use them.
|
||||||
|
/// Twitch is offered for sign-in here but the profile refuses it, so it is not linkable.
|
||||||
|
/// </summary>
|
||||||
|
public static readonly IReadOnlyList<string> LinkableProviders = ["discord", "patreon", "google"];
|
||||||
|
|
||||||
|
private static readonly HashSet<string> ProofProviders = new(LinkableProviders, StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The one provider whose step-up a bifrost sign-in can use (docs/service-contract.md, step-up: Discord and Patreon cannot
|
||||||
|
/// re-authenticate, Twitch and YouTube are not in the bifrost profile). Heimdall answers 503 until a Google client is configured.
|
||||||
|
/// </summary>
|
||||||
|
public const string StepUpProvider = "google";
|
||||||
|
|
||||||
|
public const string LinkReturnPath = "/Membership/LinkedAccounts";
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The slot the new attempt's cookie takes: the first slot that holds no live attempt, else the one whose
|
/// The slot the new attempt's cookie takes: the first slot that holds no live attempt, else the one whose
|
||||||
/// attempt expires first. Parallel starts carry the same request cookies, so they choose the same slot and
|
/// attempt expires first. Parallel starts carry the same request cookies, so they choose the same slot and
|
||||||
|
|
@ -156,15 +342,13 @@ public sealed class HeimdallAuthService(
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<HeimdallCompletedSignIn> CompleteAsync(
|
/// <summary>What the attempt this browser holds for the nonce is for, or null when it holds none.</summary>
|
||||||
string callbackBaseUrl,
|
public HeimdallAttemptPurpose? PurposeOf(string? cookieValue, string? nonce) => Unprotect(cookieValue, nonce)?.Purpose;
|
||||||
string? nonce,
|
|
||||||
string? cookieValue,
|
/// <summary>Redeems the code of an attempt and verifies the token it carries. The token is returned to the caller's stack frame and nowhere else.</summary>
|
||||||
string? code,
|
private async Task<(HeimdallVerifiedIdentity Identity, string AccessToken, HeimdallRedeemResponse Redeemed)> RedeemVerifiedAsync(
|
||||||
CancellationToken cancellationToken)
|
string callbackBaseUrl, HeimdallAttempt attempt, string? code, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var attempt = Unprotect(cookieValue, nonce)
|
|
||||||
?? throw new HeimdallAuthException("Heimdall completion has no attempt for this browser.");
|
|
||||||
if (string.IsNullOrWhiteSpace(code))
|
if (string.IsNullOrWhiteSpace(code))
|
||||||
{
|
{
|
||||||
throw new HeimdallAuthException("Heimdall completion carried no code.");
|
throw new HeimdallAuthException("Heimdall completion carried no code.");
|
||||||
|
|
@ -182,6 +366,24 @@ public sealed class HeimdallAuthService(
|
||||||
}
|
}
|
||||||
|
|
||||||
var identity = await tokenVerifier.VerifyAsync(redeemed.AccessToken, redeemed.Account?.Id, cancellationToken);
|
var identity = await tokenVerifier.VerifyAsync(redeemed.AccessToken, redeemed.Account?.Id, cancellationToken);
|
||||||
|
return (identity, redeemed.AccessToken, redeemed);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<HeimdallCompletedSignIn> CompleteAsync(
|
||||||
|
string callbackBaseUrl,
|
||||||
|
string? nonce,
|
||||||
|
string? cookieValue,
|
||||||
|
string? code,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var attempt = Unprotect(cookieValue, nonce)
|
||||||
|
?? throw new HeimdallAuthException("Heimdall completion has no attempt for this browser.");
|
||||||
|
if (attempt.Purpose != HeimdallAttemptPurpose.SignIn)
|
||||||
|
{
|
||||||
|
throw new HeimdallAuthException("Heimdall completion is not a sign-in attempt.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var (identity, _, _) = await RedeemVerifiedAsync(callbackBaseUrl, attempt, code, cancellationToken);
|
||||||
if (attempt.RequiresMemberAccess && !identity.HasMemberAccess)
|
if (attempt.RequiresMemberAccess && !identity.HasMemberAccess)
|
||||||
{
|
{
|
||||||
throw new HeimdallAuthException("Heimdall did not grant Bifrost member access.");
|
throw new HeimdallAuthException("Heimdall did not grant Bifrost member access.");
|
||||||
|
|
@ -197,6 +399,323 @@ public sealed class HeimdallAuthService(
|
||||||
attempt.ReturnPath);
|
attempt.ReturnPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One step of a Linked accounts action, chosen by the purpose of the attempt this browser holds. The browser's session
|
||||||
|
/// must be the account the action was begun for, checked before Heimdall is asked anything and again against the identity
|
||||||
|
/// the fresh sign-in proves. Each step consumes the held attempt: the next step is a new attempt in the same slot, or none.
|
||||||
|
/// The bearer a step redeems is used inside the step and dropped.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<HeimdallLinkStep> ContinueLinkAsync(
|
||||||
|
string callbackBaseUrl,
|
||||||
|
string? nonce,
|
||||||
|
string cookieName,
|
||||||
|
string? cookieValue,
|
||||||
|
string? code,
|
||||||
|
string? pendingLinkId,
|
||||||
|
string? sessionAccountId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var attempt = Unprotect(cookieValue, nonce)
|
||||||
|
?? throw new HeimdallAuthException("Heimdall link has no attempt for this browser.");
|
||||||
|
if (string.IsNullOrEmpty(attempt.ExpectedAccountId)
|
||||||
|
|| !string.Equals(sessionAccountId, attempt.ExpectedAccountId, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
throw new HeimdallAuthException("Heimdall link belongs to another session.");
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (attempt.Purpose)
|
||||||
|
{
|
||||||
|
case HeimdallAttemptPurpose.LinkStart:
|
||||||
|
{
|
||||||
|
var (_, bearer) = await RedeemOwnerAsync(callbackBaseUrl, attempt, code, cancellationToken);
|
||||||
|
var authorizationUrl = await StartLinkProviderAsync(callbackBaseUrl, attempt, bearer, cancellationToken);
|
||||||
|
return new HeimdallLinkStep(authorizationUrl.Url, cookieName, authorizationUrl.CookieValue, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
case HeimdallAttemptPurpose.LinkAwaitGoogle:
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(pendingLinkId))
|
||||||
|
{
|
||||||
|
throw new HeimdallAuthException("Heimdall link carried no pending link.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var next = await StartAsync(
|
||||||
|
attempt.Provider,
|
||||||
|
callbackBaseUrl,
|
||||||
|
attempt.ReturnPath,
|
||||||
|
requireMemberAccess: false,
|
||||||
|
[],
|
||||||
|
cancellationToken,
|
||||||
|
HeimdallAttemptPurpose.LinkConfirm,
|
||||||
|
attempt.ExpectedAccountId,
|
||||||
|
pendingLinkId,
|
||||||
|
replaceSlot: cookieName);
|
||||||
|
return new HeimdallLinkStep(next.AuthorizationUrl, next.CookieName, next.CookieValue, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
case HeimdallAttemptPurpose.LinkConfirm:
|
||||||
|
{
|
||||||
|
var (identity, bearer) = await RedeemOwnerAsync(callbackBaseUrl, attempt, code, cancellationToken);
|
||||||
|
var outcome = await ConfirmLinkAsync(attempt.PendingLinkId!, bearer, cancellationToken);
|
||||||
|
if (outcome == HeimdallLinkOutcome.Linked)
|
||||||
|
{
|
||||||
|
// Membership follows the verified identity exactly as at sign-in; the session principal is not replaced.
|
||||||
|
await UpsertUserAccountAsync(identity, attempt.Provider, grantMemberAccess: false, cancellationToken);
|
||||||
|
snapshots.Remove(identity.AccountId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new HeimdallLinkStep(null, cookieName, null, outcome);
|
||||||
|
}
|
||||||
|
|
||||||
|
case HeimdallAttemptPurpose.LinkAwaitDirect:
|
||||||
|
{
|
||||||
|
// A provider other than Google attaches in Heimdall's callback; the browser returns with a link completion.
|
||||||
|
var (identity, _, redeemed) = await RedeemOwnerWithBodyAsync(callbackBaseUrl, attempt, code, cancellationToken);
|
||||||
|
if (redeemed.Mode != "link" || !string.Equals(redeemed.Provider, attempt.Target, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
throw new HeimdallAuthException("Heimdall completion is not this link.");
|
||||||
|
}
|
||||||
|
|
||||||
|
await UpsertUserAccountAsync(identity, attempt.Target!, grantMemberAccess: false, cancellationToken);
|
||||||
|
snapshots.Remove(identity.AccountId);
|
||||||
|
return new HeimdallLinkStep(null, cookieName, null, HeimdallLinkOutcome.Linked);
|
||||||
|
}
|
||||||
|
|
||||||
|
case HeimdallAttemptPurpose.ListProof:
|
||||||
|
{
|
||||||
|
var (identity, bearer) = await RedeemOwnerAsync(callbackBaseUrl, attempt, code, cancellationToken);
|
||||||
|
var listed = await memberClient.ListAsync(bearer, cancellationToken);
|
||||||
|
if (listed.Call == HeimdallMemberCall.Ok)
|
||||||
|
{
|
||||||
|
snapshots.Put(identity.AccountId, listed.Identities!);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new HeimdallLinkStep(null, cookieName, null, listed.Call == HeimdallMemberCall.Ok ? HeimdallLinkOutcome.Listed : OutcomeOf(listed.Call));
|
||||||
|
}
|
||||||
|
|
||||||
|
case HeimdallAttemptPurpose.UnlinkProof:
|
||||||
|
{
|
||||||
|
var (identity, bearer, redeemed) = await RedeemOwnerWithBodyAsync(callbackBaseUrl, attempt, code, cancellationToken);
|
||||||
|
if (redeemed.StepUpRequested != true)
|
||||||
|
{
|
||||||
|
// Heimdall reports "asked" on a step-up completion; one that does not was not a step-up sign-in.
|
||||||
|
return new HeimdallLinkStep(null, cookieName, null, HeimdallLinkOutcome.StepUpRequired);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new HeimdallLinkStep(null, cookieName, null, await UnlinkAsync(identity.AccountId, bearer, attempt.Target, cancellationToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
case HeimdallAttemptPurpose.EnrolProof:
|
||||||
|
{
|
||||||
|
// A fresh ordinary sign-in as the session's own account: the one thing a stolen session cookie cannot produce.
|
||||||
|
// It earns a short-lived single-use ticket, handed to this browser in a cookie; no passkey exists yet.
|
||||||
|
var (identity, _) = await RedeemOwnerAsync(callbackBaseUrl, attempt, code, cancellationToken);
|
||||||
|
var ticket = ceremonies.Issue(CeremonyKind.EnrolTicket, identity.AccountId, null, null, PasskeyCeremonies.TicketLifetime);
|
||||||
|
return new HeimdallLinkStep(
|
||||||
|
null, cookieName, null, ticket is null ? HeimdallLinkOutcome.Error : HeimdallLinkOutcome.EnrolReady, ticket);
|
||||||
|
}
|
||||||
|
|
||||||
|
case HeimdallAttemptPurpose.UnlinkPasskeyProof:
|
||||||
|
{
|
||||||
|
// The assertion fact is spent before anything else: missing, expired, spent, or for another account or identity
|
||||||
|
// means no step-up is asked of Heimdall and nothing is unlinked.
|
||||||
|
var fact = ceremonies.Take(CeremonyKind.UnlinkProof, attempt.ExpectedAccountId, attempt.Target, attempt.Proof);
|
||||||
|
var (identity, bearer) = await RedeemOwnerAsync(callbackBaseUrl, attempt, code, cancellationToken);
|
||||||
|
if (fact is null)
|
||||||
|
{
|
||||||
|
return new HeimdallLinkStep(null, cookieName, null, HeimdallLinkOutcome.PasskeyExpired);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A passkey revoked since the assertion no longer proves anything: Heimdall is not asked.
|
||||||
|
if (!await passkeys.StillStrongAsync(identity.AccountId, fact.Data, cancellationToken))
|
||||||
|
{
|
||||||
|
return new HeimdallLinkStep(null, cookieName, null, HeimdallLinkOutcome.PasskeyFailed);
|
||||||
|
}
|
||||||
|
|
||||||
|
var asserted = await StepUpAsync(bearer, cancellationToken);
|
||||||
|
if (asserted.Refusal is { } refusal)
|
||||||
|
{
|
||||||
|
return new HeimdallLinkStep(null, cookieName, null, refusal);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The returned token is this request's alone: it goes to the one unlink and is dropped.
|
||||||
|
return new HeimdallLinkStep(null, cookieName, null, await UnlinkAsync(identity.AccountId, asserted.AccessToken!, attempt.Target, cancellationToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
throw new HeimdallAuthException("Heimdall completion is not a link attempt.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Heimdall's unlink with a bearer that carries fresh proof, and what the list becomes. Only Heimdall's definitive refusals leave
|
||||||
|
/// the account as the list showed it. Any other end (not found, a lost answer, a 500, a malformed 200) may have applied the
|
||||||
|
/// unlink, so the list is dropped rather than trusted.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<HeimdallLinkOutcome> UnlinkAsync(string accountId, string bearer, string? identityId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var unlinked = await memberClient.UnlinkAsync(bearer, identityId ?? string.Empty, cancellationToken);
|
||||||
|
if (unlinked.Call == HeimdallMemberCall.Ok)
|
||||||
|
{
|
||||||
|
snapshots.Put(accountId, unlinked.Identities!);
|
||||||
|
}
|
||||||
|
else if (unlinked.Call is not (HeimdallMemberCall.SignInRequired or HeimdallMemberCall.StepUpRequired or HeimdallMemberCall.LastIdentity))
|
||||||
|
{
|
||||||
|
snapshots.Remove(accountId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return unlinked.Call == HeimdallMemberCall.Ok ? HeimdallLinkOutcome.Unlinked : OutcomeOf(unlinked.Call);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The method label Heimdall's bifrost profile admits for an app-asserted passkey proof.</summary>
|
||||||
|
public const string PasskeyStepUpMethod = "bifrost-passkey";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// POST /v1/apps/{app}/step-up (Heimdall docs/service-contract.md): the app secret, the member's live bearer and a fresh random
|
||||||
|
/// nonce; Heimdall answers a token for the same session that carries the proof. Called only from the completion that holds the
|
||||||
|
/// bearer, after a verified passkey assertion fact was spent. Each refusal is a closed outcome; nothing is retried here.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<(HeimdallLinkOutcome? Refusal, string? AccessToken)> StepUpAsync(string bearer, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var nonce = WebEncoders.Base64UrlEncode(RandomNumberGenerator.GetBytes(32));
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var message = new HttpRequestMessage(
|
||||||
|
HttpMethod.Post, $"/v1/apps/{Uri.EscapeDataString(heimdallOptions.Value.AppSlug)}/step-up")
|
||||||
|
{
|
||||||
|
Content = JsonContent.Create(new HeimdallStepUpRequest(PasskeyStepUpMethod, nonce))
|
||||||
|
};
|
||||||
|
message.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", bearer);
|
||||||
|
using var response = await httpClient.SendAsync(message, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
|
||||||
|
if (response.StatusCode == HttpStatusCode.Created)
|
||||||
|
{
|
||||||
|
var minted = await response.Content.ReadFromJsonAsync<HeimdallStepUpResponse>(cancellationToken);
|
||||||
|
return string.IsNullOrWhiteSpace(minted?.AccessToken) ? (HeimdallLinkOutcome.Error, null) : (null, minted.AccessToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
var error = await HeimdallRefusal.CodeAsync(response, cancellationToken);
|
||||||
|
return ((int)response.StatusCode, error) switch
|
||||||
|
{
|
||||||
|
(401, "sign_in_required") => (HeimdallLinkOutcome.SignInRequired, null),
|
||||||
|
(429, _) => (HeimdallLinkOutcome.StepUpRateLimited, null),
|
||||||
|
(409, "step_up_nonce_replayed") => (HeimdallLinkOutcome.StepUpReplayed, null),
|
||||||
|
(401 or 403 or 400, _) => (HeimdallLinkOutcome.StepUpUnavailable, null),
|
||||||
|
_ => (HeimdallLinkOutcome.Error, null)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (exception is HttpRequestException or JsonException or NotSupportedException or TaskCanceledException
|
||||||
|
&& !cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
return (HeimdallLinkOutcome.Error, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static HeimdallLinkOutcome OutcomeOf(HeimdallMemberCall call) => call switch
|
||||||
|
{
|
||||||
|
HeimdallMemberCall.SignInRequired => HeimdallLinkOutcome.SignInRequired,
|
||||||
|
HeimdallMemberCall.StepUpRequired => HeimdallLinkOutcome.StepUpRequired,
|
||||||
|
HeimdallMemberCall.IdentityNotFound => HeimdallLinkOutcome.IdentityNotFound,
|
||||||
|
HeimdallMemberCall.LastIdentity => HeimdallLinkOutcome.LastIdentity,
|
||||||
|
_ => HeimdallLinkOutcome.Error
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>Redeems a link attempt's code and requires it to prove the account the link was begun for.</summary>
|
||||||
|
private async Task<(HeimdallVerifiedIdentity Identity, string AccessToken)> RedeemOwnerAsync(
|
||||||
|
string callbackBaseUrl, HeimdallAttempt attempt, string? code, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var (identity, bearer, _) = await RedeemOwnerWithBodyAsync(callbackBaseUrl, attempt, code, cancellationToken);
|
||||||
|
return (identity, bearer);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<(HeimdallVerifiedIdentity Identity, string AccessToken, HeimdallRedeemResponse Redeemed)> RedeemOwnerWithBodyAsync(
|
||||||
|
string callbackBaseUrl, HeimdallAttempt attempt, string? code, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var proven = await RedeemVerifiedAsync(callbackBaseUrl, attempt, code, cancellationToken);
|
||||||
|
if (!string.Equals(proven.Identity.AccountId, attempt.ExpectedAccountId, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
throw new HeimdallAuthException("Heimdall link was proven by another account.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return proven;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Starts the link to the attempt's target provider with the member's bearer. Google attaches in two phases (the
|
||||||
|
/// callback writes a pending link, a second fresh sign-in confirms it); the other admitted providers attach in the callback.
|
||||||
|
/// A cookie written before the target existed names no provider and is a Google link.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<(Uri Url, string CookieValue)> StartLinkProviderAsync(
|
||||||
|
string callbackBaseUrl, HeimdallAttempt attempt, string bearer, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var target = attempt.Target ?? "google";
|
||||||
|
if (!LinkableProviders.Contains(target))
|
||||||
|
{
|
||||||
|
throw new HeimdallAuthException("Unsupported provider to link.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var options = heimdallOptions.Value;
|
||||||
|
var nonce = WebEncoders.Base64UrlEncode(RandomNumberGenerator.GetBytes(32));
|
||||||
|
using var message = new HttpRequestMessage(HttpMethod.Post, $"/v1/oauth/{target}/start")
|
||||||
|
{
|
||||||
|
Content = JsonContent.Create(new HeimdallStartRequest(
|
||||||
|
options.AppSlug, "link", ToAttemptReturnUrl(callbackBaseUrl, nonce), new HeimdallHandoff("browser_completion"), null, null))
|
||||||
|
};
|
||||||
|
message.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", bearer);
|
||||||
|
using var response = await SendStartAsync(message, cancellationToken);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
throw new HeimdallAuthException(await RefusedStartMessageAsync(response, cancellationToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload = await response.Content.ReadFromJsonAsync<HeimdallStartResponse>(cancellationToken);
|
||||||
|
if (payload is null || string.IsNullOrWhiteSpace(payload.AuthorizationUrl))
|
||||||
|
{
|
||||||
|
throw new HeimdallAuthException("Heimdall link start returned no authorization URL.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var awaiting = new HeimdallAttempt(
|
||||||
|
nonce,
|
||||||
|
attempt.Provider,
|
||||||
|
false,
|
||||||
|
attempt.ReturnPath,
|
||||||
|
timeProvider.GetUtcNow() + HeimdallAttemptCookie.Lifetime,
|
||||||
|
target == "google" ? HeimdallAttemptPurpose.LinkAwaitGoogle : HeimdallAttemptPurpose.LinkAwaitDirect,
|
||||||
|
attempt.ExpectedAccountId,
|
||||||
|
null,
|
||||||
|
target);
|
||||||
|
return (new Uri(payload.AuthorizationUrl, UriKind.Absolute), Protect(awaiting));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<HeimdallLinkOutcome> ConfirmLinkAsync(string pendingLinkId, string bearer, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var message = new HttpRequestMessage(
|
||||||
|
HttpMethod.Post, $"/v1/apps/{Uri.EscapeDataString(heimdallOptions.Value.AppSlug)}/links/confirm")
|
||||||
|
{
|
||||||
|
Content = JsonContent.Create(new HeimdallConfirmLinkRequest(pendingLinkId))
|
||||||
|
};
|
||||||
|
message.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", bearer);
|
||||||
|
using var response = await httpClient.SendAsync(message, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
|
||||||
|
switch (response.StatusCode)
|
||||||
|
{
|
||||||
|
case HttpStatusCode.Created:
|
||||||
|
var linked = await response.Content.ReadFromJsonAsync<HeimdallConfirmLinkResponse>(cancellationToken);
|
||||||
|
return linked?.Status == "linked" ? HeimdallLinkOutcome.Linked : HeimdallLinkOutcome.Error;
|
||||||
|
case HttpStatusCode.Conflict:
|
||||||
|
return HeimdallLinkOutcome.IdentityLinkedElsewhere;
|
||||||
|
case HttpStatusCode.Gone:
|
||||||
|
return HeimdallLinkOutcome.Expired;
|
||||||
|
default:
|
||||||
|
return HeimdallLinkOutcome.Error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception error) when (error is HttpRequestException or JsonException or NotSupportedException && !cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
return HeimdallLinkOutcome.Error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<HeimdallRedeemResponse> RedeemAsync(string code, CancellationToken cancellationToken)
|
private async Task<HeimdallRedeemResponse> RedeemAsync(string code, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
|
@ -221,7 +740,8 @@ public sealed class HeimdallAuthService(
|
||||||
|
|
||||||
private string Protect(HeimdallAttempt attempt) =>
|
private string Protect(HeimdallAttempt attempt) =>
|
||||||
_protector.Protect(JsonSerializer.Serialize(new AttemptWire(
|
_protector.Protect(JsonSerializer.Serialize(new AttemptWire(
|
||||||
attempt.Nonce, attempt.Provider, attempt.RequiresMemberAccess, attempt.ReturnPath.Value, attempt.ExpiresAt)));
|
attempt.Nonce, attempt.Provider, attempt.RequiresMemberAccess, attempt.ReturnPath.Value, attempt.ExpiresAt,
|
||||||
|
attempt.Purpose, attempt.ExpectedAccountId, attempt.PendingLinkId, attempt.Target, attempt.Proof)));
|
||||||
|
|
||||||
/// <summary>The live attempt a cookie value protects when it belongs to this nonce, else null.</summary>
|
/// <summary>The live attempt a cookie value protects when it belongs to this nonce, else null.</summary>
|
||||||
private HeimdallAttempt? Unprotect(string? cookieValue, string? nonce) =>
|
private HeimdallAttempt? Unprotect(string? cookieValue, string? nonce) =>
|
||||||
|
|
@ -238,13 +758,14 @@ public sealed class HeimdallAuthService(
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var wire = JsonSerializer.Deserialize<AttemptWire>(_protector.Unprotect(cookieValue));
|
var wire = JsonSerializer.Deserialize<AttemptWire>(_protector.Unprotect(cookieValue));
|
||||||
if (wire is null || wire.ExpiresAt <= timeProvider.GetUtcNow())
|
if (wire is null || wire.ExpiresAt <= timeProvider.GetUtcNow() || !Enum.IsDefined(wire.Purpose))
|
||||||
{
|
{
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return new HeimdallAttempt(
|
return new HeimdallAttempt(
|
||||||
wire.Nonce, wire.Provider, wire.RequiresMemberAccess, LocalReturnPath.Parse(wire.ReturnPath), wire.ExpiresAt);
|
wire.Nonce, wire.Provider, wire.RequiresMemberAccess, LocalReturnPath.Parse(wire.ReturnPath), wire.ExpiresAt,
|
||||||
|
wire.Purpose, wire.ExpectedAccountId, wire.PendingLinkId, wire.Target, wire.Proof);
|
||||||
}
|
}
|
||||||
catch (Exception error) when (error is CryptographicException or JsonException or FormatException)
|
catch (Exception error) when (error is CryptographicException or JsonException or FormatException)
|
||||||
{
|
{
|
||||||
|
|
@ -257,7 +778,12 @@ public sealed class HeimdallAuthService(
|
||||||
string Provider,
|
string Provider,
|
||||||
bool RequiresMemberAccess,
|
bool RequiresMemberAccess,
|
||||||
string ReturnPath,
|
string ReturnPath,
|
||||||
DateTimeOffset ExpiresAt);
|
DateTimeOffset ExpiresAt,
|
||||||
|
HeimdallAttemptPurpose Purpose = HeimdallAttemptPurpose.SignIn,
|
||||||
|
string? ExpectedAccountId = null,
|
||||||
|
string? PendingLinkId = null,
|
||||||
|
string? Target = null,
|
||||||
|
string? Proof = null);
|
||||||
|
|
||||||
private async Task<UserAccount> UpsertUserAccountAsync(
|
private async Task<UserAccount> UpsertUserAccountAsync(
|
||||||
HeimdallVerifiedIdentity identity,
|
HeimdallVerifiedIdentity identity,
|
||||||
|
|
@ -409,7 +935,150 @@ public sealed record HeimdallAttempt(
|
||||||
string Provider,
|
string Provider,
|
||||||
bool RequiresMemberAccess,
|
bool RequiresMemberAccess,
|
||||||
LocalReturnPath ReturnPath,
|
LocalReturnPath ReturnPath,
|
||||||
DateTimeOffset ExpiresAt);
|
DateTimeOffset ExpiresAt,
|
||||||
|
HeimdallAttemptPurpose Purpose = HeimdallAttemptPurpose.SignIn,
|
||||||
|
string? ExpectedAccountId = null,
|
||||||
|
string? PendingLinkId = null,
|
||||||
|
string? Target = null,
|
||||||
|
string? Proof = null);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// What a held attempt is for. SignIn is the default, so a cookie written before purposes existed reads as a sign-in.
|
||||||
|
/// The other purposes belong to the Linked accounts page: the fresh sign-ins that prove an account for a list, a link or
|
||||||
|
/// an unlink, and the provider round trips of a link. They never sign anyone in. Values are persisted in cookies: append only.
|
||||||
|
/// </summary>
|
||||||
|
public enum HeimdallAttemptPurpose
|
||||||
|
{
|
||||||
|
SignIn = 0,
|
||||||
|
LinkStart = 1,
|
||||||
|
LinkAwaitGoogle = 2,
|
||||||
|
LinkConfirm = 3,
|
||||||
|
ListProof = 4,
|
||||||
|
UnlinkProof = 5,
|
||||||
|
LinkAwaitDirect = 6,
|
||||||
|
EnrolProof = 7,
|
||||||
|
UnlinkPasskeyProof = 8
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>How a Linked accounts action ended. <see cref="HeimdallLinkOutcomes.Code"/> is its closed wire form.</summary>
|
||||||
|
public enum HeimdallLinkOutcome
|
||||||
|
{
|
||||||
|
Linked,
|
||||||
|
IdentityLinkedElsewhere,
|
||||||
|
Expired,
|
||||||
|
Error,
|
||||||
|
Listed,
|
||||||
|
Unlinked,
|
||||||
|
LastIdentity,
|
||||||
|
StepUpRequired,
|
||||||
|
IdentityNotFound,
|
||||||
|
SignInRequired,
|
||||||
|
ProofUnavailable,
|
||||||
|
ListExpired,
|
||||||
|
ProviderNeeded,
|
||||||
|
EnrolReady,
|
||||||
|
EnrolNeedsSignIn,
|
||||||
|
PasskeyAdded,
|
||||||
|
PasskeyRevoked,
|
||||||
|
PasskeyLimit,
|
||||||
|
PasskeyExpired,
|
||||||
|
PasskeyFailed,
|
||||||
|
PasskeyRequired,
|
||||||
|
PasskeysUnavailable,
|
||||||
|
RateLimited,
|
||||||
|
StepUpUnavailable,
|
||||||
|
StepUpReplayed,
|
||||||
|
StepUpRateLimited
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Where a Linked accounts step sends the browser: to <see cref="HeimdallLinkStep.Next"/> with a new attempt cookie in the held
|
||||||
|
/// slot, or, when the action is finished, back to the page with the outcome's code in <c>?result=</c>.
|
||||||
|
/// </summary>
|
||||||
|
public static class HeimdallLinkOutcomes
|
||||||
|
{
|
||||||
|
public const string ResultParameter = "result";
|
||||||
|
|
||||||
|
public static string Redirect(HeimdallLinkOutcome outcome) =>
|
||||||
|
HeimdallAuthService.LinkReturnPath + "?" + ResultParameter + "=" + Code(outcome);
|
||||||
|
|
||||||
|
public static string Code(HeimdallLinkOutcome outcome) => outcome switch
|
||||||
|
{
|
||||||
|
HeimdallLinkOutcome.Linked => "linked",
|
||||||
|
HeimdallLinkOutcome.IdentityLinkedElsewhere => "identity_linked_elsewhere",
|
||||||
|
HeimdallLinkOutcome.Expired => "expired",
|
||||||
|
HeimdallLinkOutcome.Listed => "listed",
|
||||||
|
HeimdallLinkOutcome.Unlinked => "unlinked",
|
||||||
|
HeimdallLinkOutcome.LastIdentity => "last_identity",
|
||||||
|
HeimdallLinkOutcome.StepUpRequired => "step_up_required",
|
||||||
|
HeimdallLinkOutcome.IdentityNotFound => "identity_not_found",
|
||||||
|
HeimdallLinkOutcome.SignInRequired => "sign_in_required",
|
||||||
|
HeimdallLinkOutcome.ProofUnavailable => "proof_unavailable",
|
||||||
|
HeimdallLinkOutcome.ListExpired => "list_expired",
|
||||||
|
HeimdallLinkOutcome.ProviderNeeded => "provider_needed",
|
||||||
|
HeimdallLinkOutcome.EnrolReady => "enrol_ready",
|
||||||
|
HeimdallLinkOutcome.EnrolNeedsSignIn => "enrol_needs_sign_in",
|
||||||
|
HeimdallLinkOutcome.PasskeyAdded => "passkey_added",
|
||||||
|
HeimdallLinkOutcome.PasskeyRevoked => "passkey_revoked",
|
||||||
|
HeimdallLinkOutcome.PasskeyLimit => "passkey_limit",
|
||||||
|
HeimdallLinkOutcome.PasskeyExpired => "passkey_expired",
|
||||||
|
HeimdallLinkOutcome.PasskeyFailed => "passkey_failed",
|
||||||
|
HeimdallLinkOutcome.PasskeyRequired => "passkey_required",
|
||||||
|
HeimdallLinkOutcome.PasskeysUnavailable => "passkeys_unavailable",
|
||||||
|
HeimdallLinkOutcome.RateLimited => "rate_limited",
|
||||||
|
HeimdallLinkOutcome.StepUpUnavailable => "step_up_unavailable",
|
||||||
|
HeimdallLinkOutcome.StepUpReplayed => "step_up_replayed",
|
||||||
|
HeimdallLinkOutcome.StepUpRateLimited => "step_up_rate_limited",
|
||||||
|
_ => "error"
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>The words for an outcome code: a closed set, never Heimdall's text and never the query's. Anything else gets none.</summary>
|
||||||
|
public static string? Wording(string? code) => code switch
|
||||||
|
{
|
||||||
|
"linked" => "The login is now linked to your Heimdall account.",
|
||||||
|
"identity_linked_elsewhere" => "That login belongs to another account. Nothing was linked.",
|
||||||
|
"expired" => "The link expired before it was confirmed. Nothing was linked; start the link again.",
|
||||||
|
"listed" => null,
|
||||||
|
"unlinked" => "The login was unlinked.",
|
||||||
|
"last_identity" => "That is the last login on your account, and Heimdall will not remove it. Nothing was changed.",
|
||||||
|
"step_up_required" => "Heimdall did not accept the sign-in as fresh proof. Nothing was changed; try again.",
|
||||||
|
"identity_not_found" => "That login is no longer on your account. Nothing was changed; the list below is read again when you ask.",
|
||||||
|
"sign_in_required" => "Heimdall no longer accepts that sign-in. Nothing was changed; try again.",
|
||||||
|
"proof_unavailable" => "Fresh proof could not be started, so nothing was changed. Unlinking needs a fresh Google sign-in, and it is not available right now.",
|
||||||
|
"provider_needed" => "Bifrost does not know which logins this account holds right now, so it will not guess. Pick one you know it holds to read the list. Nothing was changed.",
|
||||||
|
"enrol_ready" => "You signed in again. Add the passkey below within five minutes.",
|
||||||
|
"enrol_needs_sign_in" => "Adding a passkey needs a fresh sign-in first, and none is current. Start again with \"Sign in again to add a passkey\". Nothing was added.",
|
||||||
|
"passkey_added" => "The passkey was added. It counts as strong proof after a 48 hour cooling-off; until then it cannot unlink anything.",
|
||||||
|
"passkey_revoked" => "The passkey was revoked.",
|
||||||
|
"passkey_limit" => "This account already has the most passkeys it can hold. Revoke one to add another.",
|
||||||
|
"passkey_expired" => "The passkey check expired or was already used. Nothing was changed; start the unlink again.",
|
||||||
|
"passkey_failed" => "The passkey could not be verified. Nothing was changed.",
|
||||||
|
"passkey_required" => "This account has a passkey, so unlinking needs it. Nothing was changed.",
|
||||||
|
"passkeys_unavailable" => "Passkeys are not available on this server right now. Nothing was changed.",
|
||||||
|
"rate_limited" => "Too many passkey attempts. Nothing was changed; wait a few minutes.",
|
||||||
|
"step_up_unavailable" => "Your passkey was verified, but Heimdall would not take Bifrost's proof. Nothing was changed.",
|
||||||
|
"step_up_replayed" => "Heimdall had already seen that proof. Nothing was changed; start the unlink again.",
|
||||||
|
"step_up_rate_limited" => "Heimdall is limiting proofs for now. Nothing was changed; wait a few minutes and start the unlink again.",
|
||||||
|
"list_expired" => "The list was out of date. Nothing was changed; read your linked logins again.",
|
||||||
|
"error" => "That could not be completed. Nothing was changed.",
|
||||||
|
_ => null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record HeimdallLinkStep(Uri? Next, string CookieName, string? CookieValue, HeimdallLinkOutcome? Outcome, string? EnrolTicket = null);
|
||||||
|
|
||||||
|
public sealed record HeimdallConfirmLinkRequest(
|
||||||
|
[property: JsonPropertyName("pendingLinkId")] string PendingLinkId);
|
||||||
|
|
||||||
|
public sealed record HeimdallStepUpRequest(
|
||||||
|
[property: JsonPropertyName("method")] string Method,
|
||||||
|
[property: JsonPropertyName("nonce")] string Nonce);
|
||||||
|
|
||||||
|
public sealed record HeimdallStepUpResponse(
|
||||||
|
[property: JsonPropertyName("accessToken")] string? AccessToken);
|
||||||
|
|
||||||
|
public sealed record HeimdallConfirmLinkResponse(
|
||||||
|
[property: JsonPropertyName("status")] string? Status);
|
||||||
|
|
||||||
public sealed record HeimdallStartRequest(
|
public sealed record HeimdallStartRequest(
|
||||||
[property: JsonPropertyName("appSlug")] string AppSlug,
|
[property: JsonPropertyName("appSlug")] string AppSlug,
|
||||||
|
|
@ -417,7 +1086,9 @@ public sealed record HeimdallStartRequest(
|
||||||
[property: JsonPropertyName("returnTo")] string ReturnTo,
|
[property: JsonPropertyName("returnTo")] string ReturnTo,
|
||||||
[property: JsonPropertyName("handoff")] HeimdallHandoff Handoff,
|
[property: JsonPropertyName("handoff")] HeimdallHandoff Handoff,
|
||||||
[property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
[property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||||
[property: JsonPropertyName("entitlementPolicy")] HeimdallEntitlementPolicy? EntitlementPolicy);
|
[property: JsonPropertyName("entitlementPolicy")] HeimdallEntitlementPolicy? EntitlementPolicy,
|
||||||
|
[property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||||
|
[property: JsonPropertyName("stepUp")] bool? StepUp = null);
|
||||||
|
|
||||||
public sealed record HeimdallHandoff(
|
public sealed record HeimdallHandoff(
|
||||||
[property: JsonPropertyName("kind")] string Kind);
|
[property: JsonPropertyName("kind")] string Kind);
|
||||||
|
|
@ -440,7 +1111,10 @@ public sealed record HeimdallRedeemRequest(
|
||||||
public sealed record HeimdallRedeemResponse(
|
public sealed record HeimdallRedeemResponse(
|
||||||
[property: JsonPropertyName("returnTo")] string? ReturnTo,
|
[property: JsonPropertyName("returnTo")] string? ReturnTo,
|
||||||
[property: JsonPropertyName("accessToken")] string? AccessToken,
|
[property: JsonPropertyName("accessToken")] string? AccessToken,
|
||||||
[property: JsonPropertyName("account")] HeimdallAccount? Account);
|
[property: JsonPropertyName("account")] HeimdallAccount? Account,
|
||||||
|
[property: JsonPropertyName("mode")] string? Mode = null,
|
||||||
|
[property: JsonPropertyName("provider")] string? Provider = null,
|
||||||
|
[property: JsonPropertyName("stepUpRequested")] bool? StepUpRequested = null);
|
||||||
|
|
||||||
public sealed record HeimdallAccount(
|
public sealed record HeimdallAccount(
|
||||||
[property: JsonPropertyName("id")] string Id);
|
[property: JsonPropertyName("id")] string Id);
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,8 @@ namespace Bifrost.Web.Features.Heimdall;
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The page Heimdall's "Return to the app" lands on. The completion code arrives in the URL fragment, which
|
/// The page Heimdall's "Return to the app" lands on. The completion code arrives in the URL fragment, which
|
||||||
/// the server never sees, so the page's script reads it, drops it from the address bar, and posts it with
|
/// the server never sees, so the page's script reads it, drops it from the address bar, and posts it with
|
||||||
/// the attempt to the same path. The page is static: it never contains a code.
|
/// the attempt to the same path. A Google link returns a pending link id the same way instead of a code. The page
|
||||||
|
/// is static: it never contains a code or an id.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class HeimdallCompletePage
|
public static class HeimdallCompletePage
|
||||||
{
|
{
|
||||||
|
|
@ -22,21 +23,30 @@ public static class HeimdallCompletePage
|
||||||
<form id="complete" method="post" action="/auth/heimdall/complete">
|
<form id="complete" method="post" action="/auth/heimdall/complete">
|
||||||
<input type="hidden" name="attempt" />
|
<input type="hidden" name="attempt" />
|
||||||
<input type="hidden" name="code" />
|
<input type="hidden" name="code" />
|
||||||
|
<input type="hidden" name="pendingLinkId" />
|
||||||
</form>
|
</form>
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
var fragment = new URLSearchParams(location.hash.slice(1));
|
var fragment = new URLSearchParams(location.hash.slice(1));
|
||||||
var attempt = new URLSearchParams(location.search).get("attempt");
|
var attempt = new URLSearchParams(location.search).get("attempt");
|
||||||
var code = fragment.get("heimdall_completion_code");
|
var code = fragment.get("heimdall_completion_code");
|
||||||
|
var pendingLinkId = fragment.get("heimdall_pending_link_id");
|
||||||
var failed = fragment.get("heimdall_status") === "error";
|
var failed = fragment.get("heimdall_status") === "error";
|
||||||
history.replaceState(null, "", location.pathname + location.search);
|
history.replaceState(null, "", location.pathname + location.search);
|
||||||
if (failed || !code || !attempt) {
|
// A provider other than Google attaches in Heimdall's callback, which refuses an identity another account
|
||||||
|
// holds. Only this one refusal is carried on, as a fixed result the page words itself; nothing from the fragment is shown.
|
||||||
|
if (failed && attempt && fragment.get("heimdall_error") === "link_initiator_mismatch") {
|
||||||
|
location.replace("/Membership/LinkedAccounts?result=identity_linked_elsewhere");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (failed || !(code || pendingLinkId) || !attempt) {
|
||||||
location.replace("/?auth=heimdall-error");
|
location.replace("/?auth=heimdall-error");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
var form = document.getElementById("complete");
|
var form = document.getElementById("complete");
|
||||||
form.elements.attempt.value = attempt;
|
form.elements.attempt.value = attempt;
|
||||||
form.elements.code.value = code;
|
form.elements.code.value = code || "";
|
||||||
|
form.elements.pendingLinkId.value = pendingLinkId || "";
|
||||||
form.submit();
|
form.submit();
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
112
src/Bifrost.Web/Features/Heimdall/HeimdallMemberClient.cs
Normal file
112
src/Bifrost.Web/Features/Heimdall/HeimdallMemberClient.cs
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Bifrost.Web.Configuration;
|
||||||
|
|
||||||
|
namespace Bifrost.Web.Features.Heimdall;
|
||||||
|
|
||||||
|
/// <summary>One login a Heimdall account holds, as the Linked accounts page shows it. No provider user id, scope or email.</summary>
|
||||||
|
public sealed record LinkedIdentitySummary(string Id, string Provider, string? DisplayName, DateTimeOffset? LinkedAt);
|
||||||
|
|
||||||
|
public enum HeimdallMemberCall
|
||||||
|
{
|
||||||
|
Ok,
|
||||||
|
SignInRequired,
|
||||||
|
StepUpRequired,
|
||||||
|
IdentityNotFound,
|
||||||
|
LastIdentity,
|
||||||
|
Error
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record HeimdallMemberResult(HeimdallMemberCall Call, IReadOnlyList<LinkedIdentitySummary>? Identities);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Heimdall's member routes (docs/service-contract.md: GET /v1/me/identities, POST /v1/me/identities/{id}/unlink). Each
|
||||||
|
/// takes the member's bearer as an argument, uses it for that one request and keeps nothing. Its HttpClient carries no
|
||||||
|
/// app secret: these routes act only for a member who is present, so a Bifrost fault cannot reach them without one.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class HeimdallMemberClient(HttpClient httpClient)
|
||||||
|
{
|
||||||
|
private const int MaxIdentities = 32;
|
||||||
|
private const int MaxDisplayName = 80;
|
||||||
|
|
||||||
|
public static void ConfigureClient(HttpClient client, HeimdallOptions options) =>
|
||||||
|
client.BaseAddress = new Uri(options.BaseUrl.TrimEnd('/'));
|
||||||
|
|
||||||
|
public Task<HeimdallMemberResult> ListAsync(string bearer, CancellationToken cancellationToken) =>
|
||||||
|
SendAsync(HttpMethod.Get, "/v1/me/identities", bearer, cancellationToken);
|
||||||
|
|
||||||
|
public Task<HeimdallMemberResult> UnlinkAsync(string bearer, string identityId, CancellationToken cancellationToken) =>
|
||||||
|
SendAsync(HttpMethod.Post, $"/v1/me/identities/{Uri.EscapeDataString(identityId)}/unlink", bearer, cancellationToken);
|
||||||
|
|
||||||
|
private async Task<HeimdallMemberResult> SendAsync(HttpMethod method, string path, string bearer, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var message = new HttpRequestMessage(method, path);
|
||||||
|
message.Headers.Authorization = new AuthenticationHeaderValue("Bearer", bearer);
|
||||||
|
using var response = await httpClient.SendAsync(message, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
|
||||||
|
if (response.StatusCode == HttpStatusCode.OK)
|
||||||
|
{
|
||||||
|
return new HeimdallMemberResult(HeimdallMemberCall.Ok, await ReadIdentitiesAsync(response, cancellationToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
var code = await HeimdallRefusal.CodeAsync(response, cancellationToken);
|
||||||
|
var call = (response.StatusCode, code) switch
|
||||||
|
{
|
||||||
|
(HttpStatusCode.Unauthorized, _) => HeimdallMemberCall.SignInRequired,
|
||||||
|
(HttpStatusCode.Forbidden, "step_up_required") => HeimdallMemberCall.StepUpRequired,
|
||||||
|
(HttpStatusCode.NotFound, "identity_not_found") => HeimdallMemberCall.IdentityNotFound,
|
||||||
|
(HttpStatusCode.Conflict, "last_identity") => HeimdallMemberCall.LastIdentity,
|
||||||
|
_ => HeimdallMemberCall.Error
|
||||||
|
};
|
||||||
|
return new HeimdallMemberResult(call, null);
|
||||||
|
}
|
||||||
|
catch (Exception error) when (error is HttpRequestException or JsonException or NotSupportedException or TaskCanceledException
|
||||||
|
&& !cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
return new HeimdallMemberResult(HeimdallMemberCall.Error, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The identities array of a 200 answer, keeping only the fields the page shows; a malformed entry is dropped.</summary>
|
||||||
|
private static async Task<IReadOnlyList<LinkedIdentitySummary>> ReadIdentitiesAsync(HttpResponseMessage response, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
using var body = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync(cancellationToken), cancellationToken: cancellationToken);
|
||||||
|
var list = new List<LinkedIdentitySummary>();
|
||||||
|
if (body.RootElement.ValueKind != JsonValueKind.Object
|
||||||
|
|| !body.RootElement.TryGetProperty("identities", out var identities)
|
||||||
|
|| identities.ValueKind != JsonValueKind.Array)
|
||||||
|
{
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var item in identities.EnumerateArray().Take(MaxIdentities))
|
||||||
|
{
|
||||||
|
var id = Text(item, "id");
|
||||||
|
var provider = Text(item, "provider");
|
||||||
|
if (!Guid.TryParse(id, out _)
|
||||||
|
|| provider is not { Length: > 0 and <= 32 }
|
||||||
|
|| !provider.All(c => c is (>= 'a' and <= 'z') or (>= '0' and <= '9') or '_'))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var name = Text(item, "displayName") ?? Text(item, "username");
|
||||||
|
if (name is { Length: > MaxDisplayName })
|
||||||
|
{
|
||||||
|
name = name[..MaxDisplayName];
|
||||||
|
}
|
||||||
|
|
||||||
|
DateTimeOffset? linkedAt = DateTimeOffset.TryParse(Text(item, "linkedAt"), out var parsed) ? parsed : null;
|
||||||
|
list.Add(new LinkedIdentitySummary(id!, provider, name, linkedAt));
|
||||||
|
}
|
||||||
|
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? Text(JsonElement item, string name) =>
|
||||||
|
item.ValueKind == JsonValueKind.Object && item.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.String
|
||||||
|
? value.GetString()
|
||||||
|
: null;
|
||||||
|
}
|
||||||
42
src/Bifrost.Web/Features/Heimdall/HeimdallRefusal.cs
Normal file
42
src/Bifrost.Web/Features/Heimdall/HeimdallRefusal.cs
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace Bifrost.Web.Features.Heimdall;
|
||||||
|
|
||||||
|
/// <summary>Reads the one thing Bifrost takes from a Heimdall refusal body: its error code, and only if it is a safe token.</summary>
|
||||||
|
public static class HeimdallRefusal
|
||||||
|
{
|
||||||
|
private const int BodyLimit = 4096;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The error code in the first 4 KiB of a body when it is a short snake_case token, else null. A body that cannot be
|
||||||
|
/// read or parsed yields null: a refusal never becomes a 500, and free text or tokens never reach a log or a page.
|
||||||
|
/// </summary>
|
||||||
|
public static async Task<string?> CodeAsync(HttpResponseMessage response, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
|
||||||
|
var head = new byte[BodyLimit];
|
||||||
|
var length = 0;
|
||||||
|
while (length < head.Length && await stream.ReadAsync(head.AsMemory(length), cancellationToken) is > 0 and var read)
|
||||||
|
{
|
||||||
|
length += read;
|
||||||
|
}
|
||||||
|
|
||||||
|
using var body = JsonDocument.Parse(head.AsMemory(0, length));
|
||||||
|
if (body.RootElement.ValueKind == JsonValueKind.Object
|
||||||
|
&& body.RootElement.TryGetProperty("error", out var error)
|
||||||
|
&& error.ValueKind == JsonValueKind.String
|
||||||
|
&& error.GetString() is { Length: > 0 and <= 64 } code
|
||||||
|
&& code.All(c => c is (>= 'a' and <= 'z') or (>= '0' and <= '9') or '_'))
|
||||||
|
{
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception) when (!cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
35
src/Bifrost.Web/Features/Heimdall/LinkedAccountsSnapshots.cs
Normal file
35
src/Bifrost.Web/Features/Heimdall/LinkedAccountsSnapshots.cs
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
using System.Collections.Concurrent;
|
||||||
|
|
||||||
|
namespace Bifrost.Web.Features.Heimdall;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// What the Linked accounts page last read from Heimdall for an account: id, provider, display name and link date of
|
||||||
|
/// each login, nothing else, for <see cref="Lifetime"/>. It lives in this process only, keyed by the Heimdall account id,
|
||||||
|
/// and is never given to the browser; it holds no bearer. The page renders from it because the list is fetched inside a
|
||||||
|
/// completion request, which redirects. After a restart it is empty and the page offers to read the list again.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class LinkedAccountsSnapshots(TimeProvider timeProvider)
|
||||||
|
{
|
||||||
|
public static readonly TimeSpan Lifetime = TimeSpan.FromMinutes(5);
|
||||||
|
|
||||||
|
private readonly ConcurrentDictionary<string, (IReadOnlyList<LinkedIdentitySummary> Identities, DateTimeOffset ExpiresAt)> _held = new(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
public void Put(string accountId, IReadOnlyList<LinkedIdentitySummary> identities)
|
||||||
|
{
|
||||||
|
var now = timeProvider.GetUtcNow();
|
||||||
|
foreach (var (key, value) in _held)
|
||||||
|
{
|
||||||
|
if (value.ExpiresAt <= now)
|
||||||
|
{
|
||||||
|
_held.TryRemove(key, out _);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_held[accountId] = (identities, now + Lifetime);
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyList<LinkedIdentitySummary>? Get(string accountId) =>
|
||||||
|
_held.TryGetValue(accountId, out var value) && value.ExpiresAt > timeProvider.GetUtcNow() ? value.Identities : null;
|
||||||
|
|
||||||
|
public void Remove(string accountId) => _held.TryRemove(accountId, out _);
|
||||||
|
}
|
||||||
139
src/Bifrost.Web/Features/Passkeys/PasskeyCeremonies.cs
Normal file
139
src/Bifrost.Web/Features/Passkeys/PasskeyCeremonies.cs
Normal file
|
|
@ -0,0 +1,139 @@
|
||||||
|
using Bifrost.Web.Features.Shared;
|
||||||
|
|
||||||
|
namespace Bifrost.Web.Features.Passkeys;
|
||||||
|
|
||||||
|
public enum CeremonyKind
|
||||||
|
{
|
||||||
|
/// <summary>Permission to enrol one passkey, earned by a fresh Heimdall sign-in. Held by id in a cookie of the browser that signed in.</summary>
|
||||||
|
EnrolTicket,
|
||||||
|
/// <summary>The WebAuthn creation options in flight for an account.</summary>
|
||||||
|
EnrolChallenge,
|
||||||
|
/// <summary>The WebAuthn request options in flight for an account's unlink of one identity.</summary>
|
||||||
|
UnlinkChallenge,
|
||||||
|
/// <summary>The fact "this account's passkey assertion was verified for unlinking this identity". Carries no bearer and no credential.</summary>
|
||||||
|
UnlinkProof
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The short-lived single-use server-side state of the passkey flows, in this process only (like the Linked accounts list):
|
||||||
|
/// a restart empties it and every flow is simply begun again. Every entry is bound to an account and a kind, and the unlink
|
||||||
|
/// kinds to the identity they are for; <c>Take</c> removes the entry whether or not the caller then succeeds, so a
|
||||||
|
/// challenge, a ticket or an assertion fact can be spent once. A challenge kind has one live entry per account: a newer one
|
||||||
|
/// replaces the older. A full table refuses new entries rather than grow.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class PasskeyCeremonies(TimeProvider clock)
|
||||||
|
{
|
||||||
|
public static readonly TimeSpan TicketLifetime = TimeSpan.FromMinutes(5);
|
||||||
|
public static readonly TimeSpan ChallengeLifetime = TimeSpan.FromMinutes(2);
|
||||||
|
public static readonly TimeSpan ProofLifetime = TimeSpan.FromMinutes(5);
|
||||||
|
public const int MaxLive = 2048;
|
||||||
|
|
||||||
|
public sealed record Entry(string Id, CeremonyKind Kind, string Account, string? Subject, string? Data, DateTimeOffset ExpiresAt);
|
||||||
|
|
||||||
|
private readonly Dictionary<string, Entry> _held = new(StringComparer.Ordinal);
|
||||||
|
private readonly object _gate = new();
|
||||||
|
|
||||||
|
/// <summary>Adds an entry and returns its id, or null when the table is full.</summary>
|
||||||
|
public string? Issue(CeremonyKind kind, string account, string? subject, string? data, TimeSpan lifetime)
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
Purge();
|
||||||
|
if (kind != CeremonyKind.UnlinkProof)
|
||||||
|
{
|
||||||
|
foreach (var old in _held.Values.Where(x => x.Kind == kind && x.Account == account).ToList())
|
||||||
|
{
|
||||||
|
_held.Remove(old.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_held.Count >= MaxLive)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var id = Convert.ToBase64String(System.Security.Cryptography.RandomNumberGenerator.GetBytes(32))
|
||||||
|
.Replace('+', '-').Replace('/', '_').TrimEnd('=');
|
||||||
|
_held[id] = new Entry(id, kind, account, subject, data, clock.GetUtcNow() + lifetime);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The live entry, without spending it. <paramref name="id"/> null finds the account's one current entry of that kind.</summary>
|
||||||
|
public Entry? Peek(CeremonyKind kind, string account, string? subject, string? id)
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
Purge();
|
||||||
|
return Find(kind, account, subject, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The live entry, removed: it can be taken once.</summary>
|
||||||
|
public Entry? Take(CeremonyKind kind, string account, string? subject, string? id)
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
Purge();
|
||||||
|
var entry = Find(kind, account, subject, id);
|
||||||
|
if (entry is not null)
|
||||||
|
{
|
||||||
|
_held.Remove(entry.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Discard(string? id)
|
||||||
|
{
|
||||||
|
if (id is not null)
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
_held.Remove(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Entry? Find(CeremonyKind kind, string account, string? subject, string? id)
|
||||||
|
{
|
||||||
|
var entry = id is null
|
||||||
|
? _held.Values.FirstOrDefault(x => x.Kind == kind && x.Account == account)
|
||||||
|
: _held.GetValueOrDefault(id);
|
||||||
|
return entry is not null && entry.Kind == kind
|
||||||
|
&& string.Equals(entry.Account, account, StringComparison.Ordinal)
|
||||||
|
&& string.Equals(entry.Subject, subject, StringComparison.Ordinal)
|
||||||
|
? entry
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Purge()
|
||||||
|
{
|
||||||
|
var now = clock.GetUtcNow();
|
||||||
|
foreach (var dead in _held.Values.Where(x => x.ExpiresAt <= now).ToList())
|
||||||
|
{
|
||||||
|
_held.Remove(dead.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The budgets on the passkey ceremonies, per Heimdall account, on the shared <see cref="FixedWindowBudget"/>. Two separate
|
||||||
|
/// ones: asking for options or starting an enrolment sign-in (anyone holding the session can do that), and verifying a
|
||||||
|
/// registration or an assertion, which is spent only after the caller holds what the verification needs (a live ticket, or one of
|
||||||
|
/// the account's own strong credentials and a live challenge). Junk from a session thief that names no credential of the account, or
|
||||||
|
/// arrives with no live challenge, spends nothing; junk naming one of the account's credentials spends one permit per challenge that
|
||||||
|
/// was asked for, because the first such post takes the challenge. A session holder can still exhaust both budgets by asking for
|
||||||
|
/// challenges over and over: a lockout the same session already has through revoking passkeys, not a bypass. Budgets are per process.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class PasskeyRateLimit(TimeProvider clock)
|
||||||
|
{
|
||||||
|
public const int Permits = 20;
|
||||||
|
public static readonly TimeSpan Window = TimeSpan.FromMinutes(10);
|
||||||
|
|
||||||
|
private readonly FixedWindowBudget _options = new(Permits, Window, 10_000, clock);
|
||||||
|
private readonly FixedWindowBudget _verifications = new(Permits, Window, 10_000, clock);
|
||||||
|
|
||||||
|
public bool TryAcquire(string account, bool verification = false) => (verification ? _verifications : _options).Spend(account) is null;
|
||||||
|
}
|
||||||
54
src/Bifrost.Web/Features/Passkeys/PasskeyRelyingParty.cs
Normal file
54
src/Bifrost.Web/Features/Passkeys/PasskeyRelyingParty.cs
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
using Bifrost.Web.Configuration;
|
||||||
|
using Fido2NetLib;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace Bifrost.Web.Features.Passkeys;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The relying party every passkey ceremony runs as. RP ID and origin come from <see cref="BifrostHostOptions"/> and from
|
||||||
|
/// nothing the browser sends: the public base URL must be https and its host must be the expected host, otherwise passkeys
|
||||||
|
/// are unavailable (the page offers none and every ceremony endpoint refuses). A passkey is bound to the RP ID by the
|
||||||
|
/// authenticator, so a wrong or http origin would enrol keys that cannot be used or can be phished.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class PasskeyRelyingParty
|
||||||
|
{
|
||||||
|
public const string Name = "Bifrost";
|
||||||
|
|
||||||
|
public PasskeyRelyingParty(IOptions<BifrostHostOptions> hostOptions, ILogger<PasskeyRelyingParty> logger)
|
||||||
|
{
|
||||||
|
var host = hostOptions.Value;
|
||||||
|
if (host.ExpectedHost.Contains(':') || System.Net.IPAddress.TryParse(host.ExpectedHost.Trim('[', ']'), out _))
|
||||||
|
{
|
||||||
|
logger.LogWarning("Passkeys are unavailable: Host:ExpectedHost must be a bare DNS host name, with no port and not an IP address (an IP address is not a valid RP ID).");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Uri.TryCreate(host.PublicBaseUrl, UriKind.Absolute, out var url)
|
||||||
|
|| url.Scheme != Uri.UriSchemeHttps
|
||||||
|
|| string.IsNullOrWhiteSpace(host.ExpectedHost)
|
||||||
|
|| !string.Equals(url.Host, host.ExpectedHost, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
logger.LogWarning("Passkeys are unavailable: Host:PublicBaseUrl must be https and its host must equal Host:ExpectedHost.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Origin = url.GetLeftPart(UriPartial.Authority);
|
||||||
|
RpId = url.Host.ToLowerInvariant();
|
||||||
|
Fido2 = new Fido2(new Fido2Configuration
|
||||||
|
{
|
||||||
|
RPID = RpId,
|
||||||
|
RPName = Name,
|
||||||
|
Origins = new HashSet<string>([Origin], StringComparer.Ordinal),
|
||||||
|
ChallengeSize = 32,
|
||||||
|
Timeout = 120_000
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsAvailable => Fido2 is not null;
|
||||||
|
|
||||||
|
public string? Origin { get; }
|
||||||
|
|
||||||
|
public string? RpId { get; }
|
||||||
|
|
||||||
|
public IFido2? Fido2 { get; }
|
||||||
|
}
|
||||||
470
src/Bifrost.Web/Features/Passkeys/PasskeyService.cs
Normal file
470
src/Bifrost.Web/Features/Passkeys/PasskeyService.cs
Normal file
|
|
@ -0,0 +1,470 @@
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Text;
|
||||||
|
using Bifrost.Web.Data;
|
||||||
|
using Bifrost.Web.Domain;
|
||||||
|
using Bifrost.Web.Features.Heimdall;
|
||||||
|
using Fido2NetLib;
|
||||||
|
using Fido2NetLib.Objects;
|
||||||
|
using Microsoft.AspNetCore.WebUtilities;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Bifrost.Web.Features.Passkeys;
|
||||||
|
|
||||||
|
/// <summary>One passkey as the Linked accounts page shows it: a label, its cooling-off state, never a key.</summary>
|
||||||
|
public sealed record PasskeyView(Guid Id, string Label, DateTimeOffset CreatedUtc, DateTimeOffset StrongFromUtc, DateTimeOffset? LastUsedUtc, bool IsStrong);
|
||||||
|
|
||||||
|
/// <summary>The base64url fields a browser posts after navigator.credentials.get (an assertion) or .create (an enrolment).</summary>
|
||||||
|
public sealed record PasskeyFields(
|
||||||
|
string? Id, string? RawId, string? AuthenticatorData, string? ClientDataJson, string? Signature, string? UserHandle,
|
||||||
|
string? AttestationObject, string? Transports);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Passkeys for accounts that have a Heimdall account id (docs/passkeys.md). Enrolment needs a ticket earned by a fresh
|
||||||
|
/// Heimdall sign-in and starts a credential in a 48 hour cooling-off; an assertion for an unlink needs a credential that is
|
||||||
|
/// the session's own, not revoked and past its cooling-off, and records a single-use fact for that account and identity.
|
||||||
|
/// The WebAuthn parsing and verification (CBOR, COSE, signatures, origin, RP ID, challenge, user verification, sign count)
|
||||||
|
/// is Fido2NetLib's; this class decides who may begin, what is bound to what, and what is kept. Outcomes are the closed set
|
||||||
|
/// of <see cref="HeimdallLinkOutcome"/> the page words itself.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class PasskeyService(
|
||||||
|
BifrostDbContext db,
|
||||||
|
PasskeyRelyingParty relyingParty,
|
||||||
|
PasskeyCeremonies ceremonies,
|
||||||
|
PasskeyRateLimit rateLimit,
|
||||||
|
TimeProvider clock,
|
||||||
|
ILogger<PasskeyService> logger)
|
||||||
|
{
|
||||||
|
public const int MaxActive = 5;
|
||||||
|
public const int MaxLabel = 40;
|
||||||
|
public static readonly TimeSpan CoolingOff = TimeSpan.FromHours(48);
|
||||||
|
|
||||||
|
private static readonly HashSet<string> TransportNames = new(StringComparer.Ordinal)
|
||||||
|
{
|
||||||
|
"usb", "nfc", "ble", "internal", "hybrid", "smart-card"
|
||||||
|
};
|
||||||
|
|
||||||
|
public bool IsAvailable => relyingParty.IsAvailable;
|
||||||
|
|
||||||
|
private Task<UserAccount?> AccountAsync(string heimdallAccountId, CancellationToken ct) =>
|
||||||
|
string.IsNullOrEmpty(heimdallAccountId)
|
||||||
|
? Task.FromResult<UserAccount?>(null)
|
||||||
|
: db.UserAccounts.SingleOrDefaultAsync(x => x.HeimdallAccountId == heimdallAccountId, ct);
|
||||||
|
|
||||||
|
private IQueryable<PasskeyCredential> Active(Guid userId) =>
|
||||||
|
db.PasskeyCredentials.Where(x => x.UserAccountId == userId && x.RevokedUtc == null);
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<PasskeyView>> ListAsync(string heimdallAccountId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (await AccountAsync(heimdallAccountId, ct) is not { } user)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
var now = clock.GetUtcNow();
|
||||||
|
var rows = await Active(user.Id).ToListAsync(ct);
|
||||||
|
return rows.OrderBy(x => x.CreatedUtc)
|
||||||
|
.Select(x => new PasskeyView(x.Id, x.Label, x.CreatedUtc, x.StrongFromUtc, x.LastUsedUtc, x.StrongFromUtc <= now))
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Whether a fresh enrolment ticket is held by this browser for this account.</summary>
|
||||||
|
public bool HasTicket(string heimdallAccountId, string? ticketId) =>
|
||||||
|
ticketId is not null && ceremonies.Peek(CeremonyKind.EnrolTicket, heimdallAccountId, null, ticketId) is not null;
|
||||||
|
|
||||||
|
/// <summary>Why a fresh sign-in for enrolment may not even start, or null when it may. Spends a ceremony permit.</summary>
|
||||||
|
public async Task<HeimdallLinkOutcome?> PreflightEnrolStartAsync(string heimdallAccountId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (!relyingParty.IsAvailable)
|
||||||
|
{
|
||||||
|
return HeimdallLinkOutcome.PasskeysUnavailable;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await AccountAsync(heimdallAccountId, ct) is not { } user)
|
||||||
|
{
|
||||||
|
return HeimdallLinkOutcome.Error;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await Active(user.Id).CountAsync(ct) >= MaxActive)
|
||||||
|
{
|
||||||
|
return HeimdallLinkOutcome.PasskeyLimit;
|
||||||
|
}
|
||||||
|
|
||||||
|
return rateLimit.TryAcquire(heimdallAccountId) ? null : HeimdallLinkOutcome.RateLimited;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The creation options for one enrolment, bound to the account; needs the ticket the fresh sign-in earned.</summary>
|
||||||
|
public async Task<(HeimdallLinkOutcome? Refusal, string? OptionsJson)> BeginEnrolAsync(string heimdallAccountId, string? ticketId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (!relyingParty.IsAvailable)
|
||||||
|
{
|
||||||
|
return (HeimdallLinkOutcome.PasskeysUnavailable, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!HasTicket(heimdallAccountId, ticketId) || await AccountAsync(heimdallAccountId, ct) is not { } user)
|
||||||
|
{
|
||||||
|
return (HeimdallLinkOutcome.EnrolNeedsSignIn, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!rateLimit.TryAcquire(heimdallAccountId))
|
||||||
|
{
|
||||||
|
return (HeimdallLinkOutcome.RateLimited, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
var existing = await Active(user.Id).Select(x => x.CredentialId).ToListAsync(ct);
|
||||||
|
if (existing.Count >= MaxActive)
|
||||||
|
{
|
||||||
|
return (HeimdallLinkOutcome.PasskeyLimit, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
var options = relyingParty.Fido2!.RequestNewCredential(new RequestNewCredentialParams
|
||||||
|
{
|
||||||
|
User = new Fido2User { Id = user.Id.ToByteArray(), Name = Label(user.DisplayName, "Bifrost member"), DisplayName = Label(user.DisplayName, "Bifrost member") },
|
||||||
|
ExcludeCredentials = existing.Select(x => new PublicKeyCredentialDescriptor(x)).ToList(),
|
||||||
|
AuthenticatorSelection = new AuthenticatorSelection
|
||||||
|
{
|
||||||
|
ResidentKey = ResidentKeyRequirement.Preferred,
|
||||||
|
UserVerification = UserVerificationRequirement.Required
|
||||||
|
},
|
||||||
|
AttestationPreference = AttestationConveyancePreference.None
|
||||||
|
});
|
||||||
|
var json = options.ToJson();
|
||||||
|
return ceremonies.Issue(CeremonyKind.EnrolChallenge, heimdallAccountId, null, json, PasskeyCeremonies.ChallengeLifetime) is null
|
||||||
|
? (HeimdallLinkOutcome.Error, null)
|
||||||
|
: (null, json);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies a registration and stores the credential, cooling off for <see cref="CoolingOff"/>. The ticket and the
|
||||||
|
/// challenge are both spent here, whatever the outcome: a failed attempt needs a fresh sign-in and a new challenge.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<HeimdallLinkOutcome> EnrolAsync(
|
||||||
|
string heimdallAccountId, string? ticketId, PasskeyFields fields, string? label, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (!relyingParty.IsAvailable)
|
||||||
|
{
|
||||||
|
return HeimdallLinkOutcome.PasskeysUnavailable;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The caller's own preconditions first, spending nothing: someone with only the session has no ticket, so junk from them
|
||||||
|
// can neither burn the member's ticket and challenge nor spend the verification permits.
|
||||||
|
if (!HasTicket(heimdallAccountId, ticketId) || await AccountAsync(heimdallAccountId, ct) is not { } user)
|
||||||
|
{
|
||||||
|
return HeimdallLinkOutcome.EnrolNeedsSignIn;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await Active(user.Id).CountAsync(ct) >= MaxActive)
|
||||||
|
{
|
||||||
|
return HeimdallLinkOutcome.PasskeyLimit;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!TryDecode(fields.RawId, out var rawId) || !TryDecode(fields.AttestationObject, out var attestation)
|
||||||
|
|| !TryDecode(fields.ClientDataJson, out var clientData) || fields.Id is null)
|
||||||
|
{
|
||||||
|
return HeimdallLinkOutcome.PasskeyFailed;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!rateLimit.TryAcquire(heimdallAccountId, verification: true))
|
||||||
|
{
|
||||||
|
return HeimdallLinkOutcome.RateLimited;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now both are spent, whatever the outcome.
|
||||||
|
var ticket = ceremonies.Take(CeremonyKind.EnrolTicket, heimdallAccountId, null, ticketId);
|
||||||
|
var challenge = ceremonies.Take(CeremonyKind.EnrolChallenge, heimdallAccountId, null, null);
|
||||||
|
if (ticket is null)
|
||||||
|
{
|
||||||
|
return HeimdallLinkOutcome.EnrolNeedsSignIn;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (challenge?.Data is null)
|
||||||
|
{
|
||||||
|
return HeimdallLinkOutcome.PasskeyExpired;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var registered = await relyingParty.Fido2!.MakeNewCredentialAsync(new MakeNewCredentialParams
|
||||||
|
{
|
||||||
|
AttestationResponse = new AuthenticatorAttestationRawResponse
|
||||||
|
{
|
||||||
|
Id = fields.Id,
|
||||||
|
RawId = rawId,
|
||||||
|
Type = PublicKeyCredentialType.PublicKey,
|
||||||
|
ClientExtensionResults = new AuthenticationExtensionsClientOutputs(),
|
||||||
|
Response = new AuthenticatorAttestationRawResponse.AttestationResponse
|
||||||
|
{
|
||||||
|
AttestationObject = attestation,
|
||||||
|
ClientDataJson = clientData,
|
||||||
|
Transports = Transports(fields.Transports)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
OriginalOptions = CredentialCreateOptions.FromJson(challenge.Data),
|
||||||
|
IsCredentialIdUniqueToUserCallback = async (parameters, token) =>
|
||||||
|
!await db.PasskeyCredentials.AnyAsync(x => x.CredentialId == parameters.CredentialId, token)
|
||||||
|
}, ct);
|
||||||
|
|
||||||
|
var now = clock.GetUtcNow();
|
||||||
|
db.PasskeyCredentials.Add(new PasskeyCredential
|
||||||
|
{
|
||||||
|
UserAccountId = user.Id,
|
||||||
|
CredentialId = registered.Id,
|
||||||
|
PublicKey = registered.PublicKey,
|
||||||
|
SignCount = registered.SignCount,
|
||||||
|
Transports = string.Join(',', Transports(fields.Transports).Select(x => TransportName(x))),
|
||||||
|
AaGuid = registered.AaGuid == Guid.Empty ? null : registered.AaGuid,
|
||||||
|
Label = Label(label, "Passkey"),
|
||||||
|
CreatedUtc = now,
|
||||||
|
StrongFromUtc = now + CoolingOff
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
return HeimdallLinkOutcome.PasskeyAdded;
|
||||||
|
}
|
||||||
|
catch (Exception error) when (error is Fido2VerificationException or DbUpdateException or FormatException or System.Text.Json.JsonException)
|
||||||
|
{
|
||||||
|
logger.LogWarning("Passkey enrolment refused: {Reason}", error is Fido2VerificationException f ? f.Code.ToString() : error.GetType().Name);
|
||||||
|
return HeimdallLinkOutcome.PasskeyFailed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Revokes one of the session's own passkeys. Needs no strong proof; the page shows the revocation.</summary>
|
||||||
|
public async Task<HeimdallLinkOutcome> RevokeAsync(string heimdallAccountId, Guid credentialId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (await AccountAsync(heimdallAccountId, ct) is not { } user)
|
||||||
|
{
|
||||||
|
return HeimdallLinkOutcome.Error;
|
||||||
|
}
|
||||||
|
|
||||||
|
var credential = await Active(user.Id).SingleOrDefaultAsync(x => x.Id == credentialId, ct);
|
||||||
|
if (credential is null)
|
||||||
|
{
|
||||||
|
return HeimdallLinkOutcome.PasskeyFailed;
|
||||||
|
}
|
||||||
|
|
||||||
|
credential.RevokedUtc = clock.GetUtcNow();
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
return HeimdallLinkOutcome.PasskeyRevoked;
|
||||||
|
}
|
||||||
|
|
||||||
|
private IQueryable<PasskeyCredential> Strong(Guid userId)
|
||||||
|
{
|
||||||
|
var now = clock.GetUtcNow();
|
||||||
|
return Active(userId).Where(x => x.StrongFromUtc <= now);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> HasStrongAsync(string heimdallAccountId, CancellationToken ct) =>
|
||||||
|
await AccountAsync(heimdallAccountId, ct) is { } user && await Strong(user.Id).AnyAsync(ct);
|
||||||
|
|
||||||
|
/// <summary>When the first cooling-off credential becomes strong, or null when none is cooling.</summary>
|
||||||
|
public async Task<DateTimeOffset?> NextStrongAtAsync(string heimdallAccountId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (await AccountAsync(heimdallAccountId, ct) is not { } user)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var now = clock.GetUtcNow();
|
||||||
|
var cooling = await Active(user.Id).Where(x => x.StrongFromUtc > now).Select(x => x.StrongFromUtc).ToListAsync(ct);
|
||||||
|
return cooling.Count == 0 ? null : cooling.Min();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The request options for unlinking one identity: only the account's strong passkeys may answer, with user verification.</summary>
|
||||||
|
public async Task<(HeimdallLinkOutcome? Refusal, string? OptionsJson)> BeginAssertionAsync(string heimdallAccountId, string identityId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (!relyingParty.IsAvailable)
|
||||||
|
{
|
||||||
|
return (HeimdallLinkOutcome.PasskeysUnavailable, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!rateLimit.TryAcquire(heimdallAccountId))
|
||||||
|
{
|
||||||
|
return (HeimdallLinkOutcome.RateLimited, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await AccountAsync(heimdallAccountId, ct) is not { } user)
|
||||||
|
{
|
||||||
|
return (HeimdallLinkOutcome.Error, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
var strong = await Strong(user.Id).ToListAsync(ct);
|
||||||
|
if (strong.Count == 0)
|
||||||
|
{
|
||||||
|
return (HeimdallLinkOutcome.PasskeyRequired, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
var options = relyingParty.Fido2!.GetAssertionOptions(new GetAssertionOptionsParams
|
||||||
|
{
|
||||||
|
AllowedCredentials = strong.Select(x => new PublicKeyCredentialDescriptor(PublicKeyCredentialType.PublicKey, x.CredentialId, Transports(x.Transports))).ToList(),
|
||||||
|
UserVerification = UserVerificationRequirement.Required
|
||||||
|
});
|
||||||
|
var json = options.ToJson();
|
||||||
|
return ceremonies.Issue(CeremonyKind.UnlinkChallenge, heimdallAccountId, identityId, json, PasskeyCeremonies.ChallengeLifetime) is null
|
||||||
|
? (HeimdallLinkOutcome.Error, null)
|
||||||
|
: (null, json);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies an assertion made for unlinking <paramref name="identityId"/>. The challenge is spent first. On success the
|
||||||
|
/// credential's sign count and last use are recorded and a single-use fact for this account and identity is issued; its id
|
||||||
|
/// (not a credential, not a bearer) is what the fresh Heimdall sign-in's attempt carries.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<(HeimdallLinkOutcome? Refusal, string? FactId)> VerifyAssertionAsync(
|
||||||
|
string heimdallAccountId, string identityId, PasskeyFields fields, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (!relyingParty.IsAvailable)
|
||||||
|
{
|
||||||
|
return (HeimdallLinkOutcome.PasskeysUnavailable, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The caller's own preconditions first, spending nothing: a decodable assertion naming one of this account's strong,
|
||||||
|
// un-revoked credentials. Junk from someone holding only the session fails here and burns neither the member's challenge
|
||||||
|
// nor the verification permits.
|
||||||
|
if (await AccountAsync(heimdallAccountId, ct) is not { } user
|
||||||
|
|| !TryDecode(fields.RawId, out var rawId) || !TryDecode(fields.AuthenticatorData, out var authenticatorData)
|
||||||
|
|| !TryDecode(fields.ClientDataJson, out var clientData) || !TryDecode(fields.Signature, out var signature)
|
||||||
|
|| fields.Id is null)
|
||||||
|
{
|
||||||
|
return (HeimdallLinkOutcome.PasskeyFailed, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[]? userHandle = null;
|
||||||
|
if (!string.IsNullOrEmpty(fields.UserHandle) && !TryDecode(fields.UserHandle, out userHandle))
|
||||||
|
{
|
||||||
|
return (HeimdallLinkOutcome.PasskeyFailed, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The credential must be this session's own, not revoked and past its cooling-off. Anything else is not a strong proof.
|
||||||
|
var credential = await Strong(user.Id).SingleOrDefaultAsync(x => x.CredentialId == rawId, ct);
|
||||||
|
if (credential is null)
|
||||||
|
{
|
||||||
|
return (HeimdallLinkOutcome.PasskeyFailed, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Without a live challenge for this account and identity nothing can verify, so no permit is spent either: junk naming the
|
||||||
|
// account's own credential spends at most one permit per challenge that was asked for (the first junk then takes it).
|
||||||
|
if (ceremonies.Peek(CeremonyKind.UnlinkChallenge, heimdallAccountId, identityId, null) is null)
|
||||||
|
{
|
||||||
|
return (HeimdallLinkOutcome.PasskeyExpired, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!rateLimit.TryAcquire(heimdallAccountId, verification: true))
|
||||||
|
{
|
||||||
|
return (HeimdallLinkOutcome.RateLimited, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spent from here on, whatever the outcome.
|
||||||
|
var challenge = ceremonies.Take(CeremonyKind.UnlinkChallenge, heimdallAccountId, identityId, null);
|
||||||
|
if (challenge?.Data is null)
|
||||||
|
{
|
||||||
|
return (HeimdallLinkOutcome.PasskeyExpired, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await relyingParty.Fido2!.MakeAssertionAsync(new MakeAssertionParams
|
||||||
|
{
|
||||||
|
AssertionResponse = new AuthenticatorAssertionRawResponse
|
||||||
|
{
|
||||||
|
Id = fields.Id,
|
||||||
|
RawId = rawId,
|
||||||
|
Type = PublicKeyCredentialType.PublicKey,
|
||||||
|
ClientExtensionResults = new AuthenticationExtensionsClientOutputs(),
|
||||||
|
Response = new AuthenticatorAssertionRawResponse.AssertionResponse
|
||||||
|
{
|
||||||
|
AuthenticatorData = authenticatorData,
|
||||||
|
ClientDataJson = clientData,
|
||||||
|
Signature = signature,
|
||||||
|
UserHandle = userHandle
|
||||||
|
}
|
||||||
|
},
|
||||||
|
OriginalOptions = AssertionOptions.FromJson(challenge.Data),
|
||||||
|
StoredPublicKey = credential.PublicKey,
|
||||||
|
StoredSignatureCounter = (uint)credential.SignCount,
|
||||||
|
IsUserHandleOwnerOfCredentialIdCallback = (parameters, _) =>
|
||||||
|
Task.FromResult(parameters.UserHandle.AsSpan().SequenceEqual(user.Id.ToByteArray()) && parameters.CredentialId.AsSpan().SequenceEqual(credential.CredentialId))
|
||||||
|
}, ct);
|
||||||
|
|
||||||
|
credential.SignCount = result.SignCount;
|
||||||
|
credential.LastUsedUtc = clock.GetUtcNow();
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
catch (Exception error) when (error is Fido2VerificationException or FormatException or System.Text.Json.JsonException)
|
||||||
|
{
|
||||||
|
logger.LogWarning("Passkey assertion refused: {Reason}", error is Fido2VerificationException f ? f.Code.ToString() : error.GetType().Name);
|
||||||
|
return (HeimdallLinkOutcome.PasskeyFailed, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
var fact = ceremonies.Issue(CeremonyKind.UnlinkProof, heimdallAccountId, identityId, credential.Id.ToString(), PasskeyCeremonies.ProofLifetime);
|
||||||
|
return fact is null ? (HeimdallLinkOutcome.Error, null) : (null, fact);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void DiscardFact(string? factId) => ceremonies.Discard(factId);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether the credential an assertion fact was made with is still this account's own, un-revoked and strong. Asked in the
|
||||||
|
/// completion, before Heimdall is: a passkey revoked between the assertion and the completion no longer proves anything.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<bool> StillStrongAsync(string heimdallAccountId, string? credentialId, CancellationToken ct) =>
|
||||||
|
Guid.TryParse(credentialId, out var id) && await AccountAsync(heimdallAccountId, ct) is { } user
|
||||||
|
&& await Strong(user.Id).AnyAsync(x => x.Id == id, ct);
|
||||||
|
|
||||||
|
/// <summary>A label from a form field: trimmed, control characters dropped, bounded; the fallback when nothing is left.</summary>
|
||||||
|
private static string Label(string? value, string fallback)
|
||||||
|
{
|
||||||
|
// By rune and by text element, never by UTF-16 unit: a cut inside a surrogate pair is not valid text and Postgres refuses it.
|
||||||
|
// Control and format characters (bidi overrides, joiners, zero-width marks) are dropped; a lone surrogate becomes U+FFFD.
|
||||||
|
var runes = (value ?? string.Empty).EnumerateRunes()
|
||||||
|
.Where(r => Rune.GetUnicodeCategory(r) is not (UnicodeCategory.Control or UnicodeCategory.Format))
|
||||||
|
.Take(MaxLabel * 4);
|
||||||
|
var cleaned = string.Concat(runes.Select(r => r.ToString())).Trim();
|
||||||
|
var elements = new StringInfo(cleaned);
|
||||||
|
if (elements.LengthInTextElements > MaxLabel)
|
||||||
|
{
|
||||||
|
cleaned = elements.SubstringByTextElements(0, MaxLabel);
|
||||||
|
}
|
||||||
|
|
||||||
|
return cleaned.Length == 0 ? fallback : cleaned;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryDecode(string? value, out byte[] bytes)
|
||||||
|
{
|
||||||
|
bytes = [];
|
||||||
|
if (string.IsNullOrEmpty(value) || value.Length > 16_384)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
bytes = WebEncoders.Base64UrlDecode(value);
|
||||||
|
return bytes.Length > 0;
|
||||||
|
}
|
||||||
|
catch (FormatException)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AuthenticatorTransport[] Transports(string? csv)
|
||||||
|
{
|
||||||
|
var parsed = new List<AuthenticatorTransport>();
|
||||||
|
foreach (var name in (csv ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).Distinct())
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Only names of the closed set reach the converter; one it does not know is dropped.
|
||||||
|
if (TransportNames.Contains(name))
|
||||||
|
{
|
||||||
|
parsed.Add(System.Text.Json.JsonSerializer.Deserialize<AuthenticatorTransport>($"\"{name}\""));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (System.Text.Json.JsonException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string TransportName(AuthenticatorTransport transport) =>
|
||||||
|
System.Text.Json.JsonSerializer.Serialize(transport).Trim('"');
|
||||||
|
}
|
||||||
|
|
@ -12,6 +12,7 @@ public static class BifrostCookies
|
||||||
public const string Antiforgery = HostPrefix + "bifrost_antiforgery";
|
public const string Antiforgery = HostPrefix + "bifrost_antiforgery";
|
||||||
public const string Voter = HostPrefix + "bifrost_voter";
|
public const string Voter = HostPrefix + "bifrost_voter";
|
||||||
public const string HeimdallAttemptPrefix = HostPrefix + "bifrost_heimdall_";
|
public const string HeimdallAttemptPrefix = HostPrefix + "bifrost_heimdall_";
|
||||||
|
public const string PasskeyTicket = HostPrefix + "bifrost_passkey_ticket";
|
||||||
|
|
||||||
public const string Path = "/";
|
public const string Path = "/";
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,9 @@ public sealed class StartupConfigurationValidator(
|
||||||
{
|
{
|
||||||
var failures = new List<string>();
|
var failures = new List<string>();
|
||||||
|
|
||||||
|
// Building the options resolves Heimdall:AppSecretFile, and a bad file stops startup in every environment.
|
||||||
|
_ = heimdallOptions.Value;
|
||||||
|
|
||||||
// Checked in every environment: a bad value here would otherwise pass startup and throw on every cast.
|
// Checked in every environment: a bad value here would otherwise pass startup and throw on every cast.
|
||||||
var conceptVotes = conceptVoteOptions.Value;
|
var conceptVotes = conceptVoteOptions.Value;
|
||||||
if (!NetworkPrefix.IsValidLength(conceptVotes.IPv4PrefixLength, NetworkPrefix.IPv4Bits))
|
if (!NetworkPrefix.IsValidLength(conceptVotes.IPv4PrefixLength, NetworkPrefix.IPv4Bits))
|
||||||
|
|
@ -102,6 +105,13 @@ public sealed class StartupConfigurationValidator(
|
||||||
failures.Add("Heimdall:PatronSupportIntakeSecret is required when patron support intake is enabled.");
|
failures.Add("Heimdall:PatronSupportIntakeSecret is required when patron support intake is enabled.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The options are already resolved, so a secret from Heimdall:AppSecretFile or Heimdall:AppSecret lands in AppSecret.
|
||||||
|
// Blank would silently switch Discord and Patreon member sign-in off.
|
||||||
|
if (string.IsNullOrWhiteSpace(heimdallOptions.Value.AppSecret))
|
||||||
|
{
|
||||||
|
failures.Add("Heimdall:AppSecretFile (or Heimdall:AppSecret) must supply the Heimdall app secret outside development.");
|
||||||
|
}
|
||||||
|
|
||||||
if (stripeOptions.Value.EnableCheckout)
|
if (stripeOptions.Value.EnableCheckout)
|
||||||
{
|
{
|
||||||
if (!stripeOptions.Value.IsCheckoutConfigured)
|
if (!stripeOptions.Value.IsCheckoutConfigured)
|
||||||
|
|
|
||||||
230
src/Bifrost.Web/Pages/Membership/LinkedAccounts.cshtml
Normal file
230
src/Bifrost.Web/Pages/Membership/LinkedAccounts.cshtml
Normal file
|
|
@ -0,0 +1,230 @@
|
||||||
|
@page
|
||||||
|
@model Bifrost.Web.Pages.Membership.LinkedAccountsModel
|
||||||
|
@{
|
||||||
|
ViewData["Title"] = "Linked accounts";
|
||||||
|
}
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<span class="eyebrow">Heimdall</span>
|
||||||
|
<h1>Linked accounts</h1>
|
||||||
|
<p class="panel-intro">
|
||||||
|
The provider logins that sign in to this account. Heimdall holds them and decides what may change; Bifrost keeps none of
|
||||||
|
your Heimdall sign-in, so each action sends you through Heimdall once more.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
@if (Model.ResultMessage is not null)
|
||||||
|
{
|
||||||
|
<div class="callout" role="status">@Model.ResultMessage</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (!Model.HasHeimdallAccount)
|
||||||
|
{
|
||||||
|
<div class="callout warning">
|
||||||
|
This session is not a Heimdall account, so there are no provider logins to manage here. Sign in with Discord or Patreon
|
||||||
|
through Heimdall to see them.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<h2>Your logins</h2>
|
||||||
|
@if (Model.Identities is null)
|
||||||
|
{
|
||||||
|
<p class="muted">
|
||||||
|
Bifrost does not hold the list: it asks Heimdall, which means signing in to Heimdall again, and keeps the answer for
|
||||||
|
five minutes. It also cannot tell which logins this account holds right now, so it will not choose one for you.
|
||||||
|
Pick a login you know it holds. Signing in through Discord or Patreon when the account no longer holds that login
|
||||||
|
would create a separate Heimdall account holding it. Google cannot do that: Heimdall refuses a Google sign-in for a
|
||||||
|
login no account holds.
|
||||||
|
</p>
|
||||||
|
<div class="panel-actions">
|
||||||
|
@foreach (var provider in Model.LinkableProviders.OrderBy(x => x == "google" ? 0 : 1))
|
||||||
|
{
|
||||||
|
<form method="post" asp-page-handler="Refresh" class="inline-actions">
|
||||||
|
<input type="hidden" name="via" value="@provider" />
|
||||||
|
<button class="button secondary" type="submit">Show my logins through @LinkedAccountsModel.ProviderName(provider)@(provider == "google" ? " (cannot create an account)" : "")</button>
|
||||||
|
</form>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<ul>
|
||||||
|
@foreach (var identity in Model.Identities)
|
||||||
|
{
|
||||||
|
<li>
|
||||||
|
<strong>@LinkedAccountsModel.ProviderName(identity.Provider)</strong>
|
||||||
|
@if (identity.DisplayName is not null)
|
||||||
|
{
|
||||||
|
<span>@identity.DisplayName</span>
|
||||||
|
}
|
||||||
|
@if (identity.LinkedAt is { } linkedAt)
|
||||||
|
{
|
||||||
|
<span class="muted">linked @linkedAt.UtcDateTime.ToString("dd MMM yyyy") UTC</span>
|
||||||
|
}
|
||||||
|
@if (Model.HasStrongPasskey)
|
||||||
|
{
|
||||||
|
@if (Model.PasskeysAvailable)
|
||||||
|
{
|
||||||
|
<form method="post" asp-page-handler="UnlinkPasskey" class="inline-actions" data-passkey-unlink>
|
||||||
|
<input type="hidden" name="identityId" value="@identity.Id" />
|
||||||
|
<input type="hidden" name="id" />
|
||||||
|
<input type="hidden" name="rawId" />
|
||||||
|
<input type="hidden" name="authenticatorData" />
|
||||||
|
<input type="hidden" name="clientDataJson" />
|
||||||
|
<input type="hidden" name="signature" />
|
||||||
|
<input type="hidden" name="userHandle" />
|
||||||
|
<button class="button secondary" type="submit">Unlink with passkey</button>
|
||||||
|
</form>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (Model.HasGoogle)
|
||||||
|
{
|
||||||
|
<form method="post" asp-page-handler="Unlink" class="inline-actions">
|
||||||
|
<input type="hidden" name="identityId" value="@identity.Id" />
|
||||||
|
<button class="button secondary" type="submit">Unlink</button>
|
||||||
|
</form>
|
||||||
|
}
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
@if (Model.Identities.Count == 1)
|
||||||
|
{
|
||||||
|
<p class="muted">This is your only login. Heimdall will not remove the last one.</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (Model.HasStrongPasskey && !Model.PasskeysAvailable)
|
||||||
|
{
|
||||||
|
<p class="callout warning">
|
||||||
|
This account has a passkey, so unlinking needs it, but passkeys are not available on this server right now (a
|
||||||
|
configuration fault the operator must fix), so nothing can be unlinked from here at the moment. Nothing is lost:
|
||||||
|
try again later. If you cannot wait, revoke your passkeys below; unlinking then falls back to a fresh Google sign-in,
|
||||||
|
if a Google login is linked to this account.
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
else if (Model.HasStrongPasskey)
|
||||||
|
{
|
||||||
|
<p class="muted">Unlinking asks for one of your passkeys, then signs you in to Heimdall once more and removes only the login you picked.</p>
|
||||||
|
}
|
||||||
|
else if (Model.HasGoogle)
|
||||||
|
{
|
||||||
|
<p class="muted">Unlinking asks you to sign in with Google again as proof, then removes only the login you picked. A passkey, once it has cooled off, is stronger proof and takes over from Google.</p>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<p class="muted">
|
||||||
|
Unlinking needs strong proof, and none is available. Add a passkey below@(Model.NextStrongAt is { } waiting
|
||||||
|
? $" (your newest one counts from {waiting.UtcDateTime:dd MMM yyyy HH:mm} UTC)"
|
||||||
|
: " (it counts after a 48 hour cooling-off)"),
|
||||||
|
or link a Google login once Heimdall has Google set up. Discord and Patreon cannot give that proof.
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
<form method="post" asp-page-handler="Refresh" class="panel-actions">
|
||||||
|
<button class="button secondary" type="submit">Read the list again</button>
|
||||||
|
</form>
|
||||||
|
}
|
||||||
|
|
||||||
|
<h2>Passkeys</h2>
|
||||||
|
@if (!Model.PasskeysAvailable)
|
||||||
|
{
|
||||||
|
<p class="muted">Passkeys are not available on this server right now, so none can be added or used. You can still revoke the ones listed.</p>
|
||||||
|
}
|
||||||
|
@if (Model.PasskeysAvailable)
|
||||||
|
{
|
||||||
|
<p class="muted">
|
||||||
|
A passkey is the strong proof for unlinking a login: it stays on your device, and a site that is not Bifrost cannot use it.
|
||||||
|
A new passkey is added only right after you sign in to Heimdall again, and it counts as strong proof 48 hours later, so
|
||||||
|
someone who borrows your open browser cannot add one and use it at once. You can revoke a passkey here at any time.
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
@if (Model.Passkeys.Count > 0)
|
||||||
|
{
|
||||||
|
<ul>
|
||||||
|
@foreach (var key in Model.Passkeys)
|
||||||
|
{
|
||||||
|
<li>
|
||||||
|
<strong>@key.Label</strong>
|
||||||
|
@if (key.IsStrong)
|
||||||
|
{
|
||||||
|
<span class="muted">active</span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span class="muted">cooling off until @key.StrongFromUtc.UtcDateTime.ToString("dd MMM yyyy HH:mm") UTC; cannot unlink yet</span>
|
||||||
|
}
|
||||||
|
<span class="muted">added @key.CreatedUtc.UtcDateTime.ToString("dd MMM yyyy") UTC</span>
|
||||||
|
@if (key.LastUsedUtc is { } used)
|
||||||
|
{
|
||||||
|
<span class="muted">last used @used.UtcDateTime.ToString("dd MMM yyyy") UTC</span>
|
||||||
|
}
|
||||||
|
<form method="post" asp-page-handler="PasskeyRevoke" class="inline-actions">
|
||||||
|
<input type="hidden" name="credentialId" value="@key.Id" />
|
||||||
|
<button class="button secondary" type="submit">Revoke</button>
|
||||||
|
</form>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (Model.PasskeysAvailable)
|
||||||
|
{
|
||||||
|
if (Model.PasskeyLimitReached)
|
||||||
|
{
|
||||||
|
<p class="muted">This is the most passkeys an account can hold. Revoke one to add another.</p>
|
||||||
|
}
|
||||||
|
else if (Model.CanEnrol)
|
||||||
|
{
|
||||||
|
<form method="post" asp-page-handler="PasskeyEnrol" id="passkey-enrol" data-passkey-enrol>
|
||||||
|
<input type="hidden" name="id" />
|
||||||
|
<input type="hidden" name="rawId" />
|
||||||
|
<input type="hidden" name="attestationObject" />
|
||||||
|
<input type="hidden" name="clientDataJson" />
|
||||||
|
<input type="hidden" name="transports" />
|
||||||
|
<label>Name for this passkey <input type="text" name="label" maxlength="40" value="Passkey" /></label>
|
||||||
|
<button class="button" type="submit">Add a passkey</button>
|
||||||
|
</form>
|
||||||
|
<noscript><p class="muted">Adding a passkey needs JavaScript.</p></noscript>
|
||||||
|
}
|
||||||
|
else if (Model.Identities is not null)
|
||||||
|
{
|
||||||
|
<form method="post" asp-page-handler="PasskeyEnrolStart" class="panel-actions">
|
||||||
|
<button class="button secondary" type="submit">Sign in again to add a passkey</button>
|
||||||
|
</form>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<p class="muted">Read your linked logins first; adding a passkey then asks you to sign in to Heimdall once more.</p>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
<p id="passkey-status" class="muted" role="status"></p>
|
||||||
|
|
||||||
|
@if (Model.Identities is not null)
|
||||||
|
{
|
||||||
|
<h2>Link another login</h2>
|
||||||
|
<p class="muted">
|
||||||
|
You will sign in to Heimdall again, then to the provider you pick. If that login already belongs to another account,
|
||||||
|
nothing is linked and nothing is merged.
|
||||||
|
</p>
|
||||||
|
<div class="panel-actions">
|
||||||
|
@foreach (var provider in Model.LinkableProviders)
|
||||||
|
{
|
||||||
|
<form method="post" asp-page-handler="Link" class="inline-actions">
|
||||||
|
<input type="hidden" name="provider" value="@provider" />
|
||||||
|
<button class="button secondary" type="submit">Link @LinkedAccountsModel.ProviderName(provider)</button>
|
||||||
|
</form>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="panel-actions">
|
||||||
|
<a class="button secondary" asp-page="/Membership/Status">Back to membership status</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
@section Scripts {
|
||||||
|
@if (Model.HasHeimdallAccount && Model.PasskeysAvailable)
|
||||||
|
{
|
||||||
|
<script src="~/js/passkeys.js" asp-append-version="true"></script>
|
||||||
|
}
|
||||||
|
}
|
||||||
295
src/Bifrost.Web/Pages/Membership/LinkedAccounts.cshtml.cs
Normal file
295
src/Bifrost.Web/Pages/Membership/LinkedAccounts.cshtml.cs
Normal file
|
|
@ -0,0 +1,295 @@
|
||||||
|
using System.Security.Claims;
|
||||||
|
using Bifrost.Web.Configuration;
|
||||||
|
using Bifrost.Web.Features.Heimdall;
|
||||||
|
using Bifrost.Web.Features.Membership;
|
||||||
|
using Bifrost.Web.Features.Passkeys;
|
||||||
|
using Bifrost.Web.Features.Shared;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace Bifrost.Web.Pages.Membership;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The member's provider logins (docs/linked-accounts.md). Bifrost keeps no Heimdall token, so every action here begins a
|
||||||
|
/// fresh Heimdall sign-in for the account already signed in and finishes inside that sign-in's completion
|
||||||
|
/// (<c>POST /auth/heimdall/complete</c>); this page only starts them and shows what the last read found. Every handler is a
|
||||||
|
/// POST with antiforgery. Nothing from the query or the form is ever shown: the outcome is a code from a closed set.
|
||||||
|
/// </summary>
|
||||||
|
[Authorize]
|
||||||
|
[ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)]
|
||||||
|
public sealed class LinkedAccountsModel(
|
||||||
|
HeimdallAuthService heimdallAuthService,
|
||||||
|
LinkedAccountsSnapshots snapshots,
|
||||||
|
PasskeyService passkeys,
|
||||||
|
IOptions<BifrostHostOptions> hostOptions,
|
||||||
|
ILogger<LinkedAccountsModel> logger) : PageModel
|
||||||
|
{
|
||||||
|
private static readonly Dictionary<string, string> ProviderNames = new(StringComparer.Ordinal)
|
||||||
|
{
|
||||||
|
["discord"] = "Discord",
|
||||||
|
["patreon"] = "Patreon",
|
||||||
|
["google"] = "Google"
|
||||||
|
};
|
||||||
|
|
||||||
|
private string? AccountId => User.FindFirstValue(BifrostClaimTypes.HeimdallAccountId) is { Length: > 0 } id ? id : null;
|
||||||
|
|
||||||
|
/// <summary>The wording of the outcome the page was sent back with: a closed set, null for anything else.</summary>
|
||||||
|
public string? ResultMessage { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>Whether this session is a Heimdall account. A Bifrost-native GitHub session has none, so there is nothing to list.</summary>
|
||||||
|
public bool HasHeimdallAccount => AccountId is not null;
|
||||||
|
|
||||||
|
/// <summary>The logins the last read found, or null when there is none or it has expired.</summary>
|
||||||
|
public IReadOnlyList<LinkedIdentitySummary>? Identities { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>Whether a fresh Google sign-in can be had for this account: without a strong passkey, unlinking needs one, and only Google can give it.</summary>
|
||||||
|
public bool HasGoogle => Identities?.Any(x => x.Provider == HeimdallAuthService.StepUpProvider) == true;
|
||||||
|
|
||||||
|
/// <summary>The account's passkeys that are not revoked, with their cooling-off state.</summary>
|
||||||
|
public IReadOnlyList<PasskeyView> Passkeys { get; private set; } = [];
|
||||||
|
|
||||||
|
/// <summary>Whether a passkey past its cooling-off exists: unlinking then needs it and no longer accepts Google as the proof.</summary>
|
||||||
|
public bool HasStrongPasskey => Passkeys.Any(x => x.IsStrong);
|
||||||
|
|
||||||
|
/// <summary>When the first passkey still cooling off becomes strong.</summary>
|
||||||
|
public DateTimeOffset? NextStrongAt => Passkeys.Where(x => !x.IsStrong).Select(x => (DateTimeOffset?)x.StrongFromUtc).Min();
|
||||||
|
|
||||||
|
/// <summary>Whether this browser holds a live ticket from a fresh sign-in, so a passkey can be added now.</summary>
|
||||||
|
public bool CanEnrol { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>Whether this server can run passkey ceremonies at all (https and the host it expects).</summary>
|
||||||
|
public bool PasskeysAvailable => passkeys.IsAvailable;
|
||||||
|
|
||||||
|
public bool PasskeyLimitReached => Passkeys.Count >= PasskeyService.MaxActive;
|
||||||
|
|
||||||
|
public bool CanUnlink => HasStrongPasskey || HasGoogle;
|
||||||
|
|
||||||
|
public IReadOnlyList<string> LinkableProviders => HeimdallAuthService.LinkableProviders;
|
||||||
|
|
||||||
|
public static string ProviderName(string provider) => ProviderNames.GetValueOrDefault(provider, provider);
|
||||||
|
|
||||||
|
public async Task OnGetAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
ResultMessage = HeimdallLinkOutcomes.Wording(Request.Query[HeimdallLinkOutcomes.ResultParameter].ToString());
|
||||||
|
Identities = AccountId is { } id ? snapshots.Get(id) : null;
|
||||||
|
if (AccountId is { } account)
|
||||||
|
{
|
||||||
|
Passkeys = await passkeys.ListAsync(account, cancellationToken);
|
||||||
|
CanEnrol = passkeys.HasTicket(account, Request.Cookies[BifrostCookies.PasskeyTicket]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Reads the account's logins: an ordinary Heimdall sign-in, then the list inside its completion.</summary>
|
||||||
|
public Task<IActionResult> OnPostRefreshAsync(string? via, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
// With no current list Bifrost cannot tell which provider the account still holds, so the member names one.
|
||||||
|
if (AccountId is not { } id || (snapshots.Get(id) is null && (via is null || !HeimdallAuthService.LinkableProviders.Contains(via))))
|
||||||
|
{
|
||||||
|
return Task.FromResult(Done(HeimdallLinkOutcome.ProviderNeeded));
|
||||||
|
}
|
||||||
|
|
||||||
|
return BeginAsync(
|
||||||
|
(account, ct) => heimdallAuthService.StartListAsync(account, via, hostOptions.Value.PublicBaseUrl, Request.Cookies, ct),
|
||||||
|
HeimdallLinkOutcome.Error,
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Links another provider. The provider is one Heimdall's bifrost profile admits; anything else starts nothing, and so does a missing list.</summary>
|
||||||
|
public Task<IActionResult> OnPostLinkAsync(string? provider, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (provider is null || !HeimdallAuthService.LinkableProviders.Contains(provider))
|
||||||
|
{
|
||||||
|
return Task.FromResult(Done(HeimdallLinkOutcome.Error));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (AccountId is not { } id || snapshots.Get(id) is null)
|
||||||
|
{
|
||||||
|
return Task.FromResult(Done(HeimdallLinkOutcome.ListExpired));
|
||||||
|
}
|
||||||
|
|
||||||
|
return BeginAsync(
|
||||||
|
(account, ct) => heimdallAuthService.StartLinkAsync(account, provider, hostOptions.Value.PublicBaseUrl, Request.Cookies, ct),
|
||||||
|
HeimdallLinkOutcome.Error,
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Unlinks one login with fresh proof. The identity must be on the list this account was last shown and the account must
|
||||||
|
/// hold a Google login to prove with; otherwise Heimdall is not asked, and the page says so.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<IActionResult> OnPostUnlinkAsync(string? identityId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var shown = AccountId is { } id ? snapshots.Get(id) : null;
|
||||||
|
if (shown is null || identityId is null || !shown.Any(x => x.Id == identityId))
|
||||||
|
{
|
||||||
|
return Done(HeimdallLinkOutcome.ListExpired);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A strong passkey replaces Google as the proof: a member who has one is never asked for the weaker one.
|
||||||
|
if (await passkeys.HasStrongAsync(AccountId!, cancellationToken))
|
||||||
|
{
|
||||||
|
return Done(HeimdallLinkOutcome.PasskeyRequired);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!shown.Any(x => x.Provider == HeimdallAuthService.StepUpProvider))
|
||||||
|
{
|
||||||
|
return Done(HeimdallLinkOutcome.ProofUnavailable);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await BeginAsync(
|
||||||
|
(account, ct) => heimdallAuthService.StartUnlinkAsync(account, identityId, hostOptions.Value.PublicBaseUrl, Request.Cookies, ct),
|
||||||
|
HeimdallLinkOutcome.ProofUnavailable,
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Begins the fresh sign-in whose completion issues the enrolment ticket. Needs a current list: the proof provider is never guessed.</summary>
|
||||||
|
public async Task<IActionResult> OnPostPasskeyEnrolStartAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (AccountId is not { } id)
|
||||||
|
{
|
||||||
|
return Done(HeimdallLinkOutcome.Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (snapshots.Get(id) is null)
|
||||||
|
{
|
||||||
|
return Done(HeimdallLinkOutcome.ListExpired);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await passkeys.PreflightEnrolStartAsync(id, cancellationToken) is { } refused)
|
||||||
|
{
|
||||||
|
return Done(refused);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await BeginAsync(
|
||||||
|
(account, ct) => heimdallAuthService.StartEnrolProofAsync(account, hostOptions.Value.PublicBaseUrl, Request.Cookies, ct),
|
||||||
|
HeimdallLinkOutcome.Error,
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The WebAuthn creation options for the ticket this browser holds, as JSON for the page's script.</summary>
|
||||||
|
public async Task<IActionResult> OnPostPasskeyEnrolOptionsAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (AccountId is not { } id)
|
||||||
|
{
|
||||||
|
return Options(HeimdallLinkOutcome.Error, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
var (refusal, json) = await passkeys.BeginEnrolAsync(id, Request.Cookies[BifrostCookies.PasskeyTicket], cancellationToken);
|
||||||
|
return Options(refusal, json);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies the registration the script posted. The ticket cookie is spent whatever the outcome.</summary>
|
||||||
|
public async Task<IActionResult> OnPostPasskeyEnrolAsync(
|
||||||
|
string? id, string? rawId, string? attestationObject, string? clientDataJson, string? transports, string? label, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (AccountId is not { } account)
|
||||||
|
{
|
||||||
|
return Done(HeimdallLinkOutcome.Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
var outcome = await passkeys.EnrolAsync(
|
||||||
|
account, Request.Cookies[BifrostCookies.PasskeyTicket],
|
||||||
|
new PasskeyFields(id, rawId, null, clientDataJson, null, null, attestationObject, transports), label, cancellationToken);
|
||||||
|
Response.Cookies.Delete(BifrostCookies.PasskeyTicket, HeimdallAttemptCookie.DeleteOptions());
|
||||||
|
return Done(outcome);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IActionResult> OnPostPasskeyRevokeAsync(Guid credentialId, CancellationToken cancellationToken) =>
|
||||||
|
AccountId is { } id ? Done(await passkeys.RevokeAsync(id, credentialId, cancellationToken)) : Done(HeimdallLinkOutcome.Error);
|
||||||
|
|
||||||
|
/// <summary>The WebAuthn request options for unlinking one identity, as JSON for the page's script.</summary>
|
||||||
|
public async Task<IActionResult> OnPostPasskeyChallengeAsync(string? identityId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (AccountId is not { } id)
|
||||||
|
{
|
||||||
|
return Options(HeimdallLinkOutcome.Error, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (snapshots.Get(id) is not { } shown || identityId is null || !shown.Any(x => x.Id == identityId))
|
||||||
|
{
|
||||||
|
return Options(HeimdallLinkOutcome.ListExpired, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
var (refusal, json) = await passkeys.BeginAssertionAsync(id, identityId, cancellationToken);
|
||||||
|
return Options(refusal, json);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Unlinks one login on a passkey assertion. The assertion is verified here; only then does Bifrost start the fresh Heimdall sign-in
|
||||||
|
/// whose completion asks Heimdall for the step-up and unlinks. A refused or unverifiable assertion starts no sign-in.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<IActionResult> OnPostUnlinkPasskeyAsync(
|
||||||
|
string? identityId, string? id, string? rawId, string? authenticatorData, string? clientDataJson, string? signature, string? userHandle,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var shown = AccountId is { } account ? snapshots.Get(account) : null;
|
||||||
|
if (shown is null || identityId is null || !shown.Any(x => x.Id == identityId))
|
||||||
|
{
|
||||||
|
return Done(HeimdallLinkOutcome.ListExpired);
|
||||||
|
}
|
||||||
|
|
||||||
|
var (refusal, factId) = await passkeys.VerifyAssertionAsync(
|
||||||
|
AccountId!, identityId, new PasskeyFields(id, rawId, authenticatorData, clientDataJson, signature, userHandle, null, null), cancellationToken);
|
||||||
|
if (refusal is { } failed)
|
||||||
|
{
|
||||||
|
return Done(failed);
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var started = await heimdallAuthService.StartUnlinkPasskeyAsync(
|
||||||
|
AccountId!, identityId, factId!, hostOptions.Value.PublicBaseUrl, Request.Cookies, cancellationToken);
|
||||||
|
Response.Cookies.Append(started.CookieName, started.CookieValue, HeimdallAttemptCookie.Options());
|
||||||
|
return Redirect(started.AuthorizationUrl.ToString());
|
||||||
|
}
|
||||||
|
catch (HeimdallAuthException error)
|
||||||
|
{
|
||||||
|
logger.LogWarning("Linked accounts start refused: {Reason}", error.Message);
|
||||||
|
passkeys.DiscardFact(factId);
|
||||||
|
return Done(HeimdallLinkOutcome.ProofUnavailable);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>JSON for the page's script: the options, or only a closed outcome code with the status that fits.</summary>
|
||||||
|
private IActionResult Options(HeimdallLinkOutcome? refusal, string? json)
|
||||||
|
{
|
||||||
|
if (refusal is null && json is not null)
|
||||||
|
{
|
||||||
|
return Content(json, "application/json");
|
||||||
|
}
|
||||||
|
|
||||||
|
var status = refusal == HeimdallLinkOutcome.RateLimited ? StatusCodes.Status429TooManyRequests : StatusCodes.Status400BadRequest;
|
||||||
|
return new ContentResult
|
||||||
|
{
|
||||||
|
StatusCode = status,
|
||||||
|
ContentType = "application/json",
|
||||||
|
Content = "{\"error\":\"" + HeimdallLinkOutcomes.Code(refusal ?? HeimdallLinkOutcome.Error) + "\"}"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private IActionResult Done(HeimdallLinkOutcome outcome) =>
|
||||||
|
RedirectToPage(null, new Dictionary<string, string?> { [HeimdallLinkOutcomes.ResultParameter] = HeimdallLinkOutcomes.Code(outcome) });
|
||||||
|
|
||||||
|
private async Task<IActionResult> BeginAsync(
|
||||||
|
Func<string, CancellationToken, Task<HeimdallAuthStartResult>> start, HeimdallLinkOutcome onRefusal, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (AccountId is not { } accountId)
|
||||||
|
{
|
||||||
|
return Done(HeimdallLinkOutcome.Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var started = await start(accountId, cancellationToken);
|
||||||
|
Response.Cookies.Append(started.CookieName, started.CookieValue, HeimdallAttemptCookie.Options());
|
||||||
|
return Redirect(started.AuthorizationUrl.ToString());
|
||||||
|
}
|
||||||
|
catch (HeimdallAuthException error)
|
||||||
|
{
|
||||||
|
logger.LogWarning("Linked accounts start refused: {Reason}", error.Message);
|
||||||
|
return Done(onRefusal);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -24,6 +24,14 @@
|
||||||
<p class="muted">@Model.StatusMessage</p>
|
<p class="muted">@Model.StatusMessage</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@if (Model.CanManageLogins)
|
||||||
|
{
|
||||||
|
<div class="panel-actions">
|
||||||
|
<a class="button secondary" asp-page="/Membership/LinkedAccounts">Linked accounts</a>
|
||||||
|
<span class="muted">See, add and remove the provider logins that sign in to this account.</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
@if (Model.Invitation is not null)
|
@if (Model.Invitation is not null)
|
||||||
{
|
{
|
||||||
<div class="callout success">
|
<div class="callout success">
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
using Bifrost.Web.Data;
|
using Bifrost.Web.Data;
|
||||||
using Bifrost.Web.Domain;
|
using Bifrost.Web.Domain;
|
||||||
|
using System.Security.Claims;
|
||||||
|
using Bifrost.Web.Features.Membership;
|
||||||
using Bifrost.Web.Features.Shared;
|
using Bifrost.Web.Features.Shared;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||||
|
|
@ -12,6 +14,9 @@ public sealed class StatusModel(
|
||||||
ICurrentBifrostActorAccessor actorAccessor,
|
ICurrentBifrostActorAccessor actorAccessor,
|
||||||
BifrostDbContext dbContext) : PageModel
|
BifrostDbContext dbContext) : PageModel
|
||||||
{
|
{
|
||||||
|
/// <summary>Whether this session belongs to a Heimdall account whose logins the Linked accounts page can manage.</summary>
|
||||||
|
public bool CanManageLogins => User.FindFirstValue(BifrostClaimTypes.HeimdallAccountId) is { Length: > 0 };
|
||||||
|
|
||||||
public CurrentBifrostActor Actor { get; private set; } = CurrentBifrostActor.Anonymous;
|
public CurrentBifrostActor Actor { get; private set; } = CurrentBifrostActor.Anonymous;
|
||||||
|
|
||||||
public MembershipInvitation? Invitation { get; private set; }
|
public MembershipInvitation? Invitation { get; private set; }
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,8 @@ builder.Services
|
||||||
|
|
||||||
builder.Services
|
builder.Services
|
||||||
.AddOptions<HeimdallOptions>()
|
.AddOptions<HeimdallOptions>()
|
||||||
.BindConfiguration(HeimdallOptions.SectionName);
|
.BindConfiguration(HeimdallOptions.SectionName)
|
||||||
|
.PostConfigure<ILogger<HeimdallOptions>>(HeimdallOptions.ResolveAppSecret);
|
||||||
|
|
||||||
builder.Services
|
builder.Services
|
||||||
.AddOptions<StripeOptions>()
|
.AddOptions<StripeOptions>()
|
||||||
|
|
@ -92,6 +93,7 @@ builder.Services.AddAntiforgery(options =>
|
||||||
builder.Services.AddRazorPages(options =>
|
builder.Services.AddRazorPages(options =>
|
||||||
{
|
{
|
||||||
options.Conventions.AuthorizePage("/Membership/Status");
|
options.Conventions.AuthorizePage("/Membership/Status");
|
||||||
|
options.Conventions.AuthorizePage("/Membership/LinkedAccounts");
|
||||||
options.Conventions.AuthorizeFolder("/App", ActiveMemberRequirement.PolicyName);
|
options.Conventions.AuthorizeFolder("/App", ActiveMemberRequirement.PolicyName);
|
||||||
options.Conventions.AllowAnonymousToFolder("/Mill");
|
options.Conventions.AllowAnonymousToFolder("/Mill");
|
||||||
});
|
});
|
||||||
|
|
@ -151,7 +153,16 @@ builder.Services.AddSingleton<StartupConfigurationValidator>();
|
||||||
builder.Services.AddScoped<IAuthorizationHandler, ActiveMemberAuthorizationHandler>();
|
builder.Services.AddScoped<IAuthorizationHandler, ActiveMemberAuthorizationHandler>();
|
||||||
builder.Services.AddHttpClient<HeimdallAuthService>((serviceProvider, client) =>
|
builder.Services.AddHttpClient<HeimdallAuthService>((serviceProvider, client) =>
|
||||||
HeimdallAuthService.ConfigureClient(client, serviceProvider.GetRequiredService<IOptions<HeimdallOptions>>().Value))
|
HeimdallAuthService.ConfigureClient(client, serviceProvider.GetRequiredService<IOptions<HeimdallOptions>>().Value))
|
||||||
.RedactLoggedHeaders(header => string.Equals(header, HeimdallAuthService.AppSecretHeader, StringComparison.OrdinalIgnoreCase));
|
.RedactLoggedHeaders(HeimdallAuthService.RedactsHeader);
|
||||||
|
// Heimdall's member routes take only the member's bearer: this client carries no app secret.
|
||||||
|
builder.Services.AddHttpClient<HeimdallMemberClient>((serviceProvider, client) =>
|
||||||
|
HeimdallMemberClient.ConfigureClient(client, serviceProvider.GetRequiredService<IOptions<HeimdallOptions>>().Value))
|
||||||
|
.RedactLoggedHeaders(HeimdallAuthService.RedactsHeader);
|
||||||
|
builder.Services.AddSingleton<LinkedAccountsSnapshots>();
|
||||||
|
builder.Services.AddSingleton<Bifrost.Web.Features.Passkeys.PasskeyRelyingParty>();
|
||||||
|
builder.Services.AddSingleton<Bifrost.Web.Features.Passkeys.PasskeyCeremonies>();
|
||||||
|
builder.Services.AddSingleton<Bifrost.Web.Features.Passkeys.PasskeyRateLimit>();
|
||||||
|
builder.Services.AddScoped<Bifrost.Web.Features.Passkeys.PasskeyService>();
|
||||||
builder.Services.AddSingleton<HeimdallTokenVerifier>();
|
builder.Services.AddSingleton<HeimdallTokenVerifier>();
|
||||||
builder.Services.AddHttpClient(HeimdallTokenVerifier.ClientName, (serviceProvider, client) =>
|
builder.Services.AddHttpClient(HeimdallTokenVerifier.ClientName, (serviceProvider, client) =>
|
||||||
HeimdallTokenVerifier.ConfigureClient(client, serviceProvider.GetRequiredService<IOptions<HeimdallOptions>>().Value));
|
HeimdallTokenVerifier.ConfigureClient(client, serviceProvider.GetRequiredService<IOptions<HeimdallOptions>>().Value));
|
||||||
|
|
@ -274,6 +285,8 @@ var app = builder.Build();
|
||||||
var appJsonSerializerOptions = CreateAppJsonSerializerOptions();
|
var appJsonSerializerOptions = CreateAppJsonSerializerOptions();
|
||||||
|
|
||||||
app.Services.GetRequiredService<StartupConfigurationValidator>().Validate();
|
app.Services.GetRequiredService<StartupConfigurationValidator>().Validate();
|
||||||
|
// Resolved now so a misconfigured RP (not https, host differs, a port in ExpectedHost) is logged once at boot, not on the first ceremony.
|
||||||
|
_ = app.Services.GetRequiredService<Bifrost.Web.Features.Passkeys.PasskeyRelyingParty>();
|
||||||
await using (var scope = app.Services.CreateAsyncScope())
|
await using (var scope = app.Services.CreateAsyncScope())
|
||||||
{
|
{
|
||||||
await scope.ServiceProvider
|
await scope.ServiceProvider
|
||||||
|
|
@ -822,11 +835,50 @@ app.MapPost("/auth/heimdall/complete", async (
|
||||||
ILogger<HeimdallAuthService> logger,
|
ILogger<HeimdallAuthService> logger,
|
||||||
CancellationToken cancellationToken) =>
|
CancellationToken cancellationToken) =>
|
||||||
{
|
{
|
||||||
|
// Every answer here sets or clears an attempt cookie or signs someone in: none may be stored.
|
||||||
|
httpContext.Response.Headers.CacheControl = "no-store";
|
||||||
var form = await ReadFormOrNullAsync(httpContext.Request, cancellationToken);
|
var form = await ReadFormOrNullAsync(httpContext.Request, cancellationToken);
|
||||||
var attempt = form?["attempt"].ToString();
|
var attempt = form?["attempt"].ToString();
|
||||||
var held = heimdallAuthService.FindAttemptCookie(httpContext.Request.Cookies, attempt);
|
var held = heimdallAuthService.FindAttemptCookie(httpContext.Request.Cookies, attempt);
|
||||||
|
var linking = held is not null
|
||||||
|
&& heimdallAuthService.PurposeOf(held.Value.Value, attempt) is not (null or HeimdallAttemptPurpose.SignIn);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
if (linking)
|
||||||
|
{
|
||||||
|
// A link step never signs anyone in: the session in this browser is the one being linked.
|
||||||
|
var step = await heimdallAuthService.ContinueLinkAsync(
|
||||||
|
hostOptions.Value.PublicBaseUrl,
|
||||||
|
attempt,
|
||||||
|
held!.Value.Name,
|
||||||
|
held.Value.Value,
|
||||||
|
form?["code"].ToString(),
|
||||||
|
form?["pendingLinkId"].ToString(),
|
||||||
|
httpContext.User.FindFirstValue(BifrostClaimTypes.HeimdallAccountId),
|
||||||
|
cancellationToken);
|
||||||
|
if (step.Next is not null)
|
||||||
|
{
|
||||||
|
httpContext.Response.Cookies.Append(step.CookieName, step.CookieValue!, HeimdallAttemptCookie.Options());
|
||||||
|
return Results.Redirect(step.Next.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (step.EnrolTicket is not null)
|
||||||
|
{
|
||||||
|
// The ticket id goes only to the browser that completed the fresh sign-in; the server holds what it is bound to.
|
||||||
|
httpContext.Response.Cookies.Append(BifrostCookies.PasskeyTicket, step.EnrolTicket, new CookieOptions
|
||||||
|
{
|
||||||
|
HttpOnly = true,
|
||||||
|
Secure = true,
|
||||||
|
SameSite = SameSiteMode.Lax,
|
||||||
|
Path = BifrostCookies.Path,
|
||||||
|
MaxAge = Bifrost.Web.Features.Passkeys.PasskeyCeremonies.TicketLifetime
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
httpContext.Response.Cookies.Delete(step.CookieName, HeimdallAttemptCookie.DeleteOptions());
|
||||||
|
return Results.LocalRedirect(HeimdallLinkOutcomes.Redirect(step.Outcome!.Value));
|
||||||
|
}
|
||||||
|
|
||||||
var signIn = await heimdallAuthService.CompleteAsync(
|
var signIn = await heimdallAuthService.CompleteAsync(
|
||||||
hostOptions.Value.PublicBaseUrl,
|
hostOptions.Value.PublicBaseUrl,
|
||||||
attempt,
|
attempt,
|
||||||
|
|
@ -845,7 +897,9 @@ app.MapPost("/auth/heimdall/complete", async (
|
||||||
httpContext.Response.Cookies.Delete(held.Value.Name, HeimdallAttemptCookie.DeleteOptions());
|
httpContext.Response.Cookies.Delete(held.Value.Name, HeimdallAttemptCookie.DeleteOptions());
|
||||||
}
|
}
|
||||||
|
|
||||||
return Results.Redirect(HeimdallAuthService.ErrorRedirect);
|
return linking
|
||||||
|
? Results.LocalRedirect(HeimdallLinkOutcomes.Redirect(HeimdallLinkOutcome.Error))
|
||||||
|
: Results.Redirect(HeimdallAuthService.ErrorRedirect);
|
||||||
}
|
}
|
||||||
}).AllowAnonymous();
|
}).AllowAnonymous();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@
|
||||||
"BaseUrl": "https://heimdall.gamecult.org",
|
"BaseUrl": "https://heimdall.gamecult.org",
|
||||||
"AppSlug": "bifrost",
|
"AppSlug": "bifrost",
|
||||||
"AppSecret": "",
|
"AppSecret": "",
|
||||||
|
"AppSecretFile": "",
|
||||||
"DiscordGuildId": "",
|
"DiscordGuildId": "",
|
||||||
"DiscordAllowedRoleIds": [],
|
"DiscordAllowedRoleIds": [],
|
||||||
"PatreonTierTitle": "Inner Sanctum",
|
"PatreonTierTitle": "Inner Sanctum",
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,19 @@
|
||||||
"resolved": "2.4.0",
|
"resolved": "2.4.0",
|
||||||
"contentHash": "SwXsAV3sMvAU/Nn31pbjhWurYSjJ+/giI/0n6tCrYoupEK34iIHCuk3STAd9fx8yudM85KkLSVdn951vTng/vQ=="
|
"contentHash": "SwXsAV3sMvAU/Nn31pbjhWurYSjJ+/giI/0n6tCrYoupEK34iIHCuk3STAd9fx8yudM85KkLSVdn951vTng/vQ=="
|
||||||
},
|
},
|
||||||
|
"Fido2": {
|
||||||
|
"type": "Direct",
|
||||||
|
"requested": "[4.3.3, )",
|
||||||
|
"resolved": "4.3.3",
|
||||||
|
"contentHash": "++G8ohXlhp9AH49FXIvYqziXUAEpuCtT+z5HBevX6+Pl6sYz9zfDHGTgvne6t3UEhM0hudesgMudyNG7RlpaEA==",
|
||||||
|
"dependencies": {
|
||||||
|
"Fido2.Models": "4.3.3",
|
||||||
|
"Microsoft.Extensions.Http": "9.0.0",
|
||||||
|
"Microsoft.IdentityModel.JsonWebTokens": "8.2.0",
|
||||||
|
"NSec.Cryptography": "25.4.0",
|
||||||
|
"System.Formats.Cbor": "9.0.24"
|
||||||
|
}
|
||||||
|
},
|
||||||
"MessagePack": {
|
"MessagePack": {
|
||||||
"type": "Direct",
|
"type": "Direct",
|
||||||
"requested": "[3.1.12, )",
|
"requested": "[3.1.12, )",
|
||||||
|
|
@ -111,11 +124,24 @@
|
||||||
"resolved": "8.0.5",
|
"resolved": "8.0.5",
|
||||||
"contentHash": "0f1B50Ss7rqxXiaBJyzUu9bWFOO2/zSlifZ/UNMdiIpDYe4cY4LQQicP4nirK1OS31I43rn062UIJ1Q9bpmHpg=="
|
"contentHash": "0f1B50Ss7rqxXiaBJyzUu9bWFOO2/zSlifZ/UNMdiIpDYe4cY4LQQicP4nirK1OS31I43rn062UIJ1Q9bpmHpg=="
|
||||||
},
|
},
|
||||||
|
"Fido2.Models": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "4.3.3",
|
||||||
|
"contentHash": "G/Q8MYgptOwMrbWgDoGsy90u3J5GTdNAHub8aZRg/mQrUETzB5CqFzstGdTXE9UhL0qD6sRynOoPUnqCwKRnMw==",
|
||||||
|
"dependencies": {
|
||||||
|
"Microsoft.Bcl.Memory": "9.0.14"
|
||||||
|
}
|
||||||
|
},
|
||||||
"Humanizer.Core": {
|
"Humanizer.Core": {
|
||||||
"type": "Transitive",
|
"type": "Transitive",
|
||||||
"resolved": "2.14.1",
|
"resolved": "2.14.1",
|
||||||
"contentHash": "lQKvtaTDOXnoVJ20ibTuSIOf2i0uO0MPbDhd1jm238I+U/2ZnRENj0cktKZhtchBMtCUSRQ5v4xBCUbKNmyVMw=="
|
"contentHash": "lQKvtaTDOXnoVJ20ibTuSIOf2i0uO0MPbDhd1jm238I+U/2ZnRENj0cktKZhtchBMtCUSRQ5v4xBCUbKNmyVMw=="
|
||||||
},
|
},
|
||||||
|
"libsodium": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "1.0.20.1",
|
||||||
|
"contentHash": "pkiceEqJDQFHjbga3k/oPfTVX9g+GKJZ1VrkuiLhaymt9YNw1Dr7cX9hNuZuNaWcr9WD0moW55k4pMwzQ7JaZQ=="
|
||||||
|
},
|
||||||
"MessagePack.Annotations": {
|
"MessagePack.Annotations": {
|
||||||
"type": "Transitive",
|
"type": "Transitive",
|
||||||
"resolved": "3.1.12",
|
"resolved": "3.1.12",
|
||||||
|
|
@ -139,6 +165,11 @@
|
||||||
"System.Formats.Asn1": "10.0.2"
|
"System.Formats.Asn1": "10.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Microsoft.Bcl.Memory": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "9.0.14",
|
||||||
|
"contentHash": "laQCcjTM2FnbyznkAu9hwBuQh3z2/OtJKwjGkJOUevylFpFXZIUVq/+MU2jM0HQm1n5wsjjwtLIDOiWJ6Nai3A=="
|
||||||
|
},
|
||||||
"Microsoft.CodeAnalysis.Analyzers": {
|
"Microsoft.CodeAnalysis.Analyzers": {
|
||||||
"type": "Transitive",
|
"type": "Transitive",
|
||||||
"resolved": "3.3.3",
|
"resolved": "3.3.3",
|
||||||
|
|
@ -226,26 +257,43 @@
|
||||||
"Microsoft.Extensions.Primitives": "8.0.0"
|
"Microsoft.Extensions.Primitives": "8.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Microsoft.Extensions.Configuration": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "9.0.0",
|
||||||
|
"contentHash": "YIMO9T3JL8MeEXgVozKt2v79hquo/EFtnY0vgxmLnUvk1Rei/halI7kOWZL2RBeV9FMGzgM9LZA8CVaNwFMaNA==",
|
||||||
|
"dependencies": {
|
||||||
|
"Microsoft.Extensions.Configuration.Abstractions": "9.0.0",
|
||||||
|
"Microsoft.Extensions.Primitives": "9.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"Microsoft.Extensions.Configuration.Abstractions": {
|
"Microsoft.Extensions.Configuration.Abstractions": {
|
||||||
"type": "Transitive",
|
"type": "Transitive",
|
||||||
"resolved": "8.0.0",
|
"resolved": "9.0.0",
|
||||||
"contentHash": "3lE/iLSutpgX1CC0NOW70FJoGARRHbyKmG7dc0klnUZ9Dd9hS6N/POPWhKhMLCEuNN5nXEY5agmlFtH562vqhQ==",
|
"contentHash": "lqvd7W3FGKUO1+ZoUEMaZ5XDJeWvjpy2/M/ptCGz3tXLD4HWVaSzjufsAsjemasBEg+2SxXVtYVvGt5r2nKDlg==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"Microsoft.Extensions.Primitives": "8.0.0"
|
"Microsoft.Extensions.Primitives": "9.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Microsoft.Extensions.Configuration.Binder": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "9.0.0",
|
||||||
|
"contentHash": "RiScL99DcyngY9zJA2ROrri7Br8tn5N4hP4YNvGdTN/bvg1A3dwvDOxHnNZ3Im7x2SJ5i4LkX1uPiR/MfSFBLQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"Microsoft.Extensions.Configuration.Abstractions": "9.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Microsoft.Extensions.DependencyInjection": {
|
"Microsoft.Extensions.DependencyInjection": {
|
||||||
"type": "Transitive",
|
"type": "Transitive",
|
||||||
"resolved": "8.0.1",
|
"resolved": "9.0.0",
|
||||||
"contentHash": "BmANAnR5Xd4Oqw7yQ75xOAYODybZQRzdeNucg7kS5wWKd2PNnMdYtJ2Vciy0QLylRmv42DGl5+AFL9izA6F1Rw==",
|
"contentHash": "MCPrg7v3QgNMr0vX4vzRXvkNGgLg8vKWX0nKCWUxu2uPyMsaRgiRc1tHBnbTcfJMhMKj2slE/j2M9oGkd25DNw==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.2"
|
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Microsoft.Extensions.DependencyInjection.Abstractions": {
|
"Microsoft.Extensions.DependencyInjection.Abstractions": {
|
||||||
"type": "Transitive",
|
"type": "Transitive",
|
||||||
"resolved": "8.0.2",
|
"resolved": "9.0.0",
|
||||||
"contentHash": "3iE7UF7MQkCv1cxzCahz+Y/guQbTqieyxyaWKhrRO91itI9cOKO76OHeQDahqG4MmW5umr3CcCvGmK92lWNlbg=="
|
"contentHash": "+6f2qv2a3dLwd5w6JanPIPs47CxRbnk+ZocMJUhv9NxP88VlOcJYZs9jY+MYSjxvady08bUZn6qgiNh7DadGgg=="
|
||||||
},
|
},
|
||||||
"Microsoft.Extensions.DependencyModel": {
|
"Microsoft.Extensions.DependencyModel": {
|
||||||
"type": "Transitive",
|
"type": "Transitive",
|
||||||
|
|
@ -256,37 +304,83 @@
|
||||||
"System.Text.Json": "8.0.0"
|
"System.Text.Json": "8.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Microsoft.Extensions.Diagnostics": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "9.0.0",
|
||||||
|
"contentHash": "0CF9ZrNw5RAlRfbZuVIvzzhP8QeWqHiUmMBU/2H7Nmit8/vwP3/SbHeEctth7D4Gz2fBnEbokPc1NU8/j/1ZLw==",
|
||||||
|
"dependencies": {
|
||||||
|
"Microsoft.Extensions.Configuration": "9.0.0",
|
||||||
|
"Microsoft.Extensions.Diagnostics.Abstractions": "9.0.0",
|
||||||
|
"Microsoft.Extensions.Options.ConfigurationExtensions": "9.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Microsoft.Extensions.Diagnostics.Abstractions": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "9.0.0",
|
||||||
|
"contentHash": "1K8P7XzuzX8W8pmXcZjcrqS6x5eSSdvhQohmcpgiQNY/HlDAlnrhR9dvlURfFz428A+RTCJpUyB+aKTA6AgVcQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.0",
|
||||||
|
"Microsoft.Extensions.Options": "9.0.0",
|
||||||
|
"System.Diagnostics.DiagnosticSource": "9.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Microsoft.Extensions.Http": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "9.0.0",
|
||||||
|
"contentHash": "DqI4q54U4hH7bIAq9M5a/hl5Odr/KBAoaZ0dcT4OgutD8dook34CbkvAfAIzkMVjYXiL+E5ul9etwwqiX4PHGw==",
|
||||||
|
"dependencies": {
|
||||||
|
"Microsoft.Extensions.Configuration.Abstractions": "9.0.0",
|
||||||
|
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.0",
|
||||||
|
"Microsoft.Extensions.Diagnostics": "9.0.0",
|
||||||
|
"Microsoft.Extensions.Logging": "9.0.0",
|
||||||
|
"Microsoft.Extensions.Logging.Abstractions": "9.0.0",
|
||||||
|
"Microsoft.Extensions.Options": "9.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"Microsoft.Extensions.Logging": {
|
"Microsoft.Extensions.Logging": {
|
||||||
"type": "Transitive",
|
"type": "Transitive",
|
||||||
"resolved": "8.0.1",
|
"resolved": "9.0.0",
|
||||||
"contentHash": "4x+pzsQEbqxhNf1QYRr5TDkLP9UsLT3A6MdRKDDEgrW7h1ljiEPgTNhKYUhNCCAaVpQECVQ+onA91PTPnIp6Lw==",
|
"contentHash": "crjWyORoug0kK7RSNJBTeSE6VX8IQgLf3nUpTB9m62bPXp/tzbnOsnbe8TXEG0AASNaKZddnpHKw7fET8E++Pg==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"Microsoft.Extensions.DependencyInjection": "8.0.1",
|
"Microsoft.Extensions.DependencyInjection": "9.0.0",
|
||||||
"Microsoft.Extensions.Logging.Abstractions": "8.0.2",
|
"Microsoft.Extensions.Logging.Abstractions": "9.0.0",
|
||||||
"Microsoft.Extensions.Options": "8.0.2"
|
"Microsoft.Extensions.Options": "9.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Microsoft.Extensions.Logging.Abstractions": {
|
"Microsoft.Extensions.Logging.Abstractions": {
|
||||||
"type": "Transitive",
|
"type": "Transitive",
|
||||||
"resolved": "8.0.2",
|
"resolved": "9.0.0",
|
||||||
"contentHash": "nroMDjS7hNBPtkZqVBbSiQaQjWRDxITI8Y7XnDs97rqG3EbzVTNLZQf7bIeUJcaHOV8bca47s1Uxq94+2oGdxA==",
|
"contentHash": "g0UfujELzlLbHoVG8kPKVBaW470Ewi+jnptGS9KUi6jcb+k2StujtK3m26DFSGGwQ/+bVgZfsWqNzlP6YOejvw==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.2"
|
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.0",
|
||||||
|
"System.Diagnostics.DiagnosticSource": "9.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Microsoft.Extensions.Options": {
|
"Microsoft.Extensions.Options": {
|
||||||
"type": "Transitive",
|
"type": "Transitive",
|
||||||
"resolved": "8.0.2",
|
"resolved": "9.0.0",
|
||||||
"contentHash": "dWGKvhFybsaZpGmzkGCbNNwBD1rVlWzrZKANLW/CcbFJpCEceMCGzT7zZwHOGBCbwM0SzBuceMj5HN1LKV1QqA==",
|
"contentHash": "y2146b3jrPI3Q0lokKXdKLpmXqakYbDIPDV6r3M8SqvSf45WwOTzkyfDpxnZXJsJQEpAsAqjUq5Pu8RCJMjubg==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0",
|
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.0",
|
||||||
"Microsoft.Extensions.Primitives": "8.0.0"
|
"Microsoft.Extensions.Primitives": "9.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Microsoft.Extensions.Options.ConfigurationExtensions": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "9.0.0",
|
||||||
|
"contentHash": "Ob3FXsXkcSMQmGZi7qP07EQ39kZpSBlTcAZLbJLdI4FIf0Jug8biv2HTavWmnTirchctPlq9bl/26CXtQRguzA==",
|
||||||
|
"dependencies": {
|
||||||
|
"Microsoft.Extensions.Configuration.Abstractions": "9.0.0",
|
||||||
|
"Microsoft.Extensions.Configuration.Binder": "9.0.0",
|
||||||
|
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.0",
|
||||||
|
"Microsoft.Extensions.Options": "9.0.0",
|
||||||
|
"Microsoft.Extensions.Primitives": "9.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Microsoft.Extensions.Primitives": {
|
"Microsoft.Extensions.Primitives": {
|
||||||
"type": "Transitive",
|
"type": "Transitive",
|
||||||
"resolved": "8.0.0",
|
"resolved": "9.0.0",
|
||||||
"contentHash": "bXJEZrW9ny8vjMF1JV253WeLhpEVzFo1lyaZu1vQ4ZxWUlVvknZ/+ftFgVheLubb4eZPSwwxBeqS1JkCOjxd8g=="
|
"contentHash": "N3qEBzmLMYiASUlKxxFIISP4AiwuPTHF5uCh+2CWSwwzAJiIYx0kBJsS30cp1nvhSySFAVi30jecD307jV+8Kg=="
|
||||||
},
|
},
|
||||||
"Microsoft.IdentityModel.Abstractions": {
|
"Microsoft.IdentityModel.Abstractions": {
|
||||||
"type": "Transitive",
|
"type": "Transitive",
|
||||||
|
|
@ -340,6 +434,14 @@
|
||||||
"System.CodeDom": "4.4.0"
|
"System.CodeDom": "4.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"NSec.Cryptography": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "25.4.0",
|
||||||
|
"contentHash": "Z3wPpG0xefMLmhn9T+Ka/EzAmMQPT0THL/5E4lf9cXI/N9rbGH0G23ZFpRcPnD0ast2k7ieG6QSVNIjmfPCXBQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"libsodium": "[1.0.20.1, 1.0.21)"
|
||||||
|
}
|
||||||
|
},
|
||||||
"OpenIddict.Abstractions": {
|
"OpenIddict.Abstractions": {
|
||||||
"type": "Transitive",
|
"type": "Transitive",
|
||||||
"resolved": "7.7.1",
|
"resolved": "7.7.1",
|
||||||
|
|
@ -448,11 +550,21 @@
|
||||||
"System.Composition.Runtime": "6.0.0"
|
"System.Composition.Runtime": "6.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"System.Diagnostics.DiagnosticSource": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "9.0.0",
|
||||||
|
"contentHash": "ddppcFpnbohLWdYKr/ZeLZHmmI+DXFgZ3Snq+/E7SwcdW4UnvxmaugkwGywvGVWkHPGCSZjCP+MLzu23AL5SDw=="
|
||||||
|
},
|
||||||
"System.Formats.Asn1": {
|
"System.Formats.Asn1": {
|
||||||
"type": "Transitive",
|
"type": "Transitive",
|
||||||
"resolved": "10.0.2",
|
"resolved": "10.0.2",
|
||||||
"contentHash": "9Hee6iQ74pDgdRNIL6lO8Vlx2onVWd5aDnqwk4Pvo0h5FklBd7QwcQDKgxeLuhiy0o3ibzUVO0KFQe0/hPXU+Q=="
|
"contentHash": "9Hee6iQ74pDgdRNIL6lO8Vlx2onVWd5aDnqwk4Pvo0h5FklBd7QwcQDKgxeLuhiy0o3ibzUVO0KFQe0/hPXU+Q=="
|
||||||
},
|
},
|
||||||
|
"System.Formats.Cbor": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "9.0.24",
|
||||||
|
"contentHash": "SEfEvHu6qXcXoDsDKagddLDf8A9iHIhHUD0hGemdmwlP206KLLCI5ksskOpeXMgNpQCs2diB0fOBKcnjySHFUQ=="
|
||||||
|
},
|
||||||
"System.IO.Pipelines": {
|
"System.IO.Pipelines": {
|
||||||
"type": "Transitive",
|
"type": "Transitive",
|
||||||
"resolved": "6.0.3",
|
"resolved": "6.0.3",
|
||||||
|
|
|
||||||
125
src/Bifrost.Web/wwwroot/js/passkeys.js
Normal file
125
src/Bifrost.Web/wwwroot/js/passkeys.js
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
// The Linked accounts page's passkey ceremonies (docs/passkeys.md). First-party, no dependency. It asks the server for
|
||||||
|
// WebAuthn options, calls navigator.credentials, and posts back only base64url strings in a same-origin form. Nothing it
|
||||||
|
// reads from the options is shown or evaluated; the server checks the origin, the challenge and the signature.
|
||||||
|
(function () {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
var status = document.getElementById("passkey-status");
|
||||||
|
|
||||||
|
function say(text) {
|
||||||
|
if (status) {
|
||||||
|
status.textContent = text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toBytes(value) {
|
||||||
|
var padded = value.replace(/-/g, "+").replace(/_/g, "/");
|
||||||
|
while (padded.length % 4) {
|
||||||
|
padded += "=";
|
||||||
|
}
|
||||||
|
var raw = atob(padded);
|
||||||
|
var bytes = new Uint8Array(raw.length);
|
||||||
|
for (var i = 0; i < raw.length; i++) {
|
||||||
|
bytes[i] = raw.charCodeAt(i);
|
||||||
|
}
|
||||||
|
return bytes.buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toText(buffer) {
|
||||||
|
var bytes = new Uint8Array(buffer);
|
||||||
|
var raw = "";
|
||||||
|
for (var i = 0; i < bytes.length; i++) {
|
||||||
|
raw += String.fromCharCode(bytes[i]);
|
||||||
|
}
|
||||||
|
return btoa(raw).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function token(form) {
|
||||||
|
var field = form.querySelector("input[name=__RequestVerificationToken]");
|
||||||
|
return field ? field.value : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Posts to a handler of this page with the form's antiforgery token and returns the parsed JSON, or throws.
|
||||||
|
function options(handler, form, fields) {
|
||||||
|
return fetch(location.pathname + "?handler=" + handler, {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "same-origin",
|
||||||
|
headers: { "RequestVerificationToken": token(form), "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
|
body: new URLSearchParams(fields)
|
||||||
|
}).then(function (response) {
|
||||||
|
return response.json().then(function (body) {
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(body && body.error ? body.error : "error");
|
||||||
|
}
|
||||||
|
return body;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function descriptors(list) {
|
||||||
|
return (list || []).map(function (item) {
|
||||||
|
var copy = { type: item.type, id: toBytes(item.id) };
|
||||||
|
if (item.transports) {
|
||||||
|
copy.transports = item.transports;
|
||||||
|
}
|
||||||
|
return copy;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function set(form, name, value) {
|
||||||
|
form.elements[name].value = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function failed(error) {
|
||||||
|
var code = error && error.message ? error.message : "error";
|
||||||
|
location.assign(location.pathname + "?result=" + (/^[a-z_]+$/.test(code) && code !== "error" ? encodeURIComponent(code) : "passkey_failed"));
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll("form[data-passkey-enrol]").forEach(function (form) {
|
||||||
|
form.addEventListener("submit", function (event) {
|
||||||
|
if (form.dataset.ready === "1") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
say("Waiting for your device...");
|
||||||
|
options("PasskeyEnrolOptions", form, {}).then(function (created) {
|
||||||
|
created.challenge = toBytes(created.challenge);
|
||||||
|
created.user.id = toBytes(created.user.id);
|
||||||
|
created.excludeCredentials = descriptors(created.excludeCredentials);
|
||||||
|
return navigator.credentials.create({ publicKey: created });
|
||||||
|
}).then(function (credential) {
|
||||||
|
set(form, "id", credential.id);
|
||||||
|
set(form, "rawId", toText(credential.rawId));
|
||||||
|
set(form, "attestationObject", toText(credential.response.attestationObject));
|
||||||
|
set(form, "clientDataJson", toText(credential.response.clientDataJSON));
|
||||||
|
set(form, "transports", credential.response.getTransports ? credential.response.getTransports().join(",") : "");
|
||||||
|
form.dataset.ready = "1";
|
||||||
|
form.submit();
|
||||||
|
}).catch(failed);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll("form[data-passkey-unlink]").forEach(function (form) {
|
||||||
|
form.addEventListener("submit", function (event) {
|
||||||
|
if (form.dataset.ready === "1") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
say("Waiting for your device...");
|
||||||
|
options("PasskeyChallenge", form, { identityId: form.elements.identityId.value }).then(function (requested) {
|
||||||
|
requested.challenge = toBytes(requested.challenge);
|
||||||
|
requested.allowCredentials = descriptors(requested.allowCredentials);
|
||||||
|
return navigator.credentials.get({ publicKey: requested });
|
||||||
|
}).then(function (credential) {
|
||||||
|
set(form, "id", credential.id);
|
||||||
|
set(form, "rawId", toText(credential.rawId));
|
||||||
|
set(form, "authenticatorData", toText(credential.response.authenticatorData));
|
||||||
|
set(form, "clientDataJson", toText(credential.response.clientDataJSON));
|
||||||
|
set(form, "signature", toText(credential.response.signature));
|
||||||
|
set(form, "userHandle", credential.response.userHandle ? toText(credential.response.userHandle) : "");
|
||||||
|
form.dataset.ready = "1";
|
||||||
|
form.submit();
|
||||||
|
}).catch(failed);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
@ -210,7 +210,8 @@ public sealed class ConceptRankedExportTests : IClassFixture<ConceptRankedExport
|
||||||
yield return ["not base64!"];
|
yield return ["not base64!"];
|
||||||
yield return ["AAAA"];
|
yield return ["AAAA"];
|
||||||
yield return [new string('A', 201)];
|
yield return [new string('A', 201)];
|
||||||
yield return [new string('A', 100_000)];
|
// 50,000 chars is the longest the client Uri (65,519 cap on Unix) can carry with the path; far past the 200-char RankCursor.MaxLength.
|
||||||
|
yield return [new string('A', 50_000)];
|
||||||
yield return [B64("")];
|
yield return [B64("")];
|
||||||
yield return [B64("1|0|0|5")];
|
yield return [B64("1|0|0|5")];
|
||||||
yield return [B64("1|0|0|5|alpha|extra")];
|
yield return [B64("1|0|0|5|alpha|extra")];
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ public sealed class CookieHardeningTests(CookieHardeningTests.OAuthConfiguredFac
|
||||||
Assert.Equal("__Host-bifrost_antiforgery", BifrostCookies.Antiforgery);
|
Assert.Equal("__Host-bifrost_antiforgery", BifrostCookies.Antiforgery);
|
||||||
Assert.Equal("__Host-bifrost_voter", VoterKeyResolver.CookieName);
|
Assert.Equal("__Host-bifrost_voter", VoterKeyResolver.CookieName);
|
||||||
Assert.Equal("__Host-bifrost_heimdall_", HeimdallAttemptCookie.Prefix);
|
Assert.Equal("__Host-bifrost_heimdall_", HeimdallAttemptCookie.Prefix);
|
||||||
|
Assert.Equal("__Host-bifrost_passkey_ticket", BifrostCookies.PasskeyTicket);
|
||||||
Assert.All(HeimdallAttemptCookie.SlotNames, x => Assert.StartsWith("__Host-", x, StringComparison.Ordinal));
|
Assert.All(HeimdallAttemptCookie.SlotNames, x => Assert.StartsWith("__Host-", x, StringComparison.Ordinal));
|
||||||
Assert.Equal("/", BifrostCookies.Path);
|
Assert.Equal("/", BifrostCookies.Path);
|
||||||
Assert.Equal("/", VoterKeyResolver.CookiePath);
|
Assert.Equal("/", VoterKeyResolver.CookiePath);
|
||||||
|
|
|
||||||
|
|
@ -35,12 +35,13 @@ function run(name, search, hash) {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const form = {
|
const form = {
|
||||||
elements: { attempt: { value: "" }, code: { value: "" } },
|
elements: { attempt: { value: "" }, code: { value: "" }, pendingLinkId: { value: "" } },
|
||||||
submit() {
|
submit() {
|
||||||
events.push("submit");
|
events.push("submit");
|
||||||
submit = {
|
submit = {
|
||||||
attempt: form.elements.attempt.value,
|
attempt: form.elements.attempt.value,
|
||||||
code: form.elements.code.value,
|
code: form.elements.code.value,
|
||||||
|
pendingLinkId: form.elements.pendingLinkId.value,
|
||||||
href: location.href,
|
href: location.href,
|
||||||
hash: location.hash,
|
hash: location.hash,
|
||||||
};
|
};
|
||||||
|
|
@ -59,6 +60,14 @@ console.log(
|
||||||
run("error-status-with-code", "?attempt=NONCE", "#heimdall_status=error&heimdall_error=denied" + complete),
|
run("error-status-with-code", "?attempt=NONCE", "#heimdall_status=error&heimdall_error=denied" + complete),
|
||||||
run("no-code", "?attempt=NONCE", "#heimdall_status=success"),
|
run("no-code", "?attempt=NONCE", "#heimdall_status=success"),
|
||||||
run("no-attempt", "", "#heimdall_status=success" + complete),
|
run("no-attempt", "", "#heimdall_status=success" + complete),
|
||||||
|
run("link-return", "?attempt=NONCE", "#heimdall_status=success&heimdall_pending_link_id=PEND1"),
|
||||||
|
run("link-return-with-decoy-query-id", "?attempt=NONCE&heimdall_pending_link_id=QPEND", "#heimdall_status=success&heimdall_pending_link_id=PEND1"),
|
||||||
|
run("link-error-with-pending-id", "?attempt=NONCE", "#heimdall_status=error&heimdall_error=denied&heimdall_pending_link_id=PEND1"),
|
||||||
|
run("link-pending-id-in-query-only", "?attempt=NONCE&heimdall_pending_link_id=QPEND", ""),
|
||||||
|
run("link-refused-elsewhere", "?attempt=NONCE", "#heimdall_status=error&heimdall_error=link_initiator_mismatch"),
|
||||||
|
run("link-refused-elsewhere-no-attempt", "", "#heimdall_status=error&heimdall_error=link_initiator_mismatch"),
|
||||||
|
run("other-refusal-text", "?attempt=NONCE", "#heimdall_status=error&heimdall_error=%3Cb%3Ex&heimdall_error_description=EVIL"),
|
||||||
|
run("refusal-in-query-only", "?attempt=NONCE&heimdall_error=link_initiator_mismatch", ""),
|
||||||
run("code-in-query-only", "?attempt=NONCE&heimdall_completion_code=QCODE", ""),
|
run("code-in-query-only", "?attempt=NONCE&heimdall_completion_code=QCODE", ""),
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
96
tests/Bifrost.Web.Tests/Fixtures/passkey-script-harness.cjs
Normal file
96
tests/Bifrost.Web.Tests/Fixtures/passkey-script-harness.cjs
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
// Runs wwwroot/js/passkeys.js against a simulated browser, once per scenario, and prints what it did as JSON:
|
||||||
|
// every fetch (URL, headers, body), the arguments to navigator.credentials, the form values at the submit, and any navigation.
|
||||||
|
// Usage: node passkey-script-harness.cjs <passkeys.js>
|
||||||
|
const fs = require("fs");
|
||||||
|
const vm = require("vm");
|
||||||
|
|
||||||
|
const source = fs.readFileSync(process.argv[2], "utf8");
|
||||||
|
const buf = (...bytes) => Uint8Array.from(bytes).buffer;
|
||||||
|
|
||||||
|
function form(names, extra = {}) {
|
||||||
|
const elements = {};
|
||||||
|
for (const name of names) {
|
||||||
|
elements[name] = { value: "" };
|
||||||
|
}
|
||||||
|
Object.assign(elements, extra);
|
||||||
|
return {
|
||||||
|
elements,
|
||||||
|
dataset: {},
|
||||||
|
handler: null,
|
||||||
|
addEventListener(type, fn) {
|
||||||
|
if (type === "submit") this.handler = fn;
|
||||||
|
},
|
||||||
|
querySelector() {
|
||||||
|
return { value: "TOKEN" };
|
||||||
|
},
|
||||||
|
submit() {
|
||||||
|
this.submitted = Object.fromEntries(Object.entries(this.elements).map(([k, v]) => [k, v.value]));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run(name, { kind, fetchReply, credentialsReply }) {
|
||||||
|
const enrol = kind === "enrol";
|
||||||
|
const theForm = enrol
|
||||||
|
? form(["id", "rawId", "attestationObject", "clientDataJson", "transports", "label"])
|
||||||
|
: form(["id", "rawId", "authenticatorData", "clientDataJson", "signature", "userHandle"], { identityId: { value: "IDENTITY" } });
|
||||||
|
const log = { name, fetches: [], credentialCall: null, navigations: [], status: "" };
|
||||||
|
const statusEl = { textContent: "" };
|
||||||
|
const document = {
|
||||||
|
getElementById: (id) => (id === "passkey-status" ? statusEl : null),
|
||||||
|
querySelectorAll: (selector) => (selector === (enrol ? "form[data-passkey-enrol]" : "form[data-passkey-unlink]") ? [theForm] : []),
|
||||||
|
};
|
||||||
|
const location = { pathname: "/Membership/LinkedAccounts", assign: (url) => log.navigations.push(url) };
|
||||||
|
const fetch = async (url, init) => {
|
||||||
|
log.fetches.push({ url, method: init.method, headers: init.headers, body: String(init.body), credentials: init.credentials });
|
||||||
|
return { ok: fetchReply.ok, json: async () => fetchReply.body };
|
||||||
|
};
|
||||||
|
const call = async (publicKey) => {
|
||||||
|
log.credentialCall = {
|
||||||
|
challengeIsBuffer: publicKey.challenge instanceof ArrayBuffer,
|
||||||
|
challengeBytes: Array.from(new Uint8Array(publicKey.challenge)),
|
||||||
|
userIdBytes: publicKey.user ? Array.from(new Uint8Array(publicKey.user.id)) : null,
|
||||||
|
listIdsAreBuffers: (publicKey.excludeCredentials || publicKey.allowCredentials).every((x) => x.id instanceof ArrayBuffer),
|
||||||
|
};
|
||||||
|
if (credentialsReply === "reject") throw new Error("NotAllowedError");
|
||||||
|
return credentialsReply;
|
||||||
|
};
|
||||||
|
const navigator = {
|
||||||
|
credentials: enrol ? { create: ({ publicKey }) => call(publicKey) } : { get: ({ publicKey }) => call(publicKey) },
|
||||||
|
};
|
||||||
|
vm.runInNewContext(source, { document, location, fetch, navigator, URLSearchParams, Uint8Array, ArrayBuffer, atob, btoa, Promise, Error });
|
||||||
|
await theForm.handler({ preventDefault() {} });
|
||||||
|
await new Promise((r) => setTimeout(r, 20));
|
||||||
|
log.submitted = theForm.submitted || null;
|
||||||
|
log.status = statusEl.textContent;
|
||||||
|
return log;
|
||||||
|
}
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const creationOptions = {
|
||||||
|
ok: true,
|
||||||
|
body: { challenge: "AQID", user: { id: "BAUG" }, excludeCredentials: [{ type: "public-key", id: "BwgJ" }] },
|
||||||
|
};
|
||||||
|
const requestOptions = {
|
||||||
|
ok: true,
|
||||||
|
body: { challenge: "AQID", allowCredentials: [{ type: "public-key", id: "BwgJ", transports: ["internal"] }] },
|
||||||
|
};
|
||||||
|
const created = {
|
||||||
|
id: "CRED-ID",
|
||||||
|
rawId: buf(251, 255, 254),
|
||||||
|
response: { attestationObject: buf(1, 2, 3), clientDataJSON: buf(4, 5), getTransports: () => ["internal", "hybrid"] },
|
||||||
|
};
|
||||||
|
const asserted = {
|
||||||
|
id: "CRED-ID",
|
||||||
|
rawId: buf(251, 255, 254),
|
||||||
|
response: { authenticatorData: buf(9, 9), clientDataJSON: buf(4, 5), signature: buf(7, 7, 7), userHandle: buf(1, 1) },
|
||||||
|
};
|
||||||
|
const out = [
|
||||||
|
await run("enrol", { kind: "enrol", fetchReply: creationOptions, credentialsReply: created }),
|
||||||
|
await run("unlink", { kind: "unlink", fetchReply: requestOptions, credentialsReply: asserted }),
|
||||||
|
await run("refused", { kind: "enrol", fetchReply: { ok: false, body: { error: "enrol_needs_sign_in" } }, credentialsReply: created }),
|
||||||
|
await run("hostile-error", { kind: "unlink", fetchReply: { ok: false, body: { error: "<script>alert(1)</script>" } }, credentialsReply: asserted }),
|
||||||
|
await run("cancelled", { kind: "unlink", fetchReply: requestOptions, credentialsReply: "reject" }),
|
||||||
|
];
|
||||||
|
console.log(JSON.stringify(out));
|
||||||
|
})();
|
||||||
157
tests/Bifrost.Web.Tests/HeimdallAppSecretFileTests.cs
Normal file
157
tests/Bifrost.Web.Tests/HeimdallAppSecretFileTests.cs
Normal file
|
|
@ -0,0 +1,157 @@
|
||||||
|
using Bifrost.Web.Configuration;
|
||||||
|
using Bifrost.Web.Tests.Support;
|
||||||
|
using Microsoft.AspNetCore.Hosting;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace Bifrost.Web.Tests;
|
||||||
|
|
||||||
|
public sealed class HeimdallAppSecretFileTests : IDisposable
|
||||||
|
{
|
||||||
|
private const string FileSecret = "file-secret-7f3a91c2";
|
||||||
|
private const string EnvSecret = "env-secret-0b5d44e8";
|
||||||
|
|
||||||
|
private readonly string _directory = Directory.CreateTempSubdirectory("bifrost-appsecret-").FullName;
|
||||||
|
private readonly CapturingLoggerProvider _logs = new();
|
||||||
|
|
||||||
|
public void Dispose() => Directory.Delete(_directory, recursive: true);
|
||||||
|
|
||||||
|
private string Write(string content)
|
||||||
|
{
|
||||||
|
var path = Path.Combine(_directory, Guid.NewGuid().ToString("N"));
|
||||||
|
File.WriteAllText(path, content);
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
private HeimdallOptions Resolve(string appSecret = "", string appSecretFile = "")
|
||||||
|
{
|
||||||
|
var options = new HeimdallOptions { AppSecret = appSecret, AppSecretFile = appSecretFile };
|
||||||
|
HeimdallOptions.ResolveAppSecret(options, _logs.CreateLogger("test"));
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AssertNoSecretLogged()
|
||||||
|
{
|
||||||
|
foreach (var line in _logs.Lines)
|
||||||
|
{
|
||||||
|
Assert.DoesNotContain(FileSecret, line);
|
||||||
|
Assert.DoesNotContain(EnvSecret, line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("")]
|
||||||
|
[InlineData("\n")]
|
||||||
|
[InlineData("\r\n")]
|
||||||
|
public void The_file_is_the_secret_with_only_trailing_newlines_trimmed(string ending)
|
||||||
|
{
|
||||||
|
var options = Resolve(appSecretFile: Write(FileSecret + ending));
|
||||||
|
|
||||||
|
Assert.Equal(FileSecret, options.AppSecret);
|
||||||
|
Assert.False(options.IsDiscordConfigured); // no guild configured
|
||||||
|
Assert.True(options.IsPatreonConfigured);
|
||||||
|
Assert.Empty(_logs.Lines);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Inner_whitespace_survives_the_trim()
|
||||||
|
{
|
||||||
|
Assert.Equal(" a b ", Resolve(appSecretFile: Write(" a b \n")).AppSecret);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void The_file_wins_over_the_environment_value_and_the_deprecation_is_logged()
|
||||||
|
{
|
||||||
|
var options = Resolve(EnvSecret, Write(FileSecret + "\n"));
|
||||||
|
|
||||||
|
Assert.Equal(FileSecret, options.AppSecret);
|
||||||
|
Assert.Contains(_logs.WarningLines, line => line.Contains("deprecated"));
|
||||||
|
AssertNoSecretLogged();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_missing_file_fails_closed_naming_option_and_path_but_no_secret()
|
||||||
|
{
|
||||||
|
var path = Path.Combine(_directory, "absent");
|
||||||
|
|
||||||
|
var failure = Assert.Throws<InvalidOperationException>(() => Resolve(EnvSecret, path));
|
||||||
|
|
||||||
|
Assert.Contains("Heimdall:AppSecretFile", failure.Message);
|
||||||
|
Assert.Contains(path, failure.Message);
|
||||||
|
Assert.DoesNotContain(EnvSecret, failure.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_directory_in_place_of_the_file_fails_closed()
|
||||||
|
{
|
||||||
|
Assert.Throws<InvalidOperationException>(() => Resolve(appSecretFile: _directory));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("")]
|
||||||
|
[InlineData("\n")]
|
||||||
|
[InlineData(" \r\n")]
|
||||||
|
public void An_empty_file_fails_closed_even_when_the_environment_value_is_set(string content)
|
||||||
|
{
|
||||||
|
var path = Write(content);
|
||||||
|
|
||||||
|
var failure = Assert.Throws<InvalidOperationException>(() => Resolve(EnvSecret, path));
|
||||||
|
|
||||||
|
Assert.Contains("Heimdall:AppSecretFile", failure.Message);
|
||||||
|
Assert.Contains(path, failure.Message);
|
||||||
|
Assert.DoesNotContain(EnvSecret, failure.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void The_environment_value_alone_still_works_and_warns_without_logging_it()
|
||||||
|
{
|
||||||
|
var options = Resolve(EnvSecret);
|
||||||
|
|
||||||
|
Assert.Equal(EnvSecret, options.AppSecret);
|
||||||
|
Assert.Single(_logs.WarningLines, line => line.Contains("deprecated") && line.Contains("Heimdall:AppSecretFile"));
|
||||||
|
AssertNoSecretLogged();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Neither_set_is_unconfigured_as_before_and_silent()
|
||||||
|
{
|
||||||
|
var options = Resolve();
|
||||||
|
|
||||||
|
Assert.Equal(string.Empty, options.AppSecret);
|
||||||
|
Assert.False(options.IsPatreonConfigured);
|
||||||
|
Assert.Empty(_logs.Lines);
|
||||||
|
}
|
||||||
|
|
||||||
|
private TestWebApplicationFactory Host(string file) => new TestHost(file, _logs);
|
||||||
|
|
||||||
|
private sealed class TestHost(string file, CapturingLoggerProvider logs) : TestWebApplicationFactory
|
||||||
|
{
|
||||||
|
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||||
|
{
|
||||||
|
base.ConfigureWebHost(builder);
|
||||||
|
builder.ConfigureAppConfiguration((_, configuration) =>
|
||||||
|
configuration.AddInMemoryCollection(new Dictionary<string, string?> { ["Heimdall:AppSecretFile"] = file }));
|
||||||
|
builder.ConfigureLogging(logging => logging.AddProvider(logs));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void The_running_host_resolves_the_file_into_its_options()
|
||||||
|
{
|
||||||
|
using var host = Host(Write(FileSecret + "\n"));
|
||||||
|
|
||||||
|
Assert.Equal(FileSecret, host.Services.GetRequiredService<IOptions<HeimdallOptions>>().Value.AppSecret);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void The_running_host_refuses_to_start_on_a_missing_file_in_every_environment()
|
||||||
|
{
|
||||||
|
using var host = Host(Path.Combine(_directory, "absent"));
|
||||||
|
|
||||||
|
var failure = Assert.ThrowsAny<Exception>(() => host.CreateClient());
|
||||||
|
|
||||||
|
Assert.Contains("Heimdall:AppSecretFile", failure.ToString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -15,7 +15,7 @@ public sealed class HeimdallCompletePageScriptTests
|
||||||
|
|
||||||
private sealed record Run(string Name, string[] Events, Submit? Submit);
|
private sealed record Run(string Name, string[] Events, Submit? Submit);
|
||||||
|
|
||||||
private sealed record Submit(string Attempt, string Code, string Href, string Hash);
|
private sealed record Submit(string Attempt, string Code, string Href, string Hash, string PendingLinkId);
|
||||||
|
|
||||||
private static async Task<Dictionary<string, Run>> RunScenariosAsync()
|
private static async Task<Dictionary<string, Run>> RunScenariosAsync()
|
||||||
{
|
{
|
||||||
|
|
@ -81,7 +81,7 @@ public sealed class HeimdallCompletePageScriptTests
|
||||||
{
|
{
|
||||||
var runs = await RunScenariosAsync();
|
var runs = await RunScenariosAsync();
|
||||||
|
|
||||||
Assert.Equal(new Submit("NONCE", "CODE123", "/auth/heimdall/complete?attempt=NONCE", ""), runs["success"].Submit);
|
Assert.Equal(new Submit("NONCE", "CODE123", "/auth/heimdall/complete?attempt=NONCE", "", ""), runs["success"].Submit);
|
||||||
Assert.Equal("CODE123", runs["success-with-decoy-query-code"].Submit!.Code);
|
Assert.Equal("CODE123", runs["success-with-decoy-query-code"].Submit!.Code);
|
||||||
|
|
||||||
var queryOnly = runs["code-in-query-only"];
|
var queryOnly = runs["code-in-query-only"];
|
||||||
|
|
@ -90,6 +90,50 @@ public sealed class HeimdallCompletePageScriptTests
|
||||||
Assert.Equal(ErrorTarget, queryOnly.Events[^1]);
|
Assert.Equal(ErrorTarget, queryOnly.Events[^1]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_pending_link_id_is_read_only_from_the_fragment_and_posted_without_a_code()
|
||||||
|
{
|
||||||
|
var runs = await RunScenariosAsync();
|
||||||
|
|
||||||
|
foreach (var name in new[] { "link-return", "link-return-with-decoy-query-id" })
|
||||||
|
{
|
||||||
|
var run = runs[name];
|
||||||
|
Assert.Equal(new Submit("NONCE", "", run.Submit!.Href, "", "PEND1"), run.Submit);
|
||||||
|
Assert.DoesNotContain("PEND1", run.Submit.Href);
|
||||||
|
Assert.StartsWith("replaceState:", run.Events[0]);
|
||||||
|
Assert.DoesNotContain("PEND1", run.Events[0]);
|
||||||
|
Assert.DoesNotContain("#", run.Events[0]);
|
||||||
|
Assert.Equal("submit", run.Events[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.Null(runs["link-pending-id-in-query-only"].Submit);
|
||||||
|
Assert.Equal(ErrorTarget, runs["link-pending-id-in-query-only"].Events[^1]);
|
||||||
|
Assert.Null(runs["link-error-with-pending-id"].Submit);
|
||||||
|
Assert.Equal(ErrorTarget, runs["link-error-with-pending-id"].Events[^1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_link_refused_because_the_login_belongs_to_another_account_goes_to_the_page_with_a_fixed_result_and_submits_nothing()
|
||||||
|
{
|
||||||
|
var runs = await RunScenariosAsync();
|
||||||
|
const string Target = "replace:/Membership/LinkedAccounts?result=identity_linked_elsewhere";
|
||||||
|
|
||||||
|
var run = runs["link-refused-elsewhere"];
|
||||||
|
Assert.Null(run.Submit);
|
||||||
|
Assert.Equal(Target, run.Events[^1]);
|
||||||
|
Assert.DoesNotContain("submit", run.Events);
|
||||||
|
Assert.Contains(run.Events, x => x.StartsWith("replaceState:", StringComparison.Ordinal) && !x.Contains('#'));
|
||||||
|
|
||||||
|
// Without an attempt, from the query only, or for any other refusal text, it is the ordinary error exit.
|
||||||
|
foreach (var name in new[] { "link-refused-elsewhere-no-attempt", "other-refusal-text", "refusal-in-query-only" })
|
||||||
|
{
|
||||||
|
Assert.Null(runs[name].Submit);
|
||||||
|
Assert.Equal(ErrorTarget, runs[name].Events[^1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.DoesNotContain("EVIL", string.Join("\n", runs["other-refusal-text"].Events));
|
||||||
|
}
|
||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData("error-status-with-code")]
|
[InlineData("error-status-with-code")]
|
||||||
[InlineData("no-code")]
|
[InlineData("no-code")]
|
||||||
|
|
|
||||||
|
|
@ -1115,7 +1115,7 @@ public sealed class HeimdallCompletionTests
|
||||||
}
|
}
|
||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData(HttpStatusCode.InternalServerError, "{}", "Heimdall rejected the auth start.")]
|
[InlineData(HttpStatusCode.InternalServerError, "{}", "Heimdall rejected the auth start (HTTP 500).")]
|
||||||
[InlineData(HttpStatusCode.OK, "{}", "Heimdall auth start returned no authorization URL.")]
|
[InlineData(HttpStatusCode.OK, "{}", "Heimdall auth start returned no authorization URL.")]
|
||||||
[InlineData(HttpStatusCode.OK, "{\"authorizationUrl\":\" \"}", "Heimdall auth start returned no authorization URL.")]
|
[InlineData(HttpStatusCode.OK, "{\"authorizationUrl\":\" \"}", "Heimdall auth start returned no authorization URL.")]
|
||||||
public async Task A_start_Heimdall_cannot_serve_issues_no_cookie_and_a_fixed_refusal(HttpStatusCode status, string body, string reason)
|
public async Task A_start_Heimdall_cannot_serve_issues_no_cookie_and_a_fixed_refusal(HttpStatusCode status, string body, string reason)
|
||||||
|
|
@ -1132,6 +1132,71 @@ public sealed class HeimdallCompletionTests
|
||||||
Assert.Contains("Heimdall start refused: " + reason, string.Join("\n", host.Logs.Lines));
|
Assert.Contains("Heimdall start refused: " + reason, string.Join("\n", host.Logs.Lines));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(HttpStatusCode.Unauthorized, """{"error":"app_auth_required"}""", "Heimdall rejected the auth start (HTTP 401, app_auth_required).")]
|
||||||
|
[InlineData(HttpStatusCode.BadRequest, """{"error":"provider_not_supported_for_app","detail":"free text"}""", "Heimdall rejected the auth start (HTTP 400, provider_not_supported_for_app).")]
|
||||||
|
[InlineData(HttpStatusCode.Unauthorized, """{"error":"<b>Not a code</b> CANARY secret=abc"}""", "Heimdall rejected the auth start (HTTP 401).")]
|
||||||
|
[InlineData(HttpStatusCode.Unauthorized, """{"error":{"nested":"app_auth_required"}}""", "Heimdall rejected the auth start (HTTP 401).")]
|
||||||
|
[InlineData(HttpStatusCode.Unauthorized, "not json at all", "Heimdall rejected the auth start (HTTP 401).")]
|
||||||
|
public async Task A_refused_start_logs_the_status_and_a_well_formed_error_code_and_nothing_else_from_the_body(
|
||||||
|
HttpStatusCode status, string body, string reason)
|
||||||
|
{
|
||||||
|
using var host = new HeimdallHost();
|
||||||
|
host.Heimdall.StartStatus = status;
|
||||||
|
host.Heimdall.StartBody = body;
|
||||||
|
|
||||||
|
var attempt = await host.StartAsync(member: true);
|
||||||
|
|
||||||
|
Assert.Equal(ErrorRedirect, attempt.Response.Headers.Location!.OriginalString);
|
||||||
|
var logs = string.Join("\n", host.Logs.Lines);
|
||||||
|
Assert.Contains("Heimdall start refused: " + reason, logs);
|
||||||
|
Assert.DoesNotContain("CANARY", logs);
|
||||||
|
Assert.DoesNotContain("free text", logs);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_code_longer_than_sixty_four_characters_is_not_logged()
|
||||||
|
{
|
||||||
|
using var host = new HeimdallHost();
|
||||||
|
host.Heimdall.StartStatus = HttpStatusCode.Unauthorized;
|
||||||
|
host.Heimdall.StartBody = "{\"error\":\"" + new string('a', 65) + "\"}";
|
||||||
|
|
||||||
|
await host.StartAsync(member: true);
|
||||||
|
|
||||||
|
var logs = string.Join("\n", host.Logs.Lines);
|
||||||
|
Assert.Contains("Heimdall start refused: Heimdall rejected the auth start (HTTP 401).", logs);
|
||||||
|
Assert.DoesNotContain(new string('a', 65), logs);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("text/html; charset=windows-1252")]
|
||||||
|
[InlineData("text/html; charset=bogus")]
|
||||||
|
[InlineData("text/html; charset=utf-7")]
|
||||||
|
public async Task A_refusal_with_an_unusual_charset_still_redirects_with_the_status(string contentType)
|
||||||
|
{
|
||||||
|
using var host = new HeimdallHost();
|
||||||
|
host.Heimdall.StartStatus = HttpStatusCode.BadGateway;
|
||||||
|
host.Heimdall.StartContentType = contentType;
|
||||||
|
host.Heimdall.StartBody = "<html>bad gateway</html>";
|
||||||
|
|
||||||
|
var attempt = await host.StartAsync();
|
||||||
|
|
||||||
|
Assert.Equal(ErrorRedirect, attempt.Response.Headers.Location!.OriginalString);
|
||||||
|
Assert.Contains("Heimdall start refused: Heimdall rejected the auth start (HTTP 502).", string.Join("\n", host.Logs.Lines));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Only_the_head_of_a_refusal_body_is_read()
|
||||||
|
{
|
||||||
|
using var host = new HeimdallHost();
|
||||||
|
host.Heimdall.StartStatus = HttpStatusCode.Unauthorized;
|
||||||
|
host.Heimdall.StartBody = "{\"pad\":\"" + new string('x', 200_000) + "\",\"error\":\"app_auth_required\"}";
|
||||||
|
|
||||||
|
await host.StartAsync(member: true);
|
||||||
|
|
||||||
|
Assert.Contains("Heimdall start refused: Heimdall rejected the auth start (HTTP 401).", string.Join("\n", host.Logs.Lines));
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Refusals_echo_no_input_in_responses_or_logs()
|
public async Task Refusals_echo_no_input_in_responses_or_logs()
|
||||||
{
|
{
|
||||||
|
|
@ -1445,7 +1510,7 @@ public sealed class HeimdallCompletionTests
|
||||||
var inputs = Regex.Matches(form.Groups[2].Value, @"<input\b([^>]*)>");
|
var inputs = Regex.Matches(form.Groups[2].Value, @"<input\b([^>]*)>");
|
||||||
Assert.All(inputs, x => Assert.Contains("type=\"hidden\"", x.Groups[1].Value));
|
Assert.All(inputs, x => Assert.Contains("type=\"hidden\"", x.Groups[1].Value));
|
||||||
var names = inputs.Select(x => Regex.Match(x.Groups[1].Value, @"\bname=""([^""]*)""").Groups[1].Value).ToArray();
|
var names = inputs.Select(x => Regex.Match(x.Groups[1].Value, @"\bname=""([^""]*)""").Groups[1].Value).ToArray();
|
||||||
Assert.Equal(new[] { "attempt", "code" }, names);
|
Assert.Equal(new[] { "attempt", "code", "pendingLinkId" }, names);
|
||||||
|
|
||||||
// The page's own field names and action, filled as its script fills them, complete a sign-in.
|
// The page's own field names and action, filled as its script fills them, complete a sign-in.
|
||||||
var attempt = await host.StartAsync();
|
var attempt = await host.StartAsync();
|
||||||
|
|
|
||||||
604
tests/Bifrost.Web.Tests/HeimdallGoogleLinkTests.cs
Normal file
604
tests/Bifrost.Web.Tests/HeimdallGoogleLinkTests.cs
Normal file
|
|
@ -0,0 +1,604 @@
|
||||||
|
using System.Net;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Bifrost.Web.Domain;
|
||||||
|
using Bifrost.Web.Features.Heimdall;
|
||||||
|
using Bifrost.Web.Features.Shared;
|
||||||
|
using Bifrost.Web.Tests.Support;
|
||||||
|
using Microsoft.AspNetCore.DataProtection;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
|
namespace Bifrost.Web.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Attaching a Google login to a signed-in Heimdall account by explicit link. Bifrost keeps no Heimdall token: each
|
||||||
|
/// Heimdall call that needs a bearer follows a fresh Heimdall sign-in, and the bearer lives inside that one request.
|
||||||
|
/// The attempt cookie carries only a purpose, the expected account and a pending link id.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class HeimdallGoogleLinkTests
|
||||||
|
{
|
||||||
|
private const string LinkedPage = "/Membership/LinkedAccounts";
|
||||||
|
private const string ErrorRedirect = "/?auth=heimdall-error";
|
||||||
|
|
||||||
|
private sealed record Step(HttpResponseMessage Response, StartedAttempt Next, string Token);
|
||||||
|
|
||||||
|
private static string Jar(string session, StartedAttempt attempt) =>
|
||||||
|
$"{BifrostCookies.Session}={session}; {attempt.CookieName}={attempt.Cookie}";
|
||||||
|
|
||||||
|
private static string? Location(HttpResponseMessage response) => response.Headers.Location?.OriginalString;
|
||||||
|
|
||||||
|
/// <summary>B: the fresh sign-in of a LinkStart attempt completes; Bifrost asks Heimdall for the Google link.</summary>
|
||||||
|
private static async Task<Step> StepB(HeimdallHost host, string session, StartedAttempt start, string account, string? token = null)
|
||||||
|
{
|
||||||
|
token ??= host.Token(account);
|
||||||
|
var code = host.CodeFor(start, account, token);
|
||||||
|
var before = host.Heimdall.StartBodies.Count;
|
||||||
|
var response = await host.PostCompleteAsync(
|
||||||
|
new Dictionary<string, string> { ["attempt"] = start.Nonce, ["code"] = code }, Jar(session, start));
|
||||||
|
return new Step(response, host.AttemptFrom(response, before), token);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>C: the browser is back from Google with a pending link id; Bifrost starts a fresh sign-in.</summary>
|
||||||
|
private static async Task<Step> StepC(HeimdallHost host, string session, StartedAttempt awaiting, string pendingLinkId)
|
||||||
|
{
|
||||||
|
var before = host.Heimdall.StartBodies.Count;
|
||||||
|
var response = await host.PostCompleteAsync(
|
||||||
|
new Dictionary<string, string> { ["attempt"] = awaiting.Nonce, ["pendingLinkId"] = pendingLinkId }, Jar(session, awaiting));
|
||||||
|
return new Step(response, host.AttemptFrom(response, before), string.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>D: the fresh sign-in of a LinkConfirm attempt completes; Bifrost confirms the link.</summary>
|
||||||
|
private static async Task<Step> StepD(HeimdallHost host, string session, StartedAttempt confirming, string account, string? token = null)
|
||||||
|
{
|
||||||
|
token ??= host.Token(account);
|
||||||
|
var code = host.CodeFor(confirming, account, token);
|
||||||
|
var response = await host.PostCompleteAsync(
|
||||||
|
new Dictionary<string, string> { ["attempt"] = confirming.Nonce, ["code"] = code }, Jar(session, confirming));
|
||||||
|
return new Step(response, confirming, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record Journey(string Account, string Session, StartedAttempt Start, Step B, string PendingLinkId, Step C);
|
||||||
|
|
||||||
|
/// <summary>Everything up to the LinkConfirm attempt, waiting for the fresh sign-in's code.</summary>
|
||||||
|
private static async Task<Journey> ThroughC(HeimdallHost host, string? pendingLinkId = null)
|
||||||
|
{
|
||||||
|
var account = HeimdallShapes.NewAccountId();
|
||||||
|
var session = await host.SignInSessionAsync(account);
|
||||||
|
var start = await host.StartLinkAsync(session);
|
||||||
|
var b = await StepB(host, session, start, account);
|
||||||
|
var pending = host.Heimdall.RegisterPendingLink(pendingLinkId);
|
||||||
|
var c = await StepC(host, session, b.Next, pending);
|
||||||
|
return new Journey(account, session, start, b, pending, c);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Unprotect(HeimdallHost host, string cookie) =>
|
||||||
|
host.Services.GetRequiredService<IDataProtectionProvider>().CreateProtector("Bifrost.Heimdall.Attempt.v1").Unprotect(cookie);
|
||||||
|
|
||||||
|
// ---- 1. the link start carries a fresh bearer ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task The_account_page_starts_an_ordinary_sign_in_and_the_link_start_follows_with_the_fresh_bearer()
|
||||||
|
{
|
||||||
|
using var host = new HeimdallHost(realSessions: true);
|
||||||
|
var account = HeimdallShapes.NewAccountId();
|
||||||
|
var session = await host.SignInSessionAsync(account);
|
||||||
|
var signInStarts = host.Heimdall.StartBodies.Count;
|
||||||
|
|
||||||
|
var start = await host.StartLinkAsync(session);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.Redirect, start.Response.StatusCode);
|
||||||
|
Assert.Equal(signInStarts + 1, host.Heimdall.StartBodies.Count);
|
||||||
|
using (var body = JsonDocument.Parse(host.Heimdall.StartBodies[^1]))
|
||||||
|
{
|
||||||
|
Assert.Equal("sign_in", body.RootElement.GetProperty("mode").GetString());
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.Equal("/v1/oauth/discord/start", host.Heimdall.StartPaths[^1]);
|
||||||
|
Assert.Null(host.Heimdall.StartAuthorizations[^1]);
|
||||||
|
Assert.NotNull(start.Cookie);
|
||||||
|
|
||||||
|
var b = await StepB(host, session, start, account);
|
||||||
|
|
||||||
|
Assert.Equal(signInStarts + 2, host.Heimdall.StartBodies.Count);
|
||||||
|
Assert.Equal("/v1/oauth/google/start", host.Heimdall.StartPaths[^1]);
|
||||||
|
Assert.Equal("Bearer " + b.Token, host.Heimdall.StartAuthorizations[^1]);
|
||||||
|
using var link = JsonDocument.Parse(host.Heimdall.StartBodies[^1]);
|
||||||
|
Assert.Equal("link", link.RootElement.GetProperty("mode").GetString());
|
||||||
|
Assert.Equal("bifrost", link.RootElement.GetProperty("appSlug").GetString());
|
||||||
|
Assert.Equal("browser_completion", link.RootElement.GetProperty("handoff").GetProperty("kind").GetString());
|
||||||
|
Assert.StartsWith("https://localhost/auth/heimdall/complete?attempt=", link.RootElement.GetProperty("returnTo").GetString());
|
||||||
|
Assert.NotEqual(start.Nonce, b.Next.Nonce);
|
||||||
|
Assert.Equal("https://heimdall.test/oauth/google/authorize?state=link-fixture", Location(b.Response));
|
||||||
|
// The sign-in session is untouched: the link flow never replaces who is signed in.
|
||||||
|
Assert.Null(HeimdallHost.CookieValue(b.Response, BifrostCookies.Session));
|
||||||
|
Assert.Equal(HttpStatusCode.Redirect, b.Response.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_link_start_attempt_without_a_code_asks_Heimdall_for_nothing()
|
||||||
|
{
|
||||||
|
using var host = new HeimdallHost(realSessions: true);
|
||||||
|
var account = HeimdallShapes.NewAccountId();
|
||||||
|
var session = await host.SignInSessionAsync(account);
|
||||||
|
var start = await host.StartLinkAsync(session);
|
||||||
|
var before = host.Heimdall.StartBodies.Count;
|
||||||
|
|
||||||
|
var response = await host.PostCompleteAsync(
|
||||||
|
new Dictionary<string, string> { ["attempt"] = start.Nonce }, Jar(session, start));
|
||||||
|
|
||||||
|
Assert.Equal(before, host.Heimdall.StartBodies.Count);
|
||||||
|
Assert.Equal(LinkedPage + "?result=error", Location(response));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_link_start_attempt_cannot_sign_in_a_session()
|
||||||
|
{
|
||||||
|
using var host = new HeimdallHost(realSessions: true);
|
||||||
|
var account = HeimdallShapes.NewAccountId();
|
||||||
|
var session = await host.SignInSessionAsync(account);
|
||||||
|
var start = await host.StartLinkAsync(session);
|
||||||
|
var other = HeimdallShapes.NewAccountId();
|
||||||
|
|
||||||
|
var done = await host.CompleteAsync(start, host.CodeFor(start, other));
|
||||||
|
|
||||||
|
Assert.False(done.SignedIn);
|
||||||
|
Assert.Null(await host.Account(other));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 2. only a signed-in user, only by POST with antiforgery ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Without_a_session_a_link_post_starts_nothing()
|
||||||
|
{
|
||||||
|
using var host = new HeimdallHost(realSessions: true);
|
||||||
|
var before = host.Heimdall.StartBodies.Count;
|
||||||
|
|
||||||
|
var start = await host.StartLinkAsync(session: null);
|
||||||
|
|
||||||
|
Assert.Equal(before, host.Heimdall.StartBodies.Count);
|
||||||
|
Assert.Null(start.Cookie);
|
||||||
|
Assert.NotEqual(HttpStatusCode.OK, start.Response.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_get_never_starts_a_link()
|
||||||
|
{
|
||||||
|
using var host = new HeimdallHost(realSessions: true);
|
||||||
|
var session = await host.SignInSessionAsync(HeimdallShapes.NewAccountId());
|
||||||
|
var before = host.Heimdall.StartBodies.Count;
|
||||||
|
|
||||||
|
var start = await host.StartLinkAsync(session, method: HttpMethod.Get);
|
||||||
|
|
||||||
|
Assert.Equal(before, host.Heimdall.StartBodies.Count);
|
||||||
|
Assert.Null(start.Cookie);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_link_post_without_the_antiforgery_token_starts_nothing()
|
||||||
|
{
|
||||||
|
using var host = new HeimdallHost(realSessions: true);
|
||||||
|
var session = await host.SignInSessionAsync(HeimdallShapes.NewAccountId());
|
||||||
|
var before = host.Heimdall.StartBodies.Count;
|
||||||
|
|
||||||
|
var start = await host.StartLinkAsync(session, antiforgery: false);
|
||||||
|
|
||||||
|
Assert.Equal(before, host.Heimdall.StartBodies.Count);
|
||||||
|
Assert.Null(start.Cookie);
|
||||||
|
Assert.Equal(HttpStatusCode.BadRequest, start.Response.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 3. a browser that is not the one that began the link ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_foreign_browser_holding_a_pending_link_id_confirms_nothing_and_calls_Heimdall_zero_times()
|
||||||
|
{
|
||||||
|
using var host = new HeimdallHost(realSessions: true);
|
||||||
|
var journey = await ThroughC(host);
|
||||||
|
var redeems = host.Heimdall.RedeemCalls;
|
||||||
|
var starts = host.Heimdall.StartBodies.Count;
|
||||||
|
|
||||||
|
// No cookie at all, and a session of its own; then an unrelated sign-in attempt's cookie.
|
||||||
|
var strangerAccount = HeimdallShapes.NewAccountId();
|
||||||
|
var stranger = await host.SignInSessionAsync(strangerAccount);
|
||||||
|
redeems = host.Heimdall.RedeemCalls;
|
||||||
|
starts = host.Heimdall.StartBodies.Count;
|
||||||
|
var noCookie = await host.PostCompleteAsync(
|
||||||
|
new Dictionary<string, string> { ["attempt"] = journey.B.Next.Nonce, ["pendingLinkId"] = journey.PendingLinkId },
|
||||||
|
$"{BifrostCookies.Session}={stranger}");
|
||||||
|
var unrelated = await host.StartAsync();
|
||||||
|
starts = host.Heimdall.StartBodies.Count;
|
||||||
|
var wrongCookie = await host.PostCompleteAsync(
|
||||||
|
new Dictionary<string, string> { ["attempt"] = journey.B.Next.Nonce, ["pendingLinkId"] = journey.PendingLinkId },
|
||||||
|
$"{BifrostCookies.Session}={stranger}; {unrelated.CookieName}={unrelated.Cookie}");
|
||||||
|
var forgedCode = host.CodeFor(journey.C.Next, strangerAccount);
|
||||||
|
var confirmWithoutCookie = await host.PostCompleteAsync(
|
||||||
|
new Dictionary<string, string> { ["attempt"] = journey.C.Next.Nonce, ["code"] = forgedCode },
|
||||||
|
$"{BifrostCookies.Session}={stranger}");
|
||||||
|
|
||||||
|
Assert.Equal(starts, host.Heimdall.StartBodies.Count);
|
||||||
|
Assert.Equal(redeems, host.Heimdall.RedeemCalls);
|
||||||
|
Assert.Empty(host.Heimdall.ConfirmBodies);
|
||||||
|
foreach (var response in new[] { noCookie, wrongCookie, confirmWithoutCookie })
|
||||||
|
{
|
||||||
|
Assert.Equal(HttpStatusCode.Redirect, response.StatusCode);
|
||||||
|
Assert.DoesNotContain("Bearer", Location(response));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 4. a different Heimdall account ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Completion_for_a_different_account_than_the_session_is_refused_with_no_link_call()
|
||||||
|
{
|
||||||
|
using var host = new HeimdallHost(realSessions: true);
|
||||||
|
var account = HeimdallShapes.NewAccountId();
|
||||||
|
var session = await host.SignInSessionAsync(account);
|
||||||
|
var start = await host.StartLinkAsync(session);
|
||||||
|
var starts = host.Heimdall.StartBodies.Count;
|
||||||
|
|
||||||
|
var b = await StepB(host, session, start, HeimdallShapes.NewAccountId());
|
||||||
|
|
||||||
|
Assert.Equal(starts, host.Heimdall.StartBodies.Count);
|
||||||
|
Assert.Equal(LinkedPage + "?result=error", Location(b.Response));
|
||||||
|
AssertAttemptCleared(b.Response, start);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_session_of_another_account_cannot_continue_the_link()
|
||||||
|
{
|
||||||
|
using var host = new HeimdallHost(realSessions: true);
|
||||||
|
var account = HeimdallShapes.NewAccountId();
|
||||||
|
var session = await host.SignInSessionAsync(account);
|
||||||
|
var start = await host.StartLinkAsync(session);
|
||||||
|
var intruder = HeimdallShapes.NewAccountId();
|
||||||
|
var intruderSession = await host.SignInSessionAsync(intruder);
|
||||||
|
var starts = host.Heimdall.StartBodies.Count;
|
||||||
|
var redeems = host.Heimdall.RedeemCalls;
|
||||||
|
|
||||||
|
var b = await StepB(host, intruderSession, start, intruder);
|
||||||
|
|
||||||
|
Assert.Equal(starts, host.Heimdall.StartBodies.Count);
|
||||||
|
Assert.Equal(redeems, host.Heimdall.RedeemCalls);
|
||||||
|
Assert.Equal(LinkedPage + "?result=error", Location(b.Response));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_confirming_sign_in_for_another_account_confirms_nothing()
|
||||||
|
{
|
||||||
|
using var host = new HeimdallHost(realSessions: true);
|
||||||
|
var journey = await ThroughC(host);
|
||||||
|
var other = HeimdallShapes.NewAccountId();
|
||||||
|
|
||||||
|
var d = await StepD(host, journey.Session, journey.C.Next, other);
|
||||||
|
|
||||||
|
Assert.Empty(host.Heimdall.ConfirmBodies);
|
||||||
|
Assert.Equal(LinkedPage + "?result=error", Location(d.Response));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AssertAttemptCleared(HttpResponseMessage response, StartedAttempt attempt)
|
||||||
|
{
|
||||||
|
var header = HeimdallHost.SetCookieHeader(response, attempt.CookieName!);
|
||||||
|
Assert.NotNull(header);
|
||||||
|
Assert.Contains("1970", header);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 5. outcomes ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Success_confirms_once_with_the_pending_link_id_and_a_fresh_bearer_and_shows_linked()
|
||||||
|
{
|
||||||
|
using var host = new HeimdallHost(realSessions: true);
|
||||||
|
var journey = await ThroughC(host);
|
||||||
|
Assert.Empty(host.Heimdall.ConfirmBodies);
|
||||||
|
Assert.Equal(HttpStatusCode.Redirect, journey.C.Response.StatusCode);
|
||||||
|
Assert.Equal("/v1/oauth/discord/start", host.Heimdall.StartPaths[^1]);
|
||||||
|
Assert.Null(host.Heimdall.StartAuthorizations[^1]);
|
||||||
|
|
||||||
|
var d = await StepD(host, journey.Session, journey.C.Next, journey.Account);
|
||||||
|
|
||||||
|
Assert.Equal(LinkedPage + "?result=linked", Location(d.Response));
|
||||||
|
Assert.Single(host.Heimdall.ConfirmBodies);
|
||||||
|
using var body = JsonDocument.Parse(host.Heimdall.ConfirmBodies[0]);
|
||||||
|
Assert.Equal(journey.PendingLinkId, body.RootElement.GetProperty("pendingLinkId").GetString());
|
||||||
|
Assert.Equal("Bearer " + d.Token, host.Heimdall.ConfirmAuthorizations[0]);
|
||||||
|
Assert.NotEqual("Bearer " + journey.B.Token, host.Heimdall.ConfirmAuthorizations[0]);
|
||||||
|
AssertAttemptCleared(d.Response, journey.C.Next);
|
||||||
|
Assert.Null(HeimdallHost.CookieValue(d.Response, BifrostCookies.Session));
|
||||||
|
var account = await host.Account(journey.Account);
|
||||||
|
Assert.Equal(MembershipStatus.Authenticated, account!.Membership!.Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(HttpStatusCode.Conflict, "identity_linked_elsewhere")]
|
||||||
|
[InlineData(HttpStatusCode.Gone, "expired")]
|
||||||
|
[InlineData(HttpStatusCode.Unauthorized, "error")]
|
||||||
|
[InlineData(HttpStatusCode.InternalServerError, "error")]
|
||||||
|
public async Task A_refused_confirm_redirects_with_its_own_fixed_outcome(HttpStatusCode status, string outcome)
|
||||||
|
{
|
||||||
|
using var host = new HeimdallHost(realSessions: true);
|
||||||
|
var journey = await ThroughC(host);
|
||||||
|
host.Heimdall.ConfirmStatus = status;
|
||||||
|
|
||||||
|
var d = await StepD(host, journey.Session, journey.C.Next, journey.Account);
|
||||||
|
|
||||||
|
Assert.Equal($"{LinkedPage}?result={outcome}", Location(d.Response));
|
||||||
|
Assert.DoesNotContain("HEIMDALL FREE TEXT", string.Join("\n", host.Logs.Lines));
|
||||||
|
AssertAttemptCleared(d.Response, journey.C.Next);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task The_account_page_words_each_outcome_from_a_closed_set_and_ignores_anything_else()
|
||||||
|
{
|
||||||
|
using var host = new HeimdallHost(realSessions: true);
|
||||||
|
var session = await host.SignInSessionAsync(HeimdallShapes.NewAccountId());
|
||||||
|
using var client = host.NewClient();
|
||||||
|
|
||||||
|
async Task<string> Page(string link)
|
||||||
|
{
|
||||||
|
using var request = new HttpRequestMessage(HttpMethod.Get, LinkedPage + "?result=" + Uri.EscapeDataString(link));
|
||||||
|
request.Headers.Add("Cookie", $"{BifrostCookies.Session}={session}");
|
||||||
|
return await (await client.SendAsync(request)).Content.ReadAsStringAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.Contains("now linked to your Heimdall account", await Page("linked"));
|
||||||
|
Assert.Contains("belongs to another account", await Page("identity_linked_elsewhere"));
|
||||||
|
Assert.Contains("expired before it was confirmed", await Page("expired"));
|
||||||
|
Assert.Contains("could not be completed", await Page("error"));
|
||||||
|
var hostile = await Page("<script>alert(1)</script>");
|
||||||
|
Assert.DoesNotContain("alert(1)", hostile);
|
||||||
|
Assert.DoesNotContain("now linked to your", hostile);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 6. google stays out of sign-in and member access ----
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("/auth/heimdall/google")]
|
||||||
|
[InlineData("/auth/heimdall/google?access=member")]
|
||||||
|
[InlineData("/auth/heimdall/Google")]
|
||||||
|
public async Task Google_is_refused_as_a_sign_in_provider_and_asks_Heimdall_for_nothing(string url)
|
||||||
|
{
|
||||||
|
using var host = new HeimdallHost(realSessions: true);
|
||||||
|
|
||||||
|
var start = await host.StartAtAsync(url);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.Redirect, start.Response.StatusCode);
|
||||||
|
Assert.Equal(ErrorRedirect, Location(start.Response));
|
||||||
|
Assert.Empty(host.Heimdall.StartBodies);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 7. nothing secret leaves the request ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task No_heimdall_token_or_pending_link_id_reaches_a_log_a_cookie_or_a_location()
|
||||||
|
{
|
||||||
|
using var host = new HeimdallHost(traceHttp: true, realSessions: true);
|
||||||
|
const string pendingCanary = "PENDING-CANARY-7f3a";
|
||||||
|
var account = HeimdallShapes.NewAccountId();
|
||||||
|
var session = await host.SignInSessionAsync(account);
|
||||||
|
var start = await host.StartLinkAsync(session);
|
||||||
|
var b = await StepB(host, session, start, account);
|
||||||
|
var pending = host.Heimdall.RegisterPendingLink(pendingCanary);
|
||||||
|
var c = await StepC(host, session, b.Next, pending);
|
||||||
|
var d = await StepD(host, session, c.Next, account);
|
||||||
|
Assert.Equal(LinkedPage + "?result=linked", Location(d.Response));
|
||||||
|
|
||||||
|
var tokens = new[] { b.Token, d.Token }
|
||||||
|
.SelectMany(token => new[] { token, token.Split('.')[2], token.Split('.')[1] })
|
||||||
|
.ToArray();
|
||||||
|
var logs = string.Join("\n", host.Logs.Lines);
|
||||||
|
Assert.Contains("Bearer", string.Join("\n", host.Heimdall.StartAuthorizations.Where(x => x is not null)));
|
||||||
|
foreach (var secret in tokens.Append(pendingCanary))
|
||||||
|
{
|
||||||
|
Assert.DoesNotContain(secret, logs);
|
||||||
|
foreach (var step in new[] { start.Response, b.Response, c.Response, d.Response })
|
||||||
|
{
|
||||||
|
Assert.DoesNotContain(secret, Location(step) ?? string.Empty);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every attempt cookie Bifrost set along the way, unprotected: purpose, account and pending id only.
|
||||||
|
foreach (var cookie in new[] { start.Cookie, b.Next.Cookie, c.Next.Cookie })
|
||||||
|
{
|
||||||
|
var payload = Unprotect(host, cookie!);
|
||||||
|
foreach (var token in tokens)
|
||||||
|
{
|
||||||
|
Assert.DoesNotContain(token, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.DoesNotContain("eyJ", payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.DoesNotContain(pendingCanary, Unprotect(host, start.Cookie!));
|
||||||
|
Assert.DoesNotContain(pendingCanary, Unprotect(host, b.Next.Cookie!));
|
||||||
|
Assert.Contains(account, Unprotect(host, b.Next.Cookie!));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 8. old attempt cookies keep signing in ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task An_attempt_cookie_written_before_purposes_existed_still_completes_a_normal_sign_in()
|
||||||
|
{
|
||||||
|
using var host = new HeimdallHost(realSessions: true);
|
||||||
|
var nonce = "legacy-nonce-" + Guid.NewGuid().ToString("N");
|
||||||
|
var protector = host.Services.GetRequiredService<IDataProtectionProvider>().CreateProtector("Bifrost.Heimdall.Attempt.v1");
|
||||||
|
var legacy = protector.Protect(JsonSerializer.Serialize(new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
["Nonce"] = nonce,
|
||||||
|
["Provider"] = "discord",
|
||||||
|
["RequiresMemberAccess"] = false,
|
||||||
|
["ReturnPath"] = "/App/Mill",
|
||||||
|
["ExpiresAt"] = host.Clock.Now.AddMinutes(5)
|
||||||
|
}));
|
||||||
|
var account = HeimdallShapes.NewAccountId();
|
||||||
|
var attempt = new StartedAttempt(
|
||||||
|
nonce, HeimdallAttemptCookie.SlotNames[0], legacy, $"https://localhost/auth/heimdall/complete?attempt={nonce}", null!);
|
||||||
|
|
||||||
|
var done = await host.CompleteAsync(attempt, host.CodeFor(attempt, account));
|
||||||
|
|
||||||
|
Assert.True(done.SignedIn);
|
||||||
|
Assert.Equal("/App/Mill", Location(done.Response));
|
||||||
|
Assert.NotNull(await host.Account(account));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 9. attempts are single use ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Replaying_a_consumed_link_start_attempt_does_nothing()
|
||||||
|
{
|
||||||
|
using var host = new HeimdallHost(realSessions: true);
|
||||||
|
var account = HeimdallShapes.NewAccountId();
|
||||||
|
var session = await host.SignInSessionAsync(account);
|
||||||
|
var start = await host.StartLinkAsync(session);
|
||||||
|
var code = host.CodeFor(start, account);
|
||||||
|
var fields = new Dictionary<string, string> { ["attempt"] = start.Nonce, ["code"] = code };
|
||||||
|
var first = await host.PostCompleteAsync(fields, Jar(session, start));
|
||||||
|
Assert.Contains("google", Location(first));
|
||||||
|
var starts = host.Heimdall.StartBodies.Count;
|
||||||
|
|
||||||
|
var replay = await host.PostCompleteAsync(fields, Jar(session, start));
|
||||||
|
|
||||||
|
Assert.Equal(starts, host.Heimdall.StartBodies.Count);
|
||||||
|
Assert.Equal(LinkedPage + "?result=error", Location(replay));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Replaying_a_consumed_confirm_attempt_confirms_nothing_twice()
|
||||||
|
{
|
||||||
|
using var host = new HeimdallHost(realSessions: true);
|
||||||
|
var journey = await ThroughC(host);
|
||||||
|
var code = host.CodeFor(journey.C.Next, journey.Account);
|
||||||
|
var fields = new Dictionary<string, string> { ["attempt"] = journey.C.Next.Nonce, ["code"] = code };
|
||||||
|
var first = await host.PostCompleteAsync(fields, Jar(journey.Session, journey.C.Next));
|
||||||
|
Assert.Equal(LinkedPage + "?result=linked", Location(first));
|
||||||
|
|
||||||
|
var replay = await host.PostCompleteAsync(fields, Jar(journey.Session, journey.C.Next));
|
||||||
|
|
||||||
|
Assert.Single(host.Heimdall.ConfirmBodies);
|
||||||
|
Assert.Equal(LinkedPage + "?result=error", Location(replay));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Replaying_a_consumed_await_attempt_links_nothing()
|
||||||
|
{
|
||||||
|
using var host = new HeimdallHost(realSessions: true);
|
||||||
|
var journey = await ThroughC(host);
|
||||||
|
var again = await StepC(host, journey.Session, journey.B.Next, journey.PendingLinkId);
|
||||||
|
|
||||||
|
// The replay can only begin another fresh sign-in; the pending id is spent at Heimdall by the first confirm.
|
||||||
|
var d = await StepD(host, journey.Session, again.Next, journey.Account);
|
||||||
|
var replayed = await StepD(host, journey.Session, journey.C.Next, journey.Account);
|
||||||
|
|
||||||
|
Assert.Equal(LinkedPage + "?result=linked", Location(d.Response));
|
||||||
|
Assert.Equal(LinkedPage + "?result=expired", Location(replayed.Response));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 10. pins for guards the first pass left unproven ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_link_continues_in_its_own_slot_and_leaves_a_parallel_sign_in_attempt_alive()
|
||||||
|
{
|
||||||
|
using var host = new HeimdallHost(realSessions: true);
|
||||||
|
var account = HeimdallShapes.NewAccountId();
|
||||||
|
var session = await host.SignInSessionAsync(account);
|
||||||
|
var parallel = await host.StartAsync();
|
||||||
|
var parallelCookie = $"{parallel.CookieName}={parallel.Cookie}";
|
||||||
|
var start = await host.StartLinkAsync(session, extraCookies: parallelCookie);
|
||||||
|
// The parallel attempt holds slot 0, so the link lives in the other slot.
|
||||||
|
Assert.Equal(HeimdallAttemptCookie.SlotNames[0], parallel.CookieName);
|
||||||
|
Assert.Equal(HeimdallAttemptCookie.SlotNames[1], start.CookieName);
|
||||||
|
|
||||||
|
var b = await StepB(host, session, start, account);
|
||||||
|
var awaiting = new Step(b.Response, b.Next, b.Token);
|
||||||
|
var pending = host.Heimdall.RegisterPendingLink();
|
||||||
|
var c = await StepC(host, session, awaiting.Next, pending);
|
||||||
|
var d = await StepD(host, session, c.Next, account);
|
||||||
|
|
||||||
|
// Every continuation wrote the link's own slot and none touched the parallel attempt's slot.
|
||||||
|
foreach (var response in new[] { b.Response, c.Response })
|
||||||
|
{
|
||||||
|
Assert.NotNull(HeimdallHost.SetCookieHeader(response, start.CookieName!));
|
||||||
|
Assert.Null(HeimdallHost.SetCookieHeader(response, parallel.CookieName!));
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.Equal(start.CookieName, b.Next.CookieName);
|
||||||
|
Assert.Equal(start.CookieName, c.Next.CookieName);
|
||||||
|
Assert.Equal(LinkedPage + "?result=linked", Location(d.Response));
|
||||||
|
Assert.Null(HeimdallHost.SetCookieHeader(d.Response, parallel.CookieName!));
|
||||||
|
|
||||||
|
var signIn = await host.CompleteWithHeaderAsync(
|
||||||
|
parallel.Nonce, host.CodeFor(parallel, HeimdallShapes.NewAccountId()), parallelCookie);
|
||||||
|
Assert.True(signIn.SignedIn);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("9")]
|
||||||
|
[InlineData("-1")]
|
||||||
|
[InlineData("99")]
|
||||||
|
public async Task An_attempt_cookie_with_an_undefined_purpose_is_no_attempt_and_asks_Heimdall_for_nothing(string purpose)
|
||||||
|
{
|
||||||
|
using var host = new HeimdallHost(realSessions: true);
|
||||||
|
var account = HeimdallShapes.NewAccountId();
|
||||||
|
var session = await host.SignInSessionAsync(account);
|
||||||
|
var nonce = "crafted-" + Guid.NewGuid().ToString("N");
|
||||||
|
var protector = host.Services.GetRequiredService<IDataProtectionProvider>().CreateProtector("Bifrost.Heimdall.Attempt.v1");
|
||||||
|
var crafted = protector.Protect(
|
||||||
|
$$"""{"Nonce":"{{nonce}}","Provider":"discord","RequiresMemberAccess":false,"ReturnPath":"/App/Mill","ExpiresAt":"{{host.Clock.Now.AddMinutes(5):O}}","Purpose":{{purpose}},"ExpectedAccountId":"{{account}}","PendingLinkId":"p"}""");
|
||||||
|
var attempt = new StartedAttempt(
|
||||||
|
nonce, HeimdallAttemptCookie.SlotNames[0], crafted, $"https://localhost/auth/heimdall/complete?attempt={nonce}", null!);
|
||||||
|
var starts = host.Heimdall.StartBodies.Count;
|
||||||
|
var redeems = host.Heimdall.RedeemCalls;
|
||||||
|
|
||||||
|
var response = await host.PostCompleteAsync(
|
||||||
|
new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["attempt"] = nonce, ["code"] = host.CodeFor(attempt, account), ["pendingLinkId"] = "p"
|
||||||
|
},
|
||||||
|
Jar(session, attempt));
|
||||||
|
|
||||||
|
Assert.Equal(ErrorRedirect, Location(response));
|
||||||
|
Assert.Equal(starts, host.Heimdall.StartBodies.Count);
|
||||||
|
Assert.Equal(redeems, host.Heimdall.RedeemCalls);
|
||||||
|
Assert.Empty(host.Heimdall.ConfirmBodies);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("")]
|
||||||
|
[InlineData(" ")]
|
||||||
|
public async Task An_await_step_without_a_pending_link_id_is_refused_before_Heimdall_hears_of_it(string pendingLinkId)
|
||||||
|
{
|
||||||
|
using var host = new HeimdallHost(realSessions: true);
|
||||||
|
var account = HeimdallShapes.NewAccountId();
|
||||||
|
var session = await host.SignInSessionAsync(account);
|
||||||
|
var start = await host.StartLinkAsync(session);
|
||||||
|
var b = await StepB(host, session, start, account);
|
||||||
|
var starts = host.Heimdall.StartBodies.Count;
|
||||||
|
var redeems = host.Heimdall.RedeemCalls;
|
||||||
|
|
||||||
|
var c = await StepC(host, session, b.Next, pendingLinkId);
|
||||||
|
|
||||||
|
Assert.Equal(LinkedPage + "?result=error", Location(c.Response));
|
||||||
|
Assert.Equal(starts, host.Heimdall.StartBodies.Count);
|
||||||
|
Assert.Equal(redeems, host.Heimdall.RedeemCalls);
|
||||||
|
Assert.Empty(host.Heimdall.ConfirmBodies);
|
||||||
|
AssertAttemptCleared(c.Response, b.Next);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("")]
|
||||||
|
[InlineData("{}")]
|
||||||
|
[InlineData("""{"status":"pending"}""")]
|
||||||
|
[InlineData("""{"status":"Linked"}""")]
|
||||||
|
[InlineData("not json")]
|
||||||
|
public async Task A_201_confirm_that_does_not_say_linked_is_not_shown_as_linked(string body)
|
||||||
|
{
|
||||||
|
using var host = new HeimdallHost(realSessions: true);
|
||||||
|
var journey = await ThroughC(host);
|
||||||
|
host.Heimdall.ConfirmStatus = HttpStatusCode.Created;
|
||||||
|
host.Heimdall.ConfirmBody = body;
|
||||||
|
|
||||||
|
var d = await StepD(host, journey.Session, journey.C.Next, journey.Account);
|
||||||
|
|
||||||
|
Assert.Equal(LinkedPage + "?result=error", Location(d.Response));
|
||||||
|
Assert.Single(host.Heimdall.ConfirmBodies);
|
||||||
|
AssertAttemptCleared(d.Response, journey.C.Next);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -145,6 +145,36 @@ public sealed class HostTrustTests : IClassFixture<TestWebApplicationFactory>
|
||||||
Assert.DoesNotContain(canary, refusal.Message);
|
Assert.DoesNotContain(canary, refusal.Message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("")]
|
||||||
|
[InlineData(" ")]
|
||||||
|
public void Production_without_a_heimdall_app_secret_refuses_startup_naming_the_options(string blank)
|
||||||
|
{
|
||||||
|
var refusal = Assert.Throws<InvalidOperationException>(
|
||||||
|
() => Validate(keysPath: "/keys", appSecret: blank));
|
||||||
|
|
||||||
|
Assert.Contains("Heimdall:AppSecretFile", refusal.Message);
|
||||||
|
Assert.Contains("Heimdall:AppSecret", refusal.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Production_with_a_heimdall_app_secret_passes_and_other_refusals_never_echo_it()
|
||||||
|
{
|
||||||
|
Validate(keysPath: "/keys", appSecret: ValidAppSecret);
|
||||||
|
|
||||||
|
var refusal = Assert.Throws<InvalidOperationException>(
|
||||||
|
() => Validate(keysPath: null, appSecret: ValidAppSecret));
|
||||||
|
Assert.DoesNotContain(ValidAppSecret, refusal.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("Development")]
|
||||||
|
[InlineData("Testing")]
|
||||||
|
public void Development_and_Testing_start_without_a_heimdall_app_secret(string environment)
|
||||||
|
{
|
||||||
|
Validate(keysPath: null, environment: environment, appSecret: "");
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Production_without_network_tag_secret_fails_and_never_echoes_values()
|
public void Production_without_network_tag_secret_fails_and_never_echoes_values()
|
||||||
{
|
{
|
||||||
|
|
@ -315,12 +345,14 @@ public sealed class HostTrustTests : IClassFixture<TestWebApplicationFactory>
|
||||||
}
|
}
|
||||||
|
|
||||||
private const string ValidTagSecret = "tag-secret-0123456789-0123456789-abcdef";
|
private const string ValidTagSecret = "tag-secret-0123456789-0123456789-abcdef";
|
||||||
|
private const string ValidAppSecret = "app-secret-5c0e91d4";
|
||||||
|
|
||||||
private static void Validate(
|
private static void Validate(
|
||||||
string? keysPath,
|
string? keysPath,
|
||||||
string environment = "Production",
|
string environment = "Production",
|
||||||
string? connectionString = "Host=db",
|
string? connectionString = "Host=db",
|
||||||
string? tagSecret = ValidTagSecret,
|
string? tagSecret = ValidTagSecret,
|
||||||
|
string appSecret = ValidAppSecret,
|
||||||
int ipv4Length = 32,
|
int ipv4Length = 32,
|
||||||
int ipv6Length = 56,
|
int ipv6Length = 56,
|
||||||
string[]? allowedOrigins = null,
|
string[]? allowedOrigins = null,
|
||||||
|
|
@ -344,7 +376,7 @@ public sealed class HostTrustTests : IClassFixture<TestWebApplicationFactory>
|
||||||
hostEnvironment,
|
hostEnvironment,
|
||||||
Options.Create(new GitHubOAuthOptions { ClientId = "id", ClientSecret = "secret" }),
|
Options.Create(new GitHubOAuthOptions { ClientId = "id", ClientSecret = "secret" }),
|
||||||
Options.Create(new GitHubAppOptions { EnableWebhookSync = false }),
|
Options.Create(new GitHubAppOptions { EnableWebhookSync = false }),
|
||||||
Options.Create(new HeimdallOptions { EnablePatronSupportIntake = false }),
|
Options.Create(new HeimdallOptions { EnablePatronSupportIntake = false, AppSecret = appSecret }),
|
||||||
Options.Create(new StripeOptions()),
|
Options.Create(new StripeOptions()),
|
||||||
Options.Create(new BifrostHostOptions
|
Options.Create(new BifrostHostOptions
|
||||||
{
|
{
|
||||||
|
|
|
||||||
912
tests/Bifrost.Web.Tests/LinkedAccountsTests.cs
Normal file
912
tests/Bifrost.Web.Tests/LinkedAccountsTests.cs
Normal file
|
|
@ -0,0 +1,912 @@
|
||||||
|
using System.Net;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Bifrost.Web.Features.Heimdall;
|
||||||
|
using Bifrost.Web.Features.Shared;
|
||||||
|
using Bifrost.Web.Tests.Support;
|
||||||
|
using Microsoft.AspNetCore.DataProtection;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
|
namespace Bifrost.Web.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The Linked accounts page from the member's side, against a fake Heimdall. Bifrost keeps no Heimdall token: a list, a link
|
||||||
|
/// and an unlink each follow a fresh Heimdall sign-in begun by Bifrost, and the bearer lives inside that one completion request.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class LinkedAccountsTests
|
||||||
|
{
|
||||||
|
private const string Page = "/Membership/LinkedAccounts";
|
||||||
|
private const string ErrorRedirect = "/?auth=heimdall-error";
|
||||||
|
|
||||||
|
private static string Jar(string session, StartedAttempt attempt) =>
|
||||||
|
$"{BifrostCookies.Session}={session}; {attempt.CookieName}={attempt.Cookie}";
|
||||||
|
|
||||||
|
private static string? Location(HttpResponseMessage response) => response.Headers.Location?.OriginalString;
|
||||||
|
|
||||||
|
private static string Unprotect(HeimdallHost host, string cookie) =>
|
||||||
|
host.Services.GetRequiredService<IDataProtectionProvider>().CreateProtector("Bifrost.Heimdall.Attempt.v1").Unprotect(cookie);
|
||||||
|
|
||||||
|
/// <summary>The fresh sign-in's completion as the browser posts it: Heimdall minted a code for <paramref name="proofAccount"/>.</summary>
|
||||||
|
private static async Task<HttpResponseMessage> Finish(
|
||||||
|
HeimdallHost host, string session, StartedAttempt attempt, string proofAccount, string? token = null,
|
||||||
|
string mode = "sign_in", string provider = "discord", string? sessionOverride = null, bool? stepUpRequested = null)
|
||||||
|
{
|
||||||
|
var code = host.CodeFor(attempt, proofAccount, token, mode: mode, provider: provider, stepUpRequested: stepUpRequested);
|
||||||
|
return await host.PostCompleteAsync(
|
||||||
|
new Dictionary<string, string> { ["attempt"] = attempt.Nonce, ["code"] = code }, Jar(sessionOverride ?? session, attempt));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string StepUpToken(HeimdallHost host, string account) =>
|
||||||
|
host.Token(account, mutate: claims => claims["step_up_at"] = host.Clock.Now.ToUnixTimeSeconds());
|
||||||
|
|
||||||
|
private sealed record Member(string Account, string Session);
|
||||||
|
|
||||||
|
/// <summary>A signed-in member whose fake Heimdall account holds these providers, and whose list has been read once.</summary>
|
||||||
|
private static async Task<Member> ListedMember(HeimdallHost host, params string[] providers)
|
||||||
|
{
|
||||||
|
var account = HeimdallShapes.NewAccountId();
|
||||||
|
var session = await host.SignInSessionAsync(account);
|
||||||
|
foreach (var provider in providers)
|
||||||
|
{
|
||||||
|
host.Heimdall.AddIdentity(provider, "Name of " + provider);
|
||||||
|
}
|
||||||
|
|
||||||
|
var start = await host.StartListAsync(session, providers[0]);
|
||||||
|
var done = await Finish(host, session, start, account);
|
||||||
|
Assert.Equal(Page + "?result=listed", Location(done));
|
||||||
|
Assert.Empty(host.Heimdall.OrphanMints);
|
||||||
|
return new Member(account, session);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- who may use the page ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task An_anonymous_visitor_is_challenged_and_nothing_is_started()
|
||||||
|
{
|
||||||
|
using var host = new HeimdallHost(realSessions: true);
|
||||||
|
|
||||||
|
var (get, _) = await host.GetLinkedAccountsAsync(session: null);
|
||||||
|
var post = await host.PostLinkedAccountsAsync(session: null, "Refresh");
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.Redirect, get.StatusCode);
|
||||||
|
Assert.Contains("/auth/sign-in", Location(get));
|
||||||
|
Assert.NotEqual(HttpStatusCode.OK, post.Response.StatusCode);
|
||||||
|
Assert.Empty(host.Heimdall.StartBodies);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task The_page_is_no_store_and_a_member_without_a_list_is_offered_one()
|
||||||
|
{
|
||||||
|
using var host = new HeimdallHost(realSessions: true);
|
||||||
|
var session = await host.SignInSessionAsync(HeimdallShapes.NewAccountId());
|
||||||
|
|
||||||
|
var (response, html) = await host.GetLinkedAccountsAsync(session);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||||
|
Assert.Contains("no-store", response.Headers.CacheControl?.ToString());
|
||||||
|
Assert.Contains("Show my logins through", html);
|
||||||
|
Assert.Contains("Show my logins through Patreon", html);
|
||||||
|
Assert.Contains("will not choose one for you", html);
|
||||||
|
Assert.DoesNotContain("Link Discord", html);
|
||||||
|
Assert.DoesNotContain("Unlink", html);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task With_a_list_the_page_offers_each_admitted_provider_to_link_and_never_twitch()
|
||||||
|
{
|
||||||
|
using var host = new HeimdallHost(realSessions: true);
|
||||||
|
var member = await ListedMember(host, "discord", "google");
|
||||||
|
|
||||||
|
var (_, html) = await host.GetLinkedAccountsAsync(member.Session);
|
||||||
|
|
||||||
|
Assert.Contains("Link Discord", html);
|
||||||
|
Assert.Contains("Link Patreon", html);
|
||||||
|
Assert.Contains("Link Google", html);
|
||||||
|
Assert.DoesNotContain("Link Twitch", html);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task After_the_list_expired_a_proof_is_never_started_through_a_provider_the_member_unlinked()
|
||||||
|
{
|
||||||
|
using var host = new HeimdallHost(realSessions: true);
|
||||||
|
var account = HeimdallShapes.NewAccountId();
|
||||||
|
var session = await host.SignInSessionAsync(account); // signed in through Discord
|
||||||
|
var discord = host.Heimdall.AddIdentity("discord", "D");
|
||||||
|
host.Heimdall.AddIdentity("google", "G");
|
||||||
|
var list = await host.StartListAsync(session, "discord");
|
||||||
|
await Finish(host, session, list, account);
|
||||||
|
var unlink = await host.StartUnlinkAsync(session, discord);
|
||||||
|
var done = await Finish(host, session, unlink, account, provider: "google");
|
||||||
|
Assert.Equal(Page + "?result=unlinked", Location(done));
|
||||||
|
host.Clock.Advance(LinkedAccountsSnapshots.Lifetime + TimeSpan.FromSeconds(1));
|
||||||
|
var starts = host.Heimdall.StartBodies.Count;
|
||||||
|
|
||||||
|
var refresh = await host.StartListAsync(session, via: null);
|
||||||
|
var link = await host.StartLinkAsync(session, "patreon", seedList: false);
|
||||||
|
var (_, html) = await host.GetLinkedAccountsAsync(session);
|
||||||
|
|
||||||
|
// Signing in through Discord again would mint an unrelated account holding that login: nothing is started.
|
||||||
|
Assert.Equal(starts, host.Heimdall.StartBodies.Count);
|
||||||
|
Assert.Equal(Page + "?result=provider_needed", Location(refresh.Response));
|
||||||
|
Assert.Equal(Page + "?result=list_expired", Location(link.Response));
|
||||||
|
Assert.Empty(host.Heimdall.OrphanMints);
|
||||||
|
Assert.Contains("will not choose one for you", html);
|
||||||
|
Assert.DoesNotContain("Link Patreon", html);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(HttpStatusCode.InternalServerError)]
|
||||||
|
[InlineData(HttpStatusCode.OK)]
|
||||||
|
public async Task An_unlink_whose_answer_is_lost_drops_the_list_and_a_link_then_starts_nothing(HttpStatusCode lost)
|
||||||
|
{
|
||||||
|
using var host = new HeimdallHost(realSessions: true);
|
||||||
|
var member = await ListedMember(host, "discord", "google");
|
||||||
|
var discord = (string)host.Heimdall.Identities[0]["id"]!;
|
||||||
|
var start = await host.StartUnlinkAsync(member.Session, discord);
|
||||||
|
host.Heimdall.UnlinkApplyThenFail = lost;
|
||||||
|
|
||||||
|
var done = await Finish(host, member.Session, start, member.Account, provider: "google");
|
||||||
|
var starts = host.Heimdall.StartBodies.Count;
|
||||||
|
var link = await host.StartLinkAsync(member.Session, "patreon", seedList: false);
|
||||||
|
var (_, html) = await host.GetLinkedAccountsAsync(member.Session);
|
||||||
|
|
||||||
|
// Heimdall did unlink Discord. Linking now through the stale list would sign in through Discord and mint an orphan.
|
||||||
|
Assert.Equal(Page + "?result=error", Location(done));
|
||||||
|
Assert.Single(host.Heimdall.Identities);
|
||||||
|
Assert.Equal(starts, host.Heimdall.StartBodies.Count);
|
||||||
|
Assert.Equal(Page + "?result=list_expired", Location(link.Response));
|
||||||
|
Assert.Empty(host.Heimdall.OrphanMints);
|
||||||
|
Assert.Contains("will not choose one for you", html);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Without_a_list_the_picker_names_Google_first_as_unable_to_create_an_account()
|
||||||
|
{
|
||||||
|
using var host = new HeimdallHost(realSessions: true);
|
||||||
|
var session = await host.SignInSessionAsync(HeimdallShapes.NewAccountId());
|
||||||
|
|
||||||
|
var (_, html) = await host.GetLinkedAccountsAsync(session);
|
||||||
|
|
||||||
|
Assert.Contains("Google cannot do that", html);
|
||||||
|
Assert.Contains("through Google (cannot create an account)", html);
|
||||||
|
Assert.True(html.IndexOf("Show my logins through Google", StringComparison.Ordinal) < html.IndexOf("Show my logins through Discord", StringComparison.Ordinal));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void With_no_list_and_no_choice_there_is_no_proof_provider()
|
||||||
|
{
|
||||||
|
Assert.Null(HeimdallAuthService.ProofProvider(null, null));
|
||||||
|
Assert.Null(HeimdallAuthService.ProofProvider(null, "twitch"));
|
||||||
|
Assert.Equal("patreon", HeimdallAuthService.ProofProvider(null, "Patreon"));
|
||||||
|
Assert.Null(HeimdallAuthService.ProofProvider([], "discord"));
|
||||||
|
Assert.Null(HeimdallAuthService.ProofProvider(["twitch"], null));
|
||||||
|
Assert.Equal("patreon", HeimdallAuthService.ProofProvider(["google", "patreon"], null));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task The_service_refuses_to_start_a_proof_with_no_list_and_no_choice()
|
||||||
|
{
|
||||||
|
using var host = new HeimdallHost(realSessions: true);
|
||||||
|
using var scope = host.Services.CreateScope();
|
||||||
|
var service = scope.ServiceProvider.GetRequiredService<HeimdallAuthService>();
|
||||||
|
var starts = host.Heimdall.StartBodies.Count;
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<HeimdallAuthException>(() => service.StartListAsync(
|
||||||
|
HeimdallShapes.NewAccountId(), null, "https://localhost", [], CancellationToken.None));
|
||||||
|
await Assert.ThrowsAsync<HeimdallAuthException>(() => service.StartLinkAsync(
|
||||||
|
HeimdallShapes.NewAccountId(), "patreon", "https://localhost", [], CancellationToken.None));
|
||||||
|
|
||||||
|
Assert.Equal(starts, host.Heimdall.StartBodies.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(null)]
|
||||||
|
[InlineData("twitch")]
|
||||||
|
[InlineData("nonsense")]
|
||||||
|
public async Task Without_a_list_the_member_must_name_an_admitted_provider_or_nothing_is_started(string? via)
|
||||||
|
{
|
||||||
|
using var host = new HeimdallHost(realSessions: true);
|
||||||
|
var session = await host.SignInSessionAsync(HeimdallShapes.NewAccountId());
|
||||||
|
var starts = host.Heimdall.StartBodies.Count;
|
||||||
|
|
||||||
|
var post = await host.StartListAsync(session, via);
|
||||||
|
|
||||||
|
Assert.Equal(starts, host.Heimdall.StartBodies.Count);
|
||||||
|
Assert.Equal(Page + "?result=provider_needed", Location(post.Response));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task With_a_list_the_member_cannot_steer_the_proof_to_a_provider_the_account_does_not_hold()
|
||||||
|
{
|
||||||
|
using var host = new HeimdallHost(realSessions: true);
|
||||||
|
var member = await ListedMember(host, "patreon", "google");
|
||||||
|
|
||||||
|
var post = await host.StartListAsync(member.Session, "discord");
|
||||||
|
|
||||||
|
Assert.Equal("/v1/oauth/patreon/start", host.Heimdall.StartPaths[^1]);
|
||||||
|
Assert.Empty(host.Heimdall.OrphanMints);
|
||||||
|
Assert.NotNull(post.Cookie);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_session_with_no_Heimdall_account_has_nothing_to_list_and_starts_nothing()
|
||||||
|
{
|
||||||
|
using var host = new HeimdallHost();
|
||||||
|
|
||||||
|
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);
|
||||||
|
Assert.Null(start.Cookie);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("Refresh")]
|
||||||
|
[InlineData("Link")]
|
||||||
|
[InlineData("Unlink")]
|
||||||
|
public async Task Every_post_needs_the_antiforgery_token_and_a_get_starts_nothing(string handler)
|
||||||
|
{
|
||||||
|
using var host = new HeimdallHost(realSessions: true);
|
||||||
|
var session = await host.SignInSessionAsync(HeimdallShapes.NewAccountId());
|
||||||
|
var form = new Dictionary<string, string> { ["provider"] = "discord", ["identityId"] = Guid.NewGuid().ToString() };
|
||||||
|
var starts = host.Heimdall.StartBodies.Count;
|
||||||
|
|
||||||
|
var without = await host.PostLinkedAccountsAsync(session, handler, form, antiforgery: false);
|
||||||
|
var get = await host.PostLinkedAccountsAsync(session, handler, form, method: HttpMethod.Get);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.BadRequest, without.Response.StatusCode);
|
||||||
|
Assert.Null(without.Cookie);
|
||||||
|
Assert.Null(get.Cookie);
|
||||||
|
Assert.Equal(starts, host.Heimdall.StartBodies.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- the list ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task The_list_is_a_fresh_ordinary_sign_in_then_GET_identities_with_its_bearer_and_the_page_shows_it()
|
||||||
|
{
|
||||||
|
using var host = new HeimdallHost(traceHttp: true, realSessions: true);
|
||||||
|
var account = HeimdallShapes.NewAccountId();
|
||||||
|
var session = await host.SignInSessionAsync(account);
|
||||||
|
host.Heimdall.AddIdentity("discord", "Discord Dana");
|
||||||
|
host.Heimdall.AddIdentity("google", "Google Gale");
|
||||||
|
var signIns = host.Heimdall.StartBodies.Count;
|
||||||
|
|
||||||
|
var start = await host.StartListAsync(session);
|
||||||
|
|
||||||
|
Assert.Equal(signIns + 1, host.Heimdall.StartBodies.Count);
|
||||||
|
using (var body = JsonDocument.Parse(host.Heimdall.StartBodies[^1]))
|
||||||
|
{
|
||||||
|
Assert.Equal("sign_in", body.RootElement.GetProperty("mode").GetString());
|
||||||
|
Assert.False(body.RootElement.TryGetProperty("stepUp", out _));
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.Equal("/v1/oauth/discord/start", host.Heimdall.StartPaths[^1]);
|
||||||
|
Assert.Empty(host.Heimdall.ListAuthorizations);
|
||||||
|
|
||||||
|
var token = host.Token(account);
|
||||||
|
var done = await Finish(host, session, start, account, token);
|
||||||
|
|
||||||
|
Assert.Equal(Page + "?result=listed", Location(done));
|
||||||
|
Assert.Equal(["Bearer " + token], host.Heimdall.ListAuthorizations);
|
||||||
|
Assert.Null(HeimdallHost.CookieValue(done, BifrostCookies.Session));
|
||||||
|
var (_, html) = await host.GetLinkedAccountsAsync(session);
|
||||||
|
Assert.Contains("Discord Dana", html);
|
||||||
|
Assert.Contains("Google Gale", html);
|
||||||
|
Assert.Contains("13 Nov 2026", html);
|
||||||
|
// Only what the page shows was kept: nothing of the fields a login also carries.
|
||||||
|
foreach (var canary in new[] { "PUID-CANARY", "username-canary", "scope-canary" })
|
||||||
|
{
|
||||||
|
Assert.DoesNotContain(canary, html);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task No_bearer_reaches_a_cookie_a_log_a_location_or_the_page()
|
||||||
|
{
|
||||||
|
using var host = new HeimdallHost(traceHttp: true, realSessions: true);
|
||||||
|
var account = HeimdallShapes.NewAccountId();
|
||||||
|
var session = await host.SignInSessionAsync(account);
|
||||||
|
host.Heimdall.AddIdentity("discord");
|
||||||
|
host.Heimdall.AddIdentity("google");
|
||||||
|
var listStart = await host.StartListAsync(session);
|
||||||
|
var listToken = host.Token(account);
|
||||||
|
var listed = await Finish(host, session, listStart, account, listToken);
|
||||||
|
var unlinkStart = await host.StartUnlinkAsync(session, (string)host.Heimdall.Identities[0]["id"]!);
|
||||||
|
var stepUpToken = StepUpToken(host, account);
|
||||||
|
var unlinked = await Finish(host, session, unlinkStart, account, stepUpToken);
|
||||||
|
Assert.Equal(Page + "?result=unlinked", Location(unlinked));
|
||||||
|
var (_, html) = await host.GetLinkedAccountsAsync(session);
|
||||||
|
|
||||||
|
var logs = string.Join("\n", host.Logs.Lines);
|
||||||
|
var secrets = new[] { listToken, stepUpToken }
|
||||||
|
.SelectMany(token => new[] { token, token.Split('.')[2], token.Split('.')[1] })
|
||||||
|
.ToArray();
|
||||||
|
Assert.Contains("Bearer", string.Join("\n", host.Heimdall.ListAuthorizations));
|
||||||
|
foreach (var secret in secrets)
|
||||||
|
{
|
||||||
|
Assert.DoesNotContain(secret, logs);
|
||||||
|
Assert.DoesNotContain(secret, html);
|
||||||
|
foreach (var response in new[] { listStart.Response, listed, unlinkStart.Response, unlinked })
|
||||||
|
{
|
||||||
|
Assert.DoesNotContain(secret, Location(response) ?? string.Empty);
|
||||||
|
Assert.DoesNotContain(secret, string.Join("\n", response.Headers.SelectMany(x => x.Value)));
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var cookie in new[] { listStart.Cookie, unlinkStart.Cookie })
|
||||||
|
{
|
||||||
|
var payload = Unprotect(host, cookie!);
|
||||||
|
Assert.DoesNotContain(secret, payload);
|
||||||
|
Assert.DoesNotContain("eyJ", payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The completion itself sets no cookie but the attempt's deletion.
|
||||||
|
foreach (var response in new[] { listed, unlinked })
|
||||||
|
{
|
||||||
|
Assert.All(
|
||||||
|
response.Headers.TryGetValues("Set-Cookie", out var set) ? set : [],
|
||||||
|
header => Assert.StartsWith(HeimdallAttemptCookie.Prefix, header, StringComparison.Ordinal));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_list_is_per_account_and_expires_after_five_minutes()
|
||||||
|
{
|
||||||
|
using var host = new HeimdallHost(realSessions: true);
|
||||||
|
var member = await ListedMember(host, "discord", "google");
|
||||||
|
var stranger = await host.SignInSessionAsync(HeimdallShapes.NewAccountId());
|
||||||
|
|
||||||
|
Assert.Contains("Name of discord", (await host.GetLinkedAccountsAsync(member.Session)).Html);
|
||||||
|
var (_, other) = await host.GetLinkedAccountsAsync(stranger);
|
||||||
|
Assert.DoesNotContain("Name of discord", other);
|
||||||
|
Assert.Contains("Show my logins through", other);
|
||||||
|
|
||||||
|
host.Clock.Advance(LinkedAccountsSnapshots.Lifetime + TimeSpan.FromSeconds(1));
|
||||||
|
|
||||||
|
var (_, later) = await host.GetLinkedAccountsAsync(member.Session);
|
||||||
|
Assert.DoesNotContain("Name of discord", later);
|
||||||
|
Assert.Contains("Show my logins through", later);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(HttpStatusCode.Unauthorized, "sign_in_required")]
|
||||||
|
[InlineData(HttpStatusCode.InternalServerError, "error")]
|
||||||
|
public async Task A_list_Heimdall_refuses_shows_its_fixed_outcome_and_no_list(HttpStatusCode status, string outcome)
|
||||||
|
{
|
||||||
|
using var host = new HeimdallHost(realSessions: true);
|
||||||
|
var account = HeimdallShapes.NewAccountId();
|
||||||
|
var session = await host.SignInSessionAsync(account);
|
||||||
|
host.Heimdall.AddIdentity("discord");
|
||||||
|
host.Heimdall.ListStatus = status;
|
||||||
|
var start = await host.StartListAsync(session);
|
||||||
|
|
||||||
|
var done = await Finish(host, session, start, account);
|
||||||
|
|
||||||
|
Assert.Equal($"{Page}?result={outcome}", Location(done));
|
||||||
|
Assert.DoesNotContain("HEIMDALL FREE TEXT", string.Join("\n", host.Logs.Lines));
|
||||||
|
Assert.Contains("Show my logins through", (await host.GetLinkedAccountsAsync(session)).Html);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_Heimdall_that_cannot_be_reached_for_the_list_changes_nothing()
|
||||||
|
{
|
||||||
|
using var host = new HeimdallHost(realSessions: true);
|
||||||
|
var account = HeimdallShapes.NewAccountId();
|
||||||
|
var session = await host.SignInSessionAsync(account);
|
||||||
|
host.Heimdall.AddIdentity("discord");
|
||||||
|
var start = await host.StartListAsync(session);
|
||||||
|
host.Heimdall.MemberFailure = new HttpRequestException("connection refused");
|
||||||
|
|
||||||
|
var done = await Finish(host, session, start, account);
|
||||||
|
|
||||||
|
Assert.Equal(Page + "?result=error", Location(done));
|
||||||
|
Assert.Contains("Show my logins through", (await host.GetLinkedAccountsAsync(session)).Html);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_Heimdall_that_is_down_at_the_list_start_is_a_refusal_not_a_server_error()
|
||||||
|
{
|
||||||
|
using var host = new HeimdallHost(realSessions: true);
|
||||||
|
var session = await host.SignInSessionAsync(HeimdallShapes.NewAccountId());
|
||||||
|
host.Heimdall.StartStatus = HttpStatusCode.ServiceUnavailable;
|
||||||
|
|
||||||
|
var start = await host.StartListAsync(session);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.Redirect, start.Response.StatusCode);
|
||||||
|
Assert.Equal(Page + "?result=error", Location(start.Response));
|
||||||
|
Assert.Null(start.Cookie);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- unlink ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Unlink_starts_a_step_up_sign_in_with_the_app_secret_and_the_unlink_carries_that_sign_ins_bearer()
|
||||||
|
{
|
||||||
|
using var host = new HeimdallHost(realSessions: true);
|
||||||
|
var member = await ListedMember(host, "discord", "google");
|
||||||
|
var discord = (string)host.Heimdall.Identities[0]["id"]!;
|
||||||
|
var listToken = host.Heimdall.ListAuthorizations.Single();
|
||||||
|
var starts = host.Heimdall.StartBodies.Count;
|
||||||
|
|
||||||
|
var start = await host.StartUnlinkAsync(member.Session, discord);
|
||||||
|
|
||||||
|
Assert.Equal(starts + 1, host.Heimdall.StartBodies.Count);
|
||||||
|
Assert.Equal("/v1/oauth/google/start", host.Heimdall.StartPaths[^1]);
|
||||||
|
Assert.Equal(HeimdallHost.AppSecret, host.Heimdall.StartSecrets[^1]);
|
||||||
|
Assert.Null(host.Heimdall.StartAuthorizations[^1]);
|
||||||
|
using (var body = JsonDocument.Parse(host.Heimdall.StartBodies[^1]))
|
||||||
|
{
|
||||||
|
Assert.Equal("sign_in", body.RootElement.GetProperty("mode").GetString());
|
||||||
|
Assert.True(body.RootElement.GetProperty("stepUp").GetBoolean());
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.Empty(host.Heimdall.Unlinks);
|
||||||
|
var token = StepUpToken(host, member.Account);
|
||||||
|
var done = await Finish(host, member.Session, start, member.Account, token, provider: "google");
|
||||||
|
|
||||||
|
Assert.Equal(Page + "?result=unlinked", Location(done));
|
||||||
|
var unlink = Assert.Single(host.Heimdall.Unlinks);
|
||||||
|
Assert.Equal(discord, unlink.Id);
|
||||||
|
Assert.Equal("Bearer " + token, unlink.Authorization);
|
||||||
|
Assert.NotEqual(listToken, unlink.Authorization);
|
||||||
|
var (_, html) = await host.GetLinkedAccountsAsync(member.Session);
|
||||||
|
Assert.DoesNotContain("Name of discord", html);
|
||||||
|
Assert.Contains("Name of google", html);
|
||||||
|
Assert.Contains("Heimdall will not remove the last one", html);
|
||||||
|
Assert.Contains("The login was unlinked.", (await host.GetLinkedAccountsAsync(member.Session, "?result=unlinked")).Html);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task The_last_login_is_refused_by_Heimdall_and_the_page_still_shows_it()
|
||||||
|
{
|
||||||
|
using var host = new HeimdallHost(realSessions: true);
|
||||||
|
var member = await ListedMember(host, "google");
|
||||||
|
var only = (string)host.Heimdall.Identities[0]["id"]!;
|
||||||
|
var start = await host.StartUnlinkAsync(member.Session, only);
|
||||||
|
|
||||||
|
var done = await Finish(host, member.Session, start, member.Account, StepUpToken(host, member.Account), provider: "google");
|
||||||
|
|
||||||
|
Assert.Equal(Page + "?result=last_identity", Location(done));
|
||||||
|
Assert.Single(host.Heimdall.Unlinks);
|
||||||
|
Assert.Single(host.Heimdall.Identities);
|
||||||
|
var (_, html) = await host.GetLinkedAccountsAsync(member.Session);
|
||||||
|
Assert.Contains("Name of google", html);
|
||||||
|
Assert.Contains("will not remove it. Nothing was changed.", (await host.GetLinkedAccountsAsync(member.Session, "?result=last_identity")).Html);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_sign_in_Heimdall_does_not_count_as_fresh_proof_changes_nothing()
|
||||||
|
{
|
||||||
|
using var host = new HeimdallHost(realSessions: true);
|
||||||
|
var member = await ListedMember(host, "discord", "google");
|
||||||
|
var start = await host.StartUnlinkAsync(member.Session, (string)host.Heimdall.Identities[0]["id"]!);
|
||||||
|
|
||||||
|
// An ordinary token: no step_up_at claim, so Heimdall answers 403 step_up_required.
|
||||||
|
var done = await Finish(host, member.Session, start, member.Account, host.Token(member.Account), provider: "google");
|
||||||
|
|
||||||
|
Assert.Equal(Page + "?result=step_up_required", Location(done));
|
||||||
|
Assert.Equal(2, host.Heimdall.Identities.Count);
|
||||||
|
Assert.Contains("Name of discord", (await host.GetLinkedAccountsAsync(member.Session)).Html);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_completion_that_does_not_say_a_step_up_was_asked_unlinks_nothing()
|
||||||
|
{
|
||||||
|
using var host = new HeimdallHost(realSessions: true);
|
||||||
|
var member = await ListedMember(host, "discord", "google");
|
||||||
|
var start = await host.StartUnlinkAsync(member.Session, (string)host.Heimdall.Identities[0]["id"]!);
|
||||||
|
|
||||||
|
// The token even carries step_up_at, but the completion is not the answer to a step-up start.
|
||||||
|
var done = await Finish(host, member.Session, start, member.Account, StepUpToken(host, member.Account), provider: "google",
|
||||||
|
stepUpRequested: false);
|
||||||
|
|
||||||
|
Assert.Equal(Page + "?result=step_up_required", Location(done));
|
||||||
|
Assert.Empty(host.Heimdall.Unlinks);
|
||||||
|
Assert.Equal(2, host.Heimdall.Identities.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_proof_older_than_600_seconds_is_refused_by_Heimdall_and_changes_nothing()
|
||||||
|
{
|
||||||
|
using var host = new HeimdallHost(realSessions: true);
|
||||||
|
var member = await ListedMember(host, "discord", "google");
|
||||||
|
var start = await host.StartUnlinkAsync(member.Session, (string)host.Heimdall.Identities[0]["id"]!);
|
||||||
|
// The sign-in happened 601 seconds before the unlink reaches Heimdall (the attempt itself lives ten minutes).
|
||||||
|
var stale = host.Token(member.Account, mutate: claims => claims["step_up_at"] = host.Clock.Now.ToUnixTimeSeconds() - 601);
|
||||||
|
|
||||||
|
var done = await Finish(host, member.Session, start, member.Account, stale, provider: "google");
|
||||||
|
|
||||||
|
Assert.Equal(Page + "?result=step_up_required", Location(done));
|
||||||
|
Assert.Single(host.Heimdall.Unlinks);
|
||||||
|
Assert.Equal(2, host.Heimdall.Identities.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Every_completion_answer_is_no_store()
|
||||||
|
{
|
||||||
|
using var host = new HeimdallHost(realSessions: true);
|
||||||
|
var account = HeimdallShapes.NewAccountId();
|
||||||
|
var session = await host.SignInSessionAsync(account);
|
||||||
|
host.Heimdall.AddIdentity("discord");
|
||||||
|
var start = await host.StartListAsync(session);
|
||||||
|
|
||||||
|
var done = await Finish(host, session, start, account);
|
||||||
|
var refused = await host.PostCompleteAsync(new Dictionary<string, string> { ["attempt"] = "nope" }, null);
|
||||||
|
|
||||||
|
Assert.Contains("no-store", done.Headers.CacheControl?.ToString());
|
||||||
|
Assert.Contains("no-store", refused.Headers.CacheControl?.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(HttpStatusCode.NotFound, "identity_not_found")]
|
||||||
|
[InlineData(HttpStatusCode.Unauthorized, "sign_in_required")]
|
||||||
|
[InlineData(HttpStatusCode.InternalServerError, "error")]
|
||||||
|
public async Task Each_refused_unlink_has_its_own_fixed_outcome_and_leaks_no_Heimdall_text(HttpStatusCode status, string outcome)
|
||||||
|
{
|
||||||
|
using var host = new HeimdallHost(realSessions: true);
|
||||||
|
var member = await ListedMember(host, "discord", "google");
|
||||||
|
var start = await host.StartUnlinkAsync(member.Session, (string)host.Heimdall.Identities[0]["id"]!);
|
||||||
|
host.Heimdall.UnlinkStatus = status;
|
||||||
|
|
||||||
|
var done = await Finish(host, member.Session, start, member.Account, StepUpToken(host, member.Account), provider: "google");
|
||||||
|
|
||||||
|
Assert.Equal($"{Page}?result={outcome}", Location(done));
|
||||||
|
Assert.DoesNotContain("HEIMDALL FREE TEXT", string.Join("\n", host.Logs.Lines));
|
||||||
|
var (_, html) = await host.GetLinkedAccountsAsync(member.Session);
|
||||||
|
Assert.DoesNotContain("HEIMDALL FREE TEXT", html);
|
||||||
|
// Only a definitive refusal (401 here) leaves the list shown; not found or an unreadable end may have changed the account, so it is dropped.
|
||||||
|
Assert.Equal(status == HttpStatusCode.Unauthorized, html.Contains("Name of discord"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_Heimdall_that_cannot_be_reached_for_the_unlink_changes_nothing()
|
||||||
|
{
|
||||||
|
using var host = new HeimdallHost(realSessions: true);
|
||||||
|
var member = await ListedMember(host, "discord", "google");
|
||||||
|
var start = await host.StartUnlinkAsync(member.Session, (string)host.Heimdall.Identities[0]["id"]!);
|
||||||
|
host.Heimdall.MemberFailure = new HttpRequestException("connection refused");
|
||||||
|
|
||||||
|
var done = await Finish(host, member.Session, start, member.Account, StepUpToken(host, member.Account), provider: "google");
|
||||||
|
|
||||||
|
Assert.Equal(Page + "?result=error", Location(done));
|
||||||
|
Assert.DoesNotContain("Name of discord", (await host.GetLinkedAccountsAsync(member.Session)).Html);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Without_a_Google_login_no_proof_can_be_had_and_the_page_says_so_without_asking_Heimdall()
|
||||||
|
{
|
||||||
|
using var host = new HeimdallHost(realSessions: true);
|
||||||
|
var member = await ListedMember(host, "discord", "patreon");
|
||||||
|
var (_, html) = await host.GetLinkedAccountsAsync(member.Session);
|
||||||
|
var starts = host.Heimdall.StartBodies.Count;
|
||||||
|
|
||||||
|
var post = await host.StartUnlinkAsync(member.Session, (string)host.Heimdall.Identities[0]["id"]!);
|
||||||
|
|
||||||
|
Assert.DoesNotContain(">Unlink<", html);
|
||||||
|
Assert.Contains("Unlinking needs strong proof", html);
|
||||||
|
Assert.Contains("Add a passkey below", html);
|
||||||
|
Assert.Contains("link a Google login", html);
|
||||||
|
Assert.Equal(starts, host.Heimdall.StartBodies.Count);
|
||||||
|
Assert.Equal(Page + "?result=proof_unavailable", Location(post.Response));
|
||||||
|
Assert.Null(post.Cookie);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_Heimdall_with_no_Google_client_refuses_the_step_up_start_and_the_page_says_proof_is_unavailable_once()
|
||||||
|
{
|
||||||
|
using var host = new HeimdallHost(realSessions: true);
|
||||||
|
var member = await ListedMember(host, "discord", "google");
|
||||||
|
host.Heimdall.StartStatus = HttpStatusCode.ServiceUnavailable;
|
||||||
|
host.Heimdall.StartBody = """{"error":"provider_not_configured"}""";
|
||||||
|
var starts = host.Heimdall.StartBodies.Count;
|
||||||
|
|
||||||
|
var post = await host.StartUnlinkAsync(member.Session, (string)host.Heimdall.Identities[0]["id"]!);
|
||||||
|
|
||||||
|
Assert.Equal(starts + 1, host.Heimdall.StartBodies.Count);
|
||||||
|
Assert.Null(post.Cookie);
|
||||||
|
Assert.Equal(Page + "?result=proof_unavailable", Location(post.Response));
|
||||||
|
Assert.Contains("Fresh proof could not be started", (await host.GetLinkedAccountsAsync(member.Session, "?result=proof_unavailable")).Html);
|
||||||
|
Assert.Equal(2, host.Heimdall.Identities.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(null)]
|
||||||
|
[InlineData("not-a-guid")]
|
||||||
|
[InlineData("00000000-0000-0000-0000-000000000001")]
|
||||||
|
public async Task An_identity_that_is_not_on_the_list_the_member_was_shown_starts_nothing(string? identityId)
|
||||||
|
{
|
||||||
|
using var host = new HeimdallHost(realSessions: true);
|
||||||
|
var member = await ListedMember(host, "discord", "google");
|
||||||
|
var starts = host.Heimdall.StartBodies.Count;
|
||||||
|
|
||||||
|
var post = await host.PostLinkedAccountsAsync(
|
||||||
|
member.Session, "Unlink", identityId is null ? null : new Dictionary<string, string> { ["identityId"] = identityId });
|
||||||
|
|
||||||
|
Assert.Equal(starts, host.Heimdall.StartBodies.Count);
|
||||||
|
Assert.Equal(Page + "?result=list_expired", Location(post.Response));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task An_unlink_after_the_list_expired_starts_nothing()
|
||||||
|
{
|
||||||
|
using var host = new HeimdallHost(realSessions: true);
|
||||||
|
var member = await ListedMember(host, "discord", "google");
|
||||||
|
var id = (string)host.Heimdall.Identities[0]["id"]!;
|
||||||
|
host.Clock.Advance(LinkedAccountsSnapshots.Lifetime + TimeSpan.FromSeconds(1));
|
||||||
|
var starts = host.Heimdall.StartBodies.Count;
|
||||||
|
|
||||||
|
var post = await host.StartUnlinkAsync(member.Session, id);
|
||||||
|
|
||||||
|
Assert.Equal(starts, host.Heimdall.StartBodies.Count);
|
||||||
|
Assert.Equal(Page + "?result=list_expired", Location(post.Response));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- an attempt belongs to the account and browser that began it ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task An_unlink_attempt_held_by_another_accounts_session_does_nothing_and_asks_Heimdall_for_nothing()
|
||||||
|
{
|
||||||
|
using var host = new HeimdallHost(realSessions: true);
|
||||||
|
var member = await ListedMember(host, "discord", "google");
|
||||||
|
var start = await host.StartUnlinkAsync(member.Session, (string)host.Heimdall.Identities[0]["id"]!);
|
||||||
|
var otherAccount = HeimdallShapes.NewAccountId();
|
||||||
|
var otherSession = await host.SignInSessionAsync(otherAccount);
|
||||||
|
var redeems = host.Heimdall.RedeemCalls;
|
||||||
|
|
||||||
|
var done = await Finish(host, member.Session, start, member.Account, StepUpToken(host, member.Account), provider: "google",
|
||||||
|
sessionOverride: otherSession);
|
||||||
|
|
||||||
|
Assert.Equal(Page + "?result=error", Location(done));
|
||||||
|
Assert.Equal(redeems, host.Heimdall.RedeemCalls);
|
||||||
|
Assert.Empty(host.Heimdall.Unlinks);
|
||||||
|
Assert.Equal(2, host.Heimdall.Identities.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_proof_by_another_account_unlinks_nothing()
|
||||||
|
{
|
||||||
|
using var host = new HeimdallHost(realSessions: true);
|
||||||
|
var member = await ListedMember(host, "discord", "google");
|
||||||
|
var start = await host.StartUnlinkAsync(member.Session, (string)host.Heimdall.Identities[0]["id"]!);
|
||||||
|
var stranger = HeimdallShapes.NewAccountId();
|
||||||
|
|
||||||
|
var done = await Finish(host, member.Session, start, stranger, StepUpToken(host, stranger), provider: "google");
|
||||||
|
|
||||||
|
Assert.Equal(Page + "?result=error", Location(done));
|
||||||
|
Assert.Empty(host.Heimdall.Unlinks);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_list_proof_by_another_account_lists_nothing()
|
||||||
|
{
|
||||||
|
using var host = new HeimdallHost(realSessions: true);
|
||||||
|
var account = HeimdallShapes.NewAccountId();
|
||||||
|
var session = await host.SignInSessionAsync(account);
|
||||||
|
host.Heimdall.AddIdentity("discord");
|
||||||
|
var start = await host.StartListAsync(session);
|
||||||
|
|
||||||
|
var done = await Finish(host, session, start, HeimdallShapes.NewAccountId());
|
||||||
|
|
||||||
|
Assert.Equal(Page + "?result=error", Location(done));
|
||||||
|
Assert.Empty(host.Heimdall.ListAuthorizations);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_consumed_unlink_attempt_cannot_be_replayed_into_a_second_unlink()
|
||||||
|
{
|
||||||
|
using var host = new HeimdallHost(realSessions: true);
|
||||||
|
var member = await ListedMember(host, "discord", "patreon", "google");
|
||||||
|
var start = await host.StartUnlinkAsync(member.Session, (string)host.Heimdall.Identities[0]["id"]!);
|
||||||
|
var fields = new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["attempt"] = start.Nonce,
|
||||||
|
["code"] = host.CodeFor(start, member.Account, StepUpToken(host, member.Account), provider: "google")
|
||||||
|
};
|
||||||
|
var first = await host.PostCompleteAsync(fields, Jar(member.Session, start));
|
||||||
|
Assert.Equal(Page + "?result=unlinked", Location(first));
|
||||||
|
|
||||||
|
var replay = await host.PostCompleteAsync(fields, Jar(member.Session, start));
|
||||||
|
|
||||||
|
Assert.Equal(Page + "?result=error", Location(replay));
|
||||||
|
Assert.Single(host.Heimdall.Unlinks);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- link another provider ----
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("discord")]
|
||||||
|
[InlineData("patreon")]
|
||||||
|
[InlineData("google")]
|
||||||
|
public async Task Linking_each_admitted_provider_proves_the_account_then_starts_that_providers_link_with_the_bearer(string provider)
|
||||||
|
{
|
||||||
|
using var host = new HeimdallHost(realSessions: true);
|
||||||
|
var account = HeimdallShapes.NewAccountId();
|
||||||
|
var session = await host.SignInSessionAsync(account);
|
||||||
|
var starts = host.Heimdall.StartBodies.Count;
|
||||||
|
|
||||||
|
var start = await host.StartLinkAsync(session, provider);
|
||||||
|
|
||||||
|
Assert.Equal(starts + 1, host.Heimdall.StartBodies.Count);
|
||||||
|
Assert.Equal("/v1/oauth/discord/start", host.Heimdall.StartPaths[^1]);
|
||||||
|
Assert.Null(host.Heimdall.StartAuthorizations[^1]);
|
||||||
|
var token = host.Token(account);
|
||||||
|
var step = await Finish(host, session, start, account, token);
|
||||||
|
|
||||||
|
Assert.Equal(starts + 2, host.Heimdall.StartBodies.Count);
|
||||||
|
Assert.Equal($"/v1/oauth/{provider}/start", host.Heimdall.StartPaths[^1]);
|
||||||
|
Assert.Equal("Bearer " + token, host.Heimdall.StartAuthorizations[^1]);
|
||||||
|
using var link = JsonDocument.Parse(host.Heimdall.StartBodies[^1]);
|
||||||
|
Assert.Equal("link", link.RootElement.GetProperty("mode").GetString());
|
||||||
|
Assert.Equal("bifrost", link.RootElement.GetProperty("appSlug").GetString());
|
||||||
|
Assert.False(link.RootElement.TryGetProperty("stepUp", out _));
|
||||||
|
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")]
|
||||||
|
[InlineData("DISCORD2")]
|
||||||
|
[InlineData("")]
|
||||||
|
public async Task A_provider_Heimdalls_bifrost_profile_does_not_admit_starts_nothing(string provider)
|
||||||
|
{
|
||||||
|
using var host = new HeimdallHost(realSessions: true);
|
||||||
|
var session = await host.SignInSessionAsync(HeimdallShapes.NewAccountId());
|
||||||
|
var starts = host.Heimdall.StartBodies.Count;
|
||||||
|
|
||||||
|
var start = await host.StartLinkAsync(session, provider);
|
||||||
|
|
||||||
|
Assert.Equal(starts, host.Heimdall.StartBodies.Count);
|
||||||
|
Assert.Equal(Page + "?result=error", Location(start.Response));
|
||||||
|
Assert.Null(start.Cookie);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_provider_that_attaches_in_the_callback_comes_back_with_a_link_completion_and_is_linked_once()
|
||||||
|
{
|
||||||
|
using var host = new HeimdallHost(realSessions: true);
|
||||||
|
var account = HeimdallShapes.NewAccountId();
|
||||||
|
var session = await host.SignInSessionAsync(account);
|
||||||
|
var start = await host.StartLinkAsync(session, "patreon");
|
||||||
|
var awaiting = host.AttemptFrom((await ContinueResponse(host, session, start, account)), host.Heimdall.StartBodies.Count - 1);
|
||||||
|
Assert.Contains("\"Purpose\":6", Unprotect(host, awaiting.Cookie!));
|
||||||
|
Assert.Contains("\"Target\":\"patreon\"", Unprotect(host, awaiting.Cookie!));
|
||||||
|
host.Heimdall.AddIdentity("discord");
|
||||||
|
var redeems = host.Heimdall.RedeemCalls;
|
||||||
|
|
||||||
|
var done = await Finish(host, session, awaiting, account, mode: "link", provider: "patreon");
|
||||||
|
|
||||||
|
Assert.Equal(Page + "?result=linked", Location(done));
|
||||||
|
Assert.Equal(redeems + 1, host.Heimdall.RedeemCalls);
|
||||||
|
Assert.Contains("now linked to your Heimdall account", (await host.GetLinkedAccountsAsync(session, "?result=linked")).Html);
|
||||||
|
Assert.Null(HeimdallHost.CookieValue(done, BifrostCookies.Session));
|
||||||
|
var replay = await host.PostCompleteAsync(
|
||||||
|
new Dictionary<string, string> { ["attempt"] = awaiting.Nonce, ["code"] = "gone" }, Jar(session, awaiting));
|
||||||
|
Assert.Equal(Page + "?result=error", Location(replay));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<HttpResponseMessage> ContinueResponse(HeimdallHost host, string session, StartedAttempt start, string account) =>
|
||||||
|
await Finish(host, session, start, account);
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("sign_in", "patreon")]
|
||||||
|
[InlineData("link", "discord")]
|
||||||
|
public async Task A_direct_link_completion_for_another_mode_or_provider_links_nothing(string mode, string provider)
|
||||||
|
{
|
||||||
|
using var host = new HeimdallHost(realSessions: true);
|
||||||
|
var account = HeimdallShapes.NewAccountId();
|
||||||
|
var session = await host.SignInSessionAsync(account);
|
||||||
|
var start = await host.StartLinkAsync(session, "patreon");
|
||||||
|
var before = host.Heimdall.StartBodies.Count;
|
||||||
|
var awaiting = host.AttemptFrom(await ContinueResponse(host, session, start, account), before);
|
||||||
|
|
||||||
|
var done = await Finish(host, session, awaiting, account, mode: mode, provider: provider);
|
||||||
|
|
||||||
|
Assert.Equal(Page + "?result=error", Location(done));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_direct_link_completion_proven_by_another_account_links_nothing()
|
||||||
|
{
|
||||||
|
using var host = new HeimdallHost(realSessions: true);
|
||||||
|
var account = HeimdallShapes.NewAccountId();
|
||||||
|
var session = await host.SignInSessionAsync(account);
|
||||||
|
var start = await host.StartLinkAsync(session, "discord");
|
||||||
|
var before = host.Heimdall.StartBodies.Count;
|
||||||
|
var awaiting = host.AttemptFrom(await ContinueResponse(host, session, start, account), before);
|
||||||
|
|
||||||
|
var done = await Finish(host, session, awaiting, HeimdallShapes.NewAccountId(), mode: "link", provider: "discord");
|
||||||
|
|
||||||
|
Assert.Equal(Page + "?result=error", Location(done));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_link_attempt_cookie_written_before_targets_existed_still_links_Google()
|
||||||
|
{
|
||||||
|
using var host = new HeimdallHost(realSessions: true);
|
||||||
|
var account = HeimdallShapes.NewAccountId();
|
||||||
|
var session = await host.SignInSessionAsync(account);
|
||||||
|
var nonce = "legacy-" + Guid.NewGuid().ToString("N");
|
||||||
|
var protector = host.Services.GetRequiredService<IDataProtectionProvider>().CreateProtector("Bifrost.Heimdall.Attempt.v1");
|
||||||
|
var legacy = protector.Protect(JsonSerializer.Serialize(new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
["Nonce"] = nonce, ["Provider"] = "discord", ["RequiresMemberAccess"] = false, ["ReturnPath"] = "/Membership/Status",
|
||||||
|
["ExpiresAt"] = host.Clock.Now.AddMinutes(5), ["Purpose"] = 1, ["ExpectedAccountId"] = account
|
||||||
|
}));
|
||||||
|
var attempt = new StartedAttempt(
|
||||||
|
nonce, HeimdallAttemptCookie.SlotNames[0], legacy, $"https://localhost/auth/heimdall/complete?attempt={nonce}", null!);
|
||||||
|
|
||||||
|
var step = await Finish(host, session, attempt, account);
|
||||||
|
|
||||||
|
Assert.Equal("/v1/oauth/google/start", host.Heimdall.StartPaths[^1]);
|
||||||
|
Assert.Equal("https://heimdall.test/oauth/google/authorize?state=link-fixture", Location(step));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- the page and its neighbours ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Status_points_a_Heimdall_member_to_the_page_and_no_longer_starts_a_link_itself()
|
||||||
|
{
|
||||||
|
using var host = new HeimdallHost(realSessions: true);
|
||||||
|
var session = await host.SignInSessionAsync(HeimdallShapes.NewAccountId());
|
||||||
|
using var client = host.NewClient();
|
||||||
|
using var request = new HttpRequestMessage(HttpMethod.Get, "/Membership/Status");
|
||||||
|
request.Headers.Add("Cookie", $"{BifrostCookies.Session}={session}");
|
||||||
|
|
||||||
|
var html = await (await client.SendAsync(request)).Content.ReadAsStringAsync();
|
||||||
|
|
||||||
|
Assert.Contains(Page, html);
|
||||||
|
Assert.DoesNotContain("handler=LinkGoogle", html);
|
||||||
|
Assert.DoesNotContain("LinkGoogle", html);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("linked")]
|
||||||
|
[InlineData("identity_linked_elsewhere")]
|
||||||
|
[InlineData("expired")]
|
||||||
|
[InlineData("unlinked")]
|
||||||
|
[InlineData("last_identity")]
|
||||||
|
[InlineData("step_up_required")]
|
||||||
|
[InlineData("identity_not_found")]
|
||||||
|
[InlineData("sign_in_required")]
|
||||||
|
[InlineData("proof_unavailable")]
|
||||||
|
[InlineData("list_expired")]
|
||||||
|
[InlineData("provider_needed")]
|
||||||
|
[InlineData("error")]
|
||||||
|
public void Every_outcome_code_has_words_and_nothing_else_does(string code)
|
||||||
|
{
|
||||||
|
Assert.False(string.IsNullOrWhiteSpace(HeimdallLinkOutcomes.Wording(code)));
|
||||||
|
Assert.Null(HeimdallLinkOutcomes.Wording(code + " "));
|
||||||
|
Assert.Null(HeimdallLinkOutcomes.Wording("<b>" + code));
|
||||||
|
Assert.Contains(
|
||||||
|
Enum.GetValues<HeimdallLinkOutcome>(), outcome => HeimdallLinkOutcomes.Code(outcome) == code);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Link_refusals_say_the_login_belongs_to_another_account_without_naming_it()
|
||||||
|
{
|
||||||
|
using var host = new HeimdallHost(realSessions: true);
|
||||||
|
var session = await host.SignInSessionAsync(HeimdallShapes.NewAccountId());
|
||||||
|
|
||||||
|
var (_, html) = await host.GetLinkedAccountsAsync(session, "?result=identity_linked_elsewhere");
|
||||||
|
|
||||||
|
Assert.Contains("That login belongs to another account. Nothing was linked.", html);
|
||||||
|
Assert.DoesNotContain("merge", html.Replace("nothing is merged", string.Empty), StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -28,6 +28,7 @@ public sealed class MigrationDiscoveryTests
|
||||||
"20261111100000_ConsentReceiptsOneLivePerVersion",
|
"20261111100000_ConsentReceiptsOneLivePerVersion",
|
||||||
"20261111110000_AddMembershipSuspensionReason",
|
"20261111110000_AddMembershipSuspensionReason",
|
||||||
"20261111120000_ClearLegacyConsentReceipts",
|
"20261111120000_ClearLegacyConsentReceipts",
|
||||||
|
"20261113120000_AddPasskeyCredentials",
|
||||||
];
|
];
|
||||||
|
|
||||||
// Database.GetMigrations() reads assembly metadata only; no connection is opened.
|
// Database.GetMigrations() reads assembly metadata only; no connection is opened.
|
||||||
|
|
|
||||||
|
|
@ -105,6 +105,7 @@ public sealed partial class NoThirdPartyAssetTests(TestWebApplicationFactory fac
|
||||||
("/auth/sign-in", null, true),
|
("/auth/sign-in", null, true),
|
||||||
("/auth/bifrost/register", null, true),
|
("/auth/bifrost/register", null, true),
|
||||||
("/auth/bifrost/consent", identity, false),
|
("/auth/bifrost/consent", identity, false),
|
||||||
|
("/Membership/LinkedAccounts", identity, false),
|
||||||
};
|
};
|
||||||
|
|
||||||
foreach (var (path, who, anonymous) in pages)
|
foreach (var (path, who, anonymous) in pages)
|
||||||
|
|
|
||||||
99
tests/Bifrost.Web.Tests/PasskeyScriptTests.cs
Normal file
99
tests/Bifrost.Web.Tests/PasskeyScriptTests.cs
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace Bifrost.Web.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Runs the page's first-party passkey script (src/Bifrost.Web/wwwroot/js/passkeys.js) under Node against a simulated browser
|
||||||
|
/// (Fixtures/passkey-script-harness.cjs) and pins what it posts: the antiforgery header, base64url strings only, and no navigation
|
||||||
|
/// text that did not come from a closed code. Node is required: a missing Node fails these tests.
|
||||||
|
/// </summary>
|
||||||
|
public sealed partial class PasskeyScriptTests
|
||||||
|
{
|
||||||
|
private sealed record Fetch(string Url, string Method, Dictionary<string, string> Headers, string Body, string Credentials);
|
||||||
|
|
||||||
|
private sealed record Call(string Name, Fetch[] Fetches, JsonElement CredentialCall, string[] Navigations, Dictionary<string, string>? Submitted, string Status);
|
||||||
|
|
||||||
|
[GeneratedRegex("^[A-Za-z0-9_-]*$")]
|
||||||
|
private static partial Regex Base64Url();
|
||||||
|
|
||||||
|
private static string ScriptPath()
|
||||||
|
{
|
||||||
|
var dir = new DirectoryInfo(AppContext.BaseDirectory);
|
||||||
|
while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "Bifrost.slnx")))
|
||||||
|
{
|
||||||
|
dir = dir.Parent;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Path.Combine(dir!.FullName, "src", "Bifrost.Web", "wwwroot", "js", "passkeys.js");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<Dictionary<string, Call>> RunAsync()
|
||||||
|
{
|
||||||
|
var start = new ProcessStartInfo("node") { RedirectStandardOutput = true, RedirectStandardError = true };
|
||||||
|
start.ArgumentList.Add(Path.Combine(AppContext.BaseDirectory, "Fixtures", "passkey-script-harness.cjs"));
|
||||||
|
start.ArgumentList.Add(ScriptPath());
|
||||||
|
using var process = Process.Start(start)!;
|
||||||
|
var output = process.StandardOutput.ReadToEndAsync();
|
||||||
|
var errors = process.StandardError.ReadToEndAsync();
|
||||||
|
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(60));
|
||||||
|
await process.WaitForExitAsync(timeout.Token);
|
||||||
|
Assert.True(process.ExitCode == 0, await errors);
|
||||||
|
return JsonSerializer.Deserialize<Call[]>(await output, new JsonSerializerOptions(JsonSerializerDefaults.Web))!.ToDictionary(x => x.Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Enrolment_posts_the_antiforgery_header_to_this_page_and_only_base64url_fields_after_the_ceremony()
|
||||||
|
{
|
||||||
|
var run = (await RunAsync())["enrol"];
|
||||||
|
|
||||||
|
var fetch = Assert.Single(run.Fetches);
|
||||||
|
Assert.Equal("/Membership/LinkedAccounts?handler=PasskeyEnrolOptions", fetch.Url);
|
||||||
|
Assert.Equal("POST", fetch.Method);
|
||||||
|
Assert.Equal("same-origin", fetch.Credentials);
|
||||||
|
Assert.Equal("TOKEN", fetch.Headers["RequestVerificationToken"]);
|
||||||
|
Assert.Equal("", fetch.Body);
|
||||||
|
Assert.True(run.CredentialCall.GetProperty("challengeIsBuffer").GetBoolean());
|
||||||
|
Assert.True(run.CredentialCall.GetProperty("listIdsAreBuffers").GetBoolean());
|
||||||
|
Assert.Equal([4, 5, 6], run.CredentialCall.GetProperty("userIdBytes").EnumerateArray().Select(x => x.GetInt32()));
|
||||||
|
Assert.Equal("CRED-ID", run.Submitted!["id"]);
|
||||||
|
Assert.Equal("-__-", run.Submitted["rawId"]); // 251,255,254 in base64url without padding
|
||||||
|
Assert.Matches(Base64Url(), run.Submitted["attestationObject"]);
|
||||||
|
Assert.Matches(Base64Url(), run.Submitted["clientDataJson"]);
|
||||||
|
Assert.Equal("internal,hybrid", run.Submitted["transports"]);
|
||||||
|
Assert.Empty(run.Navigations);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Unlinking_asks_for_a_challenge_for_the_chosen_identity_only_and_posts_the_assertion_as_base64url()
|
||||||
|
{
|
||||||
|
var run = (await RunAsync())["unlink"];
|
||||||
|
|
||||||
|
var fetch = Assert.Single(run.Fetches);
|
||||||
|
Assert.Equal("/Membership/LinkedAccounts?handler=PasskeyChallenge", fetch.Url);
|
||||||
|
Assert.Equal("identityId=IDENTITY", fetch.Body);
|
||||||
|
Assert.Equal("TOKEN", fetch.Headers["RequestVerificationToken"]);
|
||||||
|
Assert.True(run.CredentialCall.GetProperty("listIdsAreBuffers").GetBoolean());
|
||||||
|
foreach (var name in new[] { "rawId", "authenticatorData", "clientDataJson", "signature", "userHandle" })
|
||||||
|
{
|
||||||
|
Assert.Matches(Base64Url(), run.Submitted![name]);
|
||||||
|
Assert.NotEmpty(run.Submitted[name]);
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.Equal("IDENTITY", run.Submitted!["identityId"]);
|
||||||
|
Assert.Empty(run.Navigations);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("refused", "?result=enrol_needs_sign_in")]
|
||||||
|
[InlineData("hostile-error", "?result=passkey_failed")]
|
||||||
|
[InlineData("cancelled", "?result=passkey_failed")]
|
||||||
|
public async Task A_refusal_or_a_cancelled_ceremony_submits_nothing_and_navigates_only_to_a_closed_result_code(string scenario, string expected)
|
||||||
|
{
|
||||||
|
var run = (await RunAsync())[scenario];
|
||||||
|
|
||||||
|
Assert.Null(run.Submitted);
|
||||||
|
Assert.Equal(["/Membership/LinkedAccounts" + expected], run.Navigations);
|
||||||
|
}
|
||||||
|
}
|
||||||
1153
tests/Bifrost.Web.Tests/PasskeyTests.cs
Normal file
1153
tests/Bifrost.Web.Tests/PasskeyTests.cs
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -5,6 +5,19 @@ namespace Bifrost.Web.Tests.Support;
|
||||||
public sealed class CapturingLoggerProvider : ILoggerProvider
|
public sealed class CapturingLoggerProvider : ILoggerProvider
|
||||||
{
|
{
|
||||||
private readonly List<string> _lines = [];
|
private readonly List<string> _lines = [];
|
||||||
|
private readonly List<string> _warnings = [];
|
||||||
|
|
||||||
|
/// <summary>Lines logged at Warning or above, to pin that an operator-facing notice reaches the journal.</summary>
|
||||||
|
public IReadOnlyList<string> WarningLines
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
lock (_lines)
|
||||||
|
{
|
||||||
|
return _warnings.ToArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public IReadOnlyList<string> Lines
|
public IReadOnlyList<string> Lines
|
||||||
{
|
{
|
||||||
|
|
@ -38,7 +51,12 @@ public sealed class CapturingLoggerProvider : ILoggerProvider
|
||||||
{
|
{
|
||||||
lock (owner._lines)
|
lock (owner._lines)
|
||||||
{
|
{
|
||||||
owner._lines.Add(formatter(state, exception) + (exception is null ? string.Empty : "\n" + exception));
|
var line = formatter(state, exception) + (exception is null ? string.Empty : "\n" + exception);
|
||||||
|
owner._lines.Add(line);
|
||||||
|
if (logLevel >= LogLevel.Warning)
|
||||||
|
{
|
||||||
|
owner._warnings.Add(line);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -133,11 +133,13 @@ public static class HeimdallShapes
|
||||||
string accessToken,
|
string accessToken,
|
||||||
string[]? looseCapabilities = null,
|
string[]? looseCapabilities = null,
|
||||||
string[]? looseFacts = null,
|
string[]? looseFacts = null,
|
||||||
string looseName = "Loose Name") => new()
|
string looseName = "Loose Name",
|
||||||
|
string mode = "sign_in",
|
||||||
|
string provider = "discord") => new()
|
||||||
{
|
{
|
||||||
["status"] = "success",
|
["status"] = "success",
|
||||||
["provider"] = "discord",
|
["provider"] = provider,
|
||||||
["mode"] = "sign_in",
|
["mode"] = mode,
|
||||||
["appSlug"] = "bifrost",
|
["appSlug"] = "bifrost",
|
||||||
["account"] = new Dictionary<string, object?> { ["id"] = accountId, ["displayName"] = looseName },
|
["account"] = new Dictionary<string, object?> { ["id"] = accountId, ["displayName"] = looseName },
|
||||||
["entitlements"] = new Dictionary<string, object?>
|
["entitlements"] = new Dictionary<string, object?>
|
||||||
|
|
@ -202,11 +204,287 @@ public sealed class FakeHeimdall : HttpMessageHandler
|
||||||
|
|
||||||
public List<string?> StartSecrets { get; } = [];
|
public List<string?> StartSecrets { get; } = [];
|
||||||
|
|
||||||
|
/// <summary>The path of every start, so a google link start can be told from a sign-in start.</summary>
|
||||||
|
public List<string> StartPaths { get; } = [];
|
||||||
|
|
||||||
|
/// <summary>The Authorization header of every start (null when absent).</summary>
|
||||||
|
public List<string?> StartAuthorizations { get; } = [];
|
||||||
|
|
||||||
|
public List<string> ConfirmBodies { get; } = [];
|
||||||
|
|
||||||
|
public List<string?> ConfirmAuthorizations { get; } = [];
|
||||||
|
|
||||||
|
/// <summary>When set, every links/confirm answers this status with Heimdall's error code for it.</summary>
|
||||||
|
public HttpStatusCode? ConfirmStatus { get; set; }
|
||||||
|
|
||||||
|
/// <summary>When set with <see cref="ConfirmStatus"/>, the body of that answer verbatim (empty string included).</summary>
|
||||||
|
public string? ConfirmBody { get; set; }
|
||||||
|
|
||||||
|
private readonly HashSet<string> _pendingLinks = new(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
/// <summary>The logins the fake account holds, as GET /v1/me/identities answers them (with the fields Bifrost must never keep).</summary>
|
||||||
|
public List<Dictionary<string, object?>> Identities { get; } = [];
|
||||||
|
|
||||||
|
/// <summary>Adds a login to the fake account and returns its id.</summary>
|
||||||
|
public string AddIdentity(string provider, string? displayName = "A Name", string? id = null)
|
||||||
|
{
|
||||||
|
id ??= Guid.NewGuid().ToString();
|
||||||
|
Identities.Add(new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["id"] = id,
|
||||||
|
["provider"] = provider,
|
||||||
|
["providerUserId"] = "PUID-CANARY-" + id[..8],
|
||||||
|
["username"] = "username-canary-" + id[..8],
|
||||||
|
["displayName"] = displayName,
|
||||||
|
["linkedAt"] = "2026-11-13T12:00:00.000Z",
|
||||||
|
["scopes"] = new[] { "scope-canary" }
|
||||||
|
});
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<string?> ListAuthorizations { get; } = [];
|
||||||
|
|
||||||
|
/// <summary>The clock the fake judges a step-up proof's age by (the host sets it to its test clock).</summary>
|
||||||
|
public Func<DateTimeOffset> Now { get; set; } = () => DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
|
/// <summary>The returnTo of every start that asked for a step-up and passed Heimdall's checks: its completion carries the proof.</summary>
|
||||||
|
public HashSet<string> StepUpReturnTos { get; } = new(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Discord or Patreon sign-ins that came through a provider the fake account (once it holds any login) does not hold. Heimdall mints an
|
||||||
|
/// unrelated account for such a sign-in, which would then hold that login. Google never mints.
|
||||||
|
/// </summary>
|
||||||
|
public List<string> OrphanMints { get; } = [];
|
||||||
|
|
||||||
|
/// <summary>When set, GET /v1/me/identities answers this status with an error body.</summary>
|
||||||
|
public HttpStatusCode? ListStatus { get; set; }
|
||||||
|
|
||||||
|
/// <summary>The Authorization header and identity id of every unlink.</summary>
|
||||||
|
public List<(string? Authorization, string Id)> Unlinks { get; } = [];
|
||||||
|
|
||||||
|
/// <summary>When set, every unlink answers this status with Heimdall's error code for it (the fake's own rules are bypassed).</summary>
|
||||||
|
public HttpStatusCode? UnlinkStatus { get; set; }
|
||||||
|
|
||||||
|
/// <summary>When set, an unlink is applied and then answered with this status (200 answers a body that is not JSON): a lost answer.</summary>
|
||||||
|
public HttpStatusCode? UnlinkApplyThenFail { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Every step-up assertion that reached the route: the app secret header, the Authorization header and the raw body.</summary>
|
||||||
|
public List<(string? Secret, string? Authorization, string Body)> StepUps { get; } = [];
|
||||||
|
|
||||||
|
/// <summary>When set, the step-up route refuses with this status and the contract's error code for it, after the schema and secret checks.</summary>
|
||||||
|
public HttpStatusCode? StepUpStatus { get; set; }
|
||||||
|
|
||||||
|
/// <summary>When true the step-up route throws, as an unreachable Heimdall does.</summary>
|
||||||
|
public bool StepUpDown { get; set; }
|
||||||
|
|
||||||
|
private readonly HashSet<string> _stepUpNonces = new(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
private static readonly System.Text.RegularExpressions.Regex NonceShape = new("^[A-Za-z0-9_-]{16,128}$");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// POST /v1/apps/bifrost/step-up as Heimdall's service-contract.md describes it: schema (method 1..64, nonce pattern) before the
|
||||||
|
/// secret; then the app secret; then the bearer (live, for the same app); then the method set; then single-use per session and
|
||||||
|
/// nonce; a 201 token that copies the bearer's claims and adds step_up_at and step_up_method.
|
||||||
|
/// </summary>
|
||||||
|
private HttpResponseMessage StepUpRoute(HttpRequestMessage request, string body)
|
||||||
|
{
|
||||||
|
static HttpResponseMessage Json(HttpStatusCode status, string json) =>
|
||||||
|
new(status) { Content = new StringContent(json, Encoding.UTF8, "application/json") };
|
||||||
|
static HttpResponseMessage Refuse(HttpStatusCode status, string error) => Json(status, $$"""{"error":"{{error}}"}""");
|
||||||
|
var secret = request.Headers.TryGetValues("x-heimdall-app-secret", out var values) ? values.Single() : null;
|
||||||
|
var authorization = request.Headers.Authorization?.ToString();
|
||||||
|
StepUps.Add((secret, authorization, body));
|
||||||
|
if (StepUpDown)
|
||||||
|
{
|
||||||
|
throw new HttpRequestException("Heimdall is down");
|
||||||
|
}
|
||||||
|
|
||||||
|
string? method = null;
|
||||||
|
string? nonce = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var root = JsonDocument.Parse(body).RootElement;
|
||||||
|
method = root.TryGetProperty("method", out var m) && m.ValueKind == JsonValueKind.String ? m.GetString() : null;
|
||||||
|
nonce = root.TryGetProperty("nonce", out var n) && n.ValueKind == JsonValueKind.String ? n.GetString() : null;
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method is not { Length: >= 1 and <= 64 } || nonce is null || !NonceShape.IsMatch(nonce))
|
||||||
|
{
|
||||||
|
return Json(HttpStatusCode.BadRequest, """{"statusCode":400,"error":"Bad Request","message":"body does not match the schema"}""");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (secret != RequiredAppSecret)
|
||||||
|
{
|
||||||
|
return Refuse(HttpStatusCode.Unauthorized, "app_auth_required");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (StepUpStatus is { } forced)
|
||||||
|
{
|
||||||
|
return forced switch
|
||||||
|
{
|
||||||
|
HttpStatusCode.Forbidden => Refuse(forced, "step_up_assertion_not_permitted"),
|
||||||
|
HttpStatusCode.TooManyRequests => Refuse(forced, "step_up_rate_limited"),
|
||||||
|
HttpStatusCode.Unauthorized => Refuse(forced, "sign_in_required"),
|
||||||
|
HttpStatusCode.Conflict => Refuse(forced, "step_up_nonce_replayed"),
|
||||||
|
_ => Json(forced, """{"error":"server_error","detail":"HEIMDALL FREE TEXT"}""")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
Dictionary<string, JsonElement>? claims = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var token = authorization is not null && authorization.StartsWith("Bearer ", StringComparison.Ordinal) ? authorization["Bearer ".Length..] : null;
|
||||||
|
var parts = token?.Split('.');
|
||||||
|
if (parts is { Length: 3 })
|
||||||
|
{
|
||||||
|
claims = JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(WebEncoders.Base64UrlDecode(parts[1]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception error) when (error is JsonException or FormatException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
if (claims is null || !claims.TryGetValue("exp", out var exp) || exp.GetInt64() <= Now().ToUnixTimeSeconds() || !claims.TryGetValue("sid", out var sid))
|
||||||
|
{
|
||||||
|
return Refuse(HttpStatusCode.Unauthorized, "sign_in_required");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (claims["app"].GetProperty("slug").GetString() != "bifrost")
|
||||||
|
{
|
||||||
|
return Refuse(HttpStatusCode.Forbidden, "bearer_app_mismatch");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method is not ("bifrost-passkey" or "bifrost-email-code"))
|
||||||
|
{
|
||||||
|
return Refuse(HttpStatusCode.BadRequest, "unknown_step_up_method");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_stepUpNonces.Add(sid.GetString() + ":" + nonce))
|
||||||
|
{
|
||||||
|
return Refuse(HttpStatusCode.Conflict, "step_up_nonce_replayed");
|
||||||
|
}
|
||||||
|
|
||||||
|
var minted = claims.ToDictionary(x => x.Key, x => (object?)x.Value);
|
||||||
|
minted["jti"] = Guid.NewGuid().ToString();
|
||||||
|
minted["step_up_at"] = Now().ToUnixTimeSeconds();
|
||||||
|
minted["step_up_method"] = method;
|
||||||
|
return Json(
|
||||||
|
HttpStatusCode.Created,
|
||||||
|
JsonSerializer.Serialize(new
|
||||||
|
{
|
||||||
|
accessToken = Published[0].Sign(minted),
|
||||||
|
claimSet = new { },
|
||||||
|
stepUp = new { at = Now().ToUnixTimeSeconds(), method, validForSeconds = 600 }
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Throws instead of answering a member route, as an unreachable Heimdall does.</summary>
|
||||||
|
public Exception? MemberFailure { get; set; }
|
||||||
|
|
||||||
|
private HttpResponseMessage MemberRoute(HttpRequestMessage request, string path)
|
||||||
|
{
|
||||||
|
static HttpResponseMessage Json(HttpStatusCode status, string json) =>
|
||||||
|
new(status) { Content = new StringContent(json, Encoding.UTF8, "application/json") };
|
||||||
|
if (MemberFailure is not null)
|
||||||
|
{
|
||||||
|
throw MemberFailure;
|
||||||
|
}
|
||||||
|
|
||||||
|
var authorization = request.Headers.Authorization?.ToString();
|
||||||
|
object Listing() => new { accountId = "a", identities = Identities };
|
||||||
|
if (request.Method == HttpMethod.Get && path == "/v1/me/identities")
|
||||||
|
{
|
||||||
|
ListAuthorizations.Add(authorization);
|
||||||
|
if (authorization is null)
|
||||||
|
{
|
||||||
|
return Json(HttpStatusCode.Unauthorized, """{"error":"sign_in_required"}""");
|
||||||
|
}
|
||||||
|
|
||||||
|
return ListStatus switch
|
||||||
|
{
|
||||||
|
HttpStatusCode.Unauthorized => Json(HttpStatusCode.Unauthorized, """{"error":"sign_in_required"}"""),
|
||||||
|
{ } listed => Json(listed, """{"error":"server_error","detail":"HEIMDALL FREE TEXT"}"""),
|
||||||
|
_ => Json(HttpStatusCode.OK, JsonSerializer.Serialize(Listing()))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
var segments = path.Split('/');
|
||||||
|
if (request.Method == HttpMethod.Post && segments is ["", "v1", "me", "identities", var id, "unlink"])
|
||||||
|
{
|
||||||
|
Unlinks.Add((authorization, id));
|
||||||
|
if (authorization is null)
|
||||||
|
{
|
||||||
|
return Json(HttpStatusCode.Unauthorized, """{"error":"sign_in_required"}""");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (UnlinkApplyThenFail is { } lost)
|
||||||
|
{
|
||||||
|
// The unlink happened; the answer did not arrive intact.
|
||||||
|
Identities.RemoveAll(x => (string?)x["id"] == id);
|
||||||
|
return Json(lost, lost == HttpStatusCode.OK ? "not json" : """{"error":"server_error"}""");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (UnlinkStatus is { } forced)
|
||||||
|
{
|
||||||
|
return forced switch
|
||||||
|
{
|
||||||
|
HttpStatusCode.Unauthorized => Json(forced, """{"error":"sign_in_required"}"""),
|
||||||
|
HttpStatusCode.Forbidden => Json(forced, """{"error":"step_up_required"}"""),
|
||||||
|
HttpStatusCode.NotFound => Json(forced, """{"error":"identity_not_found"}"""),
|
||||||
|
HttpStatusCode.Conflict => Json(forced, """{"error":"last_identity"}"""),
|
||||||
|
_ => Json(forced, """{"error":"server_error","detail":"HEIMDALL FREE TEXT"}""")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Heimdall's rule: the bearer must carry a step_up_at claim from the last 600 seconds, and not from the future.
|
||||||
|
var payload = Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(authorization["Bearer ".Length..].Split('.')[1]));
|
||||||
|
using var claims = JsonDocument.Parse(payload);
|
||||||
|
var fresh = claims.RootElement.TryGetProperty("step_up_at", out var at) && at.TryGetInt64(out var seconds)
|
||||||
|
&& seconds <= Now().ToUnixTimeSeconds() && Now().ToUnixTimeSeconds() - seconds <= 600;
|
||||||
|
if (!fresh)
|
||||||
|
{
|
||||||
|
return Json(HttpStatusCode.Forbidden, """{"error":"step_up_required"}""");
|
||||||
|
}
|
||||||
|
|
||||||
|
var held = Identities.FirstOrDefault(x => (string?)x["id"] == id);
|
||||||
|
if (held is null)
|
||||||
|
{
|
||||||
|
return Json(HttpStatusCode.NotFound, """{"error":"identity_not_found"}""");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Identities.Count == 1)
|
||||||
|
{
|
||||||
|
return Json(HttpStatusCode.Conflict, """{"error":"last_identity"}""");
|
||||||
|
}
|
||||||
|
|
||||||
|
Identities.Remove(held);
|
||||||
|
return Json(
|
||||||
|
HttpStatusCode.OK,
|
||||||
|
JsonSerializer.Serialize(new { unlinked = new { id, provider = held["provider"], providerUserId = held["providerUserId"] }, identities = Identities }));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new HttpResponseMessage(HttpStatusCode.NotFound);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Registers a pending link Heimdall will confirm once for any bearer.</summary>
|
||||||
|
public string RegisterPendingLink(string? id = null)
|
||||||
|
{
|
||||||
|
id ??= "pending-" + Convert.ToHexString(RandomNumberGenerator.GetBytes(8)).ToLowerInvariant();
|
||||||
|
_pendingLinks.Add(id);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>The secret a policy start must carry in x-heimdall-app-secret; null means Heimdall asks for none.</summary>
|
/// <summary>The secret a policy start must carry in x-heimdall-app-secret; null means Heimdall asks for none.</summary>
|
||||||
public string? RequiredAppSecret { get; set; }
|
public string? RequiredAppSecret { get; set; }
|
||||||
|
|
||||||
public HttpStatusCode StartStatus { get; set; } = HttpStatusCode.OK;
|
public HttpStatusCode StartStatus { get; set; } = HttpStatusCode.OK;
|
||||||
|
|
||||||
|
/// <summary>A raw Content-Type header for the start answer (for example a charset .NET cannot decode); null means application/json.</summary>
|
||||||
|
public string? StartContentType { get; set; }
|
||||||
|
|
||||||
public string StartBody { get; set; } =
|
public string StartBody { get; set; } =
|
||||||
"""{"authorizationUrl":"https://heimdall.test/oauth/discord/authorize?state=fixture"}""";
|
"""{"authorizationUrl":"https://heimdall.test/oauth/discord/authorize?state=fixture"}""";
|
||||||
|
|
||||||
|
|
@ -252,8 +530,63 @@ public sealed class FakeHeimdall : HttpMessageHandler
|
||||||
{
|
{
|
||||||
var body = await request.Content!.ReadAsStringAsync(cancellationToken);
|
var body = await request.Content!.ReadAsStringAsync(cancellationToken);
|
||||||
var secret = request.Headers.TryGetValues("x-heimdall-app-secret", out var values) ? values.Single() : null;
|
var secret = request.Headers.TryGetValues("x-heimdall-app-secret", out var values) ? values.Single() : null;
|
||||||
|
var authorization = request.Headers.Authorization?.ToString();
|
||||||
StartBodies.Add(body);
|
StartBodies.Add(body);
|
||||||
StartSecrets.Add(secret);
|
StartSecrets.Add(secret);
|
||||||
|
StartPaths.Add(path);
|
||||||
|
StartAuthorizations.Add(authorization);
|
||||||
|
var root = JsonDocument.Parse(body).RootElement;
|
||||||
|
static HttpResponseMessage Refuse(HttpStatusCode status, string error) =>
|
||||||
|
new(status) { Content = new StringContent($$"""{"error":"{{error}}"}""", Encoding.UTF8, "application/json") };
|
||||||
|
var startProvider = path.Split('/')[3];
|
||||||
|
if (root.TryGetProperty("stepUp", out var stepUp) && stepUp.ValueKind == JsonValueKind.True)
|
||||||
|
{
|
||||||
|
// Heimdall's step-up rules (service-contract.md, "Step-up"): the app's request, sign_in only, a provider that can re-authenticate.
|
||||||
|
if (secret != RequiredAppSecret)
|
||||||
|
{
|
||||||
|
return Refuse(HttpStatusCode.Unauthorized, "app_auth_required");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (root.TryGetProperty("mode", out var stepMode) && stepMode.GetString() != "sign_in")
|
||||||
|
{
|
||||||
|
return Refuse(HttpStatusCode.BadRequest, "step_up_only_in_sign_in");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (startProvider is not ("google" or "twitch" or "youtube"))
|
||||||
|
{
|
||||||
|
return Refuse(HttpStatusCode.BadRequest, "step_up_not_supported");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (root.TryGetProperty("mode", out var mode) && mode.GetString() == "link")
|
||||||
|
{
|
||||||
|
if (authorization is null)
|
||||||
|
{
|
||||||
|
return new HttpResponseMessage(HttpStatusCode.Unauthorized)
|
||||||
|
{
|
||||||
|
Content = new StringContent("""{"error":"link_session_required"}""", Encoding.UTF8, "application/json")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
var linkProvider = path.Split('/')[3];
|
||||||
|
return new HttpResponseMessage(HttpStatusCode.Created)
|
||||||
|
{
|
||||||
|
Content = new StringContent(
|
||||||
|
$$"""{"authorizationUrl":"https://heimdall.test/oauth/{{linkProvider}}/authorize?state=link-fixture"}""", Encoding.UTF8, "application/json")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Discord and Patreon mint an account for an identity no account holds; Google is requires_link and refuses (identity_not_linked).
|
||||||
|
if (startProvider != "google" && Identities.Count > 0 && Identities.All(x => (string?)x["provider"] != startProvider))
|
||||||
|
{
|
||||||
|
OrphanMints.Add(startProvider);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (StartStatus == HttpStatusCode.OK && root.TryGetProperty("stepUp", out var asked) && asked.ValueKind == JsonValueKind.True)
|
||||||
|
{
|
||||||
|
StepUpReturnTos.Add(root.GetProperty("returnTo").GetString()!);
|
||||||
|
}
|
||||||
|
|
||||||
var wantsPolicy = JsonDocument.Parse(body).RootElement.TryGetProperty("entitlementPolicy", out _);
|
var wantsPolicy = JsonDocument.Parse(body).RootElement.TryGetProperty("entitlementPolicy", out _);
|
||||||
if (wantsPolicy && RequiredAppSecret is not null && secret != RequiredAppSecret)
|
if (wantsPolicy && RequiredAppSecret is not null && secret != RequiredAppSecret)
|
||||||
{
|
{
|
||||||
|
|
@ -263,10 +596,53 @@ public sealed class FakeHeimdall : HttpMessageHandler
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return new HttpResponseMessage(StartStatus)
|
var content = new ByteArrayContent(Encoding.UTF8.GetBytes(StartBody));
|
||||||
|
content.Headers.TryAddWithoutValidation("Content-Type", StartContentType ?? "application/json");
|
||||||
|
return new HttpResponseMessage(StartStatus) { Content = content };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.Method == HttpMethod.Post && path == "/v1/apps/bifrost/step-up")
|
||||||
|
{
|
||||||
|
return StepUpRoute(request, await request.Content!.ReadAsStringAsync(cancellationToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path.StartsWith("/v1/me/identities", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return MemberRoute(request, path);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.Method == HttpMethod.Post && path == "/v1/apps/bifrost/links/confirm")
|
||||||
|
{
|
||||||
|
var body = await request.Content!.ReadAsStringAsync(cancellationToken);
|
||||||
|
var authorization = request.Headers.Authorization?.ToString();
|
||||||
|
ConfirmBodies.Add(body);
|
||||||
|
ConfirmAuthorizations.Add(authorization);
|
||||||
|
static HttpResponseMessage Json(HttpStatusCode status, string json) =>
|
||||||
|
new(status) { Content = new StringContent(json, Encoding.UTF8, "application/json") };
|
||||||
|
if (authorization is null)
|
||||||
{
|
{
|
||||||
Content = new StringContent(StartBody, Encoding.UTF8, "application/json")
|
return Json(HttpStatusCode.Unauthorized, """{"error":"invalid_link_session"}""");
|
||||||
};
|
}
|
||||||
|
|
||||||
|
if (ConfirmStatus is { } forced)
|
||||||
|
{
|
||||||
|
if (ConfirmBody is not null)
|
||||||
|
{
|
||||||
|
return Json(forced, ConfirmBody);
|
||||||
|
}
|
||||||
|
|
||||||
|
return forced switch
|
||||||
|
{
|
||||||
|
HttpStatusCode.Conflict => Json(forced, """{"error":"identity_linked_elsewhere"}"""),
|
||||||
|
HttpStatusCode.Gone => Json(forced, """{"error":"invalid_or_expired_pending_link"}"""),
|
||||||
|
_ => Json(forced, """{"error":"invalid_link_session","detail":"HEIMDALL FREE TEXT"}""")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
var id = JsonDocument.Parse(body).RootElement.GetProperty("pendingLinkId").GetString()!;
|
||||||
|
return _pendingLinks.Remove(id)
|
||||||
|
? Json(HttpStatusCode.Created, """{"status":"linked","provider":"google","accountId":"a","returnTo":"https://localhost/auth/heimdall/complete"}""")
|
||||||
|
: Json(HttpStatusCode.Gone, """{"error":"invalid_or_expired_pending_link"}""");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (request.Method == HttpMethod.Post && path == "/v1/apps/bifrost/auth-completions/redeem")
|
if (request.Method == HttpMethod.Post && path == "/v1/apps/bifrost/auth-completions/redeem")
|
||||||
|
|
@ -352,6 +728,8 @@ public sealed class HeimdallHost : TestWebApplicationFactory
|
||||||
private readonly string _baseUrl;
|
private readonly string _baseUrl;
|
||||||
private readonly string _publicBaseUrl;
|
private readonly string _publicBaseUrl;
|
||||||
private readonly bool _discordRoles;
|
private readonly bool _discordRoles;
|
||||||
|
private readonly bool _traceHttp;
|
||||||
|
private readonly bool _realSessions;
|
||||||
|
|
||||||
public TestClock Clock { get; } = new();
|
public TestClock Clock { get; } = new();
|
||||||
|
|
||||||
|
|
@ -366,14 +744,19 @@ public sealed class HeimdallHost : TestWebApplicationFactory
|
||||||
string baseUrl = HeimdallShapes.Issuer,
|
string baseUrl = HeimdallShapes.Issuer,
|
||||||
string publicBaseUrl = "https://localhost",
|
string publicBaseUrl = "https://localhost",
|
||||||
bool discordRoles = true,
|
bool discordRoles = true,
|
||||||
string appSlug = "bifrost")
|
string appSlug = "bifrost",
|
||||||
|
bool traceHttp = false,
|
||||||
|
bool realSessions = false)
|
||||||
{
|
{
|
||||||
|
_traceHttp = traceHttp;
|
||||||
|
_realSessions = realSessions;
|
||||||
_appSecret = appSecret;
|
_appSecret = appSecret;
|
||||||
_appSlug = appSlug;
|
_appSlug = appSlug;
|
||||||
_baseUrl = baseUrl;
|
_baseUrl = baseUrl;
|
||||||
_publicBaseUrl = publicBaseUrl;
|
_publicBaseUrl = publicBaseUrl;
|
||||||
_discordRoles = discordRoles;
|
_discordRoles = discordRoles;
|
||||||
Heimdall.Published.Add(Key);
|
Heimdall.Published.Add(Key);
|
||||||
|
Heimdall.Now = () => Clock.Now;
|
||||||
Heimdall.RequiredAppSecret = AppSecret;
|
Heimdall.RequiredAppSecret = AppSecret;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -398,13 +781,32 @@ public sealed class HeimdallHost : TestWebApplicationFactory
|
||||||
configuration.AddInMemoryCollection(new Dictionary<string, string?> { ["Heimdall:DiscordAllowedRoleIds:0"] = "role-1" });
|
configuration.AddInMemoryCollection(new Dictionary<string, string?> { ["Heimdall:DiscordAllowedRoleIds:0"] = "role-1" });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
builder.ConfigureLogging(logging => logging.AddProvider(Logs));
|
builder.ConfigureLogging(logging =>
|
||||||
|
{
|
||||||
|
logging.AddProvider(Logs);
|
||||||
|
if (_traceHttp)
|
||||||
|
{
|
||||||
|
// The framework's HttpClient logging writes request headers at Trace; it must never write a bearer there.
|
||||||
|
logging.AddFilter("System.Net.Http", LogLevel.Trace);
|
||||||
|
}
|
||||||
|
});
|
||||||
builder.ConfigureServices(services =>
|
builder.ConfigureServices(services =>
|
||||||
{
|
{
|
||||||
|
if (_realSessions)
|
||||||
|
{
|
||||||
|
// The base factory authenticates every request as a test admin; a link needs the session cookie to be the identity.
|
||||||
|
services.Configure<Microsoft.AspNetCore.Authentication.AuthenticationOptions>(options =>
|
||||||
|
{
|
||||||
|
options.DefaultAuthenticateScheme = Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationDefaults.AuthenticationScheme;
|
||||||
|
options.DefaultScheme = Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationDefaults.AuthenticationScheme;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
services.RemoveAll<TimeProvider>();
|
services.RemoveAll<TimeProvider>();
|
||||||
services.AddSingleton<TimeProvider>(Clock);
|
services.AddSingleton<TimeProvider>(Clock);
|
||||||
services.AddHttpClient(HeimdallTokenVerifier.ClientName).ConfigurePrimaryHttpMessageHandler(() => Heimdall);
|
services.AddHttpClient(HeimdallTokenVerifier.ClientName).ConfigurePrimaryHttpMessageHandler(() => Heimdall);
|
||||||
services.AddHttpClient<HeimdallAuthService>().ConfigurePrimaryHttpMessageHandler(() => Heimdall);
|
services.AddHttpClient<HeimdallAuthService>().ConfigurePrimaryHttpMessageHandler(() => Heimdall);
|
||||||
|
services.AddHttpClient<HeimdallMemberClient>().ConfigurePrimaryHttpMessageHandler(() => Heimdall);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -435,7 +837,12 @@ public sealed class HeimdallHost : TestWebApplicationFactory
|
||||||
request.Headers.Add("Cookie", cookieHeader);
|
request.Headers.Add("Cookie", cookieHeader);
|
||||||
}
|
}
|
||||||
|
|
||||||
var response = await client.SendAsync(request);
|
return AttemptFrom(await client.SendAsync(request), before);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The attempt a response started at Heimdall, if the start count moved since <paramref name="before"/>.</summary>
|
||||||
|
public StartedAttempt AttemptFrom(HttpResponseMessage response, int before)
|
||||||
|
{
|
||||||
if (Heimdall.StartBodies.Count == before)
|
if (Heimdall.StartBodies.Count == before)
|
||||||
{
|
{
|
||||||
return new StartedAttempt(string.Empty, null, null, string.Empty, response);
|
return new StartedAttempt(string.Empty, null, null, string.Empty, response);
|
||||||
|
|
@ -449,10 +856,21 @@ public sealed class HeimdallHost : TestWebApplicationFactory
|
||||||
|
|
||||||
/// <summary>Registers a completion code as Heimdall would mint it after authenticating this attempt.</summary>
|
/// <summary>Registers a completion code as Heimdall would mint it after authenticating this attempt.</summary>
|
||||||
public string CodeFor(StartedAttempt attempt, string accountId, string? accessToken = null, string[]? looseCapabilities = null,
|
public string CodeFor(StartedAttempt attempt, string accountId, string? accessToken = null, string[]? looseCapabilities = null,
|
||||||
string[]? looseFacts = null, string looseName = "Loose Name", string? returnTo = null)
|
string[]? looseFacts = null, string looseName = "Loose Name", string? returnTo = null, string mode = "sign_in", string provider = "discord",
|
||||||
|
bool? stepUpRequested = null)
|
||||||
{
|
{
|
||||||
|
// A completion of a start that asked for a step-up carries the proof the way Heimdall's does: step_up_at in the token and stepUpRequested.
|
||||||
|
var asked = Heimdall.StepUpReturnTos.Contains(returnTo ?? attempt.ReturnTo);
|
||||||
|
var token = accessToken ?? (asked
|
||||||
|
? Token(accountId, mutate: claims => claims["step_up_at"] = Clock.Now.ToUnixTimeSeconds())
|
||||||
|
: Token(accountId));
|
||||||
var completion = HeimdallShapes.Completion(
|
var completion = HeimdallShapes.Completion(
|
||||||
returnTo ?? attempt.ReturnTo, accountId, accessToken ?? Token(accountId), looseCapabilities, looseFacts, looseName);
|
returnTo ?? attempt.ReturnTo, accountId, token, looseCapabilities, looseFacts, looseName, mode, provider);
|
||||||
|
if (stepUpRequested ?? asked)
|
||||||
|
{
|
||||||
|
completion["stepUpRequested"] = true;
|
||||||
|
}
|
||||||
|
|
||||||
return Heimdall.RegisterCode(completion);
|
return Heimdall.RegisterCode(completion);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -487,6 +905,123 @@ public sealed class HeimdallHost : TestWebApplicationFactory
|
||||||
return new CompletionResult(response, !string.IsNullOrEmpty(session));
|
return new CompletionResult(response, !string.IsNullOrEmpty(session));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>A browser session: a normal Heimdall sign-in, answered with the session cookie it earned.</summary>
|
||||||
|
private readonly Dictionary<string, string> _sessionAccounts = new(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
public async Task<string> SignInSessionAsync(string accountId, string provider = "discord")
|
||||||
|
{
|
||||||
|
var attempt = await StartAsync(provider: provider);
|
||||||
|
var done = await CompleteAsync(attempt, CodeFor(attempt, accountId));
|
||||||
|
var session = CookieValue(done.Response, BifrostCookies.Session) ?? throw new InvalidOperationException("sign-in set no session");
|
||||||
|
_sessionAccounts[session] = accountId;
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The raw POST of the complete page, whatever fields and cookies the caller names.</summary>
|
||||||
|
public async Task<HttpResponseMessage> PostCompleteAsync(IDictionary<string, string> fields, string? cookieHeader)
|
||||||
|
{
|
||||||
|
using var client = NewClient();
|
||||||
|
using var request = new HttpRequestMessage(HttpMethod.Post, "/auth/heimdall/complete")
|
||||||
|
{
|
||||||
|
Content = new FormUrlEncodedContent(fields)
|
||||||
|
};
|
||||||
|
if (cookieHeader is not null)
|
||||||
|
{
|
||||||
|
request.Headers.Add("Cookie", cookieHeader);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await client.SendAsync(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
public const string LinkedAccountsUrl = "/Membership/LinkedAccounts";
|
||||||
|
|
||||||
|
/// <summary>The Linked accounts page as a browser reads it, with its session when there is one.</summary>
|
||||||
|
public async Task<(HttpResponseMessage Response, string Html)> GetLinkedAccountsAsync(string? session, string query = "")
|
||||||
|
{
|
||||||
|
using var client = NewClient();
|
||||||
|
using var request = new HttpRequestMessage(HttpMethod.Get, LinkedAccountsUrl + query);
|
||||||
|
if (session is not null)
|
||||||
|
{
|
||||||
|
request.Headers.Add("Cookie", $"{BifrostCookies.Session}={session}");
|
||||||
|
}
|
||||||
|
|
||||||
|
var response = await client.SendAsync(request);
|
||||||
|
return (response, await response.Content.ReadAsStringAsync());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One of the page's buttons as a browser posts it: the page is fetched for its antiforgery token and cookie (when
|
||||||
|
/// <paramref name="antiforgery"/>), then the form is posted with the session. Without a session nothing is fetched.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<StartedAttempt> PostLinkedAccountsAsync(
|
||||||
|
string? session, string handler, IDictionary<string, string>? form = null, bool antiforgery = true, HttpMethod? method = null,
|
||||||
|
string? extraCookies = null)
|
||||||
|
{
|
||||||
|
using var client = NewClient();
|
||||||
|
var cookies = new List<string>();
|
||||||
|
var fields = new Dictionary<string, string>(form ?? new Dictionary<string, string>());
|
||||||
|
if (session is not null)
|
||||||
|
{
|
||||||
|
cookies.Add($"{BifrostCookies.Session}={session}");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (antiforgery && session is not null)
|
||||||
|
{
|
||||||
|
using var get = new HttpRequestMessage(HttpMethod.Get, LinkedAccountsUrl);
|
||||||
|
get.Headers.Add("Cookie", string.Join("; ", cookies));
|
||||||
|
using var page = await client.SendAsync(get);
|
||||||
|
var html = await page.Content.ReadAsStringAsync();
|
||||||
|
var token = System.Text.RegularExpressions.Regex.Match(
|
||||||
|
html, "name=\"__RequestVerificationToken\"[^>]*value=\"([^\"]+)\"").Groups[1].Value;
|
||||||
|
fields["__RequestVerificationToken"] = token;
|
||||||
|
cookies.Add($"{BifrostCookies.Antiforgery}={CookieValue(page, BifrostCookies.Antiforgery)}");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (extraCookies is not null)
|
||||||
|
{
|
||||||
|
cookies.Add(extraCookies);
|
||||||
|
}
|
||||||
|
|
||||||
|
var before = Heimdall.StartBodies.Count;
|
||||||
|
using var request = new HttpRequestMessage(method ?? HttpMethod.Post, $"{LinkedAccountsUrl}?handler={handler}");
|
||||||
|
if (request.Method == HttpMethod.Post)
|
||||||
|
{
|
||||||
|
request.Content = new FormUrlEncodedContent(fields);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cookies.Count > 0)
|
||||||
|
{
|
||||||
|
request.Headers.Add("Cookie", string.Join("; ", cookies));
|
||||||
|
}
|
||||||
|
|
||||||
|
return AttemptFrom(await client.SendAsync(request), before);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A link start needs the account's current list (Bifrost will not guess a provider to prove with). Link tests that are not
|
||||||
|
/// about the list get one seeded for a session of this host: the Discord login it signed in with. <paramref name="seedList"/> false leaves it.
|
||||||
|
/// </summary>
|
||||||
|
public Task<StartedAttempt> StartLinkAsync(
|
||||||
|
string? session, string provider = "google", bool antiforgery = true, HttpMethod? method = null, string? extraCookies = null,
|
||||||
|
bool seedList = true)
|
||||||
|
{
|
||||||
|
if (seedList && session is not null && _sessionAccounts.TryGetValue(session, out var account)
|
||||||
|
&& Services.GetRequiredService<LinkedAccountsSnapshots>().Get(account) is null)
|
||||||
|
{
|
||||||
|
Services.GetRequiredService<LinkedAccountsSnapshots>().Put(
|
||||||
|
account, [new LinkedIdentitySummary(Guid.NewGuid().ToString(), "discord", "Seeded", null)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return PostLinkedAccountsAsync(session, "Link", new Dictionary<string, string> { ["provider"] = provider }, antiforgery, method, extraCookies);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The list button. <paramref name="via"/> is the provider the member names, needed only when no current list is held.</summary>
|
||||||
|
public Task<StartedAttempt> StartListAsync(string? session, string? via = "discord") =>
|
||||||
|
PostLinkedAccountsAsync(session, "Refresh", via is null ? null : new Dictionary<string, string> { ["via"] = via });
|
||||||
|
|
||||||
|
public Task<StartedAttempt> StartUnlinkAsync(string? session, string identityId) =>
|
||||||
|
PostLinkedAccountsAsync(session, "Unlink", new Dictionary<string, string> { ["identityId"] = identityId });
|
||||||
|
|
||||||
public Task<CompletionResult> CompleteAsync(StartedAttempt attempt, string code) =>
|
public Task<CompletionResult> CompleteAsync(StartedAttempt attempt, string code) =>
|
||||||
CompleteAsync(attempt.Nonce, code, attempt.Cookie, attempt.CookieName);
|
CompleteAsync(attempt.Nonce, code, attempt.Cookie, attempt.CookieName);
|
||||||
|
|
||||||
|
|
|
||||||
156
tests/Bifrost.Web.Tests/Support/SoftwareAuthenticator.cs
Normal file
156
tests/Bifrost.Web.Tests/Support/SoftwareAuthenticator.cs
Normal file
|
|
@ -0,0 +1,156 @@
|
||||||
|
using System.Formats.Cbor;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Microsoft.AspNetCore.WebUtilities;
|
||||||
|
using Org.BouncyCastle.Math.EC.Rfc8032;
|
||||||
|
|
||||||
|
namespace Bifrost.Web.Tests.Support;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A WebAuthn authenticator in software: it makes real "none" attestations and real assertions (CBOR authenticator data, COSE key,
|
||||||
|
/// clientDataJSON, a signature the server must verify) from the options JSON the server sent, and it can be told to misbehave:
|
||||||
|
/// another origin, another RP ID, no user verification, a chosen sign count. The test's only trust is in the crypto it does itself,
|
||||||
|
/// not in the library under test.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SoftwareAuthenticator
|
||||||
|
{
|
||||||
|
public enum Kind
|
||||||
|
{
|
||||||
|
Es256,
|
||||||
|
EdDsa
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly Kind _kind;
|
||||||
|
private readonly ECDsa? _ec;
|
||||||
|
private readonly byte[]? _edSeed;
|
||||||
|
private readonly byte[] _edPublic = new byte[Ed25519.PublicKeySize];
|
||||||
|
|
||||||
|
public SoftwareAuthenticator(Kind kind = Kind.Es256)
|
||||||
|
{
|
||||||
|
_kind = kind;
|
||||||
|
if (kind == Kind.Es256)
|
||||||
|
{
|
||||||
|
_ec = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_edSeed = RandomNumberGenerator.GetBytes(Ed25519.SecretKeySize);
|
||||||
|
Ed25519.GeneratePublicKey(_edSeed, 0, _edPublic, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public byte[] CredentialId { get; } = RandomNumberGenerator.GetBytes(32);
|
||||||
|
|
||||||
|
public uint SignCount { get; set; }
|
||||||
|
|
||||||
|
public string Origin { get; set; } = "https://localhost";
|
||||||
|
|
||||||
|
public string RpId { get; set; } = "localhost";
|
||||||
|
|
||||||
|
public bool UserVerified { get; set; } = true;
|
||||||
|
|
||||||
|
private static string B64(byte[] bytes) => WebEncoders.Base64UrlEncode(bytes);
|
||||||
|
|
||||||
|
private byte[] CoseKey()
|
||||||
|
{
|
||||||
|
var writer = new CborWriter(CborConformanceMode.Lax);
|
||||||
|
if (_kind == Kind.Es256)
|
||||||
|
{
|
||||||
|
var p = _ec!.ExportParameters(false);
|
||||||
|
writer.WriteStartMap(5);
|
||||||
|
writer.WriteInt32(1); writer.WriteInt32(2);
|
||||||
|
writer.WriteInt32(3); writer.WriteInt32(-7);
|
||||||
|
writer.WriteInt32(-1); writer.WriteInt32(1);
|
||||||
|
writer.WriteInt32(-2); writer.WriteByteString(p.Q.X!);
|
||||||
|
writer.WriteInt32(-3); writer.WriteByteString(p.Q.Y!);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
writer.WriteStartMap(4);
|
||||||
|
writer.WriteInt32(1); writer.WriteInt32(1);
|
||||||
|
writer.WriteInt32(3); writer.WriteInt32(-8);
|
||||||
|
writer.WriteInt32(-1); writer.WriteInt32(6);
|
||||||
|
writer.WriteInt32(-2); writer.WriteByteString(_edPublic);
|
||||||
|
}
|
||||||
|
|
||||||
|
writer.WriteEndMap();
|
||||||
|
return writer.Encode();
|
||||||
|
}
|
||||||
|
|
||||||
|
private byte[] AuthData(bool attested)
|
||||||
|
{
|
||||||
|
var data = new List<byte>();
|
||||||
|
data.AddRange(SHA256.HashData(Encoding.UTF8.GetBytes(RpId)));
|
||||||
|
// UP always; UV when told; AT for a registration.
|
||||||
|
data.Add((byte)(0x01 | (UserVerified ? 0x04 : 0) | (attested ? 0x40 : 0)));
|
||||||
|
data.AddRange(new[] { (byte)(SignCount >> 24), (byte)(SignCount >> 16), (byte)(SignCount >> 8), (byte)SignCount });
|
||||||
|
if (attested)
|
||||||
|
{
|
||||||
|
data.AddRange(new byte[16]);
|
||||||
|
data.Add((byte)(CredentialId.Length >> 8));
|
||||||
|
data.Add((byte)CredentialId.Length);
|
||||||
|
data.AddRange(CredentialId);
|
||||||
|
data.AddRange(CoseKey());
|
||||||
|
}
|
||||||
|
|
||||||
|
return data.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private byte[] ClientData(string type, string challenge) =>
|
||||||
|
JsonSerializer.SerializeToUtf8Bytes(new Dictionary<string, object> { ["type"] = type, ["challenge"] = challenge, ["origin"] = Origin, ["crossOrigin"] = false });
|
||||||
|
|
||||||
|
private byte[] Sign(byte[] data) =>
|
||||||
|
_kind == Kind.Es256
|
||||||
|
? _ec!.SignData(data, HashAlgorithmName.SHA256, DSASignatureFormat.Rfc3279DerSequence)
|
||||||
|
: Ed25519Sign(data);
|
||||||
|
|
||||||
|
private byte[] Ed25519Sign(byte[] data)
|
||||||
|
{
|
||||||
|
var signature = new byte[Ed25519.SignatureSize];
|
||||||
|
Ed25519.Sign(_edSeed!, 0, data, 0, data.Length, signature, 0);
|
||||||
|
return signature;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The form fields the page's script posts after navigator.credentials.create, for these creation options.</summary>
|
||||||
|
public Dictionary<string, string> Register(string optionsJson, string label = "Test key")
|
||||||
|
{
|
||||||
|
using var options = JsonDocument.Parse(optionsJson);
|
||||||
|
var challenge = options.RootElement.GetProperty("challenge").GetString()!;
|
||||||
|
var attestation = new CborWriter(CborConformanceMode.Lax);
|
||||||
|
attestation.WriteStartMap(3);
|
||||||
|
attestation.WriteTextString("fmt"); attestation.WriteTextString("none");
|
||||||
|
attestation.WriteTextString("attStmt"); attestation.WriteStartMap(0); attestation.WriteEndMap();
|
||||||
|
attestation.WriteTextString("authData"); attestation.WriteByteString(AuthData(attested: true));
|
||||||
|
attestation.WriteEndMap();
|
||||||
|
return new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["id"] = B64(CredentialId),
|
||||||
|
["rawId"] = B64(CredentialId),
|
||||||
|
["attestationObject"] = B64(attestation.Encode()),
|
||||||
|
["clientDataJson"] = B64(ClientData("webauthn.create", challenge)),
|
||||||
|
["transports"] = "internal",
|
||||||
|
["label"] = label
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The form fields the page's script posts after navigator.credentials.get, for these request options. Counts one use first.</summary>
|
||||||
|
public Dictionary<string, string> Assert(string optionsJson, byte[]? userHandle = null)
|
||||||
|
{
|
||||||
|
using var options = JsonDocument.Parse(optionsJson);
|
||||||
|
var challenge = options.RootElement.GetProperty("challenge").GetString()!;
|
||||||
|
SignCount++; // a real authenticator counts every assertion
|
||||||
|
var authData = AuthData(attested: false);
|
||||||
|
var clientData = ClientData("webauthn.get", challenge);
|
||||||
|
var signature = Sign([.. authData, .. SHA256.HashData(clientData)]);
|
||||||
|
return new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["id"] = B64(CredentialId),
|
||||||
|
["rawId"] = B64(CredentialId),
|
||||||
|
["authenticatorData"] = B64(authData),
|
||||||
|
["clientDataJson"] = B64(clientData),
|
||||||
|
["signature"] = B64(signature),
|
||||||
|
["userHandle"] = userHandle is null ? string.Empty : B64(userHandle)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -60,6 +60,31 @@
|
||||||
"resolved": "2.4.0",
|
"resolved": "2.4.0",
|
||||||
"contentHash": "SwXsAV3sMvAU/Nn31pbjhWurYSjJ+/giI/0n6tCrYoupEK34iIHCuk3STAd9fx8yudM85KkLSVdn951vTng/vQ=="
|
"contentHash": "SwXsAV3sMvAU/Nn31pbjhWurYSjJ+/giI/0n6tCrYoupEK34iIHCuk3STAd9fx8yudM85KkLSVdn951vTng/vQ=="
|
||||||
},
|
},
|
||||||
|
"Fido2": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "4.3.3",
|
||||||
|
"contentHash": "++G8ohXlhp9AH49FXIvYqziXUAEpuCtT+z5HBevX6+Pl6sYz9zfDHGTgvne6t3UEhM0hudesgMudyNG7RlpaEA==",
|
||||||
|
"dependencies": {
|
||||||
|
"Fido2.Models": "4.3.3",
|
||||||
|
"Microsoft.Extensions.Http": "9.0.0",
|
||||||
|
"Microsoft.IdentityModel.JsonWebTokens": "8.2.0",
|
||||||
|
"NSec.Cryptography": "25.4.0",
|
||||||
|
"System.Formats.Cbor": "9.0.24"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Fido2.Models": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "4.3.3",
|
||||||
|
"contentHash": "G/Q8MYgptOwMrbWgDoGsy90u3J5GTdNAHub8aZRg/mQrUETzB5CqFzstGdTXE9UhL0qD6sRynOoPUnqCwKRnMw==",
|
||||||
|
"dependencies": {
|
||||||
|
"Microsoft.Bcl.Memory": "9.0.14"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"libsodium": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "1.0.20.1",
|
||||||
|
"contentHash": "pkiceEqJDQFHjbga3k/oPfTVX9g+GKJZ1VrkuiLhaymt9YNw1Dr7cX9hNuZuNaWcr9WD0moW55k4pMwzQ7JaZQ=="
|
||||||
|
},
|
||||||
"MessagePack": {
|
"MessagePack": {
|
||||||
"type": "Transitive",
|
"type": "Transitive",
|
||||||
"resolved": "3.1.12",
|
"resolved": "3.1.12",
|
||||||
|
|
@ -96,6 +121,11 @@
|
||||||
"System.Formats.Asn1": "10.0.2"
|
"System.Formats.Asn1": "10.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Microsoft.Bcl.Memory": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "9.0.14",
|
||||||
|
"contentHash": "laQCcjTM2FnbyznkAu9hwBuQh3z2/OtJKwjGkJOUevylFpFXZIUVq/+MU2jM0HQm1n5wsjjwtLIDOiWJ6Nai3A=="
|
||||||
|
},
|
||||||
"Microsoft.CodeCoverage": {
|
"Microsoft.CodeCoverage": {
|
||||||
"type": "Transitive",
|
"type": "Transitive",
|
||||||
"resolved": "17.14.1",
|
"resolved": "17.14.1",
|
||||||
|
|
@ -153,27 +183,27 @@
|
||||||
},
|
},
|
||||||
"Microsoft.Extensions.Configuration": {
|
"Microsoft.Extensions.Configuration": {
|
||||||
"type": "Transitive",
|
"type": "Transitive",
|
||||||
"resolved": "8.0.0",
|
"resolved": "9.0.0",
|
||||||
"contentHash": "0J/9YNXTMWSZP2p2+nvl8p71zpSwokZXZuJW+VjdErkegAnFdO1XlqtA62SJtgVYHdKu3uPxJHcMR/r35HwFBA==",
|
"contentHash": "YIMO9T3JL8MeEXgVozKt2v79hquo/EFtnY0vgxmLnUvk1Rei/halI7kOWZL2RBeV9FMGzgM9LZA8CVaNwFMaNA==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"Microsoft.Extensions.Configuration.Abstractions": "8.0.0",
|
"Microsoft.Extensions.Configuration.Abstractions": "9.0.0",
|
||||||
"Microsoft.Extensions.Primitives": "8.0.0"
|
"Microsoft.Extensions.Primitives": "9.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Microsoft.Extensions.Configuration.Abstractions": {
|
"Microsoft.Extensions.Configuration.Abstractions": {
|
||||||
"type": "Transitive",
|
"type": "Transitive",
|
||||||
"resolved": "8.0.0",
|
"resolved": "9.0.0",
|
||||||
"contentHash": "3lE/iLSutpgX1CC0NOW70FJoGARRHbyKmG7dc0klnUZ9Dd9hS6N/POPWhKhMLCEuNN5nXEY5agmlFtH562vqhQ==",
|
"contentHash": "lqvd7W3FGKUO1+ZoUEMaZ5XDJeWvjpy2/M/ptCGz3tXLD4HWVaSzjufsAsjemasBEg+2SxXVtYVvGt5r2nKDlg==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"Microsoft.Extensions.Primitives": "8.0.0"
|
"Microsoft.Extensions.Primitives": "9.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Microsoft.Extensions.Configuration.Binder": {
|
"Microsoft.Extensions.Configuration.Binder": {
|
||||||
"type": "Transitive",
|
"type": "Transitive",
|
||||||
"resolved": "8.0.0",
|
"resolved": "9.0.0",
|
||||||
"contentHash": "mBMoXLsr5s1y2zOHWmKsE9veDcx8h1x/c3rz4baEdQKTeDcmQAPNbB54Pi/lhFO3K431eEq6PFbMgLaa6PHFfA==",
|
"contentHash": "RiScL99DcyngY9zJA2ROrri7Br8tn5N4hP4YNvGdTN/bvg1A3dwvDOxHnNZ3Im7x2SJ5i4LkX1uPiR/MfSFBLQ==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"Microsoft.Extensions.Configuration.Abstractions": "8.0.0"
|
"Microsoft.Extensions.Configuration.Abstractions": "9.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Microsoft.Extensions.Configuration.CommandLine": {
|
"Microsoft.Extensions.Configuration.CommandLine": {
|
||||||
|
|
@ -231,16 +261,16 @@
|
||||||
},
|
},
|
||||||
"Microsoft.Extensions.DependencyInjection": {
|
"Microsoft.Extensions.DependencyInjection": {
|
||||||
"type": "Transitive",
|
"type": "Transitive",
|
||||||
"resolved": "8.0.1",
|
"resolved": "9.0.0",
|
||||||
"contentHash": "BmANAnR5Xd4Oqw7yQ75xOAYODybZQRzdeNucg7kS5wWKd2PNnMdYtJ2Vciy0QLylRmv42DGl5+AFL9izA6F1Rw==",
|
"contentHash": "MCPrg7v3QgNMr0vX4vzRXvkNGgLg8vKWX0nKCWUxu2uPyMsaRgiRc1tHBnbTcfJMhMKj2slE/j2M9oGkd25DNw==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.2"
|
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Microsoft.Extensions.DependencyInjection.Abstractions": {
|
"Microsoft.Extensions.DependencyInjection.Abstractions": {
|
||||||
"type": "Transitive",
|
"type": "Transitive",
|
||||||
"resolved": "8.0.2",
|
"resolved": "9.0.0",
|
||||||
"contentHash": "3iE7UF7MQkCv1cxzCahz+Y/guQbTqieyxyaWKhrRO91itI9cOKO76OHeQDahqG4MmW5umr3CcCvGmK92lWNlbg=="
|
"contentHash": "+6f2qv2a3dLwd5w6JanPIPs47CxRbnk+ZocMJUhv9NxP88VlOcJYZs9jY+MYSjxvady08bUZn6qgiNh7DadGgg=="
|
||||||
},
|
},
|
||||||
"Microsoft.Extensions.DependencyModel": {
|
"Microsoft.Extensions.DependencyModel": {
|
||||||
"type": "Transitive",
|
"type": "Transitive",
|
||||||
|
|
@ -253,22 +283,22 @@
|
||||||
},
|
},
|
||||||
"Microsoft.Extensions.Diagnostics": {
|
"Microsoft.Extensions.Diagnostics": {
|
||||||
"type": "Transitive",
|
"type": "Transitive",
|
||||||
"resolved": "8.0.0",
|
"resolved": "9.0.0",
|
||||||
"contentHash": "3PZp/YSkIXrF7QK7PfC1bkyRYwqOHpWFad8Qx+4wkuumAeXo1NHaxpS9LboNA9OvNSAu+QOVlXbMyoY+pHSqcw==",
|
"contentHash": "0CF9ZrNw5RAlRfbZuVIvzzhP8QeWqHiUmMBU/2H7Nmit8/vwP3/SbHeEctth7D4Gz2fBnEbokPc1NU8/j/1ZLw==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"Microsoft.Extensions.Configuration": "8.0.0",
|
"Microsoft.Extensions.Configuration": "9.0.0",
|
||||||
"Microsoft.Extensions.Diagnostics.Abstractions": "8.0.0",
|
"Microsoft.Extensions.Diagnostics.Abstractions": "9.0.0",
|
||||||
"Microsoft.Extensions.Options.ConfigurationExtensions": "8.0.0"
|
"Microsoft.Extensions.Options.ConfigurationExtensions": "9.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Microsoft.Extensions.Diagnostics.Abstractions": {
|
"Microsoft.Extensions.Diagnostics.Abstractions": {
|
||||||
"type": "Transitive",
|
"type": "Transitive",
|
||||||
"resolved": "8.0.0",
|
"resolved": "9.0.0",
|
||||||
"contentHash": "JHYCQG7HmugNYUhOl368g+NMxYE/N/AiclCYRNlgCY9eVyiBkOHMwK4x60RYMxv9EL3+rmj1mqHvdCiPpC+D4Q==",
|
"contentHash": "1K8P7XzuzX8W8pmXcZjcrqS6x5eSSdvhQohmcpgiQNY/HlDAlnrhR9dvlURfFz428A+RTCJpUyB+aKTA6AgVcQ==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0",
|
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.0",
|
||||||
"Microsoft.Extensions.Options": "8.0.0",
|
"Microsoft.Extensions.Options": "9.0.0",
|
||||||
"System.Diagnostics.DiagnosticSource": "8.0.0"
|
"System.Diagnostics.DiagnosticSource": "9.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Microsoft.Extensions.FileProviders.Abstractions": {
|
"Microsoft.Extensions.FileProviders.Abstractions": {
|
||||||
|
|
@ -335,22 +365,36 @@
|
||||||
"Microsoft.Extensions.Logging.Abstractions": "8.0.0"
|
"Microsoft.Extensions.Logging.Abstractions": "8.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Microsoft.Extensions.Http": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "9.0.0",
|
||||||
|
"contentHash": "DqI4q54U4hH7bIAq9M5a/hl5Odr/KBAoaZ0dcT4OgutD8dook34CbkvAfAIzkMVjYXiL+E5ul9etwwqiX4PHGw==",
|
||||||
|
"dependencies": {
|
||||||
|
"Microsoft.Extensions.Configuration.Abstractions": "9.0.0",
|
||||||
|
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.0",
|
||||||
|
"Microsoft.Extensions.Diagnostics": "9.0.0",
|
||||||
|
"Microsoft.Extensions.Logging": "9.0.0",
|
||||||
|
"Microsoft.Extensions.Logging.Abstractions": "9.0.0",
|
||||||
|
"Microsoft.Extensions.Options": "9.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"Microsoft.Extensions.Logging": {
|
"Microsoft.Extensions.Logging": {
|
||||||
"type": "Transitive",
|
"type": "Transitive",
|
||||||
"resolved": "8.0.1",
|
"resolved": "9.0.0",
|
||||||
"contentHash": "4x+pzsQEbqxhNf1QYRr5TDkLP9UsLT3A6MdRKDDEgrW7h1ljiEPgTNhKYUhNCCAaVpQECVQ+onA91PTPnIp6Lw==",
|
"contentHash": "crjWyORoug0kK7RSNJBTeSE6VX8IQgLf3nUpTB9m62bPXp/tzbnOsnbe8TXEG0AASNaKZddnpHKw7fET8E++Pg==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"Microsoft.Extensions.DependencyInjection": "8.0.1",
|
"Microsoft.Extensions.DependencyInjection": "9.0.0",
|
||||||
"Microsoft.Extensions.Logging.Abstractions": "8.0.2",
|
"Microsoft.Extensions.Logging.Abstractions": "9.0.0",
|
||||||
"Microsoft.Extensions.Options": "8.0.2"
|
"Microsoft.Extensions.Options": "9.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Microsoft.Extensions.Logging.Abstractions": {
|
"Microsoft.Extensions.Logging.Abstractions": {
|
||||||
"type": "Transitive",
|
"type": "Transitive",
|
||||||
"resolved": "8.0.2",
|
"resolved": "9.0.0",
|
||||||
"contentHash": "nroMDjS7hNBPtkZqVBbSiQaQjWRDxITI8Y7XnDs97rqG3EbzVTNLZQf7bIeUJcaHOV8bca47s1Uxq94+2oGdxA==",
|
"contentHash": "g0UfujELzlLbHoVG8kPKVBaW470Ewi+jnptGS9KUi6jcb+k2StujtK3m26DFSGGwQ/+bVgZfsWqNzlP6YOejvw==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.2"
|
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.0",
|
||||||
|
"System.Diagnostics.DiagnosticSource": "9.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Microsoft.Extensions.Logging.Configuration": {
|
"Microsoft.Extensions.Logging.Configuration": {
|
||||||
|
|
@ -418,29 +462,29 @@
|
||||||
},
|
},
|
||||||
"Microsoft.Extensions.Options": {
|
"Microsoft.Extensions.Options": {
|
||||||
"type": "Transitive",
|
"type": "Transitive",
|
||||||
"resolved": "8.0.2",
|
"resolved": "9.0.0",
|
||||||
"contentHash": "dWGKvhFybsaZpGmzkGCbNNwBD1rVlWzrZKANLW/CcbFJpCEceMCGzT7zZwHOGBCbwM0SzBuceMj5HN1LKV1QqA==",
|
"contentHash": "y2146b3jrPI3Q0lokKXdKLpmXqakYbDIPDV6r3M8SqvSf45WwOTzkyfDpxnZXJsJQEpAsAqjUq5Pu8RCJMjubg==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0",
|
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.0",
|
||||||
"Microsoft.Extensions.Primitives": "8.0.0"
|
"Microsoft.Extensions.Primitives": "9.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Microsoft.Extensions.Options.ConfigurationExtensions": {
|
"Microsoft.Extensions.Options.ConfigurationExtensions": {
|
||||||
"type": "Transitive",
|
"type": "Transitive",
|
||||||
"resolved": "8.0.0",
|
"resolved": "9.0.0",
|
||||||
"contentHash": "0f4DMRqEd50zQh+UyJc+/HiBsZ3vhAQALgdkcQEalSH1L2isdC7Yj54M3cyo5e+BeO5fcBQ7Dxly8XiBBcvRgw==",
|
"contentHash": "Ob3FXsXkcSMQmGZi7qP07EQ39kZpSBlTcAZLbJLdI4FIf0Jug8biv2HTavWmnTirchctPlq9bl/26CXtQRguzA==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"Microsoft.Extensions.Configuration.Abstractions": "8.0.0",
|
"Microsoft.Extensions.Configuration.Abstractions": "9.0.0",
|
||||||
"Microsoft.Extensions.Configuration.Binder": "8.0.0",
|
"Microsoft.Extensions.Configuration.Binder": "9.0.0",
|
||||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0",
|
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.0",
|
||||||
"Microsoft.Extensions.Options": "8.0.0",
|
"Microsoft.Extensions.Options": "9.0.0",
|
||||||
"Microsoft.Extensions.Primitives": "8.0.0"
|
"Microsoft.Extensions.Primitives": "9.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Microsoft.Extensions.Primitives": {
|
"Microsoft.Extensions.Primitives": {
|
||||||
"type": "Transitive",
|
"type": "Transitive",
|
||||||
"resolved": "8.0.0",
|
"resolved": "9.0.0",
|
||||||
"contentHash": "bXJEZrW9ny8vjMF1JV253WeLhpEVzFo1lyaZu1vQ4ZxWUlVvknZ/+ftFgVheLubb4eZPSwwxBeqS1JkCOjxd8g=="
|
"contentHash": "N3qEBzmLMYiASUlKxxFIISP4AiwuPTHF5uCh+2CWSwwzAJiIYx0kBJsS30cp1nvhSySFAVi30jecD307jV+8Kg=="
|
||||||
},
|
},
|
||||||
"Microsoft.IdentityModel.Abstractions": {
|
"Microsoft.IdentityModel.Abstractions": {
|
||||||
"type": "Transitive",
|
"type": "Transitive",
|
||||||
|
|
@ -527,6 +571,14 @@
|
||||||
"Npgsql": "8.0.0"
|
"Npgsql": "8.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"NSec.Cryptography": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "25.4.0",
|
||||||
|
"contentHash": "Z3wPpG0xefMLmhn9T+Ka/EzAmMQPT0THL/5E4lf9cXI/N9rbGH0G23ZFpRcPnD0ast2k7ieG6QSVNIjmfPCXBQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"libsodium": "[1.0.20.1, 1.0.21)"
|
||||||
|
}
|
||||||
|
},
|
||||||
"OpenIddict.Abstractions": {
|
"OpenIddict.Abstractions": {
|
||||||
"type": "Transitive",
|
"type": "Transitive",
|
||||||
"resolved": "7.7.1",
|
"resolved": "7.7.1",
|
||||||
|
|
@ -616,8 +668,8 @@
|
||||||
},
|
},
|
||||||
"System.Diagnostics.DiagnosticSource": {
|
"System.Diagnostics.DiagnosticSource": {
|
||||||
"type": "Transitive",
|
"type": "Transitive",
|
||||||
"resolved": "8.0.0",
|
"resolved": "9.0.0",
|
||||||
"contentHash": "c9xLpVz6PL9lp/djOWtk5KPDZq3cSYpmXoJQY524EOtuFl5z9ZtsotpsyrDW40U1DRnQSYvcPKEUV0X//u6gkQ=="
|
"contentHash": "ddppcFpnbohLWdYKr/ZeLZHmmI+DXFgZ3Snq+/E7SwcdW4UnvxmaugkwGywvGVWkHPGCSZjCP+MLzu23AL5SDw=="
|
||||||
},
|
},
|
||||||
"System.Diagnostics.EventLog": {
|
"System.Diagnostics.EventLog": {
|
||||||
"type": "Transitive",
|
"type": "Transitive",
|
||||||
|
|
@ -629,6 +681,11 @@
|
||||||
"resolved": "10.0.2",
|
"resolved": "10.0.2",
|
||||||
"contentHash": "9Hee6iQ74pDgdRNIL6lO8Vlx2onVWd5aDnqwk4Pvo0h5FklBd7QwcQDKgxeLuhiy0o3ibzUVO0KFQe0/hPXU+Q=="
|
"contentHash": "9Hee6iQ74pDgdRNIL6lO8Vlx2onVWd5aDnqwk4Pvo0h5FklBd7QwcQDKgxeLuhiy0o3ibzUVO0KFQe0/hPXU+Q=="
|
||||||
},
|
},
|
||||||
|
"System.Formats.Cbor": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "9.0.24",
|
||||||
|
"contentHash": "SEfEvHu6qXcXoDsDKagddLDf8A9iHIhHUD0hGemdmwlP206KLLCI5ksskOpeXMgNpQCs2diB0fOBKcnjySHFUQ=="
|
||||||
|
},
|
||||||
"System.IO.Pipelines": {
|
"System.IO.Pipelines": {
|
||||||
"type": "Transitive",
|
"type": "Transitive",
|
||||||
"resolved": "8.0.0",
|
"resolved": "8.0.0",
|
||||||
|
|
@ -696,6 +753,7 @@
|
||||||
"type": "Project",
|
"type": "Project",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"BouncyCastle.Cryptography": "[2.4.0, )",
|
"BouncyCastle.Cryptography": "[2.4.0, )",
|
||||||
|
"Fido2": "[4.3.3, )",
|
||||||
"MessagePack": "[3.1.12, )",
|
"MessagePack": "[3.1.12, )",
|
||||||
"Microsoft.Extensions.Caching.Memory": "[8.0.1, )",
|
"Microsoft.Extensions.Caching.Memory": "[8.0.1, )",
|
||||||
"Npgsql": "[8.0.3, )",
|
"Npgsql": "[8.0.3, )",
|
||||||
|
|
|
||||||
50
tests/path-within.test.mjs
Normal file
50
tests/path-within.test.mjs
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import path from "node:path";
|
||||||
|
import { isWithin } from "../tools/path-within.mjs";
|
||||||
|
|
||||||
|
const flavours = [
|
||||||
|
{ name: "posix", pathApi: path.posix, root: "/srv/repo", sep: "/", inside: "/srv/repo/src/a.txt", sibling: "/srv/repo-evil/a.txt", other: "/etc/passwd" },
|
||||||
|
{ name: "win32", pathApi: path.win32, root: "C:\\Work\\Repo", sep: "\\", inside: "C:\\Work\\Repo\\src\\a.txt", sibling: "C:\\Work\\Repo-evil\\a.txt", other: "D:\\Work\\Repo\\a.txt" },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const f of flavours) {
|
||||||
|
const opts = { pathApi: f.pathApi };
|
||||||
|
test(`${f.name}: a path inside the root, and the root itself, are within`, () => {
|
||||||
|
assert.equal(isWithin(f.root, f.inside, opts), true);
|
||||||
|
assert.equal(isWithin(f.root, f.root, opts), true);
|
||||||
|
assert.equal(isWithin(f.root, f.root + f.sep, opts), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(`${f.name}: a sibling sharing the root as a name prefix is refused`, () => {
|
||||||
|
assert.equal(isWithin(f.root, f.sibling, opts), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(`${f.name}: a dot-dot escape and an unrelated absolute path are refused`, () => {
|
||||||
|
assert.equal(isWithin(f.root, f.root + f.sep + ["..", "x"].join(f.sep), opts), false);
|
||||||
|
assert.equal(isWithin(f.root, f.other, opts), false);
|
||||||
|
assert.equal(isWithin(f.root, f.root + f.sep + "..", opts), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(`${f.name}: a name that merely starts with two dots is inside`, () => {
|
||||||
|
assert.equal(isWithin(f.root, f.root + f.sep + "..cache" + f.sep + "x", opts), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(`${f.name}: excluded dir, strictly beneath it vs not`, () => {
|
||||||
|
const excluded = f.root + f.sep + "tools" + f.sep + "git-gate";
|
||||||
|
const strict = { ...opts, allowSelf: false };
|
||||||
|
assert.equal(isWithin(excluded, excluded + f.sep + "git.cmd", strict), true);
|
||||||
|
assert.equal(isWithin(excluded, excluded, strict), false);
|
||||||
|
assert.equal(isWithin(excluded, excluded + "-other" + f.sep + "git", strict), false);
|
||||||
|
assert.equal(isWithin(excluded, f.root + f.sep + "bin" + f.sep + "git", strict), false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("win32: comparison ignores case, as Windows paths do", () => {
|
||||||
|
assert.equal(isWithin("C:\\Work\\Repo", "c:\\work\\repo\\SRC\\a.txt", { pathApi: path.win32 }), true);
|
||||||
|
assert.equal(isWithin("C:\\Work\\Repo", "c:\\work\\repo-evil\\a.txt", { pathApi: path.win32 }), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("posix: comparison is case-sensitive", () => {
|
||||||
|
assert.equal(isWithin("/srv/repo", "/srv/Repo/a.txt", { pathApi: path.posix }), false);
|
||||||
|
});
|
||||||
|
|
@ -176,7 +176,7 @@ test("follow: at most three an hour per room and peer, counted independently; a
|
||||||
assert.deepEqual(got, { $k0: null, $l0: null, $ko0: null, $k1: "follow", $ko1: "follow", $k2: "follow", $k3: "follow", $k4: null, $l4: "follow", $k30: null, $k31: null, $k60: null, $k62: "follow" });
|
assert.deepEqual(got, { $k0: null, $l0: null, $ko0: null, $k1: "follow", $ko1: "follow", $k2: "follow", $k3: "follow", $k4: null, $l4: "follow", $k30: null, $k31: null, $k60: null, $k62: "follow" });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("follow: an edit and a notice are never follow; a room-basis message from a counted sender is; a tag keeps its own basis", async () => {
|
test("follow: an edit and a notice are never follow; a room-basis message from a counted sender stays room; a tag keeps its own basis", async () => {
|
||||||
const w = world({ rounds: [
|
const w = world({ rounds: [
|
||||||
{ [ROOM]: [msg("$k0", KIKO, T0 - MIN)] },
|
{ [ROOM]: [msg("$k0", KIKO, T0 - MIN)] },
|
||||||
{ [ROOM]: [mine("$mine", T0)] },
|
{ [ROOM]: [mine("$mine", T0)] },
|
||||||
|
|
@ -187,7 +187,7 @@ test("follow: an edit and a notice are never follow; a room-basis message from a
|
||||||
msg("$tag", KIKO, T0 + SEC, { "m.mentions": { user_ids: [ME] } }),
|
msg("$tag", KIKO, T0 + SEC, { "m.mentions": { user_ids: [ME] } }),
|
||||||
] },
|
] },
|
||||||
] });
|
] });
|
||||||
assert.deepEqual((await run(w, 3))[2].sort(), [["$edit", null], ["$notice", null], ["$room", "follow"], ["$tag", "tag"]]);
|
assert.deepEqual((await run(w, 3))[2].sort(), [["$edit", null], ["$notice", null], ["$room", "room"], ["$tag", "tag"]]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("follow: flag off emits nothing new and reads nothing extra; a fresh mouth has no engagement", async () => {
|
test("follow: flag off emits nothing new and reads nothing extra; a fresh mouth has no engagement", async () => {
|
||||||
|
|
@ -376,3 +376,15 @@ test("follow: an echo that arrives while the send's anchor is still reading wait
|
||||||
assert.equal(w.seen.filter(l => l.endsWith("/state")).length, 1);
|
assert.equal(w.seen.filter(l => l.endsWith("/state")).length, 1);
|
||||||
assert.equal(w.mouth.followState().engagements[0].from, T0, "anchored at the send's time, not the echo's");
|
assert.equal(w.mouth.followState().engagements[0].from, T0, "anchored at the send's time, not the echo's");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("follow: an @room from a followed peer stays room and spends none of the cap; plain messages still upgrade to follow", async () => {
|
||||||
|
const w = world({ rounds: [
|
||||||
|
{ [ROOM]: [msg("$k0", KIKO, T0 - MIN)] },
|
||||||
|
{ [ROOM]: [mine("$mine", T0)] },
|
||||||
|
{ [ROOM]: [1, 2, 3, 4].map(n => msg(`$r${n}`, KIKO, T0 + n * SEC, { "m.mentions": { room: true } })) },
|
||||||
|
{ [ROOM]: [1, 2, 3].map(n => msg(`$p${n}`, KIKO, T0 + 10 * SEC + n)) },
|
||||||
|
] });
|
||||||
|
const out = await run(w, 4);
|
||||||
|
assert.deepEqual(out[2], [["$r1", "room"], ["$r2", "room"], ["$r3", "room"], ["$r4", "room"]]);
|
||||||
|
assert.deepEqual(out[3], [["$p1", "follow"], ["$p2", "follow"], ["$p3", "follow"]]); // all three of K still unspent
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { mkdir, readFile, unlink, writeFile } from "node:fs/promises";
|
||||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||||
import { dirname, resolve } from "node:path";
|
import { dirname, resolve } from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { isWithin } from "./path-within.mjs";
|
||||||
import {
|
import {
|
||||||
beginCrossingReceipt,
|
beginCrossingReceipt,
|
||||||
crossingProvenanceFromOptions,
|
crossingProvenanceFromOptions,
|
||||||
|
|
@ -182,7 +183,7 @@ async function createGitHubDraftPr(options) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const targetPath = resolve(repoRoot, relativePath);
|
const targetPath = resolve(repoRoot, relativePath);
|
||||||
if (!targetPath.toLowerCase().startsWith(`${repoRoot.toLowerCase()}\\`) && targetPath.toLowerCase() !== repoRoot.toLowerCase()) {
|
if (!isWithin(repoRoot, targetPath)) {
|
||||||
throw new Error(`Target path escapes repo root: ${relativePath}`);
|
throw new Error(`Target path escapes repo root: ${relativePath}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
import { spawnSync } from "node:child_process";
|
import { spawnSync } from "node:child_process";
|
||||||
import { dirname, resolve } from "node:path";
|
import { dirname } from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { isWithin } from "../path-within.mjs";
|
||||||
|
|
||||||
const args = process.argv.slice(2);
|
const args = process.argv.slice(2);
|
||||||
const wrapperDir = dirname(fileURLToPath(import.meta.url));
|
const wrapperDir = dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
@ -90,9 +91,7 @@ function resolveExecutable(command, excludedDir) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function isWithinExcludedDir(candidate, excludedDir) {
|
function isWithinExcludedDir(candidate, excludedDir) {
|
||||||
const normalizedCandidate = resolve(candidate).toLowerCase();
|
return isWithin(excludedDir, candidate, { allowSelf: false });
|
||||||
const normalizedExcludedDir = `${resolve(excludedDir).toLowerCase()}\\`;
|
|
||||||
return normalizedCandidate.startsWith(normalizedExcludedDir);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function isWindowsCommandScript(command) {
|
function isWindowsCommandScript(command) {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
import { spawnSync } from "node:child_process";
|
import { spawnSync } from "node:child_process";
|
||||||
import { dirname, resolve } from "node:path";
|
import { dirname } from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { isWithin } from "../path-within.mjs";
|
||||||
|
|
||||||
const args = process.argv.slice(2);
|
const args = process.argv.slice(2);
|
||||||
const wrapperDir = dirname(fileURLToPath(import.meta.url));
|
const wrapperDir = dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
@ -59,9 +60,7 @@ function resolveExecutable(command, excludedDir) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function isWithinExcludedDir(candidate, excludedDir) {
|
function isWithinExcludedDir(candidate, excludedDir) {
|
||||||
const normalizedCandidate = resolve(candidate).toLowerCase();
|
return isWithin(excludedDir, candidate, { allowSelf: false });
|
||||||
const normalizedExcludedDir = `${resolve(excludedDir).toLowerCase()}\\`;
|
|
||||||
return normalizedCandidate.startsWith(normalizedExcludedDir);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function findGitSubcommand(input) {
|
function findGitSubcommand(input) {
|
||||||
|
|
|
||||||
15
tools/path-within.mjs
Normal file
15
tools/path-within.mjs
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
// True when `candidate` is `root` itself (only if allowSelf) or lies beneath it.
|
||||||
|
// Containment is path.relative-based, so it follows the host's separator and
|
||||||
|
// case rules; pass path.win32 or path.posix to pin a flavour.
|
||||||
|
export function isWithin(root, candidate, { pathApi = path, allowSelf = true } = {}) {
|
||||||
|
const relative = pathApi.relative(pathApi.resolve(root), pathApi.resolve(candidate));
|
||||||
|
if (relative === "") {
|
||||||
|
return allowSelf;
|
||||||
|
}
|
||||||
|
if (pathApi.isAbsolute(relative) || relative === ".." || relative.startsWith(`..${pathApi.sep}`)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
@ -25,8 +25,8 @@
|
||||||
//
|
//
|
||||||
// Follow (CHAT_FOLLOW=1, default off; docs/bridge.md "Follow"): her own send in a public room (not a DM, not encrypted, not bridged) anchors the room
|
// Follow (CHAT_FOLLOW=1, default off; docs/bridge.md "Follow"): her own send in a public room (not a DM, not encrypted, not bridged) anchors the room
|
||||||
// for 10 minutes; those who spoke there in the 10 minutes before it, and whoever it tagged or replied to, get basis "follow" on their later messages
|
// for 10 minutes; those who spoke there in the 10 minutes before it, and whoever it tagged or replied to, get basis "follow" on their later messages
|
||||||
// inside the window, at most 3 an hour per peer and room. Memory only. Off, no basis "follow" is ever emitted: Huginn's parser refuses it until its
|
// inside the window, at most 3 an hour per peer and room; only a plain message (no basis) is upgraded, an @room keeps `room`. Memory only. Off, no
|
||||||
// Basis::Follow lands.
|
// basis "follow" is ever emitted. Huginn's variant is Basis::FollowUp, wire string "follow" (FOLLOW_UP_BASIS, f09bc56e).
|
||||||
//
|
//
|
||||||
// Media: an m.image/m.file/m.audio/m.video event is delivered as one typed line (see bodyText), not its bare filename.
|
// Media: an m.image/m.file/m.audio/m.video event is delivered as one typed line (see bodyText), not its bare filename.
|
||||||
// An m.image event in a delivered message is also fetched, as this Persona and only when an image store is configured
|
// An m.image event in a delivered message is also fetched, as this Persona and only when an image store is configured
|
||||||
|
|
@ -137,7 +137,7 @@ export function createMouth({
|
||||||
persona, homeserver, serverName, rooms, token, fetch: httpFetch = globalThis.fetch, state, brake,
|
persona, homeserver, serverName, rooms, token, fetch: httpFetch = globalThis.fetch, state, brake,
|
||||||
now = Date.now, log = () => {}, syncTimeoutMs = 30000, maxPending = MAX_PENDING, gate = createRoomGate(), sendTimeoutMs = SEND_TIMEOUT_MS,
|
now = Date.now, log = () => {}, syncTimeoutMs = 30000, maxPending = MAX_PENDING, gate = createRoomGate(), sendTimeoutMs = SEND_TIMEOUT_MS,
|
||||||
presenceGraceMs = PRESENCE_GRACE_MS, fetchDeadlineMs = FETCH_DEADLINE_MS,
|
presenceGraceMs = PRESENCE_GRACE_MS, fetchDeadlineMs = FETCH_DEADLINE_MS,
|
||||||
follow = false, // the follow basis (CHAT_FOLLOW): an untagged message from someone she tagged or replied to, within FOLLOW_WINDOW_MS of her own post, is for her
|
follow = false, // the follow basis (CHAT_FOLLOW): a plain (no-basis) message from someone she tagged or replied to, within FOLLOW_WINDOW_MS of her own post, is for her
|
||||||
replyLink = false, // resolve each reply's quoted event into delivery.replyTo (the v4 answer; one more event read per reply to a non-Mind)
|
replyLink = false, // resolve each reply's quoted event into delivery.replyTo (the v4 answer; one more event read per reply to a non-Mind)
|
||||||
images = null, // this Persona's image store (createImageStore), or null: images stay the typed text line
|
images = null, // this Persona's image store (createImageStore), or null: images stay the typed text line
|
||||||
isServed = async () => false, // (async) whether a Matrix user id is an account this process speaks for: a Mind is not a person to open a DM with
|
isServed = async () => false, // (async) whether a Matrix user id is an account this process speaks for: a Mind is not a person to open a DM with
|
||||||
|
|
@ -580,7 +580,7 @@ export function createMouth({
|
||||||
if (!baseline && !freshRooms.has(roomId) && event.sender === selfId) await noteOwnPost(roomId, event);
|
if (!baseline && !freshRooms.has(roomId) && event.sender === selfId) await noteOwnPost(roomId, event);
|
||||||
noteSpeaker(roomId, event);
|
noteSpeaker(roomId, event);
|
||||||
let basis = deliverable ? await basisOf(roomId, event) : null;
|
let basis = deliverable ? await basisOf(roomId, event) : null;
|
||||||
if (deliverable && (basis === null || basis === "room") && await followed(roomId, event)) basis = "follow";
|
if (deliverable && basis === null && await followed(roomId, event)) basis = "follow";
|
||||||
const replyTo = deliverable ? await replyToOf(roomId, event) : null;
|
const replyTo = deliverable ? await replyToOf(roomId, event) : null;
|
||||||
processed.add(event.event_id);
|
processed.add(event.event_id);
|
||||||
if (deliverable) {
|
if (deliverable) {
|
||||||
|
|
@ -1185,7 +1185,7 @@ export function loadRuntime(cultRoot) {
|
||||||
export const replyLinkFromEnv = env => env.CHAT_PENDING_V4 === "1";
|
export const replyLinkFromEnv = env => env.CHAT_PENDING_V4 === "1";
|
||||||
// The fetch flag, the same way: on only for exactly "1". Off, the fetch request kind is an unknown kind.
|
// The fetch flag, the same way: on only for exactly "1". Off, the fetch request kind is an unknown kind.
|
||||||
export const fetchFromEnv = env => env.CHAT_FETCH === "1";
|
export const fetchFromEnv = env => env.CHAT_FETCH === "1";
|
||||||
// The follow flag, the same way. Off, the mouth never emits basis "follow" (Huginn's parser refuses an unknown basis until its Basis::Follow lands).
|
// The follow flag, the same way. Off, the mouth never emits basis "follow" (Huginn's variant is Basis::FollowUp, wire string "follow", FOLLOW_UP_BASIS f09bc56e).
|
||||||
export const followFromEnv = env => env.CHAT_FOLLOW === "1";
|
export const followFromEnv = env => env.CHAT_FOLLOW === "1";
|
||||||
|
|
||||||
function required(env, name) {
|
function required(env, name) {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue