use std::io::Read;
use super::{did::Did, did_doc::DidDocument};
use crate::http::{AsyncGenericResolver, AsyncHttpResolver, HttpResolverError};
pub(crate) const MAX_DID_DOC_SIZE: u64 = 1024 * 1024;
const USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"));
#[cfg(test)]
use std::cell::RefCell;
#[cfg(test)]
thread_local! {
pub(crate) static PROXY: RefCell<Option<String>> = const { RefCell::new(None) };
}
use http::header;
#[derive(Debug, thiserror::Error)]
pub enum DidWebError {
#[error("error building HTTP client: {0}")]
Client(HttpResolverError),
#[error("error sending HTTP request ({0}): {1}")]
Request(String, HttpResolverError),
#[error("server error: {0}")]
Server(String),
#[error("error reading HTTP response: {0}")]
Response(HttpResolverError),
#[error("the document was not found: {0}")]
NotFound(String),
#[error("the document was not a valid DID document: {0}")]
InvalidData(String),
#[error("invalid web DID: {0}")]
InvalidWebDid(String),
#[error("response body exceeded size limit of {MAX_DID_DOC_SIZE} bytes")]
ResponseTooLarge,
}
pub(crate) async fn resolve(did: &Did<'_>) -> Result<DidDocument, DidWebError> {
let method = did.method_name();
#[allow(clippy::panic)] if method != "web" {
panic!("Unexpected DID method {method}");
}
let method_specific_id = did.method_specific_id();
let url = to_url(method_specific_id)?;
let did_doc = get_did_doc(&url).await?;
let json = String::from_utf8(did_doc).map_err(|_| DidWebError::InvalidData(url.clone()))?;
DidDocument::from_json(&json).map_err(|_| DidWebError::InvalidData(url))
}
async fn get_did_doc(url: &str) -> Result<Vec<u8>, DidWebError> {
let request = http::Request::get(url)
.header(header::USER_AGENT, USER_AGENT)
.header(header::ACCEPT, "application/did+json")
.body(Vec::new())
.map_err(|e| DidWebError::Request(url.to_owned(), e.into()))?;
let response = AsyncGenericResolver::with_redirects()
.unwrap_or_default()
.with_max_response_body_size(MAX_DID_DOC_SIZE)
.http_resolve_async(request)
.await
.map_err(|e| match e {
HttpResolverError::ResponseTooLarge => DidWebError::ResponseTooLarge,
e => DidWebError::Request(url.to_owned(), e),
})?;
let (parts, mut body) = response.into_parts();
match parts.status {
http::StatusCode::OK => (),
http::StatusCode::NOT_FOUND => return Err(DidWebError::NotFound(url.to_string())),
_ => return Err(DidWebError::Server(parts.status.to_string())),
};
let mut document = Vec::new();
body.read_to_end(&mut document)
.map_err(|e| DidWebError::Response(e.into()))?;
Ok(document)
}
pub(crate) fn to_url(did: &str) -> Result<String, DidWebError> {
let mut parts = did.split(':').peekable();
let domain_name = parts
.next()
.ok_or_else(|| DidWebError::InvalidWebDid(did.to_owned()))?;
let path = match parts.peek() {
Some(_) => parts.collect::<Vec<&str>>().join("/"),
None => ".well-known".to_string(),
};
let proto = if domain_name.starts_with("localhost") {
"http"
} else {
"https"
};
#[allow(unused_mut)]
let mut url = format!(
"{proto}://{}/{path}/did.json",
domain_name.replacen("%3A", ":", 1)
);
#[cfg(test)]
PROXY.with(|proxy| {
if let Some(ref proxy) = *proxy.borrow() {
if domain_name == "localhost" {
url = format!("{proxy}{path}/did.json");
dbg!(&url);
}
}
});
Ok(url)
}
#[cfg(test)]
mod tests {
#![allow(clippy::panic)]
#![allow(clippy::unwrap_used)]
#[cfg(all(target_arch = "wasm32", not(target_os = "wasi")))]
use wasm_bindgen_test::wasm_bindgen_test;
use crate::identity::claim_aggregation::w3c_vc::{did::Did, did_web};
#[test]
#[cfg_attr(
all(target_arch = "wasm32", not(target_os = "wasi")),
wasm_bindgen_test
)]
fn to_url() {
assert_eq!(
did_web::to_url(did("did:web:w3c-ccg.github.io").method_specific_id()).unwrap(),
"https://w3c-ccg.github.io/.well-known/did.json"
);
assert_eq!(
did_web::to_url(did("did:web:w3c-ccg.github.io:user:alice").method_specific_id())
.unwrap(),
"https://w3c-ccg.github.io/user/alice/did.json"
);
assert_eq!(
did_web::to_url(did("did:web:example.com:u:bob").method_specific_id()).unwrap(),
"https://example.com/u/bob/did.json"
);
assert_eq!(
did_web::to_url(did("did:web:example.com%3A443:u:bob").method_specific_id()).unwrap(),
"https://example.com:443/u/bob/did.json"
);
}
#[cfg(not(target_arch = "wasm32"))]
mod resolve {
use httpmock::prelude::*;
use super::did;
use crate::identity::claim_aggregation::w3c_vc::{
did_doc::DidDocument,
did_web::{self, DidWebError, MAX_DID_DOC_SIZE, PROXY},
};
#[tokio::test]
async fn from_did_key() {
const DID_JSON: &str = r#"{
"@context": "https://www.w3.org/ns/did/v1",
"id": "did:web:localhost",
"verificationMethod": [{
"id": "did:web:localhost#key1",
"type": "Ed25519VerificationKey2018",
"controller": "did:web:localhost",
"publicKeyBase58": "2sXRz2VfrpySNEL6xmXJWQg6iY94qwNp1qrJJFBuPWmH"
}],
"assertionMethod": ["did:web:localhost#key1"]
}"#;
let server = MockServer::start();
PROXY.with(|proxy| {
let server_url = server.url("/").replace("127.0.0.1", "localhost");
dbg!(&server_url);
proxy.replace(Some(server_url));
});
let did_doc_mock = server.mock(|when, then| {
when.method(GET).path("/.well-known/did.json");
then.status(200)
.header("content-type", "application/json")
.body(DID_JSON);
});
let doc = did_web::resolve(&did("did:web:localhost")).await.unwrap();
let doc_expected = DidDocument::from_json(DID_JSON).unwrap();
assert_eq!(doc, doc_expected);
PROXY.with(|proxy| {
proxy.replace(None);
});
did_doc_mock.assert();
}
#[tokio::test]
async fn content_length_above_limit_rejected() {
let server = MockServer::start();
PROXY.with(|proxy| {
let server_url = server.url("/").replace("127.0.0.1", "localhost");
proxy.replace(Some(server_url));
});
let oversized_body = vec![b'X'; (MAX_DID_DOC_SIZE + 1) as usize];
let _mock = server.mock(|when, then| {
when.method(GET).path("/.well-known/did.json");
then.status(200)
.header("content-type", "application/did+json")
.header("content-length", (MAX_DID_DOC_SIZE + 1).to_string())
.body(oversized_body);
});
let result = did_web::resolve(&did("did:web:localhost")).await;
PROXY.with(|proxy| {
proxy.replace(None);
});
assert!(
matches!(result, Err(did_web::DidWebError::ResponseTooLarge)),
"expected ResponseTooLarge, got {result:?}"
);
}
#[tokio::test]
async fn oversized_response_returns_error() {
let server = MockServer::start();
PROXY.with(|proxy| {
let server_url = server.url("/").replace("127.0.0.1", "localhost");
proxy.replace(Some(server_url));
});
let oversized_body = vec![b'X'; (MAX_DID_DOC_SIZE + 1) as usize];
let _mock = server.mock(|when, then| {
when.method(GET).path("/.well-known/did.json");
then.status(200)
.header("content-type", "application/did+json")
.body(oversized_body);
});
let result = did_web::resolve(&did("did:web:localhost")).await;
PROXY.with(|proxy| {
proxy.replace(None);
});
assert!(
matches!(result, Err(did_web::DidWebError::ResponseTooLarge)),
"expected ResponseTooLarge, got {result:?}"
);
}
}
fn did(s: &'static str) -> Did<'static> {
Did::new(s).unwrap()
}
}