Next.js App Router & Auth.js (next-auth v5) に frebase auth 組み込んでみる
Next.js App Router & Auth.js に入門したついでに、firebase auth の idToken ベースの認証を Auth.js に組み込んでみる。
- server action では cookie base の認証管理を行いたい
- firestore, firebase storage に firebase auth の認証情報を利用して client side から直接アクセスしたい
といったケースを想定している。
firebase project 用意
firebase に project 作っておく。
firebase auth をセットアップしていく
install
npm install firebase
Setup sdk
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
import { getAuth } from 'firebase/auth';
import { firebaseApp } from './app';
export const firebaseAuth = getAuth(firebaseApp);
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 を利用する。
やること
- SignIn form 作成
- 認証情報を送信
- Credentials provider で受け取った認証情報を使った認証ロジックを実装
SignIn form 作成
firebases auth の email & password signIn を行うページを用意する。
email password form をもつ Server component として作る。
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;
認証情報を送信
- firebase auth で email & password signIn
- firebase auth uesr の idToken を取得
- next-auth の
signIn
で idToken を受け渡す
こんなイメージでやってみる。
firebase auth signInWithEmailAndPassword
用の util 作っておく。
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 });
}}
//...
>
//...
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 の取得までできてるのでこのまま進める。
Credentials provider で受け取った認証情報を使った認証ロジックを実装
NextAuthConfig.providers に Credentials provider を追加する。公式 Doc のサンプルコードを参考に idToken を受け取る形で作成。一旦 authorize
の挙動見たいので第一引数の credentials を log にだす。
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 ってのが追加されてる。
firebase-admin setup
server-side で firebase auth idToken の検証を行いたいので firebase-admin を setup する。
install
npm install firebase-admin --save
Initialize the SDK
import { initializeApp } from 'firebase-admin/app';
export const adminApp = initializeApp();
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
firebase auth idToken の検証
Credentials provider の authorize
callback 内で idToken の検証を行う。
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,
};
},
}),
],
});
エラー発生
エラー詳細
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 を削除することで対応。
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 されていたら利用できないように対応。
import 'server-only';
import { getApps, initializeApp } from 'firebase-admin/app';
const [existingApp] = getApps();
export const adminApp = existingApp ?? initializeApp();
動作確認
問題なく signIn できている
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 すれば良い。
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>
//...
server action を別ファイルへ移動
Client Components can only import actions that use the module-level "use server" directive.
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 に切り出す。
'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);
}}
>
//...
動作確認
ちゃんと client side で firebase auth の signIn が行われた後に、server side の NextAuth の signIn 処理が実行されている
signOut 時に firebase auth も signOut させる
NextAuth signOut 用の server action
'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 を作る。
'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>
);
};
動作確認
問題なし
認証エラーと redirect
signIn()
後に reidrect させたい場合は、error handling に注意が必要。
AuthError 以外の場合は catch した error を throw する ようにしないと、redirect してくれない。
というのも、signIn()
成功時の reidrect は NEXT_REDIRECT_ERROR を throw することによって実現されているため、try catch block で catch すると 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
}
所感
- firebase-admin が edge runtime で使えないが故に Middleware 使えないのがきつい
- ログイン状態が NextAuth と firebase auth で二重管理になるのが微妙ではある
verifyIdToken 相当の処理を自前で実装するか...?
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 が分離してりよろしくない。
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 状態であるべき。
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 を行えばよさそう。
NextAuth が logout & firebase auth が login の場合
application としての auth session を NextAuth に寄せている場合、このケースに関しては特段気にしなくてもいい気がする...
おそらく、session が存在しない場合 middleware で login page へリダイレクトさせることが多いだろうから、再度 login すれば NextAuth が login の状態になるし、firebase auth だけ login の状態で残っていたとしてもそもそも application が利用できない状態だろうから問題ない気もする
几帳面に対応するなら session がない場合は client-side で signOut してあげてもいいとは思う。