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

@ -4,7 +4,7 @@
"type": "object",
"required": ["provider", "mode", "returnTo", "entitlementPolicy"],
"properties": {
"provider": { "enum": ["discord", "patreon", "github", "twitch", "youtube", "spotify"] },
"provider": { "enum": ["discord", "patreon", "github", "twitch", "youtube", "spotify", "joystick"] },
"mode": { "enum": ["sign_in", "link", "connect"] },
"returnTo": { "type": "string", "format": "uri" },
"entitlementPolicy": {

View file

@ -99,10 +99,10 @@ const repixelizerProfile: AppProfile = {
const streampixelsProfile: AppProfile = {
slug: "streampixels",
displayName: "StreamPixels",
profileVersion: "2026-04-26",
profileVersion: "2026-10-19",
identityProviders: ["twitch", "youtube"],
entitlementSources: [],
managedConnectionProviders: ["twitch", "youtube"],
managedConnectionProviders: ["twitch", "youtube", "joystick"],
allowedReturnOrigins: ["https://streampixels.gamecult.org"],
capabilities: [
{

View file

@ -1,4 +1,4 @@
export const providers = ["discord", "patreon", "github", "twitch", "youtube", "spotify"] as const;
export const providers = ["discord", "patreon", "github", "twitch", "youtube", "spotify", "joystick"] as const;
export type Provider = (typeof providers)[number];

View file

@ -513,6 +513,45 @@ async function resolveSpotifyIdentity(options: { accessToken: string }): Promise
return identity;
}
async function resolveJoystickIdentity(options: { accessToken: string }): Promise<ResolvedIdentity> {
const profile = await fetchJson<{
channel_id?: string;
username?: string;
nickname?: string;
photo_url?: string;
}>(
"https://api.joystick.tv/api/v1/me/identity",
{
headers: {
Authorization: `Bearer ${options.accessToken}`,
},
},
"Joystick identity lookup failed"
);
if (!profile.channel_id) {
throw new Error("Joystick identity response did not include a channel id.");
}
const identity: ResolvedIdentity = {
provider: "joystick",
providerUserId: profile.channel_id,
profile: profile as unknown as Record<string, unknown>,
};
const displayName = profile.nickname ?? profile.username;
if (profile.username) {
identity.username = profile.username;
}
if (displayName) {
identity.displayName = displayName;
}
if (profile.photo_url) {
identity.avatarUrl = profile.photo_url;
}
return identity;
}
async function evaluateDiscordEntitlements(options: {
config: HeimdallConfig;
callback: OAuthCallbackContext;
@ -860,6 +899,15 @@ const spotifyRuntime: OAuthProviderRuntime = {
},
};
const joystickRuntime: OAuthProviderRuntime = {
exchangeAuthorizationCode: sharedRuntimeExchange("joystick"),
refreshAccessToken: sharedRuntimeRefresh("joystick"),
resolveIdentity: resolveJoystickIdentity,
async evaluateEntitlements() {
return { facts: [], snapshots: [] };
},
};
const notImplementedRuntime: OAuthProviderRuntime = {
async exchangeAuthorizationCode() {
throw new Error("Provider callback exchange is not implemented yet.");
@ -882,5 +930,6 @@ export function createOAuthRuntimeRegistry(): OAuthRuntimeRegistry {
twitch: twitchRuntime,
youtube: youtubeRuntime,
spotify: spotifyRuntime,
joystick: joystickRuntime,
};
}

View file

@ -105,6 +105,18 @@ export const providerCatalog: Record<Provider, ProviderDescriptor> = {
clientIdEnv: "GC_ACCESS_PROVIDER_SPOTIFY_CLIENT_ID",
clientSecretEnv: "GC_ACCESS_PROVIDER_SPOTIFY_CLIENT_SECRET_FILE",
},
joystick: {
key: "joystick",
displayName: "Joystick",
authorizationEndpoint: "https://joystick.tv/api/oauth/authorize",
tokenEndpoint: "https://api.joystick.tv/api/oauth/token",
tokenEndpointAuthMethod: "client_secret_basic",
rotatesRefreshTokens: true,
defaultScopes: ["identity:read", "stream:read", "chat:read", "followers:read", "subscribers:read"],
roles: ["identity", "managed_credential"],
clientIdEnv: "GC_ACCESS_PROVIDER_JOYSTICK_CLIENT_ID",
clientSecretEnv: "GC_ACCESS_PROVIDER_JOYSTICK_CLIENT_SECRET_FILE",
},
};
export function buildAuthorizationUrl(options: {

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",