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認証のみを利用するので
providers
にGoogleProvider
を指定 adapter
にDrizzleAdapter
を指定するだけで、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. 動作確認
/api/auth/signin
にアクセスするとログインボタンが表示された。
押下するとGoogleアカウントのログイン画面が表示されるのでアカウントを選択。
/me
にアクセスすると、対象ユーザーの情報が表示された。
npm run db:preview
でDrizzle Studioを起動し、User
・Account
・Session
テーブルを参照してみるとレコードも挿入されていることが確認できる。
また「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を含めることで型も補完される。