Copyright © 2023 - All right reserved by Junpei K.

Photo by Martin Sanchez

    🖌️ Table of Contents

    🖌️ TOC

  1. 記事の前提
    記事の前提
  2. 全体像
    全体像
  3. 必要ライブラリの...
    必要ライブ...
  4. middlewareの実装
    middleware...
  5. LoginFormの実装
    LoginForm...
  6. SessionProvider...
    SessionPro...
  7. APIの実装
    APIの実装
  8. TopPageの実装
    TopPageの...
  9. 参考
    参考

【Next.js】Firebaseによる認証機構を実装してみる with App router

ここではNext.js(App router)のアプリケーションに、Firebaseを使って認証機構を実装してみます。

記事の前提

この記事では、以下の状況を前提として実装を進めます。

  • Next.js v14.0.3 × Tailwind CSSで構築したアプリケーション
  • App routerを利用して実装を進めている
  • 認証はFirebaseを利用する

また、既に類似記事(【Next.js】NextAuth×Firebaseで認証管理 in appディレクトリ)がzennに存在しますがこちらとは別記事・別著者です。本記事は↑の記事を読み込んだ筆者が自らのアプリケーションに適用するにあたって遭遇したエラーを乗り越え、最終的に辿り着いた実装を紹介するものです。

全体像

先に、実装全体のイメージをシーケンス図でお見せします。

firebase_auth_sequence

↑の図の補足説明

  • LoginFormはclient componentです
  • TopPageはログイン必須を想定したcomponentです (client or serverは任意)
  • は配下に定義するAPIです

必要ライブラリの準備

以下コマンドで、必要になるライブラリを導入します。

1npm i next-auth firebase firebase-admin
2

middlewareの実装

まずはmiddlewareを実装します。

src/middleware.ts
1import { cookies } from 'next/headers';
2import type { NextRequest } from 'next/server';
3import { NextResponse } from 'next/server';
4
5export const middleware = async (request: NextRequest): Promise<NextResponse | undefined> => {
6  console.log('[middleware] request.nextUrl:', JSON.stringify(request.nextUrl));
7
8  // Cookieに含まれるセッショントークンの検証
9  // (ログイン済みであれば200 OKが返る)
10  const res = await fetch(`${request.nextUrl.origin}/api/idToken`, {
11    method: 'POST',
12    body: JSON.stringify({ idToken: cookies().get('next-auth.session-token')?.value }),
13  })
14    .then((res) => {
15      console.log('[middleware] res:', res.status, res.statusText);
16      return res;
17    })
18    .catch((err) => {
19      console.error(err);
20      return NextResponse.redirect(new URL('/login', request.url));
21    });
22  if (res.status === 401) {
23    // セッショントークンが検証できなければログインページへリダイレクト
24    return NextResponse.redirect(new URL('/login', request.url));
25  }
26  return NextResponse.next();
27};
28
29export const config = {
30  // 上記middlewareの処理を通さないパス
31  matcher: ['/((?!api/auth|_next/static|_next/image|favicon.ico).*)'],
32};
33

↑の実装で呼び出しているのAPIは後ほど実装するので、この時点では正常なレスポンスは得られません。

LoginFormの実装

次にLoginForm(client component)を実装します。

