Files
codex/codex-rs/rmcp-client/src/user_verification.rs
T
riley-oai 3cd6004dc4 Add capability-gated MCP user-verification handling (#43289)
## What changed

- Handle `openai/userVerification` requests through `openai/elicitation/create` when the client advertises `userVerification` support.
- Validate request fields, size limits, and base64url encoding. Require a correctly shaped, bounded proof for acceptance; cancel invalid acceptance responses and discard proof material on decline or cancellation.
- Handle request and service cancellation, including cancellation received before request dispatch. Bound early-cancellation storage and cancel new verifications when capacity is exceeded.
- Keep verification inactive in Codex: cancel requests without prompting until the typed app-server and UI path is available.

## Testing

Add tests for request and proof validation, malformed modes, cancellation races and capacity limits, timeout-pause cleanup, and independent concurrent requests. Add integration coverage showing that verification does not prompt or prevent tool and turn completion before activation.

GitOrigin-RevId: 6313c1be40fae0b33cc3ce0f8fe025b4124ed2ef
2026-09-06 21:41:16 +00:00

92 lines
2.9 KiB
Rust

//! Validation for the user-verification elicitation extension.
use base64::Engine as _;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use rmcp::model::CustomRequest;
use rmcp::model::ElicitationAction;
use serde::Deserialize;
use crate::rmcp_client::Elicitation;
use crate::rmcp_client::ElicitationResponse;
pub(crate) const MODE: &str = "openai/userVerification";
#[derive(Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct RequestParams {
mode: String,
title: String,
description: String,
challenge: String,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct Proof<'a> {
credential_id: &'a str,
signature: &'a str,
}
pub(crate) fn parse_request(request: CustomRequest) -> Result<Elicitation, rmcp::ErrorData> {
let params = request
.params_as::<RequestParams>()
.ok()
.flatten()
.ok_or_else(invalid_request)?;
if params.mode != MODE
|| params.title.is_empty()
|| params.title.len() > 256
|| params.description.len() > 4096
|| !valid_bytes(&params.challenge, /*max_decoded_bytes*/ 4096)
{
return Err(invalid_request());
}
Ok(Elicitation::UserVerification {
title: params.title,
description: params.description,
challenge: params.challenge,
})
}
/// Accept only a bounded proof, and never return proof material for cancellation or rejection.
pub(crate) fn validate_response(mut response: ElicitationResponse) -> ElicitationResponse {
response.meta = None;
match response.action {
ElicitationAction::Accept => {
let proof = response
.content
.as_ref()
.and_then(|content| Proof::deserialize(content).ok());
if proof.is_some_and(|proof| {
!proof.credential_id.is_empty()
&& proof.credential_id.len() <= 1024
&& valid_bytes(proof.signature, /*max_decoded_bytes*/ 128)
}) {
return response;
}
tracing::warn!("user-verification acceptance omitted a valid proof; cancelling");
response.action = ElicitationAction::Cancel;
}
ElicitationAction::Decline | ElicitationAction::Cancel => {}
_ => response.action = ElicitationAction::Cancel,
}
response.content = None;
response
}
fn valid_bytes(encoded: &str, max_decoded_bytes: usize) -> bool {
!encoded.is_empty()
&& encoded.len() <= max_decoded_bytes.div_ceil(3) * 4
&& URL_SAFE_NO_PAD
.decode(encoded)
.is_ok_and(|bytes| !bytes.is_empty() && bytes.len() <= max_decoded_bytes)
}
fn invalid_request() -> rmcp::ErrorData {
rmcp::ErrorData::invalid_params("invalid user-verification request", /*data*/ None)
}
#[cfg(test)]
#[path = "user_verification_tests.rs"]
mod tests;