diff --git a/README.md b/README.md index ec7e324..d6f23b5 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,36 @@ Alternatives: After install, either restart your shell or run `export PATH="$HOME/.steel/bin:$PATH"`. +### Device login + +`steel login` shows a short code and authorization URL; approve in a browser on +any machine. `steel login --resume` continues the saved request after interruption. +Non-interactive commands return promptly while approval is pending. Repeat +`steel init` after approval to continue setup. API keys remain supported, with +`STEEL_API_KEY` taking precedence for existing automation. + +The defaults are production values. These settings may be independently overridden +for a test environment; an audience is an exact resource identifier, not an API URL: + +| Setting | Default | +| --- | --- | +| `STEEL_AUTH_ISSUER` | `https://auth.steel.dev` | +| `STEEL_AUTH_AUDIENCE` | `https://api.steel.dev` | +| `STEEL_API_URL` | `https://api.steel.dev/v1` | + +Use a separate `STEEL_CONFIG_DIR` for testing. Auth context, pending authorization +and credentials are saved privately in its `auth.json`; credentials are never +silently retargeted when an override changes. Device access tokens use Bearer +authorization and refresh automatically; API keys still use `Steel-Api-Key`. +CDP uses the connection URL returned by the API without appending an API key or +device token. A missing connection URL is an error, not a synthesized fallback. + +`steel doctor --preflight` checks product API access. `steel logout` revokes the +saved refresh grant before deleting local state. `--local-only` explicitly forgets +local state when offline or recovering an interrupted refresh; revoke that device +in your account as well. Issued access JWTs can remain valid until expiry. +Do not paste or log access/refresh tokens, device codes or signed connection URLs. + ## Shell Completions Shell completions are installed automatically by `install.sh`. To regenerate or install manually: diff --git a/src/api/client.rs b/src/api/client.rs index 6782340..9016d4c 100644 --- a/src/api/client.rs +++ b/src/api/client.rs @@ -13,6 +13,9 @@ pub enum ApiError { #[error("Missing Steel API key. Run `steel login` or set `STEEL_API_KEY`.")] MissingAuth, + #[error("{0}")] + DeviceAuth(#[source] anyhow::Error), + #[error("Failed to reach Steel API at {url}.")] Unreachable { url: String, @@ -83,6 +86,25 @@ pub struct SteelClient { } impl SteelClient { + pub(crate) async fn authorize( + &self, + request: reqwest::RequestBuilder, + base: &str, + auth: &Auth, + ) -> Result { + if auth.access_token.is_some() { + let token = crate::config::device::access_token_for(base) + .await + .map_err(ApiError::DeviceAuth)?; + return Ok(request.bearer_auth(token)); + } + Ok(if let Some(key) = &auth.api_key { + request.header("Steel-Api-Key", key) + } else { + request + }) + } + pub fn new() -> Result { let http = Client::builder().user_agent("steel-cli").build()?; Ok(Self { http }) @@ -100,7 +122,7 @@ impl SteelClient { auth: &Auth, ) -> Result { // Cloud mode requires auth - if mode == ApiMode::Cloud && auth.api_key.is_none() { + if mode == ApiMode::Cloud && !auth.has_credentials() { return Err(ApiError::MissingAuth); } @@ -109,9 +131,7 @@ impl SteelClient { let mut req = self.http.request(method, &url); req = req.header("Content-Type", "application/json"); - if let Some(key) = &auth.api_key { - req = req.header("Steel-Api-Key", key); - } + req = self.authorize(req, base_url, auth).await?; if let Some(body) = body { req = req.json(&body); @@ -156,16 +176,14 @@ impl SteelClient { body: Option, auth: &Auth, ) -> Result { - if mode == ApiMode::Cloud && auth.api_key.is_none() { + if mode == ApiMode::Cloud && !auth.has_credentials() { return Err(ApiError::MissingAuth); } let url = format!("{base_url}{path}"); let mut req = self.http.request(method, &url); req = req.header("Content-Type", "application/json"); - if let Some(key) = &auth.api_key { - req = req.header("Steel-Api-Key", key); - } + req = self.authorize(req, base_url, auth).await?; if let Some(body) = body { req = req.json(&body); } @@ -273,6 +291,7 @@ mod tests { let auth = Auth { api_key: None, source: crate::config::auth::AuthSource::None, + access_token: None, }; let result = client @@ -296,6 +315,7 @@ mod tests { let auth = Auth { api_key: None, source: crate::config::auth::AuthSource::None, + access_token: None, }; let result = client diff --git a/src/api/projects.rs b/src/api/projects.rs index 834be10..22b8d8c 100644 --- a/src/api/projects.rs +++ b/src/api/projects.rs @@ -216,6 +216,7 @@ mod tests { let auth = Auth { api_key: Some("test-key".into()), source: AuthSource::Env, + access_token: None, }; let data = client diff --git a/src/api/session.rs b/src/api/session.rs index 9e8d41f..45929ff 100644 --- a/src/api/session.rs +++ b/src/api/session.rs @@ -405,6 +405,7 @@ mod tests { Auth { api_key: Some("test-key".into()), source: AuthSource::Env, + access_token: None, } } diff --git a/src/api/top_level.rs b/src/api/top_level.rs index e11de37..d29c153 100644 --- a/src/api/top_level.rs +++ b/src/api/top_level.rs @@ -318,6 +318,7 @@ mod tests { Auth { api_key: Some("test-key".into()), source: AuthSource::Env, + access_token: None, } } diff --git a/src/browser/daemon/protocol.rs b/src/browser/daemon/protocol.rs index ffe72d7..74e124b 100644 --- a/src/browser/daemon/protocol.rs +++ b/src/browser/daemon/protocol.rs @@ -12,6 +12,8 @@ pub const DEFAULT_INACTIVITY_TIMEOUT_MS: u64 = 120_000; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DaemonCreateParams { pub api_key: Option, + #[serde(default)] + pub device_auth: bool, pub base_url: String, pub mode: ApiMode, pub session_name: String, diff --git a/src/browser/daemon/server.rs b/src/browser/daemon/server.rs index b2a15a4..d2889c3 100644 --- a/src/browser/daemon/server.rs +++ b/src/browser/daemon/server.rs @@ -31,7 +31,12 @@ pub async fn run(session_name: String, params: DaemonCreateParams) -> Result<()> let api_client = SteelClient::new()?; let auth = Auth { api_key: params.api_key.clone(), - source: AuthSource::Env, + access_token: params.device_auth.then(String::new), + source: if params.device_auth { + AuthSource::Device + } else { + AuthSource::Env + }, }; let options = params.to_create_options(); @@ -47,7 +52,7 @@ pub async fn run(session_name: String, params: DaemonCreateParams) -> Result<()> } }; - let summary = match to_session_summary(&session, params.mode, Some(&session_name), &auth) { + let summary = match to_session_summary(&session, params.mode, Some(&session_name)) { Ok(s) => s, Err(e) => { cleanup(&session_name); @@ -58,6 +63,9 @@ pub async fn run(session_name: String, params: DaemonCreateParams) -> Result<()> let cdp_url = match summary.connect_url { Some(ref url) => url.clone(), None => { + let _ = api_client + .release_session(¶ms.base_url, params.mode, &summary.id, &auth) + .await; cleanup(&session_name); return Err(anyhow::anyhow!("Session has no CDP connect URL")); } diff --git a/src/browser/engine.rs b/src/browser/engine.rs index dea2d61..c3fb159 100644 --- a/src/browser/engine.rs +++ b/src/browser/engine.rs @@ -27,7 +27,10 @@ pub struct BrowserEngine { impl BrowserEngine { /// Connect to an existing browser via CDP endpoint. pub async fn connect(cdp_url: &str) -> Result { - let manager = BrowserManager::connect_cdp(cdp_url) + // Ensure root URLs with queries produce GET /?... rather than GET ?... + // in the native HTTP URI parser, matching browser URL serialization. + let normalized = url::Url::parse(cdp_url)?.to_string(); + let manager = BrowserManager::connect_cdp(&normalized) .await .map_err(|e| anyhow::anyhow!(e))?; Ok(Self { diff --git a/src/browser/lifecycle.rs b/src/browser/lifecycle.rs index 1bedf97..b1300a7 100644 --- a/src/browser/lifecycle.rs +++ b/src/browser/lifecycle.rs @@ -203,33 +203,11 @@ pub fn to_session_summary( session: &Value, mode: ApiMode, name: Option<&str>, - auth: &Auth, ) -> anyhow::Result { let session_id = get_session_id(session) .ok_or_else(|| anyhow::anyhow!("Session response did not contain an id."))?; - let mut connect_url = get_connect_url(session); - - // Fallback: build connect URL for cloud mode - if connect_url.is_none() - && mode == ApiMode::Cloud - && let Some(ref api_key) = auth.api_key - { - connect_url = Some(format!( - "wss://connect.steel.dev?apiKey={api_key}&sessionId={session_id}" - )); - } - - // Inject apiKey into connect URL for cloud mode - if let Some(ref url) = connect_url - && mode == ApiMode::Cloud - && let Some(ref api_key) = auth.api_key - && !url.contains("apiKey=") - && let Ok(mut parsed) = url::Url::parse(url) - { - parsed.query_pairs_mut().append_pair("apiKey", api_key); - connect_url = Some(parsed.to_string()); - } + let connect_url = get_connect_url(session); Ok(SessionSummary { id: session_id.clone(), @@ -404,14 +382,19 @@ fn is_terminal_captcha_status(status: &str) -> bool { matches!(status, "solved" | "failed" | "none") } -/// Sanitize connect URL for display (mask API key). +/// Connection URLs are display metadata, not a credential export. pub fn sanitize_connect_url(url: &str) -> String { if let Ok(mut parsed) = url::Url::parse(url) { + let _ = parsed.set_username(""); + let _ = parsed.set_password(None); let pairs: Vec<(String, String)> = parsed .query_pairs() .map(|(k, v)| { - if k == "apiKey" && v.len() > 7 { - (k.to_string(), format!("{}...", &v[..7])) + if matches!( + k.to_ascii_lowercase().as_str(), + "apikey" | "api_key" | "token" | "access_token" + ) { + (k.to_string(), "[redacted]".to_string()) } else { (k.to_string(), v.to_string()) } @@ -427,7 +410,7 @@ pub fn sanitize_connect_url(url: &str) -> String { parsed.to_string() } else { - url.to_string() + "[redacted connection URL]".to_string() } } @@ -509,7 +492,7 @@ mod tests { fn sanitize_masks_api_key() { let url = "wss://connect.steel.dev?apiKey=sk-12345678&sessionId=s1"; let sanitized = sanitize_connect_url(url); - assert!(sanitized.contains("sk-1234...")); + assert!(!sanitized.contains("sk-1234")); assert!(sanitized.contains("sessionId=s1")); } diff --git a/src/browser/profile_porter.rs b/src/browser/profile_porter.rs index 68b6866..3b5b202 100644 --- a/src/browser/profile_porter.rs +++ b/src/browser/profile_porter.rs @@ -966,7 +966,7 @@ pub fn package_profile( pub async fn upload_profile_to_steel( zip_buffer: Vec, - api_key: &str, + auth: &crate::config::auth::Auth, api_base: &str, ) -> Result { let part = reqwest::multipart::Part::bytes(zip_buffer) @@ -975,10 +975,10 @@ pub async fn upload_profile_to_steel( let form = reqwest::multipart::Form::new().part("userDataDir", part); let client = reqwest::Client::new(); - let res = client - .post(format!("{api_base}/profiles")) - .header("Steel-Api-Key", api_key) - .multipart(form) + let request = client.post(format!("{api_base}/profiles")).multipart(form); + let res = crate::api::client::SteelClient::new()? + .authorize(request, api_base, auth) + .await? .send() .await?; @@ -1003,7 +1003,7 @@ pub async fn upload_profile_to_steel( pub async fn update_profile_on_steel( profile_id: &str, zip_buffer: Vec, - api_key: &str, + auth: &crate::config::auth::Auth, api_base: &str, ) -> Result<()> { let part = reqwest::multipart::Part::bytes(zip_buffer) @@ -1012,10 +1012,12 @@ pub async fn update_profile_on_steel( let form = reqwest::multipart::Form::new().part("userDataDir", part); let client = reqwest::Client::new(); - let res = client + let request = client .patch(format!("{api_base}/profiles/{profile_id}")) - .header("Steel-Api-Key", api_key) - .multipart(form) + .multipart(form); + let res = crate::api::client::SteelClient::new()? + .authorize(request, api_base, auth) + .await? .send() .await?; diff --git a/src/commands/browser/action.rs b/src/commands/browser/action.rs index ad8d50d..9901773 100644 --- a/src/commands/browser/action.rs +++ b/src/commands/browser/action.rs @@ -1250,6 +1250,7 @@ async fn ensure_daemon(session_name: Option<&str>) -> Result { let (mode, base_url, auth) = api::resolve_with_auth(); let params = DaemonCreateParams { api_key: auth.api_key, + device_auth: auth.access_token.is_some(), base_url, mode, session_name: name.to_string(), diff --git a/src/commands/browser/start.rs b/src/commands/browser/start.rs index 210eb5a..c179cf3 100644 --- a/src/commands/browser/start.rs +++ b/src/commands/browser/start.rs @@ -104,6 +104,7 @@ pub async fn run(args: Args, session: Option<&str>) -> anyhow::Result<()> { // Build params and spawn daemon let params = DaemonCreateParams { api_key: auth.api_key, + device_auth: auth.access_token.is_some(), base_url, mode, session_name: session_name.clone(), diff --git a/src/commands/computer/ssh.rs b/src/commands/computer/ssh.rs index 0230f91..13eef11 100644 --- a/src/commands/computer/ssh.rs +++ b/src/commands/computer/ssh.rs @@ -51,7 +51,7 @@ impl Handler for Client { pub async fn run(args: Args) -> Result<()> { let id = resolve_computer_id(args.computer_id.as_deref())?; let (mode, base_url, auth) = api::resolve_with_auth(); - if mode == ApiMode::Cloud && auth.api_key.is_none() { + if mode == ApiMode::Cloud && !auth.has_credentials() { return Err(ApiError::MissingAuth.into()); } let url = ssh_url(&base_url, &id)?; @@ -123,6 +123,15 @@ async fn connect(url: &str, auth: &Auth) -> Result anyhow::Result<()> { let auth = auth::resolve_auth(); let config = read_config().ok(); - if auth.api_key.is_none() && config.is_none() { + if !auth.has_credentials() && config.is_none() { println!("You are not logged in. Please run `steel login` to authenticate."); return Ok(()); } @@ -30,6 +30,9 @@ pub async fn run(_args: Args) -> anyhow::Result<()> { } print_project_info(&auth).await; + if auth.access_token.is_some() { + println!("source: device (Bearer; token redacted)"); + } if let Some(ref cfg) = config && let Some(ref browser) = cfg.browser @@ -50,7 +53,7 @@ pub async fn run(_args: Args) -> anyhow::Result<()> { /// so `steel config` stays usable offline. async fn print_project_info(auth: &Auth) { let (mode, base_url) = crate::util::api::resolve(); - if mode != ApiMode::Cloud || auth.api_key.is_none() { + if mode != ApiMode::Cloud || !auth.has_credentials() { return; } let Ok(client) = SteelClient::new() else { diff --git a/src/commands/doctor.rs b/src/commands/doctor.rs index c647fbc..c1b6290 100644 --- a/src/commands/doctor.rs +++ b/src/commands/doctor.rs @@ -90,7 +90,7 @@ pub async fn run(args: Args) -> anyhow::Result<()> { async fn check_auth_and_api(mode: ApiMode, base_url: &str, auth: &Auth) -> Vec { let mut checks = Vec::new(); - let has_key = auth.api_key.is_some(); + let has_key = auth.has_credentials(); let need_auth = mode == ApiMode::Cloud; if !has_key && need_auth { @@ -116,7 +116,7 @@ async fn check_auth_and_api(mode: ApiMode, base_url: &str, auth: &Auth) -> Vec Vec Vec Vec { + checks.push(Check { + category: "auth", + name: error.to_string(), + status: "fail", + detail: json!(null), + fix: Some("steel login --resume"), + transient: false, + }); + } Err(ApiError::MissingAuth) => { checks.push(Check { category: "auth", diff --git a/src/commands/init/mod.rs b/src/commands/init/mod.rs index 455f3e6..12bea00 100644 --- a/src/commands/init/mod.rs +++ b/src/commands/init/mod.rs @@ -34,7 +34,16 @@ pub async fn run(args: Args) -> anyhow::Result<()> { status!(""); // Step 1: login (no-ops if already logged in). - login::run(login::Args {}).await?; + login::run(login::Args { + no_wait: args.agent, + no_browser: args.agent, + ..Default::default() + }) + .await?; + if !config::auth::resolve_auth().has_credentials() { + // Login already emitted its resumable handoff; do not report setup ready. + return Ok(()); + } // Step 2: preflight check. status!(""); diff --git a/src/commands/login.rs b/src/commands/login.rs index 72d88c3..94301b8 100644 --- a/src/commands/login.rs +++ b/src/commands/login.rs @@ -1,291 +1,45 @@ -use std::io::{BufRead, BufReader, Write as IoWrite}; - +use crate::config::{auth, device}; +use crate::util::output; use clap::Parser; -use tokio::net::TcpListener; - -use crate::config; -use crate::config::auth; -use crate::config::settings::{Config, read_config_from, write_config_to}; -use crate::status; - -#[derive(Parser)] -pub struct Args {} - -const LOGIN_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5 * 60); - -pub async fn run(_args: Args) -> anyhow::Result<()> { - // Check if already logged in - let existing_auth = auth::resolve_auth(); - if existing_auth.api_key.is_some() { - status!("You are already logged in."); +use std::io::IsTerminal; + +#[derive(Parser, Default)] +pub struct Args { + /// Continue the saved authorization request; do not issue a new code + #[arg(long)] + pub resume: bool, + /// Show the handoff without waiting; check an existing request once if due + #[arg(long)] + pub no_wait: bool, + /// Do not open a browser on this host + #[arg(long)] + pub no_browser: bool, +} + +pub async fn run(args: Args) -> anyhow::Result<()> { + let existing = auth::resolve_auth(); + if existing.api_key.is_some() { + output::success_data(serde_json::json!({"status":"authenticated","method":"api_key"})); return Ok(()); } - - status!("Launching browser for authentication..."); - - let (api_key, name) = login_flow().await?; - - save_api_key(&api_key, &name)?; - - crate::telemetry::track_event("login_completed", serde_json::Map::new()); - - status!("Authentication successful! Your API key has been saved."); - - 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 - ); - - status!("Opening your browser for authentication..."); - status!("If it does not open automatically, please click:"); - status!("{auth_url}"); - - if let Err(e) = open::that(&auth_url) { - 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); - - let received_state = query - .iter() - .find(|(k, _)| k == "state") - .map(|(_, v)| v.as_str()); - - if received_state != Some(&state) { - send_response(&std_stream, 400, "Error: Invalid state parameter.")?; - anyhow::bail!("Invalid state parameter. Possible CSRF attack."); + let interactive = std::io::stdout().is_terminal(); + let complete = device::login( + args.no_wait || !interactive, + args.no_browser || !interactive, + args.resume, + ) + .await?; + if complete { + let settings = device::saved_settings()?.expect("saved login"); + device::access_token_for(&settings.api_base_url).await?; + if existing.access_token.is_none() { + crate::telemetry::track_event("login_completed", serde_json::Map::new()); } - - 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."); - }; - - // 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; - - match result { - Ok(inner) => inner, - Err(_) => anyhow::bail!("Login timed out. Please try again."), } -} - -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?; - - if !response.status().is_success() { - anyhow::bail!( - "Failed to get API key: {} {}", - response.status().as_u16(), - response.status().canonical_reason().unwrap_or("") - ); - } - - 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<()> { - let config_dir = config::config_dir(); - std::fs::create_dir_all(&config_dir)?; - let config_path = config::config_path_in(&config_dir); - - let mut config = read_config_from(&config_path).unwrap_or_else(|_| Config::default()); - config.api_key = Some(api_key.to_string()); - config.name = Some(name.to_string()); - - write_config_to(&config_path, &config)?; - - 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()?; + output::success_data(serde_json::json!({ + "status":if complete {"authenticated"} else {"pending"}, + "method":"device", + "next_action":if complete {"Run steel doctor --preflight to verify product API access"} else {"Approve the displayed code, then run steel login --resume or repeat steel init"} + })); 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/commands/logout.rs b/src/commands/logout.rs index 5caa119..1f87b97 100644 --- a/src/commands/logout.rs +++ b/src/commands/logout.rs @@ -9,11 +9,21 @@ use crate::status; const ENV_KEY_WARNING: &str = "warning: STEEL_API_KEY is set in your environment and will still be used for authentication. Run `unset STEEL_API_KEY` to fully log out."; #[derive(Parser)] -pub struct Args {} +pub struct Args { + /// Forget local device state without server revocation (offline recovery) + #[arg(long)] + pub local_only: bool, +} -pub async fn run(_args: Args) -> anyhow::Result<()> { +pub async fn run(args: Args) -> anyhow::Result<()> { + let device_cleared = config::device::logout(args.local_only).await?; + if args.local_only { + eprintln!( + "Local state removed only; revoke the old device in your account. Issued JWTs remain valid until expiry." + ); + } let env_key_set = env_key_present(std::env::var("STEEL_API_KEY").ok().as_deref()); - let cleared = clear_saved_login(&config::config_path())?; + let cleared = clear_saved_login(&config::config_path())? || device_cleared; match (cleared, env_key_set) { (true, _) => status!("Successfully logged out. Have a great day!"), @@ -24,6 +34,9 @@ pub async fn run(_args: Args) -> anyhow::Result<()> { if env_key_set { status!("{ENV_KEY_WARNING}"); } + crate::util::output::success_data( + serde_json::json!({"status":if cleared {"logged_out"} else {"no_saved_credentials"},"environment_api_key_still_active":env_key_set}), + ); Ok(()) } diff --git a/src/commands/profile.rs b/src/commands/profile.rs index 7703153..d45e240 100644 --- a/src/commands/profile.rs +++ b/src/commands/profile.rs @@ -161,12 +161,12 @@ fn resolve_name(positional: Option, flag: Option) -> anyhow::Res .ok_or_else(|| anyhow::anyhow!("Profile name is required.")) } -fn resolve_api() -> anyhow::Result<(String, String)> { +fn resolve_api() -> anyhow::Result<(crate::config::auth::Auth, String)> { let (_, base_url, auth) = api::resolve_with_auth(); - let api_key = auth.api_key.ok_or_else(|| { - anyhow::anyhow!("Not authenticated. Run `steel login` or set STEEL_API_KEY.") - })?; - Ok((api_key, base_url)) + if !auth.has_credentials() { + anyhow::bail!("Not authenticated. Run steel login or set STEEL_API_KEY"); + } + Ok((auth, base_url)) } fn parse_browser(name: &str) -> anyhow::Result { @@ -222,7 +222,7 @@ async fn run_import(args: ImportArgs) -> anyhow::Result<()> { ); } - let (api_key, api_base) = resolve_api()?; + let (auth, api_base) = resolve_api()?; // Discover profiles let choices = discover_all_profiles(args.browser.as_deref())?; @@ -316,13 +316,13 @@ async fn run_import(args: ImportArgs) -> anyhow::Result<()> { profile_porter::update_profile_on_steel( &existing.profile_id, result.zip_buffer, - &api_key, + &auth, &api_base, ) .await?; existing.profile_id.clone() } else { - profile_porter::upload_profile_to_steel(result.zip_buffer, &api_key, &api_base).await? + profile_porter::upload_profile_to_steel(result.zip_buffer, &auth, &api_base).await? }; // Save metadata @@ -363,7 +363,7 @@ async fn run_sync(args: SyncArgs) -> anyhow::Result<()> { anyhow::bail!("{err}"); } - let (api_key, api_base) = resolve_api()?; + let (auth, api_base) = resolve_api()?; // Read existing profile let stored = profile_store::read_profile(&name)?.ok_or_else(|| { @@ -429,7 +429,7 @@ async fn run_sync(args: SyncArgs) -> anyhow::Result<()> { profile_porter::update_profile_on_steel( &stored.profile_id, result.zip_buffer, - &api_key, + &auth, &api_base, ) .await?; diff --git a/src/commands/sessions.rs b/src/commands/sessions.rs index 73a0917..89004f8 100644 --- a/src/commands/sessions.rs +++ b/src/commands/sessions.rs @@ -383,7 +383,16 @@ async fn follow_if_live( let url = build_ws_url(base_url, mode, auth, stream_kind, session_id)?; status!("Following live session {session_id}. Press Ctrl-C to stop."); - let (ws_stream, _) = connect_async(url.as_str()).await.with_context(|| { + use tokio_tungstenite::tungstenite::client::IntoClientRequest; + let mut request = url.as_str().into_client_request()?; + if auth.access_token.is_some() { + crate::config::device::validate_websocket_transport(base_url, &url)?; + let token = crate::config::device::access_token_for(base_url).await?; + request + .headers_mut() + .insert("Authorization", format!("Bearer {token}").parse()?); + } + let (ws_stream, _) = connect_async(request).await.with_context(|| { format!( "Failed to connect to live {} stream", stream_label(stream_kind) @@ -416,7 +425,7 @@ fn build_ws_url( session_id: &str, ) -> Result { let ws_base_url = std::env::var("STEEL_WS_URL").unwrap_or_else(|_| { - if mode == ApiMode::Cloud { + if mode == ApiMode::Cloud && auth.access_token.is_none() { "wss://connect.steel.dev".to_string() } else { base_url.to_string() @@ -615,6 +624,7 @@ mod tests { &Auth { api_key: Some("sk_test".into()), source: crate::config::auth::AuthSource::Env, + access_token: None, }, StreamKind::Traces, "sess", @@ -634,6 +644,7 @@ mod tests { &Auth { api_key: Some("sk_test".into()), source: crate::config::auth::AuthSource::Env, + access_token: None, }, StreamKind::Logs, "sess", diff --git a/src/commands/skills/mod.rs b/src/commands/skills/mod.rs index 5395a0c..ee906c3 100644 --- a/src/commands/skills/mod.rs +++ b/src/commands/skills/mod.rs @@ -404,7 +404,7 @@ async fn doctor(offline: bool) -> anyhow::Result<()> { let auth = crate::config::auth::resolve_auth(); let agent_paths = agent_paths_for(&manifest); let installed_count = agent_paths.iter().filter(|path| path.installed).count(); - let has_fail = !npx_ok || auth.api_key.is_none(); + let has_fail = !npx_ok || !auth.has_credentials(); if output::is_json() { output::success_data(json!({ @@ -412,7 +412,7 @@ async fn doctor(offline: bool) -> anyhow::Result<()> { "checks": { "manifest": manifest_ok, "npx": npx_ok, - "auth": auth.api_key.is_some(), + "auth": auth.has_credentials(), "installed_paths": installed_count, }, "paths": agent_paths, @@ -421,12 +421,12 @@ async fn doctor(offline: bool) -> anyhow::Result<()> { println!("Steel Skills Doctor\n"); println!("Manifest reachable: {}", yes_no(manifest_ok)); println!("npx available: {}", yes_no(npx_ok)); - println!("Steel auth configured: {}", yes_no(auth.api_key.is_some())); + println!("Steel auth configured: {}", yes_no(auth.has_credentials())); println!("Installed Steel skill paths detected: {installed_count}"); if !npx_ok { println!("\nFix: install Node.js/npm so `npx skills` is available."); } - if auth.api_key.is_none() { + if !auth.has_credentials() { println!("Fix: run `steel login` or set STEEL_API_KEY."); } if !manifest_ok { diff --git a/src/config/auth.rs b/src/config/auth.rs index 3546b2c..33a310f 100644 --- a/src/config/auth.rs +++ b/src/config/auth.rs @@ -6,6 +6,7 @@ use crate::config::settings; pub enum AuthSource { Env, Config, + Device, None, } @@ -14,17 +15,34 @@ impl std::fmt::Display for AuthSource { match self { Self::Env => write!(f, "env (STEEL_API_KEY)"), Self::Config => write!(f, "config"), + Self::Device => write!(f, "device (Bearer)"), Self::None => write!(f, "none"), } } } -#[derive(Debug, Clone)] +#[derive(Clone)] pub struct Auth { pub api_key: Option, + pub access_token: Option, pub source: AuthSource, } +impl Auth { + pub const fn has_credentials(&self) -> bool { + self.api_key.is_some() || self.access_token.is_some() + } +} + +impl std::fmt::Debug for Auth { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Auth") + .field("source", &self.source) + .field("configured", &self.has_credentials()) + .finish() + } +} + /// Resolve API key with explicit inputs (testable). /// /// Priority: env var → config file → none. @@ -36,6 +54,7 @@ pub fn resolve_auth_with(env_api_key: Option<&str>, config_api_key: Option<&str> if !trimmed.is_empty() { return Auth { api_key: Some(trimmed.to_string()), + access_token: None, source: AuthSource::Env, }; } @@ -47,6 +66,7 @@ pub fn resolve_auth_with(env_api_key: Option<&str>, config_api_key: Option<&str> if !trimmed.is_empty() { return Auth { api_key: Some(trimmed.to_string()), + access_token: None, source: AuthSource::Config, }; } @@ -54,6 +74,7 @@ pub fn resolve_auth_with(env_api_key: Option<&str>, config_api_key: Option<&str> Auth { api_key: None, + access_token: None, source: AuthSource::None, } } @@ -71,7 +92,18 @@ pub fn resolve_auth() -> Auth { let env_key = std::env::var("STEEL_API_KEY").ok(); let config_key = read_api_key_from_config(&super::config_path()); - resolve_auth_with(env_key.as_deref(), config_key.as_deref()) + let explicit = resolve_auth_with(env_key.as_deref(), None); + if explicit.has_credentials() { + return explicit; + } + if let Some(token) = super::device::saved_access_token() { + return Auth { + api_key: None, + access_token: Some(token), + source: AuthSource::Device, + }; + } + resolve_auth_with(None, config_key.as_deref()) } #[cfg(test)] diff --git a/src/config/device.rs b/src/config/device.rs new file mode 100644 index 0000000..9b7ef4b --- /dev/null +++ b/src/config/device.rs @@ -0,0 +1,562 @@ +//! Private state and protocol for the first-party device grant. +//! +//! Keep auth separate from API keys/config.json. Serializing polling, refresh +//! and logout across CLI processes is required by rotating refresh tokens. +use std::fs::{File, OpenOptions}; +use std::io::{Read, Write}; +use std::path::Path; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use anyhow::{Context, Result, bail}; +use serde::{Deserialize, Serialize}; + +const CLIENT_ID: &str = "steel-cli"; +const DEVICE_GRANT: &str = "urn:ietf:params:oauth:grant-type:device_code"; + +#[derive(Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct Settings { + pub issuer: String, + pub audience: String, + pub api_base_url: String, +} + +impl Settings { + pub fn resolve(saved: Option<&Self>) -> Result { + let value = |name: &str, previous: Option<&str>, default: &str| { + std::env::var(name) + .ok() + .filter(|s| !s.trim().is_empty()) + .unwrap_or_else(|| previous.unwrap_or(default).to_string()) + }; + let settings = Self { + issuer: value( + "STEEL_AUTH_ISSUER", + saved.map(|s| s.issuer.as_str()), + super::DEFAULT_AUTH_ISSUER, + ) + .trim_end_matches('/') + .to_string(), + audience: value( + "STEEL_AUTH_AUDIENCE", + saved.map(|s| s.audience.as_str()), + super::DEFAULT_AUTH_AUDIENCE, + ), + api_base_url: value( + "STEEL_API_URL", + saved.map(|s| s.api_base_url.as_str()), + super::DEFAULT_API_URL, + ) + .trim_end_matches('/') + .to_string(), + }; + settings.validate()?; + Ok(settings) + } + + fn validate(&self) -> Result<()> { + for (name, value) in [ + ("issuer", &self.issuer), + ("audience", &self.audience), + ("API transport", &self.api_base_url), + ] { + let url = url::Url::parse(value).with_context(|| format!("Invalid {name} URL"))?; + let local = matches!(url.host_str(), Some("localhost" | "127.0.0.1" | "[::1]")); + if !(url.scheme() == "https" || (local && url.scheme() == "http")) + || !url.username().is_empty() + || url.password().is_some() + || url.fragment().is_some() + || (name != "audience" && url.query().is_some()) + { + bail!( + "{name} requires HTTPS (HTTP only on loopback), without credentials or fragments" + ); + } + if name == "issuer" && url.path() != "/" { + bail!("Auth issuer must be an origin, not an API path"); + } + } + Ok(()) + } +} + +#[derive(Serialize, Deserialize)] +struct State { + settings: Settings, + installation_id: String, + pending: Option, + tokens: Option, + #[serde(default)] + refresh_in_flight: bool, +} + +#[derive(Serialize, Deserialize)] +struct Pending { + device_code: String, + user_code: String, + verification_uri: String, + verification_uri_complete: String, + expires_at: u64, + interval: u64, + next_poll_at: u64, +} + +#[derive(Serialize, Deserialize)] +struct Tokens { + access_token: String, + refresh_token: Option, + expires_at: u64, +} + +#[derive(Deserialize)] +struct DeviceCode { + device_code: String, + user_code: String, + verification_uri: String, + verification_uri_complete: String, + expires_in: u64, + interval: u64, +} + +#[derive(Deserialize)] +struct TokenResponse { + access_token: String, + refresh_token: Option, + expires_in: u64, + token_type: String, +} + +fn now() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} +fn state_path() -> std::path::PathBuf { + super::config_dir().join("auth.json") +} + +fn private_dir(path: &Path) -> Result<()> { + if std::fs::symlink_metadata(path).is_ok_and(|m| m.file_type().is_symlink()) { + bail!("Refusing symlinked auth directory"); + } + std::fs::create_dir_all(path)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700))?; + } + Ok(()) +} + +fn private_open(path: &Path, create: bool) -> Result { + let mut options = OpenOptions::new(); + options.read(true).write(create).create(create); + #[cfg(unix)] + { + use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; + options.mode(0o600).custom_flags(libc::O_NOFOLLOW); + let file = options.open(path)?; + if file.metadata()?.permissions().mode() & 0o077 != 0 { + bail!("Auth files require mode 0600"); + } + Ok(file) + } + #[cfg(not(unix))] + Ok(options.open(path)?) +} + +fn read() -> Result> { + let path = state_path(); + if let Some(parent) = path.parent() + && std::fs::symlink_metadata(parent).is_ok_and(|m| m.file_type().is_symlink()) + { + bail!("Refusing symlinked auth directory"); + } + let mut file = match private_open(&path, false) { + Ok(file) => file, + Err(error) + if error + .downcast_ref::() + .is_some_and(|e| e.kind() == std::io::ErrorKind::NotFound) => + { + return Ok(None); + } + Err(error) => return Err(error.context("Cannot read private auth state")), + }; + let mut text = String::new(); + file.read_to_string(&mut text)?; + // Never echo malformed input, which can contain credential values. + serde_json::from_str(&text).map(Some).map_err(|_| { + anyhow::anyhow!("Invalid auth.json; restore securely or run steel logout --local-only") + }) +} + +fn save(state: &State) -> Result<()> { + let path = state_path(); + let parent = path.parent().context("Missing auth directory")?; + private_dir(parent)?; + if std::fs::symlink_metadata(&path).is_ok_and(|m| m.file_type().is_symlink()) { + bail!("Refusing symlinked auth state"); + } + let mut temp = tempfile::NamedTempFile::new_in(parent)?; // 0600 before writing secrets. + temp.write_all(&serde_json::to_vec(state)?)?; + temp.as_file().sync_all()?; + temp.persist(&path) + .map_err(|_| anyhow::anyhow!("Could not atomically save auth state"))?; + File::open(parent)?.sync_all()?; + Ok(()) +} + +async fn lock() -> Result { + let dir = super::config_dir(); + private_dir(&dir)?; + tokio::task::spawn_blocking(move || { + let file = private_open(&dir.join("auth.lock"), true)?; + let deadline = std::time::Instant::now() + Duration::from_secs(20); + loop { + match file.try_lock() { + Ok(()) => return Ok(file), + Err(std::fs::TryLockError::WouldBlock) => { + if std::time::Instant::now() >= deadline { + bail!( + "Another CLI process is authorizing or refreshing; retry this command" + ); + } + std::thread::sleep(Duration::from_millis(50)); + } + Err(std::fs::TryLockError::Error(error)) => return Err(error.into()), + } + } + }) + .await? +} + +fn http() -> Result { + Ok(reqwest::Client::builder() + .timeout(Duration::from_secs(15)) + .redirect(reqwest::redirect::Policy::none()) + .user_agent("steel-cli") + .build()?) +} + +async fn exchange( + settings: &Settings, + fields: &[(&str, &str)], +) -> Result> { + let response = http()?.post(format!("{}/api/auth/oauth2/token", settings.issuer)).form(fields).send().await.context("Could not reach auth service; resume pending login or reauthorize an interrupted refresh")?; + let success = response.status().is_success(); + let body: serde_json::Value = response + .json() + .await + .context("Auth service returned non-JSON data")?; + if !success { + let code = body + .get("error") + .and_then(|v| v.as_str()) + .filter(|s| { + matches!( + *s, + "authorization_pending" + | "slow_down" + | "access_denied" + | "expired_token" + | "invalid_grant" + | "invalid_client" + | "invalid_target" + | "invalid_scope" + ) + }) + .unwrap_or("auth_service_error"); + return Ok(Err(code.to_string())); + } + let tokens: TokenResponse = + serde_json::from_value(body).map_err(|_| anyhow::anyhow!("Invalid auth token response"))?; + if tokens.access_token.is_empty() + || tokens.expires_in == 0 + || !tokens.token_type.eq_ignore_ascii_case("bearer") + { + bail!("Auth service did not return a usable Bearer token"); + } + Ok(Ok(tokens)) +} + +fn store_tokens(state: &mut State, response: TokenResponse) { + state.tokens = Some(Tokens { + access_token: response.access_token, + refresh_token: response.refresh_token, + expires_at: now().saturating_add(response.expires_in), + }); + state.pending = None; + state.refresh_in_flight = false; +} + +pub fn saved_settings() -> Result> { + Ok(read()?.map(|s| s.settings)) +} + +pub fn saved_access_token() -> Option { + match read() { + Ok(state) => state.and_then(|s| s.tokens.map(|t| t.access_token)), + Err(_) => Some(String::new()), // Request path reports error, never falls back to a saved key. + } +} + +/// Returns false while approval is pending. Repeated calls continue the same +/// request and polling schedule; --resume never replaces an expired request. +pub async fn login(no_wait: bool, no_browser: bool, resume: bool) -> Result { + let _guard = lock().await?; + let existing = read()?; + let settings = Settings::resolve(existing.as_ref().map(|s| &s.settings))?; + if existing.as_ref().is_some_and(|s| s.settings != settings) { + bail!("Auth issuer, audience or API transport changed; log out before switching context"); + } + let mut state = existing.unwrap_or_else(|| State { + settings, + installation_id: random_id(), + pending: None, + tokens: None, + refresh_in_flight: false, + }); + if state.tokens.is_some() { + return Ok(true); + } + if state + .pending + .as_ref() + .is_some_and(|p| p.expires_at <= now()) + { + state.pending = None; + save(&state)?; + if resume { + bail!("Device login expired; run steel login for a new code"); + } + } + let new_request = state.pending.is_none(); + if new_request { + if resume { + bail!("No pending login; run steel login"); + } + let response = http()? + .post(format!("{}/api/auth/device/code", state.settings.issuer)) + .header("x-steel-device-installation", &state.installation_id) + .header("x-steel-device-version", env!("CARGO_PKG_VERSION")) + .header("x-steel-device-platform", std::env::consts::OS) + .form(&[ + ("client_id", CLIENT_ID), + ("scope", "openid offline_access steel"), + ("resource", state.settings.audience.as_str()), + ]) + .send() + .await + .context("Could not reach device authorization endpoint")?; + if !response.status().is_success() { + bail!( + "Device authorization rejected (HTTP {}); check issuer, audience and client registration", + response.status().as_u16() + ); + } + let code: DeviceCode = response + .json() + .await + .map_err(|_| anyhow::anyhow!("Invalid device authorization response"))?; + if code.device_code.is_empty() || code.user_code.is_empty() || code.expires_in == 0 { + bail!("Unusable device authorization response"); + } + for uri in [&code.verification_uri, &code.verification_uri_complete] { + let url = url::Url::parse(uri)?; + if url.origin() != url::Url::parse(&state.settings.issuer)?.origin() + || !url.username().is_empty() + || url.password().is_some() + { + bail!("Verification URL does not belong to auth issuer"); + } + } + let interval = code.interval.max(1); + state.pending = Some(Pending { + device_code: code.device_code, + user_code: code.user_code, + verification_uri: code.verification_uri, + verification_uri_complete: code.verification_uri_complete, + expires_at: now().saturating_add(code.expires_in), + interval, + next_poll_at: now().saturating_add(interval), + }); + save(&state)?; + } + let pending = state.pending.as_ref().expect("pending request"); + eprintln!("Authorize Steel at {}", pending.verification_uri_complete); + eprintln!( + "Compare code {} in the browser, or visit {} and enter it.", + pending.user_code, pending.verification_uri + ); + eprintln!( + "After approval, run steel login --resume or repeat steel init. Pending login survives interruption." + ); + if new_request && !no_browser { + let _ = open::that(&pending.verification_uri_complete); + } + let deadline = tokio::time::Instant::now() + + Duration::from_secs(if no_wait { u64::from(!new_request) } else { 45 }); + loop { + let p = state.pending.as_mut().expect("pending request"); + if now() >= p.expires_at { + state.pending = None; + save(&state)?; + bail!("Device login expired; run steel login again"); + } + let delay = Duration::from_secs(p.next_poll_at.saturating_sub(now())); + if delay >= deadline.saturating_duration_since(tokio::time::Instant::now()) { + return Ok(false); + } + tokio::time::sleep(delay).await; + p.next_poll_at = now().saturating_add(p.interval); + save(&state)?; + let p = state.pending.as_ref().expect("pending request"); + match exchange( + &state.settings, + &[ + ("grant_type", DEVICE_GRANT), + ("device_code", &p.device_code), + ("client_id", CLIENT_ID), + ], + ) + .await? + { + Ok(tokens) => { + store_tokens(&mut state, tokens); + save(&state)?; + return Ok(true); + } + Err(code) if code == "authorization_pending" => {} + Err(code) if code == "slow_down" => { + let p = state.pending.as_mut().expect("pending request"); + p.interval = p.interval.saturating_add(5); + p.next_poll_at = now().saturating_add(p.interval); + save(&state)?; + } + Err(code) => { + if matches!( + code.as_str(), + "access_denied" | "expired_token" | "invalid_grant" + ) { + state.pending = None; + save(&state)?; + } + bail!("Device login failed ({code}); run steel login again"); + } + } + } +} + +pub async fn access_token_for(api_base_url: &str) -> Result { + let _guard = lock().await?; + let mut state = read()?.context("Not logged in; run steel login")?; + if state.refresh_in_flight { + bail!( + "Previous refresh was interrupted; refusing to replay its token. Run steel logout --local-only, revoke the old device in your account, then log in again" + ); + } + if Settings::resolve(Some(&state.settings))? != state.settings + || api_base_url.trim_end_matches('/') != state.settings.api_base_url + { + bail!( + "Refusing to send tokens to a changed auth context or API transport; log out before switching" + ); + } + let tokens = state + .tokens + .as_ref() + .context("Login pending; run steel login --resume")?; + if tokens.expires_at > now().saturating_add(30) { + return Ok(tokens.access_token.clone()); + } + let refresh = tokens + .refresh_token + .clone() + .context("No usable refresh token; log out and sign in again")?; + state.refresh_in_flight = true; + save(&state)?; // Durable before consuming a rotating token. + match exchange( + &state.settings, + &[ + ("grant_type", "refresh_token"), + ("refresh_token", &refresh), + ("client_id", CLIENT_ID), + ], + ) + .await? + { + Ok(tokens) => { + store_tokens(&mut state, tokens); + save(&state)?; + Ok(state.tokens.expect("saved tokens").access_token) + } + Err(code) => bail!( + "Refresh failed ({code}); use steel logout --local-only, revoke the old device in your account, then log in again" + ), + } +} + +pub async fn logout(local_only: bool) -> Result { + let _guard = lock().await?; + let path = state_path(); + if local_only { + return match std::fs::remove_file(path) { + Ok(()) => Ok(true), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(e) => Err(e.into()), + }; + } + let Some(state) = read()? else { + return Ok(false); + }; + state.settings.validate()?; + if let Some(refresh) = state.tokens.and_then(|t| t.refresh_token) { + let response = http()? + .post(format!("{}/api/auth/oauth2/revoke", state.settings.issuer)) + .form(&[ + ("token", refresh.as_str()), + ("client_id", CLIENT_ID), + ("token_type_hint", "refresh_token"), + ]) + .send() + .await + .context("Revocation unreachable; local state retained for retry")?; + if !response.status().is_success() { + bail!( + "Revocation failed (HTTP {}); local state retained", + response.status().as_u16() + ); + } + } + std::fs::remove_file(path)?; + Ok(true) +} + +pub fn validate_websocket_transport(api_base: &str, endpoint: &str) -> Result<()> { + let base = url::Url::parse(api_base)?; + let mut target = url::Url::parse(endpoint)?; + let scheme = match target.scheme() { + "wss" => "https", + "ws" => "http", + _ => bail!("Invalid product WebSocket scheme"), + }; + target + .set_scheme(scheme) + .map_err(|_| anyhow::anyhow!("Invalid WebSocket URL"))?; + if target.origin() != base.origin() + || !target.username().is_empty() + || target.password().is_some() + { + bail!( + "Refusing a product-audience token on a different WebSocket transport; use session-scoped gateway credentials" + ); + } + Ok(()) +} + +fn random_id() -> String { + let mut bytes = [0; 16]; + getrandom::fill(&mut bytes).expect("OS randomness"); + bytes.iter().map(|b| format!("{b:02x}")).collect() +} diff --git a/src/config/mod.rs b/src/config/mod.rs index 4e9b4a8..2f8e1d0 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -1,9 +1,12 @@ pub mod auth; +pub mod device; pub mod settings; use std::path::{Path, PathBuf}; pub const DEFAULT_API_URL: &str = "https://api.steel.dev/v1"; +pub const DEFAULT_AUTH_ISSUER: &str = "https://auth.steel.dev"; +pub const DEFAULT_AUTH_AUDIENCE: &str = "https://api.steel.dev"; 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"; diff --git a/src/telemetry.rs b/src/telemetry.rs index 154a913..225116b 100644 --- a/src/telemetry.rs +++ b/src/telemetry.rs @@ -388,6 +388,7 @@ fn error_class(err: &anyhow::Error) -> &'static str { if let Some(api_err) = err.downcast_ref::() { return match api_err { crate::api::client::ApiError::MissingAuth => "auth_required", + crate::api::client::ApiError::DeviceAuth(_) => "device_auth_failed", crate::api::client::ApiError::Unreachable { .. } => "network_unreachable", crate::api::client::ApiError::RequestFailed { status, .. } => match *status { 401 => "api_unauthorized", diff --git a/src/util/api.rs b/src/util/api.rs index cb6cd53..0f9ae33 100644 --- a/src/util/api.rs +++ b/src/util/api.rs @@ -37,7 +37,21 @@ pub fn resolve() -> (ApiMode, String) { let env_vars = EnvVars::from_env(); let config = crate::config::settings::read_config().ok(); let local_config_url = config.as_ref().and_then(|c| c.local_api_url()); - let base_url = mode.resolve_base_url(api_url(), &env_vars, local_config_url); + let base_url = if mode == ApiMode::Cloud + && env_vars + .steel_api_url + .as_deref() + .is_none_or(|s| s.trim().is_empty()) + && auth::resolve_auth().access_token.is_some() + { + crate::config::device::saved_settings() + .ok() + .flatten() + .map(|s| s.api_base_url) + .unwrap_or_else(|| mode.resolve_base_url(api_url(), &env_vars, local_config_url)) + } else { + mode.resolve_base_url(api_url(), &env_vars, local_config_url) + }; (mode, base_url) } diff --git a/src/util/output.rs b/src/util/output.rs index 342f55c..6b3e806 100644 --- a/src/util/output.rs +++ b/src/util/output.rs @@ -243,6 +243,11 @@ pub fn handle_error(err: &anyhow::Error) -> ! { fn classify_error(err: &anyhow::Error) -> (i32, &'static str, Option<&'static str>) { if let Some(api_err) = err.downcast_ref::() { match api_err { + crate::api::client::ApiError::DeviceAuth(_) => ( + exit_code::AUTH, + "device_auth_failed", + Some("Resume pending login or reauthorize as directed."), + ), crate::api::client::ApiError::MissingAuth => ( exit_code::AUTH, "auth_required", diff --git a/tests/browser_device_auth.rs b/tests/browser_device_auth.rs new file mode 100644 index 0000000..33df804 --- /dev/null +++ b/tests/browser_device_auth.rs @@ -0,0 +1,165 @@ +//! Token-only device credential -> product API -> native CDP -> explicit release. +//! Exercises real binary/wire format without any live service or product secret. +use futures_util::{SinkExt, StreamExt}; +use serde_json::{Value, json}; +use std::{ + process::Command, + sync::{Arc, Mutex}, +}; +use tokio::net::TcpListener; +use tokio_tungstenite::tungstenite::{ + Message, + handshake::server::{Request, Response}, +}; +use wiremock::{ + Mock, MockServer, ResponseTemplate, + matchers::{header, method, path}, +}; + +struct Fixture { + home: tempfile::TempDir, + api: MockServer, + cdp: tokio::task::JoinHandle<()>, +} +impl Fixture { + fn command(&self) -> Command { + let mut cmd = Command::new(env!("CARGO_BIN_EXE_steel")); + cmd.env("HOME", self.home.path()) + .env("STEEL_CONFIG_DIR", self.home.path()) + .env("STEEL_TELEMETRY_DISABLED", "1") + .env_remove("STEEL_API_KEY") + .env_remove("STEEL_API_URL") + .env_remove("STEEL_AUTH_ISSUER") + .env_remove("STEEL_AUTH_AUDIENCE") + .args(["--no-update-check", "--json"]); + cmd + } +} +impl Drop for Fixture { + fn drop(&mut self) { + let _ = self + .command() + .args(["browser", "stop", "--session", "test"]) + .output(); + self.cdp.abort(); + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn device_auth_uses_bearer_and_normalizes_root_query_before_native_cdp() { + use std::os::unix::fs::PermissionsExt; + let api = MockServer::start().await; + let home = tempfile::tempdir().unwrap(); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let endpoint = format!( + "ws://{}?sessionId=test-session&token=synthetic-connect-token", + listener.local_addr().unwrap() + ); + let seen = Arc::new(Mutex::new(Vec::new())); + let observations = Arc::clone(&seen); + let cdp = tokio::spawn(async move { + while let Ok((stream, _)) = listener.accept().await { + let observations = Arc::clone(&observations); + tokio::spawn(async move { + let Ok(mut socket) = tokio_tungstenite::accept_hdr_async( + stream, + move |request: &Request, response: Response| { + observations + .lock() + .unwrap() + .push(request.uri().path_and_query().unwrap().as_str().to_string()); + Ok(response) + }, + ) + .await + else { + return; + }; + while let Some(Ok(Message::Text(text))) = socket.next().await { + let request: Value = serde_json::from_str(&text).unwrap(); + let result = match request["method"].as_str().unwrap_or("") { + "Target.getTargets" => { + json!({"targetInfos":[{"targetId":"page","type":"page","title":"Synthetic page","url":"about:blank","attached":false}]}) + } + "Target.attachToTarget" => json!({"sessionId":"synthetic-cdp-session"}), + _ => json!({}), + }; + if socket + .send(Message::Text( + json!({"id":request["id"],"result":result}) + .to_string() + .into(), + )) + .await + .is_err() + { + break; + } + if request["method"] == "Browser.close" { + break; + } + } + }); + } + }); + Mock::given(method("POST")) + .and(path("/sessions")) + .and(header("authorization", "Bearer synthetic-access-token")) + .respond_with(ResponseTemplate::new(201).set_body_json( + json!({"id":"test-session","status":"live","timeout":300000,"websocketUrl":endpoint}), + )) + .expect(1) + .mount(&api) + .await; + Mock::given(method("POST")) + .and(path("/sessions/test-session/release")) + .and(header("authorization", "Bearer synthetic-access-token")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({"status":"released"}))) + .expect(1) + .mount(&api) + .await; + let auth = json!({"settings":{"issuer":api.uri(),"audience":"https://resource.example.test","api_base_url":api.uri()},"installation_id":"synthetic-installation","pending":null,"tokens":{"access_token":"synthetic-access-token","refresh_token":"synthetic-refresh-token","expires_at":u64::MAX}}); + let file = home.path().join("auth.json"); + std::fs::write(&file, serde_json::to_vec(&auth).unwrap()).unwrap(); + std::fs::set_permissions(file, std::fs::Permissions::from_mode(0o600)).unwrap(); + let fixture = Fixture { home, api, cdp }; + let start = fixture + .command() + .args(["browser", "start", "--session", "test"]) + .output() + .unwrap(); + assert!( + start.status.success(), + "{}", + String::from_utf8_lossy(&start.stdout) + ); + assert_eq!( + *seen.lock().unwrap(), + ["/?sessionId=test-session&token=synthetic-connect-token"] + ); + assert!(!String::from_utf8_lossy(&start.stdout).contains("synthetic-connect-token")); + assert!(!String::from_utf8_lossy(&start.stdout).contains("synthetic-access-token")); + let stop = fixture + .command() + .args(["browser", "stop", "--session", "test"]) + .output() + .unwrap(); + assert!(stop.status.success()); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(3); + loop { + let requests = fixture.api.received_requests().await.unwrap(); + if requests.iter().any(|r| r.url.path().ends_with("/release")) { + assert!( + requests + .iter() + .all(|r| !r.headers.contains_key("steel-api-key")) + ); + break; + } + assert!( + std::time::Instant::now() < deadline, + "Explicit stop did not release session" + ); + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + } +} diff --git a/tests/device_auth.rs b/tests/device_auth.rs new file mode 100644 index 0000000..4dadcd8 --- /dev/null +++ b/tests/device_auth.rs @@ -0,0 +1,324 @@ +//! Offline protocol/credential tests; subprocesses never use normal user state. +use serde_json::{Value, json}; +use std::process::{Command, Output}; +use wiremock::{ + Mock, MockServer, ResponseTemplate, + matchers::{body_string_contains, header, method, path}, +}; + +fn command(home: &std::path::Path) -> Command { + let mut command = Command::new(env!("CARGO_BIN_EXE_steel")); + command + .env("HOME", home) + .env("STEEL_CONFIG_DIR", home) + .env("STEEL_TELEMETRY_DISABLED", "1") + .env_remove("STEEL_API_KEY") + .env_remove("STEEL_API_URL") + .env_remove("STEEL_AUTH_ISSUER") + .env_remove("STEEL_AUTH_AUDIENCE") + .args(["--no-update-check", "--json"]); + command +} + +fn state(home: &std::path::Path, issuer: &str, transport: &str, expires: u64) { + let file = home.join("auth.json"); + std::fs::write(&file,serde_json::to_vec(&json!({"settings":{"issuer":issuer,"audience":"https://resource.example.test","api_base_url":transport},"installation_id":"synthetic-installation","pending":null,"tokens":{"access_token":"old-access-fixture","refresh_token":"old-refresh-fixture","expires_at":expires}})).unwrap()).unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(file, std::fs::Permissions::from_mode(0o600)).unwrap(); + } +} + +fn text(output: &Output) -> String { + format!( + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ) +} + +async fn device(server: &MockServer) { + Mock::given(method("POST")).and(path("/api/auth/device/code")) + .and(body_string_contains("client_id=steel-cli")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({"device_code":"private-device-fixture","user_code":"ABCD2345","verification_uri":format!("{}/device",server.uri()),"verification_uri_complete":format!("{}/device?user_code=ABCD2345",server.uri()),"expires_in":600,"interval":1}))) + .expect(1).mount(server).await; +} + +#[tokio::test] +async fn production_defaults_are_saved_and_independent_overrides_are_not_inferred() { + let home = tempfile::tempdir().unwrap(); + let auth = MockServer::start().await; + device(&auth).await; + let result = command(home.path()) + .env("STEEL_AUTH_ISSUER", auth.uri()) + .args(["login", "--no-wait", "--no-browser"]) + .output() + .unwrap(); + assert!(result.status.success(), "{}", text(&result)); + let saved: Value = + serde_json::from_slice(&std::fs::read(home.path().join("auth.json")).unwrap()).unwrap(); + assert_eq!(saved["settings"]["issuer"], auth.uri()); + assert_eq!(saved["settings"]["audience"], "https://api.steel.dev"); + assert_eq!( + saved["settings"]["api_base_url"], + "https://api.steel.dev/v1" + ); + assert_eq!( + steel_cli::config::DEFAULT_AUTH_ISSUER, + "https://auth.steel.dev" + ); + let request = &auth.received_requests().await.unwrap()[0]; + assert!( + String::from_utf8_lossy(&request.body).contains("resource=https%3A%2F%2Fapi.steel.dev") + ); +} + +#[tokio::test] +async fn pending_login_resumes_same_request_and_persists_private_tokens() { + use std::io::BufRead; + let home = tempfile::tempdir().unwrap(); + let auth = MockServer::start().await; + let api = MockServer::start().await; + device(&auth).await; + Mock::given(method("POST")).and(path("/api/auth/oauth2/token")) + .and(body_string_contains("device_code=private-device-fixture")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({"access_token":"new-access-fixture","refresh_token":"new-refresh-fixture","expires_in":600,"token_type":"Bearer"}))) + .expect(1).mount(&auth).await; + let mut child = command(home.path()) + .env("STEEL_AUTH_ISSUER", auth.uri()) + .env("STEEL_AUTH_AUDIENCE", "https://resource.example.test") + .env("STEEL_API_URL", api.uri()) + .args(["login", "--no-browser"]) + .stderr(std::process::Stdio::piped()) + .stdout(std::process::Stdio::null()) + .spawn() + .unwrap(); + let mut line = String::new(); + std::io::BufReader::new(child.stderr.take().unwrap()) + .read_line(&mut line) + .unwrap(); + assert!(line.contains("Authorize Steel")); + let _ = child.kill(); + child.wait().unwrap(); + std::thread::sleep(std::time::Duration::from_millis(1100)); + let result = command(home.path()) + .args(["login", "--resume", "--no-browser"]) + .output() + .unwrap(); + assert!(result.status.success(), "{}", text(&result)); + assert!(text(&result).contains("authenticated")); + assert!(!text(&result).contains("new-access-fixture")); + assert!(!text(&result).contains("new-refresh-fixture")); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + std::fs::metadata(home.path().join("auth.json")) + .unwrap() + .permissions() + .mode() + & 0o777, + 0o600 + ); + assert_eq!( + std::fs::metadata(home.path()).unwrap().permissions().mode() & 0o777, + 0o700 + ); + } +} + +#[tokio::test] +async fn concurrent_commands_rotate_once_and_use_bearer_without_an_api_key() { + let home = tempfile::tempdir().unwrap(); + let auth = MockServer::start().await; + let api = MockServer::start().await; + state(home.path(), &auth.uri(), &api.uri(), 0); + Mock::given(method("POST")).and(path("/api/auth/oauth2/token")).and(body_string_contains("refresh_token=old-refresh-fixture")) + .respond_with(ResponseTemplate::new(200).set_delay(std::time::Duration::from_millis(150)).set_body_json(json!({"access_token":"rotated-access-fixture","refresh_token":"rotated-refresh-fixture","expires_in":600,"token_type":"Bearer"}))) + .expect(1).mount(&auth).await; + Mock::given(method("GET")) + .and(path("/sessions")) + .and(header("Authorization", "Bearer rotated-access-fixture")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!([]))) + .expect(2) + .mount(&api) + .await; + let start = || { + command(home.path()) + .args(["sessions", "list"]) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .unwrap() + }; + let a = start(); + let b = start(); + for result in [a.wait_with_output().unwrap(), b.wait_with_output().unwrap()] { + assert!(result.status.success(), "{}", text(&result)); + assert!(!text(&result).contains("rotated-access-fixture")); + } + for request in api.received_requests().await.unwrap() { + assert!(!request.headers.contains_key("steel-api-key")); + } + let saved: Value = + serde_json::from_slice(&std::fs::read(home.path().join("auth.json")).unwrap()).unwrap(); + assert_eq!(saved["tokens"]["refresh_token"], "rotated-refresh-fixture"); + assert_eq!(saved["refresh_in_flight"], false); +} + +#[tokio::test] +async fn changed_transport_and_uncertain_refresh_never_send_or_replay_a_token() { + let home = tempfile::tempdir().unwrap(); + let auth = MockServer::start().await; + let api = MockServer::start().await; + let other = MockServer::start().await; + state(home.path(), &auth.uri(), &api.uri(), u64::MAX); + let result = command(home.path()) + .env("STEEL_API_URL", other.uri()) + .args(["sessions", "list"]) + .output() + .unwrap(); + assert!(!result.status.success()); + assert!(other.received_requests().await.unwrap().is_empty()); + let file = home.path().join("auth.json"); + let mut data: Value = serde_json::from_slice(&std::fs::read(&file).unwrap()).unwrap(); + data["refresh_in_flight"] = json!(true); + std::fs::write(&file, serde_json::to_vec(&data).unwrap()).unwrap(); + let result = command(home.path()) + .args(["sessions", "list"]) + .output() + .unwrap(); + assert!(!result.status.success()); + assert!(text(&result).contains("refusing to replay")); + assert!(auth.received_requests().await.unwrap().is_empty()); + assert!(api.received_requests().await.unwrap().is_empty()); +} + +#[tokio::test] +async fn logout_revokes_before_deleting_and_missing_refresh_does_not_reuse_old_value() { + let home = tempfile::tempdir().unwrap(); + let auth = MockServer::start().await; + let api = MockServer::start().await; + state(home.path(), &auth.uri(), &api.uri(), 0); + Mock::given(method("POST")) + .and(path("/api/auth/oauth2/token")) + .respond_with(ResponseTemplate::new(200).set_body_json( + json!({"access_token":"access-without-refresh","expires_in":600,"token_type":"Bearer"}), + )) + .expect(1) + .mount(&auth) + .await; + Mock::given(method("GET")) + .and(path("/sessions")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!([]))) + .mount(&api) + .await; + let result = command(home.path()) + .args(["sessions", "list"]) + .output() + .unwrap(); + assert!(result.status.success()); + let saved: Value = + serde_json::from_slice(&std::fs::read(home.path().join("auth.json")).unwrap()).unwrap(); + assert!(saved["tokens"]["refresh_token"].is_null()); + state(home.path(), &auth.uri(), &api.uri(), u64::MAX); + Mock::given(method("POST")) + .and(path("/api/auth/oauth2/revoke")) + .and(body_string_contains("token=old-refresh-fixture")) + .respond_with(ResponseTemplate::new(200)) + .expect(1) + .mount(&auth) + .await; + let result = command(home.path()).arg("logout").output().unwrap(); + assert!(result.status.success()); + assert!(!home.path().join("auth.json").exists()); +} + +#[cfg(unix)] +#[tokio::test] +async fn unsafe_or_symlinked_state_never_falls_back_to_saved_key() { + use std::os::unix::fs::{PermissionsExt, symlink}; + let home = tempfile::tempdir().unwrap(); + let api = MockServer::start().await; + state(home.path(), &api.uri(), &api.uri(), u64::MAX); + std::fs::write( + home.path().join("config.json"), + r#"{"apiKey":"unrelated-key-fixture"}"#, + ) + .unwrap(); + let file = home.path().join("auth.json"); + std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o644)).unwrap(); + let result = command(home.path()) + .env("STEEL_API_URL", api.uri()) + .args(["sessions", "list"]) + .output() + .unwrap(); + assert!(!result.status.success()); + let target = home.path().join("other.json"); + std::fs::rename(&file, &target).unwrap(); + symlink(&target, &file).unwrap(); + let result = command(home.path()) + .env("STEEL_API_URL", api.uri()) + .args(["sessions", "list"]) + .output() + .unwrap(); + assert!(!result.status.success()); + assert!(api.received_requests().await.unwrap().is_empty()); + assert!( + command(home.path()) + .args(["logout", "--local-only"]) + .output() + .unwrap() + .status + .success() + ); + assert!(target.exists()); +} + +#[tokio::test] +async fn pending_backoff_and_expiry_survive_separate_commands() { + let home = tempfile::tempdir().unwrap(); + let auth = MockServer::start().await; + device(&auth).await; + let start = command(home.path()) + .env("STEEL_AUTH_ISSUER", auth.uri()) + .args(["login", "--no-wait", "--no-browser"]) + .output() + .unwrap(); + assert!(start.status.success()); + Mock::given(method("POST")) + .and(path("/api/auth/oauth2/token")) + .respond_with(ResponseTemplate::new(400).set_body_json( + json!({"error":"slow_down","error_description":"never-echo-body-fixture"}), + )) + .expect(1) + .mount(&auth) + .await; + let file = home.path().join("auth.json"); + let mut data: Value = serde_json::from_slice(&std::fs::read(&file).unwrap()).unwrap(); + data["pending"]["next_poll_at"] = json!(0); + std::fs::write(&file, serde_json::to_vec(&data).unwrap()).unwrap(); + let result = command(home.path()) + .args(["login", "--resume", "--no-wait", "--no-browser"]) + .output() + .unwrap(); + assert!(result.status.success()); + assert!(!text(&result).contains("never-echo-body-fixture")); + let mut data: Value = serde_json::from_slice(&std::fs::read(&file).unwrap()).unwrap(); + assert_eq!(data["pending"]["interval"], 6); + assert!(data["pending"]["next_poll_at"].as_u64().unwrap() > 0); + data["pending"]["expires_at"] = json!(0); + std::fs::write(&file, serde_json::to_vec(&data).unwrap()).unwrap(); + let expired = command(home.path()) + .args(["login", "--resume", "--no-browser"]) + .output() + .unwrap(); + assert!(!expired.status.success()); + assert!(text(&expired).contains("expired")); + assert_eq!( + auth.received_requests().await.unwrap().len(), + 2, + "Resume must not silently issue a new code" + ); +} diff --git a/tests/lifecycle_contract.rs b/tests/lifecycle_contract.rs index 026addb..1376c75 100644 --- a/tests/lifecycle_contract.rs +++ b/tests/lifecycle_contract.rs @@ -1,7 +1,7 @@ //! Integration tests for browser session lifecycle contracts. //! //! Pure-function tests for `to_session_summary`, `sanitize_connect_url`, and -//! connect-URL injection logic. Also covers daemon protocol types +//! API-returned connection URLs. Also covers daemon protocol types //! (`DaemonCreateParams`, `SessionInfo`, `GetSessionInfo`) and //! `list_daemon_names()`. @@ -10,51 +10,24 @@ use tempfile::TempDir; use steel_cli::browser::daemon::protocol::{DaemonCommand, DaemonCreateParams, SessionInfo}; use steel_cli::browser::lifecycle::*; -use steel_cli::config::auth::{Auth, AuthSource}; use steel_cli::config::settings::ApiMode; -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -fn cloud_auth() -> Auth { - Auth { - api_key: Some("sk-test-key-12345678".to_string()), - source: AuthSource::Env, - } -} - -const fn local_auth() -> Auth { - Auth { - api_key: None, - source: AuthSource::None, - } -} - // =========================================================================== -// Connect URL Contract: injects apiKey into connect URL for cloud mode +// Connect URL Contract: use API-returned URLs without adding credentials // =========================================================================== #[test] -fn injects_api_key_into_connect_url_for_cloud() { +fn preserves_token_only_connect_url_for_cloud() { let session = json!({ "id": "sess-1", "status": "live", "isLive": true, - "websocketUrl": "wss://connect.steel.dev?sessionId=sess-1", + "websocketUrl": "wss://connect.steel.dev?sessionId=sess-1&token=synthetic-connect-token", }); - let auth = cloud_auth(); - let summary = to_session_summary(&session, ApiMode::Cloud, None, &auth).unwrap(); + let summary = to_session_summary(&session, ApiMode::Cloud, None).unwrap(); let url = summary.connect_url.unwrap(); - assert!( - url.contains("apiKey=sk-test-key-12345678"), - "Expected apiKey in URL, got: {url}" - ); - assert!( - url.contains("sessionId=sess-1"), - "Expected sessionId preserved in URL, got: {url}" - ); + assert_eq!(url, session["websocketUrl"].as_str().unwrap()); } #[test] @@ -65,8 +38,7 @@ fn does_not_inject_api_key_for_local_mode() { "isLive": true, "websocketUrl": "ws://localhost:3000/ws?sessionId=sess-1", }); - let auth = cloud_auth(); - let summary = to_session_summary(&session, ApiMode::Local, None, &auth).unwrap(); + let summary = to_session_summary(&session, ApiMode::Local, None).unwrap(); let url = summary.connect_url.unwrap(); assert!( @@ -76,18 +48,17 @@ fn does_not_inject_api_key_for_local_mode() { } #[test] -fn does_not_duplicate_api_key_if_already_present() { +fn preserves_api_returned_credentials() { let session = json!({ "id": "sess-1", "status": "live", "isLive": true, "websocketUrl": "wss://connect.steel.dev?apiKey=existing-key&sessionId=sess-1", }); - let auth = cloud_auth(); - let summary = to_session_summary(&session, ApiMode::Cloud, None, &auth).unwrap(); + let summary = to_session_summary(&session, ApiMode::Cloud, None).unwrap(); let url = summary.connect_url.unwrap(); - // Should keep existing key, not inject a second one + // Do not rewrite credentials supplied by the API. assert!(url.contains("apiKey=existing-key")); let count = url.matches("apiKey").count(); assert_eq!( @@ -97,32 +68,18 @@ fn does_not_duplicate_api_key_if_already_present() { } // =========================================================================== -// Connect URL Contract: fallback builds wss://connect.steel.dev URL +// Connect URL Contract: missing URLs must not synthesize API-key fallbacks // =========================================================================== #[test] -fn builds_fallback_connect_url_when_not_provided() { +fn no_fallback_connect_url_for_cloud_mode() { let session = json!({ "id": "sess-1", "status": "live", "isLive": true, }); - let auth = cloud_auth(); - let summary = to_session_summary(&session, ApiMode::Cloud, None, &auth).unwrap(); - - let url = summary.connect_url.unwrap(); - assert!( - url.starts_with("wss://connect.steel.dev"), - "Expected fallback URL, got: {url}" - ); - assert!( - url.contains("apiKey=sk-test-key-12345678"), - "Expected apiKey in fallback URL, got: {url}" - ); - assert!( - url.contains("sessionId=sess-1"), - "Expected sessionId in fallback URL, got: {url}" - ); + let summary = to_session_summary(&session, ApiMode::Cloud, None).unwrap(); + assert!(summary.connect_url.is_none()); } #[test] @@ -132,8 +89,7 @@ fn no_fallback_connect_url_for_local_mode() { "status": "live", "isLive": true, }); - let auth = local_auth(); - let summary = to_session_summary(&session, ApiMode::Local, None, &auth).unwrap(); + let summary = to_session_summary(&session, ApiMode::Local, None).unwrap(); assert!( summary.connect_url.is_none(), @@ -151,7 +107,7 @@ fn sanitize_masks_api_key_in_url() { let sanitized = sanitize_connect_url(url); assert!( - sanitized.contains("sk-1234..."), + sanitized.contains("%5Bredacted%5D"), "Expected masked key, got: {sanitized}" ); assert!( @@ -165,13 +121,12 @@ fn sanitize_masks_api_key_in_url() { } #[test] -fn sanitize_preserves_short_api_key() { - // Keys with 7 or fewer chars are not masked (threshold check) +fn sanitize_redacts_short_api_key() { let url = "wss://connect.steel.dev?apiKey=short&sessionId=s1"; let sanitized = sanitize_connect_url(url); assert!( - sanitized.contains("apiKey=short"), - "Short key should not be masked, got: {sanitized}" + !sanitized.contains("apiKey=short"), + "Short keys must also be masked, got: {sanitized}" ); } @@ -186,7 +141,7 @@ fn sanitize_handles_url_without_api_key() { fn sanitize_handles_invalid_url() { let url = "not a valid url"; let sanitized = sanitize_connect_url(url); - assert_eq!(sanitized, url); + assert_eq!(sanitized, "[redacted connection URL]"); } // =========================================================================== @@ -200,8 +155,7 @@ fn viewer_url_defaults_to_cloud_url() { "status": "live", "isLive": true, }); - let auth = cloud_auth(); - let summary = to_session_summary(&session, ApiMode::Cloud, None, &auth).unwrap(); + let summary = to_session_summary(&session, ApiMode::Cloud, None).unwrap(); assert_eq!( summary.viewer_url.as_deref(), @@ -216,8 +170,7 @@ fn viewer_url_none_for_local_without_explicit() { "status": "live", "isLive": true, }); - let auth = local_auth(); - let summary = to_session_summary(&session, ApiMode::Local, None, &auth).unwrap(); + let summary = to_session_summary(&session, ApiMode::Local, None).unwrap(); assert!(summary.viewer_url.is_none()); } @@ -230,8 +183,7 @@ fn viewer_url_uses_api_response_when_present() { "isLive": true, "sessionViewerUrl": "https://custom-viewer.example.com/view/sess-1", }); - let auth = cloud_auth(); - let summary = to_session_summary(&session, ApiMode::Cloud, None, &auth).unwrap(); + let summary = to_session_summary(&session, ApiMode::Cloud, None).unwrap(); assert_eq!( summary.viewer_url.as_deref(), @@ -246,8 +198,7 @@ fn viewer_url_uses_api_response_when_present() { #[test] fn to_session_summary_requires_id() { let session = json!({"status": "live"}); - let auth = cloud_auth(); - let result = to_session_summary(&session, ApiMode::Cloud, None, &auth); + let result = to_session_summary(&session, ApiMode::Cloud, None); assert!(result.is_err()); } @@ -258,8 +209,7 @@ fn to_session_summary_passes_name_through() { "status": "live", "isLive": true, }); - let auth = cloud_auth(); - let summary = to_session_summary(&session, ApiMode::Cloud, Some("my-session"), &auth).unwrap(); + let summary = to_session_summary(&session, ApiMode::Cloud, Some("my-session")).unwrap(); assert_eq!(summary.name.as_deref(), Some("my-session")); } @@ -271,6 +221,7 @@ fn to_session_summary_passes_name_through() { fn daemon_create_params_json_roundtrip() { let params = DaemonCreateParams { api_key: Some("sk-test-key".to_string()), + device_auth: false, base_url: "https://api.steel.dev/v1".to_string(), mode: ApiMode::Cloud, session_name: "work".to_string(), @@ -312,6 +263,7 @@ fn daemon_create_params_minimal_roundtrip() { let params = DaemonCreateParams { api_key: None, base_url: "http://localhost:3000/v1".to_string(), + device_auth: false, mode: ApiMode::Local, session_name: "default".to_string(), stealth: false, @@ -340,6 +292,7 @@ fn daemon_create_params_minimal_roundtrip() { fn daemon_create_params_to_create_options() { let params = DaemonCreateParams { api_key: Some("sk-key".to_string()), + device_auth: false, base_url: "https://api.steel.dev/v1".to_string(), mode: ApiMode::Cloud, session_name: "test".to_string(),