🤡

Auth0 の Silent AuthenticationとRefresh Token Rotation、完全に理解した

2024/12/07に公開

Auth0 の Silent Authentication (サイレント認証)と Refresh Token Rotation (リフレッシュトークンローテーション)を完全に理解したい気持ちが急に高まってきたので書きます。

全体の構成として

  • React SPA with Auth0 での認可フローについて
  • Silent Authentication (サイレント認証)について
  • Refresh Token Rotation について
  • まとめ

みたいな流れで書きつつ、Silent Authentication や Refresh Token Rotation は何を解決しようとしているのか、それぞれのリフレッシュ方法でどのような挙動になるのか、などについて理解を深めていきたいと思います。

また、React SPA with Auth0 の認可フロー部分のイメージを沸かせるために以下のサンプルリポジトリを用意しています:

https://github.com/danimal141/auth0-react-playground

こちらは

  • frontend
  • backend
    • Rails ベースの GraphQL API は SPA からアクセストークン (JWT)を受け取って、トークンを検証する
      • 検証が成功したら認可が必要な情報を返却する
  • auth0
    • Auth0 も一応コードからデプロイして環境構築可能
      • めっちゃ個人名の入ったアプリ名になってしまっているので名前変えてください
    • 一部、設定ファイルだけでは更新できない設定もあるので注意 (デプロイ用のアプリ作成など)

のような構成になっていて、アクセストークン取得まわりの動作検証に使ったものなのでよかったら見てみてください。ちなみに GraphQL とか使っちゃっていますが、今回の文脈において GraphQL の知識は特に必要ありません。


React SPA with Auth0 での認可フロー

Auth0 の React 用 SDK: auth0-reactを利用した場合、認可フローはこちらUnder the hood, it implements Universal Login and the Authorization Code Grant Flow with PKCE. と書かれているように、Authorization Code Flow with Proof Key for Code Exchange (PKCE)になる。より詳細な説明はこちらに書かれている。

PKCE は雑にいうと、「OAuth2 の Authorization Code Flow で認可コード奪われたらアクセストークンも奪われちゃうからツラいですよね、認可コード送信元も検証して、せめてアクセストークンは取られんようにしましょうや」っていう仕組み。こちらの記事の解説がめちゃめちゃわかりやすかったのでオススメ。

auth0-reactを使う場合、Authorization Code Flow with Proof Key for Code Exchange (PKCE)な実装は SDK 側でサポートしてくれているので自前で code_challengeなどをハンドリングするような必要はない。

ちなみにauth0-reactは元々あったauth0-spa-jsを React の Hooks ベースの実装でいい感じに抽象化しただけのものなので、実装詳細を知りたい場合はauth0-spa-jsの方のコードを読んだほうがよい (読まざるを得ない)。

ログイン -> アクセストークン取得 -> ログアウトまでのライフサイクル

会員登録済みで Auth0 上にすでにユーザが存在する前提で、ログインからアクセストークン取得、ログアウトまでの流れはざっくり以下のようになると思う。

  • auth0-reactloginWithRedirectを利用してログイン処理を呼び出す
    • ログインボタンなどを設置して、そいつの Click イベントで loginWithRedirectを呼ぶみたいなイメージ
    • Universal Login の画面が表示され、認証情報を入力する
      • ここで Auth0 とのセッションが生成される
        • Auth0 ドメイン側の Cookie にこちらで記載されているような値がセットされているはず
      • セッションの有効期限は Auth0 の Tenant Settings にある Log In Session Management の項目で設定できる
  • Auth0 の Application (SPA)で callback URL として許可している URL に?code=xxxのクエリパラメータ付きでリダイレクトされる
    • codeなどが URL に含まれててツラいが、auth0-reactonRedirectCallbackを指定しておくと勝手にその辺のツラいクエリパラメータが除外された URL に勝手にリダイレクトしてくれる
  • 上記のcodeを使って {auth0_domain}/oauth/tokenにリクエストを投げてaccess_tokenを取得
    • アクセストークン以外にもscopeなど色々情報は返ってくるはずだけど、今回は割愛
    • auth0-reactgetAccessTokenSilentlyというメソッドがその辺やってくれている
  • トークンストレージはデフォルトではインメモリ
    • 画面をリロードしたらアクセストークンは再取得する必要がある
    • localStorage を利用することも可能 (後述するけど、リスクを理解せずノリで使うのは危険)
  • auth0-reactlogoutを呼ぶとログアウトし、Auth0 ドメインとのセッションも切れる

