Skip to main content

bctx_cloud_core/client/
auth.rs

1use anyhow::{bail, Result};
2use serde::{Deserialize, Serialize};
3use std::path::PathBuf;
4
5/// The server no longer accepts the saved login (tokens last 30 days); `bctx login` renews it.
6#[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/// Begin the OAuth2 device flow. Returns the URL and user code to display.
50#[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/// Poll for the token after the user authorises on the verification URI.
73#[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    // HOME on Unix, USERPROFILE on Windows (cloud has no forge dep to share home_dir()).
137    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}