From b7c1453ad2abb69894e1e95344e553225edb130d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Richard=20Sol=C3=A1r?= Date: Tue, 21 Jul 2026 16:01:21 +0200 Subject: [PATCH 1/3] fix(auth): honor APIFY_TOKEN env var over stored login token `APIFY_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 --- src/commands/run.ts | 3 ++- src/lib/utils.ts | 1 + test/local/commands/run.test.ts | 27 +++++++++++++++++++++++++++ test/local/lib/credentials.test.ts | 28 +++++++++++++++++++++++++++- 4 files changed, 57 insertions(+), 2 deletions(-) diff --git a/src/commands/run.ts b/src/commands/run.ts index 050e3e3c6..2e9f5c8a3 100644 --- a/src/commands/run.ts +++ b/src/commands/run.ts @@ -328,7 +328,8 @@ export class RunCommand extends ApifyCommand { if (proxy && proxy.password) localEnvVars[APIFY_ENV_VARS.PROXY_PASSWORD] = proxy.password; if (userId) localEnvVars[APIFY_ENV_VARS.USER_ID] = userId; - if (token) localEnvVars[APIFY_ENV_VARS.TOKEN] = token; + // Don't clobber an explicitly-set APIFY_TOKEN inherited from the environment — it must win over the stored login. + if (token && !process.env[APIFY_ENV_VARS.TOKEN]) localEnvVars[APIFY_ENV_VARS.TOKEN] = token; if (localConfig!.environmentVariables) { const updatedEnv = replaceSecretsValue(localConfig!.environmentVariables as Record, undefined, { allowMissing: this.flags.allowMissingSecrets, diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 99d8784e8..ae00a50b1 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -131,6 +131,7 @@ export async function getLoggedClientOrThrow() { const resolveToken = async (existingToken?: string): Promise => { if (existingToken) return existingToken; + if (process.env[APIFY_ENV_VARS.TOKEN]) return process.env[APIFY_ENV_VARS.TOKEN]; await ensureMigrated(); return getToken(); }; diff --git a/test/local/commands/run.test.ts b/test/local/commands/run.test.ts index bc4e26323..bd438ab92 100644 --- a/test/local/commands/run.test.ts +++ b/test/local/commands/run.test.ts @@ -216,6 +216,33 @@ describe('apify run', () => { expect(actOutput2).toStrictEqual('can play'); }); + // Regression: an inherited APIFY_TOKEN must reach the Actor unchanged, not be clobbered by the stored login token. + it(`[api] does not override an inherited ${APIFY_ENV_VARS.TOKEN} with the stored token`, async () => { + await safeLogin(); + + const auth = JSON.parse(readFileSync(AUTH_FILE_PATH(), 'utf8')); + const inheritedToken = `${auth.token}_inherited`; + vitest.stubEnv(APIFY_ENV_VARS.TOKEN, inheritedToken); + + const actCode = ` + import { Actor } from 'apify'; + + Actor.main(async () => { + await Actor.setValue('OUTPUT', process.env); + console.log('Done.'); + }); + `; + writeFileSync(joinPath('src/main.js'), actCode, { flag: 'w' }); + + await testRunCommand(RunCommand, {}); + + const actOutputPath = joinPath(getLocalKeyValueStorePath(), 'OUTPUT.json'); + const localEnvVars = JSON.parse(readFileSync(actOutputPath, 'utf8')); + + expect(localEnvVars[APIFY_ENV_VARS.TOKEN]).toStrictEqual(inheritedToken); + expect(localEnvVars[APIFY_ENV_VARS.TOKEN]).not.toStrictEqual(auth.token); + }); + it('run purge stores', async () => { const input = { myInput: 'value', diff --git a/test/local/lib/credentials.test.ts b/test/local/lib/credentials.test.ts index 4a0566ed8..4b78727fa 100644 --- a/test/local/lib/credentials.test.ts +++ b/test/local/lib/credentials.test.ts @@ -14,7 +14,7 @@ import { setProxyPassword, setToken, } from '../../../src/lib/credentials.js'; -import { getLocalUserInfo } from '../../../src/lib/utils.js'; +import { getApifyClientOptions, getLocalUserInfo } from '../../../src/lib/utils.js'; const keyringStore = new Map(); const keyringFailures = new Set(); @@ -278,4 +278,30 @@ describe('credentials', () => { expect(info.proxy?.password).toBe('pw_kr'); }); }); + + // Precedence: explicit token arg (e.g. --token) > APIFY_TOKEN env var > stored login token. + // Regression guard for the env var being ignored in favour of the stored token. + describe('token resolution precedence (getApifyClientOptions)', () => { + it('prefers the APIFY_TOKEN env var over the stored token', async () => { + vitest.stubEnv('APIFY_DISABLE_KEYRING', '1'); + await setToken('stored_tok'); + vitest.stubEnv('APIFY_TOKEN', 'env_tok'); + const { token } = await getApifyClientOptions(); + expect(token).toBe('env_tok'); + }); + + it('prefers an explicitly passed token over the APIFY_TOKEN env var', async () => { + vitest.stubEnv('APIFY_TOKEN', 'env_tok'); + const { token } = await getApifyClientOptions('explicit_tok'); + expect(token).toBe('explicit_tok'); + }); + + it('falls back to the stored token when APIFY_TOKEN is not set', async () => { + vitest.stubEnv('APIFY_DISABLE_KEYRING', '1'); + vitest.stubEnv('APIFY_TOKEN', ''); + await setToken('stored_tok'); + const { token } = await getApifyClientOptions(); + expect(token).toBe('stored_tok'); + }); + }); }); From 9e65803ed53c2161dcee0baa97f6f8c294cf808a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Richard=20Sol=C3=A1r?= Date: Wed, 29 Jul 2026 13:20:56 +0200 Subject: [PATCH 2/3] fix(auth): don't persist one-time token overrides over the stored login 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 --- src/commands/actor/charge.ts | 4 +- src/commands/auth/login.ts | 2 +- src/commands/auth/token.ts | 11 +++--- src/lib/actor.ts | 19 ++++------ src/lib/utils.ts | 26 +++++++++++-- test/local/lib/credentials.test.ts | 59 +++++++++++++++++++++++++++++- 6 files changed, 96 insertions(+), 25 deletions(-) diff --git a/src/commands/actor/charge.ts b/src/commands/actor/charge.ts index dc2bd9ebb..b12a422a3 100644 --- a/src/commands/actor/charge.ts +++ b/src/commands/actor/charge.ts @@ -1,6 +1,6 @@ import { APIFY_ENV_VARS } from '@apify/consts'; -import { getApifyTokenFromEnvOrAuthFile } from '../../lib/actor.js'; +import { getApifyToken } from '../../lib/actor.js'; import { ApifyCommand } from '../../lib/command-framework/apify-command.js'; import { Args } from '../../lib/command-framework/args.js'; import { Flags } from '../../lib/command-framework/flags.js'; @@ -86,7 +86,7 @@ export class ActorChargeCommand extends ApifyCommand return; } - const apifyToken = await getApifyTokenFromEnvOrAuthFile(); + const apifyToken = await getApifyToken(); const apifyClient = await getLoggedClient(apifyToken); if (!apifyClient) { throw new Error('Apify token is not set. Please set it using the environment variable APIFY_TOKEN.'); diff --git a/src/commands/auth/login.ts b/src/commands/auth/login.ts index ceb1498d3..6ebf92816 100644 --- a/src/commands/auth/login.ts +++ b/src/commands/auth/login.ts @@ -30,7 +30,7 @@ const API_BASE_URL = CONSOLE_BASE_URL.includes('localhost') ? 'http://localhost: const API_VERSION = 'v1'; const tryToLogin = async (token: string) => { - const isUserLogged = await getLoggedClient(token, API_BASE_URL); + const isUserLogged = await getLoggedClient(token, API_BASE_URL, { persistCredentials: true }); const userInfo = await getLocalUserInfo(); if (isUserLogged) { diff --git a/src/commands/auth/token.ts b/src/commands/auth/token.ts index c2a307e88..4f962380a 100644 --- a/src/commands/auth/token.ts +++ b/src/commands/auth/token.ts @@ -1,6 +1,6 @@ import { ApifyCommand } from '../../lib/command-framework/apify-command.js'; import { simpleLog } from '../../lib/outputs.js'; -import { getLocalUserInfo, getLoggedClientOrThrow } from '../../lib/utils.js'; +import { getLoggedClientOrThrow, resolveToken } from '../../lib/utils.js'; export class AuthTokenCommand extends ApifyCommand { static override name = 'token' as const; @@ -9,7 +9,7 @@ export class AuthTokenCommand extends ApifyCommand { static override examples = [ { - description: 'Print the stored API token to stdout (use with care — it is a secret).', + description: 'Print the API token in use to stdout (use with care — it is a secret).', command: 'apify auth token', }, ]; @@ -18,10 +18,11 @@ export class AuthTokenCommand extends ApifyCommand { async run() { await getLoggedClientOrThrow(); - const userInfo = await getLocalUserInfo(); + // Must match what the other commands actually authenticate with, so APIFY_TOKEN wins over the stored login. + const token = await resolveToken(); - if (userInfo.token) { - simpleLog({ message: userInfo.token, stdout: true }); + if (token) { + simpleLog({ message: token, stdout: true }); } } } diff --git a/src/lib/actor.ts b/src/lib/actor.ts index f0823c2c7..950bd7c6e 100644 --- a/src/lib/actor.ts +++ b/src/lib/actor.ts @@ -9,7 +9,7 @@ import { ApifyClient } from 'apify-client'; import { ACTOR_ENV_VARS, APIFY_ENV_VARS, KEY_VALUE_STORE_KEYS, LOCAL_ACTOR_ENV_VARS } from '@apify/consts'; -import { getApifyClientOptions, getLocalStorageDir, getLocalUserInfo } from './utils.js'; +import { getApifyClientOptions, getLocalStorageDir, resolveToken } from './utils.js'; export const APIFY_STORAGE_TYPES = { KEY_VALUE_STORE: 'KEY_VALUE_STORE', @@ -18,23 +18,18 @@ export const APIFY_STORAGE_TYPES = { } as const; /** - * Returns Apify token from environment variable or local auth file. + * Returns the Apify token to use — the `APIFY_TOKEN` env var, else the token stored by `apify login`. * @returns Apify token */ -export const getApifyTokenFromEnvOrAuthFile = async () => { - const apifyToken = process.env[APIFY_ENV_VARS.TOKEN]; - if (apifyToken) { - return apifyToken; - } - - const localUserInfo = await getLocalUserInfo(); - if (!localUserInfo || !localUserInfo.token) { +export const getApifyToken = async () => { + const apifyToken = await resolveToken(); + if (!apifyToken) { throw new Error( 'Apify token is not set. Please set it using the environment variable APIFY_TOKEN or apify login command.', ); } - return localUserInfo.token; + return apifyToken; }; /** @@ -54,7 +49,7 @@ export const getApifyStorageClient = async ( ...options, }); } - const apifyToken = await getApifyTokenFromEnvOrAuthFile(); + const apifyToken = await getApifyToken(); return new ApifyClient({ ...(await getApifyClientOptions(apifyToken)), diff --git a/src/lib/utils.ts b/src/lib/utils.ts index ae00a50b1..e3083e290 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -129,7 +129,14 @@ export async function getLoggedClientOrThrow() { return loggedClient; } -const resolveToken = async (existingToken?: string): Promise => { +/** + * Resolves the token to use, in order: explicitly passed token (e.g. `--token`) > + * `APIFY_TOKEN` env var > the token stored by `apify login`. + * + * The first two are one-time overrides and must never be written over the stored login — + * see the `persistCredentials` option of {@link getLoggedClient}. + */ +export const resolveToken = async (existingToken?: string): Promise => { if (existingToken) return existingToken; if (process.env[APIFY_ENV_VARS.TOKEN]) return process.env[APIFY_ENV_VARS.TOKEN]; await ensureMigrated(); @@ -164,10 +171,17 @@ export const getApifyClientOptions = async (token?: string, apiBaseUrl?: string) /** * Gets instance of ApifyClient for token or for params from global auth file. * - * Refreshes the user metadata in auth.json each run. Secrets (token, proxy.password) only - * get written when their value actually changes — avoids macOS Keychain prompts on every command. + * Secrets (token, proxy.password) and user metadata are only written when `persistCredentials` + * is set — i.e. from `apify login`. Every other caller resolves a possibly-overridden token + * (`--token`, `APIFY_TOKEN`), and persisting that would silently replace the stored login. + * When writing, secrets only change on disk if their value differs — avoids macOS Keychain + * prompts on every command. */ -export async function getLoggedClient(token?: string, apiBaseUrl?: string) { +export async function getLoggedClient( + token?: string, + apiBaseUrl?: string, + { persistCredentials = false }: { persistCredentials?: boolean } = {}, +) { const resolvedToken = await resolveToken(token); const apifyClient = new ApifyClient(await getApifyClientOptions(resolvedToken, apiBaseUrl)); @@ -180,6 +194,10 @@ export async function getLoggedClient(token?: string, apiBaseUrl?: string) { return null; } + if (!persistCredentials) { + return apifyClient; + } + if (apifyClient.token) { await setToken(apifyClient.token, { skipIfUnchanged: true }); } diff --git a/test/local/lib/credentials.test.ts b/test/local/lib/credentials.test.ts index 4b78727fa..f768a1e0a 100644 --- a/test/local/lib/credentials.test.ts +++ b/test/local/lib/credentials.test.ts @@ -14,7 +14,27 @@ import { setProxyPassword, setToken, } from '../../../src/lib/credentials.js'; -import { getApifyClientOptions, getLocalUserInfo } from '../../../src/lib/utils.js'; +import { getApifyClientOptions, getLocalUserInfo, getLoggedClient } from '../../../src/lib/utils.js'; + +// Stubs out the `user('me').get()` round-trip so getLoggedClient() can be tested without the API. +vi.mock('apify-client', () => { + class ApifyClient { + token?: string; + constructor(options: { token?: string }) { + this.token = options.token; + } + user() { + return { + get: async () => ({ + id: `id_for_${this.token}`, + username: `user_for_${this.token}`, + proxy: { password: `pw_for_${this.token}` }, + }), + }; + } + } + return { ApifyClient }; +}); const keyringStore = new Map(); const keyringFailures = new Set(); @@ -304,4 +324,41 @@ describe('credentials', () => { expect(token).toBe('stored_tok'); }); }); + + // A one-time token override (APIFY_TOKEN / --token) must never be written over the durable + // `apify login` credentials — only `apify login` itself persists. + describe('getLoggedClient() credential persistence', () => { + beforeEach(async () => { + vitest.stubEnv('APIFY_DISABLE_KEYRING', '1'); + await setToken('stored_tok'); + writeAuthFile({ ...readAuthFile(), username: 'stored_user', id: 'stored_id' }); + }); + + it('uses APIFY_TOKEN without overwriting the stored login', async () => { + vitest.stubEnv('APIFY_TOKEN', 'env_tok'); + + const client = await getLoggedClient(); + + expect(client?.token).toBe('env_tok'); + expect(await getToken()).toBe('stored_tok'); + expect(readAuthFile().username).toBe('stored_user'); + expect(readAuthFile().id).toBe('stored_id'); + }); + + it('uses an explicitly passed token without overwriting the stored login', async () => { + const client = await getLoggedClient('flag_tok'); + + expect(client?.token).toBe('flag_tok'); + expect(await getToken()).toBe('stored_tok'); + expect(readAuthFile().username).toBe('stored_user'); + }); + + it('persists the token and user metadata when persistCredentials is set (apify login)', async () => { + const client = await getLoggedClient('login_tok', undefined, { persistCredentials: true }); + + expect(client?.token).toBe('login_tok'); + expect(await getToken()).toBe('login_tok'); + expect(readAuthFile().username).toBe('user_for_login_tok'); + }); + }); }); From 5b764bfb0c25a50d800c3c1449a3bfc0f087e5a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Richard=20Sol=C3=A1r?= Date: Wed, 29 Jul 2026 15:38:58 +0200 Subject: [PATCH 3/3] fix(auth): resolve user identity from the APIFY_TOKEN account MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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/" 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 --- src/lib/utils.ts | 40 +++++++++++++++++++++++++++--- test/local/lib/credentials.test.ts | 22 ++++++++++++++++ 2 files changed, 59 insertions(+), 3 deletions(-) diff --git a/src/lib/utils.ts b/src/lib/utils.ts index e3083e290..d0a3482df 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -86,10 +86,19 @@ export const getLocalRequestQueuePath = (storeId?: string) => { /** * Returns object from auth file or empty object. Secrets (token, proxy password) are * pulled from the keyring when that backend is active; user metadata lives in auth.json. + * + * auth.json only ever describes the account `apify login` stored, and `APIFY_TOKEN` exists to act + * as a different one (typically an organization), so the identity is read from the API whenever it + * is set. */ export const getLocalUserInfo = async (): Promise => { await ensureMigrated(); + const overrideToken = process.env[APIFY_ENV_VARS.TOKEN]; + if (overrideToken) { + return { ...(await fetchActiveUserInfo(overrideToken)), token: overrideToken }; + } + let result: AuthJSON = {}; try { const raw = await readFile(AUTH_FILE_PATH(), 'utf-8'); @@ -98,10 +107,10 @@ export const getLocalUserInfo = async (): Promise => { // auth.json may not exist yet (fresh keyring-only state); fall through } - if ((await getBackend()) === 'keyring') { - const token = await getToken(); - if (token) result.token = token; + const storedToken = await getToken(); + if (storedToken) result.token = storedToken; + if ((await getBackend()) === 'keyring') { const proxyPassword = await getProxyPassword(); if (proxyPassword) result.proxy = { ...result.proxy, password: proxyPassword }; } @@ -168,6 +177,27 @@ export const getApifyClientOptions = async (token?: string, apiBaseUrl?: string) }; }; +/** + * User info of the account the process authenticates as, keyed by the token it was fetched with. + * Seeded by {@link getLoggedClient} from the user info it fetches anyway, so commands that get a + * client first (nearly all of them) pay no extra API call. + */ +let activeUserInfo: { token: string; info: AuthJSON } | undefined; + +async function fetchActiveUserInfo(token: string): Promise { + if (activeUserInfo?.token !== token) { + const client = new ApifyClient(await getApifyClientOptions(token)); + try { + activeUserInfo = { token, info: await client.user('me').get() }; + } catch (err) { + cliDebugPrint('[fetchActiveUserInfo] error getting user info', { error: err }); + throw new Error(`The token in ${APIFY_ENV_VARS.TOKEN} was rejected by the Apify API. Is it still valid?`); + } + } + + return activeUserInfo.info; +} + /** * Gets instance of ApifyClient for token or for params from global auth file. * @@ -194,6 +224,10 @@ export async function getLoggedClient( return null; } + if (apifyClient.token) { + activeUserInfo = { token: apifyClient.token, info: userInfo }; + } + if (!persistCredentials) { return apifyClient; } diff --git a/test/local/lib/credentials.test.ts b/test/local/lib/credentials.test.ts index f768a1e0a..0ee9bdc21 100644 --- a/test/local/lib/credentials.test.ts +++ b/test/local/lib/credentials.test.ts @@ -69,6 +69,7 @@ const readAuthFile = () => JSON.parse(readFileSync(AUTH_FILE_PATH(), 'utf-8')); describe('credentials', () => { beforeEach(() => { vitest.stubEnv('__APIFY_INTERNAL_TEST_AUTH_PATH__', cryptoRandomObjectId(12)); + vitest.stubEnv('APIFY_TOKEN', undefined); keyringStore.clear(); keyringFailures.clear(); __resetCredentialsForTests(); @@ -297,6 +298,27 @@ describe('credentials', () => { expect(info.token).toBe('tok_kr'); expect(info.proxy?.password).toBe('pw_kr'); }); + + it('describes the APIFY_TOKEN account, not the stored login', async () => { + vitest.stubEnv('APIFY_DISABLE_KEYRING', '1'); + writeAuthFile({ username: 'stored', id: 'stored_id', token: 'stored_tok', secretsBackend: 'file' }); + vitest.stubEnv('APIFY_TOKEN', 'env_tok_a'); + + const info = await getLocalUserInfo(); + expect(info).toMatchObject({ + username: 'user_for_env_tok_a', + id: 'id_for_env_tok_a', + token: 'env_tok_a', + }); + expect(info.proxy?.password).toBe('pw_for_env_tok_a'); + }); + + it('describes the APIFY_TOKEN account when there is no stored login at all', async () => { + vitest.stubEnv('APIFY_DISABLE_KEYRING', '1'); + vitest.stubEnv('APIFY_TOKEN', 'env_tok_b'); + + expect(await getLocalUserInfo()).toMatchObject({ username: 'user_for_env_tok_b', id: 'id_for_env_tok_b' }); + }); }); // Precedence: explicit token arg (e.g. --token) > APIFY_TOKEN env var > stored login token.