✔️

NextAuth v5で外部APIを使った認証を実装する

2024/10/02に公開

はじめに

この記事は、最近Next.jsを使い始めた初心者が、NextAuthと外部(?)APIを使って簡易的な認証を実装するまでを記した記事です。

  • 情報を渡すとAPIからトークンが発行され、そのトークンを元にAPIにリクエストを送る
  • ↑のトークンをNextAuthのセッション情報に保存する
  • ログインしていないと見れないページを実装し、そのページでAPIのリクエストの結果を表示する

上のような状況をどのように実装するか、そしてNextAuthの基本的な使い方と、どのようにAPIにリクエストを送るかを解説しています。

環境

  • Next.js 14.1.1 (App Router)
  • NextAuth V5 Beta
  • Bun 1.1.29

APIの仕様

この記事では、localhost:3000にAPIサーバーが立っていると仮定します。
仕様はだいたい以下の通りです。

  • /loginPOSTリクエスト(内容にusernameを含める)を送ると、tokenが含まれたレスポンスが返ってくる
  • Sessionヘッダにトークンを入れた上で、/useGETリクエストを送ると、ユーザー名(username)とメッセージ(message)が含まれたレスポンスが返ってくる

NextAuthをセットアップ

ということで、Next.jsとNextAuthをセットアップしていきます。

Next.jsをセットアップ

まずはNextAuthのインストール...と行きたいところですが、その前にNext.jsの雛形を作ります。
コマンドは以下の通りです。

ターミナル
bun create next-app@14

ここでバージョンを指定しているのは、現在(2024/10/1時点)バージョンを指定しないとNext.js 15(RC)が使用されるためです。
15でも動くかどうかに不安があったため、一応Next.js 14を使っています。

不要なファイルを削除する

雛形が作成できたら、不要なファイルを削除します。
ここではフォントやCSSを削除し、最低限必要なファイルのみにしました。

ポートを設定

Next.jsはデフォルトだとlocalhost:3000で開発サーバーを起動するのですが、今回このポートはすでにAPIが使っています。
この記事では代わりに5500番を使います。

ポートは--portオプションで指定することができます。
package.jsonを編集("dev": "next dev --port 5500"など)しておくと、いちいちポートを指定しなくてもいいので楽です。

設定が済んだら、以下のコマンドで開発サーバーを起動できます。

ターミナル
bun run dev

起動したら http://localhost:5500 をブラウザで開いてみてください。

ページを作成

では次に、Next.jsで以下のページを作成します。

ページはどのようなものでも大丈夫です。
この記事では以下のようにしてみました。

  • /: いろいろなページへのリンクを載せるページ
  • /about: ログインしなくても見れるページ
  • /protected: ログインが必要なページ

/protectedには以下の情報を載せようと思います。

  • ログイン中のユーザーの名前
  • アクセストークンを使ってAPIから取得したデータ

しかし、現状はまだログイン機能がないので実装することができません。
ここはひとまずほぼ空のページを置いておきます。

NextAuthをインストール

ということで、いよいよNextAuthをインストールします。

しかし、ここで一つ注意が必要です。
どうやら、NextAuth v4はNext.js14に対応していないらしいです。
この記事ではNext.js 14を使用しているため、現在Beta版であるNextAuth v5を使用する必要があります。

インストールには以下のコマンドを使用します。

ターミナル
bun add next-auth@beta

インストールの詳細は公式ドキュメントをご覧ください。

まずは作動させる

準備は整ったので、まずはNextAuthを作動させてみようと思います。
ログインが必要なページにアクセスしたら/loginにリダイレクトするよう設定していきます。

auth.tsを作成

/src/auth.tsはNextAuthの設定を書くファイルです。
また、NextAuthという関数を呼び出して、ログインやセッション情報の取得に必要な関数をエクスポートする役割も担っています。

本格的な設定は後回しにして、今はとりあえず以下のようにしておきます。

/src/auth.ts
import NextAuth from "next-auth";

export const { auth } = NextAuth({
  providers: [],
  callbacks: {
    authorized() {
      return false
    },
  }
})

authorized()とは?

上の設定ファイルでは、callbacks.authrizedを定義しました。
ここで設定した関数は、ユーザーが認可されているかを判断するのに使われます。

イメージ的には、ユーザーが何かのページにアクセスした時に呼ばれ、そのページにアクセスする権限があるかどうかをbooleanで返すという感じです。
また、Response.redirectを返すことで、リダイレクトするようにもできます。

今回の設定は以下のようになっています。

authorized() {
  return false
}

これはどのような場合でも全てのページでアクセスを許可しないというものです。

ひとまずこのようになっていますが、これだと以下のような問題があります。

  • ログインが不要なページでもアクセスできない
  • ログインしていてもページにアクセスできない

