client-api: Add support for API scope restriction
According to MSC2967
This commit is contained in:
parent
e0bbf16c3c
commit
f5000cb52f
@ -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
|
||||
|
||||
|
@ -26,6 +26,7 @@ unstable-msc2654 = []
|
||||
unstable-msc2676 = []
|
||||
unstable-msc2677 = []
|
||||
unstable-msc2965 = []
|
||||
unstable-msc2967 = []
|
||||
unstable-msc3440 = []
|
||||
unstable-msc3488 = []
|
||||
client = []
|
||||
|
@ -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<AuthenticateError>,
|
||||
}
|
||||
|
||||
impl EndpointError for Error {
|
||||
@ -249,8 +253,28 @@ impl EndpointError for Error {
|
||||
) -> Result<Self, DeserializationError> {
|
||||
let status = response.status();
|
||||
let error_body: ErrorBody = from_json_slice(response.body().as_ref())?;
|
||||
|
||||
#[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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for Error {
|
||||
@ -270,7 +294,13 @@ impl From<Error> 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<T: Default + BufMut>(
|
||||
self,
|
||||
) -> Result<http::Response<T>, 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<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.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");
|
||||
}
|
||||
}
|
||||
|
@ -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",
|
||||
|
Loading…
x
Reference in New Issue
Block a user