mcpField
N
nextjs-authentication

nextjs-authentication

github.com/josiahsiegelセキュリティ5974 回インストール19 週間前に更新
リポジトリ

nextjs-authentication について

Next.js アプリに Auth.js v5 による認証と認可を導入するためのスキルです。セッション、サインイン、ルート保護、ロールや権限によるアクセス制御を扱います。

主な機能

  1. 01Auth.js v5 の設定ガイド
  2. 02サーバーとクライアントのセッション参照
  3. 03OAuth と認証情報ログインのパターン
  4. 04ルートとアクションの保護
  5. 05ロール・権限ベースのアクセス制御

活用シーン

  1. 01GitHub や Google のサインイン追加
  2. 02ページや API ルートの保護
  3. 03管理機能へのアクセス前のロール確認

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/josiahsiegel/claude-plugin-marketplace --skill nextjs-authentication

インストール後、Claude Code で/nextjs-authenticationを入力して読み込まれたか確認します。

詳細

クイックリファレンス

Auth.js のエクスポート目的使用方法
authセッションを取得(サーバー)const session = await auth()
signInサインインを実行await signIn('github')
signOutサインアウトを実行await signOut()
handlersAPI ルートハンドラーexport const { GET, POST } = handlers
セッションへのアクセス場所コード
サーバーコンポーネントサーバーconst session = await auth()
クライアントコンポーネントクライアントconst { data: session } = useSession()
ミドルウェアエッジreq.auth
サーバーアクションサーバーconst session = await auth()
保護パターン場所方法
ルートレベルmiddleware.tsreq.auth を確認
ページレベルサーバーコンポーネントif (!session) redirect()
コンポーネントレベルクライアントuseSession() + ガード
アクションレベルサーバーアクションawait auth() で確認

このスキルを使う場面

認証と認可に使用します。

  • NextAuth.js(Auth.js v5)のセットアップ
  • OAuth プロバイダー(GitHub、Google など)の追加
  • 認証情報ベースのログインの実装
  • ミドルウェアによるルートの保護
  • ロールベースのアクセス制御

関連スキル:

  • ミドルウェアのパターンについては、nextjs-middleware を参照
  • サーバーアクションについては、nextjs-server-actions を参照
  • フォーム処理については、nextjs-server-actions を参照

Next.js 認証

NextAuth.js(Auth.js)

インストールとセットアップ
npm install next-auth@beta
// auth.ts
import NextAuth from 'next-auth';
import GitHub from 'next-auth/providers/github';
import Google from 'next-auth/providers/google';
import Credentials from 'next-auth/providers/credentials';
import { PrismaAdapter } from '@auth/prisma-adapter';
import { prisma } from '@/lib/prisma';
import bcrypt from 'bcryptjs';

export const { handlers, signIn, signOut, auth } = NextAuth({
  adapter: PrismaAdapter(prisma),
  providers: [
    GitHub({
      clientId: process.env.GITHUB_ID,
      clientSecret: process.env.GITHUB_SECRET,
    }),
    Google({
      clientId: process.env.GOOGLE_ID,
      clientSecret: process.env.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 isValid = await bcrypt.compare(
          credentials.password as string,
          user.password
        );

        if (!isValid) {
          return null;
        }

        return {
          id: user.id,
          email: user.email,
          name: user.name,
        };
      },
    }),
  ],
  session: {
    strategy: 'jwt',
  },
  pages: {
    signIn: '/login',
    error: '/auth/error',
  },
  callbacks: {
    async jwt({ token, user }) {
      if (user) {
        token.id = user.id;
        token.role = user.role;
      }
      return token;
    },
    async session({ session, token }) {
      if (session.user) {
        session.user.id = token.id as string;
        session.user.role = token.role as string;
      }
      return session;
    },
  },
});
ルートハンドラー
// app/api/auth/[...nextauth]/route.ts
import { handlers } from '@/auth';

export const { GET, POST } = handlers;
環境変数
# .env
AUTH_SECRET=your-secret-key-generate-with-openssl-rand-base64-32
GITHUB_ID=your-github-oauth-id
GITHUB_SECRET=your-github-oauth-secret
GOOGLE_ID=your-google-oauth-id
GOOGLE_SECRET=your-google-oauth-secret

セッション管理

サーバー側でのセッションアクセス
// app/dashboard/page.tsx
import { auth } from '@/auth';
import { redirect } from 'next/navigation';

export default async function DashboardPage() {
  const session = await auth();

  if (!session) {
    redirect('/login');
  }

  return (
    <div>
      <h1>Welcome, {session.user?.name}</h1>
      <p>Email: {session.user?.email}</p>
    </div>
  );
}
クライアント側でのセッションアクセス
// components/UserMenu.tsx
'use client';

