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 <noreply@anthropic.com>
This commit is contained in:
parent
9fd152feff
commit
ece6ba1d62
4 changed files with 495 additions and 90 deletions
|
|
@ -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<ReturnType<typeof buildApp>>[] = [];
|
||||
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);
|
||||
|
|
|
|||
124
tests/oauth-flow.ts
Normal file
124
tests/oauth-flow.ts
Normal file
|
|
@ -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, unknown> }): 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<LaunchedFlow> {
|
||||
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 };
|
||||
}
|
||||
314
tests/oauth-state-binding.test.ts
Normal file
314
tests/oauth-state-binding.test.ts
Normal file
|
|
@ -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<string, unknown> = {}) {
|
||||
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<string, unknown>, 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<string, string> = {
|
||||
"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));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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<Set<string>> {
|
|||
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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue