Skip to content

考试系统 OIDC 接入指南

本文面向“第三方考试系统接入 IAM 单点登录(SSO)”场景,覆盖从 IAM 侧创建 OIDC 客户端,到考试系统前后端联调上线的完整流程。

适用目标:

  • IAM 作为身份提供方(IdP),域名示例:https://iam.cq-i.cn
  • 考试系统作为 OIDC Client(例如:https://feat-iam-login.exam-web-b0e.pages.dev
  • 登录方式包括邮箱登录与企业微信登录

先验证以下发现端点可访问:

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

如果不可访问,请先完成 IAM 侧 oauthProvider 插件配置,参考 OAuth 2.1 Provider

至少准备 2 个回调地址:

  • 开发环境:http://localhost:5173/oauth-callback
  • 生产环境:https://feat-iam-login.exam-web-b0e.pages.dev/oauth-callback

回调地址必须和 IAM 客户端配置中的 redirect_uris 完全一致(包含协议、域名、路径、端口)。

可以通过 IAM 后台或 API 创建客户端,核心参数建议如下:

参数建议值
client_name考试系统
redirect_uris开发 + 生产回调地址
grant_typesauthorization_code, refresh_token
response_typescode
scopeopenid profile email(按需追加)
token_endpoint_auth_method后端保密客户端用 client_secret_post
skip_consent建议 false(初期便于审计)
enable_end_session建议 true

创建后请安全保存:

  • client_id
  • client_secret(仅服务端保存,不可下发浏览器)

三、整体时序(考试系统 + IAM)

Section titled “三、整体时序(考试系统 + IAM)”
sequenceDiagram
    participant U as 用户浏览器
    participant E as 考试系统前端
    participant B as 考试系统后端
    participant I as IAM(iam.cq-i.cn)

    U->>E: 访问受保护页面 /paper/123
    E->>B: 检查本地登录态
    B-->>E: 未登录
    E->>U: 302 跳转到 IAM authorize
    U->>I: GET /api/auth/oauth2/authorize?...
    I->>U: 展示 IAM 登录页(邮箱/企业微信)
    U->>I: 完成登录
    I->>U: 302 到考试系统 callback?code=...&state=...
    U->>B: GET /oauth-callback?code=...&state=...
    B->>I: POST /api/auth/oauth2/token 换 token
    I-->>B: access_token / id_token / refresh_token
    B->>B: 校验 token + 建立本地会话
    B-->>U: 302 回原始页面 /paper/123

前端的职责是“引导跳转”,不要在前端换 token。

建议前端点击“统一登录”时跳转到考试系统后端:

window.location.href = '/auth/oidc/start?returnTo=/paper/123'

后端再拼接 IAM 授权 URL 并 302,避免前端暴露敏感逻辑。

returnTo 只允许站内路径(如 /paper/123),避免开放重定向漏洞。

五、考试系统后端接入(核心)

Section titled “五、考试系统后端接入(核心)”

以下示例以 Node.js/Express 思路展示,框架可替换。

Terminal window
OIDC_ISSUER=https://iam.cq-i.cn/api/auth
OIDC_CLIENT_ID=你的_client_id
OIDC_CLIENT_SECRET=你的_client_secret
OIDC_REDIRECT_URI=https://feat-iam-login.exam-web-b0e.pages.dev/oauth-callback
OIDC_POST_LOGOUT_REDIRECT_URI=https://feat-iam-login.exam-web-b0e.pages.dev/

⚠️ 踩坑提醒(exam-api 线上环境):如果你的 exam-api 部署在 Cloudflare Worker,线上环境变量不是改本地 .env 就会生效,必须同步修改 wrangler.toml(以及需要保密的值用 wrangler secret 配置)。否则会出现本地联调正常、线上仍报 invalid_client 或配置缺失的问题。

2. 强烈建议:优先使用标准 OIDC 客户端库

Section titled “2. 强烈建议:优先使用标准 OIDC 客户端库”

手写 authorize/token 请求虽然可行,但上线后容易遗漏签名校验、时钟偏差、JWKS 轮换等细节。 生产环境建议优先使用成熟库(如 openid-client)处理发现、跳转、回调和令牌校验。

如果你的后端暂时不能引入第三方库,可先按下文“手写版示例”实现,再逐步替换为标准库方案。

3. 手写版:发起授权请求(start 接口)

Section titled “3. 手写版:发起授权请求(start 接口)”
import crypto from 'node:crypto'
import type { Request, Response } from 'express'
export async function startOidc(req: Request, res: Response) {
const state = crypto.randomBytes(16).toString('hex')
const nonce = crypto.randomBytes(16).toString('hex')
const codeVerifier = crypto.randomBytes(32).toString('base64url')
const codeChallenge = crypto.createHash('sha256').update(codeVerifier).digest('base64url')
const returnTo = typeof req.query.returnTo === 'string' ? req.query.returnTo : '/'
const safeReturnTo = returnTo.startsWith('/') ? returnTo : '/'
// 这里将 state/nonce/codeVerifier/returnTo 放入后端会话或临时缓存
// 建议加 TTL(5~10 分钟)并限制一次性消费,防止重放
req.session.oidc = { state, nonce, codeVerifier, returnTo: safeReturnTo }
const url = new URL('https://iam.cq-i.cn/api/auth/oauth2/authorize')
url.searchParams.set('client_id', process.env.OIDC_CLIENT_ID!)
url.searchParams.set('redirect_uri', process.env.OIDC_REDIRECT_URI!)
url.searchParams.set('response_type', 'code')
url.searchParams.set('scope', 'openid profile email')
url.searchParams.set('state', state)
url.searchParams.set('nonce', nonce)
url.searchParams.set('code_challenge_method', 'S256')
url.searchParams.set('code_challenge', codeChallenge)
res.redirect(url.toString())
}

4. 手写版:处理回调并换取 token(callback 接口)

Section titled “4. 手写版:处理回调并换取 token(callback 接口)”
import type { Request, Response } from 'express'
export async function oidcCallback(req: Request, res: Response) {
const { code, state } = req.query
const snapshot = req.session.oidc
if (!snapshot || typeof code !== 'string' || typeof state !== 'string') {
return res.status(400).send('OIDC 回调参数缺失')
}
if (state !== snapshot.state) {
return res.status(400).send('state 校验失败')
}
const body = new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: process.env.OIDC_REDIRECT_URI!,
client_id: process.env.OIDC_CLIENT_ID!,
client_secret: process.env.OIDC_CLIENT_SECRET!,
code_verifier: snapshot.codeVerifier
})
const tokenResp = await fetch('https://iam.cq-i.cn/api/auth/oauth2/token', {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body
})
if (!tokenResp.ok) {
const message = await tokenResp.text()
return res.status(401).send(`换取 token 失败: ${message}`)
}
const tokenSet = (await tokenResp.json()) as {
access_token: string
refresh_token?: string
id_token?: string
expires_in?: number
}
// TODO: 校验 id_token(iss、aud、exp、nonce、签名)
// TODO: 解析用户唯一标识 sub,并映射考试系统本地用户
// TODO: 创建考试系统本地会话(不要直接把 IAM token 当作前端会话)
req.session.user = {
iamSub: '从 id_token 解出的 sub',
loginAt: Date.now()
}
const returnTo = snapshot.returnTo || '/'
delete req.session.oidc
res.redirect(returnTo)
}

