diff --git a/Cargo.toml b/Cargo.toml index c5d362aae..a083a4f63 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,8 +14,8 @@ build = "src/build.rs" chrono = ">= 0.2" log = ">= 0.3" mime = ">= 0.1" -url = ">= 0.5" -hyper = ">= 0.8.0" +url = "= 0.5" +hyper = "0.8.0" itertools = ">= 0.4" serde = "0.6.0" serde_json = "0.6.0" diff --git a/src/common.rs b/src/common.rs index 22bb6a041..1dee61af1 100644 --- a/src/common.rs +++ b/src/common.rs @@ -136,8 +136,17 @@ impl Token { /// All known authentication types, for suitable constants #[derive(Clone, Copy)] pub enum FlowType { - /// [device authentication](https://developers.google.com/youtube/v3/guides/authentication#devices) + /// [device authentication](https://developers.google.com/youtube/v3/guides/authentication#devices). Only works + /// for certain scopes. Device, + /// [installed app flow](https://developers.google.com/identity/protocols/OAuth2InstalledApp). Required + /// for Drive, Calendar, Gmail...; Requires user to paste a code from the browser. + InstalledInteractive, + /// Same as InstalledInteractive, but uses a redirect: The OAuth provider redirects the user's + /// browser to a web server that is running on localhost. This may not work as well with the + /// Windows Firewall, but is more comfortable otherwise. The integer describes which port to + /// bind to (default: 8080) + InstalledRedirect(u32), } impl AsRef for FlowType { @@ -145,6 +154,8 @@ impl AsRef for FlowType { fn as_ref(&self) -> &'static str { match *self { FlowType::Device => "https://accounts.google.com/o/oauth2/device/code", + FlowType::InstalledInteractive => "https://accounts.google.com/o/oauth2/v2/auth", + FlowType::InstalledRedirect(_) => "https://accounts.google.com/o/oauth2/v2/auth", } } } diff --git a/src/helper.rs b/src/helper.rs index 83f232e20..917b07202 100644 --- a/src/helper.rs +++ b/src/helper.rs @@ -7,9 +7,11 @@ use std::cmp::min; use std::error::Error; use std::fmt; use std::convert::From; +use std::io; use common::{Token, FlowType, ApplicationSecret}; use device::{PollInformation, RequestError, DeviceFlow, PollError}; +use installed::{InstalledFlow, InstalledFlowReturnMethod}; use refresh::{RefreshResult, RefreshFlow}; use chrono::{DateTime, UTC, Local}; use std::time::Duration; @@ -189,6 +191,26 @@ impl Authenticator } } + + fn do_installed_flow(&mut self, scopes: &Vec<&str>) -> Result> { + let installed_type; + + match self.flow_type { + FlowType::InstalledInteractive => { + installed_type = Some(InstalledFlowReturnMethod::Interactive) + } + FlowType::InstalledRedirect(port) => { + installed_type = Some(InstalledFlowReturnMethod::HTTPRedirect(port)) + } + _ => installed_type = None, + } + + let mut flow = InstalledFlow::new(self.client.borrow_mut(), installed_type); + flow.obtain_token(&mut self.delegate, + &self.secret, + scopes.iter()) + } + fn retrieve_device_token(&mut self, scopes: &Vec<&str>) -> Result> { let mut flow = DeviceFlow::new(self.client.borrow_mut()); @@ -343,6 +365,8 @@ impl GetToken for Authenticator match match self.flow_type { FlowType::Device => self.retrieve_device_token(&scopes), + FlowType::InstalledInteractive => self.do_installed_flow(&scopes), + FlowType::InstalledRedirect(_) => self.do_installed_flow(&scopes), } { Ok(token) => { @@ -448,9 +472,31 @@ pub trait AuthenticatorDelegate { /// * Will only be called if the Authenticator's flow_type is `FlowType::Device`. fn present_user_code(&mut self, pi: &PollInformation) { println!("Please enter {} at {} and grant access to this application", - pi.user_code, pi.verification_url); + pi.user_code, + pi.verification_url); println!("Do not close this application until you either denied or granted access."); - println!("You have time until {}.", pi.expires_at.with_timezone(&Local)); + println!("You have time until {}.", + pi.expires_at.with_timezone(&Local)); + } + + /// Only method currently used by the InstalledFlow. + /// We need the user to navigate to a URL using their browser and potentially paste back a code + /// (or maybe not). Whether they have to enter a code depends on the InstalledFlowReturnMethod + /// used. + fn present_user_url(&mut self, url: &String, need_code: bool) -> Option { + if need_code { + println!("Please direct your browser to {}, follow the instructions and enter the \ + code displayed here: ", + url); + + let mut code = String::new(); + io::stdin().read_line(&mut code).ok().map(|_| code) + } else { + println!("Please direct your browser to {} and follow the instructions displayed \ + there.", + url); + None + } } } diff --git a/src/installed.rs b/src/installed.rs new file mode 100644 index 000000000..19f87011c --- /dev/null +++ b/src/installed.rs @@ -0,0 +1,356 @@ +// Copyright (c) 2016 Google Inc (lewinb@google.com). +// +// Refer to the project root for licensing information. +// +extern crate serde_json; +extern crate url; + +use std::borrow::BorrowMut; +use std::convert::AsRef; +use std::error::Error; +use std::io; +use std::io::Read; +use std::sync::Mutex; +use std::sync::mpsc::{channel, Receiver, Sender}; + +use hyper; +use hyper::{client, header, server, status, uri}; +use serde_json::error; +use url::form_urlencoded; +use url::percent_encoding::{percent_encode, QUERY_ENCODE_SET}; + +use common::{ApplicationSecret, Token}; +use helper::AuthenticatorDelegate; + +const OOB_REDIRECT_URI: &'static str = "urn:ietf:wg:oauth:2.0:oob"; + +/// Assembles a URL to request an authorization token (with user interaction). +/// Note that the redirect_uri here has to be either None or some variation of +/// http://localhost:{port}, or the authorization won't work (error "redirect_uri_mismatch") +fn build_authentication_request_url<'a, T, I>(auth_uri: &str, + client_id: &str, + scopes: I, + redirect_uri: Option) + -> String + where T: AsRef + 'a, + I: IntoIterator +{ + let mut url = String::new(); + let mut scopes_string = scopes.into_iter().fold(String::new(), |mut acc, sc| { + acc.push_str(sc.as_ref()); + acc.push_str(" "); + acc + }); + // Remove last space + scopes_string.pop(); + + url.push_str(auth_uri); + vec![format!("?scope={}", scopes_string), + format!("&redirect_uri={}", + redirect_uri.unwrap_or(OOB_REDIRECT_URI.to_string())), + format!("&response_type=code"), + format!("&client_id={}", client_id)] + .into_iter() + .fold(url, |mut u, param| { + u.push_str(&percent_encode(param.as_ref(), QUERY_ENCODE_SET)); + u + }) +} + +pub struct InstalledFlow { + client: C, + server: Option, + port: Option, + + auth_code_rcv: Option>, +} + +/// cf. https://developers.google.com/identity/protocols/OAuth2InstalledApp#choosingredirecturi +pub enum InstalledFlowReturnMethod { + /// Involves showing a URL to the user and asking to copy a code from their browser + /// (default) + Interactive, + /// Involves spinning up a local HTTP server and Google redirecting the browser to + /// the server with a URL containing the code (preferred, but not as reliable). The + /// parameter is the port to listen on. + HTTPRedirect(u32), +} + +impl InstalledFlow where C: BorrowMut +{ + /// Starts a new Installed App auth flow. + /// If HTTPRedirect is chosen as method and the server can't be started, the flow falls + /// back to Interactive. + pub fn new(client: C, method: Option) -> InstalledFlow { + let default = InstalledFlow { + client: client, + server: None, + port: None, + auth_code_rcv: None, + }; + match method { + None => default, + Some(InstalledFlowReturnMethod::Interactive) => default, + // Start server on localhost to accept auth code. + Some(InstalledFlowReturnMethod::HTTPRedirect(port)) => { + let server = server::Server::http(format!("127.0.0.1:{}", port).as_str()); + + match server { + Result::Err(_) => default, + Result::Ok(server) => { + let (tx, rx) = channel(); + let listening = server.handle(InstalledFlowHandler { + auth_code_snd: Mutex::new(tx), + }); + + match listening { + Result::Err(_) => default, + Result::Ok(listening) => { + InstalledFlow { + client: default.client, + server: Some(listening), + port: Some(port), + auth_code_rcv: Some(rx), + } + } + } + } + } + } + } + } + + /// Handles the token request flow; it consists of the following steps: + /// . Obtain a auhorization code with user cooperation or internal redirect. + /// . Obtain a token and refresh token using that code. + /// . Return that token + /// + /// It's recommended not to use the DefaultAuthenticatorDelegate, but a specialized one. + pub fn obtain_token<'a, AD: AuthenticatorDelegate, S, T>(&mut self, + auth_delegate: &mut AD, + appsecret: &ApplicationSecret, + scopes: S) + -> Result> + where T: AsRef + 'a, + S: Iterator + { + use std::error::Error; + let authcode = try!(self.get_authorization_code(auth_delegate, &appsecret, scopes)); + let tokens = try!(self.request_token(&appsecret, &authcode)); + + // Successful response + if tokens.access_token.is_some() { + let token = Token { + access_token: tokens.access_token.unwrap(), + refresh_token: tokens.refresh_token.unwrap(), + token_type: tokens.token_type.unwrap(), + expires_in: tokens.expires_in, + expires_in_timestamp: None, + }; + + Result::Ok(token) + } else { + let err = io::Error::new(io::ErrorKind::Other, + format!("Token API error: {} {}", + tokens.error.unwrap_or("".to_string()), + tokens.error_description + .unwrap_or("".to_string())) + .as_str()); + Result::Err(Box::new(err)) + } + } + + /// Obtains an authorization code either interactively or via HTTP redirect (see + /// InstalledFlowReturnMethod). + fn get_authorization_code<'a, AD: AuthenticatorDelegate, S, T>(&mut self, + auth_delegate: &mut AD, + appsecret: &ApplicationSecret, + scopes: S) + -> Result> + where T: AsRef + 'a, + S: Iterator + { + let result: Result> = match self.server { + None => { + let url = build_authentication_request_url(&appsecret.auth_uri, + &appsecret.client_id, + scopes, + None); + match auth_delegate.present_user_url(&url, true /* need_code */) { + None => { + Result::Err(Box::new(io::Error::new(io::ErrorKind::UnexpectedEof, + "couldn't read code"))) + } + // Remove newline + Some(mut code) => { + code.pop(); + Result::Ok(code) + } + } + } + Some(_) => { + // The redirect URI must be this very localhost URL, otherwise Google refuses + // authorization. + let url = build_authentication_request_url(&appsecret.auth_uri, + &appsecret.client_id, + scopes, + Some(format!("http://localhost:{}", + self.port + .unwrap_or(8080)))); + auth_delegate.present_user_url(&url, false /* need_code */); + + match self.auth_code_rcv.as_ref().unwrap().recv() { + Result::Err(e) => Result::Err(Box::new(e)), + Result::Ok(s) => Result::Ok(s), + } + } + }; + self.server.as_mut().map(|l| l.close()).is_some(); + result + } + + /// Sends the authorization code to the provider in order to obtain access and refresh tokens. + fn request_token(&mut self, + appsecret: &ApplicationSecret, + authcode: &str) + -> Result> { + let redirect_uri; + + match self.port { + None => redirect_uri = OOB_REDIRECT_URI.to_string(), + Some(p) => redirect_uri = format!("http://localhost:{}", p), + } + + let body = form_urlencoded::serialize(vec![("code".to_string(), authcode.to_string()), + ("client_id".to_string(), + appsecret.client_id.clone()), + ("client_secret".to_string(), + appsecret.client_secret.clone()), + ("redirect_uri".to_string(), redirect_uri), + ("grant_type".to_string(), + "authorization_code".to_string())]); + + let result: Result = + self.client + .borrow_mut() + .post(&appsecret.token_uri) + .body(&body) + .header(header::ContentType("application/x-www-form-urlencoded".parse().unwrap())) + .send(); + + let mut resp = String::new(); + + match result { + Result::Err(e) => return Result::Err(Box::new(e)), + Result::Ok(mut response) => { + let result = response.read_to_string(&mut resp); + + match result { + Result::Err(e) => return Result::Err(Box::new(e)), + Result::Ok(_) => (), + } + } + } + + let token_resp: Result = serde_json::from_str(&resp); + + match token_resp { + Result::Err(e) => return Result::Err(Box::new(e)), + Result::Ok(tok) => Result::Ok(tok) as Result>, + } + } +} + +#[derive(Deserialize)] +struct JSONTokenResponse { + access_token: Option, + refresh_token: Option, + token_type: Option, + expires_in: Option, + + error: Option, + error_description: Option, +} + +/// HTTP handler handling the redirect from the provider. +struct InstalledFlowHandler { + auth_code_snd: Mutex>, +} + +impl server::Handler for InstalledFlowHandler { + fn handle(&self, rq: server::Request, mut rp: server::Response) { + match rq.uri { + uri::RequestUri::AbsolutePath(path) => { + // We use a fake URL because the redirect goes to a URL, meaning we + // can't use the url form decode (because there's slashes and hashes and stuff in + // it). + let url = hyper::Url::parse(&format!("http://example.com{}", path)); + + if url.is_err() { + *rp.status_mut() = status::StatusCode::BadRequest; + let _ = rp.send("Unparseable URL".as_ref()); + } else { + self.handle_url(url.unwrap()); + *rp.status_mut() = status::StatusCode::Ok; + let _ = rp.send("SuccessYou may now \ + close this window." + .as_ref()); + } + } + _ => { + *rp.status_mut() = status::StatusCode::BadRequest; + let _ = rp.send("Invalid Request!".as_ref()); + } + } + } +} + +impl InstalledFlowHandler { + fn handle_url(&self, url: hyper::Url) { + // Google redirects to the specified localhost URL, appending the authorization + // code, like this: http://localhost:8080/xyz/?code=4/731fJ3BheyCouCniPufAd280GHNV5Ju35yYcGs + // We take that code and send it to the get_authorization_code() function that + // waits for it. + for (param, val) in url.query_pairs().unwrap_or(Vec::new()) { + if param == "code".to_string() { + let _ = self.auth_code_snd.lock().unwrap().send(val); + } + } + + } +} + +#[cfg(test)] +mod tests { + use super::build_authentication_request_url; + use super::InstalledFlowHandler; + + use std::sync::Mutex; + use std::sync::mpsc::channel; + + use hyper::Url; + + #[test] + fn test_request_url_builder() { + assert_eq!("https://accounts.google.\ + com/o/oauth2/auth?scope=email%20profile&redirect_uri=urn:ietf:wg:oauth:2.0:\ + oob&response_type=code&client_id=812741506391-h38jh0j4fv0ce1krdkiq0hfvt6n5amr\ + f.apps.googleusercontent.com", + build_authentication_request_url("https://accounts.google.com/o/oauth2/auth", + "812741506391-h38jh0j4fv0ce1krdkiq0hfvt6n5am\ + rf.apps.googleusercontent.com", + vec![&"email".to_string(), + &"profile".to_string()], + None)); + } + + #[test] + fn test_http_handle_url() { + let (tx, rx) = channel(); + let handler = InstalledFlowHandler { auth_code_snd: Mutex::new(tx) }; + // URLs are usually a bit botched + let url = Url::parse("http://example.com:1234/?code=ab/c%2Fd#").unwrap(); + handler.handle_url(url); + assert_eq!(rx.recv().unwrap(), "ab/c/d".to_string()); + } +} diff --git a/src/lib.rs.in b/src/lib.rs.in index 17d510388..cdf1d22a6 100644 --- a/src/lib.rs.in +++ b/src/lib.rs.in @@ -13,12 +13,14 @@ extern crate url; extern crate itertools; mod device; +mod installed; +mod helper; mod refresh; mod common; -mod helper; pub use device::{DeviceFlow, PollInformation, PollError}; pub use refresh::{RefreshFlow, RefreshResult}; pub use common::{Token, FlowType, ApplicationSecret, ConsoleApplicationSecret, Scheme, TokenType}; -pub use helper::{TokenStorage, NullStorage, MemoryStorage, Authenticator, - AuthenticatorDelegate, Retry, DefaultAuthenticatorDelegate, GetToken}; +pub use installed::{InstalledFlow, InstalledFlowReturnMethod}; +pub use helper::{TokenStorage, NullStorage, MemoryStorage, Authenticator, AuthenticatorDelegate, + Retry, DefaultAuthenticatorDelegate, GetToken};