Compare commits
30 changed files with 180 additions and 4770 deletions
|
|
@ -1,265 +0,0 @@
|
|||
# Answer Collector: Record and Consent
|
||||
|
||||
Status: specification. Nothing here is implemented. It fixes what an answer
|
||||
record is and what a respondent has agreed to, so that a collector, a tally and
|
||||
a training export can be built against one definition. Field names and types
|
||||
below are the contract for the typed CultCache records.
|
||||
|
||||
Scope: free-text and choice answers that members give to a sample put to them
|
||||
by an experiment. Not identity proof (see `architecture.md`). Personal details
|
||||
(names, contact data, account identifiers, anything that identifies a person) are
|
||||
out of scope: the collector does not ask for them and a sample must not solicit
|
||||
them. The only identifier it keeps is `member_id`, and that stays on the reward
|
||||
path.
|
||||
|
||||
## Notice
|
||||
|
||||
Consent is declared once per room, not per reply. Each experiment room carries
|
||||
one notice that names the scope the room's answers may be used for and the way
|
||||
out. A notice has a `notice_version`; any change to its wording or scope is a new
|
||||
version.
|
||||
|
||||
### Where the notice lives
|
||||
|
||||
A room's notice is a Matrix room state event: type `org.gamecult.notice`, empty
|
||||
`state_key`, content `{version, scope}`.
|
||||
|
||||
- `version` follows the version rule under Representation.
|
||||
- `scope` is `show`, `study` or `train`.
|
||||
- The event is set under the room's power levels. A pinned message is not
|
||||
accepted as a notice: anyone who can pin could forge it.
|
||||
- Absent or malformed (a missing or unknown field, a `version` the version rule
|
||||
does not accept, an unknown `scope`) means no `train`
|
||||
line and the narrowest scope, `show`. Malformed content is never repaired.
|
||||
|
||||
The wording shown to respondents is the room's own business; the collector reads
|
||||
only `version` and `scope`. A change to either is a new state event and so a
|
||||
new `version`.
|
||||
|
||||
| scope | what the notice allows |
|
||||
|---|---|
|
||||
| `show` | display back inside the experiment (to the respondent, and in aggregate to participants). Nothing else. |
|
||||
| `study` | `show`, plus analysis by the experiment's researchers. Quoted in findings only unattributed. |
|
||||
| `train` | `study`, plus use as training or evaluation data for models. |
|
||||
|
||||
`train` is a declared scope: it is used only when the notice for that room says
|
||||
`train` in terms. It is never inferred, never a default, and never raised by a
|
||||
reply.
|
||||
|
||||
The first reply a member posts in the room is their acceptance of the notice
|
||||
version then in force. No separate click, command or form. A later notice version
|
||||
needs a new acceptance: the member's first reply under it.
|
||||
|
||||
### Notice acceptance record
|
||||
|
||||
| field | type | rule |
|
||||
|---|---|---|
|
||||
| `member_id` | Heimdall member id | the accepting member. |
|
||||
| `experiment_id` | string | the room. |
|
||||
| `notice_version` | integer | the `version` of the room's notice state event when the reply was received. |
|
||||
| `scope` | `show` \| `study` \| `train` | the scope that version declared. |
|
||||
| `accepted_at` | UTC timestamp | `origin_server_ts` of the first reply. |
|
||||
| `event_id` | Matrix event id | that reply, as proof. |
|
||||
|
||||
Key: `(experiment_id, member_id, notice_version)`. Written once, never updated.
|
||||
|
||||
## Terms (newcomers only)
|
||||
|
||||
Joining Heimdall is gated by terms. Acceptance is the terms version recorded by
|
||||
the Matrix `m.login.terms` stage at registration: member field `terms_version`
|
||||
(string) and `terms_accepted_at` (UTC timestamp). These fields belong to the
|
||||
Heimdall member record. A typed `MemberTerms` record keyed by `member_id` is the
|
||||
shape of that pair in a typed library; it is not a second store, and the
|
||||
collector does not hold it. This applies to newcomers
|
||||
only. Members who joined before the terms stage existed, and Minds, are not
|
||||
re-asked and have no `terms_version`; the collector does not read it and does not
|
||||
gate on it. The notice above is the only consent the collector itself checks.
|
||||
|
||||
Open gap: the registration checkbox records no version. Void is raising this;
|
||||
nothing here rules it.
|
||||
|
||||
## Answer record
|
||||
|
||||
One record per answer. Fields:
|
||||
|
||||
| field | type | rule |
|
||||
|---|---|---|
|
||||
| `sample_id` | string `<sample>@<version>` | names the thing answered about; `<version>` follows the version rule under Representation. A changed sample is a new version. |
|
||||
| `experiment_id` | string | the experiment that asked. |
|
||||
| `member_id` | Heimdall member id | the respondent. Never a Matrix id, never a display name. |
|
||||
| `notice_version` | integer | the notice version in force when the answer was received; matches an acceptance record by the key above. |
|
||||
| `scope` | `show` \| `study` \| `train` | copied from that notice version at answer time. Never edited afterwards. |
|
||||
| `text` | string, or absent | the answer as given. Non-empty while the answer is live; absent once exited. |
|
||||
| `exited` | boolean, default false | one-way: set by redaction, never cleared. A new answer is a new record. |
|
||||
| `text_hash` | 32 bytes, or absent | the tombstone hash of `text` (see "Tombstone"). Present only once exited, and only when the collector held `text` before the redaction. |
|
||||
| `received_at` | UTC timestamp | assigned by the collector when it receives the reply. Not copied from the reply. |
|
||||
| `event_id` | Matrix event id | the reply; the receipt. |
|
||||
|
||||
Key: `(experiment_id, sample_id, member_id)`. A member's later answer to the
|
||||
same key replaces the earlier one (latest `received_at` wins); the earlier text
|
||||
is not retained. Two replies to one key with equal `received_at` tie-break on
|
||||
`event_id` compared bytewise: the greater `event_id` wins as the later reply.
|
||||
|
||||
Empty text: a live (non-exited) answer must have non-empty `text`, and the
|
||||
collector rejects a reply whose text is empty. Empty text is valid only on an
|
||||
exited answer, where redaction clears `text` and leaves the hash-only tombstone.
|
||||
|
||||
A reply whose `notice_version` or `scope` is missing, unparseable or has no
|
||||
matching acceptance record is stored, with those fields absent. It is excluded
|
||||
from every use: no tally, no display, and from training in particular. It
|
||||
writes no acceptance record. The reply is kept so the member can still exit it
|
||||
and the collector can show what it refused, but no scope was declared, so none
|
||||
is inferred. The notice version of a reply is the one the room's notice state
|
||||
event held at the reply's `origin_server_ts`; if the collector cannot establish
|
||||
what it held then, the reply has no notice version.
|
||||
|
||||
### Representation
|
||||
|
||||
- Timestamps are Unix milliseconds in UTC, stored as a signed 64-bit integer,
|
||||
the unit of Matrix `origin_server_ts`. Every "UTC timestamp" above means this.
|
||||
- `member_id`, `experiment_id` and `event_id` are non-empty strings;
|
||||
`member_id` is opaque and `event_id` is the Matrix event id verbatim.
|
||||
- Version rule. Every version field in the consent system is a positive integer
|
||||
written in decimal with no leading zeros and at most 4294967295 (u32 max):
|
||||
the `<version>` of `sample_id` (`s@<version>`), `notice_version`, the
|
||||
`version` of the room notice `org.gamecult.notice`, and the notice acceptance
|
||||
and terms records (`terms_version`, carried as that decimal string). Anything
|
||||
else (zero, a sign, whitespace, a non-digit, a leading zero such as `s@01`, a
|
||||
value above the maximum) is refused, not normalised: `s@1` is the only
|
||||
spelling of version 1, so one version has exactly one key. A room notice
|
||||
whose `version` fails the rule is malformed: no `train` line, narrowest scope
|
||||
`show`. Nothing is silently rewritten to fit.
|
||||
- Two encodings of the one version rule. In the `org.gamecult.notice` room state
|
||||
event, `version` is a JSON number; a string (including `"01"`) is malformed.
|
||||
In the answer key (`s@<version>`, and the composite keys), the version stays
|
||||
its encoded decimal text, held to the leading-zero refusal. The rule is the
|
||||
same in both: a positive integer, no leading zeros, at most 4294967295.
|
||||
- A composite key is its parts joined with U+001F. A part that is empty or
|
||||
contains U+001F is refused, not escaped.
|
||||
|
||||
## Versions
|
||||
|
||||
Answers never tally across `sample_id` versions. A tally is computed for one
|
||||
full `sample_id` including version; there is no "all versions" aggregate and no
|
||||
API that takes a sample without a version. A version bump starts an empty tally.
|
||||
Replies to an old version are dropped when the tally is for a newer one.
|
||||
|
||||
## Training rule
|
||||
|
||||
A record is training or evaluation data only if all hold:
|
||||
|
||||
1. `scope = train`, copied from a notice version that declared `train`;
|
||||
2. an acceptance record exists for `(experiment_id, member_id, notice_version)`
|
||||
of the answer, its own `scope` is `train` and equals the answer's `scope`,
|
||||
and `accepted_at <= received_at`, i.e. the author had accepted that notice
|
||||
version when they answered;
|
||||
3. `exited = false` at export time.
|
||||
|
||||
Open gap: `accepted_at` is the homeserver's `origin_server_ts` and `received_at` is the collector's own clock, so
|
||||
condition 2 compares two clocks. If the collector's clock is behind the homeserver's, a member's first answer (which is
|
||||
also their acceptance) can fail `accepted_at <= received_at` and is excluded. That fails closed, never opens `train`
|
||||
wrongly. The remedy is to compare on one clock (keep the reply's `origin_server_ts` on the answer); this revision does
|
||||
not rule it.
|
||||
|
||||
No recorded scope, or no recorded acceptance, means excluded. Records wider than
|
||||
`show` are kept apart from `show` records: one store per scope (`show`, `study`,
|
||||
`train`) plus a store for scopeless replies that no read path serves. A record
|
||||
lives in the store its `scope` names, so a read for one level cannot return
|
||||
another. A typed-record library need not model the partition; the collector's
|
||||
store layout owns it.
|
||||
|
||||
## Tombstone
|
||||
|
||||
- Function: HMAC-SHA-256 (RFC 2104 with SHA-256, FIPS 180-4), output the raw 32
|
||||
bytes stored in `text_hash`. No truncation.
|
||||
- Message: the ASCII string `heimdall.tombstone.v1`, U+001F, `event_id`
|
||||
(UTF-8), U+001F, `text` (UTF-8, as received, no normalisation). The `event_id`
|
||||
makes the hash specific to the reply, so equal texts hash differently.
|
||||
- Key (the salt): 32 bytes from a cryptographically secure random source,
|
||||
generated once per member by the member-side service, which is the service that
|
||||
already maps Matrix sender to `member_id`. It is held there, beside the member
|
||||
record, and is never given to the collector, a researcher, an export or a log.
|
||||
The collector cannot compute or check a tombstone hash on its own.
|
||||
- Redaction: when the collector sees a redaction of a reply whose text it holds,
|
||||
it sends `event_id` and `text` to the member-side service, which returns the
|
||||
hash; the collector writes `text_hash`, sets `exited`, and erases `text`. If the
|
||||
service does not answer, `text` is kept, the record is not yet exited, and the
|
||||
run retries. A reply the collector never collected has no text and gets no
|
||||
tombstone.
|
||||
- Verification: only the member-side service can check a claimed text against a
|
||||
tombstone. If a member's salt is destroyed, their tombstones can no longer be
|
||||
tied to any text, and that is the member's erasure.
|
||||
|
||||
## Identity: count once, reward without exposure
|
||||
|
||||
The Matrix sender is mapped to a Heimdall member id by Heimdall's linked
|
||||
identity, at collection time. The collector stores `member_id` only; the Matrix
|
||||
sender is discarded after mapping. A sender with no linked member is dropped,
|
||||
not stored under a placeholder.
|
||||
|
||||
- Count once: the key above makes one member one answer per sample version,
|
||||
however many Matrix accounts or bridges they use.
|
||||
- Reward without exposure: a reward is issued against `member_id` by the
|
||||
rewarding service. Researchers and displays see counts and text under their
|
||||
scope, never `member_id`. The mapping from a record to a person is available
|
||||
to the reward path only.
|
||||
|
||||
## Eureka's shape (Matrix)
|
||||
|
||||
- One room per experiment. The room is `experiment_id`.
|
||||
- The sample is posted as a message; the room's notice is its `org.gamecult.notice` state event. An answer
|
||||
is a thread reply to the sample, the answer text being the body. There is no
|
||||
per-reply scope syntax.
|
||||
- The reply event is the receipt: its event id and timestamp are the proof the
|
||||
answer was received. `received_at` is the collector's own clock at receipt,
|
||||
not the reply's `origin_server_ts`.
|
||||
- Redaction is exit (see below): redacting the reply event sets `exited`.
|
||||
- The collector is on demand (it reads the room when run, it is not a standing
|
||||
listener). It drops:
|
||||
- redacted replies (recorded as exited);
|
||||
- replies from unlinked senders;
|
||||
- replies to an old sample version when the tally is for a newer one.
|
||||
|
||||
## What Matrix cannot guarantee
|
||||
|
||||
- **Receipt outliving a deleted reply.** Redaction removes content, and
|
||||
homeserver retention varies. If the collector holds a receipt for a reply that
|
||||
later disappears, it keeps a tombstone: `event_id`, `sample_id`, `received_at`,
|
||||
and a salted hash of `text`. Hash only, never text, and no `member_id`
|
||||
beyond what the record's key already holds. The count and the exit are
|
||||
provable without the content. The record is marked `exited`; `text` is erased.
|
||||
The tombstone is the exited `Answer` record itself, with `text_hash` set; it is
|
||||
not a separate record.
|
||||
- **Edits.** An edit is a new event, not the receipt. Only the original event
|
||||
counts; edits are ignored, so what was accepted is what was received. To
|
||||
change an answer, redact and reply again.
|
||||
- **Bridge spoofing.** A bridge can post as any ghost user. Only senders whose
|
||||
Matrix id resolves through Heimdall's linked identity to a member count;
|
||||
bridged ghost ids are not linked identities. A bridge cannot raise scope: scope
|
||||
comes only from the room's notice state event.
|
||||
- **Bridged origin.** Events that arrive through a bridge are dropped by origin,
|
||||
not by judging their text.
|
||||
|
||||
## What the respondent sees
|
||||
|
||||
Posted with the sample, in the room:
|
||||
|
||||
- the notice: its scope in one line, that the first reply accepts it, and how to
|
||||
exit;
|
||||
- the notice version.
|
||||
|
||||
After answering, the respondent sees their receipt: the thread reply itself,
|
||||
plus the collector's acknowledgement that names the notice version and scope it
|
||||
recorded.
|
||||
|
||||
The consent and vote controls make no reward sound, animation or other
|
||||
reinforcement. A reward is issued later, by the rewarding service, never as
|
||||
feedback to the act of consenting or voting.
|
||||
|
||||
## How to exit
|
||||
|
||||
Redact your reply. That is all. The next collection run sets `exited`, erases
|
||||
`text`, and excludes the record from every later tally and from training. It
|
||||
cannot undo past use: tallies already shown and anything already exported
|
||||
cannot be recalled by Matrix or by the collector. The experiment's page states
|
||||
the export cadence so the window is known.
|
||||
|
|
@ -77,37 +77,3 @@ patron points locally.
|
|||
This route is a bridge from auth-owned Patreon evidence to Bifrost-owned
|
||||
governance meaning. Heimdall still does not own points, votes, tiers, ledger
|
||||
history, or payout policy.
|
||||
|
||||
## App-asserted step-up
|
||||
|
||||
Bifrost's profile carries `stepUpAssertion: { methods: ["bifrost-passkey",
|
||||
"bifrost-email-code"] }`, the only such grant. With its secret and a member's
|
||||
live Heimdall bearer, Bifrost calls `POST /v1/apps/bifrost/step-up` with
|
||||
`{method, nonce}` after it has checked the factor itself, and receives an access
|
||||
token for the same session whose `step_up_at` and `step_up_method` satisfy
|
||||
Heimdall's unlink check. Bifrost must send a fresh nonce per assertion, keep
|
||||
the returned token only for the action it is for, and never send a method it did
|
||||
not verify: Heimdall cannot tell. Contract, codes, limits and what it does not
|
||||
prove are in `docs/service-contract.md`.
|
||||
|
||||
Precondition and limit: Bifrost can call this only while it holds the member's
|
||||
Heimdall bearer, which it deliberately does not today. Holding it in a cookie
|
||||
ticket would turn cookie theft into bearer theft and, with a passkey or email
|
||||
code, into unlink power. And unlink accepts any method: a `bifrost-email-code`
|
||||
proof can unlink an identity a Google re-authentication would have guarded.
|
||||
Requiring a method per route is the later hardening (identity moves).
|
||||
|
||||
## Identity moves
|
||||
|
||||
Bifrost's profile carries `identityMoves: { accountProofMethods:
|
||||
["bifrost-passkey"] }`, the only such grant (its methods are a subset of
|
||||
`stepUpAssertion.methods`). With its secret, `POST /v1/apps/bifrost/identity-moves`
|
||||
moves logins from one Heimdall account to another when it presents fresh proofs
|
||||
of both: Bifrost's merge saga owns survivor choice, preview and undo timing;
|
||||
Heimdall moves what the proofs cover. A proof through one login moves only that
|
||||
login; an asserted `bifrost-passkey` proof covers the whole source account;
|
||||
`bifrost-email-code` proves an inbox and does not. A multi-login source without a
|
||||
passkey therefore needs one fresh sign-in per login. Until the Google client is
|
||||
live, a member's strong proof is the asserted passkey only; Discord and Patreon
|
||||
give weak (300 s) proofs. Contract, refusal order and idempotency are in
|
||||
`docs/service-contract.md`.
|
||||
|
|
|
|||
|
|
@ -336,49 +336,6 @@ access token (`revoked`) or no refresh is due (`current`; due means a known
|
|||
expiry within 5 minutes, a stored refresh token, and a runtime that refreshes).
|
||||
It never decides that a refresh happens.
|
||||
|
||||
### One writer for `linked_identities` moves
|
||||
|
||||
An identity belongs to the account that holds it. `upsertLinkedIdentity` writes
|
||||
the holder's own row (`ON CONFLICT (provider, provider_user_id) DO UPDATE ...
|
||||
WHERE linked_identities.account_id = EXCLUDED.account_id`; it never sets
|
||||
`account_id`) and throws `IdentityHeldElsewhereError` when another account holds
|
||||
the identity. `unlinkLinkedIdentity` is the only delete: one transaction locks
|
||||
the account row, refuses the account's last identity, and deletes the row only
|
||||
for the account that holds it. The store role therefore holds DELETE on
|
||||
`linked_identities`, checked at startup. The HTTP side (`/v1/me/identities`) is
|
||||
in `service-contract.md`; an unlink needs a step-up proof inside 600 seconds.
|
||||
|
||||
`moveIdentities` is the only statement that sets `linked_identities.account_id`
|
||||
to another account, reached only through `POST /v1/apps/{app}/identity-moves`.
|
||||
One transaction locks both accounts in id order (opposite moves queue, they do
|
||||
not deadlock), answers a repeated `moveId` from its `identity_moves` row,
|
||||
re-checks the proof sessions, applies `decideMove` (one pure function both stores
|
||||
call, so memory and Postgres cannot disagree), moves the rows with credentials
|
||||
stripped, revokes the source's sessions, expires its pending links and unredeemed
|
||||
completions, and appends the row. `identity_moves` is append-only (SELECT and
|
||||
INSERT only, checked at startup). An alias (an account with no login and an
|
||||
unreversed move from it) is derived inside the transaction to refuse moves into
|
||||
or out of it, never stored; nothing resolves an alias to its destination, because
|
||||
no consumer needs it and a chain of moves would make one hop wrong.
|
||||
|
||||
### Step-up proof has two sources
|
||||
|
||||
An access token's `step_up_at` is set in exactly two places: the `sign_in`
|
||||
step-up callback (Heimdall asked the provider to re-authenticate; label
|
||||
`provider:<name>`) and `assertStepUp`, the app-asserted route (the app says it
|
||||
checked a factor; label from the profile's `stepUpAssertion.methods`). Both
|
||||
feed the one reader, `proofTier` in `claims.ts` (unlink and identity moves both
|
||||
read it: `strong` is a step-up inside 600 s, `weak` a sign-in inside 300 s via
|
||||
`auth_at`), and a refresh sets neither. `proofCovers` says what a proof speaks
|
||||
for: only a strong proof whose method the profile lists as an account proof
|
||||
covers the whole account; any other proof covers the one login in
|
||||
`auth_identity_id`. The
|
||||
asserted route trusts the calling app and is bounded by the app secret, a live
|
||||
bearer for that app, the method allowlist, a nonce claimed once per session
|
||||
through `claimOAuthStateUse` (phase `step_up_assert`, no new table or grant;
|
||||
retained to the session expiry as of the claim, which a refresh may slide past),
|
||||
and in-process fixed-window limits (`src/fixed-window.ts`).
|
||||
|
||||
### Three nested guards
|
||||
|
||||
1. **Single flight, per process.** `refreshesInFlight` is keyed by store, then
|
||||
|
|
@ -490,12 +447,13 @@ builder was not used.
|
|||
process, so N Heimdall processes can present one token N times after a
|
||||
transient failure. Needs a failed-attempt marker on the row, read under the
|
||||
lock. Must land before or with Idunn onboarding.
|
||||
- `joystick:follow_up:connect-start-needs-app-auth` (Heimdall side done): a
|
||||
connect start that names a connection target needs the app's own secret for
|
||||
every managed provider, and a blank target is refused at start and at the
|
||||
callback mint. What StreamPixels must do on its side is in
|
||||
`notes/streampixels-connect-redeem.md`; its redeem must refuse a creator
|
||||
connect whose target it did not start.
|
||||
- `joystick:follow_up:connect-start-needs-app-auth`: connect starts are
|
||||
unauthenticated, so anyone can name `{creator, targetId}`; the callback
|
||||
re-check proves a binding exists, not that the caller owns it. It also owns
|
||||
the whitespace-only `targetId` that passes both gates and the callback's
|
||||
fail-open `''` fallback for a vanished profile (finding
|
||||
`joystick:finding:cut-h1.s4.whitespace-targetid-passes-custody-gate`). Must
|
||||
land before the public Joystick bot or a second creator.
|
||||
- `joystick:follow_up:heimdall-idunn-onboarding` (gamecult-ops): live Heimdall
|
||||
is a hand-copied release under `heimdall.service`, not an Idunn-admitted
|
||||
generation; the recipe's Joystick lines are declared but unproven by a
|
||||
|
|
|
|||
|
|
@ -198,35 +198,6 @@ Failure modes:
|
|||
secret, or another app's, is refused before any state is signed. `sign_in`
|
||||
starts carrying a `connection` are `400 connection_not_allowed_in_sign_in`
|
||||
|
||||
Step-up (`stepUp: true`, sign_in only, off by default):
|
||||
|
||||
- Asks the provider for a real re-authentication on the authorize URL, so a
|
||||
provider session already open in the browser is not enough to complete the
|
||||
sign-in. The start request field is `stepUp`; the signed state carries it as
|
||||
`step_up`, and the launch hop builds the provider URL from the state alone.
|
||||
Nothing on the callback or redeem side can set or clear it (a `stepUp` in a
|
||||
redeem body is ignored), and a completion reports it as `stepUpRequested: true`
|
||||
when the start asked.
|
||||
- It is the authenticated app's request, like a connection target: no secret or
|
||||
another app's is `401 app_auth_required`; a `link` or `connect` start carrying
|
||||
it is `400 step_up_only_in_sign_in`; `stepUp: false` is the same as absent.
|
||||
- Provider parameters (`stepUpAuthorizationParams`, `src/providers.ts`): Twitch
|
||||
`force_verify=true`, YouTube `prompt=login consent` (consent is kept; the
|
||||
provider always asks it), Google `prompt=login`. **The strengths differ.**
|
||||
Google `prompt=login` forces the person to re-authenticate. Twitch
|
||||
`force_verify` re-shows the authorize page: it proves a person at that browser
|
||||
chose to continue, not that they entered credentials. A consumer that needs
|
||||
credentials re-entered must not rely on Twitch step-up.
|
||||
- A provider with no such parameter (Discord, Patreon, GitHub, Spotify,
|
||||
Joystick) is `400 step_up_not_supported`, with the provider named, before any
|
||||
state is signed. The URL builder throws rather than omit the step.
|
||||
- A `backend_callback` handoff delivery does not carry `stepUpRequested`; only the redeemed completion does.
|
||||
- A state signed by a build with step-up and launched by an older build loses the step
|
||||
silently (the older parser ignores `step_up`): deploy every Heimdall instance on one
|
||||
release, with no mixed window.
|
||||
- Heimdall requests the step; it cannot observe that the provider honoured it.
|
||||
`stepUpRequested` says "asked", not "verified".
|
||||
|
||||
Launch note:
|
||||
|
||||
- Discord and Patreon are the implemented provider runtimes for Repixelizer
|
||||
|
|
@ -548,256 +519,6 @@ Important behavior:
|
|||
- the endpoint is not a provider OAuth start path and should not produce a
|
||||
provider authorization URL
|
||||
|
||||
### `GET /v1/me/identities`
|
||||
|
||||
The identities the signed-in account holds, for a member's own view.
|
||||
|
||||
Authentication: a live Heimdall access token for any app as
|
||||
`Authorization: Bearer` (the same check as `/v1/me/grants`). `401
|
||||
sign_in_required` otherwise. The answer is `Cache-Control: no-store`.
|
||||
|
||||
```json
|
||||
{
|
||||
"accountId": "uuid",
|
||||
"identities": [
|
||||
{
|
||||
"id": "uuid",
|
||||
"provider": "twitch",
|
||||
"providerUserId": "12345",
|
||||
"username": "name",
|
||||
"displayName": "Name",
|
||||
"linkedAt": "2026-11-13T12:00:00.000Z",
|
||||
"scopes": ["user:read:email"]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`username` and `displayName` are present when the provider gave them. The
|
||||
answer never carries a token column, the stored provider profile or the
|
||||
account's email.
|
||||
|
||||
### `POST /v1/me/identities/{identityId}/unlink`
|
||||
|
||||
Takes one identity off the signed-in account. It is the only way a linked
|
||||
identity is deleted, and it never takes the account's last one.
|
||||
|
||||
Authentication: a live Heimdall access token for any app, and fresh proof. The
|
||||
body is empty or `null`.
|
||||
|
||||
- `401 sign_in_required`: no live bearer.
|
||||
- `403 step_up_required`: the access token carries no `step_up_at` claim, or it
|
||||
is in the future, or it is older than 600 seconds
|
||||
(`STEP_UP_PROOF_TTL_SECONDS`, `src/claims.ts`). The remedy is a `sign_in`
|
||||
start with `stepUp: true` (see step-up above) through an identity on the
|
||||
account whose provider has re-authentication parameters, today twitch,
|
||||
youtube and google, then the access token that sign-in returns.
|
||||
- `404 identity_not_found`: no such identity, or one that belongs to another
|
||||
account. The two are not distinguishable.
|
||||
- `409 last_identity`: the account holds no other identity.
|
||||
- `200`: `{"unlinked": {"id", "provider", "providerUserId"}, "identities": [...]}`
|
||||
with the remaining list in the shape above.
|
||||
|
||||
`step_up_at` (epoch seconds) is set on the access claim only when the callback's
|
||||
signed state has `step_up` true and mode `sign_in`, or by an app's `POST
|
||||
/v1/apps/{appSlug}/step-up` assertion (below). A refresh never carries it:
|
||||
tokens a refresh issues cannot unlink, so proof is renewed only by a new step-up
|
||||
sign-in. The unlink writes the audit event `identity_unlinked` (provider and
|
||||
identity id only). When the account then holds no identity of that provider, the
|
||||
revocation receipts for its live grants of that provider are written, as the
|
||||
grants revoke route writes them.
|
||||
|
||||
A Twitch step-up proof is weaker than YouTube's or Google's: `force_verify` does
|
||||
not force credential re-entry (see the step-up note under the OAuth start
|
||||
section, "The strengths differ").
|
||||
|
||||
Limits that stay:
|
||||
|
||||
- Access tokens issued before the unlink keep the unlinked identity in their
|
||||
`identities` claim and its facts until they expire (`sessionTtlSeconds`).
|
||||
- Discord and Patreon identities cannot give proof (no re-authentication
|
||||
parameter); an account holding only those cannot unlink.
|
||||
- A stolen access token minted by a step-up sign-in can unlink more than one
|
||||
identity during its 600 second window.
|
||||
- A sign-in through a provider whose identity is no longer linked mints a new
|
||||
account.
|
||||
|
||||
A link no longer moves an identity: `upsertLinkedIdentity` refuses an identity
|
||||
another account holds (`links/confirm` answers `409 identity_linked_elsewhere`,
|
||||
the callback `link_initiator_mismatch`). An unlinked identity belongs to no
|
||||
account and can be linked by any account through the normal link flow.
|
||||
|
||||
### `POST /v1/apps/{appSlug}/step-up`
|
||||
|
||||
An app asserts that a member just passed a factor the app itself checked (for
|
||||
Bifrost: a passkey assertion or an emailed code), and receives an access token
|
||||
that carries that proof. The unlink route's step-up check then passes unchanged.
|
||||
|
||||
Authentication: both, always.
|
||||
|
||||
- the app's secret in `x-heimdall-app-secret`, as for every app route; and
|
||||
- the member's live Heimdall access token for the same app as
|
||||
`Authorization: Bearer`.
|
||||
|
||||
Body (`additionalProperties` are dropped as on the sibling routes):
|
||||
|
||||
```json
|
||||
{ "method": "bifrost-passkey", "nonce": "16 to 128 characters, fresh per assertion" }
|
||||
```
|
||||
|
||||
Nonce: 16 to 128 characters of the base64url alphabet (`A-Za-z0-9_-`), so a
|
||||
random token fits and spaces, controls and lone surrogates are a 400 schema
|
||||
refusal. `method` is 1 to 64 characters and then must be in the profile's set.
|
||||
|
||||
Schema validation (body shape, `method` length, the nonce pattern and length,
|
||||
the app slug) runs before the secret is checked and answers `400` with
|
||||
Fastify's fixed schema error, the same for every caller whatever the secret and
|
||||
never audited. Then the order of refusals, each a closed `error` code:
|
||||
|
||||
- `401 app_auth_required`: missing, wrong or another app's secret. The first
|
||||
check of the handler; nothing is written.
|
||||
- `403 step_up_assertion_not_permitted`: the app's profile has no
|
||||
`stepUpAssertion` grant. Only `bifrost` has one; every other app is refused
|
||||
with its own secret. This branch has its own small budget (5 per 60 s per
|
||||
app); past it the answer is 429.
|
||||
- `429 step_up_rate_limited` (`retryAfterSeconds`): the per-app budget (120 per
|
||||
60 s, spent by every attempt that passed the secret and the grant, including
|
||||
ones whose bearer then fails) is spent. In-process fixed windows: they reset
|
||||
when the process restarts and are not shared between instances.
|
||||
- `401 sign_in_required`: the bearer is missing, malformed, expired, signed by
|
||||
another key, a refresh token, or rests on a session that is not live (no row,
|
||||
wrong account, revoked or expired, revision moved). One answer for all of
|
||||
them; it does not say whether an account exists.
|
||||
- `403 bearer_app_mismatch`: a live bearer issued to another app. An app cannot
|
||||
assert for a token issued to another app. It tells only a holder of the
|
||||
secret and a valid bearer that the bearer is live and for another app, which
|
||||
that holder already knows.
|
||||
- `429 step_up_rate_limited`: the per-account budget (10 per 600 s) is spent.
|
||||
It is checked after the bearer and its audience, before method and nonce.
|
||||
- `400 unknown_step_up_method`: `method` is not in the profile's closed set
|
||||
(`methods` in the answer lists it). Bifrost: `bifrost-passkey`,
|
||||
`bifrost-email-code`. Provider labels (`provider:*`) cannot be asserted.
|
||||
- `409 step_up_nonce_replayed`: that nonce was already spent for this session.
|
||||
The claim is the store's `oauth_state_uses` single-use primitive with phase
|
||||
`step_up_assert` and the key a SHA-256 of session id and nonce, so it holds
|
||||
across restarts and instances. **Retention is the session's expiry as of the
|
||||
claim plus 10.5 minutes (the 10 minute prune margin and 30 s clock skew), and
|
||||
pruning is lazy: each successful claim deletes at most 50 expired rows.** A refresh slides the session's expiry
|
||||
without bound, so a nonce can be pruned while its session is still live and
|
||||
then be accepted again. Replaying it still needs the secret and a live
|
||||
bearer, which is the power to assert with a fresh nonce; the nonce guards a
|
||||
captured request, not a compromised app. A refused method or a rate-limited
|
||||
attempt does not spend the nonce.
|
||||
- `201`: `{"accessToken", "claimSet", "stepUp": {"at", "method", "validForSeconds"}}`,
|
||||
`Cache-Control: no-store`.
|
||||
|
||||
What the minted token is: a copy of the bearer's claims with a new `jti`,
|
||||
`iat` and `nbf`, `step_up_at` = now and `step_up_method` = the asserted method.
|
||||
It keeps the bearer's `exp`, same `sid` and account. Facts, capabilities and
|
||||
identities are the bearer's and are not recomputed, so the route adds proof and
|
||||
no capability. It mints no refresh token and writes no session row: it cannot
|
||||
extend a lifetime or a session. A later refresh carries neither claim. The proof
|
||||
is good for the usual 600 seconds (`STEP_UP_PROOF_TTL_SECONDS`) and only until
|
||||
the token's own `exp`.
|
||||
|
||||
Receipts: each assertion writes the audit event `step_up_asserted`, and each
|
||||
refusal after the secret verified writes `step_up_assertion_refused`, except
|
||||
that two kinds are not recorded row by row: a rate-limited refusal is recorded
|
||||
once per window per budget (its first; the row's `budget` names `app`,
|
||||
`account` or `notPermitted`), and an app without the grant once per window.
|
||||
Every other refusal under budget still writes a row each, up to about 121 a
|
||||
minute for a holder of the secret, and `audit_events` has no retention: that is
|
||||
an open follow-up. Rows carry the
|
||||
app, the account and session when the bearer resolved, `method` (null when it
|
||||
was not in the allowlist), `outcome`, `code`, `budget` (rate-limit rows) and `nonceDigest`. Never the
|
||||
secret, the bearer, the minted token or the nonce. A request without the secret,
|
||||
or failing schema validation, writes nothing.
|
||||
|
||||
Precondition: the route is usable only once the app holds the member's Heimdall
|
||||
bearer. Bifrost deliberately does not hold it today. Putting the bearer in a
|
||||
cookie ticket would turn cookie theft into bearer theft and, with
|
||||
`bifrost-passkey` or `bifrost-email-code`, into unlink power; whether and how
|
||||
Bifrost keeps the bearer is its own decision and is not made here.
|
||||
|
||||
Unlink accepts any method today. A `bifrost-email-code` proof unlinks an
|
||||
identity a Google re-authentication would have guarded. Identity moves
|
||||
(below) are the first route to require a method for whole-account coverage;
|
||||
unlink is unchanged.
|
||||
|
||||
What this does not prove: Heimdall cannot see the passkey or the code. It trusts
|
||||
the calling app's word, exactly as it trusts `stepUpRequested` (asked, not
|
||||
verified). The word is bounded by the app secret, a live member bearer for that
|
||||
app, the profile's method allowlist, a single-use nonce, the proof window and
|
||||
the rate limits. A compromised Bifrost can assert proofs only for members whose
|
||||
live bearer it holds. `identity_unlinked` records the proof's `stepUpMethod`
|
||||
(`provider:<name>`, an app label, or null), and unlink does not yet require any
|
||||
particular method.
|
||||
|
||||
### `POST /v1/apps/{appSlug}/identity-moves`
|
||||
|
||||
The one sanctioned way a login changes account (`moveIdentities` is the only
|
||||
writer of `linked_identities.account_id`). Bifrost owns the merge: survivor
|
||||
choice, preview, grace and undo timing. Heimdall receives the choice as which
|
||||
proofs fill which side, renders no preview and picks nothing. Only a profile with
|
||||
`identityMoves` may call it (today bifrost).
|
||||
|
||||
Credential: the app's secret. Body (closed; unknown properties are ignored, and
|
||||
no account is ever named by the body):
|
||||
|
||||
- `moveId` (`^[A-Za-z0-9_-]{16,64}$`): the caller's idempotency key.
|
||||
- `fromProofs`: 1 to 8 live Heimdall access tokens for this app, all of the
|
||||
source account.
|
||||
- Forward move: `toProof` (a live token of the destination) and optionally
|
||||
`identityIds` (1 to 32; default every login the source holds).
|
||||
- Reversal: `reversesMoveId` instead of `toProof`/`identityIds`. It moves back
|
||||
exactly the logins the forward move carried, once; `fromProofs` must be of the
|
||||
forward move's destination. A reversal is not itself reversible.
|
||||
|
||||
Each proof must be fresh: a step-up inside 600 s (`strong`) or a sign-in inside
|
||||
300 s (`weak`), by `proofTier`, the same freshness rule the unlink route reads.
|
||||
A token with neither claim (a refresh, a link) is stale.
|
||||
|
||||
Coverage: a proof through login X (`auth_identity_id`) moves X and nothing else
|
||||
from that account. Only a strong proof asserted with a method in the profile's
|
||||
`identityMoves.accountProofMethods` (`bifrost-passkey`; not `bifrost-email-code`,
|
||||
which proves an inbox) speaks for the whole account. Moving several logins takes
|
||||
one proof per login, or the passkey.
|
||||
|
||||
Order of refusals, each a fixed code: `401 app_auth_required`; `403
|
||||
not_allowed_for_app`; a replayed `moveId` is answered next (below); `409
|
||||
not_reversible` (reversal names a missing, foreign or reversal move); `403
|
||||
proof_invalid` `{proof: "from"|"to"}` (not a live bearer of this app, or
|
||||
`fromProofs` naming two accounts); `409 same_account`; `403 proof_stale`
|
||||
`{proof}`; `403 identity_not_proved`; then the store: `403 proof_invalid` (a
|
||||
proof session lapsed under the lock), `409 already_moved` (either side is an
|
||||
alias), `409 provider_conflict` `{provider}`, `409 not_reversible`, `409
|
||||
move_id_conflict`, `404 identity_not_found`, `403 identity_not_proved`. A refusal
|
||||
body is `{error, detail?, proof?, provider?}` and never names an account id, an
|
||||
identity the caller did not send, an email or a name.
|
||||
|
||||
On success, in one transaction: the logins move to the destination with their
|
||||
stored credentials stripped; every live session of the source is revoked and its
|
||||
pending links and unredeemed completions expire; one append-only `identity_moves`
|
||||
row is written. Afterwards, for each moved provider the source no longer holds,
|
||||
its live grants get revocation receipts under the source, and `identity_moved`
|
||||
is audited. Answer `200`, `Cache-Control: no-store`:
|
||||
`{moveId, accountId: <destination>, movedIdentityIds, proof: {from, to}, reversesMoveId?}`,
|
||||
with `proof.from` `strong` if any from-proof was strong and `to` null on a
|
||||
reversal.
|
||||
|
||||
Idempotency: the same `moveId` with the same app, accounts and `reversesMoveId`
|
||||
answers the stored receipt byte for byte, even though the first success revoked
|
||||
the source's sessions: the proofs are then checked for a valid Heimdall
|
||||
signature, issuer, type and audience, and account, not for liveness. The replay
|
||||
re-runs the revocation receipts, so a crash between commit and receipts heals on
|
||||
the caller's retry. Anything else under a used `moveId` is `409 move_id_conflict`.
|
||||
|
||||
What a move leaves: an account holding no login with an unreversed move from it
|
||||
is an alias: derived at move time, never stored, and no route resolves it. A
|
||||
move into or out of an alias is `already_moved`. Bearers of
|
||||
the destination keep their old identity list until they refresh. A sign-in
|
||||
through a moved login lands on the destination because the row moved.
|
||||
|
||||
### `POST /v1/apps/{appSlug}/managed-credentials/resolve`
|
||||
|
||||
Purpose:
|
||||
|
|
@ -846,34 +567,12 @@ Important behavior:
|
|||
identity (`SELECT ... FOR UPDATE`), so a given refresh token is presented to
|
||||
the provider once across concurrent requests and across two Heimdall
|
||||
processes sharing one database. Within one process, concurrent refreshes of
|
||||
one identity share a single attempt and its outcome (success or failure). A
|
||||
failed or timed-out attempt is recorded on the identity row
|
||||
(`refresh_failed_at`, written in the same transaction as the lock), and no
|
||||
process presents that row's refresh token again for 60 seconds: a process
|
||||
that takes the lock inside the backoff answers the same
|
||||
`managed_credential_refresh_failed` without calling the provider. A stamp
|
||||
more than 60 seconds in the future (a writer whose clock runs ahead) is skew,
|
||||
not a fresh failure, so the wait is capped at twice the backoff. A rotation,
|
||||
a revocation or a re-link clears the marker.
|
||||
The marker is a best-effort write on a second connection, made after the
|
||||
lock's transaction rolled back; it is not part of that transaction. When the
|
||||
rotation or the revocation the provider's answer called for cannot be
|
||||
committed, the fallback write leaves the marker and blanks the refresh token
|
||||
only when that token is spent: on a revoke (a rotating provider answered
|
||||
without a refresh token, or the grant was rejected), or when the provider
|
||||
rotates refresh tokens (Joystick) and so consumed it. A provider that keeps
|
||||
its refresh token valid (Twitch) keeps it, so a transient database failure
|
||||
does not strand the link: the next caller after the backoff presents it again.
|
||||
Residual risk: the provider may consume the refresh token as soon as the
|
||||
request is sent. If a Heimdall process dies after that and before the commit
|
||||
of the marker, the rotation or the revocation, or if the blanking fallback
|
||||
write fails too, nothing is recorded, and the
|
||||
next caller can present the refresh token the provider already consumed; for
|
||||
a provider that rotates (Joystick) that reads as token theft. The Heimdall
|
||||
steward ruled (#minds, 2026-11-08) that an intent log, written on its own
|
||||
connection before the provider call, replaces this marker; it is tracked in
|
||||
`joystick:follow_up:write-ahead-refresh-attempt-record`.
|
||||
The provider call is bounded at 10 seconds.
|
||||
one identity share a single attempt and its outcome (success or failure), so
|
||||
nobody re-presents a token behind a failed holder in that burst. Residual
|
||||
risk, tracked as follow-up `joystick:follow_up:cross-process-failed-refresh-marker`:
|
||||
the guard is one presentation per process per burst, so N
|
||||
Heimdall processes can present one token N times after a transient failure.
|
||||
The provider call is bounded at 10 seconds
|
||||
- a provider whose refresh tokens rotate (Joystick) fails closed: a refresh
|
||||
response without a new refresh token revokes the credential instead of
|
||||
keeping the old token
|
||||
|
|
@ -933,10 +632,6 @@ Current claim payload fields:
|
|||
- `typ`
|
||||
- `account_id`
|
||||
- `access_revision`
|
||||
- `step_up_at` optional: epoch seconds of the proof; set only by a `sign_in` step-up callback or `POST /v1/apps/{appSlug}/step-up`, never by a refresh
|
||||
- `step_up_method` optional: present exactly when `step_up_at` is; `provider:<name>` for a provider step-up sign-in, otherwise the method label the app asserted
|
||||
- `auth_at` optional: epoch seconds of the sign-in this token was minted by; set only by a `sign_in` callback (never link, connect or refresh), always with `auth_identity_id`
|
||||
- `auth_identity_id` optional: the `linked_identities.id` that sign-in came through; the only record of which login proved the session. An asserted step-up spreads the bearer's claims, so these pass through unchanged
|
||||
- `display_name` optional
|
||||
- `app.slug`
|
||||
- `app.profile_version`
|
||||
|
|
|
|||
|
|
@ -1,35 +0,0 @@
|
|||
# For Kiko: what StreamPixels must do for a creator connect
|
||||
|
||||
Heimdall's side is done (this PR). A connect start that carries a `connection`
|
||||
target is accepted only with StreamPixels' own `x-heimdall-app-secret`, so only
|
||||
the StreamPixels backend can mint a state naming `{kind: "creator", targetId}`.
|
||||
Heimdall cannot see StreamPixels' session; the secret proves the caller is the
|
||||
backend, and the backend must have proved the creator-manage session itself
|
||||
before it starts the connect.
|
||||
|
||||
What StreamPixels has to change (not done here; this repo does not touch it):
|
||||
|
||||
1. **Start the connect only after the creator-manage check.** The route that
|
||||
calls `POST /v1/oauth/:provider/start` with `mode: "connect"` must have
|
||||
verified, for the signed-in StreamPixels user, that they may manage creator
|
||||
`targetId`. Never take `targetId` from the browser unchecked.
|
||||
2. **Remember what you started.** Store, server-side and bound to the attempt
|
||||
(the `attemptId` or browser session), the `targetId` StreamPixels asked
|
||||
Heimdall to bind.
|
||||
3. **Redeem with that stored target, never one read back from the request or
|
||||
the completion.** `POST /v1/apps/streampixels/auth/redeem` takes `targetId`;
|
||||
Heimdall answers `403 completion_target_mismatch` (code left unconsumed) when
|
||||
it differs from the signed state's. That only protects you if the value you
|
||||
send is the one you stored at step 2. A redeem with no stored target for this
|
||||
attempt must be refused by StreamPixels before it calls Heimdall.
|
||||
4. **Refuse a creator connect you did not start.** If a connect completion
|
||||
arrives for a `targetId` with no matching started attempt, do not rebind that
|
||||
creator's connector, whatever Heimdall's completion says.
|
||||
5. **A connect completion with no creator target is not a creator connect and
|
||||
not a sign-in.** Joystick is managed-connection only: a non-creator Joystick
|
||||
completion is an error. (Twitch/YouTube `connect` without a `connection` is
|
||||
Heimdall's viewer-session path; treat it as that, never as a connector rebind.)
|
||||
6. Blank targets (`" "`, zero-width characters) are refused by Heimdall at
|
||||
start with `400 invalid_connection_target`; send real ids only.
|
||||
|
||||
Tracked as `joystick:follow_up:connect-start-needs-app-auth`.
|
||||
|
|
@ -56,22 +56,6 @@ export interface AppProfile {
|
|||
* state. Absent for apps whose browser starts are public by design.
|
||||
*/
|
||||
startRequiresAppCaller?: boolean;
|
||||
/**
|
||||
* The app may assert, with its secret and a member's live bearer, that the
|
||||
* member just passed a factor the app itself checked (POST
|
||||
* /v1/apps/{app}/step-up). Heimdall cannot see that factor and trusts the
|
||||
* app, so the grant is explicit per app and closed per method: only these
|
||||
* labels may be asserted, and they end up in the claim's step_up_method.
|
||||
* Absent for every app that may not assert.
|
||||
*/
|
||||
stepUpAssertion?: { methods: readonly string[] };
|
||||
/**
|
||||
* The app may move linked identities between two accounts it holds proofs
|
||||
* for (POST /v1/apps/{app}/identity-moves). `accountProofMethods` are the
|
||||
* asserted step_up_method labels that prove a whole account rather than one
|
||||
* login; each must be one of stepUpAssertion.methods. Absent: the app may not move.
|
||||
*/
|
||||
identityMoves?: { accountProofMethods: readonly string[] };
|
||||
}
|
||||
|
||||
/** The membership signal shared by every app that gates on GameCult membership. */
|
||||
|
|
@ -170,9 +154,6 @@ const bifrostProfile: AppProfile = {
|
|||
managedConnectionProviders: [],
|
||||
allowedReturnOrigins: ["https://bifrost.gamecult.org"],
|
||||
startRequiresAppCaller: true,
|
||||
stepUpAssertion: { methods: ["bifrost-passkey", "bifrost-email-code"] },
|
||||
// A passkey proves the account; an email code proves an inbox, which is one login's worth at most.
|
||||
identityMoves: { accountProofMethods: ["bifrost-passkey"] },
|
||||
capabilities: [
|
||||
{
|
||||
key: "member_access",
|
||||
|
|
@ -282,17 +263,6 @@ export function isAllowedReturnOrigin(profile: AppProfile, returnTo: string): bo
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A connection target names something: at least one letter or number that is
|
||||
* not a default-ignorable code point (the Hangul and Khmer fillers are letters
|
||||
* that render as nothing). An allowlist, so marks, private-use, unassigned and
|
||||
* surrogate code points and symbols cannot stand in for a name. The one
|
||||
* definition the start gate and the callback mint both use.
|
||||
*/
|
||||
export function isUsableConnectionTarget(targetId: string): boolean {
|
||||
return /(?![\p{Default_Ignorable_Code_Point}])[\p{L}\p{N}]/u.test(targetId);
|
||||
}
|
||||
|
||||
/**
|
||||
* What a completed OAuth callback may mint, decided at the callback from the
|
||||
* signed state's mode and the provider's role in the profile (the start gate
|
||||
|
|
@ -319,7 +289,7 @@ export function callbackMint(
|
|||
return mode === "connect" &&
|
||||
profile.managedConnectionProviders.includes(provider) &&
|
||||
connection?.kind === "creator" &&
|
||||
isUsableConnectionTarget(connection.targetId)
|
||||
connection.targetId !== ""
|
||||
? "custody_only"
|
||||
: "refused";
|
||||
}
|
||||
|
|
|
|||
654
src/app.ts
654
src/app.ts
|
|
@ -1,4 +1,4 @@
|
|||
import { createHash, createHmac, randomUUID } from "node:crypto";
|
||||
import { createHmac, randomUUID } from "node:crypto";
|
||||
import { deflateRawSync, inflateRawSync } from "node:zlib";
|
||||
import cookie from "@fastify/cookie";
|
||||
import cors from "@fastify/cors";
|
||||
|
|
@ -24,9 +24,8 @@ import {
|
|||
type OAuthStartRequest,
|
||||
type Provider,
|
||||
} from "./contracts.js";
|
||||
import { issueAccessClaim, proofCovers, proofTier, signAssertedStepUpClaim, STEP_UP_PROOF_TTL_SECONDS, type IssueAccessClaimInput } from "./claims.js";
|
||||
import { FixedWindowBudget, type FixedWindowLimit } from "./fixed-window.js";
|
||||
import { builtInAppProfiles, getAppProfile, isAllowedReturnOrigin, serializeAppProfile, admitsProviderForMode, callbackMint, isUsableConnectionTarget } from "./app-profiles.js";
|
||||
import { issueAccessClaim, type IssueAccessClaimInput } from "./claims.js";
|
||||
import { builtInAppProfiles, getAppProfile, isAllowedReturnOrigin, serializeAppProfile, admitsProviderForMode, callbackMint } from "./app-profiles.js";
|
||||
import { renderBrowserHandoffPage } from "./browser-handoff.js";
|
||||
import { consentText, digestOfText, grantViews, GRANTS_PATH, renderConsentPage, renderGrantsPage, type ConsentAsk } from "./consent.js";
|
||||
import { type HeimdallConfig, loadConfig } from "./config.js";
|
||||
|
|
@ -42,12 +41,10 @@ import {
|
|||
import { ManagedCredentialRefreshFailed, refreshManagedCredential } from "./managed-credential.js";
|
||||
import { authorizationScopes, buildAuthorizationUrl, providerCatalog, providerExpectedEnv } from "./providers.js";
|
||||
import { createRuntimeKeyMaterial, signJwt, verifyJwt } from "./signing.js";
|
||||
import { createStore, IdentityHeldElsewhereError, type HeimdallStore } from "./store/index.js";
|
||||
import { createStore, type HeimdallStore } from "./store/index.js";
|
||||
import {
|
||||
OAUTH_STATE_SKEW_MS,
|
||||
type CreateAccountInput,
|
||||
type StoredCapabilityGrant,
|
||||
type StoredIdentityMove,
|
||||
type StoredLinkedIdentity,
|
||||
type UpsertLinkedIdentityInput,
|
||||
} from "./store/types.js";
|
||||
|
|
@ -102,7 +99,6 @@ function buildOAuthStateToken(options: {
|
|||
handoff: OAuthHandoff;
|
||||
entitlementPolicy: OAuthEntitlementPolicy | undefined;
|
||||
requestedScopes: string[] | undefined;
|
||||
stepUp: boolean | undefined;
|
||||
keys: ReturnType<typeof createRuntimeKeyMaterial>;
|
||||
}): { token: string; jti: string; expiresAt: string } {
|
||||
const issuedAt = nowEpochSeconds();
|
||||
|
|
@ -125,7 +121,6 @@ function buildOAuthStateToken(options: {
|
|||
handoff: options.handoff,
|
||||
entitlement_policy: options.entitlementPolicy ?? null,
|
||||
requested_scopes: options.requestedScopes ?? null,
|
||||
step_up: options.stepUp ? true : null,
|
||||
};
|
||||
const token = signJwt(payload as unknown as Record<string, unknown>, options.keys);
|
||||
|
||||
|
|
@ -189,8 +184,7 @@ function parseOAuthStatePayload(payload: Record<string, unknown>): OAuthStatePay
|
|||
!handoffIsValid ||
|
||||
!entitlementPolicyIsValid ||
|
||||
!(payload.initiator_account_id === undefined || payload.initiator_account_id === null || typeof payload.initiator_account_id === "string") ||
|
||||
!requestedScopesAreValid ||
|
||||
!(payload.step_up === undefined || payload.step_up === null || payload.step_up === true)
|
||||
!requestedScopesAreValid
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
|
@ -208,9 +202,6 @@ function parseOAuthStatePayload(payload: Record<string, unknown>): OAuthStatePay
|
|||
if (!normalizedPayload.requested_scopes) {
|
||||
normalizedPayload.requested_scopes = null;
|
||||
}
|
||||
if (!normalizedPayload.step_up) {
|
||||
normalizedPayload.step_up = null;
|
||||
}
|
||||
|
||||
return normalizedPayload;
|
||||
}
|
||||
|
|
@ -221,8 +212,8 @@ type OAuthStateVerdict =
|
|||
|
||||
/**
|
||||
* The one place a signed OAuth state becomes trusted: signature, shape, issuer,
|
||||
* audience and provider, and the time window with OAUTH_STATE_SKEW_MS of skew
|
||||
* (30 s, as refresh tokens use). Every route that turns a state into navigation or a completion
|
||||
* audience and provider, and the time window with the same 30 s skew refresh
|
||||
* tokens use. Every route that turns a state into navigation or a completion
|
||||
* runs this first; the refusals are fixed codes and never echo the token.
|
||||
*/
|
||||
function verifyOAuthState(
|
||||
|
|
@ -245,7 +236,7 @@ function verifyOAuthState(
|
|||
}
|
||||
|
||||
const now = nowEpochSeconds();
|
||||
if (payload.nbf > now + OAUTH_STATE_SKEW_MS / 1000 || payload.exp <= now - OAUTH_STATE_SKEW_MS / 1000) {
|
||||
if (payload.nbf > now + 30 || payload.exp <= now - 30) {
|
||||
return { ok: false, error: "state_expired" };
|
||||
}
|
||||
|
||||
|
|
@ -711,8 +702,14 @@ export async function startOAuthFlow(
|
|||
};
|
||||
}
|
||||
|
||||
if (input.stepUp && input.mode !== "sign_in") {
|
||||
return { statusCode: 400, body: { error: "step_up_only_in_sign_in", detail: "stepUp applies to sign_in starts only." } };
|
||||
if (callbackMint(profile, provider, input.mode, input.connection) === "refused") {
|
||||
return {
|
||||
statusCode: 400,
|
||||
body: {
|
||||
error: "creator_connection_required",
|
||||
detail: `${provider} on ${profile.slug} is a managed creator connection: connect needs connection.kind creator and a targetId.`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const descriptor = providerCatalog[provider];
|
||||
|
|
@ -757,19 +754,6 @@ export async function startOAuthFlow(
|
|||
return { statusCode: 400, body: { error: "initiator_only_in_link_mode" } };
|
||||
}
|
||||
|
||||
// A step-up is app-backend authority, like a connection target: it is
|
||||
// asked for by the authenticated app, never by a browser, and only on
|
||||
// sign_in. A provider with no re-authentication parameter is refused here,
|
||||
// at start, so no step-up ever reaches the person without its step.
|
||||
if (input.stepUp) {
|
||||
if (!isAppCaller(caller) || caller.appSlug !== profile.slug) {
|
||||
return { statusCode: 401, body: { error: "app_auth_required" } };
|
||||
}
|
||||
if (!providerCatalog[provider].stepUpAuthorizationParams) {
|
||||
return { statusCode: 400, body: { error: "step_up_not_supported", provider, detail: `${providerCatalog[provider].displayName} has no re-authentication parameter.` } };
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
input.requestedScopes !== undefined &&
|
||||
(input.requestedScopes.length > maxRequestedScopes || input.requestedScopes.some((scope) => scope.length > maxRequestedScopeChars))
|
||||
|
|
@ -827,24 +811,6 @@ export async function startOAuthFlow(
|
|||
return { statusCode: 401, body: { error: "app_auth_required" } };
|
||||
}
|
||||
|
||||
// After the app-secret gate, so a caller without the secret learns nothing about what a target must look like.
|
||||
if (input.connection !== undefined && !isUsableConnectionTarget(input.connection.targetId)) {
|
||||
return {
|
||||
statusCode: 400,
|
||||
body: { error: "invalid_connection_target", detail: "connection.targetId must name a target." },
|
||||
};
|
||||
}
|
||||
|
||||
if (callbackMint(profile, provider, input.mode, input.connection) === "refused") {
|
||||
return {
|
||||
statusCode: 400,
|
||||
body: {
|
||||
error: "creator_connection_required",
|
||||
detail: `${provider} on ${profile.slug} is a managed creator connection: connect needs connection.kind creator and a targetId.`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const providerConfig = config.providers[provider];
|
||||
if (!providerConfig.clientId) {
|
||||
return {
|
||||
|
|
@ -864,7 +830,6 @@ export async function startOAuthFlow(
|
|||
handoff,
|
||||
entitlementPolicy,
|
||||
requestedScopes: input.requestedScopes,
|
||||
stepUp: input.stepUp,
|
||||
keys,
|
||||
});
|
||||
return {
|
||||
|
|
@ -881,38 +846,28 @@ export async function startOAuthFlow(
|
|||
};
|
||||
}
|
||||
|
||||
/** What a live bearer proves: the account its session belongs to, and the claims it was signed with. */
|
||||
interface ResolvedBearer {
|
||||
accountId: string;
|
||||
claims: Record<string, unknown>;
|
||||
/** When the session row the bearer rests on expires (the refresh horizon, not the access token's `exp`). */
|
||||
sessionExpiresAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The one verifier of a Heimdall access token. The token must verify under
|
||||
* Heimdall's own key, be an access token for `appSlug` (or, when none is
|
||||
* named, for any app), be inside its validity window, and belong to a session
|
||||
* The account a link start binds to: the one a live Heimdall access token for
|
||||
* this app names. The token must verify under Heimdall's own key, be an access
|
||||
* token for `appSlug`, be inside its validity window, and belong to a session
|
||||
* the store still holds for that account at that revision. A bearer that fails
|
||||
* any of these proves nothing.
|
||||
* any of these binds nothing: a link is explicit or it does not happen.
|
||||
*/
|
||||
async function resolveBearer(
|
||||
async function resolveLinkAccount(
|
||||
ctx: Pick<HeimdallRuntimeContext, "config" | "keys" | "store">,
|
||||
authorization: string | undefined,
|
||||
appSlug?: AppSlug
|
||||
): Promise<ResolvedBearer | null> {
|
||||
appSlug: AppSlug,
|
||||
authorization: string | undefined
|
||||
): Promise<string | null> {
|
||||
const token = /^Bearer\s+(\S+)$/i.exec(authorization ?? "")?.[1];
|
||||
if (!token) return null;
|
||||
const verification = verifyJwt(token, ctx.keys.publicKey);
|
||||
if (!verification.valid) return null;
|
||||
const claims = verification.payload;
|
||||
const audience = appSlug ?? claims.aud;
|
||||
if (!isAppSlug(audience)) return null;
|
||||
const now = nowEpochSeconds();
|
||||
if (
|
||||
claims.typ !== "heimdall_access" ||
|
||||
claims.iss !== ctx.config.issuer ||
|
||||
claims.aud !== audience ||
|
||||
claims.aud !== appSlug ||
|
||||
typeof claims.account_id !== "string" ||
|
||||
typeof claims.sid !== "string" ||
|
||||
typeof claims.access_revision !== "number" ||
|
||||
|
|
@ -922,7 +877,7 @@ async function resolveBearer(
|
|||
) {
|
||||
return null;
|
||||
}
|
||||
const session = await ctx.store.findSession(audience, claims.sid);
|
||||
const session = await ctx.store.findSession(appSlug, claims.sid);
|
||||
if (
|
||||
!session ||
|
||||
session.accountId !== claims.account_id ||
|
||||
|
|
@ -931,16 +886,7 @@ async function resolveBearer(
|
|||
) {
|
||||
return null;
|
||||
}
|
||||
return { accountId: claims.account_id, claims, sessionExpiresAt: session.expiresAt };
|
||||
}
|
||||
|
||||
/** The account a link start binds to: the one a live Heimdall access token for this app names. A link is explicit or it does not happen. */
|
||||
async function resolveLinkAccount(
|
||||
ctx: Pick<HeimdallRuntimeContext, "config" | "keys" | "store">,
|
||||
appSlug: AppSlug,
|
||||
authorization: string | undefined
|
||||
): Promise<string | null> {
|
||||
return (await resolveBearer(ctx, authorization, appSlug))?.accountId ?? null;
|
||||
return claims.account_id;
|
||||
}
|
||||
|
||||
/** The account a Heimdall access token for any app names, when the token and its session are live. */
|
||||
|
|
@ -948,299 +894,12 @@ async function resolveAccessAccount(
|
|||
ctx: Pick<HeimdallRuntimeContext, "config" | "keys" | "store">,
|
||||
authorization: string | undefined
|
||||
): Promise<string | null> {
|
||||
return (await resolveBearer(ctx, authorization))?.accountId ?? null;
|
||||
}
|
||||
|
||||
/** What an app may spend asserting step-up proofs: per member account, and per calling app. */
|
||||
export const STEP_UP_ASSERT_LIMITS: { account: FixedWindowLimit; app: FixedWindowLimit; notPermitted: FixedWindowLimit } = {
|
||||
account: { max: 10, windowMs: 600_000 },
|
||||
app: { max: 120, windowMs: 60_000 },
|
||||
// An app whose profile has no grant, holding its own secret: a handful of refusals per window, no more.
|
||||
notPermitted: { max: 5, windowMs: 60_000 },
|
||||
};
|
||||
|
||||
export interface StepUpAssertBudgets {
|
||||
account: FixedWindowBudget;
|
||||
app: FixedWindowBudget;
|
||||
notPermitted: FixedWindowBudget;
|
||||
}
|
||||
|
||||
export function createStepUpAssertBudgets(now: () => number = () => Date.now()): StepUpAssertBudgets {
|
||||
return {
|
||||
account: new FixedWindowBudget(STEP_UP_ASSERT_LIMITS.account, now),
|
||||
app: new FixedWindowBudget(STEP_UP_ASSERT_LIMITS.app, now),
|
||||
notPermitted: new FixedWindowBudget(STEP_UP_ASSERT_LIMITS.notPermitted, now),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /v1/apps/{app}/step-up. The app says its member just passed a factor
|
||||
* the app checked; Heimdall cannot see the factor, so the word is bounded by
|
||||
* what the route demands: the app's secret AND the member's live bearer for
|
||||
* that same app, the profile's explicit grant and method allowlist, a nonce
|
||||
* spent once per session, and a rate per account and per app. Every
|
||||
* refusal after the secret verified is recorded, except that a flood is not:
|
||||
* a rate-limited refusal is recorded once per window per budget (its first),
|
||||
* and an app without the grant is recorded once per window; a request without
|
||||
* the secret writes nothing, since anyone can send one. The audit rows carry a digest of
|
||||
* the nonce, never the nonce, the bearer or the secret.
|
||||
*/
|
||||
export async function assertStepUp(
|
||||
ctx: Pick<HeimdallRuntimeContext, "config" | "keys" | "store">,
|
||||
budgets: StepUpAssertBudgets,
|
||||
appSlug: AppSlug,
|
||||
headers: Record<string, string | string[] | undefined>,
|
||||
input: { method: string; nonce: string }
|
||||
): Promise<HandlerResult> {
|
||||
const { config, keys, store } = ctx;
|
||||
if (!resolveAppCaller(config, appSlug, headers)) {
|
||||
return { statusCode: 401, body: { error: "app_auth_required" } };
|
||||
}
|
||||
const permitted = getAppProfile(appSlug)?.stepUpAssertion;
|
||||
let known: { accountId: string; sessionId: string } | undefined;
|
||||
let nonceDigest: string | undefined;
|
||||
let tripped: string | undefined;
|
||||
const refuse = async (statusCode: number, error: string, extra: Record<string, unknown> = {}, record = true): Promise<HandlerResult> => {
|
||||
if (!record) return { statusCode, body: { error, ...extra } };
|
||||
await store.createAuditEvent({
|
||||
...(known ? { accountId: known.accountId, sessionId: known.sessionId } : {}),
|
||||
appSlug,
|
||||
eventType: "step_up_assertion_refused",
|
||||
eventPayloadJson: { outcome: "refused", code: error, budget: tripped ?? null, method: permitted?.methods.includes(input.method) ? input.method : null, nonceDigest: nonceDigest ?? null },
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
return { statusCode, body: { error, ...extra } };
|
||||
};
|
||||
// Audit only the first denial of a window: the rows record that a budget tripped, not every request behind it.
|
||||
const limited = (budget: string, spent: { n: number; retryAfterSeconds: number }, max: number) => {
|
||||
tripped = budget;
|
||||
return refuse(429, "step_up_rate_limited", { retryAfterSeconds: spent.retryAfterSeconds }, spent.n === max + 1);
|
||||
};
|
||||
|
||||
if (!permitted) {
|
||||
const spent = budgets.notPermitted.spend(appSlug);
|
||||
if (spent.retryAfterSeconds) return limited("notPermitted", spent, STEP_UP_ASSERT_LIMITS.notPermitted.max);
|
||||
return refuse(403, "step_up_assertion_not_permitted", { detail: `${appSlug} may not assert step-up proofs.` }, spent.n === 1);
|
||||
}
|
||||
const appSpent = budgets.app.spend(appSlug);
|
||||
if (appSpent.retryAfterSeconds) return limited("app", appSpent, STEP_UP_ASSERT_LIMITS.app.max);
|
||||
|
||||
const bearer = await resolveBearer(ctx, headers.authorization as string | undefined);
|
||||
if (!bearer) {
|
||||
return refuse(401, "sign_in_required", { detail: "Send the member's live Heimdall access token as a Bearer token." });
|
||||
}
|
||||
const sessionId = bearer.claims.sid as string;
|
||||
known = { accountId: bearer.accountId, sessionId };
|
||||
if (bearer.claims.aud !== appSlug) {
|
||||
return refuse(403, "bearer_app_mismatch", { detail: `The access token was not issued to ${appSlug}.` });
|
||||
}
|
||||
const accountSpent = budgets.account.spend(bearer.accountId);
|
||||
if (accountSpent.retryAfterSeconds) return limited("account", accountSpent, STEP_UP_ASSERT_LIMITS.account.max);
|
||||
|
||||
nonceDigest = createHash("sha256").update(`heimdall-step-up-nonce\0${sessionId}\0${input.nonce}`).digest("hex");
|
||||
if (!permitted.methods.includes(input.method)) {
|
||||
return refuse(400, "unknown_step_up_method", { methods: permitted.methods });
|
||||
}
|
||||
const at = new Date().toISOString();
|
||||
if (!(await store.claimOAuthStateUse({ jti: nonceDigest, phase: "step_up_assert", appSlug, usedAt: at, expiresAt: bearer.sessionExpiresAt }))) {
|
||||
return refuse(409, "step_up_nonce_replayed", { detail: "A nonce is good once per session; send a fresh one." });
|
||||
}
|
||||
|
||||
const minted = signAssertedStepUpClaim({ keys, bearerClaims: bearer.claims, method: input.method });
|
||||
await store.createAuditEvent({
|
||||
accountId: bearer.accountId,
|
||||
sessionId,
|
||||
appSlug,
|
||||
eventType: "step_up_asserted",
|
||||
eventPayloadJson: { outcome: "asserted", code: null, method: input.method, nonceDigest, stepUpAt: minted.stepUpAt },
|
||||
createdAt: at,
|
||||
});
|
||||
return {
|
||||
statusCode: 201,
|
||||
body: {
|
||||
accessToken: minted.accessToken,
|
||||
claimSet: minted.claimSet,
|
||||
stepUp: { at: minted.stepUpAt, method: input.method, validForSeconds: STEP_UP_PROOF_TTL_SECONDS },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const MOVE_ID_PATTERN = "^[A-Za-z0-9_-]{16,64}$";
|
||||
|
||||
export interface MoveIdentitiesBody {
|
||||
moveId: string;
|
||||
fromProofs: string[];
|
||||
toProof?: string;
|
||||
identityIds?: string[];
|
||||
reversesMoveId?: string;
|
||||
}
|
||||
|
||||
/** The answer a move gives, derived from its stored row so a replay is byte-identical to the first answer. */
|
||||
function moveReceipt(move: StoredIdentityMove) {
|
||||
const tier = (proofs: { tier: "strong" | "weak" }[]) => (proofs.some((proof) => proof.tier === "strong") ? "strong" : "weak");
|
||||
return {
|
||||
moveId: move.id,
|
||||
accountId: move.toAccountId,
|
||||
movedIdentityIds: move.moved.map((moved) => moved.id),
|
||||
proof: { from: tier(move.proof.from), to: move.proof.to ? move.proof.to.tier : null },
|
||||
...(move.reversesMoveId !== undefined ? { reversesMoveId: move.reversesMoveId } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /v1/apps/{app}/identity-moves: the one sanctioned way a login changes
|
||||
* account. The caller's secret and its profile's identityMoves grant open the
|
||||
* route; the accounts come only from the proofs (live bearers of this app,
|
||||
* fresh by proofTier), never from the body, an email or a name. A proof
|
||||
* through login X moves X; only an asserted account proof (the profile's
|
||||
* accountProofMethods) speaks for the whole account. The store decides and
|
||||
* commits; this route verifies, maps refusals to fixed codes, then writes the
|
||||
* revocation receipts the move owes (revokeLiveGrants stays their one writer)
|
||||
* and the audit row. A replayed moveId is answered before the proofs are
|
||||
* verified, because the first success revoked the source's sessions.
|
||||
*/
|
||||
export async function moveIdentitiesRoute(
|
||||
ctx: Pick<HeimdallRuntimeContext, "config" | "keys" | "store">,
|
||||
revokeLiveGrants: (accountId: string, provider: Provider, receipts: Parameters<typeof grantViews>[0], at: string) => Promise<string[]>,
|
||||
appSlug: AppSlug,
|
||||
headers: Record<string, string | string[] | undefined>,
|
||||
body: MoveIdentitiesBody
|
||||
): Promise<HandlerResult> {
|
||||
const { config, keys, store } = ctx;
|
||||
const refuse = (statusCode: number, error: string, extra: Record<string, unknown> = {}): HandlerResult => ({ statusCode, body: { error, ...extra } });
|
||||
if (!resolveAppCaller(config, appSlug, headers)) return refuse(401, "app_auth_required");
|
||||
const grant = getAppProfile(appSlug)?.identityMoves;
|
||||
if (!grant) return refuse(403, "not_allowed_for_app");
|
||||
|
||||
// The providers a move carried away from its source that the source no longer holds: their grants are taken back under the source.
|
||||
const settleGrants = async (move: StoredIdentityMove) => {
|
||||
const held = new Set((await store.listStoredLinkedIdentitiesForAccount(move.fromAccountId)).map((identity) => identity.provider));
|
||||
const at = new Date().toISOString();
|
||||
for (const provider of new Set(move.moved.map((moved) => moved.provider))) {
|
||||
if (!held.has(provider)) await revokeLiveGrants(move.fromAccountId, provider, await store.listConsentReceipts(move.fromAccountId), at);
|
||||
}
|
||||
};
|
||||
|
||||
const existing = await store.findIdentityMove(body.moveId);
|
||||
if (existing) {
|
||||
const signedFor = (token: string | undefined, accountId: string) => {
|
||||
const verification = token ? verifyJwt(token, keys.publicKey) : null;
|
||||
return (
|
||||
verification?.valid === true &&
|
||||
verification.payload.typ === "heimdall_access" &&
|
||||
verification.payload.iss === config.issuer &&
|
||||
verification.payload.aud === appSlug &&
|
||||
verification.payload.account_id === accountId
|
||||
);
|
||||
};
|
||||
const same =
|
||||
existing.appSlug === appSlug &&
|
||||
existing.reversesMoveId === body.reversesMoveId &&
|
||||
body.fromProofs.every((token) => signedFor(token, existing.fromAccountId)) &&
|
||||
(existing.reversesMoveId !== undefined ? body.toProof === undefined : signedFor(body.toProof, existing.toAccountId));
|
||||
if (!same) return refuse(409, "move_id_conflict");
|
||||
await settleGrants(existing);
|
||||
return { statusCode: 200, body: moveReceipt(existing) };
|
||||
}
|
||||
|
||||
let forward: StoredIdentityMove | null = null;
|
||||
if (body.reversesMoveId !== undefined) {
|
||||
forward = await store.findIdentityMove(body.reversesMoveId);
|
||||
if (!forward || forward.appSlug !== appSlug || forward.reversesMoveId !== undefined) return refuse(409, "not_reversible");
|
||||
}
|
||||
|
||||
const nowS = nowEpochSeconds();
|
||||
const resolve = (token: string) => resolveBearer(ctx, `Bearer ${token}`, appSlug);
|
||||
const fromBearers = [];
|
||||
for (const token of body.fromProofs) {
|
||||
const bearer = await resolve(token);
|
||||
if (!bearer) return refuse(403, "proof_invalid", { proof: "from" });
|
||||
fromBearers.push(bearer);
|
||||
}
|
||||
const fromAccountId = fromBearers[0]!.accountId;
|
||||
if (fromBearers.some((bearer) => bearer.accountId !== fromAccountId)) return refuse(403, "proof_invalid", { proof: "from" });
|
||||
let toBearer: ResolvedBearer | null = null;
|
||||
if (forward === null) {
|
||||
toBearer = await resolve(body.toProof!);
|
||||
if (!toBearer) return refuse(403, "proof_invalid", { proof: "to" });
|
||||
} else if (fromAccountId !== forward.toAccountId) {
|
||||
return refuse(409, "not_reversible");
|
||||
}
|
||||
const toAccountId = toBearer?.accountId ?? forward!.fromAccountId;
|
||||
if (fromAccountId === toAccountId) return refuse(409, "same_account");
|
||||
|
||||
const proofOf = (bearer: ResolvedBearer, tier: "strong" | "weak") => ({
|
||||
tier,
|
||||
method: tier === "strong" && typeof bearer.claims.step_up_method === "string" ? bearer.claims.step_up_method : null,
|
||||
identityId: typeof bearer.claims.auth_identity_id === "string" ? bearer.claims.auth_identity_id : null,
|
||||
});
|
||||
const fromProof = [];
|
||||
for (const bearer of fromBearers) {
|
||||
const tier = proofTier(bearer.claims, nowS);
|
||||
if (!tier) return refuse(403, "proof_stale", { proof: "from" });
|
||||
fromProof.push(proofOf(bearer, tier));
|
||||
}
|
||||
const toTier = toBearer ? proofTier(toBearer.claims, nowS) : null;
|
||||
if (toBearer && !toTier) return refuse(403, "proof_stale", { proof: "to" });
|
||||
|
||||
const coverage = fromBearers.map((bearer) => proofCovers(bearer.claims, grant.accountProofMethods, nowS));
|
||||
if (coverage.every((covered) => covered === null)) return refuse(403, "identity_not_proved");
|
||||
const covers = coverage.includes("account") ? "account" : coverage.filter((covered): covered is string => covered !== null);
|
||||
|
||||
const at = new Date().toISOString();
|
||||
const result = await store.moveIdentities({
|
||||
moveId: body.moveId,
|
||||
appSlug,
|
||||
fromAccountId,
|
||||
toAccountId,
|
||||
identityIds: forward ? forward.moved.map((moved) => moved.id) : (body.identityIds ?? "all"),
|
||||
covers,
|
||||
proofSessions: [...fromBearers, ...(toBearer ? [toBearer] : [])].map((bearer) => ({
|
||||
appSlug,
|
||||
sessionId: bearer.claims.sid as string,
|
||||
accountId: bearer.accountId,
|
||||
accessRevision: bearer.claims.access_revision as number,
|
||||
})),
|
||||
...(body.reversesMoveId !== undefined ? { reversesMoveId: body.reversesMoveId } : {}),
|
||||
proof: { from: fromProof, to: toBearer && toTier ? proofOf(toBearer, toTier) : null },
|
||||
at,
|
||||
});
|
||||
switch (result.kind) {
|
||||
case "proof_lapsed":
|
||||
return refuse(403, "proof_invalid");
|
||||
case "already_moved":
|
||||
case "not_reversible":
|
||||
case "move_id_conflict":
|
||||
return refuse(409, result.kind);
|
||||
case "provider_conflict":
|
||||
return refuse(409, "provider_conflict", { provider: result.provider });
|
||||
case "identity_not_found":
|
||||
return refuse(404, "identity_not_found");
|
||||
case "identity_not_proved":
|
||||
return refuse(403, "identity_not_proved");
|
||||
case "replayed":
|
||||
await settleGrants(result.move);
|
||||
return { statusCode: 200, body: moveReceipt(result.move) };
|
||||
case "moved": {
|
||||
const { move } = result;
|
||||
await settleGrants(move);
|
||||
await store.createAuditEvent({
|
||||
accountId: move.fromAccountId,
|
||||
appSlug,
|
||||
eventType: "identity_moved",
|
||||
eventPayloadJson: {
|
||||
moveId: move.id,
|
||||
toAccountId: move.toAccountId,
|
||||
movedIdentityIds: move.moved.map((moved) => moved.id),
|
||||
providers: move.moved.map((moved) => moved.provider),
|
||||
tiers: { from: moveReceipt(move).proof.from, to: moveReceipt(move).proof.to },
|
||||
reversesMoveId: move.reversesMoveId ?? null,
|
||||
},
|
||||
createdAt: at,
|
||||
});
|
||||
return { statusCode: 200, body: moveReceipt(move) };
|
||||
}
|
||||
}
|
||||
const token = /^Bearer\s+(\S+)$/i.exec(authorization ?? "")?.[1];
|
||||
if (!token) return null;
|
||||
const verification = verifyJwt(token, ctx.keys.publicKey);
|
||||
if (!verification.valid) return null;
|
||||
const audience = verification.payload.aud;
|
||||
return isAppSlug(audience) ? resolveLinkAccount(ctx, audience, authorization) : null;
|
||||
}
|
||||
|
||||
const grantsPageTokenType = "heimdall_grants_page";
|
||||
|
|
@ -1508,7 +1167,6 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
|
|||
},
|
||||
},
|
||||
initiatingAccountId: { type: "string", minLength: 1 },
|
||||
stepUp: { type: "boolean" },
|
||||
handoff: {
|
||||
type: "object",
|
||||
required: ["kind"],
|
||||
|
|
@ -1674,18 +1332,6 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
|
|||
if (!clientId) {
|
||||
return providerNotConfigured(reply, state);
|
||||
}
|
||||
const oidc = providerCatalog[state.provider].oidc ? deriveOidcSecrets(state.jti, keys.privateKey) : undefined;
|
||||
// Built before the state is claimed: a state that cannot be honoured (a step-up for a provider with no
|
||||
// re-authentication parameter) fails here without burning its launch use or setting the binding cookie.
|
||||
const authorizationUrl = buildAuthorizationUrl({
|
||||
provider: state.provider,
|
||||
clientId,
|
||||
redirectUri: oauthCallbackUrl(config, state.provider),
|
||||
state: rawState,
|
||||
requestedScopes: state.requested_scopes ?? undefined,
|
||||
...(state.step_up ? { stepUp: true } : {}),
|
||||
...(oidc ? { oidc: { nonce: oidc.nonce, codeChallenge: oidc.codeChallenge } } : {}),
|
||||
});
|
||||
const first = await store.claimOAuthStateUse({
|
||||
jti: state.jti,
|
||||
phase: "launch",
|
||||
|
|
@ -1697,10 +1343,21 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
|
|||
return refuseVerifiedState(request, reply, state, "state_already_launched");
|
||||
}
|
||||
|
||||
const oidc = providerCatalog[state.provider].oidc ? deriveOidcSecrets(state.jti, keys.privateKey) : undefined;
|
||||
setOAuthBindingCookie(reply, state, signOAuthBinding(config, keys, state, shownText), state.exp - nowEpochSeconds());
|
||||
reply.header("Cache-Control", "no-store");
|
||||
reply.header("Referrer-Policy", "no-referrer");
|
||||
return reply.redirect(authorizationUrl, 302);
|
||||
return reply.redirect(
|
||||
buildAuthorizationUrl({
|
||||
provider: state.provider,
|
||||
clientId,
|
||||
redirectUri: oauthCallbackUrl(config, state.provider),
|
||||
state: rawState,
|
||||
requestedScopes: state.requested_scopes ?? undefined,
|
||||
...(oidc ? { oidc: { nonce: oidc.nonce, codeChallenge: oidc.codeChallenge } } : {}),
|
||||
}),
|
||||
302
|
||||
);
|
||||
};
|
||||
|
||||
app.get<{
|
||||
|
|
@ -2155,16 +1812,7 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
|
|||
await store.touchAccount(account.id, nowIso, accountUpdates);
|
||||
}
|
||||
|
||||
let signedInIdentity: StoredLinkedIdentity;
|
||||
try {
|
||||
signedInIdentity = await store.upsertLinkedIdentity(buildLinkedIdentityInput(account.id));
|
||||
} catch (error) {
|
||||
// The check above is the fast path; this is the write refusing a move another account won the race for.
|
||||
if (error instanceof IdentityHeldElsewhereError) {
|
||||
throw new LinkRefusal("link_initiator_mismatch", "This identity belongs to a different account than the one that started the link.");
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
await store.upsertLinkedIdentity(buildLinkedIdentityInput(account.id));
|
||||
|
||||
// Heimdall now holds a credential the person was asked about: the
|
||||
// receipt is the record of that agreement, written with the credential.
|
||||
|
|
@ -2221,10 +1869,6 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
|
|||
linkedIdentities: await store.listLinkedIdentitiesForAccount(account.id),
|
||||
facts: [...new Set([...entitlementEvaluation.facts, ...linkedFacts]), ...buildGrantFacts(grants)],
|
||||
...(mint === "custody_only" ? { withoutCapabilities: true } : {}),
|
||||
// Proof of a fresh re-authentication, from the signed state alone: only a sign_in the app started with stepUp.
|
||||
...(statePayload.step_up && statePayload.mode === "sign_in" ? { stepUpAt: nowEpochSeconds(), stepUpMethod: `provider:${statePayload.provider}` } : {}),
|
||||
// Which login proved this session, and when: only a sign_in, so a link or connect never counts as a sign-in.
|
||||
...(statePayload.mode === "sign_in" ? { authAt: nowEpochSeconds(), authIdentityId: signedInIdentity.id } : {}),
|
||||
};
|
||||
if (account.displayName !== undefined) {
|
||||
issueInput.displayName = account.displayName;
|
||||
|
|
@ -2283,7 +1927,6 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
|
|||
entitlements: entitlementEvaluation,
|
||||
returnTo: statePayload.return_to,
|
||||
connection: statePayload.connection,
|
||||
...(statePayload.step_up ? { stepUpRequested: true } : {}),
|
||||
...(consentReceiptId ? { consentReceiptId } : {}),
|
||||
...delivered,
|
||||
};
|
||||
|
|
@ -2535,14 +2178,7 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
|
|||
reply.code(409);
|
||||
return { error: "identity_linked_elsewhere" };
|
||||
}
|
||||
try {
|
||||
await store.upsertLinkedIdentity({ ...pending.identity, accountId, updatedAt: nowIso });
|
||||
} catch (error) {
|
||||
// The holder check above is the fast path; a second account may win the race to the identity.
|
||||
if (!(error instanceof IdentityHeldElsewhereError)) throw error;
|
||||
reply.code(409);
|
||||
return { error: "identity_linked_elsewhere" };
|
||||
}
|
||||
await store.upsertLinkedIdentity({ ...pending.identity, accountId, updatedAt: nowIso });
|
||||
await store.createAuditEvent({
|
||||
accountId,
|
||||
appSlug: pending.appSlug,
|
||||
|
|
@ -2584,7 +2220,7 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
|
|||
// the route stays open to it. A link completion was minted for an
|
||||
// initiator the authenticated app named at start; only that app's
|
||||
// backend redeems it. Redeem is also where the connection target is
|
||||
// held to what the signed state named, both ways: a target the state never named is refused too. Both refusals read the row
|
||||
// held to what the signed state named. Both refusals read the row
|
||||
// without consuming it, so a wrong caller or target cannot burn the code.
|
||||
const pending = await store.findAuthCompletion(request.params.appSlug, request.body.completionCode);
|
||||
if (pending && !pending.consumedAt && pending.expiresAt > nowIso) {
|
||||
|
|
@ -2592,7 +2228,7 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
|
|||
reply.code(401);
|
||||
return { error: "app_auth_required" };
|
||||
}
|
||||
if (pending.targetId !== request.body.targetId) {
|
||||
if (pending.targetId !== undefined && pending.targetId !== request.body.targetId) {
|
||||
await store.createAuditEvent({
|
||||
accountId: pending.accountId,
|
||||
sessionId: pending.sessionId,
|
||||
|
|
@ -2734,37 +2370,6 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
|
|||
}
|
||||
);
|
||||
|
||||
// An app asserting that a member just passed a factor it checked: see assertStepUp.
|
||||
const stepUpBudgets = createStepUpAssertBudgets();
|
||||
app.post<{ Params: { appSlug: AppSlug }; Body: { method: string; nonce: string } }>(
|
||||
"/v1/apps/:appSlug/step-up",
|
||||
{
|
||||
schema: {
|
||||
params: {
|
||||
type: "object",
|
||||
required: ["appSlug"],
|
||||
additionalProperties: false,
|
||||
properties: { appSlug: { type: "string", enum: [...appSlugs] } },
|
||||
},
|
||||
body: {
|
||||
type: "object",
|
||||
required: ["method", "nonce"],
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
method: { type: "string", minLength: 1, maxLength: 64 },
|
||||
// base64url alphabet: what a random token looks like, and no spaces, controls or lone surrogates.
|
||||
nonce: { type: "string", pattern: "^[A-Za-z0-9_-]{16,128}$" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const result = await assertStepUp({ config, keys, store }, stepUpBudgets, request.params.appSlug, request.headers, request.body);
|
||||
reply.code(result.statusCode).header("cache-control", "no-store");
|
||||
return result.body;
|
||||
}
|
||||
);
|
||||
|
||||
app.post<{
|
||||
Params: { appSlug: AppSlug };
|
||||
Body: {
|
||||
|
|
@ -3028,146 +2633,6 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
|
|||
return { error: "sign_in_required", detail: "Send a Heimdall access token as a Bearer token, or open a link from POST /v1/me/grants/link." };
|
||||
};
|
||||
|
||||
/**
|
||||
* Writes a revocation receipt for each live grant the account holds on
|
||||
* `provider` (whichever app asked: the credential is one row per account and
|
||||
* provider), and the audit event naming them. The credential is gone or
|
||||
* stripped first, so a failure here leaves a receipt that still reads active
|
||||
* over a credential that is gone, never the reverse. Returns the grants this
|
||||
* call revoked.
|
||||
*/
|
||||
const revokeLiveGrants = async (accountId: string, provider: Provider, receipts: Parameters<typeof grantViews>[0], at: string): Promise<string[]> => {
|
||||
const revoked: string[] = [];
|
||||
for (const live of grantViews(receipts).filter((view) => view.provider === provider && view.status === "active")) {
|
||||
const { created } = await store.createConsentReceipt({
|
||||
kind: "revocation",
|
||||
accountId,
|
||||
appSlug: live.appSlug,
|
||||
provider: live.provider,
|
||||
createdAt: at,
|
||||
revokesReceiptId: live.id,
|
||||
});
|
||||
// A concurrent revoke may have written it first; only the writer reports it.
|
||||
if (created) revoked.push(live.id);
|
||||
}
|
||||
if (revoked.length) {
|
||||
await store.createAuditEvent({ accountId, eventType: "consent_revoked", eventPayloadJson: { provider, grantIds: revoked }, createdAt: at });
|
||||
}
|
||||
return revoked;
|
||||
};
|
||||
|
||||
const identityViews = async (accountId: string) =>
|
||||
(await store.listStoredLinkedIdentitiesForAccount(accountId)).map((identity) => ({
|
||||
id: identity.id,
|
||||
provider: identity.provider,
|
||||
providerUserId: identity.providerUserId,
|
||||
...(identity.username !== undefined ? { username: identity.username } : {}),
|
||||
...(identity.displayName !== undefined ? { displayName: identity.displayName } : {}),
|
||||
linkedAt: identity.createdAt,
|
||||
scopes: identity.scopes,
|
||||
}));
|
||||
|
||||
// The identities an account holds: ids and public profile facts, never a
|
||||
// credential, a stored profile or an email.
|
||||
app.get("/v1/me/identities", async (request, reply) => {
|
||||
const bearer = await resolveBearer({ config, keys, store }, request.headers.authorization);
|
||||
if (!bearer) return signInRequired(reply);
|
||||
grantsReply(reply);
|
||||
return { accountId: bearer.accountId, identities: await identityViews(bearer.accountId) };
|
||||
});
|
||||
|
||||
// Taking an identity off the account. The bearer alone is not enough: the
|
||||
// access token must come from a step-up sign-in inside the proof window, and
|
||||
// a refresh never carries that proof forward.
|
||||
app.post<{ Params: { identityId: string }; Body?: null }>(
|
||||
"/v1/me/identities/:identityId/unlink",
|
||||
{
|
||||
schema: {
|
||||
params: { type: "object", required: ["identityId"], additionalProperties: false, properties: { identityId: { type: "string", minLength: 1 } } },
|
||||
body: { type: ["object", "null"], additionalProperties: false, properties: {} },
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const bearer = await resolveBearer({ config, keys, store }, request.headers.authorization);
|
||||
if (!bearer) return signInRequired(reply);
|
||||
grantsReply(reply);
|
||||
const { accountId, claims } = bearer;
|
||||
const nowS = nowEpochSeconds();
|
||||
if (proofTier(claims, nowS) !== "strong") {
|
||||
reply.code(403);
|
||||
return {
|
||||
error: "step_up_required",
|
||||
detail: `Unlinking needs a sign-in with stepUp completed within the last ${STEP_UP_PROOF_TTL_SECONDS} seconds: POST /v1/oauth/{provider}/start with mode sign_in and stepUp true through an identity on this account whose provider has re-authentication parameters (today twitch, youtube and google; discord and patreon cannot prove), then use the access token that sign-in returns.`,
|
||||
};
|
||||
}
|
||||
const result = await store.unlinkLinkedIdentity(accountId, request.params.identityId);
|
||||
if (result.kind === "not_found") {
|
||||
reply.code(404);
|
||||
return { error: "identity_not_found" };
|
||||
}
|
||||
if (result.kind === "last_identity") {
|
||||
reply.code(409);
|
||||
return { error: "last_identity", detail: "This is the account's only identity; unlinking it would leave the account unreachable." };
|
||||
}
|
||||
const { identity } = result;
|
||||
const at = new Date().toISOString();
|
||||
await store.createAuditEvent({
|
||||
accountId,
|
||||
eventType: "identity_unlinked",
|
||||
eventPayloadJson: { provider: identity.provider, identityId: identity.id, stepUpMethod: typeof claims.step_up_method === "string" ? claims.step_up_method : null },
|
||||
createdAt: at,
|
||||
});
|
||||
// The credential went with the row. When no identity of that provider
|
||||
// is left, the grants that rested on it are taken back in the record too.
|
||||
const remaining = await store.listStoredLinkedIdentitiesForAccount(accountId);
|
||||
if (!remaining.some((other) => other.provider === identity.provider)) {
|
||||
await revokeLiveGrants(accountId, identity.provider, await store.listConsentReceipts(accountId), at);
|
||||
}
|
||||
return {
|
||||
unlinked: { id: identity.id, provider: identity.provider, providerUserId: identity.providerUserId },
|
||||
identities: await identityViews(accountId),
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// The sanctioned mover of logins between accounts: see moveIdentitiesRoute. Registered here because it
|
||||
// needs revokeLiveGrants, which belongs to this scope.
|
||||
app.post<{ Params: { appSlug: AppSlug }; Body: MoveIdentitiesBody }>(
|
||||
"/v1/apps/:appSlug/identity-moves",
|
||||
{
|
||||
schema: {
|
||||
params: {
|
||||
type: "object",
|
||||
required: ["appSlug"],
|
||||
additionalProperties: false,
|
||||
properties: { appSlug: { type: "string", enum: [...appSlugs] } },
|
||||
},
|
||||
body: {
|
||||
type: "object",
|
||||
required: ["moveId", "fromProofs"],
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
moveId: { type: "string", pattern: MOVE_ID_PATTERN },
|
||||
fromProofs: { type: "array", minItems: 1, maxItems: 8, items: { type: "string", minLength: 1, maxLength: 8192 } },
|
||||
toProof: { type: "string", minLength: 1, maxLength: 8192 },
|
||||
identityIds: { type: "array", minItems: 1, maxItems: 32, uniqueItems: true, items: { type: "string", minLength: 1 } },
|
||||
reversesMoveId: { type: "string", pattern: MOVE_ID_PATTERN },
|
||||
},
|
||||
// A forward move names its destination proof; a reversal names the move and nothing else about who or what.
|
||||
oneOf: [
|
||||
{ required: ["toProof"], not: { anyOf: [{ required: ["reversesMoveId"] }] } },
|
||||
{ required: ["reversesMoveId"], not: { anyOf: [{ required: ["toProof"] }, { required: ["identityIds"] }] } },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const result = await moveIdentitiesRoute({ config, keys, store }, revokeLiveGrants, request.params.appSlug, request.headers, request.body);
|
||||
reply.code(result.statusCode).header("cache-control", "no-store");
|
||||
return result.body;
|
||||
}
|
||||
);
|
||||
|
||||
// The signed-in surface for consent: every grant and receipt the account
|
||||
// holds, and the one-click way to take each back.
|
||||
app.get<{ Querystring: { t?: string } }>(
|
||||
|
|
@ -3229,7 +2694,26 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
|
|||
if (identity) {
|
||||
await store.updateLinkedIdentityCredential(identity.id, async () => ({ kind: "revoke", updatedAt: at }));
|
||||
}
|
||||
revoked.push(...(await revokeLiveGrants(accountId, grant.provider, receipts, at)));
|
||||
for (const live of grantViews(receipts).filter((view) => view.provider === grant.provider && view.status === "active")) {
|
||||
const { created } = await store.createConsentReceipt({
|
||||
kind: "revocation",
|
||||
accountId,
|
||||
appSlug: live.appSlug,
|
||||
provider: live.provider,
|
||||
createdAt: at,
|
||||
revokesReceiptId: live.id,
|
||||
});
|
||||
// A concurrent revoke may have written it first; only the writer reports it.
|
||||
if (created) revoked.push(live.id);
|
||||
}
|
||||
if (revoked.length) {
|
||||
await store.createAuditEvent({
|
||||
accountId,
|
||||
eventType: "consent_revoked",
|
||||
eventPayloadJson: { provider: grant.provider, grantIds: revoked },
|
||||
createdAt: at,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (prefersHtml(request.headers.accept) || request.headers["content-type"]?.startsWith("application/x-www-form-urlencoded")) {
|
||||
reply.type("text/html; charset=utf-8");
|
||||
|
|
|
|||
|
|
@ -11,12 +11,6 @@ import { identityFacts } from "./facts.js";
|
|||
import { signJwt, type RuntimeKeyMaterial } from "./signing.js";
|
||||
import { type CreateSessionInput, type HeimdallStore } from "./store/types.js";
|
||||
|
||||
/** How long a step-up sign-in's proof stays good for an action that demands it (identity unlink). */
|
||||
export const STEP_UP_PROOF_TTL_SECONDS = 600;
|
||||
|
||||
/** How long a plain sign-in's proof stays good for an action that accepts a weak proof (an identity move). */
|
||||
export const AUTH_PROOF_TTL_SECONDS = 300;
|
||||
|
||||
function nowEpochSeconds(): number {
|
||||
return Math.floor(Date.now() / 1000);
|
||||
}
|
||||
|
|
@ -54,14 +48,6 @@ export interface IssueAccessClaimInput {
|
|||
sessionId?: string;
|
||||
accessRevision?: number;
|
||||
ttlSeconds?: number;
|
||||
/** Epoch seconds of a step-up sign-in completing; only that callback sets it, a refresh never does. */
|
||||
stepUpAt?: number;
|
||||
/** Required with stepUpAt (`provider:<name>`); the claim never carries one without the other. */
|
||||
stepUpMethod?: string;
|
||||
/** Epoch seconds of the sign-in that minted this token, with authIdentityId (both or neither). Only a sign_in callback sets them; a refresh, link or connect never does. */
|
||||
authAt?: number;
|
||||
/** The linked_identities.id the sign-in came through. */
|
||||
authIdentityId?: string;
|
||||
}
|
||||
|
||||
export interface IssuedAccessClaimResult {
|
||||
|
|
@ -144,18 +130,6 @@ export async function issueAccessClaim(options: {
|
|||
identities: options.input.linkedIdentities,
|
||||
};
|
||||
|
||||
if (options.input.stepUpAt !== undefined) {
|
||||
claimSet.step_up_at = options.input.stepUpAt;
|
||||
if (options.input.stepUpMethod !== undefined) {
|
||||
claimSet.step_up_method = options.input.stepUpMethod;
|
||||
}
|
||||
}
|
||||
|
||||
if (options.input.authAt !== undefined && options.input.authIdentityId !== undefined) {
|
||||
claimSet.auth_at = options.input.authAt;
|
||||
claimSet.auth_identity_id = options.input.authIdentityId;
|
||||
}
|
||||
|
||||
if (options.input.displayName) {
|
||||
claimSet.display_name = options.input.displayName;
|
||||
}
|
||||
|
|
@ -217,62 +191,3 @@ export async function issueAccessClaim(options: {
|
|||
: profile.capabilities.filter((capability) => capability.mode === "hybrid"),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A new access token for the session the bearer already holds, carrying proof
|
||||
* the calling app asserted. Everything the bearer claimed is copied and
|
||||
* nothing is recomputed, so the route can add proof and no capability; `exp`
|
||||
* stays the bearer's, so it cannot extend a lifetime; no refresh token is
|
||||
* minted and no session row is written, so it cannot extend the session.
|
||||
*/
|
||||
export function signAssertedStepUpClaim(options: {
|
||||
keys: RuntimeKeyMaterial;
|
||||
bearerClaims: Record<string, unknown>;
|
||||
method: string;
|
||||
}): { accessToken: string; claimSet: Record<string, unknown>; stepUpAt: number } {
|
||||
const stepUpAt = nowEpochSeconds();
|
||||
const claimSet = {
|
||||
...options.bearerClaims,
|
||||
jti: randomUUID(),
|
||||
iat: stepUpAt,
|
||||
nbf: stepUpAt,
|
||||
step_up_at: stepUpAt,
|
||||
step_up_method: options.method,
|
||||
};
|
||||
return { accessToken: signJwt(claimSet, options.keys), claimSet, stepUpAt };
|
||||
}
|
||||
|
||||
/**
|
||||
* How fresh a bearer's proof of the member is, from its claims alone:
|
||||
* `strong` when a step-up (Heimdall's or the app's asserted one) happened in
|
||||
* the last STEP_UP_PROOF_TTL_SECONDS, else `weak` when the sign-in did in the
|
||||
* last AUTH_PROOF_TTL_SECONDS, else null. The one freshness rule: identity
|
||||
* unlink and identity moves both read it.
|
||||
*/
|
||||
export function proofTier(claims: Record<string, unknown>, nowS: number): "strong" | "weak" | null {
|
||||
const within = (at: unknown, window: number) => typeof at === "number" && at <= nowS && nowS - at <= window;
|
||||
if (within(claims.step_up_at, STEP_UP_PROOF_TTL_SECONDS)) return "strong";
|
||||
if (within(claims.auth_at, AUTH_PROOF_TTL_SECONDS)) return "weak";
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* What a fresh proof covers of its account: `account` (every login) only for a
|
||||
* strong proof whose step_up_method the app's profile lists as an account
|
||||
* proof; otherwise the one login the sign-in came through (its id); otherwise
|
||||
* nothing. A proof through login X never speaks for the account's other logins.
|
||||
*/
|
||||
export function proofCovers(
|
||||
claims: Record<string, unknown>,
|
||||
accountProofMethods: readonly string[],
|
||||
nowS: number
|
||||
): "account" | string | null {
|
||||
if (
|
||||
proofTier(claims, nowS) === "strong" &&
|
||||
typeof claims.step_up_method === "string" &&
|
||||
accountProofMethods.includes(claims.step_up_method)
|
||||
) {
|
||||
return "account";
|
||||
}
|
||||
return typeof claims.auth_identity_id === "string" ? claims.auth_identity_id : null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -78,8 +78,6 @@ export interface OAuthStartRequest {
|
|||
handoff?: OAuthHandoff;
|
||||
requestedScopes?: string[];
|
||||
entitlementPolicy?: OAuthEntitlementPolicy;
|
||||
/** Sign_in only, app-authenticated: ask the provider for a real re-authentication. Off when absent. */
|
||||
stepUp?: boolean;
|
||||
}
|
||||
|
||||
export interface OAuthStatePayload {
|
||||
|
|
@ -101,8 +99,6 @@ export interface OAuthStatePayload {
|
|||
entitlement_policy: OAuthEntitlementPolicy | null;
|
||||
/** The start's requestedScopes, carried so the launch hop can build the provider URL from the signed state alone. */
|
||||
requested_scopes: string[] | null;
|
||||
/** The start's step-up request. Signed, so the launch hop reads it here and nowhere else; null when not asked. */
|
||||
step_up: true | null;
|
||||
}
|
||||
|
||||
export interface LinkedIdentityInput {
|
||||
|
|
@ -153,19 +149,6 @@ export interface AccessClaimPayload {
|
|||
typ: "heimdall_access";
|
||||
account_id: string;
|
||||
access_revision: number;
|
||||
/** Epoch seconds of the step-up sign-in this access token was minted by; absent on every other token, and never carried by a refresh. */
|
||||
step_up_at?: number;
|
||||
/**
|
||||
* How the proof in step_up_at was made: `provider:<name>` for a step-up
|
||||
* sign-in Heimdall ran, or one of the calling app's allowlisted method
|
||||
* labels when the app asserted it (POST /v1/apps/{app}/step-up). Present
|
||||
* exactly when step_up_at is; never carried by a refresh.
|
||||
*/
|
||||
step_up_method?: string;
|
||||
/** Epoch seconds of the sign-in this access token was minted by, with auth_identity_id; absent unless a sign_in callback minted it, and never carried by a refresh. */
|
||||
auth_at?: number;
|
||||
/** The linked_identities.id the sign-in came through; present exactly when auth_at is. It is the only record of which login proved the session, and it describes the sign-in, not an asserted factor. */
|
||||
auth_identity_id?: string;
|
||||
display_name?: string;
|
||||
app: {
|
||||
slug: AppSlug;
|
||||
|
|
|
|||
|
|
@ -1,49 +0,0 @@
|
|||
// A fixed-window attempt budget per key, held in memory by the process that
|
||||
// serves the route. It bounds a credentialed caller's rate; it is not a
|
||||
// security boundary on its own and resets on restart. The clock is injected
|
||||
// so tests can move it.
|
||||
|
||||
export interface FixedWindowLimit {
|
||||
max: number;
|
||||
windowMs: number;
|
||||
}
|
||||
|
||||
export class FixedWindowBudget {
|
||||
private readonly windows = new Map<string, { startedAt: number; used: number }>();
|
||||
|
||||
constructor(
|
||||
private readonly limit: FixedWindowLimit,
|
||||
private readonly now: () => number = Date.now,
|
||||
private readonly maxKeys = 4096,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Spend one attempt for `key`. `n` counts attempts in the window, denied ones
|
||||
* included, so a caller can act on the first denial (`n === max + 1`) and not
|
||||
* on the flood behind it. `retryAfterSeconds` is 0 when allowed.
|
||||
*/
|
||||
spend(key: string): { n: number; retryAfterSeconds: number } {
|
||||
const at = this.now();
|
||||
let current = this.windows.get(key);
|
||||
if (!current || at - current.startedAt >= this.limit.windowMs) {
|
||||
this.sweep(at);
|
||||
current = { startedAt: at, used: 0 };
|
||||
this.windows.set(key, current);
|
||||
}
|
||||
current.used += 1;
|
||||
if (current.used <= this.limit.max) return { n: current.used, retryAfterSeconds: 0 };
|
||||
return { n: current.used, retryAfterSeconds: Math.max(1, Math.ceil((current.startedAt + this.limit.windowMs - at) / 1000)) };
|
||||
}
|
||||
|
||||
/** Drop turned-over windows once the table is full; if it is still full, the oldest key goes. */
|
||||
private sweep(at: number): void {
|
||||
if (this.windows.size < this.maxKeys) return;
|
||||
for (const [key, window] of this.windows) {
|
||||
if (at - window.startedAt >= this.limit.windowMs) this.windows.delete(key);
|
||||
}
|
||||
if (this.windows.size >= this.maxKeys) {
|
||||
const oldest = this.windows.keys().next().value;
|
||||
if (oldest !== undefined) this.windows.delete(oldest);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { type HeimdallConfig } from "./config.js";
|
||||
import { type Provider } from "./contracts.js";
|
||||
import { type TokenCustody } from "./custody.js";
|
||||
import { type ExpiringTokenSet, type OAuthProviderRuntime, type OAuthRuntimeRegistry, ProviderResponseError } from "./oauth.js";
|
||||
import { type OAuthProviderRuntime, type OAuthRuntimeRegistry, ProviderResponseError } from "./oauth.js";
|
||||
import { providerCatalog } from "./providers.js";
|
||||
import { type HeimdallStore } from "./store/index.js";
|
||||
import { type CredentialDecision, type StoredLinkedIdentity } from "./store/types.js";
|
||||
|
|
@ -16,14 +16,6 @@ export const MANAGED_CREDENTIAL_REFRESH_SKEW_MS = 5 * 60 * 1000;
|
|||
*/
|
||||
export const MANAGED_CREDENTIAL_REFRESH_TIMEOUT_MS = 10_000;
|
||||
|
||||
/**
|
||||
* After a refresh attempt fails or times out, no process presents the row's
|
||||
* refresh token again until this long has passed (the failure is recorded on
|
||||
* the row, read under the lock). A provider that rotates may have consumed the
|
||||
* token before the attempt was aborted, and presenting it again reads as theft.
|
||||
*/
|
||||
export const MANAGED_CREDENTIAL_REFRESH_BACKOFF_MS = 60 * 1000;
|
||||
|
||||
export interface ManagedCredentialContext {
|
||||
config: HeimdallConfig;
|
||||
store: HeimdallStore;
|
||||
|
|
@ -63,17 +55,6 @@ function dueForRefresh(identity: StoredLinkedIdentity, runtime: OAuthProviderRun
|
|||
return Date.parse(identity.tokenExpiresAt) - now <= MANAGED_CREDENTIAL_REFRESH_SKEW_MS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a failure stamped `failedAt` still holds this clock off. A stamp from
|
||||
* a writer whose clock runs ahead reads as the future; one this far ahead or
|
||||
* more is skew, not a fresh failure, so the wait stays bounded at twice the
|
||||
* backoff.
|
||||
*/
|
||||
function withinBackoff(failedAt: string, now: number): boolean {
|
||||
const elapsed = now - Date.parse(failedAt);
|
||||
return elapsed > -MANAGED_CREDENTIAL_REFRESH_BACKOFF_MS && elapsed < MANAGED_CREDENTIAL_REFRESH_BACKOFF_MS;
|
||||
}
|
||||
|
||||
function withoutTokens(identity: StoredLinkedIdentity): StoredLinkedIdentity {
|
||||
const { accessTokenEncrypted: _a, refreshTokenEncrypted: _r, tokenExpiresAt: _e, ...rest } = identity;
|
||||
return rest;
|
||||
|
|
@ -87,8 +68,10 @@ function withoutTokens(identity: StoredLinkedIdentity): StoredLinkedIdentity {
|
|||
* attempt is in flight join it and receive its outcome, success or failure.
|
||||
* The entry lives exactly as long as the attempt: a later request retries.
|
||||
*
|
||||
* Across processes the row owns it: a failed attempt leaves a marker on the
|
||||
* row, and a process that takes the lock inside the backoff presents nothing.
|
||||
* Known residual risk (docs/service-contract.md): one presentation per
|
||||
* process per burst, so N processes may present one token N times. The
|
||||
* cross-process failed-refresh marker that would close it is
|
||||
* joystick:follow_up:cross-process-failed-refresh-marker.
|
||||
*/
|
||||
const refreshesInFlight = new WeakMap<HeimdallStore, Map<string, Promise<ManagedCredentialOutcome>>>();
|
||||
|
||||
|
|
@ -137,8 +120,28 @@ async function refreshUnderLock(
|
|||
const runtime = ctx.oauthRuntimes[provider];
|
||||
|
||||
let revocation: ManagedCredentialRevocationReason | undefined;
|
||||
let failed = false;
|
||||
const rotation = (locked: StoredLinkedIdentity, refreshed: ExpiringTokenSet, updatedAt: string): CredentialDecision => {
|
||||
const decide = async (locked: StoredLinkedIdentity): Promise<CredentialDecision> => {
|
||||
if (!locked.accessTokenEncrypted || !dueForRefresh(locked, runtime, clock())) {
|
||||
return { kind: "keep" };
|
||||
}
|
||||
const updatedAt = new Date(clock()).toISOString();
|
||||
const refreshToken = ctx.tokenCustody.decrypt(locked.refreshTokenEncrypted!);
|
||||
|
||||
let refreshed;
|
||||
try {
|
||||
refreshed = await runtime.refreshAccessToken!({
|
||||
config: ctx.config,
|
||||
refreshToken,
|
||||
signal: AbortSignal.timeout(ctx.timeoutMs ?? MANAGED_CREDENTIAL_REFRESH_TIMEOUT_MS),
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof ProviderResponseError && error.oauthError === "invalid_grant") {
|
||||
revocation = "provider_revoked";
|
||||
return { kind: "revoke", updatedAt };
|
||||
}
|
||||
throw new ManagedCredentialRefreshFailed(provider);
|
||||
}
|
||||
|
||||
let refreshTokenEncrypted: string;
|
||||
if (refreshed.refreshToken) {
|
||||
refreshTokenEncrypted = ctx.tokenCustody.encrypt(refreshed.refreshToken);
|
||||
|
|
@ -159,47 +162,9 @@ async function refreshUnderLock(
|
|||
tokenExpiresAt: refreshed.expiresAt,
|
||||
scopes: refreshed.scope.length ? refreshed.scope : locked.scopes,
|
||||
updatedAt,
|
||||
consumedRefreshToken: providerCatalog[provider].rotatesRefreshTokens,
|
||||
};
|
||||
};
|
||||
|
||||
const decide = async (locked: StoredLinkedIdentity): Promise<CredentialDecision> => {
|
||||
if (!locked.accessTokenEncrypted || !dueForRefresh(locked, runtime, clock())) {
|
||||
return { kind: "keep" };
|
||||
}
|
||||
if (locked.refreshFailedAt && withinBackoff(locked.refreshFailedAt, clock())) {
|
||||
failed = true;
|
||||
return { kind: "keep" };
|
||||
}
|
||||
const refreshToken = ctx.tokenCustody.decrypt(locked.refreshTokenEncrypted!);
|
||||
|
||||
let refreshed;
|
||||
try {
|
||||
refreshed = await runtime.refreshAccessToken!({
|
||||
config: ctx.config,
|
||||
refreshToken,
|
||||
signal: AbortSignal.timeout(ctx.timeoutMs ?? MANAGED_CREDENTIAL_REFRESH_TIMEOUT_MS),
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof ProviderResponseError && error.oauthError === "invalid_grant") {
|
||||
revocation = "provider_revoked";
|
||||
return { kind: "revoke", updatedAt: new Date(clock()).toISOString() };
|
||||
}
|
||||
failed = true;
|
||||
return { kind: "failed", failedAt: new Date(clock()).toISOString() };
|
||||
}
|
||||
|
||||
// The provider has answered, and may have consumed the token: from here a throw must still leave the marker, which
|
||||
// a rolled-back transaction would not (the store writes it on its own for a rotation it could not commit).
|
||||
const answeredAt = new Date(clock()).toISOString();
|
||||
try {
|
||||
return rotation(locked, refreshed, answeredAt);
|
||||
} catch {
|
||||
failed = true;
|
||||
return { kind: "failed", failedAt: new Date(clock()).toISOString() };
|
||||
}
|
||||
};
|
||||
|
||||
const committed = await ctx.store.updateLinkedIdentityCredential(snapshot.id, decide);
|
||||
if (revocation) {
|
||||
await ctx.store.createAuditEvent({
|
||||
|
|
@ -209,9 +174,6 @@ async function refreshUnderLock(
|
|||
createdAt: new Date(clock()).toISOString(),
|
||||
});
|
||||
}
|
||||
if (failed) {
|
||||
throw new ManagedCredentialRefreshFailed(provider);
|
||||
}
|
||||
if (!committed) {
|
||||
return { kind: "revoked", identity: withoutTokens(snapshot) };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,12 +22,6 @@ export interface ProviderDescriptor {
|
|||
clientIdEnv: string;
|
||||
clientSecretEnv: string;
|
||||
additionalAuthorizationParams?: Record<string, string>;
|
||||
/**
|
||||
* The authorize parameters that make this provider ask the person again
|
||||
* (a sign_in step-up). Absent means the provider has no such parameter, and
|
||||
* a step-up start for it is refused rather than passed without the step.
|
||||
*/
|
||||
stepUpAuthorizationParams?: Record<string, string>;
|
||||
/**
|
||||
* What a callback does with a (provider, subject) no account holds yet.
|
||||
* `mints_account` is every provider's behaviour before Google: the first
|
||||
|
|
@ -98,8 +92,6 @@ export const providerCatalog: Record<Provider, ProviderDescriptor> = {
|
|||
firstLogin: "mints_account",
|
||||
clientIdEnv: "GC_ACCESS_PROVIDER_TWITCH_CLIENT_ID",
|
||||
clientSecretEnv: "GC_ACCESS_PROVIDER_TWITCH_CLIENT_SECRET_FILE",
|
||||
// Twitch force_verify re-shows the authorize page; it is not a password prompt.
|
||||
stepUpAuthorizationParams: { force_verify: "true" },
|
||||
},
|
||||
youtube: {
|
||||
key: "youtube",
|
||||
|
|
@ -124,8 +116,6 @@ export const providerCatalog: Record<Provider, ProviderDescriptor> = {
|
|||
include_granted_scopes: "true",
|
||||
prompt: "consent",
|
||||
},
|
||||
// login forces re-authentication; consent is kept because this provider always asks for it.
|
||||
stepUpAuthorizationParams: { prompt: "login consent" },
|
||||
},
|
||||
spotify: {
|
||||
key: "spotify",
|
||||
|
|
@ -160,7 +150,6 @@ export const providerCatalog: Record<Provider, ProviderDescriptor> = {
|
|||
clientIdEnv: "GC_ACCESS_PROVIDER_GOOGLE_CLIENT_ID",
|
||||
clientSecretEnv: "GC_ACCESS_PROVIDER_GOOGLE_CLIENT_SECRET_FILE",
|
||||
additionalAuthorizationParams: { prompt: "select_account" },
|
||||
stepUpAuthorizationParams: { prompt: "login" },
|
||||
},
|
||||
joystick: {
|
||||
key: "joystick",
|
||||
|
|
@ -191,8 +180,6 @@ export function buildAuthorizationUrl(options: {
|
|||
requestedScopes: string[] | undefined;
|
||||
/** For an OIDC provider: the nonce, and the S256 challenge of the PKCE verifier. */
|
||||
oidc?: { nonce: string; codeChallenge: string };
|
||||
/** Ask the provider for a real re-authentication. Throws for a provider with no such parameter: never a silent pass. */
|
||||
stepUp?: boolean;
|
||||
}): string {
|
||||
const descriptor = providerCatalog[options.provider];
|
||||
const url = new URL(descriptor.authorizationEndpoint);
|
||||
|
|
@ -216,15 +203,6 @@ export function buildAuthorizationUrl(options: {
|
|||
url.searchParams.set(key, value);
|
||||
}
|
||||
|
||||
if (options.stepUp) {
|
||||
if (!descriptor.stepUpAuthorizationParams) {
|
||||
throw new Error(`${descriptor.displayName} has no re-authentication parameter: a step-up cannot be honoured.`);
|
||||
}
|
||||
for (const [key, value] of Object.entries(descriptor.stepUpAuthorizationParams)) {
|
||||
url.searchParams.set(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,88 +0,0 @@
|
|||
import { type Provider } from "../contracts.js";
|
||||
import { type MoveIdentitiesInput, type StoredIdentityMove, type StoredLinkedIdentity } from "./types.js";
|
||||
|
||||
/** Everything a store read under its locks that the move rules decide on. */
|
||||
export interface MoveFacts {
|
||||
/** The identity_moves row already holding input.moveId. */
|
||||
existing: StoredIdentityMove | null;
|
||||
/** The move input.reversesMoveId names, and whether a reversal of it already exists. */
|
||||
forward: { move: StoredIdentityMove; reversed: boolean } | null;
|
||||
/** Every proof session still carries its sid, app, account and access revision and has not expired. */
|
||||
proofSessionsLive: boolean;
|
||||
sourceIdentities: readonly StoredLinkedIdentity[];
|
||||
destinationProviders: ReadonlySet<Provider>;
|
||||
/** An account with no identity and an unreversed move from it. */
|
||||
sourceIsAlias: boolean;
|
||||
destinationIsAlias: boolean;
|
||||
}
|
||||
|
||||
export type MoveDecision =
|
||||
| { kind: "replayed"; move: StoredIdentityMove }
|
||||
| { kind: "refused"; result: { kind: "move_id_conflict" | "proof_lapsed" | "already_moved" | "identity_not_found" | "identity_not_proved" | "not_reversible" } | { kind: "provider_conflict"; provider: Provider } }
|
||||
| { kind: "move"; identities: StoredLinkedIdentity[] };
|
||||
|
||||
/**
|
||||
* The one statement of the move rules, in the order they are decided. Both
|
||||
* stores gather MoveFacts under their locks and apply the answer, so memory
|
||||
* and Postgres cannot disagree about what a move refuses.
|
||||
*/
|
||||
export function decideMove(input: MoveIdentitiesInput, facts: MoveFacts): MoveDecision {
|
||||
const refuse = (kind: "move_id_conflict" | "proof_lapsed" | "already_moved" | "identity_not_found" | "identity_not_proved" | "not_reversible"): MoveDecision => ({ kind: "refused", result: { kind } });
|
||||
|
||||
const { existing } = facts;
|
||||
if (existing) {
|
||||
return existing.appSlug === input.appSlug &&
|
||||
existing.fromAccountId === input.fromAccountId &&
|
||||
existing.toAccountId === input.toAccountId &&
|
||||
existing.reversesMoveId === input.reversesMoveId
|
||||
? { kind: "replayed", move: existing }
|
||||
: refuse("move_id_conflict");
|
||||
}
|
||||
if (!facts.proofSessionsLive) return refuse("proof_lapsed");
|
||||
|
||||
const held = new Set(facts.sourceIdentities.map((identity) => identity.id));
|
||||
if (input.reversesMoveId !== undefined) {
|
||||
const forward = facts.forward;
|
||||
if (
|
||||
!forward ||
|
||||
forward.reversed ||
|
||||
forward.move.appSlug !== input.appSlug ||
|
||||
forward.move.reversesMoveId !== undefined ||
|
||||
forward.move.toAccountId !== input.fromAccountId ||
|
||||
forward.move.fromAccountId !== input.toAccountId ||
|
||||
!forward.move.moved.every((moved) => held.has(moved.id))
|
||||
) {
|
||||
return refuse("not_reversible");
|
||||
}
|
||||
} else if (facts.sourceIsAlias || facts.destinationIsAlias) {
|
||||
return refuse("already_moved");
|
||||
}
|
||||
|
||||
let chosen: StoredLinkedIdentity[];
|
||||
if (input.identityIds === "all") {
|
||||
chosen = [...facts.sourceIdentities];
|
||||
} else {
|
||||
if (!input.identityIds.every((id) => held.has(id))) return refuse("identity_not_found");
|
||||
const wanted = new Set(input.identityIds);
|
||||
chosen = facts.sourceIdentities.filter((identity) => wanted.has(identity.id));
|
||||
}
|
||||
if (chosen.length === 0) return refuse("identity_not_found");
|
||||
|
||||
if (input.covers !== "account") {
|
||||
const covered = new Set(input.covers);
|
||||
if (!chosen.every((identity) => covered.has(identity.id))) return refuse("identity_not_proved");
|
||||
}
|
||||
const clash = chosen.find((identity) => facts.destinationProviders.has(identity.provider));
|
||||
if (clash) return { kind: "refused", result: { kind: "provider_conflict", provider: clash.provider } };
|
||||
return { kind: "move", identities: chosen };
|
||||
}
|
||||
|
||||
/** The history a move writes about what it carried. */
|
||||
export function movedRecord(identities: readonly StoredLinkedIdentity[]): StoredIdentityMove["moved"] {
|
||||
return identities.map((identity) => ({
|
||||
id: identity.id,
|
||||
provider: identity.provider,
|
||||
providerUserId: identity.providerUserId,
|
||||
credentialStripped: identity.accessTokenEncrypted !== undefined || identity.refreshTokenEncrypted !== undefined,
|
||||
}));
|
||||
}
|
||||
|
|
@ -4,8 +4,6 @@ import {
|
|||
type CreateAccountInput,
|
||||
type CreateAuthAttemptInput,
|
||||
type ClaimOAuthStateUseInput,
|
||||
OAUTH_STATE_PRUNE_BATCH,
|
||||
oauthStatePruneCutoffMs,
|
||||
type CreateAuthCompletionInput,
|
||||
type CreateConsentReceiptInput,
|
||||
type CreatePendingLinkInput,
|
||||
|
|
@ -18,7 +16,6 @@ import {
|
|||
type CreateSessionInput,
|
||||
type CreatePrivateCommandReceiptInput,
|
||||
type HeimdallStore,
|
||||
IdentityHeldElsewhereError,
|
||||
type StoredAccount,
|
||||
type StoredAuthAttempt,
|
||||
type StoredAuthCompletion,
|
||||
|
|
@ -26,13 +23,11 @@ import {
|
|||
type StoredLinkedIdentity,
|
||||
type StoredSession,
|
||||
type StoredPrivateCommandReceipt,
|
||||
type MoveIdentitiesInput,
|
||||
type MoveResult,
|
||||
type StoredIdentityMove,
|
||||
type UnlinkResult,
|
||||
type UpsertLinkedIdentityInput,
|
||||
} from "./types.js";
|
||||
import { decideMove, movedRecord } from "./identity-move.js";
|
||||
|
||||
/** The skew a signed OAuth state is still accepted for past its exp (verifyOAuthState). */
|
||||
const OAUTH_STATE_SKEW_MS = 30_000;
|
||||
|
||||
function clone<T>(value: T): T {
|
||||
return JSON.parse(JSON.stringify(value)) as T;
|
||||
|
|
@ -67,7 +62,6 @@ export class InMemoryStore implements HeimdallStore {
|
|||
private readonly entitlementSnapshots = new Map<string, CreateEntitlementSnapshotInput>();
|
||||
private readonly auditEvents = new Map<string, CreateAuditEventInput>();
|
||||
private readonly consentReceipts: StoredConsentReceipt[] = [];
|
||||
private readonly identityMoves = new Map<string, StoredIdentityMove>();
|
||||
|
||||
async ensureSchema(): Promise<void> {
|
||||
return;
|
||||
|
|
@ -155,9 +149,6 @@ export class InMemoryStore implements HeimdallStore {
|
|||
const existing = [...this.linkedIdentities.values()].find(
|
||||
(identity) => identity.provider === input.provider && identity.providerUserId === input.providerUserId
|
||||
);
|
||||
if (existing && existing.accountId !== input.accountId) {
|
||||
throw new IdentityHeldElsewhereError();
|
||||
}
|
||||
const id = existing?.id ?? input.id ?? randomUUID();
|
||||
const linkedIdentity: StoredLinkedIdentity = {
|
||||
id,
|
||||
|
|
@ -198,87 +189,6 @@ export class InMemoryStore implements HeimdallStore {
|
|||
return clone(linkedIdentity);
|
||||
}
|
||||
|
||||
async unlinkLinkedIdentity(accountId: string, identityId: string): Promise<UnlinkResult> {
|
||||
const target = this.linkedIdentities.get(identityId);
|
||||
if (!target || target.accountId !== accountId) return { kind: "not_found" };
|
||||
const held = [...this.linkedIdentities.values()].filter((identity) => identity.accountId === accountId).length;
|
||||
if (held <= 1) return { kind: "last_identity" };
|
||||
this.linkedIdentities.delete(identityId);
|
||||
return { kind: "unlinked", identity: clone(target) };
|
||||
}
|
||||
|
||||
/** No await between the reads and the writes: the event loop is the lock. */
|
||||
async moveIdentities(input: MoveIdentitiesInput): Promise<MoveResult> {
|
||||
const at = Date.parse(input.at);
|
||||
const forwardMove = input.reversesMoveId !== undefined ? this.identityMoves.get(input.reversesMoveId) : undefined;
|
||||
const sourceIdentities = [...this.linkedIdentities.values()].filter((identity) => identity.accountId === input.fromAccountId);
|
||||
const destinationIdentities = [...this.linkedIdentities.values()].filter((identity) => identity.accountId === input.toAccountId);
|
||||
const decision = decideMove(input, {
|
||||
existing: this.identityMoves.get(input.moveId) ?? null,
|
||||
forward: forwardMove ? { move: forwardMove, reversed: this.isReversed(forwardMove.id) } : null,
|
||||
proofSessionsLive: input.proofSessions.every((proof) => {
|
||||
const session = this.sessions.get(proof.sessionId);
|
||||
return (
|
||||
session !== undefined &&
|
||||
session.appSlug === proof.appSlug &&
|
||||
session.accountId === proof.accountId &&
|
||||
session.accessRevision === proof.accessRevision &&
|
||||
Date.parse(session.expiresAt) > at
|
||||
);
|
||||
}),
|
||||
sourceIdentities: clone(sourceIdentities),
|
||||
destinationProviders: new Set(destinationIdentities.map((identity) => identity.provider)),
|
||||
sourceIsAlias: sourceIdentities.length === 0 && this.hasUnreversedMoveFrom(input.fromAccountId),
|
||||
destinationIsAlias: destinationIdentities.length === 0 && this.hasUnreversedMoveFrom(input.toAccountId),
|
||||
});
|
||||
if (decision.kind === "replayed") return { kind: "replayed", move: clone(decision.move) };
|
||||
if (decision.kind === "refused") return decision.result;
|
||||
|
||||
for (const identity of decision.identities) {
|
||||
const row = this.linkedIdentities.get(identity.id)!;
|
||||
row.accountId = input.toAccountId;
|
||||
row.updatedAt = input.at;
|
||||
delete row.accessTokenEncrypted;
|
||||
delete row.refreshTokenEncrypted;
|
||||
delete row.tokenExpiresAt;
|
||||
delete row.refreshFailedAt;
|
||||
}
|
||||
for (const session of this.sessions.values()) {
|
||||
if (session.accountId === input.fromAccountId && Date.parse(session.expiresAt) > at) {
|
||||
session.expiresAt = input.at;
|
||||
session.lastSeenAt = input.at;
|
||||
session.accessRevision += 1;
|
||||
}
|
||||
}
|
||||
for (const row of [...this.pendingLinks.values(), ...this.authCompletions.values()]) {
|
||||
if (row.accountId === input.fromAccountId && !row.consumedAt && Date.parse(row.expiresAt) > at) row.expiresAt = input.at;
|
||||
}
|
||||
const move: StoredIdentityMove = {
|
||||
id: input.moveId,
|
||||
appSlug: input.appSlug,
|
||||
fromAccountId: input.fromAccountId,
|
||||
toAccountId: input.toAccountId,
|
||||
...(input.reversesMoveId !== undefined ? { reversesMoveId: input.reversesMoveId } : {}),
|
||||
moved: movedRecord(decision.identities),
|
||||
proof: clone(input.proof),
|
||||
createdAt: input.at,
|
||||
};
|
||||
this.identityMoves.set(move.id, clone(move));
|
||||
return { kind: "moved", move };
|
||||
}
|
||||
|
||||
async findIdentityMove(moveId: string): Promise<StoredIdentityMove | null> {
|
||||
return clone(this.identityMoves.get(moveId) ?? null);
|
||||
}
|
||||
|
||||
private isReversed(moveId: string): boolean {
|
||||
return [...this.identityMoves.values()].some((move) => move.reversesMoveId === moveId);
|
||||
}
|
||||
|
||||
private hasUnreversedMoveFrom(accountId: string): boolean {
|
||||
return [...this.identityMoves.values()].some((move) => move.fromAccountId === accountId && !this.isReversed(move.id));
|
||||
}
|
||||
|
||||
async updateLinkedIdentityCredential(
|
||||
id: string,
|
||||
decide: (locked: StoredLinkedIdentity) => Promise<CredentialDecision>,
|
||||
|
|
@ -293,13 +203,7 @@ export class InMemoryStore implements HeimdallStore {
|
|||
if (decision.kind === "keep") {
|
||||
return clone(current);
|
||||
}
|
||||
if (decision.kind === "failed") {
|
||||
const marked: StoredLinkedIdentity = { ...current, refreshFailedAt: decision.failedAt };
|
||||
this.linkedIdentities.set(id, clone(marked));
|
||||
return clone(marked);
|
||||
}
|
||||
const next: StoredLinkedIdentity = { ...current, updatedAt: decision.updatedAt };
|
||||
delete next.refreshFailedAt;
|
||||
delete next.accessTokenEncrypted;
|
||||
delete next.refreshTokenEncrypted;
|
||||
delete next.tokenExpiresAt;
|
||||
|
|
@ -554,23 +458,18 @@ export class InMemoryStore implements HeimdallStore {
|
|||
}
|
||||
|
||||
async claimOAuthStateUse(input: ClaimOAuthStateUseInput): Promise<boolean> {
|
||||
// A state is still accepted until its exp + 30 s skew, so a use record must outlive that.
|
||||
const usedAtMs = Date.parse(input.usedAt);
|
||||
for (const [key, expiresAt] of this.oauthStateUses) {
|
||||
if (Date.parse(expiresAt) + OAUTH_STATE_SKEW_MS <= usedAtMs) {
|
||||
this.oauthStateUses.delete(key);
|
||||
}
|
||||
}
|
||||
const key = `${input.jti}:${input.phase}`;
|
||||
if (this.oauthStateUses.has(key)) {
|
||||
return false;
|
||||
}
|
||||
this.oauthStateUses.set(key, input.expiresAt);
|
||||
// A state is still accepted until its exp + skew; a record is kept a margin longer, and each
|
||||
// successful claim removes at most a batch of what is past that (oauthStatePruneCutoffMs), as
|
||||
// the Postgres store does.
|
||||
const cutoff = oauthStatePruneCutoffMs(Date.parse(input.usedAt));
|
||||
let pruned = 0;
|
||||
for (const [oldKey, expiresAt] of this.oauthStateUses) {
|
||||
if (pruned >= OAUTH_STATE_PRUNE_BATCH) break;
|
||||
if (Date.parse(expiresAt) <= cutoff) {
|
||||
this.oauthStateUses.delete(oldKey);
|
||||
pruned += 1;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import { Pool, type PoolClient, type QueryResultRow } from "pg";
|
||||
import { type AppSlug, type HeimdallAuthAttemptStatus, type LinkedIdentityInput, type Provider } from "../contracts.js";
|
||||
import { decideMove, movedRecord, type MoveFacts } from "./identity-move.js";
|
||||
import { CREATE_SCHEMA_SQL, REQUIRED_COLUMNS, REQUIRED_KEYS, REQUIRED_TABLES } from "./schema.js";
|
||||
import {
|
||||
type CreateAccountInput,
|
||||
|
|
@ -19,12 +18,6 @@ import {
|
|||
type CreateSessionInput,
|
||||
type CreatePrivateCommandReceiptInput,
|
||||
type HeimdallStore,
|
||||
IdentityHeldElsewhereError,
|
||||
type MoveIdentitiesInput,
|
||||
type MoveResult,
|
||||
type StoredIdentityMove,
|
||||
OAUTH_STATE_PRUNE_BATCH,
|
||||
oauthStatePruneCutoffMs,
|
||||
type StoredAccount,
|
||||
type StoredAuthAttempt,
|
||||
type StoredAuthCompletion,
|
||||
|
|
@ -32,7 +25,6 @@ import {
|
|||
type StoredLinkedIdentity,
|
||||
type StoredSession,
|
||||
type StoredPrivateCommandReceipt,
|
||||
type UnlinkResult,
|
||||
type UpsertLinkedIdentityInput,
|
||||
} from "./types.js";
|
||||
|
||||
|
|
@ -55,7 +47,6 @@ interface LinkedIdentityRow extends QueryResultRow {
|
|||
access_token_encrypted: string | null;
|
||||
refresh_token_encrypted: string | null;
|
||||
token_expires_at: string | null;
|
||||
refresh_failed_at: string | null;
|
||||
scopes: string;
|
||||
profile_json: Record<string, unknown>;
|
||||
created_at: string;
|
||||
|
|
@ -179,42 +170,6 @@ function mapAccountRow(row: AccountRow): StoredAccount {
|
|||
return account;
|
||||
}
|
||||
|
||||
interface IdentityMoveRow extends QueryResultRow {
|
||||
id: string;
|
||||
app_slug: AppSlug;
|
||||
from_account_id: string;
|
||||
to_account_id: string;
|
||||
reverses_move_id: string | null;
|
||||
moved_json: StoredIdentityMove["moved"];
|
||||
proof_json: StoredIdentityMove["proof"];
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
async function selectIdentityMove(db: Pick<Pool, "query">, moveId: string): Promise<StoredIdentityMove | null> {
|
||||
const result = await db.query<IdentityMoveRow>("SELECT * FROM identity_moves WHERE id = $1", [moveId]);
|
||||
const row = result.rows[0];
|
||||
if (!row) return null;
|
||||
return {
|
||||
id: row.id,
|
||||
appSlug: row.app_slug,
|
||||
fromAccountId: row.from_account_id,
|
||||
toAccountId: row.to_account_id,
|
||||
...(row.reverses_move_id ? { reversesMoveId: row.reverses_move_id } : {}),
|
||||
moved: row.moved_json,
|
||||
proof: row.proof_json,
|
||||
createdAt: new Date(row.created_at).toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/** A move from this account that no reversal names: the account's identities went elsewhere. */
|
||||
async function hasUnreversedMoveFrom(db: Pick<Pool, "query">, accountId: string): Promise<boolean> {
|
||||
const result = await db.query(
|
||||
"SELECT 1 FROM identity_moves m WHERE m.from_account_id = $1 AND NOT EXISTS (SELECT 1 FROM identity_moves r WHERE r.reverses_move_id = m.id) LIMIT 1",
|
||||
[accountId]
|
||||
);
|
||||
return (result.rowCount ?? 0) > 0;
|
||||
}
|
||||
|
||||
function mapLinkedIdentityRow(row: LinkedIdentityRow): StoredLinkedIdentity {
|
||||
const identity: StoredLinkedIdentity = {
|
||||
id: row.id,
|
||||
|
|
@ -252,10 +207,6 @@ function mapLinkedIdentityRow(row: LinkedIdentityRow): StoredLinkedIdentity {
|
|||
identity.tokenExpiresAt = new Date(row.token_expires_at).toISOString();
|
||||
}
|
||||
|
||||
if (row.refresh_failed_at) {
|
||||
identity.refreshFailedAt = new Date(row.refresh_failed_at).toISOString();
|
||||
}
|
||||
|
||||
return identity;
|
||||
}
|
||||
|
||||
|
|
@ -493,28 +444,21 @@ export function samePredicate(declared: string, actual: string, keyColumns: stri
|
|||
/**
|
||||
* What the store's statements do to each table, and so the privileges its
|
||||
* database role needs: it reads what it returns or looks up, inserts what it
|
||||
* creates, updates what it changes or upserts, and deletes two things:
|
||||
* expired oauth_state_uses rows (which needs SELECT for its WHERE), and the one
|
||||
* linked_identities row an unlink names (unlinkLinkedIdentity, the only
|
||||
* delete there; it needs SELECT for its WHERE and for the account's count).
|
||||
* identity_moves is append-only. moveIdentities is the statement that updates
|
||||
* linked_identities.account_id (its one writer), sessions, pending_links and
|
||||
* auth_completions, all of which already hold UPDATE.
|
||||
* creates, updates what it changes or upserts, and deletes nothing.
|
||||
* audit_events is written and never read back.
|
||||
*/
|
||||
export const STORE_TABLE_PRIVILEGES: Readonly<Record<string, readonly ("SELECT" | "INSERT" | "UPDATE" | "DELETE")[]>> = {
|
||||
export const STORE_TABLE_PRIVILEGES: Readonly<Record<string, readonly ("SELECT" | "INSERT" | "UPDATE")[]>> = {
|
||||
accounts: ["SELECT", "INSERT", "UPDATE"],
|
||||
linked_identities: ["SELECT", "INSERT", "UPDATE", "DELETE"],
|
||||
linked_identities: ["SELECT", "INSERT", "UPDATE"],
|
||||
sessions: ["SELECT", "INSERT", "UPDATE"],
|
||||
auth_attempts: ["SELECT", "INSERT", "UPDATE"],
|
||||
private_command_receipts: ["SELECT", "INSERT"],
|
||||
auth_completions: ["SELECT", "INSERT", "UPDATE"],
|
||||
pending_links: ["SELECT", "INSERT", "UPDATE"],
|
||||
oauth_state_uses: ["SELECT", "INSERT", "DELETE"],
|
||||
oauth_state_uses: ["INSERT"],
|
||||
capability_grants: ["SELECT", "INSERT"],
|
||||
entitlement_snapshots: ["SELECT", "INSERT", "UPDATE"],
|
||||
consent_receipts: ["SELECT", "INSERT"],
|
||||
identity_moves: ["SELECT", "INSERT"],
|
||||
audit_events: ["INSERT"],
|
||||
};
|
||||
|
||||
|
|
@ -649,9 +593,7 @@ export async function checkSchemaRequirements(pool: Pick<Pool, "query">, require
|
|||
SELECT 1 FROM unnest($2::text[], $3::text[]) AS used(table_name, column_name)
|
||||
JOIN pg_attribute a ON a.attrelid = to_regclass(used.table_name) AND a.attname = used.column_name AND NOT a.attisdropped
|
||||
WHERE used.table_name = required.table_name
|
||||
AND NOT CASE WHEN required.privilege = 'DELETE'
|
||||
THEN has_table_privilege(a.attrelid, 'DELETE') -- DELETE is a row privilege, never held per column
|
||||
ELSE has_column_privilege(a.attrelid, a.attnum, required.privilege) END
|
||||
AND NOT has_column_privilege(a.attrelid, a.attnum, required.privilege)
|
||||
)
|
||||
`,
|
||||
[
|
||||
|
|
@ -828,17 +770,16 @@ export class PostgresStore implements HeimdallStore {
|
|||
)
|
||||
ON CONFLICT (provider, provider_user_id)
|
||||
DO UPDATE SET
|
||||
account_id = EXCLUDED.account_id,
|
||||
username = EXCLUDED.username,
|
||||
display_name = EXCLUDED.display_name,
|
||||
primary_email = EXCLUDED.primary_email,
|
||||
access_token_encrypted = EXCLUDED.access_token_encrypted,
|
||||
refresh_token_encrypted = EXCLUDED.refresh_token_encrypted,
|
||||
token_expires_at = EXCLUDED.token_expires_at,
|
||||
refresh_failed_at = NULL,
|
||||
scopes = EXCLUDED.scopes,
|
||||
profile_json = EXCLUDED.profile_json,
|
||||
updated_at = EXCLUDED.updated_at
|
||||
WHERE linked_identities.account_id = EXCLUDED.account_id
|
||||
RETURNING *
|
||||
`,
|
||||
[
|
||||
|
|
@ -859,144 +800,7 @@ export class PostgresStore implements HeimdallStore {
|
|||
]
|
||||
);
|
||||
|
||||
// The conflict arm updates only the holder's own row; another account's yields no row.
|
||||
if (!result.rows[0]) throw new IdentityHeldElsewhereError();
|
||||
return mapLinkedIdentityRow(result.rows[0]);
|
||||
}
|
||||
|
||||
async unlinkLinkedIdentity(accountId: string, identityId: string): Promise<UnlinkResult> {
|
||||
const client = await this.pool.connect();
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
try {
|
||||
// The account row serialises its unlinks: two clients racing for the last two identities queue here.
|
||||
await client.query("SELECT id FROM accounts WHERE id = $1 FOR UPDATE", [accountId]);
|
||||
const held = await client.query<{ n: string | number }>(
|
||||
"SELECT count(*) AS n FROM linked_identities WHERE account_id = $1",
|
||||
[accountId]
|
||||
);
|
||||
let result: UnlinkResult;
|
||||
if (Number(held.rows[0]?.n ?? 0) <= 1) {
|
||||
const mine = await client.query("SELECT 1 FROM linked_identities WHERE id = $1 AND account_id = $2", [identityId, accountId]);
|
||||
result = mine.rowCount ? { kind: "last_identity" } : { kind: "not_found" };
|
||||
} else {
|
||||
const deleted = await client.query<LinkedIdentityRow>(
|
||||
"DELETE FROM linked_identities WHERE id = $2 AND account_id = $1 RETURNING *",
|
||||
[accountId, identityId]
|
||||
);
|
||||
result = deleted.rows[0] ? { kind: "unlinked", identity: mapLinkedIdentityRow(deleted.rows[0]) } : { kind: "not_found" };
|
||||
}
|
||||
await client.query("COMMIT");
|
||||
return result;
|
||||
} catch (error) {
|
||||
await client.query("ROLLBACK");
|
||||
throw error;
|
||||
}
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
async moveIdentities(input: MoveIdentitiesInput): Promise<MoveResult> {
|
||||
const client = await this.pool.connect();
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
try {
|
||||
// Both accounts, one statement each in id order, before anything is read: opposite moves queue here
|
||||
// instead of deadlocking. The order is the code's own, not whatever order a plan locks a set in.
|
||||
for (const id of [input.fromAccountId, input.toAccountId].sort()) {
|
||||
await client.query("SELECT id FROM accounts WHERE id = $1 FOR UPDATE", [id]);
|
||||
}
|
||||
const existing = await selectIdentityMove(client, input.moveId);
|
||||
let forward: MoveFacts["forward"] = null;
|
||||
if (input.reversesMoveId !== undefined) {
|
||||
const forwardMove = await selectIdentityMove(client, input.reversesMoveId);
|
||||
const reversal = forwardMove ? await client.query("SELECT 1 FROM identity_moves WHERE reverses_move_id = $1", [forwardMove.id]) : null;
|
||||
forward = forwardMove ? { move: forwardMove, reversed: (reversal?.rowCount ?? 0) > 0 } : null;
|
||||
}
|
||||
let proofSessionsLive = true;
|
||||
for (const proof of input.proofSessions) {
|
||||
const live = await client.query(
|
||||
"SELECT 1 FROM sessions WHERE id = $1 AND app_slug = $2 AND account_id = $3 AND access_revision = $4 AND expires_at > $5",
|
||||
[proof.sessionId, proof.appSlug, proof.accountId, proof.accessRevision, input.at]
|
||||
);
|
||||
if (!live.rowCount) proofSessionsLive = false;
|
||||
}
|
||||
const source = await client.query<LinkedIdentityRow>(
|
||||
"SELECT * FROM linked_identities WHERE account_id = $1 ORDER BY created_at ASC, id ASC FOR UPDATE",
|
||||
[input.fromAccountId]
|
||||
);
|
||||
const destination = await client.query<{ provider: Provider }>("SELECT provider FROM linked_identities WHERE account_id = $1", [input.toAccountId]);
|
||||
const sourceIdentities = source.rows.map(mapLinkedIdentityRow);
|
||||
const decision = decideMove(input, {
|
||||
existing,
|
||||
forward,
|
||||
proofSessionsLive,
|
||||
sourceIdentities,
|
||||
destinationProviders: new Set(destination.rows.map((row) => row.provider)),
|
||||
sourceIsAlias: sourceIdentities.length === 0 && (await hasUnreversedMoveFrom(client, input.fromAccountId)),
|
||||
destinationIsAlias: destination.rowCount === 0 && (await hasUnreversedMoveFrom(client, input.toAccountId)),
|
||||
});
|
||||
if (decision.kind !== "move") {
|
||||
await client.query("ROLLBACK");
|
||||
return decision.kind === "replayed" ? { kind: "replayed", move: decision.move } : decision.result;
|
||||
}
|
||||
const ids = decision.identities.map((identity) => identity.id);
|
||||
await client.query(
|
||||
`UPDATE linked_identities
|
||||
SET account_id = $2, access_token_encrypted = NULL, refresh_token_encrypted = NULL,
|
||||
token_expires_at = NULL, refresh_failed_at = NULL, updated_at = $3
|
||||
WHERE id = ANY($1) AND account_id = $4`,
|
||||
[ids, input.toAccountId, input.at, input.fromAccountId]
|
||||
);
|
||||
await client.query(
|
||||
"UPDATE sessions SET expires_at = $2, last_seen_at = $2, access_revision = access_revision + 1 WHERE account_id = $1 AND expires_at > $2",
|
||||
[input.fromAccountId, input.at]
|
||||
);
|
||||
for (const table of ["pending_links", "auth_completions"]) {
|
||||
await client.query(
|
||||
`UPDATE ${table} SET expires_at = $2 WHERE account_id = $1 AND consumed_at IS NULL AND expires_at > $2`,
|
||||
[input.fromAccountId, input.at]
|
||||
);
|
||||
}
|
||||
const move: StoredIdentityMove = {
|
||||
id: input.moveId,
|
||||
appSlug: input.appSlug,
|
||||
fromAccountId: input.fromAccountId,
|
||||
toAccountId: input.toAccountId,
|
||||
...(input.reversesMoveId !== undefined ? { reversesMoveId: input.reversesMoveId } : {}),
|
||||
moved: movedRecord(decision.identities),
|
||||
proof: input.proof,
|
||||
createdAt: input.at,
|
||||
};
|
||||
try {
|
||||
await client.query(
|
||||
`INSERT INTO identity_moves (id, app_slug, from_account_id, to_account_id, reverses_move_id, moved_json, proof_json, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
||||
[move.id, move.appSlug, move.fromAccountId, move.toAccountId, nullable(move.reversesMoveId), JSON.stringify(move.moved), JSON.stringify(move.proof), move.createdAt]
|
||||
);
|
||||
} catch (error) {
|
||||
// The row is the arbiter of a race the locks did not serialise (a reversal of the same move through another pool).
|
||||
if ((error as { code?: string }).code !== "23505") throw error;
|
||||
await client.query("ROLLBACK");
|
||||
const winner = await this.findIdentityMove(input.moveId);
|
||||
if (!winner) return { kind: "not_reversible" };
|
||||
const again = decideMove(input, { existing: winner, forward: null, proofSessionsLive: true, sourceIdentities: [], destinationProviders: new Set(), sourceIsAlias: false, destinationIsAlias: false });
|
||||
return again.kind === "replayed" ? { kind: "replayed", move: again.move } : { kind: "move_id_conflict" };
|
||||
}
|
||||
await client.query("COMMIT");
|
||||
return { kind: "moved", move };
|
||||
} catch (error) {
|
||||
await client.query("ROLLBACK").catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
async findIdentityMove(moveId: string): Promise<StoredIdentityMove | null> {
|
||||
return selectIdentityMove(this.pool, moveId);
|
||||
return mapLinkedIdentityRow(expectRow(result.rows[0], "upsertLinkedIdentity"));
|
||||
}
|
||||
|
||||
async updateLinkedIdentityCredential(
|
||||
|
|
@ -1006,7 +810,6 @@ export class PostgresStore implements HeimdallStore {
|
|||
const client = await this.pool.connect();
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
let decision: CredentialDecision | undefined;
|
||||
try {
|
||||
const locked = await client.query<LinkedIdentityRow>(
|
||||
"SELECT * FROM linked_identities WHERE id = $1 FOR UPDATE",
|
||||
|
|
@ -1017,15 +820,9 @@ export class PostgresStore implements HeimdallStore {
|
|||
return null;
|
||||
}
|
||||
const row = mapLinkedIdentityRow(expectRow(locked.rows[0], "updateLinkedIdentityCredential"));
|
||||
decision = await decide(row);
|
||||
const decision = await decide(row);
|
||||
let committed = row;
|
||||
if (decision.kind === "failed") {
|
||||
const marked = await client.query<LinkedIdentityRow>(
|
||||
"UPDATE linked_identities SET refresh_failed_at = $2 WHERE id = $1 RETURNING *",
|
||||
[id, decision.failedAt]
|
||||
);
|
||||
committed = mapLinkedIdentityRow(expectRow(marked.rows[0], "updateLinkedIdentityCredential"));
|
||||
} else if (decision.kind !== "keep") {
|
||||
if (decision.kind !== "keep") {
|
||||
const rotated = decision.kind === "rotate" ? decision : undefined;
|
||||
const written = await client.query<LinkedIdentityRow>(
|
||||
`
|
||||
|
|
@ -1033,7 +830,6 @@ export class PostgresStore implements HeimdallStore {
|
|||
SET access_token_encrypted = $2,
|
||||
refresh_token_encrypted = $3,
|
||||
token_expires_at = $4,
|
||||
refresh_failed_at = NULL,
|
||||
scopes = $5,
|
||||
updated_at = $6
|
||||
WHERE id = $1
|
||||
|
|
@ -1054,21 +850,6 @@ export class PostgresStore implements HeimdallStore {
|
|||
return committed;
|
||||
} catch (error) {
|
||||
await client.query("ROLLBACK");
|
||||
if (decision?.kind === "rotate" || decision?.kind === "revoke") {
|
||||
// The provider answered, and what its answer called for could not be committed: the rollback took the answer
|
||||
// with it. In a transaction of its own, leave the marker, so no caller presents the token within the backoff.
|
||||
// Blank the token only when it is spent: a revoke, or a provider that rotates (Joystick) and consumed it.
|
||||
// A provider that keeps its refresh token valid (Twitch) keeps it, so a transient write failure does not
|
||||
// strand the link. Best effort: if this write fails too, the original error is the one that matters, and the
|
||||
// residual is named in docs/service-contract.md.
|
||||
const spent = decision.kind === "revoke" || decision.consumedRefreshToken;
|
||||
await client
|
||||
.query(
|
||||
`UPDATE linked_identities SET ${spent ? "refresh_token_encrypted = NULL, " : ""}refresh_failed_at = $2 WHERE id = $1`,
|
||||
[id, decision.updatedAt]
|
||||
)
|
||||
.catch(() => undefined);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
} finally {
|
||||
|
|
@ -1411,7 +1192,7 @@ export class PostgresStore implements HeimdallStore {
|
|||
}
|
||||
|
||||
async claimOAuthStateUse(input: ClaimOAuthStateUseInput): Promise<boolean> {
|
||||
// No conflict target and no RETURNING: ON CONFLICT DO NOTHING alone says whether the row was new.
|
||||
// No conflict target and no RETURNING: both need SELECT, and the role has INSERT only.
|
||||
const result = await this.pool.query(
|
||||
`
|
||||
INSERT INTO oauth_state_uses (jti, phase, app_slug, used_at, expires_at)
|
||||
|
|
@ -1420,24 +1201,7 @@ export class PostgresStore implements HeimdallStore {
|
|||
`,
|
||||
[input.jti, input.phase, input.appSlug, input.usedAt, input.expiresAt]
|
||||
);
|
||||
if (result.rowCount !== 1) return false;
|
||||
// Retention: one bounded delete of records past the shared cutoff, inline as the memory store does.
|
||||
// The claim row is already written, so a prune failure must not fail the claim: a throw would make
|
||||
// the user's retry look like a replay. Unpruned rows are harmless and the next claim prunes them.
|
||||
try {
|
||||
await this.pool.query(
|
||||
`
|
||||
DELETE FROM oauth_state_uses
|
||||
WHERE (jti, phase) IN (
|
||||
SELECT jti, phase FROM oauth_state_uses WHERE expires_at <= $1 LIMIT $2
|
||||
)
|
||||
`,
|
||||
[new Date(oauthStatePruneCutoffMs(Date.parse(input.usedAt))), OAUTH_STATE_PRUNE_BATCH]
|
||||
);
|
||||
} catch {
|
||||
// deliberately swallowed, see above
|
||||
}
|
||||
return true;
|
||||
return result.rowCount === 1;
|
||||
}
|
||||
|
||||
async upsertEntitlementSnapshot(input: CreateEntitlementSnapshotInput): Promise<void> {
|
||||
|
|
|
|||
|
|
@ -123,11 +123,6 @@ ALTER TABLE auth_completions ADD COLUMN IF NOT EXISTS attempt_id TEXT;
|
|||
-- compares it against the target the redeeming app states.
|
||||
ALTER TABLE auth_completions ADD COLUMN IF NOT EXISTS target_id TEXT;
|
||||
|
||||
-- When a refresh attempt on this row last failed or timed out, read under the row
|
||||
-- lock so a second Heimdall process does not present the same refresh token
|
||||
-- right behind it. Cleared by rotation, revocation and re-link.
|
||||
ALTER TABLE linked_identities ADD COLUMN IF NOT EXISTS refresh_failed_at TIMESTAMPTZ;
|
||||
|
||||
-- One attempt handle may bind at most one unconsumed completion (R21.1): a
|
||||
-- second completion for the same handle would let the by-attempt UPDATE in
|
||||
-- consumeAuthCompletionByAttempt match more than one row, silently consuming
|
||||
|
|
@ -143,10 +138,9 @@ CREATE UNIQUE INDEX IF NOT EXISTS auth_completions_attempt_unconsumed_unique_idx
|
|||
|
||||
-- One use per (state jti, phase): a signed OAuth state launches once and
|
||||
-- completes once. The first INSERT for a pair wins; ON CONFLICT DO NOTHING
|
||||
-- reports every later claim as no row. A state is accepted until expires_at + 30 s
|
||||
-- of skew, so a row may be pruned only after that and is never consulted again;
|
||||
-- each successful claim deletes a bounded batch of rows past expires_at + skew
|
||||
-- + margin (OAUTH_STATE_* in types.ts), found through the expires_at index.
|
||||
-- reports every later claim as no row. The Postgres store deletes nothing, so rows
|
||||
-- stay like consumed auth_completions; a state is accepted until expires_at + 30 s
|
||||
-- of skew, so a row may be pruned only after that and is never consulted again.
|
||||
CREATE TABLE IF NOT EXISTS oauth_state_uses (
|
||||
jti TEXT NOT NULL,
|
||||
phase TEXT NOT NULL,
|
||||
|
|
@ -156,8 +150,6 @@ CREATE TABLE IF NOT EXISTS oauth_state_uses (
|
|||
PRIMARY KEY (jti, phase)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS oauth_state_uses_expires_at_idx ON oauth_state_uses(expires_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS capability_grants (
|
||||
id TEXT PRIMARY KEY,
|
||||
account_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
|
||||
|
|
@ -216,29 +208,6 @@ CREATE UNIQUE INDEX IF NOT EXISTS consent_receipts_revokes_unique_idx
|
|||
CREATE INDEX IF NOT EXISTS consent_receipts_account_idx
|
||||
ON consent_receipts(account_id, created_at);
|
||||
|
||||
-- One row per identity move, append-only: no UPDATE and no DELETE, and no
|
||||
-- ON DELETE CASCADE, so the record outlives nothing it names. moved_json is
|
||||
-- [{id, provider, providerUserId, credentialStripped}]; proof_json is
|
||||
-- {from: [{tier, method, identityId}], to: {...} | null}. Never a token, sid,
|
||||
-- email or name. A move is reversed once: the second row naming it conflicts.
|
||||
CREATE TABLE IF NOT EXISTS identity_moves (
|
||||
id TEXT PRIMARY KEY,
|
||||
app_slug TEXT NOT NULL,
|
||||
from_account_id TEXT NOT NULL REFERENCES accounts(id),
|
||||
to_account_id TEXT NOT NULL REFERENCES accounts(id),
|
||||
reverses_move_id TEXT REFERENCES identity_moves(id),
|
||||
moved_json JSONB NOT NULL,
|
||||
proof_json JSONB NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS identity_moves_reverses_unique_idx
|
||||
ON identity_moves(reverses_move_id)
|
||||
WHERE reverses_move_id IS NOT NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS identity_moves_from_idx
|
||||
ON identity_moves(from_account_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS audit_events (
|
||||
id TEXT PRIMARY KEY,
|
||||
account_id TEXT,
|
||||
|
|
|
|||
|
|
@ -33,86 +33,12 @@ export interface StoredLinkedIdentity {
|
|||
accessTokenEncrypted?: string;
|
||||
refreshTokenEncrypted?: string;
|
||||
tokenExpiresAt?: string;
|
||||
/** When a refresh attempt last failed; cleared by the next rotation, revocation or re-link. */
|
||||
refreshFailedAt?: string;
|
||||
scopes: string[];
|
||||
profileJson: Record<string, unknown>;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* An upsert named an identity another account holds. A provider identity
|
||||
* belongs to the account that holds it until that account unlinks it; no
|
||||
* write moves it.
|
||||
*/
|
||||
export class IdentityHeldElsewhereError extends Error {
|
||||
constructor() {
|
||||
super("This provider identity is held by another account.");
|
||||
this.name = "IdentityHeldElsewhereError";
|
||||
}
|
||||
}
|
||||
|
||||
/** What unlinkLinkedIdentity decided; `identity` is the row as it was deleted. */
|
||||
export type UnlinkResult =
|
||||
| { kind: "unlinked"; identity: StoredLinkedIdentity }
|
||||
| { kind: "last_identity" }
|
||||
| { kind: "not_found" };
|
||||
|
||||
/** One login a move carried; `credentialStripped` is history written at the move (the row held a token the move dropped). */
|
||||
export interface MovedIdentity {
|
||||
id: string;
|
||||
provider: Provider;
|
||||
providerUserId: string;
|
||||
credentialStripped: boolean;
|
||||
}
|
||||
|
||||
/** What each side's proofs were, as tiers and methods only: never a token, sid, email or name. */
|
||||
export interface IdentityMoveProof {
|
||||
from: { tier: "strong" | "weak"; method: string | null; identityId: string | null }[];
|
||||
to: { tier: "strong" | "weak"; method: string | null; identityId: string | null } | null;
|
||||
}
|
||||
|
||||
/** The append-only record of one move. */
|
||||
export interface StoredIdentityMove {
|
||||
id: string;
|
||||
appSlug: AppSlug;
|
||||
fromAccountId: string;
|
||||
toAccountId: string;
|
||||
reversesMoveId?: string;
|
||||
moved: MovedIdentity[];
|
||||
proof: IdentityMoveProof;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface MoveIdentitiesInput {
|
||||
moveId: string;
|
||||
appSlug: AppSlug;
|
||||
fromAccountId: string;
|
||||
toAccountId: string;
|
||||
/** The logins to carry, or every login the source holds. */
|
||||
identityIds: readonly string[] | "all";
|
||||
/** What the caller's from-proofs cover: the whole account, or exactly these logins. */
|
||||
covers: "account" | readonly string[];
|
||||
/** Sessions the proofs rested on; each must still be live when the transaction commits. */
|
||||
proofSessions: readonly { appSlug: AppSlug; sessionId: string; accountId: string; accessRevision: number }[];
|
||||
reversesMoveId?: string;
|
||||
proof: IdentityMoveProof;
|
||||
at: string;
|
||||
}
|
||||
|
||||
/** What moveIdentities decided. Refusals write nothing. */
|
||||
export type MoveResult =
|
||||
| { kind: "moved"; move: StoredIdentityMove }
|
||||
| { kind: "replayed"; move: StoredIdentityMove }
|
||||
| { kind: "move_id_conflict" }
|
||||
| { kind: "proof_lapsed" }
|
||||
| { kind: "already_moved" }
|
||||
| { kind: "identity_not_found" }
|
||||
| { kind: "identity_not_proved" }
|
||||
| { kind: "provider_conflict"; provider: Provider }
|
||||
| { kind: "not_reversible" };
|
||||
|
||||
export interface UpsertLinkedIdentityInput {
|
||||
id?: string;
|
||||
accountId: string;
|
||||
|
|
@ -132,9 +58,8 @@ export interface UpsertLinkedIdentityInput {
|
|||
|
||||
/**
|
||||
* What the owner of a refresh decides after reading the row under its lock.
|
||||
* `rotate` and `revoke` write token columns, scopes, updated_at and clear the
|
||||
* failed-refresh marker; `failed` writes the marker alone. None touches
|
||||
* account, profile or identity fields.
|
||||
* `rotate` and `revoke` write token columns, scopes and updated_at only; they
|
||||
* never touch account, profile or identity fields.
|
||||
*/
|
||||
export type CredentialDecision =
|
||||
| { kind: "keep" }
|
||||
|
|
@ -145,11 +70,8 @@ export type CredentialDecision =
|
|||
tokenExpiresAt: string;
|
||||
scopes: string[];
|
||||
updatedAt: string;
|
||||
/** The provider rotates refresh tokens, so the one presented is spent whether or not this decision commits. */
|
||||
consumedRefreshToken: boolean;
|
||||
}
|
||||
| { kind: "revoke"; updatedAt: string }
|
||||
| { kind: "failed"; failedAt: string };
|
||||
| { kind: "revoke"; updatedAt: string };
|
||||
|
||||
export interface StoredSession {
|
||||
id: string;
|
||||
|
|
@ -245,26 +167,9 @@ export interface StoredAuthCompletion {
|
|||
payloadJson: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** The skew a signed OAuth state is still accepted for past its exp: verifyOAuthState in app.ts reads this, so the two cannot drift. */
|
||||
export const OAUTH_STATE_SKEW_MS = 30_000;
|
||||
/** Extra time a use record is kept past exp + skew before it may be pruned. */
|
||||
export const OAUTH_STATE_PRUNE_MARGIN_MS = 10 * 60_000;
|
||||
/** The most use records one claim deletes. */
|
||||
export const OAUTH_STATE_PRUNE_BATCH = 50;
|
||||
|
||||
/**
|
||||
* The one retention rule for oauth_state_uses, shared by both stores: a record
|
||||
* is prunable when expires_at + skew + margin <= now, that is when its
|
||||
* expires_at is at or before this cutoff.
|
||||
*/
|
||||
export function oauthStatePruneCutoffMs(nowMs: number): number {
|
||||
return nowMs - OAUTH_STATE_SKEW_MS - OAUTH_STATE_PRUNE_MARGIN_MS;
|
||||
}
|
||||
|
||||
export interface ClaimOAuthStateUseInput {
|
||||
jti: string;
|
||||
/** `step_up_assert` is the app-asserted step-up route's one-use nonce claim: jti is a digest of session and nonce. */
|
||||
phase: "launch" | "callback" | "step_up_assert";
|
||||
phase: "launch" | "callback";
|
||||
appSlug: AppSlug;
|
||||
usedAt: string;
|
||||
expiresAt: string;
|
||||
|
|
@ -384,26 +289,7 @@ export interface HeimdallStore {
|
|||
findStoredLinkedIdentity(provider: Provider, providerUserId: string): Promise<StoredLinkedIdentity | null>;
|
||||
findStoredLinkedIdentityForAccount(accountId: string, provider: Provider): Promise<StoredLinkedIdentity | null>;
|
||||
listStoredLinkedIdentitiesForAccount(accountId: string): Promise<StoredLinkedIdentity[]>;
|
||||
/** Writes the holder's own row; throws IdentityHeldElsewhereError rather than move another account's identity. */
|
||||
upsertLinkedIdentity(input: UpsertLinkedIdentityInput): Promise<StoredLinkedIdentity>;
|
||||
/**
|
||||
* The only delete of a linked identity. One transaction locks the account,
|
||||
* refuses to take its last identity, and deletes the row only if the
|
||||
* account holds it (an id that is missing or another account's is the same
|
||||
* `not_found`).
|
||||
*/
|
||||
unlinkLinkedIdentity(accountId: string, identityId: string): Promise<UnlinkResult>;
|
||||
/**
|
||||
* The only writer that sets a linked identity's account to another account,
|
||||
* in one transaction: lock both accounts (in id order), answer a repeated
|
||||
* moveId from its row, re-check the proof sessions, refuse what the rules
|
||||
* refuse, move the rows with their credentials stripped, revoke the source's
|
||||
* sessions and expire its pending links and unredeemed completions, and
|
||||
* append the identity_moves row. A reversal (`reversesMoveId`) moves back
|
||||
* exactly what the forward move carried. The destination never loses an identity.
|
||||
*/
|
||||
moveIdentities(input: MoveIdentitiesInput): Promise<MoveResult>;
|
||||
findIdentityMove(moveId: string): Promise<StoredIdentityMove | null>;
|
||||
/**
|
||||
* Serialise decisions about one row's credential. `decide` receives the row
|
||||
* as read under the lock (never a caller's snapshot) and the decision is
|
||||
|
|
|
|||
|
|
@ -37,7 +37,3 @@
|
|||
{"ts": "2026-09-08T12:20:12+00:00", "type": "security", "status": "accepted", "note": "epiphany/heimdall-caller-identity, second Hands pass: closed the two Soul-proven holes. (1) auth-completions redeem code is now always store-minted (never handoff.attemptId); attemptId survives only as a separate correlation field on StoredAuthCompletion/CreateAuthCompletionInput, and a new store.consumeAuthCompletionByAttempt(appSlug, attemptId, at) lets the private command plane's completeAuth redeem by the handle it already owns instead of forging an HTTP self-call with completionCode=handle. (2) startOAuthFlow now refuses any returnTo whose origin is not in AppProfile.allowedReturnOrigins (repixelizer/streampixels/bifrost -> <slug>.gamecult.org, ghostlight -> yggdrasil.gamecult.org per its docs/app-profiles doc and existing test fixtures; a registered app's allowlist is derived from its stored redirectUris origins). AppCaller is now branded with an unexported Symbol (app-caller.ts); private-command-plane.ts's two { appSlug } literals are gone, replaced by app-caller.ts's callerFromOpenedEnvelope. Deleted: validateAppRegistration, AppRegistrationRequest, RegistrationProblem, AppRegistrationError, HeimdallStore.registerApp (writer). Kept: findRegisteredApp/listRegisteredApps/resolveAppProfile fallback -- AppSlug is a plain string (contracts.ts), registered_apps is a real extension point for a slug Heimdall was never rebuilt for, and isBuiltInApp's only production call site (listAppProfiles) depends on that fallback existing; a row is now provisioned by direct SQL (or InMemoryStore.seedRegisteredApp in tests), not by any app-facing writer. Deleted the two appSlug===\"ghostlight\" branches in private-command-plane.ts; the requirement is now AppProfile.requiredEntitlementPolicyKind, checked generically through resolveAppProfile. tsc --noEmit clean; vitest 94 passed/1 pre-existing-skip, only pre-existing failure is tests/odin-publication.test.ts (Cannot find module 'cultcache-ts', present on main too).", "branch": "epiphany/heimdall-caller-identity"}
|
||||
{"ts": "2026-10-19T15:56:24+00:00", "type": "design", "status": "accepted", "note": "Refresh is now one locked primitive for every provider (replaces unserialized refreshLinkedIdentityCredentialIfNeeded); Joystick is managed-only (Heimdall-held credential, no viewer sign-in until joystick:follow_up:joystick-viewer-signin-public-bot). Merged in PR #1, live at d51fa6b; campaign joystick:campaign:self.", "branch": "eureka/joystick-h1"}
|
||||
{"ts": "2026-10-19T16:03:16+00:00", "type": "verification", "status": "accepted", "note": "Live d51fa6b (H1) verified by a real human sign-in: the operator's StreamPixels Twitch sign-in through Heimdall start/callback, 2026-10-19 ('Twitch auth worked fine'); a streampixels session was created 16:02:33Z, after the 15:51Z switch. Joystick consent round trip still open: joystick:follow_up:h1-joystick-consent-round-trip."}
|
||||
{"ts": "2026-11-12T23:10:15+00:00", "type": "design", "status": "accepted", "note": "Changelog 2026-11-12: sign_in start accepts stepUp (app-authenticated, sign_in only, off by default); signed state carries step_up; launch adds Twitch force_verify=true, YouTube prompt='login consent', Google prompt=login; any other provider is 400 step_up_not_supported; completion reports stepUpRequested. Not deployed. For joystick:follow_up:chat-s7055-7056 (StreamPixels link hardening, second factor via fresh sign-in)."}
|
||||
{"ts": "2026-11-13T19:10:00+00:00", "type": "design", "status": "accepted", "note": "Changelog 2026-11-13: GET /v1/me/identities lists an account's identities (no credentials, profile or email); POST /v1/me/identities/:id/unlink deletes one with a step-up proof (access claim step_up_at, 600 s, never carried by refresh), refuses the last identity (409), writes revocation receipts when the provider's last identity goes. upsertLinkedIdentity no longer moves an identity between accounts (IdentityHeldElsewhereError; links/confirm 409 identity_linked_elsewhere, callback link_initiator_mismatch). The store role needs DELETE on linked_identities (startup refuses without it): owner-role GRANT at deploy."}
|
||||
{"ts": "2026-11-13T20:01:42+00:00", "type": "design", "status": "accepted", "note": "Changelog 2026-11-13: POST /v1/apps/{app}/step-up (profile grant stepUpAssertion, bifrost only; methods bifrost-passkey, bifrost-email-code). App secret plus the member's live bearer for that app; mints a same-session access token with step_up_at now and new claim step_up_method (exp kept, no refresh, no session write); nonce single-use via oauth_state_uses phase step_up_assert (no schema change), retained to the session expiry as of the claim (a refresh may slide the session past it); in-process rate limits 10 per 600 s per account, 120 per 60 s per app; audit step_up_asserted and step_up_assertion_refused with a nonce digest (429s and not-permitted refusals audited once per window; not-permitted has a 5 per 60 s per-app budget); nonce is base64url 16-128. Provider step-up sign-ins now carry step_up_method provider:<name>; identity_unlinked records stepUpMethod. Heimdall cannot verify the factor: it trusts the calling app. Not deployed; no env, secret or migration. For heimdall-body:follow_up:chat-s7419-7419.", "branch": "heimdall/app-asserted-step-up"}
|
||||
{"ts": "2026-11-14T01:10:00+00:00", "type": "design", "status": "accepted", "note": "Changelog 2026-11-14: POST /v1/apps/{app}/identity-moves (profile grant identityMoves, bifrost only; accountProofMethods bifrost-passkey) and the identity_moves table, the only writer of linked_identities.account_id (moveIdentities, one transaction, accounts locked in id order, shared decideMove on both stores). Access claims gain auth_at and auth_identity_id (sign_in callback only). proofTier is the one freshness rule (unlink reads it; strong 600 s step_up_at, weak 300 s auth_at); proofCovers: only an asserted account-proof method covers a whole account, otherwise the login in auth_identity_id (ruling move-proof-strict-passkey). Moves strip credentials, revoke the source's sessions, expire its pending links and unredeemed completions, write revocation receipts under the source, audit identity_moved; moveId is idempotent (replay answers the stored receipt without proof liveness); a move is reversible once. Not deployed: the owner must apply the identity_moves DDL and GRANT SELECT, INSERT ON identity_moves to the store role before the new build starts (startup refuses SCHEMA_MISSING / SCHEMA_PRIVILEGES otherwise).", "branch": "heimdall/identity-moves"}
|
||||
|
|
|
|||
|
|
@ -1440,105 +1440,6 @@ describe("Heimdall service", () => {
|
|||
expect(urls).toEqual([]);
|
||||
});
|
||||
|
||||
// Targets made only of whitespace, format characters, controls and the invisible fillers that render as nothing.
|
||||
const BLANK_TARGETS = [
|
||||
" ", "\t", "\n ", "\u200B", "\u00A0\uFEFF",
|
||||
"\u2800", "\u3164", "\u115F", "\u1160", "\uFFA0", "\u034F", "\u0000", "\u0007", " \u2800\u3164 ",
|
||||
// Marks, private-use, unassigned and lone-surrogate code points, and the symbols that read as nothing: none is a letter or a number.
|
||||
"\u0300", "\uE000", "\u0378", "\uD800", "\u{16FE4}", "\u{1D159}", "\u{F0000}", "\u0300\uE000 ",
|
||||
];
|
||||
// A target is usable when it holds a letter or a number that is not an invisible filler.
|
||||
const NAMING_TARGETS = ["creator-1", " creator-1 ", "Framgång", "42", "\u2800a\u3164", "\u3164\u0300x"];
|
||||
|
||||
describe("a connect start that names a connection target is the app backend's", () => {
|
||||
// The target is app-backend authority: the StreamPixels backend proves the creator-manage session and states the
|
||||
// creator it started the connect for. Anyone else naming {creator, X} would have X's connector rebound to their
|
||||
// own OAuth account at StreamPixels' redeem.
|
||||
const returnTo = "https://streampixels.gamecult.org/auth/connect";
|
||||
const managed = ["twitch", "youtube", "joystick"] as const;
|
||||
const startFor = (app: FastifyInstance, provider: string, connection: unknown, headers: Record<string, string> = {}) =>
|
||||
app.inject({
|
||||
method: "POST",
|
||||
url: `/v1/oauth/${provider}/start`,
|
||||
headers,
|
||||
payload: { appSlug: "streampixels", mode: "connect", returnTo, connection },
|
||||
});
|
||||
const own = { "x-heimdall-app-secret": "streampixels-secret" };
|
||||
|
||||
it.each(managed)("%s: a creator target is refused without the app's own secret and minted for it", async (provider) => {
|
||||
const config = createTestConfig();
|
||||
const app = await buildApp({ config });
|
||||
apps.push(app);
|
||||
const creator = { kind: "creator", targetId: "victim-creator" };
|
||||
|
||||
for (const headers of [{}, { "x-heimdall-app-secret": "repixelizer-secret" }, { "x-heimdall-app-secret": "wrong" }]) {
|
||||
const refused = await startFor(app, provider, creator, headers);
|
||||
expect([refused.statusCode, refused.json()]).toEqual([401, { error: "app_auth_required" }]);
|
||||
expect(refused.body).not.toContain("authorizationUrl");
|
||||
}
|
||||
const started = await startFor(app, provider, creator, own);
|
||||
expect(started.statusCode).toBe(201);
|
||||
const verified = verifyJwt(stateOf(started.json()), getPublicKey(app));
|
||||
expect(verified.valid && verified.payload).toEqual(expect.objectContaining({ connection: expect.objectContaining(creator) }));
|
||||
|
||||
// A direct caller (the command plane hands startOAuthFlow its own) is held to the slug too.
|
||||
const input = { appSlug: "streampixels" as const, mode: "connect" as const, returnTo, connection: { kind: "creator" as const, targetId: "victim-creator" } };
|
||||
const foreign = resolveAppCaller(config, "repixelizer", { "x-heimdall-app-secret": "repixelizer-secret" });
|
||||
expect((await startOAuthFlow(getHeimdallRuntimeContext(app), provider, foreign, input)).statusCode).toBe(401);
|
||||
expect((await startOAuthFlow(getHeimdallRuntimeContext(app), provider, null, input)).statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it.each(["twitch", "youtube"] as const)("%s: any connection kind needs the app's secret, not only creator", async (provider) => {
|
||||
const app = await buildApp({ config: createTestConfig() });
|
||||
apps.push(app);
|
||||
const account = { kind: "account", targetId: "victim-account" };
|
||||
|
||||
const refused = await startFor(app, provider, account);
|
||||
expect([refused.statusCode, refused.json()]).toEqual([401, { error: "app_auth_required" }]);
|
||||
expect((await startFor(app, provider, account, own)).statusCode).toBe(201);
|
||||
});
|
||||
|
||||
it.each(managed)("%s: a blank target is refused even for the app, whatever the blank is made of", async (provider) => {
|
||||
const app = await buildApp({ config: createTestConfig() });
|
||||
apps.push(app);
|
||||
|
||||
for (const kind of ["creator", "account"]) {
|
||||
for (const targetId of BLANK_TARGETS) {
|
||||
const refused = await startFor(app, provider, { kind, targetId }, own);
|
||||
expect([refused.statusCode, refused.json().error], `${kind} ${JSON.stringify(targetId)}`).toEqual([400, "invalid_connection_target"]);
|
||||
expect(refused.body).not.toContain("authorizationUrl");
|
||||
}
|
||||
}
|
||||
for (const targetId of NAMING_TARGETS) {
|
||||
expect((await startFor(app, provider, { kind: "creator", targetId }, own)).statusCode, JSON.stringify(targetId)).toBe(201);
|
||||
}
|
||||
});
|
||||
|
||||
it.each(managed)("%s: a blank target without the app's secret gets 401, not the 400 that would tell a stranger what the gate checks", async (provider) => {
|
||||
const app = await buildApp({ config: createTestConfig() });
|
||||
apps.push(app);
|
||||
|
||||
for (const headers of [{}, { "x-heimdall-app-secret": "wrong" }]) {
|
||||
for (const targetId of [" ", "\u2800"]) {
|
||||
const refused = await startFor(app, provider, { kind: "creator", targetId }, headers);
|
||||
expect([refused.statusCode, refused.json()], JSON.stringify(targetId)).toEqual([401, { error: "app_auth_required" }]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("the callback mint holds a blank creator target to the same bar", () => {
|
||||
const streampixels = builtInAppProfiles.streampixels!;
|
||||
for (const targetId of BLANK_TARGETS) {
|
||||
expect(callbackMint(streampixels, "joystick", "connect", { kind: "creator", targetId }), JSON.stringify(targetId)).toBe("refused");
|
||||
}
|
||||
// A visible character among the blanks names a target.
|
||||
expect(callbackMint(streampixels, "joystick", "connect", { kind: "creator", targetId: "\u2800a\u3164" })).toBe("custody_only");
|
||||
for (const targetId of NAMING_TARGETS) {
|
||||
expect(callbackMint(streampixels, "joystick", "connect", { kind: "creator", targetId }), JSON.stringify(targetId)).toBe("custody_only");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("joystick connect", () => {
|
||||
const creator = { kind: "creator", targetId: "creator-1" };
|
||||
async function connect(app: FastifyInstance, extra: Record<string, unknown> = { connection: creator }) {
|
||||
|
|
|
|||
|
|
@ -366,7 +366,7 @@ describe("R21.2: the WeakSet brand at every consumer", () => {
|
|||
if (line.includes("caller.appSlug")) expect(line, `${file}: ${line.trim()}`).toContain("isAppCaller(caller)");
|
||||
}
|
||||
}
|
||||
expect(consumers).toEqual(["app.ts: reads=4 guards=4"]);
|
||||
expect(consumers).toEqual(["app.ts: reads=3 guards=3"]);
|
||||
});
|
||||
|
||||
it("a caller minted for app A is refused where app B is expected, over HTTP and by direct call", async () => {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { buildApp } from "../src/app.js";
|
|||
import { loadConfig, type HeimdallConfig } from "../src/config.js";
|
||||
import { resetGoogleJwksCache } from "../src/google-oidc.js";
|
||||
import { type OAuthProviderRuntime } from "../src/oauth.js";
|
||||
import { IdentityHeldElsewhereError, InMemoryStore } from "../src/store/index.js";
|
||||
import { InMemoryStore } from "../src/store/index.js";
|
||||
import { launchFlow, launchWithRedirect, stateOf, type LaunchedFlow } from "./oauth-flow.js";
|
||||
|
||||
const CLIENT_ID = "google-client";
|
||||
|
|
@ -188,18 +188,6 @@ afterEach(async () => {
|
|||
while (apps.length) await apps.pop()?.close();
|
||||
});
|
||||
|
||||
describe("a link write that loses the race to another account", () => {
|
||||
it("links/confirm answers 409 identity_linked_elsewhere when the store refuses the move after the check passed", async () => {
|
||||
const { app, store } = await build();
|
||||
const { accessToken } = await signInWithDiscord(app);
|
||||
const done = await linkGoogleCallback(app, accessToken);
|
||||
expect(done.statusCode).toBe(201);
|
||||
vi.spyOn(store, "upsertLinkedIdentity").mockRejectedValueOnce(new IdentityHeldElsewhereError());
|
||||
const confirmed = await confirmLink(app, accessToken, done.json().pendingLinkId);
|
||||
expect([confirmed.statusCode, confirmed.json().error]).toEqual([409, "identity_linked_elsewhere"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Google as a Heimdall OIDC provider", () => {
|
||||
it("builds the authorization request: openid email profile, the exact callback, state, nonce and an S256 challenge", async () => {
|
||||
const { app } = await build();
|
||||
|
|
|
|||
|
|
@ -1,632 +0,0 @@
|
|||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterAll, afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { buildApp } from "../src/app.js";
|
||||
import { builtInAppProfiles } from "../src/app-profiles.js";
|
||||
import { AUTH_PROOF_TTL_SECONDS, issueAccessClaim, proofTier } from "../src/claims.js";
|
||||
import { type AppSlug, type Provider } from "../src/contracts.js";
|
||||
import { type OAuthProviderRuntime } from "../src/oauth.js";
|
||||
import { createRuntimeKeyMaterial } from "../src/signing.js";
|
||||
import { InMemoryStore } from "../src/store/index.js";
|
||||
import { type CreateAuditEventInput } from "../src/store/types.js";
|
||||
import { createTestConfig, launchFlow } from "./oauth-flow.js";
|
||||
|
||||
// POST /v1/apps/{app}/identity-moves, driven over HTTP the way Bifrost would
|
||||
// call it: the proofs are real access tokens whose claims say how and through
|
||||
// which login the member signed in; the answers are what a caller can see.
|
||||
|
||||
const now = "2026-11-14T12:00:00.000Z";
|
||||
const appAuth = { "x-heimdall-app-secret": "bifrost-secret" };
|
||||
const moveId = (tag: string) => `move-${tag}`.padEnd(20, "x");
|
||||
const nowS = () => Math.floor(Date.now() / 1000);
|
||||
|
||||
const dir = mkdtempSync(join(tmpdir(), "heimdall-identity-moves-"));
|
||||
const config = { ...createTestConfig(), signingPrivateKeyPath: join(dir, "signing.pem"), bootstrapSigningPrivateKeyOnMissing: true };
|
||||
afterAll(() => rmSync(dir, { recursive: true, force: true }));
|
||||
|
||||
const apps: Awaited<ReturnType<typeof buildApp>>[] = [];
|
||||
afterEach(async () => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
await Promise.all(apps.splice(0).map((app) => app.close()));
|
||||
});
|
||||
|
||||
function twitchRuntime(providerUserId: string): OAuthProviderRuntime {
|
||||
return {
|
||||
async exchangeAuthorizationCode() {
|
||||
return { accessToken: "at", refreshToken: "rt", tokenType: "Bearer", scope: ["user:read:email"], raw: {} };
|
||||
},
|
||||
async resolveIdentity() {
|
||||
return { provider: "twitch", providerUserId, username: providerUserId, profile: { id: providerUserId } };
|
||||
},
|
||||
async evaluateEntitlements() {
|
||||
return { facts: [], snapshots: [] };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function discordRuntime(providerUserId: string): OAuthProviderRuntime {
|
||||
return { ...twitchRuntime(providerUserId), async resolveIdentity() { return { provider: "discord", providerUserId, username: providerUserId, profile: { id: providerUserId } }; } };
|
||||
}
|
||||
|
||||
async function rig(twitchUser = "t-signin") {
|
||||
const store = new InMemoryStore();
|
||||
const audit: CreateAuditEventInput[] = [];
|
||||
const realAudit = store.createAuditEvent.bind(store);
|
||||
store.createAuditEvent = async (input) => {
|
||||
audit.push(structuredClone(input));
|
||||
return realAudit(input);
|
||||
};
|
||||
const app = await buildApp({ config, store, oauthRuntimes: { twitch: twitchRuntime(twitchUser), discord: discordRuntime("d-linked") } });
|
||||
apps.push(app);
|
||||
const keys = createRuntimeKeyMaterial({ ...config, bootstrapSigningPrivateKeyOnMissing: false });
|
||||
return { app, store, keys, audit };
|
||||
}
|
||||
type Rig = Awaited<ReturnType<typeof rig>>;
|
||||
|
||||
let seq = 0;
|
||||
/** An account holding the given logins, each with a credential, and one live-session proof factory. */
|
||||
async function acct(r: Rig, held: Array<[Provider, string?]>) {
|
||||
seq += 1;
|
||||
const account = await r.store.createAccount({ createdAt: now, lastSeenAt: now, displayName: "DISPLAY-CANARY", primaryEmail: "canary@example.test" });
|
||||
const rows = [];
|
||||
for (const [provider, user] of held) {
|
||||
rows.push(
|
||||
await r.store.upsertLinkedIdentity({
|
||||
accountId: account.id,
|
||||
provider,
|
||||
providerUserId: user ?? `${provider}-${seq}`,
|
||||
scopes: ["identify"],
|
||||
accessTokenEncrypted: "ACCESS-CIPHERTEXT",
|
||||
refreshTokenEncrypted: "REFRESH-CIPHERTEXT",
|
||||
profileJson: {},
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
);
|
||||
}
|
||||
return { account, rows };
|
||||
}
|
||||
type Acct = Awaited<ReturnType<typeof acct>>;
|
||||
|
||||
interface ProofOptions {
|
||||
app?: AppSlug;
|
||||
/** Seconds since the sign-in; null mints a token with no sign-in claim (a refresh or link would). */
|
||||
signedInAgo?: number | null;
|
||||
via?: number;
|
||||
stepUp?: { agoS?: number; method: string };
|
||||
}
|
||||
/** A live session for the account and the access token it would hold. */
|
||||
async function proof(r: Rig, a: Acct, o: ProofOptions = {}) {
|
||||
const ago = o.signedInAgo === undefined ? 0 : o.signedInAgo;
|
||||
const issued = await issueAccessClaim({
|
||||
config,
|
||||
keys: r.keys,
|
||||
store: r.store,
|
||||
input: {
|
||||
appSlug: o.app ?? "bifrost",
|
||||
accountId: a.account.id,
|
||||
linkedIdentities: a.rows.map((row) => ({ provider: row.provider, providerUserId: row.providerUserId })),
|
||||
facts: [],
|
||||
...(ago === null ? {} : { authAt: nowS() - ago, authIdentityId: a.rows[o.via ?? 0]!.id }),
|
||||
...(o.stepUp ? { stepUpAt: nowS() - (o.stepUp.agoS ?? 0), stepUpMethod: o.stepUp.method } : {}),
|
||||
},
|
||||
});
|
||||
return { token: issued.accessToken, sid: issued.session.sessionId };
|
||||
}
|
||||
const passkey = { method: "bifrost-passkey" };
|
||||
|
||||
const move = (r: Rig, body: object, headers: Record<string, string> = appAuth, app = "bifrost") =>
|
||||
r.app.inject({ method: "POST", url: `/v1/apps/${app}/identity-moves`, headers, payload: body });
|
||||
const held = async (r: Rig, a: Acct) => (await r.store.listStoredLinkedIdentitiesForAccount(a.account.id)).map((i) => i.id).sort();
|
||||
const err = (res: { statusCode: number; json(): { error?: string } }) => [res.statusCode, res.json().error];
|
||||
|
||||
/** Source with discord+google, destination with patreon: a passkey on the source and any fresh proof on the destination. */
|
||||
async function scene(r: Rig) {
|
||||
const from = await acct(r, [["discord"], ["google"]]);
|
||||
const to = await acct(r, [["patreon"]]);
|
||||
return {
|
||||
from,
|
||||
to,
|
||||
fromProof: await proof(r, from, { stepUp: passkey }),
|
||||
toProof: await proof(r, to),
|
||||
};
|
||||
}
|
||||
|
||||
describe("a sanctioned move", () => {
|
||||
it("two fresh proofs move every login of the source and answer the receipt, with no token, email or name", async () => {
|
||||
const r = await rig();
|
||||
const s = await scene(r);
|
||||
const sourceIds = await held(r, s.from);
|
||||
const res = await move(r, { moveId: moveId("a"), fromProofs: [s.fromProof.token], toProof: s.toProof.token });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.headers["cache-control"]).toBe("no-store");
|
||||
expect(res.json()).toEqual({
|
||||
moveId: moveId("a"),
|
||||
accountId: s.to.account.id,
|
||||
movedIdentityIds: expect.arrayContaining(sourceIds),
|
||||
proof: { from: "strong", to: "weak" },
|
||||
});
|
||||
expect(res.json().movedIdentityIds).toHaveLength(2);
|
||||
expect(res.body).not.toMatch(/CIPHERTEXT|canary|DISPLAY|ey[A-Za-z0-9_-]{20,}\./);
|
||||
expect(await held(r, s.from)).toEqual([]);
|
||||
expect(await held(r, s.to)).toEqual(expect.arrayContaining(sourceIds));
|
||||
expect(await held(r, s.to)).toHaveLength(3);
|
||||
expect(r.audit.find((e) => e.eventType === "identity_moved")).toMatchObject({
|
||||
accountId: s.from.account.id,
|
||||
eventPayloadJson: { moveId: moveId("a"), toAccountId: s.to.account.id, providers: ["discord", "google"], reversesMoveId: null },
|
||||
});
|
||||
});
|
||||
|
||||
it("answers the identical body for the same moveId after the source's sessions are revoked, and changes nothing twice", async () => {
|
||||
const r = await rig();
|
||||
const s = await scene(r);
|
||||
const body = { moveId: moveId("b"), fromProofs: [s.fromProof.token], toProof: s.toProof.token };
|
||||
const first = await move(r, body);
|
||||
expect(first.statusCode).toBe(200);
|
||||
const session = await r.store.findSession("bifrost", s.fromProof.sid);
|
||||
expect(Date.parse(session!.expiresAt), "the source's session is already dead").toBeLessThanOrEqual(Date.now());
|
||||
const again = await move(r, body);
|
||||
expect([again.statusCode, again.body]).toEqual([200, first.body]);
|
||||
expect(r.audit.filter((e) => e.eventType === "identity_moved")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("a moveId reused for another account pair, another proof owner or another reversesMoveId is 409 move_id_conflict and changes nothing", async () => {
|
||||
const r = await rig();
|
||||
const s = await scene(r);
|
||||
const other = await acct(r, [["twitch"]]);
|
||||
await move(r, { moveId: moveId("c"), fromProofs: [s.fromProof.token], toProof: s.toProof.token });
|
||||
const otherProof = await proof(r, other);
|
||||
const toStranger = await move(r, { moveId: moveId("c"), fromProofs: [s.fromProof.token], toProof: otherProof.token });
|
||||
expect(err(toStranger)).toEqual([409, "move_id_conflict"]);
|
||||
const fromStranger = await move(r, { moveId: moveId("c"), fromProofs: [otherProof.token], toProof: s.toProof.token });
|
||||
expect(err(fromStranger)).toEqual([409, "move_id_conflict"]);
|
||||
const asReversal = await move(r, { moveId: moveId("c"), fromProofs: [s.toProof.token], reversesMoveId: moveId("zz") });
|
||||
expect(err(asReversal)).toEqual([409, "move_id_conflict"]);
|
||||
expect(await held(r, other)).toHaveLength(1);
|
||||
expect(await held(r, s.to)).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("a replay whose proofs are another app's tokens, or another account's, is 409 move_id_conflict, not the stored receipt", async () => {
|
||||
const r = await rig();
|
||||
const s = await scene(r);
|
||||
const body = { moveId: moveId("rp"), fromProofs: [s.fromProof.token], toProof: s.toProof.token };
|
||||
expect((await move(r, body)).statusCode).toBe(200);
|
||||
const foreign = await proof(r, s.from, { app: "streampixels", stepUp: passkey });
|
||||
expect(err(await move(r, { ...body, fromProofs: [foreign.token] }))).toEqual([409, "move_id_conflict"]);
|
||||
const foreignTo = await proof(r, s.to, { app: "streampixels" });
|
||||
expect(err(await move(r, { ...body, toProof: foreignTo.token }))).toEqual([409, "move_id_conflict"]);
|
||||
expect((await move(r, body)).statusCode, "the genuine proofs still replay").toBe(200);
|
||||
});
|
||||
|
||||
it("an app without the secret is 401; streampixels with its own secret is 403 not_allowed_for_app", async () => {
|
||||
const r = await rig();
|
||||
const s = await scene(r);
|
||||
const body = { moveId: moveId("d"), fromProofs: [s.fromProof.token], toProof: s.toProof.token };
|
||||
expect(err(await move(r, body, {}))).toEqual([401, "app_auth_required"]);
|
||||
expect(err(await move(r, body, { "x-heimdall-app-secret": "nope" }))).toEqual([401, "app_auth_required"]);
|
||||
expect(err(await move(r, body, { "x-heimdall-app-secret": "streampixels-secret" }, "streampixels"))).toEqual([403, "not_allowed_for_app"]);
|
||||
expect(await held(r, s.from)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("only bifrost is granted moves, and its account proofs are methods it may assert", () => {
|
||||
const granted = Object.values(builtInAppProfiles).filter((p) => p.identityMoves !== undefined);
|
||||
expect(granted.map((p) => p.slug)).toEqual(["bifrost"]);
|
||||
const bifrost = granted[0]!;
|
||||
expect(bifrost.identityMoves!.accountProofMethods.every((m) => bifrost.stepUpAssertion!.methods.includes(m))).toBe(true);
|
||||
expect(bifrost.identityMoves!.accountProofMethods).not.toContain("bifrost-email-code");
|
||||
});
|
||||
});
|
||||
|
||||
describe("what a proof must be", () => {
|
||||
it("another app's token, a revoked session and a forged signature are 403 proof_invalid and nothing moves", async () => {
|
||||
const r = await rig();
|
||||
const s = await scene(r);
|
||||
const foreign = await proof(r, s.from, { app: "streampixels", stepUp: passkey });
|
||||
const revoked = await proof(r, s.from, { stepUp: passkey });
|
||||
await r.store.revokeSession("bifrost", revoked.sid, s.from.account.id, 1, new Date().toISOString());
|
||||
const forged = s.fromProof.token.slice(0, -6) + "AAAAAA";
|
||||
for (const [fromProofs, toProof, who] of [
|
||||
[[foreign.token], s.toProof.token, "from"],
|
||||
[[revoked.token], s.toProof.token, "from"],
|
||||
[[forged], s.toProof.token, "from"],
|
||||
[[s.fromProof.token], forged, "to"],
|
||||
] as const) {
|
||||
const res = await move(r, { moveId: moveId("e"), fromProofs, toProof });
|
||||
expect([res.statusCode, res.json()]).toEqual([403, { error: "proof_invalid", proof: who }]);
|
||||
}
|
||||
expect(await held(r, s.from)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("a sign-in 300 s old passes and 301 s is proof_stale; a token with no sign-in claim (refresh, link) is proof_stale; a step-up inside 600 s reports strong", async () => {
|
||||
const r = await rig();
|
||||
vi.useFakeTimers({ toFake: ["Date"] });
|
||||
vi.setSystemTime(new Date(now));
|
||||
const to = await acct(r, [["patreon"]]);
|
||||
const toProof = await proof(r, to);
|
||||
const providers: Provider[] = ["discord", "google", "twitch", "github", "spotify", "youtube"];
|
||||
const attempt = async (tag: string, o: ProofOptions) => {
|
||||
const from = await acct(r, [[providers.shift()!]]);
|
||||
const p = await proof(r, from, o);
|
||||
return move(r, { moveId: moveId(tag), fromProofs: [p.token], toProof: toProof.token });
|
||||
};
|
||||
const edge = await attempt("edge", { signedInAgo: AUTH_PROOF_TTL_SECONDS });
|
||||
expect([edge.statusCode, edge.json().proof]).toEqual([200, { from: "weak", to: "weak" }]);
|
||||
const late = await attempt("late", { signedInAgo: AUTH_PROOF_TTL_SECONDS + 1 });
|
||||
expect([late.statusCode, late.json()]).toEqual([403, { error: "proof_stale", proof: "from" }]);
|
||||
const none = await attempt("none", { signedInAgo: null });
|
||||
expect(err(none)).toEqual([403, "proof_stale"]);
|
||||
const strong = await attempt("strong", { signedInAgo: 5_000, stepUp: { agoS: 600, method: "provider:twitch" } });
|
||||
expect([strong.statusCode, strong.json().proof.from]).toEqual([200, "strong"]);
|
||||
const stale = await attempt("stale", { signedInAgo: 5_000, stepUp: { agoS: 601, method: "bifrost-passkey" } });
|
||||
expect(err(stale)).toEqual([403, "proof_stale"]);
|
||||
// The destination's proof is held to the same rule.
|
||||
const from = await acct(r, [["google"]]);
|
||||
const fresh = await proof(r, from);
|
||||
const staleTo = await proof(r, to, { signedInAgo: AUTH_PROOF_TTL_SECONDS + 1 });
|
||||
const res = await move(r, { moveId: moveId("sto"), fromProofs: [fresh.token], toProof: staleTo.token });
|
||||
expect([res.statusCode, res.json()]).toEqual([403, { error: "proof_stale", proof: "to" }]);
|
||||
});
|
||||
|
||||
it("fromProofs naming two accounts is 403 proof_invalid; one account on both sides is 409 same_account", async () => {
|
||||
const r = await rig();
|
||||
const s = await scene(r);
|
||||
const split = await move(r, { moveId: moveId("f"), fromProofs: [s.fromProof.token, s.toProof.token], toProof: s.toProof.token });
|
||||
expect([split.statusCode, split.json()]).toEqual([403, { error: "proof_invalid", proof: "from" }]);
|
||||
const twice = await proof(r, s.from, { stepUp: passkey });
|
||||
const same = await move(r, { moveId: moveId("g"), fromProofs: [s.fromProof.token], toProof: twice.token });
|
||||
expect(err(same)).toEqual([409, "same_account"]);
|
||||
expect(await held(r, s.from)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("the body is closed: a forward move needs toProof, a reversal takes no toProof or identityIds, ids are patterned", async () => {
|
||||
const r = await rig();
|
||||
const s = await scene(r);
|
||||
const t = s.fromProof.token;
|
||||
for (const body of [
|
||||
{ moveId: moveId("h"), fromProofs: [t] },
|
||||
{ moveId: "short", fromProofs: [t], toProof: t },
|
||||
{ moveId: moveId("h"), fromProofs: [], toProof: t },
|
||||
{ moveId: moveId("h"), fromProofs: [t], toProof: t, reversesMoveId: moveId("i") },
|
||||
{ moveId: moveId("h"), fromProofs: [t], reversesMoveId: moveId("i"), identityIds: ["x"] },
|
||||
]) {
|
||||
expect((await move(r, body)).statusCode, JSON.stringify(Object.keys(body))).toBe(400);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("accounts come only from proofs", () => {
|
||||
it("a stray accountId in the body steers nothing", async () => {
|
||||
const r = await rig();
|
||||
const s = await scene(r);
|
||||
const stranger = await acct(r, [["twitch"]]);
|
||||
const res = await move(r, { moveId: moveId("y"), fromProofs: [s.fromProof.token], toProof: s.toProof.token, accountId: stranger.account.id, toAccountId: stranger.account.id });
|
||||
expect([res.statusCode, res.json().accountId]).toEqual([200, s.to.account.id]);
|
||||
expect(await held(r, stranger)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("which logins a proof moves", () => {
|
||||
async function multi(r: Rig) {
|
||||
const from = await acct(r, [["discord"], ["google"], ["twitch"]]);
|
||||
const to = await acct(r, [["patreon"]]);
|
||||
return { from, to, toProof: await proof(r, to) };
|
||||
}
|
||||
|
||||
it("a sign-in through login X moves X and cannot move the source's other logins", async () => {
|
||||
const r = await rig();
|
||||
const s = await multi(r);
|
||||
const [discord, google] = s.from.rows;
|
||||
const viaDiscord = await proof(r, s.from, { via: 0 });
|
||||
const all = await move(r, { moveId: moveId("j"), fromProofs: [viaDiscord.token], toProof: s.toProof.token });
|
||||
expect(err(all)).toEqual([403, "identity_not_proved"]);
|
||||
const other = await move(r, { moveId: moveId("j"), fromProofs: [viaDiscord.token], toProof: s.toProof.token, identityIds: [google!.id] });
|
||||
expect(err(other)).toEqual([403, "identity_not_proved"]);
|
||||
expect(await held(r, s.from)).toHaveLength(3);
|
||||
const only = await move(r, { moveId: moveId("j"), fromProofs: [viaDiscord.token], toProof: s.toProof.token, identityIds: [discord!.id] });
|
||||
expect([only.statusCode, only.json().movedIdentityIds]).toEqual([200, [discord!.id]]);
|
||||
expect((await held(r, s.from)).sort()).toEqual([google!.id, s.from.rows[2]!.id].sort());
|
||||
});
|
||||
|
||||
it("one proof per login moves them all; an asserted passkey covers the whole account; an email code covers only its own login", async () => {
|
||||
const r = await rig();
|
||||
let s = await multi(r);
|
||||
const perLogin = await Promise.all(s.from.rows.map((_, via) => proof(r, s.from, { via })));
|
||||
const res = await move(r, { moveId: moveId("k"), fromProofs: perLogin.map((p) => p.token), toProof: s.toProof.token });
|
||||
expect([res.statusCode, res.json().movedIdentityIds]).toHaveLength(2);
|
||||
expect(res.json().proof.from).toBe("weak");
|
||||
expect(await held(r, s.from)).toEqual([]);
|
||||
|
||||
s = await multi(r);
|
||||
const byPasskey = await proof(r, s.from, { stepUp: passkey });
|
||||
expect((await move(r, { moveId: moveId("l"), fromProofs: [byPasskey.token], toProof: s.toProof.token })).statusCode).toBe(200);
|
||||
|
||||
s = await multi(r);
|
||||
const byEmail = await proof(r, s.from, { stepUp: { method: "bifrost-email-code" } });
|
||||
const refused = await move(r, { moveId: moveId("m"), fromProofs: [byEmail.token], toProof: s.toProof.token });
|
||||
expect(err(refused)).toEqual([403, "identity_not_proved"]);
|
||||
expect(await held(r, s.from)).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("a provider held on both sides is 409 provider_conflict naming only the provider, and nothing moves", async () => {
|
||||
const r = await rig();
|
||||
const from = await acct(r, [["discord"], ["google"]]);
|
||||
const to = await acct(r, [["discord"]]);
|
||||
const res = await move(r, { moveId: moveId("n"), fromProofs: [(await proof(r, from, { stepUp: passkey })).token], toProof: (await proof(r, to)).token });
|
||||
expect([res.statusCode, res.json()]).toEqual([409, { error: "provider_conflict", provider: "discord" }]);
|
||||
expect(res.body).not.toContain(from.account.id);
|
||||
expect(await held(r, from)).toHaveLength(2);
|
||||
expect(await held(r, to)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("naming an identity the source does not hold is 404 identity_not_found", async () => {
|
||||
const r = await rig();
|
||||
const s = await scene(r);
|
||||
const res = await move(r, { moveId: moveId("o"), fromProofs: [s.fromProof.token], toProof: s.toProof.token, identityIds: [s.to.rows[0]!.id] });
|
||||
expect(err(res)).toEqual([404, "identity_not_found"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("what a move leaves behind", () => {
|
||||
it("strips the moved credentials, expires the source's sessions, pending links and unredeemed completions, and leaves the destination live", async () => {
|
||||
const r = await rig();
|
||||
const s = await scene(r);
|
||||
const linkInput = { appSlug: "bifrost" as const, provider: "discord" as const, providerUserId: "d-x", identity: { accountId: "x", provider: "discord" as const, providerUserId: "d-x", scopes: [], profileJson: {}, createdAt: now, updatedAt: now }, returnTo: "https://bifrost.gamecult.org/x", createdAt: now, expiresAt: "2099-01-01T00:00:00.000Z" };
|
||||
const srcLink = await r.store.createPendingLink({ ...linkInput, accountId: s.from.account.id });
|
||||
const dstLink = await r.store.createPendingLink({ ...linkInput, accountId: s.to.account.id });
|
||||
const completion = (accountId: string) =>
|
||||
r.store.createAuthCompletion({ appSlug: "bifrost", provider: "discord", mode: "sign_in", accountId, sessionId: "sess", returnTo: "https://bifrost.gamecult.org/x", createdAt: now, expiresAt: "2099-01-01T00:00:00.000Z", payloadJson: {} });
|
||||
const srcCompletion = await completion(s.from.account.id);
|
||||
const dstCompletion = await completion(s.to.account.id);
|
||||
const res = await move(r, { moveId: moveId("p"), fromProofs: [s.fromProof.token], toProof: s.toProof.token });
|
||||
expect(res.statusCode).toBe(200);
|
||||
const movedIds = new Set(s.from.rows.map((row) => row.id));
|
||||
for (const row of (await r.store.listStoredLinkedIdentitiesForAccount(s.to.account.id)).filter((row) => movedIds.has(row.id))) {
|
||||
expect(row.accessTokenEncrypted).toBeUndefined();
|
||||
expect(row.refreshTokenEncrypted).toBeUndefined();
|
||||
}
|
||||
const record = await r.store.findIdentityMove(moveId("p"));
|
||||
expect(record?.moved.map((m) => m.credentialStripped)).toEqual([true, true]);
|
||||
expect((await r.store.findSession("bifrost", s.fromProof.sid))!.accessRevision).toBe(2);
|
||||
expect((await r.store.findSession("bifrost", s.toProof.sid))!.accessRevision).toBe(1);
|
||||
expect(Date.parse((await r.store.findSession("bifrost", s.toProof.sid))!.expiresAt)).toBeGreaterThan(Date.now());
|
||||
expect(await r.store.consumePendingLink("bifrost", srcLink.id, s.from.account.id, new Date().toISOString())).toBeNull();
|
||||
expect(await r.store.consumePendingLink("bifrost", dstLink.id, s.to.account.id, new Date().toISOString())).not.toBeNull();
|
||||
expect(await r.store.consumeAuthCompletion("bifrost", srcCompletion.code, new Date().toISOString())).toBeNull();
|
||||
expect(await r.store.consumeAuthCompletion("bifrost", dstCompletion.code, new Date().toISOString())).not.toBeNull();
|
||||
});
|
||||
|
||||
it("revokes the source's live grants for the providers it no longer holds, leaves the destination's receipts, and a replay heals a crash after commit", async () => {
|
||||
const r = await rig();
|
||||
const s = await scene(r);
|
||||
const grant = (accountId: string, provider: Provider) =>
|
||||
r.store.createConsentReceipt({ kind: "grant", accountId, appSlug: "bifrost", provider, createdAt: now, scopesAsked: ["identify"], scopesGranted: ["identify"], consentPageDigest: "d", consentText: "t" });
|
||||
await grant(s.from.account.id, "discord");
|
||||
await grant(s.to.account.id, "patreon");
|
||||
const receiptsOf = async (a: Acct) => (await r.store.listConsentReceipts(a.account.id)).map((x) => `${x.kind}:${x.provider}`);
|
||||
const body = { moveId: moveId("q"), fromProofs: [s.fromProof.token], toProof: s.toProof.token };
|
||||
vi.spyOn(r.store, "createConsentReceipt").mockRejectedValueOnce(new Error("crash after commit"));
|
||||
expect((await move(r, body)).statusCode).toBe(500);
|
||||
expect(await held(r, s.from), "the move itself committed").toEqual([]);
|
||||
expect(await receiptsOf(s.from)).toEqual(["grant:discord"]);
|
||||
const healed = await move(r, body);
|
||||
expect(healed.statusCode).toBe(200);
|
||||
expect(await receiptsOf(s.from)).toEqual(["grant:discord", "revocation:discord"]);
|
||||
expect(await receiptsOf(s.to)).toEqual(["grant:patreon"]);
|
||||
expect((await move(r, body)).body, "and it is idempotent").toBe(healed.body);
|
||||
expect(await receiptsOf(s.from)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("the destination never loses an identity, whatever is refused", async () => {
|
||||
const r = await rig();
|
||||
const s = await scene(r);
|
||||
const before = (await held(r, s.to)).length;
|
||||
const refusals = [
|
||||
{ moveId: moveId("r1"), fromProofs: [s.fromProof.token], toProof: s.toProof.token, identityIds: ["nope"] },
|
||||
{ moveId: moveId("r2"), fromProofs: [s.fromProof.token], toProof: s.fromProof.token },
|
||||
{ moveId: moveId("r3"), fromProofs: [s.toProof.token], toProof: s.toProof.token },
|
||||
];
|
||||
for (const body of refusals) {
|
||||
await move(r, body);
|
||||
expect((await held(r, s.to)).length).toBeGreaterThanOrEqual(before);
|
||||
}
|
||||
expect((await move(r, { moveId: moveId("r4"), fromProofs: [s.fromProof.token], toProof: s.toProof.token })).statusCode).toBe(200);
|
||||
expect((await held(r, s.to)).length).toBeGreaterThanOrEqual(before);
|
||||
});
|
||||
|
||||
it("a sign-in through a moved login lands on the destination, with no alias lookup", async () => {
|
||||
const r = await rig("t-moved");
|
||||
const from = await acct(r, [["twitch", "t-moved"]]);
|
||||
const to = await acct(r, [["patreon"]]);
|
||||
const mv = await move(r, { moveId: moveId("s"), fromProofs: [(await proof(r, from, { stepUp: passkey })).token], toProof: (await proof(r, to)).token });
|
||||
expect(mv.statusCode).toBe(200);
|
||||
const started = await r.app.inject({ method: "POST", url: "/v1/oauth/twitch/start", payload: { appSlug: "streampixels", mode: "sign_in", returnTo: "https://streampixels.gamecult.org/signin/return" } });
|
||||
const flow = await launchFlow(r.app, started.json());
|
||||
const called = await r.app.inject({ method: "GET", url: `/v1/oauth/twitch/callback?code=c&state=${encodeURIComponent(flow.state)}`, headers: { cookie: flow.cookie, accept: "application/json" } });
|
||||
expect(called.statusCode).toBe(201);
|
||||
expect(called.json().account.id).toBe(to.account.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe("aliases and reversal", () => {
|
||||
it("an emptied source is an alias; moving into or out of it is 409 already_moved", async () => {
|
||||
const r = await rig();
|
||||
const s = await scene(r);
|
||||
expect((await move(r, { moveId: moveId("t"), fromProofs: [s.fromProof.token], toProof: s.toProof.token })).statusCode).toBe(200);
|
||||
// A session can outlive its logins only by being minted for an account that holds none, which is how a stale caller would look.
|
||||
const aliasProof = await proof(r, { account: s.from.account, rows: [s.to.rows[0]!] }, { stepUp: passkey });
|
||||
const third = await acct(r, [["github"]]);
|
||||
const out = await move(r, { moveId: moveId("u"), fromProofs: [aliasProof.token], toProof: (await proof(r, third)).token });
|
||||
expect(err(out)).toEqual([409, "already_moved"]);
|
||||
const aliasTo = await proof(r, { account: s.from.account, rows: [s.to.rows[0]!] });
|
||||
const into = await move(r, { moveId: moveId("u2"), fromProofs: [(await proof(r, third, { stepUp: passkey })).token], toProof: aliasTo.token });
|
||||
expect(err(into)).toEqual([409, "already_moved"]);
|
||||
expect(await held(r, third)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("a reversal through a moved login moves exactly those logins back; a second reversal is not_reversible; credentials stay stripped", async () => {
|
||||
const r = await rig();
|
||||
const s = await scene(r);
|
||||
const sourceIds = await held(r, s.from);
|
||||
await move(r, { moveId: moveId("v"), fromProofs: [s.fromProof.token], toProof: s.toProof.token });
|
||||
const [discord, google] = s.from.rows;
|
||||
// The destination now holds the moved logins: a sign-in through one of them is a proof of the destination.
|
||||
const viaMoved = await proof(r, { account: s.to.account, rows: [discord!, google!] }, { via: 0 });
|
||||
const own = await proof(r, s.to, { via: 0 });
|
||||
const denied = await move(r, { moveId: moveId("w"), fromProofs: [own.token], reversesMoveId: moveId("v") });
|
||||
expect(err(denied), "the destination's own login does not speak for the moved ones").toEqual([403, "identity_not_proved"]);
|
||||
const viaBoth = [viaMoved, await proof(r, { account: s.to.account, rows: [discord!, google!] }, { via: 1 })];
|
||||
const back = await move(r, { moveId: moveId("w"), fromProofs: viaBoth.map((p) => p.token), reversesMoveId: moveId("v") });
|
||||
expect(back.statusCode, back.body).toBe(200);
|
||||
expect(back.json()).toEqual({ moveId: moveId("w"), accountId: s.from.account.id, movedIdentityIds: expect.arrayContaining(sourceIds), proof: { from: "weak", to: null }, reversesMoveId: moveId("v") });
|
||||
expect(await held(r, s.from)).toEqual(sourceIds);
|
||||
expect(await held(r, s.to)).toHaveLength(1);
|
||||
expect((await r.store.listStoredLinkedIdentitiesForAccount(s.from.account.id)).every((i) => i.accessTokenEncrypted === undefined)).toBe(true);
|
||||
const own2 = await proof(r, s.to, { via: 0 });
|
||||
const again = await move(r, { moveId: moveId("w2"), fromProofs: [own2.token], reversesMoveId: moveId("v") });
|
||||
expect(err(again)).toEqual([409, "not_reversible"]);
|
||||
const reverseTheReversal = await move(r, { moveId: moveId("w3"), fromProofs: [(await proof(r, s.from)).token], reversesMoveId: moveId("w") });
|
||||
expect(err(reverseTheReversal)).toEqual([409, "not_reversible"]);
|
||||
const unknown = await move(r, { moveId: moveId("w4"), fromProofs: [(await proof(r, s.from)).token], reversesMoveId: moveId("nothere") });
|
||||
expect(err(unknown)).toEqual([409, "not_reversible"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("proofTier", () => {
|
||||
it("a proof dated in the future proves nothing, whichever claim carries it", () => {
|
||||
const n = nowS();
|
||||
expect(proofTier({ auth_at: n + 5 }, n)).toBeNull();
|
||||
expect(proofTier({ step_up_at: n + 5 }, n)).toBeNull();
|
||||
expect(proofTier({ step_up_at: n + 5, auth_at: n }, n), "a future step-up does not hide a valid sign-in").toBe("weak");
|
||||
expect(proofTier({ step_up_at: n, auth_at: n - 9_999 }, n)).toBe("strong");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the reversal's source must be the forward move's destination", () => {
|
||||
it("is refused as not_reversible before the proofs are judged, even for a stale proof", async () => {
|
||||
const r = await rig();
|
||||
const s = await scene(r);
|
||||
expect((await move(r, { moveId: moveId("fw"), fromProofs: [s.fromProof.token], toProof: s.toProof.token })).statusCode).toBe(200);
|
||||
const third = await acct(r, [["github"]]);
|
||||
const onward = await proof(r, s.to, { stepUp: passkey });
|
||||
expect((await move(r, { moveId: moveId("on"), fromProofs: [onward.token], toProof: (await proof(r, third)).token })).statusCode).toBe(200);
|
||||
const stale = await proof(r, third, { signedInAgo: 9_999 });
|
||||
const res = await move(r, { moveId: moveId("rv"), fromProofs: [stale.token], reversesMoveId: moveId("fw") });
|
||||
expect(err(res)).toEqual([409, "not_reversible"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("idempotency is by moveId", () => {
|
||||
it("a replayed reversal under another reversesMoveId is 409 move_id_conflict, not the stored receipt", async () => {
|
||||
const r = await rig();
|
||||
const s = await scene(r);
|
||||
await move(r, { moveId: moveId("fw2"), fromProofs: [s.fromProof.token], toProof: s.toProof.token });
|
||||
const [discord, google] = s.from.rows;
|
||||
const viaMoved = async (via: number) => (await proof(r, { account: s.to.account, rows: [discord!, google!] }, { via })).token;
|
||||
const body = { moveId: moveId("rv2"), fromProofs: [await viaMoved(0), await viaMoved(1)], reversesMoveId: moveId("fw2") };
|
||||
const back = await move(r, body);
|
||||
expect(back.statusCode, back.body).toBe(200);
|
||||
expect((await move(r, body)).body, "the same body replays").toBe(back.body);
|
||||
expect(err(await move(r, { ...body, reversesMoveId: moveId("other") }))).toEqual([409, "move_id_conflict"]);
|
||||
expect(await held(r, s.from)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("a replay with the same moveId and different identityIds returns the first receipt (steward ruling: the key is the moveId, a retry carries the same body)", async () => {
|
||||
const r = await rig();
|
||||
const from = await acct(r, [["discord"], ["google"]]);
|
||||
const to = await acct(r, [["patreon"]]);
|
||||
const fromProof = await proof(r, from, { stepUp: passkey });
|
||||
const toProof = await proof(r, to);
|
||||
const first = await move(r, { moveId: moveId("ids"), fromProofs: [fromProof.token], toProof: toProof.token, identityIds: [from.rows[0]!.id] });
|
||||
expect(first.json().movedIdentityIds).toEqual([from.rows[0]!.id]);
|
||||
const again = await move(r, { moveId: moveId("ids"), fromProofs: [fromProof.token], toProof: toProof.token, identityIds: [from.rows[1]!.id] });
|
||||
expect([again.statusCode, again.body]).toEqual([200, first.body]);
|
||||
expect(await held(r, from), "and the second selection did not move").toEqual([from.rows[1]!.id]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("what a refusal says", () => {
|
||||
it("no refusal body names an account id, an email, a name or an identity the caller did not send", async () => {
|
||||
const r = await rig();
|
||||
const s = await scene(r);
|
||||
const stranger = await acct(r, [["discord", "d-stranger"]]);
|
||||
const bodies = [
|
||||
await move(r, { moveId: moveId("x1"), fromProofs: [s.fromProof.token], toProof: "garbage" }),
|
||||
await move(r, { moveId: moveId("x2"), fromProofs: [(await proof(r, s.from, { via: 0 })).token], toProof: s.toProof.token }),
|
||||
await move(r, { moveId: moveId("x3"), fromProofs: [s.fromProof.token], toProof: s.toProof.token, identityIds: [stranger.rows[0]!.id] }),
|
||||
await move(r, { moveId: moveId("x4"), fromProofs: [s.toProof.token], toProof: s.toProof.token }),
|
||||
await move(r, { moveId: moveId("x5"), fromProofs: [s.fromProof.token], reversesMoveId: moveId("nothere") }),
|
||||
await move(r, { moveId: moveId("x6"), fromProofs: [(await proof(r, s.from, { signedInAgo: 9_999 })).token], toProof: s.toProof.token }),
|
||||
];
|
||||
const ids = [s.from, s.to, stranger].flatMap((a) => [a.account.id, ...a.rows.map((row) => row.id), ...a.rows.map((row) => row.providerUserId)]);
|
||||
for (const res of bodies) {
|
||||
expect(res.statusCode).toBeGreaterThanOrEqual(400);
|
||||
for (const id of ids) expect(res.body).not.toContain(id);
|
||||
expect(res.body).not.toMatch(/canary|DISPLAY/);
|
||||
expect(Object.keys(res.json()).every((k) => ["error", "detail", "proof", "provider"].includes(k))).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("claims: which sign-in a token came from", () => {
|
||||
const returnTo = "https://streampixels.gamecult.org/signin/return";
|
||||
async function twitchSignIn(r: Rig) {
|
||||
const started = await r.app.inject({ method: "POST", url: "/v1/oauth/twitch/start", payload: { appSlug: "streampixels", mode: "sign_in", returnTo } });
|
||||
const flow = await launchFlow(r.app, started.json());
|
||||
const called = await r.app.inject({ method: "GET", url: `/v1/oauth/twitch/callback?code=c&state=${encodeURIComponent(flow.state)}`, headers: { cookie: flow.cookie, accept: "application/json" } });
|
||||
expect(called.statusCode).toBe(201);
|
||||
const redeemed = await r.app.inject({ method: "POST", url: "/v1/apps/streampixels/auth-completions/redeem", payload: { completionCode: called.json().completion.code } });
|
||||
expect(redeemed.statusCode).toBe(201);
|
||||
return redeemed.json() as { refreshToken: string; claimSet: Record<string, unknown> };
|
||||
}
|
||||
|
||||
it("a sign_in stamps auth_at and the login it came through; a refresh does not renew them", async () => {
|
||||
const r = await rig("t-claims");
|
||||
const before = nowS();
|
||||
const signed = await twitchSignIn(r);
|
||||
const row = await r.store.findStoredLinkedIdentity("twitch", "t-claims");
|
||||
expect(signed.claimSet.auth_identity_id).toBe(row!.id);
|
||||
expect(signed.claimSet.auth_at).toBeGreaterThanOrEqual(before);
|
||||
expect(signed.claimSet.auth_at).toBeLessThanOrEqual(nowS());
|
||||
expect(signed.claimSet.step_up_at).toBeUndefined();
|
||||
const refreshed = await r.app.inject({ method: "POST", url: "/v1/apps/streampixels/sessions/refresh", payload: { refreshToken: signed.refreshToken } });
|
||||
expect(refreshed.statusCode).toBe(201);
|
||||
expect(refreshed.json().claimSet.auth_at).toBeUndefined();
|
||||
expect(refreshed.json().claimSet.auth_identity_id).toBeUndefined();
|
||||
});
|
||||
|
||||
it("a link callback mints no sign-in proof: no auth_at, no auth_identity_id", async () => {
|
||||
const r = await rig();
|
||||
const holder = await acct(r, [["twitch"]]);
|
||||
const started = await r.app.inject({ method: "POST", url: "/v1/oauth/discord/start", headers: appAuth, payload: { appSlug: "bifrost", mode: "link", initiatingAccountId: holder.account.id, returnTo: "https://bifrost.gamecult.org/x" } });
|
||||
expect(started.statusCode).toBe(201);
|
||||
const flow = await launchFlow(r.app, started.json());
|
||||
const called = await r.app.inject({ method: "GET", url: `/v1/oauth/discord/callback?code=c&state=${encodeURIComponent(flow.state)}`, headers: { cookie: flow.cookie, accept: "application/json" } });
|
||||
expect(called.statusCode).toBe(201);
|
||||
expect(called.json().claimSet.identities).toHaveLength(2);
|
||||
expect(called.json().claimSet.auth_at).toBeUndefined();
|
||||
expect(called.json().claimSet.auth_identity_id).toBeUndefined();
|
||||
expect(called.body).not.toMatch(/auth_at|auth_identity_id/);
|
||||
});
|
||||
|
||||
it("a connect callback mints no sign-in proof either", async () => {
|
||||
const r = await rig("t-connect");
|
||||
const started = await r.app.inject({ method: "POST", url: "/v1/oauth/twitch/start", headers: { "x-heimdall-app-secret": "streampixels-secret" }, payload: { appSlug: "streampixels", mode: "connect", returnTo: "https://streampixels.gamecult.org/signin/return", connection: { kind: "creator", targetId: "creator-1" } } });
|
||||
expect(started.statusCode, started.body).toBe(201);
|
||||
const flow = await launchFlow(r.app, started.json());
|
||||
const called = await r.app.inject({ method: "GET", url: `/v1/oauth/twitch/callback?code=c&state=${encodeURIComponent(flow.state)}`, headers: { cookie: flow.cookie, accept: "application/json" } });
|
||||
expect(called.statusCode, called.body).toBe(201);
|
||||
expect(called.body).toContain("claimSet");
|
||||
expect(called.body).not.toMatch(/auth_at|auth_identity_id/);
|
||||
});
|
||||
|
||||
it("an asserted step-up carries the bearer's auth claims through unchanged", async () => {
|
||||
const r = await rig();
|
||||
const a = await acct(r, [["discord"], ["google"]]);
|
||||
const p = await proof(r, a, { via: 1, signedInAgo: 30 });
|
||||
const res = await r.app.inject({ method: "POST", url: "/v1/apps/bifrost/step-up", headers: { ...appAuth, authorization: `Bearer ${p.token}` }, payload: { method: "bifrost-passkey", nonce: "n".repeat(32) } });
|
||||
expect(res.statusCode).toBe(201);
|
||||
expect(res.json().claimSet).toMatchObject({ auth_identity_id: a.rows[1]!.id, auth_at: nowS() - 30, step_up_method: "bifrost-passkey" });
|
||||
});
|
||||
});
|
||||
|
|
@ -1,334 +0,0 @@
|
|||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterAll, afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { buildApp } from "../src/app.js";
|
||||
import { issueAccessClaim } from "../src/claims.js";
|
||||
import { type Provider } from "../src/contracts.js";
|
||||
import { type OAuthProviderRuntime } from "../src/oauth.js";
|
||||
import { createRuntimeKeyMaterial, decodeJwt, signJwt, verifyJwt } from "../src/signing.js";
|
||||
import { IdentityHeldElsewhereError, InMemoryStore } from "../src/store/index.js";
|
||||
import { createTestConfig, launchFlow, launchState, stateOf } from "./oauth-flow.js";
|
||||
|
||||
// The member's identity surface: list what an account holds, and take one
|
||||
// identity off it. Taking one off is the account's own decision made with
|
||||
// fresh proof (a step-up sign-in inside the proof window), never the
|
||||
// bearer's alone. These tests drive both routes over HTTP as an app would.
|
||||
|
||||
const returnTo = "https://streampixels.gamecult.org/signin/return";
|
||||
const spAuth = { "x-heimdall-app-secret": "streampixels-secret" };
|
||||
const now = "2026-11-13T12:00:00.000Z";
|
||||
|
||||
function runtime(provider: "twitch" | "youtube", providerUserId: string): OAuthProviderRuntime {
|
||||
return {
|
||||
async exchangeAuthorizationCode() {
|
||||
return { accessToken: "at", refreshToken: "rt", tokenType: "Bearer", scope: ["user:read:email"], raw: {} };
|
||||
},
|
||||
async resolveIdentity() {
|
||||
return { provider, providerUserId, username: providerUserId, profile: { id: providerUserId } };
|
||||
},
|
||||
async evaluateEntitlements() {
|
||||
return { facts: [], snapshots: [] };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const dir = mkdtempSync(join(tmpdir(), "heimdall-unlink-"));
|
||||
const config = { ...createTestConfig(), signingPrivateKeyPath: join(dir, "signing.pem"), bootstrapSigningPrivateKeyOnMissing: true };
|
||||
afterAll(() => rmSync(dir, { recursive: true, force: true }));
|
||||
|
||||
const apps: Awaited<ReturnType<typeof buildApp>>[] = [];
|
||||
afterEach(async () => {
|
||||
vi.useRealTimers();
|
||||
await Promise.all(apps.splice(0).map((app) => app.close()));
|
||||
});
|
||||
|
||||
async function rig() {
|
||||
const store = new InMemoryStore();
|
||||
const app = await buildApp({
|
||||
config,
|
||||
store,
|
||||
oauthRuntimes: { twitch: runtime("twitch", "t-1"), youtube: runtime("youtube", "y-1") },
|
||||
});
|
||||
apps.push(app);
|
||||
const keys = createRuntimeKeyMaterial({ ...config, bootstrapSigningPrivateKeyOnMissing: false });
|
||||
return { app, store, keys };
|
||||
}
|
||||
type Rig = Awaited<ReturnType<typeof rig>>;
|
||||
|
||||
async function seedAccount(r: Rig, held: Array<[Provider, string]>) {
|
||||
const account = await r.store.createAccount({ createdAt: now, lastSeenAt: now });
|
||||
const rows = [];
|
||||
for (const [provider, providerUserId] of held) {
|
||||
rows.push(
|
||||
await r.store.upsertLinkedIdentity({
|
||||
accountId: account.id,
|
||||
provider,
|
||||
providerUserId,
|
||||
username: `${provider}-name`,
|
||||
displayName: `${provider} display`,
|
||||
primaryEmail: "secret@example.test",
|
||||
accessTokenEncrypted: "ACCESS-CIPHERTEXT",
|
||||
refreshTokenEncrypted: "REFRESH-CIPHERTEXT",
|
||||
scopes: ["identify"],
|
||||
profileJson: { raw: "PROFILE-CANARY" },
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
);
|
||||
}
|
||||
return { account, rows };
|
||||
}
|
||||
|
||||
/** A real sign_in through twitch, as StreamPixels runs it, redeemed for the tokens. */
|
||||
async function signIn(r: Rig, stepUp: boolean) {
|
||||
const started = await r.app.inject({
|
||||
method: "POST",
|
||||
url: "/v1/oauth/twitch/start",
|
||||
headers: stepUp ? spAuth : {},
|
||||
payload: { appSlug: "streampixels", mode: "sign_in", returnTo, ...(stepUp ? { stepUp: true } : {}) },
|
||||
});
|
||||
expect(started.statusCode).toBe(201);
|
||||
const flow = await launchFlow(r.app, started.json());
|
||||
const called = await r.app.inject({
|
||||
method: "GET",
|
||||
url: `/v1/oauth/twitch/callback?code=c&state=${encodeURIComponent(flow.state)}`,
|
||||
headers: { cookie: flow.cookie, accept: "application/json" },
|
||||
});
|
||||
expect(called.statusCode).toBe(201);
|
||||
const redeemed = await r.app.inject({
|
||||
method: "POST",
|
||||
url: "/v1/apps/streampixels/auth-completions/redeem",
|
||||
payload: { completionCode: called.json().completion.code },
|
||||
});
|
||||
expect(redeemed.statusCode).toBe(201);
|
||||
return redeemed.json() as { accessToken: string; refreshToken: string; claimSet: Record<string, unknown> };
|
||||
}
|
||||
|
||||
const bearer = (token: string) => ({ authorization: `Bearer ${token}` });
|
||||
const list = (r: Rig, token?: string) => r.app.inject({ method: "GET", url: "/v1/me/identities", headers: token ? bearer(token) : {} });
|
||||
const unlink = (r: Rig, id: string, token?: string, payload?: unknown) =>
|
||||
r.app.inject({ method: "POST", url: `/v1/me/identities/${encodeURIComponent(id)}/unlink`, headers: token ? bearer(token) : {}, ...(payload !== undefined ? { payload: payload as object } : {}) });
|
||||
|
||||
describe("step_up_at on the access claim", () => {
|
||||
it("a step-up sign-in's claim carries it; an ordinary sign-in and a refresh do not", async () => {
|
||||
const r = await rig();
|
||||
await seedAccount(r, [["twitch", "t-1"], ["youtube", "y-1"]]);
|
||||
const before = Math.floor(Date.now() / 1000);
|
||||
const stepped = await signIn(r, true);
|
||||
expect(stepped.claimSet.step_up_at).toBeGreaterThanOrEqual(before);
|
||||
expect(stepped.claimSet.step_up_at).toBeLessThanOrEqual(Math.floor(Date.now() / 1000));
|
||||
expect(stepped.claimSet.step_up_method).toBe("provider:twitch");
|
||||
const plain = (await signIn(r, false)).claimSet;
|
||||
expect(plain.step_up_at).toBeUndefined();
|
||||
expect(plain.step_up_method).toBeUndefined();
|
||||
|
||||
const refreshed = await r.app.inject({ method: "POST", url: "/v1/apps/streampixels/sessions/refresh", payload: { refreshToken: stepped.refreshToken } });
|
||||
expect(refreshed.statusCode).toBe(201);
|
||||
expect(refreshed.json().claimSet.step_up_at, "a refresh never renews proof").toBeUndefined();
|
||||
expect(refreshed.json().claimSet.step_up_method).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /v1/me/identities", () => {
|
||||
it("is 401 sign_in_required without a live bearer", async () => {
|
||||
const r = await rig();
|
||||
for (const token of [undefined, "not-a-token"]) {
|
||||
const res = await list(r, token);
|
||||
expect([res.statusCode, res.json().error]).toEqual([401, "sign_in_required"]);
|
||||
}
|
||||
});
|
||||
|
||||
it("lists the caller's identities and nothing that is a credential, a profile or an email", async () => {
|
||||
const r = await rig();
|
||||
const { account, rows } = await seedAccount(r, [["twitch", "t-1"], ["discord", "d-9"]]);
|
||||
await seedAccount(r, [["youtube", "y-other"]]);
|
||||
const { accessToken } = await signIn(r, false);
|
||||
const res = await list(r, accessToken);
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.headers["cache-control"]).toBe("no-store");
|
||||
const body = res.json();
|
||||
expect(body.accountId).toBe(account.id);
|
||||
expect(body.identities).toEqual([
|
||||
// The sign-in above refreshed the twitch row from what the provider returned.
|
||||
{ id: rows[0]!.id, provider: "twitch", providerUserId: "t-1", username: "t-1", linkedAt: now, scopes: ["user:read:email"] },
|
||||
{ id: rows[1]!.id, provider: "discord", providerUserId: "d-9", username: "discord-name", displayName: "discord display", linkedAt: now, scopes: ["identify"] },
|
||||
]);
|
||||
expect(res.body).not.toMatch(/CIPHERTEXT|PROFILE-CANARY|secret@example|primary_email|primaryEmail/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /v1/me/identities/:identityId/unlink", () => {
|
||||
it("is 401 without a live bearer", async () => {
|
||||
const r = await rig();
|
||||
const { rows } = await seedAccount(r, [["twitch", "t-1"], ["youtube", "y-1"]]);
|
||||
expect((await unlink(r, rows[1]!.id)).statusCode).toBe(401);
|
||||
expect((await unlink(r, rows[1]!.id, "garbage")).statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it("is 403 step_up_required for a bearer with no proof, and for a refresh-issued one, and removes nothing", async () => {
|
||||
const r = await rig();
|
||||
const { account, rows } = await seedAccount(r, [["twitch", "t-1"], ["youtube", "y-1"]]);
|
||||
const plain = await signIn(r, false);
|
||||
const denied = await unlink(r, rows[1]!.id, plain.accessToken);
|
||||
expect([denied.statusCode, denied.json().error]).toEqual([403, "step_up_required"]);
|
||||
expect(denied.json().detail).toMatch(/twitch, youtube and google/);
|
||||
|
||||
const stepped = await signIn(r, true);
|
||||
const refreshed = await r.app.inject({ method: "POST", url: "/v1/apps/streampixels/sessions/refresh", payload: { refreshToken: stepped.refreshToken } });
|
||||
const viaRefresh = await unlink(r, rows[1]!.id, refreshed.json().accessToken);
|
||||
expect([viaRefresh.statusCode, viaRefresh.json().error]).toEqual([403, "step_up_required"]);
|
||||
expect(await r.store.listStoredLinkedIdentitiesForAccount(account.id)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("is 403 for proof older than the window and for proof dated in the future; 599 s old still passes", async () => {
|
||||
const r = await rig();
|
||||
const { account, rows } = await seedAccount(r, [["twitch", "t-1"], ["youtube", "y-1"], ["discord", "d-1"]]);
|
||||
const issueWith = async (stepUpAt: number) =>
|
||||
(await issueAccessClaim({
|
||||
config,
|
||||
keys: r.keys,
|
||||
store: r.store,
|
||||
input: { appSlug: "streampixels", accountId: account.id, linkedIdentities: [], facts: [], stepUpAt },
|
||||
})).accessToken;
|
||||
const nowS = Math.floor(Date.now() / 1000);
|
||||
for (const at of [nowS - 601, nowS + 120]) {
|
||||
const res = await unlink(r, rows[1]!.id, await issueWith(at));
|
||||
expect([res.statusCode, res.json().error], String(at - nowS)).toEqual([403, "step_up_required"]);
|
||||
}
|
||||
expect(await r.store.listStoredLinkedIdentitiesForAccount(account.id)).toHaveLength(3);
|
||||
const edge = await unlink(r, rows[1]!.id, await issueWith(nowS - 599));
|
||||
expect(edge.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it("a step-up sign-in's bearer unlinks: 200 with the remaining list, an audit event without credentials", async () => {
|
||||
const r = await rig();
|
||||
const { account, rows } = await seedAccount(r, [["twitch", "t-1"], ["discord", "d-9"]]);
|
||||
const audit = vi.spyOn(r.store, "createAuditEvent");
|
||||
const { accessToken } = await signIn(r, true);
|
||||
const res = await unlink(r, rows[1]!.id, accessToken, null);
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.headers["cache-control"]).toBe("no-store");
|
||||
expect(res.json().unlinked).toEqual({ id: rows[1]!.id, provider: "discord", providerUserId: "d-9" });
|
||||
expect(res.json().identities.map((i: { id: string }) => i.id)).toEqual([rows[0]!.id]);
|
||||
expect(res.body).not.toMatch(/CIPHERTEXT|PROFILE-CANARY|secret@example/);
|
||||
expect(await r.store.listStoredLinkedIdentitiesForAccount(account.id)).toHaveLength(1);
|
||||
const event = audit.mock.calls.map(([e]) => e).find((e) => e.eventType === "identity_unlinked");
|
||||
expect(event?.accountId).toBe(account.id);
|
||||
expect(event?.eventPayloadJson).toEqual({ provider: "discord", identityId: rows[1]!.id, stepUpMethod: "provider:twitch" });
|
||||
});
|
||||
|
||||
it("the account's only identity is 409 last_identity and the row stays", async () => {
|
||||
const r = await rig();
|
||||
const { account, rows } = await seedAccount(r, [["twitch", "t-1"]]);
|
||||
const { accessToken } = await signIn(r, true);
|
||||
const res = await unlink(r, rows[0]!.id, accessToken);
|
||||
expect([res.statusCode, res.json().error]).toEqual([409, "last_identity"]);
|
||||
expect(await r.store.listStoredLinkedIdentitiesForAccount(account.id)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("another account's identity and a nonexistent one are the same 404", async () => {
|
||||
const r = await rig();
|
||||
await seedAccount(r, [["twitch", "t-1"], ["discord", "d-9"]]);
|
||||
const other = await seedAccount(r, [["youtube", "y-other"], ["github", "g-other"]]);
|
||||
const { accessToken } = await signIn(r, true);
|
||||
const foreign = await unlink(r, other.rows[0]!.id, accessToken);
|
||||
const missing = await unlink(r, "no-such-id", accessToken);
|
||||
expect([foreign.statusCode, foreign.json().error]).toEqual([404, "identity_not_found"]);
|
||||
expect([missing.statusCode, missing.body]).toEqual([404, foreign.body]);
|
||||
expect(await r.store.listStoredLinkedIdentitiesForAccount(other.account.id)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("an unlinked identity belongs to no one, and another account can then link it, but not before", async () => {
|
||||
const r = await rig();
|
||||
const mine = await seedAccount(r, [["twitch", "t-1"], ["discord", "d-9"]]);
|
||||
const theirs = await seedAccount(r, [["youtube", "y-other"]]);
|
||||
const input = { accountId: theirs.account.id, provider: "discord" as const, providerUserId: "d-9", scopes: [], profileJson: {}, createdAt: now, updatedAt: now };
|
||||
await expect(r.store.upsertLinkedIdentity(input)).rejects.toBeInstanceOf(IdentityHeldElsewhereError);
|
||||
const { accessToken } = await signIn(r, true);
|
||||
expect((await unlink(r, mine.rows[1]!.id, accessToken)).statusCode).toBe(200);
|
||||
await expect(r.store.upsertLinkedIdentity(input)).resolves.toMatchObject({ accountId: theirs.account.id });
|
||||
});
|
||||
|
||||
it("writes revocation receipts for the provider's live grants when its last identity goes, and not before", async () => {
|
||||
const r = await rig();
|
||||
const { account, rows } = await seedAccount(r, [["twitch", "t-1"], ["youtube", "y-1"], ["youtube", "y-2"]]);
|
||||
const grant = async (provider: Provider) =>
|
||||
(await r.store.createConsentReceipt({ kind: "grant", accountId: account.id, appSlug: "streampixels", provider, createdAt: now, scopesAsked: ["a"], scopesGranted: ["a"], consentPageDigest: "d", consentText: "t" })).receipt;
|
||||
const ytGrant = await grant("youtube");
|
||||
const twGrant = await grant("twitch");
|
||||
const { accessToken } = await signIn(r, true);
|
||||
|
||||
// A second youtube identity remains: the grant's provider still holds a credential.
|
||||
expect((await unlink(r, rows[1]!.id, accessToken)).statusCode).toBe(200);
|
||||
expect((await r.store.listConsentReceipts(account.id)).filter((x) => x.kind === "revocation")).toHaveLength(0);
|
||||
|
||||
// The last youtube identity goes (twitch stays as the sign-in identity): youtube's grant is revoked, twitch's is not.
|
||||
const audit = vi.spyOn(r.store, "createAuditEvent");
|
||||
expect((await unlink(r, rows[2]!.id, accessToken)).statusCode).toBe(200);
|
||||
const revocations = (await r.store.listConsentReceipts(account.id)).filter((x) => x.kind === "revocation");
|
||||
expect(revocations.map((x) => x.kind === "revocation" && x.revokesReceiptId)).toEqual([ytGrant.id]);
|
||||
expect(revocations.map((x) => x.provider)).toEqual(["youtube"]);
|
||||
expect(twGrant.id).not.toBe(ytGrant.id);
|
||||
expect(audit.mock.calls.map(([e]) => e.eventType)).toEqual(expect.arrayContaining(["identity_unlinked", "consent_revoked"]));
|
||||
});
|
||||
|
||||
it("proof past the window refuses even a bearer that was fresh when issued", async () => {
|
||||
const r = await rig();
|
||||
const { rows } = await seedAccount(r, [["twitch", "t-1"], ["discord", "d-9"]]);
|
||||
vi.useFakeTimers({ toFake: ["Date"] });
|
||||
const { accessToken } = await signIn(r, true);
|
||||
vi.setSystemTime(Date.now() + 601_000);
|
||||
const res = await unlink(r, rows[1]!.id, accessToken);
|
||||
expect([res.statusCode, res.json().error]).toEqual([403, "step_up_required"]);
|
||||
});
|
||||
|
||||
it("the proof window's edge: exactly 600 s old still passes, 601 s does not (frozen clock)", async () => {
|
||||
const r = await rig();
|
||||
const { account, rows } = await seedAccount(r, [["twitch", "t-1"], ["youtube", "y-1"], ["discord", "d-1"]]);
|
||||
vi.useFakeTimers({ toFake: ["Date"] });
|
||||
vi.setSystemTime(new Date("2026-11-13T12:00:00.000Z"));
|
||||
const nowS = Math.floor(Date.now() / 1000);
|
||||
const issueWith = async (stepUpAt: number) =>
|
||||
(await issueAccessClaim({ config, keys: r.keys, store: r.store, input: { appSlug: "streampixels", accountId: account.id, linkedIdentities: [], facts: [], stepUpAt } })).accessToken;
|
||||
const tooOld = await unlink(r, rows[1]!.id, await issueWith(nowS - 601));
|
||||
expect([tooOld.statusCode, tooOld.json().error]).toEqual([403, "step_up_required"]);
|
||||
const edge = await unlink(r, rows[1]!.id, await issueWith(nowS - 600));
|
||||
expect(edge.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it("a one-identity bearer naming another account's identity gets 404, not 409", async () => {
|
||||
const r = await rig();
|
||||
await seedAccount(r, [["twitch", "t-1"]]);
|
||||
const other = await seedAccount(r, [["youtube", "y-other"], ["github", "g-other"]]);
|
||||
const { accessToken } = await signIn(r, true);
|
||||
const res = await unlink(r, other.rows[0]!.id, accessToken);
|
||||
expect([res.statusCode, res.json().error]).toEqual([404, "identity_not_found"]);
|
||||
});
|
||||
|
||||
it("a step_up state in a non-sign_in mode (forged, signed) yields no step_up_at", async () => {
|
||||
const r = await rig();
|
||||
const { account } = await seedAccount(r, [["twitch", "t-1"]]);
|
||||
const started = await r.app.inject({
|
||||
method: "POST",
|
||||
url: "/v1/oauth/twitch/start",
|
||||
headers: spAuth,
|
||||
payload: { appSlug: "streampixels", mode: "link", initiatingAccountId: account.id, returnTo },
|
||||
});
|
||||
expect(started.statusCode).toBe(201);
|
||||
const verified = verifyJwt(stateOf(started.json()), r.keys.publicKey) as { valid: boolean; payload: Record<string, unknown> };
|
||||
expect(verified.valid).toBe(true);
|
||||
expect(verified.payload.mode).toBe("link");
|
||||
const flow = await launchState(r.app, signJwt({ ...verified.payload, step_up: true }, r.keys));
|
||||
const called = await r.app.inject({
|
||||
method: "GET",
|
||||
url: `/v1/oauth/twitch/callback?code=c&state=${encodeURIComponent(flow.state)}`,
|
||||
headers: { cookie: flow.cookie, accept: "application/json" },
|
||||
});
|
||||
expect(called.statusCode).toBe(201);
|
||||
expect(called.json().stepUpRequested, "the forged flag was read").toBe(true);
|
||||
const token = called.json().accessToken as string;
|
||||
expect(token, "the link completion minted an access token to inspect").toBeTruthy();
|
||||
expect(decodeJwt(token).payload.step_up_at).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
|
@ -11,20 +11,6 @@ import { providerCatalog } from "../src/providers.js";
|
|||
import { InMemoryStore } from "../src/store/index.js";
|
||||
import { createTestConfig, setCookieOf, stateOf } from "./oauth-flow.js";
|
||||
|
||||
// Records the options of every inflate the app runs, so the bomb test can pin the output cap
|
||||
// itself instead of timing a refusal. Behaviour is the real inflate.
|
||||
const inflateCalls = vi.hoisted(() => [] as Array<{ maxOutputLength: number | undefined }>);
|
||||
vi.mock("node:zlib", async (importOriginal) => {
|
||||
const real = await importOriginal<typeof import("node:zlib")>();
|
||||
return {
|
||||
...real,
|
||||
inflateRawSync: ((buf: Parameters<typeof real.inflateRawSync>[0], opts?: Parameters<typeof real.inflateRawSync>[1]) => {
|
||||
inflateCalls.push({ maxOutputLength: opts?.maxOutputLength });
|
||||
return real.inflateRawSync(buf, opts);
|
||||
}) as typeof real.inflateRawSync,
|
||||
};
|
||||
});
|
||||
|
||||
// The consent cut for managed custody (docs/joystick-consent-flow.md): the ask,
|
||||
// the typed refusal, the receipt, and the signed-in page that lists and revokes.
|
||||
|
||||
|
|
@ -234,7 +220,7 @@ describe("the ask", () => {
|
|||
const signedAsk = (ctx: ReturnType<typeof getHeimdallRuntimeContext>, jti: string, exp: number, text: Buffer) =>
|
||||
signJwt({ typ: "heimdall_consent_ask", iss: ctx.config.issuer, jti, iat: exp - 600, exp, ct: deflateRawSync(text).toString("base64url") }, ctx.keys);
|
||||
|
||||
it("is read up to 16 KB and no further: one byte over is refused, and a bomb is inflated under that cap", async () => {
|
||||
it("is read up to 16 KB and no further: one byte over is refused, and a bomb is refused quickly", async () => {
|
||||
const { app } = await newApp();
|
||||
const ctx = getHeimdallRuntimeContext(app);
|
||||
const state = await startConnect(app);
|
||||
|
|
@ -242,12 +228,9 @@ describe("the ask", () => {
|
|||
const answerWith = (text: Buffer, decision: "decline" | "continue" = "decline") =>
|
||||
answer(app, state, decision, `__Host-heimdall-ask-${jti}=${signedAsk(ctx, jti, exp, text)}`, "application/json");
|
||||
expect((await answerWith(Buffer.alloc(16 * 1024 + 1, 97))).json()).toEqual({ error: "state_not_bound" });
|
||||
// A bomb: far past the cap once inflated. It is refused, and the inflate that refused it was
|
||||
// handed the cap, so it never produced more than 16 KB (the property, not a wall-clock proxy).
|
||||
inflateCalls.length = 0;
|
||||
expect((await answerWith(Buffer.alloc(4 * 1024 * 1024))).json()).toEqual({ error: "state_not_bound" });
|
||||
expect(inflateCalls.length).toBeGreaterThan(0);
|
||||
expect(inflateCalls.every((c) => c.maxOutputLength !== undefined && c.maxOutputLength <= 16 * 1024)).toBe(true);
|
||||
const t0 = Date.now();
|
||||
expect((await answerWith(Buffer.alloc(300 * 1024 * 1024))).json()).toEqual({ error: "state_not_bound" });
|
||||
expect(Date.now() - t0).toBeLessThan(1500);
|
||||
// At the cap it is read.
|
||||
expect((await answerWith(Buffer.alloc(16 * 1024, 97))).json()).toMatchObject({ error: "consent_refused" });
|
||||
});
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { buildApp } from "../src/app.js";
|
|||
import { type HeimdallConfig } from "../src/config.js";
|
||||
import { type OAuthProviderRuntime } from "../src/oauth.js";
|
||||
import { createRuntimeKeyMaterial, signJwt, verifyJwt } from "../src/signing.js";
|
||||
import { IdentityHeldElsewhereError, InMemoryStore } from "../src/store/index.js";
|
||||
import { InMemoryStore } from "../src/store/index.js";
|
||||
import { launchFlow } from "./oauth-flow.js";
|
||||
|
||||
function createTestConfig(): HeimdallConfig {
|
||||
|
|
@ -166,15 +166,6 @@ describe("link callback holds the provider identity to the initiator", () => {
|
|||
expect((await r.store.findAccountByLinkedIdentity("discord", "d-held"))?.id).toBe(other.id);
|
||||
});
|
||||
|
||||
it("maps a store refusal of the move (a race past the check) to link_initiator_mismatch", async () => {
|
||||
const r = await rig("d-race");
|
||||
vi.spyOn(r.store, "upsertLinkedIdentity").mockRejectedValueOnce(new IdentityHeldElsewhereError());
|
||||
const started = await start(r, { mode: "link", initiatingAccountId: r.initiator.id });
|
||||
const done = await callback(r, started.json());
|
||||
expect([done.statusCode, done.json().error]).toEqual([403, "link_initiator_mismatch"]);
|
||||
expect(done.json().completion).toBeUndefined();
|
||||
});
|
||||
|
||||
it("refuses a link state that carries no initiator (forged or stripped), before attaching anything", async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "heimdall-link-"));
|
||||
try {
|
||||
|
|
@ -227,16 +218,6 @@ describe("redeem binds caller, target and replay", () => {
|
|||
expect((await redeem(r, { completionCode, targetId: "target-a" }, appAuth)).statusCode).toBe(201);
|
||||
});
|
||||
|
||||
it("refuses a target named against a completion that recorded none, without consuming the code", async () => {
|
||||
const r = await rig("d-r2b");
|
||||
const completionCode = await linked(r, undefined);
|
||||
const refused = await redeem(r, { completionCode, targetId: "target-a" }, appAuth);
|
||||
expect([refused.statusCode, refused.json().error]).toEqual([403, "completion_target_mismatch"]);
|
||||
expect(refused.json().accessToken).toBeUndefined();
|
||||
expect((await r.store.findAuthCompletion("repixelizer", completionCode))?.consumedAt).toBeUndefined();
|
||||
expect((await redeem(r, { completionCode }, appAuth)).statusCode).toBe(201);
|
||||
});
|
||||
|
||||
it("refuses a replayed completion", async () => {
|
||||
const r = await rig("d-r3");
|
||||
const completionCode = await linked(r, "target-a");
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import { type HeimdallConfig } from "../src/config.js";
|
|||
import { type Provider } from "../src/contracts.js";
|
||||
import { createTokenCustody } from "../src/custody.js";
|
||||
import {
|
||||
MANAGED_CREDENTIAL_REFRESH_BACKOFF_MS,
|
||||
MANAGED_CREDENTIAL_REFRESH_SKEW_MS,
|
||||
ManagedCredentialRefreshFailed,
|
||||
refreshManagedCredential,
|
||||
|
|
@ -420,18 +419,17 @@ for (const { kind, live } of rigKinds) describe.skipIf(live && !adminUrl)(`manag
|
|||
expect(Date.now() - started).toBeLessThan(5_000);
|
||||
|
||||
endpoint.mode = "normal";
|
||||
const afterBackoff = { ...ctx, clock: () => Date.now() + MANAGED_CREDENTIAL_REFRESH_BACKOFF_MS };
|
||||
const outcome = await refreshManagedCredential(afterBackoff, await rig.row());
|
||||
const outcome = await refreshManagedCredential(ctx, await rig.row());
|
||||
expect(outcome.kind).toBe("current");
|
||||
expect(rig.custody.decrypt(outcome.identity.accessTokenEncrypted!)).toBe("access-1");
|
||||
});
|
||||
|
||||
it("single flight: concurrent refreshes against a never-answering endpoint make one provider call across all processes, and every caller gets the typed failure", timeout, async () => {
|
||||
it("single flight: concurrent refreshes against a never-answering endpoint make one provider call per process, and every caller gets the typed failure", timeout, async () => {
|
||||
const rig = await makeRig(provider);
|
||||
const endpoint = installTokenEndpoint(provider, "refresh-0");
|
||||
endpoint.mode = "hang";
|
||||
const snapshot = await rig.row();
|
||||
const ctxFor = (store: HeimdallStore, clock = Date.now) => ({ config: rig.config, store, tokenCustody: rig.custody, oauthRuntimes: createOAuthRuntimeRegistry(), timeoutMs: 150, clock });
|
||||
const ctxFor = (store: HeimdallStore) => ({ config: rig.config, store, tokenCustody: rig.custody, oauthRuntimes: createOAuthRuntimeRegistry(), timeoutMs: 150 });
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
Array.from({ length: 12 }, (_, i) => refreshManagedCredential(ctxFor(rig.stores[i % rig.stores.length]!), snapshot))
|
||||
|
|
@ -441,205 +439,14 @@ for (const { kind, live } of rigKinds) describe.skipIf(live && !adminUrl)(`manag
|
|||
for (const result of results) {
|
||||
expect((result as PromiseRejectedResult).reason).toBeInstanceOf(ManagedCredentialRefreshFailed);
|
||||
}
|
||||
// The process that queued behind the failed holder reads the holder's marker under the lock and presents nothing.
|
||||
expect(endpoint.calls).toBe(1);
|
||||
expect(endpoint.calls).toBe(rig.stores.length);
|
||||
expect(rig.custody.decrypt((await rig.row()).refreshTokenEncrypted!)).toBe("refresh-0");
|
||||
|
||||
// The burst is over and so is the backoff the failure left on the row: a later independent request tries again.
|
||||
// The burst is over: a later independent request tries again.
|
||||
endpoint.mode = "normal";
|
||||
const later = await refreshManagedCredential(ctxFor(rig.stores[0]!, () => Date.now() + MANAGED_CREDENTIAL_REFRESH_BACKOFF_MS), await rig.row());
|
||||
const later = await refreshManagedCredential(ctxFor(rig.stores[0]!), await rig.row());
|
||||
expect(later.kind).toBe("current");
|
||||
expect(endpoint.calls).toBe(2);
|
||||
});
|
||||
|
||||
it.each(["server_error", "hang"] as const)(
|
||||
"cross-process failed-refresh marker (%s): the failed attempt is recorded on the row, and another process does not present the token inside the backoff",
|
||||
timeout,
|
||||
async (mode) => {
|
||||
const rig = await makeRig(provider);
|
||||
const endpoint = installTokenEndpoint(provider, "refresh-0");
|
||||
endpoint.mode = mode;
|
||||
const t0 = Date.now();
|
||||
const ctxFor = (store: HeimdallStore, at: number) => ({
|
||||
config: rig.config, store, tokenCustody: rig.custody, oauthRuntimes: createOAuthRuntimeRegistry(), timeoutMs: 100, clock: () => at,
|
||||
});
|
||||
const snapshot = await rig.row();
|
||||
const first = rig.stores[0]!;
|
||||
const other = rig.stores[rig.stores.length - 1]!;
|
||||
|
||||
await expect(refreshManagedCredential(ctxFor(first, t0), snapshot)).rejects.toBeInstanceOf(ManagedCredentialRefreshFailed);
|
||||
expect(endpoint.calls).toBe(1);
|
||||
const marked = await rig.row();
|
||||
expect(marked.refreshFailedAt).toBe(new Date(t0).toISOString());
|
||||
expect(marked.accessTokenEncrypted).toBe(snapshot.accessTokenEncrypted);
|
||||
expect(marked.refreshTokenEncrypted).toBe(snapshot.refreshTokenEncrypted);
|
||||
expect(marked.tokenExpiresAt).toBe(snapshot.tokenExpiresAt);
|
||||
|
||||
// Another process, holding the stale snapshot, one tick before the backoff ends: the locked row decides, and no token call is made.
|
||||
const backedOff = ctxFor(other, t0 + MANAGED_CREDENTIAL_REFRESH_BACKOFF_MS - 1);
|
||||
await expect(refreshManagedCredential(backedOff, snapshot)).rejects.toBeInstanceOf(ManagedCredentialRefreshFailed);
|
||||
expect(endpoint.calls).toBe(1);
|
||||
// Being turned away does not extend the backoff.
|
||||
expect((await rig.row()).refreshFailedAt).toBe(marked.refreshFailedAt);
|
||||
expect(await rig.audit()).toEqual([]);
|
||||
}
|
||||
);
|
||||
|
||||
it("cross-process failed-refresh marker: the backoff ends exactly at its length, a success clears the marker, and a failure after it marks anew", timeout, async () => {
|
||||
const rig = await makeRig(provider);
|
||||
const endpoint = installTokenEndpoint(provider, "refresh-0");
|
||||
endpoint.mode = "server_error";
|
||||
const t0 = Date.now();
|
||||
const ctxAt = (at: number) => ({ config: rig.config, store: rig.stores[0]!, tokenCustody: rig.custody, oauthRuntimes: createOAuthRuntimeRegistry(), clock: () => at });
|
||||
await expect(refreshManagedCredential(ctxAt(t0), await rig.row())).rejects.toBeInstanceOf(ManagedCredentialRefreshFailed);
|
||||
|
||||
const retryAt = t0 + MANAGED_CREDENTIAL_REFRESH_BACKOFF_MS;
|
||||
await expect(refreshManagedCredential(ctxAt(retryAt), await rig.row())).rejects.toBeInstanceOf(ManagedCredentialRefreshFailed);
|
||||
expect(endpoint.calls).toBe(2);
|
||||
expect((await rig.row()).refreshFailedAt).toBe(new Date(retryAt).toISOString());
|
||||
|
||||
endpoint.mode = "normal";
|
||||
const laterAt = retryAt + MANAGED_CREDENTIAL_REFRESH_BACKOFF_MS;
|
||||
const outcome = await refreshManagedCredential(ctxAt(laterAt), await rig.row());
|
||||
expect(outcome.kind).toBe("current");
|
||||
expect(endpoint.calls).toBe(3);
|
||||
expect(rig.custody.decrypt(outcome.identity.accessTokenEncrypted!)).toBe("access-1");
|
||||
expect((await rig.row()).refreshFailedAt).toBeUndefined();
|
||||
});
|
||||
|
||||
it("cross-process failed-refresh marker: a revocation and a re-link both clear it", timeout, async () => {
|
||||
const rig = await makeRig(provider);
|
||||
const endpoint = installTokenEndpoint(provider, "refresh-0");
|
||||
endpoint.mode = "server_error";
|
||||
const t0 = Date.now();
|
||||
const ctxAt = (at: number) => ({ config: rig.config, store: rig.stores[0]!, tokenCustody: rig.custody, oauthRuntimes: createOAuthRuntimeRegistry(), clock: () => at });
|
||||
await expect(refreshManagedCredential(ctxAt(t0), await rig.row())).rejects.toBeInstanceOf(ManagedCredentialRefreshFailed);
|
||||
expect((await rig.row()).refreshFailedAt).toBeDefined();
|
||||
|
||||
endpoint.mode = "invalid_grant";
|
||||
const revoked = await refreshManagedCredential(ctxAt(t0 + MANAGED_CREDENTIAL_REFRESH_BACKOFF_MS), await rig.row());
|
||||
expect(revoked.kind).toBe("revoked");
|
||||
expect((await rig.row()).refreshFailedAt).toBeUndefined();
|
||||
|
||||
// A fresh consent after a failure replaces the whole credential, marker included.
|
||||
endpoint.mode = "server_error";
|
||||
const relinkSeed = await rig.row();
|
||||
await rig.stores[0]!.upsertLinkedIdentity({
|
||||
accountId: rig.accountId, provider, providerUserId: relinkSeed.providerUserId, scopes: ["identity:read"], profileJson: {},
|
||||
accessTokenEncrypted: rig.custody.encrypt("access-r"), refreshTokenEncrypted: rig.custody.encrypt("refresh-r"),
|
||||
tokenExpiresAt: new Date(t0 + 1000).toISOString(), createdAt: relinkSeed.createdAt, updatedAt: new Date(t0).toISOString(),
|
||||
});
|
||||
await expect(refreshManagedCredential(ctxAt(t0 + 2000), await rig.row())).rejects.toBeInstanceOf(ManagedCredentialRefreshFailed);
|
||||
expect((await rig.row()).refreshFailedAt).toBeDefined();
|
||||
await rig.stores[0]!.upsertLinkedIdentity({
|
||||
accountId: rig.accountId, provider, providerUserId: relinkSeed.providerUserId, scopes: ["identity:read"], profileJson: {},
|
||||
accessTokenEncrypted: rig.custody.encrypt("access-s"), refreshTokenEncrypted: rig.custody.encrypt("refresh-s"),
|
||||
tokenExpiresAt: new Date(t0 + 3000).toISOString(), createdAt: relinkSeed.createdAt, updatedAt: new Date(t0 + 2500).toISOString(),
|
||||
});
|
||||
expect((await rig.row()).refreshFailedAt).toBeUndefined();
|
||||
});
|
||||
|
||||
it("cross-process failed-refresh marker: stamped with the time of the failure, so a slow failure does not eat the backoff", timeout, async () => {
|
||||
const rig = await makeRig(provider);
|
||||
const endpoint = installTokenEndpoint(provider, "refresh-0");
|
||||
endpoint.mode = "server_error";
|
||||
const t0 = Date.now();
|
||||
// The provider call takes 9 s of the clock's time: the failure is known at t0 + 9 s, not at t0.
|
||||
const slowClock = () => t0 + (endpoint.calls > 0 ? 9_000 : 0);
|
||||
const ctx = (store: HeimdallStore, clock: () => number) => ({ config: rig.config, store, tokenCustody: rig.custody, oauthRuntimes: createOAuthRuntimeRegistry(), clock });
|
||||
const snapshot = await rig.row();
|
||||
|
||||
await expect(refreshManagedCredential(ctx(rig.stores[0]!, slowClock), snapshot)).rejects.toBeInstanceOf(ManagedCredentialRefreshFailed);
|
||||
expect((await rig.row()).refreshFailedAt).toBe(new Date(t0 + 9_000).toISOString());
|
||||
|
||||
// 51 s after the failure, 60 s after the attempt began: still inside the backoff.
|
||||
const other = rig.stores[rig.stores.length - 1]!;
|
||||
await expect(refreshManagedCredential(ctx(other, () => t0 + MANAGED_CREDENTIAL_REFRESH_BACKOFF_MS), snapshot)).rejects.toBeInstanceOf(ManagedCredentialRefreshFailed);
|
||||
expect(endpoint.calls).toBe(1);
|
||||
});
|
||||
|
||||
it("cross-process failed-refresh marker: a failure after the provider answered still leaves the marker (the provider may have consumed the token)", timeout, async () => {
|
||||
const rig = await makeRig(provider);
|
||||
const endpoint = installTokenEndpoint(provider, "refresh-0");
|
||||
const snapshot = await rig.row();
|
||||
const throwingCustody = {
|
||||
...rig.custody,
|
||||
encrypt: (plaintext: string) => {
|
||||
if (plaintext === "access-1") throw new Error("CANARY custody encrypt failed");
|
||||
return rig.custody.encrypt(plaintext);
|
||||
},
|
||||
};
|
||||
const ctx = (store: HeimdallStore, tokenCustody: typeof rig.custody) => ({ config: rig.config, store, tokenCustody, oauthRuntimes: createOAuthRuntimeRegistry() });
|
||||
|
||||
const failure = await refreshManagedCredential(ctx(rig.stores[0]!, throwingCustody), snapshot).then(() => undefined, (error: unknown) => error);
|
||||
expect(failure).toBeInstanceOf(ManagedCredentialRefreshFailed);
|
||||
expect(String(failure) + JSON.stringify(failure)).not.toContain("CANARY");
|
||||
expect(endpoint.calls).toBe(1);
|
||||
const marked = await rig.row();
|
||||
expect(marked.refreshFailedAt).toBeDefined();
|
||||
expect(marked.refreshTokenEncrypted).toBe(snapshot.refreshTokenEncrypted);
|
||||
|
||||
// Another process inside the backoff does not present the old token again.
|
||||
await expect(refreshManagedCredential(ctx(rig.stores[rig.stores.length - 1]!, rig.custody), snapshot)).rejects.toBeInstanceOf(ManagedCredentialRefreshFailed);
|
||||
expect(endpoint.calls).toBe(1);
|
||||
});
|
||||
|
||||
it.skipIf(!live)("cross-process failed-refresh marker: a rotation the database refuses to write still leaves the marker", timeout, async () => {
|
||||
const rig = await makeRig(provider);
|
||||
const endpoint = installTokenEndpoint(provider, "refresh-0");
|
||||
const snapshot = await rig.row();
|
||||
const db = new Pool({ connectionString: rig.databaseUrl!, max: 1 });
|
||||
db.on("error", () => undefined);
|
||||
try {
|
||||
await db.query(`CREATE FUNCTION refuse_rotation() RETURNS trigger AS $$ BEGIN RAISE EXCEPTION 'CANARY rotation write refused'; END $$ LANGUAGE plpgsql`);
|
||||
await db.query(`CREATE TRIGGER refuse_rotation BEFORE UPDATE ON linked_identities FOR EACH ROW WHEN (NEW.access_token_encrypted IS DISTINCT FROM OLD.access_token_encrypted) EXECUTE FUNCTION refuse_rotation()`);
|
||||
const ctx = (store: HeimdallStore) => ({ config: rig.config, store, tokenCustody: rig.custody, oauthRuntimes: createOAuthRuntimeRegistry() });
|
||||
|
||||
await expect(refreshManagedCredential(ctx(rig.stores[0]!), snapshot)).rejects.toThrow();
|
||||
expect(endpoint.calls).toBe(1);
|
||||
const marked = await rig.row();
|
||||
expect(marked.refreshFailedAt).toBeDefined();
|
||||
if (providerCatalog[provider].rotatesRefreshTokens) {
|
||||
// The provider consumed the token: nothing replayable is left on the row, so the backoff ending presents nothing.
|
||||
expect(marked.refreshTokenEncrypted).toBeUndefined();
|
||||
|
||||
await expect(refreshManagedCredential(ctx(rig.stores[rig.stores.length - 1]!), snapshot)).resolves.toBeDefined();
|
||||
const afterBackoff = { ...ctx(rig.stores[0]!), clock: () => Date.now() + 5 * 60_000 };
|
||||
await expect(refreshManagedCredential(afterBackoff, snapshot)).resolves.toBeDefined();
|
||||
expect(endpoint.calls).toBe(1);
|
||||
} else {
|
||||
// A non-rotating provider keeps the token valid: a transient write failure must not strand the link.
|
||||
expect(marked.refreshTokenEncrypted).toBe(snapshot.refreshTokenEncrypted);
|
||||
|
||||
await expect(refreshManagedCredential(ctx(rig.stores[rig.stores.length - 1]!), snapshot)).rejects.toBeInstanceOf(ManagedCredentialRefreshFailed);
|
||||
expect(endpoint.calls).toBe(1);
|
||||
await db.query("DROP TRIGGER refuse_rotation ON linked_identities");
|
||||
// The fake endpoint rotates on every refresh; Twitch does not, so its answer left refresh-0 valid.
|
||||
endpoint.live = "refresh-0";
|
||||
const afterBackoff = { ...ctx(rig.stores[0]!), clock: () => Date.now() + 5 * 60_000 };
|
||||
const outcome = await refreshManagedCredential(afterBackoff, snapshot);
|
||||
expect(outcome.kind).toBe("current");
|
||||
expect(endpoint.calls).toBe(2);
|
||||
}
|
||||
} finally {
|
||||
await db.end();
|
||||
}
|
||||
});
|
||||
|
||||
it("cross-process failed-refresh marker: a writer's clock skewed by under the backoff still holds the reader off, and by the backoff or more does not", timeout, async () => {
|
||||
const rig = await makeRig(provider);
|
||||
const endpoint = installTokenEndpoint(provider, "refresh-0");
|
||||
endpoint.mode = "server_error";
|
||||
const t0 = Date.now();
|
||||
const ctxAt = (at: number) => ({ config: rig.config, store: rig.stores[0]!, tokenCustody: rig.custody, oauthRuntimes: createOAuthRuntimeRegistry(), clock: () => at });
|
||||
await expect(refreshManagedCredential(ctxAt(t0), await rig.row())).rejects.toBeInstanceOf(ManagedCredentialRefreshFailed);
|
||||
expect(endpoint.calls).toBe(1);
|
||||
|
||||
// The marker is in this reader's future: a writer ahead of it. A bounded wait, not a refusal for as long as the skew.
|
||||
await expect(refreshManagedCredential(ctxAt(t0 - MANAGED_CREDENTIAL_REFRESH_BACKOFF_MS + 1), await rig.row())).rejects.toBeInstanceOf(ManagedCredentialRefreshFailed);
|
||||
expect(endpoint.calls).toBe(1);
|
||||
await expect(refreshManagedCredential(ctxAt(t0 - MANAGED_CREDENTIAL_REFRESH_BACKOFF_MS), await rig.row())).rejects.toBeInstanceOf(ManagedCredentialRefreshFailed);
|
||||
expect(endpoint.calls).toBe(2);
|
||||
expect(endpoint.calls).toBe(rig.stores.length + 1);
|
||||
});
|
||||
|
||||
it("a snapshot taken before another caller's refresh does not decide: the locked row does", timeout, async () => {
|
||||
|
|
@ -680,38 +487,6 @@ for (const { kind, live } of rigKinds) describe.skipIf(live && !adminUrl)(`manag
|
|||
{ eventType: "managed_credential_revoked", payload: { provider, reason: "rotation_missing" } },
|
||||
]);
|
||||
});
|
||||
|
||||
it.skipIf(!live).each(["no_refresh_token", "empty_refresh_token"] as const)(
|
||||
"rotation-fails-closed: when the revoke the provider's answer called for cannot be written (%s), the marker is still left",
|
||||
timeout,
|
||||
async (mode) => {
|
||||
const rig = await makeRig(provider);
|
||||
const endpoint = installTokenEndpoint(provider, "refresh-0");
|
||||
endpoint.mode = mode;
|
||||
const snapshot = await rig.row();
|
||||
const db = new Pool({ connectionString: rig.databaseUrl!, max: 1 });
|
||||
db.on("error", () => undefined);
|
||||
try {
|
||||
await db.query(`CREATE FUNCTION refuse_revoke() RETURNS trigger AS $$ BEGIN RAISE EXCEPTION 'CANARY revoke write refused'; END $$ LANGUAGE plpgsql`);
|
||||
await db.query(`CREATE TRIGGER refuse_revoke BEFORE UPDATE ON linked_identities FOR EACH ROW WHEN (NEW.access_token_encrypted IS DISTINCT FROM OLD.access_token_encrypted) EXECUTE FUNCTION refuse_revoke()`);
|
||||
const ctx = (store: HeimdallStore) => ({ config: rig.config, store, tokenCustody: rig.custody, oauthRuntimes: createOAuthRuntimeRegistry() });
|
||||
|
||||
await expect(refreshManagedCredential(ctx(rig.stores[0]!), snapshot)).rejects.toThrow();
|
||||
expect(endpoint.calls).toBe(1);
|
||||
const marked = await rig.row();
|
||||
expect(marked.refreshFailedAt).toBeDefined();
|
||||
// The provider consumed the token: nothing replayable is left on the row, so the backoff ending presents nothing.
|
||||
expect(marked.refreshTokenEncrypted).toBeUndefined();
|
||||
|
||||
await expect(refreshManagedCredential(ctx(rig.stores[rig.stores.length - 1]!), snapshot)).resolves.toBeDefined();
|
||||
const afterBackoff = { ...ctx(rig.stores[0]!), clock: () => Date.now() + 5 * 60_000 };
|
||||
await expect(refreshManagedCredential(afterBackoff, snapshot)).resolves.toBeDefined();
|
||||
expect(endpoint.calls).toBe(1);
|
||||
} finally {
|
||||
await db.end();
|
||||
}
|
||||
}
|
||||
);
|
||||
} else {
|
||||
it.each(["no_refresh_token", "empty_refresh_token"] as const)("a response with %s keeps the old one for a non-rotating provider", timeout, async (mode) => {
|
||||
const rig = await makeRig(provider);
|
||||
|
|
@ -757,7 +532,6 @@ for (const { kind, live } of rigKinds) describe.skipIf(live && !adminUrl)(`manag
|
|||
tokenExpiresAt: past,
|
||||
scopes: locked.scopes,
|
||||
updatedAt: past,
|
||||
consumedRefreshToken: false,
|
||||
}));
|
||||
expect((await rig.resolve()).statusCode).toBe(200);
|
||||
expect(endpoint.calls).toBe(2);
|
||||
|
|
@ -937,7 +711,6 @@ for (const { kind, live } of rigKinds) describe.skipIf(live && !adminUrl)(`manag
|
|||
tokenExpiresAt: "2999-01-01T00:00:00.000Z",
|
||||
scopes: ["a", "b"],
|
||||
updatedAt: "2999-01-02T00:00:00.000Z",
|
||||
consumedRefreshToken: false,
|
||||
}));
|
||||
expect(rotated).toEqual({
|
||||
...before,
|
||||
|
|
@ -1021,7 +794,6 @@ for (const { kind, live } of rigKinds) describe.skipIf(live && !adminUrl)(`manag
|
|||
tokenExpiresAt: new Date(Date.now() + 3600_000).toISOString(),
|
||||
scopes: locked.scopes,
|
||||
updatedAt: new Date().toISOString(),
|
||||
consumedRefreshToken: false,
|
||||
};
|
||||
})
|
||||
)
|
||||
|
|
@ -1300,7 +1072,6 @@ describe("refresh answer classification", () => {
|
|||
tokenExpiresAt: new Date().toISOString(),
|
||||
scopes: locked.scopes,
|
||||
updatedAt: new Date().toISOString(),
|
||||
consumedRefreshToken: false,
|
||||
})))!;
|
||||
const calls = answer(JSON.stringify({ access_token: "access-1", refresh_token: "refresh-1", ...extra }), 200);
|
||||
let skew = 0;
|
||||
|
|
@ -1333,7 +1104,6 @@ describe("refresh answer classification", () => {
|
|||
tokenExpiresAt: dueAt,
|
||||
scopes: ["s"],
|
||||
updatedAt: dueAt,
|
||||
consumedRefreshToken: false,
|
||||
}));
|
||||
const calls = answer(JSON.stringify({ access_token: "access-2", expires_in: 3600 }), 200);
|
||||
await refreshManagedCredential(ctxFor(store), snapshot);
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import { type FastifyInstance } from "fastify";
|
|||
import { buildApp, getHeimdallRuntimeContext, startOAuthFlow } from "../src/app.js";
|
||||
import { resolveAppCaller } from "../src/app-caller.js";
|
||||
import { decodeJwt, signJwt } from "../src/signing.js";
|
||||
import { OAUTH_STATE_PRUNE_BATCH, OAUTH_STATE_PRUNE_MARGIN_MS, OAUTH_STATE_SKEW_MS } from "../src/store/types.js";
|
||||
import { createMockDiscordRuntime, createTestConfig, launchFlow, setCookieOf, stateOf } from "./oauth-flow.js";
|
||||
|
||||
// Heimdall's OAuth states complete once, only in the browser that launched
|
||||
|
|
@ -281,69 +280,23 @@ describe("OAuth state binding", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("use-record retention", () => {
|
||||
// Only Date is faked: the routes stamp each claim with the clock, and the rest of the event loop stays real.
|
||||
const clockAt = (ms: number) => vi.useFakeTimers({ toFake: ["Date"], now: ms });
|
||||
const seedStale = async (app: FastifyInstance, count: number, beforeMs: number) => {
|
||||
const ctx = getHeimdallRuntimeContext(app);
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
const expiresAt = new Date(beforeMs - i).toISOString();
|
||||
await ctx.store.claimOAuthStateUse({ jti: `stale-${i}`, phase: "launch", appSlug: "bifrost", usedAt: expiresAt, expiresAt });
|
||||
}
|
||||
};
|
||||
|
||||
it("later claims never prune a live state: its launch and callback replays stay refused", async () => {
|
||||
const app = await newApp();
|
||||
const start = await bifrostStart(app);
|
||||
const victim = await launchFlow(app, start);
|
||||
const exp = decodeJwt(victim.state).payload.exp as number;
|
||||
|
||||
// More stale records than one batch, then another user's flow, whose claims do the pruning.
|
||||
const staleBefore = Date.now() - OAUTH_STATE_SKEW_MS - OAUTH_STATE_PRUNE_MARGIN_MS;
|
||||
await seedStale(app, OAUTH_STATE_PRUNE_BATCH + 10, staleBefore);
|
||||
await launchFlow(app, await bifrostStart(app));
|
||||
|
||||
const launchReplay = await app.inject({ method: "GET", url: launchUrl(start) });
|
||||
expect(launchReplay.json()).toEqual({ error: "state_already_launched" });
|
||||
|
||||
// The callback is not pruned either: it completes once, and its replay is refused.
|
||||
expect((await callback(app, victim.state, victim.cookie, "code=victim-code")).statusCode).toBe(201);
|
||||
expect((await callback(app, victim.state, victim.cookie, "code=victim-code")).json()).toEqual({ error: "state_already_used" });
|
||||
|
||||
// Inside exp + skew the state still verifies, and the record still refuses it.
|
||||
clockAt((exp + OAUTH_STATE_SKEW_MS / 1000 - 1) * 1000);
|
||||
await launchFlow(app, await bifrostStart(app));
|
||||
const lateReplay = await app.inject({ method: "GET", url: launchUrl(start) });
|
||||
expect(lateReplay.json()).toEqual({ error: "state_already_launched" });
|
||||
|
||||
});
|
||||
|
||||
it("once its records are pruned, an expired state is refused as state_expired, by the state and not the record", async () => {
|
||||
const app = await newApp();
|
||||
const ctx = getHeimdallRuntimeContext(app);
|
||||
const start = await bifrostStart(app);
|
||||
const victim = await launchFlow(app, start);
|
||||
const exp = decodeJwt(victim.state).payload.exp as number;
|
||||
|
||||
// Past exp + skew + margin the victim's launch record is prunable; a new claim removes it.
|
||||
const pruneAt = (exp + (OAUTH_STATE_SKEW_MS + OAUTH_STATE_PRUNE_MARGIN_MS) / 1000 + 1) * 1000;
|
||||
clockAt(pruneAt);
|
||||
await launchFlow(app, await bifrostStart(app));
|
||||
const record = {
|
||||
jti: decodeJwt(victim.state).payload.jti as string,
|
||||
phase: "launch" as const,
|
||||
appSlug: "bifrost" as const,
|
||||
usedAt: new Date(pruneAt).toISOString(),
|
||||
expiresAt: new Date(exp * 1000).toISOString(),
|
||||
};
|
||||
// The record is gone, as the store (not the route) would now let the claim through ...
|
||||
expect(await ctx.store.claimOAuthStateUse(record)).toBe(true);
|
||||
// ... yet the routes refuse the state itself.
|
||||
const launchReplay = await app.inject({ method: "GET", url: launchUrl(start) });
|
||||
expect(launchReplay.statusCode).toBe(400);
|
||||
expect(launchReplay.json()).toEqual({ error: "state_expired" });
|
||||
expect((await callback(app, victim.state, victim.cookie)).json()).toEqual({ error: "state_expired" });
|
||||
});
|
||||
it("the memory store keeps a use record until the state's exp + 30 s skew", async () => {
|
||||
const app = await newApp();
|
||||
const ctx = getHeimdallRuntimeContext(app);
|
||||
const claim = (jti: string, usedAt: number) =>
|
||||
ctx.store.claimOAuthStateUse({
|
||||
jti,
|
||||
phase: "launch",
|
||||
appSlug: "bifrost",
|
||||
usedAt: new Date(usedAt).toISOString(),
|
||||
expiresAt: new Date(1_000_000).toISOString(),
|
||||
});
|
||||
expect(await claim("a", 900_000)).toBe(true);
|
||||
// Past exp but inside the skew a state still verifies, so its record must still refuse a replay.
|
||||
expect(await claim("b", 1_000_000 + 29_999)).toBe(true);
|
||||
expect(await claim("a", 1_000_000 + 29_999)).toBe(false);
|
||||
expect(await claim("c", 1_000_000 + 30_000)).toBe(true);
|
||||
expect(await claim("a", 1_000_000 + 30_000)).toBe(true);
|
||||
});
|
||||
|
||||
it("a provider refusal from the launching browser still denies the attempt, once", async () => {
|
||||
|
|
|
|||
|
|
@ -1,435 +0,0 @@
|
|||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterAll, afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { buildApp, STEP_UP_ASSERT_LIMITS } from "../src/app.js";
|
||||
import { builtInAppProfiles } from "../src/app-profiles.js";
|
||||
import { issueAccessClaim } from "../src/claims.js";
|
||||
import { type Provider } from "../src/contracts.js";
|
||||
import { createRuntimeKeyMaterial, signJwt, verifyJwt } from "../src/signing.js";
|
||||
import { InMemoryStore } from "../src/store/index.js";
|
||||
import { type CreateAuditEventInput } from "../src/store/types.js";
|
||||
import { createTestConfig } from "./oauth-flow.js";
|
||||
|
||||
// An app asserting a step-up proof for its member: POST /v1/apps/{app}/step-up.
|
||||
// Driven over HTTP through the real Fastify app, the way Bifrost would call it,
|
||||
// with the unlink route as the consumer of the proof it mints.
|
||||
|
||||
const now = "2026-11-13T12:00:00.000Z";
|
||||
const secret = "bifrost-secret";
|
||||
const appAuth = { "x-heimdall-app-secret": secret };
|
||||
const nonce = (tag: string) => `nonce-${tag.replace(/[^A-Za-z0-9_-]/g, "_")}-`.padEnd(32, "x");
|
||||
|
||||
const dir = mkdtempSync(join(tmpdir(), "heimdall-stepup-assert-"));
|
||||
const config = { ...createTestConfig(), signingPrivateKeyPath: join(dir, "signing.pem"), bootstrapSigningPrivateKeyOnMissing: true };
|
||||
afterAll(() => rmSync(dir, { recursive: true, force: true }));
|
||||
|
||||
const apps: Awaited<ReturnType<typeof buildApp>>[] = [];
|
||||
afterEach(async () => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
await Promise.all(apps.splice(0).map((app) => app.close()));
|
||||
});
|
||||
|
||||
async function rig() {
|
||||
const store = new InMemoryStore();
|
||||
const audit: CreateAuditEventInput[] = [];
|
||||
const realAudit = store.createAuditEvent.bind(store);
|
||||
store.createAuditEvent = async (input) => {
|
||||
audit.push(structuredClone(input));
|
||||
return realAudit(input);
|
||||
};
|
||||
const app = await buildApp({ config, store });
|
||||
apps.push(app);
|
||||
const keys = createRuntimeKeyMaterial({ ...config, bootstrapSigningPrivateKeyOnMissing: false });
|
||||
return { app, store, keys, audit };
|
||||
}
|
||||
type Rig = Awaited<ReturnType<typeof rig>>;
|
||||
|
||||
/** A member with the given identities and a live Heimdall session for `appSlug`, as a sign-in would have left them. */
|
||||
let seq = 0;
|
||||
async function member(r: Rig, appSlug = "bifrost", held?: Array<[Provider, string]>, accountId?: string) {
|
||||
seq += 1;
|
||||
held ??= [["discord", `d-${seq}`], ["google", `g-${seq}`]];
|
||||
const account = accountId ? { id: accountId } : await r.store.createAccount({ createdAt: now, lastSeenAt: now });
|
||||
const rows = [];
|
||||
if (!accountId) {
|
||||
for (const [provider, providerUserId] of held) {
|
||||
rows.push(await r.store.upsertLinkedIdentity({ accountId: account.id, provider, providerUserId, scopes: [], profileJson: {}, createdAt: now, updatedAt: now }));
|
||||
}
|
||||
}
|
||||
const issued = await issueAccessClaim({
|
||||
config,
|
||||
keys: r.keys,
|
||||
store: r.store,
|
||||
input: { appSlug, accountId: account.id, linkedIdentities: held.map(([provider, providerUserId]) => ({ provider, providerUserId })), facts: [] },
|
||||
});
|
||||
return { account, rows, issued, token: issued.accessToken, sid: issued.session.sessionId };
|
||||
}
|
||||
|
||||
const bearer = (token?: string) => (token ? { authorization: `Bearer ${token}` } : {});
|
||||
const assertStepUp = (r: Rig, o: { app?: string; headers?: Record<string, string>; token?: string | undefined; method?: string; nonce?: string; payload?: object } = {}) =>
|
||||
r.app.inject({
|
||||
method: "POST",
|
||||
url: `/v1/apps/${o.app ?? "bifrost"}/step-up`,
|
||||
headers: { ...(o.headers ?? appAuth), ...bearer(o.token) },
|
||||
payload: o.payload ?? { method: o.method ?? "bifrost-passkey", nonce: o.nonce ?? nonce("a") },
|
||||
});
|
||||
const unlink = (r: Rig, id: string, token: string) =>
|
||||
r.app.inject({ method: "POST", url: `/v1/me/identities/${encodeURIComponent(id)}/unlink`, headers: bearer(token) });
|
||||
const refused = (res: { statusCode: number; json(): { error?: string } }) => [res.statusCode, res.json().error];
|
||||
const claimsOf = (token: string, r: Rig) => {
|
||||
const v = verifyJwt(token, r.keys.publicKey);
|
||||
expect(v.valid).toBe(true);
|
||||
return v.valid ? v.payload : {};
|
||||
};
|
||||
const nowS = () => Math.floor(Date.now() / 1000);
|
||||
const auditOf = (r: Rig, type: string) => r.audit.filter((e) => e.eventType === type);
|
||||
|
||||
describe("the proof it mints", () => {
|
||||
it("returns a new access token for the same session and account, with step_up_at now and the method, and no refresh token", async () => {
|
||||
const r = await rig();
|
||||
const m = await member(r);
|
||||
const before = nowS();
|
||||
const res = await assertStepUp(r, { token: m.token });
|
||||
expect(res.statusCode).toBe(201);
|
||||
expect(res.headers["cache-control"]).toBe("no-store");
|
||||
const body = res.json();
|
||||
expect(Object.keys(body).sort()).toEqual(["accessToken", "claimSet", "stepUp"]);
|
||||
expect(res.body).not.toContain(m.issued.refreshToken);
|
||||
const original = claimsOf(m.token, r);
|
||||
const minted = claimsOf(body.accessToken, r);
|
||||
expect(minted.step_up_method).toBe("bifrost-passkey");
|
||||
expect(minted.step_up_at).toBeGreaterThanOrEqual(before);
|
||||
expect(minted.step_up_at).toBeLessThanOrEqual(nowS());
|
||||
expect(body.claimSet).toEqual(minted);
|
||||
expect(body.stepUp).toEqual({ at: minted.step_up_at, method: "bifrost-passkey", validForSeconds: 600 });
|
||||
expect(minted.jti).not.toBe(original.jti);
|
||||
// Everything else is the bearer's own: it adds proof, not capability, and does not move the session.
|
||||
const { jti, iat, nbf, step_up_at, step_up_method, ...rest } = minted;
|
||||
const { jti: _j, iat: _i, nbf: _n, ...originalRest } = original;
|
||||
expect(rest).toEqual(originalRest);
|
||||
expect([jti, iat, nbf, step_up_at, step_up_method].every((v) => v !== undefined)).toBe(true);
|
||||
expect(minted.sid).toBe(m.sid);
|
||||
expect(minted.account_id).toBe(m.account.id);
|
||||
expect(minted.aud).toBe("bifrost");
|
||||
});
|
||||
|
||||
it("the unlink check passes with the returned bearer and fails 403 step_up_required with the original", async () => {
|
||||
const r = await rig();
|
||||
const m = await member(r);
|
||||
expect(refused(await unlink(r, m.rows[1]!.id, m.token))).toEqual([403, "step_up_required"]);
|
||||
const minted = (await assertStepUp(r, { token: m.token })).json().accessToken;
|
||||
expect((await unlink(r, m.rows[1]!.id, m.token)).statusCode, "the original bearer gains nothing").toBe(403);
|
||||
const done = await unlink(r, m.rows[1]!.id, minted);
|
||||
expect(done.statusCode).toBe(200);
|
||||
expect(done.json().unlinked.id).toBe(m.rows[1]!.id);
|
||||
const unlinkEvent = auditOf(r, "identity_unlinked")[0]!;
|
||||
expect(unlinkEvent.eventPayloadJson).toMatchObject({ provider: "google", stepUpMethod: "bifrost-passkey" });
|
||||
});
|
||||
|
||||
it("does not extend the lifetime: exp stays the bearer's, the session row is untouched, no refresh is minted", async () => {
|
||||
const r = await rig();
|
||||
const m = await member(r);
|
||||
const sessionBefore = await r.store.findSession("bifrost", m.sid);
|
||||
vi.useFakeTimers({ toFake: ["Date"] });
|
||||
vi.setSystemTime(Date.now() + 1_500_000);
|
||||
const res = await assertStepUp(r, { token: m.token });
|
||||
expect(res.statusCode).toBe(201);
|
||||
const original = claimsOf(m.token, r);
|
||||
const minted = claimsOf(res.json().accessToken, r);
|
||||
expect(minted.exp).toBe(original.exp);
|
||||
expect(minted.iat).toBeGreaterThan(original.iat as number);
|
||||
expect(await r.store.findSession("bifrost", m.sid)).toEqual(sessionBefore);
|
||||
});
|
||||
|
||||
it("a refresh after an assertion carries neither step_up_at nor step_up_method", async () => {
|
||||
const r = await rig();
|
||||
const m = await member(r);
|
||||
expect((await assertStepUp(r, { token: m.token })).statusCode).toBe(201);
|
||||
const refreshed = await r.app.inject({ method: "POST", url: "/v1/apps/bifrost/sessions/refresh", payload: { refreshToken: m.issued.refreshToken } });
|
||||
expect(refreshed.statusCode).toBe(201);
|
||||
const claims = claimsOf(refreshed.json().accessToken, r);
|
||||
expect(claims).not.toHaveProperty("step_up_at");
|
||||
expect(claims).not.toHaveProperty("step_up_method");
|
||||
expect(refreshed.json().claimSet).not.toHaveProperty("step_up_at");
|
||||
expect(refreshed.json().claimSet).not.toHaveProperty("step_up_method");
|
||||
});
|
||||
|
||||
it("the proof is good for 599 s and not for 601 s, as for any step-up", async () => {
|
||||
const r = await rig();
|
||||
const m = await member(r, "bifrost", [["discord", "d-w"], ["google", "g-w"], ["patreon", "p-w"]]);
|
||||
vi.useFakeTimers({ toFake: ["Date"] });
|
||||
const minted = (await assertStepUp(r, { token: m.token })).json().accessToken;
|
||||
vi.setSystemTime(Date.now() + 601_000);
|
||||
expect(refused(await unlink(r, m.rows[1]!.id, minted))).toEqual([403, "step_up_required"]);
|
||||
vi.setSystemTime(Date.now() - 2_000);
|
||||
expect((await unlink(r, m.rows[1]!.id, minted)).statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it("the provider step-up callback labels its proof provider:<name>", async () => {
|
||||
// The sign_in path itself is proved in identity-unlink.test.ts; here the claim input is the contract.
|
||||
const r = await rig();
|
||||
const m = await member(r);
|
||||
const issued = await issueAccessClaim({
|
||||
config, keys: r.keys, store: r.store,
|
||||
input: { appSlug: "bifrost", accountId: m.account.id, linkedIdentities: [], facts: [], stepUpAt: nowS(), stepUpMethod: "provider:google" },
|
||||
});
|
||||
expect(issued.claimSet.step_up_method).toBe("provider:google");
|
||||
});
|
||||
});
|
||||
|
||||
describe("it is never issuable without both credentials", () => {
|
||||
it("401 app_auth_required for a missing, wrong, or another app's secret, even with a good bearer; nothing is written", async () => {
|
||||
const r = await rig();
|
||||
const m = await member(r);
|
||||
for (const headers of [{}, { "x-heimdall-app-secret": "wrong" }, { "x-heimdall-app-secret": "streampixels-secret" }, { "x-heimdall-app-secret": "" }]) {
|
||||
const res = await assertStepUp(r, { headers, token: m.token });
|
||||
expect(res.statusCode).toBe(401);
|
||||
expect(res.json()).toEqual({ error: "app_auth_required" });
|
||||
}
|
||||
expect(r.audit.filter((e) => e.eventType.startsWith("step_up"))).toEqual([]);
|
||||
});
|
||||
|
||||
it("401 sign_in_required for a missing, garbage, expired, foreign-signed, revoked-session or unknown-session bearer; the same answer for each", async () => {
|
||||
const r = await rig();
|
||||
const m = await member(r);
|
||||
const claims = claimsOf(m.token, r);
|
||||
const past = nowS() - 10;
|
||||
const other = createRuntimeKeyMaterial({ ...config, signingPrivateKeyPath: join(dir, "other.pem"), bootstrapSigningPrivateKeyOnMissing: true });
|
||||
const bad: Array<[string, string | undefined]> = [
|
||||
["missing", undefined],
|
||||
["garbage", "not-a-token"],
|
||||
["expired", signJwt({ ...claims, iat: past - 100, nbf: past - 100, exp: past }, r.keys)],
|
||||
["foreign key", signJwt(claims, other)],
|
||||
["unknown session", signJwt({ ...claims, sid: "no-such-session" }, r.keys)],
|
||||
["wrong account for the session", signJwt({ ...claims, account_id: "someone-else" }, r.keys)],
|
||||
["refresh token as bearer", m.issued.refreshToken],
|
||||
];
|
||||
for (const [label, token] of bad) {
|
||||
const res = await assertStepUp(r, { token, nonce: nonce(label) });
|
||||
expect([label, ...refused(res)]).toEqual([label, 401, "sign_in_required"]);
|
||||
expect(res.body).not.toContain("account");
|
||||
}
|
||||
// A revoked session: the row is closed and its revision moved on.
|
||||
expect(await r.store.revokeSession("bifrost", m.sid, m.account.id, 1, now)).not.toBeNull();
|
||||
const revoked = await assertStepUp(r, { token: m.token, nonce: nonce("revoked") });
|
||||
expect(refused(revoked)).toEqual([401, "sign_in_required"]);
|
||||
expect(r.audit.some((e) => e.eventType === "step_up_asserted")).toBe(false);
|
||||
});
|
||||
|
||||
it("a bearer issued to another app is 403 bearer_app_mismatch, never a proof, and says nothing about the account", async () => {
|
||||
const r = await rig();
|
||||
const sp = await member(r, "streampixels");
|
||||
const res = await assertStepUp(r, { token: sp.token });
|
||||
expect(refused(res)).toEqual([403, "bearer_app_mismatch"]);
|
||||
expect(res.body).not.toContain(sp.account.id);
|
||||
// A token for a session that does not exist is indistinguishable from garbage, whatever app it names.
|
||||
const ghost = signJwt({ ...claimsOf(sp.token, r), sid: "ghost" }, r.keys);
|
||||
expect(refused(await assertStepUp(r, { token: ghost }))).toEqual([401, "sign_in_required"]);
|
||||
});
|
||||
|
||||
it("only a profile that grants it may assert: every other app is 403 with its own secret and a live bearer", async () => {
|
||||
expect(Object.values(builtInAppProfiles).filter((p) => p.stepUpAssertion).map((p) => p.slug)).toEqual(["bifrost"]);
|
||||
const r = await rig();
|
||||
for (const [slug, appSecret] of [["streampixels", "streampixels-secret"], ["repixelizer", "repixelizer-secret"]] as const) {
|
||||
const m = await member(r, slug);
|
||||
const res = await assertStepUp(r, { app: slug, headers: { "x-heimdall-app-secret": appSecret }, token: m.token });
|
||||
expect([slug, ...refused(res)]).toEqual([slug, 403, "step_up_assertion_not_permitted"]);
|
||||
expect(res.json()).not.toHaveProperty("accessToken");
|
||||
}
|
||||
// Another app's secret on bifrost's route is still app_auth_required, not the flag.
|
||||
const b = await member(r);
|
||||
expect(refused(await assertStepUp(r, { headers: { "x-heimdall-app-secret": "streampixels-secret" }, token: b.token }))).toEqual([401, "app_auth_required"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the method and the nonce", () => {
|
||||
it("rejects a method outside the profile's closed set with 400, names the set, and does not spend the nonce", async () => {
|
||||
const r = await rig();
|
||||
const m = await member(r);
|
||||
for (const method of ["passkey", "provider:google", "streampixels-passkey", "BIFROST-PASSKEY", "bifrost-passkey "]) {
|
||||
const res = await assertStepUp(r, { token: m.token, method, nonce: nonce("m") });
|
||||
expect([method, ...refused(res)]).toEqual([method, 400, "unknown_step_up_method"]);
|
||||
expect(res.json().methods).toEqual(["bifrost-passkey", "bifrost-email-code"]);
|
||||
}
|
||||
expect((await assertStepUp(r, { token: m.token, method: "bifrost-email-code", nonce: nonce("m") })).statusCode).toBe(201);
|
||||
});
|
||||
|
||||
it("malformed bodies are 400 before anything is minted", async () => {
|
||||
const r = await rig();
|
||||
const m = await member(r);
|
||||
for (const payload of [{}, { method: "bifrost-passkey" }, { nonce: nonce("a") }, { method: "bifrost-passkey", nonce: "short" }, { method: "bifrost-passkey", nonce: "has a space in it, 16+ chars" }, { method: "bifrost-passkey", nonce: `${"a".repeat(20)}\ud800` }, { method: "bifrost-passkey", nonce: "a".repeat(129) }, { method: "bifrost-passkey", nonce: "ok-looking-nonce-é-ünï" }, { method: 7, nonce: nonce("a") }]) {
|
||||
expect((await assertStepUp(r, { token: m.token, payload })).statusCode, JSON.stringify(payload)).toBe(400);
|
||||
}
|
||||
expect(auditOf(r, "step_up_asserted")).toEqual([]);
|
||||
});
|
||||
|
||||
it("a nonce is single-use per session: the second try is 409, another session may use the same value", async () => {
|
||||
const r = await rig();
|
||||
const m = await member(r);
|
||||
const second = await member(r, "bifrost", [], m.account.id);
|
||||
expect((await assertStepUp(r, { token: m.token })).statusCode).toBe(201);
|
||||
const replay = await assertStepUp(r, { token: m.token });
|
||||
expect(refused(replay)).toEqual([409, "step_up_nonce_replayed"]);
|
||||
expect(replay.json()).not.toHaveProperty("accessToken");
|
||||
// The replay does not depend on which method rides with it.
|
||||
expect(refused(await assertStepUp(r, { token: m.token, method: "bifrost-email-code" }))).toEqual([409, "step_up_nonce_replayed"]);
|
||||
expect((await assertStepUp(r, { token: second.token })).statusCode).toBe(201);
|
||||
});
|
||||
|
||||
it("a nonce is retained to the session's expiry as of the claim plus the 10.5 min prune horizon; a refresh slides the session past that and the nonce can be reused (documented limit)", async () => {
|
||||
const r = await rig();
|
||||
const m = await member(r);
|
||||
vi.useFakeTimers({ toFake: ["Date"] });
|
||||
const t0 = Date.now();
|
||||
const R = config.refreshTtlSeconds * 1000;
|
||||
expect((await assertStepUp(r, { token: m.token, nonce: nonce("N") })).statusCode).toBe(201);
|
||||
vi.setSystemTime(t0 + R - 60_000);
|
||||
const refreshed = await r.app.inject({ method: "POST", url: "/v1/apps/bifrost/sessions/refresh", payload: { refreshToken: m.issued.refreshToken } });
|
||||
expect(refreshed.statusCode).toBe(201);
|
||||
const token = refreshed.json().accessToken as string;
|
||||
expect(refused(await assertStepUp(r, { token, nonce: nonce("N") })).join()).toBe("409,step_up_nonce_replayed");
|
||||
// 10 min past the old expiry: a claim runs the prune, and N is still inside the 10.5 min horizon (10 min margin + 30 s skew).
|
||||
vi.setSystemTime(t0 + R + 10 * 60_000);
|
||||
expect((await assertStepUp(r, { token, nonce: nonce("M") })).statusCode).toBe(201);
|
||||
expect(refused(await assertStepUp(r, { token, nonce: nonce("N") }))).toEqual([409, "step_up_nonce_replayed"]);
|
||||
// Past the horizon the next claim prunes N (lazily, with the claim) while the session lives on.
|
||||
vi.setSystemTime(t0 + R + 10 * 60_000 + 31_000 + 2_000);
|
||||
expect((await assertStepUp(r, { token, nonce: nonce("K") })).statusCode).toBe(201);
|
||||
expect((await assertStepUp(r, { token, nonce: nonce("N") })).statusCode).toBe(201);
|
||||
});
|
||||
|
||||
it("a nonce stays spent for the session's life, past the access token's own expiry, and across a restart of the process", async () => {
|
||||
const r = await rig();
|
||||
const m = await member(r);
|
||||
expect((await assertStepUp(r, { token: m.token })).statusCode).toBe(201);
|
||||
// A second app instance over the same store has no memory of the first: the store carries the claim.
|
||||
const again = await buildApp({ config, store: r.store });
|
||||
apps.push(again);
|
||||
const res = await again.inject({ method: "POST", url: "/v1/apps/bifrost/step-up", headers: { ...appAuth, ...bearer(m.token) }, payload: { method: "bifrost-passkey", nonce: nonce("a") } });
|
||||
expect(refused(res)).toEqual([409, "step_up_nonce_replayed"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rate limits", () => {
|
||||
it("trips per account, leaves other accounts alone, and turns over with the window", async () => {
|
||||
const r = await rig();
|
||||
const m = await member(r);
|
||||
const other = await member(r);
|
||||
vi.useFakeTimers({ toFake: ["Date"] });
|
||||
for (let i = 0; i < STEP_UP_ASSERT_LIMITS.account.max; i += 1) {
|
||||
expect((await assertStepUp(r, { token: m.token, nonce: nonce(`r${i}`) })).statusCode, `attempt ${i}`).toBe(201);
|
||||
}
|
||||
const tripped = await assertStepUp(r, { token: m.token, nonce: nonce("over") });
|
||||
expect(refused(tripped)).toEqual([429, "step_up_rate_limited"]);
|
||||
expect(tripped.json().retryAfterSeconds).toBeGreaterThan(0);
|
||||
expect(tripped.json()).not.toHaveProperty("accessToken");
|
||||
expect((await assertStepUp(r, { token: other.token })).statusCode).toBe(201);
|
||||
vi.setSystemTime(Date.now() + STEP_UP_ASSERT_LIMITS.account.windowMs + 1000);
|
||||
expect((await assertStepUp(r, { token: m.token, nonce: nonce("after") })).statusCode).toBe(201);
|
||||
// The tripped attempt did not spend its nonce.
|
||||
expect((await assertStepUp(r, { token: m.token, nonce: nonce("over") })).statusCode).toBe(201);
|
||||
});
|
||||
|
||||
it("trips per app across accounts, counting attempts that failed their bearer", async () => {
|
||||
const r = await rig();
|
||||
const m = await member(r);
|
||||
vi.useFakeTimers({ toFake: ["Date"] });
|
||||
for (let i = 0; i < STEP_UP_ASSERT_LIMITS.app.max; i += 1) {
|
||||
expect((await assertStepUp(r, { token: "garbage", nonce: nonce(`g${i}`) })).statusCode).toBe(401);
|
||||
}
|
||||
expect(refused(await assertStepUp(r, { token: m.token }))).toEqual([429, "step_up_rate_limited"]);
|
||||
vi.setSystemTime(Date.now() + STEP_UP_ASSERT_LIMITS.app.windowMs + 1000);
|
||||
expect((await assertStepUp(r, { token: m.token })).statusCode).toBe(201);
|
||||
});
|
||||
});
|
||||
|
||||
describe("flood handling", () => {
|
||||
it("the not-permitted budget is per app: one app's flood neither blocks bifrost's own call nor another ungranted app's first refusal", async () => {
|
||||
const r = await rig();
|
||||
const sp = await member(r, "streampixels");
|
||||
const rp = await member(r, "repixelizer");
|
||||
const b = await member(r);
|
||||
const as = (app: string, appSecret: string, token: string, i: number) => assertStepUp(r, { app, headers: { "x-heimdall-app-secret": appSecret }, token, nonce: nonce(`i${i}`) });
|
||||
for (let i = 0; i < 50; i += 1) await as("streampixels", "streampixels-secret", sp.token, i);
|
||||
expect((await as("streampixels", "streampixels-secret", sp.token, 99)).statusCode).toBe(429);
|
||||
expect(refused(await as("repixelizer", "repixelizer-secret", rp.token, 1))).toEqual([403, "step_up_assertion_not_permitted"]);
|
||||
expect((await assertStepUp(r, { token: b.token })).statusCode).toBe(201);
|
||||
});
|
||||
|
||||
it("a flood is not an audit flood: an app without the grant is audited once per window, a tripped budget once per window", async () => {
|
||||
const r = await rig();
|
||||
const sp = await member(r, "streampixels");
|
||||
vi.useFakeTimers({ toFake: ["Date"] });
|
||||
const codes: Record<number, number> = {};
|
||||
for (let i = 0; i < 500; i += 1) {
|
||||
const res = await assertStepUp(r, { app: "streampixels", headers: { "x-heimdall-app-secret": "streampixels-secret" }, token: sp.token, nonce: nonce(`f${i}`) });
|
||||
codes[res.statusCode] = (codes[res.statusCode] ?? 0) + 1;
|
||||
}
|
||||
expect(codes).toEqual({ 403: STEP_UP_ASSERT_LIMITS.notPermitted.max, 429: 500 - STEP_UP_ASSERT_LIMITS.notPermitted.max });
|
||||
const rows = (code: string) => auditOf(r, "step_up_assertion_refused").filter((e) => e.eventPayloadJson.code === code);
|
||||
expect(rows("step_up_assertion_not_permitted")).toHaveLength(1);
|
||||
expect(rows("step_up_rate_limited")).toHaveLength(1);
|
||||
expect(rows("step_up_rate_limited")[0]!.eventPayloadJson.budget).toBe("notPermitted");
|
||||
vi.setSystemTime(Date.now() + STEP_UP_ASSERT_LIMITS.notPermitted.windowMs + 1000);
|
||||
await assertStepUp(r, { app: "streampixels", headers: { "x-heimdall-app-secret": "streampixels-secret" }, token: sp.token });
|
||||
expect(rows("step_up_assertion_not_permitted")).toHaveLength(2);
|
||||
|
||||
// The same for the granted app's per-app and per-account budgets.
|
||||
const m = await member(r);
|
||||
const before = r.audit.length;
|
||||
for (let i = 0; i < 300; i += 1) await assertStepUp(r, { token: "garbage", nonce: nonce(`g${i}`) });
|
||||
expect(r.audit.length - before, "garbage-bearer refusals up to the budget, then one row for the trip").toBe(STEP_UP_ASSERT_LIMITS.app.max + 1);
|
||||
vi.setSystemTime(Date.now() + STEP_UP_ASSERT_LIMITS.app.windowMs + 1000);
|
||||
const mark = r.audit.length;
|
||||
for (let i = 0; i < 30; i += 1) await assertStepUp(r, { token: m.token, nonce: nonce(`a${i}`) });
|
||||
const accountRows = r.audit.slice(mark);
|
||||
expect(accountRows.filter((e) => e.eventType === "step_up_asserted")).toHaveLength(STEP_UP_ASSERT_LIMITS.account.max);
|
||||
const trips = accountRows.filter((e) => e.eventPayloadJson.code === "step_up_rate_limited");
|
||||
expect(trips.map((e) => e.eventPayloadJson.budget)).toEqual(["account"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("receipts", () => {
|
||||
it("records every assertion and every post-secret refusal with account, app, session, method, outcome and time, and nothing secret", async () => {
|
||||
const r = await rig();
|
||||
const m = await member(r);
|
||||
const sp = await member(r, "streampixels");
|
||||
const printed: string[] = [];
|
||||
for (const channel of ["log", "info", "warn", "error", "debug"] as const) vi.spyOn(console, channel).mockImplementation((...a) => void printed.push(a.join(" ")));
|
||||
vi.spyOn(process.stdout, "write").mockImplementation((chunk: unknown) => (printed.push(String(chunk)), true));
|
||||
vi.spyOn(process.stderr, "write").mockImplementation((chunk: unknown) => (printed.push(String(chunk)), true));
|
||||
|
||||
const nonceOk = nonce("ok-CANARY");
|
||||
const nonceBadMethod = nonce("bad-CANARY");
|
||||
const responses = [
|
||||
await assertStepUp(r, { token: m.token, nonce: nonceOk }),
|
||||
await assertStepUp(r, { token: m.token, nonce: nonceOk }),
|
||||
await assertStepUp(r, { token: m.token, method: "evil-CANARY-method", nonce: nonceBadMethod }),
|
||||
await assertStepUp(r, { token: sp.token, nonce: nonce("mismatch-CANARY") }),
|
||||
await assertStepUp(r, { token: "garbage-CANARY", nonce: nonce("anon-CANARY") }),
|
||||
];
|
||||
expect(responses.map((x) => x.statusCode)).toEqual([201, 409, 400, 403, 401]);
|
||||
|
||||
const ok = auditOf(r, "step_up_asserted");
|
||||
expect(ok).toHaveLength(1);
|
||||
expect(ok[0]).toMatchObject({ accountId: m.account.id, sessionId: m.sid, appSlug: "bifrost", eventPayloadJson: { outcome: "asserted", method: "bifrost-passkey" } });
|
||||
expect(Date.parse(ok[0]!.createdAt)).not.toBeNaN();
|
||||
const refusals = auditOf(r, "step_up_assertion_refused");
|
||||
expect(refusals.map((e) => e.eventPayloadJson.code)).toEqual(["step_up_nonce_replayed", "unknown_step_up_method", "bearer_app_mismatch", "sign_in_required"]);
|
||||
expect(refusals.map((e) => e.accountId)).toEqual([m.account.id, m.account.id, sp.account.id, undefined]);
|
||||
expect(refusals[1]!.eventPayloadJson.method).toBeNull();
|
||||
expect(refusals.every((e) => e.appSlug === "bifrost" && e.eventPayloadJson.outcome === "refused")).toBe(true);
|
||||
for (const e of [...ok, ...refusals.slice(0, 2)]) expect(e.eventPayloadJson.nonceDigest).toMatch(/^[0-9a-f]{64}$/);
|
||||
|
||||
const everything = JSON.stringify([r.audit, printed, responses.map((x) => x.body.replace(/"accessToken":"[^"]+"/, ""))]);
|
||||
const minted = responses[0]!.json().accessToken as string;
|
||||
const secrets = [secret, m.token, sp.token, m.issued.refreshToken, "CANARY"];
|
||||
for (const needle of secrets) expect(everything, needle).not.toContain(needle);
|
||||
expect(JSON.stringify(r.audit)).not.toContain(minted);
|
||||
expect(printed.join("")).not.toContain(minted);
|
||||
// A stored session row holds nothing the minted token adds.
|
||||
expect(JSON.stringify(await r.store.findSession("bifrost", m.sid))).not.toContain("step_up");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,209 +0,0 @@
|
|||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { buildApp } from "../src/app.js";
|
||||
import { type OAuthProviderRuntime } from "../src/oauth.js";
|
||||
import { buildAuthorizationUrl, providerCatalog } from "../src/providers.js";
|
||||
import { createRuntimeKeyMaterial, decodeJwt, signJwt, verifyJwt } from "../src/signing.js";
|
||||
import { InMemoryStore } from "../src/store/index.js";
|
||||
import { createTestConfig, launchFlow, launchWithRedirect } from "./oauth-flow.js";
|
||||
|
||||
// A sign_in step-up asks the provider for a real re-authentication. It is the
|
||||
// authenticated app's request (never a browser's), it rides in the signed state
|
||||
// only, it is off unless asked for, and a provider that has no such parameter is
|
||||
// refused at start. These tests drive it as StreamPixels does: its own app
|
||||
// secret, twitch and youtube sign_in starts, then the launch hop and redeem.
|
||||
|
||||
const returnTo = "https://streampixels.gamecult.org/signin/return";
|
||||
const spAuth = { "x-heimdall-app-secret": "streampixels-secret" };
|
||||
|
||||
function runtime(provider: "twitch" | "youtube", providerUserId: string): OAuthProviderRuntime {
|
||||
return {
|
||||
async exchangeAuthorizationCode() {
|
||||
return { accessToken: "at", refreshToken: "rt", tokenType: "Bearer", scope: ["user:read:email"], raw: {} };
|
||||
},
|
||||
async resolveIdentity() {
|
||||
return { provider, providerUserId, username: providerUserId, profile: { id: providerUserId } };
|
||||
},
|
||||
async evaluateEntitlements() {
|
||||
return { facts: [], snapshots: [] };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const apps: Awaited<ReturnType<typeof buildApp>>[] = [];
|
||||
afterEach(async () => {
|
||||
await Promise.all(apps.splice(0).map((app) => app.close()));
|
||||
});
|
||||
|
||||
async function rig(config = createTestConfig()) {
|
||||
const app = await buildApp({
|
||||
config,
|
||||
store: new InMemoryStore(),
|
||||
oauthRuntimes: { twitch: runtime("twitch", "t-1"), youtube: runtime("youtube", "y-1") },
|
||||
});
|
||||
apps.push(app);
|
||||
return app;
|
||||
}
|
||||
|
||||
type App = Awaited<ReturnType<typeof rig>>;
|
||||
|
||||
function start(
|
||||
app: App,
|
||||
provider: string,
|
||||
payload: Record<string, unknown> = {},
|
||||
headers: Record<string, string> = {},
|
||||
appSlug = "streampixels",
|
||||
url = returnTo
|
||||
) {
|
||||
return app.inject({ method: "POST", url: `/v1/oauth/${provider}/start`, headers, payload: { appSlug, mode: "sign_in", returnTo: url, ...payload } });
|
||||
}
|
||||
|
||||
describe("step-up on a sign_in start: the provider URL", () => {
|
||||
it("asks nothing extra without stepUp, for every provider StreamPixels signs in with", async () => {
|
||||
const app = await rig();
|
||||
for (const provider of ["twitch", "youtube"]) {
|
||||
for (const extra of [{}, { stepUp: false }]) {
|
||||
const started = await start(app, provider, extra);
|
||||
expect(started.statusCode).toBe(201);
|
||||
expect(decodeJwt(new URL(started.json().authorizationUrl).searchParams.get("state") ?? "").payload.step_up).toBeNull();
|
||||
const url = (await launchWithRedirect(app, started.json())).url;
|
||||
expect(url.searchParams.has("force_verify")).toBe(false);
|
||||
expect(url.searchParams.get("prompt")).toBe(provider === "youtube" ? "consent" : null);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("with stepUp and the app secret: twitch force_verify=true, youtube prompt=login consent, state records it", async () => {
|
||||
const app = await rig();
|
||||
const twitch = await start(app, "twitch", { stepUp: true }, spAuth);
|
||||
expect(twitch.statusCode).toBe(201);
|
||||
expect(decodeJwt(new URL(twitch.json().authorizationUrl).searchParams.get("state") ?? "").payload.step_up).toBe(true);
|
||||
expect((await launchWithRedirect(app, twitch.json())).url.searchParams.get("force_verify")).toBe("true");
|
||||
|
||||
const youtube = await start(app, "youtube", { stepUp: true }, spAuth);
|
||||
expect(youtube.statusCode).toBe(201);
|
||||
expect((await launchWithRedirect(app, youtube.json())).url.searchParams.get("prompt")).toBe("login consent");
|
||||
});
|
||||
|
||||
it("Google (a Bifrost sign_in provider) gets prompt=login in place of select_account", async () => {
|
||||
const app = await rig();
|
||||
const bifrostReturn = "https://bifrost.gamecult.org/auth/heimdall/return";
|
||||
const plain = await start(app, "google", {}, { "x-heimdall-app-secret": "bifrost-secret" }, "bifrost", bifrostReturn);
|
||||
expect((await launchWithRedirect(app, plain.json())).url.searchParams.get("prompt")).toBe("select_account");
|
||||
const stepped = await start(app, "google", { stepUp: true }, { "x-heimdall-app-secret": "bifrost-secret" }, "bifrost", bifrostReturn);
|
||||
expect(stepped.statusCode).toBe(201);
|
||||
expect((await launchWithRedirect(app, stepped.json())).url.searchParams.get("prompt")).toBe("login");
|
||||
});
|
||||
});
|
||||
|
||||
describe("step-up refusals", () => {
|
||||
it("is the app's request: no secret or a wrong secret is 401 app_auth_required, while the same start without stepUp stays open", async () => {
|
||||
const app = await rig();
|
||||
for (const headers of [{}, { "x-heimdall-app-secret": "wrong" }]) {
|
||||
const res = await start(app, "twitch", { stepUp: true }, headers);
|
||||
expect([res.statusCode, res.json().error]).toEqual([401, "app_auth_required"]);
|
||||
}
|
||||
expect((await start(app, "twitch", {}, {})).statusCode).toBe(201);
|
||||
});
|
||||
|
||||
it("a provider with no re-authentication parameter is a typed refusal, with the secret, never a silent pass", async () => {
|
||||
const app = await rig();
|
||||
const bifrost = { "x-heimdall-app-secret": "bifrost-secret" };
|
||||
const res = await start(app, "discord", { stepUp: true }, bifrost, "bifrost", "https://bifrost.gamecult.org/auth/heimdall/return");
|
||||
expect([res.statusCode, res.json().error, res.json().provider]).toEqual([400, "step_up_not_supported", "discord"]);
|
||||
// The URL builder holds the same line: it throws rather than omit the step.
|
||||
for (const provider of ["discord", "patreon", "github", "spotify", "joystick"] as const) {
|
||||
expect(providerCatalog[provider].stepUpAuthorizationParams, provider).toBeUndefined();
|
||||
expect(() =>
|
||||
buildAuthorizationUrl({ provider, clientId: "c", redirectUri: "https://x/cb", state: "s", requestedScopes: undefined, stepUp: true })
|
||||
).toThrow(/no re-authentication parameter/);
|
||||
}
|
||||
expect(Object.keys(providerCatalog).filter((p) => providerCatalog[p as keyof typeof providerCatalog].stepUpAuthorizationParams).sort()).toEqual(["google", "twitch", "youtube"]);
|
||||
});
|
||||
|
||||
it("is sign_in only: link and connect starts carrying stepUp are 400 step_up_only_in_sign_in", async () => {
|
||||
const app = await rig();
|
||||
for (const mode of ["link", "connect"]) {
|
||||
const res = await start(app, "twitch", { mode, stepUp: true }, spAuth);
|
||||
expect([res.statusCode, res.json().error]).toEqual([400, "step_up_only_in_sign_in"]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("the flag is the signed state's, not the redeem side's", () => {
|
||||
it("a completion reports stepUpRequested only when the start asked, and a stepUp handed to redeem changes nothing", async () => {
|
||||
const app = await rig();
|
||||
for (const asked of [true, false]) {
|
||||
const started = await start(app, "twitch", asked ? { stepUp: true } : {}, asked ? spAuth : {});
|
||||
const flow = await launchFlow(app, started.json());
|
||||
const called = await app.inject({
|
||||
method: "GET",
|
||||
url: `/v1/oauth/twitch/callback?code=c&state=${encodeURIComponent(flow.state)}`,
|
||||
headers: { cookie: flow.cookie, accept: "application/json" },
|
||||
});
|
||||
expect(called.statusCode).toBe(201);
|
||||
const code = called.json().completion.code as string;
|
||||
|
||||
// A stepUp handed to redeem is not read: the answer reflects the start, whatever the body says.
|
||||
const redeemed = await app.inject({ method: "POST", url: "/v1/apps/streampixels/auth-completions/redeem", payload: { completionCode: code, stepUp: true } });
|
||||
expect(redeemed.statusCode).toBe(201);
|
||||
expect(redeemed.json().stepUpRequested).toBe(asked ? true : undefined);
|
||||
// The proof is the signed state's too: the claim carries step_up_at exactly when the start asked.
|
||||
expect(typeof redeemed.json().claimSet.step_up_at).toBe(asked ? "number" : "undefined");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("a forged or odd state cannot carry a step-up the start would not have signed", () => {
|
||||
// A real signing key on disk, so the test can re-sign a state as only Heimdall's key holder could.
|
||||
const dir = mkdtempSync(join(tmpdir(), "heimdall-stepup-"));
|
||||
const config = { ...createTestConfig(), signingPrivateKeyPath: join(dir, "signing.pem"), bootstrapSigningPrivateKeyOnMissing: true };
|
||||
// The app creates the key on first boot; read it only after the rig exists.
|
||||
const loadKeys = (c: typeof config) => createRuntimeKeyMaterial({ ...c, bootstrapSigningPrivateKeyOnMissing: false });
|
||||
|
||||
async function forgedState(app: App, provider: string, headers: Record<string, string>, stepUp: unknown, appSlug = "streampixels", url = returnTo) {
|
||||
const started = await start(app, provider, {}, headers, appSlug, url);
|
||||
const real = new URL(started.json().authorizationUrl).searchParams.get("state") ?? "";
|
||||
const keys = loadKeys(config);
|
||||
const verified = verifyJwt(real, keys.publicKey) as { valid: boolean; payload: Record<string, unknown> };
|
||||
expect(verified.valid, "the test key signs what the app verifies").toBe(true);
|
||||
return { real, forged: signJwt({ ...verified.payload, step_up: stepUp }, keys) };
|
||||
}
|
||||
const launch = (app: App, provider: string, state: string) => app.inject({ method: "GET", url: `/v1/oauth/${provider}/launch?state=${encodeURIComponent(state)}` });
|
||||
|
||||
it("rejects any step_up that is not null or true as invalid_state at the launch hop", async () => {
|
||||
const app = await rig(config);
|
||||
expect((await launch(app, "twitch", (await forgedState(app, "twitch", {}, true)).forged)).statusCode, "a well-formed true still launches").toBe(302);
|
||||
for (const odd of ["true", "yes", 1, 0, false, {}, []]) {
|
||||
const res = await launch(app, "twitch", (await forgedState(app, "twitch", {}, odd)).forged);
|
||||
expect([res.statusCode, res.json().error], JSON.stringify(odd)).toEqual([400, "invalid_state"]);
|
||||
}
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("a step-up state for a provider with no re-authentication parameter fails closed and does not burn the state", async () => {
|
||||
const dir2 = mkdtempSync(join(tmpdir(), "heimdall-stepup-"));
|
||||
const config2 = { ...createTestConfig(), signingPrivateKeyPath: join(dir2, "signing.pem"), bootstrapSigningPrivateKeyOnMissing: true };
|
||||
try {
|
||||
const app = await rig(config2);
|
||||
const bifrost = { "x-heimdall-app-secret": "bifrost-secret" };
|
||||
const started = await start(app, "discord", {}, bifrost, "bifrost", "https://bifrost.gamecult.org/auth/heimdall/return");
|
||||
const real = new URL(started.json().authorizationUrl).searchParams.get("state") ?? "";
|
||||
const keys2 = loadKeys(config2);
|
||||
const verified = verifyJwt(real, keys2.publicKey) as { valid: boolean; payload: Record<string, unknown> };
|
||||
expect(verified.valid).toBe(true);
|
||||
const forged = signJwt({ ...verified.payload, step_up: true }, keys2);
|
||||
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||
const res = await launch(app, "discord", forged);
|
||||
expect(res.statusCode, "never a redirect without the step").toBe(500);
|
||||
expect(res.headers.location).toBeUndefined();
|
||||
}
|
||||
// Not "state_already_launched": the refused launches did not claim the state.
|
||||
expect((await launch(app, "discord", real)).statusCode).toBe(302);
|
||||
} finally {
|
||||
rmSync(dir2, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -3,7 +3,7 @@ import { Pool } from "pg";
|
|||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import { newDb } from "pg-mem";
|
||||
import { type HeimdallConfig } from "../src/config.js";
|
||||
import { createStore, IdentityHeldElsewhereError, InMemoryStore, type HeimdallStore } from "../src/store/index.js";
|
||||
import { createStore, InMemoryStore } from "../src/store/index.js";
|
||||
import {
|
||||
createPostgresStore,
|
||||
PostgresStore,
|
||||
|
|
@ -14,11 +14,6 @@ import {
|
|||
samePredicate,
|
||||
STORE_TABLE_PRIVILEGES,
|
||||
} from "../src/store/postgres.js";
|
||||
import {
|
||||
OAUTH_STATE_PRUNE_BATCH,
|
||||
OAUTH_STATE_PRUNE_MARGIN_MS,
|
||||
OAUTH_STATE_SKEW_MS,
|
||||
} from "../src/store/types.js";
|
||||
import { CREATE_SCHEMA_SQL, REQUIRED_COLUMNS, REQUIRED_KEYS, REQUIRED_TABLES } from "../src/store/schema.js";
|
||||
|
||||
describe("PostgresStore", () => {
|
||||
|
|
@ -203,88 +198,6 @@ describe("PostgresStore", () => {
|
|||
});
|
||||
});
|
||||
|
||||
// linked_identities has one writer that can move a row between accounts: none.
|
||||
// upsert refreshes the holder's own row and refuses another account's; unlink is
|
||||
// the only delete, and it never takes an account's last identity.
|
||||
const unlinkStores: Array<[string, () => Promise<HeimdallStore>]> = [
|
||||
["InMemoryStore", async () => new InMemoryStore()],
|
||||
[
|
||||
"PostgresStore on pg-mem",
|
||||
async () => {
|
||||
const store = new PostgresStore(new (newDb().adapters.createPg().Pool)());
|
||||
await store.ensureSchema();
|
||||
return store;
|
||||
},
|
||||
],
|
||||
];
|
||||
describe.each(unlinkStores)("linked_identities writes, %s", (_name, make) => {
|
||||
const at = "2026-11-13T12:00:00.000Z";
|
||||
const input = (accountId: string, providerUserId: string, extra: Record<string, unknown> = {}) => ({
|
||||
accountId,
|
||||
provider: "discord" as const,
|
||||
providerUserId,
|
||||
scopes: ["identify"],
|
||||
profileJson: {},
|
||||
createdAt: at,
|
||||
updatedAt: at,
|
||||
...extra,
|
||||
});
|
||||
async function twoAccounts() {
|
||||
const store = await make();
|
||||
const a = await store.createAccount({ createdAt: at, lastSeenAt: at });
|
||||
const b = await store.createAccount({ createdAt: at, lastSeenAt: at });
|
||||
return { store, a, b };
|
||||
}
|
||||
|
||||
// pg-mem has neither ON CONFLICT ... WHERE nor row locks: these two run on real Postgres below.
|
||||
const realPostgresOnly = _name !== "InMemoryStore";
|
||||
|
||||
it.skipIf(realPostgresOnly)("upsert on an identity another account holds throws IdentityHeldElsewhereError and leaves the row untouched", async () => {
|
||||
const { store, a, b } = await twoAccounts();
|
||||
const held = await store.upsertLinkedIdentity(input(a.id, "u1", { username: "original", accessTokenEncrypted: "keep" }));
|
||||
await expect(
|
||||
store.upsertLinkedIdentity(input(b.id, "u1", { username: "thief", accessTokenEncrypted: "stolen", updatedAt: "2027-01-01T00:00:00.000Z" }))
|
||||
).rejects.toBeInstanceOf(IdentityHeldElsewhereError);
|
||||
expect(await store.findStoredLinkedIdentity("discord", "u1")).toEqual(held);
|
||||
expect(await store.listStoredLinkedIdentitiesForAccount(b.id)).toEqual([]);
|
||||
});
|
||||
|
||||
it.skipIf(realPostgresOnly)("moveIdentities keeps one contract: moved, replayed, conflict, lapsed proof, coverage, reversal", async () => {
|
||||
await exerciseMoveContract(await make());
|
||||
});
|
||||
|
||||
it("upsert by the holder still refreshes its own row, keeping its id", async () => {
|
||||
const { store, a } = await twoAccounts();
|
||||
const first = await store.upsertLinkedIdentity(input(a.id, "u1", { username: "one" }));
|
||||
const again = await store.upsertLinkedIdentity(input(a.id, "u1", { username: "two", updatedAt: "2026-11-14T00:00:00.000Z" }));
|
||||
expect([again.id, again.accountId, again.username]).toEqual([first.id, a.id, "two"]);
|
||||
});
|
||||
|
||||
it("unlink: deletes one of two, refuses the last, reports another account's or a missing id as not_found", async () => {
|
||||
const { store, a, b } = await twoAccounts();
|
||||
const one = await store.upsertLinkedIdentity(input(a.id, "u1"));
|
||||
const two = await store.upsertLinkedIdentity(input(a.id, "u2"));
|
||||
const theirs = await store.upsertLinkedIdentity(input(b.id, "u3"));
|
||||
expect(await store.unlinkLinkedIdentity(a.id, theirs.id)).toEqual({ kind: "not_found" });
|
||||
expect(await store.unlinkLinkedIdentity(a.id, "no-such")).toEqual({ kind: "not_found" });
|
||||
expect(await store.unlinkLinkedIdentity(b.id, theirs.id)).toEqual({ kind: "last_identity" });
|
||||
const done = await store.unlinkLinkedIdentity(a.id, one.id);
|
||||
expect(done).toMatchObject({ kind: "unlinked", identity: { id: one.id, provider: "discord", providerUserId: "u1" } });
|
||||
expect(await store.unlinkLinkedIdentity(a.id, two.id)).toEqual({ kind: "last_identity" });
|
||||
expect((await store.listStoredLinkedIdentitiesForAccount(a.id)).map((i) => i.id)).toEqual([two.id]);
|
||||
expect(await store.findStoredLinkedIdentity("discord", "u3")).toMatchObject({ id: theirs.id, accountId: b.id });
|
||||
});
|
||||
|
||||
it.skipIf(realPostgresOnly)("two concurrent unlinks of an account's last two identities leave exactly one", async () => {
|
||||
const { store, a } = await twoAccounts();
|
||||
const one = await store.upsertLinkedIdentity(input(a.id, "u1"));
|
||||
const two = await store.upsertLinkedIdentity(input(a.id, "u2"));
|
||||
const results = await Promise.all([store.unlinkLinkedIdentity(a.id, one.id), store.unlinkLinkedIdentity(a.id, two.id)]);
|
||||
expect(results.map((r) => r.kind).sort()).toEqual(["last_identity", "unlinked"]);
|
||||
expect(await store.listStoredLinkedIdentitiesForAccount(a.id)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("consent_receipts foreign key", () => {
|
||||
it("a revocation names a receipt of its own account: the schema says so and the memory store enforces it", async () => {
|
||||
expect(CREATE_SCHEMA_SQL).toContain("FOREIGN KEY (revokes_receipt_id, account_id) REFERENCES consent_receipts(id, account_id)");
|
||||
|
|
@ -363,82 +276,19 @@ describe("InMemoryStore.claimOAuthStateUse", () => {
|
|||
expect(await store.claimOAuthStateUse({ ...use, jti: "state-2" })).toBe(true);
|
||||
});
|
||||
|
||||
it("drops a record only once its state's exp + skew + margin has passed", async () => {
|
||||
it("drops a record once its state's exp + 30 s skew has passed, and keeps it until then", async () => {
|
||||
const store = new InMemoryStore();
|
||||
await store.claimOAuthStateUse(use);
|
||||
const expMs = Date.parse(use.expiresAt);
|
||||
const at = (ms: number) => new Date(ms).toISOString();
|
||||
// Inside the skew past expiry the state still verifies, and the margin keeps the record beyond that.
|
||||
await store.claimOAuthStateUse({ ...use, jti: "state-2", usedAt: at(expMs + OAUTH_STATE_SKEW_MS) });
|
||||
// Before expiry a later claim of another state leaves the record in force.
|
||||
await store.claimOAuthStateUse({ ...use, jti: "state-2", usedAt: "2026-04-26T12:09:59.000Z" });
|
||||
expect(await store.claimOAuthStateUse(use)).toBe(false);
|
||||
const boundary = expMs + OAUTH_STATE_SKEW_MS + OAUTH_STATE_PRUNE_MARGIN_MS;
|
||||
await store.claimOAuthStateUse({ ...use, jti: "state-3", usedAt: at(boundary - 1) });
|
||||
// Inside the 30 s skew past expiry the state still verifies, so the record still refuses a replay.
|
||||
await store.claimOAuthStateUse({ ...use, jti: "state-3", usedAt: "2026-04-26T12:10:29.999Z" });
|
||||
expect(await store.claimOAuthStateUse(use)).toBe(false);
|
||||
// At exactly exp + skew + margin the record is prunable (the rule is <=).
|
||||
await store.claimOAuthStateUse({ ...use, jti: "state-4", usedAt: at(boundary) });
|
||||
// At exp + 30 s the state is refused before any claim and the record is dead weight.
|
||||
await store.claimOAuthStateUse({ ...use, jti: "state-4", usedAt: "2026-04-26T12:10:30.000Z" });
|
||||
expect(await store.claimOAuthStateUse(use)).toBe(true);
|
||||
});
|
||||
|
||||
it("removes at most one batch of prunable records per successful claim", async () => {
|
||||
const store = new InMemoryStore();
|
||||
const expMs = Date.parse(use.expiresAt);
|
||||
const total = OAUTH_STATE_PRUNE_BATCH * 2 + 7;
|
||||
for (let i = 0; i < total; i += 1) await store.claimOAuthStateUse({ ...use, jti: `old-${i}` });
|
||||
const size = () => (store as unknown as { oauthStateUses: Map<string, string> }).oauthStateUses.size;
|
||||
expect(size()).toBe(total);
|
||||
const later = new Date(expMs + OAUTH_STATE_SKEW_MS + OAUTH_STATE_PRUNE_MARGIN_MS).toISOString();
|
||||
await store.claimOAuthStateUse({ ...use, jti: "fresh-1", usedAt: later, expiresAt: later });
|
||||
expect(size()).toBe(total + 1 - OAUTH_STATE_PRUNE_BATCH);
|
||||
// A refused (replayed) claim prunes nothing, as on Postgres where the delete follows a new row.
|
||||
await store.claimOAuthStateUse({ ...use, jti: "fresh-1", usedAt: later, expiresAt: later });
|
||||
expect(size()).toBe(total + 1 - OAUTH_STATE_PRUNE_BATCH);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PostgresStore.claimOAuthStateUse retention", () => {
|
||||
const use = {
|
||||
jti: "state-1",
|
||||
phase: "launch" as const,
|
||||
appSlug: "repixelizer" as const,
|
||||
usedAt: "2026-04-26T12:00:00.000Z",
|
||||
expiresAt: "2026-04-26T12:10:00.000Z",
|
||||
};
|
||||
|
||||
function doublePool(prune: "ok" | "throws", inserted = 1) {
|
||||
const calls: Array<{ sql: string; params: unknown[] }> = [];
|
||||
const pool = {
|
||||
query: async (sql: string, params: unknown[]) => {
|
||||
calls.push({ sql, params });
|
||||
if (calls.length === 1) return { rowCount: inserted, rows: [] };
|
||||
if (prune === "throws") throw Object.assign(new Error("permission denied for table oauth_state_uses"), { code: "42501" });
|
||||
return { rowCount: 0, rows: [] };
|
||||
},
|
||||
};
|
||||
return { calls, store: new PostgresStore(pool as unknown as Pool) };
|
||||
}
|
||||
|
||||
it("runs one bounded delete after a new row, at the shared cutoff and batch", async () => {
|
||||
const { calls, store } = doublePool("ok");
|
||||
expect(await store.claimOAuthStateUse(use)).toBe(true);
|
||||
expect(calls).toHaveLength(2);
|
||||
expect(calls[1]!.sql).toMatch(/DELETE FROM oauth_state_uses/);
|
||||
expect(calls[1]!.params).toEqual([
|
||||
new Date(Date.parse(use.usedAt) - OAUTH_STATE_SKEW_MS - OAUTH_STATE_PRUNE_MARGIN_MS),
|
||||
OAUTH_STATE_PRUNE_BATCH,
|
||||
]);
|
||||
});
|
||||
|
||||
it("prunes nothing when the claim is a replay", async () => {
|
||||
const { calls, store } = doublePool("ok", 0);
|
||||
expect(await store.claimOAuthStateUse(use)).toBe(false);
|
||||
expect(calls).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("a failing prune does not change the claim's result", async () => {
|
||||
const { calls, store } = doublePool("throws");
|
||||
expect(await store.claimOAuthStateUse(use)).toBe(true);
|
||||
expect(calls).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createPostgresStore", () => {
|
||||
|
|
@ -481,10 +331,9 @@ describe("PostgresStore.checkSchema", () => {
|
|||
"capability_grants",
|
||||
"entitlement_snapshots",
|
||||
"consent_receipts",
|
||||
"identity_moves",
|
||||
"audit_events",
|
||||
]);
|
||||
expect(REQUIRED_COLUMNS).toHaveLength(121);
|
||||
expect(REQUIRED_COLUMNS).toHaveLength(112);
|
||||
expect(REQUIRED_COLUMNS).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ table: "auth_completions", column: "code", type: "TEXT", nullable: false },
|
||||
|
|
@ -495,7 +344,7 @@ describe("PostgresStore.checkSchema", () => {
|
|||
{ table: "auth_completions", column: "attempt_id", type: "TEXT", nullable: true },
|
||||
])
|
||||
);
|
||||
expect(REQUIRED_KEYS).toHaveLength(19);
|
||||
expect(REQUIRED_KEYS).toHaveLength(17);
|
||||
expect(REQUIRED_KEYS).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ table: "linked_identities", columns: "provider,provider_user_id", predicate: "" },
|
||||
|
|
@ -503,7 +352,6 @@ describe("PostgresStore.checkSchema", () => {
|
|||
{ table: "oauth_state_uses", columns: "jti,phase", predicate: "" },
|
||||
{ table: "consent_receipts", columns: "revokes_receipt_id", predicate: "revokes_receipt_id IS NOT NULL" },
|
||||
{ table: "consent_receipts", columns: "account_id,id", predicate: "" },
|
||||
{ table: "identity_moves", columns: "reverses_move_id", predicate: "reverses_move_id IS NOT NULL" },
|
||||
{ table: "entitlement_snapshots", columns: "account_id,provider,scope", predicate: "" },
|
||||
{ table: "auth_completions", columns: "app_slug,attempt_id", predicate: "attempt_id IS NOT NULL AND consumed_at IS NULL" },
|
||||
])
|
||||
|
|
@ -580,8 +428,6 @@ async function exerciseStore(store: PostgresStore): Promise<Set<string>> {
|
|||
const linked = await s.upsertLinkedIdentity(identity);
|
||||
await s.updateLinkedIdentityCredential(linked.id, async () => ({ kind: "keep" }));
|
||||
await s.upsertLinkedIdentity({ ...identity, username: "again" });
|
||||
const spare = await s.upsertLinkedIdentity({ ...identity, providerUserId: "u-spare" });
|
||||
expect((await s.unlinkLinkedIdentity(account.id, spare.id)).kind).toBe("unlinked");
|
||||
await s.findAccountByLinkedIdentity("discord", "u1");
|
||||
await s.findStoredLinkedIdentity("discord", "u1");
|
||||
await s.findStoredLinkedIdentityForAccount(account.id, "discord");
|
||||
|
|
@ -635,15 +481,6 @@ async function exerciseStore(store: PostgresStore): Promise<Set<string>> {
|
|||
expect(await s.claimOAuthStateUse(stateUse)).toBe(true);
|
||||
expect(await s.claimOAuthStateUse(stateUse)).toBe(false);
|
||||
expect(await s.claimOAuthStateUse({ ...stateUse, phase: "callback" })).toBe(true);
|
||||
// The prune is swallowed when the role may not run it, so the exercise proves it ran: a long-expired
|
||||
// record is gone after the next new claim. A missing prune stands for the privilege refusal behind it.
|
||||
const stale = { ...stateUse, jti: randomUUID(), usedAt: "2000-01-01T00:00:00.000Z", expiresAt: "2000-01-01T00:10:00.000Z" };
|
||||
expect(await s.claimOAuthStateUse(stale)).toBe(true);
|
||||
expect(await s.claimOAuthStateUse({ ...stateUse, jti: randomUUID() })).toBe(true);
|
||||
const retained = await s.claimOAuthStateUse(stale);
|
||||
if (!retained) {
|
||||
throw Object.assign(new Error("oauth_state_uses was not pruned"), { code: "42501" });
|
||||
}
|
||||
const { receipt: grant } = await s.createConsentReceipt({ kind: "grant", accountId: account.id, appSlug: "streampixels", provider: "joystick", createdAt: now, scopesAsked: ["a", "b"], scopesGranted: ["a"], consentPageDigest: "d", consentText: "t" });
|
||||
const revocation = { kind: "revocation" as const, accountId: account.id, appSlug: "streampixels" as const, provider: "joystick" as const, createdAt: later, revokesReceiptId: grant.id };
|
||||
const first = await s.createConsentReceipt(revocation);
|
||||
|
|
@ -654,178 +491,9 @@ async function exerciseStore(store: PostgresStore): Promise<Set<string>> {
|
|||
expect(second.receipt.id).toBe(first.receipt.id);
|
||||
expect((await s.listConsentReceipts(account.id)).map((r) => r.kind)).toEqual(["grant", "revocation"]);
|
||||
await s.createAuditEvent({ eventType: "probe", eventPayloadJson: {}, createdAt: now });
|
||||
const target = await s.createAccount({ createdAt: now, lastSeenAt: now });
|
||||
await s.upsertLinkedIdentity({ ...identity, accountId: target.id, provider: "patreon", providerUserId: "u-target" });
|
||||
const moveId = `move-${randomUUID()}`;
|
||||
expect((await s.moveIdentities({ moveId, appSlug: "bifrost", fromAccountId: account.id, toAccountId: target.id, identityIds: "all", covers: "account", proofSessions: [], proof: { from: [], to: null }, at: now })).kind).toBe("moved");
|
||||
expect((await s.findIdentityMove(moveId))?.id).toBe(moveId);
|
||||
return called;
|
||||
}
|
||||
|
||||
/**
|
||||
* The move contract both stores keep, once. Each case builds its own accounts,
|
||||
* so the order of cases proves nothing and a store holding earlier moves is fine.
|
||||
*/
|
||||
async function exerciseMoveContract(store: HeimdallStore): Promise<void> {
|
||||
const at = "2026-11-14T12:00:00.000Z";
|
||||
const later = "2999-01-01T00:00:00.000Z";
|
||||
const proofTiers = { from: [{ tier: "strong" as const, method: "bifrost-passkey", identityId: null }], to: { tier: "weak" as const, method: null, identityId: null } };
|
||||
const side = async (providers: Array<"discord" | "google" | "patreon">) => {
|
||||
const account = await store.createAccount({ createdAt: at, lastSeenAt: at });
|
||||
const rows = [];
|
||||
for (const provider of providers) {
|
||||
rows.push(await store.upsertLinkedIdentity({ accountId: account.id, provider, providerUserId: `${provider}-${randomUUID()}`, accessTokenEncrypted: "A", refreshTokenEncrypted: "R", scopes: [], profileJson: {}, createdAt: at, updatedAt: at }));
|
||||
}
|
||||
const sessionId = randomUUID();
|
||||
await store.createSession({ id: sessionId, accountId: account.id, appSlug: "bifrost", createdAt: at, lastSeenAt: at, expiresAt: later, accessRevision: 1, claimsJson: {} });
|
||||
return { account, rows, proofSession: { appSlug: "bifrost" as const, sessionId, accountId: account.id, accessRevision: 1 } };
|
||||
};
|
||||
const input = (from: Awaited<ReturnType<typeof side>>, to: Awaited<ReturnType<typeof side>>, extra: Record<string, unknown> = {}) => ({
|
||||
moveId: `move-${randomUUID()}`,
|
||||
appSlug: "bifrost" as const,
|
||||
fromAccountId: from.account.id,
|
||||
toAccountId: to.account.id,
|
||||
identityIds: "all" as const,
|
||||
covers: "account" as const,
|
||||
proofSessions: [from.proofSession, to.proofSession],
|
||||
proof: proofTiers,
|
||||
at,
|
||||
...extra,
|
||||
});
|
||||
const held = async (a: { account: { id: string } }) => (await store.listStoredLinkedIdentitiesForAccount(a.account.id)).map((i) => i.id).sort();
|
||||
|
||||
// Moved: rows, credentials, sessions and the record.
|
||||
const a = await side(["discord", "google"]);
|
||||
const b = await side(["patreon"]);
|
||||
const first = input(a, b);
|
||||
const moved = await store.moveIdentities(first);
|
||||
expect(moved.kind).toBe("moved");
|
||||
expect(await held(a)).toEqual([]);
|
||||
expect(await held(b)).toEqual([...a.rows.map((r) => r.id), ...b.rows.map((r) => r.id)].sort());
|
||||
for (const row of await store.listStoredLinkedIdentitiesForAccount(b.account.id)) {
|
||||
if (a.rows.some((r) => r.id === row.id)) expect([row.accessTokenEncrypted, row.refreshTokenEncrypted]).toEqual([undefined, undefined]);
|
||||
else expect(row.accessTokenEncrypted, "the destination's own credential is untouched").toBe("A");
|
||||
}
|
||||
const record = await store.findIdentityMove(first.moveId);
|
||||
expect(record).toMatchObject({ id: first.moveId, fromAccountId: a.account.id, toAccountId: b.account.id, proof: proofTiers, createdAt: at });
|
||||
expect(record!.moved.map((m) => m.credentialStripped)).toEqual([true, true]);
|
||||
expect((await store.findSession("bifrost", a.proofSession.sessionId))!.accessRevision).toBe(2);
|
||||
expect((await store.findSession("bifrost", b.proofSession.sessionId))!.accessRevision).toBe(1);
|
||||
|
||||
// Replay answers from the row, even though the proof session is gone; a different pair is a conflict.
|
||||
expect(await store.moveIdentities(first)).toEqual({ kind: "replayed", move: record });
|
||||
const c = await side(["discord"]);
|
||||
expect(await store.moveIdentities({ ...first, toAccountId: c.account.id })).toEqual({ kind: "move_id_conflict" });
|
||||
expect(await store.moveIdentities({ ...first, reversesMoveId: "x" })).toEqual({ kind: "move_id_conflict" });
|
||||
|
||||
// Into or out of an alias.
|
||||
expect((await store.moveIdentities(input(a, c, { proofSessions: [c.proofSession] }))).kind).toBe("already_moved");
|
||||
expect((await store.moveIdentities(input(c, a, { proofSessions: [c.proofSession] }))).kind).toBe("already_moved");
|
||||
|
||||
// A proof session that was revoked, or has moved on a revision, has lapsed.
|
||||
const d = await side(["google"]);
|
||||
const e = await side(["patreon"]);
|
||||
await store.revokeSession("bifrost", e.proofSession.sessionId, e.account.id, 1, at);
|
||||
expect((await store.moveIdentities(input(d, e))).kind).toBe("proof_lapsed");
|
||||
expect(await held(d)).toHaveLength(1);
|
||||
|
||||
// Coverage and conflict.
|
||||
const f = await side(["discord", "google"]);
|
||||
const g = await side(["patreon"]);
|
||||
expect((await store.moveIdentities(input(f, g, { covers: [f.rows[0]!.id] }))).kind).toBe("identity_not_proved");
|
||||
expect((await store.moveIdentities(input(f, g, { identityIds: ["nope"] }))).kind).toBe("identity_not_found");
|
||||
const h = await side(["discord"]);
|
||||
expect(await store.moveIdentities(input(f, h))).toEqual({ kind: "provider_conflict", provider: "discord" });
|
||||
expect(await held(f)).toHaveLength(2);
|
||||
const onlyDiscord = await store.moveIdentities(input(f, g, { identityIds: [f.rows[0]!.id], covers: [f.rows[0]!.id] }));
|
||||
expect(onlyDiscord.kind).toBe("moved");
|
||||
expect(await held(f)).toEqual([f.rows[1]!.id]);
|
||||
|
||||
// Reversal: moves exactly what the forward carried, once, and a reversal is not itself reversible.
|
||||
const forward = (moved as { move: { id: string } }).move;
|
||||
const back = input(b, a, { reversesMoveId: forward.id, proofSessions: [], identityIds: a.rows.map((r) => r.id), covers: a.rows.map((r) => r.id) });
|
||||
const reversed = await store.moveIdentities(back);
|
||||
expect(reversed.kind).toBe("moved");
|
||||
expect(await held(a)).toEqual(a.rows.map((r) => r.id).sort());
|
||||
expect(await held(b)).toEqual(b.rows.map((r) => r.id));
|
||||
expect((await store.moveIdentities({ ...back, moveId: `move-${randomUUID()}` })).kind).toBe("not_reversible");
|
||||
expect((await store.moveIdentities(input(a, b, { reversesMoveId: back.moveId, proofSessions: [], identityIds: "all" }))).kind).toBe("not_reversible");
|
||||
|
||||
// A moveId answers only the move that used it: each of app, source, destination and reversal must match.
|
||||
const k = await side(["discord"]);
|
||||
const l = await side(["patreon"]);
|
||||
const m = await side(["google"]);
|
||||
const once = input(k, l);
|
||||
expect((await store.moveIdentities(once)).kind).toBe("moved");
|
||||
expect((await store.moveIdentities({ ...once, fromAccountId: m.account.id })).kind).toBe("move_id_conflict");
|
||||
expect((await store.moveIdentities({ ...once, toAccountId: m.account.id })).kind).toBe("move_id_conflict");
|
||||
expect((await store.moveIdentities({ ...once, appSlug: "streampixels" })).kind).toBe("move_id_conflict");
|
||||
expect((await store.moveIdentities(once)).kind).toBe("replayed");
|
||||
|
||||
// Every step of a move is on Postgres too: expiry of the source's pending links and unredeemed completions, and the stripped credential's expiry and failure mark.
|
||||
const n = await side(["discord", "google"]);
|
||||
const o = await side(["patreon"]);
|
||||
await store.upsertLinkedIdentity({ accountId: n.account.id, provider: "discord", providerUserId: (await store.listStoredLinkedIdentitiesForAccount(n.account.id))[0]!.providerUserId, tokenExpiresAt: "2999-01-01T00:00:00.000Z", scopes: [], profileJson: {}, createdAt: at, updatedAt: at });
|
||||
await store.updateLinkedIdentityCredential(n.rows[1]!.id, async () => ({ kind: "failed", failedAt: at }));
|
||||
const linkOf = (accountId: string) => store.createPendingLink({ appSlug: "bifrost", accountId, provider: "discord", providerUserId: "p", identity: { accountId, provider: "discord", providerUserId: "p", scopes: [], profileJson: {}, createdAt: at, updatedAt: at }, returnTo: "https://x.test/", createdAt: at, expiresAt: later });
|
||||
const completionOf = (accountId: string) => store.createAuthCompletion({ appSlug: "bifrost", provider: "discord", mode: "sign_in", accountId, sessionId: n.proofSession.sessionId, returnTo: "https://x.test/", createdAt: at, expiresAt: later, payloadJson: {} });
|
||||
const sourceLink = await linkOf(n.account.id);
|
||||
const destLink = await linkOf(o.account.id);
|
||||
const sourceCompletion = await completionOf(n.account.id);
|
||||
const destCompletion = await completionOf(o.account.id);
|
||||
expect((await store.moveIdentities(input(n, o))).kind).toBe("moved");
|
||||
const after = "2026-11-14T12:00:01.000Z";
|
||||
expect(await store.consumePendingLink("bifrost", sourceLink.id, n.account.id, after)).toBeNull();
|
||||
expect(await store.consumeAuthCompletion("bifrost", sourceCompletion.code, after)).toBeNull();
|
||||
expect((await store.consumePendingLink("bifrost", destLink.id, o.account.id, after))?.id).toBe(destLink.id);
|
||||
expect((await store.consumeAuthCompletion("bifrost", destCompletion.code, after))?.code).toBe(destCompletion.code);
|
||||
for (const row of (await store.listStoredLinkedIdentitiesForAccount(o.account.id)).filter((r) => n.rows.some((x) => x.id === r.id))) {
|
||||
expect([row.accessTokenEncrypted, row.refreshTokenEncrypted, row.tokenExpiresAt, row.refreshFailedAt], row.provider).toEqual([undefined, undefined, undefined, undefined]);
|
||||
}
|
||||
|
||||
// Each reversal guard on its own: the refusal is not_reversible, never another code, and nothing moves.
|
||||
const fresh = async () => ({ p: await side(["discord", "google"]), q: await side(["patreon"]), t: await side(["patreon"]) });
|
||||
const reverse = (forwardId: string, from: { account: { id: string } }, to: { account: { id: string } }, ids: readonly string[]) => ({
|
||||
moveId: `move-${randomUUID()}`, appSlug: "bifrost" as const, fromAccountId: from.account.id, toAccountId: to.account.id,
|
||||
identityIds: ids, covers: ids, proofSessions: [], reversesMoveId: forwardId, proof: { from: [], to: null }, at,
|
||||
});
|
||||
// (a) already reversed, even though the logins are back on the destination by a later forward move
|
||||
{
|
||||
const { p, q } = await fresh();
|
||||
const ids = p.rows.map((r) => r.id);
|
||||
const m1 = input(p, q);
|
||||
expect((await store.moveIdentities(m1)).kind).toBe("moved");
|
||||
expect((await store.moveIdentities(reverse(m1.moveId, q, p, ids))).kind).toBe("moved");
|
||||
expect((await store.moveIdentities(input(p, q, { proofSessions: [] }))).kind).toBe("moved");
|
||||
expect((await store.moveIdentities(reverse(m1.moveId, q, p, ids))).kind, "reversed once").toBe("not_reversible");
|
||||
expect(await held(p)).toEqual([]);
|
||||
}
|
||||
// (d) the logins are still on the forward's destination but the reversal targets another account
|
||||
{
|
||||
const { p, q } = await fresh();
|
||||
const ids = p.rows.map((r) => r.id);
|
||||
const m1 = input(p, q);
|
||||
expect((await store.moveIdentities(m1)).kind).toBe("moved");
|
||||
const elsewhere = await side(["patreon"]);
|
||||
expect((await store.moveIdentities(reverse(m1.moveId, q, elsewhere, ids))).kind, "to is not the forward's source").toBe("not_reversible");
|
||||
expect(await held(elsewhere)).toHaveLength(1);
|
||||
}
|
||||
// (b) the logins moved on from the destination: not held, so not_reversible (not identity_not_found)
|
||||
// (c) the reversal's source is not the forward's destination, or its target is not the forward's source
|
||||
{
|
||||
const { p, q, t } = await fresh();
|
||||
const ids = p.rows.map((r) => r.id);
|
||||
const m1 = input(p, q);
|
||||
expect((await store.moveIdentities(m1)).kind).toBe("moved");
|
||||
expect((await store.moveIdentities(input(q, t, { identityIds: ids, covers: ids, proofSessions: [] }))).kind).toBe("moved");
|
||||
expect((await store.moveIdentities(reverse(m1.moveId, q, p, ids))).kind, "ids no longer held").toBe("not_reversible");
|
||||
expect((await store.moveIdentities(reverse(m1.moveId, t, p, ids))).kind, "from is not the forward's destination").toBe("not_reversible");
|
||||
const u = await side(["google"]);
|
||||
expect((await store.moveIdentities(reverse(m1.moveId, t, u, ids))).kind, "to is not the forward's source").toBe("not_reversible");
|
||||
expect(await held(t)).toEqual([...ids, ...t.rows.map((r) => r.id)].sort());
|
||||
}
|
||||
}
|
||||
|
||||
// Against a real Postgres, through the startup path itself: createStore, with
|
||||
// and without applying the schema. HEIMDALL_TEST_PG_ADMIN_URL names a role
|
||||
// that may create databases and roles. Each case clones a template database
|
||||
|
|
@ -971,214 +639,6 @@ describe.skipIf(!adminUrl)("startup against a live Postgres (HEIMDALL_TEST_PG_AD
|
|||
}
|
||||
});
|
||||
|
||||
it("startup refuses a role that lacks DELETE on linked_identities", async () => {
|
||||
expect(STORE_TABLE_PRIVILEGES.linked_identities).toContain("DELETE");
|
||||
const { name, owner } = await appliedDatabase();
|
||||
await grant(owner, { ...STORE_TABLE_PRIVILEGES, linked_identities: ["SELECT", "INSERT", "UPDATE"] });
|
||||
expect(await start(urlFor(name, role, rolePassword), false)).toBe(refusal("SCHEMA_PRIVILEGES"));
|
||||
});
|
||||
|
||||
it("startup refuses a role that lacks INSERT or SELECT on identity_moves", async () => {
|
||||
expect(STORE_TABLE_PRIVILEGES.identity_moves).toEqual(["SELECT", "INSERT"]);
|
||||
for (const [kept, dropped] of [[["SELECT"], "INSERT"], [["INSERT"], "SELECT"]] as const) {
|
||||
const { name, owner } = await appliedDatabase();
|
||||
await grant(owner, { ...STORE_TABLE_PRIVILEGES, identity_moves: kept });
|
||||
expect(await start(urlFor(name, role, rolePassword), false), `without ${dropped}`).toBe(refusal("SCHEMA_PRIVILEGES"));
|
||||
}
|
||||
});
|
||||
|
||||
it("on real Postgres: the move contract holds", async () => {
|
||||
const { owner } = await appliedDatabase();
|
||||
await exerciseMoveContract(new PostgresStore(owner));
|
||||
});
|
||||
|
||||
describe("concurrent moves", () => {
|
||||
const at = "2026-11-14T12:00:00.000Z";
|
||||
async function pair(store: PostgresStore) {
|
||||
const make = async (provider: "discord" | "patreon") => {
|
||||
const account = await store.createAccount({ createdAt: at, lastSeenAt: at });
|
||||
await store.upsertLinkedIdentity({ accountId: account.id, provider, providerUserId: `${provider}-${randomUUID()}`, scopes: [], profileJson: {}, createdAt: at, updatedAt: at });
|
||||
const sessionId = randomUUID();
|
||||
await store.createSession({ id: sessionId, accountId: account.id, appSlug: "bifrost", createdAt: at, lastSeenAt: at, expiresAt: "2999-01-01T00:00:00.000Z", accessRevision: 1, claimsJson: {} });
|
||||
return { id: account.id, proof: { appSlug: "bifrost" as const, sessionId, accountId: account.id, accessRevision: 1 } };
|
||||
};
|
||||
return { x: await make("discord"), y: await make("patreon") };
|
||||
}
|
||||
const request = (from: { id: string; proof: object }, to: { id: string; proof: object }, moveId: string) => ({
|
||||
moveId,
|
||||
appSlug: "bifrost" as const,
|
||||
fromAccountId: from.id,
|
||||
toAccountId: to.id,
|
||||
identityIds: "all" as const,
|
||||
covers: "account" as const,
|
||||
proofSessions: [from.proof, to.proof] as never,
|
||||
proof: { from: [], to: null },
|
||||
at,
|
||||
});
|
||||
|
||||
it("opposite moves A->B and B->A over two pools leave one applied and never deadlock, ten rounds", async () => {
|
||||
const { name, owner } = await appliedDatabase();
|
||||
const storeA = new PostgresStore(owner);
|
||||
const other = fixturePool(urlFor(name));
|
||||
cleanup.push(async () => {
|
||||
await other.end();
|
||||
});
|
||||
const storeB = new PostgresStore(other);
|
||||
for (let round = 0; round < 10; round += 1) {
|
||||
const { x, y } = await pair(storeA);
|
||||
const results = await Promise.all([
|
||||
storeA.moveIdentities(request(x, y, `move-${randomUUID()}`)),
|
||||
storeB.moveIdentities(request(y, x, `move-${randomUUID()}`)),
|
||||
]);
|
||||
const kinds = results.map((r) => r.kind).sort();
|
||||
expect(kinds[0], `round ${round}: ${kinds}`).toBe("moved");
|
||||
expect(["already_moved", "proof_lapsed"], `round ${round}: ${kinds}`).toContain(kinds[1]);
|
||||
const held = (await storeA.listStoredLinkedIdentitiesForAccount(x.id)).length + (await storeA.listStoredLinkedIdentitiesForAccount(y.id)).length;
|
||||
expect(held, "no identity is lost or duplicated").toBe(2);
|
||||
}
|
||||
});
|
||||
|
||||
it("one moveId over two different pairs at once: one moved, the other move_id_conflict, never a receipt for a move it did not make", async () => {
|
||||
const { name, owner } = await appliedDatabase();
|
||||
const storeA = new PostgresStore(owner);
|
||||
const other = fixturePool(urlFor(name));
|
||||
cleanup.push(async () => {
|
||||
await other.end();
|
||||
});
|
||||
const storeB = new PostgresStore(other);
|
||||
for (let round = 0; round < 6; round += 1) {
|
||||
const one = await pair(storeA);
|
||||
const two = await pair(storeA);
|
||||
const id = `move-${randomUUID()}`;
|
||||
const results = await Promise.all([storeA.moveIdentities(request(one.x, one.y, id)), storeB.moveIdentities(request(two.x, two.y, id))]);
|
||||
expect(results.map((r) => r.kind).sort(), `round ${round}`).toEqual(["move_id_conflict", "moved"]);
|
||||
const winner = (await storeA.findIdentityMove(id))!;
|
||||
const moved = results.find((r) => r.kind === "moved") as { move: { fromAccountId: string } };
|
||||
expect(winner.fromAccountId).toBe(moved.move.fromAccountId);
|
||||
}
|
||||
});
|
||||
|
||||
it("two concurrent identical moveIds: one moved, one replayed", async () => {
|
||||
const { name, owner } = await appliedDatabase();
|
||||
const storeA = new PostgresStore(owner);
|
||||
const other = fixturePool(urlFor(name));
|
||||
cleanup.push(async () => {
|
||||
await other.end();
|
||||
});
|
||||
const storeB = new PostgresStore(other);
|
||||
for (let round = 0; round < 5; round += 1) {
|
||||
const { x, y } = await pair(storeA);
|
||||
const body = request(x, y, `move-${randomUUID()}`);
|
||||
const results = await Promise.all([storeA.moveIdentities(body), storeB.moveIdentities(body)]);
|
||||
expect(results.map((r) => r.kind).sort(), `round ${round}`).toEqual(["moved", "replayed"]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("on real Postgres: an upsert never moves an identity, and concurrent unlinks of the last two leave one", async () => {
|
||||
const { name, owner } = await appliedDatabase();
|
||||
const clientA = new PostgresStore(owner);
|
||||
const at = "2026-11-13T12:00:00.000Z";
|
||||
const a = await clientA.createAccount({ createdAt: at, lastSeenAt: at });
|
||||
const b = await clientA.createAccount({ createdAt: at, lastSeenAt: at });
|
||||
const base = { provider: "discord" as const, scopes: [], profileJson: {}, createdAt: at, updatedAt: at };
|
||||
const held = await clientA.upsertLinkedIdentity({ ...base, accountId: a.id, providerUserId: "u1", username: "original" });
|
||||
await expect(clientA.upsertLinkedIdentity({ ...base, accountId: b.id, providerUserId: "u1", username: "thief" })).rejects.toBeInstanceOf(IdentityHeldElsewhereError);
|
||||
expect(await clientA.findStoredLinkedIdentity("discord", "u1")).toEqual(held);
|
||||
|
||||
await clientA.upsertLinkedIdentity({ ...base, accountId: a.id, providerUserId: "u2" });
|
||||
// Two separate pools, as two Heimdall processes would be, race for the last two.
|
||||
const other = fixturePool(urlFor(name));
|
||||
cleanup.push(async () => {
|
||||
await other.end();
|
||||
});
|
||||
const storeB = new PostgresStore(other);
|
||||
for (let round = 0; round < 10; round += 1) {
|
||||
const [first, second] = await clientA.listStoredLinkedIdentitiesForAccount(a.id);
|
||||
expect(first && second, `round ${round} starts with two`).toBeTruthy();
|
||||
const results = await Promise.all([clientA.unlinkLinkedIdentity(a.id, first!.id), storeB.unlinkLinkedIdentity(a.id, second!.id)]);
|
||||
expect(results.map((r) => r.kind).sort(), `round ${round}`).toEqual(["last_identity", "unlinked"]);
|
||||
expect(await clientA.listStoredLinkedIdentitiesForAccount(a.id)).toHaveLength(1);
|
||||
await clientA.upsertLinkedIdentity({ ...base, accountId: a.id, providerUserId: `u-next-${round}` });
|
||||
}
|
||||
});
|
||||
|
||||
describe("oauth_state_uses retention", () => {
|
||||
const expMs = Date.parse("2026-04-26T12:10:00.000Z");
|
||||
const boundary = expMs + OAUTH_STATE_SKEW_MS + OAUTH_STATE_PRUNE_MARGIN_MS;
|
||||
const use = (jti: string, usedAtMs: number, expiresAtMs = expMs) => ({
|
||||
jti,
|
||||
phase: "launch" as const,
|
||||
appSlug: "repixelizer" as const,
|
||||
usedAt: new Date(usedAtMs).toISOString(),
|
||||
expiresAt: new Date(expiresAtMs).toISOString(),
|
||||
});
|
||||
const count = async (owner: Pool) => Number((await owner.query("SELECT count(*) AS n FROM oauth_state_uses")).rows[0].n);
|
||||
const seed = async (owner: Pool, jti: string) => {
|
||||
await owner.query(
|
||||
"INSERT INTO oauth_state_uses (jti, phase, app_slug, used_at, expires_at) VALUES ($1, 'launch', 'repixelizer', $2, $2)",
|
||||
[jti, new Date(expMs)]
|
||||
);
|
||||
};
|
||||
|
||||
it.each([
|
||||
[boundary - 1, true],
|
||||
[boundary, false],
|
||||
[boundary + 1, false],
|
||||
])("a claim at exp + skew + margin %i ms keeps the record: %s", async (usedAtMs, kept) => {
|
||||
const { owner } = await appliedDatabase();
|
||||
const store = new PostgresStore(owner);
|
||||
expect(await store.claimOAuthStateUse(use("old", expMs - 1000))).toBe(true);
|
||||
expect(await store.claimOAuthStateUse(use("other", usedAtMs, usedAtMs + 600_000))).toBe(true);
|
||||
expect(await store.claimOAuthStateUse(use("old", usedAtMs))).toBe(!kept);
|
||||
});
|
||||
|
||||
it("removes at most one batch per claim and never the live record", async () => {
|
||||
const { owner } = await appliedDatabase();
|
||||
const store = new PostgresStore(owner);
|
||||
const total = OAUTH_STATE_PRUNE_BATCH * 2 + 7;
|
||||
for (let i = 0; i < total; i += 1) await seed(owner, `old-${i}`);
|
||||
const fresh = use("fresh", boundary + 5, boundary + 600_000);
|
||||
expect(await store.claimOAuthStateUse(fresh)).toBe(true);
|
||||
expect(await count(owner)).toBe(total + 1 - OAUTH_STATE_PRUNE_BATCH);
|
||||
expect(await store.claimOAuthStateUse(fresh)).toBe(false);
|
||||
expect(await count(owner)).toBe(total + 1 - OAUTH_STATE_PRUNE_BATCH);
|
||||
});
|
||||
|
||||
it("keeps its expires_at index", async () => {
|
||||
const { owner } = await appliedDatabase();
|
||||
const found = await owner.query("SELECT 1 FROM pg_indexes WHERE indexname = 'oauth_state_uses_expires_at_idx'");
|
||||
expect(found.rowCount).toBe(1);
|
||||
});
|
||||
|
||||
it("the step_up_assert nonce claim is single-use under the store's own role, and independent of the other phases", async () => {
|
||||
const { name, owner } = await appliedDatabase();
|
||||
await grant(owner, STORE_TABLE_PRIVILEGES);
|
||||
const pool = fixturePool(urlFor(name, role, rolePassword));
|
||||
cleanup.push(async () => {
|
||||
await pool.end();
|
||||
});
|
||||
const store = new PostgresStore(pool);
|
||||
const claim = { ...use("digest", boundary, boundary + 600_000), phase: "step_up_assert" as const, appSlug: "bifrost" };
|
||||
expect(await store.claimOAuthStateUse(claim)).toBe(true);
|
||||
expect(await store.claimOAuthStateUse(claim)).toBe(false);
|
||||
expect(await store.claimOAuthStateUse({ ...claim, phase: "callback" })).toBe(true);
|
||||
});
|
||||
|
||||
it("a role without DELETE still claims, and leaves the old records", async () => {
|
||||
const { name, owner } = await appliedDatabase();
|
||||
await grant(owner, { ...STORE_TABLE_PRIVILEGES, oauth_state_uses: ["SELECT", "INSERT"] });
|
||||
await seed(owner, "old");
|
||||
const pool = fixturePool(urlFor(name, role, rolePassword));
|
||||
cleanup.push(async () => {
|
||||
await pool.end();
|
||||
});
|
||||
const store = new PostgresStore(pool);
|
||||
expect(await store.claimOAuthStateUse(use("fresh", boundary + 5, boundary + 600_000))).toBe(true);
|
||||
expect(await count(owner)).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses to apply the schema as a role that may not, and closes the store", async () => {
|
||||
const { name, owner } = await appliedDatabase();
|
||||
await grant(owner, STORE_TABLE_PRIVILEGES);
|
||||
|
|
@ -1192,7 +652,6 @@ describe.skipIf(!adminUrl)("startup against a live Postgres (HEIMDALL_TEST_PG_AD
|
|||
const appliedByIndex = {
|
||||
auth_completions_attempt_unconsumed_unique_idx: "auth_completions",
|
||||
consent_receipts_revokes_unique_idx: "consent_receipts",
|
||||
identity_moves_reverses_unique_idx: "identity_moves",
|
||||
};
|
||||
const partialKeys = REQUIRED_KEYS.flatMap((key, index) => (key.predicate === "" ? [] : [{ key, index }]));
|
||||
expect(partialKeys.map(({ key }) => key.table).sort()).toEqual(Object.values(appliedByIndex).sort());
|
||||
|
|
@ -1227,10 +686,7 @@ describe.skipIf(!adminUrl)("startup against a live Postgres (HEIMDALL_TEST_PG_AD
|
|||
await owner.query(`GRANT USAGE ON SCHEMA public TO ${role}`);
|
||||
for (const [table, wanted] of Object.entries(STORE_TABLE_PRIVILEGES)) {
|
||||
const columns = REQUIRED_COLUMNS.filter((column) => column.table === table).map((column) => column.column).join(", ");
|
||||
for (const privilege of wanted) {
|
||||
// DELETE removes rows and cannot be granted per column.
|
||||
await owner.query(privilege === "DELETE" ? `GRANT DELETE ON ${table} TO ${role}` : `GRANT ${privilege} (${columns}) ON ${table} TO ${role}`);
|
||||
}
|
||||
for (const privilege of wanted) await owner.query(`GRANT ${privilege} (${columns}) ON ${table} TO ${role}`);
|
||||
}
|
||||
|
||||
expect(await start(urlFor(name, role, rolePassword), false)).toBe("started");
|
||||
|
|
|
|||
Loading…
Reference in a new issue