diff --git a/src/app.ts b/src/app.ts index 39c1f44..9d429fe 100644 --- a/src/app.ts +++ b/src/app.ts @@ -111,14 +111,18 @@ function parseOAuthStatePayload(payload: Record): OAuthStatePay const entitlementPolicyIsValid = entitlementPolicy === null || entitlementPolicy === undefined || - (typeof entitlementPolicy === "object" && + ((typeof entitlementPolicy === "object" && entitlementPolicy !== null && (entitlementPolicy as Record).kind === "discord_role_access" && typeof (entitlementPolicy as Record).guildId === "string" && Array.isArray((entitlementPolicy as Record).allowedRoleIds) && ((entitlementPolicy as Record).allowedRoleIds as unknown[]).every( (roleId) => typeof roleId === "string" - )); + )) || + (typeof entitlementPolicy === "object" && + entitlementPolicy !== null && + (entitlementPolicy as Record).kind === "patreon_membership_access" && + typeof (entitlementPolicy as Record).requiredTierTitle === "string")); if ( payload.typ !== "heimdall_oauth_state" || typeof payload.provider !== "string" || @@ -211,6 +215,17 @@ function normalizeEntitlementPolicy(value: OAuthStartRequest["entitlementPolicy" } } + if ( + value.kind === "patreon_membership_access" && + typeof value.requiredTierTitle === "string" && + value.requiredTierTitle.trim() + ) { + return { + kind: "patreon_membership_access", + requiredTierTitle: value.requiredTierTitle.trim(), + }; + } + throw new Error("Invalid entitlement policy definition."); } @@ -337,19 +352,32 @@ export async function buildApp(options: BuildAppOptions = {}): 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; + expires_in?: number; + scope?: string; + }>( + "https://www.patreon.com/api/oauth2/token", + { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body, + }, + "Patreon token exchange failed" + ); + + const tokenSet: OAuthTokenSet = { + accessToken: tokenResponse.access_token, + tokenType: tokenResponse.token_type, + scope: tokenResponse.scope?.split(/\s+/).filter(Boolean) ?? [], + 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<{ id: string; @@ -163,6 +216,78 @@ async function resolveDiscordIdentity(options: { accessToken: string }): Promise return identity; } +async function fetchPatreonIdentity(accessToken: string): Promise> { + 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]", "last_charge_status,patron_status"); + url.searchParams.set("fields[tier]", "title"); + url.searchParams.set("fields[campaign]", "creation_name,url"); + + return fetchJson>( + url, + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }, + "Patreon identity lookup failed" + ); +} + +function patreonUserAttributes(payload: Record): Record { + const data = payload.data; + if (typeof data !== "object" || data === null) { + return {}; + } + + const attributes = (data as Record).attributes; + return typeof attributes === "object" && attributes !== null ? attributes as Record : {}; +} + +async function resolvePatreonIdentity(options: { accessToken: string }): Promise { + const profile = await fetchPatreonIdentity(options.accessToken); + const data = profile.data; + if (typeof data !== "object" || data === null || typeof (data as Record).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).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 evaluateDiscordEntitlements(options: { config: HeimdallConfig; callback: OAuthCallbackContext; @@ -252,12 +377,148 @@ async function evaluateDiscordEntitlements(options: { }; } +function includedPatreonMembers(profile: Record): Array> { + const included = profile.included; + if (!Array.isArray(included)) { + return []; + } + + return included.filter( + (entry): entry is Record => + typeof entry === "object" && + entry !== null && + (entry as Record).type === "member" + ); +} + +function includedPatreonTierById(profile: Record): Map> { + const included = profile.included; + const tiers = new Map>(); + if (!Array.isArray(included)) { + return tiers; + } + + for (const entry of included) { + if (typeof entry !== "object" || entry === null) { + continue; + } + const record = entry as Record; + if (record.type === "tier" && typeof record.id === "string") { + tiers.set(record.id, record); + } + } + + return tiers; +} + +function relatedPatreonTierIds(member: Record): string[] { + const relationships = member.relationships; + if (typeof relationships !== "object" || relationships === null) { + return []; + } + + const tiers = (relationships as Record).currently_entitled_tiers; + if (typeof tiers !== "object" || tiers === null) { + return []; + } + + const data = (tiers as Record).data; + if (!Array.isArray(data)) { + return []; + } + + return data + .map((entry) => { + if (typeof entry !== "object" || entry === null) { + return undefined; + } + const record = entry as Record; + return record.type === "tier" && typeof record.id === "string" ? record.id : undefined; + }) + .filter((id): id is string => id !== undefined); +} + +async function evaluatePatreonEntitlements(options: { + callback: OAuthCallbackContext; + tokenSet: OAuthTokenSet; +}): Promise { + if (options.callback.appSlug !== "repixelizer") { + return { facts: [], snapshots: [] }; + } + + const policy = options.callback.entitlementPolicy; + if (!policy || policy.kind !== "patreon_membership_access") { + return { facts: [], snapshots: [] }; + } + + const profile = await fetchPatreonIdentity(options.tokenSet.accessToken); + const members = includedPatreonMembers(profile); + const tiersById = includedPatreonTierById(profile); + const activeMembers = members.filter((member) => { + const attributes = member.attributes; + if (typeof attributes !== "object" || attributes === null) { + return false; + } + + const patronStatus = (attributes as Record).patron_status; + const lastChargeStatus = (attributes as Record).last_charge_status; + const relatedTiers = relatedPatreonTierIds(member) + .map((tierId) => tiersById.get(tierId)) + .filter((tier): tier is Record => tier !== undefined); + const tierTitleMatches = relatedTiers.some((tier) => { + const tierAttributes = tier.attributes; + return ( + typeof tierAttributes === "object" && + tierAttributes !== null && + (tierAttributes as Record).title === policy.requiredTierTitle + ); + }); + return ( + tierTitleMatches && + patronStatus === "active_patron" && + (lastChargeStatus === undefined || lastChargeStatus === null || lastChargeStatus === "Paid") + ); + }); + const isAllowed = activeMembers.length > 0; + + return { + facts: isAllowed ? [entitlementFacts.appAccess] : [], + snapshots: [ + { + accountId: options.callback.accountId, + provider: "patreon", + scope: "repixelizer: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 Repixelizer access." + : members.length + ? "Patreon membership data was present but did not meet the active access policy." + : "Patreon did not return a membership matching the Repixelizer access policy.", + rawSummaryJson: { + requiredTierTitle: policy.requiredTierTitle, + memberCount: members.length, + entitledTierCount: tiersById.size, + matchingMemberCount: activeMembers.length, + }, + }, + ], + }; +} + const discordRuntime: OAuthProviderRuntime = { exchangeAuthorizationCode: exchangeDiscordAuthorizationCode, resolveIdentity: resolveDiscordIdentity, evaluateEntitlements: evaluateDiscordEntitlements, }; +const patreonRuntime: OAuthProviderRuntime = { + exchangeAuthorizationCode: exchangePatreonAuthorizationCode, + resolveIdentity: resolvePatreonIdentity, + evaluateEntitlements: evaluatePatreonEntitlements, +}; + const notImplementedRuntime: OAuthProviderRuntime = { async exchangeAuthorizationCode() { throw new Error("Provider callback exchange is not implemented yet."); @@ -275,7 +536,7 @@ export type OAuthRuntimeRegistry = Record; export function createOAuthRuntimeRegistry(): OAuthRuntimeRegistry { return { discord: discordRuntime, - patreon: notImplementedRuntime, + patreon: patreonRuntime, github: notImplementedRuntime, twitch: notImplementedRuntime, youtube: notImplementedRuntime, diff --git a/src/providers.ts b/src/providers.ts index ff240ad..f2f74a6 100644 --- a/src/providers.ts +++ b/src/providers.ts @@ -27,7 +27,7 @@ export const providerCatalog: Record = { key: "patreon", displayName: "Patreon", authorizationEndpoint: "https://www.patreon.com/oauth2/authorize", - defaultScopes: ["identity", "identity[email]", "campaigns.members"], + defaultScopes: ["identity", "identity[email]"], roles: ["identity", "entitlement"], clientIdEnv: "GC_ACCESS_PROVIDER_PATREON_CLIENT_ID", clientSecretEnv: "GC_ACCESS_PROVIDER_PATREON_CLIENT_SECRET", diff --git a/tests/app.test.ts b/tests/app.test.ts index 1fbbea2..52e1031 100644 --- a/tests/app.test.ts +++ b/tests/app.test.ts @@ -267,6 +267,52 @@ describe("Heimdall service", () => { } }); + it("builds Patreon OAuth start state with caller-owned membership policy", async () => { + const app = await buildApp({ config: createTestConfig() }); + apps.push(app); + + const response = await app.inject({ + method: "POST", + url: "/v1/oauth/patreon/start", + payload: { + appSlug: "repixelizer", + mode: "sign_in", + returnTo: "https://repixelizer.gamecult.org/app/", + handoff: { + kind: "backend_callback", + attemptId: "attempt-123", + callbackUrl: "https://repixelizer.gamecult.org/api/auth/heimdall/callback", + }, + entitlementPolicy: { + kind: "patreon_membership_access", + requiredTierTitle: "Inner Sanctum", + }, + }, + }); + + expect(response.statusCode).toBe(201); + const payload = response.json(); + const authorizationUrl = new URL(payload.authorizationUrl as string); + expect(`${authorizationUrl.origin}${authorizationUrl.pathname}`).toBe("https://www.patreon.com/oauth2/authorize"); + expect(authorizationUrl.searchParams.get("scope")).toBe("identity identity[email]"); + + const verified = verifyJwt(payload.stateToken as string, getPublicKey(app)); + expect(verified.valid).toBe(true); + if (verified.valid) { + expect(verified.payload).toEqual( + expect.objectContaining({ + typ: "heimdall_oauth_state", + provider: "patreon", + app_slug: "repixelizer", + entitlement_policy: { + kind: "patreon_membership_access", + requiredTierTitle: "Inner Sanctum", + }, + }) + ); + } + }); + it("completes a provider callback and issues a verified Repixelizer claim", async () => { const store = new InMemoryStore(); const app = await buildApp({