diff --git a/schemas/heimdall.auth_begin_command.v1.schema.json b/schemas/heimdall.auth_begin_command.v1.schema.json index 81fdaf8..ca22ccf 100644 --- a/schemas/heimdall.auth_begin_command.v1.schema.json +++ b/schemas/heimdall.auth_begin_command.v1.schema.json @@ -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": { diff --git a/src/app-profiles.ts b/src/app-profiles.ts index bede115..88466d9 100644 --- a/src/app-profiles.ts +++ b/src/app-profiles.ts @@ -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: [ { diff --git a/src/contracts.ts b/src/contracts.ts index 743bfdc..d6cab2a 100644 --- a/src/contracts.ts +++ b/src/contracts.ts @@ -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]; diff --git a/src/oauth.ts b/src/oauth.ts index 187df6c..c46976e 100644 --- a/src/oauth.ts +++ b/src/oauth.ts @@ -513,6 +513,45 @@ async function resolveSpotifyIdentity(options: { accessToken: string }): Promise return identity; } +async function resolveJoystickIdentity(options: { accessToken: string }): Promise { + 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, + }; + 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, }; } diff --git a/src/providers.ts b/src/providers.ts index 2998e4d..9b139ab 100644 --- a/src/providers.ts +++ b/src/providers.ts @@ -105,6 +105,18 @@ export const providerCatalog: Record = { 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: { diff --git a/tests/access-plugin.test.ts b/tests/access-plugin.test.ts index b5027d0..4789bf8 100644 --- a/tests/access-plugin.test.ts +++ b/tests/access-plugin.test.ts @@ -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: {}, }, }; } diff --git a/tests/app.test.ts b/tests/app.test.ts index d938b9c..b2960fc 100644 --- a/tests/app.test.ts +++ b/tests/app.test.ts @@ -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) { + const calls: Array<{ url: string; headers: Record; body?: URLSearchParams }> = []; + globalThis.fetch = (async (input: string | URL, init?: RequestInit) => { + const url = String(input); + calls.push({ url, headers: (init?.headers ?? {}) as Record, 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 diff --git a/tests/caller-identity-attack-soul-r21.test.ts b/tests/caller-identity-attack-soul-r21.test.ts index 4c3df32..de959a8 100644 --- a/tests/caller-identity-attack-soul-r21.test.ts +++ b/tests/caller-identity-attack-soul-r21.test.ts @@ -65,6 +65,7 @@ function testConfig(overrides: Partial = {}): HeimdallConfig { twitch: {}, youtube: {}, spotify: {}, + joystick: {}, }, ...overrides, }; diff --git a/tests/caller-identity-attack-soul.test.ts b/tests/caller-identity-attack-soul.test.ts index 9fb8e0a..8f77801 100644 --- a/tests/caller-identity-attack-soul.test.ts +++ b/tests/caller-identity-attack-soul.test.ts @@ -51,6 +51,7 @@ function testConfig(): HeimdallConfig { twitch: {}, youtube: {}, spotify: {}, + joystick: {}, }, }; } diff --git a/tests/caller-identity-soul.test.ts b/tests/caller-identity-soul.test.ts index 3ebc000..b5562a7 100644 --- a/tests/caller-identity-soul.test.ts +++ b/tests/caller-identity-soul.test.ts @@ -59,6 +59,7 @@ function testConfig(): HeimdallConfig { twitch: {}, youtube: {}, spotify: {}, + joystick: {}, }, }; } diff --git a/tests/managed-credential.test.ts b/tests/managed-credential.test.ts index b0dc2a2..427b2c8 100644 --- a/tests/managed-credential.test.ts +++ b/tests/managed-credential.test.ts @@ -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 }); diff --git a/tests/private-command-diagnostics.test.ts b/tests/private-command-diagnostics.test.ts index 41f16f1..d9d92ed 100644 --- a/tests/private-command-diagnostics.test.ts +++ b/tests/private-command-diagnostics.test.ts @@ -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; } diff --git a/tests/security.test.ts b/tests/security.test.ts index 5b07c6d..4f3f39d 100644 --- a/tests/security.test.ts +++ b/tests/security.test.ts @@ -38,6 +38,7 @@ function createTestConfig(overrides: Partial = {}): HeimdallConf twitch: {}, youtube: {}, spotify: {}, + joystick: {}, }, ...overrides, }; diff --git a/tests/verse-witness.test.ts b/tests/verse-witness.test.ts index acd9b89..07c141f 100644 --- a/tests/verse-witness.test.ts +++ b/tests/verse-witness.test.ts @@ -31,6 +31,7 @@ describe("Heimdall Verse witness advertisement", () => { "twitch", "youtube", "spotify", + "joystick", ]); expect(advertisement.appProfiles.map((profile) => profile.slug)).toEqual([ "repixelizer",