Docker環境でモバイルログインが失敗:クロスプラットフォームの不具合事例
木曜日の午後、問い合わせが届きました。「モバイルでGoogleログインが使えない」。サポートのやり取りは短く、報告したのは3人で、全員がiOSを使っていました。OAuthの読み込み表示が回り続け、先に進まないという同じ症状です。Webアプリもデスクトップも正常に動作していました。失敗するのはモバイルのOAuthログインだけで、メールアドレスとパスワードによる認証には問題がありませんでした。
すぐに直せそうに見えて、午後いっぱい原因に悩まされるタイプの不具合です。あるプラットフォームだけで認証フローが動かないという表面的な症状からは、多くの原因が考えられます。リダイレクトURI、CORS、トークン交換、セッション保存、クライアント側の設定。どれも疑う理由はありましたが、実際の原因はそのどれでもありませんでした。
システム構成
このアプリケーションは、専用サーバーにDocker Composeの全スタックをデプロイしたセルフホスト型Supabaseで動作しています。Supabaseでは認証が独立したマイクロサービスのGoTrueに分離されており、OAuthフロー、トークン発行、ユーザー管理を担います。GoTrueはPostgreSQLに直接接続し、authスキーマへの読み書きを行います。フロントエンドはReact Nativeアプリケーションで、標準のJavaScriptクライアントライブラリを介してSupabaseと通信します。
データを自ら管理したいチームでは、比較的一般的な構成です。セルフホスト型Supabaseなら、PostgreSQLデータベース、認証層、リアルタイムのサブスクリプション、ストレージサービスを、すべて自社管理のコンテナで利用できます。その代わり、マネージドサービスが裏で処理してくれる細かな設定も、自ら管理すると障害の原因になります。今回のインシデントは、まさにその例でした。
最初の仮説:リダイレクトURIの不一致
モバイルのOAuthエラーでは、まずリダイレクトURIを疑います。ネイティブアプリはHTTPSのURLではなく、独自のURLスキーム(myapp://auth/callback)を使います。登録されたコールバックとOAuthプロバイダーが想定するものが一致しないと、今回のような目立ったエラーのない失敗が起こります。OAuthフローが始まり、ブラウザーが開き、ユーザーが認証を終えても、コールバックが完了しません。
当社はGoogle Cloud ConsoleのリダイレクトURI設定、Supabaseのプロジェクト設定、React Nativeアプリのディープリンク設定を確認しました。すべて一致しており、URIは双方に正しく登録されていました。OAuthの同意画面もエラーなく完了し、Googleはログインを受け付けていました。失敗はコールバック後のトークン交換のどこかで起きていました。
2つ目の仮説:CORS
モバイルOAuthのデバッグで次に疑うのは、CORS(Cross-Origin Resource Sharing)のヘッダーです。独自ドメインのバックエンドにモバイルアプリからリクエストを送る場合、CORS設定によってブラウザーのセキュリティモデルがそのリクエストを許可するかが決まります。CORSポリシーの設定不備は、認証失敗と見分けがつかないエラーを引き起こすことがあります。
ただし、CORSはブラウザーが適用する制約であり、ネイティブアプリが独自のHTTPクライアントを通じて送るリクエストには適用されません。メール認証は正常でOAuthだけが失敗するという症状は、CORSの問題とは整合しませんでした。どちらも同じGoTrueエンドポイントを使うからです。CORSがリクエストを拒否するなら、OAuthだけでなく両方に影響する可能性が高いはずです。モバイルクライアントに詳細ログを追加すると、リクエストはGoTrueに届き、応答も返っていました。ステータスコードは500でした。
実際のログを読む
コンテナログの扱いは、セルフホスト型とマネージドサービスのデバッグで最も違いが出る部分です。Supabase Cloudでは、ダッシュボードで各サービスの構造化ログを確認できます。セルフホスト型のDocker環境では、各コンテナから直接ログを取得します。
docker logs supabase-auth --since 1h 2>&1 | grep -i error
GoTrueコンテナは継続的にエラーを記録していました。該当する行は次のとおりです。
level=error msg="Error creating oauth state" error="ERROR: function gen_random_uuid() does not exist (SQLSTATE 42883)"
OAuthのリダイレクトエラーではなく、PostgreSQLが関数を解決できないエラーでした。PostgreSQL 13より前のバージョンでは、gen_random_uuid()はpgcrypto拡張機能のみが提供する関数です。Supabaseは、この標準的なPostgreSQL拡張機能をextensionsスキーマにインストールします。既定のpublicスキーマではありません。PostgreSQL 13以降では、この関数はコアにも組み込まれ、常に解決できるため、このエラーは古いメジャーバージョンを示しています。GoTrueはスキーマ名を付けずにgen_random_uuid()を呼び出し、検索パスからPostgreSQLが関数を見つけることを想定していました。しかし、関数は見つかりませんでした。
search_pathの役割
PostgreSQLは、スキーマ名のない関数名やテーブル名を解決するとき、指定されたスキーマを順に検索します。既定値は"$user", publicで、まず現在のデータベースユーザーと同名のスキーマを探し、次にpublicを探します。別のスキーマにある関数を呼び出す際に、extensions.gen_random_uuid()のようなスキーマ付きの名前ではなく、gen_random_uuid()のようにスキーマ名を省いた名前を使うと、関数が実際に存在していてもPostgreSQLは「存在しない」というエラーを返します。
Supabaseのマネージド環境では、PostgreSQLのsearch_pathにextensions、auth、publicを含めます。この設定はSupabaseのコードベースに記載されていますが、セルフホスティングのセットアップガイドでは目立つ形で紹介されていません。マネージド環境では自動的に適用されます。セルフホスト型のDocker環境では、適用した環境変数や初期化スクリプト次第です。当社の環境には、どちらの設定もありませんでした。
Webでは動き、モバイルでは失敗した理由
ログの説明だけでは、Webの認証が成功してモバイルだけが失敗した理由はまだ分かりません。その答えは、環境ごとのOAuthフローの違いにあります。
Supabase OAuthを使うWebアプリケーションは、既定でImplicitフローを使用します。認可サーバーはOAuthコールバック後、URLフラグメントにトークンを直接返します。クライアントはURLからトークンを取り出して保存し、別途トークン交換を行わずにセッションを確立します。この処理経路では、たまたまgen_random_uuid()を呼び出していませんでした。
現在のセキュリティ上の推奨事項に従うモバイルアプリケーションは、PKCE(Proof Key for Code Exchange)を使います。PKCEでは、クライアントがランダムなコードベリファイアを生成し、そのハッシュからコードチャレンジを作って認可サーバーに送ります。コールバック後、クライアントは元のコードベリファイアをトークンエンドポイントに送り、そこで検証された後にトークンが発行されます。失敗していたのは、このPKCEフローの処理経路でした。GoTrueはPKCEフローのOAuth状態を生成する際にgen_random_uuid()を呼び出していましたが、検索パスにextensionsスキーマが含まれていなかったのです。
WebのImplicitフローは、この特定の処理経路を通りませんでした。モバイルのPKCEは通ります。OAuth実装のわずかな違いが、Dockerスタックのデプロイ当初から存在していた設定不備を表面化させました。モバイルクライアントがPKCEフローを実行するまでは、問題のある処理が使われていなかっただけです。
修正方法
正しい対処は、GoTrueとPostgRESTが依存するすべてのスキーマをPostgreSQLのsearch_pathに含めることです。Docker Compose環境では、接続するコンテナにかかわらず設定を維持できるよう、データベース側で適用するのが最も分かりやすい方法です。
-- Run once against the database
ALTER DATABASE postgres SET search_path TO extensions, auth, public, storage;
ALTER ROLE authenticator SET search_path TO extensions, auth, public, storage;
ALTER ROLE supabase_auth_admin SET search_path TO extensions, auth, public, storage;
別の方法として、Docker ComposeのPostgreSQLサービス設定から値を渡すこともできます。
services:
db:
image: supabase/postgres:15.1.0.117
command:
- postgres
- -c
- search_path=extensions,auth,public,storage
当社は両方を適用しました。GoTrueコンテナを再起動すると、モバイルのOAuthはエラーなく完了しました。デバッグ中にはテストをためらっていたAppleログインもすぐに動作し、問題がプロバイダー固有ではなくフロー固有であることを確認できました。
このインシデントから分かるセルフホスト環境のデバッグ
「モバイルで認証が動かない」から「PostgreSQLのsearch_pathが誤っている」にたどり着くまで、必要以上に時間がかかりました。ここからいくつかの教訓を得られます。
コンテナログが事実を示します。当社はGoTrueのログを読む前に、誤った2つの仮説を追ってしまいました。ログの最初の関連エントリには、正確なエラーが記録されていました。セルフホスト型のDockerスタックでは、ログ確認を最初に行うべきです。他の仮説をすべて試した後の最後の手段にしてはいけません。
共通のバックエンドでプラットフォーム固有の失敗が起こる場合、ほとんどはクライアントの処理経路の違いが原因であり、バックエンドがそのクライアントを特別扱いしているわけではありません。バックエンドは、相手がモバイルアプリかブラウザーかを知りません。リクエストのパラメータで決まる処理経路をたどります。Implicitフローに対してPKCEがどの処理経路を使うかを理解していれば、すぐにPKCEフローの状態へ注目できたはずです。
マネージドサービスとセルフホスト型サービスは、同じ設定ではありません。Supabase Cloudは、文書化されていてもDocker Compose環境には自動適用されない細かな設定を適用します。Supabase Cloudで動くものがセルフホスト環境で失敗する場合、その違いはほとんどの場合こうした設定にあります。公式セルフホスティングリポジトリのIssueを調べると、不足している設定を最も早く見つけられることがあります。
「1つのプラットフォームだけに影響する」不具合は、実は最初から存在していて、特定の処理経路を初めて使ったクライアントによって表面化しただけということがあります。今回のsearch_pathの誤りも、Dockerスタックをデプロイした日から存在していました。数か月後にモバイルのOAuthを実装して、初めて発覚したのです。