Implements credential storage in the lib.

This commit is contained in:
2026-08-23 15:13:01 -07:00
parent 691c4a7d80
commit 2e8ddd3a73
7 changed files with 417 additions and 24 deletions

View 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");
}
}

View 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);
}
}

View File

@@ -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>;

View File

@@ -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};