🎉

Auth.js(NextAuth.js v5)とNext.jsで認証機能を実装する(Credentials Provider)

2024/08/02に公開

はじめに

本記事は,Auth.js(NextAuth.js v5)とNext.js14(App Router)を用いて認証機能(Credentials Provider)を実装したものです.

バージョン情報

"next": "14.2.5",
"next-auth": "5.0.0-beta.20",

Auth.jsについて

  • Auth.jsは、認証機能を提供するライブラリ
  • Next.js,Qwik,SvelteKitなどのフレームワークに対応している

install

npm install next-auth@beta

secretの設定

tokenのハッシュ化のためにAUTH_SECRETを設定する必要がある

以下でsecretを生成しAUTH_SECRETに設定する

npx auth secret

or

openssl rand -base64 33

環境変数に設定する

/.env.local
AUTH_SECRET=secret

Providerについて

Providerは認証方法を提供するもので、OAuth,Email,Credentialsなどがある

Credentials providerについて

Auth.jsを外部のAPI等と統合する場合などに使用する
email,passwordなどの認証情報を後述のauthorizecallbackに渡すことで認証を行う

Database Adapterについて

Auth.jsはデフォルトでSessionをCookieに保存するため,データベースの設定はOptional

今回はCookieを使用してSessionを管理します.

NextAuthConfigについて

Credentials providerを設定するために,NextAuth()メソッドにNextAuthConfigを渡す必要がある

NextAuthConfigについて

providers,callbacks,pages,session,strategyなどの任意のプロパティを設定することで,認証機能をカスタマイズできる

callbacksについて

認証に関連するイベントが発生した際に呼び出される非同期関数.
DBを使用せずにアクセス制御を実装したり,外部のDBやAPIと統合するなどが可能
jwt,session,signIn,redirect,authorizeを設定できる

参考:https://authjs.dev/reference/nextjs#callbacks

実装

Auth.jsに関する全般的な設定(auth.config.ts)

NextAuthConfigを行うファイル

/auth.config.ts
import type { NextAuthConfig, Session, User } from "next-auth";
import { JWT } from "next-auth/jwt";
import { NextRequest } from "next/server";

export const authConfig = {
  pages: {
    signIn: "signin",
  },
  callbacks: {
    // Middlewareでユーザーの認証を行うときに呼び出される
    // NextResponseを返すことでリダイレクトやエラーを返すことができる
    authorized({
      auth,
      request: { nextUrl },
    }: {
      auth: Session | null;
      request: NextRequest;
    }) {
      console.log("authorized", auth, nextUrl.pathname);

      // /user以下のルートの保護
      const isOnAuthenticatedPage = nextUrl.pathname.startsWith("/user");

      if (isOnAuthenticatedPage) {
        const isLoggedin = !!auth?.user;
        if (!isLoggedin) {
          // falseを返すと,Signinページにリダイレクトされる
          return false;
        }
        return true;
      }
      return true;
    },
    // JSON Web Token が作成されたとき(サインイン時など)や更新されたとき(クライアントでセッションにアクセスしたときなど)に呼び出される。ここで返されるものはすべて JWT に保存され,session callbackに転送される。そこで、クライアントに返すべきものを制御できる。それ以外のものは、フロントエンドからは秘匿される。JWTはAUTH_SECRET環境変数によってデフォルトで暗号化される。
    // セッションに何を追加するかを決定するために使用される
    async jwt({ token, user }: { token: JWT; user: User }) {
      console.log("jwt", token, user);
      if (user) {
        token.backendToken = user.backendToken;
        token.user = user;
      }
      return token;
    },
    //セッションがチェックされるたびに呼び出される(useSessionやgetSessionを使用して/api/sessionエンドポイントを呼び出した場合など)。
    // 戻り値はクライアントに公開されるので、ここで返す値には注意が必要!
    // jwt callbackを通してトークンに追加したものをクライアントが利用できるようにしたい場合,ここでも明示的に返す必要がある
    // token引数はjwtセッションストラテジーを使用する場合にのみ利用可能で、user引数はデータベースセッションストラテジーを使用する場合にのみ利用可能
    // JWTに保存されたデータのうち,クライアントに公開したいものを返す
    async session({ session, token }: { session: Session; token: JWT }) {
      console.log("session", session, token);
      session.backendToken = token.backendToken;
      session.user = token.user;
      return session;
    },
  },
  providers: [],
} satisfies NextAuthConfig;

