mirror of
https://github.com/LukeHagar/better-auth.git
synced 2025-12-07 20:37:44 +00:00
feat(paypal): add paypal OAuth2 provider (#4107)
This commit is contained in:
@@ -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=
|
||||||
|
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
107
docs/content/docs/authentication/paypal.mdx
Normal file
107
docs/content/docs/authentication/paypal.mdx
Normal 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>
|
||||||
@@ -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;
|
||||||
|
|||||||
259
packages/better-auth/src/social-providers/paypal.ts
Normal file
259
packages/better-auth/src/social-providers/paypal.ts
Normal 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>;
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user