kych

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

client_management_cli.rs (12609B)


      1 //! OAuth2 Gateway CLI
      2 //!
      3 //! Command-line tool for managing OAuth2 Gateway clients.
      4 //!
      5 //! Set DATABASE_URL environment variable to connect to the database.
      6 //! Usage:
      7 //!   client-mgmt client list
      8 //!   client-mgmt client show <client_id>
      9 //!   client-mgmt client create --client-id <id> --secret <secret> ...
     10 //!   client-mgmt client update <client_id> --webhook-url <url>
     11 //!   client-mgmt client delete <client_id>
     12 
     13 use anyhow::{Context, Result};
     14 use clap::{Parser, Subcommand};
     15 use ini::Ini;
     16 use oauth2_gateway::db;
     17 use std::env;
     18 use std::collections::HashSet;
     19 
     20 #[derive(Parser, Debug)]
     21 #[command(name = "client-mgmt")]
     22 
     23 #[command(version)]
     24 #[command(about = "OAuth2 Gateway client management CLI")]
     25 #[command(after_help = "Environment variables:\n  DATABASE_URL  PostgreSQL connection string (required)")]
     26 struct Args {
     27     #[command(subcommand)]
     28     command: Commands,
     29 }
     30 
     31 #[derive(Subcommand, Debug)]
     32 enum Commands {
     33     List,
     34 
     35     Show {
     36         client_id: String,
     37     },
     38 
     39     Create {
     40         /// Unique client identifier
     41         #[arg(long)]
     42         client_id: String,
     43 
     44         /// Client secret (stored as hash)
     45         #[arg(long)]
     46         secret: String,
     47 
     48         /// Webhook URL for notifications
     49         #[arg(long)]
     50         webhook_url: String,
     51 
     52         /// Swiyu verifier base URL
     53         #[arg(long)]
     54         verifier_url: String,
     55 
     56         /// Verifier management API path (default: /management/api/verifications)
     57         #[arg(long)]
     58         verifier_api_path: Option<String>,
     59 
     60         /// Default redirect URI for OAuth2 flow
     61         #[arg(long)]
     62         redirect_uri: Option<String>,
     63 
     64         /// Comma-separated list of accepted issuer DIDs
     65         #[arg(long)]
     66         accepted_issuer_dids: Option<String>,
     67     },
     68 
     69     Update {
     70         client_id: String,
     71 
     72         #[arg(long)]
     73         webhook_url: Option<String>,
     74 
     75         #[arg(long)]
     76         verifier_url: Option<String>,
     77 
     78         #[arg(long)]
     79         verifier_api_path: Option<String>,
     80 
     81         #[arg(long)]
     82         redirect_uri: Option<String>,
     83 
     84         #[arg(long)]
     85         accepted_issuer_dids: Option<String>,
     86     },
     87 
     88     /// Sync clients from a configuration file
     89     Sync {
     90         /// Path to clients.conf file
     91         config_file: String,
     92 
     93         /// Remove clients not in config file
     94         #[arg(long)]
     95         prune: bool,
     96     },
     97 
     98     /// Delete a client (WARNING: cascades to all sessions)
     99     Delete {
    100         client_id: String,
    101 
    102         #[arg(long, short = 'y')]
    103         yes: bool,
    104     },   
    105 }
    106 
    107 #[tokio::main]
    108 async fn main() -> Result<()> {
    109     // Load .env (ignore if missing)
    110     let _ = dotenvy::dotenv();
    111 
    112     let args = Args::parse();
    113 
    114     let database_url = env::var("DATABASE_URL")
    115         .context("DATABASE_URL environment variable not set")?;
    116 
    117     let pool = db::create_pool(&database_url)
    118         .await
    119         .context("Failed to connect to database")?;
    120 
    121     match args.command {
    122         Commands::List => cmd_list_clients(&pool).await?,
    123         Commands::Show { client_id } => cmd_show_client(&pool,&client_id).await?,
    124         Commands::Create {
    125             client_id,
    126             secret,
    127             webhook_url,
    128             verifier_url,
    129             verifier_api_path,
    130             redirect_uri,
    131             accepted_issuer_dids,
    132         } => {
    133             cmd_create_client(
    134                 &pool,
    135                 &client_id,
    136                 &secret,
    137                 &webhook_url,
    138                 &verifier_url,
    139                 verifier_api_path.as_deref(),
    140                 redirect_uri.as_deref(),
    141                 accepted_issuer_dids.as_deref(),
    142             )
    143             .await?
    144         }
    145         Commands::Update {
    146             client_id,
    147             webhook_url,
    148             verifier_url,
    149             verifier_api_path,
    150             redirect_uri,
    151             accepted_issuer_dids,
    152         } => {
    153             cmd_update_client(
    154                 &pool,
    155                 &client_id,
    156                 webhook_url.as_deref(),
    157                 verifier_url.as_deref(),
    158                 verifier_api_path.as_deref(),
    159                 redirect_uri.as_deref(),
    160                 accepted_issuer_dids.as_deref(),
    161             )
    162             .await?
    163         }
    164         Commands::Sync { config_file, prune } => {
    165             cmd_sync_clients(&pool, &config_file, prune).await?
    166         }
    167         Commands::Delete { client_id, yes } => {
    168             cmd_delete_client(&pool, &client_id, yes).await?
    169         }
    170     }
    171 
    172     Ok(())
    173 }
    174 
    175 fn print_client_details(client: &db::clients::Client) {
    176     use chrono::Local;
    177 
    178     println!("{}", "=".repeat(60));
    179     println!("UUID:                  {}", client.id);
    180     println!("Client ID:             {}", client.client_id);
    181     println!("Secret Hash:           {}...", &client.secret_hash[..20.min(client.secret_hash.len())]);
    182     println!("Webhook URL:           {}", client.webhook_url);
    183     println!("Verifier URL:          {}", client.verifier_url);
    184     println!("Verifier API Path:     {}", client.verifier_management_api_path);
    185     println!("Redirect URI:          {}", client.redirect_uri.as_deref().unwrap_or("(not set)"));
    186     println!("Accepted Issuer DIDs:  {}", client.accepted_issuer_dids.as_deref().unwrap_or("(not set)"));
    187     println!("Created:               {}", client.created_at.with_timezone(&Local));
    188     println!("Updated:               {}", client.updated_at.with_timezone(&Local));
    189 }
    190 
    191 async fn cmd_list_clients(pool: &sqlx::PgPool) -> Result<()> {
    192     let clients = db::clients::list_clients(pool).await?;
    193 
    194     if clients.is_empty() {
    195         println!("No clients registered.");
    196         return Ok(());
    197     }
    198 
    199     let total = clients.len();
    200 
    201     for (i, client) in clients.iter().enumerate() {
    202         if i > 0 {
    203             println!();
    204         }
    205         print_client_details(&client);
    206     }
    207 
    208     println!();
    209     println!("{}", "=".repeat(60));
    210     println!("Total clients: {}", total);
    211 
    212     Ok(())
    213 }
    214 
    215 async fn cmd_show_client(pool: &sqlx::PgPool, client_id: &str) -> Result<()> {
    216     let client = db::clients::get_client_by_id(pool, client_id)
    217         .await?
    218         .ok_or_else(|| anyhow::anyhow!("Client not found: {}", client_id))?;
    219 
    220     print_client_details(&client);
    221 
    222     Ok(())
    223 }
    224 
    225 async fn cmd_create_client(
    226     pool: &sqlx::PgPool,
    227     client_id: &str,
    228     secret: &str,
    229     webhook_url: &str,
    230     verifier_url: &str,
    231     verifier_api_path: Option<&str>,
    232     redirect_uri: Option<&str>,
    233     accepted_issuer_dids: Option<&str>,
    234 ) -> Result<()> {
    235     let client = db::clients::register_client(
    236         pool,
    237         client_id,
    238         secret,
    239         webhook_url,
    240         verifier_url,
    241         verifier_api_path,
    242         redirect_uri,
    243         accepted_issuer_dids,
    244     )
    245     .await
    246     .context("Failed to create client")?;
    247 
    248     println!("Client created successfully.");
    249     println!();
    250     print_client_details(&client);
    251 
    252     Ok(())
    253 }
    254 
    255 async fn cmd_update_client(
    256     pool: &sqlx::PgPool,
    257     client_id: &str,
    258     webhook_url: Option<&str>,
    259     verifier_url: Option<&str>,
    260     verifier_api_path: Option<&str>,
    261     redirect_uri: Option<&str>,
    262     accepted_issuer_dids: Option<&str>,
    263 ) -> Result<()> {
    264     if webhook_url.is_none() && verifier_url.is_none() && verifier_api_path.is_none()
    265         && redirect_uri.is_none() && accepted_issuer_dids.is_none() {
    266         anyhow::bail!("No fields to update. Specify at least one of: --webhook-url, --verifier-url, --verifier-api-path, --redirect-uri, --accepted-issuer-dids");
    267     }
    268 
    269     let client = db::clients::get_client_by_id(pool, client_id)
    270         .await?
    271         .ok_or_else(|| anyhow::anyhow!("Client not found: {}", client_id))?;
    272 
    273     let updated = db::clients::update_client(
    274         pool,
    275         client.id,
    276         webhook_url,
    277         verifier_url,
    278         verifier_api_path,
    279         redirect_uri,
    280         accepted_issuer_dids,
    281     )
    282     .await
    283     .context("Failed to update client")?;
    284 
    285     println!("Client updated successfully.");
    286     println!();
    287     print_client_details(&updated);
    288 
    289     Ok(())
    290 }
    291 
    292 async fn cmd_delete_client(pool: &sqlx::PgPool, client_id: &str, skip_confirm: bool) -> Result<()> {
    293     let client = db::clients::get_client_by_id(pool, client_id)
    294         .await?
    295         .ok_or_else(|| anyhow::anyhow!("Client not found: {}", client_id))?;
    296 
    297     if !skip_confirm {
    298         println!("WARNING: This will delete client '{}' and ALL associated data:", client_id);
    299         println!("  - All sessions");
    300         println!("  - All tokens");
    301         println!("  - All pending webhooks");
    302         println!();
    303         print!("Type 'yes' to confirm: ");
    304 
    305         use std::io::{self, Write};
    306         io::stdout().flush()?;
    307 
    308         let mut input = String::new();
    309         io::stdin().read_line(&mut input)?;
    310 
    311         if input.trim() != "yes" {
    312             println!("Aborted.");
    313             return Ok(());
    314         }
    315     }
    316 
    317     let deleted = db::clients::delete_client(pool, client.id).await?;
    318 
    319     if deleted {
    320         println!("Client '{}' deleted successfully.", client_id);
    321     } else {
    322         println!("Client not found (may have been deleted already).");
    323     }
    324 
    325     Ok(())
    326 }
    327 
    328 async fn cmd_sync_clients(pool: &sqlx::PgPool, config_file: &str, prune: bool) -> Result<()> {
    329     println!("Loading clients from: {}", config_file);
    330 
    331     let ini = Ini::load_from_file(config_file)
    332         .context("Failed to load configuration file")?;
    333 
    334     let mut synced_client_ids = HashSet::new();
    335     let mut created_count = 0;
    336     let mut updated_count = 0;
    337 
    338     for (section_name, properties) in ini.iter() {
    339         let section_name = match section_name {
    340             Some(name) => name,
    341             None => continue,
    342         };
    343 
    344         println!("\nProcessing section: [{}]", section_name);
    345 
    346         let client_id = properties.get("client_id")
    347             .ok_or_else(|| anyhow::anyhow!("Missing client_id in section [{}]", section_name))?;
    348         let client_secret = properties.get("client_secret")
    349             .ok_or_else(|| anyhow::anyhow!("Missing client_secret in section [{}]", section_name))?;
    350         let webhook_url = properties.get("webhook_url")
    351             .ok_or_else(|| anyhow::anyhow!("Missing webhook_url in section [{}]", section_name))?;
    352         let verifier_url = properties.get("verifier_url")
    353             .ok_or_else(|| anyhow::anyhow!("Missing verifier_url in section [{}]", section_name))?;
    354 
    355         let verifier_api_path = properties.get("verifier_management_api_path");
    356         let redirect_uri = properties.get("redirect_uri");
    357         let accepted_issuer_dids = properties.get("accepted_issuer_dids");
    358 
    359         synced_client_ids.insert(client_id.to_string());
    360 
    361         let existing_client = db::clients::get_client_by_id(pool, client_id).await?;
    362 
    363         match existing_client {
    364             Some(existing) => {
    365                 println!("  Client '{}' already exists, updating...", client_id);
    366                 db::clients::update_client(
    367                     pool,
    368                     existing.id,
    369                     Some(webhook_url),
    370                     Some(verifier_url),
    371                     verifier_api_path,
    372                     redirect_uri,
    373                     accepted_issuer_dids,
    374                 )
    375                 .await
    376                 .context(format!("Failed to update client '{}'", client_id))?;
    377                 updated_count += 1;
    378                 println!("  Updated client '{}'", client_id);
    379             }
    380             None => {
    381                 println!("  Creating new client '{}'...", client_id);
    382                 db::clients::register_client(
    383                     pool,
    384                     client_id,
    385                     client_secret,
    386                     webhook_url,
    387                     verifier_url,
    388                     verifier_api_path,
    389                     redirect_uri,
    390                     accepted_issuer_dids,
    391                 )
    392                 .await
    393                 .context(format!("Failed to create client '{}'", client_id))?;
    394                 created_count += 1;
    395                 println!("  Created client '{}'", client_id);
    396             }
    397         }
    398     }
    399 
    400     if prune {
    401         println!("\nPruning clients not in configuration file...");
    402         let all_clients = db::clients::list_clients(pool).await?;
    403         let mut pruned_count = 0;
    404 
    405         for client in all_clients {
    406             if !synced_client_ids.contains(&client.client_id) {
    407                 println!("  Deleting client '{}'...", client.client_id);
    408                 db::clients::delete_client(pool, client.id).await?;
    409                 pruned_count += 1;
    410             }
    411         }
    412         println!("Pruned {} client(s)", pruned_count);
    413     }
    414 
    415     println!("\nSync complete:");
    416     println!("  Created: {}", created_count);
    417     println!("  Updated: {}", updated_count);
    418 
    419     Ok(())
    420 }