概要

既存のNext.jsプロジェクトに、人気の認証ライブラリ「Auth.js」とDBアダプター「Prisma」を利用して認証機構を導入したいところ。通常通りのやり方では壁に当たってしまったのでその説明と、どうやって事態を打開したかを解説します。

具体的な解決までの過程

私の方では、

で作業を進めました。それを前提でお読みいただければと思います。

問題に突きあたるまでの実装方法のおさらい

基本的な実装までの流れであれば、公式サイトでまとまっています。

ただし、App Routerについての記事は極端に少ない(メンテ中) ため、外部のポストを頼るしかありませんでした。

人気のライブラリだけあって記事を書いたフォロワーさんがたくさんおられましたので、そちらの手順に従って作業を行なっていました。特に参考にさせていただいたのは以下のポストです。



実装する手順を簡潔にまとめると以下のようになっていきます

認証の受付エンドポイントを定義

下記のファイルを作成・実装し、/api/auth/xxxxで認証周りの各リクエストを自動で受け付けられるようにします。

src/app/api/auth/[nextauth]/route.ts
import { options } from "@/app/options";
import NextAuth from "next-auth";
const handler = NextAuth(options);

export { handler as GET, handler as POST }

NextAuthOptionを定義する

下記のように実装し、上記route.tsから定数を呼び出せるようにします。

Googleの認証キー・シークレットはこちらの記事を参考にさせていただきました。ありがとうございます。

src/app/options.ts
import type { NextAuthOptions } from "next-auth";
import GoogleProvider from "next-auth/providers/google";
import { PrismaAdapter } from "@auth/prisma-adapter";
import { PrismaClient } from "@prisma/client";

const prisma = new PrismaClient();

export const options: NextAuthOptions = {
  debug: true,
  session: {
    strategy: "database",
  },
  adapter: PrismaAdapter(prisma), // 
  providers: [
    GoogleProvider({
      clientId: process.env.GOOGLE_CLIENT_ID || "",
      clientSecret: process.env.GOOGLE_CLIENT_SECRET || "",
    }),
  ],
  secret: process.env.NEXTAUTH_SECRET,
  callbacks: {
    redirect: async ({ url, baseUrl }) => {
      return `${baseUrl}/signup`
    },
    session: async ({ session, token, user, trigger, newSession }) => {
      return {
        ...session,
        user: {
          ...session.user,
        },
      };
    },
  }
}

prisma-adapterはnext-authをインストールするだけではダメで、別途ライブラリをインストールする必要があります。 ご不明の方は前述の参考記事のなかで手順をご確認ください。

ログイン/ログアウトボタンを実装する

src/components/Common/LoginLogoutButton.tsxに 下記のように実装し、

各ページのレイアウトファイルlayout.tsxのヘッダー部に実装し、<LoginLogoutButton /> の形式で呼び出せるようにします。

src/components/Common/LoginLogoutButton.tsx
"use client";

import { signIn, signOut } from "next-auth/react";
import { useSession } from 'next-auth/react'
import Image from "next/image"
import { useRouter } from "next/navigation";

export const LoginLogoutButton = ({ classStr }: { classStr?: string }) => {

  const { data: session } = useSession()
  return (
    <div className="flex">
      {session &&
        <div className="flex mr-7">
          {session?.user?.image && (
            <Image src={session?.user?.image} width="32" height="24" alt={session?.user?.name || ""} className="rounded-full mr-2" />
          )}
          <p className={classStr ? classStr : '' + ' text-white leading-loose'}>
            &nbsp;{session?.user?.name ?? 'guest'}&nbsp;</p >
        </div>
      }
      {
        session ?
          (
            <button className="text-black bg-white rounded px-3 py-1" onClick={() => signOut()}>ログアウト</button>
          )
          : (
            <button className="text-black bg-white rounded px-3 !py-1" onClick={() => signIn()}>ログイン</button>
          )
      }
    </div>
  )
};

Prismaの定義を作成

/prisma/schema.prismaを作成した上スキーマファイルに記述を行います。

model Account {
  id                String  @id @default(cuid())
  userId            String
  type              String
  provider          String
  providerAccountId String
  refresh_token     String? @db.Text
  access_token      String? @db.Text
  expires_at        Int?
  token_type        String?
  scope             String?
  id_token          String? @db.Text
  session_state     String?
  user              User    @relation(fields: [userId], references: [id], onDelete: Cascade)

  @@unique([provider, providerAccountId])
}

model Session {
  id           String   @id @default(cuid())
  sessionToken String   @unique
  userId       String
  expires      DateTime
  user         User     @relation(fields: [userId], references: [id], onDelete: Cascade)
}

model User {
  id            String    @id @default(cuid())
  name          String?
  email         String?   @unique
  emailVerified DateTime?
  image         String?
  phone         String?
  website       String?
  created_at    DateTime? @db.DateTime(0)
  updated_at    DateTime? @db.DateTime(0)
  accounts      Account[]
  sessions      Session[]
}

