🔐

Next.js TodoアプリにNextAuth.js v5で認証認可を実装してみた

に公開

本記事のサマリ

Next.js 16のTodoアプリにNextAuth.js(Auth.js v5)を使って認証認可機能を実装した経験をまとめました。メール/パスワード認証とGoogle OAuth、そしてadmin/userのロールベース認可まで、実際のコードと共に段階的に解説していきます。

(どうやら長らくv5はbeta版のままのようですね…早くStable版がリリースされてほしいですね)

はじめに

Web アプリを作る上で、認証認可は避けて通れない要素の一つです。特にTodoアプリのような個人データを扱うアプリでは「誰が」「何を」操作できるかをきちんと制御する必要があります。

今回は NextAuth.js の最新版である Auth.js v5 を使って、シンプルなTodoアプリに本格的な認証認可機能を追加してみました。NextAuth.js v5 は従来の v4 から大幅にリニューアルされ、より柔軟で型安全な設計になっているのが特徴です。特にJWTベースのセッション管理やServer Actionsとの親和性の高さは、Next.js 16 のような最新の開発スタイルにもよくマッチしています。

https://authjs.dev/getting-started/migrating-to-v5

実装する機能の概要

今回実装する認証認可システムは、実際のプロダクションでも使えるレベルを想定しています。認証方式はメール/パスワードとGoogle OAuthの2つを用意し、ユーザーロールはadminとuserの2段階で設計しました。

adminユーザーは全てのTodoを閲覧でき、さらにユーザー管理画面から他ユーザーの権限変更も可能です。一方でuserは自分が作成したTodoのみCRUD操作ができるという、よくあるマルチテナント的な権限設計になっています。

この設計により、チーム内でのタスク管理ツールとしても、個人的なTodo管理ツールとしても使えるアプリに仕上がります。

Phase 1: 依存パッケージのインストール

まずは必要なパッケージをインストールしていきましょう。NextAuth.js v5 は現在ベータ版なので、@beta タグを指定してインストールする必要があります。

cd app
bun add next-auth@beta @auth/prisma-adapter bcryptjs
bun add -d @types/bcryptjs

@auth/prisma-adapter は Prisma を使ったセッション管理のためのアダプターです。NextAuth.js v5 では様々なデータベースアダプターが用意されており、Prisma 以外にも MongoDB や Supabase なども選択できます。

bcryptjs はパスワードのハッシュ化に使用します。セキュリティ上、平文のパスワードをデータベースに保存するわけにはいかないので、必須のライブラリですね。

Phase 2: Prisma スキーマの更新

NextAuth.js を使用する際は、認証関連のテーブルをデータベースに追加する必要があります。公式ドキュメントを参考に、以下のモデルを prisma/schema.prisma に追加しました。

model User {
  id            String    @id @default(cuid())
  name          String?
  email         String    @unique
  emailVerified DateTime?
  image         String?
  password      String?   // メール/パスワード認証用
  role          Role      @default(USER)
  accounts      Account[]
  sessions      Session[]
  todos         Todo[]    @relation("CreatedTodos")
  createdAt     DateTime  @default(now())
  updatedAt     DateTime  @updatedAt
}

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 VerificationToken {
  identifier String
  token      String   @unique
  expires    DateTime

  @@unique([identifier, token])
}

enum Role {
  USER
  ADMIN
}

既存のTodoモデルにも、作成者を示す createdById フィールドを追加します。

model Todo {
  id          String    @id @default(cuid())
  title       String
  description String?
  completed   Boolean   @default(false)
  assigneeId  String?
  assignee    Assignee? @relation(fields: [assigneeId], references: [id], onDelete: SetNull)
  createdById String?   // 追加: 作成者
  createdBy   User?     @relation("CreatedTodos", fields: [createdById], references: [id], onDelete: SetNull)
  createdAt   DateTime  @default(now())
  updatedAt   DateTime  @updatedAt
}

スキーマを更新したら、マイグレーションを実行します。

bunx prisma migrate dev --name add_auth_models

Phase 3: 環境変数の設定

NextAuth.js の設定には環境変数での設定が必要です。.env ファイルに以下を追加しましょう。

# Auth.js
AUTH_SECRET=your_generated_secret_here
AUTH_GOOGLE_ID=your_google_client_id
AUTH_GOOGLE_SECRET=your_google_client_secret

AUTH_SECRET は NextAuth.js がJWTトークンの署名に使用する重要な値です。十分に複雑な値を設定する必要があります。

# Auth.js CLI を使って生成
npx auth secret

# または OpenSSL で生成
openssl rand -base64 32

Google OAuth の認証情報は Google Cloud Console から取得できます。新しいプロジェクトを作成して「認証情報」から OAuth 2.0 クライアント ID を作成し、承認済みリダイレクト URI に http://localhost:3001/api/auth/callback/google を設定してください。

https://console.cloud.google.com/

Phase 4: NextAuth.js の設定

NextAuth.js の設定ファイル lib/auth.ts を作成します。ここがこの実装の中核部分です。

