🔖

Fetch APIで401が返ったときにトークンをいい感じに更新する

に公開3

はじめに

以前、axios Interceptor を使用して401が生じた際にはトークンをリフレッシュさせる手法を記事にしました。
しかしaxiosでなくfetchで十分な時代になっており、余計な依存関係を含めないという意味でも置き換えたほうが良い、ということでだいぶ前に実装したもの概要をここに残しておきます。

https://zenn.dev/tanoshima/articles/db552b7962086a

memoize を使用する方法

まずはじめに、過去記事でもやっていた方法の踏襲です。memoize(旧名 mem)を利用して最初のリフレッシュが完了するまで他のリクエストは、その結果を待つということをさせていました。しかし、実は Promise キャッシュだけでやりたいことは実現できました。
なのでここは前記事を単純にfetchに置き換えた場合です。飛ばしていただいて大丈夫です。

import memoize from 'memoize';

export async function customFetchAuth(path: string, options: any) {
  let token = getToken();

  // トークンが既に期限切れなら、リクエストを送る前に更新を試みる
  if (!isAccessTokenValid(token)) {
    const success = await refresh();
    if (!success) {
        throw new Error('Unauthorized');
    }
    token = getToken(); // 更新後のトークンを取得
  }

  // リクエストヘッダーの設定など
  const config = {
    ...options,
    headers: {
      ...options.headers,
      Authorization: `Bearer ${token}`,
    },
  };
  
  try {
    const res = await fetch('url', config);

    if (!res.ok) {
      // 401 (Unauthorized) の場合、トークンをリフレッシュしてリトライ
      if (res.status === 401) {
        const success = await refresh();

        if (success) {
          // リフレッシュ成功時:自分自身を再度呼び出してリトライした結果を返す
          return customFetchAuth(path, options);
        } else {
          throw new Error('Token refresh failed');
        }
      }

      throw new Error(`Fetch failed: ${res.status}`);
    }

    return res;
  } catch (error) {
    /* エラー処理を適切に */
  }
}

const refresh = async () => {
  const refreshToken = getRefreshToken();
  if (!refreshToken) {
    redirectToLogin(); // トークンが存在しない場合の処理
    return false;
  }

  // メモイズされたリフレッシュ関数を実行
  // 同時に複数のリクエストが 401 になっても、リフレッシュAPIは1回しか呼ばれない
  const result = await memoizedRefreshToken();

  if (!result.status) {
    redirectToLogin();
    return false;
  }

  return true;
};

const refreshTokenFn = async () => {
  /* トークンをリフレッシュして再格納する */
};

// 30秒間は同じリフレッシュ処理の結果を再利用する(連続実行防止)
export const memoizedRefreshToken = memoize(refreshTokenFn, {
  maxAge: 30000,
});

Promise キャッシュによる重複防止

status 401が返るときにトークンをリフレッシュし、そのリフレッシュを実行している間は他の fetch 実行を止めておく。リフレッシュが完了したら再開させる。という要件です。
memoize の利用よりも依存関係が減り、要件も満たしたうえで実装もシンプルになっています。
これは、Promise の、"一度 resolve すると、その後何度 .then() や await を呼んでも同じ結果を返す"という性質を利用しています。
また、memoizeで設定していた有効期限を、どの程度の長さにしたら良いかなど気にしなくてよいというのも良いところです。

// 実行中のリフレッシュ処理を保持する変数
let refreshTokenPromise: Promise<{ status: boolean }> | null = null;

export async function customFetchAuth(path: string, options: any) {
  let token = getToken();

  // トークンが既に期限切れなら、リクエストを送る前に更新を試みる
  if (!isAccessTokenValid(token)) {
    const success = await refresh();
    if (!success) {
        throw new Error('Unauthorized');
    }
    token = getToken(); // 更新後のトークンを取得
  }

  // リクエストヘッダーの設定など
  const config = {
    ...options,
    headers: {
      ...options.headers,
      Authorization: `Bearer ${token}`,
    },
  };
  
  try {
    const res = await fetch('url', config);

    if (!res.ok) {
      // 401 (Unauthorized) の場合、トークンをリフレッシュしてリトライ
      if (res.status === 401) {
        const success = await refresh();

        if (success) {
          // リフレッシュ成功時:自分自身を再度呼び出してリトライした結果を返す
          return customFetchAuth(path, options);
        } else {
          throw new Error('Token refresh failed');
        }
      }

      throw new Error(`Fetch failed: ${res.status}`);
    }

    return res;
  } catch (error) {
    /* エラー処理を適切に */
  }
}

const refresh = async () => {
  const refreshToken = getRefreshToken();
  if (!refreshToken) {
    redirectToLogin(); // トークンが存在しない場合の処理
    return false;
  }

  // すでに実行中のリフレッシュ処理があれば、その Promise をそのまま返す
  if (refreshTokenPromise) {
    const result = await refreshTokenPromise;
    return result.status;
  }

  // まだ実行中でなければ、新しいリフレッシュ処理を開始して変数に格納する
  refreshTokenPromise = refreshTokenFn();

  try {
    const result = await refreshTokenPromise;
    
    if (!result.status) {
      redirectToLogin();
      return false;
    }
    return true;
  } finally {
    // 成功・失敗に関わらず、処理が終わったら変数を空にする
    // これにより、次回 401 が発生した際に再度リフレッシュが走るようになる
    refreshTokenPromise = null;
  }
};

const refreshTokenFn = async (): Promise<{ status: boolean }> => {
  /* トークンをリフレッシュして再格納する */
};

Discussion

junerjuner

それだと複数タブの場合に対応できないので次の様なアプローチもあります。

https://zenn.dev/uhyo/articles/web-locks-api-refresh-token-ai

tanotano

指摘ありがとうございます。
実際、マルチタブ対応が必要かは、トークンを管理するバックエンド側(猶予期間の有無など)の仕様との兼ね合いになるか思います。私の場合は稀にに競合が起きても問題のない仕様でしたのでこれで十分ではあったのですが、必要な場合にはuhyoさんの記事はとても参考になりますね。

junerjuner

勿論 sessionStoraget で トークン管理してるとか タブ間で共有されないアプローチなら問題はありませんね。そういえば。