1use std::collections::BTreeMap;
8
9use mas_iana::jose::JsonWebSignatureAlg;
10use schemars::JsonSchema;
11use serde::{Deserialize, Serialize, de::Error};
12use serde_with::skip_serializing_none;
13use ulid::Ulid;
14use url::Url;
15
16use crate::ConfigurationSection;
17
18#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)]
20pub struct UpstreamOAuth2Config {
21    pub providers: Vec<Provider>,
23}
24
25impl UpstreamOAuth2Config {
26    pub(crate) fn is_default(&self) -> bool {
28        self.providers.is_empty()
29    }
30}
31
32impl ConfigurationSection for UpstreamOAuth2Config {
33    const PATH: Option<&'static str> = Some("upstream_oauth2");
34
35    fn validate(&self, figment: &figment::Figment) -> Result<(), figment::Error> {
36        for (index, provider) in self.providers.iter().enumerate() {
37            let annotate = |mut error: figment::Error| {
38                error.metadata = figment
39                    .find_metadata(&format!("{root}.providers", root = Self::PATH.unwrap()))
40                    .cloned();
41                error.profile = Some(figment::Profile::Default);
42                error.path = vec![
43                    Self::PATH.unwrap().to_owned(),
44                    "providers".to_owned(),
45                    index.to_string(),
46                ];
47                Err(error)
48            };
49
50            if !matches!(provider.discovery_mode, DiscoveryMode::Disabled)
51                && provider.issuer.is_none()
52            {
53                return annotate(figment::Error::custom(
54                    "The `issuer` field is required when discovery is enabled",
55                ));
56            }
57
58            match provider.token_endpoint_auth_method {
59                TokenAuthMethod::None
60                | TokenAuthMethod::PrivateKeyJwt
61                | TokenAuthMethod::SignInWithApple => {
62                    if provider.client_secret.is_some() {
63                        return annotate(figment::Error::custom(
64                            "Unexpected field `client_secret` for the selected authentication method",
65                        ));
66                    }
67                }
68                TokenAuthMethod::ClientSecretBasic
69                | TokenAuthMethod::ClientSecretPost
70                | TokenAuthMethod::ClientSecretJwt => {
71                    if provider.client_secret.is_none() {
72                        return annotate(figment::Error::missing_field("client_secret"));
73                    }
74                }
75            }
76
77            match provider.token_endpoint_auth_method {
78                TokenAuthMethod::None
79                | TokenAuthMethod::ClientSecretBasic
80                | TokenAuthMethod::ClientSecretPost
81                | TokenAuthMethod::SignInWithApple => {
82                    if provider.token_endpoint_auth_signing_alg.is_some() {
83                        return annotate(figment::Error::custom(
84                            "Unexpected field `token_endpoint_auth_signing_alg` for the selected authentication method",
85                        ));
86                    }
87                }
88                TokenAuthMethod::ClientSecretJwt | TokenAuthMethod::PrivateKeyJwt => {
89                    if provider.token_endpoint_auth_signing_alg.is_none() {
90                        return annotate(figment::Error::missing_field(
91                            "token_endpoint_auth_signing_alg",
92                        ));
93                    }
94                }
95            }
96
97            match provider.token_endpoint_auth_method {
98                TokenAuthMethod::SignInWithApple => {
99                    if provider.sign_in_with_apple.is_none() {
100                        return annotate(figment::Error::missing_field("sign_in_with_apple"));
101                    }
102                }
103
104                _ => {
105                    if provider.sign_in_with_apple.is_some() {
106                        return annotate(figment::Error::custom(
107                            "Unexpected field `sign_in_with_apple` for the selected authentication method",
108                        ));
109                    }
110                }
111            }
112        }
113
114        Ok(())
115    }
116}
117
118#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema)]
120#[serde(rename_all = "snake_case")]
121pub enum ResponseMode {
122    Query,
125
126    FormPost,
131}
132
133#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema)]
135#[serde(rename_all = "snake_case")]
136pub enum TokenAuthMethod {
137    None,
139
140    ClientSecretBasic,
143
144    ClientSecretPost,
147
148    ClientSecretJwt,
151
152    PrivateKeyJwt,
155
156    SignInWithApple,
158}
159
160#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, JsonSchema)]
162#[serde(rename_all = "lowercase")]
163pub enum ImportAction {
164    #[default]
166    Ignore,
167
168    Suggest,
170
171    Force,
173
174    Require,
176}
177
178impl ImportAction {
179    #[allow(clippy::trivially_copy_pass_by_ref)]
180    const fn is_default(&self) -> bool {
181        matches!(self, ImportAction::Ignore)
182    }
183}
184
185#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default, JsonSchema)]
187pub struct SubjectImportPreference {
188    #[serde(default, skip_serializing_if = "Option::is_none")]
192    pub template: Option<String>,
193}
194
195impl SubjectImportPreference {
196    const fn is_default(&self) -> bool {
197        self.template.is_none()
198    }
199}
200
201#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default, JsonSchema)]
203pub struct LocalpartImportPreference {
204    #[serde(default, skip_serializing_if = "ImportAction::is_default")]
206    pub action: ImportAction,
207
208    #[serde(default, skip_serializing_if = "Option::is_none")]
212    pub template: Option<String>,
213}
214
215impl LocalpartImportPreference {
216    const fn is_default(&self) -> bool {
217        self.action.is_default() && self.template.is_none()
218    }
219}
220
221#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default, JsonSchema)]
223pub struct DisplaynameImportPreference {
224    #[serde(default, skip_serializing_if = "ImportAction::is_default")]
226    pub action: ImportAction,
227
228    #[serde(default, skip_serializing_if = "Option::is_none")]
232    pub template: Option<String>,
233}
234
235impl DisplaynameImportPreference {
236    const fn is_default(&self) -> bool {
237        self.action.is_default() && self.template.is_none()
238    }
239}
240
241#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default, JsonSchema)]
243pub struct EmailImportPreference {
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
255impl EmailImportPreference {
256    const fn is_default(&self) -> bool {
257        self.action.is_default() && self.template.is_none()
258    }
259}
260
261#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default, JsonSchema)]
263pub struct AccountNameImportPreference {
264    #[serde(default, skip_serializing_if = "Option::is_none")]
269    pub template: Option<String>,
270}
271
272impl AccountNameImportPreference {
273    const fn is_default(&self) -> bool {
274        self.template.is_none()
275    }
276}
277
278#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default, JsonSchema)]
280pub struct ClaimsImports {
281    #[serde(default, skip_serializing_if = "SubjectImportPreference::is_default")]
283    pub subject: SubjectImportPreference,
284
285    #[serde(default, skip_serializing_if = "LocalpartImportPreference::is_default")]
287    pub localpart: LocalpartImportPreference,
288
289    #[serde(
291        default,
292        skip_serializing_if = "DisplaynameImportPreference::is_default"
293    )]
294    pub displayname: DisplaynameImportPreference,
295
296    #[serde(default, skip_serializing_if = "EmailImportPreference::is_default")]
299    pub email: EmailImportPreference,
300
301    #[serde(
303        default,
304        skip_serializing_if = "AccountNameImportPreference::is_default"
305    )]
306    pub account_name: AccountNameImportPreference,
307}
308
309impl ClaimsImports {
310    const fn is_default(&self) -> bool {
311        self.subject.is_default()
312            && self.localpart.is_default()
313            && self.displayname.is_default()
314            && self.email.is_default()
315    }
316}
317
318#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, Default)]
320#[serde(rename_all = "snake_case")]
321pub enum DiscoveryMode {
322    #[default]
324    Oidc,
325
326    Insecure,
328
329    Disabled,
331}
332
333impl DiscoveryMode {
334    #[allow(clippy::trivially_copy_pass_by_ref)]
335    const fn is_default(&self) -> bool {
336        matches!(self, DiscoveryMode::Oidc)
337    }
338}
339
340#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, Default)]
343#[serde(rename_all = "snake_case")]
344pub enum PkceMethod {
345    #[default]
349    Auto,
350
351    Always,
353
354    Never,
356}
357
358impl PkceMethod {
359    #[allow(clippy::trivially_copy_pass_by_ref)]
360    const fn is_default(&self) -> bool {
361        matches!(self, PkceMethod::Auto)
362    }
363}
364
365fn default_true() -> bool {
366    true
367}
368
369#[allow(clippy::trivially_copy_pass_by_ref)]
370fn is_default_true(value: &bool) -> bool {
371    *value
372}
373
374#[allow(clippy::ref_option)]
375fn is_signed_response_alg_default(signed_response_alg: &JsonWebSignatureAlg) -> bool {
376    *signed_response_alg == signed_response_alg_default()
377}
378
379#[allow(clippy::unnecessary_wraps)]
380fn signed_response_alg_default() -> JsonWebSignatureAlg {
381    JsonWebSignatureAlg::Rs256
382}
383
384#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
385pub struct SignInWithApple {
386    pub private_key: String,
388
389    pub team_id: String,
391
392    pub key_id: String,
394}
395
396#[skip_serializing_none]
398#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
399pub struct Provider {
400    #[serde(default = "default_true", skip_serializing_if = "is_default_true")]
404    pub enabled: bool,
405
406    #[schemars(
408        with = "String",
409        regex(pattern = r"^[0123456789ABCDEFGHJKMNPQRSTVWXYZ]{26}$"),
410        description = "A ULID as per https://github.com/ulid/spec"
411    )]
412    pub id: Ulid,
413
414    #[serde(skip_serializing_if = "Option::is_none")]
418    pub issuer: Option<String>,
419
420    #[serde(skip_serializing_if = "Option::is_none")]
422    pub human_name: Option<String>,
423
424    #[serde(skip_serializing_if = "Option::is_none")]
437    pub brand_name: Option<String>,
438
439    pub client_id: String,
441
442    #[serde(skip_serializing_if = "Option::is_none")]
447    pub client_secret: Option<String>,
448
449    pub token_endpoint_auth_method: TokenAuthMethod,
451
452    #[serde(skip_serializing_if = "Option::is_none")]
454    pub sign_in_with_apple: Option<SignInWithApple>,
455
456    #[serde(skip_serializing_if = "Option::is_none")]
461    pub token_endpoint_auth_signing_alg: Option<JsonWebSignatureAlg>,
462
463    #[serde(
468        default = "signed_response_alg_default",
469        skip_serializing_if = "is_signed_response_alg_default"
470    )]
471    pub id_token_signed_response_alg: JsonWebSignatureAlg,
472
473    pub scope: String,
475
476    #[serde(default, skip_serializing_if = "DiscoveryMode::is_default")]
481    pub discovery_mode: DiscoveryMode,
482
483    #[serde(default, skip_serializing_if = "PkceMethod::is_default")]
488    pub pkce_method: PkceMethod,
489
490    #[serde(default)]
496    pub fetch_userinfo: bool,
497
498    #[serde(skip_serializing_if = "Option::is_none")]
504    pub userinfo_signed_response_alg: Option<JsonWebSignatureAlg>,
505
506    #[serde(skip_serializing_if = "Option::is_none")]
510    pub authorization_endpoint: Option<Url>,
511
512    #[serde(skip_serializing_if = "Option::is_none")]
516    pub userinfo_endpoint: Option<Url>,
517
518    #[serde(skip_serializing_if = "Option::is_none")]
522    pub token_endpoint: Option<Url>,
523
524    #[serde(skip_serializing_if = "Option::is_none")]
528    pub jwks_uri: Option<Url>,
529
530    #[serde(skip_serializing_if = "Option::is_none")]
532    pub response_mode: Option<ResponseMode>,
533
534    #[serde(default, skip_serializing_if = "ClaimsImports::is_default")]
537    pub claims_imports: ClaimsImports,
538
539    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
543    pub additional_authorization_parameters: BTreeMap<String, String>,
544
545    #[serde(skip_serializing_if = "Option::is_none")]
560    pub synapse_idp_id: Option<String>,
561}