🔒

[Next.js] App Router 時代の next-auth の使い方

2023/11/06に公開1

はじめに

こんにちは、株式会社TERASSでエンジニアをしている myrear です。
つい先日 Next.js 14 のリリースがアナウンスされましたね。
Server Actions が安定版になるなどいくつかの変更がありますが、要点は既に解説している方がいらっしゃるのでそちらを参考にしていただければと思います。

https://zenn.dev/ame_x/articles/d2d04b703bd9b1

その中でも筆者が気になったのが Next.js Learn の新しいコースで、 App Router に関するコースが新しく追加されました。
Next.js における認証といえば next-auth が一般的かと思いますが、 Pages Router との組み合わせは調べれば出てくるものの App Router との組み合わせがどうなるのかははっきりとしないままでした。
そこで今回 Next.js Learn に新しく追加されたチャプターである Chapter 15 Adding Authentication を参考にして App Router における認証にトライしてみます。

環境構築

Next.js Learn は本来チャプター1から順々にやっていくものです。
なので突然チャプター15だけ読んでもそれ以前のチャプターを履修済みの前提で話が進むのでいまいちとっかかりにくいです。
なのでチャプター15は参考程度に読むことにし、まずは Next.js が動く環境を作っていきます。
と言っても create-next-app するだけです。

$ npx create-next-app@latest

