SvelteKit 2認証実装ガイド


SvelteKit 2は、フルスタックWebアプリケーション開発のための強力なフレームワークです。本記事では、SvelteKit 2でのユーザー認証実装を基礎から応用まで徹底的に解説します。フォームアクション、サーバーサイドセッション、OAuth連携、JWT、ミドルウェア(hooks)によるガードまで、実践的な実装方法をカバーします。

SvelteKit 2の認証アーキテクチャ

SvelteKit 2では、サーバーサイドとクライアントサイドの境界が明確に設計されており、セキュアな認証実装が可能です。

認証フローの基本構造

1. ユーザーがログインフォームを送信
2. サーバーサイドでフォームアクションが処理
3. 認証情報を検証
4. セッションを作成してCookieに保存
5. hooksミドルウェアで全リクエストを保護
6. ページコンポーネントで認証状態を利用

基本的な認証実装

プロジェクトセットアップ

# SvelteKit 2プロジェクトの作成
npm create svelte@latest my-auth-app
cd my-auth-app
npm install

# 必要なパッケージをインストール
npm install @lucia-auth/adapter-prisma lucia
npm install -D prisma
npm install bcrypt
npm install -D @types/bcrypt

データベーススキーマ

// prisma/schema.prisma
generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "sqlite"
  url      = "file:./dev.db"
}

model User {
  id           String    @id @default(uuid())
  username     String    @unique
  email        String    @unique
  passwordHash String
  createdAt    DateTime  @default(now())
  updatedAt    DateTime  @updatedAt
  sessions     Session[]
}

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

  @@index([userId])
}

Lucia Auth設定

// src/lib/server/auth.ts
import { Lucia } from "lucia";
import { PrismaAdapter } from "@lucia-auth/adapter-prisma";
import { PrismaClient } from "@prisma/client";
import { dev } from "$app/environment";

const client = new PrismaClient();

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

export const lucia = new Lucia(adapter, {
  sessionCookie: {
    attributes: {
      secure: !dev
    }
  },
  getUserAttributes: (attributes) => {
    return {
      username: attributes.username,
      email: attributes.email
    };
  }
});

declare module "lucia" {
  interface Register {
    Lucia: typeof lucia;
    DatabaseUserAttributes: DatabaseUserAttributes;
  }
}

interface DatabaseUserAttributes {
  username: string;
  email: string;
}

ユーザー登録機能

登録ページ

<!-- src/routes/register/+page.svelte -->
<script lang="ts">
  import { enhance } from "$app/forms";
  import type { ActionData } from "./$types";

  export let form: ActionData;
</script>

