Skip to content

feat: key secrets by user ID on both backends - #1450

Draft
l2ysho wants to merge 2 commits into
claude/auth-json-v2-1419from
claude/secret-storage-v2-1420
Draft

l2ysho wants to merge 2 commits into
claude/auth-json-v2-1419from
claude/secret-storage-v2-1420

Conversation

@l2ysho

@l2ysho l2ysho commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

Note

TL;DR — Secrets sat under one fixed name per kind, so a second account would overwrite the first one's token. Both backends are now keyed by user ID — keyring services com.apify.cli.token / com.apify.cli.proxy-password with the user ID as the account, and the file backend inside the matching profile object. Existing secrets are re-keyed in place. No UX change.

Stacked on #1434 (Stage-1, subtask 4). Base branch is claude/auth-json-v2-1419, not master.

Closes #1420. Part of #1383.

Change

Before After
keyring com.apify.cli / token, proxy-password com.apify.cli.token, com.apify.cli.proxy-password / <userId>
file auth.json.token, auth.json.proxy.password inside profiles[<userId>]

Service per kind, not a composite account. token:<userId> under one service would depend on : being legal in an account name on macOS Keychain, libsecret and Windows Credential Manager, and it reads worse in Keychain Access.

getToken / setToken / getProxyPassword / setProxyPassword collapse into getSecret(userId, kind) and setSecret(userId, kind, value). One implementation, and the key is visible at every call site.

Both backends in one PR, because downgradeBackendToFile() flips the backend inside a single process. Split across two releases, a downgrade would write the secret under a name the next read does not look for.

Migration

ensureSecretsKeyed() is a separate step from the two that already exist. A v2 file whose secrets still sit under the old names is a supported state — every user is in it between #1434's release and this one — so the two migrations stay independent.

  • Write the new entry, verify it reads back, then delete the old one. The reverse order loses the secret when the delete succeeds and the write does not.
  • File backend: one atomic write moves the secrets into the profile and clears the top level.
  • A keyring write failure downgrades to the file backend, and the rest of the loop follows it there rather than writing under a keyring name nothing will read.
  • Idempotent, single-flight, never throws.

New ensureCredentialsCurrent() pins the order the three migrations have to run in: v1 secrets out of auth.json, then the v2 profile shape, then the per-user keys — the third needs the user ID the second writes. It replaces two hand-sequenced calls in utils.ts and auth.ts.

Behavior worth calling out

  • logout reordered. auth.json is the only index of what the keyring holds, so removing the profile first would strand its entries. It now asserts the file version, clears the keyring, then removes the profile — the version refusal still comes before anything is deleted. Legacy fixed-name entries are still deleted too, so logout works for anyone who never re-keyed.
  • Switching accounts clears the outgoing account's entries. loginWithToken() replaces the stored profile wholesale, so without this the previous user's keyring entries would be unreachable forever — logout only ever clears the active profile. Caught in review; three regression tests cover it.
  • A v1 file with a token but no id has no key to file the secret under. The secret is dropped and the next command asks for a re-login; auth.json.v1.bak still holds it. No fallback read of the legacy key, and no network lookup of the user ID — the migration stays offline and non-blocking.
  • getLocalUserInfo()'s "Stale credentials found without user metadata" branch is gone. It was the error for exactly the state above, and dropping the secret makes it unreachable. A dangling activeProfile now reports the missing profile by name whether or not a secret was found — previously it could only tell when a token happened to be readable.
  • A hand-deleted auth.json still strands keyring entries. The keyring has no listing API, and reaching for fixed names on a machine with no account would touch the keyring on every command. Asserted in a test so the trade-off is on the record.

Verification

  • pnpm run test:local648 passed, 4 skipped (63 files), up 14 from the base.
  • pnpm run lint, pnpm run format, pnpm run build — clean.
  • pnpm run update-docs — no change; no flag, arg, description or registration moved.
  • pnpm run test:api not run — no token in this environment.
  • The keyring mock keys entries as ${service}:${account}; com.apify.cli.token:uid and com.apify.cli:token are distinct strings, so the new names do not collide with the legacy ones in that map.
  • Covered: old key → new key on the keyring; top-level secret → profile on the file backend; keyring write failing mid-migration; APIFY_DISABLE_KEYRING toggled between login and logout; logout leaving other profiles alone; two accounts holding their own entries; idempotency and single-flight.
  • Install size unchanged — no dependency added or removed.

Not tested by hand: the macOS Keychain prompt. Creating an item under a new service may prompt, and this migration runs on the first command after the upgrade. Worth one manual check before release; if it prompts, re-key at next login instead of at first command.

Left out

  • No additive login, no --profile, no auth switch, no auth list.
  • clearKeyringSecrets() iterates from auth.json as the issue asks, but only ever sees one profile today — Stage-2 (Stage-2: Login - token multi account support #1386) is what puts a second one there.

🤖 Generated with Claude Code

l2ysho and others added 2 commits September 11, 2026 16:38
auth.json was a flat blob describing one account, holding the whole
user('me') response. It is now { version, activeProfile, profiles,
secretsBackend }, so it can hold N accounts. Nothing puts a second one
there yet, and users see no change.

New src/lib/auth-file.ts owns the file: reading, an atomic write, the
v1 to v2 migration, and the profile accessors. credentials.ts, login,
logout, getLocalUserInfo() and the rental notice all go through it.

The migration backs the old file up as auth.json.v1.bak, runs after
ensureMigrated() as a separate step, is idempotent and single-flight,
and never throws. Fields nothing reads are dropped: email, plan,
effectivePlatformFeatures, isPaying, createdAt and proxy.groups.

Closes #1419

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Secrets lived under one fixed name per kind, so a second account would
overwrite the first one's token. Both the keyring and the file backend
are now keyed by user ID, and existing secrets are re-keyed in place.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@l2ysho l2ysho added the t-builders Issues owned by the Builders team. label Sep 17, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

t-builders Issues owned by the Builders team.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants