【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 認証を掛けるかを宣言的に記述します。
# 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