記事一覧に戻る
Service Workerに認証ページを食べられた話

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 OAuthJavaScript (window.location)なし
Zero TrustHTTP 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を検討したほうがよさそうです。