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 × Next.js App Routerで学ぶ「認証」の実装とよくある処理

0
Posted at

はじめに

前回の記事では、Supabase を題材に 認証と認可の違いRLSAPI キーの使い分け について整理しました。

今回はその続きとして、Next.js(App Router)+ Supabase認証処理をどう書くか に焦点を当てます。

個人開発を進める中で、私が最初につまずいたのは次の2点でした。

  • ログイン・ログアウトの API の呼び方 は分かるが、セッションをどこでどう扱うか が曖昧
  • 「ログインしているか?」の分岐を クライアントとサーバー、どちらに書くべきか が分からない

本記事では、Supabase 公式ドキュメントおよび公式 Example のコード をベースに、私が読み解いた内容を解説します。

この記事で得られる観点

  • ブラウザ用 / サーバー用 クライアントの役割
  • Proxy でセッションを更新する理由
  • 公式ドキュメントからわかるサインアップ・ログイン・ログアウトの実装方法
  • getClaims() / getUser() / getSession()使い分け
  • 「未ログインならリダイレクト」など よく書く処理 の書き方と置き場所

前提

公式ドキュメント Creating a Supabase client for SSR に従い、次を導入します。

npm install @supabase/supabase-js @supabase/ssr

環境変数(.env.local)は公式 Example と同じ形式です。

NEXT_PUBLIC_SUPABASE_URL=supabase_project_url
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=supabase_publishable_key

ダッシュボード上では anon key と表示されている場合もありますが、クライアントに公開してよいキーはこの Publishable key です(service_role は使いません)。

なぜクライアントを2種類用意するのか

Supabase 公式ドキュメントでは、Next.js App Router 向けに次の2種類のクライアントが必要だと説明されています。

  1. Client Component client — ブラウザ上で動くコード向け
  2. Server Component client — Server Component、Server Actions、Route Handler 向け

引用元: Creating a Supabase client for SSR(Next.js)

種類 ファイル例 使う場所
ブラウザ用 lib/supabase/client.ts Client Component、ログインフォーム
サーバー用 lib/supabase/server.ts Server Component、Route Handler

Server Componentだけでは Cookieを書き込めない ため、
期限切れの Auth トークンを更新するためのProxyが必要です。

Proxy の役割

Proxyの役割は主に以下のとおりです。

  1. supabase.auth.getClaims() で Auth トークンを検証し、必要に応じて更新
  2. 更新したトークンを Server Component へ渡す(request.cookies.set
  3. 更新したトークンをブラウザへ渡す(response.cookies.set

ブラウザ用クライアント

以下は Supabase 公式ドキュメント(client.ts) のコードです。
lib/supabase/client.tsに配置します。

import { createBrowserClient } from '@supabase/ssr'

export function createClient() {
  return createBrowserClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!
  )
}
  • createBrowserClientブラウザ専用 の Supabase クライアントを返します
  • ログイン・ログアウト、Client Component からの Auth 操作は このクライアント 経由で行います

サーバー用クライアント

以下は Supabase 公式ドキュメント(server.ts) のコードです。
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_PUBLISHABLE_KEY!,
    {
      cookies: {
        getAll() {
          return cookieStore.getAll()
        },
        setAll(cookiesToSet, _headers) {
          try {
            cookiesToSet.forEach(({ name, value, options }) =>
              cookieStore.set(name, value, options)
            )
          } catch {
            // The `setAll` method was called from a Server Component.
            // This can be ignored if you have proxy refreshing
            // user sessions.
          }
        },
      },
    }
  )
}
  • cookies() は Next.js がリクエストごとに提供するCookie ストアです(公式: cookies
  • getAll / setAll は、Supabase が セッション用 JWT を Cookie に読み書きする ためのフックです
  • setAlltry/catch は公式コメントどおり、Server Component から Cookie を書けない場合がある ためのものです
  • その場合、セッション更新は後述の Proxy が担います

Proxy でセッションを更新する

以下は Supabase 公式ドキュメントのコードです。

updateSession(lib/supabase/proxy.ts)

Supabase 公式ドキュメント(lib/supabase/proxy.ts) より。

import { createServerClient } from '@supabase/ssr'
import { NextResponse, type NextRequest } from 'next/server'

export async function updateSession(request: NextRequest) {
  let supabaseResponse = NextResponse.next({
    request,
  })

  const supabase = createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
    {
      cookies: {
        getAll() {
          return request.cookies.getAll()
        },
        setAll(cookiesToSet, headers) {
          cookiesToSet.forEach(({ name, value }) => request.cookies.set(name, value))
          supabaseResponse = NextResponse.next({
            request,
          })
          cookiesToSet.forEach(({ name, value, options }) =>
            supabaseResponse.cookies.set(name, value, options)
          )
          Object.entries(headers).forEach(([key, value]) =>
            supabaseResponse.headers.set(key, value)
          )
        },
      },
    }
  )

  // IMPORTANT: If you remove getClaims() and you use server-side rendering
  // with the Supabase client, your users may be randomly logged out.
  const { data } = await supabase.auth.getClaims()

  const user = data?.claims

  if (
    !user &&
    !request.nextUrl.pathname.startsWith('/login') &&
    !request.nextUrl.pathname.startsWith('/auth')
  ) {
    const url = request.nextUrl.clone()
    url.pathname = '/login'
    return NextResponse.redirect(url)
  }

  return supabaseResponse
}
  • createServerClientgetClaims() の間に 余計な処理を挟まない
  • getClaims() を呼ぶことでAuth トークンを検証し、必要に応じて更新します
  • 公式ドキュメントでは、未ログイン時に /login へリダイレクトする ページ保護 もここに含まれています
  • 返却する supabaseResponse は、Cookie を書き換えたレスポンスそのもの です。別オブジェクトに差し替える場合は公式コメントの手順に従う必要があります

