1mod authentication;
2mod options;
3pub use options::{
4 PasskeyAuthenticatorSelection, PasskeyExtensions, PasskeyExtensionsResolver,
5 PasskeyOptionsContext,
6};
7
8pub(super) mod handlers;
9
10mod raw_none;
11mod source;
12
13mod registration;
14
15pub(super) mod types;
16
17pub(super) mod webauthn;
18
19use alibi_core::utils::cookie_utils::create_session_cookie;
20use alibi_core::{AuthContext, AuthError, AuthResult};
21use alibi_core::{AuthRequest, AuthResponse};
22pub use authentication::{
23 AuthenticationResult, PasskeyAuthenticationAfterVerification, PasskeyAuthenticationConfig,
24 PasskeyAuthenticationContext, VerifiedPasskeyAuthentication,
25};
26use handlers::{
27 PasskeyHandlerOutcome, delete_passkey_core, generate_authenticate_options_core,
28 generate_register_options_core, list_user_passkeys_core, update_passkey_core,
29 verify_authentication_core, verify_registration_core,
30};
31pub use registration::{
32 PasskeyRegistrationAfterVerification, PasskeyRegistrationConfig, PasskeyRegistrationContext,
33 PasskeyRegistrationOverride, PasskeyRegistrationUser, PasskeyUserResolver,
34 VerifiedPasskeyRegistration,
35};
36use types::{
37 DeletePasskeyRequest, UpdatePasskeyRequest, VerifyAuthenticationRequest,
38 VerifyRegistrationRequest,
39};
40
41pub struct PasskeyPlugin {
46 config: PasskeyConfig,
47}
48
49#[derive(Debug, Clone, alibi_core::PluginConfig)]
50#[plugin(name = "PasskeyPlugin")]
51pub struct PasskeyConfig {
52 #[config(default = String::new())]
53 pub rp_id: String,
54 #[config(default = "Better Auth".to_owned())]
55 pub rp_name: String,
56 #[config(default = String::new())]
57 pub origin: String,
58 #[config(default = Vec::new())]
60 pub origins: Vec<String>,
61 #[config(default = PasskeyAuthenticatorSelection::default())]
62 pub authenticator_selection: PasskeyAuthenticatorSelection,
63 #[config(default = 300)]
64 pub challenge_ttl_secs: i64,
65 #[config(default = "better-auth-passkey".to_owned())]
66 pub web_authn_challenge_cookie: String,
67 #[config(default = None)]
70 pub attestation_root_certificates: Option<std::collections::BTreeMap<String, Vec<String>>>,
71 #[config(default = PasskeyRegistrationConfig::default())]
72 pub registration: PasskeyRegistrationConfig,
73 #[config(default = PasskeyAuthenticationConfig::default())]
74 pub authentication: PasskeyAuthenticationConfig,
75}
76
77impl PasskeyPlugin {
78 async fn handle_generate_register_options(
80 &self,
81 req: &AuthRequest,
82 ctx: &AuthContext<impl alibi_core::AuthSchema>,
83 ) -> AuthResult<AuthResponse> {
84 use alibi_core::AuthUser;
85 let session = self.registration_session(req, ctx).await?;
86 let projected_user = session.as_ref().map(|(user, _)| ctx.user_view(user));
87 let user = if let Some((user, _)) = session {
88 let id = user.id().into_owned();
89 let name = user
90 .email()
91 .filter(|email| !email.is_empty())
92 .unwrap_or(&id)
93 .to_owned();
94 PasskeyRegistrationUser {
95 id,
96 name: name.clone(),
97 display_name: Some(name),
98 }
99 } else {
100 let Some(resolver) = &self.config.registration.resolve_user else {
101 return Ok(AuthResponse::json(
102 400,
103 &serde_json::json!({"code":"RESOLVE_USER_REQUIRED","message":"Passkey registration requires either an authenticated session or a resolveUser callback when requireSession is false"}),
104 )?);
105 };
106 let context = PasskeyRegistrationContext {
107 request: req,
108 auth_config: &ctx.config,
109 extensions: &ctx.extensions,
110 };
111 match resolver
112 .resolve_user(&context, req.query.get("context").map(String::as_str))
113 .await
114 {
115 Ok(Some(user)) if !user.id.is_empty() && !user.name.is_empty() => user,
116 Ok(_) => {
117 return Ok(AuthResponse::json(
118 400,
119 &serde_json::json!({"code":"RESOLVED_USER_INVALID","message":"Resolved user is invalid"}),
120 )?);
121 }
122 Err(error) if registration::is_application_error(&error) => return Err(error),
123 Err(_) => return Ok(AuthResponse::new(500)),
124 }
125 };
126 let extensions = if let Some(input) = &self.config.registration.extensions {
127 Some(
128 input
129 .resolve(&PasskeyOptionsContext {
130 request: req,
131 auth_config: &ctx.config,
132 extensions: &ctx.extensions,
133 user: projected_user.as_ref(),
134 })
135 .await?,
136 )
137 } else {
138 None
139 };
140 let passkey_name = req.query.get("name").map(String::as_str);
141 let authenticator_attachment = req.query.get("authenticatorAttachment").map(String::as_str);
142 let (result, cookie_header) = generate_register_options_core(
143 &user,
144 req.query.get("context").map(String::as_str),
145 passkey_name,
146 authenticator_attachment,
147 extensions,
148 &self.config,
149 ctx,
150 )
151 .await?;
152 Ok(AuthResponse::json(200, &result)?.with_header("Set-Cookie", cookie_header))
153 }
154
155 async fn handle_verify_registration(
157 &self,
158 req: &AuthRequest,
159 ctx: &AuthContext<impl alibi_core::AuthSchema>,
160 ) -> AuthResult<AuthResponse> {
161 let body: VerifyRegistrationRequest = match alibi_core::validate_request_body(req) {
162 Ok(v) => v,
163 Err(resp) => return Ok(resp),
164 };
165 let session = if self.config.registration.require_session {
166 self.registration_session(req, ctx).await?
167 } else {
168 None
169 };
170 let owner_id = session
171 .as_ref()
172 .map(|(user, _)| alibi_core::AuthUser::id(user).into_owned());
173 match verify_registration_core(&body, req, owner_id.as_deref(), &self.config, ctx).await? {
174 PasskeyHandlerOutcome::Success(result) => {
175 let token = result
176 .get("session")
177 .and_then(|session_2| session_2.get("token"))
178 .and_then(serde_json::Value::as_str);
179 let response = AuthResponse::json(200, &result)?;
180 if let Some(token) = token {
181 use alibi_core::utils::cookie_utils::{
182 create_session_cookie_with_max_age, create_session_like_cookie,
183 related_cookie_name, sign_cookie_value, verify_cookie_value,
184 };
185 let preference = related_cookie_name(&ctx.config, "dont_remember");
186 let dont_remember = crate::helpers::get_cookie(req, &preference)
187 .and_then(|value| verify_cookie_value(&value, ctx.config.current_secret()))
188 .is_some_and(|value| !value.is_empty());
189 let mut response = response.with_appended_header(
190 "Set-Cookie",
191 create_session_cookie_with_max_age(
192 Some(token),
193 if dont_remember {
194 None
195 } else {
196 Some(ctx.config.session.expires_in.num_seconds())
197 },
198 &ctx.config,
199 )?,
200 );
201 if dont_remember {
202 response.headers.append(
203 "Set-Cookie",
204 create_session_like_cookie(
205 &preference,
206 &sign_cookie_value("true", ctx.config.current_secret()),
207 None,
208 &ctx.config,
209 )?,
210 );
211 }
212 Ok(response)
213 } else {
214 Ok(response)
215 }
216 }
217 PasskeyHandlerOutcome::Response(response) => Ok(response),
218 }
219 }
220
221 async fn registration_session<S: alibi_core::AuthSchema>(
222 &self,
223 req: &AuthRequest,
224 ctx: &AuthContext<S>,
225 ) -> AuthResult<
226 Option<(
227 alibi_core::AuthenticatedUser<S>,
228 alibi_core::wire::SessionView,
229 )>,
230 > {
231 let session = match ctx.require_cached_session(req).await {
232 Ok(session) => Some(session),
233 Err(AuthError::Unauthenticated | AuthError::SessionNotFound)
234 if !self.config.registration.require_session =>
235 {
236 None
237 }
238 Err(error) => return Err(error),
239 };
240 if self.config.registration.require_session
241 && let Some((_, session)) = &session
242 && !ctx.session_manager().is_session_fresh(session)
243 {
244 return Err(AuthError::Upstream {
245 status: 403,
246 code: "SESSION_NOT_FRESH",
247 message: "Session is not fresh",
248 });
249 }
250 Ok(session)
251 }
252
253 async fn handle_generate_authenticate_options(
255 &self,
256 req: &AuthRequest,
257 ctx: &AuthContext<impl alibi_core::AuthSchema>,
258 ) -> AuthResult<AuthResponse> {
259 let maybe_user = ctx.require_cached_session(req).await.ok().map(|(u, _)| u);
260 let projected_user = maybe_user.as_ref().map(|user| ctx.user_view(user));
261 let extensions = if let Some(input) = &self.config.authentication.extensions {
262 Some(
263 input
264 .resolve(&PasskeyOptionsContext {
265 request: req,
266 auth_config: &ctx.config,
267 extensions: &ctx.extensions,
268 user: projected_user.as_ref(),
269 })
270 .await?,
271 )
272 } else {
273 None
274 };
275 let (result, cookie_header) =
276 generate_authenticate_options_core(maybe_user.as_ref(), extensions, &self.config, ctx)
277 .await?;
278 Ok(AuthResponse::json(200, &result)?.with_header("Set-Cookie", cookie_header))
279 }
280
281 async fn handle_verify_authentication(
283 &self,
284 req: &AuthRequest,
285 ctx: &AuthContext<impl alibi_core::AuthSchema>,
286 ) -> AuthResult<AuthResponse> {
287 let body: VerifyAuthenticationRequest = match alibi_core::validate_request_body(req) {
288 Ok(v) => v,
289 Err(resp) => return Ok(resp),
290 };
291 let metadata = alibi_core::RequestMeta::from_request(req);
292 match verify_authentication_core(
293 &body,
294 req,
295 &self.config,
296 metadata.ip_address,
297 metadata.user_agent,
298 ctx,
299 )
300 .await?
301 {
302 PasskeyHandlerOutcome::Success((response, token)) => {
303 let cookie_header = create_session_cookie(&token, &ctx.config)?;
304 Ok(AuthResponse::json(200, &response)?.with_header("Set-Cookie", cookie_header))
305 }
306 PasskeyHandlerOutcome::Response(response) => Ok(response),
307 }
308 }
309
310 async fn handle_list_user_passkeys(
312 &self,
313 req: &AuthRequest,
314 ctx: &AuthContext<impl alibi_core::AuthSchema>,
315 ) -> AuthResult<AuthResponse> {
316 let (user, _session) = super::helpers::ordinary_session(req, ctx).await?;
317 let result = list_user_passkeys_core(&user, ctx).await?;
318 AuthResponse::json(200, &result).map_err(AuthError::from)
319 }
320
321 async fn handle_delete_passkey(
323 &self,
324 req: &AuthRequest,
325 ctx: &AuthContext<impl alibi_core::AuthSchema>,
326 ) -> AuthResult<AuthResponse> {
327 let (user, _session) = super::helpers::ordinary_session(req, ctx).await?;
328 let body: DeletePasskeyRequest = match alibi_core::validate_request_body(req) {
329 Ok(v) => v,
330 Err(resp) => return Ok(resp),
331 };
332 match delete_passkey_core(&body, &user, ctx).await? {
333 PasskeyHandlerOutcome::Success(result) => {
334 AuthResponse::json(200, &result).map_err(AuthError::from)
335 }
336 PasskeyHandlerOutcome::Response(response) => Ok(response),
337 }
338 }
339
340 async fn handle_update_passkey(
342 &self,
343 req: &AuthRequest,
344 ctx: &AuthContext<impl alibi_core::AuthSchema>,
345 ) -> AuthResult<AuthResponse> {
346 let (user, _session) = super::helpers::ordinary_session(req, ctx).await?;
347 let body: UpdatePasskeyRequest = match alibi_core::validate_request_body(req) {
348 Ok(v) => v,
349 Err(resp) => return Ok(resp),
350 };
351 match update_passkey_core(&body, &user, ctx).await? {
352 PasskeyHandlerOutcome::Success(result) => {
353 AuthResponse::json(200, &result).map_err(AuthError::from)
354 }
355 PasskeyHandlerOutcome::Response(response) => Ok(response),
356 }
357 }
358}
359
360alibi_core::impl_auth_plugin! {
361 PasskeyPlugin, "passkey";
362 routes {
363 get "/passkey/generate-register-options" => handle_generate_register_options, "passkey_generate_register_options";
364 post "/passkey/verify-registration" => handle_verify_registration, "passkey_verify_registration";
365 get "/passkey/generate-authenticate-options" => handle_generate_authenticate_options, "passkey_generate_authenticate_options";
366 post "/passkey/verify-authentication" => handle_verify_authentication, "passkey_verify_authentication";
367 get "/passkey/list-user-passkeys" => handle_list_user_passkeys, "passkey_list_user_passkeys";
368 post "/passkey/delete-passkey" => handle_delete_passkey, "passkey_delete_passkey";
369 post "/passkey/update-passkey" => handle_update_passkey, "passkey_update_passkey";
370 }
371
372 extra {
373 route_openapi_metadata!(S);
374 }
375}
376
377impl std::fmt::Debug for PasskeyPlugin {
378 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
379 f.debug_struct("PasskeyPlugin").finish_non_exhaustive()
380 }
381}
382
383#[cfg(test)]
385mod tests {
386 mod revocation;
387 use super::*;
388 use crate::test_helpers;
389 use alibi_core::{CreatePasskey, CreateUser, HttpMethod};
390 use base64::Engine;
391 use base64::engine::general_purpose::URL_SAFE_NO_PAD;
392 use chrono::Duration;
393 use std::collections::HashMap;
394 use std::fmt::Write;
395
396 fn passkey_plugin() -> PasskeyPlugin {
397 PasskeyPlugin::new()
398 .rp_id("localhost")
399 .rp_name("Better Auth Test")
400 .origin("http://localhost:3000")
401 }
402
403 fn cookie_header(response: &AuthResponse) -> &str {
404 response
405 .headers
406 .get("Set-Cookie")
407 .expect("response should include a Set-Cookie header")
408 }
409
410 fn credential_id(label: &str) -> String {
411 URL_SAFE_NO_PAD.encode(label.as_bytes())
412 }
413
414 #[test]
415 fn test_extract_passkey_snapshot_fields_requires_all_expected_fields() {
416 let value = serde_json::json!({
417 "cred": {
418 "counter": 7,
419 "backup_state": true
420 }
421 });
422
423 let err = webauthn::extract_passkey_snapshot_fields(&value).unwrap_err();
424 assert_eq!(
425 err.to_string(),
426 "Internal server error: Stored passkey JSON missing backup_eligible"
427 );
428 }
429
430 #[test]
431 fn test_extract_passkey_snapshot_fields_reads_expected_values() {
432 let value = serde_json::json!({
433 "cred": {
434 "counter": 11,
435 "backup_state": true,
436 "backup_eligible": false
437 }
438 });
439
440 let (counter, backed_up, backup_eligible) =
441 webauthn::extract_passkey_snapshot_fields(&value).unwrap();
442 assert_eq!(counter, 11);
443 assert!(backed_up);
444 assert!(!backup_eligible);
445 }
446
447 #[tokio::test]
448 async fn test_generate_register_options_sets_cookie_and_uses_query_name() {
449 let plugin = passkey_plugin();
450 let (ctx, user, session) = test_helpers::create_test_context_with_user(
451 CreateUser::new()
452 .with_email("passkey-test@example.com")
453 .with_name("Passkey Tester"),
454 Duration::hours(1),
455 )
456 .await;
457
458 ctx.database
459 .create_passkey(CreatePasskey {
460 user_id: user.id.clone(),
461 name: Some("Existing Key".to_owned()),
462 credential_id: credential_id("cred-existing"),
463 public_key: "public-key".to_owned(),
464 counter: 0,
465 device_type: "singleDevice".to_owned(),
466 backed_up: false,
467 transports: Some("usb,nfc".to_owned()),
468 credential: "invalid-stored-passkey".to_owned(),
469 aaguid: Some("00000000-0000-0000-0000-000000000000".to_owned()),
470 })
471 .await
472 .unwrap();
473
474 let req = test_helpers::create_auth_request(
475 HttpMethod::Get,
476 "/passkey/generate-register-options",
477 Some(&session.token),
478 None,
479 HashMap::from([
480 ("name".to_owned(), "Custom Account Label".to_owned()),
481 (
482 "authenticatorAttachment".to_owned(),
483 "cross-platform".to_owned(),
484 ),
485 ]),
486 );
487
488 let response = plugin
489 .handle_generate_register_options(&req, &ctx)
490 .await
491 .unwrap();
492 assert_eq!(response.status, 200);
493 assert!(cookie_header(&response).contains("better-auth-passkey="));
494
495 let body: serde_json::Value = serde_json::from_slice(&response.body).unwrap();
496 assert!((*(body).get("challenge").unwrap_or(&serde_json::Value::Null)).is_string());
497 assert_eq!(
498 (*(*(body).get("user").unwrap_or(&serde_json::Value::Null))
499 .get("name")
500 .unwrap_or(&serde_json::Value::Null)),
501 "Custom Account Label"
502 );
503 assert_eq!(
504 (*(*(body)
505 .get("authenticatorSelection")
506 .unwrap_or(&serde_json::Value::Null))
507 .get("authenticatorAttachment")
508 .unwrap_or(&serde_json::Value::Null)),
509 "cross-platform"
510 );
511 assert_eq!(
512 (*(*(*(body)
513 .get("excludeCredentials")
514 .unwrap_or(&serde_json::Value::Null))
515 .get(0)
516 .unwrap_or(&serde_json::Value::Null))
517 .get("id")
518 .unwrap_or(&serde_json::Value::Null)),
519 credential_id("cred-existing")
520 );
521 assert_eq!(
522 (*(*(*(*(body)
523 .get("excludeCredentials")
524 .unwrap_or(&serde_json::Value::Null))
525 .get(0)
526 .unwrap_or(&serde_json::Value::Null))
527 .get("transports")
528 .unwrap_or(&serde_json::Value::Null))
529 .get(0)
530 .unwrap_or(&serde_json::Value::Null)),
531 "usb"
532 );
533 }
534
535 #[tokio::test]
536 async fn test_generate_authenticate_options_is_get_and_sets_cookie_without_auth() {
537 let plugin = passkey_plugin();
538 let ctx = test_helpers::create_test_context().await;
539 let req = test_helpers::create_auth_request_no_query(
540 HttpMethod::Get,
541 "/passkey/generate-authenticate-options",
542 None,
543 None,
544 );
545
546 let response = plugin
547 .handle_generate_authenticate_options(&req, &ctx)
548 .await
549 .unwrap();
550 assert_eq!(response.status, 200);
551 assert!(cookie_header(&response).contains("better-auth-passkey="));
552
553 let body: serde_json::Value = serde_json::from_slice(&response.body).unwrap();
554 assert!((*(body).get("challenge").unwrap_or(&serde_json::Value::Null)).is_string());
555 assert!(body.get("allowCredentials").is_none());
556 }
557
558 #[tokio::test]
559 async fn test_generate_authenticate_options_with_auth_lists_allow_credentials() {
560 let plugin = passkey_plugin();
561 let (ctx, user, session) = test_helpers::create_test_context_with_user(
562 CreateUser::new()
563 .with_email("passkey-test@example.com")
564 .with_name("Passkey Tester"),
565 Duration::hours(1),
566 )
567 .await;
568
569 ctx.database
570 .create_passkey(CreatePasskey {
571 user_id: user.id.clone(),
572 name: Some("Authenticator".to_owned()),
573 credential_id: credential_id("cred-auth"),
574 public_key: "public-key".to_owned(),
575 counter: 0,
576 device_type: "singleDevice".to_owned(),
577 backed_up: false,
578 transports: Some("internal".to_owned()),
579 credential: "invalid-stored-passkey".to_owned(),
580 aaguid: None,
581 })
582 .await
583 .unwrap();
584
585 let req = test_helpers::create_auth_request_no_query(
586 HttpMethod::Get,
587 "/passkey/generate-authenticate-options",
588 Some(&session.token),
589 None,
590 );
591
592 let response = plugin
593 .handle_generate_authenticate_options(&req, &ctx)
594 .await
595 .unwrap();
596 assert_eq!(response.status, 200);
597
598 let body: serde_json::Value = serde_json::from_slice(&response.body).unwrap();
599 assert_eq!(
600 (*(*(*(body)
601 .get("allowCredentials")
602 .unwrap_or(&serde_json::Value::Null))
603 .get(0)
604 .unwrap_or(&serde_json::Value::Null))
605 .get("id")
606 .unwrap_or(&serde_json::Value::Null)),
607 credential_id("cred-auth")
608 );
609 assert_eq!(
610 (*(*(*(*(body)
611 .get("allowCredentials")
612 .unwrap_or(&serde_json::Value::Null))
613 .get(0)
614 .unwrap_or(&serde_json::Value::Null))
615 .get("transports")
616 .unwrap_or(&serde_json::Value::Null))
617 .get(0)
618 .unwrap_or(&serde_json::Value::Null)),
619 "internal"
620 );
621 }
622
623 #[tokio::test]
624 async fn test_verify_registration_without_challenge_cookie_returns_challenge_not_found() {
625 let plugin = passkey_plugin();
626 let (ctx, _user, session) = test_helpers::create_test_context_with_user(
627 CreateUser::new()
628 .with_email("passkey-test@example.com")
629 .with_name("Passkey Tester"),
630 Duration::hours(1),
631 )
632 .await;
633
634 let body = serde_json::json!({
635 "response": {
636 "id": "fake-credential-id",
637 "rawId": "ZmFrZS1yYXctaWQ",
638 "response": {
639 "attestationObject": "ZmFrZS1hdHRlc3RhdGlvbg",
640 "clientDataJSON": "ZmFrZS1jbGllbnQtZGF0YQ"
641 },
642 "type": "public-key"
643 }
644 });
645 let mut req = test_helpers::create_auth_json_request_no_query(
646 HttpMethod::Post,
647 "/passkey/verify-registration",
648 Some(&session.token),
649 Some(body),
650 );
651 req.headers
652 .insert("origin".to_owned(), "http://localhost:3000".to_owned());
653
654 let response = plugin.handle_verify_registration(&req, &ctx).await.unwrap();
655 assert_eq!(response.status, 400);
656
657 let body_2: serde_json::Value = serde_json::from_slice(&response.body).unwrap();
658 assert_eq!(
659 (*(body_2).get("message").unwrap_or(&serde_json::Value::Null)),
660 "Challenge not found"
661 );
662 }
663
664 #[tokio::test]
665 async fn test_verify_authentication_without_challenge_cookie_returns_challenge_not_found() {
666 let plugin = passkey_plugin();
667 let ctx = test_helpers::create_test_context().await;
668
669 let body = serde_json::json!({
670 "response": {
671 "id": credential_id("cred-auth"),
672 "rawId": credential_id("cred-auth"),
673 "response": {
674 "authenticatorData": "ZmFrZS1hdXRoLWRhdGE",
675 "clientDataJSON": "ZmFrZS1jbGllbnQtZGF0YQ",
676 "signature": "ZmFrZS1zaWduYXR1cmU"
677 },
678 "type": "public-key"
679 }
680 });
681 let mut req = test_helpers::create_auth_json_request_no_query(
682 HttpMethod::Post,
683 "/passkey/verify-authentication",
684 None,
685 Some(body),
686 );
687 req.headers
688 .insert("origin".to_owned(), "http://localhost:3000".to_owned());
689
690 let response = plugin
691 .handle_verify_authentication(&req, &ctx)
692 .await
693 .unwrap();
694 assert_eq!(response.status, 400);
695
696 let body_2: serde_json::Value = serde_json::from_slice(&response.body).unwrap();
697 assert_eq!(
698 (*(body_2).get("message").unwrap_or(&serde_json::Value::Null)),
699 "Challenge not found"
700 );
701 }
702
703 #[tokio::test]
704 async fn test_list_user_passkeys_omits_updated_at_and_retains_sql_null_fields() {
705 let plugin = passkey_plugin();
706 let (ctx, user, session) = test_helpers::create_test_context_with_user(
707 CreateUser::new()
708 .with_email("passkey-test@example.com")
709 .with_name("Passkey Tester"),
710 Duration::hours(1),
711 )
712 .await;
713
714 ctx.database
715 .create_passkey(CreatePasskey {
716 user_id: user.id.clone(),
717 name: None,
718 credential_id: credential_id("cred-list"),
719 public_key: "public-key".to_owned(),
720 counter: 0,
721 device_type: "singleDevice".to_owned(),
722 backed_up: false,
723 transports: None,
724 credential: "invalid-stored-passkey".to_owned(),
725 aaguid: Some("00000000-0000-0000-0000-000000000000".to_owned()),
726 })
727 .await
728 .unwrap();
729
730 let req = test_helpers::create_auth_request_no_query(
731 HttpMethod::Get,
732 "/passkey/list-user-passkeys",
733 Some(&session.token),
734 None,
735 );
736 let response = plugin.handle_list_user_passkeys(&req, &ctx).await.unwrap();
737 assert_eq!(response.status, 200);
738
739 let body: serde_json::Value = serde_json::from_slice(&response.body).unwrap();
740 assert!(
741 (*(body).get(0).unwrap_or(&serde_json::Value::Null))
742 .get("updatedAt")
743 .is_none()
744 );
745 assert_eq!(
746 (*(*(body).get(0).unwrap_or(&serde_json::Value::Null))
747 .get("aaguid")
748 .unwrap_or(&serde_json::Value::Null)),
749 "00000000-0000-0000-0000-000000000000"
750 );
751 let passkey = body.get(0).unwrap();
752 assert_eq!(passkey.get("name"), Some(&serde_json::Value::Null));
754 assert_eq!(passkey.get("transports"), Some(&serde_json::Value::Null));
755 }
756
757 #[tokio::test]
758 async fn test_delete_passkey_non_owner_is_unauthorized() {
759 let plugin = passkey_plugin();
760 let (ctx, _user, session) = test_helpers::create_test_context_with_user(
761 CreateUser::new()
762 .with_email("owner@example.com")
763 .with_name("Owner"),
764 Duration::hours(1),
765 )
766 .await;
767 let other = test_helpers::create_user(
768 &ctx,
769 CreateUser::new()
770 .with_email("other@example.com")
771 .with_name("Other"),
772 )
773 .await;
774
775 let passkey = ctx
776 .database
777 .create_passkey(CreatePasskey {
778 user_id: other.id.clone(),
779 name: Some("Other Key".to_owned()),
780 credential_id: credential_id("cred-other-delete"),
781 public_key: "public-key".to_owned(),
782 counter: 0,
783 device_type: "singleDevice".to_owned(),
784 backed_up: false,
785 transports: None,
786 credential: "invalid-stored-passkey".to_owned(),
787 aaguid: None,
788 })
789 .await
790 .unwrap();
791
792 let req = test_helpers::create_auth_json_request_no_query(
793 HttpMethod::Post,
794 "/passkey/delete-passkey",
795 Some(&session.token),
796 Some(serde_json::json!({ "id": passkey.id })),
797 );
798
799 let response = plugin.handle_delete_passkey(&req, &ctx).await.unwrap();
800 assert_eq!(response.status, 401);
801 assert_eq!(response.body.len(), 0);
802 let preserved = ctx
803 .database
804 .get_passkey_by_id(&passkey.id)
805 .await
806 .unwrap()
807 .unwrap();
808 assert_eq!(preserved.name.as_deref(), Some("Other Key"));
809 }
810
811 #[tokio::test]
812 async fn test_update_passkey_non_owner_is_unauthorized() {
813 let plugin = passkey_plugin();
814 let (ctx, _user, session) = test_helpers::create_test_context_with_user(
815 CreateUser::new()
816 .with_email("owner@example.com")
817 .with_name("Owner"),
818 Duration::hours(1),
819 )
820 .await;
821 let other = test_helpers::create_user(
822 &ctx,
823 CreateUser::new()
824 .with_email("other@example.com")
825 .with_name("Other"),
826 )
827 .await;
828
829 let passkey = ctx
830 .database
831 .create_passkey(CreatePasskey {
832 user_id: other.id.clone(),
833 name: Some("Other Key".to_owned()),
834 credential_id: credential_id("cred-other-update"),
835 public_key: "public-key".to_owned(),
836 counter: 0,
837 device_type: "singleDevice".to_owned(),
838 backed_up: false,
839 transports: None,
840 credential: "invalid-stored-passkey".to_owned(),
841 aaguid: None,
842 })
843 .await
844 .unwrap();
845
846 let req = test_helpers::create_auth_json_request_no_query(
847 HttpMethod::Post,
848 "/passkey/update-passkey",
849 Some(&session.token),
850 Some(serde_json::json!({
851 "id": passkey.id,
852 "name": "Hijacked",
853 })),
854 );
855
856 let response = plugin.handle_update_passkey(&req, &ctx).await.unwrap();
857 assert_eq!(response.status, 401);
858 let body: serde_json::Value = serde_json::from_slice(&response.body).unwrap();
859 assert_eq!(
860 (*(body).get("code").unwrap_or(&serde_json::Value::Null)),
861 "YOU_ARE_NOT_ALLOWED_TO_REGISTER_THIS_PASSKEY"
862 );
863 let preserved = ctx
864 .database
865 .get_passkey_by_id(&passkey.id)
866 .await
867 .unwrap()
868 .unwrap();
869 assert_eq!(preserved.name.as_deref(), Some("Other Key"));
870 }
871
872 #[tokio::test]
874 #[expect(
875 clippy::panic_in_result_fn,
876 clippy::too_many_lines,
877 reason = "Keep this ordered integration scenario and its assertions together; Result propagates setup failures"
878 )]
879 async fn pending_registration_challenges_keep_original_verification_policy()
880 -> Result<(), Box<dyn std::error::Error>> {
881 use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
882 use p256::elliptic_curve::{Generate as _, sec1::ToSec1Point as _};
883 use serde_cbor_2::Value as Cbor;
884 use sha2::{Digest, Sha256};
885 use std::collections::BTreeMap;
886 use webauthn_rs::prelude::RegisterPublicKeyCredential;
887
888 let config = PasskeyConfig {
889 rp_id: "localhost".into(),
890 origin: "http://localhost:3100".into(),
891 ..Default::default()
892 };
893 let webauthn =
894 webauthn::build_webauthn(&config, &alibi_core::AuthConfig::default(), &config.origin)?;
895 let (options, legacy) = webauthn.start_passkey_registration(
896 uuid::Uuid::new_v4(),
897 "Legacy owner",
898 "Legacy owner",
899 None,
900 )?;
901 let stored = serde_json::to_string(&serde_json::json!({
903 "user_id": "legacy-owner", "user": null, "context": "old-context", "state": legacy,
904 }))?;
905 let decoded: webauthn::StoredRegistrationState = serde_json::from_str(&stored)?;
906 assert_eq!(decoded.user_id, "legacy-owner");
907 assert_eq!(decoded.context.as_deref(), Some("old-context"));
908 let webauthn::StoredRegistrationVerifier::Legacy(state) = decoded.state else {
909 panic!("Old challenge changed verifier policy");
910 };
911 let secret = p256::SecretKey::generate();
912 let point = secret.public_key().to_sec1_point(false);
913 let key = Cbor::Map(BTreeMap::from([
914 (Cbor::Integer(1), Cbor::Integer(2)),
915 (Cbor::Integer(3), Cbor::Integer(-7)),
916 (Cbor::Integer(-1), Cbor::Integer(1)),
917 (
918 Cbor::Integer(-2),
919 Cbor::Bytes(point.x().ok_or("missing generated X coordinate")?.to_vec()),
920 ),
921 (
922 Cbor::Integer(-3),
923 Cbor::Bytes(point.y().ok_or("missing generated Y coordinate")?.to_vec()),
924 ),
925 ]));
926 let core = webauthn::build_verification_core(
927 &config,
928 &alibi_core::AuthConfig::default(),
929 &config.origin,
930 )?;
931 let builder = core
932 .new_challenge_register_builder(b"actual-core-owner", "Core owner", "Core owner")?
933 .user_verification_policy(webauthn_rs_core::proto::UserVerificationPolicy::Preferred);
934 let (core_options, core_state) = core.generate_challenge_register(builder)?;
935 let old_core_wire = serde_json::to_string(&serde_json::json!({
936 "user_id": "core-owner", "user": null, "context": "old-core-context",
937 "state": {"kind": "core", "state": core_state},
938 }))?;
939 let decoded_core: webauthn::StoredRegistrationState = serde_json::from_str(&old_core_wire)?;
940 let webauthn::StoredRegistrationVerifier::Source(
941 webauthn::StoredCoreRegistrationState::Core { state: old_core },
942 ) = decoded_core.state
943 else {
944 panic!("Old Core protocol changed verifier");
945 };
946 let credential_id = b"actual-legacy-credential";
947 let client_data = serde_json::to_vec(&serde_json::json!({
948 "type": "webauthn.create", "challenge": options.public_key.challenge,
949 "origin": config.origin, "crossOrigin": false,
950 }))?;
951 for verified in [true, false] {
952 let mut auth_data = Sha256::digest(config.rp_id.as_bytes()).to_vec();
953 auth_data.push(if verified { 0x45 } else { 0x41 });
954 auth_data.extend_from_slice(&[0; 20]);
955 auth_data.extend_from_slice(&u16::try_from(credential_id.len())?.to_be_bytes());
956 auth_data.extend_from_slice(credential_id);
957 auth_data.extend_from_slice(&serde_cbor_2::to_vec(&key)?);
958 let attestation = Cbor::Map(BTreeMap::from([
959 (Cbor::Text("fmt".into()), Cbor::Text("none".into())),
960 (Cbor::Text("attStmt".into()), Cbor::Map(BTreeMap::new())),
961 (Cbor::Text("authData".into()), Cbor::Bytes(auth_data)),
962 ]));
963 let response: RegisterPublicKeyCredential = serde_json::from_value(
964 serde_json::json!({
965 "id": URL_SAFE_NO_PAD.encode(credential_id), "rawId": URL_SAFE_NO_PAD.encode(credential_id),
966 "type": "public-key", "clientExtensionResults": {}, "response": {
967 "clientDataJSON": URL_SAFE_NO_PAD.encode(&client_data),
968 "attestationObject": URL_SAFE_NO_PAD.encode(serde_cbor_2::to_vec(&attestation)?),
969 "transports": ["internal"],
970 },
971 }),
972 )?;
973 let core_client_data = serde_json::to_vec(&serde_json::json!({
976 "type": "webauthn.create", "challenge": core_options.public_key.challenge,
977 "origin": config.origin, "crossOrigin": false,
978 }))?;
979 let mut core_response = response.clone();
980 core_response.response.client_data_json = core_client_data.into();
981 let restored_core = webauthn::finish_core_registration(
982 &core,
983 &core_response,
984 &old_core,
985 &config.origin,
986 )?;
987 assert_eq!(restored_core.cred_id().as_ref(), credential_id);
988 let stored_credential = serde_json::to_string(&restored_core)?;
989 let raw_none::StoredCredential::Core(decoded_credential) =
990 serde_json::from_str(&stored_credential)?
991 else {
992 panic!("historical typed credential must retain its codec")
993 };
994 assert_eq!(decoded_credential.cred_id(), restored_core.cred_id());
995 let result = webauthn.finish_passkey_registration(&response, &state);
996 if verified {
997 let legacy_key = result?;
998 assert_eq!(legacy_key.cred_id().as_ref(), credential_id);
999 complete_historical_authentication(&config, &secret, &key, legacy_key).await?;
1000 } else {
1001 assert!(matches!(
1002 result,
1003 Err(webauthn_rs_core::error::WebauthnError::UserNotVerified)
1004 ));
1005 }
1006 }
1007 Ok(())
1008 }
1009
1010 async fn complete_historical_authentication(
1011 config: &PasskeyConfig,
1012 secret: &p256::SecretKey,
1013 cose: &serde_cbor_2::Value,
1014 mut key: webauthn_rs::prelude::Passkey,
1015 ) -> Result<(), Box<dyn std::error::Error>> {
1016 use p256::pkcs8::EncodePrivateKey;
1017 use sha2::{Digest, Sha256};
1018 let (ctx, user, _) = test_helpers::create_test_context_with_user(
1019 CreateUser::new().with_email("legacy-auth@example.test"),
1020 Duration::hours(1),
1021 )
1022 .await;
1023 let plugin = PasskeyPlugin {
1024 config: config.clone(),
1025 };
1026 let webauthn = webauthn::build_webauthn(config, &ctx.config, &config.origin)?;
1027 let credential_id = URL_SAFE_NO_PAD.encode(key.cred_id().as_ref());
1028 let persisted = ctx
1029 .database
1030 .create_passkey(CreatePasskey {
1031 user_id: user.id.clone(),
1032 name: Some("Historical".into()),
1033 credential_id: credential_id.clone(),
1034 public_key: base64::engine::general_purpose::STANDARD
1035 .encode(serde_cbor_2::to_vec(cose)?),
1036 credential: serde_json::to_string(&key)?,
1037 counter: 0,
1038 device_type: "singleDevice".into(),
1039 backed_up: false,
1040 transports: None,
1041 aaguid: None,
1042 })
1043 .await?;
1044 let signing = openssl::pkey::PKey::private_key_from_der(secret.to_pkcs8_der()?.as_bytes())?;
1045 for (kind, counter, backup) in [
1046 ("passkey", 1_u32, 0x08_u8),
1047 ("discoverable", 2, 0x18),
1048 ("discoverable", 3, 0x08),
1049 ] {
1050 for verified in [false, true] {
1051 let (challenge, state) = if kind == "passkey" {
1052 let (options, state) = webauthn.start_passkey_authentication(&[key.clone()])?;
1053 (
1054 options.public_key.challenge,
1055 serde_json::json!({"kind":"passkey","state":state}),
1056 )
1057 } else {
1058 let (options, state) = webauthn.start_discoverable_authentication()?;
1059 (
1060 options.public_key.challenge,
1061 serde_json::json!({"kind":"discoverable","state":state}),
1062 )
1063 };
1064 let token = uuid::Uuid::new_v4().to_string();
1065 _ = ctx
1066 .verifications()
1067 .create(alibi_core::CreateVerification {
1068 identifier: token.clone(),
1069 value: serde_json::to_string(&state)?,
1070 expires_at: chrono::Utc::now() + Duration::minutes(5),
1071 })
1072 .await?;
1073 let cookie = webauthn::create_challenge_cookie(&ctx.config, 300, &token, config)?;
1074 let client = serde_json::to_vec(
1075 &serde_json::json!({"type":"webauthn.get","challenge":challenge,"origin":config.origin}),
1076 )?;
1077 let mut data = Sha256::digest(config.rp_id.as_bytes()).to_vec();
1078 data.push(backup | if verified { 0x05 } else { 0x01 });
1079 data.extend_from_slice(&counter.to_be_bytes());
1080 let mut signer =
1081 openssl::sign::Signer::new(openssl::hash::MessageDigest::sha256(), &signing)?;
1082 signer.update(&data)?;
1083 signer.update(&Sha256::digest(&client))?;
1084 let body = serde_json::json!({"response":{"id":credential_id,"rawId":credential_id,"type":"public-key","clientExtensionResults":{},"response":{"clientDataJSON":URL_SAFE_NO_PAD.encode(client),"authenticatorData":URL_SAFE_NO_PAD.encode(data),"signature":URL_SAFE_NO_PAD.encode(signer.sign_to_vec()?)}}});
1085 let mut request = test_helpers::create_auth_request(
1086 HttpMethod::Post,
1087 "/passkey/verify-authentication",
1088 None,
1089 Some(serde_json::to_vec(&body)?),
1090 HashMap::new(),
1091 );
1092 _ = request
1093 .headers
1094 .insert("cookie".into(), cookie.split(';').next().unwrap().into());
1095 _ = request
1096 .headers
1097 .insert("origin".into(), config.origin.clone());
1098 let before = ctx
1099 .database
1100 .get_passkey_by_id(&persisted.id)
1101 .await?
1102 .unwrap();
1103 let sessions = ctx.database.get_user_sessions(&user.id).await?.len();
1104 let result = plugin.handle_verify_authentication(&request, &ctx).await?;
1105 assert_eq!(
1106 result.status,
1107 if verified { 200 } else { 400 },
1108 "{kind}: {}",
1109 String::from_utf8_lossy(&result.body)
1110 );
1111 let after = ctx
1112 .database
1113 .get_passkey_by_id(&persisted.id)
1114 .await?
1115 .unwrap();
1116 if verified {
1117 assert_eq!(
1118 ctx.database.get_user_sessions(&user.id).await?.len(),
1119 sessions + 1
1120 );
1121 assert_eq!(after.counter, u64::from(counter));
1122 let opaque: serde_json::Value = serde_json::from_str(&after.credential)?;
1123 assert_eq!(
1124 opaque.get("cred").and_then(|c| c.get("backup_eligible")),
1125 Some(&serde_json::json!(true))
1126 );
1127 assert_eq!(
1128 opaque.get("cred").and_then(|c| c.get("backup_state")),
1129 Some(&serde_json::json!(backup == 0x18))
1130 );
1131 assert!(!after.backed_up);
1132 assert_eq!(after.device_type, "singleDevice");
1133 key = serde_json::from_str(&after.credential)?;
1134 assert_eq!(
1135 plugin
1136 .handle_verify_authentication(&request, &ctx)
1137 .await?
1138 .status,
1139 400
1140 );
1141 } else {
1142 assert_eq!(
1143 serde_json::to_value(&after)?,
1144 serde_json::to_value(&before)?
1145 );
1146 assert_eq!(
1147 ctx.database.get_user_sessions(&user.id).await?.len(),
1148 sessions
1149 );
1150 }
1151 assert!(ctx.verifications().find(&token).await?.is_none());
1152 }
1153 }
1154 Ok(())
1155 }
1156
1157 #[tokio::test]
1158 #[expect(
1159 clippy::panic_in_result_fn,
1160 reason = "Keep this ordered integration scenario and its assertions together; Result propagates setup failures"
1161 )]
1162 async fn raw_none_credential_sql_readback_keeps_original_key_and_hidden_codec()
1163 -> Result<(), Box<dyn std::error::Error>> {
1164 use serde_cbor_2::Value as Cbor;
1165 use sha2::{Digest, Sha256};
1166 use std::collections::BTreeMap;
1167 for (algorithm, curve) in [(-8, 8), (-7, 6)] {
1170 let signing = ed25519_dalek::SigningKey::from_bytes(&[9; 32]);
1171 let plugin = passkey_plugin();
1172 let (ctx, user, session) = test_helpers::create_test_context_with_user(
1173 CreateUser::new()
1174 .with_email("raw-codec-owner@fixture.test")
1175 .with_name("Raw codec owner"),
1176 Duration::hours(1),
1177 )
1178 .await;
1179 let request = test_helpers::create_auth_request(
1180 HttpMethod::Get,
1181 "/passkey/generate-register-options",
1182 Some(&session.token),
1183 None,
1184 HashMap::new(),
1185 );
1186 let options = plugin
1187 .handle_generate_register_options(&request, &ctx)
1188 .await?;
1189 let issued: serde_json::Value = serde_json::from_slice(&options.body)?;
1190 let key = serde_cbor_2::to_vec(&Cbor::Map(BTreeMap::from([
1191 (Cbor::Integer(1), Cbor::Integer(1)),
1192 (Cbor::Integer(3), Cbor::Integer(algorithm)),
1193 (Cbor::Integer(-1), Cbor::Integer(curve)),
1194 (
1195 Cbor::Integer(-2),
1196 Cbor::Bytes(signing.verifying_key().as_bytes().to_vec()),
1197 ),
1198 ])))?;
1199 let id = b"raw-none-actual-persisted-credential";
1200 let mut data = Sha256::digest(b"localhost").to_vec();
1201 data.push(0x41);
1202 data.extend_from_slice(&25_u32.to_be_bytes());
1203 data.extend_from_slice(&[0; 16]);
1204 data.extend_from_slice(&u16::try_from(id.len())?.to_be_bytes());
1205 data.extend_from_slice(id);
1206 data.extend_from_slice(&key);
1207 let attestation = serde_cbor_2::to_vec(&Cbor::Map(BTreeMap::from([
1208 (Cbor::Text("fmt".into()), Cbor::Text("none".into())),
1209 (Cbor::Text("attStmt".into()), Cbor::Map(BTreeMap::new())),
1210 (Cbor::Text("authData".into()), Cbor::Bytes(data)),
1211 ])))?;
1212 let proof = serde_json::json!({"id":URL_SAFE_NO_PAD.encode(id),"rawId":URL_SAFE_NO_PAD.encode(id),"type":"public-key","clientExtensionResults":{},"response":{
1213 "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"}))?),
1214 "attestationObject":URL_SAFE_NO_PAD.encode(attestation),"transports":["internal"],
1215 }});
1216 let mut request_2 = test_helpers::create_auth_request(
1217 HttpMethod::Post,
1218 "/passkey/verify-registration",
1219 Some(&session.token),
1220 Some(serde_json::to_vec(&serde_json::json!({"response":proof}))?),
1221 HashMap::new(),
1222 );
1223 let issued_cookie = cookie_header(&options)
1224 .split(';')
1225 .next()
1226 .ok_or("issued challenge cookie required")?;
1227 _ = write!(
1228 request_2
1229 .headers
1230 .get_mut("cookie")
1231 .ok_or("signed owner cookie required")?,
1232 "; {issued_cookie}"
1233 );
1234 let result = plugin.handle_verify_registration(&request_2, &ctx).await?;
1235 assert_eq!(result.status, 200);
1236 let wire: serde_json::Value = serde_json::from_slice(&result.body)?;
1237 assert!(wire.get("credential").is_none());
1238 let row = ctx
1239 .database
1240 .get_passkey_by_credential_id(&URL_SAFE_NO_PAD.encode(id))
1241 .await?
1242 .ok_or("actual credential row required")?;
1243 assert_eq!(row.user_id, user.id);
1244 assert_eq!(row.counter, 25);
1245 assert_eq!(
1246 row.public_key,
1247 base64::engine::general_purpose::STANDARD.encode(&key)
1248 );
1249 let raw_none::StoredCredential::Raw(raw) = serde_json::from_str(&row.credential)?
1250 else {
1251 panic!("actual raw codec required")
1252 };
1253 assert_eq!(raw.credential_id(), id);
1254 assert_eq!(raw.public_key(), key);
1255 assert_eq!(raw.snapshot()?.counter, 25);
1256 assert!(
1257 serde_json::from_str::<webauthn_rs::prelude::Passkey>(&row.credential).is_err()
1258 );
1259 if curve == 6 {
1260 use ed25519_dalek::Signer;
1261 let mut persisted = serde_json::to_value(&row)?;
1262 for control in [
1263 "first",
1264 "signature",
1265 "counter",
1266 "origin",
1267 "challenge",
1268 "rp",
1269 "presence",
1270 "backup",
1271 "last",
1272 ] {
1273 let successful = matches!(control, "first" | "last");
1274 let counter = if matches!(control, "first" | "counter") {
1275 26_u32
1276 } else {
1277 27_u32
1278 };
1279 let request = test_helpers::create_auth_request(
1280 HttpMethod::Get,
1281 "/passkey/generate-authenticate-options",
1282 None,
1283 None,
1284 HashMap::new(),
1285 );
1286 let options = plugin
1287 .handle_generate_authenticate_options(&request, &ctx)
1288 .await?;
1289 let issued: serde_json::Value = serde_json::from_slice(&options.body)?;
1290 let challenge = issued
1291 .get("challenge")
1292 .expect("issued authentication challenge");
1293 let client = serde_json::to_vec(
1294 &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" }}),
1295 )?;
1296 let mut data = Sha256::digest(if control == "rp" {
1297 b"foreign.test".as_slice()
1298 } else {
1299 b"localhost".as_slice()
1300 })
1301 .to_vec();
1302 data.push(match control {
1303 "presence" => 0,
1304 "backup" => 0x11,
1305 _ => 1,
1306 });
1307 data.extend_from_slice(&counter.to_be_bytes());
1308 let mut signed = data.clone();
1309 signed.extend_from_slice(&Sha256::digest(&client));
1310 let mut signature = signing.sign(&signed).to_bytes();
1311 if control == "signature" {
1312 signature[0] ^= 1;
1313 }
1314 let response = serde_json::json!({"id":URL_SAFE_NO_PAD.encode(id),"rawId":URL_SAFE_NO_PAD.encode(id),"type":"public-key","clientExtensionResults":{},"response":{
1315 "clientDataJSON":URL_SAFE_NO_PAD.encode(client),"authenticatorData":URL_SAFE_NO_PAD.encode(data),"signature":URL_SAFE_NO_PAD.encode(signature)
1316 }});
1317 let mut request = test_helpers::create_auth_request(
1318 HttpMethod::Post,
1319 "/passkey/verify-authentication",
1320 None,
1321 Some(serde_json::to_vec(
1322 &serde_json::json!({"response":response}),
1323 )?),
1324 HashMap::new(),
1325 );
1326 request.headers.insert(
1327 "cookie".into(),
1328 cookie_header(&options)
1329 .split(';')
1330 .next()
1331 .ok_or("issued authentication cookie required")?
1332 .into(),
1333 );
1334 let result = plugin.handle_verify_authentication(&request, &ctx).await?;
1335 assert_eq!(
1336 result.status,
1337 if successful {
1338 200
1339 } else if control == "signature" {
1340 401
1341 } else {
1342 400
1343 },
1344 "{control}"
1345 );
1346 let updated = ctx
1347 .database
1348 .get_passkey_by_credential_id(&URL_SAFE_NO_PAD.encode(id))
1349 .await?
1350 .ok_or("updated raw row required")?;
1351 if !successful {
1352 let wire: serde_json::Value = serde_json::from_slice(&result.body)?;
1353 assert_eq!(
1354 wire.get("code"),
1355 Some(&serde_json::json!("AUTHENTICATION_FAILED")),
1356 "{control}"
1357 );
1358 assert!(
1359 result
1360 .headers
1361 .get_all("set-cookie")
1362 .all(|cookie| !cookie.contains("session_token="))
1363 );
1364 assert_eq!(serde_json::to_value(&updated)?, persisted, "{control}");
1365 assert_eq!(ctx.database.get_user_sessions(&user.id).await?.len(), 2);
1366 continue;
1367 }
1368 persisted = serde_json::to_value(&updated)?;
1369 assert_eq!(updated.public_key, row.public_key);
1370 assert_eq!(updated.credential_id, row.credential_id);
1371 assert_eq!(updated.user_id, row.user_id);
1372 assert_eq!(updated.counter, u64::from(counter));
1373 let raw_none::StoredCredential::Raw(raw) =
1374 serde_json::from_str(&updated.credential)?
1375 else {
1376 panic!("authentication must retain raw codec")
1377 };
1378 assert_eq!(raw.public_key(), key);
1379 assert_eq!(raw.credential_id(), id);
1380 assert_eq!(raw.snapshot()?.counter, u64::from(counter));
1381 assert!(
1382 serde_json::from_str::<webauthn_rs::prelude::Passkey>(&updated.credential)
1383 .is_err()
1384 );
1385 }
1386 }
1387 assert_eq!(
1388 ctx.database.get_user_sessions(&user.id).await?.len(),
1389 if curve == 6 { 3 } else { 1 }
1390 );
1391 }
1392 Ok(())
1393 }
1394}
1395