Lucia

TypeScript認証ライブラリセッション管理フレームワーク非依存セキュリティJavaScript

認証ライブラリ

Lucia

概要

Luciaは、TypeScriptとJavaScriptのためのオープンソース認証ライブラリで、ユーザーとセッションの管理の複雑さを抽象化し、シンプルで柔軟な認証システムを提供します。

詳細

Lucia(ルシア)は、TypeScript/JavaScript向けの軽量で型安全な認証ライブラリです。2024年の重要な変更として、Lucia v3は2025年3月に非推奨となり、Luciaは認証を一から実装するための学習リソースへと移行しています。これにより、開発者は様々なランタイムやデータベースをサポートする複雑さを取り除いた、非常に短くシンプルなコードでLucia v3と同等の機能を再現できます。

フレームワーク非依存の設計により、Astro、Express、Next.js、Nuxt、SolidStart、SvelteKit、その他多くのフレームワークで使用可能です。Node.js、Deno、Bun、Cloudflare Workersを含む全ての主要なJSランタイムに対応しています。データベースの柔軟性も特徴の一つで、様々なデータベース、ライブラリ、フレームワーク、ORMをサポートし、パフォーマンスを犠牲にすることなく好みのデータベースを選択できます。

認証方法においては、OAuthとメール/パスワード認証に加え、パスワードレス認証システムも提供しています。セキュリティ面では、最新のセキュリティ標準に準拠し、不要なサードパーティへのデータ露出からユーザーデータを保護します。開発者はセッションの作成、検証、破棄を自分で行うことができ、これによりセッションハイジャックのリスクを最小化できます。

従来のサードパーティ認証ライブラリ(Clerk、NextAuth、Supabase auth)と異なり、Luciaはより低レベルでシンプルなアプローチを採用し、認証に関する完全な制御を開発者に与えます。Auth.jsと比較して、はるかに低レベルでシンプルでありながら、完全な制御を提供するのが特徴です。

メリット・デメリット

メリット

  • 完全な制御権: 認証システムを完全にコントロール可能
  • 軽量で柔軟: 最小限の依存関係で高いカスタマイズ性
  • フレームワーク非依存: 主要なJSフレームワークとランタイムに対応
  • データベース選択の自由: 任意のデータベースやORMを使用可能
  • 型安全性: TypeScript完全対応で優れた型推論
  • セキュリティ重視: 最新のセキュリティ標準に準拠
  • シンプルなAPI: 理解しやすく拡張しやすいAPI設計
  • セッション管理: 高度なセッション管理機能を内蔵

デメリット

  • 学習曲線: 低レベルAPIのため初心者には複雑
  • 廃止予定: v3は2025年3月に非推奨となる予定
  • 手動実装: より多くのコードを手動で書く必要がある
  • コミュニティ: 他の認証ライブラリと比較してコミュニティが小さい
  • ドキュメント: 移行期のため一部ドキュメントが古い可能性
  • 保守負担: 自前実装のため保守責任が開発者にある

主要リンク

書き方の例

セッション管理の基本実装

// セッション管理の基本関数
function generateSessionId(): string {
  const bytes = new Uint8Array(25);
  crypto.getRandomValues(bytes);
  const token = encodeBase32LowerCaseNoPadding(bytes);
  return token;
}

const sessionExpiresInSeconds = 60 * 60 * 24 * 30; // 30日

export function createSession(dbPool: DBPool, userId: number): Promise<Session> {
  const now = new Date();
  const sessionId = generateSessionId();
  const session: Session = {
    id: sessionId,
    userId,
    expiresAt: new Date(now.getTime() + 1000 * sessionExpiresInSeconds)
  };
  
  await executeQuery(
    dbPool,
    "INSERT INTO user_session (id, user_id, expires_at) VALUES (?, ?, ?)",
    [session.id, session.userId, Math.floor(session.expiresAt.getTime() / 1000)]
  );
  
  return session;
}

export interface Session {
  id: string;
  userId: number;
  expiresAt: Date;
}

セッション検証

export function validateSession(dbPool: DBPool, sessionId: string): Promise<Session | null> {
  const now = Date.now();

  const result = dbPool.executeQuery(
    dbPool,
    "SELECT id, user_id, expires_at FROM session WHERE id = ?",
    [sessionId]
  );
  
  if (result.rows.length < 1) {
    return null;
  }
  
  const row = result.rows[0];
  const session: Session = {
    id: row[0],
    userId: row[1],
    expiresAt: new Date(row[2] * 1000)
  };
  
  // 期限切れチェック
  if (now.getTime() >= session.expiresAt.getTime()) {
    await executeQuery(dbPool, "DELETE FROM user_session WHERE id = ?", [session.id]);
    return null;
  }
  
  // セッション更新(半分経過時)
  if (now.getTime() >= session.expiresAt.getTime() - (1000 * sessionExpiresInSeconds) / 2) {
    session.expiresAt = new Date(Date.now() + 1000 * sessionExpiresInSeconds);
    await executeQuery(dbPool, "UPDATE session SET expires_at = ? WHERE id = ?", [
      Math.floor(session.expiresAt.getTime() / 1000),
      session.id
    ]);
  }
  
  return session;
}

