1use std::collections::BTreeMap;
8
9use camino::Utf8PathBuf;
10use mas_iana::jose::JsonWebSignatureAlg;
11use schemars::JsonSchema;
12use serde::{Deserialize, Serialize, de::Error};
13use serde_with::skip_serializing_none;
14use ulid::Ulid;
15use url::Url;
16
17use crate::ConfigurationSection;
18
19#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)]
21pub struct UpstreamOAuth2Config {
22 pub providers: Vec<Provider>,
24}
25
26impl UpstreamOAuth2Config {
27 pub(crate) fn is_default(&self) -> bool {
29 self.providers.is_empty()
30 }
31}
32
33impl ConfigurationSection for UpstreamOAuth2Config {
34 const PATH: Option<&'static str> = Some("upstream_oauth2");
35
36 fn validate(
37 &self,
38 figment: &figment::Figment,
39 ) -> Result<(), Box<dyn std::error::Error + Send + Sync + 'static>> {
40 for (index, provider) in self.providers.iter().enumerate() {
41 let annotate = |mut error: figment::Error| {
42 error.metadata = figment
43 .find_metadata(&format!("{root}.providers", root = Self::PATH.unwrap()))
44 .cloned();
45 error.profile = Some(figment::Profile::Default);
46 error.path = vec![
47 Self::PATH.unwrap().to_owned(),
48 "providers".to_owned(),
49 index.to_string(),
50 ];
51 error
52 };
53
54 if !matches!(provider.discovery_mode, DiscoveryMode::Disabled)
55 && provider.issuer.is_none()
56 {
57 return Err(annotate(figment::Error::custom(
58 "The `issuer` field is required when discovery is enabled",
59 ))
60 .into());
61 }
62
63 match provider.token_endpoint_auth_method {
64 TokenAuthMethod::None
65 | TokenAuthMethod::PrivateKeyJwt
66 | TokenAuthMethod::SignInWithApple => {
67 if provider.client_secret.is_some() {
68 return Err(annotate(figment::Error::custom(
69 "Unexpected field `client_secret` for the selected authentication method",
70 )).into());
71 }
72 }
73 TokenAuthMethod::ClientSecretBasic
74 | TokenAuthMethod::ClientSecretPost
75 | TokenAuthMethod::ClientSecretJwt => {
76 if provider.client_secret.is_none() {
77 return Err(annotate(figment::Error::missing_field("client_secret")).into());
78 }
79 }
80 }
81
82 match provider.token_endpoint_auth_method {
83 TokenAuthMethod::None
84 | TokenAuthMethod::ClientSecretBasic
85 | TokenAuthMethod::ClientSecretPost
86 | TokenAuthMethod::SignInWithApple => {
87 if provider.token_endpoint_auth_signing_alg.is_some() {
88 return Err(annotate(figment::Error::custom(
89 "Unexpected field `token_endpoint_auth_signing_alg` for the selected authentication method",
90 )).into());
91 }
92 }
93 TokenAuthMethod::ClientSecretJwt | TokenAuthMethod::PrivateKeyJwt => {
94 if provider.token_endpoint_auth_signing_alg.is_none() {
95 return Err(annotate(figment::Error::missing_field(
96 "token_endpoint_auth_signing_alg",
97 ))
98 .into());
99 }
100 }
101 }
102
103 match provider.token_endpoint_auth_method {
104 TokenAuthMethod::SignInWithApple => {
105 if provider.sign_in_with_apple.is_none() {
106 return Err(
107 annotate(figment::Error::missing_field("sign_in_with_apple")).into(),
108 );
109 }
110 }
111
112 _ => {
113 if provider.sign_in_with_apple.is_some() {
114 return Err(annotate(figment::Error::custom(
115 "Unexpected field `sign_in_with_apple` for the selected authentication method",
116 )).into());
117 }
118 }
119 }
120
121 if matches!(
122 provider.claims_imports.localpart.on_conflict,
123 OnConflict::Add
124 ) && !matches!(
125 provider.claims_imports.localpart.action,
126 ImportAction::Force | ImportAction::Require
127 ) {
128 return Err(annotate(figment::Error::custom(
129 "The field `action` must be either `force` or `require` when `on_conflict` is set to `add`",
130 )).into());
131 }
132 }
133
134 Ok(())
135 }
136}
137
138#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema)]
140#[serde(rename_all = "snake_case")]
141pub enum ResponseMode {
142 Query,
145
146 FormPost,
151}
152
153#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema)]
155#[serde(rename_all = "snake_case")]
156pub enum TokenAuthMethod {
157 None,
159
160 ClientSecretBasic,
163
164 ClientSecretPost,
167
168 ClientSecretJwt,
171
172 PrivateKeyJwt,
175
176 SignInWithApple,
178}
179
180#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, JsonSchema)]
182#[serde(rename_all = "lowercase")]
183pub enum ImportAction {
184 #[default]
186 Ignore,
187
188 Suggest,
190
191 Force,
193
194 Require,
196}
197
198impl ImportAction {
199 #[allow(clippy::trivially_copy_pass_by_ref)]
200 const fn is_default(&self) -> bool {
201 matches!(self, ImportAction::Ignore)
202 }
203}
204
205#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, JsonSchema)]
207#[serde(rename_all = "lowercase")]
208pub enum OnConflict {
209 #[default]
211 Fail,
212
213 Add,
216}
217
218impl OnConflict {
219 #[allow(clippy::trivially_copy_pass_by_ref)]
220 const fn is_default(&self) -> bool {
221 matches!(self, OnConflict::Fail)
222 }
223}
224
225#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default, JsonSchema)]
227pub struct SubjectImportPreference {
228 #[serde(default, skip_serializing_if = "Option::is_none")]
232 pub template: Option<String>,
233}
234
235impl SubjectImportPreference {
236 const fn is_default(&self) -> bool {
237 self.template.is_none()
238 }
239}
240
241#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default, JsonSchema)]
243pub struct LocalpartImportPreference {
244 #[serde(default, skip_serializing_if = "ImportAction::is_default")]
246 pub action: ImportAction,
247
248 #[serde(default, skip_serializing_if = "Option::is_none")]
252 pub template: Option<String>,
253
254 #[serde(default, skip_serializing_if = "OnConflict::is_default")]
256 pub on_conflict: OnConflict,
257}
258
259impl LocalpartImportPreference {
260 const fn is_default(&self) -> bool {
261 self.action.is_default() && self.template.is_none()
262 }
263}
264
265#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default, JsonSchema)]
267pub struct DisplaynameImportPreference {
268 #[serde(default, skip_serializing_if = "ImportAction::is_default")]
270 pub action: ImportAction,
271
272 #[serde(default, skip_serializing_if = "Option::is_none")]
276 pub template: Option<String>,
277}
278
279impl DisplaynameImportPreference {
280 const fn is_default(&self) -> bool {
281 self.action.is_default() && self.template.is_none()
282 }
283}
284
285#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default, JsonSchema)]
287pub struct EmailImportPreference {
288 #[serde(default, skip_serializing_if = "ImportAction::is_default")]
290 pub action: ImportAction,
291
292 #[serde(default, skip_serializing_if = "Option::is_none")]
296 pub template: Option<String>,
297}
298
299impl EmailImportPreference {
300 const fn is_default(&self) -> bool {
301 self.action.is_default() && self.template.is_none()
302 }
303}
304
305#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default, JsonSchema)]
307pub struct AccountNameImportPreference {
308 #[serde(default, skip_serializing_if = "Option::is_none")]
313 pub template: Option<String>,
314}
315
316impl AccountNameImportPreference {
317 const fn is_default(&self) -> bool {
318 self.template.is_none()
319 }
320}
321
322#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default, JsonSchema)]
324pub struct ClaimsImports {
325 #[serde(default, skip_serializing_if = "SubjectImportPreference::is_default")]
327 pub subject: SubjectImportPreference,
328
329 #[serde(default, skip_serializing_if = "LocalpartImportPreference::is_default")]
331 pub localpart: LocalpartImportPreference,
332
333 #[serde(
335 default,
336 skip_serializing_if = "DisplaynameImportPreference::is_default"
337 )]
338 pub displayname: DisplaynameImportPreference,
339
340 #[serde(default, skip_serializing_if = "EmailImportPreference::is_default")]
343 pub email: EmailImportPreference,
344
345 #[serde(
347 default,
348 skip_serializing_if = "AccountNameImportPreference::is_default"
349 )]
350 pub account_name: AccountNameImportPreference,
351}
352
353impl ClaimsImports {
354 const fn is_default(&self) -> bool {
355 self.subject.is_default()
356 && self.localpart.is_default()
357 && self.displayname.is_default()
358 && self.email.is_default()
359 }
360}
361
362#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, Default)]
364#[serde(rename_all = "snake_case")]
365pub enum DiscoveryMode {
366 #[default]
368 Oidc,
369
370 Insecure,
372
373 Disabled,
375}
376
377impl DiscoveryMode {
378 #[allow(clippy::trivially_copy_pass_by_ref)]
379 const fn is_default(&self) -> bool {
380 matches!(self, DiscoveryMode::Oidc)
381 }
382}
383
384#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, Default)]
387#[serde(rename_all = "snake_case")]
388pub enum PkceMethod {
389 #[default]
393 Auto,
394
395 Always,
397
398 Never,
400}
401
402impl PkceMethod {
403 #[allow(clippy::trivially_copy_pass_by_ref)]
404 const fn is_default(&self) -> bool {
405 matches!(self, PkceMethod::Auto)
406 }
407}
408
409fn default_true() -> bool {
410 true
411}
412
413#[allow(clippy::trivially_copy_pass_by_ref)]
414fn is_default_true(value: &bool) -> bool {
415 *value
416}
417
418#[allow(clippy::ref_option)]
419fn is_signed_response_alg_default(signed_response_alg: &JsonWebSignatureAlg) -> bool {
420 *signed_response_alg == signed_response_alg_default()
421}
422
423#[allow(clippy::unnecessary_wraps)]
424fn signed_response_alg_default() -> JsonWebSignatureAlg {
425 JsonWebSignatureAlg::Rs256
426}
427
428#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
429pub struct SignInWithApple {
430 #[serde(skip_serializing_if = "Option::is_none")]
432 #[schemars(with = "Option<String>")]
433 pub private_key_file: Option<Utf8PathBuf>,
434
435 #[serde(skip_serializing_if = "Option::is_none")]
437 pub private_key: Option<String>,
438
439 pub team_id: String,
441
442 pub key_id: String,
444}
445
446fn default_scope() -> String {
447 "openid".to_owned()
448}
449
450fn is_default_scope(scope: &str) -> bool {
451 scope == default_scope()
452}
453
454#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, Default)]
456#[serde(rename_all = "snake_case")]
457pub enum OnBackchannelLogout {
458 #[default]
460 DoNothing,
461
462 LogoutBrowserOnly,
464
465 LogoutAll,
468}
469
470impl OnBackchannelLogout {
471 #[allow(clippy::trivially_copy_pass_by_ref)]
472 const fn is_default(&self) -> bool {
473 matches!(self, OnBackchannelLogout::DoNothing)
474 }
475}
476
477#[skip_serializing_none]
479#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
480pub struct Provider {
481 #[serde(default = "default_true", skip_serializing_if = "is_default_true")]
485 pub enabled: bool,
486
487 #[schemars(
489 with = "String",
490 regex(pattern = r"^[0123456789ABCDEFGHJKMNPQRSTVWXYZ]{26}$"),
491 description = "A ULID as per https://github.com/ulid/spec"
492 )]
493 pub id: Ulid,
494
495 #[serde(skip_serializing_if = "Option::is_none")]
510 pub synapse_idp_id: Option<String>,
511
512 #[serde(skip_serializing_if = "Option::is_none")]
516 pub issuer: Option<String>,
517
518 #[serde(skip_serializing_if = "Option::is_none")]
520 pub human_name: Option<String>,
521
522 #[serde(skip_serializing_if = "Option::is_none")]
535 pub brand_name: Option<String>,
536
537 pub client_id: String,
539
540 #[serde(skip_serializing_if = "Option::is_none")]
545 pub client_secret: Option<String>,
546
547 pub token_endpoint_auth_method: TokenAuthMethod,
549
550 #[serde(skip_serializing_if = "Option::is_none")]
552 pub sign_in_with_apple: Option<SignInWithApple>,
553
554 #[serde(skip_serializing_if = "Option::is_none")]
559 pub token_endpoint_auth_signing_alg: Option<JsonWebSignatureAlg>,
560
561 #[serde(
566 default = "signed_response_alg_default",
567 skip_serializing_if = "is_signed_response_alg_default"
568 )]
569 pub id_token_signed_response_alg: JsonWebSignatureAlg,
570
571 #[serde(default = "default_scope", skip_serializing_if = "is_default_scope")]
575 pub scope: String,
576
577 #[serde(default, skip_serializing_if = "DiscoveryMode::is_default")]
582 pub discovery_mode: DiscoveryMode,
583
584 #[serde(default, skip_serializing_if = "PkceMethod::is_default")]
589 pub pkce_method: PkceMethod,
590
591 #[serde(default)]
597 pub fetch_userinfo: bool,
598
599 #[serde(skip_serializing_if = "Option::is_none")]
605 pub userinfo_signed_response_alg: Option<JsonWebSignatureAlg>,
606
607 #[serde(skip_serializing_if = "Option::is_none")]
611 pub authorization_endpoint: Option<Url>,
612
613 #[serde(skip_serializing_if = "Option::is_none")]
617 pub userinfo_endpoint: Option<Url>,
618
619 #[serde(skip_serializing_if = "Option::is_none")]
623 pub token_endpoint: Option<Url>,
624
625 #[serde(skip_serializing_if = "Option::is_none")]
629 pub jwks_uri: Option<Url>,
630
631 #[serde(skip_serializing_if = "Option::is_none")]
633 pub response_mode: Option<ResponseMode>,
634
635 #[serde(default, skip_serializing_if = "ClaimsImports::is_default")]
638 pub claims_imports: ClaimsImports,
639
640 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
644 pub additional_authorization_parameters: BTreeMap<String, String>,
645
646 #[serde(default)]
651 pub forward_login_hint: bool,
652
653 #[serde(default, skip_serializing_if = "OnBackchannelLogout::is_default")]
657 pub on_backchannel_logout: OnBackchannelLogout,
658}