Oteto Blogのロゴ

Next.js (App Router) にAuth.jsでGoogle認証機能を実装する

要件

  • Googleアカウントでログイン・ログアウトする
  • ログイン状態に応じて画面の要素を出し分ける・リダイレクトさせる (認可)
  • ログインしたユーザーとセッション情報をDBに保存する

環境・技術

  • Next.js 14 (App Router)
  • Drizzle ORM (PostgreSQL)

実装

0. ORMの導入

下記記事のようにDrizzle ORMの設定が完了している前提で進める。

【Next.js】Drizzle ORMを導入してマイグレーションするまで

1. GCPの設定

まずクライアントIDの作成 | Google Cloudに沿ってGoogleのOAuthクライアントIDとシークレットキーを取得する。

2. 環境変数を設定

.env.localGOOGLE_CLIENT_ID=xxx
GOOGLE_CLIENT_SECRET=yyy
NEXTAUTH_SECRET=zzz
NEXTAUTH_URL=http://localhost:3000

先程GCPで取得したクライアントIDとシークレットを環境変数に設定する。

openssl rand -base64 32

NEXTAUTH_SECRETはメール認証トークンのハッシュ化とJWTの暗号化に必要なもので、上記のコマンドで生成できる。

3. ライブラリのインストール

npm i next-auth@beta @auth/drizzle-adapter @auth/core

4. providerとadapterを設定

src/infra/auth/index.tsimport type { NextAuthConfig } from 'next-auth';
import NextAuth from 'next-auth';
import GoogleProvider from 'next-auth/providers/google';
import { DrizzleAdapter } from '@auth/drizzle-adapter';
import { db } from '@/infra/db';

const authConfig = {
  providers: [
    GoogleProvider({
      clientId: process.env.GOOGLE_CLIENT_ID,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET,
    }),
  ],
  adapter: DrizzleAdapter(db),
} satisfies NextAuthConfig;

export const { handlers, auth, signOut } = NextAuth(authConfig);

認証にまつわる設定を実装する。

  • 今回はGoogle認証のみを利用するのでprovidersGoogleProviderを指定
  • adapterDrizzleAdapterを指定するだけで、Drizzle ORMを介して対象のテーブルに読み書きしてくれる

以降はここでexportした{ handlers, auth, signOut }を利用していく。

5. 認証用のAPI Routeを作成

src/app/api/auth/[...nextauth]/route.tsimport { handlers } from '@/infra/auth';

export const { GET, POST } = handlers;

6. テーブルを作成

6-1. スキーマを宣言

Drizzle ORMを介して対象のテーブルに読み書きするためには、adapter側で想定しているテーブル・カラムを用意する必要がある。

そこで@auth/drizzle-adapterの公式ドキュメントに記載されている通りにスキーマを宣言する (一部カラムの型や制約はカスタムしている) 。