import { useSession, signIn, signOut } from 'next-auth/react';

export function UserMenu() {
  const { data: session, status } = useSession();

  if (status === 'loading') {
    return <div>Loading...</div>;
  }

  if (!session) {
    return <button onClick={() => signIn()}>Sign In</button>;
  }

  return (
    <div>
      <span>{session.user?.name}</span>
      <button onClick={() => signOut()}>Sign Out</button>
    </div>
  );
}
セッションプロバイダー
// app/providers.tsx
'use client';

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

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

// app/layout.tsx
import { Providers } from './providers';

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html>
      <body>
        <Providers>{children}</Providers>
      </body>
    </html>
  );
}

保護されたルート

ミドルウェアによる保護
// middleware.ts
import { auth } from '@/auth';
import { NextResponse } from 'next/server';

const publicPaths = ['/', '/login', '/register', '/api/auth'];

export default auth((req) => {
  const { nextUrl } = req;
  const isLoggedIn = !!req.auth;

  const isPublicPath = publicPaths.some((path) =>
    nextUrl.pathname.startsWith(path)
  );

  if (!isPublicPath && !isLoggedIn) {
    const loginUrl = new URL('/login', nextUrl);
    loginUrl.searchParams.set('callbackUrl', nextUrl.pathname);
    return NextResponse.redirect(loginUrl);
  }

  // Role-based access
  if (nextUrl.pathname.startsWith('/admin')) {
    if (req.auth?.user?.role !== 'admin') {
      return NextResponse.redirect(new URL('/unauthorized', nextUrl));
    }
  }

  return NextResponse.next();
});

export const config = {
  matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
};
コンポーネントレベルの保護
// components/ProtectedContent.tsx
import { auth } from '@/auth';
import { redirect } from 'next/navigation';

export async function ProtectedContent({
  children,
}: {
  children: React.ReactNode;
}) {
  const session = await auth();

  if (!session) {
    redirect('/login');
  }

  return <>{children}</>;
}
クライアント側のルートガード
'use client';

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

export function AuthGuard({ children }: { children: React.ReactNode }) {
  const { status } = useSession();
  const router = useRouter();

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

  if (status === 'loading') {
    return <div>Loading...</div>;
  }

  if (status === 'unauthenticated') {
    return null;
  }

  return <>{children}</>;
}

ログインフォームと登録フォーム

ログインフォーム
// app/login/page.tsx
import { signIn } from '@/auth';
import { redirect } from 'next/navigation';

export default function LoginPage({
  searchParams,
}: {
  searchParams: Promise<{ callbackUrl?: string; error?: string }>;
}) {
  return (
    <div className="login-page">
      <h1>Sign In</h1>

      {searchParams.error && (
        <p className="error">Invalid credentials</p>
      )}

      <form
        action={async (formData) => {
          'use server';
          await signIn('credentials', {
            email: formData.get('email'),
            password: formData.get('password'),
            redirectTo: searchParams.callbackUrl || '/dashboard',
          });
        }}
      >
        <input name="email" type="email" placeholder="Email" required />
        <input name="password" type="password" placeholder="Password" required />
        <button type="submit">Sign In</button>
      </form>

      <div className="divider">or</div>

      <form
        action={async () => {
          'use server';
          await signIn('github', {
            redirectTo: searchParams.callbackUrl || '/dashboard',
          });
        }}
      >
        <button type="submit">Sign in with GitHub</button>
      </form>

      <form
        action={async () => {
          'use server';
          await signIn('google', {
            redirectTo: searchParams.callbackUrl || '/dashboard',
          });
        }}
      >
        <button type="submit">Sign in with Google</button>
      </form>
    </div>
  );
}
登録フォーム
// app/register/page.tsx
import { register } from './actions';

export default function RegisterPage() {
  return (
    <div className="register-page">
      <h1>Create Account</h1>

      <form action={register}>
        <input name="name" placeholder="Name" required />
        <input name="email" type="email" placeholder="Email" required />
        <input
          name="password"
          type="password"
          placeholder="Password"
          minLength={8}
          required
        />
        <input
          name="confirmPassword"
          type="password"
          placeholder="Confirm Password"
          required
        />
        <button type="submit">Register</button>
      </form>
    </div>
  );
}
// app/register/actions.ts
'use server';

import { redirect } from 'next/navigation';
import bcrypt from 'bcryptjs';
import { prisma } from '@/lib/prisma';
import { z } from 'zod';

const RegisterSchema = z
  .object({
    name: z.string().min(2),
    email: z.string().email(),
    password: z.string().min(8),
    confirmPassword: z.string(),
  })
  .refine((data) => data.password === data.confirmPassword, {
    message: 'Passwords do not match',
    path: ['confirmPassword'],
  });

