Closed48

Supabaseに入門する

keitaknkeitakn

概要

認証基盤としてSupabaseがどの程度使いやすいか?を調査する。

サインアップ、サインイン、GoogleなどのOpenID プロバイダーによる認証をどのように実装していくか調査する。

keitaknkeitakn

アカウント作成

https://supabase.com/dashboard/sign-up

GitHubアカウントで登録が出来るので登録する。

プロジェクト作成

next-supabase-examples という検証用プロジェクトを作ったので、とりあえずこれと同じ名前でプロジェクトを作成する。

必要事項を入力、特に難しくはない。

Planはとりあえずフリーを選択。

create-project

keitaknkeitakn

公式の Supabase Auth with the Next.js App Router を参照しながら進める

以下のガイドに従って進めていく。

https://supabase.com/docs/guides/auth/auth-helpers/nextjs

npx create-next-app -e with-supabase とするとプロジェクトの初期構成を作ってくれるようだ。

ただしこの生成方法で生成されたテンプレートにはいくつか問題があったので通常の create-next-app で生成されたファイルからマージしている。

  • ESLintの設定がなかったので追加(本家の create-next-app の結果にはある)
  • パッケージのバージョンが常に latest になっていたりするので、こちらはバージョン固定をした
  • tsconfig.jsonpaths の設定が消えてしまったいたので create-next-app の結果と同じ設定に変更

具体的な変更内容は以下のPRにまとめている。

https://github.com/keitakn/next-supabase-examples/pull/3

keitaknkeitakn

Supabase のアクセストークンをAPI認証として利用出来るか?

ServerComponents内で以下のようにすると、JWT形式のアクセストークンを取得出来た。

  const supabase = createServerComponentClient({ cookies })

  const token = await supabase.auth.getSession();

  console.log(token);

ちなみにpayloadの形は以下のような形。

{
  "aud": "authenticated",
  "exp": 1689956112,
  "iat": 1689952512,
  "iss": "https://https://xxxxxxx.supabase.co/auth/v1",
  "sub": "UUIDv4形式",
  "email": "keita@exmple.com",
  "phone": "",
  "app_metadata": {
    "provider": "email",
    "providers": [
      "email"
    ]
  },
  "user_metadata": {},
  "role": "authenticated",
  "aal": "aal1",
  "amr": [
    {
      "method": "password",
      "timestamp": 1689952512
    }
  ],
  "session_id": "UUIDv4形式"
}

このJWTを検証すれば、自作で作ったAPIサーバー等でもこの値を認証・認可に利用出来そう。

keitaknkeitakn

Supabaseのアクセストークンを検証

以下は jose を使って検証を実施している例。(実行しているのはAppRouterのServerComponents内)

import { cookies } from 'next/headers';
import * as jose from 'jose';

const supabase = createServerComponentClient({ cookies });

const token = await supabase.auth.getSession();

const secretKey = new TextEncoder().encode(
  'https://supabase.com/dashboard/project/{自分のプロジェクトID}/settings/api 内からコピーしたJWT Secretを設定',
);

const jwtVerifyResult = await jose.jwtVerify(String(token.data.session?.access_token), secretKey, {issuer: 'https://https://{自分のプロジェクトID}.supabase.co/auth/v1'});
console.log(jwtVerifyResult);

もちろん自作したAPIサーバー内でも標準的な機能を備えたJWT用ライブラリを使って検証を実施出来る。

例えばPython + LangChain + FastAPIで作成した自作API内でもSupabaseのアクセストークンを利用してアクセス制御を行う事が可能。

keitaknkeitakn

Googleログインの実装

以下を見ながら実装していく。

https://supabase.com/docs/guides/auth/social-login/auth-google

Supabase の以下のURLからGoogleによる認証を有効化する。

project-id には実際のプロジェクトIDが入る。

https://supabase.com/dashboard/project/{project-id}/auth/providers

管理画面のメニューの Authentication → Providers から遷移する。(UIが変わる可能性はアリ)

OAuthの情報を入力する必要があるので、Google CloudのAPIとサービス → 認証情報から OAuth 2.0 クライアント ID を作成する。(Callback URLは後でGoogle Cloud側で使うのでコピーしておく)

https://console.cloud.google.com/apis/credentials

Client IDとClient Secretをコピーして Supabase 側で以下の情報を入力して事前準備完了。

  • Client ID (for OAuth)
  • Client Secret (for OAuth)
  • Authorized Client IDs これは空でOK。
keitaknkeitakn

以下は実装時のPR。

https://github.com/keitakn/next-supabase-examples/pull/6

最初はReactServerComponents内で supabase.auth.signInWithOAuth を使ってGoogleログインの実装を試みたが invalid request: both auth code and code verifier should be non-empty というエラーが発生してしまい上手くいかなかった。

