# 任意项目接入 RainID(OIDC)实操指南 > 视角:**实现 / 落地**。不是协议科普,而是"从 0 到 1 动手做完 + 线上能跑"的操作手册。 > 素材来源:RainWeb × RainID 真实接入(`lib/rainid.js`、`routes/oidc.js`、`routes/auth.js`、`routes/settings.js`、前端 `Login.jsx`/`Register.jsx`),已上线并排过障。 > 技术栈:Node.js / Express / openid-client v6(函数式 API)。**每个模式旁标注"其他栈同理"**。 --- ## 0. 全景图:一个站点接 RainID 需要什么 ``` 用户 ──→ 你的前端登录页 ├─ 方式 A(ROPC):输 RainID 账号密码 → 你的后端 POST rainid /oauth/token (password) │ → 按 sub 建/找影子账号 → 发你的本地 JWT └─ 方式 B(授权码+PKCE):点"使用 RainID 登录" → 你的后端 302 到 RainID → RainID 登录/同意 → 回跳你的 callback → 后端换 token + userinfo → 影子账号 → 一次性 ticket → 前端拿 ticket 换本地 JWT(长 token 不进 URL) 登出:你的后端 302 到 RainID end_session → RainID 确认销毁 IdP 会话 → 303 回跳你的页面 ``` **核心心智模型**: 1. **RainID 只认标准 OIDC/OAuth2**,你的项目只需要一个 `discovery_url`。 2. **身份归属 RainID,角色归属你**——影子账号按 `sub` 绑定,admin 等角色在你的本地库维护。 3. **access_token 是 opaque,id_token 只有 `sub`**——想要 email/name 必须调 userinfo 端点。 4. **你的会话是 JWT/session(本地),RainID 的会话是它的签名 cookie,两者不共享**。登出联动靠 end_session。 --- ## 1. Step-by-step 接入步骤 ### Step 1:RainID Admin 登记 client(前置,5 分钟) RainID Admin → 应用管理 → 新增应用,填: | 字段 | 值 | 说明 | |---|---|---| | `name` | 你的站点名(如 RainWeb) | 展示名 | | `confidential` | ✅ 机密 client | 拿到 `client_secret`;**明文只在创建/轮换时返回一次,立即存好** | | `redirect_uris` | `https://你的域名/api/auth/oidc/callback` | 授权码回跳白名单,**每行一个,未登记直接拒绝** | | `grant_types` | `authorization_code` + `refresh_token`(+ `password` 若要 ROPC) | 按协议选 | | `require_pkce` | ✅ 强制(默认) | 防授权码注入 | | `scope` | `openid profile email offline_access` | 白名单必须覆盖你请求的 scope,否则 `invalid_scope` | | `post_logout_redirect_uris` | `https://你的域名/login.html` | **登出回跳白名单,忘了登记 end_session 就 400** | 创建后**立即生效**(client 热更新,无需重启 RainID)。 > 踩过:登出配置好了但 `post_logout_redirect_uri` 没登记 → RainID 返回 400,用户登出卡死。 ### Step 2:装依赖 ```bash npm install openid-client # Node 版;浏览器端等价物见下 npm install jose # 仅当你绕过 SDK 手动验签时(一般不需要,SDK 全包) ``` - **Node**:`openid-client` v6(函数式 API,本项目用的就是它)。 - **Python**:`authlib`;**Go**:`coreos/go-oidc`;**Java**:`spring-security-oauth2-client`。都是标准 OIDC,照着下面模式映射即可。 - **浏览器纯前端(SPA)**:不要 `oidc-client-ts` 全放前端!**SPA 必须 BFF**(后端代理),禁止前端持有 refresh_token。 ### Step 3:配置项(先想清楚再写代码) 分两档(详见第 3 章): - **非敏感(可后台热改、可读)**:`rainid_enabled`、`rainid_client_id`、`rainid_discovery_url`、`rainid_register_redirect`。 - **机密(只写不读)**:`rainid_client_secret`——**优先 `.env.json`(gitignored)**,回退后台设置(可写、GET 不返回)。 ### Step 4:后端三个路由(授权码流)/ 一个路由(ROPC) 统一挂载(RainWeb 挂 `/api/auth/oidc`): | 方法 | 路径 | 作用 | |---|---|---| | GET | `/login` | 生成 PKCE+state,302 到 RainID authorize | | GET | `/callback` | 验 state → 换 token → userinfo → 影子账号 → 发一次性 ticket → 302 回前端 | | POST | `/exchange` | 前端用 ticket 换本地 JWT(30s 一次性) | | GET | `/logout` | 302 到 RainID end_session(登出联动) | ROPC 不需要新路由——**复用你的现有登录 POST**,在处理器里加一个分支转发 RainID。 完整代码模式见第 2 章。 ### Step 5:影子账号(数据层) 用户表加一列 `rainid_user_id`(唯一索引),按 `sub` 查/建。见 2.5。 ### Step 6:前端入口(登录页/注册页) - 登录页:`rainid_enabled=1` 时显示「使用 RainID 登录」按钮 → `window.location.href='/api/auth/oidc/login'`。 - 回跳收尾:URL 带 `?oidc_ticket=xxx` → POST `/exchange` 换 token → 存 localStorage → 通知登录态变更 → 跳首页;带 `?oidc_error=xxx` → 映射错误文案。 - 注册页:`rainid_register_redirect=1` 时整页跳 `https://rainid.rainnya.asia/register`,本地表单不渲染。 ### Step 7:登出联动 你的登出按钮:`rainid_enabled=1` 时跳 `/api/auth/oidc/logout`(后端 302 到 RainID end_session),否则直接清本地会话。 ### Step 8:验收(对照 RainID 文档 §15 checklist) 完整走一遍:注册 → RainID 登录 → 同意 → 回跳 → 影子账号 → 刷新页面保持登录 → 登出回跳。**再测一遍 2FA 用户、密码错误、未登记回调**三条错误路径。 --- ## 2. 关键代码模式(可直接抄) ### 2.1 discovery 缓存(⚠️ v6 第一参数必须是 `new URL()`) ```js const openidClient = require('openid-client'); // v6 函数式 API const DISCOVERY_DEFAULT = 'https://rainid.rainnya.asia/oauth'; // 模块级缓存:discovery 是一次网络请求 + JWKS 拉取,不能每次登录都打 let configCache = null; async function getOidcConfig() { if (configCache) return configCache; // ⚠️⚠️ v6 的 discovery() 第一个参数必须是 URL 实例! // 传字符串会抛 "server must be an instance of URL" → 所有登录请求 502(线上踩过的根因,见 §5.1) configCache = await openidClient.discovery( new URL(discoveryUrl), // ← 必须是 new URL(),不是字符串 clientId, clientSecret ); return configCache; } ``` 要点: - **缓存副作用:后台改了 discovery_url / client 配置,需重启进程生效**(RainWeb 注释里明确写了这条)。 - JWKS 多 key 轮换 SDK 自动处理,你不需要管。 - 其他栈同理:authlib 的 `discovery(url)`、go-oidc 的 `NewProvider` 接受 URL 字符串,但 Node 的 v6 就是严格。 ### 2.2 PKCE + state 无 session 框架怎么存:内存 Map + TTL,取用即删 你的框架若没有 session(RainWeb 是无状态 JWT),PKCE 的 `code_verifier` 和 `state` 不能丢。**内存 Map + 定时清理**即可(单进程够用;多进程部署换 Redis,TTL 语义一样): ```js const OIDC_STATE_TTL = 10 * 60 * 1000; // 发起登录 → 回调 const oidcStates = new Map(); // state -> { verifier, createdAt } // 每分钟清扫过期项(防内存泄漏) setInterval(() => { const now = Date.now(); for (const [k, v] of oidcStates) if (now - v.createdAt > OIDC_STATE_TTL) oidcStates.delete(k); }, 60000); // ① 发起登录 router.get('/login', async (req, res) => { const config = await getOidcConfig(); const codeVerifier = openidClient.randomPKCECodeVerifier(); const codeChallenge = await openidClient.calculatePKCECodeChallenge(codeVerifier); const state = openidClient.randomState(); oidcStates.set(state, { verifier: codeVerifier, createdAt: Date.now() }); const url = openidClient.buildAuthorizationUrl(config, { redirect_uri: siteBase(req) + '/api/auth/oidc/callback', scope: 'openid profile email', // 要 refresh 就加 offline_access(需用户同意) code_challenge: codeChallenge, code_challenge_method: 'S256', state, }); res.redirect(url.href); }); ``` 要点: - **`state` 一次性,回调里取用即删**——防重放。 - **不要手写 PKCE**,用 SDK 标准函数(`randomPKCECodeVerifier` / `calculatePKCECodeChallenge`)。 ### 2.3 回调换 token + 验签 + userinfo(id_token 只含 sub!) ```js router.get('/callback', async (req, res) => { const state = req.query.state; const stored = state && oidcStates.get(state); if (!stored) return res.status(400).send('登录状态已失效,请重新使用 RainID 登录'); oidcStates.delete(state); // 一次性 const config = await getOidcConfig(); const base = siteBase(req); const currentUrl = new URL(req.originalUrl, base); // 回调的完整 URL(含 query) try { // authorizationCodeGrant 自动完成:验 state + PKCE + 换 token // + 自动验 id_token 签名(RS256/JWKS) + iss + aud + exp const tokens = await openidClient.authorizationCodeGrant(config, currentUrl, { pkceCodeVerifier: stored.verifier, expectedState: state, idTokenExpected: true, }); const claims = tokens.claims(); // id_token 解码 const sub = claims && claims.sub; // ⚠️ 只有 sub!没有 email/name(见 §5.3) if (!sub) throw new Error('id_token 缺少 sub'); // ⚠️ 必须调 userinfo 拿 email/name/email_verified // fetchUserInfo 第三个参数传 sub = 校验 userinfo 的 sub 与 id_token 一致(防注入) const userinfo = await openidClient.fetchUserInfo(config, tokens.access_token, sub); const user = findOrCreateRainidUser(sub, userinfo); // 见 2.5 const token = jwt.sign({ id: user.id, username: user.username, role: user.role }, SECRET, { expiresIn: '7d' }); // ← 你的本地会话 const ticket = crypto.randomBytes(16).toString('hex'); oidcTickets.set(ticket, { jwt: token, username: user.username, role: user.role, email: user.email, email_verified: user.email_verified, createdAt: Date.now() }); res.redirect(frontLoginUrl(req, '?oidc_ticket=' + encodeURIComponent(ticket))); } catch (err) { // error 参数:access_denied / consent_required / invalid_grant 等 → 回前端带错误码 const code = err && err.error; res.redirect(frontLoginUrl(req, '?oidc_error=' + encodeURIComponent(code || 'server_error'))); } }); ``` 要点: - `sub` 是**稳定、永不变更**的绑定键,跨应用唯一。 - 错误码映射表见 §6(前端把 `oidc_error` 码翻译成用户文案)。 ### 2.4 一次性 ticket 换本地 JWT(避免长 token 进 URL) JWT 动辄几百字符,塞进 302 URL 会进浏览器历史/反代日志。**用一个 30 秒一次性的随机 ticket 过渡**: ```js const OIDC_TICKET_TTL = 30 * 1000; const oidcTickets = new Map(); // ticket -> { jwt, ... } router.post('/exchange', (req, res) => { const ticket = req.body && req.body.ticket; if (!ticket) return res.status(400).json({ error: '缺少 ticket' }); const rec = oidcTickets.get(ticket); if (!rec) return res.status(401).json({ error: '登录已过期,请重新使用 RainID 登录' }); oidcTickets.delete(ticket); // 一次性 res.json({ token: rec.jwt, username: rec.username, role: rec.role, email: rec.email, email_verified: rec.email_verified }); }); ``` 前端收尾(`Login.jsx` 实测模式): ```js useEffect(() => { const params = new URLSearchParams(window.location.search); const ticket = params.get('oidc_ticket'); const oidcError = params.get('oidc_error'); if (ticket) { handleOidcTicket(ticket); return; } // 先兑换,30s 有效期 if (oidcError) { setError(OIDC_ERROR_TEXT[oidcError] || 'RainID 登录失败'); clearOidcParams(); return; } ... }, []); const handleOidcTicket = async (ticket) => { const data = await authApi.oidcExchange(ticket); setToken(data.token); notifyAuthChange(); clearOidcParams(); // history.replaceState 清掉 URL 上的 ticket,避免刷新重复兑换 navigate(from || '/'); }; ``` ### 2.5 影子账号创建/绑定逻辑(含 email_verified 闸门、并发兜底、首用户 admin) ```js // ① 按 sub 查 → ② email_verified 且本地同邮箱未绑定 → 绑定 → ③ 否则创建 function findOrCreateRainidUser(sub, profile) { if (!sub) throw new Error('RainID userinfo 缺少 sub'); const email = String(profile.email || '').trim().toLowerCase(); // 1) 已绑定:sub 命中直接返回 let user = db.get('SELECT * FROM users WHERE rainid_user_id = ?', [sub]); if (user) return user; // 2) 自动绑定:仅当 RainID 已验证该邮箱(email_verified=true)才允许绑 // 防撞绑:未验证邮箱的 userinfo 绝不能自动绑到本地已有账号 if (email && profile.email_verified) { const local = db.get('SELECT * FROM users WHERE email = ?', [email]); if (local && !local.rainid_user_id) { // 条件 UPDATE:WHERE 再带 rainid_user_id='',并发下第二个人写不进来 db.run('UPDATE users SET rainid_user_id = ? WHERE id = ? AND rainid_user_id = ?', [sub, local.id, '']); const updated = db.get('SELECT * FROM users WHERE id = ?', [local.id]); if (updated && updated.rainid_user_id === sub) return updated; } } // 3) 创建影子账号 let username = profile.preferred_username || ('rainid_' + String(sub).slice(0, 8)); if (db.get('SELECT id FROM users WHERE username = ?', [username])) { username = username + '_' + String(sub).slice(0, 4); // 冲突加后缀 } // 影子账号给随机不可登录密码 —— 禁止本地密码登入(见 §4) const randomHash = bcrypt.hashSync(crypto.randomBytes(24).toString('hex'), 10); // 首用户 admin:全站无 admin 时第一个绑定的用户自动成为 admin(RainID 不管角色) const adminCount = db.get("SELECT COUNT(*) AS c FROM users WHERE role = 'admin'"); const role = adminCount && adminCount.c > 0 ? 'user' : 'admin'; try { const id = db.run( 'INSERT INTO users (username, password, email, email_verified, role, avatar, rainid_user_id) VALUES (?, ?, ?, 1, ?, ?, ?)', [username, randomHash, email, role, profile.picture || '', sub] ); return db.get('SELECT * FROM users WHERE id = ?', [id]); } catch (e) { // 并发兜底:唯一索引冲突 → 重查按 sub 返回(谁先建都行) const dup = db.get('SELECT * FROM users WHERE rainid_user_id = ?', [sub]); if (dup) return dup; throw e; } } ``` 配套 DDL(唯一索引是并发兜底的地基,**必须建**): ```sql ALTER TABLE users ADD COLUMN rainid_user_id TEXT DEFAULT ''; -- 幂等补列 CREATE UNIQUE INDEX IF NOT EXISTS idx_users_rainid ON users(rainid_user_id) WHERE rainid_user_id <> ''; -- 部分唯一索引 ``` 要点: - **email_verified 是自动绑定的闸门**:只有 RainID 验证过的邮箱才能绑本地号,防有人用他人邮箱注册 RainID 来接管本地账号。 - 绑定用**条件 UPDATE**(`WHERE rainid_user_id=''`)而非先查后改,并发安全。 ### 2.6 ROPC 密码登录(`grant_type=password` + 2FA 拒绝文案) ```js // 复用一个 getOidcConfig()。genericGrantRequest 自动带 client 认证(HTTP Basic) async function rainidRopcLogin(username, password) { const s = getOidcSettings(); if (!s.enabled) return { ok: false, status: 400, error: 'RainID 登录未启用' }; let config; try { config = await getOidcConfig(); } catch (e) { if (e && e.code === 'RAINID_NOT_CONFIGURED') return { ok: false, status: 500, error: 'RainID 客户端未配置' }; return { ok: false, status: 502, error: 'RainID 服务配置错误:Discovery 失败(检查端点是否为 https)' }; } try { const tokens = await openidClient.genericGrantRequest(config, 'password', { username, password, scope: 'openid profile email', }); // id_token 验签 SDK 负责;拿 sub 做 userinfo 的 subject 校验 let expectedSub = openidClient.skipSubjectCheck; try { const claims = tokens.claims(); if (claims && claims.sub) expectedSub = claims.sub; } catch { /* id_token 解析失败不阻断,userinfo 为准 */ } const userinfo = await openidClient.fetchUserInfo(config, tokens.access_token, expectedSub); const user = findOrCreateRainidUser(userinfo.sub, userinfo); return { ok: true, user }; } catch (err) { return mapOidcError(err); // 见下 } } // RainID OAuth 错误 → HTTP 状态 + 文案(防枚举!不区分具体原因) function mapOidcError(err) { const code = err && err.error; const desc = (err && err.error_description) || ''; switch (code) { case 'invalid_grant': // ⚠️ 2FA 用户 ROPC 被拒 → 固定文案引导走 RainID 登录页 return { ok: false, status: 401, error: desc.includes('二次验证') ? '该账号已开启二次验证,请使用 RainID 登录' : '用户名/邮箱或密码错误' }; case 'rate_limited': return { ok: false, status: 429, error: '尝试过于频繁,请稍后再试' }; case 'invalid_client': case 'invalid_scope': case 'invalid_request': return { ok: false, status: 500, error: 'RainID 配置错误,请联系管理员' }; default: return { ok: false, status: 502, error: 'RainID 服务暂不可用,请稍后再试' }; } } ``` 你的登录路由这样接线(`routes/auth.js` 实测模式): ```js router.post('/login', loginLimiter, async (req, res) => { // ...验证码校验略 if (db.getSetting('rainid_enabled') === '1') { // ① admin 逃生通道:本地 admin 且有本地密码 → 走本地 bcrypt(见 §4) const localUser = db.get('SELECT * FROM users WHERE username = ?', [username]); if (localUser && localUser.role === 'admin' && !!localUser.password) { if (!bcrypt.compareSync(password, localUser.password)) return res.status(401).json({ error: '用户名或密码错误' }); const token = jwt.sign({...}, SECRET, { expiresIn: '7d' }); return res.json({ token, ... }); } // ② 其余用户:转发 RainID(ROPC) const r = await rainidRopcLogin(username, password); if (!r.ok) return res.status(r.status).json({ error: r.error }); const token = jwt.sign({ id: r.user.id, ... }, SECRET, { expiresIn: '7d' }); return res.json({ token, ... }); } // ③ RainID 未启用 → 本地 bcrypt 登录(原有逻辑保留) ... }); ``` ### 2.7 登出联动 end_session ```js router.get('/logout', async (req, res) => { const s = getOidcSettings(); if (!s.enabled) return res.redirect('/'); try { const config = await getOidcConfig(); const url = openidClient.buildEndSessionUrl(config, { // ⚠️ 此地址必须在 RainID Admin 的 post_logout_redirect_uris 里登记过,否则 400 post_logout_redirect_uri: siteBase(req) + '/login.html', }); res.redirect(url.href); // 用户确认后 RainID 303 回跳 login.html } catch (err) { console.error('[RainID] logout error:', err.message); res.redirect('/'); // RainID 故障时至少能回站内 } }); ``` 前端(`Layout.jsx` 实测):`rainid_enabled=1` 时登出按钮跳 `/api/auth/oidc/logout`(整页跳,让 RainID 处理完再回来),否则直接清本地 token。 **清本地会话的时点**:RainID 回跳 `login.html` 后,前端 `logout()` 清 localStorage token 即可(你的 JWT 是有时效的;要彻底就再调一次 revocation,非必需)。 --- ## 3. 配置项设计:哪些进设置、哪些进机密存储 **⚠️ 最易踩的坑:凡前端需要读的 key,必须同时加进"公开设置白名单",否则前端 `getPublicSettings()` 拿不到 → 功能静默失效**(RainWeb 踩过 `rainid_register_redirect` 漏 PUBLIC_KEYS,见 §5.4)。 ### 3.1 三个集合(RainWeb 实测结构) ```js // ① 公开设置白名单:任何未登录用户可读(前端页面依赖) const PUBLIC_KEYS = [ 'site_name', /* ... */, 'rainid_enabled', 'rainid_register_redirect', // ← 前端判断显示 RainID 按钮/注册托管 /* ... */ ]; // ② 管理端可读(adminOnly GET):包含全部非敏感配置 const ALL_KEYS = [ /* ... */, 'rainid_enabled', 'rainid_client_id', 'rainid_discovery_url', 'rainid_register_redirect', /* ... */ ]; // ③ 可写白名单:管理端可写(adminOnly PUT)。机密 key 在此,但绝不在 ② const ALLOWED_SET = [...ALL_KEYS, 'rainid_client_secret']; // ← secret 只写不读! ``` **机密存储原则**: - `rainid_client_secret` **优先 `.env.json`(gitignored)**,server.js 启动时注入环境变量 `RAINID_CLIENT_SECRET`,运行时还有 `.env.json` 直读和后台设置两档回退(见 `lib/rainid.js getClientSecret()`)。 - 后台设置通道**可写不可读**:`ALLOWED_SET` 里有它(能写),`ALL_KEYS` 里没有它(GET `/api/settings` 不返回),浏览器和 API 都读不到明文。 - **fail-closed**:`rainid_enabled='1'` 但 `client_id`/`client_secret` 任一缺失 → `enabled=false`,RainID 按钮不出现、ROPC 拒绝,**本地登录不受影响**。启动时打 warning 日志。 ### 3.2 配置清单 | key | 敏感 | 公开可读 | 说明 | |---|---|---|---| | `rainid_enabled` | 否 | ✅ | `'1'`/'0',前端据此显示按钮 | | `rainid_client_id` | 否 | ❌ | 管理端可改 | | `rainid_client_secret` | ✅ | ❌ | 只写不读,双通道 | | `rainid_discovery_url` | 否 | ❌ | 默认 `https://rainid.rainnya.asia/oauth` | | `rainid_register_redirect` | 否 | ✅ | `'1'` 时前端整页跳 RainID 注册页 | --- ## 4. Admin 逃生通道(IdP 故障时的 break-glass) **原则:启用 RainID 后,绝不能出现"RainID 挂了管理员也进不去后台"的死局。** RainWeb 实践(`routes/auth.js`): ```js if (db.getSetting('rainid_enabled') === '1') { // 逃生通道:本地 admin 账号(有本地 bcrypt 密码)始终可用本地密码登录 const localUser = db.get('SELECT * FROM users WHERE username = ?', [username]); const isAdminEscape = localUser && localUser.role === 'admin' && !!localUser.password; if (isAdminEscape) { if (!bcrypt.compareSync(password, localUser.password)) { return res.status(401).json({ error: '用户名或密码错误' }); } const token = jwt.sign({ id: localUser.id, username: localUser.username, role: localUser.role }, SECRET, { expiresIn: '7d' }); return res.json({ token, username: localUser.username, role: localUser.role, ... }); } // 其余用户 → ROPC 转发 RainID const r = await rainidRopcLogin(username, password); ... } ``` 规则: 1. **本地 admin + 有本地密码** → 永远走本地 bcrypt,不经 RainID。即使 RainID 完全宕机、discovery 失败,admin 还能登录后台。 2. **影子账号(`rainid_user_id` 非空)给随机不可登录密码** → 在本地登录分支里显式禁止(`user.rainid_user_id && !user.password` 直接拒绝),防止影子账号被猜到密码本地登入。 3. 配套兜底:**首次接入前先建一个本地 admin 并记住密码**(RainWeb 播种的 `admin/admin123` 即为这个角色),再开 `rainid_enabled`。 --- ## 5. 踩坑实录(按严重程度排序) ### 5.1 [致命] discovery 传字符串 → 全部登录 502 - **现象**:启用 RainID 后,ROPC 登录和授权码登录全部失败;后端日志 `server must be an instance of URL`。 - **根因**:openid-client **v6 函数式 API** 的 `discovery()` 第一参数必须是 `new URL()` 实例;v5 类式 API 接受字符串,升级后行为变了,网上旧示例都是字符串。传字符串直接抛 TypeError。 - **修复**: ```js // ❌ v5 时代:openidClient.Issuer.discover('https://...') // ✅ v6:必须 new URL() configCache = await openidClient.discovery(new URL(discoveryUrl), clientId, clientSecret); ``` - **预防**:升级 openid-client 大版本后跑一遍登录全流程;读 changelog 的 breaking changes。 ### 5.2 [高] RainID 端点写成 http:// 被 openid-client 拒绝(RFC 9700) - **现象**:配置 `rainid_discovery_url=http://rainid...`(或反代配错协议)→ discovery 失败,登录 502。 - **根因**:openid-client 遵循 RFC 9700(OAuth 2.0 over HTTPS),**拒绝 http 端点**(localhost 除外)。RainWeb 错误文案就是为此写的:"Discovery 失败(检查端点是否为 https)"。 - **修复**:端点统一 `https://rainid.rainnya.asia/oauth`;**确认反代回源协议**——如果你的站点跑在反代后面,回调的 `req.protocol` 会算错,redirect_uri 变成 `http://` 导致 RainID 侧"回调未登记"。处理:优先用 `site_url` 设置拼基址,或用 `app.set('trust proxy', 1)` + 反代带 `X-Forwarded-Proto`。 - **预防**:`siteBase(req)` 优先读 `site_url` 设置,回退请求头,且反代必须正确转发协议。 ### 5.3 [高] id_token 无 email/name(`conformIdTokenClaims`)→ 必须 userinfo - **现象**:回调里 `tokens.claims()` 只有 `sub`,`email`/`name` 全是 undefined → 影子账号 email 为空、头像昵称丢。 - **根因**:RainID 的 access_token 是 opaque,`conformIdTokenClaims=true` 语义下 **id_token 只带 openid scope 的声明(sub)**,`profile`/`email` 数据在 userinfo 端点(OIDC Core 5.4)。拿 id_token 当唯一数据源是普遍错觉。 - **修复**: ```js const userinfo = await openidClient.fetchUserInfo(config, tokens.access_token, sub); // userinfo: { sub, name, preferred_username, picture, email, email_verified } ``` 并顺手用 `sub` 校验 userinfo 与 id_token 一致(防 userinfo 注入)。 ### 5.4 [高] 设置 key 漏 PUBLIC_KEYS → 前端功能静默失效 - **现象**:后台打开了 `rainid_register_redirect`,注册页却仍显示本地表单;无任何报错。 - **根因**:注册页靠 `settingsApi.getPublicSettings()` 读 `rainid_register_redirect`,但该 key 只加了 `ALL_KEYS` 没加 `PUBLIC_KEYS` → 前端拿到 `undefined`,静默走本地注册分支。**公共配置接口返回 undefined 不报错,是最难排查的一类 bug**。 - **修复**:`rainid_register_redirect` 加入 `PUBLIC_KEYS`。 - **预防**:凡是"前端根据设置决定显示逻辑"的 key,写完务必在 `/api/settings/public` 响应里确认存在;加新前端依赖的 key 时列 checklist。 ### 5.5 [中] end_session 卡确认页不回跳(RainID 侧 issue) - **现象**:登出跳 RainID 后停在"确认退出"页,点了确认不回跳你的站点。 - **根因**:`post_logout_redirect_uri` 未在 RainID Admin 登记,或登记的与请求的不完全一致(域名大小写/结尾斜杠/协议)。RainID 侧拒绝回跳时只渲染提示页(oidc-provider 行为),没有 303。 - **修复**:在 RainID Admin 的 `post_logout_redirect_uris` 精确登记你 `buildEndSessionUrl` 传的完整 URL(含路径 `/login.html`);检查域名规范化。 - **预防**:登出回跳地址做成常量与 `siteBase` 拼接,保证与登记一致;登记时复制请求里实际的 URL。 ### 5.6 [中] ROPC 未进"密码直连白名单" → invalid_grant 静默失败 - **现象**:密码明明正确,ROPC 登录始终 `400 invalid_grant`,文案统一"用户名/邮箱或密码错误"。 - **根因**:RainID 对 `password` grant 有 **Admin 站点设置 → 密码直连白名单**(`system_settings.client_password_grant.allowed`)。client 不在白名单 → `invalid_grant`,**文案与凭据失败完全一致**(防枚举设计)→ 让你误以为密码错了。 - **修复**:RainID Admin 把该 client_id 加进密码直连白名单(前置条件:client 必须机密 client、grant_types 含 `password`)。 - **预防**:接入文档 §5.1 四条前置逐条核对;ROPC 配好第一件事就是 curl 一次 token 端点确认不是白名单问题(见 §6)。 ### 5.7 [低] 测试端口避开 3k-4k(RainID 占 3001) - **现象**:本地开发时浏览器明明打开了 RainID 页面,但跳回登录总失败/串数据。 - **根因**:RainWeb 开发端口 3001 与 RainID 本地实例端口**冲突**,你在开自己的服务时把 RainID 顶掉了(或反之)。 - **修复**:自己的开发端口避开 3k-4k 区间(RainID 用 3001,vite 代理硬编码 3101);或至少确认本地测试时 RainID 实例可用。 - **预防**:多项目共存时先在项目文档里登记"占用的端口清单"。 --- ## 6. 快速排障指南:登录报错按什么顺序查 **自顶向下,每层 30 秒内能判**。RainWeb 线上排障就是这个顺序: ### L1 进程层 - 后端进程还活着吗?`node cli.js status` / `ps aux | grep server`。 - 重启过吗?**改了 discovery_url / client 配置要重启**(discovery 有进程级缓存)。 ### L2 依赖层 - `openid-client` 版本?v5→v6 是 breaking(§5.1)。 - Node 版本?openid-client v6 需要较新 Node(`require('esm')`),RainWeb 要求 Node ≥23 / 22+。 - 启动有没有 require 报错?`node -e "require('openid-client')"` 一把验证。 ### L3 配置层 - 后端日志有没有 `RAINID_NOT_CONFIGURED` / "client_id 或 client_secret 未配置"?→ 检查 `.env.json` 的 `rainid_client_secret`、后台 `rainid_client_id`。 - `rainid_enabled='1'` 且三项齐全?**fail-closed 下任一缺失按钮都不会出现**,先看前端到底有没有按钮。 - **管理端 GET /api/settings 里能看到 `rainid_client_secret` 吗?** 能看到就是泄露(正常不该返回)。 - 前端读到的公开设置对不对?`curl /api/settings/public` 确认 `rainid_enabled`/`rainid_register_redirect` 在不在响应里(§5.4)。 ### L4 网络层 - `curl -sI https://rainid.rainnya.asia/oauth/.well-known/openid-configuration` 通不通? - `curl -s https://rainid.rainnya.asia/oauth/.well-known/openid-configuration | head` 看是不是 json? - 本地 dev 端口有没有和 RainID 冲突(§5.7)?反代有没有把协议降级成 http(§5.2)? ### L5 协议层(最细,看 error code) 用 curl 直接打 token 端点,绕过前端看原始错误: ```bash # ROPC 直测(秒杀"白名单还是密码错"之争,§5.6) curl -s -u "$CLIENT_ID:$CLIENT_SECRET" \ -d "grant_type=password&username=test&password=xxx&scope=openid%20profile%20email" \ https://rainid.rainnya.asia/oauth/token ``` | 返回 | 判定 | 处理 | |---|---|---| | `400 invalid_grant` | ①凭据错 ②账号锁 ③2FA ④**白名单缺** | 换已确认密码重试;看 error_description 是否含"二次验证";查 Admin 白名单 | | `401 invalid_client` | client_id/secret 错或非机密 client | 核对 Admin client 配置 | | `400 invalid_scope` | scope 超白名单 | Admin 扩 scope 白名单 | | `429 rate_limited` | 限流 | 退避;也检查自己有没有重复请求循环 | | `400 redirect_uri not registered` | 回调未登记/拼接不一致 | 检查 `siteBase` 拼出的 redirect_uri 与登记是否逐字符一致 | | `400 post_logout_redirect_uri not registered` | 登出回跳未登记(§5.5) | Admin 登记完整 URL | | 502 / discovery 失败 | §5.1 / §5.2 | 查 URL 实例化与 https | ### 前端侧速查 - 白屏 + console "MIME type text/html" → 不是 OIDC 问题,是**构建产物缺失**(`npm run build` + 强刷)。 - 地址栏残留 `?oidc_ticket=...` → 前端没兑换成功,看 network 里 POST `/exchange` 的响应。 - `oidc_error=invalid_scope` → 前端按钮正常,后端 scope 配多了,回 L5。 --- ## 附:RainWeb 关键文件索引(抄作业对照) | 需求 | 文件 | |---|---| | discovery 缓存 / secret 双通道 / 影子账号 / ROPC | `lib/rainid.js` | | 授权码三路由 + ticket + 登出联动 | `routes/oidc.js` | | ROPC 接线 + admin 逃生通道 + 影子账号禁本地登入 | `routes/auth.js` | | PUBLIC_KEYS / ALL_KEYS / ALLOWED_SET | `routes/settings.js` | | 前端登录页(oidc_ticket/oidc_error 收尾) | `frontend/src/pages/Login.jsx` | | 前端注册页(注册托管跳转) | `frontend/src/pages/Register.jsx` | | 登出联动按钮 | `frontend/src/components/Layout.jsx` | | 后台设置表单(secret 置空不回显) | `frontend/src/admin/pages/Settings.jsx` | | 唯一索引迁移 | `db.js` migrations | --- *本文基于 RainWeb × RainID 线上接入实践整理。协议细节以 RainID `docs/OIDC对接文档.md` 为准。*