From 2feddf4f56dd3c2c6c171456557c2fb7fcb6d394 Mon Sep 17 00:00:00 2001 From: Sebastian Thiel Date: Thu, 26 Feb 2015 19:43:15 +0100 Subject: [PATCH] initial commit after moving it out of yup/lib --- .gitignore | 3 + Cargo.toml | 24 +++ README.md | 5 + examples/auth.rs | 72 +++++++ src/common.rs | 33 +++ src/device.rs | 527 +++++++++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 86 ++++++++ src/refresh.rs | 150 ++++++++++++++ 8 files changed, 900 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.toml create mode 100644 README.md create mode 100644 examples/auth.rs create mode 100644 src/common.rs create mode 100644 src/device.rs create mode 100644 src/lib.rs create mode 100644 src/refresh.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c420590 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +target +Cargo.lock +*.sublime-workspace diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..015a523 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,24 @@ +[package] + +name = "yup-oauth2" +version = "0.0.1" +authors = ["Sebastian Thiel "] +repository = "https://github.com/Byron/yup/lib/oauth2" +description = """A partial oauth2 implementation, providing the 'device' authorization flow. +More information about how it's done can be found here: https://developers.google.com/youtube/v3/guides/authentication#devices +""" + +[dependencies] +chrono = "*" +hyper = "*" +log = "*" +mime = "*" +url = "*" +itertools = "*" +rustc-serialize = "*" + +[dev-dependencies] +getopts = "*" + +[dev-dependencies.yup-hyper-mock] +path = "../hyper-mock" diff --git a/README.md b/README.md new file mode 100644 index 0000000..83c318e --- /dev/null +++ b/README.md @@ -0,0 +1,5 @@ +**yup-oauth2** is a utility library which will implement [oauthv2 device authentication](https://developers.google.com/youtube/v3/guides/authentication#devices) suitable for [**yup**](https://github.com/Byron/yup) to work. + +It is implemented such that it makes no assumptions about the front-end, allowing more uses than just in yup. + +Architecturally, it may never be implementing more than device authentication, yet is set up not to constrain itself. \ No newline at end of file diff --git a/examples/auth.rs b/examples/auth.rs new file mode 100644 index 0000000..4819859 --- /dev/null +++ b/examples/auth.rs @@ -0,0 +1,72 @@ +extern crate "yup-oauth2" as oauth2; +extern crate "yup-hyper-mock" as mock; +extern crate hyper; +extern crate getopts; + +use getopts::{HasArg,Options,Occur,Fail}; +use std::os; +use std::old_io::{File, FileMode, FileAccess}; +use std::old_path::Path; + +fn usage(program: &str, opts: &Options, err: Option) { + if err.is_some() { + println!("{}", err.unwrap()); + os::set_exit_status(1); + } + println!("{}", opts.short_usage(program) + " SCOPE [SCOPE ...]"); + println!("{}", opts.usage("A program to authenticate against oauthv2 services.\n\ + See https://developers.google.com/youtube/registering_an_application\n\ + and https://developers.google.com/youtube/v3/guides/authentication#devices")); +} + +fn main() { + let args = os::args(); + let prog = args[0].clone(); + + let mut opts = Options::new(); + opts.opt("c", "id", "oauthv2 ID of your application", "CLIENT_ID", HasArg::Yes, Occur::Req) + .opt("s", "secret", "oauthv2 secret of your application", "CLIENT_SECRET", HasArg::Yes, Occur::Req); + + let m = match opts.parse(args.tail()) { + Ok(m) => m, + Err(e) => { + usage(&prog, &opts, Some(e)); + return + } + }; + + if m.free.len() == 0 { + let msg = Fail::ArgumentMissing("you must provide one or more authorization scopes as free options".to_string()); + usage(&prog, &opts, Some(msg)); + return + } + + let client_id = m.opt_str("c").unwrap(); + let client_secret = m.opt_str("s").unwrap(); + + println!("THIS PROGRAM PRINTS ALL COMMUNICATION TO STDERR !!!"); + + struct StdoutHandler; + impl oauth2::DeviceFlowHelperDelegate for StdoutHandler { + fn present_user_code(&mut self, pi: oauth2::PollInformation) { + println!("Please enter '{}' at {} and authenticate the application for the\n\ + given scopes. This is not a test !\n\ + You have time until {} to do that. + Do not terminate the program until you deny or grant access !", + pi.user_code, pi.verification_url, pi.expires_at); + } + } + + let client = hyper::Client::with_connector(mock::TeeConnector { + connector: hyper::net::HttpConnector(None) + }); + if let Some(t) = oauth2::DeviceFlowHelper::new(&mut StdoutHandler) + .retrieve_token(client, &client_id, &client_secret, &m.free) { + println!("Authentication granted !"); + println!("You should store the following information for use, or revoke it."); + println!("{:?}", t); + } else { + println!("Invalid client id, invalid scope, user denied access or request expired"); + os::set_exit_status(10); + } +} \ No newline at end of file diff --git a/src/common.rs b/src/common.rs new file mode 100644 index 0000000..e43a9a6 --- /dev/null +++ b/src/common.rs @@ -0,0 +1,33 @@ +use chrono::{DateTime, UTC}; + +/// Represents a token as returned by OAuth2 servers. +/// +/// It is produced by all authentication flows. +/// It authenticates certain operations, and must be refreshed once +/// it reached it's expirey date. +#[derive(Clone, PartialEq, Debug)] +pub struct Token { + /// used when authenticating calls to oauth2 enabled services + pub access_token: String, + /// used to refresh an expired access_token + pub refresh_token: String, + /// The token type as string - usually 'Bearer' + pub token_type: String, + /// access_token is not valid for use after this date + pub expires_at: DateTime +} + +/// All known authentication types, for suitable constants +pub enum AuthenticationType { + /// [device authentication](https://developers.google.com/youtube/v3/guides/authentication#devices) + Device, +} + +impl AuthenticationType { + /// Converts itself into a URL string + pub fn url(&self) -> &'static str { + match *self { + AuthenticationType::Device => "https://accounts.google.com/o/oauth2/device/code", + } + } +} \ No newline at end of file diff --git a/src/device.rs b/src/device.rs new file mode 100644 index 0000000..9fefa9f --- /dev/null +++ b/src/device.rs @@ -0,0 +1,527 @@ +use std::iter::IntoIterator; +use std::time::Duration; +use std::default::Default; +use std::cmp::min; +use std::old_io::timer; + +use hyper; +use hyper::header::ContentType; +use url::form_urlencoded; +use itertools::Itertools; +use rustc_serialize::{self, json}; +use chrono::{DateTime,UTC}; + +use common::{Token, AuthenticationType}; + +pub const GOOGLE_TOKEN_URL: &'static str = "https://accounts.google.com/o/oauth2/token"; + +/// Implements the [Oauth2 Device Flow](https://developers.google.com/youtube/v3/guides/authentication#devices) +/// It operates in two steps: +/// * obtain a code to show to the user +/// * (repeatedly) poll for the user to authenticate your application +pub struct DeviceFlow { + client: hyper::Client, + device_code: String, + state: PollResult, + secret: String, + id: String, +} + + +/// Contains state of pending authentication requests +#[derive(Clone, Debug, PartialEq)] +pub struct PollInformation { + /// Code the user must enter ... + pub user_code: String, + /// ... at the verification URL + pub verification_url: String, + + /// The `user_code` expires at the given time + /// It's the time the user has left to authenticate your application + pub expires_at: DateTime, + /// The interval in which we may poll for a status change + /// The server responds with errors of we poll too fast. + pub interval: Duration, + + /// The message given by the server while polling it, + /// usually not relevant to the user or the application + pub server_message: String, +} + +/// Encapsulates all possible results of the `request_token(...)` operation +#[derive(Clone)] +pub enum RequestResult { + /// Indicates connection failure + Error(hyper::HttpError), + /// The OAuth client was not found + InvalidClient, + /// Some requested scopes were invalid. String contains the scopes as part of + /// the server error message + InvalidScope(String), + /// Indicates we may enter the next phase + ProceedWithPolling(PollInformation), +} + +impl RequestResult { + fn from_server_message(msg: &str, desc: &str) -> RequestResult { + match msg { + "invalid_client" => RequestResult::InvalidClient, + "invalid_scope" => RequestResult::InvalidScope(desc.to_string()), + _ => panic!("'{}' not understood", msg) + } + } +} + +/// Encapsulates all possible results of a `poll_token(...)` operation +#[derive(Clone)] +pub enum PollResult { + /// Connection failure - retry if you think it's worth it + Error(hyper::HttpError), + /// See `PollInformation` + AuthorizationPending(PollInformation), + /// indicates we are expired, including the expiration date + Expired(DateTime), + /// Indicates that the user declined access. String is server response + AccessDenied, + /// Indicates that access is granted, and you are done + AccessGranted(Token), +} + +impl Default for PollResult { + fn default() -> PollResult { + PollResult::Error(hyper::HttpError::HttpStatusError) + } +} + +impl DeviceFlow + where NC: hyper::net::NetworkConnector { + + /// # Examples + /// ```test_harness + /// extern crate hyper; + /// extern crate "yup-oauth2" as oauth2; + /// use oauth2::DeviceFlow; + /// + /// # #[test] fn new() { + /// let mut f = DeviceFlow::new(hyper::Client::new()); + /// # } + /// ``` + pub fn new(client: hyper::Client) -> DeviceFlow { + DeviceFlow { + client: client, + device_code: Default::default(), + secret: Default::default(), + id: Default::default(), + state: Default::default(), + } + } + + /// The first step involves asking the server for a code that the user + /// can type into a field at a specified URL. It is called only once, assuming + /// there was no connection error. Otherwise, it may be called again until + /// the state changes to `PollResult::AuthorizationPending`. + /// # Arguments + /// * `client_id` & `client_secret` - as obtained when [registering your application](https://developers.google.com/youtube/registering_an_application) + /// * `scopes` - an iterator yielding String-like objects which are URLs defining what your + /// application is able to do. It is considered good behaviour to authenticate + /// only once, with all scopes you will ever require. + /// However, you can also manage multiple tokens for different scopes, if your + /// application is providing distinct read-only and write modes. + /// # Handling the `PollResult` + /// * will panic if called while our state is not `PollResult::Error` + /// or `PollResult::NeedToken` + /// # Examples + /// See test-cases in source code for a more complete example. + pub fn request_code<'b, T, I>(&mut self, client_id: &str, client_secret: &str, scopes: I) + -> RequestResult + where T: Str, + I: IntoIterator { + if self.device_code.len() > 0 { + panic!("Must not be called after we have obtained a token and have no error"); + } + + // note: cloned() shouldn't be needed, see issue + // https://github.com/servo/rust-url/issues/81 + let req = form_urlencoded::serialize( + [("client_id", client_id), + ("scope", scopes.into_iter() + .map(|s| s.as_slice()) + .intersperse(" ") + .collect::() + .as_slice())].iter().cloned()); + + match self.client.post(AuthenticationType::Device.url()) + .header(ContentType("application/x-www-form-urlencoded".parse().unwrap())) + .body(req.as_slice()) + .send() { + Err(err) => { + return RequestResult::Error(err); + } + Ok(mut res) => { + + + #[derive(RustcDecodable)] + struct JsonData { + device_code: String, + user_code: String, + verification_url: String, + expires_in: i64, + interval: i64, + } + + + #[derive(RustcDecodable)] + struct JsonError { + error: String, + error_description: String + } + + // This will work once hyper uses std::io::Reader + // let decoded: JsonData = rustc_serialize::Decodable::decode( + // &mut json::Decoder::new( + // json::Json::from_reader(&mut res) + // .ok() + // .expect("decode must work!"))).unwrap(); + let json_str = String::from_utf8(res.read_to_end().unwrap()).unwrap(); + + // check for error + match json::decode::(&json_str) { + Err(_) => {}, // ignore, move on + Ok(res) => { + return RequestResult::from_server_message(&res.error, + &res.error_description) + } + } + + let decoded: JsonData = json::decode(&json_str).ok().expect("valid reply thanks to valid client_id and client_secret"); + + self.device_code = decoded.device_code; + let pi = PollInformation { + user_code: decoded.user_code, + verification_url: decoded.verification_url, + expires_at: UTC::now() + Duration::seconds(decoded.expires_in), + interval: Duration::seconds(decoded.interval), + server_message: Default::default(), + }; + self.state = PollResult::AuthorizationPending(pi.clone()); + + self.secret = client_secret.to_string(); + self.id = client_id.to_string(); + RequestResult::ProceedWithPolling(pi) + } + } + } + + /// If the first call is successful, which is expected unless there is a network problem, + /// the returned `PollResult::AuthorizationPending` variant contains enough information to + /// poll within a given `interval` to at some point obtain a result which is + /// not `PollResult::AuthorizationPending`. + /// # Handling the `PollResult` + /// * call within `PollResult::AuthorizationPending.interval` until the variant changes. + /// Keep calling as desired, even after `PollResult::Error`. + /// * Do not call after `PollResult::Expired`, `PollResult::AccessDenied` + /// or `PollResult::AccessGranted` as the flow will do nothing anymore. + /// Thus in any unsuccessful case, you will have to start over the entire flow, which + /// requires a new instance of this type. + /// + /// > ⚠️ **Warning**: We assume the caller doesn't call faster than `interval` and are not + /// > protected against this kind of mis-use. The latter will be indicated in + /// > `PollResult::AuthorizationPending.server_message` + /// + /// # Examples + /// See test-cases in source code for a more complete example. + pub fn poll_token(&mut self) -> PollResult { + // clone, as we may re-assign our state later + let state = self.state.clone(); + match state { + PollResult::AuthorizationPending(mut pi) => { + if pi.expires_at <= UTC::now() { + self.state = PollResult::Expired(pi.expires_at); + return self.state.clone(); + } + + // We should be ready for a new request + let req = form_urlencoded::serialize( + [("client_id", self.id.as_slice()), + ("client_secret", &self.secret), + ("code", &self.device_code), + ("grant_type", "http://oauth.net/grant_type/device/1.0")] + .iter().cloned()); + + let json_str = + match self.client.post(GOOGLE_TOKEN_URL) + .header(ContentType("application/x-www-form-urlencoded".parse().unwrap())) + .body(req.as_slice()) + .send() { + Err(err) => { + return PollResult::Error(err); + } + Ok(mut res) => { + String::from_utf8(res.read_to_end().unwrap()).unwrap() + } + }; + + #[derive(RustcDecodable)] + struct JsonToken { + access_token: String, + refresh_token: String, + token_type: String, + expires_in: i64, + } + + #[derive(RustcDecodable)] + struct JsonError { + error: String + } + + match json::decode::(&json_str) { + Err(_) => {}, // ignore, move on, it's not an error + Ok(res) => { + pi.server_message = res.error; + self.state = match pi.server_message.as_slice() { + "access_denied" => PollResult::AccessDenied, + "authorization_pending" => PollResult::AuthorizationPending(pi), + _ => panic!("server message '{}' not understood", pi.server_message), + }; + return self.state.clone(); + } + } + + // yes, we expect that ! + let t: JsonToken = json::decode(&json_str).unwrap(); + self.state = PollResult::AccessGranted(Token { + access_token: t.access_token, + refresh_token: t.refresh_token, + token_type: t.token_type, + expires_at: UTC::now() + Duration::seconds(t.expires_in) + }); + }, + // In any other state, we will bail out and do nothing + _ => {} + } + + self.state.clone() + } +} + +/// A utility type to help executing the `DeviceFlow` correctly. +/// +/// This involves polling the authentication server in the given intervals +/// until there is a definitive result. +/// +/// These results will be passed the `DeviceFlowHelperDelegate` implementation to deal with +/// * presenting the user code +/// * inform the user about the progress or errors +/// * abort the operation +/// +pub struct DeviceFlowHelper<'a> { + delegate: &'a mut (DeviceFlowHelperDelegate + 'a), +} + +impl<'a> DeviceFlowHelper<'a> { + + /// Initialize a new instance with the given delegate + pub fn new(delegate: &'a mut DeviceFlowHelperDelegate) -> DeviceFlowHelper<'a> { + DeviceFlowHelper { + delegate: delegate, + } + } + + /// Blocks until a token was retrieved from the server, or the delegate + /// decided to abort the attempt, or the user decided not to authorize + /// the application. + pub fn retrieve_token<'b, NC, T, I>(&mut self, + client: hyper::Client, + client_id: &str, client_secret: &str, scopes: I) + -> Option + where T: Str, + I: IntoIterator + Clone, + NC: hyper::net::NetworkConnector { + let mut flow = DeviceFlow::new(client); + + // PHASE 1: REQUEST CODE + loop { + let res = flow.request_code(client_id, client_secret, scopes.clone()); + match res { + RequestResult::Error(err) => { + match self.delegate.connection_error(err) { + Retry::Abort => return None, + Retry::After(d) => timer::sleep(d), + } + }, + RequestResult::InvalidClient + |RequestResult::InvalidScope(_) => { + self.delegate.request_failure(res); + return None + } + RequestResult::ProceedWithPolling(pi) => { + self.delegate.present_user_code(pi); + break + } + } + } + + // PHASE 1: POLL TOKEN + loop { + match flow.poll_token() { + PollResult::Error(err) => { + match self.delegate.connection_error(err) { + Retry::Abort => return None, + Retry::After(d) => timer::sleep(d), + } + }, + PollResult::Expired(t) => { + self.delegate.expired(t); + return None + }, + PollResult::AccessDenied => { + self.delegate.denied(); + return None + }, + PollResult::AuthorizationPending(pi) => { + match self.delegate.pending(&pi) { + Retry::Abort => return None, + Retry::After(d) => timer::sleep(min(d, pi.interval)), + } + }, + PollResult::AccessGranted(token) => { + return Some(token) + }, + } + } + } +} + +/// A utility type to indicate how operations DeviceFlowHelper operations should be retried +pub enum Retry { + /// Signal you don't want to retry + Abort, + /// Signals you want to retry after the given duration + After(Duration) +} + +/// A partially implemented trait to interact with the `DeviceFlowHelper` +/// +/// The only method that needs to be implemented manually is `present_user_code(...)`, +/// as no assumptions are made on how this presentation should happen. +pub trait DeviceFlowHelperDelegate { + + /// Called whenever there is an HttpError, usually if there are network problems. + /// + /// Return retry information. + fn connection_error(&mut self, hyper::HttpError) -> Retry { + Retry::After(Duration::seconds(5)) + } + + /// The server denied the attempt to obtain a request code + fn request_failure(&mut self, RequestResult) {} + + /// The server has returned a `user_code` which must be shown to the user, + /// along with the `verification_url`. + /// Will be called exactly once, provided we didn't abort during `request_code` phase. + fn present_user_code(&mut self, PollInformation); + + /// Called if the request code is expired. You will have to start over in this case. + /// This will be the last call the delegate receives. + fn expired(&mut self, DateTime) {} + + /// Called if the user denied access. You would have to start over. + /// This will be the last call the delegate receives. + fn denied(&mut self) {} + + /// Called as long as we are waiting for the user to authorize us. + /// Can be used to print progress information, or decide to time-out. + /// + /// If the returned `Retry` variant is a duration, it will only be used if it + /// is larger than the interval desired by the server. + fn pending(&mut self, &PollInformation) -> Retry { + Retry::After(Duration::seconds(5)) + } +} + + +#[cfg(test)] +mod tests { + use super::*; + use std::default::Default; + use hyper; + + mock_connector_in_order!(MockGoogleAuth { + "HTTP/1.1 200 OK\r\n\ + Server: BOGUS\r\n\ + \r\n\ + {\r\n\ + \"device_code\" : \"4/L9fTtLrhY96442SEuf1Rl3KLFg3y\",\r\n\ + \"user_code\" : \"a9xfwk9c\",\r\n\ + \"verification_url\" : \"http://www.google.com/device\",\r\n\ + \"expires_in\" : \"1800\",\r\n\ + \"interval\" : 0\r\n\ + }" + "HTTP/1.1 200 OK\r\n\ + Server: BOGUS\r\n\ + \r\n\ + {\r\n\ + \"error\" : \"authorization_pending\"\r\n\ + }" + "HTTP/1.1 200 OK\r\n\ + Server: BOGUS\r\n\ + \r\n\ + {\r\n\ + \"access_token\":\"1/fFAGRNJru1FTz70BzhT3Zg\",\r\n\ + \"expires_in\":3920,\r\n\ + \"token_type\":\"Bearer\",\r\n\ + \"refresh_token\":\"1/6BMfW9j53gdGImsixUH6kU5RsR4zwI9lUVX-tqf8JXQ\"\r\n\ + }" + }); + + #[test] + fn working_flow() { + let mut flow = DeviceFlow::new( + hyper::Client::with_connector(::default())); + + match flow.request_code("bogus_client_id", + "bogus_secret", + &["https://www.googleapis.com/auth/youtube.upload"]) { + RequestResult::ProceedWithPolling(_) => {}, + _ => unreachable!(), + }; + + match flow.poll_token() { + PollResult::AuthorizationPending(ref pi) => { + assert_eq!(pi.server_message, "authorization_pending"); + }, + _ => unreachable!(), + } + + match flow.poll_token() { + PollResult::AccessGranted(ref t) => { + assert_eq!(t.access_token, "1/fFAGRNJru1FTz70BzhT3Zg"); + }, + _ => unreachable!(), + } + + // from now on, all calls will yield the same result + // As our mock has only 3 items, we would panic on this call + flow.poll_token(); + } + + #[test] + fn authenticator() { + struct TestHandler; + impl DeviceFlowHelperDelegate for TestHandler { + fn present_user_code(&mut self, pi: PollInformation) { + println!("{:?}", pi); + } + } + if let Some(t) = DeviceFlowHelper::new(&mut TestHandler) + .retrieve_token(hyper::Client::with_connector( + ::default()), + "bogus_client_id", + "bogus_secret", + &["https://www.googleapis.com/auth/youtube.upload"]) { + assert_eq!(t.access_token, "1/fFAGRNJru1FTz70BzhT3Zg") + } else { + panic!("Expected to retrieve token in one go"); + } + } +} \ No newline at end of file diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..403266f --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,86 @@ +#![feature(old_io, std_misc, core)] +//! This library can be used to acquire oauth2.0 authentication for services. +//! At the time of writing, only one way of doing so is implemented, the [device flow](https://developers.google.com/youtube/v3/guides/authentication#devices), along with a flow +//! for [refreshing tokens](https://developers.google.com/youtube/v3/guides/authentication#devices) +//! +//! For your application to use this library, you will have to obtain an application +//! id and secret by [following this guide](https://developers.google.com/youtube/registering_an_application). +//! +//! # Device Flow Usage +//! As the `DeviceFlow` involves polling, the `DeviceFlowHelper` should be used +//! as means to adhere to the protocol, and remain resilient to all kinds of errors +//! that can occour on the way. +//! +//! The returned `Token` should be stored permanently to authorize future API requests. +//! +//! ```test_harness,no_run +//! extern crate hyper; +//! extern crate "yup-oauth2" as oauth2; +//! use oauth2::{DeviceFlowHelper, DeviceFlowHelperDelegate, PollInformation}; +//! +//! # #[test] fn device() { +//! struct PrintHandler; +//! impl DeviceFlowHelperDelegate for PrintHandler { +//! fn present_user_code(&mut self, pi: PollInformation) { +//! println!{"Please enter {} at {} and grant access to this application", +//! pi.user_code, pi.verification_url} +//! println!("Do not close this application until you either denied or granted access"); +//! } +//! } +//! if let Some(t) = DeviceFlowHelper::new(&mut PrintHandler) +//! .retrieve_token(hyper::Client::new(), +//! "your_client_id", +//! "your_secret", +//! &["https://www.googleapis.com/auth/youtube.upload"]) { +//! // now you can use t.access_token to authenticate API calls within your +//! // given scopes. It will not be valid forever, which is when you have to +//! // refresh it using the `RefreshFlow` +//! } else { +//! println!("user declined"); +//! } +//! # } +//! ``` +//! +//! # Refresh Flow Usage +//! As the `Token` you retrieved previously will only be valid for a certain time, you will have +//! to use the information from the `Token.refresh_token` field to get a new `access_token`. +//! +//! ```test_harness,no_run +//! extern crate hyper; +//! extern crate "yup-oauth2" as oauth2; +//! use oauth2::{RefreshFlow, AuthenticationType, RefreshResult}; +//! +//! # #[test] fn refresh() { +//! let mut f = RefreshFlow::new(hyper::Client::new()); +//! let new_token = match *f.refresh_token(AuthenticationType::Device, +//! "my_client_id", "my_secret", +//! "my_refresh_token") { +//! RefreshResult::Success(ref t) => t, +//! _ => panic!("bad luck ;)") +//! }; +//! # } +//! ``` + +extern crate chrono; + +#[macro_use] +extern crate hyper; +#[macro_use] +extern crate log; +#[cfg(test)] #[macro_use] +extern crate "yup-hyper-mock" as hyper_mock; +extern crate mime; +extern crate url; +extern crate itertools; +extern crate "rustc-serialize" as rustc_serialize; + + +mod device; +mod refresh; +mod common; + +pub use device::{DeviceFlow, PollInformation, PollResult, DeviceFlowHelper, + DeviceFlowHelperDelegate, Retry}; +pub use refresh::{RefreshFlow, RefreshResult}; +pub use common::{Token, AuthenticationType}; + diff --git a/src/refresh.rs b/src/refresh.rs new file mode 100644 index 0000000..d1bb6c0 --- /dev/null +++ b/src/refresh.rs @@ -0,0 +1,150 @@ +use common::AuthenticationType; + +use std::time::Duration; +use chrono::UTC; +use hyper; +use hyper::header::ContentType; +use rustc_serialize::{self, json}; +use url::form_urlencoded; +use super::Token; + +/// Implements the [Outh2 Refresh Token Flow](https://developers.google.com/youtube/v3/guides/authentication#devices). +/// +/// Refresh an expired access token, as obtained by any other authentication flow. +/// This flow is useful when your `Token` is expired and allows to obtain a new +/// and valid access token. +pub struct RefreshFlow { + client: hyper::Client, + result: RefreshResult, +} + + +/// All possible outcomes of the refresh flow +pub enum RefreshResult { + /// Indicates connection failure + Error(hyper::HttpError), + /// The server did not answer with a new token, providing the server message + Refused(String), + /// The refresh operation finished successfully, providing a new `Token` + Success(Token), +} + +impl RefreshFlow + where NC: hyper::net::NetworkConnector { + + pub fn new(client: hyper::Client) -> RefreshFlow { + RefreshFlow { + client: client, + result: RefreshResult::Error(hyper::HttpError::HttpStatusError), + } + } + + /// Attempt to refresh the given token, and obtain a new, valid one. + /// If the `RefreshResult` is `RefreshResult::Error`, you may retry within an interval + /// of your choice. If it is `RefreshResult::Refused`, your refresh token is invalid + /// or your authorization was revoked. Therefore no further attempt shall be made, + /// and you will have to re-authorize using the `DeviceFlow` + /// + /// # Arguments + /// * `authentication_url` - URL matching the one used in the flow that obtained + /// your refresh_token in the first place. + /// * `client_id` & `client_secret` - as obtained when [registering your application](https://developers.google.com/youtube/registering_an_application) + /// * `refresh_token` - obtained during previous call to `DeviceFlow::poll_token()` or equivalent + /// + /// # Examples + /// Please see the crate landing page for an example. + pub fn refresh_token(&mut self, auth_type: AuthenticationType, + client_id: &str, client_secret: &str, + refresh_token: &str) -> &RefreshResult { + if let RefreshResult::Success(_) = self.result { + return &self.result; + } + + let req = form_urlencoded::serialize( + [("client_id", client_id), + ("client_secret", client_secret), + ("refresh_token", refresh_token), + ("grant_type", "refresh_token")] + .iter().cloned()); + + let json_str = + match self.client.post(auth_type.url()) + .header(ContentType("application/x-www-form-urlencoded".parse().unwrap())) + .body(req.as_slice()) + .send() { + Err(err) => { + self.result = RefreshResult::Error(err); + return &self.result; + } + Ok(mut res) => { + String::from_utf8(res.read_to_end().unwrap()).unwrap() + } + }; + + #[derive(RustcDecodable)] + struct JsonError { + error: String + } + + #[derive(RustcDecodable)] + struct JsonToken { + access_token: String, + token_type: String, + expires_in: i64, + } + + match json::decode::(&json_str) { + Err(_) => {}, + Ok(res) => { + self.result = RefreshResult::Refused(res.error); + return &self.result; + } + } + + let t: JsonToken = json::decode(&json_str).unwrap(); + self.result = RefreshResult::Success(Token { + access_token: t.access_token, + token_type: t.token_type, + refresh_token: refresh_token.to_string(), + expires_at: UTC::now() + Duration::seconds(t.expires_in) + }); + + &self.result + } +} + + + +#[cfg(test)] +mod tests { + use hyper; + use std::default::Default; + use super::*; + use super::super::AuthenticationType; + + mock_connector_in_order!(MockGoogleRefresh { + "HTTP/1.1 200 OK\r\n\ + Server: BOGUS\r\n\ + \r\n\ + {\r\n\ + \"access_token\":\"1/fFAGRNJru1FTz70BzhT3Zg\",\r\n\ + \"expires_in\":3920,\r\n\ + \"token_type\":\"Bearer\"\r\n\ + }" + }); + + #[test] + fn refresh_flow() { + let mut flow = RefreshFlow::new( + hyper::Client::with_connector( + ::default())); + + + match *flow.refresh_token(AuthenticationType::Device, + "bogus", "secret", "bogus_refresh_token") { + RefreshResult::Success(ref t) => assert_eq!(t.access_token, + "1/fFAGRNJru1FTz70BzhT3Zg"), + _ => unreachable!() + } + } +}