From ffe3b9a1fa23fad2ed9a88b808dd476389802deb Mon Sep 17 00:00:00 2001 From: Jacob Taylor Date: Fri, 2 Oct 2026 12:14:45 -0700 Subject: [PATCH] Feat: Merge ginger/msc4484 --- Cargo.lock | 22 +- changelog.d/+1715aa4c.feature.md | 1 + changelog.d/+5b021f7c.feature.md | 1 + src/api/client/capabilities.rs | 26 +- src/api/client/profile.rs | 12 + src/api/router/args.rs | 17 +- src/api/router/auth.rs | 425 ++++++++++-------- src/core/matrix/versions.rs | 4 +- src/service/migrations.rs | 37 +- src/service/oauth/client_metadata.rs | 20 +- src/service/oauth/grant.rs | 71 ++- src/service/oauth/mod.rs | 170 ++++--- src/web/pages/components/mod.rs | 12 +- src/web/pages/oauth/grant.rs | 46 +- src/web/pages/resources/common.css | 84 +++- src/web/pages/resources/components.css | 3 +- src/web/pages/resources/grant.css | 7 +- .../_components/client_scopes.html.j2 | 20 +- .../templates/_components/device_card.html.j2 | 14 +- src/web/pages/templates/_icons.html.j2 | 25 ++ src/web/pages/templates/account.html.j2 | 5 +- src/web/pages/templates/device_info.html.j2 | 47 +- src/web/pages/templates/grant.html.j2 | 27 +- 23 files changed, 697 insertions(+), 399 deletions(-) create mode 100644 changelog.d/+1715aa4c.feature.md create mode 100644 changelog.d/+5b021f7c.feature.md create mode 100644 src/web/pages/templates/_icons.html.j2 diff --git a/Cargo.lock b/Cargo.lock index 449d1cc25..a1ff5302c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/changelog.d/+1715aa4c.feature.md b/changelog.d/+1715aa4c.feature.md new file mode 100644 index 000000000..5352a040b --- /dev/null +++ b/changelog.d/+1715aa4c.feature.md @@ -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. diff --git a/changelog.d/+5b021f7c.feature.md b/changelog.d/+5b021f7c.feature.md new file mode 100644 index 000000000..4103a9957 --- /dev/null +++ b/changelog.d/+5b021f7c.feature.md @@ -0,0 +1 @@ +Added support for MSC4363 (OAuth step-up authentication). Contributed by @ginger. diff --git a/src/api/client/capabilities.rs b/src/api/client/capabilities.rs index af5eb6ea2..28a5e0fc5 100644 --- a/src/api/client/capabilities.rs +++ b/src/api/client/capabilities.rs @@ -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, body: Ruma, ) -> Result { + let sender_user = body.identity.expect_sender_user()?; + let available: BTreeMap = 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( diff --git a/src/api/client/profile.rs b/src/api/client/profile.rs index 5739f3781..4f2d6bedc 100644 --- a/src/api/client/profile.rs +++ b/src/api/client/profile.rs @@ -29,6 +29,12 @@ pub(crate) async fn get_profile_route( State(services): State, body: Ruma, ) -> Result { + 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, body: Ruma, ) -> Result { + 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 })) diff --git a/src/api/router/args.rs b/src/api/router/args.rs index 6a868fe70..beee4a3d0 100644 --- a/src/api/router/args.rs +++ b/src/api/router/args.rs @@ -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::(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::(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 }) } } diff --git a/src/api/router/auth.rs b/src/api/router/auth.rs index b29dc2e13..cac270ee0 100644 --- a/src/api/router/auth.rs +++ b/src/api/router/auth.rs @@ -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 + Sync>( + fn check + Any>( services: &Services, - incoming_request: &hyper::Request, + incoming_request: &hyper::Request<&[u8]>, + authentication: Self::Output, query: AuthQueryParams, - ) -> impl Future> + Send { - async move { - let route = TypeId::of::(); - - 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 + Sync>( - services: &Services, - output: Self::Output, - request: &hyper::Request, - query: AuthQueryParams, - route: TypeId, ) -> impl Future> + Send; } impl CheckAuth for ServerSignatures { type Identity = OwnedServerName; - async fn verify + Sync>( + async fn check + Any>( services: &Services, - output: Self::Output, - request: &hyper::Request, + incoming_request: &hyper::Request<&[u8]>, + authentication: Self::Output, _query: AuthQueryParams, - _route: TypeId, ) -> Result { - 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 + 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 + Sync>( impl CheckAuth for AccessToken { type Identity = ClientIdentity; - async fn verify + Sync>( + async fn check + Any>( services: &Services, - output: Self::Output, - _request: &hyper::Request, + _incoming_request: &hyper::Request<&[u8]>, + authentication: Self::Output, query: AuthQueryParams, - route: TypeId, ) -> Result { - 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::() - || route == TypeId::of::()) - { - 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::required_client_scopes(), + ) + .await } } impl CheckAuth for AccessTokenOptional { type Identity = Option; - async fn verify + Sync>( + async fn check + Any>( services: &Services, - output: Self::Output, - request: &hyper::Request, + _incoming_request: &hyper::Request<&[u8]>, + authentication: Self::Output, query: AuthQueryParams, - route: TypeId, ) -> Result { - match output { - | Some(token) => - ::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::required_client_scopes(), + ) + .await + .map(Some) + } else { + Ok(None) } } } @@ -301,40 +203,31 @@ async fn verify + Sync>( impl CheckAuth for AppserviceToken { type Identity = RegistrationInfo; - async fn verify + Sync>( + async fn check( services: &Services, - output: Self::Output, - _request: &hyper::Request, + _incoming_request: &hyper::Request<&[u8]>, + authentication: Self::Output, _query: AuthQueryParams, - _route: TypeId, ) -> Result { - 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; - async fn verify + Sync>( + async fn check + Any>( services: &Services, - output: Self::Output, - request: &hyper::Request, - query: AuthQueryParams, - route: TypeId, + _incoming_request: &hyper::Request<&[u8]>, + authentication: Self::Output, + _query: AuthQueryParams, ) -> Result { - match output { - | Some(token) => - ::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 + Sync>( impl CheckAuth for NoAuthentication { type Identity = (); - fn verify + Sync>( + fn check + Any>( _services: &Services, - _output: Self::Output, - _request: &hyper::Request, + _incoming_request: &hyper::Request<&[u8]>, + _authentication: Self::Output, _query: AuthQueryParams, - _route: TypeId, - ) -> impl Future> { + ) -> impl Future> + Send { std::future::ready(Ok(())) } } @@ -356,31 +248,166 @@ fn verify + Sync>( impl CheckAuth for NoAccessToken { type Identity = Option; - async fn verify + Sync>( + async fn check + Any>( services: &Services, - _output: Self::Output, - request: &hyper::Request, + incoming_request: &hyper::Request<&[u8]>, + _authentication: Self::Output, query: AuthQueryParams, - route: TypeId, ) -> Result { // 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::() - || route == TypeId::of::() - || route == TypeId::of::() - || route == TypeId::of::()) - && 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::(), &[ + OAuthClientScope::ApiFullAccess, + ]) + .await + .map(Some) + } else { + Ok(None) } - - ::verify(services, token, request, query, route).await } } + +async fn check_access_token( + services: &Services, + token: &str, + query: AuthQueryParams, + route: TypeId, + required_scopes: &[OAuthClientScope], +) -> Result { + 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::() + || route == TypeId::of::()) + { + 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 { + let Ok(appservice_info) = services.appservice.find_from_token(token).await else { + return Err!(Request(Unauthorized("Invalid appservice token."))); + }; + + Ok(appservice_info) +} diff --git a/src/core/matrix/versions.rs b/src/core/matrix/versions.rs index 1e8050cf2..95fbd25c4 100644 --- a/src/core/matrix/versions.rs +++ b/src/core/matrix/versions.rs @@ -46,7 +46,7 @@ pub fn unstable_features() -> BTreeMap { ("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), ]) } diff --git a/src/service/migrations.rs b/src/service/migrations.rs index 8816448c3..894dd5aa3 100644 --- a/src/service/migrations.rs +++ b/src/service/migrations.rs @@ -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(()) +} diff --git a/src/service/oauth/client_metadata.rs b/src/service/oauth/client_metadata.rs index 3aa4d5943..75d6d15b3 100644 --- a/src/service/oauth/client_metadata.rs +++ b/src/service/oauth/client_metadata.rs @@ -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, D::Error> +pub(super) fn btreeset_skip_err<'de, D, V>(de: D) -> Result, 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 { impl<'de, V> Visitor<'de> for BTreeSetVisitor where - V: Deserialize<'de> + Hash + Eq + Ord, + V: DeserializeOwned + Hash + Eq + Ord + Debug, { type Value = BTreeSet; @@ -176,8 +176,14 @@ fn visit_seq(self, mut seq: A) -> Result { 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::().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); } } diff --git a/src/service/oauth/grant.rs b/src/service/oauth/grant.rs index 39955b825..dcbc146df 100644 --- a/src/service/oauth/grant.rs +++ b/src/service/oauth/grant.rs @@ -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, + pub scopes: BTreeSet, } -impl PartialEq for Scope { - fn eq(&self, other: &Self) -> bool { discriminant(self) == discriminant(other) } -} - -impl Eq for Scope {} - -impl Hash for Scope { - fn hash(&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, String> { + #[allow(clippy::trivial_regex)] + pub fn to_scopes(&self) -> Result { 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; } }; diff --git a/src/service/oauth/mod.rs b/src/service/oauth/mod.rs index 138583a09..8bf3b0e75 100644 --- a/src/service/oauth/mod.rs +++ b/src/service/oauth/mod.rs @@ -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, users: Dep, config: Dep, } @@ -55,10 +56,21 @@ struct Services { #[derive(Debug, Deserialize, Serialize)] pub struct SessionInfo { pub client_id: String, - pub scopes: BTreeSet, + #[serde(deserialize_with = "client_metadata::btreeset_skip_err")] + scopes: BTreeSet, + #[serde(default)] + temporary_scopes: BTreeSet, + #[serde(default)] current_refresh_token: String, } +impl SessionInfo { + #[must_use] + pub fn scopes(&self) -> BTreeSet { + 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, + requested_scopes: RequestedScopes, client_name: Option, 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, + requested_scopes: RequestedScopes, client_name: Option, 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, + 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> { Ok(Arc::new(Self { services: Services { + admin: args.depend::("admin"), users: args.depend::("users"), config: args.depend::("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, + requested_scopes: RequestedScopes, client_name: Option, client_id: String, ) -> Result { + 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, + ) -> 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 diff --git a/src/web/pages/components/mod.rs b/src/web/pages/components/mod.rs index 561f2b630..5cfd9af22 100644 --- a/src/web/pages/components/mod.rs +++ b/src/web/pages/components/mod.rs @@ -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, pub avatar: Avatar, pub last_active: String, - pub oauth_metadata: Option, 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, + pub scopes: BTreeSet, } impl HtmlSafe for ClientScopes {} diff --git a/src/web/pages/oauth/grant.rs b/src/web/pages/oauth/grant.rs index 5fe7656f1..bde0b7d48 100644 --- a/src/web/pages/oauth/grant.rs +++ b/src/web/pages/oauth/grant.rs @@ -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, device_code: Option } } @@ -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), )) }, diff --git a/src/web/pages/resources/common.css b/src/web/pages/resources/common.css index 169343019..0ead096d1 100644 --- a/src/web/pages/resources/common.css +++ b/src/web/pages/resources/common.css @@ -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(); +} diff --git a/src/web/pages/resources/components.css b/src/web/pages/resources/components.css index 12cd5ac64..4993d1140 100644 --- a/src/web/pages/resources/components.css +++ b/src/web/pages/resources/components.css @@ -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; } diff --git a/src/web/pages/resources/grant.css b/src/web/pages/resources/grant.css index 157998753..fca28078d 100644 --- a/src/web/pages/resources/grant.css +++ b/src/web/pages/resources/grant.css @@ -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; + } } } diff --git a/src/web/pages/templates/_components/client_scopes.html.j2 b/src/web/pages/templates/_components/client_scopes.html.j2 index bf7df3aec..7be5f2726 100644 --- a/src/web/pages/templates/_components/client_scopes.html.j2 +++ b/src/web/pages/templates/_components/client_scopes.html.j2 @@ -1,10 +1,20 @@ -