Skip to content

Commit 41094c6

Browse files
author
Benjamin Piouffle
committed
feat(PayPal): CRON to check enabled APIs
1 parent 5e374c1 commit 41094c6

File tree

2 files changed

+245
-0
lines changed

2 files changed

+245
-0
lines changed
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
/**
2+
* Daily CRON job to verify that the PayPal API scopes configured on host ConnectedAccounts
3+
* are consistent with the PayPal features they have enabled (paypalPayouts, paypalDonations).
4+
*
5+
* If any mismatches are found, a report is posted to the engineering-alerts Slack channel.
6+
* See: https://github.com/opencollective/opencollective/issues/8381
7+
*/
8+
9+
import '../../server/env';
10+
11+
import config from 'config';
12+
13+
import FEATURE from '../../server/constants/feature';
14+
import { hasFeature } from '../../server/lib/allowed-features';
15+
import logger from '../../server/lib/logger';
16+
import { getHostsWithPayPalConnected } from '../../server/lib/paypal';
17+
import { reportErrorToSentry, HandlerType } from '../../server/lib/sentry';
18+
import slackLib, { OPEN_COLLECTIVE_SLACK_CHANNEL } from '../../server/lib/slack';
19+
import { checkPaypalScopes, retrieveGrantedScopes } from '../../server/paymentProviders/paypal/api';
20+
import { runCronJob } from '../utils';
21+
22+
const DRY_RUN = process.env.DRY_RUN === 'true';
23+
24+
interface ScopeIssue {
25+
hostSlug: string;
26+
feature: FEATURE;
27+
missingScopes: string[];
28+
}
29+
30+
const run = async () => {
31+
const hosts = await getHostsWithPayPalConnected();
32+
logger.info(`Checking PayPal API scopes for ${hosts.length} host(s)...`);
33+
34+
const allIssues: ScopeIssue[] = [];
35+
const errors: string[] = [];
36+
37+
for (const host of hosts) {
38+
try {
39+
const [connectedAccount] = await host.getConnectedAccounts({
40+
where: { service: 'paypal' },
41+
order: [['createdAt', 'DESC']],
42+
limit: 1,
43+
});
44+
45+
if (!connectedAccount?.clientId || !connectedAccount?.token) {
46+
continue;
47+
}
48+
49+
const grantedScopes = await retrieveGrantedScopes(connectedAccount.clientId, connectedAccount.token);
50+
51+
const enabledFeatures = [FEATURE.PAYPAL_PAYOUTS, FEATURE.PAYPAL_DONATIONS].filter(f => hasFeature(host, f));
52+
53+
const issues = checkPaypalScopes(grantedScopes, enabledFeatures);
54+
for (const issue of issues) {
55+
allIssues.push({ hostSlug: host.slug, ...issue });
56+
logger.warn(
57+
`[${host.slug}] PayPal feature "${issue.feature}" is enabled but missing scopes: ${issue.missingScopes.join(', ')}`,
58+
);
59+
}
60+
} catch (err) {
61+
const msg = `Failed to check PayPal scopes for host "${host.slug}": ${err.message}`;
62+
logger.error(msg);
63+
errors.push(msg);
64+
reportErrorToSentry(err, { handler: HandlerType.CRON, extra: { hostSlug: host.slug } });
65+
}
66+
}
67+
68+
if (allIssues.length === 0 && errors.length === 0) {
69+
logger.info('All PayPal host accounts have the required API scopes configured correctly.');
70+
return;
71+
}
72+
73+
const lines: string[] = ['*PayPal API Scope Check — Daily Report*'];
74+
75+
if (allIssues.length > 0) {
76+
lines.push('');
77+
lines.push(`*Scope Mismatches (${allIssues.length}):*`);
78+
for (const issue of allIssues) {
79+
lines.push(
80+
`• \`${issue.hostSlug}\` | Feature: \`${issue.feature}\` | Missing: ${issue.missingScopes.join(', ')}`,
81+
);
82+
}
83+
lines.push('');
84+
lines.push(
85+
'These hosts have PayPal features enabled that require additional API scopes. Please verify their PayPal application settings at https://developer.paypal.com/developer/applications.',
86+
);
87+
}
88+
89+
if (errors.length > 0) {
90+
lines.push('');
91+
lines.push(`*Errors during check (${errors.length}):*`);
92+
for (const err of errors) {
93+
lines.push(`• ${err}`);
94+
}
95+
}
96+
97+
const message = lines.join('\n');
98+
logger.info(message);
99+
100+
if (!DRY_RUN && config.slack?.webhooks?.engineeringAlerts) {
101+
try {
102+
await slackLib.postMessageToOpenCollectiveSlack(message, OPEN_COLLECTIVE_SLACK_CHANNEL.ENGINEERING_ALERTS);
103+
} catch (slackError) {
104+
logger.error('Failed to post PayPal scope check report to Slack', slackError);
105+
reportErrorToSentry(slackError, { handler: HandlerType.CRON });
106+
}
107+
}
108+
};
109+
110+
if (require.main === module) {
111+
runCronJob('check-paypal-enabled-apis', run, 24 * 60 * 60);
112+
}

