🔑

【Firebase Emulator】Cloud API Gateway × Firebase Auth をローカルで再現する

に公開

GCP の Cloud API Gateway + Firebase Auth を使ったマイクロサービス構成のローカル開発環境を、Firebase Emulator を使って再現した構成を紹介します。Docker で Emulator を立ち上げ、Express 製のローカルゲートウェイが本番の API Gateway を肩代わりするアーキテクチャです。

前提知識

Cloud API Gateway とは

バックエンドの各サービスは Gateway の後ろに隠れているため、Firebase Auth を直接知らなくてよいのがポイントです!

openapi.yaml とは

Cloud API Gateway の設定ファイル(Swagger 2.0 形式)です。どのパスをどのサービスへ転送するか、どのエンドポイントに JWT 認証を掛けるかを宣言的に記述します。

openapi.yaml
# JWT 検証の設定(この数行だけで全エンドポイントに Firebase Auth 認証が掛かる)
securityDefinitions:
  firebase:
    type: "oauth2"
    x-google-issuer: "https://securetoken.google.com/YOUR_PROJECT_ID"
    x-google-jwks_uri: "https://www.googleapis.com/service_accounts/v1/metadata/x509/securetoken@system.gserviceaccount.com"
    x-google-audiences: "YOUR_PROJECT_ID"

security:
  - firebase: []  # これだけで全エンドポイントに JWT 検証が掛かる

paths:
  /api/users:
    get:
      x-google-backend:
        address: https://REGION-PROJECT.cloudfunctions.net/users-service

ローカルには Cloud API Gateway が存在しません。 そこで local-gateway.ts(Express)がこの役割を代替します。

X-Apigateway-Api-Userinfo とは

Cloud API Gateway が JWT を検証した後、JWT のペイロード(uid、email、カスタムクレームなど)を base64url でエンコードして各サービスに渡す専用ヘッダー です。

X-Apigateway-Api-Userinfo: eyJzdWIiOiJ1c2VyXzEyMyIsImVtYWlsIjoiYWxpY2VAZXhhbXBsZS5jb20ifQ
                            ↓ base64url デコード
                            {"sub":"user_123","email":"alice@example.com","role":"admin"}

各サービスはこのヘッダーをデコードするだけでユーザー情報を取得できます。各サービスに Firebase SDK を組み込む必要はありません。


アーキテクチャの全体像

本番の認証フロー:

クライアント
  ↓ Bearer Token(Firebase Auth JWT)
Cloud API Gateway(openapi.yaml で設定)
  ├─ JWT 署名検証
  └─ X-Apigateway-Api-Userinfo ヘッダーを base64url で付与

各 Cloud Functions(マイクロサービス)
  └─ ヘッダーを読むだけ(firebase-admin 不要)

ローカルの認証フロー:

クライアント
  ↓ Bearer Token(Firebase Emulator JWT)
local-gateway.ts(Express、port 8090)← Cloud API Gateway の代替
  ├─ Emulator トークンを unsafe decode
  └─ X-Apigateway-Api-Userinfo を base64url で付与(本番と同じ形式)

各 Node.js サービス(port 8091〜)
  └─ ヘッダーを読むだけ(本番と同じコード)

Docker Compose 構成

Firebase Emulator と DB を Docker で立ち上げます。

# docker-compose.yml
version: "3.8"
services:
  db:
    image: mysql:8.0
    container_name: app-mysql
    environment:
      MYSQL_ROOT_PASSWORD: password
      MYSQL_DATABASE: app_db
    ports:
      - "3307:3306"

  firebase:
    image: andreysenov/firebase-tools
    container_name: app-firebase
    ports:
      - "4000:4000"  # Emulator UI
      - "9099:9099"  # Auth
      - "9199:9199"  # Storage
    command: >
      firebase emulators:start --only auth,storage,ui
      --project local-dev-project
      --import /home/node/firebase-data
      --export-on-exit
    volumes:
      - ./firebase-data:/home/node/firebase-data
      - ./firebase.json:/home/node/firebase.json
      - ./storage.rules:/home/node/storage.rules
// firebase.json
{
  "storage": { "rules": "storage.rules" },
  "emulators": {
    "auth":    { "port": 9099, "host": "0.0.0.0" },
    "storage": { "port": 9199, "host": "0.0.0.0" },
    "ui":      { "port": 4000, "host": "0.0.0.0", "enabled": true }
  }
}

host: "0.0.0.0" の指定が重要です。Docker コンテナ外(ホスト上の Node.js プロセス)から Emulator に接続するために必要です。

--import / --export-on-exit を指定すると、docker-compose down 時に自動でデータを保存し、次回起動時に復元してくれます。登録したユーザーが毎回消える問題を防げます(開発中はこれに気づかず、 up するたびに作成してました・・・)


環境変数(.env)

# Firebase Emulator
FIREBASE_AUTH_EMULATOR_HOST=localhost:9099
STORAGE_EMULATOR_HOST=http://localhost:9199
USE_EMULATOR=true

# DB(ローカル Docker)
DB_HOST=localhost
DB_PORT=3307
DB_USER=root
DB_PASSWORD=password
DB_NAME=app_db

local-gateway.ts の実装

Cloud API Gateway の役割(JWT 検証 + ヘッダー付与 + ルーティング)を Express で再現します。

// tools/local-gateway.ts

// Emulator かどうかを env var から判定
const useEmulator =
  Boolean(process.env.FIREBASE_AUTH_EMULATOR_HOST) ||
  process.env.USE_EMULATOR === "true";

if (useEmulator) {
  process.env.FIREBASE_AUTH_EMULATOR_HOST ??= "localhost:9099";
  // --project フラグ(firebase emulators:start --project xxx)と揃える
  const projectId = process.env.GCP_PROJECT_ID || "local-dev-project";
  admin.initializeApp({ projectId });
} else {
  admin.initializeApp({ credential: admin.credential.applicationDefault() });
}

// このファイルはローカル開発ツールであり、本番にデプロイされません。
// 本番環境での JWT 検証は Cloud API Gateway が担います!
function decodeUnsafe(token: string): Record<string, unknown> | null {
  try {
    const payload = token.split(".")[1];
    const json = Buffer.from(payload, "base64url").toString("utf-8");
    return JSON.parse(json);
  } catch {
    return null;
  }
}

const authMiddleware = async (req, res, next) => {
  const token = req.headers.authorization?.slice("Bearer ".length);

  // Emulator: 署名検証をスキップして直接デコード
  // 本番相当: Firebase Admin SDK で署名検証
  const decoded = useEmulator
    ? decodeUnsafe(token)
    : await admin.auth().verifyIdToken(token);

  if (!decoded) return res.status(401).json({ error: "Unauthorized" });

  // 本番の Cloud API Gateway と同じ形式でヘッダーを付与
  const encoded = Buffer.from(JSON.stringify({
    sub:      decoded.uid ?? decoded.sub,
    email:    decoded.email,
    role: decoded.role,  // カスタムクレームもそのまま転送
  })).toString("base64url");

  delete req.headers["x-apigateway-api-userinfo"];  // なりすまし防止
  req.headers["x-apigateway-api-userinfo"] = encoded;
  next();
};

// ルーティングは openapi.yaml の paths をコードで表現したもの
// { context: "/api/users", target: "http://localhost:8091" } のように定義して転送

各サービス側の認証(auth.ts)

各マイクロサービスは X-Apigateway-Api-Userinfo ヘッダーを読むだけです。Firebase SDK への依存は不要です。

// src/lib/auth.ts(各マイクロサービスで共通利用)
import { Request, Response, NextFunction } from "express";

interface AuthUser {
  uid: string;
  email?: string;
  role?: string;
}

function getUserFromHeader(req: Request): AuthUser | null {
  const raw = req.headers["x-apigateway-api-userinfo"] as string | undefined;
  if (!raw) return null;

  try {
    const json = Buffer.from(raw, "base64url").toString("utf-8");
    const parsed = JSON.parse(json);
    return { uid: parsed.sub ?? parsed.uid, email: parsed.email, role: parsed.role };
  } catch {
    return null;
  }
}

export function authenticate(req: Request, res: Response, next: NextFunction): void {
  const user = getUserFromHeader(req);
  if (!user) {
    res.status(401).json({ error: "Unauthorized" });
    return;
  }
  (req as Request & { user: AuthUser }).user = user;
  next();
}

カスタムクレームはエミュレータで自動付与されない

Firebase Auth では JWT に任意のカスタムクレームを付与できます。role"member" / "admin" / "seller" など)のようなclaimでユーザー種別を判定しているサービスでは、ここでハマるかもです。

カスタムクレームの付与には Firebase Admin SDKの setCustomUserClaims() を使います。

import { getAuth } from "firebase-admin/auth";
await getAuth().setCustomUserClaims(uid, { role: "seller" });

本番でも自動付与ではなく、ユーザー登録の API フロー内で明示的に呼び出しています。

ハマるパターン:

