OAuth 2.1 提供者

一个 OAuth 2.1 提供者插件,允许你将认证服务器转换为具有 OIDC 兼容性的 OAuth 提供者,使用户和其他服务能够使用你的 API 进行认证。

【An OAuth 2.1 Provider Plugin that allows you to turn your authentication server into an OAuth provider with OIDC compatibility allowing users and other services to authenticate with your API.】

该插件默认具有安全配置,为不熟悉 OAuth 详细信息的用户提供便利。

【The plugin has a secured configuration by default providing ease to users unfamiliar with the details of OAuth.】

主要特性

  • OAuth 2.1:针对 OAuth 2.1 的受限安全做法
  • 启用 MCP:支持 MCP 认证
  • OIDC 兼容性:支持 openid 范围的 OIDC 标准
    • 用户信息:提供当前用户详细信息的端点
    • id_token:JWT 签名的用户信息
    • OIDC 登出:符合 RP 发起 的登出
  • 动态客户端注册:允许客户端动态注册客户端。
    • 公共客户端:支持原生移动客户端和用户代理客户端(如 AI)的公共客户端
    • 保密客户:支持网页客户的保密客户
    • 受信任的客户端:配置具有可选同意绕过的硬编码受信任客户端。
  • JWT 插件兼容性:默认启用,可选择禁用
    • JWT 签名:在请求 resource 时对 JWT 令牌进行签名
    • JWKS 可验证:在 /jwks 端点远程验证令牌
  • 授权提示:用于启动特定登录流程的提示
    • 同意:确保针对每个权限范围都获得了同意。可通过 prompt=consent 强制要求同意。
    • 选择账户:在授予特定权限范围之前,请确保已选择账户。使用 prompt=select_account 可强制执行。
  • 资源端点:读取和管理令牌。
    • 自省:符合 RFC7662 的自省。
    • 撤销:符合 RFC7009 的撤销。

支持的资助

  • authorization_code:用于用户令牌交换的代码,需符合 PKCE 和 S256 要求。
  • refresh_token:使用 offline_access 范围发放刷新令牌并处理访问令牌的续期。
  • client_credentials:用于 API 通信的机器对机器令牌。

该插件正在积极开发中,可能不适合在生产环境中使用。如有任何问题或漏洞,请在 GitHub上报告。

安装

【Installation】

安装插件

将 OIDC 插件添加到你的身份验证配置中。有关如何配置该插件,请参见配置部分

auth.ts
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",
      // ...other options
    })
  ],
});

迁移数据库

运行迁移或生成架构,以向数据库添加必要的字段和表。

npx @better-auth/cli migrate
npx @better-auth/cli generate

See the Schema section to add the fields manually.

添加 ./well-known 端点

请将所有知名端点添加到你的项目中。如果不确定,系统会以警告的形式提供位置提示。

  • 必须在你的发行者路径(如果没有路径则为根路径)添加 OAuth 授权服务器元数据端点。
  • 如果你正在使用 openid 范围,你必须在你的发行者路径(如果没有路径,则为根路径)添加 openid 配置。
  • 如果你正在使用资源服务器(例如用于 MCP),你必须将资源服务器元数据添加到你的 API,并附加发行者路径。

创建你的第一个 OAuth 客户端

创建你的第一个机密 OAuth 客户端。

const client = await auth.api.createOAuthClient({
		headers,
		body: {
			redirect_uris: [redirectUri],
		}
	});
console.log(client); // If you wish, you may add the `client_id` to `cachedTrustedClients`

要创建公共客户端(即没有客户端密钥),请设置 token_endpoint_auth_method: "none"

客户端插件

【Client Plugins】

存在两个客户端。根据你的设置,你可以选择添加其中一个或两个。

【There exists two clients. You may wish to add one or both depending on your setup.】

OAuth 客户端

【OAuth Client】

OAuth 客户端是连接的 oauthClient,例如移动应用或网页应用。

【The OAuth Client is the connecting oauthClient such a mobile or web application.】

client.ts
import { createAuthClient } from "better-auth/client";
import { oauthProviderClient } from "@better-auth/oauth-provider/client"
export const authClient = createAuthClient({
  plugins: [oauthProviderClient()],
});

资源客户端

【Resource Client】

资源服务器是一个在你的 API 服务器上运行的客户端,用于执行诸如令牌验证和提供元数据等操作。

【The Resource Server is a client that operates on your API server to perform actions like token verification and provide metadata.】

server-client.ts
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 optional
});

用法

【Usage】

该插件作为一个 OAuth 2.1 服务器运行,具备兼容 OIDC 的端点和可验证的 JWT 访问令牌。以下提供了关于每个端点的更详细信息。

【The plugin operates as an OAuth 2.1 server with OIDC compatible endpoints and JWT verifiable access tokens. The following provides more detailed information about each endpoint.】

OAuth 客户端

【OAuth Clients】

在 OAuth 中,有两种类型的客户端:

  • 公共客户端:无法存储客户端密钥,例如本地移动客户端和用户代理客户端(如 AI)
  • 机密客户端:可以存储客户端密钥,例如 Web 客户端

【In OAuth there are two types of clients:

  • Public Clients: Cannot store a client secret such as native mobile clients and user-agent clients (like AI)
  • Confidential Clients: Can store a client secret such as web clients】

获取客户端

【Get Client】

要获取特定用户或组织拥有的客户信息,请使用以下端点:

【To obtain client information owned by a specific user or organization use the following endpoint:】

