OAuth 2.1 提供者
一个更好的认证插件,使您的认证服务器能够作为 OAuth 2.1 提供者运行。
OAuth 2.1 Provider 插件会将您的 Better Auth 服务器转换为 OAuth 授权服务器。应用程序可以通过授权码流程请求用户访问权限,而服务可以使用客户端凭证。当客户端还需要 OpenID Connect(OIDC)身份声明时,请添加 openid scope。
默认配置会选择更安全的协议行为,包括为公有客户端启用 PKCE,以及精确匹配重定向 URI。从安装开始,然后仅启用客户端所使用的授权方式和注册路径。
主要功能
- **OAuth 安全配置:**遵循 OAuth 2.1 实践,并包含 RFC 9207
iss参数,以防止授权服务器混淆攻击 - **OpenID Connect:**签发 ID 令牌、提供 UserInfo,并在客户端请求
openid时支持 RP 发起的注销 - **客户端注册:**支持管理员管理的客户端、第一方受信任客户端,以及可选的动态客户端注册
- **公有客户端和机密客户端:**根据
token_endpoint_auth_method推导身份验证方式;对于无法安全保存密钥的客户端,请使用"none" - **资源绑定的访问权限:**为受保护资源签发令牌,支持核查和撤销,并通过 JWT 插件的
/jwks端点公开签名密钥 - **授权提示:**支持同意和账户选择提示
- **MCP 组合:**当受保护资源是 MCP 服务器时,请使用 MCP 插件
支持的授权方式
- **
authorization_code:**使用 S256 PKCE 交换用户授权码 - **
refresh_token:**通过offline_accessscope 更新访问权限 - **
client_credentials:**签发机器到机器的访问令牌 - **
device_code:**为 CLI 和输入受限的客户端添加可选的设备授权流程
client_credentials 默认拒绝访问。客户端用户委托的 scope 元数据永远不会授权机器访问。管理员必须通过管理创建或更新端点分配非空的 client_credentials_scopes 值,并且 clientPrivileges 必须明确批准 configure-client-credentials-scopes 操作。分配的值既是可请求 scope 的最大集合,也是令牌请求省略 scope 时使用的默认值。DCR、CIMD 和用户管理的注册可以声明该授权方式,但无法分配此服务器拥有的 scope 上限。
安装
挂载插件
将 OAuth Provider 插件添加到您的 auth 配置中。有关如何配置插件,请参见配置章节。
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 端点
Better Auth 会自动通过 auth handler 提供 OAuth Authorization Server 元数据和 OpenID Connect 发现元数据。如果您的框架只会将请求转发到 catch-all auth 路由,请确保 issuer 元数据 URL 能到达 auth.handler。
- OAuth Authorization Server 元数据可在
{issuer}/.well-known/oauth-authorization-server和/.well-known/oauth-authorization-server/[issuer-path]两处获得。 - 使用
openidscope 时,OpenID Connect 发现元数据可在{issuer}/.well-known/openid-configuration获得。 - 如果您使用资源服务器(例如用于 MCP),请将 OAuth Protected Resource 元数据端点添加到接收访问令牌的 API 中。
创建您的第一个 OAuth 客户端
创建一个机密客户端:
const client = await auth.api.createOAuthClient({
headers,
body: {
redirect_uris: [redirectUri],
}
});
console.log(client); // 如果您愿意,可以将 `client_id` 添加到 `cachedTrustedClients`要创建没有客户端密钥的公有客户端,请设置 token_endpoint_auth_method: "none"。
客户端插件
两个客户端插件分别覆盖不同的角色。当您的应用启动授权流程时,添加 OAuth client;当您的 API 验证访问令牌时,添加 resource client。
OAuth 客户端
OAuth client 将 Web 或原生应用连接到授权服务器。
import { createAuthClient } from "better-auth/client";
import { oauthProviderClient } from "@better-auth/oauth-provider/client"
export const authClient = createAuthClient({
plugins: [
oauthProviderClient(),
],
});资源客户端
resource client 运行在接收访问令牌的 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 客户端身份验证能力与应用拓扑是相互独立的:
- **公有客户端:**无法安全保存客户端密钥,并使用
token_endpoint_auth_method: "none" - **机密客户端:**在令牌端点使用已注册的方法进行身份验证,例如
client_secret_basic、client_secret_post或private_key_jwt - 应用类型:
application_type可以是web或native,仅控制重定向 URI 验证。它不决定客户端是公有客户端还是机密客户端
获取客户端信息
获取特定用户或组织所有的客户端信息,使用以下端点:
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要更新的字段
此端点限制如下:
- 创建后无法更改
token_endpoint_auth_method。创建时选择的方法决定客户端是否具有凭证能力 - 无法更新客户端密钥。要轮换
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 同意
对于所有非受信任客户端(尤其是无 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 只能提供一次,请确保用户保存它。公有客户端只会收到一个 client_id。
配置
启用客户端注册请在 BetterAuth 配置中设置 allowDynamicClientRegistration: true。
oauthProvider({
allowDynamicClientRegistration: true,
// ... 其他选项
})要在没有 Better Auth 会话的情况下启用开放客户端注册,还需在 auth 配置中设置 allowUnauthenticatedClientRegistration: true。公有客户端使用 token_endpoint_auth_method: "none" 注册。机密客户端会在注册响应中收到一次性的 client_secret。
对于 MCP 公有客户端身份,请使用客户端 ID 元数据文档。MCP 2026-07-28 规范弃用动态客户端注册,改为使用 CIMD;DCR 仍会为向后兼容而受到支持。
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 注册部分。
application_type(OIDC Registration §2)省略时默认为 web。web 客户端在非回环主机上要求使用 https 重定向 URI。native 客户端可以使用已声明的 https URI、仅位于 localhost、127.0.0.1 或 [::1] 且端口任意的 http 回环 URI,或者使用带有反向域名 scheme 的无 authority 私有用途 URI,例如 com.example.app:/callback(RFC 8252 §7)。回环主机匹配使用原始 URI authority,因此 127.1 等替代的数字 IPv4 拼写会在 URL 规范化之前被拒绝。Better Auth 还会拒绝凭证、片段、格式错误的 scheme,以及 file: 和 mailto: 等保留 scheme;同时拒绝回环 https 和可路由主机的 http 重定向。
应用类型与客户端身份验证相互独立。例如,native 客户端可以使用 client_secret_post,而 web 客户端可以使用 token_endpoint_auth_method: "none"。身份验证能力仅根据 token_endpoint_auth_method 推导;已移除的 type 和 public 元数据字段不会被接受或返回。
MCP 2026-07-28 规范要求 MCP 客户端发送适当的 application_type。客户端 ID 元数据文档可以省略该字段;Better Auth 会将该省略存储为 null,并根据 web 和 native 形式的安全并集验证重定向。
请注意,以下参数尚不支持:
sector_identifier_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",因为这存在安全漏洞 - 所有授权响应(成功和错误)都包含用于 issuer 验证的
iss参数(RFC 9207) - 使用
resource指示符将令牌限制到某个资源(RFC 8707)
状态(State)
客户端应发送 state 值,以缓解跨站请求伪造(CSRF)攻击。其原理是确保客户端只响应由客户端最初发起的请求。
客户端生成状态值,并存储在安全、HTTP-only Cookie 或数据库等处。
授权服务器接受不带 state 的请求,以兼容 OAuth 和 OpenID Connect;当请求提供 state 时,服务器会将其原样返回。Better Auth 的客户端辅助方法会为您生成并验证 state。
代码挑战
代码挑战用于保护授权端点返回的授权码。
其通过从代码验证器派生代码挑战,并通过 PKCE(Proof Key for Code Exchange) 发送至授权服务器。
在 redirect_uri 回调时,客户端比对返回状态与初始状态是否匹配,然后使用 authorization_code 授权方式和原始代码验证器在令牌端点交换令牌。
令牌端点
默认情况下,令牌端点支持为以下授权方式提供令牌:
- "authorization_code"
- "client_credentials"
- "refresh_token"
客户端身份验证方法
令牌端点支持以下客户端身份验证方法:
client_secret_basic——通过 HTTP Basic Auth 标头发送客户端凭证(默认)client_secret_post——在请求正文中发送客户端凭证private_key_jwt——客户端使用签名 JWT 断言进行身份验证(RFC 7523)none——公有客户端(要求 PKCE)
重要说明:
- 启用 JWT 插件时(默认),发送
resource会生成一个 JWT 访问令牌,并将所选资源放入aud声明 - 设置
disableJwtPlugin: true时,访问令牌仍为不透明令牌。请求的资源仍会绑定到令牌,并通过/oauth2/introspect和customAccessTokenClaims(通过resources)公开
令牌端点错误遵循 OAuth 错误分类:缺少必需请求字段时返回 invalid_request,客户端身份验证失败时返回 invalid_client,授权方式无效或不匹配时返回 invalid_grant。机密客户端必须使用其注册的 token_endpoint_auth_method。client_secret_post 身份验证失败时返回 400;client_secret_basic 身份验证失败时返回带有 Basic WWW-Authenticate challenge 的 401。
DPoP 发送方约束令牌
Better Auth 支持 RFC 9449 定义的持有证明(DPoP)。客户端可以通过注册时设置 dpop_bound_access_tokens: true、在授权请求中发送 dpop_jkt,或请求配置了 dpopBoundAccessTokensRequired: true 的资源,来请求绑定 DPoP 的令牌。
当令牌绑定到 DPoP 时:
- 令牌端点要求有效的
DPoP证明 JWT - 令牌响应返回
token_type: "DPoP" - JWT 访问令牌包含
cnf.jkt;不透明访问令牌和刷新令牌会持久化相同的 JWK thumbprint - 刷新令牌轮换要求使用相同密钥的 DPoP 证明
- 资源请求必须使用
Authorization: DPoP <access_token>,并发送包含访问令牌哈希(ath)的DPoP证明
oauthProvider({
dpop: {
proofMaxAgeSeconds: 300,
signingAlgorithms: ["ES256", "EdDSA"],
},
resources: [
{
identifier: "https://api.example.com",
dpopBoundAccessTokensRequired: true,
},
],
})授权服务器元数据会公开 dpop_signing_alg_values_supported。资源元数据会公开相同的证明算法;当必须使用 DPoP 时,还会公开 dpop_bound_access_tokens_required。
私钥 JWT 身份验证
使用 private_key_jwt 时,客户端通过使用私钥签名 JWT,而不是使用共享密钥进行身份验证。服务器使用客户端注册的公钥(JWKS)验证签名。
要注册 private_key_jwt 客户端,请通过 jwks 或 jwks_uri 提供客户端的公钥:
const response = await auth.api.adminCreateOAuthClient({
headers,
body: {
redirect_uris: ["https://app.example.com/callback"],
token_endpoint_auth_method: "private_key_jwt",
jwks: {
keys: [
{
kty: "RSA",
kid: "my-key-1",
alg: "RS256",
use: "sig",
n: "...",
e: "...",
},
],
},
},
});jwks 和 jwks_uri 是通用的 OIDC 客户端密钥元数据,两者互斥。内联 jwks 必须是 RFC 7517 JWK Set 对象,包含非空的 keys 数组,且其中只能包含公有非对称签名密钥;裸密钥数组会被拒绝。EC 密钥必须使用 P-256、P-384 或 P-521;OKP 密钥必须使用 Ed25519。密钥可以省略 alg。如果提供了 alg,则必须是受支持的 private_key_jwt 算法,并且与密钥类型和曲线匹配。使用 jwks_uri 时,它必须是指向公有(非私有)主机的 HTTPS URL,并且必须返回相同形状的 JWK Set 对象。
交换令牌时,客户端发送 client_assertion JWT,而不是 client_secret:
POST /api/auth/oauth2/token
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code
&code=AUTH_CODE
&redirect_uri=https://app.example.com/callback
&client_id=CLIENT_ID
&client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer
&client_assertion=eyJhbGciOiJSUzI1NiIs...断言 JWT 必须包含:
| Claim | Requirement |
|---|---|
iss | Must be the client_id |
sub | Must be the client_id |
aud | Must contain either the URL of the endpoint receiving the assertion or the OpenID Provider issuer. Use a string or an array containing at least one accepted value |
exp | Required, must not exceed assertionMaxLifetime from now |
jti | Required, must be unique (single-use) |
iat | Optional, but if present must not be older than assertionMaxLifetime |
支持的签名算法:RS256、RS384、RS512、PS256、PS384、PS512、ES256、ES384、ES512、EdDSA。
授权码授权方式
授权码授权方式允许客户端获取用户访问令牌,并可选择获取刷新令牌(通过 "offline_access" scope)。
客户端凭证授权方式
该方式允许客户端获取机器间访问令牌。
刷新令牌授权方式
该方式允许客户端刷新访问令牌,无需用户再次登录。
当前实现为每次刷新请求发放新的刷新令牌。
设置 refreshTokenReuseInterval 可允许轮换后的刷新令牌在短时间内重复使用,并接收相同的令牌响应。这样,客户端可以从重复刷新请求、响应丢失或重试中恢复,而不会生成另一对令牌。
oauthProvider({
refreshTokenReuseInterval: 30, // seconds
})默认值为 0,保持严格的重放检测。在该时间间隔内,仅当重复使用的刷新令牌来自同一客户端,且请求解析出的有效 scope、请求资源和发送方约束相同(例如使用相同的 DPoP 密钥)时,Better Auth 才会重放缓存的响应。时间间隔内发生不匹配时会返回 invalid_grant,但不会使整个令牌族失效;时间间隔过后,使用旧刷新令牌会被视为重放,并使刷新令牌族失效。
缓存的响应会加密存储在已消费的刷新令牌行中,并包含替换后的刷新令牌。每次重放响应时,都会根据缓存的 expires_at 重新计算 expires_in。
设备码授权方式
设备授权方式(RFC 8628)允许输入受限的客户端(CLI、智能电视、IoT)获取 OAuth 访问令牌。在 oauthProvider() 旁添加 oauthDeviceAuthorization(),以注册 urn:ietf:params:oauth:grant-type:device_code 令牌授权方式并公开 device_authorization_endpoint。该集成会添加 OAuth 客户端绑定和 RFC 8707 资源字段;独立的设备授权安装保持不变。设备会在 /device/code 请求代码,用户批准代码,然后客户端轮询 /oauth2/token 获取完整的 OAuth 令牌。请参见授权 CLI 调用 API。
在 /device/code 中,机密客户端使用其注册的方法进行身份验证。使用 client_secret_basic 的客户端可以发送 Authorization: Basic ... 标头,并省略正文中的 client_id;使用 client_secret_post 的客户端发送 client_id 和 client_secret;使用 none 的公有客户端发送 client_id。无论顺序如何,空的 client_id、scope、user_id 和身份验证参数都会被视为省略。重复的非空客户端标识、基础请求或身份验证参数,以及多个身份验证方法都会返回 invalid_request;而重复的 resource 参数仍受支持。
未知的 OAuth 客户端 ID 不会被静默地作为独立请求处理。只有当 oauthDeviceAuthorization({ validateClient }) 明确接受该 ID 时,才可使用独立回退。格式错误的 resource 输入仅在 resource 是失败的扩展字段时返回 invalid_target;如果基础请求字段也无效,响应则返回 invalid_request。
同意端点
接受或拒绝用户对某组权限的同意。注意拒绝某些权限时,会取消本次授权的同意,之前已有的其他同意依然有效。要移除同意,请删除该用户对应客户端的 "oauthConsent"。
const { data, error } = await authClient.oauth2.consent({ accept, // required scope, claims,});acceptboolean,required接受或拒绝用户对一组 scope 的同意
scopestring,以空格分隔的已接受 scope 列表。若未提供,则接受最初请求的 scope。
claimsstring | Record<string, unknown>,接受的 OIDC claims 请求对象。如果未提供,则接受最初请求的 claims。
继续端点
注册页面必须先 配置 以进行注册步骤。 账户选择页面必须先 配置 用于账户选择。 登录后页面必须先 配置 用于登录后操作。
const { data, error } = await authClient.oauth2.continue({ selected, created, postLogin,});selectedboolean,确认已选择账户。
createdboolean,确认已注册账户
postLoginboolean,确认登录后活动已完成
核查端点
符合 RFC7662 规范的令牌核查端点。
此端点提供所给令牌的详细信息。如令牌绑定于会话,确保该会话为 active。
使用 customAccessTokenClaims 中的 resources 字段,根据受保护资源添加声明。
谁可以核查令牌
调用方必须以已注册客户端的身份进行身份验证(RFC 7662 §2.1)。之后,它可以在以下两种情况下核查令牌:
- 该客户端签发了令牌,或
- 该客户端是与令牌资源之一关联的资源服务器
第二种情况很常见:前端客户端获取令牌,而您的 API 对其进行验证。要完成此配置,请将 API 注册为资源,并将客户端与其关联。带有 resources 字段的动态客户端注册会自动创建关联;否则,请向 oauthClientResource 表添加一行。
任何其他已身份验证的客户端都会获得 { active: false },这与未知或已过期令牌的响应相同,因此核查不能用来探测有效令牌。刷新令牌的限制更严格:只有请求该刷新令牌的客户端才能核查它。
返回哪些声明
对于同一授权方式,不透明访问令牌和 JWT 访问令牌返回相同的声明:您的 customAccessTokenClaims 和任何按资源定义的 customClaims。服务器拥有保留声明名称(iss、sub、aud、scope、auth_time 等)。如果回调返回其中任何名称,该声明会被丢弃,而不会覆盖服务器的值。
两种格式有一个区别。不透明令牌会在每次调用时重新计算,因此核查会显示其当前状态:更改资源的声明后,下一次核查会反映该更改。JWT 携带签发时签名的内容,之后不会改变。当您希望声明在签发时固定时,请通过传递 resource 请求 JWT;当您希望获取当前状态时,请使用不透明令牌。
撤销端点
符合 RFC7009 规范的撤销端点。
端点的行为取决于令牌类型:
- 不透明
access_token:立即从数据库中删除。同一授权方式产生的refresh_token仍然有效 refresh_token:删除其签发的所有access_token,并移除该refresh_token,因此它无法再签发令牌- JWT
access_token:无法在服务器端撤销。JWT 是自包含的,且从未被存储,因此没有可删除的内容。仍能通过此服务器验证的令牌会返回400 unsupported_token_type(RFC7009 §2.2.1),明确表示没有发生服务器端撤销。已经过期或携带被 OAuth 资源模型拒绝的 audience 的 JWT,会被视为无效令牌并返回200no-op
由于 JWT access_token 无法单独撤销,请做好规划:
- 缩短其生命周期。使用
accessTokenExpiresIn,并为client_credentials令牌使用m2mAccessTokenExpiresIn - 要在会话中途切断用户访问,请结束会话(退出登录、管理员撤销或后端通道注销)。携带会话 ID(
sid)的 JWTaccess_token会在/oauth2/introspect中报告为active: false,并且会在会话结束后被/oauth2/userinfo拒绝,即使令牌尚未过期 client_credentialsJWTaccess_token没有可结束的会话,因此短过期时间是唯一可用的控制手段
会话结束端点
OpenID Connect RP-Initiated Logout 1.0 允许依赖方请求提供者结束用户会话。
该端点适用于注册时设置 enable_end_session: true 的客户端。它接受 GET 查询或 application/x-www-form-urlencoded POST 正文中的注销参数。Better Auth 生成的客户端会以 JSON 发送相同的参数。经过验证的 id_token_hint 可以立即结束其引用的会话。没有有效提示时,Better Auth 会要求用户确认后再结束当前会话。当提示引用的会话与浏览器会话不同时,同样需要确认。
Better Auth 仅在 post_logout_redirect_uri 与已注册 URI 精确匹配时才会在注销后重定向。它只会向该经过验证的重定向添加 state。未注册或被修改查询参数的 URI 不会触发重定向。浏览器导航会收到 HTML 格式的确认、成功和错误页面。API 调用方会收到协议响应。
仅为信任的客户端启用此端点:
import { auth } from "@/lib/auth"
await auth.api.adminCreateOAuthClient({
headers,
body: {
redirect_uris: [redirectUri],
enable_end_session: true,
}
});Better Auth 通过 origin 和 CSRF 检查以及短期签名 Cookie 保护确认流程。当存在当前会话时,Cookie 会与该会话绑定。成功的浏览器注销会返回已注销页面。会话删除会继续通过正常 hooks,包括向已注册依赖方发送后端通道注销通知。
后端通道注销
后端通道注销是 RP 发起注销对应的服务器到服务器机制:当用户会话在 OP 处结束(退出登录、/oauth2/end-session、管理员撤销等)时,OP 会向每个已注册的依赖方 POST 一个签名的 Logout Token,使其能够终止自身的会话状态并撤销绑定的 API 访问权限。
要为客户端启用该功能,请注册 backchannel_logout_uri:
await auth.api.adminCreateOAuthClient({
headers,
body: {
redirect_uris: [redirectUri],
enable_end_session: true,
backchannel_logout_uri: "https://rp.example.com/logout/backchannel",
backchannel_logout_session_required: true,
}
});当 backchannel_logout_session_required 为 true 时,RP 要求每个 Logout Token 都包含 sid 声明。OP 发送的每个 Logout Token 本身都已包含 sid,因此这类客户端始终可以正常使用。
backchannel_logout_uri 会在注册时验证。每个客户端都必须使用绝对、无凭证、公有的 https URL,且不能包含片段;即使在本地开发环境中,也会拒绝回环和私有目标。保留主机、隧道主机(NAT64、6to4、IPv4 映射 IPv6)或云元数据名称也会被拒绝。此主机检查是语法检查:它不会解析 DNS,因此如果需要防止 DNS 重绑定,请固定或重新检查解析后的地址。相同的主机检查也会保护客户端的 jwks_uri;后者还无条件要求使用 https 和受信任的 origin。验证失败的 URI 会以 invalid_client_metadata 拒绝。CIMD 文档无法注册后端通道注销,因为其发现传输是仅支持 GET 的信任边界。
OP 会枚举具有绑定到即将结束会话的活动令牌的客户端,并行向每个客户端 POST 一个 logout_token(每个 RP 超时 5 秒,依据规范 §2.5 不重试)。之后,它会撤销该会话的令牌:
- **刷新令牌:**不带
offline_access的刷新令牌会被撤销;带有offline_access的刷新令牌会被保留,使长期 API 访问能够在浏览器会话结束后继续存在(规范 §2.7) - **访问令牌:**绑定到会话的访问令牌会作为额外强化措施被撤销;§2.7 本身只涉及刷新令牌。核查和
/oauth2/userinfo也会将会话已结束的任何令牌视为非活动状态,因此不再仅依赖存储的标志
Logout Token 携带 §2.4 中的声明(iss、aud、iat、exp、jti、events,以及 sub 和 sid),受保护标头中的 typ: logout+jwt,且不包含 nonce。其生命周期上限为 120 秒,遵循 §4 中缩短重放窗口的安全指导。它使用与 ID Token 相同的密钥签名,因此任何通过您的 JWKS 验证 ID Token 的 RP 都可以在无需额外配置的情况下验证 Logout Token。
后端通道注销要求使用 jwt 插件。在 disableJwtPlugin: true 时注册 backchannel_logout_uri 会以 invalid_client_metadata 拒绝。
如果配置了 advanced.backgroundTasks.handler,传输会通过该处理器运行(Vercel waitUntil、Cloudflare ctx.waitUntil),因此响应缓慢的 RP 不会延迟退出登录。没有处理器时,该流程会在退出登录响应返回前以内联方式完成:在持久服务器上可靠,但 RP 响应缓慢时可能增加延迟。在无服务器运行时请配置处理器。
OP 会在 .well-known/openid-configuration 和 .well-known/oauth-authorization-server 上公开 backchannel_logout_supported: true 和 backchannel_logout_session_supported: true。RP 会在动态客户端注册期间使用这些字段来决定是否注册 backchannel_logout_uri。
UserInfo 端点
UserInfo 端点提供符合 OIDC 的用户信息。该端点位于 /oauth2/userinfo,要求有效的访问令牌,且至少具有 openid scope。过期、已撤销或绑定到已结束会话的访问令牌会根据 RFC 6750 以 invalid_token(401)拒绝。
// 客户端调用 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
该端点还支持 OIDC claims 请求参数。claims.userinfo 下列出的声明会在 Better Auth 或您的自定义声明逻辑能够提供时,添加到基于 scope 请求的声明中,并限制为 claims_supported 中公开的名称。缺失的值会从 JSON 响应中省略,而不是返回 null 或空字符串。
通过 claims.userinfo 进行的逐声明选择适用于不透明访问令牌。JWT 访问令牌会根据其已授予的 scope 解析 UserInfo 声明,因此,如果单独请求的声明没有对应的基础 scope,该声明会被省略(OIDC Core §5.5.1)。当客户端需要某个没有任何已授予 scope 覆盖的声明时,请请求对应的基础 scope,或签发不透明访问令牌。
customUserInfoClaims 函数会接收用户对象、请求的 scope 数组、请求的 UserInfo 声明名称以及传入的访问令牌,从而允许您向响应添加额外信息。
Well-Known
OpenID 配置
在 {issuer}/.well-known/openid-configuration 提供 OpenID Connect 发现元数据。
要求带有 openid 范围。
OAuth Provider 插件会自动通过 Better Auth handler 提供此端点。如果您未设置自定义 issuer,则 issuer 路径就是您的 basePath,例如 /api/auth。
对于带路径的 issuer,OpenID Connect 使用路径追加。例如,issuer https://example.com/api/auth 使用 /api/auth/.well-known/openid-configuration。
如果您的框架路由未将此 URL 转发到 auth.handler,请在 issuer 路径添加路由:
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
为授权服务器提供符合 RFC 8414 的元数据。
OAuth Provider 插件会自动通过 Better Auth handler 提供以下两个带路径前缀的 issuer 别名:
{issuer}/.well-known/oauth-authorization-server/.well-known/oauth-authorization-server/[issuer-path]
例如,issuer https://example.com/api/auth 可使用 /api/auth/.well-known/oauth-authorization-server 或 /.well-known/oauth-authorization-server/api/auth。当请求到达 auth.handler 时,两者返回相同的元数据。
如果您的框架路由未将这些 URL 中的某一个转发到 auth.handler,请添加路由并调用辅助函数:
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 包提供的 verifyAccessTokenRequest 执行验证。这是推荐的资源服务器 API,因为它会验证访问令牌;当令牌绑定了 DPoP 时,还会验证请求方法、URL、Authorization: DPoP 方案、证明密钥、重放 jti 和 ath 声明。
使用 better-auth 包:
import {
requestToResourceInput,
verifyAccessTokenRequest,
} from "better-auth/oauth2";
export const GET = async (req: Request) => {
const payload = await verifyAccessTokenRequest(requestToResourceInput(req), {
verifyOptions: {
issuer: "https://auth.example.com",
audience: "https://api.example.com",
},
requiredScopes: ["read:post"], // optional
});
// ...continue
}requestToResourceInput 会从标准 Request 中读取 Authorization 和 DPoP 请求头,以及方法和 URL。如果您的框架不提供 Request,请传入包含这些字段的普通对象。
使用 oauthProviderResourceClient 插件:
import { serverClient } from "@/lib/server-client";
export const POST = async (req: Request) => {
const payload = await serverClient.verifyAccessTokenRequest(
req,
{
verifyOptions: {
issuer: "https://auth.example.com",
audience: "https://api.example.com",
},
requiredScopes: ["write:post"], // optional
}
);
// ...后续操作
}当您已经提取原始 bearer 令牌,并且明确不接受该路径上的 DPoP 绑定令牌时,仍可使用 verifyBearerToken。它会拒绝 DPoP 绑定令牌,因此对于任何可能接收这类令牌的端点,优先使用 verifyAccessTokenRequest。
DPoP 验证会将证明中的 htu 与请求 URL 进行比较,并通过 jti 存储来拒绝重放的证明。两个部署细节非常重要:
- **位于代理之后:**证明会根据
request.url进行检查,因此终止 TLS 或重写路径的代理必须转发外部可见的方案、主机和路径,否则合法证明会被拒绝。 - 重放保护:
verifyAccessTokenRequest默认使用内存中的jti存储,该存储仅适用于单实例。对于多实例或无服务器资源服务器,请传入共享的dpop.replayStore,例如createDpopReplayStore(ctx.context.internalAdapter)。该存储会将证明记录到数据库支持的验证存储中(提供者自身的端点和requireMcpAuth默认使用该存储)。它要求使用数据库支持的验证存储;仅使用辅助存储的部署会拒绝 DPoP 请求,而不是跳过重放保护。
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 重定向时签名的查询参数。包含所有参数(包括自定义参数)均被签名和验证。
如果您的登录页面包含自定义页面查询参数,它们可能会共存于 URL 中,但不应添加到已签名的 oauth_query 中。客户端插件仅转发已签名重定向中声明的参数。
如果使用客户端插件 oauthProviderClient,则 oauth_query 参数会自动发送至所有需要的端点。若是自定义登录端点,则需手动在请求体中的 oauth_query 字段添加带签名的查询,内容仅包括签名查询参数。
登录屏幕
用户跳转到 OIDC 提供者进行认证时,若未登录,会跳转至登录页面。可通过初始化时提供 loginPage 选项自定义登录页。
oauthProvider({
loginPage: "/sign-in"
})无需额外处理,插件会在新会话创建后自动继续授权流程。
同意屏幕
用户跳转到 OIDC 提供者认证时,可能需授权应用访问数据。
注意:具有 skip_consent: true 的受信任客户端将完全跳过同意屏幕,为第一方应用提供无缝体验。
oauthProvider({
consentPage: "/consent"
})插件会将用户重定向至指定路径,并附带 client_id、scope 以及(在请求时)claims 查询参数。使用 scope 和 claims.userinfo 在同意页面中显示完整的访问请求。用户同意后,可以调用 oauth2.consent 完成授权。
import { authClient } from "@/lib/auth-client"
const claims = new URLSearchParams(window.location.search).get("claims");
const requestedClaims = claims ? JSON.parse(claims) : undefined;
const res = await authClient.oauth2.consent({
accept: true,
// optional scopes accepted (if not sent, accepted scopes matches the original request)
scope: "openid profile email",
// optional claims accepted (if not sent, accepted claims match the original request)
claims: requestedClaims
});注册账户屏幕
客户端通过 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",
]),
})资源
该 OAuth 服务器为其签发访问令牌的受保护资源列表。每个标识符都是 RFC 8707 resource 参数值,并在签发 JWT 访问令牌时成为 JWT 的 aud 声明。
oauthProvider({
resources: [
"https://api.example.com",
{
identifier: "https://api.example.com/mcp",
allowedScopes: ["mcp:read", "mcp:write"],
accessTokenTtl: 300,
},
]
})当资源策略需要在运行时更改时,请使用管理资源端点。
动态注册可以在与客户端记录相同的事务中,将受保护资源附加到新客户端。clientRegistrationDefaultResources 会将服务器拥有的默认资源添加到每次注册中。clientRegistrationAllowedResources 列出客户端可以请求的其他资源;有效允许列表是默认列表和允许列表的并集。默认资源排在前面,重复项会被移除,并且每个配置值都必须存在于 resources 中。
oauthProvider({
resources: [
"https://api.example.com/default",
"https://api.example.com/optional",
],
clientRegistrationDefaultResources: [
"https://api.example.com/default",
],
clientRegistrationAllowedResources: [
"https://api.example.com/optional",
],
})显式资源请求默认采用封闭策略:当两个注册资源选项都省略时,不允许请求任何资源。有效允许列表之外的请求资源会因 invalid_target 被拒绝;缺失和禁用的资源也会被拒绝。具有已解析资源的注册会返回最终的 resources 列表,并以原子方式创建对应的 oauthClientResource 关联。
范围
Scopes 授权客户端访问指定资源。目前默认支持:
openid: 返回用户的 ID(sub声明)。profile: 从 UserInfo 返回 name、picture、given_name、family_nameemail: 从 UserInfo 返回 email 和 email_verifiedoffline_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 声明应尽可能使用命名空间,以避免潜在的未来冲突。在授权码流程中,profile、email 和 claims.userinfo 请求 UserInfo 声明;除非通过 customIdTokenClaims 添加,否则它们不会添加到 ID token 中。
对于协议拥有的 ID token 声明,customIdTokenClaims 采用追加方式。iss、sub、aud、令牌生命周期声明、nonce、sid、哈希声明、auth_time、acr、amr 和 azp 等保留名称会在签发时被移除,并记录警告日志。应用特定数据请使用诸如 https://example.com/org 的命名空间声明名称。
customIdTokenClaims 和 customUserInfoClaims 中添加的声明应被添加到 advertisedMetadata.claims_supported,以便客户端验证收到的声明。以下示例包含基础声明以及 locale 和 https://example.com/org。
提示:这两个函数也可以抛出错误,例如用户不再是组织成员或无请求权限。
oauthProvider({
// 附加声明到 id tokens
customIdTokenClaims: ({ user, scopes, metadata }) => {
return {
locale: "en-GB",
};
},
// Attach claims to access tokens
customAccessTokenClaims: ({ user, scopes, referenceId, resources, metadata }) => {
return {
"https://example.com/org": referenceId,
"https://example.com/resources": resources,
"https://example.com/roles": ["editor"],
};
},
// Additional user info claims
customUserInfoClaims: ({ user, scopes, requestedClaims, jwt }) => {
return {
locale: "en-GB",
...(requestedClaims.includes("website")
? { website: "https://example.com" }
: {}),
};
},
})自定义令牌响应字段
与上述声明回调(在 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 天refreshTokenReuseInterval默认为 0 秒codeExpiresIn默认为 10 分钟assertionMaxLifetime默认为 5 分钟——private_key_jwt客户端断言允许的最长生命周期
访问令牌还支持基于 scopes 单独设置更短过期(以最早过期时间为准,未设置的使用默认)。注意:该时间应低于默认 accessTokenExpiresIn 和 m2mAccessTokenExpiresIn。
oauthProvider({
scopeExpirations: {
"write:payments": "5m",
"read:payments": "30m",
},
})注册
动态客户端注册
动态注册允许授权注册公有和机密客户端。
oauthProvider({
allowDynamicClientRegistration: true,
})未经身份验证的客户端注册还允许客户端在没有授权标头的情况下进行注册。公共客户端注册时使用 token_endpoint_auth_method: "none"。机密客户端会在注册响应中收到一次性 client_secret。对于 MCP 认证支持,推荐通过 CIMD 插件 实现,该插件可以维护公共客户端的身份。
oauthProvider({
allowDynamicClientRegistration: true,
allowUnauthenticatedClientRegistration: true,
})对于 MCP 公共客户端身份,请使用 CIMD 插件。
受保护的动态客户端注册允许机器调用方在没有 Better Auth 用户会话的情况下注册公共或机密客户端。通过带外方式签发 RFC 7591 初始访问令牌,然后从 Authorization: Bearer <token> 请求头中验证该令牌。定义 validateInitialAccessToken 即可启用此路径;未定义该函数时,发送到注册端点的 Bearer 令牌会被拒绝。
import { createHash, timingSafeEqual } from "node:crypto"
const digest = (value: string) => createHash("sha256").update(value).digest()
oauthProvider({
allowDynamicClientRegistration: true,
validateInitialAccessToken: async ({ initialAccessToken, clientMetadata }) => {
// Compare in constant time; hashing both sides keeps the lengths equal.
const expected = digest(process.env.CLIENT_REGISTRATION_TOKEN ?? "")
if (!timingSafeEqual(digest(initialAccessToken), expected)) {
return false
}
return {
referenceId: "infra-provisioner",
}
},
})返回包含 referenceId 的对象,以便将应用所有权元数据附加到创建的客户端;或者返回 false 以拒绝该令牌。省略 referenceId 会创建无所有者客户端。传递给回调的 clientMetadata 是提交的请求数据,属于客户端自行声明且尚未完全验证,因此应将其视为不受信任的输入。
令牌签发、过期和撤销由您的应用负责,因为 RFC 7591 将初始访问令牌的生命周期策略留给授权服务器。这与 RFC 7592 注册管理令牌是分开的。
启用 Bearer 插件后,如果 Authorization: Bearer 值解析为有效的用户会话,则会将其作为该会话处理,而不是作为初始访问令牌。
动态客户端注册过期时间
可设置动态注册机密客户端的过期时间。默认动态注册机密客户端无过期。
oauthProvider({
allowDynamicClientRegistration: true,
clientRegistrationClientSecretExpiration: "30d",
})动态客户端注册默认范围
注册范围元数据描述客户端能够请求的范围;它不是用户授权授予。Better Auth 会将请求的 scope 验证为操作员策略的子集,然后持久化完整的、经操作员批准的能力集合,以便后续授权可以升级权限,而无需重新注册客户端。
使用 clientRegistrationDefaultScopes 设置基准能力列表。所有值都必须在 scopes 中定义。
oauthProvider({
scopes: ["reader", "editor"],
clientRegistrationDefaultScopes: ["reader"],
})使用 clientRegistrationAllowedScopes 添加能力。有效集合是两个列表的确定性去重并集。当两个选项都省略时,scopes 即为有效集合。DCR 或 CIMD 文档可以请求其中的子集,但该子集不会永久阻止后续特定操作的权限升级。
oauthProvider({
scopes: ["reader", "editor"],
clientRegistrationDefaultScopes: ["reader"],
clientRegistrationAllowedScopes: ["editor"],
})PKCE 配置
PKCE 是防止授权码被截获的一种安全机制。插件遵循 OAuth 2.1 规范,默认对所有授权码流程要求 PKCE。
默认行为
默认要求所有客户端使用 PKCE,最大安全、符合 OAuth 2.1 最佳实践。
PKCE 始终需要:
- 使用
token_endpoint_auth_method: "none"的客户端 - 带有
offline_access范围的授权请求,除非机密客户端已选择停用 PKCE,且 OIDC 请求同时包含openid和nonce
每客户端 PKCE 配置
Admin-created confidential clients can opt out of the PKCE requirement if needed for compatibility:
// Register a confidential client that doesn't support PKCE
const response = await auth.api.adminCreateOAuthClient({
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 时,需要 OIDC 请求同时包含openid和nonce
动态客户端注册 PKCE 配置
动态客户端注册不接受客户端请求中的 require_pkce。要更改动态注册机密客户端的服务器拥有默认值,请设置 clientRegistrationRequirePKCE。
oauthProvider({
allowDynamicClientRegistration: true,
clientRegistrationRequirePKCE: false,
})这仅适用于通过动态客户端注册创建的机密客户端。公共客户端仍然需要 PKCE。请求 offline_access 且不使用 PKCE 的机密 OIDC 客户端必须同时发送 openid 和 nonce。
何时使用 require_pkce: false:
- 从 OAuth 2.0 迁移的旧机密客户端不支持 PKCE
- 无法更新的后端到后端集成
- 分阶段迁移期间的临时兼容性
**建议:**尽可能保持启用 PKCE(默认),即使对机密客户端也增加安全防护。
安全注意事项
PKCE 防止授权码截获攻击。即使对使用 client_secret 的机密客户端,PKCE 也提供额外安全:
- 纵深防御:多层安全
- 防止错误配置:减少密钥意外泄露
- 面向未来:符合 OAuth 2.1 最佳实践
仅当绝对必要时(例如旧机密客户端的兼容性),才禁用 PKCE。
未经身份验证的客户端发现
某些客户端(尤其是 MCP 客户端)需要连接到您的授权服务器,而无需提前注册。OAuth Provider 插件通过两种机制支持此功能:
allowUnauthenticatedClientRegistration:允许匿名调用方访问/oauth2/register,在请求时创建客户端。机密客户端注册会收到一次性client_secret;公共客户端注册使用token_endpoint_auth_method: "none"。@better-auth/cimd:一个可选插件,允许客户端通过在 HTTPS URL 上托管元数据文档来标识自己。该 URL 本身会成为client_id;服务器会获取并验证该文档。通用发现遵循 Client ID Metadata Document draft-02,而 MCP 2026-07-28 配置明确固定使用 draft-00 的要求。
提供者扩展
OAuth companion 插件可以扩展提供者,而无需更改 OAuth Provider 核心。使用插件 init() 钩子中的 extendOAuthProvider() 添加令牌授权类型、基于断言的客户端认证方法、追加的发现元数据、令牌或 UserInfo 声明,以及客户端 ID 发现源。@better-auth/cimd 插件也使用相同的接口来提供基于 URL 的客户端发现。发现源提供一个稳定且全局唯一的 id,该 ID 会作为客户端来源持久化;还可以为该发现源拥有的资源提供 fetchClientMetadataResource,例如 CIMD 客户端的 jwks_uri。更改 ID 需要迁移所拥有的客户端记录;移除匹配的发现源会使这些客户端默认拒绝。
import type { BetterAuthPlugin } from "better-auth";
import { extendOAuthProvider } from "@better-auth/oauth-provider";
export const customOAuthExtension = () =>
({
id: "custom-oauth-extension",
init(ctx) {
extendOAuthProvider(ctx, {
grants: {
"urn:example:params:oauth:grant-type:custom": async ({
provider,
}) => {
const { client } = await provider.authenticateClient();
return provider.issueTokens({
client,
scopes: ["openid"],
tokenResponse: {
issued_token_type:
"urn:ietf:params:oauth:token-type:access_token",
},
});
},
},
metadata: () => ({
custom_grant_supported: true,
}),
});
},
}) satisfies BetterAuthPlugin;扩展贡献遵循两项规则:
- 已分派类型(
grants、clientAuthentication)在不同扩展之间必须互不相交。如果另一个扩展已经注册了某个授权类型、token_endpoint_auth_method或client_assertion_type,再次注册会在设置时被拒绝,因此贡献永远不会被静默覆盖。扩展授权类型和认证方法会自动在发现信息中公布。 - 追加类型(
metadata、claims)永远不会覆盖授权服务器核心。对于提供者已经拥有的元数据字段(issuer、token_endpoint、grant_types_supported、认证方法列表等),会保留原值;如果两个扩展都贡献同一个键,则使用第一个注册的扩展提供的值。
声明贡献者可以添加新的声明名称,但永远不会替换身份、认证上下文、RFC 9068 保留声明或其他由提供者拥有的声明。要公布扩展生成的声明名称,请设置 advertisedMetadata.claims_supported:claims_supported 由提供者拥有,不会根据贡献者自动推断。
授权类型之外的提供者能力
授权处理程序会接收一个提供者能力接口(getClient、authenticateClient、issueTokens、hashToken、validateAccessToken、requireActiveAccessToken)。如果插件需要在自己的端点中使用这些能力(例如后端信道授权端点、轮询端点),可通过 getOAuthProviderApi(ctx, opts, grantType?) 获取相同对象,从而解析客户端或验证令牌,而无需访问提供者内部实现。对于能够处理非活动载荷的内省式流程,请使用 validateAccessToken;对于应使用 OAuth bearer challenge 拒绝非活动或未知令牌的受保护资源端点,请使用 requireActiveAccessToken。在令牌端点之外签发令牌时传入授权类型;只读使用时省略该参数,此时调用 issueTokens 会抛出错误,而不会签发未标记授权类型的令牌。
要对签发的令牌添加发送方约束(RFC 7800 cnf),请将 confirmation 传递给 issueTokens,或从 clientAuthentication 策略中返回它。提供者会将其写入访问令牌的 cnf,并相应设置响应的 token_type。cnf 由授权服务器拥有,无法通过声明贡献者设置。
客户端认证义务
clientAuthentication 策略会根据自身密钥来源验证断言,并返回其证明的客户端 ID;提供者会自行解析并授权客户端记录,因此策略只证明身份,永远不会提供该记录。验证签名后,它必须执行内置 private_key_jwt 方法所执行的相同断言安全检查,否则提供者可能接受伪造或重放的断言。使用导出的 consumeClientAssertion 辅助函数,将断言绑定到端点受众、要求有限生命周期并拒绝 jti 重放:
import { consumeClientAssertion } from "@better-auth/oauth-provider";
authenticate: async ({ ctx, opts, assertion, expectedAudience }) => {
const payload = await verifyAssertionSignature(assertion); // your key source
await consumeClientAssertion(ctx, opts, {
// Scopes the replay tombstone; the same jti may recur across distinct
// methods or clients but never within one.
namespace: `urn:example:attestation:${payload.sub}`,
payload,
expectedAudience: expectedAudience!,
});
// Return only the proven client id (and an optional `confirmation`). The
// provider resolves and authorizes the client record itself.
return { clientId: payload.sub as string };
};声明优先级
三个声明接口按照固定顺序解析贡献。在这三个接口中,第三方扩展声明严格采用追加方式,而操作员自己的第一方回调可以覆盖配置文件风格的身份声明;协议拥有的身份、生命周期、绑定和认证上下文声明始终由提供者固定或保留。
| 令牌 | 顺序(从最低到最高权限) | 由提供者固定或保留 |
|---|---|---|
| 访问令牌 | extension claims.accessToken < per-issuance accessTokenClaims < customAccessTokenClaims < per-resource customClaims | 保留 RFC 9068 名称(iss、sub、aud、exp、iat、jti、client_id、scope、auth_time、acr、amr),签名前移除 |
| ID token | subject/authentication claims < customIdTokenClaims;extension 和 per-issuance idTokenClaims 会进行保留名称过滤并采用追加方式 | 保留 OIDC/JWT 名称(iss、sub、aud、exp、nbf、iat、jti、nonce、sid、at_hash、c_hash、s_hash、auth_time、acr、amr、azp)以及由 scope 派生的 UserInfo 声明名称 |
| UserInfo | scope 和 claims.userinfo 身份声明 < extension claims.userInfo(仅追加) < customUserInfoClaims | sub(最后重新固定) |
每次签发的 accessTokenClaims 仅适用于 JWT:不透明访问令牌不会持久化每次签发的声明,因此这些声明不会在内省时重新出现。必须在不透明令牌内省中可见的声明,应放在授权类型稳定的 claims.accessToken 贡献者中,内省路径会重新派生该声明。
组织
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 中声明,否则初始化失败。
Better Auth 公布 acr_values_supported: ["0"]。在 OIDC Core 中,"0" 表示认证未达到 ISO/IEC 29115 level 1。当前不支持自定义 ACR 策略。由于 acr_values 是可选的,对其他类别的请求会继续处理,并且 ID token 会报告 acr: "0"。在 OpenID Connect 请求中,如果必要的 claims.id_token.acr 请求中的 value 或 values 不包含 "0",则请求失败。
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)
当资源服务器内省签发给另一个客户端的令牌时,它会获得签发客户端所看到的 sub,而不是为资源服务器自身计算的 sub。因此,无论哪个资源服务器发起请求,同一用户对于该签发客户端始终显示为相同的 sub。
JWT 访问令牌始终使用真实用户 ID 作为 sub,因为资源服务器可能需要直接查找用户。
限制:
sector_identifier_uri尚未支持。一个客户端的所有重定向 URI 必须共享同一主机。跨主机的重定向 URI 将导致注册被拒绝。pairwiseSecret必须至少 32 个字符长。- 轮换
pairwiseSecret将更改所有成对sub值,破坏现有 RP 会话。请将密钥视为永久设置。
MCP
当 MCP 服务器是您的受保护资源之一时,请使用 @better-auth/mcp 插件。它基于此 OAuth Provider 构建,并添加 MCP 默认配置、RFC 9728 受保护资源元数据,以及返回 MCP 客户端所需授权质询的路由辅助函数。
mcp() 就是该 Better Auth 实例的 OAuth Provider,因此不要同时注册 mcp() 和 oauthProvider()。它直接接受 OAuth Provider 选项。当 MCP 路由与认证实例共享时,请使用 requireMcpAuth;当资源服务器独立运行时,请使用 createMcpProtectedRequestHandler。
MCP 插件还可以通过设备授权类型支持单独注册的 CLI。MCP 客户端继续使用由发现驱动的授权码流程,而 CLI 则通过设备授权向同一提供者请求绑定资源的令牌。请参阅为您自己的 CLI 添加设备授权。
表结构
OAuth 提供者插件新增以下表格:
OAuth 客户端表
表名:oauthClient
OAuth 刷新令牌表
表名:oauthRefreshToken
OAuth 刷新令牌表
表名:oauthAccessToken
OAuth 同意表
表名:oauthConsent
OAuth 客户端断言
表名:oauthClientAssertion
记录每个 private_key_jwt 客户端断言的 jti,使其只能使用一次。行 ID 是每个客户端断言标识符的摘要,因此重放或并发的断言会在主键上发生冲突,数据库会以原子方式拒绝它,即使跨越多个服务器进程也是如此。行会持续阻止其 ID,直到被删除;expiresAt 标记了可以安全移除的时间,因为它保护的断言已经过期。没有计划任务清理这些行,因此如果表不断增长,请使用你自己的清理机制移除过期行。
选项
前缀
可为不透明访问令牌、刷新令牌和客户端密钥添加前缀,这有助于秘密扫描工具(如 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 的字符串格式。