mas_config/sections/
clients.rs1use std::ops::Deref;
8
9use figment::Figment;
10use mas_iana::oauth::OAuthClientAuthenticationMethod;
11use mas_jose::jwk::PublicJsonWebKeySet;
12use schemars::JsonSchema;
13use serde::{Deserialize, Serialize, de::Error};
14use ulid::Ulid;
15use url::Url;
16
17use super::ConfigurationSection;
18
19#[derive(JsonSchema, Serialize, Deserialize, Clone, Debug)]
20#[serde(rename_all = "snake_case")]
21pub enum JwksOrJwksUri {
22 Jwks(PublicJsonWebKeySet),
23 JwksUri(Url),
24}
25
26impl From<PublicJsonWebKeySet> for JwksOrJwksUri {
27 fn from(jwks: PublicJsonWebKeySet) -> Self {
28 Self::Jwks(jwks)
29 }
30}
31
32#[derive(JsonSchema, Serialize, Deserialize, Copy, Clone, Debug)]
34#[serde(rename_all = "snake_case")]
35pub enum ClientAuthMethodConfig {
36 None,
38
39 ClientSecretBasic,
42
43 ClientSecretPost,
46
47 ClientSecretJwt,
50
51 PrivateKeyJwt,
54}
55
56impl std::fmt::Display for ClientAuthMethodConfig {
57 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
58 match self {
59 ClientAuthMethodConfig::None => write!(f, "none"),
60 ClientAuthMethodConfig::ClientSecretBasic => write!(f, "client_secret_basic"),
61 ClientAuthMethodConfig::ClientSecretPost => write!(f, "client_secret_post"),
62 ClientAuthMethodConfig::ClientSecretJwt => write!(f, "client_secret_jwt"),
63 ClientAuthMethodConfig::PrivateKeyJwt => write!(f, "private_key_jwt"),
64 }
65 }
66}
67
68#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
70pub struct ClientConfig {
71 #[schemars(
73 with = "String",
74 regex(pattern = r"^[0123456789ABCDEFGHJKMNPQRSTVWXYZ]{26}$"),
75 description = "A ULID as per https://github.com/ulid/spec"
76 )]
77 pub client_id: Ulid,
78
79 client_auth_method: ClientAuthMethodConfig,
81
82 #[serde(skip_serializing_if = "Option::is_none")]
85 pub client_secret: Option<String>,
86
87 #[serde(skip_serializing_if = "Option::is_none")]
90 pub jwks: Option<PublicJsonWebKeySet>,
91
92 #[serde(skip_serializing_if = "Option::is_none")]
95 pub jwks_uri: Option<Url>,
96
97 #[serde(default, skip_serializing_if = "Vec::is_empty")]
99 pub redirect_uris: Vec<Url>,
100}
101
102impl ClientConfig {
103 fn validate(&self) -> Result<(), figment::error::Error> {
104 let auth_method = self.client_auth_method;
105 match self.client_auth_method {
106 ClientAuthMethodConfig::PrivateKeyJwt => {
107 if self.jwks.is_none() && self.jwks_uri.is_none() {
108 let error = figment::error::Error::custom(
109 "jwks or jwks_uri is required for private_key_jwt",
110 );
111 return Err(error.with_path("client_auth_method"));
112 }
113
114 if self.jwks.is_some() && self.jwks_uri.is_some() {
115 let error =
116 figment::error::Error::custom("jwks and jwks_uri are mutually exclusive");
117 return Err(error.with_path("jwks"));
118 }
119
120 if self.client_secret.is_some() {
121 let error = figment::error::Error::custom(
122 "client_secret is not allowed with private_key_jwt",
123 );
124 return Err(error.with_path("client_secret"));
125 }
126 }
127
128 ClientAuthMethodConfig::ClientSecretPost
129 | ClientAuthMethodConfig::ClientSecretBasic
130 | ClientAuthMethodConfig::ClientSecretJwt => {
131 if self.client_secret.is_none() {
132 let error = figment::error::Error::custom(format!(
133 "client_secret is required for {auth_method}"
134 ));
135 return Err(error.with_path("client_auth_method"));
136 }
137
138 if self.jwks.is_some() {
139 let error = figment::error::Error::custom(format!(
140 "jwks is not allowed with {auth_method}"
141 ));
142 return Err(error.with_path("jwks"));
143 }
144
145 if self.jwks_uri.is_some() {
146 let error = figment::error::Error::custom(format!(
147 "jwks_uri is not allowed with {auth_method}"
148 ));
149 return Err(error.with_path("jwks_uri"));
150 }
151 }
152
153 ClientAuthMethodConfig::None => {
154 if self.client_secret.is_some() {
155 let error = figment::error::Error::custom(
156 "client_secret is not allowed with none authentication method",
157 );
158 return Err(error.with_path("client_secret"));
159 }
160
161 if self.jwks.is_some() {
162 let error = figment::error::Error::custom(
163 "jwks is not allowed with none authentication method",
164 );
165 return Err(error);
166 }
167
168 if self.jwks_uri.is_some() {
169 let error = figment::error::Error::custom(
170 "jwks_uri is not allowed with none authentication method",
171 );
172 return Err(error);
173 }
174 }
175 }
176
177 Ok(())
178 }
179
180 #[must_use]
182 pub fn client_auth_method(&self) -> OAuthClientAuthenticationMethod {
183 match self.client_auth_method {
184 ClientAuthMethodConfig::None => OAuthClientAuthenticationMethod::None,
185 ClientAuthMethodConfig::ClientSecretBasic => {
186 OAuthClientAuthenticationMethod::ClientSecretBasic
187 }
188 ClientAuthMethodConfig::ClientSecretPost => {
189 OAuthClientAuthenticationMethod::ClientSecretPost
190 }
191 ClientAuthMethodConfig::ClientSecretJwt => {
192 OAuthClientAuthenticationMethod::ClientSecretJwt
193 }
194 ClientAuthMethodConfig::PrivateKeyJwt => OAuthClientAuthenticationMethod::PrivateKeyJwt,
195 }
196 }
197}
198
199#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
201#[serde(transparent)]
202pub struct ClientsConfig(#[schemars(with = "Vec::<ClientConfig>")] Vec<ClientConfig>);
203
204impl ClientsConfig {
205 pub(crate) fn is_default(&self) -> bool {
207 self.0.is_empty()
208 }
209}
210
211impl Deref for ClientsConfig {
212 type Target = Vec<ClientConfig>;
213
214 fn deref(&self) -> &Self::Target {
215 &self.0
216 }
217}
218
219impl IntoIterator for ClientsConfig {
220 type Item = ClientConfig;
221 type IntoIter = std::vec::IntoIter<ClientConfig>;
222
223 fn into_iter(self) -> Self::IntoIter {
224 self.0.into_iter()
225 }
226}
227
228impl ConfigurationSection for ClientsConfig {
229 const PATH: Option<&'static str> = Some("clients");
230
231 fn validate(&self, figment: &Figment) -> Result<(), figment::error::Error> {
232 for (index, client) in self.0.iter().enumerate() {
233 client.validate().map_err(|mut err| {
234 err.metadata = figment.find_metadata(Self::PATH.unwrap()).cloned();
236 err.profile = Some(figment::Profile::Default);
237 err.path.insert(0, Self::PATH.unwrap().to_owned());
238 err.path.insert(1, format!("{index}"));
239 err
240 })?;
241 }
242
243 Ok(())
244 }
245}
246
247#[cfg(test)]
248mod tests {
249 use std::str::FromStr;
250
251 use figment::{
252 Figment, Jail,
253 providers::{Format, Yaml},
254 };
255
256 use super::*;
257
258 #[test]
259 fn load_config() {
260 Jail::expect_with(|jail| {
261 jail.create_file(
262 "config.yaml",
263 r#"
264 clients:
265 - client_id: 01GFWR28C4KNE04WG3HKXB7C9R
266 client_auth_method: none
267 redirect_uris:
268 - https://exemple.fr/callback
269
270 - client_id: 01GFWR32NCQ12B8Z0J8CPXRRB6
271 client_auth_method: client_secret_basic
272 client_secret: hello
273
274 - client_id: 01GFWR3WHR93Y5HK389H28VHZ9
275 client_auth_method: client_secret_post
276 client_secret: hello
277
278 - client_id: 01GFWR43R2ZZ8HX9CVBNW9TJWG
279 client_auth_method: client_secret_jwt
280 client_secret: hello
281
282 - client_id: 01GFWR4BNFDCC4QDG6AMSP1VRR
283 client_auth_method: private_key_jwt
284 jwks:
285 keys:
286 - kid: "03e84aed4ef4431014e8617567864c4efaaaede9"
287 kty: "RSA"
288 alg: "RS256"
289 use: "sig"
290 e: "AQAB"
291 n: "ma2uRyBeSEOatGuDpCiV9oIxlDWix_KypDYuhQfEzqi_BiF4fV266OWfyjcABbam59aJMNvOnKW3u_eZM-PhMCBij5MZ-vcBJ4GfxDJeKSn-GP_dJ09rpDcILh8HaWAnPmMoi4DC0nrfE241wPISvZaaZnGHkOrfN_EnA5DligLgVUbrA5rJhQ1aSEQO_gf1raEOW3DZ_ACU3qhtgO0ZBG3a5h7BPiRs2sXqb2UCmBBgwyvYLDebnpE7AotF6_xBIlR-Cykdap3GHVMXhrIpvU195HF30ZoBU4dMd-AeG6HgRt4Cqy1moGoDgMQfbmQ48Hlunv9_Vi2e2CLvYECcBw"
292
293 - kid: "d01c1abe249269f72ef7ca2613a86c9f05e59567"
294 kty: "RSA"
295 alg: "RS256"
296 use: "sig"
297 e: "AQAB"
298 n: "0hukqytPwrj1RbMYhYoepCi3CN5k7DwYkTe_Cmb7cP9_qv4ok78KdvFXt5AnQxCRwBD7-qTNkkfMWO2RxUMBdQD0ED6tsSb1n5dp0XY8dSWiBDCX8f6Hr-KolOpvMLZKRy01HdAWcM6RoL9ikbjYHUEW1C8IJnw3MzVHkpKFDL354aptdNLaAdTCBvKzU9WpXo10g-5ctzSlWWjQuecLMQ4G1mNdsR1LHhUENEnOvgT8cDkX0fJzLbEbyBYkdMgKggyVPEB1bg6evG4fTKawgnf0IDSPxIU-wdS9wdSP9ZCJJPLi5CEp-6t6rE_sb2dGcnzjCGlembC57VwpkUvyMw"
299 "#,
300 )?;
301
302 let config = Figment::new()
303 .merge(Yaml::file("config.yaml"))
304 .extract_inner::<ClientsConfig>("clients")?;
305
306 assert_eq!(config.0.len(), 5);
307
308 assert_eq!(
309 config.0[0].client_id,
310 Ulid::from_str("01GFWR28C4KNE04WG3HKXB7C9R").unwrap()
311 );
312 assert_eq!(
313 config.0[0].redirect_uris,
314 vec!["https://exemple.fr/callback".parse().unwrap()]
315 );
316
317 assert_eq!(
318 config.0[1].client_id,
319 Ulid::from_str("01GFWR32NCQ12B8Z0J8CPXRRB6").unwrap()
320 );
321 assert_eq!(config.0[1].redirect_uris, Vec::new());
322
323 Ok(())
324 });
325 }
326}