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