Auth0でパスワードリセット時にリフレッシュトークンを無効化する

Auth0のHooksで検知できるイベントに「Post Change Password」が追加されたので、前々から実現したいと思っていたパスワードリセット時のリフレッシュトークンの無効化をやってみました。

# 設定手順
## Applicationの追加
今回設定するHookはManagement APIを使うので、Hook用にManagement APIを叩くための権限を持たせたMachine to MachineのApplicationを用意します。

<img width="646" alt="スクリーンショット 2019-12-21 23.24.48.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/490013/bdf9e609-468d-e789-971e-697d4bb5e3e9.png">


APIはAuth0 Management APIを選択し、scopeは`read:device_credentials`と`delete:device_credentials`を設定します。

<img width="646" alt="スクリーンショット 2019-12-21 23.24.25.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/490013/ea2cece2-46ec-edc0-135c-7193b321df68.png">


## 新しいHookを追加
Auth0のDashboardのHooksメニューから「Post Change Password」のHookを追加します。

<img width="672" alt="スクリーンショット 2019-12-21 23.31.15.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/490013/871d75fd-059a-f02d-6b87-bed463118a1d.png">

ここから先は追加したHookに対して設定を行なっていきます。


## Secretの追加
事前に登録しておいたHook用のApplicationの`client_id`と`client_secret`をsecretに登録します。（あとついでにAuth0テナントのドメインも）

<img width="540" alt="スクリーンショット 2019-12-21 23.11.00.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/490013/f2f9ad02-7c7a-40a9-6158-266f67349824.png">

このsecretに登録した値はHook内で`context.webtask.secrets.{key}`の形式で参照することができます。こうすれば機密情報や環境によって変わる情報をHookにハードコーディングしなくて済みます。

こうしておいたほうがAuth0の設定をコマンドラインツールでexport/importするときも管理が楽だと思います。

## NPMモジュールの追加
Hookで利用するNPMモジュールを追加します。ruleと違い、Hookは任意のNPMモジュールが使えるので便利です。
今回はManagement APIにhttpリクエストをするための`axios`と、Management APIを叩くためのアクセストークンを取得するための`auth0`の2つのモジュールを追加します。

<img width="540" alt="スクリーンショット 2019-12-21 23.07.20.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/490013/1483564e-1524-f7a1-4608-f5b84b9e3bbd.png">

## Hookの実装

とりあえず動けばいいやぐらいの気持ちで書いたコードなので、エラーハンドリングとかもしてない雑な実装ですが…。

```javascript
module.exports = async function revokeRefreshTokens(user, context, cb) {
  const { ManagementClient } = require('auth0');
  const axios = require('axios');

  const domain = context.webtask.secrets.domain;

  const auth0 = new ManagementClient({
    domain,
    clientId: context.webtask.secrets.client_id,
    clientSecret: context.webtask.secrets.client_secret,
  });
  
  async function getDeviceCredentials(accessToken) {
    const response = await axios({
      method: 'GET',
      url: `https://${domain}/api/v2/device-credentials`,
      data: {
        fields: 'id',
        include_fields: true,
        user_id: user.id,
        type: 'refresh_token',
      },
      headers: {
        Authorization: `Bearer ${accessToken}`,
      },
    });
    return response.data;
  }

  function deleteDeviceCredential(accessToken, id) {
    axios({
      method: 'DELETE',
      url: `https://${domain}/api/v2/device-credentials/${id}`,
      headers: {
        Authorization: `Bearer ${accessToken}`,
      },
    });
  }

  const accessToken = await auth0.getAccessToken();
  const deviceCredentials = await getDeviceCredentials(accessToken);
  await Promise.all(deviceCredentials.map((device) => deleteDeviceCredential(accessToken, device.id)));
  cb();
}
```

やっていることは単純で

1. Management APIを叩くためのアクセストークンを取得
2. `GET /api/v2/device-credentials`でリフレッシュトークンが発行されているデバイスの一覧を取得
3. `DELETE /api/v2/device-credentials`でデバイスを一個ずつ削除

この3つだけです。最後の3でデバイスを削除することでリフレッシュトークンが無効化されます。

# まとめ
Auth0のリフレッシュトークンは有効期限がないので一度発行されたらものをずっと利用可能ですが、このHookを実装することでパスワード変更時に発行済みの全てのリフレッシュトークンを無効化することができます。
また、このHookはUniversal Login画面の「パスワードをお忘れですか？」リンクからパスワードを変更した場合でも、Authentication APIの`POST /dbconnections/change_password`を使ってパスワードを変更した場合でも動作します。

このHookを利用すればパスワード変更時にユーザをAuth0の同一テナント上の全てのアプリから強制ログアウトさせる。みたいなアプリ実装も可能になるのではないでしょうか。

〜2020/5/9 追記〜
> Auth0のリフレッシュトークンは有効期限がないので一度発行されたらものをずっと利用可能

現在はリフレッシュトークンのローテーション機能を有効にすることで、有効期限を設定することができるようになっています。