【Nuxt.js】nuxt-auth-utilsで認証機能を実装する

JavaScript

こんにちは、かつコーチです。

Nuxtでアプリを作っていると、必ずぶつかるのが認証機能の実装です。

自前でCookieやセッションを設計してもいいのですが、OAuth連携やパスワードハッシュ化まで含めると、意外と実装コストが高くなります。

今回は、この面倒な部分を一気に肩代わりしてくれるnuxt-auth-utilsを使って、セッションベース認証を実装する手順を解説します。

前提として、Nuxt 3のサーバールート(server/api)とComposition APIの基本は理解している読者を想定しています。

nuxt-auth-utilsの導入

インストールとモジュール登録

まずはnuxiコマンドでモジュールを追加します。

npx nuxi@latest module add auth-utils

これでnuxt.config.tsnuxt-auth-utilsが自動的に登録されます。

手動で追加する場合は以下の形になります。

// nuxt.config.ts
export default defineNuxtConfig({
  modules: ["nuxt-auth-utils"],
})

セッションは暗号化されたsealed cookieとして保存されるため、.envNUXT_SESSION_PASSWORDという32文字以上のランダム文字列を設定する必要があります。

# .env
NUXT_SESSION_PASSWORD=this-is-a-very-long-random-string-32chars

この値が未設定だと開発サーバー起動時に自動生成されますが、本番環境では必ず自分で発行した値を.envに明示してください。

セッションの型を拡張する

nuxt-auth-utilsUserSessionという型でセッションの中身を管理します。

自分のアプリ用にユーザー情報の型を拡張しておくと、以降の開発でエディタの補完が効くようになります。

// types/auth.d.ts
declare module "#auth-utils" {
  interface User {
    id: number
    email: string
    name: string
  }

  interface UserSession {
    loggedInAt: Date
  }
}

export {}

セッションベース認証の実装フロー

ログインAPIを作る

server/api配下に、メールアドレスとパスワードを受け取ってセッションを発行するエンドポイントを作成します。

// server/api/login.post.ts
import { z } from "zod"

const bodySchema = z.object({
  email: z.string().email(),
  password: z.string().min(8),
})

export default defineEventHandler(async (event) => {
  const { email, password } = await readValidatedBody(event, bodySchema.parse)

  // 本来はDBからユーザーを検索する
  const user = await findUserByEmail(email)

  if (!user || !(await verifyPassword(user.passwordHash, password))) {
    throw createError({
      statusCode: 401,
      statusMessage: "メールアドレスかパスワードが間違っています",
    })
  }

  await setUserSession(event, {
    user: {
      id: user.id,
      email: user.email,
      name: user.name,
    },
    loggedInAt: new Date(),
  })

  return { ok: true }
})

setUserSessionを呼ぶと、暗号化されたセッションCookieがレスポンスに自動でセットされます。

パスワードのハッシュ化・検証には、nuxt-auth-utilsが提供するhashPasswordverifyPasswordを使うと、bcryptやargon2の実装を自分で書かずに済みます。

// server/utils/password.ts
export const hashPassword = (password: string) => hashPassword(password)
export const verifyPassword = (hash: string, password: string) => verifyPassword(hash, password)

フロント側でセッション状態を扱う

コンポーネント側ではuseUserSessionというcomposableで、ログイン状態とユーザー情報を取得できます。

<!-- app/components/UserMenu.vue -->
<script setup lang="ts">
const { loggedIn, user, clear } = useUserSession()

const handleLogout = async () => {
  await clear()
  await navigateTo("/login")
}
</script>

<template>
  <div v-if="loggedIn">
    <p>{{ user?.name }}さん、こんにちは</p>
    <button @click="handleLogout">ログアウト</button>
  </div>
  <NuxtLink v-else to="/login">ログイン</NuxtLink>
</template>

clear()を呼ぶだけでセッションCookieが破棄されるため、ログアウト処理を自前で書く必要がありません。

サーバー側でセッション必須のAPIを作る

ログイン中のユーザーしかアクセスできないAPIには、requireUserSessionを使います。

// server/api/me/orders.get.ts
export default defineEventHandler(async (event) => {
  const session = await requireUserSession(event)

  // session.user は必ず存在する状態として扱える
  return await getOrdersByUserId(session.user.id)
})

requireUserSessionは未ログインの場合に自動で401エラーを投げてくれるため、if (!session.user)のような分岐を毎回書かずに済みます。

よくあるつまずきポイント・エラー対処

