Skip to content

Commit 00a7108

Browse files
committed
feat: add hub sync auth
1 parent dc5cb30 commit 00a7108

File tree

11 files changed

+333
-25
lines changed

11 files changed

+333
-25
lines changed

Cargo.lock

Lines changed: 31 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/atuin-client/src/api_client.rs

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,10 @@ use atuin_common::{
1414
};
1515
use atuin_common::{
1616
api::{
17-
AddHistoryRequest, ChangePasswordRequest, CountResponse, DeleteHistoryRequest,
18-
ErrorResponse, LoginRequest, LoginResponse, MeResponse, RegisterResponse,
19-
SendVerificationResponse, StatusResponse, SyncHistoryResponse, VerificationTokenRequest,
20-
VerificationTokenResponse,
17+
AddHistoryRequest, ChangePasswordRequest, CliCodeResponse, CliVerifyResponse,
18+
CountResponse, DeleteHistoryRequest, ErrorResponse, LoginRequest, LoginResponse,
19+
MeResponse, RegisterResponse, SendVerificationResponse, StatusResponse,
20+
SyncHistoryResponse, VerificationTokenRequest, VerificationTokenResponse,
2121
},
2222
record::RecordStatus,
2323
};
@@ -111,6 +111,40 @@ pub async fn login(address: &str, req: LoginRequest) -> Result<LoginResponse> {
111111
Ok(session)
112112
}
113113

