Repository navigation
Expand file tree
/
Copy pathdevice_flow.rs
More file actions
213 lines (190 loc) · 6.85 KB
/
Copy pathdevice_flow.rs
File metadata and controls
213 lines (190 loc) · 6.85 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
//! Device Authorization Grant flow (RFC 8628) for CLI authentication
//!
//! Implements the OAuth 2.0 device flow to authenticate CLI users via the Syncable web interface.
use super::credentials;
use anyhow::{Result, anyhow};
use reqwest::Client;
use serde::Deserialize;
use std::time::{Duration, Instant};
/// Production API URL (encore is reached via syncable.dev/api/*)
const SYNCABLE_API_URL_PROD: &str = "https://syncable.dev";
/// Development API URL
const SYNCABLE_API_URL_DEV: &str = "http://localhost:4000";
/// CLI client ID registered with the backend
const CLI_CLIENT_ID: &str = "syncable-cli";
/// Response from device code request
#[derive(Debug, Deserialize)]
struct DeviceCodeResponse {
device_code: String,
user_code: String,
verification_uri: String,
verification_uri_complete: Option<String>,
expires_in: u64,
interval: u64,
}
/// Token response (success or error)
#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum TokenResponse {
Success {
access_token: String,
#[allow(dead_code)]
token_type: String,
expires_in: Option<u64>,
refresh_token: Option<String>,
},
Error {
error: String,
#[allow(dead_code)]
error_description: Option<String>,
},
}
/// Get the API URL based on environment
fn get_api_url() -> &'static str {
// Check for development environment
if std::env::var("SYNCABLE_ENV").as_deref() == Ok("development") {
SYNCABLE_API_URL_DEV
} else {
SYNCABLE_API_URL_PROD
}
}
/// Perform the device authorization login flow
pub async fn login(no_browser: bool) -> Result<()> {
println!("🔐 Authenticating with Syncable...\n");
let client = Client::new();
let api_url = get_api_url();
// Step 1: Request device code
let response = client
.post(format!("{}/api/auth/device/code", api_url))
.json(&serde_json::json!({
"client_id": CLI_CLIENT_ID,
"scope": "openid profile email"
}))
.send()
.await
.map_err(|e| anyhow!("Failed to connect to Syncable API: {}", e))?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(anyhow!(
"Failed to request device authorization: {} - {}",
status,
body
));
}
let device_code: DeviceCodeResponse = response
.json()
.await
.map_err(|e| anyhow!("Invalid response from server: {}", e))?;
// Step 2: Display user code and instructions
println!("📱 Device Authorization");
println!(" ─────────────────────────────────────");
println!(" Visit: {}", device_code.verification_uri);
println!(" Code: \x1b[1;36m{}\x1b[0m", device_code.user_code);
println!(" ─────────────────────────────────────\n");
// Step 3: Open browser (unless --no-browser flag)
if !no_browser {
let url = device_code
.verification_uri_complete
.as_ref()
.unwrap_or(&device_code.verification_uri);
if let Err(e) = open::that(url) {
println!("⚠️ Could not open browser automatically: {}", e);
println!(" Please open the URL above manually.");
} else {
println!("🌐 Browser opened. Waiting for authorization...");
}
} else {
println!(" Please open the URL above and enter the code.");
}
println!();
// Step 4: Poll for token
poll_for_token(&client, api_url, &device_code).await
}
/// Poll the token endpoint until authorization is complete
async fn poll_for_token(
client: &Client,
api_url: &str,
device_code: &DeviceCodeResponse,
) -> Result<()> {
let mut interval = device_code.interval;
let deadline = Instant::now() + Duration::from_secs(device_code.expires_in);
loop {
// Check if code has expired
if Instant::now() > deadline {
return Err(anyhow!(
"Device code expired. Please run 'sync-ctl auth login' again."
));
}
// Wait for polling interval
tokio::time::sleep(Duration::from_secs(interval)).await;
// Poll for token
let response = client
.post(format!("{}/api/auth/device/token", api_url))
.json(&serde_json::json!({
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
"device_code": device_code.device_code,
"client_id": CLI_CLIENT_ID,
}))
.send()
.await;
let response = match response {
Ok(r) => r,
Err(e) => {
println!("⚠️ Network error, retrying: {}", e);
continue;
}
};
let body = response.text().await.unwrap_or_default();
let token_response: TokenResponse = match serde_json::from_str(&body) {
Ok(r) => r,
Err(_) => {
// Unexpected response, continue polling
continue;
}
};
match token_response {
TokenResponse::Success {
access_token,
expires_in,
refresh_token,
..
} => {
// Success! Save credentials
credentials::save_credentials(
&access_token,
refresh_token.as_deref(),
None, // TODO: Fetch user email from session endpoint
expires_in,
)?;
println!("\n\x1b[1;32m✅ Authentication successful!\x1b[0m");
println!(" Credentials saved to ~/.syncable.toml");
return Ok(());
}
TokenResponse::Error { error, .. } => {
match error.as_str() {
"authorization_pending" => {
// User hasn't completed authorization yet, keep polling
continue;
}
"slow_down" => {
// Server asked us to slow down
interval += 5;
continue;
}
"access_denied" => {
return Err(anyhow!("Authorization was denied by the user."));
}
"expired_token" => {
return Err(anyhow!(
"Device code expired. Please run 'sync-ctl auth login' again."
));
}
_ => {
return Err(anyhow!("Authorization failed: {}", error));
}
}
}
}
}
}