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";
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() )
}
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ClientSecrets {
pub client_id: String,
pub project_id: String,
pub auth_uri: String,
pub token_uri: String,
pub auth_provider_x509_cert_url: String,
pub client_secret: String,
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 {
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() ),
) )
}
}
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() {
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() {
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() {
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();
}
}