Open23

Auth0 SPA SDK(auth0-spa-js)を参考にSPAのトークン管理方法を考える

ピン留めされたアイテム
tristartristar

全体のまとめ

Auth0 SPA SDKを参考にSPAでのトークン管理方法を考えると以下のような管理の仕方がセキュリティ面では良さそうと思った。
ただ、まだ「テストし易いか」が考えられていない。

トークン発行時

  • PKCEを使用してCSRF対策を行う。
    • 「攻撃者の認可コードを使ってユーザーが攻撃者のアカウントでログイン、そのまま重要な情報をアップロードしてしまう」ことに対処する
  • IDトークンを保護するためにnonceも設定する
  • nonce, code_verifierはブラウザの場合はwindow.cryptoを利用して生成する
    • テストを考えるとここは別の方法と差し替えられるようにしておく必要がある

発行されたトークンの管理

  • トークンはローカルストレージ等に保存せずにインメモリで済ませられるのが理想
    • リフレッシュトークンは有効期限が長いので、WebWorkerの内部など外部のJSから参照できない場所に保持する
    • iOSのSafariのようにセキュリティ事情により3rdPartyクッキーが削除されサイレント認証が上手くいかないケースもある。IDPとドメイン名をそろえられない場合はインメモリで管理することが難しく、ローカルストレージを検討することになる

トークンの再発行

  • インメモリで管理している状態でトークンの有効期限が切れた場合
    • WebWorkerの中でリフレッシュトークンを使ってトークンを更新、postMessageで新しいトークンを通知する
  • インメモリで管理している状態で画面リロードが行われた場合
    • 非表示のiframe内で認可エンドポイントにprompt=none, response_mode=web_messageのリクエストを行いサイレントリフレッシュを行う。
  • トークン再発行は、トークンが期限切れした状態で画面リロードなどするとまとめて発生する可能性があるので、再発行処理にはロックを設ける

アプリ内からのアクセストークンの参照方法

  • getTokenSilently を用意し、以下の順でトークンを返す。SPAからAPI呼び出しする際は常にこの関数からトークンを取得するようにする
      1. インメモリで保持したアクセストークン。残りの有効期限が短いものは無視する。
      1. リフレッシュトークンを使い新しく手に入れたトークン
      1. (オプション)上記もダメな場合、iframeを使い認可エンドポイントからやり直すサイレントリフレッシュ
tristartristar

このメモの概要

自分でコントロールできるバックエンドがない状態のSPAの場合、アクセストークンはどう管理するのが良いか興味があり、調べているうちに以下の記事を見かけた。

結論は”Auth0 SPA SDK”を使って欲しいとなっているけど、「ローカルストレージに保持しない」ことやWebWorker(?)などの話が出て「具体的にどういうこと?」と思ったので auth0-spa-js がどのようにトークンを管理したり再発行しているか調べてみる。
(Auth0を使う場合はSDKを使うのが良いかもしれないものの、他のサービスだった場合も含め、SPAだけでアクセストークンを管理するのはどうするのが良いか知りたい)

tristartristar

トークン発行時の動作

まとめ

  • state, nonce, code_verifierはすべて同じ関数で生成。window.cryptを利用してランダムな文字列を生成している
tristartristar

state, nonce, code_verifierはすべて createRandomString で生成されている。

https://github.com/auth0/auth0-spa-js/blob/f2e566849efa398ca599daf9ebdfbbd62fcb1894/src/Auth0Client.ts#L298-L303

createRandomString は window.cryptoを使ってランダムな値を生成している。 この関数も初めて知った。
https://developer.mozilla.org/ja/docs/Web/API/Window/crypto
この関数自体は渡されたUint32Arrayの配列をランダムな整数で埋める動作のようで、その整数値をcharsetの長さで丸めた値を元にしてランダムな文字列を作っている。なるほど...。
この部分はSSRのサーバー側では動かなそうだけど、ライブラリ自体がフロント側での利用を想定しているため問題ない...?

https://github.com/auth0/auth0-spa-js/blob/f2e566849efa398ca599daf9ebdfbbd62fcb1894/src/utils.ts#L140-L154

tristartristar

発行されたトークンの管理

普通に認証を済ませた状態ではローカルストレージには特に情報は保存されず、Cookieに以下のような情報が保存されている。基本的にHttpOnlyで、ドメインはauth0のテナントのドメインになっているので、
自分のアプリからは何も参照できない。

まとめ

  • トークンを発行した直後、IDトークンのクレームの検証が行われる(署名は検証されていない。相手がAuth0なのが確定のため?)
  • 途中で発生するfetchリクエストはAbortControllerで一定時間(デフォルト=10秒)でタイムアウトするようになっている
    • この制御はこのライブラリ内のfetchのあちこちで見かける
  • キャッシュはストレージ(デフォルトはインメモリ)に格納される
    • キャッシュのキーはclientId,scope,audienceの単位
tristartristar