これはissueが報告されており、どうやらNext.js側の問題らしい。

https://github.com/supabase/auth-helpers/issues/545

仕方がないのでGoogleログイン用のButtonComponentを作って onClick 時に実行する方法を取った。

ちなみに認証成功時のアクセストークンのpayloadは以下のような感じだ。

emailログインの時よりも情報量が多くpayloadからGoogleアカウントの情報が取得出来るようになっている。

{
  "aud": "authenticated",
  "exp": 1690123901,
  "iat": 1690120301,
  "iss": "https://https://xxxxxxx.supabase.co/auth/v1",
  "sub": "UUIDv4形式",
  "email": "Gmailのメールアドレス",
  "phone": "",
  "app_metadata": {
    "provider": "google",
    "providers": [
      "google"
    ]
  },
  "user_metadata": {
    "avatar_url": "アバター画像のURL",
    "email": "Gmailのメールアドレス",
    "email_verified": true,
    "full_name": "ユーザー名のフルネーム",
    "iss": "https://accounts.google.com",
    "name": "ユーザー名",
    "picture": "エンドユーザーの画像URL",
    "provider_id": "数値(型は文字列)",
    "sub": "数値(型は文字列)"
  },
  "role": "authenticated",
  "aal": "aal1",
  "amr": [
    {
      "method": "oauth",
      "timestamp": 1690120301
    }
  ],
  "session_id": "UUIDv4形式"
}
keitaknkeitakn

ちなみに先にメールアドレスとパスワードで登録して同じGmailのアカウントを使ってGoogleログインを実行すると同じユーザーと見なしてくれる。

逆に先にGoogleログインで登録して後でパスワードとemailで登録しようとすると、重複していると見なされてしまい、登録APIでエラーが発生してしまった。

同一のメールアドレスで登録している他のソーシャルアカウント(例えばFacebookとか)で登録を試みた場合はどうなるのかが気になる。

keitaknkeitakn

細かい問題

無料版だとGoogleログイン時の同意画面に自分の supabase のプロジェクトのDomainが表示されてしまう。

ここを独自Domainにしたい場合は有料プランを契約してCustomDomainsの設定をする必要がある。

keitaknkeitakn

Supabaseの良いと思った点

  • 安いのに高機能、認証基盤としての機能は十分で認証の履歴等も閲覧可能
  • パスワードレス認証(マジックリンクによるログインやSMS認証に対応)に対応している
  • ドキュメントが充実している
  • RDBが付いているのは嬉しい

Supabaseのちょっと残念なところ

  • 対応しているソーシャルログインが少ない + B to B向けの物が多い

ちなみに対応しているソーシャルログインは以下の通り。

今後実装される可能性もあるが現状だとカスタムAuthもないので、LINEログイン等は実装出来ない。

今回は要件としてLINEログインが必須だったので残念ながら不採用とした。

keitaknkeitakn

とは言えサービスのポテンシャルはかなり感じたので、今後別の機会にいつか使ったみたい。

keitaknkeitakn

Supabase採用に向いているプロダクト開発が始まったので約1年ぶりに調査を再開します。

このアプリケーションは運営側からアカウントを作成してエンドユーザーにサインアップしてもらうタイプなのでまずはそれが出来るかどうかを調査していく。

keitaknkeitakn

運営側からアカウントを作成してエンドユーザーにサインアップしてもらう

まずはこれを試してみる。

以下でプロジェクトの雛形を作成する。

npx create-next-app -e with-supabase

以前試した時と比べて結構変わっていた。

@supabase/ssr というPackageがReactServerComponentsやMiddleware等で利用されている。

ローカルでNext.jsのアプリを起動させた後でSupabaseのプロジェクト管理画面でAuthentication → User の「Add User」→「Send invitation」からユーザーを招待する。

keitaknkeitakn

認証待ち状態のユーザーは管理画面からは以下のように表示される。

keitaknkeitakn

メールを確認すると以下のようなメールが届いているので「Accept the invite」のリンクにアクセスする。

keitaknkeitakn

{{ .SiteURL }} の部分は Authentication → URL Configuration から変更可能。
自分はDefaultの http://localhost:3000 から変更していたのでここを事前に変更した。

keitaknkeitakn

「Accept the invite」のリンクにアクセスする。URLは以下のように構成されている。

http://localhost:24000/#access_token=アクセストークン&expires_at=1716372194&expires_in=3600&refresh_token=リフレッシュトークン&token_type=bearer&type=invite

この瞬間に認証待ち状態が解除される。

しかしこの方法で追加されたユーザーは認証手段を持たないのでログイン可能な状態にする為にパスワード等を設定する必要がある。

次はこのあたりをどうやって解決するのか調査する。

keitaknkeitakn

テスト中に Failed to invite user: failed to make invite request: Email rate limit exceeded が出るようになった。

