aboutsummaryrefslogtreecommitdiffstats
path: root/crates/turtle/src/atuin_client/auth.rs
diff options
context:
space:
mode:
Diffstat (limited to 'crates/turtle/src/atuin_client/auth.rs')
-rw-r--r--crates/turtle/src/atuin_client/auth.rs223
1 files changed, 223 insertions, 0 deletions
diff --git a/crates/turtle/src/atuin_client/auth.rs b/crates/turtle/src/atuin_client/auth.rs
new file mode 100644
index 00000000..b770c488
--- /dev/null
+++ b/crates/turtle/src/atuin_client/auth.rs
@@ -0,0 +1,223 @@
+use async_trait::async_trait;
+use eyre::{Context, Result, bail};
+use reqwest::{Url, header::USER_AGENT};
+
+use crate::{
+ atuin_client::api_client,
+ atuin_common::{
+ api::{ATUIN_CARGO_VERSION, ATUIN_HEADER_VERSION, ChangePasswordRequest, LoginRequest},
+ tls::ensure_crypto_provider,
+ },
+};
+
+use crate::atuin_client::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<String>,
+ },
+ /// 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) -> Result<AuthResponse>;
+
+ /// Register a new account.
+ async fn register(&self, username: &str, email: &str, password: &str) -> Result<AuthResponse>;
+
+ /// 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<MutateResponse>;
+
+ /// Delete the account, requiring the current password and optionally a TOTP code.
+ async fn delete_account(
+ &self,
+ password: &str,
+ totp_code: Option<&str>,
+ ) -> Result<MutateResponse>;
+}
+
+/// Resolve the appropriate [`AuthClient`] for the current settings.
+pub async fn auth_client(settings: &Settings) -> Box<dyn AuthClient> {
+ Box::new(LegacyAuthClient::new(
+ &settings.sync_address,
+ settings.session_token().await.ok(),
+ settings.network_connect_timeout,
+ settings.network_timeout,
+ )) as Box<dyn AuthClient>
+}
+
+// ---------------------------------------------------------------------------
+// Legacy backend — talks to the Rust sync server
+// ---------------------------------------------------------------------------
+
+pub struct LegacyAuthClient {
+ address: String,
+ session_token: Option<String>,
+ connect_timeout: u64,
+ timeout: u64,
+}
+
+impl LegacyAuthClient {
+ pub fn new(
+ address: &str,
+ session_token: Option<String>,
+ connect_timeout: u64,
+ timeout: u64,
+ ) -> Self {
+ Self {
+ address: address.to_string(),
+ session_token,
+ connect_timeout,
+ timeout,
+ }
+ }
+
+ fn authenticated_client(&self) -> Result<reqwest::Client> {
+ 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) -> Result<AuthResponse> {
+ // The legacy server has no 2FA support; totp_code is ignored.
+ let resp = 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<AuthResponse> {
+ let resp = 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<MutateResponse> {
+ 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<MutateResponse> {
+ 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<String> {
+ 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())
+}