♣️

Supabase+Remix+Cloudflare Workersで認証

2022/02/13に公開

GitHub

今回のソースを上げました。
https://github.com/smallStall/temp-Supabase-Remix-Auth

目標

前回はCRUDのREADだけを実装しました。
https://zenn.dev/smallstall/articles/a2f1c9462a29b6
それだけではフレームワークを活かせていないので、今回はかんたんな認証を実装します。認証済みのユーザーだけsecretページに入れるようなものを作りたいと思います。それでは行きましょう。

認証

認証について調べたのですが、表題の構成ですと2022年2月では大きく2通りのパターンがあるようです。

  • Remix Auth Supabaseを用いる
  • Supabase Authを用いる

https://github.com/mitchelvanbever/remix-auth-supabase
https://supabase.com/docs/guides/auth

それぞれどういうメリット・デメリットがあるのかわからないのですが、Remix Auth Supabaseを用いるとあまりに簡単に実装できてしまい書くことがなくなるので、今回はSupabase Authの方を選択します。また、Remix Auth Supabaseの方も内部的にはSupabase Authを使っているので、結局ソースを理解するためにSupabase Authが重要だと思います。

Supabase Authは下記の4つの認証方法をサポートしています。

  • メールアドレスとパスワード
  • ワンクリック・ログイン
  • ソーシャル・プロバイダ(TwitterやGoogleなど)
  • 電話番号

どんなサイトを作りたいのかによって、どれを選択するか考える必要がありそうです。試しに「メールアドレスとパスワード」方式にしてみます。

コードの修正

前回のコードを使います。始める前にroot.tsxの一部のコードをファイルに分けます。

app/supabase.tsx
import { createClient } from "@supabase/supabase-js";

export const supabaseClient = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
  fetch: (...args) => fetch(...args),
});

root.tsx側は以下の通りになります。

app/root.tsx
import { useLoaderData, Scripts } from "remix";
import type { LoaderFunction } from "remix";
import { supabaseClient } from "~/supabase";

type Message = {
  title: string;
};

export const loader: LoaderFunction = async () => {
  const { data } = await supabaseClient
    .from<Message>("message")
    .select("title");
  return data;
};

export default function Index() {
  const messages = useLoaderData<Message[]>();
  return (
    <html lang="jp">
      <body>
        <div>
          {messages.map((message) => (
            <h1>{message.title}</h1>
          ))}
          <Scripts />
        </div>
      </body>
    </html>
  );
}

この状態で以下のコマンドを実行してみます。

npm run dev
#別ターミナルを開く
npm start

特に前回と変わりないことを確認します。

ページの作成

ページを作ります。Remixはファイルベースのルーティングをサポートしています。なので、app/routes配下にフォルダとファイルを作ればページになります。
https://remix.run/docs/en/v1/guides/routing#review

app/routes/secret.tsx
export default function Index() {
  return (
    <div>
      <h1>認証されました</h1>
    </div>
  );
}

これでオーケーですね。ですが、このままでは何も開発画面が変更されません。

Outlet

root.tsxにOutletをつけ忘れているためです。このお得そうな名前のコンポーネントは何者でしょうか?
https://remix.run/docs/en/v1/api/remix#outlet-context-

このコンポーネントはReact RouterのOutletのラッパーで、ネストしたルートにUIの状態を渡す機能を備えています。

よくわからないのでReact Router v6のOutletを見てみましょう。
https://reactrouter.com/docs/en/v6/api#outlet

親ルート要素にOutletを使用すると、その子ルート要素をレンダリングします。これにより、子ルートがレンダリングされるときにネストされたUIが表示されます。親ルートが完全に一致した場合、子ルートのインデックスルートをレンダリングし、インデックスルートがない場合は何も表示しません。

要するにOutletを使うとレイアウトが入れ子になって表示されるみたいですね。フッターとかは親でよくて中身を子にしておくみたいなイメージでしょうか。ざっくりした理解ですが、あとは使って慣れようと思います。

app/root.tsx
...
    <div>
      {messages.map((message) => (
        <h1>{message.title}</h1>
      ))}
      <Outlet />
      <Scripts />
    </div>

Outletを追加しました。
https://zenn.dev/smallstall/articles/b4e00df95c1f03

