Open29

Supabase Auth を Next.js の App router で使用する

kutakutatkutakutat

Next.js で使用するライブラリは Supabase から2 つ提供されている

公式としては @supabase/ssr を推奨している
公式ドキュメントの中でも @supabase/auth-helpers-nextjs を使用した例が多いので注意する

サーバー側の言語やフレームワーク (Next.js、SvelteKit、Remix など) で Supabase を使用する場合、ユーザー セッションの保存に Cookie を使用するように Supabase クライアントを構成することが重要です。このプロセスをできるだけシンプルにするために、 @supabase/ssr パッケージを開発しました。このパッケージは現在 beta にあります。導入が推奨されますが、API はまだ不安定であり、将来的に重大な変更が加えられる可能性があることに注意してください。
Google 翻訳

https://supabase.com/docs/guides/auth/server-side/overview

kutakutatkutakutat

Supabase Auth で認証すると主に以下の 2 つが発行される

  • JWT 形式のアクセストークン
  • リフレッシュトークン

アクセストークンは次のような形式
このアクセストークンはデモ用で公式ドキュメントに記載されているもの
※みやすさのために改行

eyJhbGciOiJIUzI1NiJ9
.eyJzdWIiOiIwMDAxIiwibmFtZSI6IlNhbSBWaW1lcyIsImlhdCI6MTUxNjIzOTAyMiwiZXhwIjoxNTE4MjM5MDIyfQ
.zMcHjKlkGhuVsiPIkyAkB2rjXzyzJsMMgpvEGvGtjvA

前から header, payload, 署名に対応
署名は header, payload, 秘密鍵を header に指定されたハッシュ関数でハッシュ化したもの

アクセス トークンの有効期間は短く設計し、リフレッシュ トークンは期限切れになることはなく一度しか使用できないようにしている
リフレッシュトークンを交換して、新しいアクセス トークンとリフレッシュ トークンのペアを取得できる

kutakutatkutakutat

ここで得たアクセストークンとクライアント側から Supabase API ゲートウェイをパスするために使用される JWT である anon key と組み合わせて、例えば次のように Supabase の API にアクセスできる

curl 'https://${project_ref}.supabase.co/rest/v1/colors?select=name' \
-H "apikey: ${anone_key}" \
-H "Authorization: Bearer ${youur_access_token}"

kutakutatkutakutat

アクセストークンの Payload には例えば次のようにデータが格納されている
session_id などはサンプル

{
  "aud": "authenticated",
  "exp": 1615824388,
  "sub": "0334744a-f2a2-4aba-8c8a-6e748f62a172",
  "email": "d.l.solove@gmail.com",
  "app_metadata": {
    "provider": "email"
  },
  "user_metadata": null,
  "role": "authenticated",
  ...
  "session_id": "10b554fc-3cb8-4a15-a5ca-fcd23f7334e8"
}
kutakutatkutakutat

Supabase の Database の中では auth Schema で次のような関係で表現されていた
プロパティはだいぶ省略している

kutakutatkutakutat

発行した JWT, リフレッシュトークンなどは次のような形式で、ブラウザ側では Cookie に保存している

Supabase クライアントを使用する際には、この Cookie と anon_key と合わせて送信するように構成している

{
  "access_token": ${youur_access_token},
  "token_type": "bearer",
  "expires_in": 3600,
  "expires_at": 1702738728,
  "refresh_token": "wHOMKe8tQPAPloIr70rYpg",
  ...
}
kutakutatkutakutat

単に session id をそのまま Cookie に持つことの違いは?

このアプローチには、JWT ベースのアプローチを使用する場合と比較して、いくつかのトレードオフがあります。

  • 認証サーバーまたはそのデータベースがクラッシュしたり、数秒間でも利用できなくなったりすると、アプリケーション全体がダウンします。メンテナンスのスケジュール設定や一時的なエラーへの対処は非常に困難になります。
  • 認証サーバーに障害が発生すると、他のシステムや API に一連の障害が発生し、アプリケーション システム全体が麻痺する可能性があります。
  • 認証を必要とするすべてのリクエストは認証を介してルーティングされる必要があるため、すべてのリクエストに追加の待機時間のオーバーヘッドが追加されます。
    google 翻訳

https://supabase.com/docs/guides/auth/sessions#what-are-the-benefits-of-using-access-and-refresh-tokens-instead-of-traditional-sessions

kutakutatkutakutat

