Generic OAuth

使用任何 OAuth 提供商对用户进行身份验证

Generic OAuth 插件可让你将任何 OAuth 2.0 或 OpenID Connect(OIDC)提供商添加到应用程序中。提供商会注册为一等社交提供商,并使用标准的 signIn.social 流程,默认启用 PKCE 和发行者验证。

何时使用此插件

在以下情况下使用 Generic OAuth 插件:

  • 你的提供商不是内置社交提供商之一(Google、GitHub、Discord 等)
  • 你需要连接到企业身份提供商(Keycloak、Okta、Auth0、Microsoft Entra ID)
  • 你希望支持具有自定义或非标准 OAuth 端点的提供商

如果你的提供商已经内置,请改用身份验证配置中的 socialProviders;内置提供商支持移动端 id-token 登录,并具有针对特定提供商的优化。

自定义选项

OAuth 流程的每个方面都可以被覆盖:

需求配置字段
提供商使用非标准令牌交换(GET、自定义参数)getToken
提供商返回非标准用户资料getUserInfo
需要将资料字段映射到用户模型mapProfileToUser
提供商需要额外的授权参数authorizationUrlParams
提供商需要额外的令牌参数tokenUrlParams
提供商需要自定义 HTTP 头discoveryHeaders、authorizationHeaders
提供商不支持 OIDC 发现显式设置 authorizationUrl、tokenUrl、userInfoUrl
提供商使用非标准的不可变用户标识符设置 accountSubject
提供商拒绝 PKCE设置 pkce: false

安装

将插件添加到身份验证配置中。

auth.ts
import { betterAuth } from "better-auth"
import { genericOAuth } from "better-auth/plugins"

export const auth = betterAuth({
    // ... other config options
    plugins: [
        genericOAuth({ 
            config: [ 
                { 
                    providerId: "provider-id", 
                    clientId: "test-client-id", 
                    clientSecret: "test-client-secret", 
                    discoveryUrl: "https://auth.example.com/.well-known/openid-configuration", 
                    // ... other config options
                }, 
                // Add more providers as needed
            ] 
        }) 
    ]
})

使用方法

Generic OAuth 提供商通过标准的社交登录流程使用:

登录

await authClient.signIn.social({
    provider: "provider-id",
    callbackURL: "/dashboard",
})

你也可以传入 additionalData(一个 Record<string, any>),在整个流程中往返传递客户端数据;在回调中通过 getOAuthState() 读取这些数据,并将其视为不受信任的数据。请参阅在 OAuth 流程中传递额外数据。

使用 ID Token 登录

配置了 discoveryUrl 且其发现文档发布了 jwks_uri 的提供商,也接受客户端直接获取的 id_token,无需执行重定向流程。该令牌会先根据提供商的 JWKS 进行验证,然后才会信任其中的任何声明:

await authClient.signIn.social({
    provider: "provider-id",
    idToken: {
        token: idTokenFromProvider,
    },
})

使用显式端点而非 discoveryUrl 配置的提供商不支持此路径,并会返回 ID_TOKEN_NOT_SUPPORTED。

关联账户

await authClient.linkSocial({
    provider: "provider-id",
    callbackURL: "/settings",
})

处理 OAuth 回调

该插件使用位于 /callback/:providerId 的核心 OAuth 回调路由。这意味着默认情况下会使用 ${baseURL}/api/auth/callback/:providerId 作为回调 URL。请确保你的 OAuth 提供商配置为使用此 URL。

与内置提供商不同,:providerId 参数是必需的,并且必须与您配置的提供商 ID 匹配。

验证 OAuth 用户信息

使用 user.validateUserInfo 在 Better Auth 创建用户(create-user)、关联新账户(link-account)或让现有用户重新登录(sign-in)之前拒绝 Generic OAuth 用户。在 sign-in 操作中,该回调会接收提供商最新的电子邮件和资料,因此可以通过域名或组织检查,拒绝其提供商身份后来超出范围的用户。检查 source.oauth?.providerId,将验证范围限定到特定的 Generic OAuth 提供商,并使用 source.oauth?.profile 获取提供商特定的资料字段。