開発画面の下になにか出てきてますね。どうやらroot.tsxの下にapp/routes/index.tsxの内容が出てきているようです。これがネストか。
URLを下記の通り変更しました。
localhost:8787/secret

「認証されました」の場違い感がすごい。それはそうとして、ちゃんと表示されて良かったです。Outletを使うとページという概念ではなくなるのかもしれませんね。ツリー構造のレイアウトと言ったほうが近いのかも。せっかくなので見た目とヘッダーを変更します。

app/root.tsx
...
const Document = ({
  children,
  title,
}: {
  children: React.ReactNode;
  title?: string;
}) => {
  return (
    <html lang="ja">
      <head>
        <meta charSet="utf-8" />
        {title ? <title>{title}</title> : null}
      </head>
      <body>
        {children}
        {process.env.NODE_ENV === "development" && <LiveReload />}
      </body>
    </html>
  );
};

export default function Index() {
  const supabase = useLoaderData<SupabaseClient>();
  return (
    <Document title="Auth&CRUD">
      <SupabaseProvider supabase={supabase}>
        <div>
          <p>------------------------------------------</p>
          <Outlet />
	  <Scripts />
          <p>------------------------------------------</p>
        </div>
      </SupabaseProvider>
    </Document>
  );
}

メールの下のほうでたまに見るレイアウトです。

 ブラウザ画面がなにも変わらない場合はラグがあるかもしれないので、何度かリロードしてみてください。

Supabaseを使う前に

Remixでライブラリを使う際には注意しなければならないことがあるため、別記事にまとめました。
https://zenn.dev/smallstall/articles/a4db938bc74cf1

ユーザー登録画面

ユーザー登録画面を作ります。こちらのリポジトリをベースにさせていただきました。ありがとうございます。
https://github.com/aaronksaunders/supabase-remix-auth
 なお、supabaseには認証用のUIが用意されています。
https://ui.supabase.io/
 ただ、今回はSupabase Authの動作を知りたいのでHTMLから作成します。
app/routes配下にsignup.tsxを作成します。

app/routes/signup.tsx
import { Form, useActionData } from "remix";

type ActionRequest = {
  request: Request;
}

export const action = async ({ request }: ActionRequest) => {
//TODO Formデータを受け取り、Supabaseにサインアップする
  return {};
};

type Inputs = {
  userName: string;
  email: string;
  password: string;
};

export default function Signup() {
  const actionData = useActionData(); //action結果を受け取る

  return (
    <div>
      <h1>ユーザー登録</h1>
      <Form method="post">
        <div>
          <label>ユーザー名:</label>
          <input id="userName" name="userName"  type="text" />
        </div>
        <div>
          <label>メールアドレス:</label>
          <input id="email" name="email" autoComplete="username" />
        </div>
        <div>
          <label>パスワード:</label>
          <input
            id="password"
            type="password"
            name="password"
            autoComplete="current-password"
          />
        </div>
        <input type="submit" />
        <p>{actionData?.error ? actionData?.error?.message : null}</p>
      </Form>
    </div>
  );
}

上の方にはactionというものを作成しています。これはRemixのloaderと同じくサーバーサイドの処理を担当します。違うのはloaderがGETで呼び出されるのに対し、actionはそれ以外で呼び出されるということです。ざっくり言うと、ページを読み込むときはloaderが呼び出されますが、ボタンを押すとactionが呼び出されます。
https://remix.run/docs/en/v1/api/conventions#action

また、HTMLのformの代わりにRemixのFormを使用しています。基本的にはformと同じようですが、便利になっていそうです。
https://remix.run/docs/en/v1/api/remix#form

これでフォームの外側ができました。今度は中身を作ります。

Supabaseのテーブルを作成

下記URLにてSupabaseでユーザーデータをどう扱えば良いか解説があります。
https://supabase.com/docs/guides/auth/managing-user-data
 まず、Supabaseにprofilesテーブルを作成します。SupabaseのTable Editorでできることは限られていますので、SQL Editorを使います。+ New queryを選択します。

SQL Editor
create table public.profiles (
  id uuid references auth.users not null,
  user_name text,

  primary key (id)
);

alter table public.profiles enable row level security;

