🔐

React Router SPA+Hono+Cloudflare WorkersにGoogleログイン認証を追加する

に公開

React Router SPA+Honoに@hono/auth-jsによるGoogleログイン認証を追加する。
コードは以下にある(以前の記事で作成したもののauthブランチ)。

https://github.com/ofk/example-react-router-hono/tree/auth

SPA配信を改修

前回の記事ではCloudflare Workersの提供するsingle-page-applicationのルーティングを利用して実装した。この設定ではSec-Fetch-Mode: navigateヘッダーがついているものをブラウザアクセスと見做し、assetsまたはindex.htmlを配信する。これは/auth/callbackのようなURLにリダイレクトして認証情報を返却するOAuthとは食い合わせが悪い。リダイレクトしてAPIサーバーにセッションクッキーをセットアップするところがindex.htmlが返ってくるためだ。この問題自体は既にissueに起票されている(一週間前で最近すぎて驚いた)。

https://github.com/cloudflare/workers-sdk/issues/8879

この記事では、issueで示されている一つ目のワークアラウンドであるnot_found_handling: "single-page-application"を使わずにHono側でSPA配信をする形で実現する。なお、二つ目のrun_worker_first: trueの方はうまく動作するものが書けなかった。

改修は以下の通りである。
not_found_handlingを変更しassetsがなければworkerに流すようにして、assetsをenv.ASSETSに結びつける。

‎wrangler.jsonc
   "assets": {
     "directory": "./build/client/",
-    "not_found_handling": "single-page-application",
+    "binding": "ASSETS",
   },

Cloudflare環境、つまりはローカルVite環境以外でindex.htmlを配信するようにする。

server/index.ts
+if (!import.meta.env?.DEV) {
+  app.get('*', async (c) => {
+    const resp = await c.env.ASSETS.fetch(c.req.raw.url.slice(0, -c.req.path.length), c.req.raw);
+    return new Response(resp.body, resp);
+  });
+}

ガード文が入っているのはローカルVite環境にはc.env.ASSETSがないためである。ルーティング定義でガードするのが微妙であれば、ルーティングないで分岐してawait next()をする必要があるだろう。この辺りは良い記述が知りたい。

https://github.com/ofk/example-react-router-hono/commit/1bf8e6e294cfbc21bb969b4f0677fe961e8ae3a3

Googleログイン認証をつける

https://zenn.dev/hirokisakabe/articles/ede0cad8d88a9f

まず、上記の記事を参考にGoogle Cloudコンソールで作業をした。

API側の実装

@hono/auth-jsのREADME通りにパッケージを導入し、実装すれば良い。

https://github.com/ofk/example-react-router-hono/commit/6b756b3c32d2f9d6def039d5f3215e253b3c051e

上記コードの実装は元のサンプルにあったGET /apiGET /api/secureに変えた変更も含む。これは/apiがミドルウェアverifyAuthが適用される/api/*には含まれない都合、変えたものである。

この変更を入れれば、/aboutclientLoaderGET /api/secureにアクセスするため、(ErrorBoundaryのケアは含まないが)Unauthorizedでアクセスできないようになる。ログイン・ログアウトは@auth/coreが提供する/api/auth/signin/api/auth/signoutを利用する形となる。

https://authjs.dev/getting-started/session-management/login

SPA側の実装

@auth/coreの提供するログイン・ログアウトページを利用するなら上記で実装は終わりだが、サポートするのはGoogle認証のみなので直接Googleのログインページに移動したい、ログアウトはボタンから直接行いたいという欲求がある。特に後者は必然的に起こる。よって、SPAも改修をする。

ログインは認証ありのページを踏んだらリダイレクトする仕様とした。
よって、ErrorBoundaryでUnauthorizedなエラーを判定して、signIn('google')を呼ぶ形とした。

export function ErrorBoundary({ error }: Route.ErrorBoundaryProps): null {
  if (error instanceof Error && error.message.startsWith('Unauthorized')) {
    void signIn('google'); // 内部でwindow.location.hrefを呼びリダイレクトされる
    return null;
  }
  throw error;
}

リクエストは発生しないが見せたくないページを作る場合はclientLoaderconst session = await getSession()を呼び、sessionの有無でログインページにリダイレクトする機構が必要になるだろう。

ログアウトはonClicksignOut({ callbackUrl: '/' })を呼ぶだけだ。

https://github.com/ofk/example-react-router-hono/commit/e2c01005113950921db7f00de5bac00d98d06461

デプロイの注意点

  • Google CloudコンソールのOAuthクライアントのURLにデプロイ先を追加する
  • npx wrangler secret bulk .dev.varsを使うと当然AUTH_URLが異なる

が実際にハマった。

あとがき

冷静に考えれば当然の挙動だが、Worker側のルーティングがCloudflare上だとブラウザからは見えないのに面食らった。とはいえ、無事ワークアラウンドが見つかってよかった。何か活用していきたい。

Discussion