銀行のような API に、2種類の呼び出しがあるとします。口座の参照は、ログイン済みのユーザーなら足ります。送金は、直近の数分以内に第二要素でサインインしたユーザーでなければなりません。最初のサインインの時点では、API は後者を要求できません。リスクはリクエストごとに変わるからです。だから、送金への最初の応答は「このトークンでは足りない。もう一度認証してほしい」となります。

このメッセージを標準化したのが RFC 9470(OAuth 2.0 Step Up Authentication Challenge Protocol)です。短い文書です。最初から最後まで読み、認可サーバー、リソースサーバー、クライアントの試作を作って、何が決まっていて何が決まっていないかを調べました。

TL;DR

  • チャレンジは WWW-Authenticate: Bearer error="insufficient_user_authentication" で、任意の acr_values と max_age が付きます。RFC の例はすべて HTTP 401 です。insufficient_scope に付く 403 を持ち込まないでください。
  • クライアントはこれらを通常の認可リクエストにします。OpenID Connect が定義済みの acr_values と max_age と同じパラメーターです。ネイティブアプリは、システムブラウザと PKCE で行います(RFC 8252)。
  • 複数のリクエストが同時にチャレンジされた場合や、求められたレベルにユーザーが到達できない場合に、クライアントがどうするかは RFC に書かれていません。試作のクライアントでは、並列の送金3件で、ステップアップを単一フライトにするまでプロンプトが3回出ました。求められたレベルを出せない認可サーバーが相手のときも、プロトコルにはクライアントの再プロンプトを止める仕組みがないので、クライアント側に止める仕組みが必要です。
  • 認可に依存するレスポンスをキャッシュするときは、キーをユーザー単位ではなくトークン(またはそのレベル)にします。ユーザーをキーにしたキャッシュは、ステップアップに成功したあとも古い縮小表示を返しました。
  • 試作のリソースサーバーは、レベルを判定できないとき、リスクの高いルートに 503 を返します。チャレンジは返さず、許可もしません。これは僕の設計上の選択で、RFC が定めているものではありません。

プロトコルの全体像

RFC の順に、細部を見ていきます。

3節:チャレンジ

RFC はエラーコードを1つ追加し、insufficient_user_authentication としています。auth-param は2つです。

  • acr_values:認証コンテキストクラス参照のスペース区切りの一覧で、「優先順」に並びます。保護されたリソースは、そのうちどれか1つを求めます。
  • max_age:最後の能動的な認証イベント(ユーザーが認可サーバーとやりとりしたこと)から許容される経過秒数です。token でも quoted-string でもよいものの、「非負の整数を表す必要がある」とされています。

RFC の例(図2と図3)は、どちらも HTTP/1.1 401 Unauthorized です。RFC 6750 は insufficient_scope に 403 を組み合わせていて、それをそのまま持ち込みやすいところです。このプロトコルで問題になっているのは、トークンが何を許すかではなく、ユーザーがどうログインしたかです。応答は再認証の要求なので、401 が合います。クライアントのライブラリが 401 でしか再試行しないなら、ステータスは重要です。

値が引用符つきでも、なしでも来るので、パーサーが最初にバグの入りやすい場所です。僕のパーサーは両方を読みます。

export function parseBearerChallenge(header) {
  if (!/^Bearer\s/i.test(header ?? '')) return null;
  const params = {};
  for (const m of header.slice(7).matchAll(/([a-z_]+)=(?:"((?:[^"\\]|\\.)*)"|([^\s,]+))/gi))
    params[m[1].toLowerCase()] = m[2] ?? m[3];
  return params;
}

max_age="5" と max_age=5 を同じに読み、RFC の図2と図3の形でテストしました。1つのヘッダーに対する正規表現なので、本番のクライアントでは、複数のチャレンジを持てる WWW-Authenticate の本物のパーサーを使ってください。

4節と5節:認可リクエストとその応答

クライアントは、WWW-Authenticate から acr_values と max_age を読み取って新しい認可リクエストに使う(SHOULD)とされています。この2つは OpenID Connect の既存のリクエストパラメーターです(OIDC Core 3.1.2.1)。max_age は、最後の認証がそれより古ければ能動的な再認証を強制し、サーバーは auth_time を返す必要があります。ネイティブアプリには RFC 8252 の要件が2つあります。公開クライアントは PKCE を使うこと(6節)、リクエストは埋め込みの Web ビューではなく外部のユーザーエージェントで行うこと(8.12節)です。

5節は、実務で問題になる状況を扱っています。認可サーバーが求められたレベルを満たせないことがあります。たとえば第二要素が登録されていないユーザーです。OIDC では、セッションが実際に持つレベルを返してよいことになっています。アクセストークンについては、この RFC は代わりに unmet_authentication_requirements で失敗させる(SHOULD)としています。そうしないと、「認可サーバーが、リソースサーバーがすでに要件を満たさないと判断したトークンを返し続ける、ループにクライアントが陥る」からです。

この文は、正常系だけで書いたときに入るバグをそのまま描いています。OIDC の既定の動作に従うサーバーは、元のレベルのトークンを返し、リソースサーバーが再びチャレンジし、クライアントがユーザーにもう一度プロンプトを出します。RFC はそれをサーバー側の問題としていますが、クライアントはそれに頼らない作りにする必要があります。

RFC がクライアントに残していること

RFC では決められない判断を、クライアントに3つ持たせました。

複数のチャレンジに対して、プロンプトは1回。 画面は複数のリクエストを同時に出すことがよくあります。送金3件が同時にチャレンジされ、それぞれがログインを始めると、ユーザーにはプロンプトが3回出ます。共有の Promise にして、最初のチャレンジがステップアップを始め、残りはそれを待ってから、最初のものが得たトークンで再試行します。

ループ防止。 1回ステップアップしたあとの再試行が、再びチャレンジされることがあります。たとえば認可サーバーが mfa ではなく pwd に到達した場合です。クライアントはリクエストごとにステップアップの回数を数え、1回で諦めて、呼び出し元に説明を任せる形で 401 を返します。

トークンは送信時に読む。 ステップアップが終わる前にキューに入ったリクエストは、古いトークンで出してはいけません。クライアントは、送る瞬間に current を取ります。

export function makeClient({ rs, as, token, maxStepUps = 1 }) {
  let current = token, stepUp = null;
  const doStepUp = async (params) => {
    stepUp ??= (async () => {                                   // 単一フライト
      const code_verifier = randomBytes(24).toString('base64url');
      const code_challenge = createHash('sha256').update(code_verifier).digest('base64url');
      const code = as.authorize({ acr_values: params.acr_values, max_age: params.max_age, code_challenge });
      current = as.token({ code, code_verifier }).access_token;
    })().finally(() => { stepUp = null; });
    return stepUp;
  };
  return {
    async call(method, path) {
      for (let attempt = 0; ; attempt++) {
        const bearerUsed = current;                              // 送信時に読む
        const res = rs(method, path, bearerUsed);
        const ch = res.status === 401 ? parseBearerChallenge(res.headers['www-authenticate']) : null;
        if (ch?.error !== 'insufficient_user_authentication') return res;
        if (attempt >= maxStepUps) return { ...res, gaveUp: true };   // ループ防止
        if (current === bearerUsed) await doStepUp(ch); else if (stepUp) await stepUp;
      }
    },
  };
}

実際のアプリでは、as.authorize はシステムブラウザを経由する往復で、await にはユーザーが操作する時間も含まれます。RFC の2節は、クライアントがアクセストークンを不透明なものとして扱い、トークンからレベルを読み取ってはならないとも述べています。ここでクライアントがレベルを知るのは、チャレンジを通じてだけです。同じ節は、新しいトークンが古いものを置き換えるとは限らず、クライアントが両方を持って呼び出しごとに選んでもよいとも述べています。試作のクライアントは current を1つしか持たない簡略版です。

6節:リソースサーバーがレベルを知る方法

リソースサーバーは、トークンの acr と auth_time を必要とします。JWT のアクセストークンならクレームです(RFC 9068)。不透明なトークンなら、イントロスペクションで返ってきます。試作のサーバーはそれらを表に持ち、ルートごとにポリシーを適用します。

const policy = {
  'GET /account':   { acr: ['pwd', 'mfa'] },
  'POST /transfer': { acr: ['mfa'], maxAgeSec: 300, onUnknown: 'closed' },
};

maxAgeSec は auth_time と比べます。ステップアップのあとでも、max_age により同じトークンは5分後に再び古くなり、次の送金は改めてチャレンジされます。これは時計を差し込んで確かめました。

onUnknown が、失敗時の選択です。イントロスペクションが止まっているとき、試作のサーバーはリスクの高いルートに 503 と Retry-After を返し、チャレンジは返しません。チャレンジを返すと、成功しないログインにユーザーを通すことになります。許可してしまうと、システムの調子が悪いまさにその時に安全装置が消えます。リスクの低いルートは基本の表示を返します。どちら側に倒すかはあなたの決めることです。大事なのは、それが決定になっていることです。

