diff --git a/.DS_Store b/.DS_Store index e4b6bee1..e2cb9d04 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/Cargo.lock b/Cargo.lock index 7cc23415..b687f67f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -95,6 +95,12 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "anyhow" +version = "1.0.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" + [[package]] name = "arc-swap" version = "1.7.1" @@ -3014,6 +3020,15 @@ dependencies = [ "serde", ] +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + [[package]] name = "is-terminal" version = "0.4.16" @@ -3025,6 +3040,16 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.1" @@ -3434,6 +3459,17 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "open" +version = "5.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43bb73a7fa3799b198970490a51174027ba0d4ec504b03cd08caf513d40024bc" +dependencies = [ + "is-wsl", + "libc", + "pathdiff", +] + [[package]] name = "openssl" version = "0.10.73" @@ -3531,6 +3567,12 @@ dependencies = [ "regex", ] +[[package]] +name = "pathdiff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" + [[package]] name = "percent-encoding" version = "2.3.1" @@ -4833,6 +4875,7 @@ version = "0.26.1" dependencies = [ "ahash", "aho-corasick", + "anyhow", "assert_cmd", "async-stream", "aws-config", @@ -4858,6 +4901,7 @@ dependencies = [ "nom", "num_cpus", "once_cell", + "open", "parking_lot", "predicates", "prettytable", diff --git a/Cargo.toml b/Cargo.toml index a453a9a3..1f78b8ae 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -100,6 +100,8 @@ strip-ansi-escapes = "0.2" # Strip ANSI codes for testing # Dockerfile linting (hadolint-rs) nom = "7" # Parser combinators for Dockerfile parsing +anyhow = "1.0.100" +open = "5.3.3" [dev-dependencies] assert_cmd = "2" diff --git a/src/auth/credentials.rs b/src/auth/credentials.rs new file mode 100644 index 00000000..646add04 --- /dev/null +++ b/src/auth/credentials.rs @@ -0,0 +1,117 @@ +//! Credential storage and retrieval for Syncable authentication +//! +//! Stores authentication tokens in ~/.syncable.toml + +use crate::config::{load_config, save_global_config, types::SyncableAuth}; +use anyhow::Result; +use std::time::{SystemTime, UNIX_EPOCH}; + +/// Save credentials to global config file +pub fn save_credentials( + access_token: &str, + refresh_token: Option<&str>, + user_email: Option<&str>, + expires_in_secs: Option, +) -> Result<()> { + let mut config = load_config(None).unwrap_or_default(); + + let expires_at = expires_in_secs.map(|secs| { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs() + + secs + }); + + config.syncable_auth = SyncableAuth { + access_token: Some(access_token.to_string()), + refresh_token: refresh_token.map(|s| s.to_string()), + expires_at, + user_email: user_email.map(|s| s.to_string()), + }; + + save_global_config(&config)?; + Ok(()) +} + +/// Get the current access token if valid +pub fn get_access_token() -> Option { + let config = load_config(None).ok()?; + + // Check expiry + if let Some(expires_at) = config.syncable_auth.expires_at { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .ok()? + .as_secs(); + if now > expires_at { + return None; // Token expired + } + } + + config.syncable_auth.access_token +} + +/// Get the authenticated user's email +pub fn get_user_email() -> Option { + let config = load_config(None).ok()?; + config.syncable_auth.user_email +} + +/// Check if the user is currently authenticated with a valid token +pub fn is_authenticated() -> bool { + get_access_token().is_some() +} + +/// Get authentication status including expiry info +pub fn get_auth_status() -> AuthStatus { + let config = match load_config(None) { + Ok(c) => c, + Err(_) => return AuthStatus::NotAuthenticated, + }; + + match &config.syncable_auth.access_token { + None => AuthStatus::NotAuthenticated, + Some(_) => { + if let Some(expires_at) = config.syncable_auth.expires_at { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + + if now > expires_at { + return AuthStatus::Expired; + } + + AuthStatus::Authenticated { + email: config.syncable_auth.user_email.clone(), + expires_at: Some(expires_at), + } + } else { + AuthStatus::Authenticated { + email: config.syncable_auth.user_email.clone(), + expires_at: None, + } + } + } + } +} + +/// Clear stored credentials (logout) +pub fn clear_credentials() -> Result<()> { + let mut config = load_config(None).unwrap_or_default(); + config.syncable_auth = SyncableAuth::default(); + save_global_config(&config)?; + Ok(()) +} + +/// Authentication status enum +#[derive(Debug)] +pub enum AuthStatus { + NotAuthenticated, + Expired, + Authenticated { + email: Option, + expires_at: Option, + }, +} diff --git a/src/auth/device_flow.rs b/src/auth/device_flow.rs new file mode 100644 index 00000000..0d69aaf6 --- /dev/null +++ b/src/auth/device_flow.rs @@ -0,0 +1,213 @@ +//! Device Authorization Grant flow (RFC 8628) for CLI authentication +//! +//! Implements the OAuth 2.0 device flow to authenticate CLI users via the Syncable web interface. + +use super::credentials; +use anyhow::{anyhow, Result}; +use reqwest::Client; +use serde::Deserialize; +use std::time::{Duration, Instant}; + +/// Production API URL (encore is reached via syncable.dev/api/*) +const SYNCABLE_API_URL_PROD: &str = "https://syncable.dev"; +/// Development API URL +const SYNCABLE_API_URL_DEV: &str = "http://localhost:4000"; +/// CLI client ID registered with the backend +const CLI_CLIENT_ID: &str = "syncable-cli"; + +/// Response from device code request +#[derive(Debug, Deserialize)] +struct DeviceCodeResponse { + device_code: String, + user_code: String, + verification_uri: String, + verification_uri_complete: Option, + expires_in: u64, + interval: u64, +} + +/// Token response (success or error) +#[derive(Debug, Deserialize)] +#[serde(untagged)] +enum TokenResponse { + Success { + access_token: String, + #[allow(dead_code)] + token_type: String, + expires_in: Option, + refresh_token: Option, + }, + Error { + error: String, + #[allow(dead_code)] + error_description: Option, + }, +} + +/// Get the API URL based on environment +fn get_api_url() -> &'static str { + // Check for development environment + if std::env::var("SYNCABLE_ENV").as_deref() == Ok("development") { + SYNCABLE_API_URL_DEV + } else { + SYNCABLE_API_URL_PROD + } +} + +/// Perform the device authorization login flow +pub async fn login(no_browser: bool) -> Result<()> { + println!("🔐 Authenticating with Syncable...\n"); + + let client = Client::new(); + let api_url = get_api_url(); + + // Step 1: Request device code + let response = client + .post(format!("{}/api/auth/device/code", api_url)) + .json(&serde_json::json!({ + "client_id": CLI_CLIENT_ID, + "scope": "openid profile email" + })) + .send() + .await + .map_err(|e| anyhow!("Failed to connect to Syncable API: {}", e))?; + + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + return Err(anyhow!( + "Failed to request device authorization: {} - {}", + status, + body + )); + } + + let device_code: DeviceCodeResponse = response + .json() + .await + .map_err(|e| anyhow!("Invalid response from server: {}", e))?; + + // Step 2: Display user code and instructions + println!("📱 Device Authorization"); + println!(" ─────────────────────────────────────"); + println!(" Visit: {}", device_code.verification_uri); + println!(" Code: \x1b[1;36m{}\x1b[0m", device_code.user_code); + println!(" ─────────────────────────────────────\n"); + + // Step 3: Open browser (unless --no-browser flag) + if !no_browser { + let url = device_code + .verification_uri_complete + .as_ref() + .unwrap_or(&device_code.verification_uri); + + if let Err(e) = open::that(url) { + println!("⚠️ Could not open browser automatically: {}", e); + println!(" Please open the URL above manually."); + } else { + println!("🌐 Browser opened. Waiting for authorization..."); + } + } else { + println!(" Please open the URL above and enter the code."); + } + + println!(); + + // Step 4: Poll for token + poll_for_token(&client, api_url, &device_code).await +} + +/// Poll the token endpoint until authorization is complete +async fn poll_for_token( + client: &Client, + api_url: &str, + device_code: &DeviceCodeResponse, +) -> Result<()> { + let mut interval = device_code.interval; + let deadline = Instant::now() + Duration::from_secs(device_code.expires_in); + + loop { + // Check if code has expired + if Instant::now() > deadline { + return Err(anyhow!( + "Device code expired. Please run 'sync-ctl auth login' again." + )); + } + + // Wait for polling interval + tokio::time::sleep(Duration::from_secs(interval)).await; + + // Poll for token + let response = client + .post(format!("{}/api/auth/device/token", api_url)) + .json(&serde_json::json!({ + "grant_type": "urn:ietf:params:oauth:grant-type:device_code", + "device_code": device_code.device_code, + "client_id": CLI_CLIENT_ID, + })) + .send() + .await; + + let response = match response { + Ok(r) => r, + Err(e) => { + println!("⚠️ Network error, retrying: {}", e); + continue; + } + }; + + let body = response.text().await.unwrap_or_default(); + let token_response: TokenResponse = match serde_json::from_str(&body) { + Ok(r) => r, + Err(_) => { + // Unexpected response, continue polling + continue; + } + }; + + match token_response { + TokenResponse::Success { + access_token, + expires_in, + refresh_token, + .. + } => { + // Success! Save credentials + credentials::save_credentials( + &access_token, + refresh_token.as_deref(), + None, // TODO: Fetch user email from session endpoint + expires_in, + )?; + + println!("\n\x1b[1;32m✅ Authentication successful!\x1b[0m"); + println!(" Credentials saved to ~/.syncable.toml"); + return Ok(()); + } + TokenResponse::Error { error, .. } => { + match error.as_str() { + "authorization_pending" => { + // User hasn't completed authorization yet, keep polling + continue; + } + "slow_down" => { + // Server asked us to slow down + interval += 5; + continue; + } + "access_denied" => { + return Err(anyhow!("Authorization was denied by the user.")); + } + "expired_token" => { + return Err(anyhow!( + "Device code expired. Please run 'sync-ctl auth login' again." + )); + } + _ => { + return Err(anyhow!("Authorization failed: {}", error)); + } + } + } + } + } +} diff --git a/src/auth/mod.rs b/src/auth/mod.rs new file mode 100644 index 00000000..54b33abf --- /dev/null +++ b/src/auth/mod.rs @@ -0,0 +1,6 @@ +//! Authentication module for Syncable platform +//! +//! Implements OAuth 2.0 Device Authorization Grant (RFC 8628) for CLI authentication. + +pub mod credentials; +pub mod device_flow; diff --git a/src/cli.rs b/src/cli.rs index 6593e319..1f8507dd 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -256,6 +256,12 @@ pub enum Commands { #[arg(long)] list_sessions: bool, }, + + /// Authenticate with the Syncable platform + Auth { + #[command(subcommand)] + command: AuthCommand, + }, } #[derive(Subcommand)] @@ -313,6 +319,30 @@ pub enum ToolsCommand { }, } +/// Authentication subcommands for the Syncable platform +#[derive(Subcommand)] +pub enum AuthCommand { + /// Log in to Syncable (opens browser for authentication) + Login { + /// Don't open browser automatically + #[arg(long)] + no_browser: bool, + }, + + /// Log out and clear stored credentials + Logout, + + /// Show current authentication status + Status, + + /// Print current access token (for scripting) + Token { + /// Print raw token without formatting + #[arg(long)] + raw: bool, + }, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] pub enum OutputFormat { Table, diff --git a/src/config/types.rs b/src/config/types.rs index 362eaadf..dbcc8366 100644 --- a/src/config/types.rs +++ b/src/config/types.rs @@ -10,6 +10,9 @@ pub struct Config { pub telemetry: TelemetryConfig, #[serde(default)] pub agent: AgentConfig, + /// Syncable platform authentication + #[serde(default)] + pub syncable_auth: SyncableAuth, } /// Analysis configuration @@ -192,6 +195,23 @@ pub struct BedrockConfig { pub default_model: Option, } +/// Syncable platform authentication credentials +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct SyncableAuth { + /// Access token from device authorization flow + #[serde(skip_serializing_if = "Option::is_none")] + pub access_token: Option, + /// Refresh token for renewing access + #[serde(skip_serializing_if = "Option::is_none")] + pub refresh_token: Option, + /// Token expiration timestamp (Unix seconds) + #[serde(skip_serializing_if = "Option::is_none")] + pub expires_at: Option, + /// Authenticated user's email + #[serde(skip_serializing_if = "Option::is_none")] + pub user_email: Option, +} + fn default_provider() -> String { "openai".to_string() } @@ -235,6 +255,7 @@ impl Default for Config { }, telemetry: TelemetryConfig { enabled: true }, agent: AgentConfig::default(), + syncable_auth: SyncableAuth::default(), } } } diff --git a/src/lib.rs b/src/lib.rs index ac00ca5f..a9d0a642 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,5 +1,6 @@ pub mod agent; pub mod analyzer; +pub mod auth; // Authentication module for Syncable platform pub mod bedrock; // Inlined rig-bedrock with extended thinking fixes pub mod cli; pub mod common; @@ -213,5 +214,72 @@ pub async fn run_command(command: Commands) -> Result<()> { Ok(()) } } + Commands::Auth { command } => { + use cli::AuthCommand; + use auth::credentials; + use auth::device_flow; + + match command { + AuthCommand::Login { no_browser } => { + device_flow::login(no_browser).await.map_err(|e| { + error::IaCGeneratorError::Config(error::ConfigError::ParsingFailed(e.to_string())) + }) + } + AuthCommand::Logout => { + credentials::clear_credentials().map_err(|e| { + error::IaCGeneratorError::Config(error::ConfigError::ParsingFailed(e.to_string())) + })?; + println!("✅ Logged out successfully. Credentials cleared."); + Ok(()) + } + AuthCommand::Status => { + match credentials::get_auth_status() { + credentials::AuthStatus::NotAuthenticated => { + println!("❌ Not logged in."); + println!(" Run: sync-ctl auth login"); + } + credentials::AuthStatus::Expired => { + println!("⚠️ Session expired."); + println!(" Run: sync-ctl auth login"); + } + credentials::AuthStatus::Authenticated { email, expires_at } => { + println!("✅ Logged in"); + if let Some(e) = email { + println!(" Email: {}", e); + } + if let Some(exp) = expires_at { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + if exp > now { + let remaining = exp - now; + let days = remaining / 86400; + let hours = (remaining % 86400) / 3600; + println!(" Expires in: {}d {}h", days, hours); + } + } + } + } + Ok(()) + } + AuthCommand::Token { raw } => { + match credentials::get_access_token() { + Some(token) => { + if raw { + print!("{}", token); + } else { + println!("Access Token: {}", token); + } + Ok(()) + } + None => { + eprintln!("Not authenticated. Run: sync-ctl auth login"); + std::process::exit(1); + } + } + } + } + } } } diff --git a/src/main.rs b/src/main.rs index d70d2bb6..160e7002 100644 --- a/src/main.rs +++ b/src/main.rs @@ -113,6 +113,7 @@ async fn run() -> syncable_cli::Result<()> { Commands::Security { .. } => "security", Commands::Tools { .. } => "tools", Commands::Chat { .. } => "chat", + Commands::Auth { .. } => "auth", }; log::debug!("Command name: {}", command_name); @@ -591,6 +592,10 @@ async fn run() -> syncable_cli::Result<()> { }) .await } + Commands::Auth { command } => { + // Auth commands are handled by lib.rs + syncable_cli::run_command(Commands::Auth { command }).await + } }; // Flush telemetry events before exiting diff --git a/syncable-cli-demo.gif b/syncable-cli-demo.gif index c6121f48..fec83002 100644 Binary files a/syncable-cli-demo.gif and b/syncable-cli-demo.gif differ