Skip to main content

bitwarden_importers/
wasm.rs

1//! WASM bindings for the direct 1Password import.
2//!
3//! The import calls back into the user mid sign-in: for a TOTP code, or for the single sign-on
4//! browser step and device approval. `wasm_bindgen` cannot express these as the `&dyn` callbacks
5//! the native API takes. JavaScript passes objects implementing the interfaces
6//! declared below instead, and the `Js*` types adapt them back to the traits.
7
8use async_trait::async_trait;
9use bitwarden_threading::ThreadBoundRunner;
10use wasm_bindgen::prelude::*;
11
12use crate::{
13    OnePasswordSsoEnrollment, OnePasswordSsoEnrollmentResult, OnePasswordSsoEnrollmentStatus,
14    OnePasswordSsoLoginResult, OnePasswordSsoUi, OnePasswordTotpResult, OnePasswordTwoFactorUi,
15    OnePasswordVerificationCodeResult,
16};
17
18#[wasm_bindgen(typescript_custom_section)]
19const TS_CUSTOM_TYPES: &'static str = r#"
20/**
21 * Asks the user for a 1Password two-step verification code during a direct import.
22 */
23export interface OnePasswordTwoFactorUi {
24    /**
25     * Provides a TOTP passcode for the given zero-based attempt. A wrong code restarts the
26     * sign-in, so `attempt` grows as the user retries.
27     *
28     * @returns the passcode, or undefined if the user declined to provide one.
29     */
30    provideTotp(attempt: number): Promise<string | undefined>;
31}
32
33/**
34 * Takes the user through a 1Password single sign-on login during a direct import.
35 */
36export interface OnePasswordSsoUi {
37    /**
38     * Opens `ssoLoginUrl`, usually in a browser, and waits for the identity provider to redirect
39     * to `redirectTo`.
40     *
41     * @returns the full URL the identity provider redirected to, or undefined if the user gave up.
42     */
43    performSsoLogin(ssoLoginUrl: string, redirectTo: string): Promise<string | undefined>;
44
45    /**
46     * Shows the device enrollment UI. Called once per import: every import is a new device.
47     */
48    beginSsoEnrollment(): Promise<OnePasswordSsoEnrollment>;
49}
50
51/**
52 * A device enrollment in progress. `endEnrollment` is called exactly once when it ends, unless the
53 * import is dropped halfway.
54 */
55export interface OnePasswordSsoEnrollment {
56    /**
57     * Resolves when the user cancels the enrollment; a rejection counts as a cancellation. Keep it
58     * pending while the enrollment runs, and settle it once `endEnrollment` is called so the
59     * object can be released.
60     */
61    cancelled(): Promise<void>;
62
63    /** Shows the stage the enrollment has reached. */
64    updateStatus(status: OnePasswordSsoEnrollmentStatus, message: string): Promise<void>;
65
66    /**
67     * Asks for the verification code the approving device shows, for the given zero-based
68     * attempt. From the second attempt on, the previous code was wrong and the approving device
69     * shows a new one. The user gets three attempts.
70     *
71     * @returns the code, or undefined if the user declined to provide one.
72     */
73    provideVerificationCode(attempt: number): Promise<string | undefined>;
74
75    /** Closes the enrollment UI. */
76    endEnrollment(result: OnePasswordSsoEnrollmentResult): Promise<void>;
77}
78"#;
79
80#[bitwarden_ffi::wasm_import]
81extern "C" {
82    /// The JavaScript object implementing the two-factor prompt.
83    #[wasm_bindgen(js_name = OnePasswordTwoFactorUi, typescript_type = "OnePasswordTwoFactorUi")]
84    pub type RawJsOnePasswordTwoFactorUi;
85
86    #[wasm_bindgen(catch, method, structural, js_name = provideTotp)]
87    async fn provide_totp(
88        this: &RawJsOnePasswordTwoFactorUi,
89        attempt: u32,
90    ) -> Result<JsValue, JsValue>;
91}
92
93/// Adapts the JavaScript prompt to [`OnePasswordTwoFactorUi`].
94///
95/// The JS object is `!Send`, so it stays pinned to its own thread behind a [`ThreadBoundRunner`];
96/// only the runner's channel handle crosses threads, which is what makes this `Send + Sync`.
97pub(crate) struct JsOnePasswordTwoFactorUi(ThreadBoundRunner<RawJsOnePasswordTwoFactorUi>);
98
99impl JsOnePasswordTwoFactorUi {
100    pub(crate) fn new(ui: RawJsOnePasswordTwoFactorUi) -> Self {
101        Self(ThreadBoundRunner::new(ui))
102    }
103}
104
105#[async_trait::async_trait]
106impl OnePasswordTwoFactorUi for JsOnePasswordTwoFactorUi {
107    async fn provide_totp(&self, attempt: u32) -> OnePasswordTotpResult {
108        let code = self
109            .0
110            .run_in_thread(move |ui| async move {
111                ui.provide_totp(attempt)
112                    .await
113                    .ok()
114                    .and_then(|code| code.as_string())
115            })
116            .await;
117
118        // A value that isn't a string, a rejected promise, and a runner whose thread went away all
119        // leave the sign-in without a code, which is what cancelling it means.
120        match code {
121            Ok(Some(code)) => OnePasswordTotpResult::Code(code),
122            Ok(None) | Err(_) => OnePasswordTotpResult::Cancel,
123        }
124    }
125}
126
127#[bitwarden_ffi::wasm_import]
128extern "C" {
129    /// The JavaScript object implementing the single sign-on prompts.
130    #[wasm_bindgen(js_name = OnePasswordSsoUi, typescript_type = "OnePasswordSsoUi")]
131    pub type RawJsOnePasswordSsoUi;
132
133    #[wasm_bindgen(catch, method, structural, js_name = performSsoLogin)]
134    async fn perform_sso_login(
135        this: &RawJsOnePasswordSsoUi,
136        sso_login_url: &str,
137        redirect_to: &str,
138    ) -> Result<JsValue, JsValue>;
139
140    #[wasm_bindgen(catch, method, structural, js_name = beginSsoEnrollment)]
141    async fn begin_sso_enrollment(
142        this: &RawJsOnePasswordSsoUi,
143    ) -> Result<RawJsOnePasswordSsoEnrollment, JsValue>;
144
145    /// The JavaScript object implementing a device enrollment in progress.
146    #[wasm_bindgen(js_name = OnePasswordSsoEnrollment, typescript_type = "OnePasswordSsoEnrollment")]
147    type RawJsOnePasswordSsoEnrollment;
148
149    #[wasm_bindgen(catch, method, structural, js_name = cancelled)]
150    async fn cancelled(this: &RawJsOnePasswordSsoEnrollment) -> Result<JsValue, JsValue>;
151
152    #[wasm_bindgen(catch, method, structural, js_name = updateStatus)]
153    async fn update_status(
154        this: &RawJsOnePasswordSsoEnrollment,
155        status: OnePasswordSsoEnrollmentStatus,
156        message: &str,
157    ) -> Result<JsValue, JsValue>;
158
159    #[wasm_bindgen(catch, method, structural, js_name = provideVerificationCode)]
160    async fn provide_verification_code(
161        this: &RawJsOnePasswordSsoEnrollment,
162        attempt: u32,
163    ) -> Result<JsValue, JsValue>;
164
165    #[wasm_bindgen(catch, method, structural, js_name = endEnrollment)]
166    async fn end_enrollment(
167        this: &RawJsOnePasswordSsoEnrollment,
168        result: OnePasswordSsoEnrollmentResult,
169    ) -> Result<JsValue, JsValue>;
170}
171
172/// Adapts the JavaScript single sign-on prompts to [`OnePasswordSsoUi`], pinned to their thread
173/// like [`JsOnePasswordTwoFactorUi`].
174pub(crate) struct JsOnePasswordSsoUi(ThreadBoundRunner<RawJsOnePasswordSsoUi>);
175
176impl JsOnePasswordSsoUi {
177    pub(crate) fn new(ui: RawJsOnePasswordSsoUi) -> Self {
178        Self(ThreadBoundRunner::new(ui))
179    }
180}
181
182#[async_trait]
183impl OnePasswordSsoUi for JsOnePasswordSsoUi {
184    async fn perform_sso_login(
185        &self,
186        sso_login_url: &str,
187        redirect_to: &str,
188    ) -> OnePasswordSsoLoginResult {
189        let sso_login_url = sso_login_url.to_owned();
190        let redirect_to = redirect_to.to_owned();
191        let redirected_to = self
192            .0
193            .run_in_thread(move |ui| async move {
194                ui.perform_sso_login(&sso_login_url, &redirect_to)
195                    .await
196                    .ok()
197                    .and_then(|url| url.as_string())
198            })
199            .await;
200
201        match redirected_to {
202            Ok(Some(url)) => OnePasswordSsoLoginResult::RedirectedTo(url),
203            Ok(None) | Err(_) => OnePasswordSsoLoginResult::Cancel,
204        }
205    }
206
207    async fn begin_sso_enrollment(&self) -> Box<dyn OnePasswordSsoEnrollment> {
208        // The enrollment object is `!Send` too, so its runner is created on the JS thread and only
209        // the runner comes back.
210        let enrollment = self
211            .0
212            .run_in_thread(|ui| async move {
213                let enrollment = ui.begin_sso_enrollment().await.ok()?;
214                Some(ThreadBoundRunner::new(enrollment))
215            })
216            .await;
217
218        match enrollment {
219            Ok(Some(runner)) => Box::new(JsOnePasswordSsoEnrollment(runner)),
220            Ok(None) | Err(_) => Box::new(CancelledEnrollment),
221        }
222    }
223}
224
225/// Adapts the JavaScript enrollment object to [`OnePasswordSsoEnrollment`].
226struct JsOnePasswordSsoEnrollment(ThreadBoundRunner<RawJsOnePasswordSsoEnrollment>);
227
228#[async_trait]
229impl OnePasswordSsoEnrollment for JsOnePasswordSsoEnrollment {
230    async fn cancelled(&self) {
231        // Failing closed: a rejection or a dead runner cancels the enrollment like the user would.
232        let _ = self
233            .0
234            .run_in_thread(|enrollment| async move {
235                let _ = enrollment.cancelled().await;
236            })
237            .await;
238    }
239
240    async fn update_status(&self, status: OnePasswordSsoEnrollmentStatus, message: &str) {
241        let message = message.to_owned();
242        let _ = self
243            .0
244            .run_in_thread(move |enrollment| async move {
245                let _ = enrollment.update_status(status, &message).await;
246            })
247            .await;
248    }
249
250    async fn provide_verification_code(&self, attempt: u32) -> OnePasswordVerificationCodeResult {
251        let code = self
252            .0
253            .run_in_thread(move |enrollment| async move {
254                enrollment
255                    .provide_verification_code(attempt)
256                    .await
257                    .ok()
258                    .and_then(|code| code.as_string())
259            })
260            .await;
261
262        match code {
263            Ok(Some(code)) => OnePasswordVerificationCodeResult::Code(code),
264            Ok(None) | Err(_) => OnePasswordVerificationCodeResult::Cancel,
265        }
266    }
267
268    async fn end_enrollment(&self, result: OnePasswordSsoEnrollmentResult) {
269        let _ = self
270            .0
271            .run_in_thread(move |enrollment| async move {
272                let _ = enrollment.end_enrollment(result).await;
273            })
274            .await;
275    }
276}
277
278/// The enrollment of a UI that could not show one, which cancels it right away.
279struct CancelledEnrollment;
280
281#[async_trait]
282impl OnePasswordSsoEnrollment for CancelledEnrollment {
283    async fn cancelled(&self) {}
284
285    async fn update_status(&self, _status: OnePasswordSsoEnrollmentStatus, _message: &str) {}
286
287    async fn provide_verification_code(&self, _attempt: u32) -> OnePasswordVerificationCodeResult {
288        OnePasswordVerificationCodeResult::Cancel
289    }
290
291    async fn end_enrollment(&self, _result: OnePasswordSsoEnrollmentResult) {}
292}