Next.js（App Router）× NextAuth × Firebase Authenticationの認証を実装する

## はじめに
#### この記事での達成事項
- NextAuthとFirebase Authenticationを使って、Login、Logoutを実装
- ログイン状態による画面遷移制御の実装
- ユーザIDをSessionに格納し、クライアントコンポーネントとサーバーコンポーネントのどちらもから、Session内のユーザIDを取り出せるように実装

#### 前提
- Next.jsの環境設定ができている
- Firebaseのプロジェクトが作成できている
- この記事は、`npx create-next-app@latest`を実行した直後の状態から開始しています


#### 各ライブラリのバージョン
```json:package.json
{
  "name": "next-auth_firebase-auth",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "dev": "next dev --turbopack",
    "build": "next build",
    "start": "next start",
    "lint": "next lint"
  },
  "dependencies": {
    "firebase": "^11.2.0",
    "firebase-admin": "^13.0.2",
    "next": "15.1.5",
    "next-auth": "^4.24.11",
    "react": "^19.0.0",
    "react-dom": "^19.0.0"
  },
  "devDependencies": {
    "@types/node": "^20",
    "@types/react": "^19",
    "@types/react-dom": "^19",
    "postcss": "^8",
    "tailwindcss": "^3.4.1",
    "typescript": "^5"
  }
}
```

## 最終成果物

https://next-auth-firebase-o4qup7vap-shunji-mutos-projects.vercel.app/

https://github.com/shunji-muto/NextAuth-Firebase

#### ↓↓↓以下、手順↓↓↓

## NextAuth、Firebaseのライブラリをインストール

[**[1] NextAuthをインストール**](https://next-auth.js.org/getting-started/example
)
```
npm install next-auth
```

