SvelteKitでGitHub OAuthログインを実装した話

固定パスワードによる簡易認証をGitHub OAuthを使った本格的な認証に置き換えました。 Arcticライブラリを使ってOAuthの実装をシンプルに保ちつつ、セッション管理にはCloudflare D1を使っています。

使用するライブラリ

  • Arctic — OAuth認証フローを抽象化するライブラリ。SvelteKit作者が開発しており、GitHubをはじめ多くのプロバイダに対応
  • Cloudflare D1 — ユーザー情報とセッションの保存先

全体の流れ

①ログインクリック → ②GitHub認証 → ③コールバック → ④セッション作成 → ⑤保護ルートへ

ファイル構成

src/
  lib/
    auth.ts                        # 新規:ArcticのGitHub設定・共通関数
  routes/
    login/
      +page.svelte                 # 変更:GitHubログインボタンに
      +page.server.ts              # 削除:パスワード認証を廃止
      github/
        +server.ts                 # 新規:GitHubへリダイレクト
        callback/
          +server.ts               # 新規:コールバック処理
  hooks.server.ts                  # 変更:D1セッションを参照
  app.d.ts                         # 変更:Locals型を更新
migrations/
  002_auth.sql                     # 新規:users・sessionsテーブル

Step 1: GitHub OAuthアプリを作成する

GitHub → Settings → Developer settings → OAuth Apps → New OAuth App で作成します。

項目
Homepage URL http://localhost:5173
Authorization callback URL http://localhost:8787/login/github/callback

作成後に Client IDClient Secret を取得します。

Step 2: 環境変数を設定する

# .env
GITHUB_CLIENT_ID=取得したClient ID
GITHUB_CLIENT_SECRET=取得したClient Secret

Step 3: D1にテーブルを追加する

-- migrations/002_auth.sql
CREATE TABLE IF NOT EXISTS users (
  id         TEXT PRIMARY KEY,
  github_id  INTEGER NOT NULL UNIQUE,
  name       TEXT NOT NULL
);

CREATE TABLE IF NOT EXISTS sessions (
  id         TEXT PRIMARY KEY,
  user_id    TEXT NOT NULL REFERENCES users(id),
  expires_at INTEGER NOT NULL
);
  • users.github_id — GitHubが発行する一意の数値ID。名前を変えても変わらないため同一人物の識別に使う
  • sessions.expires_at — Unixタイムスタンプで保存する有効期限

ローカルとリモートの両方に実行します。

npx wrangler d1 execute sveltekit-blog-db --local --file=migrations/002_auth.sql
npx wrangler d1 execute sveltekit-blog-db --remote --file=migrations/002_auth.sql

Step 4: src/lib/auth.ts を作成する

// src/lib/auth.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:8787/login/github/callback'
);

export function generateId(): string {
  return crypto.randomUUID();
}

export function createExpiresAt(): Date {
  const date = new Date();
  date.setDate(date.getDate() + 30);
  return date;
}

Step 5: GitHubへリダイレクトするエンドポイント

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

export const GET: RequestHandler = async ({ cookies }) => {
  const state = generateState();

  cookies.set('github_oauth_state', state, {
    path: '/',
    httpOnly: true,
    maxAge: 60 * 10,
  });

  const url = github.createAuthorizationURL(state, ['user:email']);
  redirect(302, url.toString());
};

state はCSRF対策用のランダム文字列です。Cookieに保存しておきコールバック時に検証します。

Step 6: コールバックエンドポイント

GitHubが /login/github/callback?code=...&state=... にリダイレクトしてきます。

