Add envsync-cli commands + tested AUTH with keycloak
Adds source modules for api (auth, vault, mod), cli entrypoints, command implementations (diff, init, log, pull, push, rollback, set, unset), plus config, crypto and error support. main.rs was updated to wire up the new CLI. Cargo.toml updated to declare required dependencies and Cargo.lock regenerated. /api/auth is able to auth against keycloak with device codes and retriving a accessToken to access the spring boot backend.
This commit is contained in:
1 parent
f32e5bfea2
commit
3d72c53262
20 files changed
+2637
-35
No files matched your search
@@ -0,0 +1,2 @@
|
||||
envsync-cli/target
|
||||
.idea
|
||||
Generated
+2392
-31
File diff suppressed because it is too large.
Load diff
@@ -4,5 +4,23 @@ version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
clap = { version = "4.5.60", features = ["derive"] }
|
||||
tokio = { version = "1.49.0", features = ["full"] }
|
||||
anyhow = "1.0.102"
|
||||
clap = "4.5.60"
|
||||
comfy-table = "7.2.2"
|
||||
console = "0.16.2"
|
||||
ctrlc = "3.5.2"
|
||||
dialoguer = "0.12.0"
|
||||
directories = "6.0.0"
|
||||
dotenvy = "0.15.7"
|
||||
indicatif = "0.18.4"
|
||||
reqwest = { version = "0.13.2", features = ["form", "json"] }
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
serde_json = "1.0.149"
|
||||
sodiumoxide = "0.2.7"
|
||||
thiserror = "2.0.18"
|
||||
tokio = { version = "1.49.0", features = ["macros", "rt-multi-thread"] }
|
||||
tracing = "0.1.44"
|
||||
tracing-subscriber = "0.3.22"
|
||||
url = "2.5.8"
|
||||
chrono = "0.4.44"
|
||||
toml = "1.0.3"
|
||||
@@ -0,0 +1,78 @@
|
||||
use anyhow::Result;
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::time::Duration;
|
||||
use tokio::time::sleep;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct DeviceAuthRequest {
|
||||
client_id: String,
|
||||
client_secret: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct DeviceAuthResponse {
|
||||
device_code: String,
|
||||
user_code: String,
|
||||
verification_uri: String,
|
||||
interval: u64,
|
||||
expires_in: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
struct TokenResponse {
|
||||
access_token: String,
|
||||
refresh_token: Option<String>,
|
||||
expires_in: u64,
|
||||
}
|
||||
|
||||
pub async fn authenticate(client: &Client, keycloak_url: &str, client_id: &str) -> Result<String> {
|
||||
// 1. Request device code
|
||||
let device_auth_url = format!("{}/protocol/openid-connect/auth/device", keycloak_url);
|
||||
|
||||
let params = [
|
||||
("client_id", client_id),
|
||||
];
|
||||
let response = client.post(&device_auth_url).form(¶ms).send().await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
anyhow::bail!("Device auth failed ({}): {}", status, body);
|
||||
}
|
||||
|
||||
let resp: DeviceAuthResponse = response.json().await?;
|
||||
|
||||
println!("Open this URL in your browser: {}", resp.verification_uri);
|
||||
println!("Enter the code: {}", resp.user_code);
|
||||
|
||||
// 2. Poll for token
|
||||
let token_url = format!("{}/protocol/openid-connect/token", keycloak_url);
|
||||
let interval = Duration::from_secs(resp.interval);
|
||||
let start = std::time::Instant::now();
|
||||
|
||||
while start.elapsed().as_secs() < resp.expires_in {
|
||||
let params = [
|
||||
("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
|
||||
("device_code", &resp.device_code),
|
||||
("client_id", client_id),
|
||||
];
|
||||
let token_resp = client.post(&token_url).form(¶ms).send().await?;
|
||||
|
||||
let status = token_resp.status();
|
||||
let text = token_resp.text().await.unwrap_or_default();
|
||||
println!("Token response status: {}, body: {}", status, text);
|
||||
|
||||
if status.is_success() {
|
||||
let token: TokenResponse = serde_json::from_str(&text)?;
|
||||
return Ok(token.access_token);
|
||||
} else if status.as_u16() == 400 && text.contains("authorization_pending") {
|
||||
sleep(interval).await;
|
||||
} else {
|
||||
anyhow::bail!("Unexpected token response: {} - {}", status, text);
|
||||
}
|
||||
}
|
||||
|
||||
anyhow::bail!("Authentication timed out");
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub mod auth;
|
||||
Whitespace-only changes.
Whitespace-only changes.
Whitespace-only changes.
@@ -0,0 +1,118 @@
|
||||
use anyhow::{Result, Context, anyhow};
|
||||
use directories::ProjectDirs;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use sodiumoxide::crypto::box__;
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct AuthConfig {
|
||||
pub access_token: String,
|
||||
pub refresh_token: Option<String>,
|
||||
pub expires_at: chrono::DateTime<chrono::Utc>,
|
||||
pub user_id: String,
|
||||
pub user_email: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct KeypairConfig {
|
||||
pub public_key: String, // base64 encoded
|
||||
pub private_key: String, // base64 encrypted with a passphrase?
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct VaultContext {
|
||||
pub project_id: String,
|
||||
pub project_name: String,
|
||||
pub environment: String,
|
||||
pub vault_key: Option<String>, // base64 encrypted with user's public key
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Default)]
|
||||
pub struct Config {
|
||||
pub api_url: String,
|
||||
pub auth: Option<AuthConfig>,
|
||||
pub keypair: Option<KeypairConfig>,
|
||||
pub current_context: Option<VaultContext>,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn path() -> PathBuf {
|
||||
ProjectDirs::from("com", "envsync", "cli")
|
||||
.expect("Failed to get config directory")
|
||||
.config_dir()
|
||||
.join("config.toml")
|
||||
}
|
||||
|
||||
pub fn load() -> Result<Self> {
|
||||
let path = Self::path();
|
||||
if path.exists() {
|
||||
let content = fs::read_to_string(&path)
|
||||
.with_context(|| format!("Failed to read config from {:?}", path))?;
|
||||
Ok(toml::from_str(&content)?)
|
||||
} else {
|
||||
Ok(Config::default())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn save(&self) -> Result<()> {
|
||||
let path = Self::path();
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
let content = toml::to_string_pretty(self)?;
|
||||
fs::write(&path, content)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_auth_token(&self) -> Result<String> {
|
||||
self.auth
|
||||
.as_ref()
|
||||
.map(|a| a.access_token.clone())
|
||||
.ok_or_else(|| anyhow!("Not authenticated. Run 'envsync init' first."))
|
||||
}
|
||||
|
||||
pub fn get_current_context(&self) -> Result<&VaultContext> {
|
||||
self.current_context
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow!("No project initialized. Run 'envsync init' in your project."))
|
||||
}
|
||||
|
||||
pub fn set_current_context(&mut self, context: VaultContext) {
|
||||
self.current_context = Some(context);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ProjectConfig {
|
||||
pub project_name: String,
|
||||
pub environment: String,
|
||||
pub env_file_path: PathBuf,
|
||||
}
|
||||
|
||||
impl ProjectConfig {
|
||||
pub fn from_current_dir(project_name: Option<String>, environment: Option<String>) -> Result<Self> {
|
||||
let current_dir = std::env::current_dir()?;
|
||||
|
||||
// Look for existing .env file
|
||||
let env_file_path = current_dir.join(".env");
|
||||
|
||||
// If project name not provided, use directory name
|
||||
let project_name = match project_name {
|
||||
Some(name) => name,
|
||||
None => current_dir
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.ok_or_else(|| anyhow!("Could not determine project name from directory"))?
|
||||
.to_string(),
|
||||
};
|
||||
|
||||
let environment = environment.unwrap_or_else(|| "development".to_string());
|
||||
|
||||
Ok(ProjectConfig {
|
||||
project_name,
|
||||
environment,
|
||||
env_file_path,
|
||||
})
|
||||
}
|
||||
}
|
||||
Whitespace-only changes.
Whitespace-only changes.
Whitespace-only changes.
Whitespace-only changes.
Whitespace-only changes.
Whitespace-only changes.
Whitespace-only changes.
Whitespace-only changes.
Whitespace-only changes.
Whitespace-only changes.
@@ -1,3 +1,27 @@
|
||||
fn main() {
|
||||
println!("Hello, world!");
|
||||
// main.rs
|
||||
use anyhow::Result;
|
||||
use reqwest::Client;
|
||||
|
||||
// Import your authenticate function from the module where you defined it
|
||||
mod api;
|
||||
use api::auth::authenticate;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
let keycloak_url = std::env::var("KEYCLOAK_URL")
|
||||
.unwrap_or_else(|_| "http://localhost:8180/realms/envsync".to_string());
|
||||
let client_id = std::env::var("KEYCLOAK_CLIENT_ID")
|
||||
.unwrap_or_else(|_| "envsync-app-cli".to_string());
|
||||
|
||||
// Create an HTTP client
|
||||
let client = Client::new();
|
||||
|
||||
// Call the authentication function and also need to wait fot the user to complete the authentication in the browser
|
||||
let token = authenticate(&client, &keycloak_url, &client_id).await?;
|
||||
|
||||
println!("Access Token: {}", token);
|
||||
|
||||
Ok(())
|
||||
|
||||
|
||||
}
|
||||
Reference in new issue
Block a user