SMTP Settingsを実施すればレートリミットを軽減出来そうなので進めていく。

keitaknkeitakn

メールのレートリミット軽減に関してはResendを連携させるのが最も簡単そう。

Resendに関しては前回まとめている、Gmailの迷惑メールポリシー強化の影響があるので必ずSPF, DKIM, DMARCの設定を終わらせておく。

https://zenn.dev/keitakn/scraps/0e1f006459d407

以下から「Add integration」を押下して進めていく。

https://supabase.com/partners/integrations/resend

Select an organization to grant API access to で組織名を間違えないように注意する。

連携完了後は以下に必要項目を入力していく。

https://resend.com/settings/integrations/supabase

keitaknkeitakn

Supabaseのプロジェクト管理画面でAuthentication → User の「Add User」→「Send invitation」から招待したユーザーはパスワードが設定されていない。

その為、初期パスワードの設定が必要。

以下のようなパスワード設定フォームを作る事で対応した。

useEffect は使いたくないがURLが http://localhost:24000/#access_token=アクセストークン&expires_at=1716372194&expires_in=3600&refresh_token=リフレッシュトークン&token_type=bearer&type=invite な感じで #refresh_token 等がくっついているのでクライアント側でしか処理出来ないのでこうしている。

'use client';

import { SubmitButton } from '@/app/login/submit-button';
import { type FormEvent, useEffect, useState } from 'react';

export const NewPasswordForm = () => {
  const [refreshToken, setRefreshToken] = useState<string>('');

  useEffect(() => {
    if (typeof window !== 'undefined' && window.location.hash) {
      // Remove the leading #
      const urlParams = new URLSearchParams(window.location.hash.substring(1));
      const extractedRefreshToken = urlParams.get('refresh_token');
      if (extractedRefreshToken != null) {
        setRefreshToken(extractedRefreshToken as string);
      }
    }
  }, []);

  const onSubmit = async (event: FormEvent<HTMLFormElement>) => {
    event.preventDefault();

    const password = new FormData(event.currentTarget).get('password') as string;

    if (password !== '' && refreshToken !== '') {
      const createPasswordResponse = await fetch('http://localhost:24000/auth/password', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({ refreshToken, password }),
      });

      const createPasswordResponseBody = (await createPasswordResponse.json()) as {
        isSuccess: boolean;
        user?: {
          email: string;
        };
        errorMessage?: string;
      };

      // TODO パスワードリセットに失敗した場合のエラーハンドリングを追加する

      const email = createPasswordResponseBody.user?.email;
      if (email != null && typeof window !== 'undefined') {
        const loginResponse = await fetch('http://localhost:24000/auth/login', {
          method: 'POST',
          headers: {
            'Content-Type': 'application/json',
          },
          body: JSON.stringify({ email, password }),
        });

        const responseBody = (await loginResponse.json()) as { loginSuccess: boolean; errorMessage?: string };

        if (responseBody.loginSuccess) {
          window.location.href = 'http://localhost:24000/protected';
        }
        // TODO ログイン失敗時のエラーハンドリングを考える
      }
    }
  };

  return (
    <div className="flex-1 flex flex-col w-full px-8 sm:max-w-md justify-center gap-2">
      <form className="animate-in flex-1 flex flex-col w-full justify-center gap-2 text-foreground" onSubmit={onSubmit}>
        <label className="text-md" htmlFor="password">
          Password
        </label>
        <input
          className="rounded-md px-4 py-2 bg-inherit border mb-6"
          type="password"
          name="password"
          placeholder="••••••••"
          required
        />
        <SubmitButton
          className="bg-green-700 rounded-md px-4 py-2 text-foreground mb-2"
          pendingText="パスワードを設定中..."
        >
          パスワードを設定
        </SubmitButton>
      </form>
    </div>
  );
};

POST /auth/password でパスワードの初期設定、POST /auth/login でログイン処理を実装してログイン済のページにリダイレクトしている。

POST /auth/passwordPOST /auth/login はそれぞれ以下のように実装されている。

POST /auth/password

import { createClient } from '@/utils/supabase/server';
import { NextResponse } from 'next/server';

export const runtime = 'edge';

type RequestBody = {
  refreshToken: string;
  password: string;
};

type ResponseBody = {
  isSuccess: boolean;
  user?: {
    email: string;
  };
  errorMessage?: string;
};

export async function POST(request: Request) {
  const requestBody = (await request.json()) as RequestBody;

  const supabase = createClient();

  await supabase.auth.refreshSession({ refresh_token: requestBody.refreshToken });

  const { data, error } = await supabase.auth.updateUser({
    password: requestBody.password,
  });

  const email = data.user?.email;
  if (email && error == null) {
    const status = 200;

    const responseBody = {
      isSuccess: true,
      user: {
        email,
      },
    } as const satisfies ResponseBody;

    return NextResponse.json(responseBody, { status });
  }

  const status = 500;

  const responseBody = {
    isSuccess: false,
    errorMessage: 'failed to set password',
  } as const satisfies ResponseBody;

  return NextResponse.json(responseBody, { status });
}

