Skip to content

fix(auth): honor APIFY_TOKEN env var over stored login token - #1293

Open
l2ysho wants to merge 10 commits into
masterfrom
claude/apify-token-permissions-bug-15c25d
Open

fix(auth): honor APIFY_TOKEN env var over stored login token#1293
l2ysho wants to merge 10 commits into
masterfrom
claude/apify-token-permissions-bug-15c25d

Conversation

@l2ysho

@l2ysho l2ysho commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Problem

  • APIFY_TOKEN=<token> apify run failed with Insufficient permissions for the Actor run.
  • APIFY_TOKEN=<token> apify actors ls listed Actors from the logged-in account, not from the token's account.
  • Cause: the CLI used the token from apify login and ignored APIFY_TOKEN.

What changed

Token order is now the same everywhere: --tokenAPIFY_TOKEN → stored login.

  • resolveToken now checks APIFY_TOKEN. This one spot covers ~50 commands.
  • apify run no longer overwrites an inherited APIFY_TOKEN when it builds the Actor's env.
  • APIFY_TOKEN and --token are one-time now. They are no longer saved over your apify login. Only apify login saves.
  • username and id also come from the APIFY_TOKEN account. Before, commands used a token from one account and a name from another, and sent undefined/<actor> in CI with no login.
  • apify auth token prints the token that is really used.
  • Renamed getApifyTokenFromEnvOrAuthFile to getApifyToken.

Good to know

  • A bad token fails on 401, 403 and 409. Other errors (offline, API down) skip the identity lookup instead, so apify run keeps working without network.
  • Commands other than apify login no longer refresh username and id in auth.json. They update on the next login.

Tests

  • Token order, and that APIFY_TOKEN does not overwrite the stored login.
  • Identity comes from the APIFY_TOKEN account. 401/403/409 throw, other errors degrade.
  • [api]: apify run passes an inherited APIFY_TOKEN to the Actor.

Follow-up

  • getLoggedClient is named as a getter but also writes. Split is left for a separate PR.

🤖 Generated with Claude Code

`APIFY_TOKEN=<token> apify run` and other commands silently ignored the
env var and used the token from `apify login` instead, so runs failed with
"Insufficient permissions" and `apify actors ls` returned the wrong account.

Two independent causes, same symptom:

- `resolveToken` (src/lib/utils.ts) resolved only an explicit token arg then
  the stored token, never `process.env.APIFY_TOKEN`. This choke point feeds
  ~50 commands via getLoggedClient/getApifyClientOptions. Precedence is now:
  explicit `--token` > `APIFY_TOKEN` > stored login.

- `apify run` injected the stored token into the child env after `process.env`
  (`{ ...process.env, ...localEnvVars }`), clobbering an inherited APIFY_TOKEN.
  The merge order was flipped in #1042 so the input-key redirect vars win; that
  flip also caught the token as collateral. Now the stored token only fills in
  when APIFY_TOKEN isn't already inherited, leaving #1042's behavior intact.

Adds local unit tests for the precedence at getApifyClientOptions and an
[api] regression test proving `apify run` doesn't override an inherited token.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@l2ysho
l2ysho requested a review from DaveHanns as a code owner July 21, 2026 14:01
@github-actions github-actions Bot added this to the 145th sprint - Tooling team milestone Jul 21, 2026
@github-actions github-actions Bot added t-tooling Issues with this label are in the ownership of the tooling team. tested Temporary label used only programatically for some analytics. labels Jul 21, 2026
@l2ysho l2ysho added adhoc Ad-hoc unplanned task added during the sprint. t-builders Issues owned by the Builders team. and removed t-tooling Issues with this label are in the ownership of the tooling team. labels Jul 21, 2026
Comment thread src/lib/utils.ts

