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
15/// Passkey / WebAuthn authentication plugin.
16///
17/// Generates WebAuthn-compatible registration and authentication options,
18/// stores challenge state via `VerificationOps`, and manages passkey CRUD.
19///
20/// **WARNING: Simplified WebAuthn mode.**
21/// This implementation does NOT perform full FIDO2 signature verification
22/// (rpId, origin, authenticatorData, signature). It trusts the client-side
23/// WebAuthn response after verifying the challenge round-trip. For production
24/// use, integrate `webauthn-rs` or another FIDO2 library for full attestation
25/// and assertion verification.
26pub struct PasskeyPlugin {
27    config: PasskeyConfig,
28}
29
30#[derive(Debug, Clone)]
31pub struct PasskeyConfig {
32    pub rp_id: String,
33    pub rp_name: String,
34    pub origin: String,
35    pub challenge_ttl_secs: i64,
36    /// Allows simplified (non-cryptographic) response verification.
37    ///
38    /// Keep disabled in production. This exists only for local development
39    /// until full WebAuthn validation is integrated.
40    pub allow_insecure_unverified_assertion: bool,
41}
42
43impl Default for PasskeyConfig {
44    fn default() -> Self {
45        Self {
46            rp_id: "localhost".to_string(),
47            rp_name: "Better Auth".to_string(),
48            origin: "http://localhost:3000".to_string(),
49            challenge_ttl_secs: 300, // 5 minutes
50            allow_insecure_unverified_assertion: false,
51        }
52    }
53}
54
55// -- Request types --
56
57#[derive(Debug, Deserialize, Validate)]
58#[serde(rename_all = "camelCase")]
59struct VerifyRegistrationRequest {
60    response: serde_json::Value,
61    name: Option<String>,
62}
63
64#[derive(Debug, Deserialize, Validate)]
65#[serde(rename_all = "camelCase")]
66struct VerifyAuthenticationRequest {
67    response: serde_json::Value,
68}
69
70#[derive(Debug, Deserialize, Validate)]
71#[serde(rename_all = "camelCase")]
72struct DeletePasskeyRequest {
73    #[validate(length(min = 1))]
74    id: String,
75}
76
77#[derive(Debug, Deserialize, Validate)]
78#[serde(rename_all = "camelCase")]
79struct UpdatePasskeyRequest {
80    #[validate(length(min = 1))]
81    id: String,
82    #[validate(length(min = 1))]
83    name: String,
84}
85
86// -- Response helpers --
87
88#[derive(Debug, Serialize)]
89struct PasskeyView {
90    id: String,
91    name: String,
92    #[serde(rename = "credentialID")]
93    credential_id: String,
94    #[serde(rename = "userId")]
95    user_id: String,
96    #[serde(rename = "publicKey")]
97    public_key: String,
98    counter: u64,
99    #[serde(rename = "deviceType")]
100    device_type: String,
101    #[serde(rename = "backedUp")]
102    backed_up: bool,
103    #[serde(skip_serializing_if = "Option::is_none")]
104    transports: Option<String>,
105    #[serde(rename = "createdAt")]
106    created_at: String,
107}
108
109impl PasskeyView {
110    fn from_entity(pk: &impl AuthPasskey) -> Self {
111        Self {
112            id: pk.id().to_string(),
113            name: pk.name().to_string(),
114            credential_id: pk.credential_id().to_string(),
115            user_id: pk.user_id().to_string(),
116            public_key: pk.public_key().to_string(),
117            counter: pk.counter(),
118            device_type: pk.device_type().to_string(),
119            backed_up: pk.backed_up(),
120            transports: pk.transports().map(|s| s.to_string()),
121            created_at: pk.created_at().to_rfc3339(),
122        }
123    }
124}
125
126#[derive(Debug, Serialize)]
127struct SessionUserResponse<U: Serialize, S: Serialize> {
128    session: S,
129    user: U,
130}
131
132#[derive(Debug, Serialize)]
133struct StatusResponse {
134    status: bool,
135}
136
137#[derive(Debug, Serialize)]
138struct PasskeyResponse {
139    passkey: PasskeyView,
140}
141
142// -- Plugin --
143
144impl PasskeyPlugin {
145    pub fn new() -> Self {
146        Self {
147            config: PasskeyConfig::default(),
148        }
149    }
150
151    pub fn with_config(config: PasskeyConfig) -> Self {
152        Self { config }
153    }
154
155    pub fn rp_id(mut self, rp_id: impl Into<String>) -> Self {
156        self.config.rp_id = rp_id.into();
157        self
158    }
159
160    pub fn rp_name(mut self, rp_name: impl Into<String>) -> Self {
161        self.config.rp_name = rp_name.into();
162        self
163    }
164
165    pub fn origin(mut self, origin: impl Into<String>) -> Self {
166        self.config.origin = origin.into();
167        self
168    }
169
170    pub fn allow_insecure_unverified_assertion(mut self, allow: bool) -> Self {
171        self.config.allow_insecure_unverified_assertion = allow;
172        self
173    }
174
175    // -- Helpers --
176
177    fn generate_challenge() -> String {
178        let mut bytes = [0u8; 32];
179        rand::rngs::OsRng.fill_bytes(&mut bytes);
180        URL_SAFE_NO_PAD.encode(bytes)
181    }
182
183    fn ensure_insecure_verification_enabled(&self) -> AuthResult<()> {
184        if self.config.allow_insecure_unverified_assertion {
185            Ok(())
186        } else {
187            Err(AuthError::not_implemented(
188                "Passkey verification requires full WebAuthn signature validation. \
189                Set `allow_insecure_unverified_assertion = true` only for local development.",
190            ))
191        }
192    }
193
194    fn decode_client_data_json(response: &serde_json::Value) -> AuthResult<serde_json::Value> {
195        let encoded = response
196            .get("response")
197            .and_then(|r| r.get("clientDataJSON"))
198            .and_then(|v| v.as_str())
199            .ok_or_else(|| AuthError::bad_request("Missing clientDataJSON in response"))?;
200
201        let decode_and_parse = |bytes: Vec<u8>| -> Option<serde_json::Value> {
202            serde_json::from_slice::<serde_json::Value>(&bytes).ok()
203        };
204
205        if let Ok(bytes) = URL_SAFE_NO_PAD.decode(encoded)
206            && let Some(client_data) = decode_and_parse(bytes)
207        {
208            return Ok(client_data);
209        }
210
211        if let Ok(bytes) = STANDARD.decode(encoded)
212            && let Some(client_data) = decode_and_parse(bytes)
213        {
214            return Ok(client_data);
215        }
216
217        Err(AuthError::bad_request("Invalid clientDataJSON encoding"))
218    }
219
220    fn validate_client_data(
221        &self,
222        client_data: &serde_json::Value,
223        expected_type: &str,
224    ) -> AuthResult<String> {
225        let client_type = client_data
226            .get("type")
227            .and_then(|v| v.as_str())
228            .ok_or_else(|| AuthError::bad_request("Missing clientDataJSON.type"))?;
229
230        if client_type != expected_type {
231            return Err(AuthError::bad_request(format!(
232                "Invalid clientDataJSON.type, expected {}",
233                expected_type
234            )));
235        }
236
237        let origin = client_data
238            .get("origin")
239            .and_then(|v| v.as_str())
240            .ok_or_else(|| AuthError::bad_request("Missing clientDataJSON.origin"))?;
241
242        if origin != self.config.origin {
243            return Err(AuthError::bad_request("Invalid clientDataJSON.origin"));
244        }
245
246        let challenge = client_data
247            .get("challenge")
248            .and_then(|v| v.as_str())
249            .ok_or_else(|| AuthError::bad_request("Missing clientDataJSON.challenge"))?;
250
251        Ok(challenge.to_string())
252    }
253
254    async fn get_authenticated_user<DB: DatabaseAdapter>(
255        req: &AuthRequest,
256        ctx: &AuthContext<DB>,
257    ) -> AuthResult<(DB::User, DB::Session)> {
258        let token = req
259            .headers
260            .get("authorization")
261            .and_then(|v| v.strip_prefix("Bearer "))
262            .ok_or(AuthError::Unauthenticated)?;
263
264        let session = ctx
265            .database
266            .get_session(token)
267            .await?
268            .ok_or(AuthError::Unauthenticated)?;
269
270        if session.expires_at() < chrono::Utc::now() {
271            return Err(AuthError::Unauthenticated);
272        }
273
274        let user = ctx
275            .database
276            .get_user_by_id(session.user_id())
277            .await?
278            .ok_or(AuthError::UserNotFound)?;
279
280        Ok((user, session))
281    }
282
283    fn create_session_cookie<DB: DatabaseAdapter>(token: &str, ctx: &AuthContext<DB>) -> String {
284        let session_config = &ctx.config.session;
285        let secure = if session_config.cookie_secure {
286            "; Secure"
287        } else {
288            ""
289        };
290        let http_only = if session_config.cookie_http_only {
291            "; HttpOnly"
292        } else {
293            ""
294        };
295        let same_site = match session_config.cookie_same_site {
296            better_auth_core::config::SameSite::Strict => "; SameSite=Strict",
297            better_auth_core::config::SameSite::Lax => "; SameSite=Lax",
298            better_auth_core::config::SameSite::None => "; SameSite=None",
299        };
300
301        let expires = chrono::Utc::now() + session_config.expires_in;
302        let expires_str = expires.format("%a, %d %b %Y %H:%M:%S GMT");
303
304        format!(
305            "{}={}; Path=/; Expires={}{}{}{}",
306            session_config.cookie_name, token, expires_str, secure, http_only, same_site
307        )
308    }
309
310    // -- Handlers --
311
312    /// GET /passkey/generate-register-options
313    async fn handle_generate_register_options<DB: DatabaseAdapter>(
314        &self,
315        req: &AuthRequest,
316        ctx: &AuthContext<DB>,
317    ) -> AuthResult<AuthResponse> {
318        let (user, _session) = Self::get_authenticated_user(req, ctx).await?;
319
320        let challenge = Self::generate_challenge();
321
322        // Store challenge as a verification token
323        let identifier = format!("passkey_reg:{}", user.id());
324        let expires_at =
325            chrono::Utc::now() + chrono::Duration::seconds(self.config.challenge_ttl_secs);
326        ctx.database
327            .create_verification(CreateVerification {
328                identifier: identifier.clone(),
329                value: challenge.clone(),
330                expires_at,
331            })
332            .await?;
333
334        // Build excludeCredentials from existing passkeys
335        let existing_passkeys = ctx.database.list_passkeys_by_user(user.id()).await?;
336        let exclude_credentials: Vec<serde_json::Value> = existing_passkeys
337            .iter()
338            .map(|pk| {
339                let mut cred = serde_json::json!({
340                    "type": "public-key",
341                    "id": pk.credential_id(),
342                });
343                if let Some(transports) = pk.transports()
344                    && let Ok(t) = serde_json::from_str::<Vec<String>>(transports)
345                {
346                    cred["transports"] = serde_json::json!(t);
347                }
348                cred
349            })
350            .collect();
351
352        // Read optional authenticatorAttachment from query params
353        let authenticator_attachment = req
354            .query
355            .get("authenticatorAttachment")
356            .cloned()
357            .unwrap_or_else(|| "platform".to_string());
358
359        let user_id_b64 = URL_SAFE_NO_PAD.encode(user.id().as_bytes());
360        let display_name = user
361            .name()
362            .unwrap_or_else(|| user.email().unwrap_or("user"));
363        let user_name = user
364            .email()
365            .unwrap_or_else(|| user.name().unwrap_or("user"));
366
367        let options = serde_json::json!({
368            "challenge": challenge,
369            "rp": {
370                "name": self.config.rp_name,
371                "id": self.config.rp_id,
372            },
373            "user": {
374                "id": user_id_b64,
375                "name": user_name,
376                "displayName": display_name,
377            },
378            "pubKeyCredParams": [
379                { "type": "public-key", "alg": -7 },
380                { "type": "public-key", "alg": -257 },
381            ],
382            "timeout": 60000,
383            "excludeCredentials": exclude_credentials,
384            "authenticatorSelection": {
385                "authenticatorAttachment": authenticator_attachment,
386                "requireResidentKey": false,
387                "userVerification": "preferred",
388            },
389            "attestation": "none",
390        });
391
392        AuthResponse::json(200, &options).map_err(AuthError::from)
393    }
394
395    /// POST /passkey/verify-registration
396    async fn handle_verify_registration<DB: DatabaseAdapter>(
397        &self,
398        req: &AuthRequest,
399        ctx: &AuthContext<DB>,
400    ) -> AuthResult<AuthResponse> {
401        self.ensure_insecure_verification_enabled()?;
402        let (user, _session) = Self::get_authenticated_user(req, ctx).await?;
403
404        let body: VerifyRegistrationRequest = match better_auth_core::validate_request_body(req) {
405            Ok(v) => v,
406            Err(resp) => return Ok(resp),
407        };
408
409        let client_data = Self::decode_client_data_json(&body.response)?;
410        let challenge = self.validate_client_data(&client_data, "webauthn.create")?;
411
412        // Atomically consume the challenge (single-use)
413        let identifier = format!("passkey_reg:{}", user.id());
414        ctx.database
415            .consume_verification(&identifier, &challenge)
416            .await?
417            .ok_or_else(|| {
418                AuthError::bad_request(
419                    "Invalid or expired registration challenge. Please generate registration options again.",
420                )
421            })?;
422
423        // Extract credential data from the client response
424        let resp = &body.response;
425        let credential_id = resp
426            .get("id")
427            .or_else(|| resp.get("rawId"))
428            .and_then(|v| v.as_str())
429            .ok_or_else(|| AuthError::bad_request("Missing credential id in response"))?;
430
431        // Extract public key from attestation response
432        // In a simplified approach, we store the clientDataJSON as the public key representation
433        let public_key = resp
434            .get("response")
435            .and_then(|r| r.get("attestationObject"))
436            .and_then(|v| v.as_str())
437            .or_else(|| {
438                resp.get("response")
439                    .and_then(|r| r.get("clientDataJSON"))
440                    .and_then(|v| v.as_str())
441            })
442            .unwrap_or("")
443            .to_string();
444
445        // Extract device type and backup info from authenticator data or client extensions
446        let authenticator_attachment = resp
447            .get("authenticatorAttachment")
448            .and_then(|v| v.as_str())
449            .unwrap_or("platform");
450
451        let device_type = if authenticator_attachment == "cross-platform" {
452            "multiDevice"
453        } else {
454            "singleDevice"
455        }
456        .to_string();
457
458        let backed_up = resp
459            .get("clientExtensionResults")
460            .and_then(|v| v.get("credProps"))
461            .and_then(|v| v.get("rk"))
462            .and_then(|v| v.as_bool())
463            .unwrap_or(false);
464
465        // Extract transports if available
466        let transports = resp
467            .get("response")
468            .and_then(|r| r.get("transports"))
469            .map(|v| v.to_string());
470
471        let passkey_name = body
472            .name
473            .unwrap_or_else(|| format!("Passkey {}", chrono::Utc::now().format("%Y-%m-%d")));
474
475        // Create the passkey
476        let passkey = ctx
477            .database
478            .create_passkey(CreatePasskey {
479                user_id: user.id().to_string(),
480                name: passkey_name,
481                credential_id: credential_id.to_string(),
482                public_key,
483                counter: 0,
484                device_type,
485                backed_up,
486                transports,
487            })
488            .await?;
489
490        let view = PasskeyView::from_entity(&passkey);
491        AuthResponse::json(200, &view).map_err(AuthError::from)
492    }
493
494    /// POST /passkey/generate-authenticate-options
495    async fn handle_generate_authenticate_options<DB: DatabaseAdapter>(
496        &self,
497        req: &AuthRequest,
498        ctx: &AuthContext<DB>,
499    ) -> AuthResult<AuthResponse> {
500        let challenge = Self::generate_challenge();
501
502        // If user is authenticated, build allowCredentials from their passkeys
503        let allow_credentials: Vec<serde_json::Value> =
504            if let Ok((user, _session)) = Self::get_authenticated_user(req, ctx).await {
505                let passkeys = ctx.database.list_passkeys_by_user(user.id()).await?;
506                passkeys
507                    .iter()
508                    .map(|pk| {
509                        let mut cred = serde_json::json!({
510                            "type": "public-key",
511                            "id": pk.credential_id(),
512                        });
513                        if let Some(transports) = pk.transports()
514                            && let Ok(t) = serde_json::from_str::<Vec<String>>(transports)
515                        {
516                            cred["transports"] = serde_json::json!(t);
517                        }
518                        cred
519                    })
520                    .collect()
521            } else {
522                vec![]
523            };
524
525        // Store challenge with the challenge itself as part of the identifier
526        let identifier = format!("passkey_auth:{}", challenge);
527        let expires_at =
528            chrono::Utc::now() + chrono::Duration::seconds(self.config.challenge_ttl_secs);
529        ctx.database
530            .create_verification(CreateVerification {
531                identifier,
532                value: challenge.clone(),
533                expires_at,
534            })
535            .await?;
536
537        let options = serde_json::json!({
538            "challenge": challenge,
539            "timeout": 60000,
540            "rpId": self.config.rp_id,
541            "allowCredentials": allow_credentials,
542            "userVerification": "preferred",
543        });
544
545        AuthResponse::json(200, &options).map_err(AuthError::from)
546    }
547
548    /// POST /passkey/verify-authentication
549    async fn handle_verify_authentication<DB: DatabaseAdapter>(
550        &self,
551        req: &AuthRequest,
552        ctx: &AuthContext<DB>,
553    ) -> AuthResult<AuthResponse> {
554        self.ensure_insecure_verification_enabled()?;
555        let body: VerifyAuthenticationRequest = match better_auth_core::validate_request_body(req) {
556            Ok(v) => v,
557            Err(resp) => return Ok(resp),
558        };
559
560        let resp = &body.response;
561
562        // Extract credential_id from the response
563        let credential_id = resp
564            .get("id")
565            .or_else(|| resp.get("rawId"))
566            .and_then(|v| v.as_str())
567            .ok_or_else(|| AuthError::bad_request("Missing credential id in response"))?;
568
569        let client_data = Self::decode_client_data_json(resp)?;
570        let challenge = self.validate_client_data(&client_data, "webauthn.get")?;
571
572        // Atomically consume challenge so it cannot be replayed.
573        let identifier = format!("passkey_auth:{}", challenge);
574        ctx.database
575            .consume_verification(&identifier, &challenge)
576            .await?
577            .ok_or_else(|| AuthError::bad_request("Invalid or expired authentication challenge"))?;
578
579        // Look up the passkey by credential_id
580        let passkey = ctx
581            .database
582            .get_passkey_by_credential_id(credential_id)
583            .await?
584            .ok_or_else(|| AuthError::bad_request("Passkey not found for credential"))?;
585
586        // Look up the user
587        let user = ctx
588            .database
589            .get_user_by_id(passkey.user_id())
590            .await?
591            .ok_or(AuthError::UserNotFound)?;
592
593        // Update the passkey counter
594        let new_counter = passkey
595            .counter()
596            .checked_add(1)
597            .ok_or_else(|| AuthError::internal("Passkey counter overflow"))?;
598        ctx.database
599            .update_passkey_counter(passkey.id(), new_counter)
600            .await?;
601
602        // Create a session
603        let ip_address = req.headers.get("x-forwarded-for").cloned();
604        let user_agent = req.headers.get("user-agent").cloned();
605        let session_manager =
606            better_auth_core::SessionManager::new(ctx.config.clone(), ctx.database.clone());
607        let session = session_manager
608            .create_session(&user, ip_address, user_agent)
609            .await?;
610
611        let cookie_header = Self::create_session_cookie(session.token(), ctx);
612        let response = SessionUserResponse { session, user };
613        Ok(AuthResponse::json(200, &response)?.with_header("Set-Cookie", cookie_header))
614    }
615
616    /// GET /passkey/list-user-passkeys
617    async fn handle_list_user_passkeys<DB: DatabaseAdapter>(
618        &self,
619        req: &AuthRequest,
620        ctx: &AuthContext<DB>,
621    ) -> AuthResult<AuthResponse> {
622        let (user, _session) = Self::get_authenticated_user(req, ctx).await?;
623
624        let passkeys = ctx.database.list_passkeys_by_user(user.id()).await?;
625        let views: Vec<PasskeyView> = passkeys.iter().map(PasskeyView::from_entity).collect();
626
627        AuthResponse::json(200, &views).map_err(AuthError::from)
628    }
629
630    /// POST /passkey/delete-passkey
631    async fn handle_delete_passkey<DB: DatabaseAdapter>(
632        &self,
633        req: &AuthRequest,
634        ctx: &AuthContext<DB>,
635    ) -> AuthResult<AuthResponse> {
636        let (user, _session) = Self::get_authenticated_user(req, ctx).await?;
637
638        let body: DeletePasskeyRequest = match better_auth_core::validate_request_body(req) {
639            Ok(v) => v,
640            Err(resp) => return Ok(resp),
641        };
642
643        // Verify ownership
644        let passkey = ctx
645            .database
646            .get_passkey_by_id(&body.id)
647            .await?
648            .ok_or_else(|| AuthError::not_found("Passkey not found"))?;
649
650        if passkey.user_id() != user.id() {
651            return Err(AuthError::not_found("Passkey not found"));
652        }
653
654        ctx.database.delete_passkey(&body.id).await?;
655
656        let response = StatusResponse { status: true };
657        AuthResponse::json(200, &response).map_err(AuthError::from)
658    }
659
660    /// POST /passkey/update-passkey
661    async fn handle_update_passkey<DB: DatabaseAdapter>(
662        &self,
663        req: &AuthRequest,
664        ctx: &AuthContext<DB>,
665    ) -> AuthResult<AuthResponse> {
666        let (user, _session) = Self::get_authenticated_user(req, ctx).await?;
667
668        let body: UpdatePasskeyRequest = match better_auth_core::validate_request_body(req) {
669            Ok(v) => v,
670            Err(resp) => return Ok(resp),
671        };
672
673        // Verify ownership
674        let passkey = ctx
675            .database
676            .get_passkey_by_id(&body.id)
677            .await?
678            .ok_or_else(|| AuthError::not_found("Passkey not found"))?;
679
680        if passkey.user_id() != user.id() {
681            return Err(AuthError::not_found("Passkey not found"));
682        }
683
684        let updated = ctx
685            .database
686            .update_passkey_name(&body.id, &body.name)
687            .await?;
688
689        let response = PasskeyResponse {
690            passkey: PasskeyView::from_entity(&updated),
691        };
692        AuthResponse::json(200, &response).map_err(AuthError::from)
693    }
694}
695
696impl Default for PasskeyPlugin {
697    fn default() -> Self {
698        Self::new()
699    }
700}
701
702#[async_trait]
703impl<DB: DatabaseAdapter> AuthPlugin<DB> for PasskeyPlugin {
704    fn name(&self) -> &'static str {
705        "passkey"
706    }
707
708    fn routes(&self) -> Vec<AuthRoute> {
709        vec![
710            AuthRoute::get(
711                "/passkey/generate-register-options",
712                "passkey_generate_register_options",
713            ),
714            AuthRoute::post(
715                "/passkey/verify-registration",
716                "passkey_verify_registration",
717            ),
718            AuthRoute::post(
719                "/passkey/generate-authenticate-options",
720                "passkey_generate_authenticate_options",
721            ),
722            AuthRoute::post(
723                "/passkey/verify-authentication",
724                "passkey_verify_authentication",
725            ),
726            AuthRoute::get("/passkey/list-user-passkeys", "passkey_list_user_passkeys"),
727            AuthRoute::post("/passkey/delete-passkey", "passkey_delete_passkey"),
728            AuthRoute::post("/passkey/update-passkey", "passkey_update_passkey"),
729        ]
730    }
731
732    async fn on_request(
733        &self,
734        req: &AuthRequest,
735        ctx: &AuthContext<DB>,
736    ) -> AuthResult<Option<AuthResponse>> {
737        match (req.method(), req.path()) {
738            (HttpMethod::Get, "/passkey/generate-register-options") => {
739                Ok(Some(self.handle_generate_register_options(req, ctx).await?))
740            }
741            (HttpMethod::Post, "/passkey/verify-registration") => {
742                Ok(Some(self.handle_verify_registration(req, ctx).await?))
743            }
744            (HttpMethod::Post, "/passkey/generate-authenticate-options") => Ok(Some(
745                self.handle_generate_authenticate_options(req, ctx).await?,
746            )),
747            (HttpMethod::Post, "/passkey/verify-authentication") => {
748                Ok(Some(self.handle_verify_authentication(req, ctx).await?))
749            }
750            (HttpMethod::Get, "/passkey/list-user-passkeys") => {
751                Ok(Some(self.handle_list_user_passkeys(req, ctx).await?))
752            }
753            (HttpMethod::Post, "/passkey/delete-passkey") => {
754                Ok(Some(self.handle_delete_passkey(req, ctx).await?))
755            }
756            (HttpMethod::Post, "/passkey/update-passkey") => {
757                Ok(Some(self.handle_update_passkey(req, ctx).await?))
758            }
759            _ => Ok(None),
760        }
761    }
762}
763
764#[cfg(test)]
765mod tests {
766    use super::*;
767    use better_auth_core::adapters::{
768        MemoryDatabaseAdapter, PasskeyOps, SessionOps, UserOps, VerificationOps,
769    };
770    use better_auth_core::{CreateSession, CreateUser, CreateVerification, Session, User};
771    use chrono::{Duration, Utc};
772    use std::collections::HashMap;
773    use std::sync::Arc;
774
775    async fn create_test_context_with_user() -> (AuthContext<MemoryDatabaseAdapter>, User, Session)
776    {
777        let config = Arc::new(better_auth_core::AuthConfig::new(
778            "test-secret-key-at-least-32-chars-long",
779        ));
780        let database = Arc::new(MemoryDatabaseAdapter::new());
781        let ctx = AuthContext::new(config, database.clone());
782
783        let user = database
784            .create_user(
785                CreateUser::new()
786                    .with_email("passkey-test@example.com")
787                    .with_name("Passkey Tester"),
788            )
789            .await
790            .unwrap();
791
792        let session = database
793            .create_session(CreateSession {
794                user_id: user.id.clone(),
795                expires_at: Utc::now() + Duration::hours(1),
796                ip_address: Some("127.0.0.1".to_string()),
797                user_agent: Some("test-agent".to_string()),
798                impersonated_by: None,
799                active_organization_id: None,
800            })
801            .await
802            .unwrap();
803
804        (ctx, user, session)
805    }
806
807    fn create_auth_request(
808        method: HttpMethod,
809        path: &str,
810        token: Option<&str>,
811        body: Option<serde_json::Value>,
812    ) -> AuthRequest {
813        let mut headers = HashMap::new();
814        if let Some(token) = token {
815            headers.insert("authorization".to_string(), format!("Bearer {}", token));
816        }
817        headers.insert("content-type".to_string(), "application/json".to_string());
818
819        AuthRequest {
820            method,
821            path: path.to_string(),
822            headers,
823            body: body.map(|b| serde_json::to_vec(&b).unwrap()),
824            query: HashMap::new(),
825        }
826    }
827
828    fn encoded_client_data(challenge: &str, client_type: &str, origin: &str) -> String {
829        let client_data = serde_json::json!({
830            "type": client_type,
831            "challenge": challenge,
832            "origin": origin,
833        });
834        URL_SAFE_NO_PAD.encode(serde_json::to_vec(&client_data).unwrap())
835    }
836
837    #[tokio::test]
838    async fn test_verify_registration_requires_insecure_opt_in() {
839        let plugin = PasskeyPlugin::new();
840        let (ctx, _user, session) = create_test_context_with_user().await;
841
842        let body = serde_json::json!({
843            "response": {
844                "id": "cred-1",
845                "response": {
846                    "clientDataJSON": encoded_client_data("challenge-1", "webauthn.create", "http://localhost:3000"),
847                }
848            }
849        });
850
851        let req = create_auth_request(
852            HttpMethod::Post,
853            "/passkey/verify-registration",
854            Some(&session.token),
855            Some(body),
856        );
857
858        let err = plugin
859            .handle_verify_registration(&req, &ctx)
860            .await
861            .unwrap_err();
862        assert_eq!(err.status_code(), 501);
863    }
864
865    #[tokio::test]
866    async fn test_verify_registration_consumes_exact_challenge_once() {
867        let plugin = PasskeyPlugin::new().allow_insecure_unverified_assertion(true);
868        let (ctx, user, session) = create_test_context_with_user().await;
869
870        let challenge = "register-challenge";
871        let identifier = format!("passkey_reg:{}", user.id);
872
873        ctx.database
874            .create_verification(CreateVerification {
875                identifier: identifier.clone(),
876                value: challenge.to_string(),
877                expires_at: Utc::now() + Duration::minutes(5),
878            })
879            .await
880            .unwrap();
881
882        let wrong_body = serde_json::json!({
883            "response": {
884                "id": "cred-reg-1",
885                "response": {
886                    "clientDataJSON": encoded_client_data("wrong-challenge", "webauthn.create", "http://localhost:3000"),
887                    "attestationObject": "fake-attestation",
888                }
889            }
890        });
891        let wrong_req = create_auth_request(
892            HttpMethod::Post,
893            "/passkey/verify-registration",
894            Some(&session.token),
895            Some(wrong_body),
896        );
897        let err = plugin
898            .handle_verify_registration(&wrong_req, &ctx)
899            .await
900            .unwrap_err();
901        assert_eq!(err.status_code(), 400);
902
903        assert!(
904            ctx.database
905                .get_verification(&identifier, challenge)
906                .await
907                .unwrap()
908                .is_some()
909        );
910
911        let ok_body = serde_json::json!({
912            "response": {
913                "id": "cred-reg-1",
914                "response": {
915                    "clientDataJSON": encoded_client_data(challenge, "webauthn.create", "http://localhost:3000"),
916                    "attestationObject": "fake-attestation",
917                }
918            }
919        });
920        let ok_req = create_auth_request(
921            HttpMethod::Post,
922            "/passkey/verify-registration",
923            Some(&session.token),
924            Some(ok_body),
925        );
926        let response = plugin
927            .handle_verify_registration(&ok_req, &ctx)
928            .await
929            .unwrap();
930        assert_eq!(response.status, 200);
931
932        assert!(
933            ctx.database
934                .get_verification(&identifier, challenge)
935                .await
936                .unwrap()
937                .is_none()
938        );
939
940        let passkeys = ctx.database.list_passkeys_by_user(&user.id).await.unwrap();
941        assert_eq!(passkeys.len(), 1);
942    }
943
944    #[tokio::test]
945    async fn test_verify_authentication_checks_type_origin_and_prevents_replay() {
946        let plugin = PasskeyPlugin::new().allow_insecure_unverified_assertion(true);
947        let (ctx, user, _session) = create_test_context_with_user().await;
948
949        let credential_id = "cred-auth-1";
950        ctx.database
951            .create_passkey(CreatePasskey {
952                user_id: user.id.clone(),
953                name: "Authenticator".to_string(),
954                credential_id: credential_id.to_string(),
955                public_key: "fake-public-key".to_string(),
956                counter: 0,
957                device_type: "singleDevice".to_string(),
958                backed_up: false,
959                transports: None,
960            })
961            .await
962            .unwrap();
963
964        let challenge = "auth-challenge-1";
965        let identifier = format!("passkey_auth:{}", challenge);
966
967        ctx.database
968            .create_verification(CreateVerification {
969                identifier: identifier.clone(),
970                value: challenge.to_string(),
971                expires_at: Utc::now() + Duration::minutes(5),
972            })
973            .await
974            .unwrap();
975
976        let wrong_type_body = serde_json::json!({
977            "response": {
978                "id": credential_id,
979                "response": {
980                    "clientDataJSON": encoded_client_data(challenge, "webauthn.create", "http://localhost:3000"),
981                }
982            }
983        });
984        let wrong_type_req = create_auth_request(
985            HttpMethod::Post,
986            "/passkey/verify-authentication",
987            None,
988            Some(wrong_type_body),
989        );
990        let err = plugin
991            .handle_verify_authentication(&wrong_type_req, &ctx)
992            .await
993            .unwrap_err();
994        assert_eq!(err.status_code(), 400);
995
996        let wrong_origin_body = serde_json::json!({
997            "response": {
998                "id": credential_id,
999                "response": {
1000                    "clientDataJSON": encoded_client_data(challenge, "webauthn.get", "http://evil.example"),
1001                }
1002            }
1003        });
1004        let wrong_origin_req = create_auth_request(
1005            HttpMethod::Post,
1006            "/passkey/verify-authentication",
1007            None,
1008            Some(wrong_origin_body),
1009        );
1010        let err = plugin
1011            .handle_verify_authentication(&wrong_origin_req, &ctx)
1012            .await
1013            .unwrap_err();
1014        assert_eq!(err.status_code(), 400);
1015
1016        assert!(
1017            ctx.database
1018                .get_verification(&identifier, challenge)
1019                .await
1020                .unwrap()
1021                .is_some()
1022        );
1023
1024        let ok_body = serde_json::json!({
1025            "response": {
1026                "id": credential_id,
1027                "response": {
1028                    "clientDataJSON": encoded_client_data(challenge, "webauthn.get", "http://localhost:3000"),
1029                }
1030            }
1031        });
1032        let ok_req = create_auth_request(
1033            HttpMethod::Post,
1034            "/passkey/verify-authentication",
1035            None,
1036            Some(ok_body.clone()),
1037        );
1038        let response = plugin
1039            .handle_verify_authentication(&ok_req, &ctx)
1040            .await
1041            .unwrap();
1042        assert_eq!(response.status, 200);
1043
1044        assert!(
1045            ctx.database
1046                .get_verification(&identifier, challenge)
1047                .await
1048                .unwrap()
1049                .is_none()
1050        );
1051
1052        let replay_req = create_auth_request(
1053            HttpMethod::Post,
1054            "/passkey/verify-authentication",
1055            None,
1056            Some(ok_body),
1057        );
1058        let err = plugin
1059            .handle_verify_authentication(&replay_req, &ctx)
1060            .await
1061            .unwrap_err();
1062        assert_eq!(err.status_code(), 400);
1063
1064        let passkey = ctx
1065            .database
1066            .get_passkey_by_credential_id(credential_id)
1067            .await
1068            .unwrap()
1069            .unwrap();
1070        assert_eq!(passkey.counter(), 1);
1071    }
1072
1073    #[tokio::test]
1074    async fn test_memory_passkey_list_is_sorted_by_created_at_desc() {
1075        let (ctx, user, _session) = create_test_context_with_user().await;
1076
1077        let first = ctx
1078            .database
1079            .create_passkey(CreatePasskey {
1080                user_id: user.id.clone(),
1081                name: "first".to_string(),
1082                credential_id: "cred-sort-1".to_string(),
1083                public_key: "pk-1".to_string(),
1084                counter: 0,
1085                device_type: "singleDevice".to_string(),
1086                backed_up: false,
1087                transports: None,
1088            })
1089            .await
1090            .unwrap();
1091
1092        tokio::time::sleep(std::time::Duration::from_millis(2)).await;
1093
1094        let second = ctx
1095            .database
1096            .create_passkey(CreatePasskey {
1097                user_id: user.id.clone(),
1098                name: "second".to_string(),
1099                credential_id: "cred-sort-2".to_string(),
1100                public_key: "pk-2".to_string(),
1101                counter: 0,
1102                device_type: "singleDevice".to_string(),
1103                backed_up: false,
1104                transports: None,
1105            })
1106            .await
1107            .unwrap();
1108
1109        let listed = ctx.database.list_passkeys_by_user(&user.id).await.unwrap();
1110        assert_eq!(listed.len(), 2);
1111        assert_eq!(listed[0].id(), second.id());
1112        assert_eq!(listed[1].id(), first.id());
1113    }
1114}