@mizumotokのブログ

テクノロジー、投資、書評、映画、筋トレなどについて

SPA認証トークンはlocalStorageでもCookieでもない、Auth0方式はいいねというお話

SPA認証トークンをどこに保存するかは論争が絶えません。localStorageやCookieがよく使われますが、Auth0は違う方法を採用しています。この記事では、Auth0のトークン管理の方式を理解でき、トークン管理上のセキュリティへの理解を深めることができます。

f:id:mizumotok:20210804112029j:plain

SPAの認証トークンをどこに保存するか

React やVueで認証付きSPA(Single Page Application)を作る場合、認証サーバにログインしてトークンをもらって、そのトークンをAPIサーバとの通信時に付与するというやり方が一般的です。

SPAの起動時に毎回認証サーバにログインするのもUXを損ねるので、SPA上でトークンを保管したいというニーズは常にあるのですが、どこに保存するのかということについては論争が絶えません。

ブラウザでトークンを保存できる場所

ブラウザで情報を保存する場合、以下の4箇所考えられます。

  • インメモリ
  • Cookie
  • ローカルストレージ
  • セッションストレージ

メモリやセッションストレージはブラウザタブを閉じると消えてしまうため、一般的にはCookieかローカルストレージが使われますが、それぞれデメリットがあります。

保存場所の比較

インメモリ Cookie ローカルストレージ セッションストレージ
容量 1GB程度 4KB 10MB 5MB
有効期限 ブラウザタブを閉じたとき 設定可能 期限なし ブラウザタブを閉じたとき
サーバ送信 送信されない 送信される 送信されない 送信されない
CSRFの脅威 なし あり なし あり
他のJSからのアクセス 実装によるが困難 不可(http-only属性時) 可能(APIあり) 可能(APIあり)

トークンの容量はせいぜい数百バイトなので、容量は気にはならないでしょう。

Cookieはサーバに自動で送信されるので、一度セットしてしまえば何も考えずに使えますが、他のストレージはHTTPリクエスト時にヘッダー等に自分で設定してあげる必要があります。HTTPリクエスト用の共通ライブラリを作ってその中でやってしまえばいいので、それほど問題にはなりません。

CSRFクロスサイトリクエストフォージェリ)について詳細の説明は避けますが、ざっくりいうとセッション(≒Cookie)を乗っ取ってサーバに勝手にリクエストを投げることです。Cookie以外の方法ではヘッダーにトークン情報を追加する必要があるので、この問題は起きません。CSRF対策としてはCSRFトークンをPOSTリクエスト時にFormData付与するのが一般的で、SPAでないウェブアプリではこの方法はとても有効です。しかしSPAの場合は、CSRFトークンを毎回サーバから受け取るのが面倒です。

他のJavascriptからアクセス可能かどうかはセキュリティ上考慮すべき点です。ウェブアプリを構築するときに他のjsライブラリを使用しないことはほとんどあり得ず、その中に悪意あるコードがあって、勝手にストレージにアクセスして何かするかもしれません。ローカルストレージとセッションストレージはアクセスするためのAPIが公開されているので、簡単にアクセスできてしまいます。依存関係も含めてすべてのライブラリのソースコードを確認するのはほとんど不可能なので、この脅威は妥協して受け入れてしまうのがほとんどです。インメモリの場合は、グローバル変数上においたりしない限り、一般的には他のJSライブラリからのアクセスは困難です。

メリット・デメリット

メリット・デメリットをまとめると以下のようになります。(○がメリット、×がデメリット)

インメモリ Cookie ローカルストレージ セッションストレージ
有効期限 × ×
CSRFの脅威 ×
サードパティライブラリによる脅威 × ×

一長一短ですね。

有効期限がタブを閉じたときでは、一般的にはUI/UXを著しく損なうため、通常はCookieかローカルストレージをセキュリティ要件に応じて選択します。

Auth0のアプローチ

トークンはインメモリに保存

Auth0ではインメモリにトークンを保存しています。ローカルストレージもセッションストレージも対応していますが、デフォルトはインメモリです。インメモリを採用したということは有効期限のデメリットを克服したということであり、サードパーティの脅威が気にならなければ、ローカルストレージでもセッションストレージでも構わないわけです。上述したようにサードパーティの脅威は受け入れるケースはありますから。

