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 #[serde(skip_serializing_if = "Option::is_none")]
77 pub client_uri: Option<Url>,
78
79 #[schemars(with = "ClientSecretRaw")]
82 #[serde_as(as = "serde_with::TryFromInto<ClientSecretRaw>")]
83 #[serde(flatten)]
84 pub client_secret: Option<ClientSecret>,
85
86 #[serde(skip_serializing_if = "Option::is_none")]
89 pub jwks: Option<PublicJsonWebKeySet>,
90
91 #[serde(skip_serializing_if = "Option::is_none")]
94 pub jwks_uri: Option<Url>,
95
96 #[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 #[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 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#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
214#[serde(transparent)]
215pub struct ClientsConfig(#[schemars(with = "Vec::<ClientConfig>")] Vec<ClientConfig>);
216
217impl ClientsConfig {
218 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 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 #![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}