そのため、この設定は後で変更する必要があります。

詳細は公式ドキュメントをご覧ください。

middleware.tsを作成

middleware.tsは、Next.jsにおけるミドルウェアを定義するファイルです。
ミドルウェアはNexhAuthではなくNext.jsの機能であり、NexhAuthはそれに乗っかる形となっています。

このファイルでは、ミドルウェアとなる関数をmiddlewareとしてエクスポートします。
また、configオブジェクトでミドルウェアが適用される範囲を指定します。

/src/middleware.ts
export { auth as middleware } from '@/auth'

export const config = {
  matcher: ['/((?!api|_next/static|_next/image|.*\\.png$).*)']
}

ミドルウェアの詳細はNext.jsドキュメントをご覧ください。

また、matcherこちらを使用しています。
これは静的ファイルなどを除くほぼ全てのルートにマッチさせるものです。


さて、これでページの保護ができたはずです。
開発サーバーを立て、//aboutなどの好きなページにアクセスしてみます。
すると、/api/auth/signinにリダイレクトされ、404が表示されるはずです。

このように、アクセス許可がない(=auth.tscallback.authorizedfalseを返した)ページはログインページにリダイレクトされるようになります。

シークレットを作成

無事に動いて嬉しいな、と思いながら徐にサーバー側のコンソールを開くと、何やらエラーが表示されています。

[auth][error] MissingSecret: Please define a `secret`. Read more at https://errors.authjs.dev#missingsecret

どうやらシークレットとやらを定義しないといけないようです。
これはAUTH_SECRETという環境変数がないときに表示されるものです。
AUTH_SECRETはNextAuthがトークンなどを暗号化する時に使われるもので、コマンドで作成することができます。

ということで、以下のコマンドを入力します。

ターミナル
bunx auth secret

実行すると/.env.localファイルが作成され、中にはAUTH_SECRETが書かれていると思います。

AUTH_SECRETができたので、開発サーバーを再起動します。
ブラウザのクッキーを削除し、再度ページにアクセスすると、サーバー側からエラーが消えるはずです。

ログインページのURLを変更

現在認証が必要なページにアクセスすると、/api/auth/signinにリダイレクトされてしまいます。
この記事では/loginにリダイレクトしたいので、設定を変更します。

環境変数を追加

まずは.env.localAUTH_URLという環境変数を追加します。
これがないと、私の環境ではビルドした時にエラーが発生してしまいました。

以下のようにファイルを編集します。

.env.local
AUTH_SECRET="略" # Added by `npx auth`. Read more: https://cli.authjs.dev
AUTH_URL="http://localhost:5500/login" # ポートは実際に使っているものを使用

設定ファイルに追記

次に、auth.tsの設定を変更します。
ログインページのURLはpages.signInで設定できます。

/src/auth.ts
  providers: [],
