use serde_json::Value;
use tracing::warn;
use trust_tasks_rs::{
Dispatcher, ErrorPayload, ErrorResponse, ProofPolicy, ProofVerifier, ResolvedParties,
StandardCode, TransportHandler, TrustTask,
specs::auth::{
authenticate::v0_2 as authenticate_v2, authenticate::v0_3 as authenticate,
challenge::v0_1 as challenge, refresh::v0_1 as refresh_v1, refresh::v0_2 as refresh,
},
};
use did_hosting_common::epoch_to_rfc3339;
use did_hosting_common::server::auth::session::{get_session, get_session_by_refresh, now_epoch};
use did_hosting_common::server::trust_tasks::{DispatchOutcome, run_pipeline};
use vti_common::auth::backend::{AuthenticateInput, RefreshInput};
use vti_common::auth::handlers::{handle_authenticate, handle_refresh};
use crate::server::AppState;
enum TypedAuth {
Challenge(Box<TrustTask<challenge::Payload>>),
AuthenticateV2(Box<TrustTask<authenticate_v2::Payload>>),
AuthenticateV3(Box<TrustTask<authenticate::Payload>>),
RefreshV1(Box<TrustTask<refresh_v1::Payload>>),
RefreshV2(Box<TrustTask<refresh::Payload>>),
}
fn build_auth_dispatcher() -> Dispatcher<TypedAuth> {
Dispatcher::new()
.on::<challenge::Payload, _>(|d| TypedAuth::Challenge(Box::new(d)))
.on::<authenticate_v2::Payload, _>(|d| TypedAuth::AuthenticateV2(Box::new(d)))
.on::<authenticate::Payload, _>(|d| TypedAuth::AuthenticateV3(Box::new(d)))
.on::<refresh_v1::Payload, _>(|d| TypedAuth::RefreshV1(Box::new(d)))
.on::<refresh::Payload, _>(|d| TypedAuth::RefreshV2(Box::new(d)))
}
pub fn owns(type_uri: &str) -> bool {
build_auth_dispatcher()
.registered_uris()
.contains(&type_uri)
}
pub async fn dispatch<V>(
state: &AppState,
transport: &(impl TransportHandler + Sync),
policy: ProofPolicy<'_, V>,
doc: TrustTask<Value>,
) -> Option<Value>
where
V: ProofVerifier + ?Sized,
{
let error_id = format!("urn:uuid:{}", uuid::Uuid::new_v4());
let typed = match build_auth_dispatcher().dispatch_or_reject(doc, error_id) {
Ok(t) => t,
Err(err) => return Some(serialise(&err)),
};
let outcome = match typed {
TypedAuth::Challenge(d) => {
run_pipeline(
transport,
policy,
*d,
my_vid(state)?,
|doc, parties| async move { challenge_arm(state, doc, &parties).await },
)
.await
}
TypedAuth::AuthenticateV2(d) => {
run_pipeline(
transport,
policy,
*d,
my_vid(state)?,
|doc, parties| async move { authenticate_v2_arm(state, doc, &parties).await },
)
.await
}
TypedAuth::AuthenticateV3(d) => {
run_pipeline(
transport,
policy,
*d,
my_vid(state)?,
|doc, parties| async move { authenticate_v3_arm(state, doc, &parties).await },
)
.await
}
TypedAuth::RefreshV1(d) => {
run_pipeline(
transport,
policy,
*d,
my_vid(state)?,
|doc, parties| async move { refresh_v1_arm(state, doc, &parties).await },
)
.await
}
TypedAuth::RefreshV2(d) => {
run_pipeline(
transport,
policy,
*d,
my_vid(state)?,
|doc, parties| async move { refresh_v2_arm(state, doc, &parties).await },
)
.await
}
};
match outcome {
DispatchOutcome::Handled(resp) => Some(serialise(&resp)),
DispatchOutcome::Rejected(err) => Some(serialise(&err)),
DispatchOutcome::Suppressed => None,
}
}
fn my_vid(state: &AppState) -> Option<&str> {
match state.config.server_did.as_deref() {
Some(v) => Some(v),
None => {
warn!("trust_tasks_auth: server_did not configured; cannot answer an auth task");
None
}
}
}
fn serialise<T: serde::Serialize>(doc: &T) -> Value {
serde_json::to_value(doc).expect("response document serialises")
}
#[allow(clippy::result_large_err)]
fn caller<P>(doc: &TrustTask<P>, parties: &ResolvedParties) -> Result<String, ErrorResponse> {
parties.issuer.clone().ok_or_else(|| {
doc.reject_with(
format!("urn:uuid:{}", uuid::Uuid::new_v4()),
ErrorPayload::new(StandardCode::PermissionDenied).with_message(
"inbound document has no in-band or transport-derived issuer, so there is \
no verified identity to authenticate",
),
)
})
}
fn denied<P>(doc: &TrustTask<P>, what: &str, err: impl std::fmt::Display) -> ErrorResponse {
warn!(error = %err, op = what, "auth trust task refused");
doc.reject_with(
format!("urn:uuid:{}", uuid::Uuid::new_v4()),
ErrorPayload::new(StandardCode::PermissionDenied).with_message("authentication refused"),
)
}
#[allow(clippy::result_large_err)]
async fn challenge_arm(
state: &AppState,
doc: TrustTask<challenge::Payload>,
parties: &ResolvedParties,
) -> Result<TrustTask<vta_sdk::protocols::auth::ChallengeResponse>, ErrorResponse> {
let did = caller(&doc, parties)?;
let resp = crate::routes::auth::issue_challenge(state, did)
.await
.map_err(|e| denied(&doc, "challenge", e))?;
Ok(doc.respond_with(format!("urn:uuid:{}", uuid::Uuid::new_v4()), resp))
}
fn ed25519_session_key(session_key: &str) -> Option<String> {
let multikey = session_key.strip_prefix("did:key:")?;
did_hosting_common::server::auth::session::is_ed25519_multikey(multikey)
.then(|| multikey.to_string())
}
#[allow(clippy::result_large_err)]
async fn authenticate_v2_arm(
state: &AppState,
doc: TrustTask<authenticate_v2::Payload>,
parties: &ResolvedParties,
) -> Result<TrustTask<authenticate_v2::Response>, ErrorResponse> {
let signer_did = caller(&doc, parties)?;
let requested_key = doc.payload.session_key.as_ref().map(|k| k.to_string());
let session_pubkey_b58btc = match requested_key.as_deref() {
None => None,
Some(key) => Some(ed25519_session_key(key).ok_or_else(|| {
warn!(session_key = %key, "authenticate refused: unsupported session key type");
doc.reject_with(
format!("urn:uuid:{}", uuid::Uuid::new_v4()),
ErrorPayload::from(authenticate_v2::error_codes::SESSION_KEY_UNSUPPORTED)
.with_message("this service binds Ed25519 did:key session keys only")
.with_details(serde_json::json!({ "requested": key })),
)
})?),
};
let backend = crate::auth::DidHostingControlAuthBackend::from_state(state)
.map_err(|e| denied(&doc, "authenticate/backend", e))?;
let session_id = doc.payload.session_id.to_string();
let resp = handle_authenticate(
&backend,
AuthenticateInput {
session_id: session_id.clone(),
challenge: doc.payload.challenge.to_string(),
signer_did,
created_time: None,
session_pubkey_b58btc,
audience: vti_common::auth::AudienceBinding::Transport,
},
)
.await
.map_err(|e| denied(&doc, "authenticate", e))?;
state.pending_challenges.release_session(&session_id);
store_auth_proxy_meta(
state,
&resp.session.id,
None,
now_epoch() + state.config.auth.absolute_session_lifetime,
)
.await
.map_err(|e| denied(&doc, "authenticate/meta", e))?;
let mut value =
serde_json::to_value(&resp).map_err(|e| denied(&doc, "authenticate/response", e))?;
if let Some(key) = requested_key {
value["session"]["sessionKey"] = Value::String(key);
}
let payload: authenticate_v2::Response =
serde_json::from_value(value).map_err(|e| denied(&doc, "authenticate/response", e))?;
Ok(doc.respond_with(format!("urn:uuid:{}", uuid::Uuid::new_v4()), payload))
}
const MANDATE_EVIDENCE_KIND: &str = "mandateCredential";
const MANDATE_EVIDENCE_TYPE_URI: &str =
"https://trusttasks.org/spec/local/did-hosting-auth-mandate/0.1";
#[derive(serde::Deserialize)]
struct MandatePayload {
delegate: String,
}
const SIOP_ID_TOKEN_EVIDENCE_KIND: &str = "siopIdToken";
#[derive(serde::Deserialize)]
struct SiopIdTokenCredential {
#[serde(rename = "idToken")]
id_token: String,
}
async fn verify_delegation_evidence(
state: &AppState,
principal: &str,
delegate: &str,
challenge: &str,
evidence: &authenticate::PayloadDelegationEvidence,
) -> Result<(), String> {
match evidence.kind.as_str() {
MANDATE_EVIDENCE_KIND => {
if evidence.credential.is_empty() {
return Err("mandateCredential requires an inline credential".into());
}
let mandate: TrustTask<Value> = serde_json::from_value(Value::Object(
evidence.credential.clone(),
))
.map_err(|e| format!("credential does not parse as a Trust Task document: {e}"))?;
if mandate.type_uri.to_string() != MANDATE_EVIDENCE_TYPE_URI {
return Err("credential is not a did-hosting-auth-mandate document".into());
}
if mandate.issuer.as_deref() != Some(principal) {
return Err("credential.issuer does not name the principal".into());
}
if mandate.recipient.as_deref() != state.config.server_did.as_deref() {
return Err("credential.recipient does not name this service".into());
}
if let Some(expires_at) = mandate.expires_at
&& expires_at < chrono::Utc::now()
{
return Err("credential has expired".into());
}
let mandate_payload: MandatePayload =
serde_json::from_value(mandate.payload.clone())
.map_err(|e| format!("credential.payload does not name a delegate: {e}"))?;
if mandate_payload.delegate != delegate {
return Err("credential does not name this delegate".into());
}
if mandate.proof.as_ref().map(|p| p.proof_purpose.as_str()) != Some("assertionMethod") {
return Err("credential proof must be an assertionMethod attestation".into());
}
let verifier = state
.trust_tasks_verifier
.as_ref()
.ok_or_else(|| "no proof verifier configured".to_string())?;
verifier
.verify(&mandate)
.await
.map_err(|e| format!("credential proof failed verification: {e}"))?;
Ok(())
}
SIOP_ID_TOKEN_EVIDENCE_KIND => {
if evidence.credential.is_empty() {
return Err("siopIdToken requires an inline credential".into());
}
let cred: SiopIdTokenCredential =
serde_json::from_value(Value::Object(evidence.credential.clone()))
.map_err(|e| format!("credential is not a siopIdToken payload: {e}"))?;
let did_resolver = state
.did_resolver
.as_ref()
.ok_or_else(|| "no DID resolver configured".to_string())?;
let verified = did_hosting_common::server::didcomm_unpack::verify_siop_id_token(
&cred.id_token,
did_resolver,
)
.await
.map_err(|e| format!("id_token failed verification: {e}"))?;
if verified.issuer != principal {
return Err("id_token iss/sub does not name the principal".into());
}
let own_vid = state
.config
.server_did
.as_deref()
.ok_or_else(|| "server_did not configured".to_string())?;
if verified.audience != own_vid {
return Err("id_token aud does not name this service".into());
}
if verified.nonce != challenge {
return Err("id_token nonce does not match this authenticate's challenge".into());
}
let now = now_epoch();
const CLOCK_SKEW_SECS: u64 = 60;
if verified.expires_at <= now {
return Err("id_token has expired".into());
}
if verified.issued_at > now + CLOCK_SKEW_SECS {
return Err("id_token iat is in the future".into());
}
if verified.issued_at > verified.expires_at {
return Err("id_token iat is after exp".into());
}
Ok(())
}
other => Err(format!("unrecognized delegationEvidence.kind {other:?}")),
}
}
#[derive(serde::Serialize, serde::Deserialize)]
struct AuthProxyMeta {
#[serde(default, skip_serializing_if = "Option::is_none")]
actor: Option<String>,
absolute_expires_at: u64,
}
fn auth_proxy_meta_key(session_id: &str) -> String {
format!("auth-proxy-meta:{session_id}")
}
pub(crate) async fn store_auth_proxy_meta(
state: &AppState,
session_id: &str,
actor: Option<String>,
absolute_expires_at: u64,
) -> Result<(), crate::error::AppError> {
state
.sessions_ks
.insert(
auth_proxy_meta_key(session_id),
&AuthProxyMeta {
actor,
absolute_expires_at,
},
)
.await?;
Ok(())
}
async fn get_auth_proxy_meta(
state: &AppState,
session_id: &str,
) -> Result<Option<AuthProxyMeta>, crate::error::AppError> {
state.sessions_ks.get(auth_proxy_meta_key(session_id)).await
}
pub(crate) async fn delete_auth_proxy_meta(
state: &AppState,
session_id: &str,
) -> Result<(), crate::error::AppError> {
state
.sessions_ks
.remove(auth_proxy_meta_key(session_id))
.await
}
#[allow(clippy::result_large_err)]
async fn authenticate_v3_arm(
state: &AppState,
doc: TrustTask<authenticate::Payload>,
parties: &ResolvedParties,
) -> Result<TrustTask<authenticate::Response>, ErrorResponse> {
let signer_did = caller(&doc, parties)?;
let requested_key = doc.payload.session_key.as_ref().map(|k| k.to_string());
let session_pubkey_b58btc = match requested_key.as_deref() {
None => None,
Some(key) => Some(ed25519_session_key(key).ok_or_else(|| {
warn!(session_key = %key, "authenticate refused: unsupported session key type");
doc.reject_with(
format!("urn:uuid:{}", uuid::Uuid::new_v4()),
ErrorPayload::from(authenticate::error_codes::SESSION_KEY_UNSUPPORTED)
.with_message("this service binds Ed25519 did:key session keys only")
.with_details(serde_json::json!({ "requested": key })),
)
})?),
};
let principal = doc.payload.principal.as_ref().map(|p| p.to_string());
let proxied = principal.as_deref().is_some_and(|p| p != signer_did);
let acting_party = if proxied {
principal.clone().expect("proxied implies principal")
} else {
signer_did.clone()
};
if proxied {
let Some(evidence) = doc.payload.delegation_evidence.as_ref() else {
return Err(doc.reject_with(
format!("urn:uuid:{}", uuid::Uuid::new_v4()),
ErrorPayload::from(authenticate::error_codes::DELEGATION_EVIDENCE_REQUIRED)
.with_message("a proxied authenticate must carry delegationEvidence"),
));
};
if let Err(reason) = verify_delegation_evidence(
state,
&acting_party,
&signer_did,
doc.payload.challenge.as_str(),
evidence,
)
.await
{
warn!(
principal = %acting_party,
delegate = %signer_did,
reason = %reason,
"proxied authenticate refused: delegation not recognized",
);
return Err(doc.reject_with(
format!("urn:uuid:{}", uuid::Uuid::new_v4()),
ErrorPayload::from(authenticate::error_codes::DELEGATION_NOT_RECOGNIZED)
.with_message("delegation evidence did not establish the entitlement"),
));
}
}
let backend = crate::auth::DidHostingControlAuthBackend::from_state(state)
.map_err(|e| denied(&doc, "authenticate/backend", e))?;
let session_id = doc.payload.session_id.to_string();
let resp = handle_authenticate(
&backend,
AuthenticateInput {
session_id: session_id.clone(),
challenge: doc.payload.challenge.to_string(),
signer_did: acting_party.clone(),
created_time: None,
session_pubkey_b58btc,
audience: vti_common::auth::AudienceBinding::Transport,
},
)
.await
.map_err(|e| denied(&doc, "authenticate", e))?;
state.pending_challenges.release_session(&session_id);
let absolute_expires_at = now_epoch() + state.config.auth.absolute_session_lifetime;
let actor = proxied.then(|| signer_did.clone());
store_auth_proxy_meta(state, &resp.session.id, actor.clone(), absolute_expires_at)
.await
.map_err(|e| denied(&doc, "authenticate/meta", e))?;
let mut value =
serde_json::to_value(&resp).map_err(|e| denied(&doc, "authenticate/response", e))?;
if let Some(key) = requested_key {
value["session"]["sessionKey"] = Value::String(key);
}
if let Some(actor) = actor {
value["session"]["actor"] = Value::String(actor);
}
value["session"]["absoluteExpiresAt"] = Value::String(epoch_to_rfc3339(absolute_expires_at));
let payload: authenticate::Response =
serde_json::from_value(value).map_err(|e| denied(&doc, "authenticate/response", e))?;
Ok(doc.respond_with(format!("urn:uuid:{}", uuid::Uuid::new_v4()), payload))
}
#[allow(clippy::result_large_err)]
async fn refresh_v1_arm(
state: &AppState,
doc: TrustTask<refresh_v1::Payload>,
parties: &ResolvedParties,
) -> Result<TrustTask<vta_sdk::protocols::auth::AuthenticateResponse>, ErrorResponse> {
let signer_did = caller(&doc, parties)?;
let backend = crate::auth::DidHostingControlAuthBackend::from_state(state)
.map_err(|e| denied(&doc, "refresh/backend", e))?;
let resp = handle_refresh(
&backend,
RefreshInput {
refresh_token: doc.payload.refresh_token.to_string(),
signer_did: Some(signer_did),
},
)
.await
.map_err(|e| denied(&doc, "refresh", e))?;
Ok(doc.respond_with(format!("urn:uuid:{}", uuid::Uuid::new_v4()), resp))
}
fn proof_probe<P: serde::Serialize>(doc: &TrustTask<P>) -> TrustTask<Value> {
let value = serde_json::to_value(doc).expect("TrustTask serialises");
serde_json::from_value(value).expect("TrustTask<Value> re-parses from its own serialisation")
}
#[allow(clippy::result_large_err)]
async fn refresh_v2_arm(
state: &AppState,
doc: TrustTask<refresh::Payload>,
parties: &ResolvedParties,
) -> Result<TrustTask<refresh::Response>, ErrorResponse> {
let signer_did = parties.issuer.clone();
let refresh_token = doc.payload.refresh_token.to_string();
let mut bound_key_verified = false;
if let Some(session_id) = get_session_by_refresh(&state.sessions_ks, &refresh_token)
.await
.map_err(|e| denied(&doc, "refresh/peek", e))?
&& let Some(session) = get_session(&state.sessions_ks, &session_id)
.await
.map_err(|e| denied(&doc, "refresh/peek", e))?
{
crate::routes::auth::refuse_if_idle_session(&session, state.config.auth.admin_idle_timeout)
.map_err(|e| denied(&doc, "refresh/idle", e))?;
crate::routes::auth::verify_session_bound_proof(&proof_probe(&doc), &session)
.await
.map_err(|e| {
warn!(session_id = %session_id, error = %e, "refresh refused: session-key proof required");
doc.reject_with(
format!("urn:uuid:{}", uuid::Uuid::new_v4()),
ErrorPayload::from(refresh::error_codes::SESSION_KEY_PROOF_REQUIRED)
.with_message(
"a session that bound a key must refresh with that key's proof",
),
)
})?;
bound_key_verified = session.session_pubkey_b58btc.is_some();
if let Some(meta) = get_auth_proxy_meta(state, &session_id)
.await
.map_err(|e| denied(&doc, "refresh/meta", e))?
&& session
.refresh_expires_at
.is_some_and(|expires| expires >= meta.absolute_expires_at)
{
warn!(session_id = %session_id, "refresh refused: absolute session lifetime reached");
return Err(doc.reject_with(
format!("urn:uuid:{}", uuid::Uuid::new_v4()),
ErrorPayload::from(refresh::error_codes::SESSION_LIFETIME_EXCEEDED).with_message(
"the session has reached its absolute lifetime; re-authenticate via \
auth/authenticate",
),
));
}
}
let backend = crate::auth::DidHostingControlAuthBackend::from_state(state)
.map_err(|e| denied(&doc, "refresh/backend", e))?;
let resp = handle_refresh(
&backend,
RefreshInput {
refresh_token,
signer_did: if bound_key_verified { None } else { signer_did },
},
)
.await
.map_err(|e| denied(&doc, "refresh", e))?;
let new_session_id = resp.session.id.clone();
let mut value = serde_json::to_value(&resp).map_err(|e| denied(&doc, "refresh/response", e))?;
if let Some(meta) = get_auth_proxy_meta(state, &new_session_id)
.await
.map_err(|e| denied(&doc, "refresh/meta", e))?
{
if let Some(mut session) = get_session(&state.sessions_ks, &new_session_id)
.await
.map_err(|e| denied(&doc, "refresh/meta", e))?
&& let Some(refresh_expires_at) = session.refresh_expires_at
&& refresh_expires_at > meta.absolute_expires_at
{
session.refresh_expires_at = Some(meta.absolute_expires_at);
did_hosting_common::server::auth::session::store_session(&state.sessions_ks, &session)
.await
.map_err(|e| denied(&doc, "refresh/meta", e))?;
let now = now_epoch();
value["tokens"]["refreshExpiresIn"] =
serde_json::json!(meta.absolute_expires_at.saturating_sub(now));
}
value["session"]["absoluteExpiresAt"] =
Value::String(epoch_to_rfc3339(meta.absolute_expires_at));
if let Some(actor) = meta.actor {
value["session"]["actor"] = Value::String(actor);
}
}
let payload: refresh::Response =
serde_json::from_value(value).map_err(|e| denied(&doc, "refresh/response", e))?;
Ok(doc.respond_with(format!("urn:uuid:{}", uuid::Uuid::new_v4()), payload))
}
#[cfg(test)]
mod tests {
use super::*;
use trust_tasks_rs::Payload as PayloadSpec;
#[test]
fn owns_the_five_request_uris_and_not_their_responses() {
for uri in [
<challenge::Payload as PayloadSpec>::TYPE_URI,
<authenticate_v2::Payload as PayloadSpec>::TYPE_URI,
<authenticate::Payload as PayloadSpec>::TYPE_URI,
<refresh_v1::Payload as PayloadSpec>::TYPE_URI,
<refresh::Payload as PayloadSpec>::TYPE_URI,
] {
assert!(owns(uri), "{uri} must be owned");
assert!(
!owns(&format!("{uri}#response")),
"{uri}#response must NOT be owned",
);
}
}
#[test]
fn owns_nothing_outside_the_family() {
assert!(!owns("https://trusttasks.org/spec/acl/list/0.1"));
assert!(!owns("https://trusttasks.org/spec/auth/whoami/0.1"));
assert!(!owns("not a uri"));
}
}