Skip to main content

atuin_client/
hub.rs

1//! Hub authentication support for Atuin
2//!
3//! This module provides programmatic access to the Atuin Hub authentication flow.
4//! It can be used by other crates (like atuin-ai) to authenticate with the Hub
5//! and obtain session tokens.
6//!
7//! Hub authentication is separate from sync authentication - users can have both
8//! a sync session (for history sync) and a hub session (for Hub-specific features
9//! like AI).
10
11use std::time::Duration;
12
13use eyre::{Context, Result, bail};
14use reqwest::{StatusCode, Url, header::USER_AGENT};
15
16use atuin_common::{
17    api::{
18        ATUIN_CARGO_VERSION, ATUIN_HEADER_VERSION, CliCodeResponse, CliVerifyResponse,
19        ErrorResponse,
20    },
21    tls::ensure_crypto_provider,
22};
23
24use crate::settings::Settings;
25
26static APP_USER_AGENT: &str = concat!("atuin/", env!("CARGO_PKG_VERSION"));
27
28/// The result of starting a hub authentication flow
29#[derive(Debug, Clone)]
30pub struct HubAuthSession {
31    /// The code to be verified
32    pub code: String,
33    /// The URL the user should visit to authenticate
34    pub auth_url: String,
35    /// The hub address being used
36    pub hub_address: String,
37}
38
39/// The result of polling for hub auth completion
40#[derive(Debug, Clone)]
41pub enum HubAuthStatus {
42    /// Still waiting for user authorization
43    Pending,
44    /// Authorization complete, contains the session token
45    Complete(String),
46    /// Authorization failed with an error
47    Failed(String),
48}
49
50/// Default poll interval for checking auth status
51pub const DEFAULT_POLL_INTERVAL: Duration = Duration::from_secs(2);
52
53/// Default timeout for the entire auth flow
54pub const DEFAULT_AUTH_TIMEOUT: Duration = Duration::from_secs(600);
55
56impl HubAuthSession {
57    /// Start a new hub authentication session
58    ///
59    /// Returns a session containing the code and auth URL that the user should visit.
60    pub async fn start(hub_address: &str) -> Result<Self> {
61        debug!("Starting Hub authentication process...");
62
63        let hub_address = hub_address.trim_end_matches('/');
64        let code_response = request_code(hub_address)
65            .await
66            .context("Failed to request authentication code from Hub")?;
67
68        debug!("Received code from Hub");
69
70        let code = code_response.code;
71        let auth_url = format!("{}/auth/cli?code={}", hub_address, code);
72
73        Ok(Self {
74            code,
75            auth_url,
76            hub_address: hub_address.to_string(),
77        })
78    }
79
80    /// Poll for the authentication status
81    ///
82    /// Returns the current status of the authentication flow.
83    pub async fn poll(&self) -> Result<HubAuthStatus> {
84        match verify_code(&self.hub_address, &self.code).await {
85            Ok(response) => {
86                if let Some(token) = response.token {
87                    debug!("Authentication complete, received token");
88                    Ok(HubAuthStatus::Complete(token))
89                } else if let Some(error) = response.error {
90                    error!("Authentication failed: {}", error);
91                    Ok(HubAuthStatus::Failed(error))
92                } else {
93                    Ok(HubAuthStatus::Pending)
94                }
95            }
96            Err(e) => {
97                // Transient errors shouldn't fail the whole flow
98                log::debug!("Verification poll failed: {}", e);
99                Ok(HubAuthStatus::Pending)
100            }
101        }
102    }
103
104    /// Poll until completion or timeout
105    ///
106    /// This is a convenience method that polls repeatedly until the auth completes
107    /// or times out.
108    pub async fn wait_for_completion(
109        &self,
110        timeout: Duration,
111        poll_interval: Duration,
112    ) -> Result<String> {
113        let start = std::time::Instant::now();
114
115        debug!("Polling for Hub authentication completion...");
116
117        loop {
118            if start.elapsed() > timeout {
119                warn!("Authentication loop exited due to timeout");
120                bail!("Authentication timed out. Please try again.");
121            }
122
123            match self.poll().await? {
124                HubAuthStatus::Complete(token) => return Ok(token),
125                HubAuthStatus::Failed(error) => bail!("Authentication failed: {}", error),
126                HubAuthStatus::Pending => {
127                    tokio::time::sleep(poll_interval).await;
128                }
129            }
130        }
131    }
132}
133
134/// Save a hub session token
135///
136/// This saves the token to the meta store so it can be used for subsequent Hub API calls.
137/// Note: This is separate from the sync session token.
138pub async fn save_session(token: &str) -> Result<()> {
139    Settings::meta_store()
140        .await?
141        .save_hub_session(token)
142        .await
143        .context("Failed to save hub session")
144}
145
146/// Delete the hub session token (logout from Hub)
147pub async fn delete_session() -> Result<()> {
148    Settings::meta_store()
149        .await?
150        .delete_hub_session()
151        .await
152        .context("Failed to delete hub session")
153}
154
155/// Check if the user is logged in with Hub authentication
156///
157/// Returns true if the user has a valid Hub session token.
158/// This is independent of whether they have a sync session.
159pub async fn is_logged_in() -> Result<bool> {
160    Settings::meta_store().await?.hub_logged_in().await
161}
162
163/// Get the hub session token if available
164///
165/// Returns the Hub session token if the user is logged in with Hub auth,
166/// or None if not logged in.
167pub async fn get_session_token() -> Result<Option<String>> {
168    Settings::meta_store().await?.hub_session_token().await
169}
170
171/// Link an existing CLI sync account to the current Hub user.
172///
173/// This associates the CLI's sync records with the Hub account, enabling
174/// unified authentication. After linking:
175/// - The Hub token can be used for sync operations
176/// - Records are migrated to be accessible via Hub auth
177///
178/// Requires:
179/// - A valid Hub session (user must be logged in to Hub)
180/// - A valid CLI session token to link
181///
182/// Returns Ok(()) on success, or an error if:
183/// - Not logged in to Hub
184/// - CLI token is invalid
185/// - CLI account is already linked to a different Hub account
186pub async fn link_account(hub_address: &str, cli_token: &str) -> Result<()> {
187    let hub_token = get_session_token()
188        .await?
189        .ok_or_else(|| eyre::eyre!("Not logged in to Hub - cannot link account"))?;
190
191    let url = make_url(hub_address, "/api/v0/account/link")?;
192
193    debug!("Linking CLI account to Hub at {}", hub_address);
194
195    ensure_crypto_provider();
196    let client = reqwest::Client::new();
197
198    let resp = client
199        .post(&url)
200        .header(USER_AGENT, APP_USER_AGENT)
201        .header(ATUIN_HEADER_VERSION, ATUIN_CARGO_VERSION)
202        .bearer_auth(&hub_token)
203        .json(&serde_json::json!({ "token": cli_token }))
204        .send()
205        .await?;
206
207    let status = resp.status();
208
209    if status == StatusCode::CONFLICT {
210        // 409 means CLI account is already linked to a (possibly different) Hub account
211        debug!("CLI account already linked to a Hub account");
212        return Ok(());
213    }
214
215    handle_resp_error(resp).await?;
216
217    info!("Successfully linked CLI account to Hub");
218    Ok(())
219}
220
221// --- Internal HTTP functions ---
222
223fn make_url(address: &str, path: &str) -> Result<String> {
224    let address = if address.ends_with('/') {
225        address.to_string()
226    } else {
227        format!("{address}/")
228    };
229
230    let path = path.strip_prefix('/').unwrap_or(path);
231
232    let url = Url::parse(&address)
233        .context("failed to parse hub address")?
234        .join(path)
235        .context("failed to join hub URL path")?;
236
237    Ok(url.to_string())
238}
239
240async fn handle_resp_error(resp: reqwest::Response) -> Result<reqwest::Response> {
241    let status = resp.status();
242
243    if status == StatusCode::SERVICE_UNAVAILABLE {
244        error!("Service unavailable: check https://status.atuin.sh");
245        bail!("Service unavailable: check https://status.atuin.sh");
246    }
247
248    if status == StatusCode::TOO_MANY_REQUESTS {
249        error!("Rate limited; please wait before trying again");
250        bail!("Rate limited; please wait before trying again");
251    }
252
253    if !status.is_success() {
254        if let Ok(error) = resp.json::<ErrorResponse>().await {
255            error!("Hub error: {} - {}", status, error.reason);
256            bail!("Hub error: {} - {}", status, error.reason);
257        }
258        error!("Hub request failed with status: {}", status);
259        bail!("Hub request failed with status: {}", status);
260    }
261
262    Ok(resp)
263}
264
265/// Request a CLI auth code from the Atuin Hub
266async fn request_code(address: &str) -> Result<CliCodeResponse> {
267    ensure_crypto_provider();
268    let url = make_url(address, "/auth/cli/code")?;
269    let client = reqwest::Client::new();
270
271    debug!("Requesting code from Hub at {url}");
272
273    let resp = client
274        .post(&url)
275        .header(USER_AGENT, APP_USER_AGENT)
276        .header(ATUIN_HEADER_VERSION, ATUIN_CARGO_VERSION)
277        .send()
278        .await?;
279    let resp = handle_resp_error(resp).await?;
280
281    let code_response = resp.json::<CliCodeResponse>().await?;
282    Ok(code_response)
283}
284
285/// Poll to verify the CLI auth code and get the session token
286async fn verify_code(address: &str, code: &str) -> Result<CliVerifyResponse> {
287    ensure_crypto_provider();
288    let base = make_url(address, "/auth/cli/verify")?;
289    let url = format!("{base}?code={code}");
290    let client = reqwest::Client::new();
291
292    debug!("Verifying code with Hub at {base}?code=******");
293
294    let resp = client
295        .post(&url)
296        .header(USER_AGENT, APP_USER_AGENT)
297        .header(ATUIN_HEADER_VERSION, ATUIN_CARGO_VERSION)
298        .send()
299        .await?;
300    let resp = handle_resp_error(resp).await?;
301
302    let verify_response = resp.json::<CliVerifyResponse>().await?;
303    Ok(verify_response)
304}