import NextAuth from 'next-auth'
import { PrismaAdapter } from '@auth/prisma-adapter'
import Google from 'next-auth/providers/google'
import Credentials from 'next-auth/providers/credentials'
import bcrypt from 'bcryptjs'
import { prisma } from './prisma'

declare module 'next-auth' {
  interface User {
    role?: Role
  }
  interface Session {
    user: {
      id: string
      name?: string | null
      email?: string | null
      image?: string | null
      role: Role
    }
  }
}

declare module '@auth/core/jwt' {
  interface JWT {
    id: string
    role: Role
  }
}

export const { handlers, auth, signIn, signOut } = NextAuth({
  adapter: PrismaAdapter(prisma),
  session: { strategy: 'jwt' },
  pages: {
    signIn: '/auth/signin',
  },
  providers: [
    Google({
      clientId: process.env.AUTH_GOOGLE_ID!,
      clientSecret: process.env.AUTH_GOOGLE_SECRET!,
    }),
    Credentials({
      name: 'credentials',
      credentials: {
        email: { label: 'Email', type: 'email' },
        password: { label: 'Password', type: 'password' },
      },
      async authorize(credentials) {
        if (!credentials?.email || !credentials?.password) {
          return null
        }

        const user = await prisma.user.findUnique({
          where: { email: credentials.email as string },
        })

        if (!user || !user.password) {
          return null
        }

        const isPasswordValid = await bcrypt.compare(
          credentials.password as string,
          user.password
        )

        if (!isPasswordValid) {
          return null
        }

        return {
          id: user.id,
          email: user.email,
          name: user.name,
          role: user.role,
        }
      },
    }),
  ],
  callbacks: {
    async jwt({ token, user }) {
      if (user) {
        token.id = user.id!
        token.role = user.role || 'USER'
      }
      return token
    },
    async session({ session, token }) {
      if (token) {
        session.user.id = token.id
        session.user.role = token.role
      }
      return session
    },
  },
})

ここではJWTベースのセッション管理を採用しています。データベースへの問い合わせが不要になるため、パフォーマンス面でのメリットがありますね。

API ルートも設定します。app/api/auth/[...nextauth]/route.ts を作成してください。

import { handlers } from '@/lib/auth'
export const { GET, POST } = handlers

Phase 5: 認証 UI の作成

認証画面を作成していきます。まずはログインページから。

// app/auth/signin/page.tsx
import { auth, signIn } from '@/lib/auth'
import { redirect } from 'next/navigation'

export default async function SignInPage() {
  const session = await auth()
  
  if (session?.user) {
    redirect('/')
  }

  return (
    <div className="min-h-screen flex items-center justify-center">
      <div className="max-w-md w-full space-y-8 p-8">
        <h2 className="text-2xl font-bold text-center">ログイン</h2>
      
        <form action={async (formData) => {
          'use server'
          await signIn('credentials', {
            email: formData.get('email'),
            password: formData.get('password'),
            redirectTo: '/',
          })
        }}>
          <div className="space-y-4">
            <input
              name="email"
              type="email"
              required
              placeholder="メールアドレス"
              className="w-full px-3 py-2 border rounded-md"
            />
            <input
              name="password"
              type="password"
              required
              placeholder="パスワード"
              className="w-full px-3 py-2 border rounded-md"
            />
            <button
              type="submit"
              className="w-full bg-blue-500 text-white py-2 rounded-md hover:bg-blue-600"
            >
              ログイン
            </button>
          </div>
        </form>

        <form action={async () => {
          'use server'
          await signIn('google', { redirectTo: '/' })
        }}>
          <button
            type="submit"
            className="w-full bg-red-500 text-white py-2 rounded-md hover:bg-red-600"
          >
            Googleでログイン
          </button>
        </form>
      </div>
    </div>
  )
}

Server Actions を使って認証処理を実装しているのがポイントです。NextAuth.js v5 は Next.js の最新機能とよく統合されているなと感じます👍

新規登録機能も同様に実装します。パスワードのハッシュ化処理を忘れずに行うことが重要です。

Phase 6: Server Actions への認可追加

既存のTodo操作のServer Actionsに認可処理を追加していきます。まずは認可用のヘルパー関数を作成しました。

// lib/auth-utils.ts
import { auth } from './auth'
import { prisma } from './prisma'

export async function getCurrentUser() {
  const session = await auth()
  return session?.user ?? null
}

export async function requireAuth() {
  const user = await getCurrentUser()
  if (!user) {
    throw new Error('認証が必要です')
  }
  return user
}

export async function requireAdmin() {
  const user = await requireAuth()
  if (user.role !== 'ADMIN') {
    throw new Error('管理者権限が必要です')
  }
  return user
}

export async function canEditTodo(todoId: string) {
  const user = await requireAuth()
  
  if (user.role === 'ADMIN') {
    return true
  }

  const todo = await prisma.todo.findUnique({
    where: { id: todoId },
    select: { createdById: true },
  })

  if (!todo || todo.createdById !== user.id) {
    throw new Error('この Todo を編集する権限がありません')
  }
  
  return true
}

これらのヘルパー関数を使って、Todo操作のServer Actionsを更新していきます。

