API が、複数の種類のクライアントにアクセストークンを発行するとします。対話型のアプリ、無人のバッチ、キオスク端末です。全部が同じことをできてはいけません。バッチは削除できてはならず、キオスクは読み取りだけです。この制限はどこで強制すべきでしょうか。あとから、何も壊さずに厳しくするにはどうすればよいでしょうか。

4つの設計を、小さな発行者と3つのバージョンの検証側に対して試しました。もっとも自然に見える設計には、JWT の標準そのものが生む穴がありました。

TL;DR

  • RFC 7519 の4節は、検証側が理解できないクレーム名は「無視しなければならない(MUST be ignored)」と定めています(他の規則がない場合)。拡張性のためには正しい既定です。同時に、新しい制限のクレームは、古い検証側からは見えません。
  • 実験では、scope: "notes:read notes:write" に新しいクレーム ro: true を付けたトークンは、ro を知っている検証側では読み取り専用、知らない検証側では読み書き可能でした。失敗は何も起きません。
  • 安全な規則は、無視されたクレームが与えるのはより少ない権限だけ、というものです。制限は、すべての検証側がすでに強制している値(ここでは scope)を発行者側で絞って表現し、新しいクレームは、なくても無害なものに使います。
  • 発行者で絞ります。scope = requested ∩ ceiling[kind] です。クライアントが求めた内容を信用せず、ボタンを隠すだけの UI にも頼りません。
  • クライアントの種類を読む検証側では、欠けている場合と未知の場合は拒否側に倒します。kind のないトークンは最も低い上限、未知の kind は拒否します。
  • crit ヘッダーは「理解できなければ拒否」の仕組みを与えますが、対象は JOSE のヘッダーのパラメーターだけで、ペイロードのクレームには使えません。

前提

発行者は各クライアントの種類を知っていて、上限の表を持っています。

const CEILING = {
  interactive: ['notes:read', 'notes:write', 'notes:delete'],
  kiosk:       ['notes:read'],
  batch:       ['notes:read', 'notes:write'],
};

トークンは HS256 で署名した JWT(実験専用のデモ鍵)で、typ: at+jwt です。「検証側」は、時間とともに変わるリソースサーバーのバージョンを表す3つの小さな関数です。ライブラリは jose の 6.2.12 で、以下はすべて Node.js 22.23.3 で動かしました。

案1:クライアントが求めたものを信用する

クライアントが欲しいスコープを送り、発行者がそのまま署名します。バッチのクライアントが notes:delete を求めた時点で破綻します。それを妨げるものは、プロトコルのどこにもありません。

修正は最終設計の第一の規則で、コードは1行です。

export function grant(requested, kind) {
  const ceiling = CEILING[kind];
  if (!ceiling) throw new Error(`unknown client kind: ${kind}`);
  return requested.filter((s) => ceiling.includes(s));
}

これがあれば、3つのスコープを求めた batch のクライアントには2つが渡され、未知の種類は既定値ではなくエラーになります。単独では不採用。基礎の層として残す。

案2:制限を表す新しいクレームを足す

あとから、一部のトークンを読み取り専用にしたくなったとします。きれいな方法は新しいクレーム ro: true です。それを理解する検証側は、読み取り以外のスコープをすべて落とします。

// v2: 新しいクレームを知っている
if (payload.ro === true) scopes = new Set([...scopes].filter((s) => s.endsWith(':read')));

問題は、それを知らない検証側です。リソースサーバーのバージョン1は、標準に従って scope だけを読み、ほかは読みません。両方のスコープと ro: true を持つトークンを発行して、2つの検証側で確かめました。

v2(ro を知っている):   {notes:read}
v1(ro を無視する):     {notes:read, notes:write}

何も壊れず、何も記録されず、「読み取り専用」のトークンが書き込みできました。リソースサーバーの更新が発行者より遅れる構成や、同じトークンを別のライブラリ、別のサービスが検証する構成では、この窓が開きます。不採用。失敗したときに開く側に倒れる。

案3:crit で古い検証側に拒否させる

JOSE には、まさにこの種の変更のための仕組みがあります。crit ヘッダーパラメーター(RFC 7515 の4.1.11節)は、受信側が理解しなければならない拡張を列挙します。理解できなければ、トークンを拒否しなければなりません。crit に ceil ヘッダーパラメーターを挙げたトークンに署名して試しました。

古い検証側:  Extension Header Parameter "ceil" is not recognized     (拒否)
対応した検証側。{crit: {ceil: true}} を指定:  受理

望んだ動作で、双方で1行を足すだけです。ただし、2つの制約があるため、部分的な答えにとどまります。

  • 対象はヘッダーのパラメーターです。crit はクレームには適用できないので、ペイロードのクレームに入れた制限を critical と印付けることはできません。実験でも、制限をヘッダーに置く必要がありました。
  • 双方が crit を正しく実装したライブラリを使う必要があります。jose は実装しています。ほかのライブラリは調べていません。検査を省くライブラリを使うと、案2に戻ります。

まれで、無視されては困る変更には有用。ただし、既定にはしない。

案4:検証側にすべての制限を満たさせる

Macaroons(Birgisson ら、NDSS 2014)の設計は、既定を逆にします。トークンは caveat と呼ぶ条件の一覧を持ち、検証では、リクエストの文脈ですべての caveat が成り立つ必要があります。評価できない caveat があれば、検証は失敗します。これなら、いつでも安全に制限を足せます。一方で、トークン形式、ライブラリ、トークンの考え方がすべて変わるので、作らず、macaroons のライブラリも動かしていません。委譲の連鎖には正しい設計。ここの問題には変更が大きすぎる。

