Skip to main content

bctx_cloud_core/client/
auth.rs

1use 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/// Begin the OAuth2 device flow. Returns the URL and user code to display.
38#[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/// Poll for the token after the user authorises on the verification URI.
61#[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}