0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Supabase Authの認証パターン比較 — Email/OAuth/Magic Linkの実装差と、E2Eテストで詰まらない選び方

0
Posted at

はじめに:認証方式は「ユーザー体験」より先に「自動化できるか」で決まった

当社(合同会社ジョインクラス)は、社員ゼロ・AIエージェントで事業を回している小さな会社です。開発スタックは Next.js / TypeScript / Supabase で、自社SaaS「PasPoy」もこの構成で動いています。

PasPoyでは紹介動画用の「実際の画面」を、Playwrightで自動録画しています。このスクリプトを書いたときに痛感したのが、認証方式によって自動化の難易度がまるで違うということでした。Email+パスワードなら4行でログインできるのに、OAuthやMagic Linkだと外部のIdPやメールボックスが絡んできて、そのままでは自動化できません。

この記事では、Supabase Authの3方式(Email+パスワード / OAuth / Magic Link)について、

  • 実装コード(Next.js App Router + @supabase/ssr)
  • それぞれの落とし穴
  • Playwrightでの検証方法(当社で実際に動いているスクリプトがベース)

を並べて比較します。読み終えたらその日のうちに、ローカルSupabaseで3方式すべてを試せる粒度で書いています。

3方式の比較表

観点 Email+パスワード OAuth(GitHub/Google) Magic Link
実装量 少 中(コールバック必須) 中(コールバック必須)
ユーザー体験 パスワード管理が負担 ワンクリック メールを開く手間
パスワード漏えいリスク あり なし なし
E2Eテストの自動化 容易 難(IdPのbot判定) ローカルなら容易(Inbucket/Mailpit)
外部依存 なし IdPのアプリ登録 SMTP(本番はレート制限に注意)
メール到達性の影響 確認メールのみ なし ログインのたびに受ける

結論を先に書くと、当社は「本番はOAuth + Magic Link、テスト/デモ用アカウントはEmail+パスワード」という併用にしています。理由は後半の検証パートで説明します。

前提:ローカルSupabaseを立ち上げる

npm i @supabase/supabase-js @supabase/ssr
npx supabase init
npx supabase start
# 出力される API URL / anon key を .env.local に入れる
# Inbucket(またはMailpit) URL: http://127.0.0.1:54324  ← Magic Linkの検証で使う
.env.local
NEXT_PUBLIC_SUPABASE_URL=http://127.0.0.1:54321
NEXT_PUBLIC_SUPABASE_ANON_KEY=<supabase start の出力>
NEXT_PUBLIC_SITE_URL=http://localhost:3000

OAuthとMagic Linkはリダイレクト先を許可リストに入れる必要があります。ローカルでは supabase/config.toml で設定します。

supabase/config.toml
[auth]
site_url = "http://localhost:3000"
additional_redirect_urls = ["http://localhost:3000/auth/callback"]

[auth.email]
enable_confirmations = false  # ローカルでは確認メールを省略

[auth.external.github]
enabled = true
client_id = "env(SUPABASE_AUTH_GITHUB_CLIENT_ID)"
secret = "env(SUPABASE_AUTH_GITHUB_SECRET)"
redirect_uri = "http://127.0.0.1:54321/auth/v1/callback"

落とし穴1: GitHub側のOAuth Appに登録するCallback URLは、アプリの /auth/callback ではなく SupabaseのURL(.../auth/v1/callback) です。ここを間違えると redirect_uri mismatch で止まります。

共通部品:サーバー側クライアント

3方式すべてで使います。Cookieにセッションを保存するため @supabase/ssr を使います。

lib/supabase/server.ts
import { createServerClient } from '@supabase/ssr';
import { cookies } from 'next/headers';

export async function createClient() {
  const cookieStore = await cookies();
  return createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      cookies: {
        getAll: () => cookieStore.getAll(),
        setAll: (cookiesToSet) => {
          cookiesToSet.forEach(({ name, value, options }) =>
            cookieStore.set(name, value, options),
          );
        },
      },
    },
  );
}

パターン1:Email + パスワード

最もシンプルです。Server Actionで完結し、コールバックルートも不要です。

app/login/actions.ts
'use server';
import { redirect } from 'next/navigation';
import { createClient } from '@/lib/supabase/server';

export async function signInWithPassword(formData: FormData) {
  const supabase = await createClient();
  const { error } = await supabase.auth.signInWithPassword({
    email: String(formData.get('email')),
    password: String(formData.get('password')),
  });
  if (error) redirect('/login?error=invalid_credentials');
  redirect('/dashboard');
}
app/login/page.tsx
import { signInWithPassword } from './actions';

