Skip to content

proxy 鉴权守卫与 API 反代

本章目标:

  1. 看懂 src/proxy.ts(Next.js 16 重命名自 middleware.ts)三个职责:/workspace/* 未登录守卫、/api/langgraph/*/api/memory/* 的 JWT 解码 + header 注入
  2. 弄清 next.config.jsrewrites() 怎么把 /api/* 同源反代到 acdm-backend / deer-flow gateway / LangGraph / ERP,以及为什么必须同源
  3. 理清环境变量(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.jsrewrites()(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.jsrewrites() 在 Next.js server 层把同源 /api/* 转发到内网,config/index.ts + env.js 决定前端代码构造 URL 时用相对路径还是绝对 URL。

组件职责入口文件Source
proxy()edge 守卫:/workspace/* 未登录 302;auth header 注入deer-flow/frontend/src/proxy.tsdeer-flow/frontend/src/proxy.ts:73-90
config.matcher声明 proxy 只对哪些路径生效(性能:不跑全站)deer-flow/frontend/src/proxy.tsdeer-flow/frontend/src/proxy.ts:92-99
parseJwtPayload()base64url 解 JWT payload(不验签)deer-flow/frontend/src/proxy.tsdeer-flow/frontend/src/proxy.ts:40-55
injectAuthHeaders()从 cookie 解 sub/is_admin → 注入 X-Auth-User-*deer-flow/frontend/src/proxy.tsdeer-flow/frontend/src/proxy.ts:57-71
rewrites()同源 /api/* → 内网服务 server-side 反代deer-flow/frontend/next.config.jsdeer-flow/frontend/next.config.js:41-122
getInternalServiceURL()解析 *_INTERNAL_*_BASE_URL env,空则用 dev fallbackdeer-flow/frontend/next.config.jsdeer-flow/frontend/next.config.js:7-12
getBackendBaseURL() / getLangGraphBaseURL()前端代码构造 URL:默认返回 ""/同源,让请求落回 rewritedeer-flow/frontend/src/core/config/index.tsdeer-flow/frontend/src/core/config/index.ts:11-40
create_access_token()acdm-backend 签的 HS256 JWT,proxy.ts 解的就是它acdm-backend/app/auth/keycloak.pyacdm-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_PATHSacdm_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 fallbackSource
/api/langgraph /api/langgraph/:path*LangGraph (Aegra)NEXT_PUBLIC_LANGGRAPH_BASE_URL 设了就跳过http://127.0.0.1:2024deer-flow/frontend/next.config.js:52-61
/api/models /api/agents /api/skills /api/mcp /api/memory /api/threads/:path*deer-flow gatewayNEXT_PUBLIC_BACKEND_BASE_URL 设了就跳过http://127.0.0.1:8001deer-flow/frontend/next.config.js:63-108
/api/acdm /api/acdm/:path*acdm-backend始终注册(Phase 1 同域 cookie)http://127.0.0.1:8003deer-flow/frontend/next.config.js:110-115
/api/erp/:path*demoo ERP 服务始终注册http://127.0.0.1:8088deer-flow/frontend/next.config.js:117-118
/api/acdm-backend/:path*acdm-backend(/api/ 前缀变体)始终注册http://127.0.0.1:8003deer-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.tsnew LangGraphClient({ apiUrl: getLangGraphBaseURL() })(deer-flow/frontend/src/core/api/api-client.ts:102-106)能让 SDK 的 SSE 请求带上 cookie 的原因。

Data Flow / 控制流

下面是一次典型的 dev 模式聊天请求(/api/langgraph/*),展示守卫 + header 注入 + 反代如何串起来:

文字说明:

  1. 浏览器 LangGraph SDK 发 /api/langgraph/...(同源,因为 getLangGraphBaseURL 返回 origin + /api/langgraph),HttpOnly acdm_session cookie 自动带上。
  2. Next.js 路由层用 config.matcher 判定该请求要进 proxy。
  3. proxy 看 AUTH_INJECT_PREFIXES 命中,走 injectAuthHeaders
  4. parseJwtPayload base64url 解 cookie 拿 {sub, is_admin},不验签。
  5. cookie 缺失或 payload 没 sub → NextResponse.next() 原样放行(后端自己会返 401,memory 路径则返空)。
  6. 解出 sub → set X-Auth-User-Id + X-Auth-User-Role,NextResponse.next({request:{headers}}) 把改过的 header 带下去。
  7. rewrites 把 /api/langgraph/:path* server-side 转发到 langgraphURL(dev=127.0.0.1:2024)。
  8. LangGraph(Aegra)侧 acdm_auth 读注入的头完成鉴权,返回 SSE。

prod 流程的差别:浏览器请求先到 Caddy,Caddy 的 @langgraph_api 块先 forward_auth acdm-backend:8002/auth/verify(deploy/prod/Caddyfile.keycloak-v1:86-96acdm-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_PATHundefined(dev=/)生产挂在 /a-cdm/* 下的路径前缀编译期静态替换,改了必重 builddeer-flow/frontend/next.config.js:35
NEXT_PRODUCTION_STANDALONE未设=1output: "standalone" 生成可独立跑的 server.js生产 docker 镜像构建deer-flow/frontend/next.config.js:27
experimental.proxyTimeout180000 (180s)rewrite 反代超时(默认 30s 会截断长识别请求)豆包多模态识别 40-100s 不被截成 500deer-flow/frontend/next.config.js:30-32
DEER_FLOW_INTERNAL_LANGGRAPH_BASE_URLhttp://127.0.0.1:2024LangGraph 反代目标prod 指向 compose 服务名deer-flow/frontend/next.config.js:43-46
DEER_FLOW_INTERNAL_GATEWAY_BASE_URLhttp://127.0.0.1:8001gateway 反代目标prod 指向 compose 服务名deer-flow/frontend/next.config.js:47-50
ACDM_BACKEND_BASE_URLhttp://127.0.0.1:8003acdm-backend 反代目标dev 直连本地 8003deer-flow/frontend/next.config.js:14-19
DEER_FLOW_INTERNAL_ERP_BASE_URLhttp://127.0.0.1:8088ERP 服务反代目标/api/erp/* 转发deer-flow/frontend/next.config.js:117
NEXT_PUBLIC_LANGGRAPH_BASE_URL未设设了则前端直连该 URL,跳过 langgraph rewritea-cdm prod 不设deer-flow/frontend/next.config.js:52, deer-flow/frontend/src/env.js:29
NEXT_PUBLIC_BACKEND_BASE_URL未设设了则前端直连,跳过 gateway 那批 rewritea-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-backend get_current_user(acdm-backend/app/util/auth.py:48-62,验签 + DB 查 is_admin)。前端注的 header 在 prod 会被 Caddy forward_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.tsproxy.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 注入 + matcher
  • deer-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() 同源 URL
  • acdm-backend/app/auth/router.py:136-301/auth/login /auth/callback 设 acdm_session cookie 的后端端
  • acdm-backend/app/auth/keycloak.py:122-142create_access_token(proxy.ts 解的 HS256 JWT)/ decode_access_token
  • acdm-backend/app/auth/verify.py:16-27 — Caddy forward_auth 调的 /auth/verify(prod 的真鉴权点)
  • acdm-backend/app/util/auth.py:37-69get_current_user 权威鉴权(验签 + DB 查 is_admin)
  • deploy/prod/Caddyfile.keycloak-v1:86-96 — prod LangGraph 路由的 forward_auth + copy_headers
PageRelationship
鉴权与授权双层体系本章 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

公司内部参考 · 由 claude-wiki-gen 基于源码自动生成的二次分析