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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -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
Expand Down
281 changes: 254 additions & 27 deletions src/auth/mod.rs
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -13,56 +15,281 @@ fn cache() -> &'static DashMap<String, String> {
CACHE.get_or_init(DashMap::new)
}

fn token_store_lock() -> &'static RwLock<TokenStore> {
static TOKEN_STORE: OnceLock<RwLock<TokenStore>> = 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<StoreOutcome> {
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<Utf8PathBuf> {
fallback_path()
}

pub fn read_workspace_token(account: &str) -> Result<Option<String>> {
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<Utf8PathBuf> {
Ok(Paths::ensure()?.root.join(FALLBACK_TOKEN_FILE))
}

fn load_fallback_tokens() -> Result<HashMap<String, String>> {
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::<HashMap<String, String>>(&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<String, String>) -> 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<Option<String>> {
let tokens = load_fallback_tokens()?;
Ok(tokens.get(account).cloned())
}

fn delete_fallback_token(account: &str) -> Result<bool> {
let mut tokens = load_fallback_tokens()?;
let existed = tokens.remove(account).is_some();
if existed {
write_fallback_tokens(&tokens)?;
}
Ok(existed)
}
}
2 changes: 2 additions & 0 deletions src/cli/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions src/commands/launch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -511,6 +511,7 @@ async fn ensure_workspace_token(ctx: &mut CommandContext<'_>) -> Result<String>
SetupCommand {
api_token: None,
no_browser: false,
token_store: None,
},
ctx,
)
Expand Down
Loading