ステップアップ認証を実装してみた。エージェントは自力で昇格できない

RFC 9470 のステップアップ認証を Keycloak で動かし、AI エージェント(MCP)からの危険操作が人間の再認証なしには通らないことを実測した。human-in-the-loop はアプリの機能として作るものではなく、認証サーバの設定から出てくる性質だった。監査にはclient_idでなくazpを使う話も。

はじめに

AI エージェントに社内の管理 API を触らせたい。ただし「アカウント削除」のような取り返しのつかない操作だけは、人間が確認してからにしたい。いわゆる human-in-the-loop(人間を判断の輪に入れる)の要件です。

これを「エージェントに確認ダイアログを出させる」「プロンプトで禁止する」で実装すると、エージェント側の実装やモデルの振る舞いに安全性を預けることになります。エージェントが確認を飛ばせば、それで終わりです。

この記事では、同じ要件を 認証の強さ(AAL) で作りました。危険操作はパスワードに加えて TOTP(ワンタイムパスワード)まで済ませたトークンでしか通らないようにし、エージェントはそのトークンを自分では手に入れられない、という構成です。検証リポジトリで実際に動かした結果を書きます。

先に結論を書くと、human-in-the-loop はアプリに実装した機能ではなく、認証サーバの設定から出てくる性質になりました。アプリ側に書いたのは「この操作には AAL2 が要る」という1行だけです。

前提: AAL とステップアップ認証

AAL(Authenticator Assurance Level) は「どれだけ確かにその人か」の段階です。NIST SP 800-63B の定義で、ここでは2段階だけ使います。

ステップアップ認証は、普段は AAL1 で使わせ、危険な操作の直前にだけ AAL2 を要求する仕組みです。要求の出し方は RFC 9470(OAuth 2.0 Step-Up Authentication Challenge Protocol) が決めていて、API は次の応答を返します。

HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer error="insufficient_user_authentication", acr_values="aal2"

「トークンは有効だが、認証の強さが足りない。aal2 で取り直してこい」という意味です。クライアントはこの acr_values を付けて認可をやり直し、認証サーバは不足分(TOTP)だけを追加で求めます。AAL の概念そのものはログイン強度の記事で整理したので、ここでは実装と実測に絞ります。

構成

登場するのは4つです。

パスワード + TOTP

ツール呼び出し

Bearer トークン

acr=aal1 / aal2 のトークン

401 + step-up チャレンジ

人間

(ブラウザ)

Keycloak

(認証サーバ)

AI エージェント

(MCP クライアント)

MCP サーバ

delete_account ツール

管理 API

(アカウント削除は AAL2 必須)

実装1: 認証サーバに AAL の段階を作る

Keycloak には「どの段階ならどの認証手段を要求するか」を条件分岐で書ける認証フローがあります。設定は JSON で git 管理しています(realm-as-code)。

{
  "attributes": { "acr.loa.map": "{\"aal1\":1,\"aal2\":2}" },
  "browserFlow": "browser-loa"
}

acr.loa.map は「クライアントが acr_values=aal2 と言ってきたら段階 2」という対応表です。フローは次の形にしました。

段階要求する認証補足
1パスワード通常のログイン
2パスワード + TOTPloa-max-age = 0(段階 2 は毎回 TOTP を求める)

loa-max-age = 0 にしたのは、「一度 TOTP を通したら当分 AAL2 のまま」を避けるためです。危険操作のたびに人間の確認を挟みたいので、毎回求めます。

実装2: アプリは1行書くだけ

削除ハンドラの中身はこうです。

func (h *handlers) adminDeleteAccount(ctx context.Context, in *Input) (*Output, error) {
	if err := h.deps.Assurance.RequireAAL(ctx, authz.AAL2); err != nil {
		return nil, err // 401 + WWW-Authenticate(RFC 9470)になる
	}
	// ... 権限確認と削除
}

RequireAAL はトークンの acr を見て、足りなければ RFC 9470 の 401 を返すだけです。エージェント向けの特別扱いは一切ありません。

実装3: MCP サーバはチャレンジをそのまま返す

MCP サーバのツールは、管理 API が 401 を返したらそのチャレンジを結果としてエージェントに返します。

