OAuth 2.1 Provider
一个 Better Auth 插件,可让您的认证服务器充当 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, OAuth 客户端的 client_id },});client_idstringrequiredOAuth 客户端的 client_id
获取公有客户端信息
获取公有客户端字段以在登录流程页面显示(如同意页面),使用以下端点。注意:需用户登录后方可使用。
const { data, error } = await authClient.oauth2.publicClient({ query: { client_id, // required, OAuth 客户端的 client_id },});client_idstringrequiredOAuth 客户端的 client_id
获取公有客户端预登录信息
若要在登录前获取公有客户端信息,您必须先在配置中启用该端点:
oauthProvider({
allowPublicClientPrelogin: true,
})然后,以下端点将获取公有客户端信息。
const { data, error } = await authClient.oauth2.publicClientPrelogin({ client_id, // required, OAuth 客户端的 client_id oauth_query, // required, 有效的 oauth 查询参数(使用提供的客户端时会自动发送)});client_idstringrequiredOAuth 客户端的 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, OAuth 客户端的 client_id update, // required, 要更新的字段});client_idstringrequiredOAuth 客户端的 client_id
updateOAuthClientrequired要更新的字段
此端点限制如下:
- 创建后无法更改
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 发起注销
}
});如需暂停令牌签发但不删除客户端,可通过仅限服务器调用的更新 API 设置 disabled:
await auth.api.adminUpdateOAuthClient({
headers,
body: { client_id, update: { disabled: true } },
});设置 disabled: false 可恢复签发。已禁用的客户端无法获取新令牌,introspection 也会将其现有令牌报告为非活动状态。若资源服务器在本地验证 JWT,而你希望立即断开客户端,仍需检查当前客户端或应用授权;JWT 签名在到期前仍然有效。与会话绑定的 updateOAuthClient 端点不能更改 disabled。
Rotate Client Secret
当前实现会立即轮换客户端密钥,并且会立即使旧密钥失效。
轮换客户端密钥,请使用以下端点:
const { data, error } = await authClient.oauth2.client.rotateSecret({ client_id, // required, OAuth 客户端的 client_id});client_idstringrequiredOAuth 客户端的 client_id
删除客户端
删除用户或组织的客户端,请使用以下端点:
const { data, error } = await authClient.oauth2.deleteClient({ client_id, // required, OAuth 客户端的 client_id});client_idstringrequiredOAuth 客户端的 client_id
OAuth 同意
对于所有非受信任客户端(尤其是无 skip_consent 的),都需要用户同意。以下端点允许用户或 reference_id 管理其已授权的同意。
获取同意详情
获取特定同意详情,使用以下端点:
const { data, error } = await authClient.oauth2.getConsent({ query: { id, // required, 同意项 id },});idstringrequired同意项 id
列出同意
获取用户的所有同意列表,使用以下端点:
const { data, error } = await authClient.oauth2.getConsents();更新同意
更新特定同意条目,使用以下端点:
const { data, error } = await authClient.oauth2.updateConsent({ id, // required, 同意项 id update, // required, 要更新的值});idstringrequired同意项 id
updateOAuthConsentrequired要更新的值
删除同意
撤销用户对某个客户端的同意。
const { data, error } = await authClient.oauth2.deleteConsent({ id, // required, 同意项 id});idstringrequired同意项 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 注册部分。
动态注册只会保留本服务器支持的已请求 grant_types,注册响应中会列出最终注册的授权类型(RFC 7591 §3.2.1)。例如,Claude 的 MCP connector 会同时请求 urn:ietf:params:oauth:grant-type:jwt-bearer、authorization_code 和 refresh_token,但只会使用后两者注册。移除 authorization_code 时,code 响应类型也会一并移除。注册请求与服务器没有任何共同授权类型时,会以 invalid_client_metadata 拒绝。通过 createOAuthClient 或 adminCreateOAuthClient 创建的客户端不会缩减授权类型;不支持的授权类型会被拒绝。
未提供 application_type 时,默认值为 web(OIDC Registration §2)。web 客户端在非 loopback 主机上必须使用 https 重定向 URI。native 客户端可以使用以下任一形式的重定向 URI:
- 非 loopback 主机上的已声明
httpsURI。 - 主机必须恰好为
localhost、127.0.0.1或[::1]的httploopback URI,端口不限。 - 使用反向域名 scheme 且不含 authority 的私有 URI,例如
com.example.app:/callback。这是 RFC 8252 §7.1 推荐的形式。 - 包含主机和路径的自定义 scheme URI,例如
cursor://anysphere.cursor-mcp/oauth/callback。scheme 无需包含句点。没有路径的 URI(例如cursor://anysphere.cursor-mcp)会被拒绝。
Loopback 主机匹配使用原始 URI authority,因此 127.1 等其他数字 IPv4 写法会在 URL 规范化前被拒绝。Better Auth 还会拒绝凭据、片段、格式错误的 URI、loopback https 和指向可路由主机的 http 重定向。Native 客户端不能使用以下保留 scheme:ws:、wss:、file:、ftp:、mailto:、javascript:、data:、vbscript:、blob:、about: 或 view-source:。主机加路径形式可以使用其他 scheme;但不含 authority 的 URI(例如 myapp:/callback)仍需使用反向域名 scheme。
应用类型与客户端身份验证相互独立。例如,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 形式的安全并集验证重定向。
一些 native MCP 客户端在动态注册时会省略 application_type。例如,Cursor 会在未提供该字段的情况下注册 cursor://anysphere.cursor-mcp/oauth/callback、一个 https 回调和 http://localhost:8787/callback;较早版本的 Claude Code 则会注册单个 http://127.0.0.1:<port>/callback,因此默认的 web 类型会拒绝这两种注册。当前版本的 Claude Code 会发送 application_type: "native",无需此选项即可注册。clientRegistrationDefaultApplicationType 用于设置动态注册省略该字段时采用的类型:
| 值 | 省略 application_type 时采用的类型 |
|---|---|
"web"(默认) | 按 OIDC Registration §2 的规定使用 web |
"native" | native |
"infer" | 任一重定向 URI 使用非 http(s) scheme,或使用指向 localhost、127.0.0.1、[::1] 的 http 时使用 native,否则使用 web |
之后会按照最终类型对应的规则验证每个重定向 URI。在 "infer" 模式下,列出自定义 scheme 或 http loopback URI 的客户端会被视为 native,因此其余 http URI 也必须指向 loopback。只列出 https URI 的客户端仍为 web。客户端自行发送的 application_type 不会被覆盖。此选项仅适用于动态注册,不影响托管客户端和 Client ID Metadata Document。
oauthProvider({
allowDynamicClientRegistration: true,
allowUnauthenticatedClientRegistration: true,
clientRegistrationDefaultApplicationType: "infer",
// ... other options
})RFC 8252 §8.4 指出,授权服务器应拒绝不含句点的私有 scheme。为兼容 native MCP 客户端,Better Auth 接受 cursor: 等 scheme。设备上的任何应用都可以注册私有 scheme,因此其他应用可能收到授权码。没有 code verifier 时,PKCE 可使被拦截的授权码无法使用。公开客户端(token_endpoint_auth_method: "none")始终要求使用 PKCE。机密客户端可通过 require_pkce: false 或 clientRegistrationRequirePKCE: false 关闭 PKCE;使用私有重定向的客户端应始终启用 PKCE。请参阅 PKCE 配置。
Note the following parameters are not yet supported:
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 身份验证失败会返回 401,并附带 Basic WWW-Authenticate challenge。对于内置的 authorization_code、refresh_token、client_credentials 和 device code 授权类型,会先验证客户端身份,再查找授权;因此未知 client_id 会返回 invalid_client,而不是 invalid_grant。扩展授权处理器(参见提供商扩展)在查找授权前调用 provider.authenticateClient(),也会得到相同结果。
DPoP 发送方约束令牌
Better Auth 支持 RFC 9449 定义的持有证明(DPoP)。客户端可以通过注册时设置 dpop_bound_access_tokens: true、在授权请求中发送 dpop_jkt,或请求配置了 dpopBoundAccessTokensRequired: true 的资源,来请求绑定 DPoP 的令牌。
当令牌绑定到 DPoP 时:
- The token endpoint requires a valid
DPoPproof JWT. - proof 中的
htu必须是端点的公开 URL:baseURL的 origin 和基础路径,加上客户端请求的路径,例如https://auth.example.com/api/auth/oauth2/token。baseURL应设置为客户端实际访问的公开 URL,而不是内部 URL,因为预期的htu会忽略请求 host,而代理或开发服务器可能会改写该值。 - The token response returns
token_type: "DPoP". - JWT access tokens include
cnf.jkt; opaque access tokens and refresh tokens persist the same JWK thumbprint. - Refresh token rotation requires a DPoP proof from the same key.
- Resource requests must use
Authorization: DPoP <access_token>and aDPoPproof containing the access-token hash (ath).
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。
轮换客户端密钥
内联配置 jwks 的客户端可以在没有用户会话的情况下替换自己的密钥,服务便可按计划轮换签名密钥。请在提供商中启用此功能:
oauthProvider({
allowClientKeyRotation: true,
})客户端生成新的密钥对,然后使用当前密钥之一签署客户端断言并调用此端点:
POST /api/auth/oauth2/client/rotate-keys
Content-Type: application/json
{
"client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
"client_assertion": "eyJhbGciOiJFUzI1NiIs...",
"jwks": { "keys": [{ "kty": "EC", "crv": "P-256", "kid": "key-2", "x": "...", "y": "..." }] }
}断言应包含上表中的声明,但有一点不同:aud 必须包含密钥轮换端点的 URL。此处令牌端点和发行者不计为 audience,因此,仅为这些地址签发的断言无法被转用来接管客户端密钥。
jwks 是客户端完整的新密钥集。未包含在其中的密钥会立即停止验证,因此在此次请求中省略已泄露密钥即可将其撤销。响应会返回 client_id 和已存储的 jwks。
如需替换密钥但又不影响仍使用旧密钥签名的实例,请像发布 jwks_uri 的客户端那样分两步轮换:
- 同时发送新旧密钥,并使用旧密钥签名。
- 所有实例都改用新密钥签名后,仅发送新密钥,并使用新密钥签名。
如果轮换过程中密钥发生变化,未能完成的请求会返回 409;如果其签名密钥已被移除,则返回 401。请使用当前密钥重新签署断言并重试。
每个轮换断言必须只能使用一次,因此使用 memory 适配器或 advanced.database.generateId: "serial" 时,提供商不会在启用 allowClientKeyRotation 的情况下启动。只有内联注册 jwks 的客户端才能使用此端点。发布 jwks_uri 的客户端应在该 URI 轮换密钥;列在 cachedTrustedClients 中的客户端无法通过客户端管理端点修改。client_secret_* 客户端应改用 /oauth2/client/rotate-secret。
Authorization code grant
授权码授权方式允许客户端获取用户访问令牌,并可选择获取刷新令牌(通过 "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 的同意 scope, // 以空格分隔的已接受 scope 列表。若未提供,则接受最初请求的 scope。 claims, // 接受的 OIDC claims 请求对象。如果未提供,则接受最初请求的 claims。});acceptbooleanrequired接受或拒绝用户对一组 scope 的同意
scopestring以空格分隔的已接受 scope 列表。若未提供,则接受最初请求的 scope。
claimsstring | Record<string, unknown>接受的 OIDC claims 请求对象。如果未提供,则接受最初请求的 claims。
继续端点
注册页面必须先 配置 以进行注册步骤。 账户选择页面必须先 配置 用于账户选择。 登录后页面必须先 配置 用于登录后操作。
const { data, error } = await authClient.oauth2.continue({ selected, // 确认已选择账户。 created, // 确认已注册账户 postLogin, // Resumes after the post login page. `postLogin.shouldRedirect` runs again with `isContinuation: true`.});selectedboolean确认已选择账户。
createdboolean确认已注册账户
postLoginbooleanResumes after the post login page. postLogin.shouldRedirect runs again with isContinuation: true.
核查端点
符合 RFC7662 规范的令牌核查端点。
此端点提供所给令牌的详细信息。如令牌绑定于会话,确保该会话为 active。
使用 customAccessTokenClaims 中的 resources 字段,根据受保护资源添加声明。
谁可以核查令牌
调用方必须以已注册客户端的身份进行身份验证(RFC 7662 §2.1)。之后,它可以在以下两种情况下核查令牌:
- 该客户端签发了令牌,或
- 该客户端是与令牌资源之一关联的资源服务器
第二种情况更常见:前端客户端获取令牌,由你的 API 验证。为此,请将 API 注册为资源并将客户端关联到该资源。动态注册和仅限服务器调用的 adminCreateOAuthClient 可以在注册时关联资源;已有客户端则使用 adminLinkClientResource。
任何其他已身份验证的客户端都会获得 { active: false },这与未知或已过期令牌的响应相同,因此核查不能用来探测有效令牌。刷新令牌的限制更严格:只有请求该刷新令牌的客户端才能核查它。
返回哪些声明
对于同一授权,Opaque 和 JWT 访问令牌会返回相同的声明:扩展的 claims.accessToken contributor、你的 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 服务器
本节展示如何让您的 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 查询参数将用户重定向至指定路径。在显示同意屏幕前,请在服务器上验证签名查询。使用 OAuth provider 所用的相同 secret;auth.$context.secret 会解析已配置的 secret,包括使用版本化 secrets 时的情况。请将其保留在服务器端。
import { verifyOAuthQueryParams } from "@better-auth/oauth-provider";
import { auth } from "@/lib/auth";
export async function getVerifiedConsentQuery(request: Request) {
const query = new URL(request.url).search.slice(1);
const { secret } = await auth.$context;
if (!(await verifyOAuthQueryParams(query, secret))) {
return null;
}
return new URLSearchParams(query);
}从服务器端的同意路由调用此辅助函数。如果返回 null,则返回错误响应,并使用返回的参数渲染页面。签名检查会拒绝被修改或已过期的查询。它不会检查客户端是否存在、用户是否已登录,也不会验证请求的 scopes 是否有效。使用 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, isContinuation }) => {
const userOnlyScopes = ["openid", "profile", "email", "offline_access"];
if (scopes.every((sc) => userOnlyScopes.includes(sc))) {
return false;
}
if (!session.activeOrganizationId) {
return true;
}
const organizations = await auth.api.listOrganizations({
headers,
});
if (!organizations.some((org) => org.id === session.activeOrganizationId)) {
return true;
}
// Ask on every new authorization; accept the selection on continuation
return !isContinuation && organizations.length > 1;
},
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;
}
},
}
})Consent 按 reference 分别存储。授权流程只会复用用户针对相同 consentReferenceId 值授予的 consent;返回 undefined 或空字符串时,只会使用未指定 reference 时授予的 consent。
插件会将用户重定向到 postLogin.page,以提示其选择账户。完成后,调用 authClient.oauth2.continue。
继续流程时会再次调用 shouldRedirect,此时 isContinuation: true。可用此值检查已选组织,避免要求拥有多个组织的用户再次选择。但这并不证明用户已完成选择:返回 false 前,请检查当前会话和组织成员身份。每个新的授权请求都会以 isContinuation: false 开始。
import { authClient } from "@/lib/auth-client"
await authClient.organization.setActive({
organizationId,
});
await authClient.oauth2.continue({
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,
},
]
})当资源策略需要在运行时更改时,请使用管理资源端点。
auth 实例启动时会将已配置资源写入 oauthResource 表。resourceSeedMode 决定如何处理已存在的记录。如果因数据库不可用或尚未迁移表结构而写入失败,系统会记录错误,并在首次发出需要资源的请求时重试。不使用资源的请求(如登录和会话检查)不受影响。
使用 Prisma 和 PostgreSQL、CockroachDB 或 MongoDB 时,未设置 allowedScopes 的资源无法存储,因为 Prisma 无法向列表列写入 null。在为资源声明 allowedScopes(列出客户端可为其请求的所有 scope)之前,所有使用已配置资源的请求都会失败。这也包括 mcp() 添加的资源;请在 resources 中用相同标识符声明该资源:
mcp({
loginPage: "/sign-in",
consentPage: "/consent",
resource: "https://api.example.com/mcp",
resources: [
{ identifier: "https://api.example.com/mcp", allowedScopes: ["openid", "profile", "offline_access"] },
],
})Dynamic registration can attach protected resources to the new client in the same transaction as the client row. clientRegistrationDefaultResources adds server-owned defaults to every registration. clientRegistrationAllowedResources lists additional resources a client may request; the effective allowlist is the union of the default and allowed lists. Defaults appear first, duplicates are removed, and every configured value must also be present in 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 关联。
要将资源关联到服务器注册的客户端,请在仅限服务器调用的 adminCreateOAuthClient 中传入 resources。此处不使用动态注册允许列表,而是在创建客户端前针对每个请求的资源调用 resourcePrivileges,其 action 为 link。clientRegistrationDefaultResources 仍会添加,且不进行权限检查。
await auth.api.adminCreateOAuthClient({
headers,
body: {
redirect_uris: ["https://app.example.com/callback"],
resources: ["https://api.example.com/default"],
},
});资源被拒绝时,此调用不会创建任何内容:
- 资源不是绝对 URI、包含 fragment,或使用
javascript:、data:、vbscript:scheme 时,请求验证会返回VALIDATION_ERROR。 - 资源不存在或已禁用时,返回
400和invalid_target。 resourcePrivileges拒绝请求时,返回401。
数据库适配器支持事务时,客户端及其资源关联会在同一事务中写入。不支持事务的适配器(如独立 MongoDB)在关联失败时,可能会留下已注册但没有资源关联的客户端。要将资源关联到现有客户端,请使用 adminLinkClientResource。
范围
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" ],
})自定义重定向 URI 验证
默认情况下,请求的重定向 URI 必须与客户端注册的某个 URI 完全匹配。对于 native 客户端注册的指向 localhost、127.0.0.1 或 [::1] 的 HTTP loopback URI,端口可以不同,其余部分必须匹配。RFC 8252 建议使用 IP 字面量而非 localhost。
客户端需要使用无法预先注册的回调 URI 时,可使用 validateRedirectUri。回调会接收请求的 URI、该客户端已注册的 URI,以及内置匹配的结果 defaultResult。它可以返回布尔值或 Promise。返回 true 即接受 URI,即使 defaultResult 为 false;返回 false 或抛出异常则会拒绝 URI。省略此选项时,只应用内置匹配规则。
如需接受某个 web 客户端的预览回调,可先使用 https://app.example.com/api/auth/callback 之类的固定 URI注册客户端。此回调不会更改注册信息,并且会应用于此 OAuth Provider 的所有客户端。下面的示例仅为注册了该 URI 的客户端扩展默认规则:
oauthProvider({
validateRedirectUri: (uri, registeredUris, defaultResult) => {
if (defaultResult) return true;
if (!registeredUris.includes('https://app.example.com/api/auth/callback')) {
return false;
}
if (uri.includes('#')) return false;
try {
const url = new URL(uri);
return (
url.protocol === 'https:' &&
url.username === '' &&
url.password === '' &&
url.hostname.endsWith('.preview.example.com') &&
url.port === '' &&
url.pathname === '/api/auth/callback' &&
url.search === ''
);
} catch {
return false;
}
},
})提前返回 defaultResult 可以保留已注册 URI 和 native loopback 的处理方式。如需收紧这些默认规则,请结合 defaultResult 和额外策略进行检查。如需替换默认规则,请返回自定义验证结果,不要使用 defaultResult。
自定义接受规则会放宽 OAuth 安全指南要求的精确重定向 URI 匹配。仅对你控制的回调主机使用此功能,并确保不可信贡献者无法在已接受的主机名下部署回调。此示例假定只有可信客户端可以注册生产回调 URI。如果不可信客户端也能注册任意 URI,registeredUris 中包含某 URI 并不能证明客户端拥有该 URI。
对于新接受的 web 回调,应明确限制 HTTPS scheme、主机名后缀、端口、路径和 query。避免使用 * 或 *.com 等宽泛规则。除非有意替换,否则应保留 native 回调的内置检查。Provider 会在调用此回调前拒绝格式错误的 URI、fragment、凭据,以及 javascript:、data: 或 vbscript: scheme。
此回调还决定是否可以将早期授权错误重定向到请求的 redirect_uri(RFC 6749 §4.1.2.1)。如果 URI 被拒绝或回调抛出异常,错误会显示在服务器错误页面。
如果使用成对 subject 标识符,sector identifier 会取自客户端注册的第一个重定向 URI。因此,该客户端接受的所有预览或租户主机都会使用相同的 sub。目前不支持 sector_identifier_uri。如果租户需要不同的 subject 标识符,请使用不同重定向 URI 主机分别注册客户端。
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",
};
},
// 将声明附加到 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" }
: {}),
};
},
})Client-Specific Access Token Claims
当某个声明取决于令牌所属的客户端时,请使用 extensions[].claims.accessToken contributor。customAccessTokenClaims 只会接收客户端的 metadata,而 contributor 会接收完整的 client 记录,包括 clientId 和 referenceId。
此示例会将客户端所属组织添加到面向某个 API 的令牌中。它假设 clientReference 会为每个客户端分配组织,并且客户端已关联到 API 资源,例如通过 adminCreateOAuthClient 的 resources 选项建立关联:
const apiResource = "https://api.example.com";
oauthProvider({
resources: [apiResource],
extensions: [{
claims: {
accessToken: ({ client, resources }) => {
if (!resources?.includes(apiResource) || !client.referenceId) return {};
return { "https://example.com/org": client.referenceId };
},
},
}],
})client.referenceId 表示客户端的所有者,由注册时的 clientReference 或受保护动态注册期间的初始访问令牌设置。它不同于这两个回调也会收到的顶层 referenceId;后者是来自 postLogin.consentReferenceId 的 consent reference。
Opaque token 的 introspection 会在没有 grantType 或 sessionId 的情况下重新派生 contributor 声明,因此 introspection 需要的声明应基于客户端、scope 和资源。customAccessTokenClaims 和各资源的 customClaims 会覆盖同名 contributor 声明;请参阅声明优先级。
组织声明不能证明客户端仍处于连接状态。JWT 会一直保留其声明直到过期,因此需要立即响应客户端停用的资源服务器应对令牌执行 introspection;停用客户端的令牌会被报告为非活动状态。
Custom Token Response Fields
与上述声明回调(在 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 配置
管理员创建的机密客户端可按需停用 PKCE 要求以兼容旧系统:
// 注册一个不支持 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;
},
})有关设置用户权限和角色的详细信息,请参见 声明。
将客户端关联到组织
组织与客户端之间可以有两种关联方式,分别适用于不同类型的客户端:
- 组织使用客户端。 多个组织可以共用一个客户端,例如第三方应用、MCP 客户端,或通过 CIMD 发现的客户端。此客户端没有所有者。每位用户的 consent 会记录其代表哪个组织授权。
- 组织拥有客户端。 客户端是组织自己的集成。组织成员可通过客户端端点查看和管理客户端,包括更改其重定向 URI 或删除客户端。仅应将客户端所有权授予实际控制该客户端的组织,因为该组织可以代表所有用户管理此客户端。
组织使用客户端
通过 postLogin.consentReferenceId 返回组织。用户的 consent 会按组织存储,因此同意一个组织的授权不会同时授权其他组织,通过删除 Consent 删除时也只会移除该组织的 consent。删除 consent 不会撤销已经签发的令牌。访问令牌回调会将相同的值作为 referenceId 接收,因此令牌可以携带组织信息:
oauthProvider({
postLogin: {
page: "/select-organization",
// The selection outlives membership, so check the user still belongs.
shouldRedirect: async ({ session, headers }) => {
if (!session.activeOrganizationId) return true
const organizations = await auth.api.listOrganizations({ headers })
return !organizations.some((org) => org.id === session.activeOrganizationId)
},
consentReferenceId: ({ session }) =>
(session?.activeOrganizationId as string | undefined) ?? undefined,
},
customAccessTokenClaims: ({ referenceId }) => ({
"https://example.com/org": referenceId,
}),
})对于所有组织,客户端记录保持不变,因此这种方式适用于自行注册的客户端,包括开放式动态客户端注册和 CIMD。
组织拥有客户端
注册客户端时设置所有者。已有所有者的客户端,其 referenceId 固定不变;客户端专属声明可读取此值。
- 由成员注册。 如果
clientReference返回当前组织,那么已登录成员通过创建客户端或使用会话身份验证的动态注册创建的客户端就属于该组织。哪些成员可以创建和管理客户端由clientPrivileges决定。 - 由机器注册。 对于自行注册的软件(例如部署流水线),请使用受保护的动态客户端注册。由组织管理员在应用中签发初始访问令牌,并在
validateInitialAccessToken中返回组织:
oauthProvider({
allowDynamicClientRegistration: true,
// Lets the organization's members find and manage the client. The active
// organization outlives membership, so check it is still current.
clientReference: async ({ user, session }) => {
const organizationId = session?.activeOrganizationId as string | undefined
if (!user || !organizationId) return undefined
return (await isMember(user.id, organizationId)) ? organizationId : undefined
},
validateInitialAccessToken: async ({ initialAccessToken }) => {
// `findRegistrationToken` and `isMember` stand for your own lookups
const token = await findRegistrationToken(initialAccessToken)
if (!token) return false
return { referenceId: token.organizationId }
},
})未使用会话或初始访问令牌的开放注册没有所有者。这适用于多个组织都要使用的客户端,并通过各自的 consent reference 分别授权。若软件由各组织安装并作为自己的客户端运行(例如自托管连接器),则应使用初始访问令牌注册,使客户端从一开始就归属该组织。
Client CRUD Privileges
确定登录用户是否具备客户端创建、读取、更新、删除权限,可通过 clientPrivileges 配置。默认允许拥有匹配 userId 或 clientReference 的用户操作。
此回调只能拒绝操作。Client CRUD 仍会在回调之后检查所有权,因此返回 true 不能让用户访问其他用户或其他 clientReference 所拥有的客户端。唯一例外是通过 adminUpdateOAuthClient 配置其他所有者客户端的 scope:如果更新内容仅包含 client_credentials_scopes,则回调的 update 和 configure-client-credentials-scopes 决策可以授权此操作,无需检查所有权。
The following is a basic example that allows all OAuth Client CRUD actions for organization owners assuming ordinary users cannot create clients:
oauthProvider({
clientPrivileges: async ({ action, headers, user, session }) => {
if (!session?.activeOrganizationId) return false;
const { data: member } = await auth.api.getActiveMember({
headers,
});
return member.role === 'owner';
},
})对于现有客户端的操作(read、update、delete、rotate 和 configure-client-credentials-scopes),回调还会收到 clientId。执行 create、list 以及创建时的 scope 配置操作时,不会提供此值。clientId 是请求中的 client_id,客户端可能尚不存在,因此该值不能证明所有权。
可以使用 clientId 在共享同一 clientReference(例如同一组织成员)的用户之间限制对单个客户端的操作。以下示例允许所有成员创建、列出和读取组织客户端,但只有客户端维护者可以更改客户端或配置其 scope。getClientMaintainers 表示你自己的查询逻辑:
oauthProvider({
clientReference: ({ session }) => {
return (session?.activeOrganizationId as string | undefined) ?? undefined;
},
clientPrivileges: async ({ action, user, clientId }) => {
if (!user) return false;
if (action === "create" || action === "list" || action === "read") {
return true;
}
// Scope configuration during creation has no client ID, so deny it here.
if (!clientId) return false;
const maintainers = await getClientMaintainers(clientId);
return maintainers.includes(user.id);
},
})Storage
默认所有密钥在数据库中以 hashed 形式存储,防止泄露时暴露 client_secret。
- storeClientSecret:应用程序
client_secrets的存储方式。仅当disableJwtPlugin: true时,客户端密钥应为encrypted。 - storeTokens:令牌值的存储方式,特别是会话刷新令牌和不透明访问令牌。
限流
OAuth 提供者内置所有 OAuth 端点限流,防止滥用和拒绝服务攻击。
限流为 每 IP 每端点。每个客户端 IP 地址对每个端点拥有独立的限流计数器。窗口期结束后限流重置。
这些限流仅在 Better Auth 的全局限流启用时生效。默认情况下仅在生产环境启用。参见 限流获取全局配置。
默认限制:
| Endpoint | Window | Max Requests |
|---|---|---|
/oauth2/token | 60s | 20 |
/oauth2/authorize | 60s | 30 |
/oauth2/introspect | 60s | 100 |
/oauth2/revoke | 60s | 30 |
/oauth2/register | 60s | 5 |
/oauth2/client/rotate-keys | 60s | 5 |
/oauth2/userinfo | 60s | 60 |
可自定义各端点的限流参数:
oauthProvider({
rateLimit: {
token: { window: 60, max: 20 }, // 20 requests per minute
authorize: { window: 60, max: 30 }, // 30 requests per minute
introspect: { window: 60, max: 100 }, // 100 requests per minute
revoke: { window: 60, max: 30 }, // 30 requests per minute
register: { window: 60, max: 5 }, // 5 requests per minute
rotateClientKeys: { window: 60, max: 5 }, // 5 requests per minute
userinfo: { window: 60, max: 60 }, // 60 requests per minute
},
})如需关闭某个端点的自定义限流,回退到全局限流,设置该端点为 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_uriis not yet supported. Allredirect_urisfor a pairwise client must share the same host. Clients with redirect URIs on different hosts will be rejected at registration.- Pairwise 客户端不能使用包含主机的自定义 scheme 重定向 URI,例如
app://rp.example.com/callback。任何应用都可以注册自定义 scheme,因此该主机无法用于标识客户端。 - Pairwise clients cannot use custom-scheme redirect URIs that include a host, such as
app://rp.example.com/callback. Any app can register a custom scheme, so that host does not identify the client. pairwiseSecretmust be at least 32 characters long.- Rotating
pairwiseSecretwill change all pairwisesubvalues, breaking existing RP sessions. Treat this secret as permanent once set.
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 是按客户端区分的断言标识符摘要,因此重放或并发断言会与主键冲突,并由数据库原子性拒绝(即使涉及多个服务器进程也是如此)。使用 advanced.database.generateId: "uuid" 时,摘要会格式化为 UUID。此检查要求 ID 为字符串:使用 "serial" 时,摘要无法作为 ID 存储,Better Auth 会记录警告。记录被删除前会持续阻止重复使用其 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 的字符串格式。