mas_config/sections/
clients.rs1use 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#[derive(JsonSchema, Serialize, Deserialize, Copy, Clone, Debug)]
22#[serde(rename_all = "snake_case")]
23pub enum ClientAuthMethodConfig {
24 None,
26
27 ClientSecretBasic,
30
31 ClientSecretPost,
34
35 ClientSecretJwt,
38
39 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#[serde_as]
58#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
59pub struct ClientConfig {
60 #[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 client_auth_method: ClientAuthMethodConfig,
70
71 #[serde(skip_serializing_if = "Option::is_none")]
73 pub client_name: Option<String>,
74
75 #[schemars(with = "ClientSecretRaw")]
78 #[serde_as(as = "serde_with::TryFromInto<ClientSecretRaw>")]
79 #[serde(flatten)]
80 pub client_secret: Option<ClientSecret>,
81
82 #[serde(skip_serializing_if = "Option::is_none")]
85 pub jwks: Option<PublicJsonWebKeySet>,
86
87 #[serde(skip_serializing_if = "Option::is_none")]
90 pub jwks_uri: Option<Url>,
91
92 #[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 #[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 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#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
210#[serde(transparent)]
211pub struct ClientsConfig(#[schemars(with = "Vec::<ClientConfig>")] Vec<ClientConfig>);
212
213impl ClientsConfig {
214 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 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 #![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}