🍐

【Express + ejs + supabase Auth】View からのサインイン処理だけが正常に処理されない問題と戦った話

2024/07/21に公開

ハッカソンメンター中に突然ドデカい迷宮に迷い込んでしまい、苦しんだので備忘録を残しておきます。
前提として、本記事執筆時点で @supabase/ssr は beta版(v0.4.0) であることをメモしておきます。

前提

環境

  • Node.js v18.17.0
  • dependencies(抜粋)
  "dependencies": {
    "@supabase/ssr": "^0.4.0",
    "@supabase/supabase-js": "^2.44.4",
    "cookie-parser": "^1.4.6",
    "ejs": "^3.1.10",
    "express": "^4.19.2",
    "express-ejs-layouts": "^2.5.1",
  },
  "devDependencies": {
    "ts-node-dev": "^2.0.0",
    "typescript": "^5.5.3"
  }

本題

ある日、いつものように supabase で OAuth 認証を実装していました。

supabase では 「Auth」と呼ばれる専用の機能で、各種 SNS アカウント(Ex. discord, GitHub)を連携してくれる機能があります。最近の Web アプリは適当な別サービスのアカウントを連携すれば動くものも多く、ユーザーにとっても登録の手間が省けてよい仕組みです。
https://supabase.com/docs/reference/javascript/auth-signinwithoauth

ただし、OAuth には「アクセストークンを傍受されてしまう」というセキュリティ的弱点があります。そして、これを対策するための追加の処理を施したものが PKCEフロー になります。
PKCEフローの詳解は、別の方があまりにもわかりやすい解説記事を書いてくださっているので、こちらを引用しておきます。
https://zenn.dev/zaki_yama/articles/oauth2-authorization-code-grant-and-pkce

つまるところ、ちょっと複雑になった OAuth を実装したわけです。

実装してみた

今回はチャレンジもかねて、PKCE フローを採用した認証を実装しました。
今回は discord でログインする場合に対応したいと思います。

supabase プロジェクトの作成 ~ redirect URL の設定、プロバイダの登録などは割愛します。(以下の公式記事が参考になりそう)
https://supabase.com/docs/guides/auth/social-login/auth-discord?queryGroups=environment&environment=server&queryGroups=framework&framework=express

まずはルーティングに /signin, `/signin/callback を定義。

// auth.ejs -> '/auth' にバインド済

router.get("/signin", async (req: Request, res: Response) => {
  const supabase = createClient(req, res);
  const { data, error } = await supabase.auth.signInWithOAuth({
    provider: "discord",
    options: {
      redirectTo: `${req.get("host}/auth/signin/callback`,
    },
  });
  if (error) {
    return res.status(500).send(error);
  }

router.get("/signin/callback", async (req: Request, res: Response) => {
  const supabase = createClient(req, res);
  const code = req.query.code;
  if (!code) {
    return res.status(400).send("No code");
  }

  var { data, error } = await supabase.auth.exchangeCodeForSession(code as string);
  if (error) {
    return res.status(500).send(error);
  }

  const userId = data.user?.id;
  const accessToken = data.session?.access_token;
  if (!userId || !accessToken) {
    return res.status(500).send("No user ID or access token");
  }

  res.cookie("user_id", userId);
  res.cookie("access_token", accessToken);
  res.redirect("/");
});
...
}

createClient()@supabase/supabase-js のものではなく、@supabase/ssrcreateServerClient() を使用して独自に実装したものです。

import { createBrowserClient, parseCookieHeader, serializeCookieHeader } from "@supabase/ssr";
import { SupabaseClient } from "@supabase/supabase-js";
import { Request, Response } from "express";
import "dotenv/config";

const supabaseUrl = process.env.SUPABASE_URL;
if (!supabaseUrl) {
  throw new Error("SUPABASE_URL is not set");
}

const supabaseKey = process.env.SUPABASE_KEY;
if (!supabaseKey) {
  throw new Error("SUPABASE_KEY is not set");
}

