RemixからFirebase Authenticationで認証する

RemixからFirebase Authenticationを使って認証する実装をしていたのだけど、少しフローが複雑でドキュメントを読んでも理解に時間がかかったので、ブログに整理しておく。

認証フロー

一般的なメールアドレスとパスワードによる認証を前提として進める。

RemixとFirebase Authの間で認証するフローを表したシーケンス図

  1. ブラウザからメールアドレスとパスワードをFirebase Authenticationに送る。認証に成功すると、ユーザーIDやメールアドレスなどを含むクレデンシャルが返ってくる。サーバーではメールアドレスやパスワードを扱わないようにブラウザから送るようにするのがポイント。
  2. 受け取ったクレデンシャルからIDトークンと呼ばれるJWTが得られるので、サーバーに送る。
  3. サーバーでは受け取ったIDトークンが不正に改ざんされていないかを検証する必要があるため、Firebase Authencationから公開鍵を取得し、IDトークンの署名を確認する。また、公開鍵のレスポンスにはmax-ageがセットされているため、適切にキャッシュする。
  4. IDトークンがFirebase Authencationの秘密鍵によって署名されたことが確認できたら、IDトークンからセッションCookieを生成し、レスポンスヘッダーのSet-Cookieにセットしてブラウザに返す。
  5. 以降はサーバーはリクエストヘッダーのCookieからセッションCookieを取得し、そこに含まれるIDトークンを再度検証することで認証状態を維持する。

実装

Remixにおける簡易的な実装例を載せる。

IDトークンの取得

// app/routes/login.tsx
export default function Login() {
  const submit = useSubmit();

  const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
    event.preventDefault();

    // formDataからemailとpasswordを取得

    try {
      const idToken = await loginFirebaseAuth(email, password);
      submit({ idToken }, { method: "post" });
    } catch (error) {
      // ...
    }
  };

  return (
    <form method="post" onSubmit={handleSubmit}>
      {/*  snip */}
    </form>
  );
}
  • RemixのFormコンポーネントを使うと入力した値をそのままサーバーに送ってしまうので、useSubmitフックを使ってFirebase Authenticationから受け取ったIDトークンを送るようにする。
  • onSubmit内でFirebase Authenticationとのやり取りを行う。詳細な実装はコンポーネント内には実装せず別途関数を用意しておく。
// app/modules/firebase.client.ts
import { initializeApp } from "firebase/app";
import { getAuth, signInWithEmailAndPassword } from "firebase/auth";

const firebaseApp = initializeApp({
  apiKey: "xxx",
  authDomain: "xxx",
  prejectId: "xxx",
  storageBucket: "xxx",
  messagingSenderId: "xxx",
  appId: "xxx",
});

export async function loginFirebaseAuth(email: string, password: string) {
  const firebaseAuth = getAuth(firebaseApp);
  const userCredential = await signInWithEmailAndPassword(
    firebaseAuth,
    email,
    password,
  );
  return await userCredential.user.getIdToken();
}
  • クライアントのみで実行する意図があるため*.client.tsとsuffixを付ける。
  • Firebase SDKを使い、アプリを初期化する。apiKeyなどの値はクライアント側で利用されることを意図して設計されており、公開されてもセキュリティ上は問題ないとされている(参考)。
  • loginFirebaseAuth()でFirebase Authenticationとの認証を行い、成功すればIDトークンを取得して返している。

IDトークンの検証とセッションCookieの生成

// app/routes/login.tsx

export async function action({ request }: ActionFunctionArgs) {
  const formData = await request.formData();
  const idToken = formData.get("idToken");

  try {
    const headers = await createSessionCookieHeaders(idToken);
    return redirect("/", { headers });
  } catch (error) {
    // ...
  }
}
  • ルートコンポーネントのaction()でサーバー側の実装を書く。受け取ったIDトークンが改ざんされていないかを検証しセッションCookieを生成するため、別ファイルに用意してある関数を使う。
  • その後生成されたレスポンスヘッダーを付与してリダイレクトする。
// app/modules/session.server.ts
import { createSessionCookie, verifySessionCookie } from "./firebase.server";

const sessionCookieName = "secret";

export async function createSessionCookieHeaders(idToken: string) {
  const headers = new Headers();
  const expiresIn = 1000 * 60 * 60 * 24 * 5; // 5 days
  const sessionCookie = await createSessionCookie(idToken, expiresIn);
  headers.append(
    "Set-Cookie",
    `${sessionCookieName}=${sessionCookie}; Max-Age=${expiresIn}; Path=/; HttpOnly; Secure; SameSite=Strict`,
  );
  return headers;
}
  • セッションを扱うモジュールをサーバーのみで扱いたいのでsession.server.tsと名付ける。
  • セッションCookieを生成する関数はFirebase Admin SDKの機能を利用するため、別モジュールを呼び出すようにしている。このモジュールではSet-Cookieヘッダーに適切な値を設定して返している。
// app/modules/firebase.server.ts
import admin from "firebase-admin";
import { getAuth } from "firebase-admin/auth";

const firebaseApp = admin.initializeApp(
  {
    credential: admin.credential.cert({
      projectId: process.env.FIREBASE_PROJECT_ID,
      clientEmail: process.env.FIREBASE_CLIENT_EMAIL,
      privateKey: process.env.FIREBASE_PRIVATE_KEY?.replace(/\\n/g, "\n"),
    }),
  },
  "sample-app"
);

export async function createSessionCookie(idToken: string, expiresIn: number) {
  return await getAuth(firebaseApp).createSessionCookie(idToken, {
    expiresIn,
  });
}
  • 先ほど紹介したfirebase.client.tsとは異なりサーバー側でのみ実行されることを意図しているため、*.server.tsとsuffixをつけている。
  • クライアント側ではFirebase SDKを使っていたが、サーバー側ではFirebase Admin SDKを使う。アプリの初期化にはJSONファイルを用いてアプリケーションデフォルト認証による初期化をおこなうこともできるが、Vercelなどのサーバーレス環境ではJSONファイルを扱いにくいため、上記のように環境変数を使った認証にしている。
  • Firebase Admin SDKで初期化したアプリを使いcreateSessionCookie()によってIDトークンを検証しつつ、IDトークンからセッションCookieを生成する。このとき内部的にFirebase Authenticationから公開鍵を取得しており、キャッシュされていればキャッシュを利用するようになっている。

セッションCookieの検証

// app/routes/_index.tsx

export async function loader({ request }: LoaderFunctionArgs) {
  const decodedToken = await verifySessionCookieHeaders(request.headers);
  if (decodedToken === null) {
    return redirect("/login");
  }

  // ...
}
  • 認証が必要なページのloader()で必ずセッションCookieを検証するようにする。
// app/modules/session.server.ts

export async function verifySessionCookieHeaders(headers: Headers) {
  const cookieHeader = headers.get("Cookie") || "";
  const cookies = Object.fromEntries(
    cookieHeader.split("; ").map((cookie) => cookie.split("=")),
  );
  const sessionCookie = cookies[sessionCookieName];

  try {
    return await verifySessionCookie(sessionCookie);
  } catch (error) {
    return null;
  }
}
  • CookieヘッダーからセッションCookieの値を取り出して、別モジュールの関数にわたす。
// app/modules/firebase.server.ts

export async function verifySessionCookie(sessionCookie: string) {
  return await getAuth(firebaseApp).verifySessionCookie(sessionCookie);
}
  • Firebase Admin SDKを使いセッションCookieを検証する。