diff --git a/Cargo.lock b/Cargo.lock index 542eac0..5f23d27 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -243,8 +243,11 @@ dependencies = [ "cdcx-core", "cdcx-tui", "clap", + "crossterm 0.28.1", "dirs 5.0.1", + "open", "predicates", + "reqwest", "rmcp", "rpassword", "serde", @@ -254,6 +257,7 @@ dependencies = [ "toml", "tracing", "tracing-subscriber", + "uuid", ] [[package]] @@ -1322,6 +1326,25 @@ dependencies = [ "serde", ] +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -1629,6 +1652,17 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "open" +version = "5.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fbaa89d2ddc8473c78a3adf69eea8cffa28c483b8e02a971ef31527cd0fc92c" +dependencies = [ + "is-wsl", + "libc", + "pathdiff", +] + [[package]] name = "openssl" version = "0.10.79" @@ -1716,6 +1750,12 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b867cad97c0791bbd3aaa6472142568c6c9e8f71937e98379f584cfb0cf35bec" +[[package]] +name = "pathdiff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" + [[package]] name = "percent-encoding" version = "2.3.2" diff --git a/crates/cdcx-cli/Cargo.toml b/crates/cdcx-cli/Cargo.toml index 4ef55fb..7a1c79c 100644 --- a/crates/cdcx-cli/Cargo.toml +++ b/crates/cdcx-cli/Cargo.toml @@ -22,6 +22,10 @@ tracing-subscriber = "0.3" rmcp = { version = "1.4", features = ["server", "transport-io"] } rpassword = "7" toml = "0.8" +uuid = { version = "1", features = ["v4"] } +open = "5" +crossterm = "0.28" +reqwest = { version = "0.12", features = ["json"] } [dev-dependencies] assert_cmd = "2" diff --git a/crates/cdcx-cli/src/cli_builder.rs b/crates/cdcx-cli/src/cli_builder.rs index fce7e59..16cd7e0 100644 --- a/crates/cdcx-cli/src/cli_builder.rs +++ b/crates/cdcx-cli/src/cli_builder.rs @@ -151,9 +151,8 @@ pub fn build_static_cli() -> clap::Command { .about("Manage authentication profiles") .subcommand_required(true) .arg_required_else_help(true) - .subcommand( - clap::Command::new("list").about("List all profiles with account balances"), - ), + .subcommand(clap::Command::new("list").about("List all profiles with account balances")) + .subcommand(clap::Command::new("login").about("Log in via browser-based OAuth")), ); app = app.subcommand( clap::Command::new("mcp") diff --git a/crates/cdcx-cli/src/dispatch.rs b/crates/cdcx-cli/src/dispatch.rs index 2af86da..33141e9 100644 --- a/crates/cdcx-cli/src/dispatch.rs +++ b/crates/cdcx-cli/src/dispatch.rs @@ -199,7 +199,7 @@ pub async fn run_schema( let require_registry = || -> Result<&SchemaRegistry, CdcxError> { registry.ok_or_else(|| { CdcxError::Config( - "No API schema cached. Run 'cdcx setup' or 'cdcx schema update' first.".into(), + "No API schema cached. Run 'cdcx auth login' or 'cdcx schema update' first.".into(), ) }) }; diff --git a/crates/cdcx-cli/src/groups/auth.rs b/crates/cdcx-cli/src/groups/auth.rs index 4cc9e5c..6b0c4d0 100644 --- a/crates/cdcx-cli/src/groups/auth.rs +++ b/crates/cdcx-cli/src/groups/auth.rs @@ -12,7 +12,7 @@ pub async fn run_auth_list() -> Result<(), CdcxError> { if !path.exists() { return Err(CdcxError::Config(format!( - "No config file found at {}. Run 'cdcx setup' first.", + "No config file found at {}. Run 'cdcx auth login' first.", path.display() ))); } @@ -40,7 +40,7 @@ pub async fn run_auth_list() -> Result<(), CdcxError> { if profiles.is_empty() { return Err(CdcxError::Config( - "No profiles found in config. Run 'cdcx setup' first.".into(), + "No profiles found in config. Run 'cdcx auth login' first.".into(), )); } diff --git a/crates/cdcx-cli/src/groups/auth_login.rs b/crates/cdcx-cli/src/groups/auth_login.rs new file mode 100644 index 0000000..8695009 --- /dev/null +++ b/crates/cdcx-cli/src/groups/auth_login.rs @@ -0,0 +1,248 @@ +use cdcx_core::error::CdcxError; +use crossterm::event::{self, Event, KeyCode, KeyEventKind}; +use serde::Deserialize; +use std::io; +use std::time::{Duration, Instant}; +use uuid::Uuid; + +const POLL_INTERVAL: Duration = Duration::from_secs(2); +const POLL_TIMEOUT: Duration = Duration::from_secs(300); + +#[derive(Deserialize)] +struct PollResponse { + code: i64, + result: Option, +} + +#[derive(Deserialize)] +struct PollResult { + api_key: String, + secret_key: String, +} + +struct RawModeGuard; + +impl RawModeGuard { + fn enable() -> io::Result { + crossterm::terminal::enable_raw_mode()?; + Ok(Self) + } +} + +impl Drop for RawModeGuard { + fn drop(&mut self) { + let _ = crossterm::terminal::disable_raw_mode(); + } +} + +fn oauth_url(environment: &str, session_id: &str) -> String { + match environment { + "uat" => format!( + "https://dpre-xweb.3ona.co/exchange/authorize_broker?broker_id=cdcx-cli\ + &redirect_uri=https%3A%2F%2Fx-growth.crypto.com%2Fstatic%2Fcli%2Fauthenticate%2Fuat%3Fid%3D{session_id}\ + &broker_reference_id={session_id}" + ), + _ => format!( + "https://crypto.com/exchange/authorize_broker?broker_id=cdcx-cli\ + &redirect_uri=https%3A%2F%2Fx-growth.crypto.com%2Fstatic%2Fcli%2Fauthenticate%3Fid%3D{session_id}\ + &broker_reference_id={session_id}" + ), + } +} + +fn poll_url(environment: &str) -> &'static str { + match environment { + "uat" => "https://x-growth.crypto.com/static/cli/authenticate/uat", + _ => "https://x-growth.crypto.com/static/cli/authenticate", + } +} + +/// Run the browser-based OAuth flow for the given environment. +/// Returns `Ok(Some((api_key, secret_key)))` on success, `Ok(None)` if cancelled. +pub async fn browser_oauth(environment: &str) -> Result, CdcxError> { + let session_id = Uuid::new_v4().to_string(); + + let url = oauth_url(environment, &session_id); + println!(); + println!(" Opening browser for authentication..."); + println!(" {}", url); + + if let Err(_) = open::that(&url) { + println!(); + println!(" Could not open browser automatically."); + println!(" Please open the URL above manually."); + } + + println!(); + println!(" Waiting for authentication... (press ESC to cancel)"); + println!(); + + poll_for_credentials(environment, &session_id).await +} + +pub async fn run_auth_login() -> Result<(), CdcxError> { + super::setup::run_setup().await +} + +async fn poll_for_credentials( + environment: &str, + session_id: &str, +) -> Result, CdcxError> { + let _guard = RawModeGuard::enable() + .map_err(|e| CdcxError::Config(format!("Failed to enable raw mode: {}", e)))?; + + // Spawn a blocking thread for ESC key detection, communicating via channel + let (esc_tx, mut esc_rx) = tokio::sync::mpsc::channel::<()>(1); + let cancel = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let cancel_clone = cancel.clone(); + tokio::task::spawn_blocking(move || { + while !cancel_clone.load(std::sync::atomic::Ordering::Relaxed) { + if let Ok(true) = event::poll(Duration::from_millis(100)) { + if let Ok(Event::Key(key_event)) = event::read() { + if key_event.kind == KeyEventKind::Press && key_event.code == KeyCode::Esc { + let _ = esc_tx.blocking_send(()); + return; + } + } + } + } + }); + + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(10)) + .no_proxy() + .pool_max_idle_per_host(0) + .build() + .unwrap_or_else(|_| reqwest::Client::new()); + let base_url = poll_url(environment); + let start = Instant::now(); + + let result = loop { + if start.elapsed() > POLL_TIMEOUT { + break Err(CdcxError::Config( + "Authentication timed out after 5 minutes".into(), + )); + } + let ts = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis(); + let url = format!("{}?ts={}", base_url, ts); + let body = serde_json::json!({ "session": session_id, "ts": ts }); + tokio::select! { + resp = client.post(&url).json(&body) + .header("Cache-Control", "no-cache, no-store") + .header("Pragma", "no-cache") + .send() => { + if let Ok(r) = resp { + if r.status() == reqwest::StatusCode::OK { + if let Ok(poll_resp) = r.json::().await { + if poll_resp.code == 0 { + if let Some(result) = poll_resp.result { + break Ok(Some((result.api_key, result.secret_key))); + } + } + } + } + } + } + _ = esc_rx.recv() => { + break Ok(None); + } + } + + // Wait before next poll, still listening for ESC + tokio::select! { + _ = tokio::time::sleep(POLL_INTERVAL) => {} + _ = esc_rx.recv() => { + break Ok(None); + } + } + }; + + cancel.store(true, std::sync::atomic::Ordering::Relaxed); + result +} + +#[cfg(test)] +mod tests { + use super::*; + + // ─── URL generation ─── + + #[test] + fn test_oauth_url_production() { + let url = oauth_url("production", "abc-123"); + assert!(url.contains("broker_id=cdcx-cli")); + assert!(url.contains("broker_reference_id=abc-123")); + assert!(url.contains("authenticate%3Fid%3Dabc-123")); + assert!(!url.contains("/uat")); + } + + #[test] + fn test_oauth_url_uat() { + let url = oauth_url("uat", "abc-123"); + assert!(url.contains("broker_id=cdcx-cli")); + assert!(url.contains("broker_reference_id=abc-123")); + assert!(url.contains("authenticate%2Fuat%3Fid%3Dabc-123")); + } + + #[test] + fn test_oauth_url_uses_session_id_in_both_params() { + let url = oauth_url("production", "my-uuid-value"); + assert_eq!( + url.matches("my-uuid-value").count(), + 2, + "session ID should appear in both redirect_uri and broker_reference_id" + ); + } + + #[test] + fn test_poll_url_production() { + assert_eq!( + poll_url("production"), + "https://x-growth.crypto.com/static/cli/authenticate" + ); + } + + #[test] + fn test_poll_url_uat() { + assert_eq!( + poll_url("uat"), + "https://x-growth.crypto.com/static/cli/authenticate/uat" + ); + } + + #[test] + fn test_poll_url_unknown_defaults_to_production() { + assert_eq!(poll_url("anything"), poll_url("production")); + } + + // ─── Poll response deserialization ─── + + #[test] + fn test_poll_response_success() { + let json = r#"{"id":1,"method":"private/broker/create-fast-api-key","code":0,"result":{"ts":1779687323265,"api_key":"the_key","secret_key":"the_secret","enabled_trading":true,"enabled_withdrawal":false}}"#; + let resp: PollResponse = serde_json::from_str(json).unwrap(); + assert_eq!(resp.code, 0); + let result = resp.result.unwrap(); + assert_eq!(result.api_key, "the_key"); + assert_eq!(result.secret_key, "the_secret"); + } + + #[test] + fn test_poll_response_non_zero_code() { + let json = r#"{"id":1,"method":"something","code":10001}"#; + let resp: PollResponse = serde_json::from_str(json).unwrap(); + assert_ne!(resp.code, 0); + assert!(resp.result.is_none()); + } + + #[test] + fn test_poll_response_with_extra_fields() { + let json = r#"{"id":1,"method":"private/broker/create-fast-api-key","code":0,"result":{"ts":123,"api_key":"k","secret_key":"s","enabled_trading":true,"enabled_withdrawal":false,"extra":"ignored"}}"#; + let resp: PollResponse = serde_json::from_str(json).unwrap(); + assert_eq!(resp.code, 0); + assert_eq!(resp.result.unwrap().api_key, "k"); + } +} diff --git a/crates/cdcx-cli/src/groups/mod.rs b/crates/cdcx-cli/src/groups/mod.rs index beb20b5..23d3de7 100644 --- a/crates/cdcx-cli/src/groups/mod.rs +++ b/crates/cdcx-cli/src/groups/mod.rs @@ -1,4 +1,5 @@ pub mod auth; +pub mod auth_login; pub mod paper; pub mod schema; pub mod setup; diff --git a/crates/cdcx-cli/src/groups/setup.rs b/crates/cdcx-cli/src/groups/setup.rs index 81bd150..656a8f0 100644 --- a/crates/cdcx-cli/src/groups/setup.rs +++ b/crates/cdcx-cli/src/groups/setup.rs @@ -199,38 +199,59 @@ pub async fn run_setup() -> Result<(), CdcxError> { _ => "uat", }; - // Step 4: API credentials - let api_keys_url = "https://crypto.com/exchange/user/account-management/account-api-management"; - println!(); - if existing.is_some() { - println!(" Enter your API credentials from the Crypto.com Exchange."); - println!(" (Settings > API Keys at {api_keys_url})"); - println!(" Press Enter to leave existing credentials unchanged."); + // Step 4: Credential method + let auth_method = prompt_choice( + "How would you like to authenticate?", + &["Browser login (recommended)", "Enter API key manually"], + 0, + ); + + let (api_key, api_secret) = if auth_method == 0 { + // Browser-based OAuth + match super::auth_login::browser_oauth(environment).await? { + Some(creds) => creds, + None => { + println!(" Cancelled."); + return Ok(()); + } + } } else { - println!(" Enter your API credentials from the Crypto.com Exchange."); - println!(" (Settings > API Keys at {api_keys_url})"); - } - println!(); - - let api_key = prompt(" API Key: "); - if api_key.is_empty() { + // Manual API key entry + let api_keys_url = + "https://crypto.com/exchange/user/account-management/account-api-management"; + println!(); if existing.is_some() { - println!(" Keeping existing credentials."); - println!(); - return Ok(()); + println!(" Enter your API credentials from the Crypto.com Exchange."); + println!(" (Settings > API Keys at {api_keys_url})"); + println!(" Press Enter to leave existing credentials unchanged."); + } else { + println!(" Enter your API credentials from the Crypto.com Exchange."); + println!(" (Settings > API Keys at {api_keys_url})"); } - return Err(CdcxError::Config("API key cannot be empty".into())); - } + println!(); - let api_secret = prompt_secret(" API Secret: "); - if api_secret.is_empty() { - if existing.is_some() { - println!(" Keeping existing credentials."); - println!(); - return Ok(()); + let key = prompt(" API Key: "); + if key.is_empty() { + if existing.is_some() { + println!(" Keeping existing credentials."); + println!(); + return Ok(()); + } + return Err(CdcxError::Config("API key cannot be empty".into())); } - return Err(CdcxError::Config("API secret cannot be empty".into())); - } + + let secret = prompt_secret(" API Secret: "); + if secret.is_empty() { + if existing.is_some() { + println!(" Keeping existing credentials."); + println!(); + return Ok(()); + } + return Err(CdcxError::Config("API secret cannot be empty".into())); + } + + (key, secret) + }; // Step 5: Verify credentials println!(); diff --git a/crates/cdcx-cli/src/main.rs b/crates/cdcx-cli/src/main.rs index b5c0bd8..48dfbb9 100644 --- a/crates/cdcx-cli/src/main.rs +++ b/crates/cdcx-cli/src/main.rs @@ -194,6 +194,12 @@ async fn main() { std::process::exit(1); } } + Some(("login", _)) => { + if let Err(e) = groups::auth_login::run_auth_login().await { + eprintln!("{}", format_error(&e.to_envelope(), format)); + std::process::exit(1); + } + } _ => unreachable!("subcommand_required is set"), }, Some(("update", sub)) => { @@ -261,7 +267,9 @@ async fn main() { Some(r) => r, None => { eprintln!("Error: No API schema cached."); - eprintln!("Run 'cdcx setup' or 'cdcx schema update' to fetch the API schema."); + eprintln!( + "Run 'cdcx auth login' or 'cdcx schema update' to fetch the API schema." + ); std::process::exit(1); } }; diff --git a/crates/cdcx-core/src/auth.rs b/crates/cdcx-core/src/auth.rs index e272370..172aee4 100644 --- a/crates/cdcx-core/src/auth.rs +++ b/crates/cdcx-core/src/auth.rs @@ -61,7 +61,7 @@ impl Credentials { } Err(CdcxError::Config( - "No credentials found. Set CDC_API_KEY/CDC_API_SECRET environment variables or configure credentials in ~/.config/cdcx/config.toml (run cdcx setup)".into(), + "No credentials found. Set CDC_API_KEY/CDC_API_SECRET environment variables or configure credentials in ~/.config/cdcx/config.toml (run cdcx auth login)".into(), )) } } diff --git a/crates/cdcx-core/src/config.rs b/crates/cdcx-core/src/config.rs index 3ac5dd7..87989a3 100644 --- a/crates/cdcx-core/src/config.rs +++ b/crates/cdcx-core/src/config.rs @@ -9,7 +9,7 @@ pub struct ProfileConfig { pub api_key: String, pub api_secret: String, pub environment: String, - #[serde(default)] + #[serde(default, skip_serializing_if = "HashMap::is_empty")] pub envs: HashMap, } diff --git a/crates/cdcx-core/src/schema.rs b/crates/cdcx-core/src/schema.rs index 2616825..b20a17c 100644 --- a/crates/cdcx-core/src/schema.rs +++ b/crates/cdcx-core/src/schema.rs @@ -96,7 +96,8 @@ impl SchemaRegistry { .load_cache() .ok_or_else(|| { SchemaError::NoSpec( - "No API schema cached. Run 'cdcx setup' or 'cdcx schema update' first.".into(), + "No API schema cached. Run 'cdcx auth login' or 'cdcx schema update' first." + .into(), ) })?; diff --git a/schemas/configs/config.json b/schemas/configs/config.json index ac0deab..d4d3163 100644 --- a/schemas/configs/config.json +++ b/schemas/configs/config.json @@ -39,6 +39,12 @@ "type": "string", "enum": ["production", "uat"], "description": "Target environment" + }, + "envs": { + "type": "object", + "description": "Extra env variables", + "properties": {}, + "additionalProperties": true } }, "required": ["api_key", "api_secret", "environment"],