export async function register(formData: FormData) {
  const validatedFields = RegisterSchema.safeParse({
    name: formData.get('name'),
    email: formData.get('email'),
    password: formData.get('password'),
    confirmPassword: formData.get('confirmPassword'),
  });

  if (!validatedFields.success) {
    return { error: validatedFields.error.flatten().fieldErrors };
  }

  const { name, email, password } = validatedFields.data;

  const existingUser = await prisma.user.findUnique({ where: { email } });
  if (existingUser) {
    return { error: { email: ['Email already in use'] } };
  }

  const hashedPassword = await bcrypt.hash(password, 10);

  await prisma.user.create({
    data: {
      name,
      email,
      password: hashedPassword,
    },
  });

  redirect('/login?registered=true');
}

認可

ロールベースのアクセス制御
// lib/auth-utils.ts
import { auth } from '@/auth';
import { redirect } from 'next/navigation';

export async function requireRole(allowedRoles: string[]) {
  const session = await auth();

  if (!session) {
    redirect('/login');
  }

  if (!allowedRoles.includes(session.user?.role || '')) {
    redirect('/unauthorized');
  }

  return session;
}
// app/admin/page.tsx
import { requireRole } from '@/lib/auth-utils';

export default async function AdminPage() {
  const session = await requireRole(['admin']);

  return (
    <div>
      <h1>Admin Dashboard</h1>
      <p>Welcome, {session.user?.name}</p>
    </div>
  );
}
権限ベースのアクセス制御
// lib/permissions.ts
type Permission = 'read:users' | 'write:users' | 'delete:users' | 'admin';

const rolePermissions: Record<string, Permission[]> = {
  admin: ['read:users', 'write:users', 'delete:users', 'admin'],
  editor: ['read:users', 'write:users'],
  viewer: ['read:users'],
};

export function hasPermission(
  userRole: string,
  permission: Permission
): boolean {
  return rolePermissions[userRole]?.includes(permission) || false;
}
// components/PermissionGuard.tsx
import { auth } from '@/auth';
import { hasPermission, Permission } from '@/lib/permissions';

export async function PermissionGuard({
  permission,
  children,
  fallback = null,
}: {
  permission: Permission;
  children: React.ReactNode;
  fallback?: React.ReactNode;
}) {
  const session = await auth();

  if (!session || !hasPermission(session.user?.role || '', permission)) {
    return fallback;
  }

  return <>{children}</>;
}

// Usage
<PermissionGuard permission="delete:users" fallback={<span>No access</span>}>
  <DeleteUserButton userId={user.id} />
</PermissionGuard>

JWT トークン

カスタム JWT コンテンツ
// auth.ts
export const { handlers, signIn, signOut, auth } = NextAuth({
  // ...
  callbacks: {
    async jwt({ token, user, account }) {
      if (user) {
        token.id = user.id;
        token.role = user.role;
        token.permissions = user.permissions;
      }

      if (account) {
        token.accessToken = account.access_token;
      }

      return token;
    },
    async session({ session, token }) {
      session.user.id = token.id as string;
      session.user.role = token.role as string;
      session.accessToken = token.accessToken as string;
      return session;
    },
  },
});
型の拡張
// types/next-auth.d.ts
import { DefaultSession, DefaultUser } from 'next-auth';
import { JWT, DefaultJWT } from 'next-auth/jwt';

declare module 'next-auth' {
  interface Session {
    user: {
      id: string;
      role: string;
    } & DefaultSession['user'];
    accessToken?: string;
  }

  interface User extends DefaultUser {
    role: string;
  }
}

declare module 'next-auth/jwt' {
  interface JWT extends DefaultJWT {
    id: string;
    role: string;
    accessToken?: string;
  }
}

ログアウト

サインアウトアクション
// components/SignOutButton.tsx
import { signOut } from '@/auth';

export function SignOutButton() {
  return (
    <form
      action={async () => {
        'use server';
        await signOut({ redirectTo: '/' });
      }}
    >
      <button type="submit">Sign Out</button>
    </form>
  );
}
クライアント側のサインアウト
'use client';

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

export function SignOutButton() {
  return (
    <button onClick={() => signOut({ callbackUrl: '/' })}>
      Sign Out
    </button>
  );
}

ベストプラクティス

プラクティス説明
保護にはミドルウェアを使用すべてのルートを保護
JWT に保存するデータを最小限にするトークンを小さく保つ
セキュア Cookie を使用httpOnly、secure、sameSite
CSRF 保護を実装NextAuth に組み込み済み
パスワードをハッシュ化bcrypt または argon2 を使用
サーバー側で検証クライアントからの入力を決して信頼しない
リフレッシュトークンを使用長時間のセッション向け
認証イベントを記録セキュリティ監査向け

コメント