Closed26

Next.js App Router & Auth.js (next-auth v5) に frebase auth 組み込んでみる

nbstshnbstsh

Next.js App Router & Auth.js に入門したついでに、firebase auth の idToken ベースの認証を Auth.js に組み込んでみる。

  • server action では cookie base の認証管理を行いたい
  • firestore, firebase storage に firebase auth の認証情報を利用して client side から直接アクセスしたい

といったケースを想定している。

https://zenn.dev/nbstsh/scraps/ef555a5ae79ec3

nbstshnbstsh

firebase auth をセットアップしていく

install

npm install firebase

Setup sdk

src/lib/firebase/app.ts

import { initializeApp } from 'firebase/app';

const firebaseConfig = {
  apiKey: "xxxxxxxxxx",
  authDomain: "xxxxxxxxxx.firebaseapp.com",
  databaseURL: "https://xxxxxxxxxx.firebaseio.com",
  projectId: "xxxxxxxxxx",
  storageBucket: "xxxxxxxxxx.appspot.com",
  messagingSenderId: "xxxxxxxxxx",
  appId: "xxxxxxxxxx"
};

const firebaseApp = initializeApp(firebaseConfig);

Setup firebase auth

src/lib/firebase/auth.ts
import { getAuth } from 'firebase/auth';
import { firebaseApp } from './app';

export const firebaseAuth = getAuth(firebaseApp);
nbstshnbstsh

Auth.js Credentials provider

To setup Auth.js with external authentication mechanisms or simply use username and password, we need to use the Credentials provider. This provider is designed to forward any credentials inserted into the login form (.i.e username/password) to your authentication service via the authorize callback on the provider configuration.

今回のケースのような "external authentication mechanisms" を組み込む場合は Credentials provider を利用する。

https://authjs.dev/getting-started/authentication/credentials

nbstshnbstsh

やること

  • SignIn form 作成
  • 認証情報を送信
  • Credentials provider で受け取った認証情報を使った認証ロジックを実装
nbstshnbstsh

SignIn form 作成

firebases auth の email & password signIn を行うページを用意する。

email password form をもつ Server component として作る。

src/app/firebase-auth-sign-in/page.tsx
src/app/firebase-auth-sign-in/page.tsx
const Page = () => {
  return (
    <div className='grid justify-center gap-y-5 p-10'>
      <h1 className='text-2xl font-bold text-center'>Firebase Auth SignIn</h1>

      <form
        className='bg-white shadow-md rounded px-8 pt-6 pb-8 mb-4 w-96'
        action={async (formData) => {
           // Do something with formData
        }}
      >
        <div className='mb-4'>
          <label
            htmlFor='email'
            className='block text-gray-700 text-sm font-bold mb-2'
          >
            Email
          </label>
          <input
            id='email'
            type='email'
            name='email'
            required
            className='shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline'
            placeholder='Enter your email'
          />
        </div>
        <div className='mb-6'>
          <label
            htmlFor='password'
            className='block text-gray-700 text-sm font-bold mb-2'
          >
            Password
          </label>
          <input
            id='password'
            type='password'
            name='password'
            required
            className='shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline'
            placeholder='Enter your password'
          />
        </div>
        <div className='grid'>
          <button
            className='bg-purple-500 hover:bg-purple-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline'
            type='submit'
          >
            Sign In
          </button>
        </div>
      </form>
    </div>
  );
};

export default Page;

nbstshnbstsh

認証情報を送信

  • firebase auth で email & password signIn
  • firebase auth uesr の idToken を取得
  • next-auth の signIn で idToken を受け渡す

こんなイメージでやってみる。

firebase auth signInWithEmailAndPassword 用の util 作っておく。

src/lib/firebase/auth.ts
import * as FirebaseAuth from 'firebase/auth';
import { firebaseApp } from './app';

export const firebaseAuth = FirebaseAuth.getAuth(firebaseApp);

export const signInWithEmailAndPassword = (email: string, password: string) => {
  return FirebaseAuth.signInWithEmailAndPassword(firebaseAuth, email, password);
};

