Feat: Merge ginger/msc4484

This commit is contained in:
Jacob Taylor committed 2026-10-03 09:04:58 -07:00
1 parent dba561f0b4
commit ffe3b9a1fa
23 files changed
+697 -399

No files matched your search

Generated
+11 -11
View File
@@ -4947,7 +4947,7 @@ dependencies = [
[[package]]
name = "ruma"
version = "0.17.0"
source = "git+https://github.com/ruma/ruma.git?rev=e7384f01eacd454c593683de50491201e2160fd1#e7384f01eacd454c593683de50491201e2160fd1"
source = "git+https://github.com/ruma/ruma.git?rev=f5e5e1c885f598036fd4326c566e9d1928027d5d#f5e5e1c885f598036fd4326c566e9d1928027d5d"
dependencies = [
"assign",
"js_int",
@@ -4966,7 +4966,7 @@ dependencies = [
[[package]]
name = "ruma-appservice-api"
version = "0.17.0"
source = "git+https://github.com/ruma/ruma.git?rev=e7384f01eacd454c593683de50491201e2160fd1#e7384f01eacd454c593683de50491201e2160fd1"
source = "git+https://github.com/ruma/ruma.git?rev=f5e5e1c885f598036fd4326c566e9d1928027d5d#f5e5e1c885f598036fd4326c566e9d1928027d5d"
dependencies = [
"http",
"js_int",
@@ -4979,7 +4979,7 @@ dependencies = [
[[package]]
name = "ruma-client-api"
version = "0.25.0"
source = "git+https://github.com/ruma/ruma.git?rev=e7384f01eacd454c593683de50491201e2160fd1#e7384f01eacd454c593683de50491201e2160fd1"
source = "git+https://github.com/ruma/ruma.git?rev=f5e5e1c885f598036fd4326c566e9d1928027d5d#f5e5e1c885f598036fd4326c566e9d1928027d5d"
dependencies = [
"as_variant",
"assign",
@@ -5001,7 +5001,7 @@ dependencies = [
[[package]]
name = "ruma-common"
version = "0.20.0"
source = "git+https://github.com/ruma/ruma.git?rev=e7384f01eacd454c593683de50491201e2160fd1#e7384f01eacd454c593683de50491201e2160fd1"
source = "git+https://github.com/ruma/ruma.git?rev=f5e5e1c885f598036fd4326c566e9d1928027d5d#f5e5e1c885f598036fd4326c566e9d1928027d5d"
dependencies = [
"as_variant",
"base64 0.23.1",
@@ -5034,7 +5034,7 @@ dependencies = [
[[package]]
name = "ruma-events"
version = "0.35.0"
source = "git+https://github.com/ruma/ruma.git?rev=e7384f01eacd454c593683de50491201e2160fd1#e7384f01eacd454c593683de50491201e2160fd1"
source = "git+https://github.com/ruma/ruma.git?rev=f5e5e1c885f598036fd4326c566e9d1928027d5d#f5e5e1c885f598036fd4326c566e9d1928027d5d"
dependencies = [
"as_variant",
"indexmap 2.14.2",
@@ -5055,7 +5055,7 @@ dependencies = [
[[package]]
name = "ruma-federation-api"
version = "0.16.0"
source = "git+https://github.com/ruma/ruma.git?rev=e7384f01eacd454c593683de50491201e2160fd1#e7384f01eacd454c593683de50491201e2160fd1"
source = "git+https://github.com/ruma/ruma.git?rev=f5e5e1c885f598036fd4326c566e9d1928027d5d#f5e5e1c885f598036fd4326c566e9d1928027d5d"
dependencies = [
"bytes",
"http",
@@ -5077,7 +5077,7 @@ dependencies = [
[[package]]
name = "ruma-identifiers-validation"
version = "0.12.1"
source = "git+https://github.com/ruma/ruma.git?rev=e7384f01eacd454c593683de50491201e2160fd1#e7384f01eacd454c593683de50491201e2160fd1"
source = "git+https://github.com/ruma/ruma.git?rev=f5e5e1c885f598036fd4326c566e9d1928027d5d#f5e5e1c885f598036fd4326c566e9d1928027d5d"
dependencies = [
"js_int",
"thiserror 2.0.21",
@@ -5086,7 +5086,7 @@ dependencies = [
[[package]]
name = "ruma-macros"
version = "0.20.0"
source = "git+https://github.com/ruma/ruma.git?rev=e7384f01eacd454c593683de50491201e2160fd1#e7384f01eacd454c593683de50491201e2160fd1"
source = "git+https://github.com/ruma/ruma.git?rev=f5e5e1c885f598036fd4326c566e9d1928027d5d#f5e5e1c885f598036fd4326c566e9d1928027d5d"
dependencies = [
"as_variant",
"cfg-if",
@@ -5102,7 +5102,7 @@ dependencies = [
[[package]]
name = "ruma-push-gateway-api"
version = "0.16.0"
source = "git+https://github.com/ruma/ruma.git?rev=e7384f01eacd454c593683de50491201e2160fd1#e7384f01eacd454c593683de50491201e2160fd1"
source = "git+https://github.com/ruma/ruma.git?rev=f5e5e1c885f598036fd4326c566e9d1928027d5d#f5e5e1c885f598036fd4326c566e9d1928027d5d"
dependencies = [
"js_int",
"ruma-common",
@@ -5114,7 +5114,7 @@ dependencies = [
[[package]]
name = "ruma-signatures"
version = "0.22.0"
source = "git+https://github.com/ruma/ruma.git?rev=e7384f01eacd454c593683de50491201e2160fd1#e7384f01eacd454c593683de50491201e2160fd1"
source = "git+https://github.com/ruma/ruma.git?rev=f5e5e1c885f598036fd4326c566e9d1928027d5d#f5e5e1c885f598036fd4326c566e9d1928027d5d"
dependencies = [
"base64 0.23.1",
"ed25519-dalek 3.0.0",
@@ -5131,7 +5131,7 @@ dependencies = [
[[package]]
name = "ruma-state-res"
version = "0.18.0"
source = "git+https://github.com/ruma/ruma.git?rev=e7384f01eacd454c593683de50491201e2160fd1#e7384f01eacd454c593683de50491201e2160fd1"
source = "git+https://github.com/ruma/ruma.git?rev=f5e5e1c885f598036fd4326c566e9d1928027d5d#f5e5e1c885f598036fd4326c566e9d1928027d5d"
dependencies = [
"js_int",
"ruma-common",
+1
View File
@@ -0,0 +1 @@
Added support for [MSC4484](https://github.com/matrix-org/matrix-spec-proposals/pull/4484). Clients which use the built-in Matrix server administration APIs will need to ask for the appropriate scope when signing in with OAuth. Contributed by @ginger.
+1
View File
@@ -0,0 +1 @@
Added support for MSC4363 (OAuth step-up authentication). Contributed by @ginger.
+15 -11
View File
@@ -1,14 +1,17 @@
use std::collections::BTreeMap;
use std::collections::{BTreeMap, BTreeSet};
use axum::extract::State;
use conduwuit::{Result, Server};
use ruma::{
RoomVersionId,
api::client::discovery::get_capabilities::{
self,
v3::{
Capabilities, GetLoginTokenCapability, ProfileFieldsCapability, RoomVersionStability,
RoomVersionsCapability, ThirdPartyIdChangesCapability,
api::{
OAuthClientScope,
client::discovery::get_capabilities::{
self,
v3::{
AdminCapability, Capabilities, GetLoginTokenCapability, ProfileFieldsCapability,
RoomVersionStability, RoomVersionsCapability, ThirdPartyIdChangesCapability,
},
},
},
assign,
@@ -25,6 +28,8 @@ pub(crate) async fn get_capabilities_route(
State(services): State<crate::State>,
body: Ruma<get_capabilities::v3::Request>,
) -> Result<get_capabilities::v3::Response> {
let sender_user = body.identity.expect_sender_user()?;
let available: BTreeMap<RoomVersionId, RoomVersionStability> =
Server::available_room_versions().collect();
@@ -43,13 +48,12 @@ pub(crate) async fn get_capabilities_route(
capabilities.forget_forced_upon_leave.enabled = true;
if services
.users
.is_admin(body.identity.expect_sender_user()?)
.await
{
if services.users.is_admin(sender_user).await {
capabilities.account_moderation.lock = true;
capabilities.account_moderation.suspend = true;
capabilities.admin =
AdminCapability::new(BTreeSet::from_iter([OAuthClientScope::ServerAdministration]));
}
capabilities.profile_fields = Some(
+12
View File
@@ -29,6 +29,12 @@ pub(crate) async fn get_profile_route(
State(services): State<crate::State>,
body: Ruma<get_profile::v3::Request>,
) -> Result<get_profile::v3::Response> {
if services.config.require_auth_for_profile_requests && body.identity.is_none() {
return Err!(Request(Unauthorized(
"This server requires authentication to access user profiles."
)));
}
let Some(profile) = fetch_full_profile(&services, &body.user_id).await else {
return Err!(Request(NotFound("This user's profile could not be fetched.")));
};
@@ -40,6 +46,12 @@ pub(crate) async fn get_profile_field_route(
State(services): State<crate::State>,
body: Ruma<get_profile_field::v3::Request>,
) -> Result<get_profile_field::v3::Response> {
if services.config.require_auth_for_profile_requests && body.identity.is_none() {
return Err!(Request(Unauthorized(
"This server requires authentication to access user profiles."
)));
}
let value = fetch_profile_field(&services, &body.user_id, body.field.clone()).await?;
Ok(assign!(get_profile_field::v3::Response::default(), { value }))
+13 -4
View File
@@ -8,7 +8,7 @@
use conduwuit::{Error, Result, err};
use ruma::{
CanonicalJsonObject,
api::{IncomingRequest, IncomingRequestExt},
api::{IncomingRequest, IncomingRequestExt, auth_scheme::AuthScheme},
};
use serde::Deserialize;
@@ -92,13 +92,22 @@ async fn from_request(
let request = hyper::Request::from_parts(parts, body.as_ref());
// Check authentication
let auth =
R::Authentication::authenticate::<R, &[u8]>(services, &request, auth_query).await?;
let authentication =
R::Authentication::extract_authentication(&request).map_err(|err| {
err!(Request(Unauthorized(warn!(
path = request.uri().path(),
err = err.into(),
"Failed to extract authentication"
))))
})?;
let identity =
R::Authentication::check::<R>(services, &request, authentication, auth_query).await?;
// Deserialize the body
let body = R::try_from_http_request(request, &borrowed_path)
.map_err(|e| err!(Request(BadJson(debug_warn!("{e}")))))?;
Ok(Self { body, json_body, identity: auth })
Ok(Self { body, json_body, identity })
}
}
+226 -199
View File
@@ -1,4 +1,7 @@
use std::any::{Any, TypeId};
use std::{
any::{Any, TypeId},
collections::BTreeSet,
};
use conduwuit::{
Err, Error, Result, err,
@@ -9,13 +12,13 @@
DeviceId, MilliSecondsSinceUnixEpoch, OwnedDeviceId, OwnedServerName, OwnedUserId, UInt,
UserId,
api::{
IncomingRequest,
IncomingRequest, OAuthClientScope,
auth_scheme::{
AccessToken, AccessTokenOptional, AppserviceToken, AppserviceTokenOptional,
AuthScheme, NoAccessToken, NoAuthentication,
},
client,
error::{ErrorKind, UnknownTokenErrorData},
error::{ErrorKind, InsufficientUserAuthenticationErrorData, UnknownTokenErrorData},
federation::authentication::ServerSignatures,
},
assign,
@@ -84,75 +87,50 @@ pub(crate) fn is_appservice(&self) -> bool { matches!(self, Self::Appservice { .
pub(crate) trait CheckAuth: AuthScheme {
type Identity: Send;
fn authenticate<R: IncomingRequest + Any, B: AsRef<[u8]> + Sync>(
fn check<R: IncomingRequest<Authentication = Self> + Any>(
services: &Services,
incoming_request: &hyper::Request<B>,
incoming_request: &hyper::Request<&[u8]>,
authentication: Self::Output,
query: AuthQueryParams,
) -> impl Future<Output = Result<Self::Identity>> + Send {
async move {
let route = TypeId::of::<R>();
let output = Self::extract_authentication(incoming_request).map_err(|err| {
err!(Request(Unauthorized(warn!(
"Failed to extract authorization: {}",
err.into()
))))
})?;
Self::verify(services, output, incoming_request, query, route).await
}
}
fn verify<B: AsRef<[u8]> + Sync>(
services: &Services,
output: Self::Output,
request: &hyper::Request<B>,
query: AuthQueryParams,
route: TypeId,
) -> impl Future<Output = Result<Self::Identity>> + Send;
}
impl CheckAuth for ServerSignatures {
type Identity = OwnedServerName;
async fn verify<B: AsRef<[u8]> + Sync>(
async fn check<R: IncomingRequest<Authentication = Self> + Any>(
services: &Services,
output: Self::Output,
request: &hyper::Request<B>,
incoming_request: &hyper::Request<&[u8]>,
authentication: Self::Output,
_query: AuthQueryParams,
_route: TypeId,
) -> Result<Self::Identity> {
let destination = services.globals.server_name();
if output
.destination
.as_ref()
.is_some_and(|supplied_destination| supplied_destination != destination)
{
return Err!(Request(Unauthorized("Destination mismatch.")));
}
let min_valid_ts =
MilliSecondsSinceUnixEpoch(UInt::new_saturating(millis_since_unix_epoch()));
let key = services
.server_keys
.get_single_verify_key(&output.origin, &output.key, min_valid_ts)
.get_single_verify_key(&authentication.origin, &authentication.key, min_valid_ts)
.await
.ok_or_else(|| {
err!(Request(Unauthorized(debug_warn!(
origin=%output.origin,
origin=%authentication.origin,
"Unable to acquire your signing key (ID: {})",
output.key
authentication.key
))))
})?;
let keys: PubKeys = [(output.key.to_string(), key.key)].into();
let keys: PubKeyMap = [(output.origin.as_str().into(), keys)].into();
let keys: PubKeys = [(authentication.key.to_string(), key.key)].into();
let keys: PubKeyMap = [(authentication.origin.as_str().into(), keys)].into();
match output.verify_http_request(request, destination, &keys) {
match authentication.verify_http_request(
incoming_request,
services.globals.server_name(),
&keys,
) {
| Ok(()) => {
if services
.moderation
.is_remote_server_forbidden(&output.origin)
.is_remote_server_forbidden(&authentication.origin)
{
return Err!(Request(Forbidden(
"You are blocked from federating with this server."
@@ -160,15 +138,15 @@ async fn verify<B: AsRef<[u8]> + Sync>(
}
// Ping the server as healthy
if services.federation.mark_healthy(&output.origin) {
if services.federation.mark_healthy(&authentication.origin) {
services
.sending
.flush_servers([output.origin.clone()].stream())
.flush_servers([authentication.origin.clone()].stream())
.await
.ok();
}
Ok(output.origin)
Ok(authentication.origin)
},
| Err(err) => Err!(Request(Unauthorized(debug_warn!(
"Failed to verify X-Matrix header: {err}"
@@ -180,120 +158,44 @@ async fn verify<B: AsRef<[u8]> + Sync>(
impl CheckAuth for AccessToken {
type Identity = ClientIdentity;
async fn verify<B: AsRef<[u8]> + Sync>(
async fn check<R: IncomingRequest<Authentication = Self> + Any>(
services: &Services,
output: Self::Output,
_request: &hyper::Request<B>,
_incoming_request: &hyper::Request<&[u8]>,
authentication: Self::Output,
query: AuthQueryParams,
route: TypeId,
) -> Result<Self::Identity> {
if output.is_empty() {
return Err!(Request(Unauthorized("Missing access token.")));
}
if let Some((sender_user, sender_device, status)) =
services.users.find_from_token(&output).await
{
// If the token is expired we return a soft logout
if matches!(status, AccessTokenStatus::Expired) {
return Err(Error::Request(
ErrorKind::UnknownToken(
assign!(UnknownTokenErrorData::new(), { soft_logout: true }),
),
"This token has expired".into(),
StatusCode::UNAUTHORIZED,
));
}
// Locked users can only use /logout and /logout/all
if services
.users
.is_locked(&sender_user)
.await
.is_ok_and(std::convert::identity)
{
if !(route == TypeId::of::<client::session::logout::v3::Request>()
|| route == TypeId::of::<client::session::logout_all::v3::Request>())
{
return Err!(Request(UserLocked("Your account is locked.")));
}
}
Ok(ClientIdentity::User { sender_user, sender_device })
} else if let Ok(appservice_info) = services.appservice.find_from_token(&output).await {
let Ok(sender_user) = query.user_id.clone().map_or_else(
|| {
UserId::parse_with_server_name(
appservice_info.registration.sender_localpart.as_str(),
services.globals.server_name(),
)
},
UserId::parse,
) else {
return Err!(Request(InvalidUsername("Username is invalid.")));
};
if !appservice_info.is_user_match(&sender_user) {
return Err!(Request(Exclusive("User is not in namespace.")));
}
// MSC3202/MSC4190: Handle device_id masquerading for appservices.
// The device_id can be provided via `device_id` or
// `org.matrix.msc3202.device_id` query parameter.
let sender_device = if let Some(device_id) = query
.device_id
.or(query.legacy_device_id)
.as_deref()
.map(Into::into)
{
// Verify the device exists for this user
if services
.users
.get_device_metadata(&sender_user, device_id)
.await
.is_err()
{
return Err!(Request(Forbidden(
"Device does not exist for user or appservice cannot masquerade as this \
device."
)));
}
Some(device_id.to_owned())
} else {
None
};
Ok(ClientIdentity::Appservice {
sender_user,
sender_device,
appservice_info: Box::new(appservice_info),
})
} else {
Err(Error::Request(
ErrorKind::UnknownToken(UnknownTokenErrorData::new()),
"Invalid token".into(),
StatusCode::UNAUTHORIZED,
))
}
check_access_token(
services,
&authentication,
query,
TypeId::of::<R>(),
R::required_client_scopes(),
)
.await
}
}
impl CheckAuth for AccessTokenOptional {
type Identity = Option<ClientIdentity>;
async fn verify<B: AsRef<[u8]> + Sync>(
async fn check<R: IncomingRequest<Authentication = Self> + Any>(
services: &Services,
output: Self::Output,
request: &hyper::Request<B>,
_incoming_request: &hyper::Request<&[u8]>,
authentication: Self::Output,
query: AuthQueryParams,
route: TypeId,
) -> Result<Self::Identity> {
match output {
| Some(token) =>
<AccessToken as CheckAuth>::verify(services, token, request, query, route)
.await
.map(Some),
| None => Ok(None),
if let Some(authentication) = authentication {
check_access_token(
services,
&authentication,
query,
TypeId::of::<R>(),
R::required_client_scopes(),
)
.await
.map(Some)
} else {
Ok(None)
}
}
}
@@ -301,40 +203,31 @@ async fn verify<B: AsRef<[u8]> + Sync>(
impl CheckAuth for AppserviceToken {
type Identity = RegistrationInfo;
async fn verify<B: AsRef<[u8]> + Sync>(
async fn check<R: IncomingRequest + Any>(
services: &Services,
output: Self::Output,
_request: &hyper::Request<B>,
_incoming_request: &hyper::Request<&[u8]>,
authentication: Self::Output,
_query: AuthQueryParams,
_route: TypeId,
) -> Result<Self::Identity> {
if output.is_empty() {
return Err!(Request(Unauthorized("Missing access token.")));
}
let Ok(appservice_info) = services.appservice.find_from_token(&output).await else {
return Err!(Request(Unauthorized("Invalid appservice token.")));
};
Ok(appservice_info)
check_appservice_token(services, &authentication).await
}
}
impl CheckAuth for AppserviceTokenOptional {
type Identity = Option<RegistrationInfo>;
async fn verify<B: AsRef<[u8]> + Sync>(
async fn check<R: IncomingRequest<Authentication = Self> + Any>(
services: &Services,
output: Self::Output,
request: &hyper::Request<B>,
query: AuthQueryParams,
route: TypeId,
_incoming_request: &hyper::Request<&[u8]>,
authentication: Self::Output,
_query: AuthQueryParams,
) -> Result<Self::Identity> {
match output {
| Some(token) =>
<AppserviceToken as CheckAuth>::verify(services, token, request, query, route)
.await
.map(Some),
| None => Ok(None),
if let Some(authentication) = authentication {
check_appservice_token(services, &authentication)
.await
.map(Some)
} else {
Ok(None)
}
}
}
@@ -342,13 +235,12 @@ async fn verify<B: AsRef<[u8]> + Sync>(
impl CheckAuth for NoAuthentication {
type Identity = ();
fn verify<B: AsRef<[u8]> + Sync>(
fn check<R: IncomingRequest<Authentication = Self> + Any>(
_services: &Services,
_output: Self::Output,
_request: &hyper::Request<B>,
_incoming_request: &hyper::Request<&[u8]>,
_authentication: Self::Output,
_query: AuthQueryParams,
_route: TypeId,
) -> impl Future<Output = Result<Self::Identity>> {
) -> impl Future<Output = Result<Self::Identity>> + Send {
std::future::ready(Ok(()))
}
}
@@ -356,31 +248,166 @@ fn verify<B: AsRef<[u8]> + Sync>(
impl CheckAuth for NoAccessToken {
type Identity = Option<ClientIdentity>;
async fn verify<B: AsRef<[u8]> + Sync>(
async fn check<R: IncomingRequest<Authentication = Self> + Any>(
services: &Services,
_output: Self::Output,
request: &hyper::Request<B>,
incoming_request: &hyper::Request<&[u8]>,
_authentication: Self::Output,
query: AuthQueryParams,
route: TypeId,
) -> Result<Self::Identity> {
// We handle these the same as AccessTokenOptional
let token = AccessTokenOptional::extract_authentication(request).map_err(|err| {
err!(Request(Unauthorized(warn!("Failed to extract authorization: {}", err))))
})?;
let authentication = AccessTokenOptional::extract_authentication(incoming_request)
.map_err(|err| {
err!(Request(Unauthorized(warn!("Failed to extract authorization: {}", err))))
})?;
// Check special access restrictions
if (route == TypeId::of::<client::profile::get_avatar_url::v3::Request>()
|| route == TypeId::of::<client::profile::get_display_name::v3::Request>()
|| route == TypeId::of::<client::profile::get_profile_field::v3::Request>()
|| route == TypeId::of::<client::profile::get_profile::v3::Request>())
&& services.config.require_auth_for_profile_requests
&& token.is_none()
{
return Err!(Request(Unauthorized(
"This server requires authentication to access user profiles."
)));
if let Some(authentication) = authentication {
check_access_token(services, &authentication, query, TypeId::of::<R>(), &[
OAuthClientScope::ApiFullAccess,
])
.await
.map(Some)
} else {
Ok(None)
}
<AccessTokenOptional as CheckAuth>::verify(services, token, request, query, route).await
}
}
async fn check_access_token(
services: &Services,
token: &str,
query: AuthQueryParams,
route: TypeId,
required_scopes: &[OAuthClientScope],
) -> Result<ClientIdentity> {
if token.is_empty() {
return Err!(Request(Unauthorized("Empty access token.")));
}
if let Some((sender_user, sender_device, status)) =
services.users.find_from_token(token).await
{
// If the token is expired we return a soft logout
if matches!(status, AccessTokenStatus::Expired) {
return Err(Error::Request(
ErrorKind::UnknownToken(
assign!(UnknownTokenErrorData::new(), { soft_logout: true }),
),
"This token has expired".into(),
StatusCode::UNAUTHORIZED,
));
}
// Locked users can only use /logout and /logout/all
if services
.users
.is_locked(&sender_user)
.await
.is_ok_and(std::convert::identity)
{
if !(route == TypeId::of::<client::session::logout::v3::Request>()
|| route == TypeId::of::<client::session::logout_all::v3::Request>())
{
return Err!(Request(UserLocked("Your account is locked.")));
}
}
let session_info = services
.oauth
.get_session_info_for_device(&sender_user, &sender_device)
.await
.map(Box::new);
// Make sure the user has the right scopes to use the route
let user_scopes = if let Some(session_info) = &session_info {
session_info.scopes()
} else {
let mut scopes = BTreeSet::from_iter([OAuthClientScope::ApiFullAccess]);
if services.admin.user_is_admin(&sender_user).await {
scopes.insert(OAuthClientScope::ServerAdministration);
}
scopes
};
if !required_scopes
.iter()
.any(|scope| user_scopes.contains(scope))
{
return Err(Error::Request(
ErrorKind::InsufficientUserAuthentication(Box::new(
assign!(InsufficientUserAuthenticationErrorData::new(), {
scope: required_scopes.iter().cloned().collect()
}),
)),
"You do not have permission to access this endpoint.".into(),
StatusCode::UNAUTHORIZED,
));
}
Ok(ClientIdentity::User { sender_user, sender_device })
} else if let Ok(appservice_info) = services.appservice.find_from_token(token).await {
let Ok(sender_user) = query.user_id.clone().map_or_else(
|| {
UserId::parse_with_server_name(
appservice_info.registration.sender_localpart.as_str(),
services.globals.server_name(),
)
},
UserId::parse,
) else {
return Err!(Request(InvalidUsername("Username is invalid.")));
};
if !appservice_info.is_user_match(&sender_user) {
return Err!(Request(Exclusive("User is not in namespace.")));
}
// MSC3202/MSC4190: Handle device_id masquerading for appservices.
// The device_id can be provided via `device_id` or
// `org.matrix.msc3202.device_id` query parameter.
let sender_device = if let Some(device_id) = query
.device_id
.or(query.legacy_device_id)
.as_deref()
.map(Into::into)
{
// Verify the device exists for this user
if services
.users
.get_device_metadata(&sender_user, device_id)
.await
.is_err()
{
return Err!(Request(Forbidden(
"Device does not exist for user or appservice cannot masquerade as this \
device."
)));
}
Some(device_id.to_owned())
} else {
None
};
Ok(ClientIdentity::Appservice {
sender_user,
sender_device,
appservice_info: Box::new(appservice_info),
})
} else {
Err(Error::Request(
ErrorKind::UnknownToken(UnknownTokenErrorData::new()),
"Invalid token".into(),
StatusCode::UNAUTHORIZED,
))
}
}
async fn check_appservice_token(services: &Services, token: &str) -> Result<RegistrationInfo> {
let Ok(appservice_info) = services.appservice.find_from_token(token).await else {
return Err!(Request(Unauthorized("Invalid appservice token.")));
};
Ok(appservice_info)
}
+2 -2
View File
@@ -46,7 +46,7 @@ pub fn unstable_features() -> BTreeMap<String, bool> {
("computer.gingershaped.msc4466".to_owned(), true),
// web push (https://github.com/matrix-org/matrix-spec-proposals/pull/4174)
("org.matrix.msc4174".to_owned(), true),
// Membership-based invite blocking (https://github.com/matrix-org/matrix-spec-proposals/pull/4494)
("uk.timedout.msc4494".to_owned(), true),
// server administration OAuth scope (https://github.com/matrix-org/matrix-spec-proposals/pull/4484)
("org.continuwuity.msc4484.unstable".to_owned(), true),
])
}
+35 -2
View File
@@ -22,7 +22,8 @@
use futures::{FutureExt, StreamExt, TryStreamExt};
use itertools::Itertools;
use ruma::{
OwnedEventId, OwnedRoomId, OwnedUserId, UserId,
OwnedDeviceId, OwnedEventId, OwnedRoomId, OwnedUserId, UserId,
api::OAuthClientScope,
events::{
AnyStrippedStateEvent, GlobalAccountDataEventType, StateEventType,
push_rules::PushRulesEvent,
@@ -31,7 +32,7 @@
push::Ruleset,
serde::{Raw, from_raw_json_value},
};
use serde_json::value::to_raw_value;
use serde_json::{Value, value::to_raw_value};
use crate::{Services, media, rooms, rooms::short::ShortStateHash};
@@ -160,6 +161,10 @@ macro_rules! migration {
// This is backwards compatible because old versions will recreate the keyspace if it's
// missing.
migration!(drop_server_signingkeys),
// v22: Old versions will fail to deserialize the fixed OAuth scope names,
// preventing users from signing in.
VersionBump(22),
migration!(fix_oauth_scope_names),
]
};
@@ -944,3 +949,31 @@ async fn drop_server_signingkeys(services: &Services) -> Result<()> {
Ok(())
}
async fn fix_oauth_scope_names(services: &Services) -> Result {
let db = &services.db;
let userdeviceid_oauthsessioninfo = db["userdeviceid_oauthsessioninfo"].clone();
userdeviceid_oauthsessioninfo
.stream::<(OwnedUserId, OwnedDeviceId), Value>()
.ignore_err()
.for_each(async |((user_id, device_id), mut session_info)| {
let map = session_info.as_object_mut().unwrap();
let scopes = map.get_mut("scopes").unwrap().as_array_mut().unwrap();
if let Some(old_scope_index) = scopes
.iter()
.position(|value| *value == Value::String("ClientApi".to_owned()))
{
scopes[old_scope_index] =
Value::String(OAuthClientScope::ApiFullAccess.to_string());
}
userdeviceid_oauthsessioninfo.put((user_id, device_id), Json(session_info));
})
.await;
info!("Fixed OAuth session scope names");
Ok(())
}
+13 -7
View File
@@ -1,7 +1,7 @@
use std::{collections::BTreeSet, hash::Hash};
use std::{collections::BTreeSet, fmt::Debug, hash::Hash};
use itertools::Itertools;
use serde::{Deserialize, Deserializer, Serialize};
use serde::{Deserialize, Deserializer, Serialize, de::DeserializeOwned};
use url::Url;
#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
@@ -147,10 +147,10 @@ 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,
V: DeserializeOwned + Hash + Eq + Ord + Debug,
{
use std::marker::PhantomData;
@@ -162,7 +162,7 @@ struct BTreeSetVisitor<V> {
impl<'de, V> Visitor<'de> for BTreeSetVisitor<V>
where
V: Deserialize<'de> + Hash + Eq + Ord,
V: DeserializeOwned + Hash + Eq + Ord + Debug,
{
type Value = BTreeSet<V>;
@@ -176,8 +176,14 @@ fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error>
{
let mut set = BTreeSet::new();
while let Some(element) = seq.next_element().transpose() {
if let Ok(element) = element {
while let Some(element) = seq.next_element::<serde_json::Value>().transpose() {
// This trip through serde_json::Value is necessary because the
// serde_json serializer will not advance to the next
// element if an element fails to deserialize. Without it this
// would loop forever.
if let Ok(element) = element
&& let Ok(element) = serde_json::from_value(element)
{
set.insert(element);
}
}
+33 -38
View File
@@ -3,12 +3,10 @@
collections::BTreeSet,
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 +80,25 @@ pub enum Prompt {
Unknown,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialOrd, Ord)]
pub enum Scope {
Device(OwnedDeviceId),
ClientApi,
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub struct RequestedScopes {
pub device_id: Option<OwnedDeviceId>,
pub scopes: BTreeSet<OAuthClientScope>,
}
impl PartialEq for Scope {
fn eq(&self, other: &Self) -> bool { discriminant(self) == discriminant(other) }
}
impl Eq for Scope {}
impl Hash for Scope {
fn hash<H: std::hash::Hasher>(&self, state: &mut H) { discriminant(self).hash(state); }
}
impl Display for Scope {
impl Display for RequestedScopes {
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}"),
};
let mut scopes: Vec<_> = self
.scopes
.iter()
.map(OAuthClientScope::to_string)
.collect();
f.write_str(&urn)
if let Some(device_id) = &self.device_id {
scopes.push(device_id.to_string());
}
f.write_str(&scopes.join(" "))
}
}
@@ -113,28 +106,30 @@ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
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<RequestedScopes, 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 = RequestedScopes::default();
for token in self.0.split(' ') {
let scope_was_new = {
if client_api_token_regex.is_match(token) {
scopes.insert(Scope::ClientApi)
scopes.scopes.insert(OAuthClientScope::ApiFullAccess)
} else if server_administration_regex.is_match(token) {
scopes.scopes.insert(OAuthClientScope::ServerAdministration)
} else if let Some(captures) = device_token_regex.captures(token) {
scopes.insert(Scope::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
true
scopes
.device_id
.replace(captures.get(2).unwrap().as_str().into())
.is_none()
} else {
return Err(format!("Invalid scope: {token}"));
continue;
}
};
+115 -55
View File
@@ -13,18 +13,18 @@
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, RequestedScopes,
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,10 +56,21 @@ struct Services {
#[derive(Debug, Deserialize, Serialize)]
pub struct SessionInfo {
pub client_id: String,
pub scopes: BTreeSet<Scope>,
#[serde(deserialize_with = "client_metadata::btreeset_skip_err")]
scopes: BTreeSet<OAuthClientScope>,
#[serde(default)]
temporary_scopes: BTreeSet<OAuthClientScope>,
#[serde(default)]
current_refresh_token: String,
}
impl SessionInfo {
#[must_use]
pub fn scopes(&self) -> BTreeSet<OAuthClientScope> {
self.scopes.union(&self.temporary_scopes).cloned().collect()
}
}
#[derive(Debug, Deserialize, Serialize)]
struct RefreshTokenInfo {
client_id: String,
@@ -68,7 +80,7 @@ struct RefreshTokenInfo {
struct PendingAuthCodeGrant {
authorizing_user: OwnedUserId,
requested_scopes: BTreeSet<Scope>,
requested_scopes: RequestedScopes,
client_name: Option<String>,
expected_client_id: String,
expected_redirect_uri: Url,
@@ -92,7 +104,7 @@ pub(crate) fn is_valid_for(&self, client_id: &str) -> bool {
struct PendingDeviceCodeGrant {
state: DeviceCodeGrantState,
requested_scopes: BTreeSet<Scope>,
requested_scopes: RequestedScopes,
client_name: Option<String>,
client_id: String,
requested_at: SystemTime,
@@ -124,7 +136,7 @@ pub(crate) fn is_valid_for(&self, client_id: &str) -> bool {
pub struct DeviceCodeGrantInfo {
pub device_code: String,
pub client_metadata: ClientMetadata,
pub requested_scopes: BTreeSet<Scope>,
pub requested_scopes: RequestedScopes,
}
/// A time-limited grant for a client to perform some sensitive action.
@@ -148,6 +160,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,73 +553,98 @@ pub async fn revoke_token(&self, token: String) -> Result<(), OAuthError> {
async fn create_session(
&self,
authorizing_user: OwnedUserId,
requested_scopes: BTreeSet<Scope>,
requested_scopes: RequestedScopes,
client_name: Option<String>,
client_id: String,
) -> Result<TokenResponse, OAuthError> {
self.check_requested_scopes(&authorizing_user, &requested_scopes.scopes)
.await?;
let access_token = DeviceToken::new_random().with_max_age(Self::ACCESS_TOKEN_MAX_AGE);
let refresh_token = Self::generate_token();
let response_scope = requested_scopes.to_string();
let device_id = requested_scopes
.iter()
.find_map(|scope| {
if let Scope::Device(device_id) = scope {
Some(device_id.to_owned())
} else {
None
}
})
.device_id
.ok_or_else(|| OAuthError::invalid_grant("No device ID scope supplied"))?;
if self
let session_info = if self
.services
.users
.get_device_metadata(&authorizing_user, &device_id)
.await
.is_ok()
{
return Err(OAuthError::new_static(
ErrorCode::InvalidScope,
"A device with the supplied ID already exists for this user",
));
}
// We're doing step-up authentication for an existing device
let Some(mut session_info) = self
.get_session_info_for_device(&authorizing_user, &device_id)
.await
else {
// The device ID doesn't refer to an OAuth device
return Err(OAuthError::new_static(
ErrorCode::InvalidScope,
"A device with the supplied ID already exists for this user",
));
};
let device_id = self.services
.users
.create_device(
&authorizing_user,
Some(device_id),
Some(access_token.clone()),
client_name,
None,
)
.await
// This can only panic if the authorizing user suffered a spontaneous existence
// failure during authentication, which should(?) be impossible(?)
.expect("failed to create device");
session_info.temporary_scopes = requested_scopes.scopes;
info!(
?client_id,
?authorizing_user,
?device_id,
?requested_scopes,
"Created new oauth session"
);
self.services
.users
.set_token(&authorizing_user, &device_id, access_token.clone())
.await
.expect("should be able to set access token");
self.db.userdeviceid_oauthsessioninfo.put(
(&authorizing_user, &device_id),
Json(SessionInfo {
info!(
?client_id,
?authorizing_user,
?device_id,
scopes = ?session_info.temporary_scopes,
"Granted temporary scopes to device"
);
session_info
} else {
self.services
.users
.create_device(
&authorizing_user,
Some(device_id.clone()),
Some(access_token.clone()),
client_name,
None,
)
.await
// This can only panic if the authorizing user suffered a spontaneous existence
// failure during authentication, which should(?) be impossible(?)
.expect("should be able to create device");
info!(
?client_id,
?authorizing_user,
?device_id,
scopes = ?requested_scopes.scopes,
"Created new OAuth device"
);
SessionInfo {
client_id: client_id.clone(),
current_refresh_token: refresh_token.clone(),
scopes: requested_scopes.clone(),
}),
);
current_refresh_token: Self::generate_token(),
scopes: requested_scopes.scopes,
temporary_scopes: BTreeSet::new(),
}
};
let refresh_token = session_info.current_refresh_token.clone();
self.db
.userdeviceid_oauthsessioninfo
.put((&authorizing_user, &device_id), Json(session_info));
self.db.refreshtoken_refreshtokeninfo.raw_put(
&refresh_token,
Json(RefreshTokenInfo {
client_id: client_id.clone(),
user_id: authorizing_user.clone(),
client_id,
user_id: authorizing_user,
device_id,
}),
);
@@ -615,7 +653,7 @@ async fn create_session(
access_token: access_token.into_token(),
token_type: TokenType::Bearer,
expires_in: Self::ACCESS_TOKEN_MAX_AGE.as_secs(),
scope: requested_scopes.iter().join(" "),
scope: response_scope,
refresh_token,
})
}
@@ -648,9 +686,12 @@ async fn refresh_session(
assert_eq!(&client_id, &session_info.client_id, "session info client id mismatch");
// TODO: this doesn't include the device ID "scope", do any clients rely
// on that?
let scope = session_info.scopes.iter().join(" ");
let new_access_token = DeviceToken::new_random().with_max_age(Self::ACCESS_TOKEN_MAX_AGE);
let new_refresh_token = Self::generate_token();
let scope = session_info.scopes.iter().join(" ");
session_info
.current_refresh_token
.clone_from(&new_refresh_token);
@@ -665,6 +706,8 @@ async fn refresh_session(
.await
.expect("should be able to set token");
session_info.temporary_scopes.clear();
self.db.userdeviceid_oauthsessioninfo.put(
(&refresh_token_info.user_id, &refresh_token_info.device_id),
Json(session_info),
@@ -699,6 +742,23 @@ pub async fn remove_session(&self, user_id: &UserId, device_id: &DeviceId) {
}
}
async fn check_requested_scopes(
&self,
authorizing_user: &UserId,
scopes: &BTreeSet<OAuthClientScope>,
) -> Result<(), OAuthError> {
if scopes.contains(&OAuthClientScope::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
+5 -7
View File
@@ -2,11 +2,11 @@
use askama::{Template, filters::HtmlSafe};
use conduwuit_core::utils;
use conduwuit_service::{
Services,
oauth::{client_metadata::ClientMetadata, grant::Scope},
use conduwuit_service::{Services, oauth::client_metadata::ClientMetadata};
use ruma::{
OwnedDeviceId, OwnedUserId, UserId,
api::{OAuthClientScope, client::device::Device},
};
use ruma::{OwnedDeviceId, OwnedUserId, UserId, api::client::device::Device};
pub(super) mod form;
@@ -122,7 +122,6 @@ pub(super) struct DeviceCard {
pub display_name: Option<String>,
pub avatar: Avatar,
pub last_active: String,
pub oauth_metadata: Option<ClientMetadata>,
pub style: DeviceCardStyle,
pub dehydrated: bool,
}
@@ -192,7 +191,6 @@ pub(super) async fn for_device(
display_name,
avatar,
last_active,
oauth_metadata,
style,
dehydrated,
}
@@ -202,7 +200,7 @@ pub(super) async fn for_device(
#[derive(Debug, Template)]
#[template(path = "_components/client_scopes.html.j2")]
pub(super) struct ClientScopes {
pub scopes: BTreeSet<Scope>,
pub scopes: BTreeSet<OAuthClientScope>,
}
impl HtmlSafe for ClientScopes {}
+42 -4
View File
@@ -5,10 +5,11 @@
routing::on,
};
use conduwuit_service::oauth::{
SessionInfo,
client_metadata::ClientMetadata,
grant::{AuthorizationCodeQuery, DeviceCodeVerifyQuery, Prompt},
grant::{AuthorizationCodeQuery, DeviceCodeVerifyQuery, Prompt, RequestedScopes},
};
use ruma::OwnedUserId;
use ruma::{OwnedUserId, api::OAuthClientScope};
use serde::{Deserialize, de::IgnoredAny};
use crate::{
@@ -37,6 +38,7 @@ struct Grant use "grant.html.j2" {
user_avatar: Avatar,
client_metadata: ClientMetadata,
scopes: ClientScopes,
existing_session_info: Option<SessionInfo>,
device_code: Option<String>
}
}
@@ -102,7 +104,23 @@ async fn route_authorization_code(
return Err(WebError::BadRequest("Invalid client ID".to_owned()));
};
let scopes = query.scope.to_scopes().map_err(WebError::BadRequest)?;
let RequestedScopes { device_id, mut scopes } =
query.scope.to_scopes().map_err(WebError::BadRequest)?;
let existing_session_info = if let Some(device_id) = &device_id {
services
.oauth
.get_session_info_for_device(&user_id, device_id)
.await
} else {
None
};
if let Some(existing_session_info) = &existing_session_info {
// For step-up auth, don't show scopes that the device already has
let existing_scopes = existing_session_info.scopes();
scopes.retain(|scope| !existing_scopes.contains(scope));
}
let user_avatar = Avatar::for_local_user(&services, &user_id).await;
@@ -118,6 +136,7 @@ async fn route_authorization_code(
user_avatar,
client,
ClientScopes { scopes },
existing_session_info,
None,
))
}
@@ -183,6 +202,24 @@ async fn route_device_code(
));
};
let RequestedScopes { device_id, mut scopes } = grant_info.requested_scopes;
let existing_session_info = if let Some(device_id) = &device_id {
services
.oauth
.get_session_info_for_device(&user_id, device_id)
.await
} else {
None
};
if let Some(existing_session_info) = &existing_session_info {
// For step-up auth, don't show scopes that the device already
// has
let existing_scopes = existing_session_info.scopes();
scopes.retain(|scope| !existing_scopes.contains(scope));
}
let user_avatar = Avatar::for_local_user(&services, &user_id).await;
response!(Grant::new(
@@ -196,7 +233,8 @@ async fn route_device_code(
user_id,
user_avatar,
grant_info.client_metadata,
ClientScopes { scopes: grant_info.requested_scopes },
ClientScopes { scopes },
existing_session_info,
Some(grant_info.device_code),
))
},
+74 -10
View File
@@ -16,6 +16,8 @@
--card-bg: oklch(from var(--panel-bg) calc(l - 0.05) c h);
--c1: oklch(0.44 0.177 353.06);
--c2: oklch(0.59 0.158 150.88);
--danger-color: oklch(87.303% 0.06774 18.457);
--danger-color-dark: oklch(0.2 0.197 20.272);
--avatar-color: var(--c2);
--name-lightness: 0.45;
@@ -41,6 +43,7 @@
--secondary: var(--secondary-dark);
--bg: var(--bg-dark);
--panel-bg: var(--panel-bg-dark);
--danger-color: var(--danger-color-dark);
--name-lightness: 0.8;
--background-lightness: 0.2;
@@ -143,7 +146,6 @@ small.error {
margin-bottom: 0.5rem;
}
img.matrix-icon {
@media (prefers-color-scheme: dark) {
filter: invert();
@@ -173,7 +175,8 @@ label {
margin-bottom: 0.2em;
}
a, a:visited {
a,
a:visited {
color: oklch(from var(--c1) var(--name-lightness) c h);
}
@@ -182,13 +185,15 @@ code {
}
pre {
background-color: oklch(from var(--panel-bg) calc(l - 0.05) c h);
background-color: var(--card-bg);
border-radius: var(--border-radius-sm);
padding: 8px;
overflow-x: scroll;
}
input, button, a.button {
input,
button,
a.button {
display: inline-block;
padding: 0.5em;
@@ -218,10 +223,11 @@ input[type="checkbox"] {
width: auto !important;
}
button, a.button {
button,
a.button {
color: white;
background-color: var(--c1);
transition: opacity .2s;
transition: opacity 0.2s;
text-align: center;
margin: 0.5rem 0;
@@ -231,7 +237,9 @@ button, a.button {
}
&:not(:disabled) {
transition: linear color, background-color 0.1s;
transition:
linear color,
background-color 0.1s;
&:hover {
opacity: 0.8;
@@ -265,6 +273,44 @@ ul.bullet-separated {
}
}
ul.list-group {
list-style: none;
display: flex;
flex-direction: column;
padding: 0;
li {
display: block;
background-color: var(--card-bg);
padding: 10px;
margin-bottom: 3px;
color: var(--secondary);
&:first-child {
border-top-left-radius: var(--border-radius-lg);
border-top-right-radius: var(--border-radius-lg);
}
&:last-child {
border-bottom-left-radius: var(--border-radius-lg);
border-bottom-right-radius: var(--border-radius-lg);
margin-bottom: 0;
}
&.danger {
color: var(--text-color);
background-color: var(--danger-color);
}
svg {
vertical-align: center;
margin-inline-end: 0.5rem;
width: 20px;
height: 20px;
}
}
}
.fullwidth {
width: 100%;
margin-bottom: 0 !important;
@@ -274,6 +320,10 @@ ul.bullet-separated {
user-select: all;
}
.small-text {
font-size: small;
}
.panel {
--preferred-width: 12rem + 40dvw;
--maximum-width: 48rem;
@@ -297,7 +347,9 @@ ul.bullet-separated {
--preferred-width: 12rem + 20dvw;
--maximum-width: 36rem;
input, button, a.button {
input,
button,
a.button {
width: 100%;
}
}
@@ -340,7 +392,12 @@ ul.bullet-separated {
}
}
@media (max-width: 450px) {
.bi {
display: inline-block;
vertical-align: -.125em;
}
@media (max-width: 425px) {
main {
padding-block-start: 2rem;
width: 100%;
@@ -362,7 +419,14 @@ ul.bullet-separated {
}
@media (max-width: 799px) {
input, button, a.button {
input,
button,
a.button {
width: 100%;
}
}
@font-face {
font-family: "Boots";
src: url();
}
+2 -1
View File
@@ -48,7 +48,8 @@
&.danger {
display: block;
background-color: oklch(from red 0.2 c h);
color: var(--text-color);
background-color: var(--danger-color);
border: 1px dashed red;
}
+6 -1
View File
@@ -8,8 +8,13 @@
margin-inline: 1em;
color: var(--secondary);
font-size: x-large;
font-weight: bold;
user-select: none;
svg {
vertical-align: center;
width: 24px;
height: 24px;
}
}
}
@@ -1,10 +1,20 @@
<ul>
{% import "../_icons.html.j2" as icons %}
<ul class="list-group">
{% for scope in scopes %}
{% match scope %}
{% when Scope::ClientApi %}
<li>Send messages and interact with chatrooms on your behalf</li>
{% when Scope::Device(_) %}
<li>Access your Matrix account</li>
{% when OAuthClientScope::ApiFullAccess %}
<li>
{{ icons::person_circle() }}
Access and control all features of your Matrix account
</li>
{% when OAuthClientScope::ServerAdministration %}
<li class="danger">
{{ icons::house_gear() }}
Access your homeserver's administrator tools
</li>
{% when _ %}
<li>missingno</li>
{% endmatch %}
{% endfor %}
</ul>
@@ -14,22 +14,12 @@
</span>
{% if style == DeviceCardStyle::Detailed %}
<span class="id">
<span class="mobile-hidden">•</span>
<ul class="bullet-separated">
<li>{{ device_id }}</li>
<li>
{% if let Some(metadata) = oauth_metadata %}
<a href="{{ metadata.client_uri }}">Client website</a>
{% else %}
legacy
{% endif %}
</li>
</span>
#{{ device_id }}
</span>
{% endif %}
</div>
<div>
Last active: {{ last_active }}
Last active {{ last_active }}
</div>
<div>
{% if style != DeviceCardStyle::Detailed %}
+25
View File
@@ -0,0 +1,25 @@
{% macro person_circle() %}
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-person-circle" viewBox="0 0 16 16">
<path d="M11 6a3 3 0 1 1-6 0 3 3 0 0 1 6 0"/>
<path fill-rule="evenodd" d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8m8-7a7 7 0 0 0-5.468 11.37C3.242 11.226 4.805 10 8 10s4.757 1.225 5.468 2.37A7 7 0 0 0 8 1"/>
</svg>
{% endmacro %}
{% macro house_gear() %}
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-house-gear" viewBox="0 0 16 16">
<path d="M7.293 1.5a1 1 0 0 1 1.414 0L11 3.793V2.5a.5.5 0 0 1 .5-.5h1a.5.5 0 0 1 .5.5v3.293l2.354 2.353a.5.5 0 0 1-.708.708L8 2.207l-5 5V13.5a.5.5 0 0 0 .5.5h4a.5.5 0 0 1 0 1h-4A1.5 1.5 0 0 1 2 13.5V8.207l-.646.647a.5.5 0 1 1-.708-.708z"/>
<path d="M11.886 9.46c.18-.613 1.048-.613 1.229 0l.043.148a.64.64 0 0 0 .921.382l.136-.074c.561-.306 1.175.308.87.869l-.075.136a.64.64 0 0 0 .382.92l.149.045c.612.18.612 1.048 0 1.229l-.15.043a.64.64 0 0 0-.38.921l.074.136c.305.561-.309 1.175-.87.87l-.136-.075a.64.64 0 0 0-.92.382l-.045.149c-.18.612-1.048.612-1.229 0l-.043-.15a.64.64 0 0 0-.921-.38l-.136.074c-.561.305-1.175-.309-.87-.87l.075-.136a.64.64 0 0 0-.382-.92l-.148-.044c-.613-.181-.613-1.049 0-1.23l.148-.043a.64.64 0 0 0 .382-.921l-.074-.136c-.306-.561.308-1.175.869-.87l.136.075a.64.64 0 0 0 .92-.382zM14 12.5a1.5 1.5 0 1 0-3 0 1.5 1.5 0 0 0 3 0"/>
</svg>
{% endmacro %}
{% macro person_lock() %}
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-person-fill-lock" viewBox="0 0 16 16">
<path d="M11 5a3 3 0 1 1-6 0 3 3 0 0 1 6 0m-9 8c0 1 1 1 1 1h5v-1a2 2 0 0 1 .01-.2 4.49 4.49 0 0 1 1.534-3.693Q8.844 9.002 8 9c-5 0-6 3-6 4m7 0a1 1 0 0 1 1-1v-1a2 2 0 1 1 4 0v1a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1h-4a1 1 0 0 1-1-1zm3-3a1 1 0 0 0-1 1v1h2v-1a1 1 0 0 0-1-1"/>
</svg>
{% endmacro %}
{% macro arrow_left_right() %}
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-arrow-left-right" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M1 11.5a.5.5 0 0 0 .5.5h11.793l-3.147 3.146a.5.5 0 0 0 .708.708l4-4a.5.5 0 0 0 0-.708l-4-4a.5.5 0 0 0-.708.708L13.293 11H1.5a.5.5 0 0 0-.5.5m14-7a.5.5 0 0 1-.5.5H2.707l3.147 3.146a.5.5 0 1 1-.708.708l-4-4a.5.5 0 0 1 0-.708l4-4a.5.5 0 1 1 .708.708L2.707 4H14.5a.5.5 0 0 1 .5.5"/>
</svg>
{% endmacro %}
+3 -2
View File
@@ -1,4 +1,5 @@
{% extends "_layout.html.j2" %}
{% import "_icons.html.j2" as icons %}
{%- block title -%}
Your account
@@ -12,7 +13,7 @@ Your account
{% when AccountBody::Unlocked { suspended, email_requirement, email, devices, oidc_enabled, dehydrated_device_id } %}
{% if suspended %}
<p class="card danger">
⚠️ Your account has been suspended by your homeserver's administrator.
{{ icons::person_lock() }} Your account has been suspended by your homeserver's administrator.
Some functionality may be restricted.
</p>
{% endif %}
@@ -73,7 +74,7 @@ Your account
</section>
{% when AccountBody::Locked %}
<p class="card danger">
⚠️ Your account has been locked by your homeserver's administrator.
{{ icons::person_lock() }} Your account has been locked by your homeserver's administrator.
</p>
<section>
<a class="button fullwidth" href="logout">Log out</a>
+23 -24
View File
@@ -9,43 +9,42 @@ Device information
<h1>About device <a class="back" href="{{ crate::ROUTE_PREFIX }}/account/">Back</a></h1>
{{ device_card }}
{% if let Some((client_metadata, _)) = client_metadata %}
{% if client_metadata.tos_uri.is_some() || client_metadata.policy_uri.is_some() %}
<section>
<ul class="bullet-separated">
{% if let Some(tos_uri) = &client_metadata.tos_uri %}
<li><a href="{{ tos_uri }}">Terms of service</a></li>
{% endif %}
{% if let Some(policy_uri) = &client_metadata.policy_uri %}
<li><a href="{{ policy_uri }}">Policies</a></li>
{% endif %}
</ul>
</section>
{% endif %}
<section>
<ul class="bullet-separated">
<li><a href="{{ client_metadata.client_uri }}">Client website</a></li>
{% if let Some(tos_uri) = &client_metadata.tos_uri %}
<li><a href="{{ tos_uri }}">Terms of service</a></li>
{% endif %}
{% if let Some(policy_uri) = &client_metadata.policy_uri %}
<li><a href="{{ policy_uri }}">Policies</a></li>
{% endif %}
</ul>
</section>
{% endif %}
<section>
<p>
{% if let Some((_, session_info)) = client_metadata %}
This device has permission to:
{{ ClientScopes { scopes: session_info.scopes.clone() } }}
{% else if device_card.dehydrated %}
This is your dehydrated device. It saves encryption keys for you while you're offline.
{% else %}
{% if let Some((_, session_info)) = client_metadata %}
<p>This device has permission to:</p>
{{ ClientScopes { scopes: session_info.scopes() } }}
{% else if device_card.dehydrated %}
<p>This is your dehydrated device. It saves encryption keys for you while you're offline.</p>
{% else %}
<p>
This device can access and control all features of your Matrix account.
<br>
<small>❖ This is a legacy device. Legacy devices always have full access to your account.</small>
{% endif %}
</p>
</p>
{% endif %}
<a class="button" href="remove">Remove this device</a>
</section>
<a class="button" href="remove">Remove this device</a>
{% if let Some((client_metadata, session_info)) = client_metadata %}
<details>
<details class="small-text">
<summary>Client registration</summary>
<p>
OAuth client ID: <code>{{ session_info.client_id }}</code>
</p>
<p>
Scopes: <code>
{% for scope in session_info.scopes %}
{% for scope in session_info.scopes() %}
{{ scope | safe }}
{% endfor %}
</code>
+23 -4
View File
@@ -1,4 +1,5 @@
{% extends "_layout.html.j2" %}
{% import "_icons.html.j2" as icons %}
{%- block head -%}
<link rel="stylesheet" href="{{ crate::ROUTE_PREFIX }}/resources/grant.css">
@@ -17,18 +18,36 @@ Authorize client
{{ user_avatar }}
</div>
<div class="separator" aria-hidden="true">
⇄
{{ icons::arrow_left_right() }}
</div>
{{ Avatar::for_client(client_metadata) }}
</div>
<div class="identity">
Signed in as <code>{{ user_id }}</code>. <a href="{{ crate::ROUTE_PREFIX }}/account/logout?{{ logout_query }}">Switch accounts</a>
</div>
<p>
<b>{{ client_name }}</b> (<a href="{{ client_metadata.client_uri }}">{{ client_metadata.client_uri.domain().unwrap() }}</a>) would like
your permission to:
{{ scopes }}
<b>{{ client_name }}</b> (<a href="{{ client_metadata.client_uri }}">{{ client_metadata.client_uri.domain().unwrap() }}</a>)
{% if let Some(existing_session_info) = existing_session_info %}
would like temporary additional permissions to:
{{ scopes }}
{{ client_name }} already has permission to:
{{ ClientScopes { scopes: existing_session_info.scopes() } }}
{% else %}
would like your permission to:
{{ scopes }}
{% endif %}
</p>
{% if scopes.scopes.contains(&OAuthClientScope::ServerAdministration) %}
<p class="card danger">
⚠️ {{ client_name }} wants to be able to administrate your homeserver. If it is untrustworthy,
it could use its administrator powers to cause severe damage to your homeserver's users and rooms.
Make sure you trust it fully before you continue.
</p>
{% endif %}
{% match (&client_metadata.policy_uri, &client_metadata.tos_uri) %}
{% when (Some(policy_uri), Some(tos_uri)) %}
<p>