僕が選ぶ設計

テストでうまくいったものを組み合わせます。

  1. 発行者で絞ります。 grant() を使います。トークンの scope は、クライアントの種類の上限を超えることがありません。
  2. すべての検証側がすでに強制しているクレームを絞って制限を表します。 「読み取り専用」が必要なら、scope を notes:read にしたトークンを発行します。古い検証側は、理由を知らなくても強制します。3つの検証側がどれも {notes:read} を返すことを確かめました。
  3. 種類を読む検証側のために、クレームで種類を持たせ、その検証側は拒否側に倒します。 strict の検証側は、kind のないトークンを最も制限された種類として扱い、未知の種類を拒否し、読んだスコープに上限を再適用します。
export async function verifyStrict(jwt) {
  const { payload } = await jwtVerify(jwt, key, { algorithms: ['HS256'], typ: 'at+jwt' });
  const kind = payload.kind ?? MOST_RESTRICTED;                      // kind のない過去のトークン
  if (!CEILING[kind]) throw new Error(`unknown kind: ${kind}`);       // 推測しない
  const scopes = String(payload.scope ?? '').split(' ').filter(Boolean);
  return new Set(scopes.filter((s) => CEILING[kind].includes(s)));   // 念のための再適用
}
  1. 明示的な型付けを使います。 検証側は typ: at+jwt(RFC 9068 がアクセストークンの JWT に使う値)を要求するので、同じ鍵で署名された別の目的のトークンは拒否されます。RFC 8725 の3.11節です。別の typ のトークンが失敗することを確かめました。

最後のテストが、規則をもっとも端的に示します。トークンに、未知の権限を与えるクレームを足しました。V1 はそれを無視し、結果はそのクレームがない場合とまったく同じスコープでした。加算するクレームを無視しても無害です。減算するクレームを無視すると無害ではありません。

発行者の上限で足りる場面と、足りない場面

複数の種類のクライアントが1つの認可サーバーを共有し、持てる範囲が違うとき、または制限をあとから厳しくしたいときは、発行者側の上限を使います。安く済み、強制する場所は1つの関数です。

トークンの保持者が、自分のトークンを受け渡す前に自分で絞る(リクエストごとの権限の縮小)ことは、解決しません。それは macaroon 型のトークンの役目です。データ自体の検査の代わりにもなりません。スコープが示すのは許される操作の種類で、どのレコードかではありません。

上限のテストを動かす

mkdir ceiling && cd ceiling && npm init -y >/dev/null && npm i [email protected]
# 実験の issuer.mjs と issuer.test.mjs をここに置く
node --test issuer.test.mjs

7つのテストは次のとおりです。ro の穴、scope を絞ること、欲張りなクライアントの制限、crit による拒否とヘッダー限定の制約、明示的な型付け、過去のトークンと未知の種類、無害な未知の権限付与クレームです。穴を直接体験するには、verifyV2 から ro の判定を消して、2つの検証側が読み書きで一致するのを見てください。

上限の仕組みが壊れる場面

  • 制限を新しいクレームで表すこと。 症状: 更新されていないサービスでは、制限したはずのトークンで書き込めて、エラーも出ません。対処: 代わりに scope を絞り、新しいクレームはなくても安全なものとして扱います。
  • クライアント側だけで絞ること。 症状: 改造したクライアントや直接の呼び出しが、より多くを求めて得てしまいます。対処: リフレッシュも含め、発行のたびに発行者で上限と交差させます。
  • 新しいクレームのない古いトークンを、最も強い権限として扱うこと。 症状: kind を導入したあと、導入前に発行されたトークンがもっとも強力になります。対処: 欠けている場合は最も低い上限に対応させ、トークンは短命にして自然に消えるようにします。
  • 未知の種類に既定値を当てること。 症状: 打ち間違いや新しいクライアントの種類が、黙って対話型の権限を受け取ります。対処: 発行者と検証側の両方で、未知の種類は例外にします。
  • crit がペイロードのクレームも守ると思い込むこと。 症状: あるクレームを「critical」にしたつもりで、古い検証側が拒否してくれると信じてしまいます。対処: crit はヘッダーのパラメーターにだけ使い、古い検証側で必ず試します。
  • 鍵で署名された JWT なら何でも受け入れること。 症状: ID トークンや別のサービスのトークンが、アクセストークンとして受け入れられます。対処: 期待する typ を要求し、audience と issuer も検証します。実験で見せているのは typ だけです。

テストが覆う範囲と、残る範囲

確認したことです。Node.js 22.23.3 と jose 6.2.12 で、上の7つの動作すべてを確認しました(node --test、7つのテスト成功)。認識できない critical ヘッダーパラメーターに対する jose の正確なエラーも含みます。RFC 7519 の4節と7.2節、RFC 7515 の4.1.11節、RFC 8725 の3.11節を RFC の本文で読みました。macaroons については論文の検証手順を読みました。

確認していないことです。ほかの JWT ライブラリ(特に crit と未知のクレームの扱い)、非対称鍵のアルゴリズム(実験では簡単のため共有の HS256 鍵を使っています)、トークンの失効、本物のリソースサーバーでの動作です。「古い検証側」と「新しい検証側」は、1つのファイルにある3つの関数で、時間経過に伴うバージョンを真似たものです。実際のデプロイではありません。

次のクレームを出す前に

理解できないものを無視する検証側は、新しいクレームが言いうる最悪のことの分しか安全ではありません。クレームが加算するなら、無視しても何も失いません。減算するなら、全員がすでに強制している値に効果を入れ、欠けている場合の扱いは、それが起きる前に決めておきます。