email & password signIn 実行後に idToken を取得。
next-auth の Credentials provider での signIn() で idToken を渡す。

import { signIn } from '@/auth';
import { signInWithEmailAndPassword } from '@/lib/firebase/auth';

//...

      <form
        action={async (formData) => {
          'use server';

          const email = formData.get('email') as string;
          const password = formData.get('password') as string;

          const userCredentials = await signInWithEmailAndPassword(
            email,
            password
          );

          const idToken = await userCredentials.user.getIdToken();

          await signIn('credentials', { idToken });
        }}
        //...
      >
     //...
nbstshnbstsh

server action 内部で firebase js client-sdk 使っているが問題ないだろうか...?
というか、server action だから、signInWithEmailAndPassword() の処理は server side で実行されるよな...?

log 出して確かめるか。

///...
        action={async (formData) => {
          'use server';

          const email = formData.get('email') as string;
          const password = formData.get('password') as string;
+          console.debug('Get email and password', { email, password });

          const userCredentials = await signInWithEmailAndPassword(
            email,
            password
          );
+          console.debug(
+            `Successfully signed in with email ${email}`,
+            userCredentials
+          );

          const idToken = await userCredentials.user.getIdToken();
+          console.debug('Get idToken', idToken);

          await signIn('credentials', { idToken });
        }}

chrome dev tool の console には log 出てない。

dev server 側の terminal に log でてる。
つまり、全部 server 側で実行されてる。

けど、問題なく idToken の取得までできてるのでこのまま進める。

nbstshnbstsh

Credentials provider で受け取った認証情報を使った認証ロジックを実装

NextAuthConfig.providers に Credentials provider を追加する。公式 Doc のサンプルコードを参考に idToken を受け取る形で作成。一旦 authorize の挙動見たいので第一引数の credentials を log にだす。

src/auth.ts
import NextAuth from 'next-auth';
import Credentials from 'next-auth/providers/credentials';
import Google from 'next-auth/providers/google';

export const { handlers, signIn, signOut, auth } = NextAuth({
  providers: [
    Google,
    Credentials({
      credentials: {
        idToken: {},
      },
      authorize: async (credentials) => {
        console.log('credentials', credentials);

        //TODO: verify idToken

        return null;
      },
    }),
  ],
});

log の内容↓

credentials {
  idToken: 'xxxxxxxxxxxxxxx',
  callbackUrl: 'http://localhost:3000/firebase-auth-sign-in'
}

idToken はちゃんと受け渡されていて、かつ、callbackUrl ってのが追加されてる。

nbstshnbstsh

firebase-admin setup

server-side で firebase auth idToken の検証を行いたいので firebase-admin を setup する。

https://firebase.google.com/docs/admin/setup

install

npm install firebase-admin --save

Initialize the SDK

src/lib/firebase-admin/app.ts
import { initializeApp } from 'firebase-admin/app';

export const adminApp = initializeApp();
src/lib/firebase-admin/auth.ts
import { getAuth } from 'firebase-admin/auth';
import { adminApp } from './app';

export const adminAuth = getAuth(adminApp);

Set GOOGLE_APPLICATION_CREDENTIALS env var

GOOGLE_APPLICATION_CREDENTIALS=path/to/service-account.json
nbstshnbstsh

firebase auth idToken の検証

Credentials provider の authorize callback 内で idToken の検証を行う。

src/auth.ts
import NextAuth from 'next-auth';
import Credentials from 'next-auth/providers/credentials';
import Google from 'next-auth/providers/google';
import z from 'zod';
import { adminAuth } from './lib/firebase-admin/auth';

const credentialsSchema = z.object({
  idToken: z.string(),
});

export const { handlers, signIn, signOut, auth } = NextAuth({
  providers: [
    Google,
    Credentials({
      credentials: {
        idToken: {},
      },
      authorize: async (credentials) => {
        console.debug('credentials', credentials);

        const parsed = credentialsSchema.parse(credentials);

        const decoded = await adminAuth.verifyIdToken(parsed.idToken);

        console.debug('decoded', decoded);

        return {
          id: decoded.uid,
          email: decoded.email,
          image: decoded.picture,
        };
      },
    }),
  ],
});
nbstshnbstsh