export const createClient = (req: Request, res: Response): SupabaseClient => {
  return createBrowserClient(supabaseUrl, supabaseKey, {
    auth: {
      detectSessionInUrl: true,
      flowType: "pkce",
    },
    cookies: {
      getAll() {
        return parseCookieHeader(req.headers.cookie || "");
      },
      setAll(cookiesToSet: { name: string; value: string; options: any }[]) {
        cookiesToSet.forEach(({ name, value, options }) => {
          res.setHeader("Set-Cookie", serializeCookieHeader(name, value, options));
        });
      },
    }
  });
};

この辺の実装は公式を参考にしました。
https://supabase.com/docs/reference/javascript/auth-signinwithoauth
https://supabase.com/docs/reference/javascript/auth-signinwithoauth
https://supabase.com/docs/guides/auth/social-login/auth-discord?queryGroups=environment&environment=server&queryGroups=framework&framework=express

この状態でサーバを起動、/auth/signin にアクセスすると...

discord の認可画面に飛びます(画像はログイン後)。
「認証」をクリックすると...

ちょっと分かりづらいですが、以下のように遷移し、結果的にログインが行われてることがわかります。

GET /auth/signin 302 
GET /auth/signin/callback?code=random-code-here 302
GET / 304 

問題発生

これで実装完了!かと思ったら、思わぬ部分に罠が潜んでました。

処理ができたので、ejs を導入し、ルートページにログインリンクを設置しました。

<a href="/auth/signin">Sign In</a>
<a href="/auth/signout">Sign Out</a>

もちろんこれを踏むと先ほどと同じ動作が発生する...と思いきや...

GET /auth/signin 302
GET /?code=random-code-here

なぜか、先ほどとは違うリダイレクト先にリダイレクトしていることがわかります。
参考までに、別の静的ファイルに同じリンクを設置して踏んでみたりしましたが、こちらは正常に処理されました。どうやら ejs(もしくはテンプレートエンジン)特有の問題のようです。

現状整理

PKCE フローを採用した OAuth 機能を実装し、

  • 直にリンクへアクセスする(静的ファイル、アドレスバー)

  • ejs で生成されたビューからアクセスする
    のふたつの方法でリンクを発火させましたが、前者は成功し、後者は失敗しました。
    ふたつの方法で発生したリクエストは以下の通りです。

  • 直にリンクへアクセスする(静的ファイル、アドレスバー)

GET /auth/signin 302
GET /auth/signin/callback?code=random 302
GET / 304
  • ejs で生成されたビューからアクセスする
GET /auth/signin 302 
GET /?code=random 200

検証

さて、検証に入ります。

リクエストのクッキーを比較する

認証データの受け渡しに cookie が使われていると踏んだので、まずはリクエストのクッキーに含まれるデータの差を比較しました。

結果、成功したリクエストには sb-random-auth-token-code-verifier が含まれていました。名前からして Code Challenge に使用する code verifier とみてよさそう。
この辺から「リクエスト形式が不十分である」という認識で調べます。

createServerClient()debugtrue にする

createServerClient() 関数の options 引数には、デバッグログを有効にする debug パラメータが存在します。これを使用しました。

が、とくに差分はなし。

signinWithOauth() が返す url を比較

/signin 内で supabase に認証用エンドポイントの URL をリクエストしていますが、もしかしたらここの redirect_url が空では?と思って比較。

これもとくに差分はなし。以下の形式でした。

https://random.supabase.co/auth/v1/authorize?provider=discord&redirect_to=localhost%3A3000%2Fauth%2Fsignin%2Fcallback&code_challenge=random&code_challenge_method=plain

User-Agent ヘッダを比較

公式ドキュメントの以下の記述を見て、デバイスの比較をしました。

コード検証は、認証フローが最初に開始されたときにローカルに作成され、保存されます。つまり、コード交換は、フローが開始されたのと同じブラウザとデバイスで開始する必要があります。

