summaryrefslogtreecommitdiffstats
path: root/third_party/rust/fxa-client/src/lib.rs
diff options
context:
space:
mode:
Diffstat (limited to 'third_party/rust/fxa-client/src/lib.rs')
-rw-r--r--third_party/rust/fxa-client/src/lib.rs599
1 files changed, 599 insertions, 0 deletions
diff --git a/third_party/rust/fxa-client/src/lib.rs b/third_party/rust/fxa-client/src/lib.rs
new file mode 100644
index 0000000000..6ec8d3006a
--- /dev/null
+++ b/third_party/rust/fxa-client/src/lib.rs
@@ -0,0 +1,599 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#![allow(unknown_lints)]
+#![warn(rust_2018_idioms)]
+
+use crate::{
+ commands::send_tab::SendTabPayload,
+ device::Device,
+ oauth::{AuthCircuitBreaker, OAuthFlow, OAUTH_WEBCHANNEL_REDIRECT},
+ scoped_keys::ScopedKey,
+ state_persistence::State,
+};
+pub use crate::{
+ config::Config,
+ error::*,
+ oauth::{AccessTokenInfo, IntrospectInfo, RefreshToken},
+ profile::Profile,
+ telemetry::FxaTelemetry,
+};
+use serde_derive::*;
+use std::{
+ cell::RefCell,
+ collections::{HashMap, HashSet},
+ sync::Arc,
+};
+use url::Url;
+
+#[cfg(feature = "integration_test")]
+pub mod auth;
+mod commands;
+mod config;
+pub mod device;
+pub mod error;
+pub mod ffi;
+mod http_client;
+pub mod migrator;
+// Include the `msg_types` module, which is generated from msg_types.proto.
+pub mod msg_types {
+ include!("mozilla.appservices.fxaclient.protobuf.rs");
+}
+mod oauth;
+mod profile;
+mod push;
+mod scoped_keys;
+pub mod scopes;
+pub mod send_tab;
+mod state_persistence;
+mod telemetry;
+mod util;
+
+type FxAClient = dyn http_client::FxAClient + Sync + Send;
+
+// FIXME: https://github.com/myelin-ai/mockiato/issues/106.
+#[cfg(test)]
+unsafe impl<'a> Send for http_client::FxAClientMock<'a> {}
+#[cfg(test)]
+unsafe impl<'a> Sync for http_client::FxAClientMock<'a> {}
+
+// It this struct is modified, please check if the
+// `FirefoxAccount.start_over` function also needs
+// to be modified.
+pub struct FirefoxAccount {
+ client: Arc<FxAClient>,
+ state: State,
+ flow_store: HashMap<String, OAuthFlow>,
+ attached_clients_cache: Option<CachedResponse<Vec<http_client::GetAttachedClientResponse>>>,
+ devices_cache: Option<CachedResponse<Vec<http_client::GetDeviceResponse>>>,
+ auth_circuit_breaker: AuthCircuitBreaker,
+ // 'telemetry' is only currently used by `&mut self` functions, but that's
+ // not something we want to insist on going forward, so RefCell<> it.
+ telemetry: RefCell<FxaTelemetry>,
+}
+
+impl FirefoxAccount {
+ fn from_state(state: State) -> Self {
+ Self {
+ client: Arc::new(http_client::Client::new()),
+ state,
+ flow_store: HashMap::new(),
+ attached_clients_cache: None,
+ devices_cache: None,
+ auth_circuit_breaker: Default::default(),
+ telemetry: RefCell::new(FxaTelemetry::new()),
+ }
+ }
+
+ /// Create a new `FirefoxAccount` instance using a `Config`.
+ ///
+ /// **💾 This method alters the persisted account state.**
+ pub fn with_config(config: Config) -> Self {
+ Self::from_state(State {
+ config,
+ refresh_token: None,
+ scoped_keys: HashMap::new(),
+ last_handled_command: None,
+ commands_data: HashMap::new(),
+ device_capabilities: HashSet::new(),
+ session_token: None,
+ current_device_id: None,
+ last_seen_profile: None,
+ access_token_cache: HashMap::new(),
+ in_flight_migration: None,
+ ecosystem_user_id: None,
+ })
+ }
+
+ /// Create a new `FirefoxAccount` instance.
+ ///
+ /// * `content_url` - The Firefox Account content server URL.
+ /// * `client_id` - The OAuth `client_id`.
+ /// * `redirect_uri` - The OAuth `redirect_uri`.
+ /// * `token_server_url_override` - Override the Token Server URL provided
+ /// by the FxA's autoconfig endpoint.
+ ///
+ /// **💾 This method alters the persisted account state.**
+ pub fn new(
+ content_url: &str,
+ client_id: &str,
+ redirect_uri: &str,
+ token_server_url_override: Option<&str>,
+ ) -> Self {
+ let mut config = Config::new(content_url, client_id, redirect_uri);
+ if let Some(token_server_url_override) = token_server_url_override {
+ config.override_token_server_url(token_server_url_override);
+ }
+ Self::with_config(config)
+ }
+
+ #[cfg(test)]
+ #[allow(dead_code)] // FIXME
+ pub(crate) fn set_client(&mut self, client: Arc<FxAClient>) {
+ self.client = client;
+ }
+
+ /// Restore a `FirefoxAccount` instance from a serialized state
+ /// created using `to_json`.
+ pub fn from_json(data: &str) -> Result<Self> {
+ let state = state_persistence::state_from_json(data)?;
+ Ok(Self::from_state(state))
+ }
+
+ /// Serialize a `FirefoxAccount` instance internal state
+ /// to be restored later using `from_json`.
+ pub fn to_json(&self) -> Result<String> {
+ state_persistence::state_to_json(&self.state)
+ }
+
+ /// Clear the attached clients and devices cache
+ pub fn clear_devices_and_attached_clients_cache(&mut self) {
+ self.attached_clients_cache = None;
+ self.devices_cache = None;
+ }
+
+ /// Clear the whole persisted/cached state of the account, but keep just
+ /// enough information to eventually reconnect to the same user account later.
+ pub fn start_over(&mut self) {
+ self.state = self.state.start_over();
+ self.flow_store.clear();
+ self.clear_devices_and_attached_clients_cache();
+ self.telemetry.replace(FxaTelemetry::new());
+ }
+
+ /// Get the Sync Token Server endpoint URL.
+ pub fn get_token_server_endpoint_url(&self) -> Result<Url> {
+ self.state.config.token_server_endpoint_url()
+ }
+
+ /// Get the pairing URL to navigate to on the Auth side (typically
+ /// a computer).
+ pub fn get_pairing_authority_url(&self) -> Result<Url> {
+ // Special case for the production server, we use the shorter firefox.com/pair URL.
+ if self.state.config.content_url()? == Url::parse(config::CONTENT_URL_RELEASE)? {
+ return Ok(Url::parse("https://firefox.com/pair")?);
+ }
+ // Similarly special case for the China server.
+ if self.state.config.content_url()? == Url::parse(config::CONTENT_URL_CHINA)? {
+ return Ok(Url::parse("https://firefox.com.cn/pair")?);
+ }
+ Ok(self.state.config.pair_url()?)
+ }
+
+ /// Get the "connection succeeded" page URL.
+ /// It is typically used to redirect the user after
+ /// having intercepted the OAuth login-flow state/code
+ /// redirection.
+ pub fn get_connection_success_url(&self) -> Result<Url> {
+ let mut url = self.state.config.connect_another_device_url()?;
+ url.query_pairs_mut()
+ .append_pair("showSuccessMessage", "true");
+ Ok(url)
+ }
+
+ /// Get the "manage account" page URL.
+ /// It is typically used in the application's account status UI,
+ /// to link the user out to a webpage where they can manage
+ /// all the details of their account.
+ ///
+ /// * `entrypoint` - Application-provided string identifying the UI touchpoint
+ /// through which the page was accessed, for metrics purposes.
+ pub fn get_manage_account_url(&mut self, entrypoint: &str) -> Result<Url> {
+ let mut url = self.state.config.settings_url()?;
+ url.query_pairs_mut().append_pair("entrypoint", entrypoint);
+ if self.state.config.redirect_uri == OAUTH_WEBCHANNEL_REDIRECT {
+ url.query_pairs_mut()
+ .append_pair("context", "oauth_webchannel_v1");
+ }
+ self.add_account_identifiers_to_url(url)
+ }
+
+ /// Get the "manage devices" page URL.
+ /// It is typically used in the application's account status UI,
+ /// to link the user out to a webpage where they can manage
+ /// the devices connected to their account.
+ ///
+ /// * `entrypoint` - Application-provided string identifying the UI touchpoint
+ /// through which the page was accessed, for metrics purposes.
+ pub fn get_manage_devices_url(&mut self, entrypoint: &str) -> Result<Url> {
+ let mut url = self.state.config.settings_clients_url()?;
+ url.query_pairs_mut().append_pair("entrypoint", entrypoint);
+ self.add_account_identifiers_to_url(url)
+ }
+
+ fn add_account_identifiers_to_url(&mut self, mut url: Url) -> Result<Url> {
+ let profile = self.get_profile(false)?;
+ url.query_pairs_mut()
+ .append_pair("uid", &profile.uid)
+ .append_pair("email", &profile.email);
+ Ok(url)
+ }
+
+ fn get_refresh_token(&self) -> Result<&str> {
+ match self.state.refresh_token {
+ Some(ref token_info) => Ok(&token_info.token),
+ None => Err(ErrorKind::NoRefreshToken.into()),
+ }
+ }
+
+ /// Disconnect from the account and optionally destroy our device record. This will
+ /// leave the account object in a state where it can eventually reconnect to the same user.
+ /// This is a "best effort" infallible method: e.g. if the network is unreachable,
+ /// the device could still be in the FxA devices manager.
+ ///
+ /// **💾 This method alters the persisted account state.**
+ pub fn disconnect(&mut self) {
+ let current_device_result;
+ {
+ current_device_result = self.get_current_device();
+ }
+
+ if let Some(ref refresh_token) = self.state.refresh_token {
+ // Delete the current device (which deletes the refresh token), or
+ // the refresh token directly if we don't have a device.
+ let destroy_result = match current_device_result {
+ // If we get an error trying to fetch our device record we'll at least
+ // still try to delete the refresh token itself.
+ Ok(Some(device)) => {
+ self.client
+ .destroy_device(&self.state.config, &refresh_token.token, &device.id)
+ }
+ _ => self
+ .client
+ .destroy_refresh_token(&self.state.config, &refresh_token.token),
+ };
+ if let Err(e) = destroy_result {
+ log::warn!("Error while destroying the device: {}", e);
+ }
+ }
+ self.start_over();
+ }
+}
+
+#[derive(Debug, Serialize)]
+#[serde(tag = "eventType", content = "data")]
+#[serde(rename_all = "camelCase")]
+pub enum AccountEvent {
+ IncomingDeviceCommand(Box<IncomingDeviceCommand>),
+ ProfileUpdated,
+ AccountAuthStateChanged,
+ AccountDestroyed,
+ // Can be removed when https://github.com/serde-rs/serde/pull/1695 lands.
+ #[serde(rename_all = "camelCase")]
+ DeviceConnected {
+ device_name: String,
+ },
+ #[serde(rename_all = "camelCase")]
+ DeviceDisconnected {
+ device_id: String,
+ is_local_device: bool,
+ },
+}
+
+#[derive(Debug, Serialize)]
+#[serde(tag = "commandType", content = "data")]
+#[serde(rename_all = "camelCase")]
+pub enum IncomingDeviceCommand {
+ TabReceived {
+ sender: Option<Device>,
+ payload: SendTabPayload,
+ },
+}
+
+#[derive(Debug, Clone, Deserialize, Serialize)]
+pub(crate) struct CachedResponse<T> {
+ response: T,
+ cached_at: u64,
+ etag: String,
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use http_client::FxAClientMock;
+
+ #[test]
+ fn test_fxa_is_send() {
+ fn is_send<T: Send>() {}
+ is_send::<FirefoxAccount>();
+ }
+
+ #[test]
+ fn test_serialize_deserialize() {
+ let config = Config::stable_dev("12345678", "https://foo.bar");
+ let fxa1 = FirefoxAccount::with_config(config);
+ let fxa1_json = fxa1.to_json().unwrap();
+ drop(fxa1);
+ let fxa2 = FirefoxAccount::from_json(&fxa1_json).unwrap();
+ let fxa2_json = fxa2.to_json().unwrap();
+ assert_eq!(fxa1_json, fxa2_json);
+ }
+
+ #[test]
+ fn test_get_connection_success_url() {
+ let config = Config::new("https://stable.dev.lcip.org", "12345678", "https://foo.bar");
+ let fxa = FirefoxAccount::with_config(config);
+ let url = fxa.get_connection_success_url().unwrap().to_string();
+ assert_eq!(
+ url,
+ "https://stable.dev.lcip.org/connect_another_device?showSuccessMessage=true"
+ .to_string()
+ );
+ }
+
+ #[test]
+ fn test_get_manage_account_url() {
+ let config = Config::new("https://stable.dev.lcip.org", "12345678", "https://foo.bar");
+ let mut fxa = FirefoxAccount::with_config(config);
+ // No current user -> Error.
+ match fxa.get_manage_account_url("test").unwrap_err().kind() {
+ ErrorKind::NoCachedToken(_) => {}
+ _ => panic!("error not NoCachedToken"),
+ };
+ // With current user -> expected Url.
+ fxa.add_cached_profile("123", "test@example.com");
+ let url = fxa.get_manage_account_url("test").unwrap().to_string();
+ assert_eq!(
+ url,
+ "https://stable.dev.lcip.org/settings?entrypoint=test&uid=123&email=test%40example.com"
+ .to_string()
+ );
+ }
+
+ #[test]
+ fn test_get_manage_account_url_with_webchannel_redirect() {
+ let config = Config::new(
+ "https://stable.dev.lcip.org",
+ "12345678",
+ OAUTH_WEBCHANNEL_REDIRECT,
+ );
+ let mut fxa = FirefoxAccount::with_config(config);
+ fxa.add_cached_profile("123", "test@example.com");
+ let url = fxa.get_manage_account_url("test").unwrap().to_string();
+ assert_eq!(
+ url,
+ "https://stable.dev.lcip.org/settings?entrypoint=test&context=oauth_webchannel_v1&uid=123&email=test%40example.com"
+ .to_string()
+ );
+ }
+
+ #[test]
+ fn test_get_manage_devices_url() {
+ let config = Config::new("https://stable.dev.lcip.org", "12345678", "https://foo.bar");
+ let mut fxa = FirefoxAccount::with_config(config);
+ // No current user -> Error.
+ match fxa.get_manage_devices_url("test").unwrap_err().kind() {
+ ErrorKind::NoCachedToken(_) => {}
+ _ => panic!("error not NoCachedToken"),
+ };
+ // With current user -> expected Url.
+ fxa.add_cached_profile("123", "test@example.com");
+ let url = fxa.get_manage_devices_url("test").unwrap().to_string();
+ assert_eq!(
+ url,
+ "https://stable.dev.lcip.org/settings/clients?entrypoint=test&uid=123&email=test%40example.com"
+ .to_string()
+ );
+ }
+
+ #[test]
+ fn test_disconnect_no_refresh_token() {
+ let config = Config::new("https://stable.dev.lcip.org", "12345678", "https://foo.bar");
+ let mut fxa = FirefoxAccount::with_config(config);
+
+ fxa.add_cached_token(
+ "profile",
+ AccessTokenInfo {
+ scope: "profile".to_string(),
+ token: "profiletok".to_string(),
+ key: None,
+ expires_at: u64::max_value(),
+ },
+ );
+
+ let client = FxAClientMock::new();
+ fxa.set_client(Arc::new(client));
+
+ assert!(!fxa.state.access_token_cache.is_empty());
+ fxa.disconnect();
+ assert!(fxa.state.access_token_cache.is_empty());
+ }
+
+ #[test]
+ fn test_disconnect_device() {
+ let config = Config::stable_dev("12345678", "https://foo.bar");
+ let mut fxa = FirefoxAccount::with_config(config);
+
+ fxa.state.refresh_token = Some(RefreshToken {
+ token: "refreshtok".to_string(),
+ scopes: HashSet::default(),
+ });
+
+ let mut client = FxAClientMock::new();
+ client
+ .expect_devices(mockiato::Argument::any, |token| {
+ token.partial_eq("refreshtok")
+ })
+ .times(1)
+ .returns_once(Ok(vec![
+ Device {
+ common: http_client::DeviceResponseCommon {
+ id: "1234a".to_owned(),
+ display_name: "My Device".to_owned(),
+ device_type: http_client::DeviceType::Mobile,
+ push_subscription: None,
+ available_commands: HashMap::default(),
+ push_endpoint_expired: false,
+ },
+ is_current_device: true,
+ location: http_client::DeviceLocation {
+ city: None,
+ country: None,
+ state: None,
+ state_code: None,
+ },
+ last_access_time: None,
+ },
+ Device {
+ common: http_client::DeviceResponseCommon {
+ id: "a4321".to_owned(),
+ display_name: "My Other Device".to_owned(),
+ device_type: http_client::DeviceType::Desktop,
+ push_subscription: None,
+ available_commands: HashMap::default(),
+ push_endpoint_expired: false,
+ },
+ is_current_device: false,
+ location: http_client::DeviceLocation {
+ city: None,
+ country: None,
+ state: None,
+ state_code: None,
+ },
+ last_access_time: None,
+ },
+ ]));
+ client
+ .expect_destroy_device(
+ mockiato::Argument::any,
+ |token| token.partial_eq("refreshtok"),
+ |device_id| device_id.partial_eq("1234a"),
+ )
+ .times(1)
+ .returns_once(Ok(()));
+ fxa.set_client(Arc::new(client));
+
+ assert!(fxa.state.refresh_token.is_some());
+ fxa.disconnect();
+ assert!(fxa.state.refresh_token.is_none());
+ }
+
+ #[test]
+ fn test_disconnect_no_device() {
+ let config = Config::stable_dev("12345678", "https://foo.bar");
+ let mut fxa = FirefoxAccount::with_config(config);
+
+ fxa.state.refresh_token = Some(RefreshToken {
+ token: "refreshtok".to_string(),
+ scopes: HashSet::default(),
+ });
+
+ let mut client = FxAClientMock::new();
+ client
+ .expect_devices(mockiato::Argument::any, |token| {
+ token.partial_eq("refreshtok")
+ })
+ .times(1)
+ .returns_once(Ok(vec![Device {
+ common: http_client::DeviceResponseCommon {
+ id: "a4321".to_owned(),
+ display_name: "My Other Device".to_owned(),
+ device_type: http_client::DeviceType::Desktop,
+ push_subscription: None,
+ available_commands: HashMap::default(),
+ push_endpoint_expired: false,
+ },
+ is_current_device: false,
+ location: http_client::DeviceLocation {
+ city: None,
+ country: None,
+ state: None,
+ state_code: None,
+ },
+ last_access_time: None,
+ }]));
+ client
+ .expect_destroy_refresh_token(mockiato::Argument::any, |token| {
+ token.partial_eq("refreshtok")
+ })
+ .times(1)
+ .returns_once(Ok(()));
+ fxa.set_client(Arc::new(client));
+
+ assert!(fxa.state.refresh_token.is_some());
+ fxa.disconnect();
+ assert!(fxa.state.refresh_token.is_none());
+ }
+
+ #[test]
+ fn test_disconnect_network_errors() {
+ let config = Config::stable_dev("12345678", "https://foo.bar");
+ let mut fxa = FirefoxAccount::with_config(config);
+
+ fxa.state.refresh_token = Some(RefreshToken {
+ token: "refreshtok".to_string(),
+ scopes: HashSet::default(),
+ });
+
+ let mut client = FxAClientMock::new();
+ client
+ .expect_devices(mockiato::Argument::any, |token| {
+ token.partial_eq("refreshtok")
+ })
+ .times(1)
+ .returns_once(Ok(vec![]));
+ client
+ .expect_destroy_refresh_token(mockiato::Argument::any, |token| {
+ token.partial_eq("refreshtok")
+ })
+ .times(1)
+ .returns_once(Err(ErrorKind::RemoteError {
+ code: 500,
+ errno: 101,
+ error: "Did not work!".to_owned(),
+ message: "Did not work!".to_owned(),
+ info: "Did not work!".to_owned(),
+ }
+ .into()));
+ fxa.set_client(Arc::new(client));
+
+ assert!(fxa.state.refresh_token.is_some());
+ fxa.disconnect();
+ assert!(fxa.state.refresh_token.is_none());
+ }
+
+ #[test]
+ fn test_get_pairing_authority_url() {
+ let config = Config::new("https://foo.bar", "12345678", "https://foo.bar");
+ let fxa = FirefoxAccount::with_config(config);
+ assert_eq!(
+ fxa.get_pairing_authority_url().unwrap().as_str(),
+ "https://foo.bar/pair"
+ );
+
+ let config = Config::release("12345678", "https://foo.bar");
+ let fxa = FirefoxAccount::with_config(config);
+ assert_eq!(
+ fxa.get_pairing_authority_url().unwrap().as_str(),
+ "https://firefox.com/pair"
+ );
+
+ let config = Config::china("12345678", "https://foo.bar");
+ let fxa = FirefoxAccount::with_config(config);
+ assert_eq!(
+ fxa.get_pairing_authority_url().unwrap().as_str(),
+ "https://firefox.com.cn/pair"
+ )
+ }
+}