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/hub.rs | 59 +++++++++++++++++++++++++++++++++++++++--- 1 file changed, 55 insertions(+), 4 deletions(-) (limited to 'crates/atuin-client/src/hub.rs') 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 { -- cgit v1.3.1