Lucia認証ライブラリ完全ガイド — セッションベース認証の新定番


Luciaは、TypeScriptファーストのセッションベース認証ライブラリです。JWTやサードパーティ認証サービスに依存せず、シンプルで柔軟なセッション管理を実現します。Next.js、SvelteKit、Astroなど、あらゆるフレームワークで使用できます。この記事では、Luciaの基本から実践的な実装まで徹底的に解説します。

Luciaとは

Luciaは、セッションベース認証に特化した軽量ライブラリです。主な特徴は以下の通りです。

  • TypeScript完全対応 - 型安全な認証実装
  • フレームワーク非依存 - Next.js、SvelteKit、Astro、Express等で使用可能
  • データベース柔軟性 - Prisma、Drizzle、Kysely、SQL等あらゆるORMに対応
  • OAuth統合 - GitHub、Google、Discord等の認証プロバイダー対応
  • シンプルなAPI - 最小限の設定で使い始められる
  • セキュアなデフォルト - ベストプラクティスを標準実装

なぜLuciaか?

従来の認証ライブラリとの比較

NextAuth.js(Auth.js):

  • プロバイダー中心の設計
  • JWTまたはデータベースセッション
  • 柔軟性に欠ける部分がある

Passport.js:

  • Express専用
  • コールバック地獄になりがち
  • TypeScript対応が不十分

Lucia:

  • セッション管理に特化
  • フレームワーク非依存
  • TypeScriptファースト
  • 完全な制御が可能

インストールとセットアップ

基本インストール

# Luciaのインストール
npm install lucia

# データベースアダプター(例: Prisma)
npm install @lucia-auth/adapter-prisma

Prismaスキーマ定義

// prisma/schema.prisma
datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

generator client {
  provider = "prisma-client-js"
}

model User {
  id       String    @id
  email    String    @unique
  username String    @unique
  sessions Session[]

  @@map("users")
}

model Session {
  id        String   @id
  userId    String
  expiresAt DateTime
  user      User     @relation(references: [id], fields: [userId], onDelete: Cascade)

  @@map("sessions")
}

Lucia初期化

// lib/auth.ts
import { Lucia } from 'lucia';
import { PrismaAdapter } from '@lucia-auth/adapter-prisma';
import { PrismaClient } from '@prisma/client';

const prisma = new PrismaClient();

const adapter = new PrismaAdapter(prisma.session, prisma.user);

export const lucia = new Lucia(adapter, {
  sessionCookie: {
    attributes: {
      secure: process.env.NODE_ENV === 'production',
    },
  },
  getUserAttributes: (attributes) => {
    return {
      email: attributes.email,
      username: attributes.username,
    };
  },
});

declare module 'lucia' {
  interface Register {
    Lucia: typeof lucia;
    DatabaseUserAttributes: {
      email: string;
      username: string;
    };
  }
}

基本的な認証フロー

ユーザー登録

// app/api/signup/route.ts
import { lucia } from '@/lib/auth';
import { cookies } from 'next/headers';
import { prisma } from '@/lib/prisma';
import { generateId } from 'lucia';
import { hash } from '@node-rs/argon2';

export async function POST(req: Request) {
  const { email, username, password } = await req.json();

  // バリデーション
  if (!email || !username || !password) {
    return Response.json({ error: 'Missing fields' }, { status: 400 });
  }

  if (password.length < 8) {
    return Response.json({ error: 'Password too short' }, { status: 400 });
  }

  // ユーザー存在チェック
  const existingUser = await prisma.user.findFirst({
    where: {
      OR: [{ email }, { username }],
    },
  });

  if (existingUser) {
    return Response.json({ error: 'User already exists' }, { status: 400 });
  }

  // パスワードハッシュ化
  const passwordHash = await hash(password, {
    memoryCost: 19456,
    timeCost: 2,
    outputLen: 32,
    parallelism: 1,
  });

  // ユーザー作成
  const userId = generateId(15);

  await prisma.user.create({
    data: {
      id: userId,
      email,
      username,
      passwordHash,
    },
  });

  // セッション作成
  const session = await lucia.createSession(userId, {});
  const sessionCookie = lucia.createSessionCookie(session.id);

  cookies().set(
    sessionCookie.name,
    sessionCookie.value,
    sessionCookie.attributes
  );

  return Response.json({ success: true });
}

ログイン

