Nuxt Nation カンファレンス開催!11月12日〜13日、ご参加ください。

nuxt-auth-utils

SSR対応のミニマリストNuxt認証モジュール。

Nuxt Auth Utils

npm versionnpm downloadsLicenseNuxt

安全でシールドされたクッキーセッションを使用して、Nuxtアプリケーションに認証を追加します。

機能

UnJSからの依存関係はわずかであり、複数のJS環境(Node、Deno、Workers)で動作し、TypeScriptで完全に型付けされています。

要件

このモジュールは、サーバーAPIルート(nuxt build)を使用するため、Nuxtサーバーで実行されている場合のみ動作します。

つまり、nuxt generateではこのモジュールを使用できません。

ただし、ハイブリッドレンダリングを使用してアプリケーションのページをプリレンダリングするか、サーバーサイドレンダリングを完全に無効にすることができます。

クイックセットアップ

  1. Nuxtプロジェクトにnuxt-auth-utilsを追加します。
npx nuxi@latest module add auth-utils
  1. .envファイルに、少なくとも32文字のNUXT_SESSION_PASSWORD環境変数を追加します。
# .env
NUXT_SESSION_PASSWORD=password-with-at-least-32-characters

NUXT_SESSION_PASSWORDが設定されていない場合、Nuxtを開発モードで初めて実行すると、Nuxt Auth Utilsによって自動的に生成されます。

  1. これで完了です!Nuxtアプリに認証を追加できます ✨

Vueコンポーザブル

Nuxt Auth Utilsは、現在のユーザーセッションを取得するためのプラグインを自動的に追加し、Vueコンポーネントからアクセスできるようにします。

ユーザーセッション

<script setup>
const { loggedIn, user, session, fetch, clear } = useUserSession()
</script>

<template>
  <div v-if="loggedIn">
    <h1>Welcome {{ user.login }}!</h1>
    <p>Logged in since {{ session.loggedInAt }}</p>
    <button @click="clear">Logout</button>
  </div>
  <div v-else>
    <h1>Not logged in</h1>
    <a href="/auth/github">Login with GitHub</a>
  </div>
</template>

TypeScriptシグネチャ

interface UserSessionComposable {
  /**
   * Computed indicating if the auth session is ready
   */
  ready: ComputedRef<boolean>
  /**
   * Computed indicating if the user is logged in.
   */
  loggedIn: ComputedRef<boolean>
  /**
   * The user object if logged in, null otherwise.
   */
  user: ComputedRef<User | null>
  /**
   * The session object.
   */
  session: Ref<UserSession>
  /**
   * Fetch the user session from the server.
   */
  fetch: () => Promise<void>
  /**
   * Clear the user session and remove the session cookie.
   */
  clear: () => Promise<void>
}

!重要 Nuxt Auth Utilsはセッション管理に/api/_auth/sessionルートを使用します。APIルートミドルウェアがこのパスと干渉しないようにしてください。

サーバーユーティリティ

次のヘルパーは、server/ディレクトリに自動的にインポートされます。

セッション管理

// Set a user session, note that this data is encrypted in the cookie but can be decrypted with an API call
// Only store the data that allow you to recognize a user, but do not store sensitive data
// Merges new data with existing data using unjs/defu library
await setUserSession(event, {
  // User data
  user: {
    login: 'atinux'
  },
  // Private data accessible only on server/ routes
  secure: {
    apiToken: '1234567890'
  },
  // Any extra fields for the session data
  loggedInAt: new Date()
})

// Replace a user session. Same behaviour as setUserSession, except it does not merge data with existing data
await replaceUserSession(event, data)

// Get the current user session
const session = await getUserSession(event)

// Clear the current user session
await clearUserSession(event)

// Require a user session (send back 401 if no `user` key in session)
const session = await requireUserSession(event)

プロジェクトに型定義ファイル(例:auth.d.ts)を作成してUserSession型を拡張することにより、ユーザーセッションの型を定義できます。

// auth.d.ts
declare module '#auth-utils' {
  interface User {
    // Add your own fields
  }

  interface UserSession {
    // Add your own fields
  }

  interface SecureSessionData {
    // Add your own fields
  }
}

export {}

!重要 セッションデータを暗号化してクッキーに保存するため、4096バイトのクッキーサイズ制限があります。重要な情報のみを保存してください。

OAuthイベントハンドラー

すべてのハンドラーは自動的にインポートでき、サーバールートまたはAPIルートで使用できます。

パターンはdefineOAuth<Provider>EventHandler({ onSuccess, config?, onError? })です。例:defineOAuthGitHubEventHandler

このヘルパーは、プロバイダーの承認ページに自動的にリダイレクトし、結果に応じてonSuccessまたはonErrorを呼び出すイベントハンドラーを返します。

