Compare commits

..
Author SHA1 Message Date
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
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
43 changed files with 1307 additions and 1432 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), 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

@ -154,11 +154,7 @@ The important split:
- Heimdall owns provider OAuth, identity linking, entitlement refresh, signed
sessions, claims, grants, audit surfaces, and managed provider credential
custody. It authenticates; it does not issue GameCult identity. Bifrost is
the identity provider, and the account id Heimdall mints is an authenticator
handle. Remaining scar: Heimdall still creates that account id on first
login (`store.createAccount`, `src/app.ts`), which the chat work does not
need moved
custody
- the host experiment owns its workload, queue, editor, render path, viewer
profile model, creator model, or other product-specific machinery
- the host experiment also owns which local entity a connection attaches to,
@ -617,9 +613,8 @@ Only the browser-driven OAuth flow:
| Path | Why it must be public |
|---|---|
| `/v1/oauth/{provider}/start` | the app's server calls it, with its app secret where the profile requires one |
| `/v1/oauth/{provider}/launch` | the user's browser goes here first; it sets the binding cookie and redirects to the provider, once per state |
| `/v1/oauth/{provider}/callback` | the provider redirects here; one-shot: it needs the binding cookie and consumes the state |
| `/v1/oauth/{provider}/start` | the user's browser goes here |
| `/v1/oauth/{provider}/callback` | the provider redirects here; one-shot and not retryable |
| `/.well-known/jwks.json` | *nothing external consumes it today* — see below |
Everything else is internal by the design already written down:

View file

