Skip to content

Commit a7e7c3c

Browse files
committed
Add clients.[].client_secret_file config option
1 parent 54d8322 commit a7e7c3c

File tree

4 files changed

+177
-75
lines changed

4 files changed

+177
-75
lines changed

crates/cli/src/sync.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -384,7 +384,7 @@ pub async fn config_sync(
384384
continue;
385385
}
386386

387-
let client_secret = client.client_secret.as_deref();
387+
let client_secret = client.client_secret().await?;
388388
let client_name = client.client_name.as_ref();
389389
let client_auth_method = client.client_auth_method();
390390
let jwks = client.jwks.as_ref();

crates/config/src/sections/clients.rs

Lines changed: 166 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,13 @@
66

77
use std::ops::Deref;
88

9+
use anyhow::bail;
10+
use camino::Utf8PathBuf;
911
use mas_iana::oauth::OAuthClientAuthenticationMethod;
1012
use mas_jose::jwk::PublicJsonWebKeySet;
1113
use schemars::JsonSchema;
1214
use serde::{Deserialize, Serialize, de::Error};
15+
use serde_with::serde_as;
1316
use ulid::Ulid;
1417
use url::Url;
1518

@@ -28,6 +31,66 @@ impl From<PublicJsonWebKeySet> for JwksOrJwksUri {
2831
}
2932
}
3033

34+
/// Client secret config option.
35+
///
36+
/// It either holds the client secret value directly or references a file where
37+
/// the client secret is stored.
38+
#[derive(Clone, Debug)]
39+
pub enum ClientSecret {
40+
File(Utf8PathBuf),
41+
Value(String),
42+
}
43+
44+
/// Client secret fields as serialized in JSON.
45+
#[derive(JsonSchema, Serialize, Deserialize, Clone, Debug)]
46+
struct ClientSecretRaw {
47+
/// Path to the file containing the client secret. The client secret is used
48+
/// by the `client_secret_basic`, `client_secret_post` and
49+
/// `client_secret_jwt` authentication methods.
50+
#[schemars(with = "Option<String>")]
51+
#[serde(skip_serializing_if = "Option::is_none")]
52+
client_secret_file: Option<Utf8PathBuf>,
53+
54+
/// Alternative to `client_secret_file`: Reads the client secret directly
55+
/// from the config.
56+
#[serde(skip_serializing_if = "Option::is_none")]
57+
client_secret: Option<String>,
58+
}
59+
60+
impl TryFrom<ClientSecretRaw> for Option<ClientSecret> {
61+
type Error = anyhow::Error;
62+
63+
fn try_from(value: ClientSecretRaw) -> Result<Self, Self::Error> {
64+
match (value.client_secret, value.client_secret_file) {
65+
(None, None) => Ok(None),
66+
(None, Some(path)) => Ok(Some(ClientSecret::File(path))),
67+
(Some(client_secret), None) => Ok(Some(ClientSecret::Value(client_secret))),
68+
(Some(_), Some(_)) => {
69+
bail!("Cannot specify both `client_secret` and `client_secret_file`")
70+
}
71+
}
72+
}
73+
}
74+
75+
impl From<Option<ClientSecret>> for ClientSecretRaw {
76+
fn from(value: Option<ClientSecret>) -> Self {
77+
match value {
78+
Some(ClientSecret::File(path)) => ClientSecretRaw {
79+
client_secret_file: Some(path),
80+
client_secret: None,
81+
},
82+
Some(ClientSecret::Value(client_secret)) => ClientSecretRaw {
83+
client_secret_file: None,
84+
client_secret: Some(client_secret),
85+
},
86+
None => ClientSecretRaw {
87+
client_secret_file: None,
88+
client_secret: None,
89+
},
90+
}
91+
}
92+
}
93+
3194
/// Authentication method used by clients
3295
#[derive(JsonSchema, Serialize, Deserialize, Copy, Clone, Debug)]
3396
#[serde(rename_all = "snake_case")]
@@ -65,6 +128,7 @@ impl std::fmt::Display for ClientAuthMethodConfig {
65128
}
66129

