feat: Import images correctly from OIDC claims

This commit is contained in:
Ginger authored and Ellis Git committed 2026-10-03 15:04:07 +00:00
1 parent 6bbea25034
commit 2773ff3e16
6 files changed
+82 -35

No files matched your search

+1
View File
@@ -0,0 +1 @@
Profile banners are now treated as images when imported from OIDC claims, like avatars already were.
+1
View File
@@ -0,0 +1 @@
For deployments using OIDC with `profile_key_import_mode` set to `on_login`, users' avatars will no longer be clobbered if they didn't change their avatar since their last login.
+9 -4
View File
@@ -2378,10 +2378,15 @@
# Per-room overrides to the user's display name or avatar will be # Per-room overrides to the user's display name or avatar will be
# preserved by the import process. # preserved by the import process.
# #
# SECURITY NOTE: If the `avatar_url` field is set, Continuwuity will # SECURITY NOTE: For the following profile fields, Continuwuity will treat
# perform a HTTP GET to the URL in the mapped claim and use the returned # the value of the mapped claim as an HTTP URL and upload the file located
# file as the user's profile picture. Make sure your users are not able # at that URL to its internal media repository:
# to set the value of the mapped claim to an arbitrary URL. #
# - `avatar_url`
# - `chat.commet.profile_banner`
#
# If you map these fields to claims, make sure your users cannot set those
# claims to arbitrary URLs.
# #
#profile_key_map = { displayname = "name" } #profile_key_map = { displayname = "name" }
+9 -4
View File
@@ -2864,10 +2864,15 @@ pub struct OidcConfig {
/// Per-room overrides to the user's display name or avatar will be /// Per-room overrides to the user's display name or avatar will be
/// preserved by the import process. /// preserved by the import process.
/// ///
/// SECURITY NOTE: If the `avatar_url` field is set, Continuwuity will /// SECURITY NOTE: For the following profile fields, Continuwuity will treat
/// perform a HTTP GET to the URL in the mapped claim and use the returned /// the value of the mapped claim as an HTTP URL and upload the file located
/// file as the user's profile picture. Make sure your users are not able /// at that URL to its internal media repository:
/// to set the value of the mapped claim to an arbitrary URL. ///
/// - `avatar_url`
/// - `chat.commet.profile_banner`
///
/// If you map these fields to claims, make sure your users cannot set those
/// claims to arbitrary URLs.
/// ///
/// default: { displayname = "name" } /// default: { displayname = "name" }
#[serde(default = "default_profile_key_map")] #[serde(default = "default_profile_key_map")]
+4
View File
@@ -130,6 +130,10 @@ pub(super) static MAPS: &[Descriptor] = &[
}, },
Descriptor { Descriptor {
name: "openidsubject_currentpictureurl", name: "openidsubject_currentpictureurl",
..descriptor::DROPPED
},
Descriptor {
name: "openidsubjectprofilefield_url",
..descriptor::RANDOM_SMALL ..descriptor::RANDOM_SMALL
}, },
Descriptor { Descriptor {
+58 -27
View File
@@ -47,7 +47,7 @@ pub struct Service {
struct Data { struct Data {
openidsubject_localpart: Arc<Map>, openidsubject_localpart: Arc<Map>,
openidsubject_currentpictureurl: Arc<Map>, openidsubjectprofilefield_url: Arc<Map>,
} }
struct Services { struct Services {
config: Dep<config::Service>, config: Dep<config::Service>,
@@ -144,7 +144,7 @@ impl crate::Service for Service {
runtime: args.server.runtime().clone(), runtime: args.server.runtime().clone(),
db: Data { db: Data {
openidsubject_localpart: args.db["openidsubject_localpart"].clone(), openidsubject_localpart: args.db["openidsubject_localpart"].clone(),
openidsubject_currentpictureurl: args.db["openidsubject_currentpictureurl"].clone(), openidsubjectprofilefield_url: args.db["openidsubjectprofilefield_url"].clone(),
}, },
client: args.server.config.oauth.oidc.as_ref().map(|config| -> Result<OidcClient> { client: args.server.config.oauth.oidc.as_ref().map(|config| -> Result<OidcClient> {
Ok(OidcClient { Ok(OidcClient {
@@ -214,6 +214,10 @@ impl crate::Service for Service {
} }
impl Service { impl Service {
// Profile keys that will be treated as image URLs and imported
// into the media repository. Keep this synced with the list in the
// doc comment for `profile_key_map` in the config.
const IMAGE_PROFILE_KEYS: &[&str] = &["avatar_url", "chat.commet.profile_banner"];
const SERVER_MISCONFIGURED: &str = const SERVER_MISCONFIGURED: &str =
"Identity server is misconfigured. Contact your homeserver's administrator."; "Identity server is misconfigured. Contact your homeserver's administrator.";
@@ -433,41 +437,66 @@ impl Service {
let user_id = user_id.clone(); let user_id = user_id.clone();
let subject = claims.subject().to_string(); let subject = claims.subject().to_string();
let profile_key_map = config.profile_key_map.clone(); let profile_key_map = config.profile_key_map.clone();
let openidsubject_currentpictureurl = self.db.openidsubject_currentpictureurl.clone(); let openidsubjectprofilefield_url = self.db.openidsubjectprofilefield_url.clone();
let users = self.services.users.clone(); let users = self.services.users.clone();
let media = self.services.media.clone(); let media = self.services.media.clone();
let import_task = self.runtime.spawn(async move { let import_task = self.runtime.spawn(async move {
// TODO: once we get support for full profile replacement, use
// that logic instead
for (field, claim) in &profile_key_map { for (field, claim) in &profile_key_map {
let Some(value) = all_claims.get(claim).cloned() else { let Some(value) = all_claims.get(claim).cloned() else {
warn!(?field, ?claim, "IDP provided no value for this mapped claim"); warn!(?field, ?claim, "IDP provided no value for this mapped claim");
continue; continue;
}; };
let value = if let Some(picture_url) = value.as_str() let value = if Self::IMAGE_PROFILE_KEYS.contains(&field.as_str()) {
&& field == ProfileFieldName::AvatarUrl.as_str() if let Some(url) = value.as_str() {
&& openidsubject_currentpictureurl // Check the last value the IDP gave us for this
.get(&subject) // profile field. We have to track this in a
.await // separate keyspace because, for fields in
.deserialized::<String>() // `IMAGE_PROFILE_KEYS`, what gets saved in
.ok() // the user's profile data is a MXC URI that has no
.is_none_or(|current_picture| current_picture != picture_url) // relationship to the URL the IDP gave us.
{ if openidsubjectprofilefield_url
match media.download_media(picture_url).await { .qry(&(&subject, field))
| Ok((mxc, size)) => { .await
openidsubject_currentpictureurl.insert(&subject, picture_url); .deserialized::<String>()
info!(?picture_url, ?mxc, ?size, "Downloaded profile picture"); .ok()
.is_none_or(|current_url| current_url != url)
{
match media.download_media(url).await {
| Ok((mxc, _)) => {
openidsubjectprofilefield_url.put((&subject, field), url);
info!(?url, ?field, ?mxc, "Downloaded profile image");
ProfileFieldValue::AvatarUrl(mxc) ProfileFieldValue::new(
}, field,
| Err(err) => { Value::String(mxc.to_string()),
warn!( )
?claim, .expect("MXC should be valid for this field")
?picture_url, },
"Failed to download profile picture: {err}" | Err(err) => {
); warn!(
?field,
?url,
"Failed to download profile image: {err}"
);
continue;
},
}
} else {
// This claim hasn't changed from its previous
// value, don't update it.
continue; continue;
}, }
} else {
warn!(
?claim,
?value,
"Claim value for profile image was not a string"
);
continue;
} }
} else { } else {
match ProfileFieldValue::new(field, value.clone()) { match ProfileFieldValue::new(field, value.clone()) {
@@ -499,8 +528,10 @@ impl Service {
info!("Profile import complete"); info!("Profile import complete");
}); });
// Only wait for import to complete if this is a new account, // Only wait for the import to complete if this is a new account,
// so they see the correct profile information in the account panel // so they see the correct profile information in the account panel.
// Otherwise let it run in the background to avoid blocking the
// sign-in process.
if new_account_registered { if new_account_registered {
let _ = import_task.await; let _ = import_task.await;
} }