// Node.js 22+, no npm dependencies. Run behind HTTPS; see ../oauth-login.html. import { createServer } from 'node:http'; import { randomBytes, createHash } from 'node:crypto'; import { readFile } from 'node:fs/promises'; import { pathToFileURL } from 'node:url'; const random = () => randomBytes(32).toString('base64url'); const fail = (status, message) => Object.assign(new Error(message), { status }); const cookieName = '__Host-jianliao_demo'; export function createDemoServer(config, upstreamFetch = fetch) { const { issuer, appOrigin, hostWebOrigin, clientId, clientSecret = '' } = config; for (const value of [issuer, appOrigin, hostWebOrigin]) { if (new URL(value).protocol !== 'https:') throw new Error('配置地址必须为 HTTPS'); } if (!issuer.endsWith('/v1') || new URL(issuer).search || new URL(issuer).hash || new URL(appOrigin).origin !== appOrigin || new URL(hostWebOrigin).origin !== hostWebOrigin || appOrigin === hostWebOrigin || appOrigin === new URL(issuer).origin || !clientId) { throw new Error('检查 issuer、跨源 appOrigin、hostWebOrigin 和 clientId'); } const redirectUri = `${appOrigin}/oauth/callback`; const sessions = new Map(); // Demo only: replace with a shared, expiring session store. const cleanup = setInterval(() => { for (const [key, value] of sessions) if (value.expires <= Date.now()) sessions.delete(key); }, 60_000).unref(); function setSession(res, data, seconds, oldId) { if (oldId) sessions.delete(oldId); const id = random(); sessions.set(id, { ...data, expires: Date.now() + seconds * 1000 }); res.setHeader('Set-Cookie', `${cookieName}=${id}; Path=/; HttpOnly; Secure; SameSite=None; Max-Age=${seconds}`); return id; } function json(res, body, status = 200) { res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' }); res.end(JSON.stringify(body)); } async function bodyOf(req) { if (!req.headers['content-type']?.startsWith('application/json')) throw fail(415, '需要 JSON'); const chunks = []; let size = 0; for await (const chunk of req) { size += chunk.length; if (size > 16_384) throw fail(413, '请求过大'); chunks.push(chunk); } try { return JSON.parse(Buffer.concat(chunks).toString()); } catch { throw fail(400, 'JSON 无效'); } } async function api(path, options = {}) { const response = await upstreamFetch(`${issuer}/oauth/${path}`, { ...options, redirect: 'error', signal: AbortSignal.timeout(10_000), }); const data = await response.json(); if (!response.ok) { // Expose only the upstream error code; never log codes, tickets or credentials. const code = data.error || data.code; throw fail(502, typeof code === 'string' ? code : 'OAuth 请求失败'); } return data; } async function exchange(code, pending) { if (typeof code !== 'string' || !code) throw fail(400, '缺少 code'); const form = new URLSearchParams({ grant_type: 'authorization_code', code, client_id: clientId, redirect_uri: redirectUri, code_verifier: pending.verifier }); if (clientSecret) form.set('client_secret', clientSecret); const tokens = await api('token', { method: 'POST', body: form }); if (typeof tokens.access_token !== 'string' || !tokens.access_token) throw fail(502, '缺少 access_token'); // Use authenticated userinfo as identity. Do not decode an unverified id_token for login. // This login-only demo does not retain OAuth tokens or implement refresh. return api('userinfo', { headers: { Authorization: `Bearer ${tokens.access_token}` } }); } function login(res, user, oldId) { if (typeof user.sub !== 'string' || !user.sub) throw fail(502, '缺少 sub'); const identity = { issuer, sub: user.sub, name: user.name, picture: user.picture }; // Key local accounts by (issuer, sub), never by name/email. setSession(res, { user: identity }, 3600, oldId); return identity; } const server = createServer(async (req, res) => { res.setHeader('Cache-Control', 'no-store'); res.setHeader('Referrer-Policy', 'no-referrer'); res.setHeader('X-Content-Type-Options', 'nosniff'); res.setHeader('Content-Security-Policy', `frame-ancestors 'self' ${hostWebOrigin}; base-uri 'none'; object-src 'none'`); try { const url = new URL(req.url, appOrigin); const id = req.headers.cookie?.split(';').map(v => v.trim()).find(v => v.startsWith(`${cookieName}=`))?.slice(cookieName.length + 1); const record = sessions.get(id); const session = record?.expires > Date.now() ? record : undefined; if (req.method === 'POST' && req.headers.origin !== appOrigin) throw fail(403, 'Origin 不匹配'); if (req.method === 'GET' && url.pathname === '/') { res.setHeader('Content-Type', 'text/html; charset=utf-8'); res.end(await readFile(new URL('./index.html', import.meta.url))); } else if (req.method === 'GET' && url.pathname === '/api/config') { json(res, { issuer, hostWebOrigin, clientId, redirectUri, clientType: clientSecret ? 'confidential' : 'public' }); } else if (req.method === 'POST' && url.pathname === '/api/start') { const verifier = random(), state = random(), csrf = random(); const params = { response_type: 'code', client_id: clientId, redirect_uri: redirectUri, scope: 'openid profile', state, code_challenge: createHash('sha256').update(verifier).digest('base64url'), code_challenge_method: 'S256' }; setSession(res, { pending: { verifier, state, csrf } }, 300, id); json(res, { csrf, params, authorizeUrl: `${issuer}/oauth/authorize?${new URLSearchParams(params)}` }); } else if (req.method === 'POST' && ['/api/code', '/api/jump'].includes(url.pathname)) { const body = await bodyOf(req); const pending = session?.pending; if (!pending || body?.csrf !== pending.csrf) throw fail(403, '登录事务失效或 Cookie 被拦截,请重新开始'); if (url.pathname === '/api/code' && body.state !== pending.state) throw fail(403, 'state 不匹配'); // Consume before awaiting the upstream request: one local transaction, one exchange. delete session.pending; let user; if (url.pathname === '/api/jump') { if (!clientSecret || typeof body.token !== 'string' || !body.token) throw fail(400, '跳转票需要 confidential 应用'); user = await api('jump/redeem', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token: body.token, client_id: clientId, client_secret: clientSecret }) }); if (user.app_id !== clientId || !Number.isFinite(user.exp) || user.exp * 1000 <= Date.now()) throw fail(502, '跳转票响应无效'); } else { user = await exchange(body.code, pending); } json(res, login(res, user, id)); } else if (req.method === 'GET' && url.pathname === '/oauth/callback') { const pending = session?.pending; if (!pending || url.searchParams.get('state') !== pending.state) throw fail(403, 'state 不匹配或登录事务过期'); delete session.pending; if (url.searchParams.has('error')) throw fail(400, url.searchParams.get('error')); login(res, await exchange(url.searchParams.get('code'), pending), id); res.writeHead(303, { Location: '/' }); res.end(); } else if (req.method === 'GET' && url.pathname === '/api/me') { json(res, session?.user || { error: '尚未登录' }, session?.user ? 200 : 401); } else { json(res, { error: 'Not found' }, 404); } } catch (error) { json(res, { error: error.status ? error.message : '请求失败,请重新开始登录' }, error.status || 500); } }); server.on('close', () => clearInterval(cleanup)); return server; } if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { const server = createDemoServer({ issuer: process.env.OAUTH_ISSUER, appOrigin: process.env.APP_ORIGIN, hostWebOrigin: process.env.HOST_WEB_ORIGIN, clientId: process.env.OAUTH_CLIENT_ID, clientSecret: process.env.OAUTH_CLIENT_SECRET, }); server.listen(Number(process.env.PORT || 3000), '127.0.0.1', () => { console.log('OAuth demo listening on loopback; open APP_ORIGIN through your HTTPS proxy.'); }); }