Cookie はデフォルトでは createBrowserClient, createServerClient に共通で次のようなオプションが渡されていた

const DEFAULT_COOKIE_OPTIONS = {
  path: "/",
  sameSite: "lax",
  httpOnly: false,
  maxAge: 60 * 60 * 24 * 365 * 1e3
};

kutakutatkutakutat

公式では少なくとも HttpOnly は true にする必要ないと公式が示している
https://supabase.com/docs/guides/auth/server-side-rendering#how-do-i-make-the-cookies-httponly-

Secure は設定したい気持ちになる
Client の初期化時に以下のような設定を渡してあげる感じだろうか

{
 secure: process.env.NODE_ENV === 'production',
 ...
}

通常、Cookie には Secure 属性が必要で、HTTPS 経由でのみ送信されます。ただし、localhost で開発する場合、これが問題になる可能性があります。
Google 翻訳

https://supabase.com/docs/guides/auth/server-side-rendering#what-should-i-use-for-the-samesite-property

kutakutatkutakutat

Quick Start ではこんな感じで Client Util を整備していた

utils/supabase/server.ts
import { createServerClient, type CookieOptions } from '@supabase/ssr'
import { cookies } from 'next/headers'

export const createClient = (cookieStore: ReturnType<typeof cookies>) => {
  return createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      cookies: {
        get(name: string) {
          return cookieStore.get(name)?.value
        },
        set(name: string, value: string, options: CookieOptions) {
          try {
            cookieStore.set({ name, value, ...options })
          } catch (error) {
            // The `set` method was called from a Server Component.
            // This can be ignored if you have middleware refreshing
            // user sessions.
          }
        },
        remove(name: string, options: CookieOptions) {
          try {
            cookieStore.set({ name, value: '', ...options })
          } catch (error) {
            // The `delete` method was called from a Server Component.
            // This can be ignored if you have middleware refreshing
            // user sessions.
          }
        },
      },
    }
  )
}

utils/supabase/middleware.ts
import { createServerClient, type CookieOptions } from '@supabase/ssr'
import { type NextRequest, NextResponse } from 'next/server'

export const createClient = (request: NextRequest) => {
  // Create an unmodified response
  let response = NextResponse.next({
    request: {
      headers: request.headers,
    },
  })

  const supabase = createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      cookies: {
        get(name: string) {
          return request.cookies.get(name)?.value
        },
        set(name: string, value: string, options: CookieOptions) {
          // If the cookie is updated, update the cookies for the request and response
          request.cookies.set({
            name,
            value,
            ...options,
          })
          response = NextResponse.next({
            request: {
              headers: request.headers,
            },
          })
          response.cookies.set({
            name,
            value,
            ...options,
          })
        },
        remove(name: string, options: CookieOptions) {
          // If the cookie is removed, update the cookies for the request and response
          request.cookies.set({
            name,
            value: '',
            ...options,
          })
          response = NextResponse.next({
            request: {
              headers: request.headers,
            },
          })
          response.cookies.set({
            name,
            value: '',
            ...options,
          })
        },
      },
    }
  )

  return { supabase, response }
}

utils/supabase/client.ts
import { createBrowserClient } from '@supabase/ssr'

export const createClient = () =>
  createBrowserClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
  )

kutakutatkutakutat

Middleware は Server Component が読み込まれる前に、トークンのリフレッシュを行なえるように利用している

middleware.ts
import { type NextRequest } from 'next/server'
import { createClient } from '@/utils/supabase/middleware'

export async function middleware(request: NextRequest) {
  const { supabase, response } = createClient(request)

  // Refresh session if expired - required for Server Components
  // https://supabase.com/docs/guides/auth/auth-helpers/nextjs#managing-session-with-middleware
  await supabase.auth.getSession()

  return response
}

export const config = {
  matcher: [
    /*
     * Match all request paths except for the ones starting with:
     * - _next/static (static files)
     * - _next/image (image optimization files)
     * - favicon.ico (favicon file)
     * Feel free to modify this pattern to include more paths.
     */
    '/((?!_next/static|_next/image|favicon.ico).*)',
  ],
}

https://supabase.com/docs/guides/auth/server-side/creating-a-client?environment=route-handler&framework=nextjs#creating-a-client

kutakutatkutakutat

signUp 関数の実装は GoTrue のなかにあり
flowType === 'pkce' で PKCE のフローの処理を追加している

Supabase Auth は GoTrue を使用して作らている

