//! Passkey / WebAuthn management API: register, list, rename, delete. use axum::{ extract::{Path, State}, response::{IntoResponse, Response}, Form, Json, }; use serde::Deserialize; use tower_sessions::Session; use webauthn_rs::prelude::*; use crate::{ auth::{verify_password, AuthUser}, db::{self, PasskeyId}, error::{AppError, Result, ResultExt}, helpers::hx_toast, templates::{PasskeyListTemplate, PasskeyDisplay}, AppState, }; /// Session key for in-flight passkey registration challenge state. const PASSKEY_REG_STATE_KEY: &str = "passkey_reg_state"; /// Maximum number of passkeys a user can register. const MAX_PASSKEYS_PER_USER: i64 = 20; /// Form input for password confirmation on registration. #[derive(Deserialize)] pub struct RegisterStartForm { password: String, } /// Start passkey registration: generate challenge, return CreationChallengeResponse as JSON. /// Requires password confirmation to prevent session-theft → persistent backdoor. #[tracing::instrument(skip_all, name = "passkeys::register_start")] pub(super) async fn register_start( State(state): State, AuthUser(user): AuthUser, session: Session, Form(form): Form, ) -> Result { user.check_not_sandbox()?; // Require password confirmation (matches delete flow) let db_user = db::users::get_user_by_id(&state.db, user.id) .await? .ok_or(AppError::Unauthorized)?; if !verify_password(&form.password, &db_user.password_hash)? { return Err(AppError::BadRequest("Incorrect password".to_string())); } // Enforce registration cap let count = db::passkeys::count_passkeys(&state.db, user.id).await?; if count >= MAX_PASSKEYS_PER_USER { return Err(AppError::BadRequest(format!( "Maximum of {} passkeys reached", MAX_PASSKEYS_PER_USER ))); } // Load existing credentials to exclude (prevents re-registration of the same authenticator) let existing_json = db::passkeys::get_passkey_credentials(&state.db, user.id).await?; let exclude_creds: Vec = existing_json .iter() .filter_map(|j| serde_json::from_value::(j.clone()).ok()) .map(|pk| pk.cred_id().clone()) .collect(); let exclude = if exclude_creds.is_empty() { None } else { Some(exclude_creds) }; let (ccr, reg_state) = state .webauthn .start_passkey_registration( *user.id.as_uuid(), user.username.as_ref(), user.username.as_ref(), exclude, ) .context("webauthn registration start")?; // Store the registration state in session for the finish step session .insert(PASSKEY_REG_STATE_KEY, ®_state) .await .context("session error")?; Ok(Json(ccr).into_response()) } /// Finish passkey registration: verify attestation, store credential. #[tracing::instrument(skip_all, name = "passkeys::register_finish")] pub(super) async fn register_finish( State(state): State, AuthUser(user): AuthUser, session: Session, Json(reg): Json, ) -> Result { let reg_state: PasskeyRegistration = session .get(PASSKEY_REG_STATE_KEY) .await .context("session error")? .ok_or_else(|| AppError::BadRequest("No pending registration".to_string()))?; // Clean up session state session .remove::(PASSKEY_REG_STATE_KEY) .await .ok(); let passkey = state .webauthn .finish_passkey_registration(®, ®_state) .map_err(|e| AppError::BadRequest(format!("Registration failed: {}", e)))?; let credential_json = serde_json::to_value(&passkey) .context("serialize passkey")?; let credential_id = passkey.cred_id().to_vec(); db::passkeys::create_passkey(&state.db, user.id, "Passkey", &credential_json, &credential_id) .await?; tracing::info!(user_id = %user.id, event = "passkey_registered", "Passkey registered"); Ok(( [("HX-Trigger", hx_toast("Passkey registered", "success"))], list_inner(&state, user.id).await?, ) .into_response()) } /// List passkeys as HTMX partial. #[tracing::instrument(skip_all, name = "passkeys::list")] pub(super) async fn list( State(state): State, AuthUser(user): AuthUser, ) -> Result { Ok(list_inner(&state, user.id).await?.into_response()) } /// Inner helper to build the passkey list template. async fn list_inner(state: &AppState, user_id: db::UserId) -> Result { let passkeys = db::passkeys::list_passkeys(&state.db, user_id).await?; let passkeys = passkeys .into_iter() .map(|p| PasskeyDisplay { id: p.id.to_string(), name: p.name, created_at: p.created_at.format("%Y-%m-%d").to_string(), last_used_at: p.last_used_at.map(|d| d.format("%Y-%m-%d").to_string()), }) .collect(); Ok(PasskeyListTemplate { passkeys }) } /// Rename a passkey. #[derive(Deserialize)] pub struct RenameForm { name: String, } #[tracing::instrument(skip_all, name = "passkeys::rename")] pub(super) async fn rename( State(state): State, AuthUser(user): AuthUser, Path(id): Path, Form(form): Form, ) -> Result { let name = form.name.trim(); if name.is_empty() || name.len() > 100 { return Err(AppError::validation("Name must be 1-100 characters".to_string())); } if !db::passkeys::rename_passkey(&state.db, id, user.id, name).await? { return Err(AppError::NotFound); } Ok(( [("HX-Trigger", hx_toast("Passkey renamed", "success"))], list_inner(&state, user.id).await?, ) .into_response()) } /// Delete a passkey (requires password confirmation). #[derive(Deserialize)] pub struct DeleteForm { password: String, } #[tracing::instrument(skip_all, name = "passkeys::delete")] pub(super) async fn delete( State(state): State, AuthUser(user): AuthUser, Path(id): Path, Form(form): Form, ) -> Result { let db_user = db::users::get_user_by_id(&state.db, user.id) .await? .ok_or(AppError::Unauthorized)?; if !verify_password(&form.password, &db_user.password_hash)? { return Err(AppError::BadRequest("Incorrect password".to_string())); } if !db::passkeys::delete_passkey(&state.db, id, user.id).await? { return Err(AppError::NotFound); } tracing::info!(user_id = %user.id, passkey_id = %id, event = "passkey_deleted", "Passkey deleted"); Ok(( [("HX-Trigger", hx_toast("Passkey deleted", "success"))], list_inner(&state, user.id).await?, ) .into_response()) }