114+
/// Request a CLI auth code from the Atuin Hub
115+
pub async fn hub_request_code(address: &str) -> Result<CliCodeResponse> {
116+
let url = make_url(address, "/auth/cli/code")?;
117+
let client = reqwest::Client::new();
118+
119+
let resp = client
120+
.post(url)
121+
.header(USER_AGENT, APP_USER_AGENT)
122+
.header(ATUIN_HEADER_VERSION, ATUIN_CARGO_VERSION)
123+
.send()
124+
.await?;
125+
let resp = handle_resp_error(resp).await?;
126+
127+
let code_response = resp.json::<CliCodeResponse>().await?;
128+
Ok(code_response)
129+
}
130+
131+
/// Poll to verify the CLI auth code and get the session token
132+
pub async fn hub_verify_code(address: &str, code: &str) -> Result<CliVerifyResponse> {
133+
let url = make_url(address, &format!("/auth/cli/verify?code={}", code))?;
134+
let client = reqwest::Client::new();
135+
136+
let resp = client
137+
.get(url)
138+
.header(USER_AGENT, APP_USER_AGENT)
139+
.header(ATUIN_HEADER_VERSION, ATUIN_CARGO_VERSION)
140+
.send()
141+
.await?;
142+
let resp = handle_resp_error(resp).await?;
143+
144+
let verify_response = resp.json::<CliVerifyResponse>().await?;
145+
Ok(verify_response)
146+
}
147+
114148
#[cfg(feature = "check-update")]
115149
pub async fn latest_version() -> Result<Version> {
116150
use atuin_common::api::IndexResponse;

crates/atuin-client/src/register.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ use tokio::io::AsyncWriteExt;
44

55
use crate::{api_client, settings::Settings};
66

7-
pub async fn register(
7+
pub async fn register_classic(
88
settings: &Settings,
99
username: String,
1010
email: String,

crates/atuin-client/src/settings.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -458,7 +458,19 @@ pub struct Settings {
458458
pub style: Style,
459459
pub auto_sync: bool,
460460
pub update_check: bool,
461+
462+
/// Enables using the Atuin Hub for syncing. This is an alternative sync server, providing
463+
/// several auth methods, profile pages, password recovery, and more.
464+
///
465+
/// The hub is not currently self-hostable, but will potentially be opened up in the future.
466+
pub hub_sync: bool,
467+
468+
/// The address of the Atuin Hub. Only used when `hub_sync` is enabled.
469+
pub hub_address: String,
470+
471+
/// The sync address for atuin. If `hub_sync` is set, this will be ignored.
461472
pub sync_address: String,
473+
462474
pub sync_frequency: String,
463475
pub db_path: String,
464476
pub record_store_path: String,
@@ -768,6 +780,8 @@ impl Settings {
768780
.set_default("timezone", "local")?
769781
.set_default("auto_sync", true)?
770782
.set_default("update_check", cfg!(feature = "check-update"))?
783+
.set_default("hub_sync", false)?
784+
.set_default("hub_address", "https://hub.atuin.sh")?
771785
.set_default("sync_address", "https://api.atuin.sh")?
772786
.set_default("sync_frequency", "5m")?
773787
.set_default("search_mode", "fuzzy")?

crates/atuin-common/src/api.rs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,3 +136,18 @@ pub struct MessageResponse {
136136
pub struct MeResponse {
137137
pub username: String,
138138
}
139+
140+
// Hub CLI authentication types
141+
142+
/// Response from POST /auth/cli/code - generates a code for CLI auth
143+
#[derive(Debug, Serialize, Deserialize)]
144+
pub struct CliCodeResponse {
145+
pub code: String,
146+
}
147+
148+
/// Response from GET /auth/cli/verify?code=<code> - polls for authorization
149+
#[derive(Debug, Serialize, Deserialize)]
150+
pub struct CliVerifyResponse {
151+
/// Session token, present only when authorization is complete
152+
pub session: Option<String>,
153+
}

crates/atuin/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ tiny-bip39 = "1"
8383
futures-util = "0.3"
8484
fuzzy-matcher = "0.3.7"
8585
colored = "2.0.4"
86+
open = "5"
8687
ratatui = "0.29.0"
8788
tracing = "0.1"
8889
tracing-subscriber = { workspace = true }

crates/atuin/src/command/client/account.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ use atuin_client::settings::Settings;
66

77
pub mod change_password;
88
pub mod delete;
9+
mod hub;
910
pub mod login;
1011
pub mod logout;
1112
pub mod register;
@@ -42,7 +43,7 @@ impl Cmd {
4243
pub async fn run(self, settings: Settings, store: SqliteStore) -> Result<()> {
4344
match self.command {
4445
Commands::Login(l) => l.run(&settings, &store).await,
45-
Commands::Register(r) => r.run(&settings).await,
46+
Commands::Register(r) => r.run(&settings, &store).await,
4647
Commands::Logout => logout::run(&settings),
4748
Commands::Delete => delete::run(&settings).await,
4849
Commands::ChangePassword(c) => c.run(&settings).await,
Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
use std::io::{self, Write};
2+
use std::path::PathBuf;
3+
use std::time::Duration;
4+
5+
use eyre::{bail, Context, Result};
6+
use indicatif::{ProgressBar, ProgressStyle};
7+
use tokio::fs::File;
8+
use tokio::io::AsyncWriteExt;
9+
10+
use atuin_client::api_client;
11+
use atuin_client::encryption::{decode_key, encode_key, load_key, Key};
12+
use atuin_client::record::sqlite_store::SqliteStore;
13+
use atuin_client::record::store::Store;
14+
use atuin_client::settings::Settings;
15+
16+
/// Timeout for the entire auth flow (10 minutes)
17+
const AUTH_TIMEOUT: Duration = Duration::from_secs(600);
18+
/// How often to poll for verification
19+
const POLL_INTERVAL: Duration = Duration::from_secs(2);
20+
21+
/// Run the Hub authentication flow.
22+
///
23+
/// This is used by both `register` and `login` commands when `hub_sync` is enabled.
24+
/// The flow is identical for both since the Hub web UI handles registration vs login.
25+
pub async fn run(settings: &Settings, store: &SqliteStore) -> Result<()> {
26+
println!("Authenticating with Atuin Hub...");
27+
28+
// 1. Request a code from the hub
29+
let code_response = api_client::hub_request_code(&settings.hub_address)
30+
.await
31+
.context("Failed to request authentication code from Hub")?;
32+
33+
let code = &code_response.code;
34+
let auth_url = format!("{}/auth/cli?code={}", settings.hub_address, code);
35+
36+
// 2. Try to open the browser and print the URL regardless
37+
println!("\nOpening your browser to complete authentication...");
38+
println!("If it doesn't open, visit this URL:\n");
39+
println!(" {auth_url}\n");
40+
41+
if let Err(e) = open::that(&auth_url) {
42+
tracing::debug!("Failed to open browser: {}", e);
43+
}
44+
45+
// 3. Poll for verification with a spinner
46+
let spinner = ProgressBar::new_spinner();
47+
spinner.set_style(
48+
ProgressStyle::default_spinner()
49+
.tick_chars("⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏")
50+
.template("{spinner:.cyan} {msg}")
51+
.expect("valid template"),
52+
);
53+
spinner.set_message("Waiting for authorization...");
54+
spinner.enable_steady_tick(Duration::from_millis(100));
55+
56+
let start = std::time::Instant::now();
57+
let session = loop {
58+
if start.elapsed() > AUTH_TIMEOUT {
59+
spinner.finish_with_message("Timed out");
60+
bail!("Authentication timed out. Please try again.");
61+
}
62+
63+
tokio::time::sleep(POLL_INTERVAL).await;
64+
65+
match api_client::hub_verify_code(&settings.hub_address, code).await {
66+
Ok(verify_response) => {
67+
if let Some(session) = verify_response.session {
68+
spinner.finish_with_message("Authorized!");
69+
break session;
70+
}
71+
// Still pending, continue polling
72+
}
73+
Err(e) => {
74+
// Log the error but keep polling - could be transient
75+
tracing::debug!("Verification poll failed: {}", e);
76+
}
77+
}
78+
};
79+
80+
// 4. Save the session token
81+
let session_path = settings.session_path.as_str();
82+
let mut file = File::create(session_path)
83+
.await
84+
.context("Failed to create session file")?;
85+
file.write_all(session.as_bytes())
86+
.await
87+
.context("Failed to write session file")?;
88+
89+
// 5. Handle encryption key - always prompt
90+
// Users will always have a key file by this point (created on first atuin usage)
91+
// But it might not be the right key for this account if they registered elsewhere
92+
let key_path = PathBuf::from(settings.key_path.as_str());
93+
94+
println!();
95+
println!(
96+
"If you have already registered on another machine, you will need your encryption key."
97+
);
98+
println!("Run 'atuin key' on your other machine to retrieve it.");
99+
println!();
100+
101+
let key_input = read_key_input()?;
102+
103+
if let Some(encoded_key) = key_input {
104+
// User provided a key - check if we need to re-encrypt
105+
let current_key: [u8; 32] = load_key(settings)?.into();
106+
let new_key: [u8; 32] = decode_key(encoded_key.clone())
107+
.context("Could not decode provided key")?
108+
.into();
109+
110+
if new_key != current_key {
111+
println!("\nRe-encrypting local store with new key...");
112+
113+
store.re_encrypt(&current_key, &new_key).await?;
114+
115+
println!("Writing new key");
116+
let mut file = File::create(&key_path)
117+
.await
118+
.context("Failed to create key file")?;
119+
file.write_all(encoded_key.as_bytes())
120+
.await
121+
.context("Failed to write key file")?;
122+
}
123+
}
124+
// If user pressed Enter, we just use the existing key (nothing to do)
125+
126+
println!("\nAuthentication successful!");
127+
println!();
128+
println!("IMPORTANT: Please make a note of your key (run 'atuin key') and keep it safe.");
129+
println!("You will need it to log in on other devices, and we cannot help recover it if you lose it.");
130+
131+
Ok(())
132+
}
133+
134+
/// Prompt the user for an encryption key.
135+
/// Returns `Some(encoded_key)` if they provided one, None if they pressed Enter.
136+
fn read_key_input() -> Result<Option<String>> {
137+
print!("Enter your encryption key, or press Enter if you don't already have one: ");
138+
io::stdout().flush()?;
139+
140+
let mut input = String::new();
141+
io::stdin().read_line(&mut input)?;
142+
let input = input.trim();
143+
144+
if input.is_empty() {
145+
return Ok(None);
146+
}
147+
148+
// The key may be EITHER base64, or a bip mnemonic
149+
// Try to normalize to base64
150+
let encoded = match bip39::Mnemonic::from_phrase(input, bip39::Language::English) {
151+
Ok(mnemonic) => encode_key(Key::from_slice(mnemonic.entropy()))?,
152+
Err(err) => {
153+
match err.downcast_ref::<bip39::ErrorKind>() {
154+
Some(bip_err) => {
155+
match bip_err {
156+
// Not a valid mnemonic word - assume they copied the base64 key
157+
bip39::ErrorKind::InvalidWord => input.to_string(),
158+
bip39::ErrorKind::InvalidChecksum => {
159+
bail!("Key mnemonic was not valid")
160+
}
161+
bip39::ErrorKind::InvalidKeysize(_)
162+
| bip39::ErrorKind::InvalidWordLength(_)
163+
| bip39::ErrorKind::InvalidEntropyLength(_, _) => {
164+
bail!("Key was not the correct length")
165+
}
166+
}
167+
}
168+
_ => {
169+
// Unknown error - assume they copied the base64 key
170+
input.to_string()
171+
}
172+
}
173+
}
174+
};
175+
176+
// Validate the key
177+
if decode_key(encoded.clone()).is_err() {
178+
bail!("The provided key was invalid");
179+
}
180+
181+
Ok(Some(encoded))
182+
}

0 commit comments

Comments
 (0)