Skip to content

feat: auth.json v2 with profiles keyed by user ID - #1434

Draft
l2ysho wants to merge 4 commits into
claude/token-resolution-1418from
claude/auth-json-v2-1419
Draft

l2ysho wants to merge 4 commits into
claude/token-resolution-1418from
claude/auth-json-v2-1419

Conversation

@l2ysho

@l2ysho l2ysho commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

Note

TL;DR — TBD

Stacked on #1431 (Stage-1, subtask 2). Base branch is claude/token-resolution-1418, not master.

What changed

New src/lib/auth-file.ts owns the file — reading, writing, migrating, and the profile accessors. credentials.ts, login, logout, getLocalUserInfo() and the rental-sunset notice all go through it, so no caller parses auth.json by hand any more.

{
  "version": 2,
  "activeProfile": "<userId>",
  "profiles": {
    "<userId>": {
      "username": "moria",
      "name": null,                    // reserved for `--profile <name>`
      "organizationOwnerUserId": "",  // set => organization profile
      "authMethod": "token",           // reserved, device flow
      "expiresAt": null,               // reserved
      "hasRefreshToken": false         // reserved
    }
  },
  "secretsBackend": "keyring"
}
  • Keyed by userId, not a display name. The key names the keyring entry in Secret storage v2 #1420, so a rename must never orphan a secret.
  • Secrets keep their v1 location — the keyring, or top-level token/proxy.password on the file backend. Per-profile secret keys are Secret storage v2 #1420; putting them in the profile now would force a second migration.
  • The reserved fields are written and never read. Leaving any of them out forces a v3 migration when the device flow lands.
  • Fields nothing reads are dropped: email, plan, effectivePlatformFeatures, isPaying, createdAt, and proxy.groups. email is also gone from the AuthJSON type, where it was declared and never used.
  • getLocalUserInfo() keeps its return shape, so its call sites are untouched.
  • Writes are atomic — temp file plus rename, 0600. Two CLI processes can run at once. A read-modify-write pair still races and last write wins; that is accepted, not locked around.

Migration

Runs after ensureMigrated() as a separate step, so a keyring failure and a shape failure cannot mask each other. Idempotent, single-flight, and it never throws — a migration failure must not block a command.

State in the wild auth.json keyring Result
A plaintext secrets, no marker empty v2, secrets wherever ensureMigrated() put them
B no secrets, marker keyring token, proxy-password v2, keyring untouched
C plaintext secrets, marker file empty v2, secrets stay in the file
  • The old file is backed up as auth.json.v1.bak, and an existing backup is never overwritten.
  • A corrupt file is left alone rather than rewritten — readers already treat it as logged out, and rewriting would destroy what is still recoverable by hand.
  • A v1 file with a token but no id has no key to store a profile under. The secrets and the .bak are kept and the next command asks for a re-login; no key is invented.
  • A file whose version is higher than 2 is refused with a clear error on read, and by both profile writers. No reverse migration.

Behavior worth calling out

  • apify logout routes through the store instead of rimraf-ing the file. It drops the active profile and its plaintext secrets, and removes the file once no profile is left — same observable result today, and it does not need rewriting when auth switch arrives. It also removes auth.json.v1.bak, which would otherwise leave a plaintext token on disk after a logout.
  • An older CLI keeps working on a v2 file — no downgrade break. Verified by hand against apify-cli@1.10.0 on the file backend: apify info printed the right username and user ID. It finds the top-level token, and its per-command auth.json refresh — the write feat: one token resolution order across every command #1431 removes — merges the user('me') response back in, restoring the flat id/username it needs. The file then carries both shapes and both CLIs read it. On the keyring backend the secretsBackend marker survives the migration, so the same path should hold; not verified, no keyring entry on the test machine. No downgrade release note needed.

Deviation from the issue

The issue specified that a dangling activeProfile should be "treated as logged out". It now throws a clear error naming the missing profile when a token is present, and reports logged out only when there is nothing to authenticate with. Returning {} while resolveAuth() still resolves the top-level token left ~9 commands building `${undefined}/${name}` and failing with a misleading Actor with name "x" was not found — and the adjacent "no activeProfile at all" state, which a user cannot tell apart, already threw. Only reachable from a hand-edited file; no writer produces it.

Verification

  • pnpm run test:local634 passed, 4 skipped (63 files), up 3 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. log_in_out.test.ts and info.test.ts asserted the v1 flat shape and could not have passed against a v2 file; both were updated, but only by reading, not by running.
  • 16 new tests in test/local/lib/auth-file.test.ts: A/B/C on both backends, idempotency on an already-v2 file, the backup guard, the dropped fields, a corrupt file, a v1 file with no id, a dangling activeProfile, and a version: 3 file surviving both a login and a logout byte-for-byte.
  • New test/__setup__/auth-file.ts helper. The test/local/ files that parsed auth.json by hand now read a profile through it or through getLocalUserInfo(), as do the two API tests that asserted the v1 shape.
  • Install size unchanged — no dependency added or removed (package.json and pnpm-lock.yaml are identical to the base branch).

Requires Node ≥22 to run pnpm 11 locally.

Left out, deliberately

Closes #1419

🤖 Generated with Claude Code

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>
l2ysho and others added 3 commits September 17, 2026 14:06
Both parsed auth.json by hand and asserted the v1 flat shape, so neither
could pass against a v2 file. log_in_out deep-equalled the file against
the whole user('me') response, which v2 deliberately no longer stores;
info read a top-level id that is now the profile key.

Both now read the active profile through the test helper, and
log_in_out checks the token through getToken() rather than the file.

Not run here — test:api needs a live token.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
copyFileSync inherits the source mode. An auth.json written before the
CLI started passing mode 0600 is still 0644, and writeFileSync's mode
applies only on create, so it stayed that way. The new atomic write
fixes auth.json on the first v2 write, but the backup is copied before
that and never rewritten — leaving a plaintext token at 0644.

Also fixes two tests: apify info prints three rows since the token
source line landed, and the idempotency check called the migration
twice without resetting the memoised promise, so the second call never
touched the file.

Adds the missing cover for logout removing the backup, which is the
only path that erases that token from disk.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The backup is written once and never refreshed, and only logout deletes
it. So after `apify login` as a second account, auth.json holds the new
token while auth.json.v1.bak still holds the previous one — for as long
as the user never logs out. Nothing reads the backup, and a downgraded
CLI finds its token through the keyring or auth.json rather than here,
so the secrets are dropped when writing it.

Also pins the two lines that make the migration run for users. Deleting
`await ensureAuthFileCurrent()` from either resolveAuth() or
getLocalUserInfo() left the whole suite green: every migration test
called it by hand.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants