1use 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#[derive(Debug, Clone)]
30pub struct HubAuthSession {
31 pub code: String,
33 pub auth_url: String,
35 pub hub_address: String,
37}
38
39#[derive(Debug, Clone)]
41pub enum HubAuthStatus {
42 Pending,
44 Complete(String),
46 Failed(String),
48}
49
50pub const DEFAULT_POLL_INTERVAL: Duration = Duration::from_secs(2);
52
53pub const DEFAULT_AUTH_TIMEOUT: Duration = Duration::from_secs(600);
55
56impl HubAuthSession {
57 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 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 log::debug!("Verification poll failed: {}", e);
99 Ok(HubAuthStatus::Pending)
100 }
101 }
102 }
103
104 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
134pub 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
146pub 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
155pub async fn is_logged_in() -> Result<bool> {
160 Settings::meta_store().await?.hub_logged_in().await
161}
162
163pub async fn get_session_token() -> Result<Option<String>> {
168 Settings::meta_store().await?.hub_session_token().await
169}
170
171pub 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 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
221fn 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
265async 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
285async 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}