🛡️

NextAuth.js と Firebase Authentication の連携

2021/09/26に公開
4

はじめに

https://next-auth.js.org/

NextAuth.js は Next.js のために作られた OSS の認証ライブラリです。

このライブラリは主に OAuth もしくは Email で認証したユーザーの情報やセッションを連携したデータベースで自動管理できるのが売りです。他の認証システムで既に認証済みのユーザーを管理する方法もありはするのですが、OAuth や Email 認証に比べるとあまり分かりやすいサンプルコードがありません。防備録も兼ねて、ここで知見を共有したいと思います。

なお、NextAuth ではログイン中のユーザーの情報は firebase.auth.User を React Context で保持するようなことはできず、あくまでユーザー ID など JSON にシリアライズして(JWT のペイロードに含めて)管理できるものだけが対象となります。この記事では Firebase Authentication が持つ ID トークンをアプリで利用し、Cookie にログインユーザーの情報を JWT として保存してログイン状態を保てるようにすることを目的とします。

NextAuth.js や Firebase Authentication 自体への入門は膨大になってしまうのでこの記事では書きません。

ソースコード

https://github.com/elpnt/nextauth-firebase-auth-example

Vercel にデプロイしたものは下リンクにあります。

https://nextauth-firebase-auth-example.vercel.app

準備

Next.js アプリの新規作成

npx create-next-app --ts nextauth-firebaseauth-example
cd nextauth-firebaseauth-example
npm install next-auth@beta firebase firebase-admin

パスエイリアス

これは個人の好みですが、コードは全てプロジェクトルートに src/ ディレクトリを作成してその中で管理し、 @/ のエイリアスを貼っておきます。

tsconfig.json
{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["src/*"]
    }
}

Firebase の設定

Firebase コンソールから新規プロジェクトを作成し、

  • ウェブアプリの有効化
  • Firebase Admin SDK 用の秘密鍵の作成(JSON がダウンロードされる)

をしておきます。

ウェブアプリの設定値と、管理者用秘密鍵の project_idclient_emailprivate_key.env.local にコピペし、環境変数に含めます(参考:柔軟に firebase admin を初期化する)。

.env.local
# Firebase SDK の初期化に必要
NEXT_PUBLIC_FIREBASE_API_KEY=<apiKey>
NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN=<authDomain>
NEXT_PUBLIC_FIREBASE_PROJECT_ID=<projectId>
NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET=<storageBucket>
NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID=<messagingSenderId>
NEXT_PUBLIC_FIREBASE_APP_ID=<appId>
NEXT_PUBLIC_FIREBASE_MEASUREMENT_ID=<measurementId>

# Firebase Admin SDK の初期化に必要
FIREBASE_PROJECT_ID=<project_id>
FIREBASE_CLIENT_EMAIL=<client_email>
FIREBASE_PRIVATE_KEY=<private_key>

Firebase ライブラリの初期化

Firebase SDK と Firebase Admin SDK を初期化します。

Firebase SDK

src/lib/firebase.ts
import { initializeApp } from "firebase/app";
import type { FirebaseOptions } from "firebase/app";

const firebaseConfig: FirebaseOptions = {
  apiKey: process.env.NEXT_PUBLIC_FIREBASE_API_KEY,
  authDomain: process.env.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN,
  databaseURL: process.env.NEXT_PUBLIC_FIREBASE_DATABASE_URL,
  projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID,
  storageBucket: process.env.NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET,
  messagingSenderId: process.env.NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID,
  appId: process.env.NEXT_PUBLIC_FIREBASE_APP_ID,
  measurementId: process.env.NEXT_PUBLIC_FIREBASE_MEASUREMENT_ID,
};

export const firebaseApp = initializeApp(firebaseConfig);

Firebase Admin SDK

src/lib/firebaseAdmin.ts
import * as admin from "firebase-admin";
import type { ServiceAccount } from "firebase-admin";

const cert: ServiceAccount = {
  projectId: process.env.FIREBASE_PROJECT_ID,
  clientEmail: process.env.FIREBASE_CLIENT_EMAIL,
  privateKey: process.env.FIREBASE_PRIVATE_KEY.replace(/\\n/g, "\n"),
};

export const firebaseAdmin =
  admin.apps[0] ??
  admin.initializeApp({
    credential: admin.credential.cert(cert),
  });

NextAuth の設定

公式ドキュメントの Example Code に従い SessionProvider を導入します。

src/pages/_app.tsx
import type { AppProps } from "next/app";
import { SessionProvider } from "next-auth/react";

function MyApp({
  Component,
  pageProps: { session, ...pageProps }
}: AppProps) {
  return (
    <SessionProvider session={session}>
      <Component {...pageProps} />
    </SessionProvider>
  );
}
export default MyApp;

認証フロー

