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, }, }