🍥

@supabase/ssrとRemixでGoogle認証を行う

はじめに

@supabase/ssrを用いて、SSRでのSupabase認証が出来るようになったため、設定手順を備忘録的に残します。

前提として、Google CloudとSupabaseのプロジェクトは作成済みの想定です。
また、今回利用するパッケージのバージョンは以下の通りとなり、@supabase/ssrは現時点でBataである点にご注意ください。

パッケージバージョン
@supabase/ssr0.5.2
@supabase/supabase-js2.47.10

GitHubレポジトリはこちらです!

※この記事は下記スクラップの内容をブログに起こしたものです。

Remixプロジェクトの作成

今回はCloudflare公式のRemix on Cloudflare Workersのテンプレートに導入していきます。

テンプレートを使用してRemixプロジェクトを作成します。

pnpm create cloudflare@latest remix-supabasessr --framework=remix --experimental

テンプレート作成後、必要なパッケージを追加しておきます。

pnpm add @supabase/ssr @supabase/supabase-js

そして、.dev.varsにSupabaseで使用する環境変数を記載します。
Suapabseの環境変数はProject Setting > APIに記載されています。

SUPABASE_URL=https://<project_id>.supabase.co
SUPABASE_ANON_KEY=your_anon_key

Google Cloudの設定

以下リンク先の手順をなぞる形になるため、本記事では変更部分だけピックアップします。

OAuth同意画面の設定

まずはOAuth同意画面を作成します。
OAuth同意画面にアクセスし、UserType外部とし、「作成」ボタンをクリックします。
supabasessr_gcp1

アプリ名とユーザサポートメールアドレスは適当に supabasessr_gcp2

認証済みドメインは環境変数に設定したSUPABASE_URLのドメイン部分だけを入力します。
supabasessr_gcp3

次の画面では「スコープの追加または削除」ボタンをクリックして画像の通り追加します。
下記を設定した後は全てデフォルトのままボタンポチポチでOKです。
supabasessr_gcp4

OAuthクライアントIDの作成

次にOAuthクライアントIDを作成します。
認証情報画面から、「認証情報を作成」「OAuthクライアントID」の順にクリックします。
supabasessr_gcp5

OAuthクライアントIDの作成で、下記画像のとおり設定します。
URIはRemixの設定に応じて変更してください。
supabasessr_gcp6

最後に承認済みリダイレクトですが、Supabaseから取得した値を入力します。
記載場所は「Authentication」>「Auth Providers」>「Google」の中にある「Callback URL (for OAuth)」に記載されています。
supabasessr_gcp7

クライアントID作成が完了すると「クライアントID」と「クライアントシークレット」が発行されます。
これをSupabaseに設定していきます。
supabasessr_gcp8

Supabaseの設定

Supabaseダッシュボードから「Authentication」>「Auth Providers」>「Google」を開き、クライアントIDとクライアントシークレットを設定します。
「Save」ボタンクリック後、GoogleがEnableとなっていれば設定完了です。
supabasessr_sb1

RemixでGoogle認証をする

認証処理の作成

まずは、RemixでSupabaseを利用するためのSupabase Clientを作成します。
こちらは、ほぼSupabaseドキュメントの例をそのまま使っています。

export function createSupabaseServerClient(request: Request, c:AppLoadContext) {
  const headers = new Headers();

  const client = createServerClient(
      c.cloudflare.env.SUPABASE_URL,
      c.cloudflare.env.SUPABASE_ANON_KEY,
    {
      cookies: {
        getAll() {
          return parseCookieHeader(request.headers.get("Cookie") ?? "");
        },
        setAll(cookiesToSet) {
          for (const cookie of cookiesToSet) {
            const { name, value, options } = cookie;
            headers.append(
            "Set-Cookie",
            serializeCookieHeader(name, value, options),
            );
          }
        },
      },
      cookieOptions: {
        httpOnly: true,
        secure: true,
      },
  });

  return { 
    client,
    headers,
  };
}

