From 7696eeed47c81e0b42faaf62852774d62bd6e759 Mon Sep 17 00:00:00 2001 From: junhsss Date: Fri, 3 Apr 2026 22:10:17 +0900 Subject: [PATCH 1/2] feat: device auth flow --- src/commands/login.rs | 322 ++++++++++++------------------------------ src/config/mod.rs | 5 +- 2 files changed, 93 insertions(+), 234 deletions(-) diff --git a/src/commands/login.rs b/src/commands/login.rs index 5ec6f34..19737a7 100644 --- a/src/commands/login.rs +++ b/src/commands/login.rs @@ -1,7 +1,6 @@ -use std::io::{BufRead, BufReader, Write as IoWrite}; +use std::time::{Duration, Instant}; use clap::Parser; -use tokio::net::TcpListener; use crate::config; use crate::config::auth; @@ -11,7 +10,7 @@ use crate::status; #[derive(Parser)] pub struct Args {} -const LOGIN_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5 * 60); +const DEVICE_CODE_TIMEOUT: Duration = Duration::from_secs(15 * 60); pub async fn run(_args: Args) -> anyhow::Result<()> { // Check if already logged in @@ -21,9 +20,7 @@ pub async fn run(_args: Args) -> anyhow::Result<()> { return Ok(()); } - status!("Launching browser for authentication..."); - - let (api_key, name) = login_flow().await?; + let (api_key, name) = device_code_flow().await?; save_api_key(&api_key, &name)?; @@ -32,111 +29,109 @@ pub async fn run(_args: Args) -> anyhow::Result<()> { Ok(()) } -async fn login_flow() -> anyhow::Result<(String, String)> { - let state = generate_random_hex(16); - - // Bind to random port on localhost - let listener = TcpListener::bind("127.0.0.1:0").await?; - let port = listener.local_addr()?.port(); - - let auth_url = format!( - "{}?cli_redirect=true&port={}&state={}", - config::LOGIN_URL, - port, - state - ); +async fn device_code_flow() -> anyhow::Result<(String, String)> { + let client = reqwest::Client::new(); - status!("Opening your browser for authentication..."); - status!("If it does not open automatically, please click:"); - status!("{auth_url}"); + // Step 1: Request device code + let resp = client + .post(config::DEVICE_AUTH_URL) + .json(&serde_json::json!({ "client_id": "cli" })) + .send() + .await?; - if let Err(e) = open::that(&auth_url) { - status!("Warning: could not open browser automatically: {e}"); - status!("Please open the URL above manually."); + if !resp.status().is_success() { + anyhow::bail!( + "Failed to start device authorization: {} {}", + resp.status().as_u16(), + resp.status().canonical_reason().unwrap_or("") + ); } - // Wait for the callback with timeout - let result = tokio::time::timeout(LOGIN_TIMEOUT, async { - let (stream, _) = listener.accept().await?; - stream.readable().await?; - - let std_stream = stream.into_std()?; - let mut reader = BufReader::new(&std_stream); - - let mut request_line = String::new(); - reader.read_line(&mut request_line)?; + let data: serde_json::Value = resp.json().await?; + + let device_code = data["device_code"] + .as_str() + .ok_or_else(|| anyhow::anyhow!("Missing device_code in response"))? + .to_string(); + let user_code = data["user_code"] + .as_str() + .ok_or_else(|| anyhow::anyhow!("Missing user_code in response"))?; + let verification_uri = data["verification_uri"] + .as_str() + .ok_or_else(|| anyhow::anyhow!("Missing verification_uri in response"))?; + let verification_uri_complete = data["verification_uri_complete"].as_str(); + let expires_in = data["expires_in"].as_u64().unwrap_or(900); + let interval = data["interval"].as_u64().unwrap_or(5); + + // Step 2: Display instructions + status!("Visit: {verification_uri}"); + status!("Enter code: {user_code}"); + + // Try to open browser to the pre-filled URL + if let Some(uri) = verification_uri_complete { + if let Err(e) = open::that(uri) { + status!("Warning: could not open browser automatically: {e}"); + status!("Please open the URL above manually."); + } + } - // Parse query params from GET /callback?jwt=...&state=... - let query = parse_query_from_request(&request_line); + // Step 3: Poll for token + let mut poll_interval = Duration::from_secs(interval); + let deadline = Instant::now() + Duration::from_secs(expires_in).min(DEVICE_CODE_TIMEOUT); - let received_state = query - .iter() - .find(|(k, _)| k == "state") - .map(|(_, v)| v.as_str()); + loop { + tokio::time::sleep(poll_interval).await; - if received_state != Some(&state) { - send_response(&std_stream, 400, "Error: Invalid state parameter.")?; - anyhow::bail!("Invalid state parameter. Possible CSRF attack."); + if Instant::now() > deadline { + anyhow::bail!("Device code expired. Please try again."); } - let jwt = query - .iter() - .find(|(k, _)| k == "jwt") - .map(|(_, v)| v.clone()); - - let Some(jwt) = jwt else { - send_response(&std_stream, 400, "Error: JWT not found in callback.")?; - anyhow::bail!("Callback did not include a JWT."); + let resp = client + .post(config::DEVICE_TOKEN_URL) + .json(&serde_json::json!({ + "grant_type": "urn:ietf:params:oauth:grant-type:device_code", + "device_code": device_code, + "client_id": "cli", + })) + .send() + .await; + + let resp = match resp { + Ok(r) => r, + Err(_) => { + // Connection error — exponential backoff, capped at 60s + poll_interval = (poll_interval * 2).min(Duration::from_secs(60)); + continue; + } }; - // Redirect browser to success page - let redirect = format!( - "HTTP/1.1 302 Found\r\nLocation: {}\r\nConnection: close\r\n\r\n", - config::SUCCESS_URL - ); - (&std_stream).write_all(redirect.as_bytes())?; - (&std_stream).flush()?; - - // Exchange JWT for API key - create_api_key_using_jwt(&jwt).await - }) - .await; + // Reset backoff on successful HTTP response + poll_interval = Duration::from_secs(interval); - match result { - Ok(inner) => inner, - Err(_) => anyhow::bail!("Login timed out. Please try again."), - } -} + let body: serde_json::Value = resp.json().await?; -async fn create_api_key_using_jwt(jwt: &str) -> anyhow::Result<(String, String)> { - let client = reqwest::Client::new(); - let response = client - .post(config::API_KEYS_URL) - .header("Content-Type", "application/json") - .header("Authorization", format!("Bearer {jwt}")) - .json(&serde_json::json!({"name": "CLI"})) - .send() - .await?; + // Success: api_key present + if let Some(api_key) = body.get("api_key").and_then(|v| v.as_str()) { + let name = body + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or("CLI"); + return Ok((api_key.to_string(), name.to_string())); + } - if !response.status().is_success() { - anyhow::bail!( - "Failed to get API key: {} {}", - response.status().as_u16(), - response.status().canonical_reason().unwrap_or("") - ); + // Error handling per RFC 8628 + match body.get("error").and_then(|v| v.as_str()) { + Some("authorization_pending") => continue, + Some("slow_down") => { + poll_interval += Duration::from_secs(5); + continue; + } + Some("expired_token") => anyhow::bail!("Device code expired. Please try again."), + Some("access_denied") => anyhow::bail!("Authorization denied by user."), + Some(other) => anyhow::bail!("Authentication failed: {other}"), + None => anyhow::bail!("Unexpected response from server"), + } } - - let data: serde_json::Value = response.json().await?; - let key = data - .get("key") - .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow::anyhow!("No API key found in response"))?; - let name = data - .get("name") - .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow::anyhow!("No name found in response"))?; - - Ok((key.to_string(), name.to_string())) } fn save_api_key(api_key: &str, name: &str) -> anyhow::Result<()> { @@ -152,138 +147,3 @@ fn save_api_key(api_key: &str, name: &str) -> anyhow::Result<()> { Ok(()) } - -fn parse_query_from_request(request_line: &str) -> Vec<(String, String)> { - // "GET /callback?jwt=xxx&state=yyy HTTP/1.1" - let parts: Vec<&str> = request_line.split_whitespace().collect(); - if parts.len() < 2 { - return vec![]; - } - - let path = parts[1]; - let query_start = match path.find('?') { - Some(i) => i + 1, - None => return vec![], - }; - - let query_str = &path[query_start..]; - query_str - .split('&') - .filter_map(|pair| { - let mut kv = pair.splitn(2, '='); - let key = kv.next()?; - let value = kv.next().unwrap_or(""); - Some(( - urlencoding::decode(key).unwrap_or_default().to_string(), - urlencoding::decode(value).unwrap_or_default().to_string(), - )) - }) - .collect() -} - -fn send_response(stream: &std::net::TcpStream, status: u16, body: &str) -> anyhow::Result<()> { - let response = format!( - "HTTP/1.1 {status} Error\r\nContent-Type: text/plain\r\nConnection: close\r\n\r\n{body}" - ); - let mut stream = stream; - stream.write_all(response.as_bytes())?; - stream.flush()?; - Ok(()) -} - -fn generate_random_hex(bytes: usize) -> String { - let mut buf = vec![0u8; bytes]; - getrandom::fill(&mut buf).expect("failed to generate random bytes"); - buf.iter().map(|b| format!("{b:02x}")).collect() -} - -#[cfg(test)] -mod tests { - use super::*; - - // --- parse_query_from_request --- - - #[test] - fn parse_query_normal_get() { - let query = parse_query_from_request("GET /callback?code=abc&state=xyz HTTP/1.1\r\n"); - assert_eq!(query.len(), 2); - assert_eq!(query[0], ("code".to_string(), "abc".to_string())); - assert_eq!(query[1], ("state".to_string(), "xyz".to_string())); - } - - #[test] - fn parse_query_no_query_string() { - let query = parse_query_from_request("GET /callback HTTP/1.1\r\n"); - assert!(query.is_empty()); - } - - #[test] - fn parse_query_empty_input() { - let query = parse_query_from_request(""); - assert!(query.is_empty()); - } - - #[test] - fn parse_query_single_word() { - let query = parse_query_from_request("GET"); - assert!(query.is_empty()); - } - - #[test] - fn parse_query_url_encoded() { - let query = parse_query_from_request("GET /cb?msg=hello%20world HTTP/1.1\r\n"); - assert_eq!(query.len(), 1); - assert_eq!(query[0], ("msg".to_string(), "hello world".to_string())); - } - - #[test] - fn parse_query_empty_value() { - let query = parse_query_from_request("GET /cb?key= HTTP/1.1\r\n"); - assert_eq!(query.len(), 1); - assert_eq!(query[0], ("key".to_string(), "".to_string())); - } - - #[test] - fn parse_query_key_without_equals() { - let query = parse_query_from_request("GET /cb?flag HTTP/1.1\r\n"); - assert_eq!(query.len(), 1); - assert_eq!(query[0], ("flag".to_string(), "".to_string())); - } - - #[test] - fn parse_query_multiple_params() { - let query = parse_query_from_request("GET /cb?a=1&b=2&c=3 HTTP/1.1\r\n"); - assert_eq!(query.len(), 3); - assert_eq!(query[0].0, "a"); - assert_eq!(query[1].0, "b"); - assert_eq!(query[2].0, "c"); - } - - #[test] - fn parse_query_value_with_equals() { - let query = parse_query_from_request("GET /cb?q=a=b HTTP/1.1\r\n"); - assert_eq!(query.len(), 1); - assert_eq!(query[0], ("q".to_string(), "a=b".to_string())); - } - - // --- generate_random_hex --- - - #[test] - fn random_hex_length() { - assert_eq!(generate_random_hex(16).len(), 32); - assert_eq!(generate_random_hex(1).len(), 2); - } - - #[test] - fn random_hex_chars_only() { - let hex = generate_random_hex(16); - assert!(hex.chars().all(|c| c.is_ascii_hexdigit())); - } - - #[test] - fn random_hex_uniqueness() { - let a = generate_random_hex(16); - let b = generate_random_hex(16); - assert_ne!(a, b); - } -} diff --git a/src/config/mod.rs b/src/config/mod.rs index 4e9b4a8..6ce556b 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -5,9 +5,8 @@ use std::path::{Path, PathBuf}; pub const DEFAULT_API_URL: &str = "https://api.steel.dev/v1"; pub const DEFAULT_LOCAL_API_URL: &str = "http://localhost:3000/v1"; -pub const LOGIN_URL: &str = "https://app.steel.dev/sign-in"; -pub const SUCCESS_URL: &str = "https://app.steel.dev/sign-in/cli-success"; -pub const API_KEYS_URL: &str = "https://api.steel.dev/v1/api-keys"; +pub const DEVICE_AUTH_URL: &str = "https://api.steel.dev/v1/auth/device"; +pub const DEVICE_TOKEN_URL: &str = "https://api.steel.dev/v1/auth/device/token"; pub const REPO_URL: &str = "https://github.com/steel-dev/steel-browser.git"; /// Resolve the Steel config directory. From 7b99ee2ee4977007a8a8cdf4ab04e604dcd85da1 Mon Sep 17 00:00:00 2001 From: junhsss Date: Fri, 17 Apr 2026 21:35:05 +0900 Subject: [PATCH 2/2] feat: paring code --- src/commands/login.rs | 30 ++++++++++++------------------ 1 file changed, 12 insertions(+), 18 deletions(-) diff --git a/src/commands/login.rs b/src/commands/login.rs index 19737a7..5edbe54 100644 --- a/src/commands/login.rs +++ b/src/commands/login.rs @@ -53,26 +53,23 @@ async fn device_code_flow() -> anyhow::Result<(String, String)> { .as_str() .ok_or_else(|| anyhow::anyhow!("Missing device_code in response"))? .to_string(); - let user_code = data["user_code"] + let pairing_code = data["pairing_code"] .as_str() - .ok_or_else(|| anyhow::anyhow!("Missing user_code in response"))?; - let verification_uri = data["verification_uri"] + .ok_or_else(|| anyhow::anyhow!("Missing pairing_code in response"))?; + let verification_uri_complete = data["verification_uri_complete"] .as_str() - .ok_or_else(|| anyhow::anyhow!("Missing verification_uri in response"))?; - let verification_uri_complete = data["verification_uri_complete"].as_str(); + .ok_or_else(|| anyhow::anyhow!("Missing verification_uri_complete in response"))?; let expires_in = data["expires_in"].as_u64().unwrap_or(900); let interval = data["interval"].as_u64().unwrap_or(5); // Step 2: Display instructions - status!("Visit: {verification_uri}"); - status!("Enter code: {user_code}"); - - // Try to open browser to the pre-filled URL - if let Some(uri) = verification_uri_complete { - if let Err(e) = open::that(uri) { - status!("Warning: could not open browser automatically: {e}"); - status!("Please open the URL above manually."); - } + status!("Please visit: {verification_uri_complete}"); + status!("Your pairing code is: {pairing_code}"); + + // Try to open browser to the URL (browser confirms the pairing code out of band) + if let Err(e) = open::that(verification_uri_complete) { + status!("Warning: could not open browser automatically: {e}"); + status!("Please open the URL above manually."); } // Step 3: Poll for token @@ -112,10 +109,7 @@ async fn device_code_flow() -> anyhow::Result<(String, String)> { // Success: api_key present if let Some(api_key) = body.get("api_key").and_then(|v| v.as_str()) { - let name = body - .get("name") - .and_then(|v| v.as_str()) - .unwrap_or("CLI"); + let name = body.get("name").and_then(|v| v.as_str()).unwrap_or("CLI"); return Ok((api_key.to_string(), name.to_string())); }