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