xal 0.1.3

Xbox Authentication library
Documentation
//! Token store

use crate::{
    response::{DeviceToken, TitleToken, UserToken, WindowsLiveTokens, XSTSToken},
    Error, XalAppParameters, XalAuthenticator, XalClientParameters,
};
use chrono::{DateTime, Utc};
use log::trace;
use serde::{Deserialize, Serialize};
use std::io::{Read, Seek};

/// Model describing authentication tokens
///
/// Can be used for de-/serializing tokens and respective
/// authentication parameters.
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct TokenStore {
    /// Stored app parameters
    pub app_params: XalAppParameters,
    /// Stored client parameters
    pub client_params: XalClientParameters,
    /// Xbox Live sandbox id used for authentication
    pub sandbox_id: String,
    /// Windows Live access- & refresh token
    pub live_token: WindowsLiveTokens,
    /// Xbox live user token
    pub user_token: Option<UserToken>,
    /// Xbox live title token
    pub title_token: Option<TitleToken>,
    /// Xbox live device token
    pub device_token: Option<DeviceToken>,
    /// Xbox live authorization/XSTS token
    pub authorization_token: Option<XSTSToken>,
    /// Update timestamp of this struct
    ///
    /// Can be updated by calling `update_timestamp`
    /// on its instance.
    pub updated: Option<DateTime<Utc>>,
}

impl From<TokenStore> for XalAuthenticator {
    fn from(value: TokenStore) -> Self {
        Self::new(
            value.app_params.clone(),
            value.client_params.clone(),
            value.sandbox_id.clone(),
        )
    }
}

#[allow(clippy::to_string_trait_impl)]
impl ToString for TokenStore {
    fn to_string(&self) -> String {
        serde_json::to_string(&self).expect("Failed to serialize TokenStore")
    }
}

impl TokenStore {
    /// Load a tokenstore from a file by providing the filename/path to the
    /// serialized JSON
    ///
    /// Returns the TokenStore instance on success
    ///
    /// # Examples
    ///
    /// Load tokenstore from file
    ///
    /// ```
    /// # use xal::TokenStore;
    /// # fn demo() -> Result<(), xal::Error> {
    /// let tokenstore = TokenStore::load_from_file("tokens.json")?;
    /// # Ok(())
    /// # }
    /// // refresh tokens etc. ..
    /// ```
    pub fn load_from_file(filepath: &str) -> Result<Self, Error> {
        trace!("Trying to load tokens from filepath={:?}", filepath);
        let mut file = std::fs::File::options().read(true).open(filepath)?;

        let mut json = String::new();
        file.read_to_string(&mut json)?;

        Self::deserialize_from_string(&json)
    }

    /// Load tokens from JSON string
    ///
    /// # Examples
    ///
    /// ```
    /// # use xal::TokenStore;
    /// # fn demo() -> Result<(), xal::Error> {
    /// let tokens_json = std::fs::read_to_string("tokens.json")?;
    /// let tokenstore = TokenStore::deserialize_from_string(&tokens_json)?;
    /// # Ok(())
    /// # }
    /// // refresh tokens etc. ..
    /// ```
    pub fn deserialize_from_string(json: &str) -> Result<Self, Error> {
        trace!("Attempting to deserialize token data");
        serde_json::from_str(json).map_err(std::convert::Into::into)
    }

    /// Save tokens to writer
    ///
    /// # Examples
    ///
    /// ```
    /// # use xal::TokenStore;
    /// # fn demo() -> Result<(), xal::Error> {
    /// let tokenstore = TokenStore::load_from_file("tokens.json")?;
    /// // refresh tokens ...
    /// let file = std::fs::File::create("tokens.json")?;
    /// tokenstore.save_to_writer(&file).ok();
    /// # Ok(())
    /// # }
    /// ```
    pub fn save_to_writer(&self, writer: impl std::io::Write) -> Result<(), Error> {
        serde_json::to_writer_pretty(writer, self).map_err(std::convert::Into::into)
    }

