Skip to content

Support MFA-protected AWS profiles via mfaToken on @initAws - #1044

Open
theoephraim wants to merge 6 commits into
mainfrom
aws-mfa-profile-support
Open

Support MFA-protected AWS profiles via mfaToken on @initAws#1044
theoephraim wants to merge 6 commits into
mainfrom
aws-mfa-profile-support

Conversation

@theoephraim

@theoephraim theoephraim commented Aug 27, 2026

Copy link
Copy Markdown
Member

Adds an mfaToken param to @initAws so profiles that require multi-factor authentication (mfa_serial in ~/.aws/config) work through the aws-secrets plugin. Addresses the underlying need in #1037: the SDK credential chain already handles role_arn profile assumption, so with MFA wired up there is no need to shell out to awsume.

# @initAws(region=us-east-1, profile=prod, mfaToken=generateOtp($MFA_SECRET))

Key behaviors:

  • The mfaToken resolver is resolved lazily, only when the SDK actually asks for a code. AWS MFA codes are single-use, so loads served from cache never burn one.
  • The resulting STS session creds are cached via the plugin cache (encrypted at rest) until they expire, so repeated loads reuse the session instead of calling STS each time.
  • In a long-running process (dev server, watch mode) the session is refreshed as it nears expiry. Re-assumes are spaced by one TOTP window, because the SDK asks its credential provider several times per request in that state and each assume burns a single-use code. An already-expired session bypasses that cooldown, since reusing it would fail anyway.
  • Parallel loads in separate processes (turborepo, several dev servers) coordinate through the cache's getOrSet, which holds a cross-process lock: one process assumes the role and the rest reuse the session it stored. Without this they would each assume with the same spent TOTP code. Needs the disk cache (the default when the OS keychain / Secure Enclave is available); documented for @cache=memory and --skip-cache.
  • MFA failures get a targeted error message with a tip pointing at mfaToken + generateOtp().

This also adds a small piece of core cache API that the feature needed. getOrSet took its TTL up front, but for anything whose lifetime the source decides (an STS session, an OAuth token, a lease) that is only known once the producer has run, which forced a guess-then-correct dance with a window where the entry outlived what it described. The ttl argument now also accepts a callback given the produced value; returning zero or less skips the write. All existing callers pass a literal and are unaffected. This should be the reusable answer for any plugin caching a credential whose lifetime it does not choose.

Also fixes a core plugin caching bug this uncovered: plugins install during finishInit, before the @cache root decorator policy is applied in finishLoad, so plugin cache accessors stayed bound to the loader's initial store. @cache=memory / @cache=disabled were silently ignored by plugin-level caching (all plugins with cacheTtl, not just this one). The accessor now reads the store lazily and the final store is re-propagated after the policy step.

Tests run the full flow end-to-end against fake local STS / Secrets Manager endpoints (via AWS_ENDPOINT_URL_*), covering the AssumeRole call contents, lazy resolution, session cred caching and expiry, mid-process refresh with the clock advanced, and both failure modes.

Closes #1037

The AWS SDK credential chain already handles role_arn profile assumption;
the missing piece was the MFA code callback. mfaToken is kept as an
unresolved resolver and only resolved when the SDK actually asks for a
code, and the resulting STS session creds are cached until expiry since
MFA codes are single-use.

Also fixes plugin cache accessors to respect the cache mode set via the
@cache root decorator: plugins install before the cache policy is applied
in finishLoad, so their accessors were bound to the loader's initial store
(or none). The accessor now reads the store lazily and the final store is
re-propagated to plugins.
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 27, 2026

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Preview URL Updated (UTC)
✅ Deployment successful!
View logs
varlock-website 58c606c Commit Preview URL

Branch Preview URL
Aug 28 2026, 06:53 PM

@github-actions

github-actions Bot commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

bumpy-frog

The changes in this PR will be included in the next version bump.

minor Minor releases

  • @varlock/aws-secrets-plugin 1.2.3 → 1.3.0
  • @varlock/native-helper-darwin 1.17.1 → 1.18.0
  • @varlock/native-helper-linux-arm64 1.17.1 → 1.18.0
  • @varlock/native-helper-linux-x64 1.17.1 → 1.18.0
  • @varlock/native-helper-win32-x64 1.17.1 → 1.18.0
  • varlock 1.17.1 → 1.18.0

Bump files in this PR

Click here if you want to add another bump file to this PR


This comment is maintained by bumpy.

@github-actions

github-actions Bot commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

📦 Bundle size

⚠️ grows the bundle by 7.1 KB (+0.2%)