export default function LoginPage() {
  return (
    <form action={signInWithPassword}>
      <input name="email" type="email" required />
      <input name="password" type="password" required />
      <button type="submit">ログイン</button>
    </form>
  );
}

落とし穴2: エラーメッセージを error.message のまま返すと、「メールアドレスが存在しない」のか「パスワードが違う」のかを区別できる実装になりがちです(ユーザー列挙)。上のように固定のエラーコードに丸めます。

パターン2:OAuth(GitHub / Google)

OAuthは「IdPへリダイレクト → Supabaseへ戻る → アプリの /auth/callback でcodeをセッションに交換」の3段階です。PKCEフローなので コールバックルートが必須 です。

app/login/actions.ts(追記)
export async function signInWithGitHub() {
  const supabase = await createClient();
  const { data, error } = await supabase.auth.signInWithOAuth({
    provider: 'github',
    options: { redirectTo: `${process.env.NEXT_PUBLIC_SITE_URL}/auth/callback` },
  });
  if (error || !data.url) redirect('/login?error=oauth');
  redirect(data.url);
}
app/auth/callback/route.ts
import { NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';

export async function GET(request: Request) {
  const { searchParams, origin } = new URL(request.url);
  const code = searchParams.get('code');
  const next = searchParams.get('next') ?? '/dashboard';

  if (!code) return NextResponse.redirect(`${origin}/login?error=no_code`);

  const supabase = await createClient();
  const { error } = await supabase.auth.exchangeCodeForSession(code);
  if (error) return NextResponse.redirect(`${origin}/login?error=exchange`);

  // オープンリダイレクト対策:相対パスのみ許可
  const safeNext = next.startsWith('/') && !next.startsWith('//') ? next : '/dashboard';
  return NextResponse.redirect(`${origin}${safeNext}`);
}

落とし穴3: next パラメータをそのままリダイレクトに使うと、?next=//evil.example でオープンリダイレクトになります。上の safeNext のように必ず検証します。

落とし穴4: PKCEのcode verifierはCookieに入ります。localhost と 127.0.0.1 を混在させるとCookieが別ドメイン扱いになり、exchangeCodeForSession が code verifier not found で失敗します。site_url とブラウザで開くURLのホストを揃えてください。

パターン3:Magic Link(OTP)

パスワード不要でメールのリンクからログインします。実はコールバックはOAuthと同じ /auth/callback を共用できます(PKCEで code が返ってくるため)。

app/login/actions.ts(追記)
export async function signInWithMagicLink(formData: FormData) {
  const supabase = await createClient();
  const { error } = await supabase.auth.signInWithOtp({
    email: String(formData.get('email')),
    options: {
      emailRedirectTo: `${process.env.NEXT_PUBLIC_SITE_URL}/auth/callback`,
      shouldCreateUser: false, // 招待制なら false。未登録メールで勝手にユーザーが作られない
    },
  });
  // 存在有無を漏らさないため、成功・失敗に関わらず同じ画面へ
  if (error) console.error('[magic-link]', error.message);
  redirect('/login/check-email');
}

落とし穴5: リンクを別のブラウザ(例:PCで申請してスマホのメールアプリで開く)で開くと、code verifierのCookieが無いため失敗します。ユーザーからの問い合わせが一番多いのがこれです。対策として、メールテンプレートを6桁OTPコード入力方式({{ .Token }} を表示し verifyOtp({ email, token, type: 'email' }) で検証)に切り替える手があります。

落とし穴6: Supabase標準のSMTPは送信数の制限が厳しく、本番でMagic Linkを主軸にするなら独自SMTP(Resend / SendGrid等)の設定が事実上必須です。ログインのたびにメールが飛ぶので、到達率がそのままログイン成功率になります。

検証:Playwrightで3方式をテストする

ここが本題です。冒頭で触れたPasPoyの画面録画スクリプトでは、Email+パスワードのデモアカウントで次のようにログインしています(実運用中のコードから抜粋)。

record-screens.mjs(抜粋)
import { chromium, devices } from '@playwright/test';

const BASE = process.env.PASPOY_BASE_URL ?? 'http://localhost:3100';
const EMAIL = 'demo1@test.local';
const PASSWORD = 'demo-password-123';

async function login(page) {
  await page.goto(`${BASE}/login`, { waitUntil: 'networkidle' });
  await page.fill('input[name="email"]', EMAIL);
  await page.fill('input[name="password"]', PASSWORD);
  await page.click('button[type="submit"]');
  await page.waitForURL((url) => !url.pathname.startsWith('/login'), { timeout: 15000 });
}

ポイントは2つです。

  • デモユーザーは npm run seed:local でローカルSupabaseに投入しておく(本番の認証情報をスクリプトに書かない)
  • ログイン完了判定は「特定URLへの遷移」ではなく 「/loginから離れたこと」 で待つ。リダイレクト先を変えてもテストが壊れない

シード用のユーザー作成は、service_role キーで Admin API を使うと確認メールをスキップできます。

scripts/seed-local.ts
import { createClient } from '@supabase/supabase-js';

const admin = createClient(
  'http://127.0.0.1:54321',
  process.env.SUPABASE_SERVICE_ROLE_KEY!, // ローカル専用。本番キーは絶対に使わない
);

const { error } = await admin.auth.admin.createUser({
  email: 'demo1@test.local',
  password: 'demo-password-123',
  email_confirm: true,
});
if (error && !error.message.includes('already')) throw error;

Magic Linkはローカルのメール受信箱から拾う

supabase start で立ち上がるInbucket(新しいCLIではMailpit)にはHTTP APIがあるので、メール本文からリンクを抜き出せます。以下はMailpitの例です。

e2e/magic-link.spec.ts
import { test, expect } from '@playwright/test';

const MAIL_API = 'http://127.0.0.1:54324/api/v1';

test('Magic Linkでログインできる', async ({ page, request }) => {
  await request.delete(`${MAIL_API}/messages`); // 受信箱を空にする

  await page.goto('/login');
  await page.fill('input[name="email"]', 'demo1@test.local');
  await page.click('button:has-text("メールでログイン")');
  await expect(page).toHaveURL(/check-email/);

  const list = await (await request.get(`${MAIL_API}/messages`)).json();
  const msg = await (await request.get(`${MAIL_API}/message/${list.messages[0].ID}`)).json();
  const link = msg.Text.match(/https?:\/\/\S+verify\S+/)?.[0];
  expect(link).toBeTruthy();

  await page.goto(link!); // 同じブラウザコンテキストで開く=落とし穴5を回避
  await page.waitForURL((url) => !url.pathname.startsWith('/login'));
});

OAuthはE2Eで「通さない」

GitHub/Googleのログイン画面を自動操作するのは、IdP側のbot判定に引っかかりやすく、テストが不安定になります。当社では次のように割り切っています。

  • E2E:OAuthボタンを押して IdPのURLへ遷移すること までを確認
  • コールバック以降:/auth/callback のルートハンドラを単体テスト(code なし・不正 next の分岐)
  • 実際のOAuthログイン:リリース前に人間が1回だけ確認
e2e/oauth.spec.ts
test('GitHubログインはIdPへ遷移する', async ({ page }) => {
  await page.goto('/login');
  await page.click('button:has-text("GitHubでログイン")');
  await page.waitForURL(/github\.com\/login/);
});

どれを選ぶべきか

当社の判断基準をまとめます。

  1. BtoB・社内ツール:Google/Microsoft OAuth一択。パスワードを持たないのが最大のセキュリティ対策
  2. BtoC・低頻度利用:Magic Link(OTPコード方式)。パスワードを忘れる層に強い。ただし独自SMTPは必須
  3. どの方式を選んでも:テスト/デモ用にEmail+パスワードを残す。ただし本番では signUp を閉じ、Admin APIで作ったアカウントだけにする

3方式は排他ではなく、同じ /auth/callback と同じ auth.users に乗るので、併用コストは想像より小さいです。

まとめ

  • Email+パスワードは実装最小・自動化最強。ただしユーザー列挙とパスワード管理に注意
  • OAuthとMagic Linkはコールバックルートを共用でき、PKCEのCookie(ホスト不一致・別ブラウザ)が最大の落とし穴
  • next パラメータのオープンリダイレクト対策を忘れない
  • E2Eは「Email+パスワードでシード」「Magic Linkはローカル受信箱から拾う」「OAuthはIdP遷移まで」で安定する

僕自身、「認証を自動テストできるか」を最初に考えるようになってから、AIエージェントにE2Eを任せられる範囲が一気に広がりました。人間が毎回ログインして確認する工程が残っていると、自動化はそこで止まります。

参考書籍

認証まわりの設計は、そのまま「AIエージェントに何の権限を渡すか」という問題にもつながります。service_roleキーの扱い、シークレットの置き場所、エージェントに触らせてよい範囲などを、当社の運用ルールとしてまとめたのがこちらの本です。

  • 『企業のためのClaude Codeセキュリティガイド』
  • Playwright等での自動検証をcron/Hooksで回す話は『Claude Code 全自動化バイブル』で扱っています

書籍一覧: https://zenn.dev/joinclass?tab=books

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?