use crate::client::ApiClient;
use crate::config::ConfigFile;
use crate::output;
use anyhow::{Context, Result};
use clap::Subcommand;
use serde::{Deserialize, Serialize};
use std::io::Write;
#[derive(Subcommand)]
pub enum AuthCommands {
Login {
#[arg(long)]
headless: bool,
},
Logout,
Whoami,
ApiKeys {
#[command(subcommand)]
command: ApiKeyCommands,
},
}
#[derive(Subcommand)]
pub enum ApiKeyCommands {
Create {
#[arg(long)]
name: Option<String>,
},
List,
Revoke {
key_id: String,
},
}
pub async fn run(command: AuthCommands, api_url: &str, json: bool) -> Result<()> {
match command {
AuthCommands::Login { headless } => {
if headless {
login_headless(api_url).await
} else {
login_browser(api_url).await
}
}
AuthCommands::Logout => logout(api_url, json).await,
AuthCommands::Whoami => whoami(api_url, json).await,
AuthCommands::ApiKeys { command } => api_keys(command, api_url, json).await,
}
}
async fn login_browser(api_url: &str) -> Result<()> {
let config = ConfigFile::load()?;
if config.auth.token.is_some() || config.auth.api_key.is_some() {
println!("Already logged in. Use `cinch auth logout` first to switch accounts.");
return Ok(());
}
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.context("failed to bind local callback server")?;
let port = listener
.local_addr()
.context("failed to get local address")?
.port();
let callback_url = format!("http://127.0.0.1:{port}/callback");
let dashboard_url = resolve_dashboard_url(api_url);
let login_url = format!(
"{dashboard_url}/auth/cli/?callback={}",
urlencoding::encode(&callback_url)
);
println!("Opening browser to log in...");
println!();
println!(" If the browser doesn't open, visit:");
println!(" {login_url}");
println!();
if let Err(e) = open::that(&login_url) {
eprintln!(" Could not open browser automatically: {e}");
eprintln!(" Please visit the URL above manually.");
}
println!("Waiting for authentication...");
let token = accept_callback(listener).await?;
let mut config = ConfigFile::load()?;
config.auth.token = Some(token);
config.save()?;
let client = ApiClient::new(api_url)?;
let user: UserInfo = client
.get("/users/me")
.await
.context("login succeeded but failed to fetch user info")?;
println!();
output::print_success(
&format!("Logged in as {}", user.email),
Some("cinch db create mydb --type redis"),
);
if let Ok(orgs) = client.get::<UserOrgsResponse>("/users/me/orgs").await {
if orgs.organizations.len() == 1 {
let org = &orgs.organizations[0];
config.context.org = Some(org.slug.clone());
config.save()?;
println!(" Set org context to \"{}\"", org.slug);
}
}
Ok(())
}
async fn accept_callback(listener: tokio::net::TcpListener) -> Result<String> {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let (mut stream, _) = listener
.accept()
.await
.context("failed to accept callback connection")?;
let mut buf = vec![0u8; 4096];
let n = stream
.read(&mut buf)
.await
.context("failed to read callback request")?;
let request = String::from_utf8_lossy(&buf[..n]);
let token = request
.lines()
.next()
.and_then(|line| {
let path = line.split_whitespace().nth(1)?;
let query = path.split('?').nth(1)?;
query
.split('&')
.find_map(|param| param.strip_prefix("token="))
})
.map(|t| t.to_string())
.context("callback did not contain a token parameter")?;
let html = r#"<!DOCTYPE html>
<html>
<head><title>CinchDB CLI</title></head>
<body style="font-family: system-ui; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0;">
<div style="text-align: center;">
<h1>Logged in!</h1>
<p>You can close this tab and return to your terminal.</p>
</div>
</body>
</html>"#;
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
html.len(),
html
);
let _ = stream.write_all(response.as_bytes()).await;
let _ = stream.shutdown().await;
Ok(token)
}
fn resolve_dashboard_url(api_url: &str) -> String {
if let Ok(url) = std::env::var("CINCH_DASHBOARD_URL") {
return url;
}
if api_url.contains("localhost") || api_url.contains("127.0.0.1") {
return api_url
.replace(":8080", ":5200")
.replace(":8081", ":5200");
}
api_url
.replace("://api.", "://app.")
.replace("://api-", "://app-")
}
async fn login_headless(api_url: &str) -> Result<()> {
println!("Headless login: paste your API key.");
println!(" Create one at: https://app.cinchdb.dev/settings/api-keys");
println!();
print!("API key: ");
std::io::stdout().flush()?;
let mut api_key = String::new();
std::io::stdin()
.read_line(&mut api_key)
.context("failed to read API key")?;
let api_key = api_key.trim().to_string();
println!();
if api_key.is_empty() {
anyhow::bail!("no API key provided");
}
if !api_key.starts_with("ck_live_") {
anyhow::bail!("invalid API key format. Keys start with 'ck_live_'");
}
let mut config = ConfigFile::load()?;
config.auth.api_key = Some(api_key.clone());
let client = ApiClient::with_config(api_url, &config)?;
let user: UserInfo = client
.get("/users/me")
.await
.context("API key validation failed")?;
config.save()?;
output::print_success(
&format!("Authenticated as {}", user.email),
Some("cinch db create mydb --type redis"),
);
if let Ok(orgs) = client.get::<UserOrgsResponse>("/users/me/orgs").await {
if orgs.organizations.len() == 1 {
let org = &orgs.organizations[0];
config.context.org = Some(org.slug.clone());
config.save()?;
println!(" Set org context to \"{}\"", org.slug);
}
}
Ok(())
}
async fn logout(api_url: &str, json: bool) -> Result<()> {
let mut config = ConfigFile::load()?;
if config.auth.token.is_some() {
if let Ok(client) = ApiClient::new(api_url) {
let _ = client.post_empty("/auth/logout").await;
}
}
config.auth.token = None;
config.auth.api_key = None;
config.save()?;
if json {
println!("{}", serde_json::json!({ "status": "logged_out" }));
} else {
println!("Logged out.");
}
Ok(())
}
async fn whoami(api_url: &str, json: bool) -> Result<()> {
let config = ConfigFile::load()?;
if config.auth_header_value().is_none() {
anyhow::bail!("not logged in. Run `cinch auth login` first.");
}
let client = ApiClient::new(api_url)?;
let user: UserInfo = client.get("/users/me").await?;
let context = crate::config::resolve_context(&config);
if json {
let data = serde_json::json!({
"user": {
"id": user.id,
"email": user.email,
"plan": user.plan,
},
"context": {
"org": context.org,
"project": context.project,
"environment": context.environment,
"scope": context.scope,
}
});
println!("{}", serde_json::to_string_pretty(&data).expect("json serialization failed"));
} else {
let plan = user.plan.as_deref().unwrap_or("unknown");
println!(" {} {}", colored::Colorize::bold("Email"), user.email);
println!(" {} {}", colored::Colorize::bold("ID"), user.id);
println!(" {} {}", colored::Colorize::bold("Plan"), plan);
println!();
println!(" {}", colored::Colorize::bold("Context"));
println!(
" org: {}",
context.org.as_deref().unwrap_or("(not set)")
);
println!(
" project: {}",
context.project.as_deref().unwrap_or("(not set)")
);
println!(
" environment: {}",
context.environment.as_deref().unwrap_or("(not set)")
);
println!(
" scope: {}",
context.scope.as_deref().unwrap_or("(not set)")
);
}
Ok(())
}
async fn api_keys(command: ApiKeyCommands, api_url: &str, json: bool) -> Result<()> {
let client = ApiClient::new(api_url)?;
match command {
ApiKeyCommands::Create { name } => {
let body = serde_json::json!({
"name": name.unwrap_or_else(|| "cli".to_string()),
});
let key: ApiKeyCreated = client.post("/users/me/api-keys", &body).await?;
if json {
println!("{}", serde_json::to_string_pretty(&key).expect("json serialization failed"));
} else {
println!(" API key created. Save it now; it won't be shown again:");
println!();
println!(" {}", key.api_key);
println!();
println!(
" {}: cinch auth login --headless",
colored::Colorize::cyan("hint")
);
}
Ok(())
}
ApiKeyCommands::List => {
let keys: ApiKeysResponse = client.get("/users/me/api-keys").await?;
let rows: Vec<Vec<String>> = keys
.api_keys
.iter()
.map(|k| {
vec![
k.id.clone(),
k.name.clone(),
format!("ck_live_{}...", &k.key_prefix),
output::format_timestamp_ms(k.created_at),
]
})
.collect();
output::print_table_or_json(
&["ID", "Name", "Prefix", "Created"],
rows,
&keys,
json,
);
Ok(())
}
ApiKeyCommands::Revoke { key_id } => {
client.delete(&format!("/users/me/api-keys/{key_id}")).await?;
if json {
println!("{}", serde_json::json!({ "status": "revoked", "key_id": key_id }));
} else {
println!("API key {key_id} revoked.");
}
Ok(())
}
}
}
#[derive(Debug, Deserialize, Serialize)]
pub struct UserInfo {
pub id: String,
pub email: String,
#[serde(default)]
pub plan: Option<String>,
#[serde(default)]
pub allowed: Option<bool>,
#[serde(default)]
pub created_at: Option<i64>,
}
#[derive(Debug, Deserialize)]
struct UserOrgsResponse {
organizations: Vec<OrgInfo>,
}
#[derive(Debug, Deserialize)]
struct OrgInfo {
slug: String,
#[allow(dead_code)]
name: String,
}
#[derive(Debug, Deserialize, Serialize)]
struct ApiKeyCreated {
id: String,
api_key: String,
name: String,
key_prefix: String,
created_at: i64,
}
#[derive(Debug, Deserialize, Serialize)]
struct ApiKeysResponse {
api_keys: Vec<ApiKeyInfo>,
}
#[derive(Debug, Deserialize, Serialize)]
struct ApiKeyInfo {
id: String,
name: String,
key_prefix: String,
#[serde(default)]
last_used_at: Option<i64>,
created_at: i64,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_user_info_deserialize_minimal() {
let json = r#"{"id": "usr_123", "email": "test@example.com"}"#;
let user: UserInfo = serde_json::from_str(json).expect("parse");
assert_eq!(user.id, "usr_123");
assert_eq!(user.email, "test@example.com");
assert!(user.plan.is_none());
}
#[test]
fn test_user_info_deserialize_full() {
let json = r#"{"id": "usr_123", "email": "test@example.com", "plan": "hobby", "allowed": true, "created_at": 1700000000}"#;
let user: UserInfo = serde_json::from_str(json).expect("parse");
assert_eq!(user.plan.as_deref(), Some("hobby"));
assert_eq!(user.allowed, Some(true));
assert_eq!(user.created_at, Some(1700000000));
}
#[test]
fn test_api_key_validation() {
assert!("ck_live_abc123def456".starts_with("ck_live_"));
assert!(!"sk_test_abc".starts_with("ck_live_"));
assert!(!"".starts_with("ck_live_"));
}
#[test]
fn test_callback_url_parsing() {
let request = "GET /callback?token=eyJhbGciOiJIUzI1NiJ9.test HTTP/1.1\r\nHost: 127.0.0.1:12345\r\n";
let token = request
.lines()
.next()
.and_then(|line| {
let path = line.split_whitespace().nth(1)?;
let query = path.split('?').nth(1)?;
query
.split('&')
.find_map(|param| param.strip_prefix("token="))
})
.map(|t| t.to_string());
assert_eq!(token.as_deref(), Some("eyJhbGciOiJIUzI1NiJ9.test"));
}
#[test]
fn test_callback_url_no_token() {
let request = "GET /callback?other=value HTTP/1.1\r\n";
let token = request
.lines()
.next()
.and_then(|line| {
let path = line.split_whitespace().nth(1)?;
let query = path.split('?').nth(1)?;
query
.split('&')
.find_map(|param| param.strip_prefix("token="))
})
.map(|t| t.to_string());
assert!(token.is_none());
}
#[test]
fn test_callback_url_multiple_params() {
let request = "GET /callback?state=abc&token=jwt123&other=val HTTP/1.1\r\n";
let token = request
.lines()
.next()
.and_then(|line| {
let path = line.split_whitespace().nth(1)?;
let query = path.split('?').nth(1)?;
query
.split('&')
.find_map(|param| param.strip_prefix("token="))
})
.map(|t| t.to_string());
assert_eq!(token.as_deref(), Some("jwt123"));
}
#[test]
fn test_orgs_response_deserialize() {
let json = r#"{"organizations": [{"slug": "acme", "name": "Acme Corp"}]}"#;
let resp: UserOrgsResponse = serde_json::from_str(json).expect("parse");
assert_eq!(resp.organizations.len(), 1);
assert_eq!(resp.organizations[0].slug, "acme");
}
#[test]
fn test_api_keys_response_deserialize() {
let json = r#"{"api_keys": [{"id": "key_1", "name": "cli", "key_prefix": "ck_live_ab", "created_at": 1700000000}]}"#;
let resp: ApiKeysResponse = serde_json::from_str(json).expect("parse");
assert_eq!(resp.api_keys.len(), 1);
assert_eq!(resp.api_keys[0].key_prefix, "ck_live_ab");
}
#[test]
fn test_resolve_dashboard_url_production() {
std::env::remove_var("CINCH_DASHBOARD_URL");
assert_eq!(
resolve_dashboard_url("https://api.cinchdb.dev"),
"https://app.cinchdb.dev"
);
}
#[test]
fn test_resolve_dashboard_url_staging() {
std::env::remove_var("CINCH_DASHBOARD_URL");
assert_eq!(
resolve_dashboard_url("https://api-staging.cinchdb.dev"),
"https://app-staging.cinchdb.dev"
);
}
#[test]
fn test_resolve_dashboard_url_localhost() {
std::env::remove_var("CINCH_DASHBOARD_URL");
assert_eq!(
resolve_dashboard_url("http://localhost:8080"),
"http://localhost:5200"
);
assert_eq!(
resolve_dashboard_url("http://127.0.0.1:8080"),
"http://127.0.0.1:5200"
);
}
#[test]
fn test_resolve_dashboard_url_env_override() {
std::env::set_var("CINCH_DASHBOARD_URL", "https://custom.dashboard.dev");
assert_eq!(
resolve_dashboard_url("https://api.cinchdb.dev"),
"https://custom.dashboard.dev"
);
std::env::remove_var("CINCH_DASHBOARD_URL");
}
}