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(&self, figment: &figment::Figment) -> Result<(), figment::Error> {
37 for (index, provider) in self.providers.iter().enumerate() {
38 let annotate = |mut error: figment::Error| {
39 error.metadata = figment
40 .find_metadata(&format!("{root}.providers", root = Self::PATH.unwrap()))
41 .cloned();
42 error.profile = Some(figment::Profile::Default);
43 error.path = vec![
44 Self::PATH.unwrap().to_owned(),
45 "providers".to_owned(),
46 index.to_string(),
47 ];
48 Err(error)
49 };
50
51 if !matches!(provider.discovery_mode, DiscoveryMode::Disabled)
52 && provider.issuer.is_none()
53 {
54 return annotate(figment::Error::custom(
55 "The `issuer` field is required when discovery is enabled",
56 ));
57 }
58
59 match provider.token_endpoint_auth_method {
60 TokenAuthMethod::None
61 | TokenAuthMethod::PrivateKeyJwt
62 | TokenAuthMethod::SignInWithApple => {
63 if provider.client_secret.is_some() {
64 return annotate(figment::Error::custom(
65 "Unexpected field `client_secret` for the selected authentication method",
66 ));
67 }
68 }
69 TokenAuthMethod::ClientSecretBasic
70 | TokenAuthMethod::ClientSecretPost
71 | TokenAuthMethod::ClientSecretJwt => {
72 if provider.client_secret.is_none() {
73 return annotate(figment::Error::missing_field("client_secret"));
74 }
75 }
76 }
77
78 match provider.token_endpoint_auth_method {
79 TokenAuthMethod::None
80 | TokenAuthMethod::ClientSecretBasic
81 | TokenAuthMethod::ClientSecretPost
82 | TokenAuthMethod::SignInWithApple => {
83 if provider.token_endpoint_auth_signing_alg.is_some() {
84 return annotate(figment::Error::custom(
85 "Unexpected field `token_endpoint_auth_signing_alg` for the selected authentication method",
86 ));
87 }
88 }
89 TokenAuthMethod::ClientSecretJwt | TokenAuthMethod::PrivateKeyJwt => {
90 if provider.token_endpoint_auth_signing_alg.is_none() {
91 return annotate(figment::Error::missing_field(
92 "token_endpoint_auth_signing_alg",
93 ));
94 }
95 }
96 }
97
98 match provider.token_endpoint_auth_method {
99 TokenAuthMethod::SignInWithApple => {
100 if provider.sign_in_with_apple.is_none() {
101 return annotate(figment::Error::missing_field("sign_in_with_apple"));
102 }
103 }
104
105 _ => {
106 if provider.sign_in_with_apple.is_some() {
107 return annotate(figment::Error::custom(
108 "Unexpected field `sign_in_with_apple` for the selected authentication method",
109 ));
110 }
111 }
112 }
113 }
114
115 Ok(())
116 }
117}
118
119#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema)]
121#[serde(rename_all = "snake_case")]
122pub enum ResponseMode {
123 Query,
126
127 FormPost,
132}
133
134#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema)]
136#[serde(rename_all = "snake_case")]
137pub enum TokenAuthMethod {
138 None,
140
141 ClientSecretBasic,
144
145 ClientSecretPost,
148
149 ClientSecretJwt,
152
153 PrivateKeyJwt,
156
157 SignInWithApple,
159}
160
161#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, JsonSchema)]
163#[serde(rename_all = "lowercase")]
164pub enum ImportAction {
165 #[default]
167 Ignore,
168
169 Suggest,
171
172 Force,
174
175 Require,
177}
178
179impl ImportAction {
180 #[allow(clippy::trivially_copy_pass_by_ref)]
181 const fn is_default(&self) -> bool {
182 matches!(self, ImportAction::Ignore)
183 }
184}
185
186#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default, JsonSchema)]
188pub struct SubjectImportPreference {
189 #[serde(default, skip_serializing_if = "Option::is_none")]
193 pub template: Option<String>,
194}
195
196impl SubjectImportPreference {
197 const fn is_default(&self) -> bool {
198 self.template.is_none()
199 }
200}
201
202#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default, JsonSchema)]
204pub struct LocalpartImportPreference {
205 #[serde(default, skip_serializing_if = "ImportAction::is_default")]
207 pub action: ImportAction,
208
209 #[serde(default, skip_serializing_if = "Option::is_none")]
213 pub template: Option<String>,
214}
215
216impl LocalpartImportPreference {
217 const fn is_default(&self) -> bool {
218 self.action.is_default() && self.template.is_none()
219 }
220}
221
222#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default, JsonSchema)]
224pub struct DisplaynameImportPreference {
225 #[serde(default, skip_serializing_if = "ImportAction::is_default")]
227 pub action: ImportAction,
228
229 #[serde(default, skip_serializing_if = "Option::is_none")]
233 pub template: Option<String>,
234}
235
236impl DisplaynameImportPreference {
237 const fn is_default(&self) -> bool {
238 self.action.is_default() && self.template.is_none()
239 }
240}
241
242#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default, JsonSchema)]
244pub struct EmailImportPreference {
245 #[serde(default, skip_serializing_if = "ImportAction::is_default")]
247 pub action: ImportAction,
248
249 #[serde(default, skip_serializing_if = "Option::is_none")]
253 pub template: Option<String>,
254}
255
256impl EmailImportPreference {
257 const fn is_default(&self) -> bool {
258 self.action.is_default() && self.template.is_none()
259 }
260}
261
262#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default, JsonSchema)]
264pub struct AccountNameImportPreference {
265 #[serde(default, skip_serializing_if = "Option::is_none")]
270 pub template: Option<String>,
271}
272
273impl AccountNameImportPreference {
274 const fn is_default(&self) -> bool {
275 self.template.is_none()
276 }
277}
278
279#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default, JsonSchema)]
281pub struct ClaimsImports {
282 #[serde(default, skip_serializing_if = "SubjectImportPreference::is_default")]
284 pub subject: SubjectImportPreference,
285
286 #[serde(default, skip_serializing_if = "LocalpartImportPreference::is_default")]
288 pub localpart: LocalpartImportPreference,
289
290 #[serde(
292 default,
293 skip_serializing_if = "DisplaynameImportPreference::is_default"
294 )]
295 pub displayname: DisplaynameImportPreference,
296
297 #[serde(default, skip_serializing_if = "EmailImportPreference::is_default")]
300 pub email: EmailImportPreference,
301
302 #[serde(
304 default,
305 skip_serializing_if = "AccountNameImportPreference::is_default"
306 )]
307 pub account_name: AccountNameImportPreference,
308}
309
310impl ClaimsImports {
311 const fn is_default(&self) -> bool {
312 self.subject.is_default()
313 && self.localpart.is_default()
314 && self.displayname.is_default()
315 && self.email.is_default()
316 }
317}
318
319#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, Default)]
321#[serde(rename_all = "snake_case")]
322pub enum DiscoveryMode {
323 #[default]
325 Oidc,
326
327 Insecure,
329
330 Disabled,
332}
333
334impl DiscoveryMode {
335 #[allow(clippy::trivially_copy_pass_by_ref)]
336 const fn is_default(&self) -> bool {
337 matches!(self, DiscoveryMode::Oidc)
338 }
339}
340
341#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, Default)]
344#[serde(rename_all = "snake_case")]
345pub enum PkceMethod {
346 #[default]
350 Auto,
351
352 Always,
354
355 Never,
357}
358
359impl PkceMethod {
360 #[allow(clippy::trivially_copy_pass_by_ref)]
361 const fn is_default(&self) -> bool {
362 matches!(self, PkceMethod::Auto)
363 }
364}
365
366fn default_true() -> bool {
367 true
368}
369
370#[allow(clippy::trivially_copy_pass_by_ref)]
371fn is_default_true(value: &bool) -> bool {
372 *value
373}
374
375#[allow(clippy::ref_option)]
376fn is_signed_response_alg_default(signed_response_alg: &JsonWebSignatureAlg) -> bool {
377 *signed_response_alg == signed_response_alg_default()
378}
379
380#[allow(clippy::unnecessary_wraps)]
381fn signed_response_alg_default() -> JsonWebSignatureAlg {
382 JsonWebSignatureAlg::Rs256
383}
384
385#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
386pub struct SignInWithApple {
387 #[serde(skip_serializing_if = "Option::is_none")]
389 #[schemars(with = "Option<String>")]
390 pub private_key_file: Option<Utf8PathBuf>,
391
392 #[serde(skip_serializing_if = "Option::is_none")]
394 pub private_key: Option<String>,
395
396 pub team_id: String,
398
399 pub key_id: String,
401}
402
403fn default_scope() -> String {
404 "openid".to_owned()
405}
406
407fn is_default_scope(scope: &str) -> bool {
408 scope == default_scope()
409}
410
411#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, Default)]
413#[serde(rename_all = "snake_case")]
414pub enum OnBackchannelLogout {
415 #[default]
417 DoNothing,
418
419 LogoutBrowserOnly,
421
422 LogoutAll,
425}
426
427impl OnBackchannelLogout {
428 #[allow(clippy::trivially_copy_pass_by_ref)]
429 const fn is_default(&self) -> bool {
430 matches!(self, OnBackchannelLogout::DoNothing)
431 }
432}
433
434#[skip_serializing_none]
436#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
437pub struct Provider {
438 #[serde(default = "default_true", skip_serializing_if = "is_default_true")]
442 pub enabled: bool,
443
444 #[schemars(
446 with = "String",
447 regex(pattern = r"^[0123456789ABCDEFGHJKMNPQRSTVWXYZ]{26}$"),
448 description = "A ULID as per https://github.com/ulid/spec"
449 )]
450 pub id: Ulid,
451
452 #[serde(skip_serializing_if = "Option::is_none")]
467 pub synapse_idp_id: Option<String>,
468
469 #[serde(skip_serializing_if = "Option::is_none")]
473 pub issuer: Option<String>,
474
475 #[serde(skip_serializing_if = "Option::is_none")]
477 pub human_name: Option<String>,
478
479 #[serde(skip_serializing_if = "Option::is_none")]
492 pub brand_name: Option<String>,
493
494 pub client_id: String,
496
497 #[serde(skip_serializing_if = "Option::is_none")]
502 pub client_secret: Option<String>,
503
504 pub token_endpoint_auth_method: TokenAuthMethod,
506
507 #[serde(skip_serializing_if = "Option::is_none")]
509 pub sign_in_with_apple: Option<SignInWithApple>,
510
511 #[serde(skip_serializing_if = "Option::is_none")]
516 pub token_endpoint_auth_signing_alg: Option<JsonWebSignatureAlg>,
517
518 #[serde(
523 default = "signed_response_alg_default",
524 skip_serializing_if = "is_signed_response_alg_default"
525 )]
526 pub id_token_signed_response_alg: JsonWebSignatureAlg,
527
528 #[serde(default = "default_scope", skip_serializing_if = "is_default_scope")]
532 pub scope: String,
533
534 #[serde(default, skip_serializing_if = "DiscoveryMode::is_default")]
539 pub discovery_mode: DiscoveryMode,
540
541 #[serde(default, skip_serializing_if = "PkceMethod::is_default")]
546 pub pkce_method: PkceMethod,
547
548 #[serde(default)]
554 pub fetch_userinfo: bool,
555
556 #[serde(skip_serializing_if = "Option::is_none")]
562 pub userinfo_signed_response_alg: Option<JsonWebSignatureAlg>,
563
564 #[serde(skip_serializing_if = "Option::is_none")]
568 pub authorization_endpoint: Option<Url>,
569
570 #[serde(skip_serializing_if = "Option::is_none")]
574 pub userinfo_endpoint: Option<Url>,
575
576 #[serde(skip_serializing_if = "Option::is_none")]
580 pub token_endpoint: Option<Url>,
581
582 #[serde(skip_serializing_if = "Option::is_none")]
586 pub jwks_uri: Option<Url>,
587
588 #[serde(skip_serializing_if = "Option::is_none")]
590 pub response_mode: Option<ResponseMode>,
591
592 #[serde(default, skip_serializing_if = "ClaimsImports::is_default")]
595 pub claims_imports: ClaimsImports,
596
597 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
601 pub additional_authorization_parameters: BTreeMap<String, String>,
602
603 #[serde(default)]
608 pub forward_login_hint: bool,
609
610 #[serde(default, skip_serializing_if = "OnBackchannelLogout::is_default")]
614 pub on_backchannel_logout: OnBackchannelLogout,
615}