From 7a69d7567b1ae3ee7d20d94b40159c8d015bcd86 Mon Sep 17 00:00:00 2001 From: Heimdall Date: Thu, 12 Nov 2026 23:10:27 +0000 Subject: [PATCH 1/2] Opt-in sign_in step-up: app-authenticated, signed-state flag, provider re-auth parameters, typed refusal Co-Authored-By: Claude Sonnet 5.5 --- docs/service-contract.md | 25 +++ src/app.ts | 29 +++- src/contracts.ts | 4 + src/providers.ts | 22 +++ state/evidence.jsonl | 1 + tests/caller-identity-attack-soul-r21.test.ts | 2 +- tests/step-up.test.ts | 152 ++++++++++++++++++ 7 files changed, 233 insertions(+), 2 deletions(-) create mode 100644 tests/step-up.test.ts diff --git a/docs/service-contract.md b/docs/service-contract.md index bf8d810..eb0b35f 100644 --- a/docs/service-contract.md +++ b/docs/service-contract.md @@ -198,6 +198,31 @@ Failure modes: secret, or another app's, is refused before any state is signed. `sign_in` starts carrying a `connection` are `400 connection_not_allowed_in_sign_in` +Step-up (`stepUp: true`, sign_in only, off by default): + +- Asks the provider for a real re-authentication on the authorize URL, so a + provider session already open in the browser is not enough to complete the + sign-in. The start request field is `stepUp`; the signed state carries it as + `step_up`, and the launch hop builds the provider URL from the state alone. + Nothing on the callback or redeem side can set or clear it (a `stepUp` in a + redeem body is ignored), and a completion reports it as `stepUpRequested: true` + when the start asked. +- It is the authenticated app's request, like a connection target: no secret or + another app's is `401 app_auth_required`; a `link` or `connect` start carrying + it is `400 step_up_only_in_sign_in`; `stepUp: false` is the same as absent. +- Provider parameters (`stepUpAuthorizationParams`, `src/providers.ts`): Twitch + `force_verify=true`, YouTube `prompt=login consent` (consent is kept; the + provider always asks it), Google `prompt=login`. **The strengths differ.** + Google `prompt=login` forces the person to re-authenticate. Twitch + `force_verify` re-shows the authorize page: it proves a person at that browser + chose to continue, not that they entered credentials. A consumer that needs + credentials re-entered must not rely on Twitch step-up. +- A provider with no such parameter (Discord, Patreon, GitHub, Spotify, + Joystick) is `400 step_up_not_supported`, with the provider named, before any + state is signed. The URL builder throws rather than omit the step. +- Heimdall requests the step; it cannot observe that the provider honoured it. + `stepUpRequested` says "asked", not "verified". + Launch note: - Discord and Patreon are the implemented provider runtimes for Repixelizer diff --git a/src/app.ts b/src/app.ts index 776d671..12ace03 100644 --- a/src/app.ts +++ b/src/app.ts @@ -99,6 +99,7 @@ function buildOAuthStateToken(options: { handoff: OAuthHandoff; entitlementPolicy: OAuthEntitlementPolicy | undefined; requestedScopes: string[] | undefined; + stepUp: boolean | undefined; keys: ReturnType; }): { token: string; jti: string; expiresAt: string } { const issuedAt = nowEpochSeconds(); @@ -121,6 +122,7 @@ function buildOAuthStateToken(options: { handoff: options.handoff, entitlement_policy: options.entitlementPolicy ?? null, requested_scopes: options.requestedScopes ?? null, + step_up: options.stepUp ? true : null, }; const token = signJwt(payload as unknown as Record, options.keys); @@ -184,7 +186,8 @@ function parseOAuthStatePayload(payload: Record): OAuthStatePay !handoffIsValid || !entitlementPolicyIsValid || !(payload.initiator_account_id === undefined || payload.initiator_account_id === null || typeof payload.initiator_account_id === "string") || - !requestedScopesAreValid + !requestedScopesAreValid || + !(payload.step_up === undefined || payload.step_up === null || payload.step_up === true) ) { return null; } @@ -202,6 +205,9 @@ function parseOAuthStatePayload(payload: Record): OAuthStatePay if (!normalizedPayload.requested_scopes) { normalizedPayload.requested_scopes = null; } + if (!normalizedPayload.step_up) { + normalizedPayload.step_up = null; + } return normalizedPayload; } @@ -702,6 +708,10 @@ export async function startOAuthFlow( }; } + if (input.stepUp && input.mode !== "sign_in") { + return { statusCode: 400, body: { error: "step_up_only_in_sign_in", detail: "stepUp applies to sign_in starts only." } }; + } + const descriptor = providerCatalog[provider]; // A provider that never mints an account attaches an identity only through // a bound link: the user's own access token, since the app's word does not @@ -744,6 +754,19 @@ export async function startOAuthFlow( return { statusCode: 400, body: { error: "initiator_only_in_link_mode" } }; } + // A step-up is app-backend authority, like a connection target: it is + // asked for by the authenticated app, never by a browser, and only on + // sign_in. A provider with no re-authentication parameter is refused here, + // at start, so no step-up ever reaches the person without its step. + if (input.stepUp) { + if (!isAppCaller(caller) || caller.appSlug !== profile.slug) { + return { statusCode: 401, body: { error: "app_auth_required" } }; + } + if (!providerCatalog[provider].stepUpAuthorizationParams) { + return { statusCode: 400, body: { error: "step_up_not_supported", provider, detail: `${providerCatalog[provider].displayName} has no re-authentication parameter.` } }; + } + } + if ( input.requestedScopes !== undefined && (input.requestedScopes.length > maxRequestedScopes || input.requestedScopes.some((scope) => scope.length > maxRequestedScopeChars)) @@ -838,6 +861,7 @@ export async function startOAuthFlow( handoff, entitlementPolicy, requestedScopes: input.requestedScopes, + stepUp: input.stepUp, keys, }); return { @@ -1175,6 +1199,7 @@ export async function buildApp(options: BuildAppOptions = {}): Promise; + /** + * The authorize parameters that make this provider ask the person again + * (a sign_in step-up). Absent means the provider has no such parameter, and + * a step-up start for it is refused rather than passed without the step. + */ + stepUpAuthorizationParams?: Record; /** * What a callback does with a (provider, subject) no account holds yet. * `mints_account` is every provider's behaviour before Google: the first @@ -92,6 +98,8 @@ export const providerCatalog: Record = { firstLogin: "mints_account", clientIdEnv: "GC_ACCESS_PROVIDER_TWITCH_CLIENT_ID", clientSecretEnv: "GC_ACCESS_PROVIDER_TWITCH_CLIENT_SECRET_FILE", + // Twitch force_verify re-shows the authorize page; it is not a password prompt. + stepUpAuthorizationParams: { force_verify: "true" }, }, youtube: { key: "youtube", @@ -116,6 +124,8 @@ export const providerCatalog: Record = { include_granted_scopes: "true", prompt: "consent", }, + // login forces re-authentication; consent is kept because this provider always asks for it. + stepUpAuthorizationParams: { prompt: "login consent" }, }, spotify: { key: "spotify", @@ -150,6 +160,7 @@ export const providerCatalog: Record = { clientIdEnv: "GC_ACCESS_PROVIDER_GOOGLE_CLIENT_ID", clientSecretEnv: "GC_ACCESS_PROVIDER_GOOGLE_CLIENT_SECRET_FILE", additionalAuthorizationParams: { prompt: "select_account" }, + stepUpAuthorizationParams: { prompt: "login" }, }, joystick: { key: "joystick", @@ -180,6 +191,8 @@ export function buildAuthorizationUrl(options: { requestedScopes: string[] | undefined; /** For an OIDC provider: the nonce, and the S256 challenge of the PKCE verifier. */ oidc?: { nonce: string; codeChallenge: string }; + /** Ask the provider for a real re-authentication. Throws for a provider with no such parameter: never a silent pass. */ + stepUp?: boolean; }): string { const descriptor = providerCatalog[options.provider]; const url = new URL(descriptor.authorizationEndpoint); @@ -203,6 +216,15 @@ export function buildAuthorizationUrl(options: { url.searchParams.set(key, value); } + if (options.stepUp) { + if (!descriptor.stepUpAuthorizationParams) { + throw new Error(`${descriptor.displayName} has no re-authentication parameter: a step-up cannot be honoured.`); + } + for (const [key, value] of Object.entries(descriptor.stepUpAuthorizationParams)) { + url.searchParams.set(key, value); + } + } + return url.toString(); } diff --git a/state/evidence.jsonl b/state/evidence.jsonl index 5329d5f..f7fa724 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -37,3 +37,4 @@ {"ts": "2026-09-08T12:20:12+00:00", "type": "security", "status": "accepted", "note": "epiphany/heimdall-caller-identity, second Hands pass: closed the two Soul-proven holes. (1) auth-completions redeem code is now always store-minted (never handoff.attemptId); attemptId survives only as a separate correlation field on StoredAuthCompletion/CreateAuthCompletionInput, and a new store.consumeAuthCompletionByAttempt(appSlug, attemptId, at) lets the private command plane's completeAuth redeem by the handle it already owns instead of forging an HTTP self-call with completionCode=handle. (2) startOAuthFlow now refuses any returnTo whose origin is not in AppProfile.allowedReturnOrigins (repixelizer/streampixels/bifrost -> .gamecult.org, ghostlight -> yggdrasil.gamecult.org per its docs/app-profiles doc and existing test fixtures; a registered app's allowlist is derived from its stored redirectUris origins). AppCaller is now branded with an unexported Symbol (app-caller.ts); private-command-plane.ts's two { appSlug } literals are gone, replaced by app-caller.ts's callerFromOpenedEnvelope. Deleted: validateAppRegistration, AppRegistrationRequest, RegistrationProblem, AppRegistrationError, HeimdallStore.registerApp (writer). Kept: findRegisteredApp/listRegisteredApps/resolveAppProfile fallback -- AppSlug is a plain string (contracts.ts), registered_apps is a real extension point for a slug Heimdall was never rebuilt for, and isBuiltInApp's only production call site (listAppProfiles) depends on that fallback existing; a row is now provisioned by direct SQL (or InMemoryStore.seedRegisteredApp in tests), not by any app-facing writer. Deleted the two appSlug===\"ghostlight\" branches in private-command-plane.ts; the requirement is now AppProfile.requiredEntitlementPolicyKind, checked generically through resolveAppProfile. tsc --noEmit clean; vitest 94 passed/1 pre-existing-skip, only pre-existing failure is tests/odin-publication.test.ts (Cannot find module 'cultcache-ts', present on main too).", "branch": "epiphany/heimdall-caller-identity"} {"ts": "2026-10-19T15:56:24+00:00", "type": "design", "status": "accepted", "note": "Refresh is now one locked primitive for every provider (replaces unserialized refreshLinkedIdentityCredentialIfNeeded); Joystick is managed-only (Heimdall-held credential, no viewer sign-in until joystick:follow_up:joystick-viewer-signin-public-bot). Merged in PR #1, live at d51fa6b; campaign joystick:campaign:self.", "branch": "eureka/joystick-h1"} {"ts": "2026-10-19T16:03:16+00:00", "type": "verification", "status": "accepted", "note": "Live d51fa6b (H1) verified by a real human sign-in: the operator's StreamPixels Twitch sign-in through Heimdall start/callback, 2026-10-19 ('Twitch auth worked fine'); a streampixels session was created 16:02:33Z, after the 15:51Z switch. Joystick consent round trip still open: joystick:follow_up:h1-joystick-consent-round-trip."} +{"ts": "2026-11-12T23:10:15+00:00", "type": "design", "status": "accepted", "note": "Changelog 2026-11-12: sign_in start accepts stepUp (app-authenticated, sign_in only, off by default); signed state carries step_up; launch adds Twitch force_verify=true, YouTube prompt='login consent', Google prompt=login; any other provider is 400 step_up_not_supported; completion reports stepUpRequested. Not deployed. For joystick:follow_up:chat-s7055-7056 (StreamPixels link hardening, second factor via fresh sign-in)."} diff --git a/tests/caller-identity-attack-soul-r21.test.ts b/tests/caller-identity-attack-soul-r21.test.ts index fe23f9f..1264a86 100644 --- a/tests/caller-identity-attack-soul-r21.test.ts +++ b/tests/caller-identity-attack-soul-r21.test.ts @@ -366,7 +366,7 @@ describe("R21.2: the WeakSet brand at every consumer", () => { if (line.includes("caller.appSlug")) expect(line, `${file}: ${line.trim()}`).toContain("isAppCaller(caller)"); } } - expect(consumers).toEqual(["app.ts: reads=3 guards=3"]); + expect(consumers).toEqual(["app.ts: reads=4 guards=4"]); }); it("a caller minted for app A is refused where app B is expected, over HTTP and by direct call", async () => { diff --git a/tests/step-up.test.ts b/tests/step-up.test.ts new file mode 100644 index 0000000..9e4cb6c --- /dev/null +++ b/tests/step-up.test.ts @@ -0,0 +1,152 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { buildApp } from "../src/app.js"; +import { type OAuthProviderRuntime } from "../src/oauth.js"; +import { buildAuthorizationUrl, providerCatalog } from "../src/providers.js"; +import { decodeJwt } from "../src/signing.js"; +import { InMemoryStore } from "../src/store/index.js"; +import { createTestConfig, launchFlow, launchWithRedirect } from "./oauth-flow.js"; + +// A sign_in step-up asks the provider for a real re-authentication. It is the +// authenticated app's request (never a browser's), it rides in the signed state +// only, it is off unless asked for, and a provider that has no such parameter is +// refused at start. These tests drive it as StreamPixels does: its own app +// secret, twitch and youtube sign_in starts, then the launch hop and redeem. + +const returnTo = "https://streampixels.gamecult.org/signin/return"; +const spAuth = { "x-heimdall-app-secret": "streampixels-secret" }; + +function runtime(provider: "twitch" | "youtube", providerUserId: string): OAuthProviderRuntime { + return { + async exchangeAuthorizationCode() { + return { accessToken: "at", refreshToken: "rt", tokenType: "Bearer", scope: ["user:read:email"], raw: {} }; + }, + async resolveIdentity() { + return { provider, providerUserId, username: providerUserId, profile: { id: providerUserId } }; + }, + async evaluateEntitlements() { + return { facts: [], snapshots: [] }; + }, + }; +} + +const apps: Awaited>[] = []; +afterEach(async () => { + await Promise.all(apps.splice(0).map((app) => app.close())); +}); + +async function rig() { + const app = await buildApp({ + config: createTestConfig(), + store: new InMemoryStore(), + oauthRuntimes: { twitch: runtime("twitch", "t-1"), youtube: runtime("youtube", "y-1") }, + }); + apps.push(app); + return app; +} + +type App = Awaited>; + +function start( + app: App, + provider: string, + payload: Record = {}, + headers: Record = {}, + appSlug = "streampixels", + url = returnTo +) { + return app.inject({ method: "POST", url: `/v1/oauth/${provider}/start`, headers, payload: { appSlug, mode: "sign_in", returnTo: url, ...payload } }); +} + +describe("step-up on a sign_in start: the provider URL", () => { + it("asks nothing extra without stepUp, for every provider StreamPixels signs in with", async () => { + const app = await rig(); + for (const provider of ["twitch", "youtube"]) { + for (const extra of [{}, { stepUp: false }]) { + const started = await start(app, provider, extra); + expect(started.statusCode).toBe(201); + expect(decodeJwt(new URL(started.json().authorizationUrl).searchParams.get("state") ?? "").payload.step_up).toBeNull(); + const url = (await launchWithRedirect(app, started.json())).url; + expect(url.searchParams.has("force_verify")).toBe(false); + expect(url.searchParams.get("prompt")).toBe(provider === "youtube" ? "consent" : null); + } + } + }); + + it("with stepUp and the app secret: twitch force_verify=true, youtube prompt=login consent, state records it", async () => { + const app = await rig(); + const twitch = await start(app, "twitch", { stepUp: true }, spAuth); + expect(twitch.statusCode).toBe(201); + expect(decodeJwt(new URL(twitch.json().authorizationUrl).searchParams.get("state") ?? "").payload.step_up).toBe(true); + expect((await launchWithRedirect(app, twitch.json())).url.searchParams.get("force_verify")).toBe("true"); + + const youtube = await start(app, "youtube", { stepUp: true }, spAuth); + expect(youtube.statusCode).toBe(201); + expect((await launchWithRedirect(app, youtube.json())).url.searchParams.get("prompt")).toBe("login consent"); + }); + + it("Google (a Bifrost sign_in provider) gets prompt=login in place of select_account", async () => { + const app = await rig(); + const bifrostReturn = "https://bifrost.gamecult.org/auth/heimdall/return"; + const plain = await start(app, "google", {}, { "x-heimdall-app-secret": "bifrost-secret" }, "bifrost", bifrostReturn); + expect((await launchWithRedirect(app, plain.json())).url.searchParams.get("prompt")).toBe("select_account"); + const stepped = await start(app, "google", { stepUp: true }, { "x-heimdall-app-secret": "bifrost-secret" }, "bifrost", bifrostReturn); + expect(stepped.statusCode).toBe(201); + expect((await launchWithRedirect(app, stepped.json())).url.searchParams.get("prompt")).toBe("login"); + }); +}); + +describe("step-up refusals", () => { + it("is the app's request: no secret or a wrong secret is 401 app_auth_required, while the same start without stepUp stays open", async () => { + const app = await rig(); + for (const headers of [{}, { "x-heimdall-app-secret": "wrong" }]) { + const res = await start(app, "twitch", { stepUp: true }, headers); + expect([res.statusCode, res.json().error]).toEqual([401, "app_auth_required"]); + } + expect((await start(app, "twitch", {}, {})).statusCode).toBe(201); + }); + + it("a provider with no re-authentication parameter is a typed refusal, with the secret, never a silent pass", async () => { + const app = await rig(); + const bifrost = { "x-heimdall-app-secret": "bifrost-secret" }; + const res = await start(app, "discord", { stepUp: true }, bifrost, "bifrost", "https://bifrost.gamecult.org/auth/heimdall/return"); + expect([res.statusCode, res.json().error, res.json().provider]).toEqual([400, "step_up_not_supported", "discord"]); + // The URL builder holds the same line: it throws rather than omit the step. + for (const provider of ["discord", "patreon", "github", "spotify", "joystick"] as const) { + expect(providerCatalog[provider].stepUpAuthorizationParams, provider).toBeUndefined(); + expect(() => + buildAuthorizationUrl({ provider, clientId: "c", redirectUri: "https://x/cb", state: "s", requestedScopes: undefined, stepUp: true }) + ).toThrow(/no re-authentication parameter/); + } + expect(Object.keys(providerCatalog).filter((p) => providerCatalog[p as keyof typeof providerCatalog].stepUpAuthorizationParams).sort()).toEqual(["google", "twitch", "youtube"]); + }); + + it("is sign_in only: link and connect starts carrying stepUp are 400 step_up_only_in_sign_in", async () => { + const app = await rig(); + for (const mode of ["link", "connect"]) { + const res = await start(app, "twitch", { mode, stepUp: true }, spAuth); + expect([res.statusCode, res.json().error]).toEqual([400, "step_up_only_in_sign_in"]); + } + }); +}); + +describe("the flag is the signed state's, not the redeem side's", () => { + it("a completion reports stepUpRequested only when the start asked, and a stepUp handed to redeem changes nothing", async () => { + const app = await rig(); + for (const asked of [true, false]) { + const started = await start(app, "twitch", asked ? { stepUp: true } : {}, asked ? spAuth : {}); + const flow = await launchFlow(app, started.json()); + const called = await app.inject({ + method: "GET", + url: `/v1/oauth/twitch/callback?code=c&state=${encodeURIComponent(flow.state)}`, + headers: { cookie: flow.cookie, accept: "application/json" }, + }); + expect(called.statusCode).toBe(201); + const code = called.json().completion.code as string; + + // A stepUp handed to redeem is not read: the answer reflects the start, whatever the body says. + const redeemed = await app.inject({ method: "POST", url: "/v1/apps/streampixels/auth-completions/redeem", payload: { completionCode: code, stepUp: true } }); + expect(redeemed.statusCode).toBe(201); + expect(redeemed.json().stepUpRequested).toBe(asked ? true : undefined); + } + }); +}); -- 2.47.3 From 673713a015789b02e3ad91baa422f5e1ede33837 Mon Sep 17 00:00:00 2001 From: Heimdall Date: Thu, 12 Nov 2026 23:21:12 +0000 Subject: [PATCH 2/2] Step-up: build the authorize URL before claiming the launch (a refused step-up no longer burns the state); pin the strict step_up parse with a signed forgery; document backend_callback and mixed-version limits Co-Authored-By: Claude Sonnet 5.5 --- docs/service-contract.md | 4 +++ src/app.ts | 26 ++++++++--------- tests/step-up.test.ts | 61 ++++++++++++++++++++++++++++++++++++++-- 3 files changed, 75 insertions(+), 16 deletions(-) diff --git a/docs/service-contract.md b/docs/service-contract.md index eb0b35f..0469186 100644 --- a/docs/service-contract.md +++ b/docs/service-contract.md @@ -220,6 +220,10 @@ Step-up (`stepUp: true`, sign_in only, off by default): - A provider with no such parameter (Discord, Patreon, GitHub, Spotify, Joystick) is `400 step_up_not_supported`, with the provider named, before any state is signed. The URL builder throws rather than omit the step. +- A `backend_callback` handoff delivery does not carry `stepUpRequested`; only the redeemed completion does. +- A state signed by a build with step-up and launched by an older build loses the step + silently (the older parser ignores `step_up`): deploy every Heimdall instance on one + release, with no mixed window. - Heimdall requests the step; it cannot observe that the provider honoured it. `stepUpRequested` says "asked", not "verified". diff --git a/src/app.ts b/src/app.ts index 12ace03..e3c4931 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1365,6 +1365,18 @@ export async function buildApp(options: BuildAppOptions = {}): Promise { await Promise.all(apps.splice(0).map((app) => app.close())); }); -async function rig() { +async function rig(config = createTestConfig()) { const app = await buildApp({ - config: createTestConfig(), + config, store: new InMemoryStore(), oauthRuntimes: { twitch: runtime("twitch", "t-1"), youtube: runtime("youtube", "y-1") }, }); @@ -150,3 +153,55 @@ describe("the flag is the signed state's, not the redeem side's", () => { } }); }); + +describe("a forged or odd state cannot carry a step-up the start would not have signed", () => { + // A real signing key on disk, so the test can re-sign a state as only Heimdall's key holder could. + const dir = mkdtempSync(join(tmpdir(), "heimdall-stepup-")); + const config = { ...createTestConfig(), signingPrivateKeyPath: join(dir, "signing.pem"), bootstrapSigningPrivateKeyOnMissing: true }; + // The app creates the key on first boot; read it only after the rig exists. + const loadKeys = (c: typeof config) => createRuntimeKeyMaterial({ ...c, bootstrapSigningPrivateKeyOnMissing: false }); + + async function forgedState(app: App, provider: string, headers: Record, stepUp: unknown, appSlug = "streampixels", url = returnTo) { + const started = await start(app, provider, {}, headers, appSlug, url); + const real = new URL(started.json().authorizationUrl).searchParams.get("state") ?? ""; + const keys = loadKeys(config); + const verified = verifyJwt(real, keys.publicKey) as { valid: boolean; payload: Record }; + expect(verified.valid, "the test key signs what the app verifies").toBe(true); + return { real, forged: signJwt({ ...verified.payload, step_up: stepUp }, keys) }; + } + const launch = (app: App, provider: string, state: string) => app.inject({ method: "GET", url: `/v1/oauth/${provider}/launch?state=${encodeURIComponent(state)}` }); + + it("rejects any step_up that is not null or true as invalid_state at the launch hop", async () => { + const app = await rig(config); + expect((await launch(app, "twitch", (await forgedState(app, "twitch", {}, true)).forged)).statusCode, "a well-formed true still launches").toBe(302); + for (const odd of ["true", "yes", 1, 0, false, {}, []]) { + const res = await launch(app, "twitch", (await forgedState(app, "twitch", {}, odd)).forged); + expect([res.statusCode, res.json().error], JSON.stringify(odd)).toEqual([400, "invalid_state"]); + } + rmSync(dir, { recursive: true, force: true }); + }); + + it("a step-up state for a provider with no re-authentication parameter fails closed and does not burn the state", async () => { + const dir2 = mkdtempSync(join(tmpdir(), "heimdall-stepup-")); + const config2 = { ...createTestConfig(), signingPrivateKeyPath: join(dir2, "signing.pem"), bootstrapSigningPrivateKeyOnMissing: true }; + try { + const app = await rig(config2); + const bifrost = { "x-heimdall-app-secret": "bifrost-secret" }; + const started = await start(app, "discord", {}, bifrost, "bifrost", "https://bifrost.gamecult.org/auth/heimdall/return"); + const real = new URL(started.json().authorizationUrl).searchParams.get("state") ?? ""; + const keys2 = loadKeys(config2); + const verified = verifyJwt(real, keys2.publicKey) as { valid: boolean; payload: Record }; + expect(verified.valid).toBe(true); + const forged = signJwt({ ...verified.payload, step_up: true }, keys2); + for (let attempt = 0; attempt < 2; attempt += 1) { + const res = await launch(app, "discord", forged); + expect(res.statusCode, "never a redirect without the step").toBe(500); + expect(res.headers.location).toBeUndefined(); + } + // Not "state_already_launched": the refused launches did not claim the state. + expect((await launch(app, "discord", real)).statusCode).toBe(302); + } finally { + rmSync(dir2, { recursive: true, force: true }); + } + }); +}); -- 2.47.3