From 8072cee319e8b0361f89fe5b42ee4ee3feb123ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Richard=20Sol=C3=A1r?= Date: Fri, 11 Sep 2026 10:44:35 +0200 Subject: [PATCH 1/4] refactor: stop credential reads from writing credentials `getLoggedClient()` both resolved a token and persisted it. Any command given a token other than the stored one overwrote the stored login with it, and rewrote `username`/`id` to match, so a transient token replaced the saved account. Splits the pair: - `resolveAuth()` in the new `src/lib/auth.ts` reads only. Order is `--token` flag, then stored login. - `loginWithToken()` authenticates and saves. Only `apify login` calls it, and it now replaces the stored account instead of merging into it, so fields the new account lacks cannot linger from the old one. - `getLoggedClient()` verifies the token and returns a client. It writes nothing. Commands that read `username`/`id` to address API resources now go through `getCurrentUserInfo()`, which returns auth.json for a stored token and the account behind a one-off token otherwise. They previously relied on `getLoggedClient()` refreshing auth.json, which is the write that just went away. Co-Authored-By: Claude Opus 5 --- src/commands/actors/call.ts | 4 +- src/commands/actors/pull.ts | 4 +- src/commands/actors/push.ts | 4 +- src/commands/actors/search.ts | 3 +- src/commands/actors/start.ts | 4 +- src/commands/auth/login.ts | 5 +- src/commands/create.ts | 6 +- src/commands/datasets/get-items.ts | 4 +- src/commands/datasets/ls.ts | 4 +- src/commands/info.ts | 4 +- src/commands/key-value-stores/ls.ts | 4 +- src/commands/run.ts | 4 +- src/commands/task/publish.ts | 4 +- src/commands/task/run.ts | 4 +- src/commands/task/unpublish.ts | 4 +- src/lib/actor.ts | 3 +- src/lib/auth.ts | 109 ++++++++++++++++++ src/lib/commands/resolve-actor-context.ts | 4 +- src/lib/commands/storages.ts | 4 +- src/lib/utils.ts | 107 ++++++----------- test/__setup__/config.ts | 2 +- test/__setup__/hooks/useAuthSetup.ts | 7 +- test/e2e/commands/builds/lifecycle.test.ts | 2 +- test/e2e/commands/datasets/lifecycle.test.ts | 2 +- .../key-value-stores/lifecycle.test.ts | 2 +- test/e2e/commands/runs/lifecycle.test.ts | 2 +- test/local/commands/auth.test.ts | 4 +- test/local/lib/credentials.test.ts | 3 +- 28 files changed, 198 insertions(+), 115 deletions(-) create mode 100644 src/lib/auth.ts diff --git a/src/commands/actors/call.ts b/src/commands/actors/call.ts index e5f31e25d..01363f7ba 100644 --- a/src/commands/actors/call.ts +++ b/src/commands/actors/call.ts @@ -20,7 +20,7 @@ import { runActorOrTaskOnCloud, SharedRunOnCloudFlags } from '../../lib/commands import { finalizeRun, runUrl } from '../../lib/commands/run-result.js'; import { CommandExitCodes, LOCAL_CONFIG_PATH } from '../../lib/consts.js'; import { error, simpleLog } from '../../lib/outputs.js'; -import { getLocalConfig, getLocalUserInfo, getLoggedClientOrThrow, TimestampFormatter } from '../../lib/utils.js'; +import { getLocalConfig, getCurrentUserInfo, getLoggedClientOrThrow, TimestampFormatter } from '../../lib/utils.js'; export class ActorsCallCommand extends ApifyCommand { static override name = 'call' as const; @@ -102,7 +102,7 @@ export class ActorsCallCommand extends ApifyCommand { const cwd = process.cwd(); const localConfig = getLocalConfig(cwd) || {}; const apifyClient = await getLoggedClientOrThrow(); - const userInfo = await getLocalUserInfo(); + const userInfo = await getCurrentUserInfo(); const usernameOrId = userInfo.username || (userInfo.id as string); if (this.flags.json && this.flags.outputDataset) { diff --git a/src/commands/actors/pull.ts b/src/commands/actors/pull.ts index 3d5d34027..37b0ba5a9 100644 --- a/src/commands/actors/pull.ts +++ b/src/commands/actors/pull.ts @@ -12,7 +12,7 @@ import { Flags } from '../../lib/command-framework/flags.js'; import { CommandExitCodes, LOCAL_CONFIG_PATH } from '../../lib/consts.js'; import { useActorConfig } from '../../lib/hooks/useActorConfig.js'; import { error, success } from '../../lib/outputs.js'; -import { downloadZip, getLocalUserInfo, getLoggedClientOrThrow } from '../../lib/utils.js'; +import { downloadZip, getCurrentUserInfo, getLoggedClientOrThrow } from '../../lib/utils.js'; const extractGitHubZip = async (url: string, directoryPath: string) => { const zipFile = await downloadZip(url); @@ -80,7 +80,7 @@ export class ActorsPullCommand extends ApifyCommand { const { config: actorConfig } = actorConfigResult.unwrap(); - const userInfo = await getLocalUserInfo(); + const userInfo = await getCurrentUserInfo(); const apifyClient = await getLoggedClientOrThrow(); const isActorAutomaticallyDetected = !this.args.actorId; diff --git a/src/commands/actors/push.ts b/src/commands/actors/push.ts index 2c7a5a7b5..e642c9a7d 100644 --- a/src/commands/actors/push.ts +++ b/src/commands/actors/push.ts @@ -29,7 +29,7 @@ import { createActZip, createSourceFiles, getActorLocalFilePaths, - getLocalUserInfo, + getCurrentUserInfo, getLoggedClientOrThrow, outputJobLog, parseWaitForFinishMillis, @@ -287,7 +287,7 @@ export class ActorsPushCommand extends ApifyCommand { const { config: actorConfig } = actorConfigResult.unwrap(); - const userInfo = await getLocalUserInfo(); + const userInfo = await getCurrentUserInfo(); const isOrganizationLoggedIn = !!userInfo.organizationOwnerUserId; const redirectUrlPart = isOrganizationLoggedIn ? `/organization/${userInfo.id}` : ''; diff --git a/src/commands/actors/search.ts b/src/commands/actors/search.ts index 49712a95e..15b663354 100644 --- a/src/commands/actors/search.ts +++ b/src/commands/actors/search.ts @@ -1,13 +1,14 @@ import { ApifyClient } from 'apify-client'; import chalk from 'chalk'; +import { getApifyClientOptions } from '../../lib/auth.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'; import { CompactMode, ResponsiveTable } from '../../lib/commands/responsive-table.js'; import { CommandExitCodes } from '../../lib/consts.js'; import { error, info, simpleLog } from '../../lib/outputs.js'; -import { getApifyClientOptions, printJsonToStdout } from '../../lib/utils.js'; +import { printJsonToStdout } from '../../lib/utils.js'; const pricingModelLabels: Record = { FREE: 'Free', diff --git a/src/commands/actors/start.ts b/src/commands/actors/start.ts index 164d04c6e..3ff5cb45e 100644 --- a/src/commands/actors/start.ts +++ b/src/commands/actors/start.ts @@ -12,7 +12,7 @@ import { runActorOrTaskOnCloud, SharedRunOnCloudFlags } from '../../lib/commands import { getConsoleUrl } from '../../lib/console-url.js'; import { LOCAL_CONFIG_PATH } from '../../lib/consts.js'; import { simpleLog } from '../../lib/outputs.js'; -import { getLocalConfig, getLocalUserInfo, getLoggedClientOrThrow, printJsonToStdout } from '../../lib/utils.js'; +import { getLocalConfig, getCurrentUserInfo, getLoggedClientOrThrow, printJsonToStdout } from '../../lib/utils.js'; import { ActorsCallCommand } from './call.js'; export class ActorsStartCommand extends ApifyCommand { @@ -73,7 +73,7 @@ export class ActorsStartCommand extends ApifyCommand const cwd = process.cwd(); const localConfig = getLocalConfig(cwd) || {}; const apifyClient = await getLoggedClientOrThrow(); - const userInfo = await getLocalUserInfo(); + const userInfo = await getCurrentUserInfo(); const usernameOrId = userInfo.username || (userInfo.id as string); const { diff --git a/src/commands/auth/login.ts b/src/commands/auth/login.ts index d1799d1c6..ad8b89d7f 100644 --- a/src/commands/auth/login.ts +++ b/src/commands/auth/login.ts @@ -7,6 +7,7 @@ import open from 'open'; import { cryptoRandomObjectId } from '@apify/utilities'; +import { loginWithToken } from '../../lib/auth.js'; import { ApifyCommand } from '../../lib/command-framework/apify-command.js'; import { Flags } from '../../lib/command-framework/flags.js'; import { getConsoleIntegrationsUrl, getConsoleUrl } from '../../lib/console-url.js'; @@ -17,7 +18,7 @@ import { useMaskedInput } from '../../lib/hooks/user-confirmations/useMaskedInpu import { useSelectFromList } from '../../lib/hooks/user-confirmations/useSelectFromList.js'; import { createLocalApiServer } from '../../lib/local-api-server.js'; import { error, info, success } from '../../lib/outputs.js'; -import { getLocalUserInfo, getLoggedClient, tildify } from '../../lib/utils.js'; +import { getLocalUserInfo, tildify } from '../../lib/utils.js'; // When logging in against a local Console instance (local platform development), validate the token // against the local API rather than production. @@ -28,7 +29,7 @@ const API_VERSION = 'v1'; const tryToLogin = async (token: string) => { const apiBaseUrl = getConsoleUrl().includes('localhost') ? LOCAL_API_BASE_URL : undefined; - const isUserLogged = await getLoggedClient(token, apiBaseUrl); + const isUserLogged = await loginWithToken(token, apiBaseUrl); const userInfo = await getLocalUserInfo(); if (isUserLogged) { diff --git a/src/commands/create.ts b/src/commands/create.ts index 1e4f05183..1ddb2ef14 100644 --- a/src/commands/create.ts +++ b/src/commands/create.ts @@ -50,7 +50,7 @@ import { LANGUAGE_FLAG_CHOICES, USE_CASE_FLAG_CHOICES } from '../lib/templates/c import { downloadAndUnzip, getJsonFileContent, - getLocalUserInfo, + getCurrentUserInfo, getLoggedClientOrThrow, isNodeVersionSupported, isPythonVersionSupported, @@ -317,8 +317,8 @@ export class CreateCommand extends ApifyCommand { ? { provider: gitProvider, client: await getLoggedClientOrThrow(), - // Read after the client, which refreshes auth.json from the token the run resolved. - account: toGitAccount(await getLocalUserInfo()), + // Read after the client, whose lookup caches the account for the token the run resolved. + account: toGitAccount(await getCurrentUserInfo()), // Omitted means on: the webhook is what makes a Git-sourced Actor rebuild on a push. autoBuild: this.flags.autoBuild !== 'off', ...parseGitRepoFlag(gitRepo, actorName), diff --git a/src/commands/datasets/get-items.ts b/src/commands/datasets/get-items.ts index 93bf6847f..d06cf9555 100644 --- a/src/commands/datasets/get-items.ts +++ b/src/commands/datasets/get-items.ts @@ -4,7 +4,7 @@ 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'; import { error, simpleLog } from '../../lib/outputs.js'; -import { getLocalUserInfo, getLoggedClientOrThrow } from '../../lib/utils.js'; +import { getCurrentUserInfo, getLoggedClientOrThrow } from '../../lib/utils.js'; const downloadFormatToContentType: Record = { [DownloadItemsFormat.JSON]: 'application/json', @@ -106,7 +106,7 @@ export class DatasetsGetItems extends ApifyCommand { }; } - const info = await getLocalUserInfo(); + const info = await getCurrentUserInfo(); const byName = await client .dataset(`${info.username!}/${datasetId}`) diff --git a/src/commands/datasets/ls.ts b/src/commands/datasets/ls.ts index 2a6102b39..793647a0a 100644 --- a/src/commands/datasets/ls.ts +++ b/src/commands/datasets/ls.ts @@ -5,7 +5,7 @@ import { Flags } from '../../lib/command-framework/flags.js'; import { prettyPrintBytes } from '../../lib/commands/pretty-print-bytes.js'; import { CompactMode, ResponsiveTable } from '../../lib/commands/responsive-table.js'; import { info, simpleLog } from '../../lib/outputs.js'; -import { getLocalUserInfo, getLoggedClientOrThrow, printJsonToStdout, TimestampFormatter } from '../../lib/utils.js'; +import { getCurrentUserInfo, getLoggedClientOrThrow, printJsonToStdout, TimestampFormatter } from '../../lib/utils.js'; const table = new ResponsiveTable({ allColumns: ['Dataset ID', 'Name', 'Items', 'Size', 'Created', 'Modified'], @@ -58,7 +58,7 @@ export class DatasetsLsCommand extends ApifyCommand { const { desc, offset, limit, json, unnamed } = this.flags; const client = await getLoggedClientOrThrow(); - const user = await getLocalUserInfo(); + const user = await getCurrentUserInfo(); const rawDatasetList = await client.datasets().list({ desc, offset, limit, unnamed }); diff --git a/src/commands/info.ts b/src/commands/info.ts index 63c78402d..9f043a259 100644 --- a/src/commands/info.ts +++ b/src/commands/info.ts @@ -1,7 +1,7 @@ import chalk from 'chalk'; import { ApifyCommand } from '../lib/command-framework/apify-command.js'; -import { getLocalUserInfo, getLoggedClientOrThrow } from '../lib/utils.js'; +import { getCurrentUserInfo, getLoggedClientOrThrow } from '../lib/utils.js'; export class InfoCommand extends ApifyCommand { static override name = 'info' as const; @@ -21,7 +21,7 @@ export class InfoCommand extends ApifyCommand { async run() { await getLoggedClientOrThrow(); - const info = await getLocalUserInfo(); + const info = await getCurrentUserInfo(); if (info) { const niceInfo = { diff --git a/src/commands/key-value-stores/ls.ts b/src/commands/key-value-stores/ls.ts index 880d595af..e516df91d 100644 --- a/src/commands/key-value-stores/ls.ts +++ b/src/commands/key-value-stores/ls.ts @@ -5,7 +5,7 @@ import { Flags } from '../../lib/command-framework/flags.js'; import { prettyPrintBytes } from '../../lib/commands/pretty-print-bytes.js'; import { CompactMode, ResponsiveTable } from '../../lib/commands/responsive-table.js'; import { info, simpleLog } from '../../lib/outputs.js'; -import { getLocalUserInfo, getLoggedClientOrThrow, printJsonToStdout, TimestampFormatter } from '../../lib/utils.js'; +import { getCurrentUserInfo, getLoggedClientOrThrow, printJsonToStdout, TimestampFormatter } from '../../lib/utils.js'; const table = new ResponsiveTable({ allColumns: ['Store ID', 'Name', 'Size', 'Created', 'Modified'], @@ -55,7 +55,7 @@ export class KeyValueStoresLsCommand extends ApifyCommand { async run() { const cwd = process.cwd(); - const { proxy, id: userId, token } = await getLocalUserInfo(); + const { proxy, id: userId, token } = await getCurrentUserInfo(); const localConfigResult = await useActorConfig({ cwd }); diff --git a/src/commands/task/publish.ts b/src/commands/task/publish.ts index bd43ea488..297f49fa3 100644 --- a/src/commands/task/publish.ts +++ b/src/commands/task/publish.ts @@ -7,7 +7,7 @@ import { ApifyCommand } from '../../lib/command-framework/apify-command.js'; import { Args } from '../../lib/command-framework/args.js'; import { CommandExitCodes } from '../../lib/consts.js'; import { error, success } from '../../lib/outputs.js'; -import { getLocalUserInfo, getLoggedClientOrThrow } from '../../lib/utils.js'; +import { getCurrentUserInfo, getLoggedClientOrThrow } from '../../lib/utils.js'; export class TaskPublishCommand extends ApifyCommand { static override name = 'publish' as const; @@ -40,7 +40,7 @@ export class TaskPublishCommand extends ApifyCommand async run() { const apifyClient = await getLoggedClientOrThrow(); - const userInfo = await getLocalUserInfo(); + const userInfo = await getCurrentUserInfo(); const usernameOrId = userInfo.username || (userInfo.id as string); const { taskId } = this.args; diff --git a/src/commands/task/run.ts b/src/commands/task/run.ts index e4e9add60..1e6d2055f 100644 --- a/src/commands/task/run.ts +++ b/src/commands/task/run.ts @@ -4,7 +4,7 @@ import { ApifyCommand } from '../../lib/command-framework/apify-command.js'; import { Args } from '../../lib/command-framework/args.js'; import { runActorOrTaskOnCloud, SharedRunOnCloudFlags } from '../../lib/commands/run-on-cloud.js'; import { finalizeRun } from '../../lib/commands/run-result.js'; -import { getLocalUserInfo, getLoggedClientOrThrow } from '../../lib/utils.js'; +import { getCurrentUserInfo, getLoggedClientOrThrow } from '../../lib/utils.js'; export class TaskRunCommand extends ApifyCommand { static override name = 'run' as const; @@ -39,7 +39,7 @@ export class TaskRunCommand extends ApifyCommand { async run() { const apifyClient = await getLoggedClientOrThrow(); - const userInfo = await getLocalUserInfo(); + const userInfo = await getCurrentUserInfo(); const usernameOrId = userInfo.username || (userInfo.id as string); const { id: taskId, userFriendlyId, title } = await this.resolveTaskId(apifyClient, usernameOrId); diff --git a/src/commands/task/unpublish.ts b/src/commands/task/unpublish.ts index 26c2a310e..b272dc60c 100644 --- a/src/commands/task/unpublish.ts +++ b/src/commands/task/unpublish.ts @@ -7,7 +7,7 @@ import { ApifyCommand } from '../../lib/command-framework/apify-command.js'; import { Args } from '../../lib/command-framework/args.js'; import { CommandExitCodes } from '../../lib/consts.js'; import { error, success } from '../../lib/outputs.js'; -import { getLocalUserInfo, getLoggedClientOrThrow } from '../../lib/utils.js'; +import { getCurrentUserInfo, getLoggedClientOrThrow } from '../../lib/utils.js'; export class TaskUnpublishCommand extends ApifyCommand { static override name = 'unpublish' as const; @@ -39,7 +39,7 @@ export class TaskUnpublishCommand extends ApifyCommand stored login. + * + * Read-only by contract: no caller of this function persists anything. Only `apify login` + * writes credentials, through {@link loginWithToken}. + */ +export const resolveAuth = async (explicitToken?: string): Promise => { + if (explicitToken) { + return { token: explicitToken, source: 'flag' }; + } + + await ensureMigrated(); + + const storedToken = await getToken(); + if (storedToken) { + return { token: storedToken, source: 'stored' }; + } + + return undefined; +}; + +type CJSAxiosHeaders = import('axios', { with: { 'resolution-mode': 'require' } }).AxiosRequestConfig['headers']; + +/** + * Returns options for ApifyClient + */ +export const getApifyClientOptions = async (token?: string, apiBaseUrl?: string): Promise => { + const auth = await resolveAuth(token); + + return { + token: auth?.token, + baseUrl: apiBaseUrl || process.env.APIFY_CLIENT_BASE_URL, + requestInterceptors: [ + (config) => { + config.headers ??= new AxiosHeaders() as CJSAxiosHeaders; + + for (const [key, value] of Object.entries(APIFY_CLIENT_DEFAULT_HEADERS)) { + config.headers![key] = value; + } + + return config; + }, + ], + }; +}; + +/** + * Authenticates `token` and saves it together with the account metadata. This is the only + * credential writer in the CLI — every other code path resolves tokens without persisting them. + * + * Returns `null` when the token is rejected, in which case nothing is written. + */ +export async function loginWithToken(token: string, apiBaseUrl?: string): Promise { + const apifyClient = new ApifyClient(await getApifyClientOptions(token, apiBaseUrl)); + + let userInfo; + try { + userInfo = await apifyClient.user('me').get(); + } catch (err) { + cliDebugPrint('[loginWithToken] error getting user info', { error: err, apiBaseUrl }); + return null; + } + + // Replaces the previous account rather than merging into it, so fields the new account + // does not have (email, organizationOwnerUserId) cannot linger from the old one. + const fileContents: Record = { ...userInfo, secretsBackend: await getBackend() }; + delete fileContents.token; + if (fileContents.proxy && typeof fileContents.proxy === 'object') { + const { password: _password, ...rest } = fileContents.proxy as { password?: string }; + if (Object.keys(rest).length > 0) { + fileContents.proxy = rest; + } else { + delete fileContents.proxy; + } + } + + ensureApifyDirectory(AUTH_FILE_PATH()); + writeFileSync(AUTH_FILE_PATH(), JSON.stringify(fileContents, null, '\t'), { mode: 0o600 }); + + // Secrets are written after the metadata file so the file backend, which stores them in + // auth.json too, is not overwritten. `skipIfUnchanged` avoids a macOS Keychain prompt + // when the value already matches. + await setToken(token, { skipIfUnchanged: true }); + + const proxyPassword = userInfo.proxy?.password; + if (proxyPassword) { + await setProxyPassword(proxyPassword, { skipIfUnchanged: true }); + } + + return apifyClient; +} diff --git a/src/lib/commands/resolve-actor-context.ts b/src/lib/commands/resolve-actor-context.ts index 22d45e34a..bc0ed27c7 100644 --- a/src/lib/commands/resolve-actor-context.ts +++ b/src/lib/commands/resolve-actor-context.ts @@ -2,7 +2,7 @@ import process from 'node:process'; import type { ApifyClient } from 'apify-client'; -import { getLocalConfig, getLocalUserInfo } from '../utils.js'; +import { getLocalConfig, getCurrentUserInfo } from '../utils.js'; /** * Tries to resolve what actor the command ran points to. If an actor id is provided via command line, attempt to resolve it, @@ -17,7 +17,7 @@ export async function resolveActorContext({ providedActorNameOrId: string | undefined; client: ApifyClient; }) { - const userInfo = await getLocalUserInfo(); + const userInfo = await getCurrentUserInfo(); const usernameOrId = userInfo.username || (userInfo.id as string); const localConfig = getLocalConfig(process.cwd()) || {}; diff --git a/src/lib/commands/storages.ts b/src/lib/commands/storages.ts index f3d043de3..1fbd6cdc7 100644 --- a/src/lib/commands/storages.ts +++ b/src/lib/commands/storages.ts @@ -1,6 +1,6 @@ import type { ApifyClient, Dataset, DatasetClient, KeyValueStore, KeyValueStoreClient } from 'apify-client'; -import { getLocalUserInfo } from '../utils.js'; +import { getCurrentUserInfo } from '../utils.js'; type ReturnTypeForStorage = T extends 'dataset' ? { @@ -25,7 +25,7 @@ async function tryToGetStorage( } as ReturnTypeForStorage; } - const info = await getLocalUserInfo(); + const info = await getCurrentUserInfo(); const byName = await client[storageType](`${info.username!}/${id}`) .get() diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 73502f9be..284bb1873 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -9,9 +9,9 @@ import { DurationFormatter as SapphireDurationFormatter, TimeTypes } from '@sapp import { Timestamp } from '@sapphire/timestamp'; import AdmZip from 'adm-zip'; import _Ajv2019 from 'ajv/dist/2019.js'; -import { type ActorRun, ApifyClient, type ApifyClientOptions, type Build } from 'apify-client'; +import { type ActorRun, ApifyClient, type Build } from 'apify-client'; import { ZipArchive } from 'archiver'; -import axios, { AxiosHeaders } from 'axios'; +import axios from 'axios'; import escapeStringRegexp from 'escape-string-regexp'; import ignoreModule, { type Ignore } from 'ignore'; import { getEncoding } from 'istextorbinary'; @@ -32,8 +32,8 @@ import { SOURCE_FILE_FORMATS, } from '@apify/consts'; +import { getApifyClientOptions, resolveAuth } from './auth.js'; import { - APIFY_CLIENT_DEFAULT_HEADERS, AUTH_FILE_PATH, CommandExitCodes, DEFAULT_LOCAL_STORAGE_DIR, @@ -41,8 +41,8 @@ import { MINIMUM_SUPPORTED_PYTHON_VERSION, SUPPORTED_NODEJS_VERSION, } from './consts.js'; -import { ensureMigrated, getBackend, getProxyPassword, getToken, setProxyPassword, setToken } from './credentials.js'; -import { deleteFile, ensureApifyDirectory, ensureFolderExistsSync, rimrafPromised } from './files.js'; +import { ensureMigrated, getBackend, getProxyPassword, getToken } from './credentials.js'; +import { deleteFile, ensureFolderExistsSync, rimrafPromised } from './files.js'; import { useCLIMetadata } from './hooks/useCLIMetadata.js'; import { inputFileRegExp, TEMP_INPUT_KEY_PREFIX } from './input-key.js'; import type { AuthJSON } from './types.js'; @@ -129,89 +129,54 @@ export async function getLoggedClientOrThrow() { return loggedClient; } -const resolveToken = async (existingToken?: string): Promise => { - if (existingToken) return existingToken; - await ensureMigrated(); - return getToken(); -}; - -type CJSAxiosHeaders = import('axios', { with: { 'resolution-mode': 'require' } }).AxiosRequestConfig['headers']; +let cachedUserInfo: { token: string; userInfo: AuthJSON } | undefined; -/** - * Returns options for ApifyClient - */ -export const getApifyClientOptions = async (token?: string, apiBaseUrl?: string): Promise => { - const resolvedToken = await resolveToken(token); - - return { - token: resolvedToken, - baseUrl: apiBaseUrl || process.env.APIFY_CLIENT_BASE_URL, - requestInterceptors: [ - (config) => { - config.headers ??= new AxiosHeaders() as CJSAxiosHeaders; - - for (const [key, value] of Object.entries(APIFY_CLIENT_DEFAULT_HEADERS)) { - config.headers![key] = value; - } - - return config; - }, - ], - }; -}; +/** Test-only: drop the in-memory account metadata so each test starts fresh. */ +export function __resetUserInfoCacheForTests() { + cachedUserInfo = undefined; +} /** - * Gets instance of ApifyClient for token or for params from global auth file. + * Gets instance of ApifyClient for the token the current command resolved, or `null` when no + * token is available or the API rejected it. * - * 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. + * Read-only: the resolved token is never persisted. Only `apify login` writes credentials. */ export async function getLoggedClient(token?: string, apiBaseUrl?: string) { - const resolvedToken = await resolveToken(token); + const auth = await resolveAuth(token); + if (!auth) return null; - const apifyClient = new ApifyClient(await getApifyClientOptions(resolvedToken, apiBaseUrl)); + const apifyClient = new ApifyClient(await getApifyClientOptions(auth.token, apiBaseUrl)); - let userInfo; try { - userInfo = await apifyClient.user('me').get(); + const userInfo = (await apifyClient.user('me').get()) as AuthJSON; + cachedUserInfo = { token: auth.token, userInfo }; } catch (err) { cliDebugPrint('[getLoggedClient] error getting user info', { error: err, apiBaseUrl }); return null; } - if (apifyClient.token) { - await setToken(apifyClient.token, { skipIfUnchanged: true }); - } + return apifyClient; +} - const proxyPassword = userInfo.proxy?.password; - if (proxyPassword) { - await setProxyPassword(proxyPassword, { skipIfUnchanged: true }); - } +/** + * Account metadata for the token the current command resolved. + * + * A one-off `--token` has no entry in auth.json, so the account is read from the API instead. + * In practice the value is already cached by the {@link getLoggedClient} call such commands + * make first. + */ +export async function getCurrentUserInfo(): Promise { + const auth = await resolveAuth(); + if (!auth || auth.source === 'stored') return getLocalUserInfo(); - ensureApifyDirectory(AUTH_FILE_PATH()); - const existingFile = (() => { - try { - return JSON.parse(readFileSync(AUTH_FILE_PATH(), 'utf-8')) as Record; - } catch { - return {}; - } - })(); - const backend = await getBackend(); - const fileContents: Record = { ...existingFile, ...userInfo, secretsBackend: backend }; - if (backend === 'keyring') { - delete fileContents.token; - if (fileContents.proxy && typeof fileContents.proxy === 'object') { - const { password: _password, ...rest } = fileContents.proxy as { password?: string }; - if (Object.keys(rest).length > 0) { - fileContents.proxy = rest; - } else { - delete fileContents.proxy; - } - } - } - writeFileSync(AUTH_FILE_PATH(), JSON.stringify(fileContents, null, '\t'), { mode: 0o600 }); + if (cachedUserInfo?.token === auth.token) return cachedUserInfo.userInfo; - return apifyClient; + const apifyClient = new ApifyClient(await getApifyClientOptions(auth.token)); + const userInfo = (await apifyClient.user('me').get()) as AuthJSON; + cachedUserInfo = { token: auth.token, userInfo }; + + return userInfo; } export const getLocalConfigPath = (cwd: string) => join(cwd, LOCAL_CONFIG_PATH); diff --git a/test/__setup__/config.ts b/test/__setup__/config.ts index 3a62b6eeb..f0420ff5a 100644 --- a/test/__setup__/config.ts +++ b/test/__setup__/config.ts @@ -3,7 +3,7 @@ import { EOL } from 'node:os'; import { ApifyClient } from 'apify-client'; import { isCI } from 'ci-info'; -import { getApifyClientOptions } from '../../src/lib/utils.js'; +import { getApifyClientOptions } from '../../src/lib/auth.js'; const { TEST_USER_TOKEN: ENV_TEST_USER_TOKEN } = process.env; diff --git a/test/__setup__/hooks/useAuthSetup.ts b/test/__setup__/hooks/useAuthSetup.ts index aa0ae48e4..9e0907b48 100644 --- a/test/__setup__/hooks/useAuthSetup.ts +++ b/test/__setup__/hooks/useAuthSetup.ts @@ -9,7 +9,7 @@ import { LoginCommand } from '../../../src/commands/login.js'; import { testRunCommand } from '../../../src/lib/command-framework/apify-command.js'; import { GLOBAL_CONFIGS_FOLDER } from '../../../src/lib/consts.js'; import { __resetCredentialsForTests } from '../../../src/lib/credentials.js'; -import { getLocalUserInfo } from '../../../src/lib/utils.js'; +import { __resetUserInfoCacheForTests, getLocalUserInfo } from '../../../src/lib/utils.js'; export interface UseAuthSetupOptions { /** @@ -43,7 +43,10 @@ export function useAuthSetup({ cleanup = true, perTest = true }: UseAuthSetupOpt // Tests pin to the file backend so they don't touch the real OS keyring. // Unit tests for credentials.ts override this explicitly. vitest.stubEnv('APIFY_DISABLE_KEYRING', '1'); + // The resolver reads APIFY_TOKEN, so a token in the developer's shell would leak into tests. + vitest.stubEnv('APIFY_TOKEN', ''); __resetCredentialsForTests(); + __resetUserInfoCacheForTests(); }); after(async () => { @@ -52,6 +55,7 @@ export function useAuthSetup({ cleanup = true, perTest = true }: UseAuthSetupOpt } __resetCredentialsForTests(); + __resetUserInfoCacheForTests(); vitest.unstubAllEnvs(); }); } @@ -71,6 +75,7 @@ export function useKeyringBackend() { vitest.stubEnv('APIFY_DISABLE_KEYRING', ''); __resetCredentialsForTests(); + __resetUserInfoCacheForTests(); }); } diff --git a/test/e2e/commands/builds/lifecycle.test.ts b/test/e2e/commands/builds/lifecycle.test.ts index 29e9c5644..154b4ec1b 100644 --- a/test/e2e/commands/builds/lifecycle.test.ts +++ b/test/e2e/commands/builds/lifecycle.test.ts @@ -2,7 +2,7 @@ import { randomBytes } from 'node:crypto'; import { ApifyClient } from 'apify-client'; -import { getApifyClientOptions } from '../../../../src/lib/utils.js'; +import { getApifyClientOptions } from '../../../../src/lib/auth.js'; import { runCli } from '../../__helpers__/run-cli.js'; import { createTestActor, removeTestActor, type TestActor } from '../../__helpers__/test-actor.js'; diff --git a/test/e2e/commands/datasets/lifecycle.test.ts b/test/e2e/commands/datasets/lifecycle.test.ts index b0b2deecf..656c0b754 100644 --- a/test/e2e/commands/datasets/lifecycle.test.ts +++ b/test/e2e/commands/datasets/lifecycle.test.ts @@ -2,7 +2,7 @@ import { randomBytes } from 'node:crypto'; import { ApifyClient } from 'apify-client'; -import { getApifyClientOptions } from '../../../../src/lib/utils.js'; +import { getApifyClientOptions } from '../../../../src/lib/auth.js'; import { runCli } from '../../__helpers__/run-cli.js'; describe('[e2e][api] datasets namespace', () => { diff --git a/test/e2e/commands/key-value-stores/lifecycle.test.ts b/test/e2e/commands/key-value-stores/lifecycle.test.ts index f01399a4b..0c477243c 100644 --- a/test/e2e/commands/key-value-stores/lifecycle.test.ts +++ b/test/e2e/commands/key-value-stores/lifecycle.test.ts @@ -2,7 +2,7 @@ import { randomBytes } from 'node:crypto'; import { ApifyClient } from 'apify-client'; -import { getApifyClientOptions } from '../../../../src/lib/utils.js'; +import { getApifyClientOptions } from '../../../../src/lib/auth.js'; import { runCli } from '../../__helpers__/run-cli.js'; describe('[e2e][api] key-value-stores namespace', () => { diff --git a/test/e2e/commands/runs/lifecycle.test.ts b/test/e2e/commands/runs/lifecycle.test.ts index aa99a05ad..cb2ff4a21 100644 --- a/test/e2e/commands/runs/lifecycle.test.ts +++ b/test/e2e/commands/runs/lifecycle.test.ts @@ -4,7 +4,7 @@ import path from 'node:path'; import { ApifyClient } from 'apify-client'; -import { getApifyClientOptions } from '../../../../src/lib/utils.js'; +import { getApifyClientOptions } from '../../../../src/lib/auth.js'; import { runCli } from '../../__helpers__/run-cli.js'; import { createTestActor, removeTestActor, type TestActor } from '../../__helpers__/test-actor.js'; import { TestTmpRoot } from '../../__helpers__/tmp.js'; diff --git a/test/local/commands/auth.test.ts b/test/local/commands/auth.test.ts index 43a012881..72bd10d18 100644 --- a/test/local/commands/auth.test.ts +++ b/test/local/commands/auth.test.ts @@ -113,8 +113,8 @@ describe('auth commands', () => { const authFile = readAuthFile(); expect(authFile).toMatchObject({ token: 'apify_api_other_token', id: 'uid2', username: 'other' }); - // Known gap: getLoggedClient merges, so the old account's extra fields survive. - expect(authFile.email).toBe('me@example.com'); + // The new account has no email, so the old one must not linger. + expect(authFile.email).toBeUndefined(); }); it('login with an invalid token stores nothing', async () => { diff --git a/test/local/lib/credentials.test.ts b/test/local/lib/credentials.test.ts index d11d0f111..771c90ed5 100644 --- a/test/local/lib/credentials.test.ts +++ b/test/local/lib/credentials.test.ts @@ -4,6 +4,7 @@ import process from 'node:process'; import { cryptoRandomObjectId } from '@apify/utilities'; +import { getApifyClientOptions } from '../../../src/lib/auth.js'; import { AUTH_FILE_PATH, GLOBAL_CONFIGS_FOLDER } from '../../../src/lib/consts.js'; import { __resetCredentialsForTests, @@ -15,7 +16,7 @@ import { setProxyPassword, setToken, } from '../../../src/lib/credentials.js'; -import { getApifyClientOptions, getLocalUserInfo } from '../../../src/lib/utils.js'; +import { getLocalUserInfo } from '../../../src/lib/utils.js'; import { KEYRING_PROXY_PASSWORD_KEY, KEYRING_TOKEN_KEY, From ec223d21674fb839e97a0760dd876b4cca0155a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Richard=20Sol=C3=A1r?= Date: Fri, 11 Sep 2026 10:52:32 +0200 Subject: [PATCH 2/4] feat: one token resolution order across every command Three resolvers decided which token a command used, and they disagreed: `resolveToken` ignored `APIFY_TOKEN`, `mcp install` honored it, the `actor` entrypoint required it. The same shell gave three answers depending on the command. There is now one order, the one `mcp install` and the `actor` entrypoint already used: token the command was given -> APIFY_TOKEN -> stored login `APIFY_TOKEN` is the way to run a command as a different account. Only `login` and `mcp install` take a token of their own, through `--token`, because both write it somewhere rather than just authenticating with it. Adding a second, flag-shaped way to do what the env var already does would leave two mechanisms for one thing. - `getApifyTokenFromEnvOrAuthFile` and `resolveApifyToken` are gone, folded into `resolveAuth`. No special case is needed for the `actor` entrypoint: inside a platform run there is no stored login, so `APIFY_TOKEN` wins on its own. - `apify auth token` prints the token that would be used, not the stored one. It only looked right before because reads overwrote the stored token. - `apify info` names the source, so an `APIFY_TOKEN` that overrides a stored login is visible rather than silent. - `apify run` passes the resolved token to the child instead of the stored one, and no longer overwrites an inherited `APIFY_TOKEN`. - A rejected token names its source. A 401 or 403 says the token was rejected; any other failure says the API request failed, so an unreachable API is not reported as a bad token. - `apify login` ignores `APIFY_TOKEN`. Logging in stays explicit. Closes #720 Co-Authored-By: Claude Opus 5 --- docs/reference.md | 11 +- src/commands/actor/charge.ts | 9 +- src/commands/auth/login.ts | 5 +- src/commands/auth/token.ts | 14 +- src/commands/info.ts | 22 ++- src/commands/mcp/install.ts | 34 ++-- src/commands/run.ts | 16 +- src/lib/actor.ts | 31 +--- src/lib/auth.ts | 51 +++++- src/lib/utils.ts | 29 +-- test/e2e/__helpers__/run-cli.ts | 3 + test/local/commands/auth.test.ts | 25 ++- test/local/commands/create-git-source.test.ts | 3 + test/local/commands/push-git-source.test.ts | 2 +- test/local/lib/auth.test.ts | 169 ++++++++++++++++++ test/local/lib/credentials.test.ts | 2 + 16 files changed, 330 insertions(+), 96 deletions(-) create mode 100644 test/local/lib/auth.test.ts diff --git a/docs/reference.md b/docs/reference.md index e7cf2fb73..f61b558bf 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -149,7 +149,8 @@ SUBCOMMANDS to '~/.apify/auth.json'. auth logout Removes authentication by deleting your API token and account information from '~/.apify/auth.json'. - auth token Prints the current API token for the Apify CLI. + auth token Prints the API token the CLI would use, resolved from + APIFY_TOKEN or the stored login. ``` ##### `apify auth login` / `apify login` @@ -168,7 +169,8 @@ USAGE FLAGS -m, --method=