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..ed7a59d0b 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,27 @@ 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.client_id, + &self.secret.client_secret, + scopes.iter()) + } + fn retrieve_device_token(&mut self, scopes: &Vec<&str>) -> Result> { let mut flow = DeviceFlow::new(self.client.borrow_mut()); @@ -448,9 +471,37 @@ 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(); + let _ = io::stdin().read_line(&mut code); + + if !code.is_empty() { + Some(code) + } else { + None + } + } 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..7e95fd077 --- /dev/null +++ b/src/installed.rs @@ -0,0 +1,354 @@ +/* + * 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::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>(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("https://accounts.google.com/o/oauth2/v2/auth?"); + 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, + client_id: &str, + client_secret: &str, + scopes: S) + -> Result> + where T: AsRef + 'a, + S: Iterator + { + use std::error::Error; + let authcode = try!(self.get_authorization_code(auth_delegate, client_id, scopes)); + let tokens = try!(self.request_token(client_secret, client_id, &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). + /// TODO(dermesser): Add timeout configurability! + fn get_authorization_code<'a, AD: AuthenticatorDelegate, S, T>(&mut self, + auth_delegate: &mut AD, + client_id: &str, + scopes: S) + -> Result> + where T: AsRef + 'a, + S: Iterator + { + let result: Result> = match self.server { + None => { + let url = build_authentication_request_url(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(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), + } + } + }; + let _ = self.server.as_mut().map(|l| l.close()); + result + } + + /// Sends the authorization code to the provider in order to obtain access and refresh tokens. + fn request_token(&mut self, + client_secret: &str, + client_id: &str, + 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(), + client_id.to_string()), + ("client_secret".to_string(), + client_secret.to_string()), + ("redirect_uri".to_string(), redirect_uri), + ("grant_type".to_string(), + "authorization_code".to_string())]); + + let result: Result = + self.client + .borrow_mut() + .post("https://www.googleapis.com/oauth2/v4/token") + .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/v2/auth?scope=email%20profile&redirect_uri=urn:ietf:wg:oauth:2.\ + 0:oob&response_type=code&client_id=812741506391-h38jh0j4fv0ce1krdkiq0hfvt6n5a\ + mrf.apps.googleusercontent.com", + build_authentication_request_url("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};