みなさん2022年いかがお過ごしですか。macopyです。
この記事はPerl Advent Calendar 2022の9日目です。
追記: Firebase Advent Calendar 2022の9日目も空いていたので入れておきました。
今回はFirebase Authenticationを使っていたら、何もしていないのにログインできなくなったと言われて一心不乱で直した話をします。
Firebase Authenticationとは
Firebase Authentication(以下Firebase Auth)とは、Googleのアプリケーション開発プラットフォームであるところのFirebaseの中にある、認証サービスです。
競合サービスとしてはAuth0で、つまりIDaaSとして使えます。専用品のAuth0よりは機能は少ないですが、複数のIdPを組み合わせてユーザの認証管理をしたいという用途には十分使えます。
Auth0に比べて使用量が安いことから、他のFirebaseのサービス(FirestoreやHosting、Configなど)を使わずに、Authだけ使っているという方もいるのではないのでしょうか。私が仕事で開発しているサービスもその一つで、本体のWebアプリケーションはAWS上で動いているものの、IdP個別対応の工数を浮かすために、IDaaSの部分だけFirebaseを用いていました。
検知編: ある日突然なぜかログインできなくなる
ある日、いつのものようにサービスのエゴサーチをしていると「ログインできない」というツイートなどがポツポツと見受けられました。はじめはよくある事例としてWebViewだとセッションが切れる問題から「ログインできていない」という話なのかなと思ったのですが、いきなり複数人が言っているので、なんだかおかしいぞとなります。
また、そういう人たちの一部がスクリーンショットをあげていたのでよく見ると、全員共通してiOS Safariでした。
首を傾げていると、サービスのお問い合わせフォームにログインできないとの問い合わせが来たと報告を受けたので、問い合わせをくださった方のOSとバージョンを聞いてみました。するとみなさんiOS 16.1とおっしゃる。
「なるほど〜」
なぜか知らないけれど、iOS 16.1でログインができないことはわかった。ちなみに、この問題を検知したのは10月29日、iOS 16.1がReleaseされたのは10月24日のようです。リリースされてから一気に更新されるわけではなく、じわじわ浸透していくので、私たちのユーザさんのところまで来るまでそれぐらいかかったようです。
社内の検証機端末もiOS 16.1にあげて、事象が再現しました。挙動としては私たちのWebサービスからFirebase Authを経由してSNS連携先でログインを行った後に、私たちのWebサービスの戻っても、ログインができていない状態です。やれやれと思いました。経験上、OS依存で起こる現象は、解決に手間がかかることが多いです。
調査編: firebase-js-sdkで上がる声
これが起こる状況は分かった。あとはなぜ起こるのかをシミュレーター等で探しつつ、「自分たちのサービスだけで起こっている」のか、それとも「他のサービスも起こっている」のかです。それによって問題の出所が絞り込めますし、後者であればもうすでに誰かが原因と解決方法を見つけているのかもしれません。
早速「firebase auth ios 16.1」などでググると、GitHubにあるfirebase-sdk-jsリポジトリのissueとしてすでに上がっていました。
10月22日時点でSafari 16.1 betaで事象が起こっているよということらしいです。ちなみにissueを上げられた方はiOSではなくMacのSafariを使って事象が発生したようです。
firebase-js-sdkにはsignInWithRedirect
という関数があります。
これは、挙動としては別のページに遷移し、そこでOAuthを行なってコールバックまで処理した上で、元のWebサイトに戻ってきてFirebaseのID TokenがJSに渡されるようになっています。
一方上記のissueで報告した方は、別の認証方法であるsignInWithPopup
だとログインが可能であるとも言っています。こちらは別のウィンドウが開いて、そこでOAuthを行います。ウィンドウが開けないモバイル環境ではタブで代用されますが、タブすらないWebViewでは使うことができないデメリットがあります。私たちのアプリケーションではモバイルではsignInWithRedirect
、PCではsignInWithPopup
を使うようにしていました。
解決編: signInWithCredential
を用いる
とりあえずよくわからないが、signInWithRedirect
を悪者ということにして他の手段を探ります。後述しますが、原因はSafari 16.1で強化されたITPでドメインをまたいだcookieが読めなくなったことなのですが、この時は何が原因なのか分かっていません。なので当てずっぽですが、開発サーバ上で色々試していきます。
先述したissueや、Firebase Authのドキュメントを見ていたところ、signInWithRedirect
、signInWithPopup
以外にsignInWithCredential
があると書かれています。こちらは、OAuthもしくはOIDCで手に入れたアクセストークンなどを変換してFirebase Authに渡すことによって、認証を行うものです。
signInWithRedirect
やsignInWithPopup
では、アクセストークンやID Tokenを手に入れるのはFirebase Authの方の責務でしたが、signInWithCredential
を使う限りは、私たちのサービスで行い、そのあとはfirebase-js-sdkで行うということになります。
これは元々のFirebase Authを導入した理由であった、異なるIdP毎に実装を行うのを省略できるメリットがなくなることを意味します。しかしにっちもさっちもいきません。やっていく!
ちなみに私たちのアプリケーションでは認証部分はPerlで書かれています。IdP毎の認証を行なってsignInWithCredentialにアクセストークンなどを渡すのもPerlで実装しました。なのでPerl Advent Calendarにこの記事を書いています。
Google版
基本的にはどの認証手段もAuthorization Code Flowでやっていきます。ドキュメントは上記。OIDCで認証してID Tokenを得ればいいっぽいので、以下のドキュメントでやっていきます。
my $auth_uri = URI->new("https://accounts.google.com/o/oauth2/v2/auth"); $auth_uri->query_form( response_type => "code", client_id => $client_id, scope => "openid email", redirect_uri => $host_url . "/auth/google/callback", state => $state, nonce => $nonce, ); $c->redirect($auth_uri->as_string, 302);
こんな感じで認証URLに飛ばして、戻ってきたら、
$res = $furl->post( "https://oauth2.googleapis.com/token", [ "Content-Type" => "application/x-www-form-urlencoded" ], [ code => $code, client_id => $client_id, client_secret => $client_secret, redirect_uri => $host_url . "/auth/google/callback", grant_type => "authorization_code", ], ); my $content = decode_json($res->content); $c->session->set(google_id_token => $content->{id_token}); $c->redirect($redirect, 302);
こんな感じでいったんセッションに入れておいて、別のAPIエンドポイントで返してあげるようにしました。ただ、一回返したらもう消すようにしておきます。そんなにずっと覚えておきたい情報ではありません。 コールバックでID Tokenを返さずに別のAPIエンドポイントで返しているのは、コールバックURLはAPIではなくブラウザ上で実際に遷移しているので、signInWithCredentialを返すのは難しそうだからです。metaタグとかに埋め込んでレンダリングとかしてあげれば別なんでしょうけれども。
Facebook編
大体Googleと一緒ですが、FacebookはOIDCではなくOAuth2なのでaccess tokenを渡してあげる必要がありそうです。
Authorization Code FlowでAccess Tokenを得ているだけなので割愛。
Twitter編
ちょっと毛色が違います。Twitterのドキュメントを見ると、TwitterはOAuth 1.0aと書かれています。
めんどくさいな!ということで、Net::Twitter::Liteの手を借ります。
my $nt = Net::Twitter::Lite::WithAPIv1_1->new( consumer_key => $client_id, consumer_secret => $client_secret, ssl => 1, ); my $callback_uri = $host_url . '/auth/twitter/callback'; my $redirect_uri = $nt->get_authorization_url(callback => $callback_uri); $c->session->set(twitter_oauth_request_token => { request_token => $nt->request_token, request_token_secret => $nt->request_token_secret, }); $c->redirect($redirect_uri);
これで認証させて、返ってきたら
my $nt = Net::Twitter::Lite::WithAPIv1_1->new( consumer_key => $client_id, consumer_secret => $client_secret, ssl => 1, ); $nt->request_token($request_tokens->{request_token}); $nt->request_token_secret($request_tokens->{request_token_secret}); my ($access_token, $access_token_secret) = $nt->request_access_token( verifier => $oauth_verifier, ); $c->session->set(twitter_credentials => { token => $access_token, secret => $access_token_secret, }); $c->redirect($redirect, 302);
こんな感じでアクセストークンを手に入れます。
とまあこんな感じでここまで1日で終わらせて、検証もやりました。signInWithRedirect
時代に作ったアカウントに紐づくSNSアカウントでsignInWithCredential
でログインした際に、ちゃんと同じアカウントになることも確認しました。
次の日にもう一度確認してから本番反映し、めでたしめでたし。と言いつつ、Firebase Authを使っている意味が半減しているので、もっといい解決策はないかとissueを監視する日々が続きます。
後日: 公式の緩和策が出る
少し経ってから、issueに軽減策ガイドが出たよと貼られていました。
私たちはFirebase Auth以外でFirebaseを用いていないので、軽減策1は使えず、WebViewのユーザもサポートしたいので軽減策2は使えません。
signInWithCredential
を用いる方法は、軽減策5に当たります。ただ、こちらは実装の負担が大きいので別のものに切り替えたいところです。
軽減策3は良さそうに思えます。私たちのアプリはnginxがいるので、特定のパスに対してFirebase Authのアセットにproxyするようにすれば良さそうです。同じドメインでFirebaseの処理も動くので、cookieの問題も解決するわけですね。
まとめ
- Webアプリ何もしていないのに勝手に壊れることがある
- 今時SaaS使わずに作ることなんて多々あるし、ユーザのブラウザによっても壊れる。大変ですね
- 頭にOAuth2の仕様が入っていてサクッと本番直せたので良かったですね
さて、Perl Advent Calendar 2022の明日10日目の記事は誰も入っていないようですが、これを見てなるほどと思った方は、ぜひ書いてみてはいかかでしょうか。こんな感じでPerlかすっている記事でも良さそうですし、昨日8日目のtomchaさんの記事を参考にやってみたとか、インストールしてみた、とかでも良さそうです。ぜひ参加をお待ちしています。