Files
codex/codex-rs/app-server/src/user_verification_rpc_tests.rs
T
riley-oai 7b491281c8 Return public key metadata from user verification enrollment (#44877)
## Why

The trusted UI host needs the local credential's public metadata to complete backend registration. `userVerification/enroll` previously returned only `credentialId`.

## What changed

- Return `algorithm` and `publicKey` for newly created or reused credentials. The algorithm is `ecdsaP256Sha256X962`; the public key is unpadded base64url SPKI-DER.
- Keep both fields optional in the protocol for compatibility with older app-servers, while current servers populate both.
- Document caller-owned backend registration and revocation, including checking metadata, signing an enrollment challenge with `userVerification/verify`, matching credential IDs, and preserving the authenticated account throughout registration.

## Testing

Add protocol coverage for absent or null metadata and populated-response round trips. Add an RPC assertion for enrollment metadata and extend the local enrollment test to check metadata when creating and reusing a key.

GitOrigin-RevId: f0726e8c430e27559e1a01ba2ea635993cbeba09
2026-09-11 17:36:50 +00:00

209 lines
7.0 KiB
Rust

//! Exercises cancellation and local-device security through the client JSON-RPC dispatcher.
use super::test_support::Harness;
use super::test_support::write_auth;
use super::*;
use crate::outgoing_message::ConnectionId;
use crate::outgoing_message::OutgoingMessage;
use anyhow::Result;
use pretty_assertions::assert_eq;
use serde_json::json;
use std::sync::atomic::Ordering;
use tokio::time::timeout;
#[tokio::test]
async fn user_verification_rpc_enroll_returns_public_registration_metadata() -> Result<()> {
let mut h = Harness::new(ConnectionOrigin::Stdio, || true).await?;
h.initialize("test-local-ui", /*opt_in*/ true).await;
h.send(/*id*/ 1, "userVerification/enroll", json!({})).await;
let OutgoingMessage::Response(response) = h.response().await else {
panic!("local enrollment must return public metadata")
};
assert_eq!(
serde_json::to_value(response.result)?,
json!({
"credentialId": "credential",
"algorithm": "ecdsaP256Sha256X962",
"publicKey": "public-key"
})
);
h.shutdown().await;
Ok(())
}
#[tokio::test]
async fn user_verification_rpc_auth_revision_discards_native_proof() -> Result<()> {
let mut h = Harness::new(ConnectionOrigin::Stdio, || true).await?;
h.initialize("test-local-ui", /*opt_in*/ true).await;
h.start_verify().await?;
write_auth(h.home.path(), "second")?;
h.auth.reload().await;
h.provider.released.store(/*val*/ true, Ordering::Release);
let OutgoingMessage::Error(error) = h.response().await else {
panic!("auth change must reject proof")
};
assert_eq!(
error.error.data,
Some(json!({"type": "cancelled", "reason": "interrupted"}))
);
h.shutdown().await;
Ok(())
}
#[tokio::test]
async fn user_verification_rpc_disconnect_cancels_worker_and_releases_slot() -> Result<()> {
let mut h = Harness::new(ConnectionOrigin::Stdio, || true).await?;
h.initialize("test-local-ui", /*opt_in*/ true).await;
h.start_verify().await?;
timeout(
Duration::from_secs(/*secs*/ 5),
h.processor.connection_closed(ConnectionId(1), &h.session),
)
.await?;
assert!(matches!(h.response().await, OutgoingMessage::Error(_)));
assert_eq!(h.service.worker.available_permits(), 1);
h.shutdown().await;
Ok(())
}
#[tokio::test]
async fn user_verification_rpc_network_peers_cannot_use_server_keys() -> Result<()> {
for origin in [ConnectionOrigin::WebSocket, ConnectionOrigin::RemoteControl] {
let mut h = Harness::new(origin, || true).await?;
h.initialize("codex-tui", /*opt_in*/ true).await;
for method in [
"userVerification/enroll",
"userVerification/delete",
"userVerification/verify",
] {
let params = if method.ends_with("verify") {
json!({"challenge":"AQ", "title":"Approve", "description":""})
} else {
json!({})
};
h.send(/*id*/ 1, method, params).await;
let OutgoingMessage::Error(error) = h.response().await else {
panic!("remote native operation must fail")
};
assert_eq!(
error.error.data,
Some(json!({"type":"unavailable", "reason":"providerUnavailable"}))
);
}
assert_eq!(h.provider.calls.load(Ordering::SeqCst), 0);
h.shutdown().await;
}
Ok(())
}
#[tokio::test]
async fn user_verification_rpc_dropped_caller_cancels_native_work() -> Result<()> {
let h = Harness::new(ConnectionOrigin::InProcess, || true).await?;
let (entered, waiting) = oneshot::channel();
*h.provider.entered.lock().unwrap() = Some(entered);
let future = h.service.handle(
Operation::Verify(rpc::UserVerificationVerifyParams {
challenge: "AQ".into(),
title: "Approve".into(),
description: String::new(),
}),
Arc::clone(&h.session.rpc_gate),
CancellationToken::new(),
ConnectionOrigin::InProcess,
);
{
let future = std::pin::pin!(future);
tokio::select! { result = future => panic!("native operation should block: {}", result.is_ok()), entered = waiting => entered? }
}
let permit = timeout(Duration::from_secs(/*secs*/ 5), h.service.worker.acquire()).await??;
drop(permit);
assert_eq!(h.provider.calls.load(Ordering::SeqCst), 1);
h.shutdown().await;
Ok(())
}
#[tokio::test]
async fn user_verification_rechecks_auth_after_response_queue_wait() -> Result<()> {
let mut h = Harness::new(ConnectionOrigin::Stdio, || true).await?;
h.provider.released.store(/*val*/ true, Ordering::Release);
let response = h
.service
.handle(
Operation::Verify(rpc::UserVerificationVerifyParams {
challenge: "AQ".into(),
title: "Approve".into(),
description: String::new(),
}),
Arc::clone(&h.session.rpc_gate),
CancellationToken::new(),
ConnectionOrigin::Stdio,
)
.await
.map_err(|error| anyhow::anyhow!("{error:?}"))?;
for id in 0..16 {
h.outgoing
.send_response(
crate::outgoing_message::ConnectionRequestId {
connection_id: ConnectionId(1),
request_id: rpc::RequestId::Integer(id),
},
rpc::UserVerificationDeleteResponse {},
)
.await;
}
let outgoing = Arc::clone(&h.outgoing);
let (payload, check) = response.into_parts();
let sending = outgoing.send_response_as_checked(
crate::outgoing_message::ConnectionRequestId {
connection_id: ConnectionId(1),
request_id: rpc::RequestId::Integer(16),
},
payload,
check,
);
let mut sending = std::pin::pin!(sending);
assert!(matches!(
futures::poll!(&mut sending),
std::task::Poll::Pending
));
write_auth(h.home.path(), "second")?;
h.auth.reload().await;
h.response().await;
sending.await;
for _ in 1..16 {
h.response().await;
}
let OutgoingMessage::Error(error) = h.response().await else {
panic!("queued response must recheck auth")
};
assert_eq!(
error.error.data,
Some(json!({"type":"cancelled", "reason":"interrupted"}))
);
h.shutdown().await;
Ok(())
}
#[tokio::test]
async fn user_verification_rejects_concurrent_native_workers() -> Result<()> {
let h = Harness::new(ConnectionOrigin::Stdio, || true).await?;
let permit = h.service.worker.clone().acquire_owned().await?;
let result = h
.service
.handle(
Operation::Status,
Arc::clone(&h.session.rpc_gate),
CancellationToken::new(),
ConnectionOrigin::Stdio,
)
.await;
assert_eq!(
result.err().unwrap().message,
"Another user verification operation is still running."
);
assert_eq!(h.provider.calls.load(Ordering::SeqCst), 0);
drop(permit);
h.shutdown().await;
Ok(())
}