概要
既存のNext.jsプロジェクトに、人気の認証ライブラリ「Auth.js」とDBアダプター「Prisma」を利用して認証機構を導入したいところ。通常通りのやり方では壁に当たってしまったのでその説明と、どうやって事態を打開したかを解説します。
具体的な解決までの過程
私の方では、
- Next 13
- Next-Auth V4 (4.24.5)
- App Router (/app以下にプロジェクトが入る方式)
で作業を進めました。それを前提でお読みいただければと思います。
問題に突きあたるまでの実装方法のおさらい
基本的な実装までの流れであれば、公式サイトでまとまっています。
ただし、App Routerについての記事は極端に少ない(メンテ中) ため、外部のポストを頼るしかありませんでした。
人気のライブラリだけあって記事を書いたフォロワーさんがたくさんおられましたので、そちらの手順に従って作業を行なっていました。特に参考にさせていただいたのは以下のポストです。
実装する手順を簡潔にまとめると以下のようになっていきます
認証の受付エンドポイントを定義
下記のファイルを作成・実装し、/api/auth/xxxx
で認証周りの各リクエストを自動で受け付けられるようにします。
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の認証キー・シークレットはこちらの記事を参考にさせていただきました。ありがとうございます。
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 />
の形式で呼び出せるようにします。
"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'}>
{session?.user?.name ?? 'guest'} 様
</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のsession
でtokenという変数があるのですが、これに具体的な値が入ってきません。。これが取れないと一時キーによる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のレポジトリに発生していました。 みなさん悩んでいるようですね...。
これを読んだ限りでは、結論としては以下のようでした。
- access_tokenはDB認証の場合以前は返していたが、セキュリティの問題で返さなくなった
- 仕方がないので自前でアクセストークンをその場で発行し、Sessionテーブルにフィルターの中で直接挿入する。こちらなど。
- あるいは、jwt認証をそのまま利用し、NextAuthOptionのコールバックの中でなんとか加工して、prismaクライアント経由でDBに必要なセッションの作成・更新を行う方向で回避する
アクセストークンを返すのを廃止したのはおそらく 帰ってきたsessionをフロントエンドのコンソールに誤って出力するケースが生じて、そのアクセストークンを剽窃して第3者がなりすましで外部サービスにアクセスできてしまう というケースを懸念したものと思います。
逆にいうとそのリスクを把握した上でaccess_tokenを適宜利用するのであればいいのかと個人的には思いましたが...
こう解決した
試行錯誤をしましたが、最終的に私の方では、prismaを経由してセッションを取得しに行くようにしました。
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)はサクッと外部サービスによる認証を実現できる素晴らしい技術ですが、ドキュメントおよび細かい点でまだ追いついていないところがあるな と 残念ながら感じました。このあたりはエコシステムの各位の協力も得て、もっと素晴らしい技術になってくれると個人的に嬉しいと思いました!
この記事が何かのお役に立てれば幸いです。
最後までお読みいただきありがとうございました!