ページ保護でloggedInの反映タイミングを間違える

私が最初につまずいたのが、ページ保護用のミドルウェアでloggedInの値が正しく反映されないケースでした。

❌ Before:グローバルミドルウェアでAPIを都度叩く

// middleware/auth.global.ts
export default defineNuxtRouteMiddleware(async (to) => {
  const { data } = await useFetch("/api/session")

  if (!data.value?.user && to.path !== "/login") {
    return navigateTo("/login")
  }
})

このように毎回/api/sessionを叩く実装にしていたところ、ページ遷移のたびに通信が発生してしまい、画面がちらつく上に無駄なリクエストが積み重なりました。

さらに、SSR時とクライアント時でレスポンスのタイミングがずれ、一瞬だけログイン画面が表示される不具合も発生しました。

✅ After:definePageMetaのinlineミドルウェアでloggedInを直接参照する

<!-- app/pages/mypage.vue -->
<script setup lang="ts">
definePageMeta({
  middleware() {
    const { loggedIn } = useUserSession()
    if (!loggedIn.value) {
      return navigateTo("/login")
    }
  },
})
</script>

nuxt-auth-utilsはセッション取得用のプラグインをenforce: "pre"でSSR時に先行実行する仕組みを持っています。

そのため、ページ側のミドルウェアが実行される時点ではloggedIn.valueがすでに正しい値になっており、追加のfetchなしで判定できます。

このエラーメッセージ自体は出ませんが、「ログイン済みなのに一瞬ログイン画面が見える」という現象に遭遇したら、まずミドルウェアの実装方法を疑ってください。

NUXT_SESSION_PASSWORD未設定でセッションが毎回リセットされる

もう一つよくあるのが、.envNUXT_SESSION_PASSWORDを設定せずに開発を続けてしまうケースです。

サーバーを再起動するたびに自動生成される値が変わるため、以前発行したセッションCookieが復号できなくなり、ログイン状態が突然消えます。

「さっきまでログインできていたのに、サーバーを再起動したらいきなりログアウトされた」という現象が起きたら、.envNUXT_SESSION_PASSWORDが固定されているか確認しましょう。

応用・一歩先の使い方

OAuthプロバイダーとの連携

nuxt-auth-utilsはGitHubやGoogleなどのOAuthプロバイダーも標準でサポートしています。

// server/routes/auth/github.get.ts
export default defineOAuthGitHubEventHandler({
  async onSuccess(event, { user, tokens }) {
    await setUserSession(event, {
      user: {
        id: user.id,
        email: user.email,
        name: user.name,
      },
      secure: {
        accessToken: tokens.access_token,
      },
      loggedInAt: new Date(),
    })

    return sendRedirect(event, "/")
  },
  onError(event, error) {
    console.error("GitHub OAuth error:", error)
    return sendRedirect(event, "/login?error=github")
  },
})

secureプロパティに入れたデータはクライアントに公開されず、サーバー側からのみ参照できるため、アクセストークンのようなセンシティブな情報を扱う際に便利です。

セッションの有効期限とロールベースの認可を組み合わせる

session-hooksを使うと、セッション取得のたびに追加のバリデーションを差し込めます。

// server/plugins/session.ts
export default defineNitroPlugin(() => {
  sessionHooks.hook("fetch", async (session, event) => {
    // 退会済みユーザーのセッションを強制的に無効化する
    if (session.user && (await isUserDeleted(session.user.id))) {
      await clearUserSession(event)
    }
  })
})

こうしておけば、DB上でユーザーが削除・無効化された瞬間に、既存のセッションも自動で失効させられます。

管理者専用ページの認可には、requireUserSessionで取得したsession.user.roleを見て分岐する形が定石です。

まとめ

この記事のポイント

  • nuxt-auth-utilsはsealed cookieによるセッション管理・OAuth・パスワードハッシュ化をまとめて提供する
  • ログインはsetUserSession、ログアウトはclear()、API保護はrequireUserSessionで実装する
  • ページ保護はdefinePageMetaのinlineミドルウェアでloggedInを直接参照するのがベストプラクティス
  • NUXT_SESSION_PASSWORDは本番運用前に固定値として.envに設定する
  • sessionHooksを使えばロールベースの認可やセッション失効も柔軟に組み込める

次に読むべき記事

→ 次の記事:Vitestでコンポーネントをテストする入門

タグ: Nuxt.js, 上級者向け, 認証

タイトルとURLをコピーしました