エラー発生

エラー詳細

Module build failed: UnhandledSchemeError: Reading from "node:stream" is not handled by plugins (Unhandled scheme).
Webpack supports "data:" and "file:" URIs by default.
You may need an additional plugin to handle "node:" URIs.

解決

どうやら、firebase-admin は edge runtime では利用できないみたいなので、middleware を削除することで対応。

nbstshnbstsh

Next.js の dev server で複数 app instance が生成される問題

'The default Firebase app already exists. This means you called initializeApp() more than once without providing an app name as the second argument. In most cases you only need to call initializeApp() once. But if you do want to initialize multiple apps, pass a second argument to initializeApp() to give each app a unique name.'

Next.js & firebase 使うと遭遇するあるあるエラー。対応忘れてたのでメモ。

getApp() を利用してすでに initialize されていたら利用できないように対応。

src/lib/firebase-admin/app.ts
import 'server-only';

import { getApps, initializeApp } from 'firebase-admin/app';

const [existingApp] = getApps();

export const adminApp = existingApp ?? initializeApp();

nbstshnbstsh

client side で firebase client sdk によるログインを行いたい

今の実装だと、firebase client sdk の処理も server side で処理されているため、client-side の firebase client sdk に認証情報が保持されない。

そのため、firestore や storage にアクセスした際に未認証とみなされてしまう...
今回わざわざ firebase auth を組み込む目的の一つに、 firestore, storage 等に client side から直接アクセスするケースを考慮しているので現状の実装だと意味がない。

そもそも firebase client sdk を用いた signIn 処理を server で実行できるのならわざわざ idToken を受け渡す必要もない。email, password を直接渡して Credentials provider 内で signInWithEmailAndPassword すれば良い。

nbstshnbstsh

signIn page を client component にしてみる

signIn form の page を client component にすればいいだけか?
やってみる。

+ 'use client';

import { signInWithEmailAndPassword } from '@/lib/firebase/auth';
import { signInAction } from './_actions/sign-in-action';
import { CurrentUserView } from './_components/current-user-view';

