From ece6ba1d620feed87883d54bb35c984af7832bcf Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 22 Oct 2026 19:04:10 +0200 Subject: [PATCH] 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; }