Normalize OAuth token scopes

This commit is contained in:
Meta Vens 2026-04-29 16:21:21 +01:00
commit 09373ccb4b
2 changed files with 45 additions and 9 deletions

View file

@ -70,6 +70,14 @@ async function fetchJson<T>(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<string, unknown>,
};
@ -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<string, unknown>,
};
@ -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<string, unknown>,
};
@ -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<string, unknown>,
};

View file

@ -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"]);
});
});