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