From ab55cc5ff10b944834d1413d7ff46b9cd75d8ba6 Mon Sep 17 00:00:00 2001 From: Michelle Tilley Date: Mon, 16 Mar 2026 15:10:32 -0700 Subject: feat: Allow headless account ops against Hub server (#3280) --- crates/atuin-ai/src/commands/inline.rs | 8 +- crates/atuin-client/src/auth.rs | 455 +++++++++++++++++++++ crates/atuin-client/src/lib.rs | 2 + crates/atuin-client/src/settings.rs | 36 +- crates/atuin/src/command/client/account.rs | 6 +- .../src/command/client/account/change_password.rs | 92 ++--- crates/atuin/src/command/client/account/delete.rs | 76 ++-- crates/atuin/src/command/client/account/link.rs | 10 +- crates/atuin/src/command/client/account/login.rs | 141 ++++--- .../atuin/src/command/client/account/register.rs | 170 +++++--- 10 files changed, 773 insertions(+), 223 deletions(-) create mode 100644 crates/atuin-client/src/auth.rs (limited to 'crates') 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; + + /// Register a new account. + async fn register(&self, username: &str, email: &str, password: &str) -> Result; + + /// 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; + + /// Delete the account, requiring the current password and optionally a TOTP code. + async fn delete_account( + &self, + password: &str, + totp_code: Option<&str>, + ) -> Result; +} + +/// Resolve the appropriate [`AuthClient`] for the current settings. +pub async fn auth_client(settings: &Settings) -> Box { + 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 + } else { + Box::new(LegacyAuthClient::new( + &settings.sync_address, + settings.session_token().await.ok(), + settings.network_connect_timeout, + settings.network_timeout, + )) as Box + } +} + +// --------------------------------------------------------------------------- +// Legacy backend — talks to the Rust sync server +// --------------------------------------------------------------------------- + +pub struct LegacyAuthClient { + address: String, + session_token: Option, + connect_timeout: u64, + timeout: u64, +} + +impl LegacyAuthClient { + pub fn new( + address: &str, + session_token: Option, + connect_timeout: u64, + timeout: u64, + ) -> Self { + Self { + address: address.to_string(), + session_token, + connect_timeout, + timeout, + } + } + + fn authenticated_client(&self) -> Result { + 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 { + // 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 { + 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 { + 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 { + 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, +} + +impl HubAuthClient { + pub fn new(address: &str, hub_token: Option) -> 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, +} + +#[async_trait] +impl AuthClient for HubAuthClient { + async fn login( + &self, + username: &str, + password: &str, + totp_code: Option<&str>, + ) -> Result { + 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::().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 { + 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::().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 { + 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::().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 { + 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::().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 { + 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 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 { + pub fn active_hub_endpoint(&self) -> Option { 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, -} -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, } -pub async fn run( - settings: &Settings, - current_password: Option, - new_password: Option, -) -> 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(¤t_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, - 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, +} - 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, + /// The two-factor authentication code for your account, if any + #[clap(long, short)] + pub totp_code: Option, + #[clap(long, hide = true)] pub from_registration: bool, } @@ -38,35 +41,102 @@ fn get_input() -> Result { 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, - email: Option, - password: Option, -) -> 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, + 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 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; + + if password.is_empty() { + bail!("please provide a password"); + } + + let response = client.register(username, email, password).await?; + + match response { + AuthResponse::Success { session } => { + Settings::meta_store() + .await? + .save_hub_session(&session) + .await?; + } + AuthResponse::TwoFactorRequired => { + bail!("unexpected two-factor requirement during registration"); + } + } + + 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); + + if password.is_empty() { + bail!("please provide a password"); + } + + let session = atuin_client::api_client::register( + settings.sync_address.as_str(), + &username, + &email, + &password, + ) + .await?; + + let meta = Settings::meta_store().await?; + meta.save_session(&session.session).await?; + + 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." + ); } - .run(settings, store) - .await?; - return Ok(()); - } - - if settings.session_token().await.is_ok() { - println!("You are already logged in."); - println!("Run 'atuin logout' to log out."); - return Ok(()); - } - - println!("Registering for an Atuin Sync account"); - let username = or_user_input(username, "username"); - let email = or_user_input(email, "email"); - - let password = password - .clone() - .unwrap_or_else(super::login::read_user_password); - - if password.is_empty() { - bail!("please provide a password"); + Ok(()) } - - let session = - api_client::register(settings.sync_address.as_str(), &username, &email, &password).await?; - - let meta = Settings::meta_store().await?; - meta.save_session(&session.session).await?; - - 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." - ); - - Ok(()) } -- cgit v1.3.1