考试系统 OIDC 接入指南
本文面向“第三方考试系统接入 IAM 单点登录(SSO)”场景,覆盖从 IAM 侧创建 OIDC 客户端,到考试系统前后端联调上线的完整流程。
适用目标:
- IAM 作为身份提供方(IdP),域名示例:
https://iam.cq-i.cn - 考试系统作为 OIDC Client(例如:
https://feat-iam-login.exam-web-b0e.pages.dev) - 登录方式包括邮箱登录与企业微信登录
一、对接前准备
Section titled “一、对接前准备”1. 确认 IAM OIDC 能力可用
Section titled “1. 确认 IAM OIDC 能力可用”先验证以下发现端点可访问:
https://iam.cq-i.cn/api/auth/.well-known/openid-configurationhttps://iam.cq-i.cn/api/auth/.well-known/oauth-authorization-server
如果不可访问,请先完成 IAM 侧 oauthProvider 插件配置,参考 OAuth 2.1 Provider。
2. 明确考试系统回调地址
Section titled “2. 明确考试系统回调地址”至少准备 2 个回调地址:
- 开发环境:
http://localhost:5173/oauth-callback - 生产环境:
https://feat-iam-login.exam-web-b0e.pages.dev/oauth-callback
回调地址必须和 IAM 客户端配置中的
redirect_uris完全一致(包含协议、域名、路径、端口)。
二、在 IAM 创建 OIDC 客户端
Section titled “二、在 IAM 创建 OIDC 客户端”可以通过 IAM 后台或 API 创建客户端,核心参数建议如下:
| 参数 | 建议值 |
|---|---|
client_name | 考试系统 |
redirect_uris | 开发 + 生产回调地址 |
grant_types | authorization_code, refresh_token |
response_types | code |
scope | openid profile email(按需追加) |
token_endpoint_auth_method | 后端保密客户端用 client_secret_post |
skip_consent | 建议 false(初期便于审计) |
enable_end_session | 建议 true |
创建后请安全保存:
client_idclient_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
四、考试系统前端接入
Section titled “四、考试系统前端接入”前端的职责是“引导跳转”,不要在前端换 token。
1. 登录入口跳转到后端发起 OIDC
Section titled “1. 登录入口跳转到后端发起 OIDC”建议前端点击“统一登录”时跳转到考试系统后端:
window.location.href = '/auth/oidc/start?returnTo=/paper/123'后端再拼接 IAM 授权 URL 并 302,避免前端暴露敏感逻辑。
2. 保存用户原始目标地址
Section titled “2. 保存用户原始目标地址”returnTo 只允许站内路径(如 /paper/123),避免开放重定向漏洞。
五、考试系统后端接入(核心)
Section titled “五、考试系统后端接入(核心)”以下示例以 Node.js/Express 思路展示,框架可替换。
1. 环境变量
Section titled “1. 环境变量”OIDC_ISSUER=https://iam.cq-i.cn/api/authOIDC_CLIENT_ID=你的_client_idOIDC_CLIENT_SECRET=你的_client_secretOIDC_REDIRECT_URI=https://feat-iam-login.exam-web-b0e.pages.dev/oauth-callbackOIDC_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_ISSUERaud:必须包含你的OIDC_CLIENT_IDexp/iat:令牌未过期,且签发时间合理nonce:必须等于 start 阶段写入会话的noncesignature:必须使用发现端点返回的jwks_uri验签
如果跳过以上校验,即便拿到 id_token 也不能视为“可信登录”。
六、用户映射策略(强烈建议)
Section titled “六、用户映射策略(强烈建议)”为了保证账号稳定性,建议:
- 以 IAM 返回的
sub作为考试系统主键映射(唯一且不可变) - 邮箱、姓名仅作为展示字段同步
- 首次登录自动建档,后续按
sub更新资料
不要用“邮箱”作为唯一主键,避免用户改邮箱后出现“新账号”问题。
七、企业微信登录如何串联
Section titled “七、企业微信登录如何串联”在本流程中,企业微信登录由 IAM 处理,考试系统无需对接企业微信 API:
- 考试系统只发起 OIDC 授权请求到 IAM
- IAM 登录页里用户自行选择“企业微信登录”或“邮箱登录”
- IAM 完成企业微信认证后,仍按 OIDC 标准把
code回调给考试系统 - 考试系统回调处理逻辑保持不变
这也是 IAM 作为统一身份中心的核心价值:第三方应用只接一种 OIDC 协议。
八、登出联动建议
Section titled “八、登出联动建议”推荐做“两段登出”:
- 考试系统先清理本地会话
- 再引导用户访问 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_endpointconst 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())九、安全与上线检查清单
Section titled “九、安全与上线检查清单”上线前至少完成以下检查:
- 全链路 HTTPS(开发环境除外)
redirect_uri白名单精确匹配state、nonce、PKCE都已启用client_secret仅保存在后端returnTo仅允许站内路径- 回调异常场景有明确错误页(拒绝、过期、重放)
- 日志已记录关键字段(requestId、state、sub、client_id),但不打印密钥与完整 token
- 发现端点拉取与 JWKS 验签缓存策略已配置(避免每次请求实时拉取)
十、联调建议(周五上线前)
Section titled “十、联调建议(周五上线前)”建议按以下顺序压测联调:
- 邮箱登录成功链路(未登录 -> IAM -> 回调 -> 进入考试页)
- 企业微信登录成功链路
- 登录后刷新页面仍保持会话
- 篡改
state后必须失败 - 重复使用同一个
code必须失败
十一、常见错误与排查
Section titled “十一、常见错误与排查”| 现象 | 常见原因 | 优先排查项 |
|---|---|---|
invalid_redirect_uri | 回调地址不完全一致 | 比对 IAM 客户端 redirect_uris 与实际请求 |
invalid_client | client_id/client_secret 错误 | 检查环境变量注入与多环境配置 |
invalid_grant | code 已使用或过期 | 检查回调是否被重复消费、服务器时钟 |
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