🔐

【Next.js】NextAuth×Firebaseで認証管理 in appディレクトリ

2023/02/14に公開
2

はじめに

Next.js 13から利用可能になったappディレクトリでは、コンポーネント単位でSSRができるのでよりUXを向上させる実装が可能になりました。

個人開発者の強い味方であるFirebaseと合わせて利用するために、NextAuthとFirebase Authenticationを利用して、サーバサイドでも認証情報を参照できるようにするやり方の紹介です。
今回はEメール&パスワードを用いた認証を利用しますが、基本的にFirebase Authenticationが用意しているどの認証方法でも利用可能です。

前提条件

今回の記事で利用しているライブラリのバージョンは下記のとおりです。

package.json
{
  "name": "auth-test-app",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "next lint"
  },
  "dependencies": {
    "@next/font": "13.1.6",
    "@types/node": "18.13.0",
    "@types/react": "18.0.28",
    "@types/react-dom": "18.0.10",
    "eslint": "8.34.0",
    "eslint-config-next": "13.1.6",
    "firebase": "^9.17.1",
    "firebase-admin": "^11.5.0",
    "next": "13.1.6",
    "next-auth": "^4.19.2",
    "react": "18.2.0",
    "react-dom": "18.2.0",
    "typescript": "4.9.5"
  }
}

環境構築

Firebaseプロジェクト作成

Firebaseプロジェクトの作成方法

Firebaseコンソールからプロジェクト作成

ここはどちらでも良いです

始めるをクリック

メール / パスワードを選択して以下の通りに設定

プロジェクトの設定からウェブを追加

この後下記firebaseConfigの中身を利用するので、どこかに控えておいてください(後からでも参照できます)

プロジェクト作成後、Authenticationからメール / パスワードプロバイダを有効にしておいてください。

秘密鍵の生成

admin sdkを利用するため、秘密鍵をダウンロードし、プロジェクトルートに配置してください。

このとき、ダウンロードしたjsonファイルの名前を変更し、.gitignoreに記載しておいてください。
※ファイル名の変更は任意です。

.gitignore
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.

# dependencies
/node_modules
/.pnp
.pnp.js

# testing
/coverage

# next.js
/.next/
/out/

# production
/build

# misc
.DS_Store
*.pem

# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*

# local env files
.env*.local

# vercel
.vercel

# typescript
*.tsbuildinfo
next-env.d.ts

+ # Firebase
+ firebaseSecretKey.json # ←ダウンロードした秘密鍵

テストアカウント作成

Firebaseコンソールからテスト用アカウントを作成します。
メールアドレス/PWはお好きなものをご利用ください。

開発環境作成

