From 11c74ce47995646927fa5ffca94dbba5d7904ca2 Mon Sep 17 00:00:00 2001 From: Rui Barbosa Date: Sun, 20 Aug 2023 18:25:53 -0400 Subject: [PATCH] Oauth (#11) * OAUTH: authorization url * OAUTH: authorization url * OAUTH: request token + set_access_token * OAUTH: request token + set_access_token * OAUTH: dynamic function to store access token * OAUTH: example and interface tuning * OAUTH: example and interface tuning * OAUTH: fixing tests * CCG: Fix hardcoded bos_subject_type from env * AUTH: example --- .gitignore | 1 + Cargo.toml | 3 + examples/auth_ccg.rs | 11 ++- examples/auth_oauth.rs | 46 +++++++++ examples/oauth/authorize_app.rs | 32 ++++++ examples/oauth/http_request_listener.rs | 49 +++++++++ examples/oauth/mod.rs | 3 + examples/oauth/storage.rs | 7 ++ examples/users_list.rs | 9 +- src/auth/auth_ccg.rs | 12 ++- src/auth/auth_developer.rs | 4 +- src/auth/auth_errors.rs | 8 +- src/auth/auth_oauth.rs | 126 ++++++++++++++++++++---- src/client/box_client.rs | 10 +- src/config.rs | 2 +- src/lib.rs | 1 + tests/common/box_client.rs | 10 +- 17 files changed, 296 insertions(+), 38 deletions(-) create mode 100644 examples/auth_oauth.rs create mode 100644 examples/oauth/authorize_app.rs create mode 100644 examples/oauth/http_request_listener.rs create mode 100644 examples/oauth/mod.rs create mode 100644 examples/oauth/storage.rs diff --git a/.gitignore b/.gitignore index 42855c5..e05d0cd 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,7 @@ Cargo.lock .DS_Store .access_token.json +*.cache.json .env *.env # Added by cargo diff --git a/Cargo.toml b/Cargo.toml index d4aaf80..5630462 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,6 +35,9 @@ rand = "0.8.5" pretty_assertions = "1.3.0" env_logger = { version = "0.10.0", default-features = false } tokio = { version = "1.11.0", features = ["rt-multi-thread", "macros"] } +webbrowser = "0.8.9" +tiny_http = "0.12.0" +serde_qs = "0.12.0" [[example]] name = "auth_developer" diff --git a/examples/auth_ccg.rs b/examples/auth_ccg.rs index 722b9ef..468056b 100644 --- a/examples/auth_ccg.rs +++ b/examples/auth_ccg.rs @@ -15,8 +15,15 @@ async fn main() -> Result<(), BoxAPIError> { let client_id = env::var("CLIENT_ID").expect("CLIENT_ID must be set"); let client_secret = env::var("CLIENT_SECRET").expect("CLIENT_SECRET must be set"); - let box_subject_type = SubjectType::Enterprise; - let box_subject_id = env::var("BOX_ENTERPRISE_ID").expect("BOX_ENTERPRISE_ID must be set"); + + let env_subject_type = env::var("BOX_SUBJECT_TYPE").expect("BOX_SUBJECT_TYPE must be set"); + let box_subject_type = match env_subject_type.as_str() { + "user" => SubjectType::User, + "enterprise" => SubjectType::Enterprise, + _ => panic!("BOX_SUBJECT_TYPE must be either 'user' or 'enterprise'"), + }; + + let box_subject_id = env::var("BOX_SUBJECT_ID").expect("BOX_SUBJECT_ID must be set"); let config = Config::new(); let auth = CCGAuth::new( diff --git a/examples/auth_oauth.rs b/examples/auth_oauth.rs new file mode 100644 index 0000000..93610d4 --- /dev/null +++ b/examples/auth_oauth.rs @@ -0,0 +1,46 @@ +mod oauth; + +use rusty_box::{BoxAPIError, BoxClient, Config, OAuth}; + +use crate::oauth::{authorize_app, storage}; +use std::env; + +#[tokio::main] +async fn main() -> Result<(), BoxAPIError> { + dotenv::from_filename(".oauth.env").expect("Failed to read .env file"); + + let client_id = env::var("CLIENT_ID").expect("CLIENT_ID not set"); + let client_secret = env::var("CLIENT_SECRET").expect("CLIENT_SECRET not set"); + let redirect_uri = env::var("REDIRECT_URI").expect("REDIRECT_URI not set"); + + let config = Config::new(); + let oauth = OAuth::new( + config, + client_id, + client_secret, + Some(storage::save_access_token), + ); + + // Load the OAuth token from the cache file + let oauth_json = oauth::storage::load_access_token(); + + let oauth = match oauth_json { + Ok(oauth_json) => { + println!("Cached token found, refreshing"); + let oauth: OAuth = + serde_json::from_str(&oauth_json).expect("Failed to parse cached token"); + oauth + } + Err(_) => { + println!("No cached token found, authorizing app"); + authorize_app::authorize_app(oauth, Some(redirect_uri)).await? + } + }; + + let mut client = BoxClient::new(Box::new(oauth)); + + let me = rusty_box::users_api::me(&mut client, None).await; + println!("Me:\n{me:#?}\n"); + + Ok(()) +} diff --git a/examples/oauth/authorize_app.rs b/examples/oauth/authorize_app.rs new file mode 100644 index 0000000..87b5501 --- /dev/null +++ b/examples/oauth/authorize_app.rs @@ -0,0 +1,32 @@ +use super::http_request_listener; +use http_request_listener::request_process; +use rusty_box::{AuthError, BoxAPIError, OAuth}; + +static HOSTNAME: &str = "127.0.0.1"; +const PORT: i16 = 5000; + +pub async fn authorize_app( + mut oauth: OAuth, + redirect_uri: Option, +) -> Result { + let (authorization_url, state_out) = oauth.authorization_url(redirect_uri, None, None)?; + + webbrowser::open(&authorization_url).expect("Failed to open browser"); + + let hostname_port = HOSTNAME.to_owned() + ":" + &PORT.to_string(); + let server = tiny_http::Server::http(hostname_port).unwrap(); + println!("Listening on {}", server.server_addr()); + + let (code, state_in) = match request_process(server) { + Ok((code, state)) => (code, state), + Err(e) => return Err(BoxAPIError::AuthError(AuthError::Generic(e))), + }; + if state_in != state_out { + return Err(BoxAPIError::AuthError(AuthError::Generic( + "State mismatch".to_string(), + ))); + } + + oauth.request_access_token(code).await?; + Ok(oauth) +} diff --git a/examples/oauth/http_request_listener.rs b/examples/oauth/http_request_listener.rs new file mode 100644 index 0000000..de0a67d --- /dev/null +++ b/examples/oauth/http_request_listener.rs @@ -0,0 +1,49 @@ +use url::Url; + +#[derive(Debug, serde::Serialize, serde::Deserialize)] +pub struct UrlParams { + #[serde(skip_serializing_if = "Option::is_none")] + pub code: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub state: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub error_description: Option, +} + +pub fn request_process(server: tiny_http::Server) -> Result<(String, String), String> { + match server.recv() { + Ok(rq) => { + let base_url = Url::parse("http://127.0.0.1").expect("Error parsing base URL"); + + let url = base_url + .join(rq.url()) + .expect("Error parsing URL from request"); + + let query_params: UrlParams = match serde_qs::from_str(url.query().unwrap_or_default()) + { + Ok(query_params) => query_params, + Err(_) => return Err("Error srializing url query".to_string()), + }; + + match query_params.code { + Some(code) => { + let response = tiny_http::Response::empty(200); + rq.respond(response) + .expect("Error sending response local server"); + Ok((code, query_params.state.unwrap_or_default())) + } + None => Err(format!( + "{} {}", + query_params.error.unwrap_or_default(), + query_params.error_description.unwrap_or_default() + )), + } + } + Err(_) => Err("Error receiving request from local server".to_string()), + } +} diff --git a/examples/oauth/mod.rs b/examples/oauth/mod.rs new file mode 100644 index 0000000..9c171e7 --- /dev/null +++ b/examples/oauth/mod.rs @@ -0,0 +1,3 @@ +pub mod authorize_app; +pub mod http_request_listener; +pub mod storage; diff --git a/examples/oauth/storage.rs b/examples/oauth/storage.rs new file mode 100644 index 0000000..b09f4c8 --- /dev/null +++ b/examples/oauth/storage.rs @@ -0,0 +1,7 @@ +pub fn save_access_token(json: String) { + std::fs::write(".token.cache.json", json).expect("Unable to save access token") +} + +pub fn load_access_token() -> std::io::Result { + std::fs::read_to_string(".token.cache.json") +} diff --git a/examples/users_list.rs b/examples/users_list.rs index 106f8b5..c0216eb 100644 --- a/examples/users_list.rs +++ b/examples/users_list.rs @@ -13,8 +13,13 @@ async fn main() -> Result<(), BoxAPIError> { let client_id = env::var("CLIENT_ID").expect("CLIENT_ID must be set"); let client_secret = env::var("CLIENT_SECRET").expect("CLIENT_SECRET must be set"); - let box_subject_type = SubjectType::Enterprise; - let box_subject_id = env::var("BOX_ENTERPRISE_ID").expect("BOX_ENTERPRISE_ID must be set"); + let env_subject_type = env::var("BOX_SUBJECT_TYPE").expect("BOX_SUBJECT_TYPE must be set"); + let box_subject_type = match env_subject_type.as_str() { + "user" => SubjectType::User, + "enterprise" => SubjectType::Enterprise, + _ => panic!("BOX_SUBJECT_TYPE must be either 'user' or 'enterprise'"), + }; + let box_subject_id = env::var("BOX_SUBJECT_ID").expect("BOX_SUBJECT_ID must be set"); let config = Config::new(); let auth = CCGAuth::new( diff --git a/src/auth/auth_ccg.rs b/src/auth/auth_ccg.rs index 9978019..026af7f 100644 --- a/src/auth/auth_ccg.rs +++ b/src/auth/auth_ccg.rs @@ -113,7 +113,7 @@ impl<'a> Auth<'a> for CCGAuth { } else { let access_token = match self.access_token.access_token.clone() { Some(token) => token, - None => return Err(AuthError::Token("CCG token is not set".to_owned())), + None => return Err(AuthError::Generic("CCG token is not set".to_owned())), }; Ok(access_token) } @@ -166,8 +166,14 @@ async fn test_ccg_request() { let config = Config::new(); let client_id = env::var("CLIENT_ID").expect("CLIENT_ID must be set"); let client_secret = env::var("CLIENT_SECRET").expect("CLIENT_SECRET must be set"); - let box_subject_type = SubjectType::Enterprise; - let box_subject_id = env::var("BOX_ENTERPRISE_ID").expect("BOX_ENTERPRISE_ID must be set"); + let env_subject_type = env::var("BOX_SUBJECT_TYPE").expect("BOX_SUBJECT_TYPE must be set"); + let box_subject_type = match env_subject_type.as_str() { + "user" => SubjectType::User, + "enterprise" => SubjectType::Enterprise, + _ => panic!("BOX_SUBJECT_TYPE must be either 'user' or 'enterprise'"), + }; + + let box_subject_id = env::var("BOX_SUBJECT_ID").expect("BOX_SUBJECT_ID must be set"); let mut auth = CCGAuth::new( config, diff --git a/src/auth/auth_developer.rs b/src/auth/auth_developer.rs index 3322441..3132891 100644 --- a/src/auth/auth_developer.rs +++ b/src/auth/auth_developer.rs @@ -36,11 +36,11 @@ impl DevAuth { impl<'a> Auth<'a> for DevAuth { async fn access_token(&mut self) -> Result { if self.is_expired() { - Err(AuthError::Token("Developer token has expired".to_owned())) + Err(AuthError::Generic("Developer token has expired".to_owned())) } else { let access_token = match self.access_token.access_token.clone() { Some(token) => token, - None => return Err(AuthError::Token("Developer token is not set".to_owned())), + None => return Err(AuthError::Generic("Developer token is not set".to_owned())), }; Ok(access_token) } diff --git a/src/auth/auth_errors.rs b/src/auth/auth_errors.rs index 62065de..a85024b 100644 --- a/src/auth/auth_errors.rs +++ b/src/auth/auth_errors.rs @@ -36,7 +36,7 @@ pub enum AuthError { Network(reqwest::Error), Serde(serde_json::Error), Io(std::io::Error), - Token(String), + Generic(String), ResponseError(AuthErrorResponse), } @@ -46,7 +46,7 @@ impl fmt::Display for AuthError { AuthError::Network(e) => ("reqwest", e.to_string()), AuthError::Serde(e) => ("serde", e.to_string()), AuthError::Io(e) => ("IO", e.to_string()), - AuthError::Token(e) => ("Token", e.to_string()), + AuthError::Generic(e) => ("Token", e.to_string()), AuthError::ResponseError(e) => ("API Error", e.to_string()), }; write!(f, "error in {}: {}", module, e) @@ -59,7 +59,7 @@ impl fmt::Debug for AuthError { AuthError::Network(e) => ("reqwest", e.to_string()), AuthError::Serde(e) => ("serde", e.to_string()), AuthError::Io(e) => ("IO", e.to_string()), - AuthError::Token(e) => ("Token", e.to_string()), + AuthError::Generic(e) => ("Token", e.to_string()), AuthError::ResponseError(e) => ("API Error", e.to_string()), }; write!(f, "error in {}: {}", module, e) @@ -86,7 +86,7 @@ impl From for AuthError { impl From for AuthError { fn from(e: String) -> Self { - AuthError::Token(e) + AuthError::Generic(e) } } diff --git a/src/auth/auth_oauth.rs b/src/auth/auth_oauth.rs index 28f0d09..0e08acb 100644 --- a/src/auth/auth_oauth.rs +++ b/src/auth/auth_oauth.rs @@ -11,7 +11,7 @@ use rand::Rng; use serde::Serialize; /// Client Credentials Grant (CCG) authentication -#[derive(Debug, Clone, Serialize, Default)] +#[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct OAuth { pub config: Config, client_id: String, @@ -21,10 +21,18 @@ pub struct OAuth { #[serde(skip)] client: AuthClient, + + #[serde(skip)] + store: Option, } impl OAuth { - pub fn new(config: Config, client_id: String, client_secret: String) -> Self { + pub fn new( + config: Config, + client_id: String, + client_secret: String, + store: Option, + ) -> Self { OAuth { config, client_id, @@ -32,6 +40,7 @@ impl OAuth { access_token: AccessToken::new(), expires_by: Utc::now(), client: AuthClient::default(), + store, } } @@ -41,9 +50,9 @@ impl OAuth { pub fn authorization_url( &self, + redirect_url: Option, scope: Option, // TODO: vector of strings? state: Option, - redirect_url: Option, ) -> Result<(String, String), AuthError> { let url = self.config.oauth2_authorize_url.clone(); let url = url + "?client_id=" + &self.client_id; @@ -70,8 +79,8 @@ impl OAuth { pub async fn request_access_token( &mut self, - client_id: String, - client_secret: String, + // client_id: String, + // client_secret: String, code: String, ) -> Result { let url = self.config.oauth2_api_url.clone() + "/token"; @@ -79,8 +88,8 @@ impl OAuth { let headers = None; // TODO: Add headers to rquest let mut payload = Form::new(); - payload.insert("client_id", &client_id); - payload.insert("client_secret", &client_secret); + payload.insert("client_id", &self.client_id); + payload.insert("client_secret", &self.client_secret); payload.insert("grant_type", "authorization_code"); payload.insert("code", &code); @@ -102,11 +111,26 @@ impl OAuth { let expires_in = access_token.expires_in.unwrap_or_default(); self.expires_by = now + Duration::seconds(expires_in); self.access_token = access_token.clone(); + match self.store { + Some(store) => { + let json_access_token = self.to_json().await?; + store(json_access_token); + } + None => return Ok(access_token), + }; Ok(access_token) } - pub fn set_access_token(&mut self, access_token: AccessToken) { + pub fn set_access_token(&mut self, access_token: AccessToken) -> Result<(), AuthError> { self.access_token = access_token; + match self.store { + Some(store) => { + let json_access_token = serde_json::to_string(&self)?; + store(json_access_token); + } + None => return Ok(()), + }; + Ok(()) } async fn refresh_access_token(&mut self) -> Result { @@ -141,6 +165,13 @@ impl OAuth { let expires_in = access_token.expires_in.unwrap_or_default(); self.expires_by = now + Duration::seconds(expires_in); self.access_token = access_token.clone(); + match self.store { + Some(store) => { + let json_access_token = self.to_json().await?; + store(json_access_token); + } + None => return Ok(access_token), + }; Ok(access_token) } } @@ -156,7 +187,7 @@ impl<'a> Auth<'a> for OAuth { } else { let access_token = match self.access_token.access_token.clone() { Some(token) => token, - None => return Err(AuthError::Token("CCG token is not set".to_owned())), + None => return Err(AuthError::Generic("CCG token is not set".to_owned())), }; Ok(access_token) } @@ -192,6 +223,12 @@ fn generate_state(length: u8) -> String { } #[cfg(test)] +fn store(json_access_token: String) { + // println!("{}", json_access_token); + assert!(json_access_token.len() > 0); + assert!(json_access_token.contains("ACCESS_TOKEN")); + assert!(json_access_token.contains("REFRESH_TOKEN")); +} #[test] fn test_generate_state() { let state = generate_state(16); @@ -208,7 +245,12 @@ fn test_urlencode() { #[test] fn test_authorization_url_default() { let config = Config::new(); - let auth = OAuth::new(config, "client_id".to_owned(), "client_secret".to_owned()); + let auth = OAuth::new( + config, + "client_id".to_owned(), + "client_secret".to_owned(), + None, + ); let (auth_url, state) = auth.authorization_url(None, None, None).unwrap_or_default(); @@ -226,10 +268,15 @@ fn test_authorization_url_default() { #[test] fn test_authorization_url_state() { let config = Config::new(); - let auth = OAuth::new(config, "client_id".to_owned(), "client_secret".to_owned()); + let auth = OAuth::new( + config, + "client_id".to_owned(), + "client_secret".to_owned(), + None, + ); let (auth_url, state) = auth - .authorization_url(None, Some("ABCDEF".to_string()), None) + .authorization_url(None, None, Some("ABCDEF".to_string())) .unwrap_or_default(); // check if auth_url contains all required params @@ -246,10 +293,15 @@ fn test_authorization_url_state() { #[test] fn test_authorization_url_redirect() { let config = Config::new(); - let auth = OAuth::new(config, "client_id".to_owned(), "client_secret".to_owned()); + let auth = OAuth::new( + config, + "client_id".to_owned(), + "client_secret".to_owned(), + None, + ); let (auth_url, state) = auth - .authorization_url(None, None, Some("https://example.com".to_string())) + .authorization_url(Some("https://example.com".to_string()), None, None) .unwrap_or_default(); let encoded_redirect = "redirect_uri=".to_string() + &urlencode("https://example.com"); @@ -267,10 +319,15 @@ fn test_authorization_url_redirect() { #[test] fn test_authorization_url_scope() { let config = Config::new(); - let auth = OAuth::new(config, "client_id".to_owned(), "client_secret".to_owned()); + let auth = OAuth::new( + config, + "client_id".to_owned(), + "client_secret".to_owned(), + None, + ); let (auth_url, state) = auth - .authorization_url(Some("admin_readwrite".to_string()), None, None) + .authorization_url(None, Some("admin_readwrite".to_string()), None) .unwrap_or_default(); // check if auth_url contains all required params @@ -288,7 +345,7 @@ fn test_oauth_new() { let config = Config::new(); let client_id = "client_id".to_owned(); let client_secret = "client_secret".to_owned(); - let auth = OAuth::new(config, client_id, client_secret); + let auth = OAuth::new(config, client_id, client_secret, None); assert_eq!(auth.client_id, "client_id".to_owned()); assert_eq!(auth.client_secret, "client_secret".to_owned()); @@ -297,13 +354,42 @@ fn test_oauth_new() { #[test] fn test_oauth_set_access_token() { let config = Config::new(); - let mut auth = OAuth::new(config, "client_id".to_owned(), "client_secret".to_owned()); + let mut auth = OAuth::new( + config, + "client_id".to_owned(), + "client_secret".to_owned(), + None, + ); let access_token = AccessToken { access_token: Some("access_token".to_owned()), refresh_token: Some("refresh_token".to_owned()), ..Default::default() }; - auth.set_access_token(access_token.clone()); + match auth.set_access_token(access_token.clone()) { + Ok(_) => assert_eq!(auth.access_token, access_token), + Err(_) => panic!("Error setting access token"), + }; +} - assert_eq!(auth.access_token, access_token); +#[test] +fn test_store() { + let config = Config::new(); + let mut auth = OAuth::new( + config, + "client_id".to_owned(), + "client_secret".to_owned(), + Some(store), + ); + let fake_access_token = AccessToken { + access_token: Some("ACCESS_TOKEN".to_string()), + expires_in: Some(3333), + token_type: Some(super::access_token::TokenType::Bearer), + restricted_to: None, + refresh_token: Some("REFRESH_TOKEN".to_string()), + issued_token_type: None, + }; + match auth.set_access_token(fake_access_token) { + Ok(_) => {} + Err(_) => panic!("Error setting access token"), + }; } diff --git a/src/client/box_client.rs b/src/client/box_client.rs index ed1baed..f2ba68e 100644 --- a/src/client/box_client.rs +++ b/src/client/box_client.rs @@ -45,8 +45,14 @@ async fn test_create_client_ccg() { let config = crate::config::Config::new(); let client_id = std::env::var("CLIENT_ID").expect("CLIENT_ID must be set"); let client_secret = std::env::var("CLIENT_SECRET").expect("CLIENT_SECRET must be set"); - let box_subject_type = SubjectType::Enterprise; - let box_subject_id = std::env::var("BOX_ENTERPRISE_ID").expect("BOX_ENTERPRISE_ID must be set"); + let env_subject_type = std::env::var("BOX_SUBJECT_TYPE").expect("BOX_SUBJECT_TYPE must be set"); + let box_subject_type = match env_subject_type.as_str() { + "user" => SubjectType::User, + "enterprise" => SubjectType::Enterprise, + _ => panic!("BOX_SUBJECT_TYPE must be either 'user' or 'enterprise'"), + }; + + let box_subject_id = std::env::var("BOX_SUBJECT_ID").expect("BOX_SUBJECT_ID must be set"); let auth = CCGAuth::new( config, diff --git a/src/config.rs b/src/config.rs index 4e4252b..2558e8e 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,6 +1,6 @@ //! Configuration for the Box API client. /// Configuration structure for the Box API. -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct Config { base_api_url: String, upload_url: String, diff --git a/src/lib.rs b/src/lib.rs index 5b386d9..deb8792 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -14,6 +14,7 @@ pub use crate::auth::auth_errors::AuthError; pub use crate::auth::auth_ccg::CCGAuth; pub use crate::auth::auth_developer::DevAuth; +pub use crate::auth::auth_oauth::OAuth; pub use crate::auth::auth_ccg::SubjectType; pub use crate::config::Config; diff --git a/tests/common/box_client.rs b/tests/common/box_client.rs index 8767614..e542f4f 100644 --- a/tests/common/box_client.rs +++ b/tests/common/box_client.rs @@ -7,8 +7,14 @@ pub fn get_box_client() -> Result, AuthError> { let client_id = env::var("CLIENT_ID").expect("CLIENT_ID must be set"); let client_secret = env::var("CLIENT_SECRET").expect("CLIENT_SECRET must be set"); - let box_subject_type = SubjectType::Enterprise; - let box_subject_id = env::var("BOX_ENTERPRISE_ID").expect("BOX_ENTERPRISE_ID must be set"); + let env_subject_type = env::var("BOX_SUBJECT_TYPE").expect("BOX_SUBJECT_TYPE must be set"); + let box_subject_type = match env_subject_type.as_str() { + "user" => SubjectType::User, + "enterprise" => SubjectType::Enterprise, + _ => panic!("BOX_SUBJECT_TYPE must be either 'user' or 'enterprise'"), + }; + + let box_subject_id = env::var("BOX_SUBJECT_ID").expect("BOX_SUBJECT_ID must be set"); let config = Config::new(); let auth = CCGAuth::new(