configは、nuxt.config.tsruntimeConfigから直接定義できます。

export default defineNuxtConfig({
  runtimeConfig: {
    oauth: {
      // provider in lowercase (github, google, etc.)
      <provider>: {
        clientId: '...',
        clientSecret: '...'
      }
    }
  }
})

環境変数を使用して設定することもできます。

  • NUXT_OAUTH_<PROVIDER>_CLIENT_ID
  • NUXT_OAUTH_<PROVIDER>_CLIENT_SECRET

プロバイダーは大文字(GITHUB、GOOGLEなど)です。

サポートされているOAuthプロバイダー

  • Auth0
  • AWS Cognito
  • Battle.net
  • Discord
  • Dropbox
  • Facebook
  • GitHub
  • GitLab
  • Google
  • Instagram
  • Keycloak
  • Linear
  • LinkedIn
  • Microsoft
  • PayPal
  • Polar
  • Spotify
  • Steam
  • TikTok
  • Twitch
  • VK
  • X (Twitter)
  • XSUAA
  • Yandex

src/runtime/server/lib/oauth/に新しいファイルを作成することで、お気に入りのプロバイダーを追加できます。

例:~/server/routes/auth/github.get.ts

export default defineOAuthGitHubEventHandler({
  config: {
    emailRequired: true
  },
  async onSuccess(event, { user, tokens }) {
    await setUserSession(event, {
      user: {
        githubId: user.id
      }
    })
    return sendRedirect(event, '/')
  },
  // Optional, will return a json error and 401 status code by default
  onError(event, error) {
    console.error('GitHub OAuth error:', error)
    return sendRedirect(event, '/')
  },
})

OAuthアプリの設定でコールバックURLを<your-domain>/auth/githubとして設定してください。

本番環境でリダイレクトURLが一致しない場合、モジュールは正しいリダイレクトURLを推測できません。NUXT_OAUTH_<PROVIDER>_REDIRECT_URL環境変数を設定して、デフォルトのURLを上書きできます。

パスワードハッシング

Nuxt Auth Utilsは、多くのJSランタイムでサポートされているscryptを使用してパスワードをハッシュおよび検証するためのhashPasswordverifyPasswordなどのパスワードハッシングユーティリティを提供します。

const hashedPassword = await hashPassword('user_password')

if (await verifyPassword(hashedPassword, 'user_password')) {
  // Password is valid
}

nuxt.config.tsでscryptオプションを設定できます。

export default defineNuxtConfig({
  modules: ['nuxt-auth-utils'],
  auth: {
    hash: {
      scrypt: {
        // See https://github.com/adonisjs/hash/blob/94637029cd526783ac0a763ec581306d98db2036/src/types.ts#L144
      }
    }
  }
})

WebAuthn(パスキー)

WebAuthn(Web認証)は、公開鍵暗号化を使用してパスワードをパスキーに置き換えることでセキュリティを強化するWeb標準です。ユーザーは、生体認証データ(指紋や顔認識など)または物理デバイス(USBキーなど)を使用して認証できるため、フィッシングやパスワード侵害のリスクを軽減できます。このアプローチは、主要なブラウザとプラットフォームでサポートされており、より安全でユーザーフレンドリーな認証方法を提供します。

WebAuthnを有効にするには、以下の手順が必要です。

  1. ピア依存関係をインストールします。
npx nypm i @simplewebauthn/server@11 @simplewebauthn/browser@11
  1. nuxt.config.tsで有効にします。
export default defineNuxtConfig({
  auth: {
    webAuthn: true
  }
})

この例では、資格情報を登録および認証するための非常に基本的な手順を実装します。

完全なコードはplaygroundにあります。この例では、次の最小限のテーブルを持つSQLiteデータベースを使用しています。

CREATE TABLE users (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  email TEXT NOT NULL
);

CREATE TABLE IF NOT EXISTS credentials (
  userId INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE ON UPDATE CASCADE,
  id TEXT UNIQUE NOT NULL,
  publicKey TEXT NOT NULL,
  counter INTEGER NOT NULL,
  backedUp INTEGER NOT NULL,
  transports TEXT NOT NULL,
  PRIMARY KEY ("userId", "id")
);
  • usersテーブルには、ユーザー名やメールアドレスなどの固有の識別子(ここではメールアドレスを使用)が必要です。新しい資格情報を生成する際には、この識別子が必須であり、ユーザーのデバイス、パスワードマネージャー、または認証装置にパスキーと共に保存されます。
  • credentialsテーブルには、以下が保存されます。
    • usersテーブルからのuserId
    • 資格情報のid(一意のインデックスとして)。
    • 資格情報のpublicKey
    • counter。資格情報が使用されるたびに、カウンターが増加します。この値を使用して、追加のセキュリティチェックを実行できます。counterの詳細については、こちらを参照してください。この例では、カウンターは使用しません。ただし、新しい値でデータベース内のカウンターを更新する必要があります。
    • backedUpフラグ。通常、資格情報は生成デバイスに保存されます。パスワードマネージャーや認証装置を使用する場合、資格情報は複数のデバイスで使用できるため「バックアップ」されます。このセクションで詳細を確認してください。
    • 資格情報のtransports。クライアントとの資格情報の通信方法を示す文字列の配列です。ユーザーが資格情報を使用するための正しいUIを表示するために使用されます。このセクションで詳細を確認してください。

