Skip to content

fix(auth): stop repeated macOS keychain password prompts - #225

Merged
nicknisi merged 3 commits into
mainfrom
fix/macos-keychain-prompts
Aug 19, 2026
Merged

fix(auth): stop repeated macOS keychain password prompts#225
nicknisi merged 3 commits into
mainfrom
fix/macos-keychain-prompts

Conversation

@nicknisi

Copy link
Copy Markdown
Member

Problem

Running npx workos@latest install prompts for the login keychain password ~5 times per run (Slack report). Users reasonably read this as malware behavior and deny it, breaking auth.

Two compounding causes:

  1. No in-process cache — one CLI run reads credentials from many call sites (ensure-auth up to 5×, telemetry, analytics, token refresh, credential proxy). Each Entry.getPassword() is a separate keychain ACL check = one dialog per read.
  2. Ad-hoc signed binary — keychain item ACLs pin trust to the requesting binary's code signature. Bun ad-hoc signs the compiled binary, so every release has a new signature and "Always Allow" never persists across versions. (Verified empirically: the item's partition list accumulates one cdhash: per build; prompts only began after the Node→Bun conversion because the signed node binary carried a stable teamid:.)

Fix

  1. In-process credential cache in credential-store.ts — first read cached, saves keep it coherent (all refresh paths write through saveCredentials in-process). Invalidated by clearCredentials/setInsecureStorage.
  2. macOS keychain access via /usr/bin/security (darwin-keychain.ts) instead of @napi-rs/keyring. The security tool is Apple-signed and stable, so items it creates carry the apple-tool: partition and read back silently forever. Writes are delete+add (updating in place keeps the old ACL) through security -i stdin so the secret never appears in argv. Legacy raw-JSON items from the native binding still read (one final prompt), and the next save re-mints them silently.

Trade-off (deliberate, interim)

Any user-level process can invoke /usr/bin/security, so app-level isolation is lost on macOS — comparable in practice to the existing plaintext file fallback. This is a stopgap until release binaries are Developer ID signed (in progress); at that point darwin should revert to the native keyring. Marked with a ponytail: comment in darwin-keychain.ts.

Testing

  • 2067 tests pass; new specs cover the cache (read-count, coherence, invalidation)
  • Test isolation guardrail extended: global setup mocks the new darwin backend too, asserted by keyring-isolation.spec.ts (a real security shell-out in tests would wipe developer logins)
  • Verified end-to-end on real credentials: fresh item shows partition: apple-tool:; 5 consecutive CLI runs complete in 3.6s with zero prompts (one unanswered prompt blocks ~60s)

Checklist

  • bun run build passes
  • bun run test passes
  • bun run typecheck passes
  • Conventional Commit format
  • JSON mode unaffected (storage layer only)

Two changes:
1. In-process credential cache: a single CLI run reads credentials from
many call sites (auth, telemetry, token refresh); each uncached keyring
read is a separate keychain ACL check, i.e. one password dialog per
read on an untrusted binary. First read is now cached; saves keep the
cache coherent.
2. macOS keychain access now shells out to /usr/bin/security instead of
the native binding. Keychain item ACLs pin trust to the requesting
binary's code signature, and the Bun-compiled release binary is ad-hoc
signed — a new signature every release — so 'Always Allow' never
persisted across versions. /usr/bin/security is Apple-signed and
stable; items it creates read back silently. Existing items are
re-minted (delete + add) on next save, so migration costs at most one
final prompt.
Interim measure until releases are Developer ID signed, at which point
darwin should revert to @napi-rs/keyring to regain app-level isolation
(any user process can invoke /usr/bin/security, so this trades app
isolation for prompt-free UX — comparable in practice to the existing
file fallback).
@greptile-apps

greptile-appsBot commented Aug 19, 2026

Copy link
Copy Markdown

Greptile Summary

The PR consolidates credential and configuration persistence behind a shared cached store and introduces a macOS /usr/bin/security backend to avoid repeated keychain prompts.

  • Adds coherent in-process caching for keychain and file-backed values.
  • Routes macOS keychain operations through a stable Apple-signed executable.
  • Extends keychain isolation mocks and cache behavior tests.

Confidence Score: 5/5

The PR appears safe to merge because no blocking failure remains within the eligible follow-up-review scope.

No blocking failure remains.

Important Files Changed

FilenameOverview
src/lib/keyring-store.tsIntroduces the shared backend-selection, caching, validation, migration, file-fallback, and diagnostics machinery used by both stores.
src/lib/darwin-keychain.tsImplements macOS generic-password reads and delete-then-add writes through /usr/bin/security, including legacy raw-JSON compatibility.
src/lib/credential-store.tsReplaces duplicated credential persistence logic with a validated KeyringStore<Credentials> wrapper.
src/lib/config-store.tsReplaces duplicated configuration persistence logic with KeyringStore<CliConfig> while preserving save read-back verification.
src/test/setup.tsGlobally mocks the new macOS backend to prevent tests from touching real developer keychain entries.

