Service Workerに認証ページを食べられた話
Cloudflare Zero TrustとService Workerの組み合わせで認証画面が出ない問題。原因はHTTPリダイレクトとSWの根本的な相性の悪さでした。
ステージング環境を第三者から保護したい。でもBasic認証は面倒だし、テスターごとにパスワードを管理するのも大変です。
そこでCloudflare Zero Trustを導入しました。GitHub認証を設定して、開発者のGitHubアカウントだけを許可。これなら認証情報の管理も不要で、開発者以外はアクセスできません。
ところが、認証画面が出ません。
結論を先に言うと、Service Workerと302リダイレクト認証は根本的に相性が悪いです。 結局、IP制限に切り替えることで回避しました。
症状
Zero Trustの設定は正しいはず。Cloudflareのダッシュボードでも有効になっている。
試しにブラウザのプライベートモードで開いてみると、ちゃんとGitHub認証画面が表示されました。
つまり、新規ユーザーには認証画面が出るけど、既存ユーザーには出ないという状態。
最初の仮説: navigateFallback
既存ユーザーだけ問題が起きる。プライベートモードでは動く。となると、Service Workerが怪しい。
// vite.config.ts(vite-plugin-pwa)
VitePWA({
workbox: {
navigateFallback: "/index.html",
},
})
navigateFallback が認証フローを横取りしているのでは?そこで navigateFallbackDenylist に /cdn-cgi を追加してデプロイ。動作確認のため、開発者ツールからService Workerを手動でunregisterしました。
navigateFallbackDenylist: [
/^\/cdn-cgi($|\/)/,
]
認証画面が表示されるようになり、解決したかに見えました。
しかし、認証セッションが切れると再び認証画面が出なくなりました。
よく考えると、認証画面が出るようになったのはunregisterしたからであって、navigateFallbackDenylist の効果ではありませんでした。そもそもリダイレクト先は外部ドメイン(<cloudflare-access-domain>.cloudflareaccess.com)なので、自サイトの navigateFallbackDenylist は関係ありません。2つのことを同時にやったせいで、何が効いたのかわからなくなっていました。
真の原因: SWはクロスオリジン302を処理できない
DevToolsのNetworkタブを詳しく見ると、<cloudflare-access-domain>.cloudflareaccess.com へのリクエストが net::ERR_BLOCKED_BY_CLIENT でエラーになっていました。
これはService Workerが原因です。
Zero Trust認証のフロー
1. <your-site>.example.com/ にアクセス
↓
2. Cloudflareが302で <team-name>.cloudflareaccess.com にリダイレクト
↓
3. GitHub等で認証
↓
4. 認証後、元のURLに戻る
問題: Service Workerが302をブロック
Service Workerがナビゲーションリクエストを処理する場合、以下のように動作します。
1. ブラウザが / にアクセス
↓
2. Service Workerがfetchイベントをインターセプト
↓
3. SWがネットワークにリクエスト
↓
4. サーバーが302で外部ドメインにリダイレクト
↓
5. SWがrespondWith()でレスポンスを返そうとする
↓
6. クロスオリジンへのリダイレクトはブロックされる
↓
7. net::ERR_BLOCKED_BY_CLIENT
これはService Workerの仕様上の制限です。SWがrespondWith()でレスポンスを返す場合、クロスオリジンへの302リダイレクトは正しく処理できません。
Supabase認証との違い
「でもSupabaseのOAuth認証は動いているけど?」と思うかもしれません。
違いはリダイレクトの方法です。
| 認証方式 | リダイレクト方法 | SWの関与 |
|---|---|---|
| Supabase OAuth | JavaScript (window.location) | なし |
| Zero Trust | HTTP 302レスポンス | あり |
Supabaseの場合、supabase.auth.signInWithOAuth() がJavaScriptで window.location を変更します。これはService Workerのfetchイベントを通りません。
Zero Trustの場合、サーバーがHTTP 302レスポンスを返します。これはService Workerがインターセプトしてしまいます。
NetworkFirstでも解決しない
「じゃあナビゲーションをNetworkFirst戦略にすれば?」と考えました。
runtimeCaching: [
{
urlPattern: ({ request }) => request.mode === "navigate",
handler: "NetworkFirst",
},
]
結果は同じでした。NetworkFirstでもSWはリクエストを処理し、respondWith() を呼びます。302レスポンスをブラウザに返そうとした時点でブロックされます。
回避策
SW側での解決は諦めました。302リダイレクトが発生する限り、SWの仕様上どうしようもありません。
残された選択肢は、302が発生しない認証方式に変更することです。
IP制限
特定のIPアドレスからのアクセスのみ許可します。今回はこれを採用しました。
- 302リダイレクトが発生しない
- 設定が簡単
- ただしIPが変わるたびに設定変更が必要
Cloudflare WARP
WARPクライアント経由のアクセスを自動認証する方式。Cloudflareの公式ドキュメントでも「302リダイレクトを処理できないアプリ向け」と記載されています。
- 302リダイレクトが発生しない
- 全員がWARPクライアントをインストールする必要がある
- Tailscale等の他のVPNと競合する可能性あり
どちらも「GitHub認証でログイン」のような体験は得られません。苦肉の策です。
教訓
Service Workerと302リダイレクト認証は根本的に相性が悪いです。
navigateFallbackDenylist で認証パスを除外しても、ナビゲーションをNetworkFirstにしても解決しません。SWがリクエストを処理する限り、クロスオリジンへの302リダイレクトはブロックされます。
PWAでZero Trustを使う場合は、最初からIdP認証を諦めてIP制限やWARPを検討したほうがよさそうです。