8つのテストを動かす

mkdir stepup && cd stepup
# 実験の stepup.mjs と stepup.test.mjs をここに置く
node --test stepup.test.mjs

8つのテストが対象にしているのは次のとおりです。RFC の例のパース、プロンプトなしの低リスクの呼び出し、チャレンジから1回のプロンプトと再試行の成功、並列3件でのプロンプト1回、mfa を出せない認可サーバー(1回のプロンプトのあと gaveUp)、max_age の失効、下記のキャッシュの件、フェイルクローズのルートです。共有の Promise が要る理由を見るには、stepUp ??= を stepUp = に変えて再実行してください。並列のテストが、プロンプト1回ではなく3回と数えます。

ステップアップが壊れる場面

  • チャレンジを insufficient_scope と同じに扱うこと。 症状: 403 を探しているため、ユーザーに追加の同意を求めたり、再試行しなかったりします。対処: 401 の WWW-Authenticate の error で分岐し、スコープの経路は別にします。
  • 並列のプロンプト。 症状: ユーザーが何度も続けて認証を求められ、新しいトークンの一部が捨てられます。対処: ステップアップを単一フライトにして、トークンは送信時に読みます。
  • 終わらないループ。 症状: 成功するたびにログイン画面が再び出ます。原因は、トークンのレベルがまだ低いことで、認可サーバーがセッションの現在のレベルを返すと起きます。対処: リクエストごとにステップアップの回数を制限し、失敗を呼び出し元に伝え、サーバーには unmet_authentication_requirements を返させます。
  • ユーザー単位のキャッシュ。 症状: ステップアップに成功しても、画面が縮小表示のままです。僕のテストでは、ユーザーをキーにしたレスポンスのキャッシュは、ユーザーが mfa のトークンを持った後も basic を返し、トークンをキーにすると full を返しました。対処: 認可に依存するレスポンスをキャッシュするものには、トークン(またはそのレベル)をキーに含めます。
  • どこへでもチャレンジに従うこと。 RFC は、悪意あるリソースサーバーが、ユーザーとのやりとりを起動する機能を悪用しうると指摘しています。対処: クライアントは、設定済みの認可サーバーに対してだけ、すでに呼び出しているリソースサーバーについてだけステップアップします。チャレンジから取り出した URL へは行きません。
  • acr_values から情報が漏れること。 RFC は、値によって、どのユーザーやリソースに高い保証レベルが必要かが分かってしまうと警告しています。対処: 中立的な値を選び、トークンを検証したあとにだけチャレンジを返すことも検討します。

ステップアップが合う場面と、合わない場面

必要な保証レベルがリクエストによって変わるときに使います。支払い、認証情報の変更、データの書き出しなどです。通常の呼び出しは軽いまま、リスクの高い操作だけが追加の認証を求めます。

すべての呼び出しで同じレベルが必要なら、サインイン時にそのレベルを求め、このプロトコルは使いません。リソースサーバーがユーザーの認証方法を見られない場合(イントロスペクションのない不透明なトークンや、それを記録しない認可サーバー)は、チャレンジの判断材料がありません。RFC 自身も、ステップアップの体験はリソースサーバーと認可サーバーが合意するポリシーに左右され、ユーザーには満たせない要件も作れてしまうと述べています(8節)。

動かしたことと、読んだだけのこと

確認したことです。実験を Node.js 22.23.3 で動かしました(node --test、8つのテスト成功)。RFC の例の形に対するパーサー、並列3件でのプロンプト1回(共有の Promise を外すと3回になること)、ループ防止、時計を差し込んだ max_age の失効、キャッシュキーの影響、フェイルクローズのルートです。RFC の本文では、401 の例、acr_values と max_age の定義、5節の動作、2節の不透明なトークンの注記、9節の考慮事項を読みました。

確認していないことは、本物の認可サーバー上での動作すべてです。試作のサーバーはプロセス内で動き、ネットワーク、本物のブラウザの往復、リフレッシュトークン、JWT の検証はありません。実在の製品が acr の値をどう名付け、max_age をどう扱い、unmet_authentication_requirements をどう返すかは調べていません。スマートフォンでも動かしていません。システムブラウザの手順は、関数呼び出しで表しただけです。

RFC から持ち帰るもの

RFC は、「足りない、もう一度ログインして」を伝える標準的な方法を与えてくれます。その周りで必要になる仕事は標準にありません。プロンプトを1つにまとめ、再試行を数え、キャッシュのキーをトークンにすることです。この3つを正常系に足せば、401 は罠でなくなります。