Skip to main content

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