// app/api/login/route.ts
import { lucia } from '@/lib/auth';
import { cookies } from 'next/headers';
import { prisma } from '@/lib/prisma';
import { verify } from '@node-rs/argon2';

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

  if (!email || !password) {
    return Response.json({ error: 'Missing fields' }, { status: 400 });
  }

  // ユーザー検索
  const user = await prisma.user.findUnique({
    where: { email },
  });

  if (!user) {
    return Response.json({ error: 'Invalid credentials' }, { status: 401 });
  }

  // パスワード検証
  const validPassword = await verify(user.passwordHash, password, {
    memoryCost: 19456,
    timeCost: 2,
    outputLen: 32,
    parallelism: 1,
  });

  if (!validPassword) {
    return Response.json({ error: 'Invalid credentials' }, { status: 401 });
  }

  // セッション作成
  const session = await lucia.createSession(user.id, {});
  const sessionCookie = lucia.createSessionCookie(session.id);

  cookies().set(
    sessionCookie.name,
    sessionCookie.value,
    sessionCookie.attributes
  );

  return Response.json({ success: true });
}

ログアウト

// app/api/logout/route.ts
import { lucia } from '@/lib/auth';
import { cookies } from 'next/headers';
import { validateRequest } from '@/lib/auth-utils';

export async function POST() {
  const { session } = await validateRequest();

  if (!session) {
    return Response.json({ error: 'Unauthorized' }, { status: 401 });
  }

  // セッション無効化
  await lucia.invalidateSession(session.id);

  // クッキー削除
  const sessionCookie = lucia.createBlankSessionCookie();
  cookies().set(
    sessionCookie.name,
    sessionCookie.value,
    sessionCookie.attributes
  );

  return Response.json({ success: true });
}

セッション検証

ミドルウェアでの検証(Next.js App Router)

// lib/auth-utils.ts
import { lucia } from './auth';
import { cookies } from 'next/headers';
import { cache } from 'react';

export const validateRequest = cache(async () => {
  const sessionId = cookies().get(lucia.sessionCookieName)?.value ?? null;

  if (!sessionId) {
    return {
      user: null,
      session: null,
    };
  }

  const result = await lucia.validateSession(sessionId);

  // セッションリフレッシュが必要な場合
  try {
    if (result.session && result.session.fresh) {
      const sessionCookie = lucia.createSessionCookie(result.session.id);
      cookies().set(
        sessionCookie.name,
        sessionCookie.value,
        sessionCookie.attributes
      );
    }
    if (!result.session) {
      const sessionCookie = lucia.createBlankSessionCookie();
      cookies().set(
        sessionCookie.name,
        sessionCookie.value,
        sessionCookie.attributes
      );
    }
  } catch {
    // Next.jsのcookies()がread-onlyの場合の処理
  }

  return result;
});

保護されたルート

// app/dashboard/page.tsx
import { validateRequest } from '@/lib/auth-utils';
import { redirect } from 'next/navigation';

export default async function DashboardPage() {
  const { user } = await validateRequest();

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

  return (
    <div>
      <h1>Dashboard</h1>
      <p>Welcome, {user.username}!</p>
    </div>
  );
}

Server Actions

// app/actions/profile.ts
'use server';

import { validateRequest } from '@/lib/auth-utils';
import { prisma } from '@/lib/prisma';

export async function updateProfile(formData: FormData) {
  const { user } = await validateRequest();

  if (!user) {
    throw new Error('Unauthorized');
  }

  const username = formData.get('username') as string;

  await prisma.user.update({
    where: { id: user.id },
    data: { username },
  });

  return { success: true };
}

OAuth統合

GitHub認証

# OAuthライブラリのインストール
npm install arctic
// lib/oauth.ts
import { GitHub } from 'arctic';

export const github = new GitHub(
  process.env.GITHUB_CLIENT_ID!,
  process.env.GITHUB_CLIENT_SECRET!
);

GitHub認証フロー

// app/api/login/github/route.ts
import { github } from '@/lib/oauth';
import { generateState } from 'arctic';
import { cookies } from 'next/headers';

export async function GET() {
  const state = generateState();

  const url = await github.createAuthorizationURL(state, {
    scopes: ['user:email'],
  });

  cookies().set('github_oauth_state', state, {
    path: '/',
    secure: process.env.NODE_ENV === 'production',
    httpOnly: true,
    maxAge: 60 * 10, // 10分
    sameSite: 'lax',
  });

  return Response.redirect(url);
}

GitHubコールバック処理

// app/api/login/github/callback/route.ts
import { github } from '@/lib/oauth';
import { lucia } from '@/lib/auth';
import { cookies } from 'next/headers';
import { prisma } from '@/lib/prisma';
import { generateId } from 'lucia';

export async function GET(req: Request) {
  const url = new URL(req.url);
  const code = url.searchParams.get('code');
  const state = url.searchParams.get('state');
  const storedState = cookies().get('github_oauth_state')?.value ?? null;

  if (!code || !state || !storedState || state !== storedState) {
    return Response.json({ error: 'Invalid request' }, { status: 400 });
  }

  try {
    // トークン取得
    const tokens = await github.validateAuthorizationCode(code);

    // GitHubユーザー情報取得
    const githubUserResponse = await fetch('https://api.github.com/user', {
      headers: {
        Authorization: `Bearer ${tokens.accessToken}`,
      },
    });

    const githubUser = await githubUserResponse.json();

    // メールアドレス取得
    const emailsResponse = await fetch('https://api.github.com/user/emails', {
      headers: {
        Authorization: `Bearer ${tokens.accessToken}`,
      },
    });

    const emails = await emailsResponse.json();
    const primaryEmail = emails.find((email: any) => email.primary)?.email;

    if (!primaryEmail) {
      return Response.json({ error: 'No email found' }, { status: 400 });
    }

    // 既存ユーザー確認
    let user = await prisma.user.findUnique({
      where: { email: primaryEmail },
    });

    // 新規ユーザー作成
    if (!user) {
      const userId = generateId(15);
      user = await prisma.user.create({
        data: {
          id: userId,
          email: primaryEmail,
          username: githubUser.login,
        },
      });
    }

    // セッション作成
    const session = await lucia.createSession(user.id, {});
    const sessionCookie = lucia.createSessionCookie(session.id);

    cookies().set(
      sessionCookie.name,
      sessionCookie.value,
      sessionCookie.attributes
    );

    return Response.redirect(new URL('/dashboard', req.url));
  } catch (error) {
    console.error(error);
    return Response.json({ error: 'Authentication failed' }, { status: 500 });
  }
}

複数のOAuthプロバイダー

Google認証

// lib/oauth.ts
import { GitHub, Google } from 'arctic';

export const github = new GitHub(
  process.env.GITHUB_CLIENT_ID!,
  process.env.GITHUB_CLIENT_SECRET!
);

export const google = new Google(
  process.env.GOOGLE_CLIENT_ID!,
  process.env.GOOGLE_CLIENT_SECRET!,
  `${process.env.NEXT_PUBLIC_BASE_URL}/api/login/google/callback`
);

Discord認証

// lib/oauth.ts
import { Discord } from 'arctic';

export const discord = new Discord(
  process.env.DISCORD_CLIENT_ID!,
  process.env.DISCORD_CLIENT_SECRET!,
  `${process.env.NEXT_PUBLIC_BASE_URL}/api/login/discord/callback`
);

データベーススキーマ(OAuth対応)

model User {
  id            String         @id
  email         String         @unique
  username      String         @unique
  passwordHash  String?
  sessions      Session[]
  oauthAccounts OAuthAccount[]

  @@map("users")
}

model OAuthAccount {
  providerId     String
  providerUserId String
  userId         String
  user           User   @relation(references: [id], fields: [userId], onDelete: Cascade)

  @@id([providerId, providerUserId])
  @@map("oauth_accounts")
}

model Session {
  id        String   @id
  userId    String
  expiresAt DateTime
  user      User     @relation(references: [id], fields: [userId], onDelete: Cascade)

  @@map("sessions")
}

2要素認証(2FA)

TOTPの実装

npm install @epic-web/totp
// lib/totp.ts
import { TOTP } from '@epic-web/totp';

export async function generateTOTP(secret: string) {
  const totp = new TOTP({
    secret,
    period: 30,
    digits: 6,
  });

  return totp.generate();
}

export async function verifyTOTP(secret: string, token: string) {
  const totp = new TOTP({
    secret,
    period: 30,
    digits: 6,
  });

  return totp.verify(token);
}

2FA有効化

