TL;DR

  • リフレッシュトークンのローテーションでは、更新のたびに新しいリフレッシュトークンが返り、古いものは無効になります。無効になったトークンが再び提示されても、サーバーには盗んだ側か正規のクライアントか区別できません。そのため RFC 9700 §4.14.2 のとおり、有効なトークンを失効させ、ユーザーは認可をやり直すことになります。
  • 複数のリクエストを並行して送り、複数の 401 を受け取り、401 ごとに更新するクライアントは、同じリフレッシュトークンを何度も提示します。成功するのは最初の1回だけで、残りは再利用(リプレイ)に見えます。後で示す疑似サーバーでは、素朴なクライアントは実行するたびにトークンのファミリーが失効しました。
  • クライアント側の対処は single-flight 更新です。すべての呼び出し元が、共有する1回の更新を待ちます。遅れて届いた 401 が2回目の更新を始めないように stale check を足し、各リクエストの再試行は最大1回にします。
  • サーバー側の対処は、直前のトークンを短時間だけ受け付ける猶予期間です。ただしこれはトレードオフがある対処で、無料の解決策ではありません。
  • デモは依存ライブラリなしの Node で93行です。失敗するケースと、3つの対処を並べて出力します。

背景:ローテーションは何を守り、何を代償にするか

モバイルアプリやシングルページアプリのような公開クライアントは、秘密を保持できません。そのため、端末にあるリフレッシュトークンは盗む価値があります。RFC 9700 §4.14.2 は、認可サーバーが公開クライアントについて、悪意ある者によるリフレッシュトークンのリプレイを検出する方法を、次のどちらか一方は必ず使う(MUST)としています。送信者制約付きリフレッシュトークン(RFC 8705 または RFC 9449)か、リフレッシュトークンのローテーションです。

RFC が説明するローテーションは次のとおりです。サーバーは更新のたびに新しいリフレッシュトークンを発行し、前のものを無効にしつつ、両者の関係は記録しておきます。トークンが盗まれ、攻撃者と正規のクライアントの両方が使うと、どちらかは無効になったトークンを提示します。RFC は代償もはっきり書いています。認可サーバーは、無効なトークンを提示したのがどちらか判断できませんが、有効なリフレッシュトークンを失効させます。これで攻撃は止まりますが、正規のクライアントは新しい認可グラントを取り直さなければなりません。

ここで、ごく普通のクライアントを想像してください。アプリがバックグラウンドにいるあいだにアクセストークンが期限切れになりました。ユーザーが画面を開くと、API 呼び出しが3つ同時に飛びます。3つとも期限切れのアクセストークンで送られ、3つとも 401 が返ります。各 401 のハンドラーが「更新して、再試行する」と考えると、同じリフレッシュトークンを持つ更新リクエストが3つ送られます。サーバーから見れば、正しい使用が1回、リプレイが2回です。警戒するのは正しい動作で、盗難と見分けがつきません。

まず壊してみる

次が「当たり前」のハンドラーです(後のデモでは naive という戦略として出てきます)。

let r = await send(accessToken);
if (r.status === 401) {
  await refresh();           // 並行する 401 がすべてここに来る
  r = await send(accessToken);
}

ローテーションと再利用検知を持つ疑似サーバーに対して、3つの並行呼び出しは次のように終わります(出力は実際のものです。どのリクエストが成功するかは、実行ごとに変わります)。

naive: every 401 refreshes       | burst: ERR,200,ERR | POST /token: 3 | after next expiry: ERR (refresh failed: 400)

左から読んでください。1つの呼び出しが成功し、2つが失敗しました。更新リクエストは3つ送られました。しかも被害はあとから出ます。新しいアクセストークンが次に期限切れになったとき、クライアントのリフレッシュトークンはファミリーごと失効していて、更新は 400 を返し、ユーザーはログアウトされます。失敗が遅れて、間欠的に、タイミング次第で起きます。高速なマシンで1リクエストずつ試すテストをすり抜けるのは、そのためです。

検討した選択肢