1. Emulator UI でテストユーザーを手動作成
2. ログインして JWT を取得
3. API を叩くと 401 / 403 が返り続ける
4. 原因:Emulator UI で作ったユーザーは API フローを通らないため、カスタムクレームが付かない

local-gateway は JWT の中身をそのまま X-Apigateway-Api-Userinfo に転送するだけなので、JWT にクレームが含まれていなければ各サービスに届きません

対応例1:シードスクリプトを用意する

開発用にユーザー作成 + クレーム付与を一括で行うスクリプトを用意しておくと便利です。

// seed-users.ts(抜粋)
const auth = getAuth();
const user = await auth.createUser({ email, password });
await auth.setCustomUserClaims(user.uid, { role });

対応例2:エミュレータ専用の API エンドポイントを用意する

本番では無効化し、エミュレータ環境でのみ有効なクレーム付与 API を作っておくと、Emulator UI で作成済みのユーザーにも後からクレームを付与できます。

// エミュレータ環境でのみ有効(本番では 403)
router.post("/api/dev/set-claims", async (req, res) => {
  if (!config.useEmulator) return res.status(403).end();
  const { uid, role } = req.body;
  await getAuth().setCustomUserClaims(uid, { role });
  res.json({ ok: true });
});

参考:Firebase Storage Emulator を使う

Storage Emulator は Auth と比べると本番との差が大きく、いくつかハマりどころがあったので紹介します。

Signed URL が使えない

本番では file.getSignedUrl() で有効期限付きの V4 署名 URL を生成して非公開ファイルへのアクセスを制御しますが、Storage Emulator ではこのメソッドが動きません。token パラメータ付きの URL で代替します。

if (config.useEmulator && config.storageEmulatorHost) {
  // token 付き URL にフォールバック
  return `${storageEmulatorHost}/v0/b/${bucket}/o/${path}?alt=media&token=${token}`;
}
// 本番: GCS V4 署名 URL を生成
const [url] = await file.getSignedUrl({ version: "v4", action: "read", expires: ... });
環境 URL 形式
本番 https://storage.googleapis.com/...?X-Goog-Signature=...
Emulator http://localhost:9199/v0/b/{bucket}/o/{path}?alt=media&token=xxx

Download Token を設定しないと 404 になる

Emulator での alt=media アクセスには firebaseStorageDownloadTokens メタデータが必要です。設定していないとファイルが存在するのに 404 が返ってきて混乱します。

await file.save(buffer, {
  metadata: {
    ...(config.useEmulator
      ? { metadata: { firebaseStorageDownloadTokens: fileId } }
      : {}),
  },
});

本番では IAM でアクセス制御するため、この設定は不要です。

バケットの自動作成ができない

バケットを動的に作る処理を書いている場合、Emulator ではこれが機能しません。Emulator のときは bucket.create() をスキップします。初回だけ Emulator UI(http://localhost:4000)でバケットを手動作成しておけば、以降は --export-on-exit / --import でデータごと復元されます。

「非公開バケット」が本番通りには機能しない

本番では IAM + Signed URL で非公開ファイルへのアクセスを制御しますが、Emulator では Signed URL が使えないため、storage.rules を開発中は allow read, write: if true にすることが多いと思います。公開・非公開の区別は Emulator では完全には再現できないので、ある程度割り切りが必要です。

フロントから直接アクセスすると CORS エラーになる

Storage Emulator の URL(http://localhost:9199/...)をフロントから直接読み込もうとすると、ブラウザの CORS チェックに引っかかります。Firebase Storage SDK の connectStorageEmulator() でエミュレータに切り替えてもうまくいかないことがありました。

バックエンドが token 付き URL を生成してレスポンスに含め、フロントは <img src="..."> で表示するだけなら CORS は出ません。Storage SDK をフロントに入れなくて済むのは、この観点でも都合がよかったです。


まとめ

X-Apigateway-Api-Userinfo ヘッダーという共通インタフェースを軸にすることで、本番とローカルで各サービスのコードを変えずに済みます。Firebase Auth を直接扱うのは Gateway レイヤーだけに集約できるので、各サービスの実装がシンプルに保てるのもうれしいところです。

レイヤー 本番 ローカル
JWT 検証 Cloud API Gateway local-gateway.ts
ヘッダー付与 X-Apigateway-Api-Userinfo(base64url) 同じ形式
各サービスのコード ヘッダーを読むだけ 変更なし
Firebase 依存 なし なし
Storage URL GCS V4 署名 URL token 付き Emulator URL

もし開発で使う機会があれば、ぜひ参考にしてみてください。ここまで読んでいただきありがとうございました!

株式会社アクトビ

Discussion