Heimdall/tests/link-binding.test.ts
Heimdall 84c54fbb5e Refuse a connection target on sign_in start
Connection targets ride only on connect and link starts, which carry
x-heimdall-app-secret. A sign_in start is open to any browser; with a
connection it now returns 400 connection_not_allowed_in_sign_in.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-03 13:30:22 +00:00

255 lines
12 KiB
TypeScript

import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { buildApp } from "../src/app.js";
import { type HeimdallConfig } from "../src/config.js";
import { 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" },
},
};
}
// Link mode binds a provider identity to the account an authenticated app
// named at start. These tests pin the three places that binding is decided:
// the callback (initiator), the completion row (target), and redeem (caller,
// target, replay).
const returnTo = "https://repixelizer.gamecult.org/app/";
const appAuth = { "x-heimdall-app-secret": "repixelizer-secret" };
function discordRuntime(providerUserId: string): OAuthProviderRuntime {
return {
async exchangeAuthorizationCode() {
return { accessToken: "at", refreshToken: "rt", tokenType: "Bearer", scope: ["identify"], raw: {} };
},
async resolveIdentity() {
return { provider: "discord", providerUserId, username: providerUserId, profile: { id: providerUserId } };
},
async evaluateEntitlements() {
return { facts: [], snapshots: [] };
},
};
}
const apps: Awaited<ReturnType<typeof buildApp>>[] = [];
afterEach(async () => {
vi.useRealTimers();
await Promise.all(apps.splice(0).map((app) => app.close()));
});
async function rig(providerUserId: string, config: HeimdallConfig = createTestConfig()) {
const store = new InMemoryStore();
const app = await buildApp({ config, store, oauthRuntimes: { discord: discordRuntime(providerUserId) } });
apps.push(app);
const now = new Date().toISOString();
const initiator = await store.createAccount({ createdAt: now, lastSeenAt: now });
return { app, store, initiator };
}
type Rig = Awaited<ReturnType<typeof rig>>;
function start(r: Rig, payload: Record<string, unknown>, headers: Record<string, string> = appAuth) {
return r.app.inject({ method: "POST", url: "/v1/oauth/discord/start", headers, payload: { appSlug: "repixelizer", returnTo, ...payload } });
}
function callback(r: Rig, stateToken: string) {
return r.app.inject({ method: "GET", url: `/v1/oauth/discord/callback?code=c&state=${encodeURIComponent(stateToken)}` });
}
function redeem(r: Rig, payload: Record<string, unknown>, headers: Record<string, string> = {}) {
return r.app.inject({ method: "POST", url: "/v1/apps/repixelizer/auth-completions/redeem", headers, payload });
}
describe("link start names its initiator", () => {
it("refuses an unauthenticated link start, a missing or unknown initiator, and an initiator outside link mode", async () => {
const r = await rig("d-1");
const mode = { mode: "link", initiatingAccountId: r.initiator.id };
expect((await start(r, mode, {})).statusCode).toBe(401);
expect((await start(r, mode, { "x-heimdall-app-secret": "wrong" })).statusCode).toBe(401);
const missing = await start(r, { mode: "link" });
expect([missing.statusCode, missing.json().error]).toEqual([400, "link_initiator_required"]);
const unknown = await start(r, { mode: "link", initiatingAccountId: "no-such-account" });
expect([unknown.statusCode, unknown.json().error]).toEqual([400, "link_initiator_unknown"]);
const stray = await start(r, { mode: "sign_in", initiatingAccountId: r.initiator.id }, {});
expect([stray.statusCode, stray.json().error]).toEqual([400, "initiator_only_in_link_mode"]);
});
});
describe("sign_in start carries no connection target", () => {
it("refuses connection.targetId on sign_in with connection_not_allowed_in_sign_in, with or without app auth", async () => {
const r = await rig("d-si");
const connection = { kind: "creator", targetId: "victim-target" };
for (const headers of [{}, appAuth]) {
const res = await start(r, { mode: "sign_in", connection }, headers);
expect([res.statusCode, res.json().error]).toEqual([400, "connection_not_allowed_in_sign_in"]);
}
expect((await start(r, { mode: "sign_in" }, {})).statusCode).toBe(201);
});
});
describe("link callback holds the provider identity to the initiator", () => {
it("attaches an unlinked identity to the initiating account, not a new one", async () => {
const r = await rig("d-new");
const started = await start(r, { mode: "link", initiatingAccountId: r.initiator.id });
expect(started.statusCode).toBe(201);
const done = await callback(r, started.json().stateToken);
expect(done.statusCode).toBe(201);
expect(done.json().account.id).toBe(r.initiator.id);
expect((await r.store.findAccountByLinkedIdentity("discord", "d-new"))?.id).toBe(r.initiator.id);
});
it("refuses with link_initiator_mismatch when the identity already belongs to another account, and mints nothing", async () => {
const r = await rig("d-held");
const now = new Date().toISOString();
const other = await r.store.createAccount({ createdAt: now, lastSeenAt: now });
await r.store.upsertLinkedIdentity({ accountId: other.id, provider: "discord", providerUserId: "d-held", scopes: [], profileJson: {}, createdAt: now, updatedAt: now });
const started = await start(r, { mode: "link", initiatingAccountId: r.initiator.id });
const done = await callback(r, started.json().stateToken);
expect(done.statusCode).toBe(403);
expect(done.json().error).toBe("link_initiator_mismatch");
expect(done.json().completion).toBeUndefined();
expect(done.body).not.toContain("accessToken");
expect((await r.store.findAccountByLinkedIdentity("discord", "d-held"))?.id).toBe(other.id);
});
it("refuses a link state that carries no initiator (forged or stripped), before attaching anything", async () => {
const dir = mkdtempSync(join(tmpdir(), "heimdall-link-"));
try {
const config = { ...createTestConfig(), signingPrivateKeyPath: join(dir, "signing.pem"), bootstrapSigningPrivateKeyOnMissing: true };
const r = await rig("d-forged", config);
const keys = createRuntimeKeyMaterial({ ...config, bootstrapSigningPrivateKeyOnMissing: false });
const started = await start(r, { mode: "link", initiatingAccountId: r.initiator.id });
const verified = verifyJwt(started.json().stateToken, keys.publicKey) as { valid: boolean; payload: Record<string, unknown> };
expect(verified.valid).toBe(true);
const stripped = signJwt({ ...verified.payload, initiator_account_id: null }, keys);
const done = await callback(r, stripped);
expect([done.statusCode, done.json().error]).toEqual([403, "link_initiator_missing"]);
expect(await r.store.findAccountByLinkedIdentity("discord", "d-forged")).toBeNull();
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});
describe("redeem binds caller, target and replay", () => {
async function linked(r: Rig, targetId: string | undefined) {
const started = await start(r, {
mode: "link",
initiatingAccountId: r.initiator.id,
...(targetId ? { connection: { kind: "account", targetId } } : {}),
});
const done = await callback(r, started.json().stateToken);
expect(done.statusCode).toBe(201);
return done.json().completion.code as string;
}
it("redeems a link completion only for the authenticated app, and a refused caller does not burn the code", async () => {
const r = await rig("d-r1");
const completionCode = await linked(r, undefined);
const anonymous = await redeem(r, { completionCode });
expect([anonymous.statusCode, anonymous.json().error]).toEqual([401, "app_auth_required"]);
expect((await redeem(r, { completionCode }, { "x-heimdall-app-secret": "wrong" })).statusCode).toBe(401);
expect((await redeem(r, { completionCode }, appAuth)).statusCode).toBe(201);
});
it("refuses a wrong or missing target with completion_target_mismatch without consuming the code", async () => {
const r = await rig("d-r2");
const completionCode = await linked(r, "target-a");
for (const payload of [{ completionCode, targetId: "target-b" }, { completionCode }]) {
const refused = await redeem(r, payload, appAuth);
expect([refused.statusCode, refused.json().error]).toEqual([403, "completion_target_mismatch"]);
expect(refused.json().accessToken).toBeUndefined();
}
expect((await redeem(r, { completionCode, targetId: "target-a" }, appAuth)).statusCode).toBe(201);
});
it("refuses a replayed completion", async () => {
const r = await rig("d-r3");
const completionCode = await linked(r, "target-a");
expect((await redeem(r, { completionCode, targetId: "target-a" }, appAuth)).statusCode).toBe(201);
expect((await redeem(r, { completionCode, targetId: "target-a" }, appAuth)).statusCode).toBe(410);
});
it("refuses a completion at and after its expiry with 410, whatever the caller and target, and honours it just before", async () => {
// completionTtlSeconds is 300. Only Date is faked: the app and store run on real timers.
vi.useFakeTimers({ toFake: ["Date"] });
const t0 = Date.parse("2026-11-03T00:00:00.000Z");
const ttlMs = createTestConfig().completionTtlSeconds * 1000;
const expired = { statusCode: 410, error: "invalid_or_expired_completion_code" };
const outcome = async (r: Rig, payload: Record<string, unknown>) => {
const res = await redeem(r, payload, appAuth);
return { statusCode: res.statusCode, error: res.json().error };
};
vi.setSystemTime(t0);
const r = await rig("d-exp");
const code = await linked(r, "target-a");
const row = await r.store.findAuthCompletion("repixelizer", code);
expect(row?.expiresAt).toBe(new Date(t0 + ttlMs).toISOString());
// Just before expiry the right caller and target redeem it.
const early = await rig("d-exp-early");
const earlyCode = await linked(early, "target-a");
vi.setSystemTime(t0 + ttlMs - 1);
expect((await redeem(early, { completionCode: earlyCode, targetId: "target-a" }, appAuth)).statusCode).toBe(201);
// At the expiry instant and after it: 410, for the right target and for a wrong one (not 403), unconsumed.
for (const at of [t0 + ttlMs, t0 + ttlMs + 1]) {
vi.setSystemTime(at);
expect(await outcome(r, { completionCode: code, targetId: "target-a" })).toEqual(expired);
expect(await outcome(r, { completionCode: code, targetId: "target-b" })).toEqual(expired);
}
expect((await r.store.findAuthCompletion("repixelizer", code))?.consumedAt).toBeUndefined();
});
it("stores the target on the completion row", async () => {
const r = await rig("d-r4");
const completionCode = await linked(r, "target-a");
expect((await r.store.findAuthCompletion("repixelizer", completionCode))?.targetId).toBe("target-a");
});
});