Generic OAuth
使用任何 OAuth 提供商对用户进行身份验证
Generic OAuth 插件可让你将任何 OAuth 2.0 或 OpenID Connect(OIDC)提供商添加到应用程序中。提供商会注册为一等社交提供商,并使用标准的 signIn.social 流程,默认启用 PKCE 和发行者验证。
何时使用此插件
在以下情况下使用 Generic OAuth 插件:
- 你的提供商不是内置社交提供商之一(Google、GitHub、Discord 等)
- 你需要连接到企业身份提供商(Keycloak、Okta、Auth0、Microsoft Entra ID)
- 你希望支持具有自定义或非标准 OAuth 端点的提供商
如果你的提供商已经内置,请改用身份验证配置中的 socialProviders;内置提供商支持移动端 id-token 登录,并具有针对特定提供商的优化。
自定义选项
OAuth 流程的每个方面都可以被覆盖:
| 需求 | 配置字段 |
|---|---|
| 提供商使用非标准令牌交换(GET、自定义参数) | getToken |
| 提供商返回非标准用户资料 | getUserInfo |
| 需要将资料字段映射到用户模型 | mapProfileToUser |
| 提供商需要额外的授权参数 | authorizationUrlParams |
| 提供商需要额外的令牌参数 | tokenUrlParams |
| 提供商需要自定义 HTTP 头 | discoveryHeaders、authorizationHeaders |
| 提供商不支持 OIDC 发现 | 显式设置 authorizationUrl、tokenUrl、userInfoUrl |
| 提供商使用非标准的不可变用户标识符 | 设置 accountSubject |
| 提供商拒绝 PKCE | 设置 pkce: false |
安装
将插件添加到身份验证配置中。
import { betterAuth } from "better-auth"
import { genericOAuth } from "better-auth/plugins"
export const auth = betterAuth({
// ... other config options
plugins: [
genericOAuth({
config: [
{
providerId: "provider-id",
clientId: "test-client-id",
clientSecret: "test-client-secret",
discoveryUrl: "https://auth.example.com/.well-known/openid-configuration",
// ... other config options
},
// Add more providers as needed
]
})
]
})使用方法
Generic OAuth 提供商通过标准的社交登录流程使用:
登录
await authClient.signIn.social({
provider: "provider-id",
callbackURL: "/dashboard",
})你也可以传入 additionalData(一个 Record<string, any>),在整个流程中往返传递客户端数据;在回调中通过 getOAuthState() 读取这些数据,并将其视为不受信任的数据。请参阅在 OAuth 流程中传递额外数据。
使用 ID Token 登录
配置了 discoveryUrl 且其发现文档发布了 jwks_uri 的提供商,也接受客户端直接获取的 id_token,无需执行重定向流程。该令牌会先根据提供商的 JWKS 进行验证,然后才会信任其中的任何声明:
await authClient.signIn.social({
provider: "provider-id",
idToken: {
token: idTokenFromProvider,
},
})使用显式端点而非 discoveryUrl 配置的提供商不支持此路径,并会返回 ID_TOKEN_NOT_SUPPORTED。
关联账户
await authClient.linkSocial({
provider: "provider-id",
callbackURL: "/settings",
})处理 OAuth 回调
该插件使用位于 /callback/:providerId 的核心 OAuth 回调路由。这意味着默认情况下会使用 ${baseURL}/api/auth/callback/:providerId 作为回调 URL。请确保你的 OAuth 提供商配置为使用此 URL。
与内置提供商不同,:providerId 参数是必需的,并且必须与您配置的提供商 ID 匹配。
验证 OAuth 用户信息
使用 user.validateUserInfo 在 Better Auth 创建用户(create-user)、关联新账户(link-account)或让现有用户重新登录(sign-in)之前拒绝 Generic OAuth 用户。在 sign-in 操作中,该回调会接收提供商最新的电子邮件和资料,因此可以通过域名或组织检查,拒绝其提供商身份后来超出范围的用户。检查 source.oauth?.providerId,将验证范围限定到特定的 Generic OAuth 提供商,并使用 source.oauth?.profile 获取提供商特定的资料字段。
import { betterAuth } from "better-auth";
import { genericOAuth } from "better-auth/plugins";
export const auth = betterAuth({
user: {
validateUserInfo: ({ user, source }) => {
if (source.oauth?.providerId !== "company-oauth") {
return;
}
if (!user.email?.endsWith("@example.com")) {
return {
error: "email_not_allowed",
errorDescription: "Use your example.com email to sign in",
};
}
},
},
plugins: [
genericOAuth({
config: [
{
providerId: "company-oauth",
clientId: process.env.COMPANY_OAUTH_CLIENT_ID!,
clientSecret: process.env.COMPANY_OAUTH_CLIENT_SECRET!,
discoveryUrl: "https://auth.example.com/.well-known/openid-configuration",
},
],
}),
],
});不返回任何内容即可允许 OAuth 流程继续。返回带有 error 的对象即可拒绝该流程。基于重定向的 OAuth 流程会将拒绝结果发送到配置的错误 URL,而程序化流程会返回 403 API 错误。
RP 发起的注销
对于公开了 end_session_endpoint 的 OIDC 提供商,authClient.signOut() 会清除 Better Auth 会话,然后将浏览器重定向到提供商的注销端点。使用 discoveryUrl 时,会从提供商的发现文档中读取该端点。你也可以通过 endSessionEndpoint 手动配置。
await authClient.signOut()传入 callbackURL,可以要求提供商在注销后将用户返回到你的应用。该 URL 必须在提供商处注册为注销后重定向 URI。
await authClient.signOut({
callbackURL: "/login",
})如果多个已关联的提供商支持 RP 发起的注销,Better Auth 会将用户重定向到最近更新账户所对应的提供商。浏览器导航一次只能完成一个提供商的注销。
如果要自行处理提供商导航,请设置 disableRedirect 并使用返回的 url。
const { data } = await authClient.signOut({
disableRedirect: true,
})
if (data?.url) {
window.location.assign(data.url)
}如果要使注销仅在 Better Auth 本地完成,请为该配置禁用提供商注销。
genericOAuth({
config: [{
providerId: "provider-id",
discoveryUrl: "https://auth.example.com/.well-known/openid-configuration",
clientId: "test-client-id",
clientSecret: "test-client-secret",
disableProviderLogout: true,
}]
})提供商辅助工具
有关内置和社区维护的提供商辅助工具,请参阅其他社交提供商。
配置
将插件添加到身份验证配置时,可以使用提供商辅助工具或自定义配置注册多个 OAuth 提供商。
手动配置
每个提供商配置对象支持以下选项:
import type {
OAuthAccountKeyContext,
OAuth2Tokens,
TokenEndpointAuth,
} from "better-auth/oauth2";
import type { GenericOAuthUserInfo } from "better-auth/plugins/generic-oauth";
interface GenericOAuthConfig {
providerId: string;
accountSubject?: (
context: OAuthAccountKeyContext<GenericOAuthUserInfo>,
) => string | number | Promise<string | number>;
discoveryUrl?: string;
requireIdTokenVerification?: boolean;
authorizationUrl?: string;
tokenUrl?: string;
userInfoUrl?: string;
endSessionEndpoint?: string;
postLogoutRedirectURI?: string;
disableProviderLogout?: boolean;
clientId: string;
clientSecret?: string;
tokenEndpointAuth?: TokenEndpointAuth;
scopes?: string[];
redirectURI?: string;
responseType?: string;
prompt?: string;
pkce?: boolean;
accessType?: string;
accessTokenExpiresIn?: number;
getUserInfo?: (tokens: OAuth2Tokens) => Promise<GenericOAuthUserInfo | null>;
}其他提供商配置说明
providerId:用于唯一标识 OAuth 提供商配置的字符串。
accountSubject:(可选)解析由提供商分配的不可变用户标识符。OpenID Connect 发现提供商默认使用经过验证的 sub 字段。普通 OAuth 提供商使用 id。Better Auth 不会在运行时在这两个字段之间切换。当提供商使用其他字段(例如 account_id)时,请设置此解析器。该解析器接收 OAuth 令牌和提供商原始资料,并可以返回字符串或数字。
mapProfileToUser 无法设置账户主题。这使本地资料映射与账户识别保持分离。
discoveryUrl:(可选)获取提供商 OAuth 2.0/OIDC 配置的 URL。如果提供了该值,则会在服务器启动时自动发现 authorizationUrl、tokenUrl 和 userInfoUrl 等端点。当发现文档发布了 jwks_uri 时,提供商返回的 id_token 会在其声明被使用前,根据该地址进行验证(包括签名、发行者、受众和公布的签名算法);验证失败的令牌会导致登录被拒绝。发现提供商还会使用服务器生成的 OIDC nonce 将 id_token 绑定到授权请求,并拒绝未回显该值的 id_token 回调。如果发现返回无效的验证元数据,或在应用显式端点和 getToken 后仍没有可用的授权端点或令牌交换方式,Better Auth 会跳过该提供商,同时保留其他身份验证功能。下次登录、回调或账户关联请求指定该提供商时会重试;发现成功后即可恢复使用。在此之前,登录和账户关联会返回 PROVIDER_NOT_FOUND,回调会重定向并带上 error=oauth_provider_not_found。发现暂时不可用时,显式端点可作为备用方案。
requireIdTokenVerification:(可选)要求发现提供可用的发行者和 jwks_uri,然后才注册提供商。当 accountSubject 或 getUserInfo 从 ID Token 声明中获取身份时,请启用此选项。如果配置的发现服务不可用或不完整,Better Auth 会跳过该提供商,而不会回退到未经验证的令牌解码。未配置 discoveryUrl 时,缺少验证元数据属于无效的静态配置,初始化会失败。microsoftEntraId 辅助工具会自动启用此选项。
disableIdTokenNonceBinding:(可选)关闭发现提供商 id_token 的 OIDC nonce 绑定。默认启用绑定,并会拒绝未回显服务器生成的 nonce 的 id_token(OIDC Core 1.0 §3.1.3.7)。只有不在授权码流程中返回 nonce 声明的提供商才应将其设置为 true;这会移除该提供商的 id_token 重放保护。
authorizationUrl:(可选)OAuth 提供商的授权端点。如果使用 discoveryUrl,则不必指定。
tokenUrl:(可选)OAuth 提供商的令牌端点。如果使用 discoveryUrl,则不必指定。
userInfoUrl:(可选)获取用户资料信息的端点。如果使用 discoveryUrl,则不必指定。
endSessionEndpoint:(可选)OIDC RP 发起的注销端点。如果使用 discoveryUrl 且提供商返回 end_session_endpoint,则不需要设置。
postLogoutRedirectURI:(可选)当 authClient.signOut() 将用户重定向到提供商时,作为 post_logout_redirect_uri 发送的默认 URI。该 URI 必须在提供商处注册。
disableProviderLogout:(可选)如果为 true,authClient.signOut() 只会清除 Better Auth 会话,不会重定向到提供商注销端点。
clientId:由你的提供商颁发的 OAuth 客户端 ID。
clientSecret:由你的提供商颁发的 OAuth 客户端密钥。
tokenEndpointAuth:(可选)令牌端点请求的客户端身份验证配置。对于 RFC 7523 客户端断言,请使用 { method: "private_key_jwt", getClientAssertion };对于基于密钥的客户端,请使用 { method: "client_secret_basic" } 或 { method: "client_secret_post" };对于公共客户端,请使用 { method: "none" }。基于密钥的方法要求提供 clientSecret;不要将 clientSecret 与 private_key_jwt 或 none 组合使用。如果省略该配置,Better Auth 会在配置了 clientSecret 时发送基于密钥的令牌请求,未配置时发送公共客户端令牌请求。
对于使用非标准令牌身份验证的提供商,请使用 method: "custom",在 Better Auth 设置标准授权参数后更新请求:
tokenEndpointAuth: {
method: "custom",
customizeRequest({ body }) {
body.set("client_key", providerClientKey);
body.set("client_secret", providerClientSecret);
},
},Better Auth 会构建标准授权参数,而 customizeRequest 会添加提供商所需的客户端身份验证。
scopes:(可选)要向提供商请求的作用域数组(例如 ["openid", "email", "profile"])。
redirectURI:(可选)OAuth 流程使用的重定向 URI。如果未设置,则会根据应用的基础 URL 构造默认值。必须包含 :providerId 占位符(例如 https://example.com/api/auth/callback/my-provider)。
responseType:(可选)OAuth 响应类型,授权码流程默认为 "code"。
responseMode:(可选)授权码请求的响应模式,如 "query" 或 "form_post"。
prompt:(可选)控制认证体验(例如强制登录、同意等)。
pkce:(可选)启用 PKCE(Proof Key for Code Exchange),这是 OAuth 2.1 的要求。默认为 true。只有明确拒绝 PKCE 的提供商才应禁用此选项。
accessType:(可选)授权请求中的访问类型。使用 "offline" 以请求刷新令牌。
accessTokenExpiresIn:(可选)备用访问令牌有效期(秒),仅在提供商的令牌响应省略 expires_in 时使用。如果没有已知的过期时间,getAccessToken 无法判断令牌已过期,也不会刷新它。请将其设置为令牌的有效期,以便跟踪过期并在需要时刷新令牌。如果提供商返回 expires_in,则留空。
getToken:(可选)一个自定义函数,用于将授权码兑换为令牌。如果提供,将使用该函数替代默认的令牌交换逻辑。适用于使用 GET 请求或自定义参数的非标准令牌端点提供商。
mapProfileToUser:(可选)函数,用于将提供商的用户资料映射到应用程序的用户对象。适用于自定义字段映射或转换。
mapProfileToUser:(可选)一个将提供商资料映射到应用程序用户可变字段的函数。它无法设置提供商身份;对于非标准的不可变标识符,请使用 accountSubject。
authorizationUrlParams:(可选)要添加到授权 URL 的其他查询参数。保留的 OAuth 密钥(state、client_id、redirect_uri、response_type、code_challenge、code_challenge_method、nonce、scope)会被忽略,因此无法替换 Better Auth 为流程管理的值;其他任何键都会覆盖默认值。
tokenUrlParams:(可选)要添加到令牌 URL 的其他查询参数。Better Auth 已设置的参数会被保留。请使用 clientId、clientSecret 和 tokenEndpointAuth 配置令牌端点客户端身份验证。
refreshTokenParams:(可选)刷新访问令牌时合并到令牌端点请求中的其他请求体参数。接受普通对象,或接受一个在刷新时运行的(同步或异步)函数,因此可以注入动态值,例如 scope、audience、resource 或租户标识符,而不必重新进行授权重定向。示例包括切换工作区时使用 Zitadel 的 urn:zitadel:iam:org:id:{orgId} 作用域,或轮换 Auth0 的 audience。函数形式会接收触发请求中的请求元数据(头和 Cookie),因此可以直接读取请求范围内的数据——调用者必须在将任何从头或 Cookie 派生的值用作作用域、受众或租户声明之前,根据经过身份验证的用户权限对其进行验证。无法覆盖 grant_type 和 refresh_token;合并后,client_id 由配置的令牌端点身份验证设置,因此这里同样无法覆盖。
genericOAuth({
config: [
{
providerId: "zitadel",
// ...
refreshTokenParams: (ctx) => {
const activeOrg = ctx?.headers?.get("x-active-org");
return activeOrg
? { scope: `openid profile email urn:zitadel:iam:org:id:${activeOrg}` }
: undefined;
},
},
],
});disableSignUp:(可选)为 true 时,完全禁用新用户注册。只能登录已有用户。
authentication:(可选)令牌请求的认证方法。可以是 'basic' 或 'post'。默认 'post'。
authentication:(可选)基于密钥的令牌请求身份验证。可以是 'basic' 或 'post'。默认为 'post'。'basic' 要求提供 clientSecret。对于超出客户端密钥范围的令牌端点方法,请配置 tokenEndpointAuth。
对于 private_key_jwt,将 tokenEndpointAuth.method 设置为 "private_key_jwt"。你可以提供自己的 getClientAssertion 函数,也可以使用 createPrivateKeyJwtClientAssertionGetter 从私钥签署 RFC 7523 JWT 断言:
import { createPrivateKeyJwtClientAssertionGetter } from "better-auth/oauth2";
genericOAuth({
config: [
{
providerId: "my-idp",
clientId: "your-client-id",
tokenUrl: "https://idp.example.com/oauth/token",
authorizationUrl: "https://idp.example.com/oauth/authorize",
tokenEndpointAuth: {
method: "private_key_jwt",
getClientAssertion: createPrivateKeyJwtClientAssertionGetter({
privateKeyJwk: { /* your JWK */ },
kid: "my-key-1",
algorithm: "RS256",
}),
},
pkce: true,
},
],
}),你也可以从另一个提供商返回断言:
genericOAuth({
config: [
{
providerId: "my-idp",
clientId: process.env.IDP_CLIENT_ID!,
discoveryUrl: "https://idp.example.com/.well-known/openid-configuration",
tokenEndpointAuth: {
method: "private_key_jwt",
getClientAssertion: async ({ clientId, tokenEndpoint, grantType }) => {
return getWorkloadIdentityToken({
clientId,
audience: tokenEndpoint,
grantType,
});
},
},
pkce: true,
},
],
});authorizationHeaders:(可选)授权请求中包含的自定义头。适用于需要特殊头的提供商。
overrideUserInfo:(可选)为 true 时,每次登录时用提供商信息更新数据库中的用户信息。默认 false。
安全性:发行者验证
高级用法
自定义令牌交换
对于使用 GET 请求或自定义参数的非标准令牌端点,你可以提供自定义 getToken 函数:
genericOAuth({
config: [
{
providerId: "custom-provider",
clientId: process.env.CUSTOM_CLIENT_ID!,
clientSecret: process.env.CUSTOM_CLIENT_SECRET,
authorizationUrl: "https://provider.example.com/oauth/authorize",
scopes: ["profile", "email"],
// 自定义令牌交换用于非标准端点
getToken: async ({ code, redirectURI }) => {
// 示例:使用 GET 请求替代 POST
const response = await fetch(
`https://provider.example.com/oauth/token?` +
`client_id=${process.env.CUSTOM_CLIENT_ID}&` +
`client_secret=${process.env.CUSTOM_CLIENT_SECRET}&` +
`code=${code}&` +
`redirect_uri=${redirectURI}&` +
`grant_type=authorization_code`,
{ method: "GET" }
);
const data = await response.json();
return {
accessToken: data.access_token,
refreshToken: data.refresh_token,
accessTokenExpiresAt: new Date(Date.now() + data.expires_in * 1000),
scopes: data.scope?.split(" ") ?? [],
// 保留提供商特定字段在 raw 中
raw: data,
};
},
getUserInfo: async (tokens) => {
// 访问 raw 令牌数据中的提供商特定字段
const userId = tokens.raw?.user_id as string;
const response = await fetch(
`https://provider.example.com/api/user?` +
`access_token=${tokens.accessToken}`
);
const data = await response.json();
return {
id: userId,
name: data.display_name,
email: data.email,
image: data.avatar_url,
emailVerified: data.email_verified,
};
},
},
],
});自定义获取用户信息
你可以提供自定义的 getUserInfo 函数以满足特定提供商需求:
genericOAuth({
config: [
{
providerId: "custom-provider",
// ... 其他配置选项
getUserInfo: async (tokens) => {
// 自定义逻辑获取并返回用户信息
const userInfo = await fetchUserInfoFromCustomProvider(tokens);
return {
id: userInfo.sub,
email: userInfo.email,
name: userInfo.name,
// ... 根据需要映射其他字段
};
}
}
]
})映射用户信息字段
如果提供商返回的用户信息格式不符合预期,或需要映射额外字段,可以使用 mapProfileToUser:
genericOAuth({
config: [
{
providerId: "custom-provider",
// ... 其他配置选项
mapProfileToUser: async (profile) => {
return {
firstName: profile.given_name,
// ... 根据需要映射其他字段
};
}
}
]
})访问原始令牌数据
tokens 参数包含一个 raw 字段,保存了提供商返回的原始令牌响应。此字段用于访问提供商特定字段:
getUserInfo: async (tokens) => {
// 访问提供商特定字段
const customField = tokens.raw?.custom_provider_field as string;
const userId = tokens.raw?.provider_user_id as string;
// 在你的逻辑中使用
return {
id: userId,
// ...
};
}IDP 发起的流程
一些 OAuth 提供商(例如 Clever)允许身份提供商将用户重定向到你的回调 URL,而无需用户先点击应用中的“登录”按钮。这些回调包含 code 参数但没有 state,通常会被拒绝,因为缺少 state 会破坏标准的 CSRF 检查。
在提供商上设置 allowIdpInitiated: true,即可安全地接受这些流程:
import { betterAuth } from "better-auth";
import { genericOAuth } from "better-auth/plugins";
export const auth = betterAuth({
plugins: [
genericOAuth({
config: [
{
providerId: "clever",
discoveryUrl: "https://clever.com/.well-known/openid-configuration",
clientId: process.env.CLEVER_CLIENT_ID,
clientSecret: process.env.CLEVER_CLIENT_SECRET,
allowIdpInitiated: true,
},
],
}),
],
});当无状态回调访问 /callback/:providerId 时,Better Auth 会丢弃提供商签发的代码,并在服务器端重新启动流程:生成新的 state 和 PKCE 验证器,然后将用户重定向到提供商的授权端点。提供商会识别用户当前的会话并自动完成流程。整个过程中都会保留 CSRF 保护和 PKCE。
对于始终从你这一侧发起流程的提供商,请保持 allowIdpInitiated 关闭(默认值)。针对这些提供商的无状态回调表示请求格式错误或具有恶意,应当被拒绝。
启用 allowIdpInitiated 时,请设置全局 baseURL 选项。IDP 发起的回调不包含发起请求的请求体,因此跳转会使用 baseURL 作为登录后的目标地址。如果未设置 baseURL,跳转会失败并返回 CALLBACK_URL_REQUIRED。
错误处理
插件内置了常见 OAuth 错误处理。错误通常会重定向到你的应用错误页面,并附带适当的错误信息 URL 参数。如果未提供错误回调 URL,用户将被重定向到 Better Auth 的默认错误页面。