RUNを押すとテーブルが作成されます。auth.usersを外部参照しています。今回からRow Level Security(RLS)をオンにします。これはSupabaseのセキュリティ上必要なもので、ざっくり言うとCRUDの際に毎回指定したフィルターをかけるようなものです。
RLSをオンにするとデフォルトのままでは管理者しかテーブルにアクセスできないようになります。なので、設定により他の人もアクセスできるようにします。もう一度+ New queryを選択し、以下のコードを実行します。

SQL Editor
create policy "Users can select their own profile."
  on profiles for select
  using ( auth.uid() = id );

create policy "Anyone can insert their own profile."
  on profiles for insert
  with check ( auth.role() = 'anon' );

create policy "Users can update own profile."
  on profiles for update
  using ( auth.uid() = id );

role()によってユーザーの種類を取得できます。anonは匿名(認証前)という意味です。これで認証前でもテーブルにinsertできるようになるはずです。
https://supabase.com/docs/learn/auth-deep-dive/auth-row-level-security#securing-your-tables
 Authentication → PoliciesにてRLSの設定を確認します。

確認メールページを作成

Supabaseの認証はデフォルトではユーザーにメールを送り、そのメールをクリックすると認証されるようになっています。なので、signupにてリダイレクトされる「メール確認ページ」を作っておきます。

app/routes/mail.tsx
export default function Mail() {
  return (
    <div>
      <p>確認メールを送信しました。</p>
    </div>
  );
}

また、送られる認証メールのURLはAuthentication → Setting → Site URLにて変更できますので、http://localhost:8787/login に変えました。

actionを作成

actionの中身を作成します。

app/routes/signup.tsx
import { Form, useActionData, redirect } from "remix";
import { supabaseClient } from "~/supabase";

type ActionRequest = {
  request: Request
}

export const action = async ({ request }: ActionRequest) => {
  const form = await request.formData();
  const email = form.get("email")?.toString();
  const password = form.get("password")?.toString();
  const userName = form.get("userName");
  await supabaseClient.auth.signOut();

  const {
    user,
    error: signUpError,
  } = await supabaseClient.auth.signUp({
    email,
    password,
  });
  console.log(userName) //miniflareにて確認
  
  if (!signUpError && user) {
    const { error: profileError } = await supabaseClient
      .from("profiles")
      .insert({ user_name: userName, id: user.id }, {returning : 'minimal'});

    if (profileError) return { error: profileError };
    return redirect("/mail")
  }
   return { user, signUpError };
};

export default function Signup() {
  const actionData = useActionData();

  return (
    <div>
      <h1>ユーザー登録</h1>
      <Form method="post">
        <div>
          <label>ユーザー名:</label>
          <input id="userName" name="userName"  type="text" />
        </div>
        <div>
          <label>メールアドレス:</label>
          <input id="email" name="email" autoComplete="username" />
        </div>
        <div>
          <label>パスワード:</label>
          <input
            id="password"
            type="password"
            name="password"
            autoComplete="current-password"
          />
        </div>
        <input type="submit" />
        <p>{actionData?.error ? actionData?.error?.message : null}</p>
      </Form>
    </div>
  );
}

正しくパスワードやメールアドレスを入力すれば、フォームの内容がSupabaseに送られます。loaderやactionに書かれたconsole.log()はターミナル(miniflareの方)に表示されます。
コードを読んでみますと、supabaseClient.auth.signOut()にてサインアウトの処理をしています。その後、supabaseClient.auth.signUpにてサインアップ(ユーザー登録)しています。authというpublicとは別な領域にメールアドレスと暗号化されたパスワードを保存しています。
https://supabase.com/docs/reference/javascript/auth-signup
この時点でもしエラーがなければ、supabaseClient.from("profiles").insertでprofilesテーブルにデータを入れます。insertの引数optionに{returning : 'minimal'}がないと、insertした後にselectしてinsertした行を取得します。今回のケースではselectのRLSに引っかかってしまいますので注意が必要です。
https://supabase.com/docs/reference/javascript/insert#notes
https://github.com/supabase/supabase/discussions/270
 minimalについてはpostgrest(Supabaseの実体の1つ)で解説があります。
https://postgrest.org/en/v7.0.0/api.html?highlight=minimal#insertions-updates

Responseには、新しいオブジェクトがどこにあるかを記述したLocationヘッダが含まれます。テーブルが書き込み専用の場合、Locationヘッダを作成するとパーミッションエラーが発生します。書き込み専用のテーブルにアイテムをうまく挿入するには、リクエストヘッダ Prefer: return=minimalを含めて、Locationレスポンスヘッダを抑制する必要があります。

書き込み専用テーブル(write-only table)というのが何を指すのか詳しくはわからないのですが、minimalにしておけばエラーは防げそうです。
insertが成功すれば確認メールページにリダイレクトされます。
受信メールを見てみると「Confirmaition Your Signup」というタイトルでメールが来ています。来ていますが、まだリンク先のloginページは工事中ですのでリンク先は押さないようにしておきます。
また、profilesテーブルに中身が入っているはずです。Supabaseで見てみましょう。Table editor → profilesにてテーブルを表示し、Refleshボタンを押します。

うまくいっていれば、レコードが表示されます。
ちなみに、開発段階で一度認証されたユーザーを消したい場合はAuthentication → Users → ... → Delete userにて消すことが可能です。ただし、profilesテーブルで外部参照しているので消す順番はprofiles → auth.Usersです。

セッション

現状、見かけは認証されているのですが、ページを移動すると認証が取り消されてしまいます。ネットで買い物かごに商品を入れたらログインするところからやり直し。それではお買い物できません。セッションを維持できれば認証状態を保てます。
https://developer.mozilla.org/ja/docs/Web/HTTP/Cookies
セッション管理には一般的にはクッキーが使われているのですが、SupabaseではJSON Web Token(JWT)が使われています。
https://supabase.com/docs/learn/auth-deep-dive/auth-deep-dive-jwts
一方でRemixでは特に明言はされていないようなのですが、クッキーを使うのを想定していそうです。SupabaseのGitHubにも議論が上がっていました。
https://github.com/supabase/supabase/discussions/1188
Remixを介さずJWTはJWTのまま使った方がいいのか? それともクッキーのヘッダーに入れた方がいいのか? よくわかりませんが今回は後者にします。ちなみに、JWTをクッキーのヘッダーに入れる方法はRemixのGitHubで答えてくださっている方がいます。JWTは単なる文字列なので、ヘッダーに入れるだけなら特に難しいことはありません。
https://github.com/remix-run/remix/issues/654
なお、Supabaseの認証に関する部分はGoTrueなので、そちらで調べたほうが確実そうです。こうして見るとSupabaseって本当に色んなものの集合体なんだなとあらためて実感します。
https://supabase.github.io/gotrue-js/modules.html
https://supabase.com/docs/architecture

session.server.ts
import { createCookieSessionStorage } from "remix";

const { getSession, commitSession, destroySession } =
  createCookieSessionStorage({
    cookie: {
      name: "__session",
      expires: new Date(Date.now() + 180), //3分間
      httpOnly: true,
      maxAge: 180, //3分間待
      path: "/",
      sameSite: "lax",
      secrets: [SESSION_KEY],
      secure: true,
    },
  });
export { getSession, commitSession, destroySession };

SESSION_KEYはSUPABASE_ANON_KEYやSUPABASE_URLと同じように.envとwrangler secret putに入れておきます。推測不可能な文字列なら良いと思います。
createCookieSessionStorageについては下記に説明があります。
https://remix.run/docs/en/v1/api/remix#sessions
Remixのセッション管理について下記のような説明があります。

Remixでは、セッションはrouteごとに管理され、loaderやactionメソッドで "session storage"オブジェクトを使用します。session storageはクッキーをパースして生成したり、セッションデータをデータベースやファイルシステムに保存することができます。

どうやらsessionはsession storageオブジェクトに保管されるみたいですね。

getSession()は受信リクエストのCookieヘッダーから現在のセッションを取得し、commitSession()/destroySession()は送信レスポンスにSet-Cookieヘッダーを提供します。

