From 87b4e0ae1af106e7382f10e171902b32b28c25af Mon Sep 17 00:00:00 2001 From: Jay Gowdy Date: Fri, 17 Apr 2026 02:53:12 -0700 Subject: [PATCH] serve: serialize credential_process with per-profile flock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two AWS CLI invocations firing `credential_process = awsenc serve` in parallel on the same profile, with the cache in Refresh or Expired state, would each independently run the transparent-reauth chain (Okta-session decrypt → SAML → STS AssumeRoleWithSAML) or fall back to printing stale cached creds. Both writers then raced the cache update. Duplicate STS traffic, possible Okta rate-limit hit, and the losing writer's fresh creds silently overwritten. Take an exclusive advisory lock on `.lock` before reading the cache, hold it across all state branches (Fresh / Refresh / Expired), and release on drop. A second caller blocks at lock_exclusive, waits for the first to finish, then re-reads the now-refreshed cache and prints its credentials — single STS call, single prompt chain, consistent session_start. Uses fs4 for cross-platform flock / LockFileEx. The lock file is a zero-byte sidecar that stays on disk; crash recovery is the OS's job (inode lock released on handle close). --- awsenc-cli/Cargo.toml | 1 + awsenc-cli/src/serve.rs | 61 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/awsenc-cli/Cargo.toml b/awsenc-cli/Cargo.toml index ab6f7c1..4d29b9e 100644 --- a/awsenc-cli/Cargo.toml +++ b/awsenc-cli/Cargo.toml @@ -29,6 +29,7 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] } zeroize = { version = "1", features = ["derive"] } rpassword = "7" regex = "1" +fs4 = "0.9" [build-dependencies] enclaveapp-build-support = { workspace = true } diff --git a/awsenc-cli/src/serve.rs b/awsenc-cli/src/serve.rs index e7bec2b..6a47e97 100644 --- a/awsenc-cli/src/serve.rs +++ b/awsenc-cli/src/serve.rs @@ -8,6 +8,9 @@ use awsenc_core::credential::{AwsCredentials, CredentialProcessOutput, Credentia use awsenc_core::okta::{OktaClient, OktaSession}; use awsenc_core::sts::{self, StsClient}; use enclaveapp_app_storage::EncryptionStorage; +use fs4::fs_std::FileExt; +use std::fs::{self, OpenOptions}; +use std::path::{Path, PathBuf}; use crate::cli::ServeArgs; use crate::usage; @@ -18,10 +21,25 @@ type Result = std::result::Result>; /// /// Outputs JSON to stdout. Never prompts for input. Never prints anything to /// stdout except the credential JSON. +/// +/// Serialized per-profile with an exclusive advisory file lock +/// (`.lock`). When two AWS CLI invocations fire +/// `credential_process = awsenc serve` concurrently on the same profile +/// and the cache is in Refresh or Expired state, without the lock both +/// paths hit STS (and possibly the Okta transparent-reauth chain) — +/// wasting STS quota, risking Okta rate limits, and causing one +/// writer's fresh cache to be silently overwritten. With the lock, the +/// second caller blocks until the first finishes, then re-reads the +/// already-refreshed cache and returns. #[allow(clippy::print_stderr)] pub async fn run_serve(args: &ServeArgs, storage: &dyn EncryptionStorage) -> Result<()> { let profile = resolve_serve_profile(args)?; + let lock_path = cache::cache_path(&profile) + .map(serve_lock_path_for_cache) + .unwrap_or_else(|_| PathBuf::from(format!("/tmp/awsenc-{profile}.lock"))); + let _guard = ServeLock::acquire(&lock_path)?; + let Some(cache) = cache::read_cache(&profile)? else { eprintln!("No cached credentials for profile '{profile}'"); eprintln!("Run: awsenc auth --profile {profile}"); @@ -197,6 +215,49 @@ async fn try_transparent_reauth( Ok(creds) } +/// Derive the per-profile serve lock path from its cache path. +fn serve_lock_path_for_cache(cache_path: PathBuf) -> PathBuf { + let mut path = cache_path; + let mut name = path + .file_name() + .map(|n| n.to_os_string()) + .unwrap_or_default(); + name.push(".lock"); + path.set_file_name(name); + path +} + +/// Exclusive advisory lock held for the duration of a single +/// `awsenc serve` invocation so that concurrent `credential_process` +/// calls from parallel AWS CLI commands don't duplicate STS / Okta +/// traffic. Released on drop. +struct ServeLock { + file: fs::File, +} + +impl ServeLock { + fn acquire(path: &Path) -> Result { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + let file = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(path)?; + FileExt::lock_exclusive(&file) + .map_err(|e| format!("acquiring serve lock on {}: {e}", path.display()))?; + Ok(Self { file }) + } +} + +impl Drop for ServeLock { + fn drop(&mut self) { + drop(FileExt::unlock(&self.file)); + } +} + #[cfg(test)] #[allow(clippy::unwrap_used)] mod tests {