React Router SPA+Hono+Cloudflare WorkersにGoogleログイン認証を追加する
React Router SPA+Honoに@hono/auth-jsによるGoogleログイン認証を追加する。
コードは以下にある(以前の記事で作成したもののauthブランチ)。
SPA配信を改修
前回の記事ではCloudflare Workersの提供するsingle-page-applicationのルーティングを利用して実装した。この設定ではSec-Fetch-Mode: navigate
ヘッダーがついているものをブラウザアクセスと見做し、assetsまたはindex.htmlを配信する。これは/auth/callback
のようなURLにリダイレクトして認証情報を返却するOAuthとは食い合わせが悪い。リダイレクトしてAPIサーバーにセッションクッキーをセットアップするところがindex.htmlが返ってくるためだ。この問題自体は既にissueに起票されている(一週間前で最近すぎて驚いた)。
この記事では、issueで示されている一つ目のワークアラウンドであるnot_found_handling: "single-page-application"
を使わずにHono側でSPA配信をする形で実現する。なお、二つ目のrun_worker_first: true
の方はうまく動作するものが書けなかった。
改修は以下の通りである。
not_found_handling
を変更しassetsがなければworkerに流すようにして、assetsをenv.ASSETS
に結びつける。
"assets": {
"directory": "./build/client/",
- "not_found_handling": "single-page-application",
+ "binding": "ASSETS",
},
Cloudflare環境、つまりはローカルVite環境以外でindex.htmlを配信するようにする。
+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()
をする必要があるだろう。この辺りは良い記述が知りたい。
Googleログイン認証をつける
まず、上記の記事を参考にGoogle Cloudコンソールで作業をした。
API側の実装
@hono/auth-jsのREADME通りにパッケージを導入し、実装すれば良い。
上記コードの実装は元のサンプルにあったGET /api
をGET /api/secure
に変えた変更も含む。これは/api
がミドルウェアverifyAuth
が適用される/api/*
には含まれない都合、変えたものである。
この変更を入れれば、/about
はclientLoader
がGET /api/secure
にアクセスするため、(ErrorBoundaryのケアは含まないが)Unauthorizedでアクセスできないようになる。ログイン・ログアウトは@auth/coreが提供する/api/auth/signin
、/api/auth/signout
を利用する形となる。
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;
}
リクエストは発生しないが見せたくないページを作る場合はclientLoader
でconst session = await getSession()
を呼び、session
の有無でログインページにリダイレクトする機構が必要になるだろう。
ログアウトはonClick
でsignOut({ callbackUrl: '/' })
を呼ぶだけだ。
デプロイの注意点
- Google CloudコンソールのOAuthクライアントのURLにデプロイ先を追加する
-
npx wrangler secret bulk .dev.vars
を使うと当然AUTH_URL
が異なる
が実際にハマった。
あとがき
冷静に考えれば当然の挙動だが、Worker側のルーティングがCloudflare上だとブラウザからは見えないのに面食らった。とはいえ、無事ワークアラウンドが見つかってよかった。何か活用していきたい。
Discussion