src/app/login/page.tsx
1'use client';
2import React from 'react';
3import { type NextPage } from 'next';
4
5import LoginForm from '@/app/atoms/LoginForm';
6
7const Login: NextPage = () => {
8  return (
9    <div className="flex justify-center items-center h-[85vh]">
10      <LoginForm />
11    </div>
12  );
13};
14
15export default Login;
16
src/app/atoms/LoginForm.tsx
1'use client';
2import React from 'react';
3import { signInWithEmailAndPassword } from 'firebase/auth';
4import { useRouter } from 'next/navigation';
5import { signIn as signInByNextAuth } from 'next-auth/react';
6
7import { auth } from '@/firebase/client';
8
9const LoginForm = (): React.ReactElement => {
10  const [email, setEmail] = React.useState('');
11  const [password, setPassword] = React.useState('');
12
13  // next/navigationのredirectがうまく動かないので、routerで対処
14  // ref: https://stackoverflow.com/questions/76191324/next-13-4-error-next-redirect-in-api-routes
15  const router = useRouter();
16
17  const handleLogin = async (): Promise<void> => {
18    const user = await signInWithEmailAndPassword(auth, email, password)
19      .then(async (userCredential) => {
20        const user = userCredential.user;
21        return user;
22      })
23      .catch((error) => {
24        const errorCode = error.code;
25        const errorMessage = error.message;
26        console.log(errorCode, errorMessage);
27        return null;
28      });
29    if (user === null) return;
30    const idToken = await user.getIdToken();
31
32    await signInByNextAuth('credentials', {
33      idToken,
34      // 引数でログイン成功後のリダイレクト先のパス(callbackUrl)を指定しても良いが
35      // 開発上の都合でメソッドチェーンでリダイレクトする形に。
36      // ref: https://next-auth.js.org/getting-started/client#specifying-a-callbackurl
37    }).then((res) => {
38      console.log('signInByNextAuth res:', JSON.stringify(res));
39      if (typeof res === 'undefined') return;
40      if (res.status === 200) {
41        router.push('/top');
42      }
43    });
44  };
45
46  return (
47    <div className="flex flex-col w-full max-w-md px-4 py-8 bg-white rounded-lg shadow dark:bg-gray-800 sm:px-6 md:px-8 lg:px-10">
48      <div className="self-center mb-6 text-xl font-light text-gray-600 sm:text-2xl dark:text-white">
49        Login To Your Account
50      </div>
51      <div className="mt-8">
52        <div className="flex flex-col mb-2">
53          <div className="flex relative ">
54            <span className="rounded-l-md inline-flex  items-center px-3 border-t bg-white border-l border-b  border-gray-300 text-gray-500 shadow-sm text-sm">
55              <svg
56                width="15"
57                height="15"
58                fill="currentColor"
59                viewBox="0 0 1792 1792"
60                xmlns="http://www.w3.org/2000/svg"
61              >
62                <path d="M1792 710v794q0 66-47 113t-113 47h-1472q-66 0-113-47t-47-113v-794q44 49 101 87 362 246 497 345 57 42 92.5 65.5t94.5 48 110 24.5h2q51 0 110-24.5t94.5-48 92.5-65.5q170-123 498-345 57-39 100-87zm0-294q0 79-49 151t-122 123q-376 261-468 325-10 7-42.5 30.5t-54 38-52 32.5-57.5 27-50 9h-2q-23 0-50-9t-57.5-27-52-32.5-54-38-42.5-30.5q-91-64-262-182.5t-205-142.5q-62-42-117-115.5t-55-136.5q0-78 41.5-130t118.5-52h1472q65 0 112.5 47t47.5 113z"></path>
63              </svg>
64            </span>
65            <input
66              type="text"
67              id="sign-in-email"
68              onChange={(e) => {
69                setEmail(e.target.value);
70              }}
71              onKeyDown={(e) => {
72                if (e.key === 'Enter') {
73                  handleLogin(); // eslint-disable-line
74                }
75              }}
76              placeholder="Your email"
77              className=" rounded-r-lg flex-1 appearance-none border border-gray-300 w-full py-2 px-4 bg-white text-gray-700 placeholder-gray-400 shadow-sm text-base focus:outline-none focus:ring-2 focus:ring-purple-600 focus:border-transparent"
78            />
79          </div>
80        </div>
81        <div className="flex flex-col mb-6">
82          <div className="flex relative ">
83            <span className="rounded-l-md inline-flex  items-center px-3 border-t bg-white border-l border-b  border-gray-300 text-gray-500 shadow-sm text-sm">
84              <svg
85                width="15"
86                height="15"
87                fill="currentColor"
88                viewBox="0 0 1792 1792"
89                xmlns="http://www.w3.org/2000/svg"
90              >
91                <path d="M1376 768q40 0 68 28t28 68v576q0 40-28 68t-68 28h-960q-40 0-68-28t-28-68v-576q0-40 28-68t68-28h32v-320q0-185 131.5-316.5t316.5-131.5 316.5 131.5 131.5 316.5q0 26-19 45t-45 19h-64q-26 0-45-19t-19-45q0-106-75-181t-181-75-181 75-75 181v320h736z"></path>
92              </svg>
93            </span>
94            <input
95              type="password"
96              id="sign-in-email"
97              onChange={(e) => {
98                setPassword(e.target.value);
99              }}
100              onKeyDown={(e) => {
101                if (e.key === 'Enter') {
102                  handleLogin(); // eslint-disable-line
103                }
104              }}
105              placeholder="Your password"
106              className=" rounded-r-lg flex-1 appearance-none border border-gray-300 w-full py-2 px-4 bg-white text-gray-700 placeholder-gray-400 shadow-sm text-base focus:outline-none focus:ring-2 focus:ring-purple-600 focus:border-transparent"
107            />
108          </div>
109        </div>
110        <div className="flex items-center mb-6 -mt-4">
111          <div className="flex ml-auto">
112            <a
113              href="#"
114              className="inline-flex text-xs font-thin text-gray-500 sm:text-sm dark:text-gray-100 hover:text-gray-700 dark:hover:text-white"
115            >
116              Forgot Your Password?
117            </a>
118          </div>
119        </div>
120        <div className="flex w-full">
121          <button
122            onClick={handleLogin} // eslint-disable-line
123            onKeyDown={(e) => {
124              if (e.key === 'Enter') {
125                handleLogin(); // eslint-disable-line
126              }
127            }}
128            className="py-2 px-4  bg-purple-600 hover:bg-purple-700 focus:ring-purple-500 focus:ring-offset-purple-200 text-white w-full transition ease-in duration-200 text-center text-base font-semibold shadow-md focus:outline-none focus:ring-2 focus:ring-offset-2  rounded-lg "
129          >
130            Login
131          </button>
132        </div>
133      </div>
134      <div className="flex items-center justify-center mt-6">
135        <a
136          href="#"
137          target="_blank"
138          className="inline-flex items-center text-xs font-thin text-center text-gray-500 hover:text-gray-700 dark:text-gray-100 dark:hover:text-white"
139        >
140          <span className="ml-2">You don&#x27;t have an account?</span>
141        </a>
142      </div>
143    </div>
144  );
145};
146
147export default LoginForm;
148

