12k
All articles

Web Locks API でトークンリフレッシュの競合を解消する

Web Locks APIとnavigator.locks.request、トークン再確認で、タブ間のトークン更新競合を防ぎます。

OpenReplay Team
OpenReplay Team
Web Locks API でトークンリフレッシュの競合を解消する

複数のタブが 1 つのローテーティングリフレッシュトークンを共有している場合、最初のタブのリフレッシュ呼び出しが、他のタブがまさに送信しようとしているトークンを無効化してしまうことがあります。その結果、他のタブのリフレッシュは失敗し、ユーザーはすべてのタブで一斉にログアウトさせられることになります。解決策は、リフレッシュ処理を navigator.locks.request でラップし、ちょうど 1 つのタブだけがリフレッシュを実行して残りは待機させ、そのタブが保存したトークンを再利用することです。

「何もしていないのにログアウトされた」というバグ報告を追いかけているのに、自分のマシンではまったく再現しない——そんなときは、報告者がタブをいくつ開いていたかを尋ねてみてください。インターセプター内のシングルタブ向けリフレッシュキューは、それ自体としては正しい実装ですが、この競合状態を検知することはできません。

本記事では、このログアウトを引き起こすシーケンス、localStorage フラグが信頼できる解決策にならない理由、そして修正を構成する 3 つの要素——ロック、ロック内での再チェック、インターセプターへの組み込み——を順に見ていきます。

要点

  • 複数のタブがローテーション対象のトークンを同時にリフレッシュすると、成功するのは最初の呼び出しだけです。サーバーは他のすべてのタブが送信しようとしているトークンを無効化し、ユーザーはあらゆる場所でログアウトされます。
  • navigator.locks.request は、同一オリジンのすべてのタブ・iframe・ワーカーに共有ミューテックスを提供します。Baseline Widely available であるため、フォールバックコードは不要です。
  • localStorage フラグはロックではありません。アトミックな compare-and-set が存在せず、クラッシュしたタブが書き込んだフラグは残り続けます。一方 Web Lock は自動的に解放されます。
  • 待機側のタブは、ロックのコールバック内で保存済みトークンを再チェックし、「最近リフレッシュした」というタイムスタンプではなく、トークン自身の有効期限で判定しなければなりません。
  • 正しく実装すれば、いくつのタブが期限切れトークンに遭遇しても、ネットワーク上のリフレッシュはちょうど 1 回だけになります。

なぜ複数タブ間でリフレッシュトークンが失敗するのか?

この競合状態には 4 つの材料が必要です。短命なアクセストークン、リフレッシュエンドポイント、リフレッシュトークンのローテーション、そして複数のタブです。いずれもごく一般的な条件です。OAuth 2.0 のセキュリティベストプラクティスである RFC 9700 は、パブリッククライアントに対してリフレッシュトークンの扱いを 2 つの選択肢に限定しています。すなわち、各トークンを発行先クライアントに紐付けるか、使用ごとに新しいトークンを発行するかです。これにより、SPA ではローテーションがデフォルトの姿勢となります。

シーケンスはこうです。ユーザーは 3 つのタブを開いており、アクセストークンが期限切れになります。各タブの次のリクエストは 401 を受け取り、各タブのインターセプターはそれぞれ独立にリフレッシュエンドポイントを呼び出します。タブ A の呼び出しが最初に到達して成功します。サーバーが使用時にローテーションを行い、以前のリフレッシュトークンを無効化する場合、タブ B と C はもう無効な資格情報を送信していることになります。それらのリフレッシュは失敗し、エラーハンドラーはリフレッシュ失敗を致命的な認証エラーとして扱い、ユーザーは作業の途中でログイン画面へリダイレクトされます。

セッションリプレイで見ると、このバグには特徴的なシグネチャがあります。直前にユーザー操作がまったくないログイン画面へのリダイレクトが、ユーザーの開いているすべてのタブで同じ 1 秒以内に発生する、というものです。コンソールに現れる何かではなく、このシグネチャがバグを特定する手がかりになります。

なぜタブごとの状態や localStorage フラグでは解決できないのか?

