use serde::Deserialize;
use std::time::{SystemTime, UNIX_EPOCH};
use tracing::{debug, info, warn};
use url::{Host, Url};
use crate::error::AppError;
use crate::webvh_auth::{
ChallengeContext, VtaSigningIdentity, build_authenticate_message, build_refresh_message,
};
pub struct WebvhClient {
http: reqwest::Client,
server_url: String,
server_did: String,
access_token: Option<String>,
}
fn is_loopback_host(host: &Host<&str>) -> bool {
match host {
Host::Domain(d) => *d == "localhost",
Host::Ipv4(ip) => ip.is_loopback(),
Host::Ipv6(ip) => ip.is_loopback(),
}
}
fn enforce_transport_security(parsed: &Url, raw: &str) -> Result<(), AppError> {
let scheme = parsed.scheme();
if scheme == "https" {
return Ok(());
}
if scheme == "http" {
if parsed.host().map(|h| is_loopback_host(&h)).unwrap_or(false) {
return Ok(());
}
return Err(AppError::Validation(format!(
"refusing to dial webvh-server `{raw}` over plaintext `http://`: \
bearer tokens and the VTA's signed authenticate payload must not be sent \
over plaintext. Only `http://` to a loopback host \
(localhost, 127/8, ::1) is permitted; advertise an `https://` endpoint in \
the server DID's service entry instead.",
)));
}
Err(AppError::Validation(format!(
"webvh-server URL `{raw}` uses unsupported scheme `{scheme}://`; \
only `https://` (recommended) or `http://` to a loopback host are accepted.",
)))
}
#[derive(Debug, Deserialize)]
pub struct RequestUriResponse {
pub did_url: String,
pub mnemonic: String,
}
#[derive(Debug, Deserialize)]
pub struct CheckPathResponse {
pub available: bool,
}
#[derive(Clone, Deserialize, zeroize::ZeroizeOnDrop)]
#[serde(rename_all = "camelCase")]
pub struct TokenData {
pub access_token: String,
pub access_expires_at: u64,
pub refresh_token: String,
pub refresh_expires_at: u64,
}
impl std::fmt::Debug for TokenData {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("TokenData")
.field("access_token", &"<redacted>")
.field("access_expires_at", &self.access_expires_at)
.field("refresh_token", &"<redacted>")
.field("refresh_expires_at", &self.refresh_expires_at)
.finish()
}
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct TokenResponseWire {
#[allow(dead_code)] session_id: String,
data: TokenData,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ChallengeResponseWire {
#[serde(alias = "session_id")]
session_id: String,
data: ChallengeData,
}
#[derive(Debug, Deserialize)]
struct ChallengeData {
challenge: String,
}
fn unix_now_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
impl WebvhClient {
pub fn new(server_url: &str, server_did: &str) -> Result<Self, AppError> {
let parsed = Url::parse(server_url).map_err(|e| {
AppError::Validation(format!("invalid webvh-server URL `{server_url}`: {e}"))
})?;
enforce_transport_security(&parsed, server_url)?;
Ok(Self {
http: reqwest::Client::new(),
server_url: server_url.trim_end_matches('/').to_string(),
server_did: server_did.to_string(),
access_token: None,
})
}
pub fn set_access_token(&mut self, token: String) {
self.access_token = Some(token);
}
pub async fn authenticate(
&self,
identity: &VtaSigningIdentity<'_>,
) -> Result<TokenData, AppError> {
let challenge = self.fetch_challenge(identity.vta_did).await?;
let jws = build_authenticate_message(
identity,
&ChallengeContext {
session_id: &challenge.session_id,
challenge: &challenge.data.challenge,
server_did: &self.server_did,
},
unix_now_secs(),
)?;
let url = format!("{}/api/auth/", self.server_url);
info!(method = "POST", %url, "webvh: authenticating");
let resp = self
.http
.post(&url)
.header("Content-Type", "application/json")
.body(jws)
.send()
.await
.map_err(|e| AppError::Internal(format!("webvh authenticate request failed: {e}")))?;
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
if !status.is_success() {
return Err(self.map_auth_failure(status, &body, identity.vta_did));
}
let parsed: TokenResponseWire = serde_json::from_str(&body).map_err(|e| {
AppError::Internal(format!(
"webvh authenticate response parse error: {e} (body: {body})"
))
})?;
Ok(parsed.data)
}
pub async fn refresh(
&self,
identity: &VtaSigningIdentity<'_>,
refresh_token: &str,
) -> Result<TokenData, AppError> {
let jws =
build_refresh_message(identity, &self.server_did, refresh_token, unix_now_secs())?;
let url = format!("{}/api/auth/refresh", self.server_url);
info!(method = "POST", %url, "webvh: refreshing token");
let resp = self
.http
.post(&url)
.header("Content-Type", "application/json")
.body(jws)
.send()
.await
.map_err(|e| AppError::Internal(format!("webvh refresh request failed: {e}")))?;
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
if !status.is_success() {
warn!(
status = %status,
vta_did = %identity.vta_did,
"webvh refresh rejected by daemon",
);
return Err(AppError::Authentication(format!(
"webvh-server {} rejected refresh token (status {status}): {body}",
self.server_did,
)));
}
let parsed: TokenResponseWire = serde_json::from_str(&body).map_err(|e| {
AppError::Internal(format!(
"webvh refresh response parse error: {e} (body: {body})"
))
})?;
Ok(parsed.data)
}
async fn fetch_challenge(&self, vta_did: &str) -> Result<ChallengeResponseWire, AppError> {
let url = format!("{}/api/auth/challenge", self.server_url);
debug!(method = "POST", %url, "webvh: fetching challenge");
let resp = self
.http
.post(&url)
.json(&serde_json::json!({ "did": vta_did }))
.send()
.await
.map_err(|e| AppError::Internal(format!("webvh challenge request failed: {e}")))?;
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
if !status.is_success() {
return Err(AppError::Internal(format!(
"webvh-server {} POST /api/auth/challenge failed (status {status}): {body}",
self.server_did,
)));
}
serde_json::from_str(&body).map_err(|e| {
AppError::Internal(format!(
"webvh challenge response parse error: {e} (body: {body})"
))
})
}
fn map_auth_failure(&self, status: reqwest::StatusCode, body: &str, vta_did: &str) -> AppError {
if status == reqwest::StatusCode::UNAUTHORIZED {
return AppError::Authentication(format!(
"webvh-server {server_did} rejected authentication signature for VTA DID `{vta_did}`. \
Likely causes: clock skew between VTA and daemon (daemon accepts a ±5min window), \
expired challenge, or a signing-key fragment that doesn't match a verification method \
in the VTA's DID document. Daemon response: {body}",
server_did = self.server_did,
));
}
if status == reqwest::StatusCode::FORBIDDEN {
return AppError::Forbidden(format!(
"webvh-server {server_did} accepted the signature for VTA DID `{vta_did}` but the \
DID is not in the daemon's ACL. The corrective action is daemon-side: grant the \
VTA's DID access on the daemon. Daemon response: {body}",
server_did = self.server_did,
));
}
if status.is_client_error() {
return AppError::Validation(format!(
"webvh-server {} rejected authentication (status {status}): {body}",
self.server_did,
));
}
AppError::Internal(format!(
"webvh-server {} authentication failed (status {status}): {body}",
self.server_did,
))
}
fn with_auth(&self, mut req: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
if let Some(ref token) = self.access_token {
req = req.header("Authorization", format!("Bearer {token}"));
}
req
}
async fn send(
&self,
req: reqwest::RequestBuilder,
context: &str,
) -> Result<reqwest::Response, AppError> {
let resp = req
.send()
.await
.map_err(|e| AppError::Internal(format!("webvh-server request failed: {e}")))?;
let status = resp.status();
if !status.is_success() {
let text = resp.text().await.unwrap_or_default();
let msg = format!(
"webvh-server {server} {context} failed ({status}): {text}",
server = self.server_did,
);
return Err(match status {
reqwest::StatusCode::UNAUTHORIZED => AppError::Unauthorized(msg),
reqwest::StatusCode::FORBIDDEN => AppError::Forbidden(msg),
s if s.is_client_error() => AppError::Validation(msg),
_ => AppError::Internal(msg),
});
}
debug!(
status = status.as_u16(),
context, "webvh: received via rest"
);
Ok(resp)
}
pub async fn request_uri(
&self,
path: Option<&str>,
domain: Option<&str>,
) -> Result<RequestUriResponse, AppError> {
let url = format!("{}/api/dids", self.server_url);
info!(method = "POST", %url, "webvh: sending via rest");
let mut body = serde_json::Map::new();
if let Some(p) = path {
body.insert("path".to_string(), serde_json::Value::String(p.to_string()));
}
if let Some(d) = domain {
body.insert(
"domain".to_string(),
serde_json::Value::String(d.to_string()),
);
}
let req = self
.with_auth(self.http.post(&url))
.json(&serde_json::Value::Object(body));
let resp = self.send(req, "POST /api/dids").await?;
resp.json()
.await
.map_err(|e| AppError::Internal(format!("webvh-server response parse error: {e}")))
}
pub async fn register_did_atomic(
&self,
path: &str,
did_log: &str,
force: bool,
domain: Option<&str>,
) -> Result<RequestUriResponse, AppError> {
let url = format!("{}/api/dids/register", self.server_url);
info!(method = "POST", %url, "webvh: sending via rest");
let mut body = serde_json::Map::new();
body.insert(
"path".to_string(),
serde_json::Value::String(path.to_string()),
);
body.insert(
"method".to_string(),
serde_json::Value::String("webvh".to_string()),
);
body.insert(
"didData".to_string(),
serde_json::Value::String(did_log.to_string()),
);
body.insert("force".to_string(), serde_json::Value::Bool(force));
if let Some(d) = domain {
body.insert(
"domain".to_string(),
serde_json::Value::String(d.to_string()),
);
}
let req = self
.with_auth(self.http.post(&url))
.json(&serde_json::Value::Object(body));
let resp = self.send(req, "POST /api/dids/register").await?;
resp.json()
.await
.map_err(|e| AppError::Internal(format!("webvh-server response parse error: {e}")))
}
pub async fn publish_did(
&self,
mnemonic: &str,
log_content: &str,
domain: Option<&str>,
) -> Result<(), AppError> {
let url = if let Some(d) = domain {
format!(
"{}/api/dids/{mnemonic}?domain={}",
self.server_url,
url::form_urlencoded::byte_serialize(d.as_bytes()).collect::<String>()
)
} else {
format!("{}/api/dids/{mnemonic}", self.server_url)
};
info!(method = "PUT", %url, "webvh: sending via rest");
let req = self
.with_auth(self.http.put(&url))
.header("Content-Type", "application/jsonl")
.body(log_content.to_string());
self.send(req, &format!("PUT /api/dids/{mnemonic}")).await?;
Ok(())
}
pub async fn delete_did(&self, mnemonic: &str, domain: Option<&str>) -> Result<(), AppError> {
let url = if let Some(d) = domain {
format!(
"{}/api/dids/{mnemonic}?domain={}",
self.server_url,
url::form_urlencoded::byte_serialize(d.as_bytes()).collect::<String>()
)
} else {
format!("{}/api/dids/{mnemonic}", self.server_url)
};
info!(method = "DELETE", %url, "webvh: sending via rest");
let req = self.with_auth(self.http.delete(&url));
self.send(req, &format!("DELETE /api/dids/{mnemonic}"))
.await?;
Ok(())
}
pub async fn check_path(
&self,
path: &str,
reserve: bool,
domain: Option<&str>,
) -> Result<CheckPathResponse, AppError> {
let url = format!("{}/api/dids/check", self.server_url);
let mut body = serde_json::Map::new();
body.insert(
"path".to_string(),
serde_json::Value::String(path.to_string()),
);
if reserve {
body.insert("reserve".to_string(), serde_json::Value::Bool(true));
}
if let Some(d) = domain {
body.insert(
"domain".to_string(),
serde_json::Value::String(d.to_string()),
);
}
let req = self
.with_auth(self.http.post(&url))
.json(&serde_json::Value::Object(body));
let resp = self.send(req, "POST /api/dids/check").await?;
resp.json()
.await
.map_err(|e| AppError::Internal(format!("webvh-server response parse error: {e}")))
}
pub async fn list_my_domains(&self) -> Result<MyDomainsResponse, AppError> {
let url = format!("{}/api/me/domains", self.server_url);
info!(method = "GET", %url, "webvh: sending via rest");
let req = self.with_auth(self.http.get(&url));
let resp = self.send(req, "GET /api/me/domains").await?;
resp.json()
.await
.map_err(|e| AppError::Internal(format!("webvh-server response parse error: {e}")))
}
}
#[derive(Debug, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MyDomainsResponse {
pub domains: Vec<MyDomainEntry>,
pub default: Option<String>,
}
#[derive(Debug, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MyDomainEntry {
pub name: String,
#[serde(default)]
pub default_domain: bool,
#[serde(default)]
pub status: String,
#[serde(default)]
pub label: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
fn assert_validation_err(result: Result<WebvhClient, AppError>, needle: &str) {
match result {
Err(AppError::Validation(msg)) => assert!(
msg.contains(needle),
"expected validation error to contain `{needle}`, got: {msg}"
),
Err(other) => panic!("expected Validation error, got {other:?}"),
Ok(_) => panic!("expected Validation error, got Ok"),
}
}
#[test]
fn https_url_is_accepted() {
let c = WebvhClient::new("https://daemon.example", "did:web:daemon.example")
.expect("https must be accepted");
assert_eq!(c.server_url, "https://daemon.example");
}
#[test]
fn https_url_trailing_slash_is_normalised() {
let c = WebvhClient::new("https://daemon.example/", "did:web:daemon.example").unwrap();
assert_eq!(c.server_url, "https://daemon.example");
}
#[test]
fn http_to_non_loopback_is_rejected() {
assert_validation_err(
WebvhClient::new("http://daemon.example", "did:web:daemon.example"),
"refusing to dial webvh-server",
);
}
#[test]
fn http_to_localhost_is_accepted_for_dev() {
let c = WebvhClient::new("http://localhost:8530", "did:web:daemon.example").unwrap();
assert_eq!(c.server_url, "http://localhost:8530");
}
#[test]
fn http_to_127_0_0_1_is_accepted() {
let c = WebvhClient::new("http://127.0.0.1:8530", "did:web:daemon.example").unwrap();
assert_eq!(c.server_url, "http://127.0.0.1:8530");
}
#[test]
fn http_to_127_0_0_x_subnet_is_accepted() {
let c = WebvhClient::new("http://127.0.0.5:8530", "did:web:daemon.example").unwrap();
assert_eq!(c.server_url, "http://127.0.0.5:8530");
}
#[test]
fn http_to_ipv6_loopback_is_accepted() {
let c = WebvhClient::new("http://[::1]:8530", "did:web:daemon.example").unwrap();
assert!(c.server_url.contains("::1"));
}
#[test]
fn http_to_0_0_0_0_is_rejected() {
assert_validation_err(
WebvhClient::new("http://0.0.0.0:8530", "did:web:daemon.example"),
"refusing to dial webvh-server",
);
}
#[test]
fn ftp_scheme_is_rejected() {
assert_validation_err(
WebvhClient::new("ftp://daemon.example/", "did:web:daemon.example"),
"unsupported scheme",
);
}
#[test]
fn ws_scheme_is_rejected() {
assert_validation_err(
WebvhClient::new("ws://daemon.example/", "did:web:daemon.example"),
"unsupported scheme",
);
}
#[test]
fn malformed_url_is_rejected() {
assert_validation_err(
WebvhClient::new("not-a-url", "did:web:daemon.example"),
"invalid webvh-server URL",
);
}
#[test]
fn empty_url_is_rejected() {
assert_validation_err(
WebvhClient::new("", "did:web:daemon.example"),
"invalid webvh-server URL",
);
}
#[test]
fn https_to_loopback_is_also_accepted() {
let c = WebvhClient::new("https://localhost:8443", "did:web:daemon.example").unwrap();
assert_eq!(c.server_url, "https://localhost:8443");
}
#[test]
fn http_to_hostname_resembling_localhost_is_rejected() {
assert_validation_err(
WebvhClient::new("http://localhost.evil.example", "did:web:daemon.example"),
"refusing to dial webvh-server",
);
}
use crate::webvh_auth::VtaSigningIdentity;
use ed25519_dalek::SigningKey;
use serde_json::json;
use wiremock::matchers::{header, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
fn signing_identity() -> ([u8; 32], String, String) {
let seed = [9u8; 32];
let sk = SigningKey::from_bytes(&seed);
let vta_did = "did:webvh:test:vta".to_string();
let kid = format!("{vta_did}#key-0");
(sk.to_bytes(), vta_did, kid)
}
fn token_response_json() -> serde_json::Value {
json!({
"sessionId": "auth-session-1",
"data": {
"accessToken": "access-token-A",
"accessExpiresAt": 9_999_999_999u64,
"refreshToken": "refresh-token-A",
"refreshExpiresAt": 9_999_999_999u64,
}
})
}
fn challenge_response_json() -> serde_json::Value {
json!({
"sessionId": "chal-session-1",
"data": { "challenge": "deadbeef" },
})
}
#[tokio::test]
async fn authenticate_round_trips_against_mock_daemon() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/auth/challenge"))
.respond_with(ResponseTemplate::new(200).set_body_json(challenge_response_json()))
.expect(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/api/auth/"))
.and(header("Content-Type", "application/json"))
.respond_with(ResponseTemplate::new(200).set_body_json(token_response_json()))
.expect(1)
.mount(&server)
.await;
let (private, vta_did, kid) = signing_identity();
let client = WebvhClient::new(&server.uri(), "did:web:daemon-mock.example").unwrap();
let identity = VtaSigningIdentity {
vta_did: &vta_did,
signing_kid: &kid,
private_key: &private,
};
let tokens = client
.authenticate(&identity)
.await
.expect("authenticate must succeed");
assert_eq!(tokens.access_token, "access-token-A");
assert_eq!(tokens.refresh_token, "refresh-token-A");
assert_eq!(tokens.access_expires_at, 9_999_999_999);
}
#[tokio::test]
async fn authenticate_401_surfaces_signature_freshness_hint() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/auth/challenge"))
.respond_with(ResponseTemplate::new(200).set_body_json(challenge_response_json()))
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/api/auth/"))
.respond_with(ResponseTemplate::new(401).set_body_string("invalid signature"))
.mount(&server)
.await;
let (private, vta_did, kid) = signing_identity();
let client = WebvhClient::new(&server.uri(), "did:web:daemon-mock.example").unwrap();
let identity = VtaSigningIdentity {
vta_did: &vta_did,
signing_kid: &kid,
private_key: &private,
};
let err = client.authenticate(&identity).await.unwrap_err();
match err {
AppError::Authentication(msg) => {
assert!(
msg.contains("clock skew") || msg.contains("expired challenge"),
"401 must hint at signature/freshness failures, not ACL: {msg}"
);
assert!(
!msg.contains("not in the daemon's ACL"),
"401 must NOT suggest ACL change (that's the 403 case): {msg}"
);
}
other => panic!("expected Authentication, got {other:?}"),
}
}
#[tokio::test]
async fn authenticate_403_surfaces_acl_hint() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/auth/challenge"))
.respond_with(ResponseTemplate::new(200).set_body_json(challenge_response_json()))
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/api/auth/"))
.respond_with(ResponseTemplate::new(403).set_body_string("DID not in ACL"))
.mount(&server)
.await;
let (private, vta_did, kid) = signing_identity();
let client = WebvhClient::new(&server.uri(), "did:web:daemon-mock.example").unwrap();
let identity = VtaSigningIdentity {
vta_did: &vta_did,
signing_kid: &kid,
private_key: &private,
};
let err = client.authenticate(&identity).await.unwrap_err();
match err {
AppError::Forbidden(msg) => {
assert!(
msg.contains("not in the daemon's ACL"),
"403 must surface the ACL hint: {msg}"
);
assert!(msg.contains(&vta_did), "should name the VTA DID: {msg}");
assert!(
msg.contains("daemon-side"),
"should point at the daemon as the fix location: {msg}"
);
}
other => panic!("expected Forbidden, got {other:?}"),
}
}
#[tokio::test]
async fn authenticate_500_yields_internal_not_auth_error() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/auth/challenge"))
.respond_with(ResponseTemplate::new(200).set_body_json(challenge_response_json()))
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/api/auth/"))
.respond_with(ResponseTemplate::new(503).set_body_string("upstream down"))
.mount(&server)
.await;
let (private, vta_did, kid) = signing_identity();
let client = WebvhClient::new(&server.uri(), "did:web:daemon-mock.example").unwrap();
let identity = VtaSigningIdentity {
vta_did: &vta_did,
signing_kid: &kid,
private_key: &private,
};
let err = client.authenticate(&identity).await.unwrap_err();
assert!(
matches!(err, AppError::Internal(_)),
"5xx should map to Internal, got: {err:?}"
);
}
#[tokio::test]
async fn refresh_returns_rotated_tokens() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/auth/refresh"))
.and(header("Content-Type", "application/json"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"sessionId": "refreshed-session",
"data": {
"accessToken": "new-access",
"accessExpiresAt": 1_900_000_000u64,
"refreshToken": "rotated-refresh",
"refreshExpiresAt": 1_900_999_999u64,
}
})))
.expect(1)
.mount(&server)
.await;
let (private, vta_did, kid) = signing_identity();
let client = WebvhClient::new(&server.uri(), "did:web:daemon-mock.example").unwrap();
let identity = VtaSigningIdentity {
vta_did: &vta_did,
signing_kid: &kid,
private_key: &private,
};
let tokens = client
.refresh(&identity, "old-refresh")
.await
.expect("refresh must succeed");
assert_eq!(tokens.access_token, "new-access");
assert_eq!(
tokens.refresh_token, "rotated-refresh",
"refresh must return rotated token, not echo input"
);
}
#[tokio::test]
async fn refresh_failure_yields_typed_authentication_error() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/auth/refresh"))
.respond_with(ResponseTemplate::new(401).set_body_string("invalid refresh token"))
.mount(&server)
.await;
let (private, vta_did, kid) = signing_identity();
let client = WebvhClient::new(&server.uri(), "did:web:daemon-mock.example").unwrap();
let identity = VtaSigningIdentity {
vta_did: &vta_did,
signing_kid: &kid,
private_key: &private,
};
let err = client
.refresh(&identity, "stale-refresh")
.await
.unwrap_err();
assert!(
matches!(err, AppError::Authentication(_)),
"expired refresh must map to Authentication, got: {err:?}"
);
}
#[test]
fn token_data_debug_redacts_secret_fields() {
let td = TokenData {
access_token: "should-not-appear-XXXX".into(),
access_expires_at: 1234,
refresh_token: "also-secret-YYYY".into(),
refresh_expires_at: 5678,
};
let dbg = format!("{td:?}");
assert!(!dbg.contains("XXXX"), "access_token must not leak: {dbg}");
assert!(!dbg.contains("YYYY"), "refresh_token must not leak: {dbg}");
assert!(dbg.contains("<redacted>"));
assert!(dbg.contains("1234"));
assert!(dbg.contains("5678"));
}
#[tokio::test]
async fn authenticate_uses_camelcase_sessionid_from_daemon() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/auth/challenge"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"sessionId": "camel-id",
"data": { "challenge": "cafebabe" }
})))
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/api/auth/"))
.respond_with(ResponseTemplate::new(200).set_body_json(token_response_json()))
.mount(&server)
.await;
let (private, vta_did, kid) = signing_identity();
let client = WebvhClient::new(&server.uri(), "did:web:daemon-mock.example").unwrap();
let identity = VtaSigningIdentity {
vta_did: &vta_did,
signing_kid: &kid,
private_key: &private,
};
let _ = client
.authenticate(&identity)
.await
.expect("must accept camelCase sessionId");
}
}