#![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;
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;
#[cfg(test)]
unsafe impl<'a> Send for http_client::FxAClientMock<'a> {}
#[cfg(test)]
unsafe impl<'a> Sync for http_client::FxAClientMock<'a> {}
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: 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()),
}
}
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,
})
}
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)]
pub(crate) fn set_client(&mut self, client: Arc<FxAClient>) {
self.client = client;
}
pub fn from_json(data: &str) -> Result<Self> {
let state = state_persistence::state_from_json(data)?;
Ok(Self::from_state(state))
}
pub fn to_json(&self) -> Result<String> {
state_persistence::state_to_json(&self.state)
}
pub fn clear_devices_and_attached_clients_cache(&mut self) {
self.attached_clients_cache = None;
self.devices_cache = None;
}
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());
}
pub fn get_token_server_endpoint_url(&self) -> Result<Url> {
self.state.config.token_server_endpoint_url()
}
pub fn get_pairing_authority_url(&self) -> Result<Url> {
if self.state.config.content_url()? == Url::parse(config::CONTENT_URL_RELEASE)? {
return Ok(Url::parse("https://firefox.com/pair")?);
}
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()?)
}
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)
}
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)
}
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()),
}
}
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 {
let destroy_result = match current_device_result {
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,
#[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);
match fxa.get_manage_account_url("test").unwrap_err().kind() {
ErrorKind::NoCachedToken(_) => {}
_ => panic!("error not NoCachedToken"),
};
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);
match fxa.get_manage_devices_url("test").unwrap_err().kind() {
ErrorKind::NoCachedToken(_) => {}
_ => panic!("error not NoCachedToken"),
};
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"
)
}
}