GoTrueClient.ts

async signUp(credentials: SignUpWithPasswordCredentials): Promise<AuthResponse> {
    try {
      await this._removeSession()

      let res: AuthResponse
      if ('email' in credentials) {
        const { email, password, options } = credentials
        let codeChallenge: string | null = null
        let codeChallengeMethod: string | null = null
        if (this.flowType === 'pkce') {
          const codeVerifier = generatePKCEVerifier()
          await setItemAsync(this.storage, `${this.storageKey}-code-verifier`, codeVerifier)
          codeChallenge = await generatePKCEChallenge(codeVerifier)
          codeChallengeMethod = codeVerifier === codeChallenge ? 'plain' : 's256'
        }
        res = await _request(this.fetch, 'POST', `${this.url}/signup`, {
          headers: this.headers,
          redirectTo: options?.emailRedirectTo,
          body: {
            email,
            password,
            data: options?.data ?? {},
            gotrue_meta_security: { captcha_token: options?.captchaToken },
            code_challenge: codeChallenge,
            code_challenge_method: codeChallengeMethod,
          },
          xform: _sessionResponse,
        })
      } else if ('phone' in credentials) {
        ...
        })
      } else {
        throw new AuthInvalidCredentialsError(
          'You must provide either an email or phone number and a password'
        )
      }

      const { data, error } = res

      if (error || !data) {
        return { data: { user: null, session: null }, error: error }
      }

      const session: Session | null = data.session
      const user: User | null = data.user

      if (data.session) {
        await this._saveSession(data.session)
        await this._notifyAllSubscribers('SIGNED_IN', session)
      }

      return { data: { user, session }, error: null }
    } catch (error) {
      if (isAuthError(error)) {
        return { data: { user: null, session: null }, error }
      }

      throw error
    }
  }

kutakutatkutakutat

signup メソッドの中で code_verifier, code_challenge を作成している

GoTrueClient.ts
        if (this.flowType === 'pkce') {
          const codeVerifier = generatePKCEVerifier()
          await setItemAsync(this.storage, `${this.storageKey}-code-verifier`, codeVerifier)
          codeChallenge = await generatePKCEChallenge(codeVerifier)
          codeChallengeMethod = codeVerifier === codeChallenge ? 'plain' : 's256'
        }

supabase では auth Schema の flow_states テーブルで保存している

サインアップ時には callback の URL を渡していて、リダイレクトされる

app/auth/sign-up/route.ts
  const { error } = await supabase.auth.signUp({
    email,
    password,
    options: {
      emailRedirectTo: `${requestUrl.origin}/auth/callback`,
    },
  })
kutakutatkutakutat

callback にリダイレクトされたら クエリパラメータで渡される code を抽出して
必要な code_verifier などを添えてアクセストークンを要求するリクエストを出している

これで Supabase Auth 側で検証が OK であればアクセストークン等を提供する

app/auth/callback/route.ts
export async function GET(request: Request) {
  // The `/auth/callback` route is required for the server-side auth flow implemented
  // by the Auth Helpers package. It exchanges an auth code for the user's session.
  // https://supabase.com/docs/guides/auth/auth-helpers/nextjs#managing-sign-in-with-code-exchange
  const requestUrl = new URL(request.url)
  const code = requestUrl.searchParams.get('code')

  if (code) {
    const cookieStore = cookies()
    const supabase = createClient(cookieStore)
    await supabase.auth.exchangeCodeForSession(code)
  }

  // URL to redirect to after sign in process completes
  return NextResponse.redirect(requestUrl.origin)
}
kutakutatkutakutat

exchangeCodeForSession のなかでの該当箇所はこんな感じ

GoTrueClient.ts
    ...
    const [codeVerifier, redirectType] = (
      (await getItemAsync(this.storage, `${this.storageKey}-code-verifier`)) as string
    ).split('/')
    const { data, error } = await _request(
      this.fetch,
      'POST',
      `${this.url}/token?grant_type=pkce`,
      {
        headers: this.headers,
        body: {
          auth_code: authCode,
          code_verifier: codeVerifier,
        },
        xform: _sessionResponse,
      }
    )
  ...
kutakutatkutakutat

PKCE は認可コードが横取りされてしまった場合でも、安全性を保つために導入されている

ざっくり、認可コードのリクエスト主と、アクセストークンのリクエスト主が同じであることを、リクエスト主しかしらない値で検証するというアイディア