fxa_client/internal/
push.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
5use std::convert::TryInto;
6
7use super::FirefoxAccount;
8use crate::{info, AccountEvent, Error, Result};
9use serde_derive::Deserialize;
10
11impl FirefoxAccount {
12    /// Handles a push message and returns a single [`AccountEvent`]
13    ///
14    /// This API is useful for when the app would like to get the AccountEvent associated
15    /// with the push message, but would **not** like to retrieve missed commands while doing so.
16    ///
17    /// **💾 This method alters the persisted account state.**
18    ///
19    /// **⚠️ This API does not increment the command index if a command was received**
20    pub fn handle_push_message(&mut self, payload: &str) -> Result<AccountEvent> {
21        let payload = serde_json::from_str(payload).or_else(|err| {
22            let v: serde_json::Value = serde_json::from_str(payload)?;
23            match v.get("command") {
24                Some(_) => Ok(PushPayload::Unknown),
25                None => Err(err),
26            }
27        })?;
28        match payload {
29            PushPayload::CommandReceived(CommandReceivedPushPayload { index, .. }) => {
30                let cmd = self.get_command_for_index(index)?;
31                Ok(AccountEvent::CommandReceived {
32                    command: cmd.try_into()?,
33                })
34            }
35            PushPayload::ProfileUpdated => {
36                self.state.clear_last_seen_profile();
37                Ok(AccountEvent::ProfileUpdated)
38            }
39            PushPayload::DeviceConnected(DeviceConnectedPushPayload { device_name }) => {
40                self.clear_devices_and_attached_clients_cache();
41                Ok(AccountEvent::DeviceConnected { device_name })
42            }
43            PushPayload::DeviceDisconnected(DeviceDisconnectedPushPayload { device_id }) => {
44                let local_device = self.get_current_device_id();
45                let is_local_device = match local_device {
46                    Err(_) => false,
47                    Ok(id) => id == device_id,
48                };
49                if is_local_device {
50                    // Note: self.disconnect calls self.start_over which clears the state for the FirefoxAccount instance
51                    self.disconnect();
52                }
53                Ok(AccountEvent::DeviceDisconnected {
54                    device_id,
55                    is_local_device,
56                })
57            }
58            PushPayload::AccountDestroyed(AccountDestroyedPushPayload { account_uid }) => {
59                let is_local_account = match self.state.last_seen_profile() {
60                    None => false,
61                    Some(profile) => profile.response.uid == account_uid,
62                };
63                Ok(if is_local_account {
64                    AccountEvent::AccountDestroyed
65                } else {
66                    return Err(Error::InvalidPushEvent);
67                })
68            }
69            PushPayload::PasswordChanged | PushPayload::PasswordReset => {
70                let status = self.check_authorization_status()?;
71                // clear any device or client data due to password change.
72                self.clear_devices_and_attached_clients_cache();
73                Ok(if !status.active {
74                    AccountEvent::AccountAuthStateChanged
75                } else {
76                    info!("Password change event, but no action required");
77                    AccountEvent::Unknown
78                })
79            }
80            PushPayload::Unknown => {
81                info!("Unknown Push command.");
82                Ok(AccountEvent::Unknown)
83            }
84        }
85    }
86}
87
88#[derive(Debug, Deserialize)]
89#[serde(tag = "command", content = "data")]
90pub enum PushPayload {
91    #[serde(rename = "fxaccounts:command_received")]
92    CommandReceived(CommandReceivedPushPayload),
93    #[serde(rename = "fxaccounts:profile_updated")]
94    ProfileUpdated,
95    #[serde(rename = "fxaccounts:device_connected")]
96    DeviceConnected(DeviceConnectedPushPayload),
97    #[serde(rename = "fxaccounts:device_disconnected")]
98    DeviceDisconnected(DeviceDisconnectedPushPayload),
99    #[serde(rename = "fxaccounts:password_changed")]
100    PasswordChanged,
101    #[serde(rename = "fxaccounts:password_reset")]
102    PasswordReset,
103    #[serde(rename = "fxaccounts:account_destroyed")]
104    AccountDestroyed(AccountDestroyedPushPayload),
105    #[serde(other)]
106    Unknown,
107}
108
109// Some of this structs fields are not read, except
110// when deserialized, we mark them as dead_code
111#[allow(dead_code)]
112#[derive(Debug, Deserialize)]
113pub struct CommandReceivedPushPayload {
114    command: String,
115    index: u64,
116    sender: String,
117    url: String,
118}
119
120#[derive(Debug, Deserialize)]
121pub struct DeviceConnectedPushPayload {
122    #[serde(rename = "deviceName")]
123    device_name: String,
124}
125
126#[derive(Debug, Deserialize)]
127pub struct DeviceDisconnectedPushPayload {
128    #[serde(rename = "id")]
129    device_id: String,
130}
131
132#[derive(Debug, Deserialize)]
133pub struct AccountDestroyedPushPayload {
134    #[serde(rename = "uid")]
135    account_uid: String,
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141    use crate::internal::http_client::IntrospectResponse;
142    use crate::internal::http_client::MockFxAClient;
143    use crate::internal::oauth::RefreshToken;
144    use crate::internal::CachedResponse;
145    use crate::internal::Config;
146    use mockall::predicate::always;
147    use mockall::predicate::eq;
148    use std::sync::Arc;
149
150    #[test]
151    fn test_deserialize_send_tab_command() {
152        let json = "{\"version\":1,\"command\":\"fxaccounts:command_received\",\"data\":{\"command\":\"send-tab-recv\",\"index\":1,\"sender\":\"bobo\",\"url\":\"https://mozilla.org\"}}";
153        let _: PushPayload = serde_json::from_str(json).unwrap();
154    }
155
156    #[test]
157    fn test_push_profile_updated() {
158        let mut fxa = FirefoxAccount::with_config(crate::internal::Config::stable_dev(
159            "12345678",
160            "https://foo.bar",
161        ));
162        fxa.add_cached_profile("123", "test@example.com");
163        let json = "{\"version\":1,\"command\":\"fxaccounts:profile_updated\"}";
164        let event = fxa.handle_push_message(json).unwrap();
165        assert!(fxa.state.last_seen_profile().is_none());
166        assert!(matches!(event, AccountEvent::ProfileUpdated));
167    }
168
169    #[test]
170    fn test_push_device_disconnected_local() {
171        let mut fxa =
172            FirefoxAccount::with_config(Config::stable_dev("12345678", "https://foo.bar"));
173        let refresh_token_scopes = std::collections::HashSet::new();
174        fxa.state
175            .force_refresh_token(crate::internal::oauth::RefreshToken {
176                token: "refresh_token".to_owned(),
177                scopes: refresh_token_scopes,
178            });
179        fxa.state.force_current_device_id("my_id");
180        let json = "{\"version\":1,\"command\":\"fxaccounts:device_disconnected\",\"data\":{\"id\":\"my_id\"}}";
181        let event = fxa.handle_push_message(json).unwrap();
182        assert!(fxa.state.refresh_token().is_none());
183        match event {
184            AccountEvent::DeviceDisconnected {
185                device_id,
186                is_local_device,
187            } => {
188                assert!(is_local_device);
189                assert_eq!(device_id, "my_id");
190            }
191            _ => unreachable!(),
192        };
193    }
194
195    #[test]
196    fn test_push_password_reset() {
197        let mut fxa =
198            FirefoxAccount::with_config(Config::stable_dev("12345678", "https://foo.bar"));
199        let mut client = MockFxAClient::new();
200        client
201            .expect_check_refresh_token_status()
202            .with(always(), eq("refresh_token"))
203            .times(1)
204            .returning(|_, _| Ok(IntrospectResponse { active: false }));
205        fxa.set_client(Arc::new(client));
206        let refresh_token_scopes = std::collections::HashSet::new();
207        fxa.state.force_refresh_token(RefreshToken {
208            token: "refresh_token".to_owned(),
209            scopes: refresh_token_scopes,
210        });
211        fxa.state.force_current_device_id("my_id");
212        fxa.devices_cache = Some(CachedResponse {
213            response: vec![],
214            cached_at: 0,
215            etag: "".to_string(),
216        });
217        let json = "{\"version\":1,\"command\":\"fxaccounts:password_reset\"}";
218        assert!(fxa.devices_cache.is_some());
219        let event = fxa.handle_push_message(json).unwrap();
220        assert!(matches!(event, AccountEvent::AccountAuthStateChanged));
221        assert!(fxa.devices_cache.is_none());
222    }
223
224    #[test]
225    fn test_push_password_change() {
226        let mut fxa =
227            FirefoxAccount::with_config(Config::stable_dev("12345678", "https://foo.bar"));
228        let mut client = MockFxAClient::new();
229        client
230            .expect_check_refresh_token_status()
231            .with(always(), eq("refresh_token"))
232            .times(1)
233            .returning(|_, _| Ok(IntrospectResponse { active: true }));
234        fxa.set_client(Arc::new(client));
235        let refresh_token_scopes = std::collections::HashSet::new();
236        fxa.state.force_refresh_token(RefreshToken {
237            token: "refresh_token".to_owned(),
238            scopes: refresh_token_scopes,
239        });
240        fxa.state.force_current_device_id("my_id");
241        fxa.devices_cache = Some(CachedResponse {
242            response: vec![],
243            cached_at: 0,
244            etag: "".to_string(),
245        });
246        let json = "{\"version\":1,\"command\":\"fxaccounts:password_changed\"}";
247        assert!(fxa.devices_cache.is_some());
248        let event = fxa.handle_push_message(json).unwrap();
249        assert!(matches!(event, AccountEvent::Unknown));
250        assert!(fxa.devices_cache.is_none());
251    }
252    #[test]
253    fn test_push_device_disconnected_remote() {
254        let mut fxa = FirefoxAccount::with_config(crate::internal::Config::stable_dev(
255            "12345678",
256            "https://foo.bar",
257        ));
258        let json = "{\"version\":1,\"command\":\"fxaccounts:device_disconnected\",\"data\":{\"id\":\"remote_id\"}}";
259        let event = fxa.handle_push_message(json).unwrap();
260        match event {
261            AccountEvent::DeviceDisconnected {
262                device_id,
263                is_local_device,
264            } => {
265                assert!(!is_local_device);
266                assert_eq!(device_id, "remote_id");
267            }
268            _ => unreachable!(),
269        };
270    }
271
272    #[test]
273    fn test_handle_push_message_ignores_unknown_command() {
274        let mut fxa =
275            FirefoxAccount::with_config(Config::stable_dev("12345678", "https://foo.bar"));
276        let json = "{\"version\":1,\"command\":\"huh\"}";
277        let event = fxa.handle_push_message(json).unwrap();
278        assert!(matches!(event, AccountEvent::Unknown));
279    }
280
281    #[test]
282    fn test_handle_push_message_ignores_unknown_command_with_data() {
283        let mut fxa =
284            FirefoxAccount::with_config(Config::stable_dev("12345678", "https://foo.bar"));
285        let json = "{\"version\":1,\"command\":\"huh\",\"data\":{\"value\":42}}";
286        let event = fxa.handle_push_message(json).unwrap();
287        assert!(matches!(event, AccountEvent::Unknown));
288    }
289
290    #[test]
291    fn test_handle_push_message_errors_on_garbage_data() {
292        let mut fxa =
293            FirefoxAccount::with_config(Config::stable_dev("12345678", "https://foo.bar"));
294        let json = "{\"wtf\":\"bbq\"}";
295        fxa.handle_push_message(json).unwrap_err();
296    }
297}