Compare commits

...
Sign in to create a new pull request.
Author SHA1 Message Date
2bd5096d81 Merge pull request 'Refusal return contract: pin the fragment, carry the attempt id, document it' (#7) from heimdall/refusal-return-contract into main 2026-11-08 13:51:45 +00:00
Heimdall
0ea6691cd3 Refusal return contract: pin the fragment, carry the attempt id, document it
The consent_refused page's return link is tested for heimdall_status,
heimdall_error, heimdall_refused_by and no completion code, for both a decline
and a provider access_denied. A browser_completion refusal now carries
heimdall_attempt_id. The contract for app authors is in the consent doc.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 13:50:58 +00:00
61671d7fe8 Merge pull request 'Joystick consent: ask page, consent_refused, receipts, signed-in grants page with revoke' (#5) from heimdall/joystick-consent-flow-design into main 2026-11-08 13:45:58 +00:00
Heimdall
b6372e5a4f Named revocation-conflict tests on pg-mem, first-create doc line, npm fallback in test-live-pg
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 13:45:57 +00:00
Heimdall
f7a38a7d2a Doc the FK, the cookie and scope bounds, consent_text_missing
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 13:39:34 +00:00
Heimdall
af554ca013 Addendum: per-index predicate check, live second-revocation and missing-table cases, test-live-pg prerequisites
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 13:38:35 +00:00
Heimdall
767852ef96 WIP t10: inflate cap, consent_text_missing before claim, scope caps, revocation FK
Tests green locally; mutants not yet run; not pushed; PR body not updated.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 13:37:13 +00:00
Heimdall
5ce8f1c14c Consent doc: ask cookie, carried wording, unique revocation, fixed link expiry
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 13:32:21 +00:00
Heimdall
d4f7dce098 Check the shown text before any code exchange; pin link expiry with a clock
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 13:31:43 +00:00
Heimdall
71ce9c16e4 Consent review fixes: ask-only cookie, fixed-expiry grants links, wording carried to the receipt, one revocation per grant
The ask sets its own cookie and no launch binding; Continue sets the binding
and carries the exact text shown into it; the receipt stores that text and its
hash. Grants-page form tokens keep the link's expiry. Revocations are unique
per grant and revoke reports only what it wrote. access_denied's attempt
denial code is pinned for Bifrost and Repixelizer.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 13:30:39 +00:00
Heimdall
48207268d0 Consent flow doc: steward rulings and the built design; pin repeat-revoke
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 13:24:48 +00:00
Heimdall
7ca4e2355f Joystick consent: the ask, typed refusal, receipts, and a signed-in grants page
Managed custody (callbackMint custody_only) now stops at a Heimdall page before
the provider redirect. A decline or a provider access_denied is consent_refused
(by viewer|provider), distinct from provider_error. A granted completion writes
an append-only consent receipt with the asked and granted scopes and the digest
of the wording shown. GET /v1/me/grants lists every grant and receipt of the
signed-in account; revoking one strips the credential, so the app's next token
request is refused, and writes a revocation receipt.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 13:22:36 +00:00
Heimdall
a7cdf0fea7 Draft: Joystick consent flow design and skipped test sketch
Documents what a viewer sees today on the Joystick path and what consent
is missing, then drafts a visible ask, typed refusal and auditable receipt.
Design only; the test file is a skipped sketch and was not run.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-06 12:17:14 +00:00
fefd2ddf4a Merge pull request 'Live-Postgres tests: exercise the pending-link methods; add a standing live run' (#4) from heimdall/live-pg-exercise-pending-links into main 2026-11-06 00:32:56 +00:00
Heimdall
575be9cf49 Add scripts/test-live-pg.sh: run the suite against a throwaway Postgres
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 23:30:57 +00:00
Heimdall
70fd6396f4 Exercise the pending-link and findAuthCompletion store methods in the live-Postgres privilege tests
The live suite asserts that exerciseStore calls every PostgresStore method and
that dropping any one table privilege makes it fail with 42501. createPendingLink,
consumePendingLink and findAuthCompletion landed without a call there, so the
coverage assertion failed, and with no operation touching pending_links the
privilege test saw no error when SELECT on it was revoked. Both failures were
invisible because the live files skip without HEIMDALL_TEST_PG_ADMIN_URL.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 23:30:23 +00:00
aa2edd3826 Merge pull request 'r2: bind OAuth completion to the launching browser, one use per state; gate connect starts' (#3) from heimdall/r2-state-binding into main 2026-11-05 17:16:39 +00:00
Heimdall
f1ddc2fffc Refuse an unbound or replayed state with a page back to the app, and keep use records through the skew
Browsers (Accept: text/html) refused for state_not_bound, state_already_launched
or state_already_used get the handoff page linking to return_to, re-checked
against the app's allowed origins; other callers keep the JSON code. A callback
with no binding denies the attempt its signed state names (pending only).
The memory store prunes a use record at exp + 30 s, as the state itself is
accepted that long; the schema comment says the same.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 17:16:38 +00:00
Heimdall
b541e094eb Merge heimdall-binds-completion (r2 state binding) into main's link and Google work; gate connect starts that carry a connection
Launch hop, signed per-flow cookie and one-use claims are merged with main's
link-initiator binding and Google OIDC (secrets now derived at launch).
A connect start carrying a connection target now needs the app's own secret.
Google, link and managed-credential tests go through the launch helper.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 12:46:05 +00:00
fd67d94aa3 Merge pull request 'Google OIDC provider: link-only, sub-keyed, PKCE and nonce' (#2) from heimdall/google-oidc-provider into main 2026-11-05 10:58:37 +00:00
heimdall
9123f2def0 Merge main (link-initiator binding) and reconcile the Google link with it
One initiator field (initiator_account_id) replaces link_account_id: a bearer
names it for requires_link providers, the authenticated app for the rest. One
refusal class (LinkRefusal, 403) replaces AccountLinkRefused. Provider count in
the verse test is 8.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 10:58:02 +00:00
da70416ebe Merge pull request 'Bind link mode to its initiator and redeem to its target' (#1) from heimdall/link-initiator-target-binding into main 2026-11-05 10:57:53 +00:00
Heimdall
c829429cd5 Google link is two-phase; callback evaluates all linked identities
The callback records a typed pending link (short TTL, single use) and
writes no linked identity; POST /v1/apps/:appSlug/links/confirm with the
Heimdall access token of the initiating account attaches it. A victim who
completes an attacker's link URL attaches nothing. The sign-in callback
also evaluates the account's other linked identities with the start-time
policy, so a Discord-origin member returning via Google keeps app_access.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-04 17:15:33 +00:00
Heimdall
8e870d20a0 Google OIDC provider: link-only, sub-keyed, PKCE and nonce
Google is a Heimdall provider behind Bifrost, with no direct upstream.
Scopes openid email profile; the callback verifies the id_token
(RS256 against Google's keys, iss, aud, exp, iat, nonce) and requires
a verified email. The identity key is sub, never the email.

PKCE verifier and nonce are derived from the state jti and Heimdall's
signing key, so nothing is stored between start and callback.

Google never mints an account. mode=link needs a Heimdall access token
for the app and attaches to that account only; sign_in signs in an
identity already linked; an unknown sub is refused, and a sub held by
another account is never moved. The six existing providers still mint
on first login.

Admitted to the bifrost profile only. The Idunn recipe declares the
Google client id and secret file.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-04 16:10:23 +00:00
Meta Vens
4185e657bb Tests: state time windows to the second, requested_scopes shapes, the launch route's validation, the phases each route claims
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 20:25:52 +02:00
Meta Vens
5076e04950 Docs: the launch hop, the binding cookie and the callback refusals
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 20:13:18 +02:00
Meta Vens
3def0fb426 OAuth states complete once, only in the browser that launched them: a launch hop sets a signed binding cookie, launch and callback each consume the state, and the callback checks expiry
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 20:13:17 +02:00
Meta Vens
14e0cde26c Store: claimOAuthStateUse claims one use of a signed OAuth state per (jti, phase), on both stores and an INSERT-only table
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 20:13:16 +02:00
Meta Vens
607c2b0a20 Depend on @fastify/cookie for the OAuth binding cookie
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 20:13:16 +02:00
Meta Vens
ece6ba1d62 Tests: a state completes once and only in the browser that launched it; the gate holds for every mode, provider and handoff (red against the callback that accepts any signed state)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 19:04:10 +02:00
Meta Vens
9fd152feff Tests: a foreign app's caller cannot start a Bifrost flow, with no policy attached
Kills the surviving mutant on the caller.appSlug comparison in startOAuthFlow: over HTTP a foreign secret never resolves to a caller, so only a direct call reaches that arm.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 18:24:42 +02:00
Meta Vens
ed964201d4 Docs: startRequiresAppCaller, and Heimdall authenticates while Bifrost issues identity
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 18:14:37 +02:00
Meta Vens
7e17a0e952 Start: a profile with startRequiresAppCaller refuses unauthenticated starts; set on Bifrost
Bifrost's browser binding is the secrecy of a nonce carried readable in the signed state, so only Bifrost may mint a Bifrost state. The gate sits in startOAuthFlow beside the entitlementPolicy caller check, so HTTP and the private command plane share it. Follows the red tests.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 18:14:37 +02:00
Meta Vens
6d43eacd84 Tests: apps that bind browser completion refuse unauthenticated starts (red; does not pass yet)
The replay of finding known-nonce-login-csrf at the HTTP surface, plus authenticated, cross-app and public-app cases.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 18:13:02 +02:00
41 changed files with 4947 additions and 332 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), 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, Google OIDC for linking), 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,6 +157,8 @@ 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,7 +154,11 @@ The important split:
- Heimdall owns provider OAuth, identity linking, entitlement refresh, signed
sessions, claims, grants, audit surfaces, and managed provider credential
custody
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
- 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,
@ -613,8 +617,9 @@ Only the browser-driven OAuth flow:
| Path | Why it must be public |
|---|---|
| `/v1/oauth/{provider}/start` | the user's browser goes here |
| `/v1/oauth/{provider}/callback` | the provider redirects here; one-shot and not retryable |
| `/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 |
| `/.well-known/jwks.json` | *nothing external consumes it today* — see below |
Everything else is internal by the design already written down:

View file

@ -0,0 +1,237 @@
# 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,6 +119,7 @@ Supported providers in the skeleton:
- `youtube`
- `spotify`
- `joystick`
- `google`
Request body:
@ -149,12 +150,29 @@ Response:
"appSlug": "repixelizer",
"mode": "sign_in",
"callbackUrl": "https://heimdall.gamecult.org/v1/oauth/discord/callback",
"authorizationUrl": "https://discord.com/oauth2/authorize?...",
"stateToken": "<signed-jwt>",
"authorizationUrl": "https://heimdall.gamecult.org/v1/oauth/discord/launch?state=<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
@ -168,7 +186,17 @@ 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
`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`
Launch note:
@ -179,6 +207,45 @@ 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:
@ -189,7 +256,11 @@ Purpose:
Current status:
- validates signed `state`
- 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
- returns provider errors cleanly
- for Discord, exchanges the authorization code, resolves the provider
identity, persists the local account/link/session/audit records, evaluates

View file

@ -11,7 +11,8 @@ evidence only.
## Owner Map
- Owner: Heimdall owns provider identity/linking, OAuth attempts, signed session and claim
- Owner: Heimdall owns provider identity/linking (an authenticator handle, not
GameCult identity, which is Bifrost's), 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

@ -17,6 +17,7 @@
"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,6 +12,9 @@ importers:
.:
dependencies:
'@fastify/cookie':
specifier: ^11.1.3
version: 11.1.5
'@fastify/cors':
specifier: ^11.2.0
version: 11.2.0
@ -229,6 +232,9 @@ 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==}
@ -469,6 +475,10 @@ 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'}
@ -547,6 +557,9 @@ 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==}
@ -1186,6 +1199,11 @@ 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
@ -1393,6 +1411,8 @@ 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
@ -1500,6 +1520,8 @@ snapshots:
fastify-plugin@5.1.0: {}
fastify-plugin@6.0.1: {}
fastify@5.12.7:
dependencies:
'@fastify/ajv-compiler': 4.0.5

28
scripts/test-live-pg.sh Executable file
View file

@ -0,0 +1,28 @@
#!/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,6 +45,17 @@ 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. */
@ -137,11 +148,12 @@ const streampixelsProfile: AppProfile = {
const bifrostProfile: AppProfile = {
slug: "bifrost",
displayName: "Bifrost",
profileVersion: "2026-06-08",
identityProviders: ["discord", "patreon"],
profileVersion: "2026-11-04",
identityProviders: ["discord", "patreon", "google"],
entitlementSources: ["discord", "patreon"],
managedConnectionProviders: [],
allowedReturnOrigins: ["https://bifrost.gamecult.org"],
startRequiresAppCaller: true,
capabilities: [
{
key: "member_access",

1038
src/app.ts

File diff suppressed because it is too large Load diff

View file

@ -30,6 +30,8 @@ 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 {
@ -37,9 +39,20 @@ export interface BackendHandoffErrorPayload extends BackendHandoffBase {
mode?: OAuthMode;
error: string;
errorDescription?: string;
/** Who said no, when `error` is consent_refused. */
by?: "viewer" | "provider";
}
export type BackendHandoffPayload = BackendHandoffSuccessPayload | BackendHandoffErrorPayload;
/** 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 async function deliverBackendHandoff(callbackUrl: string, payload: BackendHandoffPayload): Promise<void> {
const response = await fetch(callbackUrl, {

View file

@ -12,7 +12,11 @@ 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 {
@ -20,6 +24,8 @@ 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;
@ -45,11 +51,19 @@ 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);
}
@ -73,14 +87,19 @@ export function renderBrowserHandoffPage(payload: BrowserHandoffPayload): string
});
const scriptOrigin = serializeForScript(targetOrigin);
const scriptFallbackUrl = serializeForScript(fallbackUrl);
const scriptTitle = serializeForScript(
payload.status === "success" ? "Authentication Complete" : "Authentication Failed"
);
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 scriptBody = serializeForScript(
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."
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.`
: "")
: "The authentication flow failed. Return to the app to continue."
);
@ -89,7 +108,7 @@ export function renderBrowserHandoffPage(payload: BrowserHandoffPayload): string
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${payload.status === "success" ? "Authentication Complete" : "Authentication Failed"}</title>
<title>${pageTitle}</title>
<style>
:root {
color-scheme: light dark;

191
src/consent.ts Normal file
View file

@ -0,0 +1,191 @@
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"] as const;
export const providers = ["discord", "patreon", "github", "twitch", "youtube", "spotify", "joystick", "google"] as const;
export type Provider = (typeof providers)[number];
@ -97,6 +97,8 @@ 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 {

228
src/google-oidc.ts Normal file
View file

@ -0,0 +1,228 @@
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

@ -7,6 +7,7 @@ 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";
@ -49,13 +50,22 @@ 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 }): Promise<ResolvedIdentity>;
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>;
evaluateEntitlements(options: {
config: HeimdallConfig;
callback: OAuthCallbackContext;
@ -144,7 +154,7 @@ function normalizeScopes(scope: string | string[] | undefined): string[] {
}
export type TokenGrant =
| { grant_type: "authorization_code"; code: string; redirect_uri: string }
| { grant_type: "authorization_code"; code: string; redirect_uri: string; code_verifier?: string }
| { grant_type: "refresh_token"; refresh_token: string };
/**
@ -249,6 +259,7 @@ 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
@ -527,6 +538,31 @@ 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;
@ -945,6 +981,15 @@ 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"),
@ -986,5 +1031,6 @@ export function createOAuthRuntimeRegistry(): OAuthRuntimeRegistry {
youtube: youtubeRuntime,
spotify: spotifyRuntime,
joystick: joystickRuntime,
google: googleRuntime,
};
}

View file

@ -305,7 +305,6 @@ 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",
@ -316,7 +315,7 @@ async function beginAuth(
expiresAt: attempt.expiresAt,
navigation: {
url: authorizationUrl,
allowedOrigins: [authorizationOrigin, new URL(config.publicBaseUrl).origin],
allowedOrigins: [new URL(config.publicBaseUrl).origin],
},
},
};

View file

@ -22,6 +22,18 @@ 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> = {
@ -35,6 +47,7 @@ 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",
},
@ -48,6 +61,7 @@ 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",
},
@ -61,6 +75,7 @@ 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",
},
@ -74,6 +89,7 @@ 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",
},
@ -92,6 +108,7 @@ 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: {
@ -114,9 +131,26 @@ 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",
@ -127,27 +161,43 @@ 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 = options.requestedScopes?.length ? options.requestedScopes : descriptor.defaultScopes;
const scopes = authorizationScopes(options.provider, options.requestedScopes);
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,7 +3,12 @@ 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,
@ -21,6 +26,9 @@ 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;
}
@ -47,9 +55,13 @@ 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;
@ -430,6 +442,37 @@ 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));
@ -442,4 +485,22 @@ 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,7 +5,12 @@ 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,
@ -89,6 +94,19 @@ 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;
@ -232,6 +250,59 @@ 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,
@ -383,8 +454,11 @@ 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"],
};
@ -1075,6 +1149,61 @@ 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(
`
@ -1105,6 +1234,52 @@ 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,6 +82,19 @@ 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,6 +136,20 @@ 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,
@ -153,6 +180,34 @@ 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,
@ -214,7 +269,7 @@ export const REQUIRED_TABLES = createdTables.map(({ table }) => table);
export const REQUIRED_COLUMNS = [
...createdTables.flatMap(({ table, lines }) =>
lines
.filter((line) => !/^(?:PRIMARY KEY|UNIQUE)\b/.test(line))
.filter((line) => !/^(?:PRIMARY KEY|UNIQUE|FOREIGN KEY)\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

@ -167,6 +167,14 @@ 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
@ -185,6 +193,28 @@ 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;
@ -222,6 +252,33 @@ 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>;
@ -276,6 +333,27 @@ 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,6 +8,7 @@ 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> }> = [];
@ -107,9 +108,12 @@ 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 };
const navigation = begin.navigation as { url: string; allowedOrigins: 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",
@ -124,9 +128,11 @@ 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
@ -291,7 +297,7 @@ function testConfig(): HeimdallConfig {
storage: { backend: "memory", applySchemaOnStartup: true },
providers: {
discord: { clientId: "discord-client", clientSecret: "discord-secret" },
patreon: {}, github: {}, twitch: {}, youtube: {}, spotify: {}, joystick: {},
patreon: {}, github: {}, twitch: {}, youtube: {}, spotify: {}, joystick: {}, google: {},
},
};
}

View file

@ -4,7 +4,8 @@ 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 } from "../src/app.js";
import { buildApp, errorIdentity, getHeimdallRuntimeContext, startOAuthFlow } from "../src/app.js";
import { resolveAppCaller } from "../src/app-caller.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";
@ -12,96 +13,7 @@ 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";
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"],
},
},
],
};
},
};
}
import { createMockDiscordRuntime, createTestConfig, launchFlow, providerRedirect, stateOf, type LaunchedFlow } from "./oauth-flow.js";
const apps: Awaited<ReturnType<typeof buildApp>>[] = [];
const originalFetch = globalThis.fetch;
@ -134,7 +46,7 @@ function getHeimdallContext(app: FastifyInstance) {
}).heimdallContext;
}
async function startDiscordSignIn(app: FastifyInstance): Promise<string> {
async function startDiscordSignIn(app: FastifyInstance): Promise<LaunchedFlow> {
const response = await app.inject({
method: "POST",
url: "/v1/oauth/discord/start",
@ -146,7 +58,7 @@ async function startDiscordSignIn(app: FastifyInstance): Promise<string> {
});
expect(response.statusCode).toBe(201);
return response.json().stateToken as string;
return launchFlow(app, response.json());
}
afterEach(async () => {
@ -207,9 +119,9 @@ describe("Heimdall service", () => {
expect(response.statusCode).toBe(201);
const payload = response.json();
expect(payload.authorizationUrl).toContain("discord.com/oauth2/authorize");
expect((await providerRedirect(app, payload)).href).toContain("discord.com/oauth2/authorize");
const verified = verifyJwt(payload.stateToken as string, getPublicKey(app));
const verified = verifyJwt(stateOf(payload), getPublicKey(app));
expect(verified.valid).toBe(true);
if (verified.valid) {
expect(verified.payload).toEqual(
@ -249,6 +161,170 @@ 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
@ -302,9 +378,9 @@ describe("Heimdall service", () => {
expect(response.statusCode).toBe(201);
const payload = response.json();
expect(payload.authorizationUrl).toContain("discord.com/oauth2/authorize");
expect((await providerRedirect(app, payload)).href).toContain("discord.com/oauth2/authorize");
const verified = verifyJwt(payload.stateToken as string, getPublicKey(app));
const verified = verifyJwt(stateOf(payload), getPublicKey(app));
expect(verified.valid).toBe(true);
if (verified.valid) {
expect(verified.payload).toEqual(
@ -348,11 +424,11 @@ describe("Heimdall service", () => {
expect(response.statusCode).toBe(201);
const payload = response.json();
const authorizationUrl = new URL(payload.authorizationUrl as string);
const authorizationUrl = await providerRedirect(app, payload);
expect(`${authorizationUrl.origin}${authorizationUrl.pathname}`).toBe("https://www.patreon.com/oauth2/authorize");
expect(authorizationUrl.searchParams.get("scope")).toBe("identity identity[email]");
const verified = verifyJwt(payload.stateToken as string, getPublicKey(app));
const verified = verifyJwt(stateOf(payload), getPublicKey(app));
expect(verified.valid).toBe(true);
if (verified.valid) {
expect(verified.payload).toEqual(
@ -396,9 +472,9 @@ describe("Heimdall service", () => {
expect(response.statusCode).toBe(201);
const payload = response.json();
expect(payload.authorizationUrl).toContain("discord.com/oauth2/authorize");
expect((await providerRedirect(app, payload)).href).toContain("discord.com/oauth2/authorize");
const verified = verifyJwt(payload.stateToken as string, getPublicKey(app));
const verified = verifyJwt(stateOf(payload), getPublicKey(app));
expect(verified.valid).toBe(true);
if (verified.valid) {
expect(verified.payload).toEqual(
@ -442,10 +518,10 @@ describe("Heimdall service", () => {
expect(response.statusCode).toBe(201);
const payload = response.json();
const authorizationUrl = new URL(payload.authorizationUrl as string);
const authorizationUrl = await providerRedirect(app, payload);
expect(`${authorizationUrl.origin}${authorizationUrl.pathname}`).toBe("https://www.patreon.com/oauth2/authorize");
const verified = verifyJwt(payload.stateToken as string, getPublicKey(app));
const verified = verifyJwt(stateOf(payload), getPublicKey(app));
expect(verified.valid).toBe(true);
if (verified.valid) {
expect(verified.payload).toEqual(
@ -473,10 +549,11 @@ describe("Heimdall service", () => {
});
apps.push(app);
const stateToken = await startDiscordSignIn(app);
const flow = await startDiscordSignIn(app);
const response = await app.inject({
method: "GET",
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(stateToken)}`,
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(flow.state)}`,
headers: { cookie: flow.cookie },
});
expect(response.statusCode).toBe(201);
@ -689,10 +766,11 @@ describe("Heimdall service", () => {
});
apps.push(app);
const stateToken = await startDiscordSignIn(app);
const flow = await startDiscordSignIn(app);
const callbackResponse = await app.inject({
method: "GET",
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(stateToken)}`,
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(flow.state)}`,
headers: { cookie: flow.cookie },
});
const completionCode = callbackResponse.json().completion.code as string;
@ -731,10 +809,11 @@ describe("Heimdall service", () => {
const auditEvents = () =>
[...(store as unknown as { auditEvents: Map<string, { eventType: string; sessionId?: string; eventPayloadJson: unknown }> }).auditEvents.values()];
const stateToken = await startDiscordSignIn(app);
const flow = await startDiscordSignIn(app);
const callback = await app.inject({
method: "GET",
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(stateToken)}`,
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(flow.state)}`,
headers: { cookie: flow.cookie },
});
const { code, expiresAt } = callback.json().completion as { code: string; expiresAt: string };
const created = auditEvents().filter((event) => event.eventType === "auth_completion_created");
@ -815,10 +894,11 @@ describe("Heimdall service", () => {
});
apps.push(app);
const stateToken = await startDiscordSignIn(app);
const flow = await startDiscordSignIn(app);
const callbackResponse = await app.inject({
method: "GET",
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(stateToken)}`,
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(flow.state)}`,
headers: { cookie: flow.cookie },
});
expect(callbackResponse.statusCode).toBe(201);
const refreshToken = callbackResponse.json().refreshToken as string;
@ -862,11 +942,12 @@ describe("Heimdall service", () => {
});
apps.push(app);
const stateToken = await startDiscordSignIn(app);
const flow = await startDiscordSignIn(app);
const response = await app.inject({
method: "GET",
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(stateToken)}`,
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(flow.state)}`,
headers: {
cookie: flow.cookie,
accept: "text/html",
},
});
@ -912,12 +993,13 @@ describe("Heimdall service", () => {
},
});
expect(startResponse.statusCode).toBe(201);
const stateToken = startResponse.json().stateToken as string;
const flow = await launchFlow(app, startResponse.json());
const response = await app.inject({
method: "GET",
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(stateToken)}`,
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(flow.state)}`,
headers: {
cookie: flow.cookie,
accept: "text/html",
},
});
@ -1123,11 +1205,12 @@ describe("Heimdall service", () => {
returnTo: "https://streampixels.gamecult.org/auth/connect",
},
});
const stateToken = startResponse.json().stateToken as string;
const flow = await launchFlow(app, startResponse.json());
const callbackResponse = await app.inject({
method: "GET",
url: `/v1/oauth/twitch/callback?code=test-code&state=${encodeURIComponent(stateToken)}`,
url: `/v1/oauth/twitch/callback?code=test-code&state=${encodeURIComponent(flow.state)}`,
headers: { cookie: flow.cookie },
});
const accountId = callbackResponse.json().account.id as string;
@ -1210,11 +1293,12 @@ describe("Heimdall service", () => {
returnTo: "https://streampixels.gamecult.org/auth/connect",
},
});
const stateToken = startResponse.json().stateToken as string;
const flow = await launchFlow(app, startResponse.json());
const callbackResponse = await app.inject({
method: "GET",
url: `/v1/oauth/twitch/callback?code=test-code&state=${encodeURIComponent(stateToken)}`,
url: `/v1/oauth/twitch/callback?code=test-code&state=${encodeURIComponent(flow.state)}`,
headers: { cookie: flow.cookie },
});
const accountId = callbackResponse.json().account.id as string;
@ -1276,10 +1360,11 @@ describe("Heimdall service", () => {
});
apps.push(app);
const stateToken = await startDiscordSignIn(app);
const flow = await startDiscordSignIn(app);
const callbackResponse = await app.inject({
method: "GET",
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(stateToken)}`,
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(flow.state)}`,
headers: { cookie: flow.cookie },
});
expect(callbackResponse.statusCode).toBe(201);
const evaluationsAtSignIn = evaluations;
@ -1361,11 +1446,31 @@ describe("Heimdall service", () => {
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"],
@ -1377,7 +1482,7 @@ describe("Heimdall service", () => {
expect(start.statusCode).toBe(400);
expect(start.json().error).toBe(error);
expect(start.json().authorizationUrl).toBeUndefined();
expect(start.json().stateToken).toBeUndefined();
expect(start.json()).not.toHaveProperty("stateToken");
});
it.each([
@ -1393,14 +1498,15 @@ describe("Heimdall service", () => {
apps.push(app);
const keys = createRuntimeKeyMaterial({ ...config, bootstrapSigningPrivateKeyOnMissing: false });
const calls = stubJoystick({ channel_id: "chan-forged", username: "deru" });
const stateToken = (await connect(app)).json().stateToken as string;
const verified = verifyJwt(stateToken, keys.publicKey);
const flow = await launchFlow(app, (await connect(app)).json());
const verified = verifyJwt(flow.state, 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([]);
@ -1432,9 +1538,11 @@ 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(start.json().stateToken as string)}`,
url: `/v1/oauth/joystick/callback?code=c&state=${encodeURIComponent(flow.state)}`,
headers: { cookie: flow.cookie },
});
expect(callback.statusCode).toBeLessThan(300);
@ -1453,7 +1561,7 @@ describe("Heimdall service", () => {
apps.push(app);
const start = await connect(app);
expect(start.statusCode).toBe(201);
const url = new URL(start.json().authorizationUrl as string);
const url = await providerRedirect(app, start.json());
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");
@ -1467,10 +1575,11 @@ describe("Heimdall service", () => {
const app = await buildApp({ config: createTestConfig(), store });
apps.push(app);
stubJoystick({ channel_id: "deru-chan", username: "deru" });
const stateToken = (await connect(app)).json().stateToken as string;
const flow = await launchFlow(app, (await connect(app)).json());
const connected = await app.inject({
method: "GET",
url: `/v1/oauth/joystick/callback?code=a&state=${encodeURIComponent(stateToken)}`,
url: `/v1/oauth/joystick/callback?code=a&state=${encodeURIComponent(flow.state)}`,
headers: { cookie: flow.cookie },
});
const accountId = connected.json().account.id as string;
const before = await store.findStoredLinkedIdentityForAccount(accountId, "joystick");
@ -1518,14 +1627,15 @@ describe("Heimdall service", () => {
apps.push(app);
const keys = createRuntimeKeyMaterial({ ...config, bootstrapSigningPrivateKeyOnMissing: false });
const calls = stubJoystick({ channel_id: "chan-forged", username: "deru" });
const stateToken = (await connect(app)).json().stateToken as string;
const verified = verifyJwt(stateToken, keys.publicKey);
const flow = await launchFlow(app, (await connect(app)).json());
const verified = verifyJwt(flow.state, 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");
@ -1542,11 +1652,12 @@ 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 stateToken = (await connect(app)).json().stateToken as string;
const flow = await launchFlow(app, (await connect(app)).json());
const callback = await app.inject({
method: "GET",
url: `/v1/oauth/joystick/callback?code=the-code&state=${encodeURIComponent(stateToken)}`,
url: `/v1/oauth/joystick/callback?code=the-code&state=${encodeURIComponent(flow.state)}`,
headers: { cookie: flow.cookie },
});
expect(callback.statusCode).toBeLessThan(300);
@ -1575,10 +1686,11 @@ describe("Heimdall service", () => {
const app = await buildApp({ config: createTestConfig(), store });
apps.push(app);
stubJoystick({ channel_id: "chan-9", username: "deru" });
const stateToken = (await connect(app)).json().stateToken as string;
const flow = await launchFlow(app, (await connect(app)).json());
const callback = await app.inject({
method: "GET",
url: `/v1/oauth/joystick/callback?code=c&state=${encodeURIComponent(stateToken)}`,
url: `/v1/oauth/joystick/callback?code=c&state=${encodeURIComponent(flow.state)}`,
headers: { cookie: flow.cookie },
});
const row = await store.findStoredLinkedIdentityForAccount(callback.json().account.id as string, "joystick");
expect(row?.displayName).toBe("deru");
@ -1589,10 +1701,11 @@ describe("Heimdall service", () => {
const app = await buildApp({ config: createTestConfig(), store });
apps.push(app);
stubJoystick({ username: "deru" });
const stateToken = (await connect(app)).json().stateToken as string;
const flow = await launchFlow(app, (await connect(app)).json());
const callback = await app.inject({
method: "GET",
url: `/v1/oauth/joystick/callback?code=c&state=${encodeURIComponent(stateToken)}`,
url: `/v1/oauth/joystick/callback?code=c&state=${encodeURIComponent(flow.state)}`,
headers: { cookie: flow.cookie },
});
expect(callback.statusCode).toBeGreaterThanOrEqual(400);
expect(await store.findStoredLinkedIdentity("joystick", "undefined")).toBeNull();
@ -1787,11 +1900,11 @@ describe("error answers never echo an input", () => {
const app = await buildApp({ config: createTestConfig(), oauthRuntimes: { discord: failingDiscordRuntime() } });
apps.push(app);
const stateToken = await startDiscordSignIn(app);
const flow = await startDiscordSignIn(app);
const response = await app.inject({
method: "GET",
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(stateToken)}`,
headers: { accept: "text/html" },
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(flow.state)}`,
headers: { cookie: flow.cookie, accept: "text/html" },
});
expect(response.headers["content-type"]).toContain("text/html");
@ -1823,9 +1936,11 @@ 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(start.json().stateToken as string)}`,
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(flow.state)}`,
headers: { cookie: flow.cookie },
});
expect(response.statusCode).toBe(502);
@ -1843,16 +1958,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 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()}`;
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 } };
}
it("is printable and bounded in the JSON answer", async () => {
const app = await buildApp({ config: createTestConfig() });
apps.push(app);
const response = await app.inject({ method: "GET", url: providerErrorUrl(await startDiscordSignIn(app)) });
const response = await app.inject(providerErrorRequest(await startDiscordSignIn(app)));
expect(response.statusCode).toBe(400);
const { providerError, providerErrorDescription } = response.json() as Record<string, string>;
@ -1866,11 +1981,8 @@ describe("the relayed provider error", () => {
const app = await buildApp({ config: createTestConfig() });
apps.push(app);
const response = await app.inject({
method: "GET",
url: providerErrorUrl(await startDiscordSignIn(app)),
headers: { accept: "text/html" },
});
const request = providerErrorRequest(await startDiscordSignIn(app));
const response = await app.inject({ ...request, headers: { ...request.headers, accept: "text/html" } });
expect(response.headers["content-type"]).toContain("text/html");
expect(response.body.match(/<script/gi)).toHaveLength(1);
@ -1902,7 +2014,7 @@ describe("the relayed provider error", () => {
});
expect(start.statusCode).toBe(201);
await app.inject({ method: "GET", url: providerErrorUrl(start.json().stateToken as string) });
await app.inject(providerErrorRequest(await launchFlow(app, start.json())));
expect(deliveries).toHaveLength(1);
const description = String(deliveries[0]!.errorDescription);
@ -1928,10 +2040,11 @@ describe("the failed-callback audit event", () => {
});
apps.push(app);
const stateToken = await startDiscordSignIn(app);
const flow = await startDiscordSignIn(app);
const response = await app.inject({
method: "GET",
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(stateToken)}`,
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(flow.state)}`,
headers: { cookie: flow.cookie },
});
expect(response.statusCode).toBe(502);
@ -1955,10 +2068,11 @@ 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 stateToken = await startDiscordSignIn(app);
const flow = await startDiscordSignIn(app);
const callback = await app.inject({
method: "GET",
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(stateToken)}`,
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(flow.state)}`,
headers: { cookie: flow.cookie },
});
expect(callback.statusCode).toBe(502);
const events = [...(store as unknown as { auditEvents: Map<string, { eventType: string; eventPayloadJson: unknown }> }).auditEvents.values()];
@ -2012,8 +2126,12 @@ describe("the failed-callback audit event for a provider's HTTP refusal", () =>
},
});
apps.push(app);
const stateToken = await startDiscordSignIn(app);
await app.inject({ method: "GET", url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(stateToken)}` });
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 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,6 +24,7 @@ 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> }> = [];
@ -66,6 +67,7 @@ function testConfig(overrides: Partial<HeimdallConfig> = {}): HeimdallConfig {
youtube: {},
spotify: {},
joystick: {},
google: {},
},
...overrides,
};
@ -102,10 +104,11 @@ 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" },
headers: { accept: "application/json", cookie },
});
}
@ -213,7 +216,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(response.json().stateToken as string);
const state = jwtPayload(stateOf(response.json()));
expect(state.handoff).toEqual({ kind: "browser_completion" });
expect(JSON.stringify(state)).not.toContain(attempt.handle);
});
@ -237,7 +240,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(response.json().stateToken as string).handoff).toEqual({ kind: "browser_completion" });
expect(jwtPayload(stateOf(response.json())).handoff).toEqual({ kind: "browser_completion" });
} else {
expect(response.statusCode, response.body).toBe(400);
}
@ -249,7 +252,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(response.json().stateToken as string).handoff).toEqual({ kind: "browser_completion" });
expect(jwtPayload(stateOf(response.json())).handoff).toEqual({ kind: "browser_completion" });
}
});
@ -264,8 +267,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's call ends at the body; no fifth argument.
expect(source).toMatch(/startOAuthFlow\(\{ config, keys, store \}, request\.params\.provider, caller, request\.body\);/);
// 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*\}\);/);
}
}
expect(callers.sort()).toEqual(["app.ts:1", "private-command-plane.ts:1"]);
@ -306,7 +309,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, attackerStart.json().stateToken as string, "attacker");
const attackerCallback = await completeAs(app, stateOf(attackerStart.json()), "attacker");
expect(attackerCallback.statusCode, attackerCallback.body).toBe(201);
expect((await store.findAuthAttempt("ghostlight", handle))?.status).toBe("pending");
@ -340,7 +343,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, response.json().stateToken as string, "attacker");
await completeAs(app, stateOf(response.json()), "attacker");
expect((await store.findAuthAttempt("ghostlight", attempt.handle))?.status).toBe("pending");
expect(await store.consumeAuthCompletionByAttempt("ghostlight", attempt.handle, new Date().toISOString())).toBeNull();
});
@ -493,26 +496,27 @@ partialUniqueIndexContract("PostgresStore over pg-mem", async () => {
});
describe("duplicate-handle error surfacing through the callback route", () => {
it("state tokens are replayable, and a replay while the handle's completion is unconsumed echoes the store error in a 502", async () => {
it("a replayed state is refused as already used before any exchange, and leaves the real completion intact", 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 first = await completeAs(app, state, "victim");
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();
expect(first.statusCode).toBe(201);
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.
// 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" });
expect((await store.findAuthAttempt("ghostlight", handle))?.status).toBe("completed");
expect(await store.consumeAuthCompletionByAttempt("ghostlight", handle, new Date().toISOString())).toBeTruthy();
});

View file

@ -11,6 +11,7 @@ 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> }> = [];
@ -52,6 +53,7 @@ function testConfig(): HeimdallConfig {
youtube: {},
spotify: {},
joystick: {},
google: {},
},
};
}
@ -86,10 +88,11 @@ 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" },
headers: { accept: "application/json", cookie },
});
}
@ -110,7 +113,7 @@ describe("takeover chain: caller-chosen attemptId is never the redemption code",
});
expect(started.statusCode).toBe(201);
const callback = await completeAs(app, started.json().stateToken as string, "victim");
const callback = await completeAs(app, stateOf(started.json()), "victim");
expect(callback.statusCode).toBe(201);
const minted = callback.json().completion.code as string;
expect(minted).not.toBe("attacker-fixed");
@ -125,7 +128,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, started.json().stateToken as string, "victim");
const callback = await completeAs(app, stateOf(started.json()), "victim");
expect(callback.statusCode).toBe(201);
const code = callback.json().completion.code as string;
expect(code).toMatch(uuidV4);
@ -159,7 +162,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, victimStart.json().stateToken as string, "victim");
const victimCallback = await completeAs(app, stateOf(victimStart.json()), "victim");
const victimCode = victimCallback.json().completion.code as string;
const attackerStart = await start(app, {
@ -168,7 +171,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, attackerStart.json().stateToken as string, "attacker");
const attackerCallback = await completeAs(app, stateOf(attackerStart.json()), "attacker");
expect(attackerCallback.statusCode).toBe(201);
expect(attackerCallback.json().completion.code).not.toBe(victimCode);
@ -204,7 +207,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, started.json().stateToken as string, "victim");
const callback = await completeAs(app, stateOf(started.json()), "victim");
const code = callback.json().completion.code as string;
const crossApp = await redeem(app, "bifrost", code);
@ -265,7 +268,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, attackerStart.json().stateToken as string, "attacker");
await completeAs(app, stateOf(attackerStart.json()), "attacker");
const after = await store.findAuthAttempt("ghostlight", attempt.handle);
expect(after?.status).toBe("pending");

View file

@ -18,6 +18,7 @@ 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> }> = [];
@ -60,6 +61,7 @@ function testConfig(): HeimdallConfig {
youtube: {},
spotify: {},
joystick: {},
google: {},
},
};
}
@ -104,10 +106,11 @@ 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(start.json().stateToken as string)}`,
headers: { accept: "text/html" },
url: `/v1/oauth/discord/callback?code=victim-code&state=${encodeURIComponent(flow.state)}`,
headers: { accept: "text/html", cookie: flow.cookie },
});
expect(callback.statusCode).toBe(200);

View file

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

525
tests/google-link.test.ts Normal file
View file

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

197
tests/google-oidc.test.ts Normal file
View file

@ -0,0 +1,197 @@
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

@ -0,0 +1,669 @@
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,6 +7,7 @@ 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 {
@ -49,6 +50,7 @@ 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" },
},
};
}
@ -97,8 +99,15 @@ 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 } });
}
function callback(r: Rig, stateToken: string) {
return r.app.inject({ method: "GET", url: `/v1/oauth/discord/callback?code=c&state=${encodeURIComponent(stateToken)}` });
/** 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 redeem(r: Rig, payload: Record<string, unknown>, headers: Record<string, string> = {}) {
@ -137,7 +146,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().stateToken);
const done = await callback(r, started.json());
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);
@ -149,7 +158,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().stateToken);
const done = await callback(r, started.json());
expect(done.statusCode).toBe(403);
expect(done.json().error).toBe("link_initiator_mismatch");
expect(done.json().completion).toBeUndefined();
@ -164,10 +173,11 @@ 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 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);
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);
});
expect([done.statusCode, done.json().error]).toEqual([403, "link_initiator_missing"]);
expect(await r.store.findAccountByLinkedIdentity("discord", "d-forged")).toBeNull();
} finally {
@ -183,7 +193,7 @@ describe("redeem binds caller, target and replay", () => {
initiatingAccountId: r.initiator.id,
...(targetId ? { connection: { kind: "account", targetId } } : {}),
});
const done = await callback(r, started.json().stateToken);
const done = await callback(r, started.json());
expect(done.statusCode).toBe(201);
return done.json().completion.code as string;
}

View file

@ -14,6 +14,7 @@ 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));
@ -579,11 +580,13 @@ 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`, 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`, headers: mode === "connect" ? { "x-heimdall-app-secret": "streampixels-secret" } : {}, 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(start.json().stateToken as string)}`,
url: `/v1/oauth/${provider}/callback?code=c&state=${encodeURIComponent(flow.state)}`,
headers: { cookie: flow.cookie },
});
expect(callback.statusCode).toBe(201);
const redeem = await app.inject({
@ -630,12 +633,15 @@ 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(start.json().stateToken as string)}`,
url: `/v1/oauth/joystick/callback?code=c&state=${encodeURIComponent(flow.state)}`,
headers: { cookie: flow.cookie },
});
expect(callback.statusCode).toBeGreaterThanOrEqual(400);
expect(callback.body).not.toContain("tok-access");

158
tests/oauth-flow.ts Normal file
View file

@ -0,0 +1,158 @@
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

@ -0,0 +1,549 @@
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,6 +12,7 @@ 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> }> = [];
@ -30,7 +31,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: {} },
providers: { discord: { clientId: "discord-client", clientSecret: "discord-secret" }, patreon: {}, github: {}, twitch: {}, youtube: {}, spotify: {}, joystick: {}, google: {} },
} as HeimdallConfig;
}
@ -73,10 +74,11 @@ 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" },
headers: { accept: "application/json", cookie },
});
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 }));
@ -133,10 +135,11 @@ 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" },
headers: { accept: "application/json", cookie },
});
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,6 +39,7 @@ 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 } from "../src/store/index.js";
import { createStore, InMemoryStore } from "../src/store/index.js";
import {
createPostgresStore,
PostgresStore,
@ -14,7 +14,7 @@ import {
samePredicate,
STORE_TABLE_PRIVILEGES,
} from "../src/store/postgres.js";
import { REQUIRED_COLUMNS, REQUIRED_KEYS, REQUIRED_TABLES } from "../src/store/schema.js";
import { CREATE_SCHEMA_SQL, REQUIRED_COLUMNS, REQUIRED_KEYS, REQUIRED_TABLES } from "../src/store/schema.js";
describe("PostgresStore", () => {
it("round-trips core auth records through postgres", async () => {
@ -198,6 +198,99 @@ 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
@ -232,12 +325,15 @@ 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(86);
expect(REQUIRED_COLUMNS).toHaveLength(112);
expect(REQUIRED_COLUMNS).toEqual(
expect.arrayContaining([
{ table: "auth_completions", column: "code", type: "TEXT", nullable: false },
@ -248,11 +344,14 @@ describe("PostgresStore.checkSchema", () => {
{ table: "auth_completions", column: "attempt_id", type: "TEXT", nullable: true },
])
);
expect(REQUIRED_KEYS).toHaveLength(12);
expect(REQUIRED_KEYS).toHaveLength(17);
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" },
])
@ -362,9 +461,35 @@ 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;
}
@ -524,12 +649,28 @@ 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 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);
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"));
});
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/7; apps=4; healthTransport=CultNet/RUDP",
"Heimdall auth runtime active; storage=postgres; providers=2/8; apps=4; healthTransport=CultNet/RUDP",
"2026-06-16T17:10:00.000Z",
"heimdall.cultnet-rudp-provider-health",
"daemon-published",

View file

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