diff --git a/Cargo.toml b/Cargo.toml index 5cc528b..eee7973 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,8 +20,9 @@ readme = 'README.md' repository = 'https://github.com/kilork/openid' [features] -uma2 = [] default = [] +microsoft = [] +uma2 = [] [dependencies] lazy_static = '1.4' diff --git a/README.md b/README.md index 022c3e4..517c034 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,9 @@ Implements [OpenID Connect Core 1.0](https://openid.net/specs/openid-connect-cor Implements [UMA2](https://docs.kantarainitiative.org/uma/wg/oauth-uma-federated-authz-2.0-09.html) - User Managed Access, an extension to OIDC/OAuth2. Use feature flag `uma2` to enable this feature. -This is quick and dirty rewrite of [inth-oauth2](https://crates.io/crates/inth-oauth2) and [oidc](https://crates.io/crates/oidc) to use async / await. Basic idea was to solve particular task, as result most of good ideas from original crates were perverted and over-simplified. +It supports Microsoft OIDC with feature `microsoft`. This adds methods for authentication and token validation, those skip issuer check. + +This library is a quick and dirty rewrite of [inth-oauth2](https://crates.io/crates/inth-oauth2) and [oidc](https://crates.io/crates/oidc) to use async / await. Basic idea was to solve particular task, as result most of good ideas from original crates were perverted and over-simplified. Using [reqwest](https://crates.io/crates/reqwest) for the HTTP client and [biscuit](https://crates.io/crates/biscuit) for Javascript Object Signing and Encryption (JOSE). diff --git a/src/client.rs b/src/client.rs index 09d6bba..d66a7b0 100644 --- a/src/client.rs +++ b/src/client.rs @@ -76,6 +76,90 @@ impl Client { } } +pub fn validate_token_issuer(claims: &C, config: &Config) -> Result<(), Error> { + if claims.iss() != &config.issuer { + let expected = config.issuer.as_str().to_string(); + let actual = claims.iss().as_str().to_string(); + return Err(Validation::Mismatch(Mismatch::Issuer { expected, actual }).into()); + } + + Ok(()) +} + +pub fn validate_token_nonce(claims: &C, nonce: Option<&str>) -> Result<(), Error> { + match nonce { + Some(expected) => match claims.nonce() { + Some(actual) => { + if expected != actual { + let expected = expected.to_string(); + let actual = actual.to_string(); + return Err(Validation::Mismatch(Mismatch::Nonce { expected, actual }).into()); + } + } + None => return Err(Validation::Missing(Missing::Nonce).into()), + }, + None => { + if claims.nonce().is_some() { + return Err(Validation::Missing(Missing::Nonce).into()); + } + } + } + + Ok(()) +} + +pub fn validate_token_aud(claims: &C, client_id: &str) -> Result<(), Error> { + if !claims.aud().contains(client_id) { + return Err(Validation::Missing(Missing::Audience).into()); + } + // By spec, if there are multiple auds, we must have an azp + if let SingleOrMultiple::Multiple(_) = claims.aud() { + if claims.azp().is_none() { + return Err(Validation::Missing(Missing::AuthorizedParty).into()); + } + } + // If there is an authorized party, it must be our client_id + if let Some(actual) = claims.azp() { + if actual != client_id { + let expected = client_id.to_string(); + let actual = actual.to_string(); + return Err( + Validation::Mismatch(Mismatch::AuthorizedParty { expected, actual }).into(), + ); + } + } + + Ok(()) +} + +pub fn validate_token_exp(claims: &C, max_age: Option<&Duration>) -> Result<(), Error> { + 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( + chrono::naive::NaiveDateTime::from_timestamp(claims.exp(), 0), + )) + .into()); + } + + if let Some(max) = max_age { + match claims.auth_time() { + Some(time) => { + let age = chrono::Duration::seconds(now.timestamp() - time); + if age >= *max { + return Err(Validation::Expired(Expiry::MaxAge(age)).into()); + } + } + None => return Err(Validation::Missing(Missing::AuthTime).into()), + } + } + + Ok(()) +} + impl Client { /// Passthrough to the redirect_url stored in inth_oauth2 as a str. pub fn redirect_url(&self) -> &str { @@ -250,76 +334,15 @@ impl Client { max_age: Option<&Duration>, ) -> Result<(), Error> { let claims = token.payload()?; + let config = self.config(); - if claims.iss() != &self.config().issuer { - let expected = self.config().issuer.as_str().to_string(); - let actual = claims.iss().as_str().to_string(); - return Err(Validation::Mismatch(Mismatch::Issuer { expected, actual }).into()); - } + validate_token_issuer(claims, config)?; - match nonce { - Some(expected) => match claims.nonce() { - Some(actual) => { - if expected != actual { - let expected = expected.to_string(); - let actual = actual.to_string(); - return Err( - Validation::Mismatch(Mismatch::Nonce { expected, actual }).into() - ); - } - } - None => return Err(Validation::Missing(Missing::Nonce).into()), - }, - None => { - if claims.nonce().is_some() { - return Err(Validation::Missing(Missing::Nonce).into()); - } - } - } + validate_token_nonce(claims, nonce)?; - if !claims.aud().contains(&self.client_id) { - return Err(Validation::Missing(Missing::Audience).into()); - } - // By spec, if there are multiple auds, we must have an azp - if let SingleOrMultiple::Multiple(_) = claims.aud() { - if claims.azp().is_none() { - return Err(Validation::Missing(Missing::AuthorizedParty).into()); - } - } - // If there is an authorized party, it must be our client_id - if let Some(actual) = claims.azp() { - if actual != &self.client_id { - let expected = self.client_id.to_string(); - let actual = actual.to_string(); - return Err( - Validation::Mismatch(Mismatch::AuthorizedParty { expected, actual }).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( - chrono::naive::NaiveDateTime::from_timestamp(claims.exp(), 0), - )) - .into()); - } + validate_token_aud(claims, &self.client_id)?; - if let Some(max) = max_age { - match claims.auth_time() { - Some(time) => { - let age = chrono::Duration::seconds(now.timestamp() - time); - if age >= *max { - return Err(Validation::Expired(Expiry::MaxAge(age)).into()); - } - } - None => return Err(Validation::Missing(Missing::AuthTime).into()), - } - } + validate_token_exp(claims, max_age)?; Ok(()) } diff --git a/src/deserializers.rs b/src/deserializers.rs index 4aeebc8..de83c19 100644 --- a/src/deserializers.rs +++ b/src/deserializers.rs @@ -1,9 +1,10 @@ -use serde::Deserializer; use de::Visitor; use serde::de; +use serde::Deserializer; pub fn bool_from_str_or_bool<'de, D>(deserializer: D) -> Result - where D: Deserializer<'de> +where + D: Deserializer<'de>, { deserializer.deserialize_any(BoolOrStringVisitor) } diff --git a/src/lib.rs b/src/lib.rs index 24c4db7..ca23be4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -11,7 +11,9 @@ Implements [OpenID Connect Core 1.0](https://openid.net/specs/openid-connect-cor Implements [UMA2](https://docs.kantarainitiative.org/uma/wg/oauth-uma-federated-authz-2.0-09.html) - User Managed Access, an extension to OIDC/OAuth2. Use feature flag `uma2` to enable this feature. -This is quick and dirty rewrite of [inth-oauth2](https://crates.io/crates/inth-oauth2) and [oidc](https://crates.io/crates/oidc) to use async / await. Basic idea was to solve particular task, as result most of good ideas from original crates were perverted and over-simplified. +It supports Microsoft OIDC with feature `microsoft`. This adds methods for authentication and token validation, those skip issuer check. + +This library is a quick and dirty rewrite of [inth-oauth2](https://crates.io/crates/inth-oauth2) and [oidc](https://crates.io/crates/oidc) to use async / await. Basic idea was to solve particular task, as result most of good ideas from original crates were perverted and over-simplified. Using [reqwest](https://crates.io/crates/reqwest) for the HTTP client and [biscuit](https://crates.io/crates/biscuit) for Javascript Object Signing and Encryption (JOSE). diff --git a/src/provider/microsoft.rs b/src/provider/microsoft.rs new file mode 100644 index 0000000..6acd987 --- /dev/null +++ b/src/provider/microsoft.rs @@ -0,0 +1,55 @@ +use crate::{ + client::{validate_token_aud, validate_token_exp, validate_token_nonce, Client}, + error::Error, + Claims, Configurable, IdToken, Provider, Token, +}; +use biscuit::CompactJson; +use chrono::Duration; + +/// Given an auth_code and auth options, request the token, decode, and validate it. +/// This validation is specific to Microsoft OIDC provider, it skips issuer validation. +pub async fn authenticate( + client: &Client, + auth_code: &str, + nonce: Option<&str>, + max_age: Option<&Duration>, +) -> Result, Error> { + let bearer = client.request_token(auth_code).await.map_err(Error::from)?; + let mut token: Token = bearer.into(); + + if let Some(mut id_token) = token.id_token.as_mut() { + client.decode_token(&mut id_token)?; + validate_token(client, &id_token, nonce, max_age)?; + } + + Ok(token) +} + +/// Validate a decoded token for Microsoft OpenID. If you don't get an error, its valid! Nonce and max_age come from +/// your auth_uri options. Errors are: +/// +/// - Jose Error if the Token isn't decoded +/// - Validation::Mismatch::Nonce if a given nonce and the token nonce mismatch +/// - Validation::Missing::Nonce if either the token or args has a nonce and the other does not +/// - Validation::Missing::Audience if the token aud doesn't contain the client id +/// - Validation::Missing::AuthorizedParty if there are multiple audiences and azp is missing +/// - Validation::Mismatch::AuthorizedParty if the azp is not the client_id +/// - Validation::Expired::Expires if the current time is past the expiration time +/// - Validation::Expired::MaxAge is the token is older than the provided max_age +/// - Validation::Missing::Authtime if a max_age was given and the token has no auth time +pub fn validate_token( + client: &Client, + token: &IdToken, + nonce: Option<&str>, + max_age: Option<&Duration>, +) -> Result<(), Error> { + let claims = token.payload()?; + + validate_token_nonce(claims, nonce)?; + + validate_token_aud(claims, &client.client_id)?; + + validate_token_exp(claims, max_age)?; + + Ok(()) +} diff --git a/src/provider.rs b/src/provider/mod.rs similarity index 94% rename from src/provider.rs rename to src/provider/mod.rs index 62a7d60..0ca4a0b 100644 --- a/src/provider.rs +++ b/src/provider/mod.rs @@ -1,6 +1,12 @@ /*! OAuth 2.0 providers. */ +#[cfg(feature = "microsoft")] +/// Microsoft OpenID Connect. +/// +/// See [Microsoft identity platform and OpenID Connect protocol](https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-protocols-oidc) +pub mod microsoft; + use url::Url; /// OAuth 2.0 providers. diff --git a/src/userinfo.rs b/src/userinfo.rs index c938aeb..f0f306a 100644 --- a/src/userinfo.rs +++ b/src/userinfo.rs @@ -1,10 +1,10 @@ +use crate::deserializers::bool_from_str_or_bool; use crate::Address; use chrono::NaiveDate; use serde::{Deserialize, Serialize}; use url::Url; use validator::Validate; use validator_derive::Validate; -use crate::deserializers::bool_from_str_or_bool; /// The userinfo struct contains all possible userinfo fields regardless of scope. [See spec.](https://openid.net/specs/openid-connect-basic-1_0.html#StandardClaims) // TODO is there a way to use claims_supported in config to simplify this struct?