syn2mas/synapse_reader/config/
oidc.rs

1// Copyright 2025 New Vector Ltd.
2//
3// SPDX-License-Identifier: AGPL-3.0-only
4// Please see LICENSE in the repository root for full details.
5
6use std::{collections::BTreeMap, str::FromStr as _};
7
8use chrono::{DateTime, Utc};
9use mas_config::{
10    UpstreamOAuth2ClaimsImports, UpstreamOAuth2DiscoveryMode, UpstreamOAuth2ImportAction,
11    UpstreamOAuth2PkceMethod, UpstreamOAuth2ResponseMode, UpstreamOAuth2TokenAuthMethod,
12};
13use mas_iana::jose::JsonWebSignatureAlg;
14use oauth2_types::scope::{OPENID, Scope, ScopeToken};
15use rand::Rng;
16use serde::Deserialize;
17use tracing::warn;
18use ulid::Ulid;
19use url::Url;
20
21#[derive(Clone, Deserialize, Default)]
22enum UserMappingProviderModule {
23    #[default]
24    #[serde(rename = "synapse.handlers.oidc.JinjaOidcMappingProvider")]
25    Jinja,
26
27    #[serde(rename = "synapse.handlers.oidc_handler.JinjaOidcMappingProvider")]
28    JinjaLegacy,
29
30    #[serde(other)]
31    Other,
32}
33
34#[derive(Clone, Deserialize, Default)]
35struct UserMappingProviderConfig {
36    subject_template: Option<String>,
37    subject_claim: Option<String>,
38    localpart_template: Option<String>,
39    display_name_template: Option<String>,
40    email_template: Option<String>,
41
42    #[serde(default)]
43    confirm_localpart: bool,
44}
45
46impl UserMappingProviderConfig {
47    fn into_mas_config(self) -> UpstreamOAuth2ClaimsImports {
48        let mut config = UpstreamOAuth2ClaimsImports::default();
49
50        match (self.subject_claim, self.subject_template) {
51            (Some(_), Some(subject_template)) => {
52                warn!(
53                    "Both `subject_claim` and `subject_template` options are set, using `subject_template`."
54                );
55                config.subject.template = Some(subject_template);
56            }
57            (None, Some(subject_template)) => {
58                config.subject.template = Some(subject_template);
59            }
60            (Some(subject_claim), None) => {
61                config.subject.template = Some(format!("{{{{ user.{subject_claim} }}}}"));
62            }
63            (None, None) => {}
64        }
65
66        if let Some(localpart_template) = self.localpart_template {
67            config.localpart.template = Some(localpart_template);
68            config.localpart.action = if self.confirm_localpart {
69                UpstreamOAuth2ImportAction::Suggest
70            } else {
71                UpstreamOAuth2ImportAction::Require
72            };
73        }
74
75        if let Some(displayname_template) = self.display_name_template {
76            config.displayname.template = Some(displayname_template);
77            config.displayname.action = if self.confirm_localpart {
78                UpstreamOAuth2ImportAction::Suggest
79            } else {
80                UpstreamOAuth2ImportAction::Force
81            };
82        }
83
84        if let Some(email_template) = self.email_template {
85            config.email.template = Some(email_template);
86            config.email.action = if self.confirm_localpart {
87                UpstreamOAuth2ImportAction::Suggest
88            } else {
89                UpstreamOAuth2ImportAction::Force
90            };
91        }
92
93        config
94    }
95}
96
97#[derive(Clone, Deserialize, Default)]
98struct UserMappingProvider {
99    #[serde(default)]
100    module: UserMappingProviderModule,
101    #[serde(default)]
102    config: UserMappingProviderConfig,
103}
104
105#[derive(Clone, Deserialize, Default)]
106#[serde(rename_all = "lowercase")]
107enum PkceMethod {
108    #[default]
109    Auto,
110    Always,
111    Never,
112    #[serde(other)]
113    Other,
114}
115
116#[derive(Clone, Deserialize, Default)]
117#[serde(rename_all = "snake_case")]
118enum UserProfileMethod {
119    #[default]
120    Auto,
121    UserinfoEndpoint,
122    #[serde(other)]
123    Other,
124}
125
126#[derive(Clone, Deserialize)]
127#[expect(clippy::struct_excessive_bools)]
128pub struct OidcProvider {
129    pub issuer: Option<String>,
130
131    /// Required, except for the old `oidc_config` where this is implied to be
132    /// "oidc".
133    pub idp_id: Option<String>,
134
135    idp_name: Option<String>,
136    idp_brand: Option<String>,
137
138    #[serde(default = "default_true")]
139    discover: bool,
140
141    client_id: Option<String>,
142    client_secret: Option<String>,
143
144    // Unsupported, we want to shout about it
145    client_secret_path: Option<String>,
146
147    // Unsupported, we want to shout about it
148    client_secret_jwt_key: Option<serde_json::Value>,
149    client_auth_method: Option<UpstreamOAuth2TokenAuthMethod>,
150    #[serde(default)]
151    pkce_method: PkceMethod,
152    // Unsupported, we want to shout about it
153    id_token_signing_alg_values_supported: Option<Vec<String>>,
154    scopes: Option<Vec<String>>,
155    authorization_endpoint: Option<Url>,
156    token_endpoint: Option<Url>,
157    userinfo_endpoint: Option<Url>,
158    jwks_uri: Option<Url>,
159    #[serde(default)]
160    skip_verification: bool,
161
162    // Unsupported, we want to shout about it
163    #[serde(default)]
164    backchannel_logout_enabled: bool,
165
166    #[serde(default)]
167    user_profile_method: UserProfileMethod,
168
169    // Unsupported, we want to shout about it
170    attribute_requirements: Option<serde_json::Value>,
171
172    // Unsupported, we want to shout about it
173    #[serde(default = "default_true")]
174    enable_registration: bool,
175    #[serde(default)]
176    additional_authorization_parameters: BTreeMap<String, String>,
177    #[serde(default)]
178    forward_login_hint: bool,
179    #[serde(default)]
180    user_mapping_provider: UserMappingProvider,
181}
182
183fn default_true() -> bool {
184    true
185}
186
187impl OidcProvider {
188    /// Returns true if the two 'required' fields are set. This is used to
189    /// ignore an empty dict on the `oidc_config` section.
190    #[must_use]
191    pub(crate) fn has_required_fields(&self) -> bool {
192        self.issuer.is_some() && self.client_id.is_some()
193    }
194
195    /// Map this Synapse OIDC provider config to a MAS upstream provider config.
196    #[expect(clippy::too_many_lines)]
197    pub(crate) fn into_mas_config(
198        self,
199        rng: &mut impl Rng,
200        now: DateTime<Utc>,
201    ) -> Option<mas_config::UpstreamOAuth2Provider> {
202        let client_id = self.client_id?;
203
204        if self.client_secret_path.is_some() {
205            warn!(
206                "The `client_secret_path` option is not supported, ignoring. You *will* need to include the secret in the `client_secret` field."
207            );
208        }
209
210        if self.client_secret_jwt_key.is_some() {
211            warn!("The `client_secret_jwt_key` option is not supported, ignoring.");
212        }
213
214        if self.attribute_requirements.is_some() {
215            warn!("The `attribute_requirements` option is not supported, ignoring.");
216        }
217
218        if self.id_token_signing_alg_values_supported.is_some() {
219            warn!("The `id_token_signing_alg_values_supported` option is not supported, ignoring.");
220        }
221
222        if self.backchannel_logout_enabled {
223            warn!("The `backchannel_logout_enabled` option is not supported, ignoring.");
224        }
225
226        if !self.enable_registration {
227            warn!(
228                "Setting the `enable_registration` option to `false` is not supported, ignoring."
229            );
230        }
231
232        let scope: Scope = match self.scopes {
233            None => [OPENID].into_iter().collect(), // Synapse defaults to the 'openid' scope
234            Some(scopes) => scopes
235                .into_iter()
236                .filter_map(|scope| match ScopeToken::from_str(&scope) {
237                    Ok(scope) => Some(scope),
238                    Err(err) => {
239                        warn!("OIDC provider scope '{scope}' is invalid: {err}");
240                        None
241                    }
242                })
243                .collect(),
244        };
245
246        let id = Ulid::from_datetime_with_source(now.into(), rng);
247
248        let token_endpoint_auth_method = self.client_auth_method.unwrap_or_else(|| {
249            // The token auth method defaults to 'none' if no client_secret is set and
250            // 'client_secret_basic' otherwise
251            if self.client_secret.is_some() {
252                UpstreamOAuth2TokenAuthMethod::ClientSecretBasic
253            } else {
254                UpstreamOAuth2TokenAuthMethod::None
255            }
256        });
257
258        let discovery_mode = match (self.discover, self.skip_verification) {
259            (true, false) => UpstreamOAuth2DiscoveryMode::Oidc,
260            (true, true) => UpstreamOAuth2DiscoveryMode::Insecure,
261            (false, _) => UpstreamOAuth2DiscoveryMode::Disabled,
262        };
263
264        let pkce_method = match self.pkce_method {
265            PkceMethod::Auto => UpstreamOAuth2PkceMethod::Auto,
266            PkceMethod::Always => UpstreamOAuth2PkceMethod::Always,
267            PkceMethod::Never => UpstreamOAuth2PkceMethod::Never,
268            PkceMethod::Other => {
269                warn!(
270                    "The `pkce_method` option is not supported, expected 'auto', 'always', or 'never'; assuming 'auto'."
271                );
272                UpstreamOAuth2PkceMethod::default()
273            }
274        };
275
276        // "auto" doesn't mean the same thing depending on whether we request the openid
277        // scope or not
278        let has_openid_scope = scope.contains(&OPENID);
279        let fetch_userinfo = match self.user_profile_method {
280            UserProfileMethod::Auto => has_openid_scope,
281            UserProfileMethod::UserinfoEndpoint => true,
282            UserProfileMethod::Other => {
283                warn!(
284                    "The `user_profile_method` option is not supported, expected 'auto' or 'userinfo_endpoint'; assuming 'auto'."
285                );
286                has_openid_scope
287            }
288        };
289
290        // Check if there is a `response_mode` set in the additional authorization
291        // parameters
292        let mut additional_authorization_parameters = self.additional_authorization_parameters;
293        let response_mode = if let Some(response_mode) =
294            additional_authorization_parameters.remove("response_mode")
295        {
296            match response_mode.to_ascii_lowercase().as_str() {
297                "query" => Some(UpstreamOAuth2ResponseMode::Query),
298                "form_post" => Some(UpstreamOAuth2ResponseMode::FormPost),
299                _ => {
300                    warn!(
301                        "Invalid `response_mode` in the `additional_authorization_parameters` option, expected 'query' or 'form_post'; ignoring."
302                    );
303                    None
304                }
305            }
306        } else {
307            None
308        };
309
310        let claims_imports = if matches!(
311            self.user_mapping_provider.module,
312            UserMappingProviderModule::Other
313        ) {
314            warn!(
315                "The `user_mapping_provider` module specified is not supported, ignoring. Please adjust the `claims_imports` to match the mapping provider behaviour."
316            );
317            UpstreamOAuth2ClaimsImports::default()
318        } else {
319            self.user_mapping_provider.config.into_mas_config()
320        };
321
322        Some(mas_config::UpstreamOAuth2Provider {
323            enabled: true,
324            id,
325            synapse_idp_id: self.idp_id,
326            issuer: self.issuer,
327            human_name: self.idp_name,
328            brand_name: self.idp_brand,
329            client_id,
330            client_secret: self.client_secret,
331            token_endpoint_auth_method,
332            sign_in_with_apple: None,
333            token_endpoint_auth_signing_alg: None,
334            id_token_signed_response_alg: JsonWebSignatureAlg::Rs256,
335            scope: scope.to_string(),
336            discovery_mode,
337            pkce_method,
338            fetch_userinfo,
339            userinfo_signed_response_alg: None,
340            authorization_endpoint: self.authorization_endpoint,
341            userinfo_endpoint: self.userinfo_endpoint,
342            token_endpoint: self.token_endpoint,
343            jwks_uri: self.jwks_uri,
344            response_mode,
345            claims_imports,
346            additional_authorization_parameters,
347            forward_login_hint: self.forward_login_hint,
348        })
349    }
350}