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-client/src/settings.rs | 121 ++++++++++++++++++++++++++++++++++-- 1 file changed, 117 insertions(+), 4 deletions(-) (limited to 'crates/atuin-client/src/settings.rs') 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")? -- cgit v1.3.1