Compare commits

..
43 changed files with 392 additions and 5505 deletions

View file

@ -14,7 +14,7 @@ The point is painfully simple:
## Front Door
- **Value:** one shared auth authority: provider OAuth (Discord, Patreon, GitHub, Twitch, YouTube, Spotify, Google OIDC for linking), linked identities, grants, entitlements and session issuance, with Ed25519-signed claims that app backends verify locally.
- **Value:** one shared auth authority: provider OAuth (Discord, Patreon, GitHub, Twitch, YouTube, Spotify), linked identities, grants, entitlements and session issuance, with Ed25519-signed claims that app backends verify locally.
- **Problem:** every GameCult app was rebuilding Discord/Patreon login glue. Heimdall centralizes identity authority and keeps app-domain data in the apps.
- **Entry points:**
- `src/index.ts` and `src/app.ts`: the Fastify service. `src/signing.ts` and `src/claims.ts` issue JWTs and serve `/.well-known/jwks.json`; `src/oauth.ts` and `src/providers.ts` own provider flows.

View file

@ -157,8 +157,6 @@ optional_environment = [
"GC_ACCESS_PROVIDER_DISCORD_CLIENT_ID",
"GC_ACCESS_PROVIDER_DISCORD_CLIENT_SECRET_FILE",
"GC_ACCESS_PROVIDER_GITHUB_CLIENT_SECRET_FILE",
"GC_ACCESS_PROVIDER_GOOGLE_CLIENT_ID",
"GC_ACCESS_PROVIDER_GOOGLE_CLIENT_SECRET_FILE",
"GC_ACCESS_PROVIDER_JOYSTICK_CLIENT_ID",
"GC_ACCESS_PROVIDER_JOYSTICK_CLIENT_SECRET_FILE",
"GC_ACCESS_PROVIDER_PATREON_CLIENT_ID",

View file

@ -154,11 +154,7 @@ The important split:
- Heimdall owns provider OAuth, identity linking, entitlement refresh, signed
sessions, claims, grants, audit surfaces, and managed provider credential
custody. It authenticates; it does not issue GameCult identity. Bifrost is
the identity provider, and the account id Heimdall mints is an authenticator
handle. Remaining scar: Heimdall still creates that account id on first
login (`store.createAccount`, `src/app.ts`), which the chat work does not
need moved
custody
- the host experiment owns its workload, queue, editor, render path, viewer
profile model, creator model, or other product-specific machinery
- the host experiment also owns which local entity a connection attaches to,
@ -447,12 +443,13 @@ builder was not used.
process, so N Heimdall processes can present one token N times after a
transient failure. Needs a failed-attempt marker on the row, read under the
lock. Must land before or with Idunn onboarding.
- `joystick:follow_up:connect-start-needs-app-auth` (Heimdall side done): a
connect start that names a connection target needs the app's own secret for
every managed provider, and a blank target is refused at start and at the
callback mint. What StreamPixels must do on its side is in
`notes/streampixels-connect-redeem.md`; its redeem must refuse a creator
connect whose target it did not start.
- `joystick:follow_up:connect-start-needs-app-auth`: connect starts are
unauthenticated, so anyone can name `{creator, targetId}`; the callback
re-check proves a binding exists, not that the caller owns it. It also owns
the whitespace-only `targetId` that passes both gates and the callback's
fail-open `''` fallback for a vanished profile (finding
`joystick:finding:cut-h1.s4.whitespace-targetid-passes-custody-gate`). Must
land before the public Joystick bot or a second creator.
- `joystick:follow_up:heimdall-idunn-onboarding` (gamecult-ops): live Heimdall
is a hand-copied release under `heimdall.service`, not an Idunn-admitted
generation; the recipe's Joystick lines are declared but unproven by a
@ -616,9 +613,8 @@ Only the browser-driven OAuth flow:
| Path | Why it must be public |
|---|---|
| `/v1/oauth/{provider}/start` | the app's server calls it, with its app secret where the profile requires one |
| `/v1/oauth/{provider}/launch` | the user's browser goes here first; it sets the binding cookie and redirects to the provider, once per state |
| `/v1/oauth/{provider}/callback` | the provider redirects here; one-shot: it needs the binding cookie and consumes the state |
| `/v1/oauth/{provider}/start` | the user's browser goes here |
| `/v1/oauth/{provider}/callback` | the provider redirects here; one-shot and not retryable |
| `/.well-known/jwks.json` | *nothing external consumes it today* — see below |
Everything else is internal by the design already written down:

View file

@ -1,237 +0,0 @@
# Joystick sign-in and consent: what was missing, and the flow that fills it
Status: built (sections 3-6 describe the code on this branch); sections 1-2 are
the state of `fefd2dd` (main) that motivated it. Follow-up
`heimdall-body:follow_up:chat-s3124-3129`, built under
`heimdall-body:follow_up:chat-mind-20261108T131253147Z-t1`. The steward's rulings
on the five open questions are in section 4.
## 1. What a viewer sees today
**Joystick viewer sign-in does not exist.** `streampixelsProfile`
(`src/app-profiles.ts:109-117`) lists Joystick only in
`managedConnectionProviders`. `admitsProviderForMode` (`:243`) admits `sign_in`
and `link` only for `identityProviders`, so
`POST /v1/oauth/joystick/start` with `mode: sign_in` returns
`400 provider_not_supported_for_app` as JSON before any redirect
(`startOAuthFlow`, `src/app.ts:598-603`). The viewer sees whatever the app does
with a failed start; Heimdall shows them nothing. The architecture doc records
this as deliberate until the public bot is registered
(`docs/architecture.md:461`, `joystick:follow_up:joystick-viewer-signin-public-bot`).
**The only live Joystick path is a creator `connect`.** Walking it as the
person in the browser:
1. The app backend starts the flow (authenticated by app secret, `connection`
`{kind: creator, targetId}`). The person's browser is sent to
`/v1/oauth/joystick/launch?state=...`.
2. `launch` (`src/app.ts:1130-1194`) claims the state, sets the binding cookie
and answers `302` straight to `joystick.tv/api/oauth/authorize`. **Heimdall
renders no page of its own.** Nothing says who is asking, for what, or what
will be kept.
3. The only ask the person sees is Joystick's own screen, listing the scopes in
`providerCatalog.joystick.defaultScopes` (`src/providers.ts:162`):
`identity:read stream:read chat:read followers:read subscribers:read`.
Joystick words that screen, not us.
4. The callback (`:1196`) exchanges the code, stores the credential encrypted
(rotating refresh token, managed custody), and returns the generic handoff
page (`src/browser-handoff.ts`): title "Authentication Complete", body "You
can return to the app now." It does not name the app, the provider, the
scopes granted, or that a long-lived refresh token is now held by Heimdall.
5. Refusal: if the person clicks Deny at Joystick, the callback gets
`error=access_denied`, and the page says "Authentication Failed ... Return to
the app to continue." The provider's own text is relayed only in
`errorDescription` in the fallback URL fragment / postMessage payload, not
shown on the page. Refusing *at Heimdall's step* is impossible: there is no
Heimdall step.
6. After: the audit rows `oauth_callback_succeeded`, `auth_completion_created`,
`managed_credential_revoked` etc. (`createAuditEvent` call sites, e.g.
`app.ts:1582`) are written for operators. There is **no route a viewer can
call to read them**, and no route a viewer can call to revoke a credential.
Revocation exists only inside the app backend plane (`managed-credential.ts:172`).
## 2. What consent is missing
| Gap | Today | Why it matters |
| --- | --- | --- |
| Visible ask | None from Heimdall; provider screen only | The person cannot tell Heimdall custody (refresh token kept, rotated, used for stream/chat/follower reads) from a one-off login. |
| Who is asking | Not shown | Two parties act: the app (StreamPixels) and Heimdall as custodian. Neither is named. |
| Scope truth | Provider screen lists scopes; Heimdall's `requestedScopes` / `defaultScopes` are not shown or compared with what was granted | Granted scopes can differ from requested; the stored `scopes` (`app.ts:1406`) are never shown. |
| Typed refusal | A deny is an untyped `provider_error` | The app and the audit log cannot tell "viewer said no" from "provider broke". |
| Receipt | Operator audit rows only | The viewer cannot audit what was agreed, when, or revoke it. |
| Viewer vs creator | The `connect` binding proves a binding exists, not that the browser's owner is the creator (`joystick:follow_up:connect-start-needs-app-auth`) | Consent from the wrong person is not consent. |
## 3. The consent flow
Applies to every completion `callbackMint` calls `custody_only`: a managed
creator connection (Joystick today; Twitch and YouTube on StreamPixels when
they are connected rather than signed in). Viewer sign-in through an identity
provider is unchanged.
### 3.1 The ask
`GET /v1/oauth/:provider/launch` renders a Heimdall page (`src/consent.ts`)
instead of answering `302`, after the state verifies and before any use is
claimed. The page names the app and the origin the person returns to, the
provider and the exact scopes Heimdall will send (`authorizationScopes`, the
same function that builds the authorization URL), what Heimdall keeps (an
encrypted refresh token and access token, until the person takes it back or the
provider ends it), what the app receives, and how to take it back. It carries
two POST forms to `/v1/oauth/:provider/consent`, **Continue to Joystick** and
**No, return to the app**, and runs no script. It sets the ask's own
cookie (`__Host-heimdall-ask-<jti>`), which carries the exact text shown and is
accepted only by Continue and No; an answer without it is `state_not_bound` and
spends nothing. Viewing the ask claims nothing and binds nothing: the launch
binding cookie, which decides who may complete the state, is set only when
Continue claims the launch, and it carries the text shown on to the callback.
(An earlier cut set the binding on the ask, so anyone holding the launch URL
could become the state's owner; review of 2026-11-08 found it.)
What the app receives, stated as the code does it: not a claim. The app
backend can call `managed-credentials/resolve` and receive the current access
token; it never receives the refresh token. The earlier draft said the app
receives "no token"; that was wrong and the page does not say it.
### 3.2 Typed refusal
A decline, and a provider `error=access_denied` on any provider, produce the
result `consent_refused` with `by: "viewer" | "provider"`: an audit event
`consent_refused` (provider, mode, by; no account, no person), the browser
attempt denied with `denialCode consent_refused`, both uses of the state spent,
a `backend_callback` app told with the same payload, a "Not Connected" page in
the browser, and `400 {error: "consent_refused", by}` as JSON. No credential is
stored and no receipt is written. Every other provider error stays
`provider_error`. This changes the `access_denied` answer for every provider,
including Bifrost's and Repixelizer's sign-in; see the PR for the contract note.
### 3.3 Receipt
On a granted `custody_only` completion Heimdall writes one `consent_receipts`
row of kind `grant`: id (random), account, app, provider, scopes asked, scopes
granted (as the provider returned them), `consentText` (the lines and
button labels shown, taken from the ask and never rebuilt, so a deploy between
ask and callback cannot change what the receipt says), `consentPageDigest`
(sha-256 of that text), `createdAt`. A custody callback with no shown text
exchanges no code and stores no credential. The id
reaches the app (`consentReceiptId` in the completion and backend payloads) and
the person (the handoff page).
Receipts are append-only: the store role has `SELECT` and `INSERT` on the table
and nothing else. A revocation is a second row of kind `revocation` naming the
grant it takes back, unique per grant (a partial unique index); a grant is live
exactly when no revocation names it, and a concurrent second revoke reports
nothing and writes nothing. The
draft's `revokedAt` slot is gone for that reason.
### 3.4 My grants
Signed in means a live Heimdall access token for any app (`Authorization:
Bearer`), or a page link minted from one.
- `GET /v1/me/grants`: every grant and receipt of the account. `Accept:
text/html` gets the page, one **Take this back** form per live grant; anything
else gets `{grants, receipts}`.
- `POST /v1/me/grants/link` (bearer): `{url}` to the page, valid ten minutes.
The token is signed, purpose-bound (`heimdall_grants_page`), stateless, and
opens only this page and its revoke forms. The forms on a page opened by a link
carry the link's own expiry, so using a link never renews it. This is how a browser that holds no
session gets in; Heimdall sets no new cookie and stores nothing for it.
- `POST /v1/me/grants/:id/revoke` (bearer, or the page token in the form):
strips the stored tokens from the credential, then writes a revocation row
for every live grant on that credential. The credential is one row per account
and provider, whichever app asked, so taking it back ends every grant resting
on it. The next `managed-credentials/resolve` answers `410
managed_credential_revoked`; nothing else needs to learn of it. Revoking a
revoked grant changes nothing. Another account's grant id is `404`.
Heimdall can delete its copy; it cannot withdraw the permission at the
provider, and the page says so.
## 4. The steward's rulings
Each ruling picks the answer that gives the person the most agency and leaves
the least stored.
1. **Who gets the ask: managed custody only.** Where Heimdall holds a credential
the person cannot see and an app can use, the person is asked and given a
receipt. A login is already asked by the provider's own screen, and a receipt
per login would store a row per sign-in for no agency the provider's screen
does not give. The line is `callbackMint === "custody_only"`, so it needs no
second list to keep in step with the profiles.
2. **The receipt is a new typed table, `consent_receipts`.** The audit table is
operator-shaped and has no read path for the person; a viewer-readable
record in it would make every audit row a potential disclosure. The table
holds ids, scopes and a digest: no IP address, no user agent, no page text. A revocation names a
receipt of its own account: a composite foreign key `(revokes_receipt_id,
account_id)` to `(id, account_id)`, which is why `(id, account_id)` is also a
unique key (`checkSchema` counts it). The in-memory store refuses the same. The table is created
whole and first-created: no deployed database had an earlier shape, so there
is no `ALTER` step.
Refusals leave no receipt, only the anonymous audit row.
3. **Viewer identity: sign-in, not the cookie.** The binding cookie dies with
the state and proves only that a browser launched a flow; it is not an
account. The receipt page and revoke require a live access token or a
ten-minute link minted from one. The cost is that a first-time creator must
hold the session the completion returned to the app; the app mints the link.
The alternative, a new long-lived Heimdall cookie, would store a session for
every person who merely looked.
4. **`consent_refused` reaches a backend callback app.** An app that started the
attempt and is waiting must be told, or it polls to expiry and cannot tell
a person's no from a fault. The person's no is also not held against them:
Heimdall stores nothing and sets no cooldown; whether and when to ask again
is the app's rule.
5. **Wording: derived, not authored.** The text is generated from facts the code
holds and states nothing the code does not do; it is a constant in
`src/consent.ts` and its digest rides every receipt. Legal review of the
wording (the CultCoin legal thread) may only narrow it, and any change moves
the digest. Until that review lands the copy is Heimdall's, signed by the
steward.
6. **Bounds on what rides the cookies.** The wording is read from a cookie
only up to 16 KB inflated, so a compressed bomb is refused, not expanded. An
app may request at most 32 scopes of 512 characters (`requested_scopes_too_large`
at the ask). A custody callback whose binding carries no wording is refused
with `consent_text_missing` before the state is claimed, so nothing is burned.
### Refusal return contract (for app authors)
A person's No at the ask, or a provider `access_denied`, is `consent_refused`.
No completion code is minted and the state is spent. What each side gets:
- **Browser.** A 200 HTML page titled "Not Connected" (a non-HTML caller gets a 400
JSON `{error, by, provider, returnTo}`). The page does not redirect: by ruling
it never navigates on its own. It posts the payload to `window.opener`
(target origin = the origin of `returnTo`), tries `window.close()` after 50 ms,
and offers a "Return to the app" link. In a plain tab the person clicks it.
- **The link** is `returnTo` with a fragment (read `location.hash`; it never
reaches a server): `heimdall_status=error`, `heimdall_error=consent_refused`,
`heimdall_refused_by=viewer|provider`, `heimdall_provider`,
`heimdall_handoff_kind`, `heimdall_app_slug`, `heimdall_mode`, and
`heimdall_attempt_id` when the flow carries an attempt. There is no
`heimdall_completion_code`; a page with none, and `heimdall_error`, is a refusal.
A fault is a different `heimdall_error` (`provider_error` and others).
- **postMessage** carries `{source: "heimdall", kind: "oauth_result",
status: "error", error: "consent_refused", by, provider, appSlug, mode,
returnTo, handoffKind, attemptId?}`.
- **Backend callback apps** get the same result POSTed to their callback URL
(`status: "error"`, `error: "consent_refused"`, `by`, `attemptId`). An auth
attempt is marked `denied` with `denialCode: "consent_refused"`, which
`completeAuth` returns as `denied`.
`tests/joystick-consent.test.ts` ("the refusal return contract") pins the
fragment for both refusals and the attempt id.
## 5. Out of scope
Enabling Joystick in `identityProviders`; fixing
`joystick:follow_up:connect-start-needs-app-auth`; revoking at the provider
(Joystick offers no revocation call Heimdall uses); retention or deletion of
consent receipts and of the audit rows; consent for the creator's audience.
## 6. Tests
`tests/joystick-consent.test.ts` holds one behaviour per rule above. Rules were
checked by mutation, each mutant run on its own with a 300 s limit under an 8 GB
address-space cap (the PR lists them). The Postgres statements for
`consent_receipts` are exercised by `tests/store.test.ts` against the fake pool
and, when `HEIMDALL_TEST_PG_ADMIN_URL` is set, against a live database
(`scripts/test-live-pg.sh`); the live run was not possible where this was built.

View file

@ -119,7 +119,6 @@ Supported providers in the skeleton:
- `youtube`
- `spotify`
- `joystick`
- `google`
Request body:
@ -150,29 +149,12 @@ Response:
"appSlug": "repixelizer",
"mode": "sign_in",
"callbackUrl": "https://heimdall.gamecult.org/v1/oauth/discord/callback",
"authorizationUrl": "https://heimdall.gamecult.org/v1/oauth/discord/launch?state=<signed-jwt>",
"authorizationUrl": "https://discord.com/oauth2/authorize?...",
"stateToken": "<signed-jwt>",
"stateExpiresAt": "2026-04-26T12:10:00.000Z"
}
```
Send the user's browser to `authorizationUrl` unchanged. It is Heimdall's launch
hop, not the provider's URL.
### `GET /v1/oauth/{provider}/launch?state=<signed-jwt>`
The first thing the user's browser does with a state. Heimdall verifies the
state (signature, issuer, provider, `nbf`/`exp` with 30 s of skew), claims the
state's single launch, sets the binding cookie
`__Host-heimdall-oauth-<jti>` (`Path=/`, `Secure`, `HttpOnly`, `SameSite=Lax`,
`Max-Age` until the state expires; the value is a Heimdall-signed binding for
that state) and answers `302` to the provider's authorize URL, with
`Cache-Control: no-store` and `Referrer-Policy: no-referrer`. A second launch of
the same state is `400 state_already_launched` and sets no cookie. The state is
refused with `400 invalid_state`, `state_provider_mismatch` or `state_expired`.
The binding cookie needs an `https` public base URL (or `localhost`). A browser
must finish the flow in the browser that launched it.
Failure modes:
- `503 provider_not_configured` when the provider client id is missing
@ -186,17 +168,7 @@ Failure modes:
- `401 app_auth_required` when the request body carries `entitlementPolicy` and
the caller did not authenticate as that app via `x-heimdall-app-secret`
(`resolveAppCaller`, `src/app-caller.ts`) -- this applies regardless of
`handoff.kind` or app slug; a caller with no policy to attach needs no secret,
unless the app's profile sets `startRequiresAppCaller` (Bifrost only): then
every start for that app, with or without a policy, needs that app's secret.
Bifrost binds browser completion to a nonce that rides readable in the signed
state's `return_to`, so only Bifrost may mint a Bifrost state; apps whose
browser starts are public by design leave the flag unset
- `401 app_auth_required` also when `mode` is `connect` and the body carries a
`connection` (a creator or account target): a connection target is app-backend
authority, so only the authenticated app named by `appSlug` may start it. No
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`
`handoff.kind` or app slug; a caller with no policy to attach needs no secret
Launch note:
@ -207,45 +179,6 @@ Launch note:
implemented yet
- Repixelizer launch can configure Discord and Patreon on its provider buttons
### Google (OpenID Connect, link-only)
`google` is an identity provider that never mints an account. It is a second
door into a person who already has one.
- Callback redirect URI (register exactly this in the Google Cloud OAuth
client): `{GC_ACCESS_BASE_URL}/v1/oauth/google/callback`, which is
`https://heimdall.gamecult.org/v1/oauth/google/callback` in production.
- Scopes `openid email profile`. The authorization request carries `state`
(signed, Heimdall's), a `nonce`, and a PKCE `S256` challenge, all built at the
launch hop, which is where the browser first meets the provider URL. The verifier
and the nonce are derived from the state's `jti` and Heimdall's signing key,
so nothing is stored between start and callback.
- The callback verifies the `id_token` (RS256 against Google's published
keys; `iss` is Google, `aud` is this client, `exp`, `iat`, `nonce`) and
requires a `sub` and a verified email. The account key is `sub`; the email
is a display and contact field and never a lookup key.
- `mode: "link"` needs `Authorization: Bearer <Heimdall access token>` for the
same app; the identity attaches to that token's account and to no other.
`mode: "sign_in"` signs in an account the `sub` is already linked to.
- A `sub` no account holds, arriving with `sign_in`, is refused and mints
nothing. A `sub` linked to another account is refused and never moved.
Both audit as `oauth_callback_failed` with error class `LinkRefusal`
and the code `identity_not_linked` or `link_initiator_mismatch`.
- Link is two-phase. The callback verifies the identity and records a pending
link (TTL `completionTtlSeconds`), answering `status: "link_pending"` with a
`pendingLinkId` and no session; it writes no linked identity. The app calls
`POST /v1/apps/:appSlug/links/confirm` with `{ pendingLinkId }` and the
Heimdall access token of the account that began the link; only that call
attaches the identity. A different account, an expired, used or unknown id
answers 410; a `sub` another account holds by then answers 409. A refused
confirm does not consume the link.
- A sign-in callback evaluates every linked identity of the account with the
start-time entitlement policy, so a member returning through Google keeps
what Discord or Patreon grant.
- Configure with `GC_ACCESS_PROVIDER_GOOGLE_CLIENT_ID` and
`GC_ACCESS_PROVIDER_GOOGLE_CLIENT_SECRET_FILE`. Admitted to the `bifrost`
app profile only.
### `GET /v1/oauth/{provider}/callback`
Purpose:
@ -256,11 +189,7 @@ Purpose:
Current status:
- validates signed `state` (signature, issuer, provider, expiry), requires the
binding cookie the launch hop set, and consumes the state: a second callback
is `400 state_already_used`, a missing or foreign cookie `400 state_not_bound`,
an expired state `400 state_expired`; the binding is checked before the claim,
so a browser without the cookie cannot burn the launching browser's state
- validates signed `state`
- returns provider errors cleanly
- for Discord, exchanges the authorization code, resolves the provider
identity, persists the local account/link/session/audit records, evaluates
@ -567,34 +496,12 @@ Important behavior:
identity (`SELECT ... FOR UPDATE`), so a given refresh token is presented to
the provider once across concurrent requests and across two Heimdall
processes sharing one database. Within one process, concurrent refreshes of
one identity share a single attempt and its outcome (success or failure). A
failed or timed-out attempt is recorded on the identity row
(`refresh_failed_at`, written in the same transaction as the lock), and no
process presents that row's refresh token again for 60 seconds: a process
that takes the lock inside the backoff answers the same
`managed_credential_refresh_failed` without calling the provider. A stamp
more than 60 seconds in the future (a writer whose clock runs ahead) is skew,
not a fresh failure, so the wait is capped at twice the backoff. A rotation,
a revocation or a re-link clears the marker.
The marker is a best-effort write on a second connection, made after the
lock's transaction rolled back; it is not part of that transaction. When the
rotation or the revocation the provider's answer called for cannot be
committed, the fallback write leaves the marker and blanks the refresh token
only when that token is spent: on a revoke (a rotating provider answered
without a refresh token, or the grant was rejected), or when the provider
rotates refresh tokens (Joystick) and so consumed it. A provider that keeps
its refresh token valid (Twitch) keeps it, so a transient database failure
does not strand the link: the next caller after the backoff presents it again.
Residual risk: the provider may consume the refresh token as soon as the
request is sent. If a Heimdall process dies after that and before the commit
of the marker, the rotation or the revocation, or if the blanking fallback
write fails too, nothing is recorded, and the
next caller can present the refresh token the provider already consumed; for
a provider that rotates (Joystick) that reads as token theft. The Heimdall
steward ruled (#minds, 2026-11-08) that an intent log, written on its own
connection before the provider call, replaces this marker; it is tracked in
`joystick:follow_up:write-ahead-refresh-attempt-record`.
The provider call is bounded at 10 seconds.
one identity share a single attempt and its outcome (success or failure), so
nobody re-presents a token behind a failed holder in that burst. Residual
risk, tracked as follow-up `joystick:follow_up:cross-process-failed-refresh-marker`:
the guard is one presentation per process per burst, so N
Heimdall processes can present one token N times after a transient failure.
The provider call is bounded at 10 seconds
- a provider whose refresh tokens rotate (Joystick) fails closed: a refresh
response without a new refresh token revokes the credential instead of
keeping the old token

View file

@ -11,8 +11,7 @@ evidence only.
## Owner Map
- Owner: Heimdall owns provider identity/linking (an authenticator handle, not
GameCult identity, which is Bifrost's), OAuth attempts, signed session and claim
- Owner: Heimdall owns provider identity/linking, OAuth attempts, signed session and claim
issuance, entitlement refresh, managed provider token custody, grants,
auth-completion handoffs, refresh sessions, and audit events.
- Inputs: provider OAuth callbacks, app profile requests, caller-supplied

View file

@ -1,35 +0,0 @@
# For Kiko: what StreamPixels must do for a creator connect
Heimdall's side is done (this PR). A connect start that carries a `connection`
target is accepted only with StreamPixels' own `x-heimdall-app-secret`, so only
the StreamPixels backend can mint a state naming `{kind: "creator", targetId}`.
Heimdall cannot see StreamPixels' session; the secret proves the caller is the
backend, and the backend must have proved the creator-manage session itself
before it starts the connect.
What StreamPixels has to change (not done here; this repo does not touch it):
1. **Start the connect only after the creator-manage check.** The route that
calls `POST /v1/oauth/:provider/start` with `mode: "connect"` must have
verified, for the signed-in StreamPixels user, that they may manage creator
`targetId`. Never take `targetId` from the browser unchecked.
2. **Remember what you started.** Store, server-side and bound to the attempt
(the `attemptId` or browser session), the `targetId` StreamPixels asked
Heimdall to bind.
3. **Redeem with that stored target, never one read back from the request or
the completion.** `POST /v1/apps/streampixels/auth/redeem` takes `targetId`;
Heimdall answers `403 completion_target_mismatch` (code left unconsumed) when
it differs from the signed state's. That only protects you if the value you
send is the one you stored at step 2. A redeem with no stored target for this
attempt must be refused by StreamPixels before it calls Heimdall.
4. **Refuse a creator connect you did not start.** If a connect completion
arrives for a `targetId` with no matching started attempt, do not rebind that
creator's connector, whatever Heimdall's completion says.
5. **A connect completion with no creator target is not a creator connect and
not a sign-in.** Joystick is managed-connection only: a non-creator Joystick
completion is an error. (Twitch/YouTube `connect` without a `connection` is
Heimdall's viewer-session path; treat it as that, never as a connector rebind.)
6. Blank targets (`" "`, zero-width characters) are refused by Heimdall at
start with `400 invalid_connection_target`; send real ids only.
Tracked as `joystick:follow_up:connect-start-needs-app-auth`.

View file

@ -17,7 +17,6 @@
"bundle": "esbuild src/index.ts --bundle --platform=node --format=esm --target=node24 --outfile=dist/heimdall --external:pg-native --banner:js=\"#!/usr/bin/env node\nimport{createRequire}from'node:module';const require=createRequire(import.meta.url);\""
},
"dependencies": {
"@fastify/cookie": "^11.1.3",
"@fastify/cors": "^11.2.0",
"@msgpack/msgpack": "^3.1.3",
"@gamecult/cultcache-ts": "file:vendor/CultLib/packages/cultcache-ts",

22
pnpm-lock.yaml generated
View file

@ -12,9 +12,6 @@ importers:
.:
dependencies:
'@fastify/cookie':
specifier: ^11.1.3
version: 11.1.5
'@fastify/cors':
specifier: ^11.2.0
version: 11.2.0
@ -232,9 +229,6 @@ packages:
'@fastify/ajv-compiler@4.0.5':
resolution: {integrity: sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==}
'@fastify/cookie@11.1.5':
resolution: {integrity: sha512-hs7e0otf7n/lJMpt63EFdQb5/u8kO6DeexEtLPVTybSdItC6SwJ1O0vtTyWvKNR70LZcHLlsUr/y3jFpqtyWRA==}
'@fastify/cors@11.2.0':
resolution: {integrity: sha512-LbLHBuSAdGdSFZYTLVA3+Ch2t+sA6nq3Ejc6XLAKiQ6ViS2qFnvicpj0htsx03FyYeLs04HfRNBsz/a8SvbcUw==}
@ -475,10 +469,6 @@ packages:
resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==}
engines: {node: '>=18'}
cookie@2.0.1:
resolution: {integrity: sha512-yuToqVvRrj6pfDXREyQAAv8SkAEk/8GS3jQRTiUMm66TVtBYmqQeoEjL2Lmq8Rpo6271vH76InTChTitEAm65w==}
engines: {node: '>=22'}
cultmesh-ts@file:vendor/CultLib/packages/cultmesh-ts:
resolution: {directory: vendor/CultLib/packages/cultmesh-ts, type: directory}
engines: {node: '>=18'}
@ -557,9 +547,6 @@ packages:
fastify-plugin@5.1.0:
resolution: {integrity: sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==}
fastify-plugin@6.0.1:
resolution: {integrity: sha512-4wW4RxSmvPXLrh5mgQEiu43qU3bw44N2ss4FTkTkQIbprUhE+/s43QX0j/ttcztdWK7PEkHqVthdc5GdEGDvjA==}
fastify@5.12.7:
resolution: {integrity: sha512-v4pEPsTRPuqlDIYiQFB+BHjVN4SdHX9u0ABk0ZDUKbbdY1vsNryUd1j4Z+jQEZajvYIVk047Kimva9/AJ79GYw==}
@ -1199,11 +1186,6 @@ snapshots:
ajv-formats: 3.0.1(ajv@8.20.0)
fast-uri: 3.1.0
'@fastify/cookie@11.1.5':
dependencies:
cookie: 2.0.1
fastify-plugin: 6.0.1
'@fastify/cors@11.2.0':
dependencies:
fastify-plugin: 5.1.0
@ -1411,8 +1393,6 @@ snapshots:
cookie@1.1.1: {}
cookie@2.0.1: {}
cultmesh-ts@file:vendor/CultLib/packages/cultmesh-ts:
dependencies:
'@gamecult/cultcache-ts': file:vendor/CultLib/packages/cultcache-ts
@ -1520,8 +1500,6 @@ snapshots:
fastify-plugin@5.1.0: {}
fastify-plugin@6.0.1: {}
fastify@5.12.7:
dependencies:
'@fastify/ajv-compiler': 4.0.5

View file

@ -1,28 +0,0 @@
#!/usr/bin/env bash
# Runs the whole vitest suite with the live-Postgres files enabled, against a
# throwaway local cluster that this script starts and removes. Needs the
# Postgres server binaries (initdb, pg_ctl) on PATH or in PG_BIN.
# SOUL_PG_URL's test drops and recreates schema public, so it only ever sees
# this disposable cluster.
set -euo pipefail
# Prerequisites, done here when missing (each step is skipped once it holds):
# 1. the vendor/CultLib submodule checked out,
# 2. its npm dependencies installed (the vendor packages build with its tsc),
# 3. dist/ built for cultcache-ts, cultnet-ts and cultmesh-ts (package.json
# points main at dist/index.js),
# 4. this repo's pnpm install (npm install if pnpm fails with EACCES).
cd "$(dirname "$0")/.."
[ -f vendor/CultLib/package.json ] || git submodule update --init vendor/CultLib
[ -d vendor/CultLib/node_modules/typescript ] || (cd vendor/CultLib && npm ci)
for pkg in cultcache-ts cultnet-ts cultmesh-ts; do
[ -f "vendor/CultLib/packages/$pkg/dist/index.js" ] || (cd "vendor/CultLib/packages/$pkg" && npm run build)
done
[ -d node_modules/vitest ] || pnpm install --frozen-lockfile || npm install # pnpm can fail with EACCES on its store; npm does not need it
export PATH="${PG_BIN:-/usr/lib/postgresql/16/bin}:$PATH"
dir="$(mktemp -d)"
port="${PG_PORT:-54329}"
trap 'pg_ctl -D "$dir" -m immediate stop >/dev/null 2>&1 || true; rm -rf "$dir"' EXIT
initdb -D "$dir" -U pgadmin --auth=trust >/dev/null
pg_ctl -D "$dir" -o "-p $port -k $dir -c listen_addresses=127.0.0.1" -l "$dir/log" -w start >/dev/null
url="postgres://pgadmin@127.0.0.1:$port/postgres"
HEIMDALL_TEST_PG_ADMIN_URL="$url" SOUL_PG_URL="$url" npx vitest run --config vitest.config.ts "$@"

View file

@ -45,17 +45,6 @@ export interface AppProfile {
* plane; absent for apps with no such requirement.
*/
requiredEntitlementPolicyKind?: OAuthEntitlementPolicy["kind"];
/**
* The app binds browser completion to its own pending attempt, and the
* binding is the secrecy of the attempt nonce, which rides readable in the
* signed (not encrypted) state's return_to. If anyone could start a flow for
* this app, whoever learned a victim's nonce could mint a completion bound
* to it and sign the victim's browser in as themselves (login CSRF, finding
* known-nonce-login-csrf). So a start for this app is refused unless the
* caller is this app's authenticated caller: only the app can mint its own
* state. Absent for apps whose browser starts are public by design.
*/
startRequiresAppCaller?: boolean;
}
/** The membership signal shared by every app that gates on GameCult membership. */
@ -148,12 +137,11 @@ const streampixelsProfile: AppProfile = {
const bifrostProfile: AppProfile = {
slug: "bifrost",
displayName: "Bifrost",
profileVersion: "2026-11-04",
identityProviders: ["discord", "patreon", "google"],
profileVersion: "2026-06-08",
identityProviders: ["discord", "patreon"],
entitlementSources: ["discord", "patreon"],
managedConnectionProviders: [],
allowedReturnOrigins: ["https://bifrost.gamecult.org"],
startRequiresAppCaller: true,
capabilities: [
{
key: "member_access",
@ -263,17 +251,6 @@ export function isAllowedReturnOrigin(profile: AppProfile, returnTo: string): bo
}
}
/**
* A connection target names something: at least one letter or number that is
* not a default-ignorable code point (the Hangul and Khmer fillers are letters
* that render as nothing). An allowlist, so marks, private-use, unassigned and
* surrogate code points and symbols cannot stand in for a name. The one
* definition the start gate and the callback mint both use.
*/
export function isUsableConnectionTarget(targetId: string): boolean {
return /(?![\p{Default_Ignorable_Code_Point}])[\p{L}\p{N}]/u.test(targetId);
}
/**
* What a completed OAuth callback may mint, decided at the callback from the
* signed state's mode and the provider's role in the profile (the start gate
@ -300,7 +277,7 @@ export function callbackMint(
return mode === "connect" &&
profile.managedConnectionProviders.includes(provider) &&
connection?.kind === "creator" &&
isUsableConnectionTarget(connection.targetId)
connection.targetId !== ""
? "custody_only"
: "refused";
}

1060
src/app.ts

File diff suppressed because it is too large Load diff

View file

@ -30,8 +30,6 @@ export interface BackendHandoffSuccessPayload
mode: OAuthMode;
account: AccountSummary;
entitlements: EntitlementEvaluation;
/** The receipt of the consent a managed connection rests on. */
consentReceiptId?: string;
}
export interface BackendHandoffErrorPayload extends BackendHandoffBase {
@ -39,20 +37,9 @@ export interface BackendHandoffErrorPayload extends BackendHandoffBase {
mode?: OAuthMode;
error: string;
errorDescription?: string;
/** Who said no, when `error` is consent_refused. */
by?: "viewer" | "provider";
}
/** A verified link that waits for the initiating account's confirm: no session, no tokens. */
export interface BackendHandoffLinkPendingPayload extends BackendHandoffBase {
status: "link_pending";
mode: OAuthMode;
pendingLinkId: string;
expiresAt: string;
confirmEndpoint: string;
}
export type BackendHandoffPayload = BackendHandoffSuccessPayload | BackendHandoffErrorPayload | BackendHandoffLinkPendingPayload;
export type BackendHandoffPayload = BackendHandoffSuccessPayload | BackendHandoffErrorPayload;
export async function deliverBackendHandoff(callbackUrl: string, payload: BackendHandoffPayload): Promise<void> {
const response = await fetch(callbackUrl, {

View file

@ -12,11 +12,7 @@ export interface BrowserHandoffSuccess extends BrowserHandoffBase {
status: "success";
mode: OAuthMode;
completionCode?: string;
/** A verified link waiting for its initiator's confirm; carries no session. */
pendingLinkId?: string;
attemptId?: string;
/** The receipt of the consent a managed connection rests on. */
consentReceiptId?: string;
}
export interface BrowserHandoffError extends BrowserHandoffBase {
@ -24,8 +20,6 @@ export interface BrowserHandoffError extends BrowserHandoffBase {
error: string;
errorDescription?: string;
attemptId?: string;
/** Who said no, when `error` is consent_refused. */
by?: "viewer" | "provider";
}
export type BrowserHandoffPayload = BrowserHandoffSuccess | BrowserHandoffError;
@ -51,19 +45,11 @@ function buildBrowserFallbackUrl(payload: BrowserHandoffPayload): string {
fragment.set("heimdall_attempt_id", payload.attemptId);
}
if (payload.status === "success" && payload.consentReceiptId) {
fragment.set("heimdall_consent_receipt_id", payload.consentReceiptId);
}
if (payload.status === "success" && payload.completionCode) {
fragment.set("heimdall_completion_code", payload.completionCode);
} else if (payload.status === "success" && payload.pendingLinkId) {
fragment.set("heimdall_pending_link_id", payload.pendingLinkId);
} else {
if (payload.status === "error") {
fragment.set("heimdall_error", payload.error);
if (payload.by) {
fragment.set("heimdall_refused_by", payload.by);
}
if (payload.errorDescription) {
fragment.set("heimdall_error_description", payload.errorDescription);
}
@ -87,19 +73,14 @@ export function renderBrowserHandoffPage(payload: BrowserHandoffPayload): string
});
const scriptOrigin = serializeForScript(targetOrigin);
const scriptFallbackUrl = serializeForScript(fallbackUrl);
const refused = payload.status === "error" && payload.error === "consent_refused";
const pageTitle = refused ? "Not Connected" : payload.status === "success" ? "Authentication Complete" : "Authentication Failed";
const scriptTitle = serializeForScript(pageTitle);
const scriptTitle = serializeForScript(
payload.status === "success" ? "Authentication Complete" : "Authentication Failed"
);
const scriptBody = serializeForScript(
refused
? "Nothing was connected and nothing was kept. You can return to the app."
: payload.status === "success"
? (payload.handoffKind === "backend_callback"
? "The app backend has the auth result already. You can return to the app now."
: "You can return to the app now.") +
(payload.consentReceiptId
? ` Your consent is recorded as receipt ${payload.consentReceiptId}; you can review it and take it back from your Heimdall connections page.`
: "")
payload.status === "success"
? payload.handoffKind === "backend_callback"
? "The app backend has the auth result already. You can return to the app now."
: "You can return to the app now."
: "The authentication flow failed. Return to the app to continue."
);
@ -108,7 +89,7 @@ export function renderBrowserHandoffPage(payload: BrowserHandoffPayload): string
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${pageTitle}</title>
<title>${payload.status === "success" ? "Authentication Complete" : "Authentication Failed"}</title>
<style>
:root {
color-scheme: light dark;

View file

@ -1,191 +0,0 @@
import { createHash } from "node:crypto";
import { type AppSlug, type Provider } from "./contracts.js";
import { getAppProfile } from "./app-profiles.js";
import { providerCatalog } from "./providers.js";
import { type StoredConsentReceipt } from "./store/index.js";
/**
* What Heimdall tells a person before it holds a provider credential for an
* app, and the page that lists what they agreed to. The wording is built from
* the facts the code holds (the app, the provider, the scopes sent), never
* from a promise the code does not keep. Any change to a line changes the
* digest every later receipt carries, so a reader can tell which wording a
* person saw.
*/
export interface ConsentAsk {
appSlug: AppSlug;
provider: Provider;
/** The scopes Heimdall will send the provider. */
scopes: readonly string[];
/** The origin the person returns to afterwards. */
returnOrigin: string;
}
export const GRANTS_PATH = "/v1/me/grants";
function appName(slug: AppSlug): string {
return getAppProfile(slug)?.displayName ?? slug;
}
/** The exact lines shown on the ask, apart from the two buttons. The digest is over these. */
export function consentLines(ask: ConsentAsk): string[] {
const app = appName(ask.appSlug);
const provider = providerCatalog[ask.provider].displayName;
return [
`${app} asks Heimdall to hold a connection to your ${provider} account.`,
`Heimdall will ask ${provider} for these permissions: ${ask.scopes.join(", ")}.`,
`Heimdall keeps an encrypted refresh token and access token for this connection until you take it back or ${provider} ends it.`,
`${app}'s server can ask Heimdall for a current access token and use it for those permissions. It never receives the refresh token.`,
`If you say no, nothing is kept.`,
`You can take this back at any time. Heimdall then deletes its copy of the tokens and stops handing out access tokens. The permission stays with ${provider} until you remove it in your ${provider} settings.`,
`Afterwards you are returned to ${ask.returnOrigin}.`,
];
}
/** The labels on the two buttons. They are wording the person agrees to, so they are part of the text. */
export function consentButtons(ask: ConsentAsk): { go: string; no: string } {
return { go: `Continue to ${providerCatalog[ask.provider].displayName}`, no: "No, return to the app" };
}
/**
* Everything the person reads before answering, as one text: the lines and the
* button labels. It is built once, when the ask is rendered, and that exact
* text travels with the flow to the receipt; nothing rebuilds it later.
*/
export function consentText(ask: ConsentAsk): string {
const { go, no } = consentButtons(ask);
return [...consentLines(ask), go, no].join("\n");
}
export function digestOfText(text: string): string {
return createHash("sha256").update(text, "utf8").digest("hex");
}
export function consentDigest(ask: ConsentAsk): string {
return digestOfText(consentText(ask));
}
export function escapeHtml(value: string): string {
return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
}
const pageStyle = `
:root { color-scheme: light dark; font-family: ui-sans-serif, system-ui, sans-serif; }
body { margin: 0; min-height: 100vh; display: grid; place-items: center; background: #11161d; color: #f4f7fb; }
main { width: min(38rem, calc(100vw - 2rem)); border-radius: 1rem; padding: 1.5rem; background: rgba(20, 27, 36, 0.96); box-shadow: 0 24px 60px rgba(0, 0, 0, 0.3); }
h1 { margin: 0 0 0.75rem; font-size: 1.35rem; }
p, li { line-height: 1.5; color: #d5dce6; }
ul { padding-left: 1.2rem; }
code { color: #d8eefe; }
button { font: inherit; font-weight: 600; border: 0; border-radius: 999px; padding: 0.75rem 1rem; margin: 0.25rem 0.5rem 0.25rem 0; cursor: pointer; }
button.go { color: #11161d; background: #d8eefe; }
button.no { color: #f4f7fb; background: #2c3745; }
section { border-top: 1px solid #2c3745; padding: 0.75rem 0; }
small { color: #9aacbe; }`;
function page(title: string, body: string): string {
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${escapeHtml(title)}</title>
<style>${pageStyle}
</style>
</head>
<body>
<main>
${body}
</main>
</body>
</html>`;
}
/** The ask. Two POST forms carry the signed state; the page runs no script. */
export function renderConsentPage(ask: ConsentAsk, state: string): string {
const lines = consentLines(ask);
const provider = providerCatalog[ask.provider].displayName;
const [headline, ...rest] = lines;
const form = (decision: "continue" | "decline", label: string) =>
`<button class="${decision === "continue" ? "go" : "no"}" type="submit" name="decision" value="${decision}">${escapeHtml(label)}</button>`;
return page(
`${appName(ask.appSlug)} wants to connect ${provider}`,
` <h1>${escapeHtml(headline!)}</h1>
<ul>
${rest.map((line) => ` <li>${escapeHtml(line)}</li>`).join("\n")}
</ul>
<form method="post" action="/v1/oauth/${ask.provider}/consent">
<input type="hidden" name="state" value="${escapeHtml(state)}">
${form("continue", consentButtons(ask).go)}
${form("decline", consentButtons(ask).no)}
</form>`
);
}
export interface GrantView {
consentText: string;
id: string;
appSlug: AppSlug;
provider: Provider;
scopesAsked: string[];
scopesGranted: string[];
grantedAt: string;
consentPageDigest: string;
status: "active" | "revoked";
revokedAt?: string;
revocationReceiptId?: string;
}
/** Every grant the receipts hold, each live unless a revocation names it. */
export function grantViews(receipts: readonly StoredConsentReceipt[]): GrantView[] {
const revocations = new Map<string, StoredConsentReceipt & { kind: "revocation" }>();
for (const receipt of receipts) {
if (receipt.kind === "revocation") revocations.set(receipt.revokesReceiptId, receipt);
}
const views: GrantView[] = [];
for (const receipt of receipts) {
if (receipt.kind !== "grant") continue;
const revocation = revocations.get(receipt.id);
views.push({
id: receipt.id,
appSlug: receipt.appSlug,
provider: receipt.provider,
scopesAsked: receipt.scopesAsked,
scopesGranted: receipt.scopesGranted,
grantedAt: receipt.createdAt,
consentPageDigest: receipt.consentPageDigest,
consentText: receipt.consentText,
status: revocation ? "revoked" : "active",
...(revocation ? { revokedAt: revocation.createdAt, revocationReceiptId: revocation.id } : {}),
});
}
return views;
}
/** The signed-in page: every grant with its receipt, one button each to take it back. */
export function renderGrantsPage(grants: readonly GrantView[], pageToken: string, notice?: string): string {
const cards = grants.length
? grants
.map((grant) => {
const provider = providerCatalog[grant.provider].displayName;
const header = `${escapeHtml(appName(grant.appSlug))} → ${escapeHtml(provider)}`;
const detail = `granted ${escapeHtml(grant.grantedAt)}; permissions ${escapeHtml(grant.scopesGranted.join(", ") || "none returned")}; receipt <code>${escapeHtml(grant.id)}</code>; wording <code>${escapeHtml(grant.consentPageDigest.slice(0, 12))}</code>`;
if (grant.status === "revoked") {
return ` <section><strong>${header}</strong> <small>taken back ${escapeHtml(grant.revokedAt ?? "")}; receipt <code>${escapeHtml(grant.revocationReceiptId ?? "")}</code></small><br><small>${detail}</small></section>`;
}
return ` <section><strong>${header}</strong> <small>active</small><br><small>${detail}</small>
<form method="post" action="${GRANTS_PATH}/${escapeHtml(grant.id)}/revoke">
<input type="hidden" name="t" value="${escapeHtml(pageToken)}">
<button class="no" type="submit">Take this back</button>
</form></section>`;
})
.join("\n")
: ` <p>You have not connected anything through Heimdall.</p>`;
return page(
"Your connections",
` <h1>Your connections</h1>
${notice ? ` <p><strong>${escapeHtml(notice)}</strong></p>\n` : ""}${cards}
<p><small>Taking a connection back deletes Heimdall's copy of the tokens. The app's next request for a token is refused. The permission stays with the provider until you remove it in the provider's settings.</small></p>`
);
}

View file

@ -1,4 +1,4 @@
export const providers = ["discord", "patreon", "github", "twitch", "youtube", "spotify", "joystick", "google"] as const;
export const providers = ["discord", "patreon", "github", "twitch", "youtube", "spotify", "joystick"] as const;
export type Provider = (typeof providers)[number];
@ -97,8 +97,6 @@ export interface OAuthStatePayload {
initiator_account_id: string | null;
handoff: OAuthHandoff | null;
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;
}
export interface LinkedIdentityInput {

View file

@ -1,228 +0,0 @@
import { createHash, createPublicKey, sign, verify, type KeyObject } from "node:crypto";
/**
* Google sign-in as OpenID Connect: the id_token is the identity, and this
* file is the one place that decides whether to believe it. The subject
* (`sub`) is the account key; the email is a claim about the subject, never
* its key.
*/
export const GOOGLE_ISSUERS = ["https://accounts.google.com", "accounts.google.com"] as const;
export const GOOGLE_JWKS_URI = "https://www.googleapis.com/oauth2/v3/certs";
const CLOCK_LEEWAY_SECONDS = 60;
const DEFAULT_JWKS_TTL_SECONDS = 3600;
const MAX_JWKS_TTL_SECONDS = 24 * 3600;
/** An unknown `kid` refetches the key set at most this often. */
const MIN_JWKS_REFETCH_MS = 60_000;
export type GoogleIdTokenFailure =
| "malformed"
| "unsupported_algorithm"
| "unknown_key"
| "bad_signature"
| "bad_issuer"
| "bad_audience"
| "expired"
| "not_yet_valid"
| "bad_nonce"
| "missing_subject"
| "email_missing"
| "email_not_verified"
| "jwks_unavailable";
/** A refusal that names the rule that failed and quotes nothing from the token. */
export class GoogleIdTokenError extends Error {
readonly code: GoogleIdTokenFailure;
constructor(code: GoogleIdTokenFailure) {
super(`Google id_token refused (${code}).`);
this.name = "GoogleIdTokenError";
this.code = code;
}
}
export interface GoogleIdentityClaims {
sub: string;
email: string;
name?: string;
picture?: string;
hd?: string;
}
interface Jwk {
kid?: string;
kty?: string;
alg?: string;
n?: string;
e?: string;
}
let cachedKeys: { keys: Map<string, KeyObject>; expiresAt: number; fetchedAt: number } | null = null;
export function resetGoogleJwksCache(): void {
cachedKeys = null;
}
async function fetchGoogleKeys(now: number): Promise<{ keys: Map<string, KeyObject>; expiresAt: number; fetchedAt: number }> {
let response: Response;
try {
response = await fetch(GOOGLE_JWKS_URI, { headers: { Accept: "application/json" } });
} catch {
throw new GoogleIdTokenError("jwks_unavailable");
}
if (!response.ok) {
throw new GoogleIdTokenError("jwks_unavailable");
}
let body: { keys?: Jwk[] };
try {
body = (await response.json()) as { keys?: Jwk[] };
} catch {
throw new GoogleIdTokenError("jwks_unavailable");
}
const keys = new Map<string, KeyObject>();
for (const jwk of body.keys ?? []) {
if (jwk.kty !== "RSA" || !jwk.kid || (jwk.alg !== undefined && jwk.alg !== "RS256")) continue;
try {
keys.set(jwk.kid, createPublicKey({ key: jwk as never, format: "jwk" }));
} catch {
// A key Node cannot read verifies nothing; the others stand.
}
}
const maxAge = /max-age=(\d+)/.exec(response.headers.get("cache-control") ?? "");
const ttl = Math.min(maxAge ? Number(maxAge[1]) : DEFAULT_JWKS_TTL_SECONDS, MAX_JWKS_TTL_SECONDS);
return { keys, expiresAt: now + ttl * 1000, fetchedAt: now };
}
async function keyForKid(kid: string, now: number): Promise<KeyObject> {
if (cachedKeys && cachedKeys.expiresAt > now) {
const hit = cachedKeys.keys.get(kid);
if (hit) return hit;
// Unknown kid in a live cache: Google may have rotated. Refetch, but not on every bad token.
if (now - cachedKeys.fetchedAt < MIN_JWKS_REFETCH_MS) {
throw new GoogleIdTokenError("unknown_key");
}
}
cachedKeys = await fetchGoogleKeys(now);
const key = cachedKeys.keys.get(kid);
if (!key) throw new GoogleIdTokenError("unknown_key");
return key;
}
function decodeSegment(segment: string): Record<string, unknown> {
try {
const value = JSON.parse(Buffer.from(segment, "base64url").toString("utf8")) as unknown;
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
return value as Record<string, unknown>;
}
} catch {
// fallthrough
}
throw new GoogleIdTokenError("malformed");
}
/**
* Verifies a Google id_token for this client. Order is signature first, then
* claims, so no claim of an unauthenticated token decides anything. Refuses
* on: algorithm other than RS256, unknown key, bad signature, `iss` not
* Google, `aud` not this client (an array `aud` also needs `azp` to be this
* client), `exp` passed, `iat` or `nbf` in the future, `nonce` not the one
* this authorization request carried, no `sub`, no `email`, and an email
* Google has not verified.
*/
export async function verifyGoogleIdToken(
idToken: string,
expected: { clientId: string; nonce: string },
nowMs: number = Date.now()
): Promise<GoogleIdentityClaims> {
const parts = idToken.split(".");
if (parts.length !== 3 || parts.some((part) => part === "")) {
throw new GoogleIdTokenError("malformed");
}
const [encodedHeader, encodedPayload, encodedSignature] = parts as [string, string, string];
const header = decodeSegment(encodedHeader);
if (header.alg !== "RS256") {
throw new GoogleIdTokenError("unsupported_algorithm");
}
if (typeof header.kid !== "string" || header.kid === "") {
throw new GoogleIdTokenError("unknown_key");
}
const key = await keyForKid(header.kid, nowMs);
const signatureValid = verify(
"RSA-SHA256",
Buffer.from(`${encodedHeader}.${encodedPayload}`, "utf8"),
key,
Buffer.from(encodedSignature, "base64url")
);
if (!signatureValid) {
throw new GoogleIdTokenError("bad_signature");
}
const claims = decodeSegment(encodedPayload);
const nowSeconds = Math.floor(nowMs / 1000);
if (typeof claims.iss !== "string" || !(GOOGLE_ISSUERS as readonly string[]).includes(claims.iss)) {
throw new GoogleIdTokenError("bad_issuer");
}
const aud = claims.aud;
const audienceOk =
typeof aud === "string"
? aud === expected.clientId
: Array.isArray(aud) && aud.includes(expected.clientId) && claims.azp === expected.clientId;
if (!audienceOk) {
throw new GoogleIdTokenError("bad_audience");
}
if (typeof claims.exp !== "number" || claims.exp + CLOCK_LEEWAY_SECONDS <= nowSeconds) {
throw new GoogleIdTokenError("expired");
}
if (typeof claims.iat !== "number" || claims.iat - CLOCK_LEEWAY_SECONDS > nowSeconds) {
throw new GoogleIdTokenError("not_yet_valid");
}
if (claims.nbf !== undefined && (typeof claims.nbf !== "number" || claims.nbf - CLOCK_LEEWAY_SECONDS > nowSeconds)) {
throw new GoogleIdTokenError("not_yet_valid");
}
if (typeof claims.nonce !== "string" || claims.nonce !== expected.nonce) {
throw new GoogleIdTokenError("bad_nonce");
}
if (typeof claims.sub !== "string" || claims.sub === "" || claims.sub.length > 255) {
throw new GoogleIdTokenError("missing_subject");
}
if (typeof claims.email !== "string" || claims.email === "") {
throw new GoogleIdTokenError("email_missing");
}
if (claims.email_verified !== true && claims.email_verified !== "true") {
throw new GoogleIdTokenError("email_not_verified");
}
const identity: GoogleIdentityClaims = { sub: claims.sub, email: claims.email };
if (typeof claims.name === "string" && claims.name !== "") identity.name = claims.name;
if (typeof claims.picture === "string" && claims.picture !== "") identity.picture = claims.picture;
if (typeof claims.hd === "string" && claims.hd !== "") identity.hd = claims.hd;
return identity;
}
/**
* The PKCE verifier and the nonce of one authorization request, derived from
* the request's state `jti` and Heimdall's signing key instead of stored. The
* state token travels through the browser and carries the `jti`, so whoever
* can read the redirect can read the `jti`; they cannot compute an Ed25519
* signature over it. The signature is deterministic, so start and callback
* derive the same pair, and nothing is persisted between them.
*/
export function deriveOidcSecrets(stateJti: string, signingKey: KeyObject): {
codeVerifier: string;
codeChallenge: string;
nonce: string;
} {
const derive = (label: string): string =>
createHash("sha256")
.update(sign(null, Buffer.from(`heimdall:oidc:${label}:${stateJti}`, "utf8"), signingKey))
.digest("base64url");
const codeVerifier = derive("pkce-verifier");
return {
codeVerifier,
codeChallenge: createHash("sha256").update(codeVerifier, "ascii").digest("base64url"),
nonce: derive("nonce"),
};
}

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 ExpiringTokenSet, type OAuthProviderRuntime, type OAuthRuntimeRegistry, ProviderResponseError } from "./oauth.js";
import { type OAuthProviderRuntime, type OAuthRuntimeRegistry, ProviderResponseError } from "./oauth.js";
import { providerCatalog } from "./providers.js";
import { type HeimdallStore } from "./store/index.js";
import { type CredentialDecision, type StoredLinkedIdentity } from "./store/types.js";
@ -16,14 +16,6 @@ export const MANAGED_CREDENTIAL_REFRESH_SKEW_MS = 5 * 60 * 1000;
*/
export const MANAGED_CREDENTIAL_REFRESH_TIMEOUT_MS = 10_000;
/**
* After a refresh attempt fails or times out, no process presents the row's
* refresh token again until this long has passed (the failure is recorded on
* the row, read under the lock). A provider that rotates may have consumed the
* token before the attempt was aborted, and presenting it again reads as theft.
*/
export const MANAGED_CREDENTIAL_REFRESH_BACKOFF_MS = 60 * 1000;
export interface ManagedCredentialContext {
config: HeimdallConfig;
store: HeimdallStore;
@ -63,17 +55,6 @@ function dueForRefresh(identity: StoredLinkedIdentity, runtime: OAuthProviderRun
return Date.parse(identity.tokenExpiresAt) - now <= MANAGED_CREDENTIAL_REFRESH_SKEW_MS;
}
/**
* Whether a failure stamped `failedAt` still holds this clock off. A stamp from
* a writer whose clock runs ahead reads as the future; one this far ahead or
* more is skew, not a fresh failure, so the wait stays bounded at twice the
* backoff.
*/
function withinBackoff(failedAt: string, now: number): boolean {
const elapsed = now - Date.parse(failedAt);
return elapsed > -MANAGED_CREDENTIAL_REFRESH_BACKOFF_MS && elapsed < MANAGED_CREDENTIAL_REFRESH_BACKOFF_MS;
}
function withoutTokens(identity: StoredLinkedIdentity): StoredLinkedIdentity {
const { accessTokenEncrypted: _a, refreshTokenEncrypted: _r, tokenExpiresAt: _e, ...rest } = identity;
return rest;
@ -87,8 +68,10 @@ function withoutTokens(identity: StoredLinkedIdentity): StoredLinkedIdentity {
* attempt is in flight join it and receive its outcome, success or failure.
* The entry lives exactly as long as the attempt: a later request retries.
*
* Across processes the row owns it: a failed attempt leaves a marker on the
* row, and a process that takes the lock inside the backoff presents nothing.
* Known residual risk (docs/service-contract.md): one presentation per
* process per burst, so N processes may present one token N times. The
* cross-process failed-refresh marker that would close it is
* joystick:follow_up:cross-process-failed-refresh-marker.
*/
const refreshesInFlight = new WeakMap<HeimdallStore, Map<string, Promise<ManagedCredentialOutcome>>>();
@ -137,8 +120,28 @@ async function refreshUnderLock(
const runtime = ctx.oauthRuntimes[provider];
let revocation: ManagedCredentialRevocationReason | undefined;
let failed = false;
const rotation = (locked: StoredLinkedIdentity, refreshed: ExpiringTokenSet, updatedAt: string): CredentialDecision => {
const decide = async (locked: StoredLinkedIdentity): Promise<CredentialDecision> => {
if (!locked.accessTokenEncrypted || !dueForRefresh(locked, runtime, clock())) {
return { kind: "keep" };
}
const updatedAt = new Date(clock()).toISOString();
const refreshToken = ctx.tokenCustody.decrypt(locked.refreshTokenEncrypted!);
let refreshed;
try {
refreshed = await runtime.refreshAccessToken!({
config: ctx.config,
refreshToken,
signal: AbortSignal.timeout(ctx.timeoutMs ?? MANAGED_CREDENTIAL_REFRESH_TIMEOUT_MS),
});
} catch (error) {
if (error instanceof ProviderResponseError && error.oauthError === "invalid_grant") {
revocation = "provider_revoked";
return { kind: "revoke", updatedAt };
}
throw new ManagedCredentialRefreshFailed(provider);
}
let refreshTokenEncrypted: string;
if (refreshed.refreshToken) {
refreshTokenEncrypted = ctx.tokenCustody.encrypt(refreshed.refreshToken);
@ -159,47 +162,9 @@ async function refreshUnderLock(
tokenExpiresAt: refreshed.expiresAt,
scopes: refreshed.scope.length ? refreshed.scope : locked.scopes,
updatedAt,
consumedRefreshToken: providerCatalog[provider].rotatesRefreshTokens,
};
};
const decide = async (locked: StoredLinkedIdentity): Promise<CredentialDecision> => {
if (!locked.accessTokenEncrypted || !dueForRefresh(locked, runtime, clock())) {
return { kind: "keep" };
}
if (locked.refreshFailedAt && withinBackoff(locked.refreshFailedAt, clock())) {
failed = true;
return { kind: "keep" };
}
const refreshToken = ctx.tokenCustody.decrypt(locked.refreshTokenEncrypted!);
let refreshed;
try {
refreshed = await runtime.refreshAccessToken!({
config: ctx.config,
refreshToken,
signal: AbortSignal.timeout(ctx.timeoutMs ?? MANAGED_CREDENTIAL_REFRESH_TIMEOUT_MS),
});
} catch (error) {
if (error instanceof ProviderResponseError && error.oauthError === "invalid_grant") {
revocation = "provider_revoked";
return { kind: "revoke", updatedAt: new Date(clock()).toISOString() };
}
failed = true;
return { kind: "failed", failedAt: new Date(clock()).toISOString() };
}
// The provider has answered, and may have consumed the token: from here a throw must still leave the marker, which
// a rolled-back transaction would not (the store writes it on its own for a rotation it could not commit).
const answeredAt = new Date(clock()).toISOString();
try {
return rotation(locked, refreshed, answeredAt);
} catch {
failed = true;
return { kind: "failed", failedAt: new Date(clock()).toISOString() };
}
};
const committed = await ctx.store.updateLinkedIdentityCredential(snapshot.id, decide);
if (revocation) {
await ctx.store.createAuditEvent({
@ -209,9 +174,6 @@ async function refreshUnderLock(
createdAt: new Date(clock()).toISOString(),
});
}
if (failed) {
throw new ManagedCredentialRefreshFailed(provider);
}
if (!committed) {
return { kind: "revoked", identity: withoutTokens(snapshot) };
}

View file

@ -7,7 +7,6 @@ import {
} from "./contracts.js";
import { type HeimdallConfig } from "./config.js";
import { entitlementFacts } from "./facts.js";
import { verifyGoogleIdToken } from "./google-oidc.js";
import { providerCatalog } from "./providers.js";
import { type CreateEntitlementSnapshotInput } from "./store/types.js";
@ -50,22 +49,13 @@ export interface OAuthProviderRuntime {
config: HeimdallConfig;
code: string;
redirectUri: string;
/** PKCE verifier of an OIDC provider's request (RFC 7636). */
codeVerifier?: string;
}): Promise<OAuthTokenSet>;
refreshAccessToken?(options: {
config: HeimdallConfig;
refreshToken: string;
signal?: AbortSignal;
}): Promise<ExpiringTokenSet>;
resolveIdentity(options: {
config: HeimdallConfig;
accessToken: string;
/** The exchange's answer: an OIDC provider's identity is its id_token, not a lookup with the access token. */
tokenSet?: OAuthTokenSet;
/** The nonce this authorization request carried; an OIDC provider's id_token must echo it. */
oidcNonce?: string;
}): Promise<ResolvedIdentity>;
resolveIdentity(options: { config: HeimdallConfig; accessToken: string }): Promise<ResolvedIdentity>;
evaluateEntitlements(options: {
config: HeimdallConfig;
callback: OAuthCallbackContext;
@ -154,7 +144,7 @@ function normalizeScopes(scope: string | string[] | undefined): string[] {
}
export type TokenGrant =
| { grant_type: "authorization_code"; code: string; redirect_uri: string; code_verifier?: string }
| { grant_type: "authorization_code"; code: string; redirect_uri: string }
| { grant_type: "refresh_token"; refresh_token: string };
/**
@ -259,7 +249,6 @@ function sharedRuntimeExchange(provider: Provider): OAuthProviderRuntime["exchan
grant_type: "authorization_code",
code: options.code,
redirect_uri: options.redirectUri,
...(options.codeVerifier !== undefined ? { code_verifier: options.codeVerifier } : {}),
});
// A rotating provider's refresh token is the only way back to the
// credential: a grant that arrives without one would be stored as a row
@ -538,31 +527,6 @@ async function resolveYouTubeIdentity(options: { accessToken: string }): Promise
return identity;
}
async function resolveGoogleIdentity(options: {
config: HeimdallConfig;
tokenSet?: OAuthTokenSet;
oidcNonce?: string;
}): Promise<ResolvedIdentity> {
const clientId = options.config.providers.google.clientId;
const idToken = options.tokenSet?.raw.id_token;
if (!clientId || typeof idToken !== "string" || !options.oidcNonce) {
throw new Error("Google sign-in needs a client id, an id_token and the request's nonce.");
}
const claims = await verifyGoogleIdToken(idToken, { clientId, nonce: options.oidcNonce });
const identity: ResolvedIdentity = {
provider: "google",
providerUserId: claims.sub,
username: claims.email,
displayName: claims.name ?? claims.email,
primaryEmail: claims.email,
profile: { sub: claims.sub, email: claims.email, email_verified: true, name: claims.name, picture: claims.picture, hd: claims.hd },
};
if (claims.picture) {
identity.avatarUrl = claims.picture;
}
return identity;
}
async function resolveSpotifyIdentity(options: { accessToken: string }): Promise<ResolvedIdentity> {
const profile = await fetchJson<{
id?: string;
@ -981,15 +945,6 @@ const youtubeRuntime: OAuthProviderRuntime = {
},
};
const googleRuntime: OAuthProviderRuntime = {
exchangeAuthorizationCode: sharedRuntimeExchange("google"),
refreshAccessToken: sharedRuntimeRefresh("google"),
resolveIdentity: resolveGoogleIdentity,
async evaluateEntitlements() {
return { facts: [], snapshots: [] };
},
};
const spotifyRuntime: OAuthProviderRuntime = {
exchangeAuthorizationCode: sharedRuntimeExchange("spotify"),
refreshAccessToken: sharedRuntimeRefresh("spotify"),
@ -1031,6 +986,5 @@ export function createOAuthRuntimeRegistry(): OAuthRuntimeRegistry {
youtube: youtubeRuntime,
spotify: spotifyRuntime,
joystick: joystickRuntime,
google: googleRuntime,
};
}

View file

@ -305,6 +305,7 @@ async function beginAuth(
throw new Error(String(startPayload.detail ?? startPayload.error ?? "OAuth start was denied."));
}
const authorizationUrl = String(startPayload.authorizationUrl ?? "");
const authorizationOrigin = new URL(authorizationUrl).origin;
return {
status: "accepted",
payloadSchema: "heimdall.auth_begin_receipt.v1",
@ -315,7 +316,7 @@ async function beginAuth(
expiresAt: attempt.expiresAt,
navigation: {
url: authorizationUrl,
allowedOrigins: [new URL(config.publicBaseUrl).origin],
allowedOrigins: [authorizationOrigin, new URL(config.publicBaseUrl).origin],
},
},
};

View file

@ -22,18 +22,6 @@ export interface ProviderDescriptor {
clientIdEnv: string;
clientSecretEnv: string;
additionalAuthorizationParams?: 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
* login makes an account. `requires_link` never mints: the identity
* attaches only to the account a verified link start bound
* (`initiator_account_id`), otherwise the callback refuses. Google is a second
* door into a person who already has an account, so a first Google login
* must not make a stranger of them.
*/
firstLogin: "mints_account" | "requires_link";
/** OpenID Connect: the callback verifies an id_token and uses PKCE (S256) and a nonce. */
oidc?: true;
}
export const providerCatalog: Record<Provider, ProviderDescriptor> = {
@ -47,7 +35,6 @@ export const providerCatalog: Record<Provider, ProviderDescriptor> = {
defaultAccessTokenLifetimeSeconds: 3600,
defaultScopes: ["identify", "guilds.members.read"],
roles: ["identity", "entitlement"],
firstLogin: "mints_account",
clientIdEnv: "GC_ACCESS_PROVIDER_DISCORD_CLIENT_ID",
clientSecretEnv: "GC_ACCESS_PROVIDER_DISCORD_CLIENT_SECRET_FILE",
},
@ -61,7 +48,6 @@ export const providerCatalog: Record<Provider, ProviderDescriptor> = {
defaultAccessTokenLifetimeSeconds: 3600,
defaultScopes: ["identity", "identity[email]"],
roles: ["identity", "entitlement"],
firstLogin: "mints_account",
clientIdEnv: "GC_ACCESS_PROVIDER_PATREON_CLIENT_ID",
clientSecretEnv: "GC_ACCESS_PROVIDER_PATREON_CLIENT_SECRET_FILE",
},
@ -75,7 +61,6 @@ export const providerCatalog: Record<Provider, ProviderDescriptor> = {
defaultAccessTokenLifetimeSeconds: 3600,
defaultScopes: ["read:user", "user:email"],
roles: ["identity", "managed_credential"],
firstLogin: "mints_account",
clientIdEnv: "GC_ACCESS_PROVIDER_GITHUB_CLIENT_ID",
clientSecretEnv: "GC_ACCESS_PROVIDER_GITHUB_CLIENT_SECRET_FILE",
},
@ -89,7 +74,6 @@ export const providerCatalog: Record<Provider, ProviderDescriptor> = {
defaultAccessTokenLifetimeSeconds: 3600,
defaultScopes: ["user:read:email"],
roles: ["identity", "managed_credential"],
firstLogin: "mints_account",
clientIdEnv: "GC_ACCESS_PROVIDER_TWITCH_CLIENT_ID",
clientSecretEnv: "GC_ACCESS_PROVIDER_TWITCH_CLIENT_SECRET_FILE",
},
@ -108,7 +92,6 @@ export const providerCatalog: Record<Provider, ProviderDescriptor> = {
"https://www.googleapis.com/auth/youtube.readonly",
],
roles: ["identity", "managed_credential"],
firstLogin: "mints_account",
clientIdEnv: "GC_ACCESS_PROVIDER_YOUTUBE_CLIENT_ID",
clientSecretEnv: "GC_ACCESS_PROVIDER_YOUTUBE_CLIENT_SECRET_FILE",
additionalAuthorizationParams: {
@ -131,26 +114,9 @@ export const providerCatalog: Record<Provider, ProviderDescriptor> = {
"user-modify-playback-state",
],
roles: ["identity", "managed_credential"],
firstLogin: "mints_account",
clientIdEnv: "GC_ACCESS_PROVIDER_SPOTIFY_CLIENT_ID",
clientSecretEnv: "GC_ACCESS_PROVIDER_SPOTIFY_CLIENT_SECRET_FILE",
},
google: {
key: "google",
displayName: "Google",
authorizationEndpoint: "https://accounts.google.com/o/oauth2/v2/auth",
tokenEndpoint: "https://oauth2.googleapis.com/token",
tokenEndpointAuthMethod: "client_secret_post",
rotatesRefreshTokens: false,
defaultAccessTokenLifetimeSeconds: 3600,
defaultScopes: ["openid", "email", "profile"],
roles: ["identity"],
firstLogin: "requires_link",
oidc: true,
clientIdEnv: "GC_ACCESS_PROVIDER_GOOGLE_CLIENT_ID",
clientSecretEnv: "GC_ACCESS_PROVIDER_GOOGLE_CLIENT_SECRET_FILE",
additionalAuthorizationParams: { prompt: "select_account" },
},
joystick: {
key: "joystick",
displayName: "Joystick",
@ -161,43 +127,27 @@ export const providerCatalog: Record<Provider, ProviderDescriptor> = {
defaultAccessTokenLifetimeSeconds: 3600,
defaultScopes: ["identity:read", "stream:read", "chat:read", "followers:read", "subscribers:read"],
roles: ["identity", "managed_credential"],
firstLogin: "mints_account",
clientIdEnv: "GC_ACCESS_PROVIDER_JOYSTICK_CLIENT_ID",
clientSecretEnv: "GC_ACCESS_PROVIDER_JOYSTICK_CLIENT_SECRET_FILE",
},
};
/** The scopes an authorization request carries: the caller's, or the provider's defaults. */
export function authorizationScopes(provider: Provider, requested: readonly string[] | undefined): string[] {
return requested?.length ? [...requested] : [...providerCatalog[provider].defaultScopes];
}
export function buildAuthorizationUrl(options: {
provider: Provider;
clientId: string;
redirectUri: string;
state: string;
requestedScopes: string[] | undefined;
/** For an OIDC provider: the nonce, and the S256 challenge of the PKCE verifier. */
oidc?: { nonce: string; codeChallenge: string };
}): string {
const descriptor = providerCatalog[options.provider];
const url = new URL(descriptor.authorizationEndpoint);
const scopes = authorizationScopes(options.provider, options.requestedScopes);
const scopes = options.requestedScopes?.length ? options.requestedScopes : descriptor.defaultScopes;
url.searchParams.set("client_id", options.clientId);
url.searchParams.set("redirect_uri", options.redirectUri);
url.searchParams.set("response_type", "code");
url.searchParams.set("scope", scopes.join(" "));
url.searchParams.set("state", options.state);
if (descriptor.oidc) {
if (!options.oidc) {
throw new Error(`${descriptor.displayName} is an OIDC provider: its authorization URL needs a nonce and a PKCE challenge.`);
}
url.searchParams.set("nonce", options.oidc.nonce);
url.searchParams.set("code_challenge", options.oidc.codeChallenge);
url.searchParams.set("code_challenge_method", "S256");
}
for (const [key, value] of Object.entries(descriptor.additionalAuthorizationParams ?? {})) {
url.searchParams.set(key, value);

View file

@ -3,12 +3,7 @@ import { type AppSlug, type LinkedIdentityInput, type Provider } from "../contra
import {
type CreateAccountInput,
type CreateAuthAttemptInput,
type ClaimOAuthStateUseInput,
type CreateAuthCompletionInput,
type CreateConsentReceiptInput,
type CreatePendingLinkInput,
type StoredConsentReceipt,
type StoredPendingLink,
type CreateAuditEventInput,
type CreateCapabilityGrantInput,
type CredentialDecision,
@ -26,9 +21,6 @@ import {
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;
function clone<T>(value: T): T {
return JSON.parse(JSON.stringify(value)) as T;
}
@ -55,13 +47,9 @@ export class InMemoryStore implements HeimdallStore {
private readonly authAttempts = new Map<string, StoredAuthAttempt>();
private readonly privateCommandReceipts = new Map<string, StoredPrivateCommandReceipt>();
private readonly authCompletions = new Map<string, StoredAuthCompletion>();
private readonly pendingLinks = new Map<string, StoredPendingLink>();
private readonly authCompletionsByAttempt = new Map<string, string>();
/** `${jti}:${phase}` -> the state's expiresAt. */
private readonly oauthStateUses = new Map<string, string>();
private readonly entitlementSnapshots = new Map<string, CreateEntitlementSnapshotInput>();
private readonly auditEvents = new Map<string, CreateAuditEventInput>();
private readonly consentReceipts: StoredConsentReceipt[] = [];
async ensureSchema(): Promise<void> {
return;
@ -203,13 +191,7 @@ export class InMemoryStore implements HeimdallStore {
if (decision.kind === "keep") {
return clone(current);
}
if (decision.kind === "failed") {
const marked: StoredLinkedIdentity = { ...current, refreshFailedAt: decision.failedAt };
this.linkedIdentities.set(id, clone(marked));
return clone(marked);
}
const next: StoredLinkedIdentity = { ...current, updatedAt: decision.updatedAt };
delete next.refreshFailedAt;
delete next.accessTokenEncrypted;
delete next.refreshTokenEncrypted;
delete next.tokenExpiresAt;
@ -448,37 +430,6 @@ export class InMemoryStore implements HeimdallStore {
return this.consumeAuthCompletion(appSlug, code, at);
}
async createPendingLink(input: CreatePendingLinkInput): Promise<StoredPendingLink> {
const link: StoredPendingLink = { id: randomUUID(), ...clone(input) };
this.pendingLinks.set(link.id, clone(link));
return clone(link);
}
async consumePendingLink(appSlug: AppSlug, id: string, accountId: string, at: string): Promise<StoredPendingLink | null> {
const link = this.pendingLinks.get(id);
if (!link || link.appSlug !== appSlug || link.accountId !== accountId || link.expiresAt <= at || link.consumedAt) {
return null;
}
link.consumedAt = at;
return clone(link);
}
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);
return true;
}
async upsertEntitlementSnapshot(input: CreateEntitlementSnapshotInput): Promise<void> {
const key = `${input.accountId}:${input.provider}:${input.scope}`;
this.entitlementSnapshots.set(key, clone(input));
@ -491,22 +442,4 @@ export class InMemoryStore implements HeimdallStore {
});
this.auditEvents.set(event.id ?? randomUUID(), event);
}
async createConsentReceipt(input: CreateConsentReceiptInput): Promise<{ receipt: StoredConsentReceipt; created: boolean }> {
if (input.kind === "revocation") {
// The table's foreign key: a revocation names a receipt of the same account.
if (!this.consentReceipts.some((r) => r.id === input.revokesReceiptId && r.accountId === input.accountId)) {
throw new Error("A revocation must name a receipt of its own account.");
}
const existing = this.consentReceipts.find((r) => r.kind === "revocation" && r.revokesReceiptId === input.revokesReceiptId);
if (existing) return { receipt: clone(existing), created: false };
}
const receipt = { id: randomUUID(), ...clone(input) } as StoredConsentReceipt;
this.consentReceipts.push(clone(receipt));
return { receipt: clone(receipt), created: true };
}
async listConsentReceipts(accountId: string): Promise<StoredConsentReceipt[]> {
return this.consentReceipts.filter((receipt) => receipt.accountId === accountId).map(clone);
}
}

View file

@ -5,12 +5,7 @@ import { CREATE_SCHEMA_SQL, REQUIRED_COLUMNS, REQUIRED_KEYS, REQUIRED_TABLES } f
import {
type CreateAccountInput,
type CreateAuthAttemptInput,
type ClaimOAuthStateUseInput,
type CreateAuthCompletionInput,
type CreateConsentReceiptInput,
type CreatePendingLinkInput,
type StoredConsentReceipt,
type StoredPendingLink,
type CreateAuditEventInput,
type CreateCapabilityGrantInput,
type CredentialDecision,
@ -47,7 +42,6 @@ interface LinkedIdentityRow extends QueryResultRow {
access_token_encrypted: string | null;
refresh_token_encrypted: string | null;
token_expires_at: string | null;
refresh_failed_at: string | null;
scopes: string;
profile_json: Record<string, unknown>;
created_at: string;
@ -95,19 +89,6 @@ interface AuthCompletionRow extends QueryResultRow {
consumed_at: string | Date | null;
}
interface PendingLinkRow extends QueryResultRow {
id: string;
app_slug: AppSlug;
account_id: string;
provider: Provider;
provider_user_id: string;
identity_json: StoredPendingLink["identity"];
return_to: string;
created_at: string | Date;
expires_at: string | Date;
consumed_at: string | Date | null;
}
interface AuthAttemptRow extends QueryResultRow {
handle: string;
app_slug: AppSlug;
@ -208,10 +189,6 @@ function mapLinkedIdentityRow(row: LinkedIdentityRow): StoredLinkedIdentity {
identity.tokenExpiresAt = new Date(row.token_expires_at).toISOString();
}
if (row.refresh_failed_at) {
identity.refreshFailedAt = new Date(row.refresh_failed_at).toISOString();
}
return identity;
}
@ -255,59 +232,6 @@ function mapSessionRow(row: SessionRow): StoredSession {
};
}
interface ConsentReceiptRow {
id: string;
kind: "grant" | "revocation";
account_id: string;
app_slug: AppSlug;
provider: Provider;
created_at: Date | string;
scopes_asked: string | null;
scopes_granted: string | null;
consent_page_digest: string | null;
consent_text: string | null;
revokes_receipt_id: string | null;
}
function mapConsentReceiptRow(row: ConsentReceiptRow): StoredConsentReceipt {
const base = {
id: row.id,
accountId: row.account_id,
appSlug: row.app_slug,
provider: row.provider,
createdAt: normalizeTimestamp(row.created_at),
};
if (row.kind === "revocation") {
return { ...base, kind: "revocation", revokesReceiptId: row.revokes_receipt_id ?? "" };
}
return {
...base,
kind: "grant",
scopesAsked: parseScopes(row.scopes_asked ?? ""),
scopesGranted: parseScopes(row.scopes_granted ?? ""),
consentPageDigest: row.consent_page_digest ?? "",
consentText: row.consent_text ?? "",
};
}
function mapPendingLinkRow(row: PendingLinkRow): StoredPendingLink {
const link: StoredPendingLink = {
id: row.id,
appSlug: row.app_slug,
accountId: row.account_id,
provider: row.provider,
providerUserId: row.provider_user_id,
identity: row.identity_json,
returnTo: row.return_to,
createdAt: normalizeTimestamp(row.created_at),
expiresAt: normalizeTimestamp(row.expires_at),
};
if (row.consumed_at) {
link.consumedAt = normalizeTimestamp(row.consumed_at);
}
return link;
}
function mapAuthCompletionRow(row: AuthCompletionRow): StoredAuthCompletion {
const completion: StoredAuthCompletion = {
code: row.code,
@ -459,11 +383,8 @@ export const STORE_TABLE_PRIVILEGES: Readonly<Record<string, readonly ("SELECT"
auth_attempts: ["SELECT", "INSERT", "UPDATE"],
private_command_receipts: ["SELECT", "INSERT"],
auth_completions: ["SELECT", "INSERT", "UPDATE"],
pending_links: ["SELECT", "INSERT", "UPDATE"],
oauth_state_uses: ["INSERT"],
capability_grants: ["SELECT", "INSERT"],
entitlement_snapshots: ["SELECT", "INSERT", "UPDATE"],
consent_receipts: ["SELECT", "INSERT"],
audit_events: ["INSERT"],
};
@ -782,7 +703,6 @@ export class PostgresStore implements HeimdallStore {
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
@ -816,7 +736,6 @@ export class PostgresStore implements HeimdallStore {
const client = await this.pool.connect();
try {
await client.query("BEGIN");
let decision: CredentialDecision | undefined;
try {
const locked = await client.query<LinkedIdentityRow>(
"SELECT * FROM linked_identities WHERE id = $1 FOR UPDATE",
@ -827,15 +746,9 @@ export class PostgresStore implements HeimdallStore {
return null;
}
const row = mapLinkedIdentityRow(expectRow(locked.rows[0], "updateLinkedIdentityCredential"));
decision = await decide(row);
const decision = await decide(row);
let committed = row;
if (decision.kind === "failed") {
const marked = await client.query<LinkedIdentityRow>(
"UPDATE linked_identities SET refresh_failed_at = $2 WHERE id = $1 RETURNING *",
[id, decision.failedAt]
);
committed = mapLinkedIdentityRow(expectRow(marked.rows[0], "updateLinkedIdentityCredential"));
} else if (decision.kind !== "keep") {
if (decision.kind !== "keep") {
const rotated = decision.kind === "rotate" ? decision : undefined;
const written = await client.query<LinkedIdentityRow>(
`
@ -843,7 +756,6 @@ export class PostgresStore implements HeimdallStore {
SET access_token_encrypted = $2,
refresh_token_encrypted = $3,
token_expires_at = $4,
refresh_failed_at = NULL,
scopes = $5,
updated_at = $6
WHERE id = $1
@ -864,21 +776,6 @@ export class PostgresStore implements HeimdallStore {
return committed;
} catch (error) {
await client.query("ROLLBACK");
if (decision?.kind === "rotate" || decision?.kind === "revoke") {
// The provider answered, and what its answer called for could not be committed: the rollback took the answer
// with it. In a transaction of its own, leave the marker, so no caller presents the token within the backoff.
// Blank the token only when it is spent: a revoke, or a provider that rotates (Joystick) and consumed it.
// A provider that keeps its refresh token valid (Twitch) keeps it, so a transient write failure does not
// strand the link. Best effort: if this write fails too, the original error is the one that matters, and the
// residual is named in docs/service-contract.md.
const spent = decision.kind === "revoke" || decision.consumedRefreshToken;
await client
.query(
`UPDATE linked_identities SET ${spent ? "refresh_token_encrypted = NULL, " : ""}refresh_failed_at = $2 WHERE id = $1`,
[id, decision.updatedAt]
)
.catch(() => undefined);
}
throw error;
}
} finally {
@ -1178,61 +1075,6 @@ export class PostgresStore implements HeimdallStore {
return result.rowCount ? mapAuthCompletionRow(expectRow(result.rows[0], "consumeAuthCompletionByAttempt")) : null;
}
async createPendingLink(input: CreatePendingLinkInput): Promise<StoredPendingLink> {
const result = await this.pool.query<PendingLinkRow>(
`
INSERT INTO pending_links (
id, app_slug, account_id, provider, provider_user_id, identity_json,
return_to, created_at, expires_at
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING *
`,
[
randomUUID(),
input.appSlug,
input.accountId,
input.provider,
input.providerUserId,
JSON.stringify(input.identity),
input.returnTo,
input.createdAt,
input.expiresAt,
]
);
return mapPendingLinkRow(expectRow(result.rows[0], "createPendingLink"));
}
async consumePendingLink(appSlug: AppSlug, id: string, accountId: string, at: string): Promise<StoredPendingLink | null> {
const result = await this.pool.query<PendingLinkRow>(
`
UPDATE pending_links
SET consumed_at = $4
WHERE id = $1
AND app_slug = $2
AND account_id = $3
AND consumed_at IS NULL
AND expires_at > $4
RETURNING *
`,
[id, appSlug, accountId, at]
);
return result.rowCount ? mapPendingLinkRow(expectRow(result.rows[0], "consumePendingLink")) : null;
}
async claimOAuthStateUse(input: ClaimOAuthStateUseInput): Promise<boolean> {
// No conflict target and no RETURNING: both need SELECT, and the role has INSERT only.
const result = await this.pool.query(
`
INSERT INTO oauth_state_uses (jti, phase, app_slug, used_at, expires_at)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT DO NOTHING
`,
[input.jti, input.phase, input.appSlug, input.usedAt, input.expiresAt]
);
return result.rowCount === 1;
}
async upsertEntitlementSnapshot(input: CreateEntitlementSnapshotInput): Promise<void> {
await this.pool.query(
`
@ -1263,52 +1105,6 @@ export class PostgresStore implements HeimdallStore {
);
}
async createConsentReceipt(input: CreateConsentReceiptInput): Promise<{ receipt: StoredConsentReceipt; created: boolean }> {
const grant = input.kind === "grant" ? input : null;
// No conflict target and the partial unique index decides: a second
// revocation of one grant inserts nothing and the first is read back.
const result = await this.pool.query<ConsentReceiptRow>(
`
INSERT INTO consent_receipts (
id, kind, account_id, app_slug, provider, created_at,
scopes_asked, scopes_granted, consent_page_digest, consent_text, revokes_receipt_id
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
ON CONFLICT DO NOTHING
RETURNING *
`,
[
randomUUID(),
input.kind,
input.accountId,
input.appSlug,
input.provider,
input.createdAt,
grant ? grant.scopesAsked.join(" ") : null,
grant ? grant.scopesGranted.join(" ") : null,
grant ? grant.consentPageDigest : null,
grant ? grant.consentText : null,
input.kind === "revocation" ? input.revokesReceiptId : null,
]
);
if (result.rowCount) {
return { receipt: mapConsentReceiptRow(expectRow(result.rows[0], "createConsentReceipt")), created: true };
}
const existing = await this.pool.query<ConsentReceiptRow>(
`SELECT * FROM consent_receipts WHERE revokes_receipt_id = $1`,
[input.kind === "revocation" ? input.revokesReceiptId : null]
);
return { receipt: mapConsentReceiptRow(expectRow(existing.rows[0], "createConsentReceipt")), created: false };
}
async listConsentReceipts(accountId: string): Promise<StoredConsentReceipt[]> {
const result = await this.pool.query<ConsentReceiptRow>(
`SELECT * FROM consent_receipts WHERE account_id = $1 ORDER BY created_at, id`,
[accountId]
);
return result.rows.map(mapConsentReceiptRow);
}
async createAuditEvent(input: CreateAuditEventInput): Promise<void> {
await this.pool.query(
`

View file

@ -82,19 +82,6 @@ CREATE TABLE IF NOT EXISTS private_command_receipts (
CREATE INDEX IF NOT EXISTS private_command_receipts_expiry_idx
ON private_command_receipts(expires_at);
CREATE TABLE IF NOT EXISTS pending_links (
id TEXT PRIMARY KEY,
app_slug TEXT NOT NULL,
account_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
provider TEXT NOT NULL,
provider_user_id TEXT NOT NULL,
identity_json JSONB NOT NULL,
return_to TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
consumed_at TIMESTAMPTZ
);
CREATE TABLE IF NOT EXISTS auth_completions (
code TEXT PRIMARY KEY,
app_slug TEXT NOT NULL,
@ -123,11 +110,6 @@ ALTER TABLE auth_completions ADD COLUMN IF NOT EXISTS attempt_id TEXT;
-- compares it against the target the redeeming app states.
ALTER TABLE auth_completions ADD COLUMN IF NOT EXISTS target_id TEXT;
-- When a refresh attempt on this row last failed or timed out, read under the row
-- lock so a second Heimdall process does not present the same refresh token
-- right behind it. Cleared by rotation, revocation and re-link.
ALTER TABLE linked_identities ADD COLUMN IF NOT EXISTS refresh_failed_at TIMESTAMPTZ;
-- One attempt handle may bind at most one unconsumed completion (R21.1): a
-- second completion for the same handle would let the by-attempt UPDATE in
-- consumeAuthCompletionByAttempt match more than one row, silently consuming
@ -141,20 +123,6 @@ CREATE UNIQUE INDEX IF NOT EXISTS auth_completions_attempt_unconsumed_unique_idx
ON auth_completions(app_slug, attempt_id)
WHERE attempt_id IS NOT NULL AND consumed_at IS NULL;
-- 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.
CREATE TABLE IF NOT EXISTS oauth_state_uses (
jti TEXT NOT NULL,
phase TEXT NOT NULL,
app_slug TEXT NOT NULL,
used_at TIMESTAMPTZ NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
PRIMARY KEY (jti, phase)
);
CREATE TABLE IF NOT EXISTS capability_grants (
id TEXT PRIMARY KEY,
account_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
@ -185,34 +153,6 @@ CREATE TABLE IF NOT EXISTS entitlement_snapshots (
UNIQUE(account_id, provider, scope)
);
-- Consent receipts are append-only (the store role has INSERT and SELECT
-- only): a revocation is a row that names the grant it takes back.
CREATE TABLE IF NOT EXISTS consent_receipts (
id TEXT PRIMARY KEY,
kind TEXT NOT NULL,
account_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
app_slug TEXT NOT NULL,
provider TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL,
scopes_asked TEXT,
scopes_granted TEXT,
consent_page_digest TEXT,
consent_text TEXT,
revokes_receipt_id TEXT,
UNIQUE(id, account_id),
FOREIGN KEY (revokes_receipt_id, account_id) REFERENCES consent_receipts(id, account_id)
);
-- A revocation names a receipt of its own account (the composite foreign key;
-- NULL skips it for grants). A grant is taken back once: the second
-- revocation naming it conflicts.
CREATE UNIQUE INDEX IF NOT EXISTS consent_receipts_revokes_unique_idx
ON consent_receipts(revokes_receipt_id)
WHERE revokes_receipt_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS consent_receipts_account_idx
ON consent_receipts(account_id, created_at);
CREATE TABLE IF NOT EXISTS audit_events (
id TEXT PRIMARY KEY,
account_id TEXT,
@ -274,7 +214,7 @@ export const REQUIRED_TABLES = createdTables.map(({ table }) => table);
export const REQUIRED_COLUMNS = [
...createdTables.flatMap(({ table, lines }) =>
lines
.filter((line) => !/^(?:PRIMARY KEY|UNIQUE|FOREIGN KEY)\b/.test(line))
.filter((line) => !/^(?:PRIMARY KEY|UNIQUE)\b/.test(line))
.map((line) => {
const [, column, type] = /^(\w+) (\w+)/.exec(line)!;
return { table, column: column!, type: type!, nullable: !/\b(?:NOT NULL|PRIMARY KEY)\b/.test(line) };

View file

@ -33,8 +33,6 @@ 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;
@ -60,9 +58,8 @@ export interface UpsertLinkedIdentityInput {
/**
* What the owner of a refresh decides after reading the row under its lock.
* `rotate` and `revoke` write token columns, scopes, updated_at and clear the
* failed-refresh marker; `failed` writes the marker alone. None touches
* account, profile or identity fields.
* `rotate` and `revoke` write token columns, scopes and updated_at only; they
* never touch account, profile or identity fields.
*/
export type CredentialDecision =
| { kind: "keep" }
@ -73,11 +70,8 @@ export type CredentialDecision =
tokenExpiresAt: string;
scopes: string[];
updatedAt: string;
/** The provider rotates refresh tokens, so the one presented is spent whether or not this decision commits. */
consumedRefreshToken: boolean;
}
| { kind: "revoke"; updatedAt: string }
| { kind: "failed"; failedAt: string };
| { kind: "revoke"; updatedAt: string };
export interface StoredSession {
id: string;
@ -173,14 +167,6 @@ export interface StoredAuthCompletion {
payloadJson: Record<string, unknown>;
}
export interface ClaimOAuthStateUseInput {
jti: string;
phase: "launch" | "callback";
appSlug: AppSlug;
usedAt: string;
expiresAt: string;
}
export interface CreateAuthCompletionInput {
/**
* Correlation handle only (see StoredAuthCompletion.attemptId). The store
@ -199,28 +185,6 @@ export interface CreateAuthCompletionInput {
payloadJson: Record<string, unknown>;
}
/**
* A link the callback has verified but not written. The identity attaches to
* `accountId` (the account whose Heimdall access token began the link) only
* when that same account confirms it; until then no linked-identity row
* exists. `identity` holds the already-encrypted row to write.
*/
export interface StoredPendingLink {
id: string;
appSlug: AppSlug;
accountId: string;
provider: Provider;
providerUserId: string;
identity: UpsertLinkedIdentityInput;
returnTo: string;
createdAt: string;
expiresAt: string;
consumedAt?: string;
}
/** The store mints `id`; a caller cannot choose it. */
export type CreatePendingLinkInput = Omit<StoredPendingLink, "id" | "consumedAt">;
export interface StoredAuthAttempt {
handle: string;
appSlug: AppSlug;
@ -258,33 +222,6 @@ export interface StoredPrivateCommandReceipt {
export type CreatePrivateCommandReceiptInput = StoredPrivateCommandReceipt;
/**
* What a person agreed to, or took back. Append-only: a grant row is never
* edited, and a revocation is a second row naming it, so a grant is live
* exactly when no revocation names it. `scopesAsked` is what Heimdall sent the
* provider, `scopesGranted` what the provider returned, `consentPageDigest`
* the hash of the wording the person was shown and `consentText` that wording
* itself. A grant is revoked at most once: a second revocation naming the same
* grant is not written, and the store answers with the first.
*/
export type StoredConsentReceipt = {
id: string;
accountId: string;
appSlug: AppSlug;
provider: Provider;
createdAt: string;
} & (
| { kind: "grant"; scopesAsked: string[]; scopesGranted: string[]; consentPageDigest: string; consentText: string }
| { kind: "revocation"; revokesReceiptId: string }
);
/** The store mints `id`; a caller cannot choose it. */
export type CreateConsentReceiptInput = StoredConsentReceipt extends infer R
? R extends StoredConsentReceipt
? Omit<R, "id">
: never
: never;
export interface HeimdallStore {
ensureSchema(): Promise<void>;
close(): Promise<void>;
@ -339,27 +276,6 @@ export interface HeimdallStore {
* it created without ever needing to know (or forge) the code.
*/
consumeAuthCompletionByAttempt(appSlug: AppSlug, attemptId: string, at: string): Promise<StoredAuthCompletion | null>;
createPendingLink(input: CreatePendingLinkInput): Promise<StoredPendingLink>;
/**
* Single-use redemption: returns the link only for the app and the account
* it was begun by, before it expires and once. A mismatch returns null and
* leaves the link unconsumed.
*/
consumePendingLink(appSlug: AppSlug, id: string, accountId: string, at: string): Promise<StoredPendingLink | null>;
upsertEntitlementSnapshot(input: CreateEntitlementSnapshotInput): Promise<void>;
/**
* Claim one use of a signed OAuth state: true only for the first claim of
* (jti, phase), false for every later one. Without it a state is a bearer
* token any number of browsers can complete, so an attacker who learns a
* victim's state completes it as themselves and hands the victim a code
* for the attacker's account. `expiresAt` is the state's own expiry; the
* state is refused at that time before any claim, so the record is dead
* after it and a store may drop it.
*/
claimOAuthStateUse(input: ClaimOAuthStateUseInput): Promise<boolean>;
createAuditEvent(input: CreateAuditEventInput): Promise<void>;
/** `created` is false when a revocation for that grant already existed and is what comes back. */
createConsentReceipt(input: CreateConsentReceiptInput): Promise<{ receipt: StoredConsentReceipt; created: boolean }>;
/** Every receipt the account holds, oldest first. */
listConsentReceipts(accountId: string): Promise<StoredConsentReceipt[]>;
}

View file

@ -8,7 +8,6 @@ import { entitlementFacts } from "../src/facts.js";
import { startHeimdallPrivateCommandPlane } from "../src/private-command-plane.js";
import { openPrivateEnvelope, sealPrivateEnvelope, type HeimdallPrivateEnvelope } from "../src/private-command-security.js";
import { type OAuthProviderRuntime } from "../src/oauth.js";
import { launchState } from "./oauth-flow.js";
const secret = "ghostlight-private-command-secret";
const resources: Array<{ close(): Promise<unknown> }> = [];
@ -108,12 +107,9 @@ describe("Heimdall Eve access plugin", () => {
const begin = openPrivateEnvelope(beginWire, secret);
expect(begin.schema).toBe("heimdall.auth_begin_receipt.v1");
const handle = String(begin.handle);
const navigation = begin.navigation as { url: string; allowedOrigins: string[] };
const navigation = begin.navigation as { url: string };
const state = new URL(navigation.url).searchParams.get("state");
expect(state).toBeTruthy();
// The browser goes to Heimdall's launch hop first, and only Heimdall's origin is allowed.
expect(navigation.url.startsWith("https://heimdall.gamecult.org/v1/oauth/discord/launch?state=")).toBe(true);
expect(navigation.allowedOrigins).toEqual(["https://heimdall.gamecult.org"]);
const pendingEnvelope = sealPrivateEnvelope({
appSlug: "ghostlight",
@ -128,11 +124,9 @@ describe("Heimdall Eve access plugin", () => {
const pending = openPrivateEnvelope(decodeEnvelope(pendingResponse.payload), secret);
expect(pending).toMatchObject({ status: "pending", handle });
const { cookie } = await launchState(app, state ?? "");
const callback = await app.inject({
method: "GET",
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(state ?? "")}`,
headers: { cookie },
});
expect(callback.statusCode, callback.body).toBe(201);
// The completion code is always minted by the store now, never the
@ -297,7 +291,7 @@ function testConfig(): HeimdallConfig {
storage: { backend: "memory", applySchemaOnStartup: true },
providers: {
discord: { clientId: "discord-client", clientSecret: "discord-secret" },
patreon: {}, github: {}, twitch: {}, youtube: {}, spotify: {}, joystick: {}, google: {},
patreon: {}, github: {}, twitch: {}, youtube: {}, spotify: {}, joystick: {},
},
};
}

View file

@ -4,8 +4,7 @@ import { join } from "node:path";
import { inspect } from "node:util";
import { afterEach, describe, expect, it } from "vitest";
import { type FastifyInstance } from "fastify";
import { buildApp, errorIdentity, getHeimdallRuntimeContext, startOAuthFlow } from "../src/app.js";
import { resolveAppCaller } from "../src/app-caller.js";
import { buildApp, errorIdentity } from "../src/app.js";
import { builtInAppProfiles, callbackMint } from "../src/app-profiles.js";
import { deliverBackendHandoff, type BackendHandoffPayload } from "../src/backend-handoff.js";
import { type HeimdallConfig, loadConfig } from "../src/config.js";
@ -13,7 +12,96 @@ import { entitlementFacts, identityFacts } from "../src/facts.js";
import { createOAuthRuntimeRegistry, ProviderResponseError, type OAuthProviderRuntime } from "../src/oauth.js";
import { createRuntimeKeyMaterial, signJwt, verifyJwt } from "../src/signing.js";
import { InMemoryStore } from "../src/store/index.js";
import { createMockDiscordRuntime, createTestConfig, launchFlow, providerRedirect, stateOf, type LaunchedFlow } from "./oauth-flow.js";
function createTestConfig(): HeimdallConfig {
return {
serviceName: "heimdall",
host: "127.0.0.1",
port: 4100,
workspaceRoot: "E:/Projects/Heimdall",
dataRoot: "E:/Projects/Heimdall/.heimdall-data",
cultCachePath: "E:/Projects/Heimdall/.heimdall-data/cultcache/heimdall.service.cc",
publicBaseUrl: "https://heimdall.gamecult.org",
issuer: "https://heimdall.gamecult.org",
daemonId: "yggdrasil-heimdall",
idunnRudpHealth: undefined,
idunnHealthContract: "heimdall.cultnet-rudp-provider-health",
providerHealthIdentityPath: "E:/Projects/Heimdall/.heimdall-data/provider-health-identity.cc",
sessionTtlSeconds: 3600,
refreshTtlSeconds: 60 * 60 * 24 * 30,
stateTtlSeconds: 600,
completionTtlSeconds: 300,
bootstrapSigningPrivateKeyOnMissing: false,
tokenEncryptionKeyBase64: Buffer.alloc(32, 7).toString("base64"),
appSharedSecrets: {
streampixels: "streampixels-secret",
repixelizer: "repixelizer-secret",
bifrost: "bifrost-secret",
},
appBackendCallbacks: {
bifrost: ["https://bifrost.gamecult.org/auth/heimdall/callback"],
repixelizer: ["https://repixelizer.gamecult.org/api/auth/heimdall/callback"],
},
storage: {
backend: "memory",
applySchemaOnStartup: true,
},
providers: {
discord: { clientId: "discord-client", clientSecret: "discord-secret" },
patreon: { clientId: "patreon-client", clientSecret: "patreon-secret" },
github: { clientId: "github-client", clientSecret: "github-secret" },
twitch: { clientId: "twitch-client", clientSecret: "twitch-secret" },
youtube: { clientId: "youtube-client", clientSecret: "youtube-secret" },
spotify: { clientId: "spotify-client", clientSecret: "spotify-secret" },
joystick: { clientId: "joystick-client", clientSecret: "joystick-secret" },
},
};
}
function createMockDiscordRuntime(): OAuthProviderRuntime {
return {
async exchangeAuthorizationCode() {
return {
accessToken: "discord-access-token",
refreshToken: "discord-refresh-token",
tokenType: "Bearer",
scope: ["identify", "guilds.members.read"],
expiresAt: "2026-04-26T13:00:00.000Z",
raw: { source: "test" },
};
},
async resolveIdentity() {
return {
provider: "discord",
providerUserId: "discord-user-123",
username: "meta",
displayName: "Meta",
primaryEmail: "meta@gamecult.org",
profile: { id: "discord-user-123", username: "meta" },
};
},
async evaluateEntitlements({ callback }) {
return {
facts: [entitlementFacts.appAccess],
snapshots: [
{
accountId: callback.accountId,
provider: "discord",
scope: "repixelizer:discord_role_access:gamecult-guild",
evaluatedAt: "2026-04-26T12:00:00.000Z",
isAllowed: true,
reasonCode: "matched_role",
reasonDetail: "Matched Repixelizer access role.",
rawSummaryJson: {
guildId: "gamecult-guild",
matchedRoles: ["role-repixelizer"],
},
},
],
};
},
};
}
const apps: Awaited<ReturnType<typeof buildApp>>[] = [];
const originalFetch = globalThis.fetch;
@ -46,7 +134,7 @@ function getHeimdallContext(app: FastifyInstance) {
}).heimdallContext;
}
async function startDiscordSignIn(app: FastifyInstance): Promise<LaunchedFlow> {
async function startDiscordSignIn(app: FastifyInstance): Promise<string> {
const response = await app.inject({
method: "POST",
url: "/v1/oauth/discord/start",
@ -58,7 +146,7 @@ async function startDiscordSignIn(app: FastifyInstance): Promise<LaunchedFlow> {
});
expect(response.statusCode).toBe(201);
return launchFlow(app, response.json());
return response.json().stateToken as string;
}
afterEach(async () => {
@ -119,9 +207,9 @@ describe("Heimdall service", () => {
expect(response.statusCode).toBe(201);
const payload = response.json();
expect((await providerRedirect(app, payload)).href).toContain("discord.com/oauth2/authorize");
expect(payload.authorizationUrl).toContain("discord.com/oauth2/authorize");
const verified = verifyJwt(stateOf(payload), getPublicKey(app));
const verified = verifyJwt(payload.stateToken as string, getPublicKey(app));
expect(verified.valid).toBe(true);
if (verified.valid) {
expect(verified.payload).toEqual(
@ -161,170 +249,6 @@ describe("Heimdall service", () => {
expect(response.json()).toEqual(expect.objectContaining({ error: "app_auth_required" }));
});
describe("apps that bind browser completion to their own attempt", () => {
// Bifrost's browser binding is the secrecy of its attempt nonce, which
// travels readable in the signed state. Only Bifrost may mint a Bifrost
// state, so a known nonce cannot carry a stranger's completion
// (finding known-nonce-login-csrf).
const victimAttempt = "VICTIM-ATTEMPT-NONCE";
const bifrostReturn = `https://bifrost.gamecult.org/auth/heimdall/complete?attempt=${victimAttempt}`;
const bifrostStart = (handoff?: { kind: "browser_completion" }) => ({
appSlug: "bifrost" as const,
mode: "sign_in" as const,
returnTo: bifrostReturn,
...(handoff ? { handoff } : {}),
});
it("refuses an unauthenticated Bifrost start for browser_completion and for an omitted handoff", async () => {
const app = await buildApp({ config: createTestConfig() });
apps.push(app);
for (const payload of [bifrostStart({ kind: "browser_completion" }), bifrostStart()]) {
const response = await app.inject({ method: "POST", url: "/v1/oauth/discord/start", payload });
expect(response.statusCode).toBe(401);
expect(response.json()).toEqual({ error: "app_auth_required" });
}
});
it("the attack fails: a stranger cannot obtain a Bifrost state carrying a chosen return_to nonce", async () => {
const app = await buildApp({ config: createTestConfig() });
apps.push(app);
const response = await app.inject({
method: "POST",
url: "/v1/oauth/discord/start",
payload: bifrostStart({ kind: "browser_completion" }),
});
expect(response.statusCode).toBe(401);
expect(response.body).not.toContain(victimAttempt);
expect(response.body).not.toContain("stateToken");
expect(response.body).not.toContain("authorizationUrl");
});
it("still starts for Bifrost's own app secret, with the requested return_to in the state", async () => {
const app = await buildApp({ config: createTestConfig() });
apps.push(app);
const response = await app.inject({
method: "POST",
url: "/v1/oauth/discord/start",
headers: { "x-heimdall-app-secret": "bifrost-secret" },
payload: bifrostStart({ kind: "browser_completion" }),
});
expect(response.statusCode).toBe(201);
const verified = verifyJwt(stateOf(response.json()), getPublicKey(app));
expect(verified.valid).toBe(true);
if (verified.valid) {
expect(verified.payload).toEqual(expect.objectContaining({ app_slug: "bifrost", return_to: bifrostReturn }));
}
});
it("refuses another app's credentials on a Bifrost start", async () => {
const app = await buildApp({ config: createTestConfig() });
apps.push(app);
for (const secret of ["streampixels-secret", "repixelizer-secret"]) {
const response = await app.inject({
method: "POST",
url: "/v1/oauth/discord/start",
headers: { "x-heimdall-app-secret": secret },
payload: bifrostStart({ kind: "browser_completion" }),
});
expect(response.statusCode, secret).toBe(401);
expect(response.json()).toEqual({ error: "app_auth_required" });
}
});
it("refuses another app's authenticated caller on a direct Bifrost start, with no policy attached", async () => {
// Over HTTP the caller is resolved against the body's slug, so a foreign
// secret never becomes a caller at all. The command plane and direct
// callers hand startOAuthFlow a caller of their own, so the slug match is
// decided here too.
const config = createTestConfig();
const app = await buildApp({ config });
apps.push(app);
const foreign = resolveAppCaller(config, "repixelizer", { "x-heimdall-app-secret": "repixelizer-secret" });
const own = resolveAppCaller(config, "bifrost", { "x-heimdall-app-secret": "bifrost-secret" });
const refused = await startOAuthFlow(getHeimdallRuntimeContext(app), "discord", foreign, bifrostStart({ kind: "browser_completion" }));
expect(refused.statusCode).toBe(401);
const allowed = await startOAuthFlow(getHeimdallRuntimeContext(app), "discord", own, bifrostStart({ kind: "browser_completion" }));
expect(allowed.statusCode).toBe(201);
});
it("gates every mode, provider and handoff kind Bifrost admits, not one spelling of them", async () => {
// Hand mutants narrowing the gate to browser_completion or to sign_in
// survived the single-case tests (finding gate-tests-narrow).
const store = new InMemoryStore();
const now = new Date().toISOString();
const initiator = await store.createAccount({ createdAt: now, lastSeenAt: now });
const app = await buildApp({ config: createTestConfig(), store });
apps.push(app);
const handoffs = [
undefined,
{ kind: "browser_completion" },
{
kind: "backend_callback",
attemptId: "bifrost-attempt-1",
callbackUrl: "https://bifrost.gamecult.org/auth/heimdall/callback",
},
];
let cases = 0;
for (const mode of ["sign_in", "link", "connect"] as const) {
for (const provider of ["discord", "patreon"] as const) {
for (const handoff of handoffs) {
const label = `${mode} ${provider} ${handoff?.kind ?? "omitted"}`;
const payload = {
appSlug: "bifrost",
mode,
returnTo: bifrostReturn,
...(mode === "link" ? { initiatingAccountId: initiator.id } : {}),
...(handoff ? { handoff } : {}),
};
const refused = await app.inject({ method: "POST", url: `/v1/oauth/${provider}/start`, payload });
expect(refused.statusCode, label).toBe(401);
expect(refused.json(), label).toEqual({ error: "app_auth_required" });
const allowed = await app.inject({
method: "POST",
url: `/v1/oauth/${provider}/start`,
headers: { "x-heimdall-app-secret": "bifrost-secret" },
payload,
});
expect(allowed.statusCode, label).toBe(201);
expect(allowed.json().authorizationUrl, label).toContain(`/v1/oauth/${provider}/launch?state=`);
cases += 1;
}
}
}
expect(cases).toBe(18);
});
it("leaves public-by-design apps unchanged: unauthenticated browser_completion starts still succeed", async () => {
const app = await buildApp({ config: createTestConfig() });
apps.push(app);
for (const [appSlug, provider, returnTo] of [
["repixelizer", "discord", "https://repixelizer.gamecult.org/app/"],
["streampixels", "twitch", "https://streampixels.gamecult.org/app/"],
] as const) {
const response = await app.inject({
method: "POST",
url: `/v1/oauth/${provider}/start`,
payload: { appSlug, mode: "sign_in", returnTo, handoff: { kind: "browser_completion" } },
});
expect(response.statusCode, appSlug).toBe(201);
}
});
it("flags only Bifrost's profile", () => {
expect(
Object.values(builtInAppProfiles).filter((profile) => profile.startRequiresAppCaller).map((profile) => profile.slug)
).toEqual(["bifrost"]);
});
});
it("accepts caller-owned entitlement policy for a browser completion handoff when the caller is trusted", async () => {
// The other half of the same pin: a caller holding the secret may attach
// policy even without a backend_callback handoff, because identity no
@ -378,9 +302,9 @@ describe("Heimdall service", () => {
expect(response.statusCode).toBe(201);
const payload = response.json();
expect((await providerRedirect(app, payload)).href).toContain("discord.com/oauth2/authorize");
expect(payload.authorizationUrl).toContain("discord.com/oauth2/authorize");
const verified = verifyJwt(stateOf(payload), getPublicKey(app));
const verified = verifyJwt(payload.stateToken as string, getPublicKey(app));
expect(verified.valid).toBe(true);
if (verified.valid) {
expect(verified.payload).toEqual(
@ -424,11 +348,11 @@ describe("Heimdall service", () => {
expect(response.statusCode).toBe(201);
const payload = response.json();
const authorizationUrl = await providerRedirect(app, payload);
const authorizationUrl = new URL(payload.authorizationUrl as string);
expect(`${authorizationUrl.origin}${authorizationUrl.pathname}`).toBe("https://www.patreon.com/oauth2/authorize");
expect(authorizationUrl.searchParams.get("scope")).toBe("identity identity[email]");
const verified = verifyJwt(stateOf(payload), getPublicKey(app));
const verified = verifyJwt(payload.stateToken as string, getPublicKey(app));
expect(verified.valid).toBe(true);
if (verified.valid) {
expect(verified.payload).toEqual(
@ -472,9 +396,9 @@ describe("Heimdall service", () => {
expect(response.statusCode).toBe(201);
const payload = response.json();
expect((await providerRedirect(app, payload)).href).toContain("discord.com/oauth2/authorize");
expect(payload.authorizationUrl).toContain("discord.com/oauth2/authorize");
const verified = verifyJwt(stateOf(payload), getPublicKey(app));
const verified = verifyJwt(payload.stateToken as string, getPublicKey(app));
expect(verified.valid).toBe(true);
if (verified.valid) {
expect(verified.payload).toEqual(
@ -518,10 +442,10 @@ describe("Heimdall service", () => {
expect(response.statusCode).toBe(201);
const payload = response.json();
const authorizationUrl = await providerRedirect(app, payload);
const authorizationUrl = new URL(payload.authorizationUrl as string);
expect(`${authorizationUrl.origin}${authorizationUrl.pathname}`).toBe("https://www.patreon.com/oauth2/authorize");
const verified = verifyJwt(stateOf(payload), getPublicKey(app));
const verified = verifyJwt(payload.stateToken as string, getPublicKey(app));
expect(verified.valid).toBe(true);
if (verified.valid) {
expect(verified.payload).toEqual(
@ -549,11 +473,10 @@ describe("Heimdall service", () => {
});
apps.push(app);
const flow = await startDiscordSignIn(app);
const stateToken = await startDiscordSignIn(app);
const response = await app.inject({
method: "GET",
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(flow.state)}`,
headers: { cookie: flow.cookie },
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(stateToken)}`,
});
expect(response.statusCode).toBe(201);
@ -766,11 +689,10 @@ describe("Heimdall service", () => {
});
apps.push(app);
const flow = await startDiscordSignIn(app);
const stateToken = await startDiscordSignIn(app);
const callbackResponse = await app.inject({
method: "GET",
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(flow.state)}`,
headers: { cookie: flow.cookie },
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(stateToken)}`,
});
const completionCode = callbackResponse.json().completion.code as string;
@ -809,11 +731,10 @@ describe("Heimdall service", () => {
const auditEvents = () =>
[...(store as unknown as { auditEvents: Map<string, { eventType: string; sessionId?: string; eventPayloadJson: unknown }> }).auditEvents.values()];
const flow = await startDiscordSignIn(app);
const stateToken = await startDiscordSignIn(app);
const callback = await app.inject({
method: "GET",
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(flow.state)}`,
headers: { cookie: flow.cookie },
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(stateToken)}`,
});
const { code, expiresAt } = callback.json().completion as { code: string; expiresAt: string };
const created = auditEvents().filter((event) => event.eventType === "auth_completion_created");
@ -894,11 +815,10 @@ describe("Heimdall service", () => {
});
apps.push(app);
const flow = await startDiscordSignIn(app);
const stateToken = await startDiscordSignIn(app);
const callbackResponse = await app.inject({
method: "GET",
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(flow.state)}`,
headers: { cookie: flow.cookie },
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(stateToken)}`,
});
expect(callbackResponse.statusCode).toBe(201);
const refreshToken = callbackResponse.json().refreshToken as string;
@ -942,12 +862,11 @@ describe("Heimdall service", () => {
});
apps.push(app);
const flow = await startDiscordSignIn(app);
const stateToken = await startDiscordSignIn(app);
const response = await app.inject({
method: "GET",
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(flow.state)}`,
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(stateToken)}`,
headers: {
cookie: flow.cookie,
accept: "text/html",
},
});
@ -993,13 +912,12 @@ describe("Heimdall service", () => {
},
});
expect(startResponse.statusCode).toBe(201);
const flow = await launchFlow(app, startResponse.json());
const stateToken = startResponse.json().stateToken as string;
const response = await app.inject({
method: "GET",
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(flow.state)}`,
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(stateToken)}`,
headers: {
cookie: flow.cookie,
accept: "text/html",
},
});
@ -1205,12 +1123,11 @@ describe("Heimdall service", () => {
returnTo: "https://streampixels.gamecult.org/auth/connect",
},
});
const flow = await launchFlow(app, startResponse.json());
const stateToken = startResponse.json().stateToken as string;
const callbackResponse = await app.inject({
method: "GET",
url: `/v1/oauth/twitch/callback?code=test-code&state=${encodeURIComponent(flow.state)}`,
headers: { cookie: flow.cookie },
url: `/v1/oauth/twitch/callback?code=test-code&state=${encodeURIComponent(stateToken)}`,
});
const accountId = callbackResponse.json().account.id as string;
@ -1293,12 +1210,11 @@ describe("Heimdall service", () => {
returnTo: "https://streampixels.gamecult.org/auth/connect",
},
});
const flow = await launchFlow(app, startResponse.json());
const stateToken = startResponse.json().stateToken as string;
const callbackResponse = await app.inject({
method: "GET",
url: `/v1/oauth/twitch/callback?code=test-code&state=${encodeURIComponent(flow.state)}`,
headers: { cookie: flow.cookie },
url: `/v1/oauth/twitch/callback?code=test-code&state=${encodeURIComponent(stateToken)}`,
});
const accountId = callbackResponse.json().account.id as string;
@ -1360,11 +1276,10 @@ describe("Heimdall service", () => {
});
apps.push(app);
const flow = await startDiscordSignIn(app);
const stateToken = await startDiscordSignIn(app);
const callbackResponse = await app.inject({
method: "GET",
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(flow.state)}`,
headers: { cookie: flow.cookie },
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(stateToken)}`,
});
expect(callbackResponse.statusCode).toBe(201);
const evaluationsAtSignIn = evaluations;
@ -1440,136 +1355,17 @@ 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 }) {
const start = await app.inject({
method: "POST",
url: "/v1/oauth/joystick/start",
headers: { "x-heimdall-app-secret": "streampixels-secret" },
payload: { appSlug: "streampixels", mode: "connect", returnTo: "https://streampixels.gamecult.org/auth/connect", ...extra },
});
return start;
}
it("gates a connect start that names a creator on the app's own secret: none and a foreign app's are 401, its own is 201", async () => {
const config = createTestConfig();
const app = await buildApp({ config });
apps.push(app);
const body = { appSlug: "streampixels", mode: "connect", returnTo: "https://streampixels.gamecult.org/auth/connect", connection: creator };
const start = (headers: Record<string, string>) => app.inject({ method: "POST", url: "/v1/oauth/joystick/start", headers, payload: body });
for (const headers of [{}, { "x-heimdall-app-secret": "repixelizer-secret" }, { "x-heimdall-app-secret": "wrong" }]) {
const refused = await start(headers);
expect([refused.statusCode, refused.json()]).toEqual([401, { error: "app_auth_required" }]);
}
expect((await start({ "x-heimdall-app-secret": "streampixels-secret" })).statusCode).toBe(201);
// 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: body.returnTo, connection: { kind: "creator" as const, targetId: "creator-1" } };
const foreign = resolveAppCaller(config, "repixelizer", { "x-heimdall-app-secret": "repixelizer-secret" });
expect((await startOAuthFlow(getHeimdallRuntimeContext(app), "joystick", foreign, input)).statusCode).toBe(401);
});
it.each([
["no connection", {}, "creator_connection_required"],
["an account connection", { connection: { kind: "account", targetId: "victim-1" } }, "creator_connection_required"],
@ -1581,7 +1377,7 @@ describe("Heimdall service", () => {
expect(start.statusCode).toBe(400);
expect(start.json().error).toBe(error);
expect(start.json().authorizationUrl).toBeUndefined();
expect(start.json()).not.toHaveProperty("stateToken");
expect(start.json().stateToken).toBeUndefined();
});
it.each([
@ -1597,15 +1393,14 @@ describe("Heimdall service", () => {
apps.push(app);
const keys = createRuntimeKeyMaterial({ ...config, bootstrapSigningPrivateKeyOnMissing: false });
const calls = stubJoystick({ channel_id: "chan-forged", username: "deru" });
const flow = await launchFlow(app, (await connect(app)).json());
const verified = verifyJwt(flow.state, keys.publicKey);
const stateToken = (await connect(app)).json().stateToken as string;
const verified = verifyJwt(stateToken, keys.publicKey);
expect(verified.valid).toBe(true);
const forged = signJwt({ ...(verified as { payload: Record<string, unknown> }).payload, connection }, keys);
const callback = await app.inject({
method: "GET",
url: `/v1/oauth/joystick/callback?code=c&state=${encodeURIComponent(forged)}`,
headers: { cookie: flow.cookie },
});
expect(callback.statusCode).toBeGreaterThanOrEqual(400);
expect(calls).toEqual([]);
@ -1637,11 +1432,9 @@ describe("Heimdall service", () => {
handoff: { kind: "backend_callback", attemptId: "attempt-js-1", callbackUrl },
});
expect(start.statusCode).toBe(201);
const flow = await launchFlow(app, start.json());
const callback = await app.inject({
method: "GET",
url: `/v1/oauth/joystick/callback?code=c&state=${encodeURIComponent(flow.state)}`,
headers: { cookie: flow.cookie },
url: `/v1/oauth/joystick/callback?code=c&state=${encodeURIComponent(start.json().stateToken as string)}`,
});
expect(callback.statusCode).toBeLessThan(300);
@ -1660,7 +1453,7 @@ describe("Heimdall service", () => {
apps.push(app);
const start = await connect(app);
expect(start.statusCode).toBe(201);
const url = await providerRedirect(app, start.json());
const url = new URL(start.json().authorizationUrl as string);
expect(`${url.origin}${url.pathname}`).toBe("https://joystick.tv/api/oauth/authorize");
expect(url.searchParams.get("client_id")).toBe("joystick-client");
expect(url.searchParams.get("scope")).toBe("identity:read stream:read chat:read followers:read subscribers:read");
@ -1674,11 +1467,10 @@ describe("Heimdall service", () => {
const app = await buildApp({ config: createTestConfig(), store });
apps.push(app);
stubJoystick({ channel_id: "deru-chan", username: "deru" });
const flow = await launchFlow(app, (await connect(app)).json());
const stateToken = (await connect(app)).json().stateToken as string;
const connected = await app.inject({
method: "GET",
url: `/v1/oauth/joystick/callback?code=a&state=${encodeURIComponent(flow.state)}`,
headers: { cookie: flow.cookie },
url: `/v1/oauth/joystick/callback?code=a&state=${encodeURIComponent(stateToken)}`,
});
const accountId = connected.json().account.id as string;
const before = await store.findStoredLinkedIdentityForAccount(accountId, "joystick");
@ -1726,15 +1518,14 @@ describe("Heimdall service", () => {
apps.push(app);
const keys = createRuntimeKeyMaterial({ ...config, bootstrapSigningPrivateKeyOnMissing: false });
const calls = stubJoystick({ channel_id: "chan-forged", username: "deru" });
const flow = await launchFlow(app, (await connect(app)).json());
const verified = verifyJwt(flow.state, keys.publicKey);
const stateToken = (await connect(app)).json().stateToken as string;
const verified = verifyJwt(stateToken, keys.publicKey);
expect(verified.valid).toBe(true);
const forged = signJwt({ ...(verified as { payload: Record<string, unknown> }).payload, mode }, keys);
const callback = await app.inject({
method: "GET",
url: `/v1/oauth/joystick/callback?code=c&state=${encodeURIComponent(forged)}`,
headers: { cookie: flow.cookie },
});
expect(callback.statusCode).toBeGreaterThanOrEqual(400);
expect(callback.body).not.toContain("viewer_access");
@ -1751,12 +1542,11 @@ describe("Heimdall service", () => {
const app = await buildApp({ config: createTestConfig(), store });
apps.push(app);
const calls = stubJoystick({ channel_id: "chan-9", username: "deru", nickname: "Deru!", photo_url: "https://img/x.png" });
const flow = await launchFlow(app, (await connect(app)).json());
const stateToken = (await connect(app)).json().stateToken as string;
const callback = await app.inject({
method: "GET",
url: `/v1/oauth/joystick/callback?code=the-code&state=${encodeURIComponent(flow.state)}`,
headers: { cookie: flow.cookie },
url: `/v1/oauth/joystick/callback?code=the-code&state=${encodeURIComponent(stateToken)}`,
});
expect(callback.statusCode).toBeLessThan(300);
@ -1785,11 +1575,10 @@ describe("Heimdall service", () => {
const app = await buildApp({ config: createTestConfig(), store });
apps.push(app);
stubJoystick({ channel_id: "chan-9", username: "deru" });
const flow = await launchFlow(app, (await connect(app)).json());
const stateToken = (await connect(app)).json().stateToken as string;
const callback = await app.inject({
method: "GET",
url: `/v1/oauth/joystick/callback?code=c&state=${encodeURIComponent(flow.state)}`,
headers: { cookie: flow.cookie },
url: `/v1/oauth/joystick/callback?code=c&state=${encodeURIComponent(stateToken)}`,
});
const row = await store.findStoredLinkedIdentityForAccount(callback.json().account.id as string, "joystick");
expect(row?.displayName).toBe("deru");
@ -1800,11 +1589,10 @@ describe("Heimdall service", () => {
const app = await buildApp({ config: createTestConfig(), store });
apps.push(app);
stubJoystick({ username: "deru" });
const flow = await launchFlow(app, (await connect(app)).json());
const stateToken = (await connect(app)).json().stateToken as string;
const callback = await app.inject({
method: "GET",
url: `/v1/oauth/joystick/callback?code=c&state=${encodeURIComponent(flow.state)}`,
headers: { cookie: flow.cookie },
url: `/v1/oauth/joystick/callback?code=c&state=${encodeURIComponent(stateToken)}`,
});
expect(callback.statusCode).toBeGreaterThanOrEqual(400);
expect(await store.findStoredLinkedIdentity("joystick", "undefined")).toBeNull();
@ -1999,11 +1787,11 @@ describe("error answers never echo an input", () => {
const app = await buildApp({ config: createTestConfig(), oauthRuntimes: { discord: failingDiscordRuntime() } });
apps.push(app);
const flow = await startDiscordSignIn(app);
const stateToken = await startDiscordSignIn(app);
const response = await app.inject({
method: "GET",
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(flow.state)}`,
headers: { cookie: flow.cookie, accept: "text/html" },
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(stateToken)}`,
headers: { accept: "text/html" },
});
expect(response.headers["content-type"]).toContain("text/html");
@ -2035,11 +1823,9 @@ describe("error answers never echo an input", () => {
},
});
expect(start.statusCode).toBe(201);
const flow = await launchFlow(app, start.json());
const response = await app.inject({
method: "GET",
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(flow.state)}`,
headers: { cookie: flow.cookie },
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(start.json().stateToken as string)}`,
});
expect(response.statusCode).toBe(502);
@ -2057,16 +1843,16 @@ describe("the relayed provider error", () => {
const injected = `<script>alert(1)</script>"\\\u0007é${"x".repeat(300)}`;
const allowed = /^[\x20-\x21\x23-\x5b\x5d-\x7e]*$/;
function providerErrorRequest(flow: LaunchedFlow) {
const query = new URLSearchParams({ state: flow.state, error: `${"e".repeat(100)}\u0000`, error_description: injected });
return { method: "GET" as const, url: `/v1/oauth/discord/callback?${query.toString()}`, headers: { cookie: flow.cookie } };
function providerErrorUrl(stateToken: string): string {
const query = new URLSearchParams({ state: stateToken, error: `${"e".repeat(100)}\u0000`, error_description: injected });
return `/v1/oauth/discord/callback?${query.toString()}`;
}
it("is printable and bounded in the JSON answer", async () => {
const app = await buildApp({ config: createTestConfig() });
apps.push(app);
const response = await app.inject(providerErrorRequest(await startDiscordSignIn(app)));
const response = await app.inject({ method: "GET", url: providerErrorUrl(await startDiscordSignIn(app)) });
expect(response.statusCode).toBe(400);
const { providerError, providerErrorDescription } = response.json() as Record<string, string>;
@ -2080,8 +1866,11 @@ describe("the relayed provider error", () => {
const app = await buildApp({ config: createTestConfig() });
apps.push(app);
const request = providerErrorRequest(await startDiscordSignIn(app));
const response = await app.inject({ ...request, headers: { ...request.headers, accept: "text/html" } });
const response = await app.inject({
method: "GET",
url: providerErrorUrl(await startDiscordSignIn(app)),
headers: { accept: "text/html" },
});
expect(response.headers["content-type"]).toContain("text/html");
expect(response.body.match(/<script/gi)).toHaveLength(1);
@ -2113,7 +1902,7 @@ describe("the relayed provider error", () => {
});
expect(start.statusCode).toBe(201);
await app.inject(providerErrorRequest(await launchFlow(app, start.json())));
await app.inject({ method: "GET", url: providerErrorUrl(start.json().stateToken as string) });
expect(deliveries).toHaveLength(1);
const description = String(deliveries[0]!.errorDescription);
@ -2139,11 +1928,10 @@ describe("the failed-callback audit event", () => {
});
apps.push(app);
const flow = await startDiscordSignIn(app);
const stateToken = await startDiscordSignIn(app);
const response = await app.inject({
method: "GET",
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(flow.state)}`,
headers: { cookie: flow.cookie },
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(stateToken)}`,
});
expect(response.statusCode).toBe(502);
@ -2167,11 +1955,10 @@ describe("the failed-callback audit event for a provider's HTTP refusal", () =>
const store = new InMemoryStore();
const app = await buildApp({ config: createTestConfig(), store, oauthRuntimes: createOAuthRuntimeRegistry() });
apps.push(app);
const flow = await startDiscordSignIn(app);
const stateToken = await startDiscordSignIn(app);
const callback = await app.inject({
method: "GET",
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(flow.state)}`,
headers: { cookie: flow.cookie },
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(stateToken)}`,
});
expect(callback.statusCode).toBe(502);
const events = [...(store as unknown as { auditEvents: Map<string, { eventType: string; eventPayloadJson: unknown }> }).auditEvents.values()];
@ -2225,12 +2012,8 @@ describe("the failed-callback audit event for a provider's HTTP refusal", () =>
},
});
apps.push(app);
const flow = await startDiscordSignIn(app);
await app.inject({
method: "GET",
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(flow.state)}`,
headers: { cookie: flow.cookie },
});
const stateToken = await startDiscordSignIn(app);
await app.inject({ method: "GET", url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(stateToken)}` });
const events = [...(store as unknown as { auditEvents: Map<string, { eventType: string; eventPayloadJson: Record<string, unknown> }> }).auditEvents.values()];
const failed = events.find((event) => event.eventType === "oauth_callback_failed");

View file

@ -24,7 +24,6 @@ import { InMemoryStore } from "../src/store/index.js";
import { PostgresStore } from "../src/store/postgres.js";
import { CREATE_SCHEMA_SQL } from "../src/store/schema.js";
import { type CreateAuthCompletionInput, type HeimdallStore } from "../src/store/types.js";
import { launchState, stateOf } from "./oauth-flow.js";
const ghostlightSecret = "ghostlight-private-command-secret";
const resources: Array<{ close(): Promise<unknown> }> = [];
@ -67,7 +66,6 @@ function testConfig(overrides: Partial<HeimdallConfig> = {}): HeimdallConfig {
youtube: {},
spotify: {},
joystick: {},
google: {},
},
...overrides,
};
@ -104,11 +102,10 @@ async function start(app: App, payload: unknown, headers: Record<string, string>
}
async function completeAs(app: App, stateToken: string, who: string) {
const { cookie } = await launchState(app, stateToken);
return app.inject({
method: "GET",
url: `/v1/oauth/discord/callback?code=${who}&state=${encodeURIComponent(stateToken)}`,
headers: { accept: "application/json", cookie },
headers: { accept: "application/json" },
});
}
@ -216,7 +213,7 @@ describe("R21.1: the attempt handle is unreachable from the public start route",
});
expect(response.statusCode, response.body).toBe(201);
expect(response.json().handoff).toEqual({ kind: "browser_completion" });
const state = jwtPayload(stateOf(response.json()));
const state = jwtPayload(response.json().stateToken as string);
expect(state.handoff).toEqual({ kind: "browser_completion" });
expect(JSON.stringify(state)).not.toContain(attempt.handle);
});
@ -240,7 +237,7 @@ describe("R21.1: the attempt handle is unreachable from the public start route",
expect(response.body).not.toContain(planted);
if (response.statusCode === 201) {
expect(response.json().handoff).toEqual({ kind: "browser_completion" });
expect(jwtPayload(stateOf(response.json())).handoff).toEqual({ kind: "browser_completion" });
expect(jwtPayload(response.json().stateToken as string).handoff).toEqual({ kind: "browser_completion" });
} else {
expect(response.statusCode, response.body).toBe(400);
}
@ -252,7 +249,7 @@ describe("R21.1: the attempt handle is unreachable from the public start route",
const response = await start(app, { appSlug: "ghostlight", mode: "sign_in", returnTo: legitReturn, ...extra });
expect(response.statusCode, response.body).toBe(201);
expect(response.body).not.toContain(planted);
expect(jwtPayload(stateOf(response.json())).handoff).toEqual({ kind: "browser_completion" });
expect(jwtPayload(response.json().stateToken as string).handoff).toEqual({ kind: "browser_completion" });
}
});
@ -267,8 +264,8 @@ describe("R21.1: the attempt handle is unreachable from the public start route",
const calls = source.match(/(?<!function )startOAuthFlow\(/g) ?? [];
if (calls.length) callers.push(`${file}:${calls.length}`);
if (file.endsWith("app.ts")) {
// The HTTP route passes only the bearer-derived linkAccountId; never an attempt handle.
expect(source).toMatch(/startOAuthFlow\(\{ config, keys, store \}, request\.params\.provider, caller, request\.body, \{\s*\.\.\.\(linkAccountId !== undefined \? \{ linkAccountId \} : \{\}\),\s*\}\);/);
// The HTTP route's call ends at the body; no fifth argument.
expect(source).toMatch(/startOAuthFlow\(\{ config, keys, store \}, request\.params\.provider, caller, request\.body\);/);
}
}
expect(callers.sort()).toEqual(["app.ts:1", "private-command-plane.ts:1"]);
@ -309,7 +306,7 @@ describe("R21.1: the attempt handle is unreachable from the public start route",
handoff: { kind: "browser_completion", attemptId: handle },
});
expect(attackerStart.statusCode).toBe(201);
const attackerCallback = await completeAs(app, stateOf(attackerStart.json()), "attacker");
const attackerCallback = await completeAs(app, attackerStart.json().stateToken as string, "attacker");
expect(attackerCallback.statusCode, attackerCallback.body).toBe(201);
expect((await store.findAuthAttempt("ghostlight", handle))?.status).toBe("pending");
@ -343,7 +340,7 @@ describe("R21.1: the attempt handle is unreachable from the public start route",
expect(response.statusCode, response.body).toBe(201);
// Delivery to port 1 fails; whatever the callback returns, the attempt
// must be untouched and no completion may be bound to it.
await completeAs(app, stateOf(response.json()), "attacker");
await completeAs(app, response.json().stateToken as string, "attacker");
expect((await store.findAuthAttempt("ghostlight", attempt.handle))?.status).toBe("pending");
expect(await store.consumeAuthCompletionByAttempt("ghostlight", attempt.handle, new Date().toISOString())).toBeNull();
});
@ -496,27 +493,26 @@ partialUniqueIndexContract("PostgresStore over pg-mem", async () => {
});
describe("duplicate-handle error surfacing through the callback route", () => {
it("a replayed state is refused as already used before any exchange, and leaves the real completion intact", async () => {
it("state tokens are replayable, and a replay while the handle's completion is unconsumed echoes the store error in a 502", async () => {
const { app, store, begin } = await planeHarness();
const receipt = await begin("begin-replay");
const handle = String(receipt.handle);
const state = new URL((receipt.navigation as { url: string }).url).searchParams.get("state") ?? "";
const { cookie } = await launchState(app, state);
const callbackWith = () =>
app.inject({
method: "GET",
url: `/v1/oauth/discord/callback?code=victim&state=${encodeURIComponent(state)}`,
headers: { accept: "application/json", cookie },
});
const first = await callbackWith();
const first = await completeAs(app, state, "victim");
expect(first.statusCode).toBe(201);
// The state's jti was consumed by the first callback, so the second never
// reaches createAuthCompletion, whose unique index used to be the only
// thing refusing a replay (and whose Postgres 23505 text the route had to
// keep out of the answer).
const replay = await callbackWith();
expect(replay.statusCode, replay.body).toBe(400);
expect(replay.json()).toEqual({ error: "state_already_used" });
const replay = await completeAs(app, state, "victim");
// Nothing consumed the state's jti, so the second exchange still runs and
// reaches createAuthCompletion, where the unique index refuses it. The
// route's catch (app.ts ~1290) now answers with a fixed detail rather than
// the store's own text, so a Postgres 23505 cannot hand the caller a
// constraint name. The full text stays in the audit event.
expect(replay.statusCode, replay.body).toBe(502);
expect(replay.json()).toMatchObject({
error: "oauth_callback_failed",
detail: "The provider callback could not be completed.",
});
expect(JSON.stringify(replay.json())).not.toContain("unconsumed completion");
// The failed replay must not have damaged the real completion.
expect((await store.findAuthAttempt("ghostlight", handle))?.status).toBe("completed");
expect(await store.consumeAuthCompletionByAttempt("ghostlight", handle, new Date().toISOString())).toBeTruthy();
});

View file

@ -11,7 +11,6 @@ import { type HeimdallConfig } from "../src/config.js";
import { entitlementFacts } from "../src/facts.js";
import { type OAuthProviderRuntime } from "../src/oauth.js";
import { InMemoryStore } from "../src/store/index.js";
import { launchState, stateOf } from "./oauth-flow.js";
const resources: Array<{ close(): Promise<unknown> }> = [];
@ -53,7 +52,6 @@ function testConfig(): HeimdallConfig {
youtube: {},
spotify: {},
joystick: {},
google: {},
},
};
}
@ -88,11 +86,10 @@ async function start(app: App, payload: Record<string, unknown>, headers: Record
}
async function completeAs(app: App, stateToken: string, who: string) {
const { cookie } = await launchState(app, stateToken);
return app.inject({
method: "GET",
url: `/v1/oauth/discord/callback?code=${who}&state=${encodeURIComponent(stateToken)}`,
headers: { accept: "application/json", cookie },
headers: { accept: "application/json" },
});
}
@ -113,7 +110,7 @@ describe("takeover chain: caller-chosen attemptId is never the redemption code",
});
expect(started.statusCode).toBe(201);
const callback = await completeAs(app, stateOf(started.json()), "victim");
const callback = await completeAs(app, started.json().stateToken as string, "victim");
expect(callback.statusCode).toBe(201);
const minted = callback.json().completion.code as string;
expect(minted).not.toBe("attacker-fixed");
@ -128,7 +125,7 @@ describe("takeover chain: caller-chosen attemptId is never the redemption code",
const { app, store } = await harness();
const started = await start(app, { appSlug: "repixelizer", mode: "sign_in", returnTo: "https://repixelizer.gamecult.org/app/" });
expect(started.statusCode).toBe(201);
const callback = await completeAs(app, stateOf(started.json()), "victim");
const callback = await completeAs(app, started.json().stateToken as string, "victim");
expect(callback.statusCode).toBe(201);
const code = callback.json().completion.code as string;
expect(code).toMatch(uuidV4);
@ -162,7 +159,7 @@ describe("takeover chain: caller-chosen attemptId is never the redemption code",
it("attemptId set to a real victim completion code does not let the attacker's flow touch the victim's completion", async () => {
const { app } = await harness();
const victimStart = await start(app, { appSlug: "repixelizer", mode: "sign_in", returnTo: "https://repixelizer.gamecult.org/app/" });
const victimCallback = await completeAs(app, stateOf(victimStart.json()), "victim");
const victimCallback = await completeAs(app, victimStart.json().stateToken as string, "victim");
const victimCode = victimCallback.json().completion.code as string;
const attackerStart = await start(app, {
@ -171,7 +168,7 @@ describe("takeover chain: caller-chosen attemptId is never the redemption code",
returnTo: "https://repixelizer.gamecult.org/app/",
handoff: { kind: "browser_completion", attemptId: victimCode },
});
const attackerCallback = await completeAs(app, stateOf(attackerStart.json()), "attacker");
const attackerCallback = await completeAs(app, attackerStart.json().stateToken as string, "attacker");
expect(attackerCallback.statusCode).toBe(201);
expect(attackerCallback.json().completion.code).not.toBe(victimCode);
@ -207,7 +204,7 @@ describe("consumeAuthCompletion: single-use, scoped, expiring", () => {
it("a code redeems exactly once under concurrent redemption, and never for another app", async () => {
const { app } = await harness();
const started = await start(app, { appSlug: "repixelizer", mode: "sign_in", returnTo: "https://repixelizer.gamecult.org/app/" });
const callback = await completeAs(app, stateOf(started.json()), "victim");
const callback = await completeAs(app, started.json().stateToken as string, "victim");
const code = callback.json().completion.code as string;
const crossApp = await redeem(app, "bifrost", code);
@ -268,7 +265,7 @@ describe("attempt-handle binding: the public start route may attach a completion
handoff: { kind: "browser_completion", attemptId: attempt.handle },
});
expect(attackerStart.statusCode).toBe(201);
await completeAs(app, stateOf(attackerStart.json()), "attacker");
await completeAs(app, attackerStart.json().stateToken as string, "attacker");
const after = await store.findAuthAttempt("ghostlight", attempt.handle);
expect(after?.status).toBe("pending");

View file

@ -18,7 +18,6 @@ import { type OAuthProviderRuntime } from "../src/oauth.js";
import { startHeimdallPrivateCommandPlane } from "../src/private-command-plane.js";
import { sealPrivateEnvelope, type HeimdallPrivateEnvelope } from "../src/private-command-security.js";
import { InMemoryStore } from "../src/store/index.js";
import { launchFlow } from "./oauth-flow.js";
const ghostlightSecret = "ghostlight-private-command-secret";
const resources: Array<{ close(): Promise<unknown> }> = [];
@ -61,7 +60,6 @@ function testConfig(): HeimdallConfig {
youtube: {},
spotify: {},
joystick: {},
google: {},
},
};
}
@ -106,11 +104,10 @@ describe("caller identity: unauthenticated mint paths", () => {
});
expect(start.statusCode).toBe(201);
const flow = await launchFlow(app, start.json());
const callback = await app.inject({
method: "GET",
url: `/v1/oauth/discord/callback?code=victim-code&state=${encodeURIComponent(flow.state)}`,
headers: { accept: "text/html", cookie: flow.cookie },
url: `/v1/oauth/discord/callback?code=victim-code&state=${encodeURIComponent(start.json().stateToken as string)}`,
headers: { accept: "text/html" },
});
expect(callback.statusCode).toBe(200);

View file

@ -1,65 +0,0 @@
import { afterEach, expect, it, vi } from "vitest";
import { type FastifyInstance } from "fastify";
// The receipt records the wording the person was shown. If the wording changes
// between the ask and the callback (a deploy inside the state's lifetime), the
// receipt must still hold what they saw.
const drift = vi.hoisted(() => ({ on: false }));
vi.mock("../src/consent.js", async (importOriginal) => {
const real = await importOriginal<typeof import("../src/consent.js")>();
return { ...real, consentText: (ask: Parameters<typeof real.consentText>[0]) => real.consentText(ask) + (drift.on ? "\nREWORDED AFTER THE ASK" : "") };
});
import { buildApp } from "../src/app.js";
import { InMemoryStore } from "../src/store/index.js";
import { createTestConfig, setCookieOf, stateOf } from "./oauth-flow.js";
const apps: FastifyInstance[] = [];
afterEach(async () => {
drift.on = false;
while (apps.length) await apps.pop()?.close();
});
it("a receipt holds the wording of the ask, not wording rebuilt at the callback", async () => {
const store = new InMemoryStore();
const app = await buildApp({
config: createTestConfig(),
store,
oauthRuntimes: {
joystick: {
async exchangeAuthorizationCode() {
return { accessToken: "joy-access-code-a", refreshToken: "r", tokenType: "Bearer", scope: ["identity:read"], expiresAt: "2099-01-01T00:00:00.000Z", raw: {} };
},
async resolveIdentity() {
return { provider: "joystick", providerUserId: "joy-a", username: "a", profile: {} };
},
async evaluateEntitlements() {
return { facts: [], snapshots: [] };
},
},
},
});
apps.push(app);
const start = await app.inject({
method: "POST",
url: "/v1/oauth/joystick/start",
headers: { "x-heimdall-app-secret": "streampixels-secret" },
payload: { appSlug: "streampixels", mode: "connect", connection: { kind: "creator", targetId: "c" }, returnTo: "https://streampixels.gamecult.org/s", handoff: { kind: "browser_completion" } },
});
const state = stateOf(start.json());
const ask = await app.inject({ method: "GET", url: `/v1/oauth/joystick/launch?state=${encodeURIComponent(state)}`, headers: { accept: "text/html" } });
const askCookie = setCookieOf(ask).find((c) => c.startsWith("__Host-heimdall-ask-"))!.split(";")[0]!;
const go = await app.inject({
method: "POST",
url: "/v1/oauth/joystick/consent",
headers: { "content-type": "application/x-www-form-urlencoded", cookie: askCookie },
payload: new URLSearchParams({ state, decision: "continue" }).toString(),
});
const binding = setCookieOf(go).find((c) => c.startsWith("__Host-heimdall-oauth-"))!.split(";")[0]!;
drift.on = true; // the wording changes after the person has agreed
const done = await app.inject({ method: "GET", url: `/v1/oauth/joystick/callback?state=${encodeURIComponent(state)}&code=code-a`, headers: { cookie: binding, accept: "application/json" } });
expect(done.statusCode).toBe(201);
const [receipt] = await store.listConsentReceipts(done.json().account.id);
expect(receipt?.kind === "grant" && receipt.consentText).not.toContain("REWORDED");
});

View file

@ -1,525 +0,0 @@
import { createHash, createSign, generateKeyPairSync } from "node:crypto";
import { afterEach, describe, expect, it, vi } from "vitest";
import { type FastifyInstance } from "fastify";
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 { launchFlow, launchWithRedirect, stateOf, type LaunchedFlow } from "./oauth-flow.js";
const CLIENT_ID = "google-client";
const REDIRECT = "https://heimdall.gamecult.org/v1/oauth/google/callback";
const rsa = generateKeyPairSync("rsa", { modulusLength: 2048 });
const originalFetch = globalThis.fetch;
const apps: FastifyInstance[] = [];
function config(): HeimdallConfig {
const base = loadConfig({
GC_ACCESS_BASE_URL: "https://heimdall.gamecult.org",
GC_ACCESS_PROVIDER_GOOGLE_CLIENT_ID: CLIENT_ID,
GC_ACCESS_PROVIDER_DISCORD_CLIENT_ID: "discord-client",
} as NodeJS.ProcessEnv);
return {
...base,
storage: { backend: "memory", applySchemaOnStartup: true },
tokenEncryptionKeyBase64: Buffer.alloc(32, 7).toString("base64"),
bootstrapSigningPrivateKeyOnMissing: true,
appSharedSecrets: { ...base.appSharedSecrets, bifrost: "bifrost-secret" },
providers: {
...base.providers,
google: { clientId: CLIENT_ID, clientSecret: "google-secret" },
discord: { clientId: "discord-client", clientSecret: "discord-secret" },
},
};
}
function discordRuntime(providerUserId: string, name: string): OAuthProviderRuntime {
return {
async exchangeAuthorizationCode() {
return { accessToken: "d-at", refreshToken: "d-rt", tokenType: "Bearer", scope: ["identify"], expiresAt: "2099-01-01T00:00:00.000Z", raw: {} };
},
async resolveIdentity() {
return { provider: "discord", providerUserId, username: name, displayName: name, profile: { id: providerUserId } };
},
// Discord grants app access only under a policy the app named at start.
async evaluateEntitlements(input) {
return { facts: input.callback.entitlementPolicy ? ["entitlement.app_access"] : [], snapshots: [] };
},
};
}
// Bifrost refuses an unauthenticated start: its backend holds the app secret.
const BIFROST_APP = { "x-heimdall-app-secret": "bifrost-secret" };
function b64(value: unknown): string {
return Buffer.from(JSON.stringify(value)).toString("base64url");
}
function signedIdToken(claims: Record<string, unknown>): string {
const now = Math.floor(Date.now() / 1000);
const body = `${b64({ alg: "RS256", kid: "k1" })}.${b64({
iss: "https://accounts.google.com",
aud: CLIENT_ID,
azp: CLIENT_ID,
sub: "g-sub-1",
email: "person@example.org",
email_verified: true,
name: "Person",
iat: now,
exp: now + 600,
...claims,
})}`;
return `${body}.${createSign("RSA-SHA256").update(body).sign(rsa.privateKey).toString("base64url")}`;
}
interface GoogleLeg {
tokenRequests: URLSearchParams[];
}
/** Google's two endpoints: the key set, and a token endpoint whose id_token echoes the nonce it was told. */
function fakeGoogle(options: { nonceFrom: () => string | undefined; claims?: Record<string, unknown> }): GoogleLeg {
const leg: GoogleLeg = { tokenRequests: [] };
globalThis.fetch = (async (input: string | URL, init?: RequestInit) => {
const url = String(input);
if (url === "https://www.googleapis.com/oauth2/v3/certs") {
return new Response(
JSON.stringify({ keys: [{ ...(rsa.publicKey.export({ format: "jwk" }) as object), kid: "k1", alg: "RS256" }] }),
{ status: 200 }
);
}
if (url === "https://oauth2.googleapis.com/token") {
leg.tokenRequests.push(new URLSearchParams(String(init?.body)));
return new Response(
JSON.stringify({
access_token: "g-at",
token_type: "Bearer",
expires_in: 3600,
scope: "openid email profile",
id_token: signedIdToken({ nonce: options.nonceFrom(), ...options.claims }),
}),
{ status: 200 }
);
}
throw new Error(`unexpected fetch ${url}`);
}) as typeof fetch;
return leg;
}
function accountCount(store: InMemoryStore): number {
return (store as unknown as { accounts: Map<string, unknown> }).accounts.size;
}
async function signInWithDiscord(app: FastifyInstance, providerUserId = "discord-1"): Promise<{ accessToken: string; accountId: string }> {
const start = await app.inject({
method: "POST",
url: "/v1/oauth/discord/start",
headers: BIFROST_APP,
payload: { appSlug: "bifrost", mode: "sign_in", returnTo: "https://bifrost.gamecult.org/auth/heimdall/complete" },
});
expect(start.statusCode).toBe(201);
const flow = await launchFlow(app, start.json());
const callback = await app.inject({
method: "GET",
url: `/v1/oauth/discord/callback?code=c&state=${encodeURIComponent(flow.state)}`,
headers: { cookie: flow.cookie },
});
expect(callback.statusCode).toBe(201);
const body = callback.json();
return { accessToken: body.accessToken as string, accountId: body.account.id as string };
}
async function build(store = new InMemoryStore(), discordUser = "discord-1") {
const app = await buildApp({ config: config(), store, oauthRuntimes: { discord: discordRuntime(discordUser, "Person") } });
apps.push(app);
return { app, store };
}
function startGoogle(app: FastifyInstance, accessToken?: string, mode: "link" | "sign_in" = "link") {
return app.inject({
method: "POST",
url: "/v1/oauth/google/start",
headers: { ...BIFROST_APP, ...(accessToken ? { authorization: `Bearer ${accessToken}` } : {}) },
payload: { appSlug: "bifrost", mode, returnTo: "https://bifrost.gamecult.org/auth/heimdall/complete" },
});
}
function callbackGoogle(app: FastifyInstance, flow: LaunchedFlow) {
return app.inject({
method: "GET",
url: `/v1/oauth/google/callback?code=g-code&state=${encodeURIComponent(flow.state)}`,
headers: { cookie: flow.cookie },
});
}
const DISCORD_POLICY = { kind: "discord_role_access", guildId: "g", allowedRoleIds: ["member"] } as const;
/** Begin a link with `token`, let Google answer as `claims` says, and return the callback's answer. */
async function linkGoogleCallback(app: FastifyInstance, token: string, claims?: Record<string, unknown>) {
const start = await startGoogle(app, token);
expect(start.statusCode).toBe(201);
const startFlow = await launchWithRedirect(app, start.json());
const url = startFlow.url;
fakeGoogle({ nonceFrom: () => url.searchParams.get("nonce") ?? undefined, ...(claims ? { claims } : {}) });
return callbackGoogle(app, startFlow);
}
function confirmLink(app: FastifyInstance, token: string | undefined, pendingLinkId: string) {
return app.inject({
method: "POST",
url: "/v1/apps/bifrost/links/confirm",
headers: token ? { authorization: `Bearer ${token}` } : {},
payload: { pendingLinkId },
});
}
/** The whole link: callback, then the initiator's confirm. */
async function linkGoogle(app: FastifyInstance, token: string, claims?: Record<string, unknown>) {
const done = await linkGoogleCallback(app, token, claims);
expect(done.statusCode).toBe(201);
const confirmed = await confirmLink(app, token, done.json().pendingLinkId);
expect(confirmed.statusCode).toBe(201);
return confirmed;
}
afterEach(async () => {
globalThis.fetch = originalFetch;
resetGoogleJwksCache();
while (apps.length) await apps.pop()?.close();
});
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();
const { accessToken } = await signInWithDiscord(app);
const start = await startGoogle(app, accessToken);
expect(start.statusCode).toBe(201);
expect(start.json().callbackUrl).toBe(REDIRECT);
const startFlow = await launchWithRedirect(app, start.json());
const url = startFlow.url;
expect(`${url.origin}${url.pathname}`).toBe("https://accounts.google.com/o/oauth2/v2/auth");
expect(url.searchParams.get("client_id")).toBe(CLIENT_ID);
expect(url.searchParams.get("redirect_uri")).toBe(REDIRECT);
expect(url.searchParams.get("response_type")).toBe("code");
expect(url.searchParams.get("scope")).toBe("openid email profile");
expect(url.searchParams.get("state")).toBe(stateOf(start.json()));
expect(url.searchParams.get("nonce")).toMatch(/^[A-Za-z0-9_-]{43}$/);
expect(url.searchParams.get("code_challenge")).toMatch(/^[A-Za-z0-9_-]{43}$/);
expect(url.searchParams.get("code_challenge_method")).toBe("S256");
});
it("links Google to the account that asked, with PKCE proven end to end and the id_token's nonce matched", async () => {
const { app, store } = await build();
const { accessToken, accountId } = await signInWithDiscord(app);
const start = await startGoogle(app, accessToken);
const startFlow = await launchWithRedirect(app, start.json());
const url = startFlow.url;
const leg = fakeGoogle({ nonceFrom: () => url.searchParams.get("nonce") ?? undefined });
const done = await callbackGoogle(app, startFlow);
// Phase one verified the identity and recorded it; it linked nothing and issued no session.
expect(done.statusCode).toBe(201);
expect(done.json().status).toBe("link_pending");
expect(done.json().accessToken).toBeUndefined();
expect(await store.findStoredLinkedIdentity("google", "g-sub-1")).toBeNull();
const confirmed = await confirmLink(app, accessToken, done.json().pendingLinkId);
expect(confirmed.statusCode).toBe(201);
expect(confirmed.json().accountId).toBe(accountId);
expect(accountCount(store)).toBe(1);
const sent = leg.tokenRequests[0]!;
expect(sent.get("grant_type")).toBe("authorization_code");
expect(sent.get("redirect_uri")).toBe(REDIRECT);
// The verifier Heimdall sent hashes to the challenge it put in the authorization URL.
expect(createHash("sha256").update(sent.get("code_verifier") ?? "", "ascii").digest("base64url")).toBe(
url.searchParams.get("code_challenge")
);
const stored = await store.findStoredLinkedIdentity("google", "g-sub-1");
expect(stored?.accountId).toBe(accountId);
expect(stored?.primaryEmail).toBe("person@example.org");
});
it("signs in the account Google is linked to, with no session, and mints nothing", async () => {
const { app, store } = await build();
const { accessToken, accountId } = await signInWithDiscord(app);
await linkGoogle(app, accessToken);
const signIn = await startGoogle(app, undefined, "sign_in");
expect(signIn.statusCode).toBe(201);
const signInFlow = await launchWithRedirect(app, signIn.json());
const signInUrl = signInFlow.url;
fakeGoogle({ nonceFrom: () => signInUrl.searchParams.get("nonce") ?? undefined });
const done = await callbackGoogle(app, signInFlow);
expect(done.statusCode).toBe(201);
expect(done.json().account.id).toBe(accountId);
expect(accountCount(store)).toBe(1);
});
it("refuses a link start whose named initiator is not the bearer's account", async () => {
const { app, store } = await build();
const a = await signInWithDiscord(app, "discord-1");
const start = await app.inject({
method: "POST",
url: "/v1/oauth/google/start",
headers: { ...BIFROST_APP, authorization: `Bearer ${a.accessToken}` },
payload: {
appSlug: "bifrost",
mode: "link",
returnTo: "https://bifrost.gamecult.org/auth/heimdall/complete",
initiatingAccountId: `${a.accountId}-other`,
},
});
expect([start.statusCode, start.json().error]).toEqual([400, "link_initiator_conflict"]);
expect(accountCount(store)).toBe(1);
});
it("refuses a link start with no Heimdall session", async () => {
const { app, store } = await build();
const start = await startGoogle(app);
expect(start.statusCode).toBe(401);
expect(start.json().error).toBe("link_session_required");
expect(accountCount(store)).toBe(0);
});
it.each([
["a garbage bearer", () => "not-a-token"],
["a token for another app", async (app: FastifyInstance) => {
const start = await app.inject({
method: "POST",
url: "/v1/oauth/discord/start",
payload: { appSlug: "repixelizer", mode: "sign_in", returnTo: "https://repixelizer.gamecult.org/app/" },
});
const flow = await launchFlow(app, start.json());
const cb = await app.inject({ method: "GET", url: `/v1/oauth/discord/callback?code=c&state=${encodeURIComponent(flow.state)}`, headers: { cookie: flow.cookie } });
return cb.json().accessToken as string;
}],
])("refuses a link start carrying %s", async (_name, token) => {
const { app, store } = await build();
const bearer = await token(app);
const before = accountCount(store);
const start = await startGoogle(app, bearer);
expect(start.statusCode).toBe(401);
expect(start.json().error).toBe("invalid_link_session");
expect(accountCount(store)).toBe(before);
});
it("refuses a first Google login from a subject no account holds, and mints no account for it", async () => {
// The person who has a Discord account arrives with Google for the first time, signing in rather than linking.
const { app, store } = await build();
await signInWithDiscord(app);
const start = await startGoogle(app, undefined, "sign_in");
expect(start.statusCode).toBe(201);
const startFlow = await launchWithRedirect(app, start.json());
const url = startFlow.url;
fakeGoogle({ nonceFrom: () => url.searchParams.get("nonce") ?? undefined });
const before = accountCount(store);
const done = await callbackGoogle(app, startFlow);
expect(done.statusCode).toBe(403);
expect(done.json().error).toBe("identity_not_linked");
expect(accountCount(store)).toBe(before);
expect(await store.findStoredLinkedIdentity("google", "g-sub-1")).toBeNull();
});
it("never moves or merges: a Google subject already linked to one account cannot be linked to another", async () => {
const store = new InMemoryStore();
const first = await build(store, "discord-1");
const a = await signInWithDiscord(first.app, "discord-1");
await linkGoogle(first.app, a.accessToken);
// A second person, a second account, the same Google subject.
const second = await build(store, "discord-2");
const b = await signInWithDiscord(second.app, "discord-2");
expect(b.accountId).not.toBe(a.accountId);
const startB = await startGoogle(second.app, b.accessToken);
const startBFlow = await launchWithRedirect(second.app, startB.json());
const urlB = startBFlow.url;
fakeGoogle({ nonceFrom: () => urlB.searchParams.get("nonce") ?? undefined });
const done = await callbackGoogle(second.app, startBFlow);
expect(done.statusCode).toBe(403);
expect((await store.findStoredLinkedIdentity("google", "g-sub-1"))?.accountId).toBe(a.accountId);
});
it.each([
["an unverified email", { email_verified: false }],
["another client's audience", { aud: "other-client", azp: "other-client" }],
["a nonce this request did not carry", { nonce: "replayed" }],
["an expired token", { exp: Math.floor(Date.now() / 1000) - 3600 }],
])("links nothing when the id_token has %s", async (_name, claims) => {
const { app, store } = await build();
const { accessToken } = await signInWithDiscord(app);
const start = await startGoogle(app, accessToken);
const startFlow = await launchWithRedirect(app, start.json());
const url = startFlow.url;
fakeGoogle({ nonceFrom: () => url.searchParams.get("nonce") ?? undefined, claims });
const done = await callbackGoogle(app, startFlow);
expect(done.statusCode).toBe(502);
expect(await store.findStoredLinkedIdentity("google", "g-sub-1")).toBeNull();
expect(accountCount(store)).toBe(1);
});
it("keys the identity by sub: the same email under a different sub is a different, unlinkable identity", async () => {
const { app, store } = await build();
const { accessToken, accountId } = await signInWithDiscord(app);
await linkGoogle(app, accessToken, { sub: "g-sub-2" });
expect((await store.findStoredLinkedIdentity("google", "g-sub-2"))?.accountId).toBe(accountId);
expect(await store.findStoredLinkedIdentity("google", "person@example.org")).toBeNull();
});
describe("link is two-phase", () => {
it("a victim completing an attacker's link URL attaches the victim's Google sub to no account", async () => {
const store = new InMemoryStore();
const attacker = await build(store, "discord-attacker");
const a = await signInWithDiscord(attacker.app, "discord-attacker");
const victim = await build(store, "discord-victim");
const v = await signInWithDiscord(victim.app, "discord-victim");
// The attacker begins a link and sends the Google URL to the victim, who finishes it as themselves.
const done = await linkGoogleCallback(attacker.app, a.accessToken, { sub: "victim-google-sub" });
expect(done.statusCode).toBe(201);
expect(await store.findStoredLinkedIdentity("google", "victim-google-sub")).toBeNull();
// The victim's app holds the victim's token: Heimdall refuses it, and the link stays unwritten.
const asVictim = await confirmLink(victim.app, v.accessToken, done.json().pendingLinkId);
expect(asVictim.statusCode).toBe(410);
expect(await store.findStoredLinkedIdentity("google", "victim-google-sub")).toBeNull();
// No token at all, and a garbage token, are refused the same way.
expect((await confirmLink(attacker.app, undefined, done.json().pendingLinkId)).statusCode).toBe(401);
expect((await confirmLink(attacker.app, "garbage", done.json().pendingLinkId)).statusCode).toBe(401);
expect(await store.findStoredLinkedIdentity("google", "victim-google-sub")).toBeNull();
// The refused attempts did not burn the pending link: its initiator can still confirm it.
expect((await confirmLink(attacker.app, a.accessToken, done.json().pendingLinkId)).statusCode).toBe(201);
});
it("signing in with Google cannot reach a Google sub the attacker's pending link recorded", async () => {
const { app } = await build();
const a = await signInWithDiscord(app);
await linkGoogleCallback(app, a.accessToken, { sub: "victim-google-sub" });
const signIn = await startGoogle(app, undefined, "sign_in");
const signInFlow = await launchWithRedirect(app, signIn.json());
const url = signInFlow.url;
fakeGoogle({ nonceFrom: () => url.searchParams.get("nonce") ?? undefined, claims: { sub: "victim-google-sub" } });
expect((await callbackGoogle(app, signInFlow)).statusCode).toBe(403);
});
it("a pending link is single-use", async () => {
const { app } = await build();
const a = await signInWithDiscord(app);
const done = await linkGoogleCallback(app, a.accessToken);
expect((await confirmLink(app, a.accessToken, done.json().pendingLinkId)).statusCode).toBe(201);
expect((await confirmLink(app, a.accessToken, done.json().pendingLinkId)).statusCode).toBe(410);
});
it("a pending link expires after the completion TTL", async () => {
const { app, store } = await build();
const a = await signInWithDiscord(app);
const done = await linkGoogleCallback(app, a.accessToken);
const ttlMs = Date.parse(done.json().expiresAt) - Date.now();
expect(ttlMs).toBeGreaterThan(0);
expect(ttlMs).toBeLessThanOrEqual(300_000);
vi.useFakeTimers({ toFake: ["Date"], now: Date.now() + 301_000 });
try {
expect((await confirmLink(app, a.accessToken, done.json().pendingLinkId)).statusCode).toBe(410);
} finally {
vi.useRealTimers();
}
expect(await store.findStoredLinkedIdentity("google", "g-sub-1")).toBeNull();
});
it("a pending link is bound to its app: another app's session cannot confirm it", async () => {
const { app, store } = await build();
const a = await signInWithDiscord(app);
const done = await linkGoogleCallback(app, a.accessToken);
const other = await app.inject({
method: "POST",
url: "/v1/oauth/discord/start",
payload: { appSlug: "repixelizer", mode: "sign_in", returnTo: "https://repixelizer.gamecult.org/app/" },
});
const flow = await launchFlow(app, other.json());
const cb = await app.inject({ method: "GET", url: `/v1/oauth/discord/callback?code=c&state=${encodeURIComponent(flow.state)}`, headers: { cookie: flow.cookie } });
const res = await app.inject({
method: "POST",
url: "/v1/apps/repixelizer/links/confirm",
headers: { authorization: `Bearer ${cb.json().accessToken}` },
payload: { pendingLinkId: done.json().pendingLinkId },
});
expect(res.statusCode).toBe(410);
expect(await store.findStoredLinkedIdentity("google", "g-sub-1")).toBeNull();
});
it("refuses at confirm a sub another account linked in between, and never moves it", async () => {
const store = new InMemoryStore();
const first = await build(store, "discord-1");
const a = await signInWithDiscord(first.app, "discord-1");
const second = await build(store, "discord-2");
const b = await signInWithDiscord(second.app, "discord-2");
const pendingA = await linkGoogleCallback(first.app, a.accessToken);
const pendingB = await linkGoogleCallback(second.app, b.accessToken);
expect((await confirmLink(first.app, a.accessToken, pendingA.json().pendingLinkId)).statusCode).toBe(201);
const lost = await confirmLink(second.app, b.accessToken, pendingB.json().pendingLinkId);
expect(lost.statusCode).toBe(409);
expect((await store.findStoredLinkedIdentity("google", "g-sub-1"))?.accountId).toBe(a.accountId);
});
});
describe("entitlements at the callback", () => {
it("a Discord-origin member returning through Google keeps entitlement.app_access under the start-time policy", async () => {
const { app } = await build();
const member = await signInWithDiscord(app);
await linkGoogle(app, member.accessToken);
const start = await app.inject({
method: "POST",
url: "/v1/oauth/google/start",
headers: { "x-heimdall-app-secret": "bifrost-secret" },
payload: {
appSlug: "bifrost",
mode: "sign_in",
returnTo: "https://bifrost.gamecult.org/auth/heimdall/complete",
entitlementPolicy: DISCORD_POLICY,
},
});
expect(start.statusCode).toBe(201);
const startFlow = await launchWithRedirect(app, start.json());
const url = startFlow.url;
fakeGoogle({ nonceFrom: () => url.searchParams.get("nonce") ?? undefined });
const done = await callbackGoogle(app, startFlow);
expect(done.statusCode).toBe(201);
expect(done.json().claimSet.facts).toContain("entitlement.app_access");
});
it("without a start-time policy the other identities grant nothing", async () => {
const { app } = await build();
const member = await signInWithDiscord(app);
await linkGoogle(app, member.accessToken);
const start = await startGoogle(app, undefined, "sign_in");
const startFlow = await launchWithRedirect(app, start.json());
const url = startFlow.url;
fakeGoogle({ nonceFrom: () => url.searchParams.get("nonce") ?? undefined });
const done = await callbackGoogle(app, startFlow);
expect(done.statusCode).toBe(201);
expect(done.json().claimSet.facts).not.toContain("entitlement.app_access");
});
});
it("leaves the six existing providers minting on first login", async () => {
const { app, store } = await build();
await signInWithDiscord(app);
expect(accountCount(store)).toBe(1);
});
});

View file

@ -1,197 +0,0 @@
import { createSign, generateKeyPairSync, type KeyObject } from "node:crypto";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
deriveOidcSecrets,
GoogleIdTokenError,
resetGoogleJwksCache,
verifyGoogleIdToken,
type GoogleIdTokenFailure,
} from "../src/google-oidc.js";
const CLIENT_ID = "google-client.apps.googleusercontent.com";
const NONCE = "the-nonce";
const NOW_MS = Date.parse("2026-11-04T12:00:00.000Z");
const NOW_S = Math.floor(NOW_MS / 1000);
const rsa = generateKeyPairSync("rsa", { modulusLength: 2048 });
const otherRsa = generateKeyPairSync("rsa", { modulusLength: 2048 });
const originalFetch = globalThis.fetch;
let jwksFetches = 0;
function jwks(kidKeys: Record<string, KeyObject>) {
return {
keys: Object.entries(kidKeys).map(([kid, key]) => ({
...(key.export({ format: "jwk" }) as Record<string, string>),
kid,
alg: "RS256",
use: "sig",
})),
};
}
function serveJwks(kidKeys: Record<string, KeyObject>) {
globalThis.fetch = (async (input: string | URL) => {
expect(String(input)).toBe("https://www.googleapis.com/oauth2/v3/certs");
jwksFetches += 1;
return new Response(JSON.stringify(jwks(kidKeys)), { status: 200, headers: { "cache-control": "public, max-age=3600" } });
}) as typeof fetch;
}
function b64(value: unknown): string {
return Buffer.from(JSON.stringify(value)).toString("base64url");
}
function idToken(
claims: Record<string, unknown> = {},
options: { kid?: string; alg?: string; key?: KeyObject; omit?: string[] } = {}
): string {
const payload: Record<string, unknown> = {
iss: "https://accounts.google.com",
aud: CLIENT_ID,
azp: CLIENT_ID,
sub: "110169484474386276334",
email: "meta@example.org",
email_verified: true,
name: "Meta",
picture: "https://lh3.googleusercontent.com/a/x",
nonce: NONCE,
iat: NOW_S - 10,
exp: NOW_S + 3000,
...claims,
};
for (const key of options.omit ?? []) delete payload[key];
const header = b64({ alg: options.alg ?? "RS256", typ: "JWT", kid: options.kid ?? "k1" });
const body = `${header}.${b64(payload)}`;
const signature = createSign("RSA-SHA256").update(body).sign(options.key ?? rsa.privateKey).toString("base64url");
return `${body}.${signature}`;
}
async function refusal(token: string, nonce = NONCE): Promise<GoogleIdTokenFailure> {
try {
await verifyGoogleIdToken(token, { clientId: CLIENT_ID, nonce }, NOW_MS);
} catch (error) {
expect(error).toBeInstanceOf(GoogleIdTokenError);
return (error as GoogleIdTokenError).code;
}
throw new Error("the token was accepted");
}
beforeEach(() => {
resetGoogleJwksCache();
jwksFetches = 0;
serveJwks({ k1: rsa.publicKey });
});
afterEach(() => {
globalThis.fetch = originalFetch;
});
describe("verifyGoogleIdToken", () => {
it("accepts a good token and returns the subject as the identity", async () => {
const claims = await verifyGoogleIdToken(idToken(), { clientId: CLIENT_ID, nonce: NONCE }, NOW_MS);
expect(claims).toEqual({
sub: "110169484474386276334",
email: "meta@example.org",
name: "Meta",
picture: "https://lh3.googleusercontent.com/a/x",
});
});
it("accepts the bare-host issuer Google also uses", async () => {
await expect(verifyGoogleIdToken(idToken({ iss: "accounts.google.com" }), { clientId: CLIENT_ID, nonce: NONCE }, NOW_MS)).resolves.toMatchObject({
sub: "110169484474386276334",
});
});
it.each<[string, () => string, GoogleIdTokenFailure]>([
["a foreign issuer", () => idToken({ iss: "https://evil.example" }), "bad_issuer"],
["an issuer that merely contains Google's", () => idToken({ iss: "https://accounts.google.com.evil.example" }), "bad_issuer"],
["another client's audience", () => idToken({ aud: "other-client", azp: "other-client" }), "bad_audience"],
["an array audience whose azp is not this client", () => idToken({ aud: [CLIENT_ID, "other"], azp: "other" }), "bad_audience"],
["an array audience with no azp", () => idToken({ aud: [CLIENT_ID], azp: undefined }), "bad_audience"],
["an expired token", () => idToken({ exp: NOW_S - 61 }), "expired"],
["a token with no exp", () => idToken({}, { omit: ["exp"] }), "expired"],
["a token issued in the future", () => idToken({ iat: NOW_S + 3600 }), "not_yet_valid"],
["a token with no iat", () => idToken({}, { omit: ["iat"] }), "not_yet_valid"],
["a nonce this request did not carry", () => idToken({ nonce: "someone-elses" }), "bad_nonce"],
["a token with no nonce", () => idToken({}, { omit: ["nonce"] }), "bad_nonce"],
["no subject", () => idToken({}, { omit: ["sub"] }), "missing_subject"],
["an empty subject", () => idToken({ sub: "" }), "missing_subject"],
["no email", () => idToken({}, { omit: ["email"] }), "email_missing"],
["an unverified email", () => idToken({ email_verified: false }), "email_not_verified"],
["no email_verified claim", () => idToken({}, { omit: ["email_verified"] }), "email_not_verified"],
["a string email_verified other than true", () => idToken({ email_verified: "yes" }), "email_not_verified"],
["alg none", () => idToken({}, { alg: "none" }), "unsupported_algorithm"],
["alg HS256", () => idToken({}, { alg: "HS256" }), "unsupported_algorithm"],
["a key Google does not publish", () => idToken({}, { kid: "unknown" }), "unknown_key"],
["a signature by another key under Google's kid", () => idToken({}, { key: otherRsa.privateKey }), "bad_signature"],
["three empty parts", () => "..", "malformed"],
["two parts", () => "a.b", "malformed"],
])("refuses %s", async (_name, make, code) => {
expect(await refusal(make())).toBe(code);
});
it("checks the signature before it believes any claim", async () => {
// A forged payload with every claim right is still refused as a bad signature.
const forged = idToken({}, { key: otherRsa.privateKey });
expect(await refusal(forged)).toBe("bad_signature");
});
it("takes email_verified string 'true' as Google's older tokens spell it", async () => {
await expect(verifyGoogleIdToken(idToken({ email_verified: "true" }), { clientId: CLIENT_ID, nonce: NONCE }, NOW_MS)).resolves.toMatchObject({
email: "meta@example.org",
});
});
it("caches Google's keys and refetches once for a rotated kid, not for every bad token", async () => {
await verifyGoogleIdToken(idToken(), { clientId: CLIENT_ID, nonce: NONCE }, NOW_MS);
await verifyGoogleIdToken(idToken(), { clientId: CLIENT_ID, nonce: NONCE }, NOW_MS + 1000);
expect(jwksFetches).toBe(1);
serveJwks({ k1: rsa.publicKey, k2: otherRsa.publicKey });
const rotated = idToken({}, { kid: "k2", key: otherRsa.privateKey });
// Inside the refetch floor the unknown kid is refused without a fetch.
await expect(verifyGoogleIdToken(rotated, { clientId: CLIENT_ID, nonce: NONCE }, NOW_MS + 2000)).rejects.toMatchObject({ code: "unknown_key" });
expect(jwksFetches).toBe(1);
await expect(verifyGoogleIdToken(rotated, { clientId: CLIENT_ID, nonce: NONCE }, NOW_MS + 120_000)).resolves.toMatchObject({ sub: "110169484474386276334" });
expect(jwksFetches).toBe(2);
});
it("refuses with jwks_unavailable when Google's key endpoint fails", async () => {
resetGoogleJwksCache();
globalThis.fetch = (async () => new Response("down", { status: 503 })) as typeof fetch;
expect(await refusal(idToken())).toBe("jwks_unavailable");
});
it("never quotes the token in its refusal", async () => {
const token = idToken({ email: "secret-person@example.org", nonce: "wrong" });
try {
await verifyGoogleIdToken(token, { clientId: CLIENT_ID, nonce: NONCE }, NOW_MS);
} catch (error) {
expect(String((error as Error).message)).not.toContain("secret-person");
expect(String((error as Error).message)).not.toContain(token.slice(0, 20));
}
});
});
describe("deriveOidcSecrets", () => {
const key = generateKeyPairSync("ed25519").privateKey;
const otherKey = generateKeyPairSync("ed25519").privateKey;
it("is the same pair for the same state and Heimdall key, so nothing is stored between start and callback", () => {
expect(deriveOidcSecrets("jti-1", key)).toEqual(deriveOidcSecrets("jti-1", key));
});
it("differs per state, per Heimdall key, and between verifier and nonce", () => {
const a = deriveOidcSecrets("jti-1", key);
expect(deriveOidcSecrets("jti-2", key).codeVerifier).not.toBe(a.codeVerifier);
expect(deriveOidcSecrets("jti-2", key).nonce).not.toBe(a.nonce);
expect(deriveOidcSecrets("jti-1", otherKey).codeVerifier).not.toBe(a.codeVerifier);
expect(a.codeVerifier).not.toBe(a.nonce);
});
it("yields an RFC 7636 verifier (43-128 unreserved characters) and its S256 challenge", () => {
const { codeVerifier, codeChallenge } = deriveOidcSecrets("jti-1", key);
expect(codeVerifier).toMatch(/^[A-Za-z0-9_-]{43,128}$/);
expect(codeChallenge).toMatch(/^[A-Za-z0-9_-]{43}$/);
expect(codeChallenge).not.toBe(codeVerifier);
});
});

View file

@ -1,669 +0,0 @@
import { createHash, randomBytes } from "node:crypto";
import { deflateRawSync } from "node:zlib";
import { afterEach, describe, expect, it, vi } from "vitest";
import { type FastifyInstance } from "fastify";
import { buildApp, getHeimdallRuntimeContext, startOAuthFlow } from "../src/app.js";
import { resolveAppCaller } from "../src/app-caller.js";
import { consentDigest, consentText, digestOfText, type ConsentAsk } from "../src/consent.js";
import { decodeJwt, signJwt } from "../src/signing.js";
import { type OAuthProviderRuntime } from "../src/oauth.js";
import { providerCatalog } from "../src/providers.js";
import { InMemoryStore } from "../src/store/index.js";
import { createTestConfig, setCookieOf, stateOf } from "./oauth-flow.js";
// 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.
const apps: FastifyInstance[] = [];
afterEach(async () => {
vi.useRealTimers();
vi.restoreAllMocks();
while (apps.length) await apps.pop()?.close();
});
const defaultScopes = providerCatalog.joystick.defaultScopes;
const secret = { "x-heimdall-app-secret": "streampixels-secret" };
const form = { "content-type": "application/x-www-form-urlencoded" };
/** A Joystick that returns whatever `code-<user>` names, granting only `granted`. */
function joystick(granted: string[] = ["identity:read", "stream:read"]): OAuthProviderRuntime {
return {
async exchangeAuthorizationCode({ code }) {
return { accessToken: `joy-access-${code}`, refreshToken: `joy-refresh-${code}`, tokenType: "Bearer", scope: granted, expiresAt: "2099-01-01T00:00:00.000Z", raw: {} };
},
async resolveIdentity({ accessToken }) {
const id = accessToken.replace("joy-access-code-", "");
return { provider: "joystick", providerUserId: `joy-${id}`, username: id, profile: { id } };
},
async evaluateEntitlements() {
return { facts: [], snapshots: [] };
},
};
}
async function newApp(options: { granted?: string[]; callbacks?: string[] } = {}) {
const store = new InMemoryStore();
const config = createTestConfig();
if (options.callbacks) config.appBackendCallbacks = { streampixels: options.callbacks };
const app = await buildApp({ config, store, oauthRuntimes: { joystick: joystick(options.granted) } });
apps.push(app);
return { app, store };
}
async function startConnect(app: FastifyInstance, handoff: Record<string, unknown> = { kind: "browser_completion" }) {
const response = await app.inject({
method: "POST",
url: "/v1/oauth/joystick/start",
headers: secret,
payload: {
appSlug: "streampixels",
mode: "connect",
connection: { kind: "creator", targetId: "creator-1" },
returnTo: "https://streampixels.gamecult.org/settings",
handoff,
},
});
expect(response.statusCode).toBe(201);
return stateOf(response.json());
}
const launchUrl = (state: string) => `/v1/oauth/joystick/launch?state=${encodeURIComponent(state)}`;
const cookieNamed = (prefix: string) => (response: { headers: Record<string, unknown> }) =>
setCookieOf(response).find((value) => value.startsWith(prefix))?.split(";")[0];
const askCookieOf = cookieNamed("__Host-heimdall-ask-");
const bindingCookieOf = cookieNamed("__Host-heimdall-oauth-");
/** The browser is shown the ask. The cookie it keeps is the ask's, which binds nothing. */
async function seeAsk(app: FastifyInstance, state: string) {
const response = await app.inject({ method: "GET", url: launchUrl(state), headers: { accept: "text/html" } });
expect(response.statusCode).toBe(200);
expect(bindingCookieOf(response), "the ask must not set the launch binding").toBeUndefined();
return { page: response, cookie: askCookieOf(response)! };
}
function answer(app: FastifyInstance, state: string, decision: "continue" | "decline", cookie?: string, accept = "text/html") {
return app.inject({
method: "POST",
url: "/v1/oauth/joystick/consent",
headers: { ...form, accept, ...(cookie ? { cookie } : {}) },
payload: new URLSearchParams({ state, decision }).toString(),
});
}
function callback(app: FastifyInstance, state: string, cookie: string, query: string, accept = "application/json") {
return app.inject({
method: "GET",
url: `/v1/oauth/joystick/callback?state=${encodeURIComponent(state)}&${query}`,
headers: { cookie, accept },
});
}
/** Ask, accept, and complete as Joystick user `user`; returns the callback's JSON. */
async function grantAs(app: FastifyInstance, user: string) {
const state = await startConnect(app);
const asked = await seeAsk(app, state);
const go = await answer(app, state, "continue", asked.cookie);
expect(go.statusCode).toBe(302);
const done = await callback(app, state, bindingCookieOf(go)!, `code=code-${user}`);
expect(done.statusCode).toBe(201);
return done.json() as { accessToken: string; consentReceiptId: string; account: { id: string }; session: { accountId: string } };
}
const bearer = (token: string) => ({ authorization: `Bearer ${token}` });
const grantsOf = async (app: FastifyInstance, token: string) =>
(await app.inject({ method: "GET", url: "/v1/me/grants", headers: bearer(token) })).json() as { grants: Array<Record<string, any>>; receipts: Array<Record<string, any>> };
const resolveCredential = (app: FastifyInstance, accountId: string) =>
app.inject({ method: "POST", url: "/v1/apps/streampixels/managed-credentials/resolve", headers: secret, payload: { accountId, provider: "joystick" } });
describe("the ask", () => {
it("launch shows a Heimdall page naming app, provider, scopes and custody, and does not redirect", async () => {
const { app } = await newApp();
const { page } = await seeAsk(app, await startConnect(app));
expect(page.headers.location).toBeUndefined();
for (const needle of ["StreamPixels", "Joystick", ...defaultScopes, "refresh token", "streampixels.gamecult.org", "Continue to Joystick", "No, return to the app"]) {
expect(page.body).toContain(needle);
}
expect(page.body).not.toContain("<script");
expect(page.headers["content-security-policy"]).toContain("form-action 'self' https://joystick.tv");
});
it("viewing the ask claims nothing: the launch is still spendable once after any number of views", async () => {
const { app } = await newApp();
const state = await startConnect(app);
await seeAsk(app, state);
const { cookie } = await seeAsk(app, state);
expect((await answer(app, state, "continue", cookie)).statusCode).toBe(302);
const again = await answer(app, state, "continue", cookie, "application/json");
expect(again.statusCode).toBe(400);
expect(again.json()).toEqual({ error: "state_already_launched" });
});
it("Continue sends exactly the scopes the page listed", async () => {
const { app } = await newApp();
const state = await startConnect(app);
const { cookie } = await seeAsk(app, state);
const go = await answer(app, state, "continue", cookie);
expect(go.statusCode).toBe(302);
const target = new URL(go.headers.location as string);
expect(target.origin).toBe("https://joystick.tv");
expect(target.searchParams.get("scope")).toBe(defaultScopes.join(" "));
});
it("a flow with no custody (sign-in through an identity provider) gets no ask and redirects as before", async () => {
const { app } = await newApp();
const start = await app.inject({
method: "POST",
url: "/v1/oauth/twitch/start",
headers: secret,
payload: { appSlug: "streampixels", mode: "sign_in", returnTo: "https://streampixels.gamecult.org/", handoff: { kind: "browser_completion" } },
});
expect(start.statusCode).toBe(201);
const launch = await app.inject({ method: "GET", url: `/v1/oauth/twitch/launch?state=${encodeURIComponent(stateOf(start.json()))}`, headers: { accept: "text/html" } });
expect(launch.statusCode).toBe(302);
});
it("an answer without the cookie the ask set is refused and spends nothing", async () => {
const { app } = await newApp();
const state = await startConnect(app);
const { cookie } = await seeAsk(app, state);
for (const decision of ["continue", "decline"] as const) {
const stranger = await answer(app, state, decision, undefined, "application/json");
expect(stranger.statusCode, decision).toBe(400);
expect(stranger.json(), decision).toEqual({ error: "state_not_bound" });
}
expect((await answer(app, state, "continue", cookie)).statusCode).toBe(302);
});
it("a stranger who only opens the launch URL is not bound to the state and cannot complete it", async () => {
const { app } = await newApp();
const state = await startConnect(app);
const victim = await seeAsk(app, state);
await answer(app, state, "continue", victim.cookie);
// A fresh browser that holds the URL loads the ask and calls back with its own provider code.
const stranger = await seeAsk(app, state);
for (const cookie of [stranger.cookie, ""]) {
const done = await callback(app, state, cookie, "code=code-attacker");
expect(done.statusCode).toBe(400);
expect(done.json()).toEqual({ error: "state_not_bound" });
}
});
it("only Continue sets the launch binding, and the ask's cookie is no binding: it cannot complete a callback", async () => {
const { app } = await newApp();
const state = await startConnect(app);
const asked = await seeAsk(app, state);
const done = await callback(app, state, asked.cookie, "code=code-a");
expect(done.json()).toEqual({ error: "state_not_bound" });
const go = await answer(app, state, "continue", asked.cookie);
expect(bindingCookieOf(go)).toBeDefined();
expect(bindingCookieOf(go)!.length).toBeLessThan(3500);
});
it("a binding without the shown wording is refused with consent_text_missing before the state is spent", async () => {
const { app, store } = await newApp();
const ctx = getHeimdallRuntimeContext(app);
const state = await startConnect(app);
const { jti, exp } = decodeJwt(state).payload as { jti: string; exp: number };
// A binding that names this state but carries no wording: what a launch from before the ask would leave.
const bare = signJwt({ typ: "heimdall_oauth_binding", iss: ctx.config.issuer, jti, iat: exp - 600, exp }, ctx.keys);
const done = await callback(app, state, `__Host-heimdall-oauth-${jti}=${bare}`, "code=code-a");
expect(done.statusCode).toBe(400);
expect(done.json()).toEqual({ error: "consent_text_missing" });
expect(await store.findStoredLinkedIdentity("joystick", "joy-a"), "no credential is kept").toBeNull();
// Neither use of the state was spent by the refusal.
const usedAt = new Date().toISOString();
const expiresAt = new Date(exp * 1000).toISOString();
expect(await store.claimOAuthStateUse({ jti, phase: "callback", appSlug: "streampixels", usedAt, expiresAt })).toBe(true);
});
describe("the wording a cookie may carry", () => {
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 () => {
const { app } = await newApp();
const ctx = getHeimdallRuntimeContext(app);
const state = await startConnect(app);
const { jti, exp } = decodeJwt(state).payload as { jti: string; exp: number };
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);
// At the cap it is read.
expect((await answerWith(Buffer.alloc(16 * 1024, 97))).json()).toMatchObject({ error: "consent_refused" });
});
it("requested scopes are capped at 32 of 512 characters, at start", async () => {
const { app } = await newApp();
const startWith = (requestedScopes: string[]) =>
app.inject({
method: "POST",
url: "/v1/oauth/joystick/start",
headers: secret,
payload: { appSlug: "streampixels", mode: "connect", connection: { kind: "creator", targetId: "c" }, returnTo: "https://streampixels.gamecult.org/s", handoff: { kind: "browser_completion" }, requestedScopes },
});
const scopes = (n: number, len: number) => Array.from({ length: n }, (_, i) => `${i}`.padEnd(len, "x"));
expect((await startWith(scopes(33, 10))).json()).toMatchObject({ error: "requested_scopes_too_large" });
expect((await startWith(scopes(1, 513))).json()).toMatchObject({ error: "requested_scopes_too_large" });
expect((await startWith(scopes(32, 512))).statusCode).toBe(201);
});
it("an ask the cookies could not carry is refused, not shown", async () => {
const { app } = await newApp();
const noise = Array.from({ length: 32 }, () => randomBytes(256).toString("hex")); // 32 x 512 incompressible
const start = await app.inject({
method: "POST",
url: "/v1/oauth/joystick/start",
headers: secret,
payload: { appSlug: "streampixels", mode: "connect", connection: { kind: "creator", targetId: "c" }, returnTo: "https://streampixels.gamecult.org/s", handoff: { kind: "browser_completion" }, requestedScopes: noise },
});
expect(start.statusCode).toBe(201);
const page = await app.inject({ method: "GET", url: launchUrl(stateOf(start.json())), headers: { accept: "text/html" } });
expect(page.statusCode).toBe(400);
expect(page.json()).toEqual({ error: "consent_ask_too_large" });
expect(askCookieOf(page)).toBeUndefined();
});
});
it("the consent route refuses a flow that has no ask", async () => {
const { app } = await newApp();
const start = await app.inject({
method: "POST",
url: "/v1/oauth/twitch/start",
headers: secret,
payload: { appSlug: "streampixels", mode: "sign_in", returnTo: "https://streampixels.gamecult.org/", handoff: { kind: "browser_completion" } },
});
const state = stateOf(start.json());
const response = await app.inject({
method: "POST",
url: "/v1/oauth/twitch/consent",
headers: form,
payload: new URLSearchParams({ state, decision: "continue" }).toString(),
});
expect(response.statusCode).toBe(400);
expect(response.json()).toEqual({ error: "consent_not_required" });
});
});
describe("the wording digest", () => {
const ask: ConsentAsk = { appSlug: "streampixels", provider: "joystick", scopes: ["identity:read"], returnOrigin: "https://streampixels.gamecult.org" };
it("is the sha-256 of the text shown, and moves with the app, provider, scopes and origin", () => {
expect(consentDigest(ask)).toBe(createHash("sha256").update(consentText(ask), "utf8").digest("hex"));
const variants: ConsentAsk[] = [
{ ...ask, appSlug: "bifrost" },
{ ...ask, provider: "twitch" },
{ ...ask, scopes: ["identity:read", "chat:read"] },
{ ...ask, returnOrigin: "https://elsewhere.example" },
];
for (const variant of variants) expect(consentDigest(variant)).not.toBe(consentDigest(ask));
});
});
describe("refusal", () => {
it("declining yields consent_refused by the viewer, stores nothing, and spends the state", async () => {
const { app, store } = await newApp();
const state = await startConnect(app);
const { cookie } = await seeAsk(app, state);
const no = await answer(app, state, "decline", cookie, "application/json");
expect(no.statusCode).toBe(400);
expect(no.json()).toMatchObject({ error: "consent_refused", by: "viewer", provider: "joystick" });
// Nothing launches or completes afterwards, and nothing was kept.
expect((await answer(app, state, "continue", cookie, "application/json")).json()).toEqual({ error: "state_already_launched" });
expect((await callback(app, state, cookie, "code=code-a")).json()).toEqual({ error: "state_not_bound" });
expect(await store.findStoredLinkedIdentity("joystick", "joy-a")).toBeNull();
const events = [...(store as unknown as { auditEvents: Map<string, { eventType: string; eventPayloadJson: unknown }> }).auditEvents.values()];
expect(events.filter((event) => event.eventType === "consent_refused").map((event) => event.eventPayloadJson)).toEqual([
{ provider: "joystick", mode: "connect", by: "viewer" },
]);
});
it("the browser sees a 'Not Connected' page, not an authentication failure", async () => {
const { app } = await newApp();
const state = await startConnect(app);
const { cookie } = await seeAsk(app, state);
const no = await answer(app, state, "decline", cookie);
expect(no.statusCode).toBe(200);
expect(no.body).toContain("Not Connected");
expect(no.body).toContain("consent_refused");
expect(no.body).not.toContain("Authentication Failed");
});
it("an access_denied from the provider is consent_refused by the provider, distinct from provider_error", async () => {
const { app, store } = await newApp();
const denied = await newFlow(app);
const response = await callback(app, denied.state, denied.cookie, "error=access_denied");
expect(response.statusCode).toBe(400);
expect(response.json()).toMatchObject({ error: "consent_refused", by: "provider" });
const faulty = await newFlow(app);
expect((await callback(app, faulty.state, faulty.cookie, "error=server_error")).json().error).toBe("provider_error");
const events = [...(store as unknown as { auditEvents: Map<string, { eventType: string }> }).auditEvents.values()];
expect(events.filter((event) => event.eventType === "consent_refused")).toHaveLength(1);
});
it("a backend_callback app is told, with who refused", async () => {
const callbackUrl = "https://streampixels.gamecult.org/api/heimdall/callback";
const { app } = await newApp({ callbacks: [callbackUrl] });
const delivered: Array<Record<string, unknown>> = [];
vi.spyOn(globalThis, "fetch").mockImplementation(async (_url, init) => {
delivered.push(JSON.parse(String(init?.body)));
return new Response("ok", { status: 200 });
});
const handoff = { kind: "backend_callback", callbackUrl, attemptId: "attempt-1" };
const byViewer = await startConnect(app, handoff);
const { cookie } = await seeAsk(app, byViewer);
await answer(app, byViewer, "decline", cookie);
const byProvider = await startConnect(app, handoff);
const second = await seeAsk(app, byProvider);
const proceeded = await answer(app, byProvider, "continue", second.cookie);
await callback(app, byProvider, bindingCookieOf(proceeded)!, "error=access_denied");
expect(delivered.map((payload) => [payload.status, payload.error, payload.by])).toEqual([
["error", "consent_refused", "viewer"],
["error", "consent_refused", "provider"],
]);
});
});
/** What an app's landing page can read: the fragment on the page's "Return to the app" link. */
function returnFragment(page: { body: string }): URLSearchParams {
const href = /id="return-link" href="([^"]+)"/.exec(page.body)?.[1];
expect(href, "the page carries a return link").toBeDefined();
const url = new URL(href!);
expect(url.origin + url.pathname).toBe("https://streampixels.gamecult.org/settings");
return new URLSearchParams(url.hash.slice(1));
}
describe("the refusal return contract", () => {
const refusedFragment = (by: "viewer" | "provider") => ({
heimdall_status: "error",
heimdall_provider: "joystick",
heimdall_handoff_kind: "browser_completion",
heimdall_app_slug: "streampixels",
heimdall_mode: "connect",
heimdall_error: "consent_refused",
heimdall_refused_by: by,
});
it("a decline returns a fragment that says the viewer refused, with no completion code", async () => {
const { app } = await newApp();
const state = await startConnect(app);
const { cookie } = await seeAsk(app, state);
const fragment = returnFragment(await answer(app, state, "decline", cookie));
expect(Object.fromEntries(fragment)).toEqual(refusedFragment("viewer"));
expect(fragment.has("heimdall_completion_code")).toBe(false);
});
it("a provider access_denied returns a fragment that says the provider refused, with no completion code", async () => {
const { app } = await newApp();
const flow = await newFlow(app);
const fragment = returnFragment(await callback(app, flow.state, flow.cookie, "error=access_denied", "text/html"));
expect(Object.fromEntries(fragment)).toEqual(refusedFragment("provider"));
expect(fragment.has("heimdall_completion_code")).toBe(false);
});
it.each(["viewer", "provider"] as const)("a browser_completion attempt refused by the %s is named in the fragment", async (by) => {
const { app, store } = await newApp();
const ctx = getHeimdallRuntimeContext(app);
const attempt = await store.createAuthAttempt({
handle: "attempt-refused",
appSlug: "streampixels",
provider: "joystick",
mode: "connect",
returnTo: "https://streampixels.gamecult.org/settings",
createdAt: new Date().toISOString(),
expiresAt: new Date(Date.now() + 600_000).toISOString(),
});
const caller = resolveAppCaller(ctx.config, "streampixels", secret);
const started = await startOAuthFlow(
ctx,
"joystick",
caller,
{ appSlug: "streampixels", mode: "connect", connection: { kind: "creator", targetId: "creator-1" }, returnTo: "https://streampixels.gamecult.org/settings", handoff: { kind: "browser_completion" } },
{ trustedBrowserAttemptId: attempt.handle }
);
expect(started.statusCode).toBe(201);
const state = stateOf(started.body as Parameters<typeof stateOf>[0]);
const { cookie } = await seeAsk(app, state);
let page;
if (by === "viewer") {
page = await answer(app, state, "decline", cookie);
} else {
const go = await answer(app, state, "continue", cookie);
page = await callback(app, state, bindingCookieOf(go)!, "error=access_denied", "text/html");
}
expect(Object.fromEntries(returnFragment(page))).toEqual({ ...refusedFragment(by), heimdall_attempt_id: "attempt-refused" });
});
});
async function newFlow(app: FastifyInstance) {
const state = await startConnect(app);
const { cookie } = await seeAsk(app, state);
const go = await answer(app, state, "continue", cookie);
expect(go.statusCode).toBe(302);
return { state, cookie: bindingCookieOf(go)! };
}
describe("the receipt", () => {
it("a granted completion writes one receipt with asked and granted scopes and the digest of the wording shown", async () => {
const { app, store } = await newApp({ granted: ["identity:read", "stream:read"] });
const done = await grantAs(app, "a");
const receipts = await store.listConsentReceipts(done.account.id);
expect(receipts).toHaveLength(1);
const ask: ConsentAsk = { appSlug: "streampixels", provider: "joystick", scopes: defaultScopes, returnOrigin: "https://streampixels.gamecult.org" };
expect(receipts[0]).toMatchObject({
id: done.consentReceiptId,
kind: "grant",
accountId: done.account.id,
appSlug: "streampixels",
provider: "joystick",
scopesAsked: defaultScopes,
scopesGranted: ["identity:read", "stream:read"],
consentPageDigest: consentDigest(ask),
});
});
it("the receipt holds the wording the person was shown, and its digest is that wording's hash", async () => {
const { app, store } = await newApp();
const state = await startConnect(app);
const asked = await seeAsk(app, state);
const go = await answer(app, state, "continue", asked.cookie);
const done = await callback(app, state, bindingCookieOf(go)!, "code=code-a");
const [receipt] = await store.listConsentReceipts(done.json().account.id);
if (receipt?.kind !== "grant") throw new Error("no grant");
expect(receipt.consentPageDigest).toBe(digestOfText(receipt.consentText));
const shown = asked.page.body.replace(/&#39;/g, "'").replace(/&quot;/g, '"').replace(/&gt;/g, ">").replace(/&lt;/g, "<").replace(/&amp;/g, "&");
for (const line of receipt.consentText.split("\n")) expect(shown, line).toContain(line);
expect(receipt.consentText).toContain("Continue to Joystick");
expect(receipt.consentText).toContain("No, return to the app");
});
it("a refusal leaves no receipt", async () => {
const { app, store } = await newApp();
const state = await startConnect(app);
const { cookie } = await seeAsk(app, state);
await answer(app, state, "decline", cookie);
expect(await store.listConsentReceipts("anyone")).toEqual([]);
expect((await store.findStoredLinkedIdentity("joystick", "joy-a"))).toBeNull();
});
it("the success page names the receipt", async () => {
const { app } = await newApp();
const state = await startConnect(app);
const { cookie } = await seeAsk(app, state);
const go = await answer(app, state, "continue", cookie);
const page = await callback(app, state, bindingCookieOf(go)!, "code=code-a", "text/html");
expect(page.statusCode).toBe(200);
expect(page.body).toMatch(/receipt [0-9a-f-]{36}/);
});
});
describe("my grants", () => {
it("is closed to anyone without a live Heimdall access token", async () => {
const { app } = await newApp();
for (const headers of [{}, bearer("not-a-token")]) {
const response = await app.inject({ method: "GET", url: "/v1/me/grants", headers });
expect(response.statusCode).toBe(401);
expect(response.json().error).toBe("sign_in_required");
}
const link = await app.inject({ method: "POST", url: "/v1/me/grants/link" });
expect(link.statusCode).toBe(401);
const revoke = await app.inject({ method: "POST", url: "/v1/me/grants/x/revoke" });
expect(revoke.statusCode).toBe(401);
});
it("lists the signed-in account's grants and receipts, and no one else's", async () => {
const { app } = await newApp();
const a = await grantAs(app, "a");
const b = await grantAs(app, "b");
const mine = await grantsOf(app, a.accessToken);
expect(mine.grants).toHaveLength(1);
expect(mine.grants[0]).toMatchObject({ id: a.consentReceiptId, status: "active", provider: "joystick", appSlug: "streampixels", scopesGranted: ["identity:read", "stream:read"] });
expect(JSON.stringify(mine)).not.toContain(b.consentReceiptId);
expect(mine.receipts.map((receipt) => receipt.accountId)).toEqual([a.account.id]);
});
it("revoke takes effect on the next token use, edits no row, and receipts the revocation", async () => {
const { app, store } = await newApp();
const a = await grantAs(app, "a");
expect((await resolveCredential(app, a.account.id)).statusCode).toBe(200);
const before = await store.listConsentReceipts(a.account.id);
const revoke = await app.inject({ method: "POST", url: `/v1/me/grants/${a.consentReceiptId}/revoke`, headers: bearer(a.accessToken) });
expect(revoke.statusCode).toBe(200);
expect(revoke.json().revoked).toEqual([a.consentReceiptId]);
const next = await resolveCredential(app, a.account.id);
expect(next.statusCode).toBe(410);
expect(next.json().error).toBe("managed_credential_revoked");
const after = await store.listConsentReceipts(a.account.id);
expect(after).toHaveLength(2);
expect(after[0]).toEqual(before[0]);
expect(after[1]).toMatchObject({ kind: "revocation", revokesReceiptId: a.consentReceiptId, accountId: a.account.id });
const view = (await grantsOf(app, a.accessToken)).grants[0]!;
expect(view).toMatchObject({ status: "revoked", revocationReceiptId: after[1]!.id });
expect(view.revokedAt).toBe(after[1]!.createdAt);
});
it("revoke is the owner's alone: another account's grant id is not found and its credential is untouched", async () => {
const { app } = await newApp();
const a = await grantAs(app, "a");
const b = await grantAs(app, "b");
const attempt = await app.inject({ method: "POST", url: `/v1/me/grants/${a.consentReceiptId}/revoke`, headers: bearer(b.accessToken) });
expect(attempt.statusCode).toBe(404);
expect((await resolveCredential(app, a.account.id)).statusCode).toBe(200);
expect((await resolveCredential(app, b.account.id)).statusCode).toBe(200);
});
it("revoking twice changes nothing the second time", async () => {
const { app, store } = await newApp();
const a = await grantAs(app, "a");
const url = `/v1/me/grants/${a.consentReceiptId}/revoke`;
await app.inject({ method: "POST", url, headers: bearer(a.accessToken) });
const second = await app.inject({ method: "POST", url, headers: bearer(a.accessToken) });
expect(second.statusCode).toBe(200);
expect(second.json().revoked).toEqual([]);
expect(await store.listConsentReceipts(a.account.id)).toHaveLength(2);
const events = [...(store as unknown as { auditEvents: Map<string, { eventType: string }> }).auditEvents.values()];
expect(events.filter((event) => event.eventType === "consent_revoked")).toHaveLength(1);
});
it("a fresh connection after a revoke is a new grant, live again, and the old one stays revoked", async () => {
const { app } = await newApp();
const first = await grantAs(app, "a");
await app.inject({ method: "POST", url: `/v1/me/grants/${first.consentReceiptId}/revoke`, headers: bearer(first.accessToken) });
const second = await grantAs(app, "a");
expect(second.consentReceiptId).not.toBe(first.consentReceiptId);
expect((await resolveCredential(app, second.account.id)).statusCode).toBe(200);
const grants = (await grantsOf(app, second.accessToken)).grants;
expect(grants.map((grant) => [grant.id, grant.status])).toEqual([
[first.consentReceiptId, "revoked"],
[second.consentReceiptId, "active"],
]);
});
it("a browser with no session opens the page from a minted link and revokes with one form post", async () => {
const { app } = await newApp();
const a = await grantAs(app, "a");
const link = await app.inject({ method: "POST", url: "/v1/me/grants/link", headers: bearer(a.accessToken) });
expect(link.statusCode).toBe(201);
const url = new URL(link.json().url as string);
const page = await app.inject({ method: "GET", url: `${url.pathname}${url.search}`, headers: { accept: "text/html" } });
expect(page.statusCode).toBe(200);
expect(page.body).toContain(a.consentReceiptId);
expect(page.body).toContain("Take this back");
expect(page.body).not.toContain("<script");
const token = url.searchParams.get("t")!;
const post = await app.inject({
method: "POST",
url: `/v1/me/grants/${a.consentReceiptId}/revoke`,
headers: { ...form, accept: "text/html" },
payload: new URLSearchParams({ t: token }).toString(),
});
expect(post.statusCode).toBe(200);
expect(post.body).toContain("Taken back.");
expect((await resolveCredential(app, a.account.id)).statusCode).toBe(410);
});
it("a page link is never renewed by being used: the forms on its page expire when the link does", async () => {
const { app } = await newApp();
const a = await grantAs(app, "a");
const link = await app.inject({ method: "POST", url: "/v1/me/grants/link", headers: bearer(a.accessToken) });
const url = new URL(link.json().url as string);
const linkToken = url.searchParams.get("t")!;
// Two minutes on, so a form token minted afresh would differ from the link's.
vi.useFakeTimers({ toFake: ["Date"], now: Date.now() + 120_000 });
const page = await app.inject({ method: "GET", url: `${url.pathname}${url.search}`, headers: { accept: "text/html" } });
const formToken = /name="t" value="([^"]+)"/.exec(page.body)![1]!;
expect(decodeJwt(formToken).payload.exp).toBe(decodeJwt(linkToken).payload.exp);
// The same holds one hop on: the page a form post answers with.
const post = await app.inject({
method: "POST",
url: "/v1/me/grants/nope/revoke",
headers: { ...form, accept: "text/html" },
payload: new URLSearchParams({ t: formToken }).toString(),
});
expect(post.statusCode).toBe(404);
// A bearer, by contrast, opens a fresh window.
const fresh = await app.inject({ method: "GET", url: "/v1/me/grants", headers: { ...bearer(a.accessToken), accept: "text/html" } });
const freshToken = /name="t" value="([^"]+)"/.exec(fresh.body)![1]!;
expect(decodeJwt(freshToken).payload.exp).toBeGreaterThanOrEqual(decodeJwt(linkToken).payload.exp as number);
});
it("two revokes at once take the grant back once: one revocation row, one audit event, one reporter", async () => {
const { app, store } = await newApp();
const a = await grantAs(app, "a");
const url = `/v1/me/grants/${a.consentReceiptId}/revoke`;
const [one, two] = await Promise.all([
app.inject({ method: "POST", url, headers: bearer(a.accessToken) }),
app.inject({ method: "POST", url, headers: bearer(a.accessToken) }),
]);
expect([one.json().revoked.length, two.json().revoked.length].sort()).toEqual([0, 1]);
expect((await store.listConsentReceipts(a.account.id)).filter((r) => r.kind === "revocation")).toHaveLength(1);
const events = [...(store as unknown as { auditEvents: Map<string, { eventType: string }> }).auditEvents.values()];
expect(events.filter((event) => event.eventType === "consent_revoked")).toHaveLength(1);
});
it("a page link proves only what it was minted for: an access token, a foreign token or none opens nothing", async () => {
const { app } = await newApp();
const a = await grantAs(app, "a");
for (const t of [a.accessToken, "garbage", ""]) {
const response = await app.inject({ method: "GET", url: `/v1/me/grants?t=${encodeURIComponent(t)}` });
expect(response.statusCode, t).toBe(401);
const revoke = await app.inject({
method: "POST",
url: `/v1/me/grants/${a.consentReceiptId}/revoke`,
headers: form,
payload: new URLSearchParams({ t }).toString(),
});
expect(revoke.statusCode, t).toBe(401);
}
expect((await resolveCredential(app, a.account.id)).statusCode).toBe(200);
});
});

View file

@ -7,7 +7,6 @@ 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 { launchFlow } from "./oauth-flow.js";
function createTestConfig(): HeimdallConfig {
return {
@ -50,7 +49,6 @@ function createTestConfig(): HeimdallConfig {
youtube: { clientId: "youtube-client", clientSecret: "youtube-secret" },
spotify: { clientId: "spotify-client", clientSecret: "spotify-secret" },
joystick: { clientId: "joystick-client", clientSecret: "joystick-secret" },
google: { clientId: "google-client", clientSecret: "google-secret" },
},
};
}
@ -99,15 +97,8 @@ function start(r: Rig, payload: Record<string, unknown>, headers: Record<string,
return r.app.inject({ method: "POST", url: "/v1/oauth/discord/start", headers, payload: { appSlug: "repixelizer", returnTo, ...payload } });
}
/** The browser follows authorizationUrl through the launch hop, then returns with the state, or with `forged` in its place, and the cookie it was handed. */
async function callback(r: Rig, started: { authorizationUrl: string }, forged?: (state: string) => string) {
const flow = await launchFlow(r.app, started);
const state = forged ? forged(flow.state) : flow.state;
return r.app.inject({
method: "GET",
url: `/v1/oauth/discord/callback?code=c&state=${encodeURIComponent(state)}`,
headers: { cookie: flow.cookie },
});
function callback(r: Rig, stateToken: string) {
return r.app.inject({ method: "GET", url: `/v1/oauth/discord/callback?code=c&state=${encodeURIComponent(stateToken)}` });
}
function redeem(r: Rig, payload: Record<string, unknown>, headers: Record<string, string> = {}) {
@ -146,7 +137,7 @@ describe("link callback holds the provider identity to the initiator", () => {
const r = await rig("d-new");
const started = await start(r, { mode: "link", initiatingAccountId: r.initiator.id });
expect(started.statusCode).toBe(201);
const done = await callback(r, started.json());
const done = await callback(r, started.json().stateToken);
expect(done.statusCode).toBe(201);
expect(done.json().account.id).toBe(r.initiator.id);
expect((await r.store.findAccountByLinkedIdentity("discord", "d-new"))?.id).toBe(r.initiator.id);
@ -158,7 +149,7 @@ describe("link callback holds the provider identity to the initiator", () => {
const other = await r.store.createAccount({ createdAt: now, lastSeenAt: now });
await r.store.upsertLinkedIdentity({ accountId: other.id, provider: "discord", providerUserId: "d-held", scopes: [], profileJson: {}, createdAt: now, updatedAt: now });
const started = await start(r, { mode: "link", initiatingAccountId: r.initiator.id });
const done = await callback(r, started.json());
const done = await callback(r, started.json().stateToken);
expect(done.statusCode).toBe(403);
expect(done.json().error).toBe("link_initiator_mismatch");
expect(done.json().completion).toBeUndefined();
@ -173,11 +164,10 @@ describe("link callback holds the provider identity to the initiator", () => {
const r = await rig("d-forged", config);
const keys = createRuntimeKeyMaterial({ ...config, bootstrapSigningPrivateKeyOnMissing: false });
const started = await start(r, { mode: "link", initiatingAccountId: r.initiator.id });
const done = await callback(r, started.json(), (state) => {
const verified = verifyJwt(state, keys.publicKey) as { valid: boolean; payload: Record<string, unknown> };
expect(verified.valid).toBe(true);
return signJwt({ ...verified.payload, initiator_account_id: null }, keys);
});
const verified = verifyJwt(started.json().stateToken, keys.publicKey) as { valid: boolean; payload: Record<string, unknown> };
expect(verified.valid).toBe(true);
const stripped = signJwt({ ...verified.payload, initiator_account_id: null }, keys);
const done = await callback(r, stripped);
expect([done.statusCode, done.json().error]).toEqual([403, "link_initiator_missing"]);
expect(await r.store.findAccountByLinkedIdentity("discord", "d-forged")).toBeNull();
} finally {
@ -193,7 +183,7 @@ describe("redeem binds caller, target and replay", () => {
initiatingAccountId: r.initiator.id,
...(targetId ? { connection: { kind: "account", targetId } } : {}),
});
const done = await callback(r, started.json());
const done = await callback(r, started.json().stateToken);
expect(done.statusCode).toBe(201);
return done.json().completion.code as string;
}
@ -218,16 +208,6 @@ describe("redeem binds caller, target and replay", () => {
expect((await redeem(r, { completionCode, targetId: "target-a" }, appAuth)).statusCode).toBe(201);
});
it("refuses a target named against a completion that recorded none, without consuming the code", async () => {
const r = await rig("d-r2b");
const completionCode = await linked(r, undefined);
const refused = await redeem(r, { completionCode, targetId: "target-a" }, appAuth);
expect([refused.statusCode, refused.json().error]).toEqual([403, "completion_target_mismatch"]);
expect(refused.json().accessToken).toBeUndefined();
expect((await r.store.findAuthCompletion("repixelizer", completionCode))?.consumedAt).toBeUndefined();
expect((await redeem(r, { completionCode }, appAuth)).statusCode).toBe(201);
});
it("refuses a replayed completion", async () => {
const r = await rig("d-r3");
const completionCode = await linked(r, "target-a");

View file

@ -6,7 +6,6 @@ import { type HeimdallConfig } from "../src/config.js";
import { type Provider } from "../src/contracts.js";
import { createTokenCustody } from "../src/custody.js";
import {
MANAGED_CREDENTIAL_REFRESH_BACKOFF_MS,
MANAGED_CREDENTIAL_REFRESH_SKEW_MS,
ManagedCredentialRefreshFailed,
refreshManagedCredential,
@ -15,7 +14,6 @@ import { createOAuthRuntimeRegistry } from "../src/oauth.js";
import { providerCatalog } from "../src/providers.js";
import { InMemoryStore, PostgresStore, type HeimdallStore } from "../src/store/index.js";
import { type StoredLinkedIdentity } from "../src/store/types.js";
import { launchFlow } from "./oauth-flow.js";
const originalFetch = globalThis.fetch;
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
@ -420,18 +418,17 @@ for (const { kind, live } of rigKinds) describe.skipIf(live && !adminUrl)(`manag
expect(Date.now() - started).toBeLessThan(5_000);
endpoint.mode = "normal";
const afterBackoff = { ...ctx, clock: () => Date.now() + MANAGED_CREDENTIAL_REFRESH_BACKOFF_MS };
const outcome = await refreshManagedCredential(afterBackoff, await rig.row());
const outcome = await refreshManagedCredential(ctx, await rig.row());
expect(outcome.kind).toBe("current");
expect(rig.custody.decrypt(outcome.identity.accessTokenEncrypted!)).toBe("access-1");
});
it("single flight: concurrent refreshes against a never-answering endpoint make one provider call across all processes, and every caller gets the typed failure", timeout, async () => {
it("single flight: concurrent refreshes against a never-answering endpoint make one provider call per process, and every caller gets the typed failure", timeout, async () => {
const rig = await makeRig(provider);
const endpoint = installTokenEndpoint(provider, "refresh-0");
endpoint.mode = "hang";
const snapshot = await rig.row();
const ctxFor = (store: HeimdallStore, clock = Date.now) => ({ config: rig.config, store, tokenCustody: rig.custody, oauthRuntimes: createOAuthRuntimeRegistry(), timeoutMs: 150, clock });
const ctxFor = (store: HeimdallStore) => ({ config: rig.config, store, tokenCustody: rig.custody, oauthRuntimes: createOAuthRuntimeRegistry(), timeoutMs: 150 });
const results = await Promise.allSettled(
Array.from({ length: 12 }, (_, i) => refreshManagedCredential(ctxFor(rig.stores[i % rig.stores.length]!), snapshot))
@ -441,205 +438,14 @@ for (const { kind, live } of rigKinds) describe.skipIf(live && !adminUrl)(`manag
for (const result of results) {
expect((result as PromiseRejectedResult).reason).toBeInstanceOf(ManagedCredentialRefreshFailed);
}
// The process that queued behind the failed holder reads the holder's marker under the lock and presents nothing.
expect(endpoint.calls).toBe(1);
expect(endpoint.calls).toBe(rig.stores.length);
expect(rig.custody.decrypt((await rig.row()).refreshTokenEncrypted!)).toBe("refresh-0");
// The burst is over and so is the backoff the failure left on the row: a later independent request tries again.
// The burst is over: a later independent request tries again.
endpoint.mode = "normal";
const later = await refreshManagedCredential(ctxFor(rig.stores[0]!, () => Date.now() + MANAGED_CREDENTIAL_REFRESH_BACKOFF_MS), await rig.row());
const later = await refreshManagedCredential(ctxFor(rig.stores[0]!), await rig.row());
expect(later.kind).toBe("current");
expect(endpoint.calls).toBe(2);
});
it.each(["server_error", "hang"] as const)(
"cross-process failed-refresh marker (%s): the failed attempt is recorded on the row, and another process does not present the token inside the backoff",
timeout,
async (mode) => {
const rig = await makeRig(provider);
const endpoint = installTokenEndpoint(provider, "refresh-0");
endpoint.mode = mode;
const t0 = Date.now();
const ctxFor = (store: HeimdallStore, at: number) => ({
config: rig.config, store, tokenCustody: rig.custody, oauthRuntimes: createOAuthRuntimeRegistry(), timeoutMs: 100, clock: () => at,
});
const snapshot = await rig.row();
const first = rig.stores[0]!;
const other = rig.stores[rig.stores.length - 1]!;
await expect(refreshManagedCredential(ctxFor(first, t0), snapshot)).rejects.toBeInstanceOf(ManagedCredentialRefreshFailed);
expect(endpoint.calls).toBe(1);
const marked = await rig.row();
expect(marked.refreshFailedAt).toBe(new Date(t0).toISOString());
expect(marked.accessTokenEncrypted).toBe(snapshot.accessTokenEncrypted);
expect(marked.refreshTokenEncrypted).toBe(snapshot.refreshTokenEncrypted);
expect(marked.tokenExpiresAt).toBe(snapshot.tokenExpiresAt);
// Another process, holding the stale snapshot, one tick before the backoff ends: the locked row decides, and no token call is made.
const backedOff = ctxFor(other, t0 + MANAGED_CREDENTIAL_REFRESH_BACKOFF_MS - 1);
await expect(refreshManagedCredential(backedOff, snapshot)).rejects.toBeInstanceOf(ManagedCredentialRefreshFailed);
expect(endpoint.calls).toBe(1);
// Being turned away does not extend the backoff.
expect((await rig.row()).refreshFailedAt).toBe(marked.refreshFailedAt);
expect(await rig.audit()).toEqual([]);
}
);
it("cross-process failed-refresh marker: the backoff ends exactly at its length, a success clears the marker, and a failure after it marks anew", timeout, async () => {
const rig = await makeRig(provider);
const endpoint = installTokenEndpoint(provider, "refresh-0");
endpoint.mode = "server_error";
const t0 = Date.now();
const ctxAt = (at: number) => ({ config: rig.config, store: rig.stores[0]!, tokenCustody: rig.custody, oauthRuntimes: createOAuthRuntimeRegistry(), clock: () => at });
await expect(refreshManagedCredential(ctxAt(t0), await rig.row())).rejects.toBeInstanceOf(ManagedCredentialRefreshFailed);
const retryAt = t0 + MANAGED_CREDENTIAL_REFRESH_BACKOFF_MS;
await expect(refreshManagedCredential(ctxAt(retryAt), await rig.row())).rejects.toBeInstanceOf(ManagedCredentialRefreshFailed);
expect(endpoint.calls).toBe(2);
expect((await rig.row()).refreshFailedAt).toBe(new Date(retryAt).toISOString());
endpoint.mode = "normal";
const laterAt = retryAt + MANAGED_CREDENTIAL_REFRESH_BACKOFF_MS;
const outcome = await refreshManagedCredential(ctxAt(laterAt), await rig.row());
expect(outcome.kind).toBe("current");
expect(endpoint.calls).toBe(3);
expect(rig.custody.decrypt(outcome.identity.accessTokenEncrypted!)).toBe("access-1");
expect((await rig.row()).refreshFailedAt).toBeUndefined();
});
it("cross-process failed-refresh marker: a revocation and a re-link both clear it", timeout, async () => {
const rig = await makeRig(provider);
const endpoint = installTokenEndpoint(provider, "refresh-0");
endpoint.mode = "server_error";
const t0 = Date.now();
const ctxAt = (at: number) => ({ config: rig.config, store: rig.stores[0]!, tokenCustody: rig.custody, oauthRuntimes: createOAuthRuntimeRegistry(), clock: () => at });
await expect(refreshManagedCredential(ctxAt(t0), await rig.row())).rejects.toBeInstanceOf(ManagedCredentialRefreshFailed);
expect((await rig.row()).refreshFailedAt).toBeDefined();
endpoint.mode = "invalid_grant";
const revoked = await refreshManagedCredential(ctxAt(t0 + MANAGED_CREDENTIAL_REFRESH_BACKOFF_MS), await rig.row());
expect(revoked.kind).toBe("revoked");
expect((await rig.row()).refreshFailedAt).toBeUndefined();
// A fresh consent after a failure replaces the whole credential, marker included.
endpoint.mode = "server_error";
const relinkSeed = await rig.row();
await rig.stores[0]!.upsertLinkedIdentity({
accountId: rig.accountId, provider, providerUserId: relinkSeed.providerUserId, scopes: ["identity:read"], profileJson: {},
accessTokenEncrypted: rig.custody.encrypt("access-r"), refreshTokenEncrypted: rig.custody.encrypt("refresh-r"),
tokenExpiresAt: new Date(t0 + 1000).toISOString(), createdAt: relinkSeed.createdAt, updatedAt: new Date(t0).toISOString(),
});
await expect(refreshManagedCredential(ctxAt(t0 + 2000), await rig.row())).rejects.toBeInstanceOf(ManagedCredentialRefreshFailed);
expect((await rig.row()).refreshFailedAt).toBeDefined();
await rig.stores[0]!.upsertLinkedIdentity({
accountId: rig.accountId, provider, providerUserId: relinkSeed.providerUserId, scopes: ["identity:read"], profileJson: {},
accessTokenEncrypted: rig.custody.encrypt("access-s"), refreshTokenEncrypted: rig.custody.encrypt("refresh-s"),
tokenExpiresAt: new Date(t0 + 3000).toISOString(), createdAt: relinkSeed.createdAt, updatedAt: new Date(t0 + 2500).toISOString(),
});
expect((await rig.row()).refreshFailedAt).toBeUndefined();
});
it("cross-process failed-refresh marker: stamped with the time of the failure, so a slow failure does not eat the backoff", timeout, async () => {
const rig = await makeRig(provider);
const endpoint = installTokenEndpoint(provider, "refresh-0");
endpoint.mode = "server_error";
const t0 = Date.now();
// The provider call takes 9 s of the clock's time: the failure is known at t0 + 9 s, not at t0.
const slowClock = () => t0 + (endpoint.calls > 0 ? 9_000 : 0);
const ctx = (store: HeimdallStore, clock: () => number) => ({ config: rig.config, store, tokenCustody: rig.custody, oauthRuntimes: createOAuthRuntimeRegistry(), clock });
const snapshot = await rig.row();
await expect(refreshManagedCredential(ctx(rig.stores[0]!, slowClock), snapshot)).rejects.toBeInstanceOf(ManagedCredentialRefreshFailed);
expect((await rig.row()).refreshFailedAt).toBe(new Date(t0 + 9_000).toISOString());
// 51 s after the failure, 60 s after the attempt began: still inside the backoff.
const other = rig.stores[rig.stores.length - 1]!;
await expect(refreshManagedCredential(ctx(other, () => t0 + MANAGED_CREDENTIAL_REFRESH_BACKOFF_MS), snapshot)).rejects.toBeInstanceOf(ManagedCredentialRefreshFailed);
expect(endpoint.calls).toBe(1);
});
it("cross-process failed-refresh marker: a failure after the provider answered still leaves the marker (the provider may have consumed the token)", timeout, async () => {
const rig = await makeRig(provider);
const endpoint = installTokenEndpoint(provider, "refresh-0");
const snapshot = await rig.row();
const throwingCustody = {
...rig.custody,
encrypt: (plaintext: string) => {
if (plaintext === "access-1") throw new Error("CANARY custody encrypt failed");
return rig.custody.encrypt(plaintext);
},
};
const ctx = (store: HeimdallStore, tokenCustody: typeof rig.custody) => ({ config: rig.config, store, tokenCustody, oauthRuntimes: createOAuthRuntimeRegistry() });
const failure = await refreshManagedCredential(ctx(rig.stores[0]!, throwingCustody), snapshot).then(() => undefined, (error: unknown) => error);
expect(failure).toBeInstanceOf(ManagedCredentialRefreshFailed);
expect(String(failure) + JSON.stringify(failure)).not.toContain("CANARY");
expect(endpoint.calls).toBe(1);
const marked = await rig.row();
expect(marked.refreshFailedAt).toBeDefined();
expect(marked.refreshTokenEncrypted).toBe(snapshot.refreshTokenEncrypted);
// Another process inside the backoff does not present the old token again.
await expect(refreshManagedCredential(ctx(rig.stores[rig.stores.length - 1]!, rig.custody), snapshot)).rejects.toBeInstanceOf(ManagedCredentialRefreshFailed);
expect(endpoint.calls).toBe(1);
});
it.skipIf(!live)("cross-process failed-refresh marker: a rotation the database refuses to write still leaves the marker", timeout, async () => {
const rig = await makeRig(provider);
const endpoint = installTokenEndpoint(provider, "refresh-0");
const snapshot = await rig.row();
const db = new Pool({ connectionString: rig.databaseUrl!, max: 1 });
db.on("error", () => undefined);
try {
await db.query(`CREATE FUNCTION refuse_rotation() RETURNS trigger AS $$ BEGIN RAISE EXCEPTION 'CANARY rotation write refused'; END $$ LANGUAGE plpgsql`);
await db.query(`CREATE TRIGGER refuse_rotation BEFORE UPDATE ON linked_identities FOR EACH ROW WHEN (NEW.access_token_encrypted IS DISTINCT FROM OLD.access_token_encrypted) EXECUTE FUNCTION refuse_rotation()`);
const ctx = (store: HeimdallStore) => ({ config: rig.config, store, tokenCustody: rig.custody, oauthRuntimes: createOAuthRuntimeRegistry() });
await expect(refreshManagedCredential(ctx(rig.stores[0]!), snapshot)).rejects.toThrow();
expect(endpoint.calls).toBe(1);
const marked = await rig.row();
expect(marked.refreshFailedAt).toBeDefined();
if (providerCatalog[provider].rotatesRefreshTokens) {
// The provider consumed the token: nothing replayable is left on the row, so the backoff ending presents nothing.
expect(marked.refreshTokenEncrypted).toBeUndefined();
await expect(refreshManagedCredential(ctx(rig.stores[rig.stores.length - 1]!), snapshot)).resolves.toBeDefined();
const afterBackoff = { ...ctx(rig.stores[0]!), clock: () => Date.now() + 5 * 60_000 };
await expect(refreshManagedCredential(afterBackoff, snapshot)).resolves.toBeDefined();
expect(endpoint.calls).toBe(1);
} else {
// A non-rotating provider keeps the token valid: a transient write failure must not strand the link.
expect(marked.refreshTokenEncrypted).toBe(snapshot.refreshTokenEncrypted);
await expect(refreshManagedCredential(ctx(rig.stores[rig.stores.length - 1]!), snapshot)).rejects.toBeInstanceOf(ManagedCredentialRefreshFailed);
expect(endpoint.calls).toBe(1);
await db.query("DROP TRIGGER refuse_rotation ON linked_identities");
// The fake endpoint rotates on every refresh; Twitch does not, so its answer left refresh-0 valid.
endpoint.live = "refresh-0";
const afterBackoff = { ...ctx(rig.stores[0]!), clock: () => Date.now() + 5 * 60_000 };
const outcome = await refreshManagedCredential(afterBackoff, snapshot);
expect(outcome.kind).toBe("current");
expect(endpoint.calls).toBe(2);
}
} finally {
await db.end();
}
});
it("cross-process failed-refresh marker: a writer's clock skewed by under the backoff still holds the reader off, and by the backoff or more does not", timeout, async () => {
const rig = await makeRig(provider);
const endpoint = installTokenEndpoint(provider, "refresh-0");
endpoint.mode = "server_error";
const t0 = Date.now();
const ctxAt = (at: number) => ({ config: rig.config, store: rig.stores[0]!, tokenCustody: rig.custody, oauthRuntimes: createOAuthRuntimeRegistry(), clock: () => at });
await expect(refreshManagedCredential(ctxAt(t0), await rig.row())).rejects.toBeInstanceOf(ManagedCredentialRefreshFailed);
expect(endpoint.calls).toBe(1);
// The marker is in this reader's future: a writer ahead of it. A bounded wait, not a refusal for as long as the skew.
await expect(refreshManagedCredential(ctxAt(t0 - MANAGED_CREDENTIAL_REFRESH_BACKOFF_MS + 1), await rig.row())).rejects.toBeInstanceOf(ManagedCredentialRefreshFailed);
expect(endpoint.calls).toBe(1);
await expect(refreshManagedCredential(ctxAt(t0 - MANAGED_CREDENTIAL_REFRESH_BACKOFF_MS), await rig.row())).rejects.toBeInstanceOf(ManagedCredentialRefreshFailed);
expect(endpoint.calls).toBe(2);
expect(endpoint.calls).toBe(rig.stores.length + 1);
});
it("a snapshot taken before another caller's refresh does not decide: the locked row does", timeout, async () => {
@ -680,38 +486,6 @@ for (const { kind, live } of rigKinds) describe.skipIf(live && !adminUrl)(`manag
{ eventType: "managed_credential_revoked", payload: { provider, reason: "rotation_missing" } },
]);
});
it.skipIf(!live).each(["no_refresh_token", "empty_refresh_token"] as const)(
"rotation-fails-closed: when the revoke the provider's answer called for cannot be written (%s), the marker is still left",
timeout,
async (mode) => {
const rig = await makeRig(provider);
const endpoint = installTokenEndpoint(provider, "refresh-0");
endpoint.mode = mode;
const snapshot = await rig.row();
const db = new Pool({ connectionString: rig.databaseUrl!, max: 1 });
db.on("error", () => undefined);
try {
await db.query(`CREATE FUNCTION refuse_revoke() RETURNS trigger AS $$ BEGIN RAISE EXCEPTION 'CANARY revoke write refused'; END $$ LANGUAGE plpgsql`);
await db.query(`CREATE TRIGGER refuse_revoke BEFORE UPDATE ON linked_identities FOR EACH ROW WHEN (NEW.access_token_encrypted IS DISTINCT FROM OLD.access_token_encrypted) EXECUTE FUNCTION refuse_revoke()`);
const ctx = (store: HeimdallStore) => ({ config: rig.config, store, tokenCustody: rig.custody, oauthRuntimes: createOAuthRuntimeRegistry() });
await expect(refreshManagedCredential(ctx(rig.stores[0]!), snapshot)).rejects.toThrow();
expect(endpoint.calls).toBe(1);
const marked = await rig.row();
expect(marked.refreshFailedAt).toBeDefined();
// The provider consumed the token: nothing replayable is left on the row, so the backoff ending presents nothing.
expect(marked.refreshTokenEncrypted).toBeUndefined();
await expect(refreshManagedCredential(ctx(rig.stores[rig.stores.length - 1]!), snapshot)).resolves.toBeDefined();
const afterBackoff = { ...ctx(rig.stores[0]!), clock: () => Date.now() + 5 * 60_000 };
await expect(refreshManagedCredential(afterBackoff, snapshot)).resolves.toBeDefined();
expect(endpoint.calls).toBe(1);
} finally {
await db.end();
}
}
);
} else {
it.each(["no_refresh_token", "empty_refresh_token"] as const)("a response with %s keeps the old one for a non-rotating provider", timeout, async (mode) => {
const rig = await makeRig(provider);
@ -757,7 +531,6 @@ for (const { kind, live } of rigKinds) describe.skipIf(live && !adminUrl)(`manag
tokenExpiresAt: past,
scopes: locked.scopes,
updatedAt: past,
consumedRefreshToken: false,
}));
expect((await rig.resolve()).statusCode).toBe(200);
expect(endpoint.calls).toBe(2);
@ -806,13 +579,11 @@ for (const { kind, live } of rigKinds) describe.skipIf(live && !adminUrl)(`manag
async function flow(rig: Rig, provider: "joystick" | "twitch" | "youtube", mode: "connect" | "sign_in") {
const app = rig.apps[0]!;
const start = await app.inject({ method: "POST", url: `/v1/oauth/${provider}/start`, headers: mode === "connect" ? { "x-heimdall-app-secret": "streampixels-secret" } : {}, payload: { appSlug: "streampixels", mode, returnTo, ...(mode === "connect" ? { connection: { kind: "creator", targetId: "creator-1" } } : {}) } });
const start = await app.inject({ method: "POST", url: `/v1/oauth/${provider}/start`, payload: { appSlug: "streampixels", mode, returnTo, ...(mode === "connect" ? { connection: { kind: "creator", targetId: "creator-1" } } : {}) } });
expect(start.statusCode).toBe(201);
const flow = await launchFlow(app, start.json());
const callback = await app.inject({
method: "GET",
url: `/v1/oauth/${provider}/callback?code=c&state=${encodeURIComponent(flow.state)}`,
headers: { cookie: flow.cookie },
url: `/v1/oauth/${provider}/callback?code=c&state=${encodeURIComponent(start.json().stateToken as string)}`,
});
expect(callback.statusCode).toBe(201);
const redeem = await app.inject({
@ -859,15 +630,12 @@ for (const { kind, live } of rigKinds) describe.skipIf(live && !adminUrl)(`manag
const start = await app.inject({
method: "POST",
url: "/v1/oauth/joystick/start",
headers: { "x-heimdall-app-secret": "streampixels-secret" },
payload: { appSlug: "streampixels", mode: "connect", returnTo, connection: { kind: "creator", targetId: "creator-1" } },
});
expect(start.statusCode).toBe(201);
const flow = await launchFlow(app, start.json());
const callback = await app.inject({
method: "GET",
url: `/v1/oauth/joystick/callback?code=c&state=${encodeURIComponent(flow.state)}`,
headers: { cookie: flow.cookie },
url: `/v1/oauth/joystick/callback?code=c&state=${encodeURIComponent(start.json().stateToken as string)}`,
});
expect(callback.statusCode).toBeGreaterThanOrEqual(400);
expect(callback.body).not.toContain("tok-access");
@ -937,7 +705,6 @@ for (const { kind, live } of rigKinds) describe.skipIf(live && !adminUrl)(`manag
tokenExpiresAt: "2999-01-01T00:00:00.000Z",
scopes: ["a", "b"],
updatedAt: "2999-01-02T00:00:00.000Z",
consumedRefreshToken: false,
}));
expect(rotated).toEqual({
...before,
@ -1021,7 +788,6 @@ for (const { kind, live } of rigKinds) describe.skipIf(live && !adminUrl)(`manag
tokenExpiresAt: new Date(Date.now() + 3600_000).toISOString(),
scopes: locked.scopes,
updatedAt: new Date().toISOString(),
consumedRefreshToken: false,
};
})
)
@ -1300,7 +1066,6 @@ describe("refresh answer classification", () => {
tokenExpiresAt: new Date().toISOString(),
scopes: locked.scopes,
updatedAt: new Date().toISOString(),
consumedRefreshToken: false,
})))!;
const calls = answer(JSON.stringify({ access_token: "access-1", refresh_token: "refresh-1", ...extra }), 200);
let skew = 0;
@ -1333,7 +1098,6 @@ describe("refresh answer classification", () => {
tokenExpiresAt: dueAt,
scopes: ["s"],
updatedAt: dueAt,
consumedRefreshToken: false,
}));
const calls = answer(JSON.stringify({ access_token: "access-2", expires_in: 3600 }), 200);
await refreshManagedCredential(ctxFor(store), snapshot);

View file

@ -1,158 +0,0 @@
import { expect } from "vitest";
import { type FastifyInstance } from "fastify";
import { type HeimdallConfig } from "../src/config.js";
import { entitlementFacts } from "../src/facts.js";
import { type OAuthProviderRuntime } from "../src/oauth.js";
import { decodeJwt } from "../src/signing.js";
export function createTestConfig(): HeimdallConfig {
return {
serviceName: "heimdall",
host: "127.0.0.1",
port: 4100,
workspaceRoot: "E:/Projects/Heimdall",
dataRoot: "E:/Projects/Heimdall/.heimdall-data",
cultCachePath: "E:/Projects/Heimdall/.heimdall-data/cultcache/heimdall.service.cc",
publicBaseUrl: "https://heimdall.gamecult.org",
issuer: "https://heimdall.gamecult.org",
daemonId: "yggdrasil-heimdall",
idunnRudpHealth: undefined,
idunnHealthContract: "heimdall.cultnet-rudp-provider-health",
providerHealthIdentityPath: "E:/Projects/Heimdall/.heimdall-data/provider-health-identity.cc",
sessionTtlSeconds: 3600,
refreshTtlSeconds: 60 * 60 * 24 * 30,
stateTtlSeconds: 600,
completionTtlSeconds: 300,
bootstrapSigningPrivateKeyOnMissing: false,
tokenEncryptionKeyBase64: Buffer.alloc(32, 7).toString("base64"),
appSharedSecrets: {
streampixels: "streampixels-secret",
repixelizer: "repixelizer-secret",
bifrost: "bifrost-secret",
},
appBackendCallbacks: {
bifrost: ["https://bifrost.gamecult.org/auth/heimdall/callback"],
repixelizer: ["https://repixelizer.gamecult.org/api/auth/heimdall/callback"],
},
storage: {
backend: "memory",
applySchemaOnStartup: true,
},
providers: {
discord: { clientId: "discord-client", clientSecret: "discord-secret" },
patreon: { clientId: "patreon-client", clientSecret: "patreon-secret" },
github: { clientId: "github-client", clientSecret: "github-secret" },
twitch: { clientId: "twitch-client", clientSecret: "twitch-secret" },
youtube: { clientId: "youtube-client", clientSecret: "youtube-secret" },
spotify: { clientId: "spotify-client", clientSecret: "spotify-secret" },
joystick: { clientId: "joystick-client", clientSecret: "joystick-secret" },
google: { clientId: "google-client", clientSecret: "google-secret" },
},
};
}
export function createMockDiscordRuntime(): OAuthProviderRuntime {
return {
async exchangeAuthorizationCode() {
return {
accessToken: "discord-access-token",
refreshToken: "discord-refresh-token",
tokenType: "Bearer",
scope: ["identify", "guilds.members.read"],
expiresAt: "2026-04-26T13:00:00.000Z",
raw: { source: "test" },
};
},
async resolveIdentity() {
return {
provider: "discord",
providerUserId: "discord-user-123",
username: "meta",
displayName: "Meta",
primaryEmail: "meta@gamecult.org",
profile: { id: "discord-user-123", username: "meta" },
};
},
async evaluateEntitlements({ callback }) {
return {
facts: [entitlementFacts.appAccess],
snapshots: [
{
accountId: callback.accountId,
provider: "discord",
scope: "repixelizer:discord_role_access:gamecult-guild",
evaluatedAt: "2026-04-26T12:00:00.000Z",
isAllowed: true,
reasonCode: "matched_role",
reasonDetail: "Matched Repixelizer access role.",
rawSummaryJson: {
guildId: "gamecult-guild",
matchedRoles: ["role-repixelizer"],
},
},
],
};
},
};
}
/** What a start returns that the browser needs: the launch URL, whose query carries the signed state. */
export interface StartBody {
authorizationUrl: string;
}
export function stateOf(start: StartBody): string {
return new URL(start.authorizationUrl).searchParams.get("state") ?? "";
}
export interface LaunchedFlow {
state: string;
/** The `name=value` pair a browser sends back to the callback. */
cookie: string;
}
export function setCookieOf(response: { headers: Record<string, unknown> }): string[] {
return [response.headers["set-cookie"]].flat().filter((value): value is string => typeof value === "string");
}
async function followLaunch(app: FastifyInstance, state: string): Promise<LaunchedFlow & { providerUrl: URL }> {
const provider = decodeJwt(state).payload.provider as string;
let response = await app.inject({ method: "GET", url: `/v1/oauth/${provider}/launch?state=${encodeURIComponent(state)}` });
if (response.statusCode === 200) {
// Heimdall's ask (managed custody): the person says Continue, with the cookie the ask set.
const asked = setCookieOf(response).find((value) => value.startsWith("__Host-heimdall-ask-"));
expect(asked, "the ask sets its own cookie").toBeDefined();
response = await app.inject({
method: "POST",
url: `/v1/oauth/${provider}/consent`,
headers: { "content-type": "application/x-www-form-urlencoded", cookie: (asked as string).split(";")[0] as string },
payload: new URLSearchParams({ state, decision: "continue" }).toString(),
});
}
expect(response.statusCode, "launch").toBe(302);
const set = setCookieOf(response).find((value) => value.startsWith("__Host-heimdall-oauth-"));
expect(set, "launch sets the binding cookie").toBeDefined();
return { state, cookie: (set as string).split(";")[0] as string, providerUrl: new URL(response.headers.location as string) };
}
/** What a browser does with a state it was handed: open the launch hop for the state's provider and keep the cookie it sets. */
export async function launchState(app: FastifyInstance, state: string): Promise<LaunchedFlow> {
const { providerUrl: _providerUrl, ...flow } = await followLaunch(app, state);
return flow;
}
/** Drives what a user's browser does after a start: follow authorizationUrl to the launch hop and keep its cookie. */
export function launchFlow(app: FastifyInstance, start: StartBody): Promise<LaunchedFlow> {
return launchState(app, stateOf(start));
}
/** The launch hop's answer in full: the cookie the browser keeps and the provider URL it is redirected to. */
export async function launchWithRedirect(app: FastifyInstance, start: StartBody): Promise<LaunchedFlow & { url: URL }> {
const { providerUrl, ...flow } = await followLaunch(app, stateOf(start));
return { ...flow, url: providerUrl };
}
/** The provider URL a browser is redirected to once it has followed authorizationUrl through the launch hop. */
export async function providerRedirect(app: FastifyInstance, start: StartBody): Promise<URL> {
return (await followLaunch(app, stateOf(start))).providerUrl;
}

View file

@ -1,549 +0,0 @@
import { generateKeyPairSync, randomUUID } from "node:crypto";
import { afterEach, describe, expect, it, vi } from "vitest";
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 { createMockDiscordRuntime, createTestConfig, launchFlow, setCookieOf, stateOf } from "./oauth-flow.js";
// Heimdall's OAuth states complete once, only in the browser that launched
// them (finding state-replay). Soul's attack: a Bifrost state is readable in
// the URL the victim's browser is sent to, so an attacker who sees it opens it
// in their own browser, finishes the provider login as themselves, and hands
// the victim the resulting completion code.
const apps: FastifyInstance[] = [];
afterEach(async () => {
vi.useRealTimers();
while (apps.length) {
await apps.pop()?.close();
}
});
const victimAttempt = "VICTIM-ATTEMPT-NONCE";
const bifrostReturn = `https://bifrost.gamecult.org/auth/heimdall/complete?attempt=${victimAttempt}`;
async function newApp() {
const app = await buildApp({ config: createTestConfig(), oauthRuntimes: { discord: createMockDiscordRuntime() } });
apps.push(app);
return app;
}
async function bifrostStart(app: FastifyInstance, extra: Record<string, unknown> = {}) {
const response = await app.inject({
method: "POST",
url: "/v1/oauth/discord/start",
headers: { "x-heimdall-app-secret": "bifrost-secret" },
payload: { appSlug: "bifrost", mode: "sign_in", returnTo: bifrostReturn, handoff: { kind: "browser_completion" }, ...extra },
});
expect(response.statusCode).toBe(201);
return response.json() as { authorizationUrl: string };
}
function launchUrl(start: { authorizationUrl: string }): string {
const url = new URL(start.authorizationUrl);
return `${url.pathname}${url.search}`;
}
function callback(app: FastifyInstance, state: string, cookie?: string, query = "code=attacker-code") {
return app.inject({
method: "GET",
url: `/v1/oauth/discord/callback?${query}&state=${encodeURIComponent(state)}`,
...(cookie ? { headers: { cookie } } : {}),
});
}
describe("OAuth state binding", () => {
it("Soul's replay fails: one state, at most one completion code, and only for the browser that launched it", async () => {
const app = await newApp();
const start = await bifrostStart(app);
const state = stateOf(start);
const victim = await launchFlow(app, start);
// The attacker holds the same state. A second launch yields no cookie and no redirect.
const attackerLaunch = await app.inject({ method: "GET", url: launchUrl(start) });
expect(attackerLaunch.statusCode).toBe(400);
expect(attackerLaunch.json()).toEqual({ error: "state_already_launched" });
expect(setCookieOf(attackerLaunch)).toEqual([]);
expect(attackerLaunch.headers.location).toBeUndefined();
// Without the cookie the callback refuses and mints nothing.
const attackerCallback = await callback(app, state);
expect(attackerCallback.statusCode).toBe(400);
expect(attackerCallback.json()).toEqual({ error: "state_not_bound" });
expect(attackerCallback.body).not.toContain("completion");
// The victim's callback still completes, once.
const victimCallback = await callback(app, state, victim.cookie, "code=victim-code");
expect(victimCallback.statusCode).toBe(201);
const completionCode = victimCallback.json().completion.code as string;
const redeem = await app.inject({
method: "POST",
url: "/v1/apps/bifrost/auth-completions/redeem",
payload: { completionCode },
});
expect(redeem.statusCode).toBe(201);
expect(redeem.json()).toEqual(expect.objectContaining({ status: "success", appSlug: "bifrost" }));
const second = await callback(app, state, victim.cookie, "code=victim-code");
expect(second.statusCode).toBe(400);
expect(second.json()).toEqual({ error: "state_already_used" });
expect(second.body).not.toContain("completion");
});
it("a stranger's callback needs no launch to be refused: no cookie, no completion code", async () => {
const app = await newApp();
const start = await bifrostStart(app);
const response = await callback(app, stateOf(start));
expect(response.statusCode).toBe(400);
expect(response.json()).toEqual({ error: "state_not_bound" });
expect(response.body).not.toContain("completion");
});
it("a cookie for another flow, another key, another type, or the wrong lifetime does not bind", async () => {
const app = await newApp();
const ctx = getHeimdallRuntimeContext(app);
const own = await bifrostStart(app);
const attackerFlow = await app.inject({
method: "POST",
url: "/v1/oauth/discord/start",
payload: { appSlug: "repixelizer", mode: "sign_in", returnTo: "https://repixelizer.gamecult.org/app/" },
});
const attackerStart = attackerFlow.json() as { authorizationUrl: string };
const attackerCookie = (await launchFlow(app, attackerStart)).cookie;
const ownState = stateOf(own);
const ownJti = decodeJwt(ownState).payload.jti as string;
const name = `__Host-heimdall-oauth-${ownJti}`;
const now = Math.floor(Date.now() / 1000);
const binding = (overrides: Record<string, unknown>, keys = ctx.keys) =>
signJwt({ typ: "heimdall_oauth_binding", iss: ctx.config.issuer, jti: ownJti, iat: now, exp: now + 300, ...overrides }, keys);
const foreignKeys = (() => {
const pair = generateKeyPairSync("ed25519");
return { ...ctx.keys, privateKey: pair.privateKey, publicKey: pair.publicKey };
})();
const forged: Record<string, string> = {
"another flow's binding under this flow's cookie name": `${name}=${attackerCookie.split("=")[1]}`,
"a binding signed with a different key": `${name}=${binding({}, foreignKeys)}`,
"a binding for a different jti": `${name}=${binding({ jti: "someone-else" })}`,
"a binding of another type": `${name}=${binding({ typ: "heimdall_oauth_state" })}`,
"a binding from another issuer": `${name}=${binding({ iss: "https://elsewhere.example" })}`,
"an expired binding": `${name}=${binding({ exp: now - 5 })}`,
"the state token used as the cookie": `${name}=${ownState}`,
"a binding whose exp is a string": `${name}=${binding({ exp: String(now + 300) })}`,
"an empty cookie": `${name}=`,
};
for (const [label, cookie] of Object.entries(forged)) {
const response = await callback(app, ownState, cookie);
expect(response.statusCode, label).toBe(400);
expect(response.json(), label).toEqual({ error: "state_not_bound" });
}
// A correctly signed binding for this jti is the one thing that binds.
const good = await callback(app, ownState, `${name}=${binding({})}`);
expect(good.statusCode).toBe(201);
});
it("the cookie is checked before the state is consumed: a stranger cannot burn or deny the victim's flow", async () => {
const app = await newApp();
const ctx = getHeimdallRuntimeContext(app);
const attempt = await ctx.store.createAuthAttempt({
handle: "attempt-1",
appSlug: "repixelizer",
provider: "discord",
mode: "sign_in",
returnTo: "https://repixelizer.gamecult.org/app/",
createdAt: new Date().toISOString(),
expiresAt: new Date(Date.now() + 600_000).toISOString(),
});
const caller = resolveAppCaller(ctx.config, "repixelizer", { "x-heimdall-app-secret": "repixelizer-secret" });
const started = await startOAuthFlow(
ctx,
"discord",
caller,
{ appSlug: "repixelizer", mode: "sign_in", returnTo: "https://repixelizer.gamecult.org/app/", handoff: { kind: "browser_completion" } },
{ trustedBrowserAttemptId: attempt.handle }
);
expect(started.statusCode).toBe(201);
const start = started.body as unknown as { authorizationUrl: string };
const victim = await launchFlow(app, start);
// A cookieless provider error, which would deny the attempt, and a cookieless code: both refused.
for (const query of ["error=access_denied", "code=attacker-code", ""]) {
const stranger = await callback(app, victim.state, undefined, query);
expect(stranger.statusCode, query).toBe(400);
expect(stranger.json(), query).toEqual({ error: "state_not_bound" });
}
// The state is still unclaimed, so the launcher's cookie still completes it...
const response = await callback(app, victim.state, victim.cookie, "code=victim-code");
expect(response.statusCode).toBe(201);
});
it("a callback with no binding denies the attempt its state names, once, and never a completed one", async () => {
const app = await newApp();
const ctx = getHeimdallRuntimeContext(app);
const make = async (handle: string) => {
await ctx.store.createAuthAttempt({
handle,
appSlug: "repixelizer",
provider: "discord",
mode: "sign_in",
returnTo: "https://repixelizer.gamecult.org/app/",
createdAt: new Date().toISOString(),
expiresAt: new Date(Date.now() + 600_000).toISOString(),
});
const caller = resolveAppCaller(ctx.config, "repixelizer", { "x-heimdall-app-secret": "repixelizer-secret" });
const started = await startOAuthFlow(
ctx,
"discord",
caller,
{ appSlug: "repixelizer", mode: "sign_in", returnTo: "https://repixelizer.gamecult.org/app/", handoff: { kind: "browser_completion" } },
{ trustedBrowserAttemptId: handle }
);
return started.body as unknown as { authorizationUrl: string };
};
// Lapsed cookie: the launcher launched, then its cookie is gone at the callback.
const lapsed = await make("lapsed");
await launchFlow(app, lapsed);
const refused = await callback(app, stateOf(lapsed), undefined, "code=c");
expect(refused.json()).toEqual({ error: "state_not_bound" });
const denied = await ctx.store.findAuthAttempt("repixelizer", "lapsed");
expect(denied?.status).toBe("denied");
expect(denied?.denialCode).toBe("state_not_bound");
// A completed attempt stays completed under a later cookieless callback.
const done = await make("done");
const launched = await launchFlow(app, done);
expect((await callback(app, launched.state, launched.cookie, "code=c")).statusCode).toBe(201);
await callback(app, launched.state, undefined, "code=c");
expect(["completed", "consumed"]).toContain((await ctx.store.findAuthAttempt("repixelizer", "done"))?.status);
});
it("a state with no jti is refused as invalid and names no attempt to deny", async () => {
const app = await newApp();
const ctx = getHeimdallRuntimeContext(app);
const original = decodeJwt(stateOf(await bifrostStart(app))).payload;
const { jti: _jti, ...rest } = original;
const token = signJwt(rest, ctx.keys);
const response = await callback(app, token, undefined, "code=c");
expect(response.statusCode).toBe(400);
expect(response.json()).toEqual({ error: "invalid_state" });
});
describe("a browser refused for its binding gets a page back to the app, not bare JSON", () => {
const html = { accept: "text/html" };
const get = (app: FastifyInstance, url: string, cookie?: string) =>
app.inject({ method: "GET", url, headers: { ...html, ...(cookie ? { cookie } : {}) } });
it("on a cookieless callback, a lapsed cookie and a reloaded launch", async () => {
const app = await newApp();
const start = await bifrostStart(app);
const state = stateOf(start);
const reloadBeforeLaunch = await get(app, `/v1/oauth/discord/callback?code=c&state=${encodeURIComponent(state)}`);
expect(reloadBeforeLaunch.statusCode).toBe(400);
expect(reloadBeforeLaunch.headers["content-type"]).toContain("text/html");
expect(reloadBeforeLaunch.body).toContain("state_not_bound");
expect(reloadBeforeLaunch.body).toContain("https://bifrost.gamecult.org/auth/heimdall/complete");
const victim = await launchFlow(app, start);
const reloaded = await get(app, launchUrl(start));
expect(reloaded.statusCode).toBe(400);
expect(reloaded.headers["content-type"]).toContain("text/html");
expect(reloaded.body).toContain("state_already_launched");
expect(setCookieOf(reloaded)).toEqual([]);
// Handoff reload: the page after a completed callback, loaded again.
expect((await get(app, `/v1/oauth/discord/callback?code=c&state=${encodeURIComponent(victim.state)}`, victim.cookie)).statusCode).toBeLessThan(300);
const reload = await get(app, `/v1/oauth/discord/callback?code=c&state=${encodeURIComponent(victim.state)}`, victim.cookie);
expect(reload.statusCode).toBe(400);
expect(reload.headers["content-type"]).toContain("text/html");
expect(reload.body).toContain("state_already_used");
});
it("never links to an origin the app does not own, and JSON stays JSON without Accept", async () => {
const app = await newApp();
const ctx = getHeimdallRuntimeContext(app);
const original = decodeJwt(stateOf(await bifrostStart(app))).payload;
const token = signJwt({ ...original, return_to: "https://evil.example/steal" }, ctx.keys);
const evil = await get(app, `/v1/oauth/discord/callback?code=c&state=${encodeURIComponent(token)}`);
expect(evil.statusCode).toBe(400);
expect(evil.json()).toEqual({ error: "state_not_bound" });
expect(evil.body).not.toContain("evil.example");
const plain = await callback(app, stateOf(await bifrostStart(app)));
expect(plain.json()).toEqual({ error: "state_not_bound" });
});
});
it("the memory store keeps a use record until the state's exp + 30 s skew", async () => {
const app = await newApp();
const ctx = getHeimdallRuntimeContext(app);
const claim = (jti: string, usedAt: number) =>
ctx.store.claimOAuthStateUse({
jti,
phase: "launch",
appSlug: "bifrost",
usedAt: new Date(usedAt).toISOString(),
expiresAt: new Date(1_000_000).toISOString(),
});
expect(await claim("a", 900_000)).toBe(true);
// Past exp but inside the skew a state still verifies, so its record must still refuse a replay.
expect(await claim("b", 1_000_000 + 29_999)).toBe(true);
expect(await claim("a", 1_000_000 + 29_999)).toBe(false);
expect(await claim("c", 1_000_000 + 30_000)).toBe(true);
expect(await claim("a", 1_000_000 + 30_000)).toBe(true);
});
it("a provider refusal from the launching browser still denies the attempt, once", async () => {
const app = await newApp();
const start = await bifrostStart(app);
const victim = await launchFlow(app, start);
const denied = await callback(app, victim.state, victim.cookie, "error=access_denied");
expect(denied.statusCode).toBe(400);
expect(denied.json().error).toBe("consent_refused");
expect(denied.json().by).toBe("provider");
const replay = await callback(app, victim.state, victim.cookie, "error=access_denied");
expect(replay.json()).toEqual({ error: "state_already_used" });
});
// The private command plane's completeAuth answers a denied attempt with
// attempt.denialCode as its error (private-command-plane.ts, completeAuth), so
// these two codes are what Bifrost, Repixelizer and Ghostlight poll for.
it.each([
["bifrost", "https://bifrost.gamecult.org/auth/heimdall/complete?attempt=a1", "access_denied", "consent_refused"],
["repixelizer", "https://repixelizer.gamecult.org/app/", "access_denied", "consent_refused"],
["repixelizer", "https://repixelizer.gamecult.org/app/", "server_error", "provider_error"],
] as const)("a %s attempt whose provider answers %s is denied with %s", async (appSlug, returnTo, providerError, denialCode) => {
const app = await newApp();
const ctx = getHeimdallRuntimeContext(app);
const attempt = await ctx.store.createAuthAttempt({
handle: `attempt-${providerError}`,
appSlug,
provider: "discord",
mode: "sign_in",
returnTo,
createdAt: new Date().toISOString(),
expiresAt: new Date(Date.now() + 600_000).toISOString(),
});
const caller = resolveAppCaller(ctx.config, appSlug, { "x-heimdall-app-secret": `${appSlug}-secret` });
const started = await startOAuthFlow(
ctx,
"discord",
caller,
{ appSlug, mode: "sign_in", returnTo, handoff: { kind: "browser_completion" } },
{ trustedBrowserAttemptId: attempt.handle }
);
const flow = await launchFlow(app, started.body as unknown as { authorizationUrl: string });
await callback(app, flow.state, flow.cookie, `error=${providerError}`);
expect(await ctx.store.findAuthAttempt(appSlug, attempt.handle)).toMatchObject({ status: "denied", denialCode });
});
describe("a state that is expired, foreign or tampered is refused at launch and at callback", () => {
it.each([
["expired beyond the skew", { exp: Math.floor(Date.now() / 1000) - 100 }, "state_expired"],
["not yet valid", { nbf: Math.floor(Date.now() / 1000) + 100 }, "state_expired"],
["another issuer", { iss: "https://elsewhere.example" }, "invalid_state"],
["another provider in aud", { aud: "patreon" }, "state_provider_mismatch"],
["another provider in provider", { provider: "patreon", aud: "patreon" }, "state_provider_mismatch"],
["a wrong type", { typ: "heimdall_refresh" }, "invalid_state"],
])("%s", async (_label, change, error) => {
const app = await newApp();
const ctx = getHeimdallRuntimeContext(app);
const start = await bifrostStart(app);
const original = decodeJwt(stateOf(start)).payload;
const token = signJwt({ ...original, ...change }, ctx.keys);
const launch = await app.inject({ method: "GET", url: `/v1/oauth/discord/launch?state=${encodeURIComponent(token)}` });
expect(launch.statusCode).toBe(400);
expect(launch.json()).toEqual({ error });
expect(setCookieOf(launch)).toEqual([]);
const cookie = `__Host-heimdall-oauth-${original.jti as string}=${signJwt({ typ: "heimdall_oauth_binding", iss: ctx.config.issuer, jti: original.jti, iat: 0, exp: 4_000_000_000 }, ctx.keys)}`;
const response = await callback(app, token, cookie);
expect(response.statusCode).toBe(400);
expect(response.json()).toEqual({ error });
expect(response.body).not.toContain(token.slice(20, 60));
});
it("a state inside the 30 s skew still works, as a refresh token does", async () => {
const app = await newApp();
const ctx = getHeimdallRuntimeContext(app);
const start = await bifrostStart(app);
const original = decodeJwt(stateOf(start)).payload;
const token = signJwt({ ...original, exp: Math.floor(Date.now() / 1000) - 10 }, ctx.keys);
const launch = await app.inject({ method: "GET", url: `/v1/oauth/discord/launch?state=${encodeURIComponent(token)}` });
expect(launch.statusCode).toBe(302);
});
it("a tampered signature, a malformed token and a provider that is not the route's", async () => {
const app = await newApp();
const start = await bifrostStart(app);
const state = stateOf(start);
const tampered = `${state.slice(0, -4)}AAAA`;
for (const token of [tampered, "not-a-jwt", "a.b"]) {
const launch = await app.inject({ method: "GET", url: `/v1/oauth/discord/launch?state=${encodeURIComponent(token)}` });
expect(launch.statusCode).toBe(400);
expect(launch.json()).toEqual({ error: "invalid_state" });
expect(launch.body).not.toContain(token);
}
const wrongRoute = await app.inject({ method: "GET", url: `/v1/oauth/patreon/launch?state=${encodeURIComponent(state)}` });
expect(wrongRoute.statusCode).toBe(400);
expect(wrongRoute.json()).toEqual({ error: "state_provider_mismatch" });
});
});
it("the launch response and the binding cookie carry the protections the binding relies on", async () => {
const app = await newApp();
const start = await bifrostStart(app, { requestedScopes: ["identify", "email"] });
const jti = decodeJwt(stateOf(start)).payload.jti as string;
const launch = await app.inject({ method: "GET", url: launchUrl(start) });
expect(launch.statusCode).toBe(302);
const cookies = setCookieOf(launch);
expect(cookies).toHaveLength(1);
const cookie = cookies[0] as string;
expect(cookie.startsWith(`__Host-heimdall-oauth-${jti}=`)).toBe(true);
const attributes = cookie.split(";").slice(1).map((part) => part.trim());
expect(attributes).toEqual(expect.arrayContaining(["Path=/", "Secure", "HttpOnly", "SameSite=Lax"]));
expect(attributes.some((attribute) => /^domain=/i.test(attribute))).toBe(false);
const maxAge = Number(attributes.find((attribute) => attribute.startsWith("Max-Age="))?.slice("Max-Age=".length));
expect(maxAge).toBeGreaterThan(0);
expect(maxAge).toBeLessThanOrEqual(600);
const location = new URL(launch.headers.location as string);
expect(location.origin + location.pathname).toBe("https://discord.com/oauth2/authorize");
expect(location.searchParams.get("state")).toBe(stateOf(start));
expect(location.searchParams.get("scope")).toBe("identify email");
expect(location.searchParams.get("client_id")).toBe("discord-client");
expect(location.searchParams.get("redirect_uri")).toBe("https://heimdall.gamecult.org/v1/oauth/discord/callback");
expect(launch.headers["cache-control"]).toBe("no-store");
expect(launch.headers["referrer-policy"]).toBe("no-referrer");
const done = await callback(app, stateOf(start), cookie.split(";")[0], "code=c");
expect(done.statusCode).toBe(201);
const cleared = setCookieOf(done).find((value) => value.startsWith(`__Host-heimdall-oauth-${jti}=`));
expect(cleared).toBeDefined();
expect(cleared).toMatch(/Max-Age=0/);
expect(cleared?.startsWith(`__Host-heimdall-oauth-${jti}=;`)).toBe(true);
});
it("a launch for a provider with no client id is 503 and burns nothing", async () => {
const config = { ...createTestConfig(), providers: { ...createTestConfig().providers } };
const app = await buildApp({ config, oauthRuntimes: { discord: createMockDiscordRuntime() } });
apps.push(app);
const start = await bifrostStart(app);
config.providers.discord = { clientId: undefined, clientSecret: undefined } as unknown as typeof config.providers.discord;
const refused = await app.inject({ method: "GET", url: launchUrl(start) });
expect(refused.statusCode).toBe(503);
expect(refused.json().error).toBe("provider_not_configured");
expect(setCookieOf(refused)).toEqual([]);
});
it("every start returns Heimdall's launch URL and no state token", async () => {
const app = await newApp();
for (const [headers, payload] of [
[{}, { appSlug: "repixelizer", mode: "sign_in", returnTo: "https://repixelizer.gamecult.org/app/" }],
[
{ "x-heimdall-app-secret": "bifrost-secret" },
{ appSlug: "bifrost", mode: "sign_in", returnTo: bifrostReturn },
],
] as const) {
const response = await app.inject({ method: "POST", url: "/v1/oauth/discord/start", headers, payload });
expect(response.statusCode).toBe(201);
const body = response.json();
expect(body.stateToken).toBeUndefined();
expect(new URL(body.authorizationUrl).origin + new URL(body.authorizationUrl).pathname).toBe(
"https://heimdall.gamecult.org/v1/oauth/discord/launch"
);
expect(body.stateExpiresAt).toEqual(expect.any(String));
}
});
it("claims the launch and the callback of a state under their own phases, until the state's expiry", async () => {
const app = await newApp();
const ctx = getHeimdallRuntimeContext(app);
const spy = vi.spyOn(ctx.store, "claimOAuthStateUse");
const start = await bifrostStart(app);
const { jti, exp } = decodeJwt(stateOf(start)).payload as { jti: string; exp: number };
const victim = await launchFlow(app, start);
expect((await callback(app, victim.state, victim.cookie, "code=victim-code")).statusCode).toBe(201);
const expiresAt = new Date(exp * 1000).toISOString();
expect(spy.mock.calls.map(([claim]) => claim)).toEqual([
{ jti, phase: "launch", appSlug: "bifrost", usedAt: expect.any(String), expiresAt },
{ jti, phase: "callback", appSlug: "bifrost", usedAt: expect.any(String), expiresAt },
]);
});
it("the launch route validates its provider and its state parameter", async () => {
const app = await newApp();
for (const url of ["/v1/oauth/discord/launch", "/v1/oauth/discord/launch?state=", "/v1/oauth/bogus/launch?state=abc"]) {
const response = await app.inject({ method: "GET", url });
expect(response.statusCode, url).toBe(400);
expect(response.json().error, url).toBe("invalid_request");
}
});
it("a state's requested scopes are null, absent, or a list of strings, and nothing else", async () => {
const app = await newApp();
const ctx = getHeimdallRuntimeContext(app);
const start = await bifrostStart(app);
const original = decodeJwt(stateOf(start)).payload;
const launchWith = async (change: Record<string, unknown>) => {
const rest = { ...original };
delete rest.requested_scopes;
const token = signJwt({ ...rest, jti: randomUUID(), ...change }, ctx.keys);
return app.inject({ method: "GET", url: `/v1/oauth/discord/launch?state=${encodeURIComponent(token)}` });
};
for (const [label, change] of [
["absent", {}],
["null", { requested_scopes: null }],
["a list", { requested_scopes: ["identify"] }],
] as const) {
expect((await launchWith(change)).statusCode, label).toBe(302);
}
for (const [label, change] of [
["a string", { requested_scopes: "identify" }],
["a list with a number", { requested_scopes: ["identify", 7] }],
["a list of numbers", { requested_scopes: [1, 2] }],
["an object", { requested_scopes: { 0: "identify" } }],
] as const) {
const response = await launchWith(change);
expect(response.statusCode, label).toBe(400);
expect(response.json(), label).toEqual({ error: "invalid_state" });
}
});
it("the time windows hold to the second: a state from nbf - 30 s to exp + 30 s, a binding until its exp", async () => {
const app = await newApp();
const ctx = getHeimdallRuntimeContext(app);
const start = await bifrostStart(app);
const original = decodeJwt(stateOf(start)).payload;
vi.useFakeTimers({ toFake: ["Date"] });
const now = 1_900_000_000;
vi.setSystemTime(now * 1000);
const stateWith = (change: Record<string, unknown>) =>
signJwt({ ...original, jti: randomUUID(), nbf: now - 1, exp: now + 600, ...change }, ctx.keys);
const launch = (token: string) =>
app.inject({ method: "GET", url: `/v1/oauth/discord/launch?state=${encodeURIComponent(token)}` });
expect((await launch(stateWith({ exp: now - 30 }))).json()).toEqual({ error: "state_expired" });
expect((await launch(stateWith({ exp: now - 29 }))).statusCode).toBe(302);
expect((await launch(stateWith({ nbf: now + 31 }))).json()).toEqual({ error: "state_expired" });
expect((await launch(stateWith({ nbf: now + 30 }))).statusCode).toBe(302);
const token = stateWith({});
const jti = decodeJwt(token).payload.jti as string;
const bindingWith = (exp: number) =>
`__Host-heimdall-oauth-${jti}=${signJwt({ typ: "heimdall_oauth_binding", iss: ctx.config.issuer, jti, iat: now - 5, exp }, ctx.keys)}`;
expect((await callback(app, token, bindingWith(now))).json()).toEqual({ error: "state_not_bound" });
expect((await callback(app, token, bindingWith(now + 1))).statusCode).toBe(201);
});
});

View file

@ -12,7 +12,6 @@ import { type HeimdallConfig } from "../src/config.js";
import { startHeimdallPrivateCommandPlane } from "../src/private-command-plane.js";
import { openPrivateEnvelope, sealPrivateEnvelope, type HeimdallPrivateEnvelope } from "../src/private-command-security.js";
import { InMemoryStore } from "../src/store/index.js";
import { launchState } from "./oauth-flow.js";
const secret = "ghostlight-private-command-secret";
const resources: Array<{ close(): Promise<unknown> }> = [];
@ -31,7 +30,7 @@ function config(): HeimdallConfig {
bootstrapSigningPrivateKeyOnMissing: false, tokenEncryptionKeyBase64: Buffer.alloc(32, 7).toString("base64"),
appSharedSecrets: { ghostlight: secret }, appRuntimeIds: { ghostlight: ["yggdrasil-ghostlight"] }, appBackendCallbacks: {},
storage: { backend: "memory", applySchemaOnStartup: true },
providers: { discord: { clientId: "discord-client", clientSecret: "discord-secret" }, patreon: {}, github: {}, twitch: {}, youtube: {}, spotify: {}, joystick: {}, google: {} },
providers: { discord: { clientId: "discord-client", clientSecret: "discord-secret" }, patreon: {}, github: {}, twitch: {}, youtube: {}, spotify: {}, joystick: {} },
} as HeimdallConfig;
}
@ -74,11 +73,10 @@ async function refreshAgainst(refreshAnswer: () => Response) {
const begin = open(await command(plane.endpoint, "heimdall.auth.begin", "heimdall.auth_begin_command.v1", "k1",
{ provider: "discord", mode: "sign_in", returnTo: "https://yggdrasil.gamecult.org/ghostlight/", entitlementPolicy: policy }));
const state = new URL(String((begin.navigation as { url: string }).url)).searchParams.get("state")!;
const { cookie } = await launchState(app, state);
const callback = await app.inject({
method: "GET",
url: `/v1/oauth/discord/callback?code=c1&state=${encodeURIComponent(state)}`,
headers: { accept: "application/json", cookie },
headers: { accept: "application/json" },
});
expect(callback.statusCode, callback.body).toBe(201);
const done = open(await command(plane.endpoint, "heimdall.auth.complete", "heimdall.auth_complete_command.v1", "k2", { handle: begin.handle }));
@ -135,11 +133,10 @@ it("keeps the completion code out of the audit trail when the plane redeems by a
const begin = open(await command(plane.endpoint, "heimdall.auth.begin", "heimdall.auth_begin_command.v1", "k1",
{ provider: "discord", mode: "sign_in", returnTo: "https://yggdrasil.gamecult.org/ghostlight/", entitlementPolicy: policy }));
const state = new URL(String((begin.navigation as { url: string }).url)).searchParams.get("state")!;
const { cookie } = await launchState(app, state);
const callback = await app.inject({
method: "GET",
url: `/v1/oauth/discord/callback?code=c1&state=${encodeURIComponent(state)}`,
headers: { accept: "application/json", cookie },
headers: { accept: "application/json" },
});
const code = (callback.json().completion as { code: string }).code;
const done = open(await command(plane.endpoint, "heimdall.auth.complete", "heimdall.auth_complete_command.v1", "k2", { handle: begin.handle }));

View file

@ -39,7 +39,6 @@ function createTestConfig(overrides: Partial<HeimdallConfig> = {}): HeimdallConf
youtube: {},
spotify: {},
joystick: {},
google: {},
},
...overrides,
};

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 } from "../src/store/index.js";
import {
createPostgresStore,
PostgresStore,
@ -14,7 +14,7 @@ import {
samePredicate,
STORE_TABLE_PRIVILEGES,
} from "../src/store/postgres.js";
import { CREATE_SCHEMA_SQL, REQUIRED_COLUMNS, REQUIRED_KEYS, REQUIRED_TABLES } from "../src/store/schema.js";
import { REQUIRED_COLUMNS, REQUIRED_KEYS, REQUIRED_TABLES } from "../src/store/schema.js";
describe("PostgresStore", () => {
it("round-trips core auth records through postgres", async () => {
@ -198,99 +198,6 @@ describe("PostgresStore", () => {
});
});
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)");
const store = new InMemoryStore();
const base = { appSlug: "streampixels" as const, provider: "joystick" as const, createdAt: "2026-11-08T00:00:00.000Z" };
const { receipt: grant } = await store.createConsentReceipt({ ...base, accountId: "a", kind: "grant", scopesAsked: [], scopesGranted: [], consentPageDigest: "d", consentText: "t" });
await expect(store.createConsentReceipt({ ...base, accountId: "b", kind: "revocation", revokesReceiptId: grant.id })).rejects.toThrow(/own account/);
await expect(store.createConsentReceipt({ ...base, accountId: "a", kind: "revocation", revokesReceiptId: "no-such" })).rejects.toThrow(/own account/);
expect(await store.listConsentReceipts("a")).toHaveLength(1);
});
});
describe("PostgresStore.createConsentReceipt revocation conflict", () => {
async function granted() {
const adapter = newDb().adapters.createPg();
const store = new PostgresStore(new adapter.Pool());
await store.ensureSchema();
const now = "2026-11-08T00:00:00.000Z";
const account = await store.createAccount({ createdAt: now, lastSeenAt: now, displayName: "Probe" });
const base = { accountId: account.id, appSlug: "streampixels" as const, provider: "joystick" as const };
const { receipt: grant } = await store.createConsentReceipt({ ...base, kind: "grant", createdAt: now, scopesAsked: ["a"], scopesGranted: ["a"], consentPageDigest: "d", consentText: "t" });
return { store, base, grant };
}
it("a second revocation of one grant inserts nothing: created:false, the first row's id", async () => {
const { store, base, grant } = await granted();
const first = await store.createConsentReceipt({ ...base, kind: "revocation", createdAt: "2026-11-08T01:00:00.000Z", revokesReceiptId: grant.id });
const second = await store.createConsentReceipt({ ...base, kind: "revocation", createdAt: "2026-11-08T02:00:00.000Z", revokesReceiptId: grant.id });
expect(first.created).toBe(true);
expect(second.created).toBe(false);
expect(second.receipt.id).toBe(first.receipt.id);
expect(second.receipt.createdAt).toBe("2026-11-08T01:00:00.000Z");
expect((await store.listConsentReceipts(base.accountId)).filter((r) => r.kind === "revocation")).toHaveLength(1);
});
it("the read-back returns the revocation of this grant, not another's or the grant", async () => {
const { store, base, grant } = await granted();
const other = (await store.createConsentReceipt({ ...base, kind: "grant", createdAt: "2026-11-08T00:30:00.000Z", scopesAsked: [], scopesGranted: [], consentPageDigest: "d2", consentText: "t2" })).receipt;
const otherRevocation = await store.createConsentReceipt({ ...base, kind: "revocation", createdAt: "2026-11-08T01:00:00.000Z", revokesReceiptId: other.id });
const first = await store.createConsentReceipt({ ...base, kind: "revocation", createdAt: "2026-11-08T01:10:00.000Z", revokesReceiptId: grant.id });
const second = await store.createConsentReceipt({ ...base, kind: "revocation", createdAt: "2026-11-08T02:00:00.000Z", revokesReceiptId: grant.id });
expect(second.receipt.id).toBe(first.receipt.id);
expect(second.receipt.id).not.toBe(otherRevocation.receipt.id);
expect(second.receipt).toMatchObject({ kind: "revocation", revokesReceiptId: grant.id });
});
});
describe("InMemoryStore.createConsentReceipt", () => {
it("writes one revocation per grant: the second names the first and is not created", async () => {
const store = new InMemoryStore();
const base = { accountId: "a", appSlug: "streampixels" as const, provider: "joystick" as const, createdAt: "2026-11-08T00:00:00.000Z" };
const { receipt: grant } = await store.createConsentReceipt({ ...base, kind: "grant", scopesAsked: [], scopesGranted: [], consentPageDigest: "d", consentText: "t" });
const first = await store.createConsentReceipt({ ...base, kind: "revocation", revokesReceiptId: grant.id });
const second = await store.createConsentReceipt({ ...base, createdAt: "2026-11-09T00:00:00.000Z", kind: "revocation", revokesReceiptId: grant.id });
expect([first.created, second.created]).toEqual([true, false]);
expect(second.receipt).toEqual(first.receipt);
expect(await store.listConsentReceipts("a")).toHaveLength(2);
});
});
describe("InMemoryStore.claimOAuthStateUse", () => {
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",
};
it("is true once per (jti, phase)", async () => {
const store = new InMemoryStore();
expect(await store.claimOAuthStateUse(use)).toBe(true);
expect(await store.claimOAuthStateUse(use)).toBe(false);
expect(await store.claimOAuthStateUse({ ...use, phase: "callback" })).toBe(true);
expect(await store.claimOAuthStateUse({ ...use, phase: "callback" })).toBe(false);
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 () => {
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" });
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" });
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" });
expect(await store.claimOAuthStateUse(use)).toBe(true);
});
});
describe("createPostgresStore", () => {
// pg emits an idle client's connection loss on the pool. With no listener
// that event kills the process, and Node prints the pg Client with its
@ -325,15 +232,12 @@ describe("PostgresStore.checkSchema", () => {
"sessions",
"auth_attempts",
"private_command_receipts",
"pending_links",
"auth_completions",
"oauth_state_uses",
"capability_grants",
"entitlement_snapshots",
"consent_receipts",
"audit_events",
]);
expect(REQUIRED_COLUMNS).toHaveLength(113);
expect(REQUIRED_COLUMNS).toHaveLength(86);
expect(REQUIRED_COLUMNS).toEqual(
expect.arrayContaining([
{ table: "auth_completions", column: "code", type: "TEXT", nullable: false },
@ -344,14 +248,11 @@ describe("PostgresStore.checkSchema", () => {
{ table: "auth_completions", column: "attempt_id", type: "TEXT", nullable: true },
])
);
expect(REQUIRED_KEYS).toHaveLength(17);
expect(REQUIRED_KEYS).toHaveLength(12);
expect(REQUIRED_KEYS).toEqual(
expect.arrayContaining([
{ table: "linked_identities", columns: "provider,provider_user_id", predicate: "" },
{ table: "private_command_receipts", columns: "app_slug,idempotency_key", predicate: "" },
{ 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: "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" },
])
@ -461,35 +362,9 @@ async function exerciseStore(store: PostgresStore): Promise<Set<string>> {
await s.consumeAuthCompletion("ghostlight", byCode.code, now);
await s.createAuthCompletion({ ...completion, attemptId: attempt.handle });
await s.consumeAuthCompletionByAttempt("ghostlight", attempt.handle, now);
const found = await s.createAuthCompletion(completion);
await s.findAuthCompletion("ghostlight", found.code);
const pending = await s.createPendingLink({
appSlug: "ghostlight",
accountId: account.id,
provider: "discord",
providerUserId: "u2",
identity: { ...identity, providerUserId: "u2" },
returnTo: "https://x.test/",
createdAt: now,
expiresAt: later,
});
await s.consumePendingLink("ghostlight", pending.id, account.id, now);
const snapshot = { accountId: account.id, provider: "discord" as const, scope: "ghostlight:probe", evaluatedAt: now, isAllowed: true, reasonCode: "ok", rawSummaryJson: {} };
await s.upsertEntitlementSnapshot(snapshot);
await s.upsertEntitlementSnapshot({ ...snapshot, isAllowed: false });
const stateUse = { jti: randomUUID(), phase: "launch" as const, appSlug: "ghostlight" as const, usedAt: now, expiresAt: later };
expect(await s.claimOAuthStateUse(stateUse)).toBe(true);
expect(await s.claimOAuthStateUse(stateUse)).toBe(false);
expect(await s.claimOAuthStateUse({ ...stateUse, phase: "callback" })).toBe(true);
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);
// The second revocation of one grant hits ON CONFLICT DO NOTHING and reads the first back.
const second = await s.createConsentReceipt(revocation);
expect(first.created).toBe(true);
expect(second.created).toBe(false);
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 });
return called;
}
@ -649,28 +524,12 @@ describe.skipIf(!adminUrl)("startup against a live Postgres (HEIMDALL_TEST_PG_AD
it("prints each declared predicate exactly as Postgres prints the applied index's", async () => {
const { owner } = await appliedDatabase();
const printed = await printedDeclaredPredicates(owner, HEIMDALL_SCHEMA_REQUIREMENTS);
const appliedByIndex = {
auth_completions_attempt_unconsumed_unique_idx: "auth_completions",
consent_receipts_revokes_unique_idx: "consent_receipts",
};
const partialKeys = REQUIRED_KEYS.flatMap((key, index) => (key.predicate === "" ? [] : [{ key, index }]));
expect(partialKeys.map(({ key }) => key.table).sort()).toEqual(Object.values(appliedByIndex).sort());
for (const [indexName, table] of Object.entries(appliedByIndex)) {
const applied = await owner.query<{ predicate: string }>(
"SELECT pg_get_expr(indpred, indrelid) AS predicate FROM pg_index WHERE indexrelid = $1::regclass",
[indexName]
);
const { index } = partialKeys.find(({ key }) => key.table === table)!;
expect(printed[index], indexName).toBe(applied.rows[0]!.predicate);
}
expect(printed.filter((predicate) => predicate !== "")).toHaveLength(partialKeys.length);
});
it("refuses to boot with consent_receipts missing when the schema is not applied on startup", async () => {
const { name, owner } = await appliedDatabase();
await owner.query("DROP TABLE consent_receipts");
expect(await start(urlFor(name), false)).toBe(refusal("SCHEMA_MISSING"));
const applied = await owner.query<{ predicate: string }>(
"SELECT pg_get_expr(indpred, indrelid) AS predicate FROM pg_index WHERE indexrelid = 'auth_completions_attempt_unconsumed_unique_idx'::regclass"
);
const attemptKey = REQUIRED_KEYS.findIndex((key) => key.predicate !== "");
expect(printed[attemptKey]).toBe(applied.rows[0]!.predicate);
expect(printed.filter((predicate) => predicate !== "")).toHaveLength(1);
});
it("refuses a table whose primary key was dropped, even after applying the schema", async () => {

View file

@ -71,7 +71,7 @@ describe("Heimdall verse runtime publication", () => {
expect(bySchema.get("idunn.daemon_health")).toEqual([
"yggdrasil-heimdall",
"active",
"Heimdall auth runtime active; storage=postgres; providers=2/8; apps=4; healthTransport=CultNet/RUDP",
"Heimdall auth runtime active; storage=postgres; providers=2/7; apps=4; healthTransport=CultNet/RUDP",
"2026-06-16T17:10:00.000Z",
"heimdall.cultnet-rudp-provider-health",
"daemon-published",

View file

@ -32,7 +32,6 @@ describe("Heimdall Verse witness advertisement", () => {
"youtube",
"spotify",
"joystick",
"google",
]);
expect(advertisement.appProfiles.map((profile) => profile.slug)).toEqual([
"repixelizer",