Collapse ten per-provider token functions into one descriptor-driven request

Endpoint, client-auth method (RFC 7591 names) and rotation now live on
ProviderDescriptor; requestProviderTokens is the only code that talks to a
token endpoint. Failure labels are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-19 11:06:32 +02:00
commit 0dbcd98019
3 changed files with 202 additions and 485 deletions

View file

@ -7,6 +7,7 @@ import {
} from "./contracts.js";
import { type HeimdallConfig } from "./config.js";
import { entitlementFacts } from "./facts.js";
import { providerCatalog } from "./providers.js";
import { type CreateEntitlementSnapshotInput } from "./store/types.js";
export interface OAuthTokenSet {
@ -49,6 +50,7 @@ export interface OAuthProviderRuntime {
refreshAccessToken?(options: {
config: HeimdallConfig;
refreshToken: string;
signal?: AbortSignal;
}): Promise<OAuthTokenSet>;
resolveIdentity(options: { config: HeimdallConfig; accessToken: string }): Promise<ResolvedIdentity>;
evaluateEntitlements(options: {
@ -106,512 +108,88 @@ function normalizeScopes(scope: string | string[] | undefined): string[] {
return scope?.split(/\s+/).filter(Boolean) ?? [];
}
async function exchangeDiscordAuthorizationCode(options: {
config: HeimdallConfig;
code: string;
redirectUri: string;
}): Promise<OAuthTokenSet> {
const providerConfig = options.config.providers.discord;
export type TokenGrant =
| { grant_type: "authorization_code"; code: string; redirect_uri: string }
| { grant_type: "refresh_token"; refresh_token: string };
/**
* The one token request: endpoint, client authentication and rotation all come
* from the provider's descriptor. `basic` sends the client as an Authorization
* header and nothing in the body; `post` sends it in the body. A refresh token
* is returned only when the provider sent one; keeping or discarding the old
* one is the caller's rule (ProviderDescriptor.rotatesRefreshTokens).
*/
export async function requestProviderTokens(
provider: Provider,
config: HeimdallConfig,
grant: TokenGrant,
signal?: AbortSignal
): Promise<OAuthTokenSet> {
const descriptor = providerCatalog[provider];
const providerConfig = config.providers[provider];
if (!providerConfig.clientId || !providerConfig.clientSecret) {
throw new Error("Discord OAuth is not fully configured.");
throw new Error(`${descriptor.displayName} OAuth is not fully configured.`);
}
const body = new URLSearchParams({
grant_type: "authorization_code",
code: options.code,
redirect_uri: options.redirectUri,
});
const basicAuth = Buffer.from(`${providerConfig.clientId}:${providerConfig.clientSecret}`).toString("base64");
const tokenResponse = await fetchJson<{
access_token: string;
refresh_token?: string;
token_type: string;
expires_in: number;
scope?: string | string[];
}>(
"https://discord.com/api/v10/oauth2/token",
{
method: "POST",
headers: {
Authorization: `Basic ${basicAuth}`,
"Content-Type": "application/x-www-form-urlencoded",
},
body,
},
"Discord token exchange failed"
);
const tokenSet: OAuthTokenSet = {
accessToken: tokenResponse.access_token,
tokenType: tokenResponse.token_type,
scope: normalizeScopes(tokenResponse.scope),
expiresAt: new Date(Date.now() + tokenResponse.expires_in * 1000).toISOString(),
raw: tokenResponse as unknown as Record<string, unknown>,
const body = new URLSearchParams(grant);
const headers: Record<string, string> = {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
};
if (tokenResponse.refresh_token) {
tokenSet.refreshToken = tokenResponse.refresh_token;
if (descriptor.tokenEndpointAuthMethod === "client_secret_basic") {
headers.Authorization = `Basic ${Buffer.from(`${providerConfig.clientId}:${providerConfig.clientSecret}`).toString("base64")}`;
} else {
body.set("client_id", providerConfig.clientId);
body.set("client_secret", providerConfig.clientSecret);
}
return tokenSet;
}
async function refreshDiscordAccessToken(options: {
config: HeimdallConfig;
refreshToken: string;
}): Promise<OAuthTokenSet> {
const providerConfig = options.config.providers.discord;
if (!providerConfig.clientId || !providerConfig.clientSecret) {
throw new Error("Discord OAuth is not fully configured.");
const init: RequestInit = { method: "POST", headers, body };
if (signal) {
init.signal = signal;
}
const basicAuth = Buffer.from(`${providerConfig.clientId}:${providerConfig.clientSecret}`).toString("base64");
const label = grant.grant_type === "refresh_token" ? "refresh" : "exchange";
const tokenResponse = await fetchJson<{
access_token: string;
refresh_token?: string;
token_type: string;
expires_in: number;
scope?: string | string[];
}>(
"https://discord.com/api/v10/oauth2/token",
{
method: "POST",
headers: {
Authorization: `Basic ${basicAuth}`,
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
grant_type: "refresh_token",
refresh_token: options.refreshToken,
}),
},
"Discord token refresh failed"
);
const tokenSet: OAuthTokenSet = {
accessToken: tokenResponse.access_token,
tokenType: tokenResponse.token_type,
scope: normalizeScopes(tokenResponse.scope),
expiresAt: new Date(Date.now() + tokenResponse.expires_in * 1000).toISOString(),
raw: tokenResponse as unknown as Record<string, unknown>,
};
if (tokenResponse.refresh_token) {
tokenSet.refreshToken = tokenResponse.refresh_token;
}
return tokenSet;
}
async function exchangePatreonAuthorizationCode(options: {
config: HeimdallConfig;
code: string;
redirectUri: string;
}): Promise<OAuthTokenSet> {
const providerConfig = options.config.providers.patreon;
if (!providerConfig.clientId || !providerConfig.clientSecret) {
throw new Error("Patreon OAuth is not fully configured.");
}
const body = new URLSearchParams({
grant_type: "authorization_code",
code: options.code,
client_id: providerConfig.clientId,
client_secret: providerConfig.clientSecret,
redirect_uri: options.redirectUri,
});
const tokenResponse = await fetchJson<{
access_token: string;
refresh_token?: string;
token_type: string;
token_type?: string;
expires_in?: number;
scope?: string | string[];
}>(
"https://www.patreon.com/api/oauth2/token",
{
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body,
},
"Patreon token exchange failed"
);
}>(descriptor.tokenEndpoint, init, `${descriptor.displayName} token ${label} failed`);
const tokenSet: OAuthTokenSet = {
accessToken: tokenResponse.access_token,
tokenType: tokenResponse.token_type,
tokenType: tokenResponse.token_type ?? "Bearer",
scope: normalizeScopes(tokenResponse.scope),
raw: tokenResponse as unknown as Record<string, unknown>,
};
if (tokenResponse.refresh_token) {
tokenSet.refreshToken = tokenResponse.refresh_token;
}
if (tokenResponse.expires_in) {
if (typeof tokenResponse.expires_in === "number" && tokenResponse.expires_in > 0) {
tokenSet.expiresAt = new Date(Date.now() + tokenResponse.expires_in * 1000).toISOString();
}
return tokenSet;
}
async function refreshPatreonAccessToken(options: {
config: HeimdallConfig;
refreshToken: string;
}): Promise<OAuthTokenSet> {
const providerConfig = options.config.providers.patreon;
if (!providerConfig.clientId || !providerConfig.clientSecret) {
throw new Error("Patreon OAuth is not fully configured.");
}
const tokenResponse = await fetchJson<{
access_token: string;
refresh_token?: string;
token_type: string;
expires_in?: number;
scope?: string | string[];
}>(
"https://www.patreon.com/api/oauth2/token",
{
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
grant_type: "refresh_token",
refresh_token: options.refreshToken,
client_id: providerConfig.clientId,
client_secret: providerConfig.clientSecret,
}),
},
"Patreon token refresh failed"
);
const tokenSet: OAuthTokenSet = {
accessToken: tokenResponse.access_token,
tokenType: tokenResponse.token_type,
scope: normalizeScopes(tokenResponse.scope),
raw: tokenResponse as unknown as Record<string, unknown>,
};
if (tokenResponse.refresh_token) {
tokenSet.refreshToken = tokenResponse.refresh_token;
}
if (tokenResponse.expires_in) {
tokenSet.expiresAt = new Date(Date.now() + tokenResponse.expires_in * 1000).toISOString();
}
return tokenSet;
function sharedRuntimeExchange(provider: Provider): OAuthProviderRuntime["exchangeAuthorizationCode"] {
return (options) =>
requestProviderTokens(provider, options.config, {
grant_type: "authorization_code",
code: options.code,
redirect_uri: options.redirectUri,
});
}
async function exchangeTwitchAuthorizationCode(options: {
config: HeimdallConfig;
code: string;
redirectUri: string;
}): Promise<OAuthTokenSet> {
const providerConfig = options.config.providers.twitch;
if (!providerConfig.clientId || !providerConfig.clientSecret) {
throw new Error("Twitch OAuth is not fully configured.");
}
const tokenResponse = await fetchJson<{
access_token: string;
refresh_token?: string;
token_type: string;
expires_in?: number;
scope?: string | string[];
}>(
"https://id.twitch.tv/oauth2/token",
{
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
client_id: providerConfig.clientId,
client_secret: providerConfig.clientSecret,
code: options.code,
grant_type: "authorization_code",
redirect_uri: options.redirectUri,
}),
},
"Twitch token exchange failed"
);
const tokenSet: OAuthTokenSet = {
accessToken: tokenResponse.access_token,
tokenType: tokenResponse.token_type,
scope: normalizeScopes(tokenResponse.scope),
raw: tokenResponse as unknown as Record<string, unknown>,
};
if (tokenResponse.refresh_token) {
tokenSet.refreshToken = tokenResponse.refresh_token;
}
if (tokenResponse.expires_in) {
tokenSet.expiresAt = new Date(Date.now() + tokenResponse.expires_in * 1000).toISOString();
}
return tokenSet;
function sharedRuntimeRefresh(provider: Provider): NonNullable<OAuthProviderRuntime["refreshAccessToken"]> {
return (options) =>
requestProviderTokens(
provider,
options.config,
{ grant_type: "refresh_token", refresh_token: options.refreshToken },
options.signal
);
}
async function refreshTwitchAccessToken(options: {
config: HeimdallConfig;
refreshToken: string;
}): Promise<OAuthTokenSet> {
const providerConfig = options.config.providers.twitch;
if (!providerConfig.clientId || !providerConfig.clientSecret) {
throw new Error("Twitch OAuth is not fully configured.");
}
const tokenResponse = await fetchJson<{
access_token: string;
refresh_token?: string;
token_type: string;
expires_in?: number;
scope?: string | string[];
}>(
"https://id.twitch.tv/oauth2/token",
{
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
client_id: providerConfig.clientId,
client_secret: providerConfig.clientSecret,
grant_type: "refresh_token",
refresh_token: options.refreshToken,
}),
},
"Twitch token refresh failed"
);
const tokenSet: OAuthTokenSet = {
accessToken: tokenResponse.access_token,
tokenType: tokenResponse.token_type,
scope: normalizeScopes(tokenResponse.scope),
raw: tokenResponse as unknown as Record<string, unknown>,
};
if (tokenResponse.refresh_token) {
tokenSet.refreshToken = tokenResponse.refresh_token;
}
if (tokenResponse.expires_in) {
tokenSet.expiresAt = new Date(Date.now() + tokenResponse.expires_in * 1000).toISOString();
}
return tokenSet;
}
async function exchangeYouTubeAuthorizationCode(options: {
config: HeimdallConfig;
code: string;
redirectUri: string;
}): Promise<OAuthTokenSet> {
const providerConfig = options.config.providers.youtube;
if (!providerConfig.clientId || !providerConfig.clientSecret) {
throw new Error("YouTube OAuth is not fully configured.");
}
const tokenResponse = await fetchJson<{
access_token: string;
refresh_token?: string;
token_type: string;
expires_in?: number;
scope?: string | string[];
id_token?: string;
}>(
"https://oauth2.googleapis.com/token",
{
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
client_id: providerConfig.clientId,
client_secret: providerConfig.clientSecret,
code: options.code,
grant_type: "authorization_code",
redirect_uri: options.redirectUri,
}),
},
"YouTube token exchange failed"
);
const tokenSet: OAuthTokenSet = {
accessToken: tokenResponse.access_token,
tokenType: tokenResponse.token_type,
scope: normalizeScopes(tokenResponse.scope),
raw: tokenResponse as unknown as Record<string, unknown>,
};
if (tokenResponse.refresh_token) {
tokenSet.refreshToken = tokenResponse.refresh_token;
}
if (tokenResponse.expires_in) {
tokenSet.expiresAt = new Date(Date.now() + tokenResponse.expires_in * 1000).toISOString();
}
return tokenSet;
}
async function refreshYouTubeAccessToken(options: {
config: HeimdallConfig;
refreshToken: string;
}): Promise<OAuthTokenSet> {
const providerConfig = options.config.providers.youtube;
if (!providerConfig.clientId || !providerConfig.clientSecret) {
throw new Error("YouTube OAuth is not fully configured.");
}
const tokenResponse = await fetchJson<{
access_token: string;
refresh_token?: string;
token_type: string;
expires_in?: number;
scope?: string | string[];
}>(
"https://oauth2.googleapis.com/token",
{
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
client_id: providerConfig.clientId,
client_secret: providerConfig.clientSecret,
grant_type: "refresh_token",
refresh_token: options.refreshToken,
}),
},
"YouTube token refresh failed"
);
const tokenSet: OAuthTokenSet = {
accessToken: tokenResponse.access_token,
tokenType: tokenResponse.token_type,
scope: normalizeScopes(tokenResponse.scope),
raw: tokenResponse as unknown as Record<string, unknown>,
};
if (tokenResponse.refresh_token) {
tokenSet.refreshToken = tokenResponse.refresh_token;
}
if (tokenResponse.expires_in) {
tokenSet.expiresAt = new Date(Date.now() + tokenResponse.expires_in * 1000).toISOString();
}
return tokenSet;
}
async function exchangeSpotifyAuthorizationCode(options: {
config: HeimdallConfig;
code: string;
redirectUri: string;
}): Promise<OAuthTokenSet> {
const providerConfig = options.config.providers.spotify;
if (!providerConfig.clientId || !providerConfig.clientSecret) {
throw new Error("Spotify OAuth is not fully configured.");
}
const basicAuth = Buffer.from(`${providerConfig.clientId}:${providerConfig.clientSecret}`).toString("base64");
const tokenResponse = await fetchJson<{
access_token: string;
refresh_token?: string;
token_type: string;
expires_in?: number;
scope?: string | string[];
}>(
"https://accounts.spotify.com/api/token",
{
method: "POST",
headers: {
Authorization: `Basic ${basicAuth}`,
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
grant_type: "authorization_code",
code: options.code,
redirect_uri: options.redirectUri,
}),
},
"Spotify token exchange failed"
);
const tokenSet: OAuthTokenSet = {
accessToken: tokenResponse.access_token,
tokenType: tokenResponse.token_type,
scope: normalizeScopes(tokenResponse.scope),
raw: tokenResponse as unknown as Record<string, unknown>,
};
if (tokenResponse.refresh_token) {
tokenSet.refreshToken = tokenResponse.refresh_token;
}
if (tokenResponse.expires_in) {
tokenSet.expiresAt = new Date(Date.now() + tokenResponse.expires_in * 1000).toISOString();
}
return tokenSet;
}
async function refreshSpotifyAccessToken(options: {
config: HeimdallConfig;
refreshToken: string;
}): Promise<OAuthTokenSet> {
const providerConfig = options.config.providers.spotify;
if (!providerConfig.clientId || !providerConfig.clientSecret) {
throw new Error("Spotify OAuth is not fully configured.");
}
const basicAuth = Buffer.from(`${providerConfig.clientId}:${providerConfig.clientSecret}`).toString("base64");
const tokenResponse = await fetchJson<{
access_token: string;
refresh_token?: string;
token_type: string;
expires_in?: number;
scope?: string | string[];
}>(
"https://accounts.spotify.com/api/token",
{
method: "POST",
headers: {
Authorization: `Basic ${basicAuth}`,
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
grant_type: "refresh_token",
refresh_token: options.refreshToken,
}),
},
"Spotify token refresh failed"
);
const tokenSet: OAuthTokenSet = {
accessToken: tokenResponse.access_token,
tokenType: tokenResponse.token_type,
scope: normalizeScopes(tokenResponse.scope),
raw: tokenResponse as unknown as Record<string, unknown>,
};
if (tokenResponse.refresh_token) {
tokenSet.refreshToken = tokenResponse.refresh_token;
}
if (tokenResponse.expires_in) {
tokenSet.expiresAt = new Date(Date.now() + tokenResponse.expires_in * 1000).toISOString();
}
return tokenSet;
}
async function resolveDiscordIdentity(options: { accessToken: string }): Promise<ResolvedIdentity> {
const user = await fetchJson<{
@ -1217,22 +795,22 @@ async function evaluatePatreonEntitlements(options: {
}
const discordRuntime: OAuthProviderRuntime = {
exchangeAuthorizationCode: exchangeDiscordAuthorizationCode,
refreshAccessToken: refreshDiscordAccessToken,
exchangeAuthorizationCode: sharedRuntimeExchange("discord"),
refreshAccessToken: sharedRuntimeRefresh("discord"),
resolveIdentity: resolveDiscordIdentity,
evaluateEntitlements: evaluateDiscordEntitlements,
};
const patreonRuntime: OAuthProviderRuntime = {
exchangeAuthorizationCode: exchangePatreonAuthorizationCode,
refreshAccessToken: refreshPatreonAccessToken,
exchangeAuthorizationCode: sharedRuntimeExchange("patreon"),
refreshAccessToken: sharedRuntimeRefresh("patreon"),
resolveIdentity: resolvePatreonIdentity,
evaluateEntitlements: evaluatePatreonEntitlements,
};
const twitchRuntime: OAuthProviderRuntime = {
exchangeAuthorizationCode: exchangeTwitchAuthorizationCode,
refreshAccessToken: refreshTwitchAccessToken,
exchangeAuthorizationCode: sharedRuntimeExchange("twitch"),
refreshAccessToken: sharedRuntimeRefresh("twitch"),
resolveIdentity: resolveTwitchIdentity,
async evaluateEntitlements() {
return { facts: [], snapshots: [] };
@ -1240,8 +818,8 @@ const twitchRuntime: OAuthProviderRuntime = {
};
const youtubeRuntime: OAuthProviderRuntime = {
exchangeAuthorizationCode: exchangeYouTubeAuthorizationCode,
refreshAccessToken: refreshYouTubeAccessToken,
exchangeAuthorizationCode: sharedRuntimeExchange("youtube"),
refreshAccessToken: sharedRuntimeRefresh("youtube"),
resolveIdentity: resolveYouTubeIdentity,
async evaluateEntitlements() {
return { facts: [], snapshots: [] };
@ -1249,8 +827,8 @@ const youtubeRuntime: OAuthProviderRuntime = {
};
const spotifyRuntime: OAuthProviderRuntime = {
exchangeAuthorizationCode: exchangeSpotifyAuthorizationCode,
refreshAccessToken: refreshSpotifyAccessToken,
exchangeAuthorizationCode: sharedRuntimeExchange("spotify"),
refreshAccessToken: sharedRuntimeRefresh("spotify"),
resolveIdentity: resolveSpotifyIdentity,
async evaluateEntitlements() {
return { facts: [], snapshots: [] };

View file

@ -6,6 +6,11 @@ export interface ProviderDescriptor {
key: Provider;
displayName: string;
authorizationEndpoint: string;
tokenEndpoint: string;
/** RFC 7591 names: how the client authenticates at the token endpoint. */
tokenEndpointAuthMethod: "client_secret_basic" | "client_secret_post";
/** True when a refresh response replaces the refresh token and the old one dies. */
rotatesRefreshTokens: boolean;
defaultScopes: string[];
roles: ProviderRole[];
clientIdEnv: string;
@ -18,6 +23,9 @@ export const providerCatalog: Record<Provider, ProviderDescriptor> = {
key: "discord",
displayName: "Discord",
authorizationEndpoint: "https://discord.com/oauth2/authorize",
tokenEndpoint: "https://discord.com/api/v10/oauth2/token",
tokenEndpointAuthMethod: "client_secret_basic",
rotatesRefreshTokens: false,
defaultScopes: ["identify", "guilds.members.read"],
roles: ["identity", "entitlement"],
clientIdEnv: "GC_ACCESS_PROVIDER_DISCORD_CLIENT_ID",
@ -27,6 +35,9 @@ export const providerCatalog: Record<Provider, ProviderDescriptor> = {
key: "patreon",
displayName: "Patreon",
authorizationEndpoint: "https://www.patreon.com/oauth2/authorize",
tokenEndpoint: "https://www.patreon.com/api/oauth2/token",
tokenEndpointAuthMethod: "client_secret_post",
rotatesRefreshTokens: false,
defaultScopes: ["identity", "identity[email]"],
roles: ["identity", "entitlement"],
clientIdEnv: "GC_ACCESS_PROVIDER_PATREON_CLIENT_ID",
@ -36,6 +47,9 @@ export const providerCatalog: Record<Provider, ProviderDescriptor> = {
key: "github",
displayName: "GitHub",
authorizationEndpoint: "https://github.com/login/oauth/authorize",
tokenEndpoint: "https://github.com/login/oauth/access_token",
tokenEndpointAuthMethod: "client_secret_post",
rotatesRefreshTokens: false,
defaultScopes: ["read:user", "user:email"],
roles: ["identity", "managed_credential"],
clientIdEnv: "GC_ACCESS_PROVIDER_GITHUB_CLIENT_ID",
@ -45,6 +59,9 @@ export const providerCatalog: Record<Provider, ProviderDescriptor> = {
key: "twitch",
displayName: "Twitch",
authorizationEndpoint: "https://id.twitch.tv/oauth2/authorize",
tokenEndpoint: "https://id.twitch.tv/oauth2/token",
tokenEndpointAuthMethod: "client_secret_post",
rotatesRefreshTokens: false,
defaultScopes: ["user:read:email"],
roles: ["identity", "managed_credential"],
clientIdEnv: "GC_ACCESS_PROVIDER_TWITCH_CLIENT_ID",
@ -54,6 +71,9 @@ export const providerCatalog: Record<Provider, ProviderDescriptor> = {
key: "youtube",
displayName: "YouTube",
authorizationEndpoint: "https://accounts.google.com/o/oauth2/v2/auth",
tokenEndpoint: "https://oauth2.googleapis.com/token",
tokenEndpointAuthMethod: "client_secret_post",
rotatesRefreshTokens: false,
defaultScopes: [
"openid",
"email",
@ -73,6 +93,9 @@ export const providerCatalog: Record<Provider, ProviderDescriptor> = {
key: "spotify",
displayName: "Spotify",
authorizationEndpoint: "https://accounts.spotify.com/authorize",
tokenEndpoint: "https://accounts.spotify.com/api/token",
tokenEndpointAuthMethod: "client_secret_basic",
rotatesRefreshTokens: false,
defaultScopes: [
"user-read-currently-playing",
"user-read-playback-state",

View file

@ -0,0 +1,116 @@
import { afterEach, describe, expect, it } from "vitest";
import { type HeimdallConfig } from "../src/config.js";
import { providers, type Provider } from "../src/contracts.js";
import { requestProviderTokens } from "../src/oauth.js";
import { providerCatalog } from "../src/providers.js";
const originalFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = originalFetch;
});
function configFor(): HeimdallConfig {
return {
providers: Object.fromEntries(providers.map((p) => [p, { clientId: `${p}-client`, clientSecret: `${p}-secret` }])),
} as unknown as HeimdallConfig;
}
interface Captured {
url: string;
headers: Record<string, string>;
body: URLSearchParams;
}
function capture(answer: () => Response): { calls: Captured[] } {
const calls: Captured[] = [];
globalThis.fetch = (async (input: string | URL, init?: RequestInit) => {
calls.push({
url: String(input),
headers: init?.headers as Record<string, string>,
body: init?.body as URLSearchParams,
});
return answer();
}) as typeof fetch;
return { calls };
}
const ok = () =>
new Response(JSON.stringify({ access_token: "a", refresh_token: "r", token_type: "Bearer", expires_in: 3600, scope: "x y" }), {
status: 200,
});
const requestable: Provider[] = [...providers];
describe("requestProviderTokens follows the provider descriptor", () => {
it.each(requestable.flatMap((p) => [[p, "authorization_code"] as const, [p, "refresh_token"] as const]))(
"%s %s hits its token endpoint with its client-auth method only",
async (provider, grantType) => {
const { calls } = capture(ok);
const descriptor = providerCatalog[provider];
const grant =
grantType === "authorization_code"
? ({ grant_type: "authorization_code", code: "the-code", redirect_uri: "https://h/cb" } as const)
: ({ grant_type: "refresh_token", refresh_token: "the-refresh" } as const);
const tokens = await requestProviderTokens(provider, configFor(), grant);
expect(calls).toHaveLength(1);
const call = calls[0]!;
expect(call.url).toBe(descriptor.tokenEndpoint);
expect(call.headers["Content-Type"]).toBe("application/x-www-form-urlencoded");
expect(call.headers.Accept).toBe("application/json");
expect(call.body.get("grant_type")).toBe(grantType);
if (descriptor.tokenEndpointAuthMethod === "client_secret_basic") {
expect(call.headers.Authorization).toBe(`Basic ${Buffer.from(`${provider}-client:${provider}-secret`).toString("base64")}`);
expect(call.body.has("client_id")).toBe(false);
expect(call.body.has("client_secret")).toBe(false);
} else {
expect(call.headers.Authorization).toBeUndefined();
expect(call.body.get("client_id")).toBe(`${provider}-client`);
expect(call.body.get("client_secret")).toBe(`${provider}-secret`);
}
expect(tokens.accessToken).toBe("a");
expect(tokens.refreshToken).toBe("r");
expect(tokens.scope).toEqual(["x", "y"]);
expect(Date.parse(tokens.expiresAt ?? "")).toBeGreaterThan(Date.now());
}
);
it("sends the code and redirect uri on exchange and the refresh token on refresh", async () => {
const { calls } = capture(ok);
await requestProviderTokens("spotify", configFor(), { grant_type: "authorization_code", code: "C", redirect_uri: "https://h/cb" });
await requestProviderTokens("spotify", configFor(), { grant_type: "refresh_token", refresh_token: "R" });
expect(calls[0]!.body.get("code")).toBe("C");
expect(calls[0]!.body.get("redirect_uri")).toBe("https://h/cb");
expect(calls[1]!.body.get("refresh_token")).toBe("R");
expect(calls[1]!.body.has("code")).toBe(false);
});
it.each([
["no expires_in", { access_token: "a" }],
["a zero expires_in", { access_token: "a", expires_in: 0 }],
["a string expires_in", { access_token: "a", expires_in: "3600" }],
])("sets no expiry and no refresh token for %s", async (_n, body) => {
capture(() => new Response(JSON.stringify(body), { status: 200 }));
const tokens = await requestProviderTokens("twitch", configFor(), { grant_type: "refresh_token", refresh_token: "R" });
expect(tokens.expiresAt).toBeUndefined();
expect(tokens.refreshToken).toBeUndefined();
});
it.each([
["authorization_code", { grant_type: "authorization_code", code: "c", redirect_uri: "u" } as const, "exchange"],
["refresh_token", { grant_type: "refresh_token", refresh_token: "r" } as const, "refresh"],
])("keeps the failure label for %s", async (_g, grant, word) => {
capture(() => new Response("nope", { status: 500 }));
await expect(requestProviderTokens("youtube", configFor(), grant)).rejects.toThrow(
`YouTube token ${word} failed (status 500, provider_status).`
);
});
it("refuses an unconfigured provider by display name", async () => {
const config = { providers: { patreon: {} } } as unknown as HeimdallConfig;
await expect(
requestProviderTokens("patreon", config, { grant_type: "refresh_token", refresh_token: "r" })
).rejects.toThrow("Patreon OAuth is not fully configured.");
});
});