Skip to content

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

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

l2ysho wants to merge 1 commit 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;DRauth.json was a flat blob describing one account, holding the whole user('me') response. It is now { version, activeProfile, profiles, secretsBackend }, keyed by user ID, with a migration for the three states in the wild. No UX change: the file can hold N profiles, nothing puts a second one there yet.

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 before either write. 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 reads a v2 file, finds no token, and reports logged out. Worth a release note; auth.json.v1.bak is the manual way back.

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.
  • 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 five test files that parsed auth.json by hand now read a profile through it or through getLocalUserInfo().
  • 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>
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