Metric main This PR Δ
Total dist 4103.8 KB 4110.8 KB +7.1 KB (+0.2%)
JS 1589.2 KB 1590.7 KB +1.6 KB (+0.1%)
Sourcemaps 2429.5 KB 2433.7 KB +4.2 KB (+0.2%)
Type defs 85.1 KB 86.5 KB +1.3 KB (+1.6%)
Other 0.0 KB 0.0 KB

dist/ only; native binaries are versioned separately and not counted here.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Important

MFA session credentials cannot refresh after the first successful lookup, so long-lived processes will eventually use an expired STS session.

Reviewed changes covering the AWS MFA profile flow and the shared plugin cache-store rebinding.

  • MFA profile support: Adds lazy mfaToken resolution through the AWS SDK credential chain, targeted authentication errors, and expiring STS credential persistence.
  • Integration coverage: Adds local STS and Secrets Manager fixtures covering role assumption, OTP generation, cross-load cache reuse, near-expiry rejection, and failures.
  • Plugin cache lifecycle: Makes plugin cache accessors follow the graph's final cache store after @cache policy resolution.
  • Documentation and release metadata: Documents MFA profile setup and records package bumps for the plugin feature and core cache fix.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using azure/gpt-5.6-sol𝕏

Comment thread packages/plugins/aws-secrets/src/plugin.ts Outdated
@pkg-pr-new

pkg-pr-new Bot commented Aug 27, 2026

Copy link
Copy Markdown

Open in StackBlitz

varlock

npm i https://pkg.pr.new/varlock@1044

@varlock/native-helper-darwin

npm i https://pkg.pr.new/@varlock/native-helper-darwin@1044

@varlock/native-helper-linux-arm64

npm i https://pkg.pr.new/@varlock/native-helper-linux-arm64@1044

@varlock/native-helper-linux-x64

npm i https://pkg.pr.new/@varlock/native-helper-linux-x64@1044

@varlock/native-helper-win32-x64

npm i https://pkg.pr.new/@varlock/native-helper-win32-x64@1044

@varlock/aws-secrets-plugin

npm i https://pkg.pr.new/@varlock/aws-secrets-plugin@1044

commit: c8b4add

The SDK re-invokes its credential provider once creds are inside its
refresh threshold, but getMfaSessionCredentials memoized its promise
forever, so a dev server or watch-mode process would be handed the same
session past its expiry.

The memoized session is now dropped once it nears expiry. Since the SDK
asks several times per request in that state and each assume burns a
single-use MFA code, re-assumes are spaced by one TOTP window - an
already-expired session bypasses that cooldown, since reusing it is
guaranteed to fail anyway. The expiry buffer now matches the SDK's own
5 minute threshold so both sides stop trusting a session at once.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Important

A failed near-expiry refresh can still bypass the new cooldown and immediately replay a potentially spent MFA code.

Reviewed changes since the prior Pullfrog review at 327ba96, focusing on the new long-lived session refresh behavior.

  • Added expiration-aware refresh: Invalidated in-memory MFA session credentials at the AWS SDK's five-minute refresh threshold and coalesced replacement lookups.
  • Added re-assumption cooldown: Reused still-valid credentials briefly after a recent role assumption to avoid bursts of single-use TOTP requests.
  • Added same-instance coverage: Advanced the clock between sequential secret resolutions and verified a fresh STS assumption with a new TOTP.
  • Updated documentation: Documented refresh behavior for long-running processes.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using azure/gpt-5.6-sol𝕏

}

// stamped before the attempt, since a failed assume may still have spent the code
this.lastAssumeAt = Date.now();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Recording the attempt here does not enforce the cooldown when a refresh rejects: the old session was already cleared, and the catch clears the promise, so the next provider invocation bypasses the mfaSessionCreds-guarded cooldown and can replay the same TOTP. Preserve enough failure state to delay a new assumption until the next window, and cover a failed first refresh followed by another provider invocation.

Technical details
# Enforce cooldown after failed refreshes

## Affected sites
- `packages/plugins/aws-secrets/src/plugin.ts:252` - cooldown logic only runs when `mfaSessionCreds` exists.
- `packages/plugins/aws-secrets/src/plugin.ts:262` - near-expiry credentials are cleared before the replacement attempt settles.
- `packages/plugins/aws-secrets/src/plugin.ts:270` - rejection clears the promise without retaining cooldown state that the next call checks.
- `packages/plugins/aws-secrets/test/mfa.test.ts:235` - the refresh test only exercises successful STS responses.

