Skip to content

OAuth 2.1 Provider

IAM 系统内置 OAuth 2.1 Provider 功能,可以作为身份提供者(IdP)为第三方应用提供认证服务。

  • OAuth 2.1 协议支持: Authorization Code、Refresh Token、Client Credentials
  • OpenID Connect: 支持 OIDC 标准,提供 ID Token
  • 客户端管理: 创建、编辑、删除 OAuth 客户端
  • 授权管理: 查看和撤销用户授权
  • 密钥轮换: 支持客户端密钥轮换
  • 应用访问控制: 用户/部门白名单、应用角色与自定义 claims

packages/auth/src/index.ts 中配置:

import { betterAuth } from 'better-auth'
import { oauthProvider } from '@better-auth/oauth-provider'
export const auth = betterAuth({
// ... 其他配置
plugins: [
admin(),
jwt(),
oauthProvider({
loginPage: `${env.CORS_ORIGIN}/oauth-login`,
consentPage: `${env.CORS_ORIGIN}/consent`,
scopes: ['openid', 'profile', 'email', 'offline_access']
})
]
})
配置项 说明 示例
loginPage 用户未登录时重定向的登录页面 /oauth-login
consentPage 用户授权同意页面 /consent

apps/web/src/lib/auth-client.ts 中添加 OAuth Provider 客户端插件:

import { oauthProviderClient } from '@better-auth/oauth-provider/client'
import { createAuthClient } from 'better-auth/react'
export const authClient = createAuthClient({
// 省略 baseURL,客户端使用同源 /api/auth。
// 生产公开 issuer 固定为 https://iam.cq-i.cn/api/auth。
plugins: [adminClient(), oauthProviderClient()]
})

启用 OAuth Provider 后,系统会提供以下发现端点:

端点 说明
/.well-known/oauth-authorization-server/api/auth RFC 8414 授权服务器元数据(只在后端 origin)
/api/auth/.well-known/openid-configuration OpenID Connect 发现文档

示例 URL:

  • https://iam-api.cq-i.cn/.well-known/oauth-authorization-server/api/auth
  • https://iam.cq-i.cn/api/auth/.well-known/openid-configuration

