mcpField
H
headless-bff-architecture

headless-bff-architecture

github.com/vtex开发471.1k 次安装2 周前更新
代码仓库

关于 headless-bff-architecture

面向构建无头 VTEX 商店的开发者,提供 BFF 架构与安全设计指导,覆盖 API 代理、凭证管理和会话令牌处理。技能要求通过服务端 BFF 处理 VTEX API 调用,同时允许前端直接访问 Intelligent Search。

主要功能

  1. 01为无头商店设置服务端 BFF
  2. 02区分需代理与可直连的 VTEX API
  3. 03在服务端管理凭证和会话令牌
  4. 04按模块使用最小权限 API 密钥
  5. 05检查请求验证与敏感日志处理

适用场景

  1. 01为无头商店设计 API 请求路径
  2. 02防止 VTEX 密钥进入浏览器代码
  3. 03安全管理购物者身份令牌和会话

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/vtex/skills --skill headless-bff-architecture

安装后在 Claude Code 中输入/headless-bff-architecture确认已加载。

详细介绍

BFF 层设计与安全

适用场景

在构建或修改任何与 VTEX API 通信的无头 VTEX 店面时使用此技能,无论是自定义店面、移动应用还是自助服务终端。

  • 为新的无头项目搭建 BFF(前端后端)层
  • 确定哪些 VTEX API 需要在服务器端代理,哪些可以由前端直接调用
  • 实现凭据管理(VTEX_APP_KEY、VTEX_APP_TOKEN、VtexIdclientAutCookie)
  • 审查无头架构是否符合安全要求

请勿将此技能用于:

决策规则

  • 所有无头 VTEX 项目都必须使用 BFF 层。无头店面不存在可以在没有 BFF 的情况下安全运行的场景。
  • 所有 VTEX API 调用都必须通过 BFF 路由,唯一例外是 Intelligent Search,它是唯一可以安全地由前端直接调用的 API。
  • 对于按购物者范围调用的 API,使用存储在服务器端的 VtexIdclientAutCookie。对于机器对机器调用,使用 X-VTEX-API-AppKey/X-VTEX-API-AppToken。
  • 将客户端暴露 VTEX_APP_KEY、VTEX_APP_TOKEN、VtexIdclientAutCookie、结账 Cookie 或购物者/会话令牌视为安全违规,而不是建议或权衡。请使用“不得”“绝不暴露”和“仅限服务器端”等明确表述,避免使用“避免”“优先考虑”或“最好”等较弱的措辞。
  • 根据 API 路径分类:/pub/ 端点是公开的,但大多数仍需要通过 BFF 代理以管理会话;/pvt/ 端点是私有的,必须通过 BFF 调用。
  • 即使是公开的 Checkout 端点(/api/checkout/pub/),也必须通过 BFF 代理以确保安全,因为这些端点会处理敏感个人数据。
  • 为不同的 BFF 模块创建权限最小化的独立 API 密钥,不要共用一个权限宽泛的密钥。

严格约束

约束:无头 VTEX 必须使用 BFF 层,不得例外

每个无头 VTEX 店面都必须使用服务器端 BFF 层。客户端代码不得直接向 VTEX 私有 API 端点发送 HTTP 请求。所有私有 API 调用都必须通过 BFF 路由。

原因

VTEX 私有 API 需要 X-VTEX-API-AppKey 和 X-VTEX-API-AppToken 请求头。如果前端直接调用这些 API,凭据就必须嵌入客户端代码或传输到浏览器,任何打开浏览器开发者工具的用户都能获取这些凭据。被盗的 API 密钥可用于访问订单数据、修改价格或执行具有破坏性的管理操作。

检测

如果在客户端代码(src/、public/、app/ 下的文件,或任何在浏览器中执行的代码包)中发现对 vtexcommercestable.com.br/api/checkout、/api/oms、/api/profile 或任何 /pvt/ 端点的 fetch 或 axios 调用 → 立即停止。这些调用必须移至 BFF。

正确

// Frontend code — calls BFF, not VTEX directly
async function getOrderDetails(orderId: string): Promise<Order> {
  const response = await fetch(`/api/bff/orders/${orderId}`, {
    credentials: "include", // sends session cookie to BFF
  });

  if (!response.ok) {
    throw new Error(`Failed to fetch order: ${response.status}`);
  }

  return response.json();
}

