diff --git a/README.md b/README.md index a12bb94..ccd6b23 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,7 @@ Pair the CLI with your Livedocs workspace using device-code authentication. livedocs setup # Interactive browser-based auth livedocs setup --api-token TOKEN # Use a pre-generated API token livedocs setup --no-browser # Print the auth URL instead of opening it +livedocs setup --token-store file # Store token in local file (headless/SSH) ``` ### `livedocs launch` @@ -264,7 +265,7 @@ Remove the PATH entry from your shell profile (`~/.zshrc`, `~/.bashrc`, etc.) if ## Security -- Workspace tokens are stored in your system's secure credential storage (macOS Keychain, Linux Secret Service) +- Workspace tokens are stored in your system's secure credential storage (macOS Keychain, Linux Secret Service). If secure storage is unavailable, tokens fall back to `~/.livedocs/tokens.json` with 0600 permissions. - Session tokens are ephemeral and stored with restricted permissions (0600) - All communication with Livedocs Core uses HTTPS - Local runtime binds to localhost by default diff --git a/src/auth/mod.rs b/src/auth/mod.rs index c352753..f70c928 100644 --- a/src/auth/mod.rs +++ b/src/auth/mod.rs @@ -1,10 +1,12 @@ //! Authentication helpers (device flow + token storage). use dashmap::DashMap; -use std::sync::OnceLock; +use std::sync::{OnceLock, RwLock}; use anyhow::Result; +use crate::config::TokenStore; + const SERVICE_NAME: &str = "livedocs-cli"; pub const DEFAULT_TOKEN_ACCOUNT: &str = "default"; @@ -13,56 +15,281 @@ fn cache() -> &'static DashMap { CACHE.get_or_init(DashMap::new) } +fn token_store_lock() -> &'static RwLock { + static TOKEN_STORE: OnceLock> = OnceLock::new(); + TOKEN_STORE.get_or_init(|| RwLock::new(TokenStore::Auto)) +} + +pub fn set_token_store_mode(mode: TokenStore) { + let lock = token_store_lock(); + let mut guard = lock.write().unwrap_or_else(|err| err.into_inner()); + *guard = mode; +} + +fn token_store_mode() -> TokenStore { + let lock = token_store_lock(); + let guard = lock.read().unwrap_or_else(|err| err.into_inner()); + *guard +} + pub mod storage { - use super::{Result, SERVICE_NAME, cache}; + use super::{Result, SERVICE_NAME, cache, token_store_mode}; use anyhow::Context; + use camino::Utf8PathBuf; use keyring::Entry; + use std::collections::HashMap; + use std::fs; + use std::io::Write; use tracing::warn; - pub fn store_workspace_token(account: &str, token: &str) -> Result<()> { - cache().insert(account.to_string(), token.to_string()); + use crate::config::{Paths, TokenStore}; - let entry = Entry::new(SERVICE_NAME, account) - .with_context(|| format!("Failed to create keyring entry for account '{account}'"))?; - entry - .set_password(token) - .context("Failed to write workspace token to secure storage")?; + const FALLBACK_TOKEN_FILE: &str = "tokens.json"; - Ok(()) + #[derive(Debug, Clone, PartialEq, Eq)] + pub enum StoreOutcome { + Secure, + File, + Fallback { reason: String }, + } + + pub fn store_workspace_token(account: &str, token: &str) -> Result { + match token_store_mode() { + TokenStore::File => { + store_fallback_token(account, token) + .context("Failed to write workspace token to fallback store")?; + cache().insert(account.to_string(), token.to_string()); + Ok(StoreOutcome::File) + } + TokenStore::Secure => { + let entry = Entry::new(SERVICE_NAME, account) + .with_context(|| format!("Failed to create keyring entry for account '{account}'"))?; + entry + .set_password(token) + .context("Failed to write workspace token to secure storage")?; + cache().insert(account.to_string(), token.to_string()); + Ok(StoreOutcome::Secure) + } + TokenStore::Auto => { + let entry = match Entry::new(SERVICE_NAME, account) { + Ok(entry) => entry, + Err(err) => { + warn!("Secure storage unavailable: {err}"); + store_fallback_token(account, token) + .context("Failed to write workspace token to fallback store")?; + cache().insert(account.to_string(), token.to_string()); + return Ok(StoreOutcome::Fallback { + reason: err.to_string(), + }); + } + }; + + match entry.set_password(token) { + Ok(()) => { + cache().insert(account.to_string(), token.to_string()); + Ok(StoreOutcome::Secure) + } + Err(err) => { + warn!("Failed to write to secure storage: {err}"); + store_fallback_token(account, token) + .context("Failed to write workspace token to fallback store")?; + cache().insert(account.to_string(), token.to_string()); + Ok(StoreOutcome::Fallback { + reason: err.to_string(), + }) + } + } + } + } } #[allow(dead_code)] pub fn delete_workspace_token(account: &str) -> Result<()> { cache().remove(account); - let entry = Entry::new(SERVICE_NAME, account) - .with_context(|| format!("Failed to create keyring entry for account '{account}'"))?; - match entry.delete_credential() { - Ok(()) => Ok(()), - Err(keyring::Error::NoEntry) => Ok(()), - Err(err) => Err(err).context("Failed to remove workspace token from secure storage"), + match token_store_mode() { + TokenStore::File => { + delete_fallback_token(account) + .context("Failed to remove workspace token from fallback store")?; + Ok(()) + } + TokenStore::Secure => { + let entry = Entry::new(SERVICE_NAME, account) + .with_context(|| format!("Failed to create keyring entry for account '{account}'"))?; + match entry.delete_credential() { + Ok(()) | Err(keyring::Error::NoEntry) => {} + Err(err) => { + let err = Err(err).context("Failed to remove workspace token from secure storage"); + delete_fallback_token(account) + .context("Failed to remove workspace token from fallback store")?; + return err; + } + } + delete_fallback_token(account) + .context("Failed to remove workspace token from fallback store")?; + Ok(()) + } + TokenStore::Auto => { + let entry = match Entry::new(SERVICE_NAME, account) { + Ok(entry) => Some(entry), + Err(err) => { + warn!("Secure storage unavailable during delete: {err}"); + None + } + }; + + if let Some(entry) = entry { + match entry.delete_credential() { + Ok(()) | Err(keyring::Error::NoEntry) => {} + Err(err) => { + warn!("Failed to remove token from secure storage: {err}"); + } + } + } + + delete_fallback_token(account) + .context("Failed to remove workspace token from fallback store")?; + + Ok(()) + } } } + pub fn fallback_token_path() -> Result { + fallback_path() + } + pub fn read_workspace_token(account: &str) -> Result> { if let Some(value) = cache().get(account) { return Ok(Some(value.clone())); } - let entry = Entry::new(SERVICE_NAME, account) - .with_context(|| format!("Failed to create keyring entry for account '{account}'"))?; - match entry.get_password() { - Ok(value) => { - cache().insert(account.to_string(), value.clone()); - Ok(Some(value)) + match token_store_mode() { + TokenStore::File => { + if let Some(value) = read_fallback_token(account)? { + cache().insert(account.to_string(), value.clone()); + return Ok(Some(value)); + } + Ok(None) } - Err(keyring::Error::NoEntry) => Ok(None), - Err(err) => { - warn!( - "Failed to read workspace token from secure storage for account '{account}': {err}" - ); + TokenStore::Secure => { + let entry = Entry::new(SERVICE_NAME, account) + .with_context(|| format!("Failed to create keyring entry for account '{account}'"))?; + match entry.get_password() { + Ok(value) => { + cache().insert(account.to_string(), value.clone()); + Ok(Some(value)) + } + Err(keyring::Error::NoEntry) => Ok(None), + Err(err) => Err(err) + .context("Failed to read workspace token from secure storage"), + } + } + TokenStore::Auto => { + let entry = match Entry::new(SERVICE_NAME, account) { + Ok(entry) => Some(entry), + Err(err) => { + warn!("Secure storage unavailable for account '{account}': {err}"); + None + } + }; + + if let Some(entry) = entry { + match entry.get_password() { + Ok(value) => { + cache().insert(account.to_string(), value.clone()); + return Ok(Some(value)); + } + Err(keyring::Error::NoEntry) => {} + Err(err) => { + warn!( + "Failed to read workspace token from secure storage for account '{account}': {err}" + ); + } + } + } + + if let Some(value) = read_fallback_token(account)? { + cache().insert(account.to_string(), value.clone()); + return Ok(Some(value)); + } + Ok(None) } } } + + fn fallback_path() -> Result { + Ok(Paths::ensure()?.root.join(FALLBACK_TOKEN_FILE)) + } + + fn load_fallback_tokens() -> Result> { + let path = fallback_path()?; + if !path.exists() { + return Ok(HashMap::new()); + } + + let raw = fs::read(path.as_std_path()) + .with_context(|| format!("Failed to read {}", path))?; + match serde_json::from_slice::>(&raw) { + Ok(tokens) => Ok(tokens), + Err(err) => { + warn!("Failed to parse fallback token store at {path}: {err}"); + Ok(HashMap::new()) + } + } + } + + fn write_fallback_tokens(tokens: &HashMap) -> Result<()> { + let path = fallback_path()?; + if let Some(parent) = path.parent() { + fs::create_dir_all(parent.as_std_path())?; + } + + let tmp_path = path.with_extension("tmp"); + let raw = serde_json::to_vec(tokens).context("Failed to serialize fallback token store")?; + + { + let mut options = fs::OpenOptions::new(); + options.write(true).create(true).truncate(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let mut file = options.open(tmp_path.as_std_path())?; + file.write_all(&raw)?; + file.sync_all()?; + } + + fs::rename(tmp_path.as_std_path(), path.as_std_path())?; + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(path.as_std_path(), fs::Permissions::from_mode(0o600))?; + } + + Ok(()) + } + + fn store_fallback_token(account: &str, token: &str) -> Result<()> { + let mut tokens = load_fallback_tokens()?; + tokens.insert(account.to_string(), token.to_string()); + write_fallback_tokens(&tokens) + } + + fn read_fallback_token(account: &str) -> Result> { + let tokens = load_fallback_tokens()?; + Ok(tokens.get(account).cloned()) + } + + fn delete_fallback_token(account: &str) -> Result { + let mut tokens = load_fallback_tokens()?; + let existed = tokens.remove(account).is_some(); + if existed { + write_fallback_tokens(&tokens)?; + } + Ok(existed) + } } diff --git a/src/cli/mod.rs b/src/cli/mod.rs index d76646e..3fd1725 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -2,6 +2,7 @@ use anyhow::Result; use clap::{CommandFactory, FromArgMatches, Parser, Subcommand}; use crate::commands; +use crate::auth::set_token_store_mode; use crate::config::{AppConfig, Paths}; use crate::util::ui::Printer; @@ -69,6 +70,7 @@ impl<'a> CommandContext<'a> { } pub async fn run(args: Cli, mut ctx: CommandContext<'_>) -> Result<()> { + set_token_store_mode(ctx.config.token_store()); match args.command { Commands::Setup(cmd) => commands::setup::execute(cmd, &mut ctx).await, Commands::Launch(cmd) => commands::launch::execute(cmd, &mut ctx).await, diff --git a/src/commands/launch.rs b/src/commands/launch.rs index 9de59fb..1a1a8e4 100644 --- a/src/commands/launch.rs +++ b/src/commands/launch.rs @@ -511,6 +511,7 @@ async fn ensure_workspace_token(ctx: &mut CommandContext<'_>) -> Result SetupCommand { api_token: None, no_browser: false, + token_store: None, }, ctx, ) diff --git a/src/commands/setup.rs b/src/commands/setup.rs index 8994b04..ad6b58e 100644 --- a/src/commands/setup.rs +++ b/src/commands/setup.rs @@ -1,13 +1,14 @@ use std::cmp::max; use anyhow::{Result, anyhow}; -use clap::Args; +use clap::{Args, ValueEnum}; use dialoguer::Input; use tokio::time::{Duration, sleep}; use tracing::debug; -use crate::auth::{DEFAULT_TOKEN_ACCOUNT, storage}; +use crate::auth::{DEFAULT_TOKEN_ACCOUNT, set_token_store_mode, storage}; use crate::cli::CommandContext; +use crate::config::TokenStore; use crate::core_api::CoreClient; use crate::core_api::graphql::device_login_poll::DeviceLoginStatus; use crate::util::progress; @@ -21,12 +22,41 @@ pub struct SetupCommand { /// Skip launching the browser during the device pairing flow. #[arg(long)] pub no_browser: bool, + + /// Where to store the workspace token: auto, secure, or file. + #[arg(long, value_enum)] + pub token_store: Option, +} + +#[derive(Debug, Clone, Copy, ValueEnum)] +pub enum TokenStoreChoice { + Auto, + Secure, + File, +} + +impl From for TokenStore { + fn from(value: TokenStoreChoice) -> Self { + match value { + TokenStoreChoice::Auto => TokenStore::Auto, + TokenStoreChoice::Secure => TokenStore::Secure, + TokenStoreChoice::File => TokenStore::File, + } + } } pub async fn execute(cmd: SetupCommand, ctx: &mut CommandContext<'_>) -> Result<()> { let spinner = progress::spinner("Starting setup"); spinner.set_message("Initializing workspace pairing"); + if let Some(choice) = cmd.token_store { + let token_store: TokenStore = choice.into(); + ctx.config.set_token_store(Some(token_store)); + set_token_store_mode(token_store); + } else { + set_token_store_mode(ctx.config.token_store()); + } + if let Some(provided_token) = cmd .api_token .as_ref() @@ -54,7 +84,7 @@ pub async fn execute(cmd: SetupCommand, ctx: &mut CommandContext<'_>) -> Result< workspace_id.trim().to_string() }; - storage::store_workspace_token(&account, provided_token)?; + let store_outcome = storage::store_workspace_token(&account, provided_token)?; let workspace_name = workspace_name_input.trim(); let workspace_handle = workspace_handle_input.trim(); @@ -81,7 +111,25 @@ pub async fn execute(cmd: SetupCommand, ctx: &mut CommandContext<'_>) -> Result< ctx.config.set_user_name(None); ctx.config.set_user_email(None); - ctx.printer.success("Workspace token stored securely.")?; + match store_outcome { + storage::StoreOutcome::Secure => { + ctx.printer.success("Workspace token stored securely.")?; + } + storage::StoreOutcome::File => { + ctx.printer.success("Workspace token stored locally.")?; + if let Ok(path) = storage::fallback_token_path() { + ctx.printer.status("token-file", path.as_str())?; + } + } + storage::StoreOutcome::Fallback { reason } => { + ctx.printer.warn(format!( + "Secure storage unavailable ({reason}). Workspace token stored locally." + ))?; + if let Ok(path) = storage::fallback_token_path() { + ctx.printer.status("token-file", path.as_str())?; + } + } + } if account != DEFAULT_TOKEN_ACCOUNT { ctx.printer.status("workspace", &account)?; } @@ -163,7 +211,7 @@ pub async fn execute(cmd: SetupCommand, ctx: &mut CommandContext<'_>) -> Result< .clone() .unwrap_or_else(|| DEFAULT_TOKEN_ACCOUNT.to_string()); - storage::store_workspace_token(&account, &token)?; + let store_outcome = storage::store_workspace_token(&account, &token)?; ctx.config .set_workspace_id(workspace_id_opt.clone().map(|id| id.to_string())); @@ -197,8 +245,27 @@ pub async fn execute(cmd: SetupCommand, ctx: &mut CommandContext<'_>) -> Result< ctx.config.set_user_email(approver_email_opt.clone()); ctx.config.set_user_name(approver_name_opt.clone()); - ctx.printer - .success("Device paired! Workspace token stored securely.")?; + match store_outcome { + storage::StoreOutcome::Secure => { + ctx.printer + .success("Device paired! Workspace token stored securely.")?; + } + storage::StoreOutcome::File => { + ctx.printer.success("Device paired! Workspace token stored locally.")?; + if let Ok(path) = storage::fallback_token_path() { + ctx.printer.status("token-file", path.as_str())?; + } + } + storage::StoreOutcome::Fallback { reason } => { + ctx.printer.success("Device paired!")?; + ctx.printer.warn(format!( + "Secure storage unavailable ({reason}). Workspace token stored locally." + ))?; + if let Ok(path) = storage::fallback_token_path() { + ctx.printer.status("token-file", path.as_str())?; + } + } + } if account != DEFAULT_TOKEN_ACCOUNT { ctx.printer.status("workspace", &account)?; } diff --git a/src/config/mod.rs b/src/config/mod.rs index ca675c1..97e1cec 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -45,6 +45,14 @@ const CHANNEL_DEFAULTS: &[(&str, ChannelDefaults)] = &[ ), ]; +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum TokenStore { + Auto, + Secure, + File, +} + fn channel_defaults(channel: &str) -> Option { let channel = channel.to_ascii_lowercase(); CHANNEL_DEFAULTS @@ -176,6 +184,8 @@ struct AppConfigData { #[serde(default)] bucket_override: Option, #[serde(default)] + token_store: Option, + #[serde(default)] active_workspace_id: Option, #[serde(default)] active_workspace_name: Option, @@ -196,6 +206,7 @@ impl Default for AppConfigData { api_base_url: None, web_base_url: None, bucket_override: None, + token_store: None, active_workspace_id: None, active_workspace_name: None, active_workspace_handle: None, @@ -314,6 +325,17 @@ impl AppConfig { } } + pub fn token_store(&self) -> TokenStore { + self.data.token_store.unwrap_or(TokenStore::Auto) + } + + pub fn set_token_store(&mut self, token_store: Option) { + if self.data.token_store != token_store { + self.data.token_store = token_store; + self.dirty = true; + } + } + pub fn workspace_id(&self) -> Option<&str> { self.data.active_workspace_id.as_deref() } diff --git a/versions.lock b/versions.lock index 7f65258..f3e7df8 100644 --- a/versions.lock +++ b/versions.lock @@ -1,7 +1,7 @@ { "channels": { "staging": { - "cli_version": "1.0.0-rc.26", + "cli_version": "1.0.0-rc.28", "python_runtime": "3.12", "components": { "livedocs": "1.0.0-rc.25", @@ -10,7 +10,7 @@ } }, "stable": { - "cli_version": "1.2.5", + "cli_version": "1.2.6", "python_runtime": "3.12", "components": { "livedocs": "1.3.2",