🔑

NextAuth.jsについて調べたので使い方まとめとく

2022/11/06に公開
1

アプリ設定

GitHubとGoogleでのログインを試すため、あらかじめアプリを作っておく。各アプリ設定は参考リンクを参照。各クライアントIDとクライアントシークレットは取得してあるものする。

開発環境準備

以下のファイルを準備してdocker composeで起動

docker-compose.yml
version: "3"
services:
  app:
    build:
      context: .
    container_name: nextauth-example-app
    ports:
      - "3000:3000"
    tty: true
    volumes:
      - ./app:/app
    working_dir: /app
Dockerfile
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もここで指定する。

app/.env
NEXTAUTH_SECRET=xxxxxxxxxx
GITHUB_ID=xxxxxxxxxxxxxxxxxxxx
GITHUB_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

.envの値に型定義

app/types/environment.d.ts
namespace NodeJS {
  interface ProcessEnv extends NodeJS.ProcessEnv {
    GITHUB_ID: string;
    GITHUB_SECRET: string;
  }
}
app/tsconfig.json
{
  ・・・
  "include": [
    "types/**/*.ts",
    ・・・
  ],
}

NextAuthの設定。データベースを使わないのでセッションの保存先にJWTを指定

app/pages/api/auth[...nextauth].ts
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を適用

app/pages/_app.tsx
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")のようにプロバイダーを指定して呼ぶこともできる。

app/pages/index.tsx
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にこれを定義する。

app/types/next-auth.d.ts
import { DefaultSession } from "next-auth";

declare module "next-auth" {
  interface Session {
    user: {
      accessToken?: string;
    } & DefaultSession["user"];
  }
}

デフォルトの挙動ではセッションにアクセストークンが含まれないので[...nextauth].tscallbacksでデフォルトの挙動を拡張する。sessionコールバックで返す値がセッションとして使えるのでここでアクセストークンをセットする。

app/pages/api/auth/[...nextauth].ts
callbacks: {
  ・・・
  async session({ session, token }) {
    session.user.accessToken = token.accessToken;
    return session;
  },
},

ただし、sessionコールバックの引数で渡されるtoken(JWT)にもアクセストークンが含まれないので、JWTも拡張する必要がある。

app/types/next-auth.d.ts
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のみが使用可能です。

app/pages/api/auth/[...nextauth].ts
callbacks: {
  ・・・
  async jwt({ token, account }) {
    if (account) {
      token.accessToken = account.access_token
    }
    return token
  }
},

セッションからアクセストークンを取得できるようになる。また、今回のサンプルアプリではリポジトリの一覧を取得したいので、scopeを設定する。

app/pages/api/auth/[...nextauth].ts
・・・
export const authOptions: NextAuthOptions = {
  providers: [
    GithubProvider({
      ・・・
      authorization: {
        params: { scope: "repo" },
      },
    }),
  ],
  ・・・
}

export default NextAuth(authOptions);

アクセストークンを使ったAPIへリクエストができるようになった

app/pages/index.tsx
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が期待するテーブル構造にする必要がある

このアプリではUserAccountを使用する。また、VerificationTokenはメールアドレスを使ったパスワードレスログインが必要な場合に、Sessionはセッションをデータベースで管理する場合に使用する

具体的なデータベース処理はNextAuth.jsで各アダプターが用意されている。このアプリではPrismaMariaDBを使用する

データベースの準備

MariaDBの接続情報とdocker-compose.ymlを編集してdocker compose upしなおす

app/.env
・・・
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}"
docker-compose.yml
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
mariadb/dcl.sql
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を参考にスキーマ定義を追記する

app/prisma/schema.prisma
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.jsPrismaアダプターをインストールしておく

$ npx prisma generate
$ npx prisma migrate dev --name init
$ npm install --save @next-auth/prisma-adapter

[...nextauth].tsPrismaアダプターを使用するように追記

app/pages/api/auth/[...nextauth].ts
・・・
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ログインもできるようにする。ここでは省略するが.envtypes/environment.d.tsにGitHubと同じようにGOOGLE_IDGOOGLE_SECRETを追記する

Googleプロバイダを追記

app/pages/api/[...nextauth].ts
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が生成される

app/pages/index.tsx
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をセットするようにする。

app/pages/api/[...nextauth].ts
・・・
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.emailnullとしたので、ここではSignupページでEmailの入力フォームを設けてメールアドレスを登録したらSignupしたこととする

ミドルウェアでページの保護ができるので、メールアドレスの設定が完了しているかを条件に入れる

app/middleware.ts
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を指定する

app/pages/api/auth/[...nextauth].ts
callbacks: {
  ・・・
  async redirect({ baseUrl }) {
    return `${baseUrl}/signup`;
  },
},

signupページを追加する。ここではsignout状態または既にメールの設定が完了していないかどうかを表示前にチェックする

app/pages/signup.tsx
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とリクエストする部分を実装する

app/pages/signup.tsx
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

app/pages/api/signup.ts
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({});
}

メール + パスワードでの認証

https://next-auth.js.org/providers/credentials#example---username--password
Credentials Providerを使用する。ただし、ユーザー登録・メール認証を備えたユーザー登録・パスワードリセットなどの機能が必要な場合は独自で実装する必要がある。また、Credentials ProviderではJWTを使ったセッション管理を行う

その他

今回作ったサンプルアプリ
https://github.com/nrikiji/nextauth-example

参考リンク

GitHub
https://docs.github.com/ja/developers/apps/building-oauth-apps/creating-an-oauth-app

Google
https://reffect.co.jp/react/next-auth#Google_Cloud_PlatfomGCP

Discussion

kage1020kage1020

大変良い記事で参考になりました.

1つ補足ですが,@v4.15.0(2022/10/24)から各プロバイダーにおいてallowDangerousEmailAccountLinkingというプロパティを設定できるようになったらしいです.

GithubProvider({
  clientId: process.env.GITHUB_ID,
  clientSecret: process.env.GITHUB_SECRET,
  allowDangerousEmailAccountLinking: true,
}),

これは,重複するメールアドレスでSignUpしようとしたときUserAccountをリンクするかを選べる設定で,アダプターをカスタマイズする以降をまるっと行ってくれるようです.
名前にdangerousとついているので少し躊躇しますが,実装が大変な時にはこちらを使うのもありですね.