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 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
|
||||||
|
|
||||||
|
@ -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 = []
|
||||||
|
@ -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,7 +253,27 @@ 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())?;
|
||||||
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<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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -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",
|
||||||
|
Loading…
x
Reference in New Issue
Block a user