const resolveToken = async (existingToken?: string): Promise<string | undefined> => {
if (existingToken) return existingToken;
if (process.env[APIFY_ENV_VARS.TOKEN]) return process.env[APIFY_ENV_VARS.TOKEN];

@DaveHanns DaveHanns Jul 29, 2026

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.

APIFY_TOKEN now silently overwrites the stored apify login credentials

The read-side precedence added here is correct, but this line has a downstream side effect that I think makes it a blocker.

Because resolveToken() now returns process.env.APIFY_TOKEN, getLoggedClient(), reached by ~all authenticated commands via getLoggedClientOrThrow(), then persists it:

// src/lib/utils.ts, getLoggedClient()
const resolvedToken = await resolveToken(token);          // ← now the APIFY_TOKEN value

if (apifyClient.token) {
    await setToken(apifyClient.token, { skipIfUnchanged: true });   // ← writes it to keyring/auth.json
}

writeFileSync(AUTH_FILE_PATH(), JSON.stringify({ ...existingFile, ...userInfo }));  // ← rewrites username/id too

setToken(…, { skipIfUnchanged: true }) writes whenever the value differs from what's stored. Pre-PR, resolvedToken was always the stored token, so this was a no-op. Now:

apify login --token <A>   # stored login = account A
export APIFY_TOKEN=<B>    # a different, valid token
apify actors ls           # reads B (correct) — but setToken overwrites stored A with B,
                          #   and auth.json username/id are rewritten to account B
unset APIFY_TOKEN
apify actors ls           # resolveToken → getToken() → returns B  ← login A is gone

So a transient env var permanently mutates the durable login: the apify login account is silently replaced, and the change persists after unset. Two consequences:

  1. The env and stored tiers stop being independent — using APIFY_TOKEN destroys the stored login. This also contradicts the intent of the new run.ts guard's own comment ("must win over the stored login", i.e. without replacing it).
  2. It re-triggers the macOS Keychain write prompt that skipIfUnchanged was added to avoid — every command run with a differing APIFY_TOKEN rewrites the keyring.

Suggested fix: in getLoggedClient, only call setToken / setProxyPassword when the token came from an explicit apify login, not when it originated from APIFY_TOKEN or --token flag of command other than apify login (the --token on other commands should be one-time overwrite as well), mirroring the guard already added in run.ts:332.

(Heads-up: if you make this change, apify auth token will then print the stored token while other commands use the env token — it reads getLocalUserInfo().token directly and only looks correct today because of this overwrite. It'd need to become env-aware too.)

🤖 Generated with Claude Code

@l2ysho l2ysho Jul 29, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

you, this is a good catch and I am wondering how I missed this when I self reviewed 🤔

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

also I am inspecting getLoggedClient() and there are few things I really do not like (for example it is doing mutations but name suggests it is only get), I will create a follow up issue if I find it is worth of it.

@DaveHanns

Copy link
Copy Markdown
Contributor

BTW, noticed the stale name of getApifyTokenFromEnvOrAuthFile. It no longer gets the token just from Auth file, but by default from keyring. We should simplify and correct the name.

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

One possible blocker, otherwise looks good 🚀

l2ysho and others added 4 commits July 29, 2026 13:20
Addresses review on #1293: making resolveToken() return APIFY_TOKEN meant
getLoggedClient() then persisted it, because every authenticated command
reaches setToken(..., { skipIfUnchanged: true }) and the auth.json rewrite.
A transient env var permanently replaced the `apify login` account (and
re-triggered the macOS Keychain prompt skipIfUnchanged exists to avoid).

- getLoggedClient() takes `persistCredentials` (default false) and returns
  early before writing secrets or user metadata. Only `apify login` opts in,
  so --token / APIFY_TOKEN stay one-time overrides. The auth.json write is
  gated too, not just the secrets: userInfo carries username/id, so an
  override would otherwise swap the stored login's identity.
- `apify auth token` printed getLocalUserInfo().token, which only looked
  right because of the overwrite. It now resolves the same way the other
  commands authenticate, so it reports the token actually in use.
- Rename getApifyTokenFromEnvOrAuthFile -> getApifyToken: the name was stale
  (secrets come from the keyring by default now), and its env->stored chain
  duplicated resolveToken(), which it now delegates to.

Tests: getLoggedClient() leaves the stored token and username/id intact for
both APIFY_TOKEN and an explicit token, and persists only with
persistCredentials. apify-client is stubbed so this needs no API access.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ug-15c25d' into claude/apify-token-permissions-bug-15c25d

# Conflicts:
#	src/commands/auth/login.ts
`resolveToken` honors `APIFY_TOKEN`, but `username`/`id` still came from
auth.json — i.e. whoever ran `apify login`. Commands that build an Actor
name from the local identity (`builds create`, `actors push`, `task run`,
all storage commands, ...) therefore authenticated as one account and
resolved names against another, and in CI with no stored login at all they
sent the literal string "undefined/<actor>" to the API.

`getLocalUserInfo` now reads the identity from `user('me')` whenever
`APIFY_TOKEN` is set. `getLoggedClient` seeds a token-keyed cache with the
user info it already fetches, so commands that get a client first pay no
extra API call.

This also gives `apify run` the override account's proxy password and user
id, instead of pairing the inherited token with the stored account's
credentials.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@l2ysho

l2ysho commented Jul 31, 2026

Copy link
Copy Markdown
Contributor Author

@vladfrangu I really want to know what is your opinion about resolveToken function. There are multiple scenarios (APIFY_TOKEN, --token, normal auth, tests) and when I fix one I break some other. On a first look It feelslike good candidate for a deep refactoring. WDYT?

l2ysho and others added 4 commits August 10, 2026 12:47
…used

Resolving the identity from the APIFY_TOKEN account put a network call inside
getLocalUserInfo(), which threw on any failure. `apify run` and `mcp install`
call it and need no network, so `APIFY_TOKEN=<token> apify run` broke offline
or on any API hiccup — the command from the original report.

HTTP 401/403/409 mean the token itself was refused and still fail loudly, with
the status code and a mention of permissions in the message (403 is a valid
token without the required rights, which the old "is it still valid?" wording
got wrong). Everything else degrades to no identity, leaving the existing
"not logged in" handling to deal with it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The test fabricated `${auth.token}_inherited` as the env token. That was inert
until the CLI started validating APIFY_TOKEN against the API, at which point
the fake token 401s, getLocalUserInfo() throws, and the command aborts.

The abort was invisible: _run() catches everything from run(), prints it and
does not rethrow, and useConsoleSpy swallows the print. So the test read the
OUTPUT.json left by the preceding test and reported a token mismatch instead
of the real failure — expected "***_inherited", received "***".

Swap which side is fake. The stored login gets a junk token (never validated
while APIFY_TOKEN is set) and APIFY_TOKEN gets the real test token, since CI
only has one valid token and the env token is the one that has to work. Still
fails without the fix: on master the Actor receives the junk stored token.

Also clear OUTPUT.json before the run and assert it exists afterwards, so an
aborted run can no longer pass off an earlier test's dump as its own result.

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

adhoc Ad-hoc unplanned task added during the sprint. t-builders Issues owned by the Builders team. tested Temporary label used only programatically for some analytics.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants