From 6fba29efd7c9775f5cc4d87ab94e2978f5fef1ed Mon Sep 17 00:00:00 2001 From: Michael Telahun Date: Fri, 17 Feb 2023 17:52:00 +0300 Subject: [PATCH] Implement an async version of client credentials flow --- oxide-auth-async/src/code_grant.rs | 210 +++++++++ .../src/endpoint/client_credentials.rs | 380 +++++++++++++++ oxide-auth-async/src/endpoint/mod.rs | 7 + .../src/tests/client_credentials.rs | 437 ++++++++++++++++++ oxide-auth-async/src/tests/mod.rs | 1 + oxide-auth/src/code_grant/accesstoken.rs | 8 +- oxide-auth/src/code_grant/error.rs | 3 +- oxide-auth/src/code_grant/refresh.rs | 5 + oxide-auth/src/endpoint/mod.rs | 2 +- 9 files changed, 1050 insertions(+), 3 deletions(-) create mode 100644 oxide-auth-async/src/endpoint/client_credentials.rs create mode 100644 oxide-auth-async/src/tests/client_credentials.rs diff --git a/oxide-auth-async/src/code_grant.rs b/oxide-auth-async/src/code_grant.rs index 5222463e..d40a7d5a 100644 --- a/oxide-auth-async/src/code_grant.rs +++ b/oxide-auth-async/src/code_grant.rs @@ -505,3 +505,213 @@ pub mod authorization { } } } + +pub mod client_credentials { + use std::borrow::Cow; + + use async_trait::async_trait; + use chrono::{Utc, Duration}; + use oxide_auth::{ + code_grant::{ + accesstoken::{BearerToken, PrimitiveError}, + client_credentials::{ + ClientCredentials, Error, Input, Output, + Request as TokenRequest, + }, + }, + endpoint::{PreGrant, Scope, Solicitation}, + primitives::{ + grant::{Extensions, Grant}, registrar::{BoundClient, ClientUrl, RegistrarError}, + }, + }; + + /// A system of addons provided additional data. + /// + /// An endpoint not having any extension may use `&mut ()` as the result of system. + #[async_trait] + pub trait Extension { + /// Inspect the request to produce extension data. + async fn extend(&mut self, request: &(dyn TokenRequest + Sync)) -> std::result::Result; + } + + #[async_trait] + impl Extension for () { + async fn extend(&mut self, _: &(dyn TokenRequest + Sync)) -> std::result::Result { + Ok(Extensions::new()) + } + } + + /// Required functionality to respond to client credentials requests. + /// + /// Each method will only be invoked exactly once when processing a correct and authorized request, + /// and potentially less than once when the request is faulty. These methods should be implemented + /// by internally using `primitives`, as it is implemented in the `frontend` module. + pub trait Endpoint { + /// Get the client corresponding to some id. + fn registrar(&self) -> &(dyn crate::primitives::Registrar + Sync); + + /// Return the issuer instance to create the client credentials. + fn issuer(&mut self) -> &mut (dyn crate::primitives::Issuer + Send); + + /// The system of used extension, extending responses. + /// + /// It is possible to use `&mut ()`. + fn extension(&mut self) -> &mut (dyn Extension + Send); + } + + /// Represents a valid, currently pending client credentials not bound to an owner. + /// + /// This will be passed along to the solicitor to obtain the owner ID, and then + /// a token will be issued. Since this is the client credentials flow, a pending + /// response is considered an internal error. + #[derive(Clone)] + pub struct Pending { + pre_grant: PreGrant, + extensions: Extensions, + } + + impl Pending { + /// Reference this pending state as a solicitation. + pub fn as_solicitation(&self) -> Solicitation<'_> { + Solicitation::new(&self.pre_grant) + } + + /// Inform the backend about consent from a resource owner. + /// + /// Use negotiated parameters to authorize a client for an owner. The endpoint SHOULD be the + /// same endpoint as was used to create the pending request. + pub async fn issue( + self, handler: &mut (dyn Endpoint + Send), owner_id: Cow<'_, str>, allow_refresh_token: bool, + ) -> Result { + let mut token = handler + .issuer() + .issue(Grant { + owner_id: owner_id.into_owned(), + client_id: self.pre_grant.client_id, + redirect_uri: self.pre_grant.redirect_uri.into_url(), + scope: self.pre_grant.scope.clone(), + until: Utc::now() + Duration::minutes(10), + extensions: self.extensions, + }) + .await + .map_err(|()| Error::Primitive(Box::new(PrimitiveError::empty())))?; + + if !allow_refresh_token { + token.refresh = None; + } + + Ok(BearerToken::new(token, self.pre_grant.scope.to_string())) + } + } + + pub async fn client_credentials( + handler: &mut (dyn Endpoint + Send + Sync), + request: &(dyn TokenRequest + Sync) + ) -> Result { + enum Requested { + None, + Authenticate { + client: String, + passdata: Vec, + }, + Bind { + client_id: String, + }, + Extend, + Negotiate { + bound_client: BoundClient<'static>, + scope: Option, + }, + } + + let mut client_credentials = ClientCredentials::new(request); + let mut requested = Requested::None; + + loop { + let input = match requested { + Requested::None => Input::None, + Requested::Authenticate { client, passdata } => { + handler + .registrar() + .check(&client, Some(passdata.as_slice())) + .await + .map_err(|err| match err { + RegistrarError::Unspecified => Error::unauthorized("basic"), + RegistrarError::PrimitiveError => Error::Primitive(Box::new(PrimitiveError { + grant: None, + extensions: None, + })), + })?; + Input::Authenticated + } + Requested::Bind { client_id } => { + let client_url = ClientUrl { + client_id: Cow::Owned(client_id), + redirect_uri: None, + }; + let bound_client = match handler. + registrar() + .bound_redirect(client_url) + .await { + Err(RegistrarError::Unspecified) => return Err(Error::Ignore), + Err(RegistrarError::PrimitiveError) => { + return Err(Error::Primitive(Box::new(PrimitiveError { + grant: None, + extensions: None, + }))); + } + Ok(pre_grant) => pre_grant, + }; + Input::Bound { bound_client } + } + Requested::Extend => { + let extensions = handler + .extension() + .extend(request) + .await + .map_err(|_| Error::invalid())?; + Input::Extended { extensions } + } + Requested::Negotiate { bound_client, scope } => { + let pre_grant = handler + .registrar() + .negotiate(bound_client.clone(), scope.clone()) + .await + .map_err(|err| match err { + RegistrarError::PrimitiveError => Error::Primitive(Box::new(PrimitiveError { + grant: None, + extensions: None, + })), + RegistrarError::Unspecified => Error::Ignore, + })?; + Input::Negotiated { pre_grant } + } + }; + + requested = match client_credentials.advance(input) { + Output::Authenticate { client, passdata } => Requested::Authenticate { + client: client.to_owned(), + passdata: passdata.to_vec(), + }, + Output::Binding { client_id } => Requested::Bind { + client_id: client_id.to_owned(), + }, + Output::Extend => Requested::Extend, + Output::Negotiate { bound_client, scope } => Requested::Negotiate { + bound_client: bound_client.clone(), + scope, + }, + Output::Ok { + pre_grant, + extensions, + } => { + return Ok(Pending { + pre_grant: pre_grant.clone(), + extensions: extensions.clone(), + }) + } + Output::Err(e) => return Err(*e), + }; + } + } +} diff --git a/oxide-auth-async/src/endpoint/client_credentials.rs b/oxide-auth-async/src/endpoint/client_credentials.rs new file mode 100644 index 00000000..e90795d2 --- /dev/null +++ b/oxide-auth-async/src/endpoint/client_credentials.rs @@ -0,0 +1,380 @@ +use std::borrow::Cow; +use std::str::from_utf8; +use std::marker::PhantomData; + +use oxide_auth::{ + code_grant::{ + error::{AccessTokenError, AccessTokenErrorType}, + client_credentials::{ + Error as ClientCredentialsError, + Request as ClientCredentialsRequest, + }, + refresh::ErrorDescription, + }, + endpoint::{QueryParameter, WebRequest, OAuthError, WebResponse, InnerTemplate, OwnerConsent, NormalizedParameter}, +}; + +use super::Endpoint; +use crate::{ + code_grant::client_credentials::{Endpoint as TokenEndpoint, Extension, client_credentials}, + primitives::{Issuer, Registrar}, +}; + +/// Offers access tokens to authenticated third parties. +/// +/// A client may request a token that provides access to their own resources. +/// +/// Client credentials can be allowed to appear in the request body instead of being +/// required to be passed as HTTP Basic authorization. This is not recommended and must be +/// enabled explicitely. See [`allow_credentials_in_body`] for details. +/// +/// [`allow_credentials_in_body`]: #method.allow_credentials_in_body +pub struct ClientCredentialsFlow +where + E: Endpoint, + R: WebRequest, +{ + endpoint: WrappedToken, + allow_credentials_in_body: bool, + allow_refresh_token: bool, +} + +struct WrappedToken +where + E: Endpoint, + R: WebRequest, +{ + inner: E, + extension_fallback: (), + r_type: PhantomData, +} + +#[derive(Clone)] +struct WrappedRequest { + /// Original request. + request: PhantomData, + + /// The request body + body: NormalizedParameter, + + /// The authorization tuple + authorization: Option, + + /// An error if one occurred. + error: Option>, + + /// The credentials-in-body flag from the flow. + allow_credentials_in_body: bool, +} + +struct Invalid; + +#[derive(Clone)] +enum FailParse { + Invalid, + Err(E), +} + +#[derive(Clone)] +struct Authorization(String, Vec); + +impl ClientCredentialsFlow +where + E: Endpoint + Send + Sync, + R: WebRequest + Send + Sync, + ::Error: Send + Sync, +{ + /// Check that the endpoint supports the necessary operations for handling requests. + /// + /// Binds the endpoint to a particular type of request that it supports, for many + /// implementations this is probably single type anyways. + /// + /// ## Panics + /// + /// Indirectly `execute` may panic when this flow is instantiated with an inconsistent + /// endpoint, for details see the documentation of `Endpoint` and `execute`. For + /// consistent endpoints, the panic is instead caught as an error here. + pub fn prepare(mut endpoint: E) -> Result { + if endpoint.registrar().is_none() { + return Err(endpoint.error(OAuthError::PrimitiveError)); + } + + if endpoint.issuer_mut().is_none() { + return Err(endpoint.error(OAuthError::PrimitiveError)); + } + + Ok(ClientCredentialsFlow { + endpoint: WrappedToken { + inner: endpoint, + extension_fallback: (), + r_type: PhantomData, + }, + allow_credentials_in_body: false, + allow_refresh_token: false, + }) + } + + /// Credentials in body should only be enabled if use of HTTP Basic is not possible. + /// + /// Allows the request body to contain the `client_secret` as a form parameter. This is NOT + /// RECOMMENDED and need not be supported. The parameters MUST NOT appear in the request URI + /// itself. + /// + /// Thus support is disabled by default and must be explicitely enabled. + pub fn allow_credentials_in_body(&mut self, allow: bool) { + self.allow_credentials_in_body = allow; + } + + /// Allow the refresh token to be included in the response. + /// + /// According to [RFC-6749 Section 4.4.3][4.4.3] "A refresh token SHOULD NOT be included" in + /// the response for the client credentials grant. Following that recommendation, the default + /// behaviour of this flow is to discard any refresh token that is returned from the issuer. + /// + /// If this behaviour is not what you want (it is possible that your particular application + /// does have a use for a client credentials refresh token), you may enable this feature. + /// + /// [4.4.3]: https://www.rfc-editor.org/rfc/rfc6749#section-4.4.3 + pub fn allow_refresh_token(&mut self, allow: bool) { + self.allow_refresh_token = allow; + } + + /// Use the checked endpoint to check for authorization for a resource. + /// + /// ## Panics + /// + /// When the registrar, authorizer, or issuer returned by the endpoint is suddenly + /// `None` when previously it was `Some(_)`. + pub async fn execute(&mut self, mut request: R) -> Result { + let pending = client_credentials( + &mut self.endpoint, + &WrappedRequest::new(&mut request, self.allow_credentials_in_body), + ) + .await; + let pending = match pending { + Err(error) => { + return client_credentials_error(&mut self.endpoint.inner, &mut request, error) + } + Ok(pending) => pending, + }; + + let consent = self + .endpoint + .inner + .owner_solicitor() + .unwrap() + .check_consent(&mut request, pending.as_solicitation()) + .await; + + let owner_id = match consent { + OwnerConsent::Authorized(owner_id) => owner_id, + OwnerConsent::Error(error) => return Err(self.endpoint.inner.web_error(error)), + OwnerConsent::InProgress(..) => { + // User interaction is not permitted in the client credentials flow, so + // an InProgress response is invalid. + return Err(self.endpoint.inner.error(OAuthError::PrimitiveError)); + } + OwnerConsent::Denied => { + let mut error = AccessTokenError::default(); + error.set_type(AccessTokenErrorType::InvalidClient); + let mut json = ErrorDescription::new(error); + let mut response = self.endpoint.inner.response( + &mut request, + InnerTemplate::Unauthorized { + error: None, + access_token_error: Some(json.description()), + } + .into(), + )?; + response + .client_error() + .map_err(|err| self.endpoint.inner.web_error(err))?; + response + .body_json(&json.to_json()) + .map_err(|err| self.endpoint.inner.web_error(err))?; + return Ok(response); + } + }; + + let token = match pending + .issue(&mut self.endpoint, owner_id.into(), self.allow_refresh_token) + .await { + Err(error) => { + return client_credentials_error(&mut self.endpoint.inner, &mut request, error) + } + Ok(token) => token, + }; + + let mut response = self + .endpoint + .inner + .response(&mut request, InnerTemplate::Ok.into())?; + response + .body_json(&token.to_json()) + .map_err(|err| self.endpoint.inner.web_error(err))?; + Ok(response) + } +} + +fn client_credentials_error, R: WebRequest>( + endpoint: &mut E, request: &mut R, error: ClientCredentialsError, +) -> Result { + Ok(match error { + ClientCredentialsError::Ignore => return Err(endpoint.error(OAuthError::DenySilently)), + ClientCredentialsError::Invalid(mut json) => { + let mut response = endpoint.response( + request, + InnerTemplate::BadRequest { + access_token_error: Some(json.description()), + } + .into(), + )?; + response.client_error().map_err(|err| endpoint.web_error(err))?; + response + .body_json(&json.to_json()) + .map_err(|err| endpoint.web_error(err))?; + response + } + ClientCredentialsError::Unauthorized(mut json, scheme) => { + let mut response = endpoint.response( + request, + InnerTemplate::Unauthorized { + error: None, + access_token_error: Some(json.description()), + } + .into(), + )?; + response + .unauthorized(&scheme) + .map_err(|err| endpoint.web_error(err))?; + response + .body_json(&json.to_json()) + .map_err(|err| endpoint.web_error(err))?; + response + } + ClientCredentialsError::Primitive(_) => { + // FIXME: give the context for restoration. + return Err(endpoint.error(OAuthError::PrimitiveError)); + } + }) +} + +impl TokenEndpoint for WrappedToken +where + E: Endpoint, + R: WebRequest, +{ + fn registrar(&self) -> &(dyn Registrar + Sync) { + self.inner.registrar().unwrap() + } + + fn issuer(&mut self) -> &mut (dyn Issuer + Send) { + self.inner.issuer_mut().unwrap() + } + + fn extension(&mut self) -> &mut (dyn Extension + Send) { + self.inner + .extension() + .and_then(super::Extension::client_credentials) + .unwrap_or(&mut self.extension_fallback) + } +} + +impl WrappedRequest { + pub fn new(request: &mut R, credentials: bool) -> Self { + Self::new_or_fail(request, credentials).unwrap_or_else(Self::from_err) + } + + fn new_or_fail(request: &mut R, credentials: bool) -> Result> { + // If there is a header, it must parse correctly. + let authorization = match request.authheader() { + Err(err) => return Err(FailParse::Err(err)), + Ok(Some(header)) => Self::parse_header(header).map(Some)?, + Ok(None) => None, + }; + + Ok(WrappedRequest { + request: PhantomData, + body: request.urlbody().map_err(FailParse::Err)?.into_owned(), + authorization, + error: None, + allow_credentials_in_body: credentials, + }) + } + + fn from_err(err: FailParse) -> Self { + WrappedRequest { + request: PhantomData, + body: Default::default(), + authorization: None, + error: Some(err), + allow_credentials_in_body: false, + } + } + + fn parse_header(header: Cow) -> Result { + let authorization = { + if !header.starts_with("Basic ") { + return Err(Invalid); + } + + let combined = match base64::decode(&header[6..]) { + Err(_) => return Err(Invalid), + Ok(vec) => vec, + }; + + let mut split = combined.splitn(2, |&c| c == b':'); + let client_bin = match split.next() { + None => return Err(Invalid), + Some(client) => client, + }; + let passwd = match split.next() { + None => return Err(Invalid), + Some(passwd64) => passwd64, + }; + + let client = match from_utf8(client_bin) { + Err(_) => return Err(Invalid), + Ok(client) => client, + }; + + Authorization(client.to_string(), passwd.to_vec()) + }; + + Ok(authorization) + } +} + +impl ClientCredentialsRequest for WrappedRequest { + fn valid(&self) -> bool { + self.error.is_none() + } + + fn authorization(&self) -> Option<(Cow, Cow<[u8]>)> { + self.authorization + .as_ref() + .map(|auth| (auth.0.as_str().into(), auth.1.as_slice().into())) + } + + fn grant_type(&self) -> Option> { + self.body.unique_value("grant_type") + } + + fn scope(&self) -> Option> { + self.body.unique_value("scope") + } + + fn extension(&self, key: &str) -> Option> { + self.body.unique_value(key) + } + + fn allow_credentials_in_body(&self) -> bool { + self.allow_credentials_in_body + } +} + +impl From for FailParse { + fn from(_: Invalid) -> Self { + FailParse::Invalid + } +} diff --git a/oxide-auth-async/src/endpoint/mod.rs b/oxide-auth-async/src/endpoint/mod.rs index 564e2754..15a0f306 100644 --- a/oxide-auth-async/src/endpoint/mod.rs +++ b/oxide-auth-async/src/endpoint/mod.rs @@ -3,10 +3,12 @@ use oxide_auth::endpoint::{OAuthError, Template, WebRequest, OwnerConsent, Solic pub use crate::code_grant::access_token::{Extension as AccessTokenExtension}; pub use crate::code_grant::authorization::Extension as AuthorizationExtension; +pub use crate::code_grant::client_credentials::Extension as ClientCredentialsExtension; use crate::primitives::{Authorizer, Registrar, Issuer}; pub mod authorization; pub mod access_token; +pub mod client_credentials; pub mod refresh; pub mod resource; @@ -79,6 +81,11 @@ pub trait Extension { fn access_token(&mut self) -> Option<&mut (dyn AccessTokenExtension + Send)> { None } + + /// The handler for client credentials extensions. + fn client_credentials(&mut self) -> Option<&mut (dyn ClientCredentialsExtension + Send)> { + None + } } /// Checks consent with the owner of a resource, identified in a request. diff --git a/oxide-auth-async/src/tests/client_credentials.rs b/oxide-auth-async/src/tests/client_credentials.rs new file mode 100644 index 00000000..611664c5 --- /dev/null +++ b/oxide-auth-async/src/tests/client_credentials.rs @@ -0,0 +1,437 @@ +use oxide_auth::{ + primitives::{ + issuer::TokenMap, + registrar::{Client, ClientMap, RegisteredUrl}, + }, frontends::simple::endpoint::Error, endpoint::WebRequest, +}; + +use crate::{ + endpoint::{ + client_credentials::ClientCredentialsFlow, + OwnerSolicitor, Endpoint, + }, +}; + +use super::{CraftedRequest, Status, TestGenerator, ToSingleValueQuery}; +use super::{Allow, Deny}; +use super::defaults::*; + +struct ClientCredentialsSetup { + registrar: ClientMap, + issuer: TokenMap, + basic_authorization: String, + allow_credentials_in_body: bool, +} + +struct ClientCredentialsEndpoint<'a> { + registrar: &'a ClientMap, + issuer: &'a mut TokenMap, + solicitor: &'a mut (dyn OwnerSolicitor + Send + Sync), +} + +impl<'a> ClientCredentialsEndpoint<'a> { + pub fn new( + registrar: &'a ClientMap, + issuer: &'a mut TokenMap, + solicitor: &'a mut (dyn OwnerSolicitor + Send + Sync), + ) -> Self { + Self { + registrar, + issuer, + solicitor, + } + } +} + +impl<'a> Endpoint for ClientCredentialsEndpoint<'a> { + type Error = Error; + + fn registrar(&self) -> Option<&(dyn crate::primitives::Registrar + Sync)> { + Some(self.registrar) + } + fn issuer_mut(&mut self) -> Option<&mut (dyn crate::primitives::Issuer + Send)> { + Some(self.issuer) + } + fn response( + &mut self, _: &mut CraftedRequest, _: oxide_auth::endpoint::Template, + ) -> Result<::Response, Self::Error> { + Ok(Default::default()) + } + fn error(&mut self, _err: oxide_auth::endpoint::OAuthError) -> Self::Error { + unimplemented!() + } + fn web_error(&mut self, _err: ::Error) -> Self::Error { + unimplemented!() + } + fn scopes(&mut self) -> Option<&mut dyn oxide_auth::endpoint::Scopes> { + None + } + fn owner_solicitor( + &mut self, + ) -> Option<&mut (dyn crate::endpoint::OwnerSolicitor + Send)> { + Some(self.solicitor) + } + + fn authorizer_mut(&mut self) -> Option<&mut (dyn crate::primitives::Authorizer + Send)> { + unimplemented!() + } +} + +impl ClientCredentialsSetup { + fn new() -> ClientCredentialsSetup { + let mut registrar = ClientMap::new(); + let issuer = TokenMap::new(TestGenerator("AuthToken".to_owned())); + + let client = Client::confidential( + EXAMPLE_CLIENT_ID, + RegisteredUrl::Semantic(EXAMPLE_REDIRECT_URI.parse().unwrap()), + EXAMPLE_SCOPE.parse().unwrap(), + EXAMPLE_PASSPHRASE.as_bytes(), + ); + registrar.register_client(client); + let basic_authorization = + base64::encode(&format!("{}:{}", EXAMPLE_CLIENT_ID, EXAMPLE_PASSPHRASE)); + ClientCredentialsSetup { + registrar, + issuer, + basic_authorization, + allow_credentials_in_body: false, + } + } + + fn public_client() -> Self { + let mut registrar = ClientMap::new(); + let issuer = TokenMap::new(TestGenerator("AccessToken".to_owned())); + + let client = Client::public( + EXAMPLE_CLIENT_ID, + RegisteredUrl::Semantic(EXAMPLE_REDIRECT_URI.parse().unwrap()), + EXAMPLE_SCOPE.parse().unwrap(), + ); + registrar.register_client(client); + let basic_authorization = + base64::encode(&format!("{}:{}", EXAMPLE_CLIENT_ID, EXAMPLE_PASSPHRASE)); + ClientCredentialsSetup { + registrar, + issuer, + basic_authorization, + allow_credentials_in_body: false, + } + } + + fn test_success(&mut self, request: CraftedRequest, mut solicitor: S) + where + S: OwnerSolicitor + Send + Sync, + { + let mut client_credentials_flow = ClientCredentialsFlow::prepare( + ClientCredentialsEndpoint::new(&self.registrar, &mut self.issuer, &mut solicitor) + ) + .unwrap(); + client_credentials_flow.allow_credentials_in_body(self.allow_credentials_in_body); + + let response = smol::block_on(client_credentials_flow.execute(request)) + .expect("Expected a non-error response"); + + assert_eq!(response.status, Status::Ok); + } + + fn test_bad_request(&mut self, request: CraftedRequest, mut solicitor: S) + where + S: OwnerSolicitor + Send + Sync, + { + let mut client_credentials_flow = ClientCredentialsFlow::prepare( + ClientCredentialsEndpoint::new(&self.registrar, &mut self.issuer, &mut solicitor) + ) + .unwrap(); + client_credentials_flow.allow_credentials_in_body(self.allow_credentials_in_body); + + let response = smol::block_on(client_credentials_flow.execute(request)) + .expect("Expected a non-error response"); + + assert_eq!(response.status, Status::BadRequest); + } + + fn test_unauthorized(&mut self, request: CraftedRequest, mut solicitor: S) + where + S: OwnerSolicitor + Send + Sync, + { + let mut client_credentials_flow = ClientCredentialsFlow::prepare( + ClientCredentialsEndpoint::new(&self.registrar, &mut self.issuer, &mut solicitor) + ) + .unwrap(); + client_credentials_flow.allow_credentials_in_body(self.allow_credentials_in_body); + + let response = smol::block_on(client_credentials_flow.execute(request)) + .expect("Expected a non-error response"); + + assert_eq!(response.status, Status::Unauthorized); + } +} + +#[test] +fn client_credentials_success() { + let mut setup = ClientCredentialsSetup::new(); + let success = CraftedRequest { + query: None, + urlbody: Some( + vec![("grant_type", "client_credentials")] + .iter() + .to_single_value_query(), + ), + auth: Some(format!("Basic {}", setup.basic_authorization)), + }; + + setup.test_success(success, Allow(EXAMPLE_CLIENT_ID.to_owned())); +} + +#[test] +fn client_credentials_success_changed_owner() { + let mut setup = ClientCredentialsSetup::new(); + let success = CraftedRequest { + query: None, + urlbody: Some( + vec![("grant_type", "client_credentials")] + .iter() + .to_single_value_query(), + ), + auth: Some(format!("Basic {}", setup.basic_authorization)), + }; + + setup.test_success(success, Allow("OtherOwnerId".to_owned())); +} + +#[test] +fn client_credentials_deny_public_client() { + let mut setup = ClientCredentialsSetup::public_client(); + let public_client = CraftedRequest { + query: None, + urlbody: Some( + vec![ + ("grant_type", "client_credentials"), + ("client_id", EXAMPLE_CLIENT_ID), + ] + .iter() + .to_single_value_query(), + ), + auth: None, + }; + + setup.test_bad_request(public_client, Deny); +} + +#[test] +fn client_credentials_deny_incorrect_credentials() { + let mut setup = ClientCredentialsSetup::new(); + let basic_authorization = base64::encode(&format!("{}:the wrong passphrase", EXAMPLE_CLIENT_ID)); + let wrong_credentials = CraftedRequest { + query: None, + urlbody: Some( + vec![("grant_type", "client_credentials")] + .iter() + .to_single_value_query(), + ), + auth: Some(format!("Basic {}", basic_authorization)), + }; + + setup.test_unauthorized(wrong_credentials, Allow(EXAMPLE_CLIENT_ID.to_owned())); +} + +#[test] +fn client_credentials_deny_missing_credentials() { + let mut setup = ClientCredentialsSetup::new(); + let missing_credentials = CraftedRequest { + query: None, + urlbody: Some( + vec![ + ("grant_type", "client_credentials"), + ("client_id", EXAMPLE_CLIENT_ID), + ] + .iter() + .to_single_value_query(), + ), + auth: None, + }; + + setup.test_bad_request(missing_credentials, Allow(EXAMPLE_CLIENT_ID.to_owned())); +} + +#[test] +fn client_credentials_deny_unknown_client_missing_password() { + // The client_id is not registered + let unknown_client = CraftedRequest { + query: None, + urlbody: Some( + vec![ + ("grant_type", "client_credentials"), + ("client_id", "SomeOtherClient"), + ] + .iter() + .to_single_value_query(), + ), + auth: None, + }; + + ClientCredentialsSetup::new().test_bad_request(unknown_client, Allow("SomeOtherClient".to_owned())); +} + +#[test] +fn client_credentials_deny_body_missing_password() { + let mut setup = ClientCredentialsSetup::new(); + setup.allow_credentials_in_body = true; + // The client_id is not registered + let unknown_client = CraftedRequest { + query: None, + urlbody: Some( + vec![ + ("grant_type", "client_credentials"), + ("client_id", EXAMPLE_CLIENT_ID), + ] + .iter() + .to_single_value_query(), + ), + auth: None, + }; + + setup.test_bad_request(unknown_client, Allow(EXAMPLE_CLIENT_ID.to_owned())); +} + +#[test] +fn client_credentials_deny_unknown_client() { + // The client_id is not registered + let mut setup = ClientCredentialsSetup::new(); + let basic_authorization = base64::encode(&format!("{}:{}", "SomeOtherClient", EXAMPLE_PASSPHRASE)); + let unknown_client = CraftedRequest { + query: None, + urlbody: Some( + vec![("grant_type", "client_credentials")] + .iter() + .to_single_value_query(), + ), + auth: Some(format!("Basic {}", basic_authorization)), + }; + + // Do not leak the information that this is unknown. It must appear as a bad login attempt. + setup.test_unauthorized(unknown_client, Allow("SomeOtherClient".to_owned())); +} + +#[test] +fn client_credentials_deny_body_unknown_client() { + let mut setup = ClientCredentialsSetup::new(); + // The client_id is not registered + let unknown_client = CraftedRequest { + query: None, + urlbody: Some( + vec![ + ("grant_type", "client_credentials"), + ("client_id", "SomeOtherClient"), + ("client_secret", EXAMPLE_PASSPHRASE), + ] + .iter() + .to_single_value_query(), + ), + auth: None, + }; + + // Do not leak the information that this is unknown. It must appear as a bad login attempt. + setup.test_bad_request(unknown_client, Allow("SomeOtherClient".to_owned())); +} + +#[test] +fn client_body_credentials() { + let mut setup = ClientCredentialsSetup::new(); + setup.allow_credentials_in_body = true; + + // The client_id is not registered + let unknown_client = CraftedRequest { + query: None, + urlbody: Some( + vec![ + ("grant_type", "client_credentials"), + ("client_id", EXAMPLE_CLIENT_ID), + ("client_secret", EXAMPLE_PASSPHRASE), + ] + .iter() + .to_single_value_query(), + ), + auth: None, + }; + + setup.test_success(unknown_client, Allow(EXAMPLE_OWNER_ID.to_owned())); +} + +#[test] +fn client_duplicate_credentials_deniend() { + let mut setup = ClientCredentialsSetup::new(); + setup.allow_credentials_in_body = true; + + // Both body and authorization header is not allowed. + let unknown_client = CraftedRequest { + query: None, + urlbody: Some( + vec![ + ("grant_type", "client_credentials"), + ("client_id", EXAMPLE_CLIENT_ID), + ("client_secret", EXAMPLE_PASSPHRASE), + ] + .iter() + .to_single_value_query(), + ), + auth: Some(setup.basic_authorization.clone()), + }; + + setup.test_bad_request(unknown_client, Allow(EXAMPLE_OWNER_ID.to_owned())); +} + +#[test] +fn client_credentials_request_error_denied() { + let mut setup = ClientCredentialsSetup::new(); + // Used in conjunction with a denying solicitor below + let denied_request = CraftedRequest { + query: None, + urlbody: Some( + vec![("grant_type", "client_credentials")] + .iter() + .to_single_value_query(), + ), + auth: Some(format!("Basic {}", setup.basic_authorization)), + }; + + setup.test_bad_request(denied_request, Deny); +} + +#[test] +fn client_credentials_request_error_unsupported_grant_type() { + let mut setup = ClientCredentialsSetup::new(); + // Requesting grant with a grant_type other than client_credentials + let unsupported_grant_type = CraftedRequest { + query: None, + urlbody: Some( + vec![("grant_type", "not_client_credentials")] + .iter() + .to_single_value_query(), + ), + auth: Some(format!("Basic {}", setup.basic_authorization)), + }; + + setup.test_bad_request(unsupported_grant_type, Allow(EXAMPLE_OWNER_ID.to_owned())); +} + +#[test] +fn client_credentials_request_error_malformed_scope() { + let mut setup = ClientCredentialsSetup::new(); + // A scope with malformed formatting + let malformed_scope = CraftedRequest { + query: None, + urlbody: Some( + vec![ + ("grant_type", "client_credentials"), + ("scope", "\"no quotes (0x22) allowed\""), + ] + .iter() + .to_single_value_query(), + ), + auth: Some(format!("Basic {}", setup.basic_authorization)), + }; + + setup.test_bad_request(malformed_scope, Allow(EXAMPLE_OWNER_ID.to_owned())); +} diff --git a/oxide-auth-async/src/tests/mod.rs b/oxide-auth-async/src/tests/mod.rs index 0c1c564c..25081f5b 100644 --- a/oxide-auth-async/src/tests/mod.rs +++ b/oxide-auth-async/src/tests/mod.rs @@ -225,6 +225,7 @@ pub mod defaults { mod authorization; mod access_token; +mod client_credentials; mod type_properties; mod resource; mod refresh; diff --git a/oxide-auth/src/code_grant/accesstoken.rs b/oxide-auth/src/code_grant/accesstoken.rs index 0c95bbf9..1ef63c3a 100644 --- a/oxide-auth/src/code_grant/accesstoken.rs +++ b/oxide-auth/src/code_grant/accesstoken.rs @@ -617,7 +617,8 @@ impl Error { } impl PrimitiveError { - pub(crate) fn empty() -> Self { + /// Reset the results cache. + pub fn empty() -> Self { PrimitiveError { grant: None, extensions: None, @@ -644,6 +645,11 @@ impl ErrorDescription { } impl BearerToken { + /// Given token parameters and a scope(s), create a new BearerToken. + pub fn new(token: IssuedToken, scope: String) -> BearerToken { + Self(token, scope) + } + /// Convert the token into a json string, viable for being sent over a network with /// `application/json` encoding. pub fn to_json(&self) -> String { diff --git a/oxide-auth/src/code_grant/error.rs b/oxide-auth/src/code_grant/error.rs index 3436d9e4..31e5f8a1 100644 --- a/oxide-auth/src/code_grant/error.rs +++ b/oxide-auth/src/code_grant/error.rs @@ -174,7 +174,8 @@ impl AccessTokenError { } } - pub(crate) fn set_type(&mut self, new_type: AccessTokenErrorType) { + /// Set a new error code. + pub fn set_type(&mut self, new_type: AccessTokenErrorType) { self.error = new_type; } diff --git a/oxide-auth/src/code_grant/refresh.rs b/oxide-auth/src/code_grant/refresh.rs index 0d5ae28e..05eada19 100644 --- a/oxide-auth/src/code_grant/refresh.rs +++ b/oxide-auth/src/code_grant/refresh.rs @@ -532,6 +532,11 @@ impl Error { } impl ErrorDescription { + /// Create a new description from an access token error + pub fn new(error: AccessTokenError) -> Self { + Self { error } + } + /// Get a handle to the description the client will receive. pub fn description(&mut self) -> &mut AccessTokenError { &mut self.error diff --git a/oxide-auth/src/endpoint/mod.rs b/oxide-auth/src/endpoint/mod.rs index 065d7b22..a7b6fbf1 100644 --- a/oxide-auth/src/endpoint/mod.rs +++ b/oxide-auth/src/endpoint/mod.rs @@ -121,7 +121,7 @@ pub enum ResponseStatus { /// not derive this until this has shown unlikely but strongly requested. Please open an issue if you /// think the pros or cons should be evaluated differently. #[derive(Debug)] -enum InnerTemplate<'a> { +pub enum InnerTemplate<'a> { /// Authorization to access the resource has not been granted. Unauthorized { /// The underlying cause for denying access.