5. 手写版必须补齐:id_token 校验

Section titled “5. 手写版必须补齐:id_token 校验”

最少需要校验以下字段:

  • iss:必须等于你的 OIDC_ISSUER
  • aud:必须包含你的 OIDC_CLIENT_ID
  • exp/iat:令牌未过期,且签发时间合理
  • nonce:必须等于 start 阶段写入会话的 nonce
  • signature:必须使用发现端点返回的 jwks_uri 验签

如果跳过以上校验,即便拿到 id_token 也不能视为“可信登录”。

六、用户映射策略(强烈建议)

Section titled “六、用户映射策略(强烈建议)”

为了保证账号稳定性,建议:

  1. 以 IAM 返回的 sub 作为考试系统主键映射(唯一且不可变)
  2. 邮箱、姓名仅作为展示字段同步
  3. 首次登录自动建档,后续按 sub 更新资料

不要用“邮箱”作为唯一主键,避免用户改邮箱后出现“新账号”问题。

在本流程中,企业微信登录由 IAM 处理,考试系统无需对接企业微信 API:

  1. 考试系统只发起 OIDC 授权请求到 IAM
  2. IAM 登录页里用户自行选择“企业微信登录”或“邮箱登录”
  3. IAM 完成企业微信认证后,仍按 OIDC 标准把 code 回调给考试系统
  4. 考试系统回调处理逻辑保持不变

