Nuxt.jsのJwtトークンを自動でリフレッシュする

# TL;DR

```js:nuxt.config.js
module.exports = {
  mode: 'universal',
  /*
  ** Nuxt.js modules
  */
  modules: [
    'bootstrap-vue/nuxt',
    '@nuxtjs/axios',
    '@nuxtjs/pwa',
    '@nuxtjs/auth',
  ],
  /*
  ** Axios module configuration
  ** See https://axios.nuxtjs.org/options
  */
  axios: {
    proxy: true,
  },
  auth: {
    plugins: [
      '~plugins/axios.js',
    ],
    redirect: {
      login: '/admin/login',
      logout: '/',
      callback: false,
      home: '/admin/home'
    },
    strategies: {
      local: {
        endpoints: {
          login: { url: '/api/auth/login', method: 'post', propertyName: 'access_token' },
          user: {
            url: '/api/auth/me', method: 'get', propertyName: 'user',
          },
          refresh: { url: '/api/auth/refresh', method: 'post' },
          logout: {
            url: '/api/auth/logout', method: 'post', propertyName: false
          }
        },
        //v5~
        refreshToken: {
          property: 'refresh_token',
          data: 'refresh_token',
          maxAge: 60 * 60 * 24 * 30
        },
      }
    }
  },
  server: {
    port: 3000
  }
}
```

```js:plugins/axios.js
export default function (context) {
  const $router = context.app.router
  const {$axios, $auth} = context
  $axios.interceptors.response.use((response) => {
    return response
  }, async function (error) {
    if ($auth.loggedIn) {
      const originalRequest = error.config
      if (
        error.response.status === 401 &&
        error.response.data.message === 'Token has expired' &&
        !originalRequest._retry
      ) {
        originalRequest._retry = true
        return await $axios.post('api/auth/refresh').then(res => {
          $auth.setUserToken(res.data.access_token)
          $axios.setToken(res.data.access_token, 'Bearer')
          originalRequest.headers.Authorization = 'Bearer ' + res.data.access_token
          return $axios.request(originalRequest)
        }).catch(err => {
          //error handling
        })
      }
    }
    if (error.response.status === 500 && (
      error.response.data.message === 'Token has expired and can no longer be refreshed' ||
      error.response.data.message === 'The token has been blacklisted'
    )) {
      await $router.push({name: 'admin-login'})
    }
    return Promise.reject(error)
  })
}

```

