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,
};
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),
}
}
#[derive(Serialize, Deserialize)]
#[serde(tag = "schema_version")]
#[allow(clippy::large_enum_variant)]
enum PersistedState {
#[serde(skip_serializing)]
V1(StateV1),
V2(StateV2),
}
#[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>,
#[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>,
pub(crate) last_seen_profile: Option<CachedResponse<Profile>>,
pub(crate) in_flight_migration: Option<MigrationData>,
pub(crate) ecosystem_user_id: Option<String>,
}
impl StateV2 {
pub(crate) fn start_over(&self) -> StateV2 {
StateV2 {
config: self.config.clone(),
current_device_id: None,
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,
}
}
}
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);
}
}
}
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,
})
}
}
#[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,
pub scopes: Vec<String>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_migration_from_v1() {
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() {
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);
}
}