aboutsummaryrefslogtreecommitdiffstats
path: root/crates
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
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')
-rw-r--r--crates/atuin-ai/src/commands/inline.rs8
-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
-rw-r--r--crates/atuin/src/command/client/account.rs6
-rw-r--r--crates/atuin/src/command/client/account/change_password.rs92
-rw-r--r--crates/atuin/src/command/client/account/delete.rs76
-rw-r--r--crates/atuin/src/command/client/account/link.rs10
-rw-r--r--crates/atuin/src/command/client/account/login.rs141
-rw-r--r--crates/atuin/src/command/client/account/register.rs156
10 files changed, 766 insertions, 216 deletions
diff --git a/crates/atuin-ai/src/commands/inline.rs b/crates/atuin-ai/src/commands/inline.rs
index ce566be1..e0ee05d6 100644
--- a/crates/atuin-ai/src/commands/inline.rs
+++ b/crates/atuin-ai/src/commands/inline.rs
@@ -84,9 +84,7 @@ async fn ensure_hub_session(settings: &atuin_client::settings::Settings) -> Resu
return Ok(token);
}
- let hub_address = settings
- .active_hub_endpoint()
- .unwrap_or("https://hub.atuin.sh".to_string());
+ let hub_address = settings.active_hub_endpoint().unwrap_or_default();
let will_sync = settings.is_hub_sync();
@@ -109,7 +107,7 @@ async fn ensure_hub_session(settings: &atuin_client::settings::Settings) -> Resu
debug!("Starting Atuin Hub authentication...");
println!("Authenticating with Atuin Hub...");
- let session = atuin_client::hub::HubAuthSession::start(&hub_address).await?;
+ let session = atuin_client::hub::HubAuthSession::start(hub_address.as_ref()).await?;
println!("Open this URL to continue:");
println!("{}", session.auth_url);
@@ -130,7 +128,7 @@ async fn ensure_hub_session(settings: &atuin_client::settings::Settings) -> Resu
&& let Ok(Some(cli_token)) = meta.session_token().await
{
debug!("CLI session found, attempting to link accounts");
- if let Err(e) = atuin_client::hub::link_account(&hub_address, &cli_token).await {
+ if let Err(e) = atuin_client::hub::link_account(hub_address.as_ref(), &cli_token).await {
// Don't fail AI flow if linking fails - it's not critical
debug!("Could not link CLI account to Hub: {}", e);
} else {
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
diff --git a/crates/atuin/src/command/client/account.rs b/crates/atuin/src/command/client/account.rs
index e9861c0a..a1be65a5 100644
--- a/crates/atuin/src/command/client/account.rs
+++ b/crates/atuin/src/command/client/account.rs
@@ -11,8 +11,6 @@ pub mod login;
pub mod logout;
pub mod register;
-const DEFAULT_HUB_ENDPOINT: &str = "https://hub.atuin.sh";
-
#[derive(Args, Debug)]
pub struct Cmd {
#[command(subcommand)]
@@ -31,7 +29,7 @@ pub enum Commands {
Logout,
/// Delete your account, and all synced data
- Delete,
+ Delete(delete::Cmd),
/// Change your password
ChangePassword(change_password::Cmd),
@@ -46,7 +44,7 @@ impl Cmd {
Commands::Login(l) => l.run(&settings, &store).await,
Commands::Register(r) => r.run(&settings, &store).await,
Commands::Logout => logout::run().await,
- Commands::Delete => delete::run(&settings).await,
+ Commands::Delete(d) => d.run(&settings).await,
Commands::ChangePassword(c) => c.run(&settings).await,
Commands::Link => link::run(&settings).await,
}
diff --git a/crates/atuin/src/command/client/account/change_password.rs b/crates/atuin/src/command/client/account/change_password.rs
index 0f4a31cd..234d4dc0 100644
--- a/crates/atuin/src/command/client/account/change_password.rs
+++ b/crates/atuin/src/command/client/account/change_password.rs
@@ -1,11 +1,12 @@
use clap::Parser;
use eyre::{Result, bail};
-use atuin_client::{api_client, settings::Settings};
+use atuin_client::{
+ auth::{self, MutateResponse},
+ settings::Settings,
+};
use rpassword::prompt_password;
-use crate::command::client::account::DEFAULT_HUB_ENDPOINT;
-
#[derive(Parser, Debug)]
pub struct Cmd {
#[clap(long, short)]
@@ -13,65 +14,54 @@ pub struct Cmd {
#[clap(long, short)]
pub new_password: Option<String>,
-}
-impl Cmd {
- pub async fn run(self, settings: &Settings) -> Result<()> {
- run(settings, self.current_password, self.new_password).await
- }
+ /// The two-factor authentication code for your account, if any
+ #[clap(long, short)]
+ pub totp_code: Option<String>,
}
-pub async fn run(
- settings: &Settings,
- current_password: Option<String>,
- new_password: Option<String>,
-) -> Result<()> {
- let using_hub_sync = settings.is_hub_sync();
- let has_sync_session = settings.session_token().await.is_ok();
- let has_hub_session = settings.hub_session_token().await.is_ok();
-
- if using_hub_sync && has_hub_session {
- let endpoint = settings
- .active_hub_endpoint()
- .unwrap_or_else(|| DEFAULT_HUB_ENDPOINT.to_string());
+impl Cmd {
+ pub async fn run(&self, settings: &Settings) -> Result<()> {
+ if !settings.logged_in().await? {
+ bail!("You are not logged in");
+ }
- println!("You are authenticated with Atuin Hub.");
- println!("Manage your account on the site: {endpoint}/settings/account");
- return Ok(());
- }
+ let client = auth::auth_client(settings).await;
- if !has_sync_session {
- bail!("You are not logged in");
- }
+ let current_password = self.current_password.clone().unwrap_or_else(|| {
+ prompt_password("Please enter the current password: ")
+ .expect("Failed to read from input")
+ });
- let client = api_client::Client::new(
- &settings.sync_address,
- settings.sync_auth_token().await?,
- settings.network_connect_timeout,
- settings.network_timeout,
- )?;
+ if current_password.is_empty() {
+ bail!("please provide the current password");
+ }
- let current_password = current_password.clone().unwrap_or_else(|| {
- prompt_password("Please enter the current password: ").expect("Failed to read from input")
- });
+ let new_password = self.new_password.clone().unwrap_or_else(|| {
+ prompt_password("Please enter the new password: ").expect("Failed to read from input")
+ });
- if current_password.is_empty() {
- bail!("please provide the current password");
- }
+ if new_password.is_empty() {
+ bail!("please provide a new password");
+ }
- let new_password = new_password.clone().unwrap_or_else(|| {
- prompt_password("Please enter the new password: ").expect("Failed to read from input")
- });
+ let mut totp_code = self.totp_code.clone();
- if new_password.is_empty() {
- bail!("please provide a new password");
- }
+ loop {
+ let response = client
+ .change_password(&current_password, &new_password, totp_code.as_deref())
+ .await?;
- client
- .change_password(current_password, new_password)
- .await?;
+ match response {
+ MutateResponse::Success => break,
+ MutateResponse::TwoFactorRequired => {
+ totp_code = Some(super::login::or_user_input(None, "two-factor code"));
+ }
+ }
+ }
- println!("Account password successfully changed!");
+ println!("Account password successfully changed!");
- Ok(())
+ Ok(())
+ }
}
diff --git a/crates/atuin/src/command/client/account/delete.rs b/crates/atuin/src/command/client/account/delete.rs
index 5c0439a3..7f8dc682 100644
--- a/crates/atuin/src/command/client/account/delete.rs
+++ b/crates/atuin/src/command/client/account/delete.rs
@@ -1,40 +1,58 @@
-use atuin_client::{api_client, settings::Settings};
+use atuin_client::{
+ auth::{self, MutateResponse},
+ settings::Settings,
+};
+use clap::Parser;
use eyre::{Result, bail};
-use crate::command::client::account::DEFAULT_HUB_ENDPOINT;
+use super::login::{or_user_input, read_user_password};
-pub async fn run(settings: &Settings) -> Result<()> {
- let using_hub_sync = settings.is_hub_sync();
- let has_sync_session = settings.session_token().await.is_ok();
- let has_hub_session = settings.hub_session_token().await.is_ok();
+#[derive(Parser, Debug)]
+pub struct Cmd {
+ #[clap(long, short)]
+ pub password: Option<String>,
- if using_hub_sync && has_hub_session {
- let endpoint = settings
- .active_hub_endpoint()
- .unwrap_or_else(|| DEFAULT_HUB_ENDPOINT.to_string());
- println!("You are authenticated with Atuin Hub.");
- println!("Manage your account on the site: {endpoint}/settings/account");
- return Ok(());
- }
+ /// The two-factor authentication code for your account, if any
+ #[clap(long, short)]
+ pub totp_code: Option<String>,
+}
- if !has_sync_session {
- bail!("You are not logged in");
- }
+impl Cmd {
+ pub async fn run(&self, settings: &Settings) -> Result<()> {
+ if !settings.logged_in().await? {
+ bail!("You are not logged in");
+ }
+
+ let client = auth::auth_client(settings).await;
- let client = api_client::Client::new(
- &settings.sync_address,
- settings.sync_auth_token().await?,
- settings.network_connect_timeout,
- settings.network_timeout,
- )?;
+ let password = self.password.clone().unwrap_or_else(read_user_password);
- client.delete().await?;
+ if password.is_empty() {
+ bail!("please provide your password");
+ }
- // Clean up session from meta store
- Settings::meta_store().await?.delete_session().await?;
- Settings::meta_store().await?.delete_hub_session().await?;
+ let mut totp_code = self.totp_code.clone();
- println!("Your account is deleted");
+ loop {
+ let response = client
+ .delete_account(&password, totp_code.as_deref())
+ .await?;
- Ok(())
+ match response {
+ MutateResponse::Success => break,
+ MutateResponse::TwoFactorRequired => {
+ totp_code = Some(or_user_input(None, "two-factor code"));
+ }
+ }
+ }
+
+ // Clean up sessions from meta store
+ let meta = Settings::meta_store().await?;
+ meta.delete_session().await?;
+ meta.delete_hub_session().await?;
+
+ println!("Your account is deleted");
+
+ Ok(())
+ }
}
diff --git a/crates/atuin/src/command/client/account/link.rs b/crates/atuin/src/command/client/account/link.rs
index 5a2e4044..69c4eebe 100644
--- a/crates/atuin/src/command/client/account/link.rs
+++ b/crates/atuin/src/command/client/account/link.rs
@@ -2,8 +2,6 @@ use eyre::{Result, bail};
use atuin_client::settings::Settings;
-use super::DEFAULT_HUB_ENDPOINT;
-
pub async fn run(settings: &Settings) -> Result<()> {
let meta = Settings::meta_store().await?;
@@ -14,16 +12,14 @@ pub async fn run(settings: &Settings) -> Result<()> {
bail!("No CLI session found. Please log in first with 'atuin login'.");
};
- let hub_address = settings
- .active_hub_endpoint()
- .unwrap_or_else(|| DEFAULT_HUB_ENDPOINT.to_string());
+ let hub_address = settings.active_hub_endpoint().unwrap_or_default();
if hub_token.is_some() {
println!("Found both Hub and CLI sessions. Linking accounts...");
} else {
println!("Found CLI session but no Hub session. Logging in to Hub first...");
- let session = atuin_client::hub::HubAuthSession::start(&hub_address).await?;
+ let session = atuin_client::hub::HubAuthSession::start(hub_address.as_ref()).await?;
println!("Open this URL to authenticate with Atuin Hub:");
println!("{}", session.auth_url);
@@ -38,7 +34,7 @@ pub async fn run(settings: &Settings) -> Result<()> {
println!("Hub authentication complete.");
}
- atuin_client::hub::link_account(&hub_address, &cli_token).await?;
+ atuin_client::hub::link_account(hub_address.as_ref(), &cli_token).await?;
println!("Successfully linked CLI account to Hub.");
Ok(())
diff --git a/crates/atuin/src/command/client/account/login.rs b/crates/atuin/src/command/client/account/login.rs
index b8aad5a9..c9ba74c9 100644
--- a/crates/atuin/src/command/client/account/login.rs
+++ b/crates/atuin/src/command/client/account/login.rs
@@ -5,13 +5,12 @@ use eyre::{Context, Result, bail};
use tokio::{fs::File, io::AsyncWriteExt};
use atuin_client::{
- api_client,
+ auth::{self, AuthResponse},
encryption::{Key, decode_key, encode_key, load_key},
record::sqlite_store::SqliteStore,
record::store::Store,
settings::Settings,
};
-use atuin_common::api::LoginRequest;
use rpassword::prompt_password;
#[derive(Parser, Debug)]
@@ -26,6 +25,10 @@ pub struct Cmd {
#[clap(long, short)]
pub key: Option<String>,
+ /// The two-factor authentication code for your account, if any
+ #[clap(long, short)]
+ pub totp_code: Option<String>,
+
#[clap(long, hide = true)]
pub from_registration: bool,
}
@@ -38,35 +41,102 @@ fn get_input() -> Result<String> {
impl Cmd {
pub async fn run(&self, settings: &Settings, store: &SqliteStore) -> Result<()> {
- if let Some(endpoint) = settings.active_hub_endpoint() {
- if settings.hub_session_token().await.is_ok() {
+ if settings.logged_in().await? {
+ if settings.is_hub_sync() {
println!("You are authenticated with Atuin Hub.");
- println!("Run 'atuin logout' to log out.");
- return Ok(());
+ } else {
+ println!("You are already logged in.");
}
+ println!("Run 'atuin logout' to log out.");
+ return Ok(());
+ }
+
+ if settings.is_hub_sync() {
+ self.run_hub_login(settings, store).await
+ } else {
+ self.run_legacy_login(settings, store).await
+ }
+ }
+
+ /// Hub login: use the browser OAuth flow unless all three flags
+ /// (username, password, key) were provided for headless/CI use.
+ async fn run_hub_login(&self, settings: &Settings, store: &SqliteStore) -> Result<()> {
+ let endpoint = settings.active_hub_endpoint().unwrap_or_default();
+
+ if let Some(username) = &self.username {
+ // Headless login via v0 API (for CI / scripting).
+ let client = auth::auth_client(settings).await;
+
+ self.prompt_and_store_key(settings, store).await?;
+
+ let password = self.password.clone().unwrap_or_else(read_user_password);
+ let mut totp_code = self.totp_code.clone();
- // The only difference between login and registration is that registration doesn't prompt for a key
+ let session = loop {
+ let response = client
+ .login(username, &password, totp_code.as_deref())
+ .await?;
+
+ match response {
+ AuthResponse::Success { session } => break session,
+ AuthResponse::TwoFactorRequired => {
+ totp_code = Some(or_user_input(None, "two-factor code"));
+ }
+ }
+ };
+
+ Settings::meta_store()
+ .await?
+ .save_hub_session(&session)
+ .await?;
+ } else {
+ // Interactive login via browser OAuth flow.
if self.from_registration {
load_key(settings)?;
} else {
self.prompt_and_store_key(settings, store).await?;
}
- self.ensure_hub_session(settings, endpoint.as_str()).await?;
- println!("Successfully authenticated with Atuin Hub.");
- return Ok(());
+ self.ensure_hub_session(settings, endpoint.as_ref()).await?;
}
- if settings.logged_in().await? {
- println!("You are already logged in.");
- println!("Run 'atuin logout' to log out.");
- return Ok(());
+ // Silently attempt to link CLI account to Hub if one exists
+ if let Ok(cli_token) = settings.session_token().await
+ && let Err(e) = atuin_client::hub::link_account(endpoint.as_ref(), &cli_token).await
+ {
+ tracing::debug!("Could not link CLI account to Hub: {}", e);
+ }
+
+ println!("Successfully authenticated with Atuin Hub.");
+ Ok(())
+ }
+
+ /// Legacy login: always prompt for username/password interactively
+ /// (or accept them via flags).
+ async fn run_legacy_login(&self, settings: &Settings, store: &SqliteStore) -> Result<()> {
+ let username = or_user_input(self.username.clone(), "username");
+ let password = self.password.clone().unwrap_or_else(read_user_password);
+
+ self.prompt_and_store_key(settings, store).await?;
+
+ let client = auth::auth_client(settings).await;
+ let response = client.login(&username, &password, None).await?;
+
+ match response {
+ AuthResponse::Success { session } => {
+ Settings::meta_store().await?.save_session(&session).await?;
+ }
+ AuthResponse::TwoFactorRequired => {
+ // Legacy server doesn't support 2FA, so this shouldn't happen.
+ bail!("unexpected two-factor requirement from legacy server");
+ }
}
- self.run_sync_login(settings, store).await
+ println!("Logged in!");
+ Ok(())
}
- async fn ensure_hub_session(&self, settings: &Settings, hub_address: &str) -> Result<()> {
+ async fn ensure_hub_session(&self, _settings: &Settings, hub_address: &str) -> Result<()> {
tracing::info!("Authenticating with Atuin Hub...");
let session = atuin_client::hub::HubAuthSession::start(hub_address).await?;
@@ -84,45 +154,6 @@ impl Cmd {
atuin_client::hub::save_session(&token).await?;
- // Silently attempt to link CLI account to Hub if one exists
- // This enables unified auth - users can use their Hub token for sync
- if let Ok(cli_token) = settings.session_token().await {
- tracing::debug!("CLI session found, attempting to link accounts");
- if let Err(e) = atuin_client::hub::link_account(hub_address, &cli_token).await {
- tracing::debug!("Could not link CLI account to Hub: {}", e);
- } else {
- tracing::info!("Successfully linked CLI account to Hub");
- }
- }
-
- Ok(())
- }
-
- async fn run_sync_login(&self, settings: &Settings, store: &SqliteStore) -> Result<()> {
- // TODO(ellie): Replace this with a call to atuin_client::login::login
- // The reason I haven't done this yet is that this implementation allows for
- // an empty key. This will use an existing key file.
- //
- // I'd quite like to ditch that behaviour, so have not brought it into the library
- // function.
- let username = or_user_input(self.username.clone(), "username");
- let password = self.password.clone().unwrap_or_else(read_user_password);
-
- self.prompt_and_store_key(settings, store).await?;
-
- let session = api_client::login(
- settings.sync_address.as_str(),
- LoginRequest { username, password },
- )
- .await?;
-
- Settings::meta_store()
- .await?
- .save_session(&session.session)
- .await?;
-
- println!("Logged in!");
-
Ok(())
}
diff --git a/crates/atuin/src/command/client/account/register.rs b/crates/atuin/src/command/client/account/register.rs
index a2f4edfd..03a97512 100644
--- a/crates/atuin/src/command/client/account/register.rs
+++ b/crates/atuin/src/command/client/account/register.rs
@@ -2,7 +2,11 @@ use clap::Parser;
use eyre::{Result, bail};
use super::login::or_user_input;
-use atuin_client::{api_client, record::sqlite_store::SqliteStore, settings::Settings};
+use atuin_client::{
+ auth::{self, AuthResponse},
+ record::sqlite_store::SqliteStore,
+ settings::Settings,
+};
#[derive(Parser, Debug)]
pub struct Cmd {
@@ -17,71 +21,115 @@ pub struct Cmd {
}
impl Cmd {
- pub async fn run(self, settings: &Settings, store: &SqliteStore) -> Result<()> {
- run(settings, store, self.username, self.email, self.password).await
- }
-}
-
-pub async fn run(
- settings: &Settings,
- store: &SqliteStore,
- username: Option<String>,
- email: Option<String>,
- password: Option<String>,
-) -> Result<()> {
- if let Some(_endpoint) = settings.active_hub_endpoint() {
- if settings.hub_session_token().await.is_ok() {
- println!("You are already authenticated with Atuin Hub.");
+ pub async fn run(&self, settings: &Settings, store: &SqliteStore) -> Result<()> {
+ if settings.logged_in().await? {
+ if settings.is_hub_sync() {
+ println!("You are already authenticated with Atuin Hub.");
+ } else {
+ println!("You are already logged in.");
+ }
println!("Run 'atuin logout' to log out.");
return Ok(());
}
- // Login can also handle registration, as the registration piece for Hub auth lives on the server
- // (e.g. create a new Hub account, then log in as normal)
- super::login::Cmd {
- username: None,
- password: None,
- key: None,
- from_registration: true,
- }
- .run(settings, store)
- .await?;
- return Ok(());
- }
+ if settings.is_hub_sync() {
+ let required_for_headless = 3;
+ let provided = [
+ self.username.is_some(),
+ self.email.is_some(),
+ self.password.is_some(),
+ ]
+ .iter()
+ .filter(|&b| *b)
+ .count();
+ if provided < required_for_headless {
+ println!(
+ "Username, password, and email are all required for headless registration. Continuing with interactive registration.\n"
+ );
+ }
- if settings.session_token().await.is_ok() {
- println!("You are already logged in.");
- println!("Run 'atuin logout' to log out.");
- return Ok(());
- }
+ if let (Some(username), Some(email), Some(password)) =
+ (&self.username, &self.email, &self.password)
+ {
+ // Headless registration via v0 API (for CI / scripting).
+ let client = auth::auth_client(settings).await;
- println!("Registering for an Atuin Sync account");
+ if password.is_empty() {
+ bail!("please provide a password");
+ }
- let username = or_user_input(username, "username");
- let email = or_user_input(email, "email");
+ let response = client.register(username, email, password).await?;
- let password = password
- .clone()
- .unwrap_or_else(super::login::read_user_password);
+ match response {
+ AuthResponse::Success { session } => {
+ Settings::meta_store()
+ .await?
+ .save_hub_session(&session)
+ .await?;
+ }
+ AuthResponse::TwoFactorRequired => {
+ bail!("unexpected two-factor requirement during registration");
+ }
+ }
- if password.is_empty() {
- bail!("please provide a password");
- }
+ let _key = atuin_client::encryption::load_key(settings)?;
+
+ println!(
+ "Registration successful! Please make a note of your key (run 'atuin key') and keep it safe."
+ );
+ println!(
+ "You will need it to log in on other devices, and we cannot help recover it if you lose it."
+ );
+ } else {
+ // Interactive registration: delegate to the browser OAuth flow.
+ // Registration on Hub happens on the website; the CLI just needs
+ // to authenticate afterwards.
+ super::login::Cmd {
+ username: None,
+ password: None,
+ key: None,
+ totp_code: None,
+ from_registration: true,
+ }
+ .run(settings, store)
+ .await?;
+ }
+ } else {
+ // Legacy registration flow
+ println!("Registering for an Atuin Sync account");
+
+ let username = or_user_input(self.username.clone(), "username");
+ let email = or_user_input(self.email.clone(), "email");
+ let password = self
+ .password
+ .clone()
+ .unwrap_or_else(super::login::read_user_password);
- let session =
- api_client::register(settings.sync_address.as_str(), &username, &email, &password).await?;
+ if password.is_empty() {
+ bail!("please provide a password");
+ }
- let meta = Settings::meta_store().await?;
- meta.save_session(&session.session).await?;
+ let session = atuin_client::api_client::register(
+ settings.sync_address.as_str(),
+ &username,
+ &email,
+ &password,
+ )
+ .await?;
- let _key = atuin_client::encryption::load_key(settings)?;
+ let meta = Settings::meta_store().await?;
+ meta.save_session(&session.session).await?;
- println!(
- "Registration successful! Please make a note of your key (run 'atuin key') and keep it safe."
- );
- println!(
- "You will need it to log in on other devices, and we cannot help recover it if you lose it."
- );
+ let _key = atuin_client::encryption::load_key(settings)?;
- Ok(())
+ println!(
+ "Registration successful! Please make a note of your key (run 'atuin key') and keep it safe."
+ );
+ println!(
+ "You will need it to log in on other devices, and we cannot help recover it if you lose it."
+ );
+ }
+
+ Ok(())
+ }
}