インメモリということはタブを閉じたタイミングで消えるということです。再度ページにアクセスしたときに認証状態を維持するにはどうしているかというと、Auth0のサーバとはCookieでセッションを維持しておき、トークンが有効でないとき(タブを閉じて消えたとき、トークンの有効期限が切れたとき)にAuth0のサーバから取得しているわけです。

OpenID Connect準拠とトークン取得のUI/UXの悪化回避を両立

OAuth2やOpenID Connectではログイン時は、URLにトークン情報を載せてリダイレクトすることでこの仕組を実現しているわけですが、毎回リダイレクトするのはかっこ悪いです。つまりUI/UXを損ねます。ログイン状態では、UIの裏(ユーザの見えないところ)でこの処理をやりたいのですが、簡単にはいきません。

  • OAuth2 / OpenID Connectにjavascriptのfetch APIから認可コードを取得するための仕様がない

SPAのために認証サーバをAPIサーバと別に立てるというのはSSO(Single Sign On)のためだったりで、よくあることですが、Auth0の場合と同じ問題が発生します。OAuth2 / OpenID Connectにこだわらずに独自APIを追加したりすればいいのですが、そうするとどんどんアプリ依存していくため、それはやりたくないわけです。認証サーバは標準仕様で独立させておきたいです。

Auth0では一度認証を済ませてセッションができると、iframeとWeb WorkerのpostMessageを駆使して OpenID Connectの認可コードやアクセストークンを取得しています。実際の仕組みは後述しますが、OpenID Connectの仕組みで解決しているのです。

Auth0のjsライブラリ

Auth0には多くのライブラリが用意されています。

Javascript用のライブラリとしては以下のものがあります。

auth0-spa-jsを見れば、SPAに必要な機能実装はすべてわかります。オープンソースソースコードも比較的わかりやすいです。Auth0サーバはブラックボックスですが基本的にはOAuth2準拠なので、OAuth2を理解していればサーバの動きは容易に想像がつきます。

では、auth0-spa-jsの動きを見ていきましょう。

ログイン

READMEからの抜粋ですが、ログイン処理は以下のようになります。

//redirect to the Universal Login Page
document.getElementById('login').addEventListener('click', async () => {
  await auth0.loginWithRedirect();
});

//in your callback route (<MY_CALLBACK_URL>)
window.addEventListener('load', async () => {
  const redirectResult = await auth0.handleRedirectCallback();
  //logged in. you can get the user profile like this:
  const user = await auth0.getUser();
  console.log(user);
});

まず、loginWithRedirectを呼びますが、これは通常のOAuth2の認証です。認可エンドポイントにリダイレクトします。

public async loginWithRedirect(options: RedirectLoginOptions = {}) {
  const { redirectMethod, ...urlOptions } = options;
  const url = await this.buildAuthorizeUrl(urlOptions);
  window.location[redirectMethod || 'assign'](url);
}

Auth0Client.ts

ここのコードにはありませんがresponse_type=code&response_mode=query&scope=openid,profile,emailを指定して、認可コードを取得しに行きます。scopeにopenidを含んでいるので、トークンエンドポイントからはアクセストークンに加えて、IDトークンを取得する予定です。

OAuth2の認証を終えると、OAuth2で指定済みのredirect_uriにリダイレクトされて来ますが、ここでhandleRedirectCallbackを呼びます。コールバックURLのクエリ文字列で認証コードを取得できますので、これを使ってアクセストークンとIDトークンをバックグラウンドで取得します。

