🙄

Axios Interceptor を使用して401が返ったときにトークン(JWT)更新処理を呼ぶ

2022/11/14に公開

前提

  • axiosを使用している
  • JWT(Json Web Token) を使用している
  • APIサーバ側は、期限切れのアクセストークンを受けたときに401を返す
  • トークン更新には axios interceptors を使用する

やりたいこと

  • axios で401のレスポンスを受けたとき、リフレッシュトークン用いてアクセストークンを更新したい

どうやったか

様々な問題に当たって困っていたところ、こちらの方法でシンプルに解決できました。トークンリフレッシュをメモ化することで解決しています。この発想はできなかった。。
https://dev.to/franciscomendes10866/how-to-use-axios-interceptors-b7d

簡単に解説

重要な点は2点

  • リフレッシュトークンの更新処理をメモ化する
  • メモ化した関数を async await を使用して呼び出す

メモ化にはmem を使用している
https://github.com/sindresorhus/mem

なぜメモ化するのか

同時にAPIを呼び出すような場合、401のレスポンスが複数、ほぼ同時に返ってくる。対策をしてない場合には、更新処理が全てで起こり、結果的に無限にリフレッシュトークンの更新が走り続ける。
リフレッシュトークンの更新がメモ化されていれば、複数の401が返ってきた場合でも、最初のresponseで関数がメモ化されて、2回目以降の応答は1回目と同一になる。つまり、2回目以降のすべてのリフレッシュトークンの更新処理を無視できる。

この点については上記のブログにも説明がある

Why is memoization done? The answer is very simple, if twenty http requests are made and all of them get a 401 (Unauthorized), we don't want the token to be refreshed twenty times.

When we memoize the function, the first time it is invoked, the http request is made, but from then on, its response will always be the same because it will be cached for ten seconds (and without making the other nineteen http requests).

With the public instance of axios configured and the refresh token function memorized, we can move on to the next step.

メモ化する時間は仕様にあった判断で。例では10秒だが、20秒くらいあったほうが安全かもしれない。

実装の抜粋

いろいろと削っています。

import Axios from 'axios'
import mem from 'mem'

const axios = Axios.create({
  baseURL: 'http://localhost:3333',
  headers: {}
})

axios.interceptors.request.use((config) => {
  const token = localStorage.getItem('auth-token')

  if (token) {
    config.headers.authorization = `Bearer ${token}`
  }

  return config
})

axios.interceptors.response.use(
  (response) => response,
  async function (error) {

    if (error?.response?.status === 401) {
      await memoizedRefreshToken()

      return axios.request(error.config)
    }

    return Promise.reject(error)
  }
)

export default axios


const refreshTokenFn = async () => {
  const refreshToken = localStorage.getItem('refresh-token')

  await axios
    .post("/user/refresh", { refreshToken })
    .then((response) => {
      const data = response.data

      localStorage.setItem('auth-token', data['auth-token'])
      localStorage.setItem('refresh-token', data['refresh-token'])
    })
    .catch((error) => {
      localStorage.clear()
      return Promise.reject(error)
    })
}

const maxAge = 10000 //メモ化している時間
export const memoizedRefreshToken = mem(refreshTokenFn, {
  maxAge,
})

機能しない実装たち

忘備録代わりに置いておきます。参考になることはないと思うので折り畳んでいます。

おりたたみ

その1

シンプルに401が返ってきたらトークンを更新する

問題点

  • 複数件401が返ったときに無限ループする
if (error?.response?.status === 401) {
  
  // (略)RefreshToken 更新をここでする

  return axios.request(error.config)
}

その2

トークン更新中のフラグを localStorage に立てて、リフレッシュトークンの更新を2件目以降でしない

問題点

  • 無限ループはしないが、2件目以降の再フェッチがおこなわれない
if (error?.response?.status === 401) {
  
  const doRefresh = localStorage.getItem("do-refresh");
  if (doRefresh === "1") {
    return Promise.reject(error)
  }

  localStorage.setItem("do-refresh", "1"); // トークン更新中のフラグを立てる
  // (略)RefreshToken 更新をここでする

  return axios.request(error.config)
}

その3

トークン更新中のフラグを localStorage に立て、2件目以降を待機させる。

問題点

  • フラグを立てている間に2件目の更新処理が走ることがある(つまり何の解決にもならない)
  • トークンの更新を一定の時間待たせることになる
if (error?.response?.status === 401) {
  
  // 2件目以降は待機 & 待機終了後に再フェッチ
  const doRefresh = localStorage.getItem("do-refresh");
  if (doRefresh === "1") {
    check()
  }

  localStorage.setItem("do-refresh", "1"); // トークン更新中のフラグを立てる
  // (略)RefreshToken 更新をここでする

  return axios.request(error.config)
}

-----

const check = async () => {
  await wait(1000); // 指定時間待たせる。この間にトークンが更新完了しているはず

  const canRefresh = localStorage.getItem("do-refresh") !== "1";
  if (canRefresh) {
    //再取得処理
    const { config } = error;
    return axios.request(config);
  }

  return Promise.reject(error);
};

const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

Discussion