OAuth 2.1 提供者

一个更好的认证插件,使您的认证服务器能够作为 OAuth 2.1 提供者运行。

一个OAuth 2.1 提供者插件,允许您将认证服务器转变为兼容 OIDC 的 OAuth 提供者,支持用户和其他服务通过您的 API 进行认证。

该插件默认具有安全配置,使不熟悉 OAuth 细节的用户也能轻松使用。

主要特性

  • OAuth 2.1: 限制安全实践遵循 OAuth 2.1
  • Issuer Validation: 授权响应包含 iss 参数,以防止 混淆攻击
  • MCP Enabled: 支持 MCP 认证
  • OIDC compatibility: 与 OIDC 兼容,支持 openid scope
    • UserInfo: 提供当前用户详情的端点
    • id_token: JWT 签名的用户信息
    • OIDC Logout: 符合 RP-initiated 的注销
  • Dynamic Client Registration: 允许客户端动态注册客户端。
    • Public Clients: 支持原生移动客户端和用户代理客户端(如 AI)使用公有客户端
    • Confidential Clients: 支持 Web 客户端使用机密客户端
    • Trusted Clients: 配置硬编码的受信任客户端,并可选绕过同意。
  • JWT Plugin compatibility: 默认需要,可选择禁用
    • JWT Signing: 在请求 resource 时签名 JWT 令牌
    • JWKS Verifiable: 在 /jwks 端点远程验证令牌
  • Authorization Prompts: 触发特定登录流程的提示
    • Consent: 确保每个 scope 都获得同意。可通过 prompt=consent 强制。
    • Select Account: 在授予特定 scope 之前确保已选择账户。可通过 prompt=select_account 强制。
  • Resource Endpoints: 读取和管理令牌。
    • Introspection: 符合 RFC7662 的令牌核查。
    • Revocation: 符合 RFC7009 的撤销。

支持的授权方式

  • authorization_code: 使用 PKCE 和 S256 要求进行用户令牌交换的授权码。
  • refresh_token: 通过 offline_access scope 发放刷新令牌并处理访问令牌续期。
  • client_credentials: 用于 API 通信的机器对机器令牌。

安装

挂载插件

将 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", 
      // ...其他选项
    }) 
  ],
});

迁移数据库

运行迁移或生成 schema 以新增数据库所需的字段和表。

npx auth migrate
npx auth generate

请参见 Schema 章节以手动添加这些字段。

添加 ./well-known 端点

请添加所有 Well-Known 端点 到您的项目。如果不确定,系统会通过警告提示位置。

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

创建您的第一个 OAuth 客户端

创建一个机密 OAuth 客户端。

const client = await auth.api.createOAuthClient({
		headers,
		body: {
			redirect_uris: [redirectUri],
		}
	});
console.log(client); // 如果您愿意,可以将 `client_id` 添加到 `cachedTrustedClients`

若要创建公有客户端(即没有 client secret),请设置 token_endpoint_auth_method: "none"

客户端插件

存在两种客户端,您可根据需求添加一种或两种。

OAuth 客户端

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

auth-client.ts
import { createAuthClient } from "better-auth/client";
import { oauthProviderClient } from "@better-auth/oauth-provider/client"

export const authClient = createAuthClient({
  plugins: [
    oauthProviderClient(), 
  ],
});

资源客户端

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

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

用法

该插件作为 OAuth 2.1 服务器运行,具有 OIDC 兼容端点和 JWT 可验证的访问令牌。以下为各端点的详细说明。

OAuth 客户端

OAuth 中有两类客户端:

  • Public Clients:无法存储 client secret,例如原生移动客户端和用户代理客户端(如 AI)
  • Confidential Clients:可以存储 client secret,例如 Web 客户端

获取客户端信息

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

