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").unwrap_or_else(|_| ".".into());
125 PathBuf::from(home).join(TOKEN_FILE)
126}
127
128pub fn save_token(store: &TokenStore) -> Result<()> {
129 let path = token_path();
130 if let Some(parent) = path.parent() {
131 std::fs::create_dir_all(parent)?;
132 }
133 std::fs::write(&path, serde_json::to_string_pretty(store)?)?;
134 Ok(())
135}
136
137pub fn load_token() -> Option<TokenStore> {
138 let path = token_path();
139 let data = std::fs::read_to_string(path).ok()?;
140 serde_json::from_str(&data).ok()
141}
142
143pub fn clear_token() -> Result<()> {
144 let path = token_path();
145 if path.exists() {
146 std::fs::remove_file(path)?;
147 }
148 Ok(())
149}
150
151#[cfg(feature = "cloud-server")]
152pub async fn fetch_account(endpoint: &str, token: &str) -> Result<serde_json::Value> {
153 let url = format!("{endpoint}/account/me");
154 let resp = reqwest::Client::builder()
155 .timeout(std::time::Duration::from_secs(10))
156 .connect_timeout(std::time::Duration::from_secs(8))
157 .build()?
158 .get(&url)
159 .bearer_auth(token)
160 .send()
161 .await?
162 .error_for_status()?
163 .json::<serde_json::Value>()
164 .await?;
165 Ok(resp)
166}
167
168#[cfg(not(feature = "cloud-server"))]
169pub async fn fetch_account(_endpoint: &str, _token: &str) -> Result<serde_json::Value> {
170 bail!("cloud-server feature not enabled")
171}