67130
/// An OAuth 2.0 client configuration
131+
#[serde_as]
68132
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
69133
pub struct ClientConfig {
70134
/// The client ID
@@ -84,8 +148,10 @@ pub struct ClientConfig {
84148

85149
/// The client secret, used by the `client_secret_basic`,
86150
/// `client_secret_post` and `client_secret_jwt` authentication methods
87-
#[serde(skip_serializing_if = "Option::is_none")]
88-
pub client_secret: Option<String>,
151+
#[schemars(with = "ClientSecretRaw")]
152+
#[serde_as(as = "serde_with::TryFromInto<ClientSecretRaw>")]
153+
#[serde(flatten)]
154+
pub client_secret: Option<ClientSecret>,
89155

90156
/// The JSON Web Key Set (JWKS) used by the `private_key_jwt` authentication
91157
/// method. Mutually exclusive with `jwks_uri`
@@ -197,6 +263,21 @@ impl ClientConfig {
197263
ClientAuthMethodConfig::PrivateKeyJwt => OAuthClientAuthenticationMethod::PrivateKeyJwt,
198264
}
199265
}
266+
267+
/// Returns the client secret.
268+
///
269+
/// If `client_secret_file` was given, the secret is read from that file.
270+
///
271+
/// # Errors
272+
///
273+
/// Returns an error when the client secret could not be read from file.
274+
pub async fn client_secret(&self) -> anyhow::Result<Option<String>> {
275+
Ok(match &self.client_secret {
276+
Some(ClientSecret::File(path)) => Some(tokio::fs::read_to_string(path).await?),
277+
Some(ClientSecret::Value(client_secret)) => Some(client_secret.clone()),
278+
None => None,
279+
})
280+
}
200281
}
201282

202283
/// List of OAuth 2.0/OIDC clients config
@@ -258,75 +339,91 @@ mod tests {
258339
Figment, Jail,
259340
providers::{Format, Yaml},
260341
};
342+
use tokio::{runtime::Handle, task};
261343

