From c8fd7d3d8f2c93c24a4a5fc41361dfed8714e73a Mon Sep 17 00:00:00 2001 From: Michelle Tilley Date: Wed, 11 Mar 2026 08:50:17 -0700 Subject: feat: Allow authenticating with Atuin Hub (#3237) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary This PR enables the Atuin CLI to authenticate with Atuin Hub, unifying authentication across CLI sync and Hub features (AI, runbooks, etc.). ### Key Changes - **Dual auth support**: New `AuthToken` enum supports both `Bearer` (Hub) and `Token` (legacy CLI) authentication - **Smart protocol selection**: New `sync_protocol` setting (`auto`/`hub`/`legacy`) determines auth method. By default, `api.atuin.sh` uses Hub auth; custom sync addresses use legacy auth - **Hub login flow**: `atuin login` now initiates an OAuth-like flow for Hub users—generates a code, user authorizes in browser, CLI polls for completion - **Account linking**: After Hub auth, silently attempts to link existing CLI sync account to Hub account for seamless migration - **Graceful fallback**: `sync_auth_token()` prefers Hub token when available, falls back to CLI session token ### Auth Flow 1. User runs `atuin login` (with default sync address) 2. CLI requests auth code from Hub, displays URL 3. User opens URL, logs in/registers on Hub 4. Hub attaches API token to code 5. CLI polls, receives token, saves as hub session 6. If user had existing CLI sync account, it's automatically linked ### Backward Compatibility - Existing self-hosted users: unaffected (legacy auth via `Token` header) - Existing `api.atuin.sh` users: continue working with CLI session until they run `atuin login` - New users: go through Hub flow automatically ## Test Plan - [ ] New user registration via Hub flow - [ ] Existing CLI user can still sync without changes - [ ] `atuin login` links CLI account to Hub account - [ ] Self-hosted users unaffected by changes - [ ] AI commands work after Hub auth --------- Co-authored-by: Ellie Huxtable --- crates/atuin-ai/src/commands/inline.rs | 35 ++++-- crates/atuin-client/src/api_client.rs | 37 ++++++- crates/atuin-client/src/hub.rs | 59 +++++++++- crates/atuin-client/src/logout.rs | 1 + crates/atuin-client/src/meta.rs | 2 +- crates/atuin-client/src/record/sync.rs | 10 +- crates/atuin-client/src/settings.rs | 121 ++++++++++++++++++++- crates/atuin-client/src/sync.rs | 2 +- crates/atuin/src/command/client/account.rs | 9 +- .../src/command/client/account/change_password.rs | 22 +++- crates/atuin/src/command/client/account/delete.rs | 20 +++- crates/atuin/src/command/client/account/link.rs | 45 ++++++++ crates/atuin/src/command/client/account/login.rs | 117 +++++++++++++++----- .../atuin/src/command/client/account/register.rs | 35 +++++- crates/atuin/src/command/client/doctor.rs | 2 +- crates/atuin/src/command/client/store/push.rs | 2 +- crates/atuin/src/command/client/sync.rs | 2 +- crates/atuin/src/command/client/sync/status.rs | 2 +- crates/atuin/tests/common/mod.rs | 16 ++- 19 files changed, 470 insertions(+), 69 deletions(-) create mode 100644 crates/atuin/src/command/client/account/link.rs diff --git a/crates/atuin-ai/src/commands/inline.rs b/crates/atuin-ai/src/commands/inline.rs index cd670bf8..df4a2d19 100644 --- a/crates/atuin-ai/src/commands/inline.rs +++ b/crates/atuin-ai/src/commands/inline.rs @@ -45,7 +45,12 @@ pub async fn run( let token = if let Some(token) = &api_token { token.to_string() } else { - ensure_hub_session(settings, endpoint).await? + // If no token is provided, assume we're using Hub as the endpoint if we're using Hub sync + if settings.is_hub_sync() { + ensure_hub_session(settings).await? + } else { + bail!("No API token provided in ai.api_token settings or command line argument.") + } }; let action = run_inline_tui( @@ -62,15 +67,16 @@ pub async fn run( Ok(()) } -async fn ensure_hub_session( - settings: &atuin_client::settings::Settings, - hub_address: &str, -) -> Result { +async fn ensure_hub_session(settings: &atuin_client::settings::Settings) -> Result { if let Some(token) = atuin_client::hub::get_session_token().await? { debug!("Found Hub session, using existing token"); return Ok(token); } + let hub_address = settings + .active_hub_endpoint() + .unwrap_or("https://hub.atuin.sh".to_string()); + info!("No Hub session found, prompting for authentication"); println!("Atuin AI requires authenticating with Atuin Hub."); @@ -83,9 +89,7 @@ async fn ensure_hub_session( debug!("Starting Atuin Hub authentication..."); println!("Authenticating with Atuin Hub..."); - let mut auth_settings = settings.clone(); - auth_settings.hub_address = hub_address.to_string(); - let session = atuin_client::hub::HubAuthSession::start(&auth_settings).await?; + let session = atuin_client::hub::HubAuthSession::start(&hub_address).await?; println!("Open this URL to continue:"); println!("{}", session.auth_url); @@ -99,6 +103,21 @@ async fn ensure_hub_session( info!("Authentication complete, saving session token"); 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(meta) = atuin_client::settings::Settings::meta_store().await + && 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 { + // Don't fail AI flow if linking fails - it's not critical + debug!("Could not link CLI account to Hub: {}", e); + } else { + info!("Successfully linked CLI account to Hub"); + } + } + Ok(token) } diff --git a/crates/atuin-client/src/api_client.rs b/crates/atuin-client/src/api_client.rs index aeca6492..066fecb5 100644 --- a/crates/atuin-client/src/api_client.rs +++ b/crates/atuin-client/src/api_client.rs @@ -30,6 +30,32 @@ use crate::{history::History, sync::hash_str, utils::get_host_user}; static APP_USER_AGENT: &str = concat!("atuin/", env!("CARGO_PKG_VERSION"),); +/// Authentication token for sync API requests. +/// +/// The sync API supports two authentication methods: +/// - `Bearer`: Hub API tokens (for users authenticated via Atuin Hub) +/// - `Token`: Legacy CLI session tokens (for users registered via CLI or self-hosted) +/// +/// When both are available, Hub tokens are preferred as they provide unified +/// authentication across CLI and Hub features. +#[derive(Debug, Clone)] +pub enum AuthToken { + /// Hub API token, used with "Bearer {token}" header + Bearer(String), + /// Legacy CLI session token, used with "Token {token}" header + Token(String), +} + +impl AuthToken { + /// Format the token as an Authorization header value + fn to_header_value(&self) -> String { + match self { + AuthToken::Bearer(token) => format!("Bearer {token}"), + AuthToken::Token(token) => format!("Token {token}"), + } + } +} + pub struct Client<'a> { sync_addr: &'a str, client: reqwest::Client, @@ -162,6 +188,7 @@ pub fn ensure_version(response: &Response) -> Result { async fn handle_resp_error(resp: Response) -> Result { let status = resp.status(); + let url = resp.url().to_string(); if status == StatusCode::SERVICE_UNAVAILABLE { bail!( @@ -178,16 +205,16 @@ async fn handle_resp_error(resp: Response) -> Result { let reason = error.reason; if status.is_client_error() { - bail!("Invalid request to the service: {status} - {reason}.") + bail!("Invalid request to the service at {url}, {status} - {reason}.") } bail!( - "There was an error with the atuin sync service, server error {status}: {reason}.\nIf the problem persists, contact the host" + "There was an error with the atuin sync service at {url}, server error {status}: {reason}.\nIf the problem persists, contact the host" ) } bail!( - "There was an error with the atuin sync service: Status {status:?}.\nIf the problem persists, contact the host" + "There was an error with the atuin sync service at {url}, Status {status:?}.\nIf the problem persists, contact the host" ) } @@ -197,13 +224,13 @@ async fn handle_resp_error(resp: Response) -> Result { impl<'a> Client<'a> { pub fn new( sync_addr: &'a str, - session_token: &str, + auth: AuthToken, connect_timeout: u64, timeout: u64, ) -> Result { ensure_crypto_provider(); let mut headers = HeaderMap::new(); - headers.insert(AUTHORIZATION, format!("Token {session_token}").parse()?); + headers.insert(AUTHORIZATION, auth.to_header_value().parse()?); // used for semver server check headers.insert(ATUIN_HEADER_VERSION, ATUIN_CARGO_VERSION.parse()?); diff --git a/crates/atuin-client/src/hub.rs b/crates/atuin-client/src/hub.rs index b94c69ea..2e40aad4 100644 --- a/crates/atuin-client/src/hub.rs +++ b/crates/atuin-client/src/hub.rs @@ -57,22 +57,23 @@ impl HubAuthSession { /// Start a new hub authentication session /// /// Returns a session containing the code and auth URL that the user should visit. - pub async fn start(settings: &Settings) -> Result { + pub async fn start(hub_address: &str) -> Result { debug!("Starting Hub authentication process..."); - let code_response = request_code(&settings.hub_address) + let hub_address = hub_address.trim_end_matches('/'); + let code_response = request_code(hub_address) .await .context("Failed to request authentication code from Hub")?; debug!("Received code from Hub"); let code = code_response.code; - let auth_url = format!("{}/auth/cli?code={}", settings.hub_address, code); + let auth_url = format!("{}/auth/cli?code={}", hub_address, code); Ok(Self { code, auth_url, - hub_address: settings.hub_address.clone(), + hub_address: hub_address.to_string(), }) } @@ -167,6 +168,56 @@ pub async fn get_session_token() -> Result> { Settings::meta_store().await?.hub_session_token().await } +/// Link an existing CLI sync account to the current Hub user. +/// +/// This associates the CLI's sync records with the Hub account, enabling +/// unified authentication. After linking: +/// - The Hub token can be used for sync operations +/// - Records are migrated to be accessible via Hub auth +/// +/// Requires: +/// - A valid Hub session (user must be logged in to Hub) +/// - A valid CLI session token to link +/// +/// Returns Ok(()) on success, or an error if: +/// - Not logged in to Hub +/// - CLI token is invalid +/// - CLI account is already linked to a different Hub account +pub async fn link_account(hub_address: &str, cli_token: &str) -> Result<()> { + let hub_token = get_session_token() + .await? + .ok_or_else(|| eyre::eyre!("Not logged in to Hub - cannot link account"))?; + + let url = make_url(hub_address, "/api/v0/account/link")?; + + debug!("Linking CLI account to Hub at {}", hub_address); + + ensure_crypto_provider(); + let client = reqwest::Client::new(); + + let resp = client + .post(&url) + .header(USER_AGENT, APP_USER_AGENT) + .header(ATUIN_HEADER_VERSION, ATUIN_CARGO_VERSION) + .bearer_auth(&hub_token) + .json(&serde_json::json!({ "token": cli_token })) + .send() + .await?; + + let status = resp.status(); + + if status == StatusCode::CONFLICT { + // 409 means CLI account is already linked to a (possibly different) Hub account + debug!("CLI account already linked to a Hub account"); + return Ok(()); + } + + handle_resp_error(resp).await?; + + info!("Successfully linked CLI account to Hub"); + Ok(()) +} + // --- Internal HTTP functions --- fn make_url(address: &str, path: &str) -> Result { diff --git a/crates/atuin-client/src/logout.rs b/crates/atuin-client/src/logout.rs index f720b302..80f0ad73 100644 --- a/crates/atuin-client/src/logout.rs +++ b/crates/atuin-client/src/logout.rs @@ -7,6 +7,7 @@ pub async fn logout() -> Result<()> { if meta.logged_in().await? { meta.delete_session().await?; + meta.delete_hub_session().await?; println!("You have logged out!"); } else { println!("You are not logged in"); diff --git a/crates/atuin-client/src/meta.rs b/crates/atuin-client/src/meta.rs index 94ddcaf3..eb6dd8cf 100644 --- a/crates/atuin-client/src/meta.rs +++ b/crates/atuin-client/src/meta.rs @@ -187,7 +187,7 @@ impl MetaStore { } pub async fn logged_in(&self) -> Result { - Ok(self.session_token().await?.is_some()) + Ok(self.session_token().await?.is_some() || self.hub_session_token().await?.is_some()) } // Hub session methods (separate from sync session, used for Hub-specific features like AI) diff --git a/crates/atuin-client/src/record/sync.rs b/crates/atuin-client/src/record/sync.rs index 52c34a50..37840b75 100644 --- a/crates/atuin-client/src/record/sync.rs +++ b/crates/atuin-client/src/record/sync.rs @@ -56,10 +56,9 @@ pub async fn diff( let client = Client::new( &settings.sync_address, settings - .session_token() + .sync_auth_token() .await - .map_err(|e| SyncError::RemoteRequestError { msg: e.to_string() })? - .as_str(), + .map_err(|e| SyncError::RemoteRequestError { msg: e.to_string() })?, settings.network_connect_timeout, settings.network_timeout, ) @@ -282,10 +281,9 @@ pub async fn sync_remote( let client = Client::new( &settings.sync_address, settings - .session_token() + .sync_auth_token() .await - .map_err(|e| SyncError::RemoteRequestError { msg: e.to_string() })? - .as_str(), + .map_err(|e| SyncError::RemoteRequestError { msg: e.to_string() })?, settings.network_connect_timeout, settings.network_timeout, ) diff --git a/crates/atuin-client/src/settings.rs b/crates/atuin-client/src/settings.rs index ddd047cd..62b3a098 100644 --- a/crates/atuin-client/src/settings.rs +++ b/crates/atuin-client/src/settings.rs @@ -342,6 +342,27 @@ pub struct Sync { pub records: bool, } +/// Sync protocol type for authentication. +/// +/// This setting is primarily for development/testing. When not explicitly set, +/// the protocol is inferred from the sync_address: +/// - Default sync address (api.atuin.sh) → Hub protocol +/// - Custom sync address → Legacy protocol +/// +/// Set explicitly to "hub" to use Hub authentication with a custom sync_address +/// (useful for local development against a Hub instance). +#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, Serialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum SyncProtocol { + /// Use Hub authentication (Bearer token from Hub OAuth flow) + Hub, + /// Use legacy CLI authentication (Token from CLI register/login) + Legacy, + /// Infer from sync_address (default behavior) + #[default] + Auto, +} + #[derive(Clone, Debug, Deserialize, Default, Serialize)] pub struct Keys { pub scroll_exits: bool, @@ -960,12 +981,15 @@ pub struct Settings { pub auto_sync: bool, pub update_check: bool, - /// The address of the Atuin Hub. Used for Hub-specific features like AI. - pub hub_address: String, - /// The sync address for atuin. pub sync_address: String, + /// Sync protocol for authentication. When set to "auto" (default), the protocol + /// is inferred from sync_address. Set to "hub" to force Hub auth with a custom + /// sync_address (useful for local development). + #[serde(default)] + pub sync_protocol: SyncProtocol, + pub sync_frequency: String, pub db_path: String, pub record_store_path: String, @@ -1142,6 +1166,96 @@ impl Settings { } } + pub async fn hub_session_token(&self) -> Result { + match Self::meta_store().await?.hub_session_token().await? { + Some(token) => Ok(token), + None => Err(eyre!("Tried to load hub session; not logged in")), + } + } + + /// 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('/') + } + + /// 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) + } + + /// Returns whether this configuration uses Hub-style sync. + /// + /// Hub sync uses Bearer token authentication and is the default for + /// Atuin's hosted service. This returns true when: + /// - `sync_protocol` is explicitly set to `Hub`, OR + /// - `sync_protocol` is `Auto` and `sync_address` is an official Atuin address + pub fn is_hub_sync(&self) -> bool { + match self.sync_protocol { + SyncProtocol::Hub => true, + SyncProtocol::Legacy => false, + SyncProtocol::Auto => Self::is_official_address(&self.sync_address), + } + } + + /// Returns the base URL for the Hub endpoint. + /// + /// 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 { + if self.is_hub_sync() { + if Self::is_official_address(&self.sync_address) { + Some(Self::DEFAULT_HUB_ENDPOINT.to_string()) + } else { + Some(self.sync_address.clone()) + } + } else { + None + } + } + + /// Returns the best available auth token for sync operations. + /// + /// Token priority when using Hub sync: + /// 1. Hub token (Bearer) - enables unified Hub auth + /// 2. CLI session token (Token) - fallback if Hub token revoked + /// + /// For legacy/self-hosted sync, only CLI session token is used. + /// + /// Hub tokens are preferred when available because they provide unified + /// authentication across CLI and Hub features, and users can manage them + /// via the Hub web interface. + #[cfg(feature = "sync")] + pub async fn sync_auth_token(&self) -> Result { + use crate::api_client::AuthToken; + + let meta = Self::meta_store().await?; + + // Try Hub token first if we're using Hub sync + if self.is_hub_sync() + && let Some(hub_token) = meta.hub_session_token().await? + { + return Ok(AuthToken::Bearer(hub_token)); + } + + // Fall back to CLI session token + match meta.session_token().await? { + Some(token) => Ok(AuthToken::Token(token)), + None => Err(eyre!( + "Not logged in - no Hub session or CLI session found. \ + Run 'atuin login' or 'atuin register' to authenticate." + )), + } + } + #[cfg(feature = "check-update")] async fn needs_update_check(&self) -> Result { let last_check = Settings::last_version_check().await?; @@ -1253,7 +1367,6 @@ impl Settings { .set_default("timezone", "local")? .set_default("auto_sync", true)? .set_default("update_check", cfg!(feature = "check-update"))? - .set_default("hub_address", "https://hub.atuin.sh")? .set_default("sync_address", "https://api.atuin.sh")? .set_default("sync_frequency", "5m")? .set_default("search_mode", "fuzzy")? diff --git a/crates/atuin-client/src/sync.rs b/crates/atuin-client/src/sync.rs index 4c236de4..2c902794 100644 --- a/crates/atuin-client/src/sync.rs +++ b/crates/atuin-client/src/sync.rs @@ -194,7 +194,7 @@ async fn sync_upload( pub async fn sync(settings: &Settings, force: bool, db: &impl Database) -> Result<()> { let client = api_client::Client::new( &settings.sync_address, - settings.session_token().await?.as_str(), + settings.sync_auth_token().await?, settings.network_connect_timeout, settings.network_timeout, )?; diff --git a/crates/atuin/src/command/client/account.rs b/crates/atuin/src/command/client/account.rs index eae8afdb..e9861c0a 100644 --- a/crates/atuin/src/command/client/account.rs +++ b/crates/atuin/src/command/client/account.rs @@ -6,10 +6,13 @@ use atuin_client::settings::Settings; pub mod change_password; pub mod delete; +pub mod link; 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)] @@ -32,16 +35,20 @@ pub enum Commands { /// Change your password ChangePassword(change_password::Cmd), + + /// Link your CLI sync account to your Hub account + Link, } impl Cmd { pub async fn run(self, settings: Settings, store: SqliteStore) -> Result<()> { match self.command { Commands::Login(l) => l.run(&settings, &store).await, - Commands::Register(r) => r.run(&settings).await, + Commands::Register(r) => r.run(&settings, &store).await, Commands::Logout => logout::run().await, Commands::Delete => delete::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 acd4b262..0f4a31cd 100644 --- a/crates/atuin/src/command/client/account/change_password.rs +++ b/crates/atuin/src/command/client/account/change_password.rs @@ -4,6 +4,8 @@ use eyre::{Result, bail}; use atuin_client::{api_client, settings::Settings}; use rpassword::prompt_password; +use crate::command::client::account::DEFAULT_HUB_ENDPOINT; + #[derive(Parser, Debug)] pub struct Cmd { #[clap(long, short)] @@ -24,9 +26,27 @@ pub async fn run( 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()); + + println!("You are authenticated with Atuin Hub."); + println!("Manage your account on the site: {endpoint}/settings/account"); + return Ok(()); + } + + if !has_sync_session { + bail!("You are not logged in"); + } + let client = api_client::Client::new( &settings.sync_address, - settings.session_token().await?.as_str(), + settings.sync_auth_token().await?, settings.network_connect_timeout, settings.network_timeout, )?; diff --git a/crates/atuin/src/command/client/account/delete.rs b/crates/atuin/src/command/client/account/delete.rs index 73dcb5dd..5c0439a3 100644 --- a/crates/atuin/src/command/client/account/delete.rs +++ b/crates/atuin/src/command/client/account/delete.rs @@ -1,14 +1,29 @@ use atuin_client::{api_client, settings::Settings}; use eyre::{Result, bail}; +use crate::command::client::account::DEFAULT_HUB_ENDPOINT; + pub async fn run(settings: &Settings) -> Result<()> { - if !settings.logged_in().await? { + 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()); + println!("You are authenticated with Atuin Hub."); + println!("Manage your account on the site: {endpoint}/settings/account"); + return Ok(()); + } + + if !has_sync_session { bail!("You are not logged in"); } let client = api_client::Client::new( &settings.sync_address, - settings.session_token().await?.as_str(), + settings.sync_auth_token().await?, settings.network_connect_timeout, settings.network_timeout, )?; @@ -17,6 +32,7 @@ pub async fn run(settings: &Settings) -> Result<()> { // Clean up session from meta store Settings::meta_store().await?.delete_session().await?; + Settings::meta_store().await?.delete_hub_session().await?; println!("Your account is deleted"); diff --git a/crates/atuin/src/command/client/account/link.rs b/crates/atuin/src/command/client/account/link.rs new file mode 100644 index 00000000..5a2e4044 --- /dev/null +++ b/crates/atuin/src/command/client/account/link.rs @@ -0,0 +1,45 @@ +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?; + + let cli_token = meta.session_token().await?; + let hub_token = meta.hub_session_token().await?; + + let Some(cli_token) = cli_token else { + 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()); + + 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?; + println!("Open this URL to authenticate with Atuin Hub:"); + println!("{}", session.auth_url); + + let token = session + .wait_for_completion( + atuin_client::hub::DEFAULT_AUTH_TIMEOUT, + atuin_client::hub::DEFAULT_POLL_INTERVAL, + ) + .await?; + + atuin_client::hub::save_session(&token).await?; + println!("Hub authentication complete."); + } + + atuin_client::hub::link_account(&hub_address, &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 d93dcf19..b8aad5a9 100644 --- a/crates/atuin/src/command/client/account/login.rs +++ b/crates/atuin/src/command/client/account/login.rs @@ -25,6 +25,9 @@ pub struct Cmd { /// The encryption key for your account #[clap(long, short)] pub key: Option, + + #[clap(long, hide = true)] + pub from_registration: bool, } fn get_input() -> Result { @@ -35,15 +38,66 @@ 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() { + println!("You are authenticated with Atuin Hub."); + println!("Run 'atuin logout' to log out."); + return Ok(()); + } + + // The only difference between login and registration is that registration doesn't prompt for a key + 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(()); + } + if settings.logged_in().await? { - bail!( - "You are already logged in! Please run 'atuin logout' if you wish to login again" - ); + println!("You are already logged in."); + println!("Run 'atuin logout' to log out."); + return Ok(()); } self.run_sync_login(settings, store).await } + 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?; + println!("Open this URL to continue authenticating with Atuin Hub:"); + println!("{}", session.auth_url); + + let token = session + .wait_for_completion( + atuin_client::hub::DEFAULT_AUTH_TIMEOUT, + atuin_client::hub::DEFAULT_POLL_INTERVAL, + ) + .await?; + + tracing::info!("Authentication complete, saving session token"); + + 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 @@ -54,6 +108,25 @@ impl Cmd { 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(()) + } + + async fn prompt_and_store_key(&self, settings: &Settings, store: &SqliteStore) -> Result<()> { let key_path = settings.key_path.as_str(); let key_path = PathBuf::from(key_path); @@ -61,8 +134,8 @@ impl Cmd { println!( "If you are already logged in on another machine, you must ensure that the key you use here is the same as the key you used there." ); - println!("You can find your key by running 'atuin key' on the other machine"); - println!("Do not share this key with anyone"); + println!("You can find your key by running 'atuin key' on the other machine."); + println!("Do not share this key with anyone."); println!("\nRead more here: https://docs.atuin.sh/guide/sync/#login \n"); let key = or_user_input( @@ -83,12 +156,12 @@ impl Cmd { // assume they copied in the base64 key bip39::ErrorKind::InvalidWord(_) => key, bip39::ErrorKind::InvalidChecksum => { - bail!("key mnemonic was not valid") + bail!("Key mnemonic is not valid") } bip39::ErrorKind::InvalidKeysize(_) | bip39::ErrorKind::InvalidWordLength(_) | bip39::ErrorKind::InvalidEntropyLength(_, _) => { - bail!("key was not the correct length") + bail!("Key is not the correct length") } } } @@ -97,19 +170,24 @@ impl Cmd { if key.is_empty() { if key_path.exists() { - let bytes = fs_err::read_to_string(&key_path) - .context("existing key file couldn't be read")?; + let bytes = fs_err::read_to_string(&key_path).context(format!( + "Existing key file at '{}' could not be read", + key_path.to_string_lossy() + ))?; if decode_key(bytes).is_err() { - bail!("the key in existing key file was invalid"); + bail!(format!( + "The key in existing key file at '{}' is invalid", + key_path.to_string_lossy() + )); } } else { panic!( - "No key provided. Please use 'atuin key' on your other machine, or recover your key from a backup." + "No key provided and no existing key file found. Please use 'atuin key' on your other machine, or recover your key from a backup" ) } } else if !key_path.exists() { if decode_key(key.clone()).is_err() { - bail!("the specified key was invalid"); + bail!("The specified key is invalid"); } let mut file = File::create(&key_path).await?; @@ -124,7 +202,7 @@ impl Cmd { let encoded = key.clone(); // gonna want to save it in a bit let new_key: [u8; 32] = decode_key(key) - .context("could not decode provided key - is not valid base64")? + .context("Could not decode provided key; is not valid base64-encoded key")? .into(); if new_key != current_key { @@ -138,19 +216,6 @@ impl Cmd { } } - 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 918c89e8..a2f4edfd 100644 --- a/crates/atuin/src/command/client/account/register.rs +++ b/crates/atuin/src/command/client/account/register.rs @@ -1,7 +1,8 @@ use clap::Parser; use eyre::{Result, bail}; -use atuin_client::{api_client, settings::Settings}; +use super::login::or_user_input; +use atuin_client::{api_client, record::sqlite_store::SqliteStore, settings::Settings}; #[derive(Parser, Debug)] pub struct Cmd { @@ -16,18 +17,44 @@ pub struct Cmd { } impl Cmd { - pub async fn run(self, settings: &Settings) -> Result<()> { - run(settings, self.username, self.email, self.password).await + 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<()> { - use super::login::or_user_input; + if let Some(_endpoint) = settings.active_hub_endpoint() { + if settings.hub_session_token().await.is_ok() { + println!("You are already authenticated with Atuin Hub."); + 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.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"); diff --git a/crates/atuin/src/command/client/doctor.rs b/crates/atuin/src/command/client/doctor.rs index 6f9cd875..c2c47a58 100644 --- a/crates/atuin/src/command/client/doctor.rs +++ b/crates/atuin/src/command/client/doctor.rs @@ -248,7 +248,7 @@ struct SyncInfo { impl SyncInfo { pub async fn new(settings: &Settings) -> Self { Self { - cloud: settings.sync_address == "https://api.atuin.sh", + cloud: settings.is_hub_sync(), auto_sync: settings.auto_sync, records: settings.sync.records, last_sync: Settings::last_sync() diff --git a/crates/atuin/src/command/client/store/push.rs b/crates/atuin/src/command/client/store/push.rs index 243dc7ec..23958d7f 100644 --- a/crates/atuin/src/command/client/store/push.rs +++ b/crates/atuin/src/command/client/store/push.rs @@ -42,7 +42,7 @@ impl Push { let client = Client::new( &settings.sync_address, - settings.session_token().await?.as_str(), + settings.sync_auth_token().await?, settings.network_connect_timeout, settings.network_timeout * 10, // we may be deleting a lot of data... so up the // timeout diff --git a/crates/atuin/src/command/client/sync.rs b/crates/atuin/src/command/client/sync.rs index 6063e489..250e98aa 100644 --- a/crates/atuin/src/command/client/sync.rs +++ b/crates/atuin/src/command/client/sync.rs @@ -54,7 +54,7 @@ impl Cmd { Self::Sync { force } => run(&settings, force, db, store).await, Self::Login(l) => l.run(&settings, &store).await, Self::Logout => account::logout::run().await, - Self::Register(r) => r.run(&settings).await, + Self::Register(r) => r.run(&settings, &store).await, Self::Status => status::run(&settings, db).await, Self::Key { base64 } => { use atuin_client::encryption::{encode_key, load_key}; diff --git a/crates/atuin/src/command/client/sync/status.rs b/crates/atuin/src/command/client/sync/status.rs index 2162fa00..54911cc8 100644 --- a/crates/atuin/src/command/client/sync/status.rs +++ b/crates/atuin/src/command/client/sync/status.rs @@ -10,7 +10,7 @@ pub async fn run(settings: &Settings, db: &impl Database) -> Result<()> { let client = api_client::Client::new( &settings.sync_address, - settings.session_token().await?.as_str(), + settings.sync_auth_token().await?, settings.network_connect_timeout, settings.network_timeout, )?; diff --git a/crates/atuin/tests/common/mod.rs b/crates/atuin/tests/common/mod.rs index fa663ef3..0a7c5275 100644 --- a/crates/atuin/tests/common/mod.rs +++ b/crates/atuin/tests/common/mod.rs @@ -81,7 +81,13 @@ pub async fn register_inner<'a>( .await .unwrap(); - api_client::Client::new(address, ®istration_response.session, 5, 30).unwrap() + api_client::Client::new( + address, + api_client::AuthToken::Token(registration_response.session), + 5, + 30, + ) + .unwrap() } #[allow(dead_code)] @@ -94,7 +100,13 @@ pub async fn login(address: &str, username: String, password: String) -> api_cli .await .unwrap(); - api_client::Client::new(address, &login_response.session, 5, 30).unwrap() + api_client::Client::new( + address, + api_client::AuthToken::Token(login_response.session), + 5, + 30, + ) + .unwrap() } #[allow(dead_code)] -- cgit v1.3.1