Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 20 additions & 18 deletions src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

16 changes: 11 additions & 5 deletions src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ serde = { version = "1.0", features = ["derive"] }
log = "0.4"
tauri = { version = "2.10.2", features = ["protocol-asset", "devtools"] }
tauri-plugin-log = "2"
sqlx = { version = "0.8.6", features = ["runtime-tokio", "sqlite", "mysql", "postgres", "tls-native-tls", "chrono", "uuid", "rust_decimal", "json"] }
sqlx = { version = "0.8.6", features = ["runtime-tokio", "sqlite", "mysql", "postgres", "tls-rustls", "chrono", "uuid", "rust_decimal", "json"] }
tokio = { version = "1.49.0", features = ["full"] }
openssl = { version = "0.10", features = ["vendored"] }
uuid = { version = "1.20.0", features = ["v4", "serde"] }
Expand Down Expand Up @@ -59,10 +59,16 @@ zip = "4.2.0"
tauri-plugin-clipboard-manager = "2"
tokio-postgres = { version = "0.7.13", features = ["with-chrono-0_4", "with-uuid-1", "with-serde_json-1", "array-impls"] }
deadpool-postgres = "0.14.1"
# rustls is used for the PostgreSQL deadpool TLS path. native-tls (used by
# sqlx and the MySQL pool) trips macOS Secure Transport's strict EKU checks
# on user-supplied root anchors (e.g. the AWS RDS bundle), so the Postgres
# pool routes through rustls + the platform verifier instead.
# sqlx uses `tls-rustls` for both the MySQL pool and the SQLite/embedded
# paths. `tls-native-tls` is intentionally NOT enabled: macOS Secure
# Transport (the native-tls backend on Darwin) trips strict EKU checks on
# user-supplied root anchors (e.g. the AWS RDS regional CA bundle), so
# MySQL connections that go through native-tls fail the TLS handshake
# with the opaque error "One or more parameters passed to a function
# were not valid." even though the same bundle validates fine with
# `openssl s_client` and `mysql --ssl-mode=VERIFY_IDENTITY`. rustls has
# no such restriction. The PostgreSQL deadpool path uses rustls + the
# platform verifier for the same reason.
tokio-postgres-rustls = "0.13"
rustls = { version = "0.23", default-features = false, features = ["ring", "logging", "std", "tls12"] }
rustls-pemfile = "2"
Expand Down
69 changes: 60 additions & 9 deletions src-tauri/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -410,10 +410,13 @@ pub fn find_connection_by_id<R: Runtime>(
}
};

