Fixed 42 clippy warnings across integration and command modules: - unnecessary_lazy_evaluations: Changed unwrap_or_else to unwrap_or - uninlined_format_args: Modernized format strings to use inline syntax - needless_borrows_for_generic_args: Removed unnecessary borrows - only_used_in_recursion: Prefixed unused recursive param with underscore All files now pass cargo clippy -- -D warnings Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
337 lines
12 KiB
Rust
337 lines
12 KiB
Rust
use serde::{Deserialize, Serialize};
|
|
use tauri::{AppHandle, WebviewUrl, WebviewWindow, WebviewWindowBuilder};
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct ExtractedCredentials {
|
|
pub cookies: Vec<Cookie>,
|
|
pub service: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct Cookie {
|
|
pub name: String,
|
|
pub value: String,
|
|
pub domain: String,
|
|
pub path: String,
|
|
pub secure: bool,
|
|
pub http_only: bool,
|
|
pub expires: Option<i64>,
|
|
}
|
|
|
|
/// Open an embedded browser window for the user to log in and extract cookies.
|
|
/// This approach works when user is off-VPN (can access web UI) but APIs require VPN.
|
|
pub async fn authenticate_with_webview(
|
|
app_handle: AppHandle,
|
|
service: &str,
|
|
base_url: &str,
|
|
project_name: Option<&str>,
|
|
) -> Result<ExtractedCredentials, String> {
|
|
let trimmed_base_url = base_url.trim_end_matches('/');
|
|
|
|
tracing::info!(
|
|
"authenticate_with_webview called: service={}, base_url={}, project_name={:?}",
|
|
service,
|
|
base_url,
|
|
project_name
|
|
);
|
|
|
|
let login_url = match service {
|
|
"confluence" => format!("{trimmed_base_url}/login.action"),
|
|
"azuredevops" => {
|
|
// Azure DevOps - go directly to project if provided, otherwise org home
|
|
if let Some(project) = project_name {
|
|
let url = format!("{trimmed_base_url}/{project}");
|
|
tracing::info!("Azure DevOps URL with project: {}", url);
|
|
url
|
|
} else {
|
|
tracing::info!("Azure DevOps URL without project: {}", trimmed_base_url);
|
|
trimmed_base_url.to_string()
|
|
}
|
|
}
|
|
"servicenow" => format!("{trimmed_base_url}/login.do"),
|
|
_ => return Err(format!("Unknown service: {service}")),
|
|
};
|
|
|
|
tracing::info!("Final login_url for {} = {}", service, login_url);
|
|
|
|
// Create persistent browser window (stays open for browsing and fresh cookie extraction)
|
|
let webview_label = format!("{service}-auth");
|
|
|
|
tracing::info!("Creating webview window with label: {}", webview_label);
|
|
|
|
let parsed_url = login_url.parse().map_err(|e| {
|
|
let err_msg = format!("Failed to parse URL '{login_url}': {e}");
|
|
tracing::error!("{err_msg}");
|
|
err_msg
|
|
})?;
|
|
|
|
tracing::info!("Parsed URL successfully: {:?}", parsed_url);
|
|
|
|
let webview = WebviewWindowBuilder::new(
|
|
&app_handle,
|
|
&webview_label,
|
|
WebviewUrl::External(parsed_url),
|
|
)
|
|
.title(format!(
|
|
"{service} Browser (Troubleshooting and RCA Assistant)"
|
|
))
|
|
.inner_size(1000.0, 800.0)
|
|
.min_inner_size(800.0, 600.0)
|
|
.resizable(true)
|
|
.center()
|
|
.focused(true)
|
|
.visible(true) // Show immediately - let user see loading
|
|
.user_agent("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
|
|
.zoom_hotkeys_enabled(true)
|
|
.devtools(true)
|
|
.initialization_script("console.log('Webview initialized');")
|
|
.build()
|
|
.map_err(|e| format!("Failed to create webview: {e}"))?;
|
|
|
|
tracing::info!("Webview window created successfully, setting focus");
|
|
|
|
// Ensure window is focused
|
|
webview
|
|
.set_focus()
|
|
.map_err(|e| tracing::warn!("Failed to set focus: {}", e))
|
|
.ok();
|
|
|
|
// Wait for user to complete login
|
|
// User will click "Complete Login" button in the UI after successful authentication
|
|
// This function just opens the window - extraction happens in extract_cookies_via_ipc
|
|
|
|
Ok(ExtractedCredentials {
|
|
cookies: vec![],
|
|
service: service.to_string(),
|
|
})
|
|
}
|
|
|
|
/// Extract cookies from a webview using localStorage as intermediary.
|
|
/// This works for external URLs where window.__TAURI__ is not available.
|
|
pub async fn extract_cookies_via_ipc<R: tauri::Runtime>(
|
|
webview_window: &WebviewWindow<R>,
|
|
_app_handle: &AppHandle<R>,
|
|
) -> Result<Vec<Cookie>, String> {
|
|
// Step 1: Inject JavaScript to extract cookies and store in a global variable
|
|
// We can't use __TAURI__ for external URLs, so we use a polling approach
|
|
let cookie_extraction_script = r#"
|
|
(function() {
|
|
try {
|
|
const cookieString = document.cookie;
|
|
const cookies = [];
|
|
|
|
if (cookieString && cookieString.trim() !== '') {
|
|
const cookieList = cookieString.split(';').map(c => c.trim()).filter(c => c.length > 0);
|
|
for (const cookie of cookieList) {
|
|
const equalIndex = cookie.indexOf('=');
|
|
if (equalIndex === -1) continue;
|
|
|
|
const name = cookie.substring(0, equalIndex).trim();
|
|
const value = cookie.substring(equalIndex + 1).trim();
|
|
|
|
cookies.push({
|
|
name: name,
|
|
value: value,
|
|
domain: window.location.hostname,
|
|
path: '/',
|
|
secure: window.location.protocol === 'https:',
|
|
http_only: false,
|
|
expires: null
|
|
});
|
|
}
|
|
}
|
|
|
|
// Store in a global variable that Rust can read
|
|
window.__TFTSR_COOKIES__ = cookies;
|
|
console.log('[TFTSR] Extracted', cookies.length, 'cookies');
|
|
return cookies.length;
|
|
} catch (e) {
|
|
console.error('[TFTSR] Cookie extraction failed:', e);
|
|
window.__TFTSR_COOKIES__ = [];
|
|
window.__TFTSR_ERROR__ = e.message;
|
|
return -1;
|
|
}
|
|
})();
|
|
"#;
|
|
|
|
// Inject the extraction script
|
|
webview_window
|
|
.eval(cookie_extraction_script)
|
|
.map_err(|e| format!("Failed to inject cookie extraction script: {e}"))?;
|
|
|
|
tracing::info!("Cookie extraction script injected, waiting for cookies...");
|
|
|
|
// Give JavaScript a moment to execute
|
|
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
|
|
|
|
// Step 2: Poll for the extracted cookies using document.title as communication channel
|
|
let mut attempts = 0;
|
|
let max_attempts = 20; // 10 seconds total (500ms * 20)
|
|
|
|
loop {
|
|
attempts += 1;
|
|
|
|
// Store result in localStorage, then copy to document.title for Rust to read
|
|
let check_and_signal_script = r#"
|
|
try {
|
|
if (typeof window.__TFTSR_ERROR__ !== 'undefined') {
|
|
window.localStorage.setItem('tftsr_result', JSON.stringify({ error: window.__TFTSR_ERROR__ }));
|
|
} else if (typeof window.__TFTSR_COOKIES__ !== 'undefined' && window.__TFTSR_COOKIES__.length > 0) {
|
|
window.localStorage.setItem('tftsr_result', JSON.stringify({ cookies: window.__TFTSR_COOKIES__ }));
|
|
} else if (typeof window.__TFTSR_COOKIES__ !== 'undefined') {
|
|
window.localStorage.setItem('tftsr_result', JSON.stringify({ cookies: [] }));
|
|
}
|
|
} catch (e) {
|
|
window.localStorage.setItem('tftsr_result', JSON.stringify({ error: e.message }));
|
|
}
|
|
"#;
|
|
|
|
webview_window.eval(check_and_signal_script).ok();
|
|
|
|
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
|
|
|
|
// We can't get return values from eval(), so let's use a different approach:
|
|
// Execute script that sets document.title temporarily
|
|
let read_via_title = r#"
|
|
(function() {
|
|
const result = window.localStorage.getItem('tftsr_result');
|
|
if (result) {
|
|
window.localStorage.removeItem('tftsr_result');
|
|
// Store in title temporarily for Rust to read
|
|
window.__TFTSR_ORIGINAL_TITLE__ = document.title;
|
|
document.title = 'TFTSR_RESULT:' + result;
|
|
}
|
|
})();
|
|
"#;
|
|
|
|
webview_window.eval(read_via_title).ok();
|
|
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
|
|
|
|
// Read the title
|
|
if let Ok(title) = webview_window.title() {
|
|
if let Some(json_str) = title.strip_prefix("TFTSR_RESULT:") {
|
|
// Restore original title
|
|
let restore_title = r#"
|
|
if (typeof window.__TFTSR_ORIGINAL_TITLE__ !== 'undefined') {
|
|
document.title = window.__TFTSR_ORIGINAL_TITLE__;
|
|
}
|
|
"#;
|
|
webview_window.eval(restore_title).ok();
|
|
|
|
// Parse the JSON
|
|
match serde_json::from_str::<serde_json::Value>(json_str) {
|
|
Ok(result) => {
|
|
if let Some(error) = result.get("error").and_then(|e| e.as_str()) {
|
|
return Err(format!("Cookie extraction error: {error}"));
|
|
}
|
|
|
|
if let Some(cookies_value) = result.get("cookies") {
|
|
match serde_json::from_value::<Vec<Cookie>>(cookies_value.clone()) {
|
|
Ok(cookies) => {
|
|
tracing::info!(
|
|
"Successfully extracted {} cookies",
|
|
cookies.len()
|
|
);
|
|
return Ok(cookies);
|
|
}
|
|
Err(e) => {
|
|
return Err(format!("Failed to parse cookies: {e}"));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Err(e) => {
|
|
tracing::warn!("Failed to parse result JSON: {e}");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if attempts >= max_attempts {
|
|
return Err(
|
|
"Timeout extracting cookies. This may be because:\n\
|
|
1. Confluence uses HttpOnly cookies that JavaScript cannot access\n\
|
|
2. You're not logged in yet\n\
|
|
3. The page hasn't finished loading\n\n\
|
|
Recommendation: Use 'Manual Token' authentication with a Confluence Personal Access Token instead."
|
|
.to_string(),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Build cookie header string for HTTP requests
|
|
pub fn cookies_to_header(cookies: &[Cookie]) -> String {
|
|
cookies
|
|
.iter()
|
|
.map(|c| {
|
|
format!(
|
|
"{name}={value}",
|
|
name = c.name.as_str(),
|
|
value = c.value.as_str()
|
|
)
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join("; ")
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_cookies_to_header() {
|
|
let cookies = vec![
|
|
Cookie {
|
|
name: "JSESSIONID".to_string(),
|
|
value: "abc123".to_string(),
|
|
domain: "example.com".to_string(),
|
|
path: "/".to_string(),
|
|
secure: true,
|
|
http_only: true,
|
|
expires: None,
|
|
},
|
|
Cookie {
|
|
name: "auth_token".to_string(),
|
|
value: "xyz789".to_string(),
|
|
domain: "example.com".to_string(),
|
|
path: "/".to_string(),
|
|
secure: true,
|
|
http_only: false,
|
|
expires: None,
|
|
},
|
|
];
|
|
|
|
let header = cookies_to_header(&cookies);
|
|
assert_eq!(header, "JSESSIONID=abc123; auth_token=xyz789");
|
|
}
|
|
|
|
#[test]
|
|
fn test_empty_cookies_to_header() {
|
|
let cookies = vec![];
|
|
let header = cookies_to_header(&cookies);
|
|
assert_eq!(header, "");
|
|
}
|
|
|
|
#[test]
|
|
fn test_cookie_json_serialization() {
|
|
let cookies = vec![Cookie {
|
|
name: "test".to_string(),
|
|
value: "value123".to_string(),
|
|
domain: "example.com".to_string(),
|
|
path: "/".to_string(),
|
|
secure: true,
|
|
http_only: false,
|
|
expires: None,
|
|
}];
|
|
|
|
let json = serde_json::to_string(&cookies).unwrap();
|
|
assert!(json.contains("\"name\":\"test\""));
|
|
assert!(json.contains("\"value\":\"value123\""));
|
|
|
|
let deserialized: Vec<Cookie> = serde_json::from_str(&json).unwrap();
|
|
assert_eq!(deserialized.len(), 1);
|
|
assert_eq!(deserialized[0].name, "test");
|
|
}
|
|
}
|