syn2mas/synapse_reader/config/
mod.rs

1// Copyright 2025 New Vector Ltd.
2//
3// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
4// Please see LICENSE files in the repository root for full details.
5
6mod oidc;
7
8use std::collections::BTreeMap;
9
10use camino::Utf8PathBuf;
11use chrono::{DateTime, Utc};
12use figment::providers::{Format, Yaml};
13use mas_config::{PasswordAlgorithm, PasswordHashingScheme};
14use rand::Rng;
15use serde::Deserialize;
16use sqlx::postgres::PgConnectOptions;
17use tracing::warn;
18use url::Url;
19
20pub use self::oidc::OidcProvider;
21
22/// The root of a Synapse configuration.
23/// This struct only includes fields which the Synapse-to-MAS migration is
24/// interested in.
25///
26/// See: <https://element-hq.github.io/synapse/latest/usage/configuration/config_documentation.html>
27#[derive(Deserialize)]
28#[expect(clippy::struct_excessive_bools)]
29pub struct Config {
30    pub database: DatabaseSection,
31
32    #[serde(default)]
33    pub password_config: PasswordSection,
34
35    pub bcrypt_rounds: Option<u32>,
36
37    #[serde(default)]
38    pub allow_guest_access: bool,
39
40    #[serde(default)]
41    pub enable_registration: bool,
42
43    #[serde(default)]
44    pub enable_registration_captcha: bool,
45    pub recaptcha_public_key: Option<String>,
46    pub recaptcha_private_key: Option<String>,
47
48    /// Normally this defaults to true, but when MAS integration is enabled in
49    /// Synapse it defaults to false.
50    #[serde(default)]
51    pub enable_3pid_changes: Option<bool>,
52
53    #[serde(default = "default_true")]
54    enable_set_display_name: bool,
55
56    #[serde(default)]
57    pub user_consent: Option<UserConsentSection>,
58
59    #[serde(default)]
60    pub registrations_require_3pid: Vec<String>,
61
62    #[serde(default)]
63    pub registration_requires_token: bool,
64
65    pub registration_shared_secret: Option<String>,
66
67    #[serde(default)]
68    pub login_via_existing_session: EnableableSection,
69
70    #[serde(default)]
71    pub cas_config: EnableableSection,
72
73    #[serde(default)]
74    pub saml2_config: EnableableSection,
75
76    #[serde(default)]
77    pub jwt_config: EnableableSection,
78
79    #[serde(default)]
80    pub oidc_config: Option<OidcProvider>,
81
82    #[serde(default)]
83    pub oidc_providers: Vec<OidcProvider>,
84
85    pub server_name: String,
86
87    pub public_baseurl: Option<Url>,
88}
89
90impl Config {
91    /// Load a Synapse configuration from the given list of configuration files.
92    ///
93    /// # Errors
94    ///
95    /// - If there is a problem reading any of the files.
96    /// - If the configuration is not valid.
97    pub fn load(
98        files: &[Utf8PathBuf],
99    ) -> Result<Config, Box<dyn std::error::Error + Send + Sync + 'static>> {
100        let mut figment = figment::Figment::new();
101        for file in files {
102            // TODO this is not exactly correct behaviour — Synapse does not merge anything
103            // other than the top level dict.
104            // https://github.com/element-hq/matrix-authentication-service/pull/3805#discussion_r1922680825
105            // https://github.com/element-hq/synapse/blob/develop/synapse/config/_base.py?rgh-link-date=2025-01-20T17%3A02%3A56Z#L870
106            figment = figment.merge(Yaml::file(file));
107        }
108        let config = figment.extract::<Config>()?;
109        Ok(config)
110    }
111
112    /// Returns a map of all OIDC providers from the Synapse configuration.
113    ///
114    /// The keys are the `auth_provider` IDs as they would have been stored in
115    /// Synapse's database.
116    ///
117    /// These are compatible with the `synapse_idp_id` field of
118    /// [`mas_config::UpstreamOAuth2Provider`].
119    #[must_use]
120    pub fn all_oidc_providers(&self) -> BTreeMap<String, OidcProvider> {
121        let mut out = BTreeMap::new();
122
123        if let Some(provider) = &self.oidc_config
124            && provider.has_required_fields()
125        {
126            let mut provider = provider.clone();
127            // The legacy configuration has an implied IdP ID of `oidc`.
128            let idp_id = provider.idp_id.take().unwrap_or("oidc".to_owned());
129            provider.idp_id = Some(idp_id.clone());
130            out.insert(idp_id, provider);
131        }
132
133        for provider in &self.oidc_providers {
134            let mut provider = provider.clone();
135            let idp_id = match provider.idp_id.take() {
136                None => "oidc".to_owned(),
137                Some(idp_id) if idp_id == "oidc" => idp_id,
138                // Synapse internally prefixes the IdP IDs with `oidc-`.
139                Some(idp_id) => format!("oidc-{idp_id}"),
140            };
141            provider.idp_id = Some(idp_id.clone());
142            out.insert(idp_id, provider);
143        }
144
145        out
146    }
147
148    /// Adjust a MAS configuration to match this Synapse configuration.
149    #[must_use]
150    pub fn adjust_mas_config(
151        self,
152        mut mas_config: mas_config::RootConfig,
153        rng: &mut impl Rng,
154        now: DateTime<Utc>,
155    ) -> mas_config::RootConfig {
156        let providers = self.all_oidc_providers();
157        for provider in providers.into_values() {
158            let Some(mas_provider_config) = provider.into_mas_config(rng, now) else {
159                // TODO: better log message
160                warn!("Could not convert OIDC provider to MAS config");
161                continue;
162            };
163
164            mas_config
165                .upstream_oauth2
166                .providers
167                .push(mas_provider_config);
168        }
169
170        // TODO: manage when the option is not set
171        if let Some(enable_3pid_changes) = self.enable_3pid_changes {
172            mas_config.account.email_change_allowed = enable_3pid_changes;
173        }
174        mas_config.account.displayname_change_allowed = self.enable_set_display_name;
175        if self.password_config.enabled {
176            mas_config.passwords.enabled = true;
177            mas_config.passwords.schemes = vec![
178                // This is the password hashing scheme synapse uses
179                PasswordHashingScheme {
180                    version: 1,
181                    algorithm: PasswordAlgorithm::Bcrypt,
182                    cost: self.bcrypt_rounds,
183                    secret: self.password_config.pepper,
184                    secret_file: None,
185                    unicode_normalization: true,
186                },
187                // Use the default algorithm MAS uses as a second hashing scheme, so that users
188                // will get their password hash upgraded to a more modern algorithm over time
189                PasswordHashingScheme {
190                    version: 2,
191                    algorithm: PasswordAlgorithm::default(),
192                    cost: None,
193                    secret: None,
194                    secret_file: None,
195                    unicode_normalization: false,
196                },
197            ];
198
199            mas_config.account.password_registration_enabled = self.enable_registration;
200        } else {
201            mas_config.passwords.enabled = false;
202        }
203
204        if self.enable_registration_captcha {
205            mas_config.captcha.service = Some(mas_config::CaptchaServiceKind::RecaptchaV2);
206            mas_config.captcha.site_key = self.recaptcha_public_key;
207            mas_config.captcha.secret_key = self.recaptcha_private_key;
208        }
209
210        mas_config.matrix.homeserver = self.server_name;
211        if let Some(public_baseurl) = self.public_baseurl {
212            mas_config.matrix.endpoint = public_baseurl;
213        }
214
215        mas_config
216    }
217}
218
219/// The `database` section of the Synapse configuration.
220///
221/// See: <https://element-hq.github.io/synapse/latest/usage/configuration/config_documentation.html#database>
222#[derive(Deserialize)]
223pub struct DatabaseSection {
224    /// Expecting `psycopg2` for Postgres or `sqlite3` for `SQLite3`, but may be
225    /// an arbitrary string and future versions of Synapse may support other
226    /// database drivers, e.g. psycopg3.
227    pub name: String,
228    #[serde(default)]
229    pub args: DatabaseArgsSuboption,
230}
231
232/// The database driver name for Synapse when it is using Postgres via psycopg2.
233pub const SYNAPSE_DATABASE_DRIVER_NAME_PSYCOPG2: &str = "psycopg2";
234/// The database driver name for Synapse when it is using SQLite 3.
235pub const SYNAPSE_DATABASE_DRIVER_NAME_SQLITE3: &str = "sqlite3";
236
237impl DatabaseSection {
238    /// Process the configuration into Postgres connection options.
239    ///
240    /// Environment variables and libpq defaults will be used as fallback for
241    /// any missing values; this should match what Synapse does.
242    /// But note that if syn2mas is not run in the same context (host, user,
243    /// environment variables) as Synapse normally runs, then the connection
244    /// options may not be valid.
245    ///
246    /// # Errors
247    ///
248    /// Returns an error if this database configuration is invalid or
249    /// unsupported.
250    pub fn to_sqlx_postgres(&self) -> Result<PgConnectOptions, anyhow::Error> {
251        if self.name != SYNAPSE_DATABASE_DRIVER_NAME_PSYCOPG2 {
252            anyhow::bail!("syn2mas does not support the {} database driver", self.name);
253        }
254
255        if self.args.database.is_some() && self.args.dbname.is_some() {
256            anyhow::bail!(
257                "Only one of `database` and `dbname` may be specified in the Synapse database configuration, not both."
258            );
259        }
260
261        let mut opts = PgConnectOptions::new().application_name("syn2mas-synapse");
262
263        if let Some(host) = &self.args.host {
264            opts = opts.host(host);
265        }
266        if let Some(port) = self.args.port {
267            opts = opts.port(port);
268        }
269        if let Some(dbname) = &self.args.dbname {
270            opts = opts.database(dbname);
271        }
272        if let Some(database) = &self.args.database {
273            opts = opts.database(database);
274        }
275        if let Some(user) = &self.args.user {
276            opts = opts.username(user);
277        }
278        if let Some(password) = &self.args.password {
279            opts = opts.password(password);
280        }
281
282        Ok(opts)
283    }
284}
285
286/// The `args` suboption of the `database` section of the Synapse configuration.
287/// This struct assumes Postgres is in use and does not represent fields used by
288/// SQLite.
289#[derive(Deserialize, Default)]
290pub struct DatabaseArgsSuboption {
291    pub user: Option<String>,
292    pub password: Option<String>,
293    pub dbname: Option<String>,
294    // This is a deperecated way of specifying the database name.
295    pub database: Option<String>,
296    pub host: Option<String>,
297    pub port: Option<u16>,
298}
299
300/// The `password_config` section of the Synapse configuration.
301///
302/// See: <https://element-hq.github.io/synapse/latest/usage/configuration/config_documentation.html#password_config>
303#[derive(Deserialize)]
304pub struct PasswordSection {
305    #[serde(default = "default_true")]
306    pub enabled: bool,
307    #[serde(default = "default_true")]
308    pub localdb_enabled: bool,
309    pub pepper: Option<String>,
310}
311
312impl Default for PasswordSection {
313    fn default() -> Self {
314        PasswordSection {
315            enabled: true,
316            localdb_enabled: true,
317            pepper: None,
318        }
319    }
320}
321
322/// A section that we only care about whether it's enabled or not, but is not
323/// enabled by default.
324#[derive(Default, Deserialize)]
325pub struct EnableableSection {
326    #[serde(default)]
327    pub enabled: bool,
328}
329
330fn default_true() -> bool {
331    true
332}
333
334#[cfg(test)]
335mod test {
336    use sqlx::postgres::PgConnectOptions;
337
338    use super::{DatabaseArgsSuboption, DatabaseSection};
339
340    #[test]
341    fn test_to_sqlx_postgres() {
342        #[track_caller]
343        #[expect(clippy::needless_pass_by_value)]
344        fn assert_eq_options(config: DatabaseSection, uri: &str) {
345            let config_connect_options = config
346                .to_sqlx_postgres()
347                .expect("no connection options generated by config");
348            let uri_connect_options: PgConnectOptions = uri
349                .parse()
350                .expect("example URI did not parse as PgConnectionOptions");
351
352            assert_eq!(
353                config_connect_options.get_host(),
354                uri_connect_options.get_host()
355            );
356            assert_eq!(
357                config_connect_options.get_port(),
358                uri_connect_options.get_port()
359            );
360            assert_eq!(
361                config_connect_options.get_username(),
362                uri_connect_options.get_username()
363            );
364            // The password is not public so we can't assert it. But that's hopefully fine.
365            assert_eq!(
366                config_connect_options.get_database(),
367                uri_connect_options.get_database()
368            );
369        }
370
371        // SQLite configs are not accepted
372        assert!(
373            DatabaseSection {
374                name: "sqlite3".to_owned(),
375                args: DatabaseArgsSuboption::default(),
376            }
377            .to_sqlx_postgres()
378            .is_err()
379        );
380
381        // Only one of `database` and `dbname` may be specified
382        assert!(
383            DatabaseSection {
384                name: "psycopg2".to_owned(),
385                args: DatabaseArgsSuboption {
386                    user: Some("synapse_user".to_owned()),
387                    password: Some("verysecret".to_owned()),
388                    dbname: Some("synapse_db".to_owned()),
389                    database: Some("synapse_db".to_owned()),
390                    host: Some("synapse-db.example.com".to_owned()),
391                    port: Some(42),
392                },
393            }
394            .to_sqlx_postgres()
395            .is_err()
396        );
397
398        assert_eq_options(
399            DatabaseSection {
400                name: "psycopg2".to_owned(),
401                args: DatabaseArgsSuboption::default(),
402            },
403            "postgresql:///",
404        );
405        assert_eq_options(
406            DatabaseSection {
407                name: "psycopg2".to_owned(),
408                args: DatabaseArgsSuboption {
409                    user: Some("synapse_user".to_owned()),
410                    password: Some("verysecret".to_owned()),
411                    dbname: Some("synapse_db".to_owned()),
412                    database: None,
413                    host: Some("synapse-db.example.com".to_owned()),
414                    port: Some(42),
415                },
416            },
417            "postgresql://synapse_user:verysecret@synapse-db.example.com:42/synapse_db",
418        );
419    }
420}
421
422/// We don't care about any of the fields in this section,
423/// just whether it's present.
424#[derive(Deserialize)]
425pub struct UserConsentSection {}