Appearance
認証 / 認可
参加者・運営・主催者・スタッフ全ロールを統合する認証基盤の設計。
✅ 決定事項
採用方針
- Source of Truth = Next.js (Better Auth + Drizzle adapter)
- CMS (Payload 等) は導入しない。認証は Better Auth が単独所有
- セッション Cookie は1本、運営/参加者/スタッフ問わず同じセッション基盤
- ライブラリ = Better Auth
- TypeScript ネイティブ、Plugin 構造、自前運用可、ライセンス MIT
- SaaS (Clerk / Auth0 / Supabase Auth) は不採用 (ロックイン回避)
- 5ロール:
participant/operator/partner/staff/admin(1 user テーブル +role列で表現) - ログイン入口は 2 つ:
/login(参加者) と/manage/login(運営) でブランド分離。backend は同じ Better Auth API を共有 - ログイン手段は段階導入 (Email/Password → Magic Link → OAuth → Passkey)
- DB は Postgres 1個 / 同一スキーマ。テーブル prefix なし (Better Auth の規定テーブル名 + 自前 master / business が同居)
ロール
| role | 日本語 | 主なスコープ |
|---|---|---|
participant | 参加者 | 申込 / 自分の申込履歴 / マイページ |
operator | 運営オペレータ | 全大会の作成・編集・公開 / 主催者管理 |
partner | 主催者 | 自分の Organizer の大会のみ作成・編集 (row-level access) |
staff | 当日スタッフ | 担当大会のスコア入力のみ |
admin | システム管理者 | 全権限 / /manage/masters/* 含む運営機能の full access |
partner は所属する Organizer の大会のみ編集可能。Better Auth user に organizerId を持たせ、Server Action / Hono ハンドラ内で currentUser.organizerId === tournament.organizerId を都度 check する (Drizzle クエリで条件追加 or アプリ層 guard)。
統合アーキテクチャ
┌──────────────────────── Browser ──────────────────────────┐
│ /login (参加者ログイン UI) │
│ /manage/login (運営ログイン UI) │
│ └─ どちらも Better Auth API に POST │
└─────────────────────────┬─────────────────────────────────┘
│ session cookie (HTTP-only / 1本)
▼
┌──────────────── Next.js (apps/web) ───────────────────────┐
│ │
│ /api/auth/[...all] ← Better Auth handler │
│ sign-in / sign-up / sign-out / oauth callback │
│ │
│ Server Components / Server Actions │
│ const session = await auth.api.getSession({ headers }) │
│ const user = session?.user │
│ if (user?.role !== "operator") notFound() │
│ │
└─────────────────────────┬───────────────────────────────────┘
│ packages/db (Drizzle)
▼
┌──────────────────────────┐
│ PostgreSQL │
│ (1 DB / 1 schema) │
│ ────────────────── │
│ user, session, │ ← Better Auth (規定テーブル)
│ account, verification │
│ │
│ levels, sports, ... │ ← master
│ media │ ← 自前
│ tournaments, │ ← business
│ tournament_plans, │
│ registrations, ... │
└──────────────────────────┘ロール / 認可の実装
ロール / organizerId は Better Auth の user.additionalFields で持たせる:
ts
// apps/web/src/lib/auth.ts
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { db } from "@liga/db";
import * as schema from "@liga/db/schema";
export const auth = betterAuth({
database: drizzleAdapter(db, { provider: "pg", schema }),
emailAndPassword: { enabled: true },
user: {
additionalFields: {
role: {
type: ["participant", "operator", "partner", "staff", "admin"],
required: true,
defaultValue: "participant",
input: false, // sign-up からはデフォルト固定 (admin が後から付与)
},
organizerId: {
type: "string",
required: false,
input: false,
},
},
},
});Server Action / Server Component 側はラッパーで gate:
ts
// apps/web/src/lib/auth-helpers.ts
import { headers } from "next/headers";
import { redirect } from "next/navigation";
import { auth } from "./auth";
export async function getCurrentUser() {
const session = await auth.api.getSession({ headers: await headers() });
return session?.user ?? null;
}
export async function requireUser(opts?: { role?: Role | Role[]; returnTo?: string }) {
const user = await getCurrentUser();
if (!user) {
redirect(opts?.returnTo ? `/login?redirect=${encodeURIComponent(opts.returnTo)}` : "/login");
}
const roles = Array.isArray(opts?.role) ? opts.role : opts?.role ? [opts.role] : null;
if (roles && !roles.includes(user.role)) redirect("/login");
return user;
}ログイン手段の段階導入
| Phase | 方式 | 対象 | 備考 |
|---|---|---|---|
| 1 | Email + Password | 全ユーザ | Better Auth 組み込み |
| 2 | Magic Link (メール) | 参加者中心 | パスワード忘れ防止 |
| 3 | OAuth (Google / LINE) | 参加者中心 | 登録の摩擦を下げる |
| 4 | Passkey (WebAuthn) | 運営 / 主催者 | 管理画面の安全性向上 |
| — | Two-Factor (TOTP) | Admin / Operator | Phase 1 でも optional 可 |
移行ステップ (Payload 内蔵 auth → Better Auth)
packages/dbを Drizzle で立ち上げ (PGlite / Cloud SQL 兼用)- Better Auth を
apps/webにインストール (Drizzle adapter) - Schema 追加:
user/session/account/verification(Better Auth 規定) + 独自role/organizerIdをadditionalFieldsで /api/auth/[...all]/route.tsにtoNextJsHandler(auth)を設置/login(参加者) //manage/login(運営) を Next.js で実装、submit は同じ Better Auth API へ- 既存の
lib/auth.ts(Payloadpayload.auth()経由) をauth.api.getSession()に置き換え requireUser({ role })ヘルパで Server Component / Action のゲート- Payload を撤去 (
payload.config.ts/app/(payload)//@payloadcms/*deps を削除) - OAuth プロバイダ (Google / LINE) を順次追加
設計上の注意
| 論点 | 結論 |
|---|---|
| DB | 1 Postgres / 1 schema / prefix なし。Better Auth 規定テーブル名 (user / session / account / verification) と業務テーブルが同居 |
| 主催者 row-level | Server Action / Hono ハンドラ内で currentUser.organizerId === doc.organizerId を check |
| Email verification | 参加者は必須、運営は招待制で省略可 |
| 旧サイトからの移行 | メールアドレスのみ移行 + 初回ログイン時にパスワード再設定 |
| セッション有効期限 | 参加者 30日 / 運営 8時間 + sliding |
補足
📂 ライブラリ4案の比較 (Better Auth / Auth.js / Lucia / SaaS)
A. Better Auth ← 採用
- TypeScript-first / Plugin 構造 / 自前運用
- Pros: 型が良い / Plugin で機能を選択追加 / Drizzle/Prisma adapter / Framework agnostic / MIT
- Cons: 新しめ (2024+) / 仕様変更追随が必要
B. Auth.js (NextAuth v5)
- Pros: OAuth プロバイダのカバレッジ広 / 日本語情報多い / DB session / JWT 両対応
- Cons: Magic Link / Passkey 等は別途 / Adapter+Provider の組合せが煩雑 / v5 (beta) で動く部分
C. Lucia v3
- Pros: 依存最小・透明 / OAuth / Passkey は arctic 等で個別連携
- Cons: Boilerplate 多い / メンテ縮小の話あり
D. SaaS (Clerk / Auth0 / Supabase Auth)
- Pros: ログイン UI / メール / OAuth 全部入り / 管理 UI 付
- Cons: ベンダーロックイン / アクティブ user 数で課金 / DB 共有しづらい
📂 設計原則 (5つ)
- Source of Truth は Next.js 側 (Better Auth) — セッション発行・OAuth・MFA をここで完結。CMS には依存しない
- セッション基盤は1本 — 参加者 / 運営 / 主催者 / スタッフを区別なく同一 Better Auth で扱う。Cookie 1本。ログイン UI は 2 つに分けてブランド統一 (
/loginダーク基調 = 参加者、/manage/loginライト基調 = 運営)、ただし backend は同じ - ロール / 権限は Better Auth user に持たせる —
additionalFieldsでrole/organizerIdを保持、Server Action / Hono ハンドラでcurrentUser.roleを見て判定 - ログイン UX は段階導入 — Phase 1 は Email/Password、その後 Magic Link / OAuth / Passkey を Plugin で順次追加
- ベンダーロックを避ける — SaaS Auth ではなく自前で持てる Better Auth、Postgres は出ない
📂 旧設計 (Payload Custom Auth Strategy) を不採用にした経緯
設計初期は Better Auth + Payload 同居 + Custom Auth Strategy を仮採用していた:
User
│ Login at /login (Next 自作 UI)
▼
Better Auth ─ session cookie 発行
▲
│ session 検証
Next.js Server Components / Server Action
│
│ Payload.auth.strategies = [betterAuthStrategy]
▼
Payload (data layer) ─ /admin も同セッションで入るこれを以下の理由で離脱し、Payload 自体を撤去した:
- Master が数十行レベルしかなく、Payload
/adminの auto-admin で得られる時短が、自作 admin UI 1〜2h × 5〜6 画面の労力を上回らない /adminのデザインが/manageのブランドと噛み合わず、運営の体感が分断する- Custom Auth Strategy が Better Auth / Payload のバージョンアップで微妙に壊れるリスク
- スキーマ二重管理 (Payload
push: true↔ Drizzle migrations) が常時オーバーヘッド
最終形は Better Auth + Drizzle のみ、Cookie 1本で /manage 配下を含めて完結する。Payload 撤去の全体経緯は サーバー構成 末尾の補足を参照。
📂 未決事項 (実装着手前に詰めたい論点)
- 主催者ロールの row-level access control を共通ヘルパに切り出すか (案:
requireOrganizerOwnership(tournamentId)をlib/auth-helpers.tsに) - Email verification は Phase 1 から必須にするか (推奨: 参加者は必須、運営は招待制で省略)
- 旧サイト (ligad-genius.jp) からの参加者移行 — メールアドレスのみ移行 + 初回ログイン時にパスワード再設定の流れにするか
- セッションの有効期限 (推奨: 参加者 30日、運営 8時間 + sliding)
- 運営の MFA (TOTP) を Phase 1 から入れるか (推奨: admin / operator は Phase 2 で必須化)