    /// Save the tokens to a JSON file
    ///
    /// NOTE: If the file already exists it will be overwritten
    ///
    /// # Examples
    ///
    /// ```
    /// # use xal::TokenStore;
    /// # fn demo() -> Result<(), xal::Error> {
    /// let tokenstore = TokenStore::load_from_file("tokens.json")?;
    /// // refresh tokens ...
    /// tokenstore.save_to_file("tokens.json").ok();
    /// # Ok(())
    /// # }
    /// ```
    pub fn save_to_file(&self, filepath: &str) -> Result<(), Error> {
        trace!(
            "Trying to open tokenfile read/write/create path={:?}",
            filepath
        );
        let mut file = std::fs::File::options()
            .read(true)
            .write(true)
            .create(true)
            .truncate(true)
            .open(filepath)?;

        file.rewind()?;
        file.set_len(0)?;

        trace!("Saving tokens path={:?}", filepath);
        self.save_to_writer(file)
    }

    /// Update the timestamp of this instance
    ///
    /// # Examples
    ///
    /// ```
    /// # use xal::TokenStore;
    /// # fn demo() -> Result<(), xal::Error> {
    /// let mut tokenstore = TokenStore::load_from_file("tokens.json")?;
    /// // refresh tokens ...
    /// tokenstore.update_timestamp();
    /// tokenstore.save_to_file("tokens.json").ok();
    /// # Ok(())
    /// # }
    /// ```
    pub fn update_timestamp(&mut self) {
        self.updated = Some(chrono::offset::Utc::now());
    }
}

#[cfg(test)]
mod tests {
    use oauth2::TokenResponse;
    use rand::distributions::{Alphanumeric, DistString};

    use super::*;

    fn random_filename() -> String {
        Alphanumeric.sample_string(&mut rand::thread_rng(), 16)
    }

    #[test]
    fn read_invalid_tokenfile() {
        let res = TokenStore::load_from_file(&random_filename());

        assert!(res.is_err());
    }

    #[test]
    fn read_from_string() {
        let tokens_str = r#"{"app_params":{"client_id":"00000000441cc96b","title_id":"42","auth_scopes":["service::user.auth.xboxlive.com::MBI_SSL"],"redirect_uri":"https://login.live.com/oauth20_desktop.srf"},"client_params":{"user_agent":"XAL","device_type":"NINTENDO","client_version":"0.0.0","query_display":"touch"},"sandbox_id":"RETAIL","live_token":{"access_token":"accessTokenABC","token_type":"bearer","expires_in":86400,"refresh_token":"refreshTokenABC","scope":"service::user.auth.xboxlive.com::MBI_SSL"}}"#;
        let ts = TokenStore::deserialize_from_string(tokens_str).unwrap();

        assert_eq!(ts.app_params.client_id, "00000000441cc96b");
        assert_eq!(ts.app_params.title_id, Some("42".into()));
        assert_eq!(
            ts.app_params.auth_scopes.first().unwrap().as_str(),
            "service::user.auth.xboxlive.com::MBI_SSL"
        );
        assert_eq!(
            ts.app_params.redirect_uri.unwrap().as_str(),
            "https://login.live.com/oauth20_desktop.srf"
        );

        assert_eq!(ts.client_params.client_version, "0.0.0");
        assert_eq!(ts.client_params.device_type.to_string(), "Nintendo");
        assert_eq!(ts.client_params.query_display, "touch");
        assert_eq!(ts.client_params.user_agent, "XAL");

        assert_eq!(ts.live_token.access_token().secret(), "accessTokenABC");
        assert_eq!(
            ts.live_token.refresh_token().unwrap().secret(),
            "refreshTokenABC"
        );
        assert_eq!(
            ts.live_token.expires_in().unwrap(),
            std::time::Duration::from_secs(86400)
        );
        assert_eq!(
            ts.live_token.scopes().unwrap().first().unwrap().as_str(),
            "service::user.auth.xboxlive.com::MBI_SSL"
        );
    }
}