Add experimental user verification API contracts (#43265)

## What changed

Add `userVerification/status`, `userVerification/enroll`, `userVerification/delete`, and `userVerification/verify` behind the `experimentalApi` capability. Define request, response, proof, and typed error contracts, export their schemas, and document the API.

All four methods currently return `unavailable` with reason `providerUnavailable`; native verification operations are not implemented by this change.

## Testing

Add tests for experimental opt-in, typed provider unavailability, readiness serialization, and rejection of mismatched error reasons and native diagnostic payloads.

GitOrigin-RevId: a858ab41523c5acec7dbf3cfa7fcab693ac44a8c
This commit is contained in:
riley-oai authored and copyberry committed 2026-09-06 18:39:32 +00:00
1 parent 7bab4526f9
commit ad931a45b2
12 files changed
+368

No files matched your search

@@ -55,6 +55,13 @@ const EXPERIMENTAL_CLIENT_METHOD_DEPENDENCY_TYPES: &[&str] = &[
"ThreadSearchOccurrence", "ThreadSearchOccurrence",
"ThreadSearchTextRange", "ThreadSearchTextRange",
"TurnSettingsUpdateStatus", "TurnSettingsUpdateStatus",
"UserVerificationProof",
"UserVerificationCancellationReason",
"UserVerificationErrorDetails",
"UserVerificationFailureReason",
"UserVerificationInvalidRequestReason",
"UserVerificationRpcError",
"UserVerificationUnavailableReason",
]; ];
const SPECIAL_DEFINITIONS: &[&str] = &[ const SPECIAL_DEFINITIONS: &[&str] = &[
"ClientNotification", "ClientNotification",
@@ -123,6 +130,7 @@ pub fn generate_ts_with_options(
ClientRequest::export_all_to(out_dir)?; ClientRequest::export_all_to(out_dir)?;
export_client_responses(out_dir)?; export_client_responses(out_dir)?;
ClientNotification::export_all_to(out_dir)?; ClientNotification::export_all_to(out_dir)?;
crate::UserVerificationRpcError::export_all_to(out_dir)?;
ServerRequest::export_all_to(out_dir)?; ServerRequest::export_all_to(out_dir)?;
export_server_responses(out_dir)?; export_server_responses(out_dir)?;
@@ -220,6 +228,10 @@ pub fn generate_json_with_experimental(out_dir: &Path, experimental_api: bool) -
schemas.push(emit(out_dir)?); schemas.push(emit(out_dir)?);
} }
schemas.push(write_json_schema::<crate::UserVerificationRpcError>(
out_dir,
"v2::UserVerificationRpcError",
)?);
schemas.extend(export_client_param_schemas(out_dir)?); schemas.extend(export_client_param_schemas(out_dir)?);
schemas.extend(export_client_response_schemas(out_dir)?); schemas.extend(export_client_response_schemas(out_dir)?);
schemas.extend(export_server_param_schemas(out_dir)?); schemas.extend(export_server_param_schemas(out_dir)?);
@@ -518,6 +518,34 @@ client_request_definitions! {
response: v2::ServerDiagnosticsResponse, response: v2::ServerDiagnosticsResponse,
}, },
#[experimental("userVerification/status")]
UserVerificationStatus => "userVerification/status" {
params: v2::UserVerificationStatusParams,
serialization: None,
response: v2::UserVerificationStatusResponse,
},
#[experimental("userVerification/enroll")]
UserVerificationEnroll => "userVerification/enroll" {
params: v2::UserVerificationEnrollParams,
serialization: None,
response: v2::UserVerificationEnrollResponse,
},
#[experimental("userVerification/delete")]
UserVerificationDelete => "userVerification/delete" {
params: v2::UserVerificationDeleteParams,
serialization: None,
response: v2::UserVerificationDeleteResponse,
},
#[experimental("userVerification/verify")]
UserVerificationVerify => "userVerification/verify" {
params: v2::UserVerificationVerifyParams,
serialization: None,
response: v2::UserVerificationVerifyResponse,
},
/// NEW APIs /// NEW APIs
// Thread lifecycle // Thread lifecycle
// Uses `inspect_params` because only some fields are experimental. // Uses `inspect_params` because only some fields are experimental.
@@ -33,6 +33,7 @@ mod thread;
mod thread_data; mod thread_data;
mod thread_usage; mod thread_usage;
mod turn; mod turn;
mod user_verification;
mod windows_sandbox; mod windows_sandbox;
pub use account::*; pub use account::*;
@@ -69,6 +70,7 @@ pub use thread::*;
pub use thread_data::*; pub use thread_data::*;
pub use thread_usage::*; pub use thread_usage::*;
pub use turn::*; pub use turn::*;
pub use user_verification::*;
pub use windows_sandbox::*; pub use windows_sandbox::*;
#[cfg(test)] #[cfg(test)]
@@ -0,0 +1,150 @@
//! Experimental user-verification APIs for trusted UI clients.
//! Native implementation and registration transport are independent of these contracts.
use crate::JsonSchema;
use crate::TS;
use serde::Deserialize;
use serde::Serialize;
/// A signature over the exact decoded challenge. The verifier validates and consumes it.
#[derive(Serialize, Deserialize, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
#[ts(export_to = "v2/")]
pub struct UserVerificationProof {
pub credential_id: String,
/// Unpadded base64url DER ECDSA signature using P-256 and SHA-256.
pub signature: String,
}
impl std::fmt::Debug for UserVerificationProof {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("UserVerificationProof")
.finish_non_exhaustive()
}
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase", export_to = "v2/")]
pub enum UserVerificationUnavailableReason {
CredentialMissing,
BiometricsUnavailable,
ProviderUnavailable,
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase", export_to = "v2/")]
pub enum UserVerificationCancellationReason {
UserCancelled,
Interrupted,
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase", export_to = "v2/")]
pub enum UserVerificationFailureReason {
AuthenticationFailed,
Timeout,
ProviderError,
ServiceError,
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase", export_to = "v2/")]
pub enum UserVerificationInvalidRequestReason {
InvalidParams,
}
/// Closed error categories; native diagnostic payloads must not cross this boundary.
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
#[serde(tag = "type", rename_all = "camelCase", deny_unknown_fields)]
#[ts(tag = "type", rename_all = "camelCase", export_to = "v2/")]
pub enum UserVerificationErrorDetails {
InvalidRequest {
reason: UserVerificationInvalidRequestReason,
},
Unavailable {
reason: UserVerificationUnavailableReason,
},
Cancelled {
reason: UserVerificationCancellationReason,
},
Failed {
reason: UserVerificationFailureReason,
},
}
/// The error object inside the normal JSON-RPC envelope.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
#[ts(export_to = "v2/")]
pub struct UserVerificationRpcError {
#[ts(type = "number")]
pub code: i64,
pub message: String,
pub data: UserVerificationErrorDetails,
}
#[derive(Serialize, Deserialize, Debug, Default, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
#[ts(export_to = "v2/")]
pub struct UserVerificationStatusParams {}
/// Local readiness only; this neither prompts nor queries server registration.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct UserVerificationStatusResponse {
pub credential_id: Option<String>,
pub unavailable_reason: Option<UserVerificationUnavailableReason>,
pub unavailable_message: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Default, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
#[ts(export_to = "v2/")]
pub struct UserVerificationEnrollParams {}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct UserVerificationEnrollResponse {
pub credential_id: String,
}
#[derive(Serialize, Deserialize, Debug, Default, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
#[ts(export_to = "v2/")]
pub struct UserVerificationDeleteParams {}
#[derive(Serialize, Deserialize, Debug, Default, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct UserVerificationDeleteResponse {}
/// Local signing primitive, independent of any pending elicitation.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
#[ts(export_to = "v2/")]
pub struct UserVerificationVerifyParams {
/// Unpadded base64url encoding of 1–4096 challenge bytes.
pub challenge: String,
/// Display context already approved by the UI; 1–256 UTF-8 bytes.
pub title: String,
/// Additional display context; at most 4096 UTF-8 bytes.
pub description: String,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct UserVerificationVerifyResponse {
pub proof: UserVerificationProof,
}
#[cfg(test)]
#[path = "user_verification_tests.rs"]
mod tests;
@@ -0,0 +1,44 @@
use super::*;
use pretty_assertions::assert_eq;
use serde_json::json;
#[test]
fn local_readiness_retains_credential_during_biometric_unavailability() {
let status = UserVerificationStatusResponse {
credential_id: Some("credential".into()),
unavailable_reason: Some(UserVerificationUnavailableReason::BiometricsUnavailable),
unavailable_message: Some("Unlock biometrics to continue.".into()),
};
assert_eq!(
serde_json::to_value(&status).unwrap(),
json!({
"credentialId": "credential",
"unavailableReason": "biometricsUnavailable",
"unavailableMessage": "Unlock biometrics to continue."
})
);
assert_eq!(
serde_json::from_value::<UserVerificationStatusResponse>(
serde_json::to_value(&status).unwrap()
)
.unwrap(),
status
);
}
#[test]
fn error_details_reject_cross_category_reasons_and_native_payloads() {
let details = UserVerificationErrorDetails::Cancelled {
reason: UserVerificationCancellationReason::UserCancelled,
};
assert_eq!(
serde_json::to_value(details).unwrap(),
json!({"type": "cancelled", "reason": "userCancelled"})
);
for invalid in [
json!({"type": "failed", "reason": "userCancelled"}),
json!({"type": "unavailable", "reason": "providerUnavailable", "nativeError": {"code": 1}}),
] {
assert!(serde_json::from_value::<UserVerificationErrorDetails>(invalid).is_err());
}
}
+30
View File
@@ -3007,3 +3007,33 @@ For server-initiated request payloads, annotate the field the same way so schema
```bash ```bash
just test -p codex-app-server-protocol just test -p codex-app-server-protocol
``` ```
## User verification (experimental)
Trusted UI clients use four experimental methods. They require the existing
`experimentalApi` opt-in. This contract-only stage returns
`unavailable/providerUnavailable` until native operations are connected.
| Method | Params | Result |
| --- | --- | --- |
| `userVerification/status` | `{}` | `{credentialId, unavailableReason, unavailableMessage}` |
| `userVerification/enroll` | `{}` | `{credentialId}` |
| `userVerification/delete` | `{}` | `{}` |
| `userVerification/verify` | `{challenge, title, description}` | `{proof: {credentialId, signature}}` |
Status reads local readiness without prompting or contacting a backend. A null
`unavailableReason` means local checks passed, not that registration is valid.
Enrollment and deletion coordinate credential lifecycle; callers do not issue
separate generate or rotate commands. Identity comes from the authenticated
account; this API exposes no caller-selected scope.
Verify signs 1–4096 decoded challenge bytes using P-256 ECDSA with SHA-256. The
challenge and DER signature use unpadded base64url. Title is 1–256 UTF-8 bytes;
description is at most 4096 bytes. The UI obtains approval for that display
context before calling. Verify does not require a pending elicitation; a UI with
its own authenticator can return proof directly in elicitation response content.
The calling flow owns pending-request checks and discards late proofs.
Failures use the normal JSON-RPC error envelope with closed `{type, reason}` data:
`invalidRequest`, `unavailable`, `cancelled`, or `failed`. UI clients branch on
these values rather than message text. Native diagnostic payloads stay private.
+1
View File
@@ -132,6 +132,7 @@ mod thread_state;
mod thread_status; mod thread_status;
mod transport; mod transport;
mod turn_cost_worker; mod turn_cost_worker;
mod user_verification;
pub use crate::code_mode_host::AppServerCodeModeHostArgs; pub use crate::code_mode_host::AppServerCodeModeHostArgs;
pub use crate::code_mode_host::CodeModeHostTransport; pub use crate::code_mode_host::CodeModeHostTransport;
@@ -980,6 +980,12 @@ impl MessageProcessor {
ClientRequest::Initialize { .. } => { ClientRequest::Initialize { .. } => {
panic!("Initialize should be handled before initialized request dispatch"); panic!("Initialize should be handled before initialized request dispatch");
} }
ClientRequest::UserVerificationStatus { .. }
| ClientRequest::UserVerificationEnroll { .. }
| ClientRequest::UserVerificationDelete { .. }
| ClientRequest::UserVerificationVerify { .. } => {
Err(crate::user_verification::unavailable())
}
ClientRequest::ServerDiagnostics { .. } => Ok(Some(read_server_diagnostics().into())), ClientRequest::ServerDiagnostics { .. } => Ok(Some(read_server_diagnostics().into())),
ClientRequest::ConfigRead { params, .. } => self ClientRequest::ConfigRead { params, .. } => self
.config_processor .config_processor
@@ -0,0 +1,17 @@
//! Dispatch boundary for experimental verification APIs.
//! Native operations are introduced separately from the public contract.
use codex_app_server_protocol::JSONRPCErrorError;
use codex_app_server_protocol::UserVerificationErrorDetails;
use codex_app_server_protocol::UserVerificationUnavailableReason;
pub(crate) fn unavailable() -> JSONRPCErrorError {
JSONRPCErrorError {
code: -32603,
message: "User verification is not available in this build.".into(),
data: serde_json::to_value(UserVerificationErrorDetails::Unavailable {
reason: UserVerificationUnavailableReason::ProviderUnavailable,
})
.ok(),
}
}
@@ -130,3 +130,5 @@ mod turn_steer;
mod view_image; mod view_image;
mod web_search; mod web_search;
mod windows_sandbox_setup; mod windows_sandbox_setup;
mod user_verification;
@@ -0,0 +1,76 @@
use anyhow::Result;
use app_test_support::DEFAULT_CLIENT_NAME;
use app_test_support::TestAppServer;
use codex_app_server_protocol::ClientInfo;
use codex_app_server_protocol::InitializeCapabilities;
use codex_app_server_protocol::JSONRPCErrorError;
use codex_app_server_protocol::RequestId;
use pretty_assertions::assert_eq;
use serde_json::json;
#[tokio::test]
async fn user_verification_methods_require_experimental_opt_in() -> Result<()> {
let mut app_server = TestAppServer::builder().build().await?;
app_server
.initialize_with_capabilities(
ClientInfo {
name: DEFAULT_CLIENT_NAME.into(),
title: None,
version: "0.1.0".into(),
},
Some(InitializeCapabilities {
experimental_api: false,
..Default::default()
}),
)
.await?;
for (method, params) in [
("userVerification/status", json!({})),
("userVerification/enroll", json!({})),
("userVerification/delete", json!({})),
(
"userVerification/verify",
json!({"challenge": "AQ", "title": "Approve", "description": ""}),
),
] {
let id = app_server.send_raw_request(method, Some(params)).await?;
let response = app_server
.read_stream_until_error_message(RequestId::Integer(id))
.await?;
assert_eq!(
response.error,
JSONRPCErrorError {
code: -32600,
message: format!("{method} requires experimentalApi capability"),
data: None,
}
);
}
Ok(())
}
#[tokio::test]
async fn user_verification_without_provider_returns_typed_unavailability() -> Result<()> {
let mut app_server = TestAppServer::builder().build_initialized().await?;
let id = app_server
.send_raw_request(
"userVerification/verify",
Some(json!({
"challenge": "AQ", "title": "Approve", "description": ""
})),
)
.await?;
let response = app_server
.read_stream_until_error_message(RequestId::Integer(id))
.await?;
assert_eq!(
response.error,
JSONRPCErrorError {
code: -32603,
message: "User verification is not available in this build.".into(),
data: Some(json!({"type": "unavailable", "reason": "providerUnavailable"})),
}
);
Ok(())
}