Joystick custody needs a creator binding, at start and at the callback

callbackMint now takes the connection: custody_only is a connect for a
managed-connection-only provider bound to connection.kind creator with a
targetId. Anything else is refused at start with creator_connection_required
before any redirect, and at the callback from the signed state before the
code exchange. The streampixels profile note says what Heimdall guarantees
and records the S1 rule that SP's redeem treats a non-creator Joystick
completion as an error, never a sign-in.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-19 14:34:24 +02:00
commit e1ac84fb70
5 changed files with 88 additions and 13 deletions

View file

@ -1356,15 +1356,59 @@ describe("Heimdall service", () => {
});
describe("joystick connect", () => {
async function connect(app: FastifyInstance) {
const creator = { kind: "creator", targetId: "creator-1" };
async function connect(app: FastifyInstance, extra: Record<string, unknown> = { connection: creator }) {
const start = await app.inject({
method: "POST",
url: "/v1/oauth/joystick/start",
payload: { appSlug: "streampixels", mode: "connect", returnTo: "https://streampixels.gamecult.org/auth/connect" },
payload: { appSlug: "streampixels", mode: "connect", returnTo: "https://streampixels.gamecult.org/auth/connect", ...extra },
});
return start;
}
it.each([
["no connection", {}],
["an account connection", { connection: { kind: "account", targetId: "victim-1" } }],
["an empty targetId", { connection: { kind: "creator", targetId: "" } }],
])("refuses a joystick connect with %s at start, before any redirect", async (_name, extra) => {
const app = await buildApp({ config: createTestConfig() });
apps.push(app);
const start = await connect(app, extra);
expect(start.statusCode).toBe(400);
expect(start.json().authorizationUrl).toBeUndefined();
expect(start.json().stateToken).toBeUndefined();
});
it.each([
["no connection", null],
["an account connection", { kind: "account", targetId: "victim-1" }],
["an empty targetId", { kind: "creator", targetId: "" }],
])("the callback refuses a signed joystick connect with %s before the code exchange, and nothing is written", async (_name, connection) => {
const dir = mkdtempSync(join(tmpdir(), "heimdall-forge-"));
try {
const config = { ...createTestConfig(), signingPrivateKeyPath: join(dir, "signing.pem"), bootstrapSigningPrivateKeyOnMissing: true };
const store = new InMemoryStore();
const app = await buildApp({ config, store });
apps.push(app);
const keys = createRuntimeKeyMaterial({ ...config, bootstrapSigningPrivateKeyOnMissing: false });
const calls = stubJoystick({ channel_id: "chan-forged", username: "deru" });
const stateToken = (await connect(app)).json().stateToken as string;
const verified = verifyJwt(stateToken, keys.publicKey);
expect(verified.valid).toBe(true);
const forged = signJwt({ ...(verified as { payload: Record<string, unknown> }).payload, connection }, keys);
const callback = await app.inject({
method: "GET",
url: `/v1/oauth/joystick/callback?code=c&state=${encodeURIComponent(forged)}`,
});
expect(callback.statusCode).toBeGreaterThanOrEqual(400);
expect(calls).toEqual([]);
expect(await store.findStoredLinkedIdentity("joystick", "chan-forged")).toBeNull();
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("starts a streampixels connect at joystick.tv with the five scopes and the callback redirect", async () => {
const app = await buildApp({ config: createTestConfig() });
apps.push(app);
@ -2096,10 +2140,19 @@ describe("callbackMint", () => {
["discord", "connect", "refused"],
["discord", "sign_in", "refused"],
] as const)("%s %s on streampixels is %s", (provider, mode, expected) => {
expect(callbackMint(streampixels, provider, mode)).toBe(expected);
expect(callbackMint(streampixels, provider, mode, { kind: "creator", targetId: "creator-1" })).toBe(expected);
});
it.each([
["null", null],
["undefined", undefined],
["an account binding", { kind: "account", targetId: "a-1" }],
["an empty creator targetId", { kind: "creator", targetId: "" }],
] as const)("a joystick connect with %s is refused", (_name, connection) => {
expect(callbackMint(streampixels, "joystick", "connect", connection)).toBe("refused");
});
it("keeps Bifrost's discord connect a viewer session", () => {
expect(callbackMint(builtInAppProfiles.bifrost!, "discord", "connect")).toBe("viewer_session");
expect(callbackMint(builtInAppProfiles.bifrost!, "discord", "connect", null)).toBe("viewer_session");
});
});

View file

@ -562,7 +562,7 @@ for (const { kind, live } of rigKinds) describe.skipIf(live && !adminUrl)(`manag
async function flow(rig: Rig, provider: "joystick" | "twitch" | "youtube", mode: "connect" | "sign_in") {
const app = rig.apps[0]!;
const start = await app.inject({ method: "POST", url: `/v1/oauth/${provider}/start`, payload: { appSlug: "streampixels", mode, returnTo } });
const start = await app.inject({ method: "POST", url: `/v1/oauth/${provider}/start`, payload: { appSlug: "streampixels", mode, returnTo, ...(mode === "connect" ? { connection: { kind: "creator", targetId: "creator-1" } } : {}) } });
expect(start.statusCode).toBe(201);
const callback = await app.inject({
method: "GET",