const Page = () => {
  return (
    <div className='p-10 grid gap-y-5 justify-items-center'>
      <h1 className='text-2xl font-bold'>Firebase Auth SignIn</h1>

//...
nbstshnbstsh

server action を別ファイルへ移動

Client Components can only import actions that use the module-level "use server" directive.

https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions-and-mutations#client-components

inline の server action は server component でしか利用できないので、server action を別ファイルに切り出す。

これは client component では NG↓

      <form
        className='bg-white shadow-md rounded px-8 pt-6 pb-8 mb-4 w-96'
        action={async (formData) => {
          'use server';

          const email = formData.get('email') as string;
          const password = formData.get('password') as string;

          try {
            const userCredentials = await signInWithEmailPassword(
              email,
              password
            );

//...

server action として実行したいのは NextAuth の signIn() のみななので、その部分だけ server action に切り出す。

src/app/firebase-auth-sign-in/_actions/sign-in-action.ts
'use server';

import { signIn } from '@/auth';
import { AuthError } from 'next-auth';

export const signInAction = async (idToken: string) => {
  try {
    await signIn('credentials', { idToken });
  } catch (error) {
    if (error instanceof AuthError) {
      console.log('AuthError', error);
      //TODO: Handle error
    }

    throw error;
  }
};

切り出した server action を呼び出す。

'use client';

import { signInWithEmailAndPassword } from '@/lib/firebase/auth';
import { signInAction } from './_actions/sign-in-action';

//...

      <form
        action={async (formData) => {
          const email = formData.get('email') as string;
          const password = formData.get('password') as string;

          console.debug('Get email and password', { email, password });

          const userCredentials = await signInWithEmailAndPassword(
            email,
            password
          );

          console.debug(
            `Successfully signed in with email ${email}`,
            userCredentials
          );

          const idToken = await userCredentials.user.getIdToken();

          console.debug('Get idToken', idToken);

          await signInAction(idToken);
        }}
      >


//...
nbstshnbstsh

動作確認

ちゃんと client side で firebase auth の signIn が行われた後に、server side の NextAuth の signIn 処理が実行されている

nbstshnbstsh

signOut 時に firebase auth も signOut させる

NextAuth signOut 用の server action

src/app/_components/sign-out-action.ts
'use server';

import { signOut } from '@/auth';

export const signOutAction = () => {
  return signOut();
};

NextAuth signOut & firebase auth signOut を行う client component

firebase auth のsignOut は client side で実行する必要ががるので client component を作る。

src/app/_components/sign-out-buttont.tsx
'use client';

import * as FirebaseAuthLib from '@/lib/firebase/auth';
import { signOutAction } from './sign-out-action';

const handleSignOut = async () => {
  await signOutAction();
  await FirebaseAuthLib.signOut();
};

export const SignOutButton = () => {
  return (
    <button
      className='bg-red-500 hover:bg-red-700 text-white font-bold py-2 px-4 rounded'
      onClick={async () => {
        await handleSignOut();
      }}
    >
      Sign Out
    </button>
  );
};

動作確認

問題なし

nbstshnbstsh

認証エラーと redirect

signIn() 後に reidrect させたい場合は、error handling に注意が必要。

AuthError 以外の場合は catch した error を throw する ようにしないと、redirect してくれない。

というのも、signIn() 成功時の reidrect は NEXT_REDIRECT_ERROR を throw することによって実現されているため、try catch block で catch すると redirect 処理が潰されてしまう。

https://nextjs.org/docs/app/api-reference/functions/redirect

import { AuthError } from "next-auth"

//...

try {
   await signIn("credentials", { ...formData,  redirectTo: '/' })
} catch(error) {
  if (error instanceof AuthError) {
    // Handle auth errors
  }
  throw error // Rethrow all other errors
}
nbstshnbstsh

所感

  • firebase-admin が edge runtime で使えないが故に Middleware 使えないのがきつい
  • ログイン状態が NextAuth と firebase auth で二重管理になるのが微妙ではある
nbstshnbstsh

verifyIdToken 相当の処理を自前で実装するか...?

nbstshnbstsh

NextAuth と firebase auth で login state が別れる問題について考える

  • client-side での firebase auth の login state
  • server-side での NextAuth (cookie based) の login state

と、client-side, server-side で login state が二重管理になる。
両者 login or 両者 logout の場合は問題ないが、どちらかが login & どちらかが logout の場合は login state が分離してりよろしくない。

nbstshnbstsh

NextAuth が login & firebase auth が logout の場合

server-side では session が生きており login 状態だが、client-side では firebase auth が logout の状態。

firebase auth を併用する場合、firestore や storage を client-side から直接利用するケースが考えられる。なので、auth 管理の中枢は server-side session でありながら技術的な都合上 firebase auth でも login 状態にしたいケースになる。

=> この場合は、server-side が login 状態なら、server-side に併せて firebase auth も login 状態であるべき。

nbstshnbstsh

NextAuth が login なら firebase auth を login させる

これをどう実現するか考える。

  • server session に firebase auth uid を入れとく
  • server session の firebase auth uid から firebase auth custom token を生成
  • custom token を用いて firebase auth signIn する

これで client-side から server session に基づいた user で firebase auth login できる。

あとは、onAuthStateChanged でログイン情報を監視し、session が存在するが firebase auth user が未ログインの場合に、上記 custom token による signIn を行えばよさそう。

nbstshnbstsh

NextAuth が logout & firebase auth が login の場合

application としての auth session を NextAuth に寄せている場合、このケースに関しては特段気にしなくてもいい気がする...

おそらく、session が存在しない場合 middleware で login page へリダイレクトさせることが多いだろうから、再度 login すれば NextAuth が login の状態になるし、firebase auth だけ login の状態で残っていたとしてもそもそも application が利用できない状態だろうから問題ない気もする

几帳面に対応するなら session がない場合は client-side で signOut してあげてもいいとは思う。

このスクラップは12日前にクローズされました