Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions src/commands/auth/logout.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { APIFY_ENV_VARS } from '@apify/consts';

import { removeActiveProfile } from '../../lib/auth-file.js';
import { getEnvToken } from '../../lib/auth.js';
import { ApifyCommand } from '../../lib/command-framework/apify-command.js';
import { AUTH_FILE_PATH } from '../../lib/consts.js';
import { clearKeyringSecrets } from '../../lib/credentials.js';
import { rimrafPromised } from '../../lib/files.js';
import { updateUserId } from '../../lib/hooks/telemetry/useTelemetryState.js';
import { success, warning } from '../../lib/outputs.js';
import { tildify } from '../../lib/utils.js';
Expand All @@ -28,8 +28,10 @@ export class AuthLogoutCommand extends ApifyCommand<typeof AuthLogoutCommand> {
static override docsUrl = 'https://docs.apify.com/cli/docs/reference#apify-logout';

async run() {
// The file goes first: it is the step that can refuse, and refusing before the keyring is
// cleared leaves a logged-in state rather than half a logout.
removeActiveProfile();
await clearKeyringSecrets();
await rimrafPromised(AUTH_FILE_PATH());

await updateUserId(null);

Expand Down
247 changes: 247 additions & 0 deletions src/lib/auth-file.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,247 @@
import { copyFileSync, existsSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';

import { cryptoRandomObjectId } from '@apify/utilities';

import { AUTH_FILE_PATH } from './consts.js';
import type { CredentialsBackend } from './credentials.js';
import { ensureApifyDirectory } from './files.js';
import { cliDebugPrint } from './utils/cliDebugPrint.js';

const AUTH_FILE_VERSION = 2;

/** The way back to a CLI that only reads the v1 shape. */
export const AUTH_BACKUP_FILE_PATH = () => `${AUTH_FILE_PATH()}.v1.bak`;

/**
* One account. Keyed by user ID in {@link AuthFile.profiles}, so renaming a profile can never
* orphan the secret that key names.
*/
export interface AuthProfile {
username?: string;
/** Human label for `--profile <name>`. Unused until profiles get names. */
name: string | null;
/** Set means the profile is an organization rather than a personal account. */
organizationOwnerUserId?: string;
/** How the token was obtained. Unused until the device flow lands. */
authMethod: 'token';
/** When the access token expires. Unused until the device flow lands. */
expiresAt: string | null;
/** Whether a refresh token came with the access token. Unused until the device flow lands. */
hasRefreshToken: boolean;
}

/**
* `auth.json` as it sits on disk. `token` and `proxy` are the file backend's secret storage; they
* stay outside the profiles until each profile gets its own keys.
*/
export interface AuthFile {
version?: number;
activeProfile?: string;
profiles?: Record<string, AuthProfile>;
secretsBackend?: CredentialsBackend;
token?: string;
proxy?: { password?: string; [k: string]: unknown };
[k: string]: unknown;
}

export interface ActiveProfileLookup {
profile?: AuthProfile & { id: string };
/** Set when `activeProfile` names a profile the file does not contain. */
missingProfile?: string;
}

let migrationPromise: Promise<void> | undefined;

/** Test-only: let each test run the v2 migration again. */
export function __resetAuthFileForTests() {
migrationPromise = undefined;
}

/** `null` tells a corrupt file from an absent one, which the migration must not overwrite. */
function parseAuthFile(): AuthFile | null {
if (!existsSync(AUTH_FILE_PATH())) return {};

try {
return JSON.parse(readFileSync(AUTH_FILE_PATH(), 'utf-8')) as AuthFile;
} catch {
return null;
}
}

/** The parsed file, or an empty object when it is missing or unreadable. */
export function readAuthFile(): AuthFile {
return parseAuthFile() ?? {};
}

/**
* Atomic write: a temp file next to the target, then a rename. Two CLI processes can run at once,
* and a half-written auth.json reads as logged out.
*/
export function writeAuthFile(data: AuthFile) {
const path = AUTH_FILE_PATH();
ensureApifyDirectory(path);

const tempPath = `${path}.tmp-${cryptoRandomObjectId(8)}`;

try {
writeFileSync(tempPath, JSON.stringify(data, null, '\t'), { mode: 0o600 });
renameSync(tempPath, path);
} catch (err) {
rmSync(tempPath, { force: true });
throw err;
}
}

/** The one account a v1 file described, as a profile. */
function v1Profile(file: AuthFile): AuthProfile {
return {
...(typeof file.username === 'string' ? { username: file.username } : {}),
name: null,
...(typeof file.organizationOwnerUserId === 'string'
? { organizationOwnerUserId: file.organizationOwnerUserId }
: {}),
authMethod: 'token',
expiresAt: null,
hasRefreshToken: false,
};
}

/**
* A v1 file described one account, so everything in it belongs to one profile. `email`, `plan`,
* `effectivePlatformFeatures`, `isPaying`, `createdAt` and `proxy.groups` are dropped — nothing in
* the CLI reads them.
*/
function toV2(file: AuthFile): AuthFile {
const migrated: AuthFile = { version: AUTH_FILE_VERSION, profiles: {} };

// A v1 file with a token but no ID has no key to store the profile under. Keep the secrets so
// the next command reports stale credentials instead of a silent logged-out state.
if (typeof file.id === 'string') {
migrated.activeProfile = file.id;
migrated.profiles![file.id] = v1Profile(file);
}

if (file.secretsBackend) migrated.secretsBackend = file.secretsBackend;
if (typeof file.token === 'string') migrated.token = file.token;
if (typeof file.proxy?.password === 'string') migrated.proxy = { password: file.proxy.password };

return migrated;
}

/** Never overwrites an existing backup: the first one is the file the user started with. */
function backUpV1File() {
if (existsSync(AUTH_BACKUP_FILE_PATH())) return;
copyFileSync(AUTH_FILE_PATH(), AUTH_BACKUP_FILE_PATH());
}

async function migrateToV2(): Promise<void> {
migrationPromise ??= (async () => {
try {
const file = parseAuthFile();

// A corrupt file is left alone: readers already treat it as logged out, and rewriting
// it would destroy what the user could still recover by hand.
if (!file) return;
// A numbered version is either already current or from another CLI; either way there
// is nothing to migrate. `assertSupportedAuthFileVersion` reports a newer one.
if (typeof file.version === 'number') return;
if (Object.keys(file).length === 0) return;

backUpV1File();
writeAuthFile(toV2(file));
} catch (err) {
cliDebugPrint('auth-file', 'migration to v2 failed', err);
}
})();

return migrationPromise;
}

/**
* A file from a newer CLI is not something to guess at — migrating it backwards would drop
* whatever that version stores.
*/
function assertSupportedAuthFileVersion() {
const { version } = readAuthFile();

if (typeof version === 'number' && version > AUTH_FILE_VERSION) {
throw new Error(
`Your credentials in ${AUTH_FILE_PATH()} were written by a newer Apify CLI (auth file version ${version}, this one reads ${AUTH_FILE_VERSION}). Upgrade the CLI to use them.`,
);
}
}

/**
* Brings `auth.json` to the v2 profile shape and refuses a file a newer CLI wrote. Runs after
* `ensureMigrated()`, which moves v1 secrets into the keyring; the two steps stay separate so a
* keyring failure and a shape failure cannot mask each other.
*
* The migration itself is idempotent, single-flight and never throws — it must not block a command.
*/
export async function ensureAuthFileCurrent(): Promise<void> {
await migrateToV2();
assertSupportedAuthFileVersion();
}

/**
* The active profile with its user ID. Reads a v1 file too, so a command that runs before the
* migration still finds the account.
*/
export function lookUpActiveProfile(): ActiveProfileLookup {
const file = readAuthFile();

if (file.version !== AUTH_FILE_VERSION) {
return typeof file.id === 'string' ? { profile: { id: file.id, ...v1Profile(file) } } : {};
}

if (!file.activeProfile) return {};

const profile = file.profiles?.[file.activeProfile];
if (!profile) return { missingProfile: file.activeProfile };

return { profile: { id: file.activeProfile, ...profile } };
}

/** The active profile, or `undefined` when nothing usable is stored. */
export function getActiveProfile(): (AuthProfile & { id: string }) | undefined {
return lookUpActiveProfile().profile;
}

/**
* Stores one account and makes it active, replacing whatever was there. Nothing puts a second
* profile in the file yet, so `apify login` owns all of it.
*/
export function setActiveProfile(userId: string, profile: AuthProfile, secretsBackend: CredentialsBackend) {
assertSupportedAuthFileVersion();

writeAuthFile({
version: AUTH_FILE_VERSION,
activeProfile: userId,
profiles: { [userId]: profile },
secretsBackend,
});
}

/**
* Drops the active profile together with the secrets stored beside it. The file and the v1 backup
* go away once no profile is left, so logging out leaves no token on disk.
*/
export function removeActiveProfile() {
assertSupportedAuthFileVersion();

const file = readAuthFile();
const active = file.version === AUTH_FILE_VERSION ? file.activeProfile : undefined;

if (active && file.profiles) delete file.profiles[active];
delete file.activeProfile;
delete file.token;
delete file.proxy;

if (Object.keys(file.profiles ?? {}).length === 0) {
rmSync(AUTH_FILE_PATH(), { force: true });
rmSync(AUTH_BACKUP_FILE_PATH(), { force: true });
return;
}

writeAuthFile(file);
}
35 changes: 20 additions & 15 deletions src/lib/auth.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import { existsSync, writeFileSync } from 'node:fs';
import { existsSync } from 'node:fs';
import process from 'node:process';

import { ApifyClient, type ApifyClientOptions } from 'apify-client';
import { AxiosHeaders } from 'axios';

import { APIFY_ENV_VARS } from '@apify/consts';

import { ensureAuthFileCurrent, setActiveProfile } from './auth-file.js';
import { APIFY_CLIENT_DEFAULT_HEADERS, AUTH_FILE_PATH } from './consts.js';
import { ensureMigrated, getBackend, getToken, setProxyPassword, setToken } from './credentials.js';
import { ensureApifyDirectory } from './files.js';
import { warning } from './outputs.js';
import { cliDebugPrint } from './utils/cliDebugPrint.js';

Expand Down Expand Up @@ -86,6 +86,7 @@ export const resolveAuth = async (explicitToken?: string): Promise<ResolvedAuth
}

await ensureMigrated();
await ensureAuthFileCurrent();

const storedToken = await getToken();
if (storedToken) {
Expand Down Expand Up @@ -165,21 +166,25 @@ export async function loginWithToken(token: string, apiBaseUrl?: string): Promis
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<string, unknown> = { ...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;
}
if (!userInfo.id) {
throw new Error('The Apify API returned no user ID for this token, so the login cannot be stored.');
}

ensureApifyDirectory(AUTH_FILE_PATH());
writeFileSync(AUTH_FILE_PATH(), JSON.stringify(fileContents, null, '\t'), { mode: 0o600 });
// The profile is keyed by user ID, and it replaces whatever was stored rather than merging
// into it, so fields the new account does not have cannot linger from the old one.
const { organizationOwnerUserId } = userInfo as { organizationOwnerUserId?: string };
setActiveProfile(
userInfo.id,
{
username: userInfo.username,
name: null,
...(organizationOwnerUserId ? { organizationOwnerUserId } : {}),
authMethod: 'token',
expiresAt: null,
hasRefreshToken: false,
},
await getBackend(),
);

// Written after the metadata file, which would otherwise clobber them on the file backend.
// `skipIfUnchanged` avoids a macOS Keychain prompt when the value already matches.
Expand Down
26 changes: 1 addition & 25 deletions src/lib/credentials.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import process from 'node:process';

import { AUTH_FILE_PATH } from './consts.js';
import { ensureApifyDirectory } from './files.js';
import { readAuthFile, writeAuthFile } from './auth-file.js';
import { useCLIMetadata } from './hooks/useCLIMetadata.js';
import { cliDebugPrint } from './utils/cliDebugPrint.js';

Expand All @@ -22,13 +20,6 @@ interface KeyringModule {
Entry: new (service: string, account: string) => KeyringEntry;
}

interface StoredAuthFile {
token?: string;
proxy?: { password?: string; [k: string]: unknown };
secretsBackend?: CredentialsBackend;
[k: string]: unknown;
}

let cachedKeyringModule: KeyringModule | null | undefined;
let backendPromise: Promise<CredentialsBackend> | undefined;
let migrationPromise: Promise<void> | undefined;
Expand Down Expand Up @@ -104,21 +95,6 @@ function downgradeBackendToFile() {
backendPromise = Promise.resolve('file');
}

function readAuthFile(): StoredAuthFile {
if (!existsSync(AUTH_FILE_PATH())) return {};
try {
const raw = readFileSync(AUTH_FILE_PATH(), 'utf-8');
return JSON.parse(raw) as StoredAuthFile;
} catch {
return {};
}
}

function writeAuthFile(data: StoredAuthFile) {
ensureApifyDirectory(AUTH_FILE_PATH());
writeFileSync(AUTH_FILE_PATH(), JSON.stringify(data, null, '\t'), { mode: 0o600 });
}

async function getKeyringEntry(account: string): Promise<KeyringEntry | null> {
const mod = await loadKeyringModule();
if (!mod) return null;
Expand Down
4 changes: 2 additions & 2 deletions src/lib/hooks/useCLIMetadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ export const DEVELOPMENT_VERSION_MARKER = '0.0.0';
export const DEVELOPMENT_HASH_MARKER = '0000000';

// These values are replaced with the actual values when building the CLI
const CLI_VERSION = DEVELOPMENT_VERSION_MARKER;
const CLI_HASH = DEVELOPMENT_HASH_MARKER;
const CLI_VERSION = '1.10.1';
const CLI_HASH = '1e7e56cb213537bb4bd5e7878e574191d732e106';

export type InstallMethod = 'npm' | 'pnpm' | 'homebrew' | 'volta' | 'bundle' | 'bun';

Expand Down
Loading