POST /auth/login

import { createClient } from '@/utils/supabase/server';
import { NextResponse } from 'next/server';

export const runtime = 'edge';

type RequestBody = {
  email: string;
  password: string;
};

type ResponseBody = {
  loginSuccess: boolean;
  errorMessage?: string;
};

export async function POST(request: Request) {
  const requestBody = (await request.json()) as RequestBody;

  const supabase = createClient();

  const { error } = await supabase.auth.signInWithPassword({
    email: requestBody.email,
    password: requestBody.password,
  });

  if (error) {
    const status = 500;

    const responseBody = {
      loginSuccess: false,
      errorMessage: 'failed to login',
    } as const satisfies ResponseBody;

    return NextResponse.json(responseBody, { status });
  }

  const status = 200;

  const responseBody = {
    loginSuccess: true,
  } as const satisfies ResponseBody;

  return NextResponse.json(responseBody, { status });
}
keitaknkeitakn

ちなみにSupabaseの管理画面からユーザーを招待した場合認証完了後のリダイレクト先のURLが強制的に / になってしまう。

直接Supabaseの inviteUserByEmail を利用して redirectTo に任意のURLを指定する事で戻り先のURLを変える事が出来る。

ちなみに指定するURLはRedirect URLsから事前に登録しておく必要がある。

keitaknkeitakn

次に調査する事

以下のやり方が分かれば最低限実践で利用出来るかなと思う。

  • Vercelのプレビュー環境でのSupabaseを使った認証が利用出来るようにする
  • 複数環境での運用方法(ローカル、ステージング、本番の3環境で運用する場合どのような構成にするか)
  • RDBのマイグレーション方法
keitaknkeitakn

Magic Link Loginの実装

先ほど作成したのは一般的なパスワード認証によるログインフォームだがパスワードログインはセキュリティ的な問題が多く、パスワード忘れ時のフローも考慮しないといけないので正直メリットが少ない。

そこでMagic Link Loginというパスワードレスのログインを実装する。
メールアドレスに認証URLを送信してエンドユーザーが認証URLにアクセスする事でログイン処理を実現する。

パスワードが存在しないのでパスワード流出による不正ログインは起こらないし、エンドユーザーもパスワードを覚えておく必要がないので開発者、エンドユーザー共にメリットが大きい手法だと思う。

以下のようなフォームを用意して signInWithOtp というメソッドを利用する事で簡単に実現出来た。

以下は実行時のPR。

https://github.com/nekochans/supabase-private-platform-example/pull/3

keitaknkeitakn

VercelとSupabaseの連携

Vercelのプレビュー環境でのSupabaseを使った認証が出来ないとかなり不便なのでそれが可能になるように設定を実施する。

まずはVercelへのデプロイを実施する。手順はGitHubのリポジトリを連携するだけの簡単な方法なので省略。
ちなみにSupabase周りの環境変数は後で自動連携されるので、この時点では設定しなくてよい。

以下からSupabase、Vercelの連携を実施していく。

https://vercel.com/integrations/supabase

1. Add Integrationを押下

2. Vercelに連携しているGitHub Orgとプロジェクトを連携する

3. Supabaseのorgを選択する

4. SupabaseのプロジェクトとVercelのプロジェクトを選択する

5. Vercel側で環境変数が同期されている事を確認

ちなみに注意点としてこれらの環境変数はVercelのProduction環境でしか読み込む事が出来ない、プレビュー環境やローカル環境で運用する為の方法はこれから調査していく。

keitaknkeitakn

Supabaseの認証をVercelのプレビュー環境で実現する

サインアップやMagicLinkのログイン(パスワードレスのログイン)は以下の方法で実現出来る。

1. SupabaseのRedirect URLsにVercelのプレビュー環境を登録しておく

Vercelのプレビュー環境は https://{プロジェクト名やGitのBranch名}-{VercelのOrg名}.vercel.app のようなURLで構成されているので https://*nekochans.vercel.app/auth/callback のようにアスタリクスを使った形で登録しておく。

2. Supabaseに渡すURLはVercelのプレビュー環境のドメインを含める

以下のように next/headers を使う方法で https:://ドメイン名 の部分を取得するようにする。

import { headers } from 'next/headers';

  const signIn = async (formData: FormData) => {
    'use server';

    const email = formData.get('email') as string;
    const supabase = createClient();

    const { error } = await supabase.auth.signInWithOtp({
      email,
      options: {
        emailRedirectTo: `${headers().get('origin')}/auth/callback`,
      },
    });
    // 省略
  };

