空雲リファレンス

supabase + GraphQL + Next.js で認証とリソースの権限を設定する

※ 書きかけです

supabase と GraphQL と認証

supabase はテーブルの構造に合わせて GraphQL スキーマが自動生成され、それを利用してプログラムを組むことができるようになりました。ただしユーザーの作成や認証機能は GraphQL を通しては実装されておらず、その部分だけは RestAPI を用いる必要があります。また、GraphQL がアクセス可能なデータベーススキーマは public のみのため、auth 配下にあるユーザ情報は GraphQL 上では関連付けることができません。これらの問題にうまく対処していく必要があります。

ローカル開発で用意するもの

https://github.com/supabase/cli

ローカル開発で必要となります。

supabase-cli の初期設定

supabase init supabase start

これで supabase が起動できます。supabase-cli を使う場合の注意点があります。外部からのアクセス可能な環境では絶対に使用しないでください。jwt シークレットがsuper-secret-jwt-token-with-at-least-32-characters-longという内容で決め打ちになっているので、外に出したら一瞬でクラックされます。

用意を推奨するファイル

supabase initsupabaseディレクトリが作成されます。その中に用意しておくと良いファイルです。

  • supabase/seed.sql
drop extension if exists pg_graphql; create extension if not exists pg_graphql; select graphql.rebuild_schema();

本来初期データを入れるためのファイルですが、マイグレーション後に GraphQL の機能を有効にするために必要です。supabase startsupabase db resetでデータベースを作成した際に、一見 pg_graphql の拡張機能が有効になっているように見えるのですが、再起動しないと使えません。また、graphql.rebuild_schema()はデーブルの構造を変更するたびに必要になります。

最初に用意しておくユーザ管理用マイグレーションファイル

以下、ユーザ管理用のテーブルを作成するためのマイグレーションファイルです

  • supabase/migrations/20220418113839_create_user.sql
CREATE OR REPLACE FUNCTION public.handle_users_update() RETURNS trigger LANGUAGE 'plpgsql' COST 100 VOLATILE NOT LEAKPROOF SECURITY DEFINER AS $BODY$ begin IF (TG_OP = 'DELETE') THEN delete from public."User" where id=old.id; return old; ELSEIF (TG_OP = 'UPDATE') THEN update public."User" set email=NEW.email,raw_user_meta_data=NEW.raw_user_meta_data where id=old.id; return new; ELSEIF (TG_OP = 'INSERT') THEN insert into public."User"(id, email,raw_user_meta_data) values(NEW.id,NEW.email,NEW.raw_user_meta_data); return new; END IF; return NULL; end; $BODY$; ALTER FUNCTION public.handle_users_update() OWNER TO postgres; GRANT EXECUTE ON FUNCTION public.handle_users_update() TO authenticated; GRANT EXECUTE ON FUNCTION public.handle_users_update() TO postgres; GRANT EXECUTE ON FUNCTION public.handle_users_update() TO PUBLIC; GRANT EXECUTE ON FUNCTION public.handle_users_update() TO anon; GRANT EXECUTE ON FUNCTION public.handle_users_update() TO service_role; CREATE TABLE IF NOT EXISTS public."User" ( id uuid NOT NULL, email character varying(255) COLLATE pg_catalog."default", raw_user_meta_data text, CONSTRAINT "User_pkey" PRIMARY KEY (id) ) TABLESPACE pg_default; ALTER TABLE IF EXISTS public."User" OWNER to postgres; ALTER TABLE IF EXISTS public."User" ENABLE ROW LEVEL SECURITY; GRANT ALL ON TABLE public."User" TO anon; GRANT ALL ON TABLE public."User" TO authenticated; GRANT ALL ON TABLE public."User" TO postgres; GRANT ALL ON TABLE public."User" TO service_role; CREATE POLICY "Enable access to all users" ON public."User" AS PERMISSIVE FOR SELECT TO public USING (true); CREATE trigger on_auth_user_update AFTER INSERT OR UPDATE OR DELETE ON auth.users for each row execute procedure public.handle_users_update();

auth.usersへの操作をフックして、public.Userに必要なデータを書き込みます。こうしておくと、GraphQL のスキーマーがユーザ情報へアクセスできるようになります。

Todo アプリ用のテーブル

