Skip to main content

alibi_plugins/passkey/
mod.rs

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