もしくは以下のように NEXT_PUBLIC_VERCEL_URL から取得するような関数を実装するのも良いと思います。

export const appUrl = (): string => {
  if (process.env.NEXT_PUBLIC_VERCEL_URL) {
    return `https://${process.env.NEXT_PUBLIC_VERCEL_URL}`;
  }

  return 'http://localhost:24000';
};
keitaknkeitakn

今回はパスワードレスのメールアドレスを行ったログインの場合は問題はない。

しかしGoogleログイン等の外部の認証プロバイダを利用している場合は結構難しいと思う。

外部の認証プロバイダ側(Google)にもVercelのプレビュー環境のURLをリダイレクトURIをその都度登録する必要があるので運用するのは結構辛いと思う。

keitaknkeitakn

開発環境用のSupabaseプロジェクトを作成する

以下でも記載されているようにBranch機能はまだ正式版ではない。

https://supabase.com/docs/guides/platform/branching

以下のディスカッションを読んでみたが現時点では複数環境で利用する為には複数プロジェクトを作るのが無難。

https://github.com/orgs/supabase/discussions/542

無料プランでも2つまでSupabaseプロジェクトを作成出来るので検証だけなら無料プランでも可能。

ResendのSMTPサーバー等は本番と同じ物を利用するが、実運用ではこれは分けたほうが無難。

keitaknkeitakn

PostgreSQLのMigrationを実行出来るようにする

Supabase CLIのインストール

Prismaを使うのも良いがなるべく安定した仕組みを使いたいので公式の Supabase CLI を利用する。

以下でインストールを実行する。

npm install supabase --save-dev
keitaknkeitakn

ローカルでのSupabaseの起動

まずは以下のコマンドを実行して初期化を行う。

./node_modules/.bin/supabase init

するといくつかファイルが生成される、これはGitにコミットしておけば良さそう。

supabase/config.toml を眺めてみるとSupabaseの設定が色々書いてある事がわかる。

# A string used to distinguish different Supabase projects on the same host. Defaults to the
# working directory name when running `supabase init`.
project_id = "supabase-private-platform-example"

[api]
enabled = true
# Port to use for the API URL.
port = 54321
# Schemas to expose in your API. Tables, views and stored procedures in this schema will get API
# endpoints. `public` is always included.
schemas = ["public", "graphql_public"]
# Extra schemas to add to the search_path of every request. `public` is always included.
extra_search_path = ["public", "extensions"]
# The maximum number of rows returns from a view, table, or stored procedure. Limits payload size
# for accidental or malicious requests.
max_rows = 1000

[db]
# Port to use for the local database URL.
port = 54322
# Port used by db diff command to initialize the shadow database.
shadow_port = 54320
# The database major version to use. This has to be the same as your remote database's. Run `SHOW
# server_version;` on the remote database to check.
major_version = 15

[db.pooler]
enabled = false
# Port to use for the local connection pooler.
port = 54329
# Specifies when a server connection can be reused by other clients.
# Configure one of the supported pooler modes: `transaction`, `session`.
pool_mode = "transaction"
# How many server connections to allow per user/database pair.
default_pool_size = 20
# Maximum number of client connections allowed.
max_client_conn = 100

# 以下省略

以下でローカルのサーバーを起動する。

./node_modules/.bin/supabase start

コンテナが使われているので Docker Desktop 等がインストールされている必要がある。
(OrbStack等で動作するかは試していない)

以下のURLでアクセスが可能。

http://localhost:54323/project/default

いつものSupabaseの画面が表示されている。

停止させるには以下のコマンドを実行する。

./node_modules/.bin/supabase stop
keitaknkeitakn

ローカル環境を動かす上での注意点

初見で自分が引っかかった点を記載しておく。

SupabaseのURLとANON_KEYについて

ローカルで立ち上がっているSupabaseを参照する為にはこれらの値を環境変数として登録する必要がある。

Next.jsを使っている場合 .envNEXT_PUBLIC_SUPABASE_URLNEXT_PUBLIC_SUPABASE_ANON_KEY を設定しておく必要がある。

以下のコマンドを実行する事で確認が可能。

./node_modules/.bin/supabase status

以下のように結果が表示される。

         API URL: http://127.0.0.1:54321
     GraphQL URL: http://127.0.0.1:54321/graphql/v1
  S3 Storage URL: http://127.0.0.1:54321/storage/v1/s3
          DB URL: postgresql://postgres:postgres@127.0.0.1:54322/postgres
      Studio URL: http://127.0.0.1:54323
    Inbucket URL: http://127.0.0.1:54324
      JWT secret: super-secret-jwt-token-with-at-least-32-characters-long
        anon key: eeeeeee.yyyyy.xxxxx
