use std::net::SocketAddr;
use axum::Json;
use axum::extract::{ConnectInfo, State};
use axum::http::HeaderMap;
use tracing::warn;
use did_hosting_common::{ChallengeRequest, ChallengeResponse};
use crate::auth::session::now_epoch;
use crate::error::AppError;
use crate::rate_limit::resolve_client_ip;
use crate::server::AppState;
pub(crate) const MAX_PENDING_CHALLENGES_PER_DID: usize = 10;
const _: () = assert!(MAX_PENDING_CHALLENGES_PER_DID == 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");
})?;
let canonical = issue_challenge(&state, req.did).await?;
Ok(Json(ChallengeResponse {
challenge: canonical.challenge,
session_id: canonical.session_id,
expires_at: canonical.expires_at,
}))
}
pub(crate) async fn issue_challenge(
state: &AppState,
did: String,
) -> Result<vta_sdk::protocols::auth::ChallengeResponse, AppError> {
let per_did_cap = state.config.auth.max_pending_challenges_per_did;
let reservation = state
.pending_challenges
.try_issue(&did, per_did_cap)
.inspect_err(|e| {
warn!(did = %did, error = %e, "challenge rate limited");
})?;
let minted = match crate::auth::DidHostingControlAuthBackend::from_state(state) {
Ok(backend) => {
vti_common::auth::handlers::handle_challenge(
&backend,
vti_common::auth::ChallengeInput {
did: did.clone(),
session_pubkey_b58btc: None,
},
)
.await
}
Err(e) => Err(e),
};
let resp = match minted {
Ok(resp) => resp,
Err(e) => {
state.pending_challenges.cancel(reservation);
return Err(e);
}
};
let eviction = state.pending_challenges.bind(reservation, &resp.session_id);
if let Err(e) = claim_evicted(state, eviction).await {
warn!(did = %did, error = %e, "could not claim an evicted challenge");
match did_hosting_common::server::auth::session::take_session(
&state.sessions_ks,
&resp.session_id,
)
.await
{
Ok(_) => {
state.pending_challenges.release_session(&resp.session_id);
}
Err(e) => {
warn!(did = %did, error = %e, "could not withdraw the new challenge");
}
}
return Err(e);
}
Ok(resp)
}
async fn claim_evicted(
state: &AppState,
eviction: crate::pending_challenges::Eviction,
) -> Result<(), AppError> {
let mut victims = eviction.into_victims().into_iter();
while let Some(victim) = victims.next() {
match did_hosting_common::server::auth::session::take_session(
&state.sessions_ks,
victim.session_id(),
)
.await
{
Ok(_) => state.pending_challenges.complete_eviction(&victim),
Err(e) => {
state.pending_challenges.restore_eviction(&victim);
for rest in victims {
state.pending_challenges.restore_eviction(&rest);
}
return Err(e);
}
}
}
Ok(())
}
pub(crate) fn refuse_if_idle_session(
session: &did_hosting_common::server::auth::session::Session,
idle_ttl: u64,
) -> Result<(), AppError> {
use did_hosting_common::server::auth::session::now_epoch;
let last_activity = if session.last_seen == 0 {
session.created_at
} else {
session.last_seen
};
let idle_for = now_epoch().saturating_sub(last_activity);
if idle_for > idle_ttl {
warn!(
session_id = %session.session_id,
did = %session.did,
idle_for,
idle_ttl,
"refresh rejected: session idle past the timeout",
);
return Err(AppError::Authentication(
"session signed out after the configured period of inactivity".into(),
));
}
Ok(())
}
pub(crate) async fn verify_session_bound_proof(
doc: &trust_tasks_rs::TrustTask<serde_json::Value>,
session: &did_hosting_common::server::auth::session::Session,
) -> Result<(), AppError> {
use affinidi_data_integrity::DidKeyResolver;
use did_hosting_common::server::trust_tasks::TransportBoundVerifier;
use trust_tasks_rs::ProofVerifier;
let Some(pk) = session.session_pubkey_b58btc.as_deref() else {
return Ok(());
};
let Some(proof) = doc.proof.as_ref() else {
return Err(AppError::Authentication(
"refresh document must carry a proof from this session's key".into(),
));
};
let expected_vm = format!("did:key:{pk}#{pk}");
if proof.verification_method != expected_vm {
warn!(
session_id = %session.session_id,
actual_vm = %proof.verification_method,
"refresh proof verificationMethod is not this session's key",
);
return Err(AppError::Authentication(
"refresh proof is not bound to this session".into(),
));
}
TransportBoundVerifier::with_resolver(std::sync::Arc::new(DidKeyResolver))
.verify(doc)
.await
.map_err(|e| {
warn!(
session_id = %session.session_id,
error = %e,
"refresh proof failed verification",
);
AppError::Authentication("refresh proof failed verification".into())
})
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use did_hosting_common::server::auth::session::{Session, SessionState};
fn session_with(pubkey: Option<&str>, last_seen: u64) -> Session {
Session {
session_id: "sess-refresh".into(),
did: "did:example:alice".into(),
challenge: String::new(),
state: SessionState::Authenticated,
created_at: last_seen,
last_seen,
refresh_token: Some("tok".into()),
refresh_expires_at: Some(u64::MAX),
tee_attested: false,
amr: vec!["passkey".into()],
acr: "aal1".into(),
acr_expires_at: None,
not_after: None,
token_id: Some("jti".into()),
session_pubkey_b58btc: pubkey.map(str::to_string),
}
}
fn refresh_doc(
proof: Option<serde_json::Value>,
) -> trust_tasks_rs::TrustTask<serde_json::Value> {
let mut v = json!({
"type": "https://trusttasks.org/spec/auth/refresh/0.2",
"id": "urn:uuid:11111111-1111-4111-8111-111111111111",
"payload": { "refreshToken": "tok" },
});
if let Some(p) = proof {
v.as_object_mut().unwrap().insert("proof".into(), p);
}
serde_json::from_value(v).expect("refresh document parses")
}
#[tokio::test]
async fn a_bound_session_refresh_without_a_proof_is_refused() {
let session = session_with(Some("z6MkExampleKeyMaterialNotReal"), 0);
let err = verify_session_bound_proof(&refresh_doc(None), &session)
.await
.expect_err("a bound session must demand a proof");
assert!(format!("{err}").contains("proof"), "{err}");
}
#[tokio::test]
async fn a_proof_from_another_key_is_refused() {
let (other_did, signer) = crate::signing::test_util::did_key_signer(&[7u8; 32]);
let _ = signer;
let session = session_with(Some("z6MkTheSessionsOwnKeyNotThatOne"), 0);
let proof = json!({
"type": "DataIntegrityProof",
"cryptosuite": "eddsa-jcs-2022",
"verificationMethod": format!("{other_did}#irrelevant"),
"created": "2026-01-01T00:00:00Z",
"proofPurpose": "assertionMethod",
"proofValue": "z2fake",
});
let err = verify_session_bound_proof(&refresh_doc(Some(proof)), &session)
.await
.expect_err("a proof from a foreign key must be refused");
assert!(
format!("{err}").contains("not bound to this session"),
"the refusal should name the binding, got: {err}"
);
}
#[tokio::test]
async fn a_session_with_no_bound_key_needs_no_proof() {
let session = session_with(None, 0);
verify_session_bound_proof(&refresh_doc(None), &session)
.await
.expect("an unbound session keeps its previous behaviour");
}
#[test]
fn an_idle_session_is_refused_and_a_busy_one_is_not() {
let now = did_hosting_common::server::auth::session::now_epoch();
let fresh = session_with(None, now - 60);
refuse_if_idle_session(&fresh, 900).expect("60s idle is inside a 900s window");
let stale = session_with(None, now - 1_200);
let err =
refuse_if_idle_session(&stale, 900).expect_err("1200s idle is outside a 900s window");
assert!(format!("{err}").contains("inactivity"), "{err}");
}
}