66
77use std:: ops:: Deref ;
88
9+ use anyhow:: bail;
10+ use camino:: Utf8PathBuf ;
911use mas_iana:: oauth:: OAuthClientAuthenticationMethod ;
1012use mas_jose:: jwk:: PublicJsonWebKeySet ;
1113use schemars:: JsonSchema ;
1214use serde:: { Deserialize , Serialize , de:: Error } ;
15+ use serde_with:: serde_as;
1316use ulid:: Ulid ;
1417use 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 ) ]
69133pub 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}
0 commit comments