create-ekka-desktop-app 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +137 -0
- package/bin/cli.js +72 -0
- package/package.json +23 -0
- package/template/branding/app.json +6 -0
- package/template/branding/icon.icns +0 -0
- package/template/eslint.config.js +98 -0
- package/template/index.html +29 -0
- package/template/package.json +40 -0
- package/template/src/app/App.tsx +24 -0
- package/template/src/demo/DemoApp.tsx +260 -0
- package/template/src/demo/components/Banner.tsx +82 -0
- package/template/src/demo/components/EmptyState.tsx +61 -0
- package/template/src/demo/components/InfoPopover.tsx +171 -0
- package/template/src/demo/components/InfoTooltip.tsx +76 -0
- package/template/src/demo/components/LearnMore.tsx +98 -0
- package/template/src/demo/components/NodeCredentialsOnboarding.tsx +219 -0
- package/template/src/demo/components/SetupWizard.tsx +48 -0
- package/template/src/demo/components/StatusBadge.tsx +83 -0
- package/template/src/demo/components/index.ts +10 -0
- package/template/src/demo/hooks/index.ts +6 -0
- package/template/src/demo/hooks/useAuditEvents.ts +30 -0
- package/template/src/demo/layout/Shell.tsx +110 -0
- package/template/src/demo/layout/Sidebar.tsx +192 -0
- package/template/src/demo/pages/AuditLogPage.tsx +235 -0
- package/template/src/demo/pages/DocGenPage.tsx +874 -0
- package/template/src/demo/pages/HomeSetupPage.tsx +182 -0
- package/template/src/demo/pages/LoginPage.tsx +192 -0
- package/template/src/demo/pages/PathPermissionsPage.tsx +873 -0
- package/template/src/demo/pages/RunnerPage.tsx +445 -0
- package/template/src/demo/pages/SystemPage.tsx +557 -0
- package/template/src/demo/pages/VaultPage.tsx +805 -0
- package/template/src/ekka/__tests__/demo-backend.test.ts +187 -0
- package/template/src/ekka/audit/index.ts +7 -0
- package/template/src/ekka/audit/store.ts +68 -0
- package/template/src/ekka/audit/types.ts +22 -0
- package/template/src/ekka/auth/client.ts +212 -0
- package/template/src/ekka/auth/index.ts +30 -0
- package/template/src/ekka/auth/storage.ts +114 -0
- package/template/src/ekka/auth/types.ts +67 -0
- package/template/src/ekka/backend/demo.ts +151 -0
- package/template/src/ekka/backend/interface.ts +36 -0
- package/template/src/ekka/config.ts +48 -0
- package/template/src/ekka/constants.ts +143 -0
- package/template/src/ekka/errors.ts +54 -0
- package/template/src/ekka/index.ts +516 -0
- package/template/src/ekka/internal/backend.ts +156 -0
- package/template/src/ekka/internal/index.ts +7 -0
- package/template/src/ekka/ops/auth.ts +29 -0
- package/template/src/ekka/ops/debug.ts +68 -0
- package/template/src/ekka/ops/home.ts +101 -0
- package/template/src/ekka/ops/index.ts +16 -0
- package/template/src/ekka/ops/nodeCredentials.ts +131 -0
- package/template/src/ekka/ops/nodeSession.ts +145 -0
- package/template/src/ekka/ops/paths.ts +183 -0
- package/template/src/ekka/ops/runner.ts +86 -0
- package/template/src/ekka/ops/runtime.ts +31 -0
- package/template/src/ekka/ops/setup.ts +47 -0
- package/template/src/ekka/ops/vault.ts +459 -0
- package/template/src/ekka/ops/workflowRuns.ts +116 -0
- package/template/src/ekka/types.ts +82 -0
- package/template/src/ekka/utils/idempotency.ts +14 -0
- package/template/src/ekka/utils/index.ts +7 -0
- package/template/src/ekka/utils/time.ts +77 -0
- package/template/src/main.tsx +12 -0
- package/template/src/vite-env.d.ts +12 -0
- package/template/src-tauri/Cargo.toml +41 -0
- package/template/src-tauri/build.rs +3 -0
- package/template/src-tauri/capabilities/default.json +11 -0
- package/template/src-tauri/icons/icon.icns +0 -0
- package/template/src-tauri/icons/icon.png +0 -0
- package/template/src-tauri/resources/ekka-engine-bootstrap +0 -0
- package/template/src-tauri/src/bootstrap.rs +37 -0
- package/template/src-tauri/src/commands.rs +1215 -0
- package/template/src-tauri/src/device_secret.rs +111 -0
- package/template/src-tauri/src/engine_process.rs +538 -0
- package/template/src-tauri/src/grants.rs +129 -0
- package/template/src-tauri/src/handlers/home.rs +65 -0
- package/template/src-tauri/src/handlers/mod.rs +7 -0
- package/template/src-tauri/src/handlers/paths.rs +128 -0
- package/template/src-tauri/src/handlers/vault.rs +680 -0
- package/template/src-tauri/src/main.rs +243 -0
- package/template/src-tauri/src/node_auth.rs +858 -0
- package/template/src-tauri/src/node_credentials.rs +541 -0
- package/template/src-tauri/src/node_runner.rs +882 -0
- package/template/src-tauri/src/node_vault_crypto.rs +113 -0
- package/template/src-tauri/src/node_vault_store.rs +267 -0
- package/template/src-tauri/src/ops/auth.rs +50 -0
- package/template/src-tauri/src/ops/home.rs +251 -0
- package/template/src-tauri/src/ops/mod.rs +7 -0
- package/template/src-tauri/src/ops/runtime.rs +21 -0
- package/template/src-tauri/src/state.rs +639 -0
- package/template/src-tauri/src/types.rs +84 -0
- package/template/src-tauri/tauri.conf.json +41 -0
- package/template/tsconfig.json +26 -0
- package/template/tsconfig.tsbuildinfo +1 -0
- package/template/vite.config.ts +34 -0
|
@@ -0,0 +1,541 @@
|
|
|
1
|
+
//! Node Credentials Management
|
|
2
|
+
//!
|
|
3
|
+
//! Stores node_id + node_secret encrypted in the node vault.
|
|
4
|
+
//! Used for headless engine startup without interactive login.
|
|
5
|
+
//!
|
|
6
|
+
//! ## Security Model
|
|
7
|
+
//!
|
|
8
|
+
//! - Credentials stored in node vault (AES-256-GCM encrypted)
|
|
9
|
+
//! - Device-bound key derivation (device secret + node_id + epoch)
|
|
10
|
+
//! - Credentials never logged
|
|
11
|
+
//! - No OS keychain prompts
|
|
12
|
+
|
|
13
|
+
use crate::bootstrap::{initialize_home, resolve_home_path};
|
|
14
|
+
use crate::node_vault_store::{
|
|
15
|
+
delete_node_secret, has_node_secret, read_node_secret, write_node_secret,
|
|
16
|
+
SECRET_ID_NODE_CREDENTIALS,
|
|
17
|
+
};
|
|
18
|
+
use chrono::{DateTime, Utc};
|
|
19
|
+
use serde::{Deserialize, Serialize};
|
|
20
|
+
use std::sync::RwLock;
|
|
21
|
+
use std::time::Duration;
|
|
22
|
+
use uuid::Uuid;
|
|
23
|
+
|
|
24
|
+
// =============================================================================
|
|
25
|
+
// Types
|
|
26
|
+
// =============================================================================
|
|
27
|
+
|
|
28
|
+
/// Node credentials stored in vault
|
|
29
|
+
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
30
|
+
pub struct NodeCredentials {
|
|
31
|
+
pub node_id: String,
|
|
32
|
+
pub node_secret: String,
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/// Node authentication token received from server (role=node)
|
|
36
|
+
/// Stored in memory only - never persisted to disk
|
|
37
|
+
#[derive(Debug, Clone)]
|
|
38
|
+
pub struct NodeAuthToken {
|
|
39
|
+
pub token: String,
|
|
40
|
+
pub node_id: Uuid,
|
|
41
|
+
pub tenant_id: Uuid,
|
|
42
|
+
pub workspace_id: Uuid,
|
|
43
|
+
pub session_id: Uuid,
|
|
44
|
+
pub expires_at: DateTime<Utc>,
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
impl NodeAuthToken {
|
|
48
|
+
/// Check if token is expired (with 60s buffer)
|
|
49
|
+
pub fn is_expired(&self) -> bool {
|
|
50
|
+
Utc::now() + chrono::Duration::seconds(60) >= self.expires_at
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/// Thread-safe holder for node auth token
|
|
55
|
+
pub struct NodeAuthTokenHolder {
|
|
56
|
+
inner: RwLock<Option<NodeAuthToken>>,
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
impl NodeAuthTokenHolder {
|
|
60
|
+
pub fn new() -> Self {
|
|
61
|
+
Self {
|
|
62
|
+
inner: RwLock::new(None),
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
pub fn get(&self) -> Option<NodeAuthToken> {
|
|
67
|
+
self.inner.read().ok()?.clone()
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
pub fn set(&self, token: NodeAuthToken) {
|
|
71
|
+
if let Ok(mut guard) = self.inner.write() {
|
|
72
|
+
*guard = Some(token);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
#[allow(dead_code)]
|
|
77
|
+
pub fn clear(&self) {
|
|
78
|
+
if let Ok(mut guard) = self.inner.write() {
|
|
79
|
+
*guard = None;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/// Get valid token or None if missing/expired
|
|
84
|
+
pub fn get_valid(&self) -> Option<NodeAuthToken> {
|
|
85
|
+
let token = self.get()?;
|
|
86
|
+
if token.is_expired() {
|
|
87
|
+
None
|
|
88
|
+
} else {
|
|
89
|
+
Some(token)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
impl Default for NodeAuthTokenHolder {
|
|
95
|
+
fn default() -> Self {
|
|
96
|
+
Self::new()
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/// Result of loading credentials
|
|
101
|
+
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
102
|
+
#[serde(rename_all = "camelCase")]
|
|
103
|
+
pub struct CredentialsStatus {
|
|
104
|
+
pub has_credentials: bool,
|
|
105
|
+
pub node_id: Option<String>,
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/// Error type for credential operations
|
|
109
|
+
#[derive(Debug)]
|
|
110
|
+
pub enum CredentialsError {
|
|
111
|
+
VaultError(String),
|
|
112
|
+
InvalidNodeId(String),
|
|
113
|
+
InvalidNodeSecret(String),
|
|
114
|
+
NotConfigured,
|
|
115
|
+
AuthFailed(u16, String),
|
|
116
|
+
HttpError(String),
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
impl std::fmt::Display for CredentialsError {
|
|
120
|
+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
121
|
+
match self {
|
|
122
|
+
CredentialsError::VaultError(msg) => write!(f, "Vault error: {}", msg),
|
|
123
|
+
CredentialsError::InvalidNodeId(msg) => write!(f, "Invalid node_id: {}", msg),
|
|
124
|
+
CredentialsError::InvalidNodeSecret(msg) => write!(f, "Invalid node_secret: {}", msg),
|
|
125
|
+
CredentialsError::NotConfigured => write!(f, "Node credentials not configured"),
|
|
126
|
+
CredentialsError::AuthFailed(status, msg) => {
|
|
127
|
+
write!(f, "Node auth failed ({}): {}", status, msg)
|
|
128
|
+
}
|
|
129
|
+
CredentialsError::HttpError(msg) => write!(f, "HTTP error: {}", msg),
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
impl std::error::Error for CredentialsError {}
|
|
135
|
+
|
|
136
|
+
// =============================================================================
|
|
137
|
+
// Helper: Get epoch for vault operations
|
|
138
|
+
// =============================================================================
|
|
139
|
+
|
|
140
|
+
/// Get the security epoch from environment
|
|
141
|
+
fn get_security_epoch() -> u32 {
|
|
142
|
+
std::env::var("EKKA_SECURITY_EPOCH")
|
|
143
|
+
.ok()
|
|
144
|
+
.and_then(|s| s.parse().ok())
|
|
145
|
+
.unwrap_or(1)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// =============================================================================
|
|
149
|
+
// Core Functions
|
|
150
|
+
// =============================================================================
|
|
151
|
+
|
|
152
|
+
/// Store node credentials in vault
|
|
153
|
+
///
|
|
154
|
+
/// # Arguments
|
|
155
|
+
/// * `node_id` - UUID of the node
|
|
156
|
+
/// * `node_secret` - Secret key for the node (NEVER logged)
|
|
157
|
+
///
|
|
158
|
+
/// # Returns
|
|
159
|
+
/// * `Ok(())` on success
|
|
160
|
+
/// * `Err(CredentialsError)` on failure
|
|
161
|
+
pub fn store_credentials(node_id: &Uuid, node_secret: &str) -> Result<(), CredentialsError> {
|
|
162
|
+
// Validate inputs
|
|
163
|
+
if node_secret.is_empty() {
|
|
164
|
+
return Err(CredentialsError::InvalidNodeSecret(
|
|
165
|
+
"node_secret cannot be empty".to_string(),
|
|
166
|
+
));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Initialize home if needed
|
|
170
|
+
let bootstrap = initialize_home().map_err(|e| CredentialsError::VaultError(e))?;
|
|
171
|
+
let home = bootstrap.home_path();
|
|
172
|
+
let epoch = get_security_epoch();
|
|
173
|
+
|
|
174
|
+
// Store node_id + node_secret as JSON
|
|
175
|
+
let creds = NodeCredentials {
|
|
176
|
+
node_id: node_id.to_string(),
|
|
177
|
+
node_secret: node_secret.to_string(),
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
let json = serde_json::to_vec(&creds)
|
|
181
|
+
.map_err(|e| CredentialsError::VaultError(format!("JSON encode error: {}", e)))?;
|
|
182
|
+
|
|
183
|
+
// Key derivation uses device_secret + epoch only (not node_id)
|
|
184
|
+
write_node_secret(home, epoch, SECRET_ID_NODE_CREDENTIALS, &json)
|
|
185
|
+
.map_err(|e| CredentialsError::VaultError(e.to_string()))?;
|
|
186
|
+
|
|
187
|
+
tracing::info!(
|
|
188
|
+
op = "node_credentials.stored",
|
|
189
|
+
storage = "vault",
|
|
190
|
+
node_id = %node_id,
|
|
191
|
+
"Node credentials stored in vault"
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
Ok(())
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/// Load node credentials from vault
|
|
198
|
+
///
|
|
199
|
+
/// # Returns
|
|
200
|
+
/// * `Ok((node_id, node_secret))` on success
|
|
201
|
+
/// * `Err(CredentialsError)` if not found or invalid
|
|
202
|
+
pub fn load_credentials() -> Result<(Uuid, String), CredentialsError> {
|
|
203
|
+
let home = resolve_home_path().map_err(|e| CredentialsError::VaultError(e))?;
|
|
204
|
+
let epoch = get_security_epoch();
|
|
205
|
+
|
|
206
|
+
// Key derivation uses device_secret + epoch only (not node_id)
|
|
207
|
+
let plaintext = read_node_secret(&home, epoch, SECRET_ID_NODE_CREDENTIALS)
|
|
208
|
+
.map_err(|e| CredentialsError::VaultError(e.to_string()))?
|
|
209
|
+
.ok_or(CredentialsError::NotConfigured)?;
|
|
210
|
+
|
|
211
|
+
let creds: NodeCredentials = serde_json::from_slice(&plaintext)
|
|
212
|
+
.map_err(|e| CredentialsError::VaultError(format!("JSON decode error: {}", e)))?;
|
|
213
|
+
|
|
214
|
+
let node_id = Uuid::parse_str(&creds.node_id)
|
|
215
|
+
.map_err(|e| CredentialsError::InvalidNodeId(format!("Invalid UUID: {}", e)))?;
|
|
216
|
+
|
|
217
|
+
tracing::info!(
|
|
218
|
+
op = "node_credentials.loaded",
|
|
219
|
+
storage = "vault",
|
|
220
|
+
ok = true,
|
|
221
|
+
"Node credentials loaded from vault"
|
|
222
|
+
);
|
|
223
|
+
|
|
224
|
+
Ok((node_id, creds.node_secret))
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/// Check if credentials exist
|
|
228
|
+
pub fn has_credentials() -> bool {
|
|
229
|
+
let Ok(home) = resolve_home_path() else {
|
|
230
|
+
tracing::info!(
|
|
231
|
+
op = "desktop.node.credentials.check",
|
|
232
|
+
has_credentials = false,
|
|
233
|
+
reason = "no_home_path",
|
|
234
|
+
"Credentials check: no home path"
|
|
235
|
+
);
|
|
236
|
+
return false;
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
// Check if vault file exists
|
|
240
|
+
let has_creds = has_node_secret(&home, SECRET_ID_NODE_CREDENTIALS);
|
|
241
|
+
|
|
242
|
+
tracing::info!(
|
|
243
|
+
op = "desktop.node.credentials.check",
|
|
244
|
+
has_credentials = has_creds,
|
|
245
|
+
storage = "vault",
|
|
246
|
+
"Credentials check"
|
|
247
|
+
);
|
|
248
|
+
|
|
249
|
+
has_creds
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/// Get credentials status (has credentials + node_id if present)
|
|
253
|
+
pub fn get_status() -> CredentialsStatus {
|
|
254
|
+
let node_id = load_credentials().ok().map(|(id, _)| id.to_string());
|
|
255
|
+
let has_creds = node_id.is_some();
|
|
256
|
+
|
|
257
|
+
CredentialsStatus {
|
|
258
|
+
has_credentials: has_creds,
|
|
259
|
+
node_id,
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/// Clear node credentials from vault
|
|
264
|
+
pub fn clear_credentials() -> Result<(), CredentialsError> {
|
|
265
|
+
let home = resolve_home_path().map_err(|e| CredentialsError::VaultError(e))?;
|
|
266
|
+
|
|
267
|
+
delete_node_secret(&home, SECRET_ID_NODE_CREDENTIALS)
|
|
268
|
+
.map_err(|e| CredentialsError::VaultError(e.to_string()))?;
|
|
269
|
+
|
|
270
|
+
tracing::info!(
|
|
271
|
+
op = "desktop.node.credentials.cleared",
|
|
272
|
+
"Node credentials cleared from vault"
|
|
273
|
+
);
|
|
274
|
+
|
|
275
|
+
Ok(())
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/// Validate node_id format (must be valid UUID)
|
|
279
|
+
pub fn validate_node_id(node_id_str: &str) -> Result<Uuid, CredentialsError> {
|
|
280
|
+
Uuid::parse_str(node_id_str)
|
|
281
|
+
.map_err(|e| CredentialsError::InvalidNodeId(format!("Invalid UUID format: {}", e)))
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/// Validate node_secret (must be non-empty)
|
|
285
|
+
pub fn validate_node_secret(node_secret: &str) -> Result<(), CredentialsError> {
|
|
286
|
+
if node_secret.is_empty() {
|
|
287
|
+
return Err(CredentialsError::InvalidNodeSecret(
|
|
288
|
+
"node_secret cannot be empty".to_string(),
|
|
289
|
+
));
|
|
290
|
+
}
|
|
291
|
+
if node_secret.len() < 16 {
|
|
292
|
+
return Err(CredentialsError::InvalidNodeSecret(
|
|
293
|
+
"node_secret must be at least 16 characters".to_string(),
|
|
294
|
+
));
|
|
295
|
+
}
|
|
296
|
+
Ok(())
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// =============================================================================
|
|
300
|
+
// Node Authentication
|
|
301
|
+
// =============================================================================
|
|
302
|
+
|
|
303
|
+
/// Load instance_id from marker file for session reuse
|
|
304
|
+
fn load_instance_id_from_marker() -> Option<String> {
|
|
305
|
+
let home = resolve_home_path().ok()?;
|
|
306
|
+
let marker_path = home.join(".ekka-marker.json");
|
|
307
|
+
|
|
308
|
+
let content = std::fs::read_to_string(&marker_path).ok()?;
|
|
309
|
+
let marker: serde_json::Value = serde_json::from_str(&content).ok()?;
|
|
310
|
+
|
|
311
|
+
marker
|
|
312
|
+
.get("instance_id")
|
|
313
|
+
.and_then(|v| v.as_str())
|
|
314
|
+
.map(|s| s.to_string())
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/// Response from POST /engine/nodes/auth
|
|
318
|
+
/// Server returns: token, session_id, tenant_id, workspace_id, expires_in_seconds, expires_at_iso_utc
|
|
319
|
+
/// Note: node_id is NOT in response - we already know it from the request
|
|
320
|
+
#[derive(Debug, Deserialize)]
|
|
321
|
+
struct NodeAuthResponse {
|
|
322
|
+
token: String,
|
|
323
|
+
#[serde(alias = "tenantId")]
|
|
324
|
+
tenant_id: Option<String>,
|
|
325
|
+
#[serde(alias = "workspaceId")]
|
|
326
|
+
workspace_id: Option<String>,
|
|
327
|
+
#[serde(alias = "sessionId")]
|
|
328
|
+
session_id: Option<String>,
|
|
329
|
+
#[serde(alias = "expiresAtIsoUtc", alias = "expiresAt", alias = "expires_at")]
|
|
330
|
+
expires_at_iso_utc: Option<String>,
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/// Error response from auth endpoints
|
|
334
|
+
#[derive(Debug, Deserialize)]
|
|
335
|
+
struct AuthErrorResponse {
|
|
336
|
+
error: Option<String>,
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Error codes that indicate secret is invalid or revoked
|
|
340
|
+
const ERROR_INVALID_SECRET: &str = "invalid_secret";
|
|
341
|
+
const ERROR_SECRET_REVOKED: &str = "secret_revoked";
|
|
342
|
+
const ERROR_INVALID_CREDENTIALS: &str = "invalid_credentials";
|
|
343
|
+
|
|
344
|
+
/// Check if an auth failure indicates the secret is invalid or revoked.
|
|
345
|
+
pub fn is_secret_error(status: u16, body: &str) -> bool {
|
|
346
|
+
if status == 401 || status == 403 {
|
|
347
|
+
if let Ok(err) = serde_json::from_str::<AuthErrorResponse>(body) {
|
|
348
|
+
if let Some(error) = err.error {
|
|
349
|
+
return error == ERROR_INVALID_SECRET
|
|
350
|
+
|| error == ERROR_SECRET_REVOKED
|
|
351
|
+
|| error == ERROR_INVALID_CREDENTIALS;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
false
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/// Authenticate node with server using node_id + node_secret
|
|
359
|
+
/// Includes instance_id from marker for session reuse (avoids session_limit on restarts)
|
|
360
|
+
pub fn authenticate_node(engine_url: &str) -> Result<NodeAuthToken, CredentialsError> {
|
|
361
|
+
// Load credentials from vault
|
|
362
|
+
let (node_id, node_secret) = load_credentials()?;
|
|
363
|
+
|
|
364
|
+
// Load instance_id from marker file (for session reuse)
|
|
365
|
+
let instance_id = load_instance_id_from_marker();
|
|
366
|
+
|
|
367
|
+
let client = reqwest::blocking::Client::builder()
|
|
368
|
+
.timeout(Duration::from_secs(30))
|
|
369
|
+
.build()
|
|
370
|
+
.map_err(|e| CredentialsError::HttpError(format!("Client build error: {}", e)))?;
|
|
371
|
+
|
|
372
|
+
let request_id = Uuid::new_v4().to_string();
|
|
373
|
+
|
|
374
|
+
// Build request body with optional instance_id
|
|
375
|
+
let mut body = serde_json::json!({
|
|
376
|
+
"node_id": node_id.to_string(),
|
|
377
|
+
"node_secret": node_secret
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
if let Some(ref iid) = instance_id {
|
|
381
|
+
body["instance_id"] = serde_json::Value::String(iid.clone());
|
|
382
|
+
tracing::info!(
|
|
383
|
+
op = "desktop.node.auth.request",
|
|
384
|
+
instance_id = %iid,
|
|
385
|
+
"Authenticating with instance_id for session reuse"
|
|
386
|
+
);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
let response = client
|
|
390
|
+
.post(format!("{}/engine/nodes/auth", engine_url))
|
|
391
|
+
.header("Content-Type", "application/json")
|
|
392
|
+
.header("X-EKKA-PROOF-TYPE", "node_secret")
|
|
393
|
+
.header("X-REQUEST-ID", &request_id)
|
|
394
|
+
.header("X-EKKA-CORRELATION-ID", &request_id)
|
|
395
|
+
.header("X-EKKA-MODULE", "desktop.node_auth")
|
|
396
|
+
.header("X-EKKA-ACTION", "authenticate")
|
|
397
|
+
.header("X-EKKA-CLIENT", "ekka-desktop")
|
|
398
|
+
.header("X-EKKA-CLIENT-VERSION", "0.2.0")
|
|
399
|
+
.json(&body)
|
|
400
|
+
.send()
|
|
401
|
+
.map_err(|e| CredentialsError::HttpError(format!("Request failed: {}", e)))?;
|
|
402
|
+
|
|
403
|
+
let status = response.status();
|
|
404
|
+
|
|
405
|
+
if !status.is_success() {
|
|
406
|
+
let status_code = status.as_u16();
|
|
407
|
+
let body = response.text().unwrap_or_default();
|
|
408
|
+
tracing::warn!(
|
|
409
|
+
op = "desktop.node.auth.failed",
|
|
410
|
+
status = status_code,
|
|
411
|
+
body = %body,
|
|
412
|
+
"Node authentication failed"
|
|
413
|
+
);
|
|
414
|
+
return Err(CredentialsError::AuthFailed(status_code, body));
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// Get response text first to log it for debugging
|
|
418
|
+
let response_text = response.text().map_err(|e| {
|
|
419
|
+
CredentialsError::HttpError(format!("Failed to read response: {}", e))
|
|
420
|
+
})?;
|
|
421
|
+
|
|
422
|
+
tracing::debug!(
|
|
423
|
+
op = "desktop.node.auth.response",
|
|
424
|
+
body = %response_text,
|
|
425
|
+
"Raw auth response"
|
|
426
|
+
);
|
|
427
|
+
|
|
428
|
+
let auth_response: NodeAuthResponse = serde_json::from_str(&response_text).map_err(|e| {
|
|
429
|
+
tracing::error!(
|
|
430
|
+
op = "desktop.node.auth.parse_error",
|
|
431
|
+
error = %e,
|
|
432
|
+
body = %response_text,
|
|
433
|
+
"Failed to parse auth response"
|
|
434
|
+
);
|
|
435
|
+
CredentialsError::HttpError(format!("Parse error: {}. Response: {}", e, &response_text[..response_text.len().min(200)]))
|
|
436
|
+
})?;
|
|
437
|
+
|
|
438
|
+
// Extract fields with helpful error messages
|
|
439
|
+
// Note: node_id is not in response - we use the one we sent in the request
|
|
440
|
+
let tenant_id_str = auth_response.tenant_id.ok_or_else(|| {
|
|
441
|
+
CredentialsError::HttpError("Response missing tenant_id/tenantId field".to_string())
|
|
442
|
+
})?;
|
|
443
|
+
let workspace_id_str = auth_response.workspace_id.ok_or_else(|| {
|
|
444
|
+
CredentialsError::HttpError("Response missing workspace_id/workspaceId field".to_string())
|
|
445
|
+
})?;
|
|
446
|
+
let session_id_str = auth_response.session_id.ok_or_else(|| {
|
|
447
|
+
CredentialsError::HttpError("Response missing session_id/sessionId field".to_string())
|
|
448
|
+
})?;
|
|
449
|
+
let expires_at_str = auth_response.expires_at_iso_utc.ok_or_else(|| {
|
|
450
|
+
CredentialsError::HttpError("Response missing expires_at field".to_string())
|
|
451
|
+
})?;
|
|
452
|
+
|
|
453
|
+
let token = NodeAuthToken {
|
|
454
|
+
token: auth_response.token,
|
|
455
|
+
node_id, // Use the node_id from load_credentials() - already a Uuid
|
|
456
|
+
tenant_id: Uuid::parse_str(&tenant_id_str)
|
|
457
|
+
.map_err(|e| CredentialsError::HttpError(format!("Invalid tenant_id: {}", e)))?,
|
|
458
|
+
workspace_id: Uuid::parse_str(&workspace_id_str)
|
|
459
|
+
.map_err(|e| CredentialsError::HttpError(format!("Invalid workspace_id: {}", e)))?,
|
|
460
|
+
session_id: Uuid::parse_str(&session_id_str)
|
|
461
|
+
.map_err(|e| CredentialsError::HttpError(format!("Invalid session_id: {}", e)))?,
|
|
462
|
+
expires_at: DateTime::parse_from_rfc3339(&expires_at_str)
|
|
463
|
+
.map_err(|e| CredentialsError::HttpError(format!("Invalid expires_at: {}", e)))?
|
|
464
|
+
.with_timezone(&Utc),
|
|
465
|
+
};
|
|
466
|
+
|
|
467
|
+
tracing::info!(
|
|
468
|
+
op = "desktop.node.auth.success",
|
|
469
|
+
keys = ?["node_id", "session_id"],
|
|
470
|
+
node_id = %token.node_id,
|
|
471
|
+
session_id = %token.session_id,
|
|
472
|
+
"Node authenticated successfully"
|
|
473
|
+
);
|
|
474
|
+
|
|
475
|
+
Ok(token)
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
#[cfg(test)]
|
|
479
|
+
mod tests {
|
|
480
|
+
use super::*;
|
|
481
|
+
|
|
482
|
+
#[test]
|
|
483
|
+
fn test_validate_node_id() {
|
|
484
|
+
// Valid UUID
|
|
485
|
+
let result = validate_node_id("550e8400-e29b-41d4-a716-446655440000");
|
|
486
|
+
assert!(result.is_ok());
|
|
487
|
+
|
|
488
|
+
// Invalid UUID
|
|
489
|
+
let result = validate_node_id("not-a-uuid");
|
|
490
|
+
assert!(result.is_err());
|
|
491
|
+
|
|
492
|
+
// Empty
|
|
493
|
+
let result = validate_node_id("");
|
|
494
|
+
assert!(result.is_err());
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
#[test]
|
|
498
|
+
fn test_validate_node_secret() {
|
|
499
|
+
// Valid secret
|
|
500
|
+
let result = validate_node_secret("a-sufficiently-long-secret-key");
|
|
501
|
+
assert!(result.is_ok());
|
|
502
|
+
|
|
503
|
+
// Too short
|
|
504
|
+
let result = validate_node_secret("short");
|
|
505
|
+
assert!(result.is_err());
|
|
506
|
+
|
|
507
|
+
// Empty
|
|
508
|
+
let result = validate_node_secret("");
|
|
509
|
+
assert!(result.is_err());
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
#[test]
|
|
513
|
+
fn test_is_secret_error() {
|
|
514
|
+
// 401 with invalid_secret
|
|
515
|
+
assert!(is_secret_error(401, r#"{"error":"invalid_secret"}"#));
|
|
516
|
+
|
|
517
|
+
// 401 with secret_revoked
|
|
518
|
+
assert!(is_secret_error(401, r#"{"error":"secret_revoked"}"#));
|
|
519
|
+
|
|
520
|
+
// 401 with invalid_credentials
|
|
521
|
+
assert!(is_secret_error(401, r#"{"error":"invalid_credentials"}"#));
|
|
522
|
+
|
|
523
|
+
// 403 with invalid_secret
|
|
524
|
+
assert!(is_secret_error(403, r#"{"error":"invalid_secret"}"#));
|
|
525
|
+
|
|
526
|
+
// 401 with different error
|
|
527
|
+
assert!(!is_secret_error(401, r#"{"error":"some_other_error"}"#));
|
|
528
|
+
|
|
529
|
+
// 200 with invalid_secret (should not trigger - wrong status)
|
|
530
|
+
assert!(!is_secret_error(200, r#"{"error":"invalid_secret"}"#));
|
|
531
|
+
|
|
532
|
+
// 401 with malformed JSON
|
|
533
|
+
assert!(!is_secret_error(401, "not json"));
|
|
534
|
+
|
|
535
|
+
// 401 with empty body
|
|
536
|
+
assert!(!is_secret_error(401, ""));
|
|
537
|
+
|
|
538
|
+
// 401 with no error field
|
|
539
|
+
assert!(!is_secret_error(401, r#"{"message":"error"}"#));
|
|
540
|
+
}
|
|
541
|
+
}
|