Node.jsでOAuthトークンのリフレッシュが重複する競合問題をPromiseロックで解決した

Node.jsでOAuthトークンのリフレッシュが重複する競合問題をPromiseロックで解決した

Promise.allで並列実行するとOAuthトークンのリフレッシュが重複する問題を、進行中のPromiseをロックとして保持するパターンで解決しました。booleanフラグの落とし穴と、正しい実装方法を解説します。
2026.07.23

はじめに

複数の外部APIと連携するNode.js自動化システムを運用しているなかで、あるタイミングでOAuthトークンのリフレッシュが重複して発行される問題に遭遇しました。

症状はこうです。複数の処理が並列で走るタイミングで、「トークンの期限切れ → リフレッシュ」が競合して同時に3〜4件のリフレッシュリクエストが送信されることがありました。外部APIによっては、これが原因でリフレッシュトークンが無効化されてしまい、その後の処理がすべて認証エラーになるケースが発生していました。

この記事では、その問題の原因と、Promiseを使ったシンプルな解決策を紹介します。

問題: なぜリフレッシュが重複するのか

まず典型的なトークン管理の実装を見てみましょう。

ApiClient.ts
class ApiClient {
  private accessToken: string = "";
  private expiredAt: number = Date.now();

  private async checkToken(): Promise<void> {
    if (Date.now() >= this.expiredAt) {
      await this.refreshAccessToken(); // ← ここが問題
    }
  }

  private async refreshAccessToken(): Promise<void> {
    const res = await fetch("/oauth/token", {
      method: "POST",
      body: new URLSearchParams({ grant_type: "refresh_token", ... }),
    });
    const data = await res.json();
    this.accessToken = data.access_token;
    this.expiredAt = Date.now() + data.expires_in * 1000;
  }

  public async getUsers(): Promise<User[]> {
    await this.checkToken();
    // ...
  }

  public async getOrders(): Promise<Order[]> {
    await this.checkToken();
    // ...
  }
}

このコードの問題は、checkToken() が非同期であることです。

// 並列実行されるケース
await Promise.all([
  client.getUsers(),
  client.getOrders(),
  client.getProducts(),
]);

Promise.all で並列実行すると、各メソッドが checkToken() を呼ぶ時点ではトークンはまだ期限切れのままです。なぜなら最初の refreshAccessToken() がまだ完了していないからです。

oauth-token-refresh-race-condition-race

if (Date.now() >= this.expiredAt) のチェックは同期的に評価されますが、その後の await refreshAccessToken() は非同期なため、3つのメソッドがほぼ同時にチェックを通過して、それぞれがリフレッシュを開始してしまいます。

よくある間違った解決策

boolean のフラグで「リフレッシュ中かどうか」を管理しようとするパターンを見かけます。

private isRefreshing = false;

private async checkToken(): Promise<void> {
  if (this.isRefreshing) return; // ← これでは解決しない
  if (Date.now() >= this.expiredAt) {
    this.isRefreshing = true;
    await this.refreshAccessToken();
    this.isRefreshing = false;
  }
}

これは動作しません。確かに isRefreshing = trueawait の前に同期的に代入されるため、2番目以降の呼び出し元はフラグを見てリフレッシュの重複自体は防げます。しかし、本当の問題はその先にあります。

isRefreshingtrue のとき、後続の呼び出し元は早期 return してしまい、リフレッシュの完了を待たずに処理を進めてしまいます。結果として、まだ期限切れの古いトークンを使ったAPIリクエストが発行され、認証エラーが発生します。

解決策: Promiseそのものをロックとして使う

正しいアプローチは、進行中のリフレッシュ処理のPromiseを保存しておき、後続の呼び出し元がそのPromiseをawaitすることです。

ApiClient.ts
class ApiClient {
  private accessToken: string = "";
  private expiredAt: number = Date.now();
  private refreshToken: string = "";
  private tokenRefreshPromise: Promise<void> | null = null; // ← 追加

