config.rs (10977B)
1 //! KyCH configuration. 2 //! 3 //! The file format, the search path for it and the accessors used below all 4 //! come from `taler-config`, the vendored copy of the GNU Taler configuration 5 //! parser; see `../../taler-config/README`. Only the meaning of the options 6 //! is KyCH's own. 7 8 use std::collections::HashSet; 9 10 use sqlx::postgres::PgConnectOptions; 11 use taler_config::{ 12 config::{Config as TalerConfig, Section, ValueErr, parser::ConfigSource}, 13 serve::Serve, 14 }; 15 16 /// Where the tools look for their configuration when `-c` is not given: 17 /// `$XDG_CONFIG_HOME/kych.conf`, `~/.config/kych.conf`, `/etc/kych.conf`, 18 /// `/etc/kych/kych.conf`. The executable name is what `$PREFIX` and the other 19 /// `[paths]` variables are derived from, by locating it on `$PATH`. 20 pub const CONFIG_SOURCE: ConfigSource = ConfigSource::new("kych", "kych", "kych-oauth2-gateway"); 21 22 const MAIN_SECTION: &str = "kych-oauth2-gateway"; 23 const CLIENT_SECTION_PREFIX: &str = "client_"; 24 const DEFAULT_VERIFIER_MANAGEMENT_API_PATH: &str = "/management/api/verifications"; 25 26 #[derive(Debug, Clone)] 27 pub struct Config { 28 pub serve: Serve, 29 pub database: PgConnectOptions, 30 pub crypto: CryptoConfig, 31 pub vc: VcConfig, 32 pub allowed_scopes: Option<Vec<String>>, 33 pub clients: Vec<ClientConfig>, 34 } 35 36 #[derive(Debug, Clone)] 37 pub struct CryptoConfig { 38 pub nonce_bytes: usize, 39 pub token_bytes: usize, 40 pub authorization_code_bytes: usize, 41 pub authorization_code_ttl_minutes: i64, 42 } 43 44 #[derive(Debug, Clone)] 45 pub struct VcConfig { 46 pub vc_type: String, 47 pub vc_format: String, 48 pub vc_algorithms: Vec<String>, 49 pub vc_claims: HashSet<String>, 50 } 51 52 /// One `[client_*]` section. Note that the running gateway never looks at 53 /// these: they are the input to `kych-client-management sync`, which writes 54 /// them to the `oauth2gw.clients` table the gateway does read. 55 #[derive(Debug, Clone)] 56 pub struct ClientConfig { 57 pub section_name: String, 58 pub client_id: String, 59 pub client_secret: String, 60 pub verifier_url: String, 61 pub verifier_management_api_path: String, 62 pub redirect_uri: String, 63 pub accepted_issuer_dids: Option<String>, 64 } 65 66 impl Config { 67 /// Interpret an already-parsed configuration file. 68 pub fn parse(cfg: &TalerConfig) -> Result<Self, ValueErr> { 69 let main = cfg.section(MAIN_SECTION); 70 71 let crypto = CryptoConfig { 72 nonce_bytes: main.number("NONCE_BYTES").require()?, 73 token_bytes: main.number("TOKEN_BYTES").require()?, 74 authorization_code_bytes: main.number("AUTH_CODE_BYTES").require()?, 75 authorization_code_ttl_minutes: main.number("AUTH_CODE_TTL_MINUTES").default(10)?, 76 }; 77 78 let vc = VcConfig { 79 vc_type: main.str("VC_TYPE").require()?, 80 vc_format: main.str("VC_FORMAT").require()?, 81 vc_algorithms: list(&main, "VC_ALGORITHMS").require()?, 82 vc_claims: list(&main, "VC_CLAIMS").require()?.into_iter().collect(), 83 }; 84 85 let mut clients = Vec::new(); 86 for section in cfg.sections() { 87 if !section.name.starts_with(CLIENT_SECTION_PREFIX) { 88 continue; 89 } 90 clients.push(ClientConfig { 91 section_name: section.name.to_owned(), 92 client_id: section.str("CLIENT_ID").require()?, 93 client_secret: section.str("CLIENT_SECRET").require()?, 94 verifier_url: section.str("VERIFIER_URL").require()?, 95 verifier_management_api_path: section 96 .str("VERIFIER_MANAGEMENT_API_PATH") 97 .default(DEFAULT_VERIFIER_MANAGEMENT_API_PATH.to_owned())?, 98 redirect_uri: section.str("REDIRECT_URI").require()?, 99 accepted_issuer_dids: section.str("ACCEPTED_ISSUER_DIDS").opt()?, 100 }); 101 } 102 103 Ok(Config { 104 serve: Serve::parse(&main)?, 105 database: main.postgres("DATABASE").require()?, 106 crypto, 107 vc, 108 allowed_scopes: list(&main, "ALLOWED_SCOPES").opt()?, 109 clients, 110 }) 111 } 112 } 113 114 /// A `{a, b, c}` list. The braces are optional and both commas and whitespace 115 /// separate, so `{a, b}`, `a, b` and `a b` all parse; an option that is present 116 /// but holds no item at all is an error, since every list KyCH reads needs at 117 /// least one entry to be useful. 118 fn list<'cfg, 'arg>( 119 section: &Section<'cfg, 'arg>, 120 option: &'arg str, 121 ) -> taler_config::config::Value<'arg, Vec<String>> { 122 section.value("list", option, |raw| { 123 let trimmed = raw.trim(); 124 let inner = match trimmed.strip_prefix('{') { 125 Some(rest) => rest 126 .strip_suffix('}') 127 .ok_or_else(|| format!("unbalanced braces in '{trimmed}'"))?, 128 None => trimmed, 129 }; 130 let items: Vec<String> = inner 131 .split(|c: char| c == ',' || c.is_whitespace()) 132 .map(str::trim) 133 .filter(|it| !it.is_empty()) 134 .map(str::to_owned) 135 .collect(); 136 if items.is_empty() { 137 return Err("expected at least one item".to_owned()); 138 } 139 Ok(items) 140 }) 141 } 142 143 #[cfg(test)] 144 mod tests { 145 use super::*; 146 147 /// A configuration with every required option set, so that each test can 148 /// override or drop exactly the one it is about. 149 const MINIMAL: &str = "\ 150 [kych-oauth2-gateway] 151 SERVE = unix 152 UNIXPATH = /run/kych/kych.sock 153 UNIXPATH_MODE = 660 154 DATABASE = postgres:///kych 155 NONCE_BYTES = 32 156 TOKEN_BYTES = 32 157 AUTH_CODE_BYTES = 32 158 VC_TYPE = betaid-sdjwt 159 VC_FORMAT = vc+sd-jwt 160 VC_ALGORITHMS = {ES256} 161 VC_CLAIMS = {given_name, family_name, age_over_18} 162 "; 163 164 fn parse(extra: &str) -> Result<Config, String> { 165 let cfg = TalerConfig::from_mem(&format!("{MINIMAL}{extra}")).unwrap(); 166 Config::parse(&cfg).map_err(|e| e.to_string()) 167 } 168 169 fn without(option: &str) -> Result<Config, String> { 170 let stripped: String = MINIMAL 171 .lines() 172 .filter(|line| !line.starts_with(option)) 173 .collect::<Vec<_>>() 174 .join("\n"); 175 let cfg = TalerConfig::from_mem(&stripped).unwrap(); 176 Config::parse(&cfg).map_err(|e| e.to_string()) 177 } 178 179 #[test] 180 fn minimal() { 181 let cfg = parse("").unwrap(); 182 assert!(matches!(cfg.serve, Serve::Unix { .. })); 183 assert_eq!(32, cfg.crypto.nonce_bytes); 184 // Not in MINIMAL, so the documented default applies. 185 assert_eq!(10, cfg.crypto.authorization_code_ttl_minutes); 186 assert_eq!(vec!["ES256".to_owned()], cfg.vc.vc_algorithms); 187 assert_eq!(3, cfg.vc.vc_claims.len()); 188 assert!(cfg.vc.vc_claims.contains("age_over_18")); 189 assert_eq!(None, cfg.allowed_scopes); 190 assert!(cfg.clients.is_empty()); 191 } 192 193 #[test] 194 fn missing_options_name_themselves() { 195 assert_eq!( 196 "Missing number option NONCE_BYTES in section [kych-oauth2-gateway]", 197 without("NONCE_BYTES").unwrap_err() 198 ); 199 assert_eq!( 200 "Missing string option VC_TYPE in section [kych-oauth2-gateway]", 201 without("VC_TYPE").unwrap_err() 202 ); 203 assert_eq!( 204 "Missing list option VC_CLAIMS in section [kych-oauth2-gateway]", 205 without("VC_CLAIMS").unwrap_err() 206 ); 207 assert_eq!( 208 "Missing Postgres URI option DATABASE in section [kych-oauth2-gateway]", 209 without("DATABASE").unwrap_err() 210 ); 211 } 212 213 #[test] 214 fn lists_accept_braces_commas_and_spaces() { 215 for raw in ["{a, b, c}", "a, b, c", "a b c", "{a b, c}", " {a,b,c} "] { 216 let cfg = parse(&format!("VC_ALGORITHMS = {raw}\n")).unwrap(); 217 assert_eq!(vec!["a", "b", "c"], cfg.vc.vc_algorithms, "for '{raw}'"); 218 } 219 } 220 221 #[test] 222 fn empty_and_malformed_lists_are_rejected() { 223 assert_eq!( 224 "Invalid list option VC_ALGORITHMS in section [kych-oauth2-gateway]: \ 225 expected at least one item", 226 parse("VC_ALGORITHMS = {}\n").unwrap_err() 227 ); 228 assert_eq!( 229 "Invalid list option VC_ALGORITHMS in section [kych-oauth2-gateway]: \ 230 unbalanced braces in '{ES256'", 231 parse("VC_ALGORITHMS = {ES256\n").unwrap_err() 232 ); 233 // An option present but empty reads as absent, so this is "missing". 234 assert_eq!( 235 "Missing list option VC_ALGORITHMS in section [kych-oauth2-gateway]", 236 parse("VC_ALGORITHMS =\n").unwrap_err() 237 ); 238 } 239 240 #[test] 241 fn allowed_scopes_are_optional() { 242 let cfg = parse("ALLOWED_SCOPES = {age_over_18}\n").unwrap(); 243 assert_eq!(Some(vec!["age_over_18".to_owned()]), cfg.allowed_scopes); 244 } 245 246 #[test] 247 fn serve_modes() { 248 let cfg = parse("").unwrap(); 249 let Serve::Unix { path, .. } = cfg.serve else { 250 panic!("expected a UNIX domain socket") 251 }; 252 assert_eq!("/run/kych/kych.sock", path); 253 254 let cfg = TalerConfig::from_mem( 255 "[kych-oauth2-gateway]\nSERVE = systemd\nDATABASE = postgres:///kych\n\ 256 NONCE_BYTES = 32\nTOKEN_BYTES = 32\nAUTH_CODE_BYTES = 32\nVC_TYPE = t\n\ 257 VC_FORMAT = f\nVC_ALGORITHMS = {ES256}\nVC_CLAIMS = {a}\n", 258 ) 259 .unwrap(); 260 assert!(matches!( 261 Config::parse(&cfg).unwrap().serve, 262 Serve::Systemd 263 )); 264 } 265 266 #[test] 267 fn client_sections() { 268 let cfg = parse( 269 "\n[client_exchange]\n\ 270 CLIENT_ID = exchange-prod-01\n\ 271 CLIENT_SECRET = s3cret\n\ 272 VERIFIER_URL = https://verifier.example.com\n\ 273 REDIRECT_URI = https://exchange.example.com/kyc-proof/kych\n\ 274 ACCEPTED_ISSUER_DIDS = {did:tdw:example:issuer}\n", 275 ) 276 .unwrap(); 277 278 assert_eq!(1, cfg.clients.len()); 279 let client = &cfg.clients[0]; 280 assert_eq!("client_exchange", client.section_name); 281 assert_eq!("exchange-prod-01", client.client_id); 282 // Not given, so the default path applies. 283 assert_eq!( 284 DEFAULT_VERIFIER_MANAGEMENT_API_PATH, 285 client.verifier_management_api_path 286 ); 287 assert_eq!( 288 Some("{did:tdw:example:issuer}".to_owned()), 289 client.accepted_issuer_dids 290 ); 291 } 292 293 #[test] 294 fn client_section_errors_name_the_section() { 295 assert_eq!( 296 "Missing string option CLIENT_SECRET in section [client_exchange]", 297 parse("\n[client_exchange]\nCLIENT_ID = exchange-prod-01\n").unwrap_err() 298 ); 299 } 300 301 /// Sections whose name does not start with `client_` are ignored, and so 302 /// is the synthetic `[paths]` section the parser adds. 303 #[test] 304 fn unrelated_sections_are_ignored() { 305 let cfg = parse("\n[paths]\nDATADIR = /tmp\n\n[something-else]\nKEY = value\n").unwrap(); 306 assert!(cfg.clients.is_empty()); 307 } 308 }