Credentials Providerの設定(auth.ts)

Credentials Provider設定を行い,Auth.jsのAPI(auth,signIn,signOut)をexportする

/auth.ts
import NextAuth from "next-auth";
import { authConfig } from "./auth.config";
import credentials from "next-auth/providers/credentials";

// auth: Next.jsアプリでNextAuth.jsとやりとりするための汎用メソッド。auth.ts(このファイル)でNextAuth.jsを初期化した後、Middleware、ServerComponents、Route Handler(app router)でこのメソッドを使う
//
// signIn: providerを指定してサインインすることができる。指定されていない場合、ユーザはサインインページにリダイレクトされる。デフォルトでは、ユーザはサインイン後に現在のページにリダイレクトされます。redirectToオプションに相対パスを設定することで、この動作をオーバーライドできる。
//
// signOut: ユーザーをサインアウトする。セッションがデータベース戦略を使用して作成された場合、セッションはデータベースから削除され、関連するクッキーは無効になります。セッションがJWTを使用して作成された場合、クッキーは無効になる.デフォルトでは、サインアウト後、ユーザーは現在のページにリダイレクトされます。redirectTo オプションに相対パスを設定することで、この動作をオーバーライドできます。
//
// handlers: 今回は使用しない
// NextAuth.jsのRouteHandlerメソッド。これらは、OAuth/Emailプロバイダー用のエンドポイント、および(`/api/auth/session`のような)クライアントから接続できるREST APIエンドポイントを公開するために使用されます。
export const { auth, signIn, signOut, handlers } = NextAuth({
  ...authConfig,
  providers: [
    credentials({
      // signInが呼ばれた際にこの関数が呼び出される
      async authorize({ email, password }) {
        console.log("authorize:", email, password);
        // 実際にはここでバックエンドにリクエストを送信して認証を行う
        const url = process.env.API_URL + "/auth/login";
        const res = await fetch(url, {
          method: "POST",
          headers: {
            Accept: "application/json",
            "Content-Type": "application/json",
          },
          body: JSON.stringify({ email, password }),
        });
        const data = await res.json();
        const backendToken = data.accessToken;
        const user = { backendToken };

        console.log("token:", backendToken);
        if (!backendToken) {
          // 認証に失敗した場合は nullを返すか,エラーを投げることが期待される
          // CredentialsSignin がスローされた場合、または null が返された場合、以下の 2 つのことが起こる:
          // 1. URL に error=CredentialsSignin&code=credentials を指定して、ユーザーをログインページにリダイレクトする。
          // 2. フォームアクションをサーバーサイドで処理するフレームワークでこのエラーを投げる場合(例えばserver actionsでsignInを呼び出す場合)、このエラーはログインフォームアクションによって投げられるので、そこで処理する必要がある。
          return null;
        }
        return user;
      },
    }),
  ],
});

ルートの保護(middleware.ts)

Next.jsのmiddleware
任意のrouteの保護,sessionの維持に使用する

/middleware.ts
import { auth } from "./auth";

// NextAuthConfigのauthorized callbackが呼び出される
export default auth;

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

参考:https://authjs.dev/getting-started/session-management/protecting

型の拡張(types/next-auth.d.ts)

User,Session,JWTの型を拡張する
これを行わないとcallbacksで型エラーが発生するので注意

/types/next-auth.d.ts
import { Session } from "inspector";
import { Session, User as DefaultUser, DefaultSession } from "next-auth";
import { DefaultJWT, JWT } from "next-auth/jwt";
declare module "next-auth" {
  // ログインユーザーのセッション情報,auth(),useSession(),getServerSession()で使用可能
  interface Session extends DefaultSession {
    backendToken?: string;
    user?: {
      backendToken?: string;
    } & DefaultSession["user"];
  }

  //jwt callbackとsession callbackで使用可能。データベースを使用する場合は、session callbackの2番目のパラメータ。
  interface User extends DefaultUser {
    backendToken?: string;
  }
}

declare module "next-auth/jwt" {
  // JWT session使用時にjwt callbackで返されるオブジェクトの形状
  interface JWT extends DefaultJWT {
    backendToken?: string;
    user?: User;
  }
}

Server Actionsでの使用

login(Server Actions)の実装例

/app/(auth)/_action/action.ts
"use server";

import { redirect } from "next/navigation";
import { signIn } from "../../../../auth";
import { AuthError } from "next-auth";
import { isRedirectError } from "next/dist/client/components/redirect";

