diff --git a/examples/github.rs b/examples/github.rs index 4461482..3eaa5e5 100644 --- a/examples/github.rs +++ b/examples/github.rs @@ -1,7 +1,9 @@ extern crate inth_oauth2; use std::io; -use inth_oauth2::{Client, GitHub}; + +use inth_oauth2::Client; +use inth_oauth2::provider::GitHub; fn main() { let client = Client::::new( @@ -12,13 +14,11 @@ fn main() { ); let auth_uri = client.auth_uri(Some("user"), None).unwrap(); - println!("{}", auth_uri); let mut code = String::new(); io::stdin().read_line(&mut code).unwrap(); - let token_pair = client.request_token(code.trim()).unwrap(); - - println!("{:?}", token_pair); + let token = client.request_token(code.trim()).unwrap(); + println!("{:?}", token); } diff --git a/examples/google.rs b/examples/google.rs index a64ebaf..0e431db 100644 --- a/examples/google.rs +++ b/examples/google.rs @@ -1,7 +1,9 @@ extern crate inth_oauth2; use std::io; -use inth_oauth2::{Client, Google}; + +use inth_oauth2::Client; +use inth_oauth2::provider::Google; fn main() { let client = Client::::new( @@ -11,21 +13,16 @@ fn main() { Some("urn:ietf:wg:oauth:2.0:oob") ); - let auth_uri = client.auth_uri( - Some("https://www.googleapis.com/auth/userinfo.email"), - None - ).unwrap(); - + let auth_uri = client.auth_uri(Some("https://www.googleapis.com/auth/userinfo.email"), None) + .unwrap(); println!("{}", auth_uri); let mut code = String::new(); io::stdin().read_line(&mut code).unwrap(); - let token_pair = client.request_token(code.trim()).unwrap(); + let token = client.request_token(code.trim()).unwrap(); + println!("{:?}", token); - println!("{:?}", token_pair); - - let refreshed = client.refresh_token(token_pair.refresh.unwrap(), None).unwrap(); - - println!("{:?}", refreshed); + let token = client.refresh_token(token, None).unwrap(); + println!("{:?}", token); } diff --git a/examples/imgur.rs b/examples/imgur.rs index 08b0dea..c48a39e 100644 --- a/examples/imgur.rs +++ b/examples/imgur.rs @@ -1,7 +1,9 @@ extern crate inth_oauth2; use std::io; -use inth_oauth2::{Client, Imgur}; + +use inth_oauth2::Client; +use inth_oauth2::provider::Imgur; fn main() { let client = Client::::new( @@ -12,13 +14,14 @@ fn main() { ); let auth_uri = client.auth_uri(None, None).unwrap(); - println!("{}", auth_uri); let mut code = String::new(); io::stdin().read_line(&mut code).unwrap(); - let token_pair = client.request_token(code.trim()).unwrap(); + let token = client.request_token(code.trim()).unwrap(); + println!("{:?}", token); - println!("{:?}", token_pair); + let token = client.refresh_token(token, None).unwrap(); + println!("{:?}", token); } diff --git a/src/client.rs b/src/client.rs deleted file mode 100644 index 13264a7..0000000 --- a/src/client.rs +++ /dev/null @@ -1,206 +0,0 @@ -use std::io::Read; -use std::marker::PhantomData; - -use chrono::{UTC, Duration}; -use hyper::{self, header, mime}; -use rustc_serialize::json; -use url::{Url, form_urlencoded}; - -use super::Provider; -use super::{TokenPair, AccessTokenType, AccessToken, RefreshToken}; -use super::error::{Error, Result, OAuth2Error, OAuth2ErrorCode}; - -/// OAuth 2.0 client. -/// -/// Performs HTTP requests using the provided `hyper::Client`. -/// -/// See [RFC6749 section 4.1](http://tools.ietf.org/html/rfc6749#section-4.1). -pub struct Client { - http_client: hyper::Client, - - client_id: String, - client_secret: String, - redirect_uri: Option, - - provider: PhantomData

, -} - -impl Client

{ - /// Creates an OAuth 2.0 client. - pub fn new( - http_client: hyper::Client, - client_id: S, - client_secret: S, - redirect_uri: Option - ) -> Self where S: Into { - Client { - http_client: http_client, - client_id: client_id.into(), - client_secret: client_secret.into(), - redirect_uri: redirect_uri.map(Into::into), - provider: PhantomData, - } - } -} - -#[derive(RustcDecodable)] -struct TokenResponse { - access_token: String, - token_type: String, - expires_in: Option, - refresh_token: Option, - scope: Option, -} - -impl Into for TokenResponse { - fn into(self) -> TokenPair { - TokenPair { - access: AccessToken { - token: self.access_token, - token_type: match &self.token_type[..] { - "Bearer" | "bearer" => AccessTokenType::Bearer, - _ => AccessTokenType::Unrecognized(self.token_type), - }, - expires: self.expires_in.map(|s| UTC::now() + Duration::seconds(s)), - scope: self.scope, - }, - refresh: self.refresh_token.map(|t| RefreshToken { token: t }), - } - } -} - -#[derive(RustcDecodable)] -struct ErrorResponse { - error: String, - error_description: Option, - error_uri: Option, -} - -impl Into for ErrorResponse { - fn into(self) -> OAuth2Error { - let code = match &self.error[..] { - "invalid_request" => OAuth2ErrorCode::InvalidRequest, - "invalid_client" => OAuth2ErrorCode::InvalidClient, - "invalid_grant" => OAuth2ErrorCode::InvalidGrant, - "unauthorized_client" => OAuth2ErrorCode::UnauthorizedClient, - "unsupported_grant_type" => OAuth2ErrorCode::UnsupportedGrantType, - "invalid_scope" => OAuth2ErrorCode::InvalidScope, - _ => OAuth2ErrorCode::Unrecognized(self.error), - }; - OAuth2Error { - code: code, - description: self.error_description, - uri: self.error_uri, - } - } -} - -impl Client

{ - /// Constructs an authorization request URI. - /// - /// See [RFC6749 section 4.1.1](http://tools.ietf.org/html/rfc6749#section-4.1.1). - pub fn auth_uri(&self, scope: Option<&str>, state: Option<&str>) -> Result { - let mut uri = try!(Url::parse(P::auth_uri())); - - let mut query_pairs = vec![ - ("response_type", "code"), - ("client_id", &self.client_id), - ]; - if let Some(ref redirect_uri) = self.redirect_uri { - query_pairs.push(("redirect_uri", redirect_uri)); - } - if let Some(scope) = scope { - query_pairs.push(("scope", scope)); - } - if let Some(state) = state { - query_pairs.push(("state", state)); - } - - uri.set_query_from_pairs(query_pairs); - - Ok(uri.serialize()) - } - - fn auth_header(&self) -> header::Authorization { - header::Authorization( - header::Basic { - username: self.client_id.clone(), - password: Some(self.client_secret.clone()), - } - ) - } - - fn accept_header(&self) -> header::Accept { - header::Accept(vec![ - header::qitem( - mime::Mime( - mime::TopLevel::Application, - mime::SubLevel::Json, - vec![] - ) - ), - ]) - } - - fn token_post(&self, body_pairs: Vec<(&str, &str)>) -> Result { - let post_body = form_urlencoded::serialize(body_pairs); - let request = self.http_client.post(P::token_uri()) - .header(self.auth_header()) - .header(self.accept_header()) - .header(header::ContentType::form_url_encoded()) - .body(&post_body); - - let mut response = try!(request.send()); - let mut body = String::new(); - try!(response.read_to_string(&mut body)); - - let token = json::decode::(&body); - if let Ok(token) = token { - return Ok(token.into()); - } - - let error: ErrorResponse = try!(json::decode(&body)); - Err(Error::OAuth2(error.into())) - } - - /// Requests an access token using an authorization code. - /// - /// See [RFC6749 section 4.1.3](http://tools.ietf.org/html/rfc6749#section-4.1.3). - pub fn request_token(&self, code: &str) -> Result { - let mut body_pairs = vec![ - ("grant_type", "authorization_code"), - ("code", code), - ]; - if let Some(ref redirect_uri) = self.redirect_uri { - body_pairs.push(("redirect_uri", redirect_uri)); - } - self.token_post(body_pairs) - } - - /// Refreshes an access token. - /// - /// The returned `TokenPair` will always have a `refresh`. - /// - /// See [RFC6749 section 6](http://tools.ietf.org/html/rfc6749#section-6). - pub fn refresh_token(&self, refresh: RefreshToken, scope: Option<&str>) -> Result { - let mut result = { - let mut body_pairs = vec![ - ("grant_type", "refresh_token"), - ("refresh_token", &refresh.token), - ]; - if let Some(scope) = scope { - body_pairs.push(("scope", scope)); - } - - self.token_post(body_pairs) - }; - - if let Ok(ref mut pair) = result { - if pair.refresh.is_none() { - pair.refresh = Some(refresh); - } - } - - result - } -} diff --git a/src/client/error.rs b/src/client/error.rs new file mode 100644 index 0000000..2b39579 --- /dev/null +++ b/src/client/error.rs @@ -0,0 +1,80 @@ +use std::error::Error; +use std::{fmt, io}; + +use hyper; +use rustc_serialize::json; +use url; + +use client::response::ParseError; +use error::OAuth2Error; + +/// Errors that can occur during authorization. +#[derive(Debug)] +pub enum ClientError { + /// IO error. + Io(io::Error), + /// URL error. + Url(url::ParseError), + /// Hyper error. + Hyper(hyper::Error), + /// JSON error. + Json(json::ParserError), + /// Response parse error. + Parse(ParseError), + /// OAuth 2.0 error. + OAuth2(OAuth2Error), +} + +impl fmt::Display for ClientError { + fn fmt(&self, f: &mut fmt::Formatter) -> Result<(), fmt::Error> { + match *self { + ClientError::Io(ref err) => write!(f, "{}", err), + ClientError::Url(ref err) => write!(f, "{}", err), + ClientError::Hyper(ref err) => write!(f, "{}", err), + ClientError::Json(ref err) => write!(f, "{}", err), + ClientError::Parse(ref err) => write!(f, "{}", err), + ClientError::OAuth2(ref err) => write!(f, "{}", err), + } + } +} + +impl Error for ClientError { + fn description(&self) -> &str { + match *self { + ClientError::Io(ref err) => err.description(), + ClientError::Url(ref err) => err.description(), + ClientError::Hyper(ref err) => err.description(), + ClientError::Json(ref err) => err.description(), + ClientError::Parse(ref err) => err.description(), + ClientError::OAuth2(ref err) => err.description(), + } + } + + fn cause(&self) -> Option<&Error> { + match *self { + ClientError::Io(ref err) => Some(err), + ClientError::Url(ref err) => Some(err), + ClientError::Hyper(ref err) => Some(err), + ClientError::Json(ref err) => Some(err), + ClientError::Parse(ref err) => Some(err), + ClientError::OAuth2(ref err) => Some(err), + } + } +} + +macro_rules! impl_from { + ($v:path, $t:ty) => { + impl From<$t> for ClientError { + fn from(err: $t) -> Self { + $v(err) + } + } + } +} + +impl_from!(ClientError::Io, io::Error); +impl_from!(ClientError::Url, url::ParseError); +impl_from!(ClientError::Hyper, hyper::Error); +impl_from!(ClientError::Json, json::ParserError); +impl_from!(ClientError::Parse, ParseError); +impl_from!(ClientError::OAuth2, OAuth2Error); diff --git a/src/client/mod.rs b/src/client/mod.rs new file mode 100644 index 0000000..613411d --- /dev/null +++ b/src/client/mod.rs @@ -0,0 +1,249 @@ +//! Client. + +use std::fmt; +use std::marker::PhantomData; + +use hyper::{self, header, mime}; +use rustc_serialize::json::Json; +use url::{form_urlencoded, Url}; + +use error::OAuth2Error; +use provider::Provider; +use token::{Token, Lifetime, Expiring}; + +use self::response::FromResponse; +pub mod response; + +pub use self::error::ClientError; +mod error; + +/// OAuth 2.0 client. +pub struct Client { + http_client: hyper::Client, + client_id: String, + client_secret: String, + redirect_uri: Option, + provider: PhantomData

, +} + +impl fmt::Debug for Client

{ + fn fmt(&self, f: &mut fmt::Formatter) -> Result<(), fmt::Error> { + f.debug_struct("Client") + .field("client_id", &self.client_id) + .field("client_secret", &self.client_secret) + .field("redirect_uri", &self.redirect_uri) + .finish() + } +} + +impl Client

{ + /// Creates a client. + /// + /// # Examples + /// + /// ``` + /// use inth_oauth2::Client; + /// use inth_oauth2::provider::Google; + /// + /// let client = Client::::new( + /// Default::default(), + /// "CLIENT_ID", + /// "CLIENT_SECRET", + /// Some("urn:ietf:wg:oauth:2.0:oob") + /// ); + /// ``` + pub fn new( + http_client: hyper::Client, + client_id: S, + client_secret: S, + redirect_uri: Option + ) -> Self where S: Into { + Client { + http_client: http_client, + client_id: client_id.into(), + client_secret: client_secret.into(), + redirect_uri: redirect_uri.map(Into::into), + provider: PhantomData, + } + } + + /// Returns an authorization endpoint URI to direct the user to. + /// + /// See [RFC 6749, section 3.1](http://tools.ietf.org/html/rfc6749#section-3.1). + /// + /// # Examples + /// + /// ``` + /// use inth_oauth2::Client; + /// use inth_oauth2::provider::Google; + /// + /// let client = Client::::new( + /// Default::default(), + /// "CLIENT_ID", + /// "CLIENT_SECRET", + /// Some("urn:ietf:wg:oauth:2.0:oob") + /// ); + /// + /// let auth_uri = client.auth_uri( + /// Some("https://www.googleapis.com/auth/userinfo.email"), + /// None + /// ); + /// ``` + pub fn auth_uri(&self, scope: Option<&str>, state: Option<&str>) -> Result + { + let mut uri = try!(Url::parse(P::auth_uri())); + + let mut query_pairs = vec![ + ("response_type", "code"), + ("client_id", &self.client_id), + ]; + if let Some(ref redirect_uri) = self.redirect_uri { + query_pairs.push(("redirect_uri", redirect_uri)); + } + if let Some(scope) = scope { + query_pairs.push(("scope", scope)); + } + if let Some(state) = state { + query_pairs.push(("state", state)); + } + + uri.set_query_from_pairs(query_pairs.iter()); + + Ok(uri.serialize()) + } + + fn post_token(&self, body_pairs: Vec<(&str, &str)>) -> Result { + let body = form_urlencoded::serialize(body_pairs); + let auth_header = header::Authorization( + header::Basic { + username: self.client_id.clone(), + password: Some(self.client_secret.clone()), + } + ); + let accept_header = header::Accept(vec![ + header::qitem(mime::Mime(mime::TopLevel::Application, mime::SubLevel::Json, vec![])), + ]); + + let request = self.http_client.post(P::token_uri()) + .header(auth_header) + .header(accept_header) + .header(header::ContentType::form_url_encoded()) + .body(&body); + + let mut response = try!(request.send()); + let json = try!(Json::from_reader(&mut response)); + + let error = OAuth2Error::from_response(&json); + + if let Ok(error) = error { + Err(ClientError::from(error)) + } else { + Ok(json) + } + } + + /// Requests an access token using an authorization code. + /// + /// See [RFC 6749, section 4.1.3](http://tools.ietf.org/html/rfc6749#section-4.1.3). + pub fn request_token(&self, code: &str) -> Result { + let mut body_pairs = vec![ + ("grant_type", "authorization_code"), + ("code", code), + ]; + if let Some(ref redirect_uri) = self.redirect_uri { + body_pairs.push(("redirect_uri", redirect_uri)); + } + + let json = try!(self.post_token(body_pairs)); + let token = try!(P::Token::from_response(&json)); + Ok(token) + } +} + +impl Client

where P::Token: Token { + /// Refreshes an access token. + /// + /// See [RFC 6749, section 6](http://tools.ietf.org/html/rfc6749#section-6). + pub fn refresh_token( + &self, + token: P::Token, + scope: Option<&str> + ) -> Result { + let mut body_pairs = vec![ + ("grant_type", "refresh_token"), + ("refresh_token", token.lifetime().refresh_token()), + ]; + if let Some(scope) = scope { + body_pairs.push(("scope", scope)); + } + + let json = try!(self.post_token(body_pairs)); + let token = try!(P::Token::from_response_inherit(&json, &token)); + Ok(token) + } + + /// Ensures an access token is valid by refreshing it if necessary. + pub fn ensure_token(&self, token: P::Token) -> Result { + if token.lifetime().expired() { + self.refresh_token(token, None) + } else { + Ok(token) + } + } +} + +#[cfg(test)] +mod tests { + use token::{Bearer, Static}; + use provider::Provider; + use super::Client; + + struct Test; + impl Provider for Test { + type Lifetime = Static; + type Token = Bearer; + fn auth_uri() -> &'static str { "http://example.com/oauth2/auth" } + fn token_uri() -> &'static str { "http://example.com/oauth2/token" } + } + + #[test] + fn auth_uri() { + let client = Client::::new(Default::default(), "foo", "bar", None); + assert_eq!( + "http://example.com/oauth2/auth?response_type=code&client_id=foo", + client.auth_uri(None, None).unwrap() + ); + } + + #[test] + fn auth_uri_with_redirect_uri() { + let client = Client::::new( + Default::default(), + "foo", + "bar", + Some("http://example.com/oauth2/callback") + ); + assert_eq!( + "http://example.com/oauth2/auth?response_type=code&client_id=foo&redirect_uri=http%3A%2F%2Fexample.com%2Foauth2%2Fcallback", + client.auth_uri(None, None).unwrap() + ); + } + + #[test] + fn auth_uri_with_scope() { + let client = Client::::new(Default::default(), "foo", "bar", None); + assert_eq!( + "http://example.com/oauth2/auth?response_type=code&client_id=foo&scope=baz", + client.auth_uri(Some("baz"), None).unwrap() + ); + } + + #[test] + fn auth_uri_with_state() { + let client = Client::::new(Default::default(), "foo", "bar", None); + assert_eq!( + "http://example.com/oauth2/auth?response_type=code&client_id=foo&state=baz", + client.auth_uri(None, Some("baz")).unwrap() + ); + } +} diff --git a/src/client/response.rs b/src/client/response.rs new file mode 100644 index 0000000..47a54cc --- /dev/null +++ b/src/client/response.rs @@ -0,0 +1,95 @@ +//! Response parsing. + +use std::error::Error; +use std::fmt; + +use rustc_serialize::json::{self, Json}; + +/// Response parsing. +pub trait FromResponse: Sized { + /// Parse a JSON response. + fn from_response(json: &Json) -> Result; + + /// Parse a JSON response, inheriting missing values from the previous instance. + /// + /// Necessary for parsing refresh token responses where the absence of a new refresh token + /// implies that the previous refresh token is still valid. + #[allow(unused_variables)] + fn from_response_inherit(json: &Json, prev: &Self) -> Result { + FromResponse::from_response(json) + } +} + +/// Response parse errors. +#[derive(Debug, PartialEq, Eq)] +pub enum ParseError { + /// Expected response to be of type. + ExpectedType(&'static str), + + /// Expected field to be of type. + ExpectedFieldType(&'static str, &'static str), + + /// Expected field to equal value. + ExpectedFieldValue(&'static str, &'static str), + + /// Expected field to not be present. + UnexpectedField(&'static str), +} + +impl fmt::Display for ParseError { + fn fmt(&self, f: &mut fmt::Formatter) -> Result<(), fmt::Error> { + match *self { + ParseError::ExpectedType(t) => + write!(f, "Expected response of type {}", t), + ParseError::ExpectedFieldType(k, t) => + write!(f, "Expected field {} of type {}", k, t), + ParseError::ExpectedFieldValue(k, v) => + write!(f, "Expected field {} to equal {}", k, v), + ParseError::UnexpectedField(k) => + write!(f, "Unexpected field {}", k), + } + } +} + +impl Error for ParseError { + fn description(&self) -> &str { "response parse error" } +} + +/// JSON helper for response parsing. +#[derive(Debug)] +pub struct JsonHelper<'a>(pub &'a Json); + +impl<'a> JsonHelper<'a> { + /// Returns self as a `JsonObjectHelper` or fails with `ParseError::ExpectedType`. + pub fn as_object(&self) -> Result, ParseError>{ + self.0.as_object() + .ok_or(ParseError::ExpectedType("object")) + .map(|o| JsonObjectHelper(o)) + } +} + +/// JSON object helper for response parsing. +#[derive(Debug)] +pub struct JsonObjectHelper<'a>(pub &'a json::Object); + +impl<'a> JsonObjectHelper<'a> { + /// Gets a field as a string or returns `None`. + pub fn get_string_option(&self, key: &'static str) -> Option<&'a str> { + self.0.get(key).and_then(Json::as_string) + } + + /// Gets a field as a string or fails with `ParseError::ExpectedFieldType`. + pub fn get_string(&self, key: &'static str) -> Result<&'a str, ParseError> { + self.get_string_option(key).ok_or(ParseError::ExpectedFieldType(key, "string")) + } + + /// Gets a field as an i64 or returns `None`. + pub fn get_i64_option(&self, key: &'static str) -> Option { + self.0.get(key).and_then(Json::as_i64) + } + + /// Gets a field as an i64 or fails with `ParseError::ExpectedFieldType`. + pub fn get_i64(&self, key: &'static str) -> Result { + self.get_i64_option(key).ok_or(ParseError::ExpectedFieldType(key, "i64")) + } +} diff --git a/src/error.rs b/src/error.rs index b05e7c9..7d65502 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,13 +1,16 @@ -use std::{error, fmt, io, result}; +//! Errors. -use hyper; -use rustc_serialize::json; -use url; +use std::error::Error; +use std::fmt; + +use rustc_serialize::json::Json; + +use client::response::{FromResponse, ParseError, JsonHelper}; /// OAuth 2.0 error codes. /// -/// See [RFC6749 section 5.2](http://tools.ietf.org/html/rfc6749#section-5.2). -#[derive(Debug, Clone)] +/// See [RFC 6749, section 5.2](http://tools.ietf.org/html/rfc6749#section-5.2). +#[derive(Debug, PartialEq, Eq)] pub enum OAuth2ErrorCode { /// The request is missing a required parameter, includes an unsupported parameter value (other /// than grant type), repeats a parameter, includes multiple credentials, utilizes more than @@ -33,14 +36,28 @@ pub enum OAuth2ErrorCode { /// resource owner. InvalidScope, - /// An unrecognized error code, not defined in RFC6749. + /// An unrecognized error code, not defined in RFC 6749. Unrecognized(String), } +impl<'a> From<&'a str> for OAuth2ErrorCode { + fn from(s: &str) -> OAuth2ErrorCode { + match s { + "invalid_request" => OAuth2ErrorCode::InvalidRequest, + "invalid_client" => OAuth2ErrorCode::InvalidClient, + "invalid_grant" => OAuth2ErrorCode::InvalidGrant, + "unauthorized_client" => OAuth2ErrorCode::UnauthorizedClient, + "unsupported_grant_type" => OAuth2ErrorCode::UnsupportedGrantType, + "invalid_scope" => OAuth2ErrorCode::InvalidScope, + s => OAuth2ErrorCode::Unrecognized(s.to_string()), + } + } +} + /// OAuth 2.0 error. /// -/// See [RFC6749 section 5.2](http://tools.ietf.org/html/rfc6749#section-5.2). -#[derive(Debug, Clone)] +/// See [RFC 6749, section 5.2](http://tools.ietf.org/html/rfc6749#section-5.2). +#[derive(Debug, PartialEq, Eq)] pub struct OAuth2Error { /// Error code. pub code: OAuth2ErrorCode, @@ -53,7 +70,7 @@ pub struct OAuth2Error { } impl fmt::Display for OAuth2Error { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + fn fmt(&self, f: &mut fmt::Formatter) -> Result<(), fmt::Error> { try!(write!(f, "{:?}", self.code)); if let Some(ref description) = self.description { try!(write!(f, ": {}", description)); @@ -65,71 +82,81 @@ impl fmt::Display for OAuth2Error { } } -impl error::Error for OAuth2Error { - fn description(&self) -> &str { - "OAuth2 API error" +impl Error for OAuth2Error { + fn description(&self) -> &str { "OAuth 2.0 API error" } +} + +impl FromResponse for OAuth2Error { + fn from_response(json: &Json) -> Result { + let obj = try!(JsonHelper(json).as_object()); + + let code = try!(obj.get_string("error")); + let description = obj.get_string_option("error_description"); + let uri = obj.get_string_option("error_uri"); + + Ok(OAuth2Error { + code: code.into(), + description: description.map(Into::into), + uri: uri.map(Into::into), + }) } } -/// Errors that can occur during authentication flow. -#[derive(Debug)] -pub enum Error { - Io(io::Error), - Url(url::ParseError), - Hyper(hyper::Error), - Json(json::DecoderError), - OAuth2(OAuth2Error), -} +#[cfg(test)] +mod tests { + use rustc_serialize::json::Json; -/// Result type returned from authentication flow methods. -pub type Result = result::Result; + use client::response::{FromResponse, ParseError}; + use super::{OAuth2Error, OAuth2ErrorCode}; -impl fmt::Display for Error { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - match *self { - Error::Io(ref err) => write!(f, "{}", err), - Error::Url(ref err) => write!(f, "{}", err), - Error::Hyper(ref err) => write!(f, "{}", err), - Error::Json(ref err) => write!(f, "{}", err), - Error::OAuth2(ref err) => write!(f, "{}", err), - } - } -} - -impl error::Error for Error { - fn description(&self) -> &str { - match *self { - Error::Io(_) => "OAuth2 IO error", - Error::Url(_) => "OAuth2 URL error", - Error::Hyper(_) => "OAuth2 Hyper error", - Error::Json(_) => "OAuth2 JSON error", - Error::OAuth2(_) => "OAuth2 API error", - } + #[test] + fn from_response_empty() { + let json = Json::from_str("{}").unwrap(); + assert_eq!( + ParseError::ExpectedFieldType("error", "string"), + OAuth2Error::from_response(&json).unwrap_err() + ); } - fn cause(&self) -> Option<&error::Error> { - match *self { - Error::Io(ref err) => Some(err), - Error::Url(ref err) => Some(err), - Error::Hyper(ref err) => Some(err), - Error::Json(ref err) => Some(err), - Error::OAuth2(ref err) => Some(err), - } + #[test] + fn from_response() { + let json = Json::from_str(r#"{"error":"invalid_request"}"#).unwrap(); + assert_eq!( + OAuth2Error { + code: OAuth2ErrorCode::InvalidRequest, + description: None, + uri: None, + }, + OAuth2Error::from_response(&json).unwrap() + ); + } + + #[test] + fn from_response_with_description() { + let json = Json::from_str(r#"{"error":"invalid_request","error_description":"foo"}"#) + .unwrap(); + assert_eq!( + OAuth2Error { + code: OAuth2ErrorCode::InvalidRequest, + description: Some(String::from("foo")), + uri: None, + }, + OAuth2Error::from_response(&json).unwrap() + ); + } + + #[test] + fn from_response_with_uri() { + let json = Json::from_str( + r#"{"error":"invalid_request","error_uri":"http://example.com"}"# + ).unwrap(); + assert_eq!( + OAuth2Error { + code: OAuth2ErrorCode::InvalidRequest, + description: None, + uri: Some(String::from("http://example.com")), + }, + OAuth2Error::from_response(&json).unwrap() + ); } } - -macro_rules! impl_from { - ($v:path, $t:ty) => { - impl From<$t> for Error { - fn from(err: $t) -> Error { - $v(err) - } - } - } -} - -impl_from!(Error::Io, io::Error); -impl_from!(Error::Url, url::ParseError); -impl_from!(Error::Hyper, hyper::Error); -impl_from!(Error::Json, json::DecoderError); -impl_from!(Error::OAuth2, OAuth2Error); diff --git a/src/lib.rs b/src/lib.rs index b91533b..67a80ca 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,8 +1,8 @@ -//! # "It's not that hard" OAuth2 Client +//! # "It's not that hard" OAuth 2.0 //! //! OAuth 2.0 really isn't that hard, you know? //! -//! Implementation of [RFC6749](http://tools.ietf.org/html/rfc6749). +//! Implementation of [RFC 6749](http://tools.ietf.org/html/rfc6749). //! //! `inth_oauth2` is on [Crates.io][crate] and [GitHub][github]. //! @@ -11,126 +11,139 @@ //! //! ## Providers //! -//! `inth_oauth2` supports the following OAuth 2.0 providers: +//! Support for the following OAuth 2.0 providers is included: //! -//! - `Google` -//! - `GitHub` -//! - `Imgur` +//! - Google +//! - GitHub +//! - Imgur //! -//! Support for others can be added by implementing the `Provider` trait. +//! Support for other providers can be added by implementing the `Provider` trait. +//! +//! ## Token types +//! +//! The only supported token type is Bearer. Support for others can be added by implementing the +//! `Token` trait. //! //! ## Examples //! //! ### Creating a client //! //! ``` -//! use inth_oauth2::{Client, Google}; +//! use inth_oauth2::Client; +//! use inth_oauth2::provider::Google; //! //! let client = Client::::new( //! Default::default(), -//! "CLIENT_ID", -//! "CLIENT_SECRET", -//! Some("REDIRECT_URI") +//! "client_id", +//! "client_secret", +//! Some("redirect_uri") //! ); //! ``` //! //! ### Constructing an authorization URI //! //! ``` -//! # use inth_oauth2::{Client, Google}; +//! # use inth_oauth2::Client; +//! # use inth_oauth2::provider::Google; //! # let client = Client::::new(Default::default(), "", "", None); //! let auth_uri = client.auth_uri(Some("scope"), Some("state")).unwrap(); +//! println!("Authorize the application by clicking on the link: {}", auth_uri); //! ``` //! -//! Direct the user to an authorization URI to have them authorize your application. -//! //! ### Requesting an access token //! -//! Request an access token using a code obtained from the redirect of the authorization URI. -//! //! ```no_run -//! # use inth_oauth2::{Client, Google}; +//! use std::io; +//! use inth_oauth2::{Client, Token}; +//! # use inth_oauth2::provider::Google; //! # let client = Client::::new(Default::default(), "", "", None); -//! # let code = String::new(); -//! let token_pair = client.request_token(&code).unwrap(); -//! println!("{}", token_pair.access.token); +//! +//! let mut code = String::new(); +//! io::stdin().read_line(&mut code).unwrap(); +//! +//! let token = client.request_token(code.trim()).unwrap(); +//! println!("{}", token.access_token()); //! ``` //! //! ### Refreshing an access token //! //! ```no_run -//! # use inth_oauth2::{Client, Google}; +//! # use inth_oauth2::Client; +//! # use inth_oauth2::provider::Google; //! # let client = Client::::new(Default::default(), "", "", None); -//! # let mut token_pair = client.request_token("").unwrap(); -//! if token_pair.expired() { -//! if let Some(refresh) = token_pair.refresh { -//! token_pair = client.refresh_token(refresh, None).unwrap(); -//! } -//! } +//! # let token = client.request_token("").unwrap(); +//! let token = client.refresh_token(token, None).unwrap(); +//! ``` +//! +//! ### Ensuring an access token is still valid +//! +//! ```no_run +//! # use inth_oauth2::Client; +//! # use inth_oauth2::provider::Google; +//! # let client = Client::::new(Default::default(), "", "", None); +//! # let mut token = client.request_token("").unwrap(); +//! // Refresh token only if it has expired. +//! token = client.ensure_token(token).unwrap(); //! ``` //! //! ### Using bearer access tokens //! -//! If the obtained token is of the `Bearer` type, a Hyper `Authorization` header can be created -//! from it. +//! Bearer tokens can be converted to Hyper headers. //! //! ```no_run //! # extern crate hyper; //! # extern crate inth_oauth2; +//! # use inth_oauth2::Client; +//! # use inth_oauth2::provider::Google; +//! use hyper::header::Authorization; +//! //! # fn main() { -//! # use inth_oauth2::{Client, Google}; //! # let client = Client::::new(Default::default(), "", "", None); -//! # let mut token_pair = client.request_token("").unwrap(); +//! # let token = client.request_token("").unwrap(); //! let client = hyper::Client::new(); -//! let res = client.get("https://example.com/resource") -//! .header(token_pair.to_bearer_header().unwrap()) -//! .send() -//! .unwrap(); +//! let request = client.get("https://example.com/resource") +//! .header(Into::>::into(&token)); //! # } //! ``` //! //! ### Persisting tokens //! -//! `TokenPair` implements `Encodable` and `Decodable` from `rustc_serialize`, so can be persisted -//! as JSON. +//! All token types implement `Encodable` and `Decodable` from `rustc_serialize`. //! -//! ``` +//! ```no_run //! # extern crate inth_oauth2; //! # extern crate rustc_serialize; -//! # extern crate chrono; -//! use inth_oauth2::{TokenPair, AccessTokenType, AccessToken, RefreshToken}; +//! # use inth_oauth2::Client; +//! # use inth_oauth2::provider::Google; //! use rustc_serialize::json; -//! # use chrono::{UTC, Timelike}; //! # fn main() { -//! # let token_pair = TokenPair { -//! # access: AccessToken { -//! # token: String::from("AAAAAAAA"), -//! # token_type: AccessTokenType::Bearer, -//! # expires: Some(UTC::now().with_nanosecond(0).unwrap()), -//! # scope: None, -//! # }, -//! # refresh: Some(RefreshToken { token: String::from("BBBBBBBB") }), -//! # }; -//! -//! let json = json::encode(&token_pair).unwrap(); -//! let decoded: TokenPair = json::decode(&json).unwrap(); -//! assert_eq!(token_pair, decoded); +//! # let client = Client::::new(Default::default(), "", "", None); +//! # let token = client.request_token("").unwrap(); +//! let json = json::encode(&token).unwrap(); //! # } //! ``` +#![warn( + missing_docs, + missing_debug_implementations, + trivial_casts, + trivial_numeric_casts, + unused_extern_crates, + unused_import_braces, + unused_qualifications, + unused_results, + variant_size_differences +)] + extern crate chrono; extern crate hyper; extern crate rustc_serialize; extern crate url; -pub use client::Client; -pub mod client; +pub use token::{Token, Lifetime}; +pub use client::{Client, ClientError}; -pub use provider::{Provider, Google, GitHub, Imgur}; -pub mod provider; - -pub use token::{TokenPair, AccessTokenType, AccessToken, RefreshToken}; pub mod token; - -pub use error::{Error, Result}; +pub mod provider; pub mod error; +pub mod client; diff --git a/src/provider.rs b/src/provider.rs index f4e6141..ca86a57 100644 --- a/src/provider.rs +++ b/src/provider.rs @@ -1,9 +1,23 @@ -/// An OAuth 2.0 provider. +//! Providers. + +use token::{Token, Lifetime, Bearer, Static, Expiring}; + +/// OAuth 2.0 providers. pub trait Provider { + /// The lifetime of tokens issued by the provider. + type Lifetime: Lifetime; + + /// The type of token issued by the provider. + type Token: Token; + /// The authorization endpoint URI. + /// + /// See [RFC 6749, section 3.1](http://tools.ietf.org/html/rfc6749#section-3.1). fn auth_uri() -> &'static str; /// The token endpoint URI. + /// + /// See [RFC 6749, section 3.2](http://tools.ietf.org/html/rfc6749#section-3.2). fn token_uri() -> &'static str; } @@ -11,17 +25,23 @@ pub trait Provider { /// /// See [Using OAuth 2.0 to Access Google /// APIs](https://developers.google.com/identity/protocols/OAuth2). +#[derive(Debug)] pub struct Google; impl Provider for Google { - fn auth_uri() -> &'static str { "https://accounts.google.com/o/oauth2/auth" } - fn token_uri() -> &'static str { "https://accounts.google.com/o/oauth2/token" } + type Lifetime = Expiring; + type Token = Bearer; + fn auth_uri() -> &'static str { "https://accounts.google.com/o/oauth2/v2/auth" } + fn token_uri() -> &'static str { "https://www.googleapis.com/oauth2/v4/token" } } /// GitHub OAuth 2.0 provider. /// -/// See [OAuth, GitHub API](https://developer.github.com/v3/oauth/). +/// See [OAuth, GitHub Developer Guide](https://developer.github.com/v3/oauth/). +#[derive(Debug)] pub struct GitHub; impl Provider for GitHub { + type Lifetime = Static; + type Token = Bearer; fn auth_uri() -> &'static str { "https://github.com/login/oauth/authorize" } fn token_uri() -> &'static str { "https://github.com/login/oauth/access_token" } } @@ -29,8 +49,11 @@ impl Provider for GitHub { /// Imgur OAuth 2.0 provider. /// /// See [OAuth 2.0, Imgur](https://api.imgur.com/oauth2). +#[derive(Debug)] pub struct Imgur; impl Provider for Imgur { + type Lifetime = Expiring; + type Token = Bearer; fn auth_uri() -> &'static str { "https://api.imgur.com/oauth2/authorize" } fn token_uri() -> &'static str { "https://api.imgur.com/oauth2/token" } } diff --git a/src/token.rs b/src/token.rs deleted file mode 100644 index 9cca8f3..0000000 --- a/src/token.rs +++ /dev/null @@ -1,122 +0,0 @@ -use std::ops::Deref; - -use chrono::{DateTime, UTC, TimeZone}; -use hyper::header; -use rustc_serialize::{Encodable, Encoder, Decodable, Decoder}; - -/// OAuth 2.0 access token and refresh token pair. -#[derive(Debug, Clone, PartialEq, Eq, RustcEncodable, RustcDecodable)] -pub struct TokenPair { - /// The access token. - pub access: AccessToken, - /// The refresh token. - pub refresh: Option, -} - -/// OAuth 2.0 access token type. -/// -/// See [RFC6749 section 7.1](http://tools.ietf.org/html/rfc6749#section-7.1). -#[derive(Debug, Clone, PartialEq, Eq, RustcEncodable, RustcDecodable)] -pub enum AccessTokenType { - /// The bearer token type. - /// - /// See [RFC6750](http://tools.ietf.org/html/rfc6750). - Bearer, - - /// An unrecognized token type. - Unrecognized(String), -} - -/// OAuth 2.0 access token. -/// -/// See [RFC6749 section 5](http://tools.ietf.org/html/rfc6749#section-5). -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct AccessToken { - /// The access token issued by the authorization server. - pub token: String, - - /// The type of the token issued. - pub token_type: AccessTokenType, - - /// The expiry time of the access token. - pub expires: Option>, - - /// The scope of the access token. - pub scope: Option, -} - -/// OAuth 2.0 refresh token. -/// -/// See [RFC6749 section 1.5](http://tools.ietf.org/html/rfc6749#section-1.5). -#[derive(Debug, Clone, PartialEq, Eq, RustcEncodable, RustcDecodable)] -pub struct RefreshToken { - /// The refresh token issued by the authorization server. - pub token: String, -} - -impl AccessToken { - /// Returns true if token is expired. - pub fn expired(&self) -> bool { - self.expires.map_or(false, |dt| dt < UTC::now()) - } - - /// Creates an Authorization header. - /// - /// Returns `None` if `token_type` is not `Bearer`. - pub fn to_bearer_header(&self) -> Option> { - if self.token_type == AccessTokenType::Bearer { - Some(header::Authorization(header::Bearer { token: self.token.clone() })) - } else { - None - } - } -} - -impl Deref for TokenPair { - type Target = AccessToken; - - fn deref(&self) -> &AccessToken { - &self.access - } -} - -#[derive(RustcEncodable, RustcDecodable)] -struct SerializableAccessToken { - token: String, - token_type: AccessTokenType, - expires: Option, - scope: Option, -} - -impl SerializableAccessToken { - fn from_access_token(access: &AccessToken) -> Self { - SerializableAccessToken { - token: access.token.clone(), - token_type: access.token_type.clone(), - expires: access.expires.as_ref().map(DateTime::timestamp), - scope: access.scope.clone(), - } - } - - fn into_access_token(self) -> AccessToken { - AccessToken { - token: self.token, - token_type: self.token_type, - expires: self.expires.map(|t| UTC.timestamp(t, 0)), - scope: self.scope, - } - } -} - -impl Encodable for AccessToken { - fn encode(&self, s: &mut S) -> Result<(), S::Error> { - SerializableAccessToken::from_access_token(self).encode(s) - } -} - -impl Decodable for AccessToken { - fn decode(d: &mut D) -> Result { - SerializableAccessToken::decode(d) - .map(SerializableAccessToken::into_access_token) - } -} diff --git a/src/token/bearer.rs b/src/token/bearer.rs new file mode 100644 index 0000000..0ff675d --- /dev/null +++ b/src/token/bearer.rs @@ -0,0 +1,166 @@ +use hyper::header; +use rustc_serialize::json::Json; + +use super::{Token, Lifetime}; +use client::response::{FromResponse, ParseError, JsonHelper}; + +/// The bearer token type. +/// +/// See [RFC 6750](http://tools.ietf.org/html/rfc6750). +#[derive(Debug, PartialEq, Eq, RustcEncodable, RustcDecodable)] +pub struct Bearer { + access_token: String, + scope: Option, + lifetime: L, +} + +impl Token for Bearer { + fn access_token(&self) -> &str { &self.access_token } + fn scope(&self) -> Option<&str> { self.scope.as_ref().map(|s| &s[..]) } + fn lifetime(&self) -> &L { &self.lifetime } +} + +impl<'a, L: Lifetime> Into> for &'a Bearer { + fn into(self) -> header::Authorization { + header::Authorization(header::Bearer { token: self.access_token.clone() }) + } +} + +impl Bearer { + fn from_response_and_lifetime(json: &Json, lifetime: L) -> Result { + let obj = try!(JsonHelper(json).as_object()); + + let token_type = try!(obj.get_string("token_type")); + if token_type != "Bearer" && token_type != "bearer" { + return Err(ParseError::ExpectedFieldValue("token_type", "Bearer")); + } + + let access_token = try!(obj.get_string("access_token")); + let scope = obj.get_string_option("scope"); + + Ok(Bearer { + access_token: access_token.into(), + scope: scope.map(Into::into), + lifetime: lifetime, + }) + } +} + +impl FromResponse for Bearer { + fn from_response(json: &Json) -> Result { + let lifetime = try!(FromResponse::from_response(json)); + Bearer::from_response_and_lifetime(json, lifetime) + } + + fn from_response_inherit(json: &Json, prev: &Self) -> Result { + let lifetime = try!(FromResponse::from_response_inherit(json, &prev.lifetime)); + Bearer::from_response_and_lifetime(json, lifetime) + } +} + +#[cfg(test)] +mod tests { + use chrono::{UTC, Duration}; + use rustc_serialize::json::Json; + + use client::response::{FromResponse, ParseError}; + use token::{Static, Expiring}; + use super::Bearer; + + #[test] + fn from_response_with_invalid_token_type() { + let json = Json::from_str(r#"{"token_type":"MAC","access_token":"aaaaaaaa"}"#).unwrap(); + assert_eq!( + ParseError::ExpectedFieldValue("token_type", "Bearer"), + Bearer::::from_response(&json).unwrap_err() + ); + } + + #[test] + fn from_response_capital_b() { + let json = Json::from_str(r#"{"token_type":"Bearer","access_token":"aaaaaaaa"}"#).unwrap(); + assert_eq!( + Bearer { + access_token: String::from("aaaaaaaa"), + scope: None, + lifetime: Static, + }, + Bearer::::from_response(&json).unwrap() + ); + } + + #[test] + fn from_response_little_b() { + let json = Json::from_str(r#"{"token_type":"bearer","access_token":"aaaaaaaa"}"#).unwrap(); + assert_eq!( + Bearer { + access_token: String::from("aaaaaaaa"), + scope: None, + lifetime: Static, + }, + Bearer::::from_response(&json).unwrap() + ); + } + + #[test] + fn from_response_with_scope() { + let json = Json::from_str( + r#"{"token_type":"Bearer","access_token":"aaaaaaaa","scope":"foo"}"# + ).unwrap(); + assert_eq!( + Bearer { + access_token: String::from("aaaaaaaa"), + scope: Some(String::from("foo")), + lifetime: Static, + }, + Bearer::::from_response(&json).unwrap() + ); + } + + #[test] + fn from_response_expiring() { + let json = Json::from_str(r#" + { + "token_type":"Bearer", + "access_token":"aaaaaaaa", + "expires_in":3600, + "refresh_token":"bbbbbbbb" + } + "#).unwrap(); + let bearer = Bearer::::from_response(&json).unwrap(); + assert_eq!("aaaaaaaa", bearer.access_token); + assert_eq!(None, bearer.scope); + let expiring = bearer.lifetime; + assert_eq!("bbbbbbbb", expiring.refresh_token()); + assert!(expiring.expires() > &UTC::now()); + assert!(expiring.expires() <= &(UTC::now() + Duration::seconds(3600))); + } + + #[test] + fn from_response_inherit_expiring() { + let json = Json::from_str(r#" + { + "token_type":"Bearer", + "access_token":"aaaaaaaa", + "expires_in":3600, + "refresh_token":"bbbbbbbb" + } + "#).unwrap(); + let prev = Bearer::::from_response(&json).unwrap(); + + let json = Json::from_str(r#" + { + "token_type":"Bearer", + "access_token":"cccccccc", + "expires_in":3600 + } + "#).unwrap(); + let bearer = Bearer::::from_response_inherit(&json, &prev).unwrap(); + assert_eq!("cccccccc", bearer.access_token); + assert_eq!(None, bearer.scope); + let expiring = bearer.lifetime; + assert_eq!("bbbbbbbb", expiring.refresh_token()); + assert!(expiring.expires() > &UTC::now()); + assert!(expiring.expires() <= &(UTC::now() + Duration::seconds(3600))); + } +} diff --git a/src/token/expiring.rs b/src/token/expiring.rs new file mode 100644 index 0000000..647eaf1 --- /dev/null +++ b/src/token/expiring.rs @@ -0,0 +1,134 @@ +use chrono::{DateTime, UTC, Duration, TimeZone}; +use rustc_serialize::json::Json; +use rustc_serialize::{Encodable, Encoder, Decodable, Decoder}; + +use super::Lifetime; +use client::response::{FromResponse, ParseError, JsonHelper}; + +/// An expiring token. +#[derive(Debug, PartialEq, Eq)] +pub struct Expiring { + refresh_token: String, + expires: DateTime, +} + +impl Expiring { + /// Returns the refresh token. + /// + /// See [RFC 6749, section 1.5](http://tools.ietf.org/html/rfc6749#section-1.5). + pub fn refresh_token(&self) -> &str { &self.refresh_token } + + /// Returns the expiry time of the access token. + pub fn expires(&self) -> &DateTime { &self.expires } +} + +impl Lifetime for Expiring { + fn expired(&self) -> bool { self.expires < UTC::now() } +} + +impl FromResponse for Expiring { + fn from_response(json: &Json) -> Result { + let obj = try!(JsonHelper(json).as_object()); + + let refresh_token = try!(obj.get_string("refresh_token")); + let expires_in = try!(obj.get_i64("expires_in")); + + Ok(Expiring { + refresh_token: refresh_token.into(), + expires: UTC::now() + Duration::seconds(expires_in), + }) + } + + fn from_response_inherit(json: &Json, prev: &Self) -> Result { + let obj = try!(JsonHelper(json).as_object()); + + let refresh_token = try! { + obj.get_string("refresh_token") + .or(Ok(&prev.refresh_token)) + }; + let expires_in = try!(obj.get_i64("expires_in")); + + Ok(Expiring { + refresh_token: refresh_token.into(), + expires: UTC::now() + Duration::seconds(expires_in), + }) + } +} + +#[derive(RustcEncodable, RustcDecodable)] +struct Serializable { + refresh_token: String, + expires: i64, +} + +impl<'a> From<&'a Expiring> for Serializable { + fn from(expiring: &Expiring) -> Self { + Serializable { + refresh_token: expiring.refresh_token.clone(), + expires: expiring.expires.timestamp(), + } + } +} + +impl Into for Serializable { + fn into(self) -> Expiring { + Expiring { + refresh_token: self.refresh_token, + expires: UTC.timestamp(self.expires, 0), + } + } +} + +impl Encodable for Expiring { + fn encode(&self, s: &mut S) -> Result<(), S::Error> { + Serializable::from(self).encode(s) + } +} + +impl Decodable for Expiring { + fn decode(d: &mut D) -> Result { + Serializable::decode(d).map(Into::into) + } +} + +#[cfg(test)] +mod tests { + use chrono::{UTC, Duration, Timelike}; + use rustc_serialize::json::{self, Json}; + + use client::response::FromResponse; + use super::Expiring; + + #[test] + fn from_response() { + let json = Json::from_str(r#"{"refresh_token":"aaaaaaaa","expires_in":3600}"#).unwrap(); + let expiring = Expiring::from_response(&json).unwrap(); + assert_eq!("aaaaaaaa", expiring.refresh_token); + assert!(expiring.expires > UTC::now()); + assert!(expiring.expires <= UTC::now() + Duration::seconds(3600)); + } + + #[test] + fn from_response_inherit() { + let json = Json::from_str(r#"{"expires_in":3600}"#).unwrap(); + let prev = Expiring { + refresh_token: String::from("aaaaaaaa"), + expires: UTC::now(), + }; + let expiring = Expiring::from_response_inherit(&json, &prev).unwrap(); + assert_eq!("aaaaaaaa", expiring.refresh_token); + assert!(expiring.expires > UTC::now()); + assert!(expiring.expires <= UTC::now() + Duration::seconds(3600)); + } + + #[test] + fn encode_decode() { + let expiring = Expiring { + refresh_token: String::from("foo"), + expires: UTC::now().with_nanosecond(0).unwrap(), + }; + let json = json::encode(&expiring).unwrap(); + let decoded = json::decode(&json).unwrap(); + assert_eq!(expiring, decoded); + } +} diff --git a/src/token/mod.rs b/src/token/mod.rs new file mode 100644 index 0000000..1771a95 --- /dev/null +++ b/src/token/mod.rs @@ -0,0 +1,39 @@ +//! Tokens. +//! +//! Access token types are abstracted through the `Token` trait. See +//! [RFC 6749, section 7.1](http://tools.ietf.org/html/rfc6749#section-7.1). +//! +//! Expiring and non-expiring tokens are abstracted through the `Lifetime` trait. + +use client::response::FromResponse; + +/// OAuth 2.0 tokens. +/// +/// See [RFC 6749, section 5](http://tools.ietf.org/html/rfc6749#section-5). +pub trait Token: FromResponse { + /// Returns the access token. + /// + /// See [RF C6749, section 1.4](http://tools.ietf.org/html/rfc6749#section-1.4). + fn access_token(&self) -> &str; + + /// Returns the scope, if available. + fn scope(&self) -> Option<&str>; + + /// Returns the token lifetime. + fn lifetime(&self) -> &L; +} + +/// OAuth 2.0 token lifetimes. +pub trait Lifetime: FromResponse { + /// Returns true if the access token is no longer valid. + fn expired(&self) -> bool; +} + +pub use self::bearer::Bearer; +mod bearer; + +pub use self::statik::Static; +mod statik; + +pub use self::expiring::Expiring; +mod expiring; diff --git a/src/token/statik.rs b/src/token/statik.rs new file mode 100644 index 0000000..d9ee569 --- /dev/null +++ b/src/token/statik.rs @@ -0,0 +1,45 @@ +use rustc_serialize::json::Json; + +use super::Lifetime; +use client::response::{FromResponse, ParseError, JsonHelper}; + +/// A static, non-expiring token. +#[derive(Debug, PartialEq, Eq, RustcEncodable, RustcDecodable)] +pub struct Static; + +impl Lifetime for Static { + fn expired(&self) -> bool { false } +} + +impl FromResponse for Static { + fn from_response(json: &Json) -> Result { + let obj = try!(JsonHelper(json).as_object()); + if obj.0.contains_key("expires_in") { + return Err(ParseError::UnexpectedField("expires_in")); + } + Ok(Static) + } +} + +#[cfg(test)] +mod tests { + use rustc_serialize::json::Json; + + use client::response::{FromResponse, ParseError}; + use super::Static; + + #[test] + fn from_response() { + let json = Json::from_str("{}").unwrap(); + assert_eq!(Static, Static::from_response(&json).unwrap()); + } + + #[test] + fn from_response_with_expires_in() { + let json = Json::from_str(r#"{"expires_in":3600}"#).unwrap(); + assert_eq!( + ParseError::UnexpectedField("expires_in"), + Static::from_response(&json).unwrap_err() + ); + } +}