1. 始めに
こんな題名にしておいてなんですが、実のところこの二つを組み合わせる必要性というのはあんまりなかったりします。
自前でデータベースを持っているのであればAuth.jsだけで大丈夫ですし、Firebaseを認証インフラにしたいのであればFirebase単体で大丈夫だからです。両者ともにOAuth認証を備えていますし、サインイン・サインアウトなどといった認証に関する機能も一通り提供されています。
Auth.jsは認証基盤としての性質が強いので、Firebaseよりも拡張性が高い点や、Auth.jsはサーバーサイドにおけるセッション管理機能も提供されてあるので、もしサーバーサイドでも認証状態や情報の取得が必要なのであればセッション管理が楽な点が主な違いとして挙げられると思います。もしAuth.jsを使わずにサーバーサイドでセッション管理が必要なのであれば、cookieを操作することになると思います。
今回はバックエンドでもセッション管理する可能性があることを踏まえて、両者を組み合わせてNext.jsに組み込んでみたので、忘備録も兼ねて紹介しようと思います。
Auth.jsの導入については公式サイトを参考にさせていただきました。
目次
2. Firebaseの設定
Firebase Authenticationを使うためにはまず、Firebaseプロジェクトを作成し、authenticationを取り扱う画面からメール/パスワードによる認証を追加します。
サイトの指示に従って設定を進めていくと認証を提供するために必要な値が表示されますので、これをNext.js側のファイルに記述して初期化します。
まず、Next.jsにFirebaseのライブラリーをインストールします。
npm install firebase @types/firebase
次に、envファイルにFirebaseの情報を記述します。
そして、firebaseのconfigファイルを作成し、そこに以下のように設定を行います。
import { initializeApp } from "firebase/app";
import { getAuth } from "firebase/auth";
const firebaseConfig = {
apiKey: process.env.FB_API_KEY,
authDomain: process.env.FB_AUTH_DOMAIN,
projectId: process.env.FB_PROJECT_ID,
storageBucket: process.env.FB_STORAGE_BUCKET,
messagingSenderId: process.env.FB_MESSAGING_SENDER_ID,
appId: process.env.FB_APPID,
measurementId: process.env.FB_MEASUREMENT_ID,
};
const app = initializeApp(firebaseConfig);
const FbAuth = getAuth(app);
export { FbAuth }
今回は認証にだけしか興味がないので、FbAuthだけexportしています。
3. Auth.jsの設定
まず、Auth.jsライブラリーをNext.jsにインストールします。
npm i next-auth@beta
次に、envファイルに暗号用の値を代入したAUTH_SECRET変数を記述します。この値はAuth.jsが暗号化のために使います。
AUTH_SECRET=GMJq2ibpbnJALiLVC73yyVX7LVwJPS8uDF+043VJH5Q=
4. Authの実装
Auth.jsの諸々の設定を書いていきます。
4-1. auth.config
よく見るAuth.jsの構成は、以下のような感じになると思います。
全体像を見せるために、今のところは具体的な記述内容や詳細な解説を省きます。
const authConfig = {
pages:{
signIn: "signIn"
},
session:{
strategy: "jwt"
}
callbacks: {
jwt(){},
session(){},
},
providers:[
Credentials({})
]
}
今回のプロジェクトでは、上記のconfigの設定を二つのファイルに分けて行いました。その理由はconfig設定をmiddlewareでも使いまわしたいからですが、実際にどう使ったかは次節でお見せします。
まず、providers以外の設定を記述します。
import type { NextAuthConfig } from 'next-auth';
export const authConfig = {
pages: {
signIn: '/signIn',
},
session: {
strategy: "jwt",
},
callbacks: {
async authorized({ auth, request: { nextUrl } }){
const isLoggedIn = !!auth?.user;
const isOnUser = nextUrl.pathname.includes("user");
if (isOnUser) {
if (isLoggedIn) return true;
return false;
} else if (isLoggedIn) {
return Response.redirect(new URL('/user/home', nextUrl));
}
return true;
},
async jwt({ token, user, }){
if(user && user.id ){
token.id = user.id;
}
return token;
},
async session({ session, token }){
session.user.id = token.id;
return session;
}
},
providers: [],
} satisfies NextAuthConfig;
pagesプロパティはAuth.jsのデフォルトの認証ページではなく自前の認証ページを使いたいときに設定します。
sessionプロパティは認証方法をデータベースにするかjwtにするかを選べます。
callbacksは、認証に関する挙動を制御する非同期関数を設定できます。authorizedはmiddlewareで呼び出され、その時にどのようにアクセスを制御するかを記述します。この場合で言えば、クライアントからのアクセスにmiddlwareが割り込み、認証済みかどうかを判定します。特にuserパスに続くURIにクライアントがアクセスしようとした際、認証ができていなければ拒否するようにしています。
jwtはJWTが作成されたり変更されたりしたときに呼び出されます。この関数で返却された値がJWTに保存されsessionコールバックへと続きます。ちなみに、JWTを暗号化するために先ほどenvファイルに設定したAUTH_SECRET変数が用いられます。
tokenオブジェクトはJWTのサブセットで、userオブジェクトはここで言えば、次のファイルで定義するCredentialsのauthenticateメソッドの返り値となります。
jwtの引数としてのaccountオブジェクトにも、providerとして何の機能を使ったのかという情報以外にも、providerAccountIdのような値が入っているのでこれをtoken.idに渡すこともできますが、確実なのはuserオブジェクトを使う方だと思います。
sessionコールバックはセッションが確認されたとき、即ち/api/sessionエンドポイントにアクセスした際に呼び出されます。/api/sessionはuseSessionを呼び出した時などにアクセスされます。この場で返却された値がクライアントに渡されるので機密情報を入れないように注意してください。
なお、token引数はjwtセッションの時にだけ使えて、user引数はデータベースセッションの時だけ使えます。ここでは、クライアントに送るUserオブジェクトのidをJWTのサブセットであるtokenオブジェクトのidに合わせています。即ち、jwtメソッドの引数にあったtokenオブジェクトと同じということですね。
次に、providersを定義するファイルを紹介します。
exportでhandlersではなくGETやPOSTメソッドを出しているのは意図的なものです。apiの節で解説します。
import NextAuth from 'next-auth';
import Credentials from 'next-auth/providers/credentials';
import { signInWithEmailAndPassword } from "firebase/auth";
import { FbAuth } from './firebase.config';
import { z } from 'zod';
import { authConfig } from './auth.config';
export const { auth, signIn, signOut, handlers: { GET, POST } } = NextAuth({
...authConfig,
providers: [
Credentials({
async authorize(credentials) {
const parsedCredentials = z
.object({ email: z.string().email(), password: z.string().min(6) })
.safeParse(credentials);
if (parsedCredentials.success) {
const { email, password } = parsedCredentials.data;
const userCredential = await signInWithEmailAndPassword(FbAuth, email, password);
const user = userCredential.user
if (user.uid) {
return { id: user.uid, email: user.email, }
};
return null;
}
console.log('Invalid credentials');
return null;
}
})
],
});
ここではメールとパスワードを使った認証を定義するので、providersプロパティーの値の配列にCredentialsメソッドを渡します。
ここでは引数をauthorizeメソッドを定義したオブジェクトのみを渡していますが、以下のように認証プロバイダーの詳細設定を記述することもできます。
Credentials({
name: 'credentials',
credentials: {
email: { label: 'email', type: 'email', placeholder: 'example@example.com' },
password: { label: 'password', type: 'password' },
},
async authorize(credentials){}
})
これによりクライアント側のsignIn関数の第二引数に、emailとpasswordプロパティに取得した値を設定したオブジェクトを渡して認証を実装することもできますが、今回はformDataの値をそのまま第二引数に渡す想定でやっているので設定はしませんでした。
このメソッドの中で、firebaseのsignInWithEmailAndPasswordメソッドを実行しており、そのままfirebaseを使った認証を実装しています。
最後に、型についての説明を加えます。
tokenはJWTのサブセットですが、JWTオブジェクトにはidがありません。そのためこのままだとcallbacksプロパティの値に定義したsession関数の引数のtoken.idがunknownのままになっています。このため、next-auth/jwtの型を拡張してあげる必要があります。
具体的には、以下の通りです。
import NextAuth from 'next-auth'
import type { DefaultSession } from 'next-auth'
import type { JWT } from "next-auth/jwt"
declare module "next-auth/jwt" {
interface JWT {
id: string
}
}
userオブジェクトの中にはid,name,email,imageプロパティなどがありますが、これ以外に独自に値を設定したいのであれば以下のようにプロパティを追加してあげる必要があります。
declare module 'next-auth' {
interface Session {
user?: {
role: string
} & DefaultSession['user']
}
}
これで、Auth.jsの基本的な設定は終わりました。
この節で紹介した引数やメソッドの他にもまだあるので、気になった方は公式レファレンスを参照することをお勧めします。
4-2. middleware
auth.config.tsで定義したauthConfigはmiddlwareで使います。
middlwareの実装は以下の通りです。
import NextAuth from 'next-auth';
import { authConfig } from './config/auth.config';
export default NextAuth(authConfig).auth;
export const config = {
matcher: ['/((?!api|_next/static|_next/image|.*\\.png$).*)'],
};
NextAuthのauthプロパティをエクスポートしているのがわかるでしょうか。このためにprovidersなしのauthConfigを先に定義していたのです。
なお、matcherはmiddlwareが介入するパスについて定義しています。
4-3. api
私が実装した時、GET /api/auth/session 404
というエラーによく合いました。これは、api/auth/sessionのフォルダーにGETメソッドに対応した関数がなかったためです。
api/auth/*
のパスに対応させるには、app/api/auth/[...nextauth]/route.ts
にハンドラーを設定することで可能です。
コードは一文です。
export { GET, POST } from "@/config/auth"
ここで、handlersをインポートしてGETやPOSTとしてエクスポートするやり方ではうまくいかなかったので注意です。
import { handlers } from "@/config/auth";
export { handlers as GET, handlers as POST }
もしかしたら私の環境ではうまくいかなかっただけかもしれません。ここら辺はっきりしませんでした……。
5. 認証の実装
最後に、サインインとサインアウトの実装を紹介して終わります。
まず、サインイン処理です。
"use server";
import { FormData } from "../types/FormType";
import { signIn } from "@/config/auth";
import { AuthError } from "next-auth";
export async function authenticate(formData:FormData){
try{
await signIn("credentials", formData);
}catch(error){
if (error instanceof AuthError) {
switch (error.type) {
case 'CredentialsSignin':
return 'Invalid credentials.';
default:
return 'Something went wrong.';
}
}
throw error;
}
}
次にサインアウト処理です。
"use server";
import { signOut } from "@/config/auth";
export async function signOutAction(){
await signOut();
}
注意したいのが、どちらも内部的にはheaderを用いているので、サーバー側でなければ機能しないということです。ホスティング環境の問題でserver actionsが使えない場合、api routeに配置するとよいでしょう。
6. おわりに
この記事は今日三本目なのですが、一番重くて大変でした。
一瞬react-hook-formのUIコンポーネントまでまとめようかと思ったのですが、今回チームの中でreact-hook-formを使う流れになったから使っただけなので、将来的に再度ふれることはないだろうと思って辞めました。個人的にはform actionsを使う派閥なのです。
react-hook-formの解説は多いと思うので、ぜひその方々の記事をご覧ください。