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