Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
1b86c13
feat(lock): add cross-process lock infrastructure
timvisher-dd Mar 20, 2026
fa92e9f
feat: add parallel-safe mode for SSO and session caching
timvisher-dd Mar 20, 2026
191e526
feat(sso): add rate-limit retry with jittered backoff
timvisher-dd Mar 20, 2026
5abae24
docs: document parallel-safe mode in USAGE.md
timvisher-dd Mar 20, 2026
6e43120
fix(sso): address code review feedback on providers
timvisher-dd Apr 7, 2026
6bed045
chore(deps): bump gofrs/flock from v0.8.1 to v0.13.0
timvisher-dd Apr 7, 2026
89ddfa3
docs: explain why login is excluded from --parallel-safe
timvisher-dd Apr 7, 2026
7c8cb8d
feat(lock): derive keyring lock key from backend config
timvisher-dd Apr 7, 2026
090dc36
style: flip > and >= comparisons to < and <= per repo convention
timvisher-dd Apr 7, 2026
e1d98d1
refactor: deduplicate defaultXxxSleep into shared defaultContextSleep
timvisher-dd Apr 7, 2026
9f334b3
fix(sso): typo, lockLogger type, comparison flip, and sleep dedup
timvisher-dd Apr 7, 2026
c877087
refactor(lock): collapse three lock-type files into NewDefaultLock
timvisher-dd Apr 7, 2026
5e01e27
refactor(lock): replace newLockWaiter positional params with options …
timvisher-dd Apr 7, 2026
a9ed64b
fix(sso): widen jitter range from 1.1x-1.3x to 0.5x-1.5x
timvisher-dd Apr 7, 2026
642e797
fix(lock): add 2-minute timeout to session and SSO lock-wait loops
timvisher-dd Apr 7, 2026
9e7f8d1
fix: exclude login from --parallel-safe keyring wrapping
timvisher-dd Apr 7, 2026
6c1387a
refactor(lock): extract withProcessLock generic helper
timvisher-dd Apr 7, 2026
94abec6
test(sso): add retry loop tests for backoff, jitter, and timeout
timvisher-dd Apr 7, 2026
4e6d700
test(cli): add table-driven tests for keyringLockKey()
timvisher-dd Apr 7, 2026
d372514
refactor: move parallel-safe locking into provider constructors
timvisher-dd Apr 7, 2026
a3a50bf
test(lock): add lockedKeyring tests for lock-wait, timeout, and error…
timvisher-dd Apr 7, 2026
400265c
test: add parallel-safe tests for GetSessionToken provider path
timvisher-dd Apr 7, 2026
d0a2941
fix(test): simplify parallel-safe provider tests
timvisher-dd Apr 7, 2026
3727f5c
fix(sso): clamp jitteredBackoff overflow to max instead of base
timvisher-dd Apr 8, 2026
0e0793d
docs(lock): note that lockedKeyring.mu blocks without timeout
timvisher-dd Apr 8, 2026
6a334be
fix(cli): simplify --parallel-safe flag description
timvisher-dd Apr 8, 2026
c2d068b
fix(lock): separate lock-wait timeout from work context
timvisher-dd Apr 9, 2026
0933895
fix(sso): use context-aware sleep in OIDC device-flow polling
timvisher-dd Apr 9, 2026
eed6b21
fix(session): log non-trivial cache read errors
timvisher-dd Apr 9, 2026
952e2d9
fix(test): use rawKeyringImpl in CLI example tests
timvisher-dd Apr 9, 2026
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
30 changes: 30 additions & 0 deletions USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,7 @@ WARNING: Use of this option runs against security best practices. It is recommen
To configure the default flag values of `aws-vault` and its subcommands:
* `AWS_VAULT_BACKEND`: Secret backend to use (see the flag `--backend`)
* `AWS_VAULT_BIOMETRICS`: Use biometric authentication using TouchID, if supported (see the flag `--biometrics`)
* `AWS_VAULT_PARALLEL_SAFE`: Enable cross-process locking for keychain and cached credentials (see the flag `--parallel-safe`)
* `AWS_VAULT_KEYCHAIN_NAME`: Name of macOS keychain to use (see the flag `--keychain`)
* `AWS_VAULT_AUTO_LOGOUT`: Enable auto-logout when doing `login` (see the flag `--auto-logout`)
* `AWS_VAULT_PROMPT`: Prompt driver to use (see the flag `--prompt`)
Expand Down Expand Up @@ -634,6 +635,35 @@ role_arn=arn:aws:iam::123456789013:role/AnotherRole
source_profile=Administrator-123456789012]
```

## Parallel-safe mode

When running many `aws-vault` processes in parallel (e.g. Terraform with hundreds of `credential_process` invocations), concurrent access to the secret store and SSO browser flows can cause errors:

- **Browser storms**: Multiple processes each open a browser tab for the same SSO login, overwhelming AWS and triggering HTTP 500 errors.
- **Secret store races**: Concurrent writes to the same keyring entry cause "item already exists" errors or partial reads.

The `--parallel-safe` flag (or `AWS_VAULT_PARALLEL_SAFE=true`) enables cross-process locking to prevent these issues:

- **SSO token lock**: Only one process per SSO Start URL opens a browser tab; others wait for the cached token.
- **Session cache lock**: Only one process writes back to a given session cache entry at a time.
- **Keyring lock**: All keyring read/write operations are serialized across processes.

This applies to **all backends** (keychain, file, pass, secret-service, etc.).

### Trade-offs

- Keyring operations are serialized, which adds a small amount of latency per operation. In practice this is negligible because the operations themselves are fast.
- **All concurrent invocations must use `--parallel-safe`**. If some processes enable it and others don't, the unprotected processes ignore the locks entirely. This is undefined behavior and may still cause races. Set `AWS_VAULT_PARALLEL_SAFE=true` in your environment to ensure consistent use.

### The `login` command

The `login` command is intentionally excluded from `--parallel-safe`. Console login sessions are inherently single-use — you cannot meaningfully log in to multiple AWS consoles in parallel — so there is no concurrent-access problem for `--parallel-safe` to solve. The `exec`, `export`, and `rotate` commands all support `--parallel-safe`.

### Limitations

- The keyring lock wait loop cannot be cancelled by the caller because the `keyring.Keyring` interface is not context-aware. If a lock holder hangs (e.g. a stuck `gpg` subprocess in the `pass` backend), waiters will time out after 2 minutes rather than waiting indefinitely.
- SSO rate-limit retries (HTTP 429 on `GetRoleCredentials`) will retry for up to 5 minutes before giving up with an error.

## Assuming roles with web identities

AWS supports assuming roles using [web identity federation and OpenID Connect](https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-role.html#cli-configure-role-oidc), including login using Amazon, Google, Facebook or any other OpenID Connect server. The configuration options are as follows:
Expand Down
11 changes: 10 additions & 1 deletion cli/exec.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ type ExecCommandInput struct {
NoSession bool
UseStdout bool
ShowHelpMessages bool
ParallelSafe bool
}

func (input ExecCommandInput) validate() error {
Expand Down Expand Up @@ -121,6 +122,7 @@ func ConfigureExecCommand(app *kingpin.Application, a *AwsVault) {
StringsVar(&input.Args)

cmd.Action(func(c *kingpin.ParseContext) (err error) {
input.ParallelSafe = a.ParallelSafe
input.Config.MfaPromptMethod = a.PromptDriver(hasBackgroundServer(input))
input.Config.NonChainedGetSessionTokenDuration = input.SessionDuration
input.Config.AssumeRoleDuration = input.SessionDuration
Expand Down Expand Up @@ -155,6 +157,7 @@ func ConfigureExecCommand(app *kingpin.Application, a *AwsVault) {
Config: input.Config,
SessionDuration: input.SessionDuration,
NoSession: input.NoSession,
ParallelSafe: input.ParallelSafe,
}

err = ExportCommand(exportCommandInput, f, keyring)
Expand Down Expand Up @@ -185,7 +188,13 @@ func ExecCommand(input ExecCommandInput, f *vault.ConfigFile, keyring keyring.Ke
return 0, fmt.Errorf("Error loading config: %w", err)
}

credsProvider, err := vault.NewTempCredentialsProvider(config, &vault.CredentialKeyring{Keyring: keyring}, input.NoSession, false)
credsProvider, err := vault.NewTempCredentialsProviderWithOptions(
config,
&vault.CredentialKeyring{Keyring: keyring},
input.NoSession,
false,
vault.TempCredentialsOptions{ParallelSafe: input.ParallelSafe},
)
if err != nil {
return 0, fmt.Errorf("Error getting temporary credentials: %w", err)
}
Expand Down
2 changes: 1 addition & 1 deletion cli/exec_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import (
func ExampleExecCommand() {
app := kingpin.New("aws-vault", "")
awsVault := ConfigureGlobals(app)
awsVault.keyringImpl = keyring.NewArrayKeyring([]keyring.Item{
awsVault.rawKeyringImpl = keyring.NewArrayKeyring([]keyring.Item{
{Key: "llamas", Data: []byte(`{"AccessKeyID":"ABC","SecretAccessKey":"XYZ"}`)},
})
ConfigureExecCommand(app, awsVault)
Expand Down
10 changes: 9 additions & 1 deletion cli/export.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ type ExportCommandInput struct {
SessionDuration time.Duration
NoSession bool
UseStdout bool
ParallelSafe bool
}

var (
Expand Down Expand Up @@ -66,6 +67,7 @@ func ConfigureExportCommand(app *kingpin.Application, a *AwsVault) {
StringVar(&input.ProfileName)

cmd.Action(func(c *kingpin.ParseContext) (err error) {
input.ParallelSafe = a.ParallelSafe
input.Config.MfaPromptMethod = a.PromptDriver(false)
input.Config.NonChainedGetSessionTokenDuration = input.SessionDuration
input.Config.AssumeRoleDuration = input.SessionDuration
Expand Down Expand Up @@ -108,7 +110,13 @@ func ExportCommand(input ExportCommandInput, f *vault.ConfigFile, keyring keyrin
}

ckr := &vault.CredentialKeyring{Keyring: keyring}
credsProvider, err := vault.NewTempCredentialsProvider(config, ckr, input.NoSession, false)
credsProvider, err := vault.NewTempCredentialsProviderWithOptions(
config,
ckr,
input.NoSession,
false,
vault.TempCredentialsOptions{ParallelSafe: input.ParallelSafe},
)
if err != nil {
return fmt.Errorf("Error getting temporary credentials: %w", err)
}
Expand Down
2 changes: 1 addition & 1 deletion cli/export_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import (
func ExampleExportCommand() {
app := kingpin.New("aws-vault", "")
awsVault := ConfigureGlobals(app)
awsVault.keyringImpl = keyring.NewArrayKeyring([]keyring.Item{
awsVault.rawKeyringImpl = keyring.NewArrayKeyring([]keyring.Item{
{Key: "llamas", Data: []byte(`{"AccessKeyID":"ABC","SecretAccessKey":"XYZ"}`)},
})
ConfigureExportCommand(app, awsVault)
Expand Down
83 changes: 78 additions & 5 deletions cli/global.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,11 @@ type AwsVault struct {
KeyringConfig keyring.Config
KeyringBackend string
promptDriver string
ParallelSafe bool

keyringImpl keyring.Keyring
awsConfigFile *vault.ConfigFile
rawKeyringImpl keyring.Keyring
keyringImpl keyring.Keyring
awsConfigFile *vault.ConfigFile
UseBiometrics bool
}

Expand Down Expand Up @@ -68,18 +70,85 @@ func (a *AwsVault) PromptDriver(avoidTerminalPrompt bool) string {
}

func (a *AwsVault) Keyring() (keyring.Keyring, error) {
if a.keyringImpl == nil {
raw, err := a.rawKeyring()
if err != nil {
return nil, err
}
if a.ParallelSafe {
if a.keyringImpl == nil {
lockKey := a.keyringLockKey()
a.keyringImpl = vault.NewLockedKeyring(raw, lockKey)
}
return a.keyringImpl, nil
}
return raw, nil
}

// RawKeyring returns the keyring without the parallel-safe lock wrapper.
// Used by commands like login that are excluded from --parallel-safe.
func (a *AwsVault) RawKeyring() (keyring.Keyring, error) {
return a.rawKeyring()
}

func (a *AwsVault) rawKeyring() (keyring.Keyring, error) {
if a.rawKeyringImpl == nil {
if a.KeyringBackend != "" {
a.KeyringConfig.AllowedBackends = []keyring.BackendType{keyring.BackendType(a.KeyringBackend)}
}
var err error
a.keyringImpl, err = keyring.Open(a.KeyringConfig)
a.rawKeyringImpl, err = keyring.Open(a.KeyringConfig)
if err != nil {
return nil, err
}
}
return a.rawKeyringImpl, nil
}

return a.keyringImpl, nil
// keyringLockKey returns a backend-specific key for the cross-process keyring
// lock. Different backends (and different configurations of the same backend)
// produce different keys so they don't contend on the same lock file.
func (a *AwsVault) keyringLockKey() string {
backend := a.KeyringBackend
switch keyring.BackendType(backend) {
case keyring.KeychainBackend:
if a.KeyringConfig.KeychainName != "" {
return backend + ":" + a.KeyringConfig.KeychainName
}
case keyring.FileBackend:
if a.KeyringConfig.FileDir != "" {
return backend + ":" + a.KeyringConfig.FileDir
}
case keyring.PassBackend:
key := backend
if a.KeyringConfig.PassDir != "" {
key += ":" + a.KeyringConfig.PassDir
}
if a.KeyringConfig.PassPrefix != "" {
key += ":" + a.KeyringConfig.PassPrefix
}
return key
case keyring.SecretServiceBackend:
if a.KeyringConfig.LibSecretCollectionName != "" {
return backend + ":" + a.KeyringConfig.LibSecretCollectionName
}
case keyring.KWalletBackend:
if a.KeyringConfig.KWalletFolder != "" {
return backend + ":" + a.KeyringConfig.KWalletFolder
}
case keyring.WinCredBackend:
if a.KeyringConfig.WinCredPrefix != "" {
return backend + ":" + a.KeyringConfig.WinCredPrefix
}
case keyring.OPBackend, keyring.OPConnectBackend, keyring.OPDesktopBackend:
if a.KeyringConfig.OPVaultID != "" {
return backend + ":" + a.KeyringConfig.OPVaultID
}
}
// Fall back to backend name, which is always set (defaults to first available).
if backend != "" {
return backend
}
return "aws-vault"
}

func (a *AwsVault) AwsConfigFile() (*vault.ConfigFile, error) {
Expand Down Expand Up @@ -201,6 +270,10 @@ func ConfigureGlobals(app *kingpin.Application) *AwsVault {
Envar("AWS_VAULT_BIOMETRICS").
BoolVar(&a.UseBiometrics)

app.Flag("parallel-safe", "Enable cross-process locking for keyring operations, session caching, and SSO browser flows").
Envar("AWS_VAULT_PARALLEL_SAFE").
BoolVar(&a.ParallelSafe)

app.PreAction(func(c *kingpin.ParseContext) error {
if !a.Debug {
log.SetOutput(io.Discard)
Expand Down
Loading
Loading