これでログイン画面っぽいものができました。

login_form_ui

ちなみに

LoginFormでimportしている@/firebase/clientは以下のような実装をしています。

src/firebase/client.ts
1import { getApps, initializeApp } from 'firebase/app';
2import { getAuth } from 'firebase/auth';
3
4const firebaseConfig = {
5  apiKey: process.env.NEXT_PUBLIC_API_KEY,
6  authDomain: process.env.NEXT_PUBLIC_AUTH_DOMAIN,
7  databaseURL: process.env.NEXT_PUBLIC_DATABASE_URL,
8  projectId: process.env.NEXT_PUBLIC_PROJECT_ID,
9  storageBucket: process.env.NEXT_PUBLIC_STORAGE_BUCKET,
10  messagingSenderId: process.env.NEXT_PUBLIC_MESSAGING_SENDER_ID,
11  appId: process.env.NEXT_PUBLIC_APP_ID,
12  measurementId: process.env.NEXT_PUBLIC_MEASUREMENT_ID,
13};
14
15// eslint-disable-next-line
16const app = getApps()?.length ? getApps()[0] : initializeApp(firebaseConfig);
17export const auth = getAuth(app);
18

NEXT_PUBLIC_API_KEY等に設定すべき値は「[React + Firebase Authentication](前編)reactプロジェクトの作成とfirebaseの初期設定」など、詳しく書かれた記事があるのでそちらをご覧ください🙏

SessionProviderの実装

NextAuthはクライアントコードでsessionを閲覧(useSession()を利用)するために、SessionProviderでラップしておく必要があります。

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

↑の理由により、ここでSessionProviderの準備をしていきます。

src/app/components/Providers.tsx
1'use client';
2import React from 'react';
3import { type FC, type PropsWithChildren } from 'react';
4import { SessionProvider } from 'next-auth/react';
5
6export const Providers: FC<PropsWithChildren> = ({ children }) => {
7  return (
8    <SessionProvider>{children}</SessionProvider>
9  );
10};
11

APIの実装

では、サーバサイドで稼働するAPIを準備していきます。

セッショントークン検証API(/api/idToken)

src/app/api/idToken/route.ts
1import { getAuth } from 'firebase-admin/auth';
2import { type NextRequest, NextResponse } from 'next/server';
3
4interface RequestBody {
5  idToken?: string;
6}
7
8export const POST = async (request: NextRequest): Promise<NextResponse> => {
9  console.info('=========== POST /api/idToken ============');
10
11  const reqBody: RequestBody = await request.json();
12  if (typeof reqBody.idToken !== 'string') {
13    console.error('idToken is invalid:', reqBody.idToken);
14    console.info('=========== ============ ============');
15    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
16  }
17  await getAuth()
18    .verifyIdToken(reqBody.idToken, true)
19    .catch((error) => {
20      console.error('idToken is invalid.');
21      console.error(error);
22      console.info('=========== ============ ============');
23      return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
24    });
25  console.info('idToken is valid.');
26  console.info('=========== ============ ============');
27  return NextResponse.json({ status: 'ok' });
28};
29

