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