server/paymentProviders/paypal/api.ts

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,20 @@
11
import config from 'config';
22
import fetch, { Response } from 'node-fetch';
33

4+
import FEATURE from '../../constants/feature';
45
import logger from '../../lib/logger';
56
import { getHostPaypalAccount } from '../../lib/paypal';
67
import { reportMessageToSentry } from '../../lib/sentry';
78
import { Collective } from '../../models';
89

10+
import { PaypalUserInfo } from './types';
11+
12+
/** PayPal scopes required by each platform feature */
13+
export const PAYPAL_SCOPE_REQUIREMENTS: Partial<Record<FEATURE, string[]>> = {
14+
[FEATURE.PAYPAL_PAYOUTS]: ['https://uri.paypal.com/services/payouts'],
15+
[FEATURE.PAYPAL_DONATIONS]: ['https://uri.paypal.com/services/payments/payment/authcapture'],
16+
};
17+
918
/** Build an URL for the PayPal API */
1019
export function paypalUrl(path: string, version = 'v1'): string {
1120
if (path.startsWith('/')) {
@@ -19,6 +28,13 @@ export function paypalUrl(path: string, version = 'v1'): string {
1928
return new URL(baseUrl + path).toString();
2029
}
2130

31+
/** Build the PayPal authorization URL for "Log in with PayPal" */
32+
export function paypalConnectAuthorizeUrl(): string {
33+
return config.paypal.payment.environment === 'sandbox'
34+
? 'https://www.sandbox.paypal.com/connect/'
35+
: 'https://www.paypal.com/connect/';
36+
}
37+
2238
/** Exchange clientid and secretid by an auth token with PayPal API */
2339
export async function retrieveOAuthToken({ clientId, clientSecret }): Promise<string> {
2440
const url = paypalUrl('oauth2/token');
@@ -33,6 +49,123 @@ export async function retrieveOAuthToken({ clientId, clientSecret }): Promise<st
3349
return jsonOutput.access_token;
3450
}
3551

52+
/**
53+
* Exchange an authorization code for a user access + refresh token using the platform PayPal Connect app.
54+
* Used in the "Log in with PayPal" flow.
55+
*/
56+
export async function exchangeAuthCodeForToken(code: string): Promise<{
57+
access_token: string;
58+
refresh_token: string;
59+
token_type: string;
60+
expires_in: number;
61+
scope: string;
62+
nonce: string;
63+
state: string;
64+
}> {
65+
const url = paypalUrl('oauth2/token');
66+
const authStr = `${config.paypal.connect.clientId}:${config.paypal.connect.clientSecret}`;
67+
const basicAuth = Buffer.from(authStr).toString('base64');
68+
const headers = { Authorization: `Basic ${basicAuth}`, 'Content-Type': 'application/x-www-form-urlencoded' };
69+
const body = new URLSearchParams();
70+
body.set('grant_type', 'authorization_code');
71+
body.set('code', code);
72+
body.set('redirect_uri', config.paypal.connect.redirectUri); // TODO: Should be auto-generated if missing
73+
74+
const response = await fetch(url, { method: 'post', body, headers });
75+
if (!response.ok) {
76+
const error = await response.json().catch(() => ({}));
77+
throw new Error(
78+
`PayPal token exchange failed (${response.status}): ${(error as any).error_description || response.statusText}`,
79+
);
80+
}
81+
82+
return response.json();
83+
}
84+
85+
/**
86+
* Refresh a user's PayPal access token using their stored refresh token.
87+
*/
88+
export async function refreshPaypalUserToken(
89+
refreshToken: string,
90+
): Promise<{ access_token: string; refresh_token: string; expires_in: number }> {
91+
const url = paypalUrl('oauth2/token');
92+
const authStr = `${config.paypal.connect.clientId}:${config.paypal.connect.clientSecret}`;
93+
const basicAuth = Buffer.from(authStr).toString('base64');
94+
const headers = { Authorization: `Basic ${basicAuth}`, 'Content-Type': 'application/x-www-form-urlencoded' };
95+
const body = new URLSearchParams();
96+
body.set('grant_type', 'refresh_token');
97+
body.set('refresh_token', refreshToken);
98+
99+
const response = await fetch(url, { method: 'post', body, headers });
100+
if (!response.ok) {
101+
const error = await response.json().catch(() => ({}));
102+
throw new Error(
103+
`PayPal token refresh failed (${response.status}): ${(error as any).error_description || response.statusText}`,
104+
);
105+
}
106+
return response.json();
107+
}
108+
109+
/**
110+
* Retrieve the authenticated user's PayPal identity information using their access token.
111+
* Requires the `openid`, `email`, and `https://uri.paypal.com/services/paypalattributes` scopes.
112+
*/
113+
export async function retrievePaypalUserInfo(accessToken: string): Promise<PaypalUserInfo> {
114+
const url = `${paypalUrl('identity/oauth2/userinfo')}?schema=paypalv1.1`;
115+
const headers = { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' };
116+
const response = await fetch(url, { method: 'get', headers });
117+
if (!response.ok) {
118+
const error = await response.json().catch(() => ({}));
119+
throw new Error(
120+
`PayPal userinfo request failed (${response.status}): ${(error as any).message || response.statusText}`,
121+
);
122+
}
123+
return response.json();
124+
}
125+
126+
/**
127+
* Retrieve the list of scopes granted to a host's PayPal application credentials.
128+
* Used to verify that required APIs are enabled on the PayPal account.
129+
*/
130+
export async function retrieveGrantedScopes(clientId: string, clientSecret: string): Promise<string[]> {
131+
const url = paypalUrl('oauth2/token');
132+
const body = 'grant_type=client_credentials';
133+
const authStr = `${clientId}:${clientSecret}`;
134+
const basicAuth = Buffer.from(authStr).toString('base64');
135+
const headers = { Authorization: `Basic ${basicAuth}`, 'Content-Type': 'application/x-www-form-urlencoded' };
136+
const response = await fetch(url, { method: 'post', body, headers });
137+
if (!response.ok) {
138+
const error = await response.json().catch(() => ({}));
139+
throw new Error(
140+
`PayPal credentials check failed (${response.status}): ${(error as any).error_description || response.statusText}`,
141+
);
142+
}
143+
const result = (await response.json()) as { scope?: string };
144+
return result.scope ? result.scope.split(' ') : [];
145+
}
146+
147+
/**
148+
* Check whether the granted scopes satisfy the requirements for the given set of enabled features.
149+
* Returns a list of missing scope descriptions for any unsatisfied features.
150+
*/
151+
export function checkPaypalScopes(
152+
grantedScopes: string[],
153+
enabledFeatures: FEATURE[],
154+
): { feature: FEATURE; missingScopes: string[] }[] {
155+
const issues: { feature: FEATURE; missingScopes: string[] }[] = [];
156+
for (const feature of enabledFeatures) {
157+
const required = PAYPAL_SCOPE_REQUIREMENTS[feature];
158+
if (!required) {
159+
continue;
160+
}
161+
const missing = required.filter(scope => !grantedScopes.includes(scope));
162+
if (missing.length > 0) {
163+
issues.push({ feature, missingScopes: missing });
164+
}
165+
}
166+
return issues;
167+
}
168+
36169
const parsePaypalError = async (
37170
response: Response,
38171
defaultMessage = 'PayPal request failed',

0 commit comments

Comments
 (0)