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
15pub 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 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, allow_insecure_unverified_assertion: false,
51 }
52 }
53}
54
55#[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#[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
142impl 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 let user = ctx
588 .database
589 .get_user_by_id(passkey.user_id())
590 .await?
591 .ok_or(AuthError::UserNotFound)?;
592
593 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 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 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 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 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 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 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_generate_register_options_returns_challenge_and_stores_verification() {
1075 let plugin = PasskeyPlugin::new();
1076 let (ctx, user, session) = create_test_context_with_user().await;
1077
1078 let req = create_auth_request(
1079 HttpMethod::Get,
1080 "/passkey/generate-register-options",
1081 Some(&session.token),
1082 None,
1083 );
1084
1085 let response = plugin
1086 .handle_generate_register_options(&req, &ctx)
1087 .await
1088 .unwrap();
1089 assert_eq!(response.status, 200);
1090
1091 let body: serde_json::Value = serde_json::from_slice(&response.body).unwrap();
1092 assert!(body["challenge"].is_string());
1093 assert_eq!(body["rp"]["id"], "localhost");
1094 assert_eq!(body["rp"]["name"], "Better Auth");
1095 assert!(body["user"]["id"].is_string());
1096 assert!(body["pubKeyCredParams"].is_array());
1097 assert!(body["excludeCredentials"].is_array());
1098
1099 let challenge = body["challenge"].as_str().unwrap();
1101 let identifier = format!("passkey_reg:{}", user.id);
1102 let verification = ctx
1103 .database
1104 .get_verification(&identifier, challenge)
1105 .await
1106 .unwrap();
1107 assert!(verification.is_some());
1108 }
1109
1110 #[tokio::test]
1111 async fn test_generate_register_options_unauthenticated() {
1112 let plugin = PasskeyPlugin::new();
1113 let (ctx, _user, _session) = create_test_context_with_user().await;
1114
1115 let req = create_auth_request(
1116 HttpMethod::Get,
1117 "/passkey/generate-register-options",
1118 None,
1119 None,
1120 );
1121
1122 let err = plugin
1123 .handle_generate_register_options(&req, &ctx)
1124 .await
1125 .unwrap_err();
1126 assert_eq!(err.status_code(), 401);
1127 }
1128
1129 #[tokio::test]
1130 async fn test_generate_authenticate_options_returns_challenge() {
1131 let plugin = PasskeyPlugin::new();
1132 let (ctx, _user, _session) = create_test_context_with_user().await;
1133
1134 let req = create_auth_request(
1136 HttpMethod::Post,
1137 "/passkey/generate-authenticate-options",
1138 None,
1139 None,
1140 );
1141
1142 let response = plugin
1143 .handle_generate_authenticate_options(&req, &ctx)
1144 .await
1145 .unwrap();
1146 assert_eq!(response.status, 200);
1147
1148 let body: serde_json::Value = serde_json::from_slice(&response.body).unwrap();
1149 assert!(body["challenge"].is_string());
1150 assert_eq!(body["rpId"], "localhost");
1151 assert!(body["allowCredentials"].is_array());
1152 assert_eq!(body["allowCredentials"].as_array().unwrap().len(), 0);
1153 }
1154
1155 #[tokio::test]
1156 async fn test_generate_authenticate_options_with_auth_includes_credentials() {
1157 let plugin = PasskeyPlugin::new();
1158 let (ctx, user, session) = create_test_context_with_user().await;
1159
1160 ctx.database
1162 .create_passkey(CreatePasskey {
1163 user_id: user.id.clone(),
1164 name: "Test Key".to_string(),
1165 credential_id: "cred-gen-auth-1".to_string(),
1166 public_key: "pk".to_string(),
1167 counter: 0,
1168 device_type: "singleDevice".to_string(),
1169 backed_up: false,
1170 transports: Some("[\"usb\"]".to_string()),
1171 })
1172 .await
1173 .unwrap();
1174
1175 let req = create_auth_request(
1176 HttpMethod::Post,
1177 "/passkey/generate-authenticate-options",
1178 Some(&session.token),
1179 None,
1180 );
1181
1182 let response = plugin
1183 .handle_generate_authenticate_options(&req, &ctx)
1184 .await
1185 .unwrap();
1186 assert_eq!(response.status, 200);
1187
1188 let body: serde_json::Value = serde_json::from_slice(&response.body).unwrap();
1189 let allow = body["allowCredentials"].as_array().unwrap();
1190 assert_eq!(allow.len(), 1);
1191 assert_eq!(allow[0]["id"], "cred-gen-auth-1");
1192 }
1193
1194 #[tokio::test]
1195 async fn test_list_user_passkeys() {
1196 let plugin = PasskeyPlugin::new();
1197 let (ctx, user, session) = create_test_context_with_user().await;
1198
1199 let req = create_auth_request(
1201 HttpMethod::Get,
1202 "/passkey/list-user-passkeys",
1203 Some(&session.token),
1204 None,
1205 );
1206 let response = plugin.handle_list_user_passkeys(&req, &ctx).await.unwrap();
1207 assert_eq!(response.status, 200);
1208 let body: Vec<serde_json::Value> = serde_json::from_slice(&response.body).unwrap();
1209 assert_eq!(body.len(), 0);
1210
1211 ctx.database
1213 .create_passkey(CreatePasskey {
1214 user_id: user.id.clone(),
1215 name: "My Key".to_string(),
1216 credential_id: "cred-list-1".to_string(),
1217 public_key: "pk".to_string(),
1218 counter: 0,
1219 device_type: "singleDevice".to_string(),
1220 backed_up: false,
1221 transports: None,
1222 })
1223 .await
1224 .unwrap();
1225
1226 let response = plugin.handle_list_user_passkeys(&req, &ctx).await.unwrap();
1227 assert_eq!(response.status, 200);
1228 let body: Vec<serde_json::Value> = serde_json::from_slice(&response.body).unwrap();
1229 assert_eq!(body.len(), 1);
1230 assert_eq!(body[0]["name"], "My Key");
1231 assert_eq!(body[0]["credentialID"], "cred-list-1");
1232 }
1233
1234 #[tokio::test]
1235 async fn test_list_user_passkeys_unauthenticated() {
1236 let plugin = PasskeyPlugin::new();
1237 let (ctx, _user, _session) = create_test_context_with_user().await;
1238
1239 let req = create_auth_request(HttpMethod::Get, "/passkey/list-user-passkeys", None, None);
1240 let err = plugin
1241 .handle_list_user_passkeys(&req, &ctx)
1242 .await
1243 .unwrap_err();
1244 assert_eq!(err.status_code(), 401);
1245 }
1246
1247 #[tokio::test]
1248 async fn test_delete_passkey_success() {
1249 let plugin = PasskeyPlugin::new();
1250 let (ctx, user, session) = create_test_context_with_user().await;
1251
1252 let passkey = ctx
1253 .database
1254 .create_passkey(CreatePasskey {
1255 user_id: user.id.clone(),
1256 name: "To Delete".to_string(),
1257 credential_id: "cred-del-1".to_string(),
1258 public_key: "pk".to_string(),
1259 counter: 0,
1260 device_type: "singleDevice".to_string(),
1261 backed_up: false,
1262 transports: None,
1263 })
1264 .await
1265 .unwrap();
1266
1267 let body = serde_json::json!({ "id": passkey.id });
1268 let req = create_auth_request(
1269 HttpMethod::Post,
1270 "/passkey/delete-passkey",
1271 Some(&session.token),
1272 Some(body),
1273 );
1274
1275 let response = plugin.handle_delete_passkey(&req, &ctx).await.unwrap();
1276 assert_eq!(response.status, 200);
1277
1278 let result = ctx.database.get_passkey_by_id(&passkey.id).await.unwrap();
1280 assert!(result.is_none());
1281 }
1282
1283 #[tokio::test]
1284 async fn test_delete_passkey_non_owner_rejected() {
1285 let plugin = PasskeyPlugin::new();
1286 let (ctx, _user, session) = create_test_context_with_user().await;
1287
1288 let other_user = ctx
1290 .database
1291 .create_user(
1292 CreateUser::new()
1293 .with_email("other@example.com")
1294 .with_name("Other User"),
1295 )
1296 .await
1297 .unwrap();
1298
1299 let passkey = ctx
1300 .database
1301 .create_passkey(CreatePasskey {
1302 user_id: other_user.id.clone(),
1303 name: "Other's Key".to_string(),
1304 credential_id: "cred-other-del".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 });
1315 let req = create_auth_request(
1316 HttpMethod::Post,
1317 "/passkey/delete-passkey",
1318 Some(&session.token),
1319 Some(body),
1320 );
1321
1322 let err = plugin.handle_delete_passkey(&req, &ctx).await.unwrap_err();
1323 assert_eq!(err.status_code(), 404);
1324
1325 let result = ctx.database.get_passkey_by_id(&passkey.id).await.unwrap();
1327 assert!(result.is_some());
1328 }
1329
1330 #[tokio::test]
1331 async fn test_update_passkey_success() {
1332 let plugin = PasskeyPlugin::new();
1333 let (ctx, user, session) = create_test_context_with_user().await;
1334
1335 let passkey = ctx
1336 .database
1337 .create_passkey(CreatePasskey {
1338 user_id: user.id.clone(),
1339 name: "Old Name".to_string(),
1340 credential_id: "cred-upd-1".to_string(),
1341 public_key: "pk".to_string(),
1342 counter: 0,
1343 device_type: "singleDevice".to_string(),
1344 backed_up: false,
1345 transports: None,
1346 })
1347 .await
1348 .unwrap();
1349
1350 let body = serde_json::json!({ "id": passkey.id, "name": "New Name" });
1351 let req = create_auth_request(
1352 HttpMethod::Post,
1353 "/passkey/update-passkey",
1354 Some(&session.token),
1355 Some(body),
1356 );
1357
1358 let response = plugin.handle_update_passkey(&req, &ctx).await.unwrap();
1359 assert_eq!(response.status, 200);
1360
1361 let resp_body: serde_json::Value = serde_json::from_slice(&response.body).unwrap();
1362 assert_eq!(resp_body["passkey"]["name"], "New Name");
1363
1364 let updated = ctx
1366 .database
1367 .get_passkey_by_id(&passkey.id)
1368 .await
1369 .unwrap()
1370 .unwrap();
1371 assert_eq!(updated.name(), "New Name");
1372 }
1373
1374 #[tokio::test]
1375 async fn test_update_passkey_non_owner_rejected() {
1376 let plugin = PasskeyPlugin::new();
1377 let (ctx, _user, session) = create_test_context_with_user().await;
1378
1379 let other_user = ctx
1380 .database
1381 .create_user(
1382 CreateUser::new()
1383 .with_email("other-upd@example.com")
1384 .with_name("Other"),
1385 )
1386 .await
1387 .unwrap();
1388
1389 let passkey = ctx
1390 .database
1391 .create_passkey(CreatePasskey {
1392 user_id: other_user.id.clone(),
1393 name: "Other's Key".to_string(),
1394 credential_id: "cred-other-upd".to_string(),
1395 public_key: "pk".to_string(),
1396 counter: 0,
1397 device_type: "singleDevice".to_string(),
1398 backed_up: false,
1399 transports: None,
1400 })
1401 .await
1402 .unwrap();
1403
1404 let body = serde_json::json!({ "id": passkey.id, "name": "Hijacked" });
1405 let req = create_auth_request(
1406 HttpMethod::Post,
1407 "/passkey/update-passkey",
1408 Some(&session.token),
1409 Some(body),
1410 );
1411
1412 let err = plugin.handle_update_passkey(&req, &ctx).await.unwrap_err();
1413 assert_eq!(err.status_code(), 404);
1414
1415 let unchanged = ctx
1417 .database
1418 .get_passkey_by_id(&passkey.id)
1419 .await
1420 .unwrap()
1421 .unwrap();
1422 assert_eq!(unchanged.name(), "Other's Key");
1423 }
1424
1425 #[tokio::test]
1426 async fn test_expired_challenge_rejected() {
1427 let plugin = PasskeyPlugin::new().allow_insecure_unverified_assertion(true);
1428 let (ctx, user, session) = create_test_context_with_user().await;
1429
1430 let challenge = "expired-challenge";
1431 let identifier = format!("passkey_reg:{}", user.id);
1432
1433 ctx.database
1435 .create_verification(CreateVerification {
1436 identifier: identifier.clone(),
1437 value: challenge.to_string(),
1438 expires_at: Utc::now() - Duration::seconds(1),
1439 })
1440 .await
1441 .unwrap();
1442
1443 let body = serde_json::json!({
1444 "response": {
1445 "id": "cred-exp-1",
1446 "response": {
1447 "clientDataJSON": encoded_client_data(challenge, "webauthn.create", "http://localhost:3000"),
1448 "attestationObject": "fake",
1449 }
1450 }
1451 });
1452 let req = create_auth_request(
1453 HttpMethod::Post,
1454 "/passkey/verify-registration",
1455 Some(&session.token),
1456 Some(body),
1457 );
1458
1459 let err = plugin
1460 .handle_verify_registration(&req, &ctx)
1461 .await
1462 .unwrap_err();
1463 assert_eq!(err.status_code(), 400);
1464 }
1465
1466 #[tokio::test]
1467 async fn test_verify_authentication_requires_insecure_opt_in() {
1468 let plugin = PasskeyPlugin::new(); let (ctx, _user, _session) = create_test_context_with_user().await;
1470
1471 let body = serde_json::json!({
1472 "response": {
1473 "id": "cred-1",
1474 "response": {
1475 "clientDataJSON": encoded_client_data("c", "webauthn.get", "http://localhost:3000"),
1476 }
1477 }
1478 });
1479
1480 let req = create_auth_request(
1481 HttpMethod::Post,
1482 "/passkey/verify-authentication",
1483 None,
1484 Some(body),
1485 );
1486
1487 let err = plugin
1488 .handle_verify_authentication(&req, &ctx)
1489 .await
1490 .unwrap_err();
1491 assert_eq!(err.status_code(), 501);
1492 }
1493
1494 #[tokio::test]
1495 async fn test_memory_passkey_list_is_sorted_by_created_at_desc() {
1496 let (ctx, user, _session) = create_test_context_with_user().await;
1497
1498 let first = ctx
1499 .database
1500 .create_passkey(CreatePasskey {
1501 user_id: user.id.clone(),
1502 name: "first".to_string(),
1503 credential_id: "cred-sort-1".to_string(),
1504 public_key: "pk-1".to_string(),
1505 counter: 0,
1506 device_type: "singleDevice".to_string(),
1507 backed_up: false,
1508 transports: None,
1509 })
1510 .await
1511 .unwrap();
1512
1513 tokio::time::sleep(std::time::Duration::from_millis(2)).await;
1514
1515 let second = ctx
1516 .database
1517 .create_passkey(CreatePasskey {
1518 user_id: user.id.clone(),
1519 name: "second".to_string(),
1520 credential_id: "cred-sort-2".to_string(),
1521 public_key: "pk-2".to_string(),
1522 counter: 0,
1523 device_type: "singleDevice".to_string(),
1524 backed_up: false,
1525 transports: None,
1526 })
1527 .await
1528 .unwrap();
1529
1530 let listed = ctx.database.list_passkeys_by_user(&user.id).await.unwrap();
1531 assert_eq!(listed.len(), 2);
1532 assert_eq!(listed[0].id(), second.id());
1533 assert_eq!(listed[1].id(), first.id());
1534 }
1535}