Skip to content
Open
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
15 changes: 14 additions & 1 deletion cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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 {
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
37 changes: 34 additions & 3 deletions telegram-client.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand All @@ -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;
}
Expand Down Expand Up @@ -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());
});
Expand All @@ -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) => {
Expand All @@ -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());
Expand All @@ -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()
Expand All @@ -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;
Expand Down Expand Up @@ -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.');
}
Expand Down Expand Up @@ -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();
Expand Down
19 changes: 19 additions & 0 deletions tests/auth-recovery.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')),
Expand Down
44 changes: 44 additions & 0 deletions tests/cli-auth.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
94 changes: 94 additions & 0 deletions tests/telegram-client-auth.test.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
import { spawn } from 'node:child_process';
import readline from 'node:readline';
import { PassThrough } from 'node:stream';

const {
mtcuteClientCtor,
proxyTransportFromUrlMock,
Expand Down Expand Up @@ -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();
}
});

});
Loading