プロジェクトルートの proxy.ts

Supabase 公式 Example(proxy.ts) より。

import { type NextRequest } from 'next/server'
import { updateSession } from '@/lib/supabase/proxy'

export async function proxy(request: NextRequest) {
  return await updateSession(request)
}

export const config = {
  matcher: [
    '/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
  ],
}
  • Next.js の Proxy公式: Proxy)に updateSession を接続しています
  • リクエスト前後でCookieを読み書きし、セッション更新を行います
    ※公式ドキュメントでは、ここに未ログイン時のページガードも組み合わせています
  • matcher により、静的ファイル以外のリクエストで Proxy が走ります

サインアップ・ログイン・ログアウト

Auth API自体は Password-based Auth および JavaScript リファレンス のコードが基準です。
SSR 構成では、ブラウザ用 createClient() から呼びます。

サインアップ

公式: signUp より

const supabase = createClient()

const { data, error } = await supabase.auth.signUp({
  email: 'example@email.com',
  password: 'example-password',
})
  • ホスト型 Supabase では メール確認が有効 なことが多く、確認後にログイン可能になります
  • メール確認後のトークン交換には、公式どおり app/auth/confirm/route.ts 等の Route Handler が必要です(Password-based Auth: PKCE flow

ログイン

公式: signInWithPassword より。

const supabase = createClient()

const { data, error } = await supabase.auth.signInWithPassword({
  email: 'example@email.com',
  password: 'example-password',
})
  • 成功すると JWT が Cookie に保存され、以降のリクエストに自動で付与されます
  • @supabase/ssr が Cookie への保存を担当するため、localStorage へ手動で token を書く必要はありません

ログアウト

公式: signOut より。

const supabase = createClient()

const { error } = await supabase.auth.signOut({ scope: 'local' })

解説

  • デフォルトの scope: 'global'全デバイスからログアウト します
  • 多くのアプリでは 現在のセッションだけ 終了したいため、公式も { scope: 'local' } の例を示しています

よく書く処理

公式が示す3つの API

Supabase 公式ドキュメントでは、Auth のユーザー確認に次の3つがあると整理されています(Creating a Supabase client for SSR)。

API 通信の有無 用途(公式の整理)
getClaims() 通常はAuthサーバーへのユーザー問い合わせなし(JWTを検証) ページやデータの 保護。Proxy やページガード向き
getUser() Auth サーバーへ ネットワーク通信あり 最新のユーザー情報 が必要なとき。確実だが通信コストがある
getSession() セッション情報を取得する。サーバー側でuser情報だけを認可判断に利用しない access / refresh token 自体が必要なとき。サーバーでの認可判断には user を信頼しない

ページ保護にはgetClaims()
最新のユーザー属性が必要ならgetUser()
という使い分けが公式の推奨です。

「通信が発生するかどうか」の視点で選ぶと、
Proxy では getClaims()
メールアドレス表示など確実性が欲しい場面では getUser()
と整理できます。

ページを保護するとは?

「マイページ」のように、ログインした人だけが見られる画面を作ることです。
具体的には、ページを表示する前にログイン状態を確認し、未ログインならログイン画面へ送り返します。
ボタンを非表示にするだけでは URL を直接入力されると素通りされるため、表示前のサーバー側で確認するのがポイントです。

サーバーでページを保護する

Proxyのサンプルコードに加え、個別ページでもServer Component内で確認できます。

import { redirect } from 'next/navigation'
import { createClient } from '@/lib/supabase/server'

export default async function DashboardPage() {
  const supabase = await createClient()
  const { data } = await supabase.auth.getClaims()

  if (!data?.claims) {
    redirect('/login')
  }

  return <div>マイページ</div>
}
  • 公式は サーバー側で getSession() だけを信頼しない よう警告しています(Cookie は改ざん可能なため)
  • getClaims() は JWT 署名を検証するため、ページガード向き です

クライアントで UI を出し分ける

ヘッダーの「ログイン / ログアウト」切り替えなど、表示だけ なら Client Component で createClient() を使います。
ここは公式ドキュメントには単独ファイルがありませんが、Auth API の組み合わせです。

'use client'

import { createClient } from '@/lib/supabase/client'

export function LoginButton() {
  const supabase = createClient()

  async function handleSignOut() {
    await supabase.auth.signOut({ scope: 'local' })
  }

  return (
    <button type="button" onClick={handleSignOut}>
      ログアウト
    </button>
  )
}

処理ごとの置き場所の目安

やりたいこと 推奨する場所 使う API
未ログインならページ自体を見せない Proxy または Server Component getClaims()
ボタン・メニューの表示切替 Client Component signOut
最新の user.email 等をサーバーで表示 Server Component getUser()
他人のデータを読めないようにする RLS(DB) auth.uid()(前回整理)

INSERT 時に user_id を付ける

「ログインユーザーが自分のレシピを登録する」場合、user_id には サーバーで取得したユーザー ID を使います。

const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()

if (!user) {
  throw new Error('未ログイン')
}

await supabase.from('recipes').insert({
  title,
  user_id: user.id,
})

解説

  • ここでは Auth サーバー確認付きの getUser() を使っています
  • アプリ側で user_id を付けても、RLS で INSERT を縛っていなければ 改ざんを防げません
  • auth.uid() = user_id のポリシーを DB に置くことで、クライアントの注意に頼らない形にできます

よくあるアンチパターン

1. サーバーで getSession() だけ使ってページを保護している
→JWTを介して検証していない

2. Proxy から getClaims() を削除している
→ProxyのキモとなるgetClaims()を消すとトークン更新ができない

3. クライアントの if (user) だけでページを保護している
→クライアントの表示制御だけではページ保護にならない。サーバー側でgetClaims()などを使って認証状態を検証する

4. ログイン済みだから RLS を後回しにしている
→認証と認可は別概念のため、認可となるRLSは開発者が設定する必要がある

5. Server Component 内で Supabase クライアントを使い回している
リクエストごとにクライアントを生成する ことが推奨されています

最後に

SupabaseのAuth API自体はシンプルですが、
Next.js App RouterではCookie・Proxy・Server / Client の境界を公式どおりに組み立てる必要があります。

私が整理して得たのは、次の順番です。

  1. 公式ドキュメントの client.ts / server.ts / proxy.ts をそのまま土台にする
  2. ログイン操作は Client、ページのガードは getClaims() に寄せる
  3. データの安全性は RLS に委ねる(認証の実装とセット)

前回の記事で「認証と認可の境界」を押さえ、今回の記事で「公式に沿った認証の書き方」を足すと、個人開発でも一通りの土台ができます。

参考リンク

Supabase

Next.js

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?