fix(auth): honor APIFY_TOKEN env var over stored login token - #1293
fix(auth): honor APIFY_TOKEN env var over stored login token#1293l2ysho wants to merge 10 commits into
Conversation
`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>
|
|
||
| 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]; |
There was a problem hiding this comment.
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 toosetToken(…, { 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 goneSo a transient env var permanently mutates the durable login: the apify login account is silently replaced, and the change persists after unset. Two consequences:
- The env and stored tiers stop being independent — using
APIFY_TOKENdestroys the stored login. This also contradicts the intent of the newrun.tsguard's own comment ("must win over the stored login", i.e. without replacing it). - It re-triggers the macOS Keychain write prompt that
skipIfUnchangedwas added to avoid — every command run with a differingAPIFY_TOKENrewrites 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
There was a problem hiding this comment.
you, this is a good catch and I am wondering how I missed this when I self reviewed 🤔
There was a problem hiding this comment.
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.
|
BTW, noticed the stale name of |
DaveHanns
left a comment
There was a problem hiding this comment.
One possible blocker, otherwise looks good 🚀
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>
|
@vladfrangu I really want to know what is your opinion about |
…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>
Problem
APIFY_TOKEN=<token> apify runfailed withInsufficient permissions for the Actor run.APIFY_TOKEN=<token> apify actors lslisted Actors from the logged-in account, not from the token's account.apify loginand ignoredAPIFY_TOKEN.What changed
Token order is now the same everywhere:
--token→APIFY_TOKEN→ stored login.resolveTokennow checksAPIFY_TOKEN. This one spot covers ~50 commands.apify runno longer overwrites an inheritedAPIFY_TOKENwhen it builds the Actor's env.APIFY_TOKENand--tokenare one-time now. They are no longer saved over yourapify login. Onlyapify loginsaves.usernameandidalso come from theAPIFY_TOKENaccount. Before, commands used a token from one account and a name from another, and sentundefined/<actor>in CI with no login.apify auth tokenprints the token that is really used.getApifyTokenFromEnvOrAuthFiletogetApifyToken.Good to know
apify runkeeps working without network.apify loginno longer refreshusernameandidinauth.json. They update on the next login.Tests
APIFY_TOKENdoes not overwrite the stored login.APIFY_TOKENaccount. 401/403/409 throw, other errors degrade.[api]:apify runpasses an inheritedAPIFY_TOKENto the Actor.Follow-up
getLoggedClientis named as a getter but also writes. Split is left for a separate PR.🤖 Generated with Claude Code