mas_config/sections/
clients.rs

1// Copyright 2024, 2025 New Vector Ltd.
2// Copyright 2021-2024 The Matrix.org Foundation C.I.C.
3//
4// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
5// Please see LICENSE files in the repository root for full details.
6
7use std::ops::Deref;
8
9use mas_iana::oauth::OAuthClientAuthenticationMethod;
10use mas_jose::jwk::PublicJsonWebKeySet;
11use schemars::JsonSchema;
12use serde::{Deserialize, Serialize, de::Error};
13use ulid::Ulid;
14use url::Url;
15
16use super::ConfigurationSection;
17
18#[derive(JsonSchema, Serialize, Deserialize, Clone, Debug)]
19#[serde(rename_all = "snake_case")]
20pub enum JwksOrJwksUri {
21    Jwks(PublicJsonWebKeySet),
22    JwksUri(Url),
23}
24
25impl From<PublicJsonWebKeySet> for JwksOrJwksUri {
26    fn from(jwks: PublicJsonWebKeySet) -> Self {
27        Self::Jwks(jwks)
28    }
29}
30
31/// Authentication method used by clients
32#[derive(JsonSchema, Serialize, Deserialize, Copy, Clone, Debug)]
33#[serde(rename_all = "snake_case")]
34pub enum ClientAuthMethodConfig {
35    /// `none`: No authentication
36    None,
37
38    /// `client_secret_basic`: `client_id` and `client_secret` used as basic
39    /// authorization credentials
40    ClientSecretBasic,
41
42    /// `client_secret_post`: `client_id` and `client_secret` sent in the
43    /// request body
44    ClientSecretPost,
45
46    /// `client_secret_basic`: a `client_assertion` sent in the request body and
47    /// signed using the `client_secret`
48    ClientSecretJwt,
49
50    /// `client_secret_basic`: a `client_assertion` sent in the request body and
51    /// signed by an asymmetric key
52    PrivateKeyJwt,
53}
54
55impl std::fmt::Display for ClientAuthMethodConfig {
56    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
57        match self {
58            ClientAuthMethodConfig::None => write!(f, "none"),
59            ClientAuthMethodConfig::ClientSecretBasic => write!(f, "client_secret_basic"),
60            ClientAuthMethodConfig::ClientSecretPost => write!(f, "client_secret_post"),
61            ClientAuthMethodConfig::ClientSecretJwt => write!(f, "client_secret_jwt"),
62            ClientAuthMethodConfig::PrivateKeyJwt => write!(f, "private_key_jwt"),
63        }
64    }
65}
66
67/// An OAuth 2.0 client configuration
68#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
69pub struct ClientConfig {
70    /// The client ID
71    #[schemars(
72        with = "String",
73        regex(pattern = r"^[0123456789ABCDEFGHJKMNPQRSTVWXYZ]{26}$"),
74        description = "A ULID as per https://github.com/ulid/spec"
75    )]
76    pub client_id: Ulid,
77
78    /// Authentication method used for this client
79    client_auth_method: ClientAuthMethodConfig,
80
81    /// Name of the `OAuth2` client
82    #[serde(skip_serializing_if = "Option::is_none")]
83    pub client_name: Option<String>,
84
85    /// The client secret, used by the `client_secret_basic`,
86    /// `client_secret_post` and `client_secret_jwt` authentication methods
87    #[serde(skip_serializing_if = "Option::is_none")]
88    pub client_secret: Option<String>,
89
90    /// The JSON Web Key Set (JWKS) used by the `private_key_jwt` authentication
91    /// method. Mutually exclusive with `jwks_uri`
92    #[serde(skip_serializing_if = "Option::is_none")]
93    pub jwks: Option<PublicJsonWebKeySet>,
94
95    /// The URL of the JSON Web Key Set (JWKS) used by the `private_key_jwt`
96    /// authentication method. Mutually exclusive with `jwks`
97    #[serde(skip_serializing_if = "Option::is_none")]
98    pub jwks_uri: Option<Url>,
99
100    /// List of allowed redirect URIs
101    #[serde(default, skip_serializing_if = "Vec::is_empty")]
102    pub redirect_uris: Vec<Url>,
103}
104
105impl ClientConfig {
106    fn validate(&self) -> Result<(), Box<figment::error::Error>> {
107        let auth_method = self.client_auth_method;
108        match self.client_auth_method {
109            ClientAuthMethodConfig::PrivateKeyJwt => {
110                if self.jwks.is_none() && self.jwks_uri.is_none() {
111                    let error = figment::error::Error::custom(
112                        "jwks or jwks_uri is required for private_key_jwt",
113                    );
114                    return Err(Box::new(error.with_path("client_auth_method")));
115                }
116
117                if self.jwks.is_some() && self.jwks_uri.is_some() {
118                    let error =
119                        figment::error::Error::custom("jwks and jwks_uri are mutually exclusive");
120                    return Err(Box::new(error.with_path("jwks")));
121                }
122
123                if self.client_secret.is_some() {
124                    let error = figment::error::Error::custom(
125                        "client_secret is not allowed with private_key_jwt",
126                    );
127                    return Err(Box::new(error.with_path("client_secret")));
128                }
129            }
130
131            ClientAuthMethodConfig::ClientSecretPost
132            | ClientAuthMethodConfig::ClientSecretBasic
133            | ClientAuthMethodConfig::ClientSecretJwt => {
134                if self.client_secret.is_none() {
135                    let error = figment::error::Error::custom(format!(
136                        "client_secret is required for {auth_method}"
137                    ));
138                    return Err(Box::new(error.with_path("client_auth_method")));
139                }
140
141                if self.jwks.is_some() {
142                    let error = figment::error::Error::custom(format!(
143                        "jwks is not allowed with {auth_method}"
144                    ));
145                    return Err(Box::new(error.with_path("jwks")));
146                }
147
148                if self.jwks_uri.is_some() {
149                    let error = figment::error::Error::custom(format!(
150                        "jwks_uri is not allowed with {auth_method}"
151                    ));
152                    return Err(Box::new(error.with_path("jwks_uri")));
153                }
154            }
155
156            ClientAuthMethodConfig::None => {
157                if self.client_secret.is_some() {
158                    let error = figment::error::Error::custom(
159                        "client_secret is not allowed with none authentication method",
160                    );
161                    return Err(Box::new(error.with_path("client_secret")));
162                }
163
164                if self.jwks.is_some() {
165                    let error = figment::error::Error::custom(
166                        "jwks is not allowed with none authentication method",
167                    );
168                    return Err(Box::new(error));
169                }
170
171                if self.jwks_uri.is_some() {
172                    let error = figment::error::Error::custom(
173                        "jwks_uri is not allowed with none authentication method",
174                    );
175                    return Err(Box::new(error));
176                }
177            }
178        }
179
180        Ok(())
181    }
182
183    /// Authentication method used for this client
184    #[must_use]
185    pub fn client_auth_method(&self) -> OAuthClientAuthenticationMethod {
186        match self.client_auth_method {
187            ClientAuthMethodConfig::None => OAuthClientAuthenticationMethod::None,
188            ClientAuthMethodConfig::ClientSecretBasic => {
189                OAuthClientAuthenticationMethod::ClientSecretBasic
190            }
191            ClientAuthMethodConfig::ClientSecretPost => {
192                OAuthClientAuthenticationMethod::ClientSecretPost
193            }
194            ClientAuthMethodConfig::ClientSecretJwt => {
195                OAuthClientAuthenticationMethod::ClientSecretJwt
196            }
197            ClientAuthMethodConfig::PrivateKeyJwt => OAuthClientAuthenticationMethod::PrivateKeyJwt,
198        }
199    }
200}
201
202/// List of OAuth 2.0/OIDC clients config
203#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
204#[serde(transparent)]
205pub struct ClientsConfig(#[schemars(with = "Vec::<ClientConfig>")] Vec<ClientConfig>);
206
207impl ClientsConfig {
208    /// Returns true if all fields are at their default values
209    pub(crate) fn is_default(&self) -> bool {
210        self.0.is_empty()
211    }
212}
213
214impl Deref for ClientsConfig {
215    type Target = Vec<ClientConfig>;
216
217    fn deref(&self) -> &Self::Target {
218        &self.0
219    }
220}
221
222impl IntoIterator for ClientsConfig {
223    type Item = ClientConfig;
224    type IntoIter = std::vec::IntoIter<ClientConfig>;
225
226    fn into_iter(self) -> Self::IntoIter {
227        self.0.into_iter()
228    }
229}
230
231impl ConfigurationSection for ClientsConfig {
232    const PATH: Option<&'static str> = Some("clients");
233
234    fn validate(
235        &self,
236        figment: &figment::Figment,
237    ) -> Result<(), Box<dyn std::error::Error + Send + Sync + 'static>> {
238        for (index, client) in self.0.iter().enumerate() {
239            client.validate().map_err(|mut err| {
240                // Save the error location information in the error
241                err.metadata = figment.find_metadata(Self::PATH.unwrap()).cloned();
242                err.profile = Some(figment::Profile::Default);
243                err.path.insert(0, Self::PATH.unwrap().to_owned());
244                err.path.insert(1, format!("{index}"));
245                err
246            })?;
247        }
248
249        Ok(())
250    }
251}
252
253#[cfg(test)]
254mod tests {
255    use std::str::FromStr;
256
257    use figment::{
258        Figment, Jail,
259        providers::{Format, Yaml},
260    };
261
262    use super::*;
263
264    #[test]
265    fn load_config() {
266        Jail::expect_with(|jail| {
267            jail.create_file(
268                "config.yaml",
269                r#"
270                  clients:
271                    - client_id: 01GFWR28C4KNE04WG3HKXB7C9R
272                      client_auth_method: none
273                      redirect_uris:
274                        - https://exemple.fr/callback
275
276                    - client_id: 01GFWR32NCQ12B8Z0J8CPXRRB6
277                      client_auth_method: client_secret_basic
278                      client_secret: hello
279
280                    - client_id: 01GFWR3WHR93Y5HK389H28VHZ9
281                      client_auth_method: client_secret_post
282                      client_secret: hello
283
284                    - client_id: 01GFWR43R2ZZ8HX9CVBNW9TJWG
285                      client_auth_method: client_secret_jwt
286                      client_secret: hello
287
288                    - client_id: 01GFWR4BNFDCC4QDG6AMSP1VRR
289                      client_auth_method: private_key_jwt
290                      jwks:
291                        keys:
292                        - kid: "03e84aed4ef4431014e8617567864c4efaaaede9"
293                          kty: "RSA"
294                          alg: "RS256"
295                          use: "sig"
296                          e: "AQAB"
297                          n: "ma2uRyBeSEOatGuDpCiV9oIxlDWix_KypDYuhQfEzqi_BiF4fV266OWfyjcABbam59aJMNvOnKW3u_eZM-PhMCBij5MZ-vcBJ4GfxDJeKSn-GP_dJ09rpDcILh8HaWAnPmMoi4DC0nrfE241wPISvZaaZnGHkOrfN_EnA5DligLgVUbrA5rJhQ1aSEQO_gf1raEOW3DZ_ACU3qhtgO0ZBG3a5h7BPiRs2sXqb2UCmBBgwyvYLDebnpE7AotF6_xBIlR-Cykdap3GHVMXhrIpvU195HF30ZoBU4dMd-AeG6HgRt4Cqy1moGoDgMQfbmQ48Hlunv9_Vi2e2CLvYECcBw"
298
299                        - kid: "d01c1abe249269f72ef7ca2613a86c9f05e59567"
300                          kty: "RSA"
301                          alg: "RS256"
302                          use: "sig"
303                          e: "AQAB"
304                          n: "0hukqytPwrj1RbMYhYoepCi3CN5k7DwYkTe_Cmb7cP9_qv4ok78KdvFXt5AnQxCRwBD7-qTNkkfMWO2RxUMBdQD0ED6tsSb1n5dp0XY8dSWiBDCX8f6Hr-KolOpvMLZKRy01HdAWcM6RoL9ikbjYHUEW1C8IJnw3MzVHkpKFDL354aptdNLaAdTCBvKzU9WpXo10g-5ctzSlWWjQuecLMQ4G1mNdsR1LHhUENEnOvgT8cDkX0fJzLbEbyBYkdMgKggyVPEB1bg6evG4fTKawgnf0IDSPxIU-wdS9wdSP9ZCJJPLi5CEp-6t6rE_sb2dGcnzjCGlembC57VwpkUvyMw"
305                "#,
306            )?;
307
308            let config = Figment::new()
309                .merge(Yaml::file("config.yaml"))
310                .extract_inner::<ClientsConfig>("clients")?;
311
312            assert_eq!(config.0.len(), 5);
313
314            assert_eq!(
315                config.0[0].client_id,
316                Ulid::from_str("01GFWR28C4KNE04WG3HKXB7C9R").unwrap()
317            );
318            assert_eq!(
319                config.0[0].redirect_uris,
320                vec!["https://exemple.fr/callback".parse().unwrap()]
321            );
322
323            assert_eq!(
324                config.0[1].client_id,
325                Ulid::from_str("01GFWR32NCQ12B8Z0J8CPXRRB6").unwrap()
326            );
327            assert_eq!(config.0[1].redirect_uris, Vec::new());
328
329            Ok(())
330        });
331    }
332}