// actions/todo.ts
export async function getTodos() {
  const user = await getCurrentUser()
  
  if (!user) {
    return []
  }

  // Adminは全Todoを閲覧可能
  if (user.role === 'ADMIN') {
    return await prisma.todo.findMany({
      include: {
        assignee: true,
        createdBy: {
          select: { id: true, name: true, email: true },
        },
      },
      orderBy: { createdAt: 'desc' },
    })
  }

  // Userは自分が作成したTodoのみ
  return await prisma.todo.findMany({
    where: { createdById: user.id },
    include: {
      assignee: true,
      createdBy: {
        select: { id: true, name: true, email: true },
      },
    },
    orderBy: { createdAt: 'desc' },
  })
}

export async function createTodo(formData: FormData) {
  const user = await requireAuth()
  
  const title = formData.get('title') as string
  const description = formData.get('description') as string | null

  await prisma.todo.create({
    data: {
      title,
      description: description || null,
      createdById: user.id, // 作成者を自動設定
    },
  })

  revalidatePath('/')
}

export async function updateTodo(id: string, formData: FormData) {
  await canEditTodo(id) // 編集権限チェック
  
  const title = formData.get('title') as string
  const completed = formData.get('completed') === 'on'

  await prisma.todo.update({
    where: { id },
    data: { title, completed },
  })

  revalidatePath('/')
}

この認可設計により、userは自分のTodoだけを操作でき、adminは全体を管理できるという、実用的な権限制御が実現できました✨

Phase 7: Admin ユーザー管理機能

Admin専用のユーザー管理機能を実装します。これにより、管理者がユーザーのロールを変更したり、ユーザーを削除したりできるようになります。

// actions/user.ts
export async function getUsers() {
  await requireAdmin()

  return await prisma.user.findMany({
    select: {
      id: true,
      name: true,
      email: true,
      role: true,
      createdAt: true,
    },
    orderBy: { createdAt: 'desc' },
  })
}

export async function updateUserRole(userId: string, role: Role) {
  const admin = await requireAdmin()

  // 自分自身の権限は変更不可
  if (admin.id === userId) {
    throw new Error('自分自身の権限は変更できません')
  }

  await prisma.user.update({
    where: { id: userId },
    data: { role },
  })

  revalidatePath('/admin/users')
}

自分自身の権限変更を防ぐ処理を入れているのがポイントです。これがないと、うっかり自分をuser権限にして管理機能にアクセスできなくなってしまう可能性があります。

Phase 8: Middleware によるルート保護

最後に、Middleware を使ってルートレベルでの認証・認可制御を実装します。

// middleware.ts
import { auth } from '@/lib/auth'
import { NextResponse } from 'next/server'

const publicPaths = ['/auth/signin', '/auth/signup']

export default auth((req) => {
  const { pathname } = req.nextUrl

  // 認証不要なパスはスキップ
  if (publicPaths.some((path) => pathname.startsWith(path))) {
    return NextResponse.next()
  }

  // 未認証の場合はログインページへリダイレクト
  if (!req.auth) {
    const signInUrl = new URL('/auth/signin', req.url)
    signInUrl.searchParams.set('callbackUrl', pathname)
    return NextResponse.redirect(signInUrl)
  }

  // Adminページは管理者のみアクセス可能
  if (pathname.startsWith('/admin') && req.auth.user.role !== 'ADMIN') {
    return NextResponse.redirect(new URL('/', req.url))
  }

  return NextResponse.next()
})

export const config = {
  matcher: [
    '/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
  ],
}

Middleware での制御は多層防御の一環として重要です。Server Actions での認可チェックと併せて、よりセキュアなアプリになります。

セキュリティ考慮事項

今回の実装でセキュリティ面で気をつけた点をいくつか紹介します。

パスワードのハッシュ化は bcryptjs を使用し、cost factor は 10 に設定しました。これは現在のハードウェア性能において適切なバランスと考えています。JWT トークンの署名には十分に長い AUTH_SECRET を使用し、本番環境では必ず環境変数で管理するようにしてください。

また、認可処理については多層防御を意識しました。Middlewareでのルートレベル制御、Server Actionsでの操作レベル制御、そしてデータベースクエリでのユーザースコープ制御を組み合わせることで、複数の段階でセキュリティを確保しています。

CSRF 対策については、Server Actions が自動的にCSRFトークンで保護されるため、特別な実装は不要でした。これもNext.jsの最新機能を使うメリットの一つですね。

まとめ

NextAuth.js v5 を使った認証認可の実装は、思った以上にスムーズに進められました。特にJWTベースのセッション管理やServer Actionsとの親和性の高さは、開発体験を大きく向上させてくれます。

今回実装した機能は基本的なものですが、実際のプロダクションでも十分に使えるレベルの認証認可システムになっていると思います。ロールベースの権限制御により、個人向けアプリからチーム向けアプリまで幅広く対応できます。

NextAuth.js v5 はまだベータ版ですが、安定版がリリースされれば、さらに多くのプロジェクトで採用されそうな予感がします。認証認可の実装で悩んでいる方は、ぜひ試してみてください!

https://authjs.dev/

株式会社StellarCreate | Tech blog📚

Discussion