Compare commits
No commits in common. "75302a1cc7760b6d5b6f414b9c332803e069f90b" and "fd244781e1ee49371d6a69ace99a1b769c332276" have entirely different histories.
75302a1cc7
...
fd244781e1
62
src-tauri/Cargo.lock
generated
62
src-tauri/Cargo.lock
generated
@ -118,16 +118,6 @@ version = "0.7.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
|
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]]
|
[[package]]
|
||||||
name = "async-trait"
|
name = "async-trait"
|
||||||
version = "0.1.89"
|
version = "0.1.89"
|
||||||
@ -526,15 +516,6 @@ version = "1.1.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
|
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]]
|
[[package]]
|
||||||
name = "combine"
|
name = "combine"
|
||||||
version = "4.6.7"
|
version = "4.6.7"
|
||||||
@ -739,7 +720,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"generic-array",
|
"generic-array",
|
||||||
"rand_core 0.6.4",
|
|
||||||
"typenum",
|
"typenum",
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -2045,12 +2025,6 @@ version = "1.10.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
|
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "httpdate"
|
|
||||||
version = "1.0.3"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hyper"
|
name = "hyper"
|
||||||
version = "1.8.1"
|
version = "1.8.1"
|
||||||
@ -2065,7 +2039,6 @@ dependencies = [
|
|||||||
"http",
|
"http",
|
||||||
"http-body",
|
"http-body",
|
||||||
"httparse",
|
"httparse",
|
||||||
"httpdate",
|
|
||||||
"itoa",
|
"itoa",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"pin-utils",
|
"pin-utils",
|
||||||
@ -2851,31 +2824,6 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"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]]
|
[[package]]
|
||||||
name = "moxcms"
|
name = "moxcms"
|
||||||
version = "0.8.1"
|
version = "0.8.1"
|
||||||
@ -4757,12 +4705,6 @@ version = "0.3.8"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2"
|
checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "similar"
|
|
||||||
version = "2.7.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "siphasher"
|
name = "siphasher"
|
||||||
version = "0.3.11"
|
version = "0.3.11"
|
||||||
@ -5498,7 +5440,6 @@ dependencies = [
|
|||||||
name = "tftsr"
|
name = "tftsr"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes-gcm",
|
|
||||||
"aho-corasick",
|
"aho-corasick",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
@ -5508,10 +5449,7 @@ dependencies = [
|
|||||||
"docx-rs",
|
"docx-rs",
|
||||||
"futures",
|
"futures",
|
||||||
"hex",
|
"hex",
|
||||||
"lazy_static",
|
|
||||||
"mockito",
|
|
||||||
"printpdf",
|
"printpdf",
|
||||||
"rand 0.8.5",
|
|
||||||
"regex",
|
"regex",
|
||||||
"reqwest 0.12.28",
|
"reqwest 0.12.28",
|
||||||
"rusqlite",
|
"rusqlite",
|
||||||
|
|||||||
@ -38,13 +38,9 @@ futures = "0.3"
|
|||||||
async-trait = "0.1"
|
async-trait = "0.1"
|
||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
dirs = "5"
|
dirs = "5"
|
||||||
aes-gcm = "0.10"
|
|
||||||
rand = "0.8"
|
|
||||||
lazy_static = "1.4"
|
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tokio-test = "0.4"
|
tokio-test = "0.4"
|
||||||
mockito = "1.2"
|
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
opt-level = "s"
|
opt-level = "s"
|
||||||
|
|||||||
@ -1,13 +1,4 @@
|
|||||||
use crate::integrations::{ConnectionResult, PublishResult, TicketResult};
|
use crate::integrations::{ConnectionResult, PublishResult, TicketResult};
|
||||||
use crate::state::AppState;
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::sync::{Arc, Mutex};
|
|
||||||
use tauri::State;
|
|
||||||
|
|
||||||
// Global OAuth state storage (verifier per state key)
|
|
||||||
lazy_static::lazy_static! {
|
|
||||||
static ref OAUTH_STATE: Arc<Mutex<HashMap<String, String>>> = Arc::new(Mutex::new(HashMap::new()));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn test_confluence_connection(
|
pub async fn test_confluence_connection(
|
||||||
@ -58,249 +49,3 @@ pub async fn create_azuredevops_workitem(
|
|||||||
) -> Result<TicketResult, String> {
|
) -> Result<TicketResult, String> {
|
||||||
Err("Integrations available in v0.2. Please update to the latest version.".to_string())
|
Err("Integrations available in v0.2. Please update to the latest version.".to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── OAuth2 Commands ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
#[derive(Debug, serde::Serialize, serde::Deserialize)]
|
|
||||||
pub struct OAuthInitResponse {
|
|
||||||
pub auth_url: String,
|
|
||||||
pub state: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Initiate OAuth2 authorization flow for a service.
|
|
||||||
/// Returns the authorization URL and a state key.
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn initiate_oauth(
|
|
||||||
service: String,
|
|
||||||
_state: State<'_, AppState>,
|
|
||||||
) -> Result<OAuthInitResponse, String> {
|
|
||||||
// Generate PKCE challenge
|
|
||||||
let pkce = crate::integrations::auth::generate_pkce();
|
|
||||||
|
|
||||||
// Generate state key for this OAuth session
|
|
||||||
let state_key = uuid::Uuid::now_v7().to_string();
|
|
||||||
|
|
||||||
// Store verifier temporarily
|
|
||||||
{
|
|
||||||
let mut oauth_state = OAUTH_STATE
|
|
||||||
.lock()
|
|
||||||
.map_err(|e| format!("Failed to lock OAuth state: {}", e))?;
|
|
||||||
oauth_state.insert(state_key.clone(), pkce.code_verifier.clone());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build authorization URL based on service
|
|
||||||
let (auth_endpoint, client_id, scope, redirect_uri) = match service.as_str() {
|
|
||||||
"confluence" => (
|
|
||||||
"https://auth.atlassian.com/authorize",
|
|
||||||
std::env::var("CONFLUENCE_CLIENT_ID")
|
|
||||||
.unwrap_or_else(|_| "confluence-client-id-placeholder".to_string()),
|
|
||||||
"read:confluence-space.summary read:confluence-content.summary write:confluence-content",
|
|
||||||
"http://localhost:8765/callback",
|
|
||||||
),
|
|
||||||
"azuredevops" => (
|
|
||||||
"https://login.microsoftonline.com/common/oauth2/v2.0/authorize",
|
|
||||||
std::env::var("ADO_CLIENT_ID")
|
|
||||||
.unwrap_or_else(|_| "ado-client-id-placeholder".to_string()),
|
|
||||||
"vso.work vso.work_write",
|
|
||||||
"http://localhost:8765/callback",
|
|
||||||
),
|
|
||||||
"servicenow" => {
|
|
||||||
// ServiceNow uses basic auth, not OAuth2
|
|
||||||
return Err("ServiceNow uses basic authentication, not OAuth2".to_string());
|
|
||||||
}
|
|
||||||
_ => return Err(format!("Unknown service: {}", service)),
|
|
||||||
};
|
|
||||||
|
|
||||||
let auth_url = crate::integrations::auth::build_auth_url(
|
|
||||||
auth_endpoint,
|
|
||||||
&client_id,
|
|
||||||
redirect_uri,
|
|
||||||
scope,
|
|
||||||
&pkce,
|
|
||||||
);
|
|
||||||
|
|
||||||
Ok(OAuthInitResponse {
|
|
||||||
auth_url,
|
|
||||||
state: state_key,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Handle OAuth2 callback after user authorization.
|
|
||||||
/// Exchanges authorization code for access token and stores it.
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn handle_oauth_callback(
|
|
||||||
service: String,
|
|
||||||
code: String,
|
|
||||||
state_key: String,
|
|
||||||
app_state: State<'_, AppState>,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
// Retrieve verifier from temporary state
|
|
||||||
let verifier = {
|
|
||||||
let mut oauth_state = OAUTH_STATE
|
|
||||||
.lock()
|
|
||||||
.map_err(|e| format!("Failed to lock OAuth state: {}", e))?;
|
|
||||||
oauth_state
|
|
||||||
.remove(&state_key)
|
|
||||||
.ok_or_else(|| "Invalid or expired OAuth state".to_string())?
|
|
||||||
};
|
|
||||||
|
|
||||||
// Get token endpoint and client_id based on service
|
|
||||||
let (token_endpoint, client_id, redirect_uri) = match service.as_str() {
|
|
||||||
"confluence" => (
|
|
||||||
"https://auth.atlassian.com/oauth/token",
|
|
||||||
std::env::var("CONFLUENCE_CLIENT_ID")
|
|
||||||
.unwrap_or_else(|_| "confluence-client-id-placeholder".to_string()),
|
|
||||||
"http://localhost:8765/callback",
|
|
||||||
),
|
|
||||||
"azuredevops" => (
|
|
||||||
"https://login.microsoftonline.com/common/oauth2/v2.0/token",
|
|
||||||
std::env::var("ADO_CLIENT_ID")
|
|
||||||
.unwrap_or_else(|_| "ado-client-id-placeholder".to_string()),
|
|
||||||
"http://localhost:8765/callback",
|
|
||||||
),
|
|
||||||
_ => return Err(format!("Unknown service: {}", service)),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Exchange authorization code for access token
|
|
||||||
let oauth_token = crate::integrations::auth::exchange_code(
|
|
||||||
token_endpoint,
|
|
||||||
&client_id,
|
|
||||||
&code,
|
|
||||||
redirect_uri,
|
|
||||||
&verifier,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
// Store token in database with encryption
|
|
||||||
let token_hash = {
|
|
||||||
use sha2::{Digest, Sha256};
|
|
||||||
let mut hasher = Sha256::new();
|
|
||||||
hasher.update(oauth_token.access_token.as_bytes());
|
|
||||||
format!("{:x}", hasher.finalize())
|
|
||||||
};
|
|
||||||
|
|
||||||
let encrypted_token = crate::integrations::auth::encrypt_token(&oauth_token.access_token)?;
|
|
||||||
|
|
||||||
let expires_at = Some(
|
|
||||||
chrono::DateTime::from_timestamp(oauth_token.expires_at, 0)
|
|
||||||
.ok_or_else(|| "Invalid expires_at timestamp".to_string())?
|
|
||||||
.format("%Y-%m-%d %H:%M:%S")
|
|
||||||
.to_string(),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Insert into credentials table
|
|
||||||
let db = app_state
|
|
||||||
.db
|
|
||||||
.lock()
|
|
||||||
.map_err(|e| format!("Failed to lock database: {}", e))?;
|
|
||||||
|
|
||||||
db.execute(
|
|
||||||
"INSERT OR REPLACE INTO credentials (id, service, token_hash, encrypted_token, created_at, expires_at)
|
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
|
||||||
rusqlite::params![
|
|
||||||
uuid::Uuid::now_v7().to_string(),
|
|
||||||
service,
|
|
||||||
token_hash,
|
|
||||||
encrypted_token,
|
|
||||||
chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string(),
|
|
||||||
expires_at,
|
|
||||||
],
|
|
||||||
)
|
|
||||||
.map_err(|e| format!("Failed to store credentials: {}", e))?;
|
|
||||||
|
|
||||||
// Log audit event
|
|
||||||
let audit_details = serde_json::json!({
|
|
||||||
"service": service,
|
|
||||||
"token_hash": token_hash,
|
|
||||||
"expires_at": expires_at,
|
|
||||||
});
|
|
||||||
|
|
||||||
db.execute(
|
|
||||||
"INSERT INTO audit_log (id, timestamp, action, entity_type, entity_id, user_id, details)
|
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
|
|
||||||
rusqlite::params![
|
|
||||||
uuid::Uuid::now_v7().to_string(),
|
|
||||||
chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string(),
|
|
||||||
"oauth_callback_success",
|
|
||||||
"credential",
|
|
||||||
service,
|
|
||||||
"local",
|
|
||||||
audit_details.to_string(),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
.map_err(|e| format!("Failed to log audit event: {}", e))?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_oauth_state_storage() {
|
|
||||||
let key = "test-key".to_string();
|
|
||||||
let verifier = "test-verifier".to_string();
|
|
||||||
|
|
||||||
// Store
|
|
||||||
{
|
|
||||||
let mut state = OAUTH_STATE.lock().unwrap();
|
|
||||||
state.insert(key.clone(), verifier.clone());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Retrieve
|
|
||||||
{
|
|
||||||
let state = OAUTH_STATE.lock().unwrap();
|
|
||||||
assert_eq!(state.get(&key), Some(&verifier));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove
|
|
||||||
{
|
|
||||||
let mut state = OAUTH_STATE.lock().unwrap();
|
|
||||||
state.remove(&key);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify removed
|
|
||||||
{
|
|
||||||
let state = OAUTH_STATE.lock().unwrap();
|
|
||||||
assert!(!state.contains_key(&key));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_oauth_state_multiple_keys() {
|
|
||||||
let key1 = "key1".to_string();
|
|
||||||
let key2 = "key2".to_string();
|
|
||||||
|
|
||||||
{
|
|
||||||
let mut state = OAUTH_STATE.lock().unwrap();
|
|
||||||
state.insert(key1.clone(), "verifier1".to_string());
|
|
||||||
state.insert(key2.clone(), "verifier2".to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
|
||||||
let mut state = OAUTH_STATE.lock().unwrap();
|
|
||||||
state.remove(&key1);
|
|
||||||
}
|
|
||||||
|
|
||||||
let state = OAUTH_STATE.lock().unwrap();
|
|
||||||
assert!(!state.contains_key(&key1));
|
|
||||||
assert!(state.contains_key(&key2));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_oauth_init_response_serialization() {
|
|
||||||
let response = OAuthInitResponse {
|
|
||||||
auth_url: "https://example.com/auth".to_string(),
|
|
||||||
state: "state-123".to_string(),
|
|
||||||
};
|
|
||||||
|
|
||||||
let json = serde_json::to_string(&response).unwrap();
|
|
||||||
assert!(json.contains("https://example.com/auth"));
|
|
||||||
assert!(json.contains("state-123"));
|
|
||||||
|
|
||||||
let deserialized: OAuthInitResponse = serde_json::from_str(&json).unwrap();
|
|
||||||
assert_eq!(deserialized.auth_url, response.auth_url);
|
|
||||||
assert_eq!(deserialized.state, response.state);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
use rusqlite::OptionalExtension;
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@ -65,100 +64,38 @@ pub fn build_auth_url(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Exchange an authorization code for tokens using PKCE.
|
/// Exchange an authorization code for tokens. Placeholder for v0.2.
|
||||||
pub async fn exchange_code(
|
pub async fn exchange_code(
|
||||||
token_endpoint: &str,
|
_token_endpoint: &str,
|
||||||
client_id: &str,
|
_client_id: &str,
|
||||||
code: &str,
|
_code: &str,
|
||||||
redirect_uri: &str,
|
_redirect_uri: &str,
|
||||||
code_verifier: &str,
|
_code_verifier: &str,
|
||||||
) -> Result<OAuthToken, String> {
|
) -> Result<OAuthToken, String> {
|
||||||
let client = reqwest::Client::new();
|
Err("OAuth token exchange available in v0.2".to_string())
|
||||||
|
|
||||||
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 with AES-256-GCM encryption.
|
/// Store a PAT credential securely. Placeholder - in production, use OS keychain.
|
||||||
pub fn store_pat(conn: &rusqlite::Connection, credential: &PatCredential) -> Result<(), String> {
|
pub fn store_pat(conn: &rusqlite::Connection, credential: &PatCredential) -> Result<(), String> {
|
||||||
let id = uuid::Uuid::now_v7().to_string();
|
let id = uuid::Uuid::now_v7().to_string();
|
||||||
let token_hash = hash_token(&credential.token);
|
let now = chrono::Utc::now().timestamp_millis();
|
||||||
let encrypted_token = encrypt_token(&credential.token)?;
|
|
||||||
let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
|
|
||||||
|
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT OR REPLACE INTO credentials (id, service, token_hash, encrypted_token, created_at)
|
"INSERT OR REPLACE INTO credentials (id, service, token_hash, created_at) VALUES (?1, ?2, ?3, ?4)",
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5)",
|
rusqlite::params![id, credential.service, hash_token(&credential.token), now],
|
||||||
rusqlite::params![id, credential.service, token_hash, encrypted_token, now],
|
|
||||||
)
|
)
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Retrieve and decrypt a stored PAT.
|
/// Retrieve a stored PAT. In production, retrieve from OS keychain.
|
||||||
pub fn get_pat(conn: &rusqlite::Connection, service: &str) -> Result<Option<String>, String> {
|
pub fn get_pat(conn: &rusqlite::Connection, service: &str) -> Result<Option<String>, String> {
|
||||||
let mut stmt = conn
|
let mut stmt = conn
|
||||||
.prepare(
|
.prepare("SELECT token_hash FROM credentials WHERE service = ?1 ORDER BY created_at DESC LIMIT 1")
|
||||||
"SELECT encrypted_token FROM credentials WHERE service = ?1 ORDER BY created_at DESC LIMIT 1",
|
|
||||||
)
|
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
|
let result = stmt
|
||||||
let encrypted = stmt
|
|
||||||
.query_row([service], |row| row.get::<_, String>(0))
|
.query_row([service], |row| row.get::<_, String>(0))
|
||||||
.optional()
|
.ok();
|
||||||
.map_err(|e| e.to_string())?;
|
Ok(result)
|
||||||
|
|
||||||
match encrypted {
|
|
||||||
Some(enc) => {
|
|
||||||
let decrypted = decrypt_token(&enc)?;
|
|
||||||
Ok(Some(decrypted))
|
|
||||||
}
|
|
||||||
None => Ok(None),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn hash_token(token: &str) -> String {
|
fn hash_token(token: &str) -> String {
|
||||||
@ -179,88 +116,6 @@ fn urlencoding_encode(s: &str) -> String {
|
|||||||
.replace('+', "%2B")
|
.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<String, String> {
|
|
||||||
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<String, String> {
|
|
||||||
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@ -345,222 +200,4 @@ mod tests {
|
|||||||
assert!(!encoded.contains('+'));
|
assert!(!encoded.contains('+'));
|
||||||
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()));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -85,8 +85,6 @@ pub fn run() {
|
|||||||
commands::integrations::create_servicenow_incident,
|
commands::integrations::create_servicenow_incident,
|
||||||
commands::integrations::test_azuredevops_connection,
|
commands::integrations::test_azuredevops_connection,
|
||||||
commands::integrations::create_azuredevops_workitem,
|
commands::integrations::create_azuredevops_workitem,
|
||||||
commands::integrations::initiate_oauth,
|
|
||||||
commands::integrations::handle_oauth_callback,
|
|
||||||
// System / Settings
|
// System / Settings
|
||||||
commands::system::check_ollama_installed,
|
commands::system::check_ollama_installed,
|
||||||
commands::system::get_ollama_install_guide,
|
commands::system::get_ollama_install_guide,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user