// app/api/2fa/enable/route.ts
import { validateRequest } from '@/lib/auth-utils';
import { prisma } from '@/lib/prisma';
import { generateRandomString } from 'lucia';

export async function POST() {
  const { user } = await validateRequest();

  if (!user) {
    return Response.json({ error: 'Unauthorized' }, { status: 401 });
  }

  // TOTP秘密鍵生成
  const secret = generateRandomString(20, '0123456789abcdef');

  // ユーザーに秘密鍵を保存
  await prisma.user.update({
    where: { id: user.id },
    data: {
      totpSecret: secret,
      totpEnabled: false, // 検証後に有効化
    },
  });

  // QRコード生成用のURI
  const uri = `otpauth://totp/${encodeURIComponent(
    user.email
  )}?secret=${secret}&issuer=YourApp`;

  return Response.json({ secret, uri });
}

2FA検証

// app/api/2fa/verify/route.ts
import { validateRequest } from '@/lib/auth-utils';
import { prisma } from '@/lib/prisma';
import { verifyTOTP } from '@/lib/totp';

export async function POST(req: Request) {
  const { user } = await validateRequest();

  if (!user) {
    return Response.json({ error: 'Unauthorized' }, { status: 401 });
  }

  const { token } = await req.json();

  const dbUser = await prisma.user.findUnique({
    where: { id: user.id },
  });

  if (!dbUser?.totpSecret) {
    return Response.json({ error: 'TOTP not configured' }, { status: 400 });
  }

  const valid = await verifyTOTP(dbUser.totpSecret, token);

  if (!valid) {
    return Response.json({ error: 'Invalid token' }, { status: 401 });
  }

  // 2FA有効化
  await prisma.user.update({
    where: { id: user.id },
    data: { totpEnabled: true },
  });

  return Response.json({ success: true });
}

メール確認(Email Verification)

データベーススキーマ

model User {
  id               String            @id
  email            String            @unique
  emailVerified    Boolean           @default(false)
  verificationTokens VerificationToken[]

  @@map("users")
}

model VerificationToken {
  id        String   @id
  userId    String
  expiresAt DateTime
  user      User     @relation(references: [id], fields: [userId], onDelete: Cascade)

  @@map("verification_tokens")
}

確認メール送信

// lib/email.ts
import { Resend } from 'resend';

const resend = new Resend(process.env.RESEND_API_KEY);

export async function sendVerificationEmail(email: string, token: string) {
  const verificationUrl = `${process.env.NEXT_PUBLIC_BASE_URL}/verify-email?token=${token}`;

  await resend.emails.send({
    from: 'noreply@yourapp.com',
    to: email,
    subject: 'Verify your email',
    html: `<p>Click <a href="${verificationUrl}">here</a> to verify your email.</p>`,
  });
}

トークン生成と送信

// app/api/send-verification/route.ts
import { validateRequest } from '@/lib/auth-utils';
import { prisma } from '@/lib/prisma';
import { generateId } from 'lucia';
import { sendVerificationEmail } from '@/lib/email';

export async function POST() {
  const { user } = await validateRequest();

  if (!user) {
    return Response.json({ error: 'Unauthorized' }, { status: 401 });
  }

  if (user.emailVerified) {
    return Response.json({ error: 'Already verified' }, { status: 400 });
  }

  // 既存のトークンを削除
  await prisma.verificationToken.deleteMany({
    where: { userId: user.id },
  });

  // 新しいトークン生成
  const tokenId = generateId(40);

  await prisma.verificationToken.create({
    data: {
      id: tokenId,
      userId: user.id,
      expiresAt: new Date(Date.now() + 1000 * 60 * 60), // 1時間
    },
  });

  // メール送信
  await sendVerificationEmail(user.email, tokenId);

  return Response.json({ success: true });
}

メール確認

// app/api/verify-email/route.ts
import { prisma } from '@/lib/prisma';

export async function GET(req: Request) {
  const url = new URL(req.url);
  const token = url.searchParams.get('token');

  if (!token) {
    return Response.json({ error: 'Invalid token' }, { status: 400 });
  }

  const verificationToken = await prisma.verificationToken.findUnique({
    where: { id: token },
  });

  if (!verificationToken) {
    return Response.json({ error: 'Invalid token' }, { status: 400 });
  }

  if (verificationToken.expiresAt < new Date()) {
    return Response.json({ error: 'Token expired' }, { status: 400 });
  }

  // ユーザーを確認済みにする
  await prisma.user.update({
    where: { id: verificationToken.userId },
    data: { emailVerified: true },
  });

  // トークン削除
  await prisma.verificationToken.delete({
    where: { id: token },
  });

  return Response.redirect(new URL('/dashboard', req.url));
}

