mcpField
H
headless-bff-architecture

headless-bff-architecture

github.com/vtex開発471.1k 回インストール2 週間前に更新
リポジトリ

headless-bff-architecture について

ヘッドレス VTEX ストアを構築する開発者向けの、BFF アーキテクチャとセキュリティのガイドです。API のプロキシ、認証情報、セッショントークンを扱い、Intelligent Search 以外の VTEX API 呼び出しにはサーバー側 BFF を求めます。

主な機能

  1. 01ヘッドレスストア向けサーバーサイド BFF の構築
  2. 02プロキシ対象と直接呼び出し可能な VTEX API の区別
  3. 03認証情報とセッショントークンのサーバー側管理
  4. 04モジュール別の最小権限 API キー
  5. 05入力検証と機密ログ処理の確認

活用シーン

  1. 01ヘッドレスストアの API リクエスト経路設計
  2. 02VTEX 認証情報のブラウザーコードへの混入防止
  3. 03購入者トークンとセッションの安全な管理

インストール

コマンドをコピーしてターミナルで実行します。

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

インストール後、Claude Code で/headless-bff-architectureを入力して読み込まれたか確認します。

詳細

BFF レイヤーの設計とセキュリティ

このスキルを適用する場面

VTEX API と通信するヘッドレス VTEX ストアフロント(カスタムストアフロント、モバイルアプリ、キオスクなど)を構築または変更する場合に、このスキルを使用します。

  • 新しいヘッドレスプロジェクト向けに BFF(Backend-for-Frontend)レイヤーを設定する
  • サーバー側でプロキシする VTEX API と、フロントエンドから直接呼び出す VTEX API を判断する
  • 認証情報(VTEX_APP_KEY、VTEX_APP_TOKEN、VtexIdclientAutCookie)を管理する
  • ヘッドレスアーキテクチャのセキュリティ準拠状況を確認する

次の用途では、このスキルを使用しないでください。

判断ルール

  • ヘッドレス VTEX プロジェクトには、必ず BFF レイヤーが必要です。ヘッドレスストアフロントが BFF なしで安全に稼働できる状況はありません。
  • フロントエンドから直接呼び出しても安全な唯一の API である Intelligent Search を除き、すべての VTEX API 呼び出しを BFF 経由にします。
  • 買い物客単位の API 呼び出しには(サーバー側に保存した)VtexIdclientAutCookie を使用します。マシン間の呼び出しには X-VTEX-API-AppKey/X-VTEX-API-AppToken を使用します。
  • VTEX_APP_KEY、VTEX_APP_TOKEN、VtexIdclientAutCookie、Checkout Cookie、買い物客/セッションのトークンをクライアント側に公開することは、推奨事項やトレードオフではなく、セキュリティ違反として扱います。「必ず〜してはならない」「決して公開しない」「サーバー側のみ」などの明確な表現を使用し、「避ける」「できれば」「理想的には」などの曖昧な表現は使用しないでください。
  • API はパスで分類します。/pub/ エンドポイントは公開されていますが、その大半はセッション管理のために BFF 経由でプロキシする必要があります。/pvt/ エンドポイントは非公開であり、必ず BFF 経由にする必要があります。
  • 公開されている Checkout エンドポイント(/api/checkout/pub/)も、機密性の高い個人データを扱うため、必ず BFF 経由でプロキシします。
  • 広範なアクセス権を持つ 1 つの API キーを共有するのではなく、BFF のモジュールごとに最小限の権限を持つ個別の API キーを作成します。

厳守事項

制約:ヘッドレス VTEX には BFF レイヤーが必須です。例外はありません

すべてのヘッドレス VTEX ストアフロントには、必ずサーバー側の BFF レイヤーを用意しなければなりません。クライアント側のコードから非公開の VTEX API エンドポイントへ HTTP リクエストを直接送信してはなりません。すべての非公開 API 呼び出しは BFF 経由にする必要があります。

この制約が重要な理由

非公開の VTEX API では、X-VTEX-API-AppKey および X-VTEX-API-AppToken ヘッダーが必要です。フロントエンドからこれらの API を直接呼び出す場合、認証情報をクライアント側のコードに埋め込むか、ブラウザーに送信する必要があるため、ブラウザーの DevTools を開いたユーザーなら誰でも認証情報を見られる状態になります。盗まれた 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 攻撃、ブラウザー拡張機能、共有・公共のコンピューターを介して盗まれる可能性があります。このトークンを持つ攻撃者は、購入者になりすますことができます。

検出

localStorage.setItem、sessionStorage.setItem 内で VtexIdclientAutCookie が参照されている場合、またはクライアント側のコードで 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,
  });
});

よくある失敗パターン

  • Intelligent Search を BFF 経由でプロキシする:Intelligent Search を含むすべての VTEX API 呼び出しを BFF 経由にすると、不要な遅延とサーバー負荷が発生します。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 キーを共有する:すべての BFF 操作に広範な権限(Owner ロールなど)を持つ単一の API キーを使用すると、キーが侵害された際に、すべての 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 キーを使用し、権限を最小限にしていますか?
  • すべてのログ出力で機密ヘッダーがマスキングされていますか?

参考資料

コメント