Next Auth(v4)を導入しようとしているあなたに届けたい仕様と使い所
NextAuthについて色々検証する機会があったので備忘録としてここに残します。
NextAuthの導入を考えている方に少しでも参考になれば嬉しいです!
NextAuthはとても便利ですが、表面的に使うと仕様が暗黙的で戸惑う部分が多かったので仕様を探り解説していきます!
概要
そもそもNextAuthとは何かについて軽くおさらいしましょう。
NextAuthの概要
NextAuthは、Next.jsアプリケーションのためのオープンソースの認証ライブラリです。
Next.jsアプリケーションに簡単に認証機能を追加するためのライブラリであり、対応プロバイダーはOAuth
(Google, Facebook, Twitterなど)、Email
、Credentials
、任意のカスタムプロバイダーに対応しています。セッション管理はクッキーを使用したセッション管理が標準で提供され、ユーザーの認証状態を簡単に管理できます。また、セッションの永続化も対応しており、対応できるデータベースはMongoDB
、PostgreSQL
、MySQL
、DynamoDB
、SQLite
など、様々なデータベースと連携が可能です。
NextAuthの主な機能
認証プロバイダーのサポート
- OAuth 2.0プロバイダー(Google、GitHub、Facebookなど)
- Email/Password認証
- Credentials認証(独自の認証ロジックを追加可能)
セッション管理
- サーバーサイドセッション、JWT(JSON Web Tokens)セッションの両方をサポート
- セッションの持続時間や更新のカスタマイズが可能
- 認証フローの各ステージ(サインイン、サインアウト、JWT生成など)に対してカスタムロジックを追加可能
- ログイン成功時やエラー発生時などのイベントに対するハンドラーを設定可能
ユーザー情報の保護
- トークンの暗号化とセキュアなストレージ
- 簡単に導入できるセキュリティ設定
概要をおさらいしたところで、実際に具体的な設定や使い方、仕様について解説していきます。
サーバー側の設定・挙動
今回は前提として以下の設定を用います。
※firebaseのクライアントSDKをNextAuthでセッション管理すると、メアド更新やパスワード更新した時の認証が引き継がれないのでオススメしません。もしfirebaseとNextAuthでセッション管理するのであれば、admin SDKを使用するようにしましょう。
import type { NextAuthOptions } from 'next-auth'
import CredentialsProvider from 'next-auth/providers/credentials'
import { getAccessToken } from '@api/token'
import { createMultipass } from '@repo/backend-api'
import { getAuth, signInWithEmailAndPassword } from 'firebase/auth'
const authOptions: NextAuthOptions = {
// ①シークレットキーの設定
secret: 'LlKq6ZtYbr+hTC073mAmAh9/h2HwMfsFo4hrfCx5mLg=',
// ②ログの差し込み
logger: {
error(code, metadata) {
console.error(code, metadata)
},
warn(code) {
console.warn(code)
},
},
providers: [
// ③認証プロバイダーの設定
CredentialsProvider({
name: 'credentials',
credentials: {
email: { label: 'Email', type: 'email', placeholder: 'example@example.com' },
password: { label: 'Password', type: 'password' },
},
// ④認証処理の実装
async authorize(credentials) {
if (!credentials) return null
try {
const auth = getAuth()
const res = await signInWithEmailAndPassword(auth, credentials.email, credentials.password)
const idToken = await res.user.getIdToken()
const accessToken = await getAccessToken(idToken)
if (accessToken) {
return {
id: res.user.uid,
email: res.user.email,
accessToken,
idToken,
}
}
} catch (error) {
throw new Error('Invalid credentials')
}
return null
},
}),
],
// ⑤セッションの設定
session: {
strategy: 'jwt',
},
// ⑥処理のコールバック設定
callbacks: {
async signIn({ user, account, profile, credentials }) {
return true
},
async redirect({ url, baseUrl }) {
return url.startsWith(baseUrl) ? url : baseUrl
},
async jwt({ token, trigger, session, user }) {
if (trigger === 'update') token.name = session?.user?.name
return {
...user,
...token,
}
},
async session({ user, session, token }) {
session.user = user
session.accessToken = token.accessToken
session.idToken = token.idToken
return session
},
},
}
export { authOptions }
// ⑦interfaceの拡張
declare module 'next-auth' {
interface Session {
accessToken?: string
idToken?: string
}
interface User {
accessToken?: string
idToken?: string
}
}
declare module 'next-auth/jwt' {
interface JWT {
accessToken?: string
idToken?: string
}
}
import type { NextApiRequest, NextApiResponse } from 'next'
import NextAuth from 'next-auth/next'
import { authOptions } from '@/auth'
// ⑧NextAuthのハンドラを設定する
export default async function auth(req: NextApiRequest, res: NextApiResponse) {
return await NextAuth(req, res, {
...authOptions,
})
}
// ⑨クライアントの設定
import { SessionProvider, signIn as nextAuthSignIn, useSession } from 'next-auth/react'
export default APP = () => {
return (
<SessionProvider>
<Card>
<Title>NextAuth</Title>
<Divider />
<Flex>
<NextAuthLogin />
</Flex>
</Card>
</SessionProvider>
)
}
const NextAuthLogin = () => {
const { register, handleSubmit } = useForm({
defaultValues: {
email: '',
password: '',
},
})
const onSubmit = handleSubmit(async (data) => {
await nextAuthSignIn('credentials', {
email: data.email,
password: data.password,
redirect: false,
})
})
const { data, status, update } = useSession()
return (
<FormContainer>
<form onSubmit={onSubmit}>
<Flex>
<FormInput type="email" {...register('email')} placeholder="email" />
<FormInput type="password" {...register('password')} placeholder="password" />
<Button type="submit">Login</Button>
<Button
type="button"
onClick={async () => {
await update()
}}
>
UPDATE
</Button>
</Flex>
</form>
<pre>{JSON.stringify(data, null, 2)}</pre>
</FormContainer>
)
}
①シークレットキーの設定
secret: 'LlKq6ZtYbr+hTC073mAmAh9/h2HwMfsFo4hrfCx5mLg=',
ページを跨いだセッション管理ができなくなる
ので必ず設定しておくと良いでしょう。
JWT_SESSION_ERROR
のエラーがthrowされます。
next-auth.session-token
としてクライアントのCookieに発行し、セッションデータを取得してくる際にシークレットキーで暗号化されたJWTをデコードしsessionデータとして返しています。
なので、仮にクライアントのCookieのnext-auth.session-token
が漏洩したとしてもサーバー側で保持しているシークレットキーを知らなければデコードすることができないようになっています。
②ログの差し込み
logger: {
error(code, metadata) {
console.error(code, metadata)
},
warn(code) {
console.warn(code)
},
余談ですがデプロイ先がVercelであればVercelのロギングサービスで確認することもできます。
③認証のプロバイダを設定
CredentialsProvider({
name: 'credentials',
credentials: {
email: { label: 'Email', type: 'email', placeholder: 'example@example.com' },
password: { label: 'Password', type: 'password' },
},
CredentialsProvider
を用いて独自の認証方法を定義することができます。
この例では、emailとpasswordを受け取って認証することを設定しています。
ここで作成した設定を元にクライアント側で認証を行うことができます。
import { signIn } from 'next-auth/react'
await signIn('credentials', {
email: data.email,
password: data.password,
redirect: false,
})
next-auth/react
から提供されているsignIn
の第一引数にCredentialsProvider
で設定したname
を渡すと型もつくのでとても開発しやすくなります。
providers: [
Apple,
Auth0,
Cognito,
Coinbase,
// ...中略
Slack,
Spotify,
Twitch,
Twitter,
],
また、providers
は複数設定することができるので、一つのサービスで複数の認証方法を採用することも容易です。
どの認証方法が使えるかは公式ドキュメントにまとまっています。
④認証処理の実装
async authorize(credentials) {
if (!credentials) return null
try {
const auth = getAuth()
const res = await signInWithEmailAndPassword(auth, credentials.email, credentials.password)
const idToken = await res.user.getIdToken()
const accessToken = await getAccessToken(idToken)
if (accessToken) {
return {
id: res.user.uid,
email: res.user.email,
accessToken,
idToken,
}
}
} catch (error) {
throw new Error('Invalid credentials')
}
return null
},
ここが認証処理の箇所になります。
ここで行っているのはfirebaseのSDKを使用してauthを取得して、それを元にcredentials
で渡ってきたemail
とpassword
を用いてsignInWithEmailAndPassword
を実行しています。
firebaseのidToken
を使ってアプリケーションやサーバーのAPIと疎通するaccessToken
を取得しsession情報として返り値を設定しています。
前述したsignIn
関数を実行すると/api/auth/signin
にルーティングされauthorize
が実行されます。成功すれば戻り値で指定したデータが、後述するcallbackのsignIn
とjwt
が実行され、最終的にsession
としてクライアントにsession情報を渡すことができます。
⑤セッションの設定
session: {
strategy: 'jwt',
},
strategy: 'jwt' | 'database'
を設定することができ、デフォルトではjwt
が設定されます。sessionを永続的に持ちたいのであればdatabase
を指定し、そうでないのであればjwt
を設定すればOKです。
要件や課題次第でどちらが適切か判断し、設定してください。
jwt
を指定すれば、sessionデータをJWTで管理し、暗号化されたJWTがセッションクッキーに保存されます。
database
を指定するとadapter
の設定が必須になります。adapter
にはsessionの保存先のDBを指定します。また、この設定を行う場合はセッションクッキーのsessionToken
のみ扱う挙動になります。そして、callbackで設定しているjwt
は不要とみなされ実行されなくなります。
以下がstrategy: "database"
で設定する場合のサンプルです。
session: {
strategy: "database",
// Seconds - アイドル・セッションが期限切れで無効になるまでの時間。
maxAge: 30 * 24 * 60 * 60, // 30 days
// Seconds - セッションを延長するために、データベースへの書き込み頻度を制限する。
// 書き込み操作を制限するために使用します。常にデータベースを更新するには `0` に設定します。
// 注: JSON Web トークンを使用している場合、このオプションは無視されます。
updateAge: 24 * 60 * 60, // 24 hours
// セッション・トークンは通常、ランダムなUUIDか文字列のどちらかです。
// よりカスタマイズされたセッショントークン文字列が必要な場合は、独自の生成関数を定義することができます。
generateSessionToken: () => {
return randomUUID?.() ?? randomBytes(32).toString("hex")
}
}
⑥処理のコールバック設定
callbacks: {
async signIn({ user, account, profile, credentials }) {
return true
},
async redirect({ url, baseUrl }) {
return url.startsWith(baseUrl) ? url : baseUrl
},
async jwt({ token, trigger, session, user }) {
if (trigger === 'update') token.name = session?.user?.name
return {
...user,
...token,
}
},
async session({ user, session, token }) {
session.user = user
session.accessToken = token.accessToken
session.idToken = token.idToken
return session
},
},
signIn
callbacks: {
async signIn({ user, account, profile, email, credentials }) {
const isAllowedToSignIn = true
if (isAllowedToSignIn) {
return true
} else {
// Return false to display a default error message
return false
// Or you can return a URL to redirect to:
// return '/unauthorized'
}
}
}
このコールバックは主にEmail Provider
を使用する際に有効です。ユーザがVerification Request
を行ったとき(サインインするためのリンクが記載されたメールが送信される前)と、サインインメールに記載されたリンクをユーザが有効化した後の両方でトリガされます。
true
を返せば処理が続行されます。false
を返せばVerify Errorとしてエラーを返すことができ、処理がスタックされます。false
以外にもリダイレクト先のURLを設定することもできます(/unauthorized
のようにパス指定できる)
redirect
callbacks: {
async redirect({ url, baseUrl }) {
// Allows relative callback URLs
if (url.startsWith("/")) return `${baseUrl}${url}`
// Allows callback URLs on the same origin
else if (new URL(url).origin === baseUrl) return url
return baseUrl
}
}
このコールバックはユーザーがコールバックURLにリダイレクトされるたびに呼び出されます(サインイン時やサインアウト時など)。デフォルトではサイトと同じURLのみが許可されますが、リダイレクトコールバックを使って動作をカスタマイズすることができます。リダイレクトさせたくない場合はsignIn()
やsignOut()
などでオプションでredirect: false
をすると効かなくなります。
jwt
callbacks: {
async jwt({ token, account, profile }) {
// Persist the OAuth access_token and or the user id to the token right after signin
if (account) {
token.accessToken = account.access_token
token.id = profile.id
}
return token
}
}
このコールバックはJWTの作成時(サインイン時など)や更新時(クライアントでセッションにアクセスする時など)に呼び出されます。返される値は暗号化され、クッキーに保存されます。主にクライアントからsessionの呼び出しを行うときに内部でこのコールバックが実行されますが、前述した通り、JWTセッションを使用している場合のみ実行でき、DBでsessionを永続化している場合は実行されません。サインイン時に引数のuser
もしくはaccount
(OAuthを使用している場合はAccountが渡ってくる)からjwtに追加したいデータを抽出して、引数のtoken
に渡すことができます。こうすることでsession
データにaccsess_token
などの機密性が高いデータも渡すことが可能です。
また、access_tokenのリフレッシュなど、jwtの更新を行いたい場合は引数のtrigger
のupdate
をトリガーとしてリフレッシュ処理を行えばOKです。
session
callbacks: {
async session({ session, token, user }) {
// Send properties to the client, like an access_token and user id from a provider.
session.accessToken = token.accessToken
session.user.id = token.id
return session
}
}
このコールバックはsessionが呼び出される度に実行されます。主にクライアント側で実行されるuseSession()
やサーバーで実行されるgetServerSession
、/api/auth/session
にアクセスした時などにsessionが実行され、レスポンスとしてsessionデータが返ってきます。
{
user: {
name: string
email: string
image: string
},
expires: Date // This is the expiry of the session, not any of the tokens within the session
}
▲デフォルトでは機密性の低いデータが返ってくるようになっています。もしsessionデータにaccess_tokenなど機密性が高いデータや別のデータも追加したい場合は前述のjwt
のコールバックで追加を行った上で、引数のtoken
から取り出してsessionに追加する必要があります。
⑦interfaceの拡張
declare module 'next-auth' {
interface Session {
accessToken?: string
idToken?: string
}
interface User {
accessToken?: string
idToken?: string
}
}
declare module 'next-auth/jwt' {
interface JWT {
accessToken?: string
idToken?: string
}
}
session
やuser
といったNextAuthから提供される、データの型は最低限の型のみサポートされています。accessToken
などsessionデータのカスタマイズを行なったら、そのデータの型もサポートしてほしいと思います。その場合はdeclare moduleでインターフェースの更新を行なって下さい。
types/next-auth.d.ts
などを作成してそこでインターフェースの更新を行うように記載がありますが、User
などの名前空間はfirebaseのSDKや、既存のアプリケーションでもよく使われている命名だと思うので、NextAuthの設定ファイルで一緒に宣言したほうが場合によっては適していると言えます。
⑧NextAuthのハンドラを設定する
export default async function auth(req: NextApiRequest, res: NextApiResponse) {
return await NextAuth(req, res, {
...authOptions,
})
}
※上記の記述はPage Routerで記載しています。
App Routerの場合はapp/api/auth/[...nextauth]/route.ts
に
Page Routerの場合はpages/api/auth/[...nextauth].ts
にハンドラを置きます。
そうすることで以下のルーティングで各処理を呼び出すことができます。基本は上記の処理をおくだけでもうOKです。各エンドポイントは大抵クライアントに提供される関数の処理で内部的にリクエストを行っている
ので、基本開発者は各エンドポイントに明示的にリクエストすることは少ないと思います。
/api/auth/signin
GETメソッドでリクエストすると、サインイン処理(authorize)が実行されます。
signIn()
は内部でこのエンドポイントにリクエストしています。
/api/auth/signin/:provider
POSTメソッドでリクエストすると、設定した各プロバイダーのサインイン処理が実行されます。
OAuth プロバイダの場合、このエンドポイントを呼び出すと、ID プロバイダへの認証リクエストが開始されます。
このエンドポイントをPOSTするときはCSRFトークン
が必要になります。
CSRFトークン(Cross-Site Request Forgery トークン)はCSRF攻撃から保護するための処置として用いられるトークンです。
CSRFトークン
は/api/auth/csrf
のリクエストでCookieに付与されます。
signIn()
は内部でこのエンドポイントにリクエストしています。
/api/auth/callback/:provider
サインイン中にOAuthサービスから返ってくるリクエストを処理するエンドポイントです。
checks
をサポートしている OAuth 2.0 プロバイダでは、state
パラメータをサインインフローの開始時に生成されたものと照合します。
▲詳しくはOAuthの仕様をご確認ください。
/api/auth/signout
GETメソッドでリクエストすると、サインアウトページへ表示・リダイレクトされます。
POSTメソッドでリクエストすると、ユーザのサインアウトを実行します。
悪意のあるリンクがユーザの同意なしにサインアウトを引き起こすことを防ぐためにPOST送信で実行しています。ユーザーのセッションは、セッションを保存するために選択したフローに応じて、cookie・DBから無効化/削除されます。
またこのリクエストにはCSRFトークン
が必要になります。
signOut()
は内部でこのエンドポイントにリクエストしています。
/api/auth/session
GETメソッドでリクエストすると、クライアントセーフなセッションオブジェクトをレスポンスします。セッションがない場合は空のオブジェクトをレスポンスします。返されるセッションオブジェクトの内容は、前述したセッションコールバックで設定可能です。
useSession()
やgetSession()
は内部でこのエンドポイントにリクエストしています。
/api/auth/csrf
GETメソッドでリクエストすると、CSRFトークン
を含むオブジェクトを返します。NextAuth.js
では、すべての認証ルートにCSRF対策が施されています。署名されたHttpOnly、ホストのみのCookieを使用する「ダブルサブミットのCookieメソッド」
を使用しています。
「ダブルサブミットのCookieメソッド」
を簡単に説明すると、CSRFトークンをCookieとして送信し、フォームデータまたはリクエストヘッダーに同じトークンを含めて送信することでCSRF攻撃のセキュリティ強化することができます。これにより、攻撃者がリクエストを偽造するためには、ユーザーのCookieにアクセスできる必要があり、さらにそのトークンを正確に取得してリクエストに含める必要があります。
このエンドポイントが返すCSRFトークンは、すべてのAPIエンドポイントへのPOST送信において、csrfToken
という名前のフォーム変数として渡す必要があります。
getCsrfToken()
は内部でこのエンドポイントにリクエストしています。
/api/auth/providers
GETメソッドでリクエストすると、設定されているOAuthサービスのリストと、各サービスの詳細(サインインやコールバックURLなど)をレスポンスします。
カスタムサインアップページを動的に生成したり、設定されている OAuth プロバイダごとにどのようなコールバック URL が設定されているかを確認したりするのに用いられます。
getProviders()
は内部でこのエンドポイントにリクエストしています。
⑨クライアントの設定
ポイントは主にSessionProvider
とuseSession
とsignIn
のあたりです。
SessionProvider
import { SessionProvider } from "next-auth/react"
export default function App({
Component,
pageProps: { session, ...pageProps },
}) {
return (
<SessionProvider
session={session}
basePath="/"
refetchInterval={5 * 60}
refetchOnWindowFocus={true}
>
<Component {...pageProps} />
</SessionProvider>
)
}
basePath
はアプリケーションのルートとなるページのパスを渡します。
refetchInterval
は指定した秒数毎にsessionを再取得します。
refetchOnWindowFocus
はウィンドウを再度フォーカスする度にsessionを再取得します。
いずれもオプショナルな設定なので、必要に応じて設定して下さい。
useSession
import { useSession } from "next-auth/react"
export default function Component() {
const { data: session, status } = useSession()
if (status === "authenticated") {
return <p>Signed in as {session.user.email}</p>
}
return <a href="/api/auth/signin">Sign in</a>
}
data
(session)はsessionのコールバックで設定したsessionデータが入ってきます。
status
はそれぞれ、"loading" | "authenticated" | "unauthenticated"
が入ってきます。loading
は認証の過程状態。
authenticated
は認証済みの状態。
unauthenticated
は未認証の状態。
ここはカスタムできないのでstatus
で処理をハンドリングする場合はここを考慮する必要があります。
他にもupdate
が提供されていますが、それを実行すると前述した、jwtのコールバックの引数であるtrigger
が update
となり、更新処理を実行することができます。
signIn
import { signIn } from "next-auth/react"
export default () => (
<button onClick={() => signIn("google")}>Sign in with Google</button>
)
signIn
はproviders
やCredentials
などで設定した認証プロバイダーに基づいた認証を実行することができます。上記だとgoogleのOAuthを使用した認証を実行することができます。
ルーティングのカスタム
デフォルトのベースパスは/api/auth
ですが、NEXTAUTH_URL
でカスタムパスを指定することでカスタマイズ可能です。
NEXTAUTH_URL=https://example.com/myapp/api/authentication
▲このように設定することで/api/auth/signin
を/myapp/api/authentication/signin
にパスを変更することもできます。既存のアプリケーションにも導入しやすいです。
NextAuthの導入に適しているケース
NextAuthの導入が適しているケースは、主にシンプルな認証フローが必要な場合に適しています。
OAuth認証(Google、GitHub、Facebookなど)、Email/Password認証、Credentials認証などの標準的な認証フローを簡単に設定できます。
複数の認証プロバイダーをサポートする必要がある場合
複数のプロバイダーを一元的に管理し、ユーザーに複数のログインオプションを提供する場合に適しています。
Next.jsを使用している場合
NextAuthはNext.jsに特化して設計されており、Next.jsアプリケーションとシームレスに統合できます。
迅速なセットアップが求められる場合
クイックスタートが可能で、数行のコードで認証機能を実装できます。設定ファイルにプロバイダーの情報を追加するだけで動作します。
カスタマイズ性が必要な場合
認証フローの各ステージに対してカスタムロジックを追加できるため、特定のビジネスロジックに基づいた認証処理が求められる場合に適しています。
セッション管理が必要な場合
サーバーサイドセッション、JWTセッションの両方をサポートしており、ユーザーのセッション管理を簡単に行えます。
NextAuthの導入に適していないケース
Next.js以外のフレームワークを使用している場合
NextAuthはNext.js専用のライブラリであり、他のフレームワークやプラットフォーム(React単体、Angular、Vueなど)には対応していません。
非常に複雑な認証フローが必要な場合
特殊な認証プロトコルや複雑な多要素認証(MFA)が必要な場合、NextAuthでは対応が難しいことがあります。そのような場合には、Auth0やFirebase Authenticationなどの専用サービスが適しています。
既に他の認証サービスを利用している場合
既存のシステムでAuth0、Firebase Authentication、AWS Cognitoなどの認証サービスを利用している場合、それらを引き続き使用する方が一貫性が保たれ、移行コストも低くなります。
大規模なエンタープライズ環境での利用
エンタープライズレベルのスケーラビリティやサポートが必要な場合、NextAuthは適していないかもしれません。Auth0やOktaなど、エンタープライズ向けの認証サービスの方が適していることが多いです。
おわり
いかがだったでしょうか?
今回はシンプルかつ網羅的にNextAuthを解説してみました。
他にもNextAuthには様々な機能が備わっているようなので、Next.jsでアプリケーションを開発する際には前向きに導入の検討してみて下さい。
1から実装すると面倒だったり困難だったり色々考慮しなければならない認証周りの機能をシンプルに実装できます。
最後にNextAuthを使った開発に役立ちそうなexampleリポジトリがあったのでこちらを参考にして実際に開発してみて下さい!
NextAuth V5も来ますね!
Discussion