Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5146c722af | ||
|
|
1a36175f3a | ||
|
|
c044155855 | ||
|
|
c0d30e8081 | ||
|
|
e8548614c8 | ||
|
|
55732a901d | ||
|
|
25d43d1121 | ||
|
|
899ea0b8fe | ||
|
|
c15fb97706 | ||
|
|
c1dfe15dd5 | ||
|
|
c7cf78bbbc |
43 changed files with 1307 additions and 1432 deletions
13
README.md
13
README.md
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
69
pnpm-lock.yaml
generated
|
|
@ -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: {}
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
211
src/app.ts
211
src/app.ts
|
|
@ -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> => {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
45
src/index.ts
45
src/index.ts
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
`
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
47
tests/fastify-malformed-url.test.ts
Normal file
47
tests/fastify-malformed-url.test.ts
Normal 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
64
tests/fixtures/fake-idunn-rudp.ts
vendored
Normal 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(),
|
||||
};
|
||||
}
|
||||
1
tests/fixtures/idunn-runtime/.gitattributes
vendored
Normal file
1
tests/fixtures/idunn-runtime/.gitattributes
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
* -text
|
||||
11
tests/fixtures/idunn-runtime/README.md
vendored
Normal file
11
tests/fixtures/idunn-runtime/README.md
vendored
Normal 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.
|
||||
1
tests/fixtures/idunn-runtime/activation-key.seed
vendored
Normal file
1
tests/fixtures/idunn-runtime/activation-key.seed
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
¯ÂOd×f¯l*oµ‘ÀFªv‰n<E280B0>hLûÑpšS´p
|
||||
BIN
tests/fixtures/idunn-runtime/activation.cc
vendored
Normal file
BIN
tests/fixtures/idunn-runtime/activation.cc
vendored
Normal file
Binary file not shown.
BIN
tests/fixtures/idunn-runtime/expected.cc
vendored
Normal file
BIN
tests/fixtures/idunn-runtime/expected.cc
vendored
Normal file
Binary file not shown.
1
tests/fixtures/idunn-runtime/machine-id
vendored
Normal file
1
tests/fixtures/idunn-runtime/machine-id
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1
|
||||
BIN
tests/fixtures/idunn-runtime/provider-identity.credential
vendored
Normal file
BIN
tests/fixtures/idunn-runtime/provider-identity.credential
vendored
Normal file
Binary file not shown.
61
tests/fixtures/presence-descriptors.ts
vendored
Normal file
61
tests/fixtures/presence-descriptors.ts
vendored
Normal 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
|
||||
}
|
||||
}),
|
||||
};
|
||||
}
|
||||
57
tests/fixtures/state-root-generation-child.ts
vendored
Normal file
57
tests/fixtures/state-root-generation-child.ts
vendored
Normal 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
320
tests/idunn-boot.test.ts
Normal 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);
|
||||
});
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 }));
|
||||
|
|
|
|||
83
tests/publisher-sequence.test.ts
Normal file
83
tests/publisher-sequence.test.ts
Normal 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"]);
|
||||
});
|
||||
});
|
||||
98
tests/state-root-generations.test.ts
Normal file
98
tests/state-root-generations.test.ts
Normal 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);
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue