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:
parent
2108d1088b
commit
0dbcd98019
3 changed files with 202 additions and 485 deletions
550
src/oauth.ts
550
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<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: [] };
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
116
tests/provider-token-request.test.ts
Normal file
116
tests/provider-token-request.test.ts
Normal 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.");
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue