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
403#[skip_serializing_none]
405#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
406pub struct Provider {
407 #[serde(default = "default_true", skip_serializing_if = "is_default_true")]
411 pub enabled: bool,
412
413 #[schemars(
415 with = "String",
416 regex(pattern = r"^[0123456789ABCDEFGHJKMNPQRSTVWXYZ]{26}$"),
417 description = "A ULID as per https://github.com/ulid/spec"
418 )]
419 pub id: Ulid,
420
421 #[serde(skip_serializing_if = "Option::is_none")]
425 pub issuer: Option<String>,
426
427 #[serde(skip_serializing_if = "Option::is_none")]
429 pub human_name: Option<String>,
430
431 #[serde(skip_serializing_if = "Option::is_none")]
444 pub brand_name: Option<String>,
445
446 pub client_id: String,
448
449 #[serde(skip_serializing_if = "Option::is_none")]
454 pub client_secret: Option<String>,
455
456 pub token_endpoint_auth_method: TokenAuthMethod,
458
459 #[serde(skip_serializing_if = "Option::is_none")]
461 pub sign_in_with_apple: Option<SignInWithApple>,
462
463 #[serde(skip_serializing_if = "Option::is_none")]
468 pub token_endpoint_auth_signing_alg: Option<JsonWebSignatureAlg>,
469
470 #[serde(
475 default = "signed_response_alg_default",
476 skip_serializing_if = "is_signed_response_alg_default"
477 )]
478 pub id_token_signed_response_alg: JsonWebSignatureAlg,
479
480 pub scope: String,
482
483 #[serde(default, skip_serializing_if = "DiscoveryMode::is_default")]
488 pub discovery_mode: DiscoveryMode,
489
490 #[serde(default, skip_serializing_if = "PkceMethod::is_default")]
495 pub pkce_method: PkceMethod,
496
497 #[serde(default)]
503 pub fetch_userinfo: bool,
504
505 #[serde(skip_serializing_if = "Option::is_none")]
511 pub userinfo_signed_response_alg: Option<JsonWebSignatureAlg>,
512
513 #[serde(skip_serializing_if = "Option::is_none")]
517 pub authorization_endpoint: Option<Url>,
518
519 #[serde(skip_serializing_if = "Option::is_none")]
523 pub userinfo_endpoint: Option<Url>,
524
525 #[serde(skip_serializing_if = "Option::is_none")]
529 pub token_endpoint: Option<Url>,
530
531 #[serde(skip_serializing_if = "Option::is_none")]
535 pub jwks_uri: Option<Url>,
536
537 #[serde(skip_serializing_if = "Option::is_none")]
539 pub response_mode: Option<ResponseMode>,
540
541 #[serde(default, skip_serializing_if = "ClaimsImports::is_default")]
544 pub claims_imports: ClaimsImports,
545
546 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
550 pub additional_authorization_parameters: BTreeMap<String, String>,
551
552 #[serde(skip_serializing_if = "Option::is_none")]
567 pub synapse_idp_id: Option<String>,
568}