diff --git a/Cargo.toml b/Cargo.toml index a41088f..41c7b0c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,14 +12,12 @@ readme = "README.md" [dependencies] base64 = "0.6" biscuit = { git = "https://github.com/Korvox/biscuit" } -error-chain = "0.11" chrono = "0.4" inth-oauth2 = "0.13" reqwest = "0.7" serde = "1" serde_derive = "1" serde_json = "1" -url = "1.5" url_serde = "0.2" validator = "0.6" validator_derive = "0.6" diff --git a/src/client.rs b/src/client.rs index 4d7ac5d..6a366df 100644 --- a/src/client.rs +++ b/src/client.rs @@ -1,16 +1,20 @@ -use biscuit::Empty; -use biscuit::jwk::JWKSet; +use biscuit::{Empty, SingleOrMultiple}; +use biscuit::jwa::{self, SignatureAlgorithm}; +use biscuit::jwk::{AlgorithmParameters, JWKSet}; +use biscuit::jws::{Compact, Secret}; use chrono::{Duration, Utc}; use inth_oauth2; -use url::Url; +use reqwest::{self, Url}; use url_serde; use validator::Validate; use std::collections::HashSet; use discovery::{self, Config, Discovered}; -use error::{ErrorKind, Result}; -use token::{Expiring, Token}; +use error::{self, Decode, Error, Expiry, Mismatch, Missing, Validation}; +use token::{Claims, Expiring, Token}; + +type IdToken = Compact; #[derive(Deserialize)] pub struct Params { @@ -119,6 +123,17 @@ pub struct Address { pub country: Option, } +// Common pattern in the Client::decode function when dealing with mismatched keys +macro_rules! wrong_key { + ($expected:expr, $actual:expr) => ( + Err(error::Jose::WrongKeyType { + expected: format!("{:?}", $expected), + actual: format!("{:?}", $actual) + }.into() + ) + ) +} + pub struct Client { oauth: inth_oauth2::Client, jwks: JWKSet, @@ -126,9 +141,10 @@ pub struct Client { impl Client { /// Constructs a client from an issuer url and client parameters via discovery - pub fn discover(issuer: &Url, params: Params) -> Result { - let config = discovery::discover(issuer)?; - let jwks = discovery::jwks(&config.jwks_uri)?; + pub fn discover(issuer: Url, params: Params) -> Result { + let client = reqwest::Client::new()?; + let config = discovery::discover(&client, issuer)?; + let jwks = discovery::jwks(&client, config.jwks_uri.clone())?; let provider = Discovered { config }; Ok(Self::new(provider, params, jwks)) } @@ -146,6 +162,13 @@ impl Client { } } + pub fn request_token(&self, + client: &reqwest::Client, + auth_code: &str, + ) -> Result, error::Oauth> { + self.oauth.request_token(client, auth_code) + } + /// A reference to the config document of the provider obtained via discovery pub fn config(&self) -> &Config { &self.oauth.provider.config @@ -154,9 +177,9 @@ impl Client { /// Constructs the auth_url to redirect a client to the provider. Options are... optional. Use /// them as needed. Keep the Options struct around for authentication, or at least the nonce /// and max_age parameter - we need to verify they stay the same and validate if you used them. - pub fn auth_url(&self, scope: &str, state: &str, options: &Options) -> Result{ + pub fn auth_url(&self, scope: &str, state: &str, options: &Options) -> Result{ if !scope.contains("openid") { - return Err(ErrorKind::MissingOpenidScope.into()) + unimplemented!() } let mut url = self.oauth.auth_uri(Some(&scope), Some(state))?; { @@ -193,10 +216,146 @@ impl Client { Ok(url) } - /// Given an auth_code, request the token, validate it, and if userinfo_endpoint exists - /// request that and give the response + /// Given an auth_code and auth options, request the token, decode, and validate it. pub fn authenticate(&self, auth_code: &str, options: &Options - ) -> Result<(Token, Option)> { - unimplemented!() + ) -> Result, Error> { + let client = reqwest::Client::new()?; + let mut token = self.request_token(&client, auth_code)?; + self.decode_token(&mut token.id_token)?; + self.validate_token(&token.id_token, + options.nonce.as_ref().map(String::as_ref), + options.max_age.as_ref())?; + Ok(token) + } + + pub fn decode_token(&self, token: &mut IdToken) -> Result<(), Error> { + // This is an early escape if the token is already decoded + token.encoded()?; + + let header = token.unverified_header()?; + // If there is more than one key, the token MUST have a key id + let key = if self.jwks.keys.len() > 1 { + let token_kid = header.registered.key_id.ok_or(Decode::MissingKid)?; + self.jwks.find(&token_kid).ok_or(Decode::MissingKey)? + } else { + self.jwks.keys.first().as_ref().ok_or(Decode::EmptySet)? + }; + + if let Some(alg) = key.common.algorithm.as_ref() { + if let &jwa::Algorithm::Signature(alg) = alg { + if header.registered.algorithm != alg { + return wrong_key!(alg, header.registered.algorithm); + } + } else { + return wrong_key!(SignatureAlgorithm::default(), alg); + } + } + + let alg = header.registered.algorithm; + match key.algorithm { + // HMAC + AlgorithmParameters::OctectKey { ref value, .. } => { + match alg { + SignatureAlgorithm::HS256 | + SignatureAlgorithm::HS384 | + SignatureAlgorithm::HS512 => { + *token = token.decode(&Secret::Bytes(value.clone()), alg)?; + Ok(()) + } + _ => wrong_key!("HS256 | HS384 | HS512", alg) + } + } + AlgorithmParameters::RSA(ref params) => { + match alg { + SignatureAlgorithm::RS256 | + SignatureAlgorithm::RS384 | + SignatureAlgorithm::RS512 => { + let pkcs = Secret::Pkcs { + n: params.n.clone(), + e: params.e.clone(), + }; + *token = token.decode(&pkcs, alg)?; + Ok(()) + } + _ => wrong_key!("RS256 | RS384 | RS512", alg) + } + } + AlgorithmParameters::EllipticCurve(_) => unimplemented!("No support for EC keys yet"), + } + } + + pub fn validate_token( + &self, + token: &IdToken, + nonce: Option<&str>, + max_age: Option<&Duration> + ) -> Result<(), Error> { + let claims = token.payload()?; + + if claims.iss != self.config().issuer { + return Err(Validation::Mismatch(Mismatch::Issuer).into()); + } + + if let Some(ref nonce) = nonce { + match claims.nonce { + Some(ref test) => { + if test != nonce { + return Err(Validation::Mismatch(Mismatch::Nonce).into()); + } + } + None => return Err(Validation::Missing(Missing::Nonce).into()), + } + } + + if !claims.aud.contains(&self.oauth.client_id) { + return Err(Validation::Mismatch(Mismatch::Audience).into()); + } + // By spec, if there are multiple auds, we must have an azp + if let SingleOrMultiple::Multiple(_) = claims.aud { + if let None = claims.azp { + return Err(Validation::Missing(Missing::AuthorizedParty).into()); + } + } + // If there is an authorized party, it must be our client_id + if let Some(ref azp) = claims.azp { + if azp != &self.oauth.client_id { + return Err(Validation::Mismatch(Mismatch::Authorized).into()); + } + } + + let now = Utc::now(); + // Now should never be less than the time this code was written! + if now.timestamp() < 1504758600 { + panic!("chrono::Utc::now() can never be before this was written!") + } + if claims.exp <= now.timestamp() { + return Err(Validation::Expired(Expiry::Expires).into()); + } + + if let Some(age) = max_age { + match claims.auth_time { + Some(time) => { + // This is not currently risky business. That could change. + if time >= (now - *age).timestamp() { + return Err(error::Validation::Expired(error::Expiry::MaxAge).into()); + } + } + None => return Err(Validation::Missing(Missing::AuthTime).into()), + } + } + + Ok(()) + } + + pub fn request_userinfo(&self, client: &reqwest::Client, token: &Token) -> Result { + match self.config().userinfo_endpoint { + Some(ref url) => { + if url.origin() != self.config().issuer.origin() { + return Err(error::Userinfo::MismatchIssuer.into()); + } + unimplemented!() + } + None => Err(error::Userinfo::NoUrl.into()) + } } } \ No newline at end of file diff --git a/src/discovery.rs b/src/discovery.rs index dcf433b..4fa2648 100644 --- a/src/discovery.rs +++ b/src/discovery.rs @@ -1,11 +1,11 @@ use biscuit::Empty; use biscuit::jwk::JWKSet; use inth_oauth2::provider::Provider; -use url::Url; +use reqwest::{Client, Url}; use url_serde; use validator::Validate; -use error::{Error, ErrorKind, Result}; +use error::Error; use token::{Expiring, Token}; #[derive(Deserialize, Serialize)] @@ -124,10 +124,24 @@ impl Provider for Discovered { } } -pub fn discover(issuer: &Url) -> Result { - unimplemented!() +/// Get the discovery config document from the given issuer url. Errors are either a reqwest error +/// or an Insecure if the Url isn't https. +pub fn discover(client: &Client, issuer: Url) -> Result { + if issuer.scheme() != "https" { + return Err(Error::Insecure) + } + + let mut resp = client.get(issuer)?.send()?; + resp.json().map_err(Error::from) } -pub fn jwks(url: &Url) -> Result> { - unimplemented!() -} \ No newline at end of file +/// Get the JWK set from the given Url. Errors are either a reqwest error or an Insecure error if +/// the url isn't https. +pub fn jwks(client: &Client, url: Url) -> Result, Error> { + if url.scheme() != "https" { + return Err(Error::Insecure) + } + + let mut resp = client.get(url)?.send()?; + resp.json().map_err(Error::from) +} diff --git a/src/error.rs b/src/error.rs index b473e1c..55bb1e5 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,45 +1,76 @@ -use biscuit; -use inth_oauth2; +pub use biscuit::errors::Error as Jose; +pub use serde_json::Error as Json; +pub use inth_oauth2::ClientError as Oauth; +pub use reqwest::Error as Reqwest; +macro_rules! from { + ($to:ident, $from:ident) => { + impl From<$from> for $to { + fn from(e: $from) -> Self { + $to::$from(e) + } + } + } +} + +#[derive(Debug)] +pub enum Error { + Jose(Jose), + Json(Json), + Oauth(Oauth), + Reqwest(Reqwest), + Decode(Decode), + Validation(Validation), + Userinfo(Userinfo), + Insecure, +} + +from!(Error, Jose); +from!(Error, Json); +from!(Error, Oauth); +from!(Error, Reqwest); +from!(Error, Decode); +from!(Error, Validation); +from!(Error, Userinfo); + +#[derive(Debug)] pub enum Decode { MissingKid, MissingKey, EmptySet, } +#[derive(Debug)] pub enum Validation { Mismatch(Mismatch), Missing(Missing), Expired(Expiry), } +#[derive(Debug)] pub enum Mismatch { Audience, Authorized, Issuer, Nonce, - Subject, } + +#[derive(Debug)] pub enum Missing { AuthorizedParty, AuthTime, Nonce, - OpenidScope, } +#[derive(Debug)] pub enum Expiry { Expires, - IssuedAt, MaxAge, } -error_chain! { - foreign_links { - Oauth(inth_oauth2::ClientError); - Biscuit(biscuit::errors::Error); - } - - errors { - MissingOpenidScope - } +#[derive(Debug)] +pub enum Userinfo { + NoUrl, + MismatchSubject, + } \ No newline at end of file diff --git a/src/issuer.rs b/src/issuer.rs index 58ffa2e..5850df8 100644 --- a/src/issuer.rs +++ b/src/issuer.rs @@ -1,4 +1,4 @@ -use url::Url; +use reqwest::Url; // TODO these should all be const, or even better, static Urls... diff --git a/src/lib.rs b/src/lib.rs index 05164e1..2aae256 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,7 +1,5 @@ extern crate base64; extern crate biscuit; -#[macro_use] -extern crate error_chain; extern crate chrono; extern crate inth_oauth2; extern crate reqwest; @@ -9,7 +7,6 @@ extern crate serde; #[macro_use] extern crate serde_derive; extern crate serde_json; -extern crate url; extern crate url_serde; extern crate validator; #[macro_use] @@ -18,4 +15,6 @@ extern crate validator_derive; pub mod client; pub mod discovery; pub mod error; -pub mod token; \ No newline at end of file +pub mod token; + +pub use error::Error; \ No newline at end of file diff --git a/src/token.rs b/src/token.rs index e2ab00f..5c689dc 100644 --- a/src/token.rs +++ b/src/token.rs @@ -3,8 +3,8 @@ use biscuit::{CompactJson, Empty, SingleOrMultiple}; use biscuit::jws::Compact; use inth_oauth2::client::response::{FromResponse, ParseError}; use inth_oauth2::token::{self, Bearer, Lifetime}; +use reqwest::Url; use serde_json::Value; -use url::Url; use url_serde; /// Rexported lifetime token types from oauth