🔒

【Next.js】Auth.jsの資格情報プロバイダーで認証機能を実装する

2023/05/05に公開

はじめに

フロントエンドの認証機能をAuth.jsを使って実装しました。
今回は既存のバックエンド認証を流用したいので、資格情報プロバイダ(CredentialsProvider)を使用します。

資格情報認証は推奨していないようです

本来、Auth.jsでは組込プロバイダ(Google, Facebook, Twitterなど)を使用することで、手軽に安全に認証機能を実装することができます。
公式ドキュメントの説明では資格情報認証はパスワードに関するセキュリティティリスクがあるため推奨していないように見えます。
https://authjs.dev/guides/providers/credentials
以下翻訳です。

おっしゃる通り、パスワード流出すると大変ですよね。
でも、既存のバックエンド連携があるんです。ごめんなさいCredentialsProvider使っちゃいます。

ざっくり概要

今回実装した認証機能のポイントを整理しておきます。
ユーザから見える画面としては認証画面と認証後画面の2つを作成しました。
また、下記機能を実装するためにAuth.jsの設定をしていきます。

  • セッション情報を拡張する
    デフォルトのセッション情報ではnameemailimageなどが用意されていますが、これを拡張して、ロールやバックエンド用アクセストークンなど、デフォルト以外の情報を保持させます。
  • 自作の認証画面を使用する
    Auth.jsでは標準で認証ページを自動生成してくれますが、バリデーションや見た目のカスタマイズが自由にできないため、自作の認証画面を作成します。ユーザ名とパスワードを入力する画面を想定しました。
  • 全ページに認証をかける
    middlewareを使用して、すべてのページを保護します。認証していないユーザがページにアクセスしようとすると、認証画面にリダイレクトします。

作ってみる

前提としてNext.js/TypeScriptを使用します。

プロジェクトの作成

create-next-appでプロジェクトを作成します。

npx create-next-app my-auth-app --ts

これでNext.jsのひな形が完成しました。

Auth.jsのインストール

next-authをインストールします。元々はNextAuth.jsという名前だったようです。

cd my-auth-app
npm install next-auth

[...nextauth].tsの作成

ここからAuth.jsの設定をしていきます。
認証機能の定義ファイルをpages/api/auth/[...nextauth].tsに作成します。
CredentialsProviderを設定しています。
セッション情報のプロパティで型エラーが出ると思いますが、次の手順で型情報を拡張します。

pages/api/auth/[...nextauth].ts
import NextAuth from "next-auth";
import CredentialsProvider from "next-auth/providers/credentials";

export default NextAuth({
  providers: [
    CredentialsProvider({
      name: "Credentials",
      // `credentials`は、サインインページでフォームを生成するために使用されます。
      credentials: {
        username: { label: "ユーザ名", type: "text" },
        password: { label: "パスワード", type: "password" },
      },
      async authorize(credentials, req) {
        // `credentials`で定義した`username`、`password`が入っています。
        // ここにロジックを追加して、資格情報からユーザーを検索します。
        // 本来はバックエンドから認証情報を取得するイメージですが、ここでは定数を返しています。
        // const user = await authenticationLogic(credentials?.username, credentials?.password);
        const user = {
          id: "1",
          name: "J Smith",
          email: "jsmith@example.com",
          role: "admin",
          backendToken: "backEndAccessToken",
        };

        if (user) {
          // 返されたオブジェクトはすべて、JWT の「user」プロパティに保存されます。
          return user;
        } else {
          // 認証失敗の場合はnullを返却します。
          return null;
        }
      },
    }),
  ],
  pages: {
    // カスタムログインページを追加します。
    signIn: "/auth/signin",
  },
  callbacks: {
    // `jwt()`コールバックは`authorize()`の後に実行されます。
    // `user`に追加したプロパティ`role`と`backendToken`を`token`に設定します。
    jwt({ token, user }) {
      if (user) {
        token.role = user.role;
        token.backendToken = user.backendToken;
      }
      return token;
    },
    // `session()`コールバックは`jwt()`の後に実行されます。
    // `token`に追加したプロパティ`role`と`backendToken`を`session`に設定します。
    session({ session, token }) {
      session.user.role = token.role;
      session.user.backendToken = token.backendToken;
      return session;
    },
  },
});

next-auth.d.tsの作成

types/next-auth.d.tsを作成し、型情報を拡張します。セッション情報にrolebackendTokenを追加しました。任意のプロパティを持たせたい場合はこのファイルに型情報を持たせます。

types/next-auth.d.ts
import NextAuth, { DefaultSession } from "next-auth";
import { JWT } from "next-auth/jwt";

declare module "next-auth" {
  // クライアント側で使用するsession(useSessionから取得するオブジェクト)にプロパティを追加します。
  // ここでは`role`と`backendToken`を追加しています。
  interface Session {
    user: {
      role?: string;
      backendToken?: string;
    } & DefaultSession["user"];
  }
  interface User {
    role?: string;
    backendToken?: string;
  }
}

declare module "next-auth/jwt" {
  // "jwt"コールバックのtokenパラメータに任意のプロパティを追加します。
  interface JWT {
    role?: string;
    backendToken?: string;
  }
}

型情報については下記を参考にしました。
https://authjs.dev/getting-started/typescript

.envの作成

環境変数NEXTAUTH_SECRETNEXTAUTH_URLを定義します。

.env
NEXTAUTH_SECRET=TESTSECRET
NEXTAUTH_URL=http://localhost:3000

NEXTAUTH_SECRETはJWTの暗号化に使用します。公開しないようにしてください。opensslコマンドでランダムな文字列を生成すると良いそうです。

openssl rand -base64 32

_app.tsxの修正

SessionProviderを追加すると、クライアント側でセッション情報が参照可能になります。
ここでは見た目は重視しないため、globals.cssは削除しました。

pages\_app.tsx
import type { AppProps } from "next/app";
import { SessionProvider } from "next-auth/react";

function MyApp({ Component, pageProps: { session, ...pageProps } }: AppProps) {
  return (
    <SessionProvider session={session}>
      <Component {...pageProps} />
    </SessionProvider>
  );
}

export default MyApp;

index.tsxの修正

セッション情報に格納したnameemailrolebackendTokenを表示するように修正しました。サインアウトボタンもつけています。

pages\index.tsx
import { useSession, signOut } from "next-auth/react";

export default function Home() {
  const { data: session, status } = useSession();

  return (
    <main>
      <div>{status}</div>
      <div>{session?.user?.name}</div>
      <div>{session?.user?.email}</div>
      <div>{session?.user?.role}</div>
      <div>{session?.user?.backendToken}</div>
      <button onClick={() => signOut()}>サインアウト</button>
    </main>
  );
}

signin.tsxの作成

自作の認証画面を作成します。ユーザ名とパスワードを入力する画面を作成しました。

pages\auth\signin.tsx
import { GetServerSideProps } from "next";
import { getCsrfToken } from "next-auth/react";
import { useRouter } from "next/router";

type SignInProps = {
  csrfToken?: string;
};

export default function SignIn({ csrfToken }: SignInProps) {
  const router = useRouter();
  const { error } = router.query;

  return (
    <form method="post" action="/api/auth/callback/credentials">
      <input name="csrfToken" type="hidden" defaultValue={csrfToken} />
      <label>
        ユーザ名
        <input name="username" type="text" />
      </label>
      <label>
        パスワード
        <input name="password" type="password" />
      </label>
      <button type="submit">サインイン</button>
      {error && <div>サインイン失敗</div>}
    </form>
  );
}

export const getServerSideProps: GetServerSideProps = async (context) => {
  return {
    props: {
      csrfToken: await getCsrfToken(context),
    },
  };
};

下記を参考にしました。
https://authjs.dev/guides/basics/pages#credentials-sign-in

middleware.tsの作成

middlewareを作成して、すべてのページを保護します。

middleware.ts
export { default } from "next-auth/middleware";

export const config = {
  matcher: ["/((?!auth).*)"],
};

ただし、認証画面にもmiddlewareが働いて、リダイレクトループしまうため、matcherの正規表現でauthパス配下は除外しています。

動作確認

さて、準備が整いました。実行してみましょう。

npm run dev

localhost:3000にアクセスしてみましょう。

インデックスページpages\index.tsxが表示されるところですが、認証されていないため、サインインページpages\auth\signin.tsxにリダイレクトされました。成功です。
サインインボタンをクリックしてみます。

期待通りnameemailrolebackendTokenの値が表示されました。
サインアウトボタンをクリックすると、サインインページにリダイレクトされました。

まとめ

Auth.jsで認証機能を作成することができました。
落ち着いて公式ドキュメントを見てみると一応書いてあるのですが、作成には少々苦労しました。
現状ではドキュメントも一部バージョンアップに追いついていない様子でした。

最後に個人的つまづきポイントを書いておきます。

  • middlewareがループする
    仕様なのかバグなのかわかりませんが、signinページに繰り返しリダイレクトされてしまいます。除外ページで回避しました。
  • .envの作成忘れ
    NEXTAUTH_SECRETNEXTAUTH_URLの説明を読み飛ばしていたのか、.envを作成しておらず、しばらく悩みました。ターミナルにメッセージが表示されたのでかろうじて気が付きました。
  • セッション情報までの変数引き渡しがわかりづらい
    authorize()からuseSession()までの動線を理解するのに時間がかかりました。
    authorize()userオブジェクト
    ⇒ コールバック関数jwt()tokenオブジェクト
    ⇒ コールバック関数session()sessionオブジェクト
    ⇒ クライアント関数useSession()sessionオブジェクト
    という変数の引き渡しをしないといけないと理解するのに少々時間がかかりました。
    CredentialsProviderの使用を思いとどまらせるために、あえて複雑にしているのかもしれません。

以上です。Auth.jsの実装など参考にして頂けると幸いです。

Discussion