Skip to main content

alibi_plugins/passkey/
mod.rs

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