src/infra/db/schemas/user.schema.tsimport { pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core';

export const UserSchema = pgTable('user', {
  id: uuid('id').defaultRandom().notNull().primaryKey(),
  name: text('name'),
  email: text('email').notNull().unique(),
  emailVerified: timestamp('emailVerified', { mode: 'date' }),
  image: text('image'),
});
src/infra/db/schemas/account.schema.tsimport { integer, pgTable, primaryKey, text, uuid } from 'drizzle-orm/pg-core';
import type { AdapterAccount } from '@auth/core/adapters';
import { UserSchema } from '@/infra/db/schemas/user.schema';

export const AccountSchema = pgTable(
  'account',
  {
    userId: uuid('userId')
      .notNull()
      .references(() => UserSchema.id, { onDelete: 'cascade' }),
    type: text('type').$type<AdapterAccount['type']>().notNull(),
    provider: text('provider').notNull(),
    providerAccountId: text('providerAccountId').notNull(),
    refresh_token: text('refresh_token'),
    access_token: text('access_token'),
    expires_at: integer('expires_at'),
    token_type: text('token_type'),
    scope: text('scope'),
    id_token: text('id_token'),
    session_state: text('session_state'),
  },
  (account) => ({
    compoundKey: primaryKey({
      columns: [account.provider, account.providerAccountId],
    }),
  }),
);
src/infra/db/schemas/session.schema.tsimport { pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core';
import { UserSchema } from '@/infra/db/schemas/user.schema';

export const SessionSchema = pgTable('session', {
  sessionToken: text('sessionToken').notNull().primaryKey(),
  userId: uuid('userId')
    .notNull()
    .references(() => UserSchema.id, { onDelete: 'cascade' }),
  expires: timestamp('expires', { mode: 'date' }).notNull(),
});

6-2. マイグレーション

npm run db:generate
npm run db:push

7. プロフィール画面を実装

ログイン中のアカウント情報を表示する/meを実装する。

src/app/me/page.tsximport { auth, signOut } from '@/infra/auth';
import { redirect } from 'next/navigation';

export default async function MyPage(): Promise<JSX.Element> {
  const session = await auth();
  if (!session?.user) {
    redirect('/api/auth/signin');
  }
  const { user } = session;

  return (
    <>
      <div>{user.name}</div>
      <div>{user.email}</div>
      <img src={user.image} alt={user.name} width="50" height="50" />
      <form
        action={async () => {
          'use server';
          await signOut({ redirectTo: '/' });
        }}
      >
        <button type="submit">logout</button>
      </form>
    </>
  );
}
  • src/infra/auth/index.tsでエクスポートしたauth()でセッション情報を取得し、ユーザー情報を表示
  • 「logout」ボタンを押下するとServer Action内でsignOut()を呼ぶ
  • 未ログインであればログインページにリダイレクト

8. 動作確認

Googleのログインボタンが表示された

/api/auth/signinにアクセスするとログインボタンが表示された。

Googleアカウントのログイン画面が表示される

押下するとGoogleアカウントのログイン画面が表示されるのでアカウントを選択。

対象ユーザーの情報が表示された

/meにアクセスすると、対象ユーザーの情報が表示された。

Userテーブルを参照してみるとレコードも挿入されていることが確認できる

npm run db:previewでDrizzle Studioを起動し、UserAccountSessionテーブルを参照してみるとレコードも挿入されていることが確認できる。

また「logout」ボタンを押下し/meにアクセスするとトップページにリダイレクトされ、Sessionテーブルのレコードも削除された。

発展

ミドルウェアで認可する

今回の/meのようなページのみを保護対象にしたいので、ミドルウェアを用いて認可 (ガード) 機能を実装する。

src/middleware.tsimport { auth } from '@/infra/auth';

export default auth;

export const config = {
  matcher: ['/((?!api/auth).*)'],
};

ミドルウェアの設定として認証周りのRouteを対象から除外し、あとはauthをdefault exportするだけ。

src/infra/auth/index.tsimport type { NextAuthConfig } from 'next-auth';
import NextAuth from 'next-auth';
import GoogleProvider from 'next-auth/providers/google';
import { DrizzleAdapter } from '@auth/drizzle-adapter';
import { db } from '@/infra/db';
import { NextResponse } from 'next/server';

const PROTECTED_PATHS = ['/me'];

export const authConfig = {
  providers: [
    GoogleProvider({
      clientId: process.env.GOOGLE_CLIENT_ID,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET,
    }),
  ],
  adapter: DrizzleAdapter(db),
  callbacks: {
    authorized({ auth, request: { nextUrl } }) {
      const isLoggedIn = !!auth?.user;
      if (isLoggedIn) return true;

      const isProtectedPath = PROTECTED_PATHS.some((path) =>
        nextUrl.pathname.startsWith(path),
      );
      if (!isProtectedPath) return true;

      return NextResponse.redirect(new URL('/api/auth/signin', nextUrl));
   },
  },
} satisfies NextAuthConfig;

export const { handlers, auth, signOut } = NextAuth(authConfig);

未ログインで/meにアクセスすると/api/auth/signinにリダイレクトされるようになった。

session.userに任意のプロパティを含める

src/infra/auth/index.ts...
  adapter: DrizzleAdapter(db),
  callbacks: {
    async session({ session, user }): Promise<Session> {
      if (!session.user) return session;

      session.user.id = user.id;
      return session;
    },
  },
} satisfies NextAuthConfig;

デフォルトだとsession.userオブジェクトにidが含まれないが、session()をオーバーライドして含めることも可能。

src/infra/auth/next-auth.d.tsimport type { DefaultSession } from 'next-auth';

declare module 'next-auth' {
  interface Session {
    user: {
      id: string;
    } & DefaultSession['user'];
  }
}

またSessionの型を拡張してidを含めることで型も補完される。