diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 03e3ab82..dfcbae76 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -118,6 +118,16 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[package]] +name = "assert-json-diff" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "async-trait" version = "0.1.89" @@ -516,6 +526,15 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" +[[package]] +name = "colored" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "combine" version = "4.6.7" @@ -720,6 +739,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", + "rand_core 0.6.4", "typenum", ] @@ -2025,6 +2045,12 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + [[package]] name = "hyper" version = "1.8.1" @@ -2039,6 +2065,7 @@ dependencies = [ "http", "http-body", "httparse", + "httpdate", "itoa", "pin-project-lite", "pin-utils", @@ -2824,6 +2851,31 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "mockito" +version = "1.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90820618712cab19cfc46b274c6c22546a82affcb3c3bdf0f29e3db8e1bb92c0" +dependencies = [ + "assert-json-diff", + "bytes", + "colored", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "log", + "pin-project-lite", + "rand 0.9.2", + "regex", + "serde_json", + "serde_urlencoded", + "similar", + "tokio", +] + [[package]] name = "moxcms" version = "0.8.1" @@ -4705,6 +4757,12 @@ version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" +[[package]] +name = "similar" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" + [[package]] name = "siphasher" version = "0.3.11" @@ -5440,6 +5498,7 @@ dependencies = [ name = "tftsr" version = "0.1.0" dependencies = [ + "aes-gcm", "aho-corasick", "anyhow", "async-trait", @@ -5449,7 +5508,9 @@ dependencies = [ "docx-rs", "futures", "hex", + "mockito", "printpdf", + "rand 0.8.5", "regex", "reqwest 0.12.28", "rusqlite", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index cc23649e..03d6a6e9 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -38,9 +38,12 @@ futures = "0.3" async-trait = "0.1" base64 = "0.22" dirs = "5" +aes-gcm = "0.10" +rand = "0.8" [dev-dependencies] tokio-test = "0.4" +mockito = "1.2" [profile.release] opt-level = "s" diff --git a/src-tauri/src/integrations/auth.rs b/src-tauri/src/integrations/auth.rs index d74bc2fd..fc9d1dba 100644 --- a/src-tauri/src/integrations/auth.rs +++ b/src-tauri/src/integrations/auth.rs @@ -1,3 +1,4 @@ +use rusqlite::OptionalExtension; use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Serialize, Deserialize)] @@ -64,38 +65,100 @@ pub fn build_auth_url( ) } -/// Exchange an authorization code for tokens. Placeholder for v0.2. +/// Exchange an authorization code for tokens using PKCE. pub async fn exchange_code( - _token_endpoint: &str, - _client_id: &str, - _code: &str, - _redirect_uri: &str, - _code_verifier: &str, + token_endpoint: &str, + client_id: &str, + code: &str, + redirect_uri: &str, + code_verifier: &str, ) -> Result { - Err("OAuth token exchange available in v0.2".to_string()) + let client = reqwest::Client::new(); + + let params = [ + ("grant_type", "authorization_code"), + ("code", code), + ("redirect_uri", redirect_uri), + ("client_id", client_id), + ("code_verifier", code_verifier), + ]; + + let resp = client + .post(token_endpoint) + .form(¶ms) + .send() + .await + .map_err(|e| format!("Failed to send token exchange request: {}", e))?; + + if !resp.status().is_success() { + return Err(format!( + "Token exchange failed with status {}: {}", + resp.status(), + resp.text().await.unwrap_or_default() + )); + } + + let body: serde_json::Value = resp + .json() + .await + .map_err(|e| format!("Failed to parse token response: {}", e))?; + + let access_token = body["access_token"] + .as_str() + .ok_or_else(|| "No access_token in response".to_string())? + .to_string(); + + let refresh_token = body["refresh_token"].as_str().map(|s| s.to_string()); + + let expires_in = body["expires_in"].as_i64().unwrap_or(3600); + let expires_at = chrono::Utc::now().timestamp() + expires_in; + + let token_type = body["token_type"].as_str().unwrap_or("Bearer").to_string(); + + Ok(OAuthToken { + access_token, + refresh_token, + expires_at, + token_type, + }) } -/// Store a PAT credential securely. Placeholder - in production, use OS keychain. +/// Store a PAT credential securely with AES-256-GCM encryption. pub fn store_pat(conn: &rusqlite::Connection, credential: &PatCredential) -> Result<(), String> { let id = uuid::Uuid::now_v7().to_string(); - let now = chrono::Utc::now().timestamp_millis(); + let token_hash = hash_token(&credential.token); + let encrypted_token = encrypt_token(&credential.token)?; + let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string(); + conn.execute( - "INSERT OR REPLACE INTO credentials (id, service, token_hash, created_at) VALUES (?1, ?2, ?3, ?4)", - rusqlite::params![id, credential.service, hash_token(&credential.token), now], + "INSERT OR REPLACE INTO credentials (id, service, token_hash, encrypted_token, created_at) + VALUES (?1, ?2, ?3, ?4, ?5)", + rusqlite::params![id, credential.service, token_hash, encrypted_token, now], ) .map_err(|e| e.to_string())?; Ok(()) } -/// Retrieve a stored PAT. In production, retrieve from OS keychain. +/// Retrieve and decrypt a stored PAT. pub fn get_pat(conn: &rusqlite::Connection, service: &str) -> Result, String> { let mut stmt = conn - .prepare("SELECT token_hash FROM credentials WHERE service = ?1 ORDER BY created_at DESC LIMIT 1") + .prepare( + "SELECT encrypted_token FROM credentials WHERE service = ?1 ORDER BY created_at DESC LIMIT 1", + ) .map_err(|e| e.to_string())?; - let result = stmt + + let encrypted = stmt .query_row([service], |row| row.get::<_, String>(0)) - .ok(); - Ok(result) + .optional() + .map_err(|e| e.to_string())?; + + match encrypted { + Some(enc) => { + let decrypted = decrypt_token(&enc)?; + Ok(Some(decrypted)) + } + None => Ok(None), + } } fn hash_token(token: &str) -> String { @@ -116,6 +179,88 @@ fn urlencoding_encode(s: &str) -> String { .replace('+', "%2B") } +/// Encrypt a token using AES-256-GCM. +/// Key is derived from TFTSR_ENCRYPTION_KEY env var or a default dev key. +/// Returns base64-encoded ciphertext with nonce prepended. +pub fn encrypt_token(token: &str) -> Result { + use aes_gcm::{ + aead::{Aead, KeyInit}, + Aes256Gcm, Nonce, + }; + use rand::{thread_rng, RngCore}; + + // Get encryption key from env or use default (WARNING: insecure for production) + let key_material = std::env::var("TFTSR_ENCRYPTION_KEY") + .unwrap_or_else(|_| "dev-key-change-me-in-production-32b".to_string()); + + let mut key_bytes = [0u8; 32]; + let src = key_material.as_bytes(); + let len = std::cmp::min(src.len(), 32); + key_bytes[..len].copy_from_slice(&src[..len]); + + let cipher = Aes256Gcm::new(&key_bytes.into()); + + // Generate random nonce + let mut nonce_bytes = [0u8; 12]; + thread_rng().fill_bytes(&mut nonce_bytes); + let nonce = Nonce::from_slice(&nonce_bytes); + + // Encrypt + let ciphertext = cipher + .encrypt(nonce, token.as_bytes()) + .map_err(|e| format!("Encryption failed: {}", e))?; + + // Prepend nonce to ciphertext + let mut result = nonce_bytes.to_vec(); + result.extend_from_slice(&ciphertext); + + // Base64 encode + use base64::engine::general_purpose::STANDARD; + use base64::Engine; + Ok(STANDARD.encode(&result)) +} + +/// Decrypt a token that was encrypted with encrypt_token(). +pub fn decrypt_token(encrypted: &str) -> Result { + use aes_gcm::{ + aead::{Aead, KeyInit}, + Aes256Gcm, Nonce, + }; + + // Base64 decode + use base64::engine::general_purpose::STANDARD; + use base64::Engine; + let data = STANDARD + .decode(encrypted) + .map_err(|e| format!("Base64 decode failed: {}", e))?; + + if data.len() < 12 { + return Err("Invalid encrypted data: too short".to_string()); + } + + // Extract nonce (first 12 bytes) and ciphertext (rest) + let nonce = Nonce::from_slice(&data[..12]); + let ciphertext = &data[12..]; + + // Get encryption key + let key_material = std::env::var("TFTSR_ENCRYPTION_KEY") + .unwrap_or_else(|_| "dev-key-change-me-in-production-32b".to_string()); + + let mut key_bytes = [0u8; 32]; + let src = key_material.as_bytes(); + let len = std::cmp::min(src.len(), 32); + key_bytes[..len].copy_from_slice(&src[..len]); + + let cipher = Aes256Gcm::new(&key_bytes.into()); + + // Decrypt + let plaintext = cipher + .decrypt(nonce, ciphertext) + .map_err(|e| format!("Decryption failed: {}", e))?; + + String::from_utf8(plaintext).map_err(|e| format!("Invalid UTF-8: {}", e)) +} + #[cfg(test)] mod tests { use super::*; @@ -200,4 +345,222 @@ mod tests { assert!(!encoded.contains('+')); assert!(!encoded.contains('/')); } + + #[tokio::test] + async fn test_exchange_code_success() { + let mut server = mockito::Server::new_async().await; + let mock = server + .mock("POST", "/oauth/token") + .match_header("content-type", "application/x-www-form-urlencoded") + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + r#"{ + "access_token": "test_access_token_123", + "refresh_token": "test_refresh_token_456", + "expires_in": 3600, + "token_type": "Bearer" + }"#, + ) + .create_async() + .await; + + let token_endpoint = format!("{}/oauth/token", server.url()); + let result = exchange_code( + &token_endpoint, + "test-client-id", + "auth_code_xyz", + "http://localhost:8765/callback", + "code_verifier_abc", + ) + .await; + + mock.assert_async().await; + assert!(result.is_ok()); + + let token = result.unwrap(); + assert_eq!(token.access_token, "test_access_token_123"); + assert_eq!( + token.refresh_token, + Some("test_refresh_token_456".to_string()) + ); + assert_eq!(token.token_type, "Bearer"); + } + + #[tokio::test] + async fn test_exchange_code_missing_access_token() { + let mut server = mockito::Server::new_async().await; + let _mock = server + .mock("POST", "/oauth/token") + .with_status(200) + .with_body(r#"{"expires_in": 3600}"#) + .create_async() + .await; + + let token_endpoint = format!("{}/oauth/token", server.url()); + let result = exchange_code( + &token_endpoint, + "test-client-id", + "code", + "http://localhost:8765/callback", + "verifier", + ) + .await; + + assert!(result.is_err()); + assert!(result.unwrap_err().contains("access_token")); + } + + #[tokio::test] + async fn test_exchange_code_http_error() { + let mut server = mockito::Server::new_async().await; + let _mock = server + .mock("POST", "/oauth/token") + .with_status(401) + .with_body(r#"{"error": "invalid_grant"}"#) + .create_async() + .await; + + let token_endpoint = format!("{}/oauth/token", server.url()); + let result = exchange_code( + &token_endpoint, + "test-client-id", + "invalid_code", + "http://localhost:8765/callback", + "verifier", + ) + .await; + + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(err.contains("401") || err.contains("Unauthorized") || err.contains("failed")); + } + + #[tokio::test] + async fn test_exchange_code_network_error() { + // Use an unreachable endpoint + let result = exchange_code( + "http://localhost:9999/token", + "client", + "code", + "http://localhost/callback", + "verifier", + ) + .await; + + assert!(result.is_err()); + } + + #[test] + fn test_encrypt_decrypt_roundtrip() { + let original = "my-secret-token-12345"; + let encrypted = encrypt_token(original).unwrap(); + let decrypted = decrypt_token(&encrypted).unwrap(); + assert_eq!(original, decrypted); + } + + #[test] + fn test_encrypt_produces_different_output_each_time() { + // Ensure env var is not set from other tests + std::env::remove_var("TFTSR_ENCRYPTION_KEY"); + + let token = "same-token"; + let enc1 = encrypt_token(token).unwrap(); + let enc2 = encrypt_token(token).unwrap(); + // Different nonces mean different ciphertext + assert_ne!(enc1, enc2); + // But both decrypt to the same value + assert_eq!(decrypt_token(&enc1).unwrap(), token); + assert_eq!(decrypt_token(&enc2).unwrap(), token); + } + + #[test] + fn test_decrypt_invalid_data_fails() { + let result = decrypt_token("invalid-base64-!!!!"); + assert!(result.is_err()); + } + + #[test] + fn test_decrypt_too_short_fails() { + use base64::engine::general_purpose::STANDARD; + use base64::Engine; + let short_data = STANDARD.encode(b"short"); + let result = decrypt_token(&short_data); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("too short")); + } + + #[test] + fn test_decrypt_wrong_key_fails() { + // Encrypt with one key + std::env::set_var( + "TFTSR_ENCRYPTION_KEY", + "key-1-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + ); + let encrypted = encrypt_token("secret").unwrap(); + + // Try to decrypt with different key + std::env::set_var( + "TFTSR_ENCRYPTION_KEY", + "key-2-yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy", + ); + let result = decrypt_token(&encrypted); + + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Decryption failed")); + + // Reset env var + std::env::remove_var("TFTSR_ENCRYPTION_KEY"); + } + + #[test] + fn test_store_and_retrieve_pat() { + // Set up test DB + let conn = rusqlite::Connection::open_in_memory().unwrap(); + crate::db::migrations::run_migrations(&conn).unwrap(); + + // Store credential + let credential = PatCredential { + service: "confluence".to_string(), + token: "my-secret-pat-token-12345".to_string(), + }; + store_pat(&conn, &credential).unwrap(); + + // Retrieve and verify + let retrieved = get_pat(&conn, "confluence").unwrap(); + assert_eq!(retrieved, Some("my-secret-pat-token-12345".to_string())); + } + + #[test] + fn test_get_pat_nonexistent_service() { + let conn = rusqlite::Connection::open_in_memory().unwrap(); + crate::db::migrations::run_migrations(&conn).unwrap(); + + let result = get_pat(&conn, "nonexistent").unwrap(); + assert_eq!(result, None); + } + + #[test] + fn test_store_pat_replaces_existing() { + let conn = rusqlite::Connection::open_in_memory().unwrap(); + crate::db::migrations::run_migrations(&conn).unwrap(); + + // Store first token + let cred1 = PatCredential { + service: "servicenow".to_string(), + token: "token-v1".to_string(), + }; + store_pat(&conn, &cred1).unwrap(); + + // Store second token for same service + let cred2 = PatCredential { + service: "servicenow".to_string(), + token: "token-v2".to_string(), + }; + store_pat(&conn, &cred2).unwrap(); + + // Should retrieve the most recent token + let retrieved = get_pat(&conn, "servicenow").unwrap(); + assert_eq!(retrieved, Some("token-v2".to_string())); + } }