トークンの発行はAuth0Client.handleRedirectCallbackで行うと思うのでこの辺りを見てみる。
https://github.com/auth0/auth0-spa-js/blob/f2e566849efa398ca599daf9ebdfbbd62fcb1894/src/Auth0Client.ts#L485-L486

transactionManagerというのが出てくるけど、ここでのtransactionはPKCEの「認可リクエストからトークンを受け取るまで」の間を1セッションとしたトランザクションのことだと思う。
PKCEは別でまとめる。

  • code_verifierがない場合だけstateが一致することをチェックする
    • code_verifierがstateと似てCSRF対策で利用されるもので、stateより後で登場した概念の認識はあったけど、それでも念のため両方やっておくものだと思っていた。

https://github.com/auth0/auth0-spa-js/blob/f2e566849efa398ca599daf9ebdfbbd62fcb1894/src/Auth0Client.ts#L516-L522

以下で認可コードからトークンを取得する。

  • audienceは明示されなければ default が指定される
  • workerオプションが指定されている場合、この先でワーカーを使ってレスポンスを受け取る動きがある。後でまとめる。
  • リクエスト形式はapplication/jsonも選択できるようになっている。

https://github.com/auth0/auth0-spa-js/blob/f2e566849efa398ca599daf9ebdfbbd62fcb1894/src/api.ts#L22-L42

tristartristar

ワーカーを利用しない場合は以下のような感じでリクエストが行われる。

  • abortControllerでクライアント側によるタイムアウト制御を行う。
    • タイムアウトのデフォルト値は10秒で、Auth0Clientの初期化時に指定可能
  • Promise.raceというのが使われている
    • 複数のPromiseのうち最初に解決した値を受け取れる。それ以外はキャンセルされるかと思ったけど、試してみる限り実行自体はすべて実行される。

https://github.com/auth0/auth0-spa-js/blob/f2e566849efa398ca599daf9ebdfbbd62fcb1894/src/http.ts#L25-L49

ここにたどり着くまでに少し気になったのは、全体的にコード内の関数は const xxx = () => {} のアロー関数スタイルになっている。ソースコードをジャンプして追いかける分には気にならないけど、上から順に読めないのが気になるのと、この方針が採用された理由が気になる。

tristartristar

トークンを受け取った後は以下のような操作が行われている。

  • IDトークンの検証(IDトークンにはnonceが含まれているため?。この時点では署名の検証はない?)
  • トークンをキャッシュに保存
    • キャッシュはCacheManagerを通し、デフォルトはRecord型の変数で保持
      • 初期化時にローカルストレージを指定可能
      • CacheManagerにはNowProvider(() => number | Promise<number>)というものも持っている。おそらくテスト用
    • IDトークンをsetIdTokenで保存、それ以外をsetメソッドで保存。
    • IDトークンだけはuserCacheというRecord型変数にも保存される。
      • キーはBASE64済のIDトークン、値はデコード済の値
  • セッションチェックの要否(boolean)に関するCookieを保存

https://github.com/auth0/auth0-spa-js/blob/f2e566849efa398ca599daf9ebdfbbd62fcb1894/src/Auth0Client.ts#L1116-L1136

tristartristar

ここまでで、大まかには以下のように保存されていそう。

  • トークンエンドポイントから受け取った結果は、まずIDトークンの検証が行われる
    (nonceはあれば検証する、というオプションの位置づけ。署名の検証はまだ出てこない。読み飛ばした...?)
  • IDトークンは独立してBASE64済のIDトークンをキーに保持されている。この用途はまだ分かっていない。
  • clientId+scope+audienceをキーにしてインメモリでキャッシュ。オプション次第でローカルストレージに変更も出来る
tristartristar

トークンエンドポイント呼び出し時、workerが指定されている場合

Workerが利用されるかどうかはAuth0Client初期化時に以下の条件で決まる。

https://github.com/auth0/auth0-spa-js/blob/f2e566849efa398ca599daf9ebdfbbd62fcb1894/src/Auth0Client.ts#L227-L240

ワーカーのURLを指定できるようになっているのはFAQに記載がある。
(Workerの中で実行するスクリプトをauth0-spa-jsと別のライブラリにすることを避けるため、メインのバンドルからblobとしてワーカーに渡しているが、それがCSPの制約に引っかかり、CSPを変更できないケースではワーカー部分のスクリプトを自身のオリジンから提供する必要がある)
https://github.com/auth0/auth0-spa-js/blob/main/FAQ.md#the-token-worker-is-being-blocked-by-my-content-security-policy-csp-what-should-i-do

まとめ

  • ブラウザがWebWorkerをサポート、トークンをインメモリで管理、リフレッシュトークンを利用する設定の場合はトークン発行はWebWorkerの中で実行される
  • WebWorkerの内部ではトークン一式が保持されるが、postMessage経由で呼び出し元に戻ってくる値にはリフレッシュトークンが含まれていない。
    • これによって「リフレッシュトークンを知っているのがワーカーの中だけ」という状況を作っている
  • この仕組みはリロードへの耐性はないので、そこは別途担保する必要がある
tristartristar