model VerificationToken {
  identifier String
  token      String   @unique
  expires    DateTime

  @@unique([identifier, token])
}

prismaのインストールがまだの方は

yarn add prisma

でインストールしてください。

この状態でnext.jsのプロジェクトルートで

npx prisma db push

を実行してください。これで、ログインの準備が行えたはずです。私の方ではこの状態で、「ログイン」ボタンを押すとログインできました。

生じた問題

セッションのフィルター である、options.tsのsessiontokenという変数があるのですが、これに具体的な値が入ってきません。。これが取れないと一時キーによるAPIを経由したDatabase認証が行えず、APIの利用が必要な私のユースケースでは実用性がないことになります。

現在日本語で書かれているポストではそこまで実用に突っ込んだ記事がなかったため、今回私の方で 取り上げてみようと思った次第です。

ここで問題が起きた

callbacks: {
    redirect: async ({ url, baseUrl }) => {
      return `${baseUrl}/signup`
    },
    session: async ({ session, token, user, trigger, newSession }) => {

      console.log(token) // これがnullになる!

      return {
        ...session,
        user: {
          ...session.user,
        },
      };
    },
  }

関数ジャンプで、node_modules/next-auth/src/core/types.tsに移動すると

 session: (
    params:
      | {
        session: Session
        /** Available when {@link SessionOptions.strategy} is set to `"jwt"` */
        token: JWT
        /** Available when {@link SessionOptions.strategy} is set to `"database"`. */
        user: AdapterUser
      } & {
        /**
         * Available when using {@link SessionOptions.strategy} `"database"`, this is the data
         * sent from the client via the [`useSession().update`](https://next-auth.js.org/getting-started/client#update-session) method.
         *
         * ⚠ Note, you should validate this data before using it.
         */
        newSession: any
        trigger: "update"
      }
  ) => Awaitable<Session | DefaultSession>

このようになっており、「token」のところに、

token: JWT Available when link SessionOptions.strategy is set to "database".

strategyをdatabaseにすると出現します

とあるのに、入ってこないです...。

ポストを漁る

これについて、めちゃくちゃ長いdiscussionsがnext-authのレポジトリに発生していました。 みなさん悩んでいるようですね...。

これを読んだ限りでは、結論としては以下のようでした。

アクセストークンを返すのを廃止したのはおそらく 帰ってきたsessionをフロントエンドのコンソールに誤って出力するケースが生じて、そのアクセストークンを剽窃して第3者がなりすましで外部サービスにアクセスできてしまう というケースを懸念したものと思います。

逆にいうとそのリスクを把握した上でaccess_tokenを適宜利用するのであればいいのかと個人的には思いましたが...

こう解決した

試行錯誤をしましたが、最終的に私の方では、prismaを経由してセッションを取得しに行くようにしました。

src/app/options.ts
import type { NextAuthOptions } from "next-auth";
import GoogleProvider from "next-auth/providers/google";
import { PrismaAdapter } from "@auth/prisma-adapter";
import { PrismaClient } from "@prisma/client";

const prisma = new PrismaClient();

export const options: NextAuthOptions = {
  debug: true,
  session: {
    strategy: "database",
  },
  adapter: PrismaAdapter(prisma),
  providers: [
    GoogleProvider({
      clientId: process.env.GOOGLE_CLIENT_ID || "",
      clientSecret: process.env.GOOGLE_CLIENT_SECRET || "",
    }),
  ],
  secret: process.env.NEXTAUTH_SECRET,
  callbacks: {
    redirect: async ({ url, baseUrl }) => {
      return `${baseUrl}/signup`
    },
    session: async ({ session, token, user, trigger, newSession }) => {

      prisma.$connect()
      const userAccount = await prisma.account.findFirst({
        where: {
          userId: user.id
        }
      })
      prisma.$disconnect()

      return {
        ...session,
        user: {
          ...session.user,
          accessToken: userAccount?.access_token
        },
      };
    },
  }
}

sessionフィルターを経由するたびに、DB認証を行い、accessTokenを返すようにします。 重い処理ではないのですが、トラフィックが増えたりするとここの実行回数が掛け算で響いてくる気がするので、早いところ公式で無駄にリクエストが生じない方法をオプションとして指定できるようにご対応いただけることを望みます。

今のところはこの方法で解決できたので、アクセストークンをログに出すことについては(アクセストークンに有効期限があるため致命的ではないにせよ)留意しつつ、APIにどんどんリクエストをしたいと思います。

まとめ

いかがでしたでしょうか。


Auth.js(旧NextAuth)はサクッと外部サービスによる認証を実現できる素晴らしい技術ですが、ドキュメントおよび細かい点でまだ追いついていないところがあるな と 残念ながら感じました。このあたりはエコシステムの各位の協力も得て、もっと素晴らしい技術になってくれると個人的に嬉しいと思いました!


この記事が何かのお役に立てれば幸いです。
最後までお読みいただきありがとうございました!