# jwtは適宜トークンの更新が必要
そもそもjwtとはというかたはこちらから
[JSON Web Tokens](https://jwt.io/)
jwtにはそれぞれのトークンに有効期限が設定されており、ユーザーのログイン状態を維持するには適宜更新してあげる必要があります。この更新が簡単そうで意外とハマったので実装方法とハマりポイントに関して記載します。

# 環境
【Nuxt】
@nuxtjs/auth@4.9.1
@nuxtjs/axios@5.12.2
【Laravel】
tymon/jwt-auth 1.0.0
laravel/framework v6.18.16

# 実装①axiosのプラグインを作成する
[プラグインの作り方](https://ja.nuxtjs.org/docs/2.x/directory-structure/plugins/)
nuxtはpluginsディレクトリ以下にjsファイルを作成してあげることで簡単にプラグインを作成することができます。今回はaxiosモジュールのインターセプター（リクエスト前やリクエスト後に処理を挟める）を使用したいのでプラグイン内部でいじれるようにします。
まずは、コンテキスト経由で$axiosを定義し、インターセプターの枠組みを作ります。今回はレスポンス時のみに適用します。

```js:plugins/axios.js
export default function (context) {
  const {$axios} = context
  $axios.interceptors.response.use((response) => {
    //正常時の処理
    return response
  }, async function (error) {
    // エラー時の処理
}
```

# 実装②$authの読み込み
ここが第一のハマりポイントでした。`$axios`と同じようにコンテキスト経由で行けるのかと思いきやそうではなく、特定のプラグイン内部で`$auth`を使用する場合には、`nuxt.config.js`の`auth`以下に`$auth`を使用したいプラグインを記載してあげる必要があります。

```js:nuxt.config.js
module.exports = {
  auth: {
    plugins: [
      '~plugins/axios.js', //ここで読み込む必要がある
    ],    
  },
  plugins: [
    //ここだと使えない
  ],
}
```
[Extending Auth Plugin](https://auth.nuxtjs.org/recipes/extend.html)
>If you have plugins that need to access $auth, you can use auth.plugins option.

#実装③リフレッシュの条件分岐
`$auth`がaxios.jsで使えるようになったら、トークンをリフレッシュする条件を決めます。
今回は若干雑ですが、以下のケースで分岐させます。

```js:plugins/axios.js
export default function (context) {
  const {$axios, $auth} = context
  $axios.interceptors.response.use((response) => {
    return response
  }, async function (error) {
    if ($auth.loggedIn) { //ログイン中か確認
      if (
        error.response.status === 401 &&
        error.response.data.message === 'Token has expired'
      ) {
        //jwtのトークンが有効切れの４０１でかつトークンがリフレッシュ可能な場合の処理
    }
    if (error.response.status === 500 && (
      error.response.data.message === 'Token has expired and can no longer be refreshed' ||
      error.response.data.message === 'The token has been blacklisted'
    )) {
      //jwtトークンが更新不可能な場合の処理
    }
    return Promise.reject(error)
  })
}
```

#実装④リフレッシュ＆リトライ
似たようなメソッドがあり、迷走しました。要約すると、以下のような処理の流れです。

1. 新しいトークンを取得
2. setUserTokenで新しくトークンをセットしかつユーザー情報を更新
3. setTokenでaxiosのヘッダートークンを更新
4. 元のリクエストをリトライ

[Auth Module setUserToken](https://auth.nuxtjs.org/api/auth.html#setusertoken-token)
> setUserToken(token)
Returns: Promise
Set the auth token and fetch the user using the new token and current strategy.

[nuxt/axios setToken](https://axios.nuxtjs.org/helpers#settoken)
> setToken
Signature: setToken(token, type, scopes='common')
Axios instance has an additional helper to easily set global authentication header.

```js:plugins/axios.js
export default function (context) {
  const $router = context.app.router
  const {$axios, $auth} = context
  $axios.interceptors.response.use((response) => {
    return response
  }, async function (error) {
    if ($auth.loggedIn) {
      const originalRequest = error.config
      if (
        error.response.status === 401 &&
        error.response.data.message === 'Token has expired' &&
        !originalRequest._retry
      ) {
        originalRequest._retry = true
        return await $axios.post('api/auth/refresh').then(res => {
          $auth.setUserToken(res.data.access_token)
          $axios.setToken(res.data.access_token, 'Bearer')
          originalRequest.headers.Authorization = 'Bearer ' + res.data.access_token
          return $axios.request(originalRequest)
        }).catch(err => {
          //error handling
        })
      }
    }
    if (error.response.status === 500 && (
      error.response.data.message === 'Token has expired and can no longer be refreshed' ||
      error.response.data.message === 'The token has been blacklisted'
    )) {
      await $router.push({name: 'admin-login'})
    }
    return Promise.reject(error)
  })
}
```

errorハンドリングの内部できちんとawait等を使用しないと処理が止まらず流れてしまうので注意。最終行のリジェクトが実行されてしまうと、例えば非同期で取得したリトライの結果はコンポーネントに渡さません。
またsetUserTokenは引数で渡されたトークンをセットした後、`nuxt.config.js`のstrategiesniに記載されたエンドポイントにてユーザー情報を取得しユーザー情報をログイン状態も含め更新するため以下のように設定しておく必要があります。

```js:nuxt.config.js
auth: {
    strategies: {
      local: {
        endpoints: {
          user: {
            url: '/api/auth/me', method: 'get', propertyName: 'user', //fetch user
          },
        },
      }
    }
  },
```

# 実装⑤ブラックリスト登録猶予時間の設定
例えばあるページ内部で複数の非同期apiリクエストを行うことがあると思います。その場合それぞれが401に対してトークンをリフレッシュすることになりますが、この場合いずれかのリフレッシュが完了した時に即座に該当トークンがブラックリスト登録されてしまい、2番目以降のリフレッシュが失敗し処理が止まってしまいます。

```
requestA -> 401 -> refresh with old token -> 200 (old token is black listed)
requestB -> 401 -> refresh with old token -> 500 (old token is already black listed)
```
そのため複数の401に対してリフレッシュする場合にブラックリスト登録までの猶予期間を設ける必要があります。tymon/jwt-authの場合`jwt.php`と`env`で設定することが可能です。

```php:config/jwt.php
    /*
    | -------------------------------------------------------------------------
    | Blacklist Grace Period
    | -------------------------------------------------------------------------
    |
    | When multiple concurrent requests are made with the same JWT,
    | it is possible that some of them fail, due to token regeneration
    | on every request.
    |
    | Set grace period in seconds to prevent parallel request failure.
    |
    */

    'blacklist_grace_period' => env('JWT_BLACKLIST_GRACE_PERIOD', 0),

```

```:.env
JWT_BLACKLIST_GRACE_PERIOD=10
```

# まとめ
VueのみでSPAを作っている場合とはまた勝手が違い、色々迷ってしまいました。バチっと決まるサンプルもなかなか見つからず思いがけず苦労しましたが一旦これで更新自体は可能になりました。authのバージョン５からはもっと簡単にリフレッシュできるようになるみたいなのでアップデートがくるまでは一旦これをベースにしていこうかなと思います。