service_role key: eeeeeee.zzzzzz.zzzzzz
   S3 Secret Key: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
       S3 Region: local

以下のように設定すればOK。

NEXT_PUBLIC_SUPABASE_URL=http://127.0.0.1:54321
NEXT_PUBLIC_SUPABASE_ANON_KEY=eeeeeee.yyyyy.xxxxx

ローカルSupabaseでのメールの受信

ローカルでメールを受信する為には Inbucket というツールを利用する。

supabase start を実行した時に Inbucket URL が表示されているのでここにアクセスする。

http://127.0.0.1:54324/monitor から確認すると以下のようにメールの本文が確認可能なのでこれでサインアップやMagicLinkのログインがローカルでも実行可能。

supabase/config.tomlsite_urladditional_redirect_urls を設定しておく

自分はローカル環境のNext.jsのURLを http://localhost:24000 としていたので、site_urladditional_redirect_urls を書き換える必要がある。

# 省略
[auth]
enabled = true
# The base URL of your website. Used as an allow-list for redirects and for constructing URLs used
# in emails.
site_url = "http://localhost:24000"
# A list of *exact* URLs that auth providers are permitted to redirect to post authentication.
additional_redirect_urls = ["http://localhost:24000/auth/callback"]

# 省略

なおローカルだとサインアップ時にメールの受信確認をしない設定になっているので、enable_confirmationstrue に設定しておくと受信確認が出来るようになる。

# 省略
[auth.email]
# 省略
enable_confirmations = true
# 省略
keitaknkeitakn

ローカルでMigrationファイルの作成

以下のようにMigrationファイルを作成する。

./node_modules/.bin/supabase migration new create_triggers_functions_refresh_updated_at
./node_modules/.bin/supabase migration new create_table_users
./node_modules/.bin/supabase migration new create_triggers_on_users

作成したのは以下の3つ。

supabase/migrations/20240529151643_create_triggers_functions_refresh_updated_at.sql

CREATE FUNCTION refresh_updated_at_step1() RETURNS trigger AS
$$
BEGIN
  IF NEW.updated_at = OLD.updated_at THEN
    NEW.updated_at := NULL;
  END IF;
  RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE FUNCTION refresh_updated_at_step2() RETURNS trigger AS
$$
BEGIN
  IF NEW.updated_at IS NULL THEN
    NEW.updated_at := OLD.updated_at;
  END IF;
  RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE FUNCTION refresh_updated_at_step3() RETURNS trigger AS
$$
BEGIN
  IF NEW.updated_at IS NULL THEN
    NEW.updated_at := CURRENT_TIMESTAMP;
  END IF;
  RETURN NEW;
END;
$$ LANGUAGE plpgsql;

supabase/migrations/20240529151715_create_table_users.sql

CREATE TABLE users(
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);

supabase/migrations/20240529151723_create_triggers_on_users.sql

CREATE TRIGGER refresh_users_updated_at_step1
    BEFORE UPDATE ON users FOR EACH ROW
    EXECUTE PROCEDURE refresh_updated_at_step1();
CREATE TRIGGER refresh_users_updated_at_step2
    BEFORE UPDATE OF updated_at ON users FOR EACH ROW
    EXECUTE PROCEDURE refresh_updated_at_step2();
CREATE TRIGGER refresh_users_updated_at_step3
    BEFORE UPDATE ON users FOR EACH ROW
    EXECUTE PROCEDURE refresh_updated_at_step3();

作成したトリガーは updated_at を更新する為の物。

MySQLと違い updated_at を自動更新する仕組みが存在しない為、これを追加している。

このあたりの内容に関しては以下の記事を参考にしました。

https://zenn.dev/mpyw/articles/rdb-ids-and-timestamps-best-practices

このトリガー関数を利用する事でMySQLの updated_at の挙動を再現可能です。

keitaknkeitakn

リモート環境でのSupabase

本番用、開発用それぞれのSupabaseプロジェクトを作ったので、以下のタイミングでそれぞれMigrationが実行されるように設定していく。

  • Vercelのプレビュー環境デプロイ時(GitHubにBranchが作成されコミットが作られたタイミング)で開発用のSupabaseプロジェクトに対してMigrationを実行する
  • Vercelの本番環境にデプロイされた時に本番用のSupabaseプロジェクトに対してMigrationを実行する
keitaknkeitakn

開発用のMigration用workflowの実装

まずはVercelのプレビュー環境デプロイ時に動作するGitHubActionsを作っていく。

以下の3つの環境変数をGitHub上に設定する。

DEV_SUPABASE_ACCESS_TOKEN
DEV_SUPABASE_DB_PASSWORD
DEV_SUPABASE_PROJECT_ID

DEV_SUPABASE_ACCESS_TOKEN に関しては https://supabase.com/dashboard/account/tokens からAccessTokenを発行すればOK。

