use crate::config::CliConfig;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
#[derive(Deserialize)]
struct DeviceAuthResponse {
device_code: String,
user_code: String,
verification_uri: Option<String>,
verification_uri_complete: Option<String>,
expires_in: u64,
interval: Option<u64>,
}
#[derive(Deserialize)]
struct TokenResponse {
access_token: String,
refresh_token: Option<String>,
}
#[derive(Deserialize)]
struct TokenErrorResponse {
error: String,
}
#[derive(Debug, Serialize, Deserialize, Default)]
struct PlatformTokensFile(HashMap<String, PlatformTokens>);
#[derive(Debug, Serialize, Deserialize, Clone)]
struct PlatformTokens {
access_token: String,
refresh_token: String,
keycloak_url: String,
realm: String,
client_id: String,
}
fn tokens_path() -> PathBuf {
let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
PathBuf::from(home)
.join(".config")
.join("cufflink")
.join("platform-tokens.json")
}
fn load_tokens(api_url: &str) -> Option<PlatformTokens> {
let path = tokens_path();
let json = std::fs::read_to_string(path).ok()?;
let file: PlatformTokensFile = serde_json::from_str(&json).ok()?;
file.0.get(api_url).cloned()
}
fn save_tokens(api_url: &str, tokens: &PlatformTokens) {
let path = tokens_path();
let mut file: PlatformTokensFile = std::fs::read_to_string(&path)
.ok()
.and_then(|json| serde_json::from_str(&json).ok())
.unwrap_or_default();
file.0.insert(api_url.to_string(), tokens.clone());
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
if let Ok(json) = serde_json::to_string_pretty(&file) {
let _ = std::fs::write(&path, json);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600));
}
}
}
async fn try_refresh(tokens: &PlatformTokens) -> Option<TokenResponse> {
let client = reqwest::Client::new();
let token_url = format!(
"{}/realms/{}/protocol/openid-connect/token",
tokens.keycloak_url, tokens.realm
);
let resp = client
.post(&token_url)
.form(&[
("grant_type", "refresh_token"),
("refresh_token", &tokens.refresh_token),
("client_id", &tokens.client_id),
])
.send()
.await
.ok()?;
if resp.status().is_success() {
resp.json::<TokenResponse>().await.ok()
} else {
None
}
}
async fn discover_auth_config(api_url: &str) -> Option<(String, String, String)> {
#[derive(Deserialize)]
struct AuthConfig {
keycloak_url: String,
realm: String,
cli_client_id: Option<String>,
client_id: Option<String>,
}
let client = reqwest::Client::new();
let resp = client
.get(format!("{}/api/auth/config", api_url))
.send()
.await
.ok()?;
if resp.status().is_success() {
let config: AuthConfig = resp.json().await.ok()?;
let client_id = config
.cli_client_id
.or(config.client_id)
.unwrap_or_else(|| "cufflink-cli".into());
Some((config.keycloak_url, config.realm, client_id))
} else {
None
}
}
async fn platform_auth(config: &CliConfig) -> eyre::Result<String> {
let (keycloak_url, realm, client_id) =
if let Some(discovered) = discover_auth_config(&config.api_url).await {
discovered
} else {
(
config
.keycloak_url
.clone()
.unwrap_or_else(|| "http://localhost:8180".to_string()),
config
.keycloak_realm
.clone()
.unwrap_or_else(|| "cufflink-platform".to_string()),
config
.keycloak_client_id
.clone()
.unwrap_or_else(|| "cufflink-cli".to_string()),
)
};
let keycloak_url = keycloak_url.as_str();
let realm = realm.as_str();
let client_id = client_id.as_str();
if let Some(cached) = load_tokens(&config.api_url) {
if let Some(refreshed) = try_refresh(&cached).await {
let new_tokens = PlatformTokens {
access_token: refreshed.access_token.clone(),
refresh_token: refreshed
.refresh_token
.unwrap_or(cached.refresh_token.clone()),
keycloak_url: cached.keycloak_url,
realm: cached.realm,
client_id: cached.client_id,
};
save_tokens(&config.api_url, &new_tokens);
return Ok(refreshed.access_token);
}
}
let client = reqwest::Client::new();
println!("Authenticating with platform...");
let device_url = format!(
"{}/realms/{}/protocol/openid-connect/auth/device",
keycloak_url, realm
);
let resp = client
.post(&device_url)
.form(&[("client_id", client_id)])
.send()
.await?;
if !resp.status().is_success() {
let body = resp.text().await.unwrap_or_default();
eyre::bail!(
"Failed to start device auth (is the platform realm '{}' configured?): {}",
realm,
body
);
}
let device: DeviceAuthResponse = resp.json().await?;
let verify_url = device
.verification_uri_complete
.as_deref()
.or(device.verification_uri.as_deref())
.unwrap_or("(no verification URL)");
println!();
println!(" Open this URL in your browser:");
println!(" {}", verify_url);
println!();
println!(" Enter code: {}", device.user_code);
println!();
println!("Waiting for authentication...");
let token_url = format!(
"{}/realms/{}/protocol/openid-connect/token",
keycloak_url, realm
);
let interval = device.interval.unwrap_or(5);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(device.expires_in);
let token_resp = loop {
if std::time::Instant::now() > deadline {
eyre::bail!("Authentication timed out. Please try again.");
}
tokio::time::sleep(std::time::Duration::from_secs(interval)).await;
let resp = client
.post(&token_url)
.form(&[
("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
("device_code", &device.device_code),
("client_id", client_id),
])
.send()
.await?;
if resp.status().is_success() {
let token: TokenResponse = resp.json().await?;
break token;
}
let err: TokenErrorResponse = resp.json().await?;
match err.error.as_str() {
"authorization_pending" => continue,
"slow_down" => {
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
continue;
}
other => eyre::bail!("Authentication failed: {}", other),
}
};
if let Some(ref refresh_token) = token_resp.refresh_token {
save_tokens(
&config.api_url,
&PlatformTokens {
access_token: token_resp.access_token.clone(),
refresh_token: refresh_token.clone(),
keycloak_url: keycloak_url.to_string(),
realm: realm.to_string(),
client_id: client_id.to_string(),
},
);
}
println!("Authenticated.");
Ok(token_resp.access_token)
}
pub async fn create(
name: &str,
slug: &str,
keycloak_url: Option<&str>,
keycloak_realm: Option<&str>,
deploy_role: Option<&str>,
api_url: Option<&str>,
env: Option<&str>,
) -> eyre::Result<()> {
let config = CliConfig::for_platform(api_url, env)?;
let token = platform_auth(&config).await?;
let client = reqwest::Client::new();
let mut body = serde_json::json!({
"name": name,
"slug": slug,
});
if let Some(url) = keycloak_url {
body["keycloak_url"] = serde_json::json!(url);
}
if let Some(realm) = keycloak_realm {
body["keycloak_realm"] = serde_json::json!(realm);
}
if let Some(role) = deploy_role {
body["deploy_role"] = serde_json::json!(role);
}
let resp = client
.post(format!("{}/api/tenants", config.api_url))
.header("Authorization", format!("Bearer {}", token))
.json(&body)
.send()
.await?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
eyre::bail!("Failed to create tenant ({}): {}", status, body);
}
let tenant: serde_json::Value = resp.json().await?;
println!();
println!(" Tenant created:");
println!(
" Name: {}",
tenant["name"].as_str().unwrap_or("")
);
println!(
" Slug: {}",
tenant["slug"].as_str().unwrap_or("")
);
println!(
" Keycloak URL: {}",
tenant["keycloak_url"].as_str().unwrap_or("(not set)")
);
println!(
" Keycloak Realm: {}",
tenant["keycloak_realm"].as_str().unwrap_or("(not set)")
);
println!(
" Deploy Role: {}",
tenant["deploy_role"].as_str().unwrap_or("")
);
println!();
println!("Developers can now run `cufflink login` with the tenant's Keycloak credentials.");
Ok(())
}
#[allow(clippy::too_many_arguments)]
pub async fn update(
slug: &str,
name: Option<&str>,
keycloak_url: Option<&str>,
keycloak_realm: Option<&str>,
deploy_role: Option<&str>,
keycloak_ops_client_id: Option<&str>,
keycloak_ops_client_secret: Option<&str>,
api_url: Option<&str>,
env: Option<&str>,
) -> eyre::Result<()> {
let config = CliConfig::for_platform(api_url, env)?;
let token = platform_auth(&config).await?;
let client = reqwest::Client::new();
let mut body = serde_json::Map::new();
if let Some(v) = name {
body.insert("name".into(), serde_json::json!(v));
}
if let Some(v) = keycloak_url {
body.insert("keycloak_url".into(), serde_json::json!(v));
}
if let Some(v) = keycloak_realm {
body.insert("keycloak_realm".into(), serde_json::json!(v));
}
if let Some(v) = deploy_role {
body.insert("deploy_role".into(), serde_json::json!(v));
}
if let Some(v) = keycloak_ops_client_id {
body.insert("keycloak_ops_client_id".into(), serde_json::json!(v));
}
if let Some(v) = keycloak_ops_client_secret {
body.insert("keycloak_ops_client_secret".into(), serde_json::json!(v));
}
if body.is_empty() {
eyre::bail!("Nothing to update. Provide at least one of: --name, --tenant-keycloak-url, --tenant-keycloak-realm, --deploy-role, --keycloak-ops-client-id, --keycloak-ops-client-secret");
}
let resp = client
.put(format!("{}/api/tenants/{}", config.api_url, slug))
.header("Authorization", format!("Bearer {}", token))
.json(&body)
.send()
.await?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
eyre::bail!("Failed to update tenant ({}): {}", status, body);
}
let tenant: serde_json::Value = resp.json().await?;
println!("Tenant '{}' updated:", slug);
println!(
" Name: {}",
tenant["name"].as_str().unwrap_or("")
);
println!(
" Keycloak URL: {}",
tenant["keycloak_url"].as_str().unwrap_or("(not set)")
);
println!(
" Keycloak Realm: {}",
tenant["keycloak_realm"].as_str().unwrap_or("(not set)")
);
println!(
" Deploy Role: {}",
tenant["deploy_role"].as_str().unwrap_or("")
);
Ok(())
}
pub async fn list(api_url: Option<&str>, env: Option<&str>) -> eyre::Result<()> {
let config = CliConfig::for_platform(api_url, env)?;
let token = platform_auth(&config).await?;
let client = reqwest::Client::new();
let resp = client
.get(format!("{}/api/tenants", config.api_url))
.header("Authorization", format!("Bearer {}", token))
.send()
.await?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
eyre::bail!("Failed to list tenants ({}): {}", status, body);
}
let tenants: Vec<serde_json::Value> = resp.json().await?;
if tenants.is_empty() {
println!("No tenants found.");
return Ok(());
}
println!(
"{:<20} {:<20} {:<40} {:<20}",
"SLUG", "NAME", "KEYCLOAK", "DEPLOY ROLE"
);
println!("{}", "-".repeat(100));
for t in &tenants {
let kc = match (t["keycloak_url"].as_str(), t["keycloak_realm"].as_str()) {
(Some(url), Some(realm)) => format!("{} / {}", url, realm),
_ => "(not configured)".to_string(),
};
println!(
"{:<20} {:<20} {:<40} {:<20}",
t["slug"].as_str().unwrap_or(""),
t["name"].as_str().unwrap_or(""),
kc,
t["deploy_role"].as_str().unwrap_or(""),
);
}
Ok(())
}
pub async fn delete(
slug: &str,
yes: bool,
api_url: Option<&str>,
env: Option<&str>,
) -> eyre::Result<()> {
if !yes {
println!(
"This will permanently delete tenant '{}' and all its data.",
slug
);
print!("Are you sure? (y/N) ");
use std::io::Write;
std::io::stdout().flush()?;
let mut input = String::new();
std::io::stdin().read_line(&mut input)?;
if !input.trim().eq_ignore_ascii_case("y") {
println!("Cancelled.");
return Ok(());
}
}
let config = CliConfig::for_platform(api_url, env)?;
let token = platform_auth(&config).await?;
let client = reqwest::Client::new();
let resp = client
.delete(format!("{}/api/tenants/{}", config.api_url, slug))
.header("Authorization", format!("Bearer {}", token))
.send()
.await?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
eyre::bail!("Failed to delete tenant ({}): {}", status, body);
}
println!("Tenant '{}' deleted.", slug);
Ok(())
}