Next.js で Supabase Auth がどんな仕組みで動いてるか調べました

[Vercel]:https://vercel.com/
[Supabase]:https://supabase.com/
[Supabase Auth]:https://supabase.com/docs/guides/auth
[Next.js]:https://nextjs.org/
[Supabase Auth > Getting Started > Next.js]:https://supabase.com/docs/guides/auth/quickstarts/nextjs
[supabase-js]:https://github.com/supabase/supabase-js
[Route Handler]:https://nextjs.org/docs/app/building-your-application/routing/route-handlers
[@supabase/ssr]:https://supabase.com/docs/guides/auth/server-side/nextjs


最近はいろんな開発者向けサービスが出てきてすごく便利ですよね。

[Vercel] や [Supabase] の存在もあり、簡易なアプリケーションであれば AWS すら使わずインフラを構築しなくて済む世界が来てとても楽です。

ただし、ブラックボックス化された結果 **「なんかよく分からないけど、チュートリアルの通りにコード書いたら動いたぞ」** というのは非常に危険だなと考えており、[Next.js] で [Supabase Auth] を使った際にまさにその状況に陥りました。

ということで、本記事では [Supabase Auth] が [Next.js] (SSR) でどの様に動作しているのか、仕組みを説明したいと思います。

# Supabase Auth

[Supabase Auth > Getting Started > Next.js] チュートリアルに従って以下の npx コマンドを実行すると、Supabase を使って DB, Auth (認証) が構築されたサンプルアプリケーションが生成されます。

```bash
npx create-next-app -e with-supabase
```

ログイン画面.

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/60131/cea664b1-668f-6924-dd29-60e10e9b8b7a.png)

コードを見ると [supabase-js] (SDK) を使って、たったこれだけでログインやユーザー情報の取得 (認証の有無) ができます。凄いですね。

```javascript
// サインアップ
await supabase.auth.signUp({ email, password, options: { emailRedirectTo: `${origin}/auth/callback` }});

// パスワードログイン
await supabase.auth.signInWithPassword({ email, password });

// ユーザー情報の取得 (認証の有無の確認)
const user = await supabase.auth.getUser();
```

# 何が疑問か？

前述の [supabase-js] (SDK) を使った認証セッション情報は標準では Local storage に保存されます。つまり、 **Next.js の SSR や [Route Handler] では認証セッション情報は参照できない筈** です。

```mermaid
sequenceDiagram
    participant ブラウザ as "ブラウザ (JS)"
    participant Next.js as "Next.js (Server-Side)"

    ブラウザ->>ブラウザ: セッション情報の取得 (Local storage)
    ブラウザ->>Next.js: API Call (ajax)
    Next.js->>Next.js: セッション情報は見れない
```

実は Supabase/Next.js チュートリアルのアプリケーションではこの問題を解決するために [@supabase/ssr] Package を使用しています。

# ここで仕組み

:::note warn
[@supabase/ssr] Package をどう導入するかは ↓ に書いてあるので説明しません。

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

## 図解

[@supabase/ssr] Package を使うと、下図の通り Cookie を用いてクライアント・サーバーサイド間の認証セッション情報を伝送する様になります。

```mermaid
sequenceDiagram
    participant ブラウザ as "ブラウザ (JS)"
    participant Next.js as "Next.js (Server-Side)"

    ブラウザ->>Supabase: ログイン API
    ブラウザ->>ブラウザ: Set-Cookie (認証セッション)
    ブラウザ->>Next.js: SSR or API Call (ajax)<br />+ 認証セッション Cookie
    Next.js->>Next.js: Cookie で認証セッションを認識
    Next.js->>Supabase: ユーザー情報取得 API
    Next.js-->>ブラウザ: Set-Cookie (トークンの更新)
```

## 詳細

コード上は、supabase client の生成コードだけを `@supabase/supabase-js` のものから `@supabase/ssr` へ変更します。それ以外のコードには変更は必要ありません。

### supabase client

```diff
- import { createClient } from '@supabase/supabase-js'

- // Create a single supabase client for interacting with your database
- const supabase = createClient(
-   process.env.NEXT_PUBLIC_SUPABASE_URL!,
-   process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
- )
```

これを [@supabase/ssr] を使ったコードに変更します。クライアントとサーバーサイドで異なるコードを使用します。

```javascript:supabase/client.ts
import { createBrowserClient } from '@supabase/ssr'

export function createClient() {
  return createBrowserClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
  )
}

// const supabase = createClient()
```

サーバーサイドはこうなります。

クライアントコードに比べ若干複雑なのは、サーバーサイドでの Cookie に関する処理が利用しているフレームワーク（※今回は Next.js) によって異なるのを吸収できる作りになっているためです。

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

export function createClient() {
  const cookieStore = cookies()

  return createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      cookies: {
        getAll() {
          // ↓これは Next.js 特有のコード.
          return cookieStore.getAll()
        },
        setAll(cookiesToSet) {
          // ↓これは Next.js 特有のコード.
          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 middleware refreshing
            // user sessions.
          }
        },
      },
    }
  )
}

// const supabase = createClient()
```

:::note
`getAll()`, `setAll()` の実装を開発者が利用する SSR フレームワークに併せて任意に変更できるようになっています。
:::

### middleware

Next.js の `middleware.ts` でも同様の実装を行います。

というのも、Server Component では Cookie の書き込みは出来ないので middleware で `Set-Cookie` ヘッダを発行することになります。

OAuth の場合、認証トークンは頻繁に更新 (refresh) されますので、これをクライアントに伝える意図で `Set-Cookie` する必要があります。

```javascript:middleware.ts
import { createServerClient } from "@supabase/ssr";
import { type NextRequest, NextResponse } from "next/server";

export async function middleware(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: {
        getAll() {
          // ↓middleware では cookie 参照の API が違うので先ほどのコードは使えない.
          return request.cookies.getAll();
        },
        setAll(cookiesToSet) {
          // ↓middleware では cookie 参照の API が違うので先ほどのコードは使えない.
          cookiesToSet.forEach(({ name, value }) =>
            request.cookies.set(name, value),
          );
          response = NextResponse.next({
            request,
          });
          cookiesToSet.forEach(({ name, value, options }) =>
            response.cookies.set(name, value, options),
          );
        },
      },
    },
  );

  // This will refresh session if expired - required for Server Components
  // https://supabase.com/docs/guides/auth/server-side/nextjs
  const user = await supabase.auth.getUser();

  // Redirect to /sign-in page.
  if (user.error) {
    return NextResponse.redirect(new URL("/sign-in", request.url));
  }

  return response;
}

export const config = {
  matcher: [
    "/((?!_next/static|_next/image|favicon.ico|sign-in|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)",
  ],
};
```