Auth0のauth0-spa-jsを使っていい感じにアクセストークンを取得する

背景

auth0/auth0-spa-jsはAuth0が提供するSPA用のライブラリです。
このライブラリを用いることで、複雑なOpenID Connect、OAuth 2.0の処理を簡易かつ安全にSPAに組み込むことができます。

しかし、最近は3rd Party Cookieに対する制約が強くなり、特にSafariではIntelligent Tracking Prevention(ITP)により、getTokenSilently()というインタラクションを伴わないアクセストークンの取得が不可能となっています。
では、そのようなブラウザではもう一つあるアクセストークンを取得するメソッド、getTokenWithPopup()を使えば問題ないかというと、こちらにはポップアップに対する制約が影響してきます。
iOS Safariは、デフォルトでポップアップブロックが有効になっており、かつ個別のサイトごとにポップアップを許可することが不可能となっています。

iOS Safariのシェアは大きく、特にメディア系のサイトではその傾向はより強くなります。
サイトにアクセスしたユーザに対して、利用するためにはiOS Safariのポップアップブロック機能を無効にする必要があると通知し、実際に機能を無効にしてもらう、という方式は現実的ではありません。

そういった制約を踏まえ、ユーザに対する負荷や影響を可能な限り低減するアクセストークンを取得する実装を解説します。

Auth0の設定

Renew Tokens When Using SafariRefresh Token Rotationを参考に、アプリケーションのリフレッシュトークンローテーションを有効にします。

アクセストークン取得関数

const getAccessTokenFunc = async (options, client) => {
  try {
    return await client.getTokenSilently(options);
  } catch (error) {
    switch (error.error) {
      case 'consent_required':
        return await client.getTokenWithPopup(options);
      case 'login_required':
        await client.loginWithRedirect({
          ...options,
          cacheLocation: 'localstorage',
          useRefreshTokens: true,
          appState: {
            targetUrl: window.location.href.replace(
              window.location.origin,
              ''
            ),
          },
        });
        return;
      default:
        throw error;
    }
  }
};

引数

optionsGetTokenSilentlyOptionsclientAuth0Clientインスタンスです。

client.getTokenSilently()

Auth0ClientgetTokenSilently()をまず最初に実行します。
内部的にはignoreCacheを指定しない場合、最初にキャッシュの確認が行われます。
アクセストークンが存在する場合はそのアクセストークンが返却され、リフレッシュトークンが存在すればリフレッシュトークンによるアクセストークンの取得を、キャッシュ自体が存在しない場合はiframeを用いたアクセストークンの取得を試みます。

case 'consent_required':

このエラーは、ユーザに対してAPIのアクセス権をクライアントに与えるかどうか確認を求める必要があることを示すものです。
Auth0のAPIの設定でAllow Skipping User Consentが有効で、かつAuth0で管理しているクライアントである場合はユーザ確認はスキップされます。
localhostまたはAuth0のDynamic Application (Client) Registrationを通じて登録されたThird-Party Appである場合、この確認が要求されることがあります。Twitterなどのアプリ連携の確認画面がこれに相当します。

case 'login_required':

3rd Party Cookieがブロックされている場合、そのリクエストにはクッキーが含まれないため、Auth0は認証が完了していない状態のリクエストとしてlogin_requiredのエラーを返します。
SafariでITPが有効な状態では、Auth0は常にこのレスポンスを返します。

loginWithRedirect()

auth0-spa-jspgetTokenWithPopup()は、内部でloginWithPopup()を呼び出しています。
これはAuth0におけるログイン処理が、 OAuth 2.0におけるアクセストークンを発行するための処理を拡張したOpenID Connectの処理に準拠していることを意味しています。
loginWithPopup()はaudienceを指定しない呼び出しではAuthorization APIへのアクセストークンをIDトークンとともに返します。
それであれば、loginWithPopup()と同じくアクセストークンとIDトークンを取得することができるloginWithRedirect()のaudienceを目的とするAPIの値にして呼び出すことで、ログイン処理ともに任意のAPIのアクセストークンを取得することができるはずです。
もちろん、ログイン処理と同様にAuth0Clientの初期化時にhandleRedirectCallback()を実行することが必要ですが、その処理の中でcacheLocationの指定により、アクセストークンを含むAPIのレスポンスが、localstorageに保存されます。

実行時の挙動

Auth0Clientインスタンスを通して、ログイン処理が完了している状態では以下の挙動となります。

  • 3rd Party Cookieが完全にブロックされていないブラウザ(Chromeなど)で、Third-Party Appやlocalhostではないアプリの場合、getTokenSilently()によりユーザに特別な操作や画面遷移を行うことなくアクセストークンを取得することができる
  • 3rd Party Cookieが完全にブロックされていないブラウザでThird-Party Appやlocalhostの場合、初回や一定時間が経過したアクセスの際に、アプリケーションがAPIにアクセスする旨を確認するダイアログが表示される場合がある
  • 3rd Party Cookieが完全にブロックされているブラウザでは、まずキャッシュを確認し、そのアクセストークンが有効でない場合、リダイレクトによるアクセストークンの取得とlocalstorageへの格納が行われる
    • アクセストークンの取得タイミングを任意に指定する処理を実装することで、iOS Safariであってもユーザに対する負荷や影響を低減するUIを検討する余地が生まれる

まとめ

  • auth0/auth0-spa-jsはSPAにAuth0の認証を追加する際に、簡易かつ安全な高レベルなAPIを提供するため、基本的に利用すべき
  • 最近のブラウザは3rd Party Cookieやポップアップに対する制約が強化されているため、auth0/auth-spa-jsが提供するAPIをそのまま使うだけでは、ユーザへの負荷や特定の設定を強いることなくアクセストークンを取得することが難しい
  • OpenID ConnectおよびOAuth 2.0の仕組みおよび、auth0/auth0-spa-jsの内部での処理を理解することで、直接Auth0のAuthentication APIを呼び出す方法よりもセキュリティを担保しつつ、ユーザへの負荷や影響を低減する手法が存在する

さて、ここまで書いておいてなんなのですが、単一サービスや特定のブランドの認証基盤としてAuth0を利用する場合、Custom Domainsの機能、つまりAuth0のドメインをクラインとのドメインサブドメインとすることで3rd Party Cookieの問題を解決することができます。
もちろん、ドメインの設計は複数サービスにまたがるブランディングやユーザに対する認知にも多分に影響しますし、開発環境やステージング環境にもカスタムドメインを設定する必要があるなど、主にコスト面での影響も大きくなるのですが。 こういった技術的な制約なども認識しつつ、適切な選択ができればと思います。