## Required outcome
- A failed role-assumption attempt that may have consumed its TOTP must not allow another assumption with the same code window.
- A subsequent attempt after the cooldown must remain possible, and already-expired credentials must not be served while waiting.

## Suggested approach
- Gate new fetches directly on `lastAssumeAt`, including when no in-memory session remains, and represent the wait or retry result explicitly rather than returning expired credentials.
- Add a fake STS failure for the first refresh and assert that immediate SDK reinvocation does not issue a second STS request.

The session fetch used a plain cache get/set pair, which has no lock, so
parallel varlock runs in separate processes (turborepo, several dev
servers) would each assume the role with the same TOTP code. AWS rejects
a code that has already been spent, so all but one would fail.

Routed through the cache's getOrSet instead, which holds a cross-process
lock and re-checks inside it - one process assumes and the rest read the
session it cached. Since getOrSet takes its TTL before the session length
is known, the producer writes the entry itself and returns undefined, so
an entry expires exactly when the session stops being usable.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Important

The new cache-lock path does not reliably share the assumed MFA session with concurrent callers, so it can still issue duplicate STS requests with one TOTP code.

Reviewed changes since the prior Pullfrog review at 064f116, focusing on the new cross-process serialization path.

  • Serialized role assumption: Moved the MFA STS request into the plugin cache's getOrSet producer and persisted the resulting session with its actual usable lifetime.
  • Added lock-path coverage: Added assertions that the session lookup uses getOrSet and that the explicit cache write receives the expected TTL.
  • Documented parallel loads: Explained disk-cache coordination and the limitations of memory or disabled caching.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using azure/gpt-5.6-sol𝕏

