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:
parent
26f264877a
commit
e1ac84fb70
5 changed files with 88 additions and 13 deletions
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in a new issue