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        viaduct_dev::use_dev_backend();
172        let mut fxa =
173            FirefoxAccount::with_config(Config::stable_dev("12345678", "https://foo.bar"));
174        let refresh_token_scopes = std::collections::HashSet::new();
175        fxa.state
176            .force_refresh_token(crate::internal::oauth::RefreshToken {
177                token: "refresh_token".to_owned(),
178                scopes: refresh_token_scopes,
179            });
180        fxa.state.force_current_device_id("my_id");
181        let json = "{\"version\":1,\"command\":\"fxaccounts:device_disconnected\",\"data\":{\"id\":\"my_id\"}}";
182        let event = fxa.handle_push_message(json).unwrap();
183        assert!(fxa.state.refresh_token().is_none());
184        match event {
185            AccountEvent::DeviceDisconnected {
186                device_id,
187                is_local_device,
188            } => {
189                assert!(is_local_device);
190                assert_eq!(device_id, "my_id");
191            }
192            _ => unreachable!(),
193        };
194    }
195
196    #[test]
197    fn test_push_password_reset() {
198        let mut fxa =
199            FirefoxAccount::with_config(Config::stable_dev("12345678", "https://foo.bar"));
200        let mut client = MockFxAClient::new();
201        client
202            .expect_check_refresh_token_status()
203            .with(always(), eq("refresh_token"))
204            .times(1)
205            .returning(|_, _| Ok(IntrospectResponse { active: false }));
206        fxa.set_client(Arc::new(client));
207        let refresh_token_scopes = std::collections::HashSet::new();
208        fxa.state.force_refresh_token(RefreshToken {
209            token: "refresh_token".to_owned(),
210            scopes: refresh_token_scopes,
211        });
212        fxa.state.force_current_device_id("my_id");
213        fxa.devices_cache = Some(CachedResponse {
214            response: vec![],
215            cached_at: 0,
216            etag: "".to_string(),
217        });
218        let json = "{\"version\":1,\"command\":\"fxaccounts:password_reset\"}";
219        assert!(fxa.devices_cache.is_some());
220        let event = fxa.handle_push_message(json).unwrap();
221        assert!(matches!(event, AccountEvent::AccountAuthStateChanged));
222        assert!(fxa.devices_cache.is_none());
223    }
224
225    #[test]
226    fn test_push_password_change() {
227        let mut fxa =
228            FirefoxAccount::with_config(Config::stable_dev("12345678", "https://foo.bar"));
229        let mut client = MockFxAClient::new();
230        client
231            .expect_check_refresh_token_status()
232            .with(always(), eq("refresh_token"))
233            .times(1)
234            .returning(|_, _| Ok(IntrospectResponse { active: true }));
235        fxa.set_client(Arc::new(client));
236        let refresh_token_scopes = std::collections::HashSet::new();
237        fxa.state.force_refresh_token(RefreshToken {
238            token: "refresh_token".to_owned(),
239            scopes: refresh_token_scopes,
240        });
241        fxa.state.force_current_device_id("my_id");
242        fxa.devices_cache = Some(CachedResponse {
243            response: vec![],
244            cached_at: 0,
245            etag: "".to_string(),
246        });
247        let json = "{\"version\":1,\"command\":\"fxaccounts:password_changed\"}";
248        assert!(fxa.devices_cache.is_some());
249        let event = fxa.handle_push_message(json).unwrap();
250        assert!(matches!(event, AccountEvent::Unknown));
251        assert!(fxa.devices_cache.is_none());
252    }
253    #[test]
254    fn test_push_device_disconnected_remote() {
255        let mut fxa = FirefoxAccount::with_config(crate::internal::Config::stable_dev(
256            "12345678",
257            "https://foo.bar",
258        ));
259        let json = "{\"version\":1,\"command\":\"fxaccounts:device_disconnected\",\"data\":{\"id\":\"remote_id\"}}";
260        let event = fxa.handle_push_message(json).unwrap();
261        match event {
262            AccountEvent::DeviceDisconnected {
263                device_id,
264                is_local_device,
265            } => {
266                assert!(!is_local_device);
267                assert_eq!(device_id, "remote_id");
268            }
269            _ => unreachable!(),
270        };
271    }
272
273    #[test]
274    fn test_handle_push_message_ignores_unknown_command() {
275        let mut fxa =
276            FirefoxAccount::with_config(Config::stable_dev("12345678", "https://foo.bar"));
277        let json = "{\"version\":1,\"command\":\"huh\"}";
278        let event = fxa.handle_push_message(json).unwrap();
279        assert!(matches!(event, AccountEvent::Unknown));
280    }
281
282    #[test]
283    fn test_handle_push_message_ignores_unknown_command_with_data() {
284        let mut fxa =
285            FirefoxAccount::with_config(Config::stable_dev("12345678", "https://foo.bar"));
286        let json = "{\"version\":1,\"command\":\"huh\",\"data\":{\"value\":42}}";
287        let event = fxa.handle_push_message(json).unwrap();
288        assert!(matches!(event, AccountEvent::Unknown));
289    }
290
291    #[test]
292    fn test_handle_push_message_errors_on_garbage_data() {
293        let mut fxa =
294            FirefoxAccount::with_config(Config::stable_dev("12345678", "https://foo.bar"));
295        let json = "{\"wtf\":\"bbq\"}";
296        fxa.handle_push_message(json).unwrap_err();
297    }
298}