aboutsummaryrefslogtreecommitdiffstats
path: root/crates
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
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')
-rw-r--r--crates/atuin-ai/src/commands/inline.rs35
-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
-rw-r--r--crates/atuin/src/command/client/account.rs9
-rw-r--r--crates/atuin/src/command/client/account/change_password.rs22
-rw-r--r--crates/atuin/src/command/client/account/delete.rs20
-rw-r--r--crates/atuin/src/command/client/account/link.rs45
-rw-r--r--crates/atuin/src/command/client/account/login.rs117
-rw-r--r--crates/atuin/src/command/client/account/register.rs35
-rw-r--r--crates/atuin/src/command/client/doctor.rs2
-rw-r--r--crates/atuin/src/command/client/store/push.rs2
-rw-r--r--crates/atuin/src/command/client/sync.rs2
-rw-r--r--crates/atuin/src/command/client/sync/status.rs2
-rw-r--r--crates/atuin/tests/common/mod.rs16
19 files changed, 470 insertions, 69 deletions
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<String> {
+async fn ensure_hub_session(settings: &atuin_client::settings::Settings) -> Result<String> {
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<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,
)?;
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<String>,
new_password: Option<String>,
) -> 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<String>,
+
+ #[clap(long, hide = true)]
+ pub from_registration: bool,
}
fn get_input() -> Result<String> {
@@ -35,15 +38,66 @@ fn get_input() -> Result<String> {
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<String>,
email: Option<String>,
password: Option<String>,
) -> 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, &registration_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)]