From f5000cb52f74d70090d79def65c629cdfb70fec7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Commaille?= Date: Mon, 25 Jul 2022 14:16:31 +0200 Subject: [PATCH] client-api: Add support for API scope restriction According to MSC2967 --- crates/ruma-client-api/CHANGELOG.md | 1 + crates/ruma-client-api/Cargo.toml | 1 + crates/ruma-client-api/src/error.rs | 205 +++++++++++++++++++++++++++- crates/ruma/Cargo.toml | 2 + 4 files changed, 203 insertions(+), 6 deletions(-) diff --git a/crates/ruma-client-api/CHANGELOG.md b/crates/ruma-client-api/CHANGELOG.md index a4182db2..ee8f07ac 100644 --- a/crates/ruma-client-api/CHANGELOG.md +++ b/crates/ruma-client-api/CHANGELOG.md @@ -21,6 +21,7 @@ Improvements: * Add unstable support for discovering an OpenID Connect server (MSC2965) * Add `SpaceRoomJoinRule::KnockRestricted` (MSC3787) * Add unstable support for private read receipts (MSC2285) +* Add unstable support for API scope restriction (MSC2967) # 0.14.1 diff --git a/crates/ruma-client-api/Cargo.toml b/crates/ruma-client-api/Cargo.toml index 6f00ec8f..ebdd0578 100644 --- a/crates/ruma-client-api/Cargo.toml +++ b/crates/ruma-client-api/Cargo.toml @@ -26,6 +26,7 @@ unstable-msc2654 = [] unstable-msc2676 = [] unstable-msc2677 = [] unstable-msc2965 = [] +unstable-msc2967 = [] unstable-msc3440 = [] unstable-msc3488 = [] client = [] diff --git a/crates/ruma-client-api/src/error.rs b/crates/ruma-client-api/src/error.rs index e1883869..75188a48 100644 --- a/crates/ruma-client-api/src/error.rs +++ b/crates/ruma-client-api/src/error.rs @@ -241,6 +241,10 @@ pub struct Error { /// The http status code. pub status_code: http::StatusCode, + + /// The `WWW-Authenticate` header error message. + #[cfg(feature = "unstable-msc2967")] + pub authenticate: Option, } impl EndpointError for Error { @@ -249,7 +253,27 @@ impl EndpointError for Error { ) -> Result { let status = response.status(); let error_body: ErrorBody = from_json_slice(response.body().as_ref())?; - Ok(error_body.into_error(status)) + + #[cfg(not(feature = "unstable-msc2967"))] + { + Ok(error_body.into_error(status)) + } + + #[cfg(feature = "unstable-msc2967")] + { + use ruma_common::api::error::HeaderDeserializationError; + + let mut error = error_body.into_error(status); + + error.authenticate = response + .headers() + .get(http::header::WWW_AUTHENTICATE) + .map(|val| val.to_str().map_err(HeaderDeserializationError::ToStrError)) + .transpose()? + .and_then(AuthenticateError::from_str); + + Ok(error) + } } } @@ -270,7 +294,13 @@ impl From for ErrorBody { impl ErrorBody { /// Convert the ErrorBody into an Error by adding the http status code. pub fn into_error(self, status_code: http::StatusCode) -> Error { - Error { kind: self.kind, message: self.message, status_code } + Error { + kind: self.kind, + message: self.message, + status_code, + #[cfg(feature = "unstable-msc2967")] + authenticate: None, + } } } @@ -278,11 +308,113 @@ impl OutgoingResponse for Error { fn try_into_http_response( self, ) -> Result, IntoHttpError> { - http::Response::builder() + let builder = http::Response::builder() .header(http::header::CONTENT_TYPE, "application/json") - .status(self.status_code) - .body(ruma_common::serde::json_to_buf(&ErrorBody::from(self))?) - .map_err(Into::into) + .status(self.status_code); + + #[cfg(feature = "unstable-msc2967")] + let builder = if let Some(auth_error) = &self.authenticate { + builder.header(http::header::WWW_AUTHENTICATE, auth_error) + } else { + builder + }; + + builder.body(ruma_common::serde::json_to_buf(&ErrorBody::from(self))?).map_err(Into::into) + } +} + +/// Errors in the `WWW-Authenticate` header. +/// +/// To construct this use `::from_str()`. To get its serialized form, use its +/// `TryInto` implementation. +#[cfg(feature = "unstable-msc2967")] +#[derive(Clone, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub enum AuthenticateError { + /// insufficient_scope + /// + /// Encountered when authentication is handled by OpenID Connect and the current access token + /// isn't authorized for the proper scope for this request. It should be paired with a + /// `401` status code and a `M_FORBIDDEN` error. + InsufficientScope { + /// The new scope to request an authorization for. + scope: String, + }, + + #[doc(hidden)] + _Custom { errcode: PrivOwnedStr, attributes: AuthenticateAttrs }, +} + +#[cfg(feature = "unstable-msc2967")] +#[doc(hidden)] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AuthenticateAttrs(BTreeMap); + +#[cfg(feature = "unstable-msc2967")] +impl AuthenticateError { + /// Construct an `AuthenticateError` from a string. + /// + /// Returns `None` if the string doesn't contain an error. + fn from_str(s: &str) -> Option { + if let Some(val) = s.strip_prefix("Bearer").map(str::trim) { + let mut errcode = None; + let mut attrs = BTreeMap::new(); + + // Split the attributes separated by commas and optionally spaces, then split the keys + // and the values, with the values optionally surrounded by double quotes. + for (key, value) in val + .split(',') + .filter_map(|attr| attr.trim().split_once('=')) + .map(|(key, value)| (key, value.trim_matches('"'))) + { + if key == "error" { + errcode = Some(value); + } else { + attrs.insert(key.to_owned(), value.to_owned()); + } + } + + if let Some(errcode) = errcode { + let error = if let Some(scope) = + attrs.get("scope").filter(|_| errcode == "insufficient_scope") + { + AuthenticateError::InsufficientScope { scope: scope.to_owned() } + } else { + AuthenticateError::_Custom { + errcode: PrivOwnedStr(errcode.into()), + attributes: AuthenticateAttrs(attrs), + } + }; + + return Some(error); + } + } + + None + } +} + +#[cfg(feature = "unstable-msc2967")] +impl TryFrom<&AuthenticateError> for http::HeaderValue { + type Error = http::header::InvalidHeaderValue; + + fn try_from(error: &AuthenticateError) -> Result { + let s = match error { + AuthenticateError::InsufficientScope { scope } => { + format!("Bearer error=\"insufficient_scope\", scope=\"{scope}\"") + } + AuthenticateError::_Custom { errcode, attributes } => { + let mut s = format!("Bearer error=\"{}\"", errcode.0); + + for (key, value) in attributes.0.iter() { + s.push_str(&format!(", {key}=\"{value}\"")); + } + + s + } + }; + + s.try_into() } } @@ -303,4 +435,65 @@ mod tests { assert_eq!(deserialized.kind, ErrorKind::Forbidden); assert_eq!(deserialized.message, "You are not authorized to ban users in this room."); } + + #[cfg(feature = "unstable-msc2967")] + #[test] + fn custom_authenticate_error_sanity() { + use super::AuthenticateError; + + let s = "Bearer error=\"custom_error\", misc=\"some content\""; + + let error = AuthenticateError::from_str(s).unwrap(); + let error_header = http::HeaderValue::try_from(&error).unwrap(); + + assert_eq!(error_header.to_str().unwrap(), s); + } + + #[cfg(feature = "unstable-msc2967")] + #[test] + fn serialize_insufficient_scope() { + use super::AuthenticateError; + + let error = + AuthenticateError::InsufficientScope { scope: "something_privileged".to_owned() }; + let error_header = http::HeaderValue::try_from(&error).unwrap(); + + assert_eq!( + error_header.to_str().unwrap(), + "Bearer error=\"insufficient_scope\", scope=\"something_privileged\"" + ); + } + + #[cfg(feature = "unstable-msc2967")] + #[test] + fn deserialize_insufficient_scope() { + use ruma_common::api::EndpointError; + + use super::{AuthenticateError, Error}; + + let response = http::Response::builder() + .header( + http::header::WWW_AUTHENTICATE, + "Bearer error=\"insufficient_scope\", scope=\"something_privileged\"", + ) + .status(http::StatusCode::UNAUTHORIZED) + .body( + serde_json::to_string(&json!({ + "errcode": "M_FORBIDDEN", + "error": "Insufficient privilege", + })) + .unwrap(), + ) + .unwrap(); + let error = Error::try_from_http_response(response).unwrap(); + + assert_eq!(error.status_code, http::StatusCode::UNAUTHORIZED); + assert_eq!(error.kind, ErrorKind::Forbidden); + assert_eq!(error.message, "Insufficient privilege"); + let scope = assert_matches::assert_matches!( + error.authenticate, + Some(AuthenticateError::InsufficientScope { scope }) => scope + ); + assert_eq!(scope, "something_privileged"); + } } diff --git a/crates/ruma/Cargo.toml b/crates/ruma/Cargo.toml index 03ce577a..35949c0e 100644 --- a/crates/ruma/Cargo.toml +++ b/crates/ruma/Cargo.toml @@ -140,6 +140,7 @@ unstable-msc2677 = [ unstable-msc2746 = ["ruma-common/unstable-msc2746"] unstable-msc2870 = ["ruma-common/unstable-msc2870"] unstable-msc2965 = ["ruma-client-api?/unstable-msc2965"] +unstable-msc2967 = ["ruma-client-api?/unstable-msc2967"] unstable-msc3245 = ["ruma-common/unstable-msc3245"] unstable-msc3246 = ["ruma-common/unstable-msc3246"] unstable-msc3381 = ["ruma-common/unstable-msc3381"] @@ -173,6 +174,7 @@ __ci = [ "unstable-msc2677", "unstable-msc2746", "unstable-msc2870", + "unstable-msc2967", "unstable-msc3245", "unstable-msc3246", "unstable-msc3381",