// Load passwords from keychain if needed, via the in-memory cache.
// On a warm cache hit this is a HashMap lookup (nanoseconds); on a cold miss
// it calls keychain once and caches the result for all subsequent reads.
if conn.params.save_in_keychain.unwrap_or(false) {
// Load passwords from keychain via the in-memory cache (warm hit = lookup,
// cold miss = keychain call + cache). Skip IAM-auth connections: their
// 15-min tokens must come from the `password` field, never the keychain,
// so a stale token from an older release can't be surfaced in the modal.
if conn.params.save_in_keychain.unwrap_or(false)
&& !conn.params.use_iam_auth.unwrap_or(false)
{
let cache = app.state::<std::sync::Arc<crate::credential_cache::CredentialCache>>();
match credential_cache::get_db_password_cached(&cache, &conn.id) {
Ok(pwd) => conn.params.password = Some(pwd),
Expand Down Expand Up @@ -846,8 +849,11 @@ pub async fn duplicate_connection<R: Runtime>(

let cache = app.state::<std::sync::Arc<crate::credential_cache::CredentialCache>>();

// Recover passwords if in keychain (via cache for fast repeat access)
if original.params.save_in_keychain.unwrap_or(false) {
// Same IAM-auth guard as `find_connection_by_id`: never copy a stale RDS
// auth token into a duplicated connection.
if original.params.save_in_keychain.unwrap_or(false)
&& !original.params.use_iam_auth.unwrap_or(false)
{
if let Ok(pwd) = credential_cache::get_db_password_cached(&cache, &original.id) {
original.params.password = Some(pwd);
}
Expand Down Expand Up @@ -1724,7 +1730,29 @@ pub async fn test_connection<R: Runtime>(
let mut expanded_params = expand_ssh_connection_params(&app, &request.params).await?;
expanded_params = expand_k8s_connection_params(&app, &expanded_params).await?;

if request.params.password.is_none() && expanded_params.password.is_none() {
// AWS RDS IAM auth tokens are short-lived (15 min) and must come from the
// password field on every test/connect, never from the keychain. Skip the
// keychain fallback so a stale token can't be reused.
let iam_auth = expanded_params.use_iam_auth.unwrap_or(false);

// IAM auth needs an RDS auth token right now. Without this guard the
// builder accepts an empty password (saved connections inject later),
// the server replies with the opaque "Access denied (using password:
// YES)", and the user can't tell whether the token is missing, wrong,
// or expired.
if iam_auth
&& request.params.password.as_deref().unwrap_or("").is_empty()
&& expanded_params.password.as_deref().unwrap_or("").is_empty()
{
return Err(
"AWS IAM authentication is enabled but the password field is empty. \
Paste the output of `aws rds generate-db-auth-token` into the \
password field and try again. Tokens expire every 15 minutes."
.to_string(),
);
}

if !iam_auth && request.params.password.is_none() && expanded_params.password.is_none() {
let saved_conn = match &request.connection_id {
Some(id) => find_connection_by_id(&app, id).ok(),
None => None,
Expand Down Expand Up @@ -1759,7 +1787,13 @@ pub async fn test_connection<R: Runtime>(
}
}

drv.test_connection(&resolved_params).await?;
drv.test_connection(&resolved_params).await.map_err(|e| {
log::warn!(
"Connection test failed for database {}: {e}",
request.params.database
);
e
})?;

log::info!(
"Connection test successful for database: {}",
Expand Down Expand Up @@ -2594,7 +2628,24 @@ pub async fn list_databases<R: Runtime>(
let mut expanded_params = expand_ssh_connection_params(&app, &request.params).await?;
expanded_params = expand_k8s_connection_params(&app, &expanded_params).await?;

if request.params.password.is_none() && expanded_params.password.is_none() {
let iam_auth = expanded_params.use_iam_auth.unwrap_or(false);

// IAM auth needs an RDS auth token right now; skip the keychain fallback
// so a stale token can't be reused, and fail fast with an actionable
// message if none was supplied.
if iam_auth
&& request.params.password.as_deref().unwrap_or("").is_empty()
&& expanded_params.password.as_deref().unwrap_or("").is_empty()
{
return Err(
"AWS IAM authentication is enabled but the password field is empty. \
Paste the output of `aws rds generate-db-auth-token` into the \
password field and try again. Tokens expire every 15 minutes."
.to_string(),
);
}

if !iam_auth && request.params.password.is_none() && expanded_params.password.is_none() {
let saved_conn = match &request.connection_id {
Some(id) => find_connection_by_id(&app, id).ok(),
None => None,
Expand Down
13 changes: 11 additions & 2 deletions src-tauri/src/mcp/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -240,8 +240,11 @@ async fn resolve_db_params(
) -> Result<(crate::models::SavedConnection, ConnectionParams), JsonRpcError> {
let mut conn = find_connection(conn_id)?;

// Load DB password from keychain if it isn't stored inline
if conn.params.save_in_keychain.unwrap_or(false) {
// Load DB password from keychain unless the connection uses IAM auth,
// whose 15-min tokens must come from the `password` field on every
// connect — never from the keychain, where a stale token would survive.
let iam_auth = conn.params.use_iam_auth.unwrap_or(false);
if !iam_auth && conn.params.save_in_keychain.unwrap_or(false) {
let cache = std::sync::Arc::new(credential_cache::CredentialCache::default());
let id = conn.id.clone();
let pwd = tokio::task::spawn_blocking(move || {
Expand All @@ -259,6 +262,12 @@ async fn resolve_db_params(
conn.params.password = Some(p);
}
}
} else if iam_auth && conn.params.save_in_keychain.unwrap_or(false) {
log::warn!(
"MCP: connection {} has use_iam_auth=true; ignoring any password stored in the keychain. A fresh RDS auth token must be supplied on every connect.",
conn_id
);
conn.params.password = None;
}

let expanded = expand_ssh_params_for_mcp(&conn.params).await?;
Expand Down
5 changes: 5 additions & 0 deletions src-tauri/src/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,11 @@ pub struct ConnectionParams {
// Set to `false` for servers that reject altering sql_mode, e.g. Vitess/PlanetScale.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pipes_as_concat: Option<bool>,
// When true, `password` is a pre-signed RDS auth token (from
// `aws rds generate-db-auth-token`) instead of a real password.
// Requires TLS; only meaningful for the `mysql` driver.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub use_iam_auth: Option<bool>,
// SSH Tunnel
pub ssh_enabled: Option<bool>,
pub ssh_connection_id: Option<String>,
Expand Down
1 change: 1 addition & 0 deletions src-tauri/src/plugins/driver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -842,6 +842,7 @@ mod tests {
k8s_resource_name: None,
k8s_port: None,
startup_script: None,
use_iam_auth: None,
connection_id: Some("conn-1".to_string()),
}
}
Expand Down
Loading