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
18pub enum AuthResponse {
20 Success {
25 session: String,
26 auth_type: Option<String>,
27 },
28 TwoFactorRequired,
31}
32
33pub enum MutateResponse {
35 Success,
37 TwoFactorRequired,
40}
41
42#[async_trait]
47pub trait AuthClient: Send + Sync {
48 async fn login(
50 &self,
51 username: &str,
52 password: &str,
53 totp_code: Option<&str>,
54 ) -> Result<AuthResponse>;
55
56 async fn register(&self, username: &str, email: &str, password: &str) -> Result<AuthResponse>;
58
59 async fn change_password(
61 &self,
62 current_password: &str,
63 new_password: &str,
64 totp_code: Option<&str>,
65 ) -> Result<MutateResponse>;
66
67 async fn delete_account(
69 &self,
70 password: &str,
71 totp_code: Option<&str>,
72 ) -> Result<MutateResponse>;
73}
74
75pub 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
93pub 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 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
223pub 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#[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
464fn 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}