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::new()
42        .post(&url)
43        .json(&serde_json::json!({ "client_id": client_id }))
44        .send()
45        .await?
46        .error_for_status()?
47        .json::<DeviceCodeResponse>()
48        .await?;
49    Ok(resp)
50}
51
52#[cfg(not(feature = "cloud-server"))]
53pub async fn device_flow_start(_endpoint: &str, _client_id: &str) -> Result<DeviceCodeResponse> {
54    bail!("cloud-server feature not enabled")
55}
56
57/// Poll for the token after the user authorises on the verification URI.
58#[cfg(feature = "cloud-server")]
59pub async fn device_flow_poll(
60    endpoint: &str,
61    client_id: &str,
62    device_code: &str,
63    interval_secs: u64,
64) -> Result<TokenResponse> {
65    let url = format!("{endpoint}/auth/token");
66    let client = reqwest::Client::new();
67    loop {
68        tokio::time::sleep(tokio::time::Duration::from_secs(interval_secs)).await;
69        let resp = client
70            .post(&url)
71            .json(&serde_json::json!({
72                "grant_type": "urn:ietf:params:oauth:grant-type:device_code",
73                "device_code": device_code,
74                "client_id": client_id,
75            }))
76            .send()
77            .await?;
78        if resp.status().is_success() {
79            return Ok(resp.json::<TokenResponse>().await?);
80        }
81        let body: serde_json::Value = resp.json().await.unwrap_or_default();
82        match body["error"].as_str() {
83            Some("authorization_pending") => {
84                print!(".");
85                let _ = std::io::Write::flush(&mut std::io::stdout());
86                continue;
87            }
88            Some("slow_down") => tokio::time::sleep(tokio::time::Duration::from_secs(5)).await,
89            Some(e) => bail!("auth error: {e}"),
90            None => bail!("unexpected auth response"),
91        }
92    }
93}
94
95#[cfg(not(feature = "cloud-server"))]
96pub async fn device_flow_poll(
97    _endpoint: &str,
98    _client_id: &str,
99    _device_code: &str,
100    _interval_secs: u64,
101) -> Result<TokenResponse> {
102    bail!("cloud-server feature not enabled")
103}
104
105pub fn token_path() -> PathBuf {
106    let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
107    PathBuf::from(home).join(TOKEN_FILE)
108}
109
110pub fn save_token(store: &TokenStore) -> Result<()> {
111    let path = token_path();
112    if let Some(parent) = path.parent() {
113        std::fs::create_dir_all(parent)?;
114    }
115    std::fs::write(&path, serde_json::to_string_pretty(store)?)?;
116    Ok(())
117}
118
119pub fn load_token() -> Option<TokenStore> {
120    let path = token_path();
121    let data = std::fs::read_to_string(path).ok()?;
122    serde_json::from_str(&data).ok()
123}
124
125pub fn clear_token() -> Result<()> {
126    let path = token_path();
127    if path.exists() {
128        std::fs::remove_file(path)?;
129    }
130    Ok(())
131}
132
133#[cfg(feature = "cloud-server")]
134pub async fn fetch_account(endpoint: &str, token: &str) -> Result<serde_json::Value> {
135    let url = format!("{endpoint}/account/me");
136    let resp = reqwest::Client::new()
137        .get(&url)
138        .bearer_auth(token)
139        .send()
140        .await?
141        .error_for_status()?
142        .json::<serde_json::Value>()
143        .await?;
144    Ok(resp)
145}
146
147#[cfg(not(feature = "cloud-server"))]
148pub async fn fetch_account(_endpoint: &str, _token: &str) -> Result<serde_json::Value> {
149    bail!("cloud-server feature not enabled")
150}