これも特に差分なし。

クライアント側エラーに着目する

八方塞がりになりかけていたところ、ブラウザ側でなにやらエラーが出ていることに気付きました。以下は成功パターンに出ていたエラー。

/oauth2/authorize?client_id=1263686874762055743&redirect_to=localhost%3A3000%2Fauth%2Fsignin%2Fcallback&redirect_uri=https%3A%2F%2Fpytzrwcvmmmmxaqhmway.supabase.co%2Fauth%2Fv1%2Fcallback&response_type=code&scope=email+identify&state=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3MjE0ODc2MTIsInNpdGVfdXJsIjoiaHR0cDovL2xvY2FsaG9zdDozMDAwL2F1dGgvc2lnbmluL2NhbGxiYWNrIiwiaWQiOiIwMDAwMDAwMC0wMDAwLTAwMDAtMDAwMC0wMDAwMDAwMDAwMDAiLCJmdW5jdGlvbl9ob29rcyI6bnVsbCwicHJvdmlkZXIiOiJkaXNjb3JkIiwicmVmZXJyZXIiOiJodHRwOi8vbG9jYWxob3N0OjMwMDAvYXV0aC9zaWduaW4vY2FsbGJhY2siLCJmbG93X3N0YXRlX2lkIjoiZmM2Y2I4NDQtYzgzOS00ODNiLWEyZDUtNzk4YjNhNWU5YzRiIn0.BaLRfe0d-u8zYVXHfGWg6P5hMPLIO_37Ch42ONic06o
:50 Refused to load the font

エラー自体はどうでもよさそうですが、おそらくその前の記述は URL のパスを示しているものと思われます。
もしかして、と思って失敗パターンも確認してみると...

authorize
:50 Refused to load the font

パラメータがないことに気が付きます。
不具合の原因は、認証画面のエンドポイントへのURLに正常にクエリパラメータが設定されていないこと のようです。

対応

原因が分かったところで公式ドキュメントを読み漁っていたところ、以下のような記事を見つけました。
https://supabase.com/docs/guides/platform/oauth-apps/build-a-supabase-integration

@supabase/ssr を使用してバックエンド認証を実装する、おそらくは公式で最も新しい記事です。
この記事の中盤には、以下のような記述があります(日訳)。

アプリの UI 内で、ユーザーを https://api.supabase.com/v1/oauth/authorize にリダイレクトします。次のような必須のクエリパラメータをすべて含めるようにしてください。

client_id: 上記のアプリ作成時のクライアント ID。
redirect_uri: 同意後に Supabase がユーザーをリダイレクトする URL。
response_type: これを code に設定します。
state: アプリの状態に関する情報。 redirect_uriとstateを合わせたサイズは 4kB を超えることはできません。

要するに、上に書かれたパラメータを埋めるように入力していけばよさそうです。
ここに関しては公式が Deno のサンプルしか書いていないので(なんで?)、Node.js では以下のように実装しました。

router.get("/signin", async (req: Request, res: Response) => {
    ...

    const clientId = process.env.DISCORD_CLIENT_ID;
    if (!clientId) {
    return res.status(500).send("No client ID in env");
    }
    
    const protocol = req.protocol;
    const origin = req.get("host");
    const redirectUri = `${protocol}://${origin}/auth/signin/callback`;
    
    const authUrl = new URL(data.url);
    authUrl.searchParams.append("client_id", clientId);
    authUrl.searchParams.append("response_type", "code");
    authUrl.searchParams.append("redirect_to", redirectUri);

    ...
}

この状態でもう一度リンクから遷移すると...

GET /auth/signin 302
GET /auth/signin/callback?code=random-code-here 302
GET / 304

問題なくリダイレクトされました!

おわりに

ハッカソンメンターなのに「認証で詰まる」とかいう最悪のアンチパターンを踏みましたが、学びがあったのでよしとします。

Progate Path コミュニティ

Discussion