[**[2] Firebase SDKのインストール**](https://firebase.google.com/docs/firestore/client/libraries?hl=ja)
```
npm install firebase
```

[**[3] Firebase Admin SDKのインスール**](https://firebase.google.com/docs/admin/setup?hl=ja)
```
npm install firebase-admin
```

※Next.jsは、クライアントサイドとサーバーサイドがあるので、Firebase Admin SDKも必要となる


## Firebaseライブラリの設定

**[1] `.env`へ環境変数の設定**

```terraform:.env
# 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>

# Firebase Admin SDK
# ※「プロジェクトの設定」→「サービスアカウント」→「新しい秘密鍵を作成」から取得
FIREBASE_CLIENT_EMAIL=<client_email>
FIREBASE_PRIVATE_KEY=<private_key>
NEXTAUTH_SECRET=//`openssl rand -base64 32`コマンドで作成したものを入れます
```

**[2] Firebase SDK**
```typescript:src/lib/firebase/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,
  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);
```

**[3] Firebase Admin SDK**
```typescript:src/lib/firebase/firebase-auth.ts
import * as admin from "firebase-admin";
import type { ServiceAccount } from "firebase-admin";

const cert: ServiceAccount = {
  projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID,
  clientEmail: process.env.NEXT_PUBLIC_FIREBASE_CLIENT_EMAIL,
  privateKey: process.env.NEXT_PUBLIC_FIREBASE_PRIVATE_KEY?.replace(/\\n/g, "\n"),
};

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

export const firebaseAdminAuth = firebaseAdmin.auth();
```

#### 参考
https://firebase.google.com/docs/web/setup?hl=ja

https://firebase.google.com/docs/admin/setup?hl=ja


## Next Authの設定


**[1] `SessionProvider`をルートパスの`Layout.tsx`に記載**
```typescript:src/app/layout.tsx
"use client"

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

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

**[2] NextAuthとFirebase Authの連携のコア部分の実装**
```typescript:lib/auth.ts
import type { NextAuthOptions } from 'next-auth'
import CredentialsProvider from 'next-auth/providers/credentials'
import { firebaseAdminAuth } from './firebase/firebase-admin'

const fetchNewIdToken = async (refreshToken: string) => {
  const res = await fetch(
    `https://securetoken.googleapis.com/v1/token?key=${process.env.NEXT_PUBLIC_FIREBASE_API_KEY}`,
    {
      method: 'POST',
      body: JSON.stringify({
        grant_type: 'refresh_token',
        refreshToken,
      }),
    },
  )

  const { id_token } = await res.json()

  return id_token
}

export const authOptions: NextAuthOptions = {
  providers: [
    CredentialsProvider({
      credentials: {},
      // @ts-ignore:理由を書く
      authorize: async ({ idToken, refreshToken }) => {
        if (idToken && refreshToken) {
          try {
            const decoded = await firebaseAdminAuth.verifyIdToken(idToken) // 2

            const user = {
              id: decoded.user_id,
              uid: decoded.uid,
              name: decoded.name || '',
              email: decoded.email || '',
              image: decoded.picture || '',
              emailVerified: decoded.email_verified || false,
              idToken,
              refreshToken,
              tokenExpiryTime: decoded.exp || 0,
            }

            return user
          } catch (err) {
            console.error(err)
          }
        }
        return null
      },
    }),
  ],
  callbacks: {
    async jwt({ token, user }) {
      if (user) {
        token.id = user.id
        token.uid = user.id
        token.name = user.name ?? ''
        token.emailVerified = !!user.emailVerified
        token.idToken = user.idToken
        token.refreshToken = user.refreshToken
        token.image = user.image ?? ''
        token.tokenExpiryTime = user.tokenExpiryTime
      }

      const currentTime = Math.floor(Date.now() / 1000)
      const tokenExpiryTime = token.tokenExpiryTime as number
      const isExpired = currentTime > tokenExpiryTime - 300 // 5分前には更新するようにする

      if (isExpired) {
        try {
          const newIdToken = await fetchNewIdToken(token.refreshToken as string)
          token.idToken = newIdToken
        } catch (error) {
          console.error('Error refreshing token:', error)
        }
      }

      return token
    },
    async session({ session, token }) {
      // sessionにFirebase Authenticationで取得した情報を追加。
      session.user.emailVerified = token.emailVerified
      session.user.uid = token.uid
      session.user.name = token.name
      session.user.image = token.image || ''
      session.user.email = token.email || ''
      session.user.emailVerified = token.emailVerified

      return session
    },
  },
  session: {
    strategy: 'jwt',
    maxAge: 90 * 24 * 60 * 60, // 90 days
  },
  pages: {
    signIn: '/',
  },
  secret: process.env.NEXTAUTH_SECRET,
}
```

**[3] NextAuthのSessionとUserの型定義を行う**
```typescript:src/types/next-auth.d.ts
import { DefaultSession, DefaultUser, Session } from "next-auth";
import { DefaultJWT, JWT } from "next-auth/jwt";

declare module "next-auth" {
    interface User extends DefaultUser{
        id: string;
        uid: string;
        name: string;
        email: string;
        image: string;
        emailVerified: boolean;
        idToken: string;
        refreshToken: string;
        tokenExpiryTime: number;
    }

    interface Session extends DefaultSession{
        user: {
            id: string;
            uid: string;
            name: string;
            email: string;
            image: string;
            emailVerified: boolean;
        } & DefaultSession['user']
    } 
}

declare module "next-auth/jwt" {
  interface JWT extends DefaultJWT{
    id: string;
    uid: string;
    name: string;
    email: string;
    image: string;
    emailVerified: boolean;
    idToken: string;
    refreshToken: string;
    tokenExpiryTime: number;
  }
}
```

**[4] 以下のファイルを作成する**
```typescript:app/api/auth/[...nextauth]/route.ts
import { authOptions } from '@/lib/firebase/auth'
import NextAuth from 'next-auth'

const handler = NextAuth(authOptions)

export { handler as GET, handler as POST }
```

#### 参考
[1]

https://next-auth.js.org/getting-started/client#sessionprovider

[2]

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

https://next-auth.js.org/configuration/providers/credentials

[3]

https://stackoverflow.com/questions/74425533/property-role-does-not-exist-on-type-user-adapteruser-in-nextauth

https://github.com/nextauthjs/next-auth/issues/2080

[4]

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


## Login、Logoutの実装
**以下のファイルを作成する**
```typescript:src/lib/firebase/firebase-auth.ts
import { GoogleAuthProvider, signInWithPopup } from "firebase/auth";
import { firebaseAuth } from "./firebase";
import { signIn as signInWithNextAuth, signOut as signOutWithNextAuth } from "next-auth/react";

export function logInWithFirebaseAuth(){
    const provider = new GoogleAuthProvider();

    signInWithPopup(firebaseAuth, provider)
        .then(async ({user})=>{
            if(user){
                const refreshToken = user.refreshToken;
                const idToken = await user.getIdToken();
                await signInWithNextAuth("credentials", {
                    idToken,
                    refreshToken,
                    callbackUrl: `/protected-page`,    //ログイン後に遷移する画面の指定
                })
            }
        })
        .catch((error)=>{
            console.error("Error Sing In with Google", error)
        });
}

export function logOutWithFirebaseAuth(){
    firebaseAuth
    .signOut()
    .then(()=>{
        signOutWithNextAuth({callbackUrl: `/`});   //ログアウト後に遷移する画面の指
    })
    .catch((error)=>{
        console.error("Error Sign Out with Google", error)
    })
}
```

#### 参考
https://next-auth.js.org/getting-started/client#signin

## ログインしてない人が見られるページの作成

**[1] ログインしている人は別ページに飛ばすロジック**
```typescript:src/app/(public)/layout.tsx
import { authOptions } from "@/lib/auth"
import { getServerSession } from "next-auth"
import { redirect } from "next/navigation"
import { ReactElement } from "react"

const Layout = async ({children}:{children:ReactElement}) => {
  const session = await getServerSession(authOptions)

  if(session?.user) redirect(`/protected-page`)

  return <>{children}</>
  
}

export default Layout
```

**[2] ログインページの作成**
```typescript:src/app/(public)/page.tsx
"use client"

import { logInWithFirebaseAuth } from "@/lib/firebase/firebase-auth"
import Link from "next/link"

const Page = () => {
  return (
    <div style={{display:"flex", flexDirection:"column", gap: 20}}>
        <div style={{fontSize:20, fontWeight:20}}>
            ログインページです
        </div>
        <Link href={`/protected-page`}>
          <button>
              "/protected-page"ページへのリンク（※ログインしてないため、このページに戻ってくるよ）
          </button>
        </Link>
        <button onClick={logInWithFirebaseAuth} style={{height:"48px", padding: "8px"}}>
            Googleでログインする
        </button>
    </div>
  )
}

export default Page
```

## ログインしている人が見られるページの作成

**[1] ログインしてない人を弾くロジックの作成**
```typescript:src/app/(protected)/layout.tsx
import { authOptions } from "@/lib/auth"
import { getServerSession } from "next-auth"
import { redirect } from "next/navigation"
import { ReactElement } from "react"

const Layout = async ({children}:{children: ReactElement}) => {
    const session = await getServerSession(authOptions)

    if(!session?.user) redirect(`/`)

    return <>{children}</>
}

export default Layout
```

**[2] ログインした人限定のぺーじをさくせ**
```typescript:src/app/(protected)/protected-page/page.tsx
"use client"

import { logOutWithFirebaseAuth } from "@/lib/firebase/firebase-auth"
import { useSession } from "next-auth/react";
import Link from "next/link"

const Page = () => {
  const {data:session} = useSession();

  return (
    <div style={{display:"flex", flexDirection:"column", gap: 20}}>  
        <div style={{fontSize: 20, fontWeight: 20}}>ログインした人限定のページ</div>
        <div>あなたの名前は{session?.user?.name}さんです</div>
        <div style={{display:"flex", flexDirection:"column"}}>
            <button>
                <Link href={`/`}>"/"ページへのリンク</Link>
            </button>
            （※ログインしているセッションが残っているため、このページに戻ってくるよ）
        </div>
        <button onClick={logOutWithFirebaseAuth}>ログアウト</button>
    </div>
  )
}

export default Page
```

## 終わりに
ここまで見てくださりありがとうございます。

NextAuthを理解したいので、解説を加えたものをZennの方で投稿しようと思っています。

https://zenn.dev/shunji_muto?tab=books

最後に「参考」で紹介している記事を元に実装したので、是非その方達の記事も参考にしてみてください。

#### 次は、自分のアプリケーション好みに変えていこう
- `layout.tsx`の認証ロジックは自分好みに変更しよう
- NextAuthのコア部分で、sessionやtokenに持たせたい情報を自分好みに変更しよう



## 参考

https://zenn.dev/psi/articles/app-router-firebase-authentication

https://zenn.dev/elpnt/articles/1af1047612992d

https://zenn.dev/tentel/articles/cc76611f4010c9