方式内容トレードオフ
single-flight 更新(クライアント)並行する呼び出し元が、実行中の1回の更新を共有します。クライアントだけで完結します。基本はこれです。対象は1プロセスのみです(失敗パターンを参照)。
更新中はリクエストを待機させる(クライアント)更新が動いているあいだ、新しいリクエストは古いトークンで送らずに待ちます。失敗が確定している 401 の波を避けられます。キューが増え、詰まる場所もできます。single-flight の代わりではなく、補完です。
期限前の先回り更新(クライアント)トークンの有効期間から、少し早めに更新します。401 は減りますが、ゼロにはなりません。停止中やスロットリングされたプロセスではタイマーが発火する保証がなく、時計もずれます。401 の経路は結局必要です。
猶予期間(サーバー)ローテーション直後のトークンを、短時間だけ有効にします。並行実行と応答の喪失を吸収できます。代わりに、リプレイを許す時間がその分だけ延びます。長さを設定できるプロバイダーもあります。バグ修正ではなく、脅威モデルの判断です。
送信者制約付きトークン(サーバーとクライアント)トークンを鍵に結び付け、別の者によるリプレイを構造的に失敗させます(RFC 9449、RFC 8705)。RFC 9700 の要件のもう一方の選択肢です。変更が大きいので、この記事ではローテーションに絞ります。

クライアントしか運用していないなら、答えは single-flight と1回きりの再試行です。サーバーも運用しているなら、猶予期間は意図して決め、理由を書き残してください。

single-flight の仕組み

single-flight は小さな発想です。結果ではなく、Promise を保存します。

let inflight = null;
const refreshOnce = () =>
  (inflight ??= doRefresh().finally(() => { inflight = null; }));

最初の呼び出し元は inflight が空なので更新を始め、その Promise を保存します。それが完了する前に来た呼び出し元は、同じ Promise を受け取って待ちます。finally は成功でも失敗でもスロットを空にするので、失敗した更新が次の試行を巻き込むことはありません。JavaScript はシングルスレッドなので、確認と保存の間に割り込みは入りません。マルチスレッドの言語では、ミューテックスなどの仕組みが必要です。

ただ、これだけでは穴が1つ残ります。古いアクセストークンで送られたものの、共有された更新が終わった後に 401 が届いたリクエストは、inflight が空なので2回目の更新を始めてしまいます。最新のリフレッシュトークンを使うのでリプレイにはなりませんが、無駄なローテーションです。stale check がこれを塞ぎます。各リクエストが使ったアクセストークンを覚えておき、401 が届いたら現在のトークンと比べます。違っていれば、すでに誰かが更新済みなので、更新せず再試行だけします。

if (state.access !== tokenThatFailed) return;   // 他の誰かがすでに更新した

あと2つルールがあります。再試行は1回だけにします。再試行したリクエストがまた 401 なら、ループせずエラーを返します。また、新しいアクセストークンと新しいリフレッシュトークンは、ひとまとまりとして一緒に保存します。1つの応答に両方が入っているからです。

デモ:疑似サーバーと4つの戦略

スクリプトは2つの部分でできています。前半は、RFC 9700 が説明する挙動(ローテーション、ファミリーを失効させる再利用検知、任意の猶予期間)を持つ疑似サーバーです。後半は、テスト対象のクライアントで、401 への反応が3種類あります。各バーストの3つ目のリクエストはわざと遅くしてあり、最初の更新が終わった後に 401 が届きます。refresh_demo.mjs として保存し、node refresh_demo.mjs で実行してください(Node 18 以降、パッケージ不要です)。

// Refresh-token rotation vs. concurrent 401s.   Run: node refresh_demo.mjs   (Node >= 18, no dependencies)
import http from 'node:http';

// ---- A toy authorization/resource server: rotation + reuse detection (+ optional grace window) ----
function makeServer({ graceMs = 0 } = {}) {
  const s = { seq: 0, access: new Set(), refresh: new Map(), used: new Map(), revoked: new Set(), tokenCalls: 0 };
  const issue = (family) => {
    const n = ++s.seq;
    s.access.add(`a${n}`);
    s.refresh.set(`r${n}`, family);
    return { access: `a${n}`, refresh: `r${n}` };
  };
  s.first = () => { const t = issue('family-1'); s.access.delete(t.access); return t; }; // access token starts out expired
  const srv = http.createServer((req, res) => {
    let body = '';
    req.on('data', (d) => (body += d));
    req.on('end', async () => {
      await new Promise((r) => setTimeout(r, Number(req.headers['x-delay'] ?? 20)));      // network + processing
      if (req.url === '/api') {
        const token = (req.headers.authorization ?? '').replace('Bearer ', '');
        res.statusCode = s.access.has(token) ? 200 : 401;
        return res.end('{}');
      }
      s.tokenCalls++;                                                                       // POST /token
      const { refresh } = JSON.parse(body);
      const family = s.refresh.get(refresh);
      if (family && !s.revoked.has(family)) {                                               // normal rotation
        s.refresh.delete(refresh);
        s.used.set(refresh, { family, at: Date.now() });
        return res.end(JSON.stringify(issue(family)));
      }
      const old = s.used.get(refresh);
      if (old && Date.now() - old.at < graceMs && !s.revoked.has(old.family)) {            // grace window
        return res.end(JSON.stringify(issue(old.family)));
      }
      if (old) s.revoked.add(old.family);                                                   // reuse detected: revoke everything
      res.statusCode = 400;
      res.end('{"error":"invalid_grant"}');
    });
  });
  s.listen = () => new Promise((ok) => srv.listen(0, '127.0.0.1', () => { s.url = `http://127.0.0.1:${srv.address().port}`; ok(); }));
  s.close = () => srv.close();
  return s;
}

