Skip to main content

atuin_client/
auth.rs

1use async_trait::async_trait;
2use eyre::{Context, Result, bail};
3use reqwest::{StatusCode, Url, header::USER_AGENT};
4use serde::Deserialize;
5
6use atuin_common::{
7    api::{
8        ATUIN_CARGO_VERSION, ATUIN_HEADER_VERSION, ChangePasswordRequest, LoginRequest,
9        LoginResponse, RegisterResponse,
10    },
11    tls::ensure_crypto_provider,
12};
13
14use crate::settings::Settings;
15
16static APP_USER_AGENT: &str = concat!("atuin/", env!("CARGO_PKG_VERSION"));
17
18/// Result of an auth operation that may require 2FA.
19pub enum AuthResponse {
20    /// Operation succeeded; for login/register, contains the session token.
21    /// `auth_type` indicates the kind of token: `Some("hub")` for Hub API
22    /// tokens (prefixed `atapi_`), `Some("cli")` for legacy CLI session
23    /// tokens. `None` when the server didn't include the field (old servers).
24    Success {
25        session: String,
26        auth_type: Option<String>,
27    },
28    /// Two-factor authentication is required; the caller should prompt for a
29    /// TOTP code and retry with it.
30    TwoFactorRequired,
31}
32
33/// Result of a mutating account operation that may require 2FA.
34pub enum MutateResponse {
35    /// Operation completed successfully.
36    Success,
37    /// Two-factor authentication is required; the caller should prompt for a
38    /// TOTP code and retry.
39    TwoFactorRequired,
40}
41
42/// Abstraction over the legacy (Rust sync server) and Hub auth APIs.
43///
44/// CLI commands use this trait so they don't need to know which backend is
45/// active — they just prompt for input and call these methods.
46#[async_trait]
47pub trait AuthClient: Send + Sync {
48    /// Log in with username + password, optionally providing a TOTP code.
49    async fn login(
50        &self,
51        username: &str,
52        password: &str,
53        totp_code: Option<&str>,
54    ) -> Result<AuthResponse>;
55
56    /// Register a new account.
57    async fn register(&self, username: &str, email: &str, password: &str) -> Result<AuthResponse>;
58
59    /// Change the account password, optionally providing a TOTP code.
60    async fn change_password(
61        &self,
62        current_password: &str,
63        new_password: &str,
64        totp_code: Option<&str>,
65    ) -> Result<MutateResponse>;
66
67    /// Delete the account, requiring the current password and optionally a TOTP code.
68    async fn delete_account(
69        &self,
70        password: &str,
71        totp_code: Option<&str>,
72    ) -> Result<MutateResponse>;
73}
74
75/// Resolve the appropriate [`AuthClient`] for the current settings.
76pub async fn auth_client(settings: &Settings) -> Box<dyn AuthClient> {
77    if settings.is_hub_sync() {
78        let endpoint = settings.active_hub_endpoint().unwrap_or_default();
79        Box::new(HubAuthClient::new(
80            endpoint.as_ref(),
81            settings.hub_session_token().await.ok(),
82        )) as Box<dyn AuthClient>
83    } else {
84        Box::new(LegacyAuthClient::new(
85            &settings.sync_address,
86            settings.session_token().await.ok(),
87            settings.network_connect_timeout,
88            settings.network_timeout,
89        )) as Box<dyn AuthClient>
90    }
91}
92
93// ---------------------------------------------------------------------------
94// Legacy backend — talks to the Rust sync server
95// ---------------------------------------------------------------------------
96
97pub struct LegacyAuthClient {
98    address: String,
99    session_token: Option<String>,
100    connect_timeout: u64,
101    timeout: u64,
102}
103
104impl LegacyAuthClient {
105    pub fn new(
106        address: &str,
107        session_token: Option<String>,
108        connect_timeout: u64,
109        timeout: u64,
110    ) -> Self {
111        Self {
112            address: address.to_string(),
113            session_token,
114            connect_timeout,
115            timeout,
116        }
117    }
118
119    fn authenticated_client(&self) -> Result<reqwest::Client> {
120        let token = self
121            .session_token
122            .as_deref()
123            .ok_or_else(|| eyre::eyre!("Not logged in"))?;
124
125        ensure_crypto_provider();
126        let mut headers = reqwest::header::HeaderMap::new();
127        headers.insert(
128            reqwest::header::AUTHORIZATION,
129            format!("Token {token}").parse()?,
130        );
131        headers.insert(USER_AGENT, APP_USER_AGENT.parse()?);
132        headers.insert(ATUIN_HEADER_VERSION, ATUIN_CARGO_VERSION.parse()?);
133
134        Ok(reqwest::Client::builder()
135            .default_headers(headers)
136            .connect_timeout(std::time::Duration::new(self.connect_timeout, 0))
137            .timeout(std::time::Duration::new(self.timeout, 0))
138            .build()?)
139    }
140}
141
142#[async_trait]
143impl AuthClient for LegacyAuthClient {
144    async fn login(
145        &self,
146        username: &str,
147        password: &str,
148        _totp_code: Option<&str>,
149    ) -> Result<AuthResponse> {
150        // The legacy server has no 2FA support; totp_code is ignored.
151        let resp = crate::api_client::login(
152            &self.address,
153            LoginRequest {
154                username: username.to_string(),
155                password: password.to_string(),
156            },
157        )
158        .await?;
159
160        Ok(AuthResponse::Success {
161            session: resp.session,
162            auth_type: resp.auth.or(Some("cli".into())),
163        })
164    }
165
166    async fn register(&self, username: &str, email: &str, password: &str) -> Result<AuthResponse> {
167        let resp = crate::api_client::register(&self.address, username, email, password).await?;
168        Ok(AuthResponse::Success {
169            session: resp.session,
170            auth_type: resp.auth.or(Some("cli".into())),
171        })
172    }
173
174    async fn change_password(
175        &self,
176        current_password: &str,
177        new_password: &str,
178        _totp_code: Option<&str>,
179    ) -> Result<MutateResponse> {
180        let client = self.authenticated_client()?;
181        let url = make_url(&self.address, "/account/password")?;
182
183        let resp = client
184            .patch(&url)
185            .json(&ChangePasswordRequest {
186                current_password: current_password.to_string(),
187                new_password: new_password.to_string(),
188            })
189            .send()
190            .await?;
191
192        match resp.status().as_u16() {
193            200 => Ok(MutateResponse::Success),
194            401 => bail!("current password is incorrect"),
195            403 => bail!("invalid login details"),
196            _ => bail!("unknown error"),
197        }
198    }
199
200    async fn delete_account(
201        &self,
202        password: &str,
203        _totp_code: Option<&str>,
204    ) -> Result<MutateResponse> {
205        let client = self.authenticated_client()?;
206        let url = make_url(&self.address, "/account")?;
207
208        let resp = client
209            .delete(&url)
210            .json(&serde_json::json!({ "password": password }))
211            .send()
212            .await?;
213
214        match resp.status().as_u16() {
215            200 => Ok(MutateResponse::Success),
216            401 => bail!("password is incorrect"),
217            403 => bail!("invalid login details"),
218            _ => bail!("unknown error"),
219        }
220    }
221}
222
223// ---------------------------------------------------------------------------
224// Hub backend — talks to the Hub v0 API endpoints
225// ---------------------------------------------------------------------------
226
227pub struct HubAuthClient {
228    address: String,
229    hub_token: Option<String>,
230}
231
232impl HubAuthClient {
233    pub fn new(address: &str, hub_token: Option<String>) -> Self {
234        Self {
235            address: address.trim_end_matches('/').to_string(),
236            hub_token,
237        }
238    }
239}
240
241/// Hub v0 error/status response — includes an optional `code` field for
242/// machine-readable status like `"2fa_required"`.
243#[derive(Debug, Deserialize)]
244struct HubErrorResponse {
245    reason: String,
246    code: Option<String>,
247}
248
249#[async_trait]
250impl AuthClient for HubAuthClient {
251    async fn login(
252        &self,
253        username: &str,
254        password: &str,
255        totp_code: Option<&str>,
256    ) -> Result<AuthResponse> {
257        ensure_crypto_provider();
258        let url = make_url(&self.address, "/api/v0/login")?;
259        let client = reqwest::Client::new();
260
261        let mut body = serde_json::json!({
262            "username": username,
263            "password": password,
264        });
265        if let Some(code) = totp_code {
266            body["totp_code"] = serde_json::Value::String(code.to_string());
267        }
268
269        let resp = client
270            .post(&url)
271            .header(USER_AGENT, APP_USER_AGENT)
272            .header(ATUIN_HEADER_VERSION, ATUIN_CARGO_VERSION)
273            .json(&body)
274            .send()
275            .await
276            .context("failed to connect to Atuin Hub")?;
277
278        let status = resp.status();
279
280        if status.is_success() {
281            let login: LoginResponse = resp.json().await?;
282            return Ok(AuthResponse::Success {
283                session: login.session,
284                auth_type: login.auth,
285            });
286        }
287
288        if status == StatusCode::FORBIDDEN
289            && let Ok(err) = resp.json::<HubErrorResponse>().await
290        {
291            if err.code.as_deref() == Some("2fa_required") {
292                return Ok(AuthResponse::TwoFactorRequired);
293            }
294            bail!("{}", err.reason);
295        }
296
297        if status == StatusCode::UNAUTHORIZED {
298            bail!("invalid credentials");
299        }
300
301        bail!("Hub login failed with status {status}");
302    }
303
304    async fn register(&self, username: &str, email: &str, password: &str) -> Result<AuthResponse> {
305        ensure_crypto_provider();
306        let url = make_url(&self.address, "/api/v0/register")?;
307        let client = reqwest::Client::new();
308
309        let resp = client
310            .post(&url)
311            .header(USER_AGENT, APP_USER_AGENT)
312            .header(ATUIN_HEADER_VERSION, ATUIN_CARGO_VERSION)
313            .json(&serde_json::json!({
314                "email": email,
315                "username": username,
316                "password": password,
317            }))
318            .send()
319            .await
320            .context("failed to connect to Atuin Hub")?;
321
322        let status = resp.status();
323
324        if status.is_success() {
325            let reg: RegisterResponse = resp.json().await?;
326            return Ok(AuthResponse::Success {
327                session: reg.session,
328                auth_type: reg.auth,
329            });
330        }
331
332        if let Ok(err) = resp.json::<HubErrorResponse>().await {
333            bail!("{}", err.reason);
334        }
335
336        bail!("Hub registration failed with status {status}");
337    }
338
339    async fn change_password(
340        &self,
341        current_password: &str,
342        new_password: &str,
343        totp_code: Option<&str>,
344    ) -> Result<MutateResponse> {
345        let hub_token = self.hub_token.as_deref().ok_or_else(|| {
346            eyre::eyre!(
347                "Not logged in to Atuin Hub. \
348                     Please run 'atuin login' to authenticate."
349            )
350        })?;
351
352        if !hub_token.starts_with("atapi_") {
353            bail!(
354                "Your Hub session token is invalid. \
355                 Please run 'atuin login' to re-authenticate with Atuin Hub."
356            );
357        }
358
359        ensure_crypto_provider();
360        let url = make_url(&self.address, "/api/v0/account/password")?;
361        let client = reqwest::Client::new();
362
363        let mut body = serde_json::json!({
364            "current_password": current_password,
365            "new_password": new_password,
366        });
367        if let Some(code) = totp_code {
368            body["totp_code"] = serde_json::Value::String(code.to_string());
369        }
370
371        let resp = client
372            .patch(&url)
373            .header(USER_AGENT, APP_USER_AGENT)
374            .header(ATUIN_HEADER_VERSION, ATUIN_CARGO_VERSION)
375            .bearer_auth(hub_token)
376            .json(&body)
377            .send()
378            .await
379            .context("failed to connect to Atuin Hub")?;
380
381        let status = resp.status();
382
383        if status.is_success() {
384            return Ok(MutateResponse::Success);
385        }
386
387        if let Ok(err) = resp.json::<HubErrorResponse>().await {
388            match err.code.as_deref() {
389                Some("2fa_required") => return Ok(MutateResponse::TwoFactorRequired),
390                Some("invalid_2fa_code") => bail!("invalid two-factor code"),
391                _ => bail!("{}", err.reason),
392            }
393        }
394
395        match status {
396            StatusCode::UNAUTHORIZED => bail!("current password is incorrect"),
397            StatusCode::FORBIDDEN => bail!("invalid login details"),
398            _ => bail!("Hub password change failed with status {status}"),
399        }
400    }
401
402    async fn delete_account(
403        &self,
404        password: &str,
405        totp_code: Option<&str>,
406    ) -> Result<MutateResponse> {
407        let hub_token = self.hub_token.as_deref().ok_or_else(|| {
408            eyre::eyre!(
409                "Not logged in to Atuin Hub. \
410                     Please run 'atuin login' to authenticate."
411            )
412        })?;
413
414        if !hub_token.starts_with("atapi_") {
415            bail!(
416                "Your Hub session token is invalid. \
417                 Please run 'atuin login' to re-authenticate with Atuin Hub."
418            );
419        }
420
421        ensure_crypto_provider();
422        let url = make_url(&self.address, "/api/v0/account")?;
423        let client = reqwest::Client::new();
424
425        let mut body = serde_json::json!({
426            "password": password,
427        });
428        if let Some(code) = totp_code {
429            body["totp_code"] = serde_json::Value::String(code.to_string());
430        }
431
432        let resp = client
433            .delete(&url)
434            .header(USER_AGENT, APP_USER_AGENT)
435            .header(ATUIN_HEADER_VERSION, ATUIN_CARGO_VERSION)
436            .bearer_auth(hub_token)
437            .json(&body)
438            .send()
439            .await
440            .context("failed to connect to Atuin Hub")?;
441
442        let status = resp.status();
443
444        if status.is_success() {
445            return Ok(MutateResponse::Success);
446        }
447
448        if let Ok(err) = resp.json::<HubErrorResponse>().await {
449            match err.code.as_deref() {
450                Some("2fa_required") => return Ok(MutateResponse::TwoFactorRequired),
451                Some("invalid_2fa_code") => bail!("invalid two-factor code"),
452                _ => bail!("{}", err.reason),
453            }
454        }
455
456        match status {
457            StatusCode::UNAUTHORIZED => bail!("password is incorrect"),
458            StatusCode::FORBIDDEN => bail!("invalid login details"),
459            _ => bail!("Hub account deletion failed with status {status}"),
460        }
461    }
462}
463
464// ---------------------------------------------------------------------------
465// Shared helpers
466// ---------------------------------------------------------------------------
467
468fn make_url(address: &str, path: &str) -> Result<String> {
469    let address = if address.ends_with('/') {
470        address.to_string()
471    } else {
472        format!("{address}/")
473    };
474
475    let path = path.strip_prefix('/').unwrap_or(path);
476
477    let url = Url::parse(&address)
478        .context("failed to parse server address")?
479        .join(path)
480        .context("failed to join URL path")?;
481
482    Ok(url.to_string())
483}