GET
/oauth2/get-client
const { data, error } = await authClient.oauth2.getClient({    query: {        client_id, // required    },});
PropDescriptionType
client_id
The OAuth client's client_id
string,

获取公共客户端

【Get Public Client】

要获取在登录流程页面(如授权同意页面)上显示的公共客户端字段,请使用以下端点。注意:用户必须已登录才能使用此端点。

【To obtain public client fields to display on login flow pages such as consent, use the following endpoint. Note: the user must be signed in to use this endpoint.:】

GET
/oauth2/public-client
const { data, error } = await authClient.oauth2.publicClient({    query: {        client_id, // required    },});
PropDescriptionType
client_id
The OAuth client's client_id
string,

客户列表

【List Clients】

要获取特定用户或组织拥有的客户列表,请使用以下端点:

【To obtain a list of clients owned by a specific user or organization, use the following endpoint:】

GET
/oauth2/get-clients
const { data, error } = await authClient.oauth2.getClients();

创建客户端

【Create Client】

要创建与特定用户或组织绑定的 OAuth 客户端,请使用 /oauth2/create-client 端点(例如 createOAuthClient)。这些参数与 RFC7591 描述的注册端点相同。

【To create an oauth client tied to a specific user or organization, use the /oauth2/create-client endpoint (eg. createOAuthClient). The parameters are equivalent to the registration endpoint described by RFC7591.】

数据库中的以下字段被认为是受限的,只有管理员用户才能编辑。

  • client_secret_expires_at:机密客户端密钥的过期时间
  • skip_consent:允许跳过用户同意流程。对受信任的客户端非常有用。
  • enable_end_session:允许用户通过客户端的 id_token/oauth2/end-session 端点登出会话。用于 OIDC 设置和指定的受信任客户端。
  • metadata:附加到客户端的额外私有元数据。

【The following fields on the database are considered restricted and should only be editable by admin users.

  • client_secret_expires_at: The expiration time for a secret of a confidential client
  • skip_consent: Allows the ability to skip user consent flow. Useful for trusted clients.
  • enable_end_session: Allows a user to logout of a session from the client via their id_token at the /oauth2/end-session endpoint. Used in OIDC-setups and specified trusted clients.
  • metadata: Additional private metadata to attach to the client.】

在某些情况下,你可能希望通过自定义 API、公司管理门户或服务器初始化来创建具有受限字段的 OAuth 客户端逻辑,你可以使用以下仅限服务器的端点:

【In some cases, you may wish to create logic to create oauth clients with restricted fields through custom APIs, company admin portals, or server initialization, you may use the following server-only endpoint:】

await auth.api.adminCreateOAuthClient({
  headers,
  body: {
    redirect_uris: [redirectUri],
    client_secret_expires_at: 0, 
    skip_consent: true, 
    enable_end_session: true, 
  }
});

更新客户端

【Update Client】

要更新与特定用户或组织绑定的 OAuth 客户端,请使用 /oauth2/update-client 端点(例如 updateOAuthClient)。参数与 RFC7591 描述的注册端点相同。

【To update an oauth client tied to a specific user or organization, use the /oauth2/update-client endpoint (eg. updateOAuthClient). The parameters are equivalent to the registration endpoint described by RFC7591.】

POST
/oauth2/update-client
const { data, error } = await authClient.oauth2.updateClient({    client_id, // required    update, // required});
PropDescriptionType
client_id
The OAuth client's client_id
string,
update
The fields to update
OAuthClient,

此端点的限制:

  • 你无法在机密客户端和公共客户端之间切换。客户端类型必须在创建时确定。
  • 你无法更新客户端密钥。要轮换 client_secret,请使用轮换客户端密钥端点。

【Restrictions on this endpoint:

  • You are unable to switch between confidential and public clients. The client type must be determined at creation.
  • You cannot update the client secret. To rotate the client_secret use the rotate client secret endpoint.】

在某些情况下,你可能希望通过自定义 API、公司管理员门户或服务器初始化来创建逻辑以更新具有受限字段的 oauth 客户端,你可以使用以下仅限服务器的端点。字段在创建部分中有描述。

【In some cases, you may wish to create logic to update oauth clients with restricted fields through custom APIs, company admin portals, or server initialization, you may use the following server-only endpoint. The fields are described in the create section.:】

await auth.api.adminUpdateOAuthClient({
  headers,
  body: {
    redirect_uris: [redirectUri],
    client_secret_expires_at: 0, 
    skip_consent: true, 
    enable_end_session: true, 
  }
});

旋转客户端密钥

【Rotate Client Secret】

当前的实现会立即旋转客户端密钥,并且之前的密钥会立即失效。

要轮换客户端密钥,你必须使用以下端点:

【To rotate a client secret, you must use the following endpoint:】

POST
/oauth2/client/rotate-secret
const { data, error } = await authClient.oauth2.client.rotateSecret({    client_id, // required});
PropDescriptionType
client_id
The OAuth client's client_id
string,

删除客户端

【Delete Client】

要删除用户或组织的客户端,请使用以下端点:

【To delete a user or organization's client, use the following endpoint:】

POST
/oauth2/delete-client
const { data, error } = await authClient.oauth2.deleteClient({    client_id, // required});
PropDescriptionType
client_id
The OAuth client's client_id
string,

【OAuth Consent】

所有非受信任客户端都需要获取同意,特别是没有 skip_consent 的客户端。以下端点允许用户或 reference_id 管理他们提供的同意。

【Consent is required on all non-trusted clients, specifically those without skip_consent. The following endpoints allow users or reference_id manage their given consents.】

【Get Consent】

要获取特定同意的详细信息,请使用以下端点:

【To obtain details of a specific consent, use the following endpoint:】

GET
/oauth2/get-consent
const { data, error } = await authClient.oauth2.getConsent({    query: {        id, // required    },});
PropDescriptionType
id
The consent id
string,

【List Consent】

要获取用户同意列表,请使用以下端点:

【To obtain a list of user consents, use the following endpoint:】

GET
/oauth2/get-consents
const { data, error } = await authClient.oauth2.getConsents();

【Update Consent】

要更新特定的同意,请使用以下端点:

【To update a specific consent, use the following endpoint:】

POST
/oauth2/update-consent
const { data, error } = await authClient.oauth2.updateConsent({    id, // required    update, // required});
PropDescriptionType
id
The consent id
string,
update
The values to update
OAuthConsent,

【Delete Consent】

撤销用户对特定客户端的同意。

【Revokes a user's consent for a specific client.】

POST
/oauth2/delete-consent
const { data, error } = await authClient.oauth2.deleteConsent({    id, // required});
PropDescriptionType
id
The consent id
string,

动态注册端点

【Dynamic Registration Endpoint】

此端点支持符合 RFC7591 的客户端注册。

安装完成后,你可以使用 OAuth 提供程序来管理应用中的身份验证流程。

【Once installed, you can utilize the OAuth Provider to manage authentication flows within your application.】

创建客户端后,你将收到一个 client_idclient_secret,可以向用户显示。client_secret 只能提供一次,请确保用户保存它。

【After the client is created, you will receive a client_id and client_secret that you can display to the user. The client_secret can only be provided once, ensure the user saves it.】

安装

【Setup】

要启用客户端注册,请在你的 BetterAuth 配置中将 allowDynamicClientRegistration: true 设置为 true。

【To enable client registration set allowDynamicClientRegistration: true in your BetterAuth config.】

auth.ts
oauthProvider({
  allowDynamicClientRegistration: true,
  // ... other options
})

要启用允许动态注册公共客户端的未经身份验证的客户端注册,请在你的身份验证配置中额外设置 allowUnauthenticatedClientRegistration: true

【To enable unauthenticated client registration which allows for dynamically registered public clients, additionally set allowUnauthenticatedClientRegistration: true in your auth config.】

allowUnauthenticatedClientRegistration 的支持将被弃用,当 MCP 协议标准化未认证的动态客户端注册时。截至目前,客户端 ID 元数据文档software_statementjwks_uri 仍在讨论中。

auth.ts
oauthProvider({
  allowDynamicClientRegistration: true,
  allowUnauthenticatedClientRegistration: true,
  // ... other options
})

基本示例

【Basic Example】

要注册一个新的 OIDC 客户端,请使用 oauth2.register 方法。

【To register a new OIDC client, use the oauth2.register method.】

const client = await client.oauth2.register({
  client_name: "My Client",
  redirect_uris: ["https://client.example.com/callback"],
});

有关所有端点参数,请参阅 RFC 7591 注册

【For all endpoint parameters, see RFC 7591 Registration.】

请注意,以下参数尚不支持:

  • jwks
  • jwks_uri

【Note the following parameters are not yet supported:

  • jwks
  • jwks_uri

授权端点

【Authorize Endpoint】

一个 OAuth 2.1 授权端点。由于许多细节尚未完全描述,部分内容借鉴了旧版的 OAuth 2.0 授权端点章节,但始终实现了 与 OAuth 2.0 的差异

【An OAuth 2.1 authorization endpoint. Since many of the details are not yet fully described, parts are adapted from the legacy OAuth 2.0 Authorization Endpoint Section but always implements the differences from OAuth 2.0.】

授权端点是启动 OAuth 2.1 授权流程的入口。

【The Authorization Endpoint is the entry point for initiating an OAuth 2.1 authorization flows.】

重要说明:

  • 在 OAuth 2.1 中,只支持 response_type: "code"
  • 不支持 code_challenge_method: "plain",因为这存在安全漏洞。

【Important notes:

  • In OAuth 2.1, only response_type: "code" is supported.
  • code_challenge_method: "plain" will not be supported since this is a security vulnerability.】

状态

我们需要发送一个状态以减轻跨站请求伪造(CSRF)攻击。这是通过确保你的客户端只对其最初发起的请求进行响应来实现的。

【We require sending a state to mitigate cross-site request forgery (CSRF) attacks. This works by ensuring your client only responds to requests that your client initially requested.】

从你的客户端生成一个状态值,并将其存储在客户端,例如在安全的仅限 HTTP 的 cookie 或数据库中。

【Generate a state value from your client and store on your client such as in a secure, HTTP-only cookie or database.】

代码挑战

代码挑战有助于保护从授权端点返回的授权 code

【Code challenges helps protect the authorization code returned from the authorization endpoint.】

为此,会从代码验证器派生出一个代码挑战,并通过 代码交换证明密钥(PKCE) 发送到授权服务器。

【To do so, a code challenge is derived from a code verifier and sent in a Proof Key for Code Exchange (PKCE) to the Authorization Server.】

现在在你的 redirect_uri(即回调)处,检查返回的 state 是否与初始 state 匹配,使用 authorization_code 授权类型和原始的 code verifier 在 Token Endpoint 获取令牌。

【Now at your redirect_uri (ie callback), check to see if the returned state matches the initial state, use the authorization_code grant and original code verifier at the Token Endpoint to obtain the tokens.】

令牌端点

【Token Endpoint】

默认情况下,令牌端点支持为以下授权类型提供令牌:

【By default, the token endpoint supports providing tokens for the following grants:】

  • 授权码
  • 客户端凭证
  • 刷新令牌

授权码授权

【Authorization code grant】

授权码授权使客户端能够获取用户访问令牌,并可选择获取刷新令牌(使用“offline_access”范围)。

【The authorization code grant enables clients to obtain access user access tokens and optionally refresh tokens (with the "offline_access" scope).】

客户端凭证授权

【Client credentials grant】

客户端凭证授权允许客户端获取机器访问令牌。

【The client credentials grant enables clients to obtain machines to obtain access tokens.】

刷新令牌授权

【Refresh token grant】

刷新令牌授权使客户端能够在无需用户重新登录的情况下更新其访问令牌。

【The refresh token grant enables clients to update their access token without needing the user to login again.】

该实现目前会为每个刷新请求发放一个新的刷新令牌。

【This implementation currently issues a new refresh token for every refresh request.】

【Consent Endpoint】

接受或拒绝用户对一组权限的同意。请注意,当拒绝某些权限时,原来的同意将被取消且已有的同意将保留。要移除同意,请删除该用户在该客户端的“oauthConsent”。

【Accept or deny user consent for a set of scopes. Note that when denying scopes, the consent cancels and pre-existing consent remains. To remove consent, delete that user's "oauthConsent" for that client.】

POST
/oauth2/consent
const { data, error } = await authClient.oauth2.consent({    accept, // required    scope,});
PropDescriptionType
accept
Accept or deny user consent for a set of scopes
boolean,
scope?
Space-separated list of accepted scopes. If not provided, the originally requested scopes are accepted.
string,

继续端点

【Continue Endpoint】

注册页面必须经过配置才能执行账户注册步骤。 账户选择必须经过配置才能执行账户选择。 登录后操作必须经过配置才能执行登录后的选择。

【Sign up registration pages must be configured to perform account registration steps. Account selection must be configured to perform account selection. Post login must be configured to perform post login selection.】

POST
/oauth2/continue
const { data, error } = await authClient.oauth2.continue({    selected,    created,    postLogin,});
PropDescriptionType
selected?
Confirms an account was selected.
boolean,
created?
Confirms an account was registered
boolean,
postLogin?
Confirms completion of post login activity
boolean,

内省端点

【Introspect Endpoint】

RFC7662 兼容的自省。

此端点提供所提供令牌的详细信息。如果该令牌还与会话关联,端点将确保该会话处于“活跃”状态。

【This endpoint provides details of the provided token. If the token is additionally tied to a session, the endpoint will ensure the session is active.】

要通过 customAccessTokenClaims 提供特定于资源的声明,请将受限制客户端可以使用的允许资源存储在其 resources 字段中。

【To provide resource specific claims via customAccessTokenClaims, store the allowed resources that a confidential client can use in its resources field.】

撤销端点

【Revoke Endpoint】

RFC7009 兼容的撤销。

此端点会撤销提供的令牌。

【This endpoint revokes the provided token.】

  • 不透明的 access_token:会立即从数据库中移除该 access_tokenrefresh_token 仍然有效。
  • JWT access_token:验证令牌是否可以安全地从客户端存储中移除。
  • refresh_token:移除使用该 refresh_token 授予的所有 access_tokens,并移除该 refresh_token 以防止进一步颁发令牌。

对于 access_token 类型,

【For an access_token type, 】

结束会话端点

【End Session Endpoint】

符合 RP 发起 的注销

此端点允许指定的受信任客户端远程注销。

【This endpoint allows specified trusted clients to logout remotely.】

要允许 RP 发起的注销,必须专门创建一个受信任的客户端来执行会话注销。

【To allow rp-initiated logout, a trusted client must specifically be created to perform session logout.】

await auth.api.adminCreateOAuthClient({
  headers,
  body: {
    redirect_uris: [redirectUri],
    enable_end_session: true, 
  }
});

如果 disableJwtPlugin: true,公共客户端将永远无法使用此端点注销,因为不会发送 id_token

用户信息端点

【UserInfo Endpoint】

UserInfo 端点提供符合 OIDC 标准的用户信息。该端点可通过 /oauth2/userinfo 访问,且需要一个至少具有 openid 范围的有效访问令牌。

【The UserInfo Endpoint provides OIDC-compliant user information. Available at /oauth2/userinfo, the endpoint requires a valid access token with at least the scope openid.】

client-app.ts
// Example of how a client would use the UserInfo endpoint
const response = await fetch('https://your-domain.com/api/auth/oauth2/userinfo', {
  headers: {
    'Authorization': 'Bearer ACCESS_TOKEN'
  }
});

const userInfo = await response.json();
// userInfo contains user details based on the scopes granted

UserInfo 端点会根据授权过程中授予的范围返回不同的声明:

【The UserInfo endpoint returns different claims based on the scopes that were granted during authorization:】

  • openid:返回用户的 ID(sub 声明)
  • profile:返回 namepicturegiven_namefamily_name
  • email:返回 emailemail_verified

customUserInfoClaims 函数接收用户对象、请求的权限数组以及传入的访问令牌,允许你向响应中添加额外的信息。

【The customUserInfoClaims function receives the user object, requested scopes array, and the passed access token, allowing you to add additional information to the response.】

众所周知

【Well known】

OpenID 配置

【Openid Configuration】

提供位于 /.well-known/openid-configurationOpenID Connect 发现元数据

【Provides OpenID connect discovery metadata located at /.well-known/openid-configuration.】

此端点需要 openid 范围。

【This endpoint requires the scope openid.】

必须在发行者路径下添加配置。如果未设置发行者,这将是你的 basePath /api/auth。 如果此路径不在根目录,并且你在根目录下尚无 openid-configuration,我们建议你添加一个,以防客户端错误地硬编码了 /.well-known/openid-configuration(忽略了规范中的发行者路径)。

【You must add the configuration at the issuer path. If an issuer is unset, this will be your basePath /api/auth. If this path is not at the root and you don't have an openid-configuration already at the root, we recommend you to add one in case a client incorrectly hard-coded /.well-known/openid-configuration (ignoring the issuer path in the spec).】

注意:对于带路径的发行方,OpenId 使用路径追加,因此发行方的任何路径都应在 /.well-known/openid-configuration 之前加上。如果没有指定发行方路径,则路径应从根目录开始。

【NOTE: For issuers with paths, OpenId utilizes path appending, thus any path on the issuer should be prepended before /.well-known/openid-configuration. If no issuer path is specified, the path should start at the root.】

[issuer-path]/.well-known/openid-configuration/route.ts
import { oauthProviderOpenIdConfigMetadata } from "@better-auth/oauth-provider";
import { auth } from "@/lib/auth";

export const GET = oauthProviderOpenIdConfigMetadata(auth);

OAuth 授权服务器

【OAuth Authorization Server】

提供符合 RFC8414 标准的元数据,位于 /.well-known/oauth-authorization-server

【Provides RFC8414-compliant metadata located at /.well-known/oauth-authorization-server.】

必须在发行者路径添加配置。如果未设置发行者,这将是你的 basePath /api/auth

【You must add the configuration at the issuer path. If an issuer is unset, this will be your basePath /api/auth.】

注意:对于具有路径的发行者,OAuth 2.1 授权服务器使用路径插入,因此发行者的任何路径都应附加在 /.well-known/oauth-authorization-server 之后。如果未指定发行者路径,则路径应从根目录开始。

【NOTE: For issuers with paths, OAuth 2.1 Authorization Server utilizes path insertion, thus any path on the issuer should be appended after /.well-known/oauth-authorization-server. If no issuer path is specified, the path should start at the root.】

/.well-known/oauth-authorization-server/[issuer-path]/route.ts
import { oauthProviderAuthServerMetadata } from "@better-auth/oauth-provider";
import { auth } from "@/lib/auth";

export const GET = oauthProviderAuthServerMetadata(auth);

API 服务器

【API Server】

本节展示了你的 API 应如何验证从客户端收到的令牌。

【This section shows how your API should verify tokens received from your clients.】

验证

【Verification】

可以使用通过 oauthProviderResourceClient 插件或 better-auth/oauth2 包提供的 verifyAccessToken 来进行验证。

【Verification can be performed using verifyAccessToken available through the oauthProviderResourceClient plugin or better-auth/oauth2 package.】

使用 better-auth 包:

【With better-auth package:】

api/[endpoint].ts
import { verifyAccessToken } from "better-auth/oauth2";

export const GET = async (req: Request) => {
  const authorization = req.headers?.get("authorization") ?? undefined;
  const accessToken = authorization?.startsWith("Bearer ")
    ? authorization.replace("Bearer ", "")
    : authorization;
  const payload = await verifyAccessToken(
    accessToken, {
      verifyOptions: {
        issuer: "https://auth.example.com",
        audience: "https://api.example.com",
      },
      scopes: ["read:post"], // optional
    }
  );
  // ...continue
}

使用 oauthProviderResourceClient 插件:

【With oauthProviderResourceClient plugin:】

api/[endpoint].ts
import { serverClient } from "@/lib/server-client";

export const POST = async (req: Request) => {
  const authorization = req.headers?.get("authorization") ?? undefined;
  const accessToken = authorization?.startsWith("Bearer ")
    ? authorization.replace("Bearer ", "")
    : authorization;
  const payload = await serverClient.verifyAccessToken(
    accessToken, {
      verifyOptions: {
        issuer: "https://auth.example.com",
        audience: "https://api.example.com",
      },
      scopes: ["write:post"], // optional
    }
  );
  // ...continue
}

JWT 验证

【JWT Verification】

  • 验证令牌是否有效:
    • 使用 JWKS 验证 signature
    • 检查 iss(发行者)和 aud(受众)声明。
    • 验证 exp(过期时间)以及(如果发送了)nbf 声明。
  • 验证每个端点的适当 scope

不透明访问令牌

【Opaque Access Tokens】

  • 将收到的令牌发送到 /oauth2/introspect 并断言返回 active: true
  • 验证每个端点的适当 scope

建议

【Recommendations】

最简单的方法是 _ 仅接受 JWT 格式的访问令牌 _ 用于你的 API,并拒绝不透明令牌。

【The simplest approach is to only accept JWT-formatted access tokens for your API and deny opaque tokens.】

优点

  • 快速:可本地验证,无需网络请求。
  • 面向未来:签发后独立于授权服务器。
  • 无需客户端密钥:API 可在无需保密客户端凭证的情况下验证令牌。

除了 JWT 令牌之外,接受不透明访问令牌也是可能的,但伴随着权衡取舍。

【Accepting opaque access tokens in addition to JWT tokens is possible, but comes with trade-offs.】

优点

  • 即时的令牌和客户端验证。
  • 客户端无需 resource 参数(取决于授权服务器配置)。

缺点

  • DOS(拒绝服务):如果客户端是外部的(例如外部 API、MCP 代理),不透明的 access_token 验证可能会使你的授权服务器过载。
  • 性能:每个接收到的不透明 access_token 都需要向内省端点发起网络请求。
  • 需要密钥:内省通常需要 client_secret,公共客户端无法安全提供。
    • 注意:内省承载令牌和私钥 JWT 方法尚未实现。

范围 vs 权限

【Scopes vs. Permissions】

  • 作用域 定义了客户端应用代表用户 请求 的内容。它们通常是包含在访问令牌中的粗粒度标签。
  • 权限 定义了用户(或服务)实际上被允许在资源上执行的细粒度操作,通常在资源服务器上强制执行。

在实际操作中,你也可以根据系统的复杂性以及资源服务器处理授权的方式来结合使用不同的方法。

【In practice, you may also combine approaches depending on system complexity and how your resource server handles authorization.】

作用域和权限相同

每个作用域直接代表一种权限。

  • 例如:作用域 read:post 完全对应权限 read:post

【Each scope directly represents a permission.

  • Example: A scope read:post corresponds exactly to the permission read:post.】

优点:

  • 简单易实现且易于理解。
  • 无需额外的映射逻辑。

Pros:

  • Simple to implement and reason about.
  • No extra mapping logic required.】

缺点:

  • 如果权限非常详细,尤其是使用 JWT 时,访问令牌可能会变得很大。
  • 对将来更细粒度的权限支持有限。

Cons:

  • Access tokens can become large if permissions are very detailed, especially with JWTs.
  • Limited flexibility for future, more granular permissions.】

作用域和权限是不同的

作用域代表高级访问类别,每个作用域对应一个或多个底层权限。

【Scopes represent high-level access categories, and each scope maps to one or more underlying permissions.】

  • 示例: 范围 view:post 可以映射为:
    • read:post:content
    • read:post:metadata(但仅限于用户拥有的帖子)

优点:

  • 对复杂系统具有灵活性和可扩展性。
  • 令牌保持紧凑,因为只包含作用域,而不是所有权限。

Pros:

  • Flexible and scalable for complex systems.
  • Tokens remain compact, since only scopes are included, not all permissions.】

缺点:

  • 资源服务器必须为每个请求将作用域解析为权限。
  • 增加实现和授权检查的复杂性。

Cons:

  • The resource server must resolve scopes into permissions for each request.
  • Adds complexity to implementation and authorization checks.】

配置

【Configuration】

重定向屏幕

【Redirect Screens】

在 OAuth 流程中,用户很可能会在不同页面之间跳转。例如,用户可能会从登录界面开始,然后重定向到同意界面,最后返回应用。以下概述了可能的登录流程以及提供每种流程所需的配置。

【During the OAuth flow, users are likely redirected between pages. For example, a user may start on a login screen then redirect to a consent screen before returning to the application. The following outlines possible login flows and configurations needed to provide each flow.】

为了处理登录流程中的每个重定向步骤,我们会验证在初始 /oauth2/authorize 重定向中提供的签名查询。发送到授权端点的所有参数(包括任何自定义参数)都会被签名并进行验证。

【To process each redirect step in the login flow, we verify the signed query provided in the initial /oauth2/authorize redirect. All parameters sent to the authorize endpoint (including any custom ones), are signed and verified.】

如果你的登录页面包含任何自定义查询参数,你可以将它们附加到签名查询的末尾(即在 sig 字段之后)。

【If your sign-in pages include any custom query parameters, you may append them to the end of the signed query (ie after the sig field).】

如果你使用客户端插件 oauthProviderClient,那么 oauth_query 参数会自动发送到所有需要它的端点。如果你有自定义的登录端点,则需要在请求体 oauth_query 中手动添加窗口的签名查询。这只应包括已签名的查询参数。

【If you utilize the Client Plugin oauthProviderClient, then the oauth_query parameter is automatically sent to every endpoint that requires it. If you have custom sign-in endpoints, you would need to manually add the window's signed query in the request body oauth_query. This should only include the signed query parameters.】

登录界面

【Login Screen】

当用户被重定向到 OIDC 提供商进行身份验证时,如果他们尚未登录,将会被重定向到登录页面。你可以在初始化时通过提供 loginPage 选项来自定义登录页面。

【When a user is redirected to the OIDC provider for authentication, if they are not already logged in, they will be redirected to the login page. You can customize the login page by providing a loginPage option during initialization.】

auth.ts
oauthProvider({
  loginPage: "/sign-in"
})

你无需从你的端处理任何事情;当创建新会话时,插件将负责继续授权流程。

【You don't need to handle anything from your side; when a new session is created, the plugin will handle continuing the authorization flow.】

【Consent Screen】

当用户被重定向到 OIDC 提供者进行身份验证时,可能会被提示授权应用访问他们的数据。

【When a user is redirected to the OIDC provider for authentication, they may be prompted to authorize the application to access their data.】

注意:具有 skipConsent: true 的受信任客户端将完全跳过同意屏幕,为第一方应用提供无缝体验。

auth.ts
oauthProvider({
  consentPage: "/consent"
})

该插件将会把用户重定向到指定路径,并附带 client_idscope 查询参数。你可以使用这些信息来显示自定义的同意页面。一旦用户同意,你可以调用 oauth2.consent 来完成授权。

【The plugin will redirect the user to the specified path with client_id and scope query parameters. You can use this information to display a custom consent screen. Once the user consents, you can call oauth2.consent to complete the authorization.】

consent-page.ts
const res = await client.oauth2.consent({
	accept: true,
  // optional scopes accepted (if not sent, accepted scopes matches the original request)
  scope: "openid profile email"
});

注册账号界面

【Sign Up Account Screen】

要将用户从客户端引导到注册页面,使用 prompt: create 时,使用 signup

【To direct users from the client to a sign up page using prompt: create, use signup.】

auth.ts
oauthProvider({
  signUp: {
    page: "/sign-up", 
  }
})

要在完成注册表格的过程中停止标记,请使用 shouldRedirect 函数。

【To stop sign in process to complete registration forms, use the shouldRedirect function.】

auth.ts
import { userRegistered } from "@lib";

oauthProvider({
  signUp: {
    page: "/sign-up",
    shouldRedirect: async ({ headers }) => { 
      const isUserRegistered = await userRegistered(headers);
      return isUserRegistered ? false : "/setup";
    },
  }
})

选择账户屏幕

【Select Account Screen】

当用户在身份验证过程中被重定向到选择账户页面时,可能会在同意之前被提示选择一个账户。要启用账户选择,你必须将以下配置添加到你的设置中。

【When a user is redirected to the select account page during authentication, they may be prompted to select an account before consenting. To enable account selection, you must add the following configuration to your settings.】

以下示例使用多会话插件,并且如果登录了多个会话,会自动重定向到选择账户页面:

【The following example uses the multi-session plugin and automatically redirects to the select-account page if more than one session is logged in:】

auth.ts
oauthProvider({
  selectAccount: {
    page: "/select-account", 
    shouldRedirect: async ({ headers }) => { 
      const allSessions = await auth.api.listDeviceSessions({
        headers,
      })
      return allSessions?.length >= 1;
    },
  }
})

该插件会将用户重定向到 selectAccount.page。此页面应提示用户选择账户,并在选择完成后调用 oauth2Continue

【The plugin will redirect the user to the selectAccount.page. This page should prompt for account selection and upon completion of selection, should call oauth2Continue.】

select-account.ts
await authClient.multiSession.setActive({
  sessionToken,
});
await client.oauth2.oauth2Continue({
  selected: true,
});

登录后界面

【Post Login Screen】

如果请求的权限范围需要一个组织,你需要提供以下所有选项,以将 reference_id(即组织 ID、团队 ID)与登录流程关联。此步骤发生在登录之后且在同意之前。

【If a requested scope requires an organization. You would need to provide all of the following options to tie the reference_id (ie organization id, team id) to the login flow. This step occurs post login and prior to consent.】

以下示例使用组织插件自动重定向到组织特定权限的选择组织页面。

【The following example uses the organization plugin to automatically redirect to the select-organization page for organization specific scopes.】

auth.ts
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

【The plugin will redirect the user to the postLogin.page to provide a prompt for account selection. Upon completion, you should call oauth2Continue.】

select-organization.ts
await authClient.organization.setActive({
  organizationId,
});
await client.oauth2.oauth2Continue({
  postLogin: true,
});

缓存的受信任客户端

【Cached Trusted Clients】

对于第一方应用和内部服务,你可以缓存受信任的客户端以获得更好的性能。所有提到的客户端的值都将缓存于内存中。此外,它们还可以防止通过 CRUD 端点进行更改。

【For first-party applications and internal services, you can cache trusted clients for better performance. Values are cached in memory for all mentioned clients. Additionally, they prevent changes through the CRUD endpoints.】

auth.ts
oauthProvider({
  // List of clientIds of the clients
  cachedTrustedClients: new Set([
    "internal-dashboard",
    "mobile-app",
  ]),
})

有效受众

【Valid Audiences】

此 Oauth 服务器的有效受众(即资源)列表。如果未指定,默认受众为 baseUrl。建议指定除 baseUrl 之外的受众,例如你的 API。

【A list of valid audiences (ie resources) for this oauth server. If not specified, the default audience is the baseUrl. It is recommended to specify an audience other than the baseUrl such as your API.】

auth.ts
oauthProvider({
  validAudiences: [
    "https://api.example.com",
    "https://api.example.com/mcp",
  ]
})

范围

【Scopes】

作用域允许客户端对特定资源进行特定访问。默认情况下,我们支持以下作用域:

【Scopes allow clients specific access to specific resources. By default, we support the following scopes are supported:】

  • openid:返回用户的 ID(sub 声明)。
  • profile:返回名称、图片、名字、姓氏
  • email:返回电子邮件和电子邮件是否已验证
  • offline_access:返回刷新令牌

范围配置可以包含任意多或任意少的范围!请注意,openid 是被认为是 OIDC 服务器所必需的,否则这只是一个标准的 OAuth 2.1 服务器。所有支持的范围都必须包含在此数组中。

【The scopes configuration can contain as many or as few scopes as you wish! Note that openid is required to be considered an OIDC server, otherwise this is a standard OAuth 2.1 server. All supported scopes must be in this array.】

auth.ts
oauthProvider({
  scopes: [ "openid", "profile", "offline_access", "read:post", "write:post" ],
})

权利要求

【Claims】

内部,我们支持以下支持的主张:“sub”、“iss”、“aud”、“exp”、“iat”、“sid”、“scope”、“azp”]。

【Internally, we support the following claims are supported: ["sub", "iss", "aud", "exp", "iat", "sid", "scope", "azp"].】

在可能的情况下,身份令牌和用户信息声明应该使用命名空间,以避免将来可能的冲突。

【Id token and user info claims should be namespaced when possible to avoid potential future conflicts.】

应将添加到 customIdTokenClaimscustomUserInfoClaims 中的声明添加到 advertisedMetadata.claims_supported,以便客户端可以验证接收到的声明。在以下示例中,它将是基础声明加上 “locale” 和 “https://example.com/org”。

【Claims added inside customIdTokenClaims and customUserInfoClaims should be added to the advertisedMetadata.claims_supported so clients can validate that claim received. In the following example, it would be the base claims plus "locale" and "https://example.com/org".】

专业提示:这些函数也可能抛出错误,例如用户不再是该组织的成员或不再拥有所请求的权限。

【Pro tip: these functions can may also throw errors such as a user is no longer a member of the organization or no longer has the requested permissions.】

auth.ts
oauthProvider({
  // Attach claims to id tokens
  customIdTokenClaims: ({ user, scopes, metadata }) => {
    return {
      locale: "en-GB",
    };
  },
  // Attach claims to access tokens
  customAccessTokenClaims: ({ user, scopes, referenceId, resource, metadata }) => {
    return {
      "https://example.com/org": referenceId,
      "https://example.com/roles": ["editor"],
    };
  },
  // Additional user info claims
  customUserInfoClaims: ({ user, scopes, jwt }) => {
    return {
      locale: "en-GB",
    };
  },
})

到期

【Expirations】

每种令牌类型和授权类型都可以独立设置默认过期时间。

【Each token type and grant type can independently can set a default expiration.】

  • accessTokenExpiresIn 默认为 1 小时
  • m2mAccessTokenExpiresIn 默认值为 1 小时
  • idTokenExpiresIn 默认为 10 小时
  • refreshTokenExpiresIn 默认为 30 天
  • codeExpiresIn 默认值为 10 分钟

此外,访问令牌可以根据权限范围设置较短的过期时间。这对需要较短过期时间的高级权限范围非常有用。最早的过期时间将优先。如果未指定,则使用默认值。注意:值应低于默认的 accessTokenExpiresInm2mAccessTokenExpiresIn

【Additionally, Access Tokens can set lower expirations based on scopes. This is useful for higher-privilege scopes that require shorter expiration times. The earliest expiration will take precedence. If not specified, the default will take place. Note: values should be lower than the defaults accessTokenExpiresIn and m2mAccessTokenExpiresIn.】

auth.ts
oauthProvider({
  scopeExpirations: {
    "write:payments": "5m",
    "read:payments": "30m",
  },
})

注册

【Registration】

动态客户端注册

【Dynamic Client Registration】

动态注册允许授权注册公共客户端和机密客户端。

【Dynamic registration allows for authorized registration of both public and confidential clients.】

auth.ts
oauthProvider({
  allowDynamicClientRegistration: true, 
})

未认证的客户端注册还允许公共客户端(永远不保密)在没有授权头的情况下注册。这对于 MCP 动态将自己注册为公共客户端特别有用。

【Unauthenticated client registration additionally allows for public clients (never confidential) to register without an authorization header. This is especially useful for an MCP to dynamically register themselves as a public client.】

auth.ts
oauthProvider({
  allowDynamicClientRegistration: true,
  allowUnauthenticatedClientRegistration: true, 
})

allowUnauthenticatedClientRegistration 的支持将被弃用,当 MCP 协议标准化未认证的动态客户端注册时。截至目前,客户端 ID 元数据文档software_statementjwks_uri 仍在讨论中。

动态客户端注册过期

【Dynamic Client Registration Expiration】

你可以设置动态注册的机密客户端的有效期限。默认情况下,动态注册的机密客户端不会过期。

【You can set an expiration time for how long a dynamically registered confidential client should last for. By default, dynamically registered confidential clients do not expire.】

auth.ts
oauthProvider({
  allowDynamicClientRegistration: true,
  clientRegistrationClientSecretExpiration: "30d", 
})

动态客户端注册范围

【Dynamic Client Registration Scopes】

要在未发送 scopes 参数时为新注册的客户端设置默认作用域列表,请设置 clientRegistrationDefaultScopes 字段。所有作用域必须在 scopes 中定义。

【To set a list of default scopes for newly registered clients when scopes parameter is not sent, set the clientRegistrationDefaultScopes field. All scopes must be defined in scopes.】

auth.ts
oauthProvider({
  scopes: ["reader", "editor"],
  clientRegistrationDefaultScopes: ["reader"], 
})

要在未发送 scopes 参数时,为新注册的客户端设置允许的范围列表,请设置 clientRegistrationAllowedScopes 字段。这些是对 clientRegistrationDefaultScopes补充。所有范围必须在 scopes 中定义。

【To also set a list of allowed scopes for newly registered clients when scopes parameter is not sent, set the clientRegistrationAllowedScopes field. These are in addition to the clientRegistrationDefaultScopes. All scopes must be defined in scopes.】

auth.ts
oauthProvider({
  scopes: ["reader", "editor"],
  clientRegistrationDefaultScopes: ["reader"],
  clientRegistrationAllowedScopes: ["editor"], 
})

组织

【Organizations】

OAuth 客户端在注册时绑定到用户或 reference_id,并且不可更改。如果你正在使用 organization 插件,在创建新客户端时,必须确保在你的活动会话中设置了 activeOrganizationId

【OAuth Clients are tied to either a user or reference_id at registration and is immutable. If you are utilizing the organization plugin, you must ensure that the activeOrganizationId is set on your active session when you create new clients.】

auth.ts
oauthProvider({
  clientReference: ({ session }) => {
    return (session?.activeOrganizationId as string | undefined) ?? undefined;
  },
})

有关在令牌上设置用户特定的权限和角色,请参见 Claims

【To set user-specific permissions and roles on tokens see Claims.】

客户 CRUD 权限

【Client CRUD Privileges】

要确定已登录用户是否有能力在客户创建中执行特定操作,可以使用 clientPrivileges 配置设置。默认情况下,CRUD 操作允许具有匹配 userIdclientReference 的用户执行。

【To determine whether a logged in user has the ability to perform specific actions in client creation, you can utilize the clientPrivileges configuration setting. By default, CRUD actions are allowed for users with matching userId or clientReference.】

以下是一个基本示例,假设普通用户无法创建客户端,允许组织所有者进行所有 OAuth 客户端的增删改查操作:

【The following is a basic example that allows all OAuth Client CRUD actions for organization owners assuming ordinary users cannot create clients:】

auth.ts
oauthProvider({
  clientPrivileges: async ({ action, headers, user, session }) => {
    if (!session?.activeOrganizationId) return false;
    const { data: member } = await auth.api.getActiveMember({
      headers,
    });
    return member.role === 'owner';
  },
})

存储

【Storage】

默认情况下,数据库中的所有密钥默认都是 hashed。这有助于在数据库泄露的情况下保护 client_secret

【By default all secrets are hashed by default on the database. This helps protect the client_secret in case of a database leak.】

  • storeClientSecret:应用程序 client_secrets 的存储方式。只有在 disableJwtPlugin: true 时,客户端密钥才应为 encrypted
  • storeTokens:令牌值的存储方式,特别是会话刷新令牌和不透明访问令牌。

刷新令牌自定义

【Refresh Token Customization】

你可以使用 formatRefreshToken 将会话令牌格式化为不同的字符串格式。

【You can choose to format your session tokens in a different string format using the formatRefreshToken.】

这些功能允许你在刷新令牌本身上添加额外的功能,例如刷新令牌加密。

【These functions allow you to add additional functionality on the refresh token itself such as refresh token encryption.】

带有刷新令牌格式更改且向后兼容原始仅令牌格式的示例:

【Example with change in refresh token format with backwards compatibility with original token-only format:】

auth.ts
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 };
    },
  }
})

令牌加密方法的伪代码:

【Pseudocode for a token encryption method:】

auth.ts
import { CompactEncrypt, compactDecrypt } from 'jose'

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);
      },
    }
  })]
})

广告元数据

【Advertised Metadata】

元数据端点可以进行自定义,以便公开的作用域和声明与服务器实际可以提供的不同。这可以防止在元数据端点上展示所有受支持的作用域和声明。

【The metadata endpoint can be customized so that the publicized scopes and claims differ from those which the server can deliver. This can prevent showcasing all your supported scopes and claims on your metadata endpoint.】

advertisedMetadata 部分内的所有权限范围必须列在 scopes 中,否则初始化将失败。

【All scopes inside the advertisedMetadata section MUST be listed in scopes otherwise initialization will fail.】

范围

【Scopes】

auth.ts
oauthProvider({
  scopes: ["openid", "profile", "email", "offline_access", "read:post"],
  advertisedMetadata: {
    scopes_supported: ["openid", "profile", "read:post"],
  },
})

权利要求

【Claims】

声明是对由 scopes 自动确定的内部支持声明的补充。声明仅适用于 OIDC(即“openid”范围)。

【Claims are in addition to the internally supported claims which are automatically determined by scopes. Claims are only applicable for the OIDC (ie "openid" scope).】

auth.ts
oauthProvider({
  advertisedMetadata: {
    claims_supported: ["https://example.com/roles"],
  },
})

禁用 JWT 插件

【Disable JWT Plugin】

默认情况下,可以通过 JWT 插件颁发和验证访问令牌和 ID 令牌。

【By default, access and id tokens can be issued and verified through the JWT plugin.】

你可以禁用 JWT 要求,在这种情况下访问令牌将始终是不可见的,ID 令牌将始终使用 client_secretHS256 签名。请注意,禁用 JWT 插件仍然符合 OIDC 标准,/userinfo 仍然可用,并且仍会提供已签名的 id_token

【You can disable the JWT requirement in which access tokens will always be opaque and id tokens are always signed in HS256 using the client_secret. Note that disabling the JWT Plugin is still OIDC compliant, /userinfo still works and signed id_token is still provided.】

主要区别:

  • 提供有效的 resource 将始终为你提供一个不透明的访问令牌,而不是 JWT 格式的令牌。
  • 对于公共客户端,不会返回 id_token,但返回的 access_token 仍然可以使用 /oauth2/userinfo 端点来获取用户数据。
  • 对于机密客户端,id_token 由其 client_secret 签名。

【Key Differences:

  • Providing a valid resource will always provide you with an opaque access token instead of an JWT formatted token.
  • id_token is not returned for public clients, but the access_token returned can still utilize the /oauth2/userinfo endpoint to obtain the user data.
  • id_token for a confidential client is signed by their client_secret.】
auth.ts
oauthProvider({
  disableJwtPlugin: true, 
})

MCP

你可以轻松地通过添加一个资源服务器来使你的 API 与 MCP 兼容,该服务器会将用户引导到此 OAuth 2.1 授权服务器。

【You can easily make your APIs MCP-compatible simply by adding a resource server which directs users to this OAuth 2.1 authorization server.】

如果您正在使用 "openid" 和机密的 MCP 客户端,则无法禁用 JWT 插件,因为 id_token 验证可能不一定通过 client_secret 支持。

安装

【Installation】

添加资源服务器客户端

(可选)如果你在本地有身份验证配置,可将该配置作为参数添加到客户端,以填写这些值并提醒你配置错误。你始终可以在函数调用中覆盖这些值。如果未提供,TypeScript 将引导你使用所需的最小配置值。

server-client.ts
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 optional
});

向你的 API 添加受 OAuth 保护的资源元数据

/.well-known/oauth-protected-resource/[resource-path]/route.ts
import { serverClient } from "@/lib/server-client";

export const GET = async () => {
  const metadata = await serverClient.getProtectedResourceMetadata({
    resource: "https://api.example.com", // `aud` claim
    authorization_servers: ["https://auth.example.com"],
  })

  return new Response(JSON.stringify(metadata), {
    headers: {
      "Content-Type": "application/json",
      "Cache-Control":
        "public, max-age=15, stale-while-revalidate=15, stale-if-error=86400",
    },
  });
};

如果你使用 allowUnauthenticatedClientRegistration,你必须确保你的 API 服务器本身是一个机密客户端:

await auth.api.createOAuthClient({
  headers,
  body: {
    redirect_uris: [redirectUri],
  }
});

这些值应该在验证选项 remoteVerify.clientIdremoteVerify.clientSecret 中使用。此外,remoteVerify.introspectUrl 类似于 ${BASE_URL}/${AUTH_PATH}/oauth2/introspect

如果你选择不支持 allowUnauthenticatedClientRegistration(只支持 allowDynamicClientRegistration),MCP 客户端(例如 ChatGPT、Anthropic、Gemini)需要允许你在其界面中或在与 AI 聊天时输入一个公共 client_id。

处理你的 API 的 MCP 错误

始终针对指定的 audience 进行验证,默认情况下将与所有 validAudiencesbaseUrl 进行比较。

  • 使用客户端的 verifyAccessToken 函数

请参阅 验证 获取验证示例。

  • 如果有认证可用,使用客户端的 verifyAccessToken 函数自动确定端点
api/[endpoint].ts
import { auth } from "@/lib/auth";
import { serverClient } from "@/lib/server-client";

export const GET = async (req: Request) => {
  const authorization = req.headers?.get("authorization") ?? undefined;
  const accessToken = authorization?.startsWith("Bearer ")
    ? authorization.replace("Bearer ", "")
    : authorization;
  const payload = await serverClient.verifyAccessToken(
    accessToken, {
      verifyOptions: {
        audience: "https://api.example.com",
      }
    }
  );
  // ...continue
}
  • 使用 mcpHandler 辅助程序
api/[transport]/route.ts
import { createMcpHandler } from "mcp-handler";
import { mcpHandler } from "@better-auth/oauth-provider";
import { z } from "zod";

const handler = mcpHandler({
  jwksUrl: "https://auth.example.com/api/auth/jwks",
  verifyOptions: {
    issuer: "https://auth.example.com",
    audience: "https://api.example.com",
  },
}, (req, jwt) => {
  return createMcpHandler(
    (server) => {
      server.registerTool(
        "echo", {
          description: "Echo a message",
          inputSchema: {
            message: z.string(),
          },
        },
        async ({ message }) => {
          return {
            content: [
              {
                type: "text",
                text: `Echo: ${message}${
                  jwt?.sub
                    ? ` for user ${jwt.sub}`
                    : ""
                }`,
              },
            ],
          };
        }
      );
    }, {
      serverInfo: {
        name: "demo-better-auth",
        version: "1.0.0",
      }
    }, {
      basePath: "/api",
      maxDuration: 60,
      verboseLogs: true,
    }
  )(req);
});

export { handler as GET, handler as POST, handler as DELETE };

架构

【Schema】

OAuth 提供者插件会向数据库添加以下表格:

【The OAuth Provider plugin adds the following tables to the database:】

OAuth 客户端

【OAuth Client】

表名:oauthClient

【Table Name: oauthClient

Field NameTypeKeyDescription
idstringDatabase ID of the OAuth client
clientIdstringUnique identifier for each OAuth client
clientSecretstringSecret key for the OAuth client. Optional for public clients using PKCE.
disabledbooleanField that indicates if the current application is disabled
skipConsentbooleanField that indicates if the application can skip consent. You may choose to enable this for trusted applications.
enableEndSessionbooleanField that indicates if the application can logout via an id_token. You may choose to enable this for trusted applications.
scopesstring[]Scopes this client is allowed to use
userIdstringID of the client owner. (optional)
referenceIdstringID of the reference of the client owner if not a user. (optional)
createdAtDate-Timestamp of when the OAuth client was created
updatedAtDate-Timestamp of when the OAuth client was last updated
namestringName of the OAuth client
uristringWebsite Uri displayed on UI Screens
iconstringWebsite Icon displayed on UI Screens
contactsstring[]Client contact list (ie customer service emails, phone numbers) to be displayed on UI Screens
tosstringClient Terms of Service displayed on UI Screens
policystringClient Privacy policy displayed on UI Screens
softwareIdstringClient-defined software identifier. This should remain the same across multiple versions for the same piece of software.
softwareVersionstringClient-defined version number of the softwareId.
softwareStatementstringSigned JWT containing the software metadata as signed claims.
redirectUrisstring[]-Array of of redirect uris
tokenEndpointAuthMethodstringIndicator of requested authentication method for the token endpoint. Supports: ['none', 'client_secret_basic', 'client_secret_post']
grantTypesstring[]Array of supported grant types. Supports: ['authorization_code', 'client_credentials', 'refresh_token']
responseTypesstring[]Array of supported grant types. Supports: ['code']
publicbooleanIndication if the client is confidential or public
typestringType of OAuth client. Supports: ['web', 'native', 'user-agent-based']
metadatajsonAdditional metadata for the OAuth client

OAuth 刷新令牌

【OAuth Refresh Token】

表名:oauthRefreshToken

【Table Name: oauthRefreshToken

Field NameTypeKeyDescription
idstringDatabase ID of the refresh token
tokenstring-Hashed/encrypted refresh token
clientIdstringID of the OAuth client
sessionIdstringID of the session used at issuance of the token (and still active)
userIdstringID of the user associated with the token
referenceIdstringID of the consented reference
scopesstring[]-Array of granted scopes
revokedDateTimestamp when the token was revoked
createdAtDate-Timestamp when the token was created
expiresAtDate-Timestamp when the token will expire

OAuth 访问令牌

【OAuth Access Token】

表名:oauthAccessToken

【Table Name: oauthAccessToken

Field NameTypeKeyDescription
idstringDatabase ID of the opaque access token
tokenstring-Hashed/encrypted access token
clientIdstringID of the OAuth client
sessionIdstringID of the session used at issuance of the token (and still active)
refreshIdstringID of the refresh associated with the token
userIdstringID of the user associated with the token
referenceIdstringID of the consented reference
scopesstring[]-Array of granted scopes
createdAtDate-Timestamp when the token was created
expiresAtDate-Timestamp when the token will expire

【OAuth Consent】

表名:oauthConsent

【Table Name: oauthConsent

Field NameTypeKeyDescription
idstringDatabase ID of the consent
userIdstringID of the user who gave consent
clientIdstringID of the OAuth client
referenceIdstringID of the consented reference
scopesstring-Comma-separated list of scopes consented to
createdAtDate-Timestamp of when the consent was given
updatedAtDate-Timestamp of when the consent was last updated

选项

【Options】

前缀

【Prefix】

在不透明访问令牌、刷新令牌或客户端密钥前添加一个 prefix。这对于秘密扫描工具(例如 GitHub Secret ScannersGitGuardianTrufflehog)非常有用,这些工具可能依赖前缀来帮助确定令牌的格式。

【Add a prefix to opaque access tokens, refresh tokens, or client secrets. This is useful for Secret Scanners (ie. GitHub Secret Scanners, GitGuardian, Trufflehog) that may rely on the prefix to help determine the token format.】

我们建议在首次生产部署之前,为以下每一项添加前缀。一旦部署,就应视为不可更改,否则将按指定生成以下功能:

【We recommend to add a prefix to each of the following prior to your first production deployment. Once deployed consider them immutable, otherwise the following generate functions as specified:】

以下内容可在 prefix 配置设置下使用:

  • opaqueAccessTokenstring | undefined - 在不透明访问令牌上添加前缀。如果之前已部署,请改用 generateOpaqueAccessToken 来执行此功能。
  • refreshTokenstring | undefined - 在刷新令牌上添加前缀。如果之前已部署,请改用 generateRefreshToken 来执行此功能。
  • clientSecretstring | undefined - 在客户端密钥上添加前缀。如果之前已部署,请改用 generateClientSecret 来执行此功能。

【The following are available under the prefix configuration setting:

  • opaqueAccessToken: string | undefined - add a prefix onto opaque access tokens. If previously deployed, utilize generateOpaqueAccessToken to perform this functionality instead.
  • refreshToken: string | undefined - add a prefix onto refresh tokens. If previously deployed, utilize generateRefreshToken to perform this functionality instead.
  • clientSecret:: string | undefined - add a prefix onto client secrets. If previously deployed, utilize generateClientSecret to perform this functionality instead.】

优化

【Optimizations】

为了提高查询性能,数据库适配器可能会将表 oauthClient 中的字段 client_id 映射到 id。请注意,id 应支持类似 UUID 和 URL 格式的字符串。

【To improve lookup performance, database adapters may map the field client_id on the table oauthClient to id. Note that id should support strings formatted like UUIDs and urls.】

迁移

【Migrations】

来自 OIDC 提供商插件

【From OIDC Provider Plugin】

配置

【Configuration】

  • idTokenExpiresIn 现在默认为 10 小时(以前通过 accessTokenExpiresIn1 小时
  • refreshTokenExpiresIn 现在默认为 30 天(之前为 7 天
  • advertisedMetadata(以前称为 metadata)不再支持更改元数据字段,以防止意外配置错误。
  • clientRegistrationDefaultScopes(以前的 defaultScope)现在使用数组格式,而不是以空格分隔的字符串
  • consentPage 现在是必需的
  • getConsentHTML 已被移除,取而代之的是 consentPage,因为原始 HTML 并不是 OAuth 中 authorize 端点支持的响应类型
  • requirePKCE 已被移除,因为在 OAuth 2.1 中必须使用 PKCE
  • allowPlainCodeChallengeMethod 已被移除,因为 plain 代码挑战方法被认为比默认的 S256 方法安全性更低
  • customUserInfoClaims(以前称为 getAdditionalUserInfoClaim)传递的是 jwt 载荷,而不是请求中使用的访问令牌的客户端。
  • storeClientSecret 现在默认为 hashed,如果 disableJwtPlugin: true 则为 encrypted(以前是 plain)。
  • JWT 插件现在默认启用。要禁用该插件,请设置 disableJwtPlugin: true
  • 授权查询 code_challenge_method "S256" 必须大写,正如 OAuth 2.1 所描述的那样

数据库

【Database】

表:oauthClient

【Table: oauthClient

之前的 oauthApplication

【Previously oauthApplication

  • 如果 storeClientSecret 未设置或为 plain,你必须将所有存储的 clientSecret 值哈希为其“SHA-256”表示,然后转换为 base64Url 格式,或使用 storeClientSecret 指定的其他存储方法。以下函数将 plain 表示转换为默认哈希:
import { createHash } from "@better-auth/utils/hash";
import { base64Url } from "@better-auth/utils/base64";

const defaultHasher = async (value: string) => {
	const hash = await createHash("SHA-256").digest(
		new TextEncoder().encode(value),
	);
	const hashed = base64Url.encode(new Uint8Array(hash), {
		padding: false,
	});
	return hashed;
};
  • type field is no longer a required field. Instead, the schema requires public of type boolean. Migrate with the following rules:
    • Clients with type: "public": set type: undefined, public: true, and clientSecret: undefined
    • Clients with type: "native": set public: true and clientSecret: undefined
    • Clients with type: "user-agent-based": set public: true and clientSecret: undefined
    • Clients with clientSecret: undefined: set public: true
  • redirectURLs renamed to redirectUris
  • metadata is now stored in database as individual fields instead of a JSON object. Parse the metadata into their respective fields. The OIDC plugin did not utilize this field but this OAuth plugin may utilize them in the future.
表格:oauthAccessToken

【Table: oauthAccessToken

选项 1(简单):

【Option 1 (simple):】

你可以选择不进行此表转换,影响最小。这样一来,现有应用的用户只需重新登录即可。只需删除现有的表 oauthAccessToken

【You may choose to opt-out of this table conversion with minimal impact. By doing so, users of the existing application will simply need to login again. Simply delete the existing table oauthAccessToken.】

选项 2(更复杂):

【Option 2 (more complex):】

迁移所有表(在迁移之前,你可能需要将 oauthAccessToken 克隆到 oauthRefreshToken)。

【Migrate all tables (you may need to create a clone of oauthAccessToken into oauthRefreshToken before a migration).】

  • 将带有 refreshToken 字段的 oauthAccessToken 转换为新的 oauthRefreshToken 条目。
{
  token: defaultHasher(refreshToken),
  expiresAt: refreshTokenExpiresAt,
  clientId: clientId,
  scopes: scopes,
  userId: userId,
  createdAt: createdAt,
  updatedAt: updatedAt,
}
  • 保留 oauthAccessToken,但引用新的 oauthRefreshToken
{
  token: defaultHasher(accessToken),
  expiresAt: accessTokenExpiresAt,
  clientId: clientId,
  scopes: scopes,
  refreshId: oauthRefreshToken.id, // `undefined` if no refreshToken
  createdAt: createdAt,
  updatedAt: updatedAt,
}

来自 MCP 插件

【From MCP Plugin】

MCP 端点已从 /mcp 移动到 /oauth2 的对应端点。

【The MCP endpoints moved from /mcp to the /oauth2 equivalent.】

  • /oauth2/authorize(以前是 /mcp/authorize
  • /oauth2/token(之前为 /mcp/token
  • /oauth2/register(之前为 /mcp/register
  • /mcp/get-session 因不符合 OAuth 2 标准已被移除,请改用 /oauth2/introspect
  • 已移除 /.well-known/oauth-protected-resource,请使用辅助工具 mcpHandler(或手动使用服务器的 api.oAuth2introspectVerify 或资源客户端的 verifyAccessToken
  • 数据库更改等同于 From OIDC Provider Plugin 部分。

On this page