From df05570ef64394b519bebc134e0c472424ebc438 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 19 Oct 2026 17:56:25 +0200 Subject: [PATCH 01/30] State: Joystick H1 merged and live at d51fa6b; follow-ups recorded by id Co-Authored-By: Claude Opus 5.5 --- notes/fresh-workspace-handoff.md | 26 +++++++++++++++----------- state/evidence.jsonl | 1 + state/map.yaml | 10 +++++----- 3 files changed, 21 insertions(+), 16 deletions(-) diff --git a/notes/fresh-workspace-handoff.md b/notes/fresh-workspace-handoff.md index 4727d7f..ad959f0 100644 --- a/notes/fresh-workspace-handoff.md +++ b/notes/fresh-workspace-handoff.md @@ -26,18 +26,23 @@ Do not trust this file for the exact live HEAD. Always check git. ## Active Work -Joystick H1 is the active, M1-critical cut (operator ruling 2026-10-18): -campaign `joystick:campaign:self`, target `joystick:target:r1`, branch -`eureka/joystick-h1`. Read the campaign and target in the typed mind; do not -re-derive them here. The Ghostlight KLTST canaries below stay open, behind H1. +Joystick H1 is merged and live (PR GameCult/Heimdall#1; campaign +`joystick:campaign:self`). Next: `joystick:follow_up:h1-live-round-trips`, then +StreamPixels S0/S1 (other sessions). Open Heimdall follow-ups, by id: +`joystick:follow_up:cross-process-failed-refresh-marker`, +`joystick:follow_up:connect-start-needs-app-auth`, +`joystick:follow_up:heimdall-idunn-onboarding`, +`joystick:follow_up:joystick-viewer-signin-public-bot`. The Ghostlight KLTST +canaries below stay open, behind them. ## Current Orientation -Deploy facts below are last-recorded, from 2026-08-22 (`1086aee`); HEAD has -moved ~100 commits past it. Check Yggdrasil, not this file, for what runs. +Yggdrasil runs Heimdall `d51fa6b`, hand-deployed 2026-10-19 15:51Z; +gamecult-ops `210a1af` records backup, rollback and provenance. Facts below +about Odin, Ghostlight and CultLib pins were verified at `1086aee` (2026-08-22) +and are not re-verified at `d51fa6b`. -- Yggdrasil runs Heimdall - `1086aee01169bf60e8a492b2740db1c6f3e8cabf` with CultLib +- At `1086aee01169bf60e8a492b2740db1c6f3e8cabf` Heimdall ran with CultLib `5cefa0db0079a8e3ee22f29d7b9e6e5aa60912a9` - Heimdall publishes four redacted access records to Odin under unique keys: `eve:provider:heimdall`, `heimdall:command-boundary`, @@ -157,8 +162,7 @@ moved ~100 commits past it. Check Yggdrasil, not this file, for what runs. over `cultnet.transport.rudp.v0` when `GC_ACCESS_IDUNN_RUDP_HEALTH` is configured; HTTP `/healthz`, JWKS, discovery, systemd, and nginx routing remain compatibility witnesses -- Yggdrasil now runs Heimdall commit - `1086aee01169bf60e8a492b2740db1c6f3e8cabf`. Heimdall persists its private +- Since `1086aee` Heimdall persists its private provider-health identity at `/srv/heimdall/keys/provider-health-identity.cc` and publishes canonical signed health independently of the slower Verse snapshot cadence. Idunn owns the exact root trust binding, authenticated @@ -180,7 +184,7 @@ moved ~100 commits past it. Check Yggdrasil, not this file, for what runs. ## Next Real Move -Joystick H1 first (see Active Work). Everything below is the standing queue. +The Active Work follow-ups first. Everything below is the standing queue. Do not continue implementation automatically from a rehydrate-only request. diff --git a/state/evidence.jsonl b/state/evidence.jsonl index 3178947..ba9a942 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -35,3 +35,4 @@ {"ts":"2026-08-22T02:01:00+00:00","type":"deployment","status":"accepted","note":"Yggdrasil runs Heimdall 1086aee01169bf60e8a492b2740db1c6f3e8cabf with CultLib 5cefa0db0079a8e3ee22f29d7b9e6e5aa60912a9. Odin b4f9a2e95f0b41cebdeddc49223781d1d3c7b42a returns Heimdall's provider, command boundary, Eve plugin, and transport profile under unique catalog keys. Ghostlight b515ca90c25573005a616244143803b37f2d06ec accepted canonical auth.begin through the Odin-resolved boundary; only advertised Discord and Heimdall navigation origins reached the browser."} {"ts": "2026-09-06T22:12:03+00:00", "type": "design", "status": "accepted", "note": "App slugs are no longer compile-time. AppSlug is a string; membership is a registry lookup, or isAppSlug shape validation where no store is reachable (the offline verifier). Capability rules are data (anyOf over Heimdall's own fact vocabulary), replacing a per-profile evaluateSharedCapabilities function that could not be accepted from a stranger. POST /v1/apps registers in RFC 7591 shape, gated on GC_ACCESS_APP_REGISTRATION_SECRET and closed until it is set. Built-in slugs are reserved so a registration cannot shadow a shipped app's audience. Motivation: a creator self-hosting Erycina alongside Heimdall would otherwise maintain a patched auth server to add one slug."} {"ts": "2026-09-08T12:20:12+00:00", "type": "security", "status": "accepted", "note": "epiphany/heimdall-caller-identity, second Hands pass: closed the two Soul-proven holes. (1) auth-completions redeem code is now always store-minted (never handoff.attemptId); attemptId survives only as a separate correlation field on StoredAuthCompletion/CreateAuthCompletionInput, and a new store.consumeAuthCompletionByAttempt(appSlug, attemptId, at) lets the private command plane's completeAuth redeem by the handle it already owns instead of forging an HTTP self-call with completionCode=handle. (2) startOAuthFlow now refuses any returnTo whose origin is not in AppProfile.allowedReturnOrigins (repixelizer/streampixels/bifrost -> .gamecult.org, ghostlight -> yggdrasil.gamecult.org per its docs/app-profiles doc and existing test fixtures; a registered app's allowlist is derived from its stored redirectUris origins). AppCaller is now branded with an unexported Symbol (app-caller.ts); private-command-plane.ts's two { appSlug } literals are gone, replaced by app-caller.ts's callerFromOpenedEnvelope. Deleted: validateAppRegistration, AppRegistrationRequest, RegistrationProblem, AppRegistrationError, HeimdallStore.registerApp (writer). Kept: findRegisteredApp/listRegisteredApps/resolveAppProfile fallback -- AppSlug is a plain string (contracts.ts), registered_apps is a real extension point for a slug Heimdall was never rebuilt for, and isBuiltInApp's only production call site (listAppProfiles) depends on that fallback existing; a row is now provisioned by direct SQL (or InMemoryStore.seedRegisteredApp in tests), not by any app-facing writer. Deleted the two appSlug===\"ghostlight\" branches in private-command-plane.ts; the requirement is now AppProfile.requiredEntitlementPolicyKind, checked generically through resolveAppProfile. tsc --noEmit clean; vitest 94 passed/1 pre-existing-skip, only pre-existing failure is tests/odin-publication.test.ts (Cannot find module 'cultcache-ts', present on main too).", "branch": "epiphany/heimdall-caller-identity"} +{"ts": "2026-10-19T15:56:24+00:00", "type": "design", "status": "accepted", "note": "Refresh is now one locked primitive for every provider (replaces unserialized refreshLinkedIdentityCredentialIfNeeded); Joystick is managed-only (Heimdall-held credential, no viewer sign-in until joystick:follow_up:joystick-viewer-signin-public-bot). Merged in PR #1, live at d51fa6b; campaign joystick:campaign:self.", "branch": "eureka/joystick-h1"} diff --git a/state/map.yaml b/state/map.yaml index 3f8772a..413718e 100644 --- a/state/map.yaml +++ b/state/map.yaml @@ -59,8 +59,8 @@ data_flow: - reject per-request auth round-trip chatter active_subgoals: - - ACTIVE, M1-critical (operator ruling 2026-10-18): Joystick H1, campaign joystick:campaign:self (branch eureka/joystick-h1). Joystick OAuth provider, joystick as a streampixels viewer provider, one locked refresh primitive for every provider replacing refreshLinkedIdentityCredentialIfNeeded. - - OPEN, behind H1: pressure the deployed Ghostlight gamecult.heimdall.access integration with two- and eight-account human canaries. + - Joystick H1 is merged and live (PR GameCult/Heimdall#1, d51fa6b; campaign joystick:campaign:self). Open follow-ups by id: joystick:follow_up:h1-live-round-trips (next), cross-process-failed-refresh-marker, connect-start-needs-app-auth, heimdall-idunn-onboarding, joystick-viewer-signin-public-bot. StreamPixels S0/S1 run in other sessions. + - OPEN, behind the H1 live round-trips: pressure the deployed Ghostlight gamecult.heimdall.access integration with two- and eight-account human canaries. - Keep the shared architecture explicit and separate from app profiles. - Preserve the default boundary that app-domain data remains app-owned. - Align the plan to Heimdall as a Yggdrasil-hosted authority service. @@ -95,7 +95,7 @@ accepted_design: landed_baseline: - Heimdall publishes its redacted provider, command boundary, Eve plugin, and transport profile through Odin under globally unique record keys; Ghostlight resolves that boundary instead of owning a direct endpoint. - - Yggdrasil runs Heimdall 1086aee01169bf60e8a492b2740db1c6f3e8cabf with CultLib 5cefa0db0079a8e3ee22f29d7b9e6e5aa60912a9; live Ghostlight auth.begin through Odin discovery is accepted. + - Yggdrasil runs Heimdall d51fa6b (hand-deployed 2026-10-19 15:51Z; gamecult-ops 210a1af holds backup, rollback and provenance; prior 1086aee) with CultLib 5cefa0db0079a8e3ee22f29d7b9e6e5aa60912a9; live Ghostlight auth.begin through Odin discovery is accepted. - The Heimdall access plugin, persistent OAuth attempts, encrypted private CultNet commands, conformance fixtures, and browser reference adapter are implemented and locally verified. - Exact-session refresh validation and logout revocation are implemented; stale or racing refresh cannot resurrect a revoked session. - Shared architecture extracted into its own repo and renamed to Heimdall. @@ -126,5 +126,5 @@ rejected_paths: reason: reusing auth mechanics does not justify centralizing app-domain audience or product data current_status: - summary: Active work is Joystick H1 (campaign joystick:campaign:self). Last recorded Yggdrasil deploy (not verifiable from this repo; HEAD is far past it, see git): Yggdrasil runs the gamecult.heimdall.access plugin and encrypted loopback begin, complete, refresh, and logout plane at 1086aee01169bf60e8a492b2740db1c6f3e8cabf. Odin returns all four redacted discovery records, Ghostlight resolves the boundary through Odin, and live anonymous auth.begin is accepted without exposing claims or secrets. - next_action: Land Joystick H1 per campaign joystick:campaign:self and target joystick:target:r1 (worktree and branch eureka/joystick-h1). Then, still open, run separate-account KLTST completion, refresh, logout, privacy, and stale-attempt canaries through the deployed Ghostlight Eve host; keep provider credential rotation on the operator list because prior tool output exposed OAuth secrets. + summary: Yggdrasil runs Heimdall release d51fa6b (hand-deployed 2026-10-19 15:51Z, gamecult-ops 210a1af), which includes Joystick H1 (PR #1). The plugin and encrypted loopback begin, complete, refresh and logout plane, the Odin discovery records and Ghostlight resolution were verified at 1086aee (2026-08-22) and are not re-verified at d51fa6b. + next_action: Run joystick:follow_up:h1-live-round-trips, then StreamPixels S0/S1 (other sessions). Still open behind that: separate-account KLTST completion, refresh, logout, privacy, and stale-attempt canaries through the deployed Ghostlight Eve host; keep provider credential rotation on the operator list because prior tool output exposed OAuth secrets. From 30a1ace6fdfa22a7be25a7ec11a4d012b51bec7f Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 19 Oct 2026 17:56:34 +0200 Subject: [PATCH 02/30] State: close eureka/joystick-h1 as accepted (merged, PR #1) Co-Authored-By: Claude Opus 5.5 --- state/branches.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/state/branches.json b/state/branches.json index 643b64f..32cd28f 100644 --- a/state/branches.json +++ b/state/branches.json @@ -16,11 +16,11 @@ { "id": "eureka/joystick-h1", "hypothesis": "One locked refresh primitive plus a Joystick OAuth provider in Heimdall lets StreamPixels ingest Joystick without ever presenting a provider refresh token.", - "status": "active", + "status": "accepted", "artifacts": [ "state/map.yaml" ], - "notes": "Campaign joystick:campaign:self, target joystick:target:r1. Hands owns code in a separate worktree." + "notes": "Merged as PR GameCult/Heimdall#1 (d51fa6b), deployed to Yggdrasil 2026-10-19 15:51Z; gamecult-ops 210a1af. Campaign joystick:campaign:self." } ] } From 5952cce8a0f52691284e34e5530c23c503b6f03b Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 19 Oct 2026 18:03:17 +0200 Subject: [PATCH 03/30] State: live d51fa6b proven by a human Twitch sign-in; Joystick consent remains Co-Authored-By: Claude Opus 5.5 --- notes/fresh-workspace-handoff.md | 2 +- state/evidence.jsonl | 1 + state/map.yaml | 4 ++-- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/notes/fresh-workspace-handoff.md b/notes/fresh-workspace-handoff.md index ad959f0..9d23cf1 100644 --- a/notes/fresh-workspace-handoff.md +++ b/notes/fresh-workspace-handoff.md @@ -27,7 +27,7 @@ Do not trust this file for the exact live HEAD. Always check git. ## Active Work Joystick H1 is merged and live (PR GameCult/Heimdall#1; campaign -`joystick:campaign:self`). Next: `joystick:follow_up:h1-live-round-trips`, then +`joystick:campaign:self`). Next: `joystick:follow_up:h1-joystick-consent-round-trip`, then StreamPixels S0/S1 (other sessions). Open Heimdall follow-ups, by id: `joystick:follow_up:cross-process-failed-refresh-marker`, `joystick:follow_up:connect-start-needs-app-auth`, diff --git a/state/evidence.jsonl b/state/evidence.jsonl index ba9a942..5329d5f 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -36,3 +36,4 @@ {"ts": "2026-09-06T22:12:03+00:00", "type": "design", "status": "accepted", "note": "App slugs are no longer compile-time. AppSlug is a string; membership is a registry lookup, or isAppSlug shape validation where no store is reachable (the offline verifier). Capability rules are data (anyOf over Heimdall's own fact vocabulary), replacing a per-profile evaluateSharedCapabilities function that could not be accepted from a stranger. POST /v1/apps registers in RFC 7591 shape, gated on GC_ACCESS_APP_REGISTRATION_SECRET and closed until it is set. Built-in slugs are reserved so a registration cannot shadow a shipped app's audience. Motivation: a creator self-hosting Erycina alongside Heimdall would otherwise maintain a patched auth server to add one slug."} {"ts": "2026-09-08T12:20:12+00:00", "type": "security", "status": "accepted", "note": "epiphany/heimdall-caller-identity, second Hands pass: closed the two Soul-proven holes. (1) auth-completions redeem code is now always store-minted (never handoff.attemptId); attemptId survives only as a separate correlation field on StoredAuthCompletion/CreateAuthCompletionInput, and a new store.consumeAuthCompletionByAttempt(appSlug, attemptId, at) lets the private command plane's completeAuth redeem by the handle it already owns instead of forging an HTTP self-call with completionCode=handle. (2) startOAuthFlow now refuses any returnTo whose origin is not in AppProfile.allowedReturnOrigins (repixelizer/streampixels/bifrost -> .gamecult.org, ghostlight -> yggdrasil.gamecult.org per its docs/app-profiles doc and existing test fixtures; a registered app's allowlist is derived from its stored redirectUris origins). AppCaller is now branded with an unexported Symbol (app-caller.ts); private-command-plane.ts's two { appSlug } literals are gone, replaced by app-caller.ts's callerFromOpenedEnvelope. Deleted: validateAppRegistration, AppRegistrationRequest, RegistrationProblem, AppRegistrationError, HeimdallStore.registerApp (writer). Kept: findRegisteredApp/listRegisteredApps/resolveAppProfile fallback -- AppSlug is a plain string (contracts.ts), registered_apps is a real extension point for a slug Heimdall was never rebuilt for, and isBuiltInApp's only production call site (listAppProfiles) depends on that fallback existing; a row is now provisioned by direct SQL (or InMemoryStore.seedRegisteredApp in tests), not by any app-facing writer. Deleted the two appSlug===\"ghostlight\" branches in private-command-plane.ts; the requirement is now AppProfile.requiredEntitlementPolicyKind, checked generically through resolveAppProfile. tsc --noEmit clean; vitest 94 passed/1 pre-existing-skip, only pre-existing failure is tests/odin-publication.test.ts (Cannot find module 'cultcache-ts', present on main too).", "branch": "epiphany/heimdall-caller-identity"} {"ts": "2026-10-19T15:56:24+00:00", "type": "design", "status": "accepted", "note": "Refresh is now one locked primitive for every provider (replaces unserialized refreshLinkedIdentityCredentialIfNeeded); Joystick is managed-only (Heimdall-held credential, no viewer sign-in until joystick:follow_up:joystick-viewer-signin-public-bot). Merged in PR #1, live at d51fa6b; campaign joystick:campaign:self.", "branch": "eureka/joystick-h1"} +{"ts": "2026-10-19T16:03:16+00:00", "type": "verification", "status": "accepted", "note": "Live d51fa6b (H1) verified by a real human sign-in: the operator's StreamPixels Twitch sign-in through Heimdall start/callback, 2026-10-19 ('Twitch auth worked fine'); a streampixels session was created 16:02:33Z, after the 15:51Z switch. Joystick consent round trip still open: joystick:follow_up:h1-joystick-consent-round-trip."} diff --git a/state/map.yaml b/state/map.yaml index 413718e..4aa4d5a 100644 --- a/state/map.yaml +++ b/state/map.yaml @@ -59,7 +59,7 @@ data_flow: - reject per-request auth round-trip chatter active_subgoals: - - Joystick H1 is merged and live (PR GameCult/Heimdall#1, d51fa6b; campaign joystick:campaign:self). Open follow-ups by id: joystick:follow_up:h1-live-round-trips (next), cross-process-failed-refresh-marker, connect-start-needs-app-auth, heimdall-idunn-onboarding, joystick-viewer-signin-public-bot. StreamPixels S0/S1 run in other sessions. + - Joystick H1 is merged and live (PR GameCult/Heimdall#1, d51fa6b; campaign joystick:campaign:self). Open follow-ups by id: joystick:follow_up:h1-joystick-consent-round-trip (next), cross-process-failed-refresh-marker, connect-start-needs-app-auth, heimdall-idunn-onboarding, joystick-viewer-signin-public-bot. StreamPixels S0/S1 run in other sessions. - OPEN, behind the H1 live round-trips: pressure the deployed Ghostlight gamecult.heimdall.access integration with two- and eight-account human canaries. - Keep the shared architecture explicit and separate from app profiles. - Preserve the default boundary that app-domain data remains app-owned. @@ -127,4 +127,4 @@ rejected_paths: current_status: summary: Yggdrasil runs Heimdall release d51fa6b (hand-deployed 2026-10-19 15:51Z, gamecult-ops 210a1af), which includes Joystick H1 (PR #1). The plugin and encrypted loopback begin, complete, refresh and logout plane, the Odin discovery records and Ghostlight resolution were verified at 1086aee (2026-08-22) and are not re-verified at d51fa6b. - next_action: Run joystick:follow_up:h1-live-round-trips, then StreamPixels S0/S1 (other sessions). Still open behind that: separate-account KLTST completion, refresh, logout, privacy, and stale-attempt canaries through the deployed Ghostlight Eve host; keep provider credential rotation on the operator list because prior tool output exposed OAuth secrets. + next_action: Run joystick:follow_up:h1-joystick-consent-round-trip, then StreamPixels S0/S1 (other sessions). Still open behind that: separate-account KLTST completion, refresh, logout, privacy, and stale-attempt canaries through the deployed Ghostlight Eve host; keep provider credential rotation on the operator list because prior tool output exposed OAuth secrets. From 6d43eacd84e69bb665b455acc57514651c581915 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 22 Oct 2026 18:13:02 +0200 Subject: [PATCH 04/30] Tests: apps that bind browser completion refuse unauthenticated starts (red; does not pass yet) The replay of finding known-nonce-login-csrf at the HTTP surface, plus authenticated, cross-app and public-app cases. Co-Authored-By: Claude Sonnet 5.5 --- tests/app.test.ts | 98 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 98 insertions(+) diff --git a/tests/app.test.ts b/tests/app.test.ts index 7f4d96d..4fe939c 100644 --- a/tests/app.test.ts +++ b/tests/app.test.ts @@ -249,6 +249,104 @@ 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?: object) => ({ + appSlug: "bifrost", + mode: "sign_in", + 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(response.json().stateToken as string, 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("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 From 7e17a0e95236ef2a695b26b4a25a78dbb9370291 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 22 Oct 2026 18:14:37 +0200 Subject: [PATCH 05/30] Start: a profile with startRequiresAppCaller refuses unauthenticated starts; set on Bifrost Bifrost's browser binding is the secrecy of a nonce carried readable in the signed state, so only Bifrost may mint a Bifrost state. The gate sits in startOAuthFlow beside the entitlementPolicy caller check, so HTTP and the private command plane share it. Follows the red tests. Co-Authored-By: Claude Sonnet 5.5 --- src/app-profiles.ts | 12 ++++++++++++ src/app.ts | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/app-profiles.ts b/src/app-profiles.ts index 3489a48..70a9b34 100644 --- a/src/app-profiles.ts +++ b/src/app-profiles.ts @@ -45,6 +45,17 @@ export interface AppProfile { * plane; absent for apps with no such requirement. */ requiredEntitlementPolicyKind?: OAuthEntitlementPolicy["kind"]; + /** + * The app binds browser completion to its own pending attempt, and the + * binding is the secrecy of the attempt nonce, which rides readable in the + * signed (not encrypted) state's return_to. If anyone could start a flow for + * this app, whoever learned a victim's nonce could mint a completion bound + * to it and sign the victim's browser in as themselves (login CSRF, finding + * known-nonce-login-csrf). So a start for this app is refused unless the + * caller is this app's authenticated caller: only the app can mint its own + * state. Absent for apps whose browser starts are public by design. + */ + startRequiresAppCaller?: boolean; } /** The membership signal shared by every app that gates on GameCult membership. */ @@ -142,6 +153,7 @@ const bifrostProfile: AppProfile = { entitlementSources: ["discord", "patreon"], managedConnectionProviders: [], allowedReturnOrigins: ["https://bifrost.gamecult.org"], + startRequiresAppCaller: true, capabilities: [ { key: "member_access", diff --git a/src/app.ts b/src/app.ts index 40ceb20..855c34e 100644 --- a/src/app.ts +++ b/src/app.ts @@ -554,7 +554,7 @@ export async function startOAuthFlow( if (options?.trustedBrowserAttemptId && handoff.kind === "browser_completion") { handoff = { kind: "browser_completion", attemptId: options.trustedBrowserAttemptId }; } - if (entitlementPolicy && (!isAppCaller(caller) || caller.appSlug !== profile.slug)) { + if ((entitlementPolicy || profile.startRequiresAppCaller) && (!isAppCaller(caller) || caller.appSlug !== profile.slug)) { return { statusCode: 401, body: { error: "app_auth_required" } }; } From ed964201d4d08d29f4c9140eab94f77429b5b4b7 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 22 Oct 2026 18:14:37 +0200 Subject: [PATCH 06/30] Docs: startRequiresAppCaller, and Heimdall authenticates while Bifrost issues identity Co-Authored-By: Claude Sonnet 5.5 --- docs/architecture.md | 6 +++++- docs/service-contract.md | 7 ++++++- docs/verse-service-contract.md | 3 ++- 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 8ae5c19..b91b4bd 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -154,7 +154,11 @@ The important split: - Heimdall owns provider OAuth, identity linking, entitlement refresh, signed sessions, claims, grants, audit surfaces, and managed provider credential - custody + custody. It authenticates; it does not issue GameCult identity. Bifrost is + the identity provider, and the account id Heimdall mints is an authenticator + handle. Remaining scar: Heimdall still creates that account id on first + login (`store.createAccount`, `src/app.ts`), which the chat work does not + need moved - the host experiment owns its workload, queue, editor, render path, viewer profile model, creator model, or other product-specific machinery - the host experiment also owns which local entity a connection attaches to, diff --git a/docs/service-contract.md b/docs/service-contract.md index 1567ae5..249c965 100644 --- a/docs/service-contract.md +++ b/docs/service-contract.md @@ -168,7 +168,12 @@ Failure modes: - `401 app_auth_required` when the request body carries `entitlementPolicy` and the caller did not authenticate as that app via `x-heimdall-app-secret` (`resolveAppCaller`, `src/app-caller.ts`) -- this applies regardless of - `handoff.kind` or app slug; a caller with no policy to attach needs no secret + `handoff.kind` or app slug; a caller with no policy to attach needs no secret, + unless the app's profile sets `startRequiresAppCaller` (Bifrost only): then + every start for that app, with or without a policy, needs that app's secret. + Bifrost binds browser completion to a nonce that rides readable in the signed + state's `return_to`, so only Bifrost may mint a Bifrost state; apps whose + browser starts are public by design leave the flag unset Launch note: diff --git a/docs/verse-service-contract.md b/docs/verse-service-contract.md index fcfa4ac..f3c98e9 100644 --- a/docs/verse-service-contract.md +++ b/docs/verse-service-contract.md @@ -11,7 +11,8 @@ evidence only. ## Owner Map -- Owner: Heimdall owns provider identity/linking, OAuth attempts, signed session and claim +- Owner: Heimdall owns provider identity/linking (an authenticator handle, not + GameCult identity, which is Bifrost's), OAuth attempts, signed session and claim issuance, entitlement refresh, managed provider token custody, grants, auth-completion handoffs, refresh sessions, and audit events. - Inputs: provider OAuth callbacks, app profile requests, caller-supplied From 9fd152feff34f4ea4b1e3a41e421eb5b88774302 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 22 Oct 2026 18:24:42 +0200 Subject: [PATCH 07/30] Tests: a foreign app's caller cannot start a Bifrost flow, with no policy attached Kills the surviving mutant on the caller.appSlug comparison in startOAuthFlow: over HTTP a foreign secret never resolves to a caller, so only a direct call reaches that arm. Co-Authored-By: Claude Sonnet 5.5 --- tests/app.test.ts | 26 ++++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/tests/app.test.ts b/tests/app.test.ts index 4fe939c..fa556c5 100644 --- a/tests/app.test.ts +++ b/tests/app.test.ts @@ -4,7 +4,8 @@ import { join } from "node:path"; import { inspect } from "node:util"; import { afterEach, describe, expect, it } from "vitest"; import { type FastifyInstance } from "fastify"; -import { buildApp, errorIdentity } from "../src/app.js"; +import { buildApp, errorIdentity, getHeimdallRuntimeContext, startOAuthFlow } from "../src/app.js"; +import { resolveAppCaller } from "../src/app-caller.js"; import { builtInAppProfiles, callbackMint } from "../src/app-profiles.js"; import { deliverBackendHandoff, type BackendHandoffPayload } from "../src/backend-handoff.js"; import { type HeimdallConfig, loadConfig } from "../src/config.js"; @@ -256,9 +257,9 @@ describe("Heimdall service", () => { // (finding known-nonce-login-csrf). const victimAttempt = "VICTIM-ATTEMPT-NONCE"; const bifrostReturn = `https://bifrost.gamecult.org/auth/heimdall/complete?attempt=${victimAttempt}`; - const bifrostStart = (handoff?: object) => ({ - appSlug: "bifrost", - mode: "sign_in", + const bifrostStart = (handoff?: { kind: "browser_completion" }) => ({ + appSlug: "bifrost" as const, + mode: "sign_in" as const, returnTo: bifrostReturn, ...(handoff ? { handoff } : {}), }); @@ -323,6 +324,23 @@ describe("Heimdall service", () => { } }); + 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("leaves public-by-design apps unchanged: unauthenticated browser_completion starts still succeed", async () => { const app = await buildApp({ config: createTestConfig() }); apps.push(app); From ece6ba1d620feed87883d54bb35c984af7832bcf Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 22 Oct 2026 19:04:10 +0200 Subject: [PATCH 08/30] Tests: a state completes once and only in the browser that launched it; the gate holds for every mode, provider and handoff (red against the callback that accepts any signed state) Co-Authored-By: Claude Sonnet 5.5 --- tests/app.test.ts | 131 ++++--------- tests/oauth-flow.ts | 124 ++++++++++++ tests/oauth-state-binding.test.ts | 314 ++++++++++++++++++++++++++++++ tests/store.test.ts | 16 ++ 4 files changed, 495 insertions(+), 90 deletions(-) create mode 100644 tests/oauth-flow.ts create mode 100644 tests/oauth-state-binding.test.ts diff --git a/tests/app.test.ts b/tests/app.test.ts index fa556c5..7e7e55f 100644 --- a/tests/app.test.ts +++ b/tests/app.test.ts @@ -13,96 +13,7 @@ import { entitlementFacts, identityFacts } from "../src/facts.js"; import { createOAuthRuntimeRegistry, ProviderResponseError, type OAuthProviderRuntime } from "../src/oauth.js"; import { createRuntimeKeyMaterial, signJwt, verifyJwt } from "../src/signing.js"; import { InMemoryStore } from "../src/store/index.js"; - -function createTestConfig(): HeimdallConfig { - return { - serviceName: "heimdall", - host: "127.0.0.1", - port: 4100, - workspaceRoot: "E:/Projects/Heimdall", - dataRoot: "E:/Projects/Heimdall/.heimdall-data", - cultCachePath: "E:/Projects/Heimdall/.heimdall-data/cultcache/heimdall.service.cc", - publicBaseUrl: "https://heimdall.gamecult.org", - issuer: "https://heimdall.gamecult.org", - daemonId: "yggdrasil-heimdall", - idunnRudpHealth: undefined, - idunnHealthContract: "heimdall.cultnet-rudp-provider-health", - providerHealthIdentityPath: "E:/Projects/Heimdall/.heimdall-data/provider-health-identity.cc", - sessionTtlSeconds: 3600, - refreshTtlSeconds: 60 * 60 * 24 * 30, - stateTtlSeconds: 600, - completionTtlSeconds: 300, - bootstrapSigningPrivateKeyOnMissing: false, - tokenEncryptionKeyBase64: Buffer.alloc(32, 7).toString("base64"), - appSharedSecrets: { - streampixels: "streampixels-secret", - repixelizer: "repixelizer-secret", - bifrost: "bifrost-secret", - }, - appBackendCallbacks: { - bifrost: ["https://bifrost.gamecult.org/auth/heimdall/callback"], - repixelizer: ["https://repixelizer.gamecult.org/api/auth/heimdall/callback"], - }, - storage: { - backend: "memory", - applySchemaOnStartup: true, - }, - providers: { - discord: { clientId: "discord-client", clientSecret: "discord-secret" }, - patreon: { clientId: "patreon-client", clientSecret: "patreon-secret" }, - github: { clientId: "github-client", clientSecret: "github-secret" }, - twitch: { clientId: "twitch-client", clientSecret: "twitch-secret" }, - youtube: { clientId: "youtube-client", clientSecret: "youtube-secret" }, - spotify: { clientId: "spotify-client", clientSecret: "spotify-secret" }, - joystick: { clientId: "joystick-client", clientSecret: "joystick-secret" }, - }, - }; -} - -function createMockDiscordRuntime(): OAuthProviderRuntime { - return { - async exchangeAuthorizationCode() { - return { - accessToken: "discord-access-token", - refreshToken: "discord-refresh-token", - tokenType: "Bearer", - scope: ["identify", "guilds.members.read"], - expiresAt: "2026-04-26T13:00:00.000Z", - raw: { source: "test" }, - }; - }, - async resolveIdentity() { - return { - provider: "discord", - providerUserId: "discord-user-123", - username: "meta", - displayName: "Meta", - primaryEmail: "meta@gamecult.org", - profile: { id: "discord-user-123", username: "meta" }, - }; - }, - async evaluateEntitlements({ callback }) { - return { - facts: [entitlementFacts.appAccess], - snapshots: [ - { - accountId: callback.accountId, - provider: "discord", - scope: "repixelizer:discord_role_access:gamecult-guild", - evaluatedAt: "2026-04-26T12:00:00.000Z", - isAllowed: true, - reasonCode: "matched_role", - reasonDetail: "Matched Repixelizer access role.", - rawSummaryJson: { - guildId: "gamecult-guild", - matchedRoles: ["role-repixelizer"], - }, - }, - ], - }; - }, - }; -} +import { createMockDiscordRuntime, createTestConfig } from "./oauth-flow.js"; const apps: Awaited>[] = []; const originalFetch = globalThis.fetch; @@ -341,6 +252,46 @@ describe("Heimdall service", () => { 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); diff --git a/tests/oauth-flow.ts b/tests/oauth-flow.ts new file mode 100644 index 0000000..6ff582e --- /dev/null +++ b/tests/oauth-flow.ts @@ -0,0 +1,124 @@ +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"; + +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[] { + return [response.headers["set-cookie"]].flat().filter((value): value is string => typeof value === "string"); +} + +/** Drives what a user's browser does after a start: follow authorizationUrl to the launch hop and keep its cookie. */ +export async function launchFlow(app: FastifyInstance, start: StartBody): Promise { + const url = new URL(start.authorizationUrl); + const response = await app.inject({ method: "GET", url: `${url.pathname}${url.search}` }); + 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: stateOf(start), cookie: (set as string).split(";")[0] as string }; +} diff --git a/tests/oauth-state-binding.test.ts b/tests/oauth-state-binding.test.ts new file mode 100644 index 0000000..3390d7f --- /dev/null +++ b/tests/oauth-state-binding.test.ts @@ -0,0 +1,314 @@ +import { generateKeyPairSync } from "node:crypto"; +import { afterEach, describe, expect, it } 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 () => { + 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 = {}) { + 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, 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 = { + "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}`, + "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/); + }); + + 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)); + } + }); +}); diff --git a/tests/store.test.ts b/tests/store.test.ts index c5f6445..c94c521 100644 --- a/tests/store.test.ts +++ b/tests/store.test.ts @@ -159,6 +159,18 @@ describe("PostgresStore", () => { const consumedAgain = await store.consumeAuthCompletion("repixelizer", completion.code, "2026-04-26T12:02:00.000Z"); expect(consumedAgain).toBeNull(); + const stateUse = { + jti: "state-1", + phase: "launch" as const, + appSlug: "repixelizer" as const, + usedAt: now, + expiresAt: "2026-04-26T12:10:00.000Z", + }; + expect(await store.claimOAuthStateUse(stateUse)).toBe(true); + expect(await store.claimOAuthStateUse(stateUse)).toBe(false); + expect(await store.claimOAuthStateUse({ ...stateUse, phase: "callback" })).toBe(true); + expect(await store.claimOAuthStateUse({ ...stateUse, jti: "state-2" })).toBe(true); + const attempt = await store.createAuthAttempt({ handle: "attempt-123", appSlug: "ghostlight", @@ -365,6 +377,10 @@ async function exerciseStore(store: PostgresStore): Promise> { 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; } From c7cf78bbbce303f4e218ae15cbc8826fab840e1a Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 22 Oct 2026 20:03:50 +0200 Subject: [PATCH 09/30] README: front door with value, problem, entry points, live versus planned Co-Authored-By: Claude Opus 5.5 --- README.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/README.md b/README.md index a34dc85..b86dbdc 100644 --- a/README.md +++ b/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. From 607c2b0a2056b190b7c4e4529cf0968fea69d07b Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 22 Oct 2026 20:13:16 +0200 Subject: [PATCH 10/30] Depend on @fastify/cookie for the OAuth binding cookie Co-Authored-By: Claude Sonnet 5.5 --- package.json | 1 + pnpm-lock.yaml | 22 ++++++++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/package.json b/package.json index 0c6cf4f..4eb8e55 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "bundle": "esbuild src/index.ts --bundle --platform=node --format=esm --target=node24 --outfile=dist/heimdall --external:pg-native --banner:js=\"#!/usr/bin/env node\nimport{createRequire}from'node:module';const require=createRequire(import.meta.url);\"" }, "dependencies": { + "@fastify/cookie": "^11.1.3", "@fastify/cors": "^11.2.0", "@msgpack/msgpack": "^3.1.3", "@gamecult/cultcache-ts": "file:vendor/CultLib/packages/cultcache-ts", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 005bb26..eb1d1db 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,6 +12,9 @@ importers: .: dependencies: + '@fastify/cookie': + specifier: ^11.1.3 + version: 11.1.3 '@fastify/cors': specifier: ^11.2.0 version: 11.2.0 @@ -229,6 +232,9 @@ packages: '@fastify/ajv-compiler@4.0.5': resolution: {integrity: sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==} + '@fastify/cookie@11.1.3': + resolution: {integrity: sha512-RBhlMNsmSG6C5KGiTkhSXBF75xIrUMSytDDBcpnVewhJ8XVYnJTu+SV6FYKXClIRsBuRjOfKbBSULvnPD3X/mg==} + '@fastify/cors@11.2.0': resolution: {integrity: sha512-LbLHBuSAdGdSFZYTLVA3+Ch2t+sA6nq3Ejc6XLAKiQ6ViS2qFnvicpj0htsx03FyYeLs04HfRNBsz/a8SvbcUw==} @@ -469,6 +475,10 @@ packages: resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} engines: {node: '>=18'} + cookie@2.0.1: + resolution: {integrity: sha512-yuToqVvRrj6pfDXREyQAAv8SkAEk/8GS3jQRTiUMm66TVtBYmqQeoEjL2Lmq8Rpo6271vH76InTChTitEAm65w==} + engines: {node: '>=22'} + cultmesh-ts@file:vendor/CultLib/packages/cultmesh-ts: resolution: {directory: vendor/CultLib/packages/cultmesh-ts, type: directory} engines: {node: '>=18'} @@ -541,6 +551,9 @@ packages: fastify-plugin@5.1.0: resolution: {integrity: sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==} + fastify-plugin@6.0.1: + resolution: {integrity: sha512-4wW4RxSmvPXLrh5mgQEiu43qU3bw44N2ss4FTkTkQIbprUhE+/s43QX0j/ttcztdWK7PEkHqVthdc5GdEGDvjA==} + fastify@5.8.5: resolution: {integrity: sha512-Yqptv59pQzPgQUSIm87hMqHJmdkb1+GPxdE6vW6FRyVE9G86mt7rOghitiU4JHRaTyDUk9pfeKmDeu70lAwM4Q==} @@ -1180,6 +1193,11 @@ 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 @@ -1387,6 +1405,8 @@ snapshots: cookie@1.1.1: {} + cookie@2.0.1: {} + cultmesh-ts@file:vendor/CultLib/packages/cultmesh-ts: dependencies: '@gamecult/cultcache-ts': file:vendor/CultLib/packages/cultcache-ts @@ -1483,6 +1503,8 @@ snapshots: fastify-plugin@5.1.0: {} + fastify-plugin@6.0.1: {} + fastify@5.8.5: dependencies: '@fastify/ajv-compiler': 4.0.5 From 14e0cde26c506347b81809785c5f61dc6fa8990e Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 22 Oct 2026 20:13:16 +0200 Subject: [PATCH 11/30] Store: claimOAuthStateUse claims one use of a signed OAuth state per (jti, phase), on both stores and an INSERT-only table Co-Authored-By: Claude Sonnet 5.5 --- src/store/in-memory.ts | 17 ++++++++++++++ src/store/postgres.ts | 15 +++++++++++++ src/store/schema.ts | 14 ++++++++++++ src/store/types.ts | 18 +++++++++++++++ tests/store.test.ts | 50 +++++++++++++++++++++++++++++------------- 5 files changed, 99 insertions(+), 15 deletions(-) diff --git a/src/store/in-memory.ts b/src/store/in-memory.ts index 6a53dad..5fc91ba 100644 --- a/src/store/in-memory.ts +++ b/src/store/in-memory.ts @@ -3,6 +3,7 @@ import { type AppSlug, type LinkedIdentityInput, type Provider } from "../contra import { type CreateAccountInput, type CreateAuthAttemptInput, + type ClaimOAuthStateUseInput, type CreateAuthCompletionInput, type CreateAuditEventInput, type CreateCapabilityGrantInput, @@ -48,6 +49,8 @@ export class InMemoryStore implements HeimdallStore { private readonly privateCommandReceipts = new Map(); private readonly authCompletions = new Map(); private readonly authCompletionsByAttempt = new Map(); + /** `${jti}:${phase}` -> the state's expiresAt. */ + private readonly oauthStateUses = new Map(); private readonly entitlementSnapshots = new Map(); private readonly auditEvents = new Map(); @@ -424,6 +427,20 @@ export class InMemoryStore implements HeimdallStore { return this.consumeAuthCompletion(appSlug, code, at); } + async claimOAuthStateUse(input: ClaimOAuthStateUseInput): Promise { + 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 { const key = `${input.accountId}:${input.provider}:${input.scope}`; this.entitlementSnapshots.set(key, clone(input)); diff --git a/src/store/postgres.ts b/src/store/postgres.ts index 487f375..4ee0ea1 100644 --- a/src/store/postgres.ts +++ b/src/store/postgres.ts @@ -5,6 +5,7 @@ import { CREATE_SCHEMA_SQL, REQUIRED_COLUMNS, REQUIRED_KEYS, REQUIRED_TABLES } f import { type CreateAccountInput, type CreateAuthAttemptInput, + type ClaimOAuthStateUseInput, type CreateAuthCompletionInput, type CreateAuditEventInput, type CreateCapabilityGrantInput, @@ -378,6 +379,7 @@ export const STORE_TABLE_PRIVILEGES: Readonly { + // 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 { await this.pool.query( ` diff --git a/src/store/schema.ts b/src/store/schema.ts index 420f5e6..80dfa6e 100644 --- a/src/store/schema.ts +++ b/src/store/schema.ts @@ -119,6 +119,20 @@ CREATE UNIQUE INDEX IF NOT EXISTS auth_completions_attempt_unconsumed_unique_idx ON auth_completions(app_slug, attempt_id) WHERE attempt_id IS NOT NULL AND consumed_at IS NULL; +-- One use per (state jti, phase): a signed OAuth state launches once and +-- completes once. The first INSERT for a pair wins; ON CONFLICT DO NOTHING +-- reports every later claim as no row. The 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, diff --git a/src/store/types.ts b/src/store/types.ts index 5307e1f..d6a2f93 100644 --- a/src/store/types.ts +++ b/src/store/types.ts @@ -165,6 +165,14 @@ export interface StoredAuthCompletion { payloadJson: Record; } +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 @@ -272,5 +280,15 @@ export interface HeimdallStore { */ consumeAuthCompletionByAttempt(appSlug: AppSlug, attemptId: string, at: string): Promise; upsertEntitlementSnapshot(input: CreateEntitlementSnapshotInput): Promise; + /** + * 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; createAuditEvent(input: CreateAuditEventInput): Promise; } diff --git a/tests/store.test.ts b/tests/store.test.ts index c94c521..60bd1f5 100644 --- a/tests/store.test.ts +++ b/tests/store.test.ts @@ -3,7 +3,7 @@ import { Pool } from "pg"; import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; import { newDb } from "pg-mem"; import { type HeimdallConfig } from "../src/config.js"; -import { createStore } from "../src/store/index.js"; +import { createStore, InMemoryStore } from "../src/store/index.js"; import { createPostgresStore, PostgresStore, @@ -159,18 +159,6 @@ describe("PostgresStore", () => { const consumedAgain = await store.consumeAuthCompletion("repixelizer", completion.code, "2026-04-26T12:02:00.000Z"); expect(consumedAgain).toBeNull(); - const stateUse = { - jti: "state-1", - phase: "launch" as const, - appSlug: "repixelizer" as const, - usedAt: now, - expiresAt: "2026-04-26T12:10:00.000Z", - }; - expect(await store.claimOAuthStateUse(stateUse)).toBe(true); - expect(await store.claimOAuthStateUse(stateUse)).toBe(false); - expect(await store.claimOAuthStateUse({ ...stateUse, phase: "callback" })).toBe(true); - expect(await store.claimOAuthStateUse({ ...stateUse, jti: "state-2" })).toBe(true); - const attempt = await store.createAuthAttempt({ handle: "attempt-123", appSlug: "ghostlight", @@ -210,6 +198,36 @@ 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 @@ -245,11 +263,12 @@ describe("PostgresStore.checkSchema", () => { "auth_attempts", "private_command_receipts", "auth_completions", + "oauth_state_uses", "capability_grants", "entitlement_snapshots", "audit_events", ]); - expect(REQUIRED_COLUMNS).toHaveLength(85); + expect(REQUIRED_COLUMNS).toHaveLength(90); expect(REQUIRED_COLUMNS).toEqual( expect.arrayContaining([ { table: "auth_completions", column: "code", type: "TEXT", nullable: false }, @@ -260,11 +279,12 @@ describe("PostgresStore.checkSchema", () => { { table: "auth_completions", column: "attempt_id", type: "TEXT", nullable: true }, ]) ); - expect(REQUIRED_KEYS).toHaveLength(12); + expect(REQUIRED_KEYS).toHaveLength(13); 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" }, ]) From 3def0fb426ffe85cf3925f40d5f53a78c53626e6 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 22 Oct 2026 20:13:17 +0200 Subject: [PATCH 12/30] OAuth states complete once, only in the browser that launched them: a launch hop sets a signed binding cookie, launch and callback each consume the state, and the callback checks expiry Co-Authored-By: Claude Sonnet 5.5 --- src/app.ts | 209 ++++++++++++++++-- src/contracts.ts | 2 + src/private-command-plane.ts | 3 +- tests/access-plugin.test.ts | 8 +- tests/app.test.ts | 156 +++++++------ tests/caller-identity-attack-soul-r21.test.ts | 45 ++-- tests/caller-identity-attack-soul.test.ts | 16 +- tests/caller-identity-soul.test.ts | 6 +- tests/managed-credential.test.ts | 9 +- tests/oauth-flow.ts | 24 +- tests/private-command-diagnostics.test.ts | 7 +- 11 files changed, 357 insertions(+), 128 deletions(-) diff --git a/src/app.ts b/src/app.ts index 855c34e..96f107b 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,6 +1,7 @@ import { createHmac, randomUUID } from "node:crypto"; +import cookie from "@fastify/cookie"; import cors from "@fastify/cors"; -import Fastify, { type FastifyError, type FastifyInstance } from "fastify"; +import Fastify, { type FastifyError, type FastifyInstance, type FastifyReply } from "fastify"; import { type AppCaller, isAppCaller, resolveAppCaller } from "./app-caller.js"; import { deliverBackendHandoff, type BackendHandoffPayload } from "./backend-handoff.js"; import { @@ -93,6 +94,7 @@ function buildOAuthStateToken(options: { connection: OAuthConnectionBinding | undefined; handoff: OAuthHandoff; entitlementPolicy: OAuthEntitlementPolicy | undefined; + requestedScopes: string[] | undefined; keys: ReturnType; }): { token: string; expiresAt: string } { const issuedAt = nowEpochSeconds(); @@ -113,6 +115,7 @@ 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, options.keys); @@ -151,6 +154,11 @@ function parseOAuthStatePayload(payload: Record): OAuthStatePay entitlementPolicy !== null && (entitlementPolicy as Record).kind === "patreon_membership_access" && typeof (entitlementPolicy as Record).requiredTierTitle === "string")); + const requestedScopes = payload.requested_scopes; + const requestedScopesAreValid = + requestedScopes === null || + requestedScopes === undefined || + (Array.isArray(requestedScopes) && requestedScopes.every((scope) => typeof scope === "string")); if ( payload.typ !== "heimdall_oauth_state" || typeof payload.provider !== "string" || @@ -168,7 +176,8 @@ function parseOAuthStatePayload(payload: Record): OAuthStatePay typeof payload.nbf !== "number" || typeof payload.exp !== "number" || !handoffIsValid || - !entitlementPolicyIsValid + !entitlementPolicyIsValid || + !requestedScopesAreValid ) { return null; } @@ -180,10 +189,99 @@ function parseOAuthStatePayload(payload: Record): 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, + 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, + 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, + 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)); } @@ -566,7 +664,6 @@ export async function startOAuthFlow( }; } - const callbackUrl = `${config.publicBaseUrl}/v1/oauth/${provider}/callback`; const state = buildOAuthStateToken({ config, provider, @@ -576,6 +673,7 @@ export async function startOAuthFlow( connection: input.connection, handoff, entitlementPolicy, + requestedScopes: input.requestedScopes, keys, }); @@ -585,16 +683,9 @@ export async function startOAuthFlow( provider, appSlug: profile.slug, mode: input.mode, - callbackUrl, - authorizationUrl: buildAuthorizationUrl({ - provider, - clientId: providerConfig.clientId, - redirectUri: callbackUrl, - state: state.token, - requestedScopes: input.requestedScopes, - }), + callbackUrl: oauthCallbackUrl(config, provider), + authorizationUrl: `${config.publicBaseUrl}/v1/oauth/${provider}/launch?state=${encodeURIComponent(state.token)}`, handoff, - stateToken: state.token, stateExpiresAt: state.expiresAt, }, }; @@ -738,6 +829,7 @@ export async function buildApp(options: BuildAppOptions = {}): Promise ({ ok: true, @@ -872,6 +964,72 @@ export async function buildApp(options: BuildAppOptions = {}): Promise( + "/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 }; @@ -905,16 +1063,29 @@ export async function buildApp(options: BuildAppOptions = {}): Promise => { diff --git a/src/contracts.ts b/src/contracts.ts index d6cab2a..daa559a 100644 --- a/src/contracts.ts +++ b/src/contracts.ts @@ -94,6 +94,8 @@ 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 { diff --git a/src/private-command-plane.ts b/src/private-command-plane.ts index 6d3025b..8e9fb8d 100644 --- a/src/private-command-plane.ts +++ b/src/private-command-plane.ts @@ -305,7 +305,6 @@ async function beginAuth( throw new Error(String(startPayload.detail ?? startPayload.error ?? "OAuth start was denied.")); } const authorizationUrl = String(startPayload.authorizationUrl ?? ""); - const authorizationOrigin = new URL(authorizationUrl).origin; return { status: "accepted", payloadSchema: "heimdall.auth_begin_receipt.v1", @@ -316,7 +315,7 @@ async function beginAuth( expiresAt: attempt.expiresAt, navigation: { url: authorizationUrl, - allowedOrigins: [authorizationOrigin, new URL(config.publicBaseUrl).origin], + allowedOrigins: [new URL(config.publicBaseUrl).origin], }, }, }; diff --git a/tests/access-plugin.test.ts b/tests/access-plugin.test.ts index 4789bf8..6afcdb8 100644 --- a/tests/access-plugin.test.ts +++ b/tests/access-plugin.test.ts @@ -8,6 +8,7 @@ import { entitlementFacts } from "../src/facts.js"; import { startHeimdallPrivateCommandPlane } from "../src/private-command-plane.js"; import { openPrivateEnvelope, sealPrivateEnvelope, type HeimdallPrivateEnvelope } from "../src/private-command-security.js"; import { type OAuthProviderRuntime } from "../src/oauth.js"; +import { launchState } from "./oauth-flow.js"; const secret = "ghostlight-private-command-secret"; const resources: Array<{ close(): Promise }> = []; @@ -107,9 +108,12 @@ describe("Heimdall Eve access plugin", () => { const begin = openPrivateEnvelope(beginWire, secret); expect(begin.schema).toBe("heimdall.auth_begin_receipt.v1"); const handle = String(begin.handle); - const navigation = begin.navigation as { url: string }; + const navigation = begin.navigation as { url: string; allowedOrigins: string[] }; const state = new URL(navigation.url).searchParams.get("state"); expect(state).toBeTruthy(); + // The browser goes to Heimdall's launch hop first, and only Heimdall's origin is allowed. + expect(navigation.url.startsWith("https://heimdall.gamecult.org/v1/oauth/discord/launch?state=")).toBe(true); + expect(navigation.allowedOrigins).toEqual(["https://heimdall.gamecult.org"]); const pendingEnvelope = sealPrivateEnvelope({ appSlug: "ghostlight", @@ -124,9 +128,11 @@ describe("Heimdall Eve access plugin", () => { const pending = openPrivateEnvelope(decodeEnvelope(pendingResponse.payload), secret); expect(pending).toMatchObject({ status: "pending", handle }); + const { cookie } = await launchState(app, state ?? ""); const callback = await app.inject({ method: "GET", url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(state ?? "")}`, + headers: { cookie }, }); expect(callback.statusCode, callback.body).toBe(201); // The completion code is always minted by the store now, never the diff --git a/tests/app.test.ts b/tests/app.test.ts index 7e7e55f..1cfe062 100644 --- a/tests/app.test.ts +++ b/tests/app.test.ts @@ -13,7 +13,7 @@ import { entitlementFacts, identityFacts } from "../src/facts.js"; import { createOAuthRuntimeRegistry, ProviderResponseError, type OAuthProviderRuntime } from "../src/oauth.js"; import { createRuntimeKeyMaterial, signJwt, verifyJwt } from "../src/signing.js"; import { InMemoryStore } from "../src/store/index.js"; -import { createMockDiscordRuntime, createTestConfig } from "./oauth-flow.js"; +import { createMockDiscordRuntime, createTestConfig, launchFlow, providerRedirect, stateOf, type LaunchedFlow } from "./oauth-flow.js"; const apps: Awaited>[] = []; const originalFetch = globalThis.fetch; @@ -46,7 +46,7 @@ function getHeimdallContext(app: FastifyInstance) { }).heimdallContext; } -async function startDiscordSignIn(app: FastifyInstance): Promise { +async function startDiscordSignIn(app: FastifyInstance): Promise { const response = await app.inject({ method: "POST", url: "/v1/oauth/discord/start", @@ -58,7 +58,7 @@ async function startDiscordSignIn(app: FastifyInstance): Promise { }); expect(response.statusCode).toBe(201); - return response.json().stateToken as string; + return launchFlow(app, response.json()); } afterEach(async () => { @@ -119,9 +119,9 @@ describe("Heimdall service", () => { expect(response.statusCode).toBe(201); const payload = response.json(); - expect(payload.authorizationUrl).toContain("discord.com/oauth2/authorize"); + expect((await providerRedirect(app, payload)).href).toContain("discord.com/oauth2/authorize"); - const verified = verifyJwt(payload.stateToken as string, getPublicKey(app)); + const verified = verifyJwt(stateOf(payload), getPublicKey(app)); expect(verified.valid).toBe(true); if (verified.valid) { expect(verified.payload).toEqual( @@ -212,7 +212,7 @@ describe("Heimdall service", () => { payload: bifrostStart({ kind: "browser_completion" }), }); expect(response.statusCode).toBe(201); - const verified = verifyJwt(response.json().stateToken as string, getPublicKey(app)); + 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 })); @@ -369,9 +369,9 @@ describe("Heimdall service", () => { expect(response.statusCode).toBe(201); const payload = response.json(); - expect(payload.authorizationUrl).toContain("discord.com/oauth2/authorize"); + expect((await providerRedirect(app, payload)).href).toContain("discord.com/oauth2/authorize"); - const verified = verifyJwt(payload.stateToken as string, getPublicKey(app)); + const verified = verifyJwt(stateOf(payload), getPublicKey(app)); expect(verified.valid).toBe(true); if (verified.valid) { expect(verified.payload).toEqual( @@ -415,11 +415,11 @@ describe("Heimdall service", () => { expect(response.statusCode).toBe(201); const payload = response.json(); - const authorizationUrl = new URL(payload.authorizationUrl as string); + const authorizationUrl = await providerRedirect(app, payload); expect(`${authorizationUrl.origin}${authorizationUrl.pathname}`).toBe("https://www.patreon.com/oauth2/authorize"); expect(authorizationUrl.searchParams.get("scope")).toBe("identity identity[email]"); - const verified = verifyJwt(payload.stateToken as string, getPublicKey(app)); + const verified = verifyJwt(stateOf(payload), getPublicKey(app)); expect(verified.valid).toBe(true); if (verified.valid) { expect(verified.payload).toEqual( @@ -463,9 +463,9 @@ describe("Heimdall service", () => { expect(response.statusCode).toBe(201); const payload = response.json(); - expect(payload.authorizationUrl).toContain("discord.com/oauth2/authorize"); + expect((await providerRedirect(app, payload)).href).toContain("discord.com/oauth2/authorize"); - const verified = verifyJwt(payload.stateToken as string, getPublicKey(app)); + const verified = verifyJwt(stateOf(payload), getPublicKey(app)); expect(verified.valid).toBe(true); if (verified.valid) { expect(verified.payload).toEqual( @@ -509,10 +509,10 @@ describe("Heimdall service", () => { expect(response.statusCode).toBe(201); const payload = response.json(); - const authorizationUrl = new URL(payload.authorizationUrl as string); + const authorizationUrl = await providerRedirect(app, payload); expect(`${authorizationUrl.origin}${authorizationUrl.pathname}`).toBe("https://www.patreon.com/oauth2/authorize"); - const verified = verifyJwt(payload.stateToken as string, getPublicKey(app)); + const verified = verifyJwt(stateOf(payload), getPublicKey(app)); expect(verified.valid).toBe(true); if (verified.valid) { expect(verified.payload).toEqual( @@ -540,10 +540,11 @@ describe("Heimdall service", () => { }); apps.push(app); - const stateToken = await startDiscordSignIn(app); + const flow = await startDiscordSignIn(app); const response = await app.inject({ method: "GET", - url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(stateToken)}`, + url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(flow.state)}`, + headers: { cookie: flow.cookie }, }); expect(response.statusCode).toBe(201); @@ -756,10 +757,11 @@ describe("Heimdall service", () => { }); apps.push(app); - const stateToken = await startDiscordSignIn(app); + const flow = await startDiscordSignIn(app); const callbackResponse = await app.inject({ method: "GET", - url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(stateToken)}`, + url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(flow.state)}`, + headers: { cookie: flow.cookie }, }); const completionCode = callbackResponse.json().completion.code as string; @@ -798,10 +800,11 @@ describe("Heimdall service", () => { const auditEvents = () => [...(store as unknown as { auditEvents: Map }).auditEvents.values()]; - const stateToken = await startDiscordSignIn(app); + const flow = await startDiscordSignIn(app); const callback = await app.inject({ method: "GET", - url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(stateToken)}`, + url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(flow.state)}`, + headers: { cookie: flow.cookie }, }); const { code, expiresAt } = callback.json().completion as { code: string; expiresAt: string }; const created = auditEvents().filter((event) => event.eventType === "auth_completion_created"); @@ -882,10 +885,11 @@ describe("Heimdall service", () => { }); apps.push(app); - const stateToken = await startDiscordSignIn(app); + const flow = await startDiscordSignIn(app); const callbackResponse = await app.inject({ method: "GET", - url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(stateToken)}`, + url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(flow.state)}`, + headers: { cookie: flow.cookie }, }); expect(callbackResponse.statusCode).toBe(201); const refreshToken = callbackResponse.json().refreshToken as string; @@ -929,11 +933,12 @@ describe("Heimdall service", () => { }); apps.push(app); - const stateToken = await startDiscordSignIn(app); + const flow = await startDiscordSignIn(app); const response = await app.inject({ method: "GET", - url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(stateToken)}`, + url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(flow.state)}`, headers: { + cookie: flow.cookie, accept: "text/html", }, }); @@ -979,12 +984,13 @@ describe("Heimdall service", () => { }, }); expect(startResponse.statusCode).toBe(201); - const stateToken = startResponse.json().stateToken as string; + const flow = await launchFlow(app, startResponse.json()); const response = await app.inject({ method: "GET", - url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(stateToken)}`, + url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(flow.state)}`, headers: { + cookie: flow.cookie, accept: "text/html", }, }); @@ -1190,11 +1196,12 @@ describe("Heimdall service", () => { returnTo: "https://streampixels.gamecult.org/auth/connect", }, }); - const stateToken = startResponse.json().stateToken as string; + const flow = await launchFlow(app, startResponse.json()); const callbackResponse = await app.inject({ method: "GET", - url: `/v1/oauth/twitch/callback?code=test-code&state=${encodeURIComponent(stateToken)}`, + url: `/v1/oauth/twitch/callback?code=test-code&state=${encodeURIComponent(flow.state)}`, + headers: { cookie: flow.cookie }, }); const accountId = callbackResponse.json().account.id as string; @@ -1277,11 +1284,12 @@ describe("Heimdall service", () => { returnTo: "https://streampixels.gamecult.org/auth/connect", }, }); - const stateToken = startResponse.json().stateToken as string; + const flow = await launchFlow(app, startResponse.json()); const callbackResponse = await app.inject({ method: "GET", - url: `/v1/oauth/twitch/callback?code=test-code&state=${encodeURIComponent(stateToken)}`, + url: `/v1/oauth/twitch/callback?code=test-code&state=${encodeURIComponent(flow.state)}`, + headers: { cookie: flow.cookie }, }); const accountId = callbackResponse.json().account.id as string; @@ -1343,10 +1351,11 @@ describe("Heimdall service", () => { }); apps.push(app); - const stateToken = await startDiscordSignIn(app); + const flow = await startDiscordSignIn(app); const callbackResponse = await app.inject({ method: "GET", - url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(stateToken)}`, + url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(flow.state)}`, + headers: { cookie: flow.cookie }, }); expect(callbackResponse.statusCode).toBe(201); const evaluationsAtSignIn = evaluations; @@ -1444,7 +1453,7 @@ describe("Heimdall service", () => { expect(start.statusCode).toBe(400); expect(start.json().error).toBe(error); expect(start.json().authorizationUrl).toBeUndefined(); - expect(start.json().stateToken).toBeUndefined(); + expect(start.json()).not.toHaveProperty("stateToken"); }); it.each([ @@ -1460,14 +1469,15 @@ describe("Heimdall service", () => { apps.push(app); const keys = createRuntimeKeyMaterial({ ...config, bootstrapSigningPrivateKeyOnMissing: false }); const calls = stubJoystick({ channel_id: "chan-forged", username: "deru" }); - const stateToken = (await connect(app)).json().stateToken as string; - const verified = verifyJwt(stateToken, keys.publicKey); + const flow = await launchFlow(app, (await connect(app)).json()); + const verified = verifyJwt(flow.state, keys.publicKey); expect(verified.valid).toBe(true); const forged = signJwt({ ...(verified as { payload: Record }).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([]); @@ -1499,9 +1509,11 @@ describe("Heimdall service", () => { handoff: { kind: "backend_callback", attemptId: "attempt-js-1", callbackUrl }, }); expect(start.statusCode).toBe(201); + const flow = await launchFlow(app, start.json()); const callback = await app.inject({ method: "GET", - url: `/v1/oauth/joystick/callback?code=c&state=${encodeURIComponent(start.json().stateToken as string)}`, + url: `/v1/oauth/joystick/callback?code=c&state=${encodeURIComponent(flow.state)}`, + headers: { cookie: flow.cookie }, }); expect(callback.statusCode).toBeLessThan(300); @@ -1520,7 +1532,7 @@ describe("Heimdall service", () => { apps.push(app); const start = await connect(app); expect(start.statusCode).toBe(201); - const url = new URL(start.json().authorizationUrl as string); + const url = await providerRedirect(app, start.json()); expect(`${url.origin}${url.pathname}`).toBe("https://joystick.tv/api/oauth/authorize"); expect(url.searchParams.get("client_id")).toBe("joystick-client"); expect(url.searchParams.get("scope")).toBe("identity:read stream:read chat:read followers:read subscribers:read"); @@ -1534,10 +1546,11 @@ describe("Heimdall service", () => { const app = await buildApp({ config: createTestConfig(), store }); apps.push(app); stubJoystick({ channel_id: "deru-chan", username: "deru" }); - const stateToken = (await connect(app)).json().stateToken as string; + const flow = await launchFlow(app, (await connect(app)).json()); const connected = await app.inject({ method: "GET", - url: `/v1/oauth/joystick/callback?code=a&state=${encodeURIComponent(stateToken)}`, + url: `/v1/oauth/joystick/callback?code=a&state=${encodeURIComponent(flow.state)}`, + headers: { cookie: flow.cookie }, }); const accountId = connected.json().account.id as string; const before = await store.findStoredLinkedIdentityForAccount(accountId, "joystick"); @@ -1585,14 +1598,15 @@ describe("Heimdall service", () => { apps.push(app); const keys = createRuntimeKeyMaterial({ ...config, bootstrapSigningPrivateKeyOnMissing: false }); const calls = stubJoystick({ channel_id: "chan-forged", username: "deru" }); - const stateToken = (await connect(app)).json().stateToken as string; - const verified = verifyJwt(stateToken, keys.publicKey); + const flow = await launchFlow(app, (await connect(app)).json()); + const verified = verifyJwt(flow.state, keys.publicKey); expect(verified.valid).toBe(true); const forged = signJwt({ ...(verified as { payload: Record }).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"); @@ -1609,11 +1623,12 @@ describe("Heimdall service", () => { const app = await buildApp({ config: createTestConfig(), store }); apps.push(app); const calls = stubJoystick({ channel_id: "chan-9", username: "deru", nickname: "Deru!", photo_url: "https://img/x.png" }); - const stateToken = (await connect(app)).json().stateToken as string; + const flow = await launchFlow(app, (await connect(app)).json()); const callback = await app.inject({ method: "GET", - url: `/v1/oauth/joystick/callback?code=the-code&state=${encodeURIComponent(stateToken)}`, + url: `/v1/oauth/joystick/callback?code=the-code&state=${encodeURIComponent(flow.state)}`, + headers: { cookie: flow.cookie }, }); expect(callback.statusCode).toBeLessThan(300); @@ -1642,10 +1657,11 @@ describe("Heimdall service", () => { const app = await buildApp({ config: createTestConfig(), store }); apps.push(app); stubJoystick({ channel_id: "chan-9", username: "deru" }); - const stateToken = (await connect(app)).json().stateToken as string; + const flow = await launchFlow(app, (await connect(app)).json()); const callback = await app.inject({ method: "GET", - url: `/v1/oauth/joystick/callback?code=c&state=${encodeURIComponent(stateToken)}`, + url: `/v1/oauth/joystick/callback?code=c&state=${encodeURIComponent(flow.state)}`, + headers: { cookie: flow.cookie }, }); const row = await store.findStoredLinkedIdentityForAccount(callback.json().account.id as string, "joystick"); expect(row?.displayName).toBe("deru"); @@ -1656,10 +1672,11 @@ describe("Heimdall service", () => { const app = await buildApp({ config: createTestConfig(), store }); apps.push(app); stubJoystick({ username: "deru" }); - const stateToken = (await connect(app)).json().stateToken as string; + const flow = await launchFlow(app, (await connect(app)).json()); const callback = await app.inject({ method: "GET", - url: `/v1/oauth/joystick/callback?code=c&state=${encodeURIComponent(stateToken)}`, + url: `/v1/oauth/joystick/callback?code=c&state=${encodeURIComponent(flow.state)}`, + headers: { cookie: flow.cookie }, }); expect(callback.statusCode).toBeGreaterThanOrEqual(400); expect(await store.findStoredLinkedIdentity("joystick", "undefined")).toBeNull(); @@ -1854,11 +1871,11 @@ describe("error answers never echo an input", () => { const app = await buildApp({ config: createTestConfig(), oauthRuntimes: { discord: failingDiscordRuntime() } }); apps.push(app); - const stateToken = await startDiscordSignIn(app); + const flow = await startDiscordSignIn(app); const response = await app.inject({ method: "GET", - url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(stateToken)}`, - headers: { accept: "text/html" }, + url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(flow.state)}`, + headers: { cookie: flow.cookie, accept: "text/html" }, }); expect(response.headers["content-type"]).toContain("text/html"); @@ -1890,9 +1907,11 @@ describe("error answers never echo an input", () => { }, }); expect(start.statusCode).toBe(201); + const flow = await launchFlow(app, start.json()); const response = await app.inject({ method: "GET", - url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(start.json().stateToken as string)}`, + url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(flow.state)}`, + headers: { cookie: flow.cookie }, }); expect(response.statusCode).toBe(502); @@ -1910,16 +1929,16 @@ describe("the relayed provider error", () => { const injected = `"\\\u0007é${"x".repeat(300)}`; const allowed = /^[\x20-\x21\x23-\x5b\x5d-\x7e]*$/; - function providerErrorUrl(stateToken: string): string { - const query = new URLSearchParams({ state: stateToken, error: `${"e".repeat(100)}\u0000`, error_description: injected }); - return `/v1/oauth/discord/callback?${query.toString()}`; + function providerErrorRequest(flow: LaunchedFlow) { + const query = new URLSearchParams({ state: flow.state, error: `${"e".repeat(100)}\u0000`, error_description: injected }); + return { method: "GET" as const, url: `/v1/oauth/discord/callback?${query.toString()}`, headers: { cookie: flow.cookie } }; } it("is printable and bounded in the JSON answer", async () => { const app = await buildApp({ config: createTestConfig() }); apps.push(app); - const response = await app.inject({ method: "GET", url: providerErrorUrl(await startDiscordSignIn(app)) }); + const response = await app.inject(providerErrorRequest(await startDiscordSignIn(app))); expect(response.statusCode).toBe(400); const { providerError, providerErrorDescription } = response.json() as Record; @@ -1933,11 +1952,8 @@ describe("the relayed provider error", () => { const app = await buildApp({ config: createTestConfig() }); apps.push(app); - const response = await app.inject({ - method: "GET", - url: providerErrorUrl(await startDiscordSignIn(app)), - headers: { accept: "text/html" }, - }); + const request = providerErrorRequest(await startDiscordSignIn(app)); + const response = await app.inject({ ...request, headers: { ...request.headers, accept: "text/html" } }); expect(response.headers["content-type"]).toContain("text/html"); expect(response.body.match(/