Silent Authentication (サイレント認証)

上述したが、アクセストークンはデフォルトではインメモリにキャッシュされるので画面リロードなどで都度、再取得が必要になる。「Universal login -> ログイン成功 -> ?code=xxx付きで SPA にコールバック」の流れでは特に問題なかったが、それ以降、Auth0 ドメインとのセッションが生きている状態でアクセストークンの再取得はどうするのか、またログインからやり直しなのか...?

Auth0 はアクセストークンをリフレッシュする手段としてSilent Authentication (サイレント認証)という仕組みをサポートしている。2021 年 6 月時点ではデフォルトで Silent Authentication がデフォルトのアクセストークンリフレッシュ手段になっている。リフレッシュトークンは auth0-reactの実装で Provider にuseRefreshTokens=trueを渡すと利用できるようになる (その他、Auth0 の設定側で offline_accessスコープを API で許可する設定なども必要)。

これは雑に説明すると、画面遷移などを介さずひっそりとセッションを確認してアクセストークンを再取得する仕組みで、Chrome のネットワークタブなどをよく見てみると{auth0_domain}/authorize?xx=xxへのリクエストで prompt=noneresponse_mode=web_messageが含まれているのが重要なポイントである。

prompt=noneをつけることで画面表示が行われない状態でセッション確認ができる。そしてresponse_mode=web_messageによってユーザからは見えない iframeを作って、「そいつから {auth0_domain}/authorizeにリクエスト -> codeの含まれたレスポンスを受け取る -> そいつを Web Messaging API 経由で親に渡してアクセストークン再取得」みたいな動きを実現している。

詳しくはこちらの記事がわかりやすく解説してくださっている。

こいつのメリットは localStorage を使わないで、かつ毎回ログインを要求されるみたいなクソな挙動を避けてアクセストークンを再取得できることだと思う。SPA はブラウザが前提になるのでネイティブと違ってセキュアなストレージがない。こちらの記事にあるように localStorage は悪意のある npm ライブラリ経由でシュッとトークンが抜かれるリスクがあるなど、セキュアではない。アクセストークンのようなデリケートな情報を長期間そこに入れておくのは危険なので、そういったリスクを避け、かつユーザ体験をそこまで損なわないこの仕組みは非常によい。

ただこいつも完璧ではなく、こちらに書かれているように、昨今の Safari の ITP のような、3rd party cookie がブロックされがちなご時世では Silent Authentication がうまく動作しないケースがある。

実際、Safari や Chrome で 3rd party Cookie をブロックする設定のまま Silent Authentication ベースのアクセストークンの再取得を試みると期待通り動作せず、以下のような感じになる。

  • SPA (iframe) <-> Auth0 ドメイン間でのリクエストのやり取りの際に 3rd party cookie に当たる情報が取得できない
  • セッション確認失敗
  • login_requiredの Error Response が返るため、codeが取得できない
  • アクセストークンも取得できない

これを回避するには Auth0 のカスタムドメイン機能を使って SPA をapp.example.com、ログイン URL を login.example.comみたいに設定して回避するか、後述の Refresh Token Rotation がサポートされたリフレッシュトークンを使う、あたりが選択肢になってくる。

Refresh Token Rotation (リフレッシュトークンローテーション)

上述のような Silent Authentication の弱点をカバーするための手段として生まれたのが Refresh Token Rotation (という理解で合ってる?)。詳しい説明はこちらに書かれている。

