SPAでのバックエンド認証用トークンの管理方法に関する考察

Single Page Application (SPA) から呼び出すバックエンドAPIの認証トークンの管理方法について、Web上の文献やライブラリの実装を調査して、自分の考えを整理した。

### 前提

* 本記事はSPAにおいてバックエンド認証に使うトークンの管理方法（典型的にはLocal Storageに保管してよいか？Cookieを使うべきか？など）についての考察となる。認証トークンの仕組み（ステートフル vs ステートレス）については言及していない。
* ブラウザにはAndroidのKeyStoreのような仕組み（暗号化され、取得時に端末の認証が必要となるストレージ）がないという批判があるが、これも考慮から外している。（つまり、システム管理者やPCの共同利用者がCookie/Local Storageを参照して…というリスクについては考えていない。）

### 考察

* まず、そもそもの原因となるXSS脆弱性への対策が最も重要、というのは間違いない。
  * XSS脆弱性によりアプリケーションのコンテキストでブラウザにコード実行させることができれば、認証トークンの管理方法に関わらず（httpOnlyなCookieを使っていても）、そのアプリケーションで実行可能な操作はできてしまう。（これが参考文献のいくつかでXSSがゲームオーバーとされる理由となる。）
  * サードパーティライブラリの既知の脆弱性チェック、実装の堅牢化（CORS設定や入力データの無害化など）、ブラウザ保護機能の活用（CSPヘッダなど）、といった各レイヤでの対策を行う必要がある。
* 認証トークンの管理方法によって変わるのは、ユーザーがXSSを踏んだタイミングでの攻撃に加えて、このタイミングで認証トークンを奪取されるリスクとなる。認証トークンを奪取されることで追加の攻撃が可能となる。（実際にはこのリスクを受容できるシステムは多いはず。）
* 敢えて認証トークンの管理方法の安全性比較すると `Cookie (httpOnly) > Closure or Web Workerへの分離 >> 単純な変数での保持 = Local Storage` というのが私の考えとなる。
  * Cookie (httpOnly) で管理すればSPA側からは認証トークンを参照できないため、XSSによる奪取は不可能となる。SPAと同じオリジンでBFFを構築できるのであれば、これが簡単に安全性を担保できる方法となる。
  * SPA側で認証トークンを扱う場合にXSSから守るには、認証トークンを扱う処理のコンテキストを、XSSが発生するリスクが高いコンテキストから完全に分離する必要がある。実装はかなり複雑になり、前者のコンテキストにおいてもユーザーの入力を受け取る処理があればリスクを0にすることはできない。
    * コンテキストを分離するには、トークンを扱う処理をClosureかWeb Workerに切り離す必要がある。（詳細は文献調査の「Secure Browser Storage: The Facts - Auth0 Blog」を参照。）
  * XSSでLocal Storageからの奪取が容易なのは言うまでもないが、単純に変数に保持するという実装では、XSSで差し込まれたコードからトークン取得用の関数を呼び出せてしまえば奪取ができてしまう。
* 多くのライブラリではトークンの保管方法を変数での保持かLocal Storageへの保存から選択できるようになっている。（詳細は実装調査を参照。）
  * これは、ライブラリ単体ではhttpOnlyなCookieを使う仕組みには踏み込めず、コンテキストの分離はライブラリとして抽象化して組み込むことが難しいからと推測する。
  * また、リロード時に認証トークンを保持するにはLocal Storageを使う必要があるため、先述のリスクを許容するオプションが用意されている。
  * Auth0 SPA SDKの実装は興味深く、漏洩時の影響が大きいリフレッシュトークンを扱う処理をWeb Workerに分離するアプローチを採っている。（リフレッシュトークンに関する処理はSDK利用者が意識する必要がないため、SDKで隠蔽しやすいということもある。）
