diff --git a/src/oauth.ts b/src/oauth.ts index 33ae495..2d0020c 100644 --- a/src/oauth.ts +++ b/src/oauth.ts @@ -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; resolveIdentity(options: { config: HeimdallConfig; accessToken: string }): Promise; 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 { - 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 { + 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, + const body = new URLSearchParams(grant); + const headers: Record = { + "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 { - 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, - }; - - if (tokenResponse.refresh_token) { - tokenSet.refreshToken = tokenResponse.refresh_token; - } - - return tokenSet; -} - -async function exchangePatreonAuthorizationCode(options: { - config: HeimdallConfig; - code: string; - redirectUri: string; -}): Promise { - 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, }; - 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 { - 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, - }; - - 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 { - 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, - }; - - 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 { + 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 { - 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, - }; - - 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 { - 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, - }; - - 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 { - 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, - }; - - 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 { - 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, - }; - - 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 { - 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, - }; - - 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 { 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: [] }; diff --git a/src/providers.ts b/src/providers.ts index 506e084..2998e4d 100644 --- a/src/providers.ts +++ b/src/providers.ts @@ -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 = { 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 = { 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 = { 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 = { 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 = { 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 = { 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", diff --git a/tests/provider-token-request.test.ts b/tests/provider-token-request.test.ts new file mode 100644 index 0000000..2d13a30 --- /dev/null +++ b/tests/provider-token-request.test.ts @@ -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; + 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, + 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."); + }); +});