mirror of
https://github.com/LukeHagar/better-auth.git
synced 2025-12-09 12:27:43 +00:00
feat(paypal): add paypal OAuth2 provider (#4107)
This commit is contained in:
@@ -21,3 +21,6 @@ FACEBOOK_CLIENT_SECRET=
|
||||
NODE_ENV=
|
||||
STRIPE_KEY=
|
||||
STRIPE_WEBHOOK_SECRET=
|
||||
PAYPAL_CLIENT_ID=
|
||||
PAYPAL_CLIENT_SECRET=
|
||||
|
||||
|
||||
@@ -112,6 +112,10 @@ export const auth = betterAuth({
|
||||
clientId: process.env.TWITTER_CLIENT_ID || "",
|
||||
clientSecret: process.env.TWITTER_CLIENT_SECRET || "",
|
||||
},
|
||||
paypal: {
|
||||
clientId: process.env.PAYPAL_CLIENT_ID || "",
|
||||
clientSecret: process.env.PAYPAL_CLIENT_SECRET || "",
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
organization({
|
||||
|
||||
@@ -568,6 +568,24 @@ export const contents: Content[] = [
|
||||
</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",
|
||||
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 { vk } from "./vk";
|
||||
import { zoom } from "./zoom";
|
||||
import { paypal } from "./paypal";
|
||||
export const socialProviders = {
|
||||
apple,
|
||||
discord,
|
||||
@@ -45,6 +46,7 @@ export const socialProviders = {
|
||||
vk,
|
||||
zoom,
|
||||
notion,
|
||||
paypal,
|
||||
};
|
||||
|
||||
export const socialProviderList = Object.keys(socialProviders) as [
|
||||
@@ -90,5 +92,6 @@ export * from "./zoom";
|
||||
export * from "./kick";
|
||||
export * from "./huggingface";
|
||||
export * from "./slack";
|
||||
export * from "./paypal";
|
||||
|
||||
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