diff options
Diffstat (limited to 'third_party/rust/fxa-client')
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(¤t_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()) + } + } +} |