OAuth 2.1 提供者
一个更好的认证插件,使您的认证服务器能够作为 OAuth 2.1 提供者运行。
一个OAuth 2.1 提供者插件,允许您将认证服务器转变为兼容 OIDC 的 OAuth 提供者,支持用户和其他服务通过您的 API 进行认证。
该插件默认具有安全配置,使不熟悉 OAuth 细节的用户也能轻松使用。
主要特性:
- OAuth 2.1: 限制安全实践遵循 OAuth 2.1
- Issuer Validation: 授权响应包含
iss参数,以防止 混淆攻击 - MCP Enabled: 支持 MCP 认证
- OIDC compatibility: 与 OIDC 兼容,支持
openidscope- UserInfo: 提供当前用户详情的端点
- id_token: JWT 签名的用户信息
- OIDC Logout: 符合 RP-initiated 的注销
- Dynamic Client Registration: 允许客户端动态注册客户端。
- Public Clients: 支持原生移动客户端和用户代理客户端(如 AI)使用公有客户端
- Confidential Clients: 支持 Web 客户端使用机密客户端
- Trusted Clients: 配置硬编码的受信任客户端,并可选绕过同意。
- JWT Plugin compatibility: 默认需要,可选择禁用
- JWT Signing: 在请求
resource时签名 JWT 令牌 - JWKS Verifiable: 在
/jwks端点远程验证令牌
- JWT Signing: 在请求
- Authorization Prompts: 触发特定登录流程的提示
- Consent: 确保每个 scope 都获得同意。可通过
prompt=consent强制。 - Select Account: 在授予特定 scope 之前确保已选择账户。可通过
prompt=select_account强制。
- Consent: 确保每个 scope 都获得同意。可通过
- Resource Endpoints: 读取和管理令牌。
支持的授权方式
- authorization_code: 使用 PKCE 和 S256 要求进行用户令牌交换的授权码。
- refresh_token: 通过
offline_accessscope 发放刷新令牌并处理访问令牌续期。 - client_credentials: 用于 API 通信的机器对机器令牌。
安装
挂载插件
将 OIDC 插件添加到您的认证配置中。详情参见 配置章节,了解如何配置插件。
import { betterAuth } from "better-auth";
import { jwt } from "better-auth/plugins";
import { oauthProvider } from "@better-auth/oauth-provider";
const auth = betterAuth({
disabledPaths: [
"/token",
],
plugins: [
jwt(),
oauthProvider({
loginPage: "/sign-in",
consentPage: "/consent",
// ...其他选项
})
],
});添加 ./well-known 端点
请添加所有 Well-Known 端点 到您的项目。如果不确定,系统会通过警告提示位置。
- 您必须在您的发行者路径上添加 OAuth 授权服务器元数据端点(如果没有路径,则为根路径)。
- 如果您使用
openidscope,您必须在您的发行者路径上添加 openid 配置(如果没有路径,则为根路径)。 - 如果您使用资源服务器(例如用于 MCP),您必须将资源服务器元数据添加到您的 API 中,并附加发行者路径。
创建您的第一个 OAuth 客户端
创建一个机密 OAuth 客户端。
const client = await auth.api.createOAuthClient({
headers,
body: {
redirect_uris: [redirectUri],
}
});
console.log(client); // 如果您愿意,可以将 `client_id` 添加到 `cachedTrustedClients`若要创建公有客户端(即没有 client secret),请设置 token_endpoint_auth_method: "none"。
客户端插件
存在两种客户端,您可根据需求添加一种或两种。
OAuth 客户端
OAuth 客户端是连接的 oauthClient,如移动或 Web 应用。
import { createAuthClient } from "better-auth/client";
import { oauthProviderClient } from "@better-auth/oauth-provider/client"
export const authClient = createAuthClient({
plugins: [
oauthProviderClient(),
],
});资源客户端
资源服务器是运行在您的 API 服务器上的客户端,用于执行令牌验证和提供元数据等操作。
import { auth } from "@/lib/auth";
import { createAuthClient } from "better-auth/client";
import { oauthProviderResourceClient } from "@better-auth/oauth-provider/resource-client"
export const serverClient = createAuthClient({
plugins: [
oauthProviderResourceClient(auth) // auth 可选
],
});用法
该插件作为 OAuth 2.1 服务器运行,具有 OIDC 兼容端点和 JWT 可验证的访问令牌。以下为各端点的详细说明。
OAuth 客户端
OAuth 中有两类客户端:
- Public Clients:无法存储 client secret,例如原生移动客户端和用户代理客户端(如 AI)
- Confidential Clients:可以存储 client secret,例如 Web 客户端
获取客户端信息
获取特定用户或组织所有的客户端信息,使用以下端点:
const { data, error } = await authClient.oauth2.getClient({ query: { client_id, // required },});client_idstring,requiredOAuth 客户端的 client_id
获取公有客户端信息
获取公有客户端字段以在登录流程页面显示(如同意页面),使用以下端点。注意:需用户登录后方可使用。
const { data, error } = await authClient.oauth2.publicClient({ query: { client_id, // required },});client_idstring,requiredOAuth 客户端的 client_id
获取公有客户端预登录信息
若要在登录前获取公有客户端信息,您必须先在配置中启用该端点:
oauthProvider({
allowPublicClientPrelogin: true,
})然后,以下端点将获取公有客户端信息。
const { data, error } = await authClient.oauth2.publicClientPrelogin({ client_id, // required oauth_query, // required});client_idstring,requiredOAuth 客户端的 client_id
oauth_querystringrequired有效的 oauth 查询参数(使用提供的客户端时会自动发送)
列出客户端
获取特定用户或组织所拥有的所有客户端列表,使用以下端点:
const { data, error } = await authClient.oauth2.getClients();创建客户端
创建与指定用户或组织关联的 OAuth 客户端,使用 /oauth2/create-client 端点(如 createOAuthClient)。参数与 RFC7591 描述的注册端点相同。
数据库中的以下字段被视为受限字段,仅应由管理员用户编辑。
client_secret_expires_at:机密客户端密钥的过期时间skip_consent:允许跳过用户同意流程。适用于受信任客户端。enable_end_session:允许用户通过客户端在/oauth2/end-session端点使用其id_token退出会话。用于 OIDC 配置和指定的受信任客户端。metadata:附加到客户端的额外私有元数据。
部分场景下,您可能希望通过自定义 API、公司管理员门户或服务器初始化逻辑创建带有受限字段的客户端,可使用以下仅限服务器端的端点:
import { auth } from "@/lib/auth"
await auth.api.adminCreateOAuthClient({
headers,
body: {
redirect_uris: [redirectUri],
client_secret_expires_at: 0, // 机密客户端密钥过期时间
skip_consent: true, // 允许跳过用户同意流程
enable_end_session: true, // 允许 RP 发起注销
}
});更新客户端
更新与指定用户或组织关联的 OAuth 客户端,使用 /oauth2/update-client 端点(如 updateOAuthClient)。参数与 RFC7591 描述的注册端点相同。
const { data, error } = await authClient.oauth2.updateClient({ client_id, // required update, // required});client_idstring,requiredOAuth 客户端的 client_id
updateOAuthClient,required要更新的字段
此端点限制如下:
- 您无法在机密客户端和公有客户端之间切换。客户端类型必须在创建时确定。
- 您不能更新客户端密钥。要轮换
client_secret,请使用轮换客户端密钥端点。
部分场景下,您可能希望通过自定义 API、公司管理员门户或服务器初始化逻辑更新带有受限字段的客户端,可使用以下仅限服务器端端点。字段同创建部分描述。
import { auth } from "@/lib/auth"
await auth.api.adminUpdateOAuthClient({
headers,
body: {
redirect_uris: [redirectUri],
client_secret_expires_at: 0, // 机密客户端密钥过期时间
skip_consent: true, // 允许跳过用户同意流程
enable_end_session: true, // 允许 RP 发起注销
}
});旋转客户端密钥
当前实现会立即轮换客户端密钥,并且会立即使旧密钥失效。
旋转客户端密钥,请使用以下端点:
const { data, error } = await authClient.oauth2.client.rotateSecret({ client_id, // required});client_idstring,requiredOAuth 客户端的 client_id
删除客户端
删除用户或组织的客户端,请使用以下端点:
const { data, error } = await authClient.oauth2.deleteClient({ client_id, // required});client_idstring,requiredOAuth 客户端的 client_id
OAuth Consent
对于所有非受信任客户端(尤其是无 skip_consent 的),都需要用户同意。以下端点允许用户或 reference_id 管理其已授权的同意。
获取同意详情
获取特定同意详情,使用以下端点:
const { data, error } = await authClient.oauth2.getConsent({ query: { id, // required },});idstring,required同意项 id
列出同意
获取用户的所有同意列表,使用以下端点:
const { data, error } = await authClient.oauth2.getConsents();更新同意
更新特定同意条目,使用以下端点:
const { data, error } = await authClient.oauth2.updateConsent({ id, // required update, // required});idstring,required同意项 id
updateOAuthConsent,required要更新的值
删除同意
撤销用户对某个客户端的同意。
const { data, error } = await authClient.oauth2.deleteConsent({ id, // required});idstring,required同意项 id
动态注册端点
此端点支持符合 RFC7591 的客户端注册。
安装后,您可以使用 OAuth 提供者管理应用内的认证流程。
创建客户端后,将获得 client_id 和 client_secret,可显示给用户。client_secret 只提供一次,务必提示用户保存。
配置
启用客户端注册请在 BetterAuth 配置中设置 allowDynamicClientRegistration: true。
oauthProvider({
allowDynamicClientRegistration: true,
// ... 其他选项
})若要启用无需认证的客户端注册(允许动态注册公有客户端),额外设置 allowUnauthenticatedClientRegistration: true。
当 MCP 协议标准化无需认证的动态客户端注册时,allowUnauthenticatedClientRegistration 的支持将被弃用。截至撰写时,Client ID Metadata Documents 和 software_statement and jwks_uri 均仍在讨论中。
oauthProvider({
allowDynamicClientRegistration: true,
allowUnauthenticatedClientRegistration: true,
// ... 其他选项
})基本示例
使用 oauth2.register 方法注册新的 OIDC 客户端。
import { authClient } from "@/lib/auth-client"
const client = await authClient.oauth2.register({
client_name: "我的客户端",
redirect_uris: ["https://client.example.com/callback"],
});所有参数详情请参见 RFC 7591 注册部分。
注意以下参数目前尚不支持:
jwksjwks_uri
授权端点
一个符合 OAuth 2.1 授权端点规范 的端点。由于某些细节尚未完全规范,部分内容参照旧版 OAuth 2.0 授权端点 但始终实现了 OAuth 2.1 与 OAuth 2.0 的差异。
授权端点为启动 OAuth 2.1 授权流程的入口。
重要说明:
- 在 OAuth 2.1 中,仅支持
response_type: "code"。 - 不支持
code_challenge_method: "plain",因为这是一项安全漏洞。 - 所有授权响应(成功和错误)都包含
iss参数用于发行者验证(RFC 9207)。
状态(State)
Clients should send a state value to mitigate cross-site request forgery (CSRF) attacks. This works by ensuring your client only responds to requests that your client initially requested.
客户端生成状态值,并存储在安全、HTTP-only Cookie 或数据库等处。
The authorization server accepts requests without state for compatibility with OAuth and OpenID Connect, and echoes state back when it is provided. Better Auth's client helpers generate and validate state for you.
Code Challenge
代码挑战用于保护授权端点返回的授权码。
其通过从代码验证器派生代码挑战并通过 PKCE(Proof Key for Code Exchange) 发送至授权服务器。
在 redirect_uri 回调时,客户端比对返回状态与初始状态是否匹配,然后使用 authorization_code 授权方式和原始代码验证器在令牌端点交换令牌。
Token Endpoint
默认情况下,令牌端点支持为以下授权方式提供令牌:
- "authorization_code"
- "client_credentials"
- "refresh_token"
授权码授权方式
授权码授权方式允许客户端获取用户访问令牌,并可选择获取刷新令牌(通过 "offline_access" scope)。
客户端凭证授权方式
该方式允许客户端获取机器间访问令牌。
刷新令牌授权方式
该方式允许客户端刷新访问令牌,无需用户再次登录。
当前实现为每次刷新请求发放新的刷新令牌。
Consent Endpoint
接受或拒绝用户对某组权限的同意。注意拒绝某些权限时,会取消本次授权的同意,之前已有的其他同意依然有效。要移除同意,请删除该用户对应客户端的 "oauthConsent"。
const { data, error } = await authClient.oauth2.consent({ accept, // required scope,});acceptboolean,required接受或拒绝用户对一组 scope 的同意
scopestring,以空格分隔的已接受 scope 列表。若未提供,则接受最初请求的 scope。
继续端点
注册页面必须先 配置 以进行注册步骤。 账户选择页面必须先 配置 用于账户选择。 登录后页面必须先 配置 用于登录后操作。
const { data, error } = await authClient.oauth2.continue({ selected, created, postLogin,});selectedboolean,确认已选择账户。
createdboolean,确认已注册账户
postLoginboolean,确认登录后活动已完成
Introspect Endpoint
符合 RFC7662 规范的令牌核查端点。
此端点提供所给令牌的详细信息。如令牌绑定于会话,确保该会话为 active。
通过 customAccessTokenClaims 可向机密客户端的 resources 字段存储允许其访问的资源,以提供资源特定声明。
Revoke Endpoint
符合 RFC7009 规范的撤销端点。
此端点撤销所给令牌。
- opaque
access_token:立即将该access_token从数据库中移除。refresh_token仍然有效。 - JWT
access_token:验证该令牌是否可安全从客户端存储中移除。 refresh_token:移除使用该refresh_token签发的所有access_tokens,并移除该refresh_token以防止进一步发放令牌。
对于 access_token 类型:
会话结束端点
符合 RP-发起注销规范。
此端点允许指定受信任客户端远程执行注销操作。
允许 RP 发起注销,需专门创建开启注销功能的受信任客户端:
import { auth } from "@/lib/auth"
await auth.api.adminCreateOAuthClient({
headers,
body: {
redirect_uris: [redirectUri],
enable_end_session: true,
}
});如果 disableJwtPlugin: true,公有客户端将永远无法使用此端点注销,因为不会发送 id_token。
UserInfo 端点
UserInfo 端点提供符合 OIDC 标准的用户信息。端点为 /oauth2/userinfo,需带有效访问令牌且至少带有 openid 权限。
// 客户端调用 UserInfo 端点示例
const response = await fetch('https://your-domain.com/api/auth/oauth2/userinfo', {
headers: {
'Authorization': 'Bearer ACCESS_TOKEN'
}
});
const userInfo = await response.json();
// userInfo 根据授予的权限返回用户信息UserInfo 端点根据授权时授予的范围返回不同的声明:
openid: 返回用户 ID(sub声明)profile: 返回name、picture、given_name、family_nameemail: 返回email和email_verified
customUserInfoClaims 函数接收用户对象、请求的 scopes 数组以及传入的访问令牌,允许添加额外响应信息。
Well known
Openid 配置
提供 OpenID Connect 发现元数据,位于 /.well-known/openid-configuration。
要求带有 openid 范围。
必须将该配置添加至发行者路径。如未设置发行者,则为基础路径 /api/auth。
若发行者路径非根路径,且根路径未提供 openid-configuration,我们推荐在根添加以防客户端硬编码访问 /.well-known/openid-configuration(忽视了规范中的发行者路径)。
注意:对于带路径的发行者,OpenID 使用路径拼接,应在发行者路径后附加 /.well-known/openid-configuration。无发行者路径则应从根开始。
import { oauthProviderOpenIdConfigMetadata } from "@better-auth/oauth-provider";
import { auth } from "@/lib/auth";
export const GET = oauthProviderOpenIdConfigMetadata(auth);如果您在本地测试时遇到 CORS 问题,例如使用 MCP Inspector 时,这是因为前端在调用端点而不是后端。测试时请添加 Access-Control-Allow-Methods": "GET" 和 "Access-Control-Allow-Origin": "*"。
OAuth Authorization Server
提供 RFC8414 兼容元数据,位于 /.well-known/oauth-authorization-server。
必须将该配置添加至发行者路径。未设置发行者,则为基础路径 /api/auth。
注意:带路径的发行者,OAuth 2.1 授权服务器采用路径插入,故在发行者路径后追加 /.well-known/oauth-authorization-server。无发行者路径则从根开始添加。
import { oauthProviderAuthServerMetadata } from "@better-auth/oauth-provider";
import { auth } from "@/lib/auth";
export const GET = oauthProviderAuthServerMetadata(auth);如果您在本地测试时遇到 CORS 问题,例如使用 MCP Inspector 时,这是因为前端在调用端点而不是后端。测试时请添加 Access-Control-Allow-Methods": "GET" 和 "Access-Control-Allow-Origin": "*"。
API Server
本节展示如何让您的 API 验证来自客户端的令牌。
验证
可通过 oauthProviderResourceClient 插件或 better-auth/oauth2 包里的 verifyAccessToken 进行验证。
使用 better-auth 包:
import { verifyAccessToken } from "better-auth/oauth2";
export const GET = async (req: Request) => {
const authorization = req.headers?.get("authorization") ?? undefined;
const accessToken = authorization?.startsWith("Bearer ")
? authorization.replace("Bearer ", "")
: authorization;
const payload = await verifyAccessToken(
accessToken, {
verifyOptions: {
issuer: "https://auth.example.com",
audience: "https://api.example.com",
},
scopes: ["read:post"], // 可选
}
);
// ...后续操作
}使用 oauthProviderResourceClient 插件:
import { serverClient } from "@/lib/server-client";
export const POST = async (req: Request) => {
const authorization = req.headers?.get("authorization") ?? undefined;
const accessToken = authorization?.startsWith("Bearer ")
? authorization.replace("Bearer ", "")
: authorization;
const payload = await serverClient.verifyAccessToken(
accessToken, {
verifyOptions: {
issuer: "https://auth.example.com",
audience: "https://api.example.com",
},
scopes: ["write:post"], // 可选
}
);
// ...后续操作
}JWT 验证
- 验证令牌是否有效:
- 使用 JWKS 验证 签名。
- 检查
iss(发行者)和aud(受众)声明。 - 验证
exp(过期)和(如果发送)nbf声明。
- 为每个端点验证适当的
scope。
不透明访问令牌(opaque)
- 将收到的令牌发送到
/oauth2/introspect,并确认返回了active: true。 - 为每个端点验证适当的
scope。
建议
最简单的方法是只接受 JWT 格式的访问令牌用于您的 API,并拒绝不透明令牌。
优点:
- 快速:本地可验证,无需网络调用。
- 面向未来:发行后独立于授权服务器。
- 无需客户端密钥:API 可在无需机密客户端凭证的情况下验证令牌。
同时接受 不透明访问令牌和 JWT 令牌 是可能的,但会带来权衡。
优点:
- 立即进行令牌和客户端验证。
- 客户端无需
resource参数(取决于授权服务器配置)。
缺点:
- DOS:如果客户端是外部(例如外部 API、MCP 代理),不透明
access_token验证可能会使授权服务器过载。 - 性能:每个收到的不透明
access_token都需要调用内省端点的网络请求。 - 需要密钥:内省通常需要
client_secret,公共客户端无法安全提供。- 注意:内省承载令牌和私钥 JWT 方法尚未实现。
范围与权限
- Scopes 定义客户端应用程序代表用户请求的内容。它们通常是访问令牌中包含的粗粒度标签。
- Permissions 定义用户(或服务)对资源实际可以执行的细粒度操作,通常在资源服务器处强制实施。
实际应用中可根据系统复杂度及资源服务器授权处理方式结合使用。
Scopes 与 Permissions 相同
每个范围直接代表一个权限。
- 示例:范围
read:post完全对应权限read:post。
优点:
- 实现简单且易于理解。
- 无需额外映射逻辑。
缺点:
- 如果权限非常详细,访问令牌可能变得很大,尤其是 JWT 格式。
- 对未来更细粒度的权限灵活性有限。
Scopes 与 Permissions 区分
Scopes 表示高层访问类别,每个范围映射到一个或多个底层权限。
- 示例:范围
view:post可能映射到:read:post:contentread:post:metadata(仅限用户拥有的帖子)
优点:
- 适用于复杂系统的灵活且可扩展。
- 令牌保持紧凑,因为只包含范围,而不是所有权限。
缺点:
- 资源服务器必须为每个请求将范围解析为权限。
- 实现和授权检查增加了复杂性。
配置
重定向屏幕
OAuth 流程中,用户可能被多次重定向。例如,用户可能先到登录页面,再跳转至同意页面,最后返回应用。以下说明常见登录流程及所需配置。
流程中检测每个重定向步骤时,会验证在初始 /oauth2/authorize 重定向时签名的查询参数。包含所有参数(包括自定义参数)均被签名和验证。
如果登录页有自定义查询参数,可将其附加在签名查询(即 sig 字段)结尾。
如果使用客户端插件 oauthProviderClient,则 oauth_query 参数会自动发送至所有需要的端点。若是自定义登录端点,则需手动在请求体中的 oauth_query 字段添加带签名的查询,内容仅包括签名查询参数。
登录屏幕
用户跳转到 OIDC 提供者进行认证时,若未登录,会跳转至登录页面。可通过初始化时提供 loginPage 选项自定义登录页。
oauthProvider({
loginPage: "/sign-in"
})无需额外处理,插件会在新会话创建后自动继续授权流程。
同意屏幕
用户跳转到 OIDC 提供者认证时,可能需授权应用访问数据。
注意:受信任客户端设置了 skipConsent: true,将跳过同意页面,提供无缝体验。
oauthProvider({
consentPage: "/consent"
})插件会携带 client_id 和 scope 参数重定向至指定路径,您可根据该信息展示自定义同意页。用户同意后调用 oauth2.consent 完成授权。
import { authClient } from "@/lib/auth-client"
const res = await authClient.oauth2.consent({
accept: true,
// 可选接受的范围,未指定时接受所有原始请求范围
scope: "openid profile email"
});注册账户屏幕
客户端通过 prompt: create 跳转用户到注册页时,配置如下:
oauthProvider({
signUp: {
page: "/sign-up",
}
})欲在注册步骤中阻断登录流程,使用 shouldRedirect 函数:
import { userRegistered } from "@lib/registered";
oauthProvider({
signUp: {
page: "/sign-up",
shouldRedirect: async ({ headers }) => {
const isUserRegistered = await userRegistered(headers);
return isUserRegistered ? false : "/setup";
},
}
})选择账户屏幕
用户认证时被重定向至选择账户页,需先启用选择账户配置。
下面示例使用多会话插件,若登录多个会话,则自动跳转选择账户页:
oauthProvider({
selectAccount: {
page: "/select-account",
shouldRedirect: async ({ headers }) => {
const allSessions = await auth.api.listDeviceSessions({
headers,
})
return allSessions?.length >= 1;
},
}
})插件会跳转至 selectAccount.page,该页面应提示用户选择账户,选择完成后调用 oauth2Continue。
import { authClient } from "@/lib/auth-client"
await authClient.multiSession.setActive({
sessionToken,
});
await client.oauth2.oauth2Continue({
selected: true,
});登录后页面
如果某个范围要求指定组织,需在登录后流程中配置所有以下选项,将 reference_id(如组织 ID、团队 ID)绑定至流程。
下面示例使用组织插件,自动在登录后跳转选择组织页:
oauthProvider({
scopes: ["openid", "profile", "email", "read:organization"]
postLogin: {
page: "/select-organization",
shouldRedirect: async ({ session, scopes, headers }) => {
const userOnlyScopes = ["openid", "profile", "email", "offline_access"];
if (scopes.every((sc) => userOnlyScopes.includes(sc))) {
return false;
}
const organizations = await auth.api.listOrganizations({
headers,
});
return organizations.length > 1 || !(
organizations.length === 1 && organizations.at(0)?.id === session.activeOrganizationId
)
},
consentReferenceId: ({ session, scopes }) => {
if (scopes.includes("read:organization")) {
const activeOrganizationId = (session?.activeOrganizationId ?? undefined) as string | undefined;
if (!activeOrganizationId) {
throw new APIError("BAD_REQUEST", {
error: "set_organization",
error_description: "must set organization for these scopes",
})
}
return activeOrganizationId;
} else {
return undefined;
}
},
}
})插件会将用户重定向至 postLogin.page 以完成选择。选择完成后调用 oauth2Continue。
import { authClient } from "@/lib/auth-client"
await authClient.organization.setActive({
organizationId,
});
await client.oauth2.oauth2Continue({
postLogin: true,
});可缓存的受信任客户端
针对第一方应用和内部服务,可缓存受信任客户端以提升性能。值以内存缓存,并阻止 CRUD 接口修改。
oauthProvider({
// 受信任客户端 clientId 列表
cachedTrustedClients: new Set([
"internal-dashboard",
"mobile-app",
]),
})有效受众(Audiences)
本 OAuth 服务器允许的一组有效受众(资源)。若未指定,则默认发送基础 URL。推荐指定除基础 URL 之外的受众,如您的 API。
oauthProvider({
validAudiences: [
"https://api.example.com",
"https://api.example.com/mcp",
]
})Scopes
Scopes 授权客户端访问指定资源。目前默认支持:
openid: 返回用户的 ID(sub声明)。profile: 返回名称、图片、given_name、family_nameemail: 返回邮箱和邮箱已验证offline_access: 返回刷新令牌
您可以自由定义所支持的 scopes!注意:需包含 openid 以满足 OIDC 服务器,否则为标准 OAuth 2.1 服务器。所有支持的 scopes 必须包含于此数组。
oauthProvider({
scopes: [ "openid", "profile", "offline_access", "read:post", "write:post" ],
})声明(Claims)
内部支持以下声明:["sub", "iss", "aud", "exp", "iat", "sid", "scope", "azp"]。
建议对 id token 和 userinfo 中附加的声明使用命名空间,防止未来冲突。
customIdTokenClaims 和 customUserInfoClaims 中添加的声明应被添加到 advertisedMetadata.claims_supported,以便客户端验证收到的声明。以下示例包含基础声明以及 locale 和 https://example.com/org。
提示:这两个函数也可以抛出错误,例如用户不再是组织成员或无请求权限。
oauthProvider({
// 附加声明到 id tokens
customIdTokenClaims: ({ user, scopes, metadata }) => {
return {
locale: "en-GB",
};
},
// 附加声明到访问令牌
customAccessTokenClaims: ({ user, scopes, referenceId, resource, metadata }) => {
return {
"https://example.com/org": referenceId,
"https://example.com/roles": ["editor"],
};
},
// 附加额外 userinfo 声明
customUserInfoClaims: ({ user, scopes, jwt) => {
return {
locale: "en-GB",
};
},
})自定义令牌响应字段
与上述声明回调(在 JWT 载荷内部添加数据)不同,customTokenResponseFields 会向令牌端点 JSON 响应中添加字段,伴随 access_token、token_type 等。标准 OAuth 字段无法被覆盖。
oauthProvider({
customTokenResponseFields: ({ grantType, user, scopes, metadata, verificationValue }) => {
// 在 authorization_code 授权类型中为租户上下文添加字段
if (grantType === "authorization_code" && verificationValue?.referenceId) {
return { tenant_id: verificationValue.referenceId };
}
return {};
},
})该回调接收授权类型、用户(client_credentials 时为 undefined)、范围、解析的客户端元数据和验证值(仅 authorization_code 授权类型)。在创建任何令牌之前调用,因此抛出错误不会留下部分应用的状态。
过期时间
每种令牌类型和授权类型均可独立设置默认过期时间。
accessTokenExpiresIn默认 1 小时m2mAccessTokenExpiresIn默认 1 小时idTokenExpiresIn默认 10 小时refreshTokenExpiresIn默认 30 天codeExpiresIn默认 10 分钟
访问令牌还支持基于 scopes 单独设置更短过期(以最早过期时间为准,未设置的使用默认)。注意:该时间应低于默认 accessTokenExpiresIn 和 m2mAccessTokenExpiresIn。
oauthProvider({
scopeExpirations: {
"write:payments": "5m",
"read:payments": "30m",
},
})注册
动态客户端注册
动态注册允许授权注册公有和机密客户端。
oauthProvider({
allowDynamicClientRegistration: true,
})无认证客户端注册允许公有客户端(非机密)无授权头动态注册,适合 MCP 自动注册公有客户端。
oauthProvider({
allowDynamicClientRegistration: true,
allowUnauthenticatedClientRegistration: true,
})allowUnauthenticatedClientRegistration 将在 MCP 协议标准化无认证动态客户端注册时弃用。截至撰写时,客户端 ID 元数据文档 和 software_statement 与 jwks_uri 仍在讨论中。
动态客户端注册过期时间
可设置动态注册机密客户端的过期时间。默认动态注册机密客户端无过期。
oauthProvider({
allowDynamicClientRegistration: true,
clientRegistrationClientSecretExpiration: "30d",
})动态客户端注册默认范围
若客户端注册时未提交 scopes,可设置默认 scopes。所有默认 scopes 必须在 scopes 中定义。
oauthProvider({
scopes: ["reader", "editor"],
clientRegistrationDefaultScopes: ["reader"],
})若还需设置允许的额外 scopes,设置 clientRegistrationAllowedScopes。该集合包含默认 scopes。
oauthProvider({
scopes: ["reader", "editor"],
clientRegistrationDefaultScopes: ["reader"],
clientRegistrationAllowedScopes: ["editor"],
})PKCE 配置
PKCE 是防止授权码被截获的一种安全机制。插件遵循 OAuth 2.1 规范,默认对所有授权码流程要求 PKCE。
默认行为
默认要求所有客户端使用 PKCE,最大安全、符合 OAuth 2.1 最佳实践。
PKCE 始终需要:
- 公共客户端(基于原生/用户代理的应用程序)
- 任何带有
offline_access范围的授权码(刷新令牌)
每客户端 PKCE 配置
客户端可在注册时选择关闭 PKCE(适配兼容性):
// 注册不支持 PKCE 的机密客户端
const response = await auth.api.createOAuthClient({
headers,
body: {
client_name: 'Legacy Backend Service',
redirect_uris: ['https://app.example.com/callback'],
token_endpoint_auth_method: 'client_secret_post',
grant_types: ['authorization_code'],
require_pkce: false, // 选择关闭 PKCE
}
});require_pkce 字段:
- 默认值为
true(需要 PKCE) - 仅适用于机密客户端
- 公共客户端(PKCE 始终需要)忽略此设置
- 带有
offline_access范围的授权码忽略此设置(PKCE 始终需要)
何时使用 require_pkce: false:
- 从 OAuth 2.0 迁移的旧机密客户端不支持 PKCE
- 无法更新的后端到后端集成
- 分阶段迁移期间的临时兼容性
建议: 尽可能保持启用 PKCE(默认),即使对机密客户端也增加安全防护。
从 oidc-provider 迁移
若从已废弃的 oidc-provider 插件迁移,且存在不支持 PKCE 的机密客户端:
- 旧客户端可针对单个客户端设置
require_pkce: false。 - 新客户端注册应始终开启 PKCE(默认)。
- 逐步淘汰不支持 PKCE 的客户端。
- 监控使用了
require_pkce: false的客户端以便规划迁移。
安全考虑
PKCE 防止授权码截获。即使机密客户端使用 client_secret 认证,PKCE 依旧提供额外安全保障:
- 防御深度:多层防护
- 防止误配置:密钥泄露风险降低
- 符合未来标准
安全性考虑
PKCE 防止授权码截获攻击。即使对使用 client_secret 的机密客户端,PKCE 也提供额外安全:
- 纵深防御:多层安全
- 防止错误配置:减少密钥意外泄露
- 面向未来:符合 OAuth 2.1 最佳实践
仅当绝对必要时(例如旧机密客户端的兼容性),才禁用 PKCE。
组织
OAuth 客户端注册时绑定用户或 reference_id,且不可变。
若使用 组织插件,请确保在新建客户端时,激活会话中的 activeOrganizationId 已被设置。
oauthProvider({
clientReference: ({ session }) => {
return (session?.activeOrganizationId as string | undefined) ?? undefined;
},
})有关设置用户权限和角色的详细信息,请参见 声明。
客户端 CRUD 权限
确定登录用户是否具备客户端创建、读取、更新、删除权限,可通过 clientPrivileges 配置。默认允许拥有匹配 userId 或 clientReference 的用户操作。
示例仅允许组织管理员对 OAuth 客户端执行 CRUD 操作,假设普通用户无法创建客户端:
oauthProvider({
clientPrivileges: async ({ action, headers, user, session }) => {
if (!session?.activeOrganizationId) return false;
const { data: member } = await auth.api.getActiveMember({
headers,
});
return member.role === 'owner';
},
})存储
默认所有密钥在数据库中以 hashed 形式存储,防止泄露时暴露 client_secret。
- storeClientSecret: 应用程序
client_secrets的存储方式。仅当disableJwtPlugin: true时,客户端密钥应为encrypted。 - storeTokens: 令牌值的存储方式,特别是会话刷新令牌和不透明访问令牌。
限流
OAuth 提供者内置所有 OAuth 端点限流,防止滥用和拒绝服务攻击。
限流为 每 IP 每端点。每个客户端 IP 地址对每个端点拥有独立的限流计数器。窗口期结束后限流重置。
这些限流仅在 Better Auth 的全局限流启用时生效。默认情况下仅在生产环境启用。参见 限流获取全局配置。
默认限制:
| 端点 | 窗口 | 最大请求数 |
|---|---|---|
/oauth2/token | 60s | 20 |
/oauth2/authorize | 60s | 30 |
/oauth2/introspect | 60s | 100 |
/oauth2/revoke | 60s | 30 |
/oauth2/register | 60s | 5 |
/oauth2/userinfo | 60s | 60 |
可自定义各端点的限流参数:
oauthProvider({
rateLimit: {
token: { window: 60, max: 20 }, // 每分钟 20 次请求
authorize: { window: 60, max: 30 }, // 每分钟 30 次请求
introspect: { window: 60, max: 100 }, // 每分钟 100 次请求
revoke: { window: 60, max: 30 }, // 每分钟 30 次请求
register: { window: 60, max: 5 }, // 每分钟 5 次请求
userinfo: { window: 60, max: 60 }, // 每分钟 60 次请求
},
})如需关闭某个端点的自定义限流,回退到全局限流,设置该端点为 false:
oauthProvider({
rateLimit: {
introspect: false, // 使用全局限流替代此端点限流
},
})将端点设置为 false 会移除 OAuth 提供者的更严格端点限流。该端点仍受 Better Auth 的全局限流约束(如已启用)。
刷新令牌自定义
可使用 formatRefreshToken 自定义会话刷新令牌的字符串格式。
此函数可为刷新令牌增加功能,如加密。
示例如更改刷新令牌格式,同时兼容原有简单格式:
oauthProvider({
formatRefreshToken: {
encrypt: (token, sessionId) => {
const res = sessionId ? `1.${token}.${sessionId}` : token;
return res;
},
decrypt: (token) => {
const tokenSplit = token.split('.');
if (tokenSplit.length === 3 && tokenSplit.at(0) === '1') {
return {
token: tokenSplit.at(1),
sessionId: tokenSplit.at(2),
};
}
return { token };
},
}
})加密伪代码示例:
import { betterAuth } from "better-auth";
import { CompactEncrypt, compactDecrypt } from 'jose'
import { oauthProvider } from "@better-auth/oauth-provider";
const secret = "SOME_SECRET_OR_KEY"
const alg = "A256KW"
const enc = "A256GCM"
const auth = betterAuth({
plugins: [
oauthProvider({
formatRefreshToken: {
encrypt: (token, sessionId) => {
const value = JSON.stringify({
sessionId,
token,
});
const jwe = await new CompactEncrypt(Buffer.from(value))
.setProtectedHeader({ alg, enc })
.encrypt(secret);
return jwe;
},
decrypt: (token) {
const { plaintext } = await compactDecrypt(token, secret);
const payload = new TextDecoder().decode(plaintext);
return JSON.parse(payload);
},
}
})
]
})广告公布元数据
可自定义元数据端点,实现对外展示的 scopes 和 claims 与实际支持的不同,避免暴露所有支持的权限。
所有出现在 advertisedMetadata 中的 scopes 必须在 scopes 中声明,否则初始化失败。
Scopes
oauthProvider({
scopes: ["openid", "profile", "email", "offline_access", "read:post"],
advertisedMetadata: {
scopes_supported: ["openid", "profile", "read:post"],
},
})声明(Claims)
声明为额外声明,除默认支持的以外。仅对 OIDC(即 openid 范围)适用。
oauthProvider({
advertisedMetadata: {
claims_supported: ["https://example.com/roles"],
},
})禁用 JWT 插件
默认情况下,访问和 ID 令牌可通过 JWT 插件签发与验证。
可禁用 JWT 要求,此时访问令牌总是以不透明格式且 ID 令牌使用 HS256 对称签名(使用 client_secret)。该选项仍符合 OIDC,/userinfo 依旧可用,签名的 id_token 依旧提供。
关键差异:
- 提供有效的
resource将始终返回不透明访问令牌而非 JWT 格式令牌。 id_token不返回给公共客户端,但返回的access_token仍可通过/oauth2/userinfo端点获取用户数据。id_token对机密客户端使用其client_secret签名。
oauthProvider({
disableJwtPlugin: true,
})成对主体标识符(Pairwise Subject Identifiers)
默认情况下,令牌中的 sub(主体)声明使用用户的内部 ID,是所有客户端通用的公开主体类型,符合 OIDC 核心规范 8 节。
您可启用 成对(pairwise) 主题标识符,使每个客户端为同一用户生成唯一且不可关联的 sub,防止关联分析。
oauthProvider({
pairwiseSecret: "your-256-bit-secret",
})当配置了 pairwiseSecret,服务器在发现端点的 subject_types_supported 同时声明 "public" 和 "pairwise"。客户端通过注册时设置 subject_type: "pairwise" 选择成对。
每客户端配置
const response = await auth.api.createOAuthClient({
headers,
body: {
client_name: 'Privacy-Sensitive App',
redirect_uris: ['https://app.example.com/callback'],
token_endpoint_auth_method: 'client_secret_post',
subject_type: 'pairwise', // 开启成对 sub
}
});工作原理
成对标识符通过基于客户端第一个重定向 URI 的主机(范围标识符)和用户 ID,使用 pairwiseSecret 进行 HMAC-SHA256 生成。
- 两个具有不同重定向 URI 主机的客户端对同一用户总会收到不同的
sub值 - 两个共享相同重定向 URI 主机的客户端对同一用户收到相同的成对
sub(符合 OIDC 核心规范 8.1) - 同一客户端对同一用户始终收到相同的
sub(确定性)
成对 sub 出现在:
id_token/oauth2/userinfo响应- 令牌内省(
/oauth2/introspect)
JWT 访问令牌仍使用真实用户 ID 作为 sub,因资源服务器可能需要直接查询用户信息。
限制:
sector_identifier_uri尚未支持。一个客户端的所有重定向 URI 必须共享同一主机。跨主机的重定向 URI 将导致注册被拒绝。pairwiseSecret必须至少 32 个字符长。- 轮换
pairwiseSecret将更改所有成对sub值,破坏现有 RP 会话。请将密钥视为永久设置。
MCP
仅需添加资源服务器指向此 OAuth 2.1 授权服务器,即可轻松让 API 兼容 MCP 协议。
如果您使用 "openid" 且为机密 MCP 客户端,则无法禁用 JWT 插件,因为 id_token 验证可能无法通过 client_secret 支持。
安装
添加资源服务器客户端
(可选)如果本地有 auth 配置,可作为参数提供给客户端,便于配置校验。亦可直接在调用时覆盖。若无,则由 TypeScript 指导最低配置要求。
import { auth } from "@/lib/auth";
import { createAuthClient } from "better-auth/client";
import { oauthProviderResourceClient } from "@better-auth/oauth-provider/resource-client"
export const serverClient = createAuthClient({
plugins: [oauthProviderResourceClient(auth)], // auth 可选
});向 API 添加 OAuth 受保护资源元数据
import { serverClient } from "@/lib/server-client";
export const GET = async () => {
const metadata = await serverClient.getProtectedResourceMetadata({
resource: "https://api.example.com", // `aud` 声明
authorization_servers: ["https://auth.example.com"],
})
return new Response(JSON.stringify(metadata), {
headers: {
"Content-Type": "application/json",
"Cache-Control":
"public, max-age=15, stale-while-revalidate=15, stale-if-error=86400",
},
});
};如果您使用 allowUnauthenticatedClientRegistration,则必须确保您的 API Server 本身也是机密客户端:
await auth.api.createOAuthClient({
headers,
body: {
redirect_uris: [redirectUri],
}
});这些值应当用于 remoteVerify.clientId 和 remoteVerify.clientSecret 验证选项。此外,remoteVerify.introspectUrl 可以设置为 ${BASE_URL}/${AUTH_PATH}/oauth2/introspect 这样的地址。
如果你选择不支持 allowUnauthenticatedClientRegistration(仅支持 allowDynamicClientRegistration),则 MCP 客户端(如 ChatGPT、Anthropic、Gemini)需要允许你在其 UI 中或与 AI 对话的运行时传入公开的 client_id。
处理 MCP 错误
必须指定特定 audience 进行验证,默认为所有 validAudiences 或 baseUrl。
- 使用客户端
verifyAccessToken函数
参考 验证 示例。
- With auth available, use the client
verifyAccessTokenfunction to automatically determine endpoints
import { auth } from "@/lib/auth";
import { serverClient } from "@/lib/server-client";
export const GET = async (req: Request) => {
const authorization = req.headers?.get("authorization") ?? undefined;
const accessToken = authorization?.startsWith("Bearer ")
? authorization.replace("Bearer ", "")
: authorization;
const payload = await serverClient.verifyAccessToken(
accessToken, {
verifyOptions: {
audience: "https://api.example.com",
}
}
);
// ...后续操作
}- Using
mcpHandlerhelper
import { createMcpHandler } from "mcp-handler";
import { mcpHandler } from "@better-auth/oauth-provider";
import { z } from "zod";
const handler = mcpHandler({
jwksUrl: "https://auth.example.com/api/auth/jwks",
verifyOptions: {
issuer: "https://auth.example.com",
audience: "https://api.example.com",
},
}, (req, jwt) => {
return createMcpHandler(
(server) => {
server.registerTool(
"echo", {
description: "Echo a message",
inputSchema: {
message: z.string(),
},
},
async ({ message }) => {
return {
content: [
{
type: "text",
text: `Echo: ${message}${
jwt?.sub
? ` for user ${jwt.sub}`
: ""
}`,
},
],
};
}
);
}, {
serverInfo: {
name: "demo-better-auth",
version: "1.0.0",
}
}, {
basePath: "/api",
maxDuration: 60,
verboseLogs: true,
}
)(req);
});
export { handler as GET, handler as POST, handler as DELETE };Schema
OAuth 提供者插件新增以下表格:
OAuth 客户端表
表名:oauthClient
OAuth 刷新令牌表
表名:oauthRefreshToken
OAuth 访问令牌表
表名:oauthAccessToken
OAuth 同意表
表名:oauthConsent
选项
前缀
可为不透明访问令牌、刷新令牌和客户端密钥添加前缀,有助于秘密扫描工具(如 GitHub Secret Scanners、GitGuardian、Trufflehog)识别令牌格式。
推荐在部署前先添加前缀,部署后视为不可变,否则应使用对应的生成函数。
prefix 配置设置下提供以下选项:
- opaqueAccessToken:
string | undefined- 为不透明访问令牌添加前缀。如果已部署,请改用generateOpaqueAccessToken来执行此功能。 - refreshToken:
string | undefined- 为刷新令牌添加前缀。如果已部署,请改用generateRefreshToken来执行此功能。 - clientSecret:
string | undefined- 为客户端密钥添加前缀。如果已部署,请改用generateClientSecret来执行此功能。
优化提示
为提升查询性能,数据库适配器可将 oauthClient 表中的 client_id 字段映射为 id。注意 id 应支持 UUID 和 URL 格式的字符串。
迁移
来自 OIDC Provider 插件
请参见 OIDC Provider Plugin 了解之前的实现。
配置变更
idTokenExpiresIn现在默认为10 hours(之前为1 hour通过accessTokenExpiresIn)refreshTokenExpiresIn现在默认为30 days(之前为7 days)advertisedMetadata(之前为metadata)不再支持更改元数据字段,以防止意外配置错误。clientRegistrationDefaultScopes(之前为defaultScope)现在采用数组格式,而非空格分隔的字符串。consentPage现在为必填项。getConsentHTML已被移除,改用consentPage,因为 OAuth 授权端点不支持原始 HTML 作为响应类型。requirePKCE(全局选项)已被移除。PKCE 现在默认要求遵循 OAuth 2.1。单个客户端可通过注册时的require_pkce: false选项选择退出以保持向后兼容。allowPlainCodeChallengeMethod被移除,因为plain代码挑战方法被认为不如默认的S256方法安全。customUserInfoClaims(之前为getAdditionalUserInfoClaim)传递 JWT 负载,而不是请求中使用的客户端访问令牌。storeClientSecret现在默认为hashed,如果disableJwtPlugin: true则为encrypted(之前为plain)。- 默认启用 JWT 插件。如需禁用,请设置
disableJwtPlugin: true。 - 授权查询中的
code_challenge_method"S256" 必须按 OAuth 2.1 规范使用大写形式。
数据库变更
表:oauthClient
旧名为 oauthApplication
- 如果
storeClientSecret未设置或为plain,需将所有存储的clientSecret转换为 SHA-256 哈希并编码为 base64Url,或按新设置方式存储。示例函数:
- 如果
storeClientSecret未设置或为plain,必须将存储的clientSecret值哈希为 "SHA-256" 表示形式,然后转换为 base64Url 格式,或使用storeClientSecret指定的其他存储方式。 以下函数将plain表示转换为默认哈希:
import { createHash } from "@better-auth/utils/hash";
import { base64Url } from "@better-auth/utils/base64";
const defaultHasher = async (value: string) => {
const hash = await createHash("SHA-256").digest(
new TextEncoder().encode(value),
);
const hashed = base64Url.encode(new Uint8Array(hash), {
padding: false,
});
return hashed;
};type字段不再是必需字段。相反,架构要求使用public(布尔类型)。迁移规则如下:type: "public"的客户端:设置type: undefined,public: true,clientSecret: undefinedtype: "native"的客户端:设置public: true,clientSecret: undefinedtype: "user-agent-based"的客户端:设置public: true,clientSecret: undefinedclientSecret: undefined的客户端:设置public: true
redirectURLs重命名为redirectUris- 新增
requirePkce字段(可选,默认为true)。对于现有不支持 PKCE 的机密客户端,请设置requirePkce: false。 metadata现在以独立字段形式存储在数据库中,而不是 JSON 对象。请将元数据解析为其对应字段。OIDC 插件未使用此字段,但此 OAuth 插件将来可能会使用。
表:oauthAccessToken
方案一(简单):
可以选择不迁移此表,影响较小。用户需要重新登录。可以直接删除现有的 oauthAccessToken 表。
方案二(复杂):
全表迁移(可能需要先将 oauthAccessToken 克隆到 oauthRefreshToken)。
- 将带有
refreshToken字段的条目转换为新建的oauthRefreshToken条目:
- 将
oauthAccessToken中带有refreshToken字段的条目转换为新的oauthRefreshToken条目:
{
token: defaultHasher(refreshToken),
expiresAt: refreshTokenExpiresAt,
clientId: clientId,
scopes: scopes,
userId: userId,
createdAt: createdAt,
updatedAt: updatedAt,
}- 保留
oauthAccessToken,但引用新的oauthRefreshToken:
{
token: defaultHasher(accessToken),
expiresAt: accessTokenExpiresAt,
clientId: clientId,
scopes: scopes,
refreshId: oauthRefreshToken.id, // 无刷新令牌时为 `undefined`
createdAt: createdAt,
updatedAt: updatedAt,
}来自 MCP 插件
请参见 MCP Plugin 了解之前的 MCP 专用端点。
MCP 端点已从 /mcp 迁移至 /oauth2:
/oauth2/authorize(之前为/mcp/authorize)/oauth2/token(之前为/mcp/token)/oauth2/register(之前为/mcp/register)/mcp/get-session已被移除,因为它不符合 OAuth 2 规范,请改用/oauth2/introspect/.well-known/oauth-protected-resource已被移除,可使用辅助函数mcpHandler(或手动使用服务器api.oAuth2introspectVerify或资源客户端verifyAccessToken)- 数据库变更与 从 OIDC Provider 插件迁移 部分相同。