diff --git a/crates/cli/src/sync.rs b/crates/cli/src/sync.rs index ff33106af..700b8eea8 100644 --- a/crates/cli/src/sync.rs +++ b/crates/cli/src/sync.rs @@ -384,7 +384,7 @@ pub async fn config_sync( continue; } - let client_secret = client.client_secret.as_deref(); + let client_secret = client.client_secret().await?; let client_name = client.client_name.as_ref(); let client_auth_method = client.client_auth_method(); let jwks = client.jwks.as_ref(); diff --git a/crates/config/src/sections/clients.rs b/crates/config/src/sections/clients.rs index 5d2b0d453..0951ebba2 100644 --- a/crates/config/src/sections/clients.rs +++ b/crates/config/src/sections/clients.rs @@ -6,10 +6,13 @@ use std::ops::Deref; +use anyhow::bail; +use camino::Utf8PathBuf; use mas_iana::oauth::OAuthClientAuthenticationMethod; use mas_jose::jwk::PublicJsonWebKeySet; use schemars::JsonSchema; use serde::{Deserialize, Serialize, de::Error}; +use serde_with::serde_as; use ulid::Ulid; use url::Url; @@ -28,6 +31,66 @@ impl From for JwksOrJwksUri { } } +/// Client secret config option. +/// +/// It either holds the client secret value directly or references a file where +/// the client secret is stored. +#[derive(Clone, Debug)] +pub enum ClientSecret { + File(Utf8PathBuf), + Value(String), +} + +/// Client secret fields as serialized in JSON. +#[derive(JsonSchema, Serialize, Deserialize, Clone, Debug)] +struct ClientSecretRaw { + /// 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. + #[schemars(with = "Option")] + #[serde(skip_serializing_if = "Option::is_none")] + client_secret_file: Option, + + /// Alternative to `client_secret_file`: Reads the client secret directly + /// from the config. + #[serde(skip_serializing_if = "Option::is_none")] + client_secret: Option, +} + +impl TryFrom for Option { + type Error = anyhow::Error; + + fn try_from(value: ClientSecretRaw) -> Result { + match (value.client_secret, value.client_secret_file) { + (None, None) => Ok(None), + (None, Some(path)) => Ok(Some(ClientSecret::File(path))), + (Some(client_secret), None) => Ok(Some(ClientSecret::Value(client_secret))), + (Some(_), Some(_)) => { + bail!("Cannot specify both `client_secret` and `client_secret_file`") + } + } + } +} + +impl From> for ClientSecretRaw { + fn from(value: Option) -> Self { + match value { + Some(ClientSecret::File(path)) => ClientSecretRaw { + client_secret_file: Some(path), + client_secret: None, + }, + Some(ClientSecret::Value(client_secret)) => ClientSecretRaw { + client_secret_file: None, + client_secret: Some(client_secret), + }, + None => ClientSecretRaw { + client_secret_file: None, + client_secret: None, + }, + } + } +} + /// Authentication method used by clients #[derive(JsonSchema, Serialize, Deserialize, Copy, Clone, Debug)] #[serde(rename_all = "snake_case")] @@ -65,6 +128,7 @@ impl std::fmt::Display for ClientAuthMethodConfig { } /// An OAuth 2.0 client configuration +#[serde_as] #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct ClientConfig { /// The client ID @@ -84,8 +148,10 @@ pub struct ClientConfig { /// The client secret, used by the `client_secret_basic`, /// `client_secret_post` and `client_secret_jwt` authentication methods - #[serde(skip_serializing_if = "Option::is_none")] - pub client_secret: Option, + #[schemars(with = "ClientSecretRaw")] + #[serde_as(as = "serde_with::TryFromInto")] + #[serde(flatten)] + pub client_secret: Option, /// The JSON Web Key Set (JWKS) used by the `private_key_jwt` authentication /// method. Mutually exclusive with `jwks_uri` @@ -197,6 +263,21 @@ impl ClientConfig { ClientAuthMethodConfig::PrivateKeyJwt => OAuthClientAuthenticationMethod::PrivateKeyJwt, } } + + /// Returns the client secret. + /// + /// If `client_secret_file` was given, the secret is read from that file. + /// + /// # Errors + /// + /// Returns an error when the client secret could not be read from file. + pub async fn client_secret(&self) -> anyhow::Result> { + Ok(match &self.client_secret { + Some(ClientSecret::File(path)) => Some(tokio::fs::read_to_string(path).await?), + Some(ClientSecret::Value(client_secret)) => Some(client_secret.clone()), + None => None, + }) + } } /// List of OAuth 2.0/OIDC clients config @@ -258,75 +339,91 @@ mod tests { Figment, Jail, providers::{Format, Yaml}, }; + use tokio::{runtime::Handle, task}; use super::*; - #[test] - fn load_config() { - Jail::expect_with(|jail| { - jail.create_file( - "config.yaml", - r#" - clients: - - client_id: 01GFWR28C4KNE04WG3HKXB7C9R - client_auth_method: none - redirect_uris: - - https://exemple.fr/callback - - - client_id: 01GFWR32NCQ12B8Z0J8CPXRRB6 - client_auth_method: client_secret_basic - client_secret: hello - - - client_id: 01GFWR3WHR93Y5HK389H28VHZ9 - client_auth_method: client_secret_post - client_secret: hello - - - client_id: 01GFWR43R2ZZ8HX9CVBNW9TJWG - client_auth_method: client_secret_jwt - client_secret: hello - - - client_id: 01GFWR4BNFDCC4QDG6AMSP1VRR - client_auth_method: private_key_jwt - jwks: - keys: - - kid: "03e84aed4ef4431014e8617567864c4efaaaede9" - kty: "RSA" - alg: "RS256" - use: "sig" - e: "AQAB" - n: "ma2uRyBeSEOatGuDpCiV9oIxlDWix_KypDYuhQfEzqi_BiF4fV266OWfyjcABbam59aJMNvOnKW3u_eZM-PhMCBij5MZ-vcBJ4GfxDJeKSn-GP_dJ09rpDcILh8HaWAnPmMoi4DC0nrfE241wPISvZaaZnGHkOrfN_EnA5DligLgVUbrA5rJhQ1aSEQO_gf1raEOW3DZ_ACU3qhtgO0ZBG3a5h7BPiRs2sXqb2UCmBBgwyvYLDebnpE7AotF6_xBIlR-Cykdap3GHVMXhrIpvU195HF30ZoBU4dMd-AeG6HgRt4Cqy1moGoDgMQfbmQ48Hlunv9_Vi2e2CLvYECcBw" - - - kid: "d01c1abe249269f72ef7ca2613a86c9f05e59567" - kty: "RSA" - alg: "RS256" - use: "sig" - e: "AQAB" - n: "0hukqytPwrj1RbMYhYoepCi3CN5k7DwYkTe_Cmb7cP9_qv4ok78KdvFXt5AnQxCRwBD7-qTNkkfMWO2RxUMBdQD0ED6tsSb1n5dp0XY8dSWiBDCX8f6Hr-KolOpvMLZKRy01HdAWcM6RoL9ikbjYHUEW1C8IJnw3MzVHkpKFDL354aptdNLaAdTCBvKzU9WpXo10g-5ctzSlWWjQuecLMQ4G1mNdsR1LHhUENEnOvgT8cDkX0fJzLbEbyBYkdMgKggyVPEB1bg6evG4fTKawgnf0IDSPxIU-wdS9wdSP9ZCJJPLi5CEp-6t6rE_sb2dGcnzjCGlembC57VwpkUvyMw" - "#, - )?; - - let config = Figment::new() - .merge(Yaml::file("config.yaml")) - .extract_inner::("clients")?; - - assert_eq!(config.0.len(), 5); - - assert_eq!( - config.0[0].client_id, - Ulid::from_str("01GFWR28C4KNE04WG3HKXB7C9R").unwrap() - ); - assert_eq!( - config.0[0].redirect_uris, - vec!["https://exemple.fr/callback".parse().unwrap()] - ); - - assert_eq!( - config.0[1].client_id, - Ulid::from_str("01GFWR32NCQ12B8Z0J8CPXRRB6").unwrap() - ); - assert_eq!(config.0[1].redirect_uris, Vec::new()); - - Ok(()) - }); + #[tokio::test] + async fn load_config() { + task::spawn_blocking(|| { + Jail::expect_with(|jail| { + jail.create_file( + "config.yaml", + r#" + clients: + - client_id: 01GFWR28C4KNE04WG3HKXB7C9R + client_auth_method: none + redirect_uris: + - https://exemple.fr/callback + + - client_id: 01GFWR32NCQ12B8Z0J8CPXRRB6 + client_auth_method: client_secret_basic + client_secret_file: secret + + - client_id: 01GFWR3WHR93Y5HK389H28VHZ9 + client_auth_method: client_secret_post + client_secret: c1!3n753c237 + + - client_id: 01GFWR43R2ZZ8HX9CVBNW9TJWG + client_auth_method: client_secret_jwt + client_secret_file: secret + + - client_id: 01GFWR4BNFDCC4QDG6AMSP1VRR + client_auth_method: private_key_jwt + jwks: + keys: + - kid: "03e84aed4ef4431014e8617567864c4efaaaede9" + kty: "RSA" + alg: "RS256" + use: "sig" + e: "AQAB" + n: "ma2uRyBeSEOatGuDpCiV9oIxlDWix_KypDYuhQfEzqi_BiF4fV266OWfyjcABbam59aJMNvOnKW3u_eZM-PhMCBij5MZ-vcBJ4GfxDJeKSn-GP_dJ09rpDcILh8HaWAnPmMoi4DC0nrfE241wPISvZaaZnGHkOrfN_EnA5DligLgVUbrA5rJhQ1aSEQO_gf1raEOW3DZ_ACU3qhtgO0ZBG3a5h7BPiRs2sXqb2UCmBBgwyvYLDebnpE7AotF6_xBIlR-Cykdap3GHVMXhrIpvU195HF30ZoBU4dMd-AeG6HgRt4Cqy1moGoDgMQfbmQ48Hlunv9_Vi2e2CLvYECcBw" + + - kid: "d01c1abe249269f72ef7ca2613a86c9f05e59567" + kty: "RSA" + alg: "RS256" + use: "sig" + e: "AQAB" + n: "0hukqytPwrj1RbMYhYoepCi3CN5k7DwYkTe_Cmb7cP9_qv4ok78KdvFXt5AnQxCRwBD7-qTNkkfMWO2RxUMBdQD0ED6tsSb1n5dp0XY8dSWiBDCX8f6Hr-KolOpvMLZKRy01HdAWcM6RoL9ikbjYHUEW1C8IJnw3MzVHkpKFDL354aptdNLaAdTCBvKzU9WpXo10g-5ctzSlWWjQuecLMQ4G1mNdsR1LHhUENEnOvgT8cDkX0fJzLbEbyBYkdMgKggyVPEB1bg6evG4fTKawgnf0IDSPxIU-wdS9wdSP9ZCJJPLi5CEp-6t6rE_sb2dGcnzjCGlembC57VwpkUvyMw" + "#, + )?; + jail.create_file("secret", r"c1!3n753c237")?; + + let config = Figment::new() + .merge(Yaml::file("config.yaml")) + .extract_inner::("clients")?; + + assert_eq!(config.0.len(), 5); + + assert_eq!( + config.0[0].client_id, + Ulid::from_str("01GFWR28C4KNE04WG3HKXB7C9R").unwrap() + ); + assert_eq!( + config.0[0].redirect_uris, + vec!["https://exemple.fr/callback".parse().unwrap()] + ); + + assert_eq!( + config.0[1].client_id, + Ulid::from_str("01GFWR32NCQ12B8Z0J8CPXRRB6").unwrap() + ); + assert_eq!(config.0[1].redirect_uris, Vec::new()); + + assert!(config.0[0].client_secret.is_none()); + assert!(matches!(config.0[1].client_secret, Some(ClientSecret::File(ref p)) if p == "secret")); + assert!(matches!(config.0[2].client_secret, Some(ClientSecret::Value(ref v)) if v == "c1!3n753c237")); + assert!(matches!(config.0[3].client_secret, Some(ClientSecret::File(ref p)) if p == "secret")); + assert!(config.0[4].client_secret.is_none()); + + Handle::current().block_on(async move { + assert_eq!(config.0[1].client_secret().await.unwrap().unwrap(), "c1!3n753c237"); + assert_eq!(config.0[2].client_secret().await.unwrap().unwrap(), "c1!3n753c237"); + assert_eq!(config.0[3].client_secret().await.unwrap().unwrap(), "c1!3n753c237"); + }); + + Ok(()) + }); + }).await.unwrap(); } } diff --git a/docs/config.schema.json b/docs/config.schema.json index 8b726f1e9..e9fd4f05e 100644 --- a/docs/config.schema.json +++ b/docs/config.schema.json @@ -243,10 +243,6 @@ "description": "Name of the `OAuth2` client", "type": "string" }, - "client_secret": { - "description": "The client secret, used by the `client_secret_basic`, `client_secret_post` and `client_secret_jwt` authentication methods", - "type": "string" - }, "jwks": { "description": "The JSON Web Key Set (JWKS) used by the `private_key_jwt` authentication method. Mutually exclusive with `jwks_uri`", "allOf": [ @@ -267,6 +263,14 @@ "type": "string", "format": "uri" } + }, + "client_secret_file": { + "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.", + "type": "string" + }, + "client_secret": { + "description": "Alternative to `client_secret_file`: Reads the client secret directly from the config.", + "type": "string" } } }, diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 79b2b29ee..8d88b8792 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -170,7 +170,8 @@ clients: # Confidential client - client_id: 000000000000000000000FIRST client_auth_method: client_secret_post - client_secret: secret + client_secret_file: secret + # OR client_secret: c1!3n753c237 # List of authorized redirect URIs redirect_uris: - http://localhost:1234/callback