diff --git a/Cargo.lock b/Cargo.lock index 6806ce2..a868377 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2410,6 +2410,7 @@ dependencies = [ "nextcloud_client", "open", "reqwest 0.12.28", + "rpassword", "serde", "serde_json", "tokio", @@ -3158,6 +3159,27 @@ dependencies = [ "web-sys", ] +[[package]] +name = "rpassword" +version = "7.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2da316a15f47e3d053de9cb2c439650bd8fa4aaeb9365f2e5f27f492ff73c196" +dependencies = [ + "libc", + "rtoolbox", + "windows-sys 0.61.2", +] + +[[package]] +name = "rtoolbox" +version = "0.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50a0e551c1e27e1731aba276dbeaeac73f53c7cd34d1bda485d02bd1e0f36844" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + [[package]] name = "rustc-hash" version = "2.1.3" diff --git a/Tasks.md b/Tasks.md index ade0daf..86c0a4c 100644 --- a/Tasks.md +++ b/Tasks.md @@ -78,7 +78,6 @@ This document defines the complete project roadmap and task tracking system for | [NUT-018](#nut-018) | Implement Packaging for macOS, Windows, Linux | Triage | Chore | | [NUT-019](#nut-019) | Implement Homebrew/Winget/Chocolatey Manifests | Triage | Chore | | [NUT-020](#nut-020) | Write Documentation + Examples | Triage | Chore | -| [NUT-021](#nut-021) | Support Headless & SSH Remote Authentication Modes | Triage | Feature | | [NUT-001](#nut-001) | Establish Repository Structure | Fixed | Foundation | | [NUT-002](#nut-002) | Implement Shared Rust Backend Library | Fixed | Foundation | | [NUT-003](#nut-003) | Implement WebDAV Upload Logic | Fixed | Feature | @@ -90,6 +89,7 @@ This document defines the complete project roadmap and task tracking system for | [NUT-009](#nut-009) | Implement CLI Output Formatting Options | Fixed | Feature | | [NUT-010](#nut-010) | Implement CLI Multi-file Upload Support | Fixed | Feature | | [NUT-011](#nut-011) | Implement CLI Progress Reporting + pv Support | Fixed | Feature | +| [NUT-021](#nut-021) | Support Headless & SSH Remote Authentication Modes | Fixed | Feature | --- @@ -497,20 +497,20 @@ Write documentation for installation, usage, examples, and API reference. - NUT-018 - + ### Support Headless & SSH Remote Authentication Modes **ID:** NUT-021 -**Status:** Triage +**Status:** Fixed **Type:** Feature **Description:** Ensure smooth authentication experiences when running the CLI over SSH or in headless environments where a local graphical browser cannot be launched automatically. **Requirements:** -- [ ] Terminal URL fallback: print clickable Login Flow v2 URL in terminal when browser fails to launch -- [ ] Add `--no-browser` flag to print URL and wait for authorization without attempting to open desktop browser -- [ ] Add interactive manual terminal prompt (`--manual`) for username and app password input -- [ ] Add non-interactive flag inputs (`--username`, `--app-password`) for automated provisioning and CI/CD +- [x] Terminal URL fallback: print clickable Login Flow v2 URL in terminal when browser fails to launch +- [x] Add `--no-browser` flag to print URL and wait for authorization without attempting to open desktop browser +- [x] Add interactive manual terminal prompt (`--manual`) for username and app password input +- [x] Add non-interactive flag inputs (`--username`, `--app-password`) for automated provisioning and CI/CD **Dependencies:** - NUT-006 diff --git a/cli/Cargo.toml b/cli/Cargo.toml index b851eb4..3d7c0d7 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -13,3 +13,4 @@ url = "2.5" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" indicatif = "0.17" +rpassword = "7.3" diff --git a/cli/src/main.rs b/cli/src/main.rs index 83f33fe..d4b388a 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -2,7 +2,7 @@ use clap::{Args, Parser, Subcommand}; use indicatif::{ProgressBar, ProgressDrawTarget, ProgressStyle}; use serde::Serialize; use std::fs; -use std::io::{self, IsTerminal, Read}; +use std::io::{self, IsTerminal, Read, Write}; use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::Duration; @@ -27,7 +27,7 @@ struct Cli { #[derive(Subcommand, Debug)] enum Commands { - /// Log in to a Nextcloud server using browser authorization (Login Flow v2) + /// Log in to a Nextcloud server using browser authorization (Login Flow v2) or credentials Login(LoginArgs), /// Upload a file or stream to Nextcloud @@ -53,6 +53,22 @@ struct LoginArgs { /// Set this account as the default active account #[arg(short, long, default_value_t = true)] default: bool, + + /// Do not automatically open a desktop browser; print authorization URL in terminal + #[arg(long)] + no_browser: bool, + + /// Prompt interactively for username and app password in terminal + #[arg(short, long, conflicts_with_all = ["username", "app_password"])] + manual: bool, + + /// Username for non-interactive / automated login + #[arg(short = 'u', long, requires = "app_password")] + username: Option, + + /// App password or token for non-interactive / automated login + #[arg(short = 'p', long, requires = "username")] + app_password: Option, } #[derive(Args, Debug, Clone, Default)] @@ -199,24 +215,63 @@ async fn main() { } } -/// Handle interactive browser login flow. +/// Handle interactive or non-interactive login flows. async fn handle_login(args: LoginArgs) -> Result<()> { let server_url = ClientConfig::normalize_url(&args.server_url)?; - let http = reqwest::Client::new(); + let server_url_str = server_url.as_str(); - println!("\x1b[1;34m==>\x1b[0m Initiating Nextcloud authentication with {}", server_url); + // 1. Non-interactive CLI flag input (--username & --app-password) + if let (Some(username), Some(password)) = (args.username.as_deref(), args.app_password.as_deref()) { + println!("\x1b[1;34m==>\x1b[0m Validating credentials for '{}' on {}...", username, server_url_str); + return save_and_verify_account(server_url_str, username, password, args.default, args.label).await; + } + + // 2. Interactive manual terminal prompt (--manual) + if args.manual { + println!("\x1b[1;34m==>\x1b[0m Manual Nextcloud Login for {}", server_url_str); + print!(" Username: "); + io::stdout().flush()?; + let mut username = String::new(); + io::stdin().read_line(&mut username)?; + let username = username.trim(); + + if username.is_empty() { + return Err(nextcloud_client::NextcloudError::Other("Username cannot be empty".into())); + } + + print!(" App Password / Token: "); + io::stdout().flush()?; + let password = rpassword::read_password() + .map_err(|e| nextcloud_client::NextcloudError::Other(format!("Failed to read password: {e}")))?; + let password = password.trim(); + + if password.is_empty() { + return Err(nextcloud_client::NextcloudError::Other("Password cannot be empty".into())); + } + + println!("\x1b[1;34m==>\x1b[0m Verifying connection..."); + return save_and_verify_account(server_url_str, username, password, args.default, args.label).await; + } + + // 3. Browser-based Login Flow v2 (default) + let http = reqwest::Client::new(); + println!("\x1b[1;34m==>\x1b[0m Initiating Nextcloud authentication with {}", server_url_str); let flow = initiate_login_flow(&http, &server_url).await?; println!("\x1b[1;32m==>\x1b[0m Please authorize access in your browser:"); println!(" \x1b[1;36m{}\x1b[0m\n", flow.login); - // Attempt to open the default desktop browser - if open::that(&flow.login).is_err() { - println!(" (Could not automatically launch browser. Please copy and open the link above.)"); + if !args.no_browser { + // Attempt to open the default desktop browser + if open::that(&flow.login).is_err() { + println!(" (Could not automatically launch browser. Please copy and open the link above.)"); + } + } else { + println!(" (Headless mode: copy and paste the URL above into any browser)"); } print!("\x1b[1;33m==>\x1b[0m Waiting for browser authorization..."); - io::Write::flush(&mut io::stdout())?; + io::stdout().flush()?; let poll_interval = Duration::from_secs(2); let timeout = Duration::from_secs(300); // 5 minute timeout @@ -261,6 +316,37 @@ async fn handle_login(args: LoginArgs) -> Result<()> { } } +async fn save_and_verify_account( + server_url: &str, + username: &str, + password: &str, + is_default: bool, + label: Option, +) -> Result<()> { + let config = ClientConfig::with_credentials(server_url, username, password)?; + let client = NextcloudClient::new(config)?; + + // Verify authentication and connectivity + client.test_connection().await?; + + let mut account = CredentialStore::save_account(server_url, username, password, is_default)?; + + if let Some(lbl) = label { + account.label = Some(lbl); + let mut accounts = CredentialStore::list_accounts()?; + if let Some(a) = accounts.iter_mut().find(|a| a.id == account.id) { + a.label = account.label.clone(); + } + CredentialStore::save_accounts(&accounts)?; + } + + println!("\x1b[1;32m✓\x1b[0m Successfully authenticated! Account '\x1b[1m{}\x1b[0m' saved securely.", account.id); + if account.is_default { + println!(" Set as default active account."); + } + Ok(()) +} + /// Handle file and stdin uploads. async fn handle_upload(args: UploadArgs) -> Result<()> { if (args.format.url_only || args.format.direct_url_only) && !args.share { diff --git a/nextcloud_client/src/client.rs b/nextcloud_client/src/client.rs index 1f74ed5..d9dc128 100644 --- a/nextcloud_client/src/client.rs +++ b/nextcloud_client/src/client.rs @@ -45,7 +45,6 @@ impl NextcloudClient { // Set Basic Auth header if credentials are provided if let Some(ref creds) = config.credentials { - use reqwest::header::HeaderValue; let raw_auth = format!("{}:{}", creds.username, creds.app_password); let encoded = base64_encode(raw_auth.as_bytes()); let mut auth_val = HeaderValue::from_str(&format!("Basic {encoded}")) @@ -126,6 +125,32 @@ impl NextcloudClient { Ok(status) } + /// Test server connectivity and verify user credentials via WebDAV PROPFIND. + pub async fn test_connection(&self) -> Result { + let status = self.check_status().await?; + + if let Some(username) = self.username() { + let root_dav = self.webdav_url("")?; + let res = self + .http + .request(reqwest::Method::from_bytes(b"PROPFIND").unwrap(), root_dav) + .header("Depth", "0") + .send() + .await?; + + if res.status() == reqwest::StatusCode::UNAUTHORIZED { + return Err(NextcloudError::AuthenticationFailed { + username: username.to_string(), + message: "Invalid credentials or unauthorized access".to_string(), + }); + } + + res.error_for_status()?; + } + + Ok(status) + } + /// High-level orchestration method that uploads a file from disk and optionally generates a public share link. pub async fn upload_and_share>( &self, diff --git a/nextcloud_client/src/config.rs b/nextcloud_client/src/config.rs index d7d7d56..825bf92 100644 --- a/nextcloud_client/src/config.rs +++ b/nextcloud_client/src/config.rs @@ -105,6 +105,18 @@ impl ClientConfig { }) } + /// Create a new `ClientConfig` with username and app password credentials. + pub fn with_credentials( + server_url_str: &str, + username: impl Into, + app_password: impl Into, + ) -> Result { + Self::new( + server_url_str, + Some(AccountCredentials::new(username, app_password)), + ) + } + /// Set a custom request timeout. pub fn with_timeout(mut self, timeout: Duration) -> Self { self.timeout = timeout;