* 別の視点では、SPAに対して発行するトークン（特に変数やLocal Storageで扱うものは）でできることを必要最小限にすることも重要となる。（これはSPAに限らず認証認可の基本ではあるが。）
  * 認証トークンでは、アプリケーションの動作に必要なバックエンド呼び出ししかできないようにする。（SPA・バックエンドが複数存在する場合は、認証トークンが使える範囲の制御をきちんと実装する必要がある。）
  * ユーザーのインタラクションなしで利用できるトークンはできる限り短命にする。（非アクティブ時間での失効だけでなく、延長可能な期間のハードリミットもあるとよい。）

### 文献調査

#### 特に参考になるもの

* [Secure Browser Storage: The Facts - Auth0 Blog](https://auth0.com/blog/secure-browser-storage-the-facts/)
  * XSS脆弱性が存在すればアプリケーションのコンテキストを使った攻撃を防ぐことはできないことを前提としつつ、XSS脆弱性が存在するフロントエンドアプリにおいてどのようにトークンを安全に扱うか、が分析されている。
  * 結論では、BFFを構築してバックエンドに保管とするのが最良で、Web Worker、Closure + 外部関数のコピー保持（適用が困難な場合もある）でXSSが入り込むコンテキストから分離することも有効としている。
    * XSS耐性という意味では、単にメモリ上にトークンを保管することは、Local Storageでの保管と大差がないことの解説が詳しくされている。（[参考](https://auth0.com/blog/secure-browser-storage-the-facts/#Secure-Browser-Storage-in-Auth0-SDKs)）
  * Auth0のSPAライブラリはリフレッシュトークンを使ったアクセストークン再発行処理をWeb Workerに隔離することで、XSSに対してもアクセストークンの取得はできても、リフレッシュトークンは取得できない仕組みになっているとのこと。
* [Building secure web apps using Web Workers | Mercari Engineering](https://engineering.mercari.com/en/blog/entry/20220930-building-secure-apps-using-web-workers/)
  * メルカリのWebアプリでの実装方針について解説されている。リフレッシュトークンはCookieで管理し、アクセストークンはCORSが必要なため、アクセストークンを扱う処理一式（トークン取得だけでなくAPI呼び出しも）をWeb Workerに分離するアプローチを採っている。
* [SPAセキュリティ入門](https://www.slideshare.net/ockeghem/phpconf2021spasecurity)
  * SPAのセキュリティを考える上で必要な基礎知識から起きうる脆弱性まで、詳しく解説されている。
  * CookieとLocal Storageの安全性は一長一短である、WebサーバーとAPIサーバーが一体であれば古典的なセッションを使うのが脆弱性が枯れているため比較的無難、とまとめられている。
* [OAuth 2.0 for Browser-Based Apps](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-browser-based-apps-08)
  * ドラフト仕様ではあるが、SPAを含むブラウザベースのアプリケーションでのOAuth 2.0のセキュリティ対策などについて言及されている。
  * 現時点ではトークンを完全に安全に保存できるブラウザAPIはないとして、バックエンドがある場合にはトークンをサーバーサイドで管理すべきとしている。バックエンドがない場合はできる限り安全に扱うこと、という記載となっており、具体的な実装には踏み込んでいない。
  * リフレッシュトークンを発行する際にはローテーションの対応と有効期限の設定を必須としている。

#### その他

* [OWASP Cheat Sheet](https://cheatsheetseries.owasp.org/index.html)
  * [Session Management](https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html)ではHTML5 Storage (Local Storage or Session Storage) へのセッション ID（トークン）の保存が1つの手段として記載されているが、[HTML5 Security](https://cheatsheetseries.owasp.org/cheatsheets/HTML5_Security_Cheat_Sheet.html#local-storage)ではLocal StorageへのセッションID保存はNGとされている。
  * 前者も当初はNGという記載であったが[#375](https://github.com/OWASP/CheatSheetSeries/issues/375)の議論を経て、HTML5 Storageへの保存も1つの手段とする記述に変わっている。おそらく後者は更新が追いついていない状態。
  * [#375](https://github.com/OWASP/CheatSheetSeries/issues/375)の議論は次のような内容となる：Cookie (httpOnly) への保存もXSSを防ぐことはできず、HTML5 Storageではトークン自体が取得されることによる追加の攻撃が可能となる差はあるにしても、HTML5 Storageを禁止してCookieを推奨するほどのものではない。
* [HTML5のLocal Storageを使ってはいけない（翻訳）](https://techracho.bpsinc.jp/hachi8833/2019_10_09/80851)
  * Local StorageはJavaScriptから自由にアクセスができるため、XSS脆弱性があると中身を取得されてしまう。（利用しているサードパーティーのライブラリに脆弱性がある場合も同様。）
  * このため、httpOnly + secure + SameSite=strictなCookieに、暗号化+署名をして保存するのがベストプラクティスである、という主張となっている。
* [Why avoiding LocalStorage for tokens is the wrong solution](https://pragmaticwebsecurity.com/articles/oauthoidc/localstorage-xss.html)
  * XSS脆弱性があるとアプリケーションのコンテキストでの任意のコード実行があるため、Local Storageではなくメモリ上にトークンを保存していたとしても取得が可能になる。 このためストレージの選択に時間を使うよりもXSSを防ぐための対策に時間を使うべき、という主張がされている。

### 実装調査

* [Auth0 Single Page App SDK](https://auth0.com/docs/libraries/auth0-single-page-app-sdk)
  * トークンはデフォルトではメモリ上に保管されるが、設定（cacheLication）でLocalStorageに保存するように変更できる。（参考：[Change storage options](https://auth0.com/docs/libraries/auth0-single-page-app-sdk#change-storage-options)）
* [Amazon Cognito Identity SDK for JavaScript (amazon-cognito-identity-js)](https://github.com/aws-amplify/amplify-js/tree/master/packages/amazon-cognito-identity-js)
  * 実装を追ってみたところ、デフォルトはLocalStorageに保存（LocalStorageが使えない環境では変数に保存）となるが、Storageオプションを指定してCookieに保存することも可能。（参考：[StorageHelper#getStorage()](https://github.com/aws-amplify/amplify-js/blob/amazon-cognito-identity-js%405.2.2/packages/amazon-cognito-identity-js/src/StorageHelper.js#L84)、[CognitoUserPool#constructor()](https://github.com/aws-amplify/amplify-js/blob/amazon-cognito-identity-js%405.2.2/packages/amazon-cognito-identity-js/src/CognitoUserPool.js#L68)）
  * Cookieに保存する場合、domain属性は（なぜか）必ず指定される。（参考：[#6743](https://github.com/aws-amplify/amplify-js/issues/6743)）expires属性はデフォルトは365日後となるが、null指定で属性なしとできそう。（参考：[CookieStorage.js](https://github.com/aws-amplify/amplify-js/blob/amazon-cognito-identity-js%405.2.2/packages/amazon-cognito-identity-js/src/CookieStorage.js#L25)）
  * StorageのI/Fはシンプルなので、自力で実装すればCookieの問題の回避や、SessionStrorageに保存することもできそう。
  * 2020年のAWS Black Belt Online Seminarの[Q&A](https://aws.amazon.com/jp/blogs/news/webinar-bb-amazon-cognito-2020/)ではSessionStorageもデフォルトでサポートされているとあるが、ドキュメント上も実装上も見当たらない。
* [Microsoft Authentication Library for JavaScript (MSAL.js) 2.0 for Browser-Based Single-Page Applications](https://github.com/AzureAD/microsoft-authentication-library-for-js/tree/dev/lib/msal-browser)
  * セッションストレージとローカルストレージがサポートされているが、セッションストレージが推奨となっている。（参考：[FAQ](https://github.com/AzureAD/microsoft-authentication-library-for-js/blob/dev/lib/msal-browser/FAQ.md#what-is-the-difference-between-sessionstorage-and-localstorage)）
* [Keycloak JavaScript Adapter](https://www.keycloak.org/docs/latest/securing_apps/#_javascript_adapter)
  * ドキュメントにはトークンの保管場所に関する記述はないが、ソースコードを追ってみるとをメモリ上に保管しているようである。（参考：[Keycloak.js](https://github.com/keycloak/keycloak/blob/main/adapters/oidc/js/src/main/resources/keycloak.js#L988)）