Skip to main content

better_auth_api/plugins/
passkey.rs

1use async_trait::async_trait;
2use base64::Engine;
3use base64::engine::general_purpose::STANDARD;
4use base64::engine::general_purpose::URL_SAFE_NO_PAD;
5use rand::RngCore;
6use serde::{Deserialize, Serialize};
7use validator::Validate;
8
9use better_auth_core::adapters::DatabaseAdapter;
10use better_auth_core::entity::{AuthPasskey, AuthSession, AuthUser};
11use better_auth_core::{AuthContext, AuthPlugin, AuthRoute};
12use better_auth_core::{AuthError, AuthResult};
13use better_auth_core::{AuthRequest, AuthResponse, CreatePasskey, CreateVerification, HttpMethod};
14
15use better_auth_core::utils::cookie_utils::create_session_cookie;
16
17use super::StatusResponse;
18
19/// Passkey / WebAuthn authentication plugin.
20///
21/// Generates WebAuthn-compatible registration and authentication options,
22/// stores challenge state via `VerificationOps`, and manages passkey CRUD.
23///
24/// **WARNING: Simplified WebAuthn mode.**
25/// This implementation does NOT perform full FIDO2 signature verification
26/// (rpId, origin, authenticatorData, signature). It trusts the client-side
27/// WebAuthn response after verifying the challenge round-trip. For production
28/// use, integrate `webauthn-rs` or another FIDO2 library for full attestation
29/// and assertion verification.
30pub struct PasskeyPlugin {
31    config: PasskeyConfig,
32}
33
34#[derive(Debug, Clone)]
35pub struct PasskeyConfig {
36    pub rp_id: String,
37    pub rp_name: String,
38    pub origin: String,
39    pub challenge_ttl_secs: i64,
40    /// Allows simplified (non-cryptographic) response verification.
41    ///
42    /// Keep disabled in production. This exists only for local development
43    /// until full WebAuthn validation is integrated.
44    pub allow_insecure_unverified_assertion: bool,
45}
46
47impl Default for PasskeyConfig {
48    fn default() -> Self {
49        Self {
50            rp_id: "localhost".to_string(),
51            rp_name: "Better Auth".to_string(),
52            origin: "http://localhost:3000".to_string(),
53            challenge_ttl_secs: 300, // 5 minutes
54            allow_insecure_unverified_assertion: false,
55        }
56    }
57}
58
59// -- Request types --
60
61#[derive(Debug, Deserialize, Validate)]
62#[serde(rename_all = "camelCase")]
63struct VerifyRegistrationRequest {
64    response: serde_json::Value,
65    name: Option<String>,
66}
67
68#[derive(Debug, Deserialize, Validate)]
69#[serde(rename_all = "camelCase")]
70struct VerifyAuthenticationRequest {
71    response: serde_json::Value,
72}
73
74#[derive(Debug, Deserialize, Validate)]
75#[serde(rename_all = "camelCase")]
76struct DeletePasskeyRequest {
77    #[validate(length(min = 1))]
78    id: String,
79}
80
81#[derive(Debug, Deserialize, Validate)]
82#[serde(rename_all = "camelCase")]
83struct UpdatePasskeyRequest {
84    #[validate(length(min = 1))]
85    id: String,
86    #[validate(length(min = 1))]
87    name: String,
88}
89
90// -- Response helpers --
91
92#[derive(Debug, Serialize)]
93struct PasskeyView {
94    id: String,
95    name: String,
96    #[serde(rename = "credentialID")]
97    credential_id: String,
98    #[serde(rename = "userId")]
99    user_id: String,
100    #[serde(rename = "publicKey")]
101    public_key: String,
102    counter: u64,
103    #[serde(rename = "deviceType")]
104    device_type: String,
105    #[serde(rename = "backedUp")]
106    backed_up: bool,
107    #[serde(skip_serializing_if = "Option::is_none")]
108    transports: Option<String>,
109    #[serde(rename = "createdAt")]
110    created_at: String,
111}
112
113impl PasskeyView {
114    fn from_entity(pk: &impl AuthPasskey) -> Self {
115        Self {
116            id: pk.id().to_string(),
117            name: pk.name().to_string(),
118            credential_id: pk.credential_id().to_string(),
119            user_id: pk.user_id().to_string(),
120            public_key: pk.public_key().to_string(),
121            counter: pk.counter(),
122            device_type: pk.device_type().to_string(),
123            backed_up: pk.backed_up(),
124            transports: pk.transports().map(|s| s.to_string()),
125            created_at: pk.created_at().to_rfc3339(),
126        }
127    }
128}
129
130#[derive(Debug, Serialize)]
131struct SessionUserResponse<U: Serialize, S: Serialize> {
132    session: S,
133    user: U,
134}
135
136#[derive(Debug, Serialize)]
137struct PasskeyResponse {
138    passkey: PasskeyView,
139}
140
141// -- Plugin --
142
143impl PasskeyPlugin {
144    pub fn new() -> Self {
145        Self {
146            config: PasskeyConfig::default(),
147        }
148    }
149
150    pub fn with_config(config: PasskeyConfig) -> Self {
151        Self { config }
152    }
153
154    pub fn rp_id(mut self, rp_id: impl Into<String>) -> Self {
155        self.config.rp_id = rp_id.into();
156        self
157    }
158
159    pub fn rp_name(mut self, rp_name: impl Into<String>) -> Self {
160        self.config.rp_name = rp_name.into();
161        self
162    }
163
164    pub fn origin(mut self, origin: impl Into<String>) -> Self {
165        self.config.origin = origin.into();
166        self
167    }
168
169    pub fn allow_insecure_unverified_assertion(mut self, allow: bool) -> Self {
170        self.config.allow_insecure_unverified_assertion = allow;
171        self
172    }
173
174    // -- Helpers --
175
176    fn generate_challenge() -> String {
177        let mut bytes = [0u8; 32];
178        rand::rngs::OsRng.fill_bytes(&mut bytes);
179        URL_SAFE_NO_PAD.encode(bytes)
180    }
181
182    fn ensure_insecure_verification_enabled(&self) -> AuthResult<()> {
183        if self.config.allow_insecure_unverified_assertion {
184            Ok(())
185        } else {
186            Err(AuthError::not_implemented(
187                "Passkey verification requires full WebAuthn signature validation. \
188                Set `allow_insecure_unverified_assertion = true` only for local development.",
189            ))
190        }
191    }
192
193    fn decode_client_data_json(response: &serde_json::Value) -> AuthResult<serde_json::Value> {
194        let encoded = response
195            .get("response")
196            .and_then(|r| r.get("clientDataJSON"))
197            .and_then(|v| v.as_str())
198            .ok_or_else(|| AuthError::bad_request("Missing clientDataJSON in response"))?;
199
200        let decode_and_parse = |bytes: Vec<u8>| -> Option<serde_json::Value> {
201            serde_json::from_slice::<serde_json::Value>(&bytes).ok()
202        };
203
204        if let Ok(bytes) = URL_SAFE_NO_PAD.decode(encoded)
205            && let Some(client_data) = decode_and_parse(bytes)
206        {
207            return Ok(client_data);
208        }
209
210        if let Ok(bytes) = STANDARD.decode(encoded)
211            && let Some(client_data) = decode_and_parse(bytes)
212        {
213            return Ok(client_data);
214        }
215
216        Err(AuthError::bad_request("Invalid clientDataJSON encoding"))
217    }
218
219    fn validate_client_data(
220        &self,
221        client_data: &serde_json::Value,
222        expected_type: &str,
223    ) -> AuthResult<String> {
224        let client_type = client_data
225            .get("type")
226            .and_then(|v| v.as_str())
227            .ok_or_else(|| AuthError::bad_request("Missing clientDataJSON.type"))?;
228
229        if client_type != expected_type {
230            return Err(AuthError::bad_request(format!(
231                "Invalid clientDataJSON.type, expected {}",
232                expected_type
233            )));
234        }
235
236        let origin = client_data
237            .get("origin")
238            .and_then(|v| v.as_str())
239            .ok_or_else(|| AuthError::bad_request("Missing clientDataJSON.origin"))?;
240
241        if origin != self.config.origin {
242            return Err(AuthError::bad_request("Invalid clientDataJSON.origin"));
243        }
244
245        let challenge = client_data
246            .get("challenge")
247            .and_then(|v| v.as_str())
248            .ok_or_else(|| AuthError::bad_request("Missing clientDataJSON.challenge"))?;
249
250        Ok(challenge.to_string())
251    }
252
253    // -- Handlers --
254
255    /// GET /passkey/generate-register-options
256    async fn handle_generate_register_options<DB: DatabaseAdapter>(
257        &self,
258        req: &AuthRequest,
259        ctx: &AuthContext<DB>,
260    ) -> AuthResult<AuthResponse> {
261        let (user, _session) = ctx.require_session(req).await?;
262
263        let challenge = Self::generate_challenge();
264
265        // Store challenge as a verification token
266        let identifier = format!("passkey_reg:{}", user.id());
267        let expires_at =
268            chrono::Utc::now() + chrono::Duration::seconds(self.config.challenge_ttl_secs);
269        ctx.database
270            .create_verification(CreateVerification {
271                identifier: identifier.clone(),
272                value: challenge.clone(),
273                expires_at,
274            })
275            .await?;
276
277        // Build excludeCredentials from existing passkeys
278        let existing_passkeys = ctx.database.list_passkeys_by_user(user.id()).await?;
279        let exclude_credentials: Vec<serde_json::Value> = existing_passkeys
280            .iter()
281            .map(|pk| {
282                let mut cred = serde_json::json!({
283                    "type": "public-key",
284                    "id": pk.credential_id(),
285                });
286                if let Some(transports) = pk.transports()
287                    && let Ok(t) = serde_json::from_str::<Vec<String>>(transports)
288                {
289                    cred["transports"] = serde_json::json!(t);
290                }
291                cred
292            })
293            .collect();
294
295        // Read optional authenticatorAttachment from query params
296        let authenticator_attachment = req
297            .query
298            .get("authenticatorAttachment")
299            .cloned()
300            .unwrap_or_else(|| "platform".to_string());
301
302        let user_id_b64 = URL_SAFE_NO_PAD.encode(user.id().as_bytes());
303        let display_name = user
304            .name()
305            .unwrap_or_else(|| user.email().unwrap_or("user"));
306        let user_name = user
307            .email()
308            .unwrap_or_else(|| user.name().unwrap_or("user"));
309
310        let options = serde_json::json!({
311            "challenge": challenge,
312            "rp": {
313                "name": self.config.rp_name,
314                "id": self.config.rp_id,
315            },
316            "user": {
317                "id": user_id_b64,
318                "name": user_name,
319                "displayName": display_name,
320            },
321            "pubKeyCredParams": [
322                { "type": "public-key", "alg": -7 },
323                { "type": "public-key", "alg": -257 },
324            ],
325            "timeout": 60000,
326            "excludeCredentials": exclude_credentials,
327            "authenticatorSelection": {
328                "authenticatorAttachment": authenticator_attachment,
329                "requireResidentKey": false,
330                "userVerification": "preferred",
331            },
332            "attestation": "none",
333        });
334
335        AuthResponse::json(200, &options).map_err(AuthError::from)
336    }
337
338    /// POST /passkey/verify-registration
339    async fn handle_verify_registration<DB: DatabaseAdapter>(
340        &self,
341        req: &AuthRequest,
342        ctx: &AuthContext<DB>,
343    ) -> AuthResult<AuthResponse> {
344        self.ensure_insecure_verification_enabled()?;
345        let (user, _session) = ctx.require_session(req).await?;
346
347        let body: VerifyRegistrationRequest = match better_auth_core::validate_request_body(req) {
348            Ok(v) => v,
349            Err(resp) => return Ok(resp),
350        };
351
352        let client_data = Self::decode_client_data_json(&body.response)?;
353        let challenge = self.validate_client_data(&client_data, "webauthn.create")?;
354
355        // Atomically consume the challenge (single-use)
356        let identifier = format!("passkey_reg:{}", user.id());
357        ctx.database
358            .consume_verification(&identifier, &challenge)
359            .await?
360            .ok_or_else(|| {
361                AuthError::bad_request(
362                    "Invalid or expired registration challenge. Please generate registration options again.",
363                )
364            })?;
365
366        // Extract credential data from the client response
367        let resp = &body.response;
368        let credential_id = resp
369            .get("id")
370            .or_else(|| resp.get("rawId"))
371            .and_then(|v| v.as_str())
372            .ok_or_else(|| AuthError::bad_request("Missing credential id in response"))?;
373
374        // Extract public key from attestation response
375        // In a simplified approach, we store the clientDataJSON as the public key representation
376        let public_key = resp
377            .get("response")
378            .and_then(|r| r.get("attestationObject"))
379            .and_then(|v| v.as_str())
380            .or_else(|| {
381                resp.get("response")
382                    .and_then(|r| r.get("clientDataJSON"))
383                    .and_then(|v| v.as_str())
384            })
385            .unwrap_or("")
386            .to_string();
387
388        // Extract device type and backup info from authenticator data or client extensions
389        let authenticator_attachment = resp
390            .get("authenticatorAttachment")
391            .and_then(|v| v.as_str())
392            .unwrap_or("platform");
393
394        let device_type = if authenticator_attachment == "cross-platform" {
395            "multiDevice"
396        } else {
397            "singleDevice"
398        }
399        .to_string();
400
401        let backed_up = resp
402            .get("clientExtensionResults")
403            .and_then(|v| v.get("credProps"))
404            .and_then(|v| v.get("rk"))
405            .and_then(|v| v.as_bool())
406            .unwrap_or(false);
407
408        // Extract transports if available
409        let transports = resp
410            .get("response")
411            .and_then(|r| r.get("transports"))
412            .map(|v| v.to_string());
413
414        let passkey_name = body
415            .name
416            .unwrap_or_else(|| format!("Passkey {}", chrono::Utc::now().format("%Y-%m-%d")));
417
418        // Create the passkey
419        let passkey = ctx
420            .database
421            .create_passkey(CreatePasskey {
422                user_id: user.id().to_string(),
423                name: passkey_name,
424                credential_id: credential_id.to_string(),
425                public_key,
426                counter: 0,
427                device_type,
428                backed_up,
429                transports,
430            })
431            .await?;
432
433        let view = PasskeyView::from_entity(&passkey);
434        AuthResponse::json(200, &view).map_err(AuthError::from)
435    }
436
437    /// POST /passkey/generate-authenticate-options
438    async fn handle_generate_authenticate_options<DB: DatabaseAdapter>(
439        &self,
440        req: &AuthRequest,
441        ctx: &AuthContext<DB>,
442    ) -> AuthResult<AuthResponse> {
443        let challenge = Self::generate_challenge();
444
445        // If user is authenticated, build allowCredentials from their passkeys
446        let allow_credentials: Vec<serde_json::Value> =
447            if let Ok((user, _session)) = ctx.require_session(req).await {
448                let passkeys = ctx.database.list_passkeys_by_user(user.id()).await?;
449                passkeys
450                    .iter()
451                    .map(|pk| {
452                        let mut cred = serde_json::json!({
453                            "type": "public-key",
454                            "id": pk.credential_id(),
455                        });
456                        if let Some(transports) = pk.transports()
457                            && let Ok(t) = serde_json::from_str::<Vec<String>>(transports)
458                        {
459                            cred["transports"] = serde_json::json!(t);
460                        }
461                        cred
462                    })
463                    .collect()
464            } else {
465                vec![]
466            };
467
468        // Store challenge with the challenge itself as part of the identifier
469        let identifier = format!("passkey_auth:{}", challenge);
470        let expires_at =
471            chrono::Utc::now() + chrono::Duration::seconds(self.config.challenge_ttl_secs);
472        ctx.database
473            .create_verification(CreateVerification {
474                identifier,
475                value: challenge.clone(),
476                expires_at,
477            })
478            .await?;
479
480        let options = serde_json::json!({
481            "challenge": challenge,
482            "timeout": 60000,
483            "rpId": self.config.rp_id,
484            "allowCredentials": allow_credentials,
485            "userVerification": "preferred",
486        });
487
488        AuthResponse::json(200, &options).map_err(AuthError::from)
489    }
490
491    /// POST /passkey/verify-authentication
492    async fn handle_verify_authentication<DB: DatabaseAdapter>(
493        &self,
494        req: &AuthRequest,
495        ctx: &AuthContext<DB>,
496    ) -> AuthResult<AuthResponse> {
497        self.ensure_insecure_verification_enabled()?;
498        let body: VerifyAuthenticationRequest = match better_auth_core::validate_request_body(req) {
499            Ok(v) => v,
500            Err(resp) => return Ok(resp),
501        };
502
503        let resp = &body.response;
504
505        // Extract credential_id from the response
506        let credential_id = resp
507            .get("id")
508            .or_else(|| resp.get("rawId"))
509            .and_then(|v| v.as_str())
510            .ok_or_else(|| AuthError::bad_request("Missing credential id in response"))?;
511
512        let client_data = Self::decode_client_data_json(resp)?;
513        let challenge = self.validate_client_data(&client_data, "webauthn.get")?;
514
515        // Atomically consume challenge so it cannot be replayed.
516        let identifier = format!("passkey_auth:{}", challenge);
517        ctx.database
518            .consume_verification(&identifier, &challenge)
519            .await?
520            .ok_or_else(|| AuthError::bad_request("Invalid or expired authentication challenge"))?;
521
522        // Look up the passkey by credential_id
523        let passkey = ctx
524            .database
525            .get_passkey_by_credential_id(credential_id)
526            .await?
527            .ok_or_else(|| AuthError::bad_request("Passkey not found for credential"))?;
528
529        // Look up the user
530        let user = ctx
531            .database
532            .get_user_by_id(passkey.user_id())
533            .await?
534            .ok_or(AuthError::UserNotFound)?;
535
536        // Update the passkey counter
537        let new_counter = passkey
538            .counter()
539            .checked_add(1)
540            .ok_or_else(|| AuthError::internal("Passkey counter overflow"))?;
541        ctx.database
542            .update_passkey_counter(passkey.id(), new_counter)
543            .await?;
544
545        // Create a session
546        let ip_address = req.headers.get("x-forwarded-for").cloned();
547        let user_agent = req.headers.get("user-agent").cloned();
548        let session_manager =
549            better_auth_core::SessionManager::new(ctx.config.clone(), ctx.database.clone());
550        let session = session_manager
551            .create_session(&user, ip_address, user_agent)
552            .await?;
553
554        let cookie_header = create_session_cookie(session.token(), ctx);
555        let response = SessionUserResponse { session, user };
556        Ok(AuthResponse::json(200, &response)?.with_header("Set-Cookie", cookie_header))
557    }
558
559    /// GET /passkey/list-user-passkeys
560    async fn handle_list_user_passkeys<DB: DatabaseAdapter>(
561        &self,
562        req: &AuthRequest,
563        ctx: &AuthContext<DB>,
564    ) -> AuthResult<AuthResponse> {
565        let (user, _session) = ctx.require_session(req).await?;
566
567        let passkeys = ctx.database.list_passkeys_by_user(user.id()).await?;
568        let views: Vec<PasskeyView> = passkeys.iter().map(PasskeyView::from_entity).collect();
569
570        AuthResponse::json(200, &views).map_err(AuthError::from)
571    }
572
573    /// POST /passkey/delete-passkey
574    async fn handle_delete_passkey<DB: DatabaseAdapter>(
575        &self,
576        req: &AuthRequest,
577        ctx: &AuthContext<DB>,
578    ) -> AuthResult<AuthResponse> {
579        let (user, _session) = ctx.require_session(req).await?;
580
581        let body: DeletePasskeyRequest = match better_auth_core::validate_request_body(req) {
582            Ok(v) => v,
583            Err(resp) => return Ok(resp),
584        };
585
586        // Verify ownership
587        let passkey = ctx
588            .database
589            .get_passkey_by_id(&body.id)
590            .await?
591            .ok_or_else(|| AuthError::not_found("Passkey not found"))?;
592
593        if passkey.user_id() != user.id() {
594            return Err(AuthError::not_found("Passkey not found"));
595        }
596
597        ctx.database.delete_passkey(&body.id).await?;
598
599        let response = StatusResponse { status: true };
600        AuthResponse::json(200, &response).map_err(AuthError::from)
601    }
602
603    /// POST /passkey/update-passkey
604    async fn handle_update_passkey<DB: DatabaseAdapter>(
605        &self,
606        req: &AuthRequest,
607        ctx: &AuthContext<DB>,
608    ) -> AuthResult<AuthResponse> {
609        let (user, _session) = ctx.require_session(req).await?;
610
611        let body: UpdatePasskeyRequest = match better_auth_core::validate_request_body(req) {
612            Ok(v) => v,
613            Err(resp) => return Ok(resp),
614        };
615
616        // Verify ownership
617        let passkey = ctx
618            .database
619            .get_passkey_by_id(&body.id)
620            .await?
621            .ok_or_else(|| AuthError::not_found("Passkey not found"))?;
622
623        if passkey.user_id() != user.id() {
624            return Err(AuthError::not_found("Passkey not found"));
625        }
626
627        let updated = ctx
628            .database
629            .update_passkey_name(&body.id, &body.name)
630            .await?;
631
632        let response = PasskeyResponse {
633            passkey: PasskeyView::from_entity(&updated),
634        };
635        AuthResponse::json(200, &response).map_err(AuthError::from)
636    }
637}
638
639impl Default for PasskeyPlugin {
640    fn default() -> Self {
641        Self::new()
642    }
643}
644
645#[async_trait]
646impl<DB: DatabaseAdapter> AuthPlugin<DB> for PasskeyPlugin {
647    fn name(&self) -> &'static str {
648        "passkey"
649    }
650
651    fn routes(&self) -> Vec<AuthRoute> {
652        vec![
653            AuthRoute::get(
654                "/passkey/generate-register-options",
655                "passkey_generate_register_options",
656            ),
657            AuthRoute::post(
658                "/passkey/verify-registration",
659                "passkey_verify_registration",
660            ),
661            AuthRoute::post(
662                "/passkey/generate-authenticate-options",
663                "passkey_generate_authenticate_options",
664            ),
665            AuthRoute::post(
666                "/passkey/verify-authentication",
667                "passkey_verify_authentication",
668            ),
669            AuthRoute::get("/passkey/list-user-passkeys", "passkey_list_user_passkeys"),
670            AuthRoute::post("/passkey/delete-passkey", "passkey_delete_passkey"),
671            AuthRoute::post("/passkey/update-passkey", "passkey_update_passkey"),
672        ]
673    }
674
675    async fn on_request(
676        &self,
677        req: &AuthRequest,
678        ctx: &AuthContext<DB>,
679    ) -> AuthResult<Option<AuthResponse>> {
680        match (req.method(), req.path()) {
681            (HttpMethod::Get, "/passkey/generate-register-options") => {
682                Ok(Some(self.handle_generate_register_options(req, ctx).await?))
683            }
684            (HttpMethod::Post, "/passkey/verify-registration") => {
685                Ok(Some(self.handle_verify_registration(req, ctx).await?))
686            }
687            (HttpMethod::Post, "/passkey/generate-authenticate-options") => Ok(Some(
688                self.handle_generate_authenticate_options(req, ctx).await?,
689            )),
690            (HttpMethod::Post, "/passkey/verify-authentication") => {
691                Ok(Some(self.handle_verify_authentication(req, ctx).await?))
692            }
693            (HttpMethod::Get, "/passkey/list-user-passkeys") => {
694                Ok(Some(self.handle_list_user_passkeys(req, ctx).await?))
695            }
696            (HttpMethod::Post, "/passkey/delete-passkey") => {
697                Ok(Some(self.handle_delete_passkey(req, ctx).await?))
698            }
699            (HttpMethod::Post, "/passkey/update-passkey") => {
700                Ok(Some(self.handle_update_passkey(req, ctx).await?))
701            }
702            _ => Ok(None),
703        }
704    }
705}
706
707#[cfg(test)]
708mod tests {
709    use super::*;
710    use crate::plugins::test_helpers;
711    use better_auth_core::adapters::{PasskeyOps, UserOps, VerificationOps};
712    use better_auth_core::{CreateUser, CreateVerification};
713    use chrono::{Duration, Utc};
714
715    fn encoded_client_data(challenge: &str, client_type: &str, origin: &str) -> String {
716        let client_data = serde_json::json!({
717            "type": client_type,
718            "challenge": challenge,
719            "origin": origin,
720        });
721        URL_SAFE_NO_PAD.encode(serde_json::to_vec(&client_data).unwrap())
722    }
723
724    #[tokio::test]
725    async fn test_verify_registration_requires_insecure_opt_in() {
726        let plugin = PasskeyPlugin::new();
727        let (ctx, _user, session) = test_helpers::create_test_context_with_user(
728            CreateUser::new()
729                .with_email("passkey-test@example.com")
730                .with_name("Passkey Tester"),
731            Duration::hours(1),
732        )
733        .await;
734
735        let body = serde_json::json!({
736            "response": {
737                "id": "cred-1",
738                "response": {
739                    "clientDataJSON": encoded_client_data("challenge-1", "webauthn.create", "http://localhost:3000"),
740                }
741            }
742        });
743
744        let req = test_helpers::create_auth_json_request_no_query(
745            HttpMethod::Post,
746            "/passkey/verify-registration",
747            Some(&session.token),
748            Some(body),
749        );
750
751        let err = plugin
752            .handle_verify_registration(&req, &ctx)
753            .await
754            .unwrap_err();
755        assert_eq!(err.status_code(), 501);
756    }
757
758    #[tokio::test]
759    async fn test_verify_registration_consumes_exact_challenge_once() {
760        let plugin = PasskeyPlugin::new().allow_insecure_unverified_assertion(true);
761        let (ctx, user, session) = test_helpers::create_test_context_with_user(
762            CreateUser::new()
763                .with_email("passkey-test@example.com")
764                .with_name("Passkey Tester"),
765            Duration::hours(1),
766        )
767        .await;
768
769        let challenge = "register-challenge";
770        let identifier = format!("passkey_reg:{}", user.id);
771
772        ctx.database
773            .create_verification(CreateVerification {
774                identifier: identifier.clone(),
775                value: challenge.to_string(),
776                expires_at: Utc::now() + Duration::minutes(5),
777            })
778            .await
779            .unwrap();
780
781        let wrong_body = serde_json::json!({
782            "response": {
783                "id": "cred-reg-1",
784                "response": {
785                    "clientDataJSON": encoded_client_data("wrong-challenge", "webauthn.create", "http://localhost:3000"),
786                    "attestationObject": "fake-attestation",
787                }
788            }
789        });
790        let wrong_req = test_helpers::create_auth_json_request_no_query(
791            HttpMethod::Post,
792            "/passkey/verify-registration",
793            Some(&session.token),
794            Some(wrong_body),
795        );
796        let err = plugin
797            .handle_verify_registration(&wrong_req, &ctx)
798            .await
799            .unwrap_err();
800        assert_eq!(err.status_code(), 400);
801
802        assert!(
803            ctx.database
804                .get_verification(&identifier, challenge)
805                .await
806                .unwrap()
807                .is_some()
808        );
809
810        let ok_body = serde_json::json!({
811            "response": {
812                "id": "cred-reg-1",
813                "response": {
814                    "clientDataJSON": encoded_client_data(challenge, "webauthn.create", "http://localhost:3000"),
815                    "attestationObject": "fake-attestation",
816                }
817            }
818        });
819        let ok_req = test_helpers::create_auth_json_request_no_query(
820            HttpMethod::Post,
821            "/passkey/verify-registration",
822            Some(&session.token),
823            Some(ok_body),
824        );
825        let response = plugin
826            .handle_verify_registration(&ok_req, &ctx)
827            .await
828            .unwrap();
829        assert_eq!(response.status, 200);
830
831        assert!(
832            ctx.database
833                .get_verification(&identifier, challenge)
834                .await
835                .unwrap()
836                .is_none()
837        );
838
839        let passkeys = ctx.database.list_passkeys_by_user(&user.id).await.unwrap();
840        assert_eq!(passkeys.len(), 1);
841    }
842
843    #[tokio::test]
844    async fn test_verify_authentication_checks_type_origin_and_prevents_replay() {
845        let plugin = PasskeyPlugin::new().allow_insecure_unverified_assertion(true);
846        let (ctx, user, _session) = test_helpers::create_test_context_with_user(
847            CreateUser::new()
848                .with_email("passkey-test@example.com")
849                .with_name("Passkey Tester"),
850            Duration::hours(1),
851        )
852        .await;
853
854        let credential_id = "cred-auth-1";
855        ctx.database
856            .create_passkey(CreatePasskey {
857                user_id: user.id.clone(),
858                name: "Authenticator".to_string(),
859                credential_id: credential_id.to_string(),
860                public_key: "fake-public-key".to_string(),
861                counter: 0,
862                device_type: "singleDevice".to_string(),
863                backed_up: false,
864                transports: None,
865            })
866            .await
867            .unwrap();
868
869        let challenge = "auth-challenge-1";
870        let identifier = format!("passkey_auth:{}", challenge);
871
872        ctx.database
873            .create_verification(CreateVerification {
874                identifier: identifier.clone(),
875                value: challenge.to_string(),
876                expires_at: Utc::now() + Duration::minutes(5),
877            })
878            .await
879            .unwrap();
880
881        let wrong_type_body = serde_json::json!({
882            "response": {
883                "id": credential_id,
884                "response": {
885                    "clientDataJSON": encoded_client_data(challenge, "webauthn.create", "http://localhost:3000"),
886                }
887            }
888        });
889        let wrong_type_req = test_helpers::create_auth_json_request_no_query(
890            HttpMethod::Post,
891            "/passkey/verify-authentication",
892            None,
893            Some(wrong_type_body),
894        );
895        let err = plugin
896            .handle_verify_authentication(&wrong_type_req, &ctx)
897            .await
898            .unwrap_err();
899        assert_eq!(err.status_code(), 400);
900
901        let wrong_origin_body = serde_json::json!({
902            "response": {
903                "id": credential_id,
904                "response": {
905                    "clientDataJSON": encoded_client_data(challenge, "webauthn.get", "http://evil.example"),
906                }
907            }
908        });
909        let wrong_origin_req = test_helpers::create_auth_json_request_no_query(
910            HttpMethod::Post,
911            "/passkey/verify-authentication",
912            None,
913            Some(wrong_origin_body),
914        );
915        let err = plugin
916            .handle_verify_authentication(&wrong_origin_req, &ctx)
917            .await
918            .unwrap_err();
919        assert_eq!(err.status_code(), 400);
920
921        assert!(
922            ctx.database
923                .get_verification(&identifier, challenge)
924                .await
925                .unwrap()
926                .is_some()
927        );
928
929        let ok_body = serde_json::json!({
930            "response": {
931                "id": credential_id,
932                "response": {
933                    "clientDataJSON": encoded_client_data(challenge, "webauthn.get", "http://localhost:3000"),
934                }
935            }
936        });
937        let ok_req = test_helpers::create_auth_json_request_no_query(
938            HttpMethod::Post,
939            "/passkey/verify-authentication",
940            None,
941            Some(ok_body.clone()),
942        );
943        let response = plugin
944            .handle_verify_authentication(&ok_req, &ctx)
945            .await
946            .unwrap();
947        assert_eq!(response.status, 200);
948
949        assert!(
950            ctx.database
951                .get_verification(&identifier, challenge)
952                .await
953                .unwrap()
954                .is_none()
955        );
956
957        let replay_req = test_helpers::create_auth_json_request_no_query(
958            HttpMethod::Post,
959            "/passkey/verify-authentication",
960            None,
961            Some(ok_body),
962        );
963        let err = plugin
964            .handle_verify_authentication(&replay_req, &ctx)
965            .await
966            .unwrap_err();
967        assert_eq!(err.status_code(), 400);
968
969        let passkey = ctx
970            .database
971            .get_passkey_by_credential_id(credential_id)
972            .await
973            .unwrap()
974            .unwrap();
975        assert_eq!(passkey.counter(), 1);
976    }
977
978    #[tokio::test]
979    async fn test_generate_register_options_returns_challenge_and_stores_verification() {
980        let plugin = PasskeyPlugin::new();
981        let (ctx, user, session) = test_helpers::create_test_context_with_user(
982            CreateUser::new()
983                .with_email("passkey-test@example.com")
984                .with_name("Passkey Tester"),
985            Duration::hours(1),
986        )
987        .await;
988
989        let req = test_helpers::create_auth_json_request_no_query(
990            HttpMethod::Get,
991            "/passkey/generate-register-options",
992            Some(&session.token),
993            None,
994        );
995
996        let response = plugin
997            .handle_generate_register_options(&req, &ctx)
998            .await
999            .unwrap();
1000        assert_eq!(response.status, 200);
1001
1002        let body: serde_json::Value = serde_json::from_slice(&response.body).unwrap();
1003        assert!(body["challenge"].is_string());
1004        assert_eq!(body["rp"]["id"], "localhost");
1005        assert_eq!(body["rp"]["name"], "Better Auth");
1006        assert!(body["user"]["id"].is_string());
1007        assert!(body["pubKeyCredParams"].is_array());
1008        assert!(body["excludeCredentials"].is_array());
1009
1010        // Verify challenge was stored
1011        let challenge = body["challenge"].as_str().unwrap();
1012        let identifier = format!("passkey_reg:{}", user.id);
1013        let verification = ctx
1014            .database
1015            .get_verification(&identifier, challenge)
1016            .await
1017            .unwrap();
1018        assert!(verification.is_some());
1019    }
1020
1021    #[tokio::test]
1022    async fn test_generate_register_options_unauthenticated() {
1023        let plugin = PasskeyPlugin::new();
1024        let (ctx, _user, _session) = test_helpers::create_test_context_with_user(
1025            CreateUser::new()
1026                .with_email("passkey-test@example.com")
1027                .with_name("Passkey Tester"),
1028            Duration::hours(1),
1029        )
1030        .await;
1031
1032        let req = test_helpers::create_auth_json_request_no_query(
1033            HttpMethod::Get,
1034            "/passkey/generate-register-options",
1035            None,
1036            None,
1037        );
1038
1039        let err = plugin
1040            .handle_generate_register_options(&req, &ctx)
1041            .await
1042            .unwrap_err();
1043        assert_eq!(err.status_code(), 401);
1044    }
1045
1046    #[tokio::test]
1047    async fn test_generate_authenticate_options_returns_challenge() {
1048        let plugin = PasskeyPlugin::new();
1049        let (ctx, _user, _session) = test_helpers::create_test_context_with_user(
1050            CreateUser::new()
1051                .with_email("passkey-test@example.com")
1052                .with_name("Passkey Tester"),
1053            Duration::hours(1),
1054        )
1055        .await;
1056
1057        // No auth required for this endpoint
1058        let req = test_helpers::create_auth_json_request_no_query(
1059            HttpMethod::Post,
1060            "/passkey/generate-authenticate-options",
1061            None,
1062            None,
1063        );
1064
1065        let response = plugin
1066            .handle_generate_authenticate_options(&req, &ctx)
1067            .await
1068            .unwrap();
1069        assert_eq!(response.status, 200);
1070
1071        let body: serde_json::Value = serde_json::from_slice(&response.body).unwrap();
1072        assert!(body["challenge"].is_string());
1073        assert_eq!(body["rpId"], "localhost");
1074        assert!(body["allowCredentials"].is_array());
1075        assert_eq!(body["allowCredentials"].as_array().unwrap().len(), 0);
1076    }
1077
1078    #[tokio::test]
1079    async fn test_generate_authenticate_options_with_auth_includes_credentials() {
1080        let plugin = PasskeyPlugin::new();
1081        let (ctx, user, session) = test_helpers::create_test_context_with_user(
1082            CreateUser::new()
1083                .with_email("passkey-test@example.com")
1084                .with_name("Passkey Tester"),
1085            Duration::hours(1),
1086        )
1087        .await;
1088
1089        // Create a passkey for the user
1090        ctx.database
1091            .create_passkey(CreatePasskey {
1092                user_id: user.id.clone(),
1093                name: "Test Key".to_string(),
1094                credential_id: "cred-gen-auth-1".to_string(),
1095                public_key: "pk".to_string(),
1096                counter: 0,
1097                device_type: "singleDevice".to_string(),
1098                backed_up: false,
1099                transports: Some("[\"usb\"]".to_string()),
1100            })
1101            .await
1102            .unwrap();
1103
1104        let req = test_helpers::create_auth_json_request_no_query(
1105            HttpMethod::Post,
1106            "/passkey/generate-authenticate-options",
1107            Some(&session.token),
1108            None,
1109        );
1110
1111        let response = plugin
1112            .handle_generate_authenticate_options(&req, &ctx)
1113            .await
1114            .unwrap();
1115        assert_eq!(response.status, 200);
1116
1117        let body: serde_json::Value = serde_json::from_slice(&response.body).unwrap();
1118        let allow = body["allowCredentials"].as_array().unwrap();
1119        assert_eq!(allow.len(), 1);
1120        assert_eq!(allow[0]["id"], "cred-gen-auth-1");
1121    }
1122
1123    #[tokio::test]
1124    async fn test_list_user_passkeys() {
1125        let plugin = PasskeyPlugin::new();
1126        let (ctx, user, session) = test_helpers::create_test_context_with_user(
1127            CreateUser::new()
1128                .with_email("passkey-test@example.com")
1129                .with_name("Passkey Tester"),
1130            Duration::hours(1),
1131        )
1132        .await;
1133
1134        // No passkeys yet
1135        let req = test_helpers::create_auth_json_request_no_query(
1136            HttpMethod::Get,
1137            "/passkey/list-user-passkeys",
1138            Some(&session.token),
1139            None,
1140        );
1141        let response = plugin.handle_list_user_passkeys(&req, &ctx).await.unwrap();
1142        assert_eq!(response.status, 200);
1143        let body: Vec<serde_json::Value> = serde_json::from_slice(&response.body).unwrap();
1144        assert_eq!(body.len(), 0);
1145
1146        // Create a passkey
1147        ctx.database
1148            .create_passkey(CreatePasskey {
1149                user_id: user.id.clone(),
1150                name: "My Key".to_string(),
1151                credential_id: "cred-list-1".to_string(),
1152                public_key: "pk".to_string(),
1153                counter: 0,
1154                device_type: "singleDevice".to_string(),
1155                backed_up: false,
1156                transports: None,
1157            })
1158            .await
1159            .unwrap();
1160
1161        let response = plugin.handle_list_user_passkeys(&req, &ctx).await.unwrap();
1162        assert_eq!(response.status, 200);
1163        let body: Vec<serde_json::Value> = serde_json::from_slice(&response.body).unwrap();
1164        assert_eq!(body.len(), 1);
1165        assert_eq!(body[0]["name"], "My Key");
1166        assert_eq!(body[0]["credentialID"], "cred-list-1");
1167    }
1168
1169    #[tokio::test]
1170    async fn test_list_user_passkeys_unauthenticated() {
1171        let plugin = PasskeyPlugin::new();
1172        let (ctx, _user, _session) = test_helpers::create_test_context_with_user(
1173            CreateUser::new()
1174                .with_email("passkey-test@example.com")
1175                .with_name("Passkey Tester"),
1176            Duration::hours(1),
1177        )
1178        .await;
1179
1180        let req = test_helpers::create_auth_json_request_no_query(
1181            HttpMethod::Get,
1182            "/passkey/list-user-passkeys",
1183            None,
1184            None,
1185        );
1186        let err = plugin
1187            .handle_list_user_passkeys(&req, &ctx)
1188            .await
1189            .unwrap_err();
1190        assert_eq!(err.status_code(), 401);
1191    }
1192
1193    #[tokio::test]
1194    async fn test_delete_passkey_success() {
1195        let plugin = PasskeyPlugin::new();
1196        let (ctx, user, session) = test_helpers::create_test_context_with_user(
1197            CreateUser::new()
1198                .with_email("passkey-test@example.com")
1199                .with_name("Passkey Tester"),
1200            Duration::hours(1),
1201        )
1202        .await;
1203
1204        let passkey = ctx
1205            .database
1206            .create_passkey(CreatePasskey {
1207                user_id: user.id.clone(),
1208                name: "To Delete".to_string(),
1209                credential_id: "cred-del-1".to_string(),
1210                public_key: "pk".to_string(),
1211                counter: 0,
1212                device_type: "singleDevice".to_string(),
1213                backed_up: false,
1214                transports: None,
1215            })
1216            .await
1217            .unwrap();
1218
1219        let body = serde_json::json!({ "id": passkey.id });
1220        let req = test_helpers::create_auth_json_request_no_query(
1221            HttpMethod::Post,
1222            "/passkey/delete-passkey",
1223            Some(&session.token),
1224            Some(body),
1225        );
1226
1227        let response = plugin.handle_delete_passkey(&req, &ctx).await.unwrap();
1228        assert_eq!(response.status, 200);
1229
1230        // Verify deleted
1231        let result = ctx.database.get_passkey_by_id(&passkey.id).await.unwrap();
1232        assert!(result.is_none());
1233    }
1234
1235    #[tokio::test]
1236    async fn test_delete_passkey_non_owner_rejected() {
1237        let plugin = PasskeyPlugin::new();
1238        let (ctx, _user, session) = test_helpers::create_test_context_with_user(
1239            CreateUser::new()
1240                .with_email("passkey-test@example.com")
1241                .with_name("Passkey Tester"),
1242            Duration::hours(1),
1243        )
1244        .await;
1245
1246        // Create another user's passkey
1247        let other_user = ctx
1248            .database
1249            .create_user(
1250                CreateUser::new()
1251                    .with_email("other@example.com")
1252                    .with_name("Other User"),
1253            )
1254            .await
1255            .unwrap();
1256
1257        let passkey = ctx
1258            .database
1259            .create_passkey(CreatePasskey {
1260                user_id: other_user.id.clone(),
1261                name: "Other's Key".to_string(),
1262                credential_id: "cred-other-del".to_string(),
1263                public_key: "pk".to_string(),
1264                counter: 0,
1265                device_type: "singleDevice".to_string(),
1266                backed_up: false,
1267                transports: None,
1268            })
1269            .await
1270            .unwrap();
1271
1272        let body = serde_json::json!({ "id": passkey.id });
1273        let req = test_helpers::create_auth_json_request_no_query(
1274            HttpMethod::Post,
1275            "/passkey/delete-passkey",
1276            Some(&session.token),
1277            Some(body),
1278        );
1279
1280        let err = plugin.handle_delete_passkey(&req, &ctx).await.unwrap_err();
1281        assert_eq!(err.status_code(), 404);
1282
1283        // Verify NOT deleted
1284        let result = ctx.database.get_passkey_by_id(&passkey.id).await.unwrap();
1285        assert!(result.is_some());
1286    }
1287
1288    #[tokio::test]
1289    async fn test_update_passkey_success() {
1290        let plugin = PasskeyPlugin::new();
1291        let (ctx, user, session) = test_helpers::create_test_context_with_user(
1292            CreateUser::new()
1293                .with_email("passkey-test@example.com")
1294                .with_name("Passkey Tester"),
1295            Duration::hours(1),
1296        )
1297        .await;
1298
1299        let passkey = ctx
1300            .database
1301            .create_passkey(CreatePasskey {
1302                user_id: user.id.clone(),
1303                name: "Old Name".to_string(),
1304                credential_id: "cred-upd-1".to_string(),
1305                public_key: "pk".to_string(),
1306                counter: 0,
1307                device_type: "singleDevice".to_string(),
1308                backed_up: false,
1309                transports: None,
1310            })
1311            .await
1312            .unwrap();
1313
1314        let body = serde_json::json!({ "id": passkey.id, "name": "New Name" });
1315        let req = test_helpers::create_auth_json_request_no_query(
1316            HttpMethod::Post,
1317            "/passkey/update-passkey",
1318            Some(&session.token),
1319            Some(body),
1320        );
1321
1322        let response = plugin.handle_update_passkey(&req, &ctx).await.unwrap();
1323        assert_eq!(response.status, 200);
1324
1325        let resp_body: serde_json::Value = serde_json::from_slice(&response.body).unwrap();
1326        assert_eq!(resp_body["passkey"]["name"], "New Name");
1327
1328        // Verify persisted
1329        let updated = ctx
1330            .database
1331            .get_passkey_by_id(&passkey.id)
1332            .await
1333            .unwrap()
1334            .unwrap();
1335        assert_eq!(updated.name(), "New Name");
1336    }
1337
1338    #[tokio::test]
1339    async fn test_update_passkey_non_owner_rejected() {
1340        let plugin = PasskeyPlugin::new();
1341        let (ctx, _user, session) = test_helpers::create_test_context_with_user(
1342            CreateUser::new()
1343                .with_email("passkey-test@example.com")
1344                .with_name("Passkey Tester"),
1345            Duration::hours(1),
1346        )
1347        .await;
1348
1349        let other_user = ctx
1350            .database
1351            .create_user(
1352                CreateUser::new()
1353                    .with_email("other-upd@example.com")
1354                    .with_name("Other"),
1355            )
1356            .await
1357            .unwrap();
1358
1359        let passkey = ctx
1360            .database
1361            .create_passkey(CreatePasskey {
1362                user_id: other_user.id.clone(),
1363                name: "Other's Key".to_string(),
1364                credential_id: "cred-other-upd".to_string(),
1365                public_key: "pk".to_string(),
1366                counter: 0,
1367                device_type: "singleDevice".to_string(),
1368                backed_up: false,
1369                transports: None,
1370            })
1371            .await
1372            .unwrap();
1373
1374        let body = serde_json::json!({ "id": passkey.id, "name": "Hijacked" });
1375        let req = test_helpers::create_auth_json_request_no_query(
1376            HttpMethod::Post,
1377            "/passkey/update-passkey",
1378            Some(&session.token),
1379            Some(body),
1380        );
1381
1382        let err = plugin.handle_update_passkey(&req, &ctx).await.unwrap_err();
1383        assert_eq!(err.status_code(), 404);
1384
1385        // Verify unchanged
1386        let unchanged = ctx
1387            .database
1388            .get_passkey_by_id(&passkey.id)
1389            .await
1390            .unwrap()
1391            .unwrap();
1392        assert_eq!(unchanged.name(), "Other's Key");
1393    }
1394
1395    #[tokio::test]
1396    async fn test_expired_challenge_rejected() {
1397        let plugin = PasskeyPlugin::new().allow_insecure_unverified_assertion(true);
1398        let (ctx, user, session) = test_helpers::create_test_context_with_user(
1399            CreateUser::new()
1400                .with_email("passkey-test@example.com")
1401                .with_name("Passkey Tester"),
1402            Duration::hours(1),
1403        )
1404        .await;
1405
1406        let challenge = "expired-challenge";
1407        let identifier = format!("passkey_reg:{}", user.id);
1408
1409        // Create an already-expired verification
1410        ctx.database
1411            .create_verification(CreateVerification {
1412                identifier: identifier.clone(),
1413                value: challenge.to_string(),
1414                expires_at: Utc::now() - Duration::seconds(1),
1415            })
1416            .await
1417            .unwrap();
1418
1419        let body = serde_json::json!({
1420            "response": {
1421                "id": "cred-exp-1",
1422                "response": {
1423                    "clientDataJSON": encoded_client_data(challenge, "webauthn.create", "http://localhost:3000"),
1424                    "attestationObject": "fake",
1425                }
1426            }
1427        });
1428        let req = test_helpers::create_auth_json_request_no_query(
1429            HttpMethod::Post,
1430            "/passkey/verify-registration",
1431            Some(&session.token),
1432            Some(body),
1433        );
1434
1435        let err = plugin
1436            .handle_verify_registration(&req, &ctx)
1437            .await
1438            .unwrap_err();
1439        assert_eq!(err.status_code(), 400);
1440    }
1441
1442    #[tokio::test]
1443    async fn test_verify_authentication_requires_insecure_opt_in() {
1444        let plugin = PasskeyPlugin::new(); // default: insecure=false
1445        let (ctx, _user, _session) = test_helpers::create_test_context_with_user(
1446            CreateUser::new()
1447                .with_email("passkey-test@example.com")
1448                .with_name("Passkey Tester"),
1449            Duration::hours(1),
1450        )
1451        .await;
1452
1453        let body = serde_json::json!({
1454            "response": {
1455                "id": "cred-1",
1456                "response": {
1457                    "clientDataJSON": encoded_client_data("c", "webauthn.get", "http://localhost:3000"),
1458                }
1459            }
1460        });
1461
1462        let req = test_helpers::create_auth_json_request_no_query(
1463            HttpMethod::Post,
1464            "/passkey/verify-authentication",
1465            None,
1466            Some(body),
1467        );
1468
1469        let err = plugin
1470            .handle_verify_authentication(&req, &ctx)
1471            .await
1472            .unwrap_err();
1473        assert_eq!(err.status_code(), 501);
1474    }
1475
1476    #[tokio::test]
1477    async fn test_memory_passkey_list_is_sorted_by_created_at_desc() {
1478        let (ctx, user, _session) = test_helpers::create_test_context_with_user(
1479            CreateUser::new()
1480                .with_email("passkey-test@example.com")
1481                .with_name("Passkey Tester"),
1482            Duration::hours(1),
1483        )
1484        .await;
1485
1486        let first = ctx
1487            .database
1488            .create_passkey(CreatePasskey {
1489                user_id: user.id.clone(),
1490                name: "first".to_string(),
1491                credential_id: "cred-sort-1".to_string(),
1492                public_key: "pk-1".to_string(),
1493                counter: 0,
1494                device_type: "singleDevice".to_string(),
1495                backed_up: false,
1496                transports: None,
1497            })
1498            .await
1499            .unwrap();
1500
1501        tokio::time::sleep(std::time::Duration::from_millis(2)).await;
1502
1503        let second = ctx
1504            .database
1505            .create_passkey(CreatePasskey {
1506                user_id: user.id.clone(),
1507                name: "second".to_string(),
1508                credential_id: "cred-sort-2".to_string(),
1509                public_key: "pk-2".to_string(),
1510                counter: 0,
1511                device_type: "singleDevice".to_string(),
1512                backed_up: false,
1513                transports: None,
1514            })
1515            .await
1516            .unwrap();
1517
1518        let listed = ctx.database.list_passkeys_by_user(&user.id).await.unwrap();
1519        assert_eq!(listed.len(), 2);
1520        assert_eq!(listed[0].id(), second.id());
1521        assert_eq!(listed[1].id(), first.id());
1522    }
1523}