npx create-next-app@latest --experimental-app
コマンド実行ログ
What is your project named? › auth-test-app
Would you like to use TypeScript with this project? › Yes
Would you like to use ESLint with this project? › Yes # ← NoでもOKです
Would you like to use `src/` directory with this project? › Yes # ← NoでもOKです
What import alias would you like configured? › @/*
Creating a new Next.js app in /Users/hiro/src/auth-test-app.

Using npm.

Installing dependencies:
- react
- react-dom
- next
- @next/font
- typescript
- @types/react
- @types/node
- @types/react-dom
- eslint
- eslint-config-next


added 270 packages, and audited 271 packages in 16s

102 packages are looking for funding
  run `npm fund` for details

found 0 vulnerabilities

Initializing project with template: app 

Initialized a git repository.

Success! Created auth-test-app at /Users/user/src/auth-test-app

Firebase SDKの設定

.env

プロジェクトのルートディレクトリに.envを作成し、以下のようにFirebaseのconfigを記述します。

NEXT_PUBLIC_API_KEY=AIzaSyBwX5ygcz9oD-sfe1ESox_Z8ulFeW5Xn_U
NEXT_PUBLIC_AUTH_DOMAIN=auth-test-app-140df.firebaseapp.com
NEXT_PUBLIC_PROJECT_ID=auth-test-app-140df
NEXT_PUBLIC_STORAGE_BUCKET=auth-test-app-140df.appspot.com
NEXT_PUBLIC_MESSAGIN_SENDER_ID=528579025242
NEXT_PUBLIC_APP_ID=1:528579025242:web:517529998dcf25a0ecfb63

firebase/client

Firebase client SDKの初期化設定のため、srcディレクトリ配下にfirebaseディレクトリを作成し、client.tsを作成してください。
client SDKはログイン認証で利用します。

src/firebase/client.ts
import { initializeApp, getApps } from "firebase/app";
import { getAuth } from "firebase/auth";

const firebaseConfig = {
  apiKey: process.env.NEXT_PUBLIC_API_KEY,
  authDomain: process.env.NEXT_PUBLIC_AUTH_DOMAIN,
  databaseURL: process.env.NEXT_PUBLIC_DATABASE_URL,
  projectId: process.env.NEXT_PUBLIC_PROJECT_ID,
  storageBucket: process.env.NEXT_PUBLIC_STORAGE_BUCKET,
  messagingSenderId: process.env.NEXT_PUBLIC_MESSAGING_SENDER_ID,
  appId: process.env.NEXT_PUBLIC_APP_ID,
  measurementId: process.env.NEXT_PUBLIC_MEASUREMENT_ID,
};

const app = getApps()?.length ? getApps()[0] : initializeApp(firebaseConfig);

export const auth = getAuth(app);

firebase/admin

続いて、Firebase admin SDKの初期化設定のため、firebaseディレクトリにadmin.tsを作成してください。
admin SDKはNextAuthのcredential認証の際に、クライアントから送られたトークンの検証に利用します。

src/firebase/admin.ts
import { initializeApp, cert, getApps } from "firebase-admin/app";
import { getAuth } from "firebase-admin/auth";

const serviceAccount = require("/firebaseSecretKey.json");
export const firebaseAdmin =
  getApps()[0] ??
  initializeApp({
    credential: cert(serviceAccount),
  });

export const auth = getAuth();

各種必要なライブラリをインストール

NextAuth install

SSRでも認証情報を参照できるようにするために、NextAuthをインストールします。

cd auth-test-app
npm i next-auth

Firebase install

Firebase SDKをインストールします。
サーバ側でトークンの検証を行うために、client SDKの他にadmin SDKもあわせてインストールしておきます。

npm i firebase firebase-admin

NextAuth関連

NEXTAUTH_SECRETの登録

JWTを暗号化し、トークンをハッシュするために利用するシークレットを登録します。
シークレットの値は何でも良いですが、以下コマンドをターミナル等で叩いて生成される値を利用しましょう。

openssl rand -base64 32
.env.local
# Next Auth
NEXTAUTH_SECRET=rDo15EMKxXGIkjDfbkgoBWgKFg4PwoeLnYzPY6RYNSs=

SessionProviderの作成

NextAuthはクライアントコードでsessionを閲覧(useSession()を利用)するために、SessionProviderでラップしておく必要があります。
https://next-auth.js.org/getting-started/client

SessionProviderはクライアントコンポーネントで利用しなければいけないですが、Next.js 13のappディレクトリはデフォルトでサーバコンポーネントのため、任意のディレクトリにSessionProvider.tsxというコンポーネントを作成します。

src/provider/SessionProvider.tsx
'use client';

import { SessionProvider as NextAuthSessionProvider } from 'next-auth/react';

export interface SessionProviderProps {
  children: React.ReactNode;
}

const SessionProvider = ({ children }: SessionProviderProps) => {
  return <NextAuthSessionProvider>{children}</NextAuthSessionProvider>;
};

export default SessionProvider;

先程作成したSessionProvider.tsxを用いてアプリケーション全体をラップします。

/app/layout.tsx
import SessionProvider from "../provider/SessionProvider";

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <SessionProvider>
      <html lang="ja">
        <head />
        <body>{children}</body>
      </html>
    </SessionProvider>
  );
}

ログイン画面&クライアント認証作成

ログイン画面を作成し、Firebase client SDKのsignInWithEmailAndPasswordを用いて認証し、idTokenを取得します。
認証後、NextAuthのsignInメソッドに対して取得したidTokenと認証後のリダイレクト先URLを渡します。

具体的には、/src/appsigninディレクトリを作成し、page.tsxに以下のように記述します。

/src/app/signin/page.tsx
"use client";

import { useState } from "react";
import { signInWithEmailAndPassword } from "firebase/auth";
import { auth } from "@/firebase/client";
import { signIn as signInByNextAuth } from "next-auth/react";

const SingIn = () => {
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");

  const signIn = async () => {
    if (!email) return;
    if (!password) return;

    try {
      const userCredential = await signInWithEmailAndPassword(
        auth,
        email,
        password
      );
      const idToken = await userCredential.user.getIdToken();
      await signInByNextAuth("credentials", {
        idToken,
        callbackUrl: "/",
      });
    } catch (e) {
      console.error(e);
    }
  };

  return (
      <div>
        <input
          type="email"
          value={email}
          onChange={(event) => setEmail(event.target.value)}
          placeholder="メールアドレス"
        />
        <input
          type="password"
          value={password}
          onChange={(event) => setPassword(event.target.value)}
          placeholder="パスワード"
        />
        <button
          type="button"
          onClick={() => {
            signIn();
          }}
        >
          ログイン
        </button>
      </div>
  );
};

export default SingIn;

NextAuth configの設定

NextAuthでクライアントから送られたトークンを検証し、セッションに格納する設定を記述します。

トークンの検証

authOptionCredentialsProviderでクライアントから送られたidTokenの検証を行います。
ユーザ認証が完了した際に実行するコールバックをauthorizeプロパティに記述します。
https://next-auth.js.org/configuration/providers/credentials

第1引数にcredentialsを受け取ります。
ここで受け取るcredentialsは先程NextAuthのsignInメソッドに渡したidTokenが渡ってくるので、分割代入で受け取ります。
その後、admin SDKを利用して受け取ったidTokenが正しいものか検証しつつ、ユーザ情報を返します。

また、サーバコンポーネントでセッションを確認する際にここで定義したauthOptionを渡す必要があるため、宣言と同時にexportします

/pages/api/auth/[...nextauth].ts
import NextAuth from 'next-auth';
import type { NextAuthOptions } from 'next-auth';

import CredentialsProvider from 'next-auth/providers/credentials';

import { auth } from '@/firebase/admin';

export const authOptions: NextAuthOptions = {
  providers: [
    CredentialsProvider({
      credentials: {},
      authorize: async ({ idToken }: any, _req) => {
        if (idToken) {
          try {
            const decoded = await auth.verifyIdToken(idToken);

            return { ...decoded };
          } catch (err) {
            console.error(err);
          }
        }
        return null;
      },
    }),
  ],
};

export default NextAuth(authOptions);

続いて、authorizeで返したユーザ情報をSessionに格納するために、以下を追記します。

/pages/api/auth/[...nextauth].ts
import NextAuth from "next-auth";
import type { NextAuthOptions } from "next-auth";

import CredentialsProvider from "next-auth/providers/credentials";

import { auth } from "@/firebase/admin";

export const authOptions: NextAuthOptions = {
  providers: [
    CredentialsProvider({
      credentials: {},
      authorize: async ({ idToken }: any, _req) => {
        if (idToken) {
          try {
            const decoded = await auth.verifyIdToken(idToken);

            return { ...decoded };
          } catch (err) {
            console.error(err);
          }
        }
        return null;
      },
    }),
  ],
+ session: {
+   strategy: "jwt",
+ },
+ callbacks: {
+   async jwt({ token, user }) {
+     return { ...token, ...user };
+   },
+   // sessionにJWTトークンからのユーザ情報を格納
+   async session({ session, token }) {
+     session.user.emailVerified = token.emailVerified;
+     session.user.uid = token.uid;
+     return session;
+   },
+ },
};

export default NextAuth(authOptions);

callbacksjwtからreturnした値がsessionの引数から参照できるようになり、sessionreturnした値がアプリケーションコードで参照するsession情報に格納されます。

流れは以下のとおりです。

  1. authorizeでユーザ認証し、取得したユーザ情報を返す
  2. callbacksjwtで、1から返されたユーザ情報を取得し、返す
  3. callbackssessionで、2から返されたユーザ情報を取得し、Sessionに格納する
  4. アプリケーションコードでuseSession()getServerSession()を利用して3から返された情報を取得する

型定義

TypeScriptを利用していると、Firebaseのユーザ情報を参照している箇所で型エラーが出てしまうため、型定義をします。

@types/next-auth.d.ts
import NextAuth, { DefaultSession } from 'next-auth';
import { JWT } from 'next-auth/jwt';

declare module 'next-auth' {
  interface Session {
    user: {
      // Firebaseの認証情報
      uid: string;
      emailVerified?: boolean;
    } & DefaultSession['user'];
  }
}

declare module 'next-auth/jwt' {
  interface JWT {
    // Firebaseの認証情報
    uid: string;
    emailVerified: boolean;
  }
}

ログイン後の画面でユーザ情報を取得

ログイン後にリダイレクトする画面(今回は/)でログインしているユーザ情報を取得します。
クライアントコンポーネント、サーバコンポーネントで取得方法が異なるので、それぞれの取得方法を記述しておきます。

クライアントコンポーネント

src/components/ClientComponent.tsx
"use client";

import { useSession } from "next-auth/react";

const ClientComponent = () => {
  const { data: session } = useSession();
  const user = session?.user;
  return <p>{JSON.stringify(user)}</p>;
};

export default ClientComponent;

サーバコンポーネント

src/components/ServerComponent.tsx
import { getServerSession } from "next-auth/next";
// path/toは適宜書き換えてください
import { authOptions } from "path/to/api/auth/[...nextauth]";

const ServerComponent = async () => {
  const session = await getServerSession(authOptions);
  const user = session?.user;

  return <p>{JSON.stringify(user)}</p>;
};

export default ServerComponent;

確認

/app/page.tsxを以下のように書き換えて、npm run devを実行後、http://localhost:3000/signinにアクセスしてログインをすると下記ページにリダイレクトしてユーザ情報が表示されます。

app/page.tsx
import ClientComponent from "@/components/ClientComponent";
import ServerComponent from "@/components/ServerComponent";

const Home = async () => {
  return (
    <main>
      <ClientComponent />
      {/* @ts-ignore */}
      <ServerComponent />
    </main>
  );
};

export default Home;

まとめ

以上がNextAuth×Firebaseで認証する方法でした。
今回はEメール/パスワードのみでしたが、Firebase Authenticationが用意している他のプロバイダを利用しても、idTokenを検証すれば良いのでどのやり方でも利用できると思います。

また、どこかのホスティングサービスにデプロイする場合はデプロイ先によって多少設定が異なるので、下記公式ドキュメントを参照してください。
.env.localに設定した環境変数と、adminSDKで利用しているjsonファイルをホスティングサービスに登録するのも忘れずに)
https://next-auth.js.org/deployment

Discussion

TsuboiTsuboi

大変学びになる記事ありがとうございます!
手順に従いサーバサイドで認証情報を取得することには成功したのですが、セキュリティールールを設定したところ

match /users/{userId} {
  allow read, write: if request.auth != null && request.auth.uid == userId;
}

Internal error: FirebaseError: Missing or insufficient permissions.とエラーとなり
request.authがnullとして返ってくる問題が起きています。

なにか特別な設定をする必要があるでしょうか?