auth.ts
import { betterAuth } from "better-auth";
import { genericOAuth } from "better-auth/plugins";

export const auth = betterAuth({
  user: {
    validateUserInfo: ({ user, source }) => {
      if (source.oauth?.providerId !== "company-oauth") {
        return;
      }

      if (!user.email?.endsWith("@example.com")) {
        return {
          error: "email_not_allowed",
          errorDescription: "Use your example.com email to sign in",
        };
      }
    },
  },
  plugins: [
    genericOAuth({
      config: [
        {
          providerId: "company-oauth",
          clientId: process.env.COMPANY_OAUTH_CLIENT_ID!,
          clientSecret: process.env.COMPANY_OAUTH_CLIENT_SECRET!,
          discoveryUrl: "https://auth.example.com/.well-known/openid-configuration",
        },
      ],
    }),
  ],
});

不返回任何内容即可允许 OAuth 流程继续。返回带有 error 的对象即可拒绝该流程。基于重定向的 OAuth 流程会将拒绝结果发送到配置的错误 URL,而程序化流程会返回 403 API 错误。

RP 发起的注销

对于公开了 end_session_endpoint 的 OIDC 提供商,authClient.signOut() 会清除 Better Auth 会话,然后将浏览器重定向到提供商的注销端点。使用 discoveryUrl 时,会从提供商的发现文档中读取该端点。你也可以通过 endSessionEndpoint 手动配置。

await authClient.signOut()

传入 callbackURL,可以要求提供商在注销后将用户返回到你的应用。该 URL 必须在提供商处注册为注销后重定向 URI。

await authClient.signOut({
  callbackURL: "/login",
})

如果多个已关联的提供商支持 RP 发起的注销,Better Auth 会将用户重定向到最近更新账户所对应的提供商。浏览器导航一次只能完成一个提供商的注销。

如果要自行处理提供商导航,请设置 disableRedirect 并使用返回的 url。

const { data } = await authClient.signOut({
  disableRedirect: true,
})

if (data?.url) {
  window.location.assign(data.url)
}

如果要使注销仅在 Better Auth 本地完成,请为该配置禁用提供商注销。

genericOAuth({
  config: [{
    providerId: "provider-id",
    discoveryUrl: "https://auth.example.com/.well-known/openid-configuration",
    clientId: "test-client-id",
    clientSecret: "test-client-secret",
    disableProviderLogout: true,
  }]
})

提供商辅助工具

有关内置和社区维护的提供商辅助工具,请参阅其他社交提供商。

配置

将插件添加到身份验证配置时,可以使用提供商辅助工具或自定义配置注册多个 OAuth 提供商。

手动配置

每个提供商配置对象支持以下选项:

import type {
  OAuthAccountKeyContext,
  OAuth2Tokens,
  TokenEndpointAuth,
} from "better-auth/oauth2";
import type { GenericOAuthUserInfo } from "better-auth/plugins/generic-oauth";

interface GenericOAuthConfig {
  providerId: string;
  accountSubject?: (
    context: OAuthAccountKeyContext<GenericOAuthUserInfo>,
  ) => string | number | Promise<string | number>;
  discoveryUrl?: string;
  requireIdTokenVerification?: boolean;
  authorizationUrl?: string;
  tokenUrl?: string;
  userInfoUrl?: string;
  endSessionEndpoint?: string;
  postLogoutRedirectURI?: string;
  disableProviderLogout?: boolean;
  clientId: string;
  clientSecret?: string;
  tokenEndpointAuth?: TokenEndpointAuth;
  scopes?: string[];
  redirectURI?: string;
  responseType?: string;
  prompt?: string;
  pkce?: boolean;
  accessType?: string;
  accessTokenExpiresIn?: number;
  getUserInfo?: (tokens: OAuth2Tokens) => Promise<GenericOAuthUserInfo | null>;
}