先に認証フローを把握しておきましょう。

  1. ユーザーはアプリのログインボタンを押し、Firebase Authentication がユーザー認証を完了する。この時点では、Firebase Authentication へのユーザー登録はされているがアプリには何も反映されていない。
  2. Firebase SDK は登録ユーザーの ID トークンをアプリに渡す。
  3. アプリは受け取った ID トークンを JWT で Cookie に保存し、アプリでのユーザー認証を完了する。

ログインページ

ここでは Firebase Authentication でログインし、ID トークンを得ることが目的です。ログインプロバイダには GitHub と Google を選びました。

src/pages/auth/signin.tsx
import { signIn } from "next-auth/react";
import {
  getAuth,
  signInWithPopup,
  GithubAuthProvider,
  GoogleAuthProvider,
} from "firebase/auth";
import type { AuthProvider } from "firebase/auth";
import { firebaseApp } from "@/lib/firebase";

export default function singIn() {
  const auth = getAuth(firebaseApp);
  const githubProvider = new GithubAuthProvider();
  const googleProvider = new GoogleAuthProvider();

  const handleOAuthSignIn = (provider: AuthProvider) => {
    signInWithPopup(auth, provider)
      // 認証に成功したら ID トークンを NextAuth に渡す
      .then((credential) => credential.user.getIdToken(true))
      .then((idToken) => {
        signIn("credentials", { idToken });
      })
      .catch((err) => console.error(err));
  };

  return (
    <>
      <p>Choose your sign-in method:</p>
      <button onClick={() => handleOAuthSignIn(githubProvider)}>GitHub</button>
      <br />
      <button onClick={() => handleOAuthSignIn(googleProvider)}>Google</button>
    </>
  );
}

重要なのは handleOAuthSignIn() の中の以下の処理です。

signIn("credentials", { idToken });

signIn() 関数に渡す引数は 2 つあり、

  1. 文字列 "credentials": NextAuth で使用するプロバイダの ID
    NextAuth は外部認証システムの情報を用いる際には CredentialsProvider というプロバイダを用い、CredentialsProvider の ID は "credentials" です。
  2. オブジェクト { idToken: idToken }CredentialsProvider がユーザー認証にするのに必要な値

NextAuth API ルート

参考: Credentials Provider

src/pages/api/auth/[...nextauth].ts
import NextAuth from "next-auth";
import CredentialsProvider from "next-auth/providers/credentials";
import { firebaseAdmin } from "@/lib/firebaseAdmin";

export default NextAuth({
  providers: [
    CredentialsProvider({
      authorize: async (credentials, req) => {
        const { idToken } = credentials;
        if (idToken != null) {
          try {
            const decoded = await firebaseAdmin.auth().verifyIdToken(idToken);
            return { ...decoded };
          } catch (error) {
            console.log("Failed to verify ID token:", error);
          }
        }
        return null;
      },
    }),
  ],
  callbacks: {
    jwt: async ({ token, user }) => {
      if (user) {
        token = user;
      }
      return token;
    }
  }
});

CredentialsProvider

先のログインページで出た

signIn("credentials", { idToken });

{ idToken }authorize(credentials, req) の第 1 引数に credentials として渡されるオブジェクトです。

authorize() では firebase-admin により ID トークンを JSON にデコードして検証が通ればそのトークンを返し、検証に失敗したらエラーとして null を返します。

JWT コールバック

callbacks オプションの jwt() はログイン時など、JWT が作成・更新されたとき呼ばれるコールバックです。

jwt({ token, user }) の引数のオブジェクトプロパティのうち、token は JWT を、userauthorize() で返ってくるオブジェクトを表します。つまりここでの user の実体は DecodedIdToken です。
上のコードでは NextAuth の発行する JWT のペイロードをデコードされた ID トークンに置き換えていますが、例えば user の持っている情報のうち一部のみが必要でればそのように書き換えることも可能です。

例:ユーザーIDだけ利用する
callbacks: {
  jwt: async ({ token, user }) => {
    if (user) {
      token.sub = user.sub;
    }
    return token;
  }
}

作成された JWT は next-auth.session-token として Cookie に保存され、jwt.io などでこの Cookie をデコードすると、ペイロードは HS512 で署名された Firebase Authentication の ID トークンに一致することが確認できます。

※ HS512 は NextAuth のデフォルトでの署名アルゴリズム

JWT

認証は以上となります。

参考記事

Discussion

elpntelpnt

コメントありがとうございます。

実は自分も最初はその adapter で実装できると思っていました。が、実際に使用してみると分かるとおり、NextAuth は Firebase Authentication とは別に Firestore にユーザー管理用のコレクションを作ってしまいます(コンソールで確認すると accounts, sessions, users が自動で作成されます)。

Firestore

adapter の初期化に firestore を要求してくるのはこういう訳ですね。

プログラミングをするパンダプログラミングをするパンダ

確かに今回は認証のみにフォーカスした内容でDBまでは不要でしたね :pray:

スクショまで貼っていただきありがとうございます。firebase を使うときに記事内容参考にさせて頂こうと思います😊

FeliFeli

I dont understand japanese but I could understand all the code, thanks for your colaboration.