Skip to main content

auth_framework/api/
webauthn.rs

1use crate::api::{ApiResponse, ApiState, extract_bearer_token, validate_api_token};
2use axum::{extract::State, http::HeaderMap, response::Json};
3use base64::Engine;
4use rand::Rng;
5use serde::{Deserialize, Serialize};
6use sha2::{Digest, Sha256};
7
8/// WebAuthn Relying Party configuration.
9///
10/// Collects the RP identity, default timeouts, and attestation preference
11/// in a single struct so that individual handlers don't need to scatter
12/// `std::env::var` calls.
13///
14/// # Example
15/// ```rust
16/// use auth_framework::api::webauthn::WebAuthnConfig;
17///
18/// // Minimal — defaults to "localhost" / "AuthFramework" / "direct"
19/// let cfg = WebAuthnConfig::default();
20/// assert_eq!(cfg.rp_id, "localhost");
21///
22/// // Typical production use
23/// let cfg = WebAuthnConfig::new("auth.example.com", "My Service")
24///     .attestation("none")
25///     .timeout(120_000);
26/// assert_eq!(cfg.rp_id, "auth.example.com");
27/// assert_eq!(cfg.attestation, "none");
28/// ```
29#[derive(Debug, Clone, Serialize, Deserialize)]
30pub struct WebAuthnConfig {
31    /// Relying Party identifier (usually the domain name).
32    pub rp_id: String,
33    /// Human-readable Relying Party name.
34    pub rp_name: String,
35    /// Attestation conveyance preference (`"direct"`, `"indirect"`, `"none"`).
36    pub attestation: String,
37    /// Timeout for ceremonies in milliseconds (default: 60 000).
38    pub timeout_ms: u64,
39}
40
41impl Default for WebAuthnConfig {
42    fn default() -> Self {
43        Self {
44            rp_id: "localhost".to_string(),
45            rp_name: "AuthFramework".to_string(),
46            attestation: "direct".to_string(),
47            timeout_ms: 60_000,
48        }
49    }
50}
51
52impl WebAuthnConfig {
53    /// Create a config with the given RP id and name.
54    pub fn new(rp_id: impl Into<String>, rp_name: impl Into<String>) -> Self {
55        Self {
56            rp_id: rp_id.into(),
57            rp_name: rp_name.into(),
58            ..Self::default()
59        }
60    }
61
62    /// Build a config from environment variables.
63    ///
64    /// | Variable | Default |
65    /// |----------|---------|
66    /// | `WEBAUTHN_RP_ID` | `"localhost"` |
67    /// | `WEBAUTHN_RP_NAME` | `"AuthFramework"` |
68    /// | `WEBAUTHN_ATTESTATION` | `"direct"` |
69    /// | `WEBAUTHN_TIMEOUT_MS` | `60000` |
70    pub fn from_env() -> Self {
71        Self {
72            rp_id: std::env::var("WEBAUTHN_RP_ID").unwrap_or_else(|_| "localhost".to_string()),
73            rp_name: std::env::var("WEBAUTHN_RP_NAME")
74                .unwrap_or_else(|_| "AuthFramework".to_string()),
75            attestation: std::env::var("WEBAUTHN_ATTESTATION")
76                .unwrap_or_else(|_| "direct".to_string()),
77            timeout_ms: std::env::var("WEBAUTHN_TIMEOUT_MS")
78                .ok()
79                .and_then(|v| v.parse().ok())
80                .unwrap_or(60_000),
81        }
82    }
83
84    /// Set the attestation conveyance preference.
85    pub fn attestation(mut self, attestation: impl Into<String>) -> Self {
86        self.attestation = attestation.into();
87        self
88    }
89
90    /// Set the ceremony timeout in milliseconds.
91    pub fn timeout(mut self, ms: u64) -> Self {
92        self.timeout_ms = ms;
93        self
94    }
95}
96
97/// Request to initiate WebAuthn registration
98#[derive(Debug, Serialize, Deserialize)]
99pub struct WebAuthnRegistrationInitRequest {
100    pub username: String,
101    pub display_name: Option<String>,
102    pub authenticator_attachment: Option<String>, // "platform" or "cross-platform"
103    pub user_verification: Option<String>,        // "required", "preferred", "discouraged"
104}
105
106/// WebAuthn registration challenge response
107#[derive(Debug, Serialize, Deserialize)]
108pub struct WebAuthnRegistrationResponse {
109    pub challenge: String,
110    pub rp: PublicKeyCredentialRpEntity,
111    pub user: PublicKeyCredentialUserEntity,
112    pub pubkey_cred_params: Vec<PublicKeyCredentialParameters>,
113    pub timeout: Option<u64>,
114    #[serde(rename = "excludeCredentials")]
115    pub exclude_credentials: Option<Vec<PublicKeyCredentialDescriptor>>,
116    #[serde(rename = "authenticatorSelection")]
117    pub authenticator_selection: Option<AuthenticatorSelectionCriteria>,
118    pub attestation: String,
119    pub session_id: String,
120}
121
122/// Complete WebAuthn registration
123#[derive(Debug, Serialize, Deserialize)]
124pub struct WebAuthnRegistrationCompleteRequest {
125    pub session_id: String,
126    pub credential_id: String,
127    pub credential_public_key: String,
128    pub attestation_object: String,
129    pub client_data_json: String,
130    pub authenticator_data: String,
131    pub signature: String,
132}
133
134/// WebAuthn authentication initiation request
135#[derive(Debug, Serialize, Deserialize)]
136pub struct WebAuthnAuthenticationRequest {
137    pub username: Option<String>,
138    pub user_verification: Option<String>,
139}
140
141/// WebAuthn authentication challenge response
142#[derive(Debug, Serialize, Deserialize)]
143pub struct WebAuthnAuthenticationResponse {
144    pub challenge: String,
145    pub allow_credentials: Vec<PublicKeyCredentialDescriptor>,
146    pub timeout: Option<u64>,
147    pub user_verification: String,
148    pub session_id: String,
149}
150
151/// Complete WebAuthn authentication
152#[derive(Debug, Serialize, Deserialize)]
153pub struct WebAuthnAuthenticationCompleteRequest {
154    pub session_id: String,
155    pub credential_id: String,
156    pub authenticator_data: String,
157    pub client_data_json: String,
158    pub signature: String,
159    pub user_handle: Option<String>,
160}
161
162/// Supporting structures
163#[derive(Debug, Serialize, Deserialize)]
164pub struct PublicKeyCredentialRpEntity {
165    pub id: String,
166    pub name: String,
167}
168
169#[derive(Debug, Serialize, Deserialize)]
170pub struct PublicKeyCredentialUserEntity {
171    pub id: String,
172    pub name: String,
173    pub display_name: String,
174}
175
176#[derive(Debug, Serialize, Deserialize)]
177pub struct PublicKeyCredentialParameters {
178    #[serde(rename = "type")]
179    pub type_field: String,
180    pub alg: i32,
181}
182
183impl PublicKeyCredentialParameters {
184    /// ES256 (ECDSA P-256) — COSE algorithm −7.
185    pub fn es256() -> Self {
186        Self {
187            type_field: "public-key".to_string(),
188            alg: -7,
189        }
190    }
191
192    /// RS256 (RSASSA-PKCS1-v1_5 with SHA-256) — COSE algorithm −257.
193    pub fn rs256() -> Self {
194        Self {
195            type_field: "public-key".to_string(),
196            alg: -257,
197        }
198    }
199
200    /// Default WebAuthn parameter set: ES256 + RS256.
201    pub fn defaults() -> Vec<Self> {
202        vec![Self::es256(), Self::rs256()]
203    }
204}
205
206#[derive(Debug, Serialize, Deserialize)]
207pub struct PublicKeyCredentialDescriptor {
208    #[serde(rename = "type")]
209    pub type_field: String,
210    pub id: String,
211    pub transports: Option<Vec<String>>,
212}
213
214#[derive(Debug, Serialize, Deserialize)]
215pub struct AuthenticatorSelectionCriteria {
216    pub authenticator_attachment: Option<String>,
217    pub require_resident_key: Option<bool>,
218    pub user_verification: String,
219}
220
221/// Initiate WebAuthn registration process
222pub async fn webauthn_registration_init(
223    State(state): State<ApiState>,
224    Json(request): Json<WebAuthnRegistrationInitRequest>,
225) -> Json<ApiResponse<WebAuthnRegistrationResponse>> {
226    // Validate username format before processing
227    if let Err(e) = crate::utils::validation::validate_username(&request.username) {
228        return Json(ApiResponse::error_typed("VALIDATION_ERROR", format!("{e}")));
229    }
230
231    // Generate a secure challenge
232    let mut challenge_bytes = [0u8; 32];
233    rand::rng().fill_bytes(&mut challenge_bytes);
234    let challenge = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(challenge_bytes);
235
236    // Generate user ID (base64url-encoded username as per WebAuthn spec)
237    let user_id =
238        base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(request.username.as_bytes());
239
240    // Create session ID for tracking this registration
241    let session_id = format!("webauthn_{}", uuid::Uuid::new_v4());
242
243    let webauthn_cfg = WebAuthnConfig::from_env();
244
245    let response = WebAuthnRegistrationResponse {
246        challenge: challenge.clone(),
247        rp: PublicKeyCredentialRpEntity {
248            id: webauthn_cfg.rp_id,
249            name: webauthn_cfg.rp_name,
250        },
251        user: PublicKeyCredentialUserEntity {
252            id: user_id,
253            name: request.username.clone(),
254            display_name: request.display_name.unwrap_or(request.username.clone()),
255        },
256        pubkey_cred_params: PublicKeyCredentialParameters::defaults(),
257        timeout: Some(webauthn_cfg.timeout_ms),
258        exclude_credentials: None,
259        authenticator_selection: Some(AuthenticatorSelectionCriteria {
260            authenticator_attachment: request.authenticator_attachment,
261            require_resident_key: Some(false),
262            user_verification: request.user_verification.unwrap_or("preferred".to_string()),
263        }),
264        // "direct" requests the authenticator to include attestation data,
265        // enabling the server to verify the authenticator's identity and provenance.
266        // Use "none" only if you explicitly do not need device attestation verification.
267        attestation: webauthn_cfg.attestation,
268        session_id: session_id.clone(),
269    };
270
271    // Store the challenge and session info with a 5-minute TTL
272    let session_key = format!("webauthn_reg_session:{}", session_id);
273    let session_data = serde_json::json!({
274        "challenge": challenge,
275        "username": request.username,
276        "timestamp": chrono::Utc::now().timestamp()
277    });
278    let _ = state
279        .auth_framework
280        .storage()
281        .store_kv(
282            &session_key,
283            session_data.to_string().as_bytes(),
284            Some(std::time::Duration::from_secs(300)),
285        )
286        .await;
287
288    Json(ApiResponse::success_with_message(
289        response,
290        "WebAuthn registration challenge generated",
291    ))
292}
293
294/// Complete WebAuthn registration process
295pub async fn webauthn_registration_complete(
296    State(state): State<ApiState>,
297    Json(request): Json<WebAuthnRegistrationCompleteRequest>,
298) -> Json<ApiResponse<()>> {
299    // Retrieve the stored session to validate the challenge
300    let session_key = format!("webauthn_reg_session:{}", request.session_id);
301    let storage = state.auth_framework.storage();
302
303    let (username, stored_challenge) = match storage.get_kv(&session_key).await {
304        Ok(Some(data)) => {
305            let session: serde_json::Value =
306                serde_json::from_slice(&data).unwrap_or(serde_json::Value::Null);
307            let uname = session
308                .get("username")
309                .and_then(|u| u.as_str())
310                .unwrap_or("unknown")
311                .to_string();
312            let challenge = session
313                .get("challenge")
314                .and_then(|c| c.as_str())
315                .unwrap_or("")
316                .to_string();
317            (uname, challenge)
318        }
319        _ => {
320            return Json(ApiResponse::validation_error(
321                "Session not found or expired",
322            ));
323        }
324    };
325
326    // Delete session immediately to prevent replay attacks
327    if let Err(e) = storage.delete_kv(&session_key).await {
328        tracing::warn!("Failed to delete WebAuthn registration session: {}", e);
329    }
330
331    // Basic validation of credential data
332    if request.credential_id.is_empty() || request.attestation_object.is_empty() {
333        return Json(ApiResponse::validation_error("Invalid credential data"));
334    }
335
336    // Verify client_data_json: challenge, origin, and type
337    let client_data_bytes = match base64::engine::general_purpose::URL_SAFE_NO_PAD
338        .decode(&request.client_data_json)
339        .or_else(|_| base64::engine::general_purpose::STANDARD.decode(&request.client_data_json))
340    {
341        Ok(b) => b,
342        Err(_) => {
343            return Json(ApiResponse::validation_error(
344                "Invalid client_data_json encoding",
345            ));
346        }
347    };
348
349    let client_data: serde_json::Value = match serde_json::from_slice(&client_data_bytes) {
350        Ok(v) => v,
351        Err(_) => {
352            return Json(ApiResponse::validation_error(
353                "Invalid client_data_json format",
354            ));
355        }
356    };
357
358    // Verify type is "webauthn.create"
359    if client_data.get("type").and_then(|t| t.as_str()) != Some("webauthn.create") {
360        return Json(ApiResponse::validation_error(
361            "Invalid ceremony type: expected webauthn.create",
362        ));
363    }
364
365    // Verify challenge matches the one we stored
366    if let Some(received_challenge) = client_data.get("challenge").and_then(|c| c.as_str()) {
367        if received_challenge != stored_challenge {
368            return Json(ApiResponse::validation_error(
369                "Challenge mismatch: possible replay attack",
370            ));
371        }
372    } else {
373        return Json(ApiResponse::validation_error(
374            "Missing challenge in client data",
375        ));
376    }
377
378    // Verify origin matches the configured RP ID
379    let expected_rp_id = WebAuthnConfig::from_env().rp_id;
380    if let Some(origin) = client_data.get("origin").and_then(|o| o.as_str()) {
381        // Origin should contain the RP ID as its hostname
382        if let Ok(origin_url) = url::Url::parse(origin) {
383            if origin_url.host_str() != Some(&expected_rp_id) {
384                return Json(ApiResponse::validation_error(
385                    "Origin mismatch: does not match relying party ID",
386                ));
387            }
388        } else if origin != expected_rp_id {
389            return Json(ApiResponse::validation_error(
390                "Origin mismatch: does not match relying party ID",
391            ));
392        }
393    }
394
395    // Store the registered credential (including initial signature counter)
396    let credential_key = format!("webauthn_credential:{}:{}", username, request.credential_id);
397    let credential_data = serde_json::json!({
398        "credential_id": request.credential_id,
399        "credential_public_key": request.credential_public_key,
400        "username": username,
401        "registered_at": chrono::Utc::now().timestamp(),
402        "sign_count": 0u64
403    });
404    let _ = storage
405        .store_kv(
406            &credential_key,
407            credential_data.to_string().as_bytes(),
408            None,
409        )
410        .await;
411
412    // Update the user's credential index so authentication can enumerate them
413    let index_key = format!("webauthn_creds_index:{}", username);
414    let mut existing_ids: Vec<String> = match storage.get_kv(&index_key).await {
415        Ok(Some(data)) => serde_json::from_slice(&data).unwrap_or_default(),
416        _ => Vec::new(),
417    };
418    if !existing_ids.contains(&request.credential_id) {
419        existing_ids.push(request.credential_id.clone());
420        let _ = storage
421            .store_kv(
422                &index_key,
423                serde_json::to_string(&existing_ids)
424                    .unwrap_or_default()
425                    .as_bytes(),
426                None,
427            )
428            .await;
429    }
430
431    Json(ApiResponse::<()>::ok_with_message(
432        "WebAuthn credential registered successfully",
433    ))
434}
435
436/// Initiate WebAuthn authentication process
437pub async fn webauthn_authentication_init(
438    State(state): State<ApiState>,
439    Json(request): Json<WebAuthnAuthenticationRequest>,
440) -> Json<ApiResponse<WebAuthnAuthenticationResponse>> {
441    let mut challenge_bytes = [0u8; 32];
442    rand::rng().fill_bytes(&mut challenge_bytes);
443    let challenge = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(challenge_bytes);
444
445    let session_id = format!("webauthn_auth_{}", uuid::Uuid::new_v4());
446    let storage = state.auth_framework.storage();
447
448    // Retrieve user's registered credentials from storage
449    let username = request.username.as_deref().unwrap_or("");
450    let allow_credentials = if !username.is_empty() {
451        // Look up registered credential IDs via the user's credential index
452        let index_key = format!("webauthn_creds_index:{}", username);
453        match storage.get_kv(&index_key).await {
454            Ok(Some(data)) => {
455                if let Ok(ids) = serde_json::from_slice::<Vec<String>>(&data) {
456                    ids.into_iter()
457                        .map(|id| PublicKeyCredentialDescriptor {
458                            type_field: "public-key".to_string(),
459                            id,
460                            transports: Some(vec!["internal".to_string(), "usb".to_string()]),
461                        })
462                        .collect::<Vec<_>>()
463                } else {
464                    Vec::new()
465                }
466            }
467            _ => Vec::new(),
468        }
469    } else {
470        Vec::new()
471    };
472
473    // Store auth session with challenge
474    let session_key = format!("webauthn_auth_session:{}", session_id);
475    let session_data = serde_json::json!({
476        "challenge": challenge,
477        "username": request.username,
478        "timestamp": chrono::Utc::now().timestamp()
479    });
480    let _ = storage
481        .store_kv(
482            &session_key,
483            session_data.to_string().as_bytes(),
484            Some(std::time::Duration::from_secs(300)), // 5-minute session
485        )
486        .await;
487
488    let response = WebAuthnAuthenticationResponse {
489        challenge,
490        allow_credentials,
491        timeout: Some(60000),
492        user_verification: request.user_verification.unwrap_or("preferred".to_string()),
493        session_id,
494    };
495
496    Json(ApiResponse::success_with_message(
497        response,
498        "WebAuthn authentication challenge generated",
499    ))
500}
501
502/// Complete WebAuthn authentication process
503pub async fn webauthn_authentication_complete(
504    State(state): State<ApiState>,
505    Json(request): Json<WebAuthnAuthenticationCompleteRequest>,
506) -> Json<ApiResponse<serde_json::Value>> {
507    let storage = state.auth_framework.storage();
508    let session_key = format!("webauthn_auth_session:{}", request.session_id);
509
510    // Retrieve and validate the stored session
511    let (username, stored_challenge) = match storage.get_kv(&session_key).await {
512        Ok(Some(data)) => {
513            let session: serde_json::Value =
514                serde_json::from_slice(&data).unwrap_or(serde_json::Value::Null);
515            let uname = session
516                .get("username")
517                .and_then(|u| u.as_str())
518                .unwrap_or("webauthn_user")
519                .to_string();
520            let challenge = session
521                .get("challenge")
522                .and_then(|c| c.as_str())
523                .unwrap_or("")
524                .to_string();
525            (uname, challenge)
526        }
527        _ => {
528            return Json(ApiResponse::validation_error_typed(
529                "Authentication session not found or expired",
530            ));
531        }
532    };
533
534    // Delete session immediately to prevent replay attacks
535    if let Err(e) = storage.delete_kv(&session_key).await {
536        tracing::warn!("Failed to delete WebAuthn authentication session: {}", e);
537    }
538
539    // Verify client_data_json: challenge, origin, and type
540    let client_data_bytes = match base64::engine::general_purpose::URL_SAFE_NO_PAD
541        .decode(&request.client_data_json)
542        .or_else(|_| base64::engine::general_purpose::STANDARD.decode(&request.client_data_json))
543    {
544        Ok(b) => b,
545        Err(_) => {
546            return Json(ApiResponse::validation_error_typed(
547                "Invalid client_data_json encoding",
548            ));
549        }
550    };
551
552    let client_data: serde_json::Value = match serde_json::from_slice(&client_data_bytes) {
553        Ok(v) => v,
554        Err(_) => {
555            return Json(ApiResponse::validation_error_typed(
556                "Invalid client_data_json format",
557            ));
558        }
559    };
560
561    // Verify type is "webauthn.get"
562    if client_data.get("type").and_then(|t| t.as_str()) != Some("webauthn.get") {
563        return Json(ApiResponse::validation_error_typed(
564            "Invalid ceremony type: expected webauthn.get",
565        ));
566    }
567
568    // Verify challenge matches the one we stored
569    if let Some(received_challenge) = client_data.get("challenge").and_then(|c| c.as_str()) {
570        if received_challenge != stored_challenge {
571            return Json(ApiResponse::validation_error_typed(
572                "Challenge mismatch: possible replay attack",
573            ));
574        }
575    } else {
576        return Json(ApiResponse::validation_error_typed(
577            "Missing challenge in client data",
578        ));
579    }
580
581    // Verify origin matches the configured RP ID
582    let expected_rp_id = WebAuthnConfig::from_env().rp_id;
583    if let Some(origin) = client_data.get("origin").and_then(|o| o.as_str()) {
584        if let Ok(origin_url) = url::Url::parse(origin) {
585            if origin_url.host_str() != Some(&expected_rp_id) {
586                return Json(ApiResponse::validation_error_typed(
587                    "Origin mismatch: does not match relying party ID",
588                ));
589            }
590        } else if origin != expected_rp_id {
591            return Json(ApiResponse::validation_error_typed(
592                "Origin mismatch: does not match relying party ID",
593            ));
594        }
595    }
596
597    // Retrieve stored credential to verify it exists and check signature counter
598    let credential_key = format!("webauthn_credential:{}:{}", username, request.credential_id);
599    let stored_credential = match storage.get_kv(&credential_key).await {
600        Ok(Some(data)) => {
601            serde_json::from_slice::<serde_json::Value>(&data).unwrap_or(serde_json::Value::Null)
602        }
603        _ => {
604            return Json(ApiResponse::validation_error_typed(
605                "Credential not found for this user",
606            ));
607        }
608    };
609
610    // Check and update signature counter to detect cloned authenticators
611    let stored_count = stored_credential
612        .get("sign_count")
613        .and_then(|c| c.as_u64())
614        .unwrap_or(0);
615    // Extract sign_count from authenticator_data (bytes 33-36 are the counter, big-endian)
616    let new_count = base64::engine::general_purpose::URL_SAFE_NO_PAD
617        .decode(&request.authenticator_data)
618        .or_else(|_| base64::engine::general_purpose::STANDARD.decode(&request.authenticator_data))
619        .ok()
620        .filter(|d| d.len() >= 37)
621        .map(|d| u32::from_be_bytes([d[33], d[34], d[35], d[36]]) as u64)
622        .unwrap_or(0);
623    if new_count > 0 && new_count <= stored_count {
624        tracing::warn!(
625            "WebAuthn signature counter regression for user {}: stored={}, received={}. Possible cloned authenticator.",
626            username,
627            stored_count,
628            new_count
629        );
630        return Json(ApiResponse::validation_error_typed(
631            "Signature counter regression detected: possible cloned authenticator",
632        ));
633    }
634
635    // ---- Cryptographic signature verification (WebAuthn §7.2 step 19-20) ----
636    // 1. Decode the authenticator data and the raw client data JSON bytes
637    let auth_data_bytes = match base64::engine::general_purpose::URL_SAFE_NO_PAD
638        .decode(&request.authenticator_data)
639        .or_else(|_| base64::engine::general_purpose::STANDARD.decode(&request.authenticator_data))
640    {
641        Ok(b) => b,
642        Err(_) => {
643            return Json(ApiResponse::validation_error_typed(
644                "Invalid authenticator_data encoding",
645            ));
646        }
647    };
648
649    // 2. Compute SHA-256 hash of the raw client_data_json bytes
650    let client_data_hash = {
651        let mut hasher = Sha256::new();
652        hasher.update(&client_data_bytes);
653        hasher.finalize()
654    };
655
656    // 3. Build the signed message: authenticatorData || SHA-256(clientDataJSON)
657    let mut signed_message = auth_data_bytes.clone();
658    signed_message.extend_from_slice(&client_data_hash);
659
660    // 4. Decode the signature
661    let signature_bytes = match base64::engine::general_purpose::URL_SAFE_NO_PAD
662        .decode(&request.signature)
663        .or_else(|_| base64::engine::general_purpose::STANDARD.decode(&request.signature))
664    {
665        Ok(b) => b,
666        Err(_) => {
667            return Json(ApiResponse::validation_error_typed(
668                "Invalid signature encoding",
669            ));
670        }
671    };
672
673    // 5. Retrieve the stored public key and verify the signature
674    let credential_pub_key = stored_credential
675        .get("credential_public_key")
676        .and_then(|k| k.as_str())
677        .unwrap_or("");
678
679    if credential_pub_key.is_empty() {
680        return Json(ApiResponse::validation_error_typed(
681            "No public key stored for this credential",
682        ));
683    }
684
685    // Decode the stored public key (base64url or standard base64)
686    let pub_key_bytes = match base64::engine::general_purpose::URL_SAFE_NO_PAD
687        .decode(credential_pub_key)
688        .or_else(|_| base64::engine::general_purpose::STANDARD.decode(credential_pub_key))
689    {
690        Ok(b) => b,
691        Err(_) => {
692            return Json(ApiResponse::validation_error_typed(
693                "Failed to decode stored public key",
694            ));
695        }
696    };
697
698    // Try ES256 (ECDSA P-256) first, then RS256 (RSA PKCS#1 v1.5 with SHA-256)
699    let sig_valid = {
700        // Attempt ES256 verification (COSE algorithm -7)
701        let es256_result = ring::signature::UnparsedPublicKey::new(
702            &ring::signature::ECDSA_P256_SHA256_ASN1,
703            &pub_key_bytes,
704        )
705        .verify(&signed_message, &signature_bytes);
706
707        if es256_result.is_ok() {
708            true
709        } else {
710            // Attempt RS256 verification (COSE algorithm -257)
711            ring::signature::UnparsedPublicKey::new(
712                &ring::signature::RSA_PKCS1_2048_8192_SHA256,
713                &pub_key_bytes,
714            )
715            .verify(&signed_message, &signature_bytes)
716            .is_ok()
717        }
718    };
719
720    if !sig_valid {
721        tracing::warn!(
722            "WebAuthn signature verification failed for user {} credential {}",
723            username,
724            request.credential_id
725        );
726        return Json(ApiResponse::validation_error_typed(
727            "Signature verification failed: authentication assertion is not valid",
728        ));
729    }
730
731    // Update the stored counter
732    let mut updated_cred = stored_credential.clone();
733    if let Some(obj) = updated_cred.as_object_mut() {
734        obj.insert("sign_count".to_string(), serde_json::json!(new_count));
735    }
736    if let Err(e) = storage
737        .store_kv(
738            &credential_key,
739            serde_json::to_string(&updated_cred)
740                .unwrap_or_default()
741                .as_bytes(),
742            None,
743        )
744        .await
745    {
746        tracing::warn!("Failed to update WebAuthn credential counter for {}: {}", username, e);
747    }
748
749    // Generate authentication token for the verified user
750    let token_lifetime = state.auth_framework.config().token_lifetime;
751    let token = match state.auth_framework.token_manager().create_jwt_token(
752        &username,
753        vec![],
754        Some(token_lifetime),
755    ) {
756        Ok(t) => t,
757        Err(e) => {
758            return Json(ApiResponse::validation_error_typed(format!(
759                "Token generation failed: {}",
760                e
761            )));
762        }
763    };
764
765    let auth_response = serde_json::json!({
766        "access_token": token,
767        "token_type": "Bearer",
768        "expires_in": token_lifetime.as_secs(),
769        "user_id": username,
770        "authentication_method": "webauthn"
771    });
772
773    Json(ApiResponse::success_with_message(
774        auth_response,
775        "WebAuthn authentication successful",
776    ))
777}
778
779/// List user's registered WebAuthn credentials (requires authentication; user can only list own credentials)
780pub async fn list_webauthn_credentials(
781    State(state): State<ApiState>,
782    headers: HeaderMap,
783    axum::extract::Path(username): axum::extract::Path<String>,
784) -> Json<ApiResponse<Vec<serde_json::Value>>> {
785    // Require authentication
786    let token = match extract_bearer_token(&headers) {
787        Some(t) => t,
788        None => {
789            return Json(ApiResponse::error_typed(
790                "UNAUTHORIZED",
791                "Authentication required",
792            ));
793        }
794    };
795    let auth_token = match validate_api_token(&state.auth_framework, &token).await {
796        Ok(t) => t,
797        Err(_) => {
798            return Json(ApiResponse::error_typed(
799                "UNAUTHORIZED",
800                "Invalid or expired token",
801            ));
802        }
803    };
804
805    // Authorize: user can only list their own credentials (admins can list any)
806    if auth_token.user_id != username && !auth_token.roles.contains(&"admin".to_string()) {
807        return Json(ApiResponse::error_typed(
808            "FORBIDDEN",
809            "You can only view your own credentials",
810        ));
811    }
812
813    let storage = state.auth_framework.storage();
814    let index_key = format!("webauthn_creds_index:{}", username);
815
816    let credentials = match storage.get_kv(&index_key).await {
817        Ok(Some(data)) => {
818            if let Ok(ids) = serde_json::from_slice::<Vec<String>>(&data) {
819                let mut creds = Vec::new();
820                for id in ids {
821                    let cred_key = format!("webauthn_credential:{}:{}", username, id);
822                    if let Ok(Some(cred_data)) = storage.get_kv(&cred_key).await
823                        && let Ok(cred) = serde_json::from_slice::<serde_json::Value>(&cred_data)
824                    {
825                        creds.push(cred);
826                    }
827                }
828                creds
829            } else {
830                Vec::new()
831            }
832        }
833        _ => Vec::new(),
834    };
835
836    Json(ApiResponse::success_with_message(
837        credentials,
838        format!("WebAuthn credentials retrieved for user: {}", username),
839    ))
840}
841
842/// Delete a WebAuthn credential (requires authentication; user can only delete own credentials)
843pub async fn delete_webauthn_credential(
844    State(state): State<ApiState>,
845    headers: HeaderMap,
846    axum::extract::Path((username, credential_id)): axum::extract::Path<(String, String)>,
847) -> Json<ApiResponse<()>> {
848    // Require authentication
849    let token = match extract_bearer_token(&headers) {
850        Some(t) => t,
851        None => {
852            return Json(ApiResponse::error(
853                "UNAUTHORIZED",
854                "Authentication required",
855            ));
856        }
857    };
858    let auth_token = match validate_api_token(&state.auth_framework, &token).await {
859        Ok(t) => t,
860        Err(_) => {
861            return Json(ApiResponse::error(
862                "UNAUTHORIZED",
863                "Invalid or expired token",
864            ));
865        }
866    };
867
868    // Authorize: user can only delete their own credentials (admins can delete any)
869    if auth_token.user_id != username && !auth_token.roles.contains(&"admin".to_string()) {
870        return Json(ApiResponse::error(
871            "FORBIDDEN",
872            "You can only delete your own credentials",
873        ));
874    }
875
876    let storage = state.auth_framework.storage();
877    let credential_key = format!("webauthn_credential:{}:{}", username, credential_id);
878
879    // Check credential exists before deleting
880    match storage.get_kv(&credential_key).await {
881        Ok(Some(_)) => {
882            if let Err(e) = storage.delete_kv(&credential_key).await {
883                tracing::warn!("Failed to delete WebAuthn credential {}: {}", credential_id, e);
884            }
885
886            // Update the credentials index
887            let index_key = format!("webauthn_creds_index:{}", username);
888            if let Ok(Some(idx_data)) = storage.get_kv(&index_key).await
889                && let Ok(mut ids) = serde_json::from_slice::<Vec<String>>(&idx_data)
890            {
891                ids.retain(|id| id != &credential_id);
892                if let Err(e) = storage
893                    .store_kv(
894                        &index_key,
895                        serde_json::to_string(&ids).unwrap_or_default().as_bytes(),
896                        None,
897                    )
898                    .await
899                {
900                    tracing::warn!("Failed to update WebAuthn credentials index for {}: {}", username, e);
901                }
902            }
903
904            Json(ApiResponse::<()>::ok_with_message(
905                "WebAuthn credential deleted successfully",
906            ))
907        }
908        _ => Json(ApiResponse::validation_error("Credential not found")),
909    }
910}
911
912#[cfg(test)]
913mod tests {
914    use super::*;
915
916    #[test]
917    fn test_webauthn_config_default() {
918        let cfg = WebAuthnConfig::default();
919        assert_eq!(cfg.rp_id, "localhost");
920        assert_eq!(cfg.rp_name, "AuthFramework");
921        assert_eq!(cfg.attestation, "direct");
922        assert_eq!(cfg.timeout_ms, 60_000);
923    }
924
925    #[test]
926    fn test_webauthn_config_new_and_chain() {
927        let cfg = WebAuthnConfig::new("auth.example.com", "My Service")
928            .attestation("none")
929            .timeout(120_000);
930        assert_eq!(cfg.rp_id, "auth.example.com");
931        assert_eq!(cfg.rp_name, "My Service");
932        assert_eq!(cfg.attestation, "none");
933        assert_eq!(cfg.timeout_ms, 120_000);
934    }
935
936    #[test]
937    fn test_pubkey_cred_params_presets() {
938        let es = PublicKeyCredentialParameters::es256();
939        assert_eq!(es.alg, -7);
940        assert_eq!(es.type_field, "public-key");
941
942        let rs = PublicKeyCredentialParameters::rs256();
943        assert_eq!(rs.alg, -257);
944        assert_eq!(rs.type_field, "public-key");
945    }
946
947    #[test]
948    fn test_pubkey_cred_params_defaults_contains_both() {
949        let params = PublicKeyCredentialParameters::defaults();
950        assert_eq!(params.len(), 2);
951        assert_eq!(params[0].alg, -7);
952        assert_eq!(params[1].alg, -257);
953    }
954}