Auth0 SPA SDK(auth0-spa-js)を参考にSPAのトークン管理方法を考える
全体のまとめ
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のリクエストを行いサイレントリフレッシュを行う。
- トークン再発行は、トークンが期限切れした状態で画面リロードなどするとまとめて発生する可能性があるので、再発行処理にはロックを設ける
- pagehideイベント時にロックを解放する制御が行われていた。ChromeのBFCacheを正常に動作させるために必要なのかもしれない
https://groups.google.com/a/chromium.org/g/blink-dev/c/os3qUVtqyWo
- pagehideイベント時にロックを解放する制御が行われていた。ChromeのBFCacheを正常に動作させるために必要なのかもしれない
アプリ内からのアクセストークンの参照方法
-
getTokenSilently
を用意し、以下の順でトークンを返す。SPAからAPI呼び出しする際は常にこの関数からトークンを取得するようにする-
- インメモリで保持したアクセストークン。残りの有効期限が短いものは無視する。
-
- リフレッシュトークンを使い新しく手に入れたトークン
-
- (オプション)上記もダメな場合、iframeを使い認可エンドポイントからやり直すサイレントリフレッシュ
-
このメモの概要
自分でコントロールできるバックエンドがない状態のSPAの場合、アクセストークンはどう管理するのが良いか興味があり、調べているうちに以下の記事を見かけた。
結論は”Auth0 SPA SDK”を使って欲しいとなっているけど、「ローカルストレージに保持しない」ことやWebWorker(?)などの話が出て「具体的にどういうこと?」と思ったので auth0-spa-js がどのようにトークンを管理したり再発行しているか調べてみる。
(Auth0を使う場合はSDKを使うのが良いかもしれないものの、他のサービスだった場合も含め、SPAだけでアクセストークンを管理するのはどうするのが良いか知りたい)
トークン発行時の動作
まとめ
- state, nonce, code_verifierはすべて同じ関数で生成。window.cryptを利用してランダムな文字列を生成している
state, nonce, code_verifierはすべて createRandomString
で生成されている。
createRandomString
は window.cryptoを使ってランダムな値を生成している。 この関数も初めて知った。
この関数自体は渡されたUint32Arrayの配列をランダムな整数で埋める動作のようで、その整数値をcharsetの長さで丸めた値を元にしてランダムな文字列を作っている。なるほど...。
この部分はSSRのサーバー側では動かなそうだけど、ライブラリ自体がフロント側での利用を想定しているため問題ない...?
window.cryptoが使えない環境ではエラーが出るようになっていて、そのメッセージの中にドキュメントへの参照も含められていた。
(セキュリティ上の理由からハッシュ値の生成にCryptが必要)
発行されたトークンの管理
普通に認証を済ませた状態ではローカルストレージには特に情報は保存されず、Cookieに以下のような情報が保存されている。基本的にHttpOnlyで、ドメインはauth0のテナントのドメインになっているので、
自分のアプリからは何も参照できない。
まとめ
- トークンを発行した直後、IDトークンのクレームの検証が行われる(署名は検証されていない。相手がAuth0なのが確定のため?)
- 途中で発生するfetchリクエストはAbortControllerで一定時間(デフォルト=10秒)でタイムアウトするようになっている
- この制御はこのライブラリ内のfetchのあちこちで見かける
- キャッシュはストレージ(デフォルトはインメモリ)に格納される
- キャッシュのキーはclientId,scope,audienceの単位
トークンの発行はAuth0Client.handleRedirectCallbackで行うと思うのでこの辺りを見てみる。
transactionManagerというのが出てくるけど、ここでのtransactionはPKCEの「認可リクエストからトークンを受け取るまで」の間を1セッションとしたトランザクションのことだと思う。
PKCEは別でまとめる。
- code_verifierがない場合だけstateが一致することをチェックする
- code_verifierがstateと似てCSRF対策で利用されるもので、stateより後で登場した概念の認識はあったけど、それでも念のため両方やっておくものだと思っていた。
以下で認可コードからトークンを取得する。
- audienceは明示されなければ
default
が指定される - workerオプションが指定されている場合、この先でワーカーを使ってレスポンスを受け取る動きがある。後でまとめる。
- リクエスト形式はapplication/jsonも選択できるようになっている。
- RFCを見てみるとトークン発行時はapplication/x-www-form-urlencoded となっているので、この部分はAuth0の拡張。
ワーカーを利用しない場合は以下のような感じでリクエストが行われる。
- abortControllerでクライアント側によるタイムアウト制御を行う。
- タイムアウトのデフォルト値は10秒で、Auth0Clientの初期化時に指定可能
- Promise.raceというのが使われている
- 複数のPromiseのうち最初に解決した値を受け取れる。それ以外はキャンセルされるかと思ったけど、試してみる限り実行自体はすべて実行される。
ここにたどり着くまでに少し気になったのは、全体的にコード内の関数は const xxx = () => {}
のアロー関数スタイルになっている。ソースコードをジャンプして追いかける分には気にならないけど、上から順に読めないのが気になるのと、この方針が採用された理由が気になる。
トークンを受け取った後は以下のような操作が行われている。
- IDトークンの検証(IDトークンにはnonceが含まれているため?。この時点では署名の検証はない?)
- トークンをキャッシュに保存
- キャッシュはCacheManagerを通し、デフォルトはRecord型の変数で保持
- 初期化時にローカルストレージを指定可能
- CacheManagerにはNowProvider(
() => number | Promise<number>
)というものも持っている。おそらくテスト用
- IDトークンをsetIdTokenで保存、それ以外をsetメソッドで保存。
- IDトークンだけはuserCacheというRecord型変数にも保存される。
- キーはBASE64済のIDトークン、値はデコード済の値
- キャッシュはCacheManagerを通し、デフォルトはRecord型の変数で保持
- セッションチェックの要否(boolean)に関するCookieを保存
キャッシュのキーはclientId,scope,audienceの単位。
wrappedCacheEntryは通常のトークン情報に加えて具体的にいつ期限が切れるかの項目を持ったオブジェクトのよう。
keyManifestはclientId単位でキャッシュを操作するために用意されているように見える。
ここまでで、大まかには以下のように保存されていそう。
- トークンエンドポイントから受け取った結果は、まずIDトークンの検証が行われる
(nonceはあれば検証する、というオプションの位置づけ。署名の検証はまだ出てこない。読み飛ばした...?) - IDトークンは独立してBASE64済のIDトークンをキーに保持されている。この用途はまだ分かっていない。
- clientId+scope+audienceをキーにしてインメモリでキャッシュ。オプション次第でローカルストレージに変更も出来る
トークンエンドポイント呼び出し時、workerが指定されている場合
Workerが利用されるかどうかはAuth0Client初期化時に以下の条件で決まる。
- トークンの保存先がインメモリであること
- リフレッシュトークンの利用が指定されていること
- (コメントによると、リフレッシュトークンをメモリ上に持つ方式でない限りWebWorkerは使わない)
https://github.com/auth0/auth0-spa-js/blob/f2e566849efa398ca599daf9ebdfbbd62fcb1894/src/Auth0Client.ts#L227-L240
- (コメントによると、リフレッシュトークンをメモリ上に持つ方式でない限りWebWorkerは使わない)
ワーカーのURLを指定できるようになっているのはFAQに記載がある。
(Workerの中で実行するスクリプトをauth0-spa-jsと別のライブラリにすることを避けるため、メインのバンドルからblobとしてワーカーに渡しているが、それがCSPの制約に引っかかり、CSPを変更できないケースではワーカー部分のスクリプトを自身のオリジンから提供する必要がある)
まとめ
- ブラウザがWebWorkerをサポート、トークンをインメモリで管理、リフレッシュトークンを利用する設定の場合はトークン発行はWebWorkerの中で実行される
- WebWorkerの内部ではトークン一式が保持されるが、postMessage経由で呼び出し元に戻ってくる値にはリフレッシュトークンが含まれていない。
- これによって「リフレッシュトークンを知っているのがワーカーの中だけ」という状況を作っている
- この仕組みはリロードへの耐性はないので、そこは別途担保する必要がある
this.worker = new TokenWorker()
でワーカーが初期化されているように見えるけど、TokenWorkerは以下のようにimportされていて、token.worker.tsの中身は何もexportしない普通のJSコードなので、これでWorkerとして動作できるのは不思議。
// @ts-ignore
import TokenWorker from './worker/token.worker.ts';
やっぱり何もしないでこのままWorkerとして動かすことは出来なそうで、
Rollupのプラグインの rollup-plugin-web-worker-loader
が使われていそう。
Workerを呼び出すコードは以下のsendMessage関数でユーティリティ化されていて、
内部ではその場でMessageChannelを作り、Workerをtoとしてメッセージを送信、Workerから受け取った値をresolve/rejectするようになっている。
Worker本体のコードは以下。
内容としては、
- 初回(grant_typeはauthorization_codeなどで呼ばれることを期待)は通常の認可コードによるトークン発行リクエストを送信。戻り値として受け取ったリフレッシュトークンをワーカー内のrefreshTokens変数にaudience+scopeをキーにして保持しておく。
- 2回目以降で、grant_type=refresh_tokenで呼び出されたら事前に保持しておいたリフレッシュトークンを使いトークンをリクエストする。
- 受け取ったトークン一式の中からrefresh_tokenを削除して、postMessageを通して呼び出し元に通知する
この仕組みなので、Workerの呼び出し元プロセスにはリフレッシュトークンが参照出来ないまま新しいアクセストークンを通知することが出来る。
ただ、画面リロードに対する耐性はなさそう。
getTokenSilentlyの仕組み
まとめ
- インメモリのキャッシュが見つかればそれがそのまま返却される
- ただし、有効期限が60秒未満だと無かったものとして扱われる
- キャッシュがない場合でリフレッシュトークンを使う場合、「ワーカーを使用したリフレッシュ」、「iframeを利用したリフレッシュ」の順に試行される
- iframeを利用する場合、prompt=none, response_mode=web_messageという指定付きで非表示のiframe内にトークンエンドポイントのURLを渡す
- この場合はユーザーへの確認はなく、非表示のiframe内でリダイレクトが発生。親ウィンドウにpostMessageするHTMLが返却される
- 親ウィンドウはmessageイベントでそれを拾うことでアクセストークンを受け取る
- この関数は全体的に
browser-tabs-lock
というパッケージを使ってLockが掛けられている。
この関数は singlePromise
というユーティリティ関数で括って内部的に _getTokenSilently
を呼び出す形になっている。
singlePromise
は第2引数で指定したキーが存在しなければ第1引数のPromiseを返し、すでに存在したらメモしておいたPromiseを返す動作。
_getTokenSilently
の中では、メモリ上のキャッシュにトークンが見つかればそこからトークンが返る(キャッシュ上のトークンの期限が60秒以内の場合は無視される)
- 5秒のタイムアウト付きでロックを獲得、以下の操作を繰り返す
- キャッシュからトークンの取得を試みる。取得出来ればそれを返す
- リフレッシュトークンを使う場合はWorkerによるトークン取得を試みる
- リフレッシュトークンを使わない場合はiframeによるトークン取得を試みる
- finallyでロックを解放する。
- pagehideイベント時にもロックの解放とイベントリスナーを解除する
- ロックには
browser-tabs-lock
というパッケージが利用されている
ここで使われているretryPromiseはユーティリティで、コールバックがfalseを返すとリトライする内容になっている。便利そう。
(例外がスローされた場合はリトライしないのは分かり難くも感じるけど、全部の例外をリトライすればよい訳でもないので難しそう)
この処理の中でリフレッシュトークンに関するエラーが起きた場合はiframeによるトークン発行にフォールバックするようになっている。
(リフレッシュトークンの期限切れの時の想定?)
_getTokenFromIframe
は以下のように始まる
- 認可リクエストを作成
-
prompt=none
とresponse_mode=web_message
を付けた認可リクエストのURLをiframe内に表示する - iframe内でリダイレクトが起きて、response_mode=web_messageにより親ウィンドウにpostMessageするHTMLが返却される
- https://qiita.com/ksakiyama134/items/bb5a97bf7762b4a87e0d
- OIDCで定められているようだけど、これが出来るのはAuth0が
response_mode=web_message
に対応しているから。(Cognitoの場合はこれは出来ない)
-
- 認可コードが発行されたらこれまでと同じWebWorkerを使うか、直接リクエストする方法でトークンエンドポイントを呼び出す。
pagehide時にロックを解放するのはなぜか気になったけど、調べると以下のようなものがあった。
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を正しく動作させるために必要なのかも。
ここまで見ているとWebWorkerを使いながらインメモリでトークンを保持、
画面リロード時はサイレントリフレッシュが良さそうなものの、iOSのSafariにはITPという3rdPartyのCookieをブロックする仕様があり、冒頭で確認したAuth0のCookieがサイレント認証時に使えない(削除される?)ので、このケースで問題になってしまう。
Auth0の場合はカスタムドメインを設定するのが現実的そう。