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