drive-v3 0.6.0

A library for interacting the Google Drive API
Documentation
use url::Url;
use std::{fs, fmt};
use std::path::Path;
use std::collections::HashMap;
use serde::{Deserialize, Serialize};

use crate::{Error, ErrorKind, LocalServer};
use crate::security::{get_code_verifier, get_random_state};

const AUTHORIZATION_COMPLETE_MESSAGE: &str = "Authorization complete, you can close this window";

/// Gets the queries present in `url` and returns them as a [`HashMap`].
///
/// # Errors
///
/// - a [`UrlParsing`](crate::ErrorKind::UrlParsing) error, if unable to parse the `url`.
fn get_url_query<T: AsRef<str>> ( url: T ) -> crate::Result< HashMap<String, String> > {
    let parsed_url = Url::parse( url.as_ref() )?;

    Ok( parsed_url.query_pairs().into_owned().collect() )
}

/// Representation of the `client_secrets` JSON file that contains the authorization info for your API.
///
/// ```no_run
/// use drive_v3::ClientSecrets;
/// use drive_v3::AccessToken;
/// # use drive_v3::Error;
///
/// let secrets_path = "my_secrets.json";
/// # let secrets_path = "../.secure-files/google_drive_secrets.json";
/// #
/// let my_client_secrets = ClientSecrets::from_file(&secrets_path)?;
///
/// // Now you can requests access tokens with your client secrets
/// let scopes = ["https://www.googleapis.com/auth/drive.metadata.readonly"];
/// let my_access_token = AccessToken::request(&my_client_secrets, &scopes)?;
///
/// // After getting your token you can make a request (with reqwest for example) using it for authorization
/// let client = reqwest::blocking::Client::new();
/// let body = client.get("google-api-endpoint")
///     .bearer_auth(&my_access_token.access_token)
///     .send()?
///     .text()?;
///
/// println!("response: {:?}", body);
/// # Ok::<(), Error>(())
/// ```
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ClientSecrets {
    /// Your API's client ID.
    pub client_id: String,

    /// Your API's project ID.
    pub project_id: String,

    /// The URI your API can call to request authorization.
    pub auth_uri: String,

    /// The URI your API can call to request an [`AccessToken`](crate::AccessToken).
    pub token_uri: String,

    /// Your API's certificate provider URL.
    pub auth_provider_x509_cert_url: String,

    /// Your API's client secret.
    pub client_secret: String,

    /// Your API's redirect URIs.
    pub redirect_uris: Vec<String>,
}

#[cfg(not(tarpaulin_include))]
impl fmt::Debug for ClientSecrets {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.debug_struct("ClientSecrets")
            .field("client_id", &format_args!("[hidden for security]"))
            .field("project_id", &format_args!("[hidden for security]"))
            .field("auth_uri", &self.auth_uri)
            .field("token_uri", &self.token_uri)
            .field(
                "auth_provider_x509_cert_url",
                &self.auth_provider_x509_cert_url,
            )
            .field("client_secret", &format_args!("[hidden for security]"))
            .field("redirect_uris", &self.redirect_uris)
            .finish()
    }
}

impl ClientSecrets {
    /// Gets [`ClientSecrets`] from `file`.
    ///
    /// # Examples
    ///
    /// ```no_run
    /// use drive_v3::ClientSecrets;
    /// use drive_v3::AccessToken;
    /// # use drive_v3::Error;
    ///
    /// let secrets_path = "my_secrets.json";
    /// # let secrets_path = "../.secure-files/google_drive_secrets.json";
    /// #
    /// let my_client_secrets = ClientSecrets::from_file(&secrets_path)?;
    ///
    /// // Now you can requests access tokens with your client secrets
    /// let scopes = ["https://www.googleapis.com/auth/drive.metadata.readonly"];
    /// let my_access_token = AccessToken::request(&my_client_secrets, &scopes)?;
    ///
    /// // After getting your token you can make a request (with reqwest for example) using it for authorization
    /// let client = reqwest::blocking::Client::new();
    /// let body = client.get("google-api-endpoint")
    ///     .bearer_auth(&my_access_token.access_token)
    ///     .send()?
    ///     .text()?;
    ///
    /// println!("response: {:?}", body);
    /// # Ok::<(), Error>(())
    /// ```
    ///
    /// # Errors
    ///
    /// - an [`IO`](crate::ErrorKind::IO) error, if the file does not exist.
    /// - a [`Json`](crate::ErrorKind::Json) error, if parsing of the file from json failed.
    pub fn from_file<T: AsRef<Path>> ( file: T ) -> crate::Result<Self> {
        let contents = fs::read_to_string( file.as_ref() )?;
        let parsed_json = serde_json::from_str::<HashMap<&str, ClientSecrets>> (&contents)?;

        match parsed_json.get("installed") {
            Some(client_secrets) => Ok( client_secrets.clone() ),
            None => Err( Error::new(
                ErrorKind::Json,
                format!( "file '{}' did not contain the required 'installed' parent object", file.as_ref().to_string_lossy() ),
            ) )
        }
    }

