Skip to content

[wrangler] Add opt-in OS keychain storage for OAuth credentials#14156

Open
petebacondarwin wants to merge 12 commits into
mainfrom
feat/keyring-credential-storage
Open

[wrangler] Add opt-in OS keychain storage for OAuth credentials#14156
petebacondarwin wants to merge 12 commits into
mainfrom
feat/keyring-credential-storage

Conversation

@petebacondarwin

@petebacondarwin petebacondarwin commented Jun 2, 2026

Copy link
Copy Markdown
Contributor

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 kSecAttrGeneric limit), 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:

Platform Key storage Install cost on opt-in
macOS /usr/bin/security (built-in) 0 KB
Linux secret-tool from libsecret-tools 0 KB (per-distro install hint if missing)
Windows @napi-rs/keyring lazy-installed via npm install on first opt-in ~1.9 MB one-time download
Other (FreeBSD, etc.) n/a — falls back to file with a warning, or hard-errors when CLOUDFLARE_AUTH_USE_KEYRING=true

Non-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

  • Algorithm: AES-256-GCM via node:crypto — no third-party crypto deps.
  • File location: <wrangler-config>/config/<env>.enc, sibling of the legacy <env>.toml so opt-in migration is non-destructive.
  • On-disk format: JSON envelope { 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.
  • Keyring entry: JSON envelope { v: 1, key, created } holding only the 32-byte symmetric key — well below the macOS Keychain ~2.5 KB per-item limit.
  • IV/nonce: 12 bytes, freshly generated per write.

Migration

  • First write after opt-in with an existing plaintext TOML: read the legacy file, generate a fresh key, encrypt and write the .enc file, delete the plaintext file. Logged as Migrated credentials from <toml> into <enc> (key in <keyProvider>)..
  • --no-use-keyring opt-out: delete the encrypted file and the keyring entry without decrypting them. Log Removed the encrypted credentials and the keyring entry. Run \wrangler login` to log in again.. The user then runs wrangler login` (or the same opt-out command continues into login) which writes fresh credentials into the plaintext TOML.
  • Lost key (file present, keyring entry missing): treated as "not logged in"; next login regenerates the key.
  • Corrupted file (auth tag mismatch): treated as "not logged in"; same recovery.

Internal architecture

All credential persistence — file backend, encrypted-file backend, key providers, and resolver — lives in @cloudflare/workers-auth. createOAuthFlow(ctx) now requires a credentialStorage block and returns a new getCredentialStore() accessor for whoami-style code:

const oauthFlow = createOAuthFlow({
  logger,
  isNonInteractiveOrCI,
  // ... existing fields
  credentialStorage: {
    serviceName: "wrangler",       // consumer-provided; future CLIs use their own
    isKeyringEnabled: () => readUserPreferences().keyring_enabled === true,
    cliName: "wrangler",            // for error-message templating
  },
});

oauthFlow.getCredentialStore().describe();
// "Encrypted file (<wrangler-config>/config/default.enc) with key in macOS Keychain (service=wrangler, account=default)"

Future Cloudflare CLIs that reuse @cloudflare/workers-auth get OS keyring–encrypted credential storage by providing their own serviceName.

CLOUDFLARE_API_TOKEN and CLOUDFLARE_API_KEY/CLOUDFLARE_EMAIL continue to take priority over any stored OAuth credentials.

Tests

  • workers-auth (new package tests, 110 tests across 7 files):
    • credential-store/crypto.test.ts (19) — round-trip, tampered ciphertext, wrong key, IV non-repetition, envelope version checks
    • credential-store/file-store.test.ts (9) — plaintext TOML round-trip, missing/corrupted file handling
    • credential-store/encrypted-file-store.test.ts (18) — encrypt/decrypt, missing key, file-tampered, legacy plaintext→encrypted migration on first read
    • credential-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_ENVIRONMENT scoping
  • wrangler: --use-keyring / --no-use-keyring flag tests rewritten to use the workers-auth setKeyProviderFactoryForTesting seam. 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.
  • All previous passes preserved: pnpm check:lint, pnpm check:type, pnpm prettify, pnpm check, both packages' test:ci.

Files

  • Added to @cloudflare/workers-auth:
    • src/credential-store/{interface,file-store,encrypted-file-store,crypto,resolver,state,index}.ts
    • src/credential-store/key-providers/{interface,shared,factory,mac-security,linux-secret-tool,napi-keyring,lazy-installer}.ts
    • 7 test files under tests/credential-store/
  • Modified in @cloudflare/workers-auth: auth-config-file.ts (slimmed; now delegates), flow.ts (validates + populates state + adds getCredentialStore accessor), context.ts (adds credentialStorage block), env-vars.ts (adds CLOUDFLARE_AUTH_USE_KEYRING), index.ts (exports new APIs), AGENTS.md (documents credential storage layer)
  • Removed from wrangler: src/user/{credential-store,mac-security-store,linux-secret-tool-store,lazy-keyring-installer,keyring-shared}.ts and their 4 test files
  • Added to wrangler: src/user/preferences.ts (the keyring_enabled user preference)
  • Modified in wrangler: user.ts (adds credentialStorage block to createOAuthFlow, exports getCredentialStore), whoami.ts (uses getCredentialStore), commands.ts (--use-keyring opts in; --no-use-keyring scrubs encrypted credentials), user.test.ts + logout.test.ts (use workers-auth test seam)

Opening as draft — the companion Cloudflare docs PR (cloudflare/cloudflare-docs#31211) is linked above. Ready to move out of draft once both PRs are reviewed.

@changeset-bot

changeset-bot Bot commented Jun 2, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: d99f67e

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

This PR includes changesets to release 4 packages
Name Type
wrangler Minor
@cloudflare/workers-auth Minor
@cloudflare/vite-plugin Patch
@cloudflare/vitest-pool-workers Patch

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

@github-actions

github-actions Bot commented Jun 2, 2026

Copy link
Copy Markdown
Contributor

✅ All changesets look good

ask-bonk[bot]

This comment was marked as resolved.

@ask-bonk

ask-bonk Bot commented Jun 2, 2026

Copy link
Copy Markdown
Contributor

Review posted successfully to PR #14156.

Summary of the review I submitted:

  1. (high) credential-store.ts:372 — the keyring-unavailable fallback warning tells users to run wrangler login --no-keyring, but that flag doesn't exist. The actual flag is --no-use-keyring. Posted a suggestion fix.
  2. (medium) credential-store.ts:360probeKeyring() does a real keychain write+delete and runs on every getActiveCredentialStore() resolution (called repeatedly per command via readStoredAuthState), so it's uncached and will hammer the OS keychain / risk macOS access prompts. Recommended memoizing the probe result per process.
  3. (low) preferences.ts:16 — stale comment referencing the same wrong --no-keyring flag name. Posted a suggestion fix.

I also verified that the @napi-rs/keyring dependency concern (regular dep vs optional) is mitigated, because the platform-specific native binaries are themselves optionalDependencies of the package and loading is lazy/guarded, so install won't break on unsupported platforms.

github run

@pkg-pr-new

pkg-pr-new Bot commented Jun 2, 2026

Copy link
Copy Markdown
@cloudflare/autoconfig

npm i https://pkg.pr.new/@cloudflare/autoconfig@14156

create-cloudflare

npm i https://pkg.pr.new/create-cloudflare@14156

@cloudflare/deploy-helpers

npm i https://pkg.pr.new/@cloudflare/deploy-helpers@14156

@cloudflare/kv-asset-handler

npm i https://pkg.pr.new/@cloudflare/kv-asset-handler@14156

miniflare

npm i https://pkg.pr.new/miniflare@14156

@cloudflare/pages-shared

npm i https://pkg.pr.new/@cloudflare/pages-shared@14156

@cloudflare/unenv-preset

npm i https://pkg.pr.new/@cloudflare/unenv-preset@14156

@cloudflare/vite-plugin

npm i https://pkg.pr.new/@cloudflare/vite-plugin@14156

@cloudflare/vitest-pool-workers

npm i https://pkg.pr.new/@cloudflare/vitest-pool-workers@14156

@cloudflare/workers-auth

npm i https://pkg.pr.new/@cloudflare/workers-auth@14156

@cloudflare/workers-editor-shared

npm i https://pkg.pr.new/@cloudflare/workers-editor-shared@14156

@cloudflare/workers-utils

npm i https://pkg.pr.new/@cloudflare/workers-utils@14156

wrangler

npm i https://pkg.pr.new/wrangler@14156

commit: d99f67e

@petebacondarwin petebacondarwin force-pushed the feat/keyring-credential-storage branch 4 times, most recently from 373cdbf to 6be1153 Compare June 3, 2026 11:43
@petebacondarwin petebacondarwin marked this pull request as ready for review June 3, 2026 13:00
@workers-devprod workers-devprod requested review from a team and penalosa and removed request for a team June 3, 2026 13:01
@workers-devprod

Copy link
Copy Markdown
Contributor

Codeowners approval required for this PR:

  • @cloudflare/wrangler
Show detailed file reviewers
  • .changeset/wrangler-keyring-credential-storage.md: [@cloudflare/wrangler]
  • packages/workers-auth/AGENTS.md: [@cloudflare/wrangler]
  • packages/workers-auth/src/auth-config-file.ts: [@cloudflare/wrangler]
  • packages/workers-auth/src/context.ts: [@cloudflare/wrangler]
  • packages/workers-auth/src/credential-store/crypto.ts: [@cloudflare/wrangler]
  • packages/workers-auth/src/credential-store/encrypted-file-store.ts: [@cloudflare/wrangler]
  • packages/workers-auth/src/credential-store/file-store.ts: [@cloudflare/wrangler]
  • packages/workers-auth/src/credential-store/index.ts: [@cloudflare/wrangler]
  • packages/workers-auth/src/credential-store/interface.ts: [@cloudflare/wrangler]
  • packages/workers-auth/src/credential-store/key-providers/factory.ts: [@cloudflare/wrangler]
  • packages/workers-auth/src/credential-store/key-providers/interface.ts: [@cloudflare/wrangler]
  • packages/workers-auth/src/credential-store/key-providers/lazy-installer.ts: [@cloudflare/wrangler]
  • packages/workers-auth/src/credential-store/key-providers/linux-secret-tool.ts: [@cloudflare/wrangler]
  • packages/workers-auth/src/credential-store/key-providers/mac-security.ts: [@cloudflare/wrangler]
  • packages/workers-auth/src/credential-store/key-providers/napi-keyring.ts: [@cloudflare/wrangler]
  • packages/workers-auth/src/credential-store/key-providers/shared.ts: [@cloudflare/wrangler]
  • packages/workers-auth/src/credential-store/resolver.ts: [@cloudflare/wrangler]
  • packages/workers-auth/src/credential-store/state.ts: [@cloudflare/wrangler]
  • packages/workers-auth/src/env-vars.ts: [@cloudflare/wrangler]
  • packages/workers-auth/src/flow.ts: [@cloudflare/wrangler]
  • packages/workers-auth/src/index.ts: [@cloudflare/wrangler]
  • packages/workers-auth/tests/credential-store/crypto.test.ts: [@cloudflare/wrangler]
  • packages/workers-auth/tests/credential-store/encrypted-file-store.test.ts: [@cloudflare/wrangler]
  • packages/workers-auth/tests/credential-store/file-store.test.ts: [@cloudflare/wrangler]
  • packages/workers-auth/tests/credential-store/key-providers/lazy-installer.test.ts: [@cloudflare/wrangler]
  • packages/workers-auth/tests/credential-store/key-providers/linux-secret-tool.test.ts: [@cloudflare/wrangler]
  • packages/workers-auth/tests/credential-store/key-providers/mac-security.test.ts: [@cloudflare/wrangler]
  • packages/workers-auth/tests/credential-store/resolver.test.ts: [@cloudflare/wrangler]
  • packages/workers-utils/src/environment-variables/factory.ts: [@cloudflare/wrangler]
  • packages/wrangler/package.json: [@cloudflare/wrangler]
  • packages/wrangler/src/tests/logout.test.ts: [@cloudflare/wrangler]
  • packages/wrangler/src/tests/user.test.ts: [@cloudflare/wrangler]
  • packages/wrangler/src/tests/whoami.test.ts: [@cloudflare/wrangler]
  • packages/wrangler/src/user/auth-variables.ts: [@cloudflare/wrangler]
  • packages/wrangler/src/user/commands.ts: [@cloudflare/wrangler]
  • packages/wrangler/src/user/preferences.ts: [@cloudflare/wrangler]
  • packages/wrangler/src/user/user.ts: [@cloudflare/wrangler]
  • packages/wrangler/src/user/whoami.ts: [@cloudflare/wrangler]
  • pnpm-lock.yaml: [@cloudflare/wrangler]

devin-ai-integration[bot]

This comment was marked as resolved.

@petebacondarwin petebacondarwin force-pushed the feat/keyring-credential-storage branch from 1075895 to 81bc660 Compare June 3, 2026 16:32
@vojkny

vojkny commented Jun 13, 2026

Copy link
Copy Markdown
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 

what would be default? I argue the keyring should be default for all unless explicitly required by no-use…

@irvinebroque

Copy link
Copy Markdown
Contributor

Love this 🙌🏼

@petebacondarwin petebacondarwin force-pushed the feat/keyring-credential-storage branch from 81bc660 to 467f6f2 Compare June 18, 2026 15:34
@petebacondarwin petebacondarwin marked this pull request as draft June 18, 2026 15:35
@petebacondarwin petebacondarwin removed the request for review from penalosa June 18, 2026 15:35
@workers-devprod workers-devprod requested review from a team and NuroDev and removed request for a team June 18, 2026 15:35
@workers-devprod

workers-devprod commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

Codeowners approval required for this PR:

  • @cloudflare/wrangler
Show detailed file reviewers
  • .changeset/wrangler-keyring-credential-storage.md: [@cloudflare/wrangler]
  • packages/workers-auth/AGENTS.md: [@cloudflare/wrangler]
  • packages/workers-auth/src/config-file/index.ts: [@cloudflare/wrangler]
  • packages/workers-auth/src/context.ts: [@cloudflare/wrangler]
  • packages/workers-auth/src/credential-store/crypto.ts: [@cloudflare/wrangler]
  • packages/workers-auth/src/credential-store/encrypted-file-store.ts: [@cloudflare/wrangler]
  • packages/workers-auth/src/credential-store/file-store.ts: [@cloudflare/wrangler]
  • packages/workers-auth/src/credential-store/index.ts: [@cloudflare/wrangler]
  • packages/workers-auth/src/credential-store/interface.ts: [@cloudflare/wrangler]
  • packages/workers-auth/src/credential-store/key-providers/factory.ts: [@cloudflare/wrangler]
  • packages/workers-auth/src/credential-store/key-providers/interface.ts: [@cloudflare/wrangler]
  • packages/workers-auth/src/credential-store/key-providers/lazy-installer.ts: [@cloudflare/wrangler]
  • packages/workers-auth/src/credential-store/key-providers/linux-secret-tool.ts: [@cloudflare/wrangler]
  • packages/workers-auth/src/credential-store/key-providers/mac-security.ts: [@cloudflare/wrangler]
  • packages/workers-auth/src/credential-store/key-providers/napi-keyring.ts: [@cloudflare/wrangler]
  • packages/workers-auth/src/credential-store/key-providers/shared.ts: [@cloudflare/wrangler]
  • packages/workers-auth/src/credential-store/resolver.ts: [@cloudflare/wrangler]
  • packages/workers-auth/src/credential-store/state.ts: [@cloudflare/wrangler]
  • packages/workers-auth/src/env-vars.ts: [@cloudflare/wrangler]
  • packages/workers-auth/src/index.ts: [@cloudflare/wrangler]
  • packages/workers-auth/src/state.ts: [@cloudflare/wrangler]
  • packages/workers-auth/src/temporary.ts: [@cloudflare/wrangler]
  • packages/workers-auth/src/token-exchange.ts: [@cloudflare/wrangler]
  • packages/workers-auth/tests/credential-store/crypto.test.ts: [@cloudflare/wrangler]
  • packages/workers-auth/tests/credential-store/encrypted-file-store.test.ts: [@cloudflare/wrangler]
  • packages/workers-auth/tests/credential-store/file-store.test.ts: [@cloudflare/wrangler]
  • packages/workers-auth/tests/credential-store/key-providers/integration.test.ts: [@cloudflare/wrangler]
  • packages/workers-auth/tests/credential-store/key-providers/lazy-installer.test.ts: [@cloudflare/wrangler]
  • packages/workers-auth/tests/credential-store/key-providers/linux-secret-tool.test.ts: [@cloudflare/wrangler]
  • packages/workers-auth/tests/credential-store/key-providers/mac-security.test.ts: [@cloudflare/wrangler]
  • packages/workers-auth/tests/credential-store/resolver.test.ts: [@cloudflare/wrangler]
  • packages/workers-auth/tests/credentials.test.ts: [@cloudflare/wrangler]
  • packages/workers-auth/tests/state.test.ts: [@cloudflare/wrangler]
  • packages/workers-utils/src/environment-variables/factory.ts: [@cloudflare/wrangler]
  • packages/wrangler/src/tests/auth-credentials.test.ts: [@cloudflare/wrangler]
  • packages/wrangler/src/tests/deploy/core.test.ts: [@cloudflare/wrangler]
  • packages/wrangler/src/tests/logout.test.ts: [@cloudflare/wrangler]
  • packages/wrangler/src/tests/user.test.ts: [@cloudflare/wrangler]
  • packages/wrangler/src/tests/whoami.test.ts: [@cloudflare/wrangler]
  • packages/wrangler/src/user/auth-config-file.ts: [@cloudflare/wrangler]
  • packages/wrangler/src/user/auth-variables.ts: [@cloudflare/wrangler]
  • packages/wrangler/src/user/commands.ts: [@cloudflare/wrangler]
  • packages/wrangler/src/user/preferences.ts: [@cloudflare/wrangler]
  • packages/wrangler/src/user/user.ts: [@cloudflare/wrangler]
  • packages/wrangler/src/user/whoami.ts: [@cloudflare/wrangler]

devin-ai-integration[bot]

This comment was marked as resolved.

@vojkny

vojkny commented Jun 19, 2026

Copy link
Copy Markdown

why would you default to plaintext storage?

@petebacondarwin

Copy link
Copy Markdown
Contributor Author

@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.

@petebacondarwin petebacondarwin marked this pull request as ready for review June 19, 2026 09:15
devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

@devin-ai-integration devin-ai-integration 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.

Devin Review found 2 new potential issues.

View 2 additional findings in Devin Review.

Open in Devin Review

Comment thread packages/wrangler/src/user/commands.ts
Comment thread packages/workers-auth/src/temporary.ts

@devin-ai-integration devin-ai-integration 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.

Devin Review found 2 new potential issues.

Open in Devin Review

Comment thread packages/workers-auth/src/credential-store/encrypted-file-store.ts
Comment thread packages/workers-auth/src/state.ts
@workers-devprod workers-devprod requested a review from a team June 23, 2026 14:42
petebacondarwin added a commit that referenced this pull request Jun 23, 2026
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.
@petebacondarwin petebacondarwin force-pushed the feat/keyring-credential-storage branch from 8d1341b to 9afa0c2 Compare June 23, 2026 14:47
devin-ai-integration[bot]

This comment was marked as resolved.

Comment thread packages/wrangler/src/user/commands.ts Outdated
Comment thread packages/wrangler/src/user/commands.ts
Comment thread packages/wrangler/src/user/preferences.ts
Comment thread packages/workers-auth/src/credential-store/encrypted-file-store.ts
Comment thread packages/workers-auth/src/credential-store/key-providers/factory.ts Outdated
@workers-devprod

Copy link
Copy Markdown
Contributor

Codeowners approval required for this PR:

  • @cloudflare/wrangler
Show detailed file reviewers
  • .changeset/wrangler-keyring-credential-storage.md: [@cloudflare/wrangler]
  • packages/workers-auth/AGENTS.md: [@cloudflare/wrangler]
  • packages/workers-auth/src/config-file/index.ts: [@cloudflare/wrangler]
  • packages/workers-auth/src/context.ts: [@cloudflare/wrangler]
  • packages/workers-auth/src/credential-store/crypto.ts: [@cloudflare/wrangler]
  • packages/workers-auth/src/credential-store/encrypted-file-store.ts: [@cloudflare/wrangler]
  • packages/workers-auth/src/credential-store/file-store.ts: [@cloudflare/wrangler]
  • packages/workers-auth/src/credential-store/index.ts: [@cloudflare/wrangler]
  • packages/workers-auth/src/credential-store/interface.ts: [@cloudflare/wrangler]
  • packages/workers-auth/src/credential-store/key-providers/factory.ts: [@cloudflare/wrangler]
  • packages/workers-auth/src/credential-store/key-providers/interface.ts: [@cloudflare/wrangler]
  • packages/workers-auth/src/credential-store/key-providers/lazy-installer.ts: [@cloudflare/wrangler]
  • packages/workers-auth/src/credential-store/key-providers/linux-secret-tool.ts: [@cloudflare/wrangler]
  • packages/workers-auth/src/credential-store/key-providers/mac-security.ts: [@cloudflare/wrangler]
  • packages/workers-auth/src/credential-store/key-providers/napi-keyring.ts: [@cloudflare/wrangler]
  • packages/workers-auth/src/credential-store/key-providers/shared.ts: [@cloudflare/wrangler]
  • packages/workers-auth/src/credential-store/resolver.ts: [@cloudflare/wrangler]
  • packages/workers-auth/src/credential-store/state.ts: [@cloudflare/wrangler]
  • packages/workers-auth/src/env-vars.ts: [@cloudflare/wrangler]
  • packages/workers-auth/src/index.ts: [@cloudflare/wrangler]
  • packages/workers-auth/src/state.ts: [@cloudflare/wrangler]
  • packages/workers-auth/src/temporary.ts: [@cloudflare/wrangler]
  • packages/workers-auth/src/token-exchange.ts: [@cloudflare/wrangler]
  • packages/workers-auth/tests/credential-store/crypto.test.ts: [@cloudflare/wrangler]
  • packages/workers-auth/tests/credential-store/encrypted-file-store.test.ts: [@cloudflare/wrangler]
  • packages/workers-auth/tests/credential-store/file-store.test.ts: [@cloudflare/wrangler]
  • packages/workers-auth/tests/credential-store/key-providers/integration.test.ts: [@cloudflare/wrangler]
  • packages/workers-auth/tests/credential-store/key-providers/lazy-installer.test.ts: [@cloudflare/wrangler]
  • packages/workers-auth/tests/credential-store/key-providers/linux-secret-tool.test.ts: [@cloudflare/wrangler]
  • packages/workers-auth/tests/credential-store/key-providers/mac-security.test.ts: [@cloudflare/wrangler]
  • packages/workers-auth/tests/credential-store/resolver.test.ts: [@cloudflare/wrangler]
  • packages/workers-auth/tests/credentials.test.ts: [@cloudflare/wrangler]
  • packages/workers-auth/tests/state.test.ts: [@cloudflare/wrangler]
  • packages/workers-utils/src/environment-variables/factory.ts: [@cloudflare/wrangler]
  • packages/wrangler/src/tests/auth-credentials.test.ts: [@cloudflare/wrangler]
  • packages/wrangler/src/tests/deploy/core.test.ts: [@cloudflare/wrangler]
  • packages/wrangler/src/tests/logout.test.ts: [@cloudflare/wrangler]
  • packages/wrangler/src/tests/user.test.ts: [@cloudflare/wrangler]
  • packages/wrangler/src/tests/whoami.test.ts: [@cloudflare/wrangler]
  • packages/wrangler/src/user/auth-config-file.ts: [@cloudflare/wrangler]
  • packages/wrangler/src/user/auth-variables.ts: [@cloudflare/wrangler]
  • packages/wrangler/src/user/commands.ts: [@cloudflare/wrangler]
  • packages/wrangler/src/user/preferences.ts: [@cloudflare/wrangler]
  • packages/wrangler/src/user/user.ts: [@cloudflare/wrangler]
  • packages/wrangler/src/user/whoami.ts: [@cloudflare/wrangler]

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).
@petebacondarwin petebacondarwin force-pushed the feat/keyring-credential-storage branch from 9afa0c2 to ff36127 Compare June 26, 2026 09:19

@devin-ai-integration devin-ai-integration 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.

Devin Review found 0 new potential issues.

Open in Devin Review

…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.

@devin-ai-integration devin-ai-integration 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.

Devin Review found 0 new potential issues.

Open in Devin Review

@petebacondarwin petebacondarwin requested a review from NuroDev June 26, 2026 14:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Untriaged

Development

Successfully merging this pull request may close these issues.

Support storing OAuth/refresh tokens in OS keychain instead of plaintext

5 participants