OIDC 客户端应使用前端域名下的 openid-configuration(经已有 /api/* 代理到 Hono)。RFC 8414 元数据只在后端 Worker 提供,不占用 iam.cq-i.cn 根路径。

使用 authClient.oauth2.createClient() API:

const { data, error } = await authClient.oauth2.createClient({
redirect_uris: ['https://your-app.com/callback'],
client_name: 'My Application',
token_endpoint_auth_method: 'client_secret_basic',
grant_types: ['authorization_code', 'refresh_token'],
response_types: ['code'],
scope: 'openid profile email',
skip_consent: false,
enable_end_session: true,
require_pkce: true
})
// 返回 client_id 和 client_secret
console.log(data.client_id, data.client_secret)
参数 类型 说明
redirect_uris string[] 授权回调 URI 列表(必填)
client_name string 客户端名称
token_endpoint_auth_method string 认证方式:client_secret_postclient_secret_basicnone
grant_types string[] 授权类型:authorization_coderefresh_tokenclient_credentials
response_types string[] 响应类型,通常为 ['code']
scope string 请求的权限范围
skip_consent boolean 是否跳过用户授权确认
enable_end_session boolean 是否启用 OIDC end_session
require_pkce boolean 是否强制 Authorization Code 使用 PKCE
post_logout_redirect_uris string[] 登出后重定向 URI

openid profile email 是当前默认 scope,机密客户端默认推荐 client_secret_basic。public client 使用 token_endpoint_auth_method=none,并应强制 require_pkce=true。邮箱相关 claims 用于下游系统的首次绑定;绑定后应改用 ID Token 的 sub

管理端还可维护 metadata:descriptionlogotokenLifetimeallowedDepartmentsallowedUsers。metadata 的解析和表单 schema 位于 @IAM/contracts

const { data, error } = await authClient.oauth2.getClients()
// data: OAuthClient[]
const { data, error } = await authClient.oauth2.updateClient({
client_id: 'xxx',
update: {
client_name: 'New Name'
}
})
const { data, error } = await authClient.oauth2.client.rotateSecret({
client_id: 'xxx'
})
// 返回新的 client_secret
const { error } = await authClient.oauth2.deleteClient({
client_id: 'xxx'
})
const { data, error } = await authClient.oauth2.getConsents()
// data: OAuthConsent[]
const { data, error } = await authClient.oauth2.getConsent({
query: { id: 'consent-id' }
})
const { data, error } = await authClient.oauth2.updateConsent({
id: 'consent-id',
update: {
scopes: ['openid', 'profile']
}
})
const { error } = await authClient.oauth2.deleteConsent({
id: 'consent-id'
})

在 OAuth 2.1 / OIDC 流程中涉及以下角色:

角色 说明 对应实体
用户 (Resource Owner) 坐在浏览器前的终端用户 浏览器
客户端 (Client) 请求访问用户资源的第三方应用 CRM、OAuth Debugger 等
授权服务器 (Authorization Server) 负责登录、发放授权码和令牌 IAM 系统
资源服务器 (Resource Server) 存放用户数据的 API IAM 后端 API
sequenceDiagram
    participant C as 第三方客户端
    participant B as 用户浏览器
    participant S as IAM 授权服务器

    C->>B: 1. 重定向到 /api/auth/oauth2/authorize?...
    B->>S: 2. 请求授权端点

    alt 用户未登录
        S->>B: 3a. 302 重定向到 /oauth-login?...&exp=...&sig=...
        B->>B: 3b. 用户选择企业微信,或跳转到 /login/email
        B->>S: 3c. 登录成功后整页访问 /api/auth/oauth2/authorize?...
    end

    alt 首次企微登录未补邮箱或邮箱未验证
        S->>B: 重定向到 /complete-profile
        B->>B: 填写企业邮箱并完成邮件验证
        B->>S: POST /api/auth/oauth2/continue
    end

    alt 需要用户授权确认
        S->>B: 4a. 302 重定向到 /consent?...&exp=...&sig=...
        B->>B: 4b. 用户点击"同意"
        B->>S: 4c. POST /api/auth/oauth2/consent(携带 oauth_query)
    end

    S->>B: 5. 302 重定向到 redirect_uri?code=...&state=...
    B->>C: 6. 携带 code 到回调地址
    C->>S: 7. POST /api/auth/oauth2/token 换取令牌
    S->>C: 8. 返回 access_token, id_token, refresh_token

OIDC 在“用户未登录”时的流程,本质上是两段流程的拼接:先走一段本地登录流程,登录成功后再恢复原来的 OIDC 授权流程继续执行。

第一步:第三方客户端发起授权请求

Section titled “第一步:第三方客户端发起授权请求”

客户端将用户重定向到 IAM 的授权端点:

GET /api/auth/oauth2/authorize
?client_id=xxx
&redirect_uri=https://app.com/callback
&response_type=code
&scope=openid profile email
&state=random-state
&nonce=random-nonce
&code_challenge=base64url-sha256
&code_challenge_method=S256

IAM 授权服务器收到请求后,检查用户是否已登录(即浏览器是否携带有效的 Session Cookie)。

第二步:用户未登录 → 跳转登录页

Section titled “第二步:用户未登录 → 跳转登录页”

如果用户尚未登录,授权服务器会将当前的 OAuth query 参数加上 exp(过期时间)和 sig(签名),然后 302 重定向 到配置的登录页:

302 → /oauth-login?client_id=xxx&redirect_uri=...&exp=...&sig=...

关键点:页面 URL 上的完整 query string 就是后续恢复 OIDC 流程的“凭据”,其中 expsig 确保参数不被篡改且有时效性。

第三步:用户在登录页完成认证

Section titled “第三步:用户在登录页完成认证”

/oauth-login 主入口是企业微信。用户也可以点击「使用邮箱密码登录」,跳到 /login/email;跳转会保留授权参数,并在缺少 callbackUrl 时补成 /api/auth/oauth2/authorize?...

邮箱登录使用 Better Auth 前端 SDK。成功后若回跳地址以 /api/ 开头,前端执行整页跳转,让浏览器带着新 Session Cookie 重新进入授权端点。

第四步:已登录会话恢复 OIDC 流程

Section titled “第四步:已登录会话恢复 OIDC 流程”

登录成功后浏览器再次请求 /api/auth/oauth2/authorize

  1. Session 已建立:登录接口通过 Set-Cookie 种入会话
  2. 验签授权上下文:授权端点校验 URL 上的 expsig,确认参数未被篡改且未过期
  3. 继续授权决策:用户已登录,进入跳过确认或 consent 流程

恢复后的请求重新进入 /oauth2/authorize 的判断逻辑,此时用户已登录:

  • 跳过授权确认:如果客户端配置了 skip_consent: true,或用户已经同意过相同 scope,则直接签发 authorization_code 并 302 到第三方 redirect_uri
  • 需要授权确认:跳转到 consent 页面

第六步:用户授权确认(可选)

Section titled “第六步:用户授权确认(可选)”

如果需要用户确认,浏览器进入 consent 页面:

/consent?client_id=xxx&scope=openid+profile+email&exp=...&sig=...

用户点击“同意”后,前端将当前 query 作为 oauth_query 提交到 /api/auth/oauth2/consent。服务端验签通过后签发 authorization_code,302 重定向到第三方 redirect_uri

302 → https://app.com/callback?code=xyz123&state=random-state

第七步:第三方客户端换取令牌

Section titled “第七步:第三方客户端换取令牌”

客户端在回调接口收到 code 后,在后端直接请求 IAM 的 Token 端点:

POST /api/auth/oauth2/token
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code
&code=xyz123
&redirect_uri=https://app.com/callback
&client_id=xxx
&client_secret=xxx
&code_verifier=original-random-value

IAM 验证无误后,返回令牌:

{
"access_token": "...",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "...",
"id_token": "..."
}

IAM 提供了 OAuth 管理界面:

  • OAuth 客户端管理: /admin/oauth-clients

    • 创建、编辑、删除客户端
    • 查看客户端详情
    • 轮换客户端密钥
  • OAuth 授权管理: /admin/consents

    • 查看所有授权记录
    • 编辑授权范围
    • 撤销用户授权

启用 OAuth Provider 插件后,需要更新数据库结构:

Terminal window
# 生成 Schema
pnpm dlx auth@1.7.1 generate --yes \
--config packages/auth/src/index.ts \
--output packages/db/src/schema/auth.ts
# 生成迁移
pnpm db:generate
# 应用迁移
pnpm db:migrate
  1. 保护客户端密钥: client_secret 仅在创建和轮换时返回一次
  2. 使用 HTTPS: 生产环境必须使用 HTTPS
  3. 验证 redirect_uri: 确保回调 URI 与注册的一致
  4. 定期轮换密钥: 建议定期轮换客户端密钥
  5. 最小权限原则: 只请求必要的 scope
  6. 强制 PKCE: public client 必须使用 S256;机密客户端也建议开启
  7. 校验 ID Token: 接入方必须校验签名、issaudexpiatnonce
  8. 角色边界: app_role 是应用角色提示,接入应用仍需落实自己的授权策略