diff options
Diffstat (limited to 'crates/atuin-client/src')
| -rw-r--r-- | crates/atuin-client/src/api_client.rs | 37 | ||||
| -rw-r--r-- | crates/atuin-client/src/hub.rs | 59 | ||||
| -rw-r--r-- | crates/atuin-client/src/logout.rs | 1 | ||||
| -rw-r--r-- | crates/atuin-client/src/meta.rs | 2 | ||||
| -rw-r--r-- | crates/atuin-client/src/record/sync.rs | 10 | ||||
| -rw-r--r-- | crates/atuin-client/src/settings.rs | 121 | ||||
| -rw-r--r-- | crates/atuin-client/src/sync.rs | 2 |
7 files changed, 211 insertions, 21 deletions
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<bool> { async fn handle_resp_error(resp: Response) -> Result<Response> { 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<Response> { 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<Response> { impl<'a> Client<'a> { pub fn new( sync_addr: &'a str, - session_token: &str, + auth: AuthToken, connect_timeout: u64, timeout: u64, ) -> Result<Self> { 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<Self> { + pub async fn start(hub_address: &str) -> Result<Self> { 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<Option<String>> { 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<String> { 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<bool> { - 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<String> { + 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<String> { + 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<crate::api_client::AuthToken> { + 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<bool> { 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, )?; |
