Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion crates/cli/src/sync.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
235 changes: 166 additions & 69 deletions crates/config/src/sections/clients.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -28,6 +31,66 @@ impl From<PublicJsonWebKeySet> 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<String>")]
#[serde(skip_serializing_if = "Option::is_none")]
client_secret_file: Option<Utf8PathBuf>,

/// Alternative to `client_secret_file`: Reads the client secret directly
/// from the config.
#[serde(skip_serializing_if = "Option::is_none")]
client_secret: Option<String>,
}

impl TryFrom<ClientSecretRaw> for Option<ClientSecret> {
type Error = anyhow::Error;

fn try_from(value: ClientSecretRaw) -> Result<Self, Self::Error> {
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<Option<ClientSecret>> for ClientSecretRaw {
fn from(value: Option<ClientSecret>) -> 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")]
Expand Down Expand Up @@ -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
Expand All @@ -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<String>,
#[schemars(with = "ClientSecretRaw")]
#[serde_as(as = "serde_with::TryFromInto<ClientSecretRaw>")]
#[serde(flatten)]
pub client_secret: Option<ClientSecret>,

/// The JSON Web Key Set (JWKS) used by the `private_key_jwt` authentication
/// method. Mutually exclusive with `jwks_uri`
Expand Down Expand Up @@ -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<Option<String>> {
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
Expand Down Expand Up @@ -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::<ClientsConfig>("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::<ClientsConfig>("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();
}
}
12 changes: 8 additions & 4 deletions docs/config.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand All @@ -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"
}
}
},
Expand Down
3 changes: 2 additions & 1 deletion docs/reference/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading