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 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#[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#[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}