syncable_cli/auth/
device_flow.rs1use super::credentials;
6use anyhow::{Result, anyhow};
7use reqwest::Client;
8use serde::Deserialize;
9use std::time::{Duration, Instant};
10
11const SYNCABLE_API_URL_PROD: &str = "https://syncable.dev";
13const SYNCABLE_API_URL_DEV: &str = "http://localhost:4000";
15const CLI_CLIENT_ID: &str = "syncable-cli";
17
18#[derive(Debug, Deserialize)]
20struct DeviceCodeResponse {
21 device_code: String,
22 user_code: String,
23 verification_uri: String,
24 verification_uri_complete: Option<String>,
25 expires_in: u64,
26 interval: u64,
27}
28
29#[derive(Debug, Deserialize)]
31#[serde(untagged)]
32enum TokenResponse {
33 Success {
34 access_token: String,
35 #[allow(dead_code)]
36 token_type: String,
37 expires_in: Option<u64>,
38 refresh_token: Option<String>,
39 },
40 Error {
41 error: String,
42 #[allow(dead_code)]
43 error_description: Option<String>,
44 },
45}
46
47fn get_api_url() -> &'static str {
49 if std::env::var("SYNCABLE_ENV").as_deref() == Ok("development") {
51 SYNCABLE_API_URL_DEV
52 } else {
53 SYNCABLE_API_URL_PROD
54 }
55}
56
57pub async fn login(no_browser: bool) -> Result<()> {
59 println!("🔐 Authenticating with Syncable...\n");
60
61 let client = Client::new();
62 let api_url = get_api_url();
63
64 let response = client
66 .post(format!("{}/api/auth/device/code", api_url))
67 .json(&serde_json::json!({
68 "client_id": CLI_CLIENT_ID,
69 "scope": "openid profile email"
70 }))
71 .send()
72 .await
73 .map_err(|e| anyhow!("Failed to connect to Syncable API: {}", e))?;
74
75 if !response.status().is_success() {
76 let status = response.status();
77 let body = response.text().await.unwrap_or_default();
78 return Err(anyhow!(
79 "Failed to request device authorization: {} - {}",
80 status,
81 body
82 ));
83 }
84
85 let device_code: DeviceCodeResponse = response
86 .json()
87 .await
88 .map_err(|e| anyhow!("Invalid response from server: {}", e))?;
89
90 println!("📱 Device Authorization");
92 println!(" ─────────────────────────────────────");
93 println!(" Visit: {}", device_code.verification_uri);
94 println!(" Code: \x1b[1;36m{}\x1b[0m", device_code.user_code);
95 println!(" ─────────────────────────────────────\n");
96
97 if !no_browser {
99 let url = device_code
100 .verification_uri_complete
101 .as_ref()
102 .unwrap_or(&device_code.verification_uri);
103
104 if let Err(e) = open::that(url) {
105 println!("⚠️ Could not open browser automatically: {}", e);
106 println!(" Please open the URL above manually.");
107 } else {
108 println!("🌐 Browser opened. Waiting for authorization...");
109 }
110 } else {
111 println!(" Please open the URL above and enter the code.");
112 }
113
114 println!();
115
116 poll_for_token(&client, api_url, &device_code).await
118}
119
120async fn poll_for_token(
122 client: &Client,
123 api_url: &str,
124 device_code: &DeviceCodeResponse,
125) -> Result<()> {
126 let mut interval = device_code.interval;
127 let deadline = Instant::now() + Duration::from_secs(device_code.expires_in);
128
129 loop {
130 if Instant::now() > deadline {
132 return Err(anyhow!(
133 "Device code expired. Please run 'sync-ctl auth login' again."
134 ));
135 }
136
137 tokio::time::sleep(Duration::from_secs(interval)).await;
139
140 let response = client
142 .post(format!("{}/api/auth/device/token", api_url))
143 .json(&serde_json::json!({
144 "grant_type": "urn:ietf:params:oauth:grant-type:device_code",
145 "device_code": device_code.device_code,
146 "client_id": CLI_CLIENT_ID,
147 }))
148 .send()
149 .await;
150
151 let response = match response {
152 Ok(r) => r,
153 Err(e) => {
154 println!("⚠️ Network error, retrying: {}", e);
155 continue;
156 }
157 };
158
159 let body = response.text().await.unwrap_or_default();
160 let token_response: TokenResponse = match serde_json::from_str(&body) {
161 Ok(r) => r,
162 Err(_) => {
163 continue;
165 }
166 };
167
168 match token_response {
169 TokenResponse::Success {
170 access_token,
171 expires_in,
172 refresh_token,
173 ..
174 } => {
175 credentials::save_credentials(
177 &access_token,
178 refresh_token.as_deref(),
179 None, expires_in,
181 )?;
182
183 println!("\n\x1b[1;32m✅ Authentication successful!\x1b[0m");
184 println!(" Credentials saved to ~/.syncable.toml");
185 return Ok(());
186 }
187 TokenResponse::Error { error, .. } => {
188 match error.as_str() {
189 "authorization_pending" => {
190 continue;
192 }
193 "slow_down" => {
194 interval += 5;
196 continue;
197 }
198 "access_denied" => {
199 return Err(anyhow!("Authorization was denied by the user."));
200 }
201 "expired_token" => {
202 return Err(anyhow!(
203 "Device code expired. Please run 'sync-ctl auth login' again."
204 ));
205 }
206 _ => {
207 return Err(anyhow!("Authorization failed: {}", error));
208 }
209 }
210 }
211 }
212 }
213}