インターセプター内の isRefreshing フラグと Promise キューは、1 つのタブの JavaScript メモリ内に存在します。タブはメモリを共有しないため、タブ B はタブ A のフラグを一切見ることができません。タブ間の協調には、ブラウザレベルのプリミティブが必要です。

従来の回避策である localStorage 内の「リフレッシュ実行中」フラグは、ロックではありません。Web Storage APIgetItemsetItem を提供しますが、アトミックな compare-and-set はありません。そのため 2 つのタブがどちらも「リフレッシュは実行中でない」と読み取り、どちらの書き込みが完了する前にも両方がリフレッシュを開始してしまう可能性があります。フラグは逆方向にも失敗します。フラグをセットしたタブがリフレッシュ途中でクラッシュしたり閉じられたりすると、フラグは永久にセットされたままになり、生き残ったすべてのタブは決して終わらないリフレッシュを待ち続けます。Web Lock は、保持者のドキュメントが消滅した瞬間にブラウザによって解放されます。これはまさに、自作フラグを行き詰まらせる障害パターンです。

リフレッシュを navigator.locks.request でラップする方法

Web Locks API は、あるオリジン上のすべてのタブ・iframe・ワーカーに共有ミューテックスを提供します。navigator.locks.request(name, callback) を使うと、与えられた名前について同時にコールバックを実行できる保持者は 1 つだけになり、そのコールバックが返す Promise が解決(resolve)でも拒否(reject)でも settle した時点で、ブラウザはロックを解放します。忘れがちな unlock 呼び出しは存在せず、fetch が失敗してもロックがリークすることはありません。Safari 15.4 が 2022 年 3 月に対応して以降、主要なすべてのエンジンがこの API を出荷しており、そのため MDN は Baseline Widely available と評価しています。したがって、機能検出やフォールバック分岐は不要です。

async function refreshTokenAcrossTabs() {
  return navigator.locks.request("token-refresh", async () => {
    const existing = getUsableToken();
    if (existing) return existing; // another tab already refreshed

    const res = await fetch("/auth/refresh", {
      method: "POST",
      credentials: "include",
    });
    if (!res.ok) throw new Error("refresh_failed");

    const { accessToken, expiresAt } = await res.json();
    writeToken(accessToken, expiresAt);
    return accessToken;
  });
}

readTokenwriteToken は意図的に抽象化しています。トークンをどこに保存するかは本記事では扱わない別のセキュリティ上の判断であり、ロックの動作はその選択に関わらず同じです。

再チェック:時計ではなくトークンを検査する

多くの実装が省略してしまうステップが、ロックのコールバック内での再チェックです。ロックを待っていたタブは、まず保存済みトークンが現在有効かどうかを検査し、有効であれば 2 回目のネットワーク呼び出しを行わずにそれを返すべきです。3 つのタブがロックのキューに並び、最初のタブがラウンドトリップを実行します。2 番目と 3 番目はその後にロックを取得し、使用可能なトークンを見つけて即座に返します。タブ数に関係なく、ネットワークリフレッシュは 1 回です。

この再チェックの判定は、タイムスタンプがどれだけ最近書き込まれたかではなく、保存済みトークンが実際に使用可能かどうかに基づかせてください。「直近 5 秒以内にリフレッシュした」という壁時計ベースの判定は、2 通りに壊れます。ウィンドウよりリフレッシュが遅いと、待機中のタブは「何も起こっていない」と誤って結論して重複リクエストを発火させます。また、クライアントの時計がずれていると、どちらの方向でも比較が無効になります。トークン自身の有効期限は、自分自身について嘘をつくことができません。

const SKEW_MS = 30_000; // tolerate modest clock drift

function getUsableToken() {
  const stored = readToken(); // { token, expiresAt } or null
  if (!stored) return null;
  return stored.expiresAt - SKEW_MS > Date.now() ? stored.token : null;
}

401 インターセプターへのロックの組み込み

インターセプターの役割は変わりません。401 を捕捉し、新しいトークンを取得し、元のリクエストを 1 回だけ再実行します。唯一の違いは、リフレッシュ呼び出しが refreshTokenAcrossTabs() になったことです。これにより、直列化がすべてのタブにまたがるようになります。

