syncable_cli/auth/
device_flow.rs

1//! Device Authorization Grant flow (RFC 8628) for CLI authentication
2//!
3//! Implements the OAuth 2.0 device flow to authenticate CLI users via the Syncable web interface.
4
5use super::credentials;
6use anyhow::{Result, anyhow};
7use reqwest::Client;
8use serde::Deserialize;
9use std::time::{Duration, Instant};
10
11/// Production API URL (encore is reached via syncable.dev/api/*)
12const SYNCABLE_API_URL_PROD: &str = "https://syncable.dev";
13/// Development API URL
14const SYNCABLE_API_URL_DEV: &str = "http://localhost:4000";
15/// CLI client ID registered with the backend
16const CLI_CLIENT_ID: &str = "syncable-cli";
17
18/// Response from device code request
19#[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/// Token response (success or error)
30#[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
47/// Get the API URL based on environment
48fn get_api_url() -> &'static str {
49    // Check for development environment
50    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
57/// Perform the device authorization login flow
58pub 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    // Step 1: Request device code
65    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    // Step 2: Display user code and instructions
91    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    // Step 3: Open browser (unless --no-browser flag)
98    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    // Step 4: Poll for token
117    poll_for_token(&client, api_url, &device_code).await
118}
119
120/// Poll the token endpoint until authorization is complete
121async 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        // Check if code has expired
131        if Instant::now() > deadline {
132            return Err(anyhow!(
133                "Device code expired. Please run 'sync-ctl auth login' again."
134            ));
135        }
136
137        // Wait for polling interval
138        tokio::time::sleep(Duration::from_secs(interval)).await;
139
140        // Poll for token
141        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                // Unexpected response, continue polling
164                continue;
165            }
166        };
167
168        match token_response {
169            TokenResponse::Success {
170                access_token,
171                expires_in,
172                refresh_token,
173                ..
174            } => {
175                // Success! Save credentials
176                credentials::save_credentials(
177                    &access_token,
178                    refresh_token.as_deref(),
179                    None, // TODO: Fetch user email from session endpoint
180                    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                        // User hasn't completed authorization yet, keep polling
191                        continue;
192                    }
193                    "slow_down" => {
194                        // Server asked us to slow down
195                        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}