diff --git a/cli.js b/cli.js index 7a32b43..6cf807a 100755 --- a/cli.js +++ b/cli.js @@ -1563,7 +1563,8 @@ async function runAuthLogin(globalFlags, options = {}) { config, forceSms: options.forceSms, useQr: options.qr, - disableUpdates: !options.follow, + // mtcute completes QR login from updateLoginToken; disabling updates drops it. + disableUpdates: !options.follow && !options.qr, })); try { const loginSuccess = await telegramClient.login(); @@ -1577,6 +1578,18 @@ async function runAuthLogin(globalFlags, options = {}) { } bindAccountIdentity(storeDir, me); } + if (options.qr && !options.follow) { + // A fresh client proves that mtcute committed the session before we report success. + const authenticatedClient = telegramClient; + telegramClient = null; + await authenticatedClient.destroy(); + ({ telegramClient } = createTelegramClient({ storeDir, config, disableUpdates: true })); + const savedUser = await telegramClient.getCurrentUser(); + if (!savedUser) { + throw new Error('Telegram accepted the QR scan, but the saved session is not authorized. Try `tgcli auth --qr` again.'); + } + console.log('QR login verified from saved session.'); + } if (options.follow) { let archiveError = null; try { diff --git a/package-lock.json b/package-lock.json index ecdca1c..d863618 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@dapi/tgcli", - "version": "2.8.3", + "version": "2.8.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@dapi/tgcli", - "version": "2.8.3", + "version": "2.8.7", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.27.1", diff --git a/package.json b/package.json index 309e537..e010b55 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@dapi/tgcli", - "version": "2.8.3", + "version": "2.8.7", "type": "module", "description": "Telegram CLI + MCP server powered by MTProto and the official MCP SDK", "main": "mcp-server.js", diff --git a/telegram-client.js b/telegram-client.js index ed8c0b2..1e55e78 100644 --- a/telegram-client.js +++ b/telegram-client.js @@ -686,6 +686,7 @@ class TelegramClient { this.updateEmitter = new EventEmitter(); this.updatesRunning = false; this.rawUpdateHandler = null; + this.activeReadline = null; const userUpdates = options.updates ?? {}; const updatesConfig = { ...userUpdates, @@ -757,6 +758,11 @@ class TelegramClient { await this._recreateClient(); } + _isPasswordRequiredError(error) { + const message = (error?.errorMessage || error?.text || error?.message || '').toUpperCase(); + return message.includes('SESSION_PASSWORD_NEEDED'); + } + _isUnauthorizedError(error) { if (!error) return false; const code = error.code || error.status || error.errorCode; @@ -775,11 +781,13 @@ class TelegramClient { } async _isAuthorized() { + this.authNeedsPassword = false; try { const user = await this.client.getMe(); await this._verifyIdentity(user); return true; } catch (error) { + this.authNeedsPassword = this._isPasswordRequiredError(error); if (this._isUnauthorizedError(error)) { return false; } @@ -812,9 +820,11 @@ class TelegramClient { input: process.stdin, output: process.stdout, }); + this.activeReadline = rl; return new Promise(resolve => { rl.question(prompt, answer => { + if (this.activeReadline === rl) this.activeReadline = null; rl.close(); resolve(answer.trim()); }); @@ -841,6 +851,7 @@ class TelegramClient { output: process.stdout, terminal: true, }); + this.activeReadline = rl; rl.stdoutMuted = false; const writeOutput = rl._writeToOutput.bind(rl); rl._writeToOutput = (stringToWrite) => { @@ -851,6 +862,7 @@ class TelegramClient { return new Promise(resolve => { rl.question(prompt, answer => { + if (this.activeReadline === rl) this.activeReadline = null; rl.output.write('\n'); rl.close(); resolve(answer.trim()); @@ -862,12 +874,23 @@ class TelegramClient { _buildStartParams() { const startParams = { password: async () => { - const value = await this._askHiddenQuestion('Enter your 2FA password (leave empty if not enabled): '); - return value.length ? value : undefined; + const prompt = this.options.useQr + ? 'Telegram requires your 2FA password to finish QR login: ' + : 'Enter your Telegram 2FA password: '; + while (true) { + const value = await this._askHiddenQuestion(prompt); + if (value.length > 0) return value; + console.log('A 2FA password is required here. Press Ctrl+C to cancel login.'); + } }, }; if (this.options.useQr) { + startParams.invalidCodeCallback = (type) => { + if (type === 'password') { + console.log('Telegram rejected that 2FA password. Try again.'); + } + }; startParams.qrCodeHandler = (url, expiresAt) => { const expiresLabel = expiresAt instanceof Date && !Number.isNaN(expiresAt.getTime()) ? expiresAt.toISOString() @@ -876,8 +899,8 @@ class TelegramClient { qrcode.generate(url, { small: true }, (rendered) => { console.log(rendered); }); - console.log(`QR login URL: ${url}`); console.log(`QR expires at: ${expiresLabel}`); + console.log('Waiting for Telegram to confirm the QR login...'); }; } else { startParams.phone = this.phoneNumber; @@ -908,6 +931,10 @@ class TelegramClient { try { const hasExistingSession = await this._isAuthorized(); + if (!hasExistingSession && this.options.useQr && this.authNeedsPassword) { + console.log('Telegram is already waiting for 2FA on this session. Another QR scan will not complete this login without the password.'); + } + if (!hasExistingSession && !this.options.useQr && !this.phoneNumber) { throw new Error('TELEGRAM_PHONE_NUMBER is not configured.'); } @@ -1740,6 +1767,10 @@ class TelegramClient { } async destroy() { + if (this.activeReadline) { + this.activeReadline.close(); + this.activeReadline = null; + } if (this.updatesRunning) { try { await this.client.stopUpdatesLoop(); diff --git a/tests/auth-recovery.test.js b/tests/auth-recovery.test.js index 73eeb8e..b9a346a 100644 --- a/tests/auth-recovery.test.js +++ b/tests/auth-recovery.test.js @@ -55,6 +55,25 @@ describe('telegram auth recovery', () => { expect(logSpy).toHaveBeenCalledWith('Existing session is valid.'); }); + it('explains that QR cannot bypass a pending Telegram 2FA challenge', async () => { + const pendingError = Object.assign(new Error('SESSION_PASSWORD_NEEDED'), { code: 401 }); + const client = { + getMe: vi.fn().mockRejectedValue(pendingError), + start: vi.fn().mockResolvedValue({}), + }; + const tc = Object.create(TelegramClient.prototype); + tc.options = { useQr: true }; + tc.phoneNumber = ''; + tc.client = client; + tc._buildStartParams = vi.fn().mockReturnValue({ qrCodeHandler: vi.fn() }); + + expect(await tc.login()).toBe(true); + expect(client.start).toHaveBeenCalledTimes(1); + expect(logSpy).toHaveBeenCalledWith( + 'Telegram is already waiting for 2FA on this session. Another QR scan will not complete this login without the password.', + ); + }); + it('login retries once after session reset by recreating the MTProto client', async () => { const firstClient = { start: vi.fn().mockRejectedValue(new Error('Session is reset')), diff --git a/tests/cli-auth.test.js b/tests/cli-auth.test.js index 86cb57d..b7d9d6c 100644 --- a/tests/cli-auth.test.js +++ b/tests/cli-auth.test.js @@ -121,6 +121,50 @@ describe('cli auth command', () => { expect(destroy).toHaveBeenCalledTimes(1); }); + it('receives QR login updates and verifies the saved session before reporting success', async () => { + const loginClient = { + destroy: vi.fn().mockResolvedValue(undefined), + login: vi.fn().mockResolvedValue(true), + }; + const statusClient = { + destroy: vi.fn().mockResolvedValue(undefined), + getCurrentUser: vi.fn().mockResolvedValue({ id: 123456789n }), + }; + createTelegramClientMock + .mockReturnValueOnce({ telegramClient: loginClient }) + .mockReturnValueOnce({ telegramClient: statusClient }); + + await runAuthLogin({ json: false, timeoutMs: null }, { qr: true }); + + expect(createTelegramClientMock).toHaveBeenNthCalledWith(1, expect.objectContaining({ + useQr: true, + disableUpdates: false, + })); + expect(createTelegramClientMock).toHaveBeenNthCalledWith(2, expect.objectContaining({ + disableUpdates: true, + })); + expect(loginClient.destroy).toHaveBeenCalledTimes(1); + expect(statusClient.getCurrentUser).toHaveBeenCalledTimes(1); + expect(logSpy).toHaveBeenCalledWith('QR login verified from saved session.'); + expect(statusClient.destroy).toHaveBeenCalledTimes(1); + }); + + it('does not report QR login success when the saved session is unauthorized', async () => { + createTelegramClientMock + .mockReturnValueOnce({ telegramClient: { + destroy: vi.fn().mockResolvedValue(undefined), + login: vi.fn().mockResolvedValue(true), + } }) + .mockReturnValueOnce({ telegramClient: { + destroy: vi.fn().mockResolvedValue(undefined), + getCurrentUser: vi.fn().mockResolvedValue(null), + } }); + + await expect(runAuthLogin({ json: false, timeoutMs: null }, { qr: true })) + .rejects.toThrow('saved session is not authorized'); + expect(logSpy).not.toHaveBeenCalledWith(expect.stringContaining('Authenticated.')); + }); + it('treats symlinked tgcli binaries as the cli entrypoint', () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tgcli-cli-entrypoint-')); const symlinkPath = path.join(tmpDir, 'tgcli'); diff --git a/tests/telegram-client-auth.test.js b/tests/telegram-client-auth.test.js index 476d6b3..33eae8c 100644 --- a/tests/telegram-client-auth.test.js +++ b/tests/telegram-client-auth.test.js @@ -1,3 +1,7 @@ +import { spawn } from 'node:child_process'; +import readline from 'node:readline'; +import { PassThrough } from 'node:stream'; + const { mtcuteClientCtor, proxyTransportFromUrlMock, @@ -91,4 +95,94 @@ describe('telegram client auth bootstrap options', () => { ); }); + it('renders a QR without printing its login token as text', () => { + const client = new TelegramClient(12345, 'hash', '', '/tmp/tgcli-auth-qr.session', { useQr: true }); + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + const url = 'tg://login?token=private-login-token'; + + try { + client._buildStartParams().qrCodeHandler(url, new Date('2026-09-28T12:00:00Z')); + expect(logSpy).not.toHaveBeenCalledWith(expect.stringContaining(url)); + expect(logSpy).toHaveBeenCalledWith('Waiting for Telegram to confirm the QR login...'); + } finally { + logSpy.mockRestore(); + } + }); + + it('explains pending QR 2FA and does not submit an empty password', async () => { + const client = new TelegramClient(12345, 'hash', '', '/tmp/tgcli-auth-qr-2fa.session', { useQr: true }); + const ask = vi.spyOn(client, '_askHiddenQuestion') + .mockResolvedValueOnce('') + .mockResolvedValueOnce('correct-password'); + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + try { + const params = client._buildStartParams(); + expect(await params.password()).toBe('correct-password'); + expect(ask).toHaveBeenCalledTimes(2); + expect(ask).toHaveBeenCalledWith('Telegram requires your 2FA password to finish QR login: '); + expect(logSpy).toHaveBeenCalledWith('A 2FA password is required here. Press Ctrl+C to cancel login.'); + params.invalidCodeCallback('password'); + expect(logSpy).toHaveBeenCalledWith('Telegram rejected that 2FA password. Try again.'); + expect(logSpy).not.toHaveBeenCalledWith(expect.stringContaining('correct-password')); + } finally { + logSpy.mockRestore(); + } + }); + + it('closes an interactive prompt when the client is destroyed', async () => { + const input = new PassThrough(); + const output = new PassThrough(); + const prompt = readline.createInterface({ input, output, terminal: true }); + prompt.question('Password: ', () => {}); + const client = Object.create(TelegramClient.prototype); + client.client = { destroy: vi.fn().mockResolvedValue(undefined) }; + client.activeReadline = prompt; + client.updatesRunning = false; + client.rawUpdateHandler = null; + + try { + await client.destroy(); + expect(prompt.closed).toBe(true); + expect(client.activeReadline).toBe(null); + } finally { + input.destroy(); + output.destroy(); + } + }); + + it('lets a timed-out login process exit while stdin remains open', async () => { + const childScript = ` + import TelegramClient from './telegram-client.js'; + const client = Object.create(TelegramClient.prototype); + client.client = { destroy: async () => {} }; + client.updatesRunning = false; + client.rawUpdateHandler = null; + void client._askQuestion('code: '); + setTimeout(() => { void client.destroy(); }, 100); + `; + const child = spawn(process.execPath, ['--input-type=module', '-e', childScript], { + cwd: process.cwd(), + stdio: ['pipe', 'pipe', 'pipe'], + }); + let timeoutId; + + try { + const exitCode = await Promise.race([ + new Promise((resolve, reject) => { + child.once('error', reject); + child.once('exit', (code) => resolve(code)); + }), + new Promise((_, reject) => { + timeoutId = setTimeout(() => reject(new Error('Login process kept stdin open')), 5000); + }), + ]); + expect(exitCode).toBe(0); + } finally { + clearTimeout(timeoutId); + child.kill(); + child.stdin.destroy(); + } + }); + });