パスワードリセット

リセットトークン生成

// app/api/forgot-password/route.ts
import { prisma } from '@/lib/prisma';
import { generateId } from 'lucia';
import { sendPasswordResetEmail } from '@/lib/email';

export async function POST(req: Request) {
  const { email } = await req.json();

  const user = await prisma.user.findUnique({
    where: { email },
  });

  if (!user) {
    // セキュリティのため、ユーザーが存在しない場合も成功を返す
    return Response.json({ success: true });
  }

  // 既存のトークンを削除
  await prisma.passwordResetToken.deleteMany({
    where: { userId: user.id },
  });

  // 新しいトークン生成
  const tokenId = generateId(40);

  await prisma.passwordResetToken.create({
    data: {
      id: tokenId,
      userId: user.id,
      expiresAt: new Date(Date.now() + 1000 * 60 * 60), // 1時間
    },
  });

  await sendPasswordResetEmail(email, tokenId);

  return Response.json({ success: true });
}

パスワードリセット実行

// app/api/reset-password/route.ts
import { prisma } from '@/lib/prisma';
import { lucia } from '@/lib/auth';
import { hash } from '@node-rs/argon2';

export async function POST(req: Request) {
  const { token, password } = await req.json();

  if (password.length < 8) {
    return Response.json({ error: 'Password too short' }, { status: 400 });
  }

  const resetToken = await prisma.passwordResetToken.findUnique({
    where: { id: token },
  });

  if (!resetToken) {
    return Response.json({ error: 'Invalid token' }, { status: 400 });
  }

  if (resetToken.expiresAt < new Date()) {
    return Response.json({ error: 'Token expired' }, { status: 400 });
  }

  // 全セッション無効化
  await lucia.invalidateUserSessions(resetToken.userId);

  // パスワード更新
  const passwordHash = await hash(password, {
    memoryCost: 19456,
    timeCost: 2,
    outputLen: 32,
    parallelism: 1,
  });

  await prisma.user.update({
    where: { id: resetToken.userId },
    data: { passwordHash },
  });

  // トークン削除
  await prisma.passwordResetToken.delete({
    where: { id: token },
  });

  return Response.json({ success: true });
}

セキュリティベストプラクティス

レート制限

// lib/rate-limit.ts
import { LRUCache } from 'lru-cache';

type Options = {
  uniqueTokenPerInterval?: number;
  interval?: number;
};

export default function rateLimit(options?: Options) {
  const tokenCache = new LRUCache({
    max: options?.uniqueTokenPerInterval || 500,
    ttl: options?.interval || 60000,
  });

  return {
    check: (res: Response, limit: number, token: string) =>
      new Promise<void>((resolve, reject) => {
        const tokenCount = (tokenCache.get(token) as number[]) || [0];
        if (tokenCount[0] === 0) {
          tokenCache.set(token, tokenCount);
        }
        tokenCount[0] += 1;

        const currentUsage = tokenCount[0];
        const isRateLimited = currentUsage >= limit;

        if (isRateLimited) {
          reject(new Error('Rate limit exceeded'));
        } else {
          resolve();
        }
      }),
  };
}

CSRF保護

LuciaはセッションクッキーにsameSite: 'lax'を自動設定し、基本的なCSRF保護を提供します。

セッション有効期限

// lib/auth.ts
export const lucia = new Lucia(adapter, {
  sessionCookie: {
    expires: false,
  },
  sessionExpiresIn: new TimeSpan(30, 'd'), // 30日
});

まとめ

Luciaは、TypeScriptファーストのセッション認証ライブラリとして、柔軟で型安全な認証実装を可能にします。

主な利点:

  • 完全な型安全性
  • フレームワーク非依存
  • データベース柔軟性
  • OAuth統合の容易さ
  • セキュアなデフォルト

NextAuth.jsやPassport.jsと比べて、より細かい制御が可能で、TypeScript開発者にとって理想的な認証ライブラリです。セッションベース認証のベストプラクティスを実装したい場合、Luciaは最良の選択肢の一つです。