Compare commits

..
Author SHA1 Message Date
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
Heimdall
84c54fbb5e Refuse a connection target on sign_in start
Connection targets ride only on connect and link starts, which carry
x-heimdall-app-secret. A sign_in start is open to any browser; with a
connection it now returns 400 connection_not_allowed_in_sign_in.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-03 13:30:22 +00:00
Heimdall
0f2e294fcb Pin completion expiry at redeem: 410 at and after expiresAt, for any caller and target
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-03 09:50:34 +00:00
Heimdall
9f5a0e6938 Bind link mode to its initiator and redeem to its target
Link start needs an authenticated app and an initiatingAccountId, carried in
the signed state. The callback refuses an identity held by another account
(link_initiator_mismatch) and attaches an unlinked one to the initiator.
auth_completions stores target_id; redeem compares it and answers
completion_target_mismatch without consuming the code. Link completions are
redeemed only by the authenticated app.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 13:38:51 +00:00
Meta Vens
5146c722af Boot test: fail on a logged publication fault, and require presence at startup
A silent early return in the startup publication would otherwise pass, the 10 s
pulse loop filling in the frames later.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 17:50:37 +02:00
Meta Vens
1a36175f3a Presence authority test: the Rust fixture's target is the fixture's own, not heimdall
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 17:49:23 +02:00
Meta Vens
c044155855 Heimdall opens its Idunn presence authority once, before it listens
index.ts declared the presence sequence below the startup try, so the bundle's
hoisted var was undefined at the first publish; decodePrivateIdentity refused the
Rust owner's integer-array key fields; both descriptors were re-read every pulse and
returned nothing after the first. openPresenceAuthority now reads the bundle facts and
each descriptor once (whole, from offset 0, then closed), owns the one publisher
sequence, and signs through them; a launch-contract fault throws before anything
listens, so the process exits non-zero. decodePrivateIdentity takes the shape of its
source and names the failed check, never a value. openActivationSigner and the
per-pulse descriptor reads are deleted.

tests/idunn-boot.test.ts boots the bundle under the Idunn launch contract and checks
the signed warming frames and the fail-fast exits. The descriptor, sequence and
generation tests now use the Rust-written fixtures.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 17:48:08 +02:00
Meta Vens
c0d30e8081 Test fixtures: the Rust owner's Idunn runtime records, and one fake Idunn RUDP endpoint
tests/fixtures/idunn-runtime holds expected.cc, activation.cc, the activation seed,
the presence identity credential and the machine-id it is bound to, copied from
CultLib 217a9480 (Rust-written; test keys only). fake-idunn-rudp.ts is the accept
server idunn-rudp-health.test.ts built inline, now keeping a session per remote and
every delivered frame, so a boot test can receive several pulses.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 17:48:02 +02:00
Meta Vens
e8548614c8 Generation test: bundle the generation process so a uid that cannot read the checkout can run it
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 14:26:13 +02:00
Meta Vens
55732a901d Presence: the publisher sequence is in-process, the state root keeps no owner-only file, an Idunn generation never self-enrolls
nextPublisherSequence read and rewrote <dataRoot>/runtime-presence-sequence at
mode 0600, which the next generation (a fresh DynamicUser uid in the shared
state group) cannot open. Odin compares publisher_sequence only within one
activation and Idunn mints a new activation per start, so PublisherSequence
derives it from the observation clock, never below the previous value plus
one, as Muninn does. openProviderHealthIdentity under
GAMECULT_IDUNN_RUNTIME_BUNDLE now refuses when the presence descriptor is
absent instead of enrolling an identity Idunn does not trust.

Adds the cross-generation test (root only) and the sequence, no-dataRoot and
missing-descriptor tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 14:25:11 +02:00
Meta Vens
25d43d1121 Merge security/fastify-upgrade: fastify 5.12.7 advisories
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 23:06:25 +02:00
Meta Vens
899ea0b8fe Heimdall: fastify 5.8.5 to 5.12.7, find-my-way 9.5.0 to 9.10.0 (clears 5 High advisories)
GHSA-p68q-wchp-6fh7 authentication bypass via malformed URLs, header and request validation bypass, request body replacement, find-my-way HTTP/2 DoS. pnpm audit --prod: 8 vulnerabilities before, none after.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 21:00:47 +02:00
Meta Vens
c15fb97706 Test: malformed URL must not reach a sibling prefix's not-found handler (GHSA-p68q-wchp-6fh7)
Red on fastify 5.8.5, the live lockfile.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 21:00:46 +02: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
c1dfe15dd5 Merge readme-front-door: README opens with value, problem, entry points, live versus planned 2026-10-22 20:04:06 +02:00
Meta Vens
c7cf78bbbc README: front door with value, problem, entry points, live versus planned
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 20:03:50 +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
Meta Vens
5952cce8a0 State: live d51fa6b proven by a human Twitch sign-in; Joystick consent remains
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 18:03:17 +02:00
Meta Vens
30a1ace6fd State: close eureka/joystick-h1 as accepted (merged, PR #1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 17:56:34 +02:00
Meta Vens
df05570ef6 State: Joystick H1 merged and live at d51fa6b; follow-ups recorded by id
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 17:56:25 +02:00
Meta Vens
d51fa6b999
Merge pull request #1 from GameCult/eureka/joystick-h1
Joystick.tv provider and serialized managed-credential refresh (cut H1)
2026-10-19 17:47:39 +02:00
60 changed files with 4475 additions and 523 deletions

View file

@ -12,6 +12,19 @@ The point is painfully simple:
become "everything phones home for every request"
- keep app-domain data separate so "shared auth" does not become "shared swamp"
## Front Door
- **Value:** one shared auth authority: provider OAuth (Discord, Patreon, GitHub, Twitch, YouTube, Spotify, Google OIDC for linking), linked identities, grants, entitlements and session issuance, with Ed25519-signed claims that app backends verify locally.
- **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.
- `src/verifier.ts`: the local verifier helper an app backend imports to validate Heimdall JWTs without phoning home.
- `src/odin-publication.ts` and `plugins/gamecult.heimdall.access`: the redacted Odin/Eve provider advertisement and the `gamecult.heimdall.access` plugin (`docs/eve-access-plugin.md`).
- `docs/architecture.md`, `docs/service-contract.md`, `docs/verse-service-contract.md`, `docs/app-profiles/`: the contracts.
- `pnpm install`, `pnpm dev`, `pnpm test` (see Dev Commands).
- **Live:** a stateful first slice: signing and JWKS, OAuth state for six providers, in-memory and Postgres stores, a real Discord callback path for the Repixelizer flow, sealed provider tokens, the loopback CultNet operation boundary, Idunn health publication, and the deployed Ghostlight Eve host consuming the discovered boundary.
- **Planned:** the two-account and eight-account Ghostlight human canaries, refresh and revocation flows, admin surfaces, and broader inspection projections beyond the redacted access boundary.
## Current Shape
This repo now has a stateful first slice instead of a decorative skeleton.

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

@ -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
@ -334,6 +405,11 @@ Request body:
}
```
Binding rules:
- A completion minted for a connection target (`connection.targetId` in the signed state) is redeemed only with `targetId` equal to it; absent or different answers `403 completion_target_mismatch` and leaves the code unconsumed.
- A `link` completion is redeemed only by the authenticated app backend (`x-heimdall-app-secret`); otherwise `401 app_auth_required`, code unconsumed. `link` start itself needs that authentication and `initiatingAccountId`; the callback refuses with `403 link_initiator_mismatch` when the provider identity belongs to a different account, and attaches an unlinked identity to the initiator.
Response:
```json

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

@ -26,18 +26,23 @@ Do not trust this file for the exact live HEAD. Always check git.
## Active Work
Joystick H1 is the active, M1-critical cut (operator ruling 2026-10-18):
campaign `joystick:campaign:self`, target `joystick:target:r1`, branch
`eureka/joystick-h1`. Read the campaign and target in the typed mind; do not
re-derive them here. The Ghostlight KLTST canaries below stay open, behind H1.
Joystick H1 is merged and live (PR GameCult/Heimdall#1; campaign
`joystick:campaign:self`). Next: `joystick:follow_up:h1-joystick-consent-round-trip`, then
StreamPixels S0/S1 (other sessions). Open Heimdall follow-ups, by id:
`joystick:follow_up:cross-process-failed-refresh-marker`,
`joystick:follow_up:connect-start-needs-app-auth`,
`joystick:follow_up:heimdall-idunn-onboarding`,
`joystick:follow_up:joystick-viewer-signin-public-bot`. The Ghostlight KLTST
canaries below stay open, behind them.
## Current Orientation
Deploy facts below are last-recorded, from 2026-08-22 (`1086aee`); HEAD has
moved ~100 commits past it. Check Yggdrasil, not this file, for what runs.
Yggdrasil runs Heimdall `d51fa6b`, hand-deployed 2026-10-19 15:51Z;
gamecult-ops `210a1af` records backup, rollback and provenance. Facts below
about Odin, Ghostlight and CultLib pins were verified at `1086aee` (2026-08-22)
and are not re-verified at `d51fa6b`.
- Yggdrasil runs Heimdall
`1086aee01169bf60e8a492b2740db1c6f3e8cabf` with CultLib
- At `1086aee01169bf60e8a492b2740db1c6f3e8cabf` Heimdall ran with CultLib
`5cefa0db0079a8e3ee22f29d7b9e6e5aa60912a9`
- Heimdall publishes four redacted access records to Odin under unique keys:
`eve:provider:heimdall`, `heimdall:command-boundary`,
@ -157,8 +162,7 @@ moved ~100 commits past it. Check Yggdrasil, not this file, for what runs.
over `cultnet.transport.rudp.v0` when `GC_ACCESS_IDUNN_RUDP_HEALTH` is
configured; HTTP `/healthz`, JWKS, discovery, systemd, and nginx routing
remain compatibility witnesses
- Yggdrasil now runs Heimdall commit
`1086aee01169bf60e8a492b2740db1c6f3e8cabf`. Heimdall persists its private
- Since `1086aee` Heimdall persists its private
provider-health identity at `/srv/heimdall/keys/provider-health-identity.cc`
and publishes canonical signed health independently of the slower Verse
snapshot cadence. Idunn owns the exact root trust binding, authenticated
@ -180,7 +184,7 @@ moved ~100 commits past it. Check Yggdrasil, not this file, for what runs.
## Next Real Move
Joystick H1 first (see Active Work). Everything below is the standing queue.
The Active Work follow-ups first. Everything below is the standing queue.
Do not continue implementation automatically from a rehydrate-only request.

View file

@ -17,12 +17,13 @@
"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",
"cultmesh-ts": "file:vendor/CultLib/packages/cultmesh-ts",
"cultnet-ts": "file:vendor/CultLib/packages/cultnet-ts",
"fastify": "^5.8.5",
"fastify": "^5.12.6",
"pg": "^8.20.0",
"zod": "^3.25.76"
},

69
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
@ -28,8 +31,8 @@ importers:
specifier: file:vendor/CultLib/packages/cultnet-ts
version: file:vendor/CultLib/packages/cultnet-ts
fastify:
specifier: ^5.8.5
version: 5.8.5
specifier: ^5.12.6
version: 5.12.7
pg:
specifier: ^8.20.0
version: 8.20.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'}
@ -532,17 +542,26 @@ packages:
fast-json-stringify@6.3.0:
resolution: {integrity: sha512-oRCntNDY/329HJPlmdNLIdogNtt6Vyjb1WuT01Soss3slIdyUp8kAcDU3saQTOquEK8KFVfwIIF7FebxUAu+yA==}
fast-json-stringify@7.1.3:
resolution: {integrity: sha512-5TDM1yOF/BDFOclKz2G6rs1RsIl5HXMCrg/npmdNIPNQCezshR5kIHjhwBNuMY67jhkTUAm9DguL8xkL/x0Wtw==}
fast-querystring@1.1.2:
resolution: {integrity: sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==}
fast-uri@3.1.0:
resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==}
fast-uri@4.2.3:
resolution: {integrity: sha512-I1PXwP5eAhlNlcdpjt9CgB59op2ZvZfxw+BkqGFVIUOgNpCVTcTqp3HhTX7brxj5vsip+nSY4nTvff+eIA76hg==}
fastify-plugin@5.1.0:
resolution: {integrity: sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==}
fastify@5.8.5:
resolution: {integrity: sha512-Yqptv59pQzPgQUSIm87hMqHJmdkb1+GPxdE6vW6FRyVE9G86mt7rOghitiU4JHRaTyDUk9pfeKmDeu70lAwM4Q==}
fastify-plugin@6.0.1:
resolution: {integrity: sha512-4wW4RxSmvPXLrh5mgQEiu43qU3bw44N2ss4FTkTkQIbprUhE+/s43QX0j/ttcztdWK7PEkHqVthdc5GdEGDvjA==}
fastify@5.12.7:
resolution: {integrity: sha512-v4pEPsTRPuqlDIYiQFB+BHjVN4SdHX9u0ABk0ZDUKbbdY1vsNryUd1j4Z+jQEZajvYIVk047Kimva9/AJ79GYw==}
fastq@1.20.1:
resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==}
@ -556,8 +575,8 @@ packages:
picomatch:
optional: true
find-my-way@9.5.0:
resolution: {integrity: sha512-VW2RfnmscZO5KgBY5XVyKREMW5nMZcxDy+buTOsL+zIPnBlbKm+00sgzoQzq1EVh4aALZLfKdwv6atBGcjvjrQ==}
find-my-way@9.10.0:
resolution: {integrity: sha512-eru+uCPQ2g+nLBkf84MHP/KyNNMar0BDy0j+i8Fb72GF9/YHBZ58q7FODUtfVV7XU9nfRUHXSmaM8NmZcIbZ4Q==}
engines: {node: '>=20'}
fsevents@2.3.3:
@ -853,8 +872,8 @@ packages:
process-warning@4.0.1:
resolution: {integrity: sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==}
process-warning@5.0.0:
resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==}
process-warning@5.1.1:
resolution: {integrity: sha512-oCkNVj4OKnKEcocVBcDfiIugN1/cE6rgfT3nCtzeqZPA8+rIxMaQ0xXzIkGP8Gj2qUaP1UR7uhJ1VD8lUCpzCg==}
quick-format-unescaped@4.0.4:
resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==}
@ -1180,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
@ -1387,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
@ -1475,15 +1501,28 @@ snapshots:
json-schema-ref-resolver: 3.0.0
rfdc: 1.4.1
fast-json-stringify@7.1.3:
dependencies:
'@fastify/merge-json-schemas': 0.2.1
ajv: 8.20.0
ajv-formats: 3.0.1(ajv@8.20.0)
fast-uri: 4.2.3
json-schema-ref-resolver: 3.0.0
rfdc: 1.4.1
fast-querystring@1.1.2:
dependencies:
fast-decode-uri-component: 1.0.1
fast-uri@3.1.0: {}
fast-uri@4.2.3: {}
fastify-plugin@5.1.0: {}
fastify@5.8.5:
fastify-plugin@6.0.1: {}
fastify@5.12.7:
dependencies:
'@fastify/ajv-compiler': 4.0.5
'@fastify/error': 4.2.0
@ -1491,11 +1530,11 @@ snapshots:
'@fastify/proxy-addr': 5.1.0
abstract-logging: 2.0.1
avvio: 9.2.0
fast-json-stringify: 6.3.0
find-my-way: 9.5.0
fast-json-stringify: 7.1.3
find-my-way: 9.10.0
light-my-request: 6.6.0
pino: 10.3.1
process-warning: 5.0.0
process-warning: 5.1.1
rfdc: 1.4.1
secure-json-parse: 4.1.0
semver: 7.7.4
@ -1509,7 +1548,7 @@ snapshots:
optionalDependencies:
picomatch: 4.0.4
find-my-way@9.5.0:
find-my-way@9.10.0:
dependencies:
fast-deep-equal: 3.1.3
fast-querystring: 1.1.2
@ -1733,7 +1772,7 @@ snapshots:
on-exit-leak-free: 2.1.2
pino-abstract-transport: 3.0.0
pino-std-serializers: 7.1.0
process-warning: 5.0.0
process-warning: 5.1.1
quick-format-unescaped: 4.0.4
real-require: 0.2.0
safe-stable-stringify: 2.5.0
@ -1758,7 +1797,7 @@ snapshots:
process-warning@4.0.1: {}
process-warning@5.0.0: {}
process-warning@5.1.1: {}
quick-format-unescaped@4.0.4: {}

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

@ -0,0 +1,15 @@
#!/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
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",

View file

