ぱいぱいにっき

Pythonが好きすぎるけれど、今からPerlを好きになりますにっき

Firebase AuthenticationのSafari 16.1で動作しなくなる問題の解決過程

みなさん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としてすでに上がっていました。

github.com

10月22日時点でSafari 16.1 betaで事象が起こっているよということらしいです。ちなみにissueを上げられた方はiOSではなくMacSafariを使って事象が発生したようです。

firebase-js-sdkにはsignInWithRedirectという関数があります。

firebase.google.com

これは、挙動としては別のページに遷移し、そこでOAuthを行なってコールバックまで処理した上で、元のWebサイトに戻ってきてFirebaseのID TokenがJSに渡されるようになっています。

一方上記のissueで報告した方は、別の認証方法であるsignInWithPopupだとログインが可能であるとも言っています。こちらは別のウィンドウが開いて、そこでOAuthを行います。ウィンドウが開けないモバイル環境ではタブで代用されますが、タブすらないWebViewでは使うことができないデメリットがあります。私たちのアプリケーションではモバイルではsignInWithRedirect、PCではsignInWithPopupを使うようにしていました。

解決編: signInWithCredentialを用いる

とりあえずよくわからないが、signInWithRedirectを悪者ということにして他の手段を探ります。後述しますが、原因はSafari 16.1で強化されたITPでドメインをまたいだcookieが読めなくなったことなのですが、この時は何が原因なのか分かっていません。なので当てずっぽですが、開発サーバ上で色々試していきます。

先述したissueや、Firebase Authのドキュメントを見ていたところ、signInWithRedirectsignInWithPopup以外にsignInWithCredentialがあると書かれています。こちらは、OAuthもしくはOIDCで手に入れたアクセストークンなどを変換してFirebase Authに渡すことによって、認証を行うものです。

firebase.google.com

signInWithRedirectsignInWithPopupでは、アクセストークンやID Tokenを手に入れるのはFirebase Authの方の責務でしたが、signInWithCredentialを使う限りは、私たちのサービスで行い、そのあとはfirebase-js-sdkで行うということになります。

これは元々のFirebase Authを導入した理由であった、異なるIdP毎に実装を行うのを省略できるメリットがなくなることを意味します。しかしにっちもさっちもいきません。やっていく!

ちなみに私たちのアプリケーションでは認証部分はPerlで書かれています。IdP毎の認証を行なってsignInWithCredentialにアクセストークンなどを渡すのもPerlで実装しました。なのでPerl Advent Calendarにこの記事を書いています。

Google

firebase.google.com

基本的にはどの認証手段もAuthorization Code Flowでやっていきます。ドキュメントは上記。OIDCで認証してID Tokenを得ればいいっぽいので、以下のドキュメントでやっていきます。

developers.google.com

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を渡してあげる必要がありそうです。

firebase.google.com

Authorization Code FlowでAccess Tokenを得ているだけなので割愛。

Twitter

firebase.google.com

ちょっと毛色が違います。Twitterのドキュメントを見ると、TwitterはOAuth 1.0aと書かれています。

developer.twitter.com

めんどくさいな!ということで、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.google.com

私たちは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さんの記事を参考にやってみたとか、インストールしてみた、とかでも良さそうです。ぜひ参加をお待ちしています。