次のコードには実際のデータベースクエリは含まれていませんが、実行する一般的な手順を示しています。完全な例はplaygroundにあります:登録認証、およびデータベース設定

// server/api/webauthn/register.post.ts
import { z } from 'zod'
export default defineWebAuthnRegisterEventHandler({
  // optional
  validateUser: z.object({
    // we want the userName to be a valid email
    userName: z.string().email() 
  }).parse,
  async onSuccess(event, { credential, user }) {
    // The credential creation has been successful
    // We need to create a user if it does not exist
    const db = useDatabase()

    // Get the user from the database
    let dbUser = await db.sql`...`
    if (!dbUser) {
      // Store new user in database & its credentials
      dbUser = await db.sql`...`
    }

    // we now need to store the credential in our database and link it to the user
    await db.sql`...`

    // Set the user session
    await setUserSession(event, {
      user: {
        id: dbUser.id
      },
      loggedInAt: Date.now(),
    })
  },
})
// server/api/webauthn/authenticate.post.ts
export default defineWebAuthnAuthenticateEventHandler({
  // Optionally, we can prefetch the credentials if the user gives their userName during login
  async allowCredentials(event, userName) {
    const credentials = await useDatabase().sql`...`
    // If no credentials are found, the authentication cannot be completed
    if (!credentials.length)
      throw createError({ statusCode: 400, message: 'User not found' })

    // If user is found, only allow credentials that are registered
    // The browser will automatically try to use the credential that it knows about
    // Skipping the step for the user to select a credential for a better user experience
    return credentials
    // example: [{ id: '...' }]
  },
  async getCredential(event, credentialId) {
    // Look for the credential in our database
    const credential = await useDatabase().sql`...`

    // If the credential is not found, there is no account to log in to
    if (!credential)
      throw createError({ statusCode: 400, message: 'Credential not found' })

    return credential
  },
  async onSuccess(event, { credential, authenticationInfo }) {
    // The credential authentication has been successful
    // We can look it up in our database and get the corresponding user
    const db = useDatabase()
    const user = await db.sql`...`

    // Update the counter in the database (authenticationInfo.newCounter)
    await db.sql`...`

    // Set the user session
    await setUserSession(event, {
      user: {
        id: user.id
      },
      loggedInAt: Date.now(),
    })
  },
})

!重要 Webauthnは、リプレイ攻撃を防ぐためにチャレンジを使用します。デフォルトでは、このモジュールはこの機能を使用しません。チャレンジを使用する場合は(**強く推奨します**)、storeChallenge関数とgetChallenge関数が提供されます。試行IDが作成され、各認証リクエストと共に送信されます。このIDを使用して、次の例のようにデータベースまたはKVストアにチャレンジを保存できます。

export default defineWebAuthnAuthenticateEventHandler({
  async storeChallenge(event, challenge, attemptId) {
    // Store the challenge in a KV store or DB
    await useStorage().setItem(`attempt:${attemptId}`, challenge)
  },
  async getChallenge(event, attemptId) {
    const challenge = await useStorage().getItem(`attempt:${attemptId}`)

    // Make sure to always remove the attempt because they are single use only!
    await useStorage().removeItem(`attempt:${attemptId}`)

    if (!challenge)
      throw createError({ statusCode: 400, message: 'Challenge expired' })

    return challenge
  },
  async onSuccess(event, { authenticator }) {
    // ...
  },
})

フロントエンドでは、次のようになります。

<script setup lang="ts">
const { register, authenticate } = useWebAuthn({
  registerEndpoint: '/api/webauthn/register', // Default
  authenticateEndpoint: '/api/webauthn/authenticate', // Default
})
const { fetch: fetchUserSession } = useUserSession()

const userName = ref('')
async function signUp() {
  await register({ userName: userName.value })
    .then(fetchUserSession) // refetch the user session
}

async function signIn() {
  await authenticate(userName.value)
    .then(fetchUserSession) // refetch the user session
}
</script>

<template>
  <form @submit.prevent="signUp">
    <input v-model="userName" placeholder="Email or username" />
    <button type="submit">Sign up</button>
  </form>
  <form @submit.prevent="signIn">
    <input v-model="userName" placeholder="Email or username" />
    <button type="submit">Sign in</button>
  </form>
