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
9 changes: 5 additions & 4 deletions docs/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,8 +147,8 @@ DESCRIPTION
SUBCOMMANDS
auth login Authenticates your Apify account and saves credentials
to '~/.apify/auth.json'.
auth logout Removes authentication by deleting your API token and
account information from '~/.apify/auth.json'.
auth logout Logs out of the active account by deleting its API
token and account information from '~/.apify/auth.json'.
auth token Prints the API token the CLI authenticates with,
resolved from APIFY_TOKEN or the token from 'apify login'.
```
Expand Down Expand Up @@ -177,8 +177,9 @@ FLAGS

```sh
DESCRIPTION
Removes authentication by deleting your API token and account information from
'~/.apify/auth.json'.
Logs out of the active account by deleting its API token and account
information from '~/.apify/auth.json'.
If other accounts are stored, the most recently logged-in one becomes active.
Run 'apify login' to authenticate again.

USAGE
Expand Down
8 changes: 8 additions & 0 deletions src/commands/auth/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import open from 'open';
import { APIFY_ENV_VARS } from '@apify/consts';
import { cryptoRandomObjectId } from '@apify/utilities';

import { profileLabel, readAuthFile } from '../../lib/auth-file.js';
import { invalidEnvTokenMessage, loginWithToken, readEnvToken } from '../../lib/auth.js';
import { ApifyCommand } from '../../lib/command-framework/apify-command.js';
import { Flags } from '../../lib/command-framework/flags.js';
Expand Down Expand Up @@ -48,6 +49,13 @@ const tryToLogin = async (token: string) => {
success({
message: `You are logged in to Apify as ${userInfo.username || userInfo.id}. ${chalk.gray(`Your token is stored in ${tokenLocation}.`)}`,
});

const others = Object.entries(readAuthFile().profiles ?? {})
.filter(([id]) => id !== userInfo.id)
.map(([id, profile]) => profileLabel({ id, ...profile }));
if (others.length > 0) {
info({ message: `Other stored accounts: ${others.join(', ')}.` });
}
} else {
process.exitCode = CommandExitCodes.MissingAuth;
error({
Expand Down
20 changes: 15 additions & 5 deletions src/commands/auth/logout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import process from 'node:process';

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

import { getActiveProfileId, removeActiveProfile } from '../../lib/auth-file.js';
import { getActiveProfileId, profileLabel, 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, CommandExitCodes } from '../../lib/consts.js';
Expand All @@ -15,7 +15,8 @@ export class AuthLogoutCommand extends ApifyCommand<typeof AuthLogoutCommand> {
static override name = 'logout' as const;

static override description =
`Removes authentication by deleting your API token and account information from '${tildify(AUTH_FILE_PATH())}'.\n` +
`Logs out of the active account by deleting its API token and account information from '${tildify(AUTH_FILE_PATH())}'.\n` +
`If other accounts are stored, the most recently logged-in one becomes active.\n` +
`Run 'apify login' to authenticate again.`;

static override group = 'Authentication';
Expand All @@ -41,8 +42,9 @@ export class AuthLogoutCommand extends ApifyCommand<typeof AuthLogoutCommand> {
);

let profileError: unknown = null;
let result: ReturnType<typeof removeActiveProfile> = {};
try {
removeActiveProfile();
result = removeActiveProfile();
} catch (err) {
profileError = err;
}
Expand All @@ -53,9 +55,17 @@ export class AuthLogoutCommand extends ApifyCommand<typeof AuthLogoutCommand> {
return;
}

await updateUserId(null);
const { removed, active } = result;

success({ message: 'You are logged out from your Apify account.' });
await updateUserId(active?.id ?? null);

if (active) {
success({
message: `You are logged out${removed ? ` of ${profileLabel(removed)}` : ''}. ${profileLabel(active)} is now the active account.`,
});
} else {
success({ message: 'You are logged out from your Apify account.' });
}

const envToken = readEnvToken();
if (envToken.kind === 'token') {
Expand Down
80 changes: 55 additions & 25 deletions src/lib/auth-file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export const AUTH_BACKUP_FILE_PATH = () => `${AUTH_FILE_PATH()}.v1.bak`;
*/
export interface AuthProfile {
username?: string;
/** Human label for `--profile <name>`. Unused until profiles get names. */
/** Human label for `--profile <name>`. */
name: string | null;
/** Set means the profile is an organization rather than a personal account. */
organizationOwnerUserId?: string;
Expand All @@ -28,8 +28,8 @@ export interface AuthProfile {
hasRefreshToken: boolean;
/**
* Where this profile's secrets live, when that differs from the file-level `secretsBackend`.
* Written only when a keyring write for this profile fails, so one profile falling back to the
* file cannot silently redirect another profile's reads to a place its secrets are not.
* Kept per profile so one profile falling back to the file cannot silently redirect another
* profile's reads to a place its secrets are not.
*/
secretsBackend?: CredentialsBackend;
loggedInAt: string | null;
Expand Down Expand Up @@ -255,6 +255,10 @@ export function lookUpActiveProfile(): ActiveProfileLookup {
return { profile: { id: file.activeProfile, ...profile } };
}

export function profileLabel(profile: AuthProfile & { id: string }) {
return profile.name ?? profile.username ?? profile.id;
}

export function getActiveProfile(): (AuthProfile & { id: string }) | undefined {
return lookUpActiveProfile().profile;
}
Expand Down Expand Up @@ -340,52 +344,78 @@ function updateProfile(userId: string, edit: (profile: AuthProfile) => void) {
}

/**
* Replaces the file with this one account, dropping any previous profile and its secrets. Nothing
* puts a second profile there yet; additive login is #1386. Dropping the old secrets is what keeps
* the write safe: the caller writes the new token next, so a failure there leaves nobody logged in
* rather than the old token beside the new name.
* Adds the account, or updates it in place when it is already stored, and makes it active. Other
* profiles are kept. A stored profile keeps its own `secretsBackend` and file-backend secrets, so
* a re-login finds its secrets where they already are.
*
* The file-level `secretsBackend` is set only on a new file: changing it would redirect every
* profile that follows it. A profile whose backend differs records its own.
*/
export function replaceStoredAccount(userId: string, profile: AuthProfile, secretsBackend: CredentialsBackend) {
export function upsertProfile(userId: string, profile: AuthProfile, backend: CredentialsBackend) {
assertSupportedAuthFileVersion();

// The snapshot described the account being replaced, and is never refreshed, so keeping it
// would leave one user's details on disk under another user's login.
rmSync(AUTH_BACKUP_FILE_PATH(), { force: true, maxRetries: 10, retryDelay: 100 });
const current = readAuthFile();
// A file the migration could not bring to v2 has no profiles to keep.
const file: AuthFile =
current.version === AUTH_FILE_VERSION ? current : { version: AUTH_FILE_VERSION, secretsBackend: backend };
file.secretsBackend ??= backend;
file.profiles ??= {};
// Unkeyed secrets belong to the account that was active, and re-keying would file them under this one.
delete file.token;
delete file.proxy;

writeAuthFile({
version: AUTH_FILE_VERSION,
activeProfile: userId,
profiles: { [userId]: profile },
secretsBackend,
});
const existing = file.profiles[userId];
const secretsBackend = existing?.secretsBackend ?? backend;
file.profiles[userId] = {
...profile,
...(secretsBackend !== file.secretsBackend ? { secretsBackend } : {}),
...(existing?.token ? { token: existing.token } : {}),
...(existing?.proxy ? { proxy: existing.proxy } : {}),
};

file.activeProfile = userId;
writeAuthFile(file);
}

/**
* 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.
* Drops the active profile together with the secrets stored beside it. The profile with the most
* recent `loggedInAt` becomes active. The file and the v1 backup go away once no profile is left,
* so logging out leaves no token on disk.
*/
export function removeActiveProfile() {
export function removeActiveProfile(): {
removed?: AuthProfile & { id: string };
active?: AuthProfile & { id: string };
} {
const file = readAuthFile();

// No version guard: refusing to discard a file this CLI cannot read leaves no way out. It goes
// whole rather than edited, which would leave something worse than either outcome.
if (file.version !== AUTH_FILE_VERSION) {
discardAuthFiles();
return;
return {};
}

const active = file.activeProfile;
if (active && file.profiles) delete file.profiles[active];
const removedId = file.activeProfile;
const removedProfile = removedId ? file.profiles?.[removedId] : undefined;
const removed = removedId && removedProfile ? { id: removedId, ...removedProfile } : undefined;

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

if (Object.keys(file.profiles ?? {}).length === 0) {
const [nextId] = Object.entries(file.profiles ?? {})
.sort(([, a], [, b]) => (b.loggedInAt ?? '').localeCompare(a.loggedInAt ?? ''))
.map(([id]) => id);

if (!nextId) {
discardAuthFiles();
return;
return { removed };
}

file.activeProfile = nextId;
writeAuthFile(file);
return { removed, active: { id: nextId, ...file.profiles![nextId] } };
}

function discardAuthFiles() {
Expand Down
20 changes: 12 additions & 8 deletions src/lib/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { AxiosHeaders } from 'axios';

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

import { ensureAuthFileCurrent, getActiveProfileId, replaceStoredAccount } from './auth-file.js';
import { ensureAuthFileCurrent, getActiveProfileId, upsertProfile } from './auth-file.js';
import { APIFY_CLIENT_DEFAULT_HEADERS, AUTH_FILE_PATH, CommandExitCodes } from './consts.js';
import {
clearKeyringSecrets,
Expand Down Expand Up @@ -180,14 +180,19 @@ export async function loginWithToken(

const proxyPassword = userInfo.proxy?.password;

// Brings a stored account to the current shape first, or the upsert below would find nothing to keep.
await ensureMigrated();
await ensureAuthFileCurrent();
await ensureSecretsKeyed();

const previousUserId = getActiveProfileId();

const { organizationOwnerUserId } = userInfo as { organizationOwnerUserId?: string };
replaceStoredAccount(
upsertProfile(
userInfo.id,
{
username: userInfo.username,
name: null,
name: userInfo.username || userInfo.id,
...(organizationOwnerUserId ? { organizationOwnerUserId } : {}),
authMethod: 'token',
expiresAt: null,
Expand All @@ -197,12 +202,11 @@ export async function loginWithToken(
await getBackend(),
);

// Only once the switch is on disk: a failed write leaves auth.json naming the previous account, whose entries nothing else can find.
if (previousUserId && previousUserId !== userInfo.id) {
await clearKeyringSecrets(previousUserId);
}
// Leftover unkeyed entries are the outgoing account's; the next keying pass would file them under this one.
// Only once the switch is on disk: a failed write leaves the previous account active, and it may still read them.
if (previousUserId && previousUserId !== userInfo.id) await clearKeyringSecrets();

// After the account, which drops the previous secrets. `skipIfUnchanged` avoids a Keychain prompt.
// After the profile, which says where its secrets go. `skipIfUnchanged` avoids a Keychain prompt.
await setSecret(userInfo.id, 'token', token, { skipIfUnchanged: true });

if (proxyPassword) {
Expand Down
5 changes: 2 additions & 3 deletions src/lib/credentials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,9 +225,8 @@ export async function setSecret(
}

/**
* Forget one of an account's secrets. Called for a proxy password when the account has none, so
* the previous account's does not survive a re-login — the keyring outlives the auth.json rewrite
* that replaces everything else.
* Forget one of an account's secrets. Called for a proxy password when the account has none, so a
* re-login does not keep one the account no longer has.
*/
export async function deleteSecret(userId: string, kind: SecretKind): Promise<void> {
if ((await backendFor(userId)) === 'keyring') {
Expand Down
Loading