keitaknkeitakn

以下のようなGitHubActionsのworkflowを追加する。

branches-ignoremain を指定しているのは main へのコミットが走った時はVercelの本番環境へのデプロイが行われる時なので開発環境のSupabaseにMigrationを適応する必要がない為。

name: dev-supabase-db-migrate

on:
  workflow_dispatch:
  push:
    branches-ignore:
      - main

jobs:
  migrate:
    runs-on: ubuntu-22.04
    env:
      SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_ACCESS_TOKEN }}
      SUPABASE_DB_PASSWORD: ${{ secrets.STAGING_DB_PASSWORD }}
      SUPABASE_PROJECT_ID: ${{ secrets.STAGING_PROJECT_ID }}
    steps:
      - uses: actions/checkout@v4
      - uses: supabase/setup-cli@v1
        with:
          version: 1.169.8
      - run: supabase link --project-ref $SUPABASE_PROJECT_ID
      - run: supabase db push

ログを見ると以下のように成功している事が確認出来る。

Connecting to remote database...
Do you want to push these migrations to the remote database?
 • 20240529151643_create_triggers_functions_refresh_updated_at.sql
 • 20240529151715_create_table_users.sql
 • 20240529151723_create_triggers_on_users.sql

 [Y/n] 
Applying migration 20240529151643_create_triggers_functions_refresh_updated_at.sql...
Applying migration 20240529151715_create_table_users.sql...
Applying migration 20240529151723_create_triggers_on_users.sql...
Finished supabase db push.
A new version of Supabase CLI is available: v1.172.2 (currently installed v1.169.8)
We recommend updating regularly for new features and bug fixes: https://supabase.com/docs/guides/cli/getting-started#updating-the-supabase-cli
keitaknkeitakn

後は同じように本番用のworkflowを作ればと思ったが、それだとGitHubActionsのほうで本番用Migrationが行われ、Vercelのほうでデプロイが行われる事になる。

そうすると本番用Migrationだけ成功してVercelデプロイが失敗(その逆もありえる)という事が起こってしまう可能性がある。

Migrationが成功した後でVercelデプロイが実施されるようにするにはどうすれば良いか考える必要がある。

keitaknkeitakn

結論

Vercelのビルドステップ上でMigrationの実行も一緒にするようにした。

最初に package.json に以下の npm scriptを作成する。

    "supabase:login": "supabase login --token $SUPABASE_ACCESS_TOKEN",
    "supabase:link": "supabase link --project-ref $SUPABASE_PROJECT_ID",
    "supabase:db:push": "supabase db push",
    "vercel:build": "npm run supabase:login && npm run supabase:link && npm run build && npm run supabase:db:push"

以下のようにVercelのビルド設定を上書きして npm run vercel:build が実行されるようにする。

以下の環境変数はVercel上に登録しておく。

  • SUPABASE_PROJECT_ID
  • SUPABASE_ACCESS_TOKEN
  • SUPABASE_DB_PASSWORD

これで理論上はアプリケーション側のデプロイが失敗して、DBのMigrationが実行されるという事態は防ぐ事が出来る。

しかもVercelの環境変数はPreview環境のBranch指定が可能なので今後ステージング環境等の固定のURLを持つ環境が出てきたとしても対応が可能になる。

ちなみに supabase db lint などもVercel上で実行したかったが、Vercelのビルド環境にはDocker等のコンテナ実行環境が存在しないようなので断念した。

以下の公式サンプルがやっているようにCI Workflowの中で supabase db lintsupabase db push --dry-run を実行するようにした。

https://github.com/supabase/supabase-action-example/blob/main/.github/workflows/ci.yaml

ちなみに supabase db lintsupabase db push --dry-run はSQLの構文エラーまでは見てくれない模様。

検証用に supabase/migrations/20240602071050_create_test.sql というファイルを追加してわざとシンタックスエラーになるSQLを追加してコミットしても --dry-run は正常に通ってしまう。

CREATE TABLE tests(
    id UUID DEFAULT gen_random_uuid(),
    supabase_user_id NOT NULL,
    created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY (ids),
    UNIQUE (user_id)
);

ただ当然だがMigrationには失敗するのでVercel上ではちゃんとエラーになってくれる。(以下はその時のメッセージ)

ERROR: syntax error at or near "NOT" (SQLSTATE 42601)
At statement 0: CREATE TABLE tests(
    id UUID DEFAULT gen_random_uuid(),
    supabase_user_id NOT NULL,
    created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY (ids),
    UNIQUE (user_id)
)
Try rerunning the command with --debug to troubleshoot the error.
Error: Command "npm run vercel:build" exited with 1

CIとしては不完全ではあるが、少なくとも間違ったテーブル等が本番環境に作られる事は防ぐ事が出来るので一旦この運用で行こうと思う。

