kych

OAuth 2.0 API for Swiyu to enable Taler integration of Swiyu for KYC (experimental)
Log | Files | Refs | README | LICENSE

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 }