</template>

完全な例については、WebAuthnModal.vueを参照してください。

デモ

Drizzle ORMNuxtHubを使用してhttps://todo-passkeys.nuxt.devで完全なデモを確認できます。

デモのソースコードはhttps://github.com/atinux/todo-passkeysで入手できます。

セッションの拡張

フックを利用して、独自のデータでセッションデータを拡張したり、ユーザーがセッションをクリアしたときにログを記録したりできます。

// server/plugins/session.ts
export default defineNitroPlugin(() => {
  // Called when the session is fetched during SSR for the Vue composable (/api/_auth/session)
  // Or when we call useUserSession().fetch()
  sessionHooks.hook('fetch', async (session, event) => {
    // extend User Session by calling your database
    // or
    // throw createError({ ... }) if session is invalid for example
  })

  // Called when we call useUserSession().clear() or clearUserSession(event)
  sessionHooks.hook('clear', async (session, event) => {
    // Log that user logged out
  })
})

サーバーサイドレンダリング

クライアントとサーバーの両方から認証済みリクエストを行うことができます。ただし、useFetch()を使用していない場合は、SSR中に認証済みリクエストを行うにはuseRequestFetch()を使用する必要があります。

<script setup lang="ts">
// When using useAsyncData
const { data } = await useAsyncData('team', () => useRequestFetch()('/api/protected-endpoint'))

// useFetch will automatically use useRequestFetch during SSR
const { data } = await useFetch('/api/protected-endpoint')
</script>

Nuxtの$fetchに資格情報を追加するための未解決の問題があります。

ハイブリッドレンダリング

ページをプリレンダリングまたはキャッシュするためにNuxt routeRulesを使用する場合、Nuxt Auth Utilsはプリレンダリング中にユーザーセッションを取得せず、代わりにクライアント側(ハイドレーション後)で取得します。

これは、ユーザーセッションがセキュアなクッキーに保存されており、プリレンダリング中はアクセスできないためです。

つまり、プリレンダリング中にユーザーセッションに依存すべきではありません。

<AuthState>コンポーネント

<AuthState>コンポーネントを使用して、レンダリングモードを気にせずに、コンポーネントで認証関連のデータを安全に表示できます。

ヘッダーのログインボタンが一般的なユースケースです。

<template>
  <header>
    <AuthState v-slot="{ loggedIn, clear }">
      <button v-if="loggedIn" @click="clear">Logout</button>
      <NuxtLink v-else to="/login">Login</NuxtLink>
    </AuthState>
  </header>
</template>

ページがキャッシュまたはプリレンダリングされている場合、クライアント側でユーザーセッションが取得されるまで何もレンダリングされません。

placeholderスロットを使用して、サーバー側と、プリレンダリングされたページのユーザーセッションがクライアント側で取得されている間にプレースホルダーを表示できます。

<template>
  <header>
    <AuthState>
      <template #default="{ loggedIn, clear }">
        <button v-if="loggedIn" @click="clear">Logout</button>
        <NuxtLink v-else to="/login">Login</NuxtLink>
      </template>
      <template #placeholder>
        <button disabled>Loading...</button>
      </template>
    </AuthState>
  </header>
</template>

routeRulesを使用してルートをキャッシュする場合は、ユーザーセッションのクライアント側取得をサポートするために、Nitro >= 2.9.7を使用してください。

設定

h3 useSessionにデフォルトオプションを提供するために、runtimeConfig.sessionを使用しています。

nuxt.config.tsでオプションを上書きできます。

export default defineNuxtConfig({
  modules: ['nuxt-auth-utils'],
  runtimeConfig: {
    session: {
      maxAge: 60 * 60 * 24 * 7 // 1 week
    }
  }
})

デフォルトは次のとおりです。

{
  name: 'nuxt-session',
  password: process.env.NUXT_SESSION_PASSWORD || '',
  cookie: {
    sameSite: 'lax'
  }
}

setUserSession関数とreplaceUserSession関数の第3引数として渡すことで、セッション設定を上書きすることもできます。

await setUserSession(event, { ... } , {
  maxAge: 60 * 60 * 24 * 7 // 1 week
})

すべてのオプションについては、SessionConfigを参照してください。

その他

  • nuxt-authorization:Nuxtアプリ内で権限を管理するための承認モジュールで、nuxt-auth-utilsと互換性があります。

開発

# Install dependencies
npm install

# Generate type stubs
npm run dev:prepare

# Develop with the playground
npm run dev

# Build the playground
npm run dev:build

# Run ESLint
npm run lint

# Run Vitest
npm run test
npm run test:watch

# Release new version
npm run release