Joystick rotates refresh tokens, so a code-exchange answer whose refresh_token is absent or empty would store a credential the first refresh revokes. The shared exchange now throws a typed ProviderResponseError before the identity is resolved or anything is written. The residual-risk comment on the in-flight refresh map cites its follow-up. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
990 lines
32 KiB
TypeScript
990 lines
32 KiB
TypeScript
import { Buffer } from "node:buffer";
|
|
import {
|
|
type AppSlug,
|
|
type OAuthConnectionBinding,
|
|
type OAuthEntitlementPolicy,
|
|
type Provider,
|
|
} 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 {
|
|
accessToken: string;
|
|
refreshToken?: string;
|
|
tokenType: string;
|
|
scope: string[];
|
|
expiresAt?: string;
|
|
raw: Record<string, unknown>;
|
|
}
|
|
|
|
export interface ResolvedIdentity {
|
|
provider: Provider;
|
|
providerUserId: string;
|
|
username?: string;
|
|
displayName?: string;
|
|
primaryEmail?: string;
|
|
avatarUrl?: string;
|
|
profile: Record<string, unknown>;
|
|
}
|
|
|
|
export interface EntitlementEvaluation {
|
|
facts: string[];
|
|
snapshots: CreateEntitlementSnapshotInput[];
|
|
}
|
|
|
|
export interface OAuthCallbackContext {
|
|
appSlug: AppSlug;
|
|
accountId: string;
|
|
connection: OAuthConnectionBinding | null;
|
|
entitlementPolicy: OAuthEntitlementPolicy | null;
|
|
}
|
|
|
|
/** A token set whose access-token expiry is known: the provider's own, or the descriptor's default. */
|
|
export type ExpiringTokenSet = OAuthTokenSet & { expiresAt: string };
|
|
|
|
export interface OAuthProviderRuntime {
|
|
exchangeAuthorizationCode(options: {
|
|
config: HeimdallConfig;
|
|
code: string;
|
|
redirectUri: string;
|
|
}): Promise<OAuthTokenSet>;
|
|
refreshAccessToken?(options: {
|
|
config: HeimdallConfig;
|
|
refreshToken: string;
|
|
signal?: AbortSignal;
|
|
}): Promise<ExpiringTokenSet>;
|
|
resolveIdentity(options: { config: HeimdallConfig; accessToken: string }): Promise<ResolvedIdentity>;
|
|
evaluateEntitlements(options: {
|
|
config: HeimdallConfig;
|
|
callback: OAuthCallbackContext;
|
|
identity: ResolvedIdentity;
|
|
tokenSet: OAuthTokenSet;
|
|
}): Promise<EntitlementEvaluation>;
|
|
}
|
|
|
|
const oauthErrorCodes = [
|
|
"invalid_request",
|
|
"invalid_client",
|
|
"invalid_grant",
|
|
"unauthorized_client",
|
|
"unsupported_grant_type",
|
|
"invalid_scope",
|
|
] as const;
|
|
export type OAuthErrorCode = (typeof oauthErrorCodes)[number];
|
|
|
|
function readOAuthErrorCode(text: string): OAuthErrorCode | undefined {
|
|
try {
|
|
const error = (JSON.parse(text) as { error?: unknown } | null)?.error;
|
|
return oauthErrorCodes.find((code) => code === error);
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* A provider answer Heimdall cannot use: a non-2xx status, or a body that is
|
|
* not JSON. It carries the status and a code, never the body or the parser's
|
|
* message: a provider's body can quote the request or hold tokens (JSON.parse
|
|
* quotes the text around the fault), and whatever catches this may record,
|
|
* print or relay it.
|
|
*/
|
|
export class ProviderResponseError extends Error {
|
|
readonly status: number;
|
|
readonly code: "provider_status" | "provider_body_not_json" | "provider_body_not_token_set";
|
|
/**
|
|
* The RFC 6749 section 5.2 error code of a refusal, when the provider sent
|
|
* one from that closed set. The only value taken from a provider's body.
|
|
*/
|
|
readonly oauthError: OAuthErrorCode | undefined;
|
|
|
|
constructor(status: number, code: ProviderResponseError["code"], failure: string, oauthError?: OAuthErrorCode) {
|
|
super(`${failure} (status ${status}, ${code}).`);
|
|
this.name = "ProviderResponseError";
|
|
this.status = status;
|
|
this.code = code;
|
|
this.oauthError = oauthError;
|
|
}
|
|
}
|
|
|
|
/** The JSON body of a provider's answer; refuses any other answer by status and code alone. */
|
|
async function readProviderJson<T>(response: Response, failure: string): Promise<T> {
|
|
const text = await response.text();
|
|
if (!response.ok) {
|
|
// RFC 6749 section 5.2 sends a grant verdict as 400 only. The same JSON
|
|
// behind a 401, a 5xx or a gateway page is not one.
|
|
throw new ProviderResponseError(
|
|
response.status,
|
|
"provider_status",
|
|
failure,
|
|
response.status === 400 ? readOAuthErrorCode(text) : undefined
|
|
);
|
|
}
|
|
if (!text) {
|
|
return {} as T;
|
|
}
|
|
try {
|
|
return JSON.parse(text) as T;
|
|
} catch {
|
|
throw new ProviderResponseError(response.status, "provider_body_not_json", failure);
|
|
}
|
|
}
|
|
|
|
async function fetchJson<T>(input: string | URL, init: RequestInit, failure: string): Promise<T> {
|
|
return readProviderJson<T>(await fetch(input, init), failure);
|
|
}
|
|
|
|
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) ?? [];
|
|
}
|
|
|
|
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<ExpiringTokenSet> {
|
|
const descriptor = providerCatalog[provider];
|
|
const providerConfig = config.providers[provider];
|
|
if (!providerConfig.clientId || !providerConfig.clientSecret) {
|
|
throw new Error(`${descriptor.displayName} OAuth is not fully configured.`);
|
|
}
|
|
|
|
const body = new URLSearchParams(grant);
|
|
const headers: Record<string, string> = {
|
|
"Content-Type": "application/x-www-form-urlencoded",
|
|
Accept: "application/json",
|
|
};
|
|
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);
|
|
}
|
|
|
|
const init: RequestInit = { method: "POST", headers, body };
|
|
if (signal) {
|
|
init.signal = signal;
|
|
}
|
|
const label = grant.grant_type === "refresh_token" ? "refresh" : "exchange";
|
|
const failure = `${descriptor.displayName} token ${label} failed`;
|
|
const response = await fetch(descriptor.tokenEndpoint, init);
|
|
const answer = await readProviderJson<unknown>(response, failure);
|
|
// A 2xx is a token set only when it is an object holding a string access
|
|
// token and no error.
|
|
if (
|
|
typeof answer !== "object" ||
|
|
answer === null ||
|
|
typeof (answer as { access_token?: unknown }).access_token !== "string" ||
|
|
!(answer as { access_token: string }).access_token ||
|
|
(answer as { error?: unknown }).error !== undefined
|
|
) {
|
|
throw new ProviderResponseError(response.status, "provider_body_not_token_set", failure);
|
|
}
|
|
const tokenResponse = answer as {
|
|
access_token: string;
|
|
refresh_token?: unknown;
|
|
token_type?: string;
|
|
expires_in?: unknown;
|
|
scope?: string | string[];
|
|
};
|
|
|
|
// A refresh token that is present must be a string: anything else is a
|
|
// malformed grant answer, refused here as a typed failure rather than
|
|
// reaching custody as a TypeError.
|
|
if (tokenResponse.refresh_token !== undefined && typeof tokenResponse.refresh_token !== "string") {
|
|
throw new ProviderResponseError(response.status, "provider_body_not_token_set", failure);
|
|
}
|
|
|
|
// Nothing issued here has an unknown expiry: an answer without a usable
|
|
// expires_in takes the descriptor's default lifetime.
|
|
const lifetimeSeconds = usableExpiresIn(tokenResponse.expires_in) ?? descriptor.defaultAccessTokenLifetimeSeconds;
|
|
const tokenSet: ExpiringTokenSet = {
|
|
accessToken: tokenResponse.access_token,
|
|
tokenType: tokenResponse.token_type ?? "Bearer",
|
|
scope: normalizeScopes(tokenResponse.scope),
|
|
expiresAt: new Date(Date.now() + lifetimeSeconds * 1000).toISOString(),
|
|
raw: tokenResponse as unknown as Record<string, unknown>,
|
|
};
|
|
if (tokenResponse.refresh_token) {
|
|
tokenSet.refreshToken = tokenResponse.refresh_token;
|
|
}
|
|
return tokenSet;
|
|
}
|
|
|
|
/** The longest access token lifetime taken at its word: a year. Anything above is not a lifetime. */
|
|
const MAX_USABLE_EXPIRES_IN_SECONDS = 365 * 24 * 60 * 60;
|
|
|
|
/**
|
|
* A positive finite number of seconds, or a string that is a positive integer,
|
|
* no longer than a year. A larger value would overflow the Date the expiry is
|
|
* stored as, so it is unusable and the descriptor's default lifetime applies.
|
|
*/
|
|
function usableExpiresIn(value: unknown): number | undefined {
|
|
const seconds = typeof value === "string" && /^\s*\d+\s*$/.test(value) ? Number(value) : value;
|
|
return typeof seconds === "number" && Number.isFinite(seconds) && seconds > 0 && seconds <= MAX_USABLE_EXPIRES_IN_SECONDS
|
|
? seconds
|
|
: undefined;
|
|
}
|
|
|
|
function sharedRuntimeExchange(provider: Provider): OAuthProviderRuntime["exchangeAuthorizationCode"] {
|
|
return async (options) => {
|
|
const tokenSet = await requestProviderTokens(provider, options.config, {
|
|
grant_type: "authorization_code",
|
|
code: options.code,
|
|
redirect_uri: options.redirectUri,
|
|
});
|
|
// A rotating provider's refresh token is the only way back to the
|
|
// credential: a grant that arrives without one would be stored as a row
|
|
// the first refresh revokes, so the callback fails before it writes.
|
|
if (providerCatalog[provider].rotatesRefreshTokens && !tokenSet.refreshToken) {
|
|
throw new ProviderResponseError(200, "provider_body_not_token_set", `${providerCatalog[provider].displayName} token exchange failed`);
|
|
}
|
|
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 resolveDiscordIdentity(options: { accessToken: string }): Promise<ResolvedIdentity> {
|
|
const user = await fetchJson<{
|
|
id: string;
|
|
username?: string;
|
|
global_name?: string;
|
|
email?: string;
|
|
avatar?: string | null;
|
|
}>(
|
|
"https://discord.com/api/v10/users/@me",
|
|
{
|
|
headers: {
|
|
Authorization: `Bearer ${options.accessToken}`,
|
|
},
|
|
},
|
|
"Discord identity lookup failed"
|
|
);
|
|
|
|
const identity: ResolvedIdentity = {
|
|
provider: "discord",
|
|
providerUserId: user.id,
|
|
profile: user as unknown as Record<string, unknown>,
|
|
};
|
|
|
|
if (user.username) {
|
|
identity.username = user.username;
|
|
}
|
|
|
|
const displayName = user.global_name ?? user.username;
|
|
if (displayName) {
|
|
identity.displayName = displayName;
|
|
}
|
|
|
|
if (user.email) {
|
|
identity.primaryEmail = user.email;
|
|
}
|
|
|
|
if (user.avatar) {
|
|
identity.avatarUrl = `https://cdn.discordapp.com/avatars/${user.id}/${user.avatar}.png`;
|
|
}
|
|
|
|
return identity;
|
|
}
|
|
|
|
export async function fetchPatreonIdentity(accessToken: string): Promise<Record<string, unknown>> {
|
|
const url = new URL("https://www.patreon.com/api/oauth2/v2/identity");
|
|
url.searchParams.set("include", "memberships.currently_entitled_tiers,memberships.campaign");
|
|
url.searchParams.set("fields[user]", "email,full_name,image_url,thumb_url,url,vanity");
|
|
url.searchParams.set(
|
|
"fields[member]",
|
|
"currently_entitled_amount_cents,last_charge_date,last_charge_status,patron_status,pledge_relationship_start"
|
|
);
|
|
url.searchParams.set("fields[tier]", "title");
|
|
url.searchParams.set("fields[campaign]", "creation_name,currency,url");
|
|
|
|
return fetchJson<Record<string, unknown>>(
|
|
url,
|
|
{
|
|
headers: {
|
|
Authorization: `Bearer ${accessToken}`,
|
|
},
|
|
},
|
|
"Patreon identity lookup failed"
|
|
);
|
|
}
|
|
|
|
function patreonUserAttributes(payload: Record<string, unknown>): Record<string, unknown> {
|
|
const data = payload.data;
|
|
if (typeof data !== "object" || data === null) {
|
|
return {};
|
|
}
|
|
|
|
const attributes = (data as Record<string, unknown>).attributes;
|
|
return typeof attributes === "object" && attributes !== null ? attributes as Record<string, unknown> : {};
|
|
}
|
|
|
|
async function resolvePatreonIdentity(options: { accessToken: string }): Promise<ResolvedIdentity> {
|
|
const profile = await fetchPatreonIdentity(options.accessToken);
|
|
const data = profile.data;
|
|
if (typeof data !== "object" || data === null || typeof (data as Record<string, unknown>).id !== "string") {
|
|
throw new Error("Patreon identity response did not include a user id.");
|
|
}
|
|
|
|
const attributes = patreonUserAttributes(profile);
|
|
const displayName = typeof attributes.full_name === "string" ? attributes.full_name : undefined;
|
|
const vanity = typeof attributes.vanity === "string" ? attributes.vanity : undefined;
|
|
const email = typeof attributes.email === "string" ? attributes.email : undefined;
|
|
const imageUrl =
|
|
typeof attributes.image_url === "string"
|
|
? attributes.image_url
|
|
: typeof attributes.thumb_url === "string"
|
|
? attributes.thumb_url
|
|
: undefined;
|
|
|
|
const identity: ResolvedIdentity = {
|
|
provider: "patreon",
|
|
providerUserId: (data as Record<string, unknown>).id as string,
|
|
profile,
|
|
};
|
|
|
|
if (vanity) {
|
|
identity.username = vanity;
|
|
}
|
|
|
|
if (displayName) {
|
|
identity.displayName = displayName;
|
|
}
|
|
|
|
if (email) {
|
|
identity.primaryEmail = email;
|
|
}
|
|
|
|
if (imageUrl) {
|
|
identity.avatarUrl = imageUrl;
|
|
}
|
|
|
|
return identity;
|
|
}
|
|
|
|
async function resolveTwitchIdentity(options: {
|
|
config: HeimdallConfig;
|
|
accessToken: string;
|
|
}): Promise<ResolvedIdentity> {
|
|
const providerConfig = options.config.providers.twitch;
|
|
const payload = await fetchJson<{
|
|
data?: Array<{
|
|
id: string;
|
|
login?: string;
|
|
display_name?: string;
|
|
profile_image_url?: string;
|
|
email?: string;
|
|
}>;
|
|
}>(
|
|
"https://api.twitch.tv/helix/users",
|
|
{
|
|
headers: {
|
|
Authorization: `Bearer ${options.accessToken}`,
|
|
"Client-Id": providerConfig.clientId ?? "",
|
|
},
|
|
},
|
|
"Twitch identity lookup failed"
|
|
);
|
|
const user = payload.data?.[0];
|
|
|
|
if (!user?.id) {
|
|
throw new Error("Twitch identity response did not include a user id.");
|
|
}
|
|
|
|
const identity: ResolvedIdentity = {
|
|
provider: "twitch",
|
|
providerUserId: user.id,
|
|
profile: user as unknown as Record<string, unknown>,
|
|
};
|
|
|
|
if (user.login) {
|
|
identity.username = user.login;
|
|
}
|
|
|
|
if (user.display_name) {
|
|
identity.displayName = user.display_name;
|
|
}
|
|
|
|
if (user.email) {
|
|
identity.primaryEmail = user.email;
|
|
}
|
|
|
|
if (user.profile_image_url) {
|
|
identity.avatarUrl = user.profile_image_url;
|
|
}
|
|
|
|
return identity;
|
|
}
|
|
|
|
async function resolveYouTubeIdentity(options: { accessToken: string }): Promise<ResolvedIdentity> {
|
|
const channelUrl = new URL("https://www.googleapis.com/youtube/v3/channels");
|
|
channelUrl.searchParams.set("part", "snippet");
|
|
channelUrl.searchParams.set("mine", "true");
|
|
const channelResponse = await fetch(channelUrl, {
|
|
headers: {
|
|
Authorization: `Bearer ${options.accessToken}`,
|
|
},
|
|
});
|
|
|
|
if (channelResponse.ok) {
|
|
const payload = await readProviderJson<{
|
|
items?: Array<{
|
|
id: string;
|
|
snippet?: {
|
|
title?: string;
|
|
customUrl?: string;
|
|
thumbnails?: {
|
|
default?: { url?: string };
|
|
medium?: { url?: string };
|
|
high?: { url?: string };
|
|
};
|
|
};
|
|
}>;
|
|
}>(channelResponse, "YouTube channel lookup failed");
|
|
const channel = payload.items?.[0];
|
|
|
|
if (channel?.id) {
|
|
const identity: ResolvedIdentity = {
|
|
provider: "youtube",
|
|
providerUserId: channel.id,
|
|
username: channel.snippet?.customUrl ?? channel.snippet?.title ?? channel.id,
|
|
displayName: channel.snippet?.title ?? channel.id,
|
|
profile: channel as unknown as Record<string, unknown>,
|
|
};
|
|
const avatarUrl =
|
|
channel.snippet?.thumbnails?.high?.url ??
|
|
channel.snippet?.thumbnails?.medium?.url ??
|
|
channel.snippet?.thumbnails?.default?.url;
|
|
if (avatarUrl) {
|
|
identity.avatarUrl = avatarUrl;
|
|
}
|
|
return identity;
|
|
}
|
|
}
|
|
|
|
const userInfo = await fetchJson<{
|
|
sub?: string;
|
|
name?: string;
|
|
picture?: string;
|
|
email?: string;
|
|
}>(
|
|
"https://openidconnect.googleapis.com/v1/userinfo",
|
|
{
|
|
headers: {
|
|
Authorization: `Bearer ${options.accessToken}`,
|
|
},
|
|
},
|
|
"Google identity lookup failed"
|
|
);
|
|
|
|
if (!userInfo.sub) {
|
|
throw new Error("Google identity response did not include a subject.");
|
|
}
|
|
|
|
const identity: ResolvedIdentity = {
|
|
provider: "youtube",
|
|
providerUserId: userInfo.sub,
|
|
username: userInfo.email ?? userInfo.name ?? userInfo.sub,
|
|
displayName: userInfo.name ?? userInfo.email ?? userInfo.sub,
|
|
profile: userInfo as unknown as Record<string, unknown>,
|
|
};
|
|
|
|
if (userInfo.email) {
|
|
identity.primaryEmail = userInfo.email;
|
|
}
|
|
|
|
if (userInfo.picture) {
|
|
identity.avatarUrl = userInfo.picture;
|
|
}
|
|
|
|
return identity;
|
|
}
|
|
|
|
async function resolveSpotifyIdentity(options: { accessToken: string }): Promise<ResolvedIdentity> {
|
|
const profile = await fetchJson<{
|
|
id?: string;
|
|
display_name?: string;
|
|
email?: string;
|
|
images?: Array<{ url?: string }>;
|
|
uri?: string;
|
|
}>(
|
|
"https://api.spotify.com/v1/me",
|
|
{
|
|
headers: {
|
|
Authorization: `Bearer ${options.accessToken}`,
|
|
},
|
|
},
|
|
"Spotify identity lookup failed"
|
|
);
|
|
|
|
if (!profile.id) {
|
|
throw new Error("Spotify identity response did not include a user id.");
|
|
}
|
|
|
|
const identity: ResolvedIdentity = {
|
|
provider: "spotify",
|
|
providerUserId: profile.id,
|
|
username: profile.id,
|
|
displayName: profile.display_name ?? profile.id,
|
|
profile: profile as unknown as Record<string, unknown>,
|
|
};
|
|
|
|
if (profile.email) {
|
|
identity.primaryEmail = profile.email;
|
|
}
|
|
|
|
const avatarUrl = profile.images?.find((image) => image.url)?.url;
|
|
if (avatarUrl) {
|
|
identity.avatarUrl = avatarUrl;
|
|
}
|
|
|
|
return identity;
|
|
}
|
|
|
|
async function resolveJoystickIdentity(options: { accessToken: string }): Promise<ResolvedIdentity> {
|
|
const profile = await fetchJson<{
|
|
channel_id?: string;
|
|
username?: string;
|
|
nickname?: string;
|
|
photo_url?: string;
|
|
}>(
|
|
"https://api.joystick.tv/api/v1/me/identity",
|
|
{
|
|
headers: {
|
|
Authorization: `Bearer ${options.accessToken}`,
|
|
},
|
|
},
|
|
"Joystick identity lookup failed"
|
|
);
|
|
|
|
if (!profile.channel_id) {
|
|
throw new Error("Joystick identity response did not include a channel id.");
|
|
}
|
|
|
|
const identity: ResolvedIdentity = {
|
|
provider: "joystick",
|
|
providerUserId: profile.channel_id,
|
|
profile: profile as unknown as Record<string, unknown>,
|
|
};
|
|
const displayName = profile.nickname ?? profile.username;
|
|
if (profile.username) {
|
|
identity.username = profile.username;
|
|
}
|
|
if (displayName) {
|
|
identity.displayName = displayName;
|
|
}
|
|
if (profile.photo_url) {
|
|
identity.avatarUrl = profile.photo_url;
|
|
}
|
|
|
|
return identity;
|
|
}
|
|
|
|
async function evaluateDiscordEntitlements(options: {
|
|
config: HeimdallConfig;
|
|
callback: OAuthCallbackContext;
|
|
tokenSet: OAuthTokenSet;
|
|
}): Promise<EntitlementEvaluation> {
|
|
const policy = options.callback.entitlementPolicy;
|
|
if (!policy || policy.kind !== "discord_role_access") {
|
|
return { facts: [], snapshots: [] };
|
|
}
|
|
const appSlug = options.callback.appSlug;
|
|
const guildId = policy.guildId;
|
|
const allowedRoleIds = policy.allowedRoleIds;
|
|
|
|
const response = await fetch(
|
|
`https://discord.com/api/v10/users/@me/guilds/${guildId}/member`,
|
|
{
|
|
headers: {
|
|
Authorization: `Bearer ${options.tokenSet.accessToken}`,
|
|
},
|
|
}
|
|
);
|
|
|
|
if (response.status === 404) {
|
|
return {
|
|
facts: [],
|
|
snapshots: [
|
|
{
|
|
accountId: options.callback.accountId,
|
|
provider: "discord",
|
|
scope: `${appSlug}:discord_member:${guildId}`,
|
|
evaluatedAt: new Date().toISOString(),
|
|
isAllowed: false,
|
|
reasonCode: "not_in_guild",
|
|
reasonDetail: "Current user is not a member of the configured GameCult guild.",
|
|
rawSummaryJson: { guildId },
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
const member = await readProviderJson<{
|
|
nick?: string;
|
|
roles?: string[];
|
|
joined_at?: string;
|
|
}>(response, "Discord guild member lookup failed");
|
|
const roles = member.roles ?? [];
|
|
const matchedRoles = allowedRoleIds.filter((roleId) => roles.includes(roleId));
|
|
const facts: string[] = [];
|
|
|
|
if (matchedRoles.length > 0) {
|
|
facts.push(entitlementFacts.appAccess);
|
|
}
|
|
|
|
return {
|
|
facts,
|
|
snapshots: [
|
|
{
|
|
accountId: options.callback.accountId,
|
|
provider: "discord",
|
|
scope: `${appSlug}:discord_role_access:${guildId}`,
|
|
evaluatedAt: new Date().toISOString(),
|
|
isAllowed: matchedRoles.length > 0,
|
|
reasonCode: matchedRoles.length > 0 ? "matched_role" : "missing_role",
|
|
reasonDetail:
|
|
matchedRoles.length > 0
|
|
? `Matched a configured ${appSlug} access role.`
|
|
: allowedRoleIds.length
|
|
? `User is in the guild but lacks the configured ${appSlug} access role.`
|
|
: `${appSlug} did not supply any Discord role ids in its entitlement policy.`,
|
|
rawSummaryJson: {
|
|
guildId,
|
|
roles,
|
|
matchedRoles,
|
|
nick: member.nick,
|
|
joinedAt: member.joined_at,
|
|
},
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
function includedPatreonMembers(profile: Record<string, unknown>): Array<Record<string, unknown>> {
|
|
const included = profile.included;
|
|
if (!Array.isArray(included)) {
|
|
return [];
|
|
}
|
|
|
|
return included.filter(
|
|
(entry): entry is Record<string, unknown> =>
|
|
typeof entry === "object" &&
|
|
entry !== null &&
|
|
(entry as Record<string, unknown>).type === "member"
|
|
);
|
|
}
|
|
|
|
function includedPatreonTierById(profile: Record<string, unknown>): Map<string, Record<string, unknown>> {
|
|
const included = profile.included;
|
|
const tiers = new Map<string, Record<string, unknown>>();
|
|
if (!Array.isArray(included)) {
|
|
return tiers;
|
|
}
|
|
|
|
for (const entry of included) {
|
|
if (typeof entry !== "object" || entry === null) {
|
|
continue;
|
|
}
|
|
const record = entry as Record<string, unknown>;
|
|
if (record.type === "tier" && typeof record.id === "string") {
|
|
tiers.set(record.id, record);
|
|
}
|
|
}
|
|
|
|
return tiers;
|
|
}
|
|
|
|
function relatedPatreonTierIds(member: Record<string, unknown>): string[] {
|
|
const relationships = member.relationships;
|
|
if (typeof relationships !== "object" || relationships === null) {
|
|
return [];
|
|
}
|
|
|
|
const tiers = (relationships as Record<string, unknown>).currently_entitled_tiers;
|
|
if (typeof tiers !== "object" || tiers === null) {
|
|
return [];
|
|
}
|
|
|
|
const data = (tiers as Record<string, unknown>).data;
|
|
if (!Array.isArray(data)) {
|
|
return [];
|
|
}
|
|
|
|
return data
|
|
.map((entry) => {
|
|
if (typeof entry !== "object" || entry === null) {
|
|
return undefined;
|
|
}
|
|
const record = entry as Record<string, unknown>;
|
|
return record.type === "tier" && typeof record.id === "string" ? record.id : undefined;
|
|
})
|
|
.filter((id): id is string => id !== undefined);
|
|
}
|
|
|
|
function relatedPatreonCampaignId(member: Record<string, unknown>): string | undefined {
|
|
const relationships = member.relationships;
|
|
if (typeof relationships !== "object" || relationships === null) {
|
|
return undefined;
|
|
}
|
|
|
|
const campaign = (relationships as Record<string, unknown>).campaign;
|
|
if (typeof campaign !== "object" || campaign === null) {
|
|
return undefined;
|
|
}
|
|
|
|
const data = (campaign as Record<string, unknown>).data;
|
|
if (typeof data !== "object" || data === null) {
|
|
return undefined;
|
|
}
|
|
|
|
const record = data as Record<string, unknown>;
|
|
return record.type === "campaign" && typeof record.id === "string" ? record.id : undefined;
|
|
}
|
|
|
|
function includedPatreonCampaignById(profile: Record<string, unknown>): Map<string, Record<string, unknown>> {
|
|
const included = profile.included;
|
|
const campaigns = new Map<string, Record<string, unknown>>();
|
|
if (!Array.isArray(included)) {
|
|
return campaigns;
|
|
}
|
|
|
|
for (const entry of included) {
|
|
if (typeof entry !== "object" || entry === null) {
|
|
continue;
|
|
}
|
|
const record = entry as Record<string, unknown>;
|
|
if (record.type === "campaign" && typeof record.id === "string") {
|
|
campaigns.set(record.id, record);
|
|
}
|
|
}
|
|
|
|
return campaigns;
|
|
}
|
|
|
|
function patreonTierTitle(tier: Record<string, unknown>): string | undefined {
|
|
const attributes = tier.attributes;
|
|
if (typeof attributes !== "object" || attributes === null) {
|
|
return undefined;
|
|
}
|
|
|
|
const title = (attributes as Record<string, unknown>).title;
|
|
return typeof title === "string" ? title : undefined;
|
|
}
|
|
|
|
function patreonMemberAttributes(member: Record<string, unknown>): Record<string, unknown> {
|
|
const attributes = member.attributes;
|
|
return typeof attributes === "object" && attributes !== null ? attributes as Record<string, unknown> : {};
|
|
}
|
|
|
|
function patreonMemberIsActivePaid(attributes: Record<string, unknown>): boolean {
|
|
const patronStatus = attributes.patron_status;
|
|
const lastChargeStatus = attributes.last_charge_status;
|
|
return patronStatus === "active_patron" && (lastChargeStatus === undefined || lastChargeStatus === null || lastChargeStatus === "Paid");
|
|
}
|
|
|
|
export interface PatreonMembershipSummary {
|
|
memberId: string;
|
|
campaignId?: string;
|
|
campaignCurrency?: string;
|
|
tierTitles: string[];
|
|
patronStatus?: string;
|
|
lastChargeStatus?: string;
|
|
currentlyEntitledAmountCents: number;
|
|
lastChargeDate?: string;
|
|
pledgeRelationshipStart?: string;
|
|
isActivePaid: boolean;
|
|
}
|
|
|
|
export function summarizePatreonMemberships(profile: Record<string, unknown>): PatreonMembershipSummary[] {
|
|
const tiersById = includedPatreonTierById(profile);
|
|
const campaignsById = includedPatreonCampaignById(profile);
|
|
|
|
return includedPatreonMembers(profile)
|
|
.filter((member) => typeof member.id === "string")
|
|
.map((member) => {
|
|
const attributes = patreonMemberAttributes(member);
|
|
const campaignId = relatedPatreonCampaignId(member);
|
|
const campaign = campaignId ? campaignsById.get(campaignId) : undefined;
|
|
const campaignAttributes =
|
|
campaign && typeof campaign.attributes === "object" && campaign.attributes !== null
|
|
? campaign.attributes as Record<string, unknown>
|
|
: {};
|
|
const campaignCurrency =
|
|
typeof campaignAttributes.currency === "string"
|
|
? campaignAttributes.currency
|
|
: undefined;
|
|
const currentlyEntitledAmountCents =
|
|
typeof attributes.currently_entitled_amount_cents === "number" ? attributes.currently_entitled_amount_cents : 0;
|
|
const patronStatus = typeof attributes.patron_status === "string" ? attributes.patron_status : undefined;
|
|
const lastChargeStatus =
|
|
typeof attributes.last_charge_status === "string" ? attributes.last_charge_status : undefined;
|
|
const lastChargeDate = typeof attributes.last_charge_date === "string" ? attributes.last_charge_date : undefined;
|
|
const pledgeRelationshipStart =
|
|
typeof attributes.pledge_relationship_start === "string" ? attributes.pledge_relationship_start : undefined;
|
|
const tierTitles = relatedPatreonTierIds(member)
|
|
.map((tierId) => tiersById.get(tierId))
|
|
.filter((tier): tier is Record<string, unknown> => tier !== undefined)
|
|
.map((tier) => patreonTierTitle(tier))
|
|
.filter((title): title is string => title !== undefined);
|
|
|
|
return {
|
|
memberId: member.id as string,
|
|
...(campaignId !== undefined ? { campaignId } : {}),
|
|
...(campaignCurrency !== undefined ? { campaignCurrency } : {}),
|
|
tierTitles,
|
|
...(patronStatus !== undefined ? { patronStatus } : {}),
|
|
...(lastChargeStatus !== undefined ? { lastChargeStatus } : {}),
|
|
currentlyEntitledAmountCents,
|
|
...(lastChargeDate !== undefined ? { lastChargeDate } : {}),
|
|
...(pledgeRelationshipStart !== undefined ? { pledgeRelationshipStart } : {}),
|
|
isActivePaid: patreonMemberIsActivePaid(attributes),
|
|
};
|
|
});
|
|
}
|
|
|
|
async function evaluatePatreonEntitlements(options: {
|
|
callback: OAuthCallbackContext;
|
|
tokenSet: OAuthTokenSet;
|
|
}): Promise<EntitlementEvaluation> {
|
|
const policy = options.callback.entitlementPolicy;
|
|
if (!policy || policy.kind !== "patreon_membership_access") {
|
|
return { facts: [], snapshots: [] };
|
|
}
|
|
const appSlug = options.callback.appSlug;
|
|
|
|
const profile = await fetchPatreonIdentity(options.tokenSet.accessToken);
|
|
const members = summarizePatreonMemberships(profile);
|
|
const activeMembers = members.filter(
|
|
(member) => member.isActivePaid && member.tierTitles.includes(policy.requiredTierTitle)
|
|
);
|
|
const isAllowed = activeMembers.length > 0;
|
|
|
|
return {
|
|
facts: isAllowed ? [entitlementFacts.appAccess] : [],
|
|
snapshots: [
|
|
{
|
|
accountId: options.callback.accountId,
|
|
provider: "patreon",
|
|
scope: `${appSlug}:patreon_membership_access`,
|
|
evaluatedAt: new Date().toISOString(),
|
|
isAllowed,
|
|
reasonCode: isAllowed ? "active_membership" : members.length ? "inactive_membership" : "missing_membership",
|
|
reasonDetail: isAllowed
|
|
? `Matched an active Patreon membership for ${appSlug} access.`
|
|
: members.length
|
|
? "Patreon membership data was present but did not meet the active access policy."
|
|
: `Patreon did not return a membership matching the ${appSlug} access policy.`,
|
|
rawSummaryJson: {
|
|
requiredTierTitle: policy.requiredTierTitle,
|
|
memberCount: members.length,
|
|
entitledTierCount: new Set(members.flatMap((member) => member.tierTitles)).size,
|
|
matchingMemberCount: activeMembers.length,
|
|
},
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
const discordRuntime: OAuthProviderRuntime = {
|
|
exchangeAuthorizationCode: sharedRuntimeExchange("discord"),
|
|
refreshAccessToken: sharedRuntimeRefresh("discord"),
|
|
resolveIdentity: resolveDiscordIdentity,
|
|
evaluateEntitlements: evaluateDiscordEntitlements,
|
|
};
|
|
|
|
const patreonRuntime: OAuthProviderRuntime = {
|
|
exchangeAuthorizationCode: sharedRuntimeExchange("patreon"),
|
|
refreshAccessToken: sharedRuntimeRefresh("patreon"),
|
|
resolveIdentity: resolvePatreonIdentity,
|
|
evaluateEntitlements: evaluatePatreonEntitlements,
|
|
};
|
|
|
|
const twitchRuntime: OAuthProviderRuntime = {
|
|
exchangeAuthorizationCode: sharedRuntimeExchange("twitch"),
|
|
refreshAccessToken: sharedRuntimeRefresh("twitch"),
|
|
resolveIdentity: resolveTwitchIdentity,
|
|
async evaluateEntitlements() {
|
|
return { facts: [], snapshots: [] };
|
|
},
|
|
};
|
|
|
|
const youtubeRuntime: OAuthProviderRuntime = {
|
|
exchangeAuthorizationCode: sharedRuntimeExchange("youtube"),
|
|
refreshAccessToken: sharedRuntimeRefresh("youtube"),
|
|
resolveIdentity: resolveYouTubeIdentity,
|
|
async evaluateEntitlements() {
|
|
return { facts: [], snapshots: [] };
|
|
},
|
|
};
|
|
|
|
const spotifyRuntime: OAuthProviderRuntime = {
|
|
exchangeAuthorizationCode: sharedRuntimeExchange("spotify"),
|
|
refreshAccessToken: sharedRuntimeRefresh("spotify"),
|
|
resolveIdentity: resolveSpotifyIdentity,
|
|
async evaluateEntitlements() {
|
|
return { facts: [], snapshots: [] };
|
|
},
|
|
};
|
|
|
|
const joystickRuntime: OAuthProviderRuntime = {
|
|
exchangeAuthorizationCode: sharedRuntimeExchange("joystick"),
|
|
refreshAccessToken: sharedRuntimeRefresh("joystick"),
|
|
resolveIdentity: resolveJoystickIdentity,
|
|
async evaluateEntitlements() {
|
|
return { facts: [], snapshots: [] };
|
|
},
|
|
};
|
|
|
|
const notImplementedRuntime: OAuthProviderRuntime = {
|
|
async exchangeAuthorizationCode() {
|
|
throw new Error("Provider callback exchange is not implemented yet.");
|
|
},
|
|
async resolveIdentity() {
|
|
throw new Error("Provider identity resolution is not implemented yet.");
|
|
},
|
|
async evaluateEntitlements() {
|
|
return { facts: [], snapshots: [] };
|
|
},
|
|
};
|
|
|
|
export type OAuthRuntimeRegistry = Record<Provider, OAuthProviderRuntime>;
|
|
|
|
export function createOAuthRuntimeRegistry(): OAuthRuntimeRegistry {
|
|
return {
|
|
discord: discordRuntime,
|
|
patreon: patreonRuntime,
|
|
github: notImplementedRuntime,
|
|
twitch: twitchRuntime,
|
|
youtube: youtubeRuntime,
|
|
spotify: spotifyRuntime,
|
|
joystick: joystickRuntime,
|
|
};
|
|
}
|