Add Joystick as a confidential, rotating managed-connection provider

Joystick joins the catalog (Basic client auth, rotatesRefreshTokens), resolves
identity by channel id, and is a streampixels managed-connection provider only:
it is not an identity provider for the profile (ruling signin-waits-for-public-bot).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-19 11:13:40 +02:00
commit 1c38d12050
14 changed files with 212 additions and 18 deletions

View file

@ -291,7 +291,7 @@ function testConfig(): HeimdallConfig {
storage: { backend: "memory", applySchemaOnStartup: true },
providers: {
discord: { clientId: "discord-client", clientSecret: "discord-secret" },
patreon: {}, github: {}, twitch: {}, youtube: {}, spotify: {},
patreon: {}, github: {}, twitch: {}, youtube: {}, spotify: {}, joystick: {},
},
};
}

View file

@ -49,6 +49,7 @@ function createTestConfig(): HeimdallConfig {
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" },
},
};
}
@ -974,7 +975,8 @@ describe("Heimdall service", () => {
expect(profileResponse.json()).toEqual(
expect.objectContaining({
slug: "streampixels",
managedConnectionProviders: ["twitch", "youtube"],
identityProviders: ["twitch", "youtube"],
managedConnectionProviders: ["twitch", "youtube", "joystick"],
})
);
});
@ -1349,6 +1351,109 @@ describe("Heimdall service", () => {
expect(urls).toEqual([]);
});
describe("joystick connect", () => {
async function connect(app: FastifyInstance) {
const start = await app.inject({
method: "POST",
url: "/v1/oauth/joystick/start",
payload: { appSlug: "streampixels", mode: "connect", returnTo: "https://streampixels.gamecult.org/auth/connect" },
});
return start;
}
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);
const start = await connect(app);
expect(start.statusCode).toBe(201);
const url = new URL(start.json().authorizationUrl as string);
expect(`${url.origin}${url.pathname}`).toBe("https://joystick.tv/api/oauth/authorize");
expect(url.searchParams.get("client_id")).toBe("joystick-client");
expect(url.searchParams.get("scope")).toBe("identity:read stream:read chat:read followers:read subscribers:read");
expect(url.searchParams.get("redirect_uri")).toBe("https://heimdall.gamecult.org/v1/oauth/joystick/callback");
});
function stubJoystick(identity: Record<string, unknown>) {
const calls: Array<{ url: string; headers: Record<string, string>; body?: URLSearchParams }> = [];
globalThis.fetch = (async (input: string | URL, init?: RequestInit) => {
const url = String(input);
calls.push({ url, headers: (init?.headers ?? {}) as Record<string, string>, body: init?.body as URLSearchParams | undefined });
if (url === "https://api.joystick.tv/api/oauth/token") {
return new Response(
JSON.stringify({ access_token: "js-access", refresh_token: "js-refresh", token_type: "Bearer", expires_in: 864000 }),
{ status: 200 }
);
}
if (url === "https://api.joystick.tv/api/v1/me/identity") {
return new Response(JSON.stringify(identity), { status: 200 });
}
return new Response("unexpected", { status: 500 });
}) as typeof fetch;
return calls;
}
it("exchanges the code with Basic client auth and links the identity by channel id", async () => {
const store = new InMemoryStore();
const app = await buildApp({ config: createTestConfig(), store });
apps.push(app);
const calls = stubJoystick({ channel_id: "chan-9", username: "deru", nickname: "Deru!", photo_url: "https://img/x.png" });
const stateToken = (await connect(app)).json().stateToken as string;
const callback = await app.inject({
method: "GET",
url: `/v1/oauth/joystick/callback?code=the-code&state=${encodeURIComponent(stateToken)}`,
});
expect(callback.statusCode).toBeLessThan(300);
const exchange = calls.find((c) => c.url === "https://api.joystick.tv/api/oauth/token")!;
expect(exchange.headers.Authorization).toBe(`Basic ${Buffer.from("joystick-client:joystick-secret").toString("base64")}`);
expect(exchange.body!.get("grant_type")).toBe("authorization_code");
expect(exchange.body!.get("code")).toBe("the-code");
expect(exchange.body!.get("redirect_uri")).toBe("https://heimdall.gamecult.org/v1/oauth/joystick/callback");
expect(exchange.body!.has("client_secret")).toBe(false);
const identityCall = calls.find((c) => c.url === "https://api.joystick.tv/api/v1/me/identity")!;
expect(identityCall.headers.Authorization).toBe("Bearer js-access");
const accountId = callback.json().account.id as string;
const row = await store.findStoredLinkedIdentityForAccount(accountId, "joystick");
expect(row).toMatchObject({ providerUserId: "chan-9", username: "deru", displayName: "Deru!" });
expect(row?.accessTokenEncrypted).toEqual(expect.any(String));
expect(row?.accessTokenEncrypted).not.toContain("js-access");
expect(row?.refreshTokenEncrypted).toEqual(expect.any(String));
const left = Date.parse(row!.tokenExpiresAt!) - Date.now();
expect(left).toBeGreaterThan(863_000 * 1000);
expect(left).toBeLessThanOrEqual(864_000 * 1000);
});
it("falls back to the username for the display name", async () => {
const store = new InMemoryStore();
const app = await buildApp({ config: createTestConfig(), store });
apps.push(app);
stubJoystick({ channel_id: "chan-9", username: "deru" });
const stateToken = (await connect(app)).json().stateToken as string;
const callback = await app.inject({
method: "GET",
url: `/v1/oauth/joystick/callback?code=c&state=${encodeURIComponent(stateToken)}`,
});
const row = await store.findStoredLinkedIdentityForAccount(callback.json().account.id as string, "joystick");
expect(row?.displayName).toBe("deru");
});
it("fails the callback when the identity has no channel id", async () => {
const store = new InMemoryStore();
const app = await buildApp({ config: createTestConfig(), store });
apps.push(app);
stubJoystick({ username: "deru" });
const stateToken = (await connect(app)).json().stateToken as string;
const callback = await app.inject({
method: "GET",
url: `/v1/oauth/joystick/callback?code=c&state=${encodeURIComponent(stateToken)}`,
});
expect(callback.statusCode).toBeGreaterThanOrEqual(400);
expect(await store.findStoredLinkedIdentity("joystick", "undefined")).toBeNull();
});
});
it("no longer honors a global shared secret across apps", async () => {
// A per-app identity model with a global fallback is a two-app shared
// secret in practice (R6, fork 2). GC_ACCESS_APP_SHARED_SECRET alone must

View file

@ -65,6 +65,7 @@ function testConfig(overrides: Partial<HeimdallConfig> = {}): HeimdallConfig {
twitch: {},
youtube: {},
spotify: {},
joystick: {},
},
...overrides,
};

View file

@ -51,6 +51,7 @@ function testConfig(): HeimdallConfig {
twitch: {},
youtube: {},
spotify: {},
joystick: {},
},
};
}