Cookieベースのセッション管理

export function setSessionCookie(response: HTTPResponse, sessionId: string, expiresAt: Date): void {
  if (env === ENV.PROD) {
    response.headers.add(
      "Set-Cookie",
      `session=${sessionId}; HttpOnly; SameSite=Lax; Expires=${expiresAt.toUTCString()}; Path=/; Secure;`
    );
  } else {
    response.headers.add(
      "Set-Cookie",
      `session=${sessionId}; HttpOnly; SameSite=Lax; Expires=${expiresAt.toUTCString()}; Path=/`
    );
  }
}

export function deleteSessionCookie(response: HTTPResponse): void {
  if (env === ENV.PROD) {
    response.headers.add(
      "Set-Cookie",
      "session=; HttpOnly; SameSite=Lax; Max-Age=0; Path=/; Secure;"
    );
  } else {
    response.headers.add("Set-Cookie", "session=; HttpOnly; SameSite=Lax; Max-Age=0; Path=/");
  }
}

Next.jsでの実装例

import { cookies } from "next/headers";
import { cache } from "react";

export const getCurrentSession = cache(async (): Promise<SessionResult> => {
  const cookieStore = await cookies();
  const token = cookieStore.get("session")?.value ?? null;
  
  if (token === null) {
    return { session: null, user: null };
  }
  
  const result = await validateSessionToken(token);
  return result;
});

// ページコンポーネントでの使用
export default async function Page() {
  const { user } = await getCurrentSession();
  
  if (user === null) {
    return redirect("/login");
  }
  
  return <h1>こんにちは、{user.name}さん!</h1>;
}

OAuth実装(GitHub連携)

// GitHub OAuth callback処理
export async function GET(request: Request): Promise<Response> {
  const url = new URL(request.url);
  const code = url.searchParams.get("code");
  const state = url.searchParams.get("state");
  
  const cookieStore = await cookies();
  const storedState = cookieStore.get("github_oauth_state")?.value ?? null;
  
  if (code === null || state === null || storedState === null) {
    return new Response(null, { status: 400 });
  }
  
  if (state !== storedState) {
    return new Response(null, { status: 400 });
  }

  let tokens: OAuth2Tokens;
  try {
    tokens = await github.validateAuthorizationCode(code);
  } catch (e) {
    return new Response(null, { status: 400 });
  }
  
  const githubUserResponse = await fetch("https://api.github.com/user", {
    headers: { Authorization: `Bearer ${tokens.accessToken()}` }
  });
  
  const githubUser = await githubUserResponse.json();
  const githubUserId = githubUser.id;
  const githubUsername = githubUser.login;

  const existingUser = await getUserFromGitHubId(githubUserId);

  if (existingUser !== null) {
    const sessionToken = generateSessionToken();
    const session = await createSession(sessionToken, existingUser.id);
    await setSessionTokenCookie(sessionToken, session.expiresAt);
    return new Response(null, {
      status: 302,
      headers: { Location: "/" }
    });
  }

  const user = await createUser(githubUserId, githubUsername);
  const sessionToken = generateSessionToken();
  const session = await createSession(sessionToken, user.id);
  await setSessionTokenCookie(sessionToken, session.expiresAt);
  
  return new Response(null, {
    status: 302,
    headers: { Location: "/" }
  });
}

セッション無効化(サインアウト)

export async function invalidateSession(dbPool: DBPool, sessionId: string): Promise<void> {
  await executeQuery(dbPool, "DELETE FROM user_session WHERE id = ?", [sessionId]);
}

export async function invalidateAllSessions(dbPool: DBPool, userId: number): Promise<void> {
  await executeQuery(dbPool, "DELETE FROM user_session WHERE user_id = ?", [userId]);
}

// サインアウト処理
async function logout(): Promise<ActionResult> {
  "use server";
  const { session } = await getCurrentSession();
  
  if (!session) {
    return { error: "Unauthorized" };
  }

  await invalidateSession(session.id);
  await deleteSessionTokenCookie();
  return redirect("/login");
}

定数時間比較(セキュリティ)

function constantTimeEqual(a: Uint8Array, b: Uint8Array): boolean {
  if (a.byteLength !== b.byteLength) {
    return false;
  }
  
  let c = 0;
  for (let i = 0; i < a.byteLength; i++) {
    c |= a[i] ^ b[i];
  }
  
  return c === 0;
}