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    /// The client secret, used by the `client_secret_basic`,
76    /// `client_secret_post` and `client_secret_jwt` authentication methods
77    #[schemars(with = "ClientSecretRaw")]
78    #[serde_as(as = "serde_with::TryFromInto<ClientSecretRaw>")]
79    #[serde(flatten)]
80    pub client_secret: Option<ClientSecret>,
81
82    /// The JSON Web Key Set (JWKS) used by the `private_key_jwt` authentication
83    /// method. Mutually exclusive with `jwks_uri`
84    #[serde(skip_serializing_if = "Option::is_none")]
85    pub jwks: Option<PublicJsonWebKeySet>,
86
87    /// The URL of the JSON Web Key Set (JWKS) used by the `private_key_jwt`
88    /// authentication method. Mutually exclusive with `jwks`
89    #[serde(skip_serializing_if = "Option::is_none")]
90    pub jwks_uri: Option<Url>,
91
92    /// List of allowed redirect URIs
93    #[serde(default, skip_serializing_if = "Vec::is_empty")]
94    pub redirect_uris: Vec<Url>,
95}
96
97impl ClientConfig {
98    fn validate(&self) -> Result<(), Box<figment::error::Error>> {
99        let auth_method = self.client_auth_method;
100        match self.client_auth_method {
101            ClientAuthMethodConfig::PrivateKeyJwt => {
102                if self.jwks.is_none() && self.jwks_uri.is_none() {
103                    let error = figment::error::Error::custom(
104                        "jwks or jwks_uri is required for private_key_jwt",
105                    );
106                    return Err(Box::new(error.with_path("client_auth_method")));
107                }
108
109                if self.jwks.is_some() && self.jwks_uri.is_some() {
110                    let error =
111                        figment::error::Error::custom("jwks and jwks_uri are mutually exclusive");
112                    return Err(Box::new(error.with_path("jwks")));
113                }
114
115                if self.client_secret.is_some() {
116                    let error = figment::error::Error::custom(
117                        "client_secret is not allowed with private_key_jwt",
118                    );
119                    return Err(Box::new(error.with_path("client_secret")));
120                }
121            }
122
123            ClientAuthMethodConfig::ClientSecretPost
124            | ClientAuthMethodConfig::ClientSecretBasic
125            | ClientAuthMethodConfig::ClientSecretJwt => {
126                if self.client_secret.is_none() {
127                    let error = figment::error::Error::custom(format!(
128                        "client_secret is required for {auth_method}"
129                    ));
130                    return Err(Box::new(error.with_path("client_auth_method")));
131                }
132
133                if self.jwks.is_some() {
134                    let error = figment::error::Error::custom(format!(
135                        "jwks is not allowed with {auth_method}"
136                    ));
137                    return Err(Box::new(error.with_path("jwks")));
138                }
139
140                if self.jwks_uri.is_some() {
141                    let error = figment::error::Error::custom(format!(
142                        "jwks_uri is not allowed with {auth_method}"
143                    ));
144                    return Err(Box::new(error.with_path("jwks_uri")));
145                }
146            }
147
148            ClientAuthMethodConfig::None => {
149                if self.client_secret.is_some() {
150                    let error = figment::error::Error::custom(
151                        "client_secret is not allowed with none authentication method",
152                    );
153                    return Err(Box::new(error.with_path("client_secret")));
154                }
155
156                if self.jwks.is_some() {
157                    let error = figment::error::Error::custom(
158                        "jwks is not allowed with none authentication method",
159                    );
160                    return Err(Box::new(error));
161                }
162
163                if self.jwks_uri.is_some() {
164                    let error = figment::error::Error::custom(
165                        "jwks_uri is not allowed with none authentication method",
166                    );
167                    return Err(Box::new(error));
168                }
169            }
170        }
171
172        Ok(())
173    }
174
175    /// Authentication method used for this client
176    #[must_use]
177    pub fn client_auth_method(&self) -> OAuthClientAuthenticationMethod {
178        match self.client_auth_method {
179            ClientAuthMethodConfig::None => OAuthClientAuthenticationMethod::None,
180            ClientAuthMethodConfig::ClientSecretBasic => {
181                OAuthClientAuthenticationMethod::ClientSecretBasic
182            }
183            ClientAuthMethodConfig::ClientSecretPost => {
184                OAuthClientAuthenticationMethod::ClientSecretPost
185            }
186            ClientAuthMethodConfig::ClientSecretJwt => {
187                OAuthClientAuthenticationMethod::ClientSecretJwt
188            }
189            ClientAuthMethodConfig::PrivateKeyJwt => OAuthClientAuthenticationMethod::PrivateKeyJwt,
190        }
191    }
192
193    /// Returns the client secret.
194    ///
195    /// If `client_secret_file` was given, the secret is read from that file.
196    ///
197    /// # Errors
198    ///
199    /// Returns an error when the client secret could not be read from file.
200    pub async fn client_secret(&self) -> anyhow::Result<Option<String>> {
201        Ok(match &self.client_secret {
202            Some(client_secret) => Some(client_secret.value().await?),
203            None => None,
204        })
205    }
206}
207
208/// List of OAuth 2.0/OIDC clients config
209#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
210#[serde(transparent)]
211pub struct ClientsConfig(#[schemars(with = "Vec::<ClientConfig>")] Vec<ClientConfig>);
212
213impl ClientsConfig {
214    /// Returns true if all fields are at their default values
215    pub(crate) fn is_default(&self) -> bool {
216        self.0.is_empty()
217    }
218}
219
220impl Deref for ClientsConfig {
221    type Target = Vec<ClientConfig>;
222
223    fn deref(&self) -> &Self::Target {
224        &self.0
225    }
226}
227
228impl IntoIterator for ClientsConfig {
229    type Item = ClientConfig;
230    type IntoIter = std::vec::IntoIter<ClientConfig>;
231
232    fn into_iter(self) -> Self::IntoIter {
233        self.0.into_iter()
234    }
235}
236
237impl ConfigurationSection for ClientsConfig {
238    const PATH: Option<&'static str> = Some("clients");
239
240    fn validate(
241        &self,
242        figment: &figment::Figment,
243    ) -> Result<(), Box<dyn std::error::Error + Send + Sync + 'static>> {
244        for (index, client) in self.0.iter().enumerate() {
245            client.validate().map_err(|mut err| {
246                // Save the error location information in the error
247                err.metadata = figment.find_metadata(Self::PATH.unwrap()).cloned();
248                err.profile = Some(figment::Profile::Default);
249                err.path.insert(0, Self::PATH.unwrap().to_owned());
250                err.path.insert(1, format!("{index}"));
251                err
252            })?;
253        }
254
255        Ok(())
256    }
257}
258
259#[cfg(test)]
260mod tests {
261    // The closures passed to `Jail::expect_with` return `figment::Error`, which is
262    // large, and we can't change figment's API.
263    #![expect(clippy::result_large_err)]
264
265    use std::str::FromStr;
266
267    use figment::{
268        Figment, Jail,
269        providers::{Format, Yaml},
270    };
271    use tokio::{runtime::Handle, task};
272
273    use super::*;
274
275    #[tokio::test]
276    async fn load_config() {
277        task::spawn_blocking(|| {
278            Jail::expect_with(|jail| {
279                jail.create_file(
280                    "config.yaml",
281                    r#"
282                      clients:
283                        - client_id: 01GFWR28C4KNE04WG3HKXB7C9R
284                          client_auth_method: none
285                          redirect_uris:
286                            - https://exemple.fr/callback
287
288                        - client_id: 01GFWR32NCQ12B8Z0J8CPXRRB6
289                          client_auth_method: client_secret_basic
290                          client_secret_file: secret
291
292                        - client_id: 01GFWR3WHR93Y5HK389H28VHZ9
293                          client_auth_method: client_secret_post
294                          client_secret: c1!3n753c237
295
296                        - client_id: 01GFWR43R2ZZ8HX9CVBNW9TJWG
297                          client_auth_method: client_secret_jwt
298                          client_secret_file: secret
299
300                        - client_id: 01GFWR4BNFDCC4QDG6AMSP1VRR
301                          client_auth_method: private_key_jwt
302                          jwks:
303                            keys:
304                            - kid: "03e84aed4ef4431014e8617567864c4efaaaede9"
305                              kty: "RSA"
306                              alg: "RS256"
307                              use: "sig"
308                              e: "AQAB"
309                              n: "ma2uRyBeSEOatGuDpCiV9oIxlDWix_KypDYuhQfEzqi_BiF4fV266OWfyjcABbam59aJMNvOnKW3u_eZM-PhMCBij5MZ-vcBJ4GfxDJeKSn-GP_dJ09rpDcILh8HaWAnPmMoi4DC0nrfE241wPISvZaaZnGHkOrfN_EnA5DligLgVUbrA5rJhQ1aSEQO_gf1raEOW3DZ_ACU3qhtgO0ZBG3a5h7BPiRs2sXqb2UCmBBgwyvYLDebnpE7AotF6_xBIlR-Cykdap3GHVMXhrIpvU195HF30ZoBU4dMd-AeG6HgRt4Cqy1moGoDgMQfbmQ48Hlunv9_Vi2e2CLvYECcBw"
310
311                            - kid: "d01c1abe249269f72ef7ca2613a86c9f05e59567"
312                              kty: "RSA"
313                              alg: "RS256"
314                              use: "sig"
315                              e: "AQAB"
316                              n: "0hukqytPwrj1RbMYhYoepCi3CN5k7DwYkTe_Cmb7cP9_qv4ok78KdvFXt5AnQxCRwBD7-qTNkkfMWO2RxUMBdQD0ED6tsSb1n5dp0XY8dSWiBDCX8f6Hr-KolOpvMLZKRy01HdAWcM6RoL9ikbjYHUEW1C8IJnw3MzVHkpKFDL354aptdNLaAdTCBvKzU9WpXo10g-5ctzSlWWjQuecLMQ4G1mNdsR1LHhUENEnOvgT8cDkX0fJzLbEbyBYkdMgKggyVPEB1bg6evG4fTKawgnf0IDSPxIU-wdS9wdSP9ZCJJPLi5CEp-6t6rE_sb2dGcnzjCGlembC57VwpkUvyMw"
317                    "#,
318                )?;
319                jail.create_file("secret", r"c1!3n753c237")?;
320
321                let config = Figment::new()
322                    .merge(Yaml::file("config.yaml"))
323                    .extract_inner::<ClientsConfig>("clients")?;
324
325                assert_eq!(config.0.len(), 5);
326
327                assert_eq!(
328                    config.0[0].client_id,
329                    Ulid::from_str("01GFWR28C4KNE04WG3HKXB7C9R").unwrap()
330                );
331                assert_eq!(
332                    config.0[0].redirect_uris,
333                    vec!["https://exemple.fr/callback".parse().unwrap()]
334                );
335
336                assert_eq!(
337                    config.0[1].client_id,
338                    Ulid::from_str("01GFWR32NCQ12B8Z0J8CPXRRB6").unwrap()
339                );
340                assert_eq!(config.0[1].redirect_uris, Vec::new());
341
342                assert!(config.0[0].client_secret.is_none());
343                assert!(matches!(config.0[1].client_secret, Some(ClientSecret::File(ref p)) if p == "secret"));
344                assert!(matches!(config.0[2].client_secret, Some(ClientSecret::Value(ref v)) if v == "c1!3n753c237"));
345                assert!(matches!(config.0[3].client_secret, Some(ClientSecret::File(ref p)) if p == "secret"));
346                assert!(config.0[4].client_secret.is_none());
347
348                Handle::current().block_on(async move {
349                    assert_eq!(config.0[1].client_secret().await.unwrap().unwrap(), "c1!3n753c237");
350                    assert_eq!(config.0[2].client_secret().await.unwrap().unwrap(), "c1!3n753c237");
351                    assert_eq!(config.0[3].client_secret().await.unwrap().unwrap(), "c1!3n753c237");
352                });
353
354                Ok(())
355            });
356        }).await.unwrap();
357    }
358}