kych

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

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 }