Opt-in sign_in step-up (force_verify / prompt=login), app-authenticated, signed-state flag #11
7 changed files with 304 additions and 14 deletions
|
|
@ -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
|
||||
|
|
|
|||
53
src/app.ts
53
src/app.ts
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)."}
|
||||
|
|
|
|||
|
|
@ -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
207
tests/step-up.test.ts
Normal 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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue