mirror of
https://forgejo.ellis.link/continuwuation/continuwuity/
synced 2026-10-22 19:01:20 +00:00
Feat: Merge ginger/msc4484
This commit is contained in:
1 parent
dba561f0b4
commit
ffe3b9a1fa
23 files changed
+697
-399
No files matched your search
Generated
+11
-11
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
@@ -0,0 +1 @@
|
||||
Added support for MSC4363 (OAuth step-up authentication). Contributed by @ginger.
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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),
|
||||
])
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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),
|
||||
))
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user