tftsr-devops_investigation/src-tauri/src/integrations/webview_auth.rs

337 lines
12 KiB
Rust
Raw Normal View History

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);
fix: persist integration settings and implement persistent browser windows ## Integration Settings Persistence - Add database commands to save/load integration configs (base_url, username, project_name, space_key) - Frontend now loads configs from DB on mount and saves changes automatically - Fixes issue where settings were lost on app restart ## Persistent Browser Window Architecture - Integration browser windows now stay open for user browsing and authentication - Extract fresh cookies before each API call to handle token rotation - Track open windows in app state (integration_webviews HashMap) - Windows titled as "{Service} Browser (TFTSR)" for clarity - Support easy navigation between app and browser windows (Cmd+Tab/Alt+Tab) - Gracefully handle closed windows with automatic cleanup ## Bug Fixes - Fix Rust formatting issues across 8 files - Fix clippy warnings: - Use is_some_and() instead of map_or() in openai.rs - Use .to_string() instead of format!() in integrations.rs - Add missing OptionalExtension import for .optional() method ## Tests - Add test_integration_config_serialization - Add test_webview_tracking - Add test_token_auth_request_serialization - All 6 integration tests passing ## Files Modified - src-tauri/src/state.rs: Add integration_webviews tracking - src-tauri/src/lib.rs: Register 3 new commands, initialize webviews HashMap - src-tauri/src/commands/integrations.rs: Config persistence, fresh cookie extraction (+151 lines) - src-tauri/src/integrations/webview_auth.rs: Persistent window behavior - src/lib/tauriCommands.ts: TypeScript wrappers for new commands - src/pages/Settings/Integrations.tsx: Load/save configs from DB Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-04-04 14:57:22 +00:00
// 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);
fix: persist integration settings and implement persistent browser windows ## Integration Settings Persistence - Add database commands to save/load integration configs (base_url, username, project_name, space_key) - Frontend now loads configs from DB on mount and saves changes automatically - Fixes issue where settings were lost on app restart ## Persistent Browser Window Architecture - Integration browser windows now stay open for user browsing and authentication - Extract fresh cookies before each API call to handle token rotation - Track open windows in app state (integration_webviews HashMap) - Windows titled as "{Service} Browser (TFTSR)" for clarity - Support easy navigation between app and browser windows (Cmd+Tab/Alt+Tab) - Gracefully handle closed windows with automatic cleanup ## Bug Fixes - Fix Rust formatting issues across 8 files - Fix clippy warnings: - Use is_some_and() instead of map_or() in openai.rs - Use .to_string() instead of format!() in integrations.rs - Add missing OptionalExtension import for .optional() method ## Tests - Add test_integration_config_serialization - Add test_webview_tracking - Add test_token_auth_request_serialization - All 6 integration tests passing ## Files Modified - src-tauri/src/state.rs: Add integration_webviews tracking - src-tauri/src/lib.rs: Register 3 new commands, initialize webviews HashMap - src-tauri/src/commands/integrations.rs: Config persistence, fresh cookie extraction (+151 lines) - src-tauri/src/integrations/webview_auth.rs: Persistent window behavior - src/lib/tauriCommands.ts: TypeScript wrappers for new commands - src/pages/Settings/Integrations.tsx: Load/save configs from DB Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-04-04 14:57:22 +00:00
let webview = WebviewWindowBuilder::new(
&app_handle,
&webview_label,
WebviewUrl::External(parsed_url),
)
.title(format!(
"{service} Browser (Troubleshooting and RCA Assistant)"
))
fix: persist integration settings and implement persistent browser windows ## Integration Settings Persistence - Add database commands to save/load integration configs (base_url, username, project_name, space_key) - Frontend now loads configs from DB on mount and saves changes automatically - Fixes issue where settings were lost on app restart ## Persistent Browser Window Architecture - Integration browser windows now stay open for user browsing and authentication - Extract fresh cookies before each API call to handle token rotation - Track open windows in app state (integration_webviews HashMap) - Windows titled as "{Service} Browser (TFTSR)" for clarity - Support easy navigation between app and browser windows (Cmd+Tab/Alt+Tab) - Gracefully handle closed windows with automatic cleanup ## Bug Fixes - Fix Rust formatting issues across 8 files - Fix clippy warnings: - Use is_some_and() instead of map_or() in openai.rs - Use .to_string() instead of format!() in integrations.rs - Add missing OptionalExtension import for .optional() method ## Tests - Add test_integration_config_serialization - Add test_webview_tracking - Add test_token_auth_request_serialization - All 6 integration tests passing ## Files Modified - src-tauri/src/state.rs: Add integration_webviews tracking - src-tauri/src/lib.rs: Register 3 new commands, initialize webviews HashMap - src-tauri/src/commands/integrations.rs: Config persistence, fresh cookie extraction (+151 lines) - src-tauri/src/integrations/webview_auth.rs: Persistent window behavior - src/lib/tauriCommands.ts: TypeScript wrappers for new commands - src/pages/Settings/Integrations.tsx: Load/save configs from DB Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-04-04 14:57:22 +00:00
.inner_size(1000.0, 800.0)
.min_inner_size(800.0, 600.0)
.resizable(true)
.center()
fix: persist integration settings and implement persistent browser windows ## Integration Settings Persistence - Add database commands to save/load integration configs (base_url, username, project_name, space_key) - Frontend now loads configs from DB on mount and saves changes automatically - Fixes issue where settings were lost on app restart ## Persistent Browser Window Architecture - Integration browser windows now stay open for user browsing and authentication - Extract fresh cookies before each API call to handle token rotation - Track open windows in app state (integration_webviews HashMap) - Windows titled as "{Service} Browser (TFTSR)" for clarity - Support easy navigation between app and browser windows (Cmd+Tab/Alt+Tab) - Gracefully handle closed windows with automatic cleanup ## Bug Fixes - Fix Rust formatting issues across 8 files - Fix clippy warnings: - Use is_some_and() instead of map_or() in openai.rs - Use .to_string() instead of format!() in integrations.rs - Add missing OptionalExtension import for .optional() method ## Tests - Add test_integration_config_serialization - Add test_webview_tracking - Add test_token_auth_request_serialization - All 6 integration tests passing ## Files Modified - src-tauri/src/state.rs: Add integration_webviews tracking - src-tauri/src/lib.rs: Register 3 new commands, initialize webviews HashMap - src-tauri/src/commands/integrations.rs: Config persistence, fresh cookie extraction (+151 lines) - src-tauri/src/integrations/webview_auth.rs: Persistent window behavior - src/lib/tauriCommands.ts: TypeScript wrappers for new commands - src/pages/Settings/Integrations.tsx: Load/save configs from DB Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-04-04 14:57:22 +00:00
.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
fix: persist integration settings and implement persistent browser windows ## Integration Settings Persistence - Add database commands to save/load integration configs (base_url, username, project_name, space_key) - Frontend now loads configs from DB on mount and saves changes automatically - Fixes issue where settings were lost on app restart ## Persistent Browser Window Architecture - Integration browser windows now stay open for user browsing and authentication - Extract fresh cookies before each API call to handle token rotation - Track open windows in app state (integration_webviews HashMap) - Windows titled as "{Service} Browser (TFTSR)" for clarity - Support easy navigation between app and browser windows (Cmd+Tab/Alt+Tab) - Gracefully handle closed windows with automatic cleanup ## Bug Fixes - Fix Rust formatting issues across 8 files - Fix clippy warnings: - Use is_some_and() instead of map_or() in openai.rs - Use .to_string() instead of format!() in integrations.rs - Add missing OptionalExtension import for .optional() method ## Tests - Add test_integration_config_serialization - Add test_webview_tracking - Add test_token_auth_request_serialization - All 6 integration tests passing ## Files Modified - src-tauri/src/state.rs: Add integration_webviews tracking - src-tauri/src/lib.rs: Register 3 new commands, initialize webviews HashMap - src-tauri/src/commands/integrations.rs: Config persistence, fresh cookie extraction (+151 lines) - src-tauri/src/integrations/webview_auth.rs: Persistent window behavior - src/lib/tauriCommands.ts: TypeScript wrappers for new commands - src/pages/Settings/Integrations.tsx: Load/save configs from DB Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-04-04 14:57:22 +00:00
webview
.set_focus()
.map_err(|e| tracing::warn!("Failed to set focus: {}", e))
fix: persist integration settings and implement persistent browser windows ## Integration Settings Persistence - Add database commands to save/load integration configs (base_url, username, project_name, space_key) - Frontend now loads configs from DB on mount and saves changes automatically - Fixes issue where settings were lost on app restart ## Persistent Browser Window Architecture - Integration browser windows now stay open for user browsing and authentication - Extract fresh cookies before each API call to handle token rotation - Track open windows in app state (integration_webviews HashMap) - Windows titled as "{Service} Browser (TFTSR)" for clarity - Support easy navigation between app and browser windows (Cmd+Tab/Alt+Tab) - Gracefully handle closed windows with automatic cleanup ## Bug Fixes - Fix Rust formatting issues across 8 files - Fix clippy warnings: - Use is_some_and() instead of map_or() in openai.rs - Use .to_string() instead of format!() in integrations.rs - Add missing OptionalExtension import for .optional() method ## Tests - Add test_integration_config_serialization - Add test_webview_tracking - Add test_token_auth_request_serialization - All 6 integration tests passing ## Files Modified - src-tauri/src/state.rs: Add integration_webviews tracking - src-tauri/src/lib.rs: Register 3 new commands, initialize webviews HashMap - src-tauri/src/commands/integrations.rs: Config persistence, fresh cookie extraction (+151 lines) - src-tauri/src/integrations/webview_auth.rs: Persistent window behavior - src/lib/tauriCommands.ts: TypeScript wrappers for new commands - src/pages/Settings/Integrations.tsx: Load/save configs from DB Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-04-04 14:57:22 +00:00
.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");
}
}