public async handleRedirectCallback(
  url: string = window.location.href
): Promise<RedirectLoginResult> {
  
  ・・・

  const authResult = await oauthToken(tokenOptions, this.worker);

  ・・・

  const cacheEntry = {
    ...authResult,
    decodedToken,
    audience: transaction.audience,
    scope: transaction.scope,
    client_id: this.options.client_id
  };

  await this.cacheManager.set(cacheEntry);

  this.cookieStorage.save('auth0.is.authenticated', true, {
    daysUntilExpire: this.sessionCheckExpiryDays
  });

  ・・・

Auth0Client.ts

oauthTokenを呼んで、トークンエンドポイントからIDトークンとアクセストークン(どちらもauthResultに含まれている)を取得します。トークンエンドポイントへのアクセスはWeb Workerを利用してい複雑なのですが、要はWeb WorkerにURLをpostMessageで渡して、Web Worker上でfetchして、Web WorkerからpostMessageでアクセストークンやIDトークンを受け取っています。Web Workerを使用しているのはUIのスレッドとは別のスレッドで少しでも効率よくするためで、Web Workerを使用できない場合でもメインスレッドでfetchする仕組みになっています。ちなみにアクセストークンの取得は認可コード横取り攻撃の対策としてAuthorization Code Flow with PKCEが使われています。

アクセストークンやIDトークン等の取得した情報はインメモリ(cacheManagerのデフォルト)に保存します。auth0.is.authenticatedというフラグだけをCookieに保存しているのは、SPA再起動時にインメモリの情報は消えていますが、Cookieから認証済みかどうかの情報だけをすぐに使う必要があるからです。認証済みならアクセストークンの(再)取得だけを行えばいいし、未認証ならログイン処理をするという判断ができます。

適材適所で保存場所を使い分けているのがわかります。

アクセストークンの(再)取得

APIをコールするのにアクセストークンが必要になります。

const accessToken = await auth0.getTokenSilently();
const result = await fetch('https://myapi.com', {
  method: 'GET',
  headers: {
    Authorization: `Bearer ${accessToken}`
  }
});
const data = await result.json();
console.log(data);

getTokenSilentlyを呼ぶだけでアクセストークンが取得できます。キャッシュに有効なアクセストークンがあればそれを返しますが、ない場合(有効期限が切れた場合、一度タブを閉じてメモリから消えた場合)はAuth0サーバに取りに行きます。このときに認可コードが必要なのですが、前述したように認可コードをjavascriptでフェッチするためのAPIがないのです。また認証時のURLリダイレクトを使うのはやりたくありません。特に有効期限切れの度に画面が切り替わるのは嫌です。

ここでiframe経由で取得するという高度な技術が持ち込まれます。

private async _getTokenFromIFrame(
  options: GetTokenSilentlyOptions
): Promise<any> {
  
  ・・・
  
  const url = this._authorizeUrl({
    ...params,
    prompt: 'none',
    response_mode: 'web_message'
  });

  ・・・

  try {
    const codeResult = await runIframe(url, this.domainUrl, timeout);

Auth0Client.ts

_authorizeUrlで認可エンドポイントを呼び出していますが、注目すべきはprompt=none&response_mode=web_messageです。

prompt=noneは認証UI(ログイン画面)や同意UI(承認 or 拒否)を表示しないためのもので、OpenID Connectの仕様に記載されています。

none
Authorization Server はいかなる認証および同意 UI をも表示してはならない (MUST NOT). End-User が認証済でない場合, Client が要求する Claim 取得に十分な事前同意を取得済でない場合, またはリクエストを処理するために必要な何らかの条件を満たさない場合には, エラーが返される. 典型的なエラーコードは login_required, interaction_required であり, その他のコードは Section 3.1.2.6 で定義されている. これは既存の認証と同意の両方, またはいずれかを確認する方法として使用できる.

認証済みかつ同意済みであればを認可エンドポイントのUIは不要になります。

response_mode=web_messageは仕様上はありません(queryまたはfragmentのみ)が、OAuth2の仕様では任意のresponse_modeを追加できることになっています。

For purposes of this specification, the default Response Mode for the OAuth 2.0 code Response Type is the query encoding. For purposes of this specification, the default Response Mode for the OAuth 2.0 token Response Type is the fragment encoding.

この仕様を根拠にAuth0が独自Response Modeを作ったようです。

認可エンドポイントURLをiframesrcにセットして呼び出します。

export const runIframe = (
  authorizeUrl: string,
  eventOrigin: string,
  timeoutInSeconds: number = DEFAULT_AUTHORIZE_TIMEOUT_IN_SECONDS
) => {
  return new Promise<AuthenticationResult>((res, rej) => {
    const iframe = window.document.createElement('iframe');

    ・・・

    iframeEventHandler = function (e: MessageEvent) {
      
      ・・・

      e.data.response.error
        ? rej(GenericError.fromPayload(e.data.response))
        : res(e.data.response);

      ・・・
    
    };

    window.addEventListener('message', iframeEventHandler, false);
    window.document.body.appendChild(iframe);
    iframe.setAttribute('src', authorizeUrl);
  });

utils.ts

iframeEventHandlermessageイベントを拾っていることから、iframeがロードされるとpostMessageが呼ばれることが想像されます。

response_mode=web_messageではおそらくこのようなHTMLが返ってくるのでしょう。

<html>
  <head>
       <script>
       window.parent.postMessage({
           type: 'authorization_response',
           response: { code: 'xxxxxxxx', state: 'xxxxxxxx' },
       }, 'redirect_urlのオリジン')
       </script>
  </head>
</html>

redirect_urlのオリジンがセットされ、URL上のフラグメントやクエリ文字列でなくpostMessageとして認可コードが返されるようです。OpenID Connectの仕様に沿ったまま、上手に拡張したなと感心しました。認可コードが得られれば、既出のoauthToken関数を使ってWeb Worker経由でアクセストークンを取得すればよいわけです。

図解

ログイン時の認可コードの取得(loginWithRedirect)、バックグラウンドでのアクセストークンとIDトークンの取得(oauthToken)、バックグラウンドでの認可コードの取得(getTokenSilently _getTokenFromIFrame)を見てきましたが、最後に図で整理しておきます。

ログイン

f:id:mizumotok:20210804115053j:plain

アクセストークンの(再)取得

f:id:mizumotok:20210804112417j:plain

自サービス内の認証だけのもっと簡易な構成

Auth0はさまざまなところに工夫が施され、OpenID Connectに準拠と高いセキュリティ(メモリ上でトークンの管理)、UI/UXの邪魔をしない(バックグラウンドでトークンを取得)ことを両立しています。しかし、この構成は真似て作ることはそれほど難しくありません。jsライブラリはオープンソースで公開されていますし、サーバはOpenID Connectのうち、Authorization Code Flowだけに対応すればよいです。実装するポイントは以下のとおりです。

  • 認可エンドポイント(Authorization Code Flow with PKCE)

    • response_type=code&scope=openid
    • response_type=code&response_mode=web_message&scope=openid&prompt=none
  • トークンエンドポイント(アクセストークンとIDトークンの作成)

自サービス内の認証の場合は認可(アクセストークン)は不要で、認証(IDトークン)だけできればいいというケースはよくあります。自サービスで発行したIDトークンなので有効性だけ確認して、権限はサービス内で管理すればいいからです。

その場合はImplicit Flow(response_type=id_token)を使ってもっと簡単にできます。認可エンドポイントだけで実現でき、トークンエンドポイントは不要になります。

認可コード横取り攻撃ならぬIDトークン横取り攻撃は考えられますが、idトークンの有効期限を短くする、リフレッシュートークンを発行しない、クエリでなくフラグメントで返すという対策である程度脅威を軽減できます。セキュリティ要件でそれを受け入れるかどうか次第です。

  • 認可エンドポイント(Implicit Flow)
    • response_type=id_token
    • response_type=id_token&response_mode=web_message&prompt_none
  • トークンエンドポイントは不要

1つのエンドポイントで、この2パターンだけなのでそれほど実装は難しくありません。例えばrubyならopenid_connect gemを使えばそれほど工数かからずに対応できます。OpenID Connect準拠なので、OpenID Connectの仕様に従ってAuthorization Code Flow with PKCE等の拡張も可能な構成となります。

ログイン

f:id:mizumotok:20210804112450j:plain

IDトークン取得

f:id:mizumotok:20210804112506j:plain

まとめ

  • SPAの認証トークンの保管場所として、インメモリ、Cookie、ローカルストレージ、セッションストレージが考えられる
  • ブラウザタブを閉じても保持してほしいので、Cookieやローカルストレージが使われることが多いが、それぞれCSRFの脅威、悪意あるサードパーティライブラリの脅威がある
  • Auth0ではインメモリでトークンを保持しつつ、必要なときはAuth0サーバに取りに行く仕組みになっている
  • Auth0ではOpenID Connectに準拠しつつ、UI/UXを損なわない仕組みをiframeやWeb Workerを使って実現している
  • 自サービス内での認証だけなら、Auth0の構成を参考に、Implicit Flowの簡易構成でも十分なケースもある