// ---- The client under test: three ways to react to a 401 ----
function makeClient(server, strategy, tokens) {
  const state = { ...tokens };      // { access, refresh }
  let inflight = null;              // the one refresh currently running, if any

  async function doRefresh() {
    const r = await fetch(server.url + '/token', { method: 'POST', body: JSON.stringify({ refresh: state.refresh }) });
    if (!r.ok) throw new Error('refresh failed: ' + r.status);
    Object.assign(state, await r.json());          // access AND refresh token are replaced together
  }

  function refresh(tokenThatFailed) {
    if (strategy === 'naive') return doRefresh();                                  // every 401 refreshes
    if (strategy === 'single-flight+stale-check' && state.access !== tokenThatFailed)
      return Promise.resolve();                                                    // someone already renewed it
    return (inflight ??= doRefresh().finally(() => { inflight = null; }));         // join the refresh in progress
  }

  return {
    state,
    async call(delay = 20) {
      const send = (token) => fetch(server.url + '/api', { headers: { authorization: 'Bearer ' + token, 'x-delay': String(delay) } });
      const used = state.access;
      let r = await send(used);
      if (r.status !== 401) return r.status;
      await refresh(used);                         // may throw
      return (await send(state.access)).status;    // retry exactly once
    },
  };
}

async function scenario(title, strategy, opts) {
  const server = makeServer(opts); await server.listen();
  const client = makeClient(server, strategy, server.first());
  // three requests start with the expired token; the third is slow, so its 401 arrives after the refresh finished
  const results = await Promise.allSettled([client.call(), client.call(), client.call(150)]);
  const burst = results.map((r) => (r.status === 'fulfilled' ? r.value : 'ERR')).join(',');
  const tokenCalls = server.tokenCalls;
  server.access.clear();                           // later, the new access token expires too
  const next = await client.call().then(String, (e) => `ERR (${e.message})`);
  console.log(`${title.padEnd(32)} | burst: ${burst.padEnd(11)} | POST /token: ${tokenCalls} | after next expiry: ${next}`);
  server.close();
}

await scenario('naive: every 401 refreshes', 'naive');
await scenario('naive + 2 s server grace window', 'naive', { graceMs: 2000 });
await scenario('single-flight', 'single-flight');
await scenario('single-flight + stale check', 'single-flight+stale-check');

実行例です。

naive: every 401 refreshes       | burst: ERR,200,ERR | POST /token: 3 | after next expiry: ERR (refresh failed: 400)
naive + 2 s server grace window  | burst: 200,200,200 | POST /token: 3 | after next expiry: 200
single-flight                    | burst: 200,200,200 | POST /token: 2 | after next expiry: 200
single-flight + stale check      | burst: 200,200,200 | POST /token: 1 | after next expiry: 200

4行は次のように読みます。

  1. naive:更新リクエストが3つ、リプレイが2つ。ファミリーが失効するので、次の期限切れが 400 で終わります。(別の実行では、成功するリクエストが別のものになりました。)
  2. naive + 猶予期間:更新リクエストは相変わらず3つですが、サーバーが2秒間はリプレイを許すので、全員が成功します。コストはサーバー側にあり、リプレイを許す時間の延長として現れます。
  3. single-flight:最初の2つの 401 が1回の更新を共有します。遅い3つ目の 401 はあとで届き、2回目の更新を始めます。有効ですが不要です。
  4. single-flight + stale check:更新はちょうど1回です。実際に使う形はこれです。

疑似サーバーは僕が作ったもので、実在するプロバイダーのものではありません。示しているのは仕組みであって、特定のベンダーの挙動ではありません。

