axiosでアクセストークン自動リフレッシュの無限ループを防ぐ

トークンベース認証のSPAで、アクセストークンの有効期限切れをaxiosのレスポンスインターセプターで検知して、自動でリフレッシュする処理を入れたんです。最初に書いたコードが想定通り動かず、むしろリクエストが無限に飛び続ける状態になったので、そのときの切り分けと修正内容を残しておきます。

最初のコードと無限ループの原因

最初はこういうシンプルな構成でした。401が返ってきたらリフレッシュエンドポイントを叩いて、成功したら元のリクエストを再送する、と。

apiClient.interceptors.response.use(
  (response) => response,
  async (error) => {
    const originalRequest = error.config;

    if (error.response?.status === 401) {
      const { data } = await apiClient.post('/api/auth/refresh', {
        refreshToken: getRefreshToken(),
      });
      setAccessToken(data.accessToken);
      originalRequest.headers['Authorization'] = 'Bearer ' + data.accessToken;
      return apiClient(originalRequest);
    }

    return Promise.reject(error);
  }
);

これでいけるかなと思ったんですが、アクセストークンが切れた状態で何かAPIを叩くと、リフレッシュエンドポイント自体も401を返すんです。そうすると、そのレスポンスがまたインターセプターに捕まって、さらにリフレッシュを叩く。結果、同じリクエストが延々と繰り返される。原因はまあ見えてて、リフレッシュのリクエストまで同じインターセプターを通るようになっていたのがまずかったんです。

インターセプターを持たない独立したインスタンスを用意する

構造的な問題として、リフレッシュ処理の中で apiClient.post を使っている部分がずっと引っかかっていました。リフレッシュ要求が何らかの理由で apiClient を経由すると、共通インターセプターが再び401を拾って、isRefreshing のフラグ管理をすり抜けて同じところをぐるぐる回りかねない。実際、最初のコードはまさにそれでループしてました。

今は _retry フラグで防げてはいるものの、チームで触っていると誰かがうっかりフラグを外したり、別の経路で apiClient を呼んだりする可能性もある。そうなる前に、インターセプターを一切持たない独立したインスタンスを用意しておくのが安全だろうと判断しました。

const refreshClient = axios.create({
  baseURL: '/api',
});

この refreshClient には共通インターセプターを付けていません。以降のリフレッシュ処理では、apiClient.post ではなく refreshClient.post を使います。baseURL に /api を指定しているので、パスから /api を除いた /auth/refresh を指定する形ですね。

// 変更前
const { data } = await apiClient.post('/api/auth/refresh', {
  refreshToken: getRefreshToken(),
});

// 変更後
const { data } = await refreshClient.post('/auth/refresh', {
  refreshToken: getRefreshToken(),
});

リフレッシュ中の後続リクエストをキューに溜める

次に、複数コンポーネントが同時にAPIを叩いたときの競合をどうするか、です。それぞれが個別にリフレッシュを実行すると、トークンの更新が競合して、先に更新されたトークンが後続のリクエストに反映されないみたいな問題が出ます。

そこで、isRefreshing フラグとキューを外側に持たせて、リフレッシュ中に入ってきたリクエストは待たせておいて、リフレッシュ完了後にまとめて再送する形に書き換えました。

type QueueItem = {
  resolve: (token: string) => void;
  reject: (error: unknown) => void;
};

type ProcessQueueArg =
  | { error: unknown; token?: never }
  | { error?: never; token: string };

let isRefreshing = false;
let failedQueue: QueueItem[] = [];

const processQueue = (arg: ProcessQueueArg) => {
  failedQueue.forEach((prom) => {
    if (arg.error) {
      prom.reject(arg.error);
    } else {
      prom.resolve(arg.token);
    }
  });
  failedQueue = [];
};

インターセプター側では、401かつ _retry が付いていないリクエストの場合、まず _retry フラグを付ける。その後、isRefreshing を見て、すでにリフレッシュ中なら新しいPromiseを返してキューに積む。リフレッシュが終わったら processQueue で待ってるリクエストに新しいトークンを渡す、という流れです。

