From 1fcf6503221239a7093baf680506a5aac4fb2cfc Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Fri, 18 Apr 2025 18:26:29 +0200 Subject: [PATCH] Option to generate a MAS config from an existing Synapse config This is a best-effort conversion, which will warn about unsupported options. --- Cargo.lock | 4 + crates/cli/src/commands/config.rs | 24 +- crates/syn2mas/Cargo.toml | 4 + crates/syn2mas/src/synapse_reader/checks.rs | 2 +- .../{config.rs => config/mod.rs} | 117 +++++- .../syn2mas/src/synapse_reader/config/oidc.rs | 347 ++++++++++++++++++ 6 files changed, 475 insertions(+), 23 deletions(-) rename crates/syn2mas/src/synapse_reader/{config.rs => config/mod.rs} (70%) create mode 100644 crates/syn2mas/src/synapse_reader/config/oidc.rs diff --git a/Cargo.lock b/Cargo.lock index e4a179e53..a3170d80a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6161,14 +6161,17 @@ dependencies = [ "futures-util", "insta", "mas-config", + "mas-iana", "mas-storage", "mas-storage-pg", + "oauth2-types", "opentelemetry", "opentelemetry-semantic-conventions", "rand 0.8.5", "rand_chacha 0.3.1", "rustc-hash 2.1.1", "serde", + "serde_json", "sqlx", "thiserror 2.0.12", "thiserror-ext", @@ -6176,6 +6179,7 @@ dependencies = [ "tokio-util", "tracing", "ulid", + "url", "uuid", ] diff --git a/crates/cli/src/commands/config.rs b/crates/cli/src/commands/config.rs index 0a246d86c..26b944016 100644 --- a/crates/cli/src/commands/config.rs +++ b/crates/cli/src/commands/config.rs @@ -11,7 +11,7 @@ use camino::Utf8PathBuf; use clap::Parser; use figment::Figment; use mas_config::{ConfigurationSection, RootConfig, SyncConfig}; -use mas_storage::SystemClock; +use mas_storage::{Clock as _, SystemClock}; use mas_storage_pg::MIGRATOR; use rand::SeedableRng; use tokio::io::AsyncWriteExt; @@ -46,6 +46,10 @@ enum Subcommand { /// If not specified, the config will be written to stdout #[clap(short, long)] output: Option, + + /// Existing Synapse configuration used to generate the MAS config + #[arg(short, long, action = clap::ArgAction::Append)] + synapse_config: Vec, }, /// Sync the clients and providers from the config file to the database @@ -88,14 +92,24 @@ impl Options { info!("Configuration file looks good"); } - SC::Generate { output } => { + SC::Generate { + output, + synapse_config, + } => { let _span = info_span!("cli.config.generate").entered(); + let clock = SystemClock::default(); // XXX: we should disallow SeedableRng::from_entropy - let rng = rand_chacha::ChaChaRng::from_entropy(); - let config = RootConfig::generate(rng).await?; - let config = serde_yaml::to_string(&config)?; + let mut rng = rand_chacha::ChaChaRng::from_entropy(); + let mut config = RootConfig::generate(&mut rng).await?; + if !synapse_config.is_empty() { + info!("Adjusting MAS config to match Synapse config from {synapse_config:?}"); + let synapse_config = syn2mas::synapse_config::Config::load(&synapse_config)?; + config = synapse_config.adjust_mas_config(config, &mut rng, clock.now()); + } + + let config = serde_yaml::to_string(&config)?; if let Some(output) = output { info!("Writing configuration to {output:?}"); let mut file = tokio::fs::File::create(output).await?; diff --git a/crates/syn2mas/Cargo.toml b/crates/syn2mas/Cargo.toml index 0e82867ce..61e7ac2d5 100644 --- a/crates/syn2mas/Cargo.toml +++ b/crates/syn2mas/Cargo.toml @@ -16,6 +16,7 @@ bitflags.workspace = true camino.workspace = true figment.workspace = true serde.workspace = true +serde_json.workspace = true thiserror.workspace = true thiserror-ext.workspace = true tokio.workspace = true @@ -26,6 +27,7 @@ compact_str.workspace = true tracing.workspace = true futures-util = "0.3.31" rustc-hash = "2.1.1" +url.workspace = true rand.workspace = true rand_chacha = "0.3.1" @@ -33,7 +35,9 @@ uuid = "1.16.0" ulid = { workspace = true, features = ["uuid"] } mas-config.workspace = true +mas-iana.workspace = true mas-storage.workspace = true +oauth2-types.workspace = true opentelemetry.workspace = true opentelemetry-semantic-conventions.workspace = true diff --git a/crates/syn2mas/src/synapse_reader/checks.rs b/crates/syn2mas/src/synapse_reader/checks.rs index 360e6d38d..4dca03029 100644 --- a/crates/syn2mas/src/synapse_reader/checks.rs +++ b/crates/syn2mas/src/synapse_reader/checks.rs @@ -157,7 +157,7 @@ pub fn synapse_config_check(synapse_config: &Config) -> (Vec, Vec< )); } - if synapse_config.enable_3pid_changes { + if synapse_config.enable_3pid_changes == Some(true) { errors.push(CheckError::ThreepidChangesEnabled); } diff --git a/crates/syn2mas/src/synapse_reader/config.rs b/crates/syn2mas/src/synapse_reader/config/mod.rs similarity index 70% rename from crates/syn2mas/src/synapse_reader/config.rs rename to crates/syn2mas/src/synapse_reader/config/mod.rs index 789be6845..390dacaa8 100644 --- a/crates/syn2mas/src/synapse_reader/config.rs +++ b/crates/syn2mas/src/synapse_reader/config/mod.rs @@ -3,12 +3,21 @@ // SPDX-License-Identifier: AGPL-3.0-only // Please see LICENSE in the repository root for full details. +mod oidc; + use std::collections::BTreeMap; use camino::Utf8PathBuf; +use chrono::{DateTime, Utc}; use figment::providers::{Format, Yaml}; +use mas_config::{PasswordAlgorithm, PasswordHashingScheme}; +use rand::Rng; use serde::Deserialize; use sqlx::postgres::PgConnectOptions; +use tracing::warn; +use url::Url; + +pub use self::oidc::OidcProvider; /// The root of a Synapse configuration. /// This struct only includes fields which the Synapse-to-MAS migration is @@ -23,6 +32,8 @@ pub struct Config { #[serde(default)] pub password_config: PasswordSection, + pub bcrypt_rounds: Option, + #[serde(default)] pub allow_guest_access: bool, @@ -31,11 +42,16 @@ pub struct Config { #[serde(default)] pub enable_registration_captcha: bool, + pub recaptcha_public_key: Option, + pub recaptcha_private_key: Option, /// Normally this defaults to true, but when MAS integration is enabled in /// Synapse it defaults to false. #[serde(default)] - pub enable_3pid_changes: bool, + pub enable_3pid_changes: Option, + + #[serde(default = "default_true")] + enable_set_display_name: bool, #[serde(default)] pub user_consent: Option, @@ -67,6 +83,8 @@ pub struct Config { pub oidc_providers: Vec, pub server_name: String, + + pub public_baseurl: Option, } impl Config { @@ -100,21 +118,97 @@ impl Config { let mut out = BTreeMap::new(); if let Some(provider) = &self.oidc_config { - if provider.issuer.is_some() { + if provider.has_required_fields() { + let mut provider = provider.clone(); // The legacy configuration has an implied IdP ID of `oidc`. - out.insert("oidc".to_owned(), provider.clone()); + let idp_id = provider.idp_id.take().unwrap_or("oidc".to_owned()); + provider.idp_id = Some(idp_id.clone()); + out.insert(idp_id, provider); } } for provider in &self.oidc_providers { - if let Some(idp_id) = &provider.idp_id { + let mut provider = provider.clone(); + let idp_id = match provider.idp_id.take() { + None => "oidc".to_owned(), + Some(idp_id) if idp_id == "oidc" => idp_id, // Synapse internally prefixes the IdP IDs with `oidc-`. - out.insert(format!("oidc-{idp_id}"), provider.clone()); - } + Some(idp_id) => format!("oidc-{idp_id}"), + }; + provider.idp_id = Some(idp_id.clone()); + out.insert(idp_id, provider); } out } + + /// Adjust a MAS configuration to match this Synapse configuration. + #[must_use] + pub fn adjust_mas_config( + self, + mut mas_config: mas_config::RootConfig, + rng: &mut impl Rng, + now: DateTime, + ) -> mas_config::RootConfig { + let providers = self.all_oidc_providers(); + for provider in providers.into_values() { + let Some(mas_provider_config) = provider.into_mas_config(rng, now) else { + // TODO: better log message + warn!("Could not convert OIDC provider to MAS config"); + continue; + }; + + mas_config + .upstream_oauth2 + .providers + .push(mas_provider_config); + } + + // TODO: manage when the option is not set + if let Some(enable_3pid_changes) = self.enable_3pid_changes { + mas_config.account.email_change_allowed = enable_3pid_changes; + } + mas_config.account.displayname_change_allowed = self.enable_set_display_name; + if self.password_config.enabled { + mas_config.passwords.enabled = true; + mas_config.passwords.schemes = vec![ + // This is the password hashing scheme synapse uses + PasswordHashingScheme { + version: 1, + algorithm: PasswordAlgorithm::Bcrypt, + cost: self.bcrypt_rounds, + secret: self.password_config.pepper, + secret_file: None, + }, + // Use the default algorithm MAS uses as a second hashing scheme, so that users + // will get their password hash upgraded to a more modern algorithm over time + PasswordHashingScheme { + version: 2, + algorithm: PasswordAlgorithm::default(), + cost: None, + secret: None, + secret_file: None, + }, + ]; + + mas_config.account.password_registration_enabled = self.enable_registration; + } else { + mas_config.passwords.enabled = false; + } + + if self.enable_registration_captcha { + mas_config.captcha.service = Some(mas_config::CaptchaServiceKind::RecaptchaV2); + mas_config.captcha.site_key = self.recaptcha_public_key; + mas_config.captcha.secret_key = self.recaptcha_private_key; + } + + mas_config.matrix.homeserver = self.server_name; + if let Some(public_baseurl) = self.public_baseurl { + mas_config.matrix.endpoint = public_baseurl; + } + + mas_config + } } /// The `database` section of the Synapse configuration. @@ -215,17 +309,6 @@ pub struct EnableableSection { pub enabled: bool, } -#[derive(Clone, Deserialize)] -pub struct OidcProvider { - /// At least for `oidc_config`, if the dict is present but left empty then - /// the config should be ignored, so this field must be optional. - pub issuer: Option, - - /// Required, except for the old `oidc_config` where this is implied to be - /// "oidc". - pub idp_id: Option, -} - fn default_true() -> bool { true } diff --git a/crates/syn2mas/src/synapse_reader/config/oidc.rs b/crates/syn2mas/src/synapse_reader/config/oidc.rs new file mode 100644 index 000000000..5a9321ce2 --- /dev/null +++ b/crates/syn2mas/src/synapse_reader/config/oidc.rs @@ -0,0 +1,347 @@ +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only +// Please see LICENSE in the repository root for full details. + +use std::{collections::BTreeMap, str::FromStr as _}; + +use chrono::{DateTime, Utc}; +use mas_config::{ + UpstreamOAuth2ClaimsImports, UpstreamOAuth2DiscoveryMode, UpstreamOAuth2ImportAction, + UpstreamOAuth2PkceMethod, UpstreamOAuth2ResponseMode, UpstreamOAuth2TokenAuthMethod, +}; +use mas_iana::jose::JsonWebSignatureAlg; +use oauth2_types::scope::{OPENID, Scope, ScopeToken}; +use rand::Rng; +use serde::Deserialize; +use tracing::warn; +use ulid::Ulid; +use url::Url; + +#[derive(Clone, Deserialize, Default)] +enum UserMappingProviderModule { + #[default] + #[serde(rename = "synapse.handlers.oidc.JinjaOidcMappingProvider")] + Jinja, + + #[serde(rename = "synapse.handlers.oidc_handler.JinjaOidcMappingProvider")] + JinjaLegacy, + + #[serde(other)] + Other, +} + +#[derive(Clone, Deserialize, Default)] +struct UserMappingProviderConfig { + subject_template: Option, + subject_claim: Option, + localpart_template: Option, + display_name_template: Option, + email_template: Option, + + #[serde(default)] + confirm_localpart: bool, +} + +impl UserMappingProviderConfig { + fn into_mas_config(self) -> UpstreamOAuth2ClaimsImports { + let mut config = UpstreamOAuth2ClaimsImports::default(); + + match (self.subject_claim, self.subject_template) { + (Some(_), Some(subject_template)) => { + warn!( + "Both `subject_claim` and `subject_template` options are set, using `subject_template`." + ); + config.subject.template = Some(subject_template); + } + (None, Some(subject_template)) => { + config.subject.template = Some(subject_template); + } + (Some(subject_claim), None) => { + config.subject.template = Some(format!("{{{{ user.{subject_claim} }}}}")); + } + (None, None) => {} + } + + if let Some(localpart_template) = self.localpart_template { + config.localpart.template = Some(localpart_template); + config.localpart.action = if self.confirm_localpart { + UpstreamOAuth2ImportAction::Suggest + } else { + UpstreamOAuth2ImportAction::Require + }; + } + + if let Some(displayname_template) = self.display_name_template { + config.displayname.template = Some(displayname_template); + config.displayname.action = if self.confirm_localpart { + UpstreamOAuth2ImportAction::Suggest + } else { + UpstreamOAuth2ImportAction::Force + }; + } + + if let Some(email_template) = self.email_template { + config.email.template = Some(email_template); + config.email.action = if self.confirm_localpart { + UpstreamOAuth2ImportAction::Suggest + } else { + UpstreamOAuth2ImportAction::Force + }; + } + + config + } +} + +#[derive(Clone, Deserialize, Default)] +struct UserMappingProvider { + #[serde(default)] + module: UserMappingProviderModule, + #[serde(default)] + config: UserMappingProviderConfig, +} + +#[derive(Clone, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +enum PkceMethod { + #[default] + Auto, + Always, + Never, + #[serde(other)] + Other, +} + +#[derive(Clone, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +enum UserProfileMethod { + #[default] + Auto, + UserinfoEndpoint, + #[serde(other)] + Other, +} + +#[derive(Clone, Deserialize)] +#[expect(clippy::struct_excessive_bools)] +pub struct OidcProvider { + pub issuer: Option, + + /// Required, except for the old `oidc_config` where this is implied to be + /// "oidc". + pub idp_id: Option, + + idp_name: Option, + idp_brand: Option, + + #[serde(default = "default_true")] + discover: bool, + + client_id: Option, + client_secret: Option, + + // Unsupported, we want to shout about it + client_secret_path: Option, + + // Unsupported, we want to shout about it + client_secret_jwt_key: Option, + client_auth_method: Option, + #[serde(default)] + pkce_method: PkceMethod, + // Unsupported, we want to shout about it + id_token_signing_alg_values_supported: Option>, + scopes: Option>, + authorization_endpoint: Option, + token_endpoint: Option, + userinfo_endpoint: Option, + jwks_uri: Option, + #[serde(default)] + skip_verification: bool, + + // Unsupported, we want to shout about it + #[serde(default)] + backchannel_logout_enabled: bool, + + #[serde(default)] + user_profile_method: UserProfileMethod, + + // Unsupported, we want to shout about it + attribute_requirements: Option, + + // Unsupported, we want to shout about it + #[serde(default = "default_true")] + enable_registration: bool, + #[serde(default)] + additional_authorization_parameters: BTreeMap, + #[serde(default)] + user_mapping_provider: UserMappingProvider, +} + +fn default_true() -> bool { + true +} + +impl OidcProvider { + /// Returns true if the two 'required' fields are set. This is used to + /// ignore an empty dict on the `oidc_config` section. + #[must_use] + pub(crate) fn has_required_fields(&self) -> bool { + self.issuer.is_some() && self.client_id.is_some() + } + + /// Map this Synapse OIDC provider config to a MAS upstream provider config. + #[expect(clippy::too_many_lines)] + pub(crate) fn into_mas_config( + self, + rng: &mut impl Rng, + now: DateTime, + ) -> Option { + let client_id = self.client_id?; + + if self.client_secret_path.is_some() { + warn!( + "The `client_secret_path` option is not supported, ignoring. You *will* need to include the secret in the `client_secret` field." + ); + } + + if self.client_secret_jwt_key.is_some() { + warn!("The `client_secret_jwt_key` option is not supported, ignoring."); + } + + if self.attribute_requirements.is_some() { + warn!("The `attribute_requirements` option is not supported, ignoring."); + } + + if self.id_token_signing_alg_values_supported.is_some() { + warn!("The `id_token_signing_alg_values_supported` option is not supported, ignoring."); + } + + if self.backchannel_logout_enabled { + warn!("The `backchannel_logout_enabled` option is not supported, ignoring."); + } + + if !self.enable_registration { + warn!( + "Setting the `enable_registration` option to `false` is not supported, ignoring." + ); + } + + let scope: Scope = match self.scopes { + None => [OPENID].into_iter().collect(), // Synapse defaults to the 'openid' scope + Some(scopes) => scopes + .into_iter() + .filter_map(|scope| match ScopeToken::from_str(&scope) { + Ok(scope) => Some(scope), + Err(err) => { + warn!("OIDC provider scope '{scope}' is invalid: {err}"); + None + } + }) + .collect(), + }; + + let id = Ulid::from_datetime_with_source(now.into(), rng); + + let token_endpoint_auth_method = self.client_auth_method.unwrap_or_else(|| { + // The token auth method defaults to 'none' if no client_secret is set and + // 'client_secret_basic' otherwise + if self.client_secret.is_some() { + UpstreamOAuth2TokenAuthMethod::ClientSecretBasic + } else { + UpstreamOAuth2TokenAuthMethod::None + } + }); + + let discovery_mode = match (self.discover, self.skip_verification) { + (true, false) => UpstreamOAuth2DiscoveryMode::Oidc, + (true, true) => UpstreamOAuth2DiscoveryMode::Insecure, + (false, _) => UpstreamOAuth2DiscoveryMode::Disabled, + }; + + let pkce_method = match self.pkce_method { + PkceMethod::Auto => UpstreamOAuth2PkceMethod::Auto, + PkceMethod::Always => UpstreamOAuth2PkceMethod::Always, + PkceMethod::Never => UpstreamOAuth2PkceMethod::Never, + PkceMethod::Other => { + warn!( + "The `pkce_method` option is not supported, expected 'auto', 'always', or 'never'; assuming 'auto'." + ); + UpstreamOAuth2PkceMethod::default() + } + }; + + // "auto" doesn't mean the same thing depending on whether we request the openid + // scope or not + let has_openid_scope = scope.contains(&OPENID); + let fetch_userinfo = match self.user_profile_method { + UserProfileMethod::Auto => has_openid_scope, + UserProfileMethod::UserinfoEndpoint => true, + UserProfileMethod::Other => { + warn!( + "The `user_profile_method` option is not supported, expected 'auto' or 'userinfo_endpoint'; assuming 'auto'." + ); + has_openid_scope + } + }; + + // Check if there is a `response_mode` set in the additional authorization + // parameters + let mut additional_authorization_parameters = self.additional_authorization_parameters; + let response_mode = if let Some(response_mode) = + additional_authorization_parameters.remove("response_mode") + { + match response_mode.to_ascii_lowercase().as_str() { + "query" => Some(UpstreamOAuth2ResponseMode::Query), + "form_post" => Some(UpstreamOAuth2ResponseMode::FormPost), + _ => { + warn!( + "Invalid `response_mode` in the `additional_authorization_parameters` option, expected 'query' or 'form_post'; ignoring." + ); + None + } + } + } else { + None + }; + + let claims_imports = if matches!( + self.user_mapping_provider.module, + UserMappingProviderModule::Other + ) { + warn!( + "The `user_mapping_provider` module specified is not supported, ignoring. Please adjust the `claims_imports` to match the mapping provider behaviour." + ); + UpstreamOAuth2ClaimsImports::default() + } else { + self.user_mapping_provider.config.into_mas_config() + }; + + Some(mas_config::UpstreamOAuth2Provider { + enabled: true, + id, + synapse_idp_id: self.idp_id, + issuer: self.issuer, + human_name: self.idp_name, + brand_name: self.idp_brand, + client_id, + client_secret: self.client_secret, + token_endpoint_auth_method, + sign_in_with_apple: None, + token_endpoint_auth_signing_alg: None, + id_token_signed_response_alg: JsonWebSignatureAlg::Rs256, + scope: scope.to_string(), + discovery_mode, + pkce_method, + fetch_userinfo, + userinfo_signed_response_alg: None, + authorization_endpoint: self.authorization_endpoint, + userinfo_endpoint: self.userinfo_endpoint, + token_endpoint: self.token_endpoint, + jwks_uri: self.jwks_uri, + response_mode, + claims_imports, + additional_authorization_parameters, + }) + } +}