CookieヘッダーとSet-Cookieヘッダーの2種類があるんですね。MDNに記載があったので、リンクを貼っておきます。
https://developer.mozilla.org/ja/docs/Web/HTTP/Cookies#the_set-cookie_and_cookie_headers

  • Set-Cookieヘッダー サーバーからブラウザへ送るCookie
  • Cookieヘッダー ブラウザが保持し、ブラウザからサーバーへ送るCookie

まとめると、getSession()で取得できるのはブラウザから送られたCookieで、commitSession()やdestroySession()はブラウザにCookieを送るということですね。これを元にしつつ、signup.tsxをベースにlogin.tsxコードを書きます。

loginページの作成

app/routes/login.tsx
import { Form, useActionData, redirect, Link } from "remix";
import { supabaseClient } from "~/supabase";
import { commitSession, getSession } from "~/session.server";

export const action = async ({ request }: { request: Request }) => {
  const form = await request.formData();
  const email = form.get("email")?.toString();
  const password = form.get("password")?.toString();

  //ログイン
  const {
    user,
    error,
    session: supabaseSession,
  } = await supabaseClient.auth.signIn({
    email,
    password,
  });
  console.log(supabaseSession) //中身を確認 機密情報なので注意
  //supabaseClient.authにて認証されたら
  if (user) {
    const session = await getSession(request.headers.get("Cookie"));
    session.set("access_token", supabaseSession?.access_token);
    return redirect("/secret", {
      headers: {
        "Set-Cookie": await commitSession(session),
      },
    });
  }
  return { user, error };
};

export default function Login() {
  const actionData = useActionData();

  return (
    <div className="remix__page">
      <main>
        <h1>ログイン</h1>
        <Form method="post">
          <div className="form_item">
            <label htmlFor="email">メールアドレス:</label>
            <input id="email" name="email" type="text" />
          </div>
          <div className="form_item">
            <label htmlFor="password">パスワード:</label>
            <input id="password" name="password" type="password" />
          </div>
          <div>
            <button type="submit">ログイン</button>
            <div>
            <Link to="/signup">
              ユーザー登録はこちら
            </Link>
            </div>
          </div>
        </Form>
        <p>{actionData?.error ? actionData?.error?.message : null}</p>
      </main>
    </div>
  );
}

signUpの代わりにsignInを使っています。signInの戻り値としてsessionを受け取っています。sessionにはJWT(access_token)などが入っています。
https://supabase.github.io/gotrue-js/interfaces/Session.html
認証されたらgetSessionでブラウザからクッキーを取得しています。ここにaccess_tokenというヘッダを追記しています。中身はJWTです。追記したクッキーをcommitSession()で返しつつ、redirectでsecretに飛びます。忙しそうです。下の方でLinkタグを使っていますが、これはRemixのアンカーです。aタグの代わりに使うようです。今回は使っていないですが、prefetch(事前fetch)できるのがおもしろそうですね。
https://remix.run/docs/en/v1/api/remix#link

secretページの認証

secretには認証されなくても行くことができます。なので、認証されていればsecretへ行き、認証されていなければloginに行くようにします。

app/routes/secret.tsx
...
type loaderRequest = {
  request: Request;
};

export const loader = async ({ request } : loaderRequest) => {
  const session = await getSession(request.headers.get("Cookie"));
  // access_tokenヘッダーがない場合
  if (!session.has("access_token")) {
    throw redirect("/login");
  } else {
    //cookieからJWTを取得
    //取得したJWTを使いsupabaseのauthからユーザーを検索する
    const { user, error: sessionErr } = await supabaseClient.auth.api.getUser(
      session.get("access_token")
    );
    // 何もエラーがなければaccess_tokenが承認済み(Supabaseに承認されている)
    if (!sessionErr) {
      supabaseClient.auth.setAuth(session.get("access_token"));
      return {};
    } else {
      return { error: sessionErr };
    }
  }
};
...

これで認証されなければログインページにリダイレクトされるようになったはずです。認証メールのリンクをクリックすれば、ログインページに行きます。もう一度メールアドレスとパスワードを入力すればsecretに入れます。signupしたのにまた情報を入力するのはちょっと面倒ですね。

次回

今回は簡単な認証を実装しましたが、まだContext等実装できていない部分があるので、次回は完成度を上げたいと考えています。
https://zenn.dev/smallstall/articles/8773e22a768cba
現場からは以上です。

Discussion