これまでリフレッシュトークンに有効期限がなかったので、それを localStorage に入れるのはリスキーだったが、Refresh Token Rotation を使うとリフレッシュトークンを使ってアクセストークン取得する際に新しくリフレッシュトークンも再発行され、以前のものは無効化されるのでよりセキュアにリフレッシュトークンが使えるようになった。

もちろんリスクが無くなるわけではないが、有効期間をそれなりに短めに設定して localStorage にトークンをキャッシュするアプローチも悪くはないと思う。

Refresh Token Rotation を有効にして localStorage をキャッシュに利用した場合のフローは以下のようになると思う。実際に色々設定をいじったり、 auth0-spa-jsのコードを読みながら挙動を確認したんですが、間違ってたらご指摘ください。

アクセストークンの有効期限が切れる -> リフレッシュトークンを使って再取得の流れ

まずアクセストークン取得からそいつの有効期限が切れる -> リフレッシュトークンを使って再取得するまでの流れは大体こんな感じかと思う。ちなみにアクセストークンの有効期限はデフォルトで 1 日、Auth0 API の Token Expiration の設定に依存する。

  • 最初のログインで Auth0 のセッションができる
    • ここでアクセストークンとリフレッシュトークンを取得
    • localStorage にこれらの情報が保存される
    • localStorage の有効期限はアクセストークンの有効期限と同じになる
  • アクセストークンの有効期限が切れた場合
    • localStorage のキャッシュもクリアされる
    • リフレッシュトークンを使ってアクセストークンを再取得
    • このタイミングでリフレッシュトークンも更新されるので、以前のリフレッシュトークンは無効になる
    • localStorage 上の値も更新される

アクセストークンの有効期限が切れる -> リフレッシュトークンも有効期限切れ / 無効だった場合の再取得の流れ

次にアクセストークンが無効になって再取得しようにもリフレッシュトークンも期限切れ、もしくは無効だった場合は設定によって挙動が変わる。ちなみにリフレッシュトークンの有効期間は Auth0 の SPA の Refresh Token Expiration にある Absolute Lifetime の設定に依存する。

  • リフレッシュトークンが invalid となって 403 が返る
    • セッション有効期間 > リフレッシュトークン有効期間な設定の場合
      • リフレッシュトークンが使えなかった場合の Fallback として、Silent Authentication が呼ばれる
        • セッション有効期間内の場合
          • 3rd party cookie が問題なく使える場合、ここで iframe 経由でcodeを取得 -> アクセストークン、リフレッシュトークンも取得できる
          • 3rd party cookie が使えない場合、Silent Authentication が失敗、内部的にlogout({ localOnly: true })が呼ばれる
            • ローカルのキャッシュや Cookie のauth0.is.authenticatedの値がクリアされる
            • ここでは Auth0 セッションは生きている想定なので振り出しに戻る (おそらく{auth0_domain}/authorizeresponse_mode=queryで再リクエスト -> code、アクセストークン、リフレッシュトークン再取得になるかな、あまり自信ない...)
        • セッション有効期間を過ぎている場合、再ログインを要求される
    • セッション有効期間 < リフレッシュトークン有効期間な設定の場合
      • リフレッシュトークンの有効期間が実質セッションの有効期間みたいになる

まとめ

  • Silent Authentication はブラウザのあまりセキュアではないストレージに頼らないかつ、UX を損なわないアクセストークンのリフレッシュを実現できるが、3rd party Cookie の扱いに厳しくなってきた昨今、いつ動かなくなるかわからないというリスクがある
    • 代替案として Refresh Token Roation のサポートされたリフレッシュトークンを利用する
    • もしくは Auth0 のカスタムドメインを設定して、3rd party cookie を扱っているとみなされないよう設定することで解決することもできるかもしれない
  • Refresh Token Rotation はリフレッシュトークンをローテーションさせることで以前よりも気軽にトークン情報をブラウザのストレージに入れられるようになったし、3rd party cookie、いつ使えなくなるんだろうという恐怖から開放される(と信じたい)

参照

Discussion