    /// Requests and authorization `code` and it's associated `code_verifier` from Google's API.
    ///
    /// This code can be used to request [`AccessTokens`](crate::AccessToken) using
    /// [OAuth2](https://developers.google.com/identity/protocols/oauth2/native-app#handlingresponse).
    ///
    /// # Note:
    ///
    /// Do not use this function directly in [`drive_v3`](crate) unless you need to make custom requests using an authorization
    /// `code`, instead use the [`Credentials`](crate::Credentials) struct to easily handle authorization.
    ///
    /// # Examples:
    ///
    /// See Google's official
    /// [handle OAuth 2.0 responses](https://developers.google.com/identity/protocols/oauth2/native-app#handlingresponse)
    /// documentation for information on what to use these codes for.
    ///
    /// # Errors
    ///
    /// - a [`HexDecoding`](crate::ErrorKind::HexDecoding) or [`UrlParsing`](crate::ErrorKind::UrlParsing) error, if the
    /// creation of the `code verifier` failed.
    /// - a [`UrlParsing`](crate::ErrorKind::UrlParsing) error, if the creation of the authorization request's URL
    /// failed.
    /// - a [`LocalServer`](crate::ErrorKind::LocalServer) or [`IO`](crate::ErrorKind::IO) error, if the
    /// [`LocalServer`](crate::ErrorKind::LocalServer) failed to listen for incoming requests.
    /// - a [`UrlParsing`](crate::ErrorKind::UrlParsing) error, if unable to get the `query` containing the returned
    ///  `authorization code`.
    /// - a [`MismatchedState`](crate::ErrorKind::MismatchedState) error, if the `state` contained on the response doesn't
    /// match the originally created one.
    /// - a [`MismatchedScopes`](crate::ErrorKind::MismatchedScopes) error, if the user didn't authorize all the requested
    /// scopes.
    /// - a [`NoAuthorizationCode`](crate::ErrorKind::NoAuthorizationCode) error, if the response didn't contain an
    /// `authorization code`.
    pub fn get_authorization_code<T: AsRef<str>> ( &self, scopes: &[T], open_browser: bool ) -> crate::Result<(String, String)> {
        let scopes: Vec<String> = scopes.iter().map( |s| s.as_ref().to_string() ).collect();
        let scope = scopes.join(" ");

        let local_server = LocalServer::new().with_response(AUTHORIZATION_COMPLETE_MESSAGE);

        let (code_verifier, code_challenge) = get_code_verifier()?;
        let random_state = get_random_state();

        let auth_parameters: [(&str, &str); 7] = [
            ( "client_id",             &self.client_id        ),
            ( "redirect_uri",          &local_server.uri      ),
            ( "response_type",         "code"                 ),
            ( "scope",                 &scope                 ),
            ( "code_challenge",        &code_challenge        ),
            ( "code_challenge_method", "S256"                 ),
            ( "state",                 &random_state          ),
        ];

        let auth_url = Url::parse_with_params(&self.auth_uri, &auth_parameters)?;

        match open_browser {
            #[cfg(not(tarpaulin_include))]
            true => {
                let _ = opener::open_browser( auth_url.as_str() );

                println!("launched authorization screen in browser");
                println!("if your browser didn't open or you closed the window, open this link:");
            },
            false => println!("to authorize your access to Google's API open this link:"),
        };

        println!("{}", auth_url);

        let received_request = local_server.wait_for_request()?;
        let response_parameters = get_url_query(received_request)?;

        if let Some(response_state) = response_parameters.get("state") {
            if response_state != &random_state {
                return Err(
                    Error::new(ErrorKind::MismatchedState, "created and returned states do not match, possible forgery detected")
                )
            }
        }

        if let Some(authorization_code) = response_parameters.get("code") {
            return Ok(( authorization_code.to_string(), code_verifier.to_string() ))
        }

        Err(
            Error::new(ErrorKind::NoAuthorizationCode, "the response from the Google Drive API did not contain the required code parameter")
        )
    }
}

#[cfg(test)]
mod tests {
    use std::thread;
    use crate::ErrorKind;
    use testfile::TestFile;
    use super::{ClientSecrets, AUTHORIZATION_COMPLETE_MESSAGE};
    use crate::utils::test::{VALID_CREDENTIALS, LOCAL_SERVER_IN_USE, curl_local_server};

    fn get_test_client_secrets() -> ClientSecrets {
        ClientSecrets {
            client_id: String::from("test-client-id"),
            project_id: String::from("test-project-id"),
            auth_uri: String::from("test-auth-uri"),
            token_uri: String::from("test-token-uri"),
            auth_provider_x509_cert_url: String::from("test-auth-provider"),
            client_secret: String::from("test-client-secret"),
            redirect_uris: vec![ String::from("test-redirect-uri") ],
        }
    }