single-flight が効く範囲

  • サーバーがリフレッシュトークンをローテーションし、アプリが複数のリクエストを同時に飛ばせるなら、single-flight を使います。 ほとんどのアプリが該当します。
  • ローテーションがなければ、上の失敗は起きないので、single-flight は正しさの修正ではなく最適化(呼び出しの削減)です。それでも安上がりです。
  • 複数のプロセスやコンテキストが1つのリフレッシュトークンを共有している場合は、これだけでは足りません。 次の節を見てください。
  • クライアントのバグを隠すためだけに猶予期間を足すのはやめてください。 まずクライアントを直し、クライアント側では直せない失敗(応答の喪失)のために猶予期間を残します。

修正したあとも残る失敗

  • タブ、ワーカー、プロセスが複数ある。 それぞれが独自の inflight を持つので、お互いのトークンをリプレイします。症状: アプリを2つ開いたときや、バックグラウンド処理が走るときだけ、ランダムにログアウトされる。対処: 「トークンを読む、更新する、トークンを書く」を、コンテキストをまたぐロックで囲みます。ブラウザでは、Web Locks API で、同一オリジンの複数のタブやワーカーのスクリプトが協調できます。例:navigator.locks.request('token-refresh', async () => { /* 保存済みトークンを読み直し、まだ古い場合だけ更新する */ })。このスニペットは、ブラウザが必要なため、サンドボックスでは実行していません。それ以外の環境では、トークンの保存先を1つのコンポーネントだけが所有するようにします。
  • 更新の応答が失われる。 サーバーはローテーションしたのに、クライアントが新しいトークンを受け取れなかった場合(タイムアウトやプロセスの強制終了)です。次の更新では古いトークンを提示し、リプレイに見えます。症状: 不安定なネットワークでのログアウト。対処: これはローテーションに内在する問題で、プロバイダーは猶予期間で対処しています。また、新しいトークンは使う前に永続化してください。
  • 更新エラーなら何でもログアウトさせる。 タイムアウトや 5xx は、グラントの失効ではありません。RFC 6749 §5.2 は、リフレッシュトークンが「invalid, expired, revoked」(無効、期限切れ、失効)のときのエラーとして invalid_grant を定義しています。対処: invalid_grant のときだけログアウトし、ネットワークエラーはバックオフ付きで再試行します。
  • 本文を再送できないリクエストを再試行する。 fetch の Request の本文は1回限りで、本文を使用済みだと clone() は例外を投げます。対処: 使用済みのオブジェクトを再利用せず、試行のたびにデータからリクエストを組み立てます。
  • 際限のない再試行。 永続的に拒否されるトークンは、更新のループに入ります。対処: リクエストごとに再試行は1回までです。
  • stale check で比べる値を間違える。 比べるのは、失敗したリクエストが使ったトークンです。コードを読んだ時点で現在のトークンではありません。デモでは、送信前に const used = state.access で控えています。

デモをわざと壊す

  1. node refresh_demo.mjs を何度か実行します。naive の行は毎回失敗し、変わるのは成功するリクエストがどれかだけのはずです。
  2. makeClient の stale check の行を削除すると、single-flight の行の POST /token が1から2に増えることを確認できます。
  3. 2つ目のシナリオの猶予期間({ graceMs: 2000 })を 0 にすると、また失敗します。僕の実行では、1 はときどき通る程度で、5 と 50 は試した3回とも通りました。実際のリプレイはネットワーク遅延で散らばるので、短い窓は見かけほど守ってくれません。窓の長さは、ローカルのテストではなく、吸収したい失敗から決めてください。
  4. 遅い呼び出し client.call(150) を client.call(20) に変えます。3つ目の 401 が更新の完了前に届くので、素の single-flight でも更新は1回で済み(3回の実行で1回でした)、stale check の有無は結果に影響しません。stale check が効くのは、遅れて届く 401 だけです。

実行したことと、していないこと

スクリプトを5回実行して、naive の行は毎回失敗し、single-flight と stale check の行は毎回成功して、更新リクエストはそれぞれ2回と1回でした。疑似サーバーは RFC 9700 §4.14.2 の記述に沿っています。実在のIDプロバイダー、モバイルOS、ブラウザは試していません。Web Locks のスニペットも実行していません。

出荷するときの規則

  • ローテーションは、リフレッシュトークンの再利用を警報にします。並行する 401 は、正規のクライアントにその警報を鳴らさせます。
  • 並行する呼び出し元で1回の更新を共有し(Promise を保存する)、トークンを比べて遅れた更新を省き、再試行は1回にします。
  • サーバーを管理しているなら、猶予期間は応答の喪失に備えた、意図的で範囲が限られた譲歩です。クライアントの並行性の修正ではありません。
  • single-flight はプロセス内の仕組みです。複数のタブやプロセスがあるなら、コンテキストをまたぐロックか、トークン保存先の単一の所有者を足してください。