32 KiB
任意项目接入 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 回跳你的页面
核心心智模型:
- RainID 只认标准 OIDC/OAuth2,你的项目只需要一个
discovery_url。 - 身份归属 RainID,角色归属你——影子账号按
sub绑定,admin 等角色在你的本地库维护。 - access_token 是 opaque,id_token 只有
sub——想要 email/name 必须调 userinfo 端点。 - 你的会话是 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:装依赖
npm install openid-client # Node 版;浏览器端等价物见下
npm install jose # 仅当你绕过 SDK 手动验签时(一般不需要,SDK 全包)
- Node:
openid-clientv6(函数式 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())
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 语义一样):
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!)
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 过渡:
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 实测模式):
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)
// ① 按 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(唯一索引是并发兜底的地基,必须建):
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 拒绝文案)
// 复用一个 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 实测模式):
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
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 实测结构)
// ① 公开设置白名单:任何未登录用户可读(前端页面依赖)
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):
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);
...
}
规则:
- 本地 admin + 有本地密码 → 永远走本地 bcrypt,不经 RainID。即使 RainID 完全宕机、discovery 失败,admin 还能登录后台。
- 影子账号(
rainid_user_id非空)给随机不可登录密码 → 在本地登录分支里显式禁止(user.rainid_user_id && !user.password直接拒绝),防止影子账号被猜到密码本地登入。 - 配套兜底:首次接入前先建一个本地 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。 - 修复:
// ❌ 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 当唯一数据源是普遍错觉。 - 修复:
并顺手用
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 对
passwordgrant 有 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 端点,绕过前端看原始错误:
# 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 为准。