262344
use super::*;
263345

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-
});
346+
#[tokio::test]
347+
async fn load_config() {
348+
task::spawn_blocking(|| {
349+
Jail::expect_with(|jail| {
350+
jail.create_file(
351+
"config.yaml",
352+
r#"
353+
clients:
354+
- client_id: 01GFWR28C4KNE04WG3HKXB7C9R
355+
client_auth_method: none
356+
redirect_uris:
357+
- https://exemple.fr/callback
358+
359+
- client_id: 01GFWR32NCQ12B8Z0J8CPXRRB6
360+
client_auth_method: client_secret_basic
361+
client_secret_file: secret
362+
363+
- client_id: 01GFWR3WHR93Y5HK389H28VHZ9
364+
client_auth_method: client_secret_post
365+
client_secret: c1!3n753c237
366+
367+
- client_id: 01GFWR43R2ZZ8HX9CVBNW9TJWG
368+
client_auth_method: client_secret_jwt
369+
client_secret_file: secret
370+
371+
- client_id: 01GFWR4BNFDCC4QDG6AMSP1VRR
372+
client_auth_method: private_key_jwt
373+
jwks:
374+
keys:
375+
- kid: "03e84aed4ef4431014e8617567864c4efaaaede9"
376+
kty: "RSA"
377+
alg: "RS256"
378+
use: "sig"
379+
e: "AQAB"
380+
n: "ma2uRyBeSEOatGuDpCiV9oIxlDWix_KypDYuhQfEzqi_BiF4fV266OWfyjcABbam59aJMNvOnKW3u_eZM-PhMCBij5MZ-vcBJ4GfxDJeKSn-GP_dJ09rpDcILh8HaWAnPmMoi4DC0nrfE241wPISvZaaZnGHkOrfN_EnA5DligLgVUbrA5rJhQ1aSEQO_gf1raEOW3DZ_ACU3qhtgO0ZBG3a5h7BPiRs2sXqb2UCmBBgwyvYLDebnpE7AotF6_xBIlR-Cykdap3GHVMXhrIpvU195HF30ZoBU4dMd-AeG6HgRt4Cqy1moGoDgMQfbmQ48Hlunv9_Vi2e2CLvYECcBw"
381+
382+
- kid: "d01c1abe249269f72ef7ca2613a86c9f05e59567"
383+
kty: "RSA"
384+
alg: "RS256"
385+
use: "sig"
386+
e: "AQAB"
387+
n: "0hukqytPwrj1RbMYhYoepCi3CN5k7DwYkTe_Cmb7cP9_qv4ok78KdvFXt5AnQxCRwBD7-qTNkkfMWO2RxUMBdQD0ED6tsSb1n5dp0XY8dSWiBDCX8f6Hr-KolOpvMLZKRy01HdAWcM6RoL9ikbjYHUEW1C8IJnw3MzVHkpKFDL354aptdNLaAdTCBvKzU9WpXo10g-5ctzSlWWjQuecLMQ4G1mNdsR1LHhUENEnOvgT8cDkX0fJzLbEbyBYkdMgKggyVPEB1bg6evG4fTKawgnf0IDSPxIU-wdS9wdSP9ZCJJPLi5CEp-6t6rE_sb2dGcnzjCGlembC57VwpkUvyMw"
388+
"#,
389+
)?;
390+
jail.create_file("secret", r"c1!3n753c237")?;
391+
392+
let config = Figment::new()
393+
.merge(Yaml::file("config.yaml"))
394+
.extract_inner::<ClientsConfig>("clients")?;
395+
396+
assert_eq!(config.0.len(), 5);
397+
398+
assert_eq!(
399+
config.0[0].client_id,
400+
Ulid::from_str("01GFWR28C4KNE04WG3HKXB7C9R").unwrap()
401+
);
402+
assert_eq!(
403+
config.0[0].redirect_uris,
404+
vec!["https://exemple.fr/callback".parse().unwrap()]
405+
);
406+
407+
assert_eq!(
408+
config.0[1].client_id,
409+
Ulid::from_str("01GFWR32NCQ12B8Z0J8CPXRRB6").unwrap()
410+
);
411+
assert_eq!(config.0[1].redirect_uris, Vec::new());
412+
413+
assert!(config.0[0].client_secret.is_none());
414+
assert!(matches!(config.0[1].client_secret, Some(ClientSecret::File(ref p)) if p == "secret"));
415+
assert!(matches!(config.0[2].client_secret, Some(ClientSecret::Value(ref v)) if v == "c1!3n753c237"));
416+
assert!(matches!(config.0[3].client_secret, Some(ClientSecret::File(ref p)) if p == "secret"));
417+
assert!(config.0[4].client_secret.is_none());
418+
419+
Handle::current().block_on(async move {
420+
assert_eq!(config.0[1].client_secret().await.unwrap().unwrap(), "c1!3n753c237");
421+
assert_eq!(config.0[2].client_secret().await.unwrap().unwrap(), "c1!3n753c237");
422+
assert_eq!(config.0[3].client_secret().await.unwrap().unwrap(), "c1!3n753c237");
423+
});
424+
425+
Ok(())
426+
});
427+
}).await.unwrap();
331428
}
332429
}

docs/config.schema.json

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -243,10 +243,6 @@
243243
"description": "Name of the `OAuth2` client",
244244
"type": "string"
245245
},
246-
"client_secret": {
247-
"description": "The client secret, used by the `client_secret_basic`, `client_secret_post` and `client_secret_jwt` authentication methods",
248-
"type": "string"
249-
},
250246
"jwks": {
251247
"description": "The JSON Web Key Set (JWKS) used by the `private_key_jwt` authentication method. Mutually exclusive with `jwks_uri`",
252248
"allOf": [
@@ -267,6 +263,14 @@
267263
"type": "string",
268264
"format": "uri"
269265
}
266+
},
267+
"client_secret_file": {
268+
"description": "Path to the file containing the client secret. The client secret is used by the `client_secret_basic`, `client_secret_post` and `client_secret_jwt` authentication methods.",
269+
"type": "string"
270+
},
271+
"client_secret": {
272+
"description": "Alternative to `client_secret_file`: Reads the client secret directly from the config.",
273+
"type": "string"
270274
}
271275
}
272276
},

docs/reference/configuration.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,8 @@ clients:
170170
# Confidential client
171171
- client_id: 000000000000000000000FIRST
172172
client_auth_method: client_secret_post
173-
client_secret: secret
173+
client_secret_file: secret
174+
# OR client_secret: c1!3n753c237
174175
# List of authorized redirect URIs
175176
redirect_uris:
176177
- http://localhost:1234/callback

0 commit comments

Comments
 (0)