use affinidi_data_integrity::{DataIntegrityProof, DidKeyResolver, VerifyOptions};
use axum::extract::FromRequestParts;
use axum::http::StatusCode;
use axum::http::request::Parts;
use axum::response::{IntoResponse, Response};
use base64::Engine as _;
use base64::engine::general_purpose;
use serde_json::{Value, json};
use trust_tasks_rs::specs::auth::step_up::approve_response::v0_1 as approve_response;
use trust_tasks_rs::{RejectReason, TrustTask};
use uuid::Uuid;
use crate::audit::audit;
use crate::auth::AuthClaims;
use crate::auth::session::{get_session, now_epoch, update_session};
use crate::operations::passkey_login::{
VtaVmResolver, enumerate_passkey_vms, verify_passkey_login,
};
use crate::server::AppState;
use vti_common::auth::step_up::{
ConsumeOutcome, consume_pending_step_up, new_pending_step_up, store_pending_step_up,
};
use vti_common::store::KeyspaceHandle;
use super::helpers::{parse_payload, reject_with, success_response};
#[allow(dead_code)] pub(super) const DISPATCHED_URIS: &[&str] =
&[vta_sdk::trust_tasks::TASK_AUTH_STEP_UP_APPROVE_RESPONSE_0_1];
#[derive(Debug, PartialEq)]
pub(super) enum GateError {
NoGate,
SubjectMismatch,
ProofInvalid(String),
}
pub(super) async fn verify_did_signed_gate(
doc: &TrustTask<Value>,
expected_subject: &str,
) -> Result<(), GateError> {
let proof = doc.proof.as_ref().ok_or(GateError::NoGate)?;
let di: DataIntegrityProof = serde_json::to_value(proof)
.ok()
.and_then(|v| serde_json::from_value(v).ok())
.ok_or_else(|| GateError::ProofInvalid("not a Data Integrity proof".to_string()))?;
let vm_did = di.verification_method.split('#').next().unwrap_or_default();
if vm_did != expected_subject {
return Err(GateError::SubjectMismatch);
}
let mut unsigned = doc.clone();
unsigned.proof = None;
di.verify(&unsigned, &DidKeyResolver, VerifyOptions::new())
.await
.map_err(|e| GateError::ProofInvalid(e.to_string()))
}
fn step_up_failure(code: &str) -> RejectReason {
RejectReason::TaskFailed {
reason: code.to_string(),
details: None,
}
}
fn acr_rank(acr: &str) -> u8 {
match acr {
"aal3" => 3,
"aal2" => 2,
"aal1" => 1,
_ => 0,
}
}
fn gate_err_to_reject(e: GateError) -> RejectReason {
match e {
GateError::NoGate => step_up_failure("auth/step-up/approve-response:no_gate"),
GateError::SubjectMismatch => {
step_up_failure("auth/step-up/approve-response:subject_mismatch")
}
GateError::ProofInvalid(_) => {
step_up_failure("auth/step-up/approve-response:proof_invalid")
}
}
}
async fn verify_webauthn_gate(
state: &AppState,
subject: &str,
challenge: &str,
assertion: &approve_response::AssertionResponse,
) -> Result<(), RejectReason> {
let did_resolver = state
.did_resolver
.clone()
.ok_or_else(|| RejectReason::InternalError {
reason: "DID resolver not configured".to_string(),
})?;
let public_url = state
.config
.read()
.await
.public_url
.clone()
.ok_or_else(|| RejectReason::InternalError {
reason: "public_url not configured".to_string(),
})?;
let config = vti_webauthn::VerifierConfig::from_public_url(&public_url, true).map_err(|e| {
RejectReason::InternalError {
reason: format!("verifier config: {e}"),
}
})?;
let resolver = VtaVmResolver::new(did_resolver);
let invalid = || step_up_failure("auth/step-up/approve-response:assertion_invalid");
let dec = |s: &str| {
general_purpose::URL_SAFE_NO_PAD
.decode(s.as_bytes())
.or_else(|_| general_purpose::URL_SAFE.decode(s.as_bytes()))
};
let credential_id = dec(&assertion.id).map_err(|_| invalid())?;
let vms = enumerate_passkey_vms(&resolver, subject)
.await
.map_err(|e| RejectReason::InternalError {
reason: format!("passkey VM enumeration: {e}"),
})?;
let vm = vms
.into_iter()
.find(|v| v.credential_id == credential_id)
.ok_or_else(invalid)?;
let payload = vti_webauthn::AssertionPayload {
credential_id,
authenticator_data: dec(&assertion.response.authenticator_data).map_err(|_| invalid())?,
client_data_json: dec(&assertion.response.client_data_json).map_err(|_| invalid())?,
signature: dec(&assertion.response.signature).map_err(|_| invalid())?,
verification_method: vm.vm_url,
};
verify_passkey_login(&payload, challenge.as_bytes(), &resolver, &config)
.await
.map(|_| ())
.map_err(|_| invalid())
}
pub(super) async fn handle_approve_response(
state: &AppState,
auth: &AuthClaims,
doc: TrustTask<Value>,
) -> Response {
let payload: approve_response::Payload = match parse_payload(&doc) {
Ok(p) => p,
Err(r) => return r,
};
let subject = payload.subject.to_string();
let session_id = payload.session_id.to_string();
let challenge = payload.challenge.to_string();
if doc.issuer.as_deref() != Some(subject.as_str()) {
return reject_with(
&doc,
step_up_failure("auth/step-up/approve-response:subject_mismatch"),
);
}
if auth.did != subject {
return reject_with(
&doc,
RejectReason::PermissionDenied {
reason: "caller is not the subject of this step-up".to_string(),
},
);
}
let pending = match consume_pending_step_up(&state.sessions_ks, &challenge, now_epoch()).await {
Ok(ConsumeOutcome::Found(p)) => *p,
Ok(ConsumeOutcome::NotFound) => {
return reject_with(
&doc,
step_up_failure("auth/step-up/approve-response:challenge_unknown"),
);
}
Ok(ConsumeOutcome::Expired) => {
return reject_with(
&doc,
step_up_failure("auth/step-up/approve-response:challenge_expired"),
);
}
Err(e) => {
tracing::error!(error = %e, "step-up consume failed");
return reject_with(
&doc,
RejectReason::InternalError {
reason: format!("step-up lookup: {e}"),
},
);
}
};
if pending.subject != subject || pending.session_id != session_id {
return reject_with(
&doc,
step_up_failure("auth/step-up/approve-response:subject_mismatch"),
);
}
if payload.decision == approve_response::PayloadDecision::Denied {
if let Err(e) = verify_did_signed_gate(&doc, &subject).await {
return reject_with(&doc, gate_err_to_reject(e));
}
audit!(
"auth.step_up_denied",
actor = &subject,
resource = &session_id,
outcome = "declined"
);
return success_response(
&doc,
json!({
"status": "rejected",
"reason": payload.denied_reason.unwrap_or_else(|| "user declined".to_string()),
}),
);
}
let factor: &str = match payload.evidence.as_ref() {
None | Some(approve_response::Evidence::DidSigned) => {
if let Err(e) = verify_did_signed_gate(&doc, &subject).await {
return reject_with(&doc, gate_err_to_reject(e));
}
"did"
}
Some(approve_response::Evidence::Webauthn(assertion)) => {
match verify_webauthn_gate(state, &subject, &challenge, assertion).await {
Ok(()) => "passkey",
Err(reason) => return reject_with(&doc, reason),
}
}
};
let granted = payload.granted_acr.as_deref().unwrap_or("aal2");
let target = pending.target_acr.as_str();
if acr_rank(target) > acr_rank(granted) {
return reject_with(
&doc,
step_up_failure("auth/step-up/approve-response:acr_unsatisfied"),
);
}
let mut session = match get_session(&state.sessions_ks, &session_id).await {
Ok(Some(s)) => s,
Ok(None) => {
return reject_with(
&doc,
step_up_failure("auth/step-up/approve-response:challenge_unknown"),
);
}
Err(e) => {
return reject_with(
&doc,
RejectReason::InternalError {
reason: format!("session lookup: {e}"),
},
);
}
};
if !session.amr.iter().any(|m| m == factor) {
session.amr.push(factor.to_string());
}
session.acr = target.to_string(); if let Err(e) = update_session(&state.sessions_ks, &session).await {
return reject_with(
&doc,
RejectReason::InternalError {
reason: format!("session update: {e}"),
},
);
}
audit!(
"auth.step_up",
actor = &subject,
resource = &session_id,
outcome = "success"
);
let issued_at = chrono::DateTime::from_timestamp(session.created_at as i64, 0)
.map(|d| d.to_rfc3339())
.unwrap_or_default();
let expires_at = session
.refresh_expires_at
.and_then(|e| chrono::DateTime::from_timestamp(e as i64, 0))
.map(|d| d.to_rfc3339())
.unwrap_or_default();
success_response(
&doc,
json!({
"status": "elevated",
"session": {
"id": session.session_id,
"subject": session.did,
"issuedAt": issued_at,
"expiresAt": expires_at,
"amr": session.amr,
"acr": session.acr,
},
}),
)
}
const STEP_UP_TARGET_ACR: &str = "aal2";
const STEP_UP_TTL_SECS: u64 = 300;
async fn mint_pending_step_up(
sessions_ks: &KeyspaceHandle,
vta_did: &str,
subject: &str,
session_id: &str,
reason: &str,
) -> Result<Value, ()> {
let acceptable = vec!["did-signed".to_string(), "webauthn".to_string()];
let mut raw = Vec::with_capacity(32);
raw.extend_from_slice(Uuid::new_v4().as_bytes());
raw.extend_from_slice(Uuid::new_v4().as_bytes());
let challenge = general_purpose::URL_SAFE_NO_PAD.encode(&raw);
let pending = new_pending_step_up(
challenge.clone(),
session_id,
subject,
STEP_UP_TARGET_ACR,
acceptable.clone(),
STEP_UP_TTL_SECS,
);
if let Err(e) = store_pending_step_up(sessions_ks, &pending).await {
tracing::error!(error = %e, "failed to persist pending step-up");
return Err(());
}
Ok(json!({
"id": format!("urn:uuid:{}", Uuid::new_v4()),
"type": "https://trusttasks.org/spec/auth/step-up/approve-request/0.1",
"issuer": vta_did,
"recipient": subject,
"payload": {
"subject": subject,
"sessionId": session_id,
"challenge": challenge,
"reason": reason,
"targetAcr": STEP_UP_TARGET_ACR,
"acceptableEvidence": acceptable,
"ttl": STEP_UP_TTL_SECS,
},
}))
}
pub(crate) async fn issue_step_up_challenge(
sessions_ks: &KeyspaceHandle,
vta_did: &str,
subject: &str,
session_id: &str,
reason: &str,
) -> Response {
let approve_request =
match mint_pending_step_up(sessions_ks, vta_did, subject, session_id, reason).await {
Ok(ar) => ar,
Err(()) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
[(axum::http::header::CONTENT_TYPE, "application/json")],
br#"{"error":"internal_error"}"#.to_vec(),
)
.into_response();
}
};
let body = json!({
"error": "step_up_required",
"requiredAcr": STEP_UP_TARGET_ACR,
"approveRequest": approve_request,
});
(
StatusCode::FORBIDDEN,
[(axum::http::header::CONTENT_TYPE, "application/json")],
serde_json::to_vec(&body).unwrap_or_default(),
)
.into_response()
}
pub(super) async fn require_step_up(
state: &AppState,
auth: &AuthClaims,
doc: &TrustTask<Value>,
) -> Option<Response> {
if auth.acr == STEP_UP_TARGET_ACR {
return None;
}
let vta_did = state
.config
.read()
.await
.vta_did
.clone()
.unwrap_or_default();
let reject = match mint_pending_step_up(
&state.sessions_ks,
&vta_did,
&auth.did,
&auth.session_id,
"this operation requires a stepped-up (AAL2) session",
)
.await
{
Ok(approve_request) => RejectReason::TaskFailed {
reason: "auth:step_up_required".to_string(),
details: Some(json!({
"requiredAcr": STEP_UP_TARGET_ACR,
"approveRequest": approve_request,
})),
},
Err(()) => RejectReason::InternalError {
reason: "failed to initiate step-up".to_string(),
},
};
Some(reject_with(doc, reject))
}
pub struct RequireStepUp;
impl FromRequestParts<AppState> for RequireStepUp {
type Rejection = Response;
async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result<Self, Response> {
let claims = AuthClaims::from_request_parts(parts, state)
.await
.map_err(IntoResponse::into_response)?;
if claims.acr == "aal2" {
return Ok(RequireStepUp);
}
let vta_did = state
.config
.read()
.await
.vta_did
.clone()
.unwrap_or_default();
Err(issue_step_up_challenge(
&state.sessions_ks,
&vta_did,
&claims.did,
&claims.session_id,
"this operation requires a stepped-up (AAL2) session",
)
.await)
}
}
#[cfg(test)]
mod tests {
use super::*;
use affinidi_data_integrity::crypto_suites::CryptoSuite;
use affinidi_data_integrity::prepare_sign_input;
use ed25519_dalek::{Signer, SigningKey};
use http_body_util::BodyExt;
use multibase::Base;
use serde_json::json;
#[tokio::test]
async fn issue_step_up_challenge_mints_pending_and_403s() {
use vti_common::auth::step_up::get_pending_step_up;
use vti_common::config::StoreConfig;
use vti_common::store::Store;
let dir = tempfile::tempdir().unwrap();
let store = Store::open(&StoreConfig {
data_dir: dir.path().to_path_buf(),
})
.unwrap();
let ks = store.keyspace("sessions").unwrap();
let resp = issue_step_up_challenge(
&ks,
"did:web:vta.example",
"did:key:zHolder",
"sess-9",
"rotate keys",
)
.await;
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
let v: Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["error"], "step_up_required");
assert_eq!(v["requiredAcr"], "aal2");
assert_eq!(
v["approveRequest"]["type"],
"https://trusttasks.org/spec/auth/step-up/approve-request/0.1"
);
assert_eq!(v["approveRequest"]["issuer"], "did:web:vta.example");
assert_eq!(v["approveRequest"]["recipient"], "did:key:zHolder");
assert_eq!(v["approveRequest"]["payload"]["sessionId"], "sess-9");
assert_eq!(v["approveRequest"]["payload"]["targetAcr"], "aal2");
assert_eq!(v["approveRequest"]["payload"]["reason"], "rotate keys");
let challenge = v["approveRequest"]["payload"]["challenge"]
.as_str()
.expect("challenge string");
let pending = get_pending_step_up(&ks, challenge).await.unwrap().unwrap();
assert_eq!(pending.session_id, "sess-9");
assert_eq!(pending.subject, "did:key:zHolder");
assert_eq!(pending.target_acr, "aal2");
assert_eq!(
pending.acceptable_evidence,
vec!["did-signed".to_string(), "webauthn".to_string()]
);
}
use trust_tasks_rs::Proof;
fn did_key(sk: &SigningKey) -> (String, String) {
let pk = sk.verifying_key();
let mut mc = vec![0xed, 0x01];
mc.extend_from_slice(pk.as_bytes());
let mb = multibase::encode(Base::Base58Btc, mc);
(format!("did:key:{mb}"), mb)
}
fn signed_doc(sk: &SigningKey, subject: &str, vm: &str) -> TrustTask<Value> {
let doc_json = json!({
"id": "approve-resp-1",
"type": "https://trusttasks.org/spec/auth/step-up/approve-response/0.1",
"issuer": subject,
"recipient": "did:web:vta.example",
"payload": {
"subject": subject,
"sessionId": "sess-1",
"challenge": "VHJhbnNmZXJDb25maXJtTm9uY2VYWQ",
"decision": "approved",
"grantedAcr": "aal2",
},
});
let mut doc: TrustTask<Value> = serde_json::from_value(doc_json).unwrap();
let mut di = DataIntegrityProof {
type_: "DataIntegrityProof".to_string(),
cryptosuite: CryptoSuite::EddsaJcs2022,
created: Some("2026-05-31T00:00:00Z".to_string()),
verification_method: vm.to_string(),
proof_purpose: "assertionMethod".to_string(),
proof_value: None,
context: None,
};
let input = prepare_sign_input(&doc, &di, CryptoSuite::EddsaJcs2022).unwrap();
let sig = sk.sign(&input);
di.proof_value = Some(multibase::encode(Base::Base58Btc, sig.to_bytes()));
let proof_json = serde_json::to_value(&di).unwrap();
doc.proof = Some(serde_json::from_value::<Proof>(proof_json).unwrap());
doc
}
#[tokio::test]
async fn verifies_a_did_signed_approve_response() {
let sk = SigningKey::from_bytes(&[7u8; 32]);
let (did, mb) = did_key(&sk);
let vm = format!("{did}#{mb}");
let doc = signed_doc(&sk, &did, &vm);
assert_eq!(verify_did_signed_gate(&doc, &did).await, Ok(()));
}
#[tokio::test]
async fn rejects_when_proof_absent() {
let sk = SigningKey::from_bytes(&[7u8; 32]);
let (did, mb) = did_key(&sk);
let vm = format!("{did}#{mb}");
let mut doc = signed_doc(&sk, &did, &vm);
doc.proof = None;
assert_eq!(
verify_did_signed_gate(&doc, &did).await,
Err(GateError::NoGate)
);
}
#[tokio::test]
async fn rejects_when_vm_did_is_not_the_subject() {
let sk = SigningKey::from_bytes(&[7u8; 32]);
let (did, mb) = did_key(&sk);
let vm = format!("{did}#{mb}");
let doc = signed_doc(&sk, &did, &vm);
assert_eq!(
verify_did_signed_gate(&doc, "did:key:zSomeoneElse").await,
Err(GateError::SubjectMismatch)
);
}
#[tokio::test]
async fn rejects_a_tampered_document() {
let sk = SigningKey::from_bytes(&[7u8; 32]);
let (did, mb) = did_key(&sk);
let vm = format!("{did}#{mb}");
let mut doc = signed_doc(&sk, &did, &vm);
doc.payload = json!({ "subject": did, "decision": "approved", "tampered": true });
assert!(matches!(
verify_did_signed_gate(&doc, &did).await,
Err(GateError::ProofInvalid(_))
));
}
}