Implements credential storage in the lib.
This commit is contained in:
13
Cargo.lock
generated
13
Cargo.lock
generated
@@ -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",
|
||||
|
||||
18
Tasks.md
18
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
|
||||
|
||||
|
||||
<a id="nut-006" class="task" data-status="triage" data-task-type="feature"></a>
|
||||
<a id="nut-006" class="task" data-status="done" data-task-type="feature"></a>
|
||||
### 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
|
||||
|
||||
@@ -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"
|
||||
|
||||
161
nextcloud_client/src/auth.rs
Normal file
161
nextcloud_client/src/auth.rs
Normal file
@@ -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<LoginFlowInitResponse> {
|
||||
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<Option<LoginFlowPollSuccess>> {
|
||||
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<LoginFlowPollSuccess> {
|
||||
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");
|
||||
}
|
||||
}
|
||||
209
nextcloud_client/src/credentials.rs
Normal file
209
nextcloud_client/src/credentials.rs
Normal file
@@ -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<String>,
|
||||
}
|
||||
|
||||
/// 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<PathBuf> {
|
||||
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<PathBuf> {
|
||||
Ok(Self::config_dir()?.join("accounts.json"))
|
||||
}
|
||||
|
||||
/// Load all stored account metadata.
|
||||
pub fn list_accounts() -> Result<Vec<StoredAccount>> {
|
||||
let path = Self::accounts_file_path()?;
|
||||
if !path.exists() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let content = fs::read_to_string(&path)?;
|
||||
let accounts: Vec<StoredAccount> = 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<Option<(StoredAccount, AccountCredentials)>> {
|
||||
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<Option<(StoredAccount, AccountCredentials)>> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<T, NextcloudError>`.
|
||||
/// Convenience type alias for `Result<T, NextcloudError>`.
|
||||
pub type Result<T> = std::result::Result<T, NextcloudError>;
|
||||
|
||||
@@ -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};
|
||||
|
||||
Reference in New Issue
Block a user