feat: implement OAuth2 token exchange and AES-256-GCM encryption

Phase 2.2: OAuth2 flow - Part 1 (Token exchange + encryption)

Implemented:
- OAuth2 authorization code exchange with PKCE
  * Real HTTP POST to token endpoint
  * Parses access_token, refresh_token, expires_in, token_type
  * Calculates expires_at timestamp

- AES-256-GCM token encryption
  * Uses TFTSR_ENCRYPTION_KEY env var (or dev default)
  * Random nonce per encryption (12 bytes)
  * Base64-encoded output with nonce prepended
  * Proper key derivation (32 bytes)

- Updated credential storage
  * store_pat() now encrypts tokens before DB storage
  * get_pat() decrypts tokens on retrieval
  * Stores both token_hash (audit) and encrypted_token (actual)

Dependencies added:
- mockito 1.7.2 (dev) - HTTP mocking for tests
- aes-gcm 0.10 - AES-256-GCM encryption
- rand 0.8 - Cryptographically secure RNG

TDD tests (20 passing with --test-threads=1):
- OAuth exchange: success, missing token, HTTP error, network error
- Encryption: roundtrip, different nonces, invalid data, wrong key
- PAT storage: store/retrieve, nonexistent service, replacement

Note: Tests require single-threaded execution due to env var
test isolation. This is acceptable for CI/CD.
This commit is contained in:
Shaun Arman 2026-04-03 14:32:17 -05:00
parent fd244781e1
commit 01474fb5f2
3 changed files with 443 additions and 16 deletions

61
src-tauri/Cargo.lock generated
View File

@ -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",

View File

@ -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"

View File

@ -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<OAuthToken, String> {
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(&params)
.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<Option<String>, 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<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)]
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()));
}
}