🔐

Auth.js JWT Auth

2024/12/07に公開

Next.js Auth.js JWT Demo

このプロジェクトは、Next.jsとAuth.jsを使用したJWT認証のデモアプリケーションです。

https://www.youtube.com/watch?v=6pUU8G9VkUY

https://authjs.dev/getting-started/installation

What is Auth.js?

Auth.js is a runtime agnostic library based on standard Web APIs that integrates deeply with multiple modern JavaScript frameworks to provide an authentication experience that’s simple to get started with, easy to extend, and always private and secure!

This documentation covers next-auth@5.0.0-beta and later and all other frameworks under the @auth/* namespace. Documentation for next-auth@4.x.y can still be found at next-auth.js.org.

Select your framework of choice to get started, or view the example application deployment or repository with the buttons below.

Auth.jsとは?
Auth.jsは標準的なWeb APIをベースにしたランタイムに依存しないライブラリで、複数のモダンなJavaScriptフレームワークと深く統合し、簡単に始められ、簡単に拡張でき、常にプライベートでセキュアな認証体験を提供します!

このドキュメントは、next-auth@5.0.0-beta 以降と、@auth/* 名前空間配下の他のすべてのフレームワークを対象としています。next-auth@4.x.y のドキュメントは、next-auth.js.org にあります。

ご希望のフレームワークを選択して始めるか、下のボタンでアプリケーションのデプロイ例やリポジトリをご覧ください。

デモアプリのリンク
完成品はこちらにあります。

認証フローの概要

1. ログインフロー

  1. ユーザーが /login ページにアクセス
  2. メールアドレスとパスワードを入力
  3. フォーム送信時に signIn() 関数が呼び出される
  4. Auth.jsの内部処理:
    • authorize() 関数でクレデンシャルを検証
    • 認証成功時にJWTトークンを生成
    • セッションにユーザー情報とトークンを保存
  5. ダッシュボードページ(/dashboard)にリダイレクト

2. 認証状態の管理

  • JWTトークンはブラウザのクッキーに保存
  • useSession() フックで現在の認証状態を取得
  • セッション情報には以下が含まれる:
    {
      user: {
        email: string;
        token: string;
      }
    }
    

3. 保護されたルート

  • useSession() フックを使用して認証状態をチェック
  • 未認証ユーザーは自動的にログインページにリダイレクト
  • 例: /dashboard は認証が必要なページ

4. ログアウトフロー

  1. ユーザーがログアウトボタンをクリック
  2. signOut() 関数が呼び出される
  3. Auth.jsの内部処理:
    • セッションを破棄
    • JWTトークンを削除
  4. ログインページにリダイレクト

実装の詳細

NextAuth設定 (/app/api/auth/[...nextauth]/route.ts)

export const authOptions: NextAuthOptions = {
  providers: [CredentialsProvider],
  session: { strategy: "jwt" },
  callbacks: {
    async jwt({ token, user }) {
      // JWTトークンにユーザー情報を追加
    },
    async session({ session, token }) {
      // セッションにトークン情報を追加
    }
  }
}

認証状態の確認例 (/app/dashboard/page.tsx)

const { data: session, status } = useSession();
useEffect(() => {
  if (status === 'unauthenticated') {
    router.push('/login');
  }
}, [status, router]);

セキュリティ考慮事項

本番環境での実装時の注意点

  1. 環境変数の管理

    • NEXTAUTH_SECRET: 安全な乱数を使用
    • NEXTAUTH_URL: 正しい本番URLを設定
  2. 認証ロジック

    • 本デモではダミー認証を使用
    • 実際の実装では:
      • パスワードのハッシュ化
      • データベースでのユーザー検証
      • レート制限の実装
  3. JWTセキュリティ

    • トークンの有効期限設定
    • 適切な署名アルゴリズムの使用
    • トークンの安全な保存

テスト用アカウント

  • Email: test@example.com
  • Password: password

技術スタック

  • Next.js 14
  • Auth.js (NextAuth.js)
  • TypeScript
  • Tailwind CSS

デモアプリを作成する

Next.jsのプロジェクトを作成して、Auth.jsを追加して設定を行う。

https://authjs.dev/getting-started/installation

bun create next-app

add package:

Start by installing the appropriate package for your framework.
フレームワークに適したパッケージをインストールすることから始める。

bun add next-auth@beta

Setup Environment
The only environment variable that is mandatory is the AUTH_SECRET. This is a random value used by the library to encrypt tokens and email verification hashes. (See Deployment to learn more). You can generate one via the official Auth.js CLI running:

環境変数の設定
必須の環境変数はAUTH_SECRETのみである。これはライブラリがトークンと電子メール検証ハッシュを暗号化するために使用するランダムな値です。(詳しくはデプロイメントを参照)。公式のAuth.js CLIで生成できます:

npx auth secret

.env.localの設定をする。先ほどのコマンドを打つと自動生成されるようだ。

AUTH_SECRET="2m9FtSKVe5r3Wo1ce9o+6TWmTPWClq+3fdK4mQolI+Y=" # Added by `npx auth`. Read more: https://cli.authjs.dev
NEXTAUTH_URL=http://localhost:3000
NEXTAUTH_SECRET="2m9FtSKVe5r3Wo1ce9o+6TWmTPWClq+3fdK4mQolI+Y="

[API Routes[(https://nextjs.org/docs/pages/building-your-application/routing/api-routes)を作成する。

API ルートは、Next.js を使用してパブリック APIを構築するためのソリューションを提供します。

フォルダー内のすべてのファイルはpages/apiにマップされ/api/*、 ではなく API エンドポイントとして扱われますpage。これらはサーバー側のみのバンドルであり、クライアント側のバンドル サイズは増加しません。

├── auth
│   └── [...nextauth]
│       └── route.ts
└── login
    └── route.ts

ダミー認証のロジックを実装する。

auth/[...nextauth]/route.ts
import NextAuth from "next-auth";
import CredentialsProvider from "next-auth/providers/credentials";
import { NextAuthOptions } from "next-auth";

export const authOptions: NextAuthOptions = {
  providers: [
    CredentialsProvider({
      name: "Credentials",
      credentials: {
        email: { label: "Email", type: "email" },
        password: { label: "Password", type: "password" }
      },
      async authorize(credentials) {
        if (!credentials?.email || !credentials?.password) return null;
        
        // ダミー認証のロジックを直接ここで実装
        if (credentials.email === "test@example.com" && credentials.password === "password") {
          return {
            id: "1",
            email: credentials.email,
            token: "dummy-jwt-token",
          };
        }
        return null;
      },
    }),
  ],
  session: {
    strategy: "jwt",
  },
  pages: {
    signIn: "/login",
  },
  callbacks: {
    async jwt({ token, user }) {
      if (user) {
        token.email = user.email;
        token.token = user.token;
      }
      return token;
    },
    async session({ session, token }) {
      if (token) {
        session.user = {
          email: token.email,
          token: token.token,
        };
      }
      return session;
    },
  },
  debug: true,
};

const handler = NextAuth(authOptions);
export { handler as GET, handler as POST };

ダミーの認証を検証するコード。

api/login/route.ts
import { NextResponse } from "next/server";
import jwt from "jsonwebtoken";

const JWT_SECRET = process.env.NEXTAUTH_SECRET || "your-secret-key";

export async function POST(request: Request) {
  try {
    const body = await request.json();
    const { email, password } = body;

    console.log('Login attempt:', { email, password }); // デバッグ用ログ

    // ダミー認証(実際のアプリケーションではデータベースで検証する)
    if (email === "test@example.com" && password === "password") {
      const token = jwt.sign(
        {
          email,
          id: 1,
        },
        JWT_SECRET,
        { expiresIn: "1d" }
      );

      console.log('Login successful:', { email, token }); // デバッグ用ログ

      return NextResponse.json({
        email,
        token,
      });
    }

    console.log('Login failed:', { email }); // デバッグ用ログ

    return NextResponse.json(
      { error: "Invalid credentials" },
      { status: 401 }
    );
  } catch (error) {
    console.error('Login error:', error); // デバッグ用ログ
    return NextResponse.json(
      { error: "Internal server error" },
      { status: 500 }
    );
  }
}

ログイン後のページを作成する。認証に成功するとこちらのページにリダイレクトします。

dashboard/page.tsx
'use client';

import { useSession, signOut } from 'next-auth/react';
import { useRouter } from 'next/navigation';
import { useEffect } from 'react';

export default function DashboardPage() {
  const { data: session, status } = useSession();
  const router = useRouter();

  useEffect(() => {
    if (status === 'unauthenticated') {
      router.push('/login');
    }
  }, [status, router]);

  const handleSignOut = async () => {
    await signOut({
      redirect: true,
      callbackUrl: '/login'
    });
  };

  if (status === 'loading') {
    return (
      <div className="min-h-screen flex items-center justify-center">
        <div className="text-xl">Loading...</div>
      </div>
    );
  }

  return (
    <div className="min-h-screen bg-gray-50 p-8">
      <div className="max-w-4xl mx-auto">
        <div className="bg-white shadow rounded-lg p-6">
          <div className="flex justify-between items-center mb-6">
            <h1 className="text-2xl font-bold text-gray-900">Dashboard</h1>
            <button
              onClick={handleSignOut}
              className="px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700"
            >
              Sign out
            </button>
          </div>
          <div className="space-y-4">
            <p className="text-gray-600">
              Welcome, {session?.user?.email}!
            </p>
            <div className="bg-gray-50 p-4 rounded">
              <h2 className="font-semibold text-gray-700">Your JWT Token:</h2>
              <p className="mt-2 text-sm text-gray-500 break-all">
                {session?.user?.token || 'No token available'}
              </p>
            </div>
          </div>
        </div>
      </div>
    </div>
  );
}

ログインページを作成する。こちらでテスト用のメールアドレスとパスワードを入力してみてください。

login/page.tsx
'use client';

import { signIn, useSession } from 'next-auth/react';
import { useRouter } from 'next/navigation';
import { FormEvent, useState, useEffect } from 'react';

export default function LoginPage() {
  const router = useRouter();
  const [error, setError] = useState('');
  const { status } = useSession();

  useEffect(() => {
    if (status === 'authenticated') {
      router.push('/dashboard');
    }
  }, [status, router]);

  const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    const formData = new FormData(e.currentTarget);
    const email = formData.get('email') as string;
    const password = formData.get('password') as string;

    try {
      const result = await signIn('credentials', {
        email,
        password,
        redirect: false,
      });

      if (result?.error) {
        setError('Invalid credentials');
      } else {
        router.push('/dashboard');
      }
    } catch (error) {
      setError('An error occurred');
    }
  };

  if (status === 'loading') {
    return (
      <div className="min-h-screen flex items-center justify-center">
        <div className="text-xl">Loading...</div>
      </div>
    );
  }

  return (
    <div className="min-h-screen flex items-center justify-center bg-gray-50">
      <div className="max-w-md w-full space-y-8 p-8 bg-white rounded-lg shadow">
        <div>
          <h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
            Sign in to your account
          </h2>
        </div>
        <form className="mt-8 space-y-6" onSubmit={handleSubmit}>
          <div className="rounded-md shadow-sm -space-y-px">
            <div>
              <label htmlFor="email" className="sr-only">
                Email address
              </label>
              <input
                id="email"
                name="email"
                type="email"
                required
                className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-t-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
                placeholder="Email address"
              />
            </div>
            <div>
              <label htmlFor="password" className="sr-only">
                Password
              </label>
              <input
                id="password"
                name="password"
                type="password"
                required
                className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-b-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
                placeholder="Password"
              />
            </div>
          </div>

          {error && (
            <div className="text-red-500 text-sm text-center">{error}</div>
          )}

          <div>
            <button
              type="submit"
              className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
            >
              Sign in
            </button>
          </div>
        </form>
      </div>
    </div>
  );
}

ブラウザを開くと最初にこちらのページが表示されるのだが、ログインしていればログイン後のページへログインしてなければログインページへリダイレクトするようになっている。

'use client';

import { useSession } from 'next-auth/react';
import { useRouter } from 'next/navigation';
import { useEffect } from 'react';

export default function Home() {
  const { data: session, status } = useSession();
  const router = useRouter();

  useEffect(() => {
    if (status === 'authenticated') {
      router.push('/dashboard');
    } else if (status === 'unauthenticated') {
      router.push('/login');
    }
  }, [status, router]);

  // ローディング中の表示
  if (status === 'loading') {
    return (
      <div className="min-h-screen flex items-center justify-center">
        <div className="text-xl">Loading...</div>
      </div>
    );
  }

  return null;
}

セッションプロバイダーのファイルを作成してこれでデモアプリは動かせると思います。

'use client';

import { SessionProvider } from 'next-auth/react';

export function Providers({ children }: { children: React.ReactNode }) {
  return <SessionProvider>{children}</SessionProvider>;
}

typeディレクトリにデータ型の定義も必要だった。こちらも作成。

import NextAuth from "next-auth"
import { JWT } from "next-auth/jwt"

declare module "next-auth" {
  interface Session {
    user: {
      email?: string | null
      token?: string
    }
  }

  interface User {
    email: string
    token: string
  }
}

declare module "next-auth/jwt" {
  interface JWT {
    email?: string
    token?: string
  }
}

感想

以前書いた記事では、middleware.tsを作成してリダイレクトの処理を実装したが、auth.jsを使用することで認証状態によってリダイレクトするロジックを作ることを学べた。流行っている雰囲気があるのだが、現場によっては使っていないところもあるようで、まずは標準機能だけど、認証のロジックを作ることを学んだほうが良いと思った。

Discussion