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