Skip to main content

glass/browser/session/
webauthn.rs

1//! Virtual WebAuthn authenticator support via CDP.
2//!
3//! Enables automated testing of WebAuthn / passkey flows by creating
4//! a virtual authenticator through the CDP `WebAuthn` domain. Use
5//! [`BrowserSession::enable_webauthn`] to start and obtain a
6//! [`WebAuthnGuard`] for credential management.
7
8use super::*;
9
10/// Options for a virtual WebAuthn authenticator.
11#[derive(Debug, Clone, serde::Serialize)]
12pub struct WebAuthnOptions {
13    /// Protocol version: `"ctap2"` (default) or `"u2f"`.
14    pub protocol: String,
15    /// Transport type: `"internal"`, `"usb"`, `"nfc"`, or `"ble"`.
16    pub transport: String,
17    /// Whether the authenticator supports resident keys (discoverable credentials).
18    #[serde(default)]
19    pub has_resident_key: bool,
20    /// Whether the authenticator supports user verification.
21    #[serde(default)]
22    pub has_user_verification: bool,
23    /// Whether this is a user-verifying platform authenticator.
24    #[serde(default)]
25    pub is_user_verifying_platform_authenticator: bool,
26}
27
28impl Default for WebAuthnOptions {
29    fn default() -> Self {
30        Self {
31            protocol: "ctap2".into(),
32            transport: "internal".into(),
33            has_resident_key: false,
34            has_user_verification: false,
35            is_user_verifying_platform_authenticator: false,
36        }
37    }
38}
39
40/// Scoped guard managing a virtual WebAuthn authenticator.
41///
42/// Created by [`BrowserSession::enable_webauthn`]. While this guard is
43/// alive, a virtual authenticator is registered in the browser. Use
44/// [`add_credential`](Self::add_credential) to provision credentials
45/// for testing, and [`disable`](Self::disable) to remove it explicitly.
46///
47/// On drop, the virtual authenticator is removed and the WebAuthn
48/// domain is disabled (best-effort via a spawned task).
49pub struct WebAuthnGuard {
50    cdp: CdpClient,
51    authenticator_id: String,
52    armed: bool,
53}
54
55impl WebAuthnGuard {
56    /// Enable the WebAuthn domain and create a virtual authenticator.
57    pub(crate) async fn start(cdp: CdpClient, options: &WebAuthnOptions) -> BrowserResult<Self> {
58        cdp.send("WebAuthn.enable", None).await?;
59        let result = cdp.send("WebAuthn.addVirtualAuthenticator", Some(serde_json::json!({
60            "options": {
61                "protocol": options.protocol,
62                "transport": options.transport,
63                "hasResidentKey": options.has_resident_key,
64                "hasUserVerification": options.has_user_verification,
65                "isUserVerifyingPlatformAuthenticator": options.is_user_verifying_platform_authenticator,
66            }
67        }))).await?;
68        let authenticator_id = result["authenticatorId"]
69            .as_str()
70            .ok_or("WebAuthn.addVirtualAuthenticator returned no authenticatorId")?
71            .to_string();
72        Ok(Self {
73            cdp,
74            authenticator_id,
75            armed: true,
76        })
77    }
78
79    /// Return the CDP authenticator ID for this virtual authenticator.
80    pub fn authenticator_id(&self) -> &str {
81        &self.authenticator_id
82    }
83
84    /// Add a resident credential to the virtual authenticator.
85    ///
86    /// `credential_id` is an arbitrary identifier for the credential.
87    /// `rp_id` is the relying party ID (typically the domain).
88    /// `user_handle` is the user identifier associated with the credential.
89    /// `private_key_pem` must be a PEM-encoded private key.
90    /// `sign_count` is the initial signature counter value.
91    pub async fn add_credential(
92        &self,
93        credential_id: &str,
94        rp_id: &str,
95        user_handle: &str,
96        private_key_pem: &str,
97        sign_count: u32,
98    ) -> BrowserResult<()> {
99        self.cdp
100            .send(
101                "WebAuthn.addCredential",
102                Some(serde_json::json!({
103                    "authenticatorId": self.authenticator_id,
104                    "credential": {
105                        "credentialId": credential_id,
106                        "rpId": rp_id,
107                        "privateKey": private_key_pem,
108                        "signCount": sign_count,
109                        "isResidentCredential": true,
110                        "userHandle": user_handle,
111                    }
112                })),
113            )
114            .await?;
115        Ok(())
116    }
117
118    /// Remove the virtual authenticator and disable the WebAuthn domain.
119    ///
120    /// After calling this, no further credential operations are possible.
121    /// This is called automatically on drop, but explicit calls let you
122    /// handle errors synchronously.
123    pub async fn disable(mut self) -> BrowserResult<()> {
124        self.armed = false;
125        let _ = self
126            .cdp
127            .send(
128                "WebAuthn.removeVirtualAuthenticator",
129                Some(serde_json::json!({"authenticatorId": self.authenticator_id})),
130            )
131            .await;
132        let _ = self.cdp.send("WebAuthn.disable", None).await;
133        Ok(())
134    }
135}
136
137impl Drop for WebAuthnGuard {
138    fn drop(&mut self) {
139        if self.armed {
140            let cdp = self.cdp.clone();
141            let auth_id = self.authenticator_id.clone();
142            tokio::spawn(async move {
143                let _ = cdp
144                    .send(
145                        "WebAuthn.removeVirtualAuthenticator",
146                        Some(serde_json::json!({"authenticatorId": auth_id})),
147                    )
148                    .await;
149                let _ = cdp.send("WebAuthn.disable", None).await;
150            });
151        }
152    }
153}
154
155impl BrowserSession {
156    /// Enable a virtual WebAuthn authenticator for the session.
157    ///
158    /// Returns a `WebAuthnGuard` scoped to this session. Use
159    /// `WebAuthnGuard::add_credential` to provision credentials before
160    /// navigating to the page under test.
161    pub async fn enable_webauthn(&self, options: &WebAuthnOptions) -> BrowserResult<WebAuthnGuard> {
162        self.cdp
163            .with_current_route(async { WebAuthnGuard::start(self.cdp.clone(), options).await })
164            .await
165    }
166}
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171
172    #[test]
173    fn webauthn_options_default_uses_ctap2_and_internal_transport() {
174        let opts = WebAuthnOptions::default();
175        assert_eq!(opts.protocol, "ctap2");
176        assert_eq!(opts.transport, "internal");
177    }
178
179    #[test]
180    fn webauthn_options_default_has_all_booleans_false() {
181        let opts = WebAuthnOptions::default();
182        assert!(!opts.has_resident_key);
183        assert!(!opts.has_user_verification);
184        assert!(!opts.is_user_verifying_platform_authenticator);
185    }
186
187    #[test]
188    fn webauthn_options_serializes_to_json() {
189        let opts = WebAuthnOptions::default();
190        let json = serde_json::to_value(&opts).unwrap();
191        assert_eq!(json["protocol"], "ctap2");
192        assert_eq!(json["transport"], "internal");
193        assert!(!json["has_resident_key"].as_bool().unwrap());
194        assert!(!json["has_user_verification"].as_bool().unwrap());
195    }
196
197    #[test]
198    fn webauthn_options_custom_serializes_all_fields() {
199        let opts = WebAuthnOptions {
200            protocol: "u2f".to_string(),
201            transport: "usb".to_string(),
202            has_resident_key: true,
203            has_user_verification: true,
204            is_user_verifying_platform_authenticator: true,
205        };
206        let json = serde_json::to_value(&opts).unwrap();
207        assert_eq!(json["protocol"], "u2f");
208        assert_eq!(json["transport"], "usb");
209        assert!(json["has_resident_key"].as_bool().unwrap());
210        assert!(json["has_user_verification"].as_bool().unwrap());
211        assert!(
212            json["is_user_verifying_platform_authenticator"]
213                .as_bool()
214                .unwrap()
215        );
216    }
217
218    #[test]
219    fn webauthn_options_with_false_booleans_still_serializes_them() {
220        let opts = WebAuthnOptions {
221            protocol: "ctap2".to_string(),
222            transport: "nfc".to_string(),
223            has_resident_key: false,
224            has_user_verification: false,
225            is_user_verifying_platform_authenticator: false,
226        };
227        let json = serde_json::to_value(&opts).unwrap();
228        // All booleans should appear in JSON because #[serde(default)] does not
229        // skip them — it only provides default when deserializing.
230        assert!(!json["has_resident_key"].as_bool().unwrap());
231        assert!(!json["has_user_verification"].as_bool().unwrap());
232        assert!(
233            !json["is_user_verifying_platform_authenticator"]
234                .as_bool()
235                .unwrap()
236        );
237    }
238}