// src/routes/login/github/callback/+server.ts
import { redirect, error } from '@sveltejs/kit';
import { github, generateId, createExpiresAt } from '$lib/auth';
import type { RequestHandler } from './$types';

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

  // stateの検証(CSRF対策)
  if (!code || !state || !storedState || state !== storedState) {
    error(400, 'Invalid OAuth state');
  }

  // codeをアクセストークンに交換
  const tokens = await github.validateAuthorizationCode(code);
  const accessToken = tokens.accessToken();

  // GitHubのAPIでユーザー情報を取得
  // User-Agentヘッダーが必須(ないと403が返る)
  const githubRes = await fetch('https://api.github.com/user', {
    headers: {
      Authorization: `Bearer ${accessToken}`,
      'User-Agent': 'sveltekit-blog'
    }
  });
  const githubUser = await githubRes.json() as { id: number; name: string };

  const db = platform?.env.DB;

  // D1にユーザーが存在しなければ新規登録
  let user = await db?.prepare('SELECT * FROM users WHERE github_id = ?')
    .bind(githubUser.id)
    .first<{ id: string; name: string }>();

  if (!user) {
    const userId = generateId();
    await db?.prepare('INSERT INTO users (id, github_id, name) VALUES (?, ?, ?)')
      .bind(userId, githubUser.id, githubUser.name)
      .run();
    user = { id: userId, name: githubUser.name };
  }

  // セッションを作成してCookieにセット
  const sessionId = generateId();
  const expiresAt = createExpiresAt();

  await db?.prepare('INSERT INTO sessions (id, user_id, expires_at) VALUES (?, ?, ?)')
    .bind(sessionId, user.id, Math.floor(expiresAt.getTime() / 1000))
    .run();

  cookies.set('session_id', sessionId, {
    path: '/',
    httpOnly: true,
    maxAge: 60 * 60 * 24 * 30,
  });

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

ハマりポイント:GitHub APIのUser-Agentヘッダー

GitHub APIは User-Agent ヘッダーが必須です。省略すると403が返り、JSONではないレスポンスを .json() で解析しようとして SyntaxError になります。

Step 7: hooks.server.ts をD1セッション参照に変更

// src/hooks.server.ts
import type { Handle } from '@sveltejs/kit';

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

  if (sessionId) {
    const db = event.platform?.env.DB;
    const session = await db?.prepare(
      'SELECT sessions.id, users.name FROM sessions JOIN users ON sessions.user_id = users.id WHERE sessions.id = ? AND sessions.expires_at > ?'
    )
      .bind(sessionId, Math.floor(Date.now() / 1000))
      .first<{ id: string; name: string }>();

    if (session) {
      event.locals.user = { name: session.name };
    } else {
      event.locals.user = null;
      event.cookies.delete('session_id', { path: '/' });
    }
  } else {
    event.locals.user = null;
  }

  return resolve(event);
};

expires_at > 現在時刻 で有効期限も同時にチェックします。セッションが無効な場合は古いCookieも削除します。

Step 8: ログインページを修正する

<!-- src/routes/login/+page.svelte -->
<h1>ログイン</h1>

<a href="/login/github">GitHubでログイン</a>

パスワード認証の +page.server.ts はファイルごと削除します。

認証の流れ(詳細)

① /login/github にアクセス
   → stateを生成してCookieに保存
   → GitHubの認証URLにリダイレクト

② GitHubでユーザーが許可
   → /login/github/callback?code=...&state=... にリダイレクト

③ コールバック処理
   → CookieのstateとURLのstateを比較(CSRF対策)
   → codeをアクセストークンに交換(サーバー間通信)
   → GitHubのAPIでユーザー情報取得
   → D1のusersテーブルで初回なら新規登録
   → D1のsessionsテーブルにセッションを作成
   → CookieにセッションIDをセット
   → /todo にリダイレクト

④ 以降のリクエスト
   → hooks.server.tsがCookieのセッションIDでD1を検索
   → 有効なセッションがあればlocals.userにセット

まとめ

概念 内容
generateState() CSRF対策用のランダム文字列
validateAuthorizationCode() codeをアクセストークンに交換
User-Agent ヘッダー GitHub API必須。省略するとSyntaxErrorになる
github_id GitHubのユーザーID。名前変更に依存しない同一人物識別子
セッションIDのみCookieに保存 ユーザー情報はD1のJOINで取得。Cookieが盗まれてもサーバー側で無効化できる
expires_at Unixタイムスタンプで保存してSQLの > 演算子で有効期限チェック
← トップページに戻る