@ -1,6 +1,7 @@
import { createHmac, randomUUID } from "node:crypto";
import cookie from "@fastify/cookie";
import cors from "@fastify/cors";
import Fastify, { type FastifyError, type FastifyInstance } from "fastify";
import Fastify, { type FastifyError, type FastifyInstance, type FastifyReply, type FastifyRequest } from "fastify";
import { type AppCaller, isAppCaller, resolveAppCaller } from "./app-caller.js";
import { deliverBackendHandoff, type BackendHandoffPayload } from "./backend-handoff.js";
import {
@ -28,6 +29,7 @@ import { renderBrowserHandoffPage } from "./browser-handoff.js";
import { type HeimdallConfig, loadConfig } from "./config.js";
import { createTokenCustody, type TokenCustody } from "./custody.js";
import { grantFact } from "./facts.js";
import { deriveOidcSecrets } from "./google-oidc.js";
import {
createOAuthRuntimeRegistry,
fetchPatreonIdentity,
@ -91,10 +93,12 @@ function buildOAuthStateToken(options: {
mode: OAuthMode;
returnTo: string;
connection: OAuthConnectionBinding | undefined;
initiatorAccountId: string | undefined;
handoff: OAuthHandoff;
entitlementPolicy: OAuthEntitlementPolicy | undefined;
requestedScopes: string[] | undefined;
keys: ReturnType<typeof createRuntimeKeyMaterial>;
}): { token: string; expiresAt: string } {
}): { token: string; jti: string; expiresAt: string } {
const issuedAt = nowEpochSeconds();
const expiresAtEpoch = issuedAt + options.config.stateTtlSeconds;
const payload: OAuthStatePayload = {
@ -111,13 +115,16 @@ function buildOAuthStateToken(options: {
mode: options.mode,
return_to: options.returnTo,
connection: options.connection ?? null,
initiator_account_id: options.initiatorAccountId ?? null,
handoff: options.handoff,
entitlement_policy: options.entitlementPolicy ?? null,
requested_scopes: options.requestedScopes ?? null,
};
const token = signJwt(payload as unknown as Record<string, unknown>, options.keys);
return {
token,
jti: payload.jti,
expiresAt: new Date(expiresAtEpoch * 1000).toISOString(),
};
}
@ -151,6 +158,11 @@ function parseOAuthStatePayload(payload: Record<string, unknown>): OAuthStatePay
entitlementPolicy !== null &&
(entitlementPolicy as Record<string, unknown>).kind === "patreon_membership_access" &&
typeof (entitlementPolicy as Record<string, unknown>).requiredTierTitle === "string"));
const requestedScopes = payload.requested_scopes;
const requestedScopesAreValid =
requestedScopes === null ||
requestedScopes === undefined ||
(Array.isArray(requestedScopes) && requestedScopes.every((scope) => typeof scope === "string"));
if (
payload.typ !== "heimdall_oauth_state" ||
typeof payload.provider !== "string" ||
@ -168,7 +180,9 @@ function parseOAuthStatePayload(payload: Record<string, unknown>): OAuthStatePay
typeof payload.nbf !== "number" ||
typeof payload.exp !== "number" ||
!handoffIsValid ||
!entitlementPolicyIsValid
!entitlementPolicyIsValid ||
!(payload.initiator_account_id === undefined || payload.initiator_account_id === null || typeof payload.initiator_account_id === "string") ||
!requestedScopesAreValid
) {
return null;
}
@ -177,13 +191,105 @@ function parseOAuthStatePayload(payload: Record<string, unknown>): OAuthStatePay
if (!normalizedPayload.handoff) {
normalizedPayload.handoff = { kind: "browser_completion" };
}
if (!normalizedPayload.initiator_account_id) {
normalizedPayload.initiator_account_id = null;
}
if (!normalizedPayload.entitlement_policy) {
normalizedPayload.entitlement_policy = null;
}
if (!normalizedPayload.requested_scopes) {
normalizedPayload.requested_scopes = null;
}
return normalizedPayload;
}
type OAuthStateVerdict =
| { ok: true; payload: OAuthStatePayload }
| { ok: false; error: "invalid_state" | "state_provider_mismatch" | "state_expired" };
/**
* The one place a signed OAuth state becomes trusted: signature, shape, issuer,
* audience and provider, and the time window with the same 30 s skew refresh
* tokens use. Every route that turns a state into navigation or a completion
* runs this first; the refusals are fixed codes and never echo the token.
*/
function verifyOAuthState(
config: HeimdallConfig,
keys: ReturnType<typeof createRuntimeKeyMaterial>,
token: string,
provider: Provider
): OAuthStateVerdict {
const verification = verifyJwt(token, keys.publicKey);
if (!verification.valid) {
return { ok: false, error: "invalid_state" };
}
const payload = parseOAuthStatePayload(verification.payload);
if (!payload || payload.iss !== config.issuer) {
return { ok: false, error: "invalid_state" };
}
if (payload.aud !== payload.provider || payload.provider !== provider) {
return { ok: false, error: "state_provider_mismatch" };
}
const now = nowEpochSeconds();
if (payload.nbf > now + 30 || payload.exp <= now - 30) {
return { ok: false, error: "state_expired" };
}
return { ok: true, payload };
}
const oauthBindingType = "heimdall_oauth_binding";
function oauthBindingCookieName(jti: string): string {
return `__Host-heimdall-oauth-${jti}`;
}
/** The signed claim that this browser launched this state: it is the cookie's value, and needs no store read to check. */
function signOAuthBinding(
config: HeimdallConfig,
keys: ReturnType<typeof createRuntimeKeyMaterial>,
state: OAuthStatePayload
): string {
return signJwt(
{ typ: oauthBindingType, iss: config.issuer, jti: state.jti, iat: nowEpochSeconds(), exp: state.exp },
keys
);
}
function oauthBindingHolds(
config: HeimdallConfig,
keys: ReturnType<typeof createRuntimeKeyMaterial>,
token: string | undefined,
state: OAuthStatePayload
): boolean {
if (!token) {
return false;
}
const verification = verifyJwt(token, keys.publicKey);
if (!verification.valid) {
return false;
}
const { typ, iss, jti, exp } = verification.payload;
return typ === oauthBindingType && iss === config.issuer && jti === state.jti && typeof exp === "number" && exp > nowEpochSeconds();
}
function setOAuthBindingCookie(reply: FastifyReply, state: OAuthStatePayload, value: string, maxAgeSeconds: number): void {
reply.setCookie(oauthBindingCookieName(state.jti), value, {
path: "/",
secure: true,
httpOnly: true,
sameSite: "lax",
maxAge: Math.max(0, maxAgeSeconds),
});
}
function oauthCallbackUrl(config: HeimdallConfig, provider: Provider): string {
return `${config.publicBaseUrl}/v1/oauth/${provider}/callback`;
}
function buildGrantFacts(grants: StoredCapabilityGrant[]): string[] {
return grants.map((grant) => grantFact(grant.capability));
}
@ -248,11 +354,17 @@ function relayedProviderText(value: string | undefined, bound: number): string |
return printable ? printable : undefined;
}
/**
* What may be recorded or answered about a caught error: its class and its
* code, each only when it is a short identifier. Never its message, which
* carries whatever the thrower quoted (a host, a database, a provider's body).
*/
/** A link callback the signed state's initiator does not license. Its message is static and safe to relay. */
export class LinkRefusal extends Error {
constructor(
readonly code: "link_initiator_missing" | "link_initiator_mismatch" | "link_initiator_unknown" | "identity_not_linked",
message: string
) {
super(message);
this.name = "LinkRefusal";
}
}
export function errorIdentity(error: unknown): { errorClass: string; errorCode: string | null } {
const code = (error as { code?: unknown } | null)?.code;
return {
@ -392,6 +504,8 @@ async function evaluateStoredEntitlements(options: {
appSlug: AppSlug;
accountId: string;
entitlementPolicies: OAuthEntitlementPolicy[];
/** A provider the caller has already evaluated with fresh tokens. */
skipProvider?: Provider;
}): Promise<string[]> {
const profile = getAppProfile(options.appSlug);
if (!profile) {
@ -401,7 +515,7 @@ async function evaluateStoredEntitlements(options: {
const facts: string[] = [];
for (const storedIdentity of linkedIdentities) {
if (!profile.entitlementSources.includes(storedIdentity.provider)) {
if (!profile.entitlementSources.includes(storedIdentity.provider) || storedIdentity.provider === options.skipProvider) {
continue;
}
@ -486,6 +600,13 @@ export async function startOAuthFlow(
* which one reaches the signed state token.
*/
trustedBrowserAttemptId?: string;
/**
* The account an explicit link binds to. Like `trustedBrowserAttemptId`
* it is a function argument, never a value parsed out of the request:
* the HTTP route sets it only from a verified Heimdall access token for
* this app (`resolveLinkAccount`), so a caller cannot name an account.
*/
linkAccountId?: string;
}
): Promise<HandlerResult> {
const { config, keys } = ctx;
@ -501,6 +622,16 @@ export async function startOAuthFlow(
};
}
// A connection target is app-backend authority: it rides only on connect
// starts that carry the app's own secret (gated below), and never on a
// sign_in start, which is open to any browser.
if (input.mode === "sign_in" && input.connection !== undefined) {
return {
statusCode: 400,
body: { error: "connection_not_allowed_in_sign_in", detail: "sign_in start must not carry a connection target." },
};
}
if (callbackMint(profile, provider, input.mode, input.connection) === "refused") {
return {
statusCode: 400,
@ -511,6 +642,48 @@ export async function startOAuthFlow(
};
}
const descriptor = providerCatalog[provider];
// A provider that never mints an account attaches an identity only through
// a bound link: the user's own access token, since the app's word does not
// name this person to Google. A link start without a verified Heimdall session would end
// in a refusal at the callback, so it is refused before the redirect.
// `sign_in` stays open: it signs in an identity already linked, and the
// callback refuses one that is not.
if (descriptor.firstLogin === "requires_link" && input.mode === "link" && !options?.linkAccountId) {
return {
statusCode: 401,
body: {
error: "link_session_required",
detail: `Linking ${descriptor.displayName} needs a Heimdall access token for ${profile.slug}: send it as a Bearer token.`,
},
};
}
// Link mode binds an identity to an existing account, so the account must
// be named by someone Heimdall can believe: the authenticated app backend.
// The name rides in the signed state and the callback holds the provider's
// identity to it. No other mode carries an initiator.
if (input.mode === "link") {
if (options?.linkAccountId) {
// The user's own access token for this app names the account.
if (input.initiatingAccountId !== undefined && input.initiatingAccountId !== options.linkAccountId) {
return { statusCode: 400, body: { error: "link_initiator_conflict", detail: "initiatingAccountId differs from the bearer's account." } };
}
} else {
if (!isAppCaller(caller) || caller.appSlug !== profile.slug) {
return { statusCode: 401, body: { error: "app_auth_required" } };
}
if (!input.initiatingAccountId) {
return { statusCode: 400, body: { error: "link_initiator_required", detail: "link mode needs initiatingAccountId." } };
}
if (!(await ctx.store.findAccountById(input.initiatingAccountId))) {
return { statusCode: 400, body: { error: "link_initiator_unknown", detail: "initiatingAccountId names no account." } };
}
}
} else if (input.initiatingAccountId !== undefined) {
return { statusCode: 400, body: { error: "initiator_only_in_link_mode" } };
}
if (!isAllowedReturnOrigin(profile, input.returnTo)) {
return {
statusCode: 400,
@ -554,7 +727,7 @@ export async function startOAuthFlow(
if (options?.trustedBrowserAttemptId && handoff.kind === "browser_completion") {
handoff = { kind: "browser_completion", attemptId: options.trustedBrowserAttemptId };
}
if (entitlementPolicy && (!isAppCaller(caller) || caller.appSlug !== profile.slug)) {
if ((entitlementPolicy || profile.startRequiresAppCaller || (input.mode === "connect" && input.connection !== undefined)) && (!isAppCaller(caller) || caller.appSlug !== profile.slug)) {
return { statusCode: 401, body: { error: "app_auth_required" } };
}
@ -566,7 +739,6 @@ export async function startOAuthFlow(
};
}
const callbackUrl = `${config.publicBaseUrl}/v1/oauth/${provider}/callback`;
const state = buildOAuthStateToken({
config,
provider,
@ -574,32 +746,69 @@ export async function startOAuthFlow(
mode: input.mode,
returnTo: input.returnTo,
connection: input.connection,
initiatorAccountId: options?.linkAccountId ?? input.initiatingAccountId,
handoff,
entitlementPolicy,
requestedScopes: input.requestedScopes,
keys,
});
return {
statusCode: 201,
body: {
provider,
appSlug: profile.slug,
mode: input.mode,
callbackUrl,
authorizationUrl: buildAuthorizationUrl({
provider,
clientId: providerConfig.clientId,
redirectUri: callbackUrl,
state: state.token,
requestedScopes: input.requestedScopes,
}),
callbackUrl: oauthCallbackUrl(config, provider),
authorizationUrl: `${config.publicBaseUrl}/v1/oauth/${provider}/launch?state=${encodeURIComponent(state.token)}`,
handoff,
stateToken: state.token,
stateExpiresAt: state.expiresAt,
},
};
}
/**
* The account a link start binds to: the one a live Heimdall access token for
* this app names. The token must verify under Heimdall's own key, be an access
* token for `appSlug`, be inside its validity window, and belong to a session
* the store still holds for that account at that revision. A bearer that fails
* any of these binds nothing: a link is explicit or it does not happen.
*/
async function resolveLinkAccount(
ctx: Pick<HeimdallRuntimeContext, "config" | "keys" | "store">,
appSlug: AppSlug,
authorization: string | undefined
): Promise<string | null> {
const token = /^Bearer\s+(\S+)$/i.exec(authorization ?? "")?.[1];
if (!token) return null;
const verification = verifyJwt(token, ctx.keys.publicKey);
if (!verification.valid) return null;
const claims = verification.payload;
const now = nowEpochSeconds();
if (
claims.typ !== "heimdall_access" ||
claims.iss !== ctx.config.issuer ||
claims.aud !== appSlug ||
typeof claims.account_id !== "string" ||
typeof claims.sid !== "string" ||
typeof claims.access_revision !== "number" ||
typeof claims.exp !== "number" ||
claims.exp <= now ||
(typeof claims.nbf === "number" && claims.nbf > now)
) {
return null;
}
const session = await ctx.store.findSession(appSlug, claims.sid);
if (
!session ||
session.accountId !== claims.account_id ||
session.accessRevision !== claims.access_revision ||
Date.parse(session.expiresAt) <= Date.now()
) {
return null;
}
return claims.account_id;
}
/**
* Session refresh, extracted for the same reason as startOAuthFlow: the HTTP
* route and the plane's `refreshAuth` (which owns Ghostlight identity) call
@ -738,6 +947,7 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
origin: true,
credentials: true,
});
await app.register(cookie);
app.get("/healthz", async () => ({
ok: true,
@ -822,6 +1032,7 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
summary: { type: "string" },
},
},
initiatingAccountId: { type: "string", minLength: 1 },
handoff: {
type: "object",
required: ["kind"],
@ -866,12 +1077,122 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
},
async (request, reply) => {
const caller = resolveAppCaller(config, request.body.appSlug, request.headers);
const result = await startOAuthFlow({ config, keys, store }, request.params.provider, caller, request.body);
let linkAccountId: string | undefined;
if (request.body.mode === "link") {
const bound = await resolveLinkAccount({ config, keys, store }, request.body.appSlug, request.headers.authorization);
if (bound === null && request.headers.authorization !== undefined) {
reply.code(401);
return { error: "invalid_link_session", detail: "The bearer is not a live Heimdall access token for this app." };
}
linkAccountId = bound ?? undefined;
}
const result = await startOAuthFlow({ config, keys, store }, request.params.provider, caller, request.body, {
...(linkAccountId !== undefined ? { linkAccountId } : {}),
});
reply.code(result.statusCode);
return result.body;
}
);
/**
* A refusal of a verified state. A browser (Accept: text/html) gets the
* handoff page linking back to the state's return_to, so the person is not
* left on a bare JSON body; the origin is re-checked against the app's
* allowlist here rather than trusting the signed value. Anything else gets
* the fixed JSON code.
*/
const refuseVerifiedState = (
request: FastifyRequest,
reply: FastifyReply,
state: OAuthStatePayload,
error: "state_not_bound" | "state_already_launched" | "state_already_used"
) => {
reply.code(400);
const profile = getAppProfile(state.app_slug);
if (prefersHtml(request.headers.accept) && profile && isAllowedReturnOrigin(profile, state.return_to)) {
reply.type("text/html; charset=utf-8");
return reply.send(
renderBrowserHandoffPage({
handoffKind: (state.handoff ?? { kind: "browser_completion" }).kind,
status: "error",
provider: state.provider,
appSlug: state.app_slug,
mode: state.mode,
returnTo: state.return_to,
error,
})
);
}
return { error };
};
app.get<{
Params: { provider: Provider };
Querystring: { state: string };
}>(
"/v1/oauth/:provider/launch",
{
schema: {
params: {
type: "object",
required: ["provider"],
additionalProperties: false,
properties: {
provider: { type: "string", enum: [...providers] },
},
},
querystring: {
type: "object",
required: ["state"],
additionalProperties: false,
properties: {
state: { type: "string", minLength: 1 },
},
},
},
},
async (request, reply) => {
const verified = verifyOAuthState(config, keys, request.query.state, request.params.provider);
if (!verified.ok) {
reply.code(400);
return { error: verified.error };
}
const state = verified.payload;
const clientId = config.providers[state.provider].clientId;
if (!clientId) {
reply.code(503);
return { error: "provider_not_configured", provider: state.provider, expectedEnv: providerExpectedEnv(state.provider) };
}
const first = await store.claimOAuthStateUse({
jti: state.jti,
phase: "launch",
appSlug: state.app_slug,
usedAt: new Date().toISOString(),
expiresAt: new Date(state.exp * 1000).toISOString(),
});
if (!first) {
return refuseVerifiedState(request, reply, state, "state_already_launched");
}
const oidc = providerCatalog[state.provider].oidc ? deriveOidcSecrets(state.jti, keys.privateKey) : undefined;
setOAuthBindingCookie(reply, state, signOAuthBinding(config, keys, state), state.exp - nowEpochSeconds());
reply.header("Cache-Control", "no-store");
reply.header("Referrer-Policy", "no-referrer");
return reply.redirect(
buildAuthorizationUrl({
provider: state.provider,
clientId,
redirectUri: oauthCallbackUrl(config, state.provider),
state: request.query.state,
requestedScopes: state.requested_scopes ?? undefined,
...(oidc ? { oidc: { nonce: oidc.nonce, codeChallenge: oidc.codeChallenge } } : {}),
}),
302
);
}
);
app.get<{
Params: { provider: Provider };
Querystring: { code?: string; state?: string; error?: string; error_description?: string };
@ -905,17 +1226,12 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
return { error: "missing_state" };
}
const verification = verifyJwt(request.query.state, keys.publicKey);
if (!verification.valid) {
const verified = verifyOAuthState(config, keys, request.query.state, request.params.provider);
if (!verified.ok) {
reply.code(400);
return { error: "invalid_state", detail: verification.error };
}
const statePayload = parseOAuthStatePayload(verification.payload);
if (!statePayload || statePayload.provider !== request.params.provider) {
reply.code(400);
return { error: "state_provider_mismatch" };
return { error: verified.error };
}
const statePayload = verified.payload;
const handoff = statePayload.handoff ?? { kind: "browser_completion" };
const denyBrowserAttempt = async (denialCode: string): Promise<void> => {
if (handoff.kind !== "browser_completion" || !handoff.attemptId) return;
@ -925,6 +1241,26 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
denialCode,
});
};
// The cookie is checked before the claim: a stranger without it must not be
// able to burn the state that the browser that launched it still needs.
// The attempt is denied, though: a launcher whose cookie lapsed can never
// complete this state, and its page polls an attempt that would otherwise
// sit pending until it expires.
if (!oauthBindingHolds(config, keys, request.cookies[oauthBindingCookieName(statePayload.jti)], statePayload)) {
await denyBrowserAttempt("state_not_bound");
return refuseVerifiedState(request, reply, statePayload, "state_not_bound");
}
setOAuthBindingCookie(reply, statePayload, "", 0);
const firstCallback = await store.claimOAuthStateUse({
jti: statePayload.jti,
phase: "callback",
appSlug: statePayload.app_slug,
usedAt: new Date().toISOString(),
expiresAt: new Date(statePayload.exp * 1000).toISOString(),
});
if (!firstCallback) {
return refuseVerifiedState(request, reply, statePayload, "state_already_used");
}
if (request.query.error) {
const providerError = relayedProviderText(request.query.error, 64);
@ -1023,45 +1359,48 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
if (mint === "refused") {
throw new Error("The signed state names a mode the provider has no role for in this app.");
}
const callbackDescriptor = providerCatalog[request.params.provider];
const oidc = callbackDescriptor.oidc ? deriveOidcSecrets(statePayload.jti, keys.privateKey) : undefined;
const tokenSet = await runtime.exchangeAuthorizationCode({
config,
code: request.query.code,
redirectUri: callbackUrl,
...(oidc ? { codeVerifier: oidc.codeVerifier } : {}),
});
const identity = await runtime.resolveIdentity({
config,
accessToken: tokenSet.accessToken,
tokenSet,
...(oidc ? { oidcNonce: oidc.nonce } : {}),
});
const nowIso = new Date().toISOString();
let account = await store.findAccountByLinkedIdentity(identity.provider, identity.providerUserId);
if (!account) {
const createAccountInput: CreateAccountInput = {
createdAt: nowIso,
lastSeenAt: nowIso,
};
const displayName = identity.displayName ?? identity.username;
if (displayName !== undefined) {
createAccountInput.displayName = displayName;
// One person, one account. A link attaches the provider identity to the
// account the signed state names, and to no other: an identity another
// account holds is a refusal, never a quiet sign-in as that account or a
// move. A provider that never mints an account refuses an identity no
// account holds.
const boundAccountId = statePayload.mode === "link" ? statePayload.initiator_account_id : null;
if (statePayload.mode === "link") {
if (!boundAccountId) {
throw new LinkRefusal("link_initiator_missing", "The signed link state names no initiating account.");
}
if (identity.primaryEmail !== undefined) {
createAccountInput.primaryEmail = identity.primaryEmail;
if (account && account.id !== boundAccountId) {
throw new LinkRefusal("link_initiator_mismatch", "This identity belongs to a different account than the one that started the link.");
}
account = await store.createAccount(createAccountInput);
} else {
const accountUpdates: { displayName?: string; primaryEmail?: string } = {};
const displayName = identity.displayName ?? identity.username;
if (displayName !== undefined) {
accountUpdates.displayName = displayName;
if (!account) {
account = await store.findAccountById(boundAccountId);
if (!account) {
throw new LinkRefusal("link_initiator_unknown", "The initiating account no longer exists.");
}
}
if (identity.primaryEmail !== undefined) {
accountUpdates.primaryEmail = identity.primaryEmail;
}
await store.touchAccount(account.id, nowIso, accountUpdates);
} else if (!account && callbackDescriptor.firstLogin === "requires_link") {
throw new LinkRefusal("identity_not_linked", "No account holds this identity; sign in another way and link it first.");
}
const buildLinkedIdentityInput = (accountId: string): UpsertLinkedIdentityInput => {
const linkedIdentityInput: UpsertLinkedIdentityInput = {
accountId: account.id,
accountId,
provider: identity.provider,
providerUserId: identity.providerUserId,
scopes: tokenSet.scope,
@ -1085,7 +1424,100 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
if (tokenSet.expiresAt !== undefined) {
linkedIdentityInput.tokenExpiresAt = tokenSet.expiresAt;
}
await store.upsertLinkedIdentity(linkedIdentityInput);
return linkedIdentityInput;
};
// A link is two-phase. The callback cannot tie itself to the browser
// that began the link (start is a server-to-server POST), so whoever
// completes the Google login here may be someone the initiator sent
// the URL to. Nothing is written to the account: the verified identity
// waits as a pending link, and only a confirm bearing the access token
// of the account that began the link attaches it.
if (boundAccountId && callbackDescriptor.firstLogin === "requires_link") {
const pendingLink = await store.createPendingLink({
appSlug: statePayload.app_slug,
accountId: boundAccountId,
provider: identity.provider,
providerUserId: identity.providerUserId,
identity: buildLinkedIdentityInput(boundAccountId),
returnTo: statePayload.return_to,
createdAt: nowIso,
expiresAt: new Date(Date.now() + config.completionTtlSeconds * 1000).toISOString(),
});
await store.createAuditEvent({
accountId: boundAccountId,
appSlug: statePayload.app_slug,
eventType: "link_pending",
// Never the pending link's id: it is the confirm secret.
eventPayloadJson: { provider: identity.provider, expiresAt: pendingLink.expiresAt },
createdAt: nowIso,
});
const pendingBody = {
status: "link_pending" as const,
provider: request.params.provider,
mode: statePayload.mode,
appSlug: statePayload.app_slug,
returnTo: statePayload.return_to,
pendingLinkId: pendingLink.id,
expiresAt: pendingLink.expiresAt,
confirmEndpoint: `${config.publicBaseUrl}/v1/apps/${statePayload.app_slug}/links/confirm`,
};
if (handoff.kind === "backend_callback") {
await maybeDeliverBackendHandoff(handoff, {
source: "heimdall",
kind: "oauth_result",
handoffKind: "backend_callback",
attemptId: handoff.attemptId,
connection: statePayload.connection,
...pendingBody,
});
}
if (prefersHtml(request.headers.accept)) {
reply.type("text/html; charset=utf-8");
return reply.send(
renderBrowserHandoffPage({
handoffKind: handoff.kind,
status: "success",
provider: request.params.provider,
appSlug: statePayload.app_slug,
mode: statePayload.mode,
returnTo: statePayload.return_to,
pendingLinkId: pendingLink.id,
...(handoff.attemptId ? { attemptId: handoff.attemptId } : {}),
})
);
}
reply.code(201);
return pendingBody;
}
if (!account) {
const createAccountInput: CreateAccountInput = {
createdAt: nowIso,
lastSeenAt: nowIso,
};
const displayName = identity.displayName ?? identity.username;
if (displayName !== undefined) {
createAccountInput.displayName = displayName;
}
if (identity.primaryEmail !== undefined) {
createAccountInput.primaryEmail = identity.primaryEmail;
}
account = await store.createAccount(createAccountInput);
} else {
const accountUpdates: { displayName?: string; primaryEmail?: string } = {};
const displayName = identity.displayName ?? identity.username;
// A link adds an identity; it does not rewrite the account's profile.
if (displayName !== undefined && statePayload.mode !== "link") {
accountUpdates.displayName = displayName;
}
if (identity.primaryEmail !== undefined && statePayload.mode !== "link") {
accountUpdates.primaryEmail = identity.primaryEmail;
}
await store.touchAccount(account.id, nowIso, accountUpdates);
}
await store.upsertLinkedIdentity(buildLinkedIdentityInput(account.id));
const entitlementEvaluation = await runtime.evaluateEntitlements({
config,
@ -1101,13 +1533,27 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
for (const snapshot of entitlementEvaluation.snapshots) {
await store.upsertEntitlementSnapshot(snapshot);
}
// The account's other linked identities count too: a member who
// returns through a provider that grants nothing (Google) keeps what
// Discord or Patreon grant. They are evaluated with the app's
// start-time policy, the same call the refresh route makes.
const linkedFacts = await evaluateStoredEntitlements({
config,
store,
tokenCustody,
oauthRuntimes,
appSlug: statePayload.app_slug,
accountId: account.id,
entitlementPolicies: statePayload.entitlement_policy ? [statePayload.entitlement_policy] : [],
skipProvider: identity.provider,
});
const grants = await store.listActiveGrants(account.id, statePayload.app_slug, nowIso);
const issueInput: IssueAccessClaimInput = {
appSlug: statePayload.app_slug,
accountId: account.id,
linkedIdentities: await store.listLinkedIdentitiesForAccount(account.id),
facts: [...entitlementEvaluation.facts, ...buildGrantFacts(grants)],
facts: [...new Set([...entitlementEvaluation.facts, ...linkedFacts]), ...buildGrantFacts(grants)],
...(mint === "custody_only" ? { withoutCapabilities: true } : {}),
};
if (account.displayName !== undefined) {
@ -1246,6 +1692,7 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
accountId: account.id,
sessionId: issued.session.sessionId,
returnTo: statePayload.return_to,
...(statePayload.connection ? { targetId: statePayload.connection.targetId } : {}),
createdAt: nowIso,
expiresAt: completionExpiresAt,
payloadJson: completionPayload as unknown as Record<string, unknown>,
@ -1307,12 +1754,16 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
// JSON.parse quotes the provider's response body. The audit event
// keeps the error's class, its code, and a provider's HTTP status.
const status = (error as { status?: unknown }).status;
const failure = "The provider callback could not be completed.";
// A link refusal is a typed decision with a static message; every
// other failure keeps the opaque answer above.
const refusal = error instanceof LinkRefusal ? error : null;
const failureCode = refusal?.code ?? "oauth_callback_failed";
const failure = refusal?.message ?? "The provider callback could not be completed.";
const nowIso = new Date().toISOString();
await denyBrowserAttempt("oauth_callback_failed");
await denyBrowserAttempt(failureCode);
await store.createAuditEvent({
appSlug: statePayload.app_slug,
eventType: "oauth_callback_failed",
eventType: refusal ? "oauth_link_refused" : "oauth_callback_failed",
eventPayloadJson: {
provider: request.params.provider,
mode: statePayload.mode,
@ -1335,7 +1786,7 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
mode: statePayload.mode,
returnTo: statePayload.return_to,
connection: statePayload.connection,
error: "oauth_callback_failed",
error: failureCode,
errorDescription: failure,
});
} catch {}
@ -1352,15 +1803,15 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
mode: statePayload.mode,
returnTo: statePayload.return_to,
...(handoff.kind === "backend_callback" ? { attemptId: handoff.attemptId } : {}),
error: "oauth_callback_failed",
error: failureCode,
errorDescription: failure,
})
);
}
reply.code(502);
reply.code(refusal ? 403 : 502);
return {
error: "oauth_callback_failed",
error: failureCode,
detail: failure,
provider: request.params.provider,
appSlug: statePayload.app_slug,
@ -1370,6 +1821,58 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
}
);
// Second phase of a link. The callback only recorded the verified identity;
// this writes it, and only for the account whose access token began the
// link. The bearer is the same proof start demanded, so a stranger who
// completed the provider login (or holds the id) attaches nothing.
app.post<{ Params: { appSlug: AppSlug }; Body: { pendingLinkId: string } }>(
"/v1/apps/:appSlug/links/confirm",
{
schema: {
params: {
type: "object",
required: ["appSlug"],
additionalProperties: false,
properties: { appSlug: { type: "string", enum: [...appSlugs] } },
},
body: {
type: "object",
required: ["pendingLinkId"],
additionalProperties: false,
properties: { pendingLinkId: { type: "string", minLength: 1 } },
},
},
},
async (request, reply) => {
const accountId = await resolveLinkAccount({ config, keys, store }, request.params.appSlug, request.headers.authorization);
if (!accountId) {
reply.code(401);
return { error: "invalid_link_session", detail: "The bearer is not a live Heimdall access token for this app." };
}
const nowIso = new Date().toISOString();
const pending = await store.consumePendingLink(request.params.appSlug, request.body.pendingLinkId, accountId, nowIso);
if (!pending) {
reply.code(410);
return { error: "invalid_or_expired_pending_link" };
}
const holder = await store.findAccountByLinkedIdentity(pending.provider, pending.providerUserId);
if (holder && holder.id !== accountId) {
reply.code(409);
return { error: "identity_linked_elsewhere" };
}
await store.upsertLinkedIdentity({ ...pending.identity, accountId, updatedAt: nowIso });
await store.createAuditEvent({
accountId,
appSlug: pending.appSlug,
eventType: "link_confirmed",
eventPayloadJson: { provider: pending.provider },
createdAt: nowIso,
});
reply.code(201);
return { status: "linked", provider: pending.provider, accountId, returnTo: pending.returnTo };
}
);
app.post<{ Params: { appSlug: AppSlug }; Body: RedeemAuthCompletionRequest }>(
"/v1/apps/:appSlug/auth-completions/redeem",
{
@ -1388,12 +1891,38 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
additionalProperties: false,
properties: {
completionCode: { type: "string", minLength: 1 },
targetId: { type: "string", minLength: 1 },
},
},
},
},
async (request, reply) => {
const nowIso = new Date().toISOString();
// Sign-in and connect completions are redeemed by the browser flow, so
// the route stays open to it. A link completion was minted for an
// initiator the authenticated app named at start; only that app's
// backend redeems it. Redeem is also where the connection target is
// held to what the signed state named. Both refusals read the row
// without consuming it, so a wrong caller or target cannot burn the code.
const pending = await store.findAuthCompletion(request.params.appSlug, request.body.completionCode);
if (pending && !pending.consumedAt && pending.expiresAt > nowIso) {
if (pending.mode === "link" && !resolveAppCaller(config, request.params.appSlug, request.headers)) {
reply.code(401);
return { error: "app_auth_required" };
}
if (pending.targetId !== undefined && pending.targetId !== request.body.targetId) {
await store.createAuditEvent({
accountId: pending.accountId,
sessionId: pending.sessionId,
appSlug: pending.appSlug,
eventType: "auth_completion_target_refused",
eventPayloadJson: { provider: pending.provider, mode: pending.mode },
createdAt: nowIso,
});
reply.code(403);
return { error: "completion_target_mismatch" };
}
}
const completion = await store.consumeAuthCompletion(request.params.appSlug, request.body.completionCode, nowIso);
if (!completion) {

View file

@ -39,7 +39,16 @@ export interface BackendHandoffErrorPayload extends BackendHandoffBase {
errorDescription?: string;
}
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,6 +12,8 @@ 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;
}
@ -47,6 +49,8 @@ function buildBrowserFallbackUrl(payload: BrowserHandoffPayload): string {
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);

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];
@ -73,6 +73,8 @@ export interface OAuthStartRequest {
mode: OAuthMode;
returnTo: string;
connection?: OAuthConnectionBinding;
/** Link mode only: the account the authenticated app says is linking. Carried in the signed state. */
initiatingAccountId?: string;
handoff?: OAuthHandoff;
requestedScopes?: string[];
entitlementPolicy?: OAuthEntitlementPolicy;
@ -92,8 +94,11 @@ export interface OAuthStatePayload {
mode: OAuthMode;
return_to: string;
connection: OAuthConnectionBinding | null;
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 {
@ -105,6 +110,8 @@ export interface LinkedIdentityInput {
export interface RedeemAuthCompletionRequest {
completionCode: string;
/** Required when the completion was minted for a connection target; must equal it. */
targetId?: string;
}
export type HeimdallAuthAttemptStatus = "pending" | "completed" | "denied" | "consumed" | "expired";

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

@ -1,4 +1,3 @@
import path from "node:path";
import { resolveWriteLease, WriteLeaseNotHeldError } from "./process-write-lease.js";
import { buildApp } from "./app.js";
import { loadConfig } from "./config.js";
@ -7,14 +6,16 @@ import { publishHeimdallOdinState } from "./odin-publication.js";
import { startHeimdallPrivateCommandPlane } from "./private-command-plane.js";
import type { CultNetOperationServer } from "cultnet-ts";
import { createHeimdallRuntimePulse, buildHeimdallHealthDetail, publishHeimdallVerseState } from "./verse-state.js";
import {
buildPresenceStatement,
nextPublisherSequence,
readRuntimeBundleFacts,
type RuntimeBundleFacts,
} from "./runtime-presence.js";
import { openPresenceAuthority } from "./runtime-presence.js";
const config = loadConfig();
// Under Idunn the launch's presence keys, bundle facts and publisher sequence
// open here, before anything listens. A launch-contract fault throws, uncaught,
// and the process exits non-zero with no listener: Idunn fails Warming in
// seconds rather than waiting on a candidate that can never sign.
const presenceAuthority = config.idunnRuntimeBundlePath
? await openPresenceAuthority(config)
: undefined;
const app = await buildApp({ config });
const versePulseIntervalMs = 60_000;
const healthPulseIntervalMs = 10_000;
@ -70,13 +71,6 @@ async function publishVerseState(): Promise<void> {
await publishHeimdallOdinState(config, pulse);
}
/**
* Read once and keep: the bundle is Idunn's immutable record of this launch and
* cannot change while the process lives, so re-reading it every pulse would be
* work that can only ever return the same answer or fail.
*/
let runtimeBundleFacts: RuntimeBundleFacts | undefined;
async function publishHealthState(): Promise<void> {
const pulse = createHeimdallRuntimePulse(config);
@ -94,32 +88,17 @@ async function publishHealthState(): Promise<void> {
? buildHeimdallHealthDetail(config, pulse)
: `Heimdall candidate warming; ${lease.reason}`;
if (!config.idunnRuntimeBundlePath) {
if (!presenceAuthority) {
// Outside Idunn there is no launch to attest and nothing that admits the
// statement; publishing is simply not part of that world.
return;
}
runtimeBundleFacts ??= await readRuntimeBundleFacts(config.idunnRuntimeBundlePath);
const observedAtUnixMillis = Date.parse(pulse.updatedAt);
const presence = await buildPresenceStatement({
bundle: runtimeBundleFacts,
state: lease.mayWrite ? "active" : "warming",
detail,
boundEndpoint: `http://${config.host}:${config.port}`,
// A warming candidate holds no lease and must not claim one; the contract
// refuses the pair, and Idunn needs the warming statement to promote it.
writeLeaseSha256: null,
observedAtUnixMillis,
publisherSequence: await nextPublisherSequence(
path.join(config.dataRoot, "runtime-presence-sequence"),
),
providerHealthIdentityPath: config.providerHealthIdentityPath,
});
const state = lease.mayWrite ? "active" : "warming";
const presence = presenceAuthority.sign(state, detail, Date.parse(pulse.updatedAt));
await publishIdunnRudpHealth(config, {
daemonId: config.daemonId,
state: lease.mayWrite ? "active" : "warming",
state,
detail,
observedAt: pulse.updatedAt,
presence,

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

@ -7,7 +7,7 @@ import {
sign,
type KeyObject,
} from "node:crypto";
import { readFileSync } from "node:fs";
import { closeSync, fstatSync, readSync } from "node:fs";
import { chmod, readFile } from "node:fs/promises";
import { SingleFileMessagePackBackingStore, type CultCacheEnvelope } from "@gamecult/cultcache-ts";
import { readPrivateStoreRecord } from "./idunn-store.js";
@ -71,7 +71,7 @@ export async function openOrEnrollProviderHealthIdentity(path: string): Promise<
if (existing.length !== 1) {
throw new Error("Provider-health identity store must contain exactly one record.");
}
return decodePrivateIdentity(existing[0]!.payload, await readMachineId());
return decodePrivateIdentity(existing[0]!.payload, await readMachineId(), "bin");
}
const PRESENCE_IDENTITY_FD_NAME = "gamecult-runtime-presence-identity";
@ -124,19 +124,50 @@ export function passedDescriptor(
*/
export async function openProviderHealthIdentity(
path: string,
env: NodeJS.ProcessEnv = process.env
env: NodeJS.ProcessEnv = process.env,
machineId: () => Promise<string> = readMachineId
): Promise<PrivateIdentity> {
const descriptor = passedDescriptor(PRESENCE_IDENTITY_FD_NAME, env);
if (descriptor === undefined) {
if (env.GAMECULT_IDUNN_RUNTIME_BUNDLE) {
// Inside an Idunn generation a self-enrolled key is one Idunn does not
// trust, and its private store would land in a state root the next
// generation must share. The descriptor is the only identity there.
throw new Error(
`Idunn runtime bundle is set but the ${PRESENCE_IDENTITY_FD_NAME} descriptor was not passed; refusing to self-enroll`
);
}
return openOrEnrollProviderHealthIdentity(path);
}
const payload = readPrivateStoreRecord(
readFileSync(descriptor),
readPassedDescriptor(descriptor),
"Idunn runtime presence identity"
);
return decodePrivateIdentity(payload, await readMachineId());
// The descriptor's writer is the Rust owner, whose key fields are integer arrays.
return decodePrivateIdentity(payload, await machineId(), "int-array");
}
/**
* Read a passed descriptor whole, from offset 0, and close it. A descriptor is
* a one-shot input: reading it again starts at the offset the last read left,
* which is the end, so the owner reads once at open and keeps what it decoded.
*/
export function readPassedDescriptor(descriptor: number): Buffer {
try {
const size = fstatSync(descriptor).size;
const bytes = Buffer.alloc(size);
let read = 0;
while (read < size) {
const count = readSync(descriptor, bytes, read, size - read, read);
if (count === 0) break;
read += count;
}
return bytes.subarray(0, read);
} finally {
closeSync(descriptor);
}
}
export function signProviderHealthPayload(identity: PrivateIdentity, payload: Uint8Array): Uint8Array {
@ -168,23 +199,58 @@ export function providerHealthPublicIdentity(identity: PrivateIdentity): {
return { identityId: identity.identityId, publicKeyHex: Buffer.from(identity.publicKey).toString("hex") };
}
function decodePrivateIdentity(payload: Uint8Array, machineId: string): PrivateIdentity {
/**
* Which writer produced the record. The self-enrolling store this process
* writes keeps key fields as MessagePack bin; the Rust owner (cultnet-rs
* service_identity.rs) serialises a Vec<u8> without serde_bytes, so its key
* fields are arrays of integers. Each source is read in its own shape and the
* other is refused.
*/
type KeyShape = "bin" | "int-array";
function keyBytes(value: unknown, shape: KeyShape, field: string): Uint8Array {
const bytes =
shape === "bin"
? value instanceof Uint8Array
? value
: undefined
: Array.isArray(value) &&
value.every((byte) => typeof byte === "number" && Number.isInteger(byte) && byte >= 0 && byte <= 255)
? Uint8Array.from(value as number[])
: undefined;
if (bytes === undefined || bytes.length !== 32) {
throw new Error(
`Provider-health identity key shape check failed: ${field} is not a 32-byte ${shape === "bin" ? "binary" : "integer-array"} key field.`
);
}
return bytes;
}
function decodePrivateIdentity(payload: Uint8Array, machineId: string, shape: KeyShape): PrivateIdentity {
const value = decode(payload);
if (!Array.isArray(value) || value.length !== 10) {
throw new Error("Provider-health identity payload is malformed.");
throw new Error("Provider-health identity schema check failed: payload is not a 10-field record.");
}
const [schema, identityId, publicKey, protectedSeed, protector, binding, version] = value;
if (
schema !== PRIVATE_SCHEMA ||
typeof identityId !== "string" ||
!(publicKey instanceof Uint8Array) || publicKey.length !== 32 ||
!(protectedSeed instanceof Uint8Array) || protectedSeed.length !== 32 ||
protector !== "linux_file_mode_machine_id_binding" ||
binding !== protectorBinding(machineId) ||
version !== "v1" ||
identityId !== deriveIdentityId(publicKey)
) {
throw new Error("Provider-health identity does not belong to this host or profile.");
const [schema, identityId, publicKeyField, protectedSeedField, protector, binding, version] = value;
if (schema !== PRIVATE_SCHEMA) {
throw new Error("Provider-health identity schema check failed: unexpected schema id.");
}
if (typeof identityId !== "string") {
throw new Error("Provider-health identity id check failed: id is not a string.");
}
const publicKey = keyBytes(publicKeyField, shape, "public key");
const protectedSeed = keyBytes(protectedSeedField, shape, "protected seed");
if (protector !== "linux_file_mode_machine_id_binding") {
throw new Error("Provider-health identity protector check failed: unexpected protector.");
}
if (binding !== protectorBinding(machineId)) {
throw new Error("Provider-health identity binding check failed: not bound to this host's machine id.");
}
if (version !== "v1") {
throw new Error("Provider-health identity version check failed: unexpected version.");
}
if (identityId !== deriveIdentityId(publicKey)) {
throw new Error("Provider-health identity id check failed: id does not derive from the public key.");
}
const seed = xorSeed(protectedSeed, binding);
const privateKey = privateKeyFromSeed(seed);

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,6 +161,7 @@ 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",
},
@ -138,6 +173,8 @@ export function buildAuthorizationUrl(options: {
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);
@ -148,6 +185,14 @@ export function buildAuthorizationUrl(options: {
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

@ -19,8 +19,6 @@
import path from "node:path";
import { createHash, createPrivateKey, type KeyObject } from "node:crypto";
import { readFileSync } from "node:fs";
import { readFile, writeFile, rename } from "node:fs/promises";
import { decode } from "@msgpack/msgpack";
import {
@ -34,11 +32,14 @@ import {
type RuntimePresenceState,
} from "cultnet-ts";
import type { HeimdallConfig } from "./config.js";
import { readIdunnStoreRecord } from "./idunn-store.js";
import {
openProviderHealthIdentity,
passedDescriptor,
readPassedDescriptor,
signWithIdentity,
type PrivateIdentity,
} from "./provider-health-identity.js";
const PKCS8_ED25519_SEED_PREFIX = Buffer.from("302e020100300506032b657004220420", "hex");
@ -178,70 +179,37 @@ export async function readRuntimeBundleFacts(bundlePath: string): Promise<Runtim
}
/**
* Open the activation signing key Idunn passed as a descriptor.
* The publisher sequence a process stamps on its presence statements.
*
* The credential is exactly 32 bytes: the raw Ed25519 seed. It is passed as a
* parent-only descriptor and not as a path, so only the process Idunn launched
* can produce this half of the proof.
* Odin compares `publisher_sequence` only against stored presence of the same
* activation, and Idunn mints a fresh activation for every start, continuity
* restarts included. So the sequence need only strictly increase within this
* process; nothing has to survive a restart, and nothing is written to the
* state root. Muninn derives its sequence the same way: the observation clock,
* never below the previous value plus one.
*/
export function openActivationSigner(env: NodeJS.ProcessEnv = process.env): KeyObject | undefined {
const descriptor = passedDescriptor(IDUNN_RUNTIME_ACTIVATION_CREDENTIAL_NAME, env);
if (descriptor === undefined) {
return undefined;
}
const seed = readFileSync(descriptor);
if (seed.length !== 32) {
throw new Error("Idunn runtime activation credential is not a 32-byte seed");
}
return createPrivateKey({
key: Buffer.concat([PKCS8_ED25519_SEED_PREFIX, seed]),
format: "der",
type: "pkcs8",
});
}
export class PublisherSequence {
private last = 0;
/**
* The publisher sequence must strictly increase for the life of a signer
* identity, because Idunn treats a repeat or a regression as a replay and
* refuses the observation.
*
* An in-process counter is therefore not enough: a restart would reset it and
* every statement afterwards would be refused until the count climbed past the
* old high-water mark. It lives beside the target's state instead, and is
* advanced before use.
*/
export async function nextPublisherSequence(sequencePath: string): Promise<number> {
let current = 0;
try {
const raw = (await readFile(sequencePath, "utf8")).trim();
const parsed = Number(raw);
if (Number.isSafeInteger(parsed) && parsed > 0) {
current = parsed;
}
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
throw error;
next(observedAtUnixMillis: number): number {
if (!Number.isSafeInteger(observedAtUnixMillis)) {
throw new Error("observedAtUnixMillis is not a safe integer");
}
this.last = Math.max(observedAtUnixMillis, this.last + 1);
return this.last;
}
const next = current + 1;
// Written through a temporary file: a torn write here would look like a
// regression and cost the target every future observation.
const temporary = `${sequencePath}.next`;
await writeFile(temporary, `${next}\n`, { mode: 0o600 });
await rename(temporary, sequencePath);
return next;
}
export interface PresenceStatementInputs {
bundle: RuntimeBundleFacts;
identity: PrivateIdentity;
activationKey: KeyObject;
state: RuntimePresenceState;
detail: string;
boundEndpoint: string | null;
writeLeaseSha256: string | null;
observedAtUnixMillis: number;
publisherSequence: number;
providerHealthIdentityPath: string;
env?: NodeJS.ProcessEnv;
}
export interface PresenceStatement {
@ -250,24 +218,14 @@ export interface PresenceStatement {
}
/**
* Build and dual-sign one presence statement.
* Build and dual-sign one presence statement from keys and facts already open.
*
* Returns the target alongside the bytes because the document key must equal
* the signed target; Odin refuses the pair when they differ, and that is the
* one part of the envelope the transport cannot infer.
*/
export async function buildPresenceStatement(
inputs: PresenceStatementInputs,
): Promise<PresenceStatement> {
const env = inputs.env ?? process.env;
const activationKey = openActivationSigner(env);
if (activationKey === undefined) {
throw new Error(
"Idunn passed no runtime activation credential, so this process cannot prove it is the " +
"incarnation Idunn started",
);
}
const identity = await openProviderHealthIdentity(inputs.providerHealthIdentityPath, env);
export function buildPresenceStatement(inputs: PresenceStatementInputs): PresenceStatement {
const { identity } = inputs;
const presence: RuntimePresenceHealth = {
target: inputs.bundle.target,
@ -295,7 +253,7 @@ export async function buildPresenceStatement(
const proof = runtimePresenceProofPayload(presence);
const signature = signWithIdentity(identity, runtimePresenceProviderSigningMessage(proof));
const activationSignature = signWithIdentity(
{ ...identity, privateKey: activationKey },
{ ...identity, privateKey: inputs.activationKey },
runtimePresenceActivationSigningMessage(proof),
);
@ -304,3 +262,72 @@ export async function buildPresenceStatement(
payload: encodeRuntimePresenceHealth(presence, signature, activationSignature),
};
}
export interface PresenceAuthority {
sign(
state: RuntimePresenceState,
detail: string,
observedAtUnixMillis: number,
): PresenceStatement;
}
/**
* Open everything this launch's presence statements stand on, once, before the
* process listens: Idunn's bundle facts, the activation seed and the presence
* identity (each descriptor is read whole and closed), and the one publisher
* sequence. It owns them for the life of the process; a pulse supplies only its
* state, detail and observation time.
*
* A launch-contract fault (a missing or malformed bundle, descriptor or
* identity) throws here, so the process exits before it accepts a request and
* Idunn fails Warming at once, instead of a listener that can never sign.
*/
export async function openPresenceAuthority(
config: Pick<HeimdallConfig, "idunnRuntimeBundlePath" | "host" | "port" | "providerHealthIdentityPath">,
env: NodeJS.ProcessEnv = process.env,
machineId?: () => Promise<string>,
): Promise<PresenceAuthority> {
if (!config.idunnRuntimeBundlePath) {
throw new Error("no Idunn runtime bundle path: presence has no launch to attest");
}
const bundle = await readRuntimeBundleFacts(config.idunnRuntimeBundlePath);
const activationDescriptor = passedDescriptor(IDUNN_RUNTIME_ACTIVATION_CREDENTIAL_NAME, env);
if (activationDescriptor === undefined) {
throw new Error(
"Idunn passed no runtime activation credential, so this process cannot prove it is the " +
"incarnation Idunn started",
);
}
const seed = readPassedDescriptor(activationDescriptor);
if (seed.length !== 32) {
throw new Error("Idunn runtime activation credential is not a 32-byte seed");
}
const activationKey = createPrivateKey({
key: Buffer.concat([PKCS8_ED25519_SEED_PREFIX, seed]),
format: "der",
type: "pkcs8",
});
const identity = await openProviderHealthIdentity(config.providerHealthIdentityPath, env, machineId);
const boundEndpoint = `http://${config.host}:${config.port}`;
const sequence = new PublisherSequence();
return {
sign: (state, detail, observedAtUnixMillis) =>
buildPresenceStatement({
bundle,
identity,
activationKey,
state,
detail,
boundEndpoint,
// A warming candidate holds no lease and must not claim one; the
// contract refuses the pair, and Idunn needs the warming statement to
// promote it.
writeLeaseSha256: null,
observedAtUnixMillis,
publisherSequence: sequence.next(observedAtUnixMillis),
}),
};
}

View file

@ -3,7 +3,10 @@ import { type AppSlug, type LinkedIdentityInput, type Provider } from "../contra
import {
type CreateAccountInput,
type CreateAuthAttemptInput,
type ClaimOAuthStateUseInput,
type CreateAuthCompletionInput,
type CreatePendingLinkInput,
type StoredPendingLink,
type CreateAuditEventInput,
type CreateCapabilityGrantInput,
type CredentialDecision,
@ -21,6 +24,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,7 +53,10 @@ 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>();
@ -390,6 +399,7 @@ export class InMemoryStore implements HeimdallStore {
accountId: input.accountId,
sessionId: input.sessionId,
returnTo: input.returnTo,
...(input.targetId ? { targetId: input.targetId } : {}),
createdAt: input.createdAt,
expiresAt: input.expiresAt,
payloadJson: clone(input.payloadJson),
@ -402,6 +412,11 @@ export class InMemoryStore implements HeimdallStore {
return clone(completion);
}
async findAuthCompletion(appSlug: AppSlug, code: string): Promise<StoredAuthCompletion | null> {
const completion = this.authCompletions.get(code);
return completion && completion.appSlug === appSlug ? clone(completion) : null;
}
async consumeAuthCompletion(appSlug: AppSlug, code: string, at: string): Promise<StoredAuthCompletion | null> {
const completion = this.authCompletions.get(code);
if (!completion) {
@ -424,6 +439,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));

View file

@ -5,7 +5,10 @@ import { CREATE_SCHEMA_SQL, REQUIRED_COLUMNS, REQUIRED_KEYS, REQUIRED_TABLES } f
import {
type CreateAccountInput,
type CreateAuthAttemptInput,
type ClaimOAuthStateUseInput,
type CreateAuthCompletionInput,
type CreatePendingLinkInput,
type StoredPendingLink,
type CreateAuditEventInput,
type CreateCapabilityGrantInput,
type CredentialDecision,
@ -82,12 +85,26 @@ interface AuthCompletionRow extends QueryResultRow {
account_id: string;
session_id: string;
return_to: string;
target_id: string | null;
payload_json: Record<string, unknown>;
created_at: string | Date;
expires_at: string | Date;
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;
@ -231,6 +248,24 @@ function mapSessionRow(row: SessionRow): StoredSession {
};
}
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,
@ -249,6 +284,10 @@ function mapAuthCompletionRow(row: AuthCompletionRow): StoredAuthCompletion {
completion.attemptId = row.attempt_id;
}
if (row.target_id) {
completion.targetId = row.target_id;
}
if (row.consumed_at) {
completion.consumedAt = normalizeTimestamp(row.consumed_at);
}
@ -378,6 +417,8 @@ export const STORE_TABLE_PRIVILEGES: Readonly<Record<string, readonly ("SELECT"
auth_attempts: ["SELECT", "INSERT", "UPDATE"],
private_command_receipts: ["SELECT", "INSERT"],
auth_completions: ["SELECT", "INSERT", "UPDATE"],
pending_links: ["SELECT", "INSERT", "UPDATE"],
oauth_state_uses: ["INSERT"],
capability_grants: ["SELECT", "INSERT"],
entitlement_snapshots: ["SELECT", "INSERT", "UPDATE"],
audit_events: ["INSERT"],
@ -1004,9 +1045,9 @@ export class PostgresStore implements HeimdallStore {
`
INSERT INTO auth_completions (
code, attempt_id, app_slug, provider, mode, account_id, session_id,
return_to, payload_json, created_at, expires_at
return_to, target_id, payload_json, created_at, expires_at
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
RETURNING *
`,
[
@ -1018,6 +1059,7 @@ export class PostgresStore implements HeimdallStore {
input.accountId,
input.sessionId,
input.returnTo,
input.targetId ?? null,
JSON.stringify(input.payloadJson),
input.createdAt,
input.expiresAt,
@ -1027,6 +1069,14 @@ export class PostgresStore implements HeimdallStore {
return mapAuthCompletionRow(expectRow(result.rows[0], "createAuthCompletion"));
}
async findAuthCompletion(appSlug: AppSlug, code: string): Promise<StoredAuthCompletion | null> {
const result = await this.pool.query<AuthCompletionRow>(
"SELECT * FROM auth_completions WHERE code = $1 AND app_slug = $2",
[code, appSlug]
);
return result.rowCount ? mapAuthCompletionRow(expectRow(result.rows[0], "findAuthCompletion")) : null;
}
async consumeAuthCompletion(appSlug: AppSlug, code: string, at: string): Promise<StoredAuthCompletion | null> {
const result = await this.pool.query<AuthCompletionRow>(
`
@ -1061,6 +1111,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(
`

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,
@ -106,6 +119,10 @@ CREATE INDEX IF NOT EXISTS auth_completions_lookup_idx
-- redeem the completion it created without ever needing the code.
ALTER TABLE auth_completions ADD COLUMN IF NOT EXISTS attempt_id TEXT;
-- The connection target the signed state named (connection.targetId). Redeem
-- compares it against the target the redeeming app states.
ALTER TABLE auth_completions ADD COLUMN IF NOT EXISTS target_id TEXT;
-- One attempt handle may bind at most one unconsumed completion (R21.1): a
-- second completion for the same handle would let the by-attempt UPDATE in
-- consumeAuthCompletionByAttempt match more than one row, silently consuming
@ -119,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,

View file

@ -159,18 +159,29 @@ export interface StoredAuthCompletion {
accountId: string;
sessionId: string;
returnTo: string;
/** The connection target the signed state named, if any; compared at redeem. */
targetId?: string;
createdAt: string;
expiresAt: string;
consumedAt?: string;
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
* always mints `code` itself; a caller cannot choose it.
*/
attemptId?: string;
targetId?: string;
appSlug: AppSlug;
provider: Provider;
mode: OAuthMode;
@ -182,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;
@ -263,6 +296,8 @@ export interface HeimdallStore {
createPrivateCommandReceipt(input: CreatePrivateCommandReceiptInput): Promise<StoredPrivateCommandReceipt>;
findPrivateCommandReceipt(appSlug: AppSlug, idempotencyKey: string): Promise<StoredPrivateCommandReceipt | null>;
createAuthCompletion(input: CreateAuthCompletionInput): Promise<StoredAuthCompletion>;
/** Read a completion without consuming it, so redeem can refuse a mismatch without burning the code. */
findAuthCompletion(appSlug: AppSlug, code: string): Promise<StoredAuthCompletion | null>;
consumeAuthCompletion(appSlug: AppSlug, code: string, at: string): Promise<StoredAuthCompletion | null>;
/**
* Consume a completion by its correlated attempt handle instead of its
@ -271,6 +306,23 @@ 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>;
}

View file

@ -16,11 +16,11 @@
{
"id": "eureka/joystick-h1",
"hypothesis": "One locked refresh primitive plus a Joystick OAuth provider in Heimdall lets StreamPixels ingest Joystick without ever presenting a provider refresh token.",
"status": "active",
"status": "accepted",
"artifacts": [
"state/map.yaml"
],
"notes": "Campaign joystick:campaign:self, target joystick:target:r1. Hands owns code in a separate worktree."
"notes": "Merged as PR GameCult/Heimdall#1 (d51fa6b), deployed to Yggdrasil 2026-10-19 15:51Z; gamecult-ops 210a1af. Campaign joystick:campaign:self."
}
]
}

View file

@ -35,3 +35,5 @@
{"ts":"2026-08-22T02:01:00+00:00","type":"deployment","status":"accepted","note":"Yggdrasil runs Heimdall 1086aee01169bf60e8a492b2740db1c6f3e8cabf with CultLib 5cefa0db0079a8e3ee22f29d7b9e6e5aa60912a9. Odin b4f9a2e95f0b41cebdeddc49223781d1d3c7b42a returns Heimdall's provider, command boundary, Eve plugin, and transport profile under unique catalog keys. Ghostlight b515ca90c25573005a616244143803b37f2d06ec accepted canonical auth.begin through the Odin-resolved boundary; only advertised Discord and Heimdall navigation origins reached the browser."}
{"ts": "2026-09-06T22:12:03+00:00", "type": "design", "status": "accepted", "note": "App slugs are no longer compile-time. AppSlug is a string; membership is a registry lookup, or isAppSlug shape validation where no store is reachable (the offline verifier). Capability rules are data (anyOf over Heimdall's own fact vocabulary), replacing a per-profile evaluateSharedCapabilities function that could not be accepted from a stranger. POST /v1/apps registers in RFC 7591 shape, gated on GC_ACCESS_APP_REGISTRATION_SECRET and closed until it is set. Built-in slugs are reserved so a registration cannot shadow a shipped app's audience. Motivation: a creator self-hosting Erycina alongside Heimdall would otherwise maintain a patched auth server to add one slug."}
{"ts": "2026-09-08T12:20:12+00:00", "type": "security", "status": "accepted", "note": "epiphany/heimdall-caller-identity, second Hands pass: closed the two Soul-proven holes. (1) auth-completions redeem code is now always store-minted (never handoff.attemptId); attemptId survives only as a separate correlation field on StoredAuthCompletion/CreateAuthCompletionInput, and a new store.consumeAuthCompletionByAttempt(appSlug, attemptId, at) lets the private command plane's completeAuth redeem by the handle it already owns instead of forging an HTTP self-call with completionCode=handle. (2) startOAuthFlow now refuses any returnTo whose origin is not in AppProfile.allowedReturnOrigins (repixelizer/streampixels/bifrost -> <slug>.gamecult.org, ghostlight -> yggdrasil.gamecult.org per its docs/app-profiles doc and existing test fixtures; a registered app's allowlist is derived from its stored redirectUris origins). AppCaller is now branded with an unexported Symbol (app-caller.ts); private-command-plane.ts's two { appSlug } literals are gone, replaced by app-caller.ts's callerFromOpenedEnvelope. Deleted: validateAppRegistration, AppRegistrationRequest, RegistrationProblem, AppRegistrationError, HeimdallStore.registerApp (writer). Kept: findRegisteredApp/listRegisteredApps/resolveAppProfile fallback -- AppSlug is a plain string (contracts.ts), registered_apps is a real extension point for a slug Heimdall was never rebuilt for, and isBuiltInApp's only production call site (listAppProfiles) depends on that fallback existing; a row is now provisioned by direct SQL (or InMemoryStore.seedRegisteredApp in tests), not by any app-facing writer. Deleted the two appSlug===\"ghostlight\" branches in private-command-plane.ts; the requirement is now AppProfile.requiredEntitlementPolicyKind, checked generically through resolveAppProfile. tsc --noEmit clean; vitest 94 passed/1 pre-existing-skip, only pre-existing failure is tests/odin-publication.test.ts (Cannot find module 'cultcache-ts', present on main too).", "branch": "epiphany/heimdall-caller-identity"}
{"ts": "2026-10-19T15:56:24+00:00", "type": "design", "status": "accepted", "note": "Refresh is now one locked primitive for every provider (replaces unserialized refreshLinkedIdentityCredentialIfNeeded); Joystick is managed-only (Heimdall-held credential, no viewer sign-in until joystick:follow_up:joystick-viewer-signin-public-bot). Merged in PR #1, live at d51fa6b; campaign joystick:campaign:self.", "branch": "eureka/joystick-h1"}
{"ts": "2026-10-19T16:03:16+00:00", "type": "verification", "status": "accepted", "note": "Live d51fa6b (H1) verified by a real human sign-in: the operator's StreamPixels Twitch sign-in through Heimdall start/callback, 2026-10-19 ('Twitch auth worked fine'); a streampixels session was created 16:02:33Z, after the 15:51Z switch. Joystick consent round trip still open: joystick:follow_up:h1-joystick-consent-round-trip."}

View file

@ -59,8 +59,8 @@ data_flow:
- reject per-request auth round-trip chatter
active_subgoals:
- ACTIVE, M1-critical (operator ruling 2026-10-18): Joystick H1, campaign joystick:campaign:self (branch eureka/joystick-h1). Joystick OAuth provider, joystick as a streampixels viewer provider, one locked refresh primitive for every provider replacing refreshLinkedIdentityCredentialIfNeeded.
- OPEN, behind H1: pressure the deployed Ghostlight gamecult.heimdall.access integration with two- and eight-account human canaries.
- Joystick H1 is merged and live (PR GameCult/Heimdall#1, d51fa6b; campaign joystick:campaign:self). Open follow-ups by id: joystick:follow_up:h1-joystick-consent-round-trip (next), cross-process-failed-refresh-marker, connect-start-needs-app-auth, heimdall-idunn-onboarding, joystick-viewer-signin-public-bot. StreamPixels S0/S1 run in other sessions.
- OPEN, behind the H1 live round-trips: pressure the deployed Ghostlight gamecult.heimdall.access integration with two- and eight-account human canaries.
- Keep the shared architecture explicit and separate from app profiles.
- Preserve the default boundary that app-domain data remains app-owned.
- Align the plan to Heimdall as a Yggdrasil-hosted authority service.
@ -95,7 +95,7 @@ accepted_design:
landed_baseline:
- Heimdall publishes its redacted provider, command boundary, Eve plugin, and transport profile through Odin under globally unique record keys; Ghostlight resolves that boundary instead of owning a direct endpoint.
- Yggdrasil runs Heimdall 1086aee01169bf60e8a492b2740db1c6f3e8cabf with CultLib 5cefa0db0079a8e3ee22f29d7b9e6e5aa60912a9; live Ghostlight auth.begin through Odin discovery is accepted.
- Yggdrasil runs Heimdall d51fa6b (hand-deployed 2026-10-19 15:51Z; gamecult-ops 210a1af holds backup, rollback and provenance; prior 1086aee) with CultLib 5cefa0db0079a8e3ee22f29d7b9e6e5aa60912a9; live Ghostlight auth.begin through Odin discovery is accepted.
- The Heimdall access plugin, persistent OAuth attempts, encrypted private CultNet commands, conformance fixtures, and browser reference adapter are implemented and locally verified.
- Exact-session refresh validation and logout revocation are implemented; stale or racing refresh cannot resurrect a revoked session.
- Shared architecture extracted into its own repo and renamed to Heimdall.
@ -126,5 +126,5 @@ rejected_paths:
reason: reusing auth mechanics does not justify centralizing app-domain audience or product data
current_status:
summary: Active work is Joystick H1 (campaign joystick:campaign:self). Last recorded Yggdrasil deploy (not verifiable from this repo; HEAD is far past it, see git): Yggdrasil runs the gamecult.heimdall.access plugin and encrypted loopback begin, complete, refresh, and logout plane at 1086aee01169bf60e8a492b2740db1c6f3e8cabf. Odin returns all four redacted discovery records, Ghostlight resolves the boundary through Odin, and live anonymous auth.begin is accepted without exposing claims or secrets.
next_action: Land Joystick H1 per campaign joystick:campaign:self and target joystick:target:r1 (worktree and branch eureka/joystick-h1). Then, still open, run separate-account KLTST completion, refresh, logout, privacy, and stale-attempt canaries through the deployed Ghostlight Eve host; keep provider credential rotation on the operator list because prior tool output exposed OAuth secrets.
summary: Yggdrasil runs Heimdall release d51fa6b (hand-deployed 2026-10-19 15:51Z, gamecult-ops 210a1af), which includes Joystick H1 (PR #1). The plugin and encrypted loopback begin, complete, refresh and logout plane, the Odin discovery records and Ghostlight resolution were verified at 1086aee (2026-08-22) and are not re-verified at d51fa6b.
next_action: Run joystick:follow_up:h1-joystick-consent-round-trip, then StreamPixels S0/S1 (other sessions). Still open behind that: separate-account KLTST completion, refresh, logout, privacy, and stale-attempt canaries through the deployed Ghostlight Eve host; keep provider credential rotation on the operator list because prior tool output exposed OAuth secrets.

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();
});
@ -363,7 +366,7 @@ describe("R21.2: the WeakSet brand at every consumer", () => {
if (line.includes("caller.appSlug")) expect(line, `${file}: ${line.trim()}`).toContain("isAppCaller(caller)");
}
}
expect(consumers).toEqual(["app.ts: reads=2 guards=2"]);
expect(consumers).toEqual(["app.ts: reads=3 guards=3"]);
});
it("a caller minted for app A is refused where app B is expected, over HTTP and by direct call", async () => {
@ -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,47 @@
import Fastify, { type FastifyReply, type FastifyRequest } from "fastify";
import { afterEach, describe, expect, it } from "vitest";
// GHSA-p68q-wchp-6fh7 (fastify < 5.12.2): a malformed request target under one
// plugin prefix, with a method that has no route in the main router, was
// dispatched to the not-found handler of a sibling plugin, skipping that
// handler's preHandler. This pins the framework fix; it is not about a Heimdall
// route.
describe("fastify malformed URL under sibling not-found handlers", () => {
const apps: Array<ReturnType<typeof Fastify>> = [];
afterEach(async () => {
await Promise.all(apps.splice(0).map((app) => app.close()));
});
async function build() {
const app = Fastify();
apps.push(app);
await app.register(async (pub) => {
pub.get("/ping", async () => "pong");
}, { prefix: "/public" });
await app.register(async (priv) => {
priv.setNotFoundHandler(
{
preHandler: async (request: FastifyRequest, reply: FastifyReply) => {
if (!request.headers.authorization) return reply.code(401).send({ error: "auth" });
},
},
(_request: FastifyRequest, reply: FastifyReply) => reply.code(404).send({ secret: "PRIVATE-TENANT-DATA" }),
);
priv.get("/x", async () => "x");
}, { prefix: "/private" });
return app;
}
it("fails closed with 400 and never reaches another prefix's not-found handler", async () => {
const app = await build();
const response = await app.inject({ method: "DELETE", url: "/public/%zz" });
expect(response.body).not.toContain("PRIVATE-TENANT-DATA");
expect(response.statusCode).toBe(400);
});
it("still runs the authenticated not-found preHandler for a well-formed URL", async () => {
const app = await build();
const response = await app.inject({ method: "DELETE", url: "/private/nothing" });
expect(response.statusCode).toBe(401);
});
});

64
tests/fixtures/fake-idunn-rudp.ts vendored Normal file
View file

@ -0,0 +1,64 @@
/**
* A stand-in for Idunn's health endpoint: it accepts each RUDP connect, acks
* the frame that follows and keeps every delivered frame. Heimdall opens a
* fresh socket and session per publication, so sessions are kept per remote.
*/
import dgram from "node:dgram";
import {
CultNetRudpSession,
decodeRudpPacket,
encodeRudpPacket,
type CultNetTransportFrame,
} from "cultnet-ts";
export interface FakeIdunnRudp {
port: number;
frames: CultNetTransportFrame[];
/** Resolves with the nth delivered frame (0-based) once it has arrived. */
frame(index: number): Promise<CultNetTransportFrame>;
close(): void;
}
export async function startFakeIdunnRudp(): Promise<FakeIdunnRudp> {
const server = dgram.createSocket("udp4");
const sessions = new Map<string, CultNetRudpSession>();
const frames: CultNetTransportFrame[] = [];
const waiting: Array<{ index: number; resolve: (frame: CultNetTransportFrame) => void }> = [];
server.on("message", (wire, remote) => {
const packet = decodeRudpPacket(wire);
const key = `${remote.address}:${remote.port}`;
if (packet.packetType === "connect") {
const session = new CultNetRudpSession({
connectionId: 0x1d0d0001,
initialSequence: 1,
resendDelayMs: 100,
});
sessions.set(key, session);
server.send(encodeRudpPacket(session.acceptConnect(packet, Date.now())), remote.port, remote.address);
return;
}
const session = sessions.get(key);
if (!session) return;
const result = session.receive(packet, Date.now());
if (result.reply) server.send(encodeRudpPacket(result.reply), remote.port, remote.address);
for (const frame of result.delivered) {
frames.push(frame);
for (const waiter of waiting.filter((entry) => entry.index === frames.length - 1)) waiter.resolve(frame);
}
if (result.delivered.length > 0) {
server.send(encodeRudpPacket(session.createAck()), remote.port, remote.address);
}
});
await new Promise<void>((resolve) => server.bind(0, "127.0.0.1", resolve));
return {
port: server.address().port,
frames,
frame: (index) =>
frames[index]
? Promise.resolve(frames[index]!)
: new Promise((resolve) => waiting.push({ index, resolve })),
close: () => server.close(),
};
}

View file

@ -0,0 +1 @@
* -text

11
tests/fixtures/idunn-runtime/README.md vendored Normal file
View file

@ -0,0 +1,11 @@
# Idunn runtime fixtures
Written by the Rust owner, not by Heimdall's tests. Copied byte for byte from CultLib
`217a9480`, `packages/cultnet-ts/test/fixtures/idunn-runtime/web` (`expected.cc`,
`activation.cc`, `activation-key.seed`, `provider-identity.credential`) and that directory's
`machine-id`. CultLib regenerates them with `packages/cultnet-rs/examples/idunn_runtime_fixture.rs`
(its own README has the Yggdrasil command); the provider identity is bound to `machine-id`, so a
test that decodes it supplies that machine id instead of `/etc/machine-id`.
Every key here is a test key. `provider-identity.credential` is the shape Idunn passes on the
presence-identity descriptor: key fields are 32-integer arrays, which only the Rust owner writes.

View file

@ -0,0 +1 @@
¯ÂOd×f¯l*oµ‘ÀFªv‰n<E280B0>hLûÑpšS´p

Binary file not shown.

BIN
tests/fixtures/idunn-runtime/expected.cc vendored Normal file

Binary file not shown.

View file

@ -0,0 +1 @@
c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1

Binary file not shown.

61
tests/fixtures/presence-descriptors.ts vendored Normal file
View file

@ -0,0 +1,61 @@
/**
* What Idunn hands a generation for runtime presence: the bundle directory, a
* parent-only identity store and an activation seed, the last two passed as
* descriptors named in LISTEN_FDNAMES. The bytes are the Rust owner's, copied
* from CultLib (see idunn-runtime/README.md); every key in them is a test key.
*/
import { closeSync, openSync } from "node:fs";
import { chmod, copyFile, mkdir, readFile } from "node:fs/promises";
import path from "node:path";
import { IDUNN_RUNTIME_ACTIVATION_CREDENTIAL_NAME } from "cultnet-ts";
export const idunnRuntimeFixtures = path.join(import.meta.dirname, "idunn-runtime");
const FIXTURE_FILES = [
"expected.cc",
"activation.cc",
"activation-key.seed",
"provider-identity.credential",
"machine-id",
] as const;
/** The machine id the fixture identity is bound to, in place of /etc/machine-id. */
export const fixtureMachineId = async (dir: string = idunnRuntimeFixtures): Promise<string> =>
(await readFile(path.join(dir, "machine-id"), "utf8")).trim();
/** Copy the fixtures into `dir`, readable by any uid (the children run as others). */
export async function writePresenceFixtures(dir: string): Promise<void> {
await mkdir(dir, { recursive: true });
await chmod(dir, 0o755);
for (const name of FIXTURE_FILES) {
const target = path.join(dir, name);
await copyFile(path.join(idunnRuntimeFixtures, name), target);
await chmod(target, 0o644);
}
}
/** Open both descriptors and name them the way systemd's LISTEN_FDNAMES does. */
export function openPresenceDescriptors(dir: string): { env: NodeJS.ProcessEnv; close: () => void } {
const fds = [
[openSync(path.join(dir, "provider-identity.credential"), "r"), "gamecult-runtime-presence-identity"],
[openSync(path.join(dir, "activation-key.seed"), "r"), IDUNN_RUNTIME_ACTIVATION_CREDENTIAL_NAME],
] as const;
const names: string[] = [];
for (const [fd, name] of fds) {
while (names.length < fd - 3) names.push(`pad-${names.length}`);
names[fd - 3] = name;
}
return {
env: { LISTEN_FDNAMES: names.join(":") },
// The authority closes each descriptor it reads; this releases only what it did not.
close: () =>
fds.forEach(([fd]) => {
try {
closeSync(fd);
} catch {
// already closed by the reader
}
}),
};
}

View file

@ -0,0 +1,57 @@
/**
* One Idunn generation, as a process: it runs as the uid and gid its parent
* gave it, under UMask=0007, and does what Heimdall does to a state root on a
* pulse. The parent test spawns two of these under different uids that share
* a group.
*
* argv: <role: first|next> <stateRoot> <fixtureDir>
*/
import { readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { loadConfig } from "../../src/config.js";
import { openPresenceAuthority } from "../../src/runtime-presence.js";
import { fixtureMachineId, openPresenceDescriptors } from "./presence-descriptors.js";
import { createHeimdallRuntimePulse, publishHeimdallVerseState } from "../../src/verse-state.js";
const [role, stateRoot, fixtureDir] = process.argv.slice(2) as [string, string, string];
process.umask(0o007);
const config = loadConfig({
GC_ACCESS_BASE_URL: "https://heimdall.example.test",
GC_ACCESS_DATA_ROOT: stateRoot,
});
async function publishPresence(): Promise<void> {
const descriptors = openPresenceDescriptors(fixtureDir);
try {
const authority = await openPresenceAuthority(
{
idunnRuntimeBundlePath: fixtureDir,
host: config.host,
port: config.port,
providerHealthIdentityPath: path.join(stateRoot, "provider-health-identity.cc"),
},
descriptors.env,
() => fixtureMachineId(fixtureDir),
);
authority.sign("warming", "generation probe", Date.now());
} finally {
descriptors.close();
}
}
if (role === "next") {
// Generation N+1 reads what N left before it replaces it.
const previous = await readFile(config.cultCachePath);
if (previous.length === 0) throw new Error("previous generation's snapshot is empty");
}
await publishHeimdallVerseState(config, createHeimdallRuntimePulse(config));
await publishPresence();
if (role === "first") {
// The 0600 counter the 2026-09-07 generation left behind. Nothing reads it
// now; it stays as the stale fixture the next generation must live beside.
await writeFile(path.join(stateRoot, "runtime-presence-sequence"), "41\n", { mode: 0o600 });
}

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);
});
});

320
tests/idunn-boot.test.ts Normal file
View file

@ -0,0 +1,320 @@
import { execFile, spawn, type ChildProcess } from "node:child_process";
import { createPublicKey, createPrivateKey, verify } from "node:crypto";
import dgram from "node:dgram";
import { chmod, copyFile, mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises";
import { closeSync, openSync } from "node:fs";
import net from "node:net";
import os from "node:os";
import path from "node:path";
import { promisify } from "node:util";
import { decode, encode } from "@msgpack/msgpack";
import {
RUNTIME_PRESENCE_SLOT,
parseCultNetMessage,
runtimePresenceActivationSigningMessage,
runtimePresenceProviderSigningMessage,
} from "cultnet-ts";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { startFakeIdunnRudp, type FakeIdunnRudp } from "./fixtures/fake-idunn-rudp.js";
import { idunnRuntimeFixtures } from "./fixtures/presence-descriptors.js";
const run = promisify(execFile);
const GENERATION_UID = 61184;
const STATE_GID = 4242;
const ED25519_SPKI_PREFIX = Buffer.from("302a300506032b6570032100", "hex");
const PKCS8_ED25519_SEED_PREFIX = Buffer.from("302e020100300506032b657004220420", "hex");
// The launch needs root (to pick the uid and bind a machine-id) and a mount
// namespace (so the fixture machine-id does not replace the host's). The
// stopgap's node container has both; anywhere else the test steps aside.
const canLaunch =
process.getuid?.() === 0 &&
(await run("unshare", ["--mount", "--", "true"]).then(
() => true,
() => false
));
/**
* The artifact under test. By default the esbuild bundle `pnpm bundle` makes,
* run by this process's node; a runbook proves a sealed release by naming its
* bundle and node here instead.
*/
const bundleOverride = process.env.HEIMDALL_BOOT_BUNDLE;
const nodeOverride = process.env.HEIMDALL_BOOT_NODE;
async function freePort(): Promise<number> {
return new Promise((resolve, reject) => {
const probe = net.createServer();
probe.once("error", reject);
probe.listen(0, "127.0.0.1", () => {
const { port } = probe.address() as net.AddressInfo;
probe.close(() => resolve(port));
});
});
}
interface Launch {
child: ChildProcess;
stderr: () => string;
exited: Promise<number | null>;
candidatePort: number;
}
describe.skipIf(!canLaunch)("the bundle booted as Idunn launches it", () => {
let base: string;
let bundle: string;
let node: string;
let idunn: FakeIdunnRudp;
let odin: dgram.Socket;
let odinPort: number;
const launched: ChildProcess[] = [];
beforeAll(async () => {
base = await mkdtemp(path.join(os.tmpdir(), "heimdall-boot-"));
await chmod(base, 0o755);
if (bundleOverride) {
bundle = bundleOverride;
} else {
await run("corepack", ["pnpm", "bundle"], { cwd: path.join(import.meta.dirname, "..") });
bundle = path.join(base, "heimdall.mjs");
await copyFile(path.join(import.meta.dirname, "..", "dist", "heimdall"), bundle);
await chmod(bundle, 0o644);
}
node = nodeOverride ?? process.execPath;
idunn = await startFakeIdunnRudp();
odin = dgram.createSocket("udp4");
await new Promise<void>((resolve) => odin.bind(0, "127.0.0.1", resolve));
odinPort = odin.address().port;
}, 120_000);
afterAll(() => {
for (const child of launched) child.kill("SIGKILL");
idunn?.close();
odin?.close();
});
/** Credential files as Idunn holds them: readable only by the parent. */
async function credentials(dir: string, identity: Buffer | undefined) {
await mkdir(dir, { recursive: true });
const seed = path.join(dir, "activation-key.seed");
await copyFile(path.join(idunnRuntimeFixtures, "activation-key.seed"), seed);
await chmod(seed, 0o400);
let identityFile: string | undefined;
if (identity) {
identityFile = path.join(dir, "provider-identity.credential");
await writeFile(identityFile, identity, { mode: 0o400 });
}
return { seed, identityFile };
}
/** Start the bundle as the generation unit does, with the fixture machine-id bound over the host's. */
async function launch(label: string, identity: Buffer | undefined): Promise<Launch> {
const dir = path.join(base, label);
await mkdir(dir, { recursive: true });
await chmod(dir, 0o755);
// Idunn's runtime bundle: the two records, read-only.
const bundleDir = path.join(dir, "bundle");
await mkdir(bundleDir);
for (const name of ["expected.cc", "activation.cc"]) {
await copyFile(path.join(idunnRuntimeFixtures, name), path.join(bundleDir, name));
await chmod(path.join(bundleDir, name), 0o444);
}
await chmod(bundleDir, 0o555);
const machineId = path.join(dir, "machine-id");
await copyFile(path.join(idunnRuntimeFixtures, "machine-id"), machineId);
await chmod(machineId, 0o444);
const stateRoot = path.join(dir, "state");
await mkdir(stateRoot);
await run("chown", [`0:${STATE_GID}`, stateRoot]);
await chmod(stateRoot, 0o2770);
const heimdall = path.join(dir, "heimdall.mjs");
await copyFile(bundle, heimdall);
await chmod(heimdall, 0o444);
const files = await credentials(path.join(dir, "credentials"), identity);
const candidatePort = await freePort();
const fds = [openSync(files.seed, "r")];
const names = ["gamecult-idunn-runtime-activation-key"];
if (files.identityFile) {
fds.push(openSync(files.identityFile, "r"));
names.push("gamecult-runtime-presence-identity");
}
const env = {
PATH: process.env.PATH ?? "",
LISTEN_FDS: String(fds.length),
LISTEN_FDNAMES: names.join(":"),
GAMECULT_IDUNN_CANDIDATE_BIND: `127.0.0.1:${candidatePort}`,
GAMECULT_IDUNN_RUNTIME_BUNDLE: bundleDir,
GAMECULT_IDUNN_PROCESS_WRITE_LEASE: path.join(dir, "process-write-lease.cc"),
GC_ACCESS_BASE_URL: "https://heimdall.test",
GC_ACCESS_ISSUER: "https://heimdall.test",
GC_ACCESS_STORAGE_BACKEND: "memory",
GC_ACCESS_IDUNN_RUDP_HEALTH: `127.0.0.1:${idunn.port}`,
GC_ACCESS_IDUNN_HEALTH_CONTRACT: "heimdall.cultnet-rudp-provider-health",
GC_ACCESS_ODIN_CULTMESH_URI: `rudp://127.0.0.1:${odinPort}`,
CULTMESH_URI_ODIN_RUDP: `rudp://127.0.0.1:${odinPort}`,
GC_ACCESS_SIGNING_PRIVATE_KEY_BOOTSTRAP: "1",
GC_ACCESS_SIGNING_PRIVATE_KEY_PATH: path.join(stateRoot, "signing.pem"),
GC_ACCESS_SIGNING_KEY_ID: "boot-test",
};
// uid, gid and umask as Idunn's DynamicUser unit gives them. The fixture
// machine-id is bound only inside this mount namespace.
const script =
`mount --bind ${machineId} /etc/machine-id && ` +
`exec setpriv --reuid ${GENERATION_UID} --regid ${STATE_GID} --clear-groups ` +
`sh -c 'umask 0007; cd ${dir}; exec ${node} heimdall.mjs --state-root ${stateRoot}'`;
const child = spawn("unshare", ["--mount", "--", "sh", "-c", script], {
env,
stdio: ["ignore", "ignore", "pipe", ...fds],
});
launched.push(child);
fds.forEach((fd) => closeSync(fd));
let stderr = "";
child.stderr!.on("data", (chunk: Buffer) => {
stderr += chunk.toString();
});
const exited = new Promise<number | null>((resolve) => child.once("exit", (code) => resolve(code)));
return { child, stderr: () => stderr, exited, candidatePort };
}
async function answers(port: number): Promise<number | undefined> {
return fetch(`http://127.0.0.1:${port}/.well-known/jwks.json`).then(
(response) => response.status,
() => undefined
);
}
it("signs warming presence on every pulse and answers HTTP", async () => {
const credential = await readFile(path.join(idunnRuntimeFixtures, "provider-identity.credential"));
const generation = await launch("signs", credential);
// Startup publication, then one per 10 s pulse. A publication fault is
// reported as it is logged rather than after the frames never arrive.
const publicationFault = new Promise<never>((_resolve, reject) => {
const watch = setInterval(() => {
if (generation.stderr().includes(" failed on ")) {
clearInterval(watch);
reject(new Error(`publication failed; stderr: ${generation.stderr()}`));
}
}, 200);
void idunn.frame(2).then(() => clearInterval(watch));
});
const exitedEarly = generation.exited.then((code) => {
throw new Error(`bundle exited ${code}; stderr: ${generation.stderr()}`);
});
// The first statement is the startup publication, which follows the listen
// directly; the 10 s pulse loop has not yet fired.
await Promise.race([
idunn.frame(0),
publicationFault,
exitedEarly,
new Promise<never>((_resolve, reject) =>
setTimeout(() => reject(new Error(`no presence at startup; stderr: ${generation.stderr()}`)), 8_000)
),
]);
await Promise.race([idunn.frame(2), publicationFault, exitedEarly]);
expect(await answers(generation.candidatePort)).toBe(200);
// Public keys of the two proofs, from the fixtures' test keys.
const store = decode(credential) as unknown[][];
const record = decode(store[0]![2] as Uint8Array) as unknown[];
const providerKey = createPublicKey({
key: Buffer.concat([ED25519_SPKI_PREFIX, Buffer.from(record[2] as number[])]),
format: "der",
type: "spki",
});
const activationKey = createPublicKey(
createPrivateKey({
key: Buffer.concat([
PKCS8_ED25519_SEED_PREFIX,
await readFile(path.join(idunnRuntimeFixtures, "activation-key.seed")),
]),
format: "der",
type: "pkcs8",
})
);
let previousSequence = 0;
for (const frame of idunn.frames.slice(0, 3)) {
const message = parseCultNetMessage(decode(frame.payload), "cultnet.schema.v0");
if (message.schemaVersion !== "cultnet.document_put_raw.v0") throw new Error("not a raw document put");
const fields = decode(message.document.payload) as unknown[];
expect(fields[RUNTIME_PRESENCE_SLOT.state]).toBe("warming");
const unsigned = [...fields];
unsigned[RUNTIME_PRESENCE_SLOT.signature] = new Uint8Array();
unsigned[RUNTIME_PRESENCE_SLOT.activationSignature] = new Uint8Array();
const proof = encode(unsigned);
expect(
verify(
null,
runtimePresenceProviderSigningMessage(proof),
providerKey,
fields[RUNTIME_PRESENCE_SLOT.signature] as Uint8Array
)
).toBe(true);
expect(
verify(
null,
runtimePresenceActivationSigningMessage(proof),
activationKey,
fields[RUNTIME_PRESENCE_SLOT.activationSignature] as Uint8Array
)
).toBe(true);
const sequence = fields[RUNTIME_PRESENCE_SLOT.publisherSequence] as number;
expect(sequence).toBeGreaterThan(previousSequence);
previousSequence = sequence;
}
expect(generation.stderr()).not.toContain("failed on startup");
expect(generation.stderr()).not.toContain("failed on interval");
generation.child.kill("SIGKILL");
}, 90_000);
it.each([
["the identity descriptor is not passed", undefined, "gamecult-runtime-presence-identity"],
["the identity is damaged", "damaged", "identity id check"],
])("exits before listening when %s", async (_name, damage, expected) => {
const credential = await readFile(path.join(idunnRuntimeFixtures, "provider-identity.credential"));
const store = decode(credential) as unknown[][];
const record = decode(store[0]![2] as Uint8Array) as unknown[];
const publicKey = [...(record[2] as number[])];
const original = { publicKeyHex: Buffer.from(publicKey).toString("hex"), id: record[1] as string, binding: record[5] as string };
let identity: Buffer | undefined;
if (damage) {
publicKey[0] = publicKey[0]! ^ 1;
record[2] = publicKey;
identity = Buffer.from(encode([[store[0]![0], store[0]![1], encode(record), store[0]![3], store[0]![4]]]));
}
const started = Date.now();
const generation = await launch(damage ? "damaged" : "no-identity", identity);
const code = await Promise.race([
generation.exited,
new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), 5_000)),
]);
expect(code, generation.stderr()).not.toBe("timeout");
expect(code).not.toBe(0);
expect(Date.now() - started).toBeLessThan(6_000);
expect(await answers(generation.candidatePort)).toBeUndefined();
expect(generation.stderr()).toContain(expected);
// No byte of the credential: not the key bytes, not the id, not the binding.
expect(generation.stderr()).not.toContain(original.publicKeyHex);
expect(generation.stderr()).not.toContain(original.id.slice(0, 16));
expect(generation.stderr()).not.toContain(original.binding.slice(-16));
}, 30_000);
});

View file

@ -1,18 +1,12 @@
import dgram from "node:dgram";
import { mkdtemp } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { decode } from "@msgpack/msgpack";
import {
CultNetRudpSession,
decodeRudpPacket,
encodeRudpPacket,
parseCultNetMessage,
type CultNetTransportFrame,
} from "cultnet-ts";
import { parseCultNetMessage } from "cultnet-ts";
import { describe, expect, it, vi } from "vitest";
import { loadConfig } from "../src/config.js";
import { publishIdunnRudpHealth } from "../src/idunn-rudp-health.js";
import { startFakeIdunnRudp } from "./fixtures/fake-idunn-rudp.js";
vi.mock("node:fs/promises", async (importOriginal) => {
const original = await importOriginal<typeof import("node:fs/promises")>();
@ -27,37 +21,12 @@ vi.mock("node:fs/promises", async (importOriginal) => {
describe("Heimdall Idunn health publication", () => {
it("completes the canonical RUDP handshake before publishing its signed frame", async () => {
const server = dgram.createSocket("udp4");
const session = new CultNetRudpSession({
connectionId: 0x1d0d0001,
initialSequence: 1,
resendDelayMs: 100,
});
let resolveFrame!: (frame: CultNetTransportFrame) => void;
const frameReceived = new Promise<CultNetTransportFrame>((resolve) => {
resolveFrame = resolve;
});
server.on("message", (wire, remote) => {
const packet = decodeRudpPacket(wire);
if (packet.packetType === "connect") {
server.send(encodeRudpPacket(session.acceptConnect(packet, Date.now())), remote.port, remote.address);
return;
}
const result = session.receive(packet, Date.now());
if (result.reply) server.send(encodeRudpPacket(result.reply), remote.port, remote.address);
for (const frame of result.delivered) resolveFrame(frame);
if (result.delivered.length > 0) {
server.send(encodeRudpPacket(session.createAck()), remote.port, remote.address);
}
});
await new Promise<void>((resolve) => server.bind(0, "127.0.0.1", resolve));
const server = await startFakeIdunnRudp();
try {
const tmp = await mkdtemp(path.join(os.tmpdir(), "heimdall-idunn-rudp-"));
const address = server.address();
const config = loadConfig({
GC_ACCESS_IDUNN_RUDP_HEALTH: `127.0.0.1:${address.port}`,
GC_ACCESS_IDUNN_RUDP_HEALTH: `127.0.0.1:${server.port}`,
GC_ACCESS_PROVIDER_HEALTH_IDENTITY_PATH: path.join(tmp, "provider-health-identity.cc"),
});
const observedAt = "2026-08-22T00:45:00.000Z";
@ -78,7 +47,7 @@ describe("Heimdall Idunn health publication", () => {
observedAt,
presence,
});
const frame = await frameReceived;
const frame = await server.frame(0);
const message = parseCultNetMessage(decode(frame.payload), "cultnet.schema.v0");
expect(frame.channelId).toBe("schema");

265
tests/link-binding.test.ts Normal file
View file

@ -0,0 +1,265 @@
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { buildApp } from "../src/app.js";
import { type HeimdallConfig } from "../src/config.js";
import { type OAuthProviderRuntime } from "../src/oauth.js";
import { createRuntimeKeyMaterial, signJwt, verifyJwt } from "../src/signing.js";
import { InMemoryStore } from "../src/store/index.js";
import { launchFlow } from "./oauth-flow.js";
function createTestConfig(): HeimdallConfig {
return {
serviceName: "heimdall",
host: "127.0.0.1",
port: 4100,
workspaceRoot: "E:/Projects/Heimdall",
dataRoot: "E:/Projects/Heimdall/.heimdall-data",
cultCachePath: "E:/Projects/Heimdall/.heimdall-data/cultcache/heimdall.service.cc",
publicBaseUrl: "https://heimdall.gamecult.org",
issuer: "https://heimdall.gamecult.org",
daemonId: "yggdrasil-heimdall",
idunnRudpHealth: undefined,
idunnHealthContract: "heimdall.cultnet-rudp-provider-health",
providerHealthIdentityPath: "E:/Projects/Heimdall/.heimdall-data/provider-health-identity.cc",
sessionTtlSeconds: 3600,
refreshTtlSeconds: 60 * 60 * 24 * 30,
stateTtlSeconds: 600,
completionTtlSeconds: 300,
bootstrapSigningPrivateKeyOnMissing: false,
tokenEncryptionKeyBase64: Buffer.alloc(32, 7).toString("base64"),
appSharedSecrets: {
streampixels: "streampixels-secret",
repixelizer: "repixelizer-secret",
bifrost: "bifrost-secret",
},
appBackendCallbacks: {
bifrost: ["https://bifrost.gamecult.org/auth/heimdall/callback"],
repixelizer: ["https://repixelizer.gamecult.org/api/auth/heimdall/callback"],
},
storage: {
backend: "memory",
applySchemaOnStartup: true,
},
providers: {
discord: { clientId: "discord-client", clientSecret: "discord-secret" },
patreon: { clientId: "patreon-client", clientSecret: "patreon-secret" },
github: { clientId: "github-client", clientSecret: "github-secret" },
twitch: { clientId: "twitch-client", clientSecret: "twitch-secret" },
youtube: { clientId: "youtube-client", clientSecret: "youtube-secret" },
spotify: { clientId: "spotify-client", clientSecret: "spotify-secret" },
joystick: { clientId: "joystick-client", clientSecret: "joystick-secret" },
google: { clientId: "google-client", clientSecret: "google-secret" },
},
};
}
// Link mode binds a provider identity to the account an authenticated app
// named at start. These tests pin the three places that binding is decided:
// the callback (initiator), the completion row (target), and redeem (caller,
// target, replay).
const returnTo = "https://repixelizer.gamecult.org/app/";
const appAuth = { "x-heimdall-app-secret": "repixelizer-secret" };
function discordRuntime(providerUserId: string): OAuthProviderRuntime {
return {
async exchangeAuthorizationCode() {
return { accessToken: "at", refreshToken: "rt", tokenType: "Bearer", scope: ["identify"], raw: {} };
},
async resolveIdentity() {
return { provider: "discord", providerUserId, username: providerUserId, profile: { id: providerUserId } };
},
async evaluateEntitlements() {
return { facts: [], snapshots: [] };
},
};
}
const apps: Awaited<ReturnType<typeof buildApp>>[] = [];
afterEach(async () => {
vi.useRealTimers();
await Promise.all(apps.splice(0).map((app) => app.close()));
});
async function rig(providerUserId: string, config: HeimdallConfig = createTestConfig()) {
const store = new InMemoryStore();
const app = await buildApp({ config, store, oauthRuntimes: { discord: discordRuntime(providerUserId) } });
apps.push(app);
const now = new Date().toISOString();
const initiator = await store.createAccount({ createdAt: now, lastSeenAt: now });
return { app, store, initiator };
}
type Rig = Awaited<ReturnType<typeof rig>>;
function start(r: Rig, payload: Record<string, unknown>, headers: Record<string, string> = appAuth) {
return r.app.inject({ method: "POST", url: "/v1/oauth/discord/start", headers, payload: { appSlug: "repixelizer", returnTo, ...payload } });
}
/** The browser follows authorizationUrl through the launch hop, then returns with the state, or with `forged` in its place, and the cookie it was handed. */
async function callback(r: Rig, started: { authorizationUrl: string }, forged?: (state: string) => string) {
const flow = await launchFlow(r.app, started);
const state = forged ? forged(flow.state) : flow.state;
return r.app.inject({
method: "GET",
url: `/v1/oauth/discord/callback?code=c&state=${encodeURIComponent(state)}`,
headers: { cookie: flow.cookie },
});
}
function redeem(r: Rig, payload: Record<string, unknown>, headers: Record<string, string> = {}) {
return r.app.inject({ method: "POST", url: "/v1/apps/repixelizer/auth-completions/redeem", headers, payload });
}
describe("link start names its initiator", () => {
it("refuses an unauthenticated link start, a missing or unknown initiator, and an initiator outside link mode", async () => {
const r = await rig("d-1");
const mode = { mode: "link", initiatingAccountId: r.initiator.id };
expect((await start(r, mode, {})).statusCode).toBe(401);
expect((await start(r, mode, { "x-heimdall-app-secret": "wrong" })).statusCode).toBe(401);
const missing = await start(r, { mode: "link" });
expect([missing.statusCode, missing.json().error]).toEqual([400, "link_initiator_required"]);
const unknown = await start(r, { mode: "link", initiatingAccountId: "no-such-account" });
expect([unknown.statusCode, unknown.json().error]).toEqual([400, "link_initiator_unknown"]);
const stray = await start(r, { mode: "sign_in", initiatingAccountId: r.initiator.id }, {});
expect([stray.statusCode, stray.json().error]).toEqual([400, "initiator_only_in_link_mode"]);
});
});
describe("sign_in start carries no connection target", () => {
it("refuses connection.targetId on sign_in with connection_not_allowed_in_sign_in, with or without app auth", async () => {
const r = await rig("d-si");
const connection = { kind: "creator", targetId: "victim-target" };
for (const headers of [{}, appAuth]) {
const res = await start(r, { mode: "sign_in", connection }, headers);
expect([res.statusCode, res.json().error]).toEqual([400, "connection_not_allowed_in_sign_in"]);
}
expect((await start(r, { mode: "sign_in" }, {})).statusCode).toBe(201);
});
});
describe("link callback holds the provider identity to the initiator", () => {
it("attaches an unlinked identity to the initiating account, not a new one", async () => {
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());
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);
});
it("refuses with link_initiator_mismatch when the identity already belongs to another account, and mints nothing", async () => {
const r = await rig("d-held");
const now = new Date().toISOString();
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());
expect(done.statusCode).toBe(403);
expect(done.json().error).toBe("link_initiator_mismatch");
expect(done.json().completion).toBeUndefined();
expect(done.body).not.toContain("accessToken");
expect((await r.store.findAccountByLinkedIdentity("discord", "d-held"))?.id).toBe(other.id);
});
it("refuses a link state that carries no initiator (forged or stripped), before attaching anything", async () => {
const dir = mkdtempSync(join(tmpdir(), "heimdall-link-"));
try {
const config = { ...createTestConfig(), signingPrivateKeyPath: join(dir, "signing.pem"), bootstrapSigningPrivateKeyOnMissing: true };
const r = await rig("d-forged", config);
const keys = createRuntimeKeyMaterial({ ...config, bootstrapSigningPrivateKeyOnMissing: false });
const started = await start(r, { mode: "link", initiatingAccountId: r.initiator.id });
const done = await callback(r, started.json(), (state) => {
const verified = verifyJwt(state, keys.publicKey) as { valid: boolean; payload: Record<string, unknown> };
expect(verified.valid).toBe(true);
return signJwt({ ...verified.payload, initiator_account_id: null }, keys);
});
expect([done.statusCode, done.json().error]).toEqual([403, "link_initiator_missing"]);
expect(await r.store.findAccountByLinkedIdentity("discord", "d-forged")).toBeNull();
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});
describe("redeem binds caller, target and replay", () => {
async function linked(r: Rig, targetId: string | undefined) {
const started = await start(r, {
mode: "link",
initiatingAccountId: r.initiator.id,
...(targetId ? { connection: { kind: "account", targetId } } : {}),
});
const done = await callback(r, started.json());
expect(done.statusCode).toBe(201);
return done.json().completion.code as string;
}
it("redeems a link completion only for the authenticated app, and a refused caller does not burn the code", async () => {
const r = await rig("d-r1");
const completionCode = await linked(r, undefined);
const anonymous = await redeem(r, { completionCode });
expect([anonymous.statusCode, anonymous.json().error]).toEqual([401, "app_auth_required"]);
expect((await redeem(r, { completionCode }, { "x-heimdall-app-secret": "wrong" })).statusCode).toBe(401);
expect((await redeem(r, { completionCode }, appAuth)).statusCode).toBe(201);
});
it("refuses a wrong or missing target with completion_target_mismatch without consuming the code", async () => {
const r = await rig("d-r2");
const completionCode = await linked(r, "target-a");
for (const payload of [{ completionCode, targetId: "target-b" }, { completionCode }]) {
const refused = await redeem(r, payload, appAuth);
expect([refused.statusCode, refused.json().error]).toEqual([403, "completion_target_mismatch"]);
expect(refused.json().accessToken).toBeUndefined();
}
expect((await redeem(r, { completionCode, targetId: "target-a" }, appAuth)).statusCode).toBe(201);
});
it("refuses a replayed completion", async () => {
const r = await rig("d-r3");
const completionCode = await linked(r, "target-a");
expect((await redeem(r, { completionCode, targetId: "target-a" }, appAuth)).statusCode).toBe(201);
expect((await redeem(r, { completionCode, targetId: "target-a" }, appAuth)).statusCode).toBe(410);
});
it("refuses a completion at and after its expiry with 410, whatever the caller and target, and honours it just before", async () => {
// completionTtlSeconds is 300. Only Date is faked: the app and store run on real timers.
vi.useFakeTimers({ toFake: ["Date"] });
const t0 = Date.parse("2026-11-03T00:00:00.000Z");
const ttlMs = createTestConfig().completionTtlSeconds * 1000;
const expired = { statusCode: 410, error: "invalid_or_expired_completion_code" };
const outcome = async (r: Rig, payload: Record<string, unknown>) => {
const res = await redeem(r, payload, appAuth);
return { statusCode: res.statusCode, error: res.json().error };
};
vi.setSystemTime(t0);
const r = await rig("d-exp");
const code = await linked(r, "target-a");
const row = await r.store.findAuthCompletion("repixelizer", code);
expect(row?.expiresAt).toBe(new Date(t0 + ttlMs).toISOString());
// Just before expiry the right caller and target redeem it.
const early = await rig("d-exp-early");
const earlyCode = await linked(early, "target-a");
vi.setSystemTime(t0 + ttlMs - 1);
expect((await redeem(early, { completionCode: earlyCode, targetId: "target-a" }, appAuth)).statusCode).toBe(201);
// At the expiry instant and after it: 410, for the right target and for a wrong one (not 403), unconsumed.
for (const at of [t0 + ttlMs, t0 + ttlMs + 1]) {
vi.setSystemTime(at);
expect(await outcome(r, { completionCode: code, targetId: "target-a" })).toEqual(expired);
expect(await outcome(r, { completionCode: code, targetId: "target-b" })).toEqual(expired);
}
expect((await r.store.findAuthCompletion("repixelizer", code))?.consumedAt).toBeUndefined();
});
it("stores the target on the completion row", async () => {
const r = await rig("d-r4");
const completionCode = await linked(r, "target-a");
expect((await r.store.findAuthCompletion("repixelizer", completionCode))?.targetId).toBe("target-a");
});
});

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,17 +580,19 @@ 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({
method: "POST",
url: "/v1/apps/streampixels/auth-completions/redeem",
payload: { completionCode: callback.json().completion.code as string },
payload: { completionCode: callback.json().completion.code as string, ...(mode === "connect" ? { targetId: "creator-1" } : {}) },
});
expect(redeem.statusCode).toBe(201);
return { callback, redeem };
@ -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");

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

@ -0,0 +1,150 @@
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;
const response = await app.inject({ method: "GET", url: `/v1/oauth/${provider}/launch?state=${encodeURIComponent(state)}` });
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> {
const url = new URL(start.authorizationUrl);
const response = await app.inject({ method: "GET", url: `${url.pathname}${url.search}` });
expect(response.statusCode, "launch").toBe(302);
return new URL(response.headers.location as string);
}

View file

@ -0,0 +1,516 @@
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 error 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("provider_error");
const replay = await callback(app, victim.state, victim.cookie, "error=access_denied");
expect(replay.json()).toEqual({ error: "state_already_used" });
});
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

@ -1,18 +1,17 @@
import { mkdtemp, readFile, writeFile } from "node:fs/promises";
import { openSync, closeSync } from "node:fs";
import { access, mkdtemp, readFile, writeFile } from "node:fs/promises";
import { openSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { encode } from "@msgpack/msgpack";
import { SingleFileMessagePackBackingStore } from "@gamecult/cultcache-ts";
import { decode, encode } from "@msgpack/msgpack";
import { describe, expect, it } from "vitest";
import {
openOrEnrollProviderHealthIdentity,
openProviderHealthIdentity,
passedDescriptor,
providerHealthPublicIdentity,
} from "../src/provider-health-identity.js";
import { fixtureMachineId, idunnRuntimeFixtures } from "./fixtures/presence-descriptors.js";
const machineIdAvailable = await readFile("/etc/machine-id", "utf8").then(
() => true,
@ -23,59 +22,79 @@ const machineIdAvailable = await readFile("/etc/machine-id", "utf8").then(
* The identity is machine-id bound, so enrolment only works where a machine-id
* exists. These run on Linux and are skipped elsewhere.
*/
describe.skipIf(!machineIdAvailable)("presence identity from a passed descriptor", () => {
it("reads an Idunn-written store, not a cultcache-ts one", async () => {
/** Name the presence descriptor so it lands on whatever fd open() returned. */
function presenceEnv(fd: number): NodeJS.ProcessEnv {
// The lookup maps a name index onto 3 + index, so the name list is padded
// until the presence entry lands on the descriptor open() actually returned.
// Assuming it is 3 is how this passes on one machine and reads the wrong
// descriptor on another.
const names = [
...Array.from({ length: fd - 3 }, (_, i) => `pad-${i}`),
"gamecult-runtime-presence-identity",
].join(":");
return { LISTEN_FDNAMES: names, LISTEN_PID: String(process.pid) };
}
describe("presence identity from a passed descriptor", () => {
it("decodes the Rust owner's record, whose key fields are integer arrays", async () => {
const dir = await mkdtemp(path.join(os.tmpdir(), "heimdall-presence-"));
const fd = openSync(path.join(idunnRuntimeFixtures, "provider-identity.credential"), "r");
// Enrol with the TypeScript client to get a genuine machine-bound payload,
// then re-wrap that payload the way cultcache-rs writes a store: a bare
// array of positional [key, type, payload, stored_at, schema_id] envelopes.
// Idunn writes with the Rust client, and cultcache-ts cannot read that
// layout, so wrapping it here is what makes this test resemble reality.
const tsStore = path.join(dir, "ts-enrolled.cc");
const enrolled = await openOrEnrollProviderHealthIdentity(tsStore);
const records = await new SingleFileMessagePackBackingStore(tsStore).pullAll();
expect(records).toHaveLength(1);
const idunnStore = path.join(dir, "runtime-presence-identity.cc");
await writeFile(
idunnStore,
encode([
[
"gamecult-provider-health-identity",
"gamecult.provider_health_identity.private.v1",
records[0]!.payload,
"2026-09-06T00:00:00.000Z",
"gamecult.provider_health_identity.private.v1",
],
])
const opened = await openProviderHealthIdentity(
path.join(dir, "would-be-self-enrolled.cc"),
presenceEnv(fd),
fixtureMachineId
);
const fd = openSync(idunnStore, "r");
try {
// The lookup maps a name index onto 3 + index, so the name list is padded
// until the presence entry lands on whatever descriptor open() actually
// returned. Assuming it is 3 is how this passes on one machine and reads
// the wrong descriptor on another.
const names = [
...Array.from({ length: fd - 3 }, (_, i) => `pad-${i}`),
"gamecult-runtime-presence-identity",
].join(":");
// The fixture's own public key means it came from the descriptor. A fresh
// self-enrolment would produce a different key, which is exactly the failure
// that makes Idunn refuse this daemon's health and stall promotion.
const store = decode(await readFile(path.join(idunnRuntimeFixtures, "provider-identity.credential"))) as unknown[][];
const record = decode(store[0]![2] as Uint8Array) as unknown[];
expect(providerHealthPublicIdentity(opened).publicKeyHex).toBe(
Buffer.from(record[2] as number[]).toString("hex")
);
await expect(access(path.join(dir, "would-be-self-enrolled.cc"))).rejects.toMatchObject({ code: "ENOENT" });
});
const opened = await openProviderHealthIdentity(
path.join(dir, "would-be-self-enrolled.cc"),
{ LISTEN_FDNAMES: names, LISTEN_PID: String(process.pid) }
);
it("refuses a bin-shaped record on the descriptor path, naming the key-shape check and no value", async () => {
const dir = await mkdtemp(path.join(os.tmpdir(), "heimdall-presence-bin-"));
const store = decode(await readFile(path.join(idunnRuntimeFixtures, "provider-identity.credential"))) as unknown[][];
const record = decode(store[0]![2] as Uint8Array) as unknown[];
// What this process's own enroller writes: the same record with bin key fields.
const binRecord = record.map((field, index) =>
index === 2 || index === 3 ? Uint8Array.from(field as number[]) : field
);
const binStore = path.join(dir, "bin-shaped.cc");
await writeFile(binStore, encode([[store[0]![0], store[0]![1], encode(binRecord), store[0]![3], store[0]![4]]]));
const fd = openSync(binStore, "r");
// Same key means it came from the descriptor. A fresh self-enrolment
// would produce a different key -- which is exactly the failure that
// makes Idunn refuse this daemon's health and stall promotion.
expect(providerHealthPublicIdentity(opened).publicKeyHex).toBe(
providerHealthPublicIdentity(enrolled).publicKeyHex
);
} finally {
closeSync(fd);
}
const failure = await openProviderHealthIdentity(path.join(dir, "x.cc"), presenceEnv(fd), fixtureMachineId).then(
() => undefined,
(error: Error) => error
);
expect(failure?.message).toContain("key shape check");
expect(failure?.message).not.toContain((record[1] as string).slice(0, 16));
});
it.each([
["schema", 0, "bogus", "schema check"],
["protector", 4, "bogus", "protector check"],
["binding", 5, "bogus", "binding check"],
["version", 6, "v9", "version check"],
])("a record failing only the %s check is refused by that check", async (_name, index, replacement, check) => {
const dir = await mkdtemp(path.join(os.tmpdir(), "heimdall-presence-check-"));
const store = decode(await readFile(path.join(idunnRuntimeFixtures, "provider-identity.credential"))) as unknown[][];
const record = decode(store[0]![2] as Uint8Array) as unknown[];
record[index] = replacement;
const damaged = path.join(dir, "damaged.cc");
await writeFile(damaged, encode([[store[0]![0], store[0]![1], encode(record), store[0]![3], store[0]![4]]]));
const fd = openSync(damaged, "r");
await expect(
openProviderHealthIdentity(path.join(dir, "x.cc"), presenceEnv(fd), fixtureMachineId)
).rejects.toThrow(check);
});
});
@ -112,3 +131,29 @@ describe("presence identity descriptor selection", () => {
});
});
});
describe("an Idunn generation without the presence identity descriptor", () => {
it("refuses, naming the descriptor, and enrolls nothing", async () => {
const dir = await mkdtemp(path.join(os.tmpdir(), "heimdall-nodescriptor-"));
const identityPath = path.join(dir, "provider-health-identity.cc");
await expect(
openProviderHealthIdentity(identityPath, {
GAMECULT_IDUNN_RUNTIME_BUNDLE: "/run/idunn/bundle",
LISTEN_FDNAMES: "some-other-descriptor",
})
).rejects.toThrow("gamecult-runtime-presence-identity");
await expect(access(identityPath)).rejects.toMatchObject({ code: "ENOENT" });
});
it("still self-enrolls outside Idunn", async () => {
if (!machineIdAvailable) return;
const dir = await mkdtemp(path.join(os.tmpdir(), "heimdall-outside-idunn-"));
const identityPath = path.join(dir, "provider-health-identity.cc");
await openProviderHealthIdentity(identityPath, {});
await expect(access(identityPath)).resolves.toBeUndefined();
});
});

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

@ -0,0 +1,83 @@
import { mkdtemp, readdir, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { loadConfig } from "../src/config.js";
import { PublisherSequence, openPresenceAuthority } from "../src/runtime-presence.js";
import { fixtureMachineId, openPresenceDescriptors, writePresenceFixtures } from "./fixtures/presence-descriptors.js";
describe("PublisherSequence", () => {
it("starts at the observed millis", () => {
expect(new PublisherSequence().next(1_700_000_000_000)).toBe(1_700_000_000_000);
});
it("strictly increases for equal, earlier and later observations", () => {
const sequence = new PublisherSequence();
const first = sequence.next(1_700_000_000_000);
const equal = sequence.next(1_700_000_000_000);
const earlier = sequence.next(1_699_999_990_000);
const later = sequence.next(1_700_000_050_000);
expect([first, equal, earlier, later]).toEqual([
1_700_000_000_000,
1_700_000_000_001,
1_700_000_000_002,
1_700_000_050_000,
]);
});
it("never falls below the observed millis once the clock moves ahead", () => {
const sequence = new PublisherSequence();
sequence.next(1_700_000_000_000);
sequence.next(1_700_000_000_000);
sequence.next(1_700_000_000_000);
expect(sequence.next(1_700_000_000_002)).toBe(1_700_000_000_003);
expect(sequence.next(1_700_000_000_010)).toBe(1_700_000_000_010);
});
it("refuses a value that is not a safe integer and keeps its place", () => {
const sequence = new PublisherSequence();
expect(sequence.next(10)).toBe(10);
for (const bad of [Number.NaN, Number.POSITIVE_INFINITY, 1.5, 2 ** 60]) {
expect(() => sequence.next(bad)).toThrow("observedAtUnixMillis");
}
expect(sequence.next(5)).toBe(11);
});
});
describe("the presence authority", () => {
it("opens once, touches no path under dataRoot, and signs every pulse after its descriptors are gone", async () => {
const dir = await mkdtemp(path.join(os.tmpdir(), "heimdall-nodataroot-"));
const fixtures = path.join(dir, "fixtures");
await writePresenceFixtures(fixtures);
// A regular file as dataRoot: opening anything beneath it fails with
// ENOTDIR for every uid, root included.
const dataRoot = path.join(dir, "not-a-directory");
await writeFile(dataRoot, "");
const config = loadConfig({
GC_ACCESS_BASE_URL: "https://heimdall.example.test",
GC_ACCESS_DATA_ROOT: dataRoot,
});
const descriptors = openPresenceDescriptors(fixtures);
try {
const authority = await openPresenceAuthority(
{ ...config, idunnRuntimeBundlePath: fixtures },
descriptors.env,
fixtureMachineId,
);
// The descriptors were read whole and closed: a second read would find
// nothing, so signing again proves the authority holds what it decoded.
const observedAtUnixMillis = Date.parse("2026-10-23T12:00:00.000Z");
const first = authority.sign("warming", "probe", observedAtUnixMillis);
const second = authority.sign("warming", "probe", observedAtUnixMillis);
expect(first.target).toBe("streampixels-web");
expect(second.target).toBe("streampixels-web");
expect(Buffer.from(second.payload).equals(Buffer.from(first.payload))).toBe(false);
} finally {
descriptors.close();
}
expect((await readdir(dir)).sort()).toEqual(["fixtures", "not-a-directory"]);
});
});

View file

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

View file

@ -0,0 +1,98 @@
import { execFile } from "node:child_process";
import { chmod, chown, mkdir, mkdtemp, readdir, stat } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { promisify } from "node:util";
import { build } from "esbuild";
import { describe, expect, it } from "vitest";
import { writePresenceFixtures } from "./fixtures/presence-descriptors.js";
const run = promisify(execFile);
const STATE_GID = 4242;
const GENERATION_N = 61184;
const GENERATION_N_PLUS_1 = 61185;
// Creating uids and groups needs root; the stopgap's node container runs as root.
const isRoot = process.getuid?.() === 0;
async function walk(dir: string): Promise<string[]> {
const found: string[] = [];
for (const entry of await readdir(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
found.push(full);
if (entry.isDirectory()) found.push(...(await walk(full)));
}
return found;
}
/**
* Idunn gives every generation a fresh DynamicUser uid and one shared group,
* UMask=0007, and a 2770 setgid state root. Generation N+1 runs beside or after
* N and must be able to read and replace everything N left there.
*/
describe.skipIf(!isRoot)("Idunn state root across generations", () => {
it("generation N+1, a different uid in the same group, reads and replaces generation N's state", async () => {
const base = await mkdtemp(path.join(os.tmpdir(), "heimdall-generations-"));
await chmod(base, 0o755);
// The fixtures stand in for what Idunn passes as descriptors.
const fixtures = path.join(base, "fixtures");
await writePresenceFixtures(fixtures);
const stateRoot = path.join(base, "state");
await mkdir(stateRoot);
await chown(stateRoot, 0, STATE_GID);
await chmod(stateRoot, 0o2770);
// The generations run as uids that cannot read the checkout, so each runs
// one self-contained bundle built into the world-readable scratch root.
const child = path.join(base, "generation.mjs");
await build({
entryPoints: [path.join(import.meta.dirname, "fixtures", "state-root-generation-child.ts")],
bundle: true,
platform: "node",
format: "esm",
target: "node24",
outfile: child,
external: ["pg-native"],
banner: {
js: "import{createRequire}from'node:module';const require=createRequire(import.meta.url);",
},
logLevel: "silent",
});
await chmod(child, 0o644);
const generation = (role: "first" | "next", uid: number) =>
run(process.execPath, [child, role, stateRoot, fixtures], {
uid,
gid: STATE_GID,
cwd: base,
env: { PATH: process.env.PATH ?? "", HOME: fixtures },
});
await generation("first", GENERATION_N);
const left = await walk(stateRoot);
expect(left.map((entry) => path.relative(stateRoot, entry)).sort()).toEqual([
"cultcache",
path.join("cultcache", "heimdall.service.cc"),
"runtime-presence-sequence",
]);
await generation("next", GENERATION_N_PLUS_1);
// Generation N+1 replaced the snapshot, and no owner-only file is left
// behind for generation N+2: everything but the stale 0600 fixture is
// readable and writable by the state group.
const snapshot = await stat(path.join(stateRoot, "cultcache", "heimdall.service.cc"));
expect(snapshot.uid).toBe(GENERATION_N_PLUS_1);
expect(snapshot.gid).toBe(STATE_GID);
for (const entry of await walk(stateRoot)) {
if (path.basename(entry) === "runtime-presence-sequence") continue;
expect((await stat(entry)).mode & 0o060, path.relative(stateRoot, entry)).toBe(0o060);
}
expect((await readdir(stateRoot)).sort()).toEqual(["cultcache", "runtime-presence-sequence"]);
}, 60_000);
});

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,
@ -198,6 +198,39 @@ describe("PostgresStore", () => {
});
});
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 +265,14 @@ describe("PostgresStore.checkSchema", () => {
"sessions",
"auth_attempts",
"private_command_receipts",
"pending_links",
"auth_completions",
"oauth_state_uses",
"capability_grants",
"entitlement_snapshots",
"audit_events",
]);
expect(REQUIRED_COLUMNS).toHaveLength(85);
expect(REQUIRED_COLUMNS).toHaveLength(101);
expect(REQUIRED_COLUMNS).toEqual(
expect.arrayContaining([
{ table: "auth_completions", column: "code", type: "TEXT", nullable: false },
@ -248,11 +283,12 @@ describe("PostgresStore.checkSchema", () => {
{ table: "auth_completions", column: "attempt_id", type: "TEXT", nullable: true },
])
);
expect(REQUIRED_KEYS).toHaveLength(12);
expect(REQUIRED_KEYS).toHaveLength(14);
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: "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 +398,26 @@ 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);
await s.createAuditEvent({ eventType: "probe", eventPayloadJson: {}, createdAt: now });
return called;
}

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",