🐦

X OAuth2.0 が Twitter にまだ囚われている話

2024/10/25に公開

Twitter -> X

今年(2024)、5月17日ぐらいをさかいに、旧Twitterのドメインである twitter.com から新しい x.com へリダイレクトを開始したのが記憶に新しいかと思います。
それに伴い、裏側のAPIも順次 api.twitter.com から api.x.com へ移行が始まりました。

https://developer.x.com/en/products/x-api

OAuth1.0aとOAuth2.0

私たち 株式会社luco が運営・開発している スコラボ (https://sucolab.jp) では、Twitter(X)新規登録/ログインにOAuth1.0aではなく比較的新しいOAuth2.0を採用しています。
https://sucolab.jp
スコラボではTwitter(X)のほかに、Googleでの新規登録/ログインも対応しております。これらの新規登録/ログインの実装は、バックエンドにLaravelを採用しているため laravel/socialite というOAuthラッパーを使用して実装しています。
https://github.com/laravel/socialite

なぜTwitter(X)での新規登録/ログインで現在主流のOAuth1.0aではなくOAuth2.0を使用したか

1. Googleなど他の多くのサービスでは、現在OAuth2.0が主流(というかこれのみ)となっている

主な違いとして、OAuth1.0aや1.1ではステートフルだったのに対し、2.0ではステートレスとなっております。
そのため、認可の最初のリダイレクト時からコールバックまで処理の実装方法が大きく異なり、スコラボでもTwitter(X)での新規登録/ログインでのみ処理を分岐する必要があったため、ソースコード上でノイズとなっていました。

2. なぜかOAuth1.0aが不安定な時期があり、その期間OAuth2.0は元気だった

x.comが移行タイミングだったからなのかは不明ですが、Sentryにエラーが度々飛んできていました。

今後の運用を考えたときに、メールアドレスが取得できないなどの問題はあるものの Twitter(X) を用いた新規登録/ログインを安定して提供するために OAuth2.0 に移行することにしました。

問題の発見

基本的にOAuthやプロフィール情報の取得などのAPIは後方互換性があるため、問題はありません。
そのため、特に対応をせずとも api.twitter.com が非推奨にならない限りはコードベースを変更する必要はないなと思っておりましたが、OAuthでの新規登録/ログイン時に問題を見つけました。
x.com でメインで新規登録/ログインしているアカウントではないアカウントで認可処理が行われてしまうことがあるのです。

twitter.comx.com とで新規登録/ログインしているアカウントが異なる"場合がある"のです。
現在、これらは Cookie のドメイン空間により分けられておりセッションをそれぞれ別で持つことができる状態になっていました。

通常、ユーザーは twitter.com から x.com へリダイレクトされたときにログアウトされた状態になっているため再度同じアカウントで新規登録/ログインします。しかし、人によっては複数アカウントがある場合もあり、最後に twitter.com で使用したアカウントがメインではない可能性があります。その後、 x.com でメインアカウントで新規登録/ログインすると先程述べたとおり、 twitter.comx.com とで別々のアカウントにメインで新規登録/ログインした状態となってしまいます。

そしてここからが重要で、現在OAuth1.0aは twitter.comx.com へリダイレクトしますが、OAuth2.0ではリダイレクトされません

フォーラムでのユーザーによる報告
https://devcommunity.x.com/t/oauth-authorization-issue-with-unexpected-accounts/219919

この状態で、スコラボ (https://app.sucolab.jp) にTwitter(X)で新規登録/ログインしようとすると、 x.com ではなく twitter.com で認可処理が始まってしまい、ユーザーが意図していないアカウントで新規登録/ログインされてしまいます。
また、この際のOAuth2.0の画面上ではアカウントをスイッチすることができず、また twitter.com 上でアカウントを変えようにもリダイレクトされてしまうため一般ユーザーでは twitter.com のセッションを書き換えることもできなくなってしまいました。

解決法

私たちはこの問題を見つけた当初、OAuth1.0aへ戻すことも検討しましたが、 laravel/socialte 内のコードをいじって認可に使うURLを直接 api.x.com へ買い替えたらどうなる?と検証してみました。
すると、 x.com 上でOAuth2.0が動きそのままコールバックまで正しく動作することがわかりました。

そのため、コントリビューションチャンスということで laravel/socialite へプルリクエストを出しつつ、私たち側のバックエンドでは一旦 laravel/socialite にカスタムプロバイダを追加してURLを上書きすることにしました。

実際のPR

https://github.com/laravel/socialite/pull/712

upstreamにマージされるまでのバックエンドでの暫定対応

AppServiceProviderregister メソッド内で SocialiteManager をDIに登録します。
SocialiteManagerIlluminate\Support\Manager を拡張しているため、 extend を用いて 'twitter' という名前のドライバをカスタムした XProvider で上書きします。
この際、トークン等のクレデンシャルも一緒に渡しておきます(OAuth2.0が有効なトークンである必要があります)。

app/Providers/AppServiceProvider.php
<?php

class AppServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        // ...

        $this->app->singleton(SocialiteManager::class, static function ($app) {
            $manager = new SocialiteManager($app);

            return $manager->extend('twitter', static function ($app) use ($manager): AbstractProvider {
                return $manager->buildProvider(XProvider::class, $app['config']['services.twitter']);
            });
        });
    }

次に、上記で使用した XProvider を用意します。
内容は上記で作成したPRでの実装内容と同じです。OAuth2.0である Laravel\Socialite\Two\TwitterProvider を extends し、内部で twitter.com と使っている箇所すべてを x.com で置き換えているだけのかんたんな実装です。
場所はお好きな場所においてください。

XProvider.php
  <?php

  declare(strict_types=1);

  namespace App\Utils\Socialite;

  use GuzzleHttp\RequestOptions;
  use Illuminate\Support\Arr;
  use Laravel\Socialite\Two\TwitterProvider;

  class XProvider extends TwitterProvider
  {
      public function getAuthUrl($state): string
    {
!         return $this->buildAuthUrlFromBase('https://x.com/i/oauth2/authorize', $state);
      }

      protected function getTokenUrl(): string
      {
!         return 'https://api.x.com/2/oauth2/token';
      }

      protected function getUserByToken($token)
      {
!         $response = $this->getHttpClient()->get('https://api.x.com/2/users/me', [
              RequestOptions::HEADERS => ['Authorization' => 'Bearer ' . $token],
              RequestOptions::QUERY => ['user.fields' => 'profile_image_url'],
          ]);

          return Arr::get(json_decode((string)$response->getBody(), true), 'data');
      }
  }

そして最後に、 composer.jsonLaravel Package Auto Discovery を無効化しておきます。
laravel/socialite は、 Laravel Package Auto Discovery 機能を用いて自動的にboot時にDIを行いますが、今回は上書きをする都合上手動で登録しているので無効にします。

composer.json
  {
      ...
      "extra": {
          "laravel": {
              "dont-discover": [
+                 "laravel/socialite"
              ]
          }
      }
  }

これらの実装を行うことで、Twitter(X)での新規登録/ログイン時にうまく x.com へ遷移し、 x.com でメインで使用しているアカウントを用いて新規登録/ログインすることができるようになりました。意図していないアカウントの場合でも、 x.com 上であればユーザー側がアカウントスイッチするだけで解決できます。

最後に

TwitterからXへ移行した際に起きたOAuth2.0での問題とその解決法についてご紹介いたしました。

対応中に作成した upstream へのプルリクエストも後日マージ、そしてリリースしていただきました。
https://github.com/laravel/socialite/pull/712
https://github.com/laravel/socialite/releases/tag/v5.16.0
(Laravelあるあるだが無言マージ怖い)

OSSのコントリビュートもしつつ、 laravel/socialite の抽象化のおかげでupstreamでレビュー・マージされるまでの暫定対応もできたので柔軟に対応できたかと思います。

宣伝

株式会社luco では現在一緒に配信を盛り上げ、未来を創る仲間を探しております!
業務中のOSS活動はもちろん、使用する技術の選定なども柔軟に、かつ面白い方へと進めていける環境が整っています。新しい技術に挑戦したい方や、自由な発想でプロジェクトに取り組みたい方にとって、最適な場所です。

また、私たち 株式会社luco はPHP Conference 2024 に協賛しています🎉

気になった方は何卒よろしくお願いいたします。
https://www.wantedly.com/projects/1837609

スコテック

Discussion