mas_config/sections/
clients.rs1use 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#[derive(JsonSchema, Serialize, Deserialize, Copy, Clone, Debug)]
33#[serde(rename_all = "snake_case")]
34pub enum ClientAuthMethodConfig {
35 None,
37
38 ClientSecretBasic,
41
42 ClientSecretPost,
45
46 ClientSecretJwt,
49
50 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#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
69pub struct ClientConfig {
70 #[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 client_auth_method: ClientAuthMethodConfig,
80
81 #[serde(skip_serializing_if = "Option::is_none")]
83 pub client_name: Option<String>,
84
85 #[serde(skip_serializing_if = "Option::is_none")]
88 pub client_secret: Option<String>,
89
90 #[serde(skip_serializing_if = "Option::is_none")]
93 pub jwks: Option<PublicJsonWebKeySet>,
94
95 #[serde(skip_serializing_if = "Option::is_none")]
98 pub jwks_uri: Option<Url>,
99
100 #[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 #[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#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
204#[serde(transparent)]
205pub struct ClientsConfig(#[schemars(with = "Vec::<ClientConfig>")] Vec<ClientConfig>);
206
207impl ClientsConfig {
208 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 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}