主题
proxy 鉴权守卫与 API 反代
本章目标:
- 看懂
src/proxy.ts(Next.js 16 重命名自 middleware.ts)三个职责:/workspace/*未登录守卫、/api/langgraph/*与/api/memory/*的 JWT 解码 + header 注入- 弄清
next.config.js的rewrites()怎么把/api/*同源反代到 acdm-backend / deer-flow gateway / LangGraph / ERP,以及为什么必须同源- 理清环境变量(
NEXT_PUBLIC_*/*_INTERNAL_*_BASE_URL/NEXT_PUBLIC_BASE_PATH)如何在 dev 与 prod 切换 endpoint,以及 dev 直连 vs prod 走 Caddy 的边界
TL;DR
前端这一层做两件事:守卫和反代。守卫由 src/proxy.ts(deer-flow/frontend/src/proxy.ts:73)负责——访问 /workspace/* 没 acdm_session cookie 就 302 弹去登录;反代由 next.config.js 的 rewrites()(deer-flow/frontend/next.config.js:41)负责——把浏览器侧所有 /api/* 请求同源转发到内网服务,这样 HttpOnly cookie 才能随请求带上。dev 没有 Caddy,proxy.ts 额外补一个动作:从 acdm_session JWT 解出 sub/is_admin,注入 X-Auth-User-Id/X-Auth-User-Role 头给 LangGraph 和 memory 请求(prod 这步由 Caddy forward_auth 做)。整条链路 dev 与 prod 走同一份代码,无后门。
Overview(为什么前端要管鉴权和反代)
这一层回答两个问题:未登录的人怎么被挡在工作区外? 和 浏览器里的 HttpOnly cookie 怎么跟着 API 请求一起送到后端?
先看守卫。整套系统用 Keycloak SSO + acdm-backend 自签 JWT(写进 acdm_session HttpOnly cookie)。如果只靠后端 API 返回 401、前端再跳登录,会有一个尴尬的中间态:用户直接敲 /workspace/projects 这个页面 URL,Next.js 会先把整个 React 应用渲染出来,然后页面里某个 fetch 才 401,用户先看到一个空壳/报错页才被弹走。体验差,且把内部页面结构暴露给了未登录者。答案是在请求进入页面渲染之前就拦——Next.js 的 middleware(16 起改名 proxy)正是这个拦截点,它在 edge 层先看 cookie 在不在,不在直接 302,页面代码根本不执行。
再看反代,这是更隐蔽但更关键的设计约束。鉴权 cookie 是 HttpOnly + SameSite=Lax(acdm-backend/app/auth/router.py:285-292)。HttpOnly 意味着 JS 读不到它,只能靠浏览器在同源请求时自动带上;SameSite=Lax 意味着跨站请求不带它。如果前端直接 fetch("http://172.20.21.28:8002/api/..."),这是跨源请求,cookie 带不上,鉴权必然失败。所以所有 API 必须走前端同源路径(/api/xxx,域名跟页面一样),再由 Next.js 服务端 rewrite 服务端到服务端地转发到内网真实服务。浏览器只看到同源,cookie 正常带上;转发是 server-side 的,不受 CORS/cookie 同源限制。
Architecture
前端这一层由三个文件协同。proxy.ts 跑在 Next.js edge runtime(请求最前端),next.config.js 的 rewrites() 在 Next.js server 层把同源 /api/* 转发到内网,config/index.ts + env.js 决定前端代码构造 URL 时用相对路径还是绝对 URL。
| 组件 | 职责 | 入口文件 | Source |
|---|---|---|---|
proxy() | edge 守卫:/workspace/* 未登录 302;auth header 注入 | deer-flow/frontend/src/proxy.ts | deer-flow/frontend/src/proxy.ts:73-90 |
config.matcher | 声明 proxy 只对哪些路径生效(性能:不跑全站) | deer-flow/frontend/src/proxy.ts | deer-flow/frontend/src/proxy.ts:92-99 |
parseJwtPayload() | base64url 解 JWT payload(不验签) | deer-flow/frontend/src/proxy.ts | deer-flow/frontend/src/proxy.ts:40-55 |
injectAuthHeaders() | 从 cookie 解 sub/is_admin → 注入 X-Auth-User-* | deer-flow/frontend/src/proxy.ts | deer-flow/frontend/src/proxy.ts:57-71 |
rewrites() | 同源 /api/* → 内网服务 server-side 反代 | deer-flow/frontend/next.config.js | deer-flow/frontend/next.config.js:41-122 |
getInternalServiceURL() | 解析 *_INTERNAL_*_BASE_URL env,空则用 dev fallback | deer-flow/frontend/next.config.js | deer-flow/frontend/next.config.js:7-12 |
getBackendBaseURL() / getLangGraphBaseURL() | 前端代码构造 URL:默认返回 ""/同源,让请求落回 rewrite | deer-flow/frontend/src/core/config/index.ts | deer-flow/frontend/src/core/config/index.ts:11-40 |
create_access_token() | acdm-backend 签的 HS256 JWT,proxy.ts 解的就是它 | acdm-backend/app/auth/keycloak.py | acdm-backend/app/auth/keycloak.py:122-138 |
Components / Subsystems
proxy.ts:edge 守卫与 header 注入
职责:在页面渲染前拦请求,做三件事(文件头注释 deer-flow/frontend/src/proxy.ts:1-28 写得很清楚)。
关键常量(deer-flow/frontend/src/proxy.ts:31-32):
PROTECTED_PATHS = ["/workspace"]:这些路径前缀要登录态AUTH_INJECT_PREFIXES = ["/api/langgraph", "/api/memory"]:这两类请求要注入 auth header
职责 1 — /workspace/* 未登录守卫(deer-flow/frontend/src/proxy.ts:81-89):
请求 path 命中 PROTECTED_PATHS 且 acdm_session cookie 不存在 → NextResponse.redirect 到 /api/acdm/auth/login。这个 login 端点由 acdm-backend 实现(acdm-backend/app/auth/router.py:136),它再 307 接力到 Keycloak 授权页。守卫在 edge 层完成,被保护页面的 React 代码完全不执行。
职责 2 — /api/langgraph/* auth header 注入(deer-flow/frontend/src/proxy.ts:11-18 注释 + :57-71 实现):
这是 dev 与 prod 的关键差异点。生产环境 Caddy 的 forward_auth 会先调 acdm-backend /auth/verify,把 X-Auth-User-Id/X-Auth-User-Role 注入给 LangGraph 容器(deploy/prod/Caddyfile.keycloak-v1:86-96)。但 dev 默认不起 Caddy——Next.js rewrites 直连 127.0.0.1:2024。若不补这步,LangGraph 侧 acdm_auth.authenticate 因缺 header 直接 401,dev 聊天完全坏。所以 proxy.ts 在 dev/prod 同路径下都从 cookie 解 JWT、注 header。
职责 3 — /api/memory/* auth header 注入(deer-flow/frontend/src/proxy.ts:19-27 注释):
UI memory 标签页走 Next.js SSR rewrite 直连 deer-flow gateway:8001(见下文 rewrites)。同样绕过 Caddy forward_auth,gateway 缺 X-Auth-User-Id 头时 handler 拿不到 scope,memory 永远返回根目录的空数据。proxy.ts 用与 langgraph 同款逻辑注入,gateway 侧 memory.py 读 header 后 set_scope 再取数据。
实现要点 — 为什么"不验签"是对的:
parseJwtPayload()(deer-flow/frontend/src/proxy.ts:40-55)只 base64url 解 JWT 的 payload 段,不校验签名。这不是偷懒——签名密钥 SECRET_KEY(HS256)是后端机密,绝不能下发到前端 edge runtime(否则机密泄漏)。proxy.ts 的角色只是搬运工:把 cookie 里的 sub/role 解出来贴成 header 转发。真正的鉴权决策仍在 acdm-backend get_current_user(acdm-backend/app/util/auth.py:37-69)——它用 SECRET_KEY 验签 + 查 DB 再读 User.is_admin,不信任 JWT 里的 claim(acdm-backend/app/auth/keycloak.py:127-130 注释明确写"后端鉴权决策不应依赖这个 claim")。所以前端"不验签"不构成安全风险:伪造一个假 JWT 塞 cookie,proxy.ts 会照样解析并注 header,但请求到 acdm-backend(forward_auth 端点 acdm-backend/app/auth/verify.py:16)验签会立刻失败 401。
base64url 处理细节(deer-flow/frontend/src/proxy.ts:44-50):JWT 用 base64url(-/_ 替代 +//,无 padding),解码前必须 replace(/-/g, "+").replace(/_/g, "/") 再补 = padding;atob 不可用时 fallback 到 Buffer.from(...,"base64")(edge runtime 兼容)。
config.matcher:为什么不跑全站
config.matcher(deer-flow/frontend/src/proxy.ts:92-99)显式列出 proxy 只对 /workspace/:path*、/api/langgraph/:path*、/api/memory 及子路径生效。Next.js 据此在路由层决定哪些请求才进 proxy 函数。这是性能优化——静态资源(_next/static/*)、landing 页等不会无谓地跑一遍 cookie 检查。注意 /api/memory 和 /api/memory/:path* 分两条写,因为前者匹配精确路径(无尾段),后者匹配带子路径,缺一会漏掉裸 /api/memory。
rewrites():同源 API 反代
职责:把浏览器侧同源 /api/* 在 Next.js server 层 server-to-server 转发到内网真实服务。这是 HttpOnly cookie 能工作的前提(见 Overview)。
rewrites()(deer-flow/frontend/next.config.js:41-122)按服务分组装配 rewrite 规则:
| 源路径 | 目标服务 | env 覆盖开关 | dev fallback | Source |
|---|---|---|---|---|
/api/langgraph /api/langgraph/:path* | LangGraph (Aegra) | NEXT_PUBLIC_LANGGRAPH_BASE_URL 设了就跳过 | http://127.0.0.1:2024 | deer-flow/frontend/next.config.js:52-61 |
/api/models /api/agents /api/skills /api/mcp /api/memory /api/threads/:path* | deer-flow gateway | NEXT_PUBLIC_BACKEND_BASE_URL 设了就跳过 | http://127.0.0.1:8001 | deer-flow/frontend/next.config.js:63-108 |
/api/acdm /api/acdm/:path* | acdm-backend | 始终注册(Phase 1 同域 cookie) | http://127.0.0.1:8003 | deer-flow/frontend/next.config.js:110-115 |
/api/erp/:path* | demoo ERP 服务 | 始终注册 | http://127.0.0.1:8088 | deer-flow/frontend/next.config.js:117-118 |
/api/acdm-backend/:path* | acdm-backend(/api/ 前缀变体) | 始终注册 | http://127.0.0.1:8003 | deer-flow/frontend/next.config.js:119 |
getInternalServiceURL(envKey, fallbackURL)(deer-flow/frontend/next.config.js:7-12):读 process.env[envKey],trim 后非空就用它(并去掉尾部斜杠),否则用 fallback。这就是 dev 不配任何 env 也能跑(全用 127.0.0.1 fallback)、prod 通过 DEER_FLOW_INTERNAL_*_BASE_URL 指向 docker compose 服务名的机制。
NEXT_PUBLIC_* 覆盖语义:/api/langgraph 和 gateway 那批 rewrite 包在 if (!process.env.NEXT_PUBLIC_LANGGRAPH_BASE_URL) / if (!process.env.NEXT_PUBLIC_BACKEND_BASE_URL) 里(deer-flow/frontend/next.config.js:52 :63)。逻辑是:如果你显式设了公网 base URL,说明前端要直连那个地址(浏览器侧拿 NEXT_PUBLIC_* 拼绝对 URL),就不需要 Next.js server 反代,于是跳过注册 rewrite。a-cdm 生产不设这两个 NEXT_PUBLIC_*,所以走 rewrite + Caddy 同源链路。
/api/acdm/* 始终反代(deer-flow/frontend/next.config.js:110-115):注释写明"Phase 1: 走同域让 HttpOnly cookie 能用;生产由 Caddy 处理 /api/acdm/*"。dev 时这条 rewrite 把 /api/acdm/* 转给 acdm-backend(默认 8003);prod 时浏览器请求先到 Caddy,Caddy 自己有 /api/acdm/* 路由(deploy/prod/Caddyfile.keycloak-v1:41-51),不经过 Next.js rewrite——但前端代码用相对路径 /api/acdm/... 这点 dev/prod 一致。
config/index.ts:前端构造 URL 的策略
职责:前端业务代码(memory/models/skills 等 fetch 调用)需要一个 base URL,这个模块决定它返回相对路径还是绝对 URL。
getBackendBaseURL()(deer-flow/frontend/src/core/config/index.ts:11-19):NEXT_PUBLIC_BACKEND_BASE_URL 没设就返回空字符串 ""。于是 core/memory/api.ts 里 `${getBackendBaseURL()}/api/memory` 拼出来就是相对路径 /api/memory(deer-flow/frontend/src/core/memory/api.ts:89-94),走同源 → 命中 proxy.ts header 注入 + rewrite。
getLangGraphBaseURL()(deer-flow/frontend/src/core/config/index.ts:21-40):NEXT_PUBLIC_LANGGRAPH_BASE_URL 没设时,浏览器侧返回 `${window.location.origin}/api/langgraph` ——LangGraph SDK 要求完整 URL,所以用当前页面 origin 拼,仍是同源,仍走 proxy + rewrite。SSR fallback 是 http://localhost:2026/api/langgraph。这就是 api-client.ts 里 new LangGraphClient({ apiUrl: getLangGraphBaseURL() })(deer-flow/frontend/src/core/api/api-client.ts:102-106)能让 SDK 的 SSE 请求带上 cookie 的原因。
Data Flow / 控制流
下面是一次典型的 dev 模式聊天请求(/api/langgraph/*),展示守卫 + header 注入 + 反代如何串起来:
文字说明:
- 浏览器 LangGraph SDK 发
/api/langgraph/...(同源,因为getLangGraphBaseURL返回origin + /api/langgraph),HttpOnlyacdm_sessioncookie 自动带上。 - Next.js 路由层用
config.matcher判定该请求要进 proxy。 - proxy 看
AUTH_INJECT_PREFIXES命中,走injectAuthHeaders。 parseJwtPayloadbase64url 解 cookie 拿{sub, is_admin},不验签。- cookie 缺失或 payload 没 sub →
NextResponse.next()原样放行(后端自己会返 401,memory 路径则返空)。 - 解出 sub → set
X-Auth-User-Id+X-Auth-User-Role,NextResponse.next({request:{headers}})把改过的 header 带下去。 - rewrites 把
/api/langgraph/:path*server-side 转发到langgraphURL(dev=127.0.0.1:2024)。 - LangGraph(Aegra)侧
acdm_auth读注入的头完成鉴权,返回 SSE。
prod 流程的差别:浏览器请求先到 Caddy,Caddy 的 @langgraph_api 块先 forward_auth acdm-backend:8002 调 /auth/verify(deploy/prod/Caddyfile.keycloak-v1:86-96 与 acdm-backend/app/auth/verify.py:16-27),由 acdm-backend 验签 + 查 DB 后回吐 X-Auth-User-* 头,Caddy copy_headers 注入再 reverse_proxy 到 langgraph 容器。prod 走 Caddy,proxy.ts 的 header 注入逻辑虽也执行但被 Caddy 端覆盖/不依赖——两端逻辑等价,所以"dev 与 prod 同一条路径,无后门"(CLAUDE.md §0.1)。
Configuration
| Config | 默认值 | 含义 | 影响 | Source |
|---|---|---|---|---|
NEXT_PUBLIC_BASE_PATH | undefined(dev=/) | 生产挂在 /a-cdm/* 下的路径前缀 | 编译期静态替换,改了必重 build | deer-flow/frontend/next.config.js:35 |
NEXT_PRODUCTION_STANDALONE | 未设 | =1 时 output: "standalone" 生成可独立跑的 server.js | 生产 docker 镜像构建 | deer-flow/frontend/next.config.js:27 |
experimental.proxyTimeout | 180000 (180s) | rewrite 反代超时(默认 30s 会截断长识别请求) | 豆包多模态识别 40-100s 不被截成 500 | deer-flow/frontend/next.config.js:30-32 |
DEER_FLOW_INTERNAL_LANGGRAPH_BASE_URL | http://127.0.0.1:2024 | LangGraph 反代目标 | prod 指向 compose 服务名 | deer-flow/frontend/next.config.js:43-46 |
DEER_FLOW_INTERNAL_GATEWAY_BASE_URL | http://127.0.0.1:8001 | gateway 反代目标 | prod 指向 compose 服务名 | deer-flow/frontend/next.config.js:47-50 |
ACDM_BACKEND_BASE_URL | http://127.0.0.1:8003 | acdm-backend 反代目标 | dev 直连本地 8003 | deer-flow/frontend/next.config.js:14-19 |
DEER_FLOW_INTERNAL_ERP_BASE_URL | http://127.0.0.1:8088 | ERP 服务反代目标 | /api/erp/* 转发 | deer-flow/frontend/next.config.js:117 |
NEXT_PUBLIC_LANGGRAPH_BASE_URL | 未设 | 设了则前端直连该 URL,跳过 langgraph rewrite | a-cdm prod 不设 | deer-flow/frontend/next.config.js:52, deer-flow/frontend/src/env.js:29 |
NEXT_PUBLIC_BACKEND_BASE_URL | 未设 | 设了则前端直连,跳过 gateway 那批 rewrite | a-cdm prod 不设 | deer-flow/frontend/next.config.js:63, deer-flow/frontend/src/env.js:28 |
NEXT_PUBLIC_STATIC_WEBSITE_ONLY | 未设 | 仅静态站点模式标志 | env schema 校验 | deer-flow/frontend/src/env.js:30 |
SKIP_ENV_VALIDATION | 未设 | =1 跳过 t3-env Zod 校验(Docker build 用) | 构建期 | deer-flow/frontend/src/env.js:54 |
安全相关:proxy.ts 故意不验签(
deer-flow/frontend/src/proxy.ts:40-55)——这是有意设计,签名密钥SECRET_KEY绝不下发前端 edge runtime。鉴权决策权威在 acdm-backendget_current_user(acdm-backend/app/util/auth.py:48-62,验签 + DB 查 is_admin)。前端注的 header 在 prod 会被 Caddyforward_auth端的真验签结果覆盖,伪造 cookie 到不了任何受保护资源。
Common Pitfalls / 实战 Tips
- 改
basePath必须重 build(deer-flow/frontend/next.config.js:33-35注释 + CLAUDE.md lessons L26):NEXT_PUBLIC_BASE_PATH是 Next.js 编译期静态替换,不是运行期读 env。生产挂/a-cdm必须 build 时就设好,docker compose restart不生效。 - API 必须走相对路径:任何前端 fetch 写死绝对地址(如
http://172...:8002)会变成跨源请求,HttpOnly cookie 带不上,直接 401。靠getBackendBaseURL()返回""(deer-flow/frontend/src/core/config/index.ts:11-19)保证同源。 /api/memory必须 matcher 两条都写(deer-flow/frontend/src/proxy.ts:96-97):裸/api/memory和/api/memory/:path*是两个独立 matcher 模式,只写后者会漏掉无子路径的 memory 调用,导致 memory 面板拿不到 scope 永远空(注释:19-27记录了这个坑的根因)。- rewrite 默认超时 30s 会截断长请求(
deer-flow/frontend/next.config.js:28-32):豆包多模态图片识别常 40-100s,不显式设proxyTimeout: 180000会被 Next.js 默认 30s 截成 500 Internal Server Error。 - cookie 缺失时 proxy 不拦 API(
deer-flow/frontend/src/proxy.ts:59-62):injectAuthHeaders没 cookie 时NextResponse.next()原样放行(不是 401),由后端决定——langgraph 路径会 401,memory 路径只是返回空数据。proxy 只守/workspace/*页面,不守 API 路径(API 鉴权交后端)。 - Next.js 16 改名:
middleware.ts→proxy.ts,且导出函数名必须是proxy不能是middleware(deer-flow/frontend/src/proxy.ts:3-4注释),这是 Next 16 升级时的硬约束。
References
deer-flow/frontend/src/proxy.ts:1-99— 本章主源:edge 守卫 + JWT 解码 + header 注入 + matcherdeer-flow/frontend/next.config.js:1-125— rewrites 反代规则、basePath、proxyTimeout、内网 URL 解析deer-flow/frontend/src/env.js:1-60— t3-env Zod env schema(NEXT_PUBLIC_*客户端变量)deer-flow/frontend/src/core/config/index.ts:1-40— 前端构造 base URL 策略(默认同源相对路径)deer-flow/frontend/src/core/memory/api.ts:89-94— memory fetch 用相对/api/memory(走 proxy + rewrite 的实例)deer-flow/frontend/src/core/api/api-client.ts:101-120— LangGraph SDK 单例用getLangGraphBaseURL()同源 URLacdm-backend/app/auth/router.py:136-301—/auth/login/auth/callback设 acdm_session cookie 的后端端acdm-backend/app/auth/keycloak.py:122-142—create_access_token(proxy.ts 解的 HS256 JWT)/decode_access_tokenacdm-backend/app/auth/verify.py:16-27— Caddy forward_auth 调的/auth/verify(prod 的真鉴权点)acdm-backend/app/util/auth.py:37-69—get_current_user权威鉴权(验签 + DB 查 is_admin)deploy/prod/Caddyfile.keycloak-v1:86-96— prod LangGraph 路由的 forward_auth + copy_headers
Related Pages
| Page | Relationship |
|---|---|
| 鉴权与授权双层体系 | 本章 proxy 守卫是该章双层鉴权的前端入口;JWT/cookie 由该章详解 |
| Caddy 网关与生产路由拓扑 | 本章 dev header 注入在 prod 由该章 Caddy forward_auth 完成 |
| 请求生命周期与服务拓扑 | 本章 rewrites 是该章端到端请求链路的前端反代环节 |
| 前端技术栈与架构 | 本章是该章 Next.js 16 应用的 edge/server 层基础设施 |
| AI 消息流与 SSE 基础设施 | 本章 /api/langgraph 反代承载该章 SSE 流式聊天 |
| acdm-backend 控制面架构 | 本章 /api/acdm/* 反代到该章后端的 auth router |