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 { invalidEnvTokenMessage, readEnvToken } 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
254 changes: 254 additions & 0 deletions src/lib/auth-file.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,254 @@
import { 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;
}

/**
* A snapshot of the pre-v2 file, kept so an upgrade is inspectable. Written once and never
* refreshed, which is why the secrets are left out: `apify login` replaces auth.json but cannot
* reach this file, so a copy of a rotated token would sit here until the next logout. Nothing
* reads it, and a downgraded CLI finds its token through the usual backends rather than here.
*/
function backUpV1File(file: AuthFile) {
if (existsSync(AUTH_BACKUP_FILE_PATH())) return;

const { token: _token, proxy: _proxy, ...withoutSecrets } = file;
writeFileSync(AUTH_BACKUP_FILE_PATH(), JSON.stringify(withoutSecrets, null, '\t'), { mode: 0o600 });
}

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(file);
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);
}
32 changes: 22 additions & 10 deletions src/lib/auth.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { existsSync, writeFileSync } from 'node:fs';
import { existsSync } from 'node:fs';
import process from 'node:process';

import { ApifyApiError, 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, CommandExitCodes } from './consts.js';
import {
deleteProxyPassword,
Expand All @@ -14,9 +15,7 @@ import {
getToken,
setProxyPassword,
setToken,
stripProxyPassword,
} from './credentials.js';
import { ensureApifyDirectory } from './files.js';
import { warning } from './outputs.js';
import type { AuthJSON } from './types.js';
import { cliDebugPrint } from './utils/cliDebugPrint.js';
Expand Down Expand Up @@ -81,6 +80,7 @@ export function __resetAuthForTests() {
export const resolveAuth = async (): Promise<ResolvedAuth | undefined> => {
authPromise ??= (async () => {
await ensureMigrated();
await ensureAuthFileCurrent();

const envToken = readEnvToken();
if (envToken.kind === 'invalid') {
Expand Down Expand Up @@ -168,15 +168,27 @@ export async function loginWithToken(
return null;
}

const proxyPassword = userInfo.proxy?.password;
if (!userInfo.id) {
throw new Error('The Apify API returned no user ID for this token, so the login cannot be stored.');
}

// Replaces the previous account rather than merging, so stale fields cannot linger. The spread
// is shallow, so stripping here also clears userInfo.proxy — read the password first.
const fileContents = { ...userInfo, secretsBackend: await getBackend() };
stripProxyPassword(fileContents);
const proxyPassword = userInfo.proxy?.password;

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(),
);

// After the metadata file, which would clobber them on the file backend. `skipIfUnchanged` avoids a Keychain prompt.
await setToken(token, { skipIfUnchanged: true });
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,16 +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 {};
}
}

/**
* Remove the proxy password, keeping any sibling field like `groups` and dropping `proxy`
* entirely when the secret was all it carried.
Expand All @@ -125,11 +106,6 @@ export function stripProxyPassword(data: { proxy?: { password?: string } }) {
if (Object.keys(data.proxy).length === 0) delete data.proxy;
}

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
Loading