Files
rainblogweb/docs/rainid-integration/02-implementation-practice.md
T

630 lines
32 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 任意项目接入 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 是 opaqueid_token 只有 `sub`**——想要 email/name 必须调 userinfo 端点。
4. **你的会话是 JWT/session(本地),RainID 的会话是它的签名 cookie,两者不共享**。登出联动靠 end_session。
---
## 1. Step-by-step 接入步骤
### Step 1RainID 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+state302 到 RainID authorize |
| GET | `/callback` | 验 state → 换 token → userinfo → 影子账号 → 发一次性 ticket → 302 回前端 |
| POST | `/exchange` | 前端用 ticket 换本地 JWT30s 一次性) |
| 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,取用即删
你的框架若没有 sessionRainWeb 是无状态 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 + 验签 + userinfoid_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) {
// 条件 UPDATEWHERE 再带 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, ... });
}
// ② 其余用户:转发 RainIDROPC
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 9700OAuth 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-4kRainID 占 3001
- **现象**:本地开发时浏览器明明打开了 RainID 页面,但跳回登录总失败/串数据。
- **根因**RainWeb 开发端口 3001 与 RainID 本地实例端口**冲突**,你在开自己的服务时把 RainID 顶掉了(或反之)。
- **修复**:自己的开发端口避开 3k-4k 区间(RainID 用 3001vite 代理硬编码 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` 为准。*