Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
310 changes: 82 additions & 228 deletions src/commands/login.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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
Expand All @@ -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)?;

Expand All @@ -32,111 +29,103 @@ pub async fn run(_args: Args) -> anyhow::Result<()> {
Ok(())
}

async fn login_flow() -> anyhow::Result<(String, String)> {
let state = generate_random_hex(16);
async fn device_code_flow() -> anyhow::Result<(String, String)> {
let client = reqwest::Client::new();

// Step 1: Request device code
let resp = client
.post(config::DEVICE_AUTH_URL)
.json(&serde_json::json!({ "client_id": "cli" }))
.send()
.await?;

if !resp.status().is_success() {
anyhow::bail!(
"Failed to start device authorization: {} {}",
resp.status().as_u16(),
resp.status().canonical_reason().unwrap_or("")
);
}

// Bind to random port on localhost
let listener = TcpListener::bind("127.0.0.1:0").await?;
let port = listener.local_addr()?.port();
let data: serde_json::Value = resp.json().await?;

let auth_url = format!(
"{}?cli_redirect=true&port={}&state={}",
config::LOGIN_URL,
port,
state
);
let device_code = data["device_code"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("Missing device_code in response"))?
.to_string();
let pairing_code = data["pairing_code"]
.as_str()
.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_complete in response"))?;
let expires_in = data["expires_in"].as_u64().unwrap_or(900);
let interval = data["interval"].as_u64().unwrap_or(5);

status!("Opening your browser for authentication...");
status!("If it does not open automatically, please click:");
status!("{auth_url}");
// Step 2: Display instructions
status!("Please visit: {verification_uri_complete}");
status!("Your pairing code is: {pairing_code}");

if let Err(e) = open::that(&auth_url) {
// 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.");
}

// 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)?;

// 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<()> {
Expand All @@ -152,138 +141,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);
}
}
5 changes: 2 additions & 3 deletions src/config/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading