feat(paypal): add paypal OAuth2 provider (#4107)

This commit is contained in:
Shobhit Patra
2025-08-22 00:20:03 +05:30
committed by GitHub
parent ed17525775
commit 96b5fabdfa
6 changed files with 394 additions and 0 deletions

View File

@@ -21,3 +21,6 @@ FACEBOOK_CLIENT_SECRET=
NODE_ENV= NODE_ENV=
STRIPE_KEY= STRIPE_KEY=
STRIPE_WEBHOOK_SECRET= STRIPE_WEBHOOK_SECRET=
PAYPAL_CLIENT_ID=
PAYPAL_CLIENT_SECRET=

View File

@@ -112,6 +112,10 @@ export const auth = betterAuth({
clientId: process.env.TWITTER_CLIENT_ID || "", clientId: process.env.TWITTER_CLIENT_ID || "",
clientSecret: process.env.TWITTER_CLIENT_SECRET || "", clientSecret: process.env.TWITTER_CLIENT_SECRET || "",
}, },
paypal: {
clientId: process.env.PAYPAL_CLIENT_ID || "",
clientSecret: process.env.PAYPAL_CLIENT_SECRET || "",
},
}, },
plugins: [ plugins: [
organization({ organization({

View File

@@ -568,6 +568,24 @@ export const contents: Content[] = [
</svg> </svg>
), ),
}, },
{
title: "PayPal",
href: "/docs/authentication/paypal",
isNew: true,
icon: () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="1.2em"
height="1.2em"
viewBox="0 0 24 24"
>
<path
fill="currentColor"
d="M7.076 21.337H2.47a.641.641 0 0 1-.633-.74L4.944.901C5.026.382 5.474 0 5.998 0h7.46c2.57 0 4.578.543 5.69 1.81c1.01 1.15 1.304 2.42 1.012 4.287c-.023.143-.047.288-.077.437c-.983 5.05-4.349 6.797-8.647 6.797h-2.19c-.524 0-.968.382-1.05.9l-1.12 7.106zm14.146-14.42a3.35 3.35 0 0 0-.404-.404c-.404-.404-.878-.606-1.417-.606h-2.190c-.524 0-.968.382-1.05.9l-1.295 8.206c-.082.518.235.995.753.995h2.19c2.57 0 4.578-.543 5.69-1.81c1.01-1.15 1.304-2.42 1.012-4.287c-.293-1.857-.991-2.994-3.289-2.994z"
/>
</svg>
),
},
{ {
title: "Slack", title: "Slack",
href: "/docs/authentication/slack", href: "/docs/authentication/slack",

View File

@@ -0,0 +1,107 @@
---
title: PayPal
description: Paypal provider setup and usage.
---
<Steps>
<Step>
### Get your PayPal Credentials
To integrate with PayPal, you need to obtain API credentials by creating an application in the [PayPal Developer Portal](https://developer.paypal.com/dashboard).
Follow these steps:
1. Create an account on the PayPal Developer Portal
2. Create a new application, [official docs]( https://developer.paypal.com/developer/applications/)
3. Configure Log in with PayPal under "Other features"
4. Set up your Return URL (redirect URL)
5. Configure user information permissions
6. Note your Client ID and Client Secret
<Callout type="info">
- PayPal has two environments: Sandbox (for testing) and Live (for production)
- For testing, create sandbox test accounts in the Developer Dashboard under "Sandbox" → "Accounts"
- You cannot use your real PayPal account to test in sandbox mode - you must use the generated test accounts
- The Return URL in your PayPal app settings must exactly match your redirect URI
- The PayPal API does not work with localhost. You need to use a public domain for the redirect URL and HTTPS for local testing. You can use [NGROK](https://ngrok.com/) or another similar tool for this.
</Callout>
Make sure to configure "Log in with PayPal" in your app settings:
1. Go to your app in the Developer Dashboard
2. Under "Other features", check "Log in with PayPal"
3. Click "Advanced Settings"
4. Enter your Return URL
5. Select the user information you want to access (email, name, etc.)
6. Enter Privacy Policy and User Agreement URLs
<Callout type="info">
- PayPal doesn't use traditional OAuth2 scopes in the authorization URL. Instead, you configure permissions directly in the Developer Dashboard
- For live apps, PayPal must review and approve your application before it can go live, which typically takes a few weeks
</Callout>
</Step>
<Step>
### Configure the provider
To configure the provider, you need to import the provider and pass it to the `socialProviders` option of the auth instance.
```ts title="auth.ts"
import { betterAuth } from "better-auth"
export const auth = betterAuth({
socialProviders: {
paypal: { // [!code highlight]
clientId: process.env.PAYPAL_CLIENT_ID as string, // [!code highlight]
clientSecret: process.env.PAYPAL_CLIENT_SECRET as string, // [!code highlight]
environment: "sandbox", // or "live" for production //, // [!code highlight]
}, // [!code highlight]
},
})
```
#### Options
The PayPal provider accepts the following options:
- `environment`: `'sandbox' | 'live'` - PayPal environment to use (default: `'sandbox'`)
- `requestShippingAddress`: `boolean` - Whether to request shipping address information (default: `false`)
```ts title="auth.ts"
export const auth = betterAuth({
socialProviders: {
paypal: {
clientId: process.env.PAYPAL_CLIENT_ID as string,
clientSecret: process.env.PAYPAL_CLIENT_SECRET as string,
environment: "live", // Use "live" for production
requestShippingAddress: true, // Request address info
},
},
})
```
</Step>
<Step>
### Sign In with PayPal
To sign in with PayPal, you can use the `signIn.social` function provided by the client. The `signIn` function takes an object with the following properties:
- `provider`: The provider to use. It should be set to `paypal`.
```ts title="auth-client.ts"
import { createAuthClient } from "better-auth/client"
const authClient = createAuthClient()
const signIn = async () => {
const data = await authClient.signIn.social({
provider: "paypal"
})
}
```
### Additional Options:
- `environment`: PayPal environment to use.
- Default: `"sandbox"`
- Options: `"sandbox"` | `"live"`
- `requestShippingAddress`: Whether to request shipping address information.
- Default: `false`
- `scope`: Additional scopes to request (combined with default permissions).
- Default: Configured in PayPal Developer Dashboard
- Note: PayPal doesn't use traditional OAuth2 scopes - permissions are set in the Dashboard
For more details refer to the [Scopes Reference](https://developer.paypal.com/docs/log-in-with-paypal/integrate/reference/#scope-attributes)
- `mapProfileToUser`: Custom function to map PayPal profile data to user object.
- `getUserInfo`: Custom function to retrieve user information.
For more details refer to the [User Reference](https://developer.paypal.com/docs/api/identity/v1/#userinfo_get)
- `verifyIdToken`: Custom ID token verification function.
</Step>
</Steps>

View File

@@ -22,6 +22,7 @@ import { reddit } from "./reddit";
import { roblox } from "./roblox"; import { roblox } from "./roblox";
import { vk } from "./vk"; import { vk } from "./vk";
import { zoom } from "./zoom"; import { zoom } from "./zoom";
import { paypal } from "./paypal";
export const socialProviders = { export const socialProviders = {
apple, apple,
discord, discord,
@@ -45,6 +46,7 @@ export const socialProviders = {
vk, vk,
zoom, zoom,
notion, notion,
paypal,
}; };
export const socialProviderList = Object.keys(socialProviders) as [ export const socialProviderList = Object.keys(socialProviders) as [
@@ -90,5 +92,6 @@ export * from "./zoom";
export * from "./kick"; export * from "./kick";
export * from "./huggingface"; export * from "./huggingface";
export * from "./slack"; export * from "./slack";
export * from "./paypal";
export type SocialProviderList = typeof socialProviderList; export type SocialProviderList = typeof socialProviderList;

View File

@@ -0,0 +1,259 @@
import { betterFetch } from "@better-fetch/fetch";
import { BetterAuthError } from "../error";
import type { OAuthProvider, ProviderOptions } from "../oauth2";
import { createAuthorizationURL } from "../oauth2";
import { logger } from "../utils/logger";
import { decodeJwt } from "jose";
export interface PayPalProfile {
user_id: string;
name: string;
given_name: string;
family_name: string;
middle_name?: string;
picture?: string;
email: string;
email_verified: boolean;
gender?: string;
birthdate?: string;
zoneinfo?: string;
locale?: string;
phone_number?: string;
address?: {
street_address?: string;
locality?: string;
region?: string;
postal_code?: string;
country?: string;
};
verified_account?: boolean;
account_type?: string;
age_range?: string;
payer_id?: string;
}
export interface PayPalTokenResponse {
scope?: string;
access_token: string;
refresh_token?: string;
token_type: "Bearer";
id_token?: string;
expires_in: number;
nonce?: string;
}
export interface PayPalOptions extends ProviderOptions<PayPalProfile> {
/**
* PayPal environment - 'sandbox' for testing, 'live' for production
* @default 'sandbox'
*/
environment?: "sandbox" | "live";
/**
* Whether to request shipping address information
* @default false
*/
requestShippingAddress?: boolean;
}
export const paypal = (options: PayPalOptions) => {
const environment = options.environment || "sandbox";
const isSandbox = environment === "sandbox";
const authorizationEndpoint = isSandbox
? "https://www.sandbox.paypal.com/signin/authorize"
: "https://www.paypal.com/signin/authorize";
const tokenEndpoint = isSandbox
? "https://api-m.sandbox.paypal.com/v1/oauth2/token"
: "https://api-m.paypal.com/v1/oauth2/token";
const userInfoEndpoint = isSandbox
? "https://api-m.sandbox.paypal.com/v1/identity/oauth2/userinfo"
: "https://api-m.paypal.com/v1/identity/oauth2/userinfo";
return {
id: "paypal",
name: "PayPal",
async createAuthorizationURL({ state, codeVerifier, redirectURI }) {
if (!options.clientId || !options.clientSecret) {
logger.error(
"Client Id and Client Secret is required for PayPal. Make sure to provide them in the options.",
);
throw new BetterAuthError("CLIENT_ID_AND_SECRET_REQUIRED");
}
/**
* Log in with PayPal doesn't use traditional OAuth2 scopes
* Instead, permissions are configured in the PayPal Developer Dashboard
* We don't pass any scopes to avoid "invalid scope" errors
**/
const _scopes: string[] = [];
const url = await createAuthorizationURL({
id: "paypal",
options,
authorizationEndpoint,
scopes: _scopes,
state,
codeVerifier,
redirectURI,
prompt: options.prompt,
});
return url;
},
validateAuthorizationCode: async ({ code, redirectURI }) => {
/**
* PayPal requires Basic Auth for token exchange
**/
const credentials = Buffer.from(
`${options.clientId}:${options.clientSecret}`,
).toString("base64");
try {
const response = await betterFetch(tokenEndpoint, {
method: "POST",
headers: {
Authorization: `Basic ${credentials}`,
Accept: "application/json",
"Accept-Language": "en_US",
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
grant_type: "authorization_code",
code: code,
redirect_uri: redirectURI,
}).toString(),
});
if (!response.data) {
throw new BetterAuthError("FAILED_TO_GET_ACCESS_TOKEN");
}
const data = response.data as PayPalTokenResponse;
const result = {
accessToken: data.access_token,
refreshToken: data.refresh_token,
accessTokenExpiresAt: data.expires_in
? new Date(Date.now() + data.expires_in * 1000)
: undefined,
idToken: data.id_token,
};
return result;
} catch (error) {
logger.error("PayPal token exchange failed:", error);
throw new BetterAuthError("FAILED_TO_GET_ACCESS_TOKEN");
}
},
refreshAccessToken: options.refreshAccessToken
? options.refreshAccessToken
: async (refreshToken) => {
const credentials = Buffer.from(
`${options.clientId}:${options.clientSecret}`,
).toString("base64");
try {
const response = await betterFetch(tokenEndpoint, {
method: "POST",
headers: {
Authorization: `Basic ${credentials}`,
Accept: "application/json",
"Accept-Language": "en_US",
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
grant_type: "refresh_token",
refresh_token: refreshToken,
}).toString(),
});
if (!response.data) {
throw new BetterAuthError("FAILED_TO_REFRESH_ACCESS_TOKEN");
}
const data = response.data as any;
return {
accessToken: data.access_token,
refreshToken: data.refresh_token,
accessTokenExpiresAt: data.expires_in
? new Date(Date.now() + data.expires_in * 1000)
: undefined,
};
} catch (error) {
logger.error("PayPal token refresh failed:", error);
throw new BetterAuthError("FAILED_TO_REFRESH_ACCESS_TOKEN");
}
},
async verifyIdToken(token, nonce) {
if (options.disableIdTokenSignIn) {
return false;
}
if (options.verifyIdToken) {
return options.verifyIdToken(token, nonce);
}
try {
const payload = decodeJwt(token);
return !!payload.sub;
} catch (error) {
logger.error("Failed to verify PayPal ID token:", error);
return false;
}
},
async getUserInfo(token) {
if (options.getUserInfo) {
return options.getUserInfo(token);
}
if (!token.accessToken) {
logger.error("Access token is required to fetch PayPal user info");
return null;
}
try {
const response = await betterFetch<PayPalProfile>(
`${userInfoEndpoint}?schema=paypalv1.1`,
{
headers: {
Authorization: `Bearer ${token.accessToken}`,
Accept: "application/json",
},
},
);
if (!response.data) {
logger.error("Failed to fetch user info from PayPal");
return null;
}
const userInfo = response.data;
const userMap = await options.mapProfileToUser?.(userInfo);
const result = {
user: {
id: userInfo.user_id,
name: userInfo.name,
email: userInfo.email,
image: userInfo.picture,
emailVerified: userInfo.email_verified,
...userMap,
},
data: userInfo,
};
return result;
} catch (error) {
logger.error("Failed to fetch user info from PayPal:", error);
return null;
}
},
options,
} satisfies OAuthProvider<PayPalProfile>;
};