bctx_cloud_core/client/
auth.rs1use anyhow::{bail, Result};
2use serde::{Deserialize, Serialize};
3use std::path::PathBuf;
4
5#[derive(Debug)]
7pub struct LoginExpired;
8
9impl std::fmt::Display for LoginExpired {
10 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
11 f.write_str("your bctx login has expired; run `bctx login`")
12 }
13}
14
15impl std::error::Error for LoginExpired {}
16
17const TOKEN_FILE: &str = ".bctx/cloud_token.json";
18
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct TokenStore {
21 pub access_token: String,
22 pub refresh_token: Option<String>,
23 pub expires_at: Option<String>,
24 pub endpoint: String,
25 pub user_id: String,
26 pub email: Option<String>,
27 pub tier: String,
28}
29
30#[derive(Debug, Deserialize)]
31pub struct DeviceCodeResponse {
32 pub device_code: String,
33 pub user_code: String,
34 pub verification_uri: String,
35 pub expires_in: u64,
36 pub interval: u64,
37}
38
39#[derive(Debug, Deserialize)]
40pub struct TokenResponse {
41 pub access_token: String,
42 pub refresh_token: Option<String>,
43 pub expires_in: Option<u64>,
44 pub user_id: String,
45 pub email: Option<String>,
46 pub tier: String,
47}
48
49#[cfg(feature = "cloud-server")]
51pub async fn device_flow_start(endpoint: &str, client_id: &str) -> Result<DeviceCodeResponse> {
52 let url = format!("{endpoint}/auth/device");
53 let resp = reqwest::Client::builder()
54 .timeout(std::time::Duration::from_secs(10))
55 .connect_timeout(std::time::Duration::from_secs(8))
56 .build()?
57 .post(&url)
58 .json(&serde_json::json!({ "client_id": client_id }))
59 .send()
60 .await?
61 .error_for_status()?
62 .json::<DeviceCodeResponse>()
63 .await?;
64 Ok(resp)
65}
66
67#[cfg(not(feature = "cloud-server"))]
68pub async fn device_flow_start(_endpoint: &str, _client_id: &str) -> Result<DeviceCodeResponse> {
69 bail!("cloud-server feature not enabled")
70}
71
72#[cfg(feature = "cloud-server")]
74pub async fn device_flow_poll(
75 endpoint: &str,
76 client_id: &str,
77 device_code: &str,
78 interval_secs: u64,
79) -> Result<TokenResponse> {
80 let url = format!("{endpoint}/auth/token");
81 let client = reqwest::Client::builder()
82 .timeout(std::time::Duration::from_secs(15))
83 .build()?;
84 let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_secs(300);
85 loop {
86 if tokio::time::Instant::now() >= deadline {
87 bail!("authorization timed out — run `bctx login` to try again");
88 }
89 tokio::time::sleep(tokio::time::Duration::from_secs(interval_secs)).await;
90 let resp = match client
91 .post(&url)
92 .json(&serde_json::json!({
93 "grant_type": "urn:ietf:params:oauth:grant-type:device_code",
94 "device_code": device_code,
95 "client_id": client_id,
96 }))
97 .send()
98 .await
99 {
100 Ok(r) => r,
101 Err(e) if e.is_timeout() => {
102 eprintln!("\n (network timeout — retrying...)");
103 continue;
104 }
105 Err(e) => return Err(e.into()),
106 };
107 if resp.status().is_success() {
108 return Ok(resp.json::<TokenResponse>().await?);
109 }
110 let body: serde_json::Value = resp.json().await.unwrap_or_default();
111 match body["error"].as_str() {
112 Some("authorization_pending") => {
113 print!(".");
114 let _ = std::io::Write::flush(&mut std::io::stdout());
115 continue;
116 }
117 Some("slow_down") => tokio::time::sleep(tokio::time::Duration::from_secs(5)).await,
118 Some("expired_token") => bail!("device code expired — run `bctx login` to try again"),
119 Some(e) => bail!("auth error: {e}"),
120 None => bail!("unexpected auth response"),
121 }
122 }
123}
124
125#[cfg(not(feature = "cloud-server"))]
126pub async fn device_flow_poll(
127 _endpoint: &str,
128 _client_id: &str,
129 _device_code: &str,
130 _interval_secs: u64,
131) -> Result<TokenResponse> {
132 bail!("cloud-server feature not enabled")
133}
134
135pub fn token_path() -> PathBuf {
136 let home = std::env::var("HOME")
138 .or_else(|_| std::env::var("USERPROFILE"))
139 .unwrap_or_else(|_| ".".into());
140 PathBuf::from(home).join(TOKEN_FILE)
141}
142
143pub fn save_token(store: &TokenStore) -> Result<()> {
144 let path = token_path();
145 if let Some(parent) = path.parent() {
146 std::fs::create_dir_all(parent)?;
147 }
148 std::fs::write(&path, serde_json::to_string_pretty(store)?)?;
149 Ok(())
150}
151
152pub fn load_token() -> Option<TokenStore> {
153 let path = token_path();
154 let data = std::fs::read_to_string(path).ok()?;
155 serde_json::from_str(&data).ok()
156}
157
158pub fn clear_token() -> Result<()> {
159 let path = token_path();
160 if path.exists() {
161 std::fs::remove_file(path)?;
162 }
163 Ok(())
164}
165
166#[cfg(feature = "cloud-server")]
167pub async fn fetch_account(endpoint: &str, token: &str) -> Result<serde_json::Value> {
168 let url = format!("{endpoint}/account/me");
169 let resp = reqwest::Client::builder()
170 .timeout(std::time::Duration::from_secs(10))
171 .connect_timeout(std::time::Duration::from_secs(8))
172 .build()?
173 .get(&url)
174 .bearer_auth(token)
175 .send()
176 .await?
177 .error_for_status()?
178 .json::<serde_json::Value>()
179 .await?;
180 Ok(resp)
181}
182
183#[cfg(not(feature = "cloud-server"))]
184pub async fn fetch_account(_endpoint: &str, _token: &str) -> Result<serde_json::Value> {
185 bail!("cloud-server feature not enabled")
186}
187
188#[cfg(feature = "cloud-server")]
189pub async fn request_web_code(endpoint: &str, token: &str) -> Result<String> {
190 let resp = reqwest::Client::builder()
191 .timeout(std::time::Duration::from_secs(10))
192 .build()?
193 .post(format!("{endpoint}/auth/web-code"))
194 .bearer_auth(token)
195 .send()
196 .await?;
197 if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
198 return Err(LoginExpired.into());
199 }
200 let resp: serde_json::Value = resp.error_for_status()?.json().await?;
201 resp["code"]
202 .as_str()
203 .map(String::from)
204 .ok_or_else(|| anyhow::anyhow!("no code in response"))
205}
206
207#[cfg(not(feature = "cloud-server"))]
208pub async fn request_web_code(_endpoint: &str, _token: &str) -> Result<String> {
209 bail!("cloud-server feature not enabled")
210}