  private async checkToken(): Promise<void> {
    // リフレッシュがすでに進行中なら、そのPromiseをawaitして待つ
    if (this.tokenRefreshPromise) {
      await this.tokenRefreshPromise;
      // 待ち終わったあと、トークンが有効になっていれば終了
      if (this.refreshToken && Date.now() < this.expiredAt) {
        return;
      }
    }

    // リフレッシュが必要な場合
    if (!this.refreshToken || Date.now() >= this.expiredAt) {
      const refreshOperation = this.refreshAccessToken();

      // Promiseを保存して、後続の呼び出し元が共有できるようにする
      this.tokenRefreshPromise = refreshOperation;

      try {
        await refreshOperation;
      } finally {
        // 完了後(成功・失敗を問わず)Promiseをクリア
        this.tokenRefreshPromise = null;
      }
    }
  }

  private async refreshAccessToken(): Promise<void> {
    const res = await fetch("/oauth/token", {
      method: "POST",
      body: new URLSearchParams({
        grant_type: "refresh_token",
        refresh_token: this.refreshToken,
      }),
    });
    const data = await res.json();
    this.accessToken = data.access_token;
    this.refreshToken = data.refresh_token;
    this.expiredAt = Date.now() + data.expires_in * 1000;
  }
}

なぜこれが機能するのか

Promiseオブジェクトはその「結果」ではなく「処理の参照」を持っています。同じPromiseを複数箇所で await しても、リフレッシュのリクエストは1回しか発行されません。

oauth-token-refresh-race-condition-solution

finally ブロックで tokenRefreshPromise = null をクリアしているのも重要です。エラーが発生してリフレッシュが失敗した場合でも、次回の呼び出し時にリトライできるようにするためです。

実際の運用での気づき

このパターンを実装するきっかけになったのは、異なるAPIクライアントで独立して同じ問題が発生したことです。Microsoft Graph API用のクライアントとBox API用のクライアントの両方で、並列実行時に同様の競合が起きていました。

それぞれのOAuth実装はAPIの仕様が異なる(スコープ、エンドポイント、レスポンス形式など)ものの、トークン管理のロジックはほぼ同一でした。2つ目の問題が起きたとき、「これは構造的な問題だ」と気づき、同じ解決策を適用しました。

初期化が必要なケース(refreshTokenがない状態)にも対応が必要だった点も実装で重要でした。初回は refreshToken が存在しないため、ブラウザ経由でOAuth認可コードを取得するフローが走ります。このフローも同じ tokenRefreshPromise でロックすることで、並列実行時に複数のブラウザが立ち上がる問題を防いでいます。

// refreshTokenの有無で処理を分岐しながら、同じロックで保護する
if (!this.refreshToken || Date.now() >= this.expiredAt) {
  let refreshOperation: Promise<void>;
  if (!this.refreshToken) {
    refreshOperation = this.forgeRefreshToken(); // ブラウザでOAuth認可
  } else {
    refreshOperation = this.refreshAccessToken(); // 通常のリフレッシュ
  }

  this.tokenRefreshPromise = refreshOperation;
  try {
    await refreshOperation;
  } finally {
    this.tokenRefreshPromise = null;
  }
}

まとめ

Promise<void> | null をロックとして使うパターンのポイントをまとめます。

比較項目 boolean フラグ Promise ロック
後続の呼び出しの扱い 早期returnで無視 完了まで待機する
リフレッシュの重複 防げるが待機できない 防げて待機もできる
失敗時のクリーンアップ 手動管理が必要 finally で確実にクリア
コードの複雑さ シンプルだが不完全 少し複雑だが正確

非同期処理の「ロック」を実装したいとき、boolean フラグではなく 進行中のPromiseそのものを状態として保持する、という考え方が有効です。外部APIとの連携が多いシステムや、並列処理を多用する場面では特に有用なパターンだと思います。

この記事をシェアする

関連記事