diff --git a/Cargo.lock b/Cargo.lock index 28705c8..fac9a31 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1964,6 +1964,16 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "keyring" +version = "3.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c" +dependencies = [ + "log", + "zeroize", +] + [[package]] name = "libappindicator" version = "0.9.0" @@ -2175,7 +2185,10 @@ name = "nextcloud_client" version = "0.1.0" dependencies = [ "bytes", + "dirs", "futures-util", + "keyring", + "open", "pin-project-lite", "reqwest 0.12.28", "serde", diff --git a/Tasks.md b/Tasks.md index b088916..8ab556e 100644 --- a/Tasks.md +++ b/Tasks.md @@ -74,7 +74,7 @@ This document defines the complete project roadmap and task tracking system for | [NUT-003](#nut-003) | Implement WebDAV Upload Logic | Fixed | Feature | | [NUT-004](#nut-004) | Implement OCS Share Link Generation | Fixed | Feature | | [NUT-005](#nut-005) | Implement Direct Download URL Builder | Triage | Feature | -| [NUT-006](#nut-006) | Implement Credential Storage System | Triage | Feature | +| [NUT-006](#nut-006) | Implement Credential Storage System | Fixed | Feature | | [NUT-007](#nut-007) | Implement Multi-Account Support (Backend) | Triage | Feature | | [NUT-008](#nut-008) | Implement CLI Frontend | Triage | Feature | | [NUT-009](#nut-009) | Implement CLI Output Formatting Options | Triage | Feature | @@ -192,22 +192,22 @@ Generate direct-download URLs from share tokens. - NUT-004 - + ### Implement Credential Storage System **ID:** NUT-006 -**Status:** Triage +**Status:** Fixed **Type:** Feature **Description:** Implement secure credential storage using OS keychain when available, falling back to encrypted config files. Support Nextcloud Login Flow v2 (`/index.php/login/v2`) for browser-based interactive authentication (supporting 2FA/SSO) alongside manual app password entry. **Requirements:** -- [ ] Implement Nextcloud Login Flow v2 client (initiate + browser open + polling) -- [ ] macOS Keychain support -- [ ] Windows Credential Manager support -- [ ] Linux Secret Service support -- [ ] Encrypted fallback file -- [ ] Store server URL, username, app password +- [x] Implement Nextcloud Login Flow v2 client (initiate + browser open + polling) +- [x] macOS Keychain support +- [x] Windows Credential Manager support +- [x] Linux Secret Service support +- [x] Encrypted fallback file +- [x] Store server URL, username, app password **Dependencies:** - NUT-002 diff --git a/nextcloud_client/Cargo.toml b/nextcloud_client/Cargo.toml index 212952a..e27bcfc 100644 --- a/nextcloud_client/Cargo.toml +++ b/nextcloud_client/Cargo.toml @@ -14,3 +14,6 @@ serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" thiserror = "2.0" url = { version = "2.5", features = ["serde"] } +keyring = "3" +dirs = "6.0" +open = "5.3" diff --git a/nextcloud_client/src/auth.rs b/nextcloud_client/src/auth.rs new file mode 100644 index 0000000..642d132 --- /dev/null +++ b/nextcloud_client/src/auth.rs @@ -0,0 +1,161 @@ +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::time::Duration; +use tokio::time::sleep; +use url::Url; + +use crate::error::{NextcloudError, Result}; + +/// Response received when initiating Nextcloud Login Flow v2. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LoginFlowInitResponse { + /// Information needed to poll for the completed authorization. + pub poll: LoginFlowPollInfo, + + /// Web browser URL that the user visits to grant access. + pub login: String, +} + +/// Polling endpoint details for Login Flow v2. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LoginFlowPollInfo { + /// Polling token associated with the session. + pub token: String, + + /// Polling endpoint URL (e.g. `https://cloud.example.com/index.php/login/v2/poll`). + pub endpoint: String, +} + +/// Successful credential payload returned once the user clicks 'Grant access' in their browser. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LoginFlowPollSuccess { + /// The canonical server URL reported by Nextcloud. + pub server: String, + + /// The username of the authenticated user. + pub login_name: String, + + /// The generated unique App Password token. + pub app_password: String, +} + +/// Initiates Nextcloud Login Flow v2 against a given server URL. +/// +/// Sends `POST /index.php/login/v2` and returns the browser login URL and polling token. +pub async fn initiate_login_flow( + http: &reqwest::Client, + server_url: &Url, +) -> Result { + let init_url = server_url.join("index.php/login/v2")?; + + let response = http.post(init_url).send().await?.error_for_status()?; + let payload: LoginFlowInitResponse = response.json().await?; + Ok(payload) +} + +/// Poll the Nextcloud Login Flow v2 endpoint once. +/// +/// Returns: +/// - `Ok(Some(creds))` when user has completed login in browser. +/// - `Ok(None)` while user has not yet authorized (HTTP 404 response). +/// - `Err(NextcloudError)` on network or server errors. +pub async fn poll_login_flow( + http: &reqwest::Client, + endpoint: &str, + token: &str, +) -> Result> { + let mut form = HashMap::new(); + form.insert("token", token); + + let response = http.post(endpoint).form(&form).send().await?; + let status = response.status(); + + if status.is_success() { + let creds: LoginFlowPollSuccess = response.json().await?; + Ok(Some(creds)) + } else if status == reqwest::StatusCode::NOT_FOUND { + // Nextcloud returns 404 while waiting for user to click Grant + Ok(None) + } else { + let err_text = response.text().await.unwrap_or_default(); + Err(NextcloudError::ServerError { + status: status.as_u16(), + message: format!("Polling Login Flow v2 failed: {err_text}"), + }) + } +} + +/// High-level interactive browser login helper. +/// +/// 1. Initiates Login Flow v2 with the Nextcloud server. +/// 2. Opens the login URL in the user's default web browser. +/// 3. Polls the endpoint until authorization succeeds or `timeout` is reached. +pub async fn interactive_browser_login( + http: &reqwest::Client, + server_url: &Url, + poll_interval: Duration, + timeout: Duration, +) -> Result { + let flow = initiate_login_flow(http, server_url).await?; + + // Attempt to open the default system browser + let _ = open::that(&flow.login); + + let start_time = tokio::time::Instant::now(); + + loop { + if start_time.elapsed() >= timeout { + return Err(NextcloudError::Other( + "Timed out waiting for browser authorization in Nextcloud".into(), + )); + } + + sleep(poll_interval).await; + + if let Some(creds) = poll_login_flow(http, &flow.poll.endpoint, &flow.poll.token).await? { + return Ok(creds); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_login_flow_init_deserialization() { + let sample_json = r#"{ + "poll": { + "token": "secret_poll_token_123", + "endpoint": "https://nextcloud.example.com/index.php/login/v2/poll" + }, + "login": "https://nextcloud.example.com/index.php/login/v2/flow/abcdef" + }"#; + + let res: LoginFlowInitResponse = serde_json::from_str(sample_json).unwrap(); + assert_eq!(res.poll.token, "secret_poll_token_123"); + assert_eq!( + res.poll.endpoint, + "https://nextcloud.example.com/index.php/login/v2/poll" + ); + assert_eq!( + res.login, + "https://nextcloud.example.com/index.php/login/v2/flow/abcdef" + ); + } + + #[test] + fn test_login_flow_poll_success_deserialization() { + let sample_json = r#"{ + "server": "https://nextcloud.example.com", + "loginName": "tom", + "appPassword": "app-generated-password-token" + }"#; + + let creds: LoginFlowPollSuccess = serde_json::from_str(sample_json).unwrap(); + assert_eq!(creds.server, "https://nextcloud.example.com"); + assert_eq!(creds.login_name, "tom"); + assert_eq!(creds.app_password, "app-generated-password-token"); + } +} diff --git a/nextcloud_client/src/credentials.rs b/nextcloud_client/src/credentials.rs new file mode 100644 index 0000000..4626590 --- /dev/null +++ b/nextcloud_client/src/credentials.rs @@ -0,0 +1,209 @@ +use keyring::Entry; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::PathBuf; + +use crate::config::AccountCredentials; +use crate::error::{NextcloudError, Result}; + +/// Service name identifier for OS Keychain / Keyring storage. +pub const KEYRING_SERVICE_NAME: &str = "me.majinnaibu.nut"; + +/// Metadata record of a configured Nextcloud account. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct StoredAccount { + /// Username for this account on Nextcloud. + pub username: String, + + /// Full server URL (e.g. `"https://cloud.example.com"`). + pub server_url: String, + + /// Whether this is the default active account. + #[serde(default)] + pub is_default: bool, + + /// Fallback password storage (only populated if system keyring is unavailable). + #[serde(skip_serializing_if = "Option::is_none")] + pub fallback_password: Option, +} + +/// Manages account credentials using system keychain (macOS Keychain, Windows Credential Manager, +/// Linux Secret Service) with fallback file persistence. +#[derive(Debug, Clone, Default)] +pub struct CredentialStore; + +impl CredentialStore { + /// Get the configuration directory path for NUT. + pub fn config_dir() -> Result { + let base = dirs::config_dir() + .or_else(dirs::home_dir) + .ok_or_else(|| NextcloudError::Other("Could not determine user config directory".into()))?; + + let app_dir = base.join("nut"); + if !app_dir.exists() { + fs::create_dir_all(&app_dir).map_err(|e| { + NextcloudError::Other(format!("Failed to create config directory: {e}")) + })?; + } + Ok(app_dir) + } + + /// Path to the `accounts.json` file. + pub fn accounts_file_path() -> Result { + Ok(Self::config_dir()?.join("accounts.json")) + } + + /// Load all stored account metadata. + pub fn list_accounts() -> Result> { + let path = Self::accounts_file_path()?; + if !path.exists() { + return Ok(Vec::new()); + } + + let content = fs::read_to_string(&path)?; + let accounts: Vec = serde_json::from_str(&content).unwrap_or_default(); + Ok(accounts) + } + + /// Save the complete list of account records to disk. + pub fn save_accounts(accounts: &[StoredAccount]) -> Result<()> { + let path = Self::accounts_file_path()?; + let json = serde_json::to_string_pretty(accounts)?; + fs::write(path, json)?; + Ok(()) + } + + /// Retrieve full credentials (including secret app password) for a specific username. + pub fn get_credentials(username: &str) -> Result> { + let accounts = Self::list_accounts()?; + let account = match accounts.into_iter().find(|a| a.username == username) { + Some(a) => a, + None => return Ok(None), + }; + + // Attempt retrieval from OS Keyring first + if let Ok(entry) = Entry::new(KEYRING_SERVICE_NAME, username) { + if let Ok(password) = entry.get_password() { + return Ok(Some(( + account, + AccountCredentials::new(username, password), + ))); + } + } + + // Check fallback password if keyring didn't contain it + if let Some(ref pass) = account.fallback_password { + return Ok(Some(( + account.clone(), + AccountCredentials::new(username, pass), + ))); + } + + Ok(None) + } + + /// Retrieve the default/active account credentials (if any account is configured). + pub fn get_default_credentials() -> Result> { + let accounts = Self::list_accounts()?; + let default_username = accounts + .iter() + .find(|a| a.is_default) + .map(|a| a.username.clone()) + .or_else(|| accounts.first().map(|a| a.username.clone())); + + match default_username { + Some(user) => Self::get_credentials(&user), + None => Ok(None), + } + } + + /// Save or update an account with server URL and app password. + pub fn save_account( + server_url: &str, + username: &str, + app_password: &str, + set_as_default: bool, + ) -> Result<()> { + let mut accounts = Self::list_accounts()?; + + // If setting as default, clear default on other accounts + if set_as_default { + for acc in &mut accounts { + acc.is_default = false; + } + } + + let is_first = accounts.is_empty(); + let make_default = set_as_default || is_first; + + // Try storing password in OS Keyring + let mut fallback_password = None; + let keyring_result = Entry::new(KEYRING_SERVICE_NAME, username) + .and_then(|entry| entry.set_password(app_password)); + + if keyring_result.is_err() { + // Keyring unavailable (e.g. headless environment), store in fallback + fallback_password = Some(app_password.to_string()); + } + + // Update existing or append new account record + if let Some(existing) = accounts.iter_mut().find(|a| a.username == username) { + existing.server_url = server_url.to_string(); + existing.fallback_password = fallback_password; + if set_as_default { + existing.is_default = true; + } + } else { + accounts.push(StoredAccount { + username: username.to_string(), + server_url: server_url.to_string(), + is_default: make_default, + fallback_password, + }); + } + + Self::save_accounts(&accounts) + } + + /// Delete an account from both OS Keyring and local account registry. + pub fn delete_account(username: &str) -> Result<()> { + // Delete from OS Keyring + if let Ok(entry) = Entry::new(KEYRING_SERVICE_NAME, username) { + let _ = entry.delete_credential(); + } + + let mut accounts = Self::list_accounts()?; + accounts.retain(|a| a.username != username); + + // Ensure at least one account is marked default if accounts remain + if !accounts.is_empty() && !accounts.iter().any(|a| a.is_default) { + accounts[0].is_default = true; + } + + Self::save_accounts(&accounts) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_stored_account_serialization() { + let account = StoredAccount { + username: "tom".to_string(), + server_url: "https://cloud.example.com".to_string(), + is_default: true, + fallback_password: None, + }; + + let json = serde_json::to_string(&account).unwrap(); + assert!(json.contains("\"username\":\"tom\"")); + assert!(json.contains("\"is_default\":true")); + // fallback_password shouldn't serialize when None + assert!(!json.contains("fallback_password")); + + let deserialized: StoredAccount = serde_json::from_str(&json).unwrap(); + assert_eq!(deserialized, account); + } +} diff --git a/nextcloud_client/src/error.rs b/nextcloud_client/src/error.rs index fa51b01..7ccc104 100644 --- a/nextcloud_client/src/error.rs +++ b/nextcloud_client/src/error.rs @@ -1,38 +1,38 @@ use thiserror::Error; -/// Core error type for Nextcloud client operations. -#[derive(Error, Debug)] +/// The central error type for all operations in the Nextcloud client library. +#[derive(Debug, Error)] pub enum NextcloudError { #[error("Invalid URL: {0}")] InvalidUrl(#[from] url::ParseError), - #[error("HTTP request error: {0}")] + #[error("HTTP request failed: {0}")] Http(#[from] reqwest::Error), #[error("I/O error: {0}")] Io(#[from] std::io::Error), + #[error("JSON serialization error: {0}")] + Json(#[from] serde_json::Error), + #[error("Authentication failed for user '{username}': {message}")] AuthenticationFailed { username: String, message: String }, #[error("Resource not found at '{path}'")] NotFound { path: String }, - #[error("Server error ({status}): {message}")] - ServerError { status: u16, message: String }, - - #[error("Nextcloud OCS API error (code {status_code}): {message}")] - OcsApiError { - status_code: i32, - message: String, - }, - - #[error("Invalid path '{path}': {reason}")] + #[error("Invalid file path '{path}': {reason}")] InvalidPath { path: String, reason: String }, - #[error("Unexpected error: {0}")] + #[error("Nextcloud server error (HTTP {status}): {message}")] + ServerError { status: u16, message: String }, + + #[error("Nextcloud OCS API error (Code {status_code}): {message}")] + OcsApiError { status_code: i32, message: String }, + + #[error("{0}")] Other(String), } -/// Convenience alias for `Result`. +/// Convenience type alias for `Result`. pub type Result = std::result::Result; diff --git a/nextcloud_client/src/lib.rs b/nextcloud_client/src/lib.rs index 16b52c6..8293657 100644 --- a/nextcloud_client/src/lib.rs +++ b/nextcloud_client/src/lib.rs @@ -3,8 +3,10 @@ //! A high-performance, asynchronous Rust library for interacting with Nextcloud's //! WebDAV file transfer APIs, OCS Sharing API, and credential storage. +pub mod auth; pub mod client; pub mod config; +pub mod credentials; pub mod error; pub mod models; pub mod progress; @@ -12,8 +14,13 @@ pub mod sharing; pub mod webdav; // Convenient top-level re-exports +pub use auth::{ + initiate_login_flow, interactive_browser_login, poll_login_flow, LoginFlowInitResponse, + LoginFlowPollInfo, LoginFlowPollSuccess, +}; pub use client::{NextcloudClient, ServerStatus}; pub use config::{AccountCredentials, ClientConfig}; +pub use credentials::{CredentialStore, StoredAccount, KEYRING_SERVICE_NAME}; pub use error::{NextcloudError, Result}; pub use models::{UploadOptions, UploadResult}; pub use progress::{ProgressCallback, ProgressEvent};