NextAuth.jsについて調べたので使い方まとめとく
アプリ設定
GitHubとGoogleでのログインを試すため、あらかじめアプリを作っておく。各アプリ設定は参考リンクを参照。各クライアントIDとクライアントシークレットは取得してあるものする。
開発環境準備
以下のファイルを準備してdocker compose
で起動
version: "3"
services:
app:
build:
context: .
container_name: nextauth-example-app
ports:
- "3000:3000"
tty: true
volumes:
- ./app:/app
working_dir: /app
FROM node:18-slim
RUN apt-get update
RUN apt-get install -y openssl
アプリ用のコンテナ起動
$ docker compose up
コンテナにログインして、Next.jsをインストール後、起動できることを確認する
$ docker exec -it nextauth-example-app bash
$ npx create-next-app . --ts --use-npm
$ npm run dev
NextAuth.js をインストール
$ npm install --save next-auth
データベースなしで使う
アプリではユーザー情報の管理はせずに、GitHubにログインしてユーザー名とメールアドレスを取得する。GitHubの認証に必要なクライアントIDとシークレットを.env
に記述する。また、NEXTAUTH_SECRETもここで指定する。
NEXTAUTH_SECRET=xxxxxxxxxx
GITHUB_ID=xxxxxxxxxxxxxxxxxxxx
GITHUB_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
.envの値に型定義
namespace NodeJS {
interface ProcessEnv extends NodeJS.ProcessEnv {
GITHUB_ID: string;
GITHUB_SECRET: string;
}
}
{
・・・
"include": [
"types/**/*.ts",
・・・
],
}
NextAuthの設定。データベースを使わないのでセッションの保存先にJWTを指定
import NextAuth, { NextAuthOptions } from "next-auth";
import GithubProvider from "next-auth/providers/github";
export const authOptions: NextAuthOptions = {
providers: [
GithubProvider({
clientId: process.env.GITHUB_ID,
clientSecret: process.env.GITHUB_SECRET,
}),
],
session: { strategy: "jwt" },
}
export default NextAuth(authOptions);
各コンポーネントからセッションにアクセスするためにSessionProvider
を適用
import "../styles/globals.css";
import type { AppProps } from "next/app";
import { Session } from "next-auth";
import { SessionProvider } from "next-auth/react";
export default function App({ Component, pageProps }: AppProps<{ session: Session }>) {
return (
<SessionProvider session={pageProps.session}>
<Component {...pageProps} />
</SessionProvider>
);
}
GitHubでの認証とセッションを使う準備できたので、未ログインならSigninボタン、ログイン中ならセッションの中身とSignoutボタンを表示する。
ログイン状態をuseSession
の返り値で判定することができる。ここではsignIn()
を引数なしで呼んでいる。こうすることで有効なプロバイダーのログインボタンが一覧表示される。signIn("github")
のようにプロバイダーを指定して呼ぶこともできる。
import { useSession, signIn, signOut } from "next-auth/react";
export default function Home() {
const { data: session } = useSession();
return session ? (
<>
{JSON.stringify(session)}
<button onClick={() => signOut()}>SignOut</button>
</>
) : (
<>
<button onClick={() => signIn()}>SignIn</button>
</>
);
}
また、デフォルトのSessionは以下のとおり定義されている
export interface DefaultSession {
user?: {
name?: string | null
email?: string | null
image?: string | null
}
expires: ISODateString
}
export interface Session extends DefaultSession {}
アクセストークンを使う
ログイン中のユーザーのGitHubのリポジトリの一覧をアクセストークンを使用して取得する。上の定義のとおり、デフォルトのSessionにはアクセストークンが定義されていないので、Sessionのuserにこれを定義する。
import { DefaultSession } from "next-auth";
declare module "next-auth" {
interface Session {
user: {
accessToken?: string;
} & DefaultSession["user"];
}
}
デフォルトの挙動ではセッションにアクセストークンが含まれないので[...nextauth].ts
のcallbacks
でデフォルトの挙動を拡張する。session
コールバックで返す値がセッションとして使えるのでここでアクセストークンをセットする。
callbacks: {
・・・
async session({ session, token }) {
session.user.accessToken = token.accessToken;
return session;
},
},
ただし、session
コールバックの引数で渡されるtoken(JWT)にもアクセストークンが含まれないので、JWTも拡張する必要がある。
import { JWT } from "next-auth/jwt";
・・・
declare module "next-auth/jwt" {
interface JWT {
accessToken?: string;
}
}
また、デフォルトのJWTは以下のとおり定義されている
export interface DefaultJWT extends Record<string, unknown> {
name?: string | null
email?: string | null
picture?: string | null
sub?: string
}
export interface JWT extends Record<string, unknown>, DefaultJWT {}
以下は、引用の翻訳
セッション コールバックは、セッションがチェックされるたびに呼び出されます。デフォルトでは、セキュリティを高めるためにトークンのサブセットのみが返されます。もしあなたがjwt()コールバックを通してトークンに追加したもの(上記のaccess_tokenやuser.idなど)を利用可能にしたい場合は、明示的にここに転送してクライアントが利用できるようにしなければなりません。
引数user、account、profile、isNewUserは、ユーザーがサインインした後、新しいセッションでこのコールバックが最初に呼び出されたときのみ渡されます。それ以降の呼び出しでは、tokenのみが使用可能です。
callbacks: {
・・・
async jwt({ token, account }) {
if (account) {
token.accessToken = account.access_token
}
return token
}
},
セッションからアクセストークンを取得できるようになる。また、今回のサンプルアプリではリポジトリの一覧を取得したいので、scope
を設定する。
・・・
export const authOptions: NextAuthOptions = {
providers: [
GithubProvider({
・・・
authorization: {
params: { scope: "repo" },
},
}),
],
・・・
}
export default NextAuth(authOptions);
アクセストークンを使ったAPIへリクエストができるようになった
export default function Home() {
const { data: session } = useSession();
const fetchRepos = () => {
if (session?.user.accessToken) {
return;
}
const url = "https://api.github.com/user/repos?per_page=10";
const headers = {
Authorization: "token " + session.user.accessToken,
};
fetch(url, { headers })
.then((res) => res.json())
.then((json) => console.log(json));
}
・・・
}
データベースありで使う
データベース連携することでアプリでユーザー情報を管理することができる。その場合は、NextAuth.jsが期待するテーブル構造にする必要がある
このアプリではUser
とAccount
を使用する。また、VerificationToken
はメールアドレスを使ったパスワードレスログインが必要な場合に、Session
はセッションをデータベースで管理する場合に使用する
具体的なデータベース処理はNextAuth.jsで各アダプターが用意されている。このアプリではPrisma
でMariaDB
を使用する
データベースの準備
MariaDBの接続情報とdocker-compose.yml
を編集してdocker compose up
しなおす
・・・
MARIADB_HOST=nextauth-example-mariadb
MARIADB_USER=user
MARIADB_DATABASE=app
MARIADB_PASSWORD=password
MARIADB_ROOT_PASSWORD=password
DATABASE_URL="mysql://${MARIADB_USER}:${MARIADB_PASSWORD}@${MARIADB_HOST}:3306/${MARIADB_DATABASE}"
version: "3"
services:
・・・
mariadb:
image: mariadb:10.9.3
container_name: nextauth-example-mariadb
restart: always
ports:
- "3306:3306"
env_file:
- app/.env
volumes:
- mariadb:/var/lib/mysql
- ./mariadb:/docker-entrypoint-initdb.d
volumes:
mariadb:
name: nextauth-example-mariadb
GRANT ALL ON *.* TO user;
Next.jsが起動するコンテナにログインしてPrisma
のインストールと初期化処理を行う
$ docker exec -it nextauth-example-app bash
$ npm install --save next-auth @prisma/client
$ npx prisma init
prisma/schema.prisma
が作成されるのでNextAuth.jsのModelを参考にスキーマ定義を追記する
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "mysql"
url = env("DATABASE_URL")
}
model Account {
id String @id @default(cuid())
userId String
type String
provider String
providerAccountId String @map("provider_account_id")
refresh_token String? @db.Text
refresh_token_expires_in Int?
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])
@@map("accounts")
}
model User {
id String @id @default(cuid())
name String?
email String? @unique
emailVerified DateTime? @map("email_verified")
image String?
accounts Account[]
@@map("users")
}
NextAuthアダプターの設定
Prisma
クライアントの生成とマイグレーション実行する。また、NextAuth.js
のPrisma
アダプターをインストールしておく
$ npx prisma generate
$ npx prisma migrate dev --name init
$ npm install --save @next-auth/prisma-adapter
[...nextauth].ts
にPrisma
アダプターを使用するように追記
・・・
import { PrismaClient } from "@prisma/client";
import { PrismaAdapter } from "@next-auth/prisma-adapter";
const prisma = new PrismaClient();
export const authOptions: NextAuthOptions = {
・・・
adapter: PrismaAdapter(prisma),
}
export default NextAuth(authOptions);
先ほど作ったSigninボタンでログインしなおすとテーブルにユーザー情報が保存されることが確認できる
複数アカウントと連携する
先ほど登録されたUser
としてGoogleログインもできるようにする。ここでは省略するが.env
、types/environment.d.ts
にGitHubと同じようにGOOGLE_ID
とGOOGLE_SECRET
を追記する
Googleプロバイダを追記
import GoogleProvider from "next-auth/providers/google";
・・・
export const authOptions: NextAuthOptions = {
providers: [
・・・
GoogleProvider({
clientId: process.env.GOOGLE_ID,
clientSecret: process.env.GOOGLE_SECRET,
}),
],
・・・
}
export default NextAuth(authOptions);
ログイン状態でsignIn
を呼ぶとログイン中のUser
に紐づくAccount
が生成される
import { signIn, ・・・ } from "next-auth/react";
・・・
export default function Home() {
const { data: session } = useSession();
return session ? (
<>
<button onClick={() => signIn("google")}>Link Google</button>
・・・
</>
) : (
・・・
);
}
User
は1レコードのままでAccount
が2レコードになっていることが確認できる
アダプターをカスタマイズする
GoogleとGitHubで使用しているメールアドレスが同じ場合、それぞれでSignInしようとするとメールアドレスが重複するため登録できない。これはNextAuth.jsの実装である。ここではこれを回避するためにUser
を登録する際にemail
に強制的にnull
をセットするようにする。
・・・
import { PrismaAdapter } from "@next-auth/prisma-adapter";
import { AdapterUser } from "next-auth/adapters";
const prisma = new PrismaClient();
export const prismaAdapter: Adapter = {
...PrismaAdapter(prisma),
// メールアドレスでの検索不可とする
getUserByEmail: () => null,
// email = null として User を登録する
createUser: async (data) => {
const user = await prisma.user.create({ data: { ...data, email: null } });
return user as AdapterUser;
},
};
export const authOptions: NextAuthOptions = {
・・・
adapter: prismaAdapter,
}
export default NextAuth(authOptions);
ミドルウェアを使ったSignInとSignUp
このアプリでは初めてSigninするとSignupしたことになるので、初めてのSignin後にSignupページを挟んでからアプリを使えるようにする。上でUser.email
をnull
としたので、ここではSignupページでEmailの入力フォームを設けてメールアドレスを登録したらSignupしたこととする
ミドルウェアでページの保護ができるので、メールアドレスの設定が完了しているかを条件に入れる
import { withAuth } from "next-auth/middleware";
export default withAuth({
callbacks: {
async authorized({ token }) {
if (token?.name && !token?.email) {
return false;
} else {
return true;
}
},
},
});
export const config = { matcher: ["/"] };
Signin後のURLで/signup
を指定する
callbacks: {
・・・
async redirect({ baseUrl }) {
return `${baseUrl}/signup`;
},
},
signupページを追加する。ここではsignout状態または既にメールの設定が完了していないかどうかを表示前にチェックする
import { GetServerSideProps } from "next";
import { getSession } from "next-auth/react";
・・・
export const getServerSideProps: GetServerSideProps = async (context) => {
const session = await getSession(context);
if (!session || session?.user.email != null) {
return {
redirect: {
permanent: false,
destination: "/",
},
};
}
return { props: {} };
};
メールを設定するAPIとリクエストする部分を実装する
import { useState } from "react";
import { ・・・, signIn } from "next-auth/react";
・・・
export default function Signup() {
const [email, setEmail] = useState("");
const [signuped, setSignuped] = useState(false);
const onSignup = async () => {
const res = await fetch("/api/signup", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email }),
});
if (res.status == 200) {
setSignuped(true);
} else {
alert("error signup");
}
};
return signuped ? (
<>
<h2>Success Signup</h2>
<button onClick={() => signIn()}>Re Signin</button>
</>
) : (
<>
<h2>Signup</h2>
<input placeholder="email" onChange={(e) => setEmail(e.target.value)} />
<button onClick={onSignup}>Signup</button>
</>
);
}
・・・
API
import type { NextApiRequest, NextApiResponse } from "next";
import { unstable_getServerSession } from "next-auth";
import { authOptions, prismaAdapter } from "./auth/[...nextauth]";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const session = await unstable_getServerSession(req, res, authOptions);
if (!session?.user.id) {
return res.status(401).end();
}
let user = await prismaAdapter.getUser(session!.user.id!);
if (!user || user.email) {
return res.status(401).end();
}
await prismaAdapter.updateUser({ ...user, email: req.body["email"] });
res.status(200).json({});
}
メール + パスワードでの認証
Credentials Provider
を使用する。ただし、ユーザー登録・メール認証を備えたユーザー登録・パスワードリセットなどの機能が必要な場合は独自で実装する必要がある。また、Credentials Provider
ではJWTを使ったセッション管理を行う
その他
今回作ったサンプルアプリ
参考リンク
GitHub
Discussion
大変良い記事で参考になりました.
1つ補足ですが,
@v4.15.0
(2022/10/24)から各プロバイダーにおいてallowDangerousEmailAccountLinking
というプロパティを設定できるようになったらしいです.これは,重複するメールアドレスでSignUpしようとしたとき
User
とAccount
をリンクするかを選べる設定で,アダプターをカスタマイズする以降をまるっと行ってくれるようです.名前にdangerousとついているので少し躊躇しますが,実装が大変な時にはこちらを使うのもありですね.