背景痛点:第一次集成就“白屏”
很多初学者在本地跑通官方示例后,兴冲冲地把 ChatGPT 登录按钮搬到自己的页面,结果扫码授权一结束,浏览器只剩一张“白板”。刷新没用,控制台一堆红色 CORS 报错,甚至看不到任何 HTML 节点。常见诱因如下:
- 前端直接请求
https://api.openai.com/v1/chat/completions,被浏览器拦截。 - OAuth 回调地址填成
http://localhost:3000,而申请时写的是https://prod.xxx.com。 - 使用 0.x 版社区 SDK,默认携带的
scope字段与最新文档不一致,返回 403。 - 把
access_token存进localStorage,后续请求却带不上SameSite=strictCookie,后端鉴权失败重定向到空白页。
技术分析:前端代理 vs 后端中转
浏览器安全策略决定“前端直调”几乎走不通。两条主流路线对比如下:
| 方案 | 优点 | 缺点 | |---|---|---|---| | 前端代理(Vite/webpack devServer proxy) | 零部署成本,开发阶段最快 | 仅解决开发环境 CORS,上线后仍需后端 | | 后端中转(Node 中间层) | 统一鉴权、隐藏密钥、可缓存、可重试 | 多一次网络 hop,需要额外服务器 |
CORS 与 SameSite 的影响:
- 预检请求(OPTIONS)失败 => 浏览器直接拦截,页面不会重绘。
SameSite=lax是 Chrome 默认,跨域 POST 不携带 Cookie,导致 401。- 重定向响应头
Location若为跨域,浏览器不会把#fragment传给前端,OAuth 授权码无法截取。
实现方案:最小可运行样板
下面给出“后端中转”完整示例,技术栈:Node 18 + Express + dotenv,前端用原生 fetch,无框架依赖。
1. 目录结构
chatgpt-proxy/ ├─ .env ├─ server.js ├─ public/ │ ├─ index.html │ └─ app.js2. 环境变量 .env
OPENAI_CLIENT_ID=xxx OPENAI_CLIENT_SECRET=xxx REDIRECT_URI=https://yourdomain.com/oauth/callback SESSION_SECRET=random_32chars_string3. 后端 server.js
import express from 'cors'; import session from 'express-session'; import fetch from 'node-fetch'; import 'dotenv/config'; const app = express(); app.use(cors({ origin: 'https://yourdomain.com', credentials: true })); app.use(session({ secret: process.env.SESSION_SECRET, cookie: { sameSite: 'lax' }, resave: false, saveUninitialized: false })); // 步骤1:拼装授权 URL,前端点击跳转 app.get('/oauth/url', (_req, res) => { const url = new URL('https://auth0.openai.com/authorize'); url.searchParams.set('response_type', 'code'); url.searchParams.set('client_id', process.env.OPENAI_CLIENT_ID); url.searchParams.set('redirect_uri', process.env.REDIRECT_URI); url.searchParams.set('scope', 'openid api'); // 必须显式声明 res.json({ url: url.toString() }); }); // 步骤2:回调里换 token app.get('/oauth/callback', async (req, res) => { const { code } = req.query; if (!code) return res.status(400).send('Missing code'); const tokenResp = await fetch('https://auth0.openai.com/oauth/token', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ grant_type: 'authorization_code', client_id: process.env.OPENAI_CLIENT_ID, client_secret: process.env.OPENAI_CLIENT_SECRET, redirect_uri: process.env.REDIRECT_URI, code }) }); if (!tokenResp.ok) return res.status(401).send('Token exchange failed'); const { access_token } = await tokenResp.json(); req.session.token = access_token; // 存入服务端会话 res.redirect('/'); // 回到首页,不再空白 }); // 步骤3:代理聊天请求,统一加 Authorization 头 app.post('/v1/chat/completions', async (req, res) { if (!req.session.token) return res.status(401).json({ error: 'No token' }); const body = await new Promise(resolve => { let data = ''; req.on('data', chunk => data += chunk); req.on('end', () => resolve(JSON.parse(data))); }); const apiResp = await fetch('https://api.openai.com/v1/chat/completions', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${req.session.token}` }, body: JSON.stringify(body) }); if (apiResp.status === 401) { delete req.session.token; return res.status(401).json({ error: 'Token expired' }); } if (apiResp.status === 403) { return res.status(403).json({ error: 'Scope insufficient' }); } const result = await apiResp.json(); res.json(result); }); app.listen(3000, () => console.log('Proxy listening on :3000'));4. 前端 public/app.js
const chat = async (prompt) => { const resp = await fetch('/v1/chat/completions', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ model: 'gpt-3.5-turbo', messages: [{ role: 'user', content: prompt }] }), credentials: 'include' // 关键:带 Cookie }); if (resp.status === 401) { location.href = '/oauth/url'; // 重新授权 return; } const json = await resp.json(); return json.choices[0].message.content; };避坑指南:三分钟定位配置错误
redirect_uri不一致
授权 URL 与申请时填的地址必须字节级相同,包括协议与尾斜杠。scope缺失
旧版 SDK 默认仅openid,调用 ChatGPT 需要显式追加api,否则返回 403。Cookie 未写入
本地测试把域名写成.localhost,Chrome 会拒绝写入SameSite=laxCookie,导致后续 401。
Chrome DevTools 技巧:
- Network 面板勾选 Preserve log,可捕获重定向前的 302 响应。
- 过滤
oauth,快速查看授权链路是否 4xx。 - Application面板检查 Cookie 是否带
HttpOnly与SameSite属性。
进阶建议:上线前必须做的两件事
日志诊断
在server.js所有 fetch 前后打印status + text,配合x-request-id返回给前端,出现 401/403 时可在服务端直接定位是 token 过期还是 scope 不足。重试机制
生产环境用p-retry或async-retry封装调用,遇到 429/5xx 自动退避,最大重试 3 次,避免用户面对空白页。
开放讨论:在多租户 SaaS 场景下,如何安全地隔离每个租户的access_token并实现集中刷新?期待在评论区看到大家的实践思路。
如果希望亲手搭建一个“能听会说”的实时语音伙伴,不妨体验从0打造个人豆包实时通话AI动手实验,一步步把 ASR、LLM、TTS 串成低延迟对话闭环,相信会对理解 OAuth 与代理链路有更立体的感受。