diff options
| author | Michelle Tilley <michelle@michelletilley.net> | 2026-03-16 15:10:32 -0700 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-03-16 15:10:32 -0700 |
| commit | ab55cc5ff10b944834d1413d7ff46b9cd75d8ba6 (patch) | |
| tree | 5185b225df9dca2fc8cb109c816e5cd9175ccf32 /crates/atuin-client/src | |
| parent | chore: symlink changelog so dist can pick it up (diff) | |
| download | atuin-ab55cc5ff10b944834d1413d7ff46b9cd75d8ba6.zip | |
feat: Allow headless account ops against Hub server (#3280)
Diffstat (limited to 'crates/atuin-client/src')
| -rw-r--r-- | crates/atuin-client/src/auth.rs | 455 | ||||
| -rw-r--r-- | crates/atuin-client/src/lib.rs | 2 | ||||
| -rw-r--r-- | crates/atuin-client/src/settings.rs | 36 |
3 files changed, 482 insertions, 11 deletions
diff --git a/crates/atuin-client/src/auth.rs b/crates/atuin-client/src/auth.rs new file mode 100644 index 00000000..1e638c21 --- /dev/null +++ b/crates/atuin-client/src/auth.rs @@ -0,0 +1,455 @@ +use async_trait::async_trait; +use eyre::{Context, Result, bail}; +use reqwest::{StatusCode, Url, header::USER_AGENT}; +use serde::Deserialize; + +use atuin_common::{ + api::{ + ATUIN_CARGO_VERSION, ATUIN_HEADER_VERSION, ChangePasswordRequest, LoginRequest, + LoginResponse, RegisterResponse, + }, + tls::ensure_crypto_provider, +}; + +use crate::settings::Settings; + +static APP_USER_AGENT: &str = concat!("atuin/", env!("CARGO_PKG_VERSION")); + +/// Result of an auth operation that may require 2FA. +pub enum AuthResponse { + /// Operation succeeded; for login/register, contains the session token. + Success { session: String }, + /// Two-factor authentication is required; the caller should prompt for a + /// TOTP code and retry with it. + TwoFactorRequired, +} + +/// Result of a mutating account operation that may require 2FA. +pub enum MutateResponse { + /// Operation completed successfully. + Success, + /// Two-factor authentication is required; the caller should prompt for a + /// TOTP code and retry. + TwoFactorRequired, +} + +/// Abstraction over the legacy (Rust sync server) and Hub auth APIs. +/// +/// CLI commands use this trait so they don't need to know which backend is +/// active — they just prompt for input and call these methods. +#[async_trait] +pub trait AuthClient: Send + Sync { + /// Log in with username + password, optionally providing a TOTP code. + async fn login( + &self, + username: &str, + password: &str, + totp_code: Option<&str>, + ) -> Result<AuthResponse>; + + /// Register a new account. + async fn register(&self, username: &str, email: &str, password: &str) -> Result<AuthResponse>; + + /// Change the account password, optionally providing a TOTP code. + async fn change_password( + &self, + current_password: &str, + new_password: &str, + totp_code: Option<&str>, + ) -> Result<MutateResponse>; + + /// Delete the account, requiring the current password and optionally a TOTP code. + async fn delete_account( + &self, + password: &str, + totp_code: Option<&str>, + ) -> Result<MutateResponse>; +} + +/// Resolve the appropriate [`AuthClient`] for the current settings. +pub async fn auth_client(settings: &Settings) -> Box<dyn AuthClient> { + if settings.is_hub_sync() { + let endpoint = settings.active_hub_endpoint().unwrap_or_default(); + Box::new(HubAuthClient::new( + endpoint.as_ref(), + settings.hub_session_token().await.ok(), + )) as Box<dyn AuthClient> + } else { + Box::new(LegacyAuthClient::new( + &settings.sync_address, + settings.session_token().await.ok(), + settings.network_connect_timeout, + settings.network_timeout, + )) as Box<dyn AuthClient> + } +} + +// --------------------------------------------------------------------------- +// Legacy backend — talks to the Rust sync server +// --------------------------------------------------------------------------- + +pub struct LegacyAuthClient { + address: String, + session_token: Option<String>, + connect_timeout: u64, + timeout: u64, +} + +impl LegacyAuthClient { + pub fn new( + address: &str, + session_token: Option<String>, + connect_timeout: u64, + timeout: u64, + ) -> Self { + Self { + address: address.to_string(), + session_token, + connect_timeout, + timeout, + } + } + + fn authenticated_client(&self) -> Result<reqwest::Client> { + let token = self + .session_token + .as_deref() + .ok_or_else(|| eyre::eyre!("Not logged in"))?; + + ensure_crypto_provider(); + let mut headers = reqwest::header::HeaderMap::new(); + headers.insert( + reqwest::header::AUTHORIZATION, + format!("Token {token}").parse()?, + ); + headers.insert(USER_AGENT, APP_USER_AGENT.parse()?); + headers.insert(ATUIN_HEADER_VERSION, ATUIN_CARGO_VERSION.parse()?); + + Ok(reqwest::Client::builder() + .default_headers(headers) + .connect_timeout(std::time::Duration::new(self.connect_timeout, 0)) + .timeout(std::time::Duration::new(self.timeout, 0)) + .build()?) + } +} + +#[async_trait] +impl AuthClient for LegacyAuthClient { + async fn login( + &self, + username: &str, + password: &str, + _totp_code: Option<&str>, + ) -> Result<AuthResponse> { + // The legacy server has no 2FA support; totp_code is ignored. + let resp = crate::api_client::login( + &self.address, + LoginRequest { + username: username.to_string(), + password: password.to_string(), + }, + ) + .await?; + + Ok(AuthResponse::Success { + session: resp.session, + }) + } + + async fn register(&self, username: &str, email: &str, password: &str) -> Result<AuthResponse> { + let resp = crate::api_client::register(&self.address, username, email, password).await?; + Ok(AuthResponse::Success { + session: resp.session, + }) + } + + async fn change_password( + &self, + current_password: &str, + new_password: &str, + _totp_code: Option<&str>, + ) -> Result<MutateResponse> { + let client = self.authenticated_client()?; + let url = make_url(&self.address, "/account/password")?; + + let resp = client + .patch(&url) + .json(&ChangePasswordRequest { + current_password: current_password.to_string(), + new_password: new_password.to_string(), + }) + .send() + .await?; + + match resp.status().as_u16() { + 200 => Ok(MutateResponse::Success), + 401 => bail!("current password is incorrect"), + 403 => bail!("invalid login details"), + _ => bail!("unknown error"), + } + } + + async fn delete_account( + &self, + password: &str, + _totp_code: Option<&str>, + ) -> Result<MutateResponse> { + let client = self.authenticated_client()?; + let url = make_url(&self.address, "/account")?; + + let resp = client + .delete(&url) + .json(&serde_json::json!({ "password": password })) + .send() + .await?; + + match resp.status().as_u16() { + 200 => Ok(MutateResponse::Success), + 401 => bail!("password is incorrect"), + 403 => bail!("invalid login details"), + _ => bail!("unknown error"), + } + } +} + +// --------------------------------------------------------------------------- +// Hub backend — talks to the Hub v0 API endpoints +// --------------------------------------------------------------------------- + +pub struct HubAuthClient { + address: String, + hub_token: Option<String>, +} + +impl HubAuthClient { + pub fn new(address: &str, hub_token: Option<String>) -> Self { + Self { + address: address.trim_end_matches('/').to_string(), + hub_token, + } + } +} + +/// Hub v0 error/status response — includes an optional `code` field for +/// machine-readable status like `"2fa_required"`. +#[derive(Debug, Deserialize)] +struct HubErrorResponse { + reason: String, + code: Option<String>, +} + +#[async_trait] +impl AuthClient for HubAuthClient { + async fn login( + &self, + username: &str, + password: &str, + totp_code: Option<&str>, + ) -> Result<AuthResponse> { + ensure_crypto_provider(); + let url = make_url(&self.address, "/api/v0/login")?; + let client = reqwest::Client::new(); + + let mut body = serde_json::json!({ + "username": username, + "password": password, + }); + if let Some(code) = totp_code { + body["totp_code"] = serde_json::Value::String(code.to_string()); + } + + let resp = client + .post(&url) + .header(USER_AGENT, APP_USER_AGENT) + .header(ATUIN_HEADER_VERSION, ATUIN_CARGO_VERSION) + .json(&body) + .send() + .await + .context("failed to connect to Atuin Hub")?; + + let status = resp.status(); + + if status.is_success() { + let login: LoginResponse = resp.json().await?; + return Ok(AuthResponse::Success { + session: login.session, + }); + } + + if status == StatusCode::FORBIDDEN + && let Ok(err) = resp.json::<HubErrorResponse>().await + { + if err.code.as_deref() == Some("2fa_required") { + return Ok(AuthResponse::TwoFactorRequired); + } + bail!("{}", err.reason); + } + + if status == StatusCode::UNAUTHORIZED { + bail!("invalid credentials"); + } + + bail!("Hub login failed with status {status}"); + } + + async fn register(&self, username: &str, email: &str, password: &str) -> Result<AuthResponse> { + ensure_crypto_provider(); + let url = make_url(&self.address, "/api/v0/register")?; + let client = reqwest::Client::new(); + + let resp = client + .post(&url) + .header(USER_AGENT, APP_USER_AGENT) + .header(ATUIN_HEADER_VERSION, ATUIN_CARGO_VERSION) + .json(&serde_json::json!({ + "email": email, + "username": username, + "password": password, + })) + .send() + .await + .context("failed to connect to Atuin Hub")?; + + let status = resp.status(); + + if status.is_success() { + let reg: RegisterResponse = resp.json().await?; + return Ok(AuthResponse::Success { + session: reg.session, + }); + } + + if let Ok(err) = resp.json::<HubErrorResponse>().await { + bail!("{}", err.reason); + } + + bail!("Hub registration failed with status {status}"); + } + + async fn change_password( + &self, + current_password: &str, + new_password: &str, + totp_code: Option<&str>, + ) -> Result<MutateResponse> { + let hub_token = self + .hub_token + .as_deref() + .ok_or_else(|| eyre::eyre!("Not logged in to Hub"))?; + + ensure_crypto_provider(); + let url = make_url(&self.address, "/api/v0/account/password")?; + let client = reqwest::Client::new(); + + let mut body = serde_json::json!({ + "current_password": current_password, + "new_password": new_password, + }); + if let Some(code) = totp_code { + body["totp_code"] = serde_json::Value::String(code.to_string()); + } + + let resp = client + .patch(&url) + .header(USER_AGENT, APP_USER_AGENT) + .header(ATUIN_HEADER_VERSION, ATUIN_CARGO_VERSION) + .bearer_auth(hub_token) + .json(&body) + .send() + .await + .context("failed to connect to Atuin Hub")?; + + let status = resp.status(); + + if status.is_success() { + return Ok(MutateResponse::Success); + } + + if let Ok(err) = resp.json::<HubErrorResponse>().await { + match err.code.as_deref() { + Some("2fa_required") => return Ok(MutateResponse::TwoFactorRequired), + Some("invalid_2fa_code") => bail!("invalid two-factor code"), + _ => bail!("{}", err.reason), + } + } + + match status { + StatusCode::UNAUTHORIZED => bail!("current password is incorrect"), + StatusCode::FORBIDDEN => bail!("invalid login details"), + _ => bail!("Hub password change failed with status {status}"), + } + } + + async fn delete_account( + &self, + password: &str, + totp_code: Option<&str>, + ) -> Result<MutateResponse> { + let hub_token = self + .hub_token + .as_deref() + .ok_or_else(|| eyre::eyre!("Not logged in to Hub"))?; + + ensure_crypto_provider(); + let url = make_url(&self.address, "/api/v0/account")?; + let client = reqwest::Client::new(); + + let mut body = serde_json::json!({ + "password": password, + }); + if let Some(code) = totp_code { + body["totp_code"] = serde_json::Value::String(code.to_string()); + } + + let resp = client + .delete(&url) + .header(USER_AGENT, APP_USER_AGENT) + .header(ATUIN_HEADER_VERSION, ATUIN_CARGO_VERSION) + .bearer_auth(hub_token) + .json(&body) + .send() + .await + .context("failed to connect to Atuin Hub")?; + + let status = resp.status(); + + if status.is_success() { + return Ok(MutateResponse::Success); + } + + if let Ok(err) = resp.json::<HubErrorResponse>().await { + match err.code.as_deref() { + Some("2fa_required") => return Ok(MutateResponse::TwoFactorRequired), + Some("invalid_2fa_code") => bail!("invalid two-factor code"), + _ => bail!("{}", err.reason), + } + } + + match status { + StatusCode::UNAUTHORIZED => bail!("password is incorrect"), + StatusCode::FORBIDDEN => bail!("invalid login details"), + _ => bail!("Hub account deletion failed with status {status}"), + } + } +} + +// --------------------------------------------------------------------------- +// Shared helpers +// --------------------------------------------------------------------------- + +fn make_url(address: &str, path: &str) -> Result<String> { + let address = if address.ends_with('/') { + address.to_string() + } else { + format!("{address}/") + }; + + let path = path.strip_prefix('/').unwrap_or(path); + + let url = Url::parse(&address) + .context("failed to parse server address")? + .join(path) + .context("failed to join URL path")?; + + Ok(url.to_string()) +} diff --git a/crates/atuin-client/src/lib.rs b/crates/atuin-client/src/lib.rs index 352e5746..4609a8e8 100644 --- a/crates/atuin-client/src/lib.rs +++ b/crates/atuin-client/src/lib.rs @@ -5,6 +5,8 @@ extern crate log; #[cfg(feature = "sync")] pub mod api_client; +#[cfg(feature = "sync")] +pub mod auth; #[cfg(feature = "hub")] pub mod hub; #[cfg(feature = "sync")] diff --git a/crates/atuin-client/src/settings.rs b/crates/atuin-client/src/settings.rs index 2a96a2b3..745bd2ff 100644 --- a/crates/atuin-client/src/settings.rs +++ b/crates/atuin-client/src/settings.rs @@ -29,6 +29,26 @@ pub(crate) mod meta; mod scripts; pub mod watcher; +pub struct HubEndpoint(String); + +/// Default sync address for Atuin's hosted service +pub const DEFAULT_SYNC_ADDRESS: &str = "https://api.atuin.sh"; + +/// Default Hub web/API endpoint for Atuin's hosted service +pub const DEFAULT_HUB_ENDPOINT: &str = "https://hub.atuin.sh"; + +impl Default for HubEndpoint { + fn default() -> Self { + HubEndpoint(DEFAULT_HUB_ENDPOINT.to_string()) + } +} + +impl AsRef<str> for HubEndpoint { + fn as_ref(&self) -> &str { + &self.0 + } +} + #[derive(Clone, Debug, Deserialize, Copy, ValueEnum, PartialEq, Serialize)] pub enum SearchMode { #[serde(rename = "prefix")] @@ -1176,12 +1196,6 @@ impl Settings { } } - /// Default sync address for Atuin's hosted service - pub const DEFAULT_SYNC_ADDRESS: &'static str = "https://api.atuin.sh"; - - /// Default Hub web/API endpoint for Atuin's hosted service - pub const DEFAULT_HUB_ENDPOINT: &'static str = "https://hub.atuin.sh"; - /// Normalize a URL for comparison by trimming trailing slashes fn normalize_url(url: &str) -> &str { url.trim_end_matches('/') @@ -1190,8 +1204,8 @@ impl Settings { /// Check if a URL matches one of Atuin's official hosted addresses fn is_official_address(url: &str) -> bool { let normalized = Self::normalize_url(url); - normalized == Self::normalize_url(Self::DEFAULT_SYNC_ADDRESS) - || normalized == Self::normalize_url(Self::DEFAULT_HUB_ENDPOINT) + normalized == Self::normalize_url(DEFAULT_SYNC_ADDRESS) + || normalized == Self::normalize_url(DEFAULT_HUB_ENDPOINT) } /// Returns whether this configuration uses Hub-style sync. @@ -1213,12 +1227,12 @@ impl Settings { /// For Atuin's official hosted service, this always returns `https://hub.atuin.sh` /// regardless of whether `sync_address` is `api.atuin.sh` or `hub.atuin.sh`. /// For self-hosted instances, returns the configured `sync_address`. - pub fn active_hub_endpoint(&self) -> Option<String> { + pub fn active_hub_endpoint(&self) -> Option<HubEndpoint> { if self.is_hub_sync() { if Self::is_official_address(&self.sync_address) { - Some(Self::DEFAULT_HUB_ENDPOINT.to_string()) + Some(HubEndpoint::default()) } else { - Some(self.sync_address.clone()) + Some(HubEndpoint(self.sync_address.clone())) } } else { None |