View file

@ -59,6 +59,7 @@ function testConfig(): HeimdallConfig {
twitch: {},
youtube: {},
spotify: {},
joystick: {},
},
};
}

View file

@ -138,7 +138,7 @@ const rigKinds = [
{ kind: "postgres two apps", live: true },
] as const;
// Providers whose refresh goes through the real shared runtime; rotation differs by descriptor.
const refreshProviders: Provider[] = ["twitch"];
const refreshProviders: Provider[] = ["twitch", "joystick"];
for (const { kind, live } of rigKinds) describe.skipIf(live && !adminUrl)(`managed credential refresh, ${kind}`, () => {
const admin = live && adminUrl ? new Pool({ connectionString: adminUrl, max: 2 }) : undefined;
@ -392,16 +392,39 @@ for (const { kind, live } of rigKinds) describe.skipIf(live && !adminUrl)(`manag
expect(rig.custody.decrypt(outcome.identity.accessTokenEncrypted!)).toBe("access-1");
});
it("a response without a refresh token keeps the old one for a non-rotating provider", timeout, async () => {
expect(providerCatalog[provider].rotatesRefreshTokens).toBe(false);
const rig = await makeRig(provider);
const endpoint = installTokenEndpoint(provider, "refresh-0");
endpoint.mode = "no_refresh_token";
const answer = await rig.resolve();
expect(answer.statusCode).toBe(200);
expect(answer.json.accessToken).toBe("access-norefresh");
expect(rig.custody.decrypt((await rig.row()).refreshTokenEncrypted!)).toBe("refresh-0");
});
if (providerCatalog[provider].rotatesRefreshTokens) {
it("rotation-fails-closed: a response without a refresh token revokes, 410, and the old token is never presented again", timeout, async () => {
const rig = await makeRig(provider);
const endpoint = installTokenEndpoint(provider, "refresh-0");
endpoint.mode = "no_refresh_token";
const first = await rig.resolve();
expect(first.statusCode).toBe(410);
expect(first.json).toEqual({ error: "managed_credential_revoked", provider });
expect(first.body).not.toContain("access-norefresh");
const row = await rig.row();
expect(row.accessTokenEncrypted).toBeUndefined();
expect(row.refreshTokenEncrypted).toBeUndefined();
expect(row.tokenExpiresAt).toBeUndefined();
const second = await rig.resolve();
expect(second.statusCode).toBe(410);
expect(endpoint.calls).toBe(1);
expect((await rig.audit()).filter((e) => e.eventType === "managed_credential_revoked")).toEqual([
{ eventType: "managed_credential_revoked", payload: { provider, reason: "rotation_missing" } },
]);
});
} else {
it("a response without a refresh token keeps the old one for a non-rotating provider", timeout, async () => {
const rig = await makeRig(provider);
const endpoint = installTokenEndpoint(provider, "refresh-0");
endpoint.mode = "no_refresh_token";
const answer = await rig.resolve();
expect(answer.statusCode).toBe(200);
expect(answer.json.accessToken).toBe("access-norefresh");
expect(rig.custody.decrypt((await rig.row()).refreshTokenEncrypted!)).toBe("refresh-0");
});
}
it("a row with no access token resolves as revoked (410), a missing row as not found (404)", timeout, async () => {
const rig = await makeRig(provider, { accessToken: null, refreshToken: null });

View file

@ -30,7 +30,7 @@ function config(): HeimdallConfig {
bootstrapSigningPrivateKeyOnMissing: false, tokenEncryptionKeyBase64: Buffer.alloc(32, 7).toString("base64"),
appSharedSecrets: { ghostlight: secret }, appRuntimeIds: { ghostlight: ["yggdrasil-ghostlight"] }, appBackendCallbacks: {},
storage: { backend: "memory", applySchemaOnStartup: true },
providers: { discord: { clientId: "discord-client", clientSecret: "discord-secret" }, patreon: {}, github: {}, twitch: {}, youtube: {}, spotify: {} },
providers: { discord: { clientId: "discord-client", clientSecret: "discord-secret" }, patreon: {}, github: {}, twitch: {}, youtube: {}, spotify: {}, joystick: {} },
} as HeimdallConfig;
}

View file

@ -38,6 +38,7 @@ function createTestConfig(overrides: Partial<HeimdallConfig> = {}): HeimdallConf
twitch: {},
youtube: {},
spotify: {},
joystick: {},
},
...overrides,
};

View file

@ -31,6 +31,7 @@ describe("Heimdall Verse witness advertisement", () => {
"twitch",
"youtube",
"spotify",
"joystick",
]);
expect(advertisement.appProfiles.map((profile) => profile.slug)).toEqual([
"repixelizer",