aboutsummaryrefslogtreecommitdiffstats
path: root/crates/atuin-client/src
diff options
context:
space:
mode:
authorMichelle Tilley <michelle@michelletilley.net>2026-03-11 08:50:17 -0700
committerGitHub <noreply@github.com>2026-03-11 08:50:17 -0700
commitc8fd7d3d8f2c93c24a4a5fc41361dfed8714e73a (patch)
treee88cf8afa702be0925c6f606df2bb3c270679fce /crates/atuin-client/src
parentchore: update changelog (diff)
downloadatuin-c8fd7d3d8f2c93c24a4a5fc41361dfed8714e73a.zip
feat: Allow authenticating with Atuin Hub (#3237)
## 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 <ellie@elliehuxtable.com>
Diffstat (limited to 'crates/atuin-client/src')
-rw-r--r--crates/atuin-client/src/api_client.rs37
-rw-r--r--crates/atuin-client/src/hub.rs59
-rw-r--r--crates/atuin-client/src/logout.rs1
-rw-r--r--crates/atuin-client/src/meta.rs2
-rw-r--r--crates/atuin-client/src/record/sync.rs10
-rw-r--r--crates/atuin-client/src/settings.rs121
-rw-r--r--crates/atuin-client/src/sync.rs2
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,
)?;