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

32 KiB
Raw Blame History

任意项目接入 RainID(OIDC)实操指南

视角:实现 / 落地。不是协议科普,而是"从 0 到 1 动手做完 + 线上能跑"的操作手册。 素材来源:RainWeb × RainID 真实接入(lib/rainid.jsroutes/oidc.jsroutes/auth.jsroutes/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:装依赖

npm install openid-client        # Node 版;浏览器端等价物见下
npm install jose                 # 仅当你绕过 SDK 手动验签时(一般不需要,SDK 全包)
  • Nodeopenid-client v6(函数式 API,本项目用的就是它)。
  • PythonauthlibGocoreos/go-oidcJavaspring-security-oauth2-client。都是标准 OIDC,照着下面模式映射即可。
  • 浏览器纯前端(SPA:不要 oidc-client-ts 全放前端!SPA 必须 BFF(后端代理),禁止前端持有 refresh_token。

Step 3:配置项(先想清楚再写代码)

分两档(详见第 3 章):

  • 非敏感(可后台热改、可读)rainid_enabledrainid_client_idrainid_discovery_urlrainid_register_redirect
  • 机密(只写不读)rainid_client_secret——优先 .env.jsongitignored,回退后台设置(可写、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()

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_verifierstate 不能丢。内存 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 + 验签 + userinfoid_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) {
      // 条件 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(唯一索引是并发兜底的地基,必须建):

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 来接管本地账号。
  • 绑定用条件 UPDATEWHERE 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, ... });
    }
    // ② 其余用户:转发 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

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.jsongitignored,server.js 启动时注入环境变量 RAINID_CLIENT_SECRET,运行时还有 .env.json 直读和后台设置两档回退(见 lib/rainid.js getClientSecret())。
  • 后台设置通道可写不可读ALLOWED_SET 里有它(能写),ALL_KEYS 里没有它(GET /api/settings 不返回),浏览器和 API 都读不到明文。
  • fail-closedrainid_enabled='1'client_id/client_secret 任一缺失 → enabled=falseRainID 按钮不出现、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);
  ...
}

规则:

  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 函数式 APIdiscovery() 第一参数必须是 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 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/nameconformIdTokenClaims)→ 必须 userinfo

  • 现象:回调里 tokens.claims() 只有 subemail/name 全是 undefined → 影子账号 email 为空、头像昵称丢。
  • 根因RainID 的 access_token 是 opaqueconformIdTokenClaims=true 语义下 id_token 只带 openid scope 的声明(subprofile/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 对 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 需要较新 Noderequire('esm')),RainWeb 要求 Node ≥23 / 22+。
  • 启动有没有 require 报错?node -e "require('openid-client')" 一把验证。

L3 配置层

  • 后端日志有没有 RAINID_NOT_CONFIGURED / "client_id 或 client_secret 未配置"?→ 检查 .env.jsonrainid_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 为准。