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 のライブラリも動かしていません。委譲の連鎖には正しい設計。ここの問題には変更が大きすぎる。
僕が選ぶ設計
テストでうまくいったものを組み合わせます。
- 発行者で絞ります。
grant()を使います。トークンのscopeは、クライアントの種類の上限を超えることがありません。 - すべての検証側がすでに強制しているクレームを絞って制限を表します。 「読み取り専用」が必要なら、
scopeをnotes:readにしたトークンを発行します。古い検証側は、理由を知らなくても強制します。3つの検証側がどれも{notes:read}を返すことを確かめました。 - 種類を読む検証側のために、クレームで種類を持たせ、その検証側は拒否側に倒します。 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))); // 念のための再適用
}
- 明示的な型付けを使います。 検証側は
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つの関数で、時間経過に伴うバージョンを真似たものです。実際のデプロイではありません。
次のクレームを出す前に
理解できないものを無視する検証側は、新しいクレームが言いうる最悪のことの分しか安全ではありません。クレームが加算するなら、無視しても何も失いません。減算するなら、全員がすでに強制している値に効果を入れ、欠けている場合の扱いは、それが起きる前に決めておきます。