<div class="container">
  <h1>新規登録</h1>

  <form method="POST" use:enhance>
    <div class="form-group">
      <label for="username">ユーザー名</label>
      <input
        type="text"
        id="username"
        name="username"
        required
        minlength="3"
        maxlength="31"
      />
    </div>

    <div class="form-group">
      <label for="email">メールアドレス</label>
      <input
        type="email"
        id="email"
        name="email"
        required
      />
    </div>

    <div class="form-group">
      <label for="password">パスワード</label>
      <input
        type="password"
        id="password"
        name="password"
        required
        minlength="8"
      />
    </div>

    <div class="form-group">
      <label for="confirmPassword">パスワード(確認)</label>
      <input
        type="password"
        id="confirmPassword"
        name="confirmPassword"
        required
        minlength="8"
      />
    </div>

    {#if form?.error}
      <p class="error">{form.error}</p>
    {/if}

    <button type="submit">登録</button>
  </form>

  <p>
    アカウントをお持ちですか?
    <a href="/login">ログイン</a>
  </p>
</div>

<style>
  .container {
    max-width: 400px;
    margin: 2rem auto;
    padding: 2rem;
  }

  .form-group {
    margin-bottom: 1rem;
  }

  label {
    display: block;
    margin-bottom: 0.5rem;
    font-weight: 600;
  }

  input {
    width: 100%;
    padding: 0.5rem;
    border: 1px solid #ddd;
    border-radius: 4px;
    font-size: 1rem;
  }

  button {
    width: 100%;
    padding: 0.75rem;
    background-color: #4CAF50;
    color: white;
    border: none;
    border-radius: 4px;
    font-size: 1rem;
    cursor: pointer;
  }

  button:hover {
    background-color: #45a049;
  }

  .error {
    color: red;
    margin-bottom: 1rem;
  }
</style>

登録アクション

// src/routes/register/+page.server.ts
import { fail, redirect } from "@sveltejs/kit";
import { lucia } from "$lib/server/auth";
import { PrismaClient } from "@prisma/client";
import bcrypt from "bcrypt";
import type { Actions } from "./$types";

const prisma = new PrismaClient();

export const actions: Actions = {
  default: async ({ request, cookies }) => {
    const formData = await request.formData();
    const username = formData.get("username");
    const email = formData.get("email");
    const password = formData.get("password");
    const confirmPassword = formData.get("confirmPassword");

    // バリデーション
    if (
      typeof username !== "string" ||
      username.length < 3 ||
      username.length > 31
    ) {
      return fail(400, {
        error: "ユーザー名は3〜31文字で入力してください"
      });
    }

    if (typeof email !== "string" || !email.includes("@")) {
      return fail(400, {
        error: "有効なメールアドレスを入力してください"
      });
    }

    if (
      typeof password !== "string" ||
      password.length < 8 ||
      password.length > 255
    ) {
      return fail(400, {
        error: "パスワードは8文字以上で入力してください"
      });
    }

    if (password !== confirmPassword) {
      return fail(400, {
        error: "パスワードが一致しません"
      });
    }

    try {
      // ユーザー名の重複チェック
      const existingUser = await prisma.user.findUnique({
        where: { username }
      });

      if (existingUser) {
        return fail(400, {
          error: "このユーザー名は既に使用されています"
        });
      }

      // メールアドレスの重複チェック
      const existingEmail = await prisma.user.findUnique({
        where: { email }
      });

      if (existingEmail) {
        return fail(400, {
          error: "このメールアドレスは既に登録されています"
        });
      }

      // パスワードをハッシュ化
      const passwordHash = await bcrypt.hash(password, 10);

      // ユーザーを作成
      const user = await prisma.user.create({
        data: {
          username,
          email,
          passwordHash
        }
      });

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

      cookies.set(sessionCookie.name, sessionCookie.value, {
        path: ".",
        ...sessionCookie.attributes
      });
    } catch (error) {
      console.error("Registration error:", error);
      return fail(500, {
        error: "登録処理中にエラーが発生しました"
      });
    }

    throw redirect(302, "/dashboard");
  }
};

ログイン機能

ログインページ

<!-- src/routes/login/+page.svelte -->
<script lang="ts">
  import { enhance } from "$app/forms";
  import type { ActionData } from "./$types";

  export let form: ActionData;
</script>

<div class="container">
  <h1>ログイン</h1>

  <form method="POST" use:enhance>
    <div class="form-group">
      <label for="username">ユーザー名</label>
      <input
        type="text"
        id="username"
        name="username"
        required
      />
    </div>

    <div class="form-group">
      <label for="password">パスワード</label>
      <input
        type="password"
        id="password"
        name="password"
        required
      />
    </div>

    {#if form?.error}
      <p class="error">{form.error}</p>
    {/if}

    <button type="submit">ログイン</button>
  </form>

  <p>
    アカウントをお持ちでないですか?
    <a href="/register">新規登録</a>
  </p>
</div>

ログインアクション

// src/routes/login/+page.server.ts
import { fail, redirect } from "@sveltejs/kit";
import { lucia } from "$lib/server/auth";
import { PrismaClient } from "@prisma/client";
import bcrypt from "bcrypt";
import type { Actions } from "./$types";

const prisma = new PrismaClient();

export const actions: Actions = {
  default: async ({ request, cookies }) => {
    const formData = await request.formData();
    const username = formData.get("username");
    const password = formData.get("password");

    if (typeof username !== "string" || typeof password !== "string") {
      return fail(400, {
        error: "ユーザー名とパスワードを入力してください"
      });
    }

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

      if (!user) {
        return fail(400, {
          error: "ユーザー名またはパスワードが正しくありません"
        });
      }

      // パスワードを検証
      const validPassword = await bcrypt.compare(password, user.passwordHash);

      if (!validPassword) {
        return fail(400, {
          error: "ユーザー名またはパスワードが正しくありません"
        });
      }

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

      cookies.set(sessionCookie.name, sessionCookie.value, {
        path: ".",
        ...sessionCookie.attributes
      });
    } catch (error) {
      console.error("Login error:", error);
      return fail(500, {
        error: "ログイン処理中にエラーが発生しました"
      });
    }

    throw redirect(302, "/dashboard");
  }
};

Hooksミドルウェアによる認証ガード

グローバル認証チェック

// src/hooks.server.ts
import { lucia } from "$lib/server/auth";
import { redirect, type Handle } from "@sveltejs/kit";

export const handle: Handle = async ({ event, resolve }) => {
  const sessionId = event.cookies.get(lucia.sessionCookieName);

  if (!sessionId) {
    event.locals.user = null;
    event.locals.session = null;
  } else {
    const { session, user } = await lucia.validateSession(sessionId);

    if (session && session.fresh) {
      const sessionCookie = lucia.createSessionCookie(session.id);
      event.cookies.set(sessionCookie.name, sessionCookie.value, {
        path: ".",
        ...sessionCookie.attributes
      });
    }

    if (!session) {
      const sessionCookie = lucia.createBlankSessionCookie();
      event.cookies.set(sessionCookie.name, sessionCookie.value, {
        path: ".",
        ...sessionCookie.attributes
      });
    }

    event.locals.user = user;
    event.locals.session = session;
  }

  // 保護されたルートへのアクセスチェック
  const protectedRoutes = ["/dashboard", "/profile", "/settings"];
  const isProtectedRoute = protectedRoutes.some(route =>
    event.url.pathname.startsWith(route)
  );

  if (isProtectedRoute && !event.locals.user) {
    throw redirect(302, "/login");
  }

  // ログイン済みユーザーが認証ページにアクセスした場合
  const authRoutes = ["/login", "/register"];
  const isAuthRoute = authRoutes.some(route =>
    event.url.pathname.startsWith(route)
  );

  if (isAuthRoute && event.locals.user) {
    throw redirect(302, "/dashboard");
  }

  return resolve(event);
};

型定義

// src/app.d.ts
import type { User, Session } from "lucia";

declare global {
  namespace App {
    interface Locals {
      user: User | null;
      session: Session | null;
    }
  }
}

export {};

ログアウト機能

// src/routes/logout/+page.server.ts
import { lucia } from "$lib/server/auth";
import { redirect, type Actions } from "@sveltejs/kit";

export const actions: Actions = {
  default: async ({ locals, cookies }) => {
    if (!locals.session) {
      throw redirect(302, "/login");
    }

    await lucia.invalidateSession(locals.session.id);

    const sessionCookie = lucia.createBlankSessionCookie();
    cookies.set(sessionCookie.name, sessionCookie.value, {
      path: ".",
      ...sessionCookie.attributes
    });

    throw redirect(302, "/login");
  }
};

保護されたページ

<!-- src/routes/dashboard/+page.svelte -->
<script lang="ts">
  import type { PageData } from "./$types";

  export let data: PageData;
</script>

<div class="dashboard">
  <h1>ダッシュボード</h1>
  <p>ようこそ、{data.user.username}さん!</p>

  <div class="user-info">
    <h2>ユーザー情報</h2>
    <p><strong>ユーザー名:</strong> {data.user.username}</p>
    <p><strong>メール:</strong> {data.user.email}</p>
  </div>

  <form method="POST" action="/logout">
    <button type="submit">ログアウト</button>
  </form>
</div>
// src/routes/dashboard/+page.server.ts
import { redirect } from "@sveltejs/kit";
import type { PageServerLoad } from "./$types";

export const load: PageServerLoad = async ({ locals }) => {
  if (!locals.user) {
    throw redirect(302, "/login");
  }

  return {
    user: locals.user
  };
};

OAuth連携(GitHub)

環境変数設定

# .env
GITHUB_CLIENT_ID=your_github_client_id
GITHUB_CLIENT_SECRET=your_github_client_secret

OAuth設定

// src/lib/server/oauth.ts
import { GitHub } from "arctic";
import { GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET } from "$env/static/private";

export const github = new GitHub(
  GITHUB_CLIENT_ID,
  GITHUB_CLIENT_SECRET,
  "http://localhost:5173/login/github/callback"
);

GitHubログインフロー

// src/routes/login/github/+server.ts
import { redirect } from "@sveltejs/kit";
import { github } from "$lib/server/oauth";
import { generateState } from "arctic";
import type { RequestHandler } from "./$types";

export const GET: RequestHandler = async ({ cookies }) => {
  const state = generateState();
  const url = await github.createAuthorizationURL(state, {
    scopes: ["user:email"]
  });

  cookies.set("github_oauth_state", state, {
    path: "/",
    secure: import.meta.env.PROD,
    httpOnly: true,
    maxAge: 60 * 10, // 10分
    sameSite: "lax"
  });

  throw redirect(302, url.toString());
};

GitHubコールバック

// src/routes/login/github/callback/+server.ts
import { github } from "$lib/server/oauth";
import { lucia } from "$lib/server/auth";
import { PrismaClient } from "@prisma/client";
import { OAuth2RequestError } from "arctic";
import type { RequestHandler } from "./$types";

const prisma = new PrismaClient();

export const GET: RequestHandler = async ({ url, cookies }) => {
  const code = url.searchParams.get("code");
  const state = url.searchParams.get("state");
  const storedState = cookies.get("github_oauth_state");

  if (!code || !state || !storedState || state !== storedState) {
    return new Response(null, { status: 400 });
  }

  try {
    const tokens = await github.validateAuthorizationCode(code);
    const githubUserResponse = await fetch("https://api.github.com/user", {
      headers: {
        Authorization: `Bearer ${tokens.accessToken}`
      }
    });

    const githubUser: GitHubUser = await githubUserResponse.json();

    // ユーザーを検索または作成
    let user = await prisma.user.findFirst({
      where: { username: githubUser.login }
    });

    if (!user) {
      user = await prisma.user.create({
        data: {
          username: githubUser.login,
          email: githubUser.email ?? `${githubUser.login}@github.com`,
          passwordHash: "" // OAuthユーザーはパスワード不要
        }
      });
    }

    const session = await lucia.createSession(user.id, {});
    const sessionCookie = lucia.createSessionCookie(session.id);

    cookies.set(sessionCookie.name, sessionCookie.value, {
      path: ".",
      ...sessionCookie.attributes
    });

    return new Response(null, {
      status: 302,
      headers: {
        Location: "/dashboard"
      }
    });
  } catch (error) {
    if (error instanceof OAuth2RequestError) {
      return new Response(null, { status: 400 });
    }
    return new Response(null, { status: 500 });
  }
};

interface GitHubUser {
  login: string;
  email: string | null;
}

まとめ

SvelteKit 2での認証実装は、フォームアクション、hooksミドルウェア、サーバーサイドセッションを組み合わせることで、セキュアで保守しやすいシステムを構築できます。

主なポイント:

  • フォームアクション: サーバーサイドで安全に認証処理
  • Lucia Auth: シンプルで強力な認証ライブラリ
  • Hooksミドルウェア: グローバルな認証ガード
  • OAuth連携: GitHub、Google等の外部認証
  • 型安全: TypeScriptによる完全な型推論

この実装パターンを基に、プロダクション環境に適した認証システムを構築できます。