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 ID と Client 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の > 演算子で有効期限チェック |