mirror of
https://github.com/LukeHagar/better-auth.git
synced 2025-12-10 12:27:44 +00:00
feat: generic oauth plugin (#149)
This commit is contained in:
@@ -633,7 +633,29 @@ export const contents: Content[] = [
|
|||||||
</svg>
|
</svg>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "Generic OAuth",
|
||||||
|
href: "/docs/plugins/generic-oauth",
|
||||||
|
icon: () => (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="1em"
|
||||||
|
height="1em"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<g
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<path d="M2 12a10 10 0 1 0 20 0a10 10 0 1 0-20 0"></path>
|
||||||
|
<path d="M12.556 6c.65 0 1.235.373 1.508.947l2.839 7.848a1.646 1.646 0 0 1-1.01 2.108a1.673 1.673 0 0 1-2.068-.851L13.365 15h-2.73l-.398.905A1.67 1.67 0 0 1 8.26 16.95l-.153-.047a1.647 1.647 0 0 1-1.056-1.956l2.824-7.852a1.66 1.66 0 0 1 1.409-1.087z"></path>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "Authorization",
|
title: "Authorization",
|
||||||
group: true,
|
group: true,
|
||||||
|
|||||||
146
docs/content/docs/plugins/generic-oauth.mdx
Normal file
146
docs/content/docs/plugins/generic-oauth.mdx
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
---
|
||||||
|
title: Generic OAuth
|
||||||
|
description: Authenticate users with any OAuth provider
|
||||||
|
---
|
||||||
|
|
||||||
|
The Generic OAuth plugin provides a flexible way to integrate authentication with any OAuth provider. It supports both OAuth 2.0 and OpenID Connect (OIDC) flows, allowing you to easily add social login or custom OAuth authentication to your application.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
<Steps>
|
||||||
|
<Step>
|
||||||
|
### Add the plugin to your auth config
|
||||||
|
|
||||||
|
To use the Generic OAuth plugin, add it to your auth config.
|
||||||
|
|
||||||
|
```ts title="auth.ts"
|
||||||
|
import { betterAuth } from "better-auth"
|
||||||
|
import { genericOAuth } from "better-auth/plugins" // [!code highlight]
|
||||||
|
|
||||||
|
export const auth = betterAuth({
|
||||||
|
// ... other config options
|
||||||
|
plugins: [
|
||||||
|
genericOAuth({ // [!code highlight]
|
||||||
|
config: [ // [!code highlight]
|
||||||
|
{ // [!code highlight]
|
||||||
|
providerId: "provider-id", // [!code highlight]
|
||||||
|
clientId: "test-client-id", // [!code highlight]
|
||||||
|
clientSecret: "test-client-secret", // [!code highlight]
|
||||||
|
discoveryUrl: "https://auth.example.com/.well-known/openid-configuration", // [!code highlight]
|
||||||
|
// ... other config options // [!code highlight]
|
||||||
|
}, // [!code highlight]
|
||||||
|
// Add more providers as needed // [!code highlight]
|
||||||
|
] // [!code highlight]
|
||||||
|
}) // [!code highlight]
|
||||||
|
]
|
||||||
|
})
|
||||||
|
```
|
||||||
|
</Step>
|
||||||
|
|
||||||
|
<Step>
|
||||||
|
### Add the client plugin
|
||||||
|
|
||||||
|
Include the Generic OAuth client plugin in your authentication client instance.
|
||||||
|
|
||||||
|
```ts title="auth-client.ts"
|
||||||
|
import { createAuthClient } from "better-auth/client"
|
||||||
|
import { genericOAuthClient } from "better-auth/client/plugins"
|
||||||
|
|
||||||
|
const authClient = createAuthClient({
|
||||||
|
plugins: [
|
||||||
|
genericOAuthClient()
|
||||||
|
]
|
||||||
|
})
|
||||||
|
```
|
||||||
|
</Step>
|
||||||
|
</Steps>
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
The Generic OAuth plugin provides endpoints for initiating the OAuth flow and handling the callback. Here's how to use them:
|
||||||
|
|
||||||
|
### Initiate OAuth Sign-In
|
||||||
|
|
||||||
|
To start the OAuth sign-in process:
|
||||||
|
|
||||||
|
```ts title="sign-in.ts"
|
||||||
|
const response = await authClient.signIn.oauth2({
|
||||||
|
providerId: "provider-id",
|
||||||
|
callbackURL: "/dashboard" // the path to redirect to after the user is authenticated
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Handle OAuth Callback
|
||||||
|
|
||||||
|
The plugin mounts a route to handle the OAuth callback `/oauth2/callback/:providerId`. This means by default `${baseURL}/api/auth/oauth2/callback/:providerId` will be used as the callback URL. Make sure your OAuth provider is configured to use this URL.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
When adding the plugin to your auth config, you can configure multiple OAuth providers. Each provider configuration object supports the following options:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface GenericOAuthConfig {
|
||||||
|
providerId: string;
|
||||||
|
discoveryUrl?: string;
|
||||||
|
type?: "oauth2" | "oidc";
|
||||||
|
authorizationUrl?: string;
|
||||||
|
tokenUrl?: string;
|
||||||
|
userInfoUrl?: string;
|
||||||
|
clientId: string;
|
||||||
|
clientSecret: string;
|
||||||
|
scopes?: string[];
|
||||||
|
redirectURI?: string;
|
||||||
|
responseType?: string;
|
||||||
|
prompt?: string;
|
||||||
|
pkce?: boolean;
|
||||||
|
accessType?: string;
|
||||||
|
getUserInfo?: (tokens: OAuth2Tokens) => Promise<User | null>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `providerId`: A unique identifier for the OAuth provider.
|
||||||
|
- `discoveryUrl`: URL to fetch OAuth 2.0 configuration (optional, but recommended for OIDC providers).
|
||||||
|
- `type`: Type of OAuth flow ("oauth2" or "oidc", defaults to "oauth2").
|
||||||
|
- `authorizationUrl`: URL for the authorization endpoint (optional if using discoveryUrl).
|
||||||
|
- `tokenUrl`: URL for the token endpoint (optional if using discoveryUrl).
|
||||||
|
- `userInfoUrl`: URL for the user info endpoint (optional if using discoveryUrl).
|
||||||
|
- `clientId`: OAuth client ID.
|
||||||
|
- `clientSecret`: OAuth client secret.
|
||||||
|
- `scopes`: Array of OAuth scopes to request.
|
||||||
|
- `redirectURI`: Custom redirect URI (optional).
|
||||||
|
- `responseType`: OAuth response type (defaults to "code").
|
||||||
|
- `prompt`: Controls the authentication experience for the user.
|
||||||
|
- `pkce`: Whether to use PKCE (Proof Key for Code Exchange, defaults to false).
|
||||||
|
- `accessType`: Access type for the authorization request.
|
||||||
|
- `getUserInfo`: Custom function to fetch user info (optional).
|
||||||
|
|
||||||
|
## Advanced Usage
|
||||||
|
|
||||||
|
### Custom User Info Fetching
|
||||||
|
|
||||||
|
You can provide a custom `getUserInfo` function to handle specific provider requirements:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
genericOAuth({
|
||||||
|
config: [
|
||||||
|
{
|
||||||
|
providerId: "custom-provider",
|
||||||
|
// ... other config options
|
||||||
|
getUserInfo: async (tokens) => {
|
||||||
|
// Custom logic to fetch and return user info
|
||||||
|
const userInfo = await fetchUserInfoFromCustomProvider(tokens);
|
||||||
|
return {
|
||||||
|
id: userInfo.sub,
|
||||||
|
email: userInfo.email,
|
||||||
|
name: userInfo.name,
|
||||||
|
// ... map other fields as needed
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Error Handling
|
||||||
|
|
||||||
|
The plugin includes built-in error handling for common OAuth issues. Errors are typically redirected to your application's error page with an appropriate error message in the URL parameters. If the callback URL is not provided, the user will be redirected to Better Auth's default error page.
|
||||||
@@ -66,6 +66,7 @@
|
|||||||
"mongodb": "^6.9.0",
|
"mongodb": "^6.9.0",
|
||||||
"mysql2": "^3.11.0",
|
"mysql2": "^3.11.0",
|
||||||
"next": "^14.2.8",
|
"next": "^14.2.8",
|
||||||
|
"oauth2-mock-server": "^7.1.2",
|
||||||
"pg": "^8.12.0",
|
"pg": "^8.12.0",
|
||||||
"prisma": "^5.19.1",
|
"prisma": "^5.19.1",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ export type AuthEndpoint = Endpoint<
|
|||||||
options: BetterAuthOptions;
|
options: BetterAuthOptions;
|
||||||
body: any;
|
body: any;
|
||||||
query: any;
|
query: any;
|
||||||
|
params: any;
|
||||||
headers: Headers;
|
headers: Headers;
|
||||||
}) => Promise<EndpointResponse>
|
}) => Promise<EndpointResponse>
|
||||||
>;
|
>;
|
||||||
|
|||||||
9
packages/better-auth/src/plugins/generic-oauth/client.ts
Normal file
9
packages/better-auth/src/plugins/generic-oauth/client.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import type { genericOAuth } from ".";
|
||||||
|
import type { BetterAuthClientPlugin } from "../../types";
|
||||||
|
|
||||||
|
export const genericOAuthClient = () => {
|
||||||
|
return {
|
||||||
|
id: "generic-oauth-client",
|
||||||
|
$InferServerPlugin: {} as ReturnType<typeof genericOAuth>,
|
||||||
|
} satisfies BetterAuthClientPlugin;
|
||||||
|
};
|
||||||
459
packages/better-auth/src/plugins/generic-oauth/index.ts
Normal file
459
packages/better-auth/src/plugins/generic-oauth/index.ts
Normal file
@@ -0,0 +1,459 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { APIError } from "better-call";
|
||||||
|
import type { BetterAuthPlugin, User } from "../../types";
|
||||||
|
import { createAuthEndpoint } from "../../api";
|
||||||
|
import { betterFetch } from "@better-fetch/fetch";
|
||||||
|
import { generateState, parseState } from "../../utils/state";
|
||||||
|
import { generateCodeVerifier } from "oslo/oauth2";
|
||||||
|
import { logger } from "../../utils/logger";
|
||||||
|
import {
|
||||||
|
createAuthorizationURL,
|
||||||
|
validateAuthorizationCode,
|
||||||
|
} from "../../social-providers/utils";
|
||||||
|
import type { OAuth2Tokens } from "arctic";
|
||||||
|
import { parseJWT } from "oslo/jwt";
|
||||||
|
import { userSchema } from "../../db/schema";
|
||||||
|
import { generateId } from "../../utils/id";
|
||||||
|
import { getAccountTokens } from "../../utils/getAccount";
|
||||||
|
import { setSessionCookie } from "../../cookies";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration interface for generic OAuth providers.
|
||||||
|
*/
|
||||||
|
interface GenericOAuthConfig {
|
||||||
|
/** Unique identifier for the OAuth provider */
|
||||||
|
providerId: string;
|
||||||
|
/**
|
||||||
|
* URL to fetch OAuth 2.0 configuration.
|
||||||
|
* If provided, the authorization and token endpoints will be fetched from this URL.
|
||||||
|
*/
|
||||||
|
discoveryUrl?: string;
|
||||||
|
/**
|
||||||
|
* Type of OAuth flow.
|
||||||
|
* @default "oauth2"
|
||||||
|
*/
|
||||||
|
type?: "oauth2" | "oidc";
|
||||||
|
/**
|
||||||
|
* URL for the authorization endpoint.
|
||||||
|
* Optional if using discoveryUrl.
|
||||||
|
*/
|
||||||
|
authorizationUrl?: string;
|
||||||
|
/**
|
||||||
|
* URL for the token endpoint.
|
||||||
|
* Optional if using discoveryUrl.
|
||||||
|
*/
|
||||||
|
tokenUrl?: string;
|
||||||
|
/**
|
||||||
|
* URL for the user info endpoint.
|
||||||
|
* Optional if using discoveryUrl.
|
||||||
|
*/
|
||||||
|
userInfoUrl?: string;
|
||||||
|
/** OAuth client ID */
|
||||||
|
clientId: string;
|
||||||
|
/** OAuth client secret */
|
||||||
|
clientSecret: string;
|
||||||
|
/**
|
||||||
|
* Array of OAuth scopes to request.
|
||||||
|
* @default []
|
||||||
|
*/
|
||||||
|
scopes?: string[];
|
||||||
|
/**
|
||||||
|
* Custom redirect URI.
|
||||||
|
* If not provided, a default URI will be constructed.
|
||||||
|
*/
|
||||||
|
redirectURI?: string;
|
||||||
|
/**
|
||||||
|
* OAuth response type.
|
||||||
|
* @default "code"
|
||||||
|
*/
|
||||||
|
responseType?: string;
|
||||||
|
/**
|
||||||
|
* Prompt parameter for the authorization request.
|
||||||
|
* Controls the authentication experience for the user.
|
||||||
|
*/
|
||||||
|
prompt?: string;
|
||||||
|
/**
|
||||||
|
* Whether to use PKCE (Proof Key for Code Exchange)
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
pkce?: boolean;
|
||||||
|
/**
|
||||||
|
* Access type for the authorization request.
|
||||||
|
* Use "offline" to request a refresh token.
|
||||||
|
*/
|
||||||
|
accessType?: string;
|
||||||
|
/**
|
||||||
|
* Custom function to fetch user info.
|
||||||
|
* If provided, this function will be used instead of the default user info fetching logic.
|
||||||
|
* @param tokens - The OAuth tokens received after successful authentication
|
||||||
|
* @returns A promise that resolves to a User object or null
|
||||||
|
*/
|
||||||
|
getUserInfo?: (tokens: OAuth2Tokens) => Promise<User | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GenericOAuthOptions {
|
||||||
|
/**
|
||||||
|
* Array of OAuth provider configurations.
|
||||||
|
*/
|
||||||
|
config: GenericOAuthConfig[];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getUserInfo(
|
||||||
|
tokens: OAuth2Tokens,
|
||||||
|
type: "oauth2" | "oidc",
|
||||||
|
finalUserInfoUrl: string | undefined,
|
||||||
|
) {
|
||||||
|
if (type === "oidc") {
|
||||||
|
const idToken = tokens.idToken();
|
||||||
|
const decoded = parseJWT(idToken);
|
||||||
|
if (decoded?.payload) {
|
||||||
|
return decoded.payload;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!finalUserInfoUrl) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userInfo = await betterFetch<User>(finalUserInfoUrl, {
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${tokens.accessToken()}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return userInfo.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A generic OAuth plugin that can be used to add OAuth support to any provider
|
||||||
|
*/
|
||||||
|
export const genericOAuth = (options: GenericOAuthOptions) => {
|
||||||
|
return {
|
||||||
|
id: "generic-oauth",
|
||||||
|
endpoints: {
|
||||||
|
signInWithOAuth2: createAuthEndpoint(
|
||||||
|
"/sign-in/oauth2",
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
query: z
|
||||||
|
.object({
|
||||||
|
/**
|
||||||
|
* Redirect to the current URL after the
|
||||||
|
* user has signed in.
|
||||||
|
*/
|
||||||
|
currentURL: z.string().optional(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
body: z.object({
|
||||||
|
providerId: z.string(),
|
||||||
|
callbackURL: z.string().optional(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
async (ctx) => {
|
||||||
|
const { providerId } = ctx.body;
|
||||||
|
const config = options.config.find(
|
||||||
|
(c) => c.providerId === providerId,
|
||||||
|
);
|
||||||
|
if (!config) {
|
||||||
|
throw new APIError("BAD_REQUEST", {
|
||||||
|
message: `No config found for provider ${providerId}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const {
|
||||||
|
discoveryUrl,
|
||||||
|
authorizationUrl,
|
||||||
|
tokenUrl,
|
||||||
|
clientId,
|
||||||
|
clientSecret,
|
||||||
|
scopes,
|
||||||
|
redirectURI,
|
||||||
|
responseType,
|
||||||
|
pkce,
|
||||||
|
prompt,
|
||||||
|
accessType,
|
||||||
|
} = config;
|
||||||
|
let finalAuthUrl = authorizationUrl;
|
||||||
|
let finalTokenUrl = tokenUrl;
|
||||||
|
if (discoveryUrl) {
|
||||||
|
const discovery = await betterFetch<{
|
||||||
|
authorization_endpoint: string;
|
||||||
|
token_endpoint: string;
|
||||||
|
}>(discoveryUrl, {
|
||||||
|
onError(context) {
|
||||||
|
logger.error(context.error, {
|
||||||
|
discoveryUrl,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (discovery.data) {
|
||||||
|
finalAuthUrl = discovery.data.authorization_endpoint;
|
||||||
|
finalTokenUrl = discovery.data.token_endpoint;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!finalAuthUrl || !finalTokenUrl) {
|
||||||
|
throw new APIError("BAD_REQUEST", {
|
||||||
|
message: "Invalid OAuth configuration.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentURL = ctx.query?.currentURL
|
||||||
|
? new URL(ctx.query?.currentURL)
|
||||||
|
: null;
|
||||||
|
const callbackURL = ctx.body.callbackURL?.startsWith("http")
|
||||||
|
? ctx.body.callbackURL
|
||||||
|
: `${currentURL?.origin}${ctx.body.callbackURL || ""}`;
|
||||||
|
const state = generateState(
|
||||||
|
callbackURL || currentURL?.origin || ctx.context.baseURL,
|
||||||
|
ctx.query?.currentURL,
|
||||||
|
);
|
||||||
|
const cookie = ctx.context.authCookies;
|
||||||
|
await ctx.setSignedCookie(
|
||||||
|
cookie.state.name,
|
||||||
|
state.code,
|
||||||
|
ctx.context.secret,
|
||||||
|
cookie.state.options,
|
||||||
|
);
|
||||||
|
const codeVerifier = generateCodeVerifier();
|
||||||
|
await ctx.setSignedCookie(
|
||||||
|
cookie.pkCodeVerifier.name,
|
||||||
|
codeVerifier,
|
||||||
|
ctx.context.secret,
|
||||||
|
cookie.pkCodeVerifier.options,
|
||||||
|
);
|
||||||
|
const authUrl = createAuthorizationURL(
|
||||||
|
providerId,
|
||||||
|
{
|
||||||
|
clientId,
|
||||||
|
clientSecret,
|
||||||
|
redirectURI:
|
||||||
|
redirectURI ||
|
||||||
|
`${ctx.context.baseURL}/oauth2/callback/${providerId}`,
|
||||||
|
},
|
||||||
|
finalAuthUrl,
|
||||||
|
state.state,
|
||||||
|
codeVerifier,
|
||||||
|
scopes || [],
|
||||||
|
!pkce,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (responseType && responseType !== "code") {
|
||||||
|
authUrl.searchParams.set("response_type", responseType);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (prompt) {
|
||||||
|
authUrl.searchParams.set("prompt", prompt);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (accessType) {
|
||||||
|
authUrl.searchParams.set("access_type", accessType);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
url: authUrl.toString(),
|
||||||
|
state: state.state,
|
||||||
|
codeVerifier,
|
||||||
|
redirect: true,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
),
|
||||||
|
oAuth2Callback: createAuthEndpoint(
|
||||||
|
"/oauth2/callback/:providerId",
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
query: z.object({
|
||||||
|
code: z.string().optional(),
|
||||||
|
error: z.string().optional(),
|
||||||
|
state: z.string(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
async (ctx) => {
|
||||||
|
if (ctx.query.error || !ctx.query.code) {
|
||||||
|
const parsedState = parseState(ctx.query.state);
|
||||||
|
const callbackURL =
|
||||||
|
parsedState.data?.currentURL || `${ctx.context.baseURL}/error`;
|
||||||
|
ctx.context.logger.error(ctx.query.error, ctx.params.providerId);
|
||||||
|
throw ctx.redirect(
|
||||||
|
`${callbackURL}?error=${ctx.query.error || "oAuth_code_missing"}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const provider = options.config.find(
|
||||||
|
(p) => p.providerId === ctx.params.providerId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!provider) {
|
||||||
|
throw new APIError("BAD_REQUEST", {
|
||||||
|
message: `No config found for provider ${ctx.params.providerId}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const codeVerifier = await ctx.getSignedCookie(
|
||||||
|
ctx.context.authCookies.pkCodeVerifier.name,
|
||||||
|
ctx.context.secret,
|
||||||
|
);
|
||||||
|
|
||||||
|
let tokens: OAuth2Tokens | undefined = undefined;
|
||||||
|
const parsedState = parseState(ctx.query.state);
|
||||||
|
if (!parsedState.success) {
|
||||||
|
throw ctx.redirect(
|
||||||
|
`${ctx.context.baseURL}/error?error=invalid_state`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const {
|
||||||
|
data: { callbackURL, currentURL, dontRememberMe, code },
|
||||||
|
} = parsedState;
|
||||||
|
const errorURL =
|
||||||
|
parsedState.data?.currentURL || `${ctx.context.baseURL}/error`;
|
||||||
|
const storedCode = await ctx.getSignedCookie(
|
||||||
|
ctx.context.authCookies.state.name,
|
||||||
|
ctx.context.secret,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (storedCode !== code) {
|
||||||
|
logger.error("OAuth code mismatch", storedCode, code);
|
||||||
|
throw ctx.redirect(`${errorURL}?error=please_restart_the_process`);
|
||||||
|
}
|
||||||
|
let finalTokenUrl = provider.tokenUrl;
|
||||||
|
let finalUserInfoUrl = provider.userInfoUrl;
|
||||||
|
if (provider.discoveryUrl) {
|
||||||
|
const discovery = await betterFetch<{
|
||||||
|
token_endpoint: string;
|
||||||
|
userinfo_endpoint: string;
|
||||||
|
}>(provider.discoveryUrl, {
|
||||||
|
method: "GET",
|
||||||
|
});
|
||||||
|
if (discovery.data) {
|
||||||
|
finalTokenUrl = discovery.data.token_endpoint;
|
||||||
|
finalUserInfoUrl = discovery.data.userinfo_endpoint;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (!finalTokenUrl) {
|
||||||
|
throw new APIError("BAD_REQUEST", {
|
||||||
|
message: "Invalid OAuth configuration.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
tokens = await validateAuthorizationCode({
|
||||||
|
code,
|
||||||
|
codeVerifier,
|
||||||
|
redirectURI: `${ctx.context.baseURL}/oauth2/callback/${provider.providerId}`,
|
||||||
|
options: {
|
||||||
|
clientId: provider.clientId,
|
||||||
|
clientSecret: provider.clientSecret,
|
||||||
|
},
|
||||||
|
tokenEndpoint: finalTokenUrl,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
ctx.context.logger.error(e);
|
||||||
|
throw ctx.redirect(
|
||||||
|
`${errorURL}?error=oauth_code_verification_failed`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!tokens) {
|
||||||
|
throw new APIError("BAD_REQUEST", {
|
||||||
|
message: "Invalid OAuth configuration.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const userInfo = provider.getUserInfo
|
||||||
|
? await provider.getUserInfo(tokens)
|
||||||
|
: await getUserInfo(
|
||||||
|
tokens,
|
||||||
|
provider.type || "oauth2",
|
||||||
|
finalUserInfoUrl,
|
||||||
|
);
|
||||||
|
const id = generateId();
|
||||||
|
const user = userInfo
|
||||||
|
? userSchema.safeParse({
|
||||||
|
...userInfo,
|
||||||
|
id,
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
if (!user?.success) {
|
||||||
|
throw ctx.redirect(`${errorURL}?error=oauth_user_info_invalid`);
|
||||||
|
}
|
||||||
|
const dbUser = await ctx.context.internalAdapter
|
||||||
|
.findUserByEmail(user.data.email)
|
||||||
|
.catch((e) => {
|
||||||
|
logger.error(
|
||||||
|
"Better auth was unable to query your database.\nError: ",
|
||||||
|
e,
|
||||||
|
);
|
||||||
|
throw ctx.redirect(`${errorURL}?error=internal_server_error`);
|
||||||
|
});
|
||||||
|
|
||||||
|
const userId = dbUser?.user.id || id;
|
||||||
|
|
||||||
|
if (dbUser) {
|
||||||
|
//check if user has already linked this provider
|
||||||
|
const hasBeenLinked = dbUser.accounts.find(
|
||||||
|
(a) => a.providerId === provider.providerId,
|
||||||
|
);
|
||||||
|
const trustedProviders =
|
||||||
|
ctx.context.options.account?.accountLinking?.trustedProviders;
|
||||||
|
const isTrustedProvider = trustedProviders
|
||||||
|
? trustedProviders.includes(provider.providerId as "apple")
|
||||||
|
: true;
|
||||||
|
|
||||||
|
if (
|
||||||
|
!hasBeenLinked &&
|
||||||
|
(!user?.data.emailVerified || !isTrustedProvider)
|
||||||
|
) {
|
||||||
|
let url: URL;
|
||||||
|
try {
|
||||||
|
url = new URL(errorURL);
|
||||||
|
url.searchParams.set("error", "account_not_linked");
|
||||||
|
} catch (e) {
|
||||||
|
throw ctx.redirect(`${errorURL}?error=account_not_linked`);
|
||||||
|
}
|
||||||
|
throw ctx.redirect(url.toString());
|
||||||
|
}
|
||||||
|
if (!hasBeenLinked) {
|
||||||
|
try {
|
||||||
|
await ctx.context.internalAdapter.linkAccount({
|
||||||
|
providerId: provider.providerId,
|
||||||
|
accountId: user.data.id,
|
||||||
|
id: `${provider.providerId}:${user.data.id}`,
|
||||||
|
userId: dbUser.user.id,
|
||||||
|
...getAccountTokens(tokens),
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.log(e);
|
||||||
|
throw ctx.redirect(`${errorURL}?error=failed_linking_account`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
await ctx.context.internalAdapter.createOAuthUser(user.data, {
|
||||||
|
...getAccountTokens(tokens),
|
||||||
|
id: `${provider.providerId}:${user.data.id}`,
|
||||||
|
providerId: provider.providerId,
|
||||||
|
accountId: user.data.id,
|
||||||
|
userId: userId!,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
const url = new URL(errorURL);
|
||||||
|
url.searchParams.set("error", "unable_to_create_user");
|
||||||
|
ctx.setHeader("Location", url.toString());
|
||||||
|
throw ctx.redirect(url.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const session = await ctx.context.internalAdapter.createSession(
|
||||||
|
userId || id,
|
||||||
|
ctx.request,
|
||||||
|
parsedState.data?.dontRememberMe,
|
||||||
|
);
|
||||||
|
if (!session) {
|
||||||
|
throw ctx.redirect(`${errorURL}?error=unable_to_create_session`);
|
||||||
|
}
|
||||||
|
await setSessionCookie(ctx, session.id, dontRememberMe);
|
||||||
|
} catch {
|
||||||
|
throw ctx.redirect(`${errorURL}?error=unable_to_create_session`);
|
||||||
|
}
|
||||||
|
throw ctx.redirect(callbackURL || currentURL || "");
|
||||||
|
},
|
||||||
|
),
|
||||||
|
},
|
||||||
|
} satisfies BetterAuthPlugin;
|
||||||
|
};
|
||||||
159
packages/better-auth/src/plugins/generic-oauth/oauth2.test.ts
Normal file
159
packages/better-auth/src/plugins/generic-oauth/oauth2.test.ts
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||||
|
import { getTestInstance } from "../../test-utils/test-instance";
|
||||||
|
import { genericOAuth } from ".";
|
||||||
|
import { genericOAuthClient } from "./client";
|
||||||
|
import { createAuthClient } from "../../client";
|
||||||
|
|
||||||
|
import { OAuth2Server } from "oauth2-mock-server";
|
||||||
|
import { betterFetch } from "@better-fetch/fetch";
|
||||||
|
import { parseSetCookieHeader } from "../../cookies";
|
||||||
|
|
||||||
|
let server = new OAuth2Server();
|
||||||
|
|
||||||
|
describe("oauth2", async () => {
|
||||||
|
const providerId = "test";
|
||||||
|
const clientId = "test-client-id";
|
||||||
|
const clientSecret = "test-client-secret";
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await server.issuer.keys.generate("RS256");
|
||||||
|
|
||||||
|
server.issuer.on;
|
||||||
|
// Start the server
|
||||||
|
await server.start(8080, "localhost");
|
||||||
|
console.log("Issuer URL:", server.issuer.url); // -> http://localhost:8080
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await server.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
const { customFetchImpl } = await getTestInstance({
|
||||||
|
plugins: [
|
||||||
|
genericOAuth({
|
||||||
|
config: [
|
||||||
|
{
|
||||||
|
providerId,
|
||||||
|
discoveryUrl:
|
||||||
|
server.issuer.url ||
|
||||||
|
"http://localhost:8080/.well-known/openid-configuration",
|
||||||
|
clientId: clientId,
|
||||||
|
clientSecret: clientSecret,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const authClient = createAuthClient({
|
||||||
|
plugins: [genericOAuthClient()],
|
||||||
|
baseURL: "http://localhost:3000",
|
||||||
|
fetchOptions: {
|
||||||
|
customFetchImpl,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
server.service.once("beforeUserinfo", (userInfoResponse, req) => {
|
||||||
|
userInfoResponse.body = {
|
||||||
|
email: "oauth2@test.com",
|
||||||
|
name: "OAuth2 Test",
|
||||||
|
sub: "oauth2",
|
||||||
|
picture: "https://test.com/picture.png",
|
||||||
|
email_verified: true,
|
||||||
|
};
|
||||||
|
userInfoResponse.statusCode = 200;
|
||||||
|
});
|
||||||
|
|
||||||
|
async function simulateOAuthFlow(authUrl: string, headers: Headers) {
|
||||||
|
let location: string | null = null;
|
||||||
|
await betterFetch(authUrl, {
|
||||||
|
method: "GET",
|
||||||
|
redirect: "manual",
|
||||||
|
onError(context) {
|
||||||
|
location = context.response.headers.get("location");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!location) throw new Error("No redirect location found");
|
||||||
|
|
||||||
|
let callbackURL = "";
|
||||||
|
const callbackResponse = await betterFetch(location, {
|
||||||
|
method: "GET",
|
||||||
|
customFetchImpl,
|
||||||
|
headers,
|
||||||
|
onError(context) {
|
||||||
|
callbackURL = context.response.headers.get("location") || "";
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return callbackURL;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("should redirect to the provider and handle the response", async () => {
|
||||||
|
let headers = new Headers();
|
||||||
|
const res = await authClient.signIn.oauth2(
|
||||||
|
{
|
||||||
|
providerId: "test",
|
||||||
|
callbackURL: "http://localhost:3000/dashboard",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess(context) {
|
||||||
|
const parsedSetCookie = parseSetCookieHeader(
|
||||||
|
context.response.headers.get("Set-Cookie") || "",
|
||||||
|
);
|
||||||
|
headers.set(
|
||||||
|
"cookie",
|
||||||
|
`better-auth.state=${
|
||||||
|
parsedSetCookie.get("better-auth.state")?.value
|
||||||
|
}; better-auth.pk_code_verifier=${
|
||||||
|
parsedSetCookie.get("better-auth.pk_code_verifier")?.value
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const callbackURL = await simulateOAuthFlow(res.data?.url || "", headers);
|
||||||
|
expect(callbackURL).toBe("http://localhost:3000/dashboard");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle invalid provider ID", async () => {
|
||||||
|
const res = await authClient.signIn.oauth2({
|
||||||
|
providerId: "invalid-provider",
|
||||||
|
callbackURL: "http://localhost:3000/dashboard",
|
||||||
|
});
|
||||||
|
expect(res.error?.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle server error during OAuth flow", async () => {
|
||||||
|
server.service.once("beforeTokenResponse", (tokenResponse) => {
|
||||||
|
tokenResponse.statusCode = 500;
|
||||||
|
tokenResponse.body = { error: "internal_server_error" };
|
||||||
|
});
|
||||||
|
|
||||||
|
let headers = new Headers();
|
||||||
|
const res = await authClient.signIn.oauth2(
|
||||||
|
{
|
||||||
|
providerId: "test",
|
||||||
|
callbackURL: "http://localhost:3000/dashboard",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess(context) {
|
||||||
|
const parsedSetCookie = parseSetCookieHeader(
|
||||||
|
context.response.headers.get("Set-Cookie") || "",
|
||||||
|
);
|
||||||
|
headers.set(
|
||||||
|
"cookie",
|
||||||
|
`better-auth.state=${
|
||||||
|
parsedSetCookie.get("better-auth.state")?.value
|
||||||
|
}; better-auth.pk_code_verifier=${
|
||||||
|
parsedSetCookie.get("better-auth.pk_code_verifier")?.value
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const callbackURL = await simulateOAuthFlow(res.data?.url || "", headers);
|
||||||
|
expect(callbackURL).toContain("?error=");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -59,6 +59,7 @@ export function createAuthorizationURL(
|
|||||||
state: string,
|
state: string,
|
||||||
codeVerifier: string,
|
codeVerifier: string,
|
||||||
scopes: string[],
|
scopes: string[],
|
||||||
|
disablePkce?: boolean,
|
||||||
): URL {
|
): URL {
|
||||||
const url = new URL(authorizationEndpoint);
|
const url = new URL(authorizationEndpoint);
|
||||||
url.searchParams.set("response_type", "code");
|
url.searchParams.set("response_type", "code");
|
||||||
@@ -69,8 +70,10 @@ export function createAuthorizationURL(
|
|||||||
"redirect_uri",
|
"redirect_uri",
|
||||||
options.redirectURI || getRedirectURI(id),
|
options.redirectURI || getRedirectURI(id),
|
||||||
);
|
);
|
||||||
|
if (!disablePkce) {
|
||||||
const codeChallenge = generateCodeChallenge(codeVerifier);
|
const codeChallenge = generateCodeChallenge(codeVerifier);
|
||||||
url.searchParams.set("code_challenge_method", "S256");
|
url.searchParams.set("code_challenge_method", "S256");
|
||||||
url.searchParams.set("code_challenge", codeChallenge);
|
url.searchParams.set("code_challenge", codeChallenge);
|
||||||
|
}
|
||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
|
|||||||
33
pnpm-lock.yaml
generated
33
pnpm-lock.yaml
generated
@@ -1571,6 +1571,9 @@ importers:
|
|||||||
next:
|
next:
|
||||||
specifier: ^14.2.8
|
specifier: ^14.2.8
|
||||||
version: 14.2.13(@babel/core@7.25.2)(react-dom@19.0.0-rc-7771d3a7-20240827(react@18.3.1))(react@18.3.1)
|
version: 14.2.13(@babel/core@7.25.2)(react-dom@19.0.0-rc-7771d3a7-20240827(react@18.3.1))(react@18.3.1)
|
||||||
|
oauth2-mock-server:
|
||||||
|
specifier: ^7.1.2
|
||||||
|
version: 7.1.2
|
||||||
pg:
|
pg:
|
||||||
specifier: ^8.12.0
|
specifier: ^8.12.0
|
||||||
version: 8.13.0
|
version: 8.13.0
|
||||||
@@ -7403,6 +7406,10 @@ packages:
|
|||||||
core-util-is@1.0.3:
|
core-util-is@1.0.3:
|
||||||
resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==}
|
resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==}
|
||||||
|
|
||||||
|
cors@2.8.5:
|
||||||
|
resolution: {integrity: sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==}
|
||||||
|
engines: {node: '>= 0.10'}
|
||||||
|
|
||||||
cose-base@1.0.3:
|
cose-base@1.0.3:
|
||||||
resolution: {integrity: sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==}
|
resolution: {integrity: sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==}
|
||||||
|
|
||||||
@@ -9650,6 +9657,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==}
|
resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
is-plain-object@5.0.0:
|
||||||
|
resolution: {integrity: sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==}
|
||||||
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
is-potential-custom-element-name@1.0.1:
|
is-potential-custom-element-name@1.0.1:
|
||||||
resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==}
|
resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==}
|
||||||
|
|
||||||
@@ -11048,6 +11059,11 @@ packages:
|
|||||||
engines: {node: ^14.16.0 || >=16.10.0}
|
engines: {node: ^14.16.0 || >=16.10.0}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
oauth2-mock-server@7.1.2:
|
||||||
|
resolution: {integrity: sha512-xUg/YOTcMRe8W+q2jphecq1fB1BAjlAPbeeA9lvqwGaQSPJKxI2e8JUnDXHrrKGNJAVXQdHgE/9h4RpCtOfYOA==}
|
||||||
|
engines: {node: ^18.12 || ^20 || ^22, yarn: ^1.15.2}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
object-assign@4.1.1:
|
object-assign@4.1.1:
|
||||||
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
|
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@@ -22177,6 +22193,11 @@ snapshots:
|
|||||||
|
|
||||||
core-util-is@1.0.3: {}
|
core-util-is@1.0.3: {}
|
||||||
|
|
||||||
|
cors@2.8.5:
|
||||||
|
dependencies:
|
||||||
|
object-assign: 4.1.1
|
||||||
|
vary: 1.1.2
|
||||||
|
|
||||||
cose-base@1.0.3:
|
cose-base@1.0.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
layout-base: 1.0.2
|
layout-base: 1.0.2
|
||||||
@@ -25075,6 +25096,8 @@ snapshots:
|
|||||||
|
|
||||||
is-plain-obj@4.1.0: {}
|
is-plain-obj@4.1.0: {}
|
||||||
|
|
||||||
|
is-plain-object@5.0.0: {}
|
||||||
|
|
||||||
is-potential-custom-element-name@1.0.1: {}
|
is-potential-custom-element-name@1.0.1: {}
|
||||||
|
|
||||||
is-property@1.0.2: {}
|
is-property@1.0.2: {}
|
||||||
@@ -27147,6 +27170,16 @@ snapshots:
|
|||||||
pkg-types: 1.2.0
|
pkg-types: 1.2.0
|
||||||
ufo: 1.5.4
|
ufo: 1.5.4
|
||||||
|
|
||||||
|
oauth2-mock-server@7.1.2:
|
||||||
|
dependencies:
|
||||||
|
basic-auth: 2.0.1
|
||||||
|
cors: 2.8.5
|
||||||
|
express: 4.21.0
|
||||||
|
is-plain-object: 5.0.0
|
||||||
|
jose: 5.9.3
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- supports-color
|
||||||
|
|
||||||
object-assign@4.1.1: {}
|
object-assign@4.1.1: {}
|
||||||
|
|
||||||
object-hash@3.0.0: {}
|
object-hash@3.0.0: {}
|
||||||
|
|||||||
Reference in New Issue
Block a user