Ok to proceed? (y) y
✔ What is your project named? … app-router-auth
✔ Would you like to use TypeScript? … Yes
✔ Would you like to use ESLint? … Yes
✔ Would you like to use Tailwind CSS? … Yes
✔ Would you like to use `src/` directory? … Yes
✔ Would you like to use App Router? (recommended) … Yes
✔ Would you like to customize the default import alias (@/*)? … Yes
✔ What import alias would you like configured? … @/*

プロジェクト名は何でもいいですがここでは app-router-auth としました。
一旦ディレクトリ移動して動くことを確認します。

$ cd app-router-auth
$ npm run dev

http://localhost:3000 を開いてこんな画面になっていればOKです。

ページの追加

/dashboard /login の2つのルートを追加します。
/dashboard がログイン後の遷移先、 /login がログイン時の遷移先の役割になります。
中身は一旦適当で大丈夫です。

src/app/dashboard.tsx
export default function Dashboard() {
  return <div>dashboard</div>
}
src/app/login.tsx
export default function Login() {
  return <div>login</div>
}

next-auth の設定

next-auth を追加します。

$ npm i next-auth@beta

プロジェクトルートに .env ファイルを以下の内容で作成します。

.env
AUTH_SECRET=some-secret
AUTH_URL=http://localhost:3000/api/auth

AUTH_SECRET は今回利用しないのですがないと next-auth に怒られるので適当な値を入れておきます。

プロジェクトルートに next-auth の設定ファイルを作成します。

auth.config.ts
import type { NextAuthConfig } from 'next-auth'

export const authConfig: NextAuthConfig = {
  providers: [],
}

この設定ファイルに色々と追加していきます。

Credentials プロバイダの設定

今回の認証にはメールアドレスとパスワードの2つで行う Credentials プロバイダ を使用します。
簡単のため、メールアドレスは user@nextemail.com かつパスワードは 123456 なら認可、それ以外は拒否します。
認可中の状態を目で追いやすくするために5000ミリ秒待つことにします。

auth.config.ts
  import type { NextAuthConfig } from 'next-auth'
+ import Credentials from 'next-auth/providers/credentials'

  export const authConfig: NextAuthConfig = {
-   providers: [],
+   providers: [
+     Credentials({
+       async authorize(credentials) {
+         await new Promise((resolve) => setTimeout(resolve, 5000))
+
+         const email = 'user@nextemail.com'
+         return credentials.email === email && credentials.password === '123456'
+           ? { id: 'userId', email }
+           : null
+       },
+     }),
+   ],
  }

リダイレクト先のページの設定

認証されていなければログインページに遷移させたい、みたいなケースはよくあると思います。
このような設定は pages オプションから行えます。

auth.config.ts
  import type { NextAuthConfig } from 'next-auth'
  import Credentials from 'next-auth/providers/credentials'

  export const authConfig: NextAuthConfig = {
    providers: [
      // ...
    ],
+   pages: {
+     signIn: '/login',
+   },
  }

ここではサインインが必要なときは /login に遷移させるようにしました。

ルートの保護

ルートに対してアクセスがあったときに、認証されている/されていないでリダイレクトしたりみたいな処理を書いていきます。

auth.config.ts
  import type { NextAuthConfig } from 'next-auth'
  import Credentials from 'next-auth/providers/credentials'

  export const authConfig: NextAuthConfig = {
    // ...
+   callbacks: {
+     authorized({ auth, request: { nextUrl } }) {
+       const isLoggedIn = !!auth?.user
+       const isOnDashboard = nextUrl.pathname.startsWith('/dashboard')
+       if (isOnDashboard) {
+         if (isLoggedIn) return true
+         return false
+       } else if (isLoggedIn) {
+         return Response.redirect(new URL('/dashboard', nextUrl))
+       }
+       return true
+     },
+   },
  }

何やら少し複雑に見えますが、リダイレクトループを防ぎつつ、ログインしていれば /dashboard に遷移するようにしているだけです。

ミドルウェアの作成

もろもろの設定を動作させるためのミドルウェアを作成します。

src/middleware.ts
import NextAuth from 'next-auth'
import { authConfig } from './auth.config'

export default NextAuth(authConfig).auth

export const config = {
  // https://nextjs.org/docs/app/building-your-application/routing/middleware#matcher
  matcher: ['/((?!api|_next/static|_next/image|.png).*)'],
}

ミドルウェアの詳細について詳細には触れませんが、簡単に言うとミドルウェアが実行する関数とミドルウェアを適用する範囲を設定しています。

認証関連の関数の作成

認証をする上でログイン・ログアウト用の関数は欠かせません。
auth.ts を作成します。

src/auth.ts
import NextAuth from 'next-auth'
import { authConfig } from '../auth.config'

export const { signIn, signOut } = NextAuth(authConfig)

これらの関数は基本的に Server Actions として呼ばれます。

認証フローの整備

設定は済んだので認証の流れを確認できるようにしていきます。

ログインフォームの実装

まずは認証するための Server Actions を作成します。
単純に <form action={signIn}> としてしまうと結果がわからないので useFormState に渡せるようなシグネチャにしてあげる必要があります。

src/app/login/actions.ts
'use server'

import { signIn } from '@/auth'

export async function authenticate(prevState: boolean, formData: FormData) {
  try {
    await signIn('credentials', Object.fromEntries(formData))
    return true
  } catch (error) {
    if ((error as Error).message.includes('CredentialsSignin')) {
      return false
    }
    throw error
  }
}

Credentials プロバイダを使っているので signIn 関数の第1引数には 'credentials' を指定します。
メールアドレスやパスワードが違っていた場合は Credentials プロバイダがそれ用のエラーをスローしてくれるのでそれだけキャッチし、ログインできたかどうかを判定可能にしています。

ログインページにログインフォームを追加します。

src/app/login/LoginForm.tsx
'use client'

import { authenticate } from '@/app/login/actions'
import { useFormState, useFormStatus } from 'react-dom'

export default function LoginForm() {
  const [state, formAction] = useFormState(authenticate, true)

  return (
    <form action={formAction}>
      <label>
        メールアドレス:
        <input className="text-gray-900" type="email" name="email" />
      </label>
      <label>
        パスワード:
        <input className="text-gray-900" type="password" name="password" />
      </label>
      {!state && (
        <div className="text-red-500">
          メールアドレスかパスワードが違います。
        </div>
      )}
      <SubmitButton />
    </form>
  )
}

function SubmitButton() {
  const { pending } = useFormStatus()
  return (
    <button aria-disabled={pending}>
      {pending ? 'ログイン中' : 'ログインする'}
    </button>
  )
}
src/app/login/page.tsx
import LoginForm from '@/app/login/LoginForm'

export default function Login() {
  return <LoginForm />
}

ダッシュボードの実装

/dashboard にログアウト用のボタンを用意します。

src/app/dashboard/page.tsx
import { signOut } from '@/auth'

export default function Dashboard() {
  return (
    <div>
      <form
        action={async () => {
          'use server'
          await signOut()
        }}
      >
        <button>ログアウト</button>
      </form>
      dashboard
    </div>
  )
}

いざ認証

ここまできたらあとは動作確認するだけです。
npm run dev で開発サーバを立ち上げ http://localhost:3000/dashboard にアクセスしてみましょう。
ログインページにリダイレクトされているはずです。

試しにログインに失敗してみると5秒間ログイン中になった後にエラーメッセージが表示されます。

正しいメールアドレスとパスワードを入力してみると、5秒間ログイン中になった後にダッシュボードに遷移します。

ログアウトボタンをクリックすると最初のログインページに戻ってきます。

お疲れ様でした。

おわりに

App Router での認証について見てきました。
カスタムフックで認証状態を取得していた Pages Router 時代と比べると簡単になったのかな?という印象を受けました(あまり詳しくはないですが)。
Server Actions も安定版になったので next-auth のベータが取れたらプロダクションでも使ってみたいですね。
コードの全体像は GitHub に置いてあります。

https://github.com/myrear/app-router-auth-sandbox

Terass Tech Blog

Discussion

maya honeymaya honey

next-auth@betaでは実装できるようですが、beta以前の4系(next-auth@4.x.y)とNext.js v14は互換性があるんでしょうか...?