use axum::{Json, extract::State}; use serde::{Deserialize, Serialize}; use tracing::{error, info, warn}; use tranquil_db_traits::WebauthnChallengeType; use tranquil_pds::api::EmptyResponse; use tranquil_pds::api::error::{ApiError, DbResultExt}; use tranquil_pds::auth::{Active, Auth, require_legacy_session_mfa, require_reauth_window}; use tranquil_pds::state::AppState; use webauthn_rs::prelude::*; #[derive(Deserialize)] #[serde(rename_all = "camelCase")] pub struct StartRegistrationInput { pub friendly_name: Option, } #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub struct StartRegistrationOutput { pub options: serde_json::Value, } pub async fn start_passkey_registration( State(state): State, auth: Auth, Json(input): Json, ) -> Result, ApiError> { let webauthn = &state.webauthn_config; let handle = state .repos .user .get_handle_by_did(&auth.did) .await .log_db_err("fetching user")? .ok_or(ApiError::AccountNotFound)?; let existing_passkeys = state .repos .user .get_passkeys_for_user(&auth.did) .await .log_db_err("fetching existing passkeys")?; let exclude_credentials: Vec = existing_passkeys .iter() .map(|p| CredentialID::from(p.credential_id.clone())) .collect(); let display_name = input.friendly_name.as_deref().unwrap_or(&handle); let (ccr, reg_state) = webauthn .start_registration(&auth.did, &handle, display_name, exclude_credentials) .map_err(|e| { error!("Failed to start passkey registration: {}", e); ApiError::InternalError(Some("Failed to start registration".into())) })?; let state_json = serde_json::to_string(®_state).map_err(|e| { error!("Failed to serialize registration state: {:?}", e); ApiError::InternalError(None) })?; state .repos .user .save_webauthn_challenge(&auth.did, WebauthnChallengeType::Registration, &state_json) .await .log_db_err("saving registration state")?; let options = serde_json::to_value(&ccr).unwrap_or(serde_json::json!({})); info!(did = %auth.did, "Passkey registration started"); Ok(Json(StartRegistrationOutput { options })) } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] pub struct FinishRegistrationInput { pub credential: serde_json::Value, pub friendly_name: Option, } #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub struct FinishRegistrationOutput { pub id: String, pub credential_id: String, } pub async fn finish_passkey_registration( State(state): State, auth: Auth, Json(input): Json, ) -> Result, ApiError> { let webauthn = &state.webauthn_config; let reg_state_json = state .repos .user .load_webauthn_challenge(&auth.did, WebauthnChallengeType::Registration) .await .log_db_err("loading registration state")? .ok_or(ApiError::NoRegistrationInProgress)?; let reg_state: SecurityKeyRegistration = serde_json::from_str(®_state_json).map_err(|e| { error!("Failed to deserialize registration state: {:?}", e); ApiError::InternalError(None) })?; let credential: RegisterPublicKeyCredential = serde_json::from_value(input.credential) .map_err(|e| { warn!("Failed to parse credential: {:?}", e); ApiError::InvalidCredential })?; let passkey = webauthn .finish_registration(&credential, ®_state) .map_err(|e| { warn!("Failed to finish passkey registration: {}", e); ApiError::RegistrationFailed })?; let public_key = serde_json::to_vec(&passkey).map_err(|e| { error!("Failed to serialize passkey: {:?}", e); ApiError::InternalError(None) })?; let passkey_id = state .repos .user .save_passkey( &auth.did, passkey.cred_id(), &public_key, input.friendly_name.as_deref(), ) .await .log_db_err("saving passkey")?; if let Err(e) = state .repos .user .delete_webauthn_challenge(&auth.did, WebauthnChallengeType::Registration) .await { warn!("Failed to delete registration state: {:?}", e); } let credential_id_base64 = base64::Engine::encode( &base64::engine::general_purpose::URL_SAFE_NO_PAD, passkey.cred_id(), ); info!(did = %auth.did, passkey_id = %passkey_id, "Passkey registered"); Ok(Json(FinishRegistrationOutput { id: passkey_id.to_string(), credential_id: credential_id_base64, })) } #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub struct PasskeyInfo { pub id: String, pub credential_id: String, pub friendly_name: Option, pub created_at: String, pub last_used: Option, } #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub struct ListPasskeysOutput { pub passkeys: Vec, } pub async fn list_passkeys( State(state): State, auth: Auth, ) -> Result, ApiError> { let passkeys = state .repos .user .get_passkeys_for_user(&auth.did) .await .log_db_err("fetching passkeys")?; let passkey_infos: Vec = passkeys .into_iter() .map(|pk| PasskeyInfo { id: pk.id.to_string(), credential_id: pk.credential_id_base64(), friendly_name: pk.friendly_name, created_at: pk.created_at.to_rfc3339(), last_used: pk.last_used.map(|dt| dt.to_rfc3339()), }) .collect(); Ok(Json(ListPasskeysOutput { passkeys: passkey_infos, })) } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] pub struct DeletePasskeyInput { pub id: String, } pub async fn delete_passkey( State(state): State, auth: Auth, Json(input): Json, ) -> Result, ApiError> { let session_mfa = require_legacy_session_mfa(&state, &auth).await?; let reauth_mfa = require_reauth_window(&state, &auth).await?; let id: uuid::Uuid = input.id.parse().map_err(|_| ApiError::InvalidId)?; match state.repos.user.delete_passkey(id, reauth_mfa.did()).await { Ok(true) => { info!(did = %session_mfa.did(), passkey_id = %id, "Passkey deleted"); Ok(Json(EmptyResponse {})) } Ok(false) => Err(ApiError::PasskeyNotFound), Err(e) => { error!("DB error deleting passkey: {:?}", e); Err(ApiError::InternalError(None)) } } } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] pub struct UpdatePasskeyInput { pub id: String, pub friendly_name: String, } pub async fn update_passkey( State(state): State, auth: Auth, Json(input): Json, ) -> Result, ApiError> { let id: uuid::Uuid = input.id.parse().map_err(|_| ApiError::InvalidId)?; match state .repos .user .update_passkey_name(id, &auth.did, &input.friendly_name) .await { Ok(true) => { info!(did = %auth.did, passkey_id = %id, "Passkey renamed"); Ok(Json(EmptyResponse {})) } Ok(false) => Err(ApiError::PasskeyNotFound), Err(e) => { error!("DB error updating passkey: {:?}", e); Err(ApiError::InternalError(None)) } } } pub async fn has_passkeys_for_user(state: &AppState, did: &tranquil_pds::types::Did) -> bool { state.repos.user.has_passkeys(did).await.unwrap_or(false) }