家計簿アプリを作る #8:ログイン・ログアウトの実装

家計簿アプリ作成シリーズの第8回(後編)です。前回に引き続き、ログイン・ログアウトを実装します。

Step 1:hooks.server.ts でセッションを取得する

すべてのリクエストでセッションを検証するため src/hooks.server.ts を作成します。

import type { Handle } from '@sveltejs/kit';
import { createAuth } from '$lib/server/auth';

export const handle: Handle = async ({ event, resolve }) => {
  const auth = createAuth(event.platform!.env.DB);
  const session = await auth.api.getSession({
    headers: event.request.headers
  });

  event.locals.user = session?.user || null;
  event.locals.session = session?.session || null;

  return resolve(event);
};

ハマりポイント①:ファイル名に + を付けてはいけない

SvelteKit のルーティングファイル(+page.svelte 等)は + が必要ですが、hooks.server.ts+ なしです。+hooks.server.ts にしてしまうと以下のエラーが出てファイルが認識されません。

> Files prefixed with + are reserved (saw src/routes/+hooks.server.ts)

また hooks.server.tssrc/routes/ ではなく src/ 直下に置く必要があります。

Step 2:ログインページを作成する

src/routes/login/+page.server.ts

import { fail, redirect } from '@sveltejs/kit';
import type { Actions } from './$types';
import { createAuth } from '$lib/server/auth';

export const actions: Actions = {
  login: async ({ request, platform, cookies }) => {
    const formData = await request.formData();
    const email = formData.get('email');
    const password = formData.get('password');

    if (!email || !password) {
      return fail(400, { error: 'メールアドレスとパスワードを入力してください' });
    }

    const auth = createAuth(platform!.env.DB);
    const response = await auth.api.signInEmail({
      body: {
        email: String(email),
        password: String(password)
      },
      asResponse: true
    });

    if (!response.ok) {
      return fail(400, { error: 'ログインに失敗しました' });
    }

    const setCookieHeader = response.headers.get('set-cookie') ?? '';

    if (setCookieHeader) {
      const [nameValue] = setCookieHeader.split(';');
      const eqIndex = nameValue.indexOf('=');
      const cookieName = nameValue.substring(0, eqIndex).trim();
      const cookieValue = decodeURIComponent(nameValue.substring(eqIndex + 1).trim());

      cookies.set(cookieName, cookieValue, {
        path: '/',
        httpOnly: true,
        sameSite: 'lax',
        maxAge: 604800
      });
    }

    redirect(303, '/');
  }
};

src/routes/login/+page.svelte

<script lang="ts">
  import { enhance } from "$app/forms";
  let { form } = $props();
</script>

<form method="POST" action="?/login" use:enhance>
  <div>
    <label for="email">メールアドレス:</label>
    <input type="email" id="email" name="email" required />
  </div>
  <div>
    <label for="password">パスワード:</label>
    <input type="password" id="password" name="password" required />
  </div>
  <button type="submit">ログイン</button>
</form>

<a href="/signup">アカウントを作成</a>

{#if form?.error}
  <p style="color: red">{form.error}</p>
{/if}

ハマりポイント②:Cookie の値を decodeURIComponent でデコードする

asResponse: true で取得したレスポンスの Set-Cookie ヘッダーには Cookie の値が URL エンコードされた状態で入っています(%2F%3D 等)。

そのまま cookies.set に渡すと Better Auth の getSession で照合に失敗します。decodeURIComponent でデコードしてから設定する必要があります。

const cookieValue = decodeURIComponent(nameValue.substring(eqIndex + 1).trim());
// ❌ Set-Cookie ヘッダー文字列全体を値にしてしまう
cookies.set('better-auth.session_token', setCookieHeader, { ... });

// ✅ name=value; Max-Age=...; ... から name と value だけ取り出す
const [nameValue] = setCookieHeader.split(';');
const eqIndex = nameValue.indexOf('=');
const cookieName = nameValue.substring(0, eqIndex).trim();
const cookieValue = decodeURIComponent(nameValue.substring(eqIndex + 1).trim());

Step 3:認証チェックを追加する

ルートページ(/)で未ログインの場合は /login にリダイレクトします。

// src/routes/+page.server.ts
export const load: PageServerLoad = async ({ locals, platform }) => {
  if (!locals.user) {
    redirect(303, '/login');
  }
  // ...
};

Step 4:ログアウトを実装する

トップページにログアウトボタンを追加し、Cookie を削除してログイン画面に戻ります。

src/routes/+page.server.ts の actions に追加

logout: async ({ cookies }) => {
  cookies.delete('better-auth.session_token', { path: '/' });
  redirect(303, '/login');
}

src/routes/+page.svelte に追加

<form method="POST" action="?/logout" use:enhance>
  <button type="submit">ログアウト</button>
</form>

まとめ

ポイント 内容
hooks.server.ts の場所 src/ 直下。src/routes/ ではない
ファイル名 + なし。hooks.server.ts+hooks.server.ts ではない)
redirect の位置 try/catch の外に出す
Cookie の値 decodeURIComponent でデコードしてから cookies.set に渡す
Cookie の解析 Set-Cookie ヘッダーから namevalue だけ取り出す
ログアウト cookies.delete でセッション Cookie を削除する
← トップページに戻る