Option to generate a MAS config from an existing Synapse config

This is a best-effort conversion, which will warn about unsupported options.
This commit is contained in:
Quentin Gliech
2025-04-18 18:26:29 +02:00
parent 0792171f91
commit 1fcf650322
6 changed files with 475 additions and 23 deletions

View File

@@ -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

View File

@@ -157,7 +157,7 @@ pub fn synapse_config_check(synapse_config: &Config) -> (Vec<CheckWarning>, Vec<
));
}
if synapse_config.enable_3pid_changes {
if synapse_config.enable_3pid_changes == Some(true) {
errors.push(CheckError::ThreepidChangesEnabled);
}

View File

@@ -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<u32>,
#[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<String>,
pub recaptcha_private_key: Option<String>,
/// 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<bool>,
#[serde(default = "default_true")]
enable_set_display_name: bool,
#[serde(default)]
pub user_consent: Option<UserConsentSection>,
@@ -67,6 +83,8 @@ pub struct Config {
pub oidc_providers: Vec<OidcProvider>,
pub server_name: String,
pub public_baseurl: Option<Url>,
}
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<Utc>,
) -> 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<String>,
/// Required, except for the old `oidc_config` where this is implied to be
/// "oidc".
pub idp_id: Option<String>,
}
fn default_true() -> bool {
true
}

View File

@@ -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<String>,
subject_claim: Option<String>,
localpart_template: Option<String>,
display_name_template: Option<String>,
email_template: Option<String>,
#[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<String>,
/// Required, except for the old `oidc_config` where this is implied to be
/// "oidc".
pub idp_id: Option<String>,
idp_name: Option<String>,
idp_brand: Option<String>,
#[serde(default = "default_true")]
discover: bool,
client_id: Option<String>,
client_secret: Option<String>,
// Unsupported, we want to shout about it
client_secret_path: Option<String>,
// Unsupported, we want to shout about it
client_secret_jwt_key: Option<serde_json::Value>,
client_auth_method: Option<UpstreamOAuth2TokenAuthMethod>,
#[serde(default)]
pkce_method: PkceMethod,
// Unsupported, we want to shout about it
id_token_signing_alg_values_supported: Option<Vec<String>>,
scopes: Option<Vec<String>>,
authorization_endpoint: Option<Url>,
token_endpoint: Option<Url>,
userinfo_endpoint: Option<Url>,
jwks_uri: Option<Url>,
#[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<serde_json::Value>,
// Unsupported, we want to shout about it
#[serde(default = "default_true")]
enable_registration: bool,
#[serde(default)]
additional_authorization_parameters: BTreeMap<String, String>,
#[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<Utc>,
) -> Option<mas_config::UpstreamOAuth2Provider> {
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,
})
}
}