GET/oauth2/get-client
const { data, error } = await authClient.oauth2.getClient({    query: {        client_id, // required    },});
Parameters
client_idstring,required

OAuth 客户端的 client_id

获取公有客户端信息

获取公有客户端字段以在登录流程页面显示(如同意页面),使用以下端点。注意:需用户登录后方可使用。

GET/oauth2/public-client
const { data, error } = await authClient.oauth2.publicClient({    query: {        client_id, // required    },});
Parameters
client_idstring,required

OAuth 客户端的 client_id

获取公有客户端预登录信息

若要在登录前获取公有客户端信息,您必须先在配置中启用该端点:

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

然后,以下端点将获取公有客户端信息。

POST/oauth2/public-client-prelogin
const { data, error } = await authClient.oauth2.publicClientPrelogin({    client_id, // required    oauth_query, // required});
Parameters
client_idstring,required

OAuth 客户端的 client_id

oauth_querystringrequired

有效的 oauth 查询参数(使用提供的客户端时会自动发送)

列出客户端

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

GET/oauth2/get-clients
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、公司管理员门户或服务器初始化逻辑创建带有受限字段的客户端,可使用以下仅限服务器端的端点:

admin-create-oauth.ts
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 描述的注册端点相同。

POST/oauth2/update-client
const { data, error } = await authClient.oauth2.updateClient({    client_id, // required    update, // required});
Parameters
client_idstring,required

OAuth 客户端的 client_id

updateOAuthClient,required

要更新的字段

此端点限制如下:

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

部分场景下,您可能希望通过自定义 API、公司管理员门户或服务器初始化逻辑更新带有受限字段的客户端,可使用以下仅限服务器端端点。字段同创建部分描述。

admin-update-oauth.ts
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 发起注销
  }
});

旋转客户端密钥

当前实现会立即轮换客户端密钥,并且会立即使旧密钥失效。

旋转客户端密钥,请使用以下端点:

POST/oauth2/client/rotate-secret
const { data, error } = await authClient.oauth2.client.rotateSecret({    client_id, // required});
Parameters
client_idstring,required

OAuth 客户端的 client_id

删除客户端

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

POST/oauth2/delete-client
const { data, error } = await authClient.oauth2.deleteClient({    client_id, // required});
Parameters
client_idstring,required

OAuth 客户端的 client_id

对于所有非受信任客户端(尤其是无 skip_consent 的),都需要用户同意。以下端点允许用户或 reference_id 管理其已授权的同意。

获取同意详情

获取特定同意详情,使用以下端点:

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

同意项 id

列出同意

获取用户的所有同意列表,使用以下端点:

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

更新同意

更新特定同意条目,使用以下端点:

POST/oauth2/update-consent
const { data, error } = await authClient.oauth2.updateConsent({    id, // required    update, // required});
Parameters
idstring,required

同意项 id

updateOAuthConsent,required

要更新的值

删除同意

撤销用户对某个客户端的同意。

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

同意项 id

动态注册端点

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

安装后,您可以使用 OAuth 提供者管理应用内的认证流程。

创建客户端后,将获得 client_idclient_secret,可显示给用户。client_secret 只提供一次,务必提示用户保存。

配置

启用客户端注册请在 BetterAuth 配置中设置 allowDynamicClientRegistration: true

auth.ts
oauthProvider({
  allowDynamicClientRegistration: true,
  // ... 其他选项
})

若要启用无需认证的客户端注册(允许动态注册公有客户端),额外设置 allowUnauthenticatedClientRegistration: true

当 MCP 协议标准化无需认证的动态客户端注册时,allowUnauthenticatedClientRegistration 的支持将被弃用。截至撰写时,Client ID Metadata Documentssoftware_statement and jwks_uri 均仍在讨论中。

auth.ts
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 注册部分

注意以下参数目前尚不支持:

  • jwks
  • jwks_uri

授权端点

一个符合 OAuth 2.1 授权端点规范 的端点。由于某些细节尚未完全规范,部分内容参照旧版 OAuth 2.0 授权端点 但始终实现了 OAuth 2.1 与 OAuth 2.0 的差异

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

重要说明:

  • 在 OAuth 2.1 中,仅支持 response_type: "code"
  • 不支持 code_challenge_method: "plain",因为这是一项安全漏洞。
  • 所有授权响应(成功和错误)都包含 iss 参数用于发行者验证(RFC 9207)。

状态(State)

Clients should send a state value to mitigate cross-site request forgery (CSRF) attacks. This works by ensuring your client only responds to requests that your client initially requested.

客户端生成状态值,并存储在安全、HTTP-only Cookie 或数据库等处。

The authorization server accepts requests without state for compatibility with OAuth and OpenID Connect, and echoes state back when it is provided. Better Auth's client helpers generate and validate state for you.

Code Challenge

代码挑战用于保护授权端点返回的授权码。

其通过从代码验证器派生代码挑战并通过 PKCE(Proof Key for Code Exchange) 发送至授权服务器。

redirect_uri 回调时,客户端比对返回状态与初始状态是否匹配,然后使用 authorization_code 授权方式和原始代码验证器在令牌端点交换令牌。

Token Endpoint

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

  • "authorization_code"
  • "client_credentials"
  • "refresh_token"

授权码授权方式

授权码授权方式允许客户端获取用户访问令牌,并可选择获取刷新令牌(通过 "offline_access" scope)。

客户端凭证授权方式

该方式允许客户端获取机器间访问令牌。

刷新令牌授权方式

该方式允许客户端刷新访问令牌,无需用户再次登录。

当前实现为每次刷新请求发放新的刷新令牌。

接受或拒绝用户对某组权限的同意。注意拒绝某些权限时,会取消本次授权的同意,之前已有的其他同意依然有效。要移除同意,请删除该用户对应客户端的 "oauthConsent"。

POST/oauth2/consent
const { data, error } = await authClient.oauth2.consent({    accept, // required    scope,});
Parameters
acceptboolean,required

接受或拒绝用户对一组 scope 的同意

scopestring,

以空格分隔的已接受 scope 列表。若未提供,则接受最初请求的 scope。

继续端点

注册页面必须先 配置 以进行注册步骤。 账户选择页面必须先 配置 用于账户选择。 登录后页面必须先 配置 用于登录后操作。

POST/oauth2/continue
const { data, error } = await authClient.oauth2.continue({    selected,    created,    postLogin,});
Parameters
selectedboolean,

确认已选择账户。

createdboolean,

确认已注册账户

postLoginboolean,

确认登录后活动已完成

Introspect Endpoint

符合 RFC7662 规范的令牌核查端点。

此端点提供所给令牌的详细信息。如令牌绑定于会话,确保该会话为 active

通过 customAccessTokenClaims 可向机密客户端的 resources 字段存储允许其访问的资源,以提供资源特定声明。

Revoke Endpoint

符合 RFC7009 规范的撤销端点。

此端点撤销所给令牌。

  • opaque access_token:立即将该 access_token 从数据库中移除。refresh_token 仍然有效。
  • JWT access_token:验证该令牌是否可安全从客户端存储中移除。
  • refresh_token:移除使用该 refresh_token 签发的所有 access_tokens,并移除该 refresh_token 以防止进一步发放令牌。

对于 access_token 类型:

会话结束端点

符合 RP-发起注销规范。

此端点允许指定受信任客户端远程执行注销操作。

允许 RP 发起注销,需专门创建开启注销功能的受信任客户端:

admin-create-oauth.ts
import { auth } from "@/lib/auth"

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

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

UserInfo 端点

UserInfo 端点提供符合 OIDC 标准的用户信息。端点为 /oauth2/userinfo,需带有效访问令牌且至少带有 openid 权限。

// 客户端调用 UserInfo 端点示例
const response = await fetch('https://your-domain.com/api/auth/oauth2/userinfo', {
  headers: {
    'Authorization': 'Bearer ACCESS_TOKEN'
  }
});

const userInfo = await response.json();
// userInfo 根据授予的权限返回用户信息

UserInfo 端点根据授权时授予的范围返回不同的声明:

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

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

Well known

Openid 配置

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

要求带有 openid 范围。

必须将该配置添加至发行者路径。如未设置发行者,则为基础路径 /api/auth

若发行者路径非根路径,且根路径未提供 openid-configuration,我们推荐在根添加以防客户端硬编码访问 /.well-known/openid-configuration(忽视了规范中的发行者路径)。

注意:对于带路径的发行者,OpenID 使用路径拼接,应在发行者路径后附加 /.well-known/openid-configuration。无发行者路径则应从根开始。

[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);

如果您在本地测试时遇到 CORS 问题,例如使用 MCP Inspector 时,这是因为前端在调用端点而不是后端。测试时请添加 Access-Control-Allow-Methods": "GET""Access-Control-Allow-Origin": "*"

OAuth Authorization Server

提供 RFC8414 兼容元数据,位于 /.well-known/oauth-authorization-server

必须将该配置添加至发行者路径。未设置发行者,则为基础路径 /api/auth

注意:带路径的发行者,OAuth 2.1 授权服务器采用路径插入,故在发行者路径后追加 /.well-known/oauth-authorization-server。无发行者路径则从根开始添加。

/.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);

如果您在本地测试时遇到 CORS 问题,例如使用 MCP Inspector 时,这是因为前端在调用端点而不是后端。测试时请添加 Access-Control-Allow-Methods": "GET""Access-Control-Allow-Origin": "*"

API Server

本节展示如何让您的 API 验证来自客户端的令牌。

验证

可通过 oauthProviderResourceClient 插件或 better-auth/oauth2 包里的 verifyAccessToken 进行验证。

使用 better-auth 包:

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"], // 可选
    }
  );
  // ...后续操作
}

使用 oauthProviderResourceClient 插件:

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"], // 可选
    }
  );
  // ...后续操作
}

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:content
    • read:post:metadata(仅限用户拥有的帖子)

优点

  • 适用于复杂系统的灵活且可扩展。
  • 令牌保持紧凑,因为只包含范围,而不是所有权限。

缺点

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

配置

重定向屏幕

OAuth 流程中,用户可能被多次重定向。例如,用户可能先到登录页面,再跳转至同意页面,最后返回应用。以下说明常见登录流程及所需配置。

流程中检测每个重定向步骤时,会验证在初始 /oauth2/authorize 重定向时签名的查询参数。包含所有参数(包括自定义参数)均被签名和验证。

如果登录页有自定义查询参数,可将其附加在签名查询(即 sig 字段)结尾。

如果使用客户端插件 oauthProviderClient,则 oauth_query 参数会自动发送至所有需要的端点。若是自定义登录端点,则需手动在请求体中的 oauth_query 字段添加带签名的查询,内容仅包括签名查询参数。

登录屏幕

用户跳转到 OIDC 提供者进行认证时,若未登录,会跳转至登录页面。可通过初始化时提供 loginPage 选项自定义登录页。

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

无需额外处理,插件会在新会话创建后自动继续授权流程。

同意屏幕

用户跳转到 OIDC 提供者认证时,可能需授权应用访问数据。

注意:受信任客户端设置了 skipConsent: true,将跳过同意页面,提供无缝体验。

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

插件会携带 client_idscope 参数重定向至指定路径,您可根据该信息展示自定义同意页。用户同意后调用 oauth2.consent 完成授权。

consent-page.ts
import { authClient } from "@/lib/auth-client"

const res = await authClient.oauth2.consent({
	accept: true,
  // 可选接受的范围,未指定时接受所有原始请求范围
  scope: "openid profile email"
});

注册账户屏幕

客户端通过 prompt: create 跳转用户到注册页时,配置如下:

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

欲在注册步骤中阻断登录流程,使用 shouldRedirect 函数:

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

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

选择账户屏幕

用户认证时被重定向至选择账户页,需先启用选择账户配置。

下面示例使用多会话插件,若登录多个会话,则自动跳转选择账户页:

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

插件会跳转至 selectAccount.page,该页面应提示用户选择账户,选择完成后调用 oauth2Continue

select-account.ts
import { authClient } from "@/lib/auth-client"

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

登录后页面

如果某个范围要求指定组织,需在登录后流程中配置所有以下选项,将 reference_id(如组织 ID、团队 ID)绑定至流程。

下面示例使用组织插件,自动在登录后跳转选择组织页:

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

select-organization.ts
import { authClient } from "@/lib/auth-client"

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

可缓存的受信任客户端

针对第一方应用和内部服务,可缓存受信任客户端以提升性能。值以内存缓存,并阻止 CRUD 接口修改。

auth.ts
oauthProvider({
  // 受信任客户端 clientId 列表
  cachedTrustedClients: new Set([
    "internal-dashboard",
    "mobile-app",
  ]),
})

有效受众(Audiences)

本 OAuth 服务器允许的一组有效受众(资源)。若未指定,则默认发送基础 URL。推荐指定除基础 URL 之外的受众,如您的 API。

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

Scopes

Scopes 授权客户端访问指定资源。目前默认支持:

  • openid: 返回用户的 ID(sub 声明)。
  • profile: 返回名称、图片、given_name、family_name
  • email: 返回邮箱和邮箱已验证
  • offline_access: 返回刷新令牌

您可以自由定义所支持的 scopes!注意:需包含 openid 以满足 OIDC 服务器,否则为标准 OAuth 2.1 服务器。所有支持的 scopes 必须包含于此数组。

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

声明(Claims)

内部支持以下声明:["sub", "iss", "aud", "exp", "iat", "sid", "scope", "azp"]。

建议对 id token 和 userinfo 中附加的声明使用命名空间,防止未来冲突。

customIdTokenClaimscustomUserInfoClaims 中添加的声明应被添加到 advertisedMetadata.claims_supported,以便客户端验证收到的声明。以下示例包含基础声明以及 localehttps://example.com/org

提示:这两个函数也可以抛出错误,例如用户不再是组织成员或无请求权限。

auth.ts
oauthProvider({
  // 附加声明到 id tokens
  customIdTokenClaims: ({ user, scopes, metadata }) => {
    return {
      locale: "en-GB",
    };
  },
  // 附加声明到访问令牌
  customAccessTokenClaims: ({ user, scopes, referenceId, resource, metadata }) => {
    return {
      "https://example.com/org": referenceId,
      "https://example.com/roles": ["editor"],
    };
  },
  // 附加额外 userinfo 声明
  customUserInfoClaims: ({ user, scopes, jwt) => {
    return {
      locale: "en-GB",
    };
  },
})

自定义令牌响应字段

与上述声明回调(在 JWT 载荷内部添加数据)不同,customTokenResponseFields 会向令牌端点 JSON 响应中添加字段,伴随 access_tokentoken_type 等。标准 OAuth 字段无法被覆盖。

auth.ts
oauthProvider({
  customTokenResponseFields: ({ grantType, user, scopes, metadata, verificationValue }) => {
    // 在 authorization_code 授权类型中为租户上下文添加字段
    if (grantType === "authorization_code" && verificationValue?.referenceId) {
      return { tenant_id: verificationValue.referenceId };
    }
    return {};
  },
})

该回调接收授权类型、用户(client_credentials 时为 undefined)、范围、解析的客户端元数据和验证值(仅 authorization_code 授权类型)。在创建任何令牌之前调用,因此抛出错误不会留下部分应用的状态。

过期时间

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

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

访问令牌还支持基于 scopes 单独设置更短过期(以最早过期时间为准,未设置的使用默认)。注意:该时间应低于默认 accessTokenExpiresInm2mAccessTokenExpiresIn

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

注册

动态客户端注册

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

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

无认证客户端注册允许公有客户端(非机密)无授权头动态注册,适合 MCP 自动注册公有客户端。

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

allowUnauthenticatedClientRegistration 将在 MCP 协议标准化无认证动态客户端注册时弃用。截至撰写时,客户端 ID 元数据文档software_statementjwks_uri 仍在讨论中。

动态客户端注册过期时间

可设置动态注册机密客户端的过期时间。默认动态注册机密客户端无过期。

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

动态客户端注册默认范围

若客户端注册时未提交 scopes,可设置默认 scopes。所有默认 scopes 必须在 scopes 中定义。

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

若还需设置允许的额外 scopes,设置 clientRegistrationAllowedScopes。该集合包含默认 scopes。

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

PKCE 配置

PKCE 是防止授权码被截获的一种安全机制。插件遵循 OAuth 2.1 规范,默认对所有授权码流程要求 PKCE。

默认行为

默认要求所有客户端使用 PKCE,最大安全、符合 OAuth 2.1 最佳实践。

PKCE 始终需要:

  • 公共客户端(基于原生/用户代理的应用程序)
  • 任何带有 offline_access 范围的授权码(刷新令牌)

每客户端 PKCE 配置

客户端可在注册时选择关闭 PKCE(适配兼容性):

register-client.ts
// 注册不支持 PKCE 的机密客户端
const response = await auth.api.createOAuthClient({
  headers,
  body: {
    client_name: 'Legacy Backend Service',
    redirect_uris: ['https://app.example.com/callback'],
    token_endpoint_auth_method: 'client_secret_post',
    grant_types: ['authorization_code'],
    require_pkce: false, // 选择关闭 PKCE
  }
});

require_pkce 字段:

  • 默认值为 true(需要 PKCE)
  • 仅适用于机密客户端
  • 公共客户端(PKCE 始终需要)忽略此设置
  • 带有 offline_access 范围的授权码忽略此设置(PKCE 始终需要)

何时使用 require_pkce: false

  • 从 OAuth 2.0 迁移的旧机密客户端不支持 PKCE
  • 无法更新的后端到后端集成
  • 分阶段迁移期间的临时兼容性

建议: 尽可能保持启用 PKCE(默认),即使对机密客户端也增加安全防护。

从 oidc-provider 迁移

若从已废弃的 oidc-provider 插件迁移,且存在不支持 PKCE 的机密客户端:

  1. 旧客户端可针对单个客户端设置 require_pkce: false
  2. 新客户端注册应始终开启 PKCE(默认)。
  3. 逐步淘汰不支持 PKCE 的客户端。
  4. 监控使用了 require_pkce: false 的客户端以便规划迁移。

安全考虑

PKCE 防止授权码截获。即使机密客户端使用 client_secret 认证,PKCE 依旧提供额外安全保障:

  • 防御深度:多层防护
  • 防止误配置:密钥泄露风险降低
  • 符合未来标准

安全性考虑

PKCE 防止授权码截获攻击。即使对使用 client_secret 的机密客户端,PKCE 也提供额外安全:

  • 纵深防御:多层安全
  • 防止错误配置:减少密钥意外泄露
  • 面向未来:符合 OAuth 2.1 最佳实践

仅当绝对必要时(例如旧机密客户端的兼容性),才禁用 PKCE。

组织

OAuth 客户端注册时绑定用户或 reference_id,且不可变。

若使用 组织插件,请确保在新建客户端时,激活会话中的 activeOrganizationId 已被设置。

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

有关设置用户权限和角色的详细信息,请参见 声明

客户端 CRUD 权限

确定登录用户是否具备客户端创建、读取、更新、删除权限,可通过 clientPrivileges 配置。默认允许拥有匹配 userIdclientReference 的用户操作。

示例仅允许组织管理员对 OAuth 客户端执行 CRUD 操作,假设普通用户无法创建客户端:

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

存储

默认所有密钥在数据库中以 hashed 形式存储,防止泄露时暴露 client_secret

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

限流

OAuth 提供者内置所有 OAuth 端点限流,防止滥用和拒绝服务攻击。

限流为 每 IP 每端点。每个客户端 IP 地址对每个端点拥有独立的限流计数器。窗口期结束后限流重置。

这些限流仅在 Better Auth 的全局限流启用时生效。默认情况下仅在生产环境启用。参见 限流获取全局配置。

默认限制:

端点窗口最大请求数
/oauth2/token60s20
/oauth2/authorize60s30
/oauth2/introspect60s100
/oauth2/revoke60s30
/oauth2/register60s5
/oauth2/userinfo60s60

可自定义各端点的限流参数:

auth.ts
oauthProvider({
  rateLimit: {
    token: { window: 60, max: 20 },        // 每分钟 20 次请求
    authorize: { window: 60, max: 30 },    // 每分钟 30 次请求
    introspect: { window: 60, max: 100 },  // 每分钟 100 次请求
    revoke: { window: 60, max: 30 },       // 每分钟 30 次请求
    register: { window: 60, max: 5 },      // 每分钟 5 次请求
    userinfo: { window: 60, max: 60 },     // 每分钟 60 次请求
  },
})

如需关闭某个端点的自定义限流,回退到全局限流,设置该端点为 false

auth.ts
oauthProvider({
  rateLimit: {
    introspect: false, // 使用全局限流替代此端点限流
  },
})

将端点设置为 false 会移除 OAuth 提供者的更严格端点限流。该端点仍受 Better Auth 的全局限流约束(如已启用)。

刷新令牌自定义

可使用 formatRefreshToken 自定义会话刷新令牌的字符串格式。

此函数可为刷新令牌增加功能,如加密。

示例如更改刷新令牌格式,同时兼容原有简单格式:

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

加密伪代码示例:

auth.ts
import { betterAuth } from "better-auth";
import { CompactEncrypt, compactDecrypt } from 'jose'
import { oauthProvider } from "@better-auth/oauth-provider"; 

const secret = "SOME_SECRET_OR_KEY"
const alg = "A256KW"
const enc = "A256GCM"

const auth = betterAuth({
  plugins: [
    oauthProvider({
    formatRefreshToken: {
      encrypt: (token, sessionId) => {
        const value = JSON.stringify({
          sessionId,
          token,
        });
        const jwe = await new CompactEncrypt(Buffer.from(value))
          .setProtectedHeader({ alg, enc })
          .encrypt(secret);
        return jwe;
      },
      decrypt: (token) {
        const { plaintext } = await compactDecrypt(token, secret);
        const payload = new TextDecoder().decode(plaintext);
        return JSON.parse(payload);
      },
    }
  })
]
})

广告公布元数据

可自定义元数据端点,实现对外展示的 scopes 和 claims 与实际支持的不同,避免暴露所有支持的权限。

所有出现在 advertisedMetadata 中的 scopes 必须scopes 中声明,否则初始化失败。

Scopes

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

声明(Claims)

声明为额外声明,除默认支持的以外。仅对 OIDC(即 openid 范围)适用。

auth.ts
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 签名。
auth.ts
oauthProvider({
  disableJwtPlugin: true, 
})

成对主体标识符(Pairwise Subject Identifiers)

默认情况下,令牌中的 sub(主体)声明使用用户的内部 ID,是所有客户端通用的公开主体类型,符合 OIDC 核心规范 8 节

您可启用 成对(pairwise) 主题标识符,使每个客户端为同一用户生成唯一且不可关联的 sub,防止关联分析。

auth.ts
oauthProvider({
  pairwiseSecret: "your-256-bit-secret", 
})

当配置了 pairwiseSecret,服务器在发现端点的 subject_types_supported 同时声明 "public""pairwise"。客户端通过注册时设置 subject_type: "pairwise" 选择成对。

每客户端配置

register-client.ts
const response = await auth.api.createOAuthClient({
  headers,
  body: {
    client_name: 'Privacy-Sensitive App',
    redirect_uris: ['https://app.example.com/callback'],
    token_endpoint_auth_method: 'client_secret_post',
    subject_type: 'pairwise', // 开启成对 sub
  }
});

工作原理

成对标识符通过基于客户端第一个重定向 URI 的主机(范围标识符)和用户 ID,使用 pairwiseSecret 进行 HMAC-SHA256 生成。

  • 两个具有不同重定向 URI 主机的客户端对同一用户总会收到不同的 sub
  • 两个共享相同重定向 URI 主机的客户端对同一用户收到相同的成对 sub(符合 OIDC 核心规范 8.1)
  • 同一客户端对同一用户始终收到相同的 sub(确定性)

成对 sub 出现在:

  • id_token
  • /oauth2/userinfo 响应
  • 令牌内省(/oauth2/introspect

JWT 访问令牌仍使用真实用户 ID 作为 sub,因资源服务器可能需要直接查询用户信息。

限制

  • sector_identifier_uri 尚未支持。一个客户端的所有重定向 URI 必须共享同一主机。跨主机的重定向 URI 将导致注册被拒绝。
  • pairwiseSecret 必须至少 32 个字符长。
  • 轮换 pairwiseSecret 将更改所有成对 sub 值,破坏现有 RP 会话。请将密钥视为永久设置。

MCP

仅需添加资源服务器指向此 OAuth 2.1 授权服务器,即可轻松让 API 兼容 MCP 协议

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

安装

添加资源服务器客户端

(可选)如果本地有 auth 配置,可作为参数提供给客户端,便于配置校验。亦可直接在调用时覆盖。若无,则由 TypeScript 指导最低配置要求。

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 可选
});

向 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` 声明
    authorization_servers: ["https://auth.example.com"],
  })

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

如果您使用 allowUnauthenticatedClientRegistration,则必须确保您的 API Server 本身也是机密客户端:

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

这些值应当用于 remoteVerify.clientIdremoteVerify.clientSecret 验证选项。此外,remoteVerify.introspectUrl 可以设置为 ${BASE_URL}/${AUTH_PATH}/oauth2/introspect 这样的地址。

如果你选择不支持 allowUnauthenticatedClientRegistration(仅支持 allowDynamicClientRegistration),则 MCP 客户端(如 ChatGPT、Anthropic、Gemini)需要允许你在其 UI 中或与 AI 对话的运行时传入公开的 client_id。

处理 MCP 错误

必须指定特定 audience 进行验证,默认为所有 validAudiencesbaseUrl

  • 使用客户端 verifyAccessToken 函数

参考 验证 示例。

  • With auth available, use the client verifyAccessToken function to automatically determine endpoints
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",
      }
    }
  );
  // ...后续操作
}
  • Using mcpHandler helper
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 提供者插件新增以下表格:

OAuth 客户端表

表名:oauthClient

Table
字段
类型
描述
id
string
PK
Database ID of the OAuth client
clientId
string
-
Unique identifier for each OAuth client
clientSecret ?
string
-
Secret key for the OAuth client. Optional for public clients using PKCE.
disabled ?
boolean
-
Field that indicates if the current application is disabled
skipConsent ?
boolean
-
Field that indicates if the application can skip consent. You may choose to enable this for trusted applications.
enableEndSession ?
boolean
-
Field that indicates if the application can logout via an id_token. You may choose to enable this for trusted applications.
subjectType ?
string
-
Subject identifier type for this client. Set to "pairwise" to receive unique, unlinkable sub claims per user. Requires pairwiseSecret to be configured on the server.
scopes ?
string[]
-
Scopes this client is allowed to use
userId ?
string
FK
ID of the client owner. (optional)
referenceId ?
string
-
ID of the reference of the client owner if not a user. (optional)
createdAt ?
Date
-
Timestamp of when the OAuth client was created
updatedAt ?
Date
-
Timestamp of when the OAuth client was last updated
name ?
string
-
Name of the OAuth client
uri ?
string
-
Website Uri displayed on UI Screens
icon ?
string
-
Website Icon displayed on UI Screens
contacts ?
string[]
-
Client contact list (ie customer service emails, phone numbers) to be displayed on UI Screens
tos ?
string
-
Client Terms of Service displayed on UI Screens
policy ?
string
-
Client Privacy policy displayed on UI Screens
softwareId ?
string
-
Client-defined software identifier. This should remain the same across multiple versions for the same piece of software.
softwareVersion ?
string
-
Client-defined version number of the softwareId.
softwareStatement ?
string
-
Signed JWT containing the software metadata as signed claims.
redirectUris
string[]
-
Array of of redirect uris
postLogoutRedirectUris ?
string[]
-
Array of post-logout redirect URIs
tokenEndpointAuthMethod ?
string
-
Indicator of requested authentication method for the token endpoint. Supports: ['none', 'client_secret_basic', 'client_secret_post']
grantTypes ?
string[]
-
Array of supported grant types. Supports: ['authorization_code', 'client_credentials', 'refresh_token']
responseTypes ?
string[]
-
Array of supported grant types. Supports: ['code']
public ?
boolean
-
Indication if the client is confidential or public
type ?
string
-
Type of OAuth client. Supports: ['web', 'native', 'user-agent-based']
requirePKCE ?
boolean
-
Whether PKCE is required for this client
metadata ?
json
-
Additional metadata for the OAuth client

OAuth 刷新令牌表

表名:oauthRefreshToken

Table
字段
类型
描述
id
string
PK
Database ID of the refresh token
token
string
-
Hashed/encrypted refresh token
clientId
string
FK
ID of the OAuth client
sessionId ?
string
FK
ID of the session used at issuance of the token (and still active)
userId
string
FK
ID of the user associated with the token
referenceId ?
string
-
ID of the consented reference
scopes
string[]
-
Array of granted scopes
revoked ?
Date
-
Timestamp when the token was revoked
authTime ?
Date
-
Original authentication time. Preserved across token rotation so refreshed ID tokens include a correct auth_time claim per OIDC Core 1.0 Section 12.2.
createdAt
Date
-
Timestamp when the token was created
expiresAt
Date
-
Timestamp when the token will expire

OAuth 访问令牌表

表名:oauthAccessToken

Table
字段
类型
描述
id
string
PK
Database ID of the opaque access token
token
string
-
Hashed/encrypted access token
clientId
string
FK
ID of the OAuth client
sessionId ?
string
FK
ID of the session used at issuance of the token (and still active)
refreshId ?
string
FK
ID of the refresh associated with the token
userId ?
string
FK
ID of the user associated with the token
referenceId ?
string
-
ID of the consented reference
scopes
string[]
-
Array of granted scopes
createdAt
Date
-
Timestamp when the token was created
expiresAt
Date
-
Timestamp when the token will expire

OAuth 同意表

表名:oauthConsent

Table
字段
类型
描述
id
string
PK
Database ID of the consent
userId
string
FK
ID of the user who gave consent
clientId
string
FK
ID of the OAuth client
referenceId ?
string
-
ID of the consented reference
scopes
string[]
-
Array of scopes consented to
createdAt
Date
-
Timestamp of when the consent was given
updatedAt
Date
-
Timestamp of when the consent was last updated

选项

前缀

可为不透明访问令牌、刷新令牌和客户端密钥添加前缀,有助于秘密扫描工具(如 GitHub Secret ScannersGitGuardianTrufflehog)识别令牌格式。

推荐在部署前先添加前缀,部署后视为不可变,否则应使用对应的生成函数。

prefix 配置设置下提供以下选项:

  • opaqueAccessToken: string | undefined - 为不透明访问令牌添加前缀。如果已部署,请改用 generateOpaqueAccessToken 来执行此功能。
  • refreshToken: string | undefined - 为刷新令牌添加前缀。如果已部署,请改用 generateRefreshToken 来执行此功能。
  • clientSecret: string | undefined - 为客户端密钥添加前缀。如果已部署,请改用 generateClientSecret 来执行此功能。

优化提示

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

迁移

来自 OIDC Provider 插件

请参见 OIDC Provider Plugin 了解之前的实现。

配置变更

  • idTokenExpiresIn 现在默认为 10 hours(之前为 1 hour 通过 accessTokenExpiresIn
  • refreshTokenExpiresIn 现在默认为 30 days(之前为 7 days
  • advertisedMetadata(之前为 metadata)不再支持更改元数据字段,以防止意外配置错误。
  • clientRegistrationDefaultScopes(之前为 defaultScope)现在采用数组格式,而非空格分隔的字符串。
  • consentPage 现在为必填项。
  • getConsentHTML 已被移除,改用 consentPage,因为 OAuth 授权端点不支持原始 HTML 作为响应类型。
  • requirePKCE(全局选项)已被移除。PKCE 现在默认要求遵循 OAuth 2.1。单个客户端可通过注册时的 require_pkce: false 选项选择退出以保持向后兼容。
  • allowPlainCodeChallengeMethod 被移除,因为 plain 代码挑战方法被认为不如默认的 S256 方法安全。
  • customUserInfoClaims(之前为 getAdditionalUserInfoClaim)传递 JWT 负载,而不是请求中使用的客户端访问令牌。
  • storeClientSecret 现在默认为 hashed,如果 disableJwtPlugin: true 则为 encrypted(之前为 plain)。
  • 默认启用 JWT 插件。如需禁用,请设置 disableJwtPlugin: true
  • 授权查询中的 code_challenge_method "S256" 必须按 OAuth 2.1 规范使用大写形式。

数据库变更

表:oauthClient

旧名为 oauthApplication

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

const defaultHasher = async (value: string) => {
	const hash = await createHash("SHA-256").digest(
		new TextEncoder().encode(value),
	);
	const hashed = base64Url.encode(new Uint8Array(hash), {
		padding: false,
	});
	return hashed;
};
  • type 字段不再是必需字段。相反,架构要求使用 public(布尔类型)。迁移规则如下:
    • type: "public" 的客户端:设置 type: undefinedpublic: trueclientSecret: undefined
    • type: "native" 的客户端:设置 public: trueclientSecret: undefined
    • type: "user-agent-based" 的客户端:设置 public: trueclientSecret: undefined
    • clientSecret: undefined 的客户端:设置 public: true
  • redirectURLs 重命名为 redirectUris
  • 新增 requirePkce 字段(可选,默认为 true)。对于现有不支持 PKCE 的机密客户端,请设置 requirePkce: false
  • metadata 现在以独立字段形式存储在数据库中,而不是 JSON 对象。请将元数据解析为其对应字段。OIDC 插件未使用此字段,但此 OAuth 插件将来可能会使用。
表:oauthAccessToken

方案一(简单):

可以选择不迁移此表,影响较小。用户需要重新登录。可以直接删除现有的 oauthAccessToken 表。

方案二(复杂):

全表迁移(可能需要先将 oauthAccessToken 克隆到 oauthRefreshToken)。

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

来自 MCP 插件

请参见 MCP Plugin 了解之前的 MCP 专用端点。

MCP 端点已从 /mcp 迁移至 /oauth2

  • /oauth2/authorize(之前为 /mcp/authorize
  • /oauth2/token(之前为 /mcp/token
  • /oauth2/register(之前为 /mcp/register
  • /mcp/get-session 已被移除,因为它不符合 OAuth 2 规范,请改用 /oauth2/introspect
  • /.well-known/oauth-protected-resource 已被移除,可使用辅助函数 mcpHandler(或手动使用服务器 api.oAuth2introspectVerify 或资源客户端 verifyAccessToken
  • 数据库变更与 从 OIDC Provider 插件迁移 部分相同。

On this page

安装
挂载插件
迁移数据库
添加 ./well-known 端点
创建您的第一个 OAuth 客户端
客户端插件
OAuth 客户端
资源客户端
用法
OAuth 客户端
获取客户端信息
获取公有客户端信息
获取公有客户端预登录信息
列出客户端
创建客户端
更新客户端
旋转客户端密钥
删除客户端
OAuth Consent
获取同意详情
列出同意
更新同意
删除同意
动态注册端点
配置
基本示例
授权端点
Token Endpoint
授权码授权方式
客户端凭证授权方式
刷新令牌授权方式
Consent Endpoint
继续端点
Introspect Endpoint
Revoke Endpoint
会话结束端点
UserInfo 端点
Well known
Openid 配置
OAuth Authorization Server
API Server
验证
JWT 验证
不透明访问令牌(opaque)
建议
范围与权限
配置
重定向屏幕
登录屏幕
同意屏幕
注册账户屏幕
选择账户屏幕
登录后页面
可缓存的受信任客户端
有效受众(Audiences)
Scopes
声明(Claims)
自定义令牌响应字段
过期时间
注册
动态客户端注册
动态客户端注册过期时间
动态客户端注册默认范围
PKCE 配置
默认行为
每客户端 PKCE 配置
从 oidc-provider 迁移
安全考虑
安全性考虑
组织
客户端 CRUD 权限
存储
限流
刷新令牌自定义
广告公布元数据
Scopes
声明(Claims)
禁用 JWT 插件
成对主体标识符(Pairwise Subject Identifiers)
每客户端配置
工作原理
MCP
安装
确保 Well Known 路径正确
添加资源服务器客户端
向 API 添加 OAuth 受保护资源元数据
处理 MCP 错误
Schema
OAuth 客户端表
OAuth 刷新令牌表
OAuth 访问令牌表
OAuth 同意表
选项
前缀
优化提示
迁移
来自 OIDC Provider 插件
配置变更
数据库变更
表:oauthClient
表:oauthAccessToken
来自 MCP 插件