其他提供商配置说明

providerId:用于唯一标识 OAuth 提供商配置的字符串。

accountSubject:(可选)解析由提供商分配的不可变用户标识符。OpenID Connect 发现提供商默认使用经过验证的 sub 字段。普通 OAuth 提供商使用 id。Better Auth 不会在运行时在这两个字段之间切换。当提供商使用其他字段(例如 account_id)时,请设置此解析器。该解析器接收 OAuth 令牌和提供商原始资料,并可以返回字符串或数字。

mapProfileToUser 无法设置账户主题。这使本地资料映射与账户识别保持分离。

discoveryUrl:(可选)获取提供商 OAuth 2.0/OIDC 配置的 URL。如果提供了该值,则会在服务器启动时自动发现 authorizationUrl、tokenUrl 和 userInfoUrl 等端点。当发现文档发布了 jwks_uri 时,提供商返回的 id_token 会在其声明被使用前,根据该地址进行验证(包括签名、发行者、受众和公布的签名算法);验证失败的令牌会导致登录被拒绝。发现提供商还会使用服务器生成的 OIDC nonce 将 id_token 绑定到授权请求,并拒绝未回显该值的 id_token 回调。如果发现返回无效的验证元数据,或在应用显式端点和 getToken 后仍没有可用的授权端点或令牌交换方式,Better Auth 会跳过该提供商,同时保留其他身份验证功能。下次登录、回调或账户关联请求指定该提供商时会重试;发现成功后即可恢复使用。在此之前,登录和账户关联会返回 PROVIDER_NOT_FOUND,回调会重定向并带上 error=oauth_provider_not_found。发现暂时不可用时,显式端点可作为备用方案。

requireIdTokenVerification:(可选)要求发现提供可用的发行者和 jwks_uri,然后才注册提供商。当 accountSubject 或 getUserInfo 从 ID Token 声明中获取身份时,请启用此选项。如果配置的发现服务不可用或不完整,Better Auth 会跳过该提供商,而不会回退到未经验证的令牌解码。未配置 discoveryUrl 时,缺少验证元数据属于无效的静态配置,初始化会失败。microsoftEntraId 辅助工具会自动启用此选项。

disableIdTokenNonceBinding:(可选)关闭发现提供商 id_token 的 OIDC nonce 绑定。默认启用绑定,并会拒绝未回显服务器生成的 nonce 的 id_token(OIDC Core 1.0 §3.1.3.7)。只有不在授权码流程中返回 nonce 声明的提供商才应将其设置为 true;这会移除该提供商的 id_token 重放保护。

authorizationUrl:(可选)OAuth 提供商的授权端点。如果使用 discoveryUrl,则不必指定。

tokenUrl:(可选)OAuth 提供商的令牌端点。如果使用 discoveryUrl,则不必指定。

userInfoUrl:(可选)获取用户资料信息的端点。如果使用 discoveryUrl,则不必指定。

endSessionEndpoint:(可选)OIDC RP 发起的注销端点。如果使用 discoveryUrl 且提供商返回 end_session_endpoint,则不需要设置。

postLogoutRedirectURI:(可选)当 authClient.signOut() 将用户重定向到提供商时,作为 post_logout_redirect_uri 发送的默认 URI。该 URI 必须在提供商处注册。

disableProviderLogout:(可选)如果为 true,authClient.signOut() 只会清除 Better Auth 会话,不会重定向到提供商注销端点。

clientId:由你的提供商颁发的 OAuth 客户端 ID。

clientSecret:由你的提供商颁发的 OAuth 客户端密钥。

