From 69163b5391d61e08a67182554765a31cc02dfcec Mon Sep 17 00:00:00 2001 From: Ginger Date: Mon, 29 Jun 2026 12:58:22 -0400 Subject: [PATCH 01/26] refactor: Split remote and deactivated users into their own columns --- src/admin/check/commands.rs | 7 +- src/admin/debug/commands.rs | 2 +- src/admin/query/users.rs | 11 +- src/admin/user/commands.rs | 106 +++++++------- src/admin/user/mod.rs | 3 + src/admin/utils.rs | 9 +- src/api/client/account/mod.rs | 3 +- src/api/client/account/register.rs | 4 +- src/api/client/admin/lock.rs | 29 ++-- src/api/client/admin/suspend.rs | 29 ++-- src/api/client/profile.rs | 2 +- src/api/client/report.rs | 2 +- src/api/client/session.rs | 8 -- src/api/client/user_directory.rs | 56 ++++---- src/api/server/send.rs | 3 +- src/database/maps.rs | 8 ++ src/service/admin/create.rs | 2 +- src/service/appservice/mod.rs | 14 +- src/service/emergency/mod.rs | 25 ++-- src/service/migrations.rs | 61 +++++++- src/service/rooms/state_cache/mod.rs | 2 +- src/service/rooms/state_cache/update.rs | 11 +- src/service/rooms/timeline/append.rs | 8 +- src/service/users/account.rs | 172 +++++++++++++++-------- src/service/users/dehydrated_device.rs | 5 +- src/service/users/device.rs | 33 ++--- src/service/users/mod.rs | 7 +- src/service/users/remote.rs | 17 +++ src/web/pages/account/password/change.rs | 12 +- src/web/pages/account/password/reset.rs | 2 +- src/web/pages/account/register.rs | 10 +- src/web/session/mod.rs | 6 +- 32 files changed, 391 insertions(+), 278 deletions(-) create mode 100644 src/service/users/remote.rs diff --git a/src/admin/check/commands.rs b/src/admin/check/commands.rs index 14ef4f78f..db17bb109 100644 --- a/src/admin/check/commands.rs +++ b/src/admin/check/commands.rs @@ -6,7 +6,12 @@ use crate::Context; impl Context<'_> { pub(super) async fn check_all_users(&self) -> Result { let timer = tokio::time::Instant::now(); - let users = self.services.users.stream().collect::>().await; + let users = self + .services + .users + .stream_local_users() + .collect::>() + .await; let query_time = timer.elapsed(); let total = users.len(); diff --git a/src/admin/debug/commands.rs b/src/admin/debug/commands.rs index 7d792a428..de17073bd 100644 --- a/src/admin/debug/commands.rs +++ b/src/admin/debug/commands.rs @@ -612,7 +612,7 @@ impl crate::Context<'_> { // Force E2EE device list updates for all users self.services .users - .stream() + .stream_local_users() .for_each(async |user_id| self.services.users.mark_device_key_update(&user_id).await) .await; diff --git a/src/admin/query/users.rs b/src/admin/query/users.rs index 13fe9a888..7449f0689 100644 --- a/src/admin/query/users.rs +++ b/src/admin/query/users.rs @@ -191,8 +191,13 @@ impl crate::Context<'_> { async fn iter_users(&self) -> Result { let timer = tokio::time::Instant::now(); - let result: Vec = - self.services.users.stream().map(Into::into).collect().await; + let result: Vec = self + .services + .users + .stream_local_users() + .map(Into::into) + .collect() + .await; let query_time = timer.elapsed(); @@ -202,7 +207,7 @@ impl crate::Context<'_> { async fn iter_users2(&self) -> Result { let timer = tokio::time::Instant::now(); - let result: Vec<_> = self.services.users.stream().collect().await; + let result: Vec<_> = self.services.users.stream_local_users().collect().await; let result: Vec<_> = result .into_iter() .map(|user_id| String::from_utf8_lossy(user_id.as_bytes()).into_owned()) diff --git a/src/admin/user/commands.rs b/src/admin/user/commands.rs index 26dadc5b5..a9ffce7ff 100644 --- a/src/admin/user/commands.rs +++ b/src/admin/user/commands.rs @@ -20,7 +20,7 @@ use ruma::{ tag::{TagEvent, TagEventContent, TagInfo}, }, }; -use service::users::HashedPassword; +use service::users::{AccountStatus, HashedPassword}; use crate::{ get_room_info, @@ -60,7 +60,7 @@ impl crate::Context<'_> { self.services .users .create_local_account(&user_id, HashedPassword::new(password)?, None) - .await; + .await?; self.write_str(&format!("Created user {user_id} with password `{password}`")) .await @@ -103,15 +103,12 @@ impl crate::Context<'_> { pub(super) async fn suspend(&self, user_id: String) -> Result { self.bail_restricted()?; - let user_id = parse_local_user_id(self.services, &user_id)?; + let user_id = parse_active_local_user_id(self.services, &user_id).await?; if user_id == self.services.globals.server_user { return Err!("Not allowed to suspend the server service account.",); } - if !self.services.users.exists(&user_id).await { - return Err!("User {user_id} does not exist."); - } if self.services.users.is_admin(&user_id).await { return Err!("Admin users cannot be suspended."); } @@ -127,15 +124,12 @@ impl crate::Context<'_> { pub(super) async fn unsuspend(&self, user_id: String) -> Result { self.bail_restricted()?; - let user_id = parse_local_user_id(self.services, &user_id)?; + let user_id = parse_active_local_user_id(self.services, &user_id).await?; if user_id == self.services.globals.server_user { return Err!("Not allowed to unsuspend the server service account.",); } - if !self.services.users.exists(&user_id).await { - return Err!("User {user_id} does not exist."); - } self.services.users.unsuspend_account(&user_id).await; self.write_str(&format!("User {user_id} has been unsuspended.")) @@ -147,6 +141,7 @@ impl crate::Context<'_> { logout: bool, username: String, password: Option, + convert_to_local_account: bool, ) -> Result { let user_id = parse_local_user_id(self.services, &username)?; @@ -159,15 +154,37 @@ impl crate::Context<'_> { let new_password = password.unwrap_or_else(|| utils::random_string(AUTO_GEN_PASSWORD_LENGTH)); + let new_password_hash = HashedPassword::new(&new_password)?; - self.services - .users - .set_password(&user_id, Some(HashedPassword::new(&new_password)?)); + if convert_to_local_account { + self.services + .users + .convert_to_local_account(&user_id, new_password_hash) + .await?; + } else { + match self.services.users.status(&user_id).await { + | AccountStatus::Active if !self.services.users.is_shadow(&user_id).await => { + self.services + .users + .set_password(&user_id, new_password_hash) + .await?; + }, + | AccountStatus::NotFound => { + return Err!("The provided user does not exist."); + }, + | _ => { + return Err!( + "The provided user is a shadow or deactivated account. To convert it to \ + a local account, pass the --convert-to-local-account flag." + ); + }, + } - self.write_str(&format!( - "Successfully reset the password for user {user_id}: `{new_password}`" - )) - .await?; + self.write_str(&format!( + "Successfully reset the password for user {user_id}: `{new_password}`" + )) + .await?; + } if logout { self.services @@ -919,21 +936,16 @@ impl crate::Context<'_> { pub(super) async fn lock(&self, user_id: String) -> Result { self.bail_restricted()?; - let user_id = parse_local_user_id(self.services, &user_id)?; - assert!( - self.services.globals.user_is_local(&user_id), - "Parsed user_id must be a local user" - ); + let user_id = parse_active_local_user_id(self.services, &user_id).await?; + if user_id == self.services.globals.server_user { return Err!("Not allowed to lock the server service account.",); } - if !self.services.users.exists(&user_id).await { - return Err!("User {user_id} does not exist."); - } if self.services.users.is_admin(&user_id).await { return Err!("Admin users cannot be locked."); } + self.services .users .lock_account(&user_id, self.sender_or_service_user()) @@ -945,11 +957,8 @@ impl crate::Context<'_> { pub(super) async fn unlock(&self, user_id: String) -> Result { self.bail_restricted()?; - let user_id = parse_local_user_id(self.services, &user_id)?; - assert!( - self.services.globals.user_is_local(&user_id), - "Parsed user_id must be a local user" - ); + let user_id = parse_active_local_user_id(self.services, &user_id).await?; + self.services.users.unlock_account(&user_id).await; self.write_str(&format!("User {user_id} has been unlocked.")) @@ -958,21 +967,16 @@ impl crate::Context<'_> { pub(super) async fn logout(&self, user_id: String) -> Result { self.bail_restricted()?; - let user_id = parse_local_user_id(self.services, &user_id)?; - assert!( - self.services.globals.user_is_local(&user_id), - "Parsed user_id must be a local user" - ); + let user_id = parse_active_local_user_id(self.services, &user_id).await?; + if user_id == self.services.globals.server_user { return Err!("Not allowed to log out the server service account.",); } - if !self.services.users.exists(&user_id).await { - return Err!("User {user_id} does not exist."); - } if self.services.users.is_admin(&user_id).await { return Err!("You cannot forcefully log out admin users."); } + self.services .users .all_device_ids(&user_id) @@ -989,18 +993,12 @@ impl crate::Context<'_> { pub(super) async fn disable_login(&self, user_id: String) -> Result { self.bail_restricted()?; - let user_id = parse_local_user_id(self.services, &user_id)?; - assert!( - self.services.globals.user_is_local(&user_id), - "Parsed user_id must be a local user" - ); + let user_id = parse_active_local_user_id(self.services, &user_id).await?; + if user_id == self.services.globals.server_user { return Err!("Not allowed to disable login for the server service account.",); } - if !self.services.users.exists(&user_id).await { - return Err!("User {user_id} does not exist."); - } if self.services.users.is_admin(&user_id).await { return Err!("Admin users cannot have their login disallowed."); } @@ -1014,14 +1012,8 @@ impl crate::Context<'_> { pub(super) async fn enable_login(&self, user_id: String) -> Result { self.bail_restricted()?; - let user_id = parse_local_user_id(self.services, &user_id)?; - assert!( - self.services.globals.user_is_local(&user_id), - "Parsed user_id must be a local user" - ); - if !self.services.users.exists(&user_id).await { - return Err!("User {user_id} does not exist."); - } + let user_id = parse_active_local_user_id(self.services, &user_id).await?; + self.services.users.enable_login(&user_id); self.write_str(&format!("{user_id} can now log in.")).await @@ -1129,10 +1121,8 @@ impl crate::Context<'_> { } pub(super) async fn reset_push_rules(&self, user_id: String) -> Result { - let user_id = parse_local_user_id(self.services, &user_id)?; - if !self.services.users.is_active(&user_id).await { - return Err!("User is not active."); - } + let user_id = parse_active_local_user_id(self.services, &user_id).await?; + recreate_push_rules_and_return(self.services, &user_id).await?; self.write_str("Reset user's push rules to the server default.") .await diff --git a/src/admin/user/mod.rs b/src/admin/user/mod.rs index b2d05d650..c5278505a 100644 --- a/src/admin/user/mod.rs +++ b/src/admin/user/mod.rs @@ -27,6 +27,9 @@ pub enum UserCommand { username: String, /// New password for the user, if unspecified one is generated password: Option, + + #[arg(long)] + convert_to_local_account: bool, }, /// Get a user's associated email address. diff --git a/src/admin/utils.rs b/src/admin/utils.rs index 3df7d844a..f39985417 100644 --- a/src/admin/utils.rs +++ b/src/admin/utils.rs @@ -54,14 +54,7 @@ pub(crate) async fn parse_active_local_user_id( user_id: &str, ) -> Result { let user_id = parse_local_user_id(services, user_id)?; - - if !services.users.exists(&user_id).await { - return Err!("User {user_id:?} does not exist on this server."); - } - - if services.users.is_deactivated(&user_id).await? { - return Err!("User {user_id:?} is deactivated."); - } + services.users.status(&user_id).await.ensure_active()?; Ok(user_id) } diff --git a/src/api/client/account/mod.rs b/src/api/client/account/mod.rs index 807ff240a..686f24fa9 100644 --- a/src/api/client/account/mod.rs +++ b/src/api/client/account/mod.rs @@ -128,7 +128,8 @@ pub(crate) async fn change_password_route( services .users - .set_password(&sender_user, Some(HashedPassword::new(&body.new_password)?)); + .set_password(&sender_user, HashedPassword::new(&body.new_password)?) + .await?; if body.logout_devices { // Logout all devices except the current one diff --git a/src/api/client/account/register.rs b/src/api/client/account/register.rs index 8f8ddf740..d41cc6a96 100644 --- a/src/api/client/account/register.rs +++ b/src/api/client/account/register.rs @@ -72,7 +72,7 @@ pub(crate) async fn register_route( .determine_registration_user_id(body.username.clone(), None, Some(appservice_info)) .await?; - services.users.create(&user_id, None)?; + services.users.create_shadow_account(&user_id).await?; user_id } else { @@ -98,7 +98,7 @@ pub(crate) async fn register_route( services .users .create_local_account(&user_id, password, identity.email) - .await; + .await?; user_id }; diff --git a/src/api/client/admin/lock.rs b/src/api/client/admin/lock.rs index f748ef1ba..81b95dd61 100644 --- a/src/api/client/admin/lock.rs +++ b/src/api/client/admin/lock.rs @@ -12,20 +12,18 @@ pub(crate) async fn get_lock_status( State(services): State, body: Ruma, ) -> Result { - let (admin, active) = join( + let (admin, status) = join( services.users.is_admin(body.identity.expect_sender_user()?), - services.users.is_active(&body.user_id), + services.users.status(&body.user_id), ) .await; + if !admin { return Err!(Request(Forbidden("Only server administrators can use this endpoint"))); } - if !services.globals.user_is_local(&body.user_id) { - return Err!(Request(InvalidParam("Can only check the lock status of local users"))); - } - if !active { - return Err!(Request(NotFound("Unknown user"))); - } + + status.ensure_active()?; + Ok(is_user_locked::v1::Response::new( services.users.is_locked(&body.user_id).await?, )) @@ -40,9 +38,9 @@ pub(crate) async fn put_lock_status( ) -> Result { let sender_user = body.identity.expect_sender_user()?; - let (sender_admin, active, target_admin) = join3( + let (sender_admin, status, target_admin) = join3( services.users.is_admin(sender_user), - services.users.is_active(&body.user_id), + services.users.status(&body.user_id), services.users.is_admin(&body.user_id), ) .await; @@ -50,18 +48,17 @@ pub(crate) async fn put_lock_status( if !sender_admin { return Err!(Request(Forbidden("Only server administrators can use this endpoint"))); } - if !services.globals.user_is_local(&body.user_id) { - return Err!(Request(InvalidParam("Can only set the lock status of local users"))); - } - if !active { - return Err!(Request(NotFound("Unknown user"))); - } + + status.ensure_active()?; + if body.user_id == *sender_user { return Err!(Request(Forbidden("You cannot lock yourself"))); } + if target_admin { return Err!(Request(Forbidden("You cannot lock another server administrator"))); } + if services.users.is_locked(&body.user_id).await? == body.locked { // No change return Ok(lock_user::v1::Response::new(body.locked)); diff --git a/src/api/client/admin/suspend.rs b/src/api/client/admin/suspend.rs index cf1beca8d..fac32f12e 100644 --- a/src/api/client/admin/suspend.rs +++ b/src/api/client/admin/suspend.rs @@ -12,20 +12,18 @@ pub(crate) async fn get_suspended_status( State(services): State, body: Ruma, ) -> Result { - let (admin, active) = join( + let (admin, status) = join( services.users.is_admin(body.identity.expect_sender_user()?), - services.users.is_active(&body.user_id), + services.users.status(&body.user_id), ) .await; + if !admin { return Err!(Request(Forbidden("Only server administrators can use this endpoint"))); } - if !services.globals.user_is_local(&body.user_id) { - return Err!(Request(InvalidParam("Can only check the suspended status of local users"))); - } - if !active { - return Err!(Request(NotFound("Unknown user"))); - } + + status.ensure_active()?; + Ok(is_user_suspended::v1::Response::new( services.users.is_suspended(&body.user_id).await?, )) @@ -40,9 +38,9 @@ pub(crate) async fn put_suspended_status( ) -> Result { let sender_user = body.identity.expect_sender_user()?; - let (sender_admin, active, target_admin) = join3( + let (sender_admin, status, target_admin) = join3( services.users.is_admin(sender_user), - services.users.is_active(&body.user_id), + services.users.status(&body.user_id), services.users.is_admin(&body.user_id), ) .await; @@ -50,18 +48,17 @@ pub(crate) async fn put_suspended_status( if !sender_admin { return Err!(Request(Forbidden("Only server administrators can use this endpoint"))); } - if !services.globals.user_is_local(&body.user_id) { - return Err!(Request(InvalidParam("Can only set the suspended status of local users"))); - } - if !active { - return Err!(Request(NotFound("Unknown user"))); - } + + status.ensure_active()?; + if body.user_id == *sender_user { return Err!(Request(Forbidden("You cannot suspend yourself"))); } + if target_admin { return Err!(Request(Forbidden("You cannot suspend another server administrator"))); } + if services.users.is_suspended(&body.user_id).await? == body.suspended { // No change return Ok(suspend_user::v1::Response::new(body.suspended)); diff --git a/src/api/client/profile.rs b/src/api/client/profile.rs index 7dd3e6371..b611e31de 100644 --- a/src/api/client/profile.rs +++ b/src/api/client/profile.rs @@ -110,7 +110,7 @@ async fn fetch_full_profile( user_id: &UserId, ) -> Option> { // If the user exists locally, fetch their local profile - if services.users.exists(user_id).await { + if services.users.status(user_id).await.is_found() { return Some(get_local_profile(services, user_id).await); } diff --git a/src/api/client/report.rs b/src/api/client/report.rs index a7567c113..a7acd9790 100644 --- a/src/api/client/report.rs +++ b/src/api/client/report.rs @@ -149,7 +149,7 @@ pub(crate) async fn report_user_route( delay_response().await; - if !services.users.is_active_local(&body.user_id).await { + if !services.users.status(&body.user_id).await.is_found() { // return 200 as to not reveal if the user exists. Recommended by spec. return Ok(report_user::v3::Response::new()); } diff --git a/src/api/client/session.rs b/src/api/client/session.rs index dd95a1c2c..c7af45fe5 100644 --- a/src/api/client/session.rs +++ b/src/api/client/session.rs @@ -88,14 +88,6 @@ pub async fn handle_login( UserId::parse_with_server_name(user_id_or_localpart, &services.config.server_name) .map_err(|_| err!(Request(InvalidUsername("User ID is malformed"))))?; - if !services.globals.user_is_local(&user_id) { - return Err!(Request(InvalidParam("User ID does not belong to this homeserver"))); - } - - if services.users.is_deactivated(&user_id).await? { - return Err!(Request(UserDeactivated("This account has been deactivated."))); - } - if services.users.is_locked(&user_id).await? { return Err!(Request(UserLocked("This account has been locked."))); } diff --git a/src/api/client/user_directory.rs b/src/api/client/user_directory.rs index 18c02163b..7d8a9a9c4 100644 --- a/src/api/client/user_directory.rs +++ b/src/api/client/user_directory.rs @@ -32,22 +32,26 @@ pub(crate) async fn search_users_route( .min(LIMIT_MAX); let search_term = body.search_term.to_lowercase(); - let mut users = services.users.stream().broad_filter_map(async |user_id| { - let display_name = services.users.displayname(&user_id).await.ok(); - let user_id_matches = user_id.as_str().to_lowercase().contains(&search_term); + let mut users = services + .users + .stream_local_users() + .chain(services.users.stream_remote_users()) + .broad_filter_map(async |user_id| { + let display_name = services.users.displayname(&user_id).await.ok(); - let display_name_matches = display_name - .as_deref() - .map(str::to_lowercase) - .is_some_and(|display_name| display_name.contains(&search_term)); + let user_id_matches = user_id.as_str().to_lowercase().contains(&search_term); - if !user_id_matches && !display_name_matches { - return None; - } + let display_name_matches = display_name + .as_deref() + .map(str::to_lowercase) + .is_some_and(|display_name| display_name.contains(&search_term)); - let user_in_public_room = - services + if !user_id_matches && !display_name_matches { + return None; + } + + let user_in_public_room = services .rooms .state_cache .rooms_joined(&user_id) @@ -60,22 +64,22 @@ pub(crate) async fn search_users_route( .await }); - let user_sees_user = services - .rooms - .state_cache - .user_sees_user(sender_user, &user_id); + let user_sees_user = services + .rooms + .state_cache + .user_sees_user(sender_user, &user_id); - pin_mut!(user_in_public_room, user_sees_user); + pin_mut!(user_in_public_room, user_sees_user); - if user_in_public_room.or(user_sees_user).await { - Some(assign!(search_users::v3::User::new(user_id.clone()), { - display_name, - avatar_url: services.users.avatar_url(&user_id).await.ok(), - })) - } else { - None - } - }); + if user_in_public_room.or(user_sees_user).await { + Some(assign!(search_users::v3::User::new(user_id.clone()), { + display_name, + avatar_url: services.users.avatar_url(&user_id).await.ok(), + })) + } else { + None + } + }); let results = users.by_ref().take(limit).collect().await; let limited = users.next().await.is_some(); diff --git a/src/api/server/send.rs b/src/api/server/send.rs index 47438ae85..ead1713c1 100644 --- a/src/api/server/send.rs +++ b/src/api/server/send.rs @@ -620,8 +620,9 @@ async fn handle_edu_direct_to_device( .broad_filter_map(|(target_user_id, map)| async move { services .users - .is_active_local(&target_user_id) + .status(&target_user_id) .await + .is_active() .then_some((target_user_id, map)) }) .for_each_concurrent(automatic_width(), |(target_user_id, map)| { diff --git a/src/database/maps.rs b/src/database/maps.rs index 778c098e8..dbaa71a78 100644 --- a/src/database/maps.rs +++ b/src/database/maps.rs @@ -169,6 +169,10 @@ pub(super) static MAPS: &[Descriptor] = &[ name: "registrationtoken_info", ..descriptor::RANDOM_SMALL }, + Descriptor { + name: "remoteuserid_remoteuser", + ..descriptor::RANDOM + }, Descriptor { name: "roomid_invitedcount", ..descriptor::RANDOM_SMALL @@ -398,6 +402,10 @@ pub(super) static MAPS: &[Descriptor] = &[ name: "userid_blurhash", ..descriptor::DROPPED }, + Descriptor { + name: "userid_deactivated", + ..descriptor::RANDOM_SMALL + }, Descriptor { name: "userid_dehydrateddevice", ..descriptor::RANDOM_SMALL diff --git a/src/service/admin/create.rs b/src/service/admin/create.rs index 46c838f9d..2febccaa4 100644 --- a/src/service/admin/create.rs +++ b/src/service/admin/create.rs @@ -48,7 +48,7 @@ pub async fn create_admin_room(services: &Services) -> Result { // Create a user for the server let server_user = services.globals.server_user.as_ref(); - services.users.create(server_user, None)?; + services.users.create_shadow_account(server_user).await?; let mut create_content = if room_version_rules.authorization.use_room_create_sender { RoomCreateEventContent::new_v1(server_user.into()) diff --git a/src/service/appservice/mod.rs b/src/service/appservice/mod.rs index ebd73d8af..3c3efa9bc 100644 --- a/src/service/appservice/mod.rs +++ b/src/service/appservice/mod.rs @@ -108,17 +108,17 @@ impl Service { self.services.globals.server_name(), )?; - if !self.services.users.exists(&appservice_user_id).await { - self.services.users.create(&appservice_user_id, None)?; - } else if self + if !self .services .users - .is_deactivated(&appservice_user_id) + .status(&appservice_user_id) .await - .unwrap_or(false) + .is_found() { - // Reactivate the appservice user if it was accidentally deactivated - self.services.users.set_password(&appservice_user_id, None); + self.services + .users + .create_shadow_account(&appservice_user_id) + .await?; } self.registration_info diff --git a/src/service/emergency/mod.rs b/src/service/emergency/mod.rs index 31fa8604c..eca3ccbf8 100644 --- a/src/service/emergency/mod.rs +++ b/src/service/emergency/mod.rs @@ -54,15 +54,22 @@ impl Service { async fn set_emergency_access(&self) -> Result { let server_user = &self.services.globals.server_user; - self.services.users.set_password( - server_user, - self.services - .config - .emergency_password - .as_deref() - .map(HashedPassword::new) - .transpose()?, - ); + match &self.services.config.emergency_password { + | Some(emergency_password) => { + let emergency_password = HashedPassword::new(emergency_password)?; + + self.services + .users + .convert_to_local_account(server_user, emergency_password) + .await?; + }, + | None => { + self.services + .users + .convert_to_shadow_account(server_user) + .await?; + }, + } let (ruleset, pwd_set) = match self.services.config.emergency_password { | Some(_) => (Ruleset::server_default(server_user), true), diff --git a/src/service/migrations.rs b/src/service/migrations.rs index bd087289b..f62aa21de 100644 --- a/src/service/migrations.rs +++ b/src/service/migrations.rs @@ -41,7 +41,7 @@ pub(crate) async fn migrations(services: &Services) -> Result<()> { // requires recreating the database from scratch. if users_count > 0 { let server_user = &services.globals.server_user; - if !services.users.exists(server_user).await { + if !services.users.status(server_user).await.is_found() { error!("The {server_user} server user does not exist, and the database is not new."); return Err!(Database( "Cannot reuse an existing database after changing the server name, please \ @@ -228,6 +228,18 @@ async fn migrate(services: &Services) -> Result<()> { .map_err(|e| err!("Failed to run 'fix_local_invite_state' migration': {e}"))?; } + if services.globals.db.database_version().await < 18 { + services.globals.db.bump_database_version(18); + info!("Migration: Bumped database version to 18"); + } + + if db["global"].get(SPLIT_USERID_PASSWORD).await.is_not_found() { + info!("Running migration 'split_userid_password'"); + split_userid_password(services) + .await + .map_err(|e| err!("Failed to run 'split_userid_password' migration': {e}"))?; + } + assert_eq!( services.globals.db.database_version().await, DATABASE_VERSION, @@ -242,9 +254,9 @@ async fn migrate(services: &Services) -> Result<()> { if !patterns.is_empty() { services .users - .stream() + .stream_local_users() .filter_map(async |user_id| { - if services.users.is_active_local(&user_id).await { + if services.users.status(&user_id).await.is_found() { Some(user_id) } else { None @@ -774,7 +786,7 @@ async fn fix_local_invite_state(services: &Services) -> Result { let db = &services.db; let cork = db.cork_and_sync(); - let userroomid_invitestate = services.db["userroomid_invitestate"].clone(); + let userroomid_invitestate = db["userroomid_invitestate"].clone(); // for each user invited to a room let fixed = userroomid_invitestate.stream() @@ -818,3 +830,44 @@ async fn fix_local_invite_state(services: &Services) -> Result { db.db.sort()?; Ok(()) } + +const SPLIT_USERID_PASSWORD: &str = "split_userid_password"; +async fn split_userid_password(services: &Services) -> Result { + // Split remote and deactivated users out from the `userid_password` table + + let db = &services.db; + let cork = db.cork_and_sync(); + let userid_password = db["userid_password"].clone(); + let remoteuserid_remoteuser = db["remoteuserid_remoteuser"].clone(); + let userid_deactivated = db["userid_deactivated"].clone(); + + let remote_users = userid_password + .stream::() + .ignore_err() + .fold(0_usize, async |mut remote_users, (user_id, hash)| { + if !services.globals.user_is_local(&user_id) { + assert!(hash.is_empty(), "non-empty hash {hash} for remote user {user_id}"); + + remoteuserid_remoteuser.insert(&user_id, ""); + userid_password.remove(&user_id); + remote_users = remote_users.saturating_add(1); + } else if hash.is_empty() { + if !(services.appservice.is_exclusive_user_id(&user_id).await + || user_id == services.globals.server_user) + { + info!("Marking {user_id} as deactivated"); + userid_deactivated.insert(&user_id, ""); + } + } + + remote_users + }) + .await; + + drop(cork); + info!(?remote_users, "Split userid_password."); + + db["global"].insert(FIXED_LOCAL_INVITE_STATE_MARKER, []); + db.db.sort()?; + Ok(()) +} diff --git a/src/service/rooms/state_cache/mod.rs b/src/service/rooms/state_cache/mod.rs index 46ef9a769..5c64ce04c 100644 --- a/src/service/rooms/state_cache/mod.rs +++ b/src/service/rooms/state_cache/mod.rs @@ -248,7 +248,7 @@ impl Service { ) -> impl Stream + Send + 'a { self.local_users_in_room(room_id) .filter_map(async |user_id| { - if self.services.users.is_active(&user_id).await { + if self.services.users.status(&user_id).await.is_active() { Some(user_id) } else { None diff --git a/src/service/rooms/state_cache/update.rs b/src/service/rooms/state_cache/update.rs index 5249df182..28f5098b7 100644 --- a/src/service/rooms/state_cache/update.rs +++ b/src/service/rooms/state_cache/update.rs @@ -30,15 +30,8 @@ impl super::Service { ) -> Result { let membership = pdu.get_content::()?; - // Keep track what remote users exist by adding them as "deactivated" users - // - // TODO: use futures to update remote profiles without blocking the membership - // update - #[allow(clippy::collapsible_if)] - if !self.services.globals.user_is_local(user_id) - && !self.services.users.exists(user_id).await - { - self.services.users.create(user_id, None)?; + if !self.services.globals.user_is_local(user_id) { + self.services.users.record_remote_user(user_id); } match &membership.membership { diff --git a/src/service/rooms/timeline/append.rs b/src/service/rooms/timeline/append.rs index ab657d4fe..132ee4f0a 100644 --- a/src/service/rooms/timeline/append.rs +++ b/src/service/rooms/timeline/append.rs @@ -255,7 +255,13 @@ impl super::Service { if let Some(state_key) = pdu.state_key() { match UserId::parse(state_key) { | Ok(target_user_id) => { - if self.services.users.is_active_local(&target_user_id).await { + if self + .services + .users + .status(&target_user_id) + .await + .is_active() + { push_targets.insert(target_user_id.clone()); } }, diff --git a/src/service/users/account.rs b/src/service/users/account.rs index d663e7940..f4ed1f564 100644 --- a/src/service/users/account.rs +++ b/src/service/users/account.rs @@ -6,7 +6,7 @@ use conduwuit::{ warn, }; use database::{Deserialized, Json}; -use futures::{FutureExt, Stream, StreamExt, TryFutureExt}; +use futures::{FutureExt, Stream, StreamExt}; use lettre::Address; use ruma::{ MilliSecondsSinceUnixEpoch, OwnedDeviceId, OwnedUserId, UserId, @@ -29,6 +29,28 @@ pub enum AccessTokenStatus { Expired, } +/// The status of a user account. +#[derive(Clone, Copy)] +pub enum AccountStatus { + NotFound, + Active, + Deactivated, +} + +impl AccountStatus { + pub fn is_found(&self) -> bool { !matches!(self, Self::NotFound) } + + pub fn is_active(&self) -> bool { matches!(self, Self::Active) } + + pub fn ensure_active(&self) -> Result<()> { + match self { + | Self::Active => Ok(()), + | Self::Deactivated => Err!(Request(UserDeactivated("This account is deactivated."))), + | Self::NotFound => Err!(Request(NotFound("This account does not exist."))), + } + } +} + impl super::Service { /// Returns true/false based on whether the recipient/receiving user has /// ignored the sender. @@ -89,16 +111,16 @@ impl super::Service { self.services.admin.user_is_admin(user_id).await } - /// Create a new user account on this homeserver. Set the password to `None` - /// to create a non-local user. Non-local users with a password will return - /// an error. - #[inline] - pub fn create(&self, user_id: &UserId, password: Option) -> Result<()> { - if !self.services.globals.user_is_local(user_id) && password.is_some() { - return Err!("Cannot create a nonlocal user with a set password"); + /// Create a new shadow user account on this server. Shadow accounts + /// have no password and cannot be logged into. + pub async fn create_shadow_account(&self, user_id: &UserId) -> Result<()> { + assert!(self.services.globals.user_is_local(user_id), "user id must be local"); + + if self.status(user_id).await.is_found() { + return Err!(Request(UserInUse("An account with this user ID already exists."))); } - self.set_password(user_id, password); + self.db.userid_password.insert(user_id, ""); Ok(()) } @@ -109,9 +131,9 @@ impl super::Service { user_id: &UserId, password: HashedPassword, email: Option
, - ) { - self.create(user_id, Some(password)) - .expect("should be able to save a new local user. what happened?"); + ) -> Result<()> { + self.create_shadow_account(user_id).await?; + self.convert_to_local_account(user_id, password).await?; // Set an initial display name { @@ -230,6 +252,8 @@ impl super::Service { } info!("Created new user account for {user_id}"); + + Ok(()) } pub async fn determine_registration_user_id( @@ -297,7 +321,7 @@ impl super::Service { }, }; - if self.exists(&user_id).await { + if self.status(&user_id).await.is_found() { return Err!(Request(UserInUse("User ID is not available."))); } @@ -329,26 +353,24 @@ impl super::Service { ) .unwrap(); - if !self.exists(&user_id).await { + if !self.status(&user_id).await.is_found() { break Ok(user_id); } } } } - /// Deactivates an account, removing all of their device IDs and unsetting - /// their password. + /// Deactivates an account, removing all of their device IDs. pub async fn deactivate_account(&self, user_id: &UserId) -> Result<()> { + self.status(user_id).await.ensure_active()?; + // Remove all associated devices self.all_device_ids(user_id) .for_each(async |device_id| self.remove_device(user_id, &device_id).await) .await; - // Set the password to "" to indicate a deactivated account. Hashes will never - // result in an empty string, so the user will not be able to log in again. - // Systems like changing the password without logging in should check if the - // account is deactivated. - self.set_password(user_id, None); + // Mark user as deactivated + self.db.userid_deactivated.insert(user_id, ""); // TODO: Unhook 3PID Ok(()) @@ -393,25 +415,6 @@ impl super::Service { /// Unlocks an account, allowing the user to log in and use it again. pub async fn unlock_account(&self, user_id: &UserId) { self.db.userid_lock.remove(user_id); } - /// Check if the provided user ID belongs to an existing (possibly - /// deactivated) account on this homeserver. - #[inline] - pub async fn exists(&self, user_id: &UserId) -> bool { - self.services.globals.user_is_local(user_id) - && self.db.userid_password.get(user_id).await.is_ok() - } - - /// Check if account is deactivated (has an empty password). Returns a - /// NotFound error if the user does not exist. - pub async fn is_deactivated(&self, user_id: &UserId) -> Result { - self.db - .userid_password - .get(user_id) - .map_ok(|val| val.is_empty()) - .map_err(|_| err!(Request(NotFound("User does not exist.")))) - .await - } - /// Check if account is suspended. Returns false if the user does not exist. pub async fn is_suspended(&self, user_id: &UserId) -> Result { match self @@ -469,18 +472,33 @@ impl super::Service { .is_ok() } - /// Check if account is active (not deactivated) - pub async fn is_active(&self, user_id: &UserId) -> bool { - !self.is_deactivated(user_id).await.unwrap_or(true) + /// Checks the activation status of the provided user ID's account. + pub async fn status(&self, user_id: &UserId) -> AccountStatus { + if !self.services.globals.user_is_local(user_id) { + AccountStatus::NotFound + } else if self.db.userid_password.exists(user_id).await.is_ok() { + if self.db.userid_deactivated.exists(user_id).await.is_ok() { + AccountStatus::Deactivated + } else { + AccountStatus::Active + } + } else { + AccountStatus::NotFound + } } - /// Check if account is a local user, and is active (not deactivated) - pub async fn is_active_local(&self, user_id: &UserId) -> bool { - self.services.globals.user_is_local(user_id) && self.is_active(user_id).await + /// Checks if a user is a shadow. + pub async fn is_shadow(&self, user_id: &UserId) -> bool { + self.db + .userid_password + .get(user_id) + .await + .deserialized::() + .is_ok_and(|hash| hash.is_empty()) } /// Returns the number of users registered on this server, including - /// deactivated users. + /// deactivated and shadow users. #[inline] pub async fn count(&self) -> usize { self.db.userid_password.count().await } @@ -522,8 +540,8 @@ impl super::Service { Some((user_id, device_id, AccessTokenStatus::Valid)) } - /// Returns an iterator over all users on this homeserver. - pub fn stream(&self) -> impl Stream + Send { + /// Returns an iterator over all local users on this homeserver. + pub fn stream_local_users(&self) -> impl Stream + Send { self.db.userid_password.keys().ignore_err() } @@ -540,16 +558,49 @@ impl super::Service { } /// Set a user's password. - pub fn set_password(&self, user_id: &UserId, password: Option) { - if let Some(hash) = password { - self.db.userid_password.insert(user_id, hash.0); - } else { - self.db.userid_password.insert(user_id, b""); + pub async fn set_password(&self, user_id: &UserId, password: HashedPassword) -> Result { + self.status(user_id).await.ensure_active()?; + + self.db.userid_password.insert(user_id, password.0); + + Ok(()) + } + + /// Convert an existing user to an active local account. This will turn + /// shadow accounts into normal accounts and will clear the deactivation + /// flag on the user if it is set. + pub async fn convert_to_local_account( + &self, + user_id: &UserId, + password: HashedPassword, + ) -> Result { + if !self.status(user_id).await.is_found() { + return Err!(Request(NotFound("This account does not exist."))); } + + self.db.userid_deactivated.remove(user_id); + self.db.userid_password.insert(user_id, password.0); + + Ok(()) + } + + /// Convert an existing user to a shadow account. This will clear their + /// password and reactivate them if they are deactivated. + pub async fn convert_to_shadow_account(&self, user_id: &UserId) -> Result { + if !self.status(user_id).await.is_found() { + return Err!(Request(NotFound("This account does not exist."))); + } + + self.db.userid_deactivated.remove(user_id); + self.db.userid_password.insert(user_id, ""); + + Ok(()) } /// Check a user's password. pub async fn check_password(&self, user_id: &UserId, password: &str) -> Result { + self.status(user_id).await.ensure_active()?; + let (hash, user_id): (String, OwnedUserId) = if let Ok(hash) = self.db.userid_password.get(user_id).await.deserialized() { (hash, user_id.to_owned()) @@ -558,21 +609,20 @@ impl super::Service { // better let lowercase_user_id = UserId::parse(user_id.as_str().to_lowercase()).unwrap(); - if let Ok(hash) = self + let hash = self .db .userid_password .get(lowercase_user_id.as_str()) .await .deserialized() - { - (hash, lowercase_user_id) - } else { - return Err!(Request(Forbidden("This user cannot log in with a password."))); - } + .expect("user should exist"); + + (hash, lowercase_user_id) }; if hash.is_empty() { - return Err!(Request(UserDeactivated("This user is deactivated"))); + // Cannot log into shadow users + return Err!(Request(Forbidden("This user cannot log in with a password."))); } utils::hash::verify_password(password, &hash) diff --git a/src/service/users/dehydrated_device.rs b/src/service/users/dehydrated_device.rs index 1711e64ed..6d536f5af 100644 --- a/src/service/users/dehydrated_device.rs +++ b/src/service/users/dehydrated_device.rs @@ -21,10 +21,7 @@ pub struct DehydratedDevice { impl super::Service { /// Creates or recreates the user's dehydrated device. pub async fn set_dehydrated_device(&self, user_id: &UserId, request: Request) -> Result { - assert!( - self.exists(user_id).await, - "Tried to create dehydrated device for non-existent user" - ); + self.status(user_id).await.ensure_active()?; let existing_id = self.get_dehydrated_device_id(user_id).await; diff --git a/src/service/users/device.rs b/src/service/users/device.rs index bc704f1d8..9d436b152 100644 --- a/src/service/users/device.rs +++ b/src/service/users/device.rs @@ -4,8 +4,8 @@ use std::{ }; use conduwuit::{ - Err, utils, - utils::{ReadyExt, stream::TryIgnore}, + Err, Result, + utils::{self, ReadyExt, stream::TryIgnore}, }; use database::{Deserialized, Ignore, Interfix, Json}; use futures::{Stream, StreamExt}; @@ -18,8 +18,7 @@ use serde_json::json; use crate::users::increment; impl super::Service { - /// Adds a new device to a user. The user must exist, otherwise InvalidParam - /// is returned. + /// Adds a new device to a user. pub async fn create_device( &self, user_id: &UserId, @@ -28,12 +27,8 @@ impl super::Service { token_max_age: Option, initial_device_display_name: Option, client_ip: Option, - ) -> conduwuit::Result<()> { - if !self.exists(user_id).await { - return Err!(Request(InvalidParam(error!( - "Called create_device for non-existent user {user_id}" - )))); - } + ) -> Result<()> { + self.status(user_id).await.ensure_active()?; let key = (user_id, device_id); let mut device = Device::new(device_id.into()); @@ -50,7 +45,7 @@ impl super::Service { /// Removes a device from a user. pub async fn remove_device(&self, user_id: &UserId, device_id: &DeviceId) { // Remove dehydrated device if this is the dehydrated device - let _: conduwuit::Result<_> = self + let _ = self .remove_dehydrated_device(user_id, Some(device_id)) .await; @@ -97,11 +92,7 @@ impl super::Service { } /// Gets the access token associated with a device. - pub async fn get_token( - &self, - user_id: &UserId, - device_id: &DeviceId, - ) -> conduwuit::Result { + pub async fn get_token(&self, user_id: &UserId, device_id: &DeviceId) -> Result { let key = (user_id, device_id); self.db.userdeviceid_token.qry(&key).await.deserialized() } @@ -131,7 +122,7 @@ impl super::Service { device_id: &DeviceId, token: &str, token_max_age: Option, - ) -> conduwuit::Result<()> { + ) -> Result<()> { let key = (user_id, device_id); if self.db.userdeviceid_metadata.qry(&key).await.is_err() { return Err!(Database(error!( @@ -259,7 +250,7 @@ impl super::Service { user_id: &UserId, device_id: &DeviceId, device: &Device, - ) -> conduwuit::Result<()> { + ) -> Result<()> { increment(&self.db.userid_devicelistversion, user_id.as_bytes()); self.update_device_metadata_no_increment(user_id, device_id, device) } @@ -273,7 +264,7 @@ impl super::Service { user_id: &UserId, device_id: &DeviceId, device: &Device, - ) -> conduwuit::Result<()> { + ) -> Result<()> { let key = (user_id, device_id); self.db.userdeviceid_metadata.put(key, Json(device)); @@ -312,7 +303,7 @@ impl super::Service { &self, user_id: &UserId, device_id: &DeviceId, - ) -> conduwuit::Result { + ) -> Result { self.db .userdeviceid_metadata .qry(&(user_id, device_id)) @@ -321,7 +312,7 @@ impl super::Service { } /// Gets the most recent device list version for a user. - pub async fn get_devicelist_version(&self, user_id: &UserId) -> conduwuit::Result { + pub async fn get_devicelist_version(&self, user_id: &UserId) -> Result { self.db .userid_devicelistversion .get(user_id) diff --git a/src/service/users/mod.rs b/src/service/users/mod.rs index ae4d47ef9..734651e5a 100644 --- a/src/service/users/mod.rs +++ b/src/service/users/mod.rs @@ -4,10 +4,11 @@ pub(super) mod device; pub(super) mod filters; pub(super) mod keys; pub(super) mod profile; +pub(super) mod remote; use std::{mem, sync::Arc}; -pub use account::AccessTokenStatus; +pub use account::{AccessTokenStatus, AccountStatus}; use conduwuit::{ Err, Error, Result, err, utils::{self}, @@ -75,11 +76,13 @@ struct Data { logintoken_expiresatuserid: Arc, todeviceid_events: Arc, token_userdeviceid: Arc, + remoteuserid_remoteuser: Arc, userdeviceid_tokenexpires: Arc, userdeviceid_metadata: Arc, userdeviceid_token: Arc, userfilterid_filter: Arc, userid_avatarurl: Arc, + userid_deactivated: Arc, userid_dehydrateddevice: Arc, userid_devicelistversion: Arc, userid_displayname: Arc, @@ -121,10 +124,12 @@ impl crate::Service for Service { logintoken_expiresatuserid: args.db["logintoken_expiresatuserid"].clone(), todeviceid_events: args.db["todeviceid_events"].clone(), token_userdeviceid: args.db["token_userdeviceid"].clone(), + remoteuserid_remoteuser: args.db["remoteuserid_remoteuser"].clone(), userdeviceid_metadata: args.db["userdeviceid_metadata"].clone(), userdeviceid_token: args.db["userdeviceid_token"].clone(), userfilterid_filter: args.db["userfilterid_filter"].clone(), userid_avatarurl: args.db["userid_avatarurl"].clone(), + userid_deactivated: args.db["userid_deactivated"].clone(), userid_dehydrateddevice: args.db["userid_dehydrateddevice"].clone(), userid_devicelistversion: args.db["userid_devicelistversion"].clone(), userid_displayname: args.db["userid_displayname"].clone(), diff --git a/src/service/users/remote.rs b/src/service/users/remote.rs new file mode 100644 index 000000000..21be89e6e --- /dev/null +++ b/src/service/users/remote.rs @@ -0,0 +1,17 @@ +use conduwuit::utils::stream::TryIgnore; +use futures::Stream; +use ruma::{OwnedUserId, UserId}; + +impl super::Service { + /// Record the existence of a remote user. + pub fn record_remote_user(&self, user_id: &UserId) { + assert!(self.services.globals.user_is_local(user_id), "user is not remote"); + + self.db.remoteuserid_remoteuser.insert(user_id, ""); + } + + /// Returns a stream over all remote users this server has ever seen. + pub fn stream_remote_users(&self) -> impl Stream + Send { + self.db.remoteuserid_remoteuser.keys().ignore_err() + } +} diff --git a/src/web/pages/account/password/change.rs b/src/web/pages/account/password/change.rs index f484b72c5..a8d4adc80 100644 --- a/src/web/pages/account/password/change.rs +++ b/src/web/pages/account/password/change.rs @@ -69,7 +69,7 @@ async fn route_change_password( let user_card = UserCard::for_local_user(&services, user_id.clone()).await; let body = if let Some(form) = form { - match change_password(&services, &user_id, form).await { + match change_password(&services, &user_id, form).await? { | Ok(()) => ChangePasswordBody::Success, | Err(errors) => ChangePasswordBody::Form(ChangePasswordForm::with_errors(context.clone(), errors)), @@ -85,7 +85,7 @@ async fn change_password( services: &crate::State, user_id: &UserId, form: ChangePasswordForm, -) -> Result<(), ValidationErrors> { +) -> Result> { form.validate()?; if services @@ -100,12 +100,12 @@ async fn change_password( ValidationError::new("wrong").with_message("Incorrect password".into()), ); - return Err(errors); + return Ok(Err(errors)); } match HashedPassword::new(&form.new_password) { | Ok(hash) => { - services.users.set_password(user_id, Some(hash)); + services.users.set_password(user_id, hash).await?; }, | Err(err) => { let mut errors = ValidationErrors::new(); @@ -114,9 +114,9 @@ async fn change_password( ValidationError::new("malformed").with_message(err.message().into()), ); - return Err(errors); + return Ok(Err(errors)); }, } - Ok(()) + Ok(Ok(())) } diff --git a/src/web/pages/account/password/reset.rs b/src/web/pages/account/password/reset.rs index 7428fb4ab..b263060b7 100644 --- a/src/web/pages/account/password/reset.rs +++ b/src/web/pages/account/password/reset.rs @@ -214,7 +214,7 @@ async fn route_reset_password_validate( | Ok(hash) => { let _ = session.consume(); - services.users.set_password(&user_id, Some(hash)); + services.users.set_password(&user_id, hash).await?; ResetPasswordValidateBody::ResetSuccess { user_card } }, diff --git a/src/web/pages/account/register.rs b/src/web/pages/account/register.rs index 529901ef4..9dffbf4ed 100644 --- a/src/web/pages/account/register.rs +++ b/src/web/pages/account/register.rs @@ -310,7 +310,7 @@ async fn get_register_email_validate( response!( complete_registration(&services, session_store, completed_registration, Some(email)) - .await + .await? ) } @@ -502,7 +502,7 @@ async fn begin_registration( } else { // If email isn't required we can immediately complete registration Ok(response!( - complete_registration(services, session_store, completed_registration, None).await + complete_registration(services, session_store, completed_registration, None).await? )) } } @@ -517,11 +517,11 @@ async fn complete_registration( next, }: CompletedRegistration, email: Option
, -) -> Redirect { +) -> Result { services .users .create_local_account(&user_id, password_hash, email) - .await; + .await?; if let Some(registration_token) = registration_token { services @@ -536,7 +536,7 @@ async fn complete_registration( .await .expect("should be able to serialize user session"); - Redirect::to(&next.unwrap_or_default().target_path()) + Ok(Redirect::to(&next.unwrap_or_default().target_path())) } pub(super) async fn registration_flow_status( diff --git a/src/web/session/mod.rs b/src/web/session/mod.rs index ccc1e6b27..6bb1ba4df 100644 --- a/src/web/session/mod.rs +++ b/src/web/session/mod.rs @@ -162,10 +162,8 @@ pub(crate) async fn require_active( user_id: &UserId, allow_locked: bool, ) -> Result<(), Response> { - if !services.users.is_active(user_id).await { - return Err( - WebError::Forbidden("Your account is deactivated.".to_owned()).into_response() - ); + if let Err(err) = services.users.status(user_id).await.ensure_active() { + return Err(WebError::Forbidden(err.message()).into_response()); } if !allow_locked From c0cb4afd8afd973b4cd54c45584eac1d35c8ff64 Mon Sep 17 00:00:00 2001 From: Ginger Date: Mon, 29 Jun 2026 21:58:45 -0400 Subject: [PATCH 02/26] feat: Initial implementation of OIDC --- Cargo.lock | 654 ++++++++++++++++-- Cargo.toml | 3 + conduwuit-example.toml | 38 + src/admin/admin.rs | 16 +- src/admin/mod.rs | 1 + src/admin/oidc/commands.rs | 17 + src/admin/oidc/mod.rs | 21 + src/admin/token/commands.rs | 2 +- src/admin/user/commands.rs | 2 +- src/api/client/account/register.rs | 4 +- src/api/client/oauth/mod.rs | 2 +- src/api/client/oauth/server_metadata.rs | 2 +- src/api/client/session.rs | 4 +- src/core/Cargo.toml | 1 + src/core/config/mod.rs | 60 +- src/database/maps.rs | 4 + src/service/Cargo.toml | 1 + src/service/mod.rs | 1 + src/service/oauth/grant.rs | 2 +- src/service/oidc/mod.rs | 283 ++++++++ src/service/services.rs | 6 +- src/service/uiaa/mod.rs | 2 +- src/service/users/account.rs | 12 +- src/web/Cargo.toml | 1 + src/web/mod.rs | 1 + src/web/pages/account/login.rs | 19 + src/web/pages/account/register.rs | 2 +- src/web/pages/mod.rs | 1 + src/web/pages/oidc/complete.rs | 126 ++++ src/web/pages/oidc/mod.rs | 35 + src/web/pages/resources/login.css | 1 + src/web/pages/templates/oidc_complete.html.j2 | 39 ++ 32 files changed, 1300 insertions(+), 63 deletions(-) create mode 100644 src/admin/oidc/commands.rs create mode 100644 src/admin/oidc/mod.rs create mode 100644 src/service/oidc/mod.rs create mode 100644 src/web/pages/oidc/complete.rs create mode 100644 src/web/pages/oidc/mod.rs create mode 100644 src/web/pages/templates/oidc_complete.html.j2 diff --git a/Cargo.lock b/Cargo.lock index 3b9ac1231..f6e1f5bcf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -421,6 +421,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + [[package]] name = "base64" version = "0.21.7" @@ -529,6 +535,15 @@ dependencies = [ "alloc-stdlib", ] +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + [[package]] name = "built" version = "0.8.1" @@ -611,7 +626,7 @@ dependencies = [ "semver", "serde", "serde_json", - "thiserror", + "thiserror 2.0.18", ] [[package]] @@ -684,7 +699,12 @@ version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ + "iana-time-zone", + "js-sys", "num-traits", + "serde", + "wasm-bindgen", + "windows-link", ] [[package]] @@ -886,7 +906,7 @@ dependencies = [ "ctor", "dtor", "futures", - "hmac", + "hmac 0.13.0", "http", "http-body-util", "hyper", @@ -953,6 +973,7 @@ dependencies = [ "maplit", "nix", "num-traits", + "openidconnect", "parking_lot", "rand 0.10.2", "rand_core 0.6.4", @@ -967,7 +988,7 @@ dependencies = [ "sha2 0.11.0", "smallstr", "smallvec", - "thiserror", + "thiserror 2.0.18", "tikv-jemalloc-ctl", "tikv-jemalloc-sys", "tikv-jemallocator", @@ -1078,6 +1099,7 @@ dependencies = [ "loole", "lru-cache", "nonzero_ext", + "openidconnect", "rand 0.10.2", "recaptcha-verify", "regex", @@ -1119,6 +1141,7 @@ dependencies = [ "futures", "lettre", "memory-serve", + "openidconnect", "rand 0.10.2", "recaptcha-verify", "reqwest 0.12.28", @@ -1126,7 +1149,7 @@ dependencies = [ "serde", "serde_json", "serde_urlencoded", - "thiserror", + "thiserror 2.0.18", "tower-http 0.7.0", "tower-sec-fetch", "tower-sessions", @@ -1405,6 +1428,18 @@ dependencies = [ "winapi", ] +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -1482,8 +1517,18 @@ version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" dependencies = [ - "darling_core", - "darling_macro", + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core 0.23.0", + "darling_macro 0.23.0", ] [[package]] @@ -1500,13 +1545,37 @@ dependencies = [ "syn", ] +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + [[package]] name = "darling_macro" version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ - "darling_core", + "darling_core 0.20.11", + "quote", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core 0.23.0", "quote", "syn", ] @@ -1540,6 +1609,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ "const-oid", + "pem-rfc7468", "zeroize", ] @@ -1581,6 +1651,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer 0.10.4", + "const-oid", "crypto-common 0.1.7", "subtle", ] @@ -1641,6 +1712,26 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest 0.10.7", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + [[package]] name = "ed25519" version = "2.2.3" @@ -1674,6 +1765,27 @@ dependencies = [ "serde", ] +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest 0.10.7", + "ff", + "generic-array", + "group", + "hkdf", + "pem-rfc7468", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + [[package]] name = "email-encoding" version = "0.4.1" @@ -1758,6 +1870,16 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "fiat-crypto" version = "0.2.9" @@ -1967,6 +2089,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", + "zeroize", ] [[package]] @@ -2061,6 +2184,17 @@ dependencies = [ "smallvec", ] +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "h2" version = "0.4.15" @@ -2073,7 +2207,7 @@ dependencies = [ "futures-core", "futures-sink", "http", - "indexmap", + "indexmap 2.14.0", "slab", "tokio", "tokio-util", @@ -2114,6 +2248,12 @@ version = "0.1.2+12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "647deb1583b14d160f85f3ff626f20b6edd366e3852c9843b06077388f794cb6" +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + [[package]] name = "hashbrown" version = "0.15.5" @@ -2212,7 +2352,7 @@ dependencies = [ "ipnet", "jni", "rand 0.10.2", - "thiserror", + "thiserror 2.0.18", "tinyvec", "tokio", "tracing", @@ -2234,7 +2374,7 @@ dependencies = [ "rand 0.10.2", "ring", "serde", - "thiserror", + "thiserror 2.0.18", "tinyvec", "tracing", "url", @@ -2262,11 +2402,29 @@ dependencies = [ "serde", "smallvec", "system-configuration", - "thiserror", + "thiserror 2.0.18", "tokio", "tracing", ] +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac 0.12.1", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", +] + [[package]] name = "hmac" version = "0.13.0" @@ -2406,6 +2564,7 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", + "webpki-roots", ] [[package]] @@ -2443,6 +2602,30 @@ dependencies = [ "tracing", ] +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "icu_collections" version = "2.2.0" @@ -2586,6 +2769,17 @@ dependencies = [ "quick-error", ] +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + [[package]] name = "indexmap" version = "2.14.0" @@ -2640,6 +2834,15 @@ dependencies = [ "serde", ] +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.13.0" @@ -2685,7 +2888,7 @@ dependencies = [ "jni-sys", "log", "simd_cesu8", - "thiserror", + "thiserror 2.0.18", "walkdir", "windows-link", ] @@ -2799,6 +3002,9 @@ name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] [[package]] name = "leb128fmt" @@ -2862,6 +3068,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + [[package]] name = "libz-sys" version = "1.1.29" @@ -3218,6 +3430,22 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.6", + "smallvec", + "zeroize", +] + [[package]] name = "num-complex" version = "0.4.6" @@ -3271,6 +3499,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", ] [[package]] @@ -3283,6 +3512,26 @@ dependencies = [ "libc", ] +[[package]] +name = "oauth2" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d" +dependencies = [ + "base64 0.22.1", + "chrono", + "getrandom 0.2.17", + "http", + "rand 0.8.6", + "reqwest 0.12.28", + "serde", + "serde_json", + "serde_path_to_error", + "sha2 0.10.9", + "thiserror 1.0.69", + "url", +] + [[package]] name = "objc2" version = "0.6.4" @@ -3461,6 +3710,37 @@ dependencies = [ "portable-atomic", ] +[[package]] +name = "openidconnect" +version = "4.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c6709ba2ea764bbed26bce1adf3c10517113ddea6f2d4196e4851757ef2b2" +dependencies = [ + "base64 0.21.7", + "chrono", + "dyn-clone", + "ed25519-dalek", + "hmac 0.12.1", + "http", + "itertools 0.10.5", + "log", + "oauth2", + "p256", + "p384", + "rand 0.8.6", + "rsa", + "serde", + "serde-value", + "serde_json", + "serde_path_to_error", + "serde_plain", + "serde_with", + "sha2 0.10.9", + "subtle", + "thiserror 1.0.69", + "url", +] + [[package]] name = "openssl-probe" version = "0.2.1" @@ -3477,7 +3757,7 @@ dependencies = [ "futures-sink", "js-sys", "pin-project-lite", - "thiserror", + "thiserror 2.0.18", "tracing", ] @@ -3507,7 +3787,7 @@ dependencies = [ "opentelemetry_sdk", "prost", "reqwest 0.13.4", - "thiserror", + "thiserror 2.0.18", "tokio", "tonic", "tonic-types", @@ -3539,11 +3819,20 @@ dependencies = [ "percent-encoding", "portable-atomic", "rand 0.9.4", - "thiserror", + "thiserror 2.0.18", "tokio", "tokio-stream", ] +[[package]] +name = "ordered-float" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c" +dependencies = [ + "num-traits", +] + [[package]] name = "os_info" version = "3.15.0" @@ -3560,6 +3849,30 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2 0.10.9", +] + +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2 0.10.9", +] + [[package]] name = "parking" version = "2.2.1" @@ -3631,6 +3944,15 @@ dependencies = [ "syn", ] +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -3644,7 +3966,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db" dependencies = [ "fixedbitset", - "indexmap", + "indexmap 2.14.0", ] [[package]] @@ -3711,6 +4033,17 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + [[package]] name = "pkcs8" version = "0.10.2" @@ -3797,6 +4130,15 @@ dependencies = [ "syn", ] +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + [[package]] name = "proc-macro-crate" version = "3.5.0" @@ -3927,7 +4269,7 @@ dependencies = [ "rustc-hash", "rustls", "socket2", - "thiserror", + "thiserror 2.0.18", "tokio", "tracing", "web-time", @@ -3949,7 +4291,7 @@ dependencies = [ "rustls", "rustls-pki-types", "slab", - "thiserror", + "thiserror 2.0.18", "tinyvec", "tracing", "web-time", @@ -4002,6 +4344,8 @@ version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" dependencies = [ + "libc", + "rand_chacha 0.3.1", "rand_core 0.6.4", ] @@ -4011,7 +4355,7 @@ version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" dependencies = [ - "rand_chacha", + "rand_chacha 0.9.0", "rand_core 0.9.5", ] @@ -4026,6 +4370,16 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + [[package]] name = "rand_chacha" version = "0.9.0" @@ -4080,6 +4434,26 @@ dependencies = [ "bitflags", ] +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "regex" version = "1.12.4" @@ -4128,6 +4502,7 @@ dependencies = [ "log", "percent-encoding", "pin-project-lite", + "quinn", "rustls", "rustls-native-certs", "rustls-pki-types", @@ -4144,6 +4519,7 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", + "webpki-roots", ] [[package]] @@ -4198,6 +4574,16 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac 0.12.1", + "subtle", +] + [[package]] name = "ring" version = "0.17.14" @@ -4212,6 +4598,26 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest 0.10.7", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "signature", + "spki", + "subtle", + "zeroize", +] + [[package]] name = "ruma" version = "0.15.1" @@ -4260,7 +4666,7 @@ dependencies = [ "serde", "serde_html_form", "serde_json", - "thiserror", + "thiserror 2.0.18", "url", "web-time", ] @@ -4277,7 +4683,7 @@ dependencies = [ "form_urlencoded", "getrandom 0.4.2", "http", - "indexmap", + "indexmap 2.14.0", "js_int", "konst", "percent-encoding", @@ -4288,7 +4694,7 @@ dependencies = [ "serde", "serde_html_form", "serde_json", - "thiserror", + "thiserror 2.0.18", "time", "tracing", "url", @@ -4304,7 +4710,7 @@ version = "0.33.0" source = "git+https://github.com/ruma/ruma.git?rev=3ecd80b92794d2d93f657a7b3db62d4be237526b#3ecd80b92794d2d93f657a7b3db62d4be237526b" dependencies = [ "as_variant", - "indexmap", + "indexmap 2.14.0", "js_int", "js_option", "pulldown-cmark", @@ -4312,7 +4718,7 @@ dependencies = [ "ruma-macros", "serde", "serde_json", - "thiserror", + "thiserror 2.0.18", "tracing", "web-time", "wildmatch", @@ -4338,7 +4744,7 @@ dependencies = [ "ruma-signatures", "serde", "serde_json", - "thiserror", + "thiserror 2.0.18", "tracing", ] @@ -4348,7 +4754,7 @@ version = "0.12.1" source = "git+https://github.com/ruma/ruma.git?rev=3ecd80b92794d2d93f657a7b3db62d4be237526b#3ecd80b92794d2d93f657a7b3db62d4be237526b" dependencies = [ "js_int", - "thiserror", + "thiserror 2.0.18", ] [[package]] @@ -4392,7 +4798,7 @@ dependencies = [ "ruma-common", "serde_json", "sha2 0.10.9", - "thiserror", + "thiserror 2.0.18", ] [[package]] @@ -4406,7 +4812,7 @@ dependencies = [ "ruma-signatures", "serde", "serde_json", - "thiserror", + "thiserror 2.0.18", "tracing", ] @@ -4575,7 +4981,7 @@ dependencies = [ "futures-util", "pin-project", "thingbuf", - "thiserror", + "thiserror 2.0.18", "unicode-segmentation", ] @@ -4612,6 +5018,30 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -4627,6 +5057,20 @@ dependencies = [ "libc", ] +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + [[package]] name = "security-framework" version = "3.7.0" @@ -4787,7 +5231,7 @@ dependencies = [ "rand 0.9.4", "serde", "serde_json", - "thiserror", + "thiserror 2.0.18", "time", "url", "uuid", @@ -4822,6 +5266,16 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde-value" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c" +dependencies = [ + "ordered-float", + "serde", +] + [[package]] name = "serde_core" version = "1.0.228" @@ -4849,7 +5303,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f0346d7a342ab90f405cfc08f25d15075f944f42fcabbc5eac923829fa6d228" dependencies = [ "form_urlencoded", - "indexmap", + "indexmap 2.14.0", "itoa", "serde_core", "zmij", @@ -4879,6 +5333,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_plain" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce1fc6db65a611022b23a0dec6975d63fb80a302cb3388835ff02c097258d50" +dependencies = [ + "serde", +] + [[package]] name = "serde_regex" version = "1.2.0" @@ -4919,6 +5382,38 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_with" +version = "3.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c" +dependencies = [ + "base64 0.22.1", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.0", + "schemars 0.9.0", + "schemars 1.2.1", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" +dependencies = [ + "darling 0.23.0", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "sha1" version = "0.10.6" @@ -5034,6 +5529,7 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ + "digest 0.10.7", "rand_core 0.6.4", ] @@ -5100,6 +5596,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" + [[package]] name = "spinning_top" version = "0.3.0" @@ -5249,7 +5751,7 @@ dependencies = [ "lazy-regex", "minimad", "serde", - "thiserror", + "thiserror 2.0.18", "unicode-width 0.1.14", ] @@ -5263,13 +5765,33 @@ dependencies = [ "pin-project", ] +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + [[package]] name = "thiserror" version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", ] [[package]] @@ -5467,7 +5989,7 @@ version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" dependencies = [ - "indexmap", + "indexmap 2.14.0", "serde_core", "serde_spanned 1.1.1", "toml_datetime 1.1.1+spec-1.1.0", @@ -5500,7 +6022,7 @@ version = "0.22.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" dependencies = [ - "indexmap", + "indexmap 2.14.0", "serde", "serde_spanned 0.6.9", "toml_datetime 0.6.11", @@ -5514,7 +6036,7 @@ version = "0.25.12+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" dependencies = [ - "indexmap", + "indexmap 2.14.0", "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", "winnow 1.0.3", @@ -5600,7 +6122,7 @@ checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" dependencies = [ "futures-core", "futures-util", - "indexmap", + "indexmap 2.14.0", "pin-project-lite", "slab", "sync_wrapper", @@ -5730,7 +6252,7 @@ dependencies = [ "rand 0.9.4", "serde", "serde_json", - "thiserror", + "thiserror 2.0.18", "time", "tokio", "tracing", @@ -5979,7 +6501,7 @@ version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7df16e474ef958526d1205f6dda359fdfab79d9aa6d54bafcb92dcd07673dca" dependencies = [ - "darling", + "darling 0.20.11", "once_cell", "proc-macro-error2", "proc-macro2", @@ -6120,7 +6642,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" dependencies = [ "anyhow", - "indexmap", + "indexmap 2.14.0", "wasm-encoder", "wasmparser", ] @@ -6146,7 +6668,7 @@ checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ "bitflags", "hashbrown 0.15.5", - "indexmap", + "indexmap 2.14.0", "semver", ] @@ -6191,6 +6713,15 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "webpki-roots" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "weezl" version = "0.1.12" @@ -6240,6 +6771,41 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "windows-link" version = "0.2.1" @@ -6483,7 +7049,7 @@ checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" dependencies = [ "anyhow", "heck", - "indexmap", + "indexmap 2.14.0", "prettyplease", "syn", "wasm-metadata", @@ -6514,7 +7080,7 @@ checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", "bitflags", - "indexmap", + "indexmap 2.14.0", "log", "serde", "serde_derive", @@ -6533,7 +7099,7 @@ checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" dependencies = [ "anyhow", "id-arena", - "indexmap", + "indexmap 2.14.0", "log", "semver", "serde", diff --git a/Cargo.toml b/Cargo.toml index 09e2f93cb..fe948046c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -403,6 +403,9 @@ default-features = false version = "0.11.0" default-features = false +[workspace.dependencies.openidconnect] +version = "4.0.1" + # optional opentelemetry, performance measurements, flamegraphs, etc for performance measurements and monitoring [workspace.dependencies.opentelemetry] version = "0.32.0" diff --git a/conduwuit-example.toml b/conduwuit-example.toml index 28d1022cf..8db23efa9 100644 --- a/conduwuit-example.toml +++ b/conduwuit-example.toml @@ -2028,3 +2028,41 @@ # legacy authentication will be unable to log in. # #compatibility_mode = "hybrid" + +# This item is undocumented. Please contribute documentation for it. +# +#oidc = +# Uncommenting this section will enable Continuwuity's support for +# authenticating users using an OpenID Connect-compatible identity provider. +# This is referred to as "delegated authentication". +# +# IMPORTANT NOTE: When delegated authentication is active, Continuwuity will behave as if +# the `global.oauth.compatibility_mode` setting is set to `exclusive`. +# Matrix clients which do not support OAuth login (also referred to as "next-gen auth") will NOT be able +# to log in while delegated authentication is active. +#[global.oauth.oidc] + +# The OIDC issuer URL. Continuwuity will use OpenID Connect Discovery to +# automatically fetch the identity provider's metadata from this URL. +# Generally you should set this to the base domain your identity provider +# runs on. +# +#discovery_url = + +# The OAuth client ID for Continuwuity to use when communicating with the +# identity provider. +# +#client_id = + +# The OAuth client secret for Continuwuity to use when communicating with +# the identity provider. +# +#client_secret = + +# Whether the user should be prompted to choose a localpart +# when signing in for the first time. If this is `false`, Continuwuity +# will attempt to use the value of the `preferred_username` claim +# returned from the IDP as the user's localpart, and authentication will +# fail if this claim is missing or is not a valid localpart. +# +#prompt_for_localpart = false diff --git a/src/admin/admin.rs b/src/admin/admin.rs index 2bebc7523..793bf1f85 100644 --- a/src/admin/admin.rs +++ b/src/admin/admin.rs @@ -1,5 +1,5 @@ use clap::Parser; -use conduwuit::Result; +use conduwuit::{Err, Result}; use crate::{ appservice::{self, AppserviceCommand}, @@ -8,6 +8,7 @@ use crate::{ debug::{self, DebugCommand}, federation::{self, FederationCommand}, media::{self, MediaCommand}, + oidc::{self, OidcCommand}, query::{self, QueryCommand}, room::{self, RoomCommand}, server::{self, ServerCommand}, @@ -30,6 +31,9 @@ pub enum AdminCommand { /// Commands for managing registration tokens Token(TokenCommand), + #[command(subcommand)] + Oidc(OidcCommand), + #[command(subcommand)] /// Commands for managing rooms Rooms(RoomCommand), @@ -80,6 +84,16 @@ pub(super) async fn process(command: AdminCommand, context: &Context<'_>) -> Res context.bail_restricted()?; token::process(command, context).await }, + | Oidc(command) => { + // OIDC commands are all restricted + context.bail_restricted()?; + + if !context.services.oidc.enabled() { + return Err!("OIDC is not configured"); + } + + oidc::process(command, context).await + }, | Rooms(command) => room::process(command, context).await, | Federation(command) => federation::process(command, context).await, | Server(command) => server::process(command, context).await, diff --git a/src/admin/mod.rs b/src/admin/mod.rs index 1bc33691c..fd73c3f15 100644 --- a/src/admin/mod.rs +++ b/src/admin/mod.rs @@ -16,6 +16,7 @@ pub(crate) mod check; pub(crate) mod debug; pub(crate) mod federation; pub(crate) mod media; +pub(crate) mod oidc; pub(crate) mod query; pub(crate) mod room; pub(crate) mod server; diff --git a/src/admin/oidc/commands.rs b/src/admin/oidc/commands.rs new file mode 100644 index 000000000..c5f9d049d --- /dev/null +++ b/src/admin/oidc/commands.rs @@ -0,0 +1,17 @@ +use conduwuit::Result; + +use crate::utils::parse_active_local_user_id; + +impl crate::Context<'_> { + pub(super) async fn oidc_link(&self, user_id: String, subject: String) -> Result { + let user_id = parse_active_local_user_id(self.services, &user_id).await?; + + self.services.oidc.link_user(&user_id, &subject).await; + + self.write_str("Account linked successfully").await?; + + Ok(()) + } + + pub(super) async fn oidc_unlink(&self, _user_id: String) -> Result { todo!() } +} diff --git a/src/admin/oidc/mod.rs b/src/admin/oidc/mod.rs new file mode 100644 index 000000000..94baab797 --- /dev/null +++ b/src/admin/oidc/mod.rs @@ -0,0 +1,21 @@ +mod commands; + +use clap::Subcommand; +use conduwuit::Result; +use conduwuit_macros::admin_command_dispatch; + +#[admin_command_dispatch] +#[derive(Debug, Subcommand)] +pub enum OidcCommand { + /// Link a user ID to the given subject claim. + #[clap(name = "link")] + OidcLink { + user_id: String, + subject: String, + }, + + #[clap(name = "unlink")] + OidcUnlink { + user_id: String, + }, +} diff --git a/src/admin/token/commands.rs b/src/admin/token/commands.rs index 0e5ae6132..21882bc32 100644 --- a/src/admin/token/commands.rs +++ b/src/admin/token/commands.rs @@ -44,7 +44,7 @@ impl crate::Context<'_> { .services .config .oauth - .compatibility_mode + .compatibility_mode() .oauth_available() { self.write_str(&format!( diff --git a/src/admin/user/commands.rs b/src/admin/user/commands.rs index a9ffce7ff..a76e3fe92 100644 --- a/src/admin/user/commands.rs +++ b/src/admin/user/commands.rs @@ -59,7 +59,7 @@ impl crate::Context<'_> { self.services .users - .create_local_account(&user_id, HashedPassword::new(password)?, None) + .create_local_account(&user_id, Some(HashedPassword::new(password)?), None) .await?; self.write_str(&format!("Created user {user_id} with password `{password}`")) diff --git a/src/api/client/account/register.rs b/src/api/client/account/register.rs index d41cc6a96..f59b05a7a 100644 --- a/src/api/client/account/register.rs +++ b/src/api/client/account/register.rs @@ -97,7 +97,7 @@ pub(crate) async fn register_route( services .users - .create_local_account(&user_id, password, identity.email) + .create_local_account(&user_id, Some(password), identity.email) .await?; user_id @@ -106,7 +106,7 @@ pub(crate) async fn register_route( let (token, device) = if !body.inhibit_login { // If UIAA is disabled, we can't create a device. In that case only appservices // can reach this point in the first place, so we return an error for them. - if !services.config.oauth.compatibility_mode.uiaa_available() { + if !services.config.oauth.compatibility_mode().uiaa_available() { return Err!(Request(AppserviceLoginUnsupported( "User-interactive appservice registration is not available on this server." ))); diff --git a/src/api/client/oauth/mod.rs b/src/api/client/oauth/mod.rs index 865332950..0c3e8fa4a 100644 --- a/src/api/client/oauth/mod.rs +++ b/src/api/client/oauth/mod.rs @@ -37,7 +37,7 @@ pub(crate) fn router(state: crate::State) -> Router { .layer(middleware::from_fn_with_state( state, async |State(state): State, request: Request, next: Next| -> Response { - if state.config.oauth.compatibility_mode.oauth_available() { + if state.config.oauth.compatibility_mode().oauth_available() { next.run(request).await } else { (StatusCode::NOT_FOUND, "OAuth is unavailable on this server").into_response() diff --git a/src/api/client/oauth/server_metadata.rs b/src/api/client/oauth/server_metadata.rs index 60d287960..eaf4d2a7a 100644 --- a/src/api/client/oauth/server_metadata.rs +++ b/src/api/client/oauth/server_metadata.rs @@ -21,7 +21,7 @@ pub(crate) async fn get_authorization_server_metadata_route( State(services): State, _body: Ruma, ) -> Result { - if !services.config.oauth.compatibility_mode.oauth_available() { + if !services.config.oauth.compatibility_mode().oauth_available() { return Err!(Request(Unrecognized("OAuth is unavailable on this server"))); } diff --git a/src/api/client/session.rs b/src/api/client/session.rs index c7af45fe5..fa04b4242 100644 --- a/src/api/client/session.rs +++ b/src/api/client/session.rs @@ -43,7 +43,7 @@ pub(crate) async fn get_login_types_route( ClientIp(client): ClientIp, _body: Ruma, ) -> Result { - if !services.config.oauth.compatibility_mode.uiaa_available() { + if !services.config.oauth.compatibility_mode().uiaa_available() { return Err!(Request(Unrecognized( "User-interactive authentication is not available on this server." ))); @@ -120,7 +120,7 @@ pub(crate) async fn login_route( ClientIp(client): ClientIp, body: Ruma, ) -> Result { - if !services.config.oauth.compatibility_mode.uiaa_available() { + if !services.config.oauth.compatibility_mode().uiaa_available() { return match body.login_info { | LoginInfo::ApplicationService(_) => { Err!(Request(AppserviceLoginUnsupported( diff --git a/src/core/Cargo.toml b/src/core/Cargo.toml index 39eded4cf..b67598aea 100644 --- a/src/core/Cargo.toml +++ b/src/core/Cargo.toml @@ -118,6 +118,7 @@ url.workspace = true parking_lot.workspace = true lock_api.workspace = true hyper-util.workspace = true +openidconnect.workspace = true [target.'cfg(unix)'.dependencies] nix.workspace = true diff --git a/src/core/config/mod.rs b/src/core/config/mod.rs index 4e81c32bd..6c458c98c 100644 --- a/src/core/config/mod.rs +++ b/src/core/config/mod.rs @@ -17,6 +17,7 @@ use either::{ use figment::providers::{Env, Format, Toml}; pub use figment::{Figment, value::Value as FigmentValue}; use lettre::message::Mailbox; +use openidconnect::{ClientId, ClientSecret}; use regex::RegexSet; use ruma::{ OwnedRoomId, OwnedRoomOrAliasId, OwnedServerName, OwnedUserId, RoomVersionId, @@ -2419,10 +2420,65 @@ pub struct OauthConfig { /// legacy authentication will be unable to log in. /// /// default: "hybrid" - pub compatibility_mode: OAuthMode, + compatibility_mode: OAuthMode, + + pub oidc: Option, } -#[derive(Clone, Debug, Default, Deserialize)] +impl OauthConfig { + #[must_use] + pub fn compatibility_mode(&self) -> OAuthMode { + if self.oidc.is_some() { + OAuthMode::Exclusive + } else { + self.compatibility_mode + } + } +} + +#[derive(Clone, Debug, Deserialize)] +#[config_example_generator( + filename = "conduwuit-example.toml", + section = "global.oauth.oidc", + optional = "true", + header = "\ +# Uncommenting this section will enable Continuwuity's support for +# authenticating users using an OpenID Connect-compatible identity provider. +# This is referred to as \"delegated authentication\". +# +# IMPORTANT NOTE: When delegated authentication is active, Continuwuity will behave as if +# the `global.oauth.compatibility_mode` setting is set to `exclusive`. +# Matrix clients which do not support OAuth login (also referred to as \"next-gen auth\") will \ + NOT be able +# to log in while delegated authentication is active." +)] +pub struct OidcConfig { + /// The OIDC issuer URL. Continuwuity will use OpenID Connect Discovery to + /// automatically fetch the identity provider's metadata from this URL. + /// Generally you should set this to the base domain your identity provider + /// runs on. + pub discovery_url: Url, + + /// The OAuth client ID for Continuwuity to use when communicating with the + /// identity provider. + pub client_id: ClientId, + + /// The OAuth client secret for Continuwuity to use when communicating with + /// the identity provider. + pub client_secret: ClientSecret, + + /// Whether the user should be prompted to choose a localpart + /// when signing in for the first time. If this is `false`, Continuwuity + /// will attempt to use the value of the `preferred_username` claim + /// returned from the IDP as the user's localpart, and authentication will + /// fail if this claim is missing or is not a valid localpart. + /// + /// default: false + #[serde(default)] + pub prompt_for_localpart: bool, +} + +#[derive(Clone, Copy, Debug, Default, Deserialize)] #[serde(rename_all = "snake_case")] pub enum OAuthMode { Disabled, diff --git a/src/database/maps.rs b/src/database/maps.rs index dbaa71a78..e47c54835 100644 --- a/src/database/maps.rs +++ b/src/database/maps.rs @@ -124,6 +124,10 @@ pub(super) static MAPS: &[Descriptor] = &[ name: "onetimekeyid_onetimekeys", ..descriptor::RANDOM_SMALL }, + Descriptor { + name: "openidsubject_localpart", + ..descriptor::RANDOM_SMALL + }, Descriptor { name: "fallbackkeyid_fallbackkey", ..descriptor::RANDOM_SMALL diff --git a/src/service/Cargo.toml b/src/service/Cargo.toml index e302ff6c9..db51692dc 100644 --- a/src/service/Cargo.toml +++ b/src/service/Cargo.toml @@ -120,6 +120,7 @@ reqwest_recaptcha = { package = "reqwest", version = "0.12.28", default-features yansi.workspace = true lettre.workspace = true serde_urlencoded.workspace = true +openidconnect.workspace = true [target.'cfg(all(unix, target_os = "linux"))'.dependencies] sd-notify.workspace = true diff --git a/src/service/mod.rs b/src/service/mod.rs index 8471891a2..5488ff178 100644 --- a/src/service/mod.rs +++ b/src/service/mod.rs @@ -28,6 +28,7 @@ pub mod mailer; pub mod media; pub mod moderation; pub mod oauth; +pub mod oidc; pub mod presence; pub mod pusher; pub mod registration_tokens; diff --git a/src/service/oauth/grant.rs b/src/service/oauth/grant.rs index d1dd8efc1..0a027f637 100644 --- a/src/service/oauth/grant.rs +++ b/src/service/oauth/grant.rs @@ -160,7 +160,7 @@ pub enum ErrorCode { InvalidClientMetadata, } -#[derive(Serialize)] +#[derive(Serialize, Deserialize)] pub struct AuthorizationCodeResponse { pub state: String, pub code: String, diff --git a/src/service/oidc/mod.rs b/src/service/oidc/mod.rs new file mode 100644 index 000000000..8b52c74a3 --- /dev/null +++ b/src/service/oidc/mod.rs @@ -0,0 +1,283 @@ +use std::{sync::Arc, time::Duration}; + +use async_trait::async_trait; +use conduwuit::{Result, config::OidcConfig, err, error, info}; +use database::{Deserialized, Map}; +use openidconnect::{ + AuthorizationCode, CsrfToken, EndpointMaybeSet, EndpointNotSet, EndpointSet, IssuerUrl, + Nonce, PkceCodeChallenge, PkceCodeVerifier, RedirectUrl, TokenResponse, + core::{CoreAuthenticationFlow, CoreClient, CoreIdTokenClaims, CoreProviderMetadata}, + reqwest, +}; +use ruma::{OwnedUserId, UserId}; +use serde::{Deserialize, Serialize}; +use tokio::sync::SetOnce; +use url::Url; + +use crate::{ + Dep, config, globals, + oauth::grant::AuthorizationCodeResponse, + users::{self, AccountStatus}, +}; + +pub struct Service { + services: Services, + db: Data, + client: Option, +} + +struct Data { + openidsubject_localpart: Arc, +} +struct Services { + config: Dep, + globals: Dep, + users: Dep, +} + +struct OidcClient { + config: OidcConfig, + machine: SetOnce< + CoreClient< + EndpointSet, + EndpointNotSet, + EndpointNotSet, + EndpointNotSet, + EndpointMaybeSet, + EndpointMaybeSet, + >, + >, + client: reqwest::Client, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct PendingSession { + pkce_verifier: PkceCodeVerifier, + nonce: Nonce, + csrf_token: CsrfToken, +} + +pub enum SessionCompletionStatus { + Complete(OwnedUserId), + NeedsLocalpart, + InvalidLocalpart(String), +} + +#[async_trait] +impl crate::Service for Service { + fn build(args: crate::Args<'_>) -> Result> { + Ok(Arc::new(Self { + services: Services { + config: args.depend::("config"), + globals: args.depend::("globals"), + users: args.depend::("users"), + }, + db: Data { + openidsubject_localpart: args.db["openidsubject_localpart"].clone(), + }, + client: args.server.config.oauth.oidc.as_ref().map(|config| OidcClient { + config: config.clone(), + machine: SetOnce::new(), + // This isn't in the client service because it has to use the `reqwest` shipped by `openidconnect` + client: reqwest::ClientBuilder::new() + .connect_timeout(Duration::from_secs(args.server.config.request_conn_timeout)) + .read_timeout(Duration::from_secs(args.server.config.request_timeout)) + .timeout(Duration::from_secs(args.server.config.request_total_timeout)) + .pool_idle_timeout(Duration::from_secs(args.server.config.request_idle_timeout)) + .pool_max_idle_per_host(args.server.config.request_idle_per_host.into()) + .user_agent(conduwuit::user_agent()) + .redirect(reqwest::redirect::Policy::none()) + .danger_accept_invalid_certs(args.server.config.allow_invalid_tls_certificates_yes_i_know_what_the_fuck_i_am_doing_with_this_and_i_know_this_is_insecure) + .build() + .expect("client should build") + }), + })) + } + + async fn worker(self: Arc) -> Result { + if let Some(OidcClient { config, machine, client }) = &self.client { + let redirect_url = self + .services + .config + .get_client_domain() + .join(&format!("{}/oidc/complete", conduwuit::ROUTE_PREFIX)) + .expect("redirect url should be valid"); + + let provider_metadata = CoreProviderMetadata::discover_async( + IssuerUrl::from_url(config.discovery_url.clone()), + client, + ) + .await + .map_err(|err| err!("Failed to discover OIDC provider metadata: {err}"))?; + + machine + .set( + CoreClient::from_provider_metadata( + provider_metadata, + config.client_id.clone(), + Some(config.client_secret.clone()), + ) + .set_redirect_uri(RedirectUrl::from_url(redirect_url)), + ) + .expect("machine should be empty"); + } + + Ok(()) + } + + fn name(&self) -> &str { crate::service::make_name(std::module_path!()) } +} + +impl Service { + const SERVER_MISCONFIGURED: &str = + "Identity server is misconfigured. Contact your homeserver's administrator."; + + pub fn enabled(&self) -> bool { self.client.is_some() } + + pub async fn begin_session(&self) -> (PendingSession, Url) { + let OidcClient { machine, .. } = self.client.as_ref().expect("oidc should be configured"); + let machine = machine.wait().await; + + let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256(); + + let (auth_url, csrf_token, nonce) = machine + .authorize_url( + CoreAuthenticationFlow::AuthorizationCode, + CsrfToken::new_random, + Nonce::new_random, + ) + .set_pkce_challenge(pkce_challenge) + .url(); + + (PendingSession { pkce_verifier, nonce, csrf_token }, auth_url) + } + + pub async fn exchange_code( + &self, + session: PendingSession, + response: AuthorizationCodeResponse, + ) -> Result { + let Some(OidcClient { machine, client, .. }) = self.client.as_ref() else { + return Err("Delegated authentication is not enabled on this server."); + }; + + let machine = machine.wait().await; + + if session.csrf_token.into_secret() != response.state { + return Err("State mismatch."); + } + + let token_response = machine + .exchange_code(AuthorizationCode::new(response.code)) + .expect("machine should be configured correctly") + .set_pkce_verifier(session.pkce_verifier) + .request_async(client) + .await + .map_err(|err| { + error!("Failed to exchange OIDC authorization code: {err}"); + "Code exchange failed." + })?; + + let Some(id_token) = token_response.id_token() else { + error!("Identity server did not return an id token"); + return Err(Self::SERVER_MISCONFIGURED); + }; + + let claims = id_token + .claims(&machine.id_token_verifier(), &session.nonce) + .map_err(|err| { + error!("Failed to verify id token claims: {err}"); + Self::SERVER_MISCONFIGURED + })? + .to_owned(); + + info!(subject = claims.subject().as_str(), "Authenticated subject"); + + Ok(claims) + } + + pub async fn complete_session( + &self, + claims: &CoreIdTokenClaims, + supplied_username: Option, + ) -> Result { + let Some(OidcClient { config, .. }) = self.client.as_ref() else { + return Err("Delegated authentication is not enabled on this server."); + }; + + let subject = claims.subject().as_str(); + + let user_id = if let Ok(localpart) = self + .db + .openidsubject_localpart + .get(subject) + .await + .deserialized::() + { + UserId::parse(format!("@{localpart}:{}", self.services.globals.server_name())) + .expect("saved localpart should be valid") + } else if config.prompt_for_localpart { + if let Some(supplied_username) = supplied_username { + match self + .services + .users + .determine_registration_user_id(Some(supplied_username), None, None) + .await + { + | Ok(user_id) => user_id, + | Err(err) => + return Ok(SessionCompletionStatus::InvalidLocalpart(err.message())), + } + } else { + return Ok(SessionCompletionStatus::NeedsLocalpart); + } + } else if let Some(preferred_username) = claims.preferred_username() { + self.services + .users + .determine_registration_user_id(Some(preferred_username.to_string()), None, None) + .await + .map_err(|err| { + error!("Preferred username claim is not a valid localpart: {err}"); + "Your preferred username is not a valid Matrix user ID localpart. Contact \ + your homeserver's administrator." + })? + } else { + error!("No preferred username claim was present"); + return Err(Self::SERVER_MISCONFIGURED); + }; + + info!(?subject, ?user_id, "User {user_id} successfully authorized with OIDC"); + + match self.services.users.status(&user_id).await { + | AccountStatus::Active => { + // Do nothing, an account already exists + }, + | AccountStatus::NotFound => { + // Create a new shadow user + self.services + .users + .create_local_account(&user_id, None, None) + .await + .map_err(|err| { + error!("Failed to create a shadow user for {user_id}: {err}"); + Self::SERVER_MISCONFIGURED + })?; + + self.link_user(&user_id, subject).await; + + info!(?subject, ?user_id, "Shadow user created for {user_id}"); + }, + | AccountStatus::Deactivated => { + return Err("Your account has been deactivated."); + }, + } + + Ok(SessionCompletionStatus::Complete(user_id)) + } + + pub async fn link_user(&self, user_id: &UserId, subject: &str) { + self.db + .openidsubject_localpart + .insert(subject, user_id.localpart()); + } +} diff --git a/src/service/services.rs b/src/service/services.rs index 0cfec43bd..92d4a899e 100644 --- a/src/service/services.rs +++ b/src/service/services.rs @@ -11,8 +11,8 @@ use crate::{ account_data, admin, announcements, antispam, appservice, client, config, emergency, federation, firstrun, globals, key_backups, mailer, manager::Manager, - media, moderation, oauth, presence, pusher, registration_tokens, resolver, rooms, sending, - server_keys, + media, moderation, oauth, oidc, presence, pusher, registration_tokens, resolver, rooms, + sending, server_keys, service::{self, Args, Map, Service}, sync, threepid, transactions, uiaa, users, }; @@ -28,6 +28,7 @@ pub struct Services { pub key_backups: Arc, pub media: Arc, pub oauth: Arc, + pub oidc: Arc, pub mailer: Arc, pub presence: Arc, pub pusher: Arc, @@ -85,6 +86,7 @@ impl Services { key_backups: build!(key_backups::Service), media: build!(media::Service), oauth: build!(oauth::Service), + oidc: build!(oidc::Service), mailer: build!(mailer::Service), presence: build!(presence::Service), pusher: build!(pusher::Service), diff --git a/src/service/uiaa/mod.rs b/src/service/uiaa/mod.rs index 2bb70c152..57665f5d6 100644 --- a/src/service/uiaa/mod.rs +++ b/src/service/uiaa/mod.rs @@ -314,7 +314,7 @@ impl Service { .services .config .oauth - .compatibility_mode + .compatibility_mode() .uiaa_available() { return Err!(Request(Unrecognized( diff --git a/src/service/users/account.rs b/src/service/users/account.rs index f4ed1f564..a3d7e2b16 100644 --- a/src/service/users/account.rs +++ b/src/service/users/account.rs @@ -38,8 +38,10 @@ pub enum AccountStatus { } impl AccountStatus { + #[must_use] pub fn is_found(&self) -> bool { !matches!(self, Self::NotFound) } + #[must_use] pub fn is_active(&self) -> bool { matches!(self, Self::Active) } pub fn ensure_active(&self) -> Result<()> { @@ -125,15 +127,19 @@ impl super::Service { Ok(()) } - /// Create a new account for a local human or bot user. + /// Create a new account for a local human or bot user. If `password` is + /// None, the account will be a shadow account. pub async fn create_local_account( &self, user_id: &UserId, - password: HashedPassword, + password: Option, email: Option
, ) -> Result<()> { self.create_shadow_account(user_id).await?; - self.convert_to_local_account(user_id, password).await?; + + if let Some(password) = password { + self.convert_to_local_account(user_id, password).await?; + } // Set an initial display name { diff --git a/src/web/Cargo.toml b/src/web/Cargo.toml index d70335ebf..516ff1712 100644 --- a/src/web/Cargo.toml +++ b/src/web/Cargo.toml @@ -49,6 +49,7 @@ url.workspace = true recaptcha-verify = { version = "0.2.0", default-features = false } reqwest_recaptcha = { package = "reqwest", version = "0.12.28", default-features = false, features = ["rustls-tls-native-roots-no-provider"] } # As long as recaptcha-verify's reqwest is outdated form_urlencoded = "1.2.2" +openidconnect.workspace = true [build-dependencies] memory-serve = "2.1.0" diff --git a/src/web/mod.rs b/src/web/mod.rs index 633b64191..847ca80fe 100644 --- a/src/web/mod.rs +++ b/src/web/mod.rs @@ -133,6 +133,7 @@ pub fn build(services: &Services) -> Router { .nest("/account/", account::build()) .merge(debug::build()) .nest("/oauth2/", oauth::build()) + .nest("/oidc/", oidc::build()) .merge(resources::build()) .merge(threepid::build()) .fallback(async || WebError::NotFound), diff --git a/src/web/pages/account/login.rs b/src/web/pages/account/login.rs index 52ef6cd69..ca66bae2d 100644 --- a/src/web/pages/account/login.rs +++ b/src/web/pages/account/login.rs @@ -21,6 +21,7 @@ use crate::{ GET_POST, Result, TemplateContext, account::register::{TrustedFlowStatus, UntrustedFlowStatus, registration_flow_status}, components::UserCard, + oidc::{OIDC_SESSION_ID_KEY, OidcSession, OidcSessionState}, }, response, session::{LoginQuery, LoginTarget, User, UserSession}, @@ -68,6 +69,24 @@ async fn route_login( ) -> Result { let user_id = user.into_session().map(|session| session.user_id); + if services.oidc.enabled() { + if user_id.is_some() && !reauthenticate { + return response!(Redirect::to(&next.unwrap_or_default().target_path())); + } + + let (session, redirect_url) = services.oidc.begin_session().await; + + session_store + .insert(OIDC_SESSION_ID_KEY, OidcSession { + next: next.unwrap_or_default(), + state: OidcSessionState::CodeExchange { expected_user: user_id, session }, + }) + .await + .expect("should be able to serialize OIDC session"); + + return response!(Redirect::to(redirect_url.as_str())); + } + let body = match &user_id { | None => { let (trusted_flow_status, untrusted_flow_status) = diff --git a/src/web/pages/account/register.rs b/src/web/pages/account/register.rs index 9dffbf4ed..0e23d3359 100644 --- a/src/web/pages/account/register.rs +++ b/src/web/pages/account/register.rs @@ -520,7 +520,7 @@ async fn complete_registration( ) -> Result { services .users - .create_local_account(&user_id, password_hash, email) + .create_local_account(&user_id, Some(password_hash), email) .await?; if let Some(registration_token) = registration_token { diff --git a/src/web/pages/mod.rs b/src/web/pages/mod.rs index e2e7ff8fe..c09e405d3 100644 --- a/src/web/pages/mod.rs +++ b/src/web/pages/mod.rs @@ -17,6 +17,7 @@ mod components; pub(super) mod debug; pub(super) mod index; pub(super) mod oauth; +pub(super) mod oidc; pub(super) mod resources; pub(super) mod threepid; diff --git a/src/web/pages/oidc/complete.rs b/src/web/pages/oidc/complete.rs new file mode 100644 index 000000000..fbe75b8ec --- /dev/null +++ b/src/web/pages/oidc/complete.rs @@ -0,0 +1,126 @@ +use std::time::SystemTime; + +use axum::{ + Extension, Router, + extract::{Query, State}, + response::Redirect, + routing::on, +}; +use conduwuit_service::{oauth::grant::AuthorizationCodeResponse, oidc::SessionCompletionStatus}; +use futures::FutureExt; +use ruma::OwnedServerName; +use serde::{Deserialize, de::IgnoredAny}; +use tower_sessions::Session; + +use crate::{ + WebError, + extract::{Expect, PostForm}, + pages::{ + GET_POST, Result, TemplateContext, + oidc::{OIDC_SESSION_ID_KEY, OidcSession, OidcSessionState}, + }, + response, + session::{User, UserSession}, + template, +}; + +pub(crate) fn build() -> Router { + Router::new().route("/", on(GET_POST, route_complete)) +} + +template! { + struct OidcComplete use "oidc_complete.html.j2" { + server_name: OwnedServerName, + username_error: Option + } +} + +#[derive(Deserialize)] +struct LoginForm { + username: String, +} + +async fn route_complete( + State(services): State, + Extension(context): Extension, + Expect(Query(query)): Expect>, + session_store: Session, + user: User, + PostForm(form): PostForm, +) -> Result { + let user_id = user.into_session().map(|session| session.user_id); + + let Some(session) = session_store + .get::(OIDC_SESSION_ID_KEY) + .await + .expect("should be able to deserialize oidc session") + else { + return response!(WebError::BadRequest( + "No OIDC session found. What are you doing here?".to_owned() + )); + }; + + let session_completion_status = match session.state { + | OidcSessionState::CodeExchange { expected_user, session: pending_session } => { + if let (Some(user_id), Some(expected_user)) = (&user_id, &expected_user) + && user_id != expected_user + { + return response!(WebError::BadRequest( + "Identity mismatch. You may have switched accounts at your identity \ + provider. Please log out and back in to continue." + .to_owned() + )); + } + + let claims = services + .oidc + .exchange_code(pending_session, query) + .boxed() + .await + .map_err(|err| WebError::BadRequest(err.to_owned()))?; + + session_store + .insert(OIDC_SESSION_ID_KEY, OidcSession { + next: session.next.clone(), + state: OidcSessionState::Authorized { claims: Box::new(claims.clone()) }, + }) + .await + .expect("Should be able to serialize oidc session"); + + services.oidc.complete_session(&claims, None).await + }, + | OidcSessionState::Authorized { claims } => + services + .oidc + .complete_session(&claims, form.map(|form| form.username)) + .await, + } + .map_err(|err| WebError::BadRequest(err.to_owned()))?; + + match session_completion_status { + | SessionCompletionStatus::Complete(user_id) => { + let _ = session_store + .remove::(OIDC_SESSION_ID_KEY) + .await; + + let user_session = UserSession { user_id, last_login: SystemTime::now() }; + + session_store + .insert(User::KEY, user_session) + .await + .expect("should be able to serialize user session"); + + response!(Redirect::to(&session.next.target_path())) + }, + | SessionCompletionStatus::NeedsLocalpart => { + response!(OidcComplete::new(context, services.globals.server_name().to_owned(), None)) + }, + | SessionCompletionStatus::InvalidLocalpart(error) => { + response!(OidcComplete::new( + context, + services.globals.server_name().to_owned(), + Some(error) + )) + }, + } +} diff --git a/src/web/pages/oidc/mod.rs b/src/web/pages/oidc/mod.rs new file mode 100644 index 000000000..a53a97930 --- /dev/null +++ b/src/web/pages/oidc/mod.rs @@ -0,0 +1,35 @@ +use axum::Router; +use conduwuit_service::oidc; +use openidconnect::core::CoreIdTokenClaims; +use ruma::OwnedUserId; +use serde::{Deserialize, Serialize}; + +use crate::session::LoginTarget; + +mod complete; + +pub(crate) const OIDC_SESSION_ID_KEY: &str = "oidc_session"; + +#[derive(Debug, Deserialize, Serialize)] +pub(crate) struct OidcSession { + pub next: LoginTarget, + pub state: OidcSessionState, +} + +#[derive(Debug, Deserialize, Serialize)] +pub(crate) enum OidcSessionState { + CodeExchange { + expected_user: Option, + session: oidc::PendingSession, + }, + Authorized { + claims: Box, + }, +} + +pub(crate) fn build() -> Router { + #[allow(clippy::wildcard_imports)] + use self::*; + + Router::new().nest("/complete", complete::build()) +} diff --git a/src/web/pages/resources/login.css b/src/web/pages/resources/login.css index ab5123885..d36810727 100644 --- a/src/web/pages/resources/login.css +++ b/src/web/pages/resources/login.css @@ -33,6 +33,7 @@ padding: 0.5em; margin-bottom: 0.5em; line-height: 1; + align-items: baseline; border-radius: var(--border-radius-sm); border: 2px solid var(--secondary); diff --git a/src/web/pages/templates/oidc_complete.html.j2 b/src/web/pages/templates/oidc_complete.html.j2 new file mode 100644 index 000000000..e5f322bd1 --- /dev/null +++ b/src/web/pages/templates/oidc_complete.html.j2 @@ -0,0 +1,39 @@ +{% extends "_layout.html.j2" %} +{% import "_components/form.html.j2" as form %} + +{%- block head -%} + +{%- endblock -%} + +{%- block title -%} +Link your account +{%- endblock -%} + +{%- block content -%} +
+

+ Link your account + + Matrix logo + +

+
+

To finish linking your account to Matrix, choose a username.

+

+ + + @ + + :{{ server_name }} + + {% if let Some(username_error) = username_error %} + + {{ username_error }} + + {% endif %} + Your username cannot be changed after you create your account. +

+ +
+
+{% endblock %} From 58a6e1232ca5ee7278c38092486719a74c60d4e7 Mon Sep 17 00:00:00 2001 From: Ginger Date: Tue, 30 Jun 2026 09:42:47 -0400 Subject: [PATCH 03/26] feat: Implement `!admin oidc unlink` --- src/admin/admin.rs | 21 +++++++++-------- src/admin/oidc/commands.rs | 14 +++++++++--- src/admin/oidc/mod.rs | 3 ++- src/service/oidc/mod.rs | 6 +++-- src/service/users/account.rs | 44 +++++++++++++++++------------------- 5 files changed, 49 insertions(+), 39 deletions(-) diff --git a/src/admin/admin.rs b/src/admin/admin.rs index 793bf1f85..bb9574a5c 100644 --- a/src/admin/admin.rs +++ b/src/admin/admin.rs @@ -19,47 +19,48 @@ use crate::{ #[derive(Debug, Parser)] #[command(name = conduwuit_core::BRANDING, version = conduwuit_core::version())] pub enum AdminCommand { - #[command(subcommand)] /// Commands for managing appservices + #[command(subcommand)] Appservices(AppserviceCommand), - #[command(subcommand)] /// Commands for managing local users + #[command(subcommand)] Users(UserCommand), - #[command(subcommand)] /// Commands for managing registration tokens + #[command(subcommand)] Token(TokenCommand), + /// Commands for managing OIDC #[command(subcommand)] Oidc(OidcCommand), - #[command(subcommand)] /// Commands for managing rooms + #[command(subcommand)] Rooms(RoomCommand), - #[command(subcommand)] /// Commands for managing federation + #[command(subcommand)] Federation(FederationCommand), - #[command(subcommand)] /// Commands for managing the server + #[command(subcommand)] Server(ServerCommand), - #[command(subcommand)] /// Commands for managing media + #[command(subcommand)] Media(MediaCommand), - #[command(subcommand)] /// Commands for checking integrity + #[command(subcommand)] Check(CheckCommand), - #[command(subcommand)] /// Commands for debugging things + #[command(subcommand)] Debug(DebugCommand), - #[command(subcommand)] /// Low-level queries for database getters and iterators + #[command(subcommand)] Query(QueryCommand), } diff --git a/src/admin/oidc/commands.rs b/src/admin/oidc/commands.rs index c5f9d049d..439d40e2c 100644 --- a/src/admin/oidc/commands.rs +++ b/src/admin/oidc/commands.rs @@ -6,12 +6,20 @@ impl crate::Context<'_> { pub(super) async fn oidc_link(&self, user_id: String, subject: String) -> Result { let user_id = parse_active_local_user_id(self.services, &user_id).await?; - self.services.oidc.link_user(&user_id, &subject).await; + self.services.oidc.link_user(&user_id, &subject); - self.write_str("Account linked successfully").await?; + self.write_str(&format!("Subject `{subject}` linked to account `{user_id}`.")) + .await?; Ok(()) } - pub(super) async fn oidc_unlink(&self, _user_id: String) -> Result { todo!() } + pub(super) async fn oidc_unlink(&self, subject: String) -> Result { + self.services.oidc.unlink_user(&subject); + + self.write_str(&format!("Subject `{subject}` unlinked.")) + .await?; + + Ok(()) + } } diff --git a/src/admin/oidc/mod.rs b/src/admin/oidc/mod.rs index 94baab797..591563919 100644 --- a/src/admin/oidc/mod.rs +++ b/src/admin/oidc/mod.rs @@ -14,8 +14,9 @@ pub enum OidcCommand { subject: String, }, + /// Unlink the given subject claim from its associated user ID. #[clap(name = "unlink")] OidcUnlink { - user_id: String, + subject: String, }, } diff --git a/src/service/oidc/mod.rs b/src/service/oidc/mod.rs index 8b52c74a3..82710d149 100644 --- a/src/service/oidc/mod.rs +++ b/src/service/oidc/mod.rs @@ -263,7 +263,7 @@ impl Service { Self::SERVER_MISCONFIGURED })?; - self.link_user(&user_id, subject).await; + self.link_user(&user_id, subject); info!(?subject, ?user_id, "Shadow user created for {user_id}"); }, @@ -275,9 +275,11 @@ impl Service { Ok(SessionCompletionStatus::Complete(user_id)) } - pub async fn link_user(&self, user_id: &UserId, subject: &str) { + pub fn link_user(&self, user_id: &UserId, subject: &str) { self.db .openidsubject_localpart .insert(subject, user_id.localpart()); } + + pub fn unlink_user(&self, subject: &str) { self.db.openidsubject_localpart.remove(subject); } } diff --git a/src/service/users/account.rs b/src/service/users/account.rs index a3d7e2b16..816ab974b 100644 --- a/src/service/users/account.rs +++ b/src/service/users/account.rs @@ -298,28 +298,7 @@ impl super::Service { &supplied_username, self.services.globals.server_name(), ) { - | Ok(user_id) => { - if let Err(e) = user_id.validate_strict() { - // Unless we are in emergency mode, we should follow synapse's behaviour - // on not allowing things like spaces and UTF-8 characters in - // usernames - if !emergency_mode_enabled { - return Err!(Request(InvalidUsername(debug_warn!( - "Username {supplied_username} contains disallowed characters or \ - spaces: {e}" - )))); - } - } - - // Don't allow registration with user IDs that aren't local - if !self.services.globals.user_is_local(&user_id) { - return Err!(Request(InvalidUsername( - "Username {supplied_username} is not local to this server" - ))); - } - - user_id - }, + | Ok(user_id) => user_id, | Err(e) => { return Err!(Request(InvalidUsername(debug_warn!( "Username {supplied_username} is not valid: {e}" @@ -327,8 +306,27 @@ impl super::Service { }, }; + if let Err(e) = user_id.validate_strict() { + // Unless we are in emergency mode, we should follow synapse's behaviour + // on not allowing things like spaces and UTF-8 characters in + // usernames + if !emergency_mode_enabled { + return Err!(Request(InvalidUsername(debug_warn!( + "Username {supplied_username} contains disallowed characters or spaces: \ + {e}" + )))); + } + } + + // Don't allow registration with user IDs that aren't local + if !self.services.globals.user_is_local(&user_id) { + return Err!(Request(InvalidUsername( + "Username {supplied_username} is not local to this server" + ))); + } + if self.status(&user_id).await.is_found() { - return Err!(Request(UserInUse("User ID is not available."))); + return Err!(Request(UserInUse("Username is not available."))); } // Check that the user ID is/is not in an appservice's namespace From 9a9cc94702cf174b6502a49fa68b545e23ada471 Mon Sep 17 00:00:00 2001 From: Ginger Date: Tue, 30 Jun 2026 09:42:56 -0400 Subject: [PATCH 04/26] chore: Update admin command docs --- docs/reference/admin/debug.md | 14 +++++++++++++- docs/reference/admin/index.md | 1 + docs/reference/admin/oidc.md | 13 +++++++++++++ docs/reference/admin/users.md | 12 ++++++++---- 4 files changed, 35 insertions(+), 5 deletions(-) create mode 100644 docs/reference/admin/oidc.md diff --git a/docs/reference/admin/debug.md b/docs/reference/admin/debug.md index 9ad88ef3e..ea0d83bc2 100644 --- a/docs/reference/admin/debug.md +++ b/docs/reference/admin/debug.md @@ -10,7 +10,13 @@ Echo input of admin command ## `!admin debug get-auth-chain` -Get the auth_chain of a PDU +Loads the auth_chain of a PDU, reporting how long it took + +## `!admin debug show-auth-chain` + +Walks & displays the auth_chain of a PDU in a mermaid graph format. + +This is useless to basically anyone but developers, and is also probably slow and memory hungry. ## `!admin debug parse-pdu` @@ -44,6 +50,12 @@ This is functionally equivalent to `GET /_matrix/client/v3/rooms/{roomid}/state` Of course the check is still done on the actual client API. +## `!admin debug get-state-at` + +Gets all the room state events at the specified event. + +State at event might not be available for some PDUs, such as rejected ones. + ## `!admin debug get-signing-keys` Get and display signing keys from local cache or remote server diff --git a/docs/reference/admin/index.md b/docs/reference/admin/index.md index 741aa78ba..3dbdf8105 100644 --- a/docs/reference/admin/index.md +++ b/docs/reference/admin/index.md @@ -14,6 +14,7 @@ Admin commands allow server administrators to manage the server from within thei - [`!admin appservices`](appservices/): Commands for managing appservices - [`!admin users`](users/): Commands for managing local users - [`!admin token`](token/): Commands for managing registration tokens +- [`!admin oidc`](oidc/): Commands for managing OIDC - [`!admin rooms`](rooms/): Commands for managing rooms - [`!admin federation`](federation/): Commands for managing federation - [`!admin server`](server/): Commands for managing the server diff --git a/docs/reference/admin/oidc.md b/docs/reference/admin/oidc.md new file mode 100644 index 000000000..4a0a6e1fc --- /dev/null +++ b/docs/reference/admin/oidc.md @@ -0,0 +1,13 @@ + +# `!admin oidc` + +Commands for managing OIDC + + +## `!admin oidc link` + +Link a user ID to the given subject claim + +## `!admin oidc unlink` + +Unlink the given subject claim from its associated user ID diff --git a/docs/reference/admin/users.md b/docs/reference/admin/users.md index 7058f5993..f2403251f 100644 --- a/docs/reference/admin/users.md +++ b/docs/reference/admin/users.md @@ -12,10 +12,6 @@ Create a new user Reset user password -## `!admin users issue-password-reset-link` - -Issue a self-service password reset link for a user - ## `!admin users get-email` Get a user's associated email address @@ -96,6 +92,14 @@ Disables login for the specified user without deactivating or locking their acco List local users in the database +## `!admin users list-invited-rooms` + +Lists all the rooms (local and remote) that the specified user is invited to + +## `!admin users reject-all-invites` + +Manually make a user reject all current invites + ## `!admin users list-joined-rooms` Lists all the rooms (local and remote) that the specified user is joined in From 4ee0bb7533e77a43056517c52b30e1b8604c2772 Mon Sep 17 00:00:00 2001 From: Ginger Date: Tue, 30 Jun 2026 10:47:48 -0400 Subject: [PATCH 05/26] feat: Allow existing legacy accounts to be linked interactively --- src/service/oidc/mod.rs | 20 +--- src/web/pages/oidc/complete.rs | 100 +++++++++++++++--- src/web/pages/templates/oidc_complete.html.j2 | 56 ++++++---- 3 files changed, 128 insertions(+), 48 deletions(-) diff --git a/src/service/oidc/mod.rs b/src/service/oidc/mod.rs index 82710d149..70ebb823e 100644 --- a/src/service/oidc/mod.rs +++ b/src/service/oidc/mod.rs @@ -58,9 +58,8 @@ pub struct PendingSession { } pub enum SessionCompletionStatus { + NeedsUserId, Complete(OwnedUserId), - NeedsLocalpart, - InvalidLocalpart(String), } #[async_trait] @@ -199,7 +198,7 @@ impl Service { pub async fn complete_session( &self, claims: &CoreIdTokenClaims, - supplied_username: Option, + supplied_user_id: Option, ) -> Result { let Some(OidcClient { config, .. }) = self.client.as_ref() else { return Err("Delegated authentication is not enabled on this server."); @@ -217,19 +216,10 @@ impl Service { UserId::parse(format!("@{localpart}:{}", self.services.globals.server_name())) .expect("saved localpart should be valid") } else if config.prompt_for_localpart { - if let Some(supplied_username) = supplied_username { - match self - .services - .users - .determine_registration_user_id(Some(supplied_username), None, None) - .await - { - | Ok(user_id) => user_id, - | Err(err) => - return Ok(SessionCompletionStatus::InvalidLocalpart(err.message())), - } + if let Some(supplied_user_id) = supplied_user_id { + supplied_user_id } else { - return Ok(SessionCompletionStatus::NeedsLocalpart); + return Ok(SessionCompletionStatus::NeedsUserId); } } else if let Some(preferred_username) = claims.preferred_username() { self.services diff --git a/src/web/pages/oidc/complete.rs b/src/web/pages/oidc/complete.rs index fbe75b8ec..62314e48f 100644 --- a/src/web/pages/oidc/complete.rs +++ b/src/web/pages/oidc/complete.rs @@ -8,7 +8,7 @@ use axum::{ }; use conduwuit_service::{oauth::grant::AuthorizationCodeResponse, oidc::SessionCompletionStatus}; use futures::FutureExt; -use ruma::OwnedServerName; +use ruma::{OwnedServerName, UserId}; use serde::{Deserialize, de::IgnoredAny}; use tower_sessions::Session; @@ -17,6 +17,7 @@ use crate::{ extract::{Expect, PostForm}, pages::{ GET_POST, Result, TemplateContext, + components::UserCard, oidc::{OIDC_SESSION_ID_KEY, OidcSession, OidcSessionState}, }, response, @@ -30,14 +31,27 @@ pub(crate) fn build() -> Router { template! { struct OidcComplete use "oidc_complete.html.j2" { - server_name: OwnedServerName, - username_error: Option + body: OidcCompleteBody } } +#[derive(Debug)] +enum OidcCompleteBody { + UsernamePrompt { + server_name: OwnedServerName, + username_error: Option, + }, + PasswordPrompt { + username: String, + user_card: UserCard, + password_error: bool, + }, +} + #[derive(Deserialize)] struct LoginForm { username: String, + password: Option, } async fn route_complete( @@ -89,11 +103,71 @@ async fn route_complete( services.oidc.complete_session(&claims, None).await }, - | OidcSessionState::Authorized { claims } => + | OidcSessionState::Authorized { claims } => { + let supplied_user_id = if let Some(form) = form { + if let Ok(user_id) = UserId::parse(format!( + "@{}:{}", + &form.username, + services.globals.server_name() + )) && services.users.status(&user_id).await.is_active() + { + let user_card = UserCard::for_local_user(&services, user_id.clone()).await; + + if let Some(password) = form.password { + if services + .users + .check_password(&user_id, &password) + .await + .is_ok() + { + Some(user_id) + } else { + return response!(OidcComplete::new( + context, + OidcCompleteBody::PasswordPrompt { + username: form.username, + user_card, + password_error: true + } + )); + } + } else { + return response!(OidcComplete::new( + context, + OidcCompleteBody::PasswordPrompt { + username: form.username, + user_card, + password_error: false, + } + )); + } + } else { + match services + .users + .determine_registration_user_id(Some(form.username), None, None) + .await + { + | Ok(user_id) => Some(user_id), + | Err(err) => { + return response!(OidcComplete::new( + context, + OidcCompleteBody::UsernamePrompt { + server_name: services.globals.server_name().to_owned(), + username_error: Some(err.message()), + } + )); + }, + } + } + } else { + None + }; + services .oidc - .complete_session(&claims, form.map(|form| form.username)) - .await, + .complete_session(&claims, supplied_user_id) + .await + }, } .map_err(|err| WebError::BadRequest(err.to_owned()))?; @@ -112,15 +186,11 @@ async fn route_complete( response!(Redirect::to(&session.next.target_path())) }, - | SessionCompletionStatus::NeedsLocalpart => { - response!(OidcComplete::new(context, services.globals.server_name().to_owned(), None)) - }, - | SessionCompletionStatus::InvalidLocalpart(error) => { - response!(OidcComplete::new( - context, - services.globals.server_name().to_owned(), - Some(error) - )) + | SessionCompletionStatus::NeedsUserId => { + response!(OidcComplete::new(context, OidcCompleteBody::UsernamePrompt { + server_name: services.globals.server_name().to_owned(), + username_error: None + })) }, } } diff --git a/src/web/pages/templates/oidc_complete.html.j2 b/src/web/pages/templates/oidc_complete.html.j2 index e5f322bd1..be58288ad 100644 --- a/src/web/pages/templates/oidc_complete.html.j2 +++ b/src/web/pages/templates/oidc_complete.html.j2 @@ -17,23 +17,43 @@ Link your account Matrix logo -
-

To finish linking your account to Matrix, choose a username.

-

- - - @ - - :{{ server_name }} - - {% if let Some(username_error) = username_error %} - - {{ username_error }} - - {% endif %} - Your username cannot be changed after you create your account. -

- -
+ {% match body %} + {% when OidcCompleteBody::UsernamePrompt { server_name, username_error } %} +
+

+ To finish linking your account to Matrix, choose a username. +
If you have an existing Matrix account, enter its user ID to link it. +

+

+ + + @ + + :{{ server_name }} + + {% if let Some(username_error) = username_error %} + + {{ username_error }} + + {% endif %} + Your username cannot be changed after you link your account. +

+ +
+ {% when OidcCompleteBody::PasswordPrompt { username, user_card, password_error } %} + {{ user_card }} +
+

To link this legacy account, enter the password you used to use when logging in.

+

+ + + {% if password_error %} + Incorrect password + {% endif %} +

+ + +
+ {% endmatch %} {% endblock %} From e2fe166d63d1d093a0f30951d1ae1cfb6cf237f5 Mon Sep 17 00:00:00 2001 From: Ginger Date: Tue, 30 Jun 2026 12:11:09 -0400 Subject: [PATCH 06/26] feat: Speedbump when logging in with OIDC with no next target --- src/service/oidc/mod.rs | 22 ++++--- src/web/pages/account/login.rs | 63 +++++++++++-------- src/web/pages/account/password/change.rs | 7 +++ src/web/pages/account/password/reset.rs | 7 +++ src/web/pages/templates/login.html.j2 | 77 ++++++++++++++---------- 5 files changed, 113 insertions(+), 63 deletions(-) diff --git a/src/service/oidc/mod.rs b/src/service/oidc/mod.rs index 70ebb823e..2adeb2259 100644 --- a/src/service/oidc/mod.rs +++ b/src/service/oidc/mod.rs @@ -6,7 +6,10 @@ use database::{Deserialized, Map}; use openidconnect::{ AuthorizationCode, CsrfToken, EndpointMaybeSet, EndpointNotSet, EndpointSet, IssuerUrl, Nonce, PkceCodeChallenge, PkceCodeVerifier, RedirectUrl, TokenResponse, - core::{CoreAuthenticationFlow, CoreClient, CoreIdTokenClaims, CoreProviderMetadata}, + core::{ + CoreAuthPrompt, CoreAuthenticationFlow, CoreClient, CoreIdTokenClaims, + CoreProviderMetadata, + }, reqwest, }; use ruma::{OwnedUserId, UserId}; @@ -133,20 +136,25 @@ impl Service { pub fn enabled(&self) -> bool { self.client.is_some() } - pub async fn begin_session(&self) -> (PendingSession, Url) { + pub async fn begin_session(&self, prompt: Option) -> (PendingSession, Url) { let OidcClient { machine, .. } = self.client.as_ref().expect("oidc should be configured"); let machine = machine.wait().await; let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256(); - let (auth_url, csrf_token, nonce) = machine + let mut auth_url = machine .authorize_url( CoreAuthenticationFlow::AuthorizationCode, CsrfToken::new_random, Nonce::new_random, ) - .set_pkce_challenge(pkce_challenge) - .url(); + .set_pkce_challenge(pkce_challenge); + + if let Some(prompt) = prompt { + auth_url = auth_url.add_prompt(prompt); + } + + let (auth_url, csrf_token, nonce) = auth_url.url(); (PendingSession { pkce_verifier, nonce, csrf_token }, auth_url) } @@ -253,8 +261,6 @@ impl Service { Self::SERVER_MISCONFIGURED })?; - self.link_user(&user_id, subject); - info!(?subject, ?user_id, "Shadow user created for {user_id}"); }, | AccountStatus::Deactivated => { @@ -262,6 +268,8 @@ impl Service { }, } + self.link_user(&user_id, subject); + Ok(SessionCompletionStatus::Complete(user_id)) } diff --git a/src/web/pages/account/login.rs b/src/web/pages/account/login.rs index ca66bae2d..5f1db34ff 100644 --- a/src/web/pages/account/login.rs +++ b/src/web/pages/account/login.rs @@ -7,12 +7,14 @@ use axum::{ routing::{get, on}, }; use conduwuit_api::client::handle_login; +use openidconnect::core::CoreAuthPrompt; use ruma::{ OwnedUserId, api::client::uiaa::{EmailUserIdentifier, MatrixUserIdentifier, UserIdentifier}, }; use serde::Deserialize; use tower_sessions::Session; +use url::Url; use crate::{ ROUTE_PREFIX, WebError, @@ -37,6 +39,7 @@ pub(crate) fn build() -> Router { template! { struct Login use "login.html.j2" { body: LoginBody, + login_type: LoginType, login_error: Option } } @@ -45,7 +48,6 @@ template! { enum LoginBody { Unauthenticated { server_name: String, - registration_available: bool, next: Option, }, Authenticated { @@ -53,6 +55,16 @@ enum LoginBody { }, } +#[derive(Debug)] +enum LoginType { + Interactive { + registration_available: bool, + }, + Oidc { + redirect_url: Url, + }, +} + #[derive(Deserialize)] struct LoginForm { identifier: Option, @@ -69,38 +81,39 @@ async fn route_login( ) -> Result { let user_id = user.into_session().map(|session| session.user_id); - if services.oidc.enabled() { - if user_id.is_some() && !reauthenticate { - return response!(Redirect::to(&next.unwrap_or_default().target_path())); - } - - let (session, redirect_url) = services.oidc.begin_session().await; + let login_type = if services.oidc.enabled() { + let (session, redirect_url) = services + .oidc + .begin_session(reauthenticate.then(|| CoreAuthPrompt::Consent)) + .await; session_store .insert(OIDC_SESSION_ID_KEY, OidcSession { - next: next.unwrap_or_default(), - state: OidcSessionState::CodeExchange { expected_user: user_id, session }, + next: next.clone().unwrap_or_default(), + state: OidcSessionState::CodeExchange { expected_user: user_id.clone(), session }, }) .await .expect("should be able to serialize OIDC session"); - return response!(Redirect::to(redirect_url.as_str())); - } + if next.is_some() { + return response!(Redirect::to(redirect_url.as_str())); + } + + LoginType::Oidc { redirect_url } + } else { + let (trusted_flow_status, untrusted_flow_status) = + registration_flow_status(&services).await; + + let registration_available = matches!(trusted_flow_status, TrustedFlowStatus::Available) + || matches!(untrusted_flow_status, UntrustedFlowStatus::Available { .. }); + + LoginType::Interactive { registration_available } + }; let body = match &user_id { - | None => { - let (trusted_flow_status, untrusted_flow_status) = - registration_flow_status(&services).await; - - let registration_available = - matches!(trusted_flow_status, TrustedFlowStatus::Available) - || matches!(untrusted_flow_status, UntrustedFlowStatus::Available { .. }); - - LoginBody::Unauthenticated { - server_name: services.globals.server_name().to_string(), - registration_available, - next: next.clone(), - } + | None => LoginBody::Unauthenticated { + server_name: services.globals.server_name().to_string(), + next: next.clone(), }, | Some(user_id) => { if !reauthenticate { @@ -113,7 +126,7 @@ async fn route_login( }, }; - let mut template = Login::new(context, body, None); + let mut template = Login::new(context, body, login_type, None); if let Some(form) = form { let login_result = match (user_id, form.identifier) { diff --git a/src/web/pages/account/password/change.rs b/src/web/pages/account/password/change.rs index a8d4adc80..74a5a5f64 100644 --- a/src/web/pages/account/password/change.rs +++ b/src/web/pages/account/password/change.rs @@ -4,6 +4,7 @@ use ruma::UserId; use validator::{Validate, ValidationError, ValidationErrors}; use crate::{ + WebError, extract::PostForm, form, pages::{ @@ -65,6 +66,12 @@ async fn route_change_password( user: User, PostForm(form): PostForm, ) -> Result { + if services.oidc.enabled() { + return Err(WebError::BadRequest( + "Password changing is not available on this server".to_owned(), + )); + } + let user_id = user.expect(LoginTarget::ChangePassword)?; let user_card = UserCard::for_local_user(&services, user_id.clone()).await; diff --git a/src/web/pages/account/password/reset.rs b/src/web/pages/account/password/reset.rs index b263060b7..b95601fc9 100644 --- a/src/web/pages/account/password/reset.rs +++ b/src/web/pages/account/password/reset.rs @@ -65,6 +65,13 @@ async fn route_reset_password( return response!(ResetPassword::new(context, ResetPasswordBody::Unavailable)); } + // Check if OIDC is enabled + if services.oidc.enabled() { + return Err(WebError::BadRequest( + "Password resets are not available on this server".to_owned(), + )); + } + let Some(form) = form else { // For GET requests return the reset request form return response!(ResetPassword::new( diff --git a/src/web/pages/templates/login.html.j2 b/src/web/pages/templates/login.html.j2 index a2ade2e04..9f118e0bc 100644 --- a/src/web/pages/templates/login.html.j2 +++ b/src/web/pages/templates/login.html.j2 @@ -9,9 +9,14 @@ Log in {%- endblock -%} {%- block content -%} +{% match login_type %} + {% when LoginType::Interactive { .. } %}
+ {% when LoginType::Oidc { .. } %} +
+{% endmatch %} {% match body %} - {% when LoginBody::Unauthenticated { server_name, registration_available, next } %} + {% when LoginBody::Unauthenticated { server_name, next } %}

{% if next.is_some() %} Log in to continue @@ -25,39 +30,49 @@ Log in

You're about to log in to your account on {{ server_name }}

-
-
-

- - -

-

- - -

- -
- + {% match login_type %} + {% when LoginType::Interactive { registration_available } %} +
+
+

+ + +

+

+ + +

+ +
+ + {% when LoginType::Oidc { redirect_url } %} + Continue + {% endmatch %} {% when LoginBody::Authenticated { user_card } %}

Confirm your identity

{{ user_card }} -

Enter your password to continue.

-
-

- - -

- -
- + {% match login_type %} + {% when LoginType::Interactive { .. } %} +

Enter your password to continue.

+
+

+ + +

+ +
+ + {% when LoginType::Oidc { redirect_url } %} + Continue + {% endmatch %} {% endmatch %} {% if let Some(error) = login_error %} {{ error }} From e649af33b98addb23bb0e48ab11898702a6c1878 Mon Sep 17 00:00:00 2001 From: Ginger Date: Tue, 30 Jun 2026 12:19:23 -0400 Subject: [PATCH 07/26] feat: Send account selection prompt to IDP when account switch link is clicked --- src/web/pages/account/login.rs | 10 +++++++--- src/web/pages/oauth/grant.rs | 5 +++-- src/web/session/mod.rs | 13 +++++++++++-- 3 files changed, 21 insertions(+), 7 deletions(-) diff --git a/src/web/pages/account/login.rs b/src/web/pages/account/login.rs index 5f1db34ff..c2079ac1e 100644 --- a/src/web/pages/account/login.rs +++ b/src/web/pages/account/login.rs @@ -26,7 +26,7 @@ use crate::{ oidc::{OIDC_SESSION_ID_KEY, OidcSession, OidcSessionState}, }, response, - session::{LoginQuery, LoginTarget, User, UserSession}, + session::{LoginIntent, LoginQuery, LoginTarget, User, UserSession}, template, }; @@ -74,7 +74,7 @@ struct LoginForm { async fn route_login( State(services): State, Extension(context): Extension, - Expect(Query(LoginQuery { next, reauthenticate })): Expect>, + Expect(Query(LoginQuery { next, reauthenticate, intent })): Expect>, session_store: Session, user: User, PostForm(form): PostForm, @@ -84,7 +84,11 @@ async fn route_login( let login_type = if services.oidc.enabled() { let (session, redirect_url) = services .oidc - .begin_session(reauthenticate.then(|| CoreAuthPrompt::Consent)) + .begin_session(match intent { + | Some(LoginIntent::SwitchAccounts) => Some(CoreAuthPrompt::SelectAccount), + | _ if reauthenticate => Some(CoreAuthPrompt::Consent), + | _ => None, + }) .await; session_store diff --git a/src/web/pages/oauth/grant.rs b/src/web/pages/oauth/grant.rs index 1ec5dae9d..aa2825e1d 100644 --- a/src/web/pages/oauth/grant.rs +++ b/src/web/pages/oauth/grant.rs @@ -17,7 +17,7 @@ use crate::{ components::{Avatar, AvatarType, ClientScopes}, }, response, - session::{LoginQuery, LoginTarget, User}, + session::{LoginIntent, LoginQuery, LoginTarget, User}, template, }; @@ -129,7 +129,8 @@ async fn route_authorization_code( context, serde_urlencoded::to_string(LoginQuery { next: Some(LoginTarget::AuthorizationCode(query)), - reauthenticate: false, + intent: Some(LoginIntent::SwitchAccounts), + ..Default::default() }) .unwrap(), user_id, diff --git a/src/web/session/mod.rs b/src/web/session/mod.rs index 6bb1ba4df..526915243 100644 --- a/src/web/session/mod.rs +++ b/src/web/session/mod.rs @@ -24,6 +24,8 @@ pub(crate) struct LoginQuery { pub next: Option, #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub reauthenticate: bool, + #[serde(default)] + pub intent: Option, } #[derive(Clone, Debug, Default, Deserialize, Serialize)] @@ -65,6 +67,12 @@ impl LoginTarget { } } +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum LoginIntent { + SwitchAccounts, +} + /// An extractor that fetches the authenticated user. pub(crate) struct User(Option); @@ -107,7 +115,7 @@ impl User { } else { Err(WebError::LoginRequired(LoginQuery { next: Some(or_else), - reauthenticate: false, + ..Default::default() })) } } @@ -122,12 +130,13 @@ impl User { Err(WebError::LoginRequired(LoginQuery { next: Some(or_else), reauthenticate: true, + ..Default::default() })) } } else { Err(WebError::LoginRequired(LoginQuery { next: Some(or_else), - reauthenticate: false, + ..Default::default() })) } } From 8c77bd61e535a5737287ead7e9de6ad55bc5cd4f Mon Sep 17 00:00:00 2001 From: Ginger Date: Tue, 30 Jun 2026 12:47:25 -0400 Subject: [PATCH 08/26] fix: Hide password change link when OIDC is enabled --- src/web/pages/account/mod.rs | 2 ++ src/web/pages/templates/account.html.j2 | 10 ++++++---- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/web/pages/account/mod.rs b/src/web/pages/account/mod.rs index ace945967..cf3d86d98 100644 --- a/src/web/pages/account/mod.rs +++ b/src/web/pages/account/mod.rs @@ -66,6 +66,7 @@ template! { enum AccountBody { Unlocked { suspended: bool, + oidc_enabled: bool, email_requirement: EmailRequirement, email: Option, devices: Vec, @@ -128,6 +129,7 @@ async fn get_account( response!(Account::new(context, user_card, AccountBody::Unlocked { suspended, + oidc_enabled: services.oidc.enabled(), email_requirement, email, devices: device_cards diff --git a/src/web/pages/templates/account.html.j2 b/src/web/pages/templates/account.html.j2 index 6e73ce15b..396aadb9b 100644 --- a/src/web/pages/templates/account.html.j2 +++ b/src/web/pages/templates/account.html.j2 @@ -9,7 +9,7 @@ Your account

Manage your account

{{ user_card }} {% match body %} - {% when AccountBody::Unlocked { suspended, email_requirement, email, devices } %} + {% when AccountBody::Unlocked { suspended, email_requirement, email, devices, oidc_enabled } %} {% if suspended %}

⚠️ Your account has been suspended by your homeserver's administrator. @@ -27,9 +27,11 @@ Your account Change your email

{% endif %} -

- Change your password -

+ {% if !oidc_enabled %} +

+ Change your password +

+ {% endif %}
From 53d6887b8648dfed89d0f4b6191b274029d2a9ea Mon Sep 17 00:00:00 2001 From: Ginger Date: Tue, 30 Jun 2026 16:02:14 -0400 Subject: [PATCH 09/26] refactor: Move profile field setting logic into users service --- src/api/client/profile.rs | 325 ++++------------------------------- src/api/server/query.rs | 15 +- src/service/users/account.rs | 12 +- src/service/users/mod.rs | 9 +- src/service/users/profile.rs | 270 +++++++++++++++++++++++++++-- 5 files changed, 319 insertions(+), 312 deletions(-) diff --git a/src/api/client/profile.rs b/src/api/client/profile.rs index b611e31de..a8dae57ff 100644 --- a/src/api/client/profile.rs +++ b/src/api/client/profile.rs @@ -1,9 +1,8 @@ use std::collections::BTreeMap; use axum::extract::State; -use conduwuit::{Err, Result, matrix::pdu::PartialPdu, utils::to_canonical_object}; +use conduwuit::{Err, Result}; use conduwuit_service::Services; -use futures::StreamExt; use ruma::{ UserId, api::{ @@ -13,11 +12,10 @@ use ruma::{ federation, }, assign, - events::room::member::MembershipState, - presence::PresenceState, profile::{ProfileFieldName, ProfileFieldValue}, }; -use serde_json::{Value, to_value}; +use serde_json::Value; +use service::users::ProfileFieldChange; use crate::Ruma; @@ -65,13 +63,14 @@ pub(crate) async fn set_profile_field_route( return Err!(Request(InvalidParam("You may not change a remote user's profile data."))); } - set_profile_field( - &services, - &body.user_id, - ProfileFieldChange::Set(body.value.clone()), - body.propagate_to.clone(), - ) - .await?; + services + .users + .set_profile_field( + &body.user_id, + ProfileFieldChange::Set(body.value.clone()), + body.propagate_to.clone(), + ) + .await?; Ok(set_profile_field::v3::Response::new()) } @@ -94,13 +93,14 @@ pub(crate) async fn delete_profile_field_route( return Err!(Request(InvalidParam("You may not change a remote user's profile data."))); } - set_profile_field( - &services, - &body.user_id, - ProfileFieldChange::Delete(body.field.clone()), - body.propagate_to.clone(), - ) - .await?; + services + .users + .set_profile_field( + &body.user_id, + ProfileFieldChange::Delete(body.field.clone()), + body.propagate_to.clone(), + ) + .await?; Ok(delete_profile_field::v3::Response::new()) } @@ -111,7 +111,7 @@ async fn fetch_full_profile( ) -> Option> { // If the user exists locally, fetch their local profile if services.users.status(user_id).await.is_found() { - return Some(get_local_profile(services, user_id).await); + return Some(services.users.get_local_profile(user_id).await); } // Otherwise ask their homeserver @@ -135,13 +135,10 @@ async fn fetch_full_profile( continue; }; - let _ = set_profile_field( - services, - user_id, - ProfileFieldChange::Set(value), - PropagateTo::None, - ) - .await; + let _ = services + .users + .set_profile_field(user_id, ProfileFieldChange::Set(value), PropagateTo::None) + .await; } Some(BTreeMap::from_iter(response)) @@ -154,7 +151,7 @@ async fn fetch_profile_field( ) -> Result> { // If the user exists locally, fetch their local profile field if services.globals.user_is_local(user_id) { - return Ok(get_local_profile_field(services, user_id, field).await); + return Ok(services.users.get_local_profile_field(user_id, field).await); } // Otherwise ask their homeserver @@ -175,13 +172,14 @@ async fn fetch_profile_field( if let Some(value) = response.get(field.as_str()).map(ToOwned::to_owned) { if let Ok(value) = ProfileFieldValue::new(field.as_str(), value) { - let _ = set_profile_field( - services, - user_id, - ProfileFieldChange::Set(value.clone()), - PropagateTo::None, - ) - .await; + let _ = services + .users + .set_profile_field( + user_id, + ProfileFieldChange::Set(value.clone()), + PropagateTo::None, + ) + .await; Ok(Some(value)) } else { @@ -190,260 +188,11 @@ async fn fetch_profile_field( ))) } } else { - let _ = set_profile_field( - services, - user_id, - ProfileFieldChange::Delete(field), - PropagateTo::None, - ) - .await; + let _ = services + .users + .set_profile_field(user_id, ProfileFieldChange::Delete(field), PropagateTo::None) + .await; Ok(None) } } - -pub(crate) async fn get_local_profile( - services: &Services, - user_id: &UserId, -) -> BTreeMap { - let mut profile = BTreeMap::new(); - - // Get displayname and avatar_url independently because `all_profile_keys` - // doesn't include them - for field in [ProfileFieldName::AvatarUrl, ProfileFieldName::DisplayName] { - let key = field.as_str().to_owned(); - - if let Some(value) = get_local_profile_field(services, user_id, field).await { - profile.insert(key, value.value().into_owned()); - } - } - - // Insert all other profile fields - let mut all_fields = services.users.all_profile_keys(user_id); - - while let Some((key, value)) = all_fields.next().await { - profile.insert(key, value); - } - - profile -} - -pub(crate) async fn get_local_profile_field( - services: &Services, - user_id: &UserId, - field: ProfileFieldName, -) -> Option { - let value = match field.clone() { - | ProfileFieldName::AvatarUrl => services - .users - .avatar_url(user_id) - .await - .ok() - .map(to_value) - .transpose() - .expect("converting avatar url to value should succeed"), - | ProfileFieldName::DisplayName => services - .users - .displayname(user_id) - .await - .ok() - .map(to_value) - .transpose() - .expect("converting displayname to value should succeed"), - | other => services - .users - .profile_key(user_id, other.as_str()) - .await - .ok(), - }?; - - Some( - ProfileFieldValue::new(field.as_str(), value) - .expect("local profile field should be valid"), - ) -} - -enum ProfileFieldChange { - Set(ProfileFieldValue), - Delete(ProfileFieldName), -} - -impl ProfileFieldChange { - fn field_name(&self) -> ProfileFieldName { - match self { - | &Self::Delete(ref name) => name.clone(), - | &Self::Set(ref value) => value.field_name(), - } - } - - fn value(&self) -> Option { - if let Self::Set(value) = self { - Some(value.value().into_owned()) - } else { - None - } - } -} - -async fn set_profile_field( - services: &Services, - user_id: &UserId, - change: ProfileFieldChange, - propagate_to: PropagateTo, -) -> Result<()> { - const MAX_KEY_LENGTH_BYTES: usize = 255; - const MAX_PROFILE_LENGTH_BYTES: usize = 65536; - - let field_name = change.field_name(); - - // TODO: The spec mentions special error codes (M_PROFILE_TOO_LARGE, - // M_KEY_TOO_LARGE) for profile field size limits, but they're not in its list - // of error codes and Ruma doesn't have them. Should we return those, or is - // M_TOO_LARGE okay? - if field_name.as_str().len() > MAX_KEY_LENGTH_BYTES { - return Err!(Request(TooLarge( - "Individual profile keys must not exceed {MAX_KEY_LENGTH_BYTES} bytes in length." - ))); - } - - // Serialize the entire profile as canonical JSON, including the new change, - // to check if it exceeds 64 KiB - { - let mut full_profile = get_local_profile(services, user_id).await; - - match &change { - | ProfileFieldChange::Set(value) => { - full_profile.insert( - value.field_name().as_str().to_owned(), - value.value().clone().into_owned(), - ); - }, - | ProfileFieldChange::Delete(key) => { - full_profile.remove(key.as_str()); - }, - } - - if let Ok(canonical_profile) = to_canonical_object(full_profile) { - if serde_json::to_string(&canonical_profile) - .expect("should be able to serialize to string") - .len() > MAX_PROFILE_LENGTH_BYTES - { - return Err!( - "Profile data must not exceed {MAX_PROFILE_LENGTH_BYTES} bytes in length." - ); - } - } else { - return Err!(Request(BadJson("Failed to canonicalize profile."))); - } - } - - // If the user is local and changed their displayname or avatar_url, update it - // in all their joined rooms. This is done before updating their profile data - // so we can check the old value of the field if `propagate_to` is `unchanged`. - if matches!(field_name, ProfileFieldName::AvatarUrl | ProfileFieldName::DisplayName) - && matches!(propagate_to, PropagateTo::All | PropagateTo::Unchanged) - && services.globals.user_is_local(user_id) - { - let current_displayname = services.users.displayname(user_id).await.ok(); - let current_avatar_url = services.users.avatar_url(user_id).await.ok(); - - let mut all_joined_rooms = services.rooms.state_cache.rooms_joined(user_id); - - while let Some(room_id) = all_joined_rooms.next().await { - // TODO: this clobbers any custom fields on the event content - let mut current_membership = services - .rooms - .state_accessor - .get_member(&room_id, user_id) - .await - .expect("should be able to fetch membership event for joined room"); - - assert_eq!( - current_membership.membership, - MembershipState::Join, - "user should be joined" - ); - - // If `propagate_to` is `unchanged`, and the current value of the field we're - // updating was changed from its global value in this room, skip it. - if matches!(propagate_to, PropagateTo::Unchanged) { - let field_changed_from_global = match field_name { - | ProfileFieldName::AvatarUrl => - current_membership.avatar_url.as_ref() != current_avatar_url.as_ref(), - | ProfileFieldName::DisplayName => - current_membership.displayname.as_ref() != current_displayname.as_ref(), - | _ => unreachable!(), - }; - - if field_changed_from_global { - continue; - } - } - - let state_lock = services.rooms.state.mutex.lock(room_id.as_str()).await; - - // Preserve keys in accordance with the key copying rules - current_membership.reason = None; - current_membership.join_authorized_via_users_server = None; - match &change { - | ProfileFieldChange::Set(ProfileFieldValue::AvatarUrl(avatar_url)) => { - current_membership.avatar_url = Some(avatar_url.clone()); - }, - | ProfileFieldChange::Set(ProfileFieldValue::DisplayName(displayname)) => { - current_membership.displayname = Some(displayname.clone()); - }, - | ProfileFieldChange::Delete(ProfileFieldName::AvatarUrl) => { - current_membership.avatar_url = None; - }, - | ProfileFieldChange::Delete(ProfileFieldName::DisplayName) => { - current_membership.displayname = None; - }, - | _ => unreachable!(), - } - - let _ = services - .rooms - .timeline - .build_and_append_pdu( - PartialPdu::state(user_id.to_string(), ¤t_membership), - user_id, - Some(&room_id), - &state_lock, - ) - .await; - } - - if services.config.allow_local_presence { - // Send a presence EDU to indicate the profile changed - let _ = services - .presence - .ping_presence(user_id, &PresenceState::Online) - .await; - } - } - - match change { - | ProfileFieldChange::Set(ProfileFieldValue::DisplayName(displayname)) => { - services - .users - .set_displayname(user_id, Some(displayname).filter(|dn| !dn.is_empty())); - }, - | ProfileFieldChange::Set(ProfileFieldValue::AvatarUrl(avatar_url)) => { - services - .users - .set_avatar_url(user_id, Some(avatar_url).filter(|av| av.is_valid())); - }, - | ProfileFieldChange::Delete(ProfileFieldName::DisplayName) => { - services.users.set_displayname(user_id, None); - }, - | ProfileFieldChange::Delete(ProfileFieldName::AvatarUrl) => { - services.users.set_avatar_url(user_id, None); - }, - | other => - services - .users - .set_profile_key(user_id, other.field_name().as_str(), other.value()), - } - - Ok(()) -} diff --git a/src/api/server/query.rs b/src/api/server/query.rs index ff19d8214..dccbce067 100644 --- a/src/api/server/query.rs +++ b/src/api/server/query.rs @@ -7,10 +7,7 @@ use ruma::{ api::federation::query::{get_profile_information, get_room_information}, }; -use crate::{ - Ruma, - client::{get_local_profile, get_local_profile_field}, -}; +use crate::Ruma; /// # `GET /_matrix/federation/v1/query/directory` /// @@ -75,15 +72,19 @@ pub(crate) async fn get_profile_information_route( let response = if let Some(field) = &body.field { let mut response = get_profile_information::v1::Response::new(); - if let Some(value) = - get_local_profile_field(&services, &body.user_id, field.to_owned()).await + if let Some(value) = services + .users + .get_local_profile_field(&body.user_id, field.to_owned()) + .await { response.set(value.field_name().as_str().to_owned(), value.value().into_owned()); } response } else { - get_local_profile(&services, &body.user_id) + services + .users + .get_local_profile(&body.user_id) .await .into_iter() .collect() diff --git a/src/service/users/account.rs b/src/service/users/account.rs index 816ab974b..a12387780 100644 --- a/src/service/users/account.rs +++ b/src/service/users/account.rs @@ -10,17 +10,19 @@ use futures::{FutureExt, Stream, StreamExt}; use lettre::Address; use ruma::{ MilliSecondsSinceUnixEpoch, OwnedDeviceId, OwnedUserId, UserId, + api::client::profile::PropagateTo, events::{ GlobalAccountDataEventType, ignored_user_list::IgnoredUserListEvent, push_rules::PushRulesEvent, room::message::RoomMessageEventContent, }, + profile::ProfileFieldValue, push::Ruleset, }; use ruminuwuity::invite_permission_config::{FilterLevel, InvitePermissionConfigEvent}; use crate::{ appservice::RegistrationInfo, - users::{HashedPassword, UserSuspension}, + users::{HashedPassword, UserSuspension, profile::ProfileFieldChange}, }; /// The status of an access token. @@ -151,7 +153,13 @@ impl super::Service { displayname.push_str(suffix); } - self.set_displayname(user_id, Some(displayname)); + self.set_profile_field( + user_id, + ProfileFieldChange::Set(ProfileFieldValue::DisplayName(displayname)), + PropagateTo::None, + ) + .await + .expect("should be able to set display name"); }; // Set default push rules diff --git a/src/service/users/mod.rs b/src/service/users/mod.rs index 734651e5a..155cdeed4 100644 --- a/src/service/users/mod.rs +++ b/src/service/users/mod.rs @@ -14,11 +14,12 @@ use conduwuit::{ utils::{self}, }; use database::Map; +pub use profile::ProfileFieldChange; use ruma::{UserId, api::error::ErrorKind, encryption::CrossSigningKey, serde::Raw}; use serde::{Deserialize, Serialize}; use crate::{ - Dep, account_data, admin, appservice, config, firstrun, globals, oauth, + Dep, account_data, admin, appservice, config, firstrun, globals, oauth, presence, rooms::{self, alias, membership}, threepid, }; @@ -62,9 +63,12 @@ struct Services { globals: Dep, membership: Dep, oauth: Dep, + presence: Dep, + state: Dep, state_accessor: Dep, state_cache: Dep, threepid: Dep, + timeline: Dep, } struct Data { @@ -110,10 +114,13 @@ impl crate::Service for Service { globals: args.depend::("globals"), membership: args.depend::("membership"), oauth: args.depend::("oauth"), + presence: args.depend::("presence"), + state: args.depend::("rooms::state"), state_accessor: args .depend::("rooms::state_accessor"), state_cache: args.depend::("rooms::state_cache"), threepid: args.depend::("threepid"), + timeline: args.depend::("rooms::timeline"), }, db: Data { keychangeid_userid: args.db["keychangeid_userid"].clone(), diff --git a/src/service/users/profile.rs b/src/service/users/profile.rs index a4ed3ee3d..2c5444795 100644 --- a/src/service/users/profile.rs +++ b/src/service/users/profile.rs @@ -1,17 +1,263 @@ -use conduwuit::utils::{ReadyExt, stream::TryIgnore}; +use std::collections::BTreeMap; + +use conduwuit::{ + Err, Result, + pdu::PartialPdu, + utils::{ReadyExt, stream::TryIgnore, to_canonical_object}, +}; use database::{Deserialized, Ignore, Interfix, Json}; use futures::{Stream, StreamExt}; -use ruma::{OwnedMxcUri, UserId}; +use ruma::{ + OwnedMxcUri, UserId, + api::client::profile::PropagateTo, + events::room::member::MembershipState, + presence::PresenceState, + profile::{ProfileFieldName, ProfileFieldValue}, +}; +use serde_json::{Value, to_value}; + +pub enum ProfileFieldChange { + Set(ProfileFieldValue), + Delete(ProfileFieldName), +} + +impl ProfileFieldChange { + fn field_name(&self) -> ProfileFieldName { + match self { + | &Self::Delete(ref name) => name.clone(), + | &Self::Set(ref value) => value.field_name(), + } + } + + fn value(&self) -> Option { + if let Self::Set(value) = self { + Some(value.value().into_owned()) + } else { + None + } + } +} impl super::Service { + pub async fn set_profile_field( + &self, + user_id: &UserId, + change: ProfileFieldChange, + propagate_to: PropagateTo, + ) -> Result<()> { + const MAX_KEY_LENGTH_BYTES: usize = 255; + const MAX_PROFILE_LENGTH_BYTES: usize = 65536; + + let field_name = change.field_name(); + + // TODO: The spec mentions special error codes (M_PROFILE_TOO_LARGE, + // M_KEY_TOO_LARGE) for profile field size limits, but they're not in its list + // of error codes and Ruma doesn't have them. Should we return those, or is + // M_TOO_LARGE okay? + if field_name.as_str().len() > MAX_KEY_LENGTH_BYTES { + return Err!(Request(TooLarge( + "Individual profile keys must not exceed {MAX_KEY_LENGTH_BYTES} bytes in length." + ))); + } + + // Serialize the entire profile as canonical JSON, including the new change, + // to check if it exceeds 64 KiB + { + let mut full_profile = self.get_local_profile(user_id).await; + + match &change { + | ProfileFieldChange::Set(value) => { + full_profile.insert( + value.field_name().as_str().to_owned(), + value.value().clone().into_owned(), + ); + }, + | ProfileFieldChange::Delete(key) => { + full_profile.remove(key.as_str()); + }, + } + + if let Ok(canonical_profile) = to_canonical_object(full_profile) { + if serde_json::to_string(&canonical_profile) + .expect("should be able to serialize to string") + .len() > MAX_PROFILE_LENGTH_BYTES + { + return Err!( + "Profile data must not exceed {MAX_PROFILE_LENGTH_BYTES} bytes in \ + length." + ); + } + } else { + return Err!(Request(BadJson("Failed to canonicalize profile."))); + } + } + + // If the user is local and changed their displayname or avatar_url, update it + // in all their joined rooms. This is done before updating their profile data + // so we can check the old value of the field if `propagate_to` is `unchanged`. + if matches!(field_name, ProfileFieldName::AvatarUrl | ProfileFieldName::DisplayName) + && matches!(propagate_to, PropagateTo::All | PropagateTo::Unchanged) + && self.services.globals.user_is_local(user_id) + { + let current_displayname = self.displayname(user_id).await.ok(); + let current_avatar_url = self.avatar_url(user_id).await.ok(); + + let mut all_joined_rooms = self.services.state_cache.rooms_joined(user_id); + + while let Some(room_id) = all_joined_rooms.next().await { + // TODO: this clobbers any custom fields on the event content + let mut current_membership = self + .services + .state_accessor + .get_member(&room_id, user_id) + .await + .expect("should be able to fetch membership event for joined room"); + + assert_eq!( + current_membership.membership, + MembershipState::Join, + "user should be joined" + ); + + // If `propagate_to` is `unchanged`, and the current value of the field we're + // updating was changed from its global value in this room, skip it. + if matches!(propagate_to, PropagateTo::Unchanged) { + let field_changed_from_global = match field_name { + | ProfileFieldName::AvatarUrl => + current_membership.avatar_url.as_ref() != current_avatar_url.as_ref(), + | ProfileFieldName::DisplayName => + current_membership.displayname.as_ref() + != current_displayname.as_ref(), + | _ => unreachable!(), + }; + + if field_changed_from_global { + continue; + } + } + + let state_lock = self.services.state.mutex.lock(room_id.as_str()).await; + + // Preserve keys in accordance with the key copying rules + current_membership.reason = None; + current_membership.join_authorized_via_users_server = None; + match &change { + | ProfileFieldChange::Set(ProfileFieldValue::AvatarUrl(avatar_url)) => { + current_membership.avatar_url = Some(avatar_url.clone()); + }, + | ProfileFieldChange::Set(ProfileFieldValue::DisplayName(displayname)) => { + current_membership.displayname = Some(displayname.clone()); + }, + | ProfileFieldChange::Delete(ProfileFieldName::AvatarUrl) => { + current_membership.avatar_url = None; + }, + | ProfileFieldChange::Delete(ProfileFieldName::DisplayName) => { + current_membership.displayname = None; + }, + | _ => unreachable!(), + } + + let _ = self + .services + .timeline + .build_and_append_pdu( + PartialPdu::state(user_id.to_string(), ¤t_membership), + user_id, + Some(&room_id), + &state_lock, + ) + .await; + } + + if self.services.config.allow_local_presence { + // Send a presence EDU to indicate the profile changed + let _ = self + .services + .presence + .ping_presence(user_id, &PresenceState::Online) + .await; + } + } + + match change { + | ProfileFieldChange::Set(ProfileFieldValue::DisplayName(displayname)) => { + self.set_displayname(user_id, Some(displayname).filter(|dn| !dn.is_empty())); + }, + | ProfileFieldChange::Set(ProfileFieldValue::AvatarUrl(avatar_url)) => { + self.set_avatar_url(user_id, Some(avatar_url).filter(|av| av.is_valid())); + }, + | ProfileFieldChange::Delete(ProfileFieldName::DisplayName) => { + self.set_displayname(user_id, None); + }, + | ProfileFieldChange::Delete(ProfileFieldName::AvatarUrl) => { + self.set_avatar_url(user_id, None); + }, + | other => self.set_profile_key(user_id, other.field_name().as_str(), other.value()), + } + + Ok(()) + } + + pub async fn get_local_profile(&self, user_id: &UserId) -> BTreeMap { + let mut profile = BTreeMap::new(); + + // Get displayname and avatar_url independently because `all_profile_keys` + // doesn't include them + for field in [ProfileFieldName::AvatarUrl, ProfileFieldName::DisplayName] { + let key = field.as_str().to_owned(); + + if let Some(value) = self.get_local_profile_field(user_id, field).await { + profile.insert(key, value.value().into_owned()); + } + } + + // Insert all other profile fields + let mut all_fields = self.all_profile_keys(user_id); + + while let Some((key, value)) = all_fields.next().await { + profile.insert(key, value); + } + + profile + } + + pub async fn get_local_profile_field( + &self, + user_id: &UserId, + field: ProfileFieldName, + ) -> Option { + let value = match field.clone() { + | ProfileFieldName::AvatarUrl => self + .avatar_url(user_id) + .await + .ok() + .map(to_value) + .transpose() + .expect("converting avatar url to value should succeed"), + | ProfileFieldName::DisplayName => self + .displayname(user_id) + .await + .ok() + .map(to_value) + .transpose() + .expect("converting displayname to value should succeed"), + | other => self.profile_key(user_id, other.as_str()).await.ok(), + }?; + + Some( + ProfileFieldValue::new(field.as_str(), value) + .expect("local profile field should be valid"), + ) + } + /// Returns the displayname of a user on this homeserver. - pub async fn displayname(&self, user_id: &UserId) -> conduwuit::Result { + pub async fn displayname(&self, user_id: &UserId) -> Result { self.db.userid_displayname.get(user_id).await.deserialized() } /// Sets a new displayname or removes it if displayname is None. You still /// need to notify all rooms of this change. - pub fn set_displayname(&self, user_id: &UserId, displayname: Option) { + fn set_displayname(&self, user_id: &UserId, displayname: Option) { if let Some(displayname) = displayname { self.db.userid_displayname.insert(user_id, displayname); } else { @@ -20,12 +266,12 @@ impl super::Service { } /// Get the `avatar_url` of a user. - pub async fn avatar_url(&self, user_id: &UserId) -> conduwuit::Result { + pub async fn avatar_url(&self, user_id: &UserId) -> Result { self.db.userid_avatarurl.get(user_id).await.deserialized() } /// Sets a new avatar_url or removes it if avatar_url is None. - pub fn set_avatar_url(&self, user_id: &UserId, avatar_url: Option) { + fn set_avatar_url(&self, user_id: &UserId, avatar_url: Option) { match avatar_url { | Some(avatar_url) => { self.db.userid_avatarurl.insert(user_id, &avatar_url); @@ -37,11 +283,7 @@ impl super::Service { } /// Gets a specific user profile key - pub async fn profile_key( - &self, - user_id: &UserId, - profile_key: &str, - ) -> conduwuit::Result { + pub async fn profile_key(&self, user_id: &UserId, profile_key: &str) -> Result { let key = (user_id, profile_key); self.db .useridprofilekey_value @@ -54,7 +296,7 @@ impl super::Service { pub fn all_profile_keys<'a>( &'a self, user_id: &'a UserId, - ) -> impl Stream + 'a + Send { + ) -> impl Stream + 'a + Send { type KeyVal<'a> = ((Ignore, String), &'a [u8]); let prefix = (user_id, Interfix); @@ -67,11 +309,11 @@ impl super::Service { } /// Sets a new profile key value, removes the key if value is None - pub fn set_profile_key( + fn set_profile_key( &self, user_id: &UserId, profile_key: &str, - profile_key_value: Option, + profile_key_value: Option, ) { let key = (user_id, profile_key); From 06afe4f1a3b0687f492a3831d8b8ba8b95b8befa Mon Sep 17 00:00:00 2001 From: Ginger Date: Tue, 30 Jun 2026 21:14:03 -0400 Subject: [PATCH 10/26] feat: Add support for importing profile data from claims --- conduwuit-example.toml | 34 ++++- src/api/client/capabilities.rs | 9 +- src/api/client/profile.rs | 20 +++ src/core/config/mod.rs | 85 ++++++++++--- src/database/maps.rs | 4 + src/service/media/preview.rs | 6 - src/service/oidc/mod.rs | 218 +++++++++++++++++++++++++++++---- src/service/users/profile.rs | 21 +++- src/web/pages/oidc/mod.rs | 5 +- 9 files changed, 339 insertions(+), 63 deletions(-) diff --git a/conduwuit-example.toml b/conduwuit-example.toml index 8db23efa9..5521acde4 100644 --- a/conduwuit-example.toml +++ b/conduwuit-example.toml @@ -2061,8 +2061,36 @@ # Whether the user should be prompted to choose a localpart # when signing in for the first time. If this is `false`, Continuwuity -# will attempt to use the value of the `preferred_username` claim -# returned from the IDP as the user's localpart, and authentication will +# will attempt to use the value of the `preferred_username_claim` +# (see below) as the user's localpart. Authentication will # fail if this claim is missing or is not a valid localpart. # -#prompt_for_localpart = false +#prompt_for_localpart = true + +# The claim to use for the user's localpart, if `prompt_for_localpart` is +# false. +# +#preferred_username_claim = "preferred_username" + +# Defines how claims returned from the IDP should be mapped to a user's +# profile data. The profile field named in each key will be set from the +# claim named in the corresponding value when the user first registers, +# and possibly on subsequent logins as well, depending on the value of +# `profile_key_import_mode` (see below). +# +# SECURITY NOTE: If the `avatar_url` field is set, Continuwuity will +# perform a HTTP GET to the URL in the mapped claim and use the returned +# file as the user's profile picture. Make sure your users are not able +# to set the value of the mapped claim to an arbitrary URL. +# +#profile_key_map = { displayname = "name" } + +# When profile keys should be imported from the IDP's claims. +# +# - "on_registration": Listed keys will be imported once, when the user +# registers. +# - "on_login": Listed keys will be imported every time the user logs in. +# Additionally, users will not be able to manually edit the keys through +# their Matrix client. +# +#profile_key_import_mode = "on_registration" diff --git a/src/api/client/capabilities.rs b/src/api/client/capabilities.rs index 522cbbd1c..871786ab8 100644 --- a/src/api/client/capabilities.rs +++ b/src/api/client/capabilities.rs @@ -7,10 +7,11 @@ use ruma::{ api::client::discovery::get_capabilities::{ self, v3::{ - Capabilities, GetLoginTokenCapability, RoomVersionStability, RoomVersionsCapability, - ThirdPartyIdChangesCapability, + Capabilities, GetLoginTokenCapability, ProfileFieldsCapability, RoomVersionStability, + RoomVersionsCapability, ThirdPartyIdChangesCapability, }, }, + assign, }; use crate::Ruma; @@ -50,5 +51,9 @@ pub(crate) async fn get_capabilities_route( capabilities.account_moderation.suspend = true; } + capabilities.profile_fields = Some( + assign!(ProfileFieldsCapability::new(true), { disallowed: Some(services.oidc.restricted_profile_fields()) }), + ); + Ok(get_capabilities::v3::Response::new(capabilities)) } diff --git a/src/api/client/profile.rs b/src/api/client/profile.rs index a8dae57ff..5739f3781 100644 --- a/src/api/client/profile.rs +++ b/src/api/client/profile.rs @@ -63,6 +63,16 @@ pub(crate) async fn set_profile_field_route( return Err!(Request(InvalidParam("You may not change a remote user's profile data."))); } + if services + .oidc + .restricted_profile_fields() + .contains(&body.value.field_name()) + { + return Err!(Request(Forbidden( + "This profile field is controlled by your identity provider." + ))); + } + services .users .set_profile_field( @@ -93,6 +103,16 @@ pub(crate) async fn delete_profile_field_route( return Err!(Request(InvalidParam("You may not change a remote user's profile data."))); } + if services + .oidc + .restricted_profile_fields() + .contains(&body.field) + { + return Err!(Request(Forbidden( + "This profile field is controlled by your identity provider." + ))); + } + services .users .set_profile_field( diff --git a/src/core/config/mod.rs b/src/core/config/mod.rs index 6c458c98c..3a28ddabb 100644 --- a/src/core/config/mod.rs +++ b/src/core/config/mod.rs @@ -4,7 +4,7 @@ pub mod manager; pub mod proxy; use std::{ - collections::{BTreeMap, BTreeSet}, + collections::{BTreeMap, BTreeSet, HashMap}, net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}, path::PathBuf, }; @@ -22,6 +22,7 @@ use regex::RegexSet; use ruma::{ OwnedRoomId, OwnedRoomOrAliasId, OwnedServerName, OwnedUserId, RoomVersionId, api::client::{discovery::discover_support::ContactRole, rtc::RtcTransport}, + profile::ProfileFieldName, serde::Base64, }; use serde::{Deserialize, Serialize, de::IgnoredAny}; @@ -2436,6 +2437,23 @@ impl OauthConfig { } } +#[derive(Clone, Copy, Debug, Default, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum OAuthMode { + Disabled, + #[default] + Hybrid, + Exclusive, +} + +impl OAuthMode { + #[must_use] + pub fn uiaa_available(&self) -> bool { matches!(self, Self::Disabled | Self::Hybrid) } + + #[must_use] + pub fn oauth_available(&self) -> bool { matches!(self, Self::Hybrid | Self::Exclusive) } +} + #[derive(Clone, Debug, Deserialize)] #[config_example_generator( filename = "conduwuit-example.toml", @@ -2469,30 +2487,55 @@ pub struct OidcConfig { /// Whether the user should be prompted to choose a localpart /// when signing in for the first time. If this is `false`, Continuwuity - /// will attempt to use the value of the `preferred_username` claim - /// returned from the IDP as the user's localpart, and authentication will + /// will attempt to use the value of the `preferred_username_claim` + /// (see below) as the user's localpart. Authentication will /// fail if this claim is missing or is not a valid localpart. /// - /// default: false - #[serde(default)] + /// default: true + #[serde(default = "true_fn")] pub prompt_for_localpart: bool, + + /// The claim to use for the user's localpart, if `prompt_for_localpart` is + /// false. + /// + /// default: "preferred_username" + #[serde(default = "default_preferred_username_claim")] + pub preferred_username_claim: String, + + /// Defines how claims returned from the IDP should be mapped to a user's + /// profile data. The profile field named in each key will be set from the + /// claim named in the corresponding value when the user first registers, + /// and possibly on subsequent logins as well, depending on the value of + /// `profile_key_import_mode` (see below). + /// + /// SECURITY NOTE: If the `avatar_url` field is set, Continuwuity will + /// perform a HTTP GET to the URL in the mapped claim and use the returned + /// file as the user's profile picture. Make sure your users are not able + /// to set the value of the mapped claim to an arbitrary URL. + /// + /// default: { displayname = "name" } + #[serde(default = "default_profile_key_map")] + pub profile_key_map: HashMap, + + /// When profile keys should be imported from the IDP's claims. + /// + /// - "on_registration": Listed keys will be imported once, when the user + /// registers. + /// - "on_login": Listed keys will be imported every time the user logs in. + /// Additionally, users will not be able to manually edit the keys through + /// their Matrix client. + /// + /// default: "on_registration" + #[serde(default)] + pub profile_key_import_mode: OidcProfileKeyImportMode, } -#[derive(Clone, Copy, Debug, Default, Deserialize)] +#[derive(Clone, Debug, Deserialize, Default)] #[serde(rename_all = "snake_case")] -pub enum OAuthMode { - Disabled, +pub enum OidcProfileKeyImportMode { #[default] - Hybrid, - Exclusive, -} - -impl OAuthMode { - #[must_use] - pub fn uiaa_available(&self) -> bool { matches!(self, Self::Disabled | Self::Hybrid) } - - #[must_use] - pub fn oauth_available(&self) -> bool { matches!(self, Self::Hybrid | Self::Exclusive) } + OnRegistration, + OnLogin, } const DEPRECATED_KEYS: &[&str] = &[ @@ -2879,3 +2922,9 @@ fn default_client_shutdown_timeout() -> u64 { 15 } fn default_sender_shutdown_timeout() -> u64 { 5 } fn default_terms_language() -> String { "en".to_owned() } + +fn default_preferred_username_claim() -> String { "preferred_username".to_owned() } + +fn default_profile_key_map() -> HashMap { + HashMap::from_iter([(ProfileFieldName::DisplayName.to_string(), "name".to_owned())]) +} diff --git a/src/database/maps.rs b/src/database/maps.rs index e47c54835..40336ee17 100644 --- a/src/database/maps.rs +++ b/src/database/maps.rs @@ -128,6 +128,10 @@ pub(super) static MAPS: &[Descriptor] = &[ name: "openidsubject_localpart", ..descriptor::RANDOM_SMALL }, + Descriptor { + name: "openidsubject_currentpictureurl", + ..descriptor::RANDOM_SMALL + }, Descriptor { name: "fallbackkeyid_fallbackkey", ..descriptor::RANDOM_SMALL diff --git a/src/service/media/preview.rs b/src/service/media/preview.rs index 54b321fba..2d08e793a 100644 --- a/src/service/media/preview.rs +++ b/src/service/media/preview.rs @@ -211,7 +211,6 @@ impl Service { Ok(preview_data) } - #[cfg(feature = "url_preview")] pub async fn download_media(&self, url: &str) -> Result<(OwnedMxcUri, usize)> { use conduwuit::utils::random_string; use http::header::CONTENT_TYPE; @@ -268,11 +267,6 @@ impl Service { Err!(FeatureDisabled("url_preview")) } - #[cfg(not(feature = "url_preview"))] - pub async fn download_media(&self, _url: &str) -> Result { - Err!(FeatureDisabled("url_preview")) - } - #[cfg(feature = "url_preview")] async fn download_html(&self, url: &str) -> Result { use webpage::HTML; diff --git a/src/service/oidc/mod.rs b/src/service/oidc/mod.rs index 2adeb2259..2cf8afbef 100644 --- a/src/service/oidc/mod.rs +++ b/src/service/oidc/mod.rs @@ -1,58 +1,104 @@ -use std::{sync::Arc, time::Duration}; +use std::{collections::HashMap, sync::Arc, time::Duration}; use async_trait::async_trait; -use conduwuit::{Result, config::OidcConfig, err, error, info}; +use conduwuit::{ + Result, + config::{OidcConfig, OidcProfileKeyImportMode}, + debug, err, error, info, warn, +}; use database::{Deserialized, Map}; use openidconnect::{ - AuthorizationCode, CsrfToken, EndpointMaybeSet, EndpointNotSet, EndpointSet, IssuerUrl, - Nonce, PkceCodeChallenge, PkceCodeVerifier, RedirectUrl, TokenResponse, + AdditionalClaims, AuthorizationCode, CsrfToken, EmptyExtraTokenFields, EndpointMaybeSet, + EndpointNotSet, EndpointSet, IdTokenClaims, IdTokenFields, IssuerUrl, Nonce, + PkceCodeChallenge, PkceCodeVerifier, RedirectUrl, StandardErrorResponse, + StandardTokenResponse, TokenResponse, core::{ - CoreAuthPrompt, CoreAuthenticationFlow, CoreClient, CoreIdTokenClaims, - CoreProviderMetadata, + CoreAuthDisplay, CoreAuthPrompt, CoreAuthenticationFlow, CoreErrorResponseType, + CoreGenderClaim, CoreJsonWebKey, CoreJweContentEncryptionAlgorithm, + CoreJwsSigningAlgorithm, CoreProviderMetadata, CoreRevocableToken, + CoreRevocationErrorResponse, CoreTokenIntrospectionResponse, CoreTokenType, }, reqwest, }; -use ruma::{OwnedUserId, UserId}; +use ruma::{ + OwnedUserId, UserId, + api::client::profile::PropagateTo, + profile::{ProfileFieldName, ProfileFieldValue}, +}; use serde::{Deserialize, Serialize}; -use tokio::sync::SetOnce; +use serde_json::Value; +use tokio::{runtime, sync::SetOnce}; use url::Url; use crate::{ - Dep, config, globals, + Dep, config, globals, media, oauth::grant::AuthorizationCodeResponse, - users::{self, AccountStatus}, + users::{self, AccountStatus, ProfileFieldChange}, }; pub struct Service { services: Services, + runtime: runtime::Handle, db: Data, client: Option, } struct Data { openidsubject_localpart: Arc, + openidsubject_currentpictureurl: Arc, } struct Services { config: Dep, globals: Dep, + media: Dep, users: Dep, } struct OidcClient { config: OidcConfig, - machine: SetOnce< - CoreClient< - EndpointSet, - EndpointNotSet, - EndpointNotSet, - EndpointNotSet, - EndpointMaybeSet, - EndpointMaybeSet, - >, - >, + machine: SetOnce, client: reqwest::Client, } +type OidcClientMachine = openidconnect::Client< + AllClaims, + CoreAuthDisplay, + CoreGenderClaim, + CoreJweContentEncryptionAlgorithm, + CoreJsonWebKey, + CoreAuthPrompt, + StandardErrorResponse, + StandardTokenResponse< + IdTokenFields< + AllClaims, + EmptyExtraTokenFields, + CoreGenderClaim, + CoreJweContentEncryptionAlgorithm, + CoreJwsSigningAlgorithm, + >, + CoreTokenType, + >, + CoreTokenIntrospectionResponse, + CoreRevocableToken, + CoreRevocationErrorResponse, + EndpointSet, + EndpointNotSet, + EndpointNotSet, + EndpointNotSet, + EndpointMaybeSet, + EndpointMaybeSet, +>; + +pub type Claims = IdTokenClaims; + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct AllClaims { + #[serde(flatten)] + pub claims: HashMap, +} + +impl AdditionalClaims for AllClaims {} + #[derive(Debug, Deserialize, Serialize)] pub struct PendingSession { pkce_verifier: PkceCodeVerifier, @@ -72,10 +118,13 @@ impl crate::Service for Service { services: Services { config: args.depend::("config"), globals: args.depend::("globals"), + media: args.depend::("media"), users: args.depend::("users"), }, + runtime: args.server.runtime().clone(), db: Data { openidsubject_localpart: args.db["openidsubject_localpart"].clone(), + openidsubject_currentpictureurl: args.db["openidsubject_currentpictureurl"].clone(), }, client: args.server.config.oauth.oidc.as_ref().map(|config| OidcClient { config: config.clone(), @@ -114,7 +163,7 @@ impl crate::Service for Service { machine .set( - CoreClient::from_provider_metadata( + OidcClientMachine::from_provider_metadata( provider_metadata, config.client_id.clone(), Some(config.client_secret.clone()), @@ -136,6 +185,20 @@ impl Service { pub fn enabled(&self) -> bool { self.client.is_some() } + pub fn restricted_profile_fields(&self) -> Vec { + if let Some(config) = self.client.as_ref().map(|client| &client.config) + && matches!(config.profile_key_import_mode, OidcProfileKeyImportMode::OnLogin) + { + config + .profile_key_map + .keys() + .map(|key| ProfileFieldName::from(key.as_str())) + .collect() + } else { + vec![] + } + } + pub async fn begin_session(&self, prompt: Option) -> (PendingSession, Url) { let OidcClient { machine, .. } = self.client.as_ref().expect("oidc should be configured"); let machine = machine.wait().await; @@ -163,7 +226,7 @@ impl Service { &self, session: PendingSession, response: AuthorizationCodeResponse, - ) -> Result { + ) -> Result { let Some(OidcClient { machine, client, .. }) = self.client.as_ref() else { return Err("Delegated authentication is not enabled on this server."); }; @@ -203,15 +266,25 @@ impl Service { Ok(claims) } + #[tracing::instrument(skip(self, claims), fields(subject = claims.subject().to_string()))] pub async fn complete_session( &self, - claims: &CoreIdTokenClaims, + claims: &Claims, supplied_user_id: Option, ) -> Result { let Some(OidcClient { config, .. }) = self.client.as_ref() else { return Err("Delegated authentication is not enabled on this server."); }; + // this is a truly awful hack but we really need all the claims in a map + let all_claims = serde_json::to_value(claims) + .expect("should be able to serialize claims") + .as_object() + .expect("claims should be an object") + .to_owned(); + + debug!(?all_claims); + let subject = claims.subject().as_str(); let user_id = if let Ok(localpart) = self @@ -229,10 +302,15 @@ impl Service { } else { return Ok(SessionCompletionStatus::NeedsUserId); } - } else if let Some(preferred_username) = claims.preferred_username() { + } else if let Some(preferred_username) = all_claims.get(&config.preferred_username_claim) + { self.services .users - .determine_registration_user_id(Some(preferred_username.to_string()), None, None) + .determine_registration_user_id( + Some(preferred_username.as_str().unwrap().to_owned()), + None, + None, + ) .await .map_err(|err| { error!("Preferred username claim is not a valid localpart: {err}"); @@ -246,9 +324,11 @@ impl Service { info!(?subject, ?user_id, "User {user_id} successfully authorized with OIDC"); - match self.services.users.status(&user_id).await { + // Create a shadow account for the user if necessary + let new_account_registered = match self.services.users.status(&user_id).await { | AccountStatus::Active => { // Do nothing, an account already exists + false }, | AccountStatus::NotFound => { // Create a new shadow user @@ -262,14 +342,98 @@ impl Service { })?; info!(?subject, ?user_id, "Shadow user created for {user_id}"); + true }, | AccountStatus::Deactivated => { return Err("Your account has been deactivated."); }, - } + }; self.link_user(&user_id, subject); + // Import profile fields + if matches!(config.profile_key_import_mode, OidcProfileKeyImportMode::OnLogin) + || (matches!( + config.profile_key_import_mode, + OidcProfileKeyImportMode::OnRegistration + ) && new_account_registered) + { + let user_id = user_id.clone(); + let subject = claims.subject().to_string(); + let profile_key_map = config.profile_key_map.clone(); + let openidsubject_currentpictureurl = self.db.openidsubject_currentpictureurl.clone(); + let users = self.services.users.clone(); + let media = self.services.media.clone(); + + let import_task = self.runtime.spawn(async move { + for (field, claim) in &profile_key_map { + let Some(value) = all_claims.get(claim).cloned() else { + warn!(?field, ?claim, "IDP provided no value for this mapped claim"); + continue; + }; + + let value = if let Some(picture) = value.as_str() + && field == ProfileFieldName::AvatarUrl.as_str() + && openidsubject_currentpictureurl + .get(&subject) + .await + .deserialized::() + .ok() + .is_none_or(|current_picture| current_picture != picture) + { + match media.download_media(picture).await { + | Ok((mxc, size)) => { + openidsubject_currentpictureurl.insert(&subject, picture); + info!(?mxc, ?size, "Downloaded profile picture"); + + ProfileFieldValue::AvatarUrl(mxc) + }, + | Err(err) => { + warn!( + ?claim, + ?picture, + "Failed to download profile picture: {err}" + ); + continue; + }, + } + } else { + match ProfileFieldValue::new(field, value.clone()) { + | Ok(value) => value, + | Err(err) => { + warn!( + ?field, + ?claim, + ?value, + "Failed to parse claim value for profile field: {err}" + ); + continue; + }, + } + }; + + if let Err(err) = users + .set_profile_field( + &user_id, + ProfileFieldChange::Set(value), + PropagateTo::Unchanged, + ) + .await + { + warn!(?field, ?claim, "Error while setting profile field: {err}"); + } + } + + info!("Profile import complete"); + }); + + // Only wait for import to complete if this is a new account, + // so they see the correct profile information in the account panel + if new_account_registered { + let _ = import_task.await; + } + } + Ok(SessionCompletionStatus::Complete(user_id)) } diff --git a/src/service/users/profile.rs b/src/service/users/profile.rs index 2c5444795..2ea869312 100644 --- a/src/service/users/profile.rs +++ b/src/service/users/profile.rs @@ -1,4 +1,4 @@ -use std::collections::BTreeMap; +use std::{borrow::Cow, collections::BTreeMap}; use conduwuit::{ Err, Result, @@ -29,9 +29,9 @@ impl ProfileFieldChange { } } - fn value(&self) -> Option { + fn value(&self) -> Option> { if let Self::Set(value) = self { - Some(value.value().into_owned()) + Some(value.value()) } else { None } @@ -92,6 +92,15 @@ impl super::Service { } } + // Check if this change would be a no-op + if self + .get_local_profile_field(user_id, field_name.clone()) + .await + .is_some_and(|value| Some(value.value()) == change.value()) + { + return Ok(()); + } + // If the user is local and changed their displayname or avatar_url, update it // in all their joined rooms. This is done before updating their profile data // so we can check the old value of the field if `propagate_to` is `unchanged`. @@ -192,7 +201,11 @@ impl super::Service { | ProfileFieldChange::Delete(ProfileFieldName::AvatarUrl) => { self.set_avatar_url(user_id, None); }, - | other => self.set_profile_key(user_id, other.field_name().as_str(), other.value()), + | other => self.set_profile_key( + user_id, + other.field_name().as_str(), + other.value().map(Cow::into_owned), + ), } Ok(()) diff --git a/src/web/pages/oidc/mod.rs b/src/web/pages/oidc/mod.rs index a53a97930..74e73e59b 100644 --- a/src/web/pages/oidc/mod.rs +++ b/src/web/pages/oidc/mod.rs @@ -1,6 +1,5 @@ use axum::Router; -use conduwuit_service::oidc; -use openidconnect::core::CoreIdTokenClaims; +use conduwuit_service::oidc::{self, Claims}; use ruma::OwnedUserId; use serde::{Deserialize, Serialize}; @@ -23,7 +22,7 @@ pub(crate) enum OidcSessionState { session: oidc::PendingSession, }, Authorized { - claims: Box, + claims: Box, }, } From 3d004b1923ff83343104534abcd320c670632743 Mon Sep 17 00:00:00 2001 From: Ginger Date: Tue, 30 Jun 2026 21:22:06 -0400 Subject: [PATCH 11/26] fix: Adjust OIDC config section comment position --- conduwuit-example.toml | 8 ++++---- src/core/config/mod.rs | 8 ++++---- src/macros/config.rs | 5 +++++ 3 files changed, 13 insertions(+), 8 deletions(-) diff --git a/conduwuit-example.toml b/conduwuit-example.toml index 5521acde4..9669ecbb3 100644 --- a/conduwuit-example.toml +++ b/conduwuit-example.toml @@ -2032,6 +2032,8 @@ # This item is undocumented. Please contribute documentation for it. # #oidc = + +#[global.oauth.oidc] # Uncommenting this section will enable Continuwuity's support for # authenticating users using an OpenID Connect-compatible identity provider. # This is referred to as "delegated authentication". @@ -2040,8 +2042,6 @@ # the `global.oauth.compatibility_mode` setting is set to `exclusive`. # Matrix clients which do not support OAuth login (also referred to as "next-gen auth") will NOT be able # to log in while delegated authentication is active. -#[global.oauth.oidc] - # The OIDC issuer URL. Continuwuity will use OpenID Connect Discovery to # automatically fetch the identity provider's metadata from this URL. # Generally you should set this to the base domain your identity provider @@ -2090,7 +2090,7 @@ # - "on_registration": Listed keys will be imported once, when the user # registers. # - "on_login": Listed keys will be imported every time the user logs in. -# Additionally, users will not be able to manually edit the keys through -# their Matrix client. +# Additionally, users will not be able to manually edit any listed keys +# through their Matrix client. # #profile_key_import_mode = "on_registration" diff --git a/src/core/config/mod.rs b/src/core/config/mod.rs index 3a28ddabb..d940b52d7 100644 --- a/src/core/config/mod.rs +++ b/src/core/config/mod.rs @@ -2459,7 +2459,7 @@ impl OAuthMode { filename = "conduwuit-example.toml", section = "global.oauth.oidc", optional = "true", - header = "\ + subheader = "\ # Uncommenting this section will enable Continuwuity's support for # authenticating users using an OpenID Connect-compatible identity provider. # This is referred to as \"delegated authentication\". @@ -2467,7 +2467,7 @@ impl OAuthMode { # IMPORTANT NOTE: When delegated authentication is active, Continuwuity will behave as if # the `global.oauth.compatibility_mode` setting is set to `exclusive`. # Matrix clients which do not support OAuth login (also referred to as \"next-gen auth\") will \ - NOT be able + NOT be able # to log in while delegated authentication is active." )] pub struct OidcConfig { @@ -2522,8 +2522,8 @@ pub struct OidcConfig { /// - "on_registration": Listed keys will be imported once, when the user /// registers. /// - "on_login": Listed keys will be imported every time the user logs in. - /// Additionally, users will not be able to manually edit the keys through - /// their Matrix client. + /// Additionally, users will not be able to manually edit any listed keys + /// through their Matrix client. /// /// default: "on_registration" #[serde(default)] diff --git a/src/macros/config.rs b/src/macros/config.rs index b0b42eddd..1f8285074 100644 --- a/src/macros/config.rs +++ b/src/macros/config.rs @@ -81,6 +81,11 @@ fn generate_example(input: &ItemStruct, args: &[Meta], write: bool) -> Result = Vec::new(); From f66c1d8f3551a84715b9bf3bfc7e9ad758e1d5c3 Mon Sep 17 00:00:00 2001 From: Ginger Date: Tue, 30 Jun 2026 21:58:36 -0400 Subject: [PATCH 12/26] fix: Hide bare oidc config option --- conduwuit-example.toml | 4 ---- src/core/config/mod.rs | 1 + 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/conduwuit-example.toml b/conduwuit-example.toml index 9669ecbb3..427131c64 100644 --- a/conduwuit-example.toml +++ b/conduwuit-example.toml @@ -2029,10 +2029,6 @@ # #compatibility_mode = "hybrid" -# This item is undocumented. Please contribute documentation for it. -# -#oidc = - #[global.oauth.oidc] # Uncommenting this section will enable Continuwuity's support for # authenticating users using an OpenID Connect-compatible identity provider. diff --git a/src/core/config/mod.rs b/src/core/config/mod.rs index d940b52d7..f993b0adb 100644 --- a/src/core/config/mod.rs +++ b/src/core/config/mod.rs @@ -2423,6 +2423,7 @@ pub struct OauthConfig { /// default: "hybrid" compatibility_mode: OAuthMode, + /// display: hidden pub oidc: Option, } From 3282ba8eab47a20b8be9b519e2e5cd99c5685f36 Mon Sep 17 00:00:00 2001 From: Ginger Date: Wed, 1 Jul 2026 15:17:10 -0400 Subject: [PATCH 13/26] fix: Miscellaneous fixes --- src/core/config/mod.rs | 5 ++++- src/service/oidc/mod.rs | 22 ++++++++++------------ src/service/users/remote.rs | 2 +- src/web/pages/account/login.rs | 4 ---- 4 files changed, 15 insertions(+), 18 deletions(-) diff --git a/src/core/config/mod.rs b/src/core/config/mod.rs index f993b0adb..5f67e4623 100644 --- a/src/core/config/mod.rs +++ b/src/core/config/mod.rs @@ -2509,6 +2509,9 @@ pub struct OidcConfig { /// and possibly on subsequent logins as well, depending on the value of /// `profile_key_import_mode` (see below). /// + /// Per-room overrides to the user's display name or avatar will be + /// preserved by the import process. + /// /// SECURITY NOTE: If the `avatar_url` field is set, Continuwuity will /// perform a HTTP GET to the URL in the mapped claim and use the returned /// file as the user's profile picture. Make sure your users are not able @@ -2521,7 +2524,7 @@ pub struct OidcConfig { /// When profile keys should be imported from the IDP's claims. /// /// - "on_registration": Listed keys will be imported once, when the user - /// registers. + /// logs in for the first time and their shadow account is created. /// - "on_login": Listed keys will be imported every time the user logs in. /// Additionally, users will not be able to manually edit any listed keys /// through their Matrix client. diff --git a/src/service/oidc/mod.rs b/src/service/oidc/mod.rs index 2cf8afbef..9b288c24a 100644 --- a/src/service/oidc/mod.rs +++ b/src/service/oidc/mod.rs @@ -302,15 +302,13 @@ impl Service { } else { return Ok(SessionCompletionStatus::NeedsUserId); } - } else if let Some(preferred_username) = all_claims.get(&config.preferred_username_claim) + } else if let Some(preferred_username) = all_claims + .get(&config.preferred_username_claim) + .and_then(|claim| claim.as_str()) { self.services .users - .determine_registration_user_id( - Some(preferred_username.as_str().unwrap().to_owned()), - None, - None, - ) + .determine_registration_user_id(Some(preferred_username.to_owned()), None, None) .await .map_err(|err| { error!("Preferred username claim is not a valid localpart: {err}"); @@ -372,26 +370,26 @@ impl Service { continue; }; - let value = if let Some(picture) = value.as_str() + let value = if let Some(picture_url) = value.as_str() && field == ProfileFieldName::AvatarUrl.as_str() && openidsubject_currentpictureurl .get(&subject) .await .deserialized::() .ok() - .is_none_or(|current_picture| current_picture != picture) + .is_none_or(|current_picture| current_picture != picture_url) { - match media.download_media(picture).await { + match media.download_media(picture_url).await { | Ok((mxc, size)) => { - openidsubject_currentpictureurl.insert(&subject, picture); - info!(?mxc, ?size, "Downloaded profile picture"); + openidsubject_currentpictureurl.insert(&subject, picture_url); + info!(?picture_url, ?mxc, ?size, "Downloaded profile picture"); ProfileFieldValue::AvatarUrl(mxc) }, | Err(err) => { warn!( ?claim, - ?picture, + ?picture_url, "Failed to download profile picture: {err}" ); continue; diff --git a/src/service/users/remote.rs b/src/service/users/remote.rs index 21be89e6e..c2c77967c 100644 --- a/src/service/users/remote.rs +++ b/src/service/users/remote.rs @@ -5,7 +5,7 @@ use ruma::{OwnedUserId, UserId}; impl super::Service { /// Record the existence of a remote user. pub fn record_remote_user(&self, user_id: &UserId) { - assert!(self.services.globals.user_is_local(user_id), "user is not remote"); + assert!(!self.services.globals.user_is_local(user_id), "user is not remote"); self.db.remoteuserid_remoteuser.insert(user_id, ""); } diff --git a/src/web/pages/account/login.rs b/src/web/pages/account/login.rs index c2079ac1e..b92e775bb 100644 --- a/src/web/pages/account/login.rs +++ b/src/web/pages/account/login.rs @@ -99,10 +99,6 @@ async fn route_login( .await .expect("should be able to serialize OIDC session"); - if next.is_some() { - return response!(Redirect::to(redirect_url.as_str())); - } - LoginType::Oidc { redirect_url } } else { let (trusted_flow_status, untrusted_flow_status) = From 786890baa059e7d127b0e45c81dad87f3ea1d54f Mon Sep 17 00:00:00 2001 From: Ginger Date: Wed, 1 Jul 2026 15:40:23 -0400 Subject: [PATCH 14/26] feat: Add support for importing email addresses from the IDP --- conduwuit-example.toml | 18 +++++++++++++++++- src/core/config/mod.rs | 14 ++++++++++++++ src/service/oidc/mod.rs | 28 ++++++++++++++++++++++++++-- src/service/threepid/mod.rs | 11 +++++++++-- 4 files changed, 66 insertions(+), 5 deletions(-) diff --git a/conduwuit-example.toml b/conduwuit-example.toml index 427131c64..cd93719ae 100644 --- a/conduwuit-example.toml +++ b/conduwuit-example.toml @@ -2068,12 +2068,28 @@ # #preferred_username_claim = "preferred_username" +# The claim which will be used to set the user's email address, +# either on initial registration or on every login depending on +# the value of `profile_key_import_mode`. Continuwuity assumes that +# the IDP has taken care of verifying that the user controls the email +# address it provides. +# +# This option does nothing if SMTP is not configured. +# +# If this option is set, and `profile_key_import_mode` is `on_login`, +# users will not be able to change their email addresses themselves. +# +#email_claim = "email" + # Defines how claims returned from the IDP should be mapped to a user's # profile data. The profile field named in each key will be set from the # claim named in the corresponding value when the user first registers, # and possibly on subsequent logins as well, depending on the value of # `profile_key_import_mode` (see below). # +# Per-room overrides to the user's display name or avatar will be +# preserved by the import process. +# # SECURITY NOTE: If the `avatar_url` field is set, Continuwuity will # perform a HTTP GET to the URL in the mapped claim and use the returned # file as the user's profile picture. Make sure your users are not able @@ -2084,7 +2100,7 @@ # When profile keys should be imported from the IDP's claims. # # - "on_registration": Listed keys will be imported once, when the user -# registers. +# logs in for the first time and their shadow account is created. # - "on_login": Listed keys will be imported every time the user logs in. # Additionally, users will not be able to manually edit any listed keys # through their Matrix client. diff --git a/src/core/config/mod.rs b/src/core/config/mod.rs index 5f67e4623..0b9342713 100644 --- a/src/core/config/mod.rs +++ b/src/core/config/mod.rs @@ -2503,6 +2503,20 @@ pub struct OidcConfig { #[serde(default = "default_preferred_username_claim")] pub preferred_username_claim: String, + /// The claim which will be used to set the user's email address, + /// either on initial registration or on every login depending on + /// the value of `profile_key_import_mode`. Continuwuity assumes that + /// the IDP has taken care of verifying that the user controls the email + /// address it provides. + /// + /// This option does nothing if SMTP is not configured. + /// + /// If this option is set, and `profile_key_import_mode` is `on_login`, + /// users will not be able to change their email addresses themselves. + /// + /// default: "email" + pub email_claim: Option, + /// Defines how claims returned from the IDP should be mapped to a user's /// profile data. The profile field named in each key will be set from the /// claim named in the corresponding value when the user first registers, diff --git a/src/service/oidc/mod.rs b/src/service/oidc/mod.rs index 9b288c24a..15fa8ba5b 100644 --- a/src/service/oidc/mod.rs +++ b/src/service/oidc/mod.rs @@ -1,4 +1,4 @@ -use std::{collections::HashMap, sync::Arc, time::Duration}; +use std::{collections::HashMap, str::FromStr, sync::Arc, time::Duration}; use async_trait::async_trait; use conduwuit::{ @@ -7,6 +7,7 @@ use conduwuit::{ debug, err, error, info, warn, }; use database::{Deserialized, Map}; +use lettre::Address; use openidconnect::{ AdditionalClaims, AuthorizationCode, CsrfToken, EmptyExtraTokenFields, EndpointMaybeSet, EndpointNotSet, EndpointSet, IdTokenClaims, IdTokenFields, IssuerUrl, Nonce, @@ -33,6 +34,7 @@ use url::Url; use crate::{ Dep, config, globals, media, oauth::grant::AuthorizationCodeResponse, + threepid, users::{self, AccountStatus, ProfileFieldChange}, }; @@ -51,6 +53,7 @@ struct Services { config: Dep, globals: Dep, media: Dep, + threepid: Dep, users: Dep, } @@ -119,6 +122,7 @@ impl crate::Service for Service { config: args.depend::("config"), globals: args.depend::("globals"), media: args.depend::("media"), + threepid: args.depend::("threepid"), users: args.depend::("users"), }, runtime: args.server.runtime().clone(), @@ -316,7 +320,7 @@ impl Service { your homeserver's administrator." })? } else { - error!("No preferred username claim was present"); + error!("Preferred username claim was not present or was not a string"); return Err(Self::SERVER_MISCONFIGURED); }; @@ -356,6 +360,26 @@ impl Service { OidcProfileKeyImportMode::OnRegistration ) && new_account_registered) { + if let Some(email_claim) = &config.email_claim { + if let Some(email) = claims.email().map(|email| email.as_str()) + && let Ok(address) = Address::from_str(email) + { + if let Err(err) = self + .services + .threepid + .associate_localpart_email(user_id.localpart(), &address) + .await + { + warn!(?email_claim, ?address, "Failed to associate email address: {err}"); + } + } else { + warn!( + ?email_claim, + "Email claim was not present or was not a valid email address" + ); + } + } + let user_id = user_id.clone(); let subject = claims.subject().to_string(); let profile_key_map = config.profile_key_map.clone(); diff --git a/src/service/threepid/mod.rs b/src/service/threepid/mod.rs index 5fbebab31..98d2387e7 100644 --- a/src/service/threepid/mod.rs +++ b/src/service/threepid/mod.rs @@ -1,6 +1,6 @@ use std::{borrow::Cow, collections::HashMap, sync::Arc}; -use conduwuit::{Err, Error, Result, result::FlatOk}; +use conduwuit::{Err, Error, Result, config::OidcProfileKeyImportMode, result::FlatOk}; use database::{Deserialized, Map}; use governor::{DefaultKeyedRateLimiter, Quota, RateLimiter}; use lettre::{Address, message::Mailbox}; @@ -87,7 +87,14 @@ impl Service { /// Check if users are required to have an email address. pub fn email_requirement(&self) -> EmailRequirement { if let Some(smtp) = &self.services.config.smtp { - if smtp.require_email_for_registration || smtp.require_email_for_token_registration { + if let Some(oidc) = &self.services.config.oauth.oidc + && matches!(oidc.profile_key_import_mode, OidcProfileKeyImportMode::OnLogin) + && oidc.email_claim.is_some() + { + EmailRequirement::Unavailable + } else if smtp.require_email_for_registration + || smtp.require_email_for_token_registration + { EmailRequirement::Required } else { EmailRequirement::Optional From 52a4b9d58625b0977a67e73053420fd24bcd2e14 Mon Sep 17 00:00:00 2001 From: Ginger Date: Wed, 1 Jul 2026 15:44:59 -0400 Subject: [PATCH 15/26] chore: News fragment --- changelog.d/765.feature.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/765.feature.md diff --git a/changelog.d/765.feature.md b/changelog.d/765.feature.md new file mode 100644 index 000000000..906c316c0 --- /dev/null +++ b/changelog.d/765.feature.md @@ -0,0 +1 @@ +Added support for linking an external identity provider with OIDC. Contributed by @ginger. From 28263982cd88477aa35fb7346f9c4a552b7e7331 Mon Sep 17 00:00:00 2001 From: Ginger Date: Wed, 1 Jul 2026 16:46:58 -0400 Subject: [PATCH 16/26] feat: Add support for requesting additional scopes --- conduwuit-example.toml | 6 ++++++ src/core/config/mod.rs | 10 +++++++++- src/service/oidc/mod.rs | 4 +++- 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/conduwuit-example.toml b/conduwuit-example.toml index cd93719ae..fa417e9f4 100644 --- a/conduwuit-example.toml +++ b/conduwuit-example.toml @@ -2055,6 +2055,12 @@ # #client_secret = +# Additional scopes Continuwuity should request from the IDP. This may be +# necessary to access certain claims. Continuwuity always requests the +# `openid` scope. +# +#additional_scopes = [] + # Whether the user should be prompted to choose a localpart # when signing in for the first time. If this is `false`, Continuwuity # will attempt to use the value of the `preferred_username_claim` diff --git a/src/core/config/mod.rs b/src/core/config/mod.rs index 0b9342713..7fe35c93f 100644 --- a/src/core/config/mod.rs +++ b/src/core/config/mod.rs @@ -17,7 +17,7 @@ use either::{ use figment::providers::{Env, Format, Toml}; pub use figment::{Figment, value::Value as FigmentValue}; use lettre::message::Mailbox; -use openidconnect::{ClientId, ClientSecret}; +use openidconnect::{ClientId, ClientSecret, Scope}; use regex::RegexSet; use ruma::{ OwnedRoomId, OwnedRoomOrAliasId, OwnedServerName, OwnedUserId, RoomVersionId, @@ -2486,6 +2486,14 @@ pub struct OidcConfig { /// the identity provider. pub client_secret: ClientSecret, + /// Additional scopes Continuwuity should request from the IDP. This may be + /// necessary to access certain claims. Continuwuity always requests the + /// `openid` scope. + /// + /// default: [] + #[serde(default)] + pub additional_scopes: Vec, + /// Whether the user should be prompted to choose a localpart /// when signing in for the first time. If this is `false`, Continuwuity /// will attempt to use the value of the `preferred_username_claim` diff --git a/src/service/oidc/mod.rs b/src/service/oidc/mod.rs index 15fa8ba5b..a1b100dc7 100644 --- a/src/service/oidc/mod.rs +++ b/src/service/oidc/mod.rs @@ -204,7 +204,8 @@ impl Service { } pub async fn begin_session(&self, prompt: Option) -> (PendingSession, Url) { - let OidcClient { machine, .. } = self.client.as_ref().expect("oidc should be configured"); + let OidcClient { machine, config, .. } = + self.client.as_ref().expect("oidc should be configured"); let machine = machine.wait().await; let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256(); @@ -215,6 +216,7 @@ impl Service { CsrfToken::new_random, Nonce::new_random, ) + .add_scopes(config.additional_scopes.iter().cloned()) .set_pkce_challenge(pkce_challenge); if let Some(prompt) = prompt { From 910925b906596c0f0d0891749133a68cac1025c1 Mon Sep 17 00:00:00 2001 From: Ginger Date: Wed, 1 Jul 2026 17:04:13 -0400 Subject: [PATCH 17/26] fix: Config file formatting --- conduwuit-example.toml | 2 ++ src/macros/config.rs | 1 + 2 files changed, 3 insertions(+) diff --git a/conduwuit-example.toml b/conduwuit-example.toml index fa417e9f4..cb3f87a32 100644 --- a/conduwuit-example.toml +++ b/conduwuit-example.toml @@ -2038,6 +2038,8 @@ # the `global.oauth.compatibility_mode` setting is set to `exclusive`. # Matrix clients which do not support OAuth login (also referred to as "next-gen auth") will NOT be able # to log in while delegated authentication is active. + + # The OIDC issuer URL. Continuwuity will use OpenID Connect Discovery to # automatically fetch the identity provider's metadata from this URL. # Generally you should set this to the base domain your identity provider diff --git a/src/macros/config.rs b/src/macros/config.rs index 1f8285074..c2e4818d9 100644 --- a/src/macros/config.rs +++ b/src/macros/config.rs @@ -85,6 +85,7 @@ fn generate_example(input: &ItemStruct, args: &[Meta], write: bool) -> Result Date: Thu, 2 Jul 2026 16:13:35 -0400 Subject: [PATCH 18/26] feat: Add support for reading the OIDC client secret from a file --- conduwuit-example.toml | 7 +++++++ src/core/config/check.rs | 9 +++++++++ src/core/config/mod.rs | 8 +++++++- src/service/oidc/mod.rs | 27 ++++++++++++++++++++------- 4 files changed, 43 insertions(+), 8 deletions(-) diff --git a/conduwuit-example.toml b/conduwuit-example.toml index cb3f87a32..a98a35639 100644 --- a/conduwuit-example.toml +++ b/conduwuit-example.toml @@ -2057,6 +2057,13 @@ # #client_secret = +# A path to a file which Continuwuity will read the client secret from. +# If this option is set, it will override `client_secret`. +# +# The server will fail to start if the file cannot be read. +# +#client_secret_file = + # Additional scopes Continuwuity should request from the IDP. This may be # necessary to access certain claims. Continuwuity always requests the # `openid` scope. diff --git a/src/core/config/check.rs b/src/core/config/check.rs index bc9878598..840413823 100644 --- a/src/core/config/check.rs +++ b/src/core/config/check.rs @@ -289,6 +289,15 @@ pub fn check(config: &Config) -> Result { | _ => (), } + if let Some(oidc) = &config.oauth.oidc + && oidc.client_secret.is_none() + && oidc.client_secret_file.is_none() + { + return Err!( + "Either `client_secret` or `client_secret_file` must be set if OIDC is configured." + ); + } + Ok(()) } diff --git a/src/core/config/mod.rs b/src/core/config/mod.rs index 7fe35c93f..e56dcbf0b 100644 --- a/src/core/config/mod.rs +++ b/src/core/config/mod.rs @@ -2484,7 +2484,13 @@ pub struct OidcConfig { /// The OAuth client secret for Continuwuity to use when communicating with /// the identity provider. - pub client_secret: ClientSecret, + pub client_secret: Option, + + /// A path to a file which Continuwuity will read the client secret from. + /// If this option is set, it will override `client_secret`. + /// + /// The server will fail to start if the file cannot be read. + pub client_secret_file: Option, /// Additional scopes Continuwuity should request from the IDP. This may be /// necessary to access certain claims. Continuwuity always requests the diff --git a/src/service/oidc/mod.rs b/src/service/oidc/mod.rs index a1b100dc7..507cbd828 100644 --- a/src/service/oidc/mod.rs +++ b/src/service/oidc/mod.rs @@ -9,9 +9,9 @@ use conduwuit::{ use database::{Deserialized, Map}; use lettre::Address; use openidconnect::{ - AdditionalClaims, AuthorizationCode, CsrfToken, EmptyExtraTokenFields, EndpointMaybeSet, - EndpointNotSet, EndpointSet, IdTokenClaims, IdTokenFields, IssuerUrl, Nonce, - PkceCodeChallenge, PkceCodeVerifier, RedirectUrl, StandardErrorResponse, + AdditionalClaims, AuthorizationCode, ClientSecret, CsrfToken, EmptyExtraTokenFields, + EndpointMaybeSet, EndpointNotSet, EndpointSet, IdTokenClaims, IdTokenFields, IssuerUrl, + Nonce, PkceCodeChallenge, PkceCodeVerifier, RedirectUrl, StandardErrorResponse, StandardTokenResponse, TokenResponse, core::{ CoreAuthDisplay, CoreAuthPrompt, CoreAuthenticationFlow, CoreErrorResponseType, @@ -59,6 +59,7 @@ struct Services { struct OidcClient { config: OidcConfig, + client_secret: ClientSecret, machine: SetOnce, client: reqwest::Client, } @@ -130,8 +131,19 @@ impl crate::Service for Service { openidsubject_localpart: args.db["openidsubject_localpart"].clone(), openidsubject_currentpictureurl: args.db["openidsubject_currentpictureurl"].clone(), }, - client: args.server.config.oauth.oidc.as_ref().map(|config| OidcClient { + client: args.server.config.oauth.oidc.as_ref().map(|config| -> Result { + Ok(OidcClient { config: config.clone(), + client_secret: if let Some(client_secret_file) = &config.client_secret_file { + std::fs::read_to_string(client_secret_file) + .map(ClientSecret::new) + .map_err(|err| err!("Failed to read OIDC client secret file: {err}"))? + } else if let Some(client_secret) = &config.client_secret { + client_secret.clone() + } else { + // The config check function should cause an early exit before this happens + panic!("neither client secret or client secret file were set"); + }, machine: SetOnce::new(), // This isn't in the client service because it has to use the `reqwest` shipped by `openidconnect` client: reqwest::ClientBuilder::new() @@ -145,12 +157,13 @@ impl crate::Service for Service { .danger_accept_invalid_certs(args.server.config.allow_invalid_tls_certificates_yes_i_know_what_the_fuck_i_am_doing_with_this_and_i_know_this_is_insecure) .build() .expect("client should build") - }), + })} + ).transpose()?, })) } async fn worker(self: Arc) -> Result { - if let Some(OidcClient { config, machine, client }) = &self.client { + if let Some(OidcClient { config, client_secret, machine, client }) = &self.client { let redirect_url = self .services .config @@ -170,7 +183,7 @@ impl crate::Service for Service { OidcClientMachine::from_provider_metadata( provider_metadata, config.client_id.clone(), - Some(config.client_secret.clone()), + Some(client_secret.clone()), ) .set_redirect_uri(RedirectUrl::from_url(redirect_url)), ) From da7d8c9821fb37504e6b8728b35285d4bb112597 Mon Sep 17 00:00:00 2001 From: Ginger Date: Thu, 2 Jul 2026 16:16:14 -0400 Subject: [PATCH 19/26] fix: Prevent register page from being accessible when OIDC is enabled --- src/web/pages/account/register.rs | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/web/pages/account/register.rs b/src/web/pages/account/register.rs index 0e23d3359..e47a84b3d 100644 --- a/src/web/pages/account/register.rs +++ b/src/web/pages/account/register.rs @@ -134,13 +134,15 @@ async fn route_register( ) -> Result { let is_first_run = services.firstrun.is_first_run(); - if session_store - .get::(User::KEY) - .await - .unwrap() - .is_some() + if services.oidc.enabled() + || session_store + .get::(User::KEY) + .await + .unwrap() + .is_some() { - // Redirect already logged-in users to the account panel + // Redirect to the account panel if the user is already logged in + // or OIDC is enabled return response!(Redirect::to(&LoginTarget::Account.target_path())); } From 514c3bbcbfc968e89b028fa1a1ac669dc0772e8f Mon Sep 17 00:00:00 2001 From: Ginger Date: Thu, 2 Jul 2026 16:57:21 -0400 Subject: [PATCH 20/26] fix: Properly show email in account page when OIDC email_claim is set --- src/api/client/account/threepid.rs | 4 ++++ src/service/threepid/mod.rs | 9 +++++++-- src/web/pages/account/email.rs | 4 ++++ src/web/pages/templates/account.html.j2 | 6 ++++-- 4 files changed, 19 insertions(+), 4 deletions(-) diff --git a/src/api/client/account/threepid.rs b/src/api/client/account/threepid.rs index dea02ea2c..3be31263b 100644 --- a/src/api/client/account/threepid.rs +++ b/src/api/client/account/threepid.rs @@ -25,6 +25,10 @@ pub(crate) async fn third_party_route( let sender_user = body.identity.expect_sender_user()?; let mut threepids = vec![]; + if !services.threepid.email_requirement().may_view() { + return Ok(get_3pids::v3::Response::new(vec![])); + } + if let Some(email) = services .threepid .get_email_for_localpart(sender_user.localpart()) diff --git a/src/service/threepid/mod.rs b/src/service/threepid/mod.rs index 98d2387e7..7913980da 100644 --- a/src/service/threepid/mod.rs +++ b/src/service/threepid/mod.rs @@ -33,7 +33,9 @@ pub enum EmailRequirement { Required, /// Users may change or remove their email. Optional, - /// Users may not change their email at all. + /// Users may see their email, but may not change it. + Locked, + /// Email features are disabled. Unavailable, } @@ -43,6 +45,9 @@ impl EmailRequirement { #[must_use] pub fn may_remove(&self) -> bool { matches!(self, Self::Optional) } + + #[must_use] + pub fn may_view(&self) -> bool { !matches!(self, Self::Unavailable) } } struct Data { @@ -91,7 +96,7 @@ impl Service { && matches!(oidc.profile_key_import_mode, OidcProfileKeyImportMode::OnLogin) && oidc.email_claim.is_some() { - EmailRequirement::Unavailable + EmailRequirement::Locked } else if smtp.require_email_for_registration || smtp.require_email_for_token_registration { diff --git a/src/web/pages/account/email.rs b/src/web/pages/account/email.rs index cc9b9e294..60baea17b 100644 --- a/src/web/pages/account/email.rs +++ b/src/web/pages/account/email.rs @@ -81,6 +81,10 @@ async fn route_change_email( ) -> Result { let user_id = user.expect_recent(LoginTarget::ChangeEmail)?; + if !services.threepid.email_requirement().may_change() { + return Err(WebError::Forbidden("You may not change your email address.".to_owned())); + } + let Some(form) = form else { return response!(ChangeEmail::new( context.clone(), diff --git a/src/web/pages/templates/account.html.j2 b/src/web/pages/templates/account.html.j2 index 396aadb9b..5cee286e9 100644 --- a/src/web/pages/templates/account.html.j2 +++ b/src/web/pages/templates/account.html.j2 @@ -17,14 +17,16 @@ Your account

{% endif %}
- {% if email_requirement.may_change() %} + {% if email_requirement.may_view() %}

{% if let Some(email) = email %} Your account's associated email address is {{ email }}. {% else %} Your account has no associated email address. {% endif %} - Change your email + {% if email_requirement.may_change() %} + Change your email\ + {% endif %}

{% endif %} {% if !oidc_enabled %} From f1b77109840c0ee1efbff3ff493196c4a7a59562 Mon Sep 17 00:00:00 2001 From: Ginger Date: Thu, 2 Jul 2026 17:13:14 -0400 Subject: [PATCH 21/26] fix: Mobile styling improvements --- src/web/pages/resources/common.css | 14 ++------------ .../templates/_components/device_card.html.j2 | 2 +- 2 files changed, 3 insertions(+), 13 deletions(-) diff --git a/src/web/pages/resources/common.css b/src/web/pages/resources/common.css index f3307c72d..9a03e7c59 100644 --- a/src/web/pages/resources/common.css +++ b/src/web/pages/resources/common.css @@ -341,18 +341,8 @@ ul.bullet-separated { margin-top: 0; } - ul.bullet-separated { - display: block; - padding-left: inherit; - - li { - display: list-item; - list-style-type: disc; - - &::before { - content: none !important; - } - } + .mobile-hidden { + display: none; } } diff --git a/src/web/pages/templates/_components/device_card.html.j2 b/src/web/pages/templates/_components/device_card.html.j2 index b8ecfc6b6..cc6c4f372 100644 --- a/src/web/pages/templates/_components/device_card.html.j2 +++ b/src/web/pages/templates/_components/device_card.html.j2 @@ -11,7 +11,7 @@ {% if style == DeviceCardStyle::Detailed %} - • + •
  • {{ device_id }}
  • From 54b63327d5069a4e95834ea41db9c4f64703fcb6 Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Fri, 3 Jul 2026 05:03:25 +0000 Subject: [PATCH 22/26] chore(deps): update rust crate arrayvec to v0.7.8 --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f6e1f5bcf..3e19af6e4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -130,9 +130,9 @@ checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236" [[package]] name = "arrayvec" -version = "0.7.7" +version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f02882884d3e1bc524fb12c79f107f6ad0e1cfd498c536ffb494301740995dfe" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" dependencies = [ "serde", ] From 649f16aa149ad91dccadb7c8af111b53312ec4eb Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Sat, 4 Jul 2026 05:03:12 +0000 Subject: [PATCH 23/26] chore(deps): update node-patch-updates to v2.0.16 --- package-lock.json | 50 +++++++++++++++++++++++------------------------ 1 file changed, 25 insertions(+), 25 deletions(-) diff --git a/package-lock.json b/package-lock.json index d5aef7c33..53da3a3e7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -383,9 +383,9 @@ } }, "node_modules/@rspress/core": { - "version": "2.0.15", - "resolved": "https://registry.npmjs.org/@rspress/core/-/core-2.0.15.tgz", - "integrity": "sha512-epLmUXYscNRw/GtQZx2oknoBE9wKbCrUGEOrQEDI4Qq8X32GdM4d7itzuHsliY7q3IbffKx8rMVbvlmygEocTQ==", + "version": "2.0.16", + "resolved": "https://registry.npmjs.org/@rspress/core/-/core-2.0.16.tgz", + "integrity": "sha512-jJcYNNBKY/VLR8oxLqdd5uvm6bHahXeF3wSaoAe2U1hxWWwoP9k4rBTDx9X3JkUWcgnthu7UgtMiHeLs+2fhFg==", "dev": true, "license": "MIT", "dependencies": { @@ -393,7 +393,7 @@ "@mdx-js/react": "^3.1.1", "@rsbuild/core": "^2.0.15", "@rsbuild/plugin-react": "~2.0.1", - "@rspress/shared": "2.0.15", + "@rspress/shared": "2.0.16", "@shikijs/rehype": "^4.2.0", "@types/unist": "^3.0.3", "@unhead/react": "^2.1.15", @@ -412,11 +412,11 @@ "react-lazy-with-preload": "^2.2.1", "react-reconciler": "0.33.0", "react-render-to-markdown": "19.1.0", - "react-router-dom": "^7.15.1", + "react-router-dom": "^7.18.1", "rehype-external-links": "^3.0.0", "rehype-raw": "^7.0.0", "remark-cjk-friendly": "^2.3.1", - "remark-cjk-friendly-gfm-strikethrough": "^2.1.0", + "remark-cjk-friendly-gfm-strikethrough": "^2.3.1", "remark-gfm": "^4.0.1", "remark-mdx": "^3.1.1", "remark-parse": "^11.0.0", @@ -436,9 +436,9 @@ } }, "node_modules/@rspress/plugin-client-redirects": { - "version": "2.0.15", - "resolved": "https://registry.npmjs.org/@rspress/plugin-client-redirects/-/plugin-client-redirects-2.0.15.tgz", - "integrity": "sha512-bPf/KIHH7Y6huLTtK6JXwRfxM7zKjksoxm46+IBsF1wisw0doKkEKR9HwJydxWnykyKBbA2cuZOaoT4h174Z1w==", + "version": "2.0.16", + "resolved": "https://registry.npmjs.org/@rspress/plugin-client-redirects/-/plugin-client-redirects-2.0.16.tgz", + "integrity": "sha512-FEjZb+3lxpkEESdt0uWa4dRQU7d/Okn5SyX7CDaMHdHuqg4XdOidAQ/95ZzgXomN7YVPv40eVe/0is3oWnAjew==", "dev": true, "license": "MIT", "engines": { @@ -449,9 +449,9 @@ } }, "node_modules/@rspress/plugin-sitemap": { - "version": "2.0.15", - "resolved": "https://registry.npmjs.org/@rspress/plugin-sitemap/-/plugin-sitemap-2.0.15.tgz", - "integrity": "sha512-z1hbyGP79ZXdSGJxiWw7ZjmX8qW0q9nXMDxr14cVEg/wdj7ToVzGtZHw0wvTPE0YiKG3BMiGkVNfE1rdOaPXiQ==", + "version": "2.0.16", + "resolved": "https://registry.npmjs.org/@rspress/plugin-sitemap/-/plugin-sitemap-2.0.16.tgz", + "integrity": "sha512-zRPvKAGF8EexblJvrkhhHtD2Kqlbaw6XFjbIMM07gs0SIUKNg1o3T2I/uo5cvWtk0pGfYyyAJj94HoqLUgUsEw==", "dev": true, "license": "MIT", "engines": { @@ -462,9 +462,9 @@ } }, "node_modules/@rspress/shared": { - "version": "2.0.15", - "resolved": "https://registry.npmjs.org/@rspress/shared/-/shared-2.0.15.tgz", - "integrity": "sha512-o8aYwEzNuTmWnmKe91ntPv+34u3RbtAe+rcK9XC5MANOlgncwOaCs3bUa8/B1/llwyLoNgrpi+VB9bEiU11ZRQ==", + "version": "2.0.16", + "resolved": "https://registry.npmjs.org/@rspress/shared/-/shared-2.0.16.tgz", + "integrity": "sha512-FjBSfGtgrlR1bRJ0EQLyNo2qXUXxzb2QE3NPRIICf8TKpP413gRCMyMRtzhbIqD4Gn7k+em82VAkWTAAQjQLTw==", "dev": true, "license": "MIT", "dependencies": { @@ -2869,9 +2869,9 @@ } }, "node_modules/react-router": { - "version": "7.17.0", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.17.0.tgz", - "integrity": "sha512-FDELK7rTMlCHO5+reyXsPlmfr7N1F91lPHsWYfMEGQm/KQ+F4JFM8jGoeQDmDvdTs93Fw9aSilH+uKRb4/jXvQ==", + "version": "7.18.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.1.tgz", + "integrity": "sha512-GDLgg3i3uM0aeJO3Fm+TCS+sDQ7gu12T6x0qdTEzcwqEfleci7JwugVNIF3U//0FWKnJT7ptG+20B2jfDqnZAg==", "dev": true, "license": "MIT", "dependencies": { @@ -2892,13 +2892,13 @@ } }, "node_modules/react-router-dom": { - "version": "7.17.0", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.17.0.tgz", - "integrity": "sha512-fyU2yjGups/hE6Xz0I5ZYbVL8Gx29eCjgpHaRaTaVU+OOAdfRX05KsvyRm0GO8YQwOkhpU3MurW1jyMUJn+zSw==", + "version": "7.18.1", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.1.tgz", + "integrity": "sha512-KaZh+X/6UtEp28x51AUYZDMg9NGoz2ja3dNHa+ta/tk40vCzKhQ/RypCWBMLbmDr6//E24Vv5uPsrqXFozdkAg==", "dev": true, "license": "MIT", "dependencies": { - "react-router": "7.17.0" + "react-router": "7.18.1" }, "engines": { "node": ">=20.0.0" @@ -3081,9 +3081,9 @@ } }, "node_modules/remark-cjk-friendly-gfm-strikethrough": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/remark-cjk-friendly-gfm-strikethrough/-/remark-cjk-friendly-gfm-strikethrough-2.1.0.tgz", - "integrity": "sha512-3Kyq2hjY7V7eU8MbVbWW6QQLN81pjJcIvKHvPxr8hZZmcq/9wqm3MJ3iUG34Ch9QTM4WHN+a1JVAVC1fSi5mig==", + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/remark-cjk-friendly-gfm-strikethrough/-/remark-cjk-friendly-gfm-strikethrough-2.3.1.tgz", + "integrity": "sha512-JE3TGgouk/sy92SemNMEUhO5mNP4on04cmzOV3s3R5Dbk160ewmpM4tgPiinKKvoJ5UW2fTu7FOYsjVbusSA9w==", "dev": true, "license": "MIT", "dependencies": { From b61f0af98ade3254c31ba15357b69f8ea0d3614c Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Sat, 4 Jul 2026 05:05:04 +0000 Subject: [PATCH 24/26] chore(deps): update rust crate bytesize to v2.4.2 --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3e19af6e4..d850ac457 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -582,9 +582,9 @@ checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" [[package]] name = "bytesize" -version = "2.4.0" +version = "2.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49e78e506b9d7633710dab98996f22f95f3d0f488e8f1aa162830556ed9fc14d" +checksum = "3d7c8918969267b2932ffd5655509bbbea0833823058c378876953217f5fc50e" [[package]] name = "bzip2-sys" From e0a754483a8e580dd1efb7b7cf60a402459a137f Mon Sep 17 00:00:00 2001 From: Ginger Date: Sat, 4 Jul 2026 13:06:49 -0400 Subject: [PATCH 25/26] fix: Give invitees creator status when creating a trusted private v12 room --- src/api/client/room/create.rs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/api/client/room/create.rs b/src/api/client/room/create.rs index 259fb9a12..d8ea0cac1 100644 --- a/src/api/client/room/create.rs +++ b/src/api/client/room/create.rs @@ -337,14 +337,21 @@ pub(crate) async fn create_room_route( }); let mut power_levels_to_grant = BTreeMap::from_iter([(sender_user.to_owned(), int!(100))]); + let mut creators: Vec = vec![sender_user.to_owned()]; if preset == RoomPreset::TrustedPrivateChat { for recipient_user in &invitees { - power_levels_to_grant.insert(recipient_user.clone(), int!(100)); + if room_version_rules + .authorization + .explicitly_privilege_room_creators + { + creators.push(recipient_user.to_owned()); + } else { + power_levels_to_grant.insert(recipient_user.clone(), int!(100)); + } } } - let mut creators: Vec = vec![sender_user.to_owned()]; // Do we care about additional_creators? if room_version_rules .authorization From d2b795d8cf9cfe8720477a25a690f08372c0cddb Mon Sep 17 00:00:00 2001 From: Ginger Date: Sat, 4 Jul 2026 13:15:54 -0400 Subject: [PATCH 26/26] refactor: Logic cleanup --- src/api/client/room/create.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/api/client/room/create.rs b/src/api/client/room/create.rs index d8ea0cac1..5a9d87cc9 100644 --- a/src/api/client/room/create.rs +++ b/src/api/client/room/create.rs @@ -340,14 +340,14 @@ pub(crate) async fn create_room_route( let mut creators: Vec = vec![sender_user.to_owned()]; if preset == RoomPreset::TrustedPrivateChat { - for recipient_user in &invitees { + for recipient_user in invitees.iter().cloned() { if room_version_rules .authorization .explicitly_privilege_room_creators { - creators.push(recipient_user.to_owned()); + creators.push(recipient_user); } else { - power_levels_to_grant.insert(recipient_user.clone(), int!(100)); + power_levels_to_grant.insert(recipient_user, int!(100)); } } }