Opt-in sign_in step-up (force_verify / prompt=login), app-authenticated, signed-state flag #11

Merged
heimdall merged 2 commits from heimdall/signin-step-up into main 2026-11-12 23:22:19 +00:00
7 changed files with 304 additions and 14 deletions

View file

@ -198,6 +198,35 @@ 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.
- 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".
Launch note:
- Discord and Patreon are the implemented provider runtimes for Repixelizer

View file

@ -99,6 +99,7 @@ function buildOAuthStateToken(options: {
handoff: OAuthHandoff;
entitlementPolicy: OAuthEntitlementPolicy | undefined;
requestedScopes: string[] | undefined;
stepUp: boolean | undefined;
keys: ReturnType<typeof createRuntimeKeyMaterial>;
}): { 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<string, unknown>, options.keys);
@ -184,7 +186,8 @@ function parseOAuthStatePayload(payload: Record<string, unknown>): 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<string, unknown>): 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<FastifyIn
},
},
initiatingAccountId: { type: "string", minLength: 1 },
stepUp: { type: "boolean" },
handoff: {
type: "object",
required: ["kind"],
@ -1340,6 +1365,18 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
if (!clientId) {
return providerNotConfigured(reply, state);
}
const oidc = providerCatalog[state.provider].oidc ? deriveOidcSecrets(state.jti, keys.privateKey) : undefined;
// Built before the state is claimed: a state that cannot be honoured (a step-up for a provider with no
// re-authentication parameter) fails here without burning its launch use or setting the binding cookie.
const authorizationUrl = buildAuthorizationUrl({
provider: state.provider,
clientId,
redirectUri: oauthCallbackUrl(config, state.provider),
state: rawState,
requestedScopes: state.requested_scopes ?? undefined,
...(state.step_up ? { stepUp: true } : {}),
...(oidc ? { oidc: { nonce: oidc.nonce, codeChallenge: oidc.codeChallenge } } : {}),
});
const first = await store.claimOAuthStateUse({
jti: state.jti,
phase: "launch",
@ -1351,21 +1388,10 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
return refuseVerifiedState(request, reply, state, "state_already_launched");
}
const oidc = providerCatalog[state.provider].oidc ? deriveOidcSecrets(state.jti, keys.privateKey) : undefined;
setOAuthBindingCookie(reply, state, signOAuthBinding(config, keys, state, shownText), 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: rawState,
requestedScopes: state.requested_scopes ?? undefined,
...(oidc ? { oidc: { nonce: oidc.nonce, codeChallenge: oidc.codeChallenge } } : {}),
}),
302
);
return reply.redirect(authorizationUrl, 302);
};
app.get<{
@ -1935,6 +1961,7 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
entitlements: entitlementEvaluation,
returnTo: statePayload.return_to,
connection: statePayload.connection,
...(statePayload.step_up ? { stepUpRequested: true } : {}),
...(consentReceiptId ? { consentReceiptId } : {}),
...delivered,
};

View file

@ -78,6 +78,8 @@ export interface OAuthStartRequest {
handoff?: OAuthHandoff;
requestedScopes?: string[];
entitlementPolicy?: OAuthEntitlementPolicy;
/** Sign_in only, app-authenticated: ask the provider for a real re-authentication. Off when absent. */
stepUp?: boolean;
}
export interface OAuthStatePayload {
@ -99,6 +101,8 @@ export interface OAuthStatePayload {
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;
/** The start's step-up request. Signed, so the launch hop reads it here and nowhere else; null when not asked. */
step_up: true | null;
}
export interface LinkedIdentityInput {

View file

@ -22,6 +22,12 @@ export interface ProviderDescriptor {
clientIdEnv: string;
clientSecretEnv: string;
additionalAuthorizationParams?: Record<string, string>;
/**
* 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<string, string>;
/**
* 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<Provider, ProviderDescriptor> = {
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<Provider, ProviderDescriptor> = {
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<Provider, ProviderDescriptor> = {
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();
}

View file

@ -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 -> <slug>.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)."}

View file

@ -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 () => {

207
tests/step-up.test.ts Normal file
View file

@ -0,0 +1,207 @@
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
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 { createRuntimeKeyMaterial, decodeJwt, signJwt, verifyJwt } 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<ReturnType<typeof buildApp>>[] = [];
afterEach(async () => {
await Promise.all(apps.splice(0).map((app) => app.close()));
});
async function rig(config = createTestConfig()) {
const app = await buildApp({
config,
store: new InMemoryStore(),
oauthRuntimes: { twitch: runtime("twitch", "t-1"), youtube: runtime("youtube", "y-1") },
});
apps.push(app);
return app;
}
type App = Awaited<ReturnType<typeof rig>>;
function start(
app: App,
provider: string,
payload: Record<string, unknown> = {},
headers: Record<string, string> = {},
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);
}
});
});
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<string, string>, 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<string, unknown> };
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<string, unknown> };
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 });
}
});
});