+ pages: {
+   signIn: '/login'
+ },
  callbacks: {
    authorized() {

以上の設定をしたら、もう一度開発サーバーを起動し直し、好きなページにアクセスしてみてください。
/loginにリダイレクトされ、画面に404が表示されたら成功です。

ログインフォームを作成

ページにアクセスしたら/loginにリダイレクトされるまではできました。
しかし現状だと、/loginにアクセスしても404が表示されてしまいます。
ということで、次はログインフォームを作成し、セッションにユーザーの情報が保存されるようにします。

この記事では、ログインフォームを以下のように実装します。

  • /loginページは自力で作成
  • Server Actionsを使用してログイン
  • APIにリクエストを送るのはまだ先で、ひとまず動くようにする

/loginページを作成

まずはログイン用のページを作成します。
今回はユーザー名さえあればログインできるという仕様なので、フォームにはユーザー名を入力するボックスを設置します。

ということで、/src/app/login/page.tsxを作成し、以下のように編集します。

/src/app/login/page.tsx
export default function Page() {
  return (
    <main>
      <h1>ログインフォーム</h1>
      <form>
        <div>
          <label htmlFor="username">ユーザー名</label>
          <input type="text" name="username" id="username"/>
        </div>
        <button type="submit">送信</button>
      </form>
    </main>
  )
}

これで/loginにアクセスしても404が表示されなくなり、代わりにログインフォームが表示されるようになりました。

Server Actionsを実装

ページの外見はできましたが、現在このフォームで送信ボタンを押しても特に何も起きません。
ということで、フォームが送信された時の動作をServer Actionsで実装します。

login/page.tsxを以下のように編集します。

/src/app/login/page.tsx
+ import { signIn } from "@/auth"

+ async function login(formData: FormData) {
+   'use server'
+   // Credentialプロバイダを使ってログイン
+   await signIn('credentials', formData)
+ }

  export default function Page() {
  <main>
      <h1>ログインフォーム</h1>
-     <form>
+     <form action={login}>

また、@/authからsignInという関数をインポートしているのですが、現状このファイルではそんな関数はエクスポートしていないので、型エラーが出ています。
ということでエクスポートします。

/src/auth.ts
- export const { auth } = NextAuth({
+ export const { auth, signIn } = NextAuth({
    providers: [],

これでフォームが送信されたらsignIn関数が呼び出されるようになりました。

保護されたルートを編集

現状の設定だと、//aboutなどのログインしていなくても見れるページが見れないという問題があります。
このままだと非常にやりづらいので、ここでこの設定を変更したいと思います。

変更する設定はauth.tscallbacks.autorizedです。
この設定についてはauthorized()とは?に書いているので、そちらも併せてご覧ください。

ということで、auth.tsを以下のように編集します。

  callbacks: {
    authorized({ auth, request: { nextUrl } }) {
      const isLoggedIn = !!auth
      const isOnProtected = nextUrl.pathname.startsWith('/protected')
      const isOnLogin = nextUrl.pathname.startsWith('/login')

      // ログイン済みでログインページにアクセスしている場合
      if (isLoggedIn && isOnLogin) {
        const callBackUrl = nextUrl.searchParams.get('callbackUrl')
        if (callBackUrl) {
          // コールバックURLが指定されていたらリダイレクト
          return Response.redirect(callBackUrl)
        } else {
          return true
        }
      }

      // ログインしていなくても見れるページの場合
      if (!isOnProtected) return true
      // ログイン済みの場合
      if (isLoggedIn) return true
      return false
    },
  }

編集できたら、//aboutなどのログインが不要なページにアクセスしてみてください。
/loginにリダイレクトされなくなり、ページの内容が表示されれば成功です。

ログインのロジックを実装

では次に、ログイン方法を追加していきます。
現状だとログイン方法が登録されていないため、ログインフォームを使ってもログインできません。

ログイン方法は、auth.tsprovidersに追加します。
providersとはNextAuthのプロバイダを追加する場所です。
プロバイダにはGitHubやGoogleなど様々な種類があり、実装したいログイン方法に対応したプロバイダを使うと、簡単に認証を実装することができます。

今回はCredentialsというプロバイダを使います。
このプロバイダは自分でログインの手順を1から定義する時に使うものです。
詳しくは公式ドキュメントをご覧ください。

プロバイダを追加するには、auth.tsを以下のように編集します。

/src/auth.ts
import Credentials from "next-auth/providers/credentials";

export const { auth, signIn } = NextAuth({
  providers: [Credentials({
    authorize(credentials) {
      const username = credentials.username
      if (typeof username !== 'string') return null

      // TODO 認証処理を書く

      return { username }
    },
  })],
  // 略

このコードでは、プロバイダにCredentialsを追加し、authorizeの設定を行っています。
authorizeは、signInが呼び出された時など、ログインの処理を定義する関数です。

この関数は以下の値を返す必要があります。

  • 認証に成功した時: ユーザー情報(Userインターフェースの値)
  • 認証に失敗した時: null

また、このコードだとUserusernameというプロパティがないため、型エラーが出てしまいます。
この問題を解決するため、以下のようなモジュール宣言を追加します。

auth.ts
declare module 'next-auth' {
  interface User {
    username: string
  }
}

これで型エラーが出なくなり、Userusernameプロパティがあることが明治できたと思います。
このコードについての詳細は公式ドキュメントをご覧ください。


ログイン方法が追加できたので、実際にログインしてみようと思います。
以下のような手順が踏めれば成功です。

  1. /protected(認証が必要なページ)にアクセスする
  2. /loginにリダイレクトされる
  3. フォームにユーザー名を入力し、ログインボタンを押す
  4. ログインされ、/protectedにリダイレクトされる

ユーザー名を表示

ログインができたということで、次は画面にユーザー名を表示してみたいと思います。
そのためには、当然コード上でユーザー名を取得する必要があります。

セッションの情報は/src/auth.tsからエクスポートされているauth関数で取得できるみたいです。
この関数はサーバーコンポーネント上で使えるみたいです。

ということで、さっそく試してみましょう。
protected/page.tsxを以下のように編集してみます。

/src/app/protected/page.tsx
+ import { auth } from "@/auth";

  export default async function Page() {
+   const session = await auth()
+   const username = session?.user?.username

    return (
      <main>
        <h1>ログインが必要なページ</h1>
+       <p>ユーザー名: {username}</p>
      </main>
    )
  }

これでブラウザを確認してみると...あれ、ユーザー名が表示されていません。
これはいったい何故でしょうか。

データを受け渡す

デフォルトで用意されているユーザーの情報以外を使う場合、手動でデータの受け渡しを定義する必要があります。
データは大まかに以下のように流れていきます。

  1. Credentialauthorizeで返したUserの値
  2. callback.jwtで返したJWTトークンの値
  3. callback.sessionで返した`Sessionの値
  4. authで取得できるセッションの情報

詳細は公式ドキュメントをご覧ください。

ということで、コードを以下のように編集します。

/src/auth.ts
  // ...
  callbacks: {
    // ...
    jwt({ token, user }) {
      // userが空でないならtoken.userを更新
      if(user?.username) token.user = user
      return token
    },
    session({ session, token }) {
      return {
        ...session,
        user: token.user
      }
    },
  }
}

また、この変更によりトークンの型が変わったため、型定義を修正する必要があります。
以下のようにコードを追記してください。

/src/auth.ts
import type { JWT } from 'next-auth/jwt'
declare module 'next-auth/jwt' {
  interface JWT {
    user: DefaultSession['user']
  }
}

編集後、ログインした状態で/protectedにアクセスすると、ユーザー名が表示されているはずです。

APIにリクエスト

ログインの実装ができたということで、次はAPIを使用してトークンを取得し、そのトークンを使ってデータを取得しようと思います。

ログイン時にAPIからトークンを取得

まずはAPIにリクエストを送るためのトークンを取得します。
この記事では、http://localhost:3000にリクエストすると、tokenプロパティからセッショントークンが返ってくる想定です。

取得は簡単で、プロバイダにリクエストのコードを書くだけです。

/src/auth.ts
  providers: [Credentials({
    async authorize(credentials) {
      const username = credentials.username
      if (typeof username !== 'string') return null

+     const res = await fetch('http://localhost:3000/login', {
+       method: 'POST',
+       headers: {
+         'Content-Type': 'application/json'
+       },
+       body: JSON.stringify({ username })
+     })
+     if (!res.ok) return null
+
+     const { token: sessionToken } = await res.json()
+     return { username, sessionToken }
    }

ここでは、設定が面倒なのでユーザーの情報にセッショントークンを入れています。
ユーザー情報にトークンを入れておくと、今回の場合はjwtsessionに変更を加えなくてもトークンが取得できるようになります。

また、ユーザー情報にsessionTokenというプロパティを追加したので、一緒に型定義も修正します。

/src/auth.ts
  declare module 'next-auth' {
    interface User {
      username: string
+     sessionToken: string
    }
  }

これでログイン状態の時にトークンを取得できるようになりました。

セッショントークンを取得する

トークンはユーザー名と同じように取得することができます。
例えばこのような感じです。

const session = await auth()
const { username, sessionToken } = session?.user ?? {}

トークンを使ってAPIにリクエスト

ここまで来たらあとは簡単です。
取得したトークンをリクエストヘッダに含め、返ってきたレスポンスを画面に表示してみようと思います。
この記事ではhttp://localhost:3000/useSessionヘッダを含めてGETリクエストを送ると、messageと自分のユーザー名が返ってくるAPIを想定しています。

ということで、/protectedにレスポンスの内容を表示してみます。
実装は例えばこのようになります。

/src/app/protected/page.tsx
export default async function Page() {
  const session = await auth()
  const { username, sessionToken } = session?.user ?? {}

  const { data } = await fetch('http://localhost:3000/use', {
    headers: new Headers({
      "Session": sessionToken ?? '' // 型エラー防止
    })
  }).then(res => res.json())

  return (
    <main>
      <h1>ログインが必要なページ</h1>
      {/* ... */}
      <p>
        取得データ: {data?.message} <br />
        取得してきたユーザー名: {data?.username} <br />
      </p>
    </main>
  )
}

編集後、ログインした状態で/protectedにアクセスすると、APIから取得したデータが表示されると思います。

おまけ:ログアウトの実装

簡単なログアウトボタンをServer Actionsを使って実装した例がこちらです。

import { signOut } from "@/auth";
function Logout() {
  return (
    <form action={async () => {
      'use server'
      await signOut({ redirectTo: '/' })
    }}>
      <button>ログアウト</button>
    </form>
  )
}

また、このコンポーネントを使用するには/src/auth.tsに以下の変更を加える必要があります。

/src/auth.ts
  import Credentials from "next-auth/providers/credentials";

- export const { auth, signIn } = NextAuth({
+ export const { auth, signIn, signOut } = NextAuth({

以上です。ここまで読んでいただきありがとうございました。

参考

https://zenn.dev/bosushi/articles/cff6ac4071f6c6
https://zenn.dev/tocomi/articles/965c6ccb676a7b
https://zenn.dev/terass_dev/articles/d45415a31694ea
https://authjs.dev/getting-started/migrating-to-v5
https://nextjs.org/learn/dashboard-app/adding-authentication

Discussion