use async_trait::async_trait; use eyre::{Context, Result, bail}; use reqwest::{Url, header::USER_AGENT}; use atuin_common::{ api::{ATUIN_CARGO_VERSION, ATUIN_HEADER_VERSION, ChangePasswordRequest, LoginRequest}, tls::ensure_crypto_provider, }; use crate::settings::Settings; 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. /// `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, }, /// Two-factor authentication is required; the caller should prompt for a /// TOTP code and retry with it. TwoFactorRequired, } /// Result of a mutating account operation that may require 2FA. pub enum MutateResponse { /// Operation completed successfully. Success, /// Two-factor authentication is required; the caller should prompt for a /// TOTP code and retry. TwoFactorRequired, } /// Abstraction over the legacy (Rust sync server) and Hub auth APIs. /// /// CLI commands use this trait so they don't need to know which backend is /// active — they just prompt for input and call these methods. #[async_trait] pub trait AuthClient: Send + Sync { /// Log in with username + password, optionally providing a TOTP code. async fn login( &self, username: &str, password: &str, totp_code: Option<&str>, ) -> Result; /// Register a new account. async fn register(&self, username: &str, email: &str, password: &str) -> Result; /// Change the account password, optionally providing a TOTP code. async fn change_password( &self, current_password: &str, new_password: &str, totp_code: Option<&str>, ) -> Result; /// Delete the account, requiring the current password and optionally a TOTP code. async fn delete_account( &self, password: &str, totp_code: Option<&str>, ) -> Result; } /// Resolve the appropriate [`AuthClient`] for the current settings. pub async fn auth_client(settings: &Settings) -> Box { Box::new(LegacyAuthClient::new( &settings.sync_address, settings.session_token().await.ok(), settings.network_connect_timeout, settings.network_timeout, )) as Box } // --------------------------------------------------------------------------- // Legacy backend — talks to the Rust sync server // --------------------------------------------------------------------------- pub struct LegacyAuthClient { address: String, session_token: Option, connect_timeout: u64, timeout: u64, } impl LegacyAuthClient { pub fn new( address: &str, session_token: Option, connect_timeout: u64, timeout: u64, ) -> Self { Self { address: address.to_string(), session_token, connect_timeout, timeout, } } fn authenticated_client(&self) -> Result { let token = self .session_token .as_deref() .ok_or_else(|| eyre::eyre!("Not logged in"))?; ensure_crypto_provider(); let mut headers = reqwest::header::HeaderMap::new(); headers.insert( reqwest::header::AUTHORIZATION, format!("Token {token}").parse()?, ); headers.insert(USER_AGENT, APP_USER_AGENT.parse()?); headers.insert(ATUIN_HEADER_VERSION, ATUIN_CARGO_VERSION.parse()?); Ok(reqwest::Client::builder() .default_headers(headers) .connect_timeout(std::time::Duration::new(self.connect_timeout, 0)) .timeout(std::time::Duration::new(self.timeout, 0)) .build()?) } } #[async_trait] impl AuthClient for LegacyAuthClient { async fn login( &self, username: &str, password: &str, _totp_code: Option<&str>, ) -> Result { // The legacy server has no 2FA support; totp_code is ignored. let resp = crate::api_client::login( &self.address, LoginRequest { username: username.to_string(), password: password.to_string(), }, ) .await?; Ok(AuthResponse::Success { session: resp.session, auth_type: resp.auth.or(Some("cli".into())), }) } async fn register(&self, username: &str, email: &str, password: &str) -> Result { 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())), }) } async fn change_password( &self, current_password: &str, new_password: &str, _totp_code: Option<&str>, ) -> Result { let client = self.authenticated_client()?; let url = make_url(&self.address, "/account/password")?; let resp = client .patch(&url) .json(&ChangePasswordRequest { current_password: current_password.to_string(), new_password: new_password.to_string(), }) .send() .await?; match resp.status().as_u16() { 200 => Ok(MutateResponse::Success), 401 => bail!("current password is incorrect"), 403 => bail!("invalid login details"), _ => bail!("unknown error"), } } async fn delete_account( &self, password: &str, _totp_code: Option<&str>, ) -> Result { let client = self.authenticated_client()?; let url = make_url(&self.address, "/account")?; let resp = client .delete(&url) .json(&serde_json::json!({ "password": password })) .send() .await?; match resp.status().as_u16() { 200 => Ok(MutateResponse::Success), 401 => bail!("password is incorrect"), 403 => bail!("invalid login details"), _ => bail!("unknown error"), } } } // --------------------------------------------------------------------------- // Shared helpers // --------------------------------------------------------------------------- fn make_url(address: &str, path: &str) -> Result { let address = if address.ends_with('/') { address.to_string() } else { format!("{address}/") }; let path = path.strip_prefix('/').unwrap_or(path); let url = Url::parse(&address) .context("failed to parse server address")? .join(path) .context("failed to join URL path")?; Ok(url.to_string()) }