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
122 Ok(())
123 }
124}
125
126#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema)]
128#[serde(rename_all = "snake_case")]
129pub enum ResponseMode {
130 Query,
133
134 FormPost,
139}
140
141#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema)]
143#[serde(rename_all = "snake_case")]
144pub enum TokenAuthMethod {
145 None,
147
148 ClientSecretBasic,
151
152 ClientSecretPost,
155
156 ClientSecretJwt,
159
160 PrivateKeyJwt,
163
164 SignInWithApple,
166}
167
168#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, JsonSchema)]
170#[serde(rename_all = "lowercase")]
171pub enum ImportAction {
172 #[default]
174 Ignore,
175
176 Suggest,
178
179 Force,
181
182 Require,
184}
185
186impl ImportAction {
187 #[allow(clippy::trivially_copy_pass_by_ref)]
188 const fn is_default(&self) -> bool {
189 matches!(self, ImportAction::Ignore)
190 }
191}
192
193#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default, JsonSchema)]
195pub struct SubjectImportPreference {
196 #[serde(default, skip_serializing_if = "Option::is_none")]
200 pub template: Option<String>,
201}
202
203impl SubjectImportPreference {
204 const fn is_default(&self) -> bool {
205 self.template.is_none()
206 }
207}
208
209#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default, JsonSchema)]
211pub struct LocalpartImportPreference {
212 #[serde(default, skip_serializing_if = "ImportAction::is_default")]
214 pub action: ImportAction,
215
216 #[serde(default, skip_serializing_if = "Option::is_none")]
220 pub template: Option<String>,
221}
222
223impl LocalpartImportPreference {
224 const fn is_default(&self) -> bool {
225 self.action.is_default() && self.template.is_none()
226 }
227}
228
229#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default, JsonSchema)]
231pub struct DisplaynameImportPreference {
232 #[serde(default, skip_serializing_if = "ImportAction::is_default")]
234 pub action: ImportAction,
235
236 #[serde(default, skip_serializing_if = "Option::is_none")]
240 pub template: Option<String>,
241}
242
243impl DisplaynameImportPreference {
244 const fn is_default(&self) -> bool {
245 self.action.is_default() && self.template.is_none()
246 }
247}
248
249#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default, JsonSchema)]
251pub struct EmailImportPreference {
252 #[serde(default, skip_serializing_if = "ImportAction::is_default")]
254 pub action: ImportAction,
255
256 #[serde(default, skip_serializing_if = "Option::is_none")]
260 pub template: Option<String>,
261}
262
263impl EmailImportPreference {
264 const fn is_default(&self) -> bool {
265 self.action.is_default() && self.template.is_none()
266 }
267}
268
269#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default, JsonSchema)]
271pub struct AccountNameImportPreference {
272 #[serde(default, skip_serializing_if = "Option::is_none")]
277 pub template: Option<String>,
278}
279
280impl AccountNameImportPreference {
281 const fn is_default(&self) -> bool {
282 self.template.is_none()
283 }
284}
285
286#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default, JsonSchema)]
288pub struct ClaimsImports {
289 #[serde(default, skip_serializing_if = "SubjectImportPreference::is_default")]
291 pub subject: SubjectImportPreference,
292
293 #[serde(default, skip_serializing_if = "LocalpartImportPreference::is_default")]
295 pub localpart: LocalpartImportPreference,
296
297 #[serde(
299 default,
300 skip_serializing_if = "DisplaynameImportPreference::is_default"
301 )]
302 pub displayname: DisplaynameImportPreference,
303
304 #[serde(default, skip_serializing_if = "EmailImportPreference::is_default")]
307 pub email: EmailImportPreference,
308
309 #[serde(
311 default,
312 skip_serializing_if = "AccountNameImportPreference::is_default"
313 )]
314 pub account_name: AccountNameImportPreference,
315}
316
317impl ClaimsImports {
318 const fn is_default(&self) -> bool {
319 self.subject.is_default()
320 && self.localpart.is_default()
321 && self.displayname.is_default()
322 && self.email.is_default()
323 }
324}
325
326#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, Default)]
328#[serde(rename_all = "snake_case")]
329pub enum DiscoveryMode {
330 #[default]
332 Oidc,
333
334 Insecure,
336
337 Disabled,
339}
340
341impl DiscoveryMode {
342 #[allow(clippy::trivially_copy_pass_by_ref)]
343 const fn is_default(&self) -> bool {
344 matches!(self, DiscoveryMode::Oidc)
345 }
346}
347
348#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, Default)]
351#[serde(rename_all = "snake_case")]
352pub enum PkceMethod {
353 #[default]
357 Auto,
358
359 Always,
361
362 Never,
364}
365
366impl PkceMethod {
367 #[allow(clippy::trivially_copy_pass_by_ref)]
368 const fn is_default(&self) -> bool {
369 matches!(self, PkceMethod::Auto)
370 }
371}
372
373fn default_true() -> bool {
374 true
375}
376
377#[allow(clippy::trivially_copy_pass_by_ref)]
378fn is_default_true(value: &bool) -> bool {
379 *value
380}
381
382#[allow(clippy::ref_option)]
383fn is_signed_response_alg_default(signed_response_alg: &JsonWebSignatureAlg) -> bool {
384 *signed_response_alg == signed_response_alg_default()
385}
386
387#[allow(clippy::unnecessary_wraps)]
388fn signed_response_alg_default() -> JsonWebSignatureAlg {
389 JsonWebSignatureAlg::Rs256
390}
391
392#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
393pub struct SignInWithApple {
394 #[serde(skip_serializing_if = "Option::is_none")]
396 #[schemars(with = "Option<String>")]
397 pub private_key_file: Option<Utf8PathBuf>,
398
399 #[serde(skip_serializing_if = "Option::is_none")]
401 pub private_key: Option<String>,
402
403 pub team_id: String,
405
406 pub key_id: String,
408}
409
410fn default_scope() -> String {
411 "openid".to_owned()
412}
413
414fn is_default_scope(scope: &str) -> bool {
415 scope == default_scope()
416}
417
418#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, Default)]
420#[serde(rename_all = "snake_case")]
421pub enum OnBackchannelLogout {
422 #[default]
424 DoNothing,
425
426 LogoutBrowserOnly,
428
429 LogoutAll,
432}
433
434impl OnBackchannelLogout {
435 #[allow(clippy::trivially_copy_pass_by_ref)]
436 const fn is_default(&self) -> bool {
437 matches!(self, OnBackchannelLogout::DoNothing)
438 }
439}
440
441#[skip_serializing_none]
443#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
444pub struct Provider {
445 #[serde(default = "default_true", skip_serializing_if = "is_default_true")]
449 pub enabled: bool,
450
451 #[schemars(
453 with = "String",
454 regex(pattern = r"^[0123456789ABCDEFGHJKMNPQRSTVWXYZ]{26}$"),
455 description = "A ULID as per https://github.com/ulid/spec"
456 )]
457 pub id: Ulid,
458
459 #[serde(skip_serializing_if = "Option::is_none")]
474 pub synapse_idp_id: Option<String>,
475
476 #[serde(skip_serializing_if = "Option::is_none")]
480 pub issuer: Option<String>,
481
482 #[serde(skip_serializing_if = "Option::is_none")]
484 pub human_name: Option<String>,
485
486 #[serde(skip_serializing_if = "Option::is_none")]
499 pub brand_name: Option<String>,
500
501 pub client_id: String,
503
504 #[serde(skip_serializing_if = "Option::is_none")]
509 pub client_secret: Option<String>,
510
511 pub token_endpoint_auth_method: TokenAuthMethod,
513
514 #[serde(skip_serializing_if = "Option::is_none")]
516 pub sign_in_with_apple: Option<SignInWithApple>,
517
518 #[serde(skip_serializing_if = "Option::is_none")]
523 pub token_endpoint_auth_signing_alg: Option<JsonWebSignatureAlg>,
524
525 #[serde(
530 default = "signed_response_alg_default",
531 skip_serializing_if = "is_signed_response_alg_default"
532 )]
533 pub id_token_signed_response_alg: JsonWebSignatureAlg,
534
535 #[serde(default = "default_scope", skip_serializing_if = "is_default_scope")]
539 pub scope: String,
540
541 #[serde(default, skip_serializing_if = "DiscoveryMode::is_default")]
546 pub discovery_mode: DiscoveryMode,
547
548 #[serde(default, skip_serializing_if = "PkceMethod::is_default")]
553 pub pkce_method: PkceMethod,
554
555 #[serde(default)]
561 pub fetch_userinfo: bool,
562
563 #[serde(skip_serializing_if = "Option::is_none")]
569 pub userinfo_signed_response_alg: Option<JsonWebSignatureAlg>,
570
571 #[serde(skip_serializing_if = "Option::is_none")]
575 pub authorization_endpoint: Option<Url>,
576
577 #[serde(skip_serializing_if = "Option::is_none")]
581 pub userinfo_endpoint: Option<Url>,
582
583 #[serde(skip_serializing_if = "Option::is_none")]
587 pub token_endpoint: Option<Url>,
588
589 #[serde(skip_serializing_if = "Option::is_none")]
593 pub jwks_uri: Option<Url>,
594
595 #[serde(skip_serializing_if = "Option::is_none")]
597 pub response_mode: Option<ResponseMode>,
598
599 #[serde(default, skip_serializing_if = "ClaimsImports::is_default")]
602 pub claims_imports: ClaimsImports,
603
604 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
608 pub additional_authorization_parameters: BTreeMap<String, String>,
609
610 #[serde(default)]
615 pub forward_login_hint: bool,
616
617 #[serde(default, skip_serializing_if = "OnBackchannelLogout::is_default")]
621 pub on_backchannel_logout: OnBackchannelLogout,
622}