use axum::Json;
use axum::extract::{Path, Query, State};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use trust_tasks_rs::TrustTask;
use trust_tasks_rs::specs::auth::authenticate::v0_1 as authenticate;
use trust_tasks_rs::specs::auth::refresh::v0_1 as refresh;
use uuid::Uuid;
use vta_sdk::protocols::auth::{AuthenticateResponse, ChallengeRequest};
use crate::acl::{Role, check_acl};
use crate::audit::audit;
use crate::auth::session::{
Session, SessionState, delete_session, get_session, list_sessions, now_epoch, store_session,
};
use crate::auth::{AdminAuth, AuthClaims, ManageAuth};
use crate::error::AppError;
use crate::server::AppState;
use tracing::{info, warn};
#[utoipa::path(
post, path = "/auth/challenge", tag = "auth",
request_body(content = String, description = "Flat-JSON or Trust-Task auth document"),
responses(
(status = 200, description = "Challenge nonce (flat JSON or Trust-Task document)"),
(status = 401, description = "ACL gate rejected the subject DID"),
),
)]
pub async fn challenge(State(state): State<AppState>, body: String) -> Result<Response, AppError> {
if let Some(resp) = try_challenge_trust_task(&state, &body).await? {
return Ok(resp);
}
let req: ChallengeRequest = serde_json::from_str(&body)
.map_err(|e| AppError::Validation(format!("challenge request body: {e}")))?;
let backend = crate::auth::VtaAuthBackend::from_state(&state).await?;
let did_for_audit = req.did.clone();
let resp = vti_common::auth::handlers::handle_challenge(
&backend,
vti_common::auth::ChallengeInput {
did: req.did,
session_pubkey_b58btc: None,
},
)
.await?;
audit!(
"auth.challenge",
actor = &did_for_audit,
resource = &resp.session_id,
outcome = "success"
);
Ok(Json(resp).into_response())
}
async fn try_challenge_trust_task(
state: &AppState,
body: &str,
) -> Result<Option<Response>, AppError> {
let doc: TrustTask<Value> = match serde_json::from_str(body) {
Ok(doc) => doc,
Err(_) => return Ok(None),
};
if doc.type_uri.to_string() != vta_sdk::trust_tasks::TASK_AUTH_CHALLENGE_0_1 {
return Ok(None);
}
let subject = doc
.payload
.get("subject")
.and_then(Value::as_str)
.ok_or_else(|| AppError::Validation("auth/challenge payload missing `subject`".into()))?
.to_string();
let backend = crate::auth::VtaAuthBackend::from_state(state).await?;
let resp = vti_common::auth::handlers::handle_challenge(
&backend,
vti_common::auth::ChallengeInput {
did: subject.clone(),
session_pubkey_b58btc: None,
},
)
.await?;
audit!(
"auth.challenge",
actor = &subject,
resource = &resp.session_id,
outcome = "success"
);
let payload = json!({
"challenge": resp.challenge,
"sessionId": resp.session_id,
"expiresAt": resp.expires_at,
});
let response_doc = doc.respond_with(format!("urn:uuid:{}", Uuid::new_v4()), payload);
Ok(Some(Json(response_doc).into_response()))
}
fn tokens_response_doc(request: &TrustTask<Value>, resp: &AuthenticateResponse) -> Response {
let payload = json!({ "tokens": resp.tokens, "session": resp.session });
let response_doc = request.respond_with(format!("urn:uuid:{}", Uuid::new_v4()), payload);
Json(response_doc).into_response()
}
#[utoipa::path(
post, path = "/auth/", tag = "auth",
request_body(content = String, description = "Flat-JSON or Trust-Task auth document"),
responses(
(status = 200, description = "Tokens (flat JSON or Trust-Task document)"),
(status = 401, description = "Authentication failed (bad proof, challenge mismatch, or replay)"),
),
)]
pub async fn authenticate(
State(state): State<AppState>,
body: String,
) -> Result<Response, AppError> {
if let Some(resp) = try_authenticate_trust_task(&state, &body).await? {
return Ok(resp);
}
let atm = state
.atm
.as_ref()
.ok_or_else(|| AppError::Authentication("ATM not configured".into()))?;
let (msg, _metadata) = atm
.unpack(&body)
.await
.map_err(|e| AppError::Authentication(format!("failed to unpack message: {e}")))?;
if msg.typ.as_str() != "https://trusttasks.org/spec/auth/authenticate/0.1" {
return Err(AppError::Authentication(format!(
"unexpected message type: {}",
msg.typ
)));
}
let challenge = msg.body["challenge"]
.as_str()
.ok_or_else(|| AppError::Authentication("missing challenge in message body".into()))?
.to_string();
let session_id = msg.body["session_id"]
.as_str()
.ok_or_else(|| AppError::Authentication("missing session_id in message body".into()))?
.to_string();
let sender_did = msg
.from
.as_deref()
.ok_or_else(|| AppError::Authentication("message has no sender (from)".into()))?;
let sender_base = sender_did
.split('#')
.next()
.unwrap_or(sender_did)
.to_string();
let backend = crate::auth::VtaAuthBackend::from_state(&state).await?;
let resp = vti_common::auth::handlers::handle_authenticate(
&backend,
vti_common::auth::AuthenticateInput {
session_id: session_id.clone(),
challenge,
signer_did: sender_base.clone(),
created_time: msg.created_time,
session_pubkey_b58btc: None,
},
)
.await?;
audit!(
"auth.authenticate",
actor = &sender_base,
resource = &session_id,
outcome = "success"
);
Ok(Json(resp).into_response())
}
async fn try_authenticate_trust_task(
state: &AppState,
body: &str,
) -> Result<Option<Response>, AppError> {
let doc: TrustTask<Value> = match serde_json::from_str(body) {
Ok(doc) => doc,
Err(_) => return Ok(None), };
if doc.type_uri.to_string() != vta_sdk::trust_tasks::TASK_AUTH_AUTHENTICATE_0_1 {
return Ok(None);
}
let signer_did = verify_authenticate_proof(&doc).await?;
let payload: authenticate::Payload = serde_json::from_value(doc.payload.clone())
.map_err(|e| AppError::Authentication(format!("invalid authenticate payload: {e}")))?;
let session_id = payload.session_id.to_string();
let challenge = payload.challenge.to_string();
let backend = crate::auth::VtaAuthBackend::from_state(state).await?;
let resp = vti_common::auth::handlers::handle_authenticate(
&backend,
vti_common::auth::AuthenticateInput {
session_id: session_id.clone(),
challenge,
signer_did: signer_did.clone(),
created_time: None,
session_pubkey_b58btc: None,
},
)
.await?;
audit!(
"auth.authenticate",
actor = &signer_did,
resource = &session_id,
outcome = "success"
);
Ok(Some(tokens_response_doc(&doc, &resp)))
}
async fn verify_authenticate_proof(doc: &TrustTask<Value>) -> Result<String, AppError> {
crate::auth::di_proof::verify_trust_task_proof(doc)
.await
.map_err(|e| AppError::Authentication(e.to_string()))
}
#[utoipa::path(
post, path = "/auth/refresh", tag = "auth",
request_body(content = String, description = "Flat-JSON or Trust-Task auth document"),
responses(
(status = 200, description = "Rotated tokens (flat JSON or Trust-Task document)"),
(status = 401, description = "Refresh token not found, revoked, or already used"),
),
)]
pub async fn refresh(State(state): State<AppState>, body: String) -> Result<Response, AppError> {
if let Some(resp) = try_refresh_trust_task(&state, &body).await? {
return Ok(resp);
}
let atm = state
.atm
.as_ref()
.ok_or_else(|| AppError::Authentication("ATM not configured".into()))?;
let (msg, _metadata) = atm
.unpack(&body)
.await
.map_err(|e| AppError::Authentication(format!("failed to unpack message: {e}")))?;
if msg.typ.as_str() != "https://trusttasks.org/spec/auth/refresh/0.1" {
return Err(AppError::Authentication(format!(
"unexpected message type: {}",
msg.typ
)));
}
let refresh_token = msg.body["refresh_token"]
.as_str()
.ok_or_else(|| AppError::Authentication("missing refresh_token in message body".into()))?
.to_string();
let sender_base = msg
.from
.as_deref()
.map(|s| s.split('#').next().unwrap_or(s).to_string());
let backend = crate::auth::VtaAuthBackend::from_state(&state).await?;
let resp = vti_common::auth::handlers::handle_refresh(
&backend,
vti_common::auth::RefreshInput {
refresh_token,
signer_did: sender_base,
},
)
.await?;
audit!(
"auth.refresh",
actor = &resp.session.subject,
resource = &resp.session.id,
outcome = "success"
);
Ok(Json(resp).into_response())
}
async fn try_refresh_trust_task(
state: &AppState,
body: &str,
) -> Result<Option<Response>, AppError> {
let doc: TrustTask<Value> = match serde_json::from_str(body) {
Ok(doc) => doc,
Err(_) => return Ok(None), };
if doc.type_uri.to_string() != vta_sdk::trust_tasks::TASK_AUTH_REFRESH_0_1 {
return Ok(None);
}
let payload: refresh::Payload = serde_json::from_value(doc.payload.clone())
.map_err(|e| AppError::Authentication(format!("invalid refresh payload: {e}")))?;
let refresh_token = payload.refresh_token.to_string();
let backend = crate::auth::VtaAuthBackend::from_state(state).await?;
let resp = vti_common::auth::handlers::handle_refresh(
&backend,
vti_common::auth::RefreshInput {
refresh_token,
signer_did: None,
},
)
.await?;
audit!(
"auth.refresh",
actor = &resp.session.subject,
resource = &resp.session.id,
outcome = "success"
);
Ok(Some(tokens_response_doc(&doc, &resp)))
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
#[derive(utoipa::ToSchema)]
pub struct SessionSummary {
pub session_id: String,
pub did: String,
pub state: SessionState,
pub created_at: u64,
pub refresh_expires_at: Option<u64>,
}
impl From<Session> for SessionSummary {
fn from(s: Session) -> Self {
Self {
session_id: s.session_id,
did: s.did,
state: s.state,
created_at: s.created_at,
refresh_expires_at: s.refresh_expires_at,
}
}
}
#[utoipa::path(
get, path = "/auth/sessions", tag = "auth",
security(("bearer_jwt" = [])),
responses(
(status = 200, description = "Active sessions", body = [SessionSummary]),
(status = 401, description = "Missing or invalid bearer token"),
(status = 403, description = "Caller is not an admin/initiator"),
),
)]
pub async fn session_list(
_auth: ManageAuth,
State(state): State<AppState>,
) -> Result<Json<Vec<SessionSummary>>, AppError> {
let all = list_sessions(&state.sessions_ks).await?;
let summaries: Vec<SessionSummary> = all.into_iter().map(SessionSummary::from).collect();
info!(caller = %_auth.0.did, count = summaries.len(), "sessions listed");
Ok(Json(summaries))
}
#[utoipa::path(
delete, path = "/auth/sessions/{session_id}", tag = "auth",
security(("bearer_jwt" = [])),
params(("session_id" = String, Path, description = "Session identifier")),
responses(
(status = 204, description = "Session revoked"),
(status = 401, description = "Missing or invalid bearer token"),
(status = 403, description = "Cannot revoke another user's session"),
(status = 404, description = "Session not found"),
),
)]
pub async fn revoke_session(
auth: AuthClaims,
State(state): State<AppState>,
Path(session_id): Path<String>,
) -> Result<impl IntoResponse, AppError> {
let session = get_session(&state.sessions_ks, &session_id)
.await?
.ok_or_else(|| AppError::NotFound(format!("session not found: {session_id}")))?;
if session.did != auth.did && auth.role != Role::Admin {
return Err(AppError::Forbidden(
"cannot revoke another user's session".into(),
));
}
delete_session(&state.sessions_ks, &session_id).await?;
info!(caller = %auth.did, session_id = %session_id, "session revoked");
audit!(
"session.revoke",
actor = &auth.did,
resource = &session_id,
outcome = "success"
);
Ok(StatusCode::NO_CONTENT)
}
#[derive(Debug, Deserialize, utoipa::ToSchema, utoipa::IntoParams)]
#[into_params(parameter_in = Query)]
pub struct RevokeByDidQuery {
pub did: String,
}
#[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct RevokeByDidResponse {
pub revoked: u64,
}
#[utoipa::path(
delete, path = "/auth/sessions", tag = "auth",
security(("bearer_jwt" = [])),
params(RevokeByDidQuery),
responses(
(status = 200, description = "Sessions revoked", body = RevokeByDidResponse),
(status = 401, description = "Missing or invalid bearer token"),
(status = 403, description = "Caller is not an admin"),
),
)]
pub async fn revoke_sessions_by_did(
_auth: AdminAuth,
State(state): State<AppState>,
Query(query): Query<RevokeByDidQuery>,
) -> Result<Json<RevokeByDidResponse>, AppError> {
let all = list_sessions(&state.sessions_ks).await?;
let mut revoked = 0u64;
for session in all {
if session.did == query.did {
delete_session(&state.sessions_ks, &session.session_id).await?;
revoked += 1;
}
}
info!(caller = %_auth.0.did, target_did = %query.did, revoked, "sessions revoked by DID");
audit!(
"session.revoke_by_did",
actor = &_auth.0.did,
resource = &query.did,
outcome = "success"
);
Ok(Json(RevokeByDidResponse { revoked }))
}
use base64::Engine as _;
use base64::engine::general_purpose;
use vta_sdk::protocols::passkey_login::{
PasskeyLoginFinishRequest, PasskeyLoginStartRequest, PasskeyLoginStartResponse,
};
use crate::operations::passkey_login::{
VtaVmResolver, enumerate_passkey_vms, verify_passkey_login,
};
#[utoipa::path(
post, path = "/auth/passkey-login/start", tag = "auth",
request_body = PasskeyLoginStartRequest,
responses(
(status = 200, description = "Passkey login challenge", body = PasskeyLoginStartResponse),
(status = 403, description = "WebAuthn service disabled or DID not in ACL"),
),
)]
pub async fn passkey_login_start(
State(state): State<AppState>,
Json(req): Json<PasskeyLoginStartRequest>,
) -> Result<Json<PasskeyLoginStartResponse>, AppError> {
if !state.config.read().await.services.webauthn {
return Err(AppError::Forbidden(
"WebAuthn service is disabled on this VTA.".into(),
));
}
check_acl(&state.acl_ks, &req.did).await?;
let session_id = Uuid::new_v4().to_string();
let mut challenge_bytes = [0u8; 32];
rand::fill(&mut challenge_bytes);
let challenge = hex::encode(challenge_bytes);
let session = Session {
session_id: session_id.clone(),
did: req.did.clone(),
challenge: challenge.clone(),
state: SessionState::ChallengeSent,
created_at: now_epoch(),
refresh_token: None,
refresh_expires_at: None,
tee_attested: false,
amr: Vec::new(),
acr: String::new(),
token_id: None,
session_pubkey_b58btc: None,
};
store_session(&state.sessions_ks, &session).await?;
let allow_credentials = match state.did_resolver.clone() {
Some(resolver) => {
let vta_resolver = VtaVmResolver::new(resolver);
enumerate_passkey_vms(&vta_resolver, &req.did)
.await
.unwrap_or_default()
.into_iter()
.map(|vm| general_purpose::URL_SAFE_NO_PAD.encode(vm.credential_id))
.collect()
}
None => Vec::new(),
};
info!(did = %req.did, session_id = %session_id, "passkey login challenge issued");
audit!(
"auth.passkey_login_start",
actor = &req.did,
resource = &session_id,
outcome = "success"
);
Ok(Json(PasskeyLoginStartResponse {
session_id,
challenge,
allow_credentials,
}))
}
#[utoipa::path(
post, path = "/auth/passkey-login/finish", tag = "auth",
request_body = PasskeyLoginFinishRequest,
responses(
(status = 200, description = "Access + refresh tokens", body = AuthenticateResponse),
(status = 401, description = "Assertion verification failed, challenge expired, or replay"),
(status = 403, description = "WebAuthn service disabled"),
),
)]
pub async fn passkey_login_finish(
State(state): State<AppState>,
Json(req): Json<PasskeyLoginFinishRequest>,
) -> Result<Json<AuthenticateResponse>, AppError> {
if !state.config.read().await.services.webauthn {
return Err(AppError::Forbidden(
"WebAuthn service is disabled on this VTA.".into(),
));
}
let did_resolver = state
.did_resolver
.clone()
.ok_or_else(|| AppError::Authentication("DID resolver not configured".into()))?;
let session = get_session(&state.sessions_ks, &req.session_id)
.await?
.ok_or_else(|| AppError::Authentication("session not found".into()))?;
if session.state != SessionState::ChallengeSent {
warn!(session_id = %req.session_id, "passkey login rejected: session replay");
return Err(AppError::Authentication(
"session already authenticated (replay)".into(),
));
}
let challenge_ttl = state.config.read().await.auth.challenge_ttl;
if now_epoch().saturating_sub(session.created_at) > challenge_ttl {
warn!(session_id = %req.session_id, "passkey login rejected: challenge expired");
return Err(AppError::Authentication("challenge expired".into()));
}
let decode = |s: &str, what: &'static str| {
general_purpose::URL_SAFE_NO_PAD
.decode(s.as_bytes())
.or_else(|_| general_purpose::URL_SAFE.decode(s.as_bytes()))
.map_err(|_| AppError::Authentication(format!("{what} is not valid base64url")))
};
let assertion = vti_webauthn::AssertionPayload {
credential_id: decode(&req.credential_id, "credential_id")?,
authenticator_data: decode(&req.authenticator_data, "authenticator_data")?,
client_data_json: decode(&req.client_data_json, "client_data_json")?,
signature: decode(&req.signature, "signature")?,
verification_method: req.verification_method.clone(),
};
let claimed_did = req
.verification_method
.split_once('#')
.map(|(did, _frag)| did)
.unwrap_or(&req.verification_method);
if claimed_did != session.did {
warn!(
session_did = %session.did,
assertion_did = %claimed_did,
"passkey login rejected: DID mismatch"
);
return Err(AppError::Authentication(
"verification_method DID does not match session DID".into(),
));
}
let public_url = state.config.read().await.public_url.clone();
let public_url =
public_url.ok_or_else(|| AppError::Config("public_url not configured".into()))?;
let config = vti_webauthn::VerifierConfig::from_public_url(&public_url, true)
.map_err(|e| AppError::Config(format!("invalid public_url: {e}")))?;
let resolver = VtaVmResolver::new(did_resolver);
let _verified =
verify_passkey_login(&assertion, session.challenge.as_bytes(), &resolver, &config)
.await
.map_err(|e| AppError::Authentication(format!("assertion verification failed: {e}")))?;
let backend = crate::auth::VtaAuthBackend::from_state(&state).await?;
let resp = vti_common::auth::handlers::handle_authenticate_with_aal(
&backend,
vti_common::auth::AuthenticateInput {
session_id: session.session_id.clone(),
challenge: session.challenge.clone(),
signer_did: session.did.clone(),
created_time: None,
session_pubkey_b58btc: None,
},
vec!["did".to_string(), "passkey".to_string()],
"aal2".to_string(),
)
.await?;
info!(did = %session.did, session_id = %session.session_id, "passkey login successful");
audit!(
"auth.passkey_login_finish",
actor = &session.did,
resource = &session.session_id,
outcome = "success"
);
Ok(Json(resp))
}