From 5f67f645921a339a2f034875ca3ae842f89cd5b1 Mon Sep 17 00:00:00 2001 From: Vlad Filippov Date: Mon, 20 Jan 2020 11:02:22 -0500 Subject: [PATCH] Add offline use case for 'migrateFromSessionToken' Fixes #2396 --- CHANGES_UNRELEASED.md | 9 ++- Cargo.lock | 1 + .../appservices/fxaclient/FirefoxAccount.kt | 53 +++++++++--- .../appservices/fxaclient/rust/LibFxAFFI.kt | 9 ++- components/fxa-client/examples/migration.rs | 27 +++++-- components/fxa-client/ffi/Cargo.toml | 1 + components/fxa-client/ffi/src/lib.rs | 34 +++++++- components/fxa-client/src/error.rs | 3 + components/fxa-client/src/lib.rs | 11 +++ components/fxa-client/src/migrator.rs | 81 +++++++++++++++++-- .../fxa-client/src/state_persistence.rs | 1 + 11 files changed, 200 insertions(+), 30 deletions(-) diff --git a/CHANGES_UNRELEASED.md b/CHANGES_UNRELEASED.md index 3034469d84..36ee2d2ab2 100644 --- a/CHANGES_UNRELEASED.md +++ b/CHANGES_UNRELEASED.md @@ -4,7 +4,6 @@ [Full Changelog](https://github.com/mozilla/application-services/compare/v0.48.3...master) - ## FxA Client ### What's New @@ -12,6 +11,14 @@ - `FirefoxAccount` is now deprecated - Introducing `FxAccountManager` which provides a higher-level interface to Firefox Accounts. Among other things, this class handles (and can recover from) authentication errors, exposes device-related account methods, handles its own keychain storage and fires observer notifications for important account events. +- `migrateFromSessionToken` now handles offline use cases. It caches the data the consumers originally provide. + If there's no network connectivity then the migration could be retried using the new `retryMigrateFromSessionToken` method. + Consumers may also use the `isInMigrationState` method to check if there's a migration in progress. + ([#2492](https://github.com/mozilla/application-services/pull/2492)) + ### Breaking changes - `FirefoxAccount.fromJSON(json: String)` has been replaced by the `FirefoxAccount(fromJsonState: String)` constructor. + +- `migrateFromSessionToken` now returns a metrics JSON object if the migration succeeded. + ([#2492](https://github.com/mozilla/application-services/pull/2492)) diff --git a/Cargo.lock b/Cargo.lock index 57b1cb0bfa..1ccce7f827 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -813,6 +813,7 @@ dependencies = [ "lazy_static", "log", "prost", + "serde_json", "url", "viaduct", ] diff --git a/components/fxa-client/android/src/main/java/mozilla/appservices/fxaclient/FirefoxAccount.kt b/components/fxa-client/android/src/main/java/mozilla/appservices/fxaclient/FirefoxAccount.kt index 2c99d1e6eb..9a48bb4e7e 100644 --- a/components/fxa-client/android/src/main/java/mozilla/appservices/fxaclient/FirefoxAccount.kt +++ b/components/fxa-client/android/src/main/java/mozilla/appservices/fxaclient/FirefoxAccount.kt @@ -13,6 +13,7 @@ import mozilla.appservices.fxaclient.rust.RustError import mozilla.appservices.support.native.toNioDirectBuffer import java.nio.ByteBuffer import java.util.concurrent.atomic.AtomicLong +import org.json.JSONObject /** * FirefoxAccount represents the authentication state of a client. @@ -317,14 +318,28 @@ class FirefoxAccount(handle: FxaHandle, persistCallback: PersistCallback?) : Aut * @param sessionToken 64 character string of hex-encoded bytes * @param kSync 128 character string of hex-encoded bytes * @param kXCS 32 character string of hex-encoded bytes + * @return JSONObject JSON object with the result of the migration * This performs network requests, and should not be used on the main thread. */ - fun migrateFromSessionToken(sessionToken: String, kSync: String, kXCS: String) { - rustCallWithLock { e -> - LibFxAFFI.INSTANCE.fxa_migrate_from_session_token(this.handle.get(), sessionToken, kSync, kXCS, - false, e) - } + fun migrateFromSessionToken(sessionToken: String, kSync: String, kXCS: String): JSONObject { + val json = rustCallWithLock { e -> + LibFxAFFI.INSTANCE.fxa_migrate_from_session_token(this.handle.get(), sessionToken, kSync, kXCS, false, e) + }.getAndConsumeRustString() + this.tryPersistState() + return JSONObject(json) + } + + /** + * Migrate from a logged-in Firefox Account, takes ownership of the provided session token. + * + * @return bool Returns a boolean if we are in a migration state + */ + fun isInMigrationState(): Boolean { + rustCall { e -> + val state = LibFxAFFI.INSTANCE.fxa_is_in_migration_state(this.handle.get(), e) + return state.toInt() != 0 + } } /** @@ -334,14 +349,32 @@ class FirefoxAccount(handle: FxaHandle, persistCallback: PersistCallback?) : Aut * @param sessionToken 64 character string of hex-encoded bytes * @param kSync 128 character string of hex-encoded bytes * @param kXCS 32 character string of hex-encoded bytes + * @return JSONObject JSON object with the result of the migration * This performs network requests, and should not be used on the main thread. */ - fun copyFromSessionToken(sessionToken: String, kSync: String, kXCS: String) { - rustCallWithLock { e -> - LibFxAFFI.INSTANCE.fxa_migrate_from_session_token(this.handle.get(), sessionToken, kSync, kXCS, - true, e) - } + fun copyFromSessionToken(sessionToken: String, kSync: String, kXCS: String): JSONObject { + val json = rustCallWithLock { e -> + LibFxAFFI.INSTANCE.fxa_migrate_from_session_token(this.handle.get(), sessionToken, kSync, kXCS, true, e) + }.getAndConsumeRustString() + + this.tryPersistState() + return JSONObject(json) + } + + /** + * Retry migration from a logged-in Firefox Account. + * + * Modifies the FirefoxAccount state. + * @return JSONObject JSON object with the result of the migration + * This performs network requests, and should not be used on the main thread. + */ + fun retryMigrateFromSessionToken(): JSONObject { + val json = rustCallWithLock { e -> + LibFxAFFI.INSTANCE.fxa_retry_migrate_from_session_token(this.handle.get(), e) + }.getAndConsumeRustString() + this.tryPersistState() + return JSONObject(json) } /** diff --git a/components/fxa-client/android/src/main/java/mozilla/appservices/fxaclient/rust/LibFxAFFI.kt b/components/fxa-client/android/src/main/java/mozilla/appservices/fxaclient/rust/LibFxAFFI.kt index e335571325..49202029f8 100644 --- a/components/fxa-client/android/src/main/java/mozilla/appservices/fxaclient/rust/LibFxAFFI.kt +++ b/components/fxa-client/android/src/main/java/mozilla/appservices/fxaclient/rust/LibFxAFFI.kt @@ -92,7 +92,14 @@ internal interface LibFxAFFI : Library { kXCS: String, copySessionToken: Boolean, e: RustError.ByReference - ) + ): Pointer? + + fun fxa_is_in_migration_state( + fxa: FxaHandle, + e: RustError.ByReference + ): Byte + + fun fxa_retry_migrate_from_session_token(fxa: FxaHandle, e: RustError.ByReference): Pointer? fun fxa_str_free(string: Pointer) fun fxa_bytebuffer_free(buffer: RustBuffer.ByValue) diff --git a/components/fxa-client/examples/migration.rs b/components/fxa-client/examples/migration.rs index 17f1264d6f..a8f650fd49 100644 --- a/components/fxa-client/examples/migration.rs +++ b/components/fxa-client/examples/migration.rs @@ -1,11 +1,10 @@ use cli_support::prompt::prompt_string; use fxa_client::FirefoxAccount; +use std::{thread, time}; static CLIENT_ID: &str = "3c49430b43dfba77"; -//static CONTENT_SERVER: &str = "https://latest.dev.lcip.org"; -static CONTENT_SERVER: &str = "http://127.0.0.1:3030"; -//static REDIRECT_URI: &str = "https://latest.dev.lcip.org/oauth/success/3c49430b43dfba77"; -static REDIRECT_URI: &str = "http://127.0.0.1:3030/oauth/success/3c49430b43dfba77"; +static CONTENT_SERVER: &str = "https://accounts.firefox.com"; +static REDIRECT_URI: &str = "https://accounts.firefox.com/oauth/success/3c49430b43dfba77"; fn main() { let mut fxa = FirefoxAccount::new(CONTENT_SERVER, CLIENT_ID, REDIRECT_URI); @@ -15,8 +14,22 @@ fn main() { let k_sync: String = prompt_string("k_sync").unwrap(); println!("Enter kXCS (hex-string):"); let k_xcs: String = prompt_string("k_xcs").unwrap(); - fxa.migrate_from_session_token(&session_token, &k_sync, &k_xcs, true) - .unwrap(); - println!("WOW! You've been migrated."); + let migration_result = + match fxa.migrate_from_session_token(&session_token, &k_sync, &k_xcs, true) { + Ok(migration_result) => migration_result, + Err(err) => { + println!("Error: {}", err); + // example for offline behaviour + loop { + thread::sleep(time::Duration::from_millis(5000)); + let retry = fxa.try_migration(); + match retry { + Ok(result) => break result, + Err(_) => println!("Retrying... Are you connected to the internet?"), + } + } + } + }; + println!("WOW! You've been migrated in {:?}.", migration_result); println!("JSON: {}", fxa.to_json().unwrap()); } diff --git a/components/fxa-client/ffi/Cargo.toml b/components/fxa-client/ffi/Cargo.toml index 73b6ea0cea..3c5e9d197e 100644 --- a/components/fxa-client/ffi/Cargo.toml +++ b/components/fxa-client/ffi/Cargo.toml @@ -12,6 +12,7 @@ crate-type = ["lib"] [dependencies] ffi-support = { path = "../../support/ffi" } log = "0.4.8" +serde_json = "1.0.44" lazy_static = "1.4.0" url = "2.1.1" prost = "0.6.1" diff --git a/components/fxa-client/ffi/src/lib.rs b/components/fxa-client/ffi/src/lib.rs index 900f79e995..3b40717a47 100644 --- a/components/fxa-client/ffi/src/lib.rs +++ b/components/fxa-client/ffi/src/lib.rs @@ -239,14 +239,40 @@ pub extern "C" fn fxa_migrate_from_session_token( k_xcs: FfiStr<'_>, copy_session_token: bool, error: &mut ExternError, -) { +) -> *mut c_char { log::debug!("fxa_migrate_from_session_token"); - ACCOUNTS.call_with_result_mut(error, handle, |fxa| { + ACCOUNTS.call_with_result_mut(error, handle, |fxa| -> fxa_client::Result { let session_token = session_token.as_str(); let k_sync = k_sync.as_str(); let k_xcs = k_xcs.as_str(); - fxa.migrate_from_session_token(session_token, k_sync, k_xcs, copy_session_token) - }); + let migration_metrics = + fxa.migrate_from_session_token(session_token, k_sync, k_xcs, copy_session_token)?; + let result = serde_json::to_string(&migration_metrics)?; + Ok(result) + }) +} + +/// Check if there is migration state. +#[no_mangle] +pub extern "C" fn fxa_is_in_migration_state(handle: u64, error: &mut ExternError) -> u8 { + log::debug!("fxa_is_in_migration_state"); + ACCOUNTS.call_with_result(error, handle, |fxa| -> fxa_client::Result { + Ok(fxa.is_in_migration_state() as u8) + }) +} + +/// Retry the migration attempt using the stored migration state. +#[no_mangle] +pub extern "C" fn fxa_retry_migrate_from_session_token( + handle: u64, + error: &mut ExternError, +) -> *mut c_char { + log::debug!("fxa_retry_migrate_from_session_token"); + ACCOUNTS.call_with_result_mut(error, handle, |fxa| -> fxa_client::Result { + let migration_metrics = fxa.try_migration()?; + let result = serde_json::to_string(&migration_metrics)?; + Ok(result) + }) } /// Try to get an access token. diff --git a/components/fxa-client/src/error.rs b/components/fxa-client/src/error.rs index b377569e8b..8803b61bd6 100644 --- a/components/fxa-client/src/error.rs +++ b/components/fxa-client/src/error.rs @@ -32,6 +32,9 @@ pub enum ErrorKind { #[fail(display = "No stored session token")] NoSessionToken, + #[fail(display = "No stored migration data")] + NoMigrationData, + #[fail(display = "No stored current device id")] NoCurrentDeviceId, diff --git a/components/fxa-client/src/lib.rs b/components/fxa-client/src/lib.rs index 55bb69a59a..50bcf3889e 100644 --- a/components/fxa-client/src/lib.rs +++ b/components/fxa-client/src/lib.rs @@ -57,6 +57,14 @@ pub struct FirefoxAccount { flow_store: HashMap, } +#[derive(Clone, Serialize, Deserialize)] +pub struct MigrationData { + k_xcs: String, + k_sync: String, + copy_session_token: bool, + session_token: String, +} + // If this structure is modified, please: // 1. Check if a migration needs to be done, as // these fields are persisted as a JSON string @@ -79,6 +87,7 @@ pub(crate) struct StateV2 { access_token_cache: HashMap, session_token: Option, // Hex-formatted string. last_seen_profile: Option>, + in_flight_migration: Option, } impl StateV2 { @@ -97,6 +106,7 @@ impl StateV2 { access_token_cache: HashMap::new(), device_capabilities: HashSet::new(), session_token: None, + in_flight_migration: None, } } } @@ -125,6 +135,7 @@ impl FirefoxAccount { current_device_id: None, last_seen_profile: None, access_token_cache: HashMap::new(), + in_flight_migration: None, }) } diff --git a/components/fxa-client/src/migrator.rs b/components/fxa-client/src/migrator.rs index ddfc7f43d2..0a26824265 100644 --- a/components/fxa-client/src/migrator.rs +++ b/components/fxa-client/src/migrator.rs @@ -2,7 +2,14 @@ * 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 crate::{error::*, scoped_keys::ScopedKey, scopes, FirefoxAccount, MigrationData}; +use serde_derive::*; +use std::time::Instant; + +#[derive(Serialize, Deserialize, PartialEq, Debug, Clone, Default)] +pub struct FxAMigrationResult { + pub total_duration: u128, +} impl FirefoxAccount { /// Migrate from a logged-in with a sessionToken Firefox Account. @@ -24,20 +31,79 @@ impl FirefoxAccount { k_sync: &str, k_xcs: &str, copy_session_token: bool, - ) -> Result<()> { + ) -> Result { // 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()); } - let migration_session_token = if copy_session_token { + 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) -> bool { + self.state.in_flight_migration.is_some() + } + + pub fn try_migration(&mut self) -> Result { + 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()); + } + }; + + let migration_session_token = if migration_data.copy_session_token { let duplicate_session = self .client - .duplicate_session(&self.state.config, &session_token)?; + .duplicate_session(&self.state.config, &migration_data.session_token)?; duplicate_session.session_token } else { - session_token.to_string() + migration_data.session_token.to_string() }; // Trade our session token for a refresh token. @@ -49,9 +115,9 @@ impl FirefoxAccount { self.handle_oauth_response(oauth_response, None)?; // Synthesize a scoped key from our kSync. - let k_sync = hex::decode(k_sync)?; + 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(k_xcs)?; + 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, @@ -72,6 +138,7 @@ impl FirefoxAccount { self.state .scoped_keys .insert(scopes::OLD_SYNC.to_string(), k_sync_scoped_key); + Ok(()) } } diff --git a/components/fxa-client/src/state_persistence.rs b/components/fxa-client/src/state_persistence.rs index 9fe979a250..0afc545404 100644 --- a/components/fxa-client/src/state_persistence.rs +++ b/components/fxa-client/src/state_persistence.rs @@ -90,6 +90,7 @@ impl From for Result { session_token: None, current_device_id: None, last_seen_profile: None, + in_flight_migration: None, access_token_cache: HashMap::new(), }) }