fxa_client/internal/
mod.rs

1/* This Source Code Form is subject to the terms of the Mozilla Public
2 * License, v. 2.0. If a copy of the MPL was not distributed with this
3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4
5//! # Internal implementation details for the fxa_client crate.
6
7use self::{
8    config::Config,
9    oauth::{AuthCircuitBreaker, OAuthFlow, OAUTH_WEBCHANNEL_REDIRECT},
10    state_manager::StateManager,
11    state_persistence::PersistedState,
12    telemetry::FxaTelemetry,
13};
14use crate::{DeviceConfig, Error, FxaConfig, FxaRustAuthState, FxaState, Result};
15use serde_derive::*;
16use std::{
17    collections::{HashMap, HashSet},
18    sync::Arc,
19};
20use url::Url;
21
22// We once had an "integration_test" feature this module was gated on.
23// We still keep the code around for now, in-case it turns out to be useful.
24// pub mod auth;
25mod close_tabs;
26mod commands;
27pub mod config;
28pub mod device;
29mod http_client;
30mod oauth;
31mod profile;
32mod push;
33mod scoped_keys;
34mod scopes;
35mod send_tab;
36mod state_manager;
37mod state_persistence;
38mod telemetry;
39mod util;
40
41type FxAClient = dyn http_client::FxAClient + Sync + Send;
42
43// FIXME: https://github.com/myelin-ai/mockiato/issues/106.
44#[cfg(test)]
45unsafe impl Send for http_client::MockFxAClient {}
46#[cfg(test)]
47unsafe impl Sync for http_client::MockFxAClient {}
48
49// It this struct is modified, please check if the
50// `FirefoxAccount.start_over` function also needs
51// to be modified.
52pub struct FirefoxAccount {
53    client: Arc<FxAClient>,
54    state: StateManager,
55    attached_clients_cache: Option<CachedResponse<Vec<http_client::GetAttachedClientResponse>>>,
56    devices_cache: Option<CachedResponse<Vec<http_client::GetDeviceResponse>>>,
57    auth_circuit_breaker: AuthCircuitBreaker,
58    telemetry: FxaTelemetry,
59    // TODO: Cleanup our usage of the word "state" and change this field name to `state`
60    // https://bugzilla.mozilla.org/show_bug.cgi?id=1868610
61    pub(crate) auth_state: FxaState,
62    // Set via `FxaEvent::Initialize`
63    pub(crate) device_config: Option<DeviceConfig>,
64}
65
66impl FirefoxAccount {
67    fn from_state(state: PersistedState) -> Self {
68        Self {
69            client: Arc::new(http_client::Client::new()),
70            state: StateManager::new(state),
71            attached_clients_cache: None,
72            devices_cache: None,
73            auth_circuit_breaker: Default::default(),
74            telemetry: FxaTelemetry::new(),
75            auth_state: FxaState::Uninitialized,
76            device_config: None,
77        }
78    }
79
80    /// Create a new `FirefoxAccount` instance using a `Config`.
81    pub fn with_config(config: Config) -> Self {
82        Self::from_state(PersistedState {
83            config,
84            refresh_token: None,
85            scoped_keys: HashMap::new(),
86            last_handled_command: None,
87            commands_data: HashMap::new(),
88            device_capabilities: HashSet::new(),
89            server_local_device_info: None,
90            session_token: None,
91            current_device_id: None,
92            last_seen_profile: None,
93            access_token_cache: HashMap::new(),
94            logged_out_from_auth_issues: false,
95        })
96    }
97
98    /// Create a new `FirefoxAccount` instance.
99    pub fn new(config: FxaConfig) -> Self {
100        Self::with_config(config.into())
101    }
102
103    #[cfg(test)]
104    pub(crate) fn set_client(&mut self, client: Arc<FxAClient>) {
105        self.client = client;
106    }
107
108    /// Restore a `FirefoxAccount` instance from a serialized state
109    /// created using `to_json`.
110    pub fn from_json(data: &str) -> Result<Self> {
111        let state = state_persistence::state_from_json(data)?;
112        Ok(Self::from_state(state))
113    }
114
115    /// Serialize a `FirefoxAccount` instance internal state
116    /// to be restored later using `from_json`.
117    pub fn to_json(&self) -> Result<String> {
118        self.state.serialize_persisted_state()
119    }
120
121    /// Clear the attached clients and devices cache
122    pub fn clear_devices_and_attached_clients_cache(&mut self) {
123        self.attached_clients_cache = None;
124        self.devices_cache = None;
125    }
126
127    /// Get the Sync Token Server endpoint URL.
128    pub fn get_token_server_endpoint_url(&self) -> Result<String> {
129        Ok(self.state.config().token_server_endpoint_url()?.into())
130    }
131
132    /// Get the pairing URL to navigate to on the Auth side (typically
133    /// a computer).
134    pub fn get_pairing_authority_url(&self) -> Result<String> {
135        // Special case for the production server, we use the shorter firefox.com/pair URL.
136        if self.state.config().content_url()? == Url::parse(config::CONTENT_URL_RELEASE)? {
137            return Ok("https://firefox.com/pair".to_owned());
138        }
139        // Similarly special case for the China server.
140        if self.state.config().content_url()? == Url::parse(config::CONTENT_URL_CHINA)? {
141            return Ok("https://firefox.com.cn/pair".to_owned());
142        }
143        Ok(self.state.config().pair_url()?.into())
144    }
145
146    /// Get the "connection succeeded" page URL.
147    /// It is typically used to redirect the user after
148    /// having intercepted the OAuth login-flow state/code
149    /// redirection.
150    pub fn get_connection_success_url(&self) -> Result<String> {
151        let mut url = self.state.config().connect_another_device_url()?;
152        url.query_pairs_mut()
153            .append_pair("showSuccessMessage", "true");
154        Ok(url.into())
155    }
156
157    /// Get the "manage account" page URL.
158    /// It is typically used in the application's account status UI,
159    /// to link the user out to a webpage where they can manage
160    /// all the details of their account.
161    ///
162    /// * `entrypoint` - Application-provided string identifying the UI touchpoint
163    ///   through which the page was accessed, for metrics purposes.
164    pub fn get_manage_account_url(&mut self, entrypoint: &str) -> Result<String> {
165        let mut url = self.state.config().settings_url()?;
166        url.query_pairs_mut().append_pair("entrypoint", entrypoint);
167        if self.state.config().redirect_uri == OAUTH_WEBCHANNEL_REDIRECT {
168            url.query_pairs_mut()
169                .append_pair("context", "oauth_webchannel_v1");
170        }
171        self.add_account_identifiers_to_url(url)
172    }
173
174    /// Get the "manage devices" page URL.
175    /// It is typically used in the application's account status UI,
176    /// to link the user out to a webpage where they can manage
177    /// the devices connected to their account.
178    ///
179    /// * `entrypoint` - Application-provided string identifying the UI touchpoint
180    ///   through which the page was accessed, for metrics purposes.
181    pub fn get_manage_devices_url(&mut self, entrypoint: &str) -> Result<String> {
182        let mut url = self.state.config().settings_clients_url()?;
183        url.query_pairs_mut().append_pair("entrypoint", entrypoint);
184        self.add_account_identifiers_to_url(url)
185    }
186
187    fn add_account_identifiers_to_url(&mut self, mut url: Url) -> Result<String> {
188        let profile = self.get_profile(false)?;
189        url.query_pairs_mut()
190            .append_pair("uid", &profile.uid)
191            .append_pair("email", &profile.email);
192        Ok(url.into())
193    }
194
195    fn get_refresh_token(&self) -> Result<&str> {
196        match self.state.refresh_token() {
197            Some(token_info) => Ok(&token_info.token),
198            None => Err(Error::NoRefreshToken),
199        }
200    }
201
202    pub fn get_auth_state(&self) -> FxaRustAuthState {
203        self.state.get_auth_state()
204    }
205
206    /// Disconnect from the account and optionally destroy our device record. This will
207    /// leave the account object in a state where it can eventually reconnect to the same user.
208    /// This is a "best effort" infallible method: e.g. if the network is unreachable,
209    /// the device could still be in the FxA devices manager.
210    ///
211    /// **💾 This method alters the persisted account state.**
212    pub fn disconnect(&mut self) {
213        let current_device_result;
214        {
215            current_device_result = self.get_current_device();
216        }
217
218        if let Some(refresh_token) = self.state.refresh_token() {
219            // Delete the current device (which deletes the refresh token), or
220            // the refresh token directly if we don't have a device.
221            let destroy_result = match current_device_result {
222                // If we get an error trying to fetch our device record we'll at least
223                // still try to delete the refresh token itself.
224                Ok(Some(device)) => self.client.destroy_device_record(
225                    self.state.config(),
226                    &refresh_token.token,
227                    &device.id,
228                ),
229                _ => self
230                    .client
231                    .destroy_refresh_token(self.state.config(), &refresh_token.token),
232            };
233            if let Err(e) = destroy_result {
234                crate::warn!("Error while destroying the device: {}", e);
235            }
236        }
237        self.state.disconnect();
238        self.clear_devices_and_attached_clients_cache();
239        self.telemetry = FxaTelemetry::new();
240    }
241
242    /// Update the state based on authentication issues.
243    ///
244    /// **💾 This method alters the persisted account state.**
245    ///
246    /// Call this if you know there's an authentication / authorization issue that requires the
247    /// user to re-authenticated.  It transitions the user to the [FxaRustAuthState.AuthIssues] state.
248    pub fn on_auth_issues(&mut self) {
249        self.state.on_auth_issues();
250        self.clear_devices_and_attached_clients_cache();
251        self.telemetry = FxaTelemetry::new();
252    }
253
254    pub fn simulate_network_error(&mut self) {
255        self.client.simulate_network_error();
256    }
257
258    pub fn simulate_temporary_auth_token_issue(&mut self) {
259        self.state.simulate_temporary_auth_token_issue()
260    }
261
262    pub fn simulate_permanent_auth_token_issue(&mut self) {
263        self.state.simulate_permanent_auth_token_issue()
264    }
265}
266
267#[derive(Debug, Clone, Deserialize, Serialize)]
268pub(crate) struct CachedResponse<T> {
269    response: T,
270    cached_at: u64,
271    etag: String,
272}
273
274#[cfg(test)]
275mod tests {
276    use super::*;
277    use crate::internal::device::*;
278    use crate::internal::http_client::MockFxAClient;
279    use crate::internal::oauth::*;
280    use mockall::predicate::always;
281    use mockall::predicate::eq;
282
283    #[test]
284    fn test_fxa_is_send() {
285        fn is_send<T: Send>() {}
286        is_send::<FirefoxAccount>();
287    }
288
289    #[test]
290    fn test_serialize_deserialize() {
291        let config = Config::stable_dev("12345678", "https://foo.bar");
292        let fxa1 = FirefoxAccount::with_config(config);
293        let fxa1_json = fxa1.to_json().unwrap();
294        drop(fxa1);
295        let fxa2 = FirefoxAccount::from_json(&fxa1_json).unwrap();
296        let fxa2_json = fxa2.to_json().unwrap();
297        assert_eq!(fxa1_json, fxa2_json);
298    }
299
300    #[test]
301    fn test_get_connection_success_url() {
302        let config = Config::new("https://stable.dev.lcip.org", "12345678", "https://foo.bar");
303        let fxa = FirefoxAccount::with_config(config);
304        let url = fxa.get_connection_success_url().unwrap();
305        assert_eq!(
306            url,
307            "https://stable.dev.lcip.org/connect_another_device?showSuccessMessage=true"
308                .to_string()
309        );
310    }
311
312    #[test]
313    fn test_get_manage_account_url() {
314        let config = Config::new("https://stable.dev.lcip.org", "12345678", "https://foo.bar");
315        let mut fxa = FirefoxAccount::with_config(config);
316        // No current user -> Error.
317        match fxa.get_manage_account_url("test").unwrap_err() {
318            Error::NoCachedToken(_) => {}
319            _ => panic!("error not NoCachedToken"),
320        };
321        // With current user -> expected Url.
322        fxa.add_cached_profile("123", "test@example.com");
323        let url = fxa.get_manage_account_url("test").unwrap();
324        assert_eq!(
325            url,
326            "https://stable.dev.lcip.org/settings?entrypoint=test&uid=123&email=test%40example.com"
327                .to_string()
328        );
329    }
330
331    #[test]
332    fn test_get_manage_account_url_with_webchannel_redirect() {
333        let config = Config::new(
334            "https://stable.dev.lcip.org",
335            "12345678",
336            OAUTH_WEBCHANNEL_REDIRECT,
337        );
338        let mut fxa = FirefoxAccount::with_config(config);
339        fxa.add_cached_profile("123", "test@example.com");
340        let url = fxa.get_manage_account_url("test").unwrap();
341        assert_eq!(
342            url,
343            "https://stable.dev.lcip.org/settings?entrypoint=test&context=oauth_webchannel_v1&uid=123&email=test%40example.com"
344                .to_string()
345        );
346    }
347
348    #[test]
349    fn test_get_manage_devices_url() {
350        let config = Config::new("https://stable.dev.lcip.org", "12345678", "https://foo.bar");
351        let mut fxa = FirefoxAccount::with_config(config);
352        // No current user -> Error.
353        match fxa.get_manage_devices_url("test").unwrap_err() {
354            Error::NoCachedToken(_) => {}
355            _ => panic!("error not NoCachedToken"),
356        };
357        // With current user -> expected Url.
358        fxa.add_cached_profile("123", "test@example.com");
359        let url = fxa.get_manage_devices_url("test").unwrap();
360        assert_eq!(
361            url,
362            "https://stable.dev.lcip.org/settings/clients?entrypoint=test&uid=123&email=test%40example.com"
363                .to_string()
364        );
365    }
366
367    #[test]
368    fn test_disconnect_no_refresh_token() {
369        let config = Config::new("https://stable.dev.lcip.org", "12345678", "https://foo.bar");
370        let mut fxa = FirefoxAccount::with_config(config);
371
372        fxa.add_cached_token(
373            "profile",
374            AccessTokenInfo {
375                scope: "profile".to_string(),
376                token: "profiletok".to_string(),
377                key: None,
378                expires_at: u64::MAX,
379            },
380        );
381
382        let client = MockFxAClient::new();
383        fxa.set_client(Arc::new(client));
384
385        assert!(!fxa.state.is_access_token_cache_empty());
386        fxa.disconnect();
387        assert!(fxa.state.is_access_token_cache_empty());
388    }
389
390    #[test]
391    fn test_disconnect_device() {
392        let config = Config::stable_dev("12345678", "https://foo.bar");
393        let mut fxa = FirefoxAccount::with_config(config);
394
395        fxa.state.force_refresh_token(RefreshToken {
396            token: "refreshtok".to_string(),
397            scopes: HashSet::default(),
398        });
399
400        let mut client = MockFxAClient::new();
401        client
402            .expect_get_devices()
403            .with(always(), eq("refreshtok"))
404            .times(1)
405            .returning(|_, _| {
406                Ok(vec![
407                    Device {
408                        common: http_client::DeviceResponseCommon {
409                            id: "1234a".to_owned(),
410                            display_name: "My Device".to_owned(),
411                            device_type: sync15::DeviceType::Mobile,
412                            push_subscription: None,
413                            available_commands: HashMap::default(),
414                            push_endpoint_expired: false,
415                        },
416                        is_current_device: true,
417                        location: http_client::DeviceLocation {
418                            city: None,
419                            country: None,
420                            state: None,
421                            state_code: None,
422                        },
423                        last_access_time: None,
424                    },
425                    Device {
426                        common: http_client::DeviceResponseCommon {
427                            id: "a4321".to_owned(),
428                            display_name: "My Other Device".to_owned(),
429                            device_type: sync15::DeviceType::Desktop,
430                            push_subscription: None,
431                            available_commands: HashMap::default(),
432                            push_endpoint_expired: false,
433                        },
434                        is_current_device: false,
435                        location: http_client::DeviceLocation {
436                            city: None,
437                            country: None,
438                            state: None,
439                            state_code: None,
440                        },
441                        last_access_time: None,
442                    },
443                ])
444            });
445        client
446            .expect_destroy_device_record()
447            .with(always(), eq("refreshtok"), eq("1234a"))
448            .times(1)
449            .returning(|_, _, _| Ok(()));
450        fxa.set_client(Arc::new(client));
451
452        assert!(fxa.state.refresh_token().is_some());
453        fxa.disconnect();
454        assert!(fxa.state.refresh_token().is_none());
455    }
456
457    #[test]
458    fn test_disconnect_no_device() {
459        let config = Config::stable_dev("12345678", "https://foo.bar");
460        let mut fxa = FirefoxAccount::with_config(config);
461
462        fxa.state.force_refresh_token(RefreshToken {
463            token: "refreshtok".to_string(),
464            scopes: HashSet::default(),
465        });
466
467        let mut client = MockFxAClient::new();
468        client
469            .expect_get_devices()
470            .with(always(), eq("refreshtok"))
471            .times(1)
472            .returning(|_, _| {
473                Ok(vec![Device {
474                    common: http_client::DeviceResponseCommon {
475                        id: "a4321".to_owned(),
476                        display_name: "My Other Device".to_owned(),
477                        device_type: sync15::DeviceType::Desktop,
478                        push_subscription: None,
479                        available_commands: HashMap::default(),
480                        push_endpoint_expired: false,
481                    },
482                    is_current_device: false,
483                    location: http_client::DeviceLocation {
484                        city: None,
485                        country: None,
486                        state: None,
487                        state_code: None,
488                    },
489                    last_access_time: None,
490                }])
491            });
492        client
493            .expect_destroy_refresh_token()
494            .with(always(), eq("refreshtok"))
495            .times(1)
496            .returning(|_, _| Ok(()));
497        fxa.set_client(Arc::new(client));
498
499        assert!(fxa.state.refresh_token().is_some());
500        fxa.disconnect();
501        assert!(fxa.state.refresh_token().is_none());
502    }
503
504    #[test]
505    fn test_disconnect_network_errors() {
506        let config = Config::stable_dev("12345678", "https://foo.bar");
507        let mut fxa = FirefoxAccount::with_config(config);
508
509        fxa.state.force_refresh_token(RefreshToken {
510            token: "refreshtok".to_string(),
511            scopes: HashSet::default(),
512        });
513
514        let mut client = MockFxAClient::new();
515        client
516            .expect_get_devices()
517            .with(always(), eq("refreshtok"))
518            .times(1)
519            .returning(|_, _| Ok(vec![]));
520        client
521            .expect_destroy_refresh_token()
522            .with(always(), eq("refreshtok"))
523            .times(1)
524            .returning(|_, _| {
525                Err(Error::RemoteError {
526                    code: 500,
527                    errno: 101,
528                    error: "Did not work!".to_owned(),
529                    message: "Did not work!".to_owned(),
530                    info: "Did not work!".to_owned(),
531                })
532            });
533        fxa.set_client(Arc::new(client));
534
535        assert!(fxa.state.refresh_token().is_some());
536        fxa.disconnect();
537        assert!(fxa.state.refresh_token().is_none());
538    }
539
540    #[test]
541    fn test_on_auth_issues() {
542        let config = Config::new("https://stable.dev.lcip.org", "12345678", "https://foo.bar");
543        let mut fxa = FirefoxAccount::with_config(config);
544
545        fxa.state.force_refresh_token(RefreshToken {
546            token: "refresh_token".to_owned(),
547            scopes: HashSet::new(),
548        });
549        fxa.state.force_current_device_id("original-device-id");
550        assert_eq!(fxa.get_auth_state(), FxaRustAuthState::Connected);
551
552        fxa.on_auth_issues();
553        assert_eq!(fxa.get_auth_state(), FxaRustAuthState::AuthIssues);
554
555        fxa.state.complete_oauth_flow(
556            vec![],
557            RefreshToken {
558                token: "refreshtok".to_owned(),
559                scopes: HashSet::default(),
560            },
561            None,
562        );
563        assert_eq!(fxa.get_auth_state(), FxaRustAuthState::Connected);
564
565        // The device ID should be the same as before `on_auth_issues` was called.  This
566        // way, methods like `ensure_capabilities` and `set_device_name`, can re-use it and we
567        // won't try to create a new device record.
568        assert_eq!(fxa.state.current_device_id(), Some("original-device-id"));
569    }
570
571    #[test]
572    fn test_get_auth_state() {
573        let config = Config::new("https://stable.dev.lcip.org", "12345678", "https://foo.bar");
574        let mut fxa = FirefoxAccount::with_config(config);
575
576        fn assert_auth_state(fxa: &FirefoxAccount, correct_state: FxaRustAuthState) {
577            assert_eq!(fxa.get_auth_state(), correct_state);
578
579            let persisted = FirefoxAccount::from_json(&fxa.to_json().unwrap()).unwrap();
580            assert_eq!(persisted.get_auth_state(), correct_state);
581        }
582
583        // The state starts as disconnected
584        assert_auth_state(&fxa, FxaRustAuthState::Disconnected);
585
586        // When we get the refresh tokens the state changes to connected
587        fxa.state.force_refresh_token(RefreshToken {
588            token: "refresh_token".to_owned(),
589            scopes: HashSet::new(),
590        });
591        assert_auth_state(&fxa, FxaRustAuthState::Connected);
592
593        fxa.disconnect();
594        assert_auth_state(&fxa, FxaRustAuthState::Disconnected);
595
596        fxa.disconnect();
597        assert_auth_state(&fxa, FxaRustAuthState::Disconnected);
598    }
599
600    #[test]
601    fn test_get_pairing_authority_url() {
602        let config = Config::new("https://foo.bar", "12345678", "https://foo.bar");
603        let fxa = FirefoxAccount::with_config(config);
604        assert_eq!(
605            fxa.get_pairing_authority_url().unwrap().as_str(),
606            "https://foo.bar/pair"
607        );
608
609        let config = Config::release("12345678", "https://foo.bar");
610        let fxa = FirefoxAccount::with_config(config);
611        assert_eq!(
612            fxa.get_pairing_authority_url().unwrap().as_str(),
613            "https://firefox.com/pair"
614        );
615
616        let config = Config::china("12345678", "https://foo.bar");
617        let fxa = FirefoxAccount::with_config(config);
618        assert_eq!(
619            fxa.get_pairing_authority_url().unwrap().as_str(),
620            "https://firefox.com.cn/pair"
621        )
622    }
623}