这也是 IAM 作为统一身份中心的核心价值:第三方应用只接一种 OIDC 协议。

推荐做“两段登出”:

  1. 考试系统先清理本地会话
  2. 再引导用户访问 IAM 的结束会话端点(若客户端启用了 enable_end_session

避免仅登出考试系统而 IAM 仍保留登录态,导致用户下次“秒登录”。

建议不要硬编码结束会话地址,而是从发现端点读取 end_session_endpoint

const metadata = await fetch('https://iam.cq-i.cn/api/auth/.well-known/openid-configuration').then(
(r) => r.json()
)
const endSessionEndpoint = metadata.end_session_endpoint
const logoutUrl = new URL(endSessionEndpoint)
logoutUrl.searchParams.set('id_token_hint', '{可选,当前用户 id_token}')
logoutUrl.searchParams.set('post_logout_redirect_uri', process.env.OIDC_POST_LOGOUT_REDIRECT_URI!)
res.redirect(logoutUrl.toString())

上线前至少完成以下检查:

  • 全链路 HTTPS(开发环境除外)
  • redirect_uri 白名单精确匹配
  • statenoncePKCE 都已启用
  • client_secret 仅保存在后端
  • returnTo 仅允许站内路径
  • 回调异常场景有明确错误页(拒绝、过期、重放)
  • 日志已记录关键字段(requestId、state、sub、client_id),但不打印密钥与完整 token
  • 发现端点拉取与 JWKS 验签缓存策略已配置(避免每次请求实时拉取)

建议按以下顺序压测联调:

  1. 邮箱登录成功链路(未登录 -> IAM -> 回调 -> 进入考试页)
  2. 企业微信登录成功链路
  3. 登录后刷新页面仍保持会话
  4. 篡改 state 后必须失败
  5. 重复使用同一个 code 必须失败
现象常见原因优先排查项
invalid_redirect_uri回调地址不完全一致比对 IAM 客户端 redirect_uris 与实际请求
invalid_clientclient_id/client_secret 错误检查环境变量注入与多环境配置
invalid_grantcode 已使用或过期检查回调是否被重复消费、服务器时钟
state 校验失败跨节点会话丢失、用户开新页会话存储是否共享(Redis)、TTL 是否过短
登录成功但本地未登录未完成 id_token 校验或用户映射回调逻辑是否落库/建会话成功

十二、exam-web + exam-api + IAM 的 IAM 登录流程

Section titled “十二、exam-web + exam-api + IAM 的 IAM 登录流程”
sequenceDiagram
  participant Web as exam-web 浏览器
  participant API as exam-api Worker
  participant IAM as IAM OIDC Provider

  Web->>IAM: GET /api/auth/oauth2/authorize<br/>client_id + redirect_uri + code_challenge + state
  IAM->>Web: 登录/授权后跳回 /oauth-callback?code=...&state=...
  Web->>API: POST /api/auth/oauth/iam/login<br/>code + redirectUri + codeVerifier
  API->>IAM: POST /api/auth/oauth2/token<br/>client_id + code + redirect_uri + code_verifier
  IAM->>API: access_token
  API->>IAM: GET /api/auth/oauth2/userinfo
  IAM->>API: IAM 用户信息
  API->>API: 映射/创建 exam 本地用户,签发 exam 自己的 JWT
  API->>Web: 返回 exam token + user