@ -149,29 +149,12 @@ Response:
"appSlug": "repixelizer",
"mode": "sign_in",
"callbackUrl": "https://heimdall.gamecult.org/v1/oauth/discord/callback",
"authorizationUrl": "https://heimdall.gamecult.org/v1/oauth/discord/launch?state=<signed-jwt>",
"authorizationUrl": "https://discord.com/oauth2/authorize?...",
"stateToken": "<signed-jwt>",
"stateExpiresAt": "2026-04-26T12:10:00.000Z"
}
```
Send the user's browser to `authorizationUrl` unchanged. It is Heimdall's launch
hop, not the provider's URL.
### `GET /v1/oauth/{provider}/launch?state=<signed-jwt>`
The first thing the user's browser does with a state. Heimdall verifies the
state (signature, issuer, provider, `nbf`/`exp` with 30 s of skew), claims the
state's single launch, sets the binding cookie
`__Host-heimdall-oauth-<jti>` (`Path=/`, `Secure`, `HttpOnly`, `SameSite=Lax`,
`Max-Age` until the state expires; the value is a Heimdall-signed binding for
that state) and answers `302` to the provider's authorize URL, with
`Cache-Control: no-store` and `Referrer-Policy: no-referrer`. A second launch of
the same state is `400 state_already_launched` and sets no cookie. The state is
refused with `400 invalid_state`, `state_provider_mismatch` or `state_expired`.
The binding cookie needs an `https` public base URL (or `localhost`). A browser
must finish the flow in the browser that launched it.
Failure modes:
- `503 provider_not_configured` when the provider client id is missing
@ -185,12 +168,7 @@ Failure modes:
- `401 app_auth_required` when the request body carries `entitlementPolicy` and
the caller did not authenticate as that app via `x-heimdall-app-secret`
(`resolveAppCaller`, `src/app-caller.ts`) -- this applies regardless of
`handoff.kind` or app slug; a caller with no policy to attach needs no secret,
unless the app's profile sets `startRequiresAppCaller` (Bifrost only): then
every start for that app, with or without a policy, needs that app's secret.
Bifrost binds browser completion to a nonce that rides readable in the signed
state's `return_to`, so only Bifrost may mint a Bifrost state; apps whose
browser starts are public by design leave the flag unset
`handoff.kind` or app slug; a caller with no policy to attach needs no secret
Launch note:
@ -211,9 +189,7 @@ Purpose:
Current status:
- validates signed `state` (signature, issuer, provider, expiry), requires the
binding cookie the launch hop set, and consumes the state: a second callback
is `400 state_already_used`, a missing or foreign cookie `400 state_not_bound`
- validates signed `state`
- returns provider errors cleanly
- for Discord, exchanges the authorization code, resolves the provider
identity, persists the local account/link/session/audit records, evaluates

View file

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

View file

@ -17,13 +17,12 @@
"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,9 +12,6 @@ importers:
.:
dependencies:
'@fastify/cookie':
specifier: ^11.1.3
version: 11.1.3
'@fastify/cors':
specifier: ^11.2.0
version: 11.2.0
@ -31,8 +28,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
@ -232,9 +229,6 @@ packages:
'@fastify/ajv-compiler@4.0.5':
resolution: {integrity: sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==}
'@fastify/cookie@11.1.3':
resolution: {integrity: sha512-RBhlMNsmSG6C5KGiTkhSXBF75xIrUMSytDDBcpnVewhJ8XVYnJTu+SV6FYKXClIRsBuRjOfKbBSULvnPD3X/mg==}
'@fastify/cors@11.2.0':
resolution: {integrity: sha512-LbLHBuSAdGdSFZYTLVA3+Ch2t+sA6nq3Ejc6XLAKiQ6ViS2qFnvicpj0htsx03FyYeLs04HfRNBsz/a8SvbcUw==}
@ -475,10 +469,6 @@ packages:
resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==}
engines: {node: '>=18'}
cookie@2.0.1:
resolution: {integrity: sha512-yuToqVvRrj6pfDXREyQAAv8SkAEk/8GS3jQRTiUMm66TVtBYmqQeoEjL2Lmq8Rpo6271vH76InTChTitEAm65w==}
engines: {node: '>=22'}
cultmesh-ts@file:vendor/CultLib/packages/cultmesh-ts:
resolution: {directory: vendor/CultLib/packages/cultmesh-ts, type: directory}
engines: {node: '>=18'}
@ -542,20 +532,23 @@ 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-plugin@6.0.1:
resolution: {integrity: sha512-4wW4RxSmvPXLrh5mgQEiu43qU3bw44N2ss4FTkTkQIbprUhE+/s43QX0j/ttcztdWK7PEkHqVthdc5GdEGDvjA==}
fastify@5.8.5:
resolution: {integrity: sha512-Yqptv59pQzPgQUSIm87hMqHJmdkb1+GPxdE6vW6FRyVE9G86mt7rOghitiU4JHRaTyDUk9pfeKmDeu70lAwM4Q==}
fastify@5.12.7:
resolution: {integrity: sha512-v4pEPsTRPuqlDIYiQFB+BHjVN4SdHX9u0ABk0ZDUKbbdY1vsNryUd1j4Z+jQEZajvYIVk047Kimva9/AJ79GYw==}
fastq@1.20.1:
resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==}
@ -569,8 +562,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:
@ -866,8 +859,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==}
@ -1193,11 +1186,6 @@ snapshots:
ajv-formats: 3.0.1(ajv@8.20.0)
fast-uri: 3.1.0
'@fastify/cookie@11.1.3':
dependencies:
cookie: 2.0.1
fastify-plugin: 6.0.1
'@fastify/cors@11.2.0':
dependencies:
fastify-plugin: 5.1.0
@ -1405,8 +1393,6 @@ snapshots:
cookie@1.1.1: {}
cookie@2.0.1: {}
cultmesh-ts@file:vendor/CultLib/packages/cultmesh-ts:
dependencies:
'@gamecult/cultcache-ts': file:vendor/CultLib/packages/cultcache-ts
@ -1495,17 +1481,26 @@ 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-plugin@6.0.1: {}
fastify@5.8.5:
fastify@5.12.7:
dependencies:
'@fastify/ajv-compiler': 4.0.5
'@fastify/error': 4.2.0
@ -1513,11 +1508,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
@ -1531,7 +1526,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
@ -1755,7 +1750,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
@ -1780,7 +1775,7 @@ snapshots:
process-warning@4.0.1: {}
process-warning@5.0.0: {}
process-warning@5.1.1: {}
quick-format-unescaped@4.0.4: {}

View file

@ -45,17 +45,6 @@ export interface AppProfile {
* plane; absent for apps with no such requirement.
*/
requiredEntitlementPolicyKind?: OAuthEntitlementPolicy["kind"];
/**
* The app binds browser completion to its own pending attempt, and the
* binding is the secrecy of the attempt nonce, which rides readable in the
* signed (not encrypted) state's return_to. If anyone could start a flow for
* this app, whoever learned a victim's nonce could mint a completion bound
* to it and sign the victim's browser in as themselves (login CSRF, finding
* known-nonce-login-csrf). So a start for this app is refused unless the
* caller is this app's authenticated caller: only the app can mint its own
* state. Absent for apps whose browser starts are public by design.
*/
startRequiresAppCaller?: boolean;
}
/** The membership signal shared by every app that gates on GameCult membership. */
@ -153,7 +142,6 @@ const bifrostProfile: AppProfile = {
entitlementSources: ["discord", "patreon"],
managedConnectionProviders: [],
allowedReturnOrigins: ["https://bifrost.gamecult.org"],
startRequiresAppCaller: true,
capabilities: [
{
key: "member_access",

View file

@ -1,7 +1,6 @@
import { createHmac, randomUUID } from "node:crypto";
import cookie from "@fastify/cookie";
import cors from "@fastify/cors";
import Fastify, { type FastifyError, type FastifyInstance, type FastifyReply } from "fastify";
import Fastify, { type FastifyError, type FastifyInstance } from "fastify";
import { type AppCaller, isAppCaller, resolveAppCaller } from "./app-caller.js";
import { deliverBackendHandoff, type BackendHandoffPayload } from "./backend-handoff.js";
import {
@ -94,7 +93,6 @@ function buildOAuthStateToken(options: {
connection: OAuthConnectionBinding | undefined;
handoff: OAuthHandoff;
entitlementPolicy: OAuthEntitlementPolicy | undefined;
requestedScopes: string[] | undefined;
keys: ReturnType<typeof createRuntimeKeyMaterial>;
}): { token: string; expiresAt: string } {
const issuedAt = nowEpochSeconds();
@ -115,7 +113,6 @@ function buildOAuthStateToken(options: {
connection: options.connection ?? 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);
@ -154,11 +151,6 @@ 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" ||
@ -176,8 +168,7 @@ function parseOAuthStatePayload(payload: Record<string, unknown>): OAuthStatePay
typeof payload.nbf !== "number" ||
typeof payload.exp !== "number" ||
!handoffIsValid ||
!entitlementPolicyIsValid ||
!requestedScopesAreValid
!entitlementPolicyIsValid
) {
return null;
}
@ -189,99 +180,10 @@ function parseOAuthStatePayload(payload: Record<string, unknown>): OAuthStatePay
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));
}
@ -652,7 +554,7 @@ export async function startOAuthFlow(
if (options?.trustedBrowserAttemptId && handoff.kind === "browser_completion") {
handoff = { kind: "browser_completion", attemptId: options.trustedBrowserAttemptId };
}
if ((entitlementPolicy || profile.startRequiresAppCaller) && (!isAppCaller(caller) || caller.appSlug !== profile.slug)) {
if (entitlementPolicy && (!isAppCaller(caller) || caller.appSlug !== profile.slug)) {
return { statusCode: 401, body: { error: "app_auth_required" } };
}
@ -664,6 +566,7 @@ export async function startOAuthFlow(
};
}
const callbackUrl = `${config.publicBaseUrl}/v1/oauth/${provider}/callback`;
const state = buildOAuthStateToken({
config,
provider,
@ -673,7 +576,6 @@ export async function startOAuthFlow(
connection: input.connection,
handoff,
entitlementPolicy,
requestedScopes: input.requestedScopes,
keys,
});
@ -683,9 +585,16 @@ export async function startOAuthFlow(
provider,
appSlug: profile.slug,
mode: input.mode,
callbackUrl: oauthCallbackUrl(config, provider),
authorizationUrl: `${config.publicBaseUrl}/v1/oauth/${provider}/launch?state=${encodeURIComponent(state.token)}`,
callbackUrl,
authorizationUrl: buildAuthorizationUrl({
provider,
clientId: providerConfig.clientId,
redirectUri: callbackUrl,
state: state.token,
requestedScopes: input.requestedScopes,
}),
handoff,
stateToken: state.token,
stateExpiresAt: state.expiresAt,
},
};
@ -829,7 +738,6 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
origin: true,
credentials: true,
});
await app.register(cookie);
app.get("/healthz", async () => ({
ok: true,
@ -964,72 +872,6 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
}
);
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) {
reply.code(400);
return { error: "state_already_launched" };
}
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,
}),
302
);
}
);
app.get<{
Params: { provider: Provider };
Querystring: { code?: string; state?: string; error?: string; error_description?: string };
@ -1063,29 +905,16 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
return { error: "missing_state" };
}
const verified = verifyOAuthState(config, keys, request.query.state, request.params.provider);
if (!verified.ok) {
const verification = verifyJwt(request.query.state, keys.publicKey);
if (!verification.valid) {
reply.code(400);
return { error: verified.error };
return { error: "invalid_state", detail: verification.error };
}
const statePayload = verified.payload;
// 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.
if (!oauthBindingHolds(config, keys, request.cookies[oauthBindingCookieName(statePayload.jti)], statePayload)) {
const statePayload = parseOAuthStatePayload(verification.payload);
if (!statePayload || statePayload.provider !== request.params.provider) {
reply.code(400);
return { error: "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) {
reply.code(400);
return { error: "state_already_used" };
return { error: "state_provider_mismatch" };
}
const handoff = statePayload.handoff ?? { kind: "browser_completion" };
const denyBrowserAttempt = async (denialCode: string): Promise<void> => {

View file

@ -94,8 +94,6 @@ export interface OAuthStatePayload {
connection: OAuthConnectionBinding | null;
handoff: OAuthHandoff | null;
entitlement_policy: OAuthEntitlementPolicy | null;
/** The start's requestedScopes, carried so the launch hop can build the provider URL from the signed state alone. */
requested_scopes: string[] | null;
}
export interface LinkedIdentityInput {

View file

@ -1,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

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

View file

@ -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

@ -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,6 @@ import { type AppSlug, type LinkedIdentityInput, type Provider } from "../contra
import {
type CreateAccountInput,
type CreateAuthAttemptInput,
type ClaimOAuthStateUseInput,
type CreateAuthCompletionInput,
type CreateAuditEventInput,
type CreateCapabilityGrantInput,
@ -49,8 +48,6 @@ export class InMemoryStore implements HeimdallStore {
private readonly privateCommandReceipts = new Map<string, StoredPrivateCommandReceipt>();
private readonly authCompletions = new Map<string, StoredAuthCompletion>();
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>();
@ -427,20 +424,6 @@ export class InMemoryStore implements HeimdallStore {
return this.consumeAuthCompletion(appSlug, code, at);
}
async claimOAuthStateUse(input: ClaimOAuthStateUseInput): Promise<boolean> {
for (const [key, expiresAt] of this.oauthStateUses) {
if (expiresAt <= input.usedAt) {
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,6 @@ import { CREATE_SCHEMA_SQL, REQUIRED_COLUMNS, REQUIRED_KEYS, REQUIRED_TABLES } f
import {
type CreateAccountInput,
type CreateAuthAttemptInput,
type ClaimOAuthStateUseInput,
type CreateAuthCompletionInput,
type CreateAuditEventInput,
type CreateCapabilityGrantInput,
@ -379,7 +378,6 @@ 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"],
oauth_state_uses: ["INSERT"],
capability_grants: ["SELECT", "INSERT"],
entitlement_snapshots: ["SELECT", "INSERT", "UPDATE"],
audit_events: ["INSERT"],
@ -1063,19 +1061,6 @@ export class PostgresStore implements HeimdallStore {
return result.rowCount ? mapAuthCompletionRow(expectRow(result.rows[0], "consumeAuthCompletionByAttempt")) : 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

@ -119,20 +119,6 @@ CREATE UNIQUE INDEX IF NOT EXISTS auth_completions_attempt_unconsumed_unique_idx
ON auth_completions(app_slug, attempt_id)
WHERE attempt_id IS NOT NULL AND consumed_at IS NULL;
-- One use per (state jti, phase): a signed OAuth state launches once and
-- completes once. The first INSERT for a pair wins; ON CONFLICT DO NOTHING
-- reports every later claim as no row. The store deletes nothing, so rows stay
-- like consumed auth_completions; after expires_at a state is refused before
-- any claim, so a row 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

@ -165,14 +165,6 @@ export interface StoredAuthCompletion {
payloadJson: Record<string, unknown>;
}
export interface ClaimOAuthStateUseInput {
jti: string;
phase: "launch" | "callback";
appSlug: AppSlug;
usedAt: string;
expiresAt: string;
}
export interface CreateAuthCompletionInput {
/**
* Correlation handle only (see StoredAuthCompletion.attemptId). The store
@ -280,15 +272,5 @@ export interface HeimdallStore {
*/
consumeAuthCompletionByAttempt(appSlug: AppSlug, attemptId: string, at: string): Promise<StoredAuthCompletion | 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

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

@ -14,7 +14,6 @@ import { createOAuthRuntimeRegistry } from "../src/oauth.js";
import { providerCatalog } from "../src/providers.js";
import { InMemoryStore, PostgresStore, type HeimdallStore } from "../src/store/index.js";
import { type StoredLinkedIdentity } from "../src/store/types.js";
import { launchFlow } from "./oauth-flow.js";
const originalFetch = globalThis.fetch;
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
@ -582,11 +581,9 @@ for (const { kind, live } of rigKinds) describe.skipIf(live && !adminUrl)(`manag
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" } } : {}) } });
expect(start.statusCode).toBe(201);
const flow = await launchFlow(app, start.json());
const callback = await app.inject({
method: "GET",
url: `/v1/oauth/${provider}/callback?code=c&state=${encodeURIComponent(flow.state)}`,
headers: { cookie: flow.cookie },
url: `/v1/oauth/${provider}/callback?code=c&state=${encodeURIComponent(start.json().stateToken as string)}`,
});
expect(callback.statusCode).toBe(201);
const redeem = await app.inject({
@ -636,11 +633,9 @@ for (const { kind, live } of rigKinds) describe.skipIf(live && !adminUrl)(`manag
payload: { appSlug: "streampixels", mode: "connect", returnTo, connection: { kind: "creator", targetId: "creator-1" } },
});
expect(start.statusCode).toBe(201);
const flow = await launchFlow(app, start.json());
const callback = await app.inject({
method: "GET",
url: `/v1/oauth/joystick/callback?code=c&state=${encodeURIComponent(flow.state)}`,
headers: { cookie: flow.cookie },
url: `/v1/oauth/joystick/callback?code=c&state=${encodeURIComponent(start.json().stateToken as string)}`,
});
expect(callback.statusCode).toBeGreaterThanOrEqual(400);
expect(callback.body).not.toContain("tok-access");

View file

@ -1,138 +0,0 @@
import { expect } from "vitest";
import { type FastifyInstance } from "fastify";
import { type HeimdallConfig } from "../src/config.js";
import { entitlementFacts } from "../src/facts.js";
import { type OAuthProviderRuntime } from "../src/oauth.js";
import { decodeJwt } from "../src/signing.js";
export function createTestConfig(): HeimdallConfig {
return {
serviceName: "heimdall",
host: "127.0.0.1",
port: 4100,
workspaceRoot: "E:/Projects/Heimdall",
dataRoot: "E:/Projects/Heimdall/.heimdall-data",
cultCachePath: "E:/Projects/Heimdall/.heimdall-data/cultcache/heimdall.service.cc",
publicBaseUrl: "https://heimdall.gamecult.org",
issuer: "https://heimdall.gamecult.org",
daemonId: "yggdrasil-heimdall",
idunnRudpHealth: undefined,
idunnHealthContract: "heimdall.cultnet-rudp-provider-health",
providerHealthIdentityPath: "E:/Projects/Heimdall/.heimdall-data/provider-health-identity.cc",
sessionTtlSeconds: 3600,
refreshTtlSeconds: 60 * 60 * 24 * 30,
stateTtlSeconds: 600,
completionTtlSeconds: 300,
bootstrapSigningPrivateKeyOnMissing: false,
tokenEncryptionKeyBase64: Buffer.alloc(32, 7).toString("base64"),
appSharedSecrets: {
streampixels: "streampixels-secret",
repixelizer: "repixelizer-secret",
bifrost: "bifrost-secret",
},
appBackendCallbacks: {
bifrost: ["https://bifrost.gamecult.org/auth/heimdall/callback"],
repixelizer: ["https://repixelizer.gamecult.org/api/auth/heimdall/callback"],
},
storage: {
backend: "memory",
applySchemaOnStartup: true,
},
providers: {
discord: { clientId: "discord-client", clientSecret: "discord-secret" },
patreon: { clientId: "patreon-client", clientSecret: "patreon-secret" },
github: { clientId: "github-client", clientSecret: "github-secret" },
twitch: { clientId: "twitch-client", clientSecret: "twitch-secret" },
youtube: { clientId: "youtube-client", clientSecret: "youtube-secret" },
spotify: { clientId: "spotify-client", clientSecret: "spotify-secret" },
joystick: { clientId: "joystick-client", clientSecret: "joystick-secret" },
},
};
}
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");
}
/** 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 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 };
}
/** 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 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

@ -1,400 +0,0 @@
import { generateKeyPairSync, randomUUID } from "node:crypto";
import { afterEach, describe, expect, it, vi } from "vitest";
import { type FastifyInstance } from "fastify";
import { buildApp, getHeimdallRuntimeContext, startOAuthFlow } from "../src/app.js";
import { resolveAppCaller } from "../src/app-caller.js";
import { decodeJwt, signJwt } from "../src/signing.js";
import { createMockDiscordRuntime, createTestConfig, launchFlow, setCookieOf, stateOf } from "./oauth-flow.js";
// Heimdall's OAuth states complete once, only in the browser that launched
// them (finding state-replay). Soul's attack: a Bifrost state is readable in
// the URL the victim's browser is sent to, so an attacker who sees it opens it
// in their own browser, finishes the provider login as themselves, and hands
// the victim the resulting completion code.
const apps: FastifyInstance[] = [];
afterEach(async () => {
vi.useRealTimers();
while (apps.length) {
await apps.pop()?.close();
}
});
const victimAttempt = "VICTIM-ATTEMPT-NONCE";
const bifrostReturn = `https://bifrost.gamecult.org/auth/heimdall/complete?attempt=${victimAttempt}`;
async function newApp() {
const app = await buildApp({ config: createTestConfig(), oauthRuntimes: { discord: createMockDiscordRuntime() } });
apps.push(app);
return app;
}
async function bifrostStart(app: FastifyInstance, extra: Record<string, unknown> = {}) {
const response = await app.inject({
method: "POST",
url: "/v1/oauth/discord/start",
headers: { "x-heimdall-app-secret": "bifrost-secret" },
payload: { appSlug: "bifrost", mode: "sign_in", returnTo: bifrostReturn, handoff: { kind: "browser_completion" }, ...extra },
});
expect(response.statusCode).toBe(201);
return response.json() as { authorizationUrl: string };
}
function launchUrl(start: { authorizationUrl: string }): string {
const url = new URL(start.authorizationUrl);
return `${url.pathname}${url.search}`;
}
function callback(app: FastifyInstance, state: string, cookie?: string, query = "code=attacker-code") {
return app.inject({
method: "GET",
url: `/v1/oauth/discord/callback?${query}&state=${encodeURIComponent(state)}`,
...(cookie ? { headers: { cookie } } : {}),
});
}
describe("OAuth state binding", () => {
it("Soul's replay fails: one state, at most one completion code, and only for the browser that launched it", async () => {
const app = await newApp();
const start = await bifrostStart(app);
const state = stateOf(start);
const victim = await launchFlow(app, start);
// The attacker holds the same state. A second launch yields no cookie and no redirect.
const attackerLaunch = await app.inject({ method: "GET", url: launchUrl(start) });
expect(attackerLaunch.statusCode).toBe(400);
expect(attackerLaunch.json()).toEqual({ error: "state_already_launched" });
expect(setCookieOf(attackerLaunch)).toEqual([]);
expect(attackerLaunch.headers.location).toBeUndefined();
// Without the cookie the callback refuses and mints nothing.
const attackerCallback = await callback(app, state);
expect(attackerCallback.statusCode).toBe(400);
expect(attackerCallback.json()).toEqual({ error: "state_not_bound" });
expect(attackerCallback.body).not.toContain("completion");
// The victim's callback still completes, once.
const victimCallback = await callback(app, state, victim.cookie, "code=victim-code");
expect(victimCallback.statusCode).toBe(201);
const completionCode = victimCallback.json().completion.code as string;
const redeem = await app.inject({
method: "POST",
url: "/v1/apps/bifrost/auth-completions/redeem",
payload: { completionCode },
});
expect(redeem.statusCode).toBe(201);
expect(redeem.json()).toEqual(expect.objectContaining({ status: "success", appSlug: "bifrost" }));
const second = await callback(app, state, victim.cookie, "code=victim-code");
expect(second.statusCode).toBe(400);
expect(second.json()).toEqual({ error: "state_already_used" });
expect(second.body).not.toContain("completion");
});
it("a stranger's callback needs no launch to be refused: no cookie, no completion code", async () => {
const app = await newApp();
const start = await bifrostStart(app);
const response = await callback(app, stateOf(start));
expect(response.statusCode).toBe(400);
expect(response.json()).toEqual({ error: "state_not_bound" });
expect(response.body).not.toContain("completion");
});
it("a cookie for another flow, another key, another type, or the wrong lifetime does not bind", async () => {
const app = await newApp();
const ctx = getHeimdallRuntimeContext(app);
const own = await bifrostStart(app);
const attackerFlow = await app.inject({
method: "POST",
url: "/v1/oauth/discord/start",
payload: { appSlug: "repixelizer", mode: "sign_in", returnTo: "https://repixelizer.gamecult.org/app/" },
});
const attackerStart = attackerFlow.json() as { authorizationUrl: string };
const attackerCookie = (await launchFlow(app, attackerStart)).cookie;
const ownState = stateOf(own);
const ownJti = decodeJwt(ownState).payload.jti as string;
const name = `__Host-heimdall-oauth-${ownJti}`;
const now = Math.floor(Date.now() / 1000);
const binding = (overrides: Record<string, unknown>, keys = ctx.keys) =>
signJwt({ typ: "heimdall_oauth_binding", iss: ctx.config.issuer, jti: ownJti, iat: now, exp: now + 300, ...overrides }, keys);
const foreignKeys = (() => {
const pair = generateKeyPairSync("ed25519");
return { ...ctx.keys, privateKey: pair.privateKey, publicKey: pair.publicKey };
})();
const forged: Record<string, string> = {
"another flow's binding under this flow's cookie name": `${name}=${attackerCookie.split("=")[1]}`,
"a binding signed with a different key": `${name}=${binding({}, foreignKeys)}`,
"a binding for a different jti": `${name}=${binding({ jti: "someone-else" })}`,
"a binding of another type": `${name}=${binding({ typ: "heimdall_oauth_state" })}`,
"a binding from another issuer": `${name}=${binding({ iss: "https://elsewhere.example" })}`,
"an expired binding": `${name}=${binding({ exp: now - 5 })}`,
"the state token used as the cookie": `${name}=${ownState}`,
"a binding whose exp is a string": `${name}=${binding({ exp: String(now + 300) })}`,
"an empty cookie": `${name}=`,
};
for (const [label, cookie] of Object.entries(forged)) {
const response = await callback(app, ownState, cookie);
expect(response.statusCode, label).toBe(400);
expect(response.json(), label).toEqual({ error: "state_not_bound" });
}
// A correctly signed binding for this jti is the one thing that binds.
const good = await callback(app, ownState, `${name}=${binding({})}`);
expect(good.statusCode).toBe(201);
});
it("the cookie is checked before the state is consumed: a stranger cannot burn or deny the victim's flow", async () => {
const app = await newApp();
const ctx = getHeimdallRuntimeContext(app);
const attempt = await ctx.store.createAuthAttempt({
handle: "attempt-1",
appSlug: "repixelizer",
provider: "discord",
mode: "sign_in",
returnTo: "https://repixelizer.gamecult.org/app/",
createdAt: new Date().toISOString(),
expiresAt: new Date(Date.now() + 600_000).toISOString(),
});
const caller = resolveAppCaller(ctx.config, "repixelizer", { "x-heimdall-app-secret": "repixelizer-secret" });
const started = await startOAuthFlow(
ctx,
"discord",
caller,
{ appSlug: "repixelizer", mode: "sign_in", returnTo: "https://repixelizer.gamecult.org/app/", handoff: { kind: "browser_completion" } },
{ trustedBrowserAttemptId: attempt.handle }
);
expect(started.statusCode).toBe(201);
const start = started.body as unknown as { authorizationUrl: string };
const victim = await launchFlow(app, start);
// A cookieless provider error, which would deny the attempt, and a cookieless code: both refused.
for (const query of ["error=access_denied", "code=attacker-code", ""]) {
const stranger = await callback(app, victim.state, undefined, query);
expect(stranger.statusCode, query).toBe(400);
expect(stranger.json(), query).toEqual({ error: "state_not_bound" });
}
expect((await ctx.store.findAuthAttempt("repixelizer", attempt.handle))?.status).toBe("pending");
// The victim's own callback is unharmed.
const response = await callback(app, victim.state, victim.cookie, "code=victim-code");
expect(response.statusCode).toBe(201);
});
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,7 +12,6 @@ import { type HeimdallConfig } from "../src/config.js";
import { startHeimdallPrivateCommandPlane } from "../src/private-command-plane.js";
import { openPrivateEnvelope, sealPrivateEnvelope, type HeimdallPrivateEnvelope } from "../src/private-command-security.js";
import { InMemoryStore } from "../src/store/index.js";
import { launchState } from "./oauth-flow.js";
const secret = "ghostlight-private-command-secret";
const resources: Array<{ close(): Promise<unknown> }> = [];
@ -74,11 +73,10 @@ async function refreshAgainst(refreshAnswer: () => Response) {
const begin = open(await command(plane.endpoint, "heimdall.auth.begin", "heimdall.auth_begin_command.v1", "k1",
{ provider: "discord", mode: "sign_in", returnTo: "https://yggdrasil.gamecult.org/ghostlight/", entitlementPolicy: policy }));
const state = new URL(String((begin.navigation as { url: string }).url)).searchParams.get("state")!;
const { cookie } = await launchState(app, state);
const callback = await app.inject({
method: "GET",
url: `/v1/oauth/discord/callback?code=c1&state=${encodeURIComponent(state)}`,
headers: { accept: "application/json", cookie },
headers: { accept: "application/json" },
});
expect(callback.statusCode, callback.body).toBe(201);
const done = open(await command(plane.endpoint, "heimdall.auth.complete", "heimdall.auth_complete_command.v1", "k2", { handle: begin.handle }));
@ -135,11 +133,10 @@ it("keeps the completion code out of the audit trail when the plane redeems by a
const begin = open(await command(plane.endpoint, "heimdall.auth.begin", "heimdall.auth_begin_command.v1", "k1",
{ provider: "discord", mode: "sign_in", returnTo: "https://yggdrasil.gamecult.org/ghostlight/", entitlementPolicy: policy }));
const state = new URL(String((begin.navigation as { url: string }).url)).searchParams.get("state")!;
const { cookie } = await launchState(app, state);
const callback = await app.inject({
method: "GET",
url: `/v1/oauth/discord/callback?code=c1&state=${encodeURIComponent(state)}`,
headers: { accept: "application/json", cookie },
headers: { accept: "application/json" },
});
const code = (callback.json().completion as { code: string }).code;
const done = open(await command(plane.endpoint, "heimdall.auth.complete", "heimdall.auth_complete_command.v1", "k2", { handle: begin.handle }));

View file

@ -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

@ -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, InMemoryStore } from "../src/store/index.js";
import { createStore } from "../src/store/index.js";
import {
createPostgresStore,
PostgresStore,
@ -198,36 +198,6 @@ 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 has expired, 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);
// At and after expiry the record is dead weight: the state is refused before any claim.
await store.claimOAuthStateUse({ ...use, jti: "state-3", usedAt: "2026-04-26T12:10:00.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
@ -263,12 +233,11 @@ describe("PostgresStore.checkSchema", () => {
"auth_attempts",
"private_command_receipts",
"auth_completions",
"oauth_state_uses",
"capability_grants",
"entitlement_snapshots",
"audit_events",
]);
expect(REQUIRED_COLUMNS).toHaveLength(90);
expect(REQUIRED_COLUMNS).toHaveLength(85);
expect(REQUIRED_COLUMNS).toEqual(
expect.arrayContaining([
{ table: "auth_completions", column: "code", type: "TEXT", nullable: false },
@ -279,12 +248,11 @@ describe("PostgresStore.checkSchema", () => {
{ table: "auth_completions", column: "attempt_id", type: "TEXT", nullable: true },
])
);
expect(REQUIRED_KEYS).toHaveLength(13);
expect(REQUIRED_KEYS).toHaveLength(12);
expect(REQUIRED_KEYS).toEqual(
expect.arrayContaining([
{ table: "linked_identities", columns: "provider,provider_user_id", predicate: "" },
{ table: "private_command_receipts", columns: "app_slug,idempotency_key", predicate: "" },
{ table: "oauth_state_uses", columns: "jti,phase", predicate: "" },
{ table: "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" },
])
@ -397,10 +365,6 @@ async function exerciseStore(store: PostgresStore): Promise<Set<string>> {
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;
}