// RainID OIDC 授权码 + PKCE 接入(挂 /api/auth/oidc) // 流程:GET /login 发起 → RainID 登录/同意 → GET /callback 换 token 建影子账号 // → 一次性 ticket → 前端 POST /exchange 换本地 JWT(避免长 token 进 URL) // → GET /logout 登出联动(end_session) // 安全:state 一次性 + PKCE S256(SDK 自动验 id_token iss/aud/exp/签名);ticket 30s 一次性。 const express = require('express'); const crypto = require('crypto'); const jwt = require('jsonwebtoken'); const openidClient = require('openid-client'); // v6 函数式 API const { SECRET } = require('../middleware/auth'); const { getOidcSettings, getOidcConfig, siteBase, findOrCreateRainidUser, } = require('../lib/rainid'); const router = express.Router(); // ---- OIDC 流程状态:内存 Map + 短 TTL(模式同 routes/captcha.js 的 captchaStore)---- const OIDC_STATE_TTL = 10 * 60 * 1000; // state:发起登录 → 回调,10 分钟 const OIDC_TICKET_TTL = 30 * 1000; // ticket:回调 → 前端换 JWT,30 秒 const oidcStates = new Map(); // state -> { verifier, createdAt } const oidcTickets = new Map(); // ticket -> { jwt, username, role, email, email_verified, createdAt } setInterval(() => { const now = Date.now(); for (const [k, v] of oidcStates) if (now - v.createdAt > OIDC_STATE_TTL) oidcStates.delete(k); for (const [k, v] of oidcTickets) if (now - v.createdAt > OIDC_TICKET_TTL) oidcTickets.delete(k); }, 60000); const REDIRECT_PATH = '/api/auth/oidc/callback'; const LOGOUT_REDIRECT_PATH = '/login.html'; // RainID 登出回跳白名单需登记该地址 function frontLoginUrl(req, qs) { return siteBase(req) + '/login.html' + (qs || ''); } // ① 发起登录:PKCE + state → 302 RainID authorize router.get('/login', async (req, res) => { const s = getOidcSettings(); if (!s.enabled) return res.status(400).json({ error: '未启用 RainID 登录' }); try { 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) + REDIRECT_PATH, scope: 'openid profile email', code_challenge: codeChallenge, code_challenge_method: 'S256', state, }); res.redirect(url.href); } catch (err) { console.error('[RainID] login redirect error:', err.message); res.status(502).json({ error: 'RainID 服务暂不可用' }); } }); // ② 回调:state 校验 → 换 token → userinfo → 影子账号 → 发 JWT → 一次性 ticket → 302 前端 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); // state 一次性 let config; try { config = await getOidcConfig(); } catch { return res.status(500).send('RainID 客户端未配置'); } const base = siteBase(req); const currentUrl = new URL(req.originalUrl, base); try { // openid-client 自动验 id_token:签名(RS256/JWKS) + iss(discovery issuer) + aud(client_id) + exp const tokens = await openidClient.authorizationCodeGrant(config, currentUrl, { pkceCodeVerifier: stored.verifier, expectedState: state, idTokenExpected: true, }); const claims = tokens.claims(); const sub = claims && claims.sub; if (!sub) throw new Error('id_token 缺少 sub'); const userinfo = await openidClient.fetchUserInfo(config, tokens.access_token, sub); const user = findOrCreateRainidUser(sub, userinfo); 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 等)或换 token 失败 → 回前端登录页带错误码 const code = err && err.error; console.error('[RainID] callback error:', code || err.message); res.redirect(frontLoginUrl(req, '?oidc_error=' + encodeURIComponent(code || 'server_error'))); } }); // ③ 一次性 ticket 换本地 JWT(响应结构同 /api/auth/login,auth.js:91) 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, }); }); // ④ 登出联动:302 RainID end_session(post_logout_redirect_uri 需先在 RainID Admin 登记) 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, { post_logout_redirect_uri: siteBase(req) + LOGOUT_REDIRECT_PATH, }); res.redirect(url.href); } catch (err) { console.error('[RainID] logout error:', err.message); res.redirect('/'); } }); module.exports = router;