本文记录一次真实项目(Nuxt 4 管理后台 + HttpOnly Cookie 会话 + 同源 /api/admin/**)中的鉴权代理问题排查结论,并给出可复用的最佳实现:浏览器只带 Cookie,Nitro 在服务端转成 Authorization 再转发上游。
目标场景与错误设想
典型约束:
- 前端异步请求同源
/api/**,用credentials: 'include'带 HttpOnly Cookie。 - 上游 BFF / 业务 API 只认 HTTP Header(如
Authorization: Bearer …)。 - Token 不能进 JS,必须由 Nuxt 服务端完成 Cookie → Header。
最容易想到的「改动最小」方案:
浏览器 + Cookie
→ server/middleware 把 Cookie 写进 Authorization
→ nuxt.config routeRules.proxy 透明转发到上游
这条链路不可靠,不要用。 routeRules.proxy 是 Nitro 偏底层的透明代理,匹配后常直接转发,不会先跑完你的 server/middleware 再带着改过的头出去。
对照实验:X-Forwarded-For(2026-07-16)
用 cookie-header-bff(Nuxt 4.4)代理到 drizzle-vite(localhost:4010)复现:
server/middleware写入X-Forwarded-For: nuxt-bff/<ip>routeRules将/usersproxy 到http://127.0.0.1:4010/users- 上游 Hono 中间件打印该头
上游日志实际收到的是:
[x-forwarded-for] ::1
[x-forwarded-for] 127.0.0.1
[x-forwarded-for] direct-test/1.2.3.4 # 仅当 curl 直打 4010 并手写 Header
[x-forwarded-for] (missing)
从未出现 nuxt-bff/…。 说明请求要么根本没进 Nuxt server/middleware,要么 middleware 改的头未被 routeRules.proxy 带出。结论与 Cookie→Authorization 场景相同:需要改头时,必须用 server/api / server/routes handler 显式转发。
可靠做法是:去掉该路径的 routeRules.proxy,用 server/api / server/routes handler 显式读 Cookie(或写 XFF)、再转发。 前端几乎不用改,仍请求同源路径。
flowchart LR A[浏览器 Cookie] --> B[server/api catch-all] B --> C[Cookie → Authorization] C --> D[上游 BFF / API]
下文先对比三种机制,再给可直接落地的最佳实现骨架;中间穿插 oa-web 的排查过程。
三种机制各自干什么
| 机制 | 配置位置 | 谁在执行 | 典型用途 |
|---|---|---|---|
routeRules.proxy |
nuxt.config.ts |
Nitro 内置反向代理(偏底层) | 零代码把某路径原样转到外部 URL |
server/middleware/* |
server/middleware/ |
Nitro 服务端中间件 | 改请求头、日志、鉴权注入(前提:请求先进入 Nitro 常规管线) |
server/api/* |
server/api/ |
Nitro 文件路由(BFF / 自定义代理) | 登录 BFF、带逻辑的 API 转发、改响应体 |
另外还有 middleware/*.global.ts(app/middleware):这是 Vue 路由中间件,只管页面导航(例如 /console 跳 /login),不参与 /api/admin/** 的 HTTP 代理。
调试前的设计:routeRules.proxy
// nuxt.config.ts(问题版本,已移除)
routeRules: {
'/api/admin/**': {
proxy: `${NUXT_API_PROXY_TARGET}/api/admin/**`
}
}
浏览器请求过程(简化):
sequenceDiagram participant Browser participant Nuxt as Nuxt_Nitro participant Proxy as routeRules_proxy participant Backend as 真实后端 Browser->>Nuxt: GET /api/admin/menus/user + Cookie Note over Nuxt: 期望 server/middleware 注入 Authorization Nuxt->>Proxy: 命中 routeRules.proxy Proxy->>Backend: 透明转发(常不带 Authorization) Backend-->>Browser: 401 未登录或 Token 已过期
日志里证实的问题
- 浏览器请求里 有
oa_authCookie。 - 前端
useClientApi确实请求http://localhost:3000/api/admin/menus/user。 server/middleware/proxy-auth.ts对/api/admin/menus/user无法完成有效鉴权(从原始Cookie头用getAuthHeaderFromCookieHeader解析时常失败;且整条链路可能不经过你期望的中间件逻辑)。- 后端返回:
{"code":401,"message":"未登录或 Token 已过期"}。
结论:routeRules.proxy 是透明转发,不会走 server/api/admin/[...path].ts,也不能指望 server/middleware 在代理前稳定地把 Cookie 转成 Authorization。
登录类请求:一直用 server/api
sequenceDiagram participant Browser participant BFF as server_api_auth participant ServerApi as createServerApi participant Backend as 真实后端 Browser->>BFF: POST /api/auth/login BFF->>ServerApi: 调 dingtalk/login 等 ServerApi->>Backend: 由 BFF 处理鉴权 Backend-->>BFF: access_token + user BFF->>Browser: Set-Cookie oa_auth + 只返回 user
server/api/auth/*:必须手写 BFF(写 HttpOnly Cookie、裁剪响应,不向浏览器暴露access_token)。createServerApi(event):直连NUXT_API_PROXY_TARGET,在beforeRequest里注入Authorization。- 这条链路 不依赖
routeRules.proxy,所以登录一直能工作。
调试后的业务 API:server/api/admin/[...path].ts
去掉 routeRules 的 /api/admin/** 代理后,由 catch-all 路由 接管:
sequenceDiagram
participant Browser
participant Handler as server_api_admin_path
participant Ky as createAdminProxyClient
participant Backend as 真实后端
Browser->>Handler: GET /api/admin/menus/user + Cookie
Handler->>Handler: getAuthHeaderFromEvent(event)
Note over Handler: Cookie 解析 + Bearer 规范化
Handler->>Ky: ky.get('menus/user')
Ky->>Backend: Authorization: Bearer xxx
Backend-->>Handler: 200 + JSON
Handler-->>Browser: 原样透传 body/status
关键实现点:
shared/auth-session.ts:Cookie 序列化/解析;将后端返回的小写bearer规范为Bearer;避免setCookie与手动encodeURIComponent双重编码。server/utils/createAdminProxyClient.ts:与 BFF 相同的 ky + 鉴权注入,但不抛业务错误(透传响应)。server/api/admin/[...path].ts:按 HTTP method 转发 body/query,设置响应status/content-type。
server/middleware/proxy-auth.ts 可保留作补充(给仍走 event.node.req 的 /api/admin 请求写 authorization),但 可靠路径是 handler + createAdminProxyClient。
最佳实现:Cookie → Header 的同源 BFF
把上述结论收成一套可复用分层。原则:会话改造只发生在 server/api;routeRules 只管页面渲染策略,不再代理业务 API。
分层
| 层 | 路径 / 文件 | 职责 |
|---|---|---|
| 登录 BFF | server/api/auth/* |
调上游换 token,写 HttpOnly Cookie,响应里只回用户信息 |
| 业务代理 | server/api/admin/[...path].ts |
读 Cookie → Authorization,透传 method / query / body / status |
| 会话工具 | shared/auth-session.ts 等 |
Cookie 读写、Bearer 规范化(避免双重 encodeURIComponent) |
| 前端 | useClientApi 等 |
只打同源 /api/...,credentials: 'include' |
| 配置 | nuxt.config.ts |
不要 对 /api/admin/** 配 routeRules.proxy |
请求顺序(业务 API):
server/middleware(可选:日志等,不依赖它做鉴权转换)
→ server/api/admin/[...path].ts
→ 从 event 读 Cookie
→ 设置 Authorization
→ fetch / ky 打到 NUXT_API_PROXY_TARGET
→ 原样回传 status + body 给浏览器
注意:server/api 与 server/routes 是 Nitro 路由表里的两类 handler,同一请求只会命中一个;业务代理放在 server/api 即可(自动带 /api 前缀)。app/middleware 只管页面导航,不参与 Cookie→Header。
1. 配置:去掉业务路径的 proxy
// nuxt.config.ts — 不要这样写业务代理
// routeRules: {
// '/api/admin/**': { proxy: `${process.env.NUXT_API_PROXY_TARGET}/api/admin/**` }
// }
export default defineNuxtConfig({
runtimeConfig: {
apiProxyTarget: '', // NUXT_API_PROXY_TARGET
public: {
apiBaseUrl: '/api/admin/', // NUXT_PUBLIC_API_BASE_URL
},
},
// routeRules 仍可用于页面:prerender、ssr: false 等
})
2. 业务 catch-all:在 handler 里转换并转发
// server/api/admin/[...path].ts
export default defineEventHandler(async (event) => {
const path = (event.context.params?.path as string | string[] | undefined)
const targetPath = Array.isArray(path) ? path.join('/') : (path ?? '')
const auth = getAuthHeaderFromEvent(event) // 从 Cookie 解析并规范为 Bearer …
const config = useRuntimeConfig(event)
const base = String(config.apiProxyTarget).replace(/\/$/, '')
const method = getMethod(event)
const query = getQuery(event)
const url = `${base}/api/admin/${targetPath}`
const headers: Record<string, string> = {}
if (auth) headers.authorization = auth
const contentType = getHeader(event, 'content-type')
if (contentType) headers['content-type'] = contentType
const hasBody = !['GET', 'HEAD'].includes(method)
const body = hasBody ? await readRawBody(event) : undefined
const upstream = await $fetch.raw(url, {
method,
query,
headers,
body,
// 业务错误码也要透传,不要在这里 throw
ignoreResponseError: true,
})
setResponseStatus(event, upstream.status)
const ct = upstream.headers.get('content-type')
if (ct) setResponseHeader(event, 'content-type', ct)
return upstream._data
})
要点:
- 在 handler(或它调用的 proxy client)里完成 Cookie→Header,而不是指望
routeRules.proxy之前的 middleware。 - 透传 status / content-type / body,让前端仍按上游约定处理 401。
- 登录、登出继续走独立的
server/api/auth/*(写/清 Cookie),与业务代理分开。
3. 前端:同源 + Cookie,零感知 Header
// 浏览器侧:只带 Cookie,从不碰 Authorization
await $fetch('/api/admin/menus/user', { credentials: 'include' })
CSR 拉取菜单等数据时注意:相对 URL 的 ky/$fetch 在 SSR 无 origin 时可能报错,管理台菜单类请求更适合放在 onMounted / 客户端。
4. 明确不要做的事
| 做法 | 结果 |
|---|---|
server/middleware + routeRules.proxy |
Cookie→Header 不可靠 |
同时开 routeRules.proxy 与 server/api/admin/[...path] |
匹配抢跑、难排查 |
在 app/middleware 里写 Header 给 API |
只管页面导航,管不到 /api/** HTTP |
页面侧:app/middleware + CSR
flowchart LR A[访问 /console/*] --> B[middleware/console-auth.global.ts] B -->|CSR| C[me 校验会话] B -->|SSR| D[只检查 oa_auth Cookie 是否存在] C --> E[console layout onMounted] E --> F[getCurrentUserMenus via useClientApi] F --> G[/api/admin/menus/user] G --> H[server/api/admin handler]
注意:
- 菜单拉取应在客户端(
onMounted/fetchMenus),避免 SSR 里 ky 对相对 URL 报Failed to parse URL from /api/admin/...。 useClientApi:credentials: 'include'带 Cookie;业务 401 时调/api/auth/logout再跳/login。
对比:该用哪种方式
| 需求 | routeRules.proxy |
server/middleware |
server/api |
|---|---|---|---|
| 零代码转发 | 适合 | 单独不够 | 可选 |
| Cookie → Authorization | 本次不可靠 | 仅当请求走常规 Nitro 管线 | 推荐 |
| 改登录响应 / 写 Cookie | 否 | 否 | server/api/auth |
| 统一透传 body/status | 否 | 否 | [...path].ts |
与 useClientApi 同源 /api/admin |
URL 可匹配 | 依赖是否进管线 | 明确命中 |
不要两套代理同时开
若同时保留:
routeRules: { '/api/admin/**': { proxy: '...' } }
和:
server/api/admin/[...path].ts
可能出现谁先匹配不确定、行为难排查。业务 API 应 只保留 handler 方案。
若坚持只用 routeRules.proxy
需要另找能在代理前注入鉴权的方式;按本次调试,仅靠 server/middleware + routeRules.proxy 不可靠。请直接采用上文的 最佳实现:server/api/auth/* + server/api/admin/[...path].ts,NUXT_API_PROXY_TARGET 只给 handler / proxy client 用,不要写进业务路径的 routeRules.proxy。
一句话结论
- 场景:浏览器 Cookie 会话 + 上游 Header 鉴权 → 必须在 Nitro handler 里做 Cookie→Header,再转发。
routeRules.proxy:适合纯静态转发;不适合会话改造;与server/middleware组合不可靠。server/middleware:可做日志等横切逻辑;不能假设它一定跑在 proxy 之前并改写上游请求头。server/api:登录 BFF + 业务 catch-all 代理是正确分层;前端只打同源 URL + Cookie。routeRules:继续管页面级规则(prerender、ssr: false等),不要再和业务/api/admin/**的proxy混用。
相关环境变量(oa-web)
| 变量 | 作用 |
|---|---|
NUXT_PUBLIC_API_BASE_URL |
前端请求前缀,一般为 /api/admin/ |
NUXT_API_PROXY_TARGET |
服务端转发真实后端根地址 |
浏览器只访问同源 /api/admin/**;Nitro 在 server/api handler 里把 Cookie 转为 Authorization 后转发到真实后端。