~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

export const signin = async (
  _state: LoginState,
  formData: FormData
): Promise<LoginState> => {
  // login処理
  try {
    // NEXT_REDIRECTが投げられ,catchでリダイレクトされる
    await signIn("credentials", formData);
    return { message: "success" };
  } catch (error) {
    if (error instanceof AuthError) {
      switch (error.type) {
        case "CredentialsSignin":
          console.error("Signin error:", error);
          return {
            message: "メールアドレスまたはパスワードが間違っています",
          };
      }
    }
    // リダイレクトエラーの場合はリダイレクト
    if (isRedirectError(error)) {
      redirect("/user");
    }
    return {
      message: "An unexpected error occurred during signin",
    };
  }
};

ログインページの実装例

/app/(auth)/signin/page.tsx
"use client";

import { useFormState } from "react-dom";
import { LoginState, signin } from "../_action/action";

export default function SignInPage() {
  const initialState = {
    errors: {},
  } satisfies LoginState;

  const [state, dispatch] = useFormState(signin, initialState);
  return (
    <div className="relative flex h-screen flex-col justify-center overflow-hidden">
      <div className="m-auto w-full rounded bg-white p-6 shadow-md md:max-w-lg">
        <h1 className="text-center text-3xl font-semibold text-primary">
          SIGN IN
        </h1>
        {/* エラーメッセージを表示 */}
        {state.message && (
          <div className="text-red-500 text-center">{state.message}</div>
        )}
        <form action={dispatch} className="space-y-4">
          {/* email */}
          <div>
            <label className="label">
              <span className="label-text text-base">email</span>
            </label>
            <input
              className="input input-bordered input-primary w-full"
              defaultValue={""}
              name="email"
              type="email"
            />
          </div>
          {/* password */}
          <div>
            <label className="label">
              <span className="label-text text-base">password</span>
            </label>
            <input
              className="input input-bordered input-primary w-full"
              defaultValue={""}
              name="password"
              type="password"
            />
          </div>
          <div>
            <button className="btn btn-primary" type="submit">
              Login
            </button>
          </div>
        </form>
      </div>
    </div>
  );
}

その他

ハマったこと

NEXT_REDIRECT エラーについて

signIn()が成功した場合NEXT_REDIRECTエラーが投げられる
そのためtry-catchでsignIn()を呼び出す場合は,catchでリダイレクト処理を行う必要があるっぽい?

上記のより良い方法があれば教えてください

/app/(auth)/_action/action.ts
"use server";

import { redirect } from "next/navigation";
import { signIn } from "../../../../auth";
import { AuthError } from "next-auth";
import { isRedirectError } from "next/dist/client/components/redirect";

~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

export const signin = async (
  _state: LoginState,
  formData: FormData
): Promise<LoginState> => {
  // login処理
  try {
    // NEXT_REDIRECTが投げられ,catchでリダイレクトされる
    await signIn("credentials", formData);
    return { message: "success" };
  } catch (error) {
    if (error instanceof AuthError) {
      switch (error.type) {
        case "CredentialsSignin":
          console.error("Signin error:", error);
          return {
            message: "メールアドレスまたはパスワードが間違っています",
          };
      }
    }
    // リダイレクトエラーの場合はリダイレクト
    if (isRedirectError(error)) {
      redirect("/user");
    }
    return {
      message: "An unexpected error occurred during signin",
    };
  }
};

zodについて

今回は簡略化のために適用していないが,zodなどを使用することが推奨されている

参考:https://authjs.dev/getting-started/authentication/credentials#verifying-data-with-zod

route handlerについて

OAuthやEmail認証などの認証方法を追加する際には、/app/api/auth/[...nextauth]/route.ts を作成する
Credentials providerでは使用しないため不要

/app/api/auth/[...nextauth]/route.ts
import { handlers } from "@/auth" 
export const { GET, POST } = handlers

session取得について

下記URLのv5を参照
なおClient Componentでのsession取得はuseSessionを使用する.そのためSessionProviderを設定する必要がある

https://authjs.dev/getting-started/migrating-to-v5#authentication-methods

今回使用したレポジトリ

// Next.jsとNextAuth.jsを使用した認証機能のサンプル
https://github.com/ph3nac/next-auth-v5-example

// 簡易的なAPIサーバー
https://github.com/ph3nac/nest-rest-example/tree/next-auth

Discussion