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:
parent
c81f923890
commit
1c38d12050
14 changed files with 212 additions and 18 deletions
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
||||
|
|
|
|||
49
src/oauth.ts
49
src/oauth.ts
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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: {},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -65,6 +65,7 @@ function testConfig(overrides: Partial<HeimdallConfig> = {}): HeimdallConfig {
|
|||
twitch: {},
|
||||
youtube: {},
|
||||
spotify: {},
|
||||
joystick: {},
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ function testConfig(): HeimdallConfig {
|
|||
twitch: {},
|
||||
youtube: {},
|
||||
spotify: {},
|
||||
joystick: {},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@ function testConfig(): HeimdallConfig {
|
|||
twitch: {},
|
||||
youtube: {},
|
||||
spotify: {},
|
||||
joystick: {},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,8 +392,30 @@ for (const { kind, live } of rigKinds) describe.skipIf(live && !adminUrl)(`manag
|
|||
expect(rig.custody.decrypt(outcome.identity.accessTokenEncrypted!)).toBe("access-1");
|
||||
});
|
||||
|
||||
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 () => {
|
||||
expect(providerCatalog[provider].rotatesRefreshTokens).toBe(false);
|
||||
const rig = await makeRig(provider);
|
||||
const endpoint = installTokenEndpoint(provider, "refresh-0");
|
||||
endpoint.mode = "no_refresh_token";
|
||||
|
|
@ -402,6 +424,7 @@ for (const { kind, live } of rigKinds) describe.skipIf(live && !adminUrl)(`manag
|
|||
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 });
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ function createTestConfig(overrides: Partial<HeimdallConfig> = {}): HeimdallConf
|
|||
twitch: {},
|
||||
youtube: {},
|
||||
spotify: {},
|
||||
joystick: {},
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ describe("Heimdall Verse witness advertisement", () => {
|
|||
"twitch",
|
||||
"youtube",
|
||||
"spotify",
|
||||
"joystick",
|
||||
]);
|
||||
expect(advertisement.appProfiles.map((profile) => profile.slug)).toEqual([
|
||||
"repixelizer",
|
||||
|
|
|
|||
Loading…
Reference in a new issue