Skip to content

認証 / 認可

参加者・運営・主催者・スタッフ全ロールを統合する認証基盤の設計。

✅ 決定事項

採用方針

  • 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方式対象備考
1Email + Password全ユーザBetter Auth 組み込み
2Magic Link (メール)参加者中心パスワード忘れ防止
3OAuth (Google / LINE)参加者中心登録の摩擦を下げる
4Passkey (WebAuthn)運営 / 主催者管理画面の安全性向上
Two-Factor (TOTP)Admin / OperatorPhase 1 でも optional 可

移行ステップ (Payload 内蔵 auth → Better Auth)

  1. packages/db を Drizzle で立ち上げ (PGlite / Cloud SQL 兼用)
  2. Better Auth を apps/web にインストール (Drizzle adapter)
  3. Schema 追加: user / session / account / verification (Better Auth 規定) + 独自 role / organizerIdadditionalFields
  4. /api/auth/[...all]/route.tstoNextJsHandler(auth) を設置
  5. /login (参加者) / /manage/login (運営) を Next.js で実装、submit は同じ Better Auth API へ
  6. 既存の lib/auth.ts (Payload payload.auth() 経由) を auth.api.getSession() に置き換え
  7. requireUser({ role }) ヘルパで Server Component / Action のゲート
  8. Payload を撤去 (payload.config.ts / app/(payload)/ / @payloadcms/* deps を削除)
  9. OAuth プロバイダ (Google / LINE) を順次追加

設計上の注意

論点結論
DB1 Postgres / 1 schema / prefix なし。Better Auth 規定テーブル名 (user / session / account / verification) と業務テーブルが同居
主催者 row-levelServer 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つ)
  1. Source of Truth は Next.js 側 (Better Auth) — セッション発行・OAuth・MFA をここで完結。CMS には依存しない
  2. セッション基盤は1本 — 参加者 / 運営 / 主催者 / スタッフを区別なく同一 Better Auth で扱う。Cookie 1本。ログイン UI は 2 つに分けてブランド統一 (/login ダーク基調 = 参加者、/manage/login ライト基調 = 運営)、ただし backend は同じ
  3. ロール / 権限は Better Auth user に持たせるadditionalFieldsrole / organizerId を保持、Server Action / Hono ハンドラで currentUser.role を見て判定
  4. ログイン UX は段階導入 — Phase 1 は Email/Password、その後 Magic Link / OAuth / Passkey を Plugin で順次追加
  5. ベンダーロックを避ける — 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 自体を撤去した:

  1. Master が数十行レベルしかなく、Payload /admin の auto-admin で得られる時短が、自作 admin UI 1〜2h × 5〜6 画面の労力を上回らない
  2. /admin のデザインが /manage のブランドと噛み合わず、運営の体感が分断する
  3. Custom Auth Strategy が Better Auth / Payload のバージョンアップで微妙に壊れるリスク
  4. スキーマ二重管理 (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 で必須化)