use axum::Json;
use axum::extract::State;
use uuid::Uuid;
use vta_sdk::protocols::auth::AuthenticateResponse;
use crate::acl::check_acl;
use crate::audit::audit;
use crate::auth::session::{Session, SessionState, get_session, now_epoch, store_session};
use crate::error::AppError;
use crate::server::AppState;
use tracing::{info, warn};
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(),
last_seen: now_epoch(),
refresh_token: None,
refresh_expires_at: None,
tee_attested: false,
amr: Vec::new(),
acr: String::new(),
acr_expires_at: None,
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,
audience: vti_common::auth::AudienceBinding::Transport,
},
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))
}