client-api: Add support for API scope restriction

According to MSC2967
This commit is contained in:
Kévin Commaille 2022-07-25 14:16:31 +02:00 committed by Kévin Commaille
parent e0bbf16c3c
commit f5000cb52f
4 changed files with 203 additions and 6 deletions

View File

@ -21,6 +21,7 @@ Improvements:
* Add unstable support for discovering an OpenID Connect server (MSC2965) * Add unstable support for discovering an OpenID Connect server (MSC2965)
* Add `SpaceRoomJoinRule::KnockRestricted` (MSC3787) * Add `SpaceRoomJoinRule::KnockRestricted` (MSC3787)
* Add unstable support for private read receipts (MSC2285) * Add unstable support for private read receipts (MSC2285)
* Add unstable support for API scope restriction (MSC2967)
# 0.14.1 # 0.14.1

View File

@ -26,6 +26,7 @@ unstable-msc2654 = []
unstable-msc2676 = [] unstable-msc2676 = []
unstable-msc2677 = [] unstable-msc2677 = []
unstable-msc2965 = [] unstable-msc2965 = []
unstable-msc2967 = []
unstable-msc3440 = [] unstable-msc3440 = []
unstable-msc3488 = [] unstable-msc3488 = []
client = [] client = []

View File

@ -241,6 +241,10 @@ pub struct Error {
/// The http status code. /// The http status code.
pub status_code: http::StatusCode, pub status_code: http::StatusCode,
/// The `WWW-Authenticate` header error message.
#[cfg(feature = "unstable-msc2967")]
pub authenticate: Option<AuthenticateError>,
} }
impl EndpointError for Error { impl EndpointError for Error {
@ -249,8 +253,28 @@ impl EndpointError for Error {
) -> Result<Self, DeserializationError> { ) -> Result<Self, DeserializationError> {
let status = response.status(); let status = response.status();
let error_body: ErrorBody = from_json_slice(response.body().as_ref())?; let error_body: ErrorBody = from_json_slice(response.body().as_ref())?;
#[cfg(not(feature = "unstable-msc2967"))]
{
Ok(error_body.into_error(status)) 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)
}
}
} }
impl fmt::Display for Error { impl fmt::Display for Error {
@ -270,7 +294,13 @@ impl From<Error> for ErrorBody {
impl ErrorBody { impl ErrorBody {
/// Convert the ErrorBody into an Error by adding the http status code. /// Convert the ErrorBody into an Error by adding the http status code.
pub fn into_error(self, status_code: http::StatusCode) -> Error { 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<T: Default + BufMut>( fn try_into_http_response<T: Default + BufMut>(
self, self,
) -> Result<http::Response<T>, IntoHttpError> { ) -> Result<http::Response<T>, IntoHttpError> {
http::Response::builder() let builder = http::Response::builder()
.header(http::header::CONTENT_TYPE, "application/json") .header(http::header::CONTENT_TYPE, "application/json")
.status(self.status_code) .status(self.status_code);
.body(ruma_common::serde::json_to_buf(&ErrorBody::from(self))?)
.map_err(Into::into) #[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<http::HeaderValue>` 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<String, String>);
#[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<Self> {
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<Self, Self::Error> {
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.kind, ErrorKind::Forbidden);
assert_eq!(deserialized.message, "You are not authorized to ban users in this room."); 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");
}
} }

View File

@ -140,6 +140,7 @@ unstable-msc2677 = [
unstable-msc2746 = ["ruma-common/unstable-msc2746"] unstable-msc2746 = ["ruma-common/unstable-msc2746"]
unstable-msc2870 = ["ruma-common/unstable-msc2870"] unstable-msc2870 = ["ruma-common/unstable-msc2870"]
unstable-msc2965 = ["ruma-client-api?/unstable-msc2965"] unstable-msc2965 = ["ruma-client-api?/unstable-msc2965"]
unstable-msc2967 = ["ruma-client-api?/unstable-msc2967"]
unstable-msc3245 = ["ruma-common/unstable-msc3245"] unstable-msc3245 = ["ruma-common/unstable-msc3245"]
unstable-msc3246 = ["ruma-common/unstable-msc3246"] unstable-msc3246 = ["ruma-common/unstable-msc3246"]
unstable-msc3381 = ["ruma-common/unstable-msc3381"] unstable-msc3381 = ["ruma-common/unstable-msc3381"]
@ -173,6 +174,7 @@ __ci = [
"unstable-msc2677", "unstable-msc2677",
"unstable-msc2746", "unstable-msc2746",
"unstable-msc2870", "unstable-msc2870",
"unstable-msc2967",
"unstable-msc3245", "unstable-msc3245",
"unstable-msc3246", "unstable-msc3246",
"unstable-msc3381", "unstable-msc3381",