0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Firebase AuthをNext.js SSRと将来のモバイルアプリで共用する認証設計

0
Posted at

はじめに

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を使いながら、将来のクライアント追加にも対応しやすい構成になりました。

参考資料

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?