↑では厚めにログ出ししていますが、不要であれば削除してください。

ログイン処理API(/api/auth/[...nextauth])

src/app/api/auth/[...nextauth]/route.ts
1import type { NextAuthOptions } from 'next-auth';
2import NextAuth from 'next-auth';
3import CredentialsProvider from 'next-auth/providers/credentials';
4
5import { auth } from '@/firebase/admin';
6
7const authOptions: NextAuthOptions = {
8  providers: [
9    CredentialsProvider({
10      credentials: {},
11      secret: process.env.NEXT_AUTH_SECRET,
12      // @ts-expect-error eslint-disable-line
13      authorize: async ({ idToken }: any, _req) => {
14        if (typeof idToken === 'string') {
15          try {
16            const decoded = await auth.verifyIdToken(idToken);
17            return { ...decoded };
18          } catch (err) {
19            console.error(err);
20          }
21        }
22        return null;
23      },
24    }),
25  ],
26  session: {
27    strategy: 'jwt',
28  },
29  callbacks: {
30    // @ts-expect-error eslint-disable-line
31    async jwt({ token, user }) {
32      return { ...token, ...user };
33    },
34    // @ts-expect-error eslint-disable-line
35    async session({ session, token }) {
36      console.log('============== api/v1/auth/[slug]/route.ts ==============');
37      console.log('session:', JSON.stringify(session));
38      console.log('token:', JSON.stringify(token));
39
40      if (typeof session.user === 'undefined') {
41        console.error('session.user is undefined.');
42        return;
43      }
44      session.user.emailVerified = token.emailVerified;
45      session.user.uid = token.uid;
46      console.log('=========== ============ ============');
47      return session;
48    },
49  },
50};
51
52const handler = NextAuth(authOptions);
53export { handler as GET, handler as POST };
54

なお、ここでimportしている@/firebase/adminは以下のような実装となっています。

src/firebase/admin.ts
1import { cert, getApps, initializeApp } from 'firebase-admin/app';
2import { getAuth } from 'firebase-admin/auth';
3
4import serviceAccount from '../../firebaseSecretKey.json';
5export const firebaseAdmin =
6  getApps()[0] ??
7  initializeApp({
8    // @ts-expect-error eslint-disable-line
9    credential: cert(serviceAccount),
10  });
11
12export const auth = getAuth();
13

また↑を正常に稼働させるために、「秘密鍵の生成」を実施してトップ階層にfirebaseSecretKey.jsonを配置します。ここではその作業詳細は省きます。

TopPageの実装

ログイン後にアクセスする想定のトップページを作成します。以下ではパスをとし、サンプル的なモック実装を行っています。