case http.StatusUnauthorized:
	return toolText(true, "この操作には人間の再認証(step-up)が必要です。"+
		"エージェントは自力で昇格できません。\n"+
		"WWW-Authenticate: "+resp.Header.Get("WWW-Authenticate"))

MCP のツール結果には「エラーかどうか」のフラグ(isError)があり、ここを true にすると、エージェントはツールが失敗したと認識します。ここで人間に引き継ぐ以外の道はありません。

実測

使ったスクリプトはリポジトリにあり、ブラウザのログインフォーム送信を curl で再現しています。TOTP の値は RFC 6238 どおりに計算して入力しています。

ステップアップ認証そのもの

手順結果
パスワードだけでログインacr=aal1 のトークン
そのトークンで削除 API401 + error="insufficient_user_authentication", acr_values="aal2"
acr_values=aal2 で認可をやり直すTOTP だけを追加で求められ、acr=aal2 のトークン(パスワードの再入力なし)
新しいトークンで削除 API200
古い AAL1 トークンで再度401 のまま

最後の行が大事で、TOTP を一度通しても、AAL1 のトークン自体が昇格するわけではありません。昇格した状態は新しいトークンにしか宿りません。

エージェント経由

手順結果
エージェントが AAL1 トークンで delete_account を呼ぶツールは isError で止まり、チャレンジを返す
エージェントが自分で AAL2 を取ろうとする(パスワードグラントに acr_values=aal2)トークンは出るが acr=aal1 のまま
人間が TOTP で AAL2 トークンを取得し、同じツールを呼ぶ成功

2行目が human-in-the-loop の要です。エージェントは TOTP のシークレットを持たず、認証サーバは非対話のグラントで AAL2 を出しません。エージェントがどう振る舞おうと、危険操作は人間の TOTP 入力を経由しないと通りません。安全性がエージェントの実装やモデルの判断に依存していないのが、この構成の価値です。

監査には client_id でなく azp を使う

「誰が危険操作を試みたか」を記録するため、削除ハンドラに監査ログを足しました。試行(attempt)・拒否(challenged)・完了(completed)の3種類で、AAL の判定より前に attempt を出します。止められた試行こそ記録に残したいからです。

ここで1つ罠がありました。最初は「どのアプリが叩いたか」をトークンの client_id クレームで取ろうとしましたが、エージェントのトークンには client_id が入っていませんでした。

トークンの取り方client_id クレームazp クレーム
client_credentials(サービス間通信)ありあり
人間のログイン経由(認可コード / パスワード)なしあり(agent)

Keycloak では client_id クレームはサービスアカウントのトークンにしか付きません。azp(authorized party = トークンを取得したクライアント) はどの取り方でも付くので、監査のクライアント識別子は azp で取るのが正解でした。監査ログはこうなります。

{"msg":"audit","audit.event":"admin.account.delete.challenged",
 "actor.subject":"b54e...","actor.client_id":"agent","actor.aal":1,"target":"probe"}

「人間の alice のアカウントで、agent というアプリが、AAL1 のまま削除を試みて止められた」が1行で分かります。

検証中に踏んだ罠

どちらも認証なしのリクエストでは見えない種類でした。

1. TOTP を設定ファイルに書くと、パスワードまで壊れる。 検証用ユーザーの TOTP を realm の JSON に直接書いたところ、TOTP ではなくパスワードのログインが invalid_user_credentials で失敗するようになりました。エラーがパスワード側に出るので原因に気づきにくいです。設定ファイルに TOTP を書くのをやめ、初回のステップアップ時に Keycloak が出す TOTP 登録画面からシークレットを読み取って、その場で登録する形にしました。登録手順まで含めて検証できるので、実際の利用に近くなりました。

2. パスの不一致がログインの 401 に隠れる。 削除 API は /accounts/{subject}:delete という形で、{subject} にコロンを含む値(user:probe)を渡すとルーティングが一致せず 404 になります。ところがトークンなしで叩くと、認証のミドルウェアがルーティングより先に 401 を返すため、404 が見えません。手元で「401 が返るからパスは合っている」と判断し、トークン付きで初めて 404 に気づきました。手前の層の応答が奥の層の問題を隠す形で、fail open の記事で分類したものと同じ構造です。

まとめ

← Back to all posts