this.worker = new TokenWorker() でワーカーが初期化されているように見えるけど、TokenWorkerは以下のようにimportされていて、token.worker.tsの中身は何もexportしない普通のJSコードなので、これでWorkerとして動作できるのは不思議。

// @ts-ignore
import TokenWorker from './worker/token.worker.ts';
tristartristar

Workerを呼び出すコードは以下のsendMessage関数でユーティリティ化されていて、
内部ではその場でMessageChannelを作り、Workerをtoとしてメッセージを送信、Workerから受け取った値をresolve/rejectするようになっている。

https://github.com/auth0/auth0-spa-js/blob/f2e566849efa398ca599daf9ebdfbbd62fcb1894/src/worker/worker.utils.ts#L8-L24

Worker本体のコードは以下。

https://github.com/auth0/auth0-spa-js/blob/f2e566849efa398ca599daf9ebdfbbd62fcb1894/src/worker/token.worker.ts

内容としては、

  • 初回(grant_typeはauthorization_codeなどで呼ばれることを期待)は通常の認可コードによるトークン発行リクエストを送信。戻り値として受け取ったリフレッシュトークンをワーカー内のrefreshTokens変数にaudience+scopeをキーにして保持しておく。
  • 2回目以降で、grant_type=refresh_tokenで呼び出されたら事前に保持しておいたリフレッシュトークンを使いトークンをリクエストする。
  • 受け取ったトークン一式の中からrefresh_tokenを削除して、postMessageを通して呼び出し元に通知する

この仕組みなので、Workerの呼び出し元プロセスにはリフレッシュトークンが参照出来ないまま新しいアクセストークンを通知することが出来る。
ただ、画面リロードに対する耐性はなさそう。

tristartristar

getTokenSilentlyの仕組み

まとめ

  • インメモリのキャッシュが見つかればそれがそのまま返却される
    • ただし、有効期限が60秒未満だと無かったものとして扱われる
  • キャッシュがない場合でリフレッシュトークンを使う場合、「ワーカーを使用したリフレッシュ」、「iframeを利用したリフレッシュ」の順に試行される
  • iframeを利用する場合、prompt=none, response_mode=web_messageという指定付きで非表示のiframe内にトークンエンドポイントのURLを渡す
    • この場合はユーザーへの確認はなく、非表示のiframe内でリダイレクトが発生。親ウィンドウにpostMessageするHTMLが返却される
    • 親ウィンドウはmessageイベントでそれを拾うことでアクセストークンを受け取る
  • この関数は全体的に browser-tabs-lock というパッケージを使ってLockが掛けられている。
tristartristar

_getTokenSilently の中では、メモリ上のキャッシュにトークンが見つかればそこからトークンが返る(キャッシュ上のトークンの期限が60秒以内の場合は無視される)

https://github.com/auth0/auth0-spa-js/blob/f2e566849efa398ca599daf9ebdfbbd62fcb1894/src/Auth0Client.ts#L673-L733

  • 5秒のタイムアウト付きでロックを獲得、以下の操作を繰り返す
    • キャッシュからトークンの取得を試みる。取得出来ればそれを返す
    • リフレッシュトークンを使う場合はWorkerによるトークン取得を試みる
    • リフレッシュトークンを使わない場合はiframeによるトークン取得を試みる
    • finallyでロックを解放する。
      • pagehideイベント時にもロックの解放とイベントリスナーを解除する
  • ロックには browser-tabs-lock というパッケージが利用されている
tristartristar

_getTokenFromIframe は以下のように始まる

  • 認可リクエストを作成
    • prompt=noneresponse_mode=web_message を付けた認可リクエストのURLをiframe内に表示する
    • iframe内でリダイレクトが起きて、response_mode=web_messageにより親ウィンドウにpostMessageするHTMLが返却される
  • 認可コードが発行されたらこれまでと同じWebWorkerを使うか、直接リクエストする方法でトークンエンドポイントを呼び出す。

https://github.com/auth0/auth0-spa-js/blob/f2e566849efa398ca599daf9ebdfbbd62fcb1894/src/Auth0Client.ts#L864-L904

tristartristar

pagehide時にロックを解放するのはなぜか気になったけど、調べると以下のようなものがあった。

https://groups.google.com/a/chromium.org/g/blink-dev/c/os3qUVtqyWo

For example, if a page uses a feature such as WebLock and does not release the acquired lock in the pagehide handler, Chrome does not put the page into BFCache. If developers want to make their site restored from BFCache, they have to release the lock in the pagehide handler.

ChromeのBFCacheを正しく動作させるために必要なのかも。

tristartristar

ここまで見ているとWebWorkerを使いながらインメモリでトークンを保持、
画面リロード時はサイレントリフレッシュが良さそうなものの、iOSのSafariにはITPという3rdPartyのCookieをブロックする仕様があり、冒頭で確認したAuth0のCookieがサイレント認証時に使えない(削除される?)ので、このケースで問題になってしまう。
Auth0の場合はカスタムドメインを設定するのが現実的そう。
https://future-architect.github.io/articles/20221007a/