错误

// Frontend code — calls VTEX OMS API directly (SECURITY VULNERABILITY)
async function getOrderDetails(orderId: string): Promise<Order> {
  const response = await fetch(
    `https://mystore.vtexcommercestable.com.br/api/oms/pvt/orders/${orderId}`,
    {
      headers: {
        "X-VTEX-API-AppKey": "vtexappkey-mystore-ABCDEF", // EXPOSED!
        "X-VTEX-API-AppToken": "eyJhbGciOi...", // EXPOSED!
      },
    }
  );
  return response.json();
}

约束:必须在服务端管理 VtexIdclientAutCookie

必须将 VtexIdclientAutCookie 令牌存储在安全的服务端会话中(例如加密 Cookie、Redis 会话存储),不得存储在 localStorage、sessionStorage 或任何客户端可访问的 JavaScript 变量中。

为何这很重要

VtexIdclientAutCookie 是一个持有者令牌,可代表购物者验证所有操作,包括下单、查看个人资料数据和访问支付信息。如果将其存储在客户端,令牌可能通过 XSS 攻击、浏览器扩展程序或共享/公用计算机被窃取。持有此令牌的攻击者可以冒充购物者。

检测

如果发现客户端代码中的 VtexIdclientAutCookie 被用于 localStorage.setItem、sessionStorage.setItem,或被赋值给 JavaScript 变量 → 立即停止。此令牌必须完全由服务端管理。

正确

// BFF route — stores VtexIdclientAutCookie in server-side session
import { Router, Request, Response } from "express";
import session from "express-session";

const router = Router();

// After VTEX login callback, extract and store token server-side
router.get("/auth/callback", async (req: Request, res: Response) => {
  const vtexAuthToken = req.cookies["VtexIdclientAutCookie"];

  if (!vtexAuthToken) {
    return res.status(401).json({ error: "Authentication failed" });
  }

  // Store in server-side session — never sent to frontend
  req.session.vtexAuthToken = vtexAuthToken;

  // Clear the cookie from the browser response
  res.clearCookie("VtexIdclientAutCookie");

  // Redirect to frontend with a secure session cookie
  res.redirect("/account");
});

// BFF proxy uses session token for VTEX API calls
router.get("/api/bff/profile", async (req: Request, res: Response) => {
  const vtexToken = req.session.vtexAuthToken;

  if (!vtexToken) {
    return res.status(401).json({ error: "Not authenticated" });
  }

  const response = await fetch(
    `https://${process.env.VTEX_ACCOUNT}.vtexcommercestable.com.br/api/checkout/pub/profiles`,
    {
      headers: {
        Cookie: `VtexIdclientAutCookie=${vtexToken}`,
      },
    }
  );

  const profile = await response.json();
  res.json(profile);
});

错误

// Frontend code — stores auth token in localStorage (SECURITY VULNERABILITY)
function handleLoginCallback() {
  const params = new URLSearchParams(window.location.search);
  const vtexToken = params.get("authToken");

  // WRONG: token is now accessible to any JS on the page, including XSS payloads
  localStorage.setItem("VtexIdclientAutCookie", vtexToken!);
}

// Later, reads from localStorage and sends in header
async function getProfile() {
  const token = localStorage.getItem("VtexIdclientAutCookie"); // EXPOSED!
  return fetch("https://mystore.vtexcommercestable.com.br/api/checkout/pub/profiles", {
    headers: { Cookie: `VtexIdclientAutCookie=${token}` },
  });
}

约束:API 密钥不得出现在客户端代码中

VTEX_APP_KEY 和 VTEX_APP_TOKEN 的值只能存在于服务端环境变量中,不得出现在任何会被打包、提供或供浏览器访问的文件中。

为何这很重要

API 密钥可授予对 VTEX 平台的程序化访问权限,权限范围取决于关联角色。将其暴露在前端包、公共目录或客户端环境变量(例如 NEXT_PUBLIC_*、VITE_*)中,会使任何人都能提取密钥并发起未经授权的 API 调用。

检测