以下はその変更時のPR。

https://github.com/nekochans/supabase-private-platform-example/pull/9

https://github.com/nekochans/supabase-private-platform-example/pull/10

keitaknkeitakn

Migrationのロールバックについて

何かしらの事情があってDBのMigrationをロールバックする場合はSupabase上に作成される schema_migrations のデータを修正する事でMigrationのロールバックを実現する事になる。

以下は schema_migrations の中身。

このデータを直接触るのではなく supabase migration repair というコマンドを使って操作する事が可能な模様。

ただしPlanetScaleのSafe migrationsのような無停止で安全にschemaのロールバックが出来るような仕組みではないので、これらのロールバックは本当の緊急時以外はやらないほうが良いと思う。

https://planetscale.com/docs/concepts/safe-migrations

keitaknkeitakn

一旦運用出来そうなところまで調査が完了したのでこのスクラップはクローズ。

nakamotonakamoto

とても有用な記事です。
スクラップという機能があることも本日始めて知りました。
スクラップ見やすくてよいですね。

keitaknkeitakn

ありがとうございます🙏
自分用のメモとして残した物ですが見て頂けて良かったです👍

keitaknkeitakn

追記 データベースを利用する際のSecurity対策

Defaultだと anon key でpublicSchemaのテーブルに対してCRUD操作が出来てしまう。

よってこれを防ぐことは運用上必須であると思われる。

以下の記事に対策が紹介されていたので実施していく。

https://zenn.dev/k_log24/articles/ff1581de72b0aa

既存権限を確認する

SupabaseのSQL Editorで以下のSQLを入力。

SELECT grantee, table_name, privilege_type
FROM information_schema.role_table_grants
WHERE table_schema = 'public'
ORDER BY grantee, table_name, privilege_type;

以下のように表示されてしまったらCRUD操作が出来る状態。ちなみに1つでもテーブルがないと先ほどのSQLを実行しても結果は得られない。

anon keyからCRUD権限を剥奪する

以下を実行する。

REVOKE all privileges
ON all tables
IN SCHEMA public
FROM anon;

今後作成されるテーブルについても対策

既存のテーブルに対しては権限を剥奪したが、新規テーブルを作成した場合はanon権限が付与されるので対策。

以下を実行することで新規作成されるテーブルにもanon権限が付与されなくなる。

ALTER DEFAULT privileges IN SCHEMA public REVOKE all ON tables FROM anon;
ALTER DEFAULT privileges IN SCHEMA public REVOKE all ON functions FROM anon;
ALTER DEFAULT privileges IN SCHEMA public REVOKE all ON sequences FROM anon;

特定テーブルだけanon権限付与

GRANT SELECT ON posts TO anon;
keitaknkeitakn

アプリケーション側で制御することも可能なので必須ではないが認証ユーザーのみデータを作成するように制御することも可能。

Row Level Security(RLS)

PostgresSQLを使用した行単位のセキュリティ。

この機能を使用することで、認証ユーザーのみデータを作成、更新ができるといった制御が可能になる。(データ行単位のアクセス制御)

SQLでポリシー作成するにあたり、理解しておくべき用語があります。

USING expression

ここで指定した条件を満たす行を操作できるようになります。言い換えるならSQLでいうWHERE句になります。

WITH CHECK expression

登録、更新をする際に指定した条件を満たす行のみ操作できるようになります。追加の条件を指定したい場合はこちらを使用します。
満たされない場合はエラーが返されます。
また、ポリシーで使える便利な関数が存在します。

以下のようにポリシーで使える便利な関数が存在します。

auth.uid() // ログイン中のユーザーIDを取得
auth.jwt() // ログイン中のJWTを取得

データ取得

全ユーザーにデータ取得を許可する

CREATE policy "Allow public read access"
ON todos
FOR select
USING ( true );

認証ユーザーのデータのみ取得する

CREATE policy "View own todos." 
ON todos
FOR select
USING ( auth.uid() = user_id );

クライアント側からリクエストを受けて、実際に実行されるSQLは以下のようになる。

SELECT *
FROM todos
WHERE auth.uid() = todos.user_id;

クライアント側でSQLを組み立てる必要がなくなります。

const { data, error } = await supabase
  .from('todos')
  .select('*')

データ登録

CREATE policy "Register own todo."
ON todos
FOR insert
TO authenticated
WITH CHECK ( ( auth.uid() = user_id ) );

データ更新

CREATE policy "Update own todo."
ON todos
FOR update
TO authenticated
USING ( ( auth.uid() = user_id ) )
WITH CHECK ( ( auth.uid() = user_id ) );

データ削除

CREATE policy "Delete own todo."
ON todos
FOR delete
TO authenticated
USING ( ( auth.uid() = user_id ) );
このスクラップは2024/06/02にクローズされました