apiClient.interceptors.response.use(
  (response) => response,
  async (error) => {
    const originalRequest = error.config;

    if (error.response?.status === 401 && !originalRequest._retry) {
      originalRequest._retry = true;

      if (isRefreshing) {
        return new Promise((resolve: (token: string) => void, reject: (error: unknown) => void) => {
          failedQueue.push({ resolve, reject });
        })
          .then((token) => {
            originalRequest.headers['Authorization'] = 'Bearer ' + token;
            return apiClient(originalRequest);
          })
          .catch((err) => {
            return Promise.reject(err);
          });
      }

      isRefreshing = true;

      try {
        const { data } = await refreshClient.post('/auth/refresh', {
          refreshToken: getRefreshToken(),
        });
        const newAccessToken = data.accessToken;
        setAccessToken(newAccessToken);
        apiClient.defaults.headers.common['Authorization'] = 'Bearer ' + newAccessToken;
        processQueue({ token: newAccessToken });
        originalRequest.headers['Authorization'] = 'Bearer ' + newAccessToken;
        return apiClient(originalRequest);
      } catch (refreshError) {
        processQueue({ error: refreshError });
        clearTokens();
        window.location.href = '/login';
        return Promise.reject(refreshError);
      } finally {
        isRefreshing = false;
      }
    }

    return Promise.reject(error);
  }
);

これで同時多発的にAPIリクエストが走っても、最初の401で一度だけリフレッシュが走り、キューで待ってるリクエストには新しいトークンが順番に渡るはず、という想定でした。

リフレッシュトークン自体が期限切れの場合の挙動

検証を進めると、リフレッシュトークンがサーバー側で失効しているケースのハンドリングがあやしいことが見えてきました。catchブロックで clearTokens() を呼んで、window.location.href = '/login' で強制リダイレクトさせているんですが、SPAのルーター状態や、Zustandのpersist middlewareによるストレージへの非同期書き込みが絡むと、クリアと画面遷移が競合してリダイレクトがループするケースがありました。

具体的には、clearTokens() でストアを空にしても、persist middlewareが localStorage や sessionStorage に書き込む前に画面遷移が走ると、古いトークンが残った状態でログイン画面が出る。再度何かのAPIを叩いたときに古いリフレッシュトークンでリフレッシュを試みて、また401になってリダイレクト、という繰り返しです。

この問題への対処としては、clearTokens() でストアを空にするだけでなく、persist middlewareの rehydrate 完了を待つか、localStorage.removeItem()sessionStorage.removeItem() を直接呼んで同期的にストレージを消してから window.location.href = '/login' に遷移するのが現実的かなと考えています。ただ、Zustandのpersist middlewareは内部で非同期にストレージへ書き込むので、完全に同期させるには clearTokens() の中でストレージの直接削除もやるか、await できる形にラップする必要がある。このへんは今後の宿題として残ってます。

リフレッシュトークンをボディに含める設計のズレ

今回はリフレッシュトークンをリクエストボディに含める設計にしています。クライアント側のストアから getRefreshToken() で値を取り出す実装なんですが、タブ間の同期ズレや、メモリ上に保持している場合のスコープ問題で、リフレッシュ要求のペイロードが空または古いトークンを指す瞬間が発生します。

本来ならHttpOnlyクッキーに逃がしたいところですが、バックエンドのドメイン構成とCORSの制約で、別サブドメイン運用をしている関係上、今の管理方法から簡単には移行できない。このへんはバックエンド側の改修も必要になるので、今回は見送りました。

それでも残る非同期処理の競合

ここまでで無限ループは収まりましたが、根本的な懸念は残っています。axiosのインターセプターはReactのライフサイクルやレンダリングツリーとは完全に切り離された関数スコープなので、そこで Zustand のセッターやゲッターを直接叩いていると、React 側の状態更新とタイミングが噛み合わないケースが出てきます。

コンソールにログを仕込んでキューの消化順序とトークンの書き換わりを確認しているんですが、ネットワークの遅延や、ユーザーが高速で画面遷移したときにキューが予期せぬ順序でrejectされ、アプリ全体が宙ぶらりんなエラー状態に落ちる挙動がまだ再現します。インスタンスの分離とキューイングだけでは、非同期処理の競合レースコンディションを防ぎきれていない。

このへんは、axiosのインターセプターの中でストアを直接更新するのではなく、トークン管理をReact Queryのキャッシュや、イベントベースの仕組みに寄せるとか、設計の見直しが必要かもしれません。とりあえず動く状態までは持ってきましたが、複数タブでの動作や、トークンの有効期限が極端に短い場合の挙動はもう少し追ってみる必要がありそうです。