use std::sync::Arc;
use axum::Json;
use axum::extract::State;
use axum::http::StatusCode;
use serde::{Deserialize, Serialize};
use tracing::info;
use uuid::Uuid;
use vti_common::auth::passkey::store::{
PasskeyUser, delete_registration_user, get_registration_user, store_credential_mapping,
store_passkey_user, store_registration_state, store_registration_user, take_registration_state,
};
use vti_common::error::AppError;
use webauthn_rs::prelude::{CreationChallengeResponse, RegisterPublicKeyCredential, Webauthn};
use crate::acl::admin::{AdminEntry, RegisteredPasskey, get_admin_entry, store_admin_entry};
use crate::install::{
INSTALL_SESSION_DEFAULT_TTL_SECS, InstallTokenSigner, InstallTokenState, claim_secret,
mint_install_session_token, parse_install_token,
};
use crate::server::AppState;
#[derive(Debug, Deserialize, utoipa::ToSchema)]
pub struct ClaimStartRequest {
pub install_token: String,
#[serde(default)]
pub claim_secret: Option<String>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
#[derive(utoipa::ToSchema)]
pub struct ClaimStartResponse {
pub registration_id: String,
#[schema(value_type = Object)]
pub options: CreationChallengeResponse,
}
#[derive(Debug, Deserialize, utoipa::ToSchema)]
pub struct ClaimFinishRequest {
pub install_token: String,
pub registration_id: String,
#[schema(value_type = Object)]
pub webauthn_response: RegisterPublicKeyCredential,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
#[derive(utoipa::ToSchema)]
pub struct ClaimFinishResponse {
pub admin_did: String,
pub setup_session_token: String,
}
#[utoipa::path(
post, path = "/install/claim/start", tag = "install",
request_body = ClaimStartRequest,
responses(
(status = 201, description = "WebAuthn creation challenge", body = ClaimStartResponse),
(status = 401, description = "Invalid install token or claim secret"),
),
)]
pub async fn claim_start(
State(state): State<AppState>,
Json(req): Json<ClaimStartRequest>,
) -> Result<(StatusCode, Json<ClaimStartResponse>), AppError> {
let signer = require_install_signer(&state)?;
let webauthn = require_webauthn(&state)?;
let store = &state.install_store;
let claims = parse_install_token(signer, &req.install_token)?;
let jti = parse_jti(&claims.jti)?;
if let Some(stored_hash) = store.peek_secret_hash(&jti).await? {
let Some(supplied) = req.claim_secret.as_deref() else {
return Err(AppError::ServiceError {
status: StatusCode::UNAUTHORIZED,
message: "claim_secret_required".into(),
});
};
let supplied = supplied.to_string();
let verified =
tokio::task::spawn_blocking(move || claim_secret::verify(&supplied, &stored_hash))
.await
.map_err(|e| {
AppError::Internal(format!("claim-secret verify task failed: {e}"))
})??;
if !verified {
return Err(AppError::ServiceError {
status: StatusCode::UNAUTHORIZED,
message: "claim_secret_invalid".into(),
});
}
}
store.start_claim(&jti).await?;
let user_uuid = jti;
let (ccr, reg_state) = crate::webauthn::start_passkey_registration(
webauthn,
user_uuid,
&claims.admin_did,
&claims.admin_did,
None,
)?;
store_registration_state(&state.passkey_ks, &jti.to_string(), ®_state).await?;
store_registration_user(&state.passkey_ks, &jti.to_string(), &user_uuid).await?;
info!(jti = %jti, "install claim ceremony started");
Ok((
StatusCode::OK,
Json(ClaimStartResponse {
registration_id: jti.to_string(),
options: ccr,
}),
))
}
#[utoipa::path(
post, path = "/install/claim/finish", tag = "install",
request_body = ClaimFinishRequest,
responses(
(status = 200, description = "Admin DID + setup-session token", body = ClaimFinishResponse),
(status = 401, description = "Invalid install token or registration state"),
),
)]
pub async fn claim_finish(
State(state): State<AppState>,
Json(req): Json<ClaimFinishRequest>,
) -> Result<(StatusCode, Json<ClaimFinishResponse>), AppError> {
let signer = require_install_signer(&state)?;
let webauthn = require_webauthn(&state)?;
let store = &state.install_store;
let claims = parse_install_token(signer, &req.install_token)?;
let jti = parse_jti(&claims.jti)?;
let reg_id = parse_jti(&req.registration_id)?;
if reg_id != jti {
return Err(AppError::Unauthorized(
"registration_id does not match install token".into(),
));
}
if let Some(InstallTokenState::Consumed { admin_did, .. }) = store.get_token(&jti).await? {
let admin_did = admin_did.unwrap_or_else(|| claims.admin_did.clone());
if get_admin_entry(&state.passkey_ks, &admin_did)
.await?
.is_some()
{
info!(jti = %jti, %admin_did, "install claim finish replayed; re-issuing setup token");
return issue_setup_session(&state, signer, admin_did, &jti).await;
}
return Err(AppError::Unauthorized("install token consumed".into()));
}
let reg_state = take_registration_state(&state.passkey_ks, &jti.to_string())
.await?
.ok_or_else(|| {
AppError::Unauthorized(
"no registration in progress for this install token (start the ceremony first)"
.into(),
)
})?;
let passkey = webauthn
.finish_passkey_registration(&req.webauthn_response, ®_state)
.map_err(|e| AppError::Authentication(format!("passkey registration failed: {e}")))?;
let admin_did = claims.admin_did.clone();
store.finish_claim(&jti).await?;
let user_uuid = get_registration_user(&state.passkey_ks, &jti.to_string())
.await?
.ok_or_else(|| AppError::Internal("missing registration_user mapping".into()))?;
delete_registration_user(&state.passkey_ks, &jti.to_string()).await?;
let user = PasskeyUser {
user_uuid,
did: admin_did.clone(),
display_name: admin_did.clone(),
credentials: vec![passkey.clone()],
};
store_passkey_user(&state.passkey_ks, &user).await?;
let cred_id_hex = hex::encode(passkey.cred_id().as_ref() as &[u8]);
store_credential_mapping(&state.passkey_ks, &cred_id_hex, user_uuid).await?;
let now = chrono::Utc::now();
let registered = RegisteredPasskey {
credential_id: cred_id_hex.clone(),
label: "install".into(),
transports: Vec::new(),
registered_at: now,
last_used_at: None,
};
let admin_entry = match get_admin_entry(&state.passkey_ks, &admin_did).await? {
Some(mut existing) => {
if !existing
.passkeys
.iter()
.any(|p| p.credential_id == cred_id_hex)
{
existing.passkeys.push(registered);
}
existing
}
None => AdminEntry {
did: admin_did.clone(),
passkeys: vec![registered],
extensions: serde_json::Value::Null,
created_at: now,
},
};
store_admin_entry(&state.passkey_ks, &admin_entry).await?;
info!(jti = %jti, %admin_did, "install claim ceremony completed");
issue_setup_session(&state, signer, admin_did, &jti).await
}
async fn issue_setup_session(
state: &AppState,
signer: &InstallTokenSigner,
admin_did: String,
jti: &Uuid,
) -> Result<(StatusCode, Json<ClaimFinishResponse>), AppError> {
let issuer_did = state
.config
.read()
.await
.vtc_did
.clone()
.unwrap_or_else(|| "did:key:vtc-install-uninitialised".to_string());
let setup_session_token = mint_install_session_token(
signer,
&issuer_did,
&admin_did,
&jti.to_string(),
INSTALL_SESSION_DEFAULT_TTL_SECS,
)?;
Ok((
StatusCode::OK,
Json(ClaimFinishResponse {
admin_did,
setup_session_token,
}),
))
}
fn require_install_signer(state: &AppState) -> Result<&Arc<InstallTokenSigner>, AppError> {
state
.install_signer
.as_ref()
.ok_or_else(|| AppError::ServiceError {
status: StatusCode::SERVICE_UNAVAILABLE,
message: "install signer not configured (run setup first)".into(),
})
}
fn require_webauthn(state: &AppState) -> Result<&Webauthn, AppError> {
state
.webauthn
.as_deref()
.ok_or_else(|| AppError::ServiceError {
status: StatusCode::SERVICE_UNAVAILABLE,
message: "WebAuthn not configured (public_url required)".into(),
})
}
fn parse_jti(s: &str) -> Result<Uuid, AppError> {
Uuid::parse_str(s)
.map_err(|_| AppError::Unauthorized("invalid install token (malformed jti)".into()))
}