Skip to main content

alibi_api/plugins/passkey/
mod.rs

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
36/// Passkey / `WebAuthn` authentication plugin.
37///
38/// Generates WebAuthn-compatible registration and authentication options,
39/// stores challenge state via the auth store, and manages passkey CRUD.
40pub 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    /// Per-format PEM roots, matching the verifier SettingsService.
58    /// Unspecified formats retain published defaults.
59    #[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
67// -- Plugin --
68
69impl PasskeyPlugin {
70    // -- Handlers (delegate to core functions) --
71
72    /// GET /passkey/generate-register-options
73    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    /// POST /passkey/verify-registration
134    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    /// GET /passkey/generate-authenticate-options
232    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    /// POST /passkey/verify-authentication
244    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    /// GET /passkey/list-user-passkeys
273    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    /// POST /passkey/delete-passkey
284    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    /// POST /passkey/update-passkey
303    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// LCOV_EXCL_START
352#[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        // The published SQL adapter returns nullable columns as own properties.
721        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    /// Previously persisted ceremonies keep both their codec and original Required policy.
841    #[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        // This is the old externally persisted protocol, with a real library state.
870        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            // Feed the historical Core wire's genuinely issued challenge to its
942            // actual consumer. This old policy accepts UV absent as well as present.
943            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        // The raw storage owner also protects the usable mismatched-key codec.
1141        // SDK observations cannot read the Native-only hidden credential column.
1142        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// LCOV_EXCL_STOP