api.interceptors.response.use(
  (response) => response,
  async (error) => {
    const original = error.config;

    if (error.response?.status !== 401 || original._retry) {
      return Promise.reject(error);
    }
    if (original.url?.includes("/auth/refresh")) {
      await logout(); // the refresh itself failed: session is gone
      return Promise.reject(error);
    }

    original._retry = true;
    try {
      const token = await refreshTokenAcrossTabs();
      original.headers.Authorization = `Bearer ${token}`;
      return api(original);
    } catch (refreshError) {
      await logout();
      return Promise.reject(refreshError);
    }
  }
);

_retry ガードはループを防止し、リフレッシュエンドポイント自身からの 401 はリトライではなくログアウトを意味します。待機側のタブの視点から見た完全な経路はこうです。401 を受け取る、ロックのキューに並ぶ、ロックを取得する、有効なトークンを見つける、ネットワーク呼び出しゼロで返る、元のリクエストを再実行する。

知っておくべきエッジケース

  • Web Locks はセキュアコンテキストを必要とします。http://localhost潜在的に信頼できるオリジンとして条件を満たします。
  • 仕様の終了規則により、ドキュメントのロックはアンロード時に解放されます。したがってロックがリロードやナビゲーションを越えて生き残ることはなく、永続的な状態にはなり得ません。
  • クリティカルセクションはリフレッシュ処理だけに留めてください。その中で await するその他の処理は、すべてのタブをブロックします。
  • 同じロックをそのコールバック内で再度リクエストしてはいけません。内側のリクエストは外側の保持の後ろにキューイングされ、何のエラーも出さずに永久にハングします。
  • 共有モード、ifAvailable によるリーダー選出、steal は別の用途のために存在します。トークンリフレッシュではいずれも必要ありません。
  • BroadcastChannel は他のタブに「何かが起こった」ことを伝えます。ロックは、それらすべてが同時に実行することを防ぎます。

まとめ

ランダムログアウトのバグは、ユーザーのマシン上で発生している分散システムの競合状態であり、ブラウザはそれを終わらせるミューテックスを標準で提供しています。リフレッシュ処理を navigator.locks.request でラップし、コールバックの最初の行をトークン有効性チェックにし、401 ハンドラーをそこ経由でルーティングしてください。まず複数のタブと短いトークン有効期間でバグを再現し、それからロックを適用して、N 回のリフレッシュ呼び出しが 1 回に収束する様子を確認しましょう。

FAQ

タブ間のトークンリフレッシュにおいて、BroadcastChannel は Web Locks API の代わりになりますか?

なりません。BroadcastChannel はメッセージングのトランスポートであり、ミューテックスではありません。リフレッシュが発生したことを通知することはできますが、どちらのメッセージが到達する前に 2 つのタブが両方ともリフレッシュを開始することを防ぐ手段はなく、localStorage フラグと同じ read-then-act の競合が生じます。リフレッシュの直列化には navigator.locks.request を使い、新しいトークンをリッスンしているタブへプッシュしたい場合にのみ、後段で BroadcastChannel を追加してください。

navigator.locks.request 呼び出しにタイムアウトを追加する方法は?

signal オプションで AbortSignal を渡します。リクエストがまだキューに並んでいる間に abort すると Promise は AbortError で reject されるため、AbortSignal.timeout を使えば待機に期限を設けられます。ロックが付与された後は signal は何の効果も持たなくなるため、すでに実行中のコールバックをタイムアウトで打ち切ることはできません。signal を steal や ifAvailable と併用すると NotSupportedError で reject されるので、リクエストごとに 1 つの戦略を選んでください。

Web Locks API は Web Worker や Service Worker でも動作しますか?

はい。仕様は LockManager を Window コンテキストと Worker コンテキストの両方に公開しているため、Dedicated Worker、Shared Worker、Service Worker のいずれからも navigator.locks.request を呼び出せます。同一オリジン上のすべてのコンテキストが 1 つのロックマネージャーを共有するため、ワーカーが 'token-refresh' ロックをリクエストすると、同じ名前をリクエストしているタブと同じキューに並びます。したがって、認証ロジックの一部がメインスレッド外で動作する場合でも、このパターンは正しく機能し続けます。

Understand every bug

Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — self-hosted, with full data ownership.

Star on GitHub

We use cookies to improve your experience. By using our site, you accept cookies.