tokenEndpointAuth:(可选)令牌端点请求的客户端身份验证配置。对于 RFC 7523 客户端断言,请使用 { method: "private_key_jwt", getClientAssertion };对于基于密钥的客户端,请使用 { method: "client_secret_basic" } 或 { method: "client_secret_post" };对于公共客户端,请使用 { method: "none" }。基于密钥的方法要求提供 clientSecret;不要将 clientSecret 与 private_key_jwt 或 none 组合使用。如果省略该配置,Better Auth 会在配置了 clientSecret 时发送基于密钥的令牌请求,未配置时发送公共客户端令牌请求。

对于使用非标准令牌身份验证的提供商,请使用 method: "custom",在 Better Auth 设置标准授权参数后更新请求:

tokenEndpointAuth: {
  method: "custom",
  customizeRequest({ body }) {
    body.set("client_key", providerClientKey);
    body.set("client_secret", providerClientSecret);
  },
},

Better Auth 会构建标准授权参数,而 customizeRequest 会添加提供商所需的客户端身份验证。

scopes:(可选)要向提供商请求的作用域数组(例如 ["openid", "email", "profile"])。

redirectURI:(可选)OAuth 流程使用的重定向 URI。如果未设置,则会根据应用的基础 URL 构造默认值。必须包含 :providerId 占位符(例如 https://example.com/api/auth/callback/my-provider)。

responseType:(可选)OAuth 响应类型,授权码流程默认为 "code"。

responseMode:(可选)授权码请求的响应模式,如 "query" 或 "form_post"。

prompt:(可选)控制认证体验(例如强制登录、同意等)。

pkce:(可选)启用 PKCE(Proof Key for Code Exchange),这是 OAuth 2.1 的要求。默认为 true。只有明确拒绝 PKCE 的提供商才应禁用此选项。

accessType:(可选)授权请求中的访问类型。使用 "offline" 以请求刷新令牌。

accessTokenExpiresIn:(可选)备用访问令牌有效期(秒),仅在提供商的令牌响应省略 expires_in 时使用。如果没有已知的过期时间,getAccessToken 无法判断令牌已过期,也不会刷新它。请将其设置为令牌的有效期,以便跟踪过期并在需要时刷新令牌。如果提供商返回 expires_in,则留空。

getToken:(可选)一个自定义函数,用于将授权码兑换为令牌。如果提供,将使用该函数替代默认的令牌交换逻辑。适用于使用 GET 请求或自定义参数的非标准令牌端点提供商。

mapProfileToUser:(可选)函数,用于将提供商的用户资料映射到应用程序的用户对象。适用于自定义字段映射或转换。

mapProfileToUser:(可选)一个将提供商资料映射到应用程序用户可变字段的函数。它无法设置提供商身份;对于非标准的不可变标识符,请使用 accountSubject。

authorizationUrlParams:(可选)要添加到授权 URL 的其他查询参数。保留的 OAuth 密钥(state、client_id、redirect_uri、response_type、code_challenge、code_challenge_method、nonce、scope)会被忽略,因此无法替换 Better Auth 为流程管理的值;其他任何键都会覆盖默认值。

tokenUrlParams:(可选)要添加到令牌 URL 的其他查询参数。Better Auth 已设置的参数会被保留。请使用 clientId、clientSecret 和 tokenEndpointAuth 配置令牌端点客户端身份验证。

refreshTokenParams:(可选)刷新访问令牌时合并到令牌端点请求中的其他请求体参数。接受普通对象,或接受一个在刷新时运行的(同步或异步)函数,因此可以注入动态值,例如 scope、audience、resource 或租户标识符,而不必重新进行授权重定向。示例包括切换工作区时使用 Zitadel 的 urn:zitadel:iam:org:id:{orgId} 作用域,或轮换 Auth0 的 audience。函数形式会接收触发请求中的请求元数据(头和 Cookie),因此可以直接读取请求范围内的数据——调用者必须在将任何从头或 Cookie 派生的值用作作用域、受众或租户声明之前,根据经过身份验证的用户权限对其进行验证。无法覆盖 grant_type 和 refresh_token;合并后,client_id 由配置的令牌端点身份验证设置,因此这里同样无法覆盖。

genericOAuth({
  config: [
    {
      providerId: "zitadel",
      // ...
      refreshTokenParams: (ctx) => {
        const activeOrg = ctx?.headers?.get("x-active-org");
        return activeOrg
          ? { scope: `openid profile email urn:zitadel:iam:org:id:${activeOrg}` }
          : undefined;
      },
    },
  ],
});

disableSignUp:(可选)为 true 时,完全禁用新用户注册。只能登录已有用户。

authentication:(可选)令牌请求的认证方法。可以是 'basic' 或 'post'。默认 'post'。

authentication:(可选)基于密钥的令牌请求身份验证。可以是 'basic' 或 'post'。默认为 'post'。'basic' 要求提供 clientSecret。对于超出客户端密钥范围的令牌端点方法,请配置 tokenEndpointAuth。

对于 private_key_jwt,将 tokenEndpointAuth.method 设置为 "private_key_jwt"。你可以提供自己的 getClientAssertion 函数,也可以使用 createPrivateKeyJwtClientAssertionGetter 从私钥签署 RFC 7523 JWT 断言:

import { createPrivateKeyJwtClientAssertionGetter } from "better-auth/oauth2";

genericOAuth({
    config: [
        {
            providerId: "my-idp",
            clientId: "your-client-id",
            tokenUrl: "https://idp.example.com/oauth/token",
            authorizationUrl: "https://idp.example.com/oauth/authorize",
            tokenEndpointAuth: {
                method: "private_key_jwt",
                getClientAssertion: createPrivateKeyJwtClientAssertionGetter({
                    privateKeyJwk: { /* your JWK */ },
                    kid: "my-key-1",
                    algorithm: "RS256",
                }),
            },
            pkce: true,
        },
    ],
}),

你也可以从另一个提供商返回断言:

genericOAuth({
    config: [
        {
            providerId: "my-idp",
            clientId: process.env.IDP_CLIENT_ID!,
            discoveryUrl: "https://idp.example.com/.well-known/openid-configuration",
            tokenEndpointAuth: {
                method: "private_key_jwt",
                getClientAssertion: async ({ clientId, tokenEndpoint, grantType }) => {
                    return getWorkloadIdentityToken({
                        clientId,
                        audience: tokenEndpoint,
                        grantType,
                    });
                },
            },
            pkce: true,
        },
    ],
});

authorizationHeaders:(可选)授权请求中包含的自定义头。适用于需要特殊头的提供商。

overrideUserInfo:(可选)为 true 时,每次登录时用提供商信息更新数据库中的用户信息。默认 false。

安全性:发行者验证

高级用法

自定义令牌交换

对于使用 GET 请求或自定义参数的非标准令牌端点,你可以提供自定义 getToken 函数:

genericOAuth({
  config: [
    {
      providerId: "custom-provider",
      clientId: process.env.CUSTOM_CLIENT_ID!,
      clientSecret: process.env.CUSTOM_CLIENT_SECRET,
      authorizationUrl: "https://provider.example.com/oauth/authorize",
      scopes: ["profile", "email"],
      // 自定义令牌交换用于非标准端点
      getToken: async ({ code, redirectURI }) => {
        // 示例:使用 GET 请求替代 POST
        const response = await fetch(
          `https://provider.example.com/oauth/token?` +
          `client_id=${process.env.CUSTOM_CLIENT_ID}&` +
          `client_secret=${process.env.CUSTOM_CLIENT_SECRET}&` +
          `code=${code}&` +
          `redirect_uri=${redirectURI}&` +
          `grant_type=authorization_code`,
          { method: "GET" }
        );

        const data = await response.json();

        return {
          accessToken: data.access_token,
          refreshToken: data.refresh_token,
          accessTokenExpiresAt: new Date(Date.now() + data.expires_in * 1000),
          scopes: data.scope?.split(" ") ?? [],
          // 保留提供商特定字段在 raw 中
          raw: data,
        };
      },
      getUserInfo: async (tokens) => {
        // 访问 raw 令牌数据中的提供商特定字段
        const userId = tokens.raw?.user_id as string;

        const response = await fetch(
          `https://provider.example.com/api/user?` +
          `access_token=${tokens.accessToken}`
        );

        const data = await response.json();

        return {
          id: userId,
          name: data.display_name,
          email: data.email,
          image: data.avatar_url,
          emailVerified: data.email_verified,
        };
      },
    },
  ],
});

自定义获取用户信息

你可以提供自定义的 getUserInfo 函数以满足特定提供商需求:

genericOAuth({
  config: [
    {
      providerId: "custom-provider",
      // ... 其他配置选项
      getUserInfo: async (tokens) => {
        // 自定义逻辑获取并返回用户信息
        const userInfo = await fetchUserInfoFromCustomProvider(tokens);
        return {
          id: userInfo.sub,
          email: userInfo.email,
          name: userInfo.name,
          // ... 根据需要映射其他字段
        };
      }
    }
  ]
})

映射用户信息字段

如果提供商返回的用户信息格式不符合预期,或需要映射额外字段,可以使用 mapProfileToUser:

genericOAuth({
  config: [
    {
      providerId: "custom-provider",
      // ... 其他配置选项
      mapProfileToUser: async (profile) => {
        return {
          firstName: profile.given_name,
          // ... 根据需要映射其他字段
        };
      }
    }
  ]
})

访问原始令牌数据

tokens 参数包含一个 raw 字段,保存了提供商返回的原始令牌响应。此字段用于访问提供商特定字段:

getUserInfo: async (tokens) => {
  // 访问提供商特定字段
  const customField = tokens.raw?.custom_provider_field as string;
  const userId = tokens.raw?.provider_user_id as string;

  // 在你的逻辑中使用
  return {
    id: userId,
    // ...
  };
}

IDP 发起的流程

一些 OAuth 提供商(例如 Clever)允许身份提供商将用户重定向到你的回调 URL,而无需用户先点击应用中的“登录”按钮。这些回调包含 code 参数但没有 state,通常会被拒绝,因为缺少 state 会破坏标准的 CSRF 检查。

在提供商上设置 allowIdpInitiated: true,即可安全地接受这些流程:

auth.ts
import { betterAuth } from "better-auth";
import { genericOAuth } from "better-auth/plugins";

export const auth = betterAuth({
    plugins: [
        genericOAuth({
            config: [
                {
                    providerId: "clever",
                    discoveryUrl: "https://clever.com/.well-known/openid-configuration",
                    clientId: process.env.CLEVER_CLIENT_ID,
                    clientSecret: process.env.CLEVER_CLIENT_SECRET,
                    allowIdpInitiated: true,
                },
            ],
        }),
    ],
});

当无状态回调访问 /callback/:providerId 时,Better Auth 会丢弃提供商签发的代码,并在服务器端重新启动流程:生成新的 state 和 PKCE 验证器,然后将用户重定向到提供商的授权端点。提供商会识别用户当前的会话并自动完成流程。整个过程中都会保留 CSRF 保护和 PKCE。

对于始终从你这一侧发起流程的提供商,请保持 allowIdpInitiated 关闭(默认值)。针对这些提供商的无状态回调表示请求格式错误或具有恶意,应当被拒绝。

启用 allowIdpInitiated 时,请设置全局 baseURL 选项。IDP 发起的回调不包含发起请求的请求体,因此跳转会使用 baseURL 作为登录后的目标地址。如果未设置 baseURL,跳转会失败并返回 CALLBACK_URL_REQUIRED。

错误处理

插件内置了常见 OAuth 错误处理。错误通常会重定向到你的应用错误页面,并附带适当的错误信息 URL 参数。如果未提供错误回调 URL,用户将被重定向到 Better Auth 的默认错误页面。