diff --git a/src/oauth.ts b/src/oauth.ts index d4531d9..1c6d3c8 100644 --- a/src/oauth.ts +++ b/src/oauth.ts @@ -70,6 +70,14 @@ async function fetchJson(input: string | URL, init: RequestInit, fallbackErro return (text ? JSON.parse(text) : {}) as T; } +function normalizeScopes(scope: string | string[] | undefined): string[] { + if (Array.isArray(scope)) { + return scope.map((value) => value.trim()).filter(Boolean); + } + + return scope?.split(/\s+/).filter(Boolean) ?? []; +} + async function exchangeDiscordAuthorizationCode(options: { config: HeimdallConfig; code: string; @@ -91,7 +99,7 @@ async function exchangeDiscordAuthorizationCode(options: { refresh_token?: string; token_type: string; expires_in: number; - scope: string; + scope?: string | string[]; }>( "https://discord.com/api/v10/oauth2/token", { @@ -108,7 +116,7 @@ async function exchangeDiscordAuthorizationCode(options: { const tokenSet: OAuthTokenSet = { accessToken: tokenResponse.access_token, tokenType: tokenResponse.token_type, - scope: tokenResponse.scope.split(/\s+/).filter(Boolean), + scope: normalizeScopes(tokenResponse.scope), expiresAt: new Date(Date.now() + tokenResponse.expires_in * 1000).toISOString(), raw: tokenResponse as unknown as Record, }; @@ -142,7 +150,7 @@ async function exchangePatreonAuthorizationCode(options: { refresh_token?: string; token_type: string; expires_in?: number; - scope?: string; + scope?: string | string[]; }>( "https://www.patreon.com/api/oauth2/token", { @@ -158,7 +166,7 @@ async function exchangePatreonAuthorizationCode(options: { const tokenSet: OAuthTokenSet = { accessToken: tokenResponse.access_token, tokenType: tokenResponse.token_type, - scope: tokenResponse.scope?.split(/\s+/).filter(Boolean) ?? [], + scope: normalizeScopes(tokenResponse.scope), raw: tokenResponse as unknown as Record, }; @@ -188,7 +196,7 @@ async function exchangeTwitchAuthorizationCode(options: { refresh_token?: string; token_type: string; expires_in?: number; - scope?: string; + scope?: string | string[]; }>( "https://id.twitch.tv/oauth2/token", { @@ -210,7 +218,7 @@ async function exchangeTwitchAuthorizationCode(options: { const tokenSet: OAuthTokenSet = { accessToken: tokenResponse.access_token, tokenType: tokenResponse.token_type, - scope: tokenResponse.scope?.split(/\s+/).filter(Boolean) ?? [], + scope: normalizeScopes(tokenResponse.scope), raw: tokenResponse as unknown as Record, }; @@ -240,7 +248,7 @@ async function exchangeYouTubeAuthorizationCode(options: { refresh_token?: string; token_type: string; expires_in?: number; - scope?: string; + scope?: string | string[]; id_token?: string; }>( "https://oauth2.googleapis.com/token", @@ -263,7 +271,7 @@ async function exchangeYouTubeAuthorizationCode(options: { const tokenSet: OAuthTokenSet = { accessToken: tokenResponse.access_token, tokenType: tokenResponse.token_type, - scope: tokenResponse.scope?.split(/\s+/).filter(Boolean) ?? [], + scope: normalizeScopes(tokenResponse.scope), raw: tokenResponse as unknown as Record, }; diff --git a/tests/app.test.ts b/tests/app.test.ts index c31bf90..a09298b 100644 --- a/tests/app.test.ts +++ b/tests/app.test.ts @@ -3,7 +3,7 @@ import { type FastifyInstance } from "fastify"; import { buildApp } from "../src/app.js"; import { type HeimdallConfig } from "../src/config.js"; import { entitlementFacts, grantFacts, identityFacts } from "../src/facts.js"; -import { type OAuthProviderRuntime } from "../src/oauth.js"; +import { createOAuthRuntimeRegistry, type OAuthProviderRuntime } from "../src/oauth.js"; import { verifyJwt } from "../src/signing.js"; import { createHeimdallAccessTokenVerifier } from "../src/verifier.js"; import { InMemoryStore } from "../src/store/index.js"; @@ -655,4 +655,32 @@ describe("Heimdall service", () => { ); expect(credentialResponse.body).not.toContain("twitch-refresh-token"); }); + + it("accepts Twitch token scopes returned as an array", async () => { + globalThis.fetch = async () => + new Response( + JSON.stringify({ + access_token: "twitch-access-token", + refresh_token: "twitch-refresh-token", + token_type: "bearer", + expires_in: 14124, + scope: ["user:read:email"], + }), + { + status: 200, + headers: { + "content-type": "application/json", + }, + } + ); + + const runtimes = createOAuthRuntimeRegistry(); + const tokenSet = await runtimes.twitch.exchangeAuthorizationCode({ + config: createTestConfig(), + code: "test-code", + redirectUri: "https://heimdall.gamecult.org/v1/oauth/twitch/callback", + }); + + expect(tokenSet.scope).toEqual(["user:read:email"]); + }); });