src/app/top/page.tsx
1import React from 'react';
2import { type NextPage } from 'next';
3
4const TopPage: NextPage = () => {
5  return (
6    <>
7      <main className="relative h-screen overflow-hidden bg-gray-100 dark:bg-gray-800 rounded-2xl">
8        <div className="flex items-start justify-between">
9          <div className="relative hidden h-screen my-4 ml-4 shadow-lg lg:block w-80">
10            <div className="h-full bg-white rounded-2xl dark:bg-gray-700">
11              <div className="flex items-center justify-center pt-6">
12                <svg width="35" height="30" viewBox="0 0 256 366" version="1.1" preserveAspectRatio="xMidYMid">
13                  <defs>
14                    <linearGradient
15                      x1="12.5189534%"
16                      y1="85.2128611%"
17                      x2="88.2282959%"
18                      y2="10.0225497%"
19                      id="linearGradient-1"
20                    >
21                      <stop stopColor="#FF0057" stopOpacity="0.16" offset="0%"></stop>
22                      <stop stopColor="#FF0057" offset="86.1354%"></stop>
23                    </linearGradient>
24                  </defs>
25                  <g>
26                    <path
27                      d="M0,60.8538006 C0,27.245261 27.245304,0 60.8542121,0 L117.027019,0 L255.996549,0 L255.996549,86.5999776 C255.996549,103.404155 242.374096,117.027222 225.569919,117.027222 L145.80812,117.027222 C130.003299,117.277829 117.242615,130.060011 117.027019,145.872817 L117.027019,335.28252 C117.027019,352.087312 103.404567,365.709764 86.5997749,365.709764 L0,365.709764 L0,117.027222 L0,60.8538006 Z"
28                      fill="#001B38"
29                    ></path>
30                    <circle
31                      fill="url(#linearGradient-1)"
32                      transform="translate(147.013244, 147.014675) rotate(90.000000) translate(-147.013244, -147.014675) "
33                      cx="147.013244"
34                      cy="147.014675"
35                      r="78.9933938"
36                    ></circle>
37                    <circle
38                      fill="url(#linearGradient-1)"
39                      opacity="0.5"
40                      transform="translate(147.013244, 147.014675) rotate(90.000000) translate(-147.013244, -147.014675) "
41                      cx="147.013244"
42                      cy="147.014675"
43                      r="78.9933938"
44                    ></circle>
45                  </g>
46                </svg>
47              </div>
48              <nav className="mt-6">
49                <div>
50                  <a
51                    className="flex items-center justify-start w-full p-4 my-2 font-thin text-blue-500 uppercase transition-colors duration-200 border-r-4 border-blue-500 bg-gradient-to-r from-white to-blue-100 dark:from-gray-700 dark:to-gray-800"
52                    href="#"
53                  >
54                    <span className="text-left">
55                      <svg
56                        width="20"
57                        height="20"
58                        fill="currentColor"
59                        viewBox="0 0 2048 1792"
60                        xmlns="http://www.w3.org/2000/svg"
61                      >
62                        <path d="M1070 1178l306-564h-654l-306 564h654zm722-282q0 182-71 348t-191 286-286 191-348 71-348-71-286-191-191-286-71-348 71-348 191-286 286-191 348-71 348 71 286 191 191 286 71 348z"></path>
63                      </svg>
64                    </span>
65                    <span className="mx-4 text-sm font-normal">Dashboard</span>
66                  </a>
67                </div>
68              </nav>
69            </div>
70          </div>
71          <div className="flex flex-col w-full pl-0 md:p-4 md:space-y-4">
72            <header className="z-40 items-center w-full h-16 bg-white shadow-lg dark:bg-gray-700 rounded-2xl">
73              <div className="relative z-20 flex flex-col justify-center h-full px-3 mx-auto flex-center">
74                <div className="relative flex items-center w-full pl-1 lg:max-w-68 sm:pr-2 sm:ml-0">
75                  <div className="container relative left-0 z-50 flex w-3/4 h-full">
76                    <div className="relative flex items-center w-full h-full lg:w-64 group">
77                      <div className="absolute z-50 flex items-center justify-center w-auto h-10 p-3 pr-2 text-sm text-gray-500 uppercase cursor-pointer sm:hidden">
78                        <svg
79                          fill="none"
80                          className="relative w-5 h-5"
81                          strokeLinecap="round"
82                          strokeLinejoin="round"
83                          strokeWidth="2"
84                          stroke="currentColor"
85                          viewBox="0 0 24 24"
86                        >
87                          <path d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
88                        </svg>
89                      </div>
90                      <svg
91                        className="absolute left-0 z-20 hidden w-4 h-4 ml-4 text-gray-500 pointer-events-none fill-current group-hover:text-gray-400 sm:block"
92                        xmlns="http://www.w3.org/2000/svg"
93                        viewBox="0 0 20 20"
94                      >
95                        <path d="M12.9 14.32a8 8 0 1 1 1.41-1.41l5.35 5.33-1.42 1.42-5.33-5.34zM8 14A6 6 0 1 0 8 2a6 6 0 0 0 0 12z"></path>
96                      </svg>
97                      <input
98                        type="text"
99                        className="block w-full py-1.5 pl-10 pr-4 leading-normal rounded-2xl focus:border-transparent focus:outline-none focus:ring-2 focus:ring-blue-500 ring-opacity-90 bg-gray-100 dark:bg-gray-800 text-gray-400 aa-input"
100                        placeholder="Search"
101                      />
102                      <div className="absolute right-0 hidden h-auto px-2 py-1 mr-2 text-xs text-gray-400 border border-gray-300 rounded-2xl md:block">
103                        +
104                      </div>
105                    </div>
106                  </div>
107                </div>
108              </div>
109            </header>
110          </div>
111        </div>
112      </main>
113    </>
114  );
115};
116
117export default TopPage;
118

これで実装完了です。

参考

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