use std::net::SocketAddr;
use axum::Json;
use axum::extract::{ConnectInfo, State};
use axum::http::HeaderMap;
use axum::response::{IntoResponse, Response};
use serde_json::Value;
use tracing::{info, warn};
use trust_tasks_rs::{ProofVerifier, TrustTask};
use did_hosting_common::server::auth::constant_time_eq;
use did_hosting_common::{ChallengeRequest, ChallengeResponse};
use crate::auth::AuthClaims;
use crate::auth::session::{self, now_epoch};
use crate::error::AppError;
use crate::rate_limit::resolve_client_ip;
use crate::server::AppState;
const MAX_PENDING_CHALLENGES_PER_DID: u64 = 10;
pub async fn challenge(
State(state): State<AppState>,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
headers: HeaderMap,
Json(req): Json<ChallengeRequest>,
) -> Result<Json<ChallengeResponse>, AppError> {
if req.did.len() > 512 {
return Err(AppError::Validation("DID exceeds maximum length".into()));
}
let xff = headers.get("x-forwarded-for").and_then(|v| v.to_str().ok());
let client_ip = resolve_client_ip(addr.ip(), xff, &state.config.server.trusted_proxies);
state
.ip_rate_limiter
.try_consume(client_ip, now_epoch())
.inspect_err(|e| {
warn!(ip = %client_ip, error = %e, "challenge IP rate limited");
})?;
state
.pending_challenges
.try_issue(&req.did, MAX_PENDING_CHALLENGES_PER_DID)
.await
.inspect_err(|e| {
warn!(did = %req.did, error = %e, "challenge rate limited");
})?;
let backend = crate::auth::DidHostingControlAuthBackend::from_state(&state)?;
let canonical = vti_common::auth::handlers::handle_challenge(
&backend,
vti_common::auth::ChallengeInput {
did: req.did,
session_pubkey_b58btc: None,
},
)
.await?;
Ok(Json(ChallengeResponse {
challenge: canonical.challenge,
session_id: canonical.session_id,
expires_at: canonical.expires_at,
}))
}
pub async fn authenticate(
State(state): State<AppState>,
body: axum::body::Bytes,
) -> Result<Response, AppError> {
use did_hosting_common::AuthenticatePayload;
use did_hosting_common::server::didcomm_unpack;
let (did_resolver, _secrets_resolver, _jwt_keys) = state.require_didcomm_auth()?;
if is_didcomm_jws_envelope(&body) {
return authenticate_didcomm_jws(&state, &body, did_resolver).await;
}
#[derive(serde::Deserialize)]
struct AuthEnvelope {
#[serde(rename = "type")]
type_uri: String,
payload: serde_json::Value,
}
let envelope: AuthEnvelope = match serde_json::from_slice(&body) {
Ok(e) => e,
Err(e) => {
return Ok(trust_task_malformed(&format!(
"body did not parse as an authenticate envelope: {e}"
)));
}
};
let expected_type = did_hosting_common::did_hosting_tasks::TASK_AUTH_AUTHENTICATE_0_1.as_str();
if envelope.type_uri != expected_type {
return Ok(trust_task_malformed(&format!(
"unexpected Trust-Task type: expected {expected_type}, got {}",
envelope.type_uri
)));
}
let payload: AuthenticatePayload = match serde_json::from_value(envelope.payload) {
Ok(p) => p,
Err(e) => {
return Ok(trust_task_malformed(&format!(
"authenticate payload malformed: {e}"
)));
}
};
let verified = didcomm_unpack::verify_siop_id_token(&payload.id_token, did_resolver).await?;
let rp_id = state.config.server_did.as_deref().ok_or_else(|| {
AppError::Config("server_did not configured; cannot verify id_token `aud`".into())
})?;
if verified.audience != rp_id {
warn!(
expected = %rp_id,
actual = %verified.audience,
"authentication rejected: id_token `aud` does not match this service",
);
return Err(AppError::Authentication(
"id_token `aud` does not match this service".into(),
));
}
let now = now_epoch();
const CLOCK_SKEW_SECS: u64 = 60;
if verified.expires_at <= now {
return Err(AppError::Authentication("id_token has expired".into()));
}
if verified.issued_at > now + CLOCK_SKEW_SECS {
return Err(AppError::Authentication(
"id_token `iat` is in the future".into(),
));
}
if verified.issued_at > verified.expires_at {
return Err(AppError::Authentication(
"id_token `iat` is after `exp`".into(),
));
}
let session_pubkey_b58btc = if let Some(pk) = payload.session_pubkey_b58btc.as_deref() {
if !pk.starts_with("z6Mk") {
warn!(prefix = %&pk[..pk.len().min(8)], "rejected unsupported session-key shape");
return Err(AppError::Authentication(
"session_pubkey_b58btc must be an Ed25519 multikey (z6Mk… prefix)".into(),
));
}
Some(pk.to_string())
} else {
None
};
let signer_did = verified.issuer.clone();
let backend = crate::auth::DidHostingControlAuthBackend::from_state(&state)?;
let result = vti_common::auth::handlers::handle_authenticate(
&backend,
vti_common::auth::AuthenticateInput {
session_id: payload.session_id.clone(),
challenge: verified.nonce.clone(),
signer_did: signer_did.clone(),
created_time: None,
session_pubkey_b58btc,
},
)
.await;
match result {
Ok(resp) => {
state.pending_challenges.release(&signer_did).await;
info!(did = %signer_did, "authenticated via SIOPv2 id_token");
Ok(Json(canonical_to_local_auth_response(resp)).into_response())
}
Err(e) => Err(e),
}
}
fn is_didcomm_jws_envelope(body: &[u8]) -> bool {
let Ok(value) = serde_json::from_slice::<serde_json::Value>(body) else {
return false;
};
value
.get("signatures")
.and_then(|s| s.as_array())
.is_some_and(|arr| !arr.is_empty())
}
async fn authenticate_didcomm_jws(
state: &AppState,
body: &[u8],
did_resolver: &affinidi_did_resolver_cache_sdk::DIDCacheClient,
) -> Result<Response, AppError> {
use did_hosting_common::server::didcomm_unpack;
let body_str = std::str::from_utf8(body)
.map_err(|e| AppError::Authentication(format!("JWS body is not valid UTF-8: {e}")))?;
let (msg, signer_did) = didcomm_unpack::unpack_signed(body_str, did_resolver).await?;
if !matches!(
msg.typ.as_str(),
"https://affinidi.com/webvh/1.0/authenticate"
| "https://trusttasks.org/spec/auth/authenticate/0.1"
) {
return Err(AppError::Authentication(format!(
"unexpected message type: {}",
msg.typ
)));
}
let challenge = msg
.body
.get("challenge")
.and_then(|v| v.as_str())
.ok_or_else(|| AppError::Authentication("missing challenge in message body".into()))?
.to_string();
let session_id = msg
.body
.get("session_id")
.and_then(|v| v.as_str())
.ok_or_else(|| AppError::Authentication("missing session_id in message body".into()))?
.to_string();
let backend = crate::auth::DidHostingControlAuthBackend::from_state(state)?;
let result = vti_common::auth::handlers::handle_authenticate(
&backend,
vti_common::auth::AuthenticateInput {
session_id,
challenge,
signer_did: signer_did.clone(),
created_time: msg.created_time,
session_pubkey_b58btc: None,
},
)
.await;
match result {
Ok(resp) => {
state.pending_challenges.release(&signer_did).await;
info!(did = %signer_did, "authenticated via DIDComm-JWS envelope");
Ok(Json(canonical_to_local_auth_response(resp)).into_response())
}
Err(e) => Err(e),
}
}
fn canonical_to_local_auth_response(
a: vta_sdk::protocols::auth::AuthenticateResponse,
) -> did_hosting_common::AuthenticateResponse {
did_hosting_common::AuthenticateResponse {
session: did_hosting_common::Session {
id: a.session.id,
subject: a.session.subject,
issued_at: a.session.issued_at,
expires_at: a.session.expires_at,
amr: a.session.amr,
acr: a.session.acr,
},
tokens: did_hosting_common::TokenBundle {
access_token: a.tokens.access_token,
refresh_token: a.tokens.refresh_token,
token_type: a.tokens.token_type,
expires_in: a.tokens.expires_in,
refresh_expires_in: a.tokens.refresh_expires_in,
scope: a.tokens.scope,
},
}
}
fn trust_task_malformed(reason: &str) -> Response {
use trust_tasks_https::status_for_code;
use trust_tasks_rs::{ErrorPayload, RejectReason};
use uuid::Uuid;
let reject = RejectReason::MalformedRequest {
reason: reason.to_string(),
};
let payload: ErrorPayload = reject.into();
let status_u16 = status_for_code(&payload.code);
let status =
axum::http::StatusCode::from_u16(status_u16).unwrap_or(axum::http::StatusCode::BAD_REQUEST);
let err_doc = trust_tasks_rs::ErrorResponse {
id: format!("urn:uuid:{}", Uuid::new_v4()),
thread_id: None,
type_uri: "https://trusttasks.org/spec/trust-task-error/0.1"
.parse()
.expect("framework error Type URI parses"),
issuer: None,
recipient: None,
issued_at: Some(chrono::Utc::now()),
expires_at: None,
payload,
context: None,
proof: None,
extra: Default::default(),
};
let body = serde_json::to_vec(&err_doc).expect("error document serialises");
(
status,
[(axum::http::header::CONTENT_TYPE, "application/json")],
body,
)
.into_response()
}
async fn build_signed_step_up_approve_request(
control_did: &str,
signing_secret: &affinidi_tdk::secrets_resolver::secrets::Secret,
subject: &str,
session_id: &str,
challenge: &str,
reason: &str,
) -> Result<Value, AppError> {
use did_hosting_common::did_hosting_tasks::TASK_AUTH_STEP_UP_VTA_START_0_2;
let unsigned = serde_json::json!({
"id": format!("urn:uuid:{}", uuid::Uuid::new_v4()),
"type": TASK_AUTH_STEP_UP_VTA_START_0_2.as_str(),
"issuer": control_did,
"recipient": subject,
"issuedAt": chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
"payload": {
"subject": subject,
"sessionId": session_id,
"challenge": challenge,
"reason": reason,
},
});
crate::signing::sign_trust_task_document(unsigned, signing_secret).await
}
pub async fn step_up_vta_start(
auth: AuthClaims,
State(state): State<AppState>,
) -> Result<Json<serde_json::Value>, AppError> {
let control_did = state.config.server_did.as_deref().ok_or_else(|| {
AppError::Config("server_did not configured; cannot sign the step-up request".into())
})?;
let signing_secret = crate::signing::control_assertion_secret(&state, control_did)?;
let challenge = rand::random::<[u8; 32]>()
.iter()
.map(|b| format!("{b:02x}"))
.collect::<String>();
let reason = "Elevate this session to aal2";
let document = build_signed_step_up_approve_request(
control_did,
&signing_secret,
&auth.did,
&auth.session_id,
&challenge,
reason,
)
.await?;
state
.sessions_ks
.insert_raw(
format!("stepup-nonce:{}", auth.session_id),
challenge.as_bytes().to_vec(),
)
.await?;
Ok(Json(serde_json::json!({
"subject": auth.did,
"sessionId": auth.session_id,
"challenge": challenge,
"reason": reason,
"document": document,
})))
}
#[allow(deprecated)]
pub async fn step_up_vta_finish(
State(state): State<AppState>,
auth: AuthClaims,
Json(doc): Json<TrustTask<Value>>,
) -> Result<Json<did_hosting_common::server::auth::session::TokenResponse>, AppError> {
use did_hosting_common::did_hosting_tasks::{
TASK_AUTH_STEP_UP_VTA_FINISH_0_1, TASK_AUTH_STEP_UP_VTA_FINISH_0_2,
};
let rp_id = state
.config
.server_did
.as_deref()
.ok_or_else(|| AppError::Config("server_did not configured".into()))?;
let jwt_keys = state
.jwt_keys
.as_deref()
.ok_or_else(|| AppError::Config("auth not configured".into()))?;
let type_uri = doc.type_uri.to_string();
if type_uri != TASK_AUTH_STEP_UP_VTA_FINISH_0_2.as_str()
&& type_uri != TASK_AUTH_STEP_UP_VTA_FINISH_0_1.as_str()
{
return Err(AppError::Authentication(format!(
"unexpected step-up document type: {type_uri}"
)));
}
let payload = &doc.payload;
let field = |k: &str| payload.get(k).and_then(Value::as_str);
let subject = field("subject")
.ok_or_else(|| AppError::Authentication("approve-response missing subject".into()))?;
let session_id = field("sessionId")
.ok_or_else(|| AppError::Authentication("approve-response missing sessionId".into()))?;
let challenge = field("challenge")
.ok_or_else(|| AppError::Authentication("approve-response missing challenge".into()))?;
let decision = field("decision")
.ok_or_else(|| AppError::Authentication("approve-response missing decision".into()))?;
if decision != "approved" {
return Err(AppError::Forbidden(format!(
"step-up not approved (decision: {decision})"
)));
}
let proof = doc
.proof
.as_ref()
.ok_or_else(|| AppError::Authentication("approve-response carries no proof".into()))?;
match auth.session_pubkey_b58btc.as_deref() {
Some(pk) => {
let expected_vm = format!("did:key:{pk}#{pk}");
if proof.verification_method != expected_vm {
warn!(actual_vm = %proof.verification_method, %expected_vm,
"step-up rejected: proof verificationMethod not bound to this session");
return Err(AppError::Authentication(
"proof verificationMethod is not bound to this session".into(),
));
}
}
None => {
let proof_did = proof
.verification_method
.split_once('#')
.map(|(d, _)| d)
.unwrap_or("");
if proof_did != auth.did {
warn!(%proof_did, authed = %auth.did,
"step-up rejected: proof verificationMethod DID does not match the authenticated caller");
return Err(AppError::Authentication(
"proof verificationMethod DID does not match the authenticated caller".into(),
));
}
}
}
let verifier = state
.trust_tasks_verifier
.as_deref()
.ok_or_else(|| AppError::Config("trust-tasks proof verifier not configured".into()))?;
verifier.verify(&doc).await.map_err(|e| {
warn!(error = %e, "step-up rejected: approve-response proof failed verification");
AppError::Authentication("approve-response proof failed verification".into())
})?;
if subject != auth.did {
warn!(authed = %auth.did, %subject, "step-up rejected: approval subject mismatch");
return Err(AppError::Authentication(
"approval subject does not match the authenticated DID".into(),
));
}
if session_id != auth.session_id {
return Err(AppError::Authentication(
"approval sessionId does not match the authenticated session".into(),
));
}
if let Some(issuer) = doc.issuer.as_deref()
&& issuer != subject
{
return Err(AppError::Authentication(
"approval issuer does not match subject".into(),
));
}
match doc.recipient.as_deref() {
Some(r) if r == rp_id => {}
_ => {
return Err(AppError::Authentication(
"approval recipient does not bind this service".into(),
));
}
}
let stored = state
.sessions_ks
.take_raw(format!("stepup-nonce:{}", auth.session_id))
.await?
.ok_or_else(|| {
AppError::Authentication("no step-up challenge issued for this session".into())
})?;
let stored = String::from_utf8(stored)
.map_err(|e| AppError::Internal(format!("stored challenge not utf8: {e}")))?;
if !constant_time_eq(stored.as_bytes(), challenge.as_bytes()) {
warn!(session_id = %auth.session_id, "step-up rejected: challenge mismatch");
return Err(AppError::Authentication(
"step-up challenge mismatch".into(),
));
}
const CLOCK_SKEW_SECS: i64 = 60;
const MAX_PROOF_AGE_SECS: i64 = 300;
let now = now_epoch() as i64;
let created = proof.created.timestamp();
if created > now + CLOCK_SKEW_SECS {
return Err(AppError::Authentication(
"approval proof `created` is in the future".into(),
));
}
if created < now - MAX_PROOF_AGE_SECS {
return Err(AppError::Authentication("approval proof is too old".into()));
}
let role = crate::acl::check_acl(&state.acl_ks, &auth.did).await?;
let token_resp = session::elevate_session(
&state.sessions_ks,
jwt_keys,
&auth.session_id,
&role,
vec!["did".to_string()],
"aal2",
state.config.auth.access_token_expiry,
state.config.auth.refresh_token_expiry,
)
.await?;
info!(did = %auth.did, "step-up complete via wallet-signed approval: session elevated to aal2");
Ok(Json(token_resp))
}
pub async fn refresh(
State(state): State<AppState>,
body: String,
) -> Result<Json<did_hosting_common::RefreshResponse>, AppError> {
use did_hosting_common::server::didcomm_unpack;
let (did_resolver, _secrets_resolver, _jwt_keys) = state.require_didcomm_auth()?;
let (msg, sender_base) = didcomm_unpack::unpack_signed(&body, did_resolver).await?;
if msg.typ != "https://affinidi.com/webvh/1.0/authenticate/refresh" {
return Err(AppError::Authentication(format!(
"unexpected message type: {}",
msg.typ
)));
}
let refresh_token = msg
.body
.get("refresh_token")
.and_then(|v| v.as_str())
.ok_or_else(|| AppError::Authentication("missing refresh_token in message body".into()))?
.to_string();
let backend = crate::auth::DidHostingControlAuthBackend::from_state(&state)?;
let resp = vti_common::auth::handlers::handle_refresh(
&backend,
vti_common::auth::RefreshInput {
refresh_token,
signer_did: Some(sender_base),
},
)
.await?;
Ok(Json(canonical_to_local_auth_response(resp)))
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;
use affinidi_data_integrity::DidKeyResolver;
use did_hosting_common::server::trust_tasks::TransportBoundVerifier;
use serde_json::json;
#[tokio::test]
async fn signed_step_up_request_verifies_and_binds_issuer() {
use did_hosting_common::did_hosting_tasks::TASK_AUTH_STEP_UP_VTA_START_0_2;
let (control_did, signer) = crate::signing::test_util::did_key_signer(&[13u8; 32]);
let subject = "did:web:alice.example";
let session_id = "sess-1234";
let challenge = "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff";
let document = build_signed_step_up_approve_request(
&control_did,
&signer,
subject,
session_id,
challenge,
"Elevate this session to aal2",
)
.await
.expect("build + sign");
assert_eq!(document["type"], TASK_AUTH_STEP_UP_VTA_START_0_2.as_str());
assert_eq!(document["issuer"], control_did);
assert_eq!(document["recipient"], subject);
assert_eq!(document["payload"]["subject"], subject);
assert_eq!(document["payload"]["sessionId"], session_id);
assert_eq!(document["payload"]["challenge"], challenge);
let vm = document["proof"]["verificationMethod"]
.as_str()
.expect("proof carries a verificationMethod");
assert_eq!(vm.split('#').next().unwrap(), control_did);
assert_eq!(document["proof"]["cryptosuite"], "eddsa-jcs-2022");
assert_eq!(document["proof"]["proofPurpose"], "assertionMethod");
let doc: TrustTask<Value> =
serde_json::from_value(document.clone()).expect("signed doc parses as a TrustTask");
TransportBoundVerifier::with_resolver(Arc::new(DidKeyResolver))
.verify(&doc)
.await
.expect("approve-request proof verifies");
let mut tampered = doc;
tampered.payload["reason"] = json!("Hand over the keys");
TransportBoundVerifier::with_resolver(Arc::new(DidKeyResolver))
.verify(&tampered)
.await
.expect_err("tampered reason must fail verification");
}
}