Skip to main content

mas_config/sections/
clients.rs

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