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, claim_secret, mint_install_session_token,
parse_install_token,
};
use crate::server::AppState;
#[derive(Debug, Deserialize)]
pub struct ClaimStartRequest {
pub install_token: String,
#[serde(default)]
pub claim_secret: Option<String>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ClaimStartResponse {
pub registration_id: String,
pub options: CreationChallengeResponse,
}
#[derive(Debug, Deserialize)]
pub struct ClaimFinishRequest {
pub install_token: String,
pub registration_id: String,
pub webauthn_response: RegisterPublicKeyCredential,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ClaimFinishResponse {
pub admin_did: String,
pub setup_session_token: String,
}
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)?;
let outcome = store.start_claim(&jti).await?;
if let Some(stored_hash) = outcome.claim_secret_hash.as_deref() {
let Some(supplied) = req.claim_secret.as_deref() else {
return Err(AppError::ServiceError {
status: StatusCode::UNAUTHORIZED,
message: "claim_secret_required".into(),
});
};
if !claim_secret::verify(supplied, stored_hash)? {
return Err(AppError::ServiceError {
status: StatusCode::UNAUTHORIZED,
message: "claim_secret_invalid".into(),
});
}
}
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,
}),
))
}
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(),
));
}
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?;
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,
)?;
info!(jti = %jti, %admin_did, "install claim ceremony completed");
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()))
}