diff --git a/src/device.rs b/src/device.rs index 6888091..e80ad01 100644 --- a/src/device.rs +++ b/src/device.rs @@ -191,158 +191,3 @@ impl DeviceFlow { Token::from_json(&body) } } - -#[cfg(test)] -mod tests { - use hyper_rustls::HttpsConnector; - use std::pin::Pin; - - use super::*; - - #[tokio::test] - async fn test_device_end2end() { - #[derive(Clone)] - struct FD; - impl DeviceFlowDelegate for FD { - fn present_user_code<'a>( - &'a self, - pi: &'a DeviceAuthResponse, - ) -> Pin + 'a + Send>> { - assert_eq!("https://example.com/verify", pi.verification_uri); - Box::pin(futures::future::ready(())) - } - } - - let server_url = mockito::server_url(); - let app_secret: ApplicationSecret = crate::parse_json!({ - "client_id": "902216714886-k2v9uei3p1dk6h686jbsn9mo96tnbvto.apps.googleusercontent.com", - "project_id": "yup-test-243420", - "auth_uri": "https://accounts.google.com/o/oauth2/auth", - "token_uri": format!("{}/token", server_url), - "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", - "client_secret": "iuMPN6Ne1PD7cos29Tk9rlqH", - "redirect_uris": ["urn:ietf:wg:oauth:2.0:oob","http://localhost"], - }); - let device_code_url = format!("{}/code", server_url); - - let https = HttpsConnector::new(); - let client = hyper::Client::builder() - .keep_alive(false) - .build::<_, hyper::Body>(https); - - let flow = DeviceFlow { - app_secret, - device_code_url: device_code_url.into(), - flow_delegate: Box::new(FD), - grant_type: GOOGLE_GRANT_TYPE.into(), - }; - - // Successful path - { - let code_response = serde_json::json!({ - "device_code": "devicecode", - "user_code": "usercode", - "verification_url": "https://example.com/verify", - "expires_in": 1234567, - "interval": 1 - }); - let _m = mockito::mock("POST", "/code") - .match_body(mockito::Matcher::Regex( - ".*client_id=902216714886.*".to_string(), - )) - .with_status(200) - .with_body(code_response.to_string()) - .create(); - let token_response = serde_json::json!({ - "access_token": "accesstoken", - "refresh_token": "refreshtoken", - "token_type": "Bearer", - "expires_in": 1234567 - }); - let _m = mockito::mock("POST", "/token") - .match_body(mockito::Matcher::Regex( - ".*client_secret=iuMPN6Ne1PD7cos29Tk9rlqH&code=devicecode.*".to_string(), - )) - .with_status(200) - .with_body(token_response.to_string()) - .create(); - - let token = flow - .token(&client, &["https://www.googleapis.com/scope/1"]) - .await - .expect("token failed"); - assert_eq!("accesstoken", token.access_token); - _m.assert(); - } - - // Code is not delivered. - { - let code_response = serde_json::json!({ - "error": "invalid_client_id", - "error_description": "description" - }); - let _m = mockito::mock("POST", "/code") - .match_body(mockito::Matcher::Regex( - ".*client_id=902216714886.*".to_string(), - )) - .with_status(400) - .with_body(code_response.to_string()) - .create(); - let token_response = serde_json::json!({ - "access_token": "accesstoken", - "refresh_token": "refreshtoken", - "token_type": "Bearer", - "expires_in": 1234567 - }); - let _m = mockito::mock("POST", "/token") - .match_body(mockito::Matcher::Regex( - ".*client_secret=iuMPN6Ne1PD7cos29Tk9rlqH&code=devicecode.*".to_string(), - )) - .with_status(200) - .with_body(token_response.to_string()) - .expect(0) // Never called! - .create(); - - let res = flow - .token(&client, &["https://www.googleapis.com/scope/1"]) - .await; - assert!(res.is_err()); - assert!(format!("{}", res.unwrap_err()).contains("invalid_client_id")); - _m.assert(); - } - - // Token is not delivered. - { - let code_response = serde_json::json!({ - "device_code": "devicecode", - "user_code": "usercode", - "verification_url": "https://example.com/verify", - "expires_in": 1234567, - "interval": 1 - }); - let _m = mockito::mock("POST", "/code") - .match_body(mockito::Matcher::Regex( - ".*client_id=902216714886.*".to_string(), - )) - .with_status(200) - .with_body(code_response.to_string()) - .create(); - let token_response = serde_json::json!({"error": "access_denied"}); - let _m = mockito::mock("POST", "/token") - .match_body(mockito::Matcher::Regex( - ".*client_secret=iuMPN6Ne1PD7cos29Tk9rlqH&code=devicecode.*".to_string(), - )) - .with_status(400) - .with_body(token_response.to_string()) - .expect(1) - .create(); - - let res = flow - .token(&client, &["https://www.googleapis.com/scope/1"]) - .await; - assert!(res.is_err()); - assert!(format!("{}", res.unwrap_err()).contains("access_denied")); - _m.assert(); - } - } -} diff --git a/src/error.rs b/src/error.rs index d83a6aa..63cf636 100644 --- a/src/error.rs +++ b/src/error.rs @@ -10,7 +10,7 @@ use serde::Deserialize; /// Error returned by the authorization server. /// https://tools.ietf.org/html/rfc6749#section-5.2 /// https://tools.ietf.org/html/rfc8628#section-3.5 -#[derive(Deserialize, Debug)] +#[derive(Deserialize, Debug, PartialEq, Eq)] pub struct AuthError { /// Error code from the server. pub error: AuthErrorCode, @@ -36,7 +36,6 @@ impl StdError for AuthError {} /// The error code returned by the authorization server. #[derive(Debug, Clone, Eq, PartialEq)] - pub enum AuthErrorCode { /// invalid_request InvalidRequest, diff --git a/src/helper.rs b/src/helper.rs index 81200f6..b143db7 100644 --- a/src/helper.rs +++ b/src/helper.rs @@ -70,13 +70,3 @@ where debug_assert_eq!(size, result.len()); result } - -#[cfg(test)] -#[macro_export] -/// Utility function for parsing json. Useful in unit tests. Simply wrap the -/// json! macro in a from_value to deserialize the contents to arbitrary structs. -macro_rules! parse_json { - ($($json:tt)+) => { - ::serde_json::from_value(::serde_json::json!($($json)+)).expect("failed to deserialize") - } -} diff --git a/src/installed.rs b/src/installed.rs index 22034d8..f2ae102 100644 --- a/src/installed.rs +++ b/src/installed.rs @@ -354,170 +354,8 @@ mod installed_flow_server { #[cfg(test)] mod tests { - use std::str::FromStr; - - use hyper::client::connect::HttpConnector; - use hyper::Uri; - use hyper_rustls::HttpsConnector; - use mockito::mock; - use super::*; - use crate::authenticator_delegate::InstalledFlowDelegate; - - #[tokio::test] - async fn test_end2end() { - #[derive(Clone)] - struct FD( - String, - hyper::Client, hyper::Body>, - ); - impl InstalledFlowDelegate for FD { - /// Depending on need_code, return the pre-set code or send the code to the server at - /// the redirect_uri given in the url. - fn present_user_url<'a>( - &'a self, - url: &'a str, - need_code: bool, - ) -> Pin> + Send + 'a>> { - Box::pin(async move { - if need_code { - Ok(self.0.clone()) - } else { - // Parse presented url to obtain redirect_uri with location of local - // code-accepting server. - let uri = Uri::from_str(url.as_ref()).unwrap(); - let query = uri.query().unwrap(); - let parsed = form_urlencoded::parse(query.as_bytes()).into_owned(); - let mut rduri = None; - for (k, v) in parsed { - if k == "redirect_uri" { - rduri = Some(v); - break; - } - } - if rduri.is_none() { - return Err("no redirect_uri!".into()); - } - let mut rduri = rduri.unwrap(); - rduri.push_str(&format!("?code={}", self.0)); - let rduri = Uri::from_str(rduri.as_ref()).unwrap(); - // Hit server. - self.1 - .get(rduri) - .await - .map_err(|e| e.to_string()) - .map(|_| "".to_string()) - } - }) - } - } - - let server_url = mockito::server_url(); - let app_secret: ApplicationSecret = crate::parse_json!({ - "client_id": "902216714886-k2v9uei3p1dk6h686jbsn9mo96tnbvto.apps.googleusercontent.com", - "project_id": "yup-test-243420", - "auth_uri": "https://accounts.google.com/o/oauth2/auth", - "token_uri": format!("{}/token", server_url), - "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", - "client_secret": "iuMPN6Ne1PD7cos29Tk9rlqH", - "redirect_uris": ["urn:ietf:wg:oauth:2.0:oob","http://localhost"] - }); - - let https = HttpsConnector::new(); - let client = hyper::Client::builder() - .keep_alive(false) - .build::<_, hyper::Body>(https); - - let fd = FD("authorizationcode".to_string(), client.clone()); - let inf = InstalledFlow { - app_secret: app_secret.clone(), - method: InstalledFlowReturnMethod::Interactive, - flow_delegate: Box::new(fd), - }; - - // Successful path. - { - let _m = mock("POST", "/token") - .match_body(mockito::Matcher::Regex( - ".*code=authorizationcode.*client_id=9022167.*".to_string(), - )) - .with_body( - serde_json::json!({ - "access_token": "accesstoken", - "refresh_token": "refreshtoken", - "token_type": "Bearer", - "expires_in": 12345678 - }) - .to_string(), - ) - .expect(1) - .create(); - - let tok = inf - .token(&client, &["https://googleapis.com/some/scope"]) - .await - .expect("failed to get token"); - assert_eq!("accesstoken", tok.access_token); - assert_eq!("refreshtoken", tok.refresh_token.unwrap()); - assert_eq!("Bearer", tok.token_type); - _m.assert(); - } - - // Successful path with HTTP redirect. - { - let inf = InstalledFlow { - app_secret: app_secret.clone(), - method: InstalledFlowReturnMethod::HTTPRedirect, - flow_delegate: Box::new(FD( - "authorizationcodefromlocalserver".to_string(), - client.clone(), - )), - }; - let _m = mock("POST", "/token") - .match_body(mockito::Matcher::Regex( - ".*code=authorizationcodefromlocalserver.*client_id=9022167.*".to_string(), - )) - .with_body( - serde_json::json!({ - "access_token": "accesstoken", - "refresh_token": "refreshtoken", - "token_type": "Bearer", - "expires_in": 12345678 - }) - .to_string(), - ) - .expect(1) - .create(); - - let tok = inf - .token(&client, &["https://googleapis.com/some/scope"]) - .await - .expect("failed to get token"); - assert_eq!("accesstoken", tok.access_token); - assert_eq!("refreshtoken", tok.refresh_token.unwrap()); - assert_eq!("Bearer", tok.token_type); - _m.assert(); - } - - // Error from server. - { - let _m = mock("POST", "/token") - .match_body(mockito::Matcher::Regex( - ".*code=authorizationcode.*client_id=9022167.*".to_string(), - )) - .with_status(400) - .with_body(serde_json::json!({"error": "invalid_code"}).to_string()) - .expect(1) - .create(); - - let tokr = inf - .token(&client, &["https://googleapis.com/some/scope"]) - .await; - assert!(tokr.is_err()); - assert!(format!("{}", tokr.unwrap_err()).contains("invalid_code")); - _m.assert(); - } - } + use hyper::Uri; #[test] fn test_request_url_builder() { diff --git a/src/refresh.rs b/src/refresh.rs index 910a38b..1ed72e8 100644 --- a/src/refresh.rs +++ b/src/refresh.rs @@ -57,64 +57,3 @@ impl RefreshFlow { Ok(token) } } - -#[cfg(test)] -mod tests { - use super::*; - use crate::helper; - - use hyper_rustls::HttpsConnector; - - #[tokio::test] - async fn test_refresh_end2end() { - let server_url = mockito::server_url(); - - let app_secret = r#"{"installed":{"client_id":"902216714886-k2v9uei3p1dk6h686jbsn9mo96tnbvto.apps.googleusercontent.com","project_id":"yup-test-243420","auth_uri":"https://accounts.google.com/o/oauth2/auth","token_uri":"https://oauth2.googleapis.com/token","auth_provider_x509_cert_url":"https://www.googleapis.com/oauth2/v1/certs","client_secret":"iuMPN6Ne1PD7cos29Tk9rlqH","redirect_uris":["urn:ietf:wg:oauth:2.0:oob","http://localhost"]}}"#; - let mut app_secret = helper::parse_application_secret(app_secret).unwrap(); - app_secret.token_uri = format!("{}/token", server_url); - let refresh_token = "my-refresh-token"; - - let https = HttpsConnector::new(); - let client = hyper::Client::builder() - .keep_alive(false) - .build::<_, hyper::Body>(https); - - // Success - { - let _m = mockito::mock("POST", "/token") - .match_body( - mockito::Matcher::Regex(".*client_id=902216714886-k2v9uei3p1dk6h686jbsn9mo96tnbvto.apps.googleusercontent.com.*refresh_token=my-refresh-token.*".to_string())) - .with_status(200) - .with_body(r#"{"access_token": "new-access-token", "token_type": "Bearer", "expires_in": 1234567}"#) - .create(); - let token = RefreshFlow::refresh_token(&client, &app_secret, refresh_token) - .await - .expect("token failed"); - assert_eq!("new-access-token", token.access_token); - assert_eq!("Bearer", token.token_type); - _m.assert(); - } - - // Refresh error. - { - let _m = mockito::mock("POST", "/token") - .match_body( - mockito::Matcher::Regex(".*client_id=902216714886-k2v9uei3p1dk6h686jbsn9mo96tnbvto.apps.googleusercontent.com.*refresh_token=my-refresh-token.*".to_string())) - .with_status(400) - .with_body(r#"{"error": "invalid_request"}"#) - .create(); - - let rr = RefreshFlow::refresh_token(&client, &app_secret, refresh_token).await; - match rr { - Err(Error::AuthError(auth_error)) => { - assert_eq!( - auth_error.error, - crate::error::AuthErrorCode::InvalidRequest - ); - } - _ => panic!(format!("unexpected RefreshResult {:?}", rr)), - } - _m.assert(); - } - } -} diff --git a/src/service_account.rs b/src/service_account.rs index 168edc2..1eeeb47 100644 --- a/src/service_account.rs +++ b/src/service_account.rs @@ -210,83 +210,8 @@ impl ServiceAccountFlow { mod tests { use super::*; use crate::helper::read_service_account_key; - use crate::parse_json; - use chrono::Utc; use hyper_rustls::HttpsConnector; - use mockito::mock; - - #[tokio::test] - async fn test_mocked_http() { - env_logger::try_init().unwrap(); - let https = HttpsConnector::new(); - let client = hyper::Client::builder() - .keep_alive(false) - .build::<_, hyper::Body>(https); - let server_url = &mockito::server_url(); - let key: ServiceAccountKey = parse_json!({ - "type": "service_account", - "project_id": "yup-test-243420", - "private_key_id": "26de294916614a5ebdf7a065307ed3ea9941902b", - "private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQDemmylrvp1KcOn\n9yTAVVKPpnpYznvBvcAU8Qjwr2fSKylpn7FQI54wCk5VJVom0jHpAmhxDmNiP8yv\nHaqsef+87Oc0n1yZ71/IbeRcHZc2OBB33/LCFqf272kThyJo3qspEqhuAw0e8neg\nLQb4jpm9PsqR8IjOoAtXQSu3j0zkXemMYFy93PWHjVpPEUX16NGfsWH7oxspBHOk\n9JPGJL8VJdbiAoDSDgF0y9RjJY5I52UeHNhMsAkTYs6mIG4kKXt2+T9tAyHw8aho\nwmuytQAfydTflTfTG8abRtliF3nil2taAc5VB07dP1b4dVYy/9r6M8Z0z4XM7aP+\nNdn2TKm3AgMBAAECggEAWi54nqTlXcr2M5l535uRb5Xz0f+Q/pv3ceR2iT+ekXQf\n+mUSShOr9e1u76rKu5iDVNE/a7H3DGopa7ZamzZvp2PYhSacttZV2RbAIZtxU6th\n7JajPAM+t9klGh6wj4jKEcE30B3XVnbHhPJI9TCcUyFZoscuPXt0LLy/z8Uz0v4B\nd5JARwyxDMb53VXwukQ8nNY2jP7WtUig6zwE5lWBPFMbi8GwGkeGZOruAK5sPPwY\nGBAlfofKANI7xKx9UXhRwisB4+/XI1L0Q6xJySv9P+IAhDUI6z6kxR+WkyT/YpG3\nX9gSZJc7qEaxTIuDjtep9GTaoEqiGntjaFBRKoe+VQKBgQDzM1+Ii+REQqrGlUJo\nx7KiVNAIY/zggu866VyziU6h5wjpsoW+2Npv6Dv7nWvsvFodrwe50Y3IzKtquIal\nVd8aa50E72JNImtK/o5Nx6xK0VySjHX6cyKENxHRDnBmNfbALRM+vbD9zMD0lz2q\nmns/RwRGq3/98EqxP+nHgHSr9QKBgQDqUYsFAAfvfT4I75Glc9svRv8IsaemOm07\nW1LCwPnj1MWOhsTxpNF23YmCBupZGZPSBFQobgmHVjQ3AIo6I2ioV6A+G2Xq/JCF\nmzfbvZfqtbbd+nVgF9Jr1Ic5T4thQhAvDHGUN77BpjEqZCQLAnUWJx9x7e2xvuBl\n1A6XDwH/ewKBgQDv4hVyNyIR3nxaYjFd7tQZYHTOQenVffEAd9wzTtVbxuo4sRlR\nNM7JIRXBSvaATQzKSLHjLHqgvJi8LITLIlds1QbNLl4U3UVddJbiy3f7WGTqPFfG\nkLhUF4mgXpCpkMLxrcRU14Bz5vnQiDmQRM4ajS7/kfwue00BZpxuZxst3QKBgQCI\nRI3FhaQXyc0m4zPfdYYVc4NjqfVmfXoC1/REYHey4I1XetbT9Nb/+ow6ew0UbgSC\nUZQjwwJ1m1NYXU8FyovVwsfk9ogJ5YGiwYb1msfbbnv/keVq0c/Ed9+AG9th30qM\nIf93hAfClITpMz2mzXIMRQpLdmQSR4A2l+E4RjkSOwKBgQCB78AyIdIHSkDAnCxz\nupJjhxEhtQ88uoADxRoEga7H/2OFmmPsqfytU4+TWIdal4K+nBCBWRvAX1cU47vH\nJOlSOZI0gRKe0O4bRBQc8GXJn/ubhYSxI02IgkdGrIKpOb5GG10m85ZvqsXw3bKn\nRVHMD0ObF5iORjZUqD0yRitAdg==\n-----END PRIVATE KEY-----\n", - "client_email": "yup-test-sa-1@yup-test-243420.iam.gserviceaccount.com", - "client_id": "102851967901799660408", - "auth_uri": "https://accounts.google.com/o/oauth2/auth", - "token_uri": format!("{}/token", server_url), - "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", - "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/yup-test-sa-1%40yup-test-243420.iam.gserviceaccount.com" - }); - - let json_response = serde_json::json!({ - "access_token": "ya29.c.ElouBywiys0LyNaZoLPJcp1Fdi2KjFMxzvYKLXkTdvM-rDfqKlvEq6PiMhGoGHx97t5FAvz3eb_ahdwlBjSStxHtDVQB4ZPRJQ_EOi-iS7PnayahU2S9Jp8S6rk", - "expires_in": 3600, - "token_type": "Bearer" - }); - let bad_json_response = serde_json::json!({ - "error": "access_denied", - }); - - // Successful path. - { - let _m = mock("POST", "/token") - .with_status(200) - .with_header("content-type", "text/json") - .with_body(json_response.to_string()) - .expect(1) - .create(); - let acc = ServiceAccountFlow::new(ServiceAccountFlowOpts { - key: key.clone(), - subject: None, - }) - .unwrap(); - let tok = acc - .token(&client, &["https://www.googleapis.com/auth/pubsub"]) - .await - .expect("token failed"); - assert!(tok.access_token.contains("ya29.c.ElouBywiys0Ly")); - assert!(Utc::now() + chrono::Duration::seconds(3600) >= tok.expires_at.unwrap()); - _m.assert(); - } - // Malformed response. - { - let _m = mock("POST", "/token") - .with_status(200) - .with_header("content-type", "text/json") - .with_body(bad_json_response.to_string()) - .create(); - let acc = ServiceAccountFlow::new(ServiceAccountFlowOpts { - key: key.clone(), - subject: None, - }) - .unwrap(); - let result = acc - .token(&client, &["https://www.googleapis.com/auth/pubsub"]) - .await; - assert!(result.is_err()); - _m.assert(); - } - } - // Valid but deactivated key. const TEST_PRIVATE_KEY_PATH: &'static str = "examples/Sanguine-69411a0c0eea.json"; diff --git a/src/storage.rs b/src/storage.rs index d838ba1..7d38575 100644 --- a/src/storage.rs +++ b/src/storage.rs @@ -381,22 +381,25 @@ mod tests { token_type: "Bearer".to_owned(), expires_at: None, }; - let tempdir = tempfile::tempdir().unwrap(); - let storage = DiskStorage::new(tempdir.path().join("tokenstorage.json")) - .await - .unwrap(); let scope_set = ScopeSet::from(&["myscope"]); - assert!(storage.get(scope_set).is_none()); - storage - .set(scope_set, new_token("my_access_token")) - .await - .unwrap(); - assert_eq!(storage.get(scope_set), Some(new_token("my_access_token"))); - - // Create a new DiskStorage instance and verify the tokens were read from disk correctly. - let storage = DiskStorage::new(tempdir.path().join("tokenstorage.json")) - .await - .unwrap(); - assert_eq!(storage.get(scope_set), Some(new_token("my_access_token"))); + let tempdir = tempfile::tempdir().unwrap(); + { + let storage = DiskStorage::new(tempdir.path().join("tokenstorage.json")) + .await + .unwrap(); + assert!(storage.get(scope_set).is_none()); + storage + .set(scope_set, new_token("my_access_token")) + .await + .unwrap(); + assert_eq!(storage.get(scope_set), Some(new_token("my_access_token"))); + } + { + // Create a new DiskStorage instance and verify the tokens were read from disk correctly. + let storage = DiskStorage::new(tempdir.path().join("tokenstorage.json")) + .await + .unwrap(); + assert_eq!(storage.get(scope_set), Some(new_token("my_access_token"))); + } } } diff --git a/tests/tests.rs b/tests/tests.rs new file mode 100644 index 0000000..40fe487 --- /dev/null +++ b/tests/tests.rs @@ -0,0 +1,574 @@ +use yup_oauth2::{ + authenticator::Authenticator, + authenticator_delegate::{DeviceAuthResponse, DeviceFlowDelegate, InstalledFlowDelegate}, + error::{AuthError, AuthErrorCode}, + ApplicationSecret, DeviceFlowAuthenticator, Error, InstalledFlowAuthenticator, + InstalledFlowReturnMethod, ServiceAccountAuthenticator, ServiceAccountKey, +}; + +use std::future::Future; +use std::path::PathBuf; +use std::pin::Pin; + +use hyper::client::connect::HttpConnector; +use hyper::Uri; +use hyper_rustls::HttpsConnector; +use url::form_urlencoded; + +/// Utility function for parsing json. Useful in unit tests. Simply wrap the +/// json! macro in a from_value to deserialize the contents to arbitrary structs. +macro_rules! parse_json { + ($($json:tt)+) => { + ::serde_json::from_value(::serde_json::json!($($json)+)).expect("failed to deserialize") + } +} + +async fn create_device_flow_auth() -> Authenticator> { + let server_url = mockito::server_url(); + let app_secret: ApplicationSecret = parse_json!({ + "client_id": "902216714886-k2v9uei3p1dk6h686jbsn9mo96tnbvto.apps.googleusercontent.com", + "project_id": "yup-test-243420", + "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "token_uri": format!("{}/token", server_url), + "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", + "client_secret": "iuMPN6Ne1PD7cos29Tk9rlqH", + "redirect_uris": ["urn:ietf:wg:oauth:2.0:oob","http://localhost"], + }); + struct FD; + impl DeviceFlowDelegate for FD { + fn present_user_code<'a>( + &'a self, + pi: &'a DeviceAuthResponse, + ) -> Pin + 'a + Send>> { + assert_eq!("https://example.com/verify", pi.verification_uri); + Box::pin(futures::future::ready(())) + } + } + + DeviceFlowAuthenticator::builder(app_secret) + .flow_delegate(Box::new(FD)) + .device_code_url(format!("{}/code", server_url)) + .build() + .await + .unwrap() +} + +#[tokio::test] +async fn test_device_success() { + let auth = create_device_flow_auth().await; + let code_response = serde_json::json!({ + "device_code": "devicecode", + "user_code": "usercode", + "verification_url": "https://example.com/verify", + "expires_in": 1234567, + "interval": 1 + }); + let _m = mockito::mock("POST", "/code") + .match_body(mockito::Matcher::Regex( + ".*client_id=902216714886.*".to_string(), + )) + .with_status(200) + .with_body(code_response.to_string()) + .create(); + let token_response = serde_json::json!({ + "access_token": "accesstoken", + "refresh_token": "refreshtoken", + "token_type": "Bearer", + "expires_in": 1234567 + }); + let _m = mockito::mock("POST", "/token") + .match_body(mockito::Matcher::Regex( + ".*client_secret=iuMPN6Ne1PD7cos29Tk9rlqH&code=devicecode.*".to_string(), + )) + .with_status(200) + .with_body(token_response.to_string()) + .create(); + + let token = auth + .token(&["https://www.googleapis.com/scope/1"]) + .await + .expect("token failed"); + assert_eq!("accesstoken", token.access_token); + _m.assert(); +} + +#[tokio::test] +async fn test_device_no_code() { + let auth = create_device_flow_auth().await; + let code_response = serde_json::json!({ + "error": "invalid_client_id", + "error_description": "description" + }); + let _m = mockito::mock("POST", "/code") + .match_body(mockito::Matcher::Regex( + ".*client_id=902216714886.*".to_string(), + )) + .with_status(400) + .with_body(code_response.to_string()) + .create(); + let token_response = serde_json::json!({ + "access_token": "accesstoken", + "refresh_token": "refreshtoken", + "token_type": "Bearer", + "expires_in": 1234567 + }); + let _m = mockito::mock("POST", "/token") + .match_body(mockito::Matcher::Regex( + ".*client_secret=iuMPN6Ne1PD7cos29Tk9rlqH&code=devicecode.*".to_string(), + )) + .with_status(200) + .with_body(token_response.to_string()) + .expect(0) // Never called! + .create(); + + let res = auth.token(&["https://www.googleapis.com/scope/1"]).await; + assert!(res.is_err()); + assert!(format!("{}", res.unwrap_err()).contains("invalid_client_id")); + _m.assert(); +} + +#[tokio::test] +async fn test_device_no_token() { + let auth = create_device_flow_auth().await; + let code_response = serde_json::json!({ + "device_code": "devicecode", + "user_code": "usercode", + "verification_url": "https://example.com/verify", + "expires_in": 1234567, + "interval": 1 + }); + let _m = mockito::mock("POST", "/code") + .match_body(mockito::Matcher::Regex( + ".*client_id=902216714886.*".to_string(), + )) + .with_status(200) + .with_body(code_response.to_string()) + .create(); + let token_response = serde_json::json!({"error": "access_denied"}); + let _m = mockito::mock("POST", "/token") + .match_body(mockito::Matcher::Regex( + ".*client_secret=iuMPN6Ne1PD7cos29Tk9rlqH&code=devicecode.*".to_string(), + )) + .with_status(400) + .with_body(token_response.to_string()) + .expect(1) + .create(); + + let res = auth.token(&["https://www.googleapis.com/scope/1"]).await; + assert!(res.is_err()); + assert!(format!("{}", res.unwrap_err()).contains("access_denied")); + _m.assert(); +} + +async fn create_installed_flow_auth( + method: InstalledFlowReturnMethod, + filename: Option, +) -> Authenticator> { + let server_url = mockito::server_url(); + let app_secret: ApplicationSecret = parse_json!({ + "client_id": "902216714886-k2v9uei3p1dk6h686jbsn9mo96tnbvto.apps.googleusercontent.com", + "project_id": "yup-test-243420", + "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "token_uri": format!("{}/token", server_url), + "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", + "client_secret": "iuMPN6Ne1PD7cos29Tk9rlqH", + "redirect_uris": ["urn:ietf:wg:oauth:2.0:oob","http://localhost"], + }); + struct FD(hyper::Client>); + impl InstalledFlowDelegate for FD { + /// Depending on need_code, return the pre-set code or send the code to the server at + /// the redirect_uri given in the url. + fn present_user_url<'a>( + &'a self, + url: &'a str, + need_code: bool, + ) -> Pin> + Send + 'a>> { + use std::str::FromStr; + Box::pin(async move { + if need_code { + Ok("authorizationcode".to_owned()) + } else { + // Parse presented url to obtain redirect_uri with location of local + // code-accepting server. + let uri = Uri::from_str(url.as_ref()).unwrap(); + let query = uri.query().unwrap(); + let parsed = form_urlencoded::parse(query.as_bytes()).into_owned(); + let mut rduri = None; + for (k, v) in parsed { + if k == "redirect_uri" { + rduri = Some(v); + break; + } + } + if rduri.is_none() { + return Err("no redirect_uri!".into()); + } + let mut rduri = rduri.unwrap(); + rduri.push_str("?code=authorizationcode"); + let rduri = Uri::from_str(rduri.as_ref()).unwrap(); + // Hit server. + self.0 + .get(rduri) + .await + .map_err(|e| e.to_string()) + .map(|_| "".to_string()) + } + }) + } + } + + let mut builder = InstalledFlowAuthenticator::builder(app_secret, method).flow_delegate( + Box::new(FD(hyper::Client::builder().build(HttpsConnector::new()))), + ); + + builder = if let Some(filename) = filename { + builder.persist_tokens_to_disk(filename) + } else { + builder + }; + + builder.build().await.unwrap() +} + +#[tokio::test] +async fn test_installed_interactive_success() { + let auth = create_installed_flow_auth(InstalledFlowReturnMethod::Interactive, None).await; + let _m = mockito::mock("POST", "/token") + .match_body(mockito::Matcher::Regex( + ".*code=authorizationcode.*client_id=9022167.*".to_string(), + )) + .with_body( + serde_json::json!({ + "access_token": "accesstoken", + "refresh_token": "refreshtoken", + "token_type": "Bearer", + "expires_in": 12345678 + }) + .to_string(), + ) + .expect(1) + .create(); + + let tok = auth + .token(&["https://googleapis.com/some/scope"]) + .await + .expect("failed to get token"); + assert_eq!("accesstoken", tok.access_token); + assert_eq!("refreshtoken", tok.refresh_token.unwrap()); + assert_eq!("Bearer", tok.token_type); + _m.assert(); +} + +#[tokio::test] +async fn test_installed_redirect_success() { + let auth = create_installed_flow_auth(InstalledFlowReturnMethod::HTTPRedirect, None).await; + let _m = mockito::mock("POST", "/token") + .match_body(mockito::Matcher::Regex( + ".*code=authorizationcode.*client_id=9022167.*".to_string(), + )) + .with_body( + serde_json::json!({ + "access_token": "accesstoken", + "refresh_token": "refreshtoken", + "token_type": "Bearer", + "expires_in": 12345678 + }) + .to_string(), + ) + .expect(1) + .create(); + + let tok = auth + .token(&["https://googleapis.com/some/scope"]) + .await + .expect("failed to get token"); + assert_eq!("accesstoken", tok.access_token); + assert_eq!("refreshtoken", tok.refresh_token.unwrap()); + assert_eq!("Bearer", tok.token_type); + _m.assert(); +} + +#[tokio::test] +async fn test_installed_error() { + let auth = create_installed_flow_auth(InstalledFlowReturnMethod::Interactive, None).await; + let _m = mockito::mock("POST", "/token") + .match_body(mockito::Matcher::Regex( + ".*code=authorizationcode.*client_id=9022167.*".to_string(), + )) + .with_status(400) + .with_body(serde_json::json!({"error": "invalid_code"}).to_string()) + .expect(1) + .create(); + + let tokr = auth.token(&["https://googleapis.com/some/scope"]).await; + assert!(tokr.is_err()); + assert!(format!("{}", tokr.unwrap_err()).contains("invalid_code")); + _m.assert(); +} + +async fn create_service_account_auth() -> Authenticator> { + let server_url = &mockito::server_url(); + let key: ServiceAccountKey = parse_json!({ + "type": "service_account", + "project_id": "yup-test-243420", + "private_key_id": "26de294916614a5ebdf7a065307ed3ea9941902b", + "private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQDemmylrvp1KcOn\n9yTAVVKPpnpYznvBvcAU8Qjwr2fSKylpn7FQI54wCk5VJVom0jHpAmhxDmNiP8yv\nHaqsef+87Oc0n1yZ71/IbeRcHZc2OBB33/LCFqf272kThyJo3qspEqhuAw0e8neg\nLQb4jpm9PsqR8IjOoAtXQSu3j0zkXemMYFy93PWHjVpPEUX16NGfsWH7oxspBHOk\n9JPGJL8VJdbiAoDSDgF0y9RjJY5I52UeHNhMsAkTYs6mIG4kKXt2+T9tAyHw8aho\nwmuytQAfydTflTfTG8abRtliF3nil2taAc5VB07dP1b4dVYy/9r6M8Z0z4XM7aP+\nNdn2TKm3AgMBAAECggEAWi54nqTlXcr2M5l535uRb5Xz0f+Q/pv3ceR2iT+ekXQf\n+mUSShOr9e1u76rKu5iDVNE/a7H3DGopa7ZamzZvp2PYhSacttZV2RbAIZtxU6th\n7JajPAM+t9klGh6wj4jKEcE30B3XVnbHhPJI9TCcUyFZoscuPXt0LLy/z8Uz0v4B\nd5JARwyxDMb53VXwukQ8nNY2jP7WtUig6zwE5lWBPFMbi8GwGkeGZOruAK5sPPwY\nGBAlfofKANI7xKx9UXhRwisB4+/XI1L0Q6xJySv9P+IAhDUI6z6kxR+WkyT/YpG3\nX9gSZJc7qEaxTIuDjtep9GTaoEqiGntjaFBRKoe+VQKBgQDzM1+Ii+REQqrGlUJo\nx7KiVNAIY/zggu866VyziU6h5wjpsoW+2Npv6Dv7nWvsvFodrwe50Y3IzKtquIal\nVd8aa50E72JNImtK/o5Nx6xK0VySjHX6cyKENxHRDnBmNfbALRM+vbD9zMD0lz2q\nmns/RwRGq3/98EqxP+nHgHSr9QKBgQDqUYsFAAfvfT4I75Glc9svRv8IsaemOm07\nW1LCwPnj1MWOhsTxpNF23YmCBupZGZPSBFQobgmHVjQ3AIo6I2ioV6A+G2Xq/JCF\nmzfbvZfqtbbd+nVgF9Jr1Ic5T4thQhAvDHGUN77BpjEqZCQLAnUWJx9x7e2xvuBl\n1A6XDwH/ewKBgQDv4hVyNyIR3nxaYjFd7tQZYHTOQenVffEAd9wzTtVbxuo4sRlR\nNM7JIRXBSvaATQzKSLHjLHqgvJi8LITLIlds1QbNLl4U3UVddJbiy3f7WGTqPFfG\nkLhUF4mgXpCpkMLxrcRU14Bz5vnQiDmQRM4ajS7/kfwue00BZpxuZxst3QKBgQCI\nRI3FhaQXyc0m4zPfdYYVc4NjqfVmfXoC1/REYHey4I1XetbT9Nb/+ow6ew0UbgSC\nUZQjwwJ1m1NYXU8FyovVwsfk9ogJ5YGiwYb1msfbbnv/keVq0c/Ed9+AG9th30qM\nIf93hAfClITpMz2mzXIMRQpLdmQSR4A2l+E4RjkSOwKBgQCB78AyIdIHSkDAnCxz\nupJjhxEhtQ88uoADxRoEga7H/2OFmmPsqfytU4+TWIdal4K+nBCBWRvAX1cU47vH\nJOlSOZI0gRKe0O4bRBQc8GXJn/ubhYSxI02IgkdGrIKpOb5GG10m85ZvqsXw3bKn\nRVHMD0ObF5iORjZUqD0yRitAdg==\n-----END PRIVATE KEY-----\n", + "client_email": "yup-test-sa-1@yup-test-243420.iam.gserviceaccount.com", + "client_id": "102851967901799660408", + "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "token_uri": format!("{}/token", server_url), + "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", + "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/yup-test-sa-1%40yup-test-243420.iam.gserviceaccount.com" + }); + + ServiceAccountAuthenticator::builder(key) + .build() + .await + .unwrap() +} + +#[tokio::test] +async fn test_service_account_success() { + use chrono::Utc; + let auth = create_service_account_auth().await; + + let json_response = serde_json::json!({ + "access_token": "ya29.c.ElouBywiys0LyNaZoLPJcp1Fdi2KjFMxzvYKLXkTdvM-rDfqKlvEq6PiMhGoGHx97t5FAvz3eb_ahdwlBjSStxHtDVQB4ZPRJQ_EOi-iS7PnayahU2S9Jp8S6rk", + "expires_in": 3600, + "token_type": "Bearer" + }); + let _m = mockito::mock("POST", "/token") + .with_status(200) + .with_header("content-type", "text/json") + .with_body(json_response.to_string()) + .expect(1) + .create(); + let tok = auth + .token(&["https://www.googleapis.com/auth/pubsub"]) + .await + .expect("token failed"); + assert!(tok.access_token.contains("ya29.c.ElouBywiys0Ly")); + assert!(Utc::now() + chrono::Duration::seconds(3600) >= tok.expires_at.unwrap()); + _m.assert(); +} + +#[tokio::test] +async fn test_service_account_error() { + let auth = create_service_account_auth().await; + let bad_json_response = serde_json::json!({ + "error": "access_denied", + }); + + let _m = mockito::mock("POST", "/token") + .with_status(200) + .with_header("content-type", "text/json") + .with_body(bad_json_response.to_string()) + .create(); + let result = auth + .token(&["https://www.googleapis.com/auth/pubsub"]) + .await; + assert!(result.is_err()); + _m.assert(); +} + +#[tokio::test] +async fn test_refresh() { + let auth = create_installed_flow_auth(InstalledFlowReturnMethod::Interactive, None).await; + // We refresh a token whenever it's within 1 minute of expiring. So + // acquiring a token that expires in 59 seconds will force a refresh on + // the next token call. + let _m = mockito::mock("POST", "/token") + .match_body(mockito::Matcher::Regex( + ".*code=authorizationcode.*client_id=9022167.*".to_string(), + )) + .with_body( + serde_json::json!({ + "access_token": "accesstoken", + "refresh_token": "refreshtoken", + "token_type": "Bearer", + "expires_in": 59, + }) + .to_string(), + ) + .expect(1) + .create(); + let tok = auth + .token(&["https://googleapis.com/some/scope"]) + .await + .expect("failed to get token"); + assert_eq!("accesstoken", tok.access_token); + assert_eq!("refreshtoken", tok.refresh_token.unwrap()); + assert_eq!("Bearer", tok.token_type); + _m.assert(); + + let _m = mockito::mock("POST", "/token") + .match_body(mockito::Matcher::Regex( + ".*client_id=9022167.*refresh_token=refreshtoken.*".to_string(), + )) + .with_body( + serde_json::json!({ + "access_token": "accesstoken2", + "token_type": "Bearer", + "expires_in": 59, + }) + .to_string(), + ) + .expect(1) + .create(); + + let tok = auth + .token(&["https://googleapis.com/some/scope"]) + .await + .expect("failed to get token"); + assert_eq!("accesstoken2", tok.access_token); + assert_eq!("refreshtoken", tok.refresh_token.unwrap()); + assert_eq!("Bearer", tok.token_type); + _m.assert(); + + let _m = mockito::mock("POST", "/token") + .match_body(mockito::Matcher::Regex( + ".*client_id=9022167.*refresh_token=refreshtoken.*".to_string(), + )) + .with_body( + serde_json::json!({ + "error": "invalid_request", + }) + .to_string(), + ) + .expect(1) + .create(); + + let tok_err = auth + .token(&["https://googleapis.com/some/scope"]) + .await + .expect_err("token refresh succeeded unexpectedly"); + match tok_err { + Error::AuthError(AuthError { + error: AuthErrorCode::InvalidRequest, + .. + }) => {} + e => panic!("unexpected error on refresh: {:?}", e), + } + _m.assert(); +} + +#[tokio::test] +async fn test_memory_storage() { + let auth = create_installed_flow_auth(InstalledFlowReturnMethod::Interactive, None).await; + let _m = mockito::mock("POST", "/token") + .match_body(mockito::Matcher::Regex( + ".*code=authorizationcode.*client_id=9022167.*".to_string(), + )) + .with_body( + serde_json::json!({ + "access_token": "accesstoken", + "refresh_token": "refreshtoken", + "token_type": "Bearer", + "expires_in": 12345678 + }) + .to_string(), + ) + .expect(1) + .create(); + + // Call token twice. Ensure that only one http request is made and + // identical tokens are returned. + let token1 = auth + .token(&["https://googleapis.com/some/scope"]) + .await + .expect("failed to get token"); + let token2 = auth + .token(&["https://googleapis.com/some/scope"]) + .await + .expect("failed to get token"); + assert_eq!(token1.access_token.as_str(), "accesstoken"); + assert_eq!(token1, token2); + _m.assert(); + + // Create a new authenticator. This authenticator does not share a cache + // with the previous one. Validate that it receives a different token. + let auth2 = create_installed_flow_auth(InstalledFlowReturnMethod::Interactive, None).await; + let _m = mockito::mock("POST", "/token") + .match_body(mockito::Matcher::Regex( + ".*code=authorizationcode.*client_id=9022167.*".to_string(), + )) + .with_body( + serde_json::json!({ + "access_token": "accesstoken2", + "refresh_token": "refreshtoken2", + "token_type": "Bearer", + "expires_in": 12345678 + }) + .to_string(), + ) + .expect(1) + .create(); + let token3 = auth2 + .token(&["https://googleapis.com/some/scope"]) + .await + .expect("failed to get token"); + assert_eq!(token3.access_token.as_str(), "accesstoken2"); + _m.assert(); +} + +#[tokio::test] +async fn test_disk_storage() { + let tempdir = tempfile::tempdir().unwrap(); + let storage_path = tempdir.path().join("tokenstorage.json"); + { + let auth = create_installed_flow_auth( + InstalledFlowReturnMethod::Interactive, + Some(storage_path.clone()), + ) + .await; + let _m = mockito::mock("POST", "/token") + .match_body(mockito::Matcher::Regex( + ".*code=authorizationcode.*client_id=9022167.*".to_string(), + )) + .with_body( + serde_json::json!({ + "access_token": "accesstoken", + "refresh_token": "refreshtoken", + "token_type": "Bearer", + "expires_in": 12345678 + }) + .to_string(), + ) + .expect(1) + .create(); + + // Call token twice. Ensure that only one http request is made and + // identical tokens are returned. + let token1 = auth + .token(&["https://googleapis.com/some/scope"]) + .await + .expect("failed to get token"); + let token2 = auth + .token(&["https://googleapis.com/some/scope"]) + .await + .expect("failed to get token"); + assert_eq!(token1.access_token.as_str(), "accesstoken"); + assert_eq!(token1, token2); + _m.assert(); + } + + // Create a new authenticator. This authenticator uses the same token + // storage file as the previous one so should receive a token without + // making any http requests. + let auth = create_installed_flow_auth( + InstalledFlowReturnMethod::Interactive, + Some(storage_path.clone()), + ) + .await; + // Call token twice. Ensure that identical tokens are returned. + let token1 = auth + .token(&["https://googleapis.com/some/scope"]) + .await + .expect("failed to get token"); + let token2 = auth + .token(&["https://googleapis.com/some/scope"]) + .await + .expect("failed to get token"); + assert_eq!(token1.access_token.as_str(), "accesstoken"); + assert_eq!(token1, token2); +}