タイトル、説明、日時、非公開属性、ユーザ情報を記憶します。ユーザ情報は public.User にリレーションを張ります。auth.users では無いので注意してください。

CREATE TABLE IF NOT EXISTS public."Todo" ( id bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY ( INCREMENT 1 START 1 MINVALUE 1 MAXVALUE 9223372036854775807 CACHE 1 ), created_at timestamp with time zone DEFAULT now(), user_id uuid NOT NULL DEFAULT auth.uid(), title text COLLATE pg_catalog."default", published boolean NOT NULL DEFAULT false, description text COLLATE pg_catalog."default", CONSTRAINT "Todo_pkey" PRIMARY KEY (id), CONSTRAINT "Todo_user_id_fkey" FOREIGN KEY (user_id) REFERENCES public."User" (id) MATCH SIMPLE ON UPDATE NO ACTION ON DELETE NO ACTION ) TABLESPACE pg_default; ALTER TABLE IF EXISTS public."Todo" OWNER to postgres; ALTER TABLE IF EXISTS public."Todo" ENABLE ROW LEVEL SECURITY; GRANT ALL ON TABLE public."Todo" TO anon; GRANT ALL ON TABLE public."Todo" TO authenticated; GRANT ALL ON TABLE public."Todo" TO postgres; GRANT ALL ON TABLE public."Todo" TO service_role; CREATE POLICY "Enable access to all users" ON public."Todo" AS PERMISSIVE FOR SELECT TO public USING (published or auth.uid() = user_id); CREATE POLICY "Enable INSERT for authenticated users only" ON public."Todo" AS PERMISSIVE FOR INSERT TO public WITH CHECK ((auth.role() = 'authenticated'::text) and auth.uid() = user_id); CREATE POLICY "Enable DELETE/UPDATE for users based on user_id" ON public."Todo" AS PERMISSIVE FOR ALL TO public USING ((auth.uid() = user_id));

Todo テーブルはCREATE POLICYで PostgreSQL の RLS(行レベルセキュリティ)を作っています。

  • Enable access to all users
    select の制限で private が設定されている場合は、ユーザーが一致しないとデータを返さないようにしています。この設定を入れておくと非公開データを作ることができます
  • Enable INSERT for authenticated users only
    認証ユーザのみ書き込むことができます。また書き込んだユーザが詐称できないように、実際のユーザと書き込まれる id が一致しているか検査しています
  • Enable DELETE/UPDATE for users based on user_id
    ユーザが一致した場合のみ、書き換えと削除を許可しています

このあたりの設定は慣れが必要です。

実験用 User の用意

認証に必要なユーザを作成します。cli では用意されていないので自分で作ります。ユーザの作成には service_role の方のキーを使います。テストユーザをさくっと作りたいので招待機能は使いません。

  • .env.local
NEXT_PUBLIC_SUPABASE_URL=http://localhost:54321 NEXT_PUBLIC_SUPABASE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24ifQ.625_WdcF3KHqz5amU0x2X5WWHP-OEs_4qj0ssLNHzTs SUPABASE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSJ9.vI9obAHOGyVVKa3pD--kJlyxp-Z2zV9UUMAhKpNLAcU
  • bin/create-user.ts
import { createClient } from "@supabase/supabase-js"; import { config } from "dotenv"; const { parsed } = config({ path: ".env.local" }); const endpoint = parsed?.NEXT_PUBLIC_SUPABASE_URL; const key = parsed?.SUPABASE_KEY; const createUser = async ({ email, password, }: { email: string; password: string; }) => { const supabase = createClient(endpoint!, key!); const result = await supabase.auth.api.createUser({ email, password, email_confirm: true, user_metadata: { name: email }, }); return result; }; (async () => { if (!endpoint || !key || process.argv.length < 4) { console.log("create-user [email] [password]"); } else { let result; for (let i = 0; i < 3; i++) { result = await createUser({ email: process.argv[2], password: process.argv[3], }); if (result.error?.status !== 500) break; } console.log(result); } })();
  • ユーザ作成
    yarn ts-node -s bin/create-user a@example.com a
    yarn ts-node -s bin/create-user b@example.com a

たまに 500 エラーを返す時があるので 3 回リトライするようにしています。とりあえずテスト用ユーザを二人作っておきます。 作成したユーザの認証と token の受け取りは GraphQL ではできないので RestAPI を直にたたくかsupabase.auth.signInを使うことになります。