Reviews (3): Last reviewed commit: "refactor(auth): extract shared KeyringSt..." | Re-trigger Greptile

@devin-ai-integrationdevin-ai-integrationBot 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 4 potential issues.

Open in Devin Review

Comment threadsrc/lib/credential-store.ts Outdated
Comment on lines 149 to 157
function getKeyringEntry(): KeyringEntry {
// On macOS, go through /usr/bin/security (stable Apple-signed binary)
// instead of the native binding: the ad-hoc-signed CLI binary changes
// signature every release, so native keychain access prompts per version.
// See darwin-keychain.ts; revert once releases are Developer ID signed.
if (process.platform === 'darwin') {
return new DarwinSecurityEntry(SERVICE_NAME, ACCOUNT_NAME);
}
return new Entry(SERVICE_NAME, ACCOUNT_NAME);

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.

🔴 Password prompts still repeat on macOS because saved settings are read through the old keychain path

Only the login credentials were switched to the stable macOS tool and given an in-process cache (getKeyringEntry() at src/lib/credential-store.ts:149-157), while the CLI's saved settings are still read through the old, per-release-signature path on every read (new Entry(...) at src/lib/config-store.ts:131-132), so macOS users keep getting repeated password dialogs in one run.
Impact: Users still see multiple keychain password prompts per command run and may deny them, breaking authentication — the symptom the change set out to remove.

Why the settings entry keeps triggering keychain ACL checks

src/lib/config-store.ts stores its blob in the same keychain service under account config (src/lib/config-store.ts:54-55) and reads it via readFromKeyring()new Entry(SERVICE_NAME, ACCOUNT_NAME).getPassword() (src/lib/config-store.ts:134-140). Unlike credential-store.ts, it has neither the /usr/bin/security routing nor any in-process cache: getConfig() (src/lib/config-store.ts:195) performs a fresh keyring read on every call, and it is called several times per run (e.g. src/commands/login.ts:71, src/commands/env.ts:26, src/lib/run-with-core.ts, src/lib/unclaimed-env-provision.ts:59). Because the item was written by the ad-hoc-signed binary, its ACL is pinned to a signature that changes each release — exactly the mechanism described in src/lib/darwin-keychain.ts:1-16 — so each read is a separate prompt.

Prompt for agents
The macOS prompt-storm fix was applied only to src/lib/credential-store.ts (routing through DarwinSecurityEntry plus an in-process cache). src/lib/config-store.ts stores a second keychain item (service `workos-cli`, account `config`) and still reads it through `new Entry(...)` on every `getConfig()` call, with no caching. `getConfig()` is invoked multiple times per CLI run (src/commands/login.ts, src/commands/env.ts, src/lib/run-with-core.ts, src/lib/unclaimed-env-provision.ts), so on macOS each call is another keychain ACL check against the ad-hoc-signed binary and therefore another password dialog — the exact symptom this PR targets. Consider extracting the shared backend selection (darwin → DarwinSecurityEntry, otherwise @napi-rs/keyring Entry) into a helper used by both stores, and adding an equivalent in-process cache to config-store (invalidated by saveConfig/clearConfig/setInsecureConfigStorage). Note the test setup already mocks the darwin backend globally, so config-store specs should keep working.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Confirmed — and empirically reproduced: with the credential fix in place, running a differently-built binary still produced one prompt, and it traced to this config item. Fixed in 9438450: config-store now uses the same darwin backend selection plus an equivalent in-process cache (invalidated by saveConfig/clearConfig/setInsecureConfigStorage). Kept the backend selection local to each store rather than extracting a shared helper so each spec's existing per-file keyring mocks keep working; the global test-setup mock covers the darwin backend for both.

Comment on lines +53 to +59
const r = spawnSync(SECURITY, ['-i'], {
input: `add-generic-password -a "${this.account}" -s "${this.service}" -w "${b64}"\n`,
encoding: 'utf-8',
});
if (r.status !== 0) {
throw new Error(`security add-generic-password exited ${r.status}: ${(r.stderr ?? '').trim()}`);
}

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.

🔍 Write failures may go undetected if security -i does not propagate the inner command's exit status

setPassword() shells out to /usr/bin/security -i and pipes add-generic-password ... on stdin, then relies solely on the process exit status to detect failure. In interactive mode security reads and executes commands from stdin; if it exits 0 even when the inner add-generic-password fails (e.g. keychain locked, ACL denial, malformed argument), writeToKeyring() at src/lib/credential-store.ts:186-201 would report success, the file fallback would never kick in, and the credential would silently not be persisted — forcing a re-login on the next run. The success path was verified end-to-end by the author, but the failure path depends on the exit-status semantics of security -i, which is worth confirming (e.g. printf 'add-generic-password -a x -s y -w z\nadd-generic-password -a x -s y -w z\n' | security -i; echo $? — the duplicate add should fail). A robust alternative is a read-back verification after write, or passing the secret via a temp-file/-w prompt mode whose status is unambiguous.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Verified empirically with exactly the suggested experiment before shipping: a duplicate add-generic-password through security -i exits 45 (SecKeychainItemCreateFromContent: The specified item already exists), and find-generic-password not-found exits 44 — security -i does propagate the inner command's failure status. setPassword also deletes before adding, so the duplicate case can't occur in practice. Belt-and-suspenders read-back verification already exists one level up in config-store's saveConfig.

Comment on lines +45 to +60
setPassword(password: string): void {
// Delete-then-add rather than update in place: updating keeps the old
// item's ACL (pinned to a previous binary). A fresh item is owned by
// /usr/bin/security and reads back without prompting.
this.deletePassword();
const b64 = Buffer.from(password, 'utf-8').toString('base64');
// -i reads commands from stdin so the secret never appears in argv,
// where it would be visible to `ps`.
const r = spawnSync(SECURITY, ['-i'], {
input: `add-generic-password -a "${this.account}" -s "${this.service}" -w "${b64}"\n`,
encoding: 'utf-8',
});
if (r.status !== 0) {
throw new Error(`security add-generic-password exited ${r.status}: ${(r.stderr ?? '').trim()}`);
}
}

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.

🟨 macOS credentials become readable by any user-level process

On macOS the login credentials are now stored and read through /usr/bin/security (src/lib/darwin-keychain.ts:23-68, selected in src/lib/credential-store.ts:149-157). Items created this way carry the apple-tool: partition, so any process running as the same user can silently read the access/refresh tokens by shelling out to the same tool, losing the per-application ACL isolation the native keyring binding provided. The stored value is only base64-encoded, which is encoding, not protection.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Correct, and deliberate — this is the documented trade-off in the PR description and the module header: app-level isolation is lost on macOS until release binaries are Developer ID signed (in progress; cert acquired). In practice the prior state was weaker: per-release prompts trained users to click Always Allow or deny (breaking auth), and the store already falls back to a plaintext file when the keyring errors. The base64 is for transport through security -i quoting, not protection — noted. Marked in-code for revert to @napi-rs/keyring once signing lands.

Comment on lines +53 to +56
const r = spawnSync(SECURITY, ['-i'], {
input: `add-generic-password -a "${this.account}" -s "${this.service}" -w "${b64}"\n`,
encoding: 'utf-8',
});

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.

🟨 Secret is interpolated unquoted-safe into a security tool command line read from stdin

setPassword builds an interactive security command string by interpolating the service, account and base64 secret into a double-quoted command line fed to /usr/bin/security -i (src/lib/darwin-keychain.ts:53-56). The values are currently module constants (workos-cli/credentials) and base64 output, so no metacharacters can appear, but the pattern is fragile: any future caller passing a service/account containing a quote or backslash would break the argument parsing of the interactive command line.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Fair — hardened in 9438450: the constructor now validates service/account against ^[\w.-]+$ and throws otherwise, so a future caller can't break the interactive parser or smuggle metacharacters. The secret itself is always base64 so it never needs quoting.

The config keychain item (workos-cli/config) had the same two problems as
credentials: native-binding reads pinned to the per-release ad-hoc
signature, and no in-process cache despite getConfig() running several
times per command. This was observable: with the credential fix in place,
a rebuilt binary still prompted once — for the config item.
Also hardens DarwinSecurityEntry: service/account are validated against a
safe charset since they are interpolated into a `security -i` command
line (review feedback).
… stores
credential-store and config-store were ~90% duplicated (backend selection,
in-process cache, file fallback, one-shot migration, warnings). Both are
now thin wrappers around one KeyringStore<T> parameterized by the parts
that actually differ: account/file names, validation (credentials must
read as logged-out when malformed), and save read-back verification
(config only). Public APIs unchanged; net -180 lines.
@nicknisi
nicknisi merged commit d8f1fbc into mainAug 19, 2026
5 checks passed
@nicknisi
nicknisi deleted the fix/macos-keychain-prompts branch August 19, 2026 01:29
@github-actionsgithub-actionsBot mentioned this pull request Aug 19, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@nicknisi