diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 6738b0b..b95d1c7 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -17,3 +17,7 @@ jobs: run: cargo build --verbose - name: Run tests run: cargo test --verbose + - name: Build with UMA2 + run: cargo build --features uma2 + - name: Run tests with UMA2 + run: cargo test --verbose --features uma2 diff --git a/.gitignore b/.gitignore index 6936990..ad513ef 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ /target **/*.rs.bk Cargo.lock +*.iml +.idea/ diff --git a/Cargo.toml b/Cargo.toml index 6eb0967..cf2ac03 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,10 @@ license = 'Unlicense OR MIT' readme = 'README.md' repository = 'https://github.com/kilork/openid' +[features] +uma2 = [] +default = [] + [dependencies] lazy_static = '1.4' serde_json = '1' diff --git a/src/claims.rs b/src/claims.rs index bf0505e..11f053f 100644 --- a/src/claims.rs +++ b/src/claims.rs @@ -1,5 +1,4 @@ use crate::Userinfo; -use base64; use biscuit::SingleOrMultiple; use url::Url; diff --git a/src/client.rs b/src/client.rs index 9e48481..09d6bba 100644 --- a/src/client.rs +++ b/src/client.rs @@ -4,8 +4,8 @@ use crate::{ ClientError, Decode, Error, Expiry, Jose, Mismatch, Missing, Userinfo as ErrorUserinfo, Validation, }, - Bearer, Claims, Config, Discovered, IdToken, OAuth2Error, Options, Provider, StandardClaims, - Token, Userinfo, + Bearer, Claims, Config, Configurable, Discovered, IdToken, OAuth2Error, Options, Provider, + StandardClaims, Token, Userinfo, }; use biscuit::{ jwa::{self, SignatureAlgorithm}, @@ -62,7 +62,9 @@ impl Client { let http_client = reqwest::Client::new(); let config = discovered::discover(&http_client, issuer).await?; let jwks = discovered::jwks(&http_client, config.jwks_uri.clone()).await?; - let provider = Discovered(config); + + let provider = config.into(); + Ok(Self::new( provider, id, @@ -72,6 +74,9 @@ impl Client { Some(jwks), )) } +} + +impl Client { /// Passthrough to the redirect_url stored in inth_oauth2 as a str. pub fn redirect_url(&self) -> &str { self.redirect_uri @@ -81,7 +86,7 @@ impl Client { /// A reference to the config document of the provider obtained via discovery pub fn config(&self) -> &Config { - &self.provider.0 + self.provider.config() } /// Constructs the auth_url to redirect a client to the provider. Options are... optional. Use @@ -100,7 +105,7 @@ impl Client { None => String::from("openid"), }; - let mut url = self.auth_uri(Some(&scope), options.state.as_ref().map(String::as_str)); + let mut url = self.auth_uri(Some(&scope), options.state.as_deref()); { let mut query = url.query_pairs_mut(); if let Some(ref nonce) = options.nonce { @@ -187,7 +192,7 @@ impl Client { }; if let Some(alg) = key.common.algorithm.as_ref() { - if let &jwa::Algorithm::Signature(sig) = alg { + if let jwa::Algorithm::Signature(sig) = *alg { if header.registered.algorithm != sig { return wrong_key!(sig, header.registered.algorithm); } @@ -277,7 +282,7 @@ impl Client { } // By spec, if there are multiple auds, we must have an azp if let SingleOrMultiple::Multiple(_) = claims.aud() { - if let None = claims.azp() { + if claims.azp().is_none() { return Err(Validation::Missing(Missing::AuthorizedParty).into()); } } @@ -491,6 +496,25 @@ where Ok(token) } + /// Requests an access token using the Client Credentials Grant flow + /// + /// See [RFC 6749, section 4.4](https://tools.ietf.org/html/rfc6749#section-4.4) + pub async fn request_token_using_client_credentials(&self) -> Result { + // Ensure the non thread-safe `Serializer` is not kept across + // an `await` boundary by localizing it to this inner scope. + let body = { + let mut body = Serializer::new(String::new()); + body.append_pair("grant_type", "client_credentials"); + body.append_pair("client_id", &self.client_id); + body.append_pair("client_secret", &self.client_secret); + body.finish() + }; + + let json = self.post_token(body).await?; + let token: Bearer = serde_json::from_value(json)?; + Ok(token) + } + /// Refreshes an access token. /// /// See [RFC 6749, section 6](http://tools.ietf.org/html/rfc6749#section-6). @@ -505,8 +529,7 @@ where "refresh_token", token .refresh_token - .as_ref() - .map(String::as_str) + .as_deref() .expect("No refresh_token field"), ); diff --git a/src/config.rs b/src/config.rs index 4a82952..518ee9a 100644 --- a/src/config.rs +++ b/src/config.rs @@ -32,8 +32,10 @@ pub struct Config { #[serde(default)] pub acr_values_supported: Option>, // pairwise and public are valid by spec, but servers can add more + #[serde(default = "empty_string_vec")] pub subject_types_supported: Vec, // Must include at least RS256, none is only allowed with response types without id tokens + #[serde(default = "empty_string_vec")] pub id_token_signing_alg_values_supported: Vec, #[serde(default)] pub id_token_encryption_alg_values_supported: Option>, @@ -93,3 +95,7 @@ pub struct Config { fn tru() -> bool { true } + +fn empty_string_vec() -> Vec { + vec![] +} diff --git a/src/configurable.rs b/src/configurable.rs new file mode 100644 index 0000000..5a4cbfc --- /dev/null +++ b/src/configurable.rs @@ -0,0 +1,5 @@ +use crate::Config; + +pub trait Configurable { + fn config(&self) -> &Config; +} diff --git a/src/discovered.rs b/src/discovered.rs index 0a5f8ed..67b87bf 100644 --- a/src/discovered.rs +++ b/src/discovered.rs @@ -1,10 +1,10 @@ -use crate::{error::Error, Config, Provider}; +use crate::{error::Error, Config, Configurable, Provider}; use biscuit::jwk::JWKSet; use biscuit::Empty; use reqwest::Client; use url::Url; -pub struct Discovered(pub Config); +pub struct Discovered(Config); impl Provider for Discovered { fn auth_uri(&self) -> &Url { @@ -16,11 +16,24 @@ impl Provider for Discovered { } } +impl Configurable for Discovered { + fn config(&self) -> &Config { + &self.0 + } +} + +impl From for Discovered { + fn from(value: Config) -> Self { + Self(value) + } +} + pub async fn discover(client: &Client, mut issuer: Url) -> Result { issuer .path_segments_mut() .map_err(|_| Error::CannotBeABase)? .extend(&[".well-known", "openid-configuration"]); + let resp = client.get(issuer).send().await?; resp.json().await.map_err(Error::from) } diff --git a/src/error.rs b/src/error.rs index 91bc95c..6350495 100644 --- a/src/error.rs +++ b/src/error.rs @@ -103,7 +103,12 @@ pub enum ClientError { /// OAuth 2.0 error. OAuth2(OAuth2Error), + + /// UMA2 error. + #[cfg(feature = "uma2")] + Uma2(Uma2Error), } + impl fmt::Display for ClientError { fn fmt(&self, f: &mut fmt::Formatter) -> Result<(), fmt::Error> { match *self { @@ -113,6 +118,8 @@ impl fmt::Display for ClientError { ClientError::Json(ref err) => write!(f, "{}", err), // ClientError::Parse(ref err) => write!(f, "{}", err), ClientError::OAuth2(ref err) => write!(f, "{}", err), + #[cfg(feature = "uma2")] + ClientError::Uma2(ref err) => write!(f, "{}", err), } } } @@ -126,6 +133,8 @@ impl std::error::Error for ClientError { ClientError::Json(ref err) => Some(err), // ClientError::Parse(ref err) => Some(err), ClientError::OAuth2(ref err) => Some(err), + #[cfg(feature = "uma2")] + ClientError::Uma2(ref err) => Some(err), } } } @@ -154,6 +163,9 @@ pub use serde_json::Error as Json; use failure::Fail; +#[cfg(feature = "uma2")] +use crate::uma2::Uma2Error; + #[derive(Debug, Fail)] pub enum Error { #[fail(display = "{}", _0)] diff --git a/src/lib.rs b/src/lib.rs index ba8310c..7265a3e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -324,6 +324,7 @@ mod bearer; mod claims; mod client; mod config; +mod configurable; mod custom_claims; mod discovered; mod display; @@ -335,6 +336,9 @@ mod standard_claims; mod token; mod userinfo; +#[cfg(feature = "uma2")] +pub mod uma2; + pub use ::biscuit::jws::Compact as Jws; pub use ::biscuit::{Compact, CompactJson, Empty, SingleOrMultiple}; pub use address::Address; @@ -342,6 +346,7 @@ pub use bearer::Bearer; pub use claims::Claims; pub use client::Client; pub use config::Config; +pub use configurable::Configurable; pub use custom_claims::CustomClaims; pub use discovered::Discovered; pub use display::Display; @@ -353,10 +358,12 @@ pub use standard_claims::StandardClaims; pub use token::Token; pub use userinfo::Userinfo; -/// Reimport `biscuit` depdendency. +/// Reimport `biscuit` dependency. pub mod biscuit { pub use biscuit::*; } type IdToken = Jws; pub type DiscoveredClient = Client; +#[cfg(feature = "uma2")] +pub type DiscoveredUma2Client = Client; diff --git a/src/provider.rs b/src/provider.rs index 19e302d..62a7d60 100644 --- a/src/provider.rs +++ b/src/provider.rs @@ -40,14 +40,14 @@ pub mod google { /// See [Choosing a redirect URI][uri]. /// /// [uri]: https://developers.google.com/identity/protocols/OAuth2InstalledApp#choosingredirecturi - pub const REDIRECT_URI_OOB: &'static str = "urn:ietf:wg:oauth:2.0:oob"; + pub const REDIRECT_URI_OOB: &str = "urn:ietf:wg:oauth:2.0:oob"; /// Signals the server to return the authorization code in the page title. /// /// See [Choosing a redirect URI][uri]. /// /// [uri]: https://developers.google.com/identity/protocols/OAuth2InstalledApp#choosingredirecturi - pub const REDIRECT_URI_OOB_AUTO: &'static str = "urn:ietf:wg:oauth:2.0:oob:auto"; + pub const REDIRECT_URI_OOB_AUTO: &str = "urn:ietf:wg:oauth:2.0:oob:auto"; lazy_static! { static ref AUTH_URI: Url = diff --git a/src/uma2/claim_token_format.rs b/src/uma2/claim_token_format.rs new file mode 100644 index 0000000..a440d42 --- /dev/null +++ b/src/uma2/claim_token_format.rs @@ -0,0 +1,23 @@ +use core::fmt; +use serde::export::Formatter; + +/// UMA2 claim token format +/// Either is an access token (urn:ietf:params:oauth:token-type:jwt) or an OIDC ID token +pub enum Uma2ClaimTokenFormat { + OAuthJwt, // urn:ietf:params:oauth:token-type:jwt + OidcIdToken, // https://openid.net/specs/openid-connect-core-1_0.html#IDToken +} + +impl fmt::Display for Uma2ClaimTokenFormat { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + write!( + f, + "{}", + match *self { + Uma2ClaimTokenFormat::OAuthJwt => "urn:ietf:params:oauth:token-type:jwt", + Uma2ClaimTokenFormat::OidcIdToken => + "https://openid.net/specs/openid-connect-core-1_0.html#IDToken", + } + ) + } +} diff --git a/src/uma2/config.rs b/src/uma2/config.rs new file mode 100644 index 0000000..0db16d1 --- /dev/null +++ b/src/uma2/config.rs @@ -0,0 +1,19 @@ +use crate::Config; +use serde::{Deserialize, Serialize}; +use url::Url; + +#[derive(Debug, Deserialize, Serialize)] +pub struct Uma2Config { + // UMA2 additions + #[serde(default)] + pub resource_registration_endpoint: Option, + #[serde(default)] + pub permission_endpoint: Option, + #[serde(default)] + pub policy_endpoint: Option, + #[serde(default)] + pub introspection_endpoint: Option, + + #[serde(flatten)] + pub config: Config, +} diff --git a/src/uma2/discovered.rs b/src/uma2/discovered.rs new file mode 100644 index 0000000..eb135a5 --- /dev/null +++ b/src/uma2/discovered.rs @@ -0,0 +1,85 @@ +use crate::{ + error::Error, + uma2::{Uma2Config, Uma2Provider}, + Claims, Client, Config, Configurable, Provider, +}; +use biscuit::CompactJson; +use url::Url; + +pub struct DiscoveredUma2(Uma2Config); + +impl Provider for DiscoveredUma2 { + fn auth_uri(&self) -> &Url { + &self.config().authorization_endpoint + } + + fn token_uri(&self) -> &Url { + &self.config().token_endpoint + } +} + +impl Configurable for DiscoveredUma2 { + fn config(&self) -> &Config { + &self.0.config + } +} + +impl From for DiscoveredUma2 { + fn from(value: Uma2Config) -> Self { + Self(value) + } +} + +impl Uma2Provider for DiscoveredUma2 { + fn uma2_discovered(&self) -> bool { + self.0.resource_registration_endpoint.is_some() + } + + fn resource_registration_uri(&self) -> Option<&Url> { + self.0.resource_registration_endpoint.as_ref() + } + + fn permission_uri(&self) -> Option<&Url> { + self.0.permission_endpoint.as_ref() + } + + fn uma_policy_uri(&self) -> Option<&Url> { + self.0.policy_endpoint.as_ref() + } +} + +impl Client { + /// Constructs a client from an issuer url and client parameters via discovery + pub async fn discover_uma2( + id: String, + secret: String, + redirect: Option, + issuer: Url, + ) -> Result { + let http_client = reqwest::Client::new(); + let uma2_config = discover_uma2(&http_client, &issuer).await?; + let jwks = + crate::discovered::jwks(&http_client, uma2_config.config.jwks_uri.clone()).await?; + + let provider = uma2_config.into(); + + Ok(Self::new( + provider, + id, + secret, + redirect, + http_client, + Some(jwks), + )) + } +} + +pub async fn discover_uma2(client: &reqwest::Client, issuer: &Url) -> Result { + let mut issuer = issuer.clone(); + issuer + .path_segments_mut() + .map_err(|_| Error::CannotBeABase)? + .extend(&[".well-known", "uma2-configuration"]); + let resp = client.get(issuer).send().await?; + resp.json().await.map_err(Error::from) +} diff --git a/src/uma2/error.rs b/src/uma2/error.rs new file mode 100644 index 0000000..f75518e --- /dev/null +++ b/src/uma2/error.rs @@ -0,0 +1,35 @@ +#[derive(Debug)] +pub enum Uma2Error { + NoUma2Discovered, + AudienceFieldRequired, + NoResourceSetEndpoint, + NoPermissionsEndpoint, + NoPolicyAssociationEndpoint, + ResourceSetEndpointMalformed, + PolicyAssociationEndpointMalformed, +} + +impl std::error::Error for Uma2Error { + fn description(&self) -> &str { + "UMA2 API error" + } +} + +impl std::fmt::Display for Uma2Error { + fn fmt(&self, f: &mut std::fmt::Formatter) -> Result<(), std::fmt::Error> { + write!( + f, + "{}", + match *self { + Uma2Error::NoUma2Discovered => "No UMA2 discovered", + Uma2Error::AudienceFieldRequired => "Audience field required", + Uma2Error::NoResourceSetEndpoint => "No resource_set endpoint discovered", + Uma2Error::NoPermissionsEndpoint => "No permissions endpoint discovered", + Uma2Error::NoPolicyAssociationEndpoint => + "No permissions policy association endpoint discovered", + Uma2Error::ResourceSetEndpointMalformed => "resource_set endpoint is malformed", + Uma2Error::PolicyAssociationEndpointMalformed => "policy_endpoint is malformed", + } + ) + } +} diff --git a/src/uma2/mod.rs b/src/uma2/mod.rs new file mode 100644 index 0000000..f86babb --- /dev/null +++ b/src/uma2/mod.rs @@ -0,0 +1,23 @@ +mod claim_token_format; +mod config; +mod discovered; +mod error; +mod permission_association; +mod permission_ticket; +mod provider; +mod resource; +mod rpt; + +pub use claim_token_format::Uma2ClaimTokenFormat; +pub use config::Uma2Config; +pub use discovered::{discover_uma2, DiscoveredUma2}; +pub use error::Uma2Error; +pub use permission_association::{ + Uma2PermissionAssociation, Uma2PermissionDecisionStrategy, Uma2PermissionLogic, +}; +pub use permission_ticket::{Uma2PermissionTicketRequest, Uma2PermissionTicketResponse}; +pub use provider::Uma2Provider; +pub use resource::Uma2Owner; +pub use resource::Uma2Resource; +pub use resource::Uma2ResourceScope; +pub use rpt::Uma2AuthenticationMethod; diff --git a/src/uma2/permission_association.rs b/src/uma2/permission_association.rs new file mode 100644 index 0000000..c744ba6 --- /dev/null +++ b/src/uma2/permission_association.rs @@ -0,0 +1,344 @@ +use crate::error::ClientError; +use crate::uma2::error::Uma2Error::*; +use crate::uma2::Uma2Provider; +use crate::{Claims, Client, OAuth2Error, Provider}; +use biscuit::CompactJson; +use reqwest::header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +#[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Eq)] +#[serde(rename_all = "UPPERCASE")] +pub enum Uma2PermissionLogic { + Positive, + Negative, +} + +#[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Eq)] +#[serde(rename_all = "UPPERCASE")] +pub enum Uma2PermissionDecisionStrategy { + Unanimous, + Affirmative, + Consensus, +} + +#[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Eq)] +pub struct Uma2PermissionAssociation { + pub id: Option, + pub name: String, + pub description: String, + pub scopes: Vec, + pub roles: Option>, + pub groups: Option>, + pub clients: Option>, + pub owner: Option, + #[serde(rename = "type")] + pub permission_type: Option, + pub logic: Option, + #[serde(rename = "decisionStrategy")] + pub decision_strategy: Option, +} + +impl Client +where + P: Provider + Uma2Provider, + C: CompactJson + Claims, +{ + /// Used when permissions can be set to resources by resource servers on behalf of their users + /// + /// # Arguments + /// * `token` This API is protected by a bearer token that must represent a consent granted by + /// the user to the resource server to manage permissions on his behalf. The bearer token + /// can be a regular access token obtained from the token endpoint using: + /// - Resource Owner Password Credentials Grant Type + /// - Token Exchange, in order to exchange an access token granted to some client + /// (public client) for a token where audience is the resource server + /// * `resource_id` Resource ID to be protected + /// * `name` Name for the permission + /// * `description` Description for the permission + /// * `scopes` A list of scopes given on this resource to the user if the permission validates + /// * `roles` Give the permission to users in a list of roles + /// * `groups` Give the permission to users in a list of groups + /// * `clients` Give the permission to users using a specific list of clients + /// * `owner` Give the permission to the owner + /// * `logic` Positive: If the user is in the required groups/roles or using the right client, then + /// give the permission to the user. Negative - the inverse + /// * `decision_strategy` Go through the required conditions. If it is more than one condition, + /// give the permission to the user if the following conditions are met: + /// - Unanimous: The default strategy if none is provided. In this case, all policies must evaluate + /// to a positive decision for the final decision to be also positive. + /// - Affirmative: In this case, at least one policy must evaluate to a positive decision + /// in order for the final decision to be also positive. + /// - Consensus: In this case, the number of positive decisions must be greater than + /// the number of negative decisions. If the number of positive and negative + /// decisions is the same, the final decision will be negative + pub async fn associate_uma2_resource_with_a_permission( + &self, + token: String, + resource_id: String, + name: String, + description: String, + scopes: Vec, + roles: Option>, + groups: Option>, + clients: Option>, + owner: Option, + logic: Option, + decision_strategy: Option, + ) -> Result { + if !self.provider.uma2_discovered() { + return Err(ClientError::Uma2(NoUma2Discovered)); + } + + if self.provider.uma_policy_uri().is_none() { + return Err(ClientError::Uma2(NoPolicyAssociationEndpoint)); + } + let mut url = self.provider.uma_policy_uri().unwrap().clone(); + url.path_segments_mut() + .map_err(|_| ClientError::Uma2(PolicyAssociationEndpointMalformed))? + .extend(&[resource_id]); + + let permission = Uma2PermissionAssociation { + id: None, + name, + description, + scopes, + roles, + groups, + clients, + owner, + permission_type: None, + logic, + decision_strategy, + }; + + let json = self + .http_client + .post(url) + .header(CONTENT_TYPE, "application/json") + .header(AUTHORIZATION, format!("Bearer {:}", token)) + .header(ACCEPT, "application/json") + .json(&permission) + .send() + .await? + .json::() + .await?; + + let error: Result = serde_json::from_value(json.clone()); + + if let Ok(error) = error { + Err(ClientError::from(error)) + } else { + let association: Uma2PermissionAssociation = serde_json::from_value(json)?; + Ok(association) + } + } + + /// Update a UMA2 resource's associated permission + /// + /// # Arguments + /// * `id` The ID of the the associated permission + /// * `token` This API is protected by a bearer token that must represent a consent granted by + /// the user to the resource server to manage permissions on his behalf. The bearer token + /// can be a regular access token obtained from the token endpoint using: + /// - Resource Owner Password Credentials Grant Type + /// - Token Exchange, in order to exchange an access token granted to some client + /// (public client) for a token where audience is the resource server + /// * `name` Name for the permission + /// * `description` Description for the permission + /// * `scopes` A list of scopes given on this resource to the user if the permission validates + /// * `roles` Give the permission to users in a list of roles + /// * `groups` Give the permission to users in a list of groups + /// * `clients` Give the permission to users using a specific list of clients + /// * `owner` Give the permission to the owner + /// * `logic` Positive: If the user is in the required groups/roles or using the right client, then + /// give the permission to the user. Negative - the inverse + /// * `decision_strategy` Go through the required conditions. If it is more than one condition, + /// give the permission to the user if the following conditions are met: + /// - Unanimous: The default strategy if none is provided. In this case, all policies must evaluate + /// to a positive decision for the final decision to be also positive. + /// - Affirmative: In this case, at least one policy must evaluate to a positive decision + /// in order for the final decision to be also positive. + /// - Consensus: In this case, the number of positive decisions must be greater than + /// the number of negative decisions. If the number of positive and negative + /// decisions is the same, the final decision will be negative + pub async fn update_uma2_resource_permission( + &self, + id: String, + token: String, + name: String, + description: String, + scopes: Vec, + roles: Option>, + groups: Option>, + clients: Option>, + owner: Option, + logic: Option, + decision_strategy: Option, + ) -> Result { + if !self.provider.uma2_discovered() { + return Err(ClientError::Uma2(NoUma2Discovered)); + } + + if self.provider.uma_policy_uri().is_none() { + return Err(ClientError::Uma2(NoPolicyAssociationEndpoint)); + } + + let mut url = self.provider.uma_policy_uri().unwrap().clone(); + url.path_segments_mut() + .map_err(|_| ClientError::Uma2(PolicyAssociationEndpointMalformed))? + .extend(&[&id]); + + let permission = Uma2PermissionAssociation { + id: Some(id), + name, + description, + scopes, + roles, + groups, + clients, + owner, + permission_type: Some("uma".to_string()), + logic, + decision_strategy, + }; + + let json = self + .http_client + .put(url) + .header(CONTENT_TYPE, "application/json") + .header(AUTHORIZATION, format!("Bearer {:}", token)) + .json(&permission) + .send() + .await? + .json::() + .await?; + + let error: Result = serde_json::from_value(json.clone()); + + if let Ok(error) = error { + Err(ClientError::from(error)) + } else { + let association: Uma2PermissionAssociation = serde_json::from_value(json)?; + Ok(association) + } + } + + /// Delete a UMA2 resource's permission + /// + /// # Arguments + /// * `id` The ID of the resource permission + /// * `token` This API is protected by a bearer token that must represent a consent granted by + /// the user to the resource server to manage permissions on his behalf. The bearer token + /// can be a regular access token obtained from the token endpoint using: + /// - Resource Owner Password Credentials Grant Type + /// - Token Exchange, in order to exchange an access token granted to some client + /// (public client) for a token where audience is the resource server + pub async fn delete_uma2_resource_permission( + &self, + id: String, + token: String, + ) -> Result<(), ClientError> { + if !self.provider.uma2_discovered() { + return Err(ClientError::Uma2(NoUma2Discovered)); + } + + if self.provider.uma_policy_uri().is_none() { + return Err(ClientError::Uma2(NoPolicyAssociationEndpoint)); + } + + let mut url = self.provider.uma_policy_uri().unwrap().clone(); + url.path_segments_mut() + .map_err(|_| ClientError::Uma2(PolicyAssociationEndpointMalformed))? + .extend(&[&id]); + + let json = self + .http_client + .delete(url) + .header(CONTENT_TYPE, "application/json") + .header(AUTHORIZATION, format!("Bearer {:}", token)) + .send() + .await? + .json::() + .await?; + + let error: Result = serde_json::from_value(json); + + if let Ok(error) = error { + Err(ClientError::from(error)) + } else { + Ok(()) + } + } + + /// Search for UMA2 resource associated permissions + /// + /// # Arguments + /// * `token` This API is protected by a bearer token that must represent a consent granted by + /// the user to the resource server to manage permissions on his behalf. The bearer token + /// can be a regular access token obtained from the token endpoint using: + /// - Resource Owner Password Credentials Grant Type + /// - Token Exchange, in order to exchange an access token granted to some client + /// (public client) for a token where audience is the resource server + /// * `resource` Search by resource id + /// * `name` Search by name + /// * `scope` Search by scope + /// * `offset` Skip n amounts of permissions. + /// * `count` Max amount of permissions to return. Should be used especially with large return sets + pub async fn search_for_uma2_resource_permission( + &self, + token: String, + resource: Option, + name: Option, + scope: Option, + offset: Option, + count: Option, + ) -> Result, ClientError> { + if !self.provider.uma2_discovered() { + return Err(ClientError::Uma2(NoUma2Discovered)); + } + + if self.provider.uma_policy_uri().is_none() { + return Err(ClientError::Uma2(NoPolicyAssociationEndpoint)); + } + + let mut url = self.provider.uma_policy_uri().unwrap().clone(); + { + let mut query = url.query_pairs_mut(); + if resource.is_some() { + query.append_pair("resource", resource.unwrap().as_str()); + } + if name.is_some() { + query.append_pair("name", name.unwrap().as_str()); + } + if scope.is_some() { + query.append_pair("scope", scope.unwrap().as_str()); + } + if offset.is_some() { + query.append_pair("first", format!("{}", offset.unwrap()).as_str()); + } + if count.is_some() { + query.append_pair("max", format!("{}", count.unwrap()).as_str()); + } + } + + let json = self + .http_client + .get(url) + .header(CONTENT_TYPE, "application/json") + .header(AUTHORIZATION, format!("Bearer {:}", token)) + .send() + .await? + .json::() + .await?; + + let error: Result = serde_json::from_value(json.clone()); + + if let Ok(error) = error { + Err(ClientError::from(error)) + } else { + let resource: Vec = serde_json::from_value(json)?; + Ok(resource) + } + } +} diff --git a/src/uma2/permission_ticket.rs b/src/uma2/permission_ticket.rs new file mode 100644 index 0000000..1ebb15a --- /dev/null +++ b/src/uma2/permission_ticket.rs @@ -0,0 +1,14 @@ +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +#[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Eq)] +pub struct Uma2PermissionTicketRequest { + pub resource_id: String, + pub resource_scopes: Option>, + pub claims: Option>, +} + +#[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Eq)] +pub struct Uma2PermissionTicketResponse { + pub ticket: String, +} diff --git a/src/uma2/provider.rs b/src/uma2/provider.rs new file mode 100644 index 0000000..680cee0 --- /dev/null +++ b/src/uma2/provider.rs @@ -0,0 +1,18 @@ +use url::Url; + +pub trait Uma2Provider { + /// Whether UMA2 capabilities have been discovered + fn uma2_discovered(&self) -> bool; + + /// UMA-compliant Resource Registration Endpoint which resource servers can use to manage their + /// protected resources and scopes. This endpoint provides operations create, read, update and + /// delete resources and scopes + fn resource_registration_uri(&self) -> Option<&Url>; + + /// UMA-compliant Permission Endpoint which resource servers can use to manage permission + /// tickets. This endpoint provides operations create, read, update, and delete permission tickets + fn permission_uri(&self) -> Option<&Url>; + + /// API from where permissions can be set to resources by resource servers on behalf of their users. + fn uma_policy_uri(&self) -> Option<&Url>; +} diff --git a/src/uma2/resource.rs b/src/uma2/resource.rs new file mode 100644 index 0000000..d084b69 --- /dev/null +++ b/src/uma2/resource.rs @@ -0,0 +1,353 @@ +use crate::error::ClientError; +use crate::uma2::error::Uma2Error::*; +use crate::uma2::Uma2Provider; +use crate::{Claims, Client, OAuth2Error, Provider}; +use biscuit::CompactJson; +use reqwest::header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +#[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Eq)] +pub struct Uma2Resource { + #[serde(rename = "_id")] + pub id: Option, + pub name: String, + #[serde(rename = "type")] + pub resource_type: Option, + pub icon_uri: Option, + pub resource_scopes: Option>, + #[serde(rename = "displayName")] + pub display_name: Option, + pub owner: Option, + #[serde(rename = "ownerManagedAccess")] + pub owner_managed_access: Option, +} + +#[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Eq)] +pub struct Uma2ResourceScope { + pub id: Option, + pub name: Option, +} + +#[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Eq)] +pub struct Uma2Owner { + pub id: Option, + pub name: Option, +} + +impl Client +where + P: Provider + Uma2Provider, + C: CompactJson + Claims, +{ + /// + /// Create a UMA2 managed resource + /// + /// # Arguments + /// * `pat_token` A Protection API token (PAT) is like any OAuth2 token, but should have the + /// uma_protection scope defined + /// * `name` User readable name for this resource. + /// * `resource_type` The type of resource. Helps to categorise resources + /// * `icon_uri` User visible icon's URL + /// * `resource_scopes` A list of scopes attached to this resource + /// * `description` A readable description + /// * `owner` Resource server is the default user, unless this value is set. Can be the username + /// of the user or its server identifier + /// * `owner_managed_access` Whether to allow user managed access of this resource + pub async fn create_uma2_resource( + &self, + pat_token: String, + name: String, + resource_type: Option, + icon_uri: Option, + resource_scopes: Option>, + display_name: Option, + owner: Option, + owner_managed_access: Option, + ) -> Result { + if !self.provider.uma2_discovered() { + return Err(ClientError::Uma2(NoUma2Discovered)); + } + + if self.provider.resource_registration_uri().is_none() { + return Err(ClientError::Uma2(NoResourceSetEndpoint)); + } + + let resource_scopes = resource_scopes.map(|names| { + names + .iter() + .map(|name| Uma2ResourceScope { + name: Some(name.clone()), + id: None, + }) + .collect() + }); + + let url = self.provider.resource_registration_uri().unwrap().clone(); + + let body = Uma2Resource { + id: None, + name, + resource_type, + icon_uri, + resource_scopes, + display_name, + owner, + owner_managed_access, + }; + + let json = self + .http_client + .post(url) + .header(CONTENT_TYPE, "application/json") + .header(AUTHORIZATION, format!("Bearer {:}", pat_token)) + .header(ACCEPT, "application/json") + .json(&body) + .send() + .await? + .json::() + .await?; + + let error: Result = serde_json::from_value(json.clone()); + + if let Ok(error) = error { + Err(ClientError::from(error)) + } else { + let resource: Uma2Resource = serde_json::from_value(json)?; + Ok(resource) + } + } + + /// + /// Update a UMA2 managed resource + /// + /// # Arguments + /// * `pat_token` A Protection API token (PAT) is like any OAuth2 token, but should have the + /// uma_protection scope defined + /// * `name` User readable name for this resource. + /// * `resource_type` The type of resource. Helps to categorise resources + /// * `icon_uri` User visible icon's URL + /// * `resource_scopes` A list of scopes attached to this resource + /// * `description` A readable description + /// * `owner` Resource server is the default user, unless this value is set. Can be the username + /// of the user or its server identifier + /// * `owner_managed_access` Whether to allow user managed access of this resource + pub async fn update_uma2_resource( + &self, + pat_token: String, + name: String, + resource_type: Option, + icon_uri: Option, + resource_scopes: Option>, + display_name: Option, + owner: Option, + owner_managed_access: Option, + ) -> Result { + if !self.provider.uma2_discovered() { + return Err(ClientError::Uma2(NoUma2Discovered)); + } + + if self.provider.resource_registration_uri().is_none() { + return Err(ClientError::Uma2(NoResourceSetEndpoint)); + } + + let resource_scopes = resource_scopes.map(|names| { + names + .iter() + .map(|name| Uma2ResourceScope { + name: Some(name.clone()), + id: None, + }) + .collect() + }); + + let url = self.provider.resource_registration_uri().unwrap().clone(); + + let body = Uma2Resource { + id: None, + name, + resource_type, + icon_uri, + resource_scopes, + display_name, + owner, + owner_managed_access, + }; + + let json = self + .http_client + .put(url) + .header(CONTENT_TYPE, "application/json") + .header(AUTHORIZATION, format!("Bearer {:}", pat_token)) + .header(ACCEPT, "application/json") + .json(&body) + .send() + .await? + .json::() + .await?; + + let error: Result = serde_json::from_value(json.clone()); + + if let Ok(error) = error { + Err(ClientError::from(error)) + } else { + let resource: Uma2Resource = serde_json::from_value(json)?; + Ok(resource) + } + } + + /// Deletes a UMA2 managed resource + /// + /// # Arguments + /// * `pat_token` A Protection API token (PAT) is like any OAuth2 token, but should have the + /// * `id` The server identifier of the resource + pub async fn delete_uma2_resource( + &self, + pat_token: String, + id: String, + ) -> Result<(), ClientError> { + if !self.provider.uma2_discovered() { + return Err(ClientError::Uma2(NoUma2Discovered)); + } + + if self.provider.resource_registration_uri().is_none() { + return Err(ClientError::Uma2(NoResourceSetEndpoint)); + } + + let mut url = self.provider.resource_registration_uri().unwrap().clone(); + + url.path_segments_mut() + .map_err(|_| ClientError::Uma2(ResourceSetEndpointMalformed))? + .extend(&[id]); + + let json = self + .http_client + .delete(url) + .header(AUTHORIZATION, format!("Bearer {:}", pat_token)) + .send() + .await? + .json::() + .await?; + + let error: Result = serde_json::from_value(json); + + if let Ok(error) = error { + Err(ClientError::from(error)) + } else { + Ok(()) + } + } + + /// Get a UMA2 managed resource by its identifier + /// + /// # Arguments + /// * `pat_token` A Protection API token (PAT) is like any OAuth2 token, but should have the + /// * `id` The server identifier of the resource + pub async fn get_uma2_resource_by_id( + &self, + pat_token: String, + id: String, + ) -> Result { + if !self.provider.uma2_discovered() { + return Err(ClientError::Uma2(NoUma2Discovered)); + } + + if self.provider.resource_registration_uri().is_none() { + return Err(ClientError::Uma2(NoResourceSetEndpoint)); + } + + let mut url = self.provider.resource_registration_uri().unwrap().clone(); + + url.path_segments_mut() + .map_err(|_| ClientError::Uma2(ResourceSetEndpointMalformed))? + .extend(&[id]); + + let json = self + .http_client + .get(url) + .header(CONTENT_TYPE, "application/json") + .header(AUTHORIZATION, format!("Bearer {:}", pat_token)) + .send() + .await? + .json::() + .await?; + + let error: Result = serde_json::from_value(json.clone()); + + if let Ok(error) = error { + Err(ClientError::from(error)) + } else { + let resource: Uma2Resource = serde_json::from_value(json)?; + Ok(resource) + } + } + + /// + /// Search for a UMA2 resource + /// + /// # Arguments + /// * `pat_token` A Protection API token (PAT) is like any OAuth2 token, but should have the + /// * `name` Search by the resource's name + /// * `uri` Search by the resource's uri + /// * `owner` Search by the resource's owner + /// * `resource_type` Search by the resource's type + /// * `scope` Search by the resource's scope + /// + pub async fn search_for_uma2_resources( + &self, + pat_token: String, + name: Option, + uri: Option, + owner: Option, + resource_type: Option, + scope: Option, + ) -> Result, ClientError> { + if !self.provider.uma2_discovered() { + return Err(ClientError::Uma2(NoUma2Discovered)); + } + + if self.provider.resource_registration_uri().is_none() { + return Err(ClientError::Uma2(NoResourceSetEndpoint)); + } + + let mut url = self.provider.resource_registration_uri().unwrap().clone(); + { + let mut query = url.query_pairs_mut(); + if name.is_some() { + query.append_pair("name", name.unwrap().as_str()); + } + if uri.is_some() { + query.append_pair("uri", uri.unwrap().as_str()); + } + if owner.is_some() { + query.append_pair("owner", owner.unwrap().as_str()); + } + if resource_type.is_some() { + query.append_pair("type", resource_type.unwrap().as_str()); + } + if scope.is_some() { + query.append_pair("scope", scope.unwrap().as_str()); + } + } + + let json = self + .http_client + .get(url) + .header(CONTENT_TYPE, "application/json") + .header(AUTHORIZATION, format!("Bearer {:}", pat_token)) + .header(ACCEPT, "application/json") + .send() + .await? + .json::() + .await?; + + let error: Result = serde_json::from_value(json.clone()); + + if let Ok(error) = error { + Err(ClientError::from(error)) + } else { + let resources: Vec = serde_json::from_value(json)?; + Ok(resources) + } + } +} diff --git a/src/uma2/rpt.rs b/src/uma2/rpt.rs new file mode 100644 index 0000000..9e1d8d8 --- /dev/null +++ b/src/uma2/rpt.rs @@ -0,0 +1,200 @@ +use crate::error::ClientError; +use crate::uma2::error::Uma2Error::*; +use crate::uma2::permission_ticket::Uma2PermissionTicketRequest; +use crate::uma2::*; +use crate::{Bearer, Claims, Client, OAuth2Error, Provider}; +use biscuit::CompactJson; +use reqwest::header::{AUTHORIZATION, CONTENT_TYPE}; +use serde_json::Value; +use url::form_urlencoded::Serializer; + +pub enum Uma2AuthenticationMethod { + Bearer, + Basic, +} + +impl Client +where + P: Provider + Uma2Provider, + C: CompactJson + Claims, +{ + /// + /// Obtain an RPT from a UMA2 compliant OIDC server + /// + /// # Arguments + /// * `token` Bearer token to do the RPT call + /// * `ticket` The most recent permission ticket received by the client as part of the UMA authorization process + /// * `claim_token` A string representing additional claims that should be considered by the + /// server when evaluating permissions for the resource(s) and scope(s) being requested. + /// * `claim_token_format` urn:ietf:params:oauth:token-type:jwt or https://openid.net/specs/openid-connect-core-1_0.html#IDToken + /// * `rpt` A previously issued RPT which permissions should also be evaluated and added in a + /// new one. This parameter allows clients in possession of an RPT to perform incremental + /// authorization where permissions are added on demand. + /// * `permission` String representing a set of one or more resources and scopes the client is + /// seeking access. This parameter can be defined multiple times in order to request + /// permission for multiple resource and scopes. This parameter is an extension to + /// urn:ietf:params:oauth:grant-type:uma-ticket grant type in order to allow clients to + /// send authorization requests without a permission ticket + /// * `audience` The client identifier of the resource server to which the client is seeking + /// access. This parameter is mandatory in case the permission parameter is defined + /// * `response_include_resource_name` A boolean value indicating to the server whether + /// resource names should be included in the RPT’s permissions. If false, only the + /// resource identifier is included + /// * `response_permissions_limit` An integer N that defines a limit for the amount of + /// permissions an RPT can have. When used together with rpt parameter, only the last N + /// requested permissions will be kept in the RPT. + /// * `submit_request` A boolean value indicating whether the server should create permission + /// requests to the resources and scopes referenced by a permission ticket. This parameter + /// only have effect if used together with the ticket parameter as part of a UMA authorization process + pub async fn obtain_requesting_party_token( + &self, + token: String, + auth_method: Uma2AuthenticationMethod, + ticket: Option, + claim_token: Option, + claim_token_format: Option, + rpt: Option, + permission: Option>, + audience: Option, + response_include_resource_name: Option, + response_permissions_limit: Option, + submit_request: Option, + ) -> Result { + if !self.provider.uma2_discovered() { + return Err(ClientError::Uma2(NoUma2Discovered)); + } + + if let Some(p) = permission.as_ref() { + if p.is_empty() && audience.is_none() { + return Err(ClientError::Uma2(AudienceFieldRequired)); + } + } + + let mut body = Serializer::new(String::new()); + body.append_pair("grant_type", "urn:ietf:params:oauth:grant-type:uma-ticket"); + if ticket.is_some() { + body.append_pair("ticket", ticket.unwrap().as_str()); + } + + if claim_token.is_some() { + body.append_pair("claim_token", claim_token.unwrap().as_str()); + } + + if claim_token_format.is_some() { + body.append_pair( + "claim_token_format", + claim_token_format.map(|b| b.to_string()).unwrap().as_str(), + ); + } + + if rpt.is_some() { + body.append_pair("rpt", rpt.unwrap().as_str()); + } + + if permission.is_some() { + permission.unwrap().iter().for_each(|perm| { + body.append_pair("permission", perm.as_str()); + }); + } + + if audience.is_some() { + body.append_pair("audience", audience.unwrap().as_str()); + } + + if response_include_resource_name.is_some() { + body.append_pair( + "response_include_resource_name", + response_include_resource_name + .map(|b| if b { "true" } else { "false" }) + .unwrap(), + ); + } + if response_permissions_limit.is_some() { + body.append_pair( + "response_permissions_limit", + format!("{:}", response_permissions_limit.unwrap()).as_str(), + ); + } + + if submit_request.is_some() { + body.append_pair( + "submit_request", + format!("{:}", submit_request.unwrap()).as_str(), + ); + } + + let body = body.finish(); + let auth_method = match auth_method { + Uma2AuthenticationMethod::Basic => format!("Basic {:}", token), + Uma2AuthenticationMethod::Bearer => format!("Bearer {:}", token), + }; + + let json = self + .http_client + .post(self.provider.token_uri().clone()) + .header(CONTENT_TYPE, "application/x-www-form-urlencoded") + .header(AUTHORIZATION, auth_method.as_str()) + .body(body) + .send() + .await? + .json::() + .await?; + + let error: Result = serde_json::from_value(json.clone()); + + if let Ok(error) = error { + Err(ClientError::from(error)) + } else { + let new_token: Bearer = serde_json::from_value(json)?; + Ok(new_token.access_token) + } + } + + /// + /// Create a permission ticket. + /// A permission ticket is a special security token type representing a permission request. + /// Per the UMA specification, a permission ticket is: + /// A correlation handle that is conveyed from an authorization server to a resource server, + /// from a resource server to a client, and ultimately from a client back to an authorization + /// server, to enable the authorization server to assess the correct policies to apply to a + /// request for authorization data. + /// + /// # Arguments + /// * `pat_token` A Protection API token (PAT) is like any OAuth2 token, but should have the + /// * `requests` A list of resources, optionally with their scopes, optionally with extra claims to be + /// processed. + pub async fn create_uma2_permission_ticket( + &self, + pat_token: String, + requests: Vec, + ) -> Result { + if !self.provider.uma2_discovered() { + return Err(ClientError::Uma2(NoUma2Discovered)); + } + + if self.provider.permission_uri().is_none() { + return Err(ClientError::Uma2(NoPermissionsEndpoint)); + } + let url = self.provider.permission_uri().unwrap().clone(); + + let json = self + .http_client + .post(url) + .header(CONTENT_TYPE, "application/json") + .header(AUTHORIZATION, format!("Bearer {:}", pat_token)) + .json(&requests) + .send() + .await? + .json::() + .await?; + + let error: Result = serde_json::from_value(json.clone()); + + if let Ok(error) = error { + Err(ClientError::from(error)) + } else { + let response = serde_json::from_value(json)?; + Ok(response) + } + } +}