はじめに
EO PLACE(エオプレイス)は、FF14のプレイヤー店舗やハウジングを写真から探し、保存し、ゲーム内で訪問できる個人開発のWebサービスです。
Web版はNext.jsのServer Componentsでログイン状態を扱いたい一方、将来スマートフォンアプリを作る場合はCookieへ依存しないAPI認証が必要です。そこで、アカウント管理はFirebase Authenticationへ任せ、バックエンドは次の2経路を受け付ける構成にしました。
| クライアント | 認証情報 | サーバーでの検証 |
|---|---|---|
| Webブラウザ |
httpOnlyセッションCookie |
verifySessionCookie() |
| モバイルアプリ等 | Authorization: Bearer <ID token> |
verifyIdToken() |
ユーザーIDの発行元はどちらもFirebaseです。認証方式を2種類持っても、認可に使うキーは同じFirebase UIDへそろえます。
全体の流れ
Webログインは次の流れです。
ブラウザ
└─ Firebase Client SDKでログイン
└─ ID tokenを取得
└─ POST /api/auth/session
└─ Admin SDKでID tokenを検証
└─ httpOnly Cookieを発行
└─ SSR/APIでCookieを検証
モバイルアプリはセッションCookieへの交換を行わず、Firebase ID tokenをAPIのBearer tokenとして送ります。
モバイルアプリ
└─ Firebase SDKでログイン
└─ Authorization: Bearer <ID token>
└─ APIでID tokenを検証
Firebase公式のセッションCookie方式も、クライアントで得たID tokenをサーバーへ送り、Admin SDKでCookieへ交換する構成です(Manage Session Cookies)。
WebではID tokenをhttpOnly Cookieへ交換する
ログイン直後、ブラウザからセッション作成APIへID tokenを送ります。簡略化したRoute Handlerです。
const FIVE_DAYS = 60 * 60 * 24 * 5 * 1000;
export async function POST(request: NextRequest) {
if (!hasTrustedOrigin(request)) {
return Response.json({ error: "Invalid origin" }, { status: 403 });
}
const { idToken } = await request.json();
const decoded = await adminAuth.verifyIdToken(idToken);
const signedInAt = decoded.auth_time * 1000;
if (Date.now() - signedInAt > 5 * 60 * 1000) {
return Response.json({ error: "Recent sign-in required" }, { status: 401 });
}
const sessionCookie = await adminAuth.createSessionCookie(idToken, {
expiresIn: FIVE_DAYS
});
const response = NextResponse.json({ ok: true });
response.cookies.set("session", sessionCookie, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
maxAge: FIVE_DAYS / 1000
});
return response;
}
httpOnlyにより、ブラウザ内JavaScriptからセッションCookieを読めません。secureは本番でHTTPSに限定し、sameSite: "lax"も設定しています。
また、取得から長時間経過したID tokenをCookieへ交換しないよう、auth_timeから5分以内のログインを要求しました。退会など特に重要な操作には、別途より短い「直近の認証」を要求しています。
メール確認済みかをサーバーでも検査する
Googleログインに加えてメールアドレスとパスワードでも登録できます。確認メールを送るだけでは、画面を迂回してセッションAPIを直接呼ばれる可能性があります。
そのため、passwordプロバイダーの場合はセッション作成時にemail_verifiedを検査します。
if (
decoded.firebase.sign_in_provider === "password" &&
decoded.email_verified !== true
) {
return Response.json(
{ error: "Email verification required" },
{ status: 403 }
);
}
クライアント側の表示制御は操作性のため、サーバー側の検査はセキュリティのためです。重要な条件はAPI側にも置きます。
SSRではセッションCookieを検証する
Server Componentから現在のユーザーを得る関数は、Cookieを読み、Admin SDKで検証します。
export async function getCurrentUser() {
const sessionCookie = (await cookies()).get("session")?.value;
if (!sessionCookie) return undefined;
try {
const token = await adminAuth.verifySessionCookie(sessionCookie, true);
return {
uid: token.uid,
email: token.email,
name: token.name,
authTime: token.auth_time
};
} catch {
return undefined;
}
}
第2引数のtrueで失効確認も行います。Firebaseの公式ドキュメントでは、失効確認には追加のネットワークリクエストが発生する点も説明されています。ここはセキュリティ要件と負荷のトレードオフです。
セッションCookieを検証できたことは「誰か」が分かっただけです。店舗を編集できるか、限定公開を閲覧できるかといった認可は、必ず別途DBで検査します。
APIはBearerを優先し、なければCookieを見る
Webと将来のアプリでRoute Handlerを分けずに済むよう、共通の認証関数を作りました。
export async function authenticateApiRequest(request: NextRequest) {
const authorization = request.headers.get("authorization");
if (authorization?.startsWith("Bearer ")) {
const idToken = authorization.slice(7).trim();
if (!idToken) return undefined;
try {
return toApiUser(await adminAuth.verifyIdToken(idToken, true));
} catch {
return undefined;
}
}
const sessionCookie = request.cookies.get("session")?.value;
if (!sessionCookie) return undefined;
try {
return toApiUser(await adminAuth.verifySessionCookie(sessionCookie, true));
} catch {
return undefined;
}
}
verifyIdToken()は、単にJWTの中身をデコードする処理ではありません。Firebase Admin SDKで署名やclaimを検証します。失効検出についてはFirebase公式のセッション管理も参照できます。
Bearer tokenを受け取ったら、Cookieへフォールバックさせないのも意図した挙動です。不正なBearer tokenが付いているのに、たまたまブラウザCookieで成功するような曖昧な状態を避けます。
CSRF対策はCookie経路とBearer経路を分けて考える
Cookieはブラウザが自動送信するため、更新系APIではCSRFを考える必要があります。EO PLACEではCookieを使うリクエストのOriginを、設定したアプリのoriginと完全一致で比較します。
export function hasTrustedOrigin(request: NextRequest) {
if (request.headers.get("authorization")?.startsWith("Bearer ")) {
return true;
}
const origin = request.headers.get("origin");
if (!origin) return false;
const expectedOrigin = process.env.APP_ORIGIN?.replace(/\/$/, "");
return origin === expectedOrigin;
}
ネイティブアプリのBearer tokenはブラウザCookieのように別サイトから自動送信されないため、このOrigin検査の対象外にしています。ただし、Bearer token自体の検証と各操作の認可は省略しません。
この関数は更新系APIで使います。参照系APIはOriginで拒否せず、閲覧者のUIDに応じて返せるデータだけをDBから取得します。
FirebaseはAuthだけ、アプリデータはPostgreSQL
Firebase側には認証アカウントを置き、表示名、プロフィール、店舗、フォロー、保存などはPostgreSQLへ保存しています。
Firebase Authentication
└─ uid
└─ PostgreSQL profiles.firebase_uid
├─ shops.owner_uid
├─ profile_follows
└─ saved_shops
この分離には次の利点がありました。
- Googleログインとメール/パスワード認証を同じUIDで扱える
- Webとモバイルで認証元を共通化できる
- 検索や関連データはRDBで扱える
- Firebaseのクライアント設定とAdmin秘密鍵の役割を分けられる
Firebase Web API keyはクライアント設定に含まれる値ですが、Admin SDKの秘密鍵はサーバーだけに置きます。NEXT_PUBLIC_等でAdmin資格情報を配布してはいけません。
アカウント作成時の利用規約同意もAPIで守る
初回ログイン時にはプロフィールを作成しますが、既存ユーザーのログインと新規登録を同じ入口にすると、利用規約同意をどこで確定するかが曖昧になります。
現在はセッション作成APIへ規約同意と規約バージョンを渡し、新規プロフィールがない場合だけ現行規約への同意を必須にしています。既存ユーザーの通常ログインでは再同意を要求しません。
const accepted = acceptTerms === true && termsVersion === CURRENT_TERMS_VERSION;
if (!existingProfile && !accepted) {
return Response.json(
{ error: "Registration requires terms acceptance" },
{ status: 400 }
);
}
これも登録画面のチェックボックスだけに任せず、サーバーで判断しています。
実装して分かったこと
- SSRのWebには
httpOnlyセッションCookieが扱いやすい - 将来のネイティブアプリにはFirebase ID tokenのBearer認証を使える
- 両経路を最終的に同じUIDへ正規化すると、認可ロジックを共有できる
- Cookieを使う更新APIではCSRF対策が必要
- メール確認、規約同意、アカウント停止は画面だけでなくAPIでも検査する
- 「認証済み」と「そのデータを操作可能」は別問題
- 秘密のAdmin資格情報と公開前提のWeb設定を混同しない
最初からモバイルアプリを作る必要はありません。しかしAPIの認証境界をCookieだけに固定しなかったことで、Next.jsのSSRを使いながら、将来のクライアント追加にも対応しやすい構成になりました。