syn2mas/synapse_reader/config/
oidc.rs1use 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 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 client_secret_path: Option<String>,
147
148 client_secret_jwt_key: Option<serde_json::Value>,
150 client_auth_method: Option<UpstreamOAuth2TokenAuthMethod>,
151 #[serde(default)]
152 pkce_method: PkceMethod,
153 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 attribute_requirements: Option<serde_json::Value>,
171
172 #[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 #[must_use]
191 pub(crate) fn has_required_fields(&self) -> bool {
192 self.issuer.is_some() && self.client_id.is_some()
193 }
194
195 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(), 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 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 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 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}