TTL_WRITTEN_BY_PRODUCER,
async () => {
assumed = await this.assumeRoleWithMfa();
await this.persistSession(cacheKey, assumed);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Returning undefined makes the producer's assumed session unavailable to other callers sharing the same in-flight InMemoryCacheStore.getOrSet promise, so each waiter reaches the direct-assume fallback and can reuse the same TOTP. A disk write failure has the same result because PluginCacheAccessor.set() discards CacheStore.set()'s failure signal before this lock is released.

Technical details
# Preserve the shared result from the locked producer

## Affected sites
- `packages/plugins/aws-secrets/src/plugin.ts:304` - the producer stores its result only in one caller's local `assumed` variable.
- `packages/plugins/aws-secrets/src/plugin.ts:306` - cache persistence is best effort, but its success is not observable through `PluginCacheAccessor.set()`.
- `packages/plugins/aws-secrets/src/plugin.ts:310` - returning `undefined` makes in-process waiters receive no credentials.
- `packages/plugins/aws-secrets/src/plugin.ts:327` - callers without the producer-local value bypass the lock and assume directly.
- `packages/plugins/aws-secrets/test/mfa.test.ts:235` - the test performs one load and checks method calls, so it cannot detect duplicate concurrent assumptions.

## Required outcome
- Every caller coordinated by `getOrSet` must receive the single produced session, or fail without issuing another STS request when that session cannot be shared safely.
- Add a concurrent-caller test that fails if more than one STS assumption occurs, including the in-memory store path.

src/grammar.js is produced by peggy and gitignored, but the parser's build
task only declared dist/** as output. On a cache hit turbo restored dist/
without it, so a fresh checkout that hit the cache had no grammar.js - and
anything importing the parser from source (tests resolving through the
ts-src condition) failed with ERR_MODULE_NOT_FOUND. This only surfaced when
a downstream test task cache-missed while the parser build cache-hit.

Also adds grammar.peggy to the build inputs. It sits at the package root, so
the shared src/** glob never covered it and editing the grammar would not
have invalidated the cache.

The eslint jsonc glob now matches package-level turbo.json files, not just
the root one, so they can carry comments like the root config does.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ No new issues in the latest commit. Two previously reported must-address findings remain open.

Reviewed changes since the prior Pullfrog review at 58c606c, focusing on the parser build cache correction.

  • Corrected parser cache inputs: Added a package-level Turbo build definition that hashes the root-level grammar.peggy source.
  • Restored generated parser source: Declared src/grammar.js as a build output so cache hits restore the source import used under the ts-src condition.
  • Expanded config lint coverage: Updated the JSONC file glob to include nested turbo.json files.

Pullfrog  | Fix it ➔View workflow run | Using azure/gpt-5.6-sol𝕏

…after failures

Two problems with the MFA session handling:

The producer passed to getOrSet returned undefined so it could write the
entry itself with a precise TTL. But callers coalesced onto the same
in-flight producer then received nothing and each fell through to assuming
directly, replaying a code the first call had just spent. The producer now
returns the session so every waiter gets it, written under a lower-bound
TTL that is rewritten once the real session length is known. Erring short
means an entry never outlives the session it describes.

A failed assume also left no session and no cooldown state, so the next
provider invocation could immediately retry with the same spent code. The
cooldown is now checked before starting any assume, not only when a session
is still held.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Important

A rejected shared MFA assumption can still be retried immediately by another plugin instance or process with the same TOTP code.

Reviewed changes since the prior Pullfrog review at c301d373, focusing on the updated result-sharing and failure-cooldown behavior.

  • Shared successful assumptions: Returned serialized STS credentials from the cache producer so coalesced callers receive the same result, then replaced the conservative initial TTL with the session's actual usable lifetime.
  • Extended failure cooldowns: Applied the re-assumption gate after failed refreshes even when no in-memory session remains.
  • Expanded integration coverage: Added failed-refresh and concurrent-instance scenarios for the MFA credential path.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using azure/gpt-5.6-sol𝕏

}

// a failed attempt also lands here, with no session left to fall back on
this.assertCanAssume();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This gate only knows about attempts made by this AwsPluginInstance. If a shared getOrSet producer rejects, another instance or process has no lastAssumeAt value and can immediately become the next producer, replaying the same TOTP; please coordinate the failed-attempt cooldown across the shared cache path and cover concurrent callers when the producer rejects.

Technical details
# Coordinate failed assumptions across callers

## Affected sites
- `packages/plugins/aws-secrets/src/plugin.ts:325` - the cooldown gate reads instance-local state.
- `packages/plugins/aws-secrets/src/plugin.ts:352` - a rejected producer leaves no shared cache value or cooldown marker before the lock is released.
- `packages/plugins/aws-secrets/test/mfa.test.ts:330` - the failure case exercises one instance only.
- `packages/plugins/aws-secrets/test/mfa.test.ts:362` - the concurrent-instance case exercises only a successful producer.

## Required outcome
- Concurrent callers sharing either the in-memory or disk-backed cache must not issue another STS assumption in the same MFA window after the first producer rejects.
- Add a concurrent failure test that would fail if a second STS request is made.

getOrSet needed a TTL up front, but for anything whose lifetime the source
decides - an STS session, an OAuth token, a lease - that is only known once
the producer has run. Callers had to guess a lower bound and correct it
afterwards, leaving a window where the entry outlived what it described.

The ttl argument now also accepts a callback given the produced value,
called once and only when the producer actually produced. Returning zero or
less skips the write. Existing callers pass a literal and are unaffected.

The aws plugin drops its lower-bound-then-rewrite dance for a single
getOrSet with an exact TTL, so an entry existing once again means the
session is still usable, by construction rather than by argument.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ No new must-address issues in the latest commit; one API-contract clarification is inline.

Reviewed changes since the prior Pullfrog review at 91ae640:

  • Derived cache lifetimes: Added callback TTLs to PluginCacheAccessor.getOrSet and the underlying cache stores, including duration-string normalization and non-positive skip semantics.
  • Simplified MFA session persistence: Derived the STS cache lifetime directly from returned session credentials and removed the provisional write-then-correct flow.
  • Expanded cache coverage: Added tests for value-derived TTLs, skipped writes, cache-hit callback behavior, and process-local result coalescing.
  • Updated release metadata: Consolidated the core cache changes into a minor release entry describing both callback TTLs and cache-policy propagation.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using azure/gpt-5.6-sol𝕏


// cache write failed (e.g. encryption unavailable) — still return the computed value
// not written (skipped, or the write failed with encryption unavailable) — the
// computed value still goes back to the caller and to anyone waiting on the lock

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For a non-positive callback TTL, this value cannot reach processes waiting on the directory lock: after the lock is released, each waiter rechecks the empty cache and runs its own producer. Please narrow this comment and PluginCacheAccessor.getOrSet's cross-process sharing guarantee to persisted results, so callers protecting single-use work do not rely on coalescing in the new skip-write branch.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch - real, and it still applies. Tracking as a follow-up in #1047 rather than growing this PR further.

Worth noting for whoever picks it up: the naive shared "someone failed, everyone hold off" marker would be wrong for transient failures. If the first attempt never reached STS the code was not consumed, and the next caller would have succeeded - blocking it turns a network blip into a failure across a whole parallel build. The marker should only be set when AWS actually responded rejecting the code. The issue covers that, plus the concurrent-failure test you asked for.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature Request] awsume plugin

1 participant