    fn get_test_json() -> String {
        String::from("{
            \"installed\": {
                \"client_id\": \"test-client-id\",
                \"project_id\": \"test-project-id\",
                \"auth_uri\": \"test-auth-uri\",
                \"token_uri\": \"test-token-uri\",
                \"auth_provider_x509_cert_url\": \"test-auth-provider\",
                \"client_secret\": \"test-client-secret\",
                \"redirect_uris\": [\"test-redirect-uri\"]
            }
        }")
    }

    fn get_test_json_invalid_main_key() -> String {
        String::from("{
            \"invalid\": {
                \"client_id\": \"test-client-id\",
                \"project_id\": \"test-project-id\",
                \"auth_uri\": \"test-auth-uri\",
                \"token_uri\": \"test-token-uri\",
                \"auth_provider_x509_cert_url\": \"test-auth-provider\",
                \"client_secret\": \"test-client-secret\",
                \"redirect_uris\": [\"test-redirect-uri\"]
            }
        }")
    }

    fn get_test_client_secrets_file() -> TestFile {
        testfile::from( &get_test_json() )
    }

    #[test]
    fn from_file_test() {
        let test_file = get_test_client_secrets_file();
        let expected_client_secrets = get_test_client_secrets();

        let client_secrets = ClientSecrets::from_file(&test_file).unwrap();

        assert_eq!(client_secrets, expected_client_secrets);
    }

    #[test]
    fn from_file_non_existent_test() {
        let result = ClientSecrets::from_file(&"non-existent-path");

        assert!( result.is_err() );
        assert_eq!( result.unwrap_err().kind, ErrorKind::IO );
    }

    #[test]
    fn from_file_invalid_json_test() {
        let test_file = testfile::from("invalid_json");
        let result = ClientSecrets::from_file(&test_file);

        assert!( result.is_err() );
        assert_eq!( result.unwrap_err().kind, ErrorKind::Json );
    }

    #[test]
    fn from_file_invalid_installed_test() {
        let test_file = testfile::from( &get_test_json_invalid_main_key() );
        let result = ClientSecrets::from_file(&test_file);

        assert!( result.is_err() );
        assert_eq!( result.unwrap_err().kind, ErrorKind::Json );
    }

    #[test]
    fn get_authorization_code_test() {
        // Only run if no other tests are using the local server
        let _unused = LOCAL_SERVER_IN_USE.lock().unwrap();

        let handle = thread::spawn( || {
            let client_secrets = VALID_CREDENTIALS.client_secrets.clone();

            let scopes = ["https://www.googleapis.com/auth/drive.metadata.readonly"];
            let (code, _) = client_secrets.get_authorization_code(&scopes, false).unwrap();

            assert_eq!( code, String::from("test-code") )
        } );

        let response = curl_local_server("?code=test-code").unwrap();
        assert_eq!(response, AUTHORIZATION_COMPLETE_MESSAGE);

        handle.join().unwrap();
    }

    #[test]
    fn get_authorization_code_invalid_url_test() {
        let mut client_secrets = VALID_CREDENTIALS.client_secrets.clone();

        client_secrets.auth_uri = String::from("invalid-uri");

        let scopes = ["https://www.googleapis.com/auth/drive.metadata.readonly"];
        let result = client_secrets.get_authorization_code(&scopes, false);

        assert!( result.is_err() );
        assert_eq!( result.unwrap_err().kind, ErrorKind::UrlParsing );
    }

    #[test]
    fn get_authorization_code_mismatched_state_test() {
        // Only run if no other tests are using the local server
        let _unused = LOCAL_SERVER_IN_USE.lock().unwrap();

        let client_secrets = VALID_CREDENTIALS.client_secrets.clone();
        let scopes = ["https://www.googleapis.com/auth/drive.metadata.readonly"];

        let handle = thread::spawn( move || {
            let result = client_secrets.get_authorization_code(&scopes, false);

            assert!( result.is_err() );
            assert_eq!( result.unwrap_err().kind, ErrorKind::MismatchedState );
        } );

        curl_local_server("?code=test-code&state=invalid-state").unwrap();

        handle.join().unwrap();
    }

    #[test]
    fn get_authorization_code_no_response_code_test() {
        // Only run if no other tests are using the local server
        let _unused = LOCAL_SERVER_IN_USE.lock().unwrap();

        let client_secrets = VALID_CREDENTIALS.client_secrets.clone();
        let scopes = ["https://www.googleapis.com/auth/drive.metadata.readonly"];

        let request_handle = thread::spawn( move || {
            let result = client_secrets.get_authorization_code(&scopes, false);

            assert!( result.is_err() );
            assert_eq!( result.unwrap_err().kind, ErrorKind::NoAuthorizationCode );
        } );

        curl_local_server("").unwrap();

        request_handle.join().unwrap();
    }
}