From 819e533723a7e6ef0218769da22f996bb42a94b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Richard=20Sol=C3=A1r?= Date: Thu, 24 Sep 2026 17:56:24 +0200 Subject: [PATCH] feat: make token login additive across accounts Login adds or updates a profile keyed by user ID and makes it active. Logout removes the active profile and activates the most recently logged-in one that remains. Co-Authored-By: Claude Opus 5.5 --- docs/reference.md | 9 +- src/commands/auth/login.ts | 8 ++ src/commands/auth/logout.ts | 17 ++- src/lib/auth-file.ts | 80 +++++++++---- src/lib/auth.ts | 20 ++-- src/lib/credentials.ts | 5 +- test/local/commands/auth.test.ts | 188 +++++++++++++++++++++++++++---- test/local/lib/auth-file.test.ts | 75 ++++++++---- 8 files changed, 313 insertions(+), 89 deletions(-) diff --git a/docs/reference.md b/docs/reference.md index dab7d12a9..6e123da75 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -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'. ``` @@ -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 diff --git a/src/commands/auth/login.ts b/src/commands/auth/login.ts index 959c99c2c..3577eff12 100644 --- a/src/commands/auth/login.ts +++ b/src/commands/auth/login.ts @@ -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'; @@ -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({ diff --git a/src/commands/auth/logout.ts b/src/commands/auth/logout.ts index 9f84a2092..c3d00cc28 100644 --- a/src/commands/auth/logout.ts +++ b/src/commands/auth/logout.ts @@ -1,6 +1,6 @@ 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 } from '../../lib/consts.js'; @@ -13,7 +13,8 @@ export class AuthLogoutCommand extends ApifyCommand { 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'; @@ -31,11 +32,17 @@ export class AuthLogoutCommand extends ApifyCommand { // The keyring goes first: `auth.json` is the only index of what it holds, so removing the // profile would strand its entries. await clearKeyringSecrets(getActiveProfileId()); - removeActiveProfile(); + const { removed, active } = removeActiveProfile(); - await updateUserId(null); + await updateUserId(active?.id ?? null); - success({ message: 'You are logged out from your Apify account.' }); + 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') { diff --git a/src/lib/auth-file.ts b/src/lib/auth-file.ts index 005716824..2e7cc221c 100644 --- a/src/lib/auth-file.ts +++ b/src/lib/auth-file.ts @@ -18,7 +18,7 @@ export const AUTH_BACKUP_FILE_PATH = () => `${AUTH_FILE_PATH()}.v1.bak`; */ export interface AuthProfile { username?: string; - /** Human label for `--profile `. Unused until profiles get names. */ + /** Human label for `--profile `. */ name: string | null; /** Set means the profile is an organization rather than a personal account. */ organizationOwnerUserId?: string; @@ -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; @@ -256,6 +256,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; } @@ -341,52 +345,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() { diff --git a/src/lib/auth.ts b/src/lib/auth.ts index e7a5b7704..49153d084 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -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, @@ -180,19 +180,21 @@ export async function loginWithToken( const proxyPassword = userInfo.proxy?.password; - // `auth.json` is the only index of what the keyring holds, so the outgoing account's entries - // have to go before its ID leaves the file. + // Brings a stored account to the current shape first, or the upsert below would find nothing to keep. + await ensureMigrated(); + await ensureAuthFileCurrent(); + await ensureSecretsKeyed(); + + // Leftover unkeyed entries are the outgoing account's; the next keying pass would file them under this one. const previousUserId = getActiveProfileId(); - if (previousUserId && previousUserId !== userInfo.id) { - await clearKeyringSecrets(previousUserId); - } + if (previousUserId && previousUserId !== userInfo.id) await clearKeyringSecrets(); 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, @@ -202,7 +204,7 @@ export async function loginWithToken( await getBackend(), ); - // 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) { diff --git a/src/lib/credentials.ts b/src/lib/credentials.ts index 38d495f43..6e3771377 100644 --- a/src/lib/credentials.ts +++ b/src/lib/credentials.ts @@ -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 { if ((await backendFor(userId)) === 'keyring') { diff --git a/test/local/commands/auth.test.ts b/test/local/commands/auth.test.ts index f7109e8af..f7aadaa80 100644 --- a/test/local/commands/auth.test.ts +++ b/test/local/commands/auth.test.ts @@ -1,10 +1,11 @@ -import { existsSync, statSync } from 'node:fs'; +import { existsSync, mkdirSync, statSync, writeFileSync } from 'node:fs'; import process from 'node:process'; -import { AUTH_FILE_PATH, CommandExitCodes } from '../../../src/lib/consts.js'; -import { getSecret } from '../../../src/lib/credentials.js'; +import { AUTH_BACKUP_FILE_PATH, type AuthProfile } from '../../../src/lib/auth-file.js'; +import { AUTH_FILE_PATH, CommandExitCodes, GLOBAL_CONFIGS_FOLDER } from '../../../src/lib/consts.js'; +import { __resetCredentialsForTests, getSecret } from '../../../src/lib/credentials.js'; import { clientState, resetApifyClientMock } from '../../__setup__/apify-client-mock.js'; -import { readActiveProfile, readAuthFile } from '../../__setup__/auth-file.js'; +import { readActiveProfile, readAuthFile, v1AuthFile } from '../../__setup__/auth-file.js'; import { useAuthSetup, useKeyringBackend } from '../../__setup__/hooks/useAuthSetup.js'; import { useConsoleSpy } from '../../__setup__/hooks/useConsoleSpy.js'; import { @@ -12,6 +13,7 @@ import { keyringSetKeys, keyringStore, keyringTokenKey, + LEGACY_KEYRING_TOKEN_KEY, resetKeyringMock, } from '../../__setup__/keyring-mock.js'; @@ -35,6 +37,19 @@ const { testRunCommand } = await import('../../../src/lib/command-framework/apif const TOKEN = 'apify_api_test_token'; +const PROFILE: AuthProfile = { + name: null, + authMethod: 'token', + expiresAt: null, + hasRefreshToken: false, + loggedInAt: null, +}; + +const writeAuthFile = (data: unknown) => { + mkdirSync(GLOBAL_CONFIGS_FOLDER(), { recursive: true }); + writeFileSync(AUTH_FILE_PATH(), JSON.stringify(data)); +}; + const login = (token = TOKEN) => testRunCommand(AuthLoginCommand, { flags_token: token }); describe('auth commands', () => { @@ -52,7 +67,7 @@ describe('auth commands', () => { expect(readActiveProfile()).toEqual({ id: 'uid', username: 'me', - name: null, + name: 'me', authMethod: 'token', expiresAt: null, hasRefreshToken: false, @@ -84,8 +99,7 @@ describe('auth commands', () => { expect(await getSecret('uid', 'token')).toBeUndefined(); }); - it('logging in as another account replaces the stored profile', async () => { - clientState.user = { id: 'uid', username: 'me', email: 'me@example.com' }; + it('logging in as another account adds a profile and makes it active', async () => { await login(); clientState.user = { id: 'uid2', username: 'other' }; @@ -93,9 +107,90 @@ describe('auth commands', () => { const authFile = readAuthFile(); expect(authFile).toMatchObject({ activeProfile: 'uid2' }); - // Additive login is a later stage; until then the old profile must not linger. - expect(Object.keys(authFile.profiles!)).toEqual(['uid2']); - expect(readActiveProfile()).toMatchObject({ username: 'other', token: 'apify_api_other_token' }); + expect(Object.keys(authFile.profiles!).sort()).toEqual(['uid', 'uid2']); + expect(authFile.profiles!.uid).toMatchObject({ name: 'me', token: TOKEN }); + expect(readActiveProfile()).toMatchObject({ name: 'other', token: 'apify_api_other_token' }); + expect(lastErrorMessage()).toContain('Other stored accounts: me.'); + }); + + it('logging in again to a stored account updates it in place and makes it active', async () => { + await login(); + const firstLoginAt = readActiveProfile()!.loggedInAt!; + + clientState.user = { id: 'uid2', username: 'other' }; + await login('apify_api_other_token'); + + clientState.user = { id: 'uid', username: 'me-renamed' }; + await new Promise((resolve) => setTimeout(resolve, 5)); + await login('apify_api_rotated_token'); + + const authFile = readAuthFile(); + expect(Object.keys(authFile.profiles!).sort()).toEqual(['uid', 'uid2']); + expect(readActiveProfile()).toMatchObject({ + id: 'uid', + name: 'me-renamed', + token: 'apify_api_rotated_token', + }); + expect(readActiveProfile()!.loggedInAt! > firstLoginAt).toBe(true); + }); + + it('logout with two accounts makes the other one active and says so', async () => { + await login(); + clientState.user = { id: 'uid2', username: 'other' }; + await login('apify_api_other_token'); + + await testRunCommand(AuthLogoutCommand, {}); + + expect(lastErrorMessage()).toContain('You are logged out of other. me is now the active account.'); + expect(readAuthFile().profiles).not.toHaveProperty('uid2'); + expect(readActiveProfile()).toMatchObject({ id: 'uid', token: TOKEN }); + + await testRunCommand(AuthTokenCommand, {}); + expect(lastLogMessage()).toBe(TOKEN); + }); + + it('logout makes the most recently logged-in remaining account active', async () => { + writeAuthFile({ + version: 2, + activeProfile: 'uid', + secretsBackend: 'file', + profiles: { + uid: { ...PROFILE, name: 'me', loggedInAt: '2026-03-01T00:00:00.000Z', token: TOKEN }, + old: { ...PROFILE, name: 'old', loggedInAt: null, token: 't-old' }, + recent: { ...PROFILE, name: 'recent', loggedInAt: '2026-02-01T00:00:00.000Z', token: 't-recent' }, + older: { ...PROFILE, name: 'older', loggedInAt: '2026-01-01T00:00:00.000Z', token: 't-older' }, + }, + }); + + await testRunCommand(AuthLogoutCommand, {}); + + expect(readAuthFile().activeProfile).toBe('recent'); + }); + + it('logout with a dangling active profile still names the account that becomes active', async () => { + writeAuthFile({ + version: 2, + activeProfile: 'gone', + secretsBackend: 'file', + profiles: { uid: { ...PROFILE, name: 'me', token: TOKEN } }, + }); + + await testRunCommand(AuthLogoutCommand, {}); + + expect(lastErrorMessage()).toContain('You are logged out. me is now the active account.'); + expect(readAuthFile().activeProfile).toBe('uid'); + }); + + it('a migrated v1 account survives logging in to a second account', async () => { + writeAuthFile(v1AuthFile()); + + clientState.user = { id: 'uid2', username: 'other' }; + await login('apify_api_other_token'); + + const authFile = readAuthFile(); + expect(authFile).toMatchObject({ version: 2, activeProfile: 'uid2' }); + expect(authFile.profiles!.uid).toMatchObject({ username: 'me', token: 'apify_api_v1_token' }); + expect(existsSync(AUTH_BACKUP_FILE_PATH())).toBe(true); }); it('login with an invalid token stores nothing and fails the command', async () => { @@ -202,30 +297,81 @@ describe('auth commands', () => { expect(readActiveProfile()).toMatchObject({ id: 'uid', username: 'me' }); }); - it('logging in as an account with no proxy password forgets the previous one', async () => { + it('logging in again with no proxy password forgets the previous one', async () => { await login(); expect(keyringStore.get(PROXY_PASSWORD_KEY)).toBe('pw'); - clientState.user = { id: 'uid2', username: 'other' }; - await login('apify_api_other_token'); + clientState.user = { id: 'uid', username: 'me' }; + await login(); - // The keyring outlives the auth.json rewrite, so without an explicit delete the child - // Actor would run with the previous account's proxy credential. expect(keyringStore.has(PROXY_PASSWORD_KEY)).toBe(false); - expect(keyringStore.has(keyringProxyPasswordKey('uid2'))).toBe(false); }); - it('switching accounts clears the outgoing account entries', async () => { + it('logging in to a second account keeps the first account entries', async () => { await login(); - expect(keyringStore.get(TOKEN_KEY)).toBe(TOKEN); clientState.user = { id: 'uid2', username: 'other', proxy: { password: 'pw2' } }; await login('apify_api_other_token'); - // auth.json no longer names uid, and the keyring has no listing API, so anything left - // under its key would be unreachable for good. - expect(keyringStore.get(TOKEN_KEY)).toBeUndefined(); + expect(keyringStore.get(TOKEN_KEY)).toBe(TOKEN); + expect(keyringStore.get(PROXY_PASSWORD_KEY)).toBe('pw'); expect(keyringStore.get(keyringTokenKey('uid2'))).toBe('apify_api_other_token'); + expect(keyringStore.get(keyringProxyPasswordKey('uid2'))).toBe('pw2'); + }); + + it('a second login with the keyring disabled leaves the first account on the keyring', async () => { + await login(); + + vitest.stubEnv('APIFY_DISABLE_KEYRING', '1'); + __resetCredentialsForTests(); + clientState.user = { id: 'uid2', username: 'other' }; + await login('apify_api_other_token'); + + const authFile = readAuthFile(); + expect(authFile.secretsBackend).toBe('keyring'); + expect(authFile.profiles!.uid2).toMatchObject({ secretsBackend: 'file', token: 'apify_api_other_token' }); + expect(authFile.profiles!.uid).not.toHaveProperty('secretsBackend'); + + vitest.stubEnv('APIFY_DISABLE_KEYRING', ''); + __resetCredentialsForTests(); + await testRunCommand(AuthLogoutCommand, {}); + + expect(await getSecret('uid', 'token')).toBe(TOKEN); + }); + + it('logging in again with the keyring disabled keeps reading the new token once it is enabled', async () => { + await login(); + + vitest.stubEnv('APIFY_DISABLE_KEYRING', '1'); + __resetCredentialsForTests(); + await login('apify_api_rotated_token'); + + vitest.stubEnv('APIFY_DISABLE_KEYRING', ''); + __resetCredentialsForTests(); + expect(await getSecret('uid', 'token')).toBe('apify_api_rotated_token'); + }); + + it('logging in to a second account drops unkeyed entries left by the first', async () => { + await login(); + keyringStore.set(LEGACY_KEYRING_TOKEN_KEY, TOKEN); + + clientState.user = { id: 'uid2', username: 'other' }; + await login('apify_api_other_token'); + + expect(keyringStore.has(LEGACY_KEYRING_TOKEN_KEY)).toBe(false); + }); + + it('logout with two accounts clears only the outgoing account entries', async () => { + await login(); + clientState.user = { id: 'uid2', username: 'other', proxy: { password: 'pw2' } }; + await login('apify_api_other_token'); + + await testRunCommand(AuthLogoutCommand, {}); + + expect(keyringStore.has(keyringTokenKey('uid2'))).toBe(false); + expect(keyringStore.has(keyringProxyPasswordKey('uid2'))).toBe(false); + expect(keyringStore.get(TOKEN_KEY)).toBe(TOKEN); + expect(readActiveProfile()).toMatchObject({ id: 'uid' }); }); it('logging in again as the same account keeps its entries', async () => { diff --git a/test/local/lib/auth-file.test.ts b/test/local/lib/auth-file.test.ts index 4c3980196..2d09f254a 100644 --- a/test/local/lib/auth-file.test.ts +++ b/test/local/lib/auth-file.test.ts @@ -9,7 +9,7 @@ import { getActiveProfile, lookUpActiveProfile, removeActiveProfile, - replaceStoredAccount, + upsertProfile, } from '../../../src/lib/auth-file.js'; import { resolveAuth } from '../../../src/lib/auth.js'; import { AUTH_FILE_PATH, GLOBAL_CONFIGS_FOLDER } from '../../../src/lib/consts.js'; @@ -267,7 +267,7 @@ describe('auth.json v2', () => { const newer = { version: 3, activeProfile: 'uid', profiles: { uid: { username: 'me' } } }; write(newer); - expect(() => replaceStoredAccount('uid2', V2_PROFILE, 'file')).toThrow('written by a newer Apify CLI'); + expect(() => upsertProfile('uid2', V2_PROFILE, 'file')).toThrow('written by a newer Apify CLI'); expect(readAuthFile()).toEqual(newer); }); @@ -287,53 +287,84 @@ describe('auth.json v2', () => { }); }); - describe('replacing the stored account', () => { - it('drops the previous account and its secrets', () => { + describe('adding or updating a profile', () => { + it('keeps the other profiles and makes the new one active', () => { write({ version: 2, activeProfile: 'old', - profiles: { old: { ...V2_PROFILE, username: 'old' } }, + profiles: { old: { ...V2_PROFILE, username: 'old', token: 'apify_api_old' } }, secretsBackend: 'file', - token: 'apify_api_old', - proxy: { password: 'old_pw' }, }); - replaceStoredAccount('new', { ...V2_PROFILE, username: 'new' }, 'file'); + upsertProfile('new', { ...V2_PROFILE, username: 'new' }, 'file'); const file = readAuthFile(); - expect(Object.keys(file.profiles!)).toEqual(['new']); + expect(Object.keys(file.profiles!).sort()).toEqual(['new', 'old']); expect(file.activeProfile).toBe('new'); - // A leftover token beside the new account authenticates as the wrong user. - expect(file).not.toHaveProperty('token'); - expect(file).not.toHaveProperty('proxy'); + expect(file.profiles!.old).toMatchObject({ token: 'apify_api_old' }); + }); + + it('keeps a stored profile secrets and backend when it logs in again', () => { + write({ + version: 2, + activeProfile: 'uid', + profiles: { + uid: { ...V2_PROFILE, username: 'me', secretsBackend: 'file', token: 'tok', proxy: { password: 'pw' } }, + }, + secretsBackend: 'keyring', + }); + + upsertProfile('uid', { ...V2_PROFILE, username: 'renamed' }, 'keyring'); + + expect(readActiveProfile()).toMatchObject({ + username: 'renamed', + secretsBackend: 'file', + token: 'tok', + proxy: { password: 'pw' }, + }); + }); + + it('records the backend on a new profile when it differs from the file-level one', () => { + write({ + version: 2, + activeProfile: 'old', + profiles: { old: { ...V2_PROFILE, username: 'old' } }, + secretsBackend: 'keyring', + }); + + upsertProfile('new', { ...V2_PROFILE, username: 'new' }, 'file'); + + const file = readAuthFile(); + expect(file.secretsBackend).toBe('keyring'); + expect(file.profiles!.new!.secretsBackend).toBe('file'); + expect(file.profiles!.old).not.toHaveProperty('secretsBackend'); }); - it('leaves no token when the caller never writes one', async () => { + it('drops unkeyed secrets so they are never keyed to the new account', async () => { write({ version: 2, activeProfile: 'old', profiles: { old: { ...V2_PROFILE, username: 'old' } }, secretsBackend: 'file', token: 'apify_api_old', + proxy: { password: 'old_pw' }, }); - replaceStoredAccount('new', { ...V2_PROFILE, username: 'new' }, 'file'); + upsertProfile('new', { ...V2_PROFILE, username: 'new' }, 'file'); - // Logged out, rather than logged in as the account that just went away. + const file = readAuthFile(); + expect(file).not.toHaveProperty('token'); + expect(file).not.toHaveProperty('proxy'); await expect(getSecret('new', 'token')).resolves.toBeUndefined(); }); - }); - describe('replacing the stored account', () => { - it('removes the snapshot of the account it replaced', async () => { + it('keeps the v1 snapshot, since the migrated account is still stored', async () => { write(v1AuthFile({ secretsBackend: 'file' })); await ensureAuthFileCurrent(); - expect(existsSync(AUTH_BACKUP_FILE_PATH())).toBe(true); - replaceStoredAccount('other', { ...V2_PROFILE, username: 'other' }, 'file'); + upsertProfile('other', { ...V2_PROFILE, username: 'other' }, 'file'); - // It described the previous account and is never refreshed, so it must not survive. - expect(existsSync(AUTH_BACKUP_FILE_PATH())).toBe(false); + expect(existsSync(AUTH_BACKUP_FILE_PATH())).toBe(true); }); });