1mod authentication;
2mod endpoint;
3pub use endpoint::{
4 PasskeyAuthenticated, PasskeyDeleted, PasskeyRegistrationInput, PasskeyUpdated,
5};
6mod options;
7pub use options::{
8 PasskeyAuthenticatorSelection, PasskeyExtensions, PasskeyExtensionsResolver,
9 PasskeyOptionsContext,
10};
11
12pub(super) mod handlers;
13
14mod raw_none;
15mod source;
16
17mod registration;
18
19pub(super) mod types;
20
21pub(super) mod webauthn;
22
23use alibi_core::error::codes;
24use alibi_core::utils::cookie_utils::create_session_cookie;
25use alibi_core::{AuthContext, AuthError, AuthResult};
26use alibi_core::{AuthRequest, AuthResponse};
27pub use authentication::{
28 AuthenticationResult, PasskeyAuthenticationAfterVerification, PasskeyAuthenticationConfig,
29 PasskeyAuthenticationContext, VerifiedPasskeyAuthentication,
30};
31use handlers::{
32 PasskeyHandlerOutcome, delete_passkey_core, generate_authenticate_options_core,
33 generate_register_options_core, list_user_passkeys_core, update_passkey_core,
34 verify_authentication_core, verify_registration_core,
35};
36pub use registration::{
37 PasskeyRegistrationAfterVerification, PasskeyRegistrationConfig, PasskeyRegistrationContext,
38 PasskeyRegistrationOverride, PasskeyRegistrationUser, PasskeyUserResolver,
39 VerifiedPasskeyRegistration,
40};
41use types::{
42 DeletePasskeyRequest, UpdatePasskeyRequest, VerifyAuthenticationRequest,
43 VerifyRegistrationRequest,
44};
45
46pub struct PasskeyPlugin {
51 config: PasskeyConfig,
52}
53
54#[derive(Debug, Clone, alibi_core::PluginConfig)]
55#[plugin(name = "PasskeyPlugin")]
56pub struct PasskeyConfig {
57 #[config(default = String::new())]
58 pub rp_id: String,
59 #[config(default = "Better Auth".to_owned())]
60 pub rp_name: String,
61 #[config(default = String::new())]
62 pub origin: String,
63 #[config(default = Vec::new())]
65 pub origins: Vec<String>,
66 #[config(default = PasskeyAuthenticatorSelection::default())]
67 pub authenticator_selection: PasskeyAuthenticatorSelection,
68 #[config(default = 300)]
69 pub challenge_ttl_secs: i64,
70 #[config(default = "better-auth-passkey".to_owned())]
71 pub web_authn_challenge_cookie: String,
72 #[config(default = None)]
75 pub attestation_root_certificates: Option<std::collections::BTreeMap<String, Vec<String>>>,
76 #[config(default = PasskeyRegistrationConfig::default())]
77 pub registration: PasskeyRegistrationConfig,
78 #[config(default = PasskeyAuthenticationConfig::default())]
79 pub authentication: PasskeyAuthenticationConfig,
80}
81
82impl PasskeyPlugin {
83 async fn handle_generate_register_options(
85 &self,
86 req: &AuthRequest,
87 ctx: &AuthContext<impl alibi_core::AuthSchema>,
88 ) -> AuthResult<AuthResponse> {
89 self.generate_register_options(req, &req.query, ctx).await
90 }
91
92 async fn generate_register_options<S: alibi_core::AuthSchema>(
93 &self,
94 req: &impl alibi_core::session::SessionRequest,
95 query: &std::collections::HashMap<String, String>,
96 ctx: &AuthContext<S>,
97 ) -> AuthResult<AuthResponse> {
98 use alibi_core::AuthUser;
99 let session = self.registration_session(req, ctx).await?;
100 let projected_user = session.as_ref().map(|(user, _)| ctx.user_view(user));
101 let user = if let Some((user, _)) = session {
102 let id = user.id().into_owned();
103 let name = user
104 .email()
105 .filter(|email| !email.is_empty())
106 .unwrap_or(&id)
107 .to_owned();
108 PasskeyRegistrationUser {
109 id,
110 name: name.clone(),
111 display_name: Some(name),
112 }
113 } else {
114 let Some(resolver) = &self.config.registration.resolve_user else {
115 return Ok(AuthResponse::json(
116 400,
117 &serde_json::json!({"code":codes::RESOLVE_USER_REQUIRED,"message":"Passkey registration requires either an authenticated session or a resolveUser callback when requireSession is false"}),
118 )?);
119 };
120 let context = PasskeyRegistrationContext {
121 request: req.physical_request(),
122 endpoint: req.endpoint_call(),
123 auth_config: &ctx.config,
124 extensions: &ctx.extensions,
125 };
126 match resolver
127 .resolve_user(&context, query.get("context").map(String::as_str))
128 .await
129 {
130 Ok(Some(user)) if !user.id.is_empty() && !user.name.is_empty() => user,
131 Ok(_) => {
132 return Ok(AuthResponse::json(
133 400,
134 &serde_json::json!({"code":codes::RESOLVED_USER_INVALID,"message":"Resolved user is invalid"}),
135 )?);
136 }
137 Err(error) if registration::is_application_error(&error) => return Err(error),
138 Err(_) => return Ok(AuthResponse::new(500)),
139 }
140 };
141 let extensions = if let Some(input) = &self.config.registration.extensions {
142 Some(
143 input
144 .resolve(&PasskeyOptionsContext {
145 request: req.physical_request(),
146 endpoint: req.endpoint_call(),
147 auth_config: &ctx.config,
148 extensions: &ctx.extensions,
149 user: projected_user.as_ref(),
150 })
151 .await?,
152 )
153 } else {
154 None
155 };
156 let passkey_name = query.get("name").map(String::as_str);
157 let authenticator_attachment = query.get("authenticatorAttachment").map(String::as_str);
158 let (result, cookie_header) = generate_register_options_core(
159 &user,
160 query.get("context").map(String::as_str),
161 passkey_name,
162 authenticator_attachment,
163 extensions,
164 &self.config,
165 ctx,
166 )
167 .await?;
168 Ok(AuthResponse::json(200, &result)?.with_header("Set-Cookie", cookie_header))
169 }
170
171 async fn handle_verify_registration(
173 &self,
174 req: &AuthRequest,
175 ctx: &AuthContext<impl alibi_core::AuthSchema>,
176 ) -> AuthResult<AuthResponse> {
177 let body: VerifyRegistrationRequest = match alibi_core::validate_request_body(req) {
178 Ok(v) => v,
179 Err(resp) => return Ok(resp),
180 };
181 self.verify_registration(&body, req, ctx).await
182 }
183
184 async fn verify_registration<S: alibi_core::AuthSchema>(
185 &self,
186 body: &VerifyRegistrationRequest,
187 req: &impl alibi_core::session::SessionRequest,
188 ctx: &AuthContext<S>,
189 ) -> AuthResult<AuthResponse> {
190 let session = if self.config.registration.require_session {
191 self.registration_session(req, ctx).await?
192 } else {
193 None
194 };
195 let owner_id = session
196 .as_ref()
197 .map(|(user, _)| alibi_core::AuthUser::id(user).into_owned());
198 match verify_registration_core(body, req, owner_id.as_deref(), &self.config, ctx).await? {
199 PasskeyHandlerOutcome::Success(result) => {
200 let token = result
201 .get("session")
202 .and_then(|session_2| session_2.get("token"))
203 .and_then(serde_json::Value::as_str);
204 let response = AuthResponse::json(200, &result)?;
205 if let Some(token) = token {
206 use alibi_core::utils::cookie_utils::{
207 create_session_cookie_with_max_age, create_session_like_cookie,
208 related_cookie_name, sign_cookie_value, verify_cookie_value,
209 };
210 let preference = related_cookie_name(&ctx.config, "dont_remember");
211 let dont_remember = crate::helpers::get_cookie(req, &preference)
212 .and_then(|value| verify_cookie_value(&value, ctx.config.current_secret()))
213 .is_some_and(|value| !value.is_empty());
214 let mut response = response.with_appended_header(
215 "Set-Cookie",
216 create_session_cookie_with_max_age(
217 Some(token),
218 if dont_remember {
219 None
220 } else {
221 Some(ctx.config.session.expires_in.num_seconds())
222 },
223 &ctx.config,
224 )?,
225 );
226 if dont_remember {
227 response.headers.append(
228 "Set-Cookie",
229 create_session_like_cookie(
230 &preference,
231 &sign_cookie_value("true", ctx.config.current_secret()),
232 None,
233 &ctx.config,
234 )?,
235 );
236 }
237 Ok(response)
238 } else {
239 Ok(response)
240 }
241 }
242 PasskeyHandlerOutcome::Response(response) => Ok(response),
243 }
244 }
245
246 async fn registration_session<S: alibi_core::AuthSchema>(
247 &self,
248 req: &impl alibi_core::session::SessionRequest,
249 ctx: &AuthContext<S>,
250 ) -> AuthResult<
251 Option<(
252 alibi_core::AuthenticatedUser<S>,
253 alibi_core::wire::SessionView,
254 )>,
255 > {
256 let session = match ctx.require_cached_session(req).await {
257 Ok(session) => Some(session),
258 Err(AuthError::Unauthenticated | AuthError::SessionNotFound)
259 if !self.config.registration.require_session =>
260 {
261 None
262 }
263 Err(error) => return Err(error),
264 };
265 if self.config.registration.require_session
266 && let Some((_, session)) = &session
267 && !ctx.session_manager().is_session_fresh(session)
268 {
269 return Err(AuthError::Upstream {
270 status: 403,
271 code: codes::SESSION_NOT_FRESH,
272 message: "Session is not fresh",
273 });
274 }
275 Ok(session)
276 }
277
278 async fn handle_generate_authenticate_options(
280 &self,
281 req: &AuthRequest,
282 ctx: &AuthContext<impl alibi_core::AuthSchema>,
283 ) -> AuthResult<AuthResponse> {
284 self.generate_authentication_options(req, ctx).await
285 }
286
287 async fn generate_authentication_options<S: alibi_core::AuthSchema>(
288 &self,
289 req: &impl alibi_core::session::SessionRequest,
290 ctx: &AuthContext<S>,
291 ) -> AuthResult<AuthResponse> {
292 let maybe_user = ctx.require_cached_session(req).await.ok().map(|(u, _)| u);
293 let projected_user = maybe_user.as_ref().map(|user| ctx.user_view(user));
294 let extensions = if let Some(input) = &self.config.authentication.extensions {
295 Some(
296 input
297 .resolve(&PasskeyOptionsContext {
298 request: req.physical_request(),
299 endpoint: req.endpoint_call(),
300 auth_config: &ctx.config,
301 extensions: &ctx.extensions,
302 user: projected_user.as_ref(),
303 })
304 .await?,
305 )
306 } else {
307 None
308 };
309 let (result, cookie_header) =
310 generate_authenticate_options_core(maybe_user.as_ref(), extensions, &self.config, ctx)
311 .await?;
312 Ok(AuthResponse::json(200, &result)?.with_header("Set-Cookie", cookie_header))
313 }
314
315 async fn handle_verify_authentication(
317 &self,
318 req: &AuthRequest,
319 ctx: &AuthContext<impl alibi_core::AuthSchema>,
320 ) -> AuthResult<AuthResponse> {
321 let body: VerifyAuthenticationRequest = match alibi_core::validate_request_body(req) {
322 Ok(v) => v,
323 Err(resp) => return Ok(resp),
324 };
325 self.verify_authentication(&body, req, ctx).await
326 }
327
328 async fn verify_authentication<S: alibi_core::AuthSchema>(
329 &self,
330 body: &VerifyAuthenticationRequest,
331 req: &impl alibi_core::session::SessionRequest,
332 ctx: &AuthContext<S>,
333 ) -> AuthResult<AuthResponse> {
334 let metadata = if req.endpoint_call().is_some() {
335 alibi_core::RequestMeta {
336 ip_address: Some(
337 ctx.config
338 .advanced
339 .ip_address
340 .resolve_ip(req.session_headers())
341 .unwrap_or_default(),
342 ),
343 user_agent: Some(
344 req.session_headers()
345 .iter()
346 .find(|(name, _)| name.eq_ignore_ascii_case("user-agent"))
347 .map(|(_, value)| value.clone())
348 .unwrap_or_default(),
349 ),
350 }
351 } else {
352 req.physical_request()
353 .map(alibi_core::RequestMeta::from_request)
354 .unwrap_or_default()
355 };
356 match verify_authentication_core(
357 body,
358 req,
359 &self.config,
360 metadata.ip_address,
361 metadata.user_agent,
362 ctx,
363 )
364 .await?
365 {
366 PasskeyHandlerOutcome::Success((response, token)) => {
367 let cookie_header = create_session_cookie(&token, &ctx.config)?;
368 Ok(AuthResponse::json(200, &response)?.with_header("Set-Cookie", cookie_header))
369 }
370 PasskeyHandlerOutcome::Response(response) => Ok(response),
371 }
372 }
373
374 async fn handle_list_user_passkeys(
376 &self,
377 req: &AuthRequest,
378 ctx: &AuthContext<impl alibi_core::AuthSchema>,
379 ) -> AuthResult<AuthResponse> {
380 let (user, _session) = super::helpers::ordinary_session(req, ctx).await?;
381 let result = list_user_passkeys_core(&user, ctx).await?;
382 AuthResponse::json(200, &result).map_err(AuthError::from)
383 }
384
385 async fn handle_delete_passkey(
387 &self,
388 req: &AuthRequest,
389 ctx: &AuthContext<impl alibi_core::AuthSchema>,
390 ) -> AuthResult<AuthResponse> {
391 let (user, _session) = super::helpers::ordinary_session(req, ctx).await?;
392 let body: DeletePasskeyRequest = match alibi_core::validate_request_body(req) {
393 Ok(v) => v,
394 Err(resp) => return Ok(resp),
395 };
396 match delete_passkey_core(&body, &user, ctx).await? {
397 PasskeyHandlerOutcome::Success(result) => {
398 AuthResponse::json(200, &result).map_err(AuthError::from)
399 }
400 PasskeyHandlerOutcome::Response(response) => Ok(response),
401 }
402 }
403
404 async fn handle_update_passkey(
406 &self,
407 req: &AuthRequest,
408 ctx: &AuthContext<impl alibi_core::AuthSchema>,
409 ) -> AuthResult<AuthResponse> {
410 let (user, _session) = super::helpers::ordinary_session(req, ctx).await?;
411 let body: UpdatePasskeyRequest = match alibi_core::validate_request_body(req) {
412 Ok(v) => v,
413 Err(resp) => return Ok(resp),
414 };
415 match update_passkey_core(&body, &user, ctx).await? {
416 PasskeyHandlerOutcome::Success(result) => {
417 AuthResponse::json(200, &result).map_err(AuthError::from)
418 }
419 PasskeyHandlerOutcome::Response(response) => Ok(response),
420 }
421 }
422}
423
424alibi_core::impl_auth_plugin! {
425 PasskeyPlugin, "passkey";
426 routes {
427 get "/passkey/generate-register-options" => handle_generate_register_options, "passkey_generate_register_options";
428 post "/passkey/verify-registration" => handle_verify_registration, "passkey_verify_registration";
429 get "/passkey/generate-authenticate-options" => handle_generate_authenticate_options, "passkey_generate_authenticate_options";
430 post "/passkey/verify-authentication" => handle_verify_authentication, "passkey_verify_authentication";
431 get "/passkey/list-user-passkeys" => handle_list_user_passkeys, "passkey_list_user_passkeys";
432 post "/passkey/delete-passkey" => handle_delete_passkey, "passkey_delete_passkey";
433 post "/passkey/update-passkey" => handle_update_passkey, "passkey_update_passkey";
434 }
435
436 extra {
437 fn server_endpoints(&self) -> Vec<alibi_core::endpoint::EndpointDefinition> {
438 endpoint::definitions()
439 }
440 fn validate_endpoint(&self, call: &alibi_core::endpoint::EndpointCall, _ctx: &AuthContext<S>) -> AuthResult<alibi_core::endpoint::EndpointInput> {
441 endpoint::validate(call)
442 }
443 async fn on_endpoint(&self, call: &alibi_core::endpoint::EndpointCall, ctx: &AuthContext<S>) -> AuthResult<alibi_core::endpoint::EndpointResponse> {
444 if call.operation_id() == "passkeyVerifyAuthentication" { self.verify_authentication_endpoint(call, ctx).await } else if call.operation_id() == "passkeyVerifyRegistration" { self.verify_registration_endpoint(call, ctx).await } else if call.operation_id() == "passkeyGenerateAuthenticateOptions" { self.authentication_options_endpoint(call, ctx).await } else if call.operation_id() == "generatePasskeyRegistrationOptions" { self.registration_options_endpoint(call, ctx).await } else if call.operation_id() == "deletePasskey" { self.delete_endpoint(call, ctx).await } else if call.operation_id() == "updatePasskey" { self.update_endpoint(call, ctx).await } else { self.list_endpoint(call, ctx).await }
445 }
446 route_openapi_metadata!(S);
447 }
448}
449
450impl std::fmt::Debug for PasskeyPlugin {
451 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
452 f.debug_struct("PasskeyPlugin").finish_non_exhaustive()
453 }
454}
455
456#[cfg(test)]
458mod tests {
459 mod revocation;
460 use super::*;
461 use crate::test_helpers;
462 use alibi_core::{CreatePasskey, CreateUser, HttpMethod};
463 use base64::Engine;
464 use base64::engine::general_purpose::URL_SAFE_NO_PAD;
465 use chrono::Duration;
466 use std::collections::HashMap;
467 use std::fmt::Write;
468
469 fn passkey_plugin() -> PasskeyPlugin {
470 PasskeyPlugin::new()
471 .rp_id("localhost")
472 .rp_name("Better Auth Test")
473 .origin("http://localhost:3000")
474 }
475
476 fn cookie_header(response: &AuthResponse) -> &str {
477 response
478 .headers
479 .get("Set-Cookie")
480 .expect("response should include a Set-Cookie header")
481 }
482
483 fn credential_id(label: &str) -> String {
484 URL_SAFE_NO_PAD.encode(label.as_bytes())
485 }
486
487 #[test]
488 fn test_extract_passkey_snapshot_fields_requires_all_expected_fields() {
489 let value = serde_json::json!({
490 "cred": {
491 "counter": 7,
492 "backup_state": true
493 }
494 });
495
496 let err = webauthn::extract_passkey_snapshot_fields(&value).unwrap_err();
497 assert_eq!(
498 err.to_string(),
499 "Internal server error: Stored passkey JSON missing backup_eligible"
500 );
501 }
502
503 #[test]
504 fn test_extract_passkey_snapshot_fields_reads_expected_values() {
505 let value = serde_json::json!({
506 "cred": {
507 "counter": 11,
508 "backup_state": true,
509 "backup_eligible": false
510 }
511 });
512
513 let (counter, backed_up, backup_eligible) =
514 webauthn::extract_passkey_snapshot_fields(&value).unwrap();
515 assert_eq!(counter, 11);
516 assert!(backed_up);
517 assert!(!backup_eligible);
518 }
519
520 #[tokio::test]
521 async fn test_generate_register_options_sets_cookie_and_uses_query_name() {
522 let plugin = passkey_plugin();
523 let (ctx, user, session) = test_helpers::create_test_context_with_user(
524 CreateUser::new()
525 .with_email("passkey-test@example.com")
526 .with_name("Passkey Tester"),
527 Duration::hours(1),
528 )
529 .await;
530
531 ctx.database
532 .create_passkey(CreatePasskey {
533 user_id: user.id.clone(),
534 name: Some("Existing Key".to_owned()),
535 credential_id: credential_id("cred-existing"),
536 public_key: "public-key".to_owned(),
537 counter: 0,
538 device_type: "singleDevice".to_owned(),
539 backed_up: false,
540 transports: Some("usb,nfc".to_owned()),
541
542 aaguid: Some("00000000-0000-0000-0000-000000000000".to_owned()),
543 })
544 .await
545 .unwrap();
546
547 let req = test_helpers::create_auth_request(
548 HttpMethod::Get,
549 "/passkey/generate-register-options",
550 Some(&session.token),
551 None,
552 HashMap::from([
553 ("name".to_owned(), "Custom Account Label".to_owned()),
554 (
555 "authenticatorAttachment".to_owned(),
556 "cross-platform".to_owned(),
557 ),
558 ]),
559 );
560
561 let response = plugin
562 .handle_generate_register_options(&req, &ctx)
563 .await
564 .unwrap();
565 assert_eq!(response.status, 200);
566 assert!(cookie_header(&response).contains("better-auth-passkey="));
567
568 let body: serde_json::Value = serde_json::from_slice(&response.body).unwrap();
569 assert!((*(body).get("challenge").unwrap_or(&serde_json::Value::Null)).is_string());
570 assert_eq!(
571 (*(*(body).get("user").unwrap_or(&serde_json::Value::Null))
572 .get("name")
573 .unwrap_or(&serde_json::Value::Null)),
574 "Custom Account Label"
575 );
576 assert_eq!(
577 (*(*(body)
578 .get("authenticatorSelection")
579 .unwrap_or(&serde_json::Value::Null))
580 .get("authenticatorAttachment")
581 .unwrap_or(&serde_json::Value::Null)),
582 "cross-platform"
583 );
584 assert_eq!(
585 (*(*(*(body)
586 .get("excludeCredentials")
587 .unwrap_or(&serde_json::Value::Null))
588 .get(0)
589 .unwrap_or(&serde_json::Value::Null))
590 .get("id")
591 .unwrap_or(&serde_json::Value::Null)),
592 credential_id("cred-existing")
593 );
594 assert_eq!(
595 (*(*(*(*(body)
596 .get("excludeCredentials")
597 .unwrap_or(&serde_json::Value::Null))
598 .get(0)
599 .unwrap_or(&serde_json::Value::Null))
600 .get("transports")
601 .unwrap_or(&serde_json::Value::Null))
602 .get(0)
603 .unwrap_or(&serde_json::Value::Null)),
604 "usb"
605 );
606 }
607
608 #[tokio::test]
609 async fn test_generate_authenticate_options_is_get_and_sets_cookie_without_auth() {
610 let plugin = passkey_plugin();
611 let ctx = test_helpers::create_test_context().await;
612 let req = test_helpers::create_auth_request_no_query(
613 HttpMethod::Get,
614 "/passkey/generate-authenticate-options",
615 None,
616 None,
617 );
618
619 let response = plugin
620 .handle_generate_authenticate_options(&req, &ctx)
621 .await
622 .unwrap();
623 assert_eq!(response.status, 200);
624 assert!(cookie_header(&response).contains("better-auth-passkey="));
625
626 let body: serde_json::Value = serde_json::from_slice(&response.body).unwrap();
627 assert!((*(body).get("challenge").unwrap_or(&serde_json::Value::Null)).is_string());
628 assert!(body.get("allowCredentials").is_none());
629 }
630
631 #[tokio::test]
632 async fn test_generate_authenticate_options_with_auth_lists_allow_credentials() {
633 let plugin = passkey_plugin();
634 let (ctx, user, session) = test_helpers::create_test_context_with_user(
635 CreateUser::new()
636 .with_email("passkey-test@example.com")
637 .with_name("Passkey Tester"),
638 Duration::hours(1),
639 )
640 .await;
641
642 ctx.database
643 .create_passkey(CreatePasskey {
644 user_id: user.id.clone(),
645 name: Some("Authenticator".to_owned()),
646 credential_id: credential_id("cred-auth"),
647 public_key: "public-key".to_owned(),
648 counter: 0,
649 device_type: "singleDevice".to_owned(),
650 backed_up: false,
651 transports: Some("internal".to_owned()),
652
653 aaguid: None,
654 })
655 .await
656 .unwrap();
657
658 let req = test_helpers::create_auth_request_no_query(
659 HttpMethod::Get,
660 "/passkey/generate-authenticate-options",
661 Some(&session.token),
662 None,
663 );
664
665 let response = plugin
666 .handle_generate_authenticate_options(&req, &ctx)
667 .await
668 .unwrap();
669 assert_eq!(response.status, 200);
670
671 let body: serde_json::Value = serde_json::from_slice(&response.body).unwrap();
672 assert_eq!(
673 (*(*(*(body)
674 .get("allowCredentials")
675 .unwrap_or(&serde_json::Value::Null))
676 .get(0)
677 .unwrap_or(&serde_json::Value::Null))
678 .get("id")
679 .unwrap_or(&serde_json::Value::Null)),
680 credential_id("cred-auth")
681 );
682 assert_eq!(
683 (*(*(*(*(body)
684 .get("allowCredentials")
685 .unwrap_or(&serde_json::Value::Null))
686 .get(0)
687 .unwrap_or(&serde_json::Value::Null))
688 .get("transports")
689 .unwrap_or(&serde_json::Value::Null))
690 .get(0)
691 .unwrap_or(&serde_json::Value::Null)),
692 "internal"
693 );
694 }
695
696 #[tokio::test]
697 async fn test_verify_registration_without_challenge_cookie_returns_challenge_not_found() {
698 let plugin = passkey_plugin();
699 let (ctx, _user, session) = test_helpers::create_test_context_with_user(
700 CreateUser::new()
701 .with_email("passkey-test@example.com")
702 .with_name("Passkey Tester"),
703 Duration::hours(1),
704 )
705 .await;
706
707 let body = serde_json::json!({
708 "response": {
709 "id": "fake-credential-id",
710 "rawId": "ZmFrZS1yYXctaWQ",
711 "response": {
712 "attestationObject": "ZmFrZS1hdHRlc3RhdGlvbg",
713 "clientDataJSON": "ZmFrZS1jbGllbnQtZGF0YQ"
714 },
715 "type": "public-key"
716 }
717 });
718 let mut req = test_helpers::create_auth_json_request_no_query(
719 HttpMethod::Post,
720 "/passkey/verify-registration",
721 Some(&session.token),
722 Some(body),
723 );
724 req.headers
725 .insert("origin".to_owned(), "http://localhost:3000".to_owned());
726
727 let response = plugin.handle_verify_registration(&req, &ctx).await.unwrap();
728 assert_eq!(response.status, 400);
729
730 let body_2: serde_json::Value = serde_json::from_slice(&response.body).unwrap();
731 assert_eq!(
732 (*(body_2).get("message").unwrap_or(&serde_json::Value::Null)),
733 "Challenge not found"
734 );
735 }
736
737 #[tokio::test]
738 async fn test_verify_authentication_without_challenge_cookie_returns_challenge_not_found() {
739 let plugin = passkey_plugin();
740 let ctx = test_helpers::create_test_context().await;
741
742 let body = serde_json::json!({
743 "response": {
744 "id": credential_id("cred-auth"),
745 "rawId": credential_id("cred-auth"),
746 "response": {
747 "authenticatorData": "ZmFrZS1hdXRoLWRhdGE",
748 "clientDataJSON": "ZmFrZS1jbGllbnQtZGF0YQ",
749 "signature": "ZmFrZS1zaWduYXR1cmU"
750 },
751 "type": "public-key"
752 }
753 });
754 let mut req = test_helpers::create_auth_json_request_no_query(
755 HttpMethod::Post,
756 "/passkey/verify-authentication",
757 None,
758 Some(body),
759 );
760 req.headers
761 .insert("origin".to_owned(), "http://localhost:3000".to_owned());
762
763 let response = plugin
764 .handle_verify_authentication(&req, &ctx)
765 .await
766 .unwrap();
767 assert_eq!(response.status, 400);
768
769 let body_2: serde_json::Value = serde_json::from_slice(&response.body).unwrap();
770 assert_eq!(
771 (*(body_2).get("message").unwrap_or(&serde_json::Value::Null)),
772 "Challenge not found"
773 );
774 }
775
776 #[tokio::test]
777 async fn test_list_user_passkeys_omits_updated_at_and_retains_sql_null_fields() {
778 let plugin = passkey_plugin();
779 let (ctx, user, session) = test_helpers::create_test_context_with_user(
780 CreateUser::new()
781 .with_email("passkey-test@example.com")
782 .with_name("Passkey Tester"),
783 Duration::hours(1),
784 )
785 .await;
786
787 ctx.database
788 .create_passkey(CreatePasskey {
789 user_id: user.id.clone(),
790 name: None,
791 credential_id: credential_id("cred-list"),
792 public_key: "public-key".to_owned(),
793 counter: 0,
794 device_type: "singleDevice".to_owned(),
795 backed_up: false,
796 transports: None,
797
798 aaguid: Some("00000000-0000-0000-0000-000000000000".to_owned()),
799 })
800 .await
801 .unwrap();
802
803 let req = test_helpers::create_auth_request_no_query(
804 HttpMethod::Get,
805 "/passkey/list-user-passkeys",
806 Some(&session.token),
807 None,
808 );
809 let response = plugin.handle_list_user_passkeys(&req, &ctx).await.unwrap();
810 assert_eq!(response.status, 200);
811
812 let body: serde_json::Value = serde_json::from_slice(&response.body).unwrap();
813 assert!(
814 (*(body).get(0).unwrap_or(&serde_json::Value::Null))
815 .get("updatedAt")
816 .is_none()
817 );
818 assert_eq!(
819 (*(*(body).get(0).unwrap_or(&serde_json::Value::Null))
820 .get("aaguid")
821 .unwrap_or(&serde_json::Value::Null)),
822 "00000000-0000-0000-0000-000000000000"
823 );
824 let passkey = body.get(0).unwrap();
825 assert_eq!(passkey.get("name"), Some(&serde_json::Value::Null));
827 assert_eq!(passkey.get("transports"), Some(&serde_json::Value::Null));
828 }
829
830 #[tokio::test]
831 async fn test_delete_passkey_non_owner_is_unauthorized() {
832 let plugin = passkey_plugin();
833 let (ctx, _user, session) = test_helpers::create_test_context_with_user(
834 CreateUser::new()
835 .with_email("owner@example.com")
836 .with_name("Owner"),
837 Duration::hours(1),
838 )
839 .await;
840 let other = test_helpers::create_user(
841 &ctx,
842 CreateUser::new()
843 .with_email("other@example.com")
844 .with_name("Other"),
845 )
846 .await;
847
848 let passkey = ctx
849 .database
850 .create_passkey(CreatePasskey {
851 user_id: other.id.clone(),
852 name: Some("Other Key".to_owned()),
853 credential_id: credential_id("cred-other-delete"),
854 public_key: "public-key".to_owned(),
855 counter: 0,
856 device_type: "singleDevice".to_owned(),
857 backed_up: false,
858 transports: None,
859
860 aaguid: None,
861 })
862 .await
863 .unwrap();
864
865 let req = test_helpers::create_auth_json_request_no_query(
866 HttpMethod::Post,
867 "/passkey/delete-passkey",
868 Some(&session.token),
869 Some(serde_json::json!({ "id": passkey.id })),
870 );
871
872 let response = plugin.handle_delete_passkey(&req, &ctx).await.unwrap();
873 assert_eq!(response.status, 401);
874 assert_eq!(response.body.len(), 0);
875 let preserved = ctx
876 .database
877 .get_passkey_by_id(&passkey.id)
878 .await
879 .unwrap()
880 .unwrap();
881 assert_eq!(preserved.name.as_deref(), Some("Other Key"));
882 }
883
884 #[tokio::test]
885 async fn test_update_passkey_non_owner_is_unauthorized() {
886 let plugin = passkey_plugin();
887 let (ctx, _user, session) = test_helpers::create_test_context_with_user(
888 CreateUser::new()
889 .with_email("owner@example.com")
890 .with_name("Owner"),
891 Duration::hours(1),
892 )
893 .await;
894 let other = test_helpers::create_user(
895 &ctx,
896 CreateUser::new()
897 .with_email("other@example.com")
898 .with_name("Other"),
899 )
900 .await;
901
902 let passkey = ctx
903 .database
904 .create_passkey(CreatePasskey {
905 user_id: other.id.clone(),
906 name: Some("Other Key".to_owned()),
907 credential_id: credential_id("cred-other-update"),
908 public_key: "public-key".to_owned(),
909 counter: 0,
910 device_type: "singleDevice".to_owned(),
911 backed_up: false,
912 transports: None,
913
914 aaguid: None,
915 })
916 .await
917 .unwrap();
918
919 let req = test_helpers::create_auth_json_request_no_query(
920 HttpMethod::Post,
921 "/passkey/update-passkey",
922 Some(&session.token),
923 Some(serde_json::json!({
924 "id": passkey.id,
925 "name": "Hijacked",
926 })),
927 );
928
929 let response = plugin.handle_update_passkey(&req, &ctx).await.unwrap();
930 assert_eq!(response.status, 401);
931 let body: serde_json::Value = serde_json::from_slice(&response.body).unwrap();
932 assert_eq!(
933 (*(body).get("code").unwrap_or(&serde_json::Value::Null)),
934 "YOU_ARE_NOT_ALLOWED_TO_REGISTER_THIS_PASSKEY"
935 );
936 let preserved = ctx
937 .database
938 .get_passkey_by_id(&passkey.id)
939 .await
940 .unwrap()
941 .unwrap();
942 assert_eq!(preserved.name.as_deref(), Some("Other Key"));
943 }
944
945 #[tokio::test]
947 #[expect(
948 clippy::panic_in_result_fn,
949 reason = "Keep this ordered integration scenario and its assertions together; Result propagates setup failures"
950 )]
951 async fn raw_none_credential_sql_readback_keeps_original_key_and_hidden_codec()
952 -> Result<(), Box<dyn std::error::Error>> {
953 use serde_cbor_2::Value as Cbor;
954 use sha2::{Digest, Sha256};
955 use std::collections::BTreeMap;
956 for (algorithm, curve) in [(-8, 8), (-7, 6)] {
959 let signing = ed25519_dalek::SigningKey::from_bytes(&[9; 32]);
960 let plugin = passkey_plugin();
961 let (ctx, user, session) = test_helpers::create_test_context_with_user(
962 CreateUser::new()
963 .with_email("raw-codec-owner@fixture.test")
964 .with_name("Raw codec owner"),
965 Duration::hours(1),
966 )
967 .await;
968 let request = test_helpers::create_auth_request(
969 HttpMethod::Get,
970 "/passkey/generate-register-options",
971 Some(&session.token),
972 None,
973 HashMap::new(),
974 );
975 let options = plugin
976 .handle_generate_register_options(&request, &ctx)
977 .await?;
978 let issued: serde_json::Value = serde_json::from_slice(&options.body)?;
979 let key = serde_cbor_2::to_vec(&Cbor::Map(BTreeMap::from([
980 (Cbor::Integer(1), Cbor::Integer(1)),
981 (Cbor::Integer(3), Cbor::Integer(algorithm)),
982 (Cbor::Integer(-1), Cbor::Integer(curve)),
983 (
984 Cbor::Integer(-2),
985 Cbor::Bytes(signing.verifying_key().as_bytes().to_vec()),
986 ),
987 ])))?;
988 let id = b"raw-none-actual-persisted-credential";
989 let mut data = Sha256::digest(b"localhost").to_vec();
990 data.push(0x41);
991 data.extend_from_slice(&25_u32.to_be_bytes());
992 data.extend_from_slice(&[0; 16]);
993 data.extend_from_slice(&u16::try_from(id.len())?.to_be_bytes());
994 data.extend_from_slice(id);
995 data.extend_from_slice(&key);
996 let attestation = serde_cbor_2::to_vec(&Cbor::Map(BTreeMap::from([
997 (Cbor::Text("fmt".into()), Cbor::Text("none".into())),
998 (Cbor::Text("attStmt".into()), Cbor::Map(BTreeMap::new())),
999 (Cbor::Text("authData".into()), Cbor::Bytes(data)),
1000 ])))?;
1001 let proof = serde_json::json!({"id":URL_SAFE_NO_PAD.encode(id),"rawId":URL_SAFE_NO_PAD.encode(id),"type":"public-key","clientExtensionResults":{},"response":{
1002 "clientDataJSON":URL_SAFE_NO_PAD.encode(serde_json::to_vec(&serde_json::json!({"type":"webauthn.create","challenge":(*(issued).get("challenge").expect("fixture contains the requested index")),"origin":"http://localhost:3000"}))?),
1003 "attestationObject":URL_SAFE_NO_PAD.encode(attestation),"transports":["internal"],
1004 }});
1005 let mut request_2 = test_helpers::create_auth_request(
1006 HttpMethod::Post,
1007 "/passkey/verify-registration",
1008 Some(&session.token),
1009 Some(serde_json::to_vec(&serde_json::json!({"response":proof}))?),
1010 HashMap::new(),
1011 );
1012 let issued_cookie = cookie_header(&options)
1013 .split(';')
1014 .next()
1015 .ok_or("issued challenge cookie required")?;
1016 _ = write!(
1017 request_2
1018 .headers
1019 .get_mut("cookie")
1020 .ok_or("signed owner cookie required")?,
1021 "; {issued_cookie}"
1022 );
1023 let result = plugin.handle_verify_registration(&request_2, &ctx).await?;
1024 assert_eq!(result.status, 200);
1025 let wire: serde_json::Value = serde_json::from_slice(&result.body)?;
1026 assert!(wire.get("credential").is_none());
1027 let row = ctx
1028 .database
1029 .get_passkey_by_credential_id(&URL_SAFE_NO_PAD.encode(id))
1030 .await?
1031 .ok_or("actual credential row required")?;
1032 assert_eq!(row.user_id, user.id);
1033 assert_eq!(row.counter, 25);
1034 assert_eq!(
1035 row.public_key,
1036 base64::engine::general_purpose::STANDARD.encode(&key)
1037 );
1038 if curve == 6 {
1039 use ed25519_dalek::Signer;
1040 let mut persisted = serde_json::to_value(&row)?;
1041 for control in [
1042 "first",
1043 "signature",
1044 "counter",
1045 "origin",
1046 "challenge",
1047 "rp",
1048 "presence",
1049 "backup",
1050 "last",
1051 ] {
1052 let successful = matches!(control, "first" | "last");
1053 let counter = if matches!(control, "first" | "counter") {
1054 26_u32
1055 } else {
1056 27_u32
1057 };
1058 let request = test_helpers::create_auth_request(
1059 HttpMethod::Get,
1060 "/passkey/generate-authenticate-options",
1061 None,
1062 None,
1063 HashMap::new(),
1064 );
1065 let options = plugin
1066 .handle_generate_authenticate_options(&request, &ctx)
1067 .await?;
1068 let issued: serde_json::Value = serde_json::from_slice(&options.body)?;
1069 let challenge = issued
1070 .get("challenge")
1071 .expect("issued authentication challenge");
1072 let client = serde_json::to_vec(
1073 &serde_json::json!({"type":"webauthn.get", "challenge":if control == "challenge" { serde_json::Value::String("Zm9yZWlnbg".into()) } else { challenge.clone() },"origin":if control == "origin" { "http://foreign.test" } else { "http://localhost:3000" }}),
1074 )?;
1075 let mut data = Sha256::digest(if control == "rp" {
1076 b"foreign.test".as_slice()
1077 } else {
1078 b"localhost".as_slice()
1079 })
1080 .to_vec();
1081 data.push(match control {
1082 "presence" => 0,
1083 "backup" => 0x11,
1084 _ => 1,
1085 });
1086 data.extend_from_slice(&counter.to_be_bytes());
1087 let mut signed = data.clone();
1088 signed.extend_from_slice(&Sha256::digest(&client));
1089 let mut signature = signing.sign(&signed).to_bytes();
1090 if control == "signature" {
1091 signature[0] ^= 1;
1092 }
1093 let response = serde_json::json!({"id":URL_SAFE_NO_PAD.encode(id),"rawId":URL_SAFE_NO_PAD.encode(id),"type":"public-key","clientExtensionResults":{},"response":{
1094 "clientDataJSON":URL_SAFE_NO_PAD.encode(client),"authenticatorData":URL_SAFE_NO_PAD.encode(data),"signature":URL_SAFE_NO_PAD.encode(signature)
1095 }});
1096 let mut request = test_helpers::create_auth_request(
1097 HttpMethod::Post,
1098 "/passkey/verify-authentication",
1099 None,
1100 Some(serde_json::to_vec(
1101 &serde_json::json!({"response":response}),
1102 )?),
1103 HashMap::new(),
1104 );
1105 request.headers.insert(
1106 "cookie".into(),
1107 cookie_header(&options)
1108 .split(';')
1109 .next()
1110 .ok_or("issued authentication cookie required")?
1111 .into(),
1112 );
1113 let result = plugin.handle_verify_authentication(&request, &ctx).await?;
1114 assert_eq!(
1115 result.status,
1116 if successful {
1117 200
1118 } else if control == "signature" {
1119 401
1120 } else {
1121 400
1122 },
1123 "{control}"
1124 );
1125 let updated = ctx
1126 .database
1127 .get_passkey_by_credential_id(&URL_SAFE_NO_PAD.encode(id))
1128 .await?
1129 .ok_or("updated raw row required")?;
1130 if !successful {
1131 let wire: serde_json::Value = serde_json::from_slice(&result.body)?;
1132 assert_eq!(
1133 wire.get("code"),
1134 Some(&serde_json::json!("AUTHENTICATION_FAILED")),
1135 "{control}"
1136 );
1137 assert!(
1138 result
1139 .headers
1140 .get_all("set-cookie")
1141 .all(|cookie| !cookie.contains("session_token="))
1142 );
1143 assert_eq!(serde_json::to_value(&updated)?, persisted, "{control}");
1144 assert_eq!(ctx.database.get_user_sessions(&user.id).await?.len(), 2);
1145 continue;
1146 }
1147 persisted = serde_json::to_value(&updated)?;
1148 assert_eq!(updated.public_key, row.public_key);
1149 assert_eq!(updated.credential_id, row.credential_id);
1150 assert_eq!(updated.user_id, row.user_id);
1151 assert_eq!(updated.counter, u64::from(counter));
1152 }
1153 }
1154 assert_eq!(
1155 ctx.database.get_user_sessions(&user.id).await?.len(),
1156 if curve == 6 { 3 } else { 1 }
1157 );
1158 }
1159 Ok(())
1160 }
1161}
1162