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 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 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 client_secret_path: Option<String>,
146
147 client_secret_jwt_key: Option<serde_json::Value>,
149 client_auth_method: Option<UpstreamOAuth2TokenAuthMethod>,
150 #[serde(default)]
151 pkce_method: PkceMethod,
152 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 #[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 #[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(), 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 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 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 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}