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