Compare commits

..
Author SHA1 Message Date
4fe367ac8d Identity mover: POST /v1/apps/:slug/identity-moves, proof of both accounts, strict-passkey coverage, alias row, credential strip (#16)
Soul s3 clean at 2f1b733. Needs identity_moves DDL + GRANT SELECT, INSERT before the build starts.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-14 01:59:11 +00:00
d957e5accb Merge pull request 'App-asserted step-up route: Bifrost asserts a native strong proof, Heimdall mints step_up_at + step_up_method' (#15) from heimdall/app-asserted-step-up into main 2026-11-13 20:19:55 +00:00
Heimdall
6ed6d8fa0d Step-up assertion: pin per-app not-permitted budget and the 10.5 min prune horizon, name the tripped budget in the audit row, correct validation-order and audit docs
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 20:19:41 +00:00
Heimdall
1259911632 Step-up assertion: audit a flood once per window, budget the not-permitted branch, base64url nonce, honest nonce retention and precondition docs
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 20:13:13 +00:00
Heimdall
2f93809a29 Docs: app-asserted step-up route, step_up_method claim, Bifrost profile grant, architecture note, evidence line
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 20:01:46 +00:00
Heimdall
33a83c34c4 App-asserted step-up: POST /v1/apps/{app}/step-up mints a same-session access token with step_up_at and step_up_method
App secret plus the member's live bearer for that app, a per-profile grant and method allowlist (bifrost only), a nonce spent once per session through the existing oauth_state_uses claim (no schema change), an in-process fixed-window rate per account and per app, and an audit row per assertion or post-secret refusal. Provider step-up sign-ins now label their proof provider:<name>.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 19:59:26 +00:00
54e15567c7 Merge pull request 'Member identity list and step-up-gated unlink; a link never moves an identity' (#14) from heimdall/identity-unlink into main 2026-11-13 19:22:35 +00:00
Heimdall
46e5d5364f Docs: a Twitch step-up proof is weaker; point the unlink section at the step-up note
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 19:21:45 +00:00
Heimdall
e9eef09446 Pin the proof window's 600 s edge, foreign-id 404 over last_identity, and the sign_in-only step_up_at guard
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 19:21:45 +00:00
Heimdall
d8b8d25e81 Docs: identity list and unlink routes, step-up proof window, linked_identities single-writer rule; evidence ledger line
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 18:59:21 +00:00
Heimdall
3afc956f92 Member identity list and step-up-gated unlink: step_up_at on a step-up sign-in's access claim, GET /v1/me/identities, POST /v1/me/identities/:id/unlink
resolveBearer is the one bearer verifier; the link race maps to link_initiator_mismatch and links/confirm 409.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 18:59:21 +00:00
Heimdall
46d5044925 Store: an upsert never moves an identity between accounts; unlinkLinkedIdentity is the one delete, under an account lock, refusing the last identity
STORE_TABLE_PRIVILEGES.linked_identities gains DELETE; startup refuses a role without it.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 18:59:21 +00:00
c98e019c5d Merge pull request 'Consent bomb test: pin the inflate output cap, not a wall-clock bound' (#13) from heimdall/consent-bomb-deterministic into main 2026-11-13 07:15:09 +00:00
Heimdall
4aa9d03c4f Consent bomb test: pin the inflate output cap, not a wall-clock bound
The 1500 ms bound measured 3330 ms under parallel load. What the test proves is that
a decompression bomb is read under the 16 KB cap; record the inflate options and assert
maxOutputLength directly. Mutant (drop the cap) fails the test.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 07:03:30 +00:00
96256d2839 Prune oauth_state_uses on claim: bounded delete past exp + skew + margin (#12)
Not in the 74004bf release list. The runtime role needs GRANT SELECT, DELETE ON oauth_state_uses before this build starts.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 05:58:45 +00:00
74004bf0ef Merge pull request 'Opt-in sign_in step-up (force_verify / prompt=login), app-authenticated, signed-state flag' (#11) from heimdall/signin-step-up into main 2026-11-12 23:22:19 +00:00
Heimdall
673713a015 Step-up: build the authorize URL before claiming the launch (a refused step-up no longer burns the state); pin the strict step_up parse with a signed forgery; document backend_callback and mixed-version limits
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-12 23:21:12 +00:00
Heimdall
7a69d7567b Opt-in sign_in step-up: app-authenticated, signed-state flag, provider re-auth parameters, typed refusal
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-12 23:10:27 +00:00
afa1b74318 Merge pull request 'Docs: answer collector record and consent' (#6) from heimdall/answer-collector-consent into main 2026-11-10 17:12:26 +00:00
Heimdall
570e930043 Note the two-clock comparison in the training rule as an open gap
accepted_at is origin_server_ts, received_at is the collector's clock; a collector clock behind the homeserver's excludes a first answer. Fails closed.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-10 17:12:15 +00:00
5fc626c7ec Connect start: pin the app-secret gate on every managed provider, refuse blank targets (#9)
The target is stored and compared raw by design (no trim).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-09 12:18:26 +00:00
cbc069f02c Merge pull request 'Redeem refuses a target the completion did not record' (#10) from heimdall/redeem-refuse-unexpected-target into main 2026-11-09 12:13:54 +00:00
Heimdall
36b02ea135 Redeem refuses a target the completion did not record, symmetric with the missing one
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-09 12:03:14 +00:00
Heimdall
a021b02310 Test first: redeem refuses a target the completion never recorded
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-09 12:03:14 +00:00
b446add144 Merge pull request 'Record a failed refresh on the identity row so another process does not re-present the token' (#8) from heimdall/failed-refresh-marker into main 2026-11-09 10:31:02 +00:00
Heimdall
246f2d4f71 Tests: rotate fixtures carry consumedRefreshToken
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-09 10:27:35 +00:00
Heimdall
d4cd112193 Blank the refresh token in the fallback write only when it is spent
A provider that keeps its refresh token valid (Twitch) keeps it when the
rotation cannot be committed: the marker is left, the token stays, and the
next caller after the backoff presents it. Blanking stays for a revoke and
for a provider that rotates (the rotate decision now carries
consumedRefreshToken). Live-Postgres test for Twitch written first and seen
failing; the contract states the rule inline.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-09 10:22:14 +00:00
Heimdall
f2f1dabc06 Blank the consumed refresh token in the fallback marker write
When the rotation or revocation the provider's answer called for cannot be
committed, the fallback write now clears refresh_token_encrypted as well as
stamping the marker, so the end of the 60 s backoff cannot replay the consumed
token. Tests (live Postgres, trigger-refused revoke) assert no replayable token
and no second provider call. Docs: the marker is a best-effort write on a
second connection after rollback, the skew cap, the blanking write's residual.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-09 10:07:24 +00:00
Heimdall
9c7d980165 Leave the failed-refresh marker when the revoke a rotating provider's answer called for cannot be written
A rotating provider that answered without a new refresh token has consumed the old one; the revoke
that follows is rolled back if its UPDATE fails, and no marker remained. The fallback marker now
covers revoke as well as rotate. The residual-risk paragraph says the window opens when the request
is sent, cites the steward ruling, and ends its sentences.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 21:56:40 +00:00
Heimdall
1118f235b0 Docs: name the process-death residual in the refresh contract
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 21:53:12 +00:00
Heimdall
9da4b12bc2 Make the failed-refresh marker durable and stamp it with the failure time
The marker carried the time the attempt began, so a slow failure ate the backoff; it now carries the
time the provider's answer (or failure) was known. A throw after the provider answered (custody
encrypt) records the marker instead of rolling it back, and a rotation Postgres refuses to write
leaves the marker in a transaction of its own. A stamp from a writer's clock ahead of the reader's by
the backoff or more is treated as skew, not a fresh failure.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 21:48:13 +00:00
Heimdall
a0bd9836b1 Count the new linked_identities column in the schema inventory test
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 21:40:39 +00:00
Heimdall
f5f4b2830f Record a failed refresh on the linked identity row so another process does not re-present the token
A failed or timed-out refresh attempt now writes refresh_failed_at under the row lock; a process
that takes the lock inside a 60 s backoff answers managed_credential_refresh_failed without calling
the provider. Rotation, revocation and re-link clear the marker. Closes the cross-process residual
that refresh-once-per-token left to a follow-up, and pins it on the two-pool rig.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 21:35:13 +00:00
2bd5096d81 Merge pull request 'Refusal return contract: pin the fragment, carry the attempt id, document it' (#7) from heimdall/refusal-return-contract into main 2026-11-08 13:51:45 +00:00
Heimdall
aaeb175dc6 State the two encodings of the version rule; note the checkbox version gap
Room notice version is a JSON number (a string, including "01", is malformed);
the answer key keeps the version as encoded text under the leading-zero
refusal. Same rule, two encodings. The registration checkbox records no
version: recorded as an open gap Void is raising.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 02:34:48 +00:00
Heimdall
d6a8d4238c Rule every consent version as a positive u32 with no leading zeros
Fold the leading-zero text into one version rule under Representation:
sample, notice, notice acceptance and terms versions, and the room notice,
all positive decimal integers, no leading zeros, at most 4294967295.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 00:30:00 +00:00
Heimdall
cafdc52e5f Rule version spelling and the room notice event
Leading-zero versions are refused, not normalised; one version, one key.
A room's notice is the org.gamecult.notice state event {version, scope}
under the room's power levels; a pinned message is not accepted. Absent
or malformed means no train line and the narrowest scope.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-06 23:28:40 +00:00
Heimdall
4e99bfa628 Rule empty text and received_at ties from CultLib #12 follow-up
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-06 19:40:44 +00:00
Heimdall
2364979f0d Rule the open points from the CultLib consent-records review
Scopeless replies are stored and excluded; tombstone is HMAC-SHA-256 with a
member-side salt; representation, training-rule scope match, scope partition
and MemberTerms ownership fixed. Docs only.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-06 17:24:39 +00:00
Heimdall
d836f29e3a Align answer-collector consent with #swarm rulings of 2026-11-06
Room notice declares scope (train included); first reply accepts a notice
version; terms version for newcomers only; training needs an acceptance at
answer time and a recorded scope; redaction is exit, past use not undone;
salted hash-only tombstone; no reward sound on consent controls; personal
details out of scope; typed field tables for the CultCache records.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-06 14:31:45 +00:00
Heimdall
e8edd88efc Specify answer collector record and consent
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-06 13:30:45 +00:00
30 changed files with 4770 additions and 180 deletions

View file

@ -0,0 +1,265 @@
# 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.

View file

@ -77,3 +77,37 @@ 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`.

View file

@ -336,6 +336,49 @@ 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
@ -447,13 +490,12 @@ 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`: 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: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: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

View file

@ -198,6 +198,35 @@ 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
@ -519,6 +548,256 @@ 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:
@ -567,12 +846,34 @@ 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), 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
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.
- 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
@ -632,6 +933,10 @@ 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`

View file

@ -0,0 +1,35 @@
# 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`.

View file

@ -56,6 +56,22 @@ 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. */
@ -154,6 +170,9 @@ 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",
@ -263,6 +282,17 @@ 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
@ -289,7 +319,7 @@ export function callbackMint(
return mode === "connect" &&
profile.managedConnectionProviders.includes(provider) &&
connection?.kind === "creator" &&
connection.targetId !== ""
isUsableConnectionTarget(connection.targetId)
? "custody_only"
: "refused";
}

View file

@ -1,4 +1,4 @@
import { createHmac, randomUUID } from "node:crypto";
import { createHash, createHmac, randomUUID } from "node:crypto";
import { deflateRawSync, inflateRawSync } from "node:zlib";
import cookie from "@fastify/cookie";
import cors from "@fastify/cors";
@ -24,8 +24,9 @@ import {
type OAuthStartRequest,
type Provider,
} from "./contracts.js";
import { issueAccessClaim, type IssueAccessClaimInput } from "./claims.js";
import { builtInAppProfiles, getAppProfile, isAllowedReturnOrigin, serializeAppProfile, admitsProviderForMode, callbackMint } from "./app-profiles.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 { 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";
@ -41,10 +42,12 @@ 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, type HeimdallStore } from "./store/index.js";
import { createStore, IdentityHeldElsewhereError, 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";
@ -99,6 +102,7 @@ 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();
@ -121,6 +125,7 @@ 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);
@ -184,7 +189,8 @@ 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
!requestedScopesAreValid ||
!(payload.step_up === undefined || payload.step_up === null || payload.step_up === true)
) {
return null;
}
@ -202,6 +208,9 @@ 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;
}
@ -212,8 +221,8 @@ type OAuthStateVerdict =
/**
* The one place a signed OAuth state becomes trusted: signature, shape, issuer,
* 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
* 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
* runs this first; the refusals are fixed codes and never echo the token.
*/
function verifyOAuthState(
@ -236,7 +245,7 @@ function verifyOAuthState(
}
const now = nowEpochSeconds();
if (payload.nbf > now + 30 || payload.exp <= now - 30) {
if (payload.nbf > now + OAUTH_STATE_SKEW_MS / 1000 || payload.exp <= now - OAUTH_STATE_SKEW_MS / 1000) {
return { ok: false, error: "state_expired" };
}
@ -702,14 +711,8 @@ export async function startOAuthFlow(
};
}
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.`,
},
};
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." } };
}
const descriptor = providerCatalog[provider];
@ -754,6 +757,19 @@ 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))
@ -811,6 +827,24 @@ 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 {
@ -830,6 +864,7 @@ export async function startOAuthFlow(
handoff,
entitlementPolicy,
requestedScopes: input.requestedScopes,
stepUp: input.stepUp,
keys,
});
return {
@ -846,28 +881,38 @@ 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 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 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 store still holds for that account at that revision. A bearer that fails
* any of these binds nothing: a link is explicit or it does not happen.
* any of these proves nothing.
*/
async function resolveLinkAccount(
async function resolveBearer(
ctx: Pick<HeimdallRuntimeContext, "config" | "keys" | "store">,
appSlug: AppSlug,
authorization: string | undefined
): Promise<string | null> {
authorization: string | undefined,
appSlug?: AppSlug
): Promise<ResolvedBearer | 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 !== appSlug ||
claims.aud !== audience ||
typeof claims.account_id !== "string" ||
typeof claims.sid !== "string" ||
typeof claims.access_revision !== "number" ||
@ -877,7 +922,7 @@ async function resolveLinkAccount(
) {
return null;
}
const session = await ctx.store.findSession(appSlug, claims.sid);
const session = await ctx.store.findSession(audience, claims.sid);
if (
!session ||
session.accountId !== claims.account_id ||
@ -886,7 +931,16 @@ async function resolveLinkAccount(
) {
return null;
}
return claims.account_id;
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;
}
/** The account a Heimdall access token for any app names, when the token and its session are live. */
@ -894,12 +948,299 @@ async function resolveAccessAccount(
ctx: Pick<HeimdallRuntimeContext, "config" | "keys" | "store">,
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 audience = verification.payload.aud;
return isAppSlug(audience) ? resolveLinkAccount(ctx, audience, authorization) : 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 grantsPageTokenType = "heimdall_grants_page";
@ -1167,6 +1508,7 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
},
},
initiatingAccountId: { type: "string", minLength: 1 },
stepUp: { type: "boolean" },
handoff: {
type: "object",
required: ["kind"],
@ -1332,6 +1674,18 @@ 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",
@ -1343,21 +1697,10 @@ 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(
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
);
return reply.redirect(authorizationUrl, 302);
};
app.get<{
@ -1812,7 +2155,16 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
await store.touchAccount(account.id, nowIso, accountUpdates);
}
await store.upsertLinkedIdentity(buildLinkedIdentityInput(account.id));
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;
}
// Heimdall now holds a credential the person was asked about: the
// receipt is the record of that agreement, written with the credential.
@ -1869,6 +2221,10 @@ 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;
@ -1927,6 +2283,7 @@ 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,
};
@ -2178,7 +2535,14 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
reply.code(409);
return { error: "identity_linked_elsewhere" };
}
await store.upsertLinkedIdentity({ ...pending.identity, accountId, updatedAt: nowIso });
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.createAuditEvent({
accountId,
appSlug: pending.appSlug,
@ -2220,7 +2584,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 refusals read the row
// held to what the signed state named, both ways: a target the state never named is refused too. 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) {
@ -2228,7 +2592,7 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
reply.code(401);
return { error: "app_auth_required" };
}
if (pending.targetId !== undefined && pending.targetId !== request.body.targetId) {
if (pending.targetId !== request.body.targetId) {
await store.createAuditEvent({
accountId: pending.accountId,
sessionId: pending.sessionId,
@ -2370,6 +2734,37 @@ 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: {
@ -2633,6 +3028,146 @@ 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 } }>(
@ -2694,26 +3229,7 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
if (identity) {
await store.updateLinkedIdentityCredential(identity.id, async () => ({ kind: "revoke", updatedAt: 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,
});
}
revoked.push(...(await revokeLiveGrants(accountId, grant.provider, receipts, at)));
}
if (prefersHtml(request.headers.accept) || request.headers["content-type"]?.startsWith("application/x-www-form-urlencoded")) {
reply.type("text/html; charset=utf-8");

View file

@ -11,6 +11,12 @@ 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);
}
@ -48,6 +54,14 @@ 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 {
@ -130,6 +144,18 @@ 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;
}
@ -191,3 +217,62 @@ 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;
}

View file

@ -78,6 +78,8 @@ 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 {
@ -99,6 +101,8 @@ 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 {
@ -149,6 +153,19 @@ 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;

49
src/fixed-window.ts Normal file
View file

@ -0,0 +1,49 @@
// 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);
}
}
}

View file

@ -1,7 +1,7 @@
import { type HeimdallConfig } from "./config.js";
import { type Provider } from "./contracts.js";
import { type TokenCustody } from "./custody.js";
import { type OAuthProviderRuntime, type OAuthRuntimeRegistry, ProviderResponseError } from "./oauth.js";
import { type ExpiringTokenSet, 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,6 +16,14 @@ 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;
@ -55,6 +63,17 @@ 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;
@ -68,10 +87,8 @@ 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.
*
* 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.
* 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.
*/
const refreshesInFlight = new WeakMap<HeimdallStore, Map<string, Promise<ManagedCredentialOutcome>>>();
@ -120,28 +137,8 @@ async function refreshUnderLock(
const runtime = ctx.oauthRuntimes[provider];
let revocation: ManagedCredentialRevocationReason | undefined;
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 failed = false;
const rotation = (locked: StoredLinkedIdentity, refreshed: ExpiringTokenSet, updatedAt: string): CredentialDecision => {
let refreshTokenEncrypted: string;
if (refreshed.refreshToken) {
refreshTokenEncrypted = ctx.tokenCustody.encrypt(refreshed.refreshToken);
@ -162,9 +159,47 @@ 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({
@ -174,6 +209,9 @@ async function refreshUnderLock(
createdAt: new Date(clock()).toISOString(),
});
}
if (failed) {
throw new ManagedCredentialRefreshFailed(provider);
}
if (!committed) {
return { kind: "revoked", identity: withoutTokens(snapshot) };
}

View file

@ -22,6 +22,12 @@ 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
@ -92,6 +98,8 @@ 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",
@ -116,6 +124,8 @@ 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",
@ -150,6 +160,7 @@ 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",
@ -180,6 +191,8 @@ 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);
@ -203,6 +216,15 @@ 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();
}

View file

@ -0,0 +1,88 @@
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,
}));
}

View file

@ -4,6 +4,8 @@ import {
type CreateAccountInput,
type CreateAuthAttemptInput,
type ClaimOAuthStateUseInput,
OAUTH_STATE_PRUNE_BATCH,
oauthStatePruneCutoffMs,
type CreateAuthCompletionInput,
type CreateConsentReceiptInput,
type CreatePendingLinkInput,
@ -16,6 +18,7 @@ import {
type CreateSessionInput,
type CreatePrivateCommandReceiptInput,
type HeimdallStore,
IdentityHeldElsewhereError,
type StoredAccount,
type StoredAuthAttempt,
type StoredAuthCompletion,
@ -23,11 +26,13 @@ import {
type StoredLinkedIdentity,
type StoredSession,
type StoredPrivateCommandReceipt,
type MoveIdentitiesInput,
type MoveResult,
type StoredIdentityMove,
type UnlinkResult,
type UpsertLinkedIdentityInput,
} from "./types.js";
/** The skew a signed OAuth state is still accepted for past its exp (verifyOAuthState). */
const OAUTH_STATE_SKEW_MS = 30_000;
import { decideMove, movedRecord } from "./identity-move.js";
function clone<T>(value: T): T {
return JSON.parse(JSON.stringify(value)) as T;
@ -62,6 +67,7 @@ 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;
@ -149,6 +155,9 @@ 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,
@ -189,6 +198,87 @@ 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>,
@ -203,7 +293,13 @@ 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;
@ -458,18 +554,23 @@ 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;
}

View file

@ -1,6 +1,7 @@
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,
@ -18,6 +19,12 @@ 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,
@ -25,6 +32,7 @@ import {
type StoredLinkedIdentity,
type StoredSession,
type StoredPrivateCommandReceipt,
type UnlinkResult,
type UpsertLinkedIdentityInput,
} from "./types.js";
@ -47,6 +55,7 @@ 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;
@ -170,6 +179,42 @@ 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,
@ -207,6 +252,10 @@ 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;
}
@ -444,21 +493,28 @@ 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 nothing.
* 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.
* audit_events is written and never read back.
*/
export const STORE_TABLE_PRIVILEGES: Readonly<Record<string, readonly ("SELECT" | "INSERT" | "UPDATE")[]>> = {
export const STORE_TABLE_PRIVILEGES: Readonly<Record<string, readonly ("SELECT" | "INSERT" | "UPDATE" | "DELETE")[]>> = {
accounts: ["SELECT", "INSERT", "UPDATE"],
linked_identities: ["SELECT", "INSERT", "UPDATE"],
linked_identities: ["SELECT", "INSERT", "UPDATE", "DELETE"],
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: ["INSERT"],
oauth_state_uses: ["SELECT", "INSERT", "DELETE"],
capability_grants: ["SELECT", "INSERT"],
entitlement_snapshots: ["SELECT", "INSERT", "UPDATE"],
consent_receipts: ["SELECT", "INSERT"],
identity_moves: ["SELECT", "INSERT"],
audit_events: ["INSERT"],
};
@ -593,7 +649,9 @@ 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 has_column_privilege(a.attrelid, a.attnum, required.privilege)
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
)
`,
[
@ -770,16 +828,17 @@ 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 *
`,
[
@ -800,7 +859,144 @@ export class PostgresStore implements HeimdallStore {
]
);
return mapLinkedIdentityRow(expectRow(result.rows[0], "upsertLinkedIdentity"));
// 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);
}
async updateLinkedIdentityCredential(
@ -810,6 +1006,7 @@ 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",
@ -820,9 +1017,15 @@ export class PostgresStore implements HeimdallStore {
return null;
}
const row = mapLinkedIdentityRow(expectRow(locked.rows[0], "updateLinkedIdentityCredential"));
const decision = await decide(row);
decision = await decide(row);
let committed = row;
if (decision.kind !== "keep") {
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") {
const rotated = decision.kind === "rotate" ? decision : undefined;
const written = await client.query<LinkedIdentityRow>(
`
@ -830,6 +1033,7 @@ 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
@ -850,6 +1054,21 @@ 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 {
@ -1192,7 +1411,7 @@ export class PostgresStore implements HeimdallStore {
}
async claimOAuthStateUse(input: ClaimOAuthStateUseInput): Promise<boolean> {
// No conflict target and no RETURNING: both need SELECT, and the role has INSERT only.
// No conflict target and no RETURNING: ON CONFLICT DO NOTHING alone says whether the row was new.
const result = await this.pool.query(
`
INSERT INTO oauth_state_uses (jti, phase, app_slug, used_at, expires_at)
@ -1201,7 +1420,24 @@ export class PostgresStore implements HeimdallStore {
`,
[input.jti, input.phase, input.appSlug, input.usedAt, input.expiresAt]
);
return result.rowCount === 1;
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;
}
async upsertEntitlementSnapshot(input: CreateEntitlementSnapshotInput): Promise<void> {

View file

@ -123,6 +123,11 @@ 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
@ -138,9 +143,10 @@ 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. 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.
-- 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.
CREATE TABLE IF NOT EXISTS oauth_state_uses (
jti TEXT NOT NULL,
phase TEXT NOT NULL,
@ -150,6 +156,8 @@ 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,
@ -208,6 +216,29 @@ 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,

View file

@ -33,12 +33,86 @@ 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;
@ -58,8 +132,9 @@ export interface UpsertLinkedIdentityInput {
/**
* What the owner of a refresh decides after reading the row under its lock.
* `rotate` and `revoke` write token columns, scopes and updated_at only; they
* never touch account, profile or identity fields.
* `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.
*/
export type CredentialDecision =
| { kind: "keep" }
@ -70,8 +145,11 @@ 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: "revoke"; updatedAt: string }
| { kind: "failed"; failedAt: string };
export interface StoredSession {
id: string;
@ -167,9 +245,26 @@ 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;
phase: "launch" | "callback";
/** `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";
appSlug: AppSlug;
usedAt: string;
expiresAt: string;
@ -289,7 +384,26 @@ 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

View file

@ -37,3 +37,7 @@
{"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"}

View file

@ -1440,6 +1440,105 @@ 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 }) {

View file

@ -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=3 guards=3"]);
expect(consumers).toEqual(["app.ts: reads=4 guards=4"]);
});
it("a caller minted for app A is refused where app B is expected, over HTTP and by direct call", async () => {

View file

@ -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 { InMemoryStore } from "../src/store/index.js";
import { IdentityHeldElsewhereError, InMemoryStore } from "../src/store/index.js";
import { launchFlow, launchWithRedirect, stateOf, type LaunchedFlow } from "./oauth-flow.js";
const CLIENT_ID = "google-client";
@ -188,6 +188,18 @@ 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();

View file

@ -0,0 +1,632 @@
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" });
});
});

View file

@ -0,0 +1,334 @@
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();
});
});

View file

@ -11,6 +11,20 @@ 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.
@ -220,7 +234,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 refused quickly", async () => {
it("is read up to 16 KB and no further: one byte over is refused, and a bomb is inflated under that cap", async () => {
const { app } = await newApp();
const ctx = getHeimdallRuntimeContext(app);
const state = await startConnect(app);
@ -228,9 +242,12 @@ 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" });
const t0 = Date.now();
expect((await answerWith(Buffer.alloc(300 * 1024 * 1024))).json()).toEqual({ error: "state_not_bound" });
expect(Date.now() - t0).toBeLessThan(1500);
// 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);
// At the cap it is read.
expect((await answerWith(Buffer.alloc(16 * 1024, 97))).json()).toMatchObject({ error: "consent_refused" });
});

View file

@ -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 { InMemoryStore } from "../src/store/index.js";
import { IdentityHeldElsewhereError, InMemoryStore } from "../src/store/index.js";
import { launchFlow } from "./oauth-flow.js";
function createTestConfig(): HeimdallConfig {
@ -166,6 +166,15 @@ 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 {
@ -218,6 +227,16 @@ 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");

View file

@ -6,6 +6,7 @@ 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,
@ -419,17 +420,18 @@ for (const { kind, live } of rigKinds) describe.skipIf(live && !adminUrl)(`manag
expect(Date.now() - started).toBeLessThan(5_000);
endpoint.mode = "normal";
const outcome = await refreshManagedCredential(ctx, await rig.row());
const afterBackoff = { ...ctx, clock: () => Date.now() + MANAGED_CREDENTIAL_REFRESH_BACKOFF_MS };
const outcome = await refreshManagedCredential(afterBackoff, 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 per process, and every caller gets the typed failure", timeout, async () => {
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 () => {
const rig = await makeRig(provider);
const endpoint = installTokenEndpoint(provider, "refresh-0");
endpoint.mode = "hang";
const snapshot = await rig.row();
const ctxFor = (store: HeimdallStore) => ({ config: rig.config, store, tokenCustody: rig.custody, oauthRuntimes: createOAuthRuntimeRegistry(), timeoutMs: 150 });
const ctxFor = (store: HeimdallStore, clock = Date.now) => ({ config: rig.config, store, tokenCustody: rig.custody, oauthRuntimes: createOAuthRuntimeRegistry(), timeoutMs: 150, clock });
const results = await Promise.allSettled(
Array.from({ length: 12 }, (_, i) => refreshManagedCredential(ctxFor(rig.stores[i % rig.stores.length]!), snapshot))
@ -439,14 +441,205 @@ for (const { kind, live } of rigKinds) describe.skipIf(live && !adminUrl)(`manag
for (const result of results) {
expect((result as PromiseRejectedResult).reason).toBeInstanceOf(ManagedCredentialRefreshFailed);
}
expect(endpoint.calls).toBe(rig.stores.length);
// 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(rig.custody.decrypt((await rig.row()).refreshTokenEncrypted!)).toBe("refresh-0");
// The burst is over: a later independent request tries again.
// The burst is over and so is the backoff the failure left on the row: a later independent request tries again.
endpoint.mode = "normal";
const later = await refreshManagedCredential(ctxFor(rig.stores[0]!), await rig.row());
const later = await refreshManagedCredential(ctxFor(rig.stores[0]!, () => Date.now() + MANAGED_CREDENTIAL_REFRESH_BACKOFF_MS), await rig.row());
expect(later.kind).toBe("current");
expect(endpoint.calls).toBe(rig.stores.length + 1);
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);
});
it("a snapshot taken before another caller's refresh does not decide: the locked row does", timeout, async () => {
@ -487,6 +680,38 @@ 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);
@ -532,6 +757,7 @@ 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);
@ -711,6 +937,7 @@ 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,
@ -794,6 +1021,7 @@ 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,
};
})
)
@ -1072,6 +1300,7 @@ 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;
@ -1104,6 +1333,7 @@ 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);

View file

@ -4,6 +4,7 @@ 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
@ -280,23 +281,69 @@ describe("OAuth state binding", () => {
});
});
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);
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("a provider refusal from the launching browser still denies the attempt, once", async () => {

View file

@ -0,0 +1,435 @@
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");
});
});

209
tests/step-up.test.ts Normal file
View file

@ -0,0 +1,209 @@
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 });
}
});
});

View file

@ -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, InMemoryStore } from "../src/store/index.js";
import { createStore, IdentityHeldElsewhereError, InMemoryStore, type HeimdallStore } from "../src/store/index.js";
import {
createPostgresStore,
PostgresStore,
@ -14,6 +14,11 @@ 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", () => {
@ -198,6 +203,88 @@ 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)");
@ -276,19 +363,82 @@ describe("InMemoryStore.claimOAuthStateUse", () => {
expect(await store.claimOAuthStateUse({ ...use, jti: "state-2" })).toBe(true);
});
it("drops a record once its state's exp + 30 s skew has passed, and keeps it until then", async () => {
it("drops a record only once its state's exp + skew + margin has passed", async () => {
const store = new InMemoryStore();
await store.claimOAuthStateUse(use);
// 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" });
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) });
expect(await store.claimOAuthStateUse(use)).toBe(false);
// 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" });
const boundary = expMs + OAUTH_STATE_SKEW_MS + OAUTH_STATE_PRUNE_MARGIN_MS;
await store.claimOAuthStateUse({ ...use, jti: "state-3", usedAt: at(boundary - 1) });
expect(await store.claimOAuthStateUse(use)).toBe(false);
// 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" });
// At exactly exp + skew + margin the record is prunable (the rule is <=).
await store.claimOAuthStateUse({ ...use, jti: "state-4", usedAt: at(boundary) });
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", () => {
@ -331,9 +481,10 @@ describe("PostgresStore.checkSchema", () => {
"capability_grants",
"entitlement_snapshots",
"consent_receipts",
"identity_moves",
"audit_events",
]);
expect(REQUIRED_COLUMNS).toHaveLength(112);
expect(REQUIRED_COLUMNS).toHaveLength(121);
expect(REQUIRED_COLUMNS).toEqual(
expect.arrayContaining([
{ table: "auth_completions", column: "code", type: "TEXT", nullable: false },
@ -344,7 +495,7 @@ describe("PostgresStore.checkSchema", () => {
{ table: "auth_completions", column: "attempt_id", type: "TEXT", nullable: true },
])
);
expect(REQUIRED_KEYS).toHaveLength(17);
expect(REQUIRED_KEYS).toHaveLength(19);
expect(REQUIRED_KEYS).toEqual(
expect.arrayContaining([
{ table: "linked_identities", columns: "provider,provider_user_id", predicate: "" },
@ -352,6 +503,7 @@ 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" },
])
@ -428,6 +580,8 @@ 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");
@ -481,6 +635,15 @@ 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);
@ -491,9 +654,178 @@ 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
@ -639,6 +971,214 @@ 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);
@ -652,6 +1192,7 @@ 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());
@ -686,7 +1227,10 @@ 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) await owner.query(`GRANT ${privilege} (${columns}) ON ${table} TO ${role}`);
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}`);
}
}
expect(await start(urlFor(name, role, rolePassword), false)).toBe("started");