[wrangler] Add opt-in OS keychain storage for OAuth credentials#14156
[wrangler] Add opt-in OS keychain storage for OAuth credentials#14156petebacondarwin wants to merge 12 commits into
Conversation
🦋 Changeset detectedLatest commit: d99f67e The changes in this PR will be included in the next version bump. This PR includes changesets to release 4 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
|
✅ All changesets look good |
|
Review posted successfully to PR #14156. Summary of the review I submitted:
I also verified that the |
@cloudflare/autoconfig
create-cloudflare
@cloudflare/deploy-helpers
@cloudflare/kv-asset-handler
miniflare
@cloudflare/pages-shared
@cloudflare/unenv-preset
@cloudflare/vite-plugin
@cloudflare/vitest-pool-workers
@cloudflare/workers-auth
@cloudflare/workers-editor-shared
@cloudflare/workers-utils
wrangler
commit: |
373cdbf to
6be1153
Compare
|
Codeowners approval required for this PR:
Show detailed file reviewers
|
1075895 to
81bc660
Compare
what would be default? I argue the keyring should be default for all unless explicitly required by |
|
Love this 🙌🏼 |
81bc660 to
467f6f2
Compare
|
Codeowners approval required for this PR:
Show detailed file reviewers
|
|
why would you default to plaintext storage? |
|
@vojkny - It is likely to be the default in the new CF CLI that we are building (and that will use this). For Wrangler it could be considered a minor breaking change to flip the default. The security element of this is pretty minimal. If a hacker has got on to your machine (e.g. invaded a Node.js app) then they can probably run the tools that can read the keychain anyway. So while switching from plain text to a small improvement, I don't see it as crucial for Wrangler. |
Addresses findings from the Devin and ask-bonk AI reviews on PR #14156. * **Duplicate `getAuthConfigFilePath`** (Devin) The wrangler-side and workers-auth-side copies of the path helper had the same behaviour but slightly different code, with only a JSDoc "Kept in sync with" comment guarding against drift. Drop the wrangler copy and re-export the workers-auth one (which is the authority — the encrypted-file store's legacy-TOML migration code also uses it). The historical `./user` import path is preserved by re-exporting through `auth-config-file.ts` and `user.ts`. * **`--use-keyring` preference rolled forward on failure** (Devin) `updateUserPreferences({ keyring_enabled: true })` was persisted *before* the eager `getCredentialStore()` validation call. When that call threw (forced env-var on an unsupported platform, Windows install failure, non-interactive Linux without secret-tool, …) the preference was left as `true` on disk. Every subsequent invocation then soft-fell-back to the file store with a one-time warning until the user explicitly ran `--no-use-keyring`. Wrap the eager validation in a try/catch and roll the preference back to its previous boolean value on throw. Add a regression test using the existing `setKeyProviderFactoryForTesting` seam (the factory itself throws, which is the same shape as a platform-install failure as far as the resolver is concerned). * **`npm` spawn without `shell: true` on Windows** (Devin) `spawnSync("npm", args, { … })` does not reliably resolve the `npm.cmd` / `npm.ps1` shim on every Windows configuration. The failure mode is currently user-friendly (a wrapped `UserError`) but the better fix is to set `shell: process.platform === "win32"` so the resolution actually succeeds. On POSIX `shell: false` is kept so argv quoting stays simple. * **`probeSecretTool()` / `findKeyringBinding()` re-spawned per credential op** (ask-bonk) The resolver re-runs `resolveActiveCredentialStore` on every credential read/write, which means `secret-tool --version` and `npm root -g` were spawned repeatedly during a single `wrangler deploy`. Memoize both per-process. The cache is invalidated by the matching test setter (`setLinuxSecretToolRunner`, `setNpmRunner`) for test isolation, and by `installKeyringBindingSync` on a successful install so the next resolution picks up the freshly-installed binding instead of returning the stale `null`. Add memoization tests plus a cache-invalidation-after-install regression test.
8d1341b to
9afa0c2
Compare
|
Codeowners approval required for this PR:
Show detailed file reviewers
|
The opt-out scrub for `wrangler login --no-use-keyring` went through `getCredentialStore()`, which resolves to `FileCredentialStore` whenever `CLOUDFLARE_AUTH_USE_KEYRING=false` is set in the environment (the resolver short-circuits on the env var before consulting the persistent preference). `FileCredentialStore.delete()` only removes the plaintext `.toml`, so the `.enc` file and the OS keyring entry were left on disk after opt-out. Resolve the encrypted store directly in the opt-out branch so the scrub always targets the backend the user is opting out of, regardless of the env-var state. When the keyring backend is unreachable on the current host, best-effort scrub the `.enc` file and warn that the keyring entry may still persist (the ciphertext is useless without the key, but leaving stale files around is confusing). Adds a regression test that stubs the env var and asserts both the `.enc` file and the keyring entry are scrubbed.
Addresses findings from the Devin and ask-bonk AI reviews on PR #14156. * **Duplicate `getAuthConfigFilePath`** (Devin) The wrangler-side and workers-auth-side copies of the path helper had the same behaviour but slightly different code, with only a JSDoc "Kept in sync with" comment guarding against drift. Drop the wrangler copy and re-export the workers-auth one (which is the authority — the encrypted-file store's legacy-TOML migration code also uses it). The historical `./user` import path is preserved by re-exporting through `auth-config-file.ts` and `user.ts`. * **`--use-keyring` preference rolled forward on failure** (Devin) `updateUserPreferences({ keyring_enabled: true })` was persisted *before* the eager `getCredentialStore()` validation call. When that call threw (forced env-var on an unsupported platform, Windows install failure, non-interactive Linux without secret-tool, …) the preference was left as `true` on disk. Every subsequent invocation then soft-fell-back to the file store with a one-time warning until the user explicitly ran `--no-use-keyring`. Wrap the eager validation in a try/catch and roll the preference back to its previous boolean value on throw. Add a regression test using the existing `setKeyProviderFactoryForTesting` seam (the factory itself throws, which is the same shape as a platform-install failure as far as the resolver is concerned). * **`npm` spawn without `shell: true` on Windows** (Devin) `spawnSync("npm", args, { … })` does not reliably resolve the `npm.cmd` / `npm.ps1` shim on every Windows configuration. The failure mode is currently user-friendly (a wrapped `UserError`) but the better fix is to set `shell: process.platform === "win32"` so the resolution actually succeeds. On POSIX `shell: false` is kept so argv quoting stays simple. * **`probeSecretTool()` / `findKeyringBinding()` re-spawned per credential op** (ask-bonk) The resolver re-runs `resolveActiveCredentialStore` on every credential read/write, which means `secret-tool --version` and `npm root -g` were spawned repeatedly during a single `wrangler deploy`. Memoize both per-process. The cache is invalidated by the matching test setter (`setLinuxSecretToolRunner`, `setNpmRunner`) for test isolation, and by `installKeyringBindingSync` on a successful install so the next resolution picks up the freshly-installed binding instead of returning the stale `null`. Add memoization tests plus a cache-invalidation-after-install regression test.
Five small follow-ups from the full review pass: * **AGENTS.md drift in workers-auth.** The default-TOML-impl pointer in `packages/workers-auth/AGENTS.md` line 15 still said the implementation lives in the consumer (`wrangler/src/user/auth-config-file.ts`). That's no longer true — `FileCredentialStore` is now in `workers-auth/src/credential-store/file-store.ts`. Reword to point at the new location and reframe wrangler's `auth-config-file.ts` as "generic TOML helper + back-compat re-export". * **Plaintext → encrypted migration is now `warn`-level (was `log`).** The auto-migration deletes the legacy plaintext file on disk. Surfacing that at `log` level meant the side effect could be missed in busy command output. Bumped to `warn` and extended the copy to explicitly call out the plaintext-file deletion. Both call sites now share a `buildMigrationLogger(config)` helper. * **`secret-tool` stdout trim consistency.** `MacSecurityKeyProvider.getKey()` already calls `.trim()` before `decodeKeyEnvelope`; the Linux provider did not. `secret-tool lookup` ends with a newline that `JSON.parse` happens to accept, but the two providers should behave identically. * **Changeset "Internal architecture" section removed.** Per `REVIEW.md`, changesets target users — not consumers of the internal `@cloudflare/workers-auth` package. The `createOAuthFlow(ctx)` / `credentialStorage` block description was implementation detail; the user-facing usage / per-platform / encryption-details sections stay. * **Keyring test cleanup moved into `afterEach`.** Tests in `user.test.ts` and `logout.test.ts` previously called `setKeyProviderFactoryForTesting(undefined)` and `resetCredentialStorageState()` at the bottom of each test. A mid-test assertion failure leaked the stubbed factory and the session-level warning latches into subsequent tests. Lifted both into describe-level `afterEach` blocks; removed the per-test cleanup tail and the duplicate dynamic imports. Also: rename `writeAuthConfigFile` → `writeAuthCredentials` and `readAuthConfigFile` → `readAuthCredentials`. The "File" suffix no longer reflects what the function does — when keyring storage is active there is no plaintext file on disk; the call routes through the active credential store (which may be the encrypted store). Full migration across wrangler test files; the workers-auth comment references are updated too. No back-compat alias — the symbol was wrangler-internal and the type error from a stale import is clearer than a silent deprecation.
The implementer of `storage.read()` should be responsible for only exposing errors that are relevant.
The previous commit (`cde86fea0 Remove catch-all around storage.read()`)
took the swallow-everything try/catch out of `readStoredAuthState`, but
left the storage implementers unchanged. `FileCredentialStore.read()`
still threw ENOENT on a missing file and `EncryptedFileCredentialStore.read()`
still threw `NoCredentialsStoredError` for every "no usable data"
shape, so 58 wrangler + 2 workers-auth tests that exercised the
"not logged in" / "no temporary account cached" path went red.
This commit finishes that refactor: it rewrites the
`ConfigStorage<T>.read()` contract so the *implementer* decides what's
worth surfacing as an exception, and updates the bundled implementers
accordingly.
* New `read(): T | undefined` contract. The interface docs spell out
the rule: `undefined` for *empty* / *unrecoverable* (missing file,
encrypted file with missing key, tampered ciphertext, corrupted
envelope, plaintext that fails to parse). `throw` only for *genuine*
errors the consumer needs to act on — typically filesystem /
permission failures (`EACCES`, `EISDIR`, disk full).
* `FileCredentialStore.read()` — `existsSync`-gates the read; returns
`undefined` when no plaintext TOML is on disk. A file that exists
but fails to parse still throws (corruption is a real, user-visible
error in this simpler backend).
* `EncryptedFileCredentialStore.read()` — drops the
`NoCredentialsStoredError` sentinel. All of the "no usable data"
shapes already collapsed to `undefined` inside `readEncryptedFile` /
`migrateFromLegacy`; the outer wrapper now just returns that
`undefined` through. Real IO failures still propagate. The encrypted
store has a clear recover path (next login regenerates the key and
re-encrypts) so even corruption is reasonable to treat as "not
logged in" here.
* `createTomlFileStorage<T>` in wrangler (used by the temporary-preview-
account store) — same shape: `existsSync` gate, return `undefined`
for missing, throw for genuine errors.
* `readStoredAuthState` — destructures `storage.read() ?? {}`, so the
destructuring shape stays uniform without re-introducing a
try/catch that swallows real errors.
* `getCachedTemporaryPreviewAccount` — drops its local try/catch (the
`undefined` from `storage.read()` is the same signal the catch used
to produce). Real IO errors now propagate up.
* `readAuthCredentials` in wrangler — signature widens to
`UserAuthConfig | undefined` to match.
* All affected tests updated: workers-auth's two `memoryStorage`
helpers return `undefined` instead of throwing; the `read throws`
tests on file-store / encrypted-file-store change to
`expect(store.read()).toBeUndefined()` for the "empty" cases while
still asserting throws for genuine corruption on the basic file
store. After this commit: workers-auth 127/127, wrangler 4422/4422.
In the same commit, two follow-ups to the latest round of AI review
feedback (Devin):
* **ANALYSIS_0001 — soft-fallback rollback** (`commands.ts`). The
previous `--use-keyring` rollback fix only ran when the resolver
threw. The resolver can also *soft-fall-back* to the file store
without throwing (interactive Linux without secret-tool, unsupported
platform without env-var force, Windows install failure not forced).
In those cases the preference stayed persisted as
`keyring_enabled: true`, so every future command re-resolved,
soft-fell-back again, and re-emitted the one-time warning until the
user explicitly ran `--no-use-keyring`. The opt-in path now checks
`store.kind` after the eager validation: if it isn't
`encrypted-file`, the preference is rolled back and a clarifying
warning explains why opt-in did not stick. Adds a regression test
using `process.platform = freebsd` to land on the unsupported-
without-force arm.
* **SEC_0001 — `shell: true` on Windows for npm spawn**. Devin
recommended swapping to `shell: false` with `npm.cmd`. After
CVE-2024-27980 (Node 20.12.2 / 21.7.2 / 22.x) that pattern errors —
`.cmd` / `.bat` files refuse to spawn without an explicit
`shell: true`. The current pattern is the documented post-CVE
approach, and Node's array-form arg handling for shell launches on
Windows applies the upstream CVE-2024-27980 escaping fix. Replace
the existing inline comment with a more thorough rationale that
cites the CVE and the trade-off; no code change.
The unit tests for `MacSecurityKeyProvider` and `LinuxSecretToolKeyProvider` (`tests/credential-store/key-providers/`) swap the `spawnSync` runner with a fake so they never touch the developer's real keychain. That gives fast, deterministic coverage of the argv shape and exit-code mapping, but it does not catch: - the actual `security` / `secret-tool` binary changing its argument format on a new OS release - keychain item-storage round-trip bugs (encoding issues in `encodeKeyEnvelope` that only manifest when the OS treats the value as opaque bytes) - permission / TCC prompts that the unit tests can't simulate Wrangler's e2e tests authenticate via `CLOUDFLARE_API_TOKEN` and never go through the OAuth flow, so they also don't exercise the keyring code path. This commit closes that gap with a focused integration suite that drives the real CLI on the real OS. Each backend is gated behind `describe.skipIf(...)` so the suite gracefully no-ops on incompatible runners: - macOS: actually runs against `/usr/bin/security` (ships with the OS — no setup needed). - Linux: gated on `secret-tool` *and* a usable D-Bus session (probed by attempting a no-op `secret-tool lookup` and treating exit 1 as the success signal). Standard CI containers without a session bus cleanly skip. - Windows: not yet covered (the `@napi-rs/keyring` lazy-install path is complex to set up cleanly in a test fixture; the unit tests remain the only coverage there). Hygiene: - A unique random service name per test run, so collisions with the developer's real wrangler-managed keychain entry are impossible. - `afterEach` cleanup, with a swallowed `deleteKey` for tests that already removed the entry themselves — leaving stray entries on a developer's keychain after a failed run is a paper-cut. - Both providers share the same `runKeyProviderRoundTripContract` helper so future backends (e.g. wincred via napi-keyring) plug in without test duplication. After this commit: workers-auth tests/ runs 130 (+3 macOS integration on macOS; +3 skipped Linux; same totals with both blocks skipped on non-supported runners).
9afa0c2 to
ff36127
Compare
…review nits - encrypted-file-store: read the file outside the try/catch in both readEncryptedFile and migrateFromLegacy so EACCES/EISDIR propagate per the ConfigStorage<T> contract instead of being swallowed as "not logged in" (which would let the next login overwrite the file and lose its key). Add regression tests for the fs-error-propagates path. - auth-config-file: fix stale JSDoc that claimed read() throws on a missing file; it now returns undefined per the contract. - login command: shorten the verbose --use-keyring describe and document why the flag intentionally has no default (tri-state undefined). - add clarifying comments: why Linux has no @napi-rs/keyring fallback, why NapiKeyringKeyProvider resolves its factory lazily, and why the lazy-installer is synchronous and installs on demand.
Fixes #14099.
Adds an opt-in path for storing wrangler's OAuth credentials in an AES-256-GCM-encrypted file under the wrangler config directory, with the encryption key held in the OS keyring (macOS Keychain, libsecret on Linux, or Windows Credential Manager). The default behavior is unchanged — existing users keep using the plaintext file. Opting in is explicit and persisted across invocations.
Why encrypted-file-with-keyring-key?
Storing credentials directly in the OS keyring runs into per-platform item-size limits (notably the ~2.5 KB macOS Keychain
kSecAttrGenericlimit), which would block adding richer auth state in the future. Storing only the encryption key (~44 bytes) sidesteps the limit while still pinning at-rest confidentiality to a hardware-/login-protected secret store.Usage
wrangler login --use-keyring— opt in; persisted to<wrangler-config>/preferences.json.wrangler login --no-use-keyring— opt back out. Deletes the encrypted file and the keyring entry; the subsequent login writes fresh credentials to the plaintext TOML. Opt-out never decrypts existing credentials onto disk (that would defeat the at-rest protection the user is choosing to disable).CLOUDFLARE_AUTH_USE_KEYRING=true|false— per-process override.wrangler whoami— reports where credentials are stored.Per-platform backends
Wrangler ships with zero native credential dependencies. Each platform uses whatever is already there:
/usr/bin/security(built-in)secret-toolfromlibsecret-tools@napi-rs/keyringlazy-installed vianpm installon first opt-inCLOUDFLARE_AUTH_USE_KEYRING=trueNon-opt-in users (the majority) pay zero install cost. macOS and Linux opt-in users pay zero install cost too. Only Windows opt-in users ever fetch a native binary, and only on first opt-in.
Encryption details
node:crypto— no third-party crypto deps.<wrangler-config>/config/<env>.enc, sibling of the legacy<env>.tomlso opt-in migration is non-destructive.{ v: 1, alg: "AES-256-GCM", iv, tag, ciphertext }(all binary fields base64). The GCM auth tag detects tampering; a mismatch is treated as "not logged in" and the next login re-encrypts.{ v: 1, key, created }holding only the 32-byte symmetric key — well below the macOS Keychain ~2.5 KB per-item limit.Migration
.encfile, delete the plaintext file. Logged asMigrated credentials from <toml> into <enc> (key in <keyProvider>)..--no-use-keyringopt-out: delete the encrypted file and the keyring entry without decrypting them. LogRemoved the encrypted credentials and the keyring entry. Run \wrangler login` to log in again.. The user then runswrangler login` (or the same opt-out command continues into login) which writes fresh credentials into the plaintext TOML.Internal architecture
All credential persistence — file backend, encrypted-file backend, key providers, and resolver — lives in
@cloudflare/workers-auth.createOAuthFlow(ctx)now requires acredentialStorageblock and returns a newgetCredentialStore()accessor forwhoami-style code:Future Cloudflare CLIs that reuse
@cloudflare/workers-authget OS keyring–encrypted credential storage by providing their ownserviceName.CLOUDFLARE_API_TOKENandCLOUDFLARE_API_KEY/CLOUDFLARE_EMAILcontinue to take priority over any stored OAuth credentials.Tests
credential-store/crypto.test.ts(19) — round-trip, tampered ciphertext, wrong key, IV non-repetition, envelope version checkscredential-store/file-store.test.ts(9) — plaintext TOML round-trip, missing/corrupted file handlingcredential-store/encrypted-file-store.test.ts(18) — encrypt/decrypt, missing key, file-tampered, legacy plaintext→encrypted migration on first readcredential-store/resolver.test.ts(21) — full platform matrix (darwin / linux-with-secret-tool / linux-missing / win32-installed / win32-missing-interactive / win32-missing-non-interactive / unsupported / forced)credential-store/key-providers/mac-security.test.ts(12),linux-secret-tool.test.ts(13),lazy-installer.test.ts(9) — argv construction, exit-code handling, stdin-based secret writes,WRANGLER_API_ENVIRONMENTscoping--use-keyring/--no-use-keyringflag tests rewritten to use the workers-authsetKeyProviderFactoryForTestingseam. The opt-out test now seeds encrypted credentials, runs--no-use-keyring, and asserts that the encrypted file + keyring entry are gone AND the stale credentials do not appear in the new plaintext file. Keyring-aware logout test updated. Full wrangler suite: 4300 passed, 4 skipped, 9 todo across 246 test files.pnpm check:lint,pnpm check:type,pnpm prettify,pnpm check, both packages'test:ci.Files
@cloudflare/workers-auth:src/credential-store/{interface,file-store,encrypted-file-store,crypto,resolver,state,index}.tssrc/credential-store/key-providers/{interface,shared,factory,mac-security,linux-secret-tool,napi-keyring,lazy-installer}.tstests/credential-store/@cloudflare/workers-auth:auth-config-file.ts(slimmed; now delegates),flow.ts(validates + populates state + addsgetCredentialStoreaccessor),context.ts(addscredentialStorageblock),env-vars.ts(addsCLOUDFLARE_AUTH_USE_KEYRING),index.ts(exports new APIs),AGENTS.md(documents credential storage layer)wrangler:src/user/{credential-store,mac-security-store,linux-secret-tool-store,lazy-keyring-installer,keyring-shared}.tsand their 4 test fileswrangler:src/user/preferences.ts(thekeyring_enableduser preference)wrangler:user.ts(addscredentialStorageblock tocreateOAuthFlow, exportsgetCredentialStore),whoami.ts(usesgetCredentialStore),commands.ts(--use-keyringopts in;--no-use-keyringscrubs encrypted credentials),user.test.ts+logout.test.ts(use workers-auth test seam)