use axum::extract::State;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use serde_json::Value;
use trust_tasks_https::{HttpsHandler, status_for_code};
use trust_tasks_rs::{ErrorPayload, RejectReason, TrustTask};
use uuid::Uuid;
use did_hosting_common::server::trust_tasks::DispatchOutcome;
use crate::auth::AuthClaims;
use crate::error::AppError;
use crate::server::AppState;
pub async fn trust_tasks_endpoint(
auth: Option<AuthClaims>,
State(state): State<AppState>,
connect: Result<
axum::extract::ConnectInfo<std::net::SocketAddr>,
<axum::extract::ConnectInfo<std::net::SocketAddr> as axum::extract::FromRequestParts<
AppState,
>>::Rejection,
>,
headers: axum::http::HeaderMap,
body: axum::body::Bytes,
) -> Result<Response, AppError> {
let Ok(axum::extract::ConnectInfo(addr)) = connect else {
tracing::error!(
"trust-tasks request without a connection address; the server must be built with \
into_make_service_with_connect_info"
);
return Err(AppError::Internal(
"no connection address for this request".into(),
));
};
let xff = headers.get("x-forwarded-for").and_then(|v| v.to_str().ok());
let ip =
crate::rate_limit::resolve_client_ip(addr.ip(), xff, &state.config.server.trusted_proxies);
let client_ip = Some(ip);
if is_redeem_start(&body) {
let limited = state.redeem_rate_limiter.try_consume(
&format!("ip:{ip}"),
did_hosting_common::server::auth::session::now_epoch(),
);
if let Err(AppError::RateLimited {
retry_after_secs, ..
}) = limited
{
tracing::warn!(%ip, "invite redemption rate limited");
if let Ok(doc) = serde_json::from_slice::<TrustTask<Value>>(&body) {
let reject = RejectReason::Unavailable {
retry_after: Some(
chrono::Utc::now() + chrono::Duration::seconds(retry_after_secs as i64),
),
};
let routed = doc.reject_with(format!("urn:uuid:{}", Uuid::new_v4()), reject);
return Ok(into_response(DispatchOutcome::Rejected(routed)));
}
}
}
dispatch_trust_task(auth, State(state), client_ip, &headers, body).await
}
fn is_redeem_start(body: &[u8]) -> bool {
use trust_tasks_rs::Payload;
serde_json::from_slice::<TrustTask<Value>>(body).is_ok_and(|doc| {
doc.type_uri.to_string()
== trust_tasks_rs::specs::auth::passkey::enroll::redeem::start::v0_1::Payload::TYPE_URI
})
}
pub async fn dispatch_trust_task(
auth: Option<AuthClaims>,
State(state): State<AppState>,
client_ip: Option<std::net::IpAddr>,
headers: &axum::http::HeaderMap,
body: axum::body::Bytes,
) -> Result<Response, AppError> {
let my_vid = state
.config
.server_did
.as_deref()
.ok_or_else(|| AppError::Config("server_did not configured".into()))?;
let address = client_ip
.map(|ip| format!("ip:{ip}"))
.unwrap_or_else(|| "ip:unknown".to_string());
let large_doc_charge =
match did_hosting_common::server::trust_tasks::size::check_for_known_issuer(
&body,
&crate::control_tasks::SERVED_TRUST_TASK_URIS,
&state.acl_ks,
&state.large_document_budget,
&address,
did_hosting_common::server::auth::session::now_epoch(),
)
.await
{
Ok(charge) => charge,
Err(err) => return Ok(into_response(DispatchOutcome::Rejected(err))),
};
let doc: TrustTask<Value> = match serde_json::from_slice(&body) {
Ok(d) => d,
Err(e) => {
return Ok(into_response(DispatchOutcome::Rejected(body_parse_error(
&e.to_string(),
))));
}
};
let auth =
auth.filter(|_| crate::messaging::proof_rule(&doc.type_uri.to_string()).admits_bearer());
let session_key_vm = auth
.as_ref()
.and_then(|a| a.session_pubkey_b58btc.as_deref())
.filter(|_| crate::messaging::session_key_may_sign(&doc.type_uri.to_string()))
.map(|pk| format!("did:key:{pk}#{pk}"));
if let (Some(auth), Some(proof)) = (auth.as_ref(), doc.proof.as_ref()) {
let own_key = did_hosting_common::server::trust_tasks::verifier::controller_did(
&proof.verification_method,
) == auth.did;
let session_key = session_key_vm.as_deref() == Some(proof.verification_method.as_str());
if !own_key && !session_key {
tracing::warn!(
actual_vm = %proof.verification_method,
auth_did = %auth.did,
session_key_vm = ?session_key_vm,
type_uri = %doc.type_uri,
"trust-task proof is neither the session subject's key nor a session key \
this task accepts — rejecting as proof_invalid"
);
let reject = RejectReason::ProofInvalid {
reason: "proof verificationMethod is not bound to this session".to_string(),
};
let routed = doc.reject_with(format!("urn:uuid:{}", Uuid::new_v4()), reject);
return Ok(into_response(DispatchOutcome::Rejected(routed)));
}
}
let shared = state
.trust_tasks_verifier
.as_deref()
.ok_or_else(|| AppError::Config("no trust-task proof verifier configured".into()))?;
let delegated;
let verifier = match (auth.as_ref(), session_key_vm) {
(Some(auth), Some(vm)) => {
delegated = shared.clone().with_session_delegate(auth.did.clone(), vm);
&delegated
}
_ => shared,
};
let peer = match auth.as_ref() {
Some(auth) => auth.did.clone(),
None => doc.issuer.clone().unwrap_or_default(),
};
let transport = HttpsHandler::new(my_vid.to_string(), (!peer.is_empty()).then(|| peer.clone()));
let https = crate::messaging::HttpsFacts {
ip: client_ip,
headers,
body_len: body.len(),
};
match crate::messaging::dispatch_trust_task_doc(
&state,
&peer,
auth.as_ref(),
Some(&https),
&transport,
doc,
verifier,
large_doc_charge,
)
.await
.map_err(|e| AppError::Internal(e.to_string()))?
{
crate::messaging::RoutedReply::Framework(outcome) => Ok(into_response(*outcome)),
crate::messaging::RoutedReply::Document(value) => {
let status = document_status(&value);
Ok((
status,
[(axum::http::header::CONTENT_TYPE, "application/json")],
serde_json::to_vec(&value).expect("Trust Task response serialises"),
)
.into_response())
}
crate::messaging::RoutedReply::Suppressed => Ok(StatusCode::NO_CONTENT.into_response()),
crate::messaging::RoutedReply::Sealed(sealed) => Ok(sealed_response(sealed)),
}
}
fn sealed_response(sealed: crate::control_tasks::SealedReply) -> Response {
use axum::http::{HeaderValue, header};
let mut resp = match sealed {
crate::control_tasks::SealedReply::Document { status, body } => (
StatusCode::from_u16(status).unwrap_or(StatusCode::BAD_REQUEST),
axum::Json(body),
)
.into_response(),
crate::control_tasks::SealedReply::Unavailable(message) => {
(StatusCode::SERVICE_UNAVAILABLE, message).into_response()
}
};
let h = resp.headers_mut();
h.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
h.insert(
header::REFERRER_POLICY,
HeaderValue::from_static("no-referrer"),
);
resp
}
fn body_parse_error(reason: &str) -> trust_tasks_rs::ErrorResponse {
let reject = RejectReason::MalformedRequest {
reason: format!("body did not parse as a Trust Task document: {reason}"),
};
let payload: ErrorPayload = reject.into();
trust_tasks_rs::ErrorResponse {
id: format!("urn:uuid:{}", Uuid::new_v4()),
thread_id: None,
parent_thread_id: None,
ceremony: None,
type_uri: error_type_uri(),
issuer: None,
recipient: None,
issued_at: Some(chrono::Utc::now()),
expires_at: None,
payload,
context: None,
proof: None,
extra: Default::default(),
}
}
fn error_type_uri() -> trust_tasks_rs::TypeUri {
did_hosting_common::server::trust_tasks::framework_error_type_uri()
}
fn into_response(outcome: DispatchOutcome) -> Response {
match outcome {
DispatchOutcome::Handled(doc) => {
let body = serde_json::to_vec(&doc)
.expect("Handled response document serialises (TrustTask<Value>)");
(
StatusCode::OK,
[(axum::http::header::CONTENT_TYPE, "application/json")],
body,
)
.into_response()
}
DispatchOutcome::Rejected(err_doc) => {
let status_u16 = status_for_code(&err_doc.payload.code);
let status = StatusCode::from_u16(status_u16).unwrap_or_else(|_| {
tracing::error!(status_u16, "unexpected status code from status_for_code");
StatusCode::INTERNAL_SERVER_ERROR
});
let body =
serde_json::to_vec(&err_doc).expect("error document serialises (trust-task-error)");
(
status,
[(axum::http::header::CONTENT_TYPE, "application/json")],
body,
)
.into_response()
}
DispatchOutcome::Suppressed => {
tracing::error!(
should_not_happen = true,
"trust-tasks dispatch suppressed its reply on HTTPS"
);
StatusCode::NO_CONTENT.into_response()
}
}
}
fn document_status(value: &Value) -> StatusCode {
let error_type_uri =
did_hosting_common::server::trust_tasks::framework_error_type_uri().to_string();
let is_error = value
.get("type")
.and_then(Value::as_str)
.map(|t| t == error_type_uri)
.unwrap_or(false);
if !is_error {
return StatusCode::OK;
}
let Some(code) = value
.get("payload")
.and_then(|p| p.get("code"))
.and_then(Value::as_str)
else {
return StatusCode::INTERNAL_SERVER_ERROR;
};
let Ok(code) = code.parse::<trust_tasks_rs::TrustTaskCode>() else {
return StatusCode::INTERNAL_SERVER_ERROR;
};
StatusCode::from_u16(status_for_code(&code)).unwrap_or_else(|_| {
tracing::error!(code = %code, "unexpected status code from status_for_code");
StatusCode::INTERNAL_SERVER_ERROR
})
}
#[cfg(test)]
mod tests {
use trust_tasks_rs::{Payload, specs::acl::grant::v0_1 as grant};
#[test]
fn body_parse_error_shape() {
let err = super::body_parse_error("expected `,`");
assert!(err.id.starts_with("urn:uuid:"));
assert!(err.thread_id.is_none());
assert_eq!(
err.type_uri,
did_hosting_common::server::trust_tasks::framework_error_type_uri(),
);
assert!(err.issuer.is_none());
assert!(err.recipient.is_none());
assert_eq!(
err.payload.code,
trust_tasks_rs::TrustTaskCode::Standard(trust_tasks_rs::StandardCode::MalformedRequest)
);
assert!(
err.payload
.message
.as_deref()
.unwrap()
.contains("did not parse as a Trust Task document")
);
}
#[test]
fn well_formed_grant_envelope_round_trips() {
let body = serde_json::json!({
"id": "urn:uuid:5b3c5e2a-1b81-4d3e-9b51-7a3c89e3d1f2",
"type": grant::Payload::TYPE_URI,
"issuer": "did:web:admin.example",
"recipient": "did:web:maintainer.example",
"issuedAt": "2026-05-18T10:00:00Z",
"payload": {
"entry": {
"subject": "did:web:alice.example",
"role": "owner",
"ext": {
"vnd.affinidi.webvh": {
"domains": { "kind": "all" }
}
}
}
}
});
let doc: trust_tasks_rs::TrustTask<serde_json::Value> =
serde_json::from_value(body).expect("envelope parses");
assert_eq!(doc.type_uri.to_string(), grant::Payload::TYPE_URI);
}
}