fxa_client/internal/
state_persistence.rs

1/* This Source Code Form is subject to the terms of the Mozilla Public
2 * License, v. 2.0. If a copy of the MPL was not distributed with this
3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4
5//! Serialization of `FirefoxAccount` state to/from a JSON string.
6//!
7//! This module implements the ability to serialize a `FirefoxAccount` struct to and from
8//! a JSON string. The idea is that calling code will use this to persist the account state
9//! to storage.
10//!
11//! Many of the details here are a straightforward use of `serde`, with all persisted data being
12//! a field on a `State` struct. This is, however, some additional complexity around handling data
13//! migrations - we need to be able to evolve the internal details of the `State` struct while
14//! gracefully handing users who are upgrading from an older version of a consuming app, which has
15//! stored account state from an older version of this component.
16//!
17//! Data migration is handled by explicitly naming different versions of the state struct to
18//! correspond to different incompatible changes to the data representation, e.g. `StateV1` and
19//! `StateV2`. We then wrap this in a `PersistedStateTagged` enum whose serialization gets explicitly
20//! tagged with the corresponding state version number.
21//!
22//! For backwards-compatible changes to the data (such as adding a new field that has a sensible
23//! default) we keep the current `State` struct, but modify it in such a way that `serde` knows
24//! how to do the right thing.
25//!
26//! For backwards-incompatible changes to the data (such as removing or significantly refactoring
27//! fields) we define a new `StateV{X+1}` struct, and use the `From` trait to define how to update
28//! from older struct versions.
29//! For an example how the conversion works, [we can look at `StateV1` which was deliberately removed](https://github.com/mozilla/application-services/issues/3912)
30//! The code that was deleted demonstrates how we can implement the migration
31
32use serde_derive::*;
33use std::collections::{HashMap, HashSet};
34
35use super::{
36    config::Config,
37    oauth::{AccessTokenInfo, RefreshToken},
38    profile::Profile,
39    CachedResponse, Result,
40};
41use crate::{DeviceCapability, LocalDevice, ScopedKey};
42
43// These are the public API for working with the persisted state.
44
45pub(crate) type PersistedState = StateV2;
46
47/// Parse a `State` from a JSON string, performing migrations if necessary.
48///
49pub(crate) fn state_from_json(data: &str) -> Result<PersistedState> {
50    let stored_state: PersistedStateTagged = serde_json::from_str(data)?;
51    upgrade_state(stored_state)
52}
53
54/// Serialize a `State` to a JSON string.
55///
56pub(crate) fn state_to_json(state: &PersistedState) -> Result<String> {
57    let state = PersistedStateTagged::V2(state.clone());
58    serde_json::to_string(&state).map_err(Into::into)
59}
60
61fn upgrade_state(in_state: PersistedStateTagged) -> Result<PersistedState> {
62    match in_state {
63        PersistedStateTagged::V2(state) => Ok(state),
64    }
65}
66
67/// `PersistedStateTagged` is a tagged container for one of the state versions.
68/// Serde picks the right `StructVX` to deserialized based on the schema_version tag.
69///
70#[derive(Serialize, Deserialize)]
71#[serde(tag = "schema_version")]
72#[allow(clippy::large_enum_variant)]
73enum PersistedStateTagged {
74    V2(StateV2),
75}
76
77/// `StateV2` is the current state schema. It and its fields all need to be public
78/// so that they can be used directly elsewhere in the crate.
79///
80/// If you want to modify what gets stored in the state, consider the following:
81///
82///   * Is the change backwards-compatible with previously-serialized data?
83///     If so then you'll need to tell serde how to fill in a suitable default.
84///     If not then you'll need to make a new `StateV3` and implement an explicit migration.
85///
86///   * How does the new field need to be modified when the user disconnects from the account or is
87///     logged out from auth issues? Update [state_manager.disconnect] and
88///     [state_manager.on_auth_issues].
89///
90#[derive(Clone, Serialize, Deserialize)]
91pub(crate) struct StateV2 {
92    pub(crate) config: Config,
93    pub(crate) current_device_id: Option<String>,
94    pub(crate) refresh_token: Option<RefreshToken>,
95    pub(crate) scoped_keys: HashMap<String, ScopedKey>,
96    pub(crate) last_handled_command: Option<u64>,
97    // Everything below here was added after `StateV2` was initially defined,
98    // and hence needs to have a suitable default value.
99    // We can remove serde(default) when we define a `StateV3`.
100    #[serde(default)]
101    pub(crate) commands_data: HashMap<String, String>,
102    #[serde(default)]
103    pub(crate) device_capabilities: HashSet<DeviceCapability>,
104    #[serde(default)]
105    pub(crate) access_token_cache: HashMap<String, AccessTokenInfo>,
106    pub(crate) session_token: Option<String>, // Hex-formatted string.
107    pub(crate) last_seen_profile: Option<CachedResponse<Profile>>,
108    // The last LocalDevice info sent back from the server
109    #[serde(default)]
110    pub(crate) server_local_device_info: Option<LocalDevice>,
111    #[serde(default)]
112    pub(crate) logged_out_from_auth_issues: bool,
113}
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118
119    #[test]
120    fn test_invalid_schema_version() {
121        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\"]}}}";
122        if state_from_json(state_v1_json).is_ok() {
123            panic!("Invalid schema passed the conversion from json")
124        }
125    }
126
127    #[test]
128    fn test_v2_ignores_unknown_fields_introduced_by_future_changes_to_the_schema() {
129        // This is a snapshot of what some persisted StateV2 data would look before any backwards-compatible changes
130        // were made. It's very important that you don't modify this string, which would defeat the point of the test!
131        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}";
132        let state = state_from_json(state_v2_json).unwrap();
133        let refresh_token = state.refresh_token.unwrap();
134        assert_eq!(
135            refresh_token.token,
136            "bed5532f4fea7e39c5c4f609f53603ee7518fd1c103cc4034da3618f786ed188"
137        );
138    }
139
140    #[test]
141    fn test_v2_creates_an_empty_access_token_cache_if_its_missing() {
142        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}}";
143        let state = state_from_json(state_v2_json).unwrap();
144        let refresh_token = state.refresh_token.unwrap();
145        assert_eq!(
146            refresh_token.token,
147            "bed5532f4fea7e39c5c4f609f53603ee7518fd1c103cc4034da3618f786ed188"
148        );
149        assert_eq!(state.access_token_cache.len(), 0);
150    }
151}