如果在 src/、public/、app/ 目录中的文件,或以 NEXT_PUBLIC_、VITE_ 或 REACT_APP_ 为前缀的环境变量中发现 VTEX_APP_KEY、VTEX_APP_TOKEN、X-VTEX-API-AppKey 或 X-VTEX-API-AppToken → 立即停止。请将它们移至仅供服务端使用的环境变量中。

正确

// BFF server code — reads keys from server-side env vars only
// File: server/vtex-client.ts (never bundled for browser)
import { Router, Request, Response } from "express";

const VTEX_ACCOUNT = process.env.VTEX_ACCOUNT!;
const VTEX_APP_KEY = process.env.VTEX_APP_KEY!;
const VTEX_APP_TOKEN = process.env.VTEX_APP_TOKEN!;

const router = Router();

router.get("/api/bff/orders/:orderId", async (req: Request, res: Response) => {
  const { orderId } = req.params;

  const response = await fetch(
    `https://${VTEX_ACCOUNT}.vtexcommercestable.com.br/api/oms/pvt/orders/${orderId}`,
    {
      headers: {
        "X-VTEX-API-AppKey": VTEX_APP_KEY,
        "X-VTEX-API-AppToken": VTEX_APP_TOKEN,
        Accept: "application/json",
      },
    }
  );

  if (!response.ok) {
    return res.status(response.status).json({ error: "Failed to fetch order" });
  }

  const order = await response.json();
  res.json(order);
});

export default router;

错误

// .env file with NEXT_PUBLIC_ prefix — exposed to browser bundle!
// NEXT_PUBLIC_VTEX_APP_KEY=vtexappkey-mystore-ABCDEF
// NEXT_PUBLIC_VTEX_APP_TOKEN=eyJhbGciOi...

// Frontend code reads exposed env vars
async function fetchOrders() {
  const response = await fetch(
    `https://mystore.vtexcommercestable.com.br/api/oms/pvt/orders`,
    {
      headers: {
        "X-VTEX-API-AppKey": process.env.NEXT_PUBLIC_VTEX_APP_KEY!, // EXPOSED IN BUNDLE!
        "X-VTEX-API-AppToken": process.env.NEXT_PUBLIC_VTEX_APP_TOKEN!, // EXPOSED IN BUNDLE!
      },
    }
  );
  return response.json();
}

首选模式

架构概览 — 请求如何通过 BFF 流转:

Frontend (Browser/App)
    │
    ├── Direct call (OK): Intelligent Search API (public, read-only)
    │
    └── All other requests → BFF Layer (Node.js/Express)
                                │
                                ├── Injects VtexIdclientAutCookie from session
                                ├── Injects X-VTEX-API-AppKey / X-VTEX-API-AppToken
                                ├── Validates & sanitizes input
                                └── Proxies to VTEX APIs
                                        │
                                        ├── Checkout API (/api/checkout/pub/...)
                                        ├── OMS API (/api/oms/pvt/...)
                                        ├── Profile API (/api/profile-system/pvt/...)
                                        └── Other VTEX services

包含会话管理的最简 BFF 服务端配置:

// server/index.ts
import express from "express";
import session from "express-session";
import cookieParser from "cookie-parser";
import cors from "cors";
import { checkoutRoutes } from "./routes/checkout";
import { profileRoutes } from "./routes/profile";
import { ordersRoutes } from "./routes/orders";

const app = express();

app.use(express.json());
app.use(cookieParser());
app.use(
  cors({
    origin: process.env.FRONTEND_URL,
    credentials: true,
  })
);
app.use(
  session({
    // Production BFFs are typically multi-replica. Configure a shared session
    // store (e.g. connect-redis, connect-memcached, a Postgres-backed store)
    // here — the default MemoryStore is per-pod and silently drops values such
    // as orderFormId and vtexAuthToken when requests are routed to a different
    // replica. For ephemeral cross-step values that the browser already sees
    // (e.g. orderGroup between Step 1 and Step 3 of order placement), prefer
    // passing them through the request body instead of writing them to the
    // session — see headless-checkout-proxy.
    secret: process.env.SESSION_SECRET!,
    resave: false,
    saveUninitialized: false,
    cookie: {
      secure: process.env.NODE_ENV === "production",
      httpOnly: true,
      sameSite: "strict",
      maxAge: 24 * 60 * 60 * 1000, // 24 hours (matches VtexIdclientAutCookie TTL)
    },
  })
);

