summaryrefslogtreecommitdiffstats
path: root/third_party/rust/fxa-client
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-28 14:29:10 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-28 14:29:10 +0000
commit2aa4a82499d4becd2284cdb482213d541b8804dd (patch)
treeb80bf8bf13c3766139fbacc530efd0dd9d54394c /third_party/rust/fxa-client
parentInitial commit. (diff)
downloadfirefox-2aa4a82499d4becd2284cdb482213d541b8804dd.tar.xz
firefox-2aa4a82499d4becd2284cdb482213d541b8804dd.zip
Adding upstream version 86.0.1.upstream/86.0.1upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'third_party/rust/fxa-client')
-rw-r--r--third_party/rust/fxa-client/.cargo-checksum.json1
-rw-r--r--third_party/rust/fxa-client/Cargo.toml39
-rw-r--r--third_party/rust/fxa-client/src/auth.rs255
-rw-r--r--third_party/rust/fxa-client/src/commands/mod.rs5
-rw-r--r--third_party/rust/fxa-client/src/commands/send_tab.rs256
-rw-r--r--third_party/rust/fxa-client/src/config.rs402
-rw-r--r--third_party/rust/fxa-client/src/device.rs798
-rw-r--r--third_party/rust/fxa-client/src/error.rs152
-rw-r--r--third_party/rust/fxa-client/src/ffi.rs345
-rw-r--r--third_party/rust/fxa-client/src/fxa_msg_types.proto142
-rw-r--r--third_party/rust/fxa-client/src/http_client.rs1162
-rw-r--r--third_party/rust/fxa-client/src/lib.rs599
-rw-r--r--third_party/rust/fxa-client/src/migrator.rs394
-rw-r--r--third_party/rust/fxa-client/src/mozilla.appservices.fxaclient.protobuf.rs208
-rw-r--r--third_party/rust/fxa-client/src/oauth.rs1146
-rw-r--r--third_party/rust/fxa-client/src/oauth/attached_clients.rs113
-rw-r--r--third_party/rust/fxa-client/src/profile.rs228
-rw-r--r--third_party/rust/fxa-client/src/push.rs274
-rw-r--r--third_party/rust/fxa-client/src/scoped_keys.rs118
-rw-r--r--third_party/rust/fxa-client/src/scopes.rs7
-rw-r--r--third_party/rust/fxa-client/src/send_tab.rs135
-rw-r--r--third_party/rust/fxa-client/src/state_persistence.rs307
-rw-r--r--third_party/rust/fxa-client/src/telemetry.rs360
-rw-r--r--third_party/rust/fxa-client/src/util.rs46
24 files changed, 7492 insertions, 0 deletions
diff --git a/third_party/rust/fxa-client/.cargo-checksum.json b/third_party/rust/fxa-client/.cargo-checksum.json
new file mode 100644
index 0000000000..7d8a8cf698
--- /dev/null
+++ b/third_party/rust/fxa-client/.cargo-checksum.json
@@ -0,0 +1 @@
+{"files":{"Cargo.toml":"d39e902bf4333f2dfd6e152ed3c55f46ab369c485deb9d101b930caa9688664c","src/auth.rs":"55d554055fdcfb4200a6a0fad64e92d522f629aaff4db82aec230f7c1affd2f5","src/commands/mod.rs":"0197979f5851da2300a481b6f7d0bd1ac7e00b08383339564b2372519965ca58","src/commands/send_tab.rs":"527602e281c85287d641c3c712c78627478d33bb66e1533b9b8758d389a727cb","src/config.rs":"40ccd4fd38a7397ec692ccb078969abf7360124cf94ffb7f6a41da751046dbb8","src/device.rs":"d171e343410fbccae16cc5ec6fa10cb9c40544544dbf2906ac46663cb1fcc2b7","src/error.rs":"d2601b4fc7f0492757ab843e0b26a166f2315c9739151cd82773dc19c7393261","src/ffi.rs":"87be94bd84d408c526df50c1af1d76761fc65bf4424169e4c76387be5795ac59","src/fxa_msg_types.proto":"b31777f821bb37e67cae9982cfa43ad2b331dc8ee67b14177085da3290d76d4c","src/http_client.rs":"45cf71da6f350e7474ae0a1dbbb9d7ea55fc829ea2654ed6437b8cacb23a77f7","src/lib.rs":"ec6c7d16fe26a8d7ae8aba292cba30cccca5afad84c607624e11916f10b977e3","src/migrator.rs":"220c142fbd87fbb3f0ee3d8d8c77d625c09bc2b9f4d0d44b3140787eebf30d2f","src/mozilla.appservices.fxaclient.protobuf.rs":"d8a4446a024ffd6dddffe4b85679c23e491c2d859ab34859a420b94940678d8b","src/oauth.rs":"1eda658c5d70458fe64b9eae2080fdcd00ea0e63fbab78238b7cd333a1f8039e","src/oauth/attached_clients.rs":"e0fd277d4294fd7982100e171cce790ea4acefb22ab1c7ab7b36f8593038cc43","src/profile.rs":"b92741613a5f7a7f381171b550320edd89d93824f072f5089fcf6c6318adf88c","src/push.rs":"c7e714d733463bcccd2627d8eca8041e389f737b2a1162667a7217c739832c18","src/scoped_keys.rs":"65bb1c8fa1c24bc3c342c7f5d45843915a4fcace7a509fc6fbd7809fb7c85024","src/scopes.rs":"000360f2193812b20e146cb5bf2782ae7a3c50883f28d018baa572c127d09391","src/send_tab.rs":"a54add670f507dedb626c7eb197a59197da984e44ba1000b683df083b875d7e5","src/state_persistence.rs":"cba27bf9e91727e8a55cd983aece5bf5a77f14e7cd37b72d4b09dcee9e0a871f","src/telemetry.rs":"207ac2940dd7ff8b7a61689fc69a9f933eb13b4abf87dab4472230a2beeb62e2","src/util.rs":"7eb861c2ee72714cd437dc0720b97ca4ca7b8a5590391f8ede00005721a24f81"},"package":null} \ No newline at end of file
diff --git a/third_party/rust/fxa-client/Cargo.toml b/third_party/rust/fxa-client/Cargo.toml
new file mode 100644
index 0000000000..a7d4e60f1b
--- /dev/null
+++ b/third_party/rust/fxa-client/Cargo.toml
@@ -0,0 +1,39 @@
+[package]
+name = "fxa-client"
+edition = "2018"
+version = "0.1.0"
+authors = ["Edouard Oger <eoger@fastmail.com>"]
+license = "MPL-2.0"
+exclude = ["/android", "/ios"]
+
+[dependencies]
+base64 = "0.12"
+hex = "0.4"
+lazy_static = "1.4"
+log = "0.4"
+prost = "0.6"
+prost-derive = "0.6"
+rand_rccrypto = { path = "../support/rand_rccrypto" }
+serde = { version = "1", features = ["rc"] }
+serde_derive = "1"
+serde_json = "1"
+sync15 = { path = "../sync15" }
+url = "2.1"
+ffi-support = "0.4"
+viaduct = { path = "../viaduct" }
+jwcrypto = { path = "../support/jwcrypto" }
+rc_crypto = { path = "../support/rc_crypto", features = ["ece", "hawk"] }
+error-support = { path = "../support/error" }
+thiserror = "1.0"
+anyhow = "1.0"
+sync-guid = { path = "../support/guid", features = ["random"] }
+
+[dev-dependencies]
+viaduct-reqwest = { path = "../support/viaduct-reqwest" }
+mockiato = "0.9"
+mockito = "0.27"
+
+[features]
+default = []
+gecko = [ "rc_crypto/gecko" ]
+integration_test = []
diff --git a/third_party/rust/fxa-client/src/auth.rs b/third_party/rust/fxa-client/src/auth.rs
new file mode 100644
index 0000000000..728554890c
--- /dev/null
+++ b/third_party/rust/fxa-client/src/auth.rs
@@ -0,0 +1,255 @@
+/* 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/. */
+
+pub use crate::oauth::{AuthorizationPKCEParams, AuthorizationParameters};
+use crate::{error::*, http_client, scoped_keys::ScopedKey, util::Xorable, Config};
+pub use http_client::{
+ derive_auth_key_from_session_token, send_authorization_request, send_verification,
+ AuthorizationRequestParameters,
+};
+use jwcrypto::{EncryptionAlgorithm, EncryptionParameters};
+use rc_crypto::{digest, hkdf, hmac, pbkdf2};
+use serde_derive::{Deserialize, Serialize};
+use std::collections::HashMap;
+pub fn get_sync_keys(
+ config: &Config,
+ key_fetch_token: &str,
+ email: &str,
+ pw: &str,
+) -> Result<(Vec<u8>, Vec<u8>)> {
+ let acct_keys = get_account_keys(config, key_fetch_token)?;
+ let wrap_kb = &acct_keys[32..];
+ let sync_key = derive_sync_key(email, pw, wrap_kb)?;
+ let xcs_key = derive_xcs_key(email, pw, wrap_kb)?;
+ Ok((sync_key, xcs_key))
+}
+
+pub fn create_keys_jwe(
+ client_id: &str,
+ scope: &str,
+ jwk: &str,
+ auth_key: &[u8],
+ config: &Config,
+ acct_keys: (&[u8], &[u8]),
+) -> anyhow::Result<String> {
+ let scoped: HashMap<String, ScopedKey> =
+ get_scoped_keys(scope, client_id, auth_key, config, acct_keys)?;
+ let scoped = serde_json::to_string(&scoped)?;
+ let scoped = scoped.as_bytes();
+ let jwk = serde_json::from_str(jwk)?;
+ let res = jwcrypto::encrypt_to_jwe(
+ scoped,
+ EncryptionParameters::ECDH_ES {
+ enc: EncryptionAlgorithm::A256GCM,
+ peer_jwk: &jwk,
+ },
+ )?;
+ Ok(res)
+}
+
+#[derive(Serialize, Deserialize)]
+struct Epk {
+ crv: String,
+ kty: String,
+ x: String,
+ y: String,
+}
+
+fn kwe(name: &str, email: &str) -> Vec<u8> {
+ format!("identity.mozilla.com/picl/v1/{}:{}", name, email)
+ .as_bytes()
+ .to_vec()
+}
+
+fn kw(name: &str) -> Vec<u8> {
+ format!("identity.mozilla.com/picl/v1/{}", name)
+ .as_bytes()
+ .to_vec()
+}
+
+pub fn get_scoped_keys(
+ scope: &str,
+ client_id: &str,
+ auth_key: &[u8],
+ config: &Config,
+ acct_keys: (&[u8], &[u8]),
+) -> anyhow::Result<HashMap<String, ScopedKey>> {
+ let key_data = http_client::get_scoped_key_data_response(scope, client_id, auth_key, config)?;
+ let mut scoped_keys: HashMap<String, ScopedKey> = HashMap::new();
+ key_data
+ .as_object()
+ .ok_or_else(|| anyhow::Error::msg("Key data not an object"))?
+ .keys()
+ .try_for_each(|key| -> anyhow::Result<()> {
+ let val = key_data
+ .as_object()
+ .ok_or_else(|| anyhow::Error::msg("Key data not an object"))?
+ .get(key)
+ .ok_or_else(|| anyhow::Error::msg("Key does not exist"))?;
+ scoped_keys.insert(key.clone(), get_key_for_scope(&key, val, acct_keys)?);
+ Ok(())
+ })?;
+ Ok(scoped_keys)
+}
+
+fn get_key_for_scope(
+ key: &str,
+ val: &serde_json::Value,
+ acct_keys: (&[u8], &[u8]),
+) -> anyhow::Result<ScopedKey> {
+ let (sync_key, xcs_key) = acct_keys;
+ let sync_key = base64::encode_config(sync_key, base64::URL_SAFE_NO_PAD);
+ let xcs_key = base64::encode_config(xcs_key, base64::URL_SAFE_NO_PAD);
+ let kid = format!(
+ "{}-{}",
+ val.as_object()
+ .ok_or_else(|| anyhow::Error::msg("Json is not an object"))?
+ .get("keyRotationTimestamp")
+ .ok_or_else(|| anyhow::Error::msg("Key rotation timestamp doesn't exist"))?
+ .as_u64()
+ .ok_or_else(|| anyhow::Error::msg("Key rotation timestamp is not a number"))?,
+ xcs_key
+ );
+ Ok(ScopedKey {
+ scope: key.to_string(),
+ kid,
+ k: sync_key,
+ kty: "oct".to_string(),
+ })
+}
+
+fn derive_xcs_key(email: &str, pwd: &str, wrap_kb: &[u8]) -> Result<Vec<u8>> {
+ let unwrap_kb = derive_unwrap_kb(email, pwd)?;
+ let kb = xored(wrap_kb, &unwrap_kb)?;
+ Ok(sha256(&kb)?[0..16].into())
+}
+
+fn sha256(kb: &[u8]) -> Result<Vec<u8>> {
+ let ret = digest::digest(&digest::SHA256, kb)?;
+ let ret: &[u8] = ret.as_ref();
+ Ok(ret.to_vec())
+}
+
+fn derive_hkdf_sha256_key(ikm: &[u8], salt: &[u8], info: &[u8], len: usize) -> Result<Vec<u8>> {
+ let salt = hmac::SigningKey::new(&digest::SHA256, salt);
+ let mut out = vec![0u8; len];
+ hkdf::extract_and_expand(&salt, ikm, info, &mut out)?;
+ Ok(out)
+}
+
+fn quick_strech_pwd(email: &str, pwd: &str) -> Result<Vec<u8>> {
+ let salt = kwe("quickStretch", email);
+ let mut out = [0u8; 32];
+ pbkdf2::derive(
+ pwd.as_bytes(),
+ &salt,
+ 1000,
+ pbkdf2::HashAlgorithm::SHA256,
+ &mut out,
+ )?;
+ Ok(out.to_vec())
+}
+
+pub fn auth_pwd(email: &str, pwd: &str) -> Result<String> {
+ let streched = quick_strech_pwd(email, pwd)?;
+ let salt = b"";
+ let context = kw("authPW");
+ let derived = derive_hkdf_sha256_key(&streched, salt, &context, 32)?;
+ Ok(hex::encode(derived))
+}
+
+#[derive(Serialize, Deserialize)]
+struct Credentials {
+ key: Vec<u8>,
+ id: Vec<u8>,
+ extra: Vec<u8>,
+ out: Vec<u8>,
+}
+
+fn derive_hawk_credentials(token_hex: &str, context: &str, size: usize) -> Result<Credentials> {
+ let token = hex::decode(token_hex)?;
+ let out = derive_hkdf_sha256_key(&token, &[0u8; 0], &kw(context), size)?;
+ let key = out[32..64].to_vec();
+ let extra = out[64..].to_vec();
+ Ok(Credentials {
+ key,
+ id: out[0..32].to_vec(),
+ extra,
+ out: out.to_vec(),
+ })
+}
+
+fn xored(a: &[u8], b: &[u8]) -> Result<Vec<u8>> {
+ a.xored_with(b)
+}
+
+fn derive_unwrap_kb(email: &str, pwd: &str) -> Result<Vec<u8>> {
+ let streched_pw = quick_strech_pwd(email, pwd)?;
+ let out = derive_hkdf_sha256_key(&streched_pw, &[0u8; 0], &kw("unwrapBkey"), 32)?;
+ Ok(out)
+}
+
+fn derive_sync_key(email: &str, pwd: &str, wrap_kb: &[u8]) -> Result<Vec<u8>> {
+ let unwrap_kb = derive_unwrap_kb(email, pwd)?;
+ let kb = xored(wrap_kb, &unwrap_kb)?;
+ derive_hkdf_sha256_key(
+ &kb,
+ &[0u8; 0],
+ "identity.mozilla.com/picl/v1/oldsync".as_bytes(),
+ 64,
+ )
+}
+
+fn get_account_keys(config: &Config, key_fetch_token: &str) -> Result<Vec<u8>> {
+ let creds = derive_hawk_credentials(key_fetch_token, "keyFetchToken", 96)?;
+ let key_request_key = &creds.extra[0..32];
+ let more_creds = derive_hkdf_sha256_key(key_request_key, &[0u8; 0], &kw("account/keys"), 96)?;
+ let _resp_hmac_key = &more_creds[0..32];
+ let resp_xor_key = &more_creds[32..96];
+ let bundle = http_client::get_keys_bundle(&config, &creds.out)?;
+ // Missing MAC matching since this is only for tests
+ xored(resp_xor_key, &bundle[0..64])
+}
+
+#[cfg(test)]
+mod tests {
+
+ // Test vectors used from https://github.com/mozilla/fxa-auth-server/wiki/onepw-protocol#test-vectors
+ use super::*;
+ const EMAIL: &str = "andré@example.org";
+ const PASSWORD: &str = "pässwörd";
+ #[test]
+ fn test_derive_quick_stretch() {
+ let qs = quick_strech_pwd(EMAIL, PASSWORD).unwrap();
+ let expected = "e4e8889bd8bd61ad6de6b95c059d56e7b50dacdaf62bd84644af7e2add84345d";
+ assert_eq!(expected, hex::encode(qs));
+ }
+
+ #[test]
+ fn test_auth_pw() {
+ let auth_pw = auth_pwd(EMAIL, PASSWORD).unwrap();
+ let expected = "247b675ffb4c46310bc87e26d712153abe5e1c90ef00a4784594f97ef54f2375";
+ assert_eq!(auth_pw, expected);
+ }
+
+ #[test]
+ fn test_derive_unwrap_kb() {
+ let unwrap_kb = derive_unwrap_kb(EMAIL, PASSWORD).unwrap();
+ let expected = "de6a2648b78284fcb9ffa81ba95803309cfba7af583c01a8a1a63e567234dd28";
+ assert_eq!(hex::encode(unwrap_kb), expected);
+ }
+
+ #[test]
+ fn test_kb() {
+ let wrap_kb =
+ hex::decode("7effe354abecbcb234a8dfc2d7644b4ad339b525589738f2d27341bb8622ecd8")
+ .unwrap();
+ let unwrap_kb =
+ hex::decode("de6a2648b78284fcb9ffa81ba95803309cfba7af583c01a8a1a63e567234dd28")
+ .unwrap();
+ let kb = xored(&wrap_kb, &unwrap_kb).unwrap();
+ let expected = "a095c51c1c6e384e8d5777d97e3c487a4fc2128a00ab395a73d57fedf41631f0";
+ assert_eq!(expected, hex::encode(kb));
+ }
+}
diff --git a/third_party/rust/fxa-client/src/commands/mod.rs b/third_party/rust/fxa-client/src/commands/mod.rs
new file mode 100644
index 0000000000..0b67545b92
--- /dev/null
+++ b/third_party/rust/fxa-client/src/commands/mod.rs
@@ -0,0 +1,5 @@
+/* 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/. */
+
+pub mod send_tab;
diff --git a/third_party/rust/fxa-client/src/commands/send_tab.rs b/third_party/rust/fxa-client/src/commands/send_tab.rs
new file mode 100644
index 0000000000..25ea94e93f
--- /dev/null
+++ b/third_party/rust/fxa-client/src/commands/send_tab.rs
@@ -0,0 +1,256 @@
+/* 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/. */
+
+/// The Send Tab functionality is backed by Firefox Accounts device commands.
+/// A device shows it can handle "Send Tab" commands by advertising the "open-uri"
+/// command in its on own device record.
+/// This command data bundle contains a one-time generated `PublicSendTabKeys`
+/// (while keeping locally `PrivateSendTabKeys` containing the private key),
+/// wrapped by the account oldsync scope `kSync` to form a `SendTabKeysPayload`.
+///
+/// When a device sends a tab to another, it decrypts that `SendTabKeysPayload` using `kSync`,
+/// uses the obtained public key to encrypt the `SendTabPayload` it created that
+/// contains the tab to send and finally forms the `EncryptedSendTabPayload` that is
+/// then sent to the target device.
+use crate::{device::Device, error::*, scoped_keys::ScopedKey, scopes, telemetry};
+use rc_crypto::ece::{self, Aes128GcmEceWebPush, EcKeyComponents, WebPushParams};
+use rc_crypto::ece_crypto::{RcCryptoLocalKeyPair, RcCryptoRemotePublicKey};
+use serde_derive::*;
+use sync15::{EncryptedPayload, KeyBundle};
+
+pub const COMMAND_NAME: &str = "https://identity.mozilla.com/cmd/open-uri";
+
+#[derive(Debug, Serialize, Deserialize)]
+pub struct EncryptedSendTabPayload {
+ /// URL Safe Base 64 encrypted send-tab payload.
+ encrypted: String,
+}
+
+impl EncryptedSendTabPayload {
+ pub(crate) fn decrypt(self, keys: &PrivateSendTabKeysV1) -> Result<SendTabPayload> {
+ rc_crypto::ensure_initialized();
+ let encrypted = base64::decode_config(&self.encrypted, base64::URL_SAFE_NO_PAD)?;
+ let private_key = RcCryptoLocalKeyPair::from_raw_components(&keys.p256key)?;
+ let decrypted = Aes128GcmEceWebPush::decrypt(&private_key, &keys.auth_secret, &encrypted)?;
+ Ok(serde_json::from_slice(&decrypted)?)
+ }
+}
+
+#[derive(Debug, Serialize, Deserialize)]
+pub struct SendTabPayload {
+ pub entries: Vec<TabHistoryEntry>,
+ #[serde(rename = "flowID", default)]
+ pub flow_id: String,
+ #[serde(rename = "streamID", default)]
+ pub stream_id: String,
+}
+
+impl SendTabPayload {
+ pub fn single_tab(title: &str, url: &str) -> (Self, telemetry::SentCommand) {
+ let sent_telemetry: telemetry::SentCommand = Default::default();
+ (
+ SendTabPayload {
+ entries: vec![TabHistoryEntry {
+ title: title.to_string(),
+ url: url.to_string(),
+ }],
+ flow_id: sent_telemetry.flow_id.clone(),
+ stream_id: sent_telemetry.stream_id.clone(),
+ },
+ sent_telemetry,
+ )
+ }
+ fn encrypt(&self, keys: PublicSendTabKeys) -> Result<EncryptedSendTabPayload> {
+ rc_crypto::ensure_initialized();
+ let bytes = serde_json::to_vec(&self)?;
+ let public_key = base64::decode_config(&keys.public_key, base64::URL_SAFE_NO_PAD)?;
+ let public_key = RcCryptoRemotePublicKey::from_raw(&public_key)?;
+ let auth_secret = base64::decode_config(&keys.auth_secret, base64::URL_SAFE_NO_PAD)?;
+ let encrypted = Aes128GcmEceWebPush::encrypt(
+ &public_key,
+ &auth_secret,
+ &bytes,
+ WebPushParams::default(),
+ )?;
+ let encrypted = base64::encode_config(&encrypted, base64::URL_SAFE_NO_PAD);
+ Ok(EncryptedSendTabPayload { encrypted })
+ }
+}
+
+#[derive(Debug, Serialize, Deserialize)]
+pub struct TabHistoryEntry {
+ pub title: String,
+ pub url: String,
+}
+
+#[derive(Serialize, Deserialize, Clone)]
+pub(crate) enum VersionnedPrivateSendTabKeys {
+ V1(PrivateSendTabKeysV1),
+}
+
+#[derive(Serialize, Deserialize, Clone)]
+pub(crate) struct PrivateSendTabKeysV1 {
+ p256key: EcKeyComponents,
+ auth_secret: Vec<u8>,
+}
+pub(crate) type PrivateSendTabKeys = PrivateSendTabKeysV1;
+
+impl PrivateSendTabKeys {
+ // We define this method so the type-checker prevents us from
+ // trying to serialize `PrivateSendTabKeys` directly since
+ // `serde_json::to_string` would compile because both types derive
+ // `Serialize`.
+ pub(crate) fn serialize(&self) -> Result<String> {
+ Ok(serde_json::to_string(&VersionnedPrivateSendTabKeys::V1(
+ self.clone(),
+ ))?)
+ }
+
+ pub(crate) fn deserialize(s: &str) -> Result<Self> {
+ let versionned: VersionnedPrivateSendTabKeys = serde_json::from_str(s)?;
+ match versionned {
+ VersionnedPrivateSendTabKeys::V1(prv_key) => Ok(prv_key),
+ }
+ }
+}
+
+impl PrivateSendTabKeys {
+ pub fn from_random() -> Result<Self> {
+ rc_crypto::ensure_initialized();
+ let (key_pair, auth_secret) = ece::generate_keypair_and_auth_secret()?;
+ Ok(Self {
+ p256key: key_pair.raw_components()?,
+ auth_secret: auth_secret.to_vec(),
+ })
+ }
+}
+
+#[derive(Serialize, Deserialize)]
+pub(crate) struct SendTabKeysPayload {
+ /// Hex encoded kid.
+ kid: String,
+ /// Base 64 encoded IV.
+ #[serde(rename = "IV")]
+ iv: String,
+ /// Hex encoded hmac.
+ hmac: String,
+ /// Base 64 encoded ciphertext.
+ ciphertext: String,
+}
+
+impl SendTabKeysPayload {
+ pub(crate) fn decrypt(self, scoped_key: &ScopedKey) -> Result<PublicSendTabKeys> {
+ let (ksync, kxcs) = extract_oldsync_key_components(scoped_key)?;
+ if hex::decode(self.kid)? != kxcs {
+ return Err(ErrorKind::MismatchedKeys.into());
+ }
+ let key = KeyBundle::from_ksync_bytes(&ksync)?;
+ let encrypted_bso = EncryptedPayload {
+ iv: self.iv,
+ hmac: self.hmac,
+ ciphertext: self.ciphertext,
+ };
+ Ok(encrypted_bso.decrypt_and_parse_payload(&key)?)
+ }
+}
+
+#[derive(Serialize, Deserialize)]
+pub struct PublicSendTabKeys {
+ /// URL Safe Base 64 encoded push public key.
+ #[serde(rename = "publicKey")]
+ public_key: String,
+ /// URL Safe Base 64 encoded auth secret.
+ #[serde(rename = "authSecret")]
+ auth_secret: String,
+}
+
+impl PublicSendTabKeys {
+ fn encrypt(&self, scoped_key: &ScopedKey) -> Result<SendTabKeysPayload> {
+ let (ksync, kxcs) = extract_oldsync_key_components(scoped_key)?;
+ let key = KeyBundle::from_ksync_bytes(&ksync)?;
+ let encrypted_payload = EncryptedPayload::from_cleartext_payload(&key, &self)?;
+ Ok(SendTabKeysPayload {
+ kid: hex::encode(kxcs),
+ iv: encrypted_payload.iv,
+ hmac: encrypted_payload.hmac,
+ ciphertext: encrypted_payload.ciphertext,
+ })
+ }
+ pub fn as_command_data(&self, scoped_key: &ScopedKey) -> Result<String> {
+ let encrypted_public_keys = self.encrypt(scoped_key)?;
+ Ok(serde_json::to_string(&encrypted_public_keys)?)
+ }
+ pub(crate) fn public_key(&self) -> &str {
+ &self.public_key
+ }
+ pub(crate) fn auth_secret(&self) -> &str {
+ &self.auth_secret
+ }
+}
+
+impl From<PrivateSendTabKeys> for PublicSendTabKeys {
+ fn from(internal: PrivateSendTabKeys) -> Self {
+ Self {
+ public_key: base64::encode_config(
+ &internal.p256key.public_key(),
+ base64::URL_SAFE_NO_PAD,
+ ),
+ auth_secret: base64::encode_config(&internal.auth_secret, base64::URL_SAFE_NO_PAD),
+ }
+ }
+}
+
+pub fn build_send_command(
+ scoped_key: &ScopedKey,
+ target: &Device,
+ send_tab_payload: &SendTabPayload,
+) -> Result<serde_json::Value> {
+ let command = target
+ .available_commands
+ .get(COMMAND_NAME)
+ .ok_or_else(|| ErrorKind::UnsupportedCommand(COMMAND_NAME))?;
+ let bundle: SendTabKeysPayload = serde_json::from_str(command)?;
+ let public_keys = bundle.decrypt(scoped_key)?;
+ let encrypted_payload = send_tab_payload.encrypt(public_keys)?;
+ Ok(serde_json::to_value(&encrypted_payload)?)
+}
+
+fn extract_oldsync_key_components(oldsync_key: &ScopedKey) -> Result<(Vec<u8>, Vec<u8>)> {
+ if oldsync_key.scope != scopes::OLD_SYNC {
+ return Err(ErrorKind::IllegalState(
+ "Only oldsync scoped keys are supported at the moment.",
+ )
+ .into());
+ }
+ let kxcs: &str = oldsync_key.kid.splitn(2, '-').collect::<Vec<_>>()[1];
+ let kxcs = base64::decode_config(&kxcs, base64::URL_SAFE_NO_PAD)?;
+ let ksync = oldsync_key.key_bytes()?;
+ Ok((ksync, kxcs))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_minimal_parse_payload() {
+ let minimal = r#"{ "entries": []}"#;
+ let payload: SendTabPayload = serde_json::from_str(minimal).expect("should work");
+ assert_eq!(payload.flow_id, "".to_string());
+ }
+
+ #[test]
+ fn test_payload() {
+ let (payload, telem) = SendTabPayload::single_tab("title", "http://example.com");
+ let json = serde_json::to_string(&payload).expect("should work");
+ assert_eq!(telem.flow_id.len(), 12);
+ assert_eq!(telem.stream_id.len(), 12);
+ assert_ne!(telem.flow_id, telem.stream_id);
+ let p2: SendTabPayload = serde_json::from_str(&json).expect("should work");
+ // no 'PartialEq' derived so check each field individually...
+ assert_eq!(payload.entries[0].url, "http://example.com".to_string());
+ assert_eq!(payload.flow_id, p2.flow_id);
+ assert_eq!(payload.stream_id, p2.stream_id);
+ }
+}
diff --git a/third_party/rust/fxa-client/src/config.rs b/third_party/rust/fxa-client/src/config.rs
new file mode 100644
index 0000000000..08aee1365d
--- /dev/null
+++ b/third_party/rust/fxa-client/src/config.rs
@@ -0,0 +1,402 @@
+/* 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/. */
+
+use crate::{error::*, http_client};
+use serde_derive::{Deserialize, Serialize};
+use std::{cell::RefCell, sync::Arc};
+use url::Url;
+
+#[derive(Clone, Debug, Serialize, Deserialize)]
+pub struct Config {
+ content_url: String,
+ token_server_url_override: Option<String>,
+ pub client_id: String,
+ pub redirect_uri: String,
+ // RemoteConfig is lazily fetched from the server.
+ #[serde(skip)]
+ remote_config: RefCell<Option<Arc<RemoteConfig>>>,
+}
+
+/// `RemoteConfig` struct stores configuration values from the FxA
+/// `/.well-known/fxa-client-configuration` and the
+/// `/.well-known/openid-configuration` endpoints.
+#[derive(Debug)]
+pub struct RemoteConfig {
+ auth_url: String,
+ oauth_url: String,
+ profile_url: String,
+ token_server_endpoint_url: String,
+ authorization_endpoint: String,
+ issuer: String,
+ jwks_uri: String,
+ token_endpoint: String,
+ userinfo_endpoint: String,
+ introspection_endpoint: String,
+}
+
+pub(crate) const CONTENT_URL_RELEASE: &str = "https://accounts.firefox.com";
+pub(crate) const CONTENT_URL_CHINA: &str = "https://accounts.firefox.com.cn";
+
+impl Config {
+ pub fn release(client_id: &str, redirect_uri: &str) -> Self {
+ Self::new(CONTENT_URL_RELEASE, client_id, redirect_uri)
+ }
+
+ pub fn stable_dev(client_id: &str, redirect_uri: &str) -> Self {
+ Self::new("https://stable.dev.lcip.org", client_id, redirect_uri)
+ }
+
+ pub fn stage_dev(client_id: &str, redirect_uri: &str) -> Self {
+ Self::new("https://accounts.stage.mozaws.net", client_id, redirect_uri)
+ }
+
+ pub fn china(client_id: &str, redirect_uri: &str) -> Self {
+ Self::new(CONTENT_URL_CHINA, client_id, redirect_uri)
+ }
+
+ pub fn localdev(client_id: &str, redirect_uri: &str) -> Self {
+ Self::new("http://127.0.0.1:3030", client_id, redirect_uri)
+ }
+
+ pub fn new(content_url: &str, client_id: &str, redirect_uri: &str) -> Self {
+ Self {
+ content_url: content_url.to_string(),
+ client_id: client_id.to_string(),
+ redirect_uri: redirect_uri.to_string(),
+ remote_config: RefCell::new(None),
+ token_server_url_override: None,
+ }
+ }
+
+ /// Override the token server URL that would otherwise be provided by the
+ /// FxA .well-known/fxa-client-configuration endpoint.
+ /// This is used by self-hosters that still use the product FxA servers
+ /// for authentication purposes but use their own Sync storage backend.
+ pub fn override_token_server_url<'a>(
+ &'a mut self,
+ token_server_url_override: &str,
+ ) -> &'a mut Self {
+ // In self-hosting setups it is common to specify the `/1.0/sync/1.5` suffix on the
+ // tokenserver URL. Accept and strip this form as a convenience for users.
+ // (ideally we'd use `strip_suffix`, but we currently target a rust version
+ // where this doesn't exist - `trim_end_matches` will repeatedly remove
+ // the suffix, but that seems fine for this use-case)
+ self.token_server_url_override = Some(
+ token_server_url_override
+ .trim_end_matches("/1.0/sync/1.5")
+ .to_owned(),
+ );
+ self
+ }
+
+ // FIXME
+ #[allow(clippy::too_many_arguments)]
+ pub(crate) fn init(
+ content_url: String,
+ auth_url: String,
+ oauth_url: String,
+ profile_url: String,
+ token_server_endpoint_url: String,
+ authorization_endpoint: String,
+ issuer: String,
+ jwks_uri: String,
+ token_endpoint: String,
+ userinfo_endpoint: String,
+ introspection_endpoint: String,
+ client_id: String,
+ redirect_uri: String,
+ token_server_url_override: Option<String>,
+ ) -> Self {
+ let remote_config = RemoteConfig {
+ auth_url,
+ oauth_url,
+ profile_url,
+ token_server_endpoint_url,
+ authorization_endpoint,
+ issuer,
+ jwks_uri,
+ token_endpoint,
+ userinfo_endpoint,
+ introspection_endpoint,
+ };
+
+ Config {
+ content_url,
+ remote_config: RefCell::new(Some(Arc::new(remote_config))),
+ client_id,
+ redirect_uri,
+ token_server_url_override,
+ }
+ }
+
+ fn remote_config(&self) -> Result<Arc<RemoteConfig>> {
+ if let Some(remote_config) = self.remote_config.borrow().clone() {
+ return Ok(remote_config);
+ }
+
+ let client_config = http_client::fxa_client_configuration(self.client_config_url()?)?;
+ let openid_config = http_client::openid_configuration(self.openid_config_url()?)?;
+
+ let remote_config = self.set_remote_config(RemoteConfig {
+ auth_url: format!("{}/", client_config.auth_server_base_url),
+ oauth_url: format!("{}/", client_config.oauth_server_base_url),
+ profile_url: format!("{}/", client_config.profile_server_base_url),
+ token_server_endpoint_url: format!("{}/", client_config.sync_tokenserver_base_url),
+ authorization_endpoint: openid_config.authorization_endpoint,
+ issuer: openid_config.issuer,
+ jwks_uri: openid_config.jwks_uri,
+ // TODO: bring back openid token endpoint once https://github.com/mozilla/fxa/issues/453 has been resolved
+ // and the openid reponse has been switched to the new endpoint.
+ // token_endpoint: openid_config.token_endpoint,
+ token_endpoint: format!("{}/v1/oauth/token", client_config.auth_server_base_url),
+ userinfo_endpoint: openid_config.userinfo_endpoint,
+ introspection_endpoint: openid_config.introspection_endpoint,
+ });
+ Ok(remote_config)
+ }
+
+ fn set_remote_config(&self, remote_config: RemoteConfig) -> Arc<RemoteConfig> {
+ let rc = Arc::new(remote_config);
+ let result = rc.clone();
+ self.remote_config.replace(Some(rc));
+ result
+ }
+
+ pub fn content_url(&self) -> Result<Url> {
+ Url::parse(&self.content_url).map_err(Into::into)
+ }
+
+ pub fn content_url_path(&self, path: &str) -> Result<Url> {
+ self.content_url()?.join(path).map_err(Into::into)
+ }
+
+ pub fn client_config_url(&self) -> Result<Url> {
+ Ok(self.content_url_path(".well-known/fxa-client-configuration")?)
+ }
+
+ pub fn openid_config_url(&self) -> Result<Url> {
+ Ok(self.content_url_path(".well-known/openid-configuration")?)
+ }
+
+ pub fn connect_another_device_url(&self) -> Result<Url> {
+ self.content_url_path("connect_another_device")
+ .map_err(Into::into)
+ }
+
+ pub fn pair_url(&self) -> Result<Url> {
+ self.content_url_path("pair").map_err(Into::into)
+ }
+
+ pub fn pair_supp_url(&self) -> Result<Url> {
+ self.content_url_path("pair/supp").map_err(Into::into)
+ }
+
+ pub fn oauth_force_auth_url(&self) -> Result<Url> {
+ self.content_url_path("oauth/force_auth")
+ .map_err(Into::into)
+ }
+
+ pub fn settings_url(&self) -> Result<Url> {
+ self.content_url_path("settings").map_err(Into::into)
+ }
+
+ pub fn settings_clients_url(&self) -> Result<Url> {
+ self.content_url_path("settings/clients")
+ .map_err(Into::into)
+ }
+
+ pub fn auth_url(&self) -> Result<Url> {
+ Url::parse(&self.remote_config()?.auth_url).map_err(Into::into)
+ }
+
+ pub fn auth_url_path(&self, path: &str) -> Result<Url> {
+ self.auth_url()?.join(path).map_err(Into::into)
+ }
+
+ pub fn profile_url(&self) -> Result<Url> {
+ Url::parse(&self.remote_config()?.profile_url).map_err(Into::into)
+ }
+
+ pub fn profile_url_path(&self, path: &str) -> Result<Url> {
+ self.profile_url()?.join(path).map_err(Into::into)
+ }
+
+ pub fn oauth_url(&self) -> Result<Url> {
+ Url::parse(&self.remote_config()?.oauth_url).map_err(Into::into)
+ }
+
+ pub fn oauth_url_path(&self, path: &str) -> Result<Url> {
+ self.oauth_url()?.join(path).map_err(Into::into)
+ }
+
+ pub fn token_server_endpoint_url(&self) -> Result<Url> {
+ if let Some(token_server_url_override) = &self.token_server_url_override {
+ return Ok(Url::parse(&token_server_url_override)?);
+ }
+ Ok(Url::parse(
+ &self.remote_config()?.token_server_endpoint_url,
+ )?)
+ }
+
+ pub fn authorization_endpoint(&self) -> Result<Url> {
+ Url::parse(&self.remote_config()?.authorization_endpoint).map_err(Into::into)
+ }
+
+ pub fn issuer(&self) -> Result<Url> {
+ Url::parse(&self.remote_config()?.issuer).map_err(Into::into)
+ }
+
+ pub fn jwks_uri(&self) -> Result<Url> {
+ Url::parse(&self.remote_config()?.jwks_uri).map_err(Into::into)
+ }
+
+ pub fn token_endpoint(&self) -> Result<Url> {
+ Url::parse(&self.remote_config()?.token_endpoint).map_err(Into::into)
+ }
+
+ pub fn introspection_endpoint(&self) -> Result<Url> {
+ Url::parse(&self.remote_config()?.introspection_endpoint).map_err(Into::into)
+ }
+
+ pub fn userinfo_endpoint(&self) -> Result<Url> {
+ Url::parse(&self.remote_config()?.userinfo_endpoint).map_err(Into::into)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_paths() {
+ let remote_config = RemoteConfig {
+ auth_url: "https://stable.dev.lcip.org/auth/".to_string(),
+ oauth_url: "https://oauth-stable.dev.lcip.org/".to_string(),
+ profile_url: "https://stable.dev.lcip.org/profile/".to_string(),
+ token_server_endpoint_url: "https://stable.dev.lcip.org/syncserver/token/1.0/sync/1.5"
+ .to_string(),
+ authorization_endpoint: "https://oauth-stable.dev.lcip.org/v1/authorization"
+ .to_string(),
+ issuer: "https://dev.lcip.org/".to_string(),
+ jwks_uri: "https://oauth-stable.dev.lcip.org/v1/jwks".to_string(),
+ token_endpoint: "https://stable.dev.lcip.org/auth/v1/oauth/token".to_string(),
+ introspection_endpoint: "https://oauth-stable.dev.lcip.org/v1/introspect".to_string(),
+ userinfo_endpoint: "https://stable.dev.lcip.org/profile/v1/profile".to_string(),
+ };
+
+ let config = Config {
+ content_url: "https://stable.dev.lcip.org/".to_string(),
+ remote_config: RefCell::new(Some(Arc::new(remote_config))),
+ client_id: "263ceaa5546dce83".to_string(),
+ redirect_uri: "https://127.0.0.1:8080".to_string(),
+ token_server_url_override: None,
+ };
+ assert_eq!(
+ config.auth_url_path("v1/account/keys").unwrap().to_string(),
+ "https://stable.dev.lcip.org/auth/v1/account/keys"
+ );
+ assert_eq!(
+ config.oauth_url_path("v1/token").unwrap().to_string(),
+ "https://oauth-stable.dev.lcip.org/v1/token"
+ );
+ assert_eq!(
+ config.profile_url_path("v1/profile").unwrap().to_string(),
+ "https://stable.dev.lcip.org/profile/v1/profile"
+ );
+ assert_eq!(
+ config.content_url_path("oauth/signin").unwrap().to_string(),
+ "https://stable.dev.lcip.org/oauth/signin"
+ );
+ assert_eq!(
+ config.token_server_endpoint_url().unwrap().to_string(),
+ "https://stable.dev.lcip.org/syncserver/token/1.0/sync/1.5"
+ );
+
+ assert_eq!(
+ config.token_endpoint().unwrap().to_string(),
+ "https://stable.dev.lcip.org/auth/v1/oauth/token"
+ );
+
+ assert_eq!(
+ config.introspection_endpoint().unwrap().to_string(),
+ "https://oauth-stable.dev.lcip.org/v1/introspect"
+ );
+ }
+
+ #[test]
+ fn test_tokenserver_url_override() {
+ let remote_config = RemoteConfig {
+ auth_url: "https://stable.dev.lcip.org/auth/".to_string(),
+ oauth_url: "https://oauth-stable.dev.lcip.org/".to_string(),
+ profile_url: "https://stable.dev.lcip.org/profile/".to_string(),
+ token_server_endpoint_url: "https://stable.dev.lcip.org/syncserver/token/1.0/sync/1.5"
+ .to_string(),
+ authorization_endpoint: "https://oauth-stable.dev.lcip.org/v1/authorization"
+ .to_string(),
+ issuer: "https://dev.lcip.org/".to_string(),
+ jwks_uri: "https://oauth-stable.dev.lcip.org/v1/jwks".to_string(),
+ token_endpoint: "https://stable.dev.lcip.org/auth/v1/oauth/token".to_string(),
+ introspection_endpoint: "https://oauth-stable.dev.lcip.org/v1/introspect".to_string(),
+ userinfo_endpoint: "https://stable.dev.lcip.org/profile/v1/profile".to_string(),
+ };
+
+ let mut config = Config {
+ content_url: "https://stable.dev.lcip.org/".to_string(),
+ remote_config: RefCell::new(Some(Arc::new(remote_config))),
+ client_id: "263ceaa5546dce83".to_string(),
+ redirect_uri: "https://127.0.0.1:8080".to_string(),
+ token_server_url_override: None,
+ };
+
+ config.override_token_server_url("https://foo.bar");
+
+ assert_eq!(
+ config.token_server_endpoint_url().unwrap().to_string(),
+ "https://foo.bar/"
+ );
+ }
+
+ #[test]
+ fn test_tokenserver_url_override_strips_sync_service_prefix() {
+ let remote_config = RemoteConfig {
+ auth_url: "https://stable.dev.lcip.org/auth/".to_string(),
+ oauth_url: "https://oauth-stable.dev.lcip.org/".to_string(),
+ profile_url: "https://stable.dev.lcip.org/profile/".to_string(),
+ token_server_endpoint_url: "https://stable.dev.lcip.org/syncserver/token/".to_string(),
+ authorization_endpoint: "https://oauth-stable.dev.lcip.org/v1/authorization"
+ .to_string(),
+ issuer: "https://dev.lcip.org/".to_string(),
+ jwks_uri: "https://oauth-stable.dev.lcip.org/v1/jwks".to_string(),
+ token_endpoint: "https://stable.dev.lcip.org/auth/v1/oauth/token".to_string(),
+ introspection_endpoint: "https://oauth-stable.dev.lcip.org/v1/introspect".to_string(),
+ userinfo_endpoint: "https://stable.dev.lcip.org/profile/v1/profile".to_string(),
+ };
+
+ let mut config = Config {
+ content_url: "https://stable.dev.lcip.org/".to_string(),
+ remote_config: RefCell::new(Some(Arc::new(remote_config))),
+ client_id: "263ceaa5546dce83".to_string(),
+ redirect_uri: "https://127.0.0.1:8080".to_string(),
+ token_server_url_override: None,
+ };
+
+ config.override_token_server_url("https://foo.bar/prefix/1.0/sync/1.5");
+ assert_eq!(
+ config.token_server_endpoint_url().unwrap().to_string(),
+ "https://foo.bar/prefix"
+ );
+
+ config.override_token_server_url("https://foo.bar/prefix-1.0/sync/1.5");
+ assert_eq!(
+ config.token_server_endpoint_url().unwrap().to_string(),
+ "https://foo.bar/prefix-1.0/sync/1.5"
+ );
+
+ config.override_token_server_url("https://foo.bar/1.0/sync/1.5/foobar");
+ assert_eq!(
+ config.token_server_endpoint_url().unwrap().to_string(),
+ "https://foo.bar/1.0/sync/1.5/foobar"
+ );
+ }
+}
diff --git a/third_party/rust/fxa-client/src/device.rs b/third_party/rust/fxa-client/src/device.rs
new file mode 100644
index 0000000000..e0a81ce154
--- /dev/null
+++ b/third_party/rust/fxa-client/src/device.rs
@@ -0,0 +1,798 @@
+/* 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/. */
+
+pub use crate::http_client::{
+ DeviceLocation as Location, DeviceType as Type, GetDeviceResponse as Device, PushSubscription,
+};
+use crate::{
+ commands,
+ error::*,
+ http_client::{
+ DeviceUpdateRequest, DeviceUpdateRequestBuilder, PendingCommand, UpdateDeviceResponse,
+ },
+ telemetry, util, CachedResponse, FirefoxAccount, IncomingDeviceCommand,
+};
+use serde_derive::*;
+use std::collections::{HashMap, HashSet};
+
+// An devices response is considered fresh for `DEVICES_FRESHNESS_THRESHOLD` ms.
+const DEVICES_FRESHNESS_THRESHOLD: u64 = 60_000; // 1 minute
+
+/// The reason we are fetching commands.
+#[derive(Clone, Copy)]
+pub enum CommandFetchReason {
+ /// We are polling in-case we've missed some.
+ Poll,
+ /// We got a push notification with the index of the message.
+ Push(u64),
+}
+
+impl FirefoxAccount {
+ /// Fetches the list of devices from the current account including
+ /// the current one.
+ ///
+ /// * `ignore_cache` - If set to true, bypass the in-memory cache
+ /// and fetch devices from the server.
+ pub fn get_devices(&mut self, ignore_cache: bool) -> Result<Vec<Device>> {
+ if let Some(d) = &self.devices_cache {
+ if !ignore_cache && util::now() < d.cached_at + DEVICES_FRESHNESS_THRESHOLD {
+ return Ok(d.response.clone());
+ }
+ }
+
+ let refresh_token = self.get_refresh_token()?;
+ let response = self.client.devices(&self.state.config, &refresh_token)?;
+
+ self.devices_cache = Some(CachedResponse {
+ response: response.clone(),
+ cached_at: util::now(),
+ etag: "".into(),
+ });
+
+ Ok(response)
+ }
+
+ pub fn get_current_device(&mut self) -> Result<Option<Device>> {
+ Ok(self
+ .get_devices(false)?
+ .into_iter()
+ .find(|d| d.is_current_device))
+ }
+
+ /// Replaces the internal set of "tracked" device capabilities by re-registering
+ /// new capabilities and returns a set of device commands to register with the
+ /// server.
+ fn register_capabilities(
+ &mut self,
+ capabilities: &[Capability],
+ ) -> Result<HashMap<String, String>> {
+ let mut capabilities_set = HashSet::new();
+ let mut commands = HashMap::new();
+ for capability in capabilities {
+ match capability {
+ Capability::SendTab => {
+ let send_tab_command = self.generate_send_tab_command_data()?;
+ commands.insert(
+ commands::send_tab::COMMAND_NAME.to_owned(),
+ send_tab_command.to_owned(),
+ );
+ capabilities_set.insert(Capability::SendTab);
+ }
+ }
+ }
+ // Remember what capabilities we've registered, so we don't register the same ones again.
+ // We write this to internal state before we've actually written the new device record,
+ // but roll it back if the server update fails.
+ self.state.device_capabilities = capabilities_set;
+ Ok(commands)
+ }
+
+ /// Initalizes our own device, most of the time this will be called right after logging-in
+ /// for the first time.
+ ///
+ /// **💾 This method alters the persisted account state.**
+ pub fn initialize_device(
+ &mut self,
+ name: &str,
+ device_type: Type,
+ capabilities: &[Capability],
+ ) -> Result<()> {
+ let commands = self.register_capabilities(capabilities)?;
+ let update = DeviceUpdateRequestBuilder::new()
+ .display_name(name)
+ .device_type(&device_type)
+ .available_commands(&commands)
+ .build();
+ let resp = self.update_device(update)?;
+ self.state.current_device_id = Option::from(resp.id);
+ Ok(())
+ }
+
+ /// Register a set of device capabilities against the current device.
+ ///
+ /// As the only capability is Send Tab now, its command is registered with the server.
+ /// Don't forget to also call this if the Sync Keys change as they
+ /// encrypt the Send Tab command data.
+ ///
+ /// **💾 This method alters the persisted account state.**
+ pub fn ensure_capabilities(&mut self, capabilities: &[Capability]) -> Result<()> {
+ // Don't re-register if we already have exactly those capabilities.
+ // Because of the way that our state object defaults `device_capabilities` to empty,
+ // we can't tell the difference between "have never registered capabilities" and
+ // have "explicitly registered an empty set of capabilities", so it's simpler to
+ // just always re-register in that case.
+ if !self.state.device_capabilities.is_empty()
+ && self.state.device_capabilities.len() == capabilities.len()
+ && capabilities
+ .iter()
+ .all(|c| self.state.device_capabilities.contains(c))
+ {
+ return Ok(());
+ }
+ let commands = self.register_capabilities(capabilities)?;
+ let update = DeviceUpdateRequestBuilder::new()
+ .available_commands(&commands)
+ .build();
+ let resp = self.update_device(update)?;
+ self.state.current_device_id = Option::from(resp.id);
+ Ok(())
+ }
+
+ /// Re-register the device capabilities, this should only be used internally.
+ pub(crate) fn reregister_current_capabilities(&mut self) -> Result<()> {
+ let current_capabilities: Vec<Capability> =
+ self.state.device_capabilities.clone().into_iter().collect();
+ let commands = self.register_capabilities(&current_capabilities)?;
+ let update = DeviceUpdateRequestBuilder::new()
+ .available_commands(&commands)
+ .build();
+ self.update_device(update)?;
+ Ok(())
+ }
+
+ pub(crate) fn invoke_command(
+ &self,
+ command: &str,
+ target: &Device,
+ payload: &serde_json::Value,
+ ) -> Result<()> {
+ let refresh_token = self.get_refresh_token()?;
+ self.client.invoke_command(
+ &self.state.config,
+ &refresh_token,
+ command,
+ &target.id,
+ payload,
+ )
+ }
+
+ /// Poll and parse any pending available command for our device.
+ /// This should be called semi-regularly as the main method of
+ /// commands delivery (push) can sometimes be unreliable on mobile devices.
+ /// Typically called even when a push notification is received, so that
+ /// any prior messages for which a push didn't arrive are still handled.
+ ///
+ /// **💾 This method alters the persisted account state.**
+ pub fn poll_device_commands(
+ &mut self,
+ reason: CommandFetchReason,
+ ) -> Result<Vec<IncomingDeviceCommand>> {
+ let last_command_index = self.state.last_handled_command.unwrap_or(0);
+ // We increment last_command_index by 1 because the server response includes the current index.
+ self.fetch_and_parse_commands(last_command_index + 1, None, reason)
+ }
+
+ /// Retrieve and parse a specific command designated by its index.
+ ///
+ /// **💾 This method alters the persisted account state.**
+ ///
+ /// Note that this should not be used if possible, as it does not correctly
+ /// handle missed messages. It's currently used only on iOS due to platform
+ /// restrictions (but we should still try and work out how to correctly
+ /// handle missed messages within those restrictions)
+ /// (What's wrong: if we get a push for tab-1 and a push for tab-3, and
+ /// between them I've never explicitly polled, I'll miss tab-2, even if I
+ /// try polling now)
+ pub fn ios_fetch_device_command(&mut self, index: u64) -> Result<IncomingDeviceCommand> {
+ let mut device_commands =
+ self.fetch_and_parse_commands(index, Some(1), CommandFetchReason::Push(index))?;
+ let device_command = device_commands
+ .pop()
+ .ok_or_else(|| ErrorKind::IllegalState("Index fetch came out empty."))?;
+ if !device_commands.is_empty() {
+ log::warn!("Index fetch resulted in more than 1 element");
+ }
+ Ok(device_command)
+ }
+
+ fn fetch_and_parse_commands(
+ &mut self,
+ index: u64,
+ limit: Option<u64>,
+ reason: CommandFetchReason,
+ ) -> Result<Vec<IncomingDeviceCommand>> {
+ let refresh_token = self.get_refresh_token()?;
+ let pending_commands =
+ self.client
+ .pending_commands(&self.state.config, refresh_token, index, limit)?;
+ if pending_commands.messages.is_empty() {
+ return Ok(Vec::new());
+ }
+ log::info!("Handling {} messages", pending_commands.messages.len());
+ let device_commands = self.parse_commands_messages(pending_commands.messages, reason)?;
+ self.state.last_handled_command = Some(pending_commands.index);
+ Ok(device_commands)
+ }
+
+ fn parse_commands_messages(
+ &mut self,
+ messages: Vec<PendingCommand>,
+ reason: CommandFetchReason,
+ ) -> Result<Vec<IncomingDeviceCommand>> {
+ let devices = self.get_devices(false)?;
+ let parsed_commands = messages
+ .into_iter()
+ .filter_map(|msg| match self.parse_command(msg, &devices, reason) {
+ Ok(device_command) => Some(device_command),
+ Err(e) => {
+ log::error!("Error while processing command: {}", e);
+ None
+ }
+ })
+ .collect();
+ Ok(parsed_commands)
+ }
+
+ fn parse_command(
+ &mut self,
+ command: PendingCommand,
+ devices: &[Device],
+ reason: CommandFetchReason,
+ ) -> Result<IncomingDeviceCommand> {
+ let telem_reason = match reason {
+ CommandFetchReason::Poll => telemetry::ReceivedReason::Poll,
+ CommandFetchReason::Push(index) if command.index < index => {
+ telemetry::ReceivedReason::PushMissed
+ }
+ _ => telemetry::ReceivedReason::Push,
+ };
+ let command_data = command.data;
+ let sender = command_data
+ .sender
+ .and_then(|s| devices.iter().find(|i| i.id == s).cloned());
+ match command_data.command.as_str() {
+ commands::send_tab::COMMAND_NAME => {
+ self.handle_send_tab_command(sender, command_data.payload, telem_reason)
+ }
+ _ => Err(ErrorKind::UnknownCommand(command_data.command).into()),
+ }
+ }
+
+ pub fn set_device_name(&mut self, name: &str) -> Result<UpdateDeviceResponse> {
+ let update = DeviceUpdateRequestBuilder::new().display_name(name).build();
+ self.update_device(update)
+ }
+
+ pub fn clear_device_name(&mut self) -> Result<UpdateDeviceResponse> {
+ let update = DeviceUpdateRequestBuilder::new()
+ .clear_display_name()
+ .build();
+ self.update_device(update)
+ }
+
+ pub fn set_push_subscription(
+ &mut self,
+ push_subscription: &PushSubscription,
+ ) -> Result<UpdateDeviceResponse> {
+ let update = DeviceUpdateRequestBuilder::new()
+ .push_subscription(&push_subscription)
+ .build();
+ self.update_device(update)
+ }
+
+ // TODO: this currently overwrites every other registered command
+ // for the device because the server does not have a `PATCH commands`
+ // endpoint yet.
+ #[allow(dead_code)]
+ pub(crate) fn register_command(
+ &mut self,
+ command: &str,
+ value: &str,
+ ) -> Result<UpdateDeviceResponse> {
+ self.state.device_capabilities.clear();
+ let mut commands = HashMap::new();
+ commands.insert(command.to_owned(), value.to_owned());
+ let update = DeviceUpdateRequestBuilder::new()
+ .available_commands(&commands)
+ .build();
+ self.update_device(update)
+ }
+
+ // TODO: this currently deletes every command registered for the device
+ // because the server does not have a `PATCH commands` endpoint yet.
+ #[allow(dead_code)]
+ pub(crate) fn unregister_command(&mut self, _: &str) -> Result<UpdateDeviceResponse> {
+ self.state.device_capabilities.clear();
+ let commands = HashMap::new();
+ let update = DeviceUpdateRequestBuilder::new()
+ .available_commands(&commands)
+ .build();
+ self.update_device(update)
+ }
+
+ #[allow(dead_code)]
+ pub(crate) fn clear_commands(&mut self) -> Result<UpdateDeviceResponse> {
+ self.state.device_capabilities.clear();
+ let update = DeviceUpdateRequestBuilder::new()
+ .clear_available_commands()
+ .build();
+ self.update_device(update)
+ }
+
+ pub(crate) fn replace_device(
+ &mut self,
+ display_name: &str,
+ device_type: &Type,
+ push_subscription: &Option<PushSubscription>,
+ commands: &HashMap<String, String>,
+ ) -> Result<UpdateDeviceResponse> {
+ self.state.device_capabilities.clear();
+ let mut builder = DeviceUpdateRequestBuilder::new()
+ .display_name(display_name)
+ .device_type(device_type)
+ .available_commands(commands);
+ if let Some(push_subscription) = push_subscription {
+ builder = builder.push_subscription(push_subscription)
+ }
+ self.update_device(builder.build())
+ }
+
+ fn update_device(&mut self, update: DeviceUpdateRequest<'_>) -> Result<UpdateDeviceResponse> {
+ let refresh_token = self.get_refresh_token()?;
+ let res = self
+ .client
+ .update_device(&self.state.config, refresh_token, update);
+ match res {
+ Ok(resp) => Ok(resp),
+ Err(err) => {
+ // We failed to write an update to the server.
+ // Clear local state so that we'll be sure to retry later.
+ self.state.device_capabilities.clear();
+ Err(err)
+ }
+ }
+ }
+
+ /// Retrieve the current device id from state
+ pub fn get_current_device_id(&mut self) -> Result<String> {
+ match self.state.current_device_id {
+ Some(ref device_id) => Ok(device_id.to_string()),
+ None => Err(ErrorKind::NoCurrentDeviceId.into()),
+ }
+ }
+}
+
+#[derive(Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
+pub enum Capability {
+ SendTab,
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::http_client::*;
+ use crate::oauth::RefreshToken;
+ use crate::scoped_keys::ScopedKey;
+ use crate::Config;
+ use std::collections::HashSet;
+ use std::sync::Arc;
+
+ fn setup() -> FirefoxAccount {
+ // I'd love to be able to configure a single mocked client here,
+ // but can't work out how to do that within the typesystem.
+ 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(),
+ });
+ fxa.state.scoped_keys.insert("https://identity.mozilla.com/apps/oldsync".to_string(), ScopedKey {
+ kty: "oct".to_string(),
+ scope: "https://identity.mozilla.com/apps/oldsync".to_string(),
+ k: "kMtwpVC0ZaYFJymPza8rXK_0CgCp3KMwRStwGfBRBDtL6hXRDVJgQFaoOQ2dimw0Bko5WVv2gNTy7RX5zFYZHg".to_string(),
+ kid: "1542236016429-Ox1FbJfFfwTe5t-xq4v2hQ".to_string(),
+ });
+ fxa
+ }
+
+ #[test]
+ fn test_ensure_capabilities_does_not_hit_the_server_if_nothing_has_changed() {
+ let mut fxa = setup();
+
+ // Do an initial call to ensure_capabilities().
+ let mut client = FxAClientMock::new();
+ client
+ .expect_update_device(
+ mockiato::Argument::any,
+ |arg| arg.partial_eq("refreshtok"),
+ mockiato::Argument::any,
+ )
+ .returns_once(Ok(UpdateDeviceResponse {
+ id: "device1".to_string(),
+ display_name: "".to_string(),
+ device_type: DeviceType::Desktop,
+ push_subscription: None,
+ available_commands: HashMap::default(),
+ push_endpoint_expired: false,
+ }));
+ fxa.set_client(Arc::new(client));
+ fxa.ensure_capabilities(&[Capability::SendTab]).unwrap();
+ let saved = fxa.to_json().unwrap();
+
+ // Do another call with the same capabilities.
+ // The FxAClientMock will panic if it tries to hit the network again, which it shouldn't.
+ fxa.ensure_capabilities(&[Capability::SendTab]).unwrap();
+
+ // Do another call with the same capabilities , after restoring from disk.
+ // The FxAClientMock will panic if it tries to hit the network, which it shouldn't.
+ let mut restored = FirefoxAccount::from_json(&saved).unwrap();
+ restored.set_client(Arc::new(FxAClientMock::new()));
+ restored
+ .ensure_capabilities(&[Capability::SendTab])
+ .unwrap();
+ }
+
+ #[test]
+ fn test_ensure_capabilities_updates_the_server_if_capabilities_increase() {
+ let mut fxa = setup();
+
+ // Do an initial call to ensure_capabilities().
+ let mut client = FxAClientMock::new();
+ client
+ .expect_update_device(
+ mockiato::Argument::any,
+ |arg| arg.partial_eq("refreshtok"),
+ mockiato::Argument::any,
+ )
+ .returns_once(Ok(UpdateDeviceResponse {
+ id: "device1".to_string(),
+ display_name: "".to_string(),
+ device_type: DeviceType::Desktop,
+ push_subscription: None,
+ available_commands: HashMap::default(),
+ push_endpoint_expired: false,
+ }));
+ fxa.set_client(Arc::new(client));
+
+ fxa.ensure_capabilities(&[]).unwrap();
+ let saved = fxa.to_json().unwrap();
+
+ // Do another call with reduced capabilities.
+ let mut client = FxAClientMock::new();
+ client
+ .expect_update_device(
+ mockiato::Argument::any,
+ |arg| arg.partial_eq("refreshtok"),
+ mockiato::Argument::any,
+ )
+ .returns_once(Ok(UpdateDeviceResponse {
+ id: "device1".to_string(),
+ display_name: "".to_string(),
+ device_type: DeviceType::Desktop,
+ push_subscription: None,
+ available_commands: HashMap::default(),
+ push_endpoint_expired: false,
+ }));
+ fxa.set_client(Arc::new(client));
+
+ fxa.ensure_capabilities(&[Capability::SendTab]).unwrap();
+
+ // Do another call with the same capabilities , after restoring from disk.
+ // The FxAClientMock will panic if it tries to hit the network, which it shouldn't.
+ let mut restored = FirefoxAccount::from_json(&saved).unwrap();
+ let mut client = FxAClientMock::new();
+ client
+ .expect_update_device(
+ mockiato::Argument::any,
+ |arg| arg.partial_eq("refreshtok"),
+ mockiato::Argument::any,
+ )
+ .returns_once(Ok(UpdateDeviceResponse {
+ id: "device1".to_string(),
+ display_name: "".to_string(),
+ device_type: DeviceType::Desktop,
+ push_subscription: None,
+ available_commands: HashMap::default(),
+ push_endpoint_expired: false,
+ }));
+ restored.set_client(Arc::new(client));
+
+ restored
+ .ensure_capabilities(&[Capability::SendTab])
+ .unwrap();
+ }
+
+ #[test]
+ fn test_ensure_capabilities_updates_the_server_if_capabilities_reduce() {
+ let mut fxa = setup();
+
+ // Do an initial call to ensure_capabilities().
+ let mut client = FxAClientMock::new();
+ client
+ .expect_update_device(
+ mockiato::Argument::any,
+ |arg| arg.partial_eq("refreshtok"),
+ mockiato::Argument::any,
+ )
+ .returns_once(Ok(UpdateDeviceResponse {
+ id: "device1".to_string(),
+ display_name: "".to_string(),
+ device_type: DeviceType::Desktop,
+ push_subscription: None,
+ available_commands: HashMap::default(),
+ push_endpoint_expired: false,
+ }));
+ fxa.set_client(Arc::new(client));
+
+ fxa.ensure_capabilities(&[Capability::SendTab]).unwrap();
+ let saved = fxa.to_json().unwrap();
+
+ // Do another call with reduced capabilities.
+ let mut client = FxAClientMock::new();
+ client
+ .expect_update_device(
+ mockiato::Argument::any,
+ |arg| arg.partial_eq("refreshtok"),
+ mockiato::Argument::any,
+ )
+ .returns_once(Ok(UpdateDeviceResponse {
+ id: "device1".to_string(),
+ display_name: "".to_string(),
+ device_type: DeviceType::Desktop,
+ push_subscription: None,
+ available_commands: HashMap::default(),
+ push_endpoint_expired: false,
+ }));
+ fxa.set_client(Arc::new(client));
+
+ fxa.ensure_capabilities(&[]).unwrap();
+
+ // Do another call with the same capabilities , after restoring from disk.
+ // The FxAClientMock will panic if it tries to hit the network, which it shouldn't.
+ let mut restored = FirefoxAccount::from_json(&saved).unwrap();
+ let mut client = FxAClientMock::new();
+ client
+ .expect_update_device(
+ mockiato::Argument::any,
+ |arg| arg.partial_eq("refreshtok"),
+ mockiato::Argument::any,
+ )
+ .returns_once(Ok(UpdateDeviceResponse {
+ id: "device1".to_string(),
+ display_name: "".to_string(),
+ device_type: DeviceType::Desktop,
+ push_subscription: None,
+ available_commands: HashMap::default(),
+ push_endpoint_expired: false,
+ }));
+ restored.set_client(Arc::new(client));
+
+ restored.ensure_capabilities(&[]).unwrap();
+ }
+
+ #[test]
+ fn test_ensure_capabilities_will_reregister_after_new_login_flow() {
+ let mut fxa = setup();
+
+ // Do an initial call to ensure_capabilities().
+ let mut client = FxAClientMock::new();
+ client
+ .expect_update_device(
+ mockiato::Argument::any,
+ |arg| arg.partial_eq("refreshtok"),
+ mockiato::Argument::any,
+ )
+ .returns_once(Ok(UpdateDeviceResponse {
+ id: "device1".to_string(),
+ display_name: "".to_string(),
+ device_type: DeviceType::Desktop,
+ push_subscription: None,
+ available_commands: HashMap::default(),
+ push_endpoint_expired: false,
+ }));
+ fxa.set_client(Arc::new(client));
+ fxa.ensure_capabilities(&[Capability::SendTab]).unwrap();
+
+ // Fake that we've completed a new login flow.
+ // (which annoyingly makes a bunch of network requests)
+ let mut client = FxAClientMock::new();
+ client
+ .expect_destroy_access_token(mockiato::Argument::any, mockiato::Argument::any)
+ .returns_once(Err(ErrorKind::RemoteError {
+ code: 500,
+ errno: 999,
+ error: "server error".to_string(),
+ message: "this will be ignored anyway".to_string(),
+ info: "".to_string(),
+ }
+ .into()));
+ client
+ .expect_devices(mockiato::Argument::any, mockiato::Argument::any)
+ .returns_once(Err(ErrorKind::RemoteError {
+ code: 500,
+ errno: 999,
+ error: "server error".to_string(),
+ message: "this will be ignored anyway".to_string(),
+ info: "".to_string(),
+ }
+ .into()));
+ client
+ .expect_destroy_refresh_token(mockiato::Argument::any, mockiato::Argument::any)
+ .returns_once(Err(ErrorKind::RemoteError {
+ code: 500,
+ errno: 999,
+ error: "server error".to_string(),
+ message: "this will be ignored anyway".to_string(),
+ info: "".to_string(),
+ }
+ .into()));
+ fxa.set_client(Arc::new(client));
+
+ fxa.handle_oauth_response(
+ OAuthTokenResponse {
+ keys_jwe: None,
+ refresh_token: Some("newRefreshTok".to_string()),
+ session_token: None,
+ expires_in: 12345,
+ scope: "profile".to_string(),
+ access_token: "accesstok".to_string(),
+ },
+ None,
+ )
+ .unwrap();
+
+ assert!(fxa.state.device_capabilities.is_empty());
+
+ // Do another call with the same capabilities.
+ // It should re-register, as server-side state may have changed.
+ let mut client = FxAClientMock::new();
+ client
+ .expect_update_device(
+ mockiato::Argument::any,
+ |arg| arg.partial_eq("newRefreshTok"),
+ mockiato::Argument::any,
+ )
+ .returns_once(Ok(UpdateDeviceResponse {
+ id: "device1".to_string(),
+ display_name: "".to_string(),
+ device_type: DeviceType::Desktop,
+ push_subscription: None,
+ available_commands: HashMap::default(),
+ push_endpoint_expired: false,
+ }));
+ fxa.set_client(Arc::new(client));
+ fxa.ensure_capabilities(&[Capability::SendTab]).unwrap();
+ }
+
+ #[test]
+ fn test_ensure_capabilities_updates_the_server_if_previous_attempt_failed() {
+ let mut fxa = setup();
+
+ // Do an initial call to ensure_capabilities(), that fails.
+ let mut client = FxAClientMock::new();
+ client
+ .expect_update_device(
+ mockiato::Argument::any,
+ |arg| arg.partial_eq("refreshtok"),
+ mockiato::Argument::any,
+ )
+ .returns_once(Err(ErrorKind::RemoteError {
+ code: 500,
+ errno: 999,
+ error: "server error".to_string(),
+ message: "this will be ignored anyway".to_string(),
+ info: "".to_string(),
+ }
+ .into()));
+ fxa.set_client(Arc::new(client));
+
+ fxa.ensure_capabilities(&[Capability::SendTab]).unwrap_err();
+
+ // Do another call, which should re-attempt the update.
+ let mut client = FxAClientMock::new();
+ client
+ .expect_update_device(
+ mockiato::Argument::any,
+ |arg| arg.partial_eq("refreshtok"),
+ mockiato::Argument::any,
+ )
+ .returns_once(Ok(UpdateDeviceResponse {
+ id: "device1".to_string(),
+ display_name: "".to_string(),
+ device_type: DeviceType::Desktop,
+ push_subscription: None,
+ available_commands: HashMap::default(),
+ push_endpoint_expired: false,
+ }));
+ fxa.set_client(Arc::new(client));
+
+ fxa.ensure_capabilities(&[Capability::SendTab]).unwrap();
+ }
+
+ #[test]
+ fn test_get_devices() {
+ let mut fxa = setup();
+ let mut client = FxAClientMock::new();
+ client
+ .expect_devices(mockiato::Argument::any, mockiato::Argument::any)
+ .times(1)
+ .returns_once(Ok(vec![Device {
+ common: DeviceResponseCommon {
+ id: "device1".into(),
+ display_name: "".to_string(),
+ device_type: DeviceType::Desktop,
+ push_subscription: None,
+ available_commands: HashMap::new(),
+ push_endpoint_expired: true,
+ },
+ is_current_device: true,
+ location: DeviceLocation {
+ city: None,
+ country: None,
+ state: None,
+ state_code: None,
+ },
+ last_access_time: None,
+ }]));
+
+ fxa.set_client(Arc::new(client));
+ assert!(fxa.devices_cache.is_none());
+
+ assert!(fxa.get_devices(false).is_ok());
+ assert!(fxa.devices_cache.is_some());
+
+ let cache = fxa.devices_cache.clone().unwrap();
+ assert!(!cache.response.is_empty());
+ assert!(cache.cached_at > 0);
+
+ let cached_devices = cache.response;
+ assert_eq!(cached_devices[0].id, "device1".to_string());
+
+ // Check that a second call to get_devices doesn't hit the server
+ assert!(fxa.get_devices(false).is_ok());
+ assert!(fxa.devices_cache.is_some());
+
+ let cache2 = fxa.devices_cache.unwrap();
+ let cached_devices2 = cache2.response;
+
+ assert_eq!(cache.cached_at, cache2.cached_at);
+ assert_eq!(cached_devices.len(), cached_devices2.len());
+ assert_eq!(cached_devices[0].id, cached_devices2[0].id);
+ }
+
+ #[test]
+ fn test_get_devices_network_errors() {
+ let mut fxa = setup();
+ let mut client = FxAClientMock::new();
+ client
+ .expect_devices(mockiato::Argument::any, mockiato::Argument::any)
+ .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.devices_cache.is_none());
+
+ let res = fxa.get_devices(false);
+
+ assert!(res.is_err());
+ assert!(fxa.devices_cache.is_none());
+ }
+}
diff --git a/third_party/rust/fxa-client/src/error.rs b/third_party/rust/fxa-client/src/error.rs
new file mode 100644
index 0000000000..8b2653b4df
--- /dev/null
+++ b/third_party/rust/fxa-client/src/error.rs
@@ -0,0 +1,152 @@
+/* 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/. */
+
+use rc_crypto::hawk;
+use std::string;
+#[derive(Debug, thiserror::Error)]
+pub enum ErrorKind {
+ #[error("Server asked the client to back off, please wait {0} seconds to try again")]
+ BackoffError(u64),
+
+ #[error("Unknown OAuth State")]
+ UnknownOAuthState,
+
+ #[error("Multiple OAuth scopes requested")]
+ MultipleScopesRequested,
+
+ #[error("No cached token for scope {0}")]
+ NoCachedToken(String),
+
+ #[error("No cached scoped keys for scope {0}")]
+ NoScopedKey(String),
+
+ #[error("No stored refresh token")]
+ NoRefreshToken,
+
+ #[error("No stored session token")]
+ NoSessionToken,
+
+ #[error("No stored migration data")]
+ NoMigrationData,
+
+ #[error("No stored current device id")]
+ NoCurrentDeviceId,
+
+ #[error("Device target is unknown (Device ID: {0})")]
+ UnknownTargetDevice(String),
+
+ #[error("Unrecoverable server error {0}")]
+ UnrecoverableServerError(&'static str),
+
+ #[error("Illegal state: {0}")]
+ IllegalState(&'static str),
+
+ #[error("Unknown command: {0}")]
+ UnknownCommand(String),
+
+ #[error("Send Tab diagnosis error: {0}")]
+ SendTabDiagnosisError(&'static str),
+
+ #[error("Cannot xor arrays with different lengths: {0} and {1}")]
+ XorLengthMismatch(usize, usize),
+
+ #[error("Origin mismatch")]
+ OriginMismatch,
+
+ #[error("Remote key and local key mismatch")]
+ MismatchedKeys,
+
+ #[error("Could not find a suitable anon_id key")]
+ NoAnonIdKey,
+
+ #[error("Client: {0} is not allowed to request scope: {1}")]
+ ScopeNotAllowed(String, String),
+
+ #[error("Unsupported command: {0}")]
+ UnsupportedCommand(&'static str),
+
+ #[error("Missing URL parameter: {0}")]
+ MissingUrlParameter(&'static str),
+
+ #[error("Null pointer passed to FFI")]
+ NullPointer,
+
+ #[error("Invalid buffer length: {0}")]
+ InvalidBufferLength(i32),
+
+ #[error("Too many calls to auth introspection endpoint")]
+ AuthCircuitBreakerError,
+
+ #[error("Remote server error: '{code}' '{errno}' '{error}' '{message}' '{info}'")]
+ RemoteError {
+ code: u64,
+ errno: u64,
+ error: String,
+ message: String,
+ info: String,
+ },
+
+ // Basically reimplement error_chain's foreign_links. (Ugh, this sucks).
+ #[error("Crypto/NSS error: {0}")]
+ CryptoError(#[from] rc_crypto::Error),
+
+ #[error("http-ece encryption error: {0}")]
+ EceError(#[from] rc_crypto::ece::Error),
+
+ #[error("Hex decode error: {0}")]
+ HexDecodeError(#[from] hex::FromHexError),
+
+ #[error("Base64 decode error: {0}")]
+ Base64Decode(#[from] base64::DecodeError),
+
+ #[error("JSON error: {0}")]
+ JsonError(#[from] serde_json::Error),
+
+ #[error("JWCrypto error: {0}")]
+ JwCryptoError(#[from] jwcrypto::JwCryptoError),
+
+ #[error("UTF8 decode error: {0}")]
+ UTF8DecodeError(#[from] string::FromUtf8Error),
+
+ #[error("Network error: {0}")]
+ RequestError(#[from] viaduct::Error),
+
+ #[error("Malformed URL error: {0}")]
+ MalformedUrl(#[from] url::ParseError),
+
+ #[error("Unexpected HTTP status: {0}")]
+ UnexpectedStatus(#[from] viaduct::UnexpectedStatus),
+
+ #[error("Sync15 error: {0}")]
+ SyncError(#[from] sync15::Error),
+
+ #[error("HAWK error: {0}")]
+ HawkError(#[from] hawk::Error),
+
+ #[error("Protobuf decode error: {0}")]
+ ProtobufDecodeError(#[from] prost::DecodeError),
+}
+
+error_support::define_error! {
+ ErrorKind {
+ (CryptoError, rc_crypto::Error),
+ (EceError, rc_crypto::ece::Error),
+ (HexDecodeError, hex::FromHexError),
+ (Base64Decode, base64::DecodeError),
+ (JsonError, serde_json::Error),
+ (JwCryptoError, jwcrypto::JwCryptoError),
+ (UTF8DecodeError, std::string::FromUtf8Error),
+ (RequestError, viaduct::Error),
+ (UnexpectedStatus, viaduct::UnexpectedStatus),
+ (MalformedUrl, url::ParseError),
+ (SyncError, sync15::Error),
+ (ProtobufDecodeError, prost::DecodeError),
+ }
+}
+
+error_support::define_error_conversions! {
+ ErrorKind {
+ (HawkError, hawk::Error),
+ }
+}
diff --git a/third_party/rust/fxa-client/src/ffi.rs b/third_party/rust/fxa-client/src/ffi.rs
new file mode 100644
index 0000000000..d5fbc91b29
--- /dev/null
+++ b/third_party/rust/fxa-client/src/ffi.rs
@@ -0,0 +1,345 @@
+/* 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/. */
+
+//! This module implement the traits and some types that make the FFI code easier to manage.
+//!
+//! Note that the FxA FFI is older than the other FFIs in application-services, and has (direct,
+//! low-level) bindings that live in the mozilla-mobile/android-components repo. As a result, it's a
+//! bit harder to change (anything breaking the ABI requires careful synchronization of updates
+//! across two repos), and doesn't follow all the same conventions that are followed by the other
+//! FFIs.
+//!
+//! None of this is that bad in practice, but much of it is not ideal.
+
+pub use crate::oauth::{AuthorizationPKCEParams, AuthorizationParameters, MetricsParams};
+use crate::{
+ commands,
+ device::{Capability as DeviceCapability, Device, PushSubscription, Type as DeviceType},
+ msg_types, send_tab, AccessTokenInfo, AccountEvent, Error, ErrorKind, IncomingDeviceCommand,
+ IntrospectInfo, Profile, Result, ScopedKey,
+};
+use ffi_support::{
+ implement_into_ffi_by_delegation, implement_into_ffi_by_protobuf, ErrorCode, ExternError,
+};
+
+pub mod error_codes {
+ // Note: -1 and 0 (panic and success) codes are reserved by the ffi-support library
+
+ /// Catch-all error code used for anything that's not a panic or covered by AUTHENTICATION.
+ pub const OTHER: i32 = 1;
+
+ /// Used by `ErrorKind::NoCachedTokens`, `ErrorKind::NoScopedKey`
+ /// and `ErrorKind::RemoteError`'s where `code == 401`.
+ pub const AUTHENTICATION: i32 = 2;
+
+ /// Code for network errors.
+ pub const NETWORK: i32 = 3;
+}
+
+/// # Safety
+/// data is a raw pointer to the protobuf data
+/// get_buffer will return an error if the length is invalid,
+/// or if the pointer is a null pointer
+pub unsafe fn from_protobuf_ptr<T, F: prost::Message + Default + Into<T>>(
+ data: *const u8,
+ len: i32,
+) -> Result<T> {
+ let buffer = get_buffer(data, len)?;
+ let item: Result<F, _> = prost::Message::decode(buffer);
+ item.map(|inner| inner.into()).map_err(|e| e.into())
+}
+
+fn get_code(err: &Error) -> ErrorCode {
+ match err.kind() {
+ ErrorKind::RemoteError { code: 401, .. }
+ | ErrorKind::NoRefreshToken
+ | ErrorKind::NoScopedKey(_)
+ | ErrorKind::NoCachedToken(_) => {
+ log::warn!("Authentication error: {:?}", err);
+ ErrorCode::new(error_codes::AUTHENTICATION)
+ }
+ ErrorKind::RequestError(_) => {
+ log::warn!("Network error: {:?}", err);
+ ErrorCode::new(error_codes::NETWORK)
+ }
+ _ => {
+ log::warn!("Unexpected error: {:?}", err);
+ ErrorCode::new(error_codes::OTHER)
+ }
+ }
+}
+
+impl From<Error> for ExternError {
+ fn from(err: Error) -> ExternError {
+ ExternError::new_error(get_code(&err), err.to_string())
+ }
+}
+
+impl From<AccessTokenInfo> for msg_types::AccessTokenInfo {
+ fn from(a: AccessTokenInfo) -> Self {
+ msg_types::AccessTokenInfo {
+ scope: a.scope,
+ token: a.token,
+ key: a.key.map(Into::into),
+ expires_at: a.expires_at,
+ }
+ }
+}
+
+impl From<IntrospectInfo> for msg_types::IntrospectInfo {
+ fn from(a: IntrospectInfo) -> Self {
+ msg_types::IntrospectInfo { active: a.active }
+ }
+}
+
+impl From<ScopedKey> for msg_types::ScopedKey {
+ fn from(sk: ScopedKey) -> Self {
+ msg_types::ScopedKey {
+ kty: sk.kty,
+ scope: sk.scope,
+ k: sk.k,
+ kid: sk.kid,
+ }
+ }
+}
+
+impl From<Profile> for msg_types::Profile {
+ fn from(p: Profile) -> Self {
+ Self {
+ avatar: Some(p.avatar),
+ avatar_default: Some(p.avatar_default),
+ display_name: p.display_name,
+ email: Some(p.email),
+ uid: Some(p.uid),
+ }
+ }
+}
+
+fn command_to_capability(command: &str) -> Option<msg_types::device::Capability> {
+ match command {
+ commands::send_tab::COMMAND_NAME => Some(msg_types::device::Capability::SendTab),
+ _ => None,
+ }
+}
+
+impl From<Device> for msg_types::Device {
+ fn from(d: Device) -> Self {
+ let capabilities = d
+ .available_commands
+ .keys()
+ .filter_map(|c| command_to_capability(c).map(|cc| cc as i32))
+ .collect();
+ Self {
+ id: d.common.id,
+ display_name: d.common.display_name,
+ r#type: Into::<msg_types::device::Type>::into(d.common.device_type) as i32,
+ push_subscription: d.common.push_subscription.map(Into::into),
+ push_endpoint_expired: d.common.push_endpoint_expired,
+ is_current_device: d.is_current_device,
+ last_access_time: d.last_access_time,
+ capabilities,
+ }
+ }
+}
+
+impl From<DeviceType> for msg_types::device::Type {
+ fn from(t: DeviceType) -> Self {
+ match t {
+ DeviceType::Desktop => msg_types::device::Type::Desktop,
+ DeviceType::Mobile => msg_types::device::Type::Mobile,
+ DeviceType::Tablet => msg_types::device::Type::Tablet,
+ DeviceType::VR => msg_types::device::Type::Vr,
+ DeviceType::TV => msg_types::device::Type::Tv,
+ DeviceType::Unknown => msg_types::device::Type::Unknown,
+ }
+ }
+}
+
+impl From<msg_types::device::Type> for DeviceType {
+ fn from(t: msg_types::device::Type) -> Self {
+ match t {
+ msg_types::device::Type::Desktop => DeviceType::Desktop,
+ msg_types::device::Type::Mobile => DeviceType::Mobile,
+ msg_types::device::Type::Tablet => DeviceType::Tablet,
+ msg_types::device::Type::Vr => DeviceType::VR,
+ msg_types::device::Type::Tv => DeviceType::TV,
+ msg_types::device::Type::Unknown => DeviceType::Unknown,
+ }
+ }
+}
+
+impl From<PushSubscription> for msg_types::device::PushSubscription {
+ fn from(p: PushSubscription) -> Self {
+ Self {
+ endpoint: p.endpoint,
+ public_key: p.public_key,
+ auth_key: p.auth_key,
+ }
+ }
+}
+
+impl From<AccountEvent> for msg_types::AccountEvent {
+ fn from(e: AccountEvent) -> Self {
+ match e {
+ AccountEvent::IncomingDeviceCommand(command) => Self {
+ r#type: msg_types::account_event::AccountEventType::IncomingDeviceCommand as i32,
+ data: Some(msg_types::account_event::Data::DeviceCommand(
+ (*command).into(),
+ )),
+ },
+ AccountEvent::ProfileUpdated => Self {
+ r#type: msg_types::account_event::AccountEventType::ProfileUpdated as i32,
+ data: None,
+ },
+ AccountEvent::AccountAuthStateChanged => Self {
+ r#type: msg_types::account_event::AccountEventType::AccountAuthStateChanged as i32,
+ data: None,
+ },
+ AccountEvent::AccountDestroyed => Self {
+ r#type: msg_types::account_event::AccountEventType::AccountDestroyed as i32,
+ data: None,
+ },
+ AccountEvent::DeviceConnected { device_name } => Self {
+ r#type: msg_types::account_event::AccountEventType::DeviceConnected as i32,
+ data: Some(msg_types::account_event::Data::DeviceConnectedName(
+ device_name,
+ )),
+ },
+ AccountEvent::DeviceDisconnected {
+ device_id,
+ is_local_device,
+ } => Self {
+ r#type: msg_types::account_event::AccountEventType::DeviceDisconnected as i32,
+ data: Some(msg_types::account_event::Data::DeviceDisconnectedData(
+ msg_types::account_event::DeviceDisconnectedData {
+ device_id,
+ is_local_device,
+ },
+ )),
+ },
+ }
+ }
+}
+
+impl From<IncomingDeviceCommand> for msg_types::IncomingDeviceCommand {
+ fn from(data: IncomingDeviceCommand) -> Self {
+ match data {
+ IncomingDeviceCommand::TabReceived { sender, payload } => Self {
+ r#type: msg_types::incoming_device_command::IncomingDeviceCommandType::TabReceived
+ as i32,
+ data: Some(msg_types::incoming_device_command::Data::TabReceivedData(
+ msg_types::incoming_device_command::SendTabData {
+ from: sender.map(Into::into),
+ entries: payload.entries.into_iter().map(Into::into).collect(),
+ },
+ )),
+ },
+ }
+ }
+}
+
+impl From<send_tab::TabHistoryEntry>
+ for msg_types::incoming_device_command::send_tab_data::TabHistoryEntry
+{
+ fn from(data: send_tab::TabHistoryEntry) -> Self {
+ Self {
+ title: data.title,
+ url: data.url,
+ }
+ }
+}
+
+impl From<msg_types::device::Capability> for DeviceCapability {
+ fn from(cap: msg_types::device::Capability) -> Self {
+ match cap {
+ msg_types::device::Capability::SendTab => DeviceCapability::SendTab,
+ }
+ }
+}
+
+impl DeviceCapability {
+ /// # Safety
+ /// Deref pointer thus unsafe
+ pub unsafe fn from_protobuf_array_ptr(ptr: *const u8, len: i32) -> Result<Vec<Self>> {
+ let buffer = get_buffer(ptr, len)?;
+ let capabilities: Result<msg_types::Capabilities, _> = prost::Message::decode(buffer);
+ Ok(capabilities
+ .map(|cc| cc.to_capabilities_vec())
+ .unwrap_or_else(|_| vec![]))
+ }
+}
+
+impl msg_types::Capabilities {
+ pub fn to_capabilities_vec(&self) -> Vec<DeviceCapability> {
+ self.capability
+ .iter()
+ .map(|c| msg_types::device::Capability::from_i32(*c).unwrap().into())
+ .collect()
+ }
+}
+
+unsafe fn get_buffer<'a>(data: *const u8, len: i32) -> Result<&'a [u8]> {
+ match len {
+ len if len < 0 => Err(ErrorKind::InvalidBufferLength(len).into()),
+ 0 => Ok(&[]),
+ _ => {
+ if data.is_null() {
+ return Err(ErrorKind::NullPointer.into());
+ }
+ Ok(std::slice::from_raw_parts(data, len as usize))
+ }
+ }
+}
+
+impl From<msg_types::AuthorizationParams> for AuthorizationParameters {
+ fn from(proto_params: msg_types::AuthorizationParams) -> Self {
+ Self {
+ client_id: proto_params.client_id,
+ scope: proto_params
+ .scope
+ .split_whitespace()
+ .map(|s| s.to_string())
+ .collect(),
+ state: proto_params.state,
+ access_type: proto_params.access_type,
+ pkce_params: proto_params
+ .pkce_params
+ .map(|pkce_params| pkce_params.into()),
+ keys_jwk: proto_params.keys_jwk,
+ }
+ }
+}
+
+impl From<msg_types::MetricsParams> for MetricsParams {
+ fn from(proto_metrics_params: msg_types::MetricsParams) -> Self {
+ Self {
+ parameters: proto_metrics_params.parameters,
+ }
+ }
+}
+
+impl From<msg_types::AuthorizationPkceParams> for AuthorizationPKCEParams {
+ fn from(proto_key_params: msg_types::AuthorizationPkceParams) -> Self {
+ Self {
+ code_challenge: proto_key_params.code_challenge,
+ code_challenge_method: proto_key_params.code_challenge_method,
+ }
+ }
+}
+
+implement_into_ffi_by_protobuf!(msg_types::Profile);
+implement_into_ffi_by_delegation!(Profile, msg_types::Profile);
+implement_into_ffi_by_protobuf!(msg_types::AccessTokenInfo);
+implement_into_ffi_by_delegation!(AccessTokenInfo, msg_types::AccessTokenInfo);
+implement_into_ffi_by_protobuf!(msg_types::IntrospectInfo);
+implement_into_ffi_by_delegation!(IntrospectInfo, msg_types::IntrospectInfo);
+implement_into_ffi_by_protobuf!(msg_types::Device);
+implement_into_ffi_by_delegation!(Device, msg_types::Device);
+implement_into_ffi_by_protobuf!(msg_types::Devices);
+implement_into_ffi_by_delegation!(AccountEvent, msg_types::AccountEvent);
+implement_into_ffi_by_protobuf!(msg_types::AccountEvent);
+implement_into_ffi_by_protobuf!(msg_types::AccountEvents);
+implement_into_ffi_by_delegation!(IncomingDeviceCommand, msg_types::IncomingDeviceCommand);
+implement_into_ffi_by_protobuf!(msg_types::IncomingDeviceCommand);
+implement_into_ffi_by_protobuf!(msg_types::IncomingDeviceCommands);
diff --git a/third_party/rust/fxa-client/src/fxa_msg_types.proto b/third_party/rust/fxa-client/src/fxa_msg_types.proto
new file mode 100644
index 0000000000..6e8d255d6f
--- /dev/null
+++ b/third_party/rust/fxa-client/src/fxa_msg_types.proto
@@ -0,0 +1,142 @@
+syntax = "proto2";
+
+// Note: this file name must be unique due to how the iOS megazord works :(
+
+package mozilla.appservices.fxaclient.protobuf;
+
+option java_package = "mozilla.appservices.fxaclient";
+option java_outer_classname = "MsgTypes";
+option swift_prefix = "MsgTypes_";
+option optimize_for = LITE_RUNTIME;
+
+message Profile {
+ optional string uid = 1;
+ optional string email = 2;
+ optional string avatar = 3;
+ optional bool avatar_default = 4;
+ optional string display_name = 5;
+}
+
+message AccessTokenInfo {
+ required string scope = 1;
+ required string token = 2;
+ optional ScopedKey key = 3;
+ required uint64 expires_at = 4;
+}
+
+message IntrospectInfo {
+ required bool active = 1;
+}
+
+message ScopedKey {
+ required string kty = 1;
+ required string scope = 2;
+ required string k = 3;
+ required string kid = 4;
+}
+
+message Device {
+ message PushSubscription {
+ required string endpoint = 1;
+ required string public_key = 2;
+ required string auth_key = 3;
+ }
+ enum Capability {
+ SEND_TAB = 1;
+ }
+ enum Type {
+ DESKTOP = 1;
+ MOBILE = 2;
+ TABLET = 3;
+ VR = 4;
+ TV = 5;
+ UNKNOWN = 6;
+ }
+ required string id = 1;
+ required string display_name = 2;
+ required Type type = 3;
+ optional PushSubscription push_subscription = 4;
+ required bool push_endpoint_expired = 5;
+ required bool is_current_device = 6;
+ optional uint64 last_access_time = 7;
+ repeated Capability capabilities = 8;
+}
+
+message Devices {
+ repeated Device devices = 1;
+}
+
+message Capabilities {
+ repeated Device.Capability capability = 1;
+}
+
+message IncomingDeviceCommand {
+ enum IncomingDeviceCommandType {
+ TAB_RECEIVED = 1; // `data` set to `tab_received_data`.
+ }
+ required IncomingDeviceCommandType type = 1;
+
+ message SendTabData {
+ message TabHistoryEntry {
+ required string title = 1;
+ required string url = 2;
+ }
+ optional Device from = 1;
+ repeated TabHistoryEntry entries = 2;
+ }
+
+ oneof data {
+ SendTabData tab_received_data = 2;
+ };
+}
+
+message IncomingDeviceCommands {
+ repeated IncomingDeviceCommand commands = 1;
+}
+
+// This is basically an enum with associated values,
+// but it's a bit harder to model in proto2.
+message AccountEvent {
+ enum AccountEventType {
+ INCOMING_DEVICE_COMMAND = 1; // `data` set to `device_command`.
+ PROFILE_UPDATED = 2;
+ DEVICE_CONNECTED = 3; // `data` set to `device_connected_name`.
+ ACCOUNT_AUTH_STATE_CHANGED = 4;
+ DEVICE_DISCONNECTED = 5; // `data` set to `device_disconnected_data`.
+ ACCOUNT_DESTROYED = 6;
+ }
+ required AccountEventType type = 1;
+
+ message DeviceDisconnectedData {
+ required string device_id = 1;
+ required bool is_local_device = 2;
+ }
+
+ oneof data {
+ IncomingDeviceCommand device_command = 2;
+ string device_connected_name = 3;
+ DeviceDisconnectedData device_disconnected_data = 4;
+ }
+}
+
+message AccountEvents {
+ repeated AccountEvent events = 1;
+}
+
+message AuthorizationPKCEParams {
+ required string code_challenge = 1;
+ required string code_challenge_method = 2;
+}
+
+message AuthorizationParams {
+ required string client_id = 1;
+ required string scope = 2;
+ required string state = 3;
+ required string access_type = 4;
+ optional AuthorizationPKCEParams pkce_params = 5;
+ optional string keys_jwk = 6;
+}
+
+message MetricsParams {
+ map<string, string> parameters = 1;
+}
diff --git a/third_party/rust/fxa-client/src/http_client.rs b/third_party/rust/fxa-client/src/http_client.rs
new file mode 100644
index 0000000000..3075b358cf
--- /dev/null
+++ b/third_party/rust/fxa-client/src/http_client.rs
@@ -0,0 +1,1162 @@
+/* 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/. */
+
+use crate::{config::Config, error::*};
+use jwcrypto::Jwk;
+use rc_crypto::{
+ digest,
+ hawk::{Credentials, Key, PayloadHasher, RequestBuilder, SHA256},
+ hkdf, hmac,
+};
+use serde_derive::{Deserialize, Serialize};
+use serde_json::json;
+use std::{
+ collections::HashMap,
+ sync::Mutex,
+ time::{Duration, Instant},
+};
+use url::Url;
+use viaduct::{header_names, status_codes, Method, Request, Response};
+
+const HAWK_HKDF_SALT: [u8; 32] = [0b0; 32];
+const HAWK_KEY_LENGTH: usize = 32;
+const RETRY_AFTER_DEFAULT_SECONDS: u64 = 10;
+
+#[cfg_attr(test, mockiato::mockable)]
+pub(crate) trait FxAClient {
+ fn refresh_token_with_code(
+ &self,
+ config: &Config,
+ code: &str,
+ code_verifier: &str,
+ ) -> Result<OAuthTokenResponse>;
+ fn refresh_token_with_session_token(
+ &self,
+ config: &Config,
+ session_token: &str,
+ scopes: &[&str],
+ ) -> Result<OAuthTokenResponse>;
+ fn oauth_introspect_refresh_token(
+ &self,
+ config: &Config,
+ refresh_token: &str,
+ ) -> Result<IntrospectResponse>;
+ fn access_token_with_refresh_token(
+ &self,
+ config: &Config,
+ refresh_token: &str,
+ ttl: Option<u64>,
+ scopes: &[&str],
+ ) -> Result<OAuthTokenResponse>;
+ fn access_token_with_session_token(
+ &self,
+ config: &Config,
+ session_token: &str,
+ scopes: &[&str],
+ ) -> Result<OAuthTokenResponse>;
+ fn authorization_code_using_session_token(
+ &self,
+ config: &Config,
+ session_token: &str,
+ auth_params: AuthorizationRequestParameters,
+ ) -> Result<OAuthAuthResponse>;
+ fn duplicate_session(
+ &self,
+ config: &Config,
+ session_token: &str,
+ ) -> Result<DuplicateTokenResponse>;
+ fn destroy_access_token(&self, config: &Config, token: &str) -> Result<()>;
+ fn destroy_refresh_token(&self, config: &Config, token: &str) -> Result<()>;
+ fn profile(
+ &self,
+ config: &Config,
+ profile_access_token: &str,
+ etag: Option<String>,
+ ) -> Result<Option<ResponseAndETag<ProfileResponse>>>;
+ fn set_ecosystem_anon_id(
+ &self,
+ config: &Config,
+ access_token: &str,
+ ecosystem_anon_id: &str,
+ ) -> Result<()>;
+ fn pending_commands(
+ &self,
+ config: &Config,
+ refresh_token: &str,
+ index: u64,
+ limit: Option<u64>,
+ ) -> Result<PendingCommandsResponse>;
+ fn invoke_command(
+ &self,
+ config: &Config,
+ refresh_token: &str,
+ command: &str,
+ target: &str,
+ payload: &serde_json::Value,
+ ) -> Result<()>;
+ fn devices(&self, config: &Config, refresh_token: &str) -> Result<Vec<GetDeviceResponse>>;
+ fn update_device(
+ &self,
+ config: &Config,
+ refresh_token: &str,
+ update: DeviceUpdateRequest<'_>,
+ ) -> Result<UpdateDeviceResponse>;
+ fn destroy_device(&self, config: &Config, refresh_token: &str, id: &str) -> Result<()>;
+ fn attached_clients(
+ &self,
+ config: &Config,
+ session_token: &str,
+ ) -> Result<Vec<GetAttachedClientResponse>>;
+ fn scoped_key_data(
+ &self,
+ config: &Config,
+ session_token: &str,
+ client_id: &str,
+ scope: &str,
+ ) -> Result<HashMap<String, ScopedKeyDataResponse>>;
+ fn fxa_client_configuration(&self, config: &Config) -> Result<ClientConfigurationResponse>;
+ fn openid_configuration(&self, config: &Config) -> Result<OpenIdConfigurationResponse>;
+}
+
+enum HttpClientState {
+ Ok,
+ Backoff {
+ backoff_end_duration: Duration,
+ time_since_backoff: Instant,
+ },
+}
+
+pub struct Client {
+ state: Mutex<HashMap<String, HttpClientState>>,
+}
+impl FxAClient for Client {
+ fn fxa_client_configuration(&self, config: &Config) -> Result<ClientConfigurationResponse> {
+ // Why go through two-levels of indirection? It looks kinda dumb.
+ // Well, `config:Config` also needs to fetch the config, but does not have access
+ // to an instance of `http_client`, so it calls the helper function directly.
+ fxa_client_configuration(config.client_config_url()?)
+ }
+ fn openid_configuration(&self, config: &Config) -> Result<OpenIdConfigurationResponse> {
+ openid_configuration(config.openid_config_url()?)
+ }
+
+ fn profile(
+ &self,
+ config: &Config,
+ access_token: &str,
+ etag: Option<String>,
+ ) -> Result<Option<ResponseAndETag<ProfileResponse>>> {
+ let url = config.userinfo_endpoint()?;
+ let mut request =
+ Request::get(url).header(header_names::AUTHORIZATION, bearer_token(access_token))?;
+ if let Some(etag) = etag {
+ request = request.header(header_names::IF_NONE_MATCH, format!("\"{}\"", etag))?;
+ }
+ let resp = self.make_request(request)?;
+ if resp.status == status_codes::NOT_MODIFIED {
+ return Ok(None);
+ }
+ let etag = resp
+ .headers
+ .get(header_names::ETAG)
+ .map(ToString::to_string);
+ Ok(Some(ResponseAndETag {
+ etag,
+ response: resp.json()?,
+ }))
+ }
+
+ fn set_ecosystem_anon_id(
+ &self,
+ config: &Config,
+ access_token: &str,
+ ecosystem_anon_id: &str,
+ ) -> Result<()> {
+ let url = config.profile_url_path("v1/ecosystem_anon_id")?;
+ let body = json!({
+ "ecosystemAnonId": ecosystem_anon_id,
+ });
+ let request = Request::post(url)
+ .header(header_names::AUTHORIZATION, bearer_token(access_token))?
+ // If-none-match prevents us from overwriting an already set value.
+ .header(header_names::IF_NONE_MATCH, "*")?
+ .body(body.to_string());
+ self.make_request(request)?;
+ Ok(())
+ }
+
+ // For the one-off generation of a `refresh_token` and associated meta from transient credentials.
+ fn refresh_token_with_code(
+ &self,
+ config: &Config,
+ code: &str,
+ code_verifier: &str,
+ ) -> Result<OAuthTokenResponse> {
+ let req_body = OAauthTokenRequest::UsingCode {
+ code: code.to_string(),
+ client_id: config.client_id.to_string(),
+ code_verifier: code_verifier.to_string(),
+ ttl: None,
+ };
+ self.make_oauth_token_request(config, serde_json::to_value(req_body).unwrap())
+ }
+
+ fn refresh_token_with_session_token(
+ &self,
+ config: &Config,
+ session_token: &str,
+ scopes: &[&str],
+ ) -> Result<OAuthTokenResponse> {
+ let url = config.token_endpoint()?;
+ let key = derive_auth_key_from_session_token(&session_token)?;
+ let body = json!({
+ "client_id": config.client_id,
+ "scope": scopes.join(" "),
+ "grant_type": "fxa-credentials",
+ "access_type": "offline",
+ });
+ let request = HawkRequestBuilder::new(Method::Post, url, &key)
+ .body(body)
+ .build()?;
+ Ok(self.make_request(request)?.json()?)
+ }
+
+ // For the regular generation of an `access_token` from long-lived credentials.
+
+ fn access_token_with_refresh_token(
+ &self,
+ config: &Config,
+ refresh_token: &str,
+ ttl: Option<u64>,
+ scopes: &[&str],
+ ) -> Result<OAuthTokenResponse> {
+ let req = OAauthTokenRequest::UsingRefreshToken {
+ client_id: config.client_id.clone(),
+ refresh_token: refresh_token.to_string(),
+ scope: Some(scopes.join(" ")),
+ ttl,
+ };
+ self.make_oauth_token_request(config, serde_json::to_value(req).unwrap())
+ }
+
+ fn access_token_with_session_token(
+ &self,
+ config: &Config,
+ session_token: &str,
+ scopes: &[&str],
+ ) -> Result<OAuthTokenResponse> {
+ let parameters = json!({
+ "client_id": config.client_id,
+ "grant_type": "fxa-credentials",
+ "scope": scopes.join(" ")
+ });
+ let key = derive_auth_key_from_session_token(session_token)?;
+ let url = config.token_endpoint()?;
+ let request = HawkRequestBuilder::new(Method::Post, url, &key)
+ .body(parameters)
+ .build()?;
+ self.make_request(request)?.json().map_err(Into::into)
+ }
+
+ fn authorization_code_using_session_token(
+ &self,
+ config: &Config,
+ session_token: &str,
+ auth_params: AuthorizationRequestParameters,
+ ) -> Result<OAuthAuthResponse> {
+ let parameters = serde_json::to_value(&auth_params)?;
+ let key = derive_auth_key_from_session_token(session_token)?;
+ let url = config.auth_url_path("v1/oauth/authorization")?;
+ let request = HawkRequestBuilder::new(Method::Post, url, &key)
+ .body(parameters)
+ .build()?;
+
+ Ok(self.make_request(request)?.json()?)
+ }
+
+ fn oauth_introspect_refresh_token(
+ &self,
+ config: &Config,
+ refresh_token: &str,
+ ) -> Result<IntrospectResponse> {
+ let body = json!({
+ "token_type_hint": "refresh_token",
+ "token": refresh_token,
+ });
+ let url = config.introspection_endpoint()?;
+ Ok(self.make_request(Request::post(url).json(&body))?.json()?)
+ }
+
+ fn duplicate_session(
+ &self,
+ config: &Config,
+ session_token: &str,
+ ) -> Result<DuplicateTokenResponse> {
+ let url = config.auth_url_path("v1/session/duplicate")?;
+ let key = derive_auth_key_from_session_token(&session_token)?;
+ let duplicate_body = json!({
+ "reason": "migration"
+ });
+ let request = HawkRequestBuilder::new(Method::Post, url, &key)
+ .body(duplicate_body)
+ .build()?;
+
+ Ok(self.make_request(request)?.json()?)
+ }
+
+ fn destroy_access_token(&self, config: &Config, access_token: &str) -> Result<()> {
+ let body = json!({
+ "token": access_token,
+ });
+ self.destroy_token_helper(config, &body)
+ }
+
+ fn destroy_refresh_token(&self, config: &Config, refresh_token: &str) -> Result<()> {
+ let body = json!({
+ "refresh_token": refresh_token,
+ });
+ self.destroy_token_helper(config, &body)
+ }
+
+ fn pending_commands(
+ &self,
+ config: &Config,
+ refresh_token: &str,
+ index: u64,
+ limit: Option<u64>,
+ ) -> Result<PendingCommandsResponse> {
+ let url = config.auth_url_path("v1/account/device/commands")?;
+ let mut request = Request::get(url)
+ .header(header_names::AUTHORIZATION, bearer_token(refresh_token))?
+ .query(&[("index", &index.to_string())]);
+ if let Some(limit) = limit {
+ request = request.query(&[("limit", &limit.to_string())])
+ }
+ Ok(self.make_request(request)?.json()?)
+ }
+
+ fn invoke_command(
+ &self,
+ config: &Config,
+ refresh_token: &str,
+ command: &str,
+ target: &str,
+ payload: &serde_json::Value,
+ ) -> Result<()> {
+ let body = json!({
+ "command": command,
+ "target": target,
+ "payload": payload
+ });
+ let url = config.auth_url_path("v1/account/devices/invoke_command")?;
+ let request = Request::post(url)
+ .header(header_names::AUTHORIZATION, bearer_token(refresh_token))?
+ .header(header_names::CONTENT_TYPE, "application/json")?
+ .body(body.to_string());
+ self.make_request(request)?;
+ Ok(())
+ }
+
+ fn devices(&self, config: &Config, refresh_token: &str) -> Result<Vec<GetDeviceResponse>> {
+ let url = config.auth_url_path("v1/account/devices")?;
+ let request =
+ Request::get(url).header(header_names::AUTHORIZATION, bearer_token(refresh_token))?;
+ Ok(self.make_request(request)?.json()?)
+ }
+
+ fn update_device(
+ &self,
+ config: &Config,
+ refresh_token: &str,
+ update: DeviceUpdateRequest<'_>,
+ ) -> Result<UpdateDeviceResponse> {
+ let url = config.auth_url_path("v1/account/device")?;
+ let request = Request::post(url)
+ .header(header_names::AUTHORIZATION, bearer_token(refresh_token))?
+ .header(header_names::CONTENT_TYPE, "application/json")?
+ .body(serde_json::to_string(&update)?);
+ Ok(self.make_request(request)?.json()?)
+ }
+
+ fn destroy_device(&self, config: &Config, refresh_token: &str, id: &str) -> Result<()> {
+ let body = json!({
+ "id": id,
+ });
+ let url = config.auth_url_path("v1/account/device/destroy")?;
+ let request = Request::post(url)
+ .header(header_names::AUTHORIZATION, bearer_token(refresh_token))?
+ .header(header_names::CONTENT_TYPE, "application/json")?
+ .body(body.to_string());
+
+ self.make_request(request)?;
+ Ok(())
+ }
+
+ fn attached_clients(
+ &self,
+ config: &Config,
+ session_token: &str,
+ ) -> Result<Vec<GetAttachedClientResponse>> {
+ let url = config.auth_url_path("v1/account/attached_clients")?;
+ let key = derive_auth_key_from_session_token(session_token)?;
+ let request = HawkRequestBuilder::new(Method::Get, url, &key).build()?;
+ Ok(self.make_request(request)?.json()?)
+ }
+
+ fn scoped_key_data(
+ &self,
+ config: &Config,
+ session_token: &str,
+ client_id: &str,
+ scope: &str,
+ ) -> Result<HashMap<String, ScopedKeyDataResponse>> {
+ let body = json!({
+ "client_id": client_id,
+ "scope": scope,
+ });
+ let url = config.auth_url_path("v1/account/scoped-key-data")?;
+ let key = derive_auth_key_from_session_token(session_token)?;
+ let request = HawkRequestBuilder::new(Method::Post, url, &key)
+ .body(body)
+ .build()?;
+ self.make_request(request)?.json().map_err(|e| e.into())
+ }
+}
+
+macro_rules! fetch {
+ ($url:expr) => {
+ viaduct::Request::get($url)
+ .send()?
+ .require_success()?
+ .json()?
+ };
+}
+
+#[inline]
+pub(crate) fn fxa_client_configuration(url: Url) -> Result<ClientConfigurationResponse> {
+ Ok(fetch!(url))
+}
+#[inline]
+pub(crate) fn openid_configuration(url: Url) -> Result<OpenIdConfigurationResponse> {
+ Ok(fetch!(url))
+}
+
+impl Client {
+ pub fn new() -> Self {
+ Self {
+ state: Mutex::new(HashMap::new()),
+ }
+ }
+
+ fn destroy_token_helper(&self, config: &Config, body: &serde_json::Value) -> Result<()> {
+ let url = config.oauth_url_path("v1/destroy")?;
+ self.make_request(Request::post(url).json(body))?;
+ Ok(())
+ }
+
+ fn make_oauth_token_request(
+ &self,
+ config: &Config,
+ body: serde_json::Value,
+ ) -> Result<OAuthTokenResponse> {
+ let url = config.token_endpoint()?;
+ Ok(self.make_request(Request::post(url).json(&body))?.json()?)
+ }
+
+ fn handle_too_many_requests(&self, resp: Response) -> Result<Response> {
+ let path = resp.url.path().to_string();
+ if let Some(retry_after) = resp.headers.get_as::<u64, _>(header_names::RETRY_AFTER) {
+ let retry_after = retry_after.unwrap_or(RETRY_AFTER_DEFAULT_SECONDS);
+ let time_out_state = HttpClientState::Backoff {
+ backoff_end_duration: Duration::from_secs(retry_after),
+ time_since_backoff: Instant::now(),
+ };
+ self.state.lock().unwrap().insert(path, time_out_state);
+ return Err(ErrorKind::BackoffError(retry_after).into());
+ }
+ Self::default_handle_response_error(resp)
+ }
+
+ fn default_handle_response_error(resp: Response) -> Result<Response> {
+ let json: std::result::Result<serde_json::Value, _> = resp.json();
+ match json {
+ Ok(json) => Err(ErrorKind::RemoteError {
+ code: json["code"].as_u64().unwrap_or(0),
+ errno: json["errno"].as_u64().unwrap_or(0),
+ error: json["error"].as_str().unwrap_or("").to_string(),
+ message: json["message"].as_str().unwrap_or("").to_string(),
+ info: json["info"].as_str().unwrap_or("").to_string(),
+ }
+ .into()),
+ Err(_) => Err(resp.require_success().unwrap_err().into()),
+ }
+ }
+
+ fn make_request(&self, request: Request) -> Result<Response> {
+ let url = request.url.path().to_string();
+ if let HttpClientState::Backoff {
+ backoff_end_duration,
+ time_since_backoff,
+ } = self
+ .state
+ .lock()
+ .unwrap()
+ .get(&url)
+ .unwrap_or(&HttpClientState::Ok)
+ {
+ let elapsed_time = time_since_backoff.elapsed();
+ if elapsed_time < *backoff_end_duration {
+ let remaining = *backoff_end_duration - elapsed_time;
+ return Err(ErrorKind::BackoffError(remaining.as_secs()).into());
+ }
+ }
+ self.state.lock().unwrap().insert(url, HttpClientState::Ok);
+ let resp = request.send()?;
+ if resp.is_success() || resp.status == status_codes::NOT_MODIFIED {
+ Ok(resp)
+ } else {
+ match resp.status {
+ status_codes::TOO_MANY_REQUESTS => self.handle_too_many_requests(resp),
+ _ => Self::default_handle_response_error(resp),
+ }
+ }
+ }
+}
+
+fn bearer_token(token: &str) -> String {
+ format!("Bearer {}", token)
+}
+
+fn kw(name: &str) -> Vec<u8> {
+ format!("identity.mozilla.com/picl/v1/{}", name)
+ .as_bytes()
+ .to_vec()
+}
+
+pub fn derive_auth_key_from_session_token(session_token: &str) -> Result<Vec<u8>> {
+ let session_token_bytes = hex::decode(session_token)?;
+ let context_info = kw("sessionToken");
+ let salt = hmac::SigningKey::new(&digest::SHA256, &HAWK_HKDF_SALT);
+ let mut out = vec![0u8; HAWK_KEY_LENGTH * 2];
+ hkdf::extract_and_expand(&salt, &session_token_bytes, &context_info, &mut out)?;
+ Ok(out)
+}
+
+#[derive(Serialize, Deserialize)]
+pub struct AuthorizationRequestParameters {
+ pub client_id: String,
+ pub scope: String,
+ pub state: String,
+ pub access_type: String,
+ pub code_challenge: Option<String>,
+ pub code_challenge_method: Option<String>,
+ pub keys_jwe: Option<String>,
+}
+
+// Keeping those functions out of the FxAClient trate becouse functions in the
+// FxAClient trate with a `test only` feature upsets the mockiato proc macro
+// And it's okay since they are only used in tests. (if they were not test only
+// Mockiato would not complain)
+#[cfg(feature = "integration_test")]
+pub fn send_authorization_request(
+ config: &Config,
+ auth_params: AuthorizationRequestParameters,
+ auth_key: &[u8],
+) -> anyhow::Result<String> {
+ let auth_endpoint = config.auth_url_path("v1/oauth/authorization")?;
+ let req = HawkRequestBuilder::new(Method::Post, auth_endpoint, auth_key)
+ .body(serde_json::to_value(&auth_params)?)
+ .build()?;
+ let client = Client::new();
+ let resp: serde_json::Value = client.make_request(req)?.json()?;
+ Ok(resp
+ .get("redirect")
+ .ok_or_else(|| anyhow::Error::msg("No redirect uri"))?
+ .as_str()
+ .ok_or_else(|| anyhow::Error::msg("redirect URI is not a string"))?
+ .to_string())
+}
+
+#[cfg(feature = "integration_test")]
+pub fn get_scoped_key_data_response(
+ scope: &str,
+ client_id: &str,
+ auth_key: &[u8],
+ config: &Config,
+) -> Result<serde_json::Value> {
+ let scoped_endpoint = config.auth_url_path("v1/account/scoped-key-data")?;
+ let body = json!({
+ "client_id": client_id,
+ "scope": scope,
+ });
+ let req = HawkRequestBuilder::new(Method::Post, scoped_endpoint, auth_key)
+ .body(body)
+ .build()?;
+ let client = Client::new();
+ let resp = client.make_request(req)?.json()?;
+ Ok(resp)
+}
+
+#[cfg(feature = "integration_test")]
+pub fn get_keys_bundle(config: &Config, hkdf_sha256_key: &[u8]) -> Result<Vec<u8>> {
+ let keys_url = config.auth_url_path("v1/account/keys").unwrap();
+ let req = HawkRequestBuilder::new(Method::Get, keys_url, hkdf_sha256_key).build()?;
+ let client = Client::new();
+ let resp: serde_json::Value = client.make_request(req)?.json()?;
+ let bundle = hex::decode(
+ &resp["bundle"]
+ .as_str()
+ .ok_or_else(|| ErrorKind::UnrecoverableServerError("bundle not present"))?,
+ )?;
+ Ok(bundle)
+}
+
+#[cfg(feature = "integration_test")]
+pub fn send_verification(config: &Config, body: serde_json::Value) -> Result<Response> {
+ let verify_endpoint = config
+ .auth_url_path("v1/recovery_email/verify_code")
+ .unwrap();
+ let resp = Request::post(verify_endpoint).json(&body).send()?;
+ Ok(resp)
+}
+
+struct HawkRequestBuilder<'a> {
+ url: Url,
+ method: Method,
+ body: Option<String>,
+ hkdf_sha256_key: &'a [u8],
+}
+
+impl<'a> HawkRequestBuilder<'a> {
+ pub fn new(method: Method, url: Url, hkdf_sha256_key: &'a [u8]) -> Self {
+ rc_crypto::ensure_initialized();
+ HawkRequestBuilder {
+ url,
+ method,
+ body: None,
+ hkdf_sha256_key,
+ }
+ }
+
+ // This class assumes that the content being sent it always of the type
+ // application/json.
+ pub fn body(mut self, body: serde_json::Value) -> Self {
+ self.body = Some(body.to_string());
+ self
+ }
+
+ fn make_hawk_header(&self) -> Result<String> {
+ // Make sure we de-allocate the hash after hawk_request_builder.
+ let hash;
+ let method = format!("{}", self.method);
+ let mut hawk_request_builder = RequestBuilder::from_url(method.as_str(), &self.url)?;
+ if let Some(ref body) = self.body {
+ hash = PayloadHasher::hash("application/json", SHA256, &body)?;
+ hawk_request_builder = hawk_request_builder.hash(&hash[..]);
+ }
+ let hawk_request = hawk_request_builder.request();
+ let token_id = hex::encode(&self.hkdf_sha256_key[0..HAWK_KEY_LENGTH]);
+ let hmac_key = &self.hkdf_sha256_key[HAWK_KEY_LENGTH..(2 * HAWK_KEY_LENGTH)];
+ let hawk_credentials = Credentials {
+ id: token_id,
+ key: Key::new(hmac_key, SHA256)?,
+ };
+ let header = hawk_request.make_header(&hawk_credentials)?;
+ Ok(format!("Hawk {}", header))
+ }
+
+ pub fn build(self) -> Result<Request> {
+ let hawk_header = self.make_hawk_header()?;
+ let mut request =
+ Request::new(self.method, self.url).header(header_names::AUTHORIZATION, hawk_header)?;
+ if let Some(body) = self.body {
+ request = request
+ .header(header_names::CONTENT_TYPE, "application/json")?
+ .body(body);
+ }
+ Ok(request)
+ }
+}
+
+#[derive(Deserialize)]
+pub(crate) struct ClientConfigurationResponse {
+ pub(crate) auth_server_base_url: String,
+ pub(crate) oauth_server_base_url: String,
+ pub(crate) profile_server_base_url: String,
+ pub(crate) sync_tokenserver_base_url: String,
+ // XXX: Remove Option once all prod servers have this field.
+ pub(crate) ecosystem_anon_id_keys: Option<Vec<Jwk>>,
+}
+
+#[derive(Deserialize)]
+pub(crate) struct OpenIdConfigurationResponse {
+ pub(crate) authorization_endpoint: String,
+ pub(crate) introspection_endpoint: String,
+ pub(crate) issuer: String,
+ pub(crate) jwks_uri: String,
+ #[allow(dead_code)]
+ pub(crate) token_endpoint: String,
+ pub(crate) userinfo_endpoint: String,
+}
+
+#[derive(Clone)]
+pub struct ResponseAndETag<T> {
+ pub response: T,
+ pub etag: Option<String>,
+}
+
+#[derive(Deserialize)]
+pub struct PendingCommandsResponse {
+ pub index: u64,
+ pub last: Option<bool>,
+ pub messages: Vec<PendingCommand>,
+}
+
+#[derive(Deserialize)]
+pub struct PendingCommand {
+ pub index: u64,
+ pub data: CommandData,
+}
+
+#[derive(Debug, Deserialize)]
+pub struct CommandData {
+ pub command: String,
+ pub payload: serde_json::Value, // Need https://github.com/serde-rs/serde/issues/912 to make payload an enum instead.
+ pub sender: Option<String>,
+}
+
+#[derive(Clone, Debug, Deserialize, Serialize)]
+pub struct PushSubscription {
+ #[serde(rename = "pushCallback")]
+ pub endpoint: String,
+ #[serde(rename = "pushPublicKey")]
+ pub public_key: String,
+ #[serde(rename = "pushAuthKey")]
+ pub auth_key: String,
+}
+
+/// We use the double Option pattern in this struct.
+/// The outer option represents the existence of the field
+/// and the inner option its value or null.
+/// TL;DR:
+/// `None`: the field will not be present in the JSON body.
+/// `Some(None)`: the field will have a `null` value.
+/// `Some(Some(T))`: the field will have the serialized value of T.
+#[derive(Serialize)]
+#[allow(clippy::option_option)]
+pub struct DeviceUpdateRequest<'a> {
+ #[serde(skip_serializing_if = "Option::is_none")]
+ #[serde(rename = "name")]
+ display_name: Option<Option<&'a str>>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ #[serde(rename = "type")]
+ device_type: Option<Option<&'a DeviceType>>,
+ #[serde(flatten)]
+ push_subscription: Option<&'a PushSubscription>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ #[serde(rename = "availableCommands")]
+ available_commands: Option<Option<&'a HashMap<String, String>>>,
+}
+
+#[derive(Clone, Debug, Serialize, Deserialize)]
+pub enum DeviceType {
+ #[serde(rename = "desktop")]
+ Desktop,
+ #[serde(rename = "mobile")]
+ Mobile,
+ #[serde(rename = "tablet")]
+ Tablet,
+ #[serde(rename = "vr")]
+ VR,
+ #[serde(rename = "tv")]
+ TV,
+ #[serde(other)]
+ #[serde(skip_serializing)] // Don't you dare trying.
+ Unknown,
+}
+
+#[allow(clippy::option_option)]
+pub struct DeviceUpdateRequestBuilder<'a> {
+ device_type: Option<Option<&'a DeviceType>>,
+ display_name: Option<Option<&'a str>>,
+ push_subscription: Option<&'a PushSubscription>,
+ available_commands: Option<Option<&'a HashMap<String, String>>>,
+}
+
+impl<'a> DeviceUpdateRequestBuilder<'a> {
+ pub fn new() -> Self {
+ Self {
+ device_type: None,
+ display_name: None,
+ push_subscription: None,
+ available_commands: None,
+ }
+ }
+
+ pub fn push_subscription(mut self, push_subscription: &'a PushSubscription) -> Self {
+ self.push_subscription = Some(push_subscription);
+ self
+ }
+
+ pub fn available_commands(mut self, available_commands: &'a HashMap<String, String>) -> Self {
+ self.available_commands = Some(Some(available_commands));
+ self
+ }
+
+ pub fn clear_available_commands(mut self) -> Self {
+ self.available_commands = Some(None);
+ self
+ }
+
+ pub fn display_name(mut self, display_name: &'a str) -> Self {
+ self.display_name = Some(Some(display_name));
+ self
+ }
+
+ pub fn clear_display_name(mut self) -> Self {
+ self.display_name = Some(None);
+ self
+ }
+
+ #[allow(dead_code)]
+ pub fn device_type(mut self, device_type: &'a DeviceType) -> Self {
+ self.device_type = Some(Some(device_type));
+ self
+ }
+
+ pub fn build(self) -> DeviceUpdateRequest<'a> {
+ DeviceUpdateRequest {
+ display_name: self.display_name,
+ device_type: self.device_type,
+ push_subscription: self.push_subscription,
+ available_commands: self.available_commands,
+ }
+ }
+}
+
+#[derive(Clone, Debug, Serialize, Deserialize)]
+pub struct DeviceLocation {
+ pub city: Option<String>,
+ pub country: Option<String>,
+ pub state: Option<String>,
+ #[serde(rename = "stateCode")]
+ pub state_code: Option<String>,
+}
+
+#[derive(Clone, Debug, Serialize, Deserialize)]
+pub struct GetDeviceResponse {
+ #[serde(flatten)]
+ pub common: DeviceResponseCommon,
+ #[serde(rename = "isCurrentDevice")]
+ pub is_current_device: bool,
+ pub location: DeviceLocation,
+ #[serde(rename = "lastAccessTime")]
+ pub last_access_time: Option<u64>,
+}
+
+impl std::ops::Deref for GetDeviceResponse {
+ type Target = DeviceResponseCommon;
+ fn deref(&self) -> &DeviceResponseCommon {
+ &self.common
+ }
+}
+
+pub type UpdateDeviceResponse = DeviceResponseCommon;
+
+#[derive(Clone, Debug, Serialize, Deserialize)]
+pub struct DeviceResponseCommon {
+ pub id: String,
+ #[serde(rename = "name")]
+ pub display_name: String,
+ #[serde(rename = "type")]
+ pub device_type: DeviceType,
+ #[serde(flatten)]
+ pub push_subscription: Option<PushSubscription>,
+ #[serde(rename = "availableCommands")]
+ pub available_commands: HashMap<String, String>,
+ #[serde(rename = "pushEndpointExpired")]
+ pub push_endpoint_expired: bool,
+}
+
+#[derive(Clone, Debug, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct GetAttachedClientResponse {
+ pub client_id: Option<String>,
+ pub session_token_id: Option<String>,
+ pub refresh_token_id: Option<String>,
+ pub device_id: Option<String>,
+ pub device_type: Option<DeviceType>,
+ pub is_current_session: bool,
+ pub name: Option<String>,
+ pub created_time: Option<u64>,
+ pub last_access_time: Option<u64>,
+ pub scope: Option<Vec<String>>,
+ pub user_agent: String,
+ pub os: Option<String>,
+}
+
+// We model the OAuthTokenRequest according to the up to date
+// definition on
+// https://github.com/mozilla/fxa/blob/8ae0e6876a50c7f386a9ec5b6df9ebb54ccdf1b5/packages/fxa-auth-server/lib/oauth/routes/token.js#L70-L152
+
+#[derive(Serialize)]
+#[serde(tag = "grant_type")]
+enum OAauthTokenRequest {
+ #[serde(rename = "refresh_token")]
+ UsingRefreshToken {
+ client_id: String,
+ refresh_token: String,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ scope: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ ttl: Option<u64>,
+ },
+ #[serde(rename = "authorization_code")]
+ UsingCode {
+ client_id: String,
+ code: String,
+ code_verifier: String,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ ttl: Option<u64>,
+ },
+}
+
+#[derive(Deserialize)]
+pub struct OAuthTokenResponse {
+ pub keys_jwe: Option<String>,
+ pub refresh_token: Option<String>,
+ pub session_token: Option<String>,
+ pub expires_in: u64,
+ pub scope: String,
+ pub access_token: String,
+}
+
+#[derive(Deserialize, Debug)]
+pub struct OAuthAuthResponse {
+ pub redirect: String,
+ pub code: String,
+ pub state: String,
+}
+
+#[derive(Deserialize)]
+pub struct IntrospectResponse {
+ pub active: bool,
+ // Technically the response has a lot of other fields,
+ // but in practice we only use `active`.
+}
+
+#[derive(Clone, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct ProfileResponse {
+ pub uid: String,
+ pub email: String,
+ pub display_name: Option<String>,
+ pub avatar: String,
+ pub avatar_default: bool,
+ pub ecosystem_anon_id: Option<String>,
+}
+
+#[derive(Deserialize)]
+pub struct ScopedKeyDataResponse {
+ pub identifier: String,
+ #[serde(rename = "keyRotationSecret")]
+ pub key_rotation_secret: String,
+ #[serde(rename = "keyRotationTimestamp")]
+ pub key_rotation_timestamp: u64,
+}
+
+#[derive(Deserialize, Clone, Debug, PartialEq, Eq)]
+pub struct DuplicateTokenResponse {
+ pub uid: String,
+ #[serde(rename = "sessionToken")]
+ pub session_token: String,
+ pub verified: bool,
+ #[serde(rename = "authAt")]
+ pub auth_at: u64,
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use mockito::mock;
+ #[test]
+ #[allow(non_snake_case)]
+ fn check_OAauthTokenRequest_serialization() {
+ // Ensure OAauthTokenRequest serializes to what the server expects.
+ let using_code = OAauthTokenRequest::UsingCode {
+ code: "foo".to_owned(),
+ client_id: "bar".to_owned(),
+ code_verifier: "bobo".to_owned(),
+ ttl: None,
+ };
+ assert_eq!("{\"grant_type\":\"authorization_code\",\"client_id\":\"bar\",\"code\":\"foo\",\"code_verifier\":\"bobo\"}", serde_json::to_string(&using_code).unwrap());
+ let using_code = OAauthTokenRequest::UsingRefreshToken {
+ client_id: "bar".to_owned(),
+ refresh_token: "foo".to_owned(),
+ scope: Some("bobo".to_owned()),
+ ttl: Some(123),
+ };
+ assert_eq!("{\"grant_type\":\"refresh_token\",\"client_id\":\"bar\",\"refresh_token\":\"foo\",\"scope\":\"bobo\",\"ttl\":123}", serde_json::to_string(&using_code).unwrap());
+ }
+
+ #[test]
+ fn test_backoff() {
+ viaduct_reqwest::use_reqwest_backend();
+ let m = mock("POST", "/v1/account/devices/invoke_command")
+ .with_status(429)
+ .with_header("Content-Type", "application/json")
+ .with_header("retry-after", "1000000")
+ .with_body(
+ r#"{
+ "code": 429,
+ "errno": 120,
+ "error": "Too many requests",
+ "message": "Too many requests",
+ "retryAfter": 1000000,
+ "info": "Some information"
+ }"#,
+ )
+ .create();
+ let client = Client::new();
+ let path = format!(
+ "{}/{}",
+ mockito::server_url(),
+ "v1/account/devices/invoke_command"
+ );
+ let url = Url::parse(&path).unwrap();
+ let path = url.path().to_string();
+ let request = Request::post(url);
+ assert!(client.make_request(request.clone()).is_err());
+ let state = client.state.lock().unwrap();
+ if let HttpClientState::Backoff {
+ backoff_end_duration,
+ time_since_backoff: _,
+ } = state.get(&path).unwrap()
+ {
+ assert_eq!(*backoff_end_duration, Duration::from_secs(1_000_000));
+ // Hacky way to drop the mutex gaurd, so that the next call to
+ // client.make_request doesn't hang or panic
+ std::mem::drop(state);
+ assert!(client.make_request(request).is_err());
+ // We should be backed off, the second "make_request" should not
+ // send a request to the server
+ m.expect(1).assert();
+ } else {
+ panic!("HttpClientState should be a timeout!");
+ }
+ }
+
+ #[test]
+ fn test_backoff_then_ok() {
+ viaduct_reqwest::use_reqwest_backend();
+ let m = mock("POST", "/v1/account/devices/invoke_command")
+ .with_status(429)
+ .with_header("Content-Type", "application/json")
+ .with_header("retry-after", "1")
+ .with_body(
+ r#"{
+ "code": 429,
+ "errno": 120,
+ "error": "Too many requests",
+ "message": "Too many requests",
+ "retryAfter": 1,
+ "info": "Some information"
+ }"#,
+ )
+ .create();
+ let client = Client::new();
+ let path = format!(
+ "{}/{}",
+ mockito::server_url(),
+ "v1/account/devices/invoke_command"
+ );
+ let url = Url::parse(&path).unwrap();
+ let path = url.path().to_string();
+ let request = Request::post(url);
+ assert!(client.make_request(request.clone()).is_err());
+ let state = client.state.lock().unwrap();
+ if let HttpClientState::Backoff {
+ backoff_end_duration,
+ time_since_backoff: _,
+ } = state.get(&path).unwrap()
+ {
+ assert_eq!(*backoff_end_duration, Duration::from_secs(1));
+ // We sleep for 1 second, so pass the backoff timeout
+ std::thread::sleep(*backoff_end_duration);
+
+ // Hacky way to drop the mutex gaurd, so that the next call to
+ // client.make_request doesn't hang or panic
+ std::mem::drop(state);
+ assert!(client.make_request(request).is_err());
+ // We backed off, but the time has passed, the second request should have
+ // went to the server
+ m.expect(2).assert();
+ } else {
+ panic!("HttpClientState should be a timeout!");
+ }
+ }
+
+ #[test]
+ fn test_backoff_per_path() {
+ viaduct_reqwest::use_reqwest_backend();
+ let m1 = mock("POST", "/v1/account/devices/invoke_command")
+ .with_status(429)
+ .with_header("Content-Type", "application/json")
+ .with_header("retry-after", "1000000")
+ .with_body(
+ r#"{
+ "code": 429,
+ "errno": 120,
+ "error": "Too many requests",
+ "message": "Too many requests",
+ "retryAfter": 1000000,
+ "info": "Some information"
+ }"#,
+ )
+ .create();
+ let m2 = mock("GET", "/v1/account/device/commands")
+ .with_status(200)
+ .with_header("Content-Type", "application/json")
+ .with_body(
+ r#"
+ {
+ "index": 3,
+ "last": true,
+ "messages": []
+ }"#,
+ )
+ .create();
+ let client = Client::new();
+ let path = format!(
+ "{}/{}",
+ mockito::server_url(),
+ "v1/account/devices/invoke_command"
+ );
+ let url = Url::parse(&path).unwrap();
+ let path = url.path().to_string();
+ let request = Request::post(url);
+ assert!(client.make_request(request).is_err());
+ let state = client.state.lock().unwrap();
+ if let HttpClientState::Backoff {
+ backoff_end_duration,
+ time_since_backoff: _,
+ } = state.get(&path).unwrap()
+ {
+ assert_eq!(*backoff_end_duration, Duration::from_secs(1_000_000));
+
+ let path2 = format!("{}/{}", mockito::server_url(), "v1/account/device/commands");
+ // Hacky way to drop the mutex guard, so that the next call to
+ // client.make_request doesn't hang or panic
+ std::mem::drop(state);
+ let second_request = Request::get(Url::parse(&path2).unwrap());
+ assert!(client.make_request(second_request).is_ok());
+ // The first endpoint is backed off, but the second one is not
+ // Both endpoint should be hit
+ m1.expect(1).assert();
+ m2.expect(1).assert();
+ } else {
+ panic!("HttpClientState should be a timeout!");
+ }
+ }
+}
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"
+ )
+ }
+}
diff --git a/third_party/rust/fxa-client/src/migrator.rs b/third_party/rust/fxa-client/src/migrator.rs
new file mode 100644
index 0000000000..4a087e6bb2
--- /dev/null
+++ b/third_party/rust/fxa-client/src/migrator.rs
@@ -0,0 +1,394 @@
+/* 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/. */
+
+use crate::{error::*, scoped_keys::ScopedKey, scopes, FirefoxAccount};
+use ffi_support::IntoFfi;
+use serde_derive::*;
+use std::time::Instant;
+
+// Values to pass back to calling code over the FFI.
+
+#[derive(Serialize, Deserialize, PartialEq, Debug, Clone, Default)]
+pub struct FxAMigrationResult {
+ pub total_duration: u128,
+}
+
+pub enum MigrationState {
+ // No in-flight migration.
+ None,
+ // An in-flight migration that will copy the sessionToken.
+ CopySessionToken,
+ // An in-flight migration that will re-use the sessionToken.
+ ReuseSessionToken,
+}
+
+unsafe impl IntoFfi for MigrationState {
+ type Value = u8;
+ fn ffi_default() -> u8 {
+ 0
+ }
+ fn into_ffi_value(self) -> u8 {
+ match self {
+ MigrationState::None => 0,
+ MigrationState::CopySessionToken => 1,
+ MigrationState::ReuseSessionToken => 2,
+ }
+ }
+}
+
+// Migration-related data that we may need to serialize in the persisted account state.
+
+#[derive(Clone, Serialize, Deserialize)]
+pub struct MigrationData {
+ k_xcs: String,
+ k_sync: String,
+ copy_session_token: bool,
+ session_token: String,
+}
+
+impl FirefoxAccount {
+ /// Migrate from a logged-in with a sessionToken Firefox Account.
+ ///
+ /// * `session_token` - Hex-formatted session token.
+ /// * `k_xcs` - Hex-formatted kXCS.
+ /// * `k_sync` - Hex-formatted kSync.
+ /// * `copy_session_token` - If true then the provided 'session_token' will be duplicated
+ /// and the resulting session will use a new session token. If false, the provided
+ /// token will be reused.
+ ///
+ /// This method remembers the provided token details and may persist them in the
+ /// account state if it encounters a temporary failure such as a network error.
+ /// Calling code is expected to store the updated state even if an error is thrown.
+ ///
+ /// **💾 This method alters the persisted account state.**
+ pub fn migrate_from_session_token(
+ &mut self,
+ session_token: &str,
+ k_sync: &str,
+ k_xcs: &str,
+ copy_session_token: bool,
+ ) -> Result<FxAMigrationResult> {
+ // if there is already a session token on account, we error out.
+ if self.state.session_token.is_some() {
+ return Err(ErrorKind::IllegalState("Session Token is already set.").into());
+ }
+
+ self.state.in_flight_migration = Some(MigrationData {
+ k_sync: k_sync.to_string(),
+ k_xcs: k_xcs.to_string(),
+ copy_session_token,
+ session_token: session_token.to_string(),
+ });
+
+ self.try_migration()
+ }
+
+ /// Check if the client is in a pending migration state
+ pub fn is_in_migration_state(&self) -> MigrationState {
+ match self.state.in_flight_migration {
+ None => MigrationState::None,
+ Some(MigrationData {
+ copy_session_token: true,
+ ..
+ }) => MigrationState::CopySessionToken,
+ Some(MigrationData {
+ copy_session_token: false,
+ ..
+ }) => MigrationState::ReuseSessionToken,
+ }
+ }
+
+ pub fn try_migration(&mut self) -> Result<FxAMigrationResult> {
+ let import_start = Instant::now();
+
+ match self.network_migration() {
+ Ok(_) => {}
+ Err(err) => {
+ match err.kind() {
+ ErrorKind::RemoteError {
+ code: 500..=599, ..
+ }
+ | ErrorKind::RemoteError { code: 429, .. }
+ | ErrorKind::RequestError(_) => {
+ // network errors that will allow hopefully migrate later
+ log::warn!("Network error: {:?}", err);
+ return Err(err);
+ }
+ _ => {
+ // probably will not recover
+
+ self.state.in_flight_migration = None;
+
+ return Err(err);
+ }
+ };
+ }
+ }
+
+ self.state.in_flight_migration = None;
+
+ let metrics = FxAMigrationResult {
+ total_duration: import_start.elapsed().as_millis(),
+ };
+
+ Ok(metrics)
+ }
+
+ fn network_migration(&mut self) -> Result<()> {
+ let migration_data = match self.state.in_flight_migration {
+ Some(ref data) => data.clone(),
+ None => {
+ return Err(ErrorKind::NoMigrationData.into());
+ }
+ };
+
+ // If we need to copy the sessionToken, do that first so we can use it
+ // for subsequent requests. TODO: we should store the duplicated token
+ // in the account state in case we fail in later steps, but need to remember
+ // the original value of `copy_session_token` if we do so.
+ let migration_session_token = if migration_data.copy_session_token {
+ let duplicate_session = self
+ .client
+ .duplicate_session(&self.state.config, &migration_data.session_token)?;
+
+ duplicate_session.session_token
+ } else {
+ migration_data.session_token.to_string()
+ };
+
+ // Synthesize a scoped key from our kSync.
+ // Do this before creating OAuth tokens because it doesn't have any side-effects,
+ // so it's low-consequence if we fail in later steps.
+ let k_sync = hex::decode(&migration_data.k_sync)?;
+ let k_sync = base64::encode_config(&k_sync, base64::URL_SAFE_NO_PAD);
+ let k_xcs = hex::decode(&migration_data.k_xcs)?;
+ let k_xcs = base64::encode_config(&k_xcs, base64::URL_SAFE_NO_PAD);
+ let scoped_key_data = self.client.scoped_key_data(
+ &self.state.config,
+ &migration_session_token,
+ &self.state.config.client_id,
+ scopes::OLD_SYNC,
+ )?;
+ let oldsync_key_data = scoped_key_data.get(scopes::OLD_SYNC).ok_or_else(|| {
+ ErrorKind::IllegalState("The session token doesn't have access to kSync!")
+ })?;
+ let kid = format!("{}-{}", oldsync_key_data.key_rotation_timestamp, k_xcs);
+ let k_sync_scoped_key = ScopedKey {
+ kty: "oct".to_string(),
+ scope: scopes::OLD_SYNC.to_string(),
+ k: k_sync,
+ kid,
+ };
+
+ // Trade our session token for a refresh token.
+ let oauth_response = self.client.refresh_token_with_session_token(
+ &self.state.config,
+ &migration_session_token,
+ &[scopes::PROFILE, scopes::OLD_SYNC],
+ )?;
+
+ // Store the new tokens in the account state.
+ // We do this all at one at the end to avoid leaving partial state.
+ self.state.session_token = Some(migration_session_token);
+ self.handle_oauth_response(oauth_response, None)?;
+ self.state
+ .scoped_keys
+ .insert(scopes::OLD_SYNC.to_string(), k_sync_scoped_key);
+
+ Ok(())
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::{http_client::*, Config};
+ use std::collections::HashMap;
+ use std::sync::Arc;
+
+ fn setup() -> FirefoxAccount {
+ // I'd love to be able to configure a single mocked client here,
+ // but can't work out how to do that within the typesystem.
+ let config = Config::stable_dev("12345678", "https://foo.bar");
+ FirefoxAccount::with_config(config)
+ }
+
+ #[test]
+ fn test_migration_can_retry_after_network_errors() {
+ let mut fxa = setup();
+
+ assert!(matches!(fxa.is_in_migration_state(), MigrationState::None));
+
+ // Initial attempt fails with a server-side failure, which we can retry.
+ let mut client = FxAClientMock::new();
+ client
+ .expect_duplicate_session(mockiato::Argument::any, |arg| arg.partial_eq("session"))
+ .returns_once(Err(ErrorKind::RemoteError {
+ code: 500,
+ errno: 999,
+ error: "server error".to_string(),
+ message: "there was a server error".to_string(),
+ info: "fyi, there was a server error".to_string(),
+ }
+ .into()));
+ fxa.set_client(Arc::new(client));
+
+ let err = fxa
+ .migrate_from_session_token("session", "aabbcc", "ddeeff", true)
+ .unwrap_err();
+ assert!(matches!(err.kind(), ErrorKind::RemoteError { code: 500, .. }));
+ assert!(matches!(
+ fxa.is_in_migration_state(),
+ MigrationState::CopySessionToken
+ ));
+
+ // Retrying can succeed.
+ // It makes a lot of network requests, so we have a lot to mock!
+ let mut client = FxAClientMock::new();
+ client
+ .expect_duplicate_session(mockiato::Argument::any, |arg| arg.partial_eq("session"))
+ .returns_once(Ok(DuplicateTokenResponse {
+ uid: "userid".to_string(),
+ session_token: "dup_session".to_string(),
+ verified: true,
+ auth_at: 12345,
+ }));
+ let mut key_data = HashMap::new();
+ key_data.insert(
+ scopes::OLD_SYNC.to_string(),
+ ScopedKeyDataResponse {
+ identifier: scopes::OLD_SYNC.to_string(),
+ key_rotation_secret: "00000000000000000000000000000000".to_string(),
+ key_rotation_timestamp: 12345,
+ },
+ );
+ client
+ .expect_scoped_key_data(
+ mockiato::Argument::any,
+ |arg| arg.partial_eq("dup_session"),
+ |arg| arg.partial_eq("12345678"),
+ |arg| arg.partial_eq(scopes::OLD_SYNC),
+ )
+ .returns_once(Ok(key_data));
+ client
+ .expect_refresh_token_with_session_token(
+ mockiato::Argument::any,
+ |arg| arg.partial_eq("dup_session"),
+ |arg| arg.unordered_vec_eq([scopes::PROFILE, scopes::OLD_SYNC].to_vec()),
+ )
+ .returns_once(Ok(OAuthTokenResponse {
+ keys_jwe: None,
+ refresh_token: Some("refresh".to_string()),
+ session_token: None,
+ expires_in: 12345,
+ scope: "profile oldsync".to_string(),
+ access_token: "access".to_string(),
+ }));
+ client
+ .expect_destroy_access_token(mockiato::Argument::any, |arg| arg.partial_eq("access"))
+ .returns_once(Ok(()));
+ fxa.set_client(Arc::new(client));
+
+ fxa.try_migration().unwrap();
+ assert!(matches!(fxa.is_in_migration_state(), MigrationState::None));
+ }
+
+ #[test]
+ fn test_migration_cannot_retry_after_other_errors() {
+ let mut fxa = setup();
+
+ assert!(matches!(fxa.is_in_migration_state(), MigrationState::None));
+
+ let mut client = FxAClientMock::new();
+ client
+ .expect_duplicate_session(mockiato::Argument::any, |arg| arg.partial_eq("session"))
+ .returns_once(Err(ErrorKind::RemoteError {
+ code: 400,
+ errno: 102,
+ error: "invalid token".to_string(),
+ message: "the token was invalid".to_string(),
+ info: "fyi, the provided token was invalid".to_string(),
+ }
+ .into()));
+ fxa.set_client(Arc::new(client));
+
+ let err = fxa
+ .migrate_from_session_token("session", "aabbcc", "ddeeff", true)
+ .unwrap_err();
+ assert!(matches!(err.kind(), ErrorKind::RemoteError { code: 400, .. }));
+ assert!(matches!(fxa.is_in_migration_state(), MigrationState::None));
+ }
+
+ #[test]
+ fn try_migration_fails_if_nothing_in_flight() {
+ let mut fxa = setup();
+
+ assert!(matches!(fxa.is_in_migration_state(), MigrationState::None));
+
+ let err = fxa.try_migration().unwrap_err();
+ assert!(matches!(err.kind(), ErrorKind::NoMigrationData));
+ assert!(matches!(fxa.is_in_migration_state(), MigrationState::None));
+ }
+
+ #[test]
+ fn test_migration_state_remembers_whether_to_copy_session_token() {
+ let mut fxa = setup();
+
+ assert!(matches!(fxa.is_in_migration_state(), MigrationState::None));
+
+ let mut client = FxAClientMock::new();
+ client
+ .expect_scoped_key_data(
+ mockiato::Argument::any,
+ |arg| arg.partial_eq("session"),
+ |arg| arg.partial_eq("12345678"),
+ |arg| arg.partial_eq(scopes::OLD_SYNC),
+ )
+ .returns_once(Err(ErrorKind::RemoteError {
+ code: 500,
+ errno: 999,
+ error: "server error".to_string(),
+ message: "there was a server error".to_string(),
+ info: "fyi, there was a server error".to_string(),
+ }
+ .into()));
+ fxa.set_client(Arc::new(client));
+
+ let err = fxa
+ .migrate_from_session_token("session", "aabbcc", "ddeeff", false)
+ .unwrap_err();
+ assert!(matches!(err.kind(), ErrorKind::RemoteError { code: 500, .. }));
+ assert!(matches!(
+ fxa.is_in_migration_state(),
+ MigrationState::ReuseSessionToken
+ ));
+
+ // Retrying should fail again in the same way (as opposed to, say, trying
+ // to duplicate the sessionToken rather than reusing it).
+ let mut client = FxAClientMock::new();
+ client
+ .expect_scoped_key_data(
+ mockiato::Argument::any,
+ |arg| arg.partial_eq("session"),
+ |arg| arg.partial_eq("12345678"),
+ |arg| arg.partial_eq(scopes::OLD_SYNC),
+ )
+ .returns_once(Err(ErrorKind::RemoteError {
+ code: 500,
+ errno: 999,
+ error: "server error".to_string(),
+ message: "there was a server error".to_string(),
+ info: "fyi, there was a server error".to_string(),
+ }
+ .into()));
+ fxa.set_client(Arc::new(client));
+
+ let err = fxa.try_migration().unwrap_err();
+ assert!(matches!(err.kind(), ErrorKind::RemoteError { code: 500, .. }));
+ assert!(matches!(
+ fxa.is_in_migration_state(),
+ MigrationState::ReuseSessionToken
+ ));
+ }
+}
diff --git a/third_party/rust/fxa-client/src/mozilla.appservices.fxaclient.protobuf.rs b/third_party/rust/fxa-client/src/mozilla.appservices.fxaclient.protobuf.rs
new file mode 100644
index 0000000000..0cbfdaa25d
--- /dev/null
+++ b/third_party/rust/fxa-client/src/mozilla.appservices.fxaclient.protobuf.rs
@@ -0,0 +1,208 @@
+#[derive(Clone, PartialEq, ::prost::Message)]
+pub struct Profile {
+ #[prost(string, optional, tag="1")]
+ pub uid: ::std::option::Option<std::string::String>,
+ #[prost(string, optional, tag="2")]
+ pub email: ::std::option::Option<std::string::String>,
+ #[prost(string, optional, tag="3")]
+ pub avatar: ::std::option::Option<std::string::String>,
+ #[prost(bool, optional, tag="4")]
+ pub avatar_default: ::std::option::Option<bool>,
+ #[prost(string, optional, tag="5")]
+ pub display_name: ::std::option::Option<std::string::String>,
+}
+#[derive(Clone, PartialEq, ::prost::Message)]
+pub struct AccessTokenInfo {
+ #[prost(string, required, tag="1")]
+ pub scope: std::string::String,
+ #[prost(string, required, tag="2")]
+ pub token: std::string::String,
+ #[prost(message, optional, tag="3")]
+ pub key: ::std::option::Option<ScopedKey>,
+ #[prost(uint64, required, tag="4")]
+ pub expires_at: u64,
+}
+#[derive(Clone, PartialEq, ::prost::Message)]
+pub struct IntrospectInfo {
+ #[prost(bool, required, tag="1")]
+ pub active: bool,
+}
+#[derive(Clone, PartialEq, ::prost::Message)]
+pub struct ScopedKey {
+ #[prost(string, required, tag="1")]
+ pub kty: std::string::String,
+ #[prost(string, required, tag="2")]
+ pub scope: std::string::String,
+ #[prost(string, required, tag="3")]
+ pub k: std::string::String,
+ #[prost(string, required, tag="4")]
+ pub kid: std::string::String,
+}
+#[derive(Clone, PartialEq, ::prost::Message)]
+pub struct Device {
+ #[prost(string, required, tag="1")]
+ pub id: std::string::String,
+ #[prost(string, required, tag="2")]
+ pub display_name: std::string::String,
+ #[prost(enumeration="device::Type", required, tag="3")]
+ pub r#type: i32,
+ #[prost(message, optional, tag="4")]
+ pub push_subscription: ::std::option::Option<device::PushSubscription>,
+ #[prost(bool, required, tag="5")]
+ pub push_endpoint_expired: bool,
+ #[prost(bool, required, tag="6")]
+ pub is_current_device: bool,
+ #[prost(uint64, optional, tag="7")]
+ pub last_access_time: ::std::option::Option<u64>,
+ #[prost(enumeration="device::Capability", repeated, packed="false", tag="8")]
+ pub capabilities: ::std::vec::Vec<i32>,
+}
+pub mod device {
+ #[derive(Clone, PartialEq, ::prost::Message)]
+ pub struct PushSubscription {
+ #[prost(string, required, tag="1")]
+ pub endpoint: std::string::String,
+ #[prost(string, required, tag="2")]
+ pub public_key: std::string::String,
+ #[prost(string, required, tag="3")]
+ pub auth_key: std::string::String,
+ }
+ #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)]
+ #[repr(i32)]
+ pub enum Capability {
+ SendTab = 1,
+ }
+ #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)]
+ #[repr(i32)]
+ pub enum Type {
+ Desktop = 1,
+ Mobile = 2,
+ Tablet = 3,
+ Vr = 4,
+ Tv = 5,
+ Unknown = 6,
+ }
+}
+#[derive(Clone, PartialEq, ::prost::Message)]
+pub struct Devices {
+ #[prost(message, repeated, tag="1")]
+ pub devices: ::std::vec::Vec<Device>,
+}
+#[derive(Clone, PartialEq, ::prost::Message)]
+pub struct Capabilities {
+ #[prost(enumeration="device::Capability", repeated, packed="false", tag="1")]
+ pub capability: ::std::vec::Vec<i32>,
+}
+#[derive(Clone, PartialEq, ::prost::Message)]
+pub struct IncomingDeviceCommand {
+ #[prost(enumeration="incoming_device_command::IncomingDeviceCommandType", required, tag="1")]
+ pub r#type: i32,
+ #[prost(oneof="incoming_device_command::Data", tags="2")]
+ pub data: ::std::option::Option<incoming_device_command::Data>,
+}
+pub mod incoming_device_command {
+ #[derive(Clone, PartialEq, ::prost::Message)]
+ pub struct SendTabData {
+ #[prost(message, optional, tag="1")]
+ pub from: ::std::option::Option<super::Device>,
+ #[prost(message, repeated, tag="2")]
+ pub entries: ::std::vec::Vec<send_tab_data::TabHistoryEntry>,
+ }
+ pub mod send_tab_data {
+ #[derive(Clone, PartialEq, ::prost::Message)]
+ pub struct TabHistoryEntry {
+ #[prost(string, required, tag="1")]
+ pub title: std::string::String,
+ #[prost(string, required, tag="2")]
+ pub url: std::string::String,
+ }
+ }
+ #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)]
+ #[repr(i32)]
+ pub enum IncomingDeviceCommandType {
+ /// `data` set to `tab_received_data`.
+ TabReceived = 1,
+ }
+ #[derive(Clone, PartialEq, ::prost::Oneof)]
+ pub enum Data {
+ #[prost(message, tag="2")]
+ TabReceivedData(SendTabData),
+ }
+}
+#[derive(Clone, PartialEq, ::prost::Message)]
+pub struct IncomingDeviceCommands {
+ #[prost(message, repeated, tag="1")]
+ pub commands: ::std::vec::Vec<IncomingDeviceCommand>,
+}
+/// This is basically an enum with associated values,
+/// but it's a bit harder to model in proto2.
+#[derive(Clone, PartialEq, ::prost::Message)]
+pub struct AccountEvent {
+ #[prost(enumeration="account_event::AccountEventType", required, tag="1")]
+ pub r#type: i32,
+ #[prost(oneof="account_event::Data", tags="2, 3, 4")]
+ pub data: ::std::option::Option<account_event::Data>,
+}
+pub mod account_event {
+ #[derive(Clone, PartialEq, ::prost::Message)]
+ pub struct DeviceDisconnectedData {
+ #[prost(string, required, tag="1")]
+ pub device_id: std::string::String,
+ #[prost(bool, required, tag="2")]
+ pub is_local_device: bool,
+ }
+ #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)]
+ #[repr(i32)]
+ pub enum AccountEventType {
+ /// `data` set to `device_command`.
+ IncomingDeviceCommand = 1,
+ ProfileUpdated = 2,
+ /// `data` set to `device_connected_name`.
+ DeviceConnected = 3,
+ AccountAuthStateChanged = 4,
+ /// `data` set to `device_disconnected_data`.
+ DeviceDisconnected = 5,
+ AccountDestroyed = 6,
+ }
+ #[derive(Clone, PartialEq, ::prost::Oneof)]
+ pub enum Data {
+ #[prost(message, tag="2")]
+ DeviceCommand(super::IncomingDeviceCommand),
+ #[prost(string, tag="3")]
+ DeviceConnectedName(std::string::String),
+ #[prost(message, tag="4")]
+ DeviceDisconnectedData(DeviceDisconnectedData),
+ }
+}
+#[derive(Clone, PartialEq, ::prost::Message)]
+pub struct AccountEvents {
+ #[prost(message, repeated, tag="1")]
+ pub events: ::std::vec::Vec<AccountEvent>,
+}
+#[derive(Clone, PartialEq, ::prost::Message)]
+pub struct AuthorizationPkceParams {
+ #[prost(string, required, tag="1")]
+ pub code_challenge: std::string::String,
+ #[prost(string, required, tag="2")]
+ pub code_challenge_method: std::string::String,
+}
+#[derive(Clone, PartialEq, ::prost::Message)]
+pub struct AuthorizationParams {
+ #[prost(string, required, tag="1")]
+ pub client_id: std::string::String,
+ #[prost(string, required, tag="2")]
+ pub scope: std::string::String,
+ #[prost(string, required, tag="3")]
+ pub state: std::string::String,
+ #[prost(string, required, tag="4")]
+ pub access_type: std::string::String,
+ #[prost(message, optional, tag="5")]
+ pub pkce_params: ::std::option::Option<AuthorizationPkceParams>,
+ #[prost(string, optional, tag="6")]
+ pub keys_jwk: ::std::option::Option<std::string::String>,
+}
+#[derive(Clone, PartialEq, ::prost::Message)]
+pub struct MetricsParams {
+ #[prost(map="string, string", tag="1")]
+ pub parameters: ::std::collections::HashMap<std::string::String, std::string::String>,
+}
diff --git a/third_party/rust/fxa-client/src/oauth.rs b/third_party/rust/fxa-client/src/oauth.rs
new file mode 100644
index 0000000000..857c48b34b
--- /dev/null
+++ b/third_party/rust/fxa-client/src/oauth.rs
@@ -0,0 +1,1146 @@
+/* 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/. */
+
+pub mod attached_clients;
+
+use crate::{
+ error::*,
+ http_client::{AuthorizationRequestParameters, OAuthTokenResponse},
+ scoped_keys::{ScopedKey, ScopedKeysFlow},
+ util, FirefoxAccount,
+};
+use jwcrypto::{EncryptionAlgorithm, EncryptionParameters};
+use rc_crypto::digest;
+use serde_derive::*;
+use std::convert::TryFrom;
+use std::{
+ collections::{HashMap, HashSet},
+ iter::FromIterator,
+ time::{SystemTime, UNIX_EPOCH},
+};
+use url::Url;
+// If a cached token has less than `OAUTH_MIN_TIME_LEFT` seconds left to live,
+// it will be considered already expired.
+const OAUTH_MIN_TIME_LEFT: u64 = 60;
+// Special redirect urn based on the OAuth native spec, signals that the
+// WebChannel flow is used
+pub const OAUTH_WEBCHANNEL_REDIRECT: &str = "urn:ietf:wg:oauth:2.0:oob:oauth-redirect-webchannel";
+
+impl FirefoxAccount {
+ /// Fetch a short-lived access token using the saved refresh token.
+ /// If there is no refresh token held or if it is not authorized for some of the requested
+ /// scopes, this method will error-out and a login flow will need to be initiated
+ /// using `begin_oauth_flow`.
+ ///
+ /// * `scopes` - Space-separated list of requested scopes.
+ /// * `ttl` - the ttl in seconds of the token requested from the server.
+ ///
+ /// **💾 This method may alter the persisted account state.**
+ pub fn get_access_token(&mut self, scope: &str, ttl: Option<u64>) -> Result<AccessTokenInfo> {
+ if scope.contains(' ') {
+ return Err(ErrorKind::MultipleScopesRequested.into());
+ }
+ if let Some(oauth_info) = self.state.access_token_cache.get(scope) {
+ if oauth_info.expires_at > util::now_secs() + OAUTH_MIN_TIME_LEFT {
+ return Ok(oauth_info.clone());
+ }
+ }
+ let resp = match self.state.refresh_token {
+ Some(ref refresh_token) => {
+ if refresh_token.scopes.contains(scope) {
+ self.client.access_token_with_refresh_token(
+ &self.state.config,
+ &refresh_token.token,
+ ttl,
+ &[scope],
+ )?
+ } else {
+ return Err(ErrorKind::NoCachedToken(scope.to_string()).into());
+ }
+ }
+ None => match self.state.session_token {
+ Some(ref session_token) => self.client.access_token_with_session_token(
+ &self.state.config,
+ &session_token,
+ &[scope],
+ )?,
+ None => return Err(ErrorKind::NoCachedToken(scope.to_string()).into()),
+ },
+ };
+ let since_epoch = SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .map_err(|_| ErrorKind::IllegalState("Current date before Unix Epoch."))?;
+ let expires_at = since_epoch.as_secs() + resp.expires_in;
+ let token_info = AccessTokenInfo {
+ scope: resp.scope,
+ token: resp.access_token,
+ key: self.state.scoped_keys.get(scope).cloned(),
+ expires_at,
+ };
+ self.state
+ .access_token_cache
+ .insert(scope.to_string(), token_info.clone());
+ Ok(token_info)
+ }
+
+ /// Retrieve the current session token from state
+ pub fn get_session_token(&self) -> Result<String> {
+ match self.state.session_token {
+ Some(ref session_token) => Ok(session_token.to_string()),
+ None => Err(ErrorKind::NoSessionToken.into()),
+ }
+ }
+
+ /// Check whether user is authorized using our refresh token.
+ pub fn check_authorization_status(&mut self) -> Result<IntrospectInfo> {
+ let resp = match self.state.refresh_token {
+ Some(ref refresh_token) => {
+ self.auth_circuit_breaker.check()?;
+ self.client
+ .oauth_introspect_refresh_token(&self.state.config, &refresh_token.token)?
+ }
+ None => return Err(ErrorKind::NoRefreshToken.into()),
+ };
+ Ok(IntrospectInfo {
+ active: resp.active,
+ })
+ }
+
+ /// Initiate a pairing flow and return a URL that should be navigated to.
+ ///
+ /// * `pairing_url` - A pairing URL obtained by scanning a QR code produced by
+ /// the pairing authority.
+ /// * `scopes` - Space-separated list of requested scopes by the pairing supplicant.
+ /// * `entrypoint` - The entrypoint to be used for data collection
+ /// * `metrics` - Optional parameters for metrics
+ pub fn begin_pairing_flow(
+ &mut self,
+ pairing_url: &str,
+ scopes: &[&str],
+ entrypoint: &str,
+ metrics: Option<MetricsParams>,
+ ) -> Result<String> {
+ let mut url = self.state.config.pair_supp_url()?;
+ url.query_pairs_mut().append_pair("entrypoint", entrypoint);
+ if let Some(metrics) = metrics {
+ metrics.append_params_to_url(&mut url);
+ }
+ let pairing_url = Url::parse(pairing_url)?;
+ if url.host_str() != pairing_url.host_str() {
+ return Err(ErrorKind::OriginMismatch.into());
+ }
+ url.set_fragment(pairing_url.fragment());
+ self.oauth_flow(url, scopes)
+ }
+
+ /// Initiate an OAuth login flow and return a URL that should be navigated to.
+ ///
+ /// * `scopes` - Space-separated list of requested scopes.
+ /// * `entrypoint` - The entrypoint to be used for metrics
+ /// * `metrics` - Optional metrics parameters
+ pub fn begin_oauth_flow(
+ &mut self,
+ scopes: &[&str],
+ entrypoint: &str,
+ metrics: Option<MetricsParams>,
+ ) -> Result<String> {
+ let mut url = if self.state.last_seen_profile.is_some() {
+ self.state.config.oauth_force_auth_url()?
+ } else {
+ self.state.config.authorization_endpoint()?
+ };
+
+ url.query_pairs_mut()
+ .append_pair("action", "email")
+ .append_pair("response_type", "code")
+ .append_pair("entrypoint", entrypoint);
+ if let Some(metrics) = metrics {
+ metrics.append_params_to_url(&mut url);
+ }
+
+ if let Some(ref cached_profile) = self.state.last_seen_profile {
+ url.query_pairs_mut()
+ .append_pair("email", &cached_profile.response.email);
+ }
+
+ let scopes: Vec<String> = match self.state.refresh_token {
+ Some(ref refresh_token) => {
+ // Union of the already held scopes and the one requested.
+ let mut all_scopes: Vec<String> = vec![];
+ all_scopes.extend(scopes.iter().map(ToString::to_string));
+ let existing_scopes = refresh_token.scopes.clone();
+ all_scopes.extend(existing_scopes);
+ HashSet::<String>::from_iter(all_scopes)
+ .into_iter()
+ .collect()
+ }
+ None => scopes.iter().map(ToString::to_string).collect(),
+ };
+ let scopes: Vec<&str> = scopes.iter().map(<_>::as_ref).collect();
+ self.oauth_flow(url, &scopes)
+ }
+
+ /// Fetch an OAuth code for a particular client using a session token from the account state.
+ ///
+ /// * `auth_params` Authorization parameters which includes:
+ /// * `client_id` - OAuth client id.
+ /// * `scope` - list of requested scopes.
+ /// * `state` - OAuth state.
+ /// * `access_type` - Type of OAuth access, can be "offline" and "online"
+ /// * `pkce_params` - Optional PKCE parameters for public clients (`code_challenge` and `code_challenge_method`)
+ /// * `keys_jwk` - Optional JWK used to encrypt scoped keys
+ pub fn authorize_code_using_session_token(
+ &self,
+ auth_params: AuthorizationParameters,
+ ) -> Result<String> {
+ let session_token = self.get_session_token()?;
+
+ // Validate request to ensure that the client is actually allowed to request
+ // the scopes they requested
+ let allowed_scopes = self.client.scoped_key_data(
+ &self.state.config,
+ &session_token,
+ &auth_params.client_id,
+ &auth_params.scope.join(" "),
+ )?;
+
+ if let Some(not_allowed_scope) = auth_params
+ .scope
+ .iter()
+ .find(|scope| !allowed_scopes.contains_key(*scope))
+ {
+ return Err(ErrorKind::ScopeNotAllowed(
+ auth_params.client_id.clone(),
+ not_allowed_scope.clone(),
+ )
+ .into());
+ }
+
+ let keys_jwe = if let Some(keys_jwk) = auth_params.keys_jwk {
+ let mut scoped_keys = HashMap::new();
+ allowed_scopes
+ .iter()
+ .try_for_each(|(scope, _)| -> Result<()> {
+ scoped_keys.insert(
+ scope,
+ self.state
+ .scoped_keys
+ .get(scope)
+ .ok_or_else(|| ErrorKind::NoScopedKey(scope.clone()))?,
+ );
+ Ok(())
+ })?;
+ let scoped_keys = serde_json::to_string(&scoped_keys)?;
+ let keys_jwk = base64::decode_config(keys_jwk, base64::URL_SAFE_NO_PAD)?;
+ let jwk = serde_json::from_slice(&keys_jwk)?;
+ Some(jwcrypto::encrypt_to_jwe(
+ scoped_keys.as_bytes(),
+ EncryptionParameters::ECDH_ES {
+ enc: EncryptionAlgorithm::A256GCM,
+ peer_jwk: &jwk,
+ },
+ )?)
+ } else {
+ None
+ };
+ let auth_request_params = AuthorizationRequestParameters {
+ client_id: auth_params.client_id,
+ scope: auth_params.scope.join(" "),
+ state: auth_params.state,
+ access_type: auth_params.access_type,
+ code_challenge: auth_params
+ .pkce_params
+ .as_ref()
+ .map(|param| param.code_challenge.clone()),
+ code_challenge_method: auth_params
+ .pkce_params
+ .map(|param| param.code_challenge_method),
+ keys_jwe,
+ };
+
+ let resp = self.client.authorization_code_using_session_token(
+ &self.state.config,
+ &session_token,
+ auth_request_params,
+ )?;
+
+ Ok(resp.code)
+ }
+
+ fn oauth_flow(&mut self, mut url: Url, scopes: &[&str]) -> Result<String> {
+ self.clear_access_token_cache();
+ let state = util::random_base64_url_string(16)?;
+ let code_verifier = util::random_base64_url_string(43)?;
+ let code_challenge = digest::digest(&digest::SHA256, &code_verifier.as_bytes())?;
+ let code_challenge = base64::encode_config(&code_challenge, base64::URL_SAFE_NO_PAD);
+ let scoped_keys_flow = ScopedKeysFlow::with_random_key()?;
+ let jwk = scoped_keys_flow.get_public_key_jwk()?;
+ let jwk_json = serde_json::to_string(&jwk)?;
+ let keys_jwk = base64::encode_config(&jwk_json, base64::URL_SAFE_NO_PAD);
+ url.query_pairs_mut()
+ .append_pair("client_id", &self.state.config.client_id)
+ .append_pair("scope", &scopes.join(" "))
+ .append_pair("state", &state)
+ .append_pair("code_challenge_method", "S256")
+ .append_pair("code_challenge", &code_challenge)
+ .append_pair("access_type", "offline")
+ .append_pair("keys_jwk", &keys_jwk);
+
+ if self.state.config.redirect_uri == OAUTH_WEBCHANNEL_REDIRECT {
+ url.query_pairs_mut()
+ .append_pair("context", "oauth_webchannel_v1");
+ } else {
+ url.query_pairs_mut()
+ .append_pair("redirect_uri", &self.state.config.redirect_uri);
+ }
+
+ self.flow_store.insert(
+ state, // Since state is supposed to be unique, we use it to key our flows.
+ OAuthFlow {
+ scoped_keys_flow: Some(scoped_keys_flow),
+ code_verifier,
+ },
+ );
+ Ok(url.to_string())
+ }
+
+ /// Complete an OAuth flow initiated in `begin_oauth_flow` or `begin_pairing_flow`.
+ /// The `code` and `state` parameters can be obtained by parsing out the
+ /// redirect URL after a successful login.
+ ///
+ /// **💾 This method alters the persisted account state.**
+ pub fn complete_oauth_flow(&mut self, code: &str, state: &str) -> Result<()> {
+ self.clear_access_token_cache();
+ let oauth_flow = match self.flow_store.remove(state) {
+ Some(oauth_flow) => oauth_flow,
+ None => return Err(ErrorKind::UnknownOAuthState.into()),
+ };
+ let resp = self.client.refresh_token_with_code(
+ &self.state.config,
+ &code,
+ &oauth_flow.code_verifier,
+ )?;
+ self.handle_oauth_response(resp, oauth_flow.scoped_keys_flow)
+ }
+
+ pub(crate) fn handle_oauth_response(
+ &mut self,
+ resp: OAuthTokenResponse,
+ scoped_keys_flow: Option<ScopedKeysFlow>,
+ ) -> Result<()> {
+ if let Some(ref jwe) = resp.keys_jwe {
+ let scoped_keys_flow = scoped_keys_flow.ok_or_else(|| {
+ ErrorKind::UnrecoverableServerError("Got a JWE but have no JWK to decrypt it.")
+ })?;
+ let decrypted_keys = scoped_keys_flow.decrypt_keys_jwe(jwe)?;
+ let scoped_keys: serde_json::Map<String, serde_json::Value> =
+ serde_json::from_str(&decrypted_keys)?;
+ for (scope, key) in scoped_keys {
+ let scoped_key: ScopedKey = serde_json::from_value(key)?;
+ self.state.scoped_keys.insert(scope, scoped_key);
+ }
+ }
+
+ // If the client requested a 'tokens/session' OAuth scope then as part of the code
+ // exchange this will get a session_token in the response.
+ if resp.session_token.is_some() {
+ self.state.session_token = resp.session_token;
+ }
+
+ // We are only interested in the refresh token at this time because we
+ // don't want to return an over-scoped access token.
+ // Let's be good citizens and destroy this access token.
+ if let Err(err) = self
+ .client
+ .destroy_access_token(&self.state.config, &resp.access_token)
+ {
+ log::warn!("Access token destruction failure: {:?}", err);
+ }
+ let old_refresh_token = self.state.refresh_token.clone();
+ let new_refresh_token = resp
+ .refresh_token
+ .ok_or_else(|| ErrorKind::UnrecoverableServerError("No refresh token in response"))?;
+ // Destroying a refresh token also destroys its associated device,
+ // grab the device information for replication later.
+ let old_device_info = match old_refresh_token {
+ Some(_) => match self.get_current_device() {
+ Ok(maybe_device) => maybe_device,
+ Err(err) => {
+ log::warn!("Error while getting previous device information: {:?}", err);
+ None
+ }
+ },
+ None => None,
+ };
+ self.state.refresh_token = Some(RefreshToken {
+ token: new_refresh_token,
+ scopes: HashSet::from_iter(resp.scope.split(' ').map(ToString::to_string)),
+ });
+ // In order to keep 1 and only 1 refresh token alive per client instance,
+ // we also destroy the existing refresh token.
+ if let Some(ref refresh_token) = old_refresh_token {
+ if let Err(err) = self
+ .client
+ .destroy_refresh_token(&self.state.config, &refresh_token.token)
+ {
+ log::warn!("Refresh token destruction failure: {:?}", err);
+ }
+ }
+ if let Some(ref device_info) = old_device_info {
+ if let Err(err) = self.replace_device(
+ &device_info.display_name,
+ &device_info.device_type,
+ &device_info.push_subscription,
+ &device_info.available_commands,
+ ) {
+ log::warn!("Device information restoration failed: {:?}", err);
+ }
+ }
+ // When our keys change, we might need to re-register device capabilities with the server.
+ // Ensure that this happens on the next call to ensure_capabilities.
+ self.state.device_capabilities.clear();
+ Ok(())
+ }
+
+ /// Typically called during a password change flow.
+ /// Invalidates all tokens and fetches a new refresh token.
+ /// Because the old refresh token is not valid anymore, we can't do like `handle_oauth_response`
+ /// and re-create the device, so it is the responsibility of the caller to do so after we're
+ /// done.
+ ///
+ /// **💾 This method alters the persisted account state.**
+ pub fn handle_session_token_change(&mut self, session_token: &str) -> Result<()> {
+ let old_refresh_token = self
+ .state
+ .refresh_token
+ .as_ref()
+ .ok_or_else(|| ErrorKind::NoRefreshToken)?;
+ let scopes: Vec<&str> = old_refresh_token.scopes.iter().map(AsRef::as_ref).collect();
+ let resp = self.client.refresh_token_with_session_token(
+ &self.state.config,
+ &session_token,
+ &scopes,
+ )?;
+ let new_refresh_token = resp
+ .refresh_token
+ .ok_or_else(|| ErrorKind::UnrecoverableServerError("No refresh token in response"))?;
+ self.state.refresh_token = Some(RefreshToken {
+ token: new_refresh_token,
+ scopes: HashSet::from_iter(resp.scope.split(' ').map(ToString::to_string)),
+ });
+ self.state.session_token = Some(session_token.to_owned());
+ self.clear_access_token_cache();
+ self.clear_devices_and_attached_clients_cache();
+ // When our keys change, we might need to re-register device capabilities with the server.
+ // Ensure that this happens on the next call to ensure_capabilities.
+ self.state.device_capabilities.clear();
+ Ok(())
+ }
+
+ /// **💾 This method may alter the persisted account state.**
+ pub fn clear_access_token_cache(&mut self) {
+ self.state.access_token_cache.clear();
+ }
+
+ #[cfg(feature = "integration_test")]
+ pub fn new_logged_in(
+ config: crate::Config,
+ session_token: &str,
+ scoped_keys: HashMap<String, ScopedKey>,
+ ) -> Self {
+ let mut fxa = FirefoxAccount::with_config(config);
+ fxa.state.session_token = Some(session_token.to_owned());
+ scoped_keys.iter().for_each(|(key, val)| {
+ fxa.state.scoped_keys.insert(key.to_string(), val.clone());
+ });
+ fxa
+ }
+}
+
+const AUTH_CIRCUIT_BREAKER_CAPACITY: u8 = 5;
+const AUTH_CIRCUIT_BREAKER_RENEWAL_RATE: f32 = 3.0 / 60.0 / 1000.0; // 3 tokens every minute.
+
+// The auth circuit breaker rate-limits access to the `oauth_introspect_refresh_token`
+// using a fairly naively implemented token bucket algorithm.
+#[derive(Clone, Copy)]
+pub(crate) struct AuthCircuitBreaker {
+ tokens: u8,
+ last_refill: u64, // in ms.
+}
+
+impl Default for AuthCircuitBreaker {
+ fn default() -> Self {
+ AuthCircuitBreaker {
+ tokens: AUTH_CIRCUIT_BREAKER_CAPACITY,
+ last_refill: Self::now(),
+ }
+ }
+}
+
+impl AuthCircuitBreaker {
+ pub(crate) fn check(&mut self) -> Result<()> {
+ self.refill();
+ if self.tokens == 0 {
+ return Err(ErrorKind::AuthCircuitBreakerError.into());
+ }
+ self.tokens -= 1;
+ Ok(())
+ }
+
+ fn refill(&mut self) {
+ let now = Self::now();
+ let new_tokens =
+ ((now - self.last_refill) as f64 * AUTH_CIRCUIT_BREAKER_RENEWAL_RATE as f64) as u8; // `as` is a truncating/saturing cast.
+ if new_tokens > 0 {
+ self.last_refill = now;
+ self.tokens = std::cmp::min(
+ AUTH_CIRCUIT_BREAKER_CAPACITY,
+ self.tokens.saturating_add(new_tokens),
+ );
+ }
+ }
+
+ #[cfg(not(test))]
+ #[inline]
+ fn now() -> u64 {
+ util::now()
+ }
+
+ #[cfg(test)]
+ fn now() -> u64 {
+ 1600000000000
+ }
+}
+
+#[derive(Clone)]
+pub struct AuthorizationPKCEParams {
+ pub code_challenge: String,
+ pub code_challenge_method: String,
+}
+
+#[derive(Clone)]
+pub struct AuthorizationParameters {
+ pub client_id: String,
+ pub scope: Vec<String>,
+ pub state: String,
+ pub access_type: String,
+ pub pkce_params: Option<AuthorizationPKCEParams>,
+ pub keys_jwk: Option<String>,
+}
+
+impl TryFrom<Url> for AuthorizationParameters {
+ type Error = Error;
+
+ fn try_from(url: Url) -> Result<Self> {
+ let query_map: HashMap<String, String> = url.query_pairs().into_owned().collect();
+ let scope = query_map
+ .get("scope")
+ .cloned()
+ .ok_or_else(|| ErrorKind::MissingUrlParameter("scope"))?;
+ let client_id = query_map
+ .get("client_id")
+ .cloned()
+ .ok_or_else(|| ErrorKind::MissingUrlParameter("client_id"))?;
+ let state = query_map
+ .get("state")
+ .cloned()
+ .ok_or_else(|| ErrorKind::MissingUrlParameter("state"))?;
+ let access_type = query_map
+ .get("access_type")
+ .cloned()
+ .ok_or_else(|| ErrorKind::MissingUrlParameter("access_type"))?;
+ let code_challenge = query_map.get("code_challenge").cloned();
+ let code_challenge_method = query_map.get("code_challenge_method").cloned();
+ let pkce_params = match (code_challenge, code_challenge_method) {
+ (Some(code_challenge), Some(code_challenge_method)) => Some(AuthorizationPKCEParams {
+ code_challenge,
+ code_challenge_method,
+ }),
+ _ => None,
+ };
+ let keys_jwk = query_map.get("keys_jwk").cloned();
+ Ok(Self {
+ client_id,
+ scope: scope.split_whitespace().map(|s| s.to_string()).collect(),
+ state,
+ access_type,
+ pkce_params,
+ keys_jwk,
+ })
+ }
+}
+pub struct MetricsParams {
+ pub parameters: std::collections::HashMap<String, String>,
+}
+
+impl MetricsParams {
+ fn append_params_to_url(&self, url: &mut Url) {
+ self.parameters
+ .iter()
+ .for_each(|(parameter_name, parameter_value)| {
+ url.query_pairs_mut()
+ .append_pair(parameter_name, parameter_value);
+ });
+ }
+}
+
+#[derive(Clone, Serialize, Deserialize)]
+pub struct RefreshToken {
+ pub token: String,
+ pub scopes: HashSet<String>,
+}
+
+impl std::fmt::Debug for RefreshToken {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ f.debug_struct("RefreshToken")
+ .field("scopes", &self.scopes)
+ .finish()
+ }
+}
+
+pub struct OAuthFlow {
+ pub scoped_keys_flow: Option<ScopedKeysFlow>,
+ pub code_verifier: String,
+}
+
+#[derive(Clone, Serialize, Deserialize)]
+pub struct AccessTokenInfo {
+ pub scope: String,
+ pub token: String,
+ pub key: Option<ScopedKey>,
+ pub expires_at: u64, // seconds since epoch
+}
+
+impl std::fmt::Debug for AccessTokenInfo {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ f.debug_struct("AccessTokenInfo")
+ .field("scope", &self.scope)
+ .field("key", &self.key)
+ .field("expires_at", &self.expires_at)
+ .finish()
+ }
+}
+
+#[derive(Clone, Serialize, Deserialize, Debug)]
+pub struct IntrospectInfo {
+ pub active: bool,
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::{http_client::*, Config};
+ use std::borrow::Cow;
+ use std::collections::HashMap;
+ use std::sync::Arc;
+
+ impl FirefoxAccount {
+ pub fn add_cached_token(&mut self, scope: &str, token_info: AccessTokenInfo) {
+ self.state
+ .access_token_cache
+ .insert(scope.to_string(), token_info);
+ }
+
+ pub fn set_session_token(&mut self, session_token: &str) {
+ self.state.session_token = Some(session_token.to_owned());
+ }
+ }
+
+ #[test]
+ fn test_oauth_flow_url() {
+ // FIXME: this test shouldn't make network requests.
+ viaduct_reqwest::use_reqwest_backend();
+ let config = Config::new(
+ "https://accounts.firefox.com",
+ "12345678",
+ "https://foo.bar",
+ );
+ let mut params = HashMap::new();
+ params.insert("flow_id".to_string(), "87654321".to_string());
+ let metrics_params = MetricsParams { parameters: params };
+ let mut fxa = FirefoxAccount::with_config(config);
+ let url = fxa
+ .begin_oauth_flow(&["profile"], "test_oauth_flow_url", Some(metrics_params))
+ .unwrap();
+ let flow_url = Url::parse(&url).unwrap();
+
+ assert_eq!(flow_url.host_str(), Some("accounts.firefox.com"));
+ assert_eq!(flow_url.path(), "/authorization");
+
+ let mut pairs = flow_url.query_pairs();
+ assert_eq!(pairs.count(), 12);
+ assert_eq!(
+ pairs.next(),
+ Some((Cow::Borrowed("action"), Cow::Borrowed("email")))
+ );
+ assert_eq!(
+ pairs.next(),
+ Some((Cow::Borrowed("response_type"), Cow::Borrowed("code")))
+ );
+ assert_eq!(
+ pairs.next(),
+ Some((
+ Cow::Borrowed("entrypoint"),
+ Cow::Borrowed("test_oauth_flow_url")
+ ))
+ );
+ assert_eq!(
+ pairs.next(),
+ Some((Cow::Borrowed("flow_id"), Cow::Borrowed("87654321")))
+ );
+ assert_eq!(
+ pairs.next(),
+ Some((Cow::Borrowed("client_id"), Cow::Borrowed("12345678")))
+ );
+
+ assert_eq!(
+ pairs.next(),
+ Some((Cow::Borrowed("scope"), Cow::Borrowed("profile")))
+ );
+ let state_param = pairs.next().unwrap();
+ assert_eq!(state_param.0, Cow::Borrowed("state"));
+ assert_eq!(state_param.1.len(), 22);
+ assert_eq!(
+ pairs.next(),
+ Some((
+ Cow::Borrowed("code_challenge_method"),
+ Cow::Borrowed("S256")
+ ))
+ );
+ let code_challenge_param = pairs.next().unwrap();
+ assert_eq!(code_challenge_param.0, Cow::Borrowed("code_challenge"));
+ assert_eq!(code_challenge_param.1.len(), 43);
+ assert_eq!(
+ pairs.next(),
+ Some((Cow::Borrowed("access_type"), Cow::Borrowed("offline")))
+ );
+ let keys_jwk = pairs.next().unwrap();
+ assert_eq!(keys_jwk.0, Cow::Borrowed("keys_jwk"));
+ assert_eq!(keys_jwk.1.len(), 168);
+
+ assert_eq!(
+ pairs.next(),
+ Some((
+ Cow::Borrowed("redirect_uri"),
+ Cow::Borrowed("https://foo.bar")
+ ))
+ );
+ }
+
+ #[test]
+ fn test_force_auth_url() {
+ let config = Config::stable_dev("12345678", "https://foo.bar");
+ let mut fxa = FirefoxAccount::with_config(config);
+ let email = "test@example.com";
+ fxa.add_cached_profile("123", email);
+ let url = fxa
+ .begin_oauth_flow(&["profile"], "test_force_auth_url", None)
+ .unwrap();
+ let url = Url::parse(&url).unwrap();
+ assert_eq!(url.path(), "/oauth/force_auth");
+ let mut pairs = url.query_pairs();
+ assert_eq!(
+ pairs.find(|e| e.0 == "email"),
+ Some((Cow::Borrowed("email"), Cow::Borrowed(email),))
+ );
+ }
+
+ #[test]
+ fn test_webchannel_context_url() {
+ // FIXME: this test shouldn't make network requests.
+ viaduct_reqwest::use_reqwest_backend();
+ const SCOPES: &[&str] = &["https://identity.mozilla.com/apps/oldsync"];
+ let config = Config::new(
+ "https://accounts.firefox.com",
+ "12345678",
+ "urn:ietf:wg:oauth:2.0:oob:oauth-redirect-webchannel",
+ );
+ let mut fxa = FirefoxAccount::with_config(config);
+ let url = fxa
+ .begin_oauth_flow(&SCOPES, "test_webchannel_context_url", None)
+ .unwrap();
+ let url = Url::parse(&url).unwrap();
+ let query_params: HashMap<_, _> = url.query_pairs().into_owned().collect();
+ let context = &query_params["context"];
+ assert_eq!(context, "oauth_webchannel_v1");
+ assert_eq!(query_params.get("redirect_uri"), None);
+ }
+
+ #[test]
+ fn test_webchannel_pairing_context_url() {
+ const SCOPES: &[&str] = &["https://identity.mozilla.com/apps/oldsync"];
+ const PAIRING_URL: &str = "https://accounts.firefox.com/pair#channel_id=658db7fe98b249a5897b884f98fb31b7&channel_key=1hIDzTj5oY2HDeSg_jA2DhcOcAn5Uqq0cAYlZRNUIo4";
+
+ let config = Config::new(
+ "https://accounts.firefox.com",
+ "12345678",
+ "urn:ietf:wg:oauth:2.0:oob:oauth-redirect-webchannel",
+ );
+ let mut fxa = FirefoxAccount::with_config(config);
+ let url = fxa
+ .begin_pairing_flow(
+ &PAIRING_URL,
+ &SCOPES,
+ "test_webchannel_pairing_context_url",
+ None,
+ )
+ .unwrap();
+ let url = Url::parse(&url).unwrap();
+ let query_params: HashMap<_, _> = url.query_pairs().into_owned().collect();
+ let context = &query_params["context"];
+ assert_eq!(context, "oauth_webchannel_v1");
+ assert_eq!(query_params.get("redirect_uri"), None);
+ }
+
+ #[test]
+ fn test_pairing_flow_url() {
+ const SCOPES: &[&str] = &["https://identity.mozilla.com/apps/oldsync"];
+ const PAIRING_URL: &str = "https://accounts.firefox.com/pair#channel_id=658db7fe98b249a5897b884f98fb31b7&channel_key=1hIDzTj5oY2HDeSg_jA2DhcOcAn5Uqq0cAYlZRNUIo4";
+ const EXPECTED_URL: &str = "https://accounts.firefox.com/pair/supp?client_id=12345678&redirect_uri=https%3A%2F%2Ffoo.bar&scope=https%3A%2F%2Fidentity.mozilla.com%2Fapps%2Foldsync&state=SmbAA_9EA5v1R2bgIPeWWw&code_challenge_method=S256&code_challenge=ZgHLPPJ8XYbXpo7VIb7wFw0yXlTa6MUOVfGiADt0JSM&access_type=offline&keys_jwk=eyJjcnYiOiJQLTI1NiIsImt0eSI6IkVDIiwieCI6Ing5LUltQjJveDM0LTV6c1VmbW5sNEp0Ti14elV2eFZlZXJHTFRXRV9BT0kiLCJ5IjoiNXBKbTB3WGQ4YXdHcm0zREl4T1pWMl9qdl9tZEx1TWlMb1RkZ1RucWJDZyJ9#channel_id=658db7fe98b249a5897b884f98fb31b7&channel_key=1hIDzTj5oY2HDeSg_jA2DhcOcAn5Uqq0cAYlZRNUIo4";
+
+ let config = Config::new(
+ "https://accounts.firefox.com",
+ "12345678",
+ "https://foo.bar",
+ );
+ let mut params = HashMap::new();
+ params.insert("flow_id".to_string(), "87654321".to_string());
+ let metrics_params = MetricsParams { parameters: params };
+
+ let mut fxa = FirefoxAccount::with_config(config);
+ let url = fxa
+ .begin_pairing_flow(
+ &PAIRING_URL,
+ &SCOPES,
+ "test_pairing_flow_url",
+ Some(metrics_params),
+ )
+ .unwrap();
+ let flow_url = Url::parse(&url).unwrap();
+ let expected_parsed_url = Url::parse(EXPECTED_URL).unwrap();
+
+ assert_eq!(flow_url.host_str(), Some("accounts.firefox.com"));
+ assert_eq!(flow_url.path(), "/pair/supp");
+ assert_eq!(flow_url.fragment(), expected_parsed_url.fragment());
+
+ let mut pairs = flow_url.query_pairs();
+ assert_eq!(pairs.count(), 10);
+ assert_eq!(
+ pairs.next(),
+ Some((
+ Cow::Borrowed("entrypoint"),
+ Cow::Borrowed("test_pairing_flow_url")
+ ))
+ );
+ assert_eq!(
+ pairs.next(),
+ Some((Cow::Borrowed("flow_id"), Cow::Borrowed("87654321")))
+ );
+ assert_eq!(
+ pairs.next(),
+ Some((Cow::Borrowed("client_id"), Cow::Borrowed("12345678")))
+ );
+ assert_eq!(
+ pairs.next(),
+ Some((
+ Cow::Borrowed("scope"),
+ Cow::Borrowed("https://identity.mozilla.com/apps/oldsync")
+ ))
+ );
+
+ let state_param = pairs.next().unwrap();
+ assert_eq!(state_param.0, Cow::Borrowed("state"));
+ assert_eq!(state_param.1.len(), 22);
+ assert_eq!(
+ pairs.next(),
+ Some((
+ Cow::Borrowed("code_challenge_method"),
+ Cow::Borrowed("S256")
+ ))
+ );
+ let code_challenge_param = pairs.next().unwrap();
+ assert_eq!(code_challenge_param.0, Cow::Borrowed("code_challenge"));
+ assert_eq!(code_challenge_param.1.len(), 43);
+ assert_eq!(
+ pairs.next(),
+ Some((Cow::Borrowed("access_type"), Cow::Borrowed("offline")))
+ );
+ let keys_jwk = pairs.next().unwrap();
+ assert_eq!(keys_jwk.0, Cow::Borrowed("keys_jwk"));
+ assert_eq!(keys_jwk.1.len(), 168);
+
+ assert_eq!(
+ pairs.next(),
+ Some((
+ Cow::Borrowed("redirect_uri"),
+ Cow::Borrowed("https://foo.bar")
+ ))
+ );
+ }
+
+ #[test]
+ fn test_pairing_flow_origin_mismatch() {
+ static PAIRING_URL: &str = "https://bad.origin.com/pair#channel_id=foo&channel_key=bar";
+ let config = Config::stable_dev("12345678", "https://foo.bar");
+ let mut fxa = FirefoxAccount::with_config(config);
+ let url = fxa.begin_pairing_flow(
+ &PAIRING_URL,
+ &["https://identity.mozilla.com/apps/oldsync"],
+ "test_pairiong_flow_origin_mismatch",
+ None,
+ );
+
+ assert!(url.is_err());
+
+ match url {
+ Ok(_) => {
+ panic!("should have error");
+ }
+ Err(err) => match err.kind() {
+ ErrorKind::OriginMismatch { .. } => {}
+ _ => panic!("error not OriginMismatch"),
+ },
+ }
+ }
+
+ #[test]
+ fn test_check_authorization_status() {
+ let config = Config::stable_dev("12345678", "https://foo.bar");
+ let mut fxa = FirefoxAccount::with_config(config);
+
+ let refresh_token_scopes = std::collections::HashSet::new();
+ fxa.state.refresh_token = Some(RefreshToken {
+ token: "refresh_token".to_owned(),
+ scopes: refresh_token_scopes,
+ });
+
+ let mut client = FxAClientMock::new();
+ client
+ .expect_oauth_introspect_refresh_token(mockiato::Argument::any, |token| {
+ token.partial_eq("refresh_token")
+ })
+ .times(1)
+ .returns_once(Ok(IntrospectResponse { active: true }));
+ fxa.set_client(Arc::new(client));
+
+ let auth_status = fxa.check_authorization_status().unwrap();
+ assert_eq!(auth_status.active, true);
+ }
+
+ #[test]
+ fn test_check_authorization_status_circuit_breaker() {
+ let config = Config::stable_dev("12345678", "https://foo.bar");
+ let mut fxa = FirefoxAccount::with_config(config);
+
+ let refresh_token_scopes = std::collections::HashSet::new();
+ fxa.state.refresh_token = Some(RefreshToken {
+ token: "refresh_token".to_owned(),
+ scopes: refresh_token_scopes,
+ });
+
+ let mut client = FxAClientMock::new();
+ // This copy-pasta (equivalent to `.returns(..).times(5)`) is there
+ // because `Error` is not cloneable :/
+ client
+ .expect_oauth_introspect_refresh_token(mockiato::Argument::any, |token| {
+ token.partial_eq("refresh_token")
+ })
+ .returns_once(Ok(IntrospectResponse { active: true }));
+ client
+ .expect_oauth_introspect_refresh_token(mockiato::Argument::any, |token| {
+ token.partial_eq("refresh_token")
+ })
+ .returns_once(Ok(IntrospectResponse { active: true }));
+ client
+ .expect_oauth_introspect_refresh_token(mockiato::Argument::any, |token| {
+ token.partial_eq("refresh_token")
+ })
+ .returns_once(Ok(IntrospectResponse { active: true }));
+ client
+ .expect_oauth_introspect_refresh_token(mockiato::Argument::any, |token| {
+ token.partial_eq("refresh_token")
+ })
+ .returns_once(Ok(IntrospectResponse { active: true }));
+ client
+ .expect_oauth_introspect_refresh_token(mockiato::Argument::any, |token| {
+ token.partial_eq("refresh_token")
+ })
+ .returns_once(Ok(IntrospectResponse { active: true }));
+ client.expect_oauth_introspect_refresh_token_calls_in_order();
+ fxa.set_client(Arc::new(client));
+
+ for _ in 0..5 {
+ assert!(fxa.check_authorization_status().is_ok());
+ }
+ match fxa.check_authorization_status() {
+ Ok(_) => unreachable!("should not happen"),
+ Err(err) => assert!(matches!(err.kind(), ErrorKind::AuthCircuitBreakerError)),
+ }
+ }
+
+ #[test]
+ fn test_auth_circuit_breaker_unit_recovery() {
+ let mut breaker = AuthCircuitBreaker::default();
+ // AuthCircuitBreaker::now is fixed for tests, let's assert that for sanity.
+ assert_eq!(AuthCircuitBreaker::now(), 1600000000000);
+ for _ in 0..AUTH_CIRCUIT_BREAKER_CAPACITY {
+ assert!(breaker.check().is_ok());
+ }
+ assert!(breaker.check().is_err());
+ // Jump back in time (1 min).
+ breaker.last_refill -= 60 * 1000;
+ let expected_tokens_before_check: u8 =
+ (AUTH_CIRCUIT_BREAKER_RENEWAL_RATE * 60.0 * 1000.0) as u8;
+ assert!(breaker.check().is_ok());
+ assert_eq!(breaker.tokens, expected_tokens_before_check - 1);
+ }
+
+ use crate::scopes;
+
+ #[test]
+ fn test_auth_code_pair_valid_not_allowed_scope() {
+ let config = Config::stable_dev("12345678", "https://foo.bar");
+ let mut fxa = FirefoxAccount::with_config(config);
+ fxa.set_session_token("session");
+ let mut client = FxAClientMock::new();
+ let not_allowed_scope = "https://identity.mozilla.com/apps/lockbox";
+ let expected_scopes = scopes::OLD_SYNC
+ .chars()
+ .chain(std::iter::once(' '))
+ .chain(not_allowed_scope.chars())
+ .collect::<String>();
+ client
+ .expect_scoped_key_data(
+ mockiato::Argument::any,
+ |arg| arg.partial_eq("session"),
+ |arg| arg.partial_eq("12345678"),
+ |arg| arg.partial_eq(expected_scopes),
+ )
+ .returns_once(Err(ErrorKind::RemoteError {
+ code: 400,
+ errno: 163,
+ error: "Invalid Scopes".to_string(),
+ message: "Not allowed to request scopes".to_string(),
+ info: "fyi, there was a server error".to_string(),
+ }
+ .into()));
+ fxa.set_client(Arc::new(client));
+ let auth_params = AuthorizationParameters {
+ client_id: "12345678".to_string(),
+ scope: vec![scopes::OLD_SYNC.to_string(), not_allowed_scope.to_string()],
+ state: "somestate".to_string(),
+ access_type: "offline".to_string(),
+ pkce_params: None,
+ keys_jwk: None,
+ };
+ let res = fxa.authorize_code_using_session_token(auth_params);
+ assert!(res.is_err());
+ let err = res.unwrap_err();
+ if let ErrorKind::RemoteError {
+ code,
+ errno,
+ error: _,
+ message: _,
+ info: _,
+ } = err.kind()
+ {
+ assert_eq!(*code, 400);
+ assert_eq!(*errno, 163); // Requested scopes not allowed
+ } else {
+ panic!("Should return an error from the server specifying that the requested scopes are not allowed");
+ }
+ }
+
+ #[test]
+ fn test_auth_code_pair_invalid_scope_not_allowed() {
+ let config = Config::stable_dev("12345678", "https://foo.bar");
+ let mut fxa = FirefoxAccount::with_config(config);
+ fxa.set_session_token("session");
+ let mut client = FxAClientMock::new();
+ let invalid_scope = "IamAnInvalidScope";
+ let expected_scopes = scopes::OLD_SYNC
+ .chars()
+ .chain(std::iter::once(' '))
+ .chain(invalid_scope.chars())
+ .collect::<String>();
+ let mut server_ret = HashMap::new();
+ server_ret.insert(
+ scopes::OLD_SYNC.to_string(),
+ ScopedKeyDataResponse {
+ key_rotation_secret: "IamASecret".to_string(),
+ key_rotation_timestamp: 100,
+ identifier: "".to_string(),
+ },
+ );
+ client
+ .expect_scoped_key_data(
+ mockiato::Argument::any,
+ |arg| arg.partial_eq("session"),
+ |arg| arg.partial_eq("12345678"),
+ |arg| arg.partial_eq(expected_scopes),
+ )
+ .returns_once(Ok(server_ret));
+ fxa.set_client(Arc::new(client));
+
+ let auth_params = AuthorizationParameters {
+ client_id: "12345678".to_string(),
+ scope: vec![scopes::OLD_SYNC.to_string(), invalid_scope.to_string()],
+ state: "somestate".to_string(),
+ access_type: "offline".to_string(),
+ pkce_params: None,
+ keys_jwk: None,
+ };
+ let res = fxa.authorize_code_using_session_token(auth_params);
+ assert!(res.is_err());
+ let err = res.unwrap_err();
+ if let ErrorKind::ScopeNotAllowed(client_id, scope) = err.kind() {
+ assert_eq!(client_id.clone(), "12345678");
+ assert_eq!(scope.clone(), "IamAnInvalidScope");
+ } else {
+ panic!("Should return an error that specifies the scope that is not allowed");
+ }
+ }
+
+ #[test]
+ fn test_auth_code_pair_scope_not_in_state() {
+ let config = Config::stable_dev("12345678", "https://foo.bar");
+ let mut fxa = FirefoxAccount::with_config(config);
+ fxa.set_session_token("session");
+ let mut client = FxAClientMock::new();
+ let mut server_ret = HashMap::new();
+ server_ret.insert(
+ scopes::OLD_SYNC.to_string(),
+ ScopedKeyDataResponse {
+ key_rotation_secret: "IamASecret".to_string(),
+ key_rotation_timestamp: 100,
+ identifier: "".to_string(),
+ },
+ );
+ client
+ .expect_scoped_key_data(
+ mockiato::Argument::any,
+ |arg| arg.partial_eq("session"),
+ |arg| arg.partial_eq("12345678"),
+ |arg| arg.partial_eq(scopes::OLD_SYNC),
+ )
+ .returns_once(Ok(server_ret));
+ fxa.set_client(Arc::new(client));
+ let auth_params = AuthorizationParameters {
+ client_id: "12345678".to_string(),
+ scope: vec![scopes::OLD_SYNC.to_string()],
+ state: "somestate".to_string(),
+ access_type: "offline".to_string(),
+ pkce_params: None,
+ keys_jwk: Some("IAmAVerySecretKeysJWkInBase64".to_string()),
+ };
+ let res = fxa.authorize_code_using_session_token(auth_params);
+ assert!(res.is_err());
+ let err = res.unwrap_err();
+ if let ErrorKind::NoScopedKey(scope) = err.kind() {
+ assert_eq!(scope.clone(), scopes::OLD_SYNC.to_string());
+ } else {
+ panic!("Should return an error that specifies the scope that is not in the state");
+ }
+ }
+}
diff --git a/third_party/rust/fxa-client/src/oauth/attached_clients.rs b/third_party/rust/fxa-client/src/oauth/attached_clients.rs
new file mode 100644
index 0000000000..52b91ed98c
--- /dev/null
+++ b/third_party/rust/fxa-client/src/oauth/attached_clients.rs
@@ -0,0 +1,113 @@
+/* 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/. */
+
+pub use crate::http_client::GetAttachedClientResponse as AttachedClient;
+use crate::{error::*, util, CachedResponse, FirefoxAccount};
+
+// An attached clients response is considered fresh for `ATTACHED_CLIENTS_FRESHNESS_THRESHOLD` ms.
+const ATTACHED_CLIENTS_FRESHNESS_THRESHOLD: u64 = 60_000; // 1 minute
+
+impl FirefoxAccount {
+ /// Fetches the list of attached clients connected to the current account.
+ pub fn get_attached_clients(&mut self) -> Result<Vec<AttachedClient>> {
+ if let Some(a) = &self.attached_clients_cache {
+ if util::now() < a.cached_at + ATTACHED_CLIENTS_FRESHNESS_THRESHOLD {
+ return Ok(a.response.clone());
+ }
+ }
+ let session_token = self.get_session_token()?;
+ let response = self
+ .client
+ .attached_clients(&self.state.config, &session_token)?;
+
+ self.attached_clients_cache = Some(CachedResponse {
+ response: response.clone(),
+ cached_at: util::now(),
+ etag: "".into(),
+ });
+
+ Ok(response)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::{
+ config::Config,
+ http_client::{DeviceType, FxAClientMock},
+ };
+ use std::sync::Arc;
+
+ #[test]
+ fn test_get_attached_clients() {
+ let config = Config::stable_dev("12345678", "https://foo.bar");
+ let mut fxa = FirefoxAccount::with_config(config);
+ fxa.set_session_token("session");
+
+ let mut client = FxAClientMock::new();
+ client
+ .expect_attached_clients(mockiato::Argument::any, |arg| arg.partial_eq("session"))
+ .times(1)
+ .returns_once(Ok(vec![AttachedClient {
+ client_id: Some("12345678".into()),
+ session_token_id: None,
+ refresh_token_id: None,
+ device_id: None,
+ device_type: Some(DeviceType::Desktop),
+ is_current_session: true,
+ name: None,
+ created_time: None,
+ last_access_time: None,
+ scope: None,
+ user_agent: "attachedClientsUserAgent".into(),
+ os: None,
+ }]));
+
+ fxa.set_client(Arc::new(client));
+ assert!(fxa.attached_clients_cache.is_none());
+
+ let res = fxa.get_attached_clients();
+
+ assert!(res.is_ok());
+ assert!(fxa.attached_clients_cache.is_some());
+
+ let cached_attached_clients_res = fxa.attached_clients_cache.unwrap();
+ assert!(!cached_attached_clients_res.response.is_empty());
+ assert!(cached_attached_clients_res.cached_at > 0);
+
+ let cached_attached_clients = &cached_attached_clients_res.response[0];
+ assert_eq!(
+ cached_attached_clients.clone().client_id.unwrap(),
+ "12345678".to_string()
+ );
+ }
+
+ #[test]
+ fn test_get_attached_clients_network_errors() {
+ let config = Config::stable_dev("12345678", "https://foo.bar");
+ let mut fxa = FirefoxAccount::with_config(config);
+ fxa.set_session_token("session");
+
+ let mut client = FxAClientMock::new();
+ client
+ .expect_attached_clients(mockiato::Argument::any, |arg| arg.partial_eq("session"))
+ .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.attached_clients_cache.is_none());
+
+ let res = fxa.get_attached_clients();
+ assert!(res.is_err());
+ assert!(fxa.attached_clients_cache.is_none());
+ }
+}
diff --git a/third_party/rust/fxa-client/src/profile.rs b/third_party/rust/fxa-client/src/profile.rs
new file mode 100644
index 0000000000..d6628a2266
--- /dev/null
+++ b/third_party/rust/fxa-client/src/profile.rs
@@ -0,0 +1,228 @@
+/* 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/. */
+
+pub use crate::http_client::ProfileResponse as Profile;
+use crate::{error::*, scopes, util, CachedResponse, FirefoxAccount};
+
+// A cached profile response is considered fresh for `PROFILE_FRESHNESS_THRESHOLD` ms.
+const PROFILE_FRESHNESS_THRESHOLD: u64 = 120_000; // 2 minutes
+
+impl FirefoxAccount {
+ /// Fetch the profile for the user.
+ /// This method will error-out if the `profile` scope is not
+ /// authorized for the current refresh token or or if we do
+ /// not have a valid refresh token.
+ ///
+ /// * `ignore_cache` - If set to true, bypass the in-memory cache
+ /// and fetch the entire profile data from the server.
+ ///
+ /// **💾 This method alters the persisted account state.**
+ pub fn get_profile(&mut self, ignore_cache: bool) -> Result<Profile> {
+ match self.get_profile_helper(ignore_cache) {
+ Ok(res) => Ok(res),
+ Err(e) => match e.kind() {
+ ErrorKind::RemoteError { code: 401, .. } => {
+ log::warn!(
+ "Access token rejected, clearing the tokens cache and trying again."
+ );
+ self.clear_access_token_cache();
+ self.clear_devices_and_attached_clients_cache();
+ self.get_profile_helper(ignore_cache)
+ }
+ _ => Err(e),
+ },
+ }
+ }
+
+ fn get_profile_helper(&mut self, ignore_cache: bool) -> Result<Profile> {
+ let mut etag = None;
+ if let Some(ref cached_profile) = self.state.last_seen_profile {
+ if !ignore_cache && util::now() < cached_profile.cached_at + PROFILE_FRESHNESS_THRESHOLD
+ {
+ return Ok(cached_profile.response.clone());
+ }
+ etag = Some(cached_profile.etag.clone());
+ }
+ let profile_access_token = self.get_access_token(scopes::PROFILE, None)?.token;
+ match self
+ .client
+ .profile(&self.state.config, &profile_access_token, etag)?
+ {
+ Some(response_and_etag) => {
+ if let Some(etag) = response_and_etag.etag {
+ self.state.last_seen_profile = Some(CachedResponse {
+ response: response_and_etag.response.clone(),
+ cached_at: util::now(),
+ etag,
+ });
+ }
+ Ok(response_and_etag.response)
+ }
+ None => {
+ match self.state.last_seen_profile.take() {
+ Some(ref cached_profile) => {
+ // Update `cached_at` timestamp.
+ self.state.last_seen_profile.replace(CachedResponse {
+ response: cached_profile.response.clone(),
+ cached_at: util::now(),
+ etag: cached_profile.etag.clone(),
+ });
+ Ok(cached_profile.response.clone())
+ }
+ None => Err(ErrorKind::UnrecoverableServerError(
+ "Got a 304 without having sent an eTag.",
+ )
+ .into()),
+ }
+ }
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::{
+ http_client::*,
+ oauth::{AccessTokenInfo, RefreshToken},
+ Config,
+ };
+ use std::sync::Arc;
+
+ impl FirefoxAccount {
+ pub fn add_cached_profile(&mut self, uid: &str, email: &str) {
+ self.state.last_seen_profile = Some(CachedResponse {
+ response: Profile {
+ uid: uid.into(),
+ email: email.into(),
+ display_name: None,
+ avatar: "".into(),
+ avatar_default: true,
+ ecosystem_anon_id: None,
+ },
+ cached_at: util::now(),
+ etag: "fake etag".into(),
+ });
+ }
+ }
+
+ #[test]
+ fn test_fetch_profile() {
+ let config = Config::stable_dev("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 mut client = FxAClientMock::new();
+ client
+ .expect_profile(
+ mockiato::Argument::any,
+ |token| token.partial_eq("profiletok"),
+ mockiato::Argument::any,
+ )
+ .times(1)
+ .returns_once(Ok(Some(ResponseAndETag {
+ response: ProfileResponse {
+ uid: "12345ab".to_string(),
+ email: "foo@bar.com".to_string(),
+ display_name: None,
+ avatar: "https://foo.avatar".to_string(),
+ avatar_default: true,
+ ecosystem_anon_id: None,
+ },
+ etag: None,
+ })));
+ fxa.set_client(Arc::new(client));
+
+ let p = fxa.get_profile(false).unwrap();
+ assert_eq!(p.email, "foo@bar.com");
+ }
+
+ #[test]
+ fn test_expired_access_token_refetch() {
+ let config = Config::stable_dev("12345678", "https://foo.bar");
+ let mut fxa = FirefoxAccount::with_config(config);
+
+ fxa.add_cached_token(
+ "profile",
+ AccessTokenInfo {
+ scope: "profile".to_string(),
+ token: "bad_access_token".to_string(),
+ key: None,
+ expires_at: u64::max_value(),
+ },
+ );
+ let mut refresh_token_scopes = std::collections::HashSet::new();
+ refresh_token_scopes.insert("profile".to_owned());
+ fxa.state.refresh_token = Some(RefreshToken {
+ token: "refreshtok".to_owned(),
+ scopes: refresh_token_scopes,
+ });
+
+ let mut client = FxAClientMock::new();
+ // First call to profile() we fail with 401.
+ client
+ .expect_profile(
+ mockiato::Argument::any,
+ |token| token.partial_eq("bad_access_token"),
+ mockiato::Argument::any,
+ )
+ .times(1)
+ .returns_once(Err(ErrorKind::RemoteError{
+ code: 401,
+ errno: 110,
+ error: "Unauthorized".to_owned(),
+ message: "Invalid authentication token in request signature".to_owned(),
+ info: "https://github.com/mozilla/fxa-auth-server/blob/master/docs/api.md#response-format".to_owned(),
+ }.into()));
+ // Then we'll try to get a new access token.
+ client
+ .expect_access_token_with_refresh_token(
+ mockiato::Argument::any,
+ |token| token.partial_eq("refreshtok"),
+ mockiato::Argument::any,
+ mockiato::Argument::any,
+ )
+ .times(1)
+ .returns_once(Ok(OAuthTokenResponse {
+ keys_jwe: None,
+ refresh_token: None,
+ expires_in: 6_000_000,
+ scope: "profile".to_owned(),
+ access_token: "good_profile_token".to_owned(),
+ session_token: None,
+ }));
+ // Then hooray it works!
+ client
+ .expect_profile(
+ mockiato::Argument::any,
+ |token| token.partial_eq("good_profile_token"),
+ mockiato::Argument::any,
+ )
+ .times(1)
+ .returns_once(Ok(Some(ResponseAndETag {
+ response: ProfileResponse {
+ uid: "12345ab".to_string(),
+ email: "foo@bar.com".to_string(),
+ display_name: None,
+ avatar: "https://foo.avatar".to_string(),
+ avatar_default: true,
+ ecosystem_anon_id: None,
+ },
+ etag: None,
+ })));
+ fxa.set_client(Arc::new(client));
+
+ let p = fxa.get_profile(false).unwrap();
+ assert_eq!(p.email, "foo@bar.com");
+ }
+}
diff --git a/third_party/rust/fxa-client/src/push.rs b/third_party/rust/fxa-client/src/push.rs
new file mode 100644
index 0000000000..978d2f556c
--- /dev/null
+++ b/third_party/rust/fxa-client/src/push.rs
@@ -0,0 +1,274 @@
+/* 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/. */
+
+use crate::device::CommandFetchReason;
+use crate::{error::*, AccountEvent, FirefoxAccount};
+use serde_derive::Deserialize;
+
+impl FirefoxAccount {
+ /// Handle any incoming push message payload coming from the Firefox Accounts
+ /// servers that has been decrypted and authenticated by the Push crate.
+ ///
+ /// Due to iOS platform restrictions, a push notification must always show UI.
+ /// Since FxA sends one push notification per command received,
+ /// we must only retrieve 1 command per push message,
+ /// otherwise we risk receiving push messages for which the UI has already been shown.
+ /// However, note that this means iOS currently risks losing messages for
+ /// which a push notification doesn't arrive.
+ ///
+ /// **💾 This method alters the persisted account state.**
+ pub fn handle_push_message(&mut self, payload: &str) -> Result<Vec<AccountEvent>> {
+ let payload = serde_json::from_str(payload).or_else(|err| {
+ // Due to a limitation of serde (https://github.com/serde-rs/serde/issues/1714)
+ // we can't parse some payloads with an unknown "command" value. Try doing a
+ // less-strongly-validating parse so we can silently ignore such messages, while
+ // while reporting errors if the payload is completely unintelligible.
+ let v: serde_json::Value = serde_json::from_str(payload)?;
+ match v.get("command") {
+ Some(_) => Ok(PushPayload::Unknown),
+ None => Err(err),
+ }
+ })?;
+ match payload {
+ PushPayload::CommandReceived(CommandReceivedPushPayload { index, .. }) => {
+ if cfg!(target_os = "ios") {
+ self.ios_fetch_device_command(index)
+ .map(|cmd| vec![AccountEvent::IncomingDeviceCommand(Box::new(cmd))])
+ } else {
+ self.poll_device_commands(CommandFetchReason::Push(index))
+ .map(|cmds| {
+ cmds.into_iter()
+ .map(|cmd| AccountEvent::IncomingDeviceCommand(Box::new(cmd)))
+ .collect()
+ })
+ }
+ }
+ PushPayload::ProfileUpdated => {
+ self.state.last_seen_profile = None;
+ Ok(vec![AccountEvent::ProfileUpdated])
+ }
+ PushPayload::DeviceConnected(DeviceConnectedPushPayload { device_name }) => {
+ self.clear_devices_and_attached_clients_cache();
+ Ok(vec![AccountEvent::DeviceConnected { device_name }])
+ }
+ PushPayload::DeviceDisconnected(DeviceDisconnectedPushPayload { device_id }) => {
+ let local_device = self.get_current_device_id();
+ let is_local_device = match local_device {
+ Err(_) => false,
+ Ok(id) => id == device_id,
+ };
+ if is_local_device {
+ // Note: self.disconnect calls self.start_over which clears the state for the FirefoxAccount instance
+ self.disconnect();
+ }
+ Ok(vec![AccountEvent::DeviceDisconnected {
+ device_id,
+ is_local_device,
+ }])
+ }
+ PushPayload::AccountDestroyed(AccountDestroyedPushPayload { account_uid }) => {
+ let is_local_account = match &self.state.last_seen_profile {
+ None => false,
+ Some(profile) => profile.response.uid == account_uid,
+ };
+ Ok(if is_local_account {
+ vec![AccountEvent::AccountDestroyed]
+ } else {
+ vec![]
+ })
+ }
+ PushPayload::PasswordChanged | PushPayload::PasswordReset => {
+ let status = self.check_authorization_status()?;
+ // clear any device or client data due to password change.
+ self.clear_devices_and_attached_clients_cache();
+ Ok(if !status.active {
+ vec![AccountEvent::AccountAuthStateChanged]
+ } else {
+ vec![]
+ })
+ }
+ PushPayload::Unknown => {
+ log::info!("Unknown Push command.");
+ Ok(vec![])
+ }
+ }
+ }
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(tag = "command", content = "data")]
+pub enum PushPayload {
+ #[serde(rename = "fxaccounts:command_received")]
+ CommandReceived(CommandReceivedPushPayload),
+ #[serde(rename = "fxaccounts:profile_updated")]
+ ProfileUpdated,
+ #[serde(rename = "fxaccounts:device_connected")]
+ DeviceConnected(DeviceConnectedPushPayload),
+ #[serde(rename = "fxaccounts:device_disconnected")]
+ DeviceDisconnected(DeviceDisconnectedPushPayload),
+ #[serde(rename = "fxaccounts:password_changed")]
+ PasswordChanged,
+ #[serde(rename = "fxaccounts:password_reset")]
+ PasswordReset,
+ #[serde(rename = "fxaccounts:account_destroyed")]
+ AccountDestroyed(AccountDestroyedPushPayload),
+ #[serde(other)]
+ Unknown,
+}
+
+#[derive(Debug, Deserialize)]
+pub struct CommandReceivedPushPayload {
+ command: String,
+ index: u64,
+ sender: String,
+ url: String,
+}
+
+#[derive(Debug, Deserialize)]
+pub struct DeviceConnectedPushPayload {
+ #[serde(rename = "deviceName")]
+ device_name: String,
+}
+
+#[derive(Debug, Deserialize)]
+pub struct DeviceDisconnectedPushPayload {
+ #[serde(rename = "id")]
+ device_id: String,
+}
+
+#[derive(Debug, Deserialize)]
+pub struct AccountDestroyedPushPayload {
+ #[serde(rename = "uid")]
+ account_uid: String,
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::http_client::FxAClientMock;
+ use crate::http_client::IntrospectResponse;
+ use crate::CachedResponse;
+ use std::sync::Arc;
+
+ #[test]
+ fn test_deserialize_send_tab_command() {
+ let json = "{\"version\":1,\"command\":\"fxaccounts:command_received\",\"data\":{\"command\":\"send-tab-recv\",\"index\":1,\"sender\":\"bobo\",\"url\":\"https://mozilla.org\"}}";
+ let _: PushPayload = serde_json::from_str(&json).unwrap();
+ }
+
+ #[test]
+ fn test_push_profile_updated() {
+ let mut fxa =
+ FirefoxAccount::with_config(crate::Config::stable_dev("12345678", "https://foo.bar"));
+ fxa.add_cached_profile("123", "test@example.com");
+ let json = "{\"version\":1,\"command\":\"fxaccounts:profile_updated\"}";
+ let events = fxa.handle_push_message(json).unwrap();
+ assert!(fxa.state.last_seen_profile.is_none());
+ assert_eq!(events.len(), 1);
+ match events[0] {
+ AccountEvent::ProfileUpdated => {}
+ _ => unreachable!(),
+ };
+ }
+
+ #[test]
+ fn test_push_device_disconnected_local() {
+ let mut fxa =
+ FirefoxAccount::with_config(crate::Config::stable_dev("12345678", "https://foo.bar"));
+ let refresh_token_scopes = std::collections::HashSet::new();
+ fxa.state.refresh_token = Some(crate::oauth::RefreshToken {
+ token: "refresh_token".to_owned(),
+ scopes: refresh_token_scopes,
+ });
+ fxa.state.current_device_id = Some("my_id".to_owned());
+ let json = "{\"version\":1,\"command\":\"fxaccounts:device_disconnected\",\"data\":{\"id\":\"my_id\"}}";
+ let events = fxa.handle_push_message(json).unwrap();
+ assert!(fxa.state.refresh_token.is_none());
+ assert_eq!(events.len(), 1);
+ match &events[0] {
+ AccountEvent::DeviceDisconnected {
+ device_id,
+ is_local_device,
+ } => {
+ assert!(is_local_device);
+ assert_eq!(device_id, "my_id");
+ }
+ _ => unreachable!(),
+ };
+ }
+
+ #[test]
+ fn test_push_password_reset() {
+ let mut fxa =
+ FirefoxAccount::with_config(crate::Config::stable_dev("12345678", "https://foo.bar"));
+ let mut client = FxAClientMock::new();
+ client
+ .expect_oauth_introspect_refresh_token(mockiato::Argument::any, |token| {
+ token.partial_eq("refresh_token")
+ })
+ .times(1)
+ .returns_once(Ok(IntrospectResponse { active: true }));
+ fxa.set_client(Arc::new(client));
+ let refresh_token_scopes = std::collections::HashSet::new();
+ fxa.state.refresh_token = Some(crate::oauth::RefreshToken {
+ token: "refresh_token".to_owned(),
+ scopes: refresh_token_scopes,
+ });
+ fxa.state.current_device_id = Some("my_id".to_owned());
+ fxa.devices_cache = Some(CachedResponse {
+ response: vec![],
+ cached_at: 0,
+ etag: "".to_string(),
+ });
+ let json = "{\"version\":1,\"command\":\"fxaccounts:password_reset\"}";
+ assert!(fxa.devices_cache.is_some());
+ fxa.handle_push_message(json).unwrap();
+ assert!(fxa.devices_cache.is_none());
+ }
+
+ #[test]
+ fn test_push_device_disconnected_remote() {
+ let mut fxa =
+ FirefoxAccount::with_config(crate::Config::stable_dev("12345678", "https://foo.bar"));
+ let json = "{\"version\":1,\"command\":\"fxaccounts:device_disconnected\",\"data\":{\"id\":\"remote_id\"}}";
+ let events = fxa.handle_push_message(json).unwrap();
+ assert_eq!(events.len(), 1);
+ match &events[0] {
+ AccountEvent::DeviceDisconnected {
+ device_id,
+ is_local_device,
+ } => {
+ assert!(!is_local_device);
+ assert_eq!(device_id, "remote_id");
+ }
+ _ => unreachable!(),
+ };
+ }
+
+ #[test]
+ fn test_handle_push_message_ignores_unknown_command() {
+ let mut fxa =
+ FirefoxAccount::with_config(crate::Config::stable_dev("12345678", "https://foo.bar"));
+ let json = "{\"version\":1,\"command\":\"huh\"}";
+ let events = fxa.handle_push_message(json).unwrap();
+ assert!(events.is_empty());
+ }
+
+ #[test]
+ fn test_handle_push_message_ignores_unknown_command_with_data() {
+ let mut fxa =
+ FirefoxAccount::with_config(crate::Config::stable_dev("12345678", "https://foo.bar"));
+ let json = "{\"version\":1,\"command\":\"huh\",\"data\":{\"value\":42}}";
+ let events = fxa.handle_push_message(json).unwrap();
+ assert!(events.is_empty());
+ }
+
+ #[test]
+ fn test_handle_push_message_errors_on_garbage_data() {
+ let mut fxa =
+ FirefoxAccount::with_config(crate::Config::stable_dev("12345678", "https://foo.bar"));
+ let json = "{\"wtf\":\"bbq\"}";
+ fxa.handle_push_message(json).unwrap_err();
+ }
+}
diff --git a/third_party/rust/fxa-client/src/scoped_keys.rs b/third_party/rust/fxa-client/src/scoped_keys.rs
new file mode 100644
index 0000000000..2b6fe5176f
--- /dev/null
+++ b/third_party/rust/fxa-client/src/scoped_keys.rs
@@ -0,0 +1,118 @@
+/* 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/. */
+
+use crate::{error::*, FirefoxAccount};
+use jwcrypto::{self, DecryptionParameters, Jwk};
+use rc_crypto::{agreement, agreement::EphemeralKeyPair};
+use serde_derive::{Deserialize, Serialize};
+
+impl FirefoxAccount {
+ pub(crate) fn get_scoped_key(&self, scope: &str) -> Result<&ScopedKey> {
+ self.state
+ .scoped_keys
+ .get(scope)
+ .ok_or_else(|| ErrorKind::NoScopedKey(scope.to_string()).into())
+ }
+}
+
+#[derive(Clone, Serialize, Deserialize)]
+pub struct ScopedKey {
+ pub kty: String,
+ pub scope: String,
+ /// URL Safe Base 64 encoded key.
+ pub k: String,
+ pub kid: String,
+}
+
+impl ScopedKey {
+ pub fn key_bytes(&self) -> Result<Vec<u8>> {
+ Ok(base64::decode_config(&self.k, base64::URL_SAFE_NO_PAD)?)
+ }
+}
+
+impl std::fmt::Debug for ScopedKey {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ f.debug_struct("ScopedKey")
+ .field("kty", &self.kty)
+ .field("scope", &self.scope)
+ .field("kid", &self.kid)
+ .finish()
+ }
+}
+
+pub struct ScopedKeysFlow {
+ key_pair: EphemeralKeyPair,
+}
+
+impl ScopedKeysFlow {
+ pub fn with_random_key() -> Result<Self> {
+ let key_pair = EphemeralKeyPair::generate(&agreement::ECDH_P256)?;
+ Ok(Self { key_pair })
+ }
+
+ #[cfg(test)]
+ pub fn from_static_key_pair(key_pair: agreement::KeyPair<agreement::Static>) -> Result<Self> {
+ let (private_key, _) = key_pair.split();
+ let ephemeral_prv_key = private_key._tests_only_dangerously_convert_to_ephemeral();
+ let key_pair = agreement::KeyPair::from_private_key(ephemeral_prv_key)?;
+ Ok(Self { key_pair })
+ }
+
+ pub fn get_public_key_jwk(&self) -> Result<Jwk> {
+ Ok(jwcrypto::ec::extract_pub_key_jwk(&self.key_pair)?)
+ }
+
+ pub fn decrypt_keys_jwe(self, jwe: &str) -> Result<String> {
+ let params = DecryptionParameters::ECDH_ES {
+ local_key_pair: self.key_pair,
+ };
+ Ok(jwcrypto::decrypt_jwe(jwe, params)?)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use jwcrypto::JwkKeyParameters;
+ use rc_crypto::agreement::{KeyPair, PrivateKey};
+
+ #[test]
+ fn test_flow() {
+ let x = base64::decode_config(
+ "ARvGIPJ5eIFdp6YTM-INVDqwfun2R9FfCUvXbH7QCIU",
+ base64::URL_SAFE_NO_PAD,
+ )
+ .unwrap();
+ let y = base64::decode_config(
+ "hk8gP0Po8nBh-WSiTsvsyesC5c1L6fGOEVuX8FHsvTs",
+ base64::URL_SAFE_NO_PAD,
+ )
+ .unwrap();
+ let d = base64::decode_config(
+ "UayD4kn_4QHvLvLLSSaANfDUp9AcQndQu_TohQKoyn8",
+ base64::URL_SAFE_NO_PAD,
+ )
+ .unwrap();
+ let ec_key =
+ agreement::EcKey::from_coordinates(agreement::Curve::P256, &d, &x, &y).unwrap();
+ let private_key = PrivateKey::<rc_crypto::agreement::Static>::import(&ec_key).unwrap();
+ let key_pair = KeyPair::from(private_key).unwrap();
+ let flow = ScopedKeysFlow::from_static_key_pair(key_pair).unwrap();
+ let jwk = flow.get_public_key_jwk().unwrap();
+ let JwkKeyParameters::EC(ec_key_params) = jwk.key_parameters;
+ assert_eq!(ec_key_params.crv, "P-256");
+ assert_eq!(
+ ec_key_params.x,
+ "ARvGIPJ5eIFdp6YTM-INVDqwfun2R9FfCUvXbH7QCIU"
+ );
+ assert_eq!(
+ ec_key_params.y,
+ "hk8gP0Po8nBh-WSiTsvsyesC5c1L6fGOEVuX8FHsvTs"
+ );
+
+ let jwe = "eyJhbGciOiJFQ0RILUVTIiwia2lkIjoiNFBKTTl5dGVGeUtsb21ILWd2UUtyWGZ0a0N3ak9HNHRfTmpYVXhLM1VqSSIsImVwayI6eyJrdHkiOiJFQyIsImNydiI6IlAtMjU2IiwieCI6IlB3eG9Na1RjSVZ2TFlKWU4wM2R0Y3o2TEJrR0FHaU1hZWlNQ3lTZXEzb2MiLCJ5IjoiLUYtTllRRDZwNUdSQ2ZoYm1hN3NvNkhxdExhVlNub012S0pFcjFBeWlaSSJ9LCJlbmMiOiJBMjU2R0NNIn0..b9FPhjjpmAmo_rP8.ur9jTry21Y2trvtcanSFmAtiRfF6s6qqyg6ruRal7PCwa7PxDzAuMN6DZW5BiK8UREOH08-FyRcIgdDOm5Zq8KwVAn56PGfcH30aNDGQNkA_mpfjx5Tj2z8kI6ryLWew4PGZb-PsL1g-_eyXhktq7dAhetjNYttKwSREWQFokv7N3nJGpukBqnwL1ost-MjDXlINZLVJKAiMHDcu-q7Epitwid2c2JVGOSCJjbZ4-zbxVmZ4o9xhFb2lbvdiaMygH6bPlrjEK99uT6XKtaIZmyDwftbD6G3x4On-CqA2TNL6ILRaJMtmyX--ctL0IrngUIHg_F0Wz94v.zBD8NACkUcZTPLH0tceGnA";
+ let keys = flow.decrypt_keys_jwe(jwe).unwrap();
+ assert_eq!(keys, "{\"https://identity.mozilla.com/apps/oldsync\":{\"kty\":\"oct\",\"scope\":\"https://identity.mozilla.com/apps/oldsync\",\"k\":\"8ek1VNk4sjrNP0DhGC4crzQtwmpoR64zHuFMHb4Tw-exR70Z2SSIfMSrJDTLEZid9lD05-hbA3n2Q4Esjlu1tA\",\"kid\":\"1526414944666-zgTjf5oXmPmBjxwXWFsDWg\"}}");
+ }
+}
diff --git a/third_party/rust/fxa-client/src/scopes.rs b/third_party/rust/fxa-client/src/scopes.rs
new file mode 100644
index 0000000000..538370a6a1
--- /dev/null
+++ b/third_party/rust/fxa-client/src/scopes.rs
@@ -0,0 +1,7 @@
+/* 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/. */
+
+pub const PROFILE: &str = "profile";
+pub const PROFILE_WRITE: &str = "profile:write";
+pub const OLD_SYNC: &str = "https://identity.mozilla.com/apps/oldsync";
diff --git a/third_party/rust/fxa-client/src/send_tab.rs b/third_party/rust/fxa-client/src/send_tab.rs
new file mode 100644
index 0000000000..cfbf5ad431
--- /dev/null
+++ b/third_party/rust/fxa-client/src/send_tab.rs
@@ -0,0 +1,135 @@
+/* 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/. */
+
+pub use crate::commands::send_tab::{SendTabPayload, TabHistoryEntry};
+use crate::{
+ commands::send_tab::{
+ self, EncryptedSendTabPayload, PrivateSendTabKeys, PublicSendTabKeys, SendTabKeysPayload,
+ },
+ error::*,
+ http_client::GetDeviceResponse,
+ scopes, telemetry, FirefoxAccount, IncomingDeviceCommand,
+};
+
+impl FirefoxAccount {
+ /// Generate the Send Tab command to be registered with the server.
+ ///
+ /// **💾 This method alters the persisted account state.**
+ pub(crate) fn generate_send_tab_command_data(&mut self) -> Result<String> {
+ let own_keys = self.load_or_generate_keys()?;
+ let public_keys: PublicSendTabKeys = own_keys.into();
+ let oldsync_key = self.get_scoped_key(scopes::OLD_SYNC)?;
+ public_keys.as_command_data(&oldsync_key)
+ }
+
+ fn load_or_generate_keys(&mut self) -> Result<PrivateSendTabKeys> {
+ if let Some(s) = self.state.commands_data.get(send_tab::COMMAND_NAME) {
+ match PrivateSendTabKeys::deserialize(s) {
+ Ok(keys) => return Ok(keys),
+ Err(_) => log::error!("Could not deserialize Send Tab keys. Re-creating them."),
+ }
+ }
+ let keys = PrivateSendTabKeys::from_random()?;
+ self.state
+ .commands_data
+ .insert(send_tab::COMMAND_NAME.to_owned(), keys.serialize()?);
+ Ok(keys)
+ }
+
+ /// Send a single tab to another device designated by its device ID.
+ /// XXX - We need a new send_tabs_to_devices() so we can correctly record
+ /// telemetry for these cases.
+ /// This probably requires a new "Tab" struct with the title and url.
+ /// android-components has SendToAllUseCase(), so this isn't just theoretical.
+ /// See https://github.com/mozilla/application-services/issues/3402
+ pub fn send_tab(&mut self, target_device_id: &str, title: &str, url: &str) -> Result<()> {
+ let devices = self.get_devices(false)?;
+ let target = devices
+ .iter()
+ .find(|d| d.id == target_device_id)
+ .ok_or_else(|| ErrorKind::UnknownTargetDevice(target_device_id.to_owned()))?;
+ let (payload, sent_telemetry) = SendTabPayload::single_tab(title, url);
+ let oldsync_key = self.get_scoped_key(scopes::OLD_SYNC)?;
+ let command_payload = send_tab::build_send_command(&oldsync_key, target, &payload)?;
+ self.invoke_command(send_tab::COMMAND_NAME, target, &command_payload)?;
+ self.telemetry.borrow_mut().record_tab_sent(sent_telemetry);
+ Ok(())
+ }
+
+ pub(crate) fn handle_send_tab_command(
+ &mut self,
+ sender: Option<GetDeviceResponse>,
+ payload: serde_json::Value,
+ reason: telemetry::ReceivedReason,
+ ) -> Result<IncomingDeviceCommand> {
+ let send_tab_key: PrivateSendTabKeys =
+ match self.state.commands_data.get(send_tab::COMMAND_NAME) {
+ Some(s) => PrivateSendTabKeys::deserialize(s)?,
+ None => {
+ return Err(ErrorKind::IllegalState(
+ "Cannot find send-tab keys. Has initialize_device been called before?",
+ )
+ .into());
+ }
+ };
+ let encrypted_payload: EncryptedSendTabPayload = serde_json::from_value(payload)?;
+ match encrypted_payload.decrypt(&send_tab_key) {
+ Ok(payload) => {
+ // It's an incoming tab, which we record telemetry for.
+ let recd_telemetry = telemetry::ReceivedCommand {
+ flow_id: payload.flow_id.clone(),
+ stream_id: payload.stream_id.clone(),
+ reason,
+ };
+ self.telemetry
+ .borrow_mut()
+ .record_tab_received(recd_telemetry);
+ // The telemetry IDs escape to the consumer, but that's OK...
+ Ok(IncomingDeviceCommand::TabReceived { sender, payload })
+ }
+ Err(e) => {
+ // XXX - this seems ripe for telemetry collection!?
+ // It also seems like it might be possible to recover - ie, one
+ // of the reasons is that there are key mismatches. Doesn't that
+ // mean the "other" key might work?
+ log::error!("Could not decrypt Send Tab payload. Diagnosing then resetting the Send Tab keys.");
+ match self.diagnose_remote_keys(send_tab_key) {
+ Ok(_) => log::error!("Could not find the cause of the Send Tab keys issue."),
+ Err(e) => log::error!("{}", e),
+ };
+ // Reset the Send Tab keys.
+ self.state.commands_data.remove(send_tab::COMMAND_NAME);
+ self.reregister_current_capabilities()?;
+ Err(e)
+ }
+ }
+ }
+
+ fn diagnose_remote_keys(&mut self, local_send_tab_key: PrivateSendTabKeys) -> Result<()> {
+ let own_device = &mut self
+ .get_current_device()?
+ .ok_or_else(|| ErrorKind::SendTabDiagnosisError("No remote device."))?;
+
+ let command = own_device
+ .available_commands
+ .get(send_tab::COMMAND_NAME)
+ .ok_or_else(|| ErrorKind::SendTabDiagnosisError("No remote command."))?;
+ let bundle: SendTabKeysPayload = serde_json::from_str(command)?;
+ let oldsync_key = self.get_scoped_key(scopes::OLD_SYNC)?;
+ let public_keys_remote = bundle.decrypt(oldsync_key).map_err(|_| {
+ ErrorKind::SendTabDiagnosisError("Unable to decrypt public key bundle.")
+ })?;
+
+ let public_keys_local: PublicSendTabKeys = local_send_tab_key.into();
+
+ if public_keys_local.public_key() != public_keys_remote.public_key() {
+ return Err(ErrorKind::SendTabDiagnosisError("Mismatch in public key.").into());
+ }
+
+ if public_keys_local.auth_secret() != public_keys_remote.auth_secret() {
+ return Err(ErrorKind::SendTabDiagnosisError("Mismatch in auth secret.").into());
+ }
+ Ok(())
+ }
+}
diff --git a/third_party/rust/fxa-client/src/state_persistence.rs b/third_party/rust/fxa-client/src/state_persistence.rs
new file mode 100644
index 0000000000..bf564e3b50
--- /dev/null
+++ b/third_party/rust/fxa-client/src/state_persistence.rs
@@ -0,0 +1,307 @@
+/* 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/. */
+
+//! This module implements the ability to serialize a `FirefoxAccount` struct to and from
+//! a JSON string. The idea is that calling code will use this to persist the account state
+//! to storage.
+//!
+//! Many of the details here are a straightforward use of `serde`, with all persisted data being
+//! a field on a `State` struct. This is, however, some additional complexity around handling data
+//! migrations - we need to be able to evolve the internal details of the `State` struct while
+//! gracefully handing users who are upgrading from an older version of a consuming app, which has
+//! stored account state from an older version of this component.
+//!
+//! Data migration is handled by explicitly naming different versions of the state struct to
+//! correspond to different incompatible changes to the data representation, e.g. `StateV1` and
+//! `StateV2`. We then wrap this in a `PersistedState` enum whose serialization gets explicitly
+//! tagged with the corresponding state version number.
+//!
+//! For backwards-compatible changes to the data (such as adding a new field that has a sensible
+//! default) we keep the current `State` struct, but modify it in such a way that `serde` knows
+//! how to do the right thing.
+//!
+//! For backwards-incompatible changes to the data (such as removing or significantly refactoring
+//! fields) we define a new `StateV{X+1}` struct, and use the `From` trait to define how to update
+//! from older struct versions.
+
+use serde_derive::*;
+use std::{
+ collections::{HashMap, HashSet},
+ iter::FromIterator,
+};
+
+use crate::{
+ config::Config,
+ device::Capability as DeviceCapability,
+ migrator::MigrationData,
+ oauth::{AccessTokenInfo, RefreshToken},
+ profile::Profile,
+ scoped_keys::ScopedKey,
+ CachedResponse, Result,
+};
+
+// These are public API for working with the persisted state.
+
+pub(crate) type State = StateV2;
+
+pub(crate) fn state_from_json(data: &str) -> Result<State> {
+ let stored_state: PersistedState = serde_json::from_str(data)?;
+ upgrade_state(stored_state)
+}
+
+pub(crate) fn state_to_json(state: &State) -> Result<String> {
+ let state = PersistedState::V2(state.clone());
+ serde_json::to_string(&state).map_err(Into::into)
+}
+
+fn upgrade_state(in_state: PersistedState) -> Result<State> {
+ match in_state {
+ PersistedState::V1(state) => state.into(),
+ PersistedState::V2(state) => Ok(state),
+ }
+}
+
+// `PersistedState` is a tagged container for one of the state versions.
+// Serde picks the right `StructVX` to deserialized based on the schema_version tag.
+
+#[derive(Serialize, Deserialize)]
+#[serde(tag = "schema_version")]
+#[allow(clippy::large_enum_variant)]
+enum PersistedState {
+ #[serde(skip_serializing)]
+ V1(StateV1),
+ V2(StateV2),
+}
+
+// `StateV2` is the current state schema. It and its fields all need to be public
+// so that they can be used directly elsewhere in the crate.
+//
+// If you want to modify what gets stored in the state, consider the following:
+//
+// * Is the change backwards-compatible with previously-serialized data?
+// If so then you'll need to tell serde how to fill in a suitable default.
+// If not then you'll need to make a new `StateV3` and implement an explicit migration.
+//
+// * Does the new field need to be modified when the user disconnects from the account?
+// If so then you'll need to update `StateV2.start_over` function.
+
+#[derive(Clone, Serialize, Deserialize)]
+pub(crate) struct StateV2 {
+ pub(crate) config: Config,
+ pub(crate) current_device_id: Option<String>,
+ pub(crate) refresh_token: Option<RefreshToken>,
+ pub(crate) scoped_keys: HashMap<String, ScopedKey>,
+ pub(crate) last_handled_command: Option<u64>,
+ // Everything below here was added after `StateV2` was initially defined,
+ // and hence needs to have a suitable default value.
+ // We can remove serde(default) when we define a `StateV3`.
+ #[serde(default)]
+ pub(crate) commands_data: HashMap<String, String>,
+ #[serde(default)]
+ pub(crate) device_capabilities: HashSet<DeviceCapability>,
+ #[serde(default)]
+ pub(crate) access_token_cache: HashMap<String, AccessTokenInfo>,
+ pub(crate) session_token: Option<String>, // Hex-formatted string.
+ pub(crate) last_seen_profile: Option<CachedResponse<Profile>>,
+ pub(crate) in_flight_migration: Option<MigrationData>,
+ pub(crate) ecosystem_user_id: Option<String>,
+}
+
+impl StateV2 {
+ /// Clear the whole persisted state of the account, but keep just enough
+ /// information to eventually reconnect to the same user account later.
+ pub(crate) fn start_over(&self) -> StateV2 {
+ StateV2 {
+ config: self.config.clone(),
+ current_device_id: None,
+ // Leave the profile cache untouched so we can reconnect later.
+ last_seen_profile: self.last_seen_profile.clone(),
+ refresh_token: None,
+ scoped_keys: HashMap::new(),
+ last_handled_command: None,
+ commands_data: HashMap::new(),
+ access_token_cache: HashMap::new(),
+ device_capabilities: HashSet::new(),
+ session_token: None,
+ in_flight_migration: None,
+ ecosystem_user_id: None,
+ }
+ }
+}
+
+// Migration from `StateV1`. There was a lot of changing of structs and renaming of fields,
+// but the key change is that we went from supporting multiple active refresh_tokens to
+// only supporting a single one.
+
+impl From<StateV1> for Result<StateV2> {
+ fn from(state: StateV1) -> Self {
+ let mut all_refresh_tokens: Vec<V1AuthInfo> = vec![];
+ let mut all_scoped_keys = HashMap::new();
+ for access_token in state.oauth_cache.values() {
+ if access_token.refresh_token.is_some() {
+ all_refresh_tokens.push(access_token.clone());
+ }
+ if let Some(ref scoped_keys) = access_token.keys {
+ let scoped_keys: serde_json::Map<String, serde_json::Value> =
+ serde_json::from_str(scoped_keys)?;
+ for (scope, key) in scoped_keys {
+ let scoped_key: ScopedKey = serde_json::from_value(key)?;
+ all_scoped_keys.insert(scope, scoped_key);
+ }
+ }
+ }
+ // In StateV2 we hold one and only one refresh token.
+ // Obviously this means a loss of information.
+ // Heuristic: We keep the most recent token.
+ let refresh_token = all_refresh_tokens
+ .iter()
+ .max_by(|a, b| a.expires_at.cmp(&b.expires_at))
+ .map(|token| RefreshToken {
+ token: token.refresh_token.clone().expect(
+ "all_refresh_tokens should only contain access tokens with refresh tokens",
+ ),
+ scopes: HashSet::from_iter(token.scopes.iter().map(ToString::to_string)),
+ });
+ let introspection_endpoint = format!("{}/v1/introspect", &state.config.oauth_url);
+ Ok(StateV2 {
+ config: Config::init(
+ state.config.content_url,
+ state.config.auth_url,
+ state.config.oauth_url,
+ state.config.profile_url,
+ state.config.token_server_endpoint_url,
+ state.config.authorization_endpoint,
+ state.config.issuer,
+ state.config.jwks_uri,
+ state.config.token_endpoint,
+ state.config.userinfo_endpoint,
+ introspection_endpoint,
+ state.client_id,
+ state.redirect_uri,
+ None,
+ ),
+ refresh_token,
+ scoped_keys: all_scoped_keys,
+ last_handled_command: None,
+ commands_data: HashMap::new(),
+ device_capabilities: HashSet::new(),
+ session_token: None,
+ current_device_id: None,
+ last_seen_profile: None,
+ in_flight_migration: None,
+ access_token_cache: HashMap::new(),
+ ecosystem_user_id: None,
+ })
+ }
+}
+
+// `StateV1` was a previous state schema.
+//
+// The below is sufficient to read existing state data serialized in this form, but should not
+// be used to create new data using that schema, so it is deliberately private and deliberately
+// does not derive(Serialize).
+//
+// If you find yourself modifying this code, you're almost certainly creating a potential data-migration
+// problem and should reconsider.
+
+#[derive(Deserialize)]
+struct StateV1 {
+ client_id: String,
+ redirect_uri: String,
+ config: V1Config,
+ oauth_cache: HashMap<String, V1AuthInfo>,
+}
+
+#[derive(Deserialize)]
+struct V1Config {
+ content_url: String,
+ auth_url: String,
+ oauth_url: String,
+ profile_url: String,
+ token_server_endpoint_url: String,
+ authorization_endpoint: String,
+ issuer: String,
+ jwks_uri: String,
+ token_endpoint: String,
+ userinfo_endpoint: String,
+}
+
+#[derive(Deserialize, Clone)]
+struct V1AuthInfo {
+ pub access_token: String,
+ pub keys: Option<String>,
+ pub refresh_token: Option<String>,
+ pub expires_at: u64, // seconds since epoch
+ pub scopes: Vec<String>,
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_migration_from_v1() {
+ // This is a snapshot of what some persisted StateV1 data would look like in practice.
+ // It's very important that you don't modify this string, which would defeat the point of the test!
+ let state_v1_json = "{\"schema_version\":\"V1\",\"client_id\":\"98adfa37698f255b\",\"redirect_uri\":\"https://lockbox.firefox.com/fxa/ios-redirect.html\",\"config\":{\"content_url\":\"https://accounts.firefox.com\",\"auth_url\":\"https://api.accounts.firefox.com/\",\"oauth_url\":\"https://oauth.accounts.firefox.com/\",\"profile_url\":\"https://profile.accounts.firefox.com/\",\"token_server_endpoint_url\":\"https://token.services.mozilla.com/1.0/sync/1.5\",\"authorization_endpoint\":\"https://accounts.firefox.com/authorization\",\"issuer\":\"https://accounts.firefox.com\",\"jwks_uri\":\"https://oauth.accounts.firefox.com/v1/jwks\",\"token_endpoint\":\"https://oauth.accounts.firefox.com/v1/token\",\"userinfo_endpoint\":\"https://profile.accounts.firefox.com/v1/profile\"},\"oauth_cache\":{\"https://identity.mozilla.com/apps/oldsync https://identity.mozilla.com/apps/lockbox profile\":{\"access_token\":\"bef37ec0340783356bcac67a86c4efa23a56f2ddd0c7a6251d19988bab7bdc99\",\"keys\":\"{\\\"https://identity.mozilla.com/apps/oldsync\\\":{\\\"kty\\\":\\\"oct\\\",\\\"scope\\\":\\\"https://identity.mozilla.com/apps/oldsync\\\",\\\"k\\\":\\\"kMtwpVC0ZaYFJymPza8rXK_0CgCp3KMwRStwGfBRBDtL6hXRDVJgQFaoOQ2dimw0Bko5WVv2gNTy7RX5zFYZHg\\\",\\\"kid\\\":\\\"1542236016429-Ox1FbJfFfwTe5t-xq4v2hQ\\\"},\\\"https://identity.mozilla.com/apps/lockbox\\\":{\\\"kty\\\":\\\"oct\\\",\\\"scope\\\":\\\"https://identity.mozilla.com/apps/lockbox\\\",\\\"k\\\":\\\"Qk4K4xF2PgQ6XvBXW8X7B7AWwWgW2bHQov9NHNd4v-k\\\",\\\"kid\\\":\\\"1231014287-KDVj0DFaO3wGpPJD8oPwVg\\\"}}\",\"refresh_token\":\"bed5532f4fea7e39c5c4f609f53603ee7518fd1c103cc4034da3618f786ed188\",\"expires_at\":1543474657,\"scopes\":[\"https://identity.mozilla.com/apps/oldsync\",\"https://identity.mozilla.com/apps/lockbox\",\"profile\"]}}}";
+ let state = state_from_json(state_v1_json).unwrap();
+ assert!(state.refresh_token.is_some());
+ let refresh_token = state.refresh_token.unwrap();
+ assert_eq!(
+ refresh_token.token,
+ "bed5532f4fea7e39c5c4f609f53603ee7518fd1c103cc4034da3618f786ed188"
+ );
+ assert_eq!(refresh_token.scopes.len(), 3);
+ assert!(refresh_token.scopes.contains("profile"));
+ assert!(refresh_token
+ .scopes
+ .contains("https://identity.mozilla.com/apps/oldsync"));
+ assert!(refresh_token
+ .scopes
+ .contains("https://identity.mozilla.com/apps/lockbox"));
+ assert_eq!(state.scoped_keys.len(), 2);
+ let oldsync_key = &state.scoped_keys["https://identity.mozilla.com/apps/oldsync"];
+ assert_eq!(oldsync_key.kid, "1542236016429-Ox1FbJfFfwTe5t-xq4v2hQ");
+ assert_eq!(oldsync_key.k, "kMtwpVC0ZaYFJymPza8rXK_0CgCp3KMwRStwGfBRBDtL6hXRDVJgQFaoOQ2dimw0Bko5WVv2gNTy7RX5zFYZHg");
+ assert_eq!(oldsync_key.kty, "oct");
+ assert_eq!(
+ oldsync_key.scope,
+ "https://identity.mozilla.com/apps/oldsync"
+ );
+ let lockbox_key = &state.scoped_keys["https://identity.mozilla.com/apps/lockbox"];
+
+ assert_eq!(lockbox_key.kid, "1231014287-KDVj0DFaO3wGpPJD8oPwVg");
+ assert_eq!(lockbox_key.k, "Qk4K4xF2PgQ6XvBXW8X7B7AWwWgW2bHQov9NHNd4v-k");
+ assert_eq!(lockbox_key.kty, "oct");
+ assert_eq!(
+ lockbox_key.scope,
+ "https://identity.mozilla.com/apps/lockbox"
+ );
+ }
+
+ #[test]
+ fn test_v2_ignores_unknown_fields_introduced_by_future_changes_to_the_schema() {
+ // This is a snapshot of what some persisted StateV2 data would look before any backwards-compatible changes
+ // were made. It's very important that you don't modify this string, which would defeat the point of the test!
+ let state_v2_json = "{\"schema_version\":\"V2\",\"config\":{\"client_id\":\"98adfa37698f255b\",\"redirect_uri\":\"https://lockbox.firefox.com/fxa/ios-redirect.html\",\"content_url\":\"https://accounts.firefox.com\",\"remote_config\":{\"auth_url\":\"https://api.accounts.firefox.com/\",\"oauth_url\":\"https://oauth.accounts.firefox.com/\",\"profile_url\":\"https://profile.accounts.firefox.com/\",\"token_server_endpoint_url\":\"https://token.services.mozilla.com/1.0/sync/1.5\",\"authorization_endpoint\":\"https://accounts.firefox.com/authorization\",\"issuer\":\"https://accounts.firefox.com\",\"jwks_uri\":\"https://oauth.accounts.firefox.com/v1/jwks\",\"token_endpoint\":\"https://oauth.accounts.firefox.com/v1/token\",\"userinfo_endpoint\":\"https://profile.accounts.firefox.com/v1/profile\"}},\"refresh_token\":{\"token\":\"bed5532f4fea7e39c5c4f609f53603ee7518fd1c103cc4034da3618f786ed188\",\"scopes\":[\"https://identity.mozilla.com/apps/oldysnc\"]},\"scoped_keys\":{\"https://identity.mozilla.com/apps/oldsync\":{\"kty\":\"oct\",\"scope\":\"https://identity.mozilla.com/apps/oldsync\",\"k\":\"kMtwpVC0ZaYFJymPza8rXK_0CgCp3KMwRStwGfBRBDtL6hXRDVJgQFaoOQ2dimw0Bko5WVv2gNTy7RX5zFYZHg\",\"kid\":\"1542236016429-Ox1FbJfFfwTe5t-xq4v2hQ\"}},\"login_state\":{\"Unknown\":null},\"a_new_field\":42}";
+ let state = state_from_json(state_v2_json).unwrap();
+ let refresh_token = state.refresh_token.unwrap();
+ assert_eq!(
+ refresh_token.token,
+ "bed5532f4fea7e39c5c4f609f53603ee7518fd1c103cc4034da3618f786ed188"
+ );
+ }
+
+ #[test]
+ fn test_v2_creates_an_empty_access_token_cache_if_its_missing() {
+ let state_v2_json = "{\"schema_version\":\"V2\",\"config\":{\"client_id\":\"98adfa37698f255b\",\"redirect_uri\":\"https://lockbox.firefox.com/fxa/ios-redirect.html\",\"content_url\":\"https://accounts.firefox.com\"},\"refresh_token\":{\"token\":\"bed5532f4fea7e39c5c4f609f53603ee7518fd1c103cc4034da3618f786ed188\",\"scopes\":[\"https://identity.mozilla.com/apps/oldysnc\"]},\"scoped_keys\":{\"https://identity.mozilla.com/apps/oldsync\":{\"kty\":\"oct\",\"scope\":\"https://identity.mozilla.com/apps/oldsync\",\"k\":\"kMtwpVC0ZaYFJymPza8rXK_0CgCp3KMwRStwGfBRBDtL6hXRDVJgQFaoOQ2dimw0Bko5WVv2gNTy7RX5zFYZHg\",\"kid\":\"1542236016429-Ox1FbJfFfwTe5t-xq4v2hQ\"}},\"login_state\":{\"Unknown\":null}}";
+ let state = state_from_json(state_v2_json).unwrap();
+ let refresh_token = state.refresh_token.unwrap();
+ assert_eq!(
+ refresh_token.token,
+ "bed5532f4fea7e39c5c4f609f53603ee7518fd1c103cc4034da3618f786ed188"
+ );
+ assert_eq!(state.access_token_cache.len(), 0);
+ }
+}
diff --git a/third_party/rust/fxa-client/src/telemetry.rs b/third_party/rust/fxa-client/src/telemetry.rs
new file mode 100644
index 0000000000..711f48bf7c
--- /dev/null
+++ b/third_party/rust/fxa-client/src/telemetry.rs
@@ -0,0 +1,360 @@
+/* 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/. */
+
+use crate::{error::*, scopes, FirefoxAccount};
+use jwcrypto::{EncryptionAlgorithm, EncryptionParameters, Jwk};
+use rand_rccrypto::rand::seq::SliceRandom;
+use rc_crypto::rand;
+use serde_derive::*;
+use sync_guid::Guid;
+
+impl FirefoxAccount {
+ /// Get the ecosystem anon id, generating it if necessary.
+ ///
+ /// **💾 This method alters the persisted account state.**
+ pub fn get_ecosystem_anon_id(&mut self) -> Result<String> {
+ self.get_ecosystem_anon_id_helper(true)
+ }
+
+ fn get_ecosystem_anon_id_helper(&mut self, generate_placeholder: bool) -> Result<String> {
+ let profile = self.get_profile(false)?;
+ // Default case: the ecosystem anon ID was generated during login.
+ if let Some(ecosystem_anon_id) = profile.ecosystem_anon_id {
+ return Ok(ecosystem_anon_id);
+ }
+ if !generate_placeholder {
+ return Err(ErrorKind::IllegalState("ecosystem_anon_id should be present").into());
+ }
+ // For older clients, we generate an ecosystem_user_id,
+ // persist it and then return ecosystem_anon_id.
+ let mut ecosystem_user_id = vec![0u8; 32];
+ rand::fill(&mut ecosystem_user_id)?;
+ // Will end up as a len 64 hex string.
+ let ecosystem_user_id = hex::encode(ecosystem_user_id);
+
+ let anon_id_key = self.fetch_random_ecosystem_anon_id_key()?;
+ let ecosystem_anon_id = jwcrypto::encrypt_to_jwe(
+ &ecosystem_user_id.as_bytes(),
+ EncryptionParameters::ECDH_ES {
+ enc: EncryptionAlgorithm::A256GCM,
+ peer_jwk: &anon_id_key,
+ },
+ )?;
+
+ let token = self.get_access_token(scopes::PROFILE_WRITE, None)?.token;
+ if let Err(err) =
+ self.client
+ .set_ecosystem_anon_id(&self.state.config, &token, &ecosystem_anon_id)
+ {
+ if let ErrorKind::RemoteError { code: 412, .. } = err.kind() {
+ // Another client beat us, fetch the new ecosystem_anon_id.
+ return self.get_ecosystem_anon_id_helper(false);
+ }
+ }
+
+ // Persist the unencrypted ecosystem_user_id for possible future use.
+ self.state.ecosystem_user_id = Some(ecosystem_user_id);
+ Ok(ecosystem_anon_id)
+ }
+
+ fn fetch_random_ecosystem_anon_id_key(&self) -> Result<Jwk> {
+ let config = self.client.fxa_client_configuration(&self.state.config)?;
+ let keys = config
+ .ecosystem_anon_id_keys
+ .ok_or_else(|| ErrorKind::NoAnonIdKey)?;
+ let mut rng = rand_rccrypto::RcCryptoRng;
+ Ok(keys
+ .choose(&mut rng)
+ .ok_or_else(|| ErrorKind::NoAnonIdKey)?
+ .clone())
+ }
+
+ /// Gathers and resets telemetry for this account instance.
+ /// This should be considered a short-term solution to telemetry gathering
+ /// and should called whenever consumers expect there might be telemetry,
+ /// and it should submit the telemetry to whatever telemetry system is in
+ /// use (probably glean).
+ ///
+ /// The data is returned as a JSON string, which consumers should parse
+ /// forgivingly (eg, be tolerant of things not existing) to try and avoid
+ /// too many changes as telemetry comes and goes.
+ pub fn gather_telemetry(&mut self) -> Result<String> {
+ let telem = self.telemetry.replace(FxaTelemetry::new());
+ Ok(serde_json::to_string(&telem)?)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::{http_client::*, oauth::AccessTokenInfo, Config};
+ use jwcrypto::{ec::ECKeysParameters, JwkKeyParameters};
+ use std::sync::Arc;
+
+ fn fxa_setup() -> FirefoxAccount {
+ let config = Config::stable_dev("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(),
+ },
+ );
+ fxa.add_cached_token(
+ "profile:write",
+ AccessTokenInfo {
+ scope: "profile".to_string(),
+ token: "profilewritetok".to_string(),
+ key: None,
+ expires_at: u64::max_value(),
+ },
+ );
+ fxa
+ }
+
+ #[test]
+ fn get_ecosystem_anon_id_in_profile() {
+ let mut fxa = fxa_setup();
+
+ let ecosystem_anon_id = "bobo".to_owned();
+
+ let mut client = FxAClientMock::new();
+ client
+ .expect_profile(
+ mockiato::Argument::any,
+ |token| token.partial_eq("profiletok"),
+ mockiato::Argument::any,
+ )
+ .times(1)
+ .returns_once(Ok(Some(ResponseAndETag {
+ response: ProfileResponse {
+ uid: "12345ab".to_string(),
+ email: "foo@bar.com".to_string(),
+ display_name: None,
+ avatar: "https://foo.avatar".to_string(),
+ avatar_default: true,
+ ecosystem_anon_id: Some(ecosystem_anon_id.to_owned()),
+ },
+ etag: None,
+ })));
+ fxa.set_client(Arc::new(client));
+
+ assert_eq!(fxa.get_ecosystem_anon_id().unwrap(), ecosystem_anon_id);
+ }
+
+ #[test]
+ fn get_ecosystem_anon_id_generate_anon_id() {
+ let mut fxa = fxa_setup();
+
+ let mut client = FxAClientMock::new();
+ client
+ .expect_profile(
+ mockiato::Argument::any,
+ |token| token.partial_eq("profiletok"),
+ mockiato::Argument::any,
+ )
+ .times(1)
+ .returns_once(Ok(Some(ResponseAndETag {
+ response: ProfileResponse {
+ uid: "12345ab".to_string(),
+ email: "foo@bar.com".to_string(),
+ display_name: None,
+ avatar: "https://foo.avatar".to_string(),
+ avatar_default: true,
+ ecosystem_anon_id: None,
+ },
+ etag: None,
+ })));
+ client
+ .expect_fxa_client_configuration(mockiato::Argument::any)
+ .times(1)
+ .returns_once(Ok(ClientConfigurationResponse {
+ auth_server_base_url: "https://foo.bar".to_owned(),
+ oauth_server_base_url: "https://foo.bar".to_owned(),
+ profile_server_base_url: "https://foo.bar".to_owned(),
+ sync_tokenserver_base_url: "https://foo.bar".to_owned(),
+ ecosystem_anon_id_keys: Some(vec![Jwk {
+ kid: Some("LlU4keOmhTuq9fCNnpIldYGT9vT9dIDwnu_SBtTgeEQ".to_owned()),
+ key_parameters: JwkKeyParameters::EC(ECKeysParameters {
+ crv: "P-256".to_owned(),
+ x: "i3FM3OFSCZEoqu-jtelXwKt6AL4ODQ75NUdHbcLWQSo".to_owned(),
+ y: "nW-S3QiHDo-9hwfBhKnGKarkt_PVqVyIPUytjutTunY".to_owned(),
+ }),
+ }]),
+ }));
+ client
+ .expect_set_ecosystem_anon_id(
+ mockiato::Argument::any,
+ |token| token.partial_eq("profilewritetok"),
+ mockiato::Argument::any,
+ )
+ .times(1)
+ .returns_once(Ok(()));
+ fxa.set_client(Arc::new(client));
+
+ let ecosystem_anon_id = fxa.get_ecosystem_anon_id().unwrap();
+ // Well, it looks like a jwe folks.
+ assert!(ecosystem_anon_id.chars().filter(|c| c == &'.').count() == 4);
+ assert!(fxa.state.ecosystem_user_id.unwrap().len() == 64);
+ }
+
+ #[test]
+ fn get_ecosystem_anon_id_generate_anon_id_412() {
+ let mut fxa = fxa_setup();
+
+ let ecosystem_anon_id = "bobo".to_owned();
+
+ let mut client = FxAClientMock::new();
+ client
+ .expect_profile(
+ mockiato::Argument::any,
+ |token| token.partial_eq("profiletok"),
+ mockiato::Argument::any,
+ )
+ .returns_once(Ok(Some(ResponseAndETag {
+ response: ProfileResponse {
+ uid: "12345ab".to_string(),
+ email: "foo@bar.com".to_string(),
+ display_name: None,
+ avatar: "https://foo.avatar".to_string(),
+ avatar_default: true,
+ ecosystem_anon_id: None,
+ },
+ etag: None,
+ })));
+ // 2nd profile call after we get the 412.
+ client
+ .expect_profile(
+ mockiato::Argument::any,
+ |token| token.partial_eq("profiletok"),
+ mockiato::Argument::any,
+ )
+ .returns_once(Ok(Some(ResponseAndETag {
+ response: ProfileResponse {
+ uid: "12345ab".to_string(),
+ email: "foo@bar.com".to_string(),
+ display_name: None,
+ avatar: "https://foo.avatar".to_string(),
+ avatar_default: true,
+ ecosystem_anon_id: Some(ecosystem_anon_id.clone()),
+ },
+ etag: None,
+ })));
+ client.expect_profile_calls_in_order();
+ client
+ .expect_fxa_client_configuration(mockiato::Argument::any)
+ .times(1)
+ .returns_once(Ok(ClientConfigurationResponse {
+ auth_server_base_url: "https://foo.bar".to_owned(),
+ oauth_server_base_url: "https://foo.bar".to_owned(),
+ profile_server_base_url: "https://foo.bar".to_owned(),
+ sync_tokenserver_base_url: "https://foo.bar".to_owned(),
+ ecosystem_anon_id_keys: Some(vec![Jwk {
+ kid: Some("LlU4keOmhTuq9fCNnpIldYGT9vT9dIDwnu_SBtTgeEQ".to_owned()),
+ key_parameters: JwkKeyParameters::EC(ECKeysParameters {
+ crv: "P-256".to_owned(),
+ x: "i3FM3OFSCZEoqu-jtelXwKt6AL4ODQ75NUdHbcLWQSo".to_owned(),
+ y: "nW-S3QiHDo-9hwfBhKnGKarkt_PVqVyIPUytjutTunY".to_owned(),
+ }),
+ }]),
+ }));
+ client
+ .expect_set_ecosystem_anon_id(
+ mockiato::Argument::any,
+ |token| token.partial_eq("profilewritetok"),
+ mockiato::Argument::any,
+ )
+ .times(1)
+ .returns_once(Err(ErrorKind::RemoteError {
+ code: 412,
+ errno: 500,
+ error: "precondition failed".to_string(),
+ message: "another user did it".to_string(),
+ info: "".to_string(),
+ }
+ .into()));
+ fxa.set_client(Arc::new(client));
+
+ assert_eq!(fxa.get_ecosystem_anon_id().unwrap(), ecosystem_anon_id);
+ assert!(fxa.state.ecosystem_user_id.is_none());
+ }
+}
+
+// A somewhat mixed-bag of all telemetry we want to collect. The idea is that
+// the app will "pull" telemetry via a new API whenever it thinks there might
+// be something to record.
+// It's considered a temporary solution until either we can record it directly
+// (eg, via glean) or we come up with something better.
+// Note that this means we'll lose telemetry if we crash between gathering it
+// here and the app submitting it, but that should be rare (in practice,
+// apps will submit it directly after an operation that generated telememtry)
+
+/// The reason a tab/command was received.
+#[derive(Debug, Serialize)]
+#[serde(rename_all = "kebab-case")]
+pub enum ReceivedReason {
+ /// A push notification for the command was received.
+ Push,
+ /// Discovered while handling a push notification for a later message.
+ PushMissed,
+ /// Explicit polling for missed commands.
+ Poll,
+}
+
+#[derive(Debug, Serialize)]
+pub struct SentCommand {
+ pub flow_id: String,
+ pub stream_id: String,
+}
+
+impl Default for SentCommand {
+ fn default() -> Self {
+ Self {
+ flow_id: Guid::random().to_string(),
+ stream_id: Guid::random().to_string(),
+ }
+ }
+}
+
+#[derive(Debug, Serialize)]
+pub struct ReceivedCommand {
+ pub flow_id: String,
+ pub stream_id: String,
+ pub reason: ReceivedReason,
+}
+
+// We have a naive strategy to avoid unbounded memory growth - the intention
+// is that if any platform lets things grow to hit these limits, it's probably
+// never going to consume anything - so it doesn't matter what we discard (ie,
+// there's no good reason to have a smarter circular buffer etc)
+const MAX_TAB_EVENTS: usize = 200;
+
+#[derive(Debug, Default, Serialize)]
+pub struct FxaTelemetry {
+ commands_sent: Vec<SentCommand>,
+ commands_received: Vec<ReceivedCommand>,
+}
+
+impl FxaTelemetry {
+ pub fn new() -> Self {
+ FxaTelemetry {
+ ..Default::default()
+ }
+ }
+
+ pub fn record_tab_sent(&mut self, sent: SentCommand) {
+ if self.commands_sent.len() < MAX_TAB_EVENTS {
+ self.commands_sent.push(sent);
+ }
+ }
+
+ pub fn record_tab_received(&mut self, recd: ReceivedCommand) {
+ if self.commands_received.len() < MAX_TAB_EVENTS {
+ self.commands_received.push(recd);
+ }
+ }
+}
diff --git a/third_party/rust/fxa-client/src/util.rs b/third_party/rust/fxa-client/src/util.rs
new file mode 100644
index 0000000000..8a2726f06c
--- /dev/null
+++ b/third_party/rust/fxa-client/src/util.rs
@@ -0,0 +1,46 @@
+/* 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/. */
+
+use crate::error::*;
+use rc_crypto::rand;
+use std::time::{SystemTime, UNIX_EPOCH};
+
+// Gets the unix epoch in ms.
+pub fn now() -> u64 {
+ let since_epoch = SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .expect("Something is very wrong.");
+ since_epoch.as_secs() * 1000 + u64::from(since_epoch.subsec_nanos()) / 1_000_000
+}
+
+pub fn now_secs() -> u64 {
+ let since_epoch = SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .expect("Something is very wrong.");
+ since_epoch.as_secs()
+}
+
+pub fn random_base64_url_string(len: usize) -> Result<String> {
+ let mut out = vec![0u8; len];
+ rand::fill(&mut out)?;
+ Ok(base64::encode_config(&out, base64::URL_SAFE_NO_PAD))
+}
+
+pub trait Xorable {
+ fn xored_with(&self, other: &[u8]) -> Result<Vec<u8>>;
+}
+
+impl Xorable for [u8] {
+ fn xored_with(&self, other: &[u8]) -> Result<Vec<u8>> {
+ if self.len() != other.len() {
+ Err(ErrorKind::XorLengthMismatch(self.len(), other.len()).into())
+ } else {
+ Ok(self
+ .iter()
+ .zip(other.iter())
+ .map(|(&x, &y)| x ^ y)
+ .collect())
+ }
+ }
+}