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
19pub 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 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, allow_insecure_unverified_assertion: false,
55 }
56 }
57}
58
59#[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#[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
141impl 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 let user = ctx
531 .database
532 .get_user_by_id(passkey.user_id())
533 .await?
534 .ok_or(AuthError::UserNotFound)?;
535
536 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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(); 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}