feat: Add support for MSC4484

This commit is contained in:
Ginger authored and Ellis Git committed 2026-10-05 14:22:53 +00:00
1 parent 3a20ad7ff2
commit 823ee9f6e1
11 files changed
+190 -76

No files matched your search

+1 -1
View File
@@ -147,7 +147,7 @@ pub enum ResponseType {
/// Deserialize a BTreeSet from a sequence, skipping items which fail to
/// deserialize. This is used as a deserialize helper for ClientMetadata to
/// ignore unknown enum variants in a few fields.
fn btreeset_skip_err<'de, D, V>(de: D) -> Result<BTreeSet<V>, D::Error>
pub(super) fn btreeset_skip_err<'de, D, V>(de: D) -> Result<BTreeSet<V>, D::Error>
where
D: Deserializer<'de>,
V: Deserialize<'de> + Hash + Eq + Ord,
+40 -24
View File
@@ -1,14 +1,14 @@
use std::{
borrow::Cow,
collections::BTreeSet,
collections::HashSet,
error::Error,
fmt::{Debug, Display},
hash::Hash,
mem::discriminant,
};
use regex::Regex;
use ruma::OwnedDeviceId;
use regex::regex;
use ruma::{OwnedDeviceId, api::OAuthClientScope};
use serde::{Deserialize, Serialize};
use url::Url;
@@ -82,30 +82,42 @@ pub enum Prompt {
Unknown,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialOrd, Ord)]
pub enum Scope {
#[derive(Debug, Clone, Deserialize, Serialize)]
pub enum RequestedScope {
Device(OwnedDeviceId),
ClientApi,
ServerAdministration,
}
impl PartialEq for Scope {
impl RequestedScope {
#[must_use]
pub fn as_client_scope(&self) -> Option<OAuthClientScope> {
match self {
| Self::ClientApi => Some(OAuthClientScope::ApiFullAccess),
| Self::Device(_) => None,
| Self::ServerAdministration => Some(OAuthClientScope::ServerAdministration),
}
}
}
impl PartialEq for RequestedScope {
fn eq(&self, other: &Self) -> bool { discriminant(self) == discriminant(other) }
}
impl Eq for Scope {}
impl Eq for RequestedScope {}
impl Hash for Scope {
impl Hash for RequestedScope {
fn hash<H: std::hash::Hasher>(&self, state: &mut H) { discriminant(self).hash(state); }
}
impl Display for Scope {
impl Display for RequestedScope {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let urn = match self {
| Self::ClientApi => "urn:matrix:client:api:*".to_owned(),
| Self::Device(device_id) => format!("urn:matrix:client:device:{device_id}"),
};
f.write_str(&urn)
match self {
| Self::ClientApi => write!(f, "urn:matrix:client:api:*"),
| Self::Device(device_id) => write!(f, "urn:matrix:client:device:{device_id}"),
| Self::ServerAdministration =>
write!(f, "urn:matrix:client:cc.c10y.msc4484.server_administration"),
}
}
}
@@ -113,22 +125,26 @@ impl Display for Scope {
pub struct RawScopes(String);
impl RawScopes {
pub fn to_scopes(&self) -> Result<BTreeSet<Scope>, String> {
#[allow(clippy::trivial_regex)]
pub fn to_scopes(&self) -> Result<HashSet<RequestedScope>, String> {
let client_api_token_regex =
Regex::new(r"urn:matrix:(client|org.matrix.msc2967.client):api:\*").unwrap();
let device_token_regex = Regex::new(
r"urn:matrix:(client|org.matrix.msc2967.client):device:([a-zA-Z0-9-._~]{5,})",
)
.unwrap();
regex!(r"urn:matrix:(client|org.matrix.msc2967.client):api:\*");
let device_token_regex =
regex!(r"urn:matrix:(client|org.matrix.msc2967.client):device:([a-zA-Z0-9-._~]{5,})");
let server_administration_regex =
regex!(r"urn:matrix:client:cc.c10y.msc4484.server_administration");
let mut scopes = BTreeSet::new();
let mut scopes = HashSet::new();
for token in self.0.split(' ') {
let scope_was_new = {
if client_api_token_regex.is_match(token) {
scopes.insert(Scope::ClientApi)
scopes.insert(RequestedScope::ClientApi)
} else if server_administration_regex.is_match(token) {
scopes.insert(RequestedScope::ServerAdministration)
} else if let Some(captures) = device_token_regex.captures(token) {
scopes.insert(Scope::Device(captures.get(2).unwrap().as_str().into()))
scopes
.insert(RequestedScope::Device(captures.get(2).unwrap().as_str().into()))
} else if token == "openid" {
// TODO(unspecced): Element sets this scope but doesn't use
// it for anything
+39 -12
View File
@@ -1,5 +1,5 @@
use std::{
collections::{BTreeSet, HashMap},
collections::{BTreeSet, HashMap, HashSet},
sync::{Arc, Mutex},
time::{Duration, SystemTime},
};
@@ -13,18 +13,18 @@ use database::{Deserialized, Json, Map};
use itertools::Itertools;
use lru_cache::LruCache;
use rand::distr::{Distribution, slice::Choose};
use ruma::{DeviceId, OwnedDeviceId, OwnedUserId, UserId};
use ruma::{DeviceId, OwnedDeviceId, OwnedUserId, UserId, api::OAuthClientScope};
use serde::{Deserialize, Serialize};
use url::Url;
use crate::{
Dep, config,
Dep, admin, config,
oauth::{
client_metadata::{ApplicationType, ClientMetadata, GrantType, ResponseType},
grant::{
AuthorizationCodeQuery, AuthorizationCodeResponse, CodeChallengeMethod,
DeviceCodeRequest, DeviceCodeResponse, ErrorCode, OAuthError, ResponseMode, Scope,
TokenRequest, TokenRequestType, TokenResponse, TokenType,
DeviceCodeRequest, DeviceCodeResponse, ErrorCode, OAuthError, RequestedScope,
ResponseMode, TokenRequest, TokenRequestType, TokenResponse, TokenType,
},
},
users::{self, DeviceToken},
@@ -48,6 +48,7 @@ struct Data {
}
struct Services {
admin: Dep<admin::Service>,
users: Dep<users::Service>,
config: Dep<config::Service>,
}
@@ -55,7 +56,9 @@ struct Services {
#[derive(Debug, Deserialize, Serialize)]
pub struct SessionInfo {
pub client_id: String,
pub scopes: BTreeSet<Scope>,
// Ignore unknown scopes, old databases might have a device scope in here
#[serde(deserialize_with = "client_metadata::btreeset_skip_err")]
pub scopes: BTreeSet<OAuthClientScope>,
current_refresh_token: String,
}
@@ -68,7 +71,7 @@ struct RefreshTokenInfo {
struct PendingAuthCodeGrant {
authorizing_user: OwnedUserId,
requested_scopes: BTreeSet<Scope>,
requested_scopes: HashSet<RequestedScope>,
client_name: Option<String>,
expected_client_id: String,
expected_redirect_uri: Url,
@@ -92,7 +95,7 @@ impl PendingAuthCodeGrant {
struct PendingDeviceCodeGrant {
state: DeviceCodeGrantState,
requested_scopes: BTreeSet<Scope>,
requested_scopes: HashSet<RequestedScope>,
client_name: Option<String>,
client_id: String,
requested_at: SystemTime,
@@ -124,7 +127,7 @@ impl PendingDeviceCodeGrant {
pub struct DeviceCodeGrantInfo {
pub device_code: String,
pub client_metadata: ClientMetadata,
pub requested_scopes: BTreeSet<Scope>,
pub requested_scopes: HashSet<RequestedScope>,
}
/// A time-limited grant for a client to perform some sensitive action.
@@ -148,6 +151,7 @@ impl crate::Service for Service {
fn build(args: crate::Args<'_>) -> Result<Arc<Self>> {
Ok(Arc::new(Self {
services: Services {
admin: args.depend::<admin::Service>("admin"),
users: args.depend::<users::Service>("users"),
config: args.depend::<config::Service>("config"),
},
@@ -540,17 +544,20 @@ impl Service {
async fn create_session(
&self,
authorizing_user: OwnedUserId,
requested_scopes: BTreeSet<Scope>,
requested_scopes: HashSet<RequestedScope>,
client_name: Option<String>,
client_id: String,
) -> Result<TokenResponse, OAuthError> {
self.check_requested_scopes(&authorizing_user, &requested_scopes)
.await?;
let access_token = DeviceToken::new_random().with_max_age(Self::ACCESS_TOKEN_MAX_AGE);
let refresh_token = Self::generate_token();
let device_id = requested_scopes
.iter()
.find_map(|scope| {
if let Scope::Device(device_id) = scope {
if let RequestedScope::Device(device_id) = scope {
Some(device_id.to_owned())
} else {
None
@@ -599,7 +606,10 @@ impl Service {
Json(SessionInfo {
client_id: client_id.clone(),
current_refresh_token: refresh_token.clone(),
scopes: requested_scopes.clone(),
scopes: requested_scopes
.iter()
.filter_map(RequestedScope::as_client_scope)
.collect(),
}),
);
@@ -700,6 +710,23 @@ impl Service {
}
}
async fn check_requested_scopes(
&self,
authorizing_user: &UserId,
requested_scopes: &HashSet<RequestedScope>,
) -> Result<(), OAuthError> {
if requested_scopes.contains(&RequestedScope::ServerAdministration)
&& !self.services.admin.user_is_admin(authorizing_user).await
{
return Err(OAuthError::new(
ErrorCode::AccessDenied,
"You are not a server administrator.".to_owned(),
));
}
Ok(())
}
/// Issue a ticket for `localpart` to perform some action.
pub fn issue_ticket(&self, localpart: String, ticket: OAuthTicket) {
self.tickets