OAuth 2.1 提供者
一个更好的认证插件,使您的认证服务器能够作为 OAuth 2.1 提供者运行。
一个OAuth 2.1 提供者插件,允许您将认证服务器转变为兼容 OIDC 的 OAuth 提供者,支持用户和其他服务通过您的 API 进行认证。
该插件默认具有安全配置,使不熟悉 OAuth 细节的用户也能轻松使用。
主要特性:
- OAuth 2.1:限制使用符合 OAuth 2.1 的安全实践
- 发行者验证:授权响应包含
iss参数以防止 混淆攻击 - 启用 MCP:支持 MCP 认证
- 兼容 OIDC:遵循 OIDC 标准,支持
openid范围- UserInfo:提供当前用户详情的端点
- id_token:JWT 签名的用户信息
- OIDC 注销:RP发起的注销支持
- 动态客户端注册:允许客户端动态注册
- 公有客户端:支持原生移动客户端和用户代理客户端(如 AI)
- 机密客户端:支持 Web 客户端
- 受信任客户端:支持硬编码受信任客户端,并可选择绕过同意
- 兼容 JWT 插件:默认必需,可选禁用
- JWT 签名:请求
resource时签发 JWT 令牌 - JWKS 校验:在
/jwks端点远程验证令牌
- JWT 签名:请求
- 授权提示:启动特定登录流程的提示
- 同意:确保为每个范围授权同意。使用
prompt=consent强制。 - 选择账户:确保在授权特定范围前选择账户。使用
prompt=select_account强制。
- 同意:确保为每个范围授权同意。使用
- 资源端点:读取和管理令牌。
支持的授权方式
- authorization_code:支持使用 PKCE 和 S256 要求的用户令牌交换
- refresh_token:支持刷新令牌及通过
offline_access范围的访问令牌续期 - 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 授权服务器元数据端点。
- 如果您使用
openid范围,必须在发行者路径(无路径则为根路径)添加 OpenID 配置。 - 如果使用资源服务器(如 MCP),必须将资源服务器元数据添加至您的 API,并在发行者路径后附加。
创建您的第一个 OAuth 客户端
创建一个机密 OAuth 客户端。
const client = await auth.api.createOAuthClient({
headers,
body: {
redirect_uris: [redirectUri],
}
});
console.log(client); // 如需要,您可以将 `client_id` 添加至 `cachedTrustedClients`若要创建公有客户端(即无客户端密钥),请设置 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 中有两种类型的客户端:
- 公有客户端:无法存储客户端密钥,如原生移动端和用户代理(如 AI)客户端
- 机密客户端:可存储客户端密钥,如 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
列出客户端
获取特定用户或组织所拥有的所有客户端列表,使用以下端点:
const { data, error } = await authClient.oauth2.getClients();创建客户端
创建与指定用户或组织关联的 OAuth 客户端,使用 /oauth2/create-client 端点(如 createOAuthClient)。参数与 RFC7591 描述的注册端点相同。
数据库中的以下字段属于受限字段,仅管理员用户可编辑:
client_secret_expires_at:机密客户端密钥过期时间skip_consent:允许跳过用户同意流程。适用于受信任客户端。enable_end_session:允许用户通过其id_token在/oauth2/end-session端点从客户端注销。用于 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,
}
});更新客户端
更新与指定用户或组织关联的 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需要更新的字段
本端点限制:
- 不支持在机密与公有客户端间切换。客户端类型创建时确定。
- 不支持更新客户端密钥。旋转客户端密钥请使用专门的端点。
部分场景下,您可能希望通过自定义 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,
}
});旋转客户端密钥
当前实现会立即生效,旧密钥即时失效。
旋转客户端密钥,请使用以下端点:
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 同意管理
对于所有非受信任客户端(尤其是无 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 将被弃用。当前,客户端 ID 元数据文档和software_statement 与 jwks_uri仍在讨论中。
oauthProvider({
allowDynamicClientRegistration: true,
allowUnauthenticatedClientRegistration: true,
// ... 其他选项
})基本示例
使用 oauth2.register 方法注册新的 OIDC 客户端。
import { authClient } from "@/lib/auth-client"
const client = await authClient.oauth2.register({
client_name: "My Client",
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)
需发送状态参数以防止跨站请求伪造(CSRF)攻击。客户端通过该方式确保只响应其发起的请求。
客户端生成状态值,并存储在安全、HTTP-only Cookie 或数据库等处。
代码挑战
代码挑战用于保护授权端点返回的授权码。
其通过从代码验证器派生代码挑战并通过 PKCE(Proof Key for Code Exchange) 发送至授权服务器。
在 redirect_uri 回调时,客户端比对返回状态与初始状态是否匹配,然后使用 authorization_code 授权方式和原始代码验证器在令牌端点交换令牌。
令牌端点
默认情况下,令牌端点支持以下授权:
- "authorization_code"
- "client_credentials"
- "refresh_token"
授权码授权方式
该方式允许客户端获取用户访问令牌,及可选的刷新令牌(包含 "offline_access" 范围)。
客户端凭证授权方式
该方式允许客户端获取机器间访问令牌。
刷新令牌授权方式
该方式允许客户端刷新访问令牌,无需用户再次登录。
当前实现为每次刷新请求发放新的刷新令牌。
同意端点
接受或拒绝用户对某组权限的同意。注意拒绝某些权限时,会取消本次授权的同意,之前已有的其他同意依然有效。要移除同意,请删除该用户对应客户端的 "oauthConsent"。
const { data, error } = await authClient.oauth2.consent({ accept, // required scope,});acceptboolean,required接受或拒绝用户同意
scopestring,空格分隔的接收权限列表。不提供则默认为原始请求权限。
Continue 端点
注册页面必须先 配置 以进行注册步骤。 账户选择页面必须先 配置 用于账户选择。 登录后页面必须先 配置 用于登录后操作。
const { data, error } = await authClient.oauth2.continue({ selected, created, postLogin,});selectedboolean,确认已选择账户
createdboolean,确认已完成账户注册
postLoginboolean,确认已完成登录后操作
令牌核查端点
符合 RFC7662 规范的令牌核查端点。
此端点提供所给令牌的详细信息。如令牌绑定于会话,确保该会话为 active。
通过 customAccessTokenClaims 可向机密客户端的 resources 字段存储允许其访问的资源,以提供资源特定声明。
撤销端点
符合 RFC7009 规范的撤销端点。
此端点撤销所给令牌。
- 不透明访问令牌(opaque
access_token):立即从数据库删除该令牌,刷新令牌依然有效 - JWT 访问令牌:验证令牌是否允许从客户端存储中删除
- 刷新令牌(
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);本地测试时,如遭遇跨域问题(例如使用 MCP Inspector),因前端调用端点而非后端。可添加 Access-Control-Allow-Methods": "GET" 及 "Access-Control-Allow-Origin": "*" 进行测试。
OAuth 授权服务器
提供 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);本地测试时,如遇跨域问题(例如使用 MCP Inspector),因前端调用端点而非后端。可添加 Access-Control-Allow-Methods": "GET" 及 "Access-Control-Allow-Origin": "*" 进行测试。
API 服务器
本节展示如何让您的 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 验证令牌时不需机密客户端凭据
附带支持不透明访问令牌也可实现,但有利弊。
优点:
- 令牌和客户端即时验证
- 客户端可无需传递
resource参数(取决授权服务器配置)
缺点:
- DOS 风险:若客户端为外部(如外部 API、MCP 代理),大量不透明令牌核查可能使授权服务器过载
- 性能影响:每个令牌核查需网络请求 introspection 端点
- 需密钥:通常 introspection 需要
client_secret,公有客户端难以安全提供- 注意:目前尚未实现 introspection 的承载令牌和私钥 JWT 方法
范围与权限
- Scopes(范围) 定义客户端代表用户请求的权限。通常为包含在访问令牌中的粗粒度标签。
- Permissions(权限) 定义用户(或服务)对资源实际被授权执行的操作,通常由资源服务器强制执行。
实际应用中可结合两种方式,视系统复杂度及资源服务器授权处理方式而定。
Scopes 与 Permissions 相同
每个范围直接对应一个权限。
- 例如:
read:post范围即权限read:post
优点:
- 简单易实现和理解
- 无需额外映射逻辑
缺点:
- 访问令牌可能因过多权限而过大,尤其 JWT
- 灵活性有限,不易扩展更细粒度权限
Scopes 与 Permissions 区分
Scopes 表示高层访问类别,每个范围映射至一个或多个底层权限。
- 例如:
view:post范围可能映射到read:post:contentread:post:metadata(且仅限用户拥有的帖子)
优点:
- 灵活可扩展,适合复杂系统
- 令牌保持简洁,仅携带 scopes,无需全部权限
缺点:
- 资源服务需为每次请求解析范围映射权限
- 增加实现复杂度和授权检查开销
配置
重定向页面
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:返回姓名、头像、名、姓email:返回邮箱及邮箱验证状态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",
};
},
})过期时间配置
每种令牌类型和授权类型均可独立设置默认过期时间。
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,
})当 MCP 协议标准化无认证动态客户端注册时,allowUnauthenticatedClientRegistration 将被弃用。当前,客户端 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 场景:
- 兼容不支持 PKCE 的遗留机密客户端
- 后端到后端集成,无法更新客户端
- 迁移期间短期兼容
建议: 尽可能保持启用 PKCE(默认),即使机密客户端,也增加安全防护。
从 oidc-provider 迁移
若从已废弃的 oidc-provider 插件迁移,且存在不支持 PKCE 的机密客户端:
- 旧客户端可针对单个客户端设置
require_pkce: false。 - 新客户端注册应始终开启 PKCE(默认)。
- 逐步淘汰不支持 PKCE 的客户端。
- 监控使用了
require_pkce: false的客户端以便规划迁移。
安全考虑
PKCE 防止授权码截获。即使机密客户端使用 client_secret 认证,PKCE 依旧提供额外安全保障:
- 防御深度:多层防护
- 防止误配置:密钥泄露风险降低
- 符合未来标准
仅在遗留兼容绝对必要时关闭机密客户端的 PKCE。
组织
OAuth 客户端注册时绑定用户或 reference_id,且不可变。
若使用 组织插件,请确保在新建客户端时,激活会话中的 activeOrganizationId 被设置。
oauthProvider({
clientReference: ({ session }) => {
return (session?.activeOrganizationId as string | undefined) ?? undefined;
},
})具体设置用户权限和角色见 Claims。
客户端 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:应用客户端密钥的存储方式。仅当
disableJwtPlugin: true时,客户端密钥以encrypted方式存储。 - storeTokens:令牌值的存储方式,主要指会话刷新令牌和不透明访问令牌。
限流
OAuth 提供者内置所有 OAuth 端点限流,防止滥用和拒绝服务攻击。
限流规则为按 IP 及端点。每个客户端 IP 对每个端点均有独立的限流计数器,重置窗口时间后复位。
此限流仅在 Better Auth 全局限流开启时生效。默认限流仅生产环境生效。详见 限流。
默认限制:
| 端点 | 时间窗口 | 最大请求数 |
|---|---|---|
/oauth2/token | 60秒 | 20 次 |
/oauth2/authorize | 60秒 | 30 次 |
/oauth2/introspect | 60秒 | 100 次 |
/oauth2/revoke | 60秒 | 30 次 |
/oauth2/register | 60秒 | 5 次 |
/oauth2/userinfo | 60秒 | 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,但返回的访问令牌依然可通过/oauth2/userinfo获取用户数据 - 机密客户端的
id_token使用其client_secret签名
oauthProvider({
disableJwtPlugin: true,
})逐对(Pairwise)主题标识符
默认情况下,令牌中的 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 的主机(sector identifier)和用户 ID,使用 pairwiseSecret 做 HMAC-SHA256 生成。
因此:
- 两个不同 host 的客户端对同一用户给出不同的
sub - 相同 host 的两个客户端对同一用户给出相同的逐对
sub(遵循 OIDC 核心 8.1 节) - 同一个客户端对某用户总是给出同样的
sub(确定性)
逐对 sub 出现在:
id_token/oauth2/userinfo响应- 令牌核查 (
/oauth2/introspect)
JWT 访问令牌始终使用真实用户 ID 作为 sub,因资源服务器可能需要直接查询用户信息。
限制:
- 不支持
sector_identifier_uri。逐对客户端所有redirect_uris必须共享同一主机。跨主机注册会被拒绝。 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 服务器本身也是机密客户端:
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 或对话时动态输入公有 client_id。
处理 MCP 错误
必须指定特定 audience 进行验证,默认为所有 validAudiences 或 baseUrl。
- 使用客户端
verifyAccessToken函数
参考 验证 示例。
- 若有 auth 配置,可用客户端
verifyAccessToken自动决定端点
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",
}
}
);
// ...后续操作
}- 使用
mcpHandler辅助函数
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:不透明访问令牌前缀。若已部署,使用
generateOpaqueAccessToken实现。 - refreshToken:刷新令牌前缀。若已部署,使用
generateRefreshToken实现。 - clientSecret:客户端密钥前缀。若已部署,使用
generateClientSecret实现。
优化提示
为提升查询性能,数据库适配器可将 oauthClient 表中的 client_id 字段映射为 id。注意 id 应支持 UUID 和 URL 格式的字符串。
迁移
自 OIDC Provider 插件 迁移
配置变更
idTokenExpiresIn默认为10 小时(之前通过accessTokenExpiresIn为 1 小时)refreshTokenExpiresIn默认为30 天(之前为 7 天)advertisedMetadata(之前为metadata)不再支持修改元数据字段,防止误配置。clientRegistrationDefaultScopes(之前defaultScope)改为数组格式,非空格分隔字符串consentPage现在必须设置getConsentHTML移除,改用consentPage,因原始 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 或按新设置方式存储。示例函数:
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(boolean)字段。迁移规则: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),兼容旧机密客户端需设置为false metadata字段拆分存储,OIDC 插件未用,OAuth 插件可能会用
表:oauthAccessToken
方案一(简单):
可选择不转移此表,影响较小。用户需重新登录。删除现有 oauthAccessToken 表即可。
方案二(复杂):
全表迁移(可能需先克隆一份 oauthAccessToken 到 oauthRefreshToken)。
- 将带有
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 端点由 /mcp 迁移至 /oauth2:
/oauth2/authorize(之前/mcp/authorize)/oauth2/token(之前/mcp/token)/oauth2/register(之前/mcp/register)/mcp/get-session删除,改用/oauth2/introspect/.well-known/oauth-protected-resource删除,使用mcpHandler辅助或手动调用服务器api.oAuth2introspectVerify或资源客户端verifyAccessToken- 数据库变更同 From OIDC Provider Plugin 部分。