aboutsummaryrefslogtreecommitdiffstats
path: root/crates/atuin-client
diff options
context:
space:
mode:
authorMichelle Tilley <michelle@michelletilley.net>2026-03-16 15:10:32 -0700
committerGitHub <noreply@github.com>2026-03-16 15:10:32 -0700
commitab55cc5ff10b944834d1413d7ff46b9cd75d8ba6 (patch)
tree5185b225df9dca2fc8cb109c816e5cd9175ccf32 /crates/atuin-client
parentchore: symlink changelog so dist can pick it up (diff)
downloadatuin-ab55cc5ff10b944834d1413d7ff46b9cd75d8ba6.zip
feat: Allow headless account ops against Hub server (#3280)
Diffstat (limited to 'crates/atuin-client')
-rw-r--r--crates/atuin-client/src/auth.rs455
-rw-r--r--crates/atuin-client/src/lib.rs2
-rw-r--r--crates/atuin-client/src/settings.rs36
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