aboutsummaryrefslogtreecommitdiffstats
path: root/crates
diff options
context:
space:
mode:
authorMichelle Tilley <michelle@michelletilley.net>2026-03-23 09:33:04 -0700
committerGitHub <noreply@github.com>2026-03-23 09:33:04 -0700
commit7f06ba0ee93eebf4482a7eb5d5d25e9d8a072f9d (patch)
tree20f214ce5d0ac08dc6eee0beb2c3c70128050a8e /crates
parentfeat: hex init nu (#3330) (diff)
downloadatuin-7f06ba0ee93eebf4482a7eb5d5d25e9d8a072f9d.zip
chore: Refactor CLI auth flows and token storage (#3317)
This PR eplaces the binary `is_hub_sync()` auth routing with an explicit `SyncAuth` enum that classifies the client's authentication state at runtime. This fixes a class of bugs where CLI session tokens were silently mis-stored or used with the wrong auth scheme during Hub migration.
Diffstat (limited to 'crates')
-rw-r--r--crates/atuin-client/src/auth.rs46
-rw-r--r--crates/atuin-client/src/settings.rs125
-rw-r--r--crates/atuin-common/src/api.rs8
-rw-r--r--crates/atuin-server/src/handlers/user.rs6
-rw-r--r--crates/atuin/src/command/client/account/login.rs52
-rw-r--r--crates/atuin/src/command/client/account/register.rs47
-rw-r--r--crates/atuin/src/command/client/doctor.rs35
7 files changed, 251 insertions, 68 deletions
diff --git a/crates/atuin-client/src/auth.rs b/crates/atuin-client/src/auth.rs
index 1e638c21..8ea4b8ab 100644
--- a/crates/atuin-client/src/auth.rs
+++ b/crates/atuin-client/src/auth.rs
@@ -18,7 +18,13 @@ static APP_USER_AGENT: &str = concat!("atuin/", env!("CARGO_PKG_VERSION"));
/// Result of an auth operation that may require 2FA.
pub enum AuthResponse {
/// Operation succeeded; for login/register, contains the session token.
- Success { session: String },
+ /// `auth_type` indicates the kind of token: `Some("hub")` for Hub API
+ /// tokens (prefixed `atapi_`), `Some("cli")` for legacy CLI session
+ /// tokens. `None` when the server didn't include the field (old servers).
+ Success {
+ session: String,
+ auth_type: Option<String>,
+ },
/// Two-factor authentication is required; the caller should prompt for a
/// TOTP code and retry with it.
TwoFactorRequired,
@@ -153,6 +159,7 @@ impl AuthClient for LegacyAuthClient {
Ok(AuthResponse::Success {
session: resp.session,
+ auth_type: resp.auth.or(Some("cli".into())),
})
}
@@ -160,6 +167,7 @@ impl AuthClient for LegacyAuthClient {
let resp = crate::api_client::register(&self.address, username, email, password).await?;
Ok(AuthResponse::Success {
session: resp.session,
+ auth_type: resp.auth.or(Some("cli".into())),
})
}
@@ -273,6 +281,7 @@ impl AuthClient for HubAuthClient {
let login: LoginResponse = resp.json().await?;
return Ok(AuthResponse::Success {
session: login.session,
+ auth_type: login.auth,
});
}
@@ -316,6 +325,7 @@ impl AuthClient for HubAuthClient {
let reg: RegisterResponse = resp.json().await?;
return Ok(AuthResponse::Success {
session: reg.session,
+ auth_type: reg.auth,
});
}
@@ -332,10 +342,19 @@ impl AuthClient for HubAuthClient {
new_password: &str,
totp_code: Option<&str>,
) -> Result<MutateResponse> {
- let hub_token = self
- .hub_token
- .as_deref()
- .ok_or_else(|| eyre::eyre!("Not logged in to Hub"))?;
+ let hub_token = self.hub_token.as_deref().ok_or_else(|| {
+ eyre::eyre!(
+ "Not logged in to Atuin Hub. \
+ Please run 'atuin login' to authenticate."
+ )
+ })?;
+
+ if !hub_token.starts_with("atapi_") {
+ bail!(
+ "Your Hub session token is invalid. \
+ Please run 'atuin login' to re-authenticate with Atuin Hub."
+ );
+ }
ensure_crypto_provider();
let url = make_url(&self.address, "/api/v0/account/password")?;
@@ -385,10 +404,19 @@ impl AuthClient for HubAuthClient {
password: &str,
totp_code: Option<&str>,
) -> Result<MutateResponse> {
- let hub_token = self
- .hub_token
- .as_deref()
- .ok_or_else(|| eyre::eyre!("Not logged in to Hub"))?;
+ let hub_token = self.hub_token.as_deref().ok_or_else(|| {
+ eyre::eyre!(
+ "Not logged in to Atuin Hub. \
+ Please run 'atuin login' to authenticate."
+ )
+ })?;
+
+ if !hub_token.starts_with("atapi_") {
+ bail!(
+ "Your Hub session token is invalid. \
+ Please run 'atuin login' to re-authenticate with Atuin Hub."
+ );
+ }
ensure_crypto_provider();
let url = make_url(&self.address, "/api/v0/account")?;
diff --git a/crates/atuin-client/src/settings.rs b/crates/atuin-client/src/settings.rs
index becf72db..5b18d9ea 100644
--- a/crates/atuin-client/src/settings.rs
+++ b/crates/atuin-client/src/settings.rs
@@ -383,6 +383,45 @@ pub enum SyncProtocol {
Auto,
}
+/// Resolved authentication state for sync operations.
+///
+/// Determined at runtime by examining which tokens are available and what
+/// server the client is configured to talk to. Operations use this to pick
+/// the right auth header and endpoint style.
+#[cfg(feature = "sync")]
+#[derive(Debug, Clone)]
+pub enum SyncAuth {
+ /// Self-hosted Rust server. Uses `Authorization: Token <session>` and
+ /// legacy endpoints.
+ Legacy { token: String },
+ /// Hub with a valid Hub API token (`atapi_*`). Uses
+ /// `Authorization: Bearer <token>` and v0 endpoints.
+ Hub { token: String },
+ /// Targeting Hub but only has a CLI session token. Uses
+ /// `Authorization: Token <session>` against compat/record endpoints.
+ /// Sync, password change, and account deletion still work, but the user
+ /// should be nudged to run `atuin login` for full Hub auth.
+ HubViaCli { token: String },
+ /// Not authenticated at all. Contains an actionable user-facing message.
+ NotLoggedIn { reason: String },
+}
+
+#[cfg(feature = "sync")]
+impl SyncAuth {
+ /// Convert into the auth token type used by the API client.
+ ///
+ /// Returns an error with an actionable message for `NotLoggedIn`.
+ pub fn into_auth_token(self) -> Result<crate::api_client::AuthToken> {
+ use crate::api_client::AuthToken;
+ match self {
+ SyncAuth::Legacy { token } => Ok(AuthToken::Token(token)),
+ SyncAuth::Hub { token } => Ok(AuthToken::Bearer(token)),
+ SyncAuth::HubViaCli { token } => Ok(AuthToken::Token(token)),
+ SyncAuth::NotLoggedIn { reason } => Err(eyre!(reason)),
+ }
+ }
+}
+
#[derive(Clone, Debug, Deserialize, Default, Serialize)]
pub struct Keys {
pub scroll_exits: bool,
@@ -1239,40 +1278,74 @@ impl Settings {
}
}
- /// 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.
+ /// Examines the configured sync target and available tokens to determine
+ /// the correct auth strategy. Also performs cleanup of mis-stored tokens
+ /// (e.g. a CLI token incorrectly saved in the Hub session slot).
#[cfg(feature = "sync")]
- pub async fn sync_auth_token(&self) -> Result<crate::api_client::AuthToken> {
- use crate::api_client::AuthToken;
+ pub async fn resolve_sync_auth(&self) -> SyncAuth {
+ let meta = match Self::meta_store().await {
+ Ok(m) => m,
+ Err(e) => {
+ return SyncAuth::NotLoggedIn {
+ reason: format!("Failed to open meta store: {e}"),
+ };
+ }
+ };
- let meta = Self::meta_store().await?;
+ if !self.is_hub_sync() {
+ // Self-hosted / legacy server
+ return match meta.session_token().await {
+ Ok(Some(token)) => SyncAuth::Legacy { token },
+ _ => SyncAuth::NotLoggedIn {
+ reason: "Not logged in. Run 'atuin login' to authenticate \
+ with your sync server."
+ .into(),
+ },
+ };
+ }
- // 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));
+ // Targeting Hub — check for a valid Hub API token first
+ if let Ok(Some(hub_token)) = meta.hub_session_token().await {
+ if hub_token.starts_with("atapi_") {
+ return SyncAuth::Hub { token: hub_token };
+ }
+
+ // A non-atapi_ token in the hub_session slot is a mis-stored CLI
+ // token (from the migration-fallback bug). Move it to the CLI
+ // session slot if that slot is empty, then clear hub_session
+ // only if the move succeeded.
+ if let Ok(None) = meta.session_token().await {
+ if meta.save_session(&hub_token).await.is_ok() {
+ let _ = meta.delete_hub_session().await;
+ }
+ } else {
+ // CLI slot already has a token; just clear the bad hub_session
+ let _ = meta.delete_hub_session().await;
+ }
+ // Fall through to check CLI token below
}
- // 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."
- )),
+ // No valid Hub token — check for a CLI session token
+ match meta.session_token().await {
+ Ok(Some(token)) => SyncAuth::HubViaCli { token },
+ _ => SyncAuth::NotLoggedIn {
+ reason: "Not logged in. Run 'atuin login' or 'atuin register' \
+ to authenticate."
+ .into(),
+ },
}
}
+ /// Returns the appropriate auth token for sync operations.
+ ///
+ /// Delegates to [`resolve_sync_auth`] and converts the result to an
+ /// `AuthToken`. Callers that need to distinguish between auth states
+ /// (e.g. to show different UI) should call `resolve_sync_auth` directly.
+ #[cfg(feature = "sync")]
+ pub async fn sync_auth_token(&self) -> Result<crate::api_client::AuthToken> {
+ self.resolve_sync_auth().await.into_auth_token()
+ }
+
#[cfg(feature = "check-update")]
async fn needs_update_check(&self) -> Result<bool> {
let last_check = Settings::last_version_check().await?;
diff --git a/crates/atuin-common/src/api.rs b/crates/atuin-common/src/api.rs
index efc17163..1a9f348c 100644
--- a/crates/atuin-common/src/api.rs
+++ b/crates/atuin-common/src/api.rs
@@ -26,6 +26,10 @@ pub struct RegisterRequest {
#[derive(Debug, Serialize, Deserialize)]
pub struct RegisterResponse {
pub session: String,
+ /// Auth type: "hub" for Hub API tokens, "cli" for legacy CLI session tokens.
+ /// Old servers that don't return this field will deserialize as None.
+ #[serde(default)]
+ pub auth: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
@@ -49,6 +53,10 @@ pub struct LoginRequest {
#[derive(Debug, Serialize, Deserialize)]
pub struct LoginResponse {
pub session: String,
+ /// Auth type: "hub" for Hub API tokens, "cli" for legacy CLI session tokens.
+ /// Old servers that don't return this field will deserialize as None.
+ #[serde(default)]
+ pub auth: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
diff --git a/crates/atuin-server/src/handlers/user.rs b/crates/atuin-server/src/handlers/user.rs
index 6436e327..dda7a381 100644
--- a/crates/atuin-server/src/handlers/user.rs
+++ b/crates/atuin-server/src/handlers/user.rs
@@ -150,7 +150,10 @@ pub async fn register<DB: Database>(
counter!("atuin_users_registered").increment(1);
match db.add_session(&new_session).await {
- Ok(_) => Ok(Json(RegisterResponse { session: token })),
+ Ok(_) => Ok(Json(RegisterResponse {
+ session: token,
+ auth: Some("cli".into()),
+ })),
Err(e) => {
error!("failed to add session: {}", e);
Err(ErrorResponse::reply("failed to register user")
@@ -254,6 +257,7 @@ pub async fn login<DB: Database>(
Ok(Json(LoginResponse {
session: session.token,
+ auth: Some("cli".into()),
}))
}
diff --git a/crates/atuin/src/command/client/account/login.rs b/crates/atuin/src/command/client/account/login.rs
index c9ba74c9..70cf3a72 100644
--- a/crates/atuin/src/command/client/account/login.rs
+++ b/crates/atuin/src/command/client/account/login.rs
@@ -9,7 +9,7 @@ use atuin_client::{
encryption::{Key, decode_key, encode_key, load_key},
record::sqlite_store::SqliteStore,
record::store::Store,
- settings::Settings,
+ settings::{Settings, SyncAuth},
};
use rpassword::prompt_password;
@@ -41,14 +41,24 @@ fn get_input() -> Result<String> {
impl Cmd {
pub async fn run(&self, settings: &Settings, store: &SqliteStore) -> Result<()> {
- if settings.logged_in().await? {
- if settings.is_hub_sync() {
+ match settings.resolve_sync_auth().await {
+ SyncAuth::Hub { .. } => {
println!("You are authenticated with Atuin Hub.");
- } else {
- println!("You are already logged in.");
+ println!("Run 'atuin logout' to log out.");
+ return Ok(());
+ }
+ SyncAuth::Legacy { .. } => {
+ println!("You are logged in to your sync server.");
+ println!("Run 'atuin logout' to log out.");
+ return Ok(());
+ }
+ SyncAuth::HubViaCli { .. } => {
+ println!(
+ "You have a legacy sync session. \
+ Continuing login to upgrade to full Hub authentication."
+ );
}
- println!("Run 'atuin logout' to log out.");
- return Ok(());
+ SyncAuth::NotLoggedIn { .. } => {}
}
if settings.is_hub_sync() {
@@ -58,8 +68,7 @@ impl Cmd {
}
}
- /// Hub login: use the browser OAuth flow unless all three flags
- /// (username, password, key) were provided for headless/CI use.
+ /// Hub login: use the browser flow unless the username was provided for headless use.
async fn run_hub_login(&self, settings: &Settings, store: &SqliteStore) -> Result<()> {
let endpoint = settings.active_hub_endpoint().unwrap_or_default();
@@ -72,23 +81,32 @@ impl Cmd {
let password = self.password.clone().unwrap_or_else(read_user_password);
let mut totp_code = self.totp_code.clone();
- let session = loop {
+ let (session, auth_type) = loop {
let response = client
.login(username, &password, totp_code.as_deref())
.await?;
match response {
- AuthResponse::Success { session } => break session,
+ AuthResponse::Success { session, auth_type } => break (session, auth_type),
AuthResponse::TwoFactorRequired => {
totp_code = Some(or_user_input(None, "two-factor code"));
}
}
};
- Settings::meta_store()
- .await?
- .save_hub_session(&session)
- .await?;
+ let meta = Settings::meta_store().await?;
+ let is_hub_token = auth_type.as_deref() == Some("hub") || session.starts_with("atapi_");
+
+ if is_hub_token {
+ meta.save_hub_session(&session).await?;
+ } else {
+ meta.save_session(&session).await?;
+ println!("\nNote: Your account has not been fully migrated to Atuin Hub.");
+ println!(
+ "Sync will continue to work, but you can visit hub.atuin.sh \
+ to create an account and link it to your existing CLI account."
+ );
+ }
} else {
// Interactive login via browser OAuth flow.
if self.from_registration {
@@ -107,7 +125,7 @@ impl Cmd {
tracing::debug!("Could not link CLI account to Hub: {}", e);
}
- println!("Successfully authenticated with Atuin Hub.");
+ println!("Successfully authenticated.");
Ok(())
}
@@ -123,7 +141,7 @@ impl Cmd {
let response = client.login(&username, &password, None).await?;
match response {
- AuthResponse::Success { session } => {
+ AuthResponse::Success { session, .. } => {
Settings::meta_store().await?.save_session(&session).await?;
}
AuthResponse::TwoFactorRequired => {
diff --git a/crates/atuin/src/command/client/account/register.rs b/crates/atuin/src/command/client/account/register.rs
index 03a97512..f01427c0 100644
--- a/crates/atuin/src/command/client/account/register.rs
+++ b/crates/atuin/src/command/client/account/register.rs
@@ -5,7 +5,7 @@ use super::login::or_user_input;
use atuin_client::{
auth::{self, AuthResponse},
record::sqlite_store::SqliteStore,
- settings::Settings,
+ settings::{Settings, SyncAuth},
};
#[derive(Parser, Debug)]
@@ -21,15 +21,28 @@ pub struct Cmd {
}
impl Cmd {
+ #[allow(clippy::too_many_lines)]
pub async fn run(&self, settings: &Settings, store: &SqliteStore) -> Result<()> {
- if settings.logged_in().await? {
- if settings.is_hub_sync() {
+ match settings.resolve_sync_auth().await {
+ SyncAuth::Hub { .. } => {
println!("You are already authenticated with Atuin Hub.");
- } else {
+ println!("Run 'atuin logout' to log out.");
+ return Ok(());
+ }
+ SyncAuth::Legacy { .. } => {
println!("You are already logged in.");
+ println!("Run 'atuin logout' to log out.");
+ return Ok(());
+ }
+ SyncAuth::HubViaCli { .. } => {
+ println!(
+ "You already have a sync session. \
+ Run 'atuin login' to upgrade to full Hub authentication."
+ );
+ println!("Run 'atuin logout' first if you want to register a new account.");
+ return Ok(());
}
- println!("Run 'atuin logout' to log out.");
- return Ok(());
+ SyncAuth::NotLoggedIn { .. } => {}
}
if settings.is_hub_sync() {
@@ -61,11 +74,23 @@ impl Cmd {
let response = client.register(username, email, password).await?;
match response {
- AuthResponse::Success { session } => {
- Settings::meta_store()
- .await?
- .save_hub_session(&session)
- .await?;
+ AuthResponse::Success { session, auth_type } => {
+ let meta = Settings::meta_store().await?;
+ let is_hub_token =
+ auth_type.as_deref() == Some("hub") || session.starts_with("atapi_");
+
+ if is_hub_token {
+ meta.save_hub_session(&session).await?;
+ } else {
+ meta.save_session(&session).await?;
+ println!(
+ "\nNote: Your account has not been fully migrated to Atuin Hub."
+ );
+ println!(
+ "Sync will continue to work, but you can visit hub.atuin.sh \
+ to create a new Hub account and link it to your existing CLI account."
+ );
+ }
}
AuthResponse::TwoFactorRequired => {
bail!("unexpected two-factor requirement during registration");
diff --git a/crates/atuin/src/command/client/doctor.rs b/crates/atuin/src/command/client/doctor.rs
index c2c47a58..eea302b8 100644
--- a/crates/atuin/src/command/client/doctor.rs
+++ b/crates/atuin/src/command/client/doctor.rs
@@ -236,9 +236,7 @@ impl SystemInfo {
#[derive(Debug, Serialize)]
struct SyncInfo {
- /// Whether the main Atuin sync server is in use
- /// I'm just calling it Atuin Cloud for lack of a better name atm
- pub cloud: bool,
+ pub auth_state: String,
pub records: bool,
pub auto_sync: bool,
@@ -247,8 +245,37 @@ struct SyncInfo {
impl SyncInfo {
pub async fn new(settings: &Settings) -> Self {
+ // Build auth state description from raw token state without calling
+ // resolve_sync_auth(), which has side effects (token migration cleanup)
+ // that a diagnostic command should not trigger.
+ let meta = Settings::meta_store().await.ok();
+ let has_hub_token = match &meta {
+ Some(m) => m
+ .hub_session_token()
+ .await
+ .ok()
+ .flatten()
+ .filter(|t| t.starts_with("atapi_"))
+ .is_some(),
+ None => false,
+ };
+ let has_cli_token = match &meta {
+ Some(m) => m.session_token().await.ok().flatten().is_some(),
+ None => false,
+ };
+
+ let auth_state = if has_hub_token {
+ "Hub (authenticated)".into()
+ } else if settings.is_hub_sync() && has_cli_token {
+ "Hub (legacy token \u{2014} run 'atuin login' to upgrade)".into()
+ } else if !settings.is_hub_sync() && has_cli_token {
+ "Self-hosted (authenticated)".into()
+ } else {
+ "Not authenticated".into()
+ };
+
Self {
- cloud: settings.is_hub_sync(),
+ auth_state,
auto_sync: settings.auto_sync,
records: settings.sync.records,
last_sync: Settings::last_sync()