次にSupabase Clientを使用したログインやログアウトを実装します。
supabase/supabase.server.tsを作成し、以下のとおり記載します。

注意点としてsuccessRedirectPathは後述するコールバック先に合わせておく必要があります。

export const signInWithGoogle = async (
  request: Request,
  c: AppLoadContext,
  successRedirectPath: string = "/",
) => {
  const supabase = createSupabaseServerClient(request, c);
  const { error } = await supabase.client.auth.signOut();

  return { 
    ok: !error ? true : false,
    data: { url: successRedirectPath }, 
    error: error ? error.message : "",
    headers: supabase.headers,
  };
};

export const getUser = async (
  request: Request,
  c: AppLoadContext,
) => {
  const supabase = createSupabaseServerClient(request, c);

  const {
    data: { session },
  } = await supabase.client.auth.getSession();

  return session?.user ?? null;
};

export const isUserLoggedIn = async (
  request: Request,
  c: AppLoadContext,
) => {
  const supabase = createSupabaseServerClient(request, c);

  const {
    data: { user },
  } = await supabase.client.auth.getUser();

  return !!user;
};

ログイン画面の作成

routes/login.tsxを作成し、以下のとおり記載します。
Loaderでは、isUserLoggedInでログイン済みか否かを判定し、ログイン済みであれば/userページにリダイレクトしています。
「Sign In」ボタンを押下すると実行されるactionではsignInWithGoogleでログインし、コールバック先へリダイレクトしています。

export const loader = async ({ request, context }: LoaderFunctionArgs) => {
  if (await isUserLoggedIn(request, context)) {
    return redirect("/user");
  }
  return null;
};

export const action = async ({ request, context }: ActionFunctionArgs) => {
  const data =  await signInWithGoogle(request, context);
  const parsedData = data;

  return redirect(parsedData.data.url!, { headers: data.headers });
};

export default function SignIn() {
  return (
    <div>
      <Form method="post">
        <button type="submit">Sign In</button>
      </Form>
    </div>
  );
}

コールバック処理の作成

次にコールバック先のページを作成します。
ここではroutes/auth.callback.tsxを作成し、以下のとおり記載します。

export async function loader({ request, context }: LoaderFunctionArgs) {
  const requestUrl = new URL(request.url);
  const code = requestUrl.searchParams.get("code");
  const next = requestUrl.searchParams.get("next") ?? "/user";
  const { client, headers } = createSupabaseServerClient(request, context);

  if (code) {
    const { error } = await client.auth.exchangeCodeForSession(code);
    if (!error) {
      return redirect(next, { headers });
    }
  }

  // return the user to an error page with instructions
  return redirect("/error", { headers });
}

codeをセッションに変換(Cookieに保存)しており、ソースコードはSupabaseドキュメントを参考にしています。

ユーザページの作成

ログイン先のユーザページroutes/user.tsxを作成します。
Loaderではユーザ情報を取得しつつ、Actionではログアウトを実装しています。

export const loader = async ({ request, context }: LoaderFunctionArgs) => {
  // ユーザ情報を取得する
  const user = await getUser(request, context);
  return { user };
};

export const action = async ({ request, context }: ActionFunctionArgs) => {
  const data =  await signOut(request, context);
  return redirect(data.data.url, { headers: data.headers });
};

export default function SignIn() {
  // Loaderからユーザ情報を受け取り、ログイン状態を分岐
  const data = useLoaderData<typeof loader>();
  return (
    <div>
      { data.user ? 
        <div>
          <h1>ログイン済みです!</h1>
          <h2>ログアウトボタン</h2>
          <Form method="post">
            <button type="submit">Sign Out</button>
          </Form>
        </div>
        : <h1>未ログインです。。</h1> 
      }
    </div>
  );  
}

あとがき

@supabase/ssrとRemixを用いたGoogle認証は以上です。
簡単ですが、redirect時はheadersを付与する必要がある点に注意で、私は半日はまりました。

また、ZennスクラップでRemixからHonoにJWTを投げてユーザ情報を取得する等行っているので、もしよければご参照ください。