// Mount BFF routes
app.use("/api/bff/checkout", checkoutRoutes);
app.use("/api/bff/profile", profileRoutes);
app.use("/api/bff/orders", ordersRoutes);

const PORT = process.env.PORT || 3001;
app.listen(PORT, () => {
  console.log(`BFF server running on port ${PORT}`);
});

为两种身份验证类型注入凭据的 VTEX API 客户端:

// server/vtex-api-client.ts
const VTEX_ACCOUNT = process.env.VTEX_ACCOUNT!;
const VTEX_ENVIRONMENT = process.env.VTEX_ENVIRONMENT || "vtexcommercestable";
const VTEX_APP_KEY = process.env.VTEX_APP_KEY!;
const VTEX_APP_TOKEN = process.env.VTEX_APP_TOKEN!;

const BASE_URL = `https://${VTEX_ACCOUNT}.${VTEX_ENVIRONMENT}.com.br`;

interface VtexRequestOptions {
  path: string;
  method?: string;
  body?: unknown;
  authType: "app-key" | "user-token";
  userToken?: string;
}

export async function vtexRequest<T>(options: VtexRequestOptions): Promise<T> {
  const { path, method = "GET", body, authType, userToken } = options;

  const headers: Record<string, string> = {
    Accept: "application/json",
    "Content-Type": "application/json",
  };

  if (authType === "app-key") {
    headers["X-VTEX-API-AppKey"] = VTEX_APP_KEY;
    headers["X-VTEX-API-AppToken"] = VTEX_APP_TOKEN;
  } else if (authType === "user-token" && userToken) {
    headers["Cookie"] = `VtexIdclientAutCookie=${userToken}`;
  }

  const response = await fetch(`${BASE_URL}${path}`, {
    method,
    headers,
    body: body ? JSON.stringify(body) : undefined,
  });

  if (!response.ok) {
    throw new Error(
      `VTEX API error: ${response.status} ${response.statusText} for ${method} ${path}`
    );
  }

  return response.json() as Promise<T>;
}

基于会话身份验证并验证输入的 BFF 路由处理程序:

// server/routes/orders.ts
import { Router, Request, Response } from "express";
import { vtexRequest } from "../vtex-api-client";

export const ordersRoutes = Router();

// Get order details — requires API key auth (private endpoint)
ordersRoutes.get("/:orderId", async (req: Request, res: Response) => {
  try {
    const { orderId } = req.params;

    // Validate input
    if (!/^[a-zA-Z0-9-]+$/.test(orderId)) {
      return res.status(400).json({ error: "Invalid order ID format" });
    }

    // Optionally check user session for authorization
    const vtexToken = req.session.vtexAuthToken;
    if (!vtexToken) {
      return res.status(401).json({ error: "Authentication required" });
    }

    const order = await vtexRequest({
      path: `/api/oms/pvt/orders/${orderId}`,
      authType: "app-key",
    });

    res.json(order);
  } catch (error) {
    console.error("Error fetching order:", error);
    res.status(500).json({ error: "Failed to fetch order" });
  }
});

使用服务端令牌管理的身份验证流程:

// server/routes/auth.ts
import { Router, Request, Response } from "express";

export const authRoutes = Router();

const VTEX_ACCOUNT = process.env.VTEX_ACCOUNT!;
const VTEX_LOGIN_URL = `https://${VTEX_ACCOUNT}.myvtex.com/login`;
const FRONTEND_URL = process.env.FRONTEND_URL!;

// Redirect shopper to VTEX login page
authRoutes.get("/login", (_req: Request, res: Response) => {
  const returnUrl = `${FRONTEND_URL}/auth/callback`;
  res.redirect(`${VTEX_LOGIN_URL}?returnUrl=${encodeURIComponent(returnUrl)}`);
});

// Handle login callback — extract VtexIdclientAutCookie and store server-side
authRoutes.get("/callback", (req: Request, res: Response) => {
  const vtexToken = req.cookies["VtexIdclientAutCookie"];

  if (!vtexToken) {
    return res.redirect(`${FRONTEND_URL}/login?error=auth_failed`);
  }

  // Store token in server-side session
  req.session.vtexAuthToken = vtexToken;

  // Clear the VTEX cookie from the browser
  res.clearCookie("VtexIdclientAutCookie");

  // Redirect to authenticated frontend page
  res.redirect(`${FRONTEND_URL}/account`);
});

// Logout — destroy session
authRoutes.post("/logout", (req: Request, res: Response) => {
  req.session.destroy((err) => {
    if (err) {
      return res.status(500).json({ error: "Logout failed" });
    }
    res.clearCookie("connect.sid");
    res.json({ success: true });
  });
});

// Check authentication status
authRoutes.get("/status", (req: Request, res: Response) => {
  res.json({
    authenticated: !!req.session.vtexAuthToken,
  });
});

常见故障模式

  • 通过 BFF 代理 Intelligent Search:通过 BFF 路由每个 VTEX API 调用(包括 Intelligent Search)会增加不必要的延迟和服务器负载。Intelligent Search 是面向公众的只读 API,专为前端直接调用而设计。请从前端直接调用。

    // Frontend code — call Intelligent Search directly (this is correct!)
    async function searchProducts(query: string, from: number = 0, to: number = 19): Promise<SearchResult> {
      const baseUrl = `https://${STORE_ACCOUNT}.vtexcommercestable.com.br`;
      const response = await fetch(
        `${baseUrl}/api/io/_v/api/intelligent-search/product_search/?query=${encodeURIComponent(query)}&from=${from}&to=${to}&locale=en-US`,
      );
      return response.json();
    }
    
  • 在所有 BFF 操作中共用一个 API 密钥:使用一个权限广泛(例如 Owner 角色)的 API 密钥执行所有 BFF 操作,意味着密钥一旦泄露,攻击者就能访问所有 VTEX 资源。请为不同的 BFF 模块创建单独的 API 密钥,并仅授予所需的最低权限。

    // server/vtex-credentials.ts — separate keys per domain
    export const credentials = {
      oms: {
        appKey: process.env.VTEX_OMS_APP_KEY!,
        appToken: process.env.VTEX_OMS_APP_TOKEN!,
      },
      checkout: {
        appKey: process.env.VTEX_CHECKOUT_APP_KEY!,
        appToken: process.env.VTEX_CHECKOUT_APP_TOKEN!,
      },
      catalog: {
        appKey: process.env.VTEX_CATALOG_APP_KEY!,
        appToken: process.env.VTEX_CATALOG_APP_TOKEN!,
      },
    } as const;
    
  • 记录 API 凭据或身份验证令牌:调试时记录请求头或完整请求对象,可能会无意中将 API 密钥或 VtexIdclientAutCookie 值写入日志文件,而这些文件可能被多个团队成员或攻击者访问。记录日志前,请清理所有日志输出,移除敏感请求头。

    // server/middleware/request-logger.ts
    import { Request, Response, NextFunction } from "express";
    
    const SENSITIVE_HEADERS = [
      "x-vtex-api-appkey",
      "x-vtex-api-apptoken",
      "cookie",
      "authorization",
    ];
    
    export function requestLogger(req: Request, _res: Response, next: NextFunction) {
      const sanitizedHeaders = Object.fromEntries(
        Object.entries(req.headers).map(([key, value]) =>
          SENSITIVE_HEADERS.includes(key.toLowerCase())
            ? [key, "[REDACTED]"]
            : [key, value]
        )
      );
    
      console.log({
        method: req.method,
        path: req.path,
        headers: sanitizedHeaders,
        timestamp: new Date().toISOString(),
      });
    
      next();
    }
    

审查清单

  • 是否存在 BFF 层?所有无头 VTEX 项目都必须具备此层,无一例外。
  • 所有私有 VTEX API 调用(/pvt/ 端点)是否都通过 BFF 路由?
  • VTEX_APP_KEY 和 VTEX_APP_TOKEN 是否仅存储在服务端环境变量中?
  • NEXT_PUBLIC_*、VITE_* 或 REACT_APP_* 环境变量中是否都没有 API 密钥?
  • VtexIdclientAutCookie 是否存储在服务端会话中,而非 localStorage 或 sessionStorage 中?
  • Intelligent Search 是否由前端直接调用(而非不必要地通过 BFF 代理)?
  • 不同的 BFF 模块是否使用了权限最小化的独立 API 密钥?
  • 是否已从所有日志输出中移除敏感请求头?

参考资料

评论

支持 Markdown