From 6d43eacd84e69bb665b455acc57514651c581915 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 22 Oct 2026 18:13:02 +0200 Subject: [PATCH 01/10] 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 02/10] 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 03/10] 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 04/10] 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 05/10] 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 607c2b0a2056b190b7c4e4529cf0968fea69d07b Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 22 Oct 2026 20:13:16 +0200 Subject: [PATCH 06/10] 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 07/10] 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 08/10] 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(/