feat(core): qr code login

This commit is contained in:
alina 🌸 2024-06-28 17:30:47 +03:00
parent 9daa551e33
commit e2464f7f3f
Signed by: teidesu
SSH key fingerprint: SHA256:uNeCpw6aTSU4aIObXLvHfLkDa82HWH9EiOj9AXOIRpI
13 changed files with 311 additions and 35 deletions

View file

@ -743,6 +743,8 @@ withParams(params: RpcCallOptions): this\n`)
'computeSrpParams',
'computeNewPasswordHash',
'onConnectionState',
'getServerUpdateHandler',
'changePrimaryDc',
].forEach((name) => {
output.write(
`TelegramClient.prototype.${name} = function(...args) {\n` +

View file

@ -15,7 +15,7 @@ import {
writeStringSession,
} from '../utils/index.js'
import { LogManager } from '../utils/logger.js'
import { ConnectionState, ITelegramClient } from './client.types.js'
import { ConnectionState, ITelegramClient, ServerUpdateHandler } from './client.types.js'
import { AppConfigManager } from './managers/app-config-manager.js'
import { ITelegramStorageProvider } from './storage/provider.js'
import { TelegramStorageManager, TelegramStorageManagerExtraOptions } from './storage/storage.js'
@ -30,7 +30,7 @@ export interface BaseTelegramClientOptions extends MtClientOptions {
export class BaseTelegramClient implements ITelegramClient {
readonly updates?: UpdatesManager
private _serverUpdatesHandler: (updates: tl.TypeUpdates) => void = () => {}
private _serverUpdatesHandler: ServerUpdateHandler = () => {}
private _connectionStateHandler: (state: ConnectionState) => void = () => {}
readonly log
@ -276,10 +276,14 @@ export class BaseTelegramClient implements ITelegramClient {
this.updates?.handleClientUpdate(updates, noDispatch)
}
onServerUpdate(handler: (update: tl.TypeUpdates) => void): void {
onServerUpdate(handler: ServerUpdateHandler): void {
this._serverUpdatesHandler = handler
}
getServerUpdateHandler(): ServerUpdateHandler {
return this._serverUpdatesHandler
}
onUpdate(handler: RawUpdateHandler): void {
if (!this.updates) {
throw new MtArgumentError('Updates manager is disabled')
@ -325,4 +329,8 @@ export class BaseTelegramClient implements ITelegramClient {
get stopSignal(): AbortSignal {
return this.mt.stopSignal
}
changePrimaryDc(dcId: number): Promise<void> {
return this.mt.network.changePrimaryDc(dcId)
}
}

View file

@ -21,6 +21,7 @@ import { sendCode } from './methods/auth/send-code.js'
import { sendRecoveryCode } from './methods/auth/send-recovery-code.js'
import { signIn } from './methods/auth/sign-in.js'
import { signInBot } from './methods/auth/sign-in-bot.js'
import { signInQr } from './methods/auth/sign-in-qr.js'
import { start } from './methods/auth/start.js'
import { startTest } from './methods/auth/start-test.js'
import { isSelfPeer } from './methods/auth/utils.js'
@ -262,6 +263,7 @@ import {
BotReactionCountUpdate,
BotReactionUpdate,
BotStoppedUpdate,
BusinessCallbackQuery,
BusinessChatLink,
BusinessConnection,
BusinessMessage,
@ -455,6 +457,13 @@ export interface TelegramClient extends ITelegramClient {
* @param handler Inline callback query handler
*/
on(name: 'inline_callback_query', handler: (upd: InlineCallbackQuery) => void): this
/**
* Register a business callback query handler
*
* @param name Event name
* @param handler Business callback query handler
*/
on(name: 'business_callback_query', handler: (upd: BusinessCallbackQuery) => void): this
/**
* Register a poll update handler
*
@ -647,6 +656,9 @@ export interface TelegramClient extends ITelegramClient {
/** Confirmation code identifier from {@link SentCode} */
phoneCodeHash: string
/** Abort signal */
abortSignal?: AbortSignal
}): Promise<SentCode>
/**
* Simple wrapper that calls {@link start} and then
@ -678,6 +690,9 @@ export interface TelegramClient extends ITelegramClient {
/** Additional code settings to pass to the server */
codeSettings?: Omit<tl.RawCodeSettings, '_' | 'logoutTokens'>
/** Abort signal */
abortSignal?: AbortSignal
}): Promise<SentCode>
/**
* Send a code to email needed to recover your password
@ -697,6 +712,29 @@ export interface TelegramClient extends ITelegramClient {
* @throws BadRequestError In case the bot token is invalid
*/
signInBot(token: string): Promise<User>
/**
* Execute the [QR login flow](https://core.telegram.org/api/qr-login).
*
* This method will resolve once the authorization is complete,
* returning the authorized user.
* **Available**: 👤 users only
*
*/
signInQr(params: {
/**
* Function that will be called whenever the login URL is changed.
*
* The app is expected to display `url` as a QR code to the user
*/
onUrlUpdated: (url: string, expires: Date) => void
/** Password for 2FA */
password?: MaybeDynamic<string>
/** Abort signal */
abortSignal?: AbortSignal
}): Promise<User>
/**
* Authorize a user in Telegram with a valid confirmation code.
*
@ -713,6 +751,8 @@ export interface TelegramClient extends ITelegramClient {
phoneCodeHash: string
/** The confirmation code that was received */
phoneCode: string
/** Abort signal */
abortSignal?: AbortSignal
}): Promise<User>
/**
* Utility function to quickly authorize on test DC
@ -778,6 +818,15 @@ export interface TelegramClient extends ITelegramClient {
*/
sessionForce?: boolean
/**
* When passed, [QR login flow](https://core.telegram.org/api/qr-login)
* will be used instead of the regular login flow.
*
* This function will be called whenever the login URL is changed,
* and the app is expected to display it as a QR code to the user.
*/
qrCodeHandler?: (url: string, expires: Date) => void
/**
* Phone number of the account.
* If account does not exist, it will be created
@ -830,6 +879,9 @@ export interface TelegramClient extends ITelegramClient {
/** Additional code settings to pass to the server */
codeSettings?: Omit<tl.RawCodeSettings, '_' | 'logoutTokens'>
/** Abort signal */
abortSignal?: AbortSignal
}): Promise<User>
/**
* Check if the given peer/input peer is referring to the current user
@ -5613,6 +5665,9 @@ TelegramClient.prototype.sendRecoveryCode = function (...args) {
TelegramClient.prototype.signInBot = function (...args) {
return signInBot(this._client, ...args)
}
TelegramClient.prototype.signInQr = function (...args) {
return signInQr(this._client, ...args)
}
TelegramClient.prototype.signIn = function (...args) {
return signIn(this._client, ...args)
}
@ -6428,6 +6483,12 @@ TelegramClient.prototype.computeNewPasswordHash = function (...args) {
TelegramClient.prototype.onConnectionState = function (...args) {
return this._client.onConnectionState(...args)
}
TelegramClient.prototype.getServerUpdateHandler = function (...args) {
return this._client.getServerUpdateHandler(...args)
}
TelegramClient.prototype.changePrimaryDc = function (...args) {
return this._client.changePrimaryDc(...args)
}
TelegramClient.prototype.onServerUpdate = function () {
throw new Error('onServerUpdate is not available for TelegramClient, use .on() methods instead')
}

View file

@ -22,6 +22,8 @@ import type { StringSessionData } from './utils/string-session.js'
*/
export type ConnectionState = 'offline' | 'connecting' | 'updating' | 'connected'
export type ServerUpdateHandler = (update: tl.TypeUpdates) => void
// NB: when adding new methods, don't forget to add them to:
// - worker/port.ts
// - generate-client script
@ -51,7 +53,8 @@ export interface ITelegramClient {
emitError(err: unknown): void
handleClientUpdate(updates: tl.TypeUpdates, noDispatch?: boolean): void
onServerUpdate(handler: (update: tl.TypeUpdates) => void): void
onServerUpdate(handler: ServerUpdateHandler): void
getServerUpdateHandler(): ServerUpdateHandler
onUpdate(handler: RawUpdateHandler): void
onConnectionState(handler: (state: ConnectionState) => void): void
@ -61,6 +64,7 @@ export interface ITelegramClient {
// or at least load this once at startup (and then these methods can be made sync)
getPoolSize(kind: ConnectionKind, dcId?: number): Promise<number>
getPrimaryDcId(): Promise<number>
changePrimaryDc(newDc: number): Promise<void>
computeSrpParams(request: tl.account.RawPassword, password: string): Promise<tl.RawInputCheckPasswordSRP>
computeNewPasswordHash(algo: tl.TypePasswordKdfAlgo, password: string): Promise<Uint8Array>

View file

@ -10,6 +10,7 @@ export { sendCode } from './methods/auth/send-code.js'
export { sendRecoveryCode } from './methods/auth/send-recovery-code.js'
export { signIn } from './methods/auth/sign-in.js'
export { signInBot } from './methods/auth/sign-in-bot.js'
export { signInQr } from './methods/auth/sign-in-qr.js'
export { start } from './methods/auth/start.js'
export { startTest } from './methods/auth/start-test.js'
export { isSelfPeer } from './methods/auth/utils.js'

View file

@ -26,6 +26,7 @@ import {
BotReactionCountUpdate,
BotReactionUpdate,
BotStoppedUpdate,
BusinessCallbackQuery,
BusinessChatLink,
BusinessConnection,
BusinessMessage,

View file

@ -17,15 +17,21 @@ export async function resendCode(
/** Confirmation code identifier from {@link SentCode} */
phoneCodeHash: string
/** Abort signal */
abortSignal?: AbortSignal
},
): Promise<SentCode> {
const { phone, phoneCodeHash } = params
const { phone, phoneCodeHash, abortSignal } = params
const res = await client.call({
const res = await client.call(
{
_: 'auth.resendCode',
phoneNumber: normalizePhoneNumber(phone),
phoneCodeHash,
})
},
{ abortSignal },
)
assertTypeIs('sendCode', res, 'auth.sentCode')

View file

@ -21,13 +21,17 @@ export async function sendCode(
/** Additional code settings to pass to the server */
codeSettings?: Omit<tl.RawCodeSettings, '_' | 'logoutTokens'>
/** Abort signal */
abortSignal?: AbortSignal
},
): Promise<SentCode> {
const phone = normalizePhoneNumber(params.phone)
const { id, hash } = await client.getApiCrenetials()
const res = await client.call({
const res = await client.call(
{
_: 'auth.sendCode',
phoneNumber: phone,
apiId: id,
@ -37,7 +41,9 @@ export async function sendCode(
logoutTokens: params.futureAuthTokens,
...params.codeSettings,
},
})
},
{ abortSignal: params.abortSignal },
)
assertTypeIs('sendCode', res, 'auth.sentCode')

View file

@ -0,0 +1,141 @@
import { tl } from '@mtcute/tl'
import { getPlatform } from '../../../platform.js'
import { ControllablePromise, createControllablePromise } from '../../../utils/controllable-promise.js'
import { sleepWithAbort } from '../../../utils/misc-utils.js'
import { assertTypeIs } from '../../../utils/type-assertions.js'
import { ITelegramClient, ServerUpdateHandler } from '../../client.types.js'
import { MaybeDynamic, User } from '../../types/index.js'
import { resolveMaybeDynamic } from '../../utils/misc-utils.js'
import { checkPassword } from './check-password.js'
// @available=user
/**
* Execute the [QR login flow](https://core.telegram.org/api/qr-login).
*
* This method will resolve once the authorization is complete,
* returning the authorized user.
*/
export async function signInQr(
client: ITelegramClient,
params: {
/**
* Function that will be called whenever the login URL is changed.
*
* The app is expected to display `url` as a QR code to the user
*/
onUrlUpdated: (url: string, expires: Date) => void
/** Password for 2FA */
password?: MaybeDynamic<string>
/** Abort signal */
abortSignal?: AbortSignal
},
): Promise<User> {
const { onUrlUpdated, abortSignal } = params
let waiter: ControllablePromise<void> | undefined
// crutch we need to wait for the updateLoginToken update.
// we replace the server update handler temporarily because:
// - updates manager may be disabled, in which case `onUpdate` will never be called
// - even if the updates manager is enabled, it won't start until we're logged in
//
// todo: how can we make this more clean?
const originalHandler = client.getServerUpdateHandler()
const onUpdate: ServerUpdateHandler = (upd) => {
if (upd._ === 'updateShort' && upd.update._ === 'updateLoginToken') {
waiter?.resolve()
client.onServerUpdate(originalHandler)
}
}
client.onServerUpdate(onUpdate)
abortSignal?.addEventListener('abort', () => {
client.onServerUpdate(originalHandler)
waiter?.reject(abortSignal.reason)
})
try {
const { id, hash } = await client.getApiCrenetials()
const platform = getPlatform()
loop: while (true) {
let res: tl.auth.TypeLoginToken
try {
res = await client.call(
{
_: 'auth.exportLoginToken',
apiId: id,
apiHash: hash,
exceptIds: [],
},
{ abortSignal },
)
} catch (e) {
if (tl.RpcError.is(e, 'SESSION_PASSWORD_NEEDED') && params.password) {
return checkPassword(client, await resolveMaybeDynamic(params.password))
}
throw e
}
switch (res._) {
case 'auth.loginToken':
onUrlUpdated(
`tg://login?token=${platform.base64Encode(res.token, true)}`,
new Date(res.expires * 1000),
)
waiter = createControllablePromise()
await Promise.race([waiter, sleepWithAbort(res.expires * 1000 - Date.now(), client.stopSignal)])
break
case 'auth.loginTokenMigrateTo': {
await client.changePrimaryDc(res.dcId)
let res2: tl.auth.TypeLoginToken
try {
res2 = await client.call(
{
_: 'auth.importLoginToken',
token: res.token,
},
{ abortSignal },
)
} catch (e) {
if (tl.RpcError.is(e, 'SESSION_PASSWORD_NEEDED') && params.password) {
return checkPassword(client, await resolveMaybeDynamic(params.password))
}
throw e
}
assertTypeIs('auth.importLoginToken', res2, 'auth.loginTokenSuccess')
break loop
}
case 'auth.loginTokenSuccess':
break loop
}
}
const [self] = await client.call(
{
_: 'users.getUsers',
id: [{ _: 'inputUserSelf' }],
},
{ abortSignal },
)
assertTypeIs('users.getUsers', self, 'user')
await client.notifyLoggedIn(self)
return new User(self)
} finally {
client.onServerUpdate(originalHandler)
}
}

View file

@ -19,16 +19,21 @@ export async function signIn(
phoneCodeHash: string
/** The confirmation code that was received */
phoneCode: string
/** Abort signal */
abortSignal?: AbortSignal
},
): Promise<User> {
const { phone, phoneCodeHash, phoneCode } = params
const { phone, phoneCodeHash, phoneCode, abortSignal } = params
const res = await client.call({
const res = await client.call(
{
_: 'auth.signIn',
phoneNumber: normalizePhoneNumber(phone),
phoneCodeHash,
phoneCode,
})
},
{ abortSignal },
)
return _onAuthorization(client, res)
}

View file

@ -15,6 +15,7 @@ import { resendCode } from './resend-code.js'
import { sendCode } from './send-code.js'
import { signIn } from './sign-in.js'
import { signInBot } from './sign-in-bot.js'
import { signInQr } from './sign-in-qr.js'
// @available=both
/**
@ -48,6 +49,15 @@ export async function start(
*/
sessionForce?: boolean
/**
* When passed, [QR login flow](https://core.telegram.org/api/qr-login)
* will be used instead of the regular login flow.
*
* This function will be called whenever the login URL is changed,
* and the app is expected to display it as a QR code to the user.
*/
qrCodeHandler?: (url: string, expires: Date) => void
/**
* Phone number of the account.
* If account does not exist, it will be created
@ -100,12 +110,17 @@ export async function start(
/** Additional code settings to pass to the server */
codeSettings?: Omit<tl.RawCodeSettings, '_' | 'logoutTokens'>
/** Abort signal */
abortSignal?: AbortSignal
},
): Promise<User> {
if (params.session) {
await client.importSession(params.session, params.sessionForce)
}
const { abortSignal } = params
let has2fa = false
let sentCode: SentCode | undefined
let phone: string | null = null
@ -128,7 +143,7 @@ export async function start(
}
// if has2fa == true, then we are half-logged in, but need to enter password
if (!has2fa) {
if (!has2fa && !params.qrCodeHandler) {
if (!params.phone && !params.botToken) {
throw new MtArgumentError('Neither phone nor bot token were provided')
}
@ -156,6 +171,7 @@ export async function start(
phone,
futureAuthTokens: params.futureAuthTokens,
codeSettings: params.codeSettings,
abortSignal,
})
} catch (e) {
if (tl.RpcError.is(e, 'SESSION_PASSWORD_NEEDED')) {
@ -168,7 +184,11 @@ export async function start(
if (sentCode) {
if (params.forceSms && (sentCode.type === 'app' || sentCode.type === 'email')) {
sentCode = await resendCode(client, { phone: phone!, phoneCodeHash: sentCode.phoneCodeHash })
sentCode = await resendCode(client, {
phone: phone!,
phoneCodeHash: sentCode.phoneCodeHash,
abortSignal,
})
}
if (params.codeSentCallback) {
@ -186,7 +206,12 @@ export async function start(
if (!code) throw new tl.RpcError(400, 'PHONE_CODE_EMPTY')
try {
return await signIn(client, { phone: phone!, phoneCodeHash: sentCode.phoneCodeHash, phoneCode: code })
return await signIn(client, {
phone: phone!,
phoneCodeHash: sentCode.phoneCodeHash,
phoneCode: code,
abortSignal,
})
} catch (e) {
if (!tl.RpcError.is(e)) throw e
@ -245,5 +270,13 @@ export async function start(
}
}
if (params.qrCodeHandler) {
return await signInQr(client, {
onUrlUpdated: params.qrCodeHandler,
password: params.password,
abortSignal,
})
}
throw new MtArgumentError('Failed to log in with provided credentials')
}

View file

@ -180,6 +180,10 @@ export class UpdatesManager {
this._handler = handler
}
getHandler(): RawUpdateHandler {
return this._handler
}
onCatchingUp(handler: (catchingUp: boolean) => void): void {
this._onCatchingUp = handler
}

View file

@ -1,7 +1,5 @@
import { tl } from '@mtcute/tl'
import { LogManager } from '../../utils/logger.js'
import { ConnectionState, ITelegramClient } from '../client.types.js'
import { ConnectionState, ITelegramClient, ServerUpdateHandler } from '../client.types.js'
import { PeersIndex } from '../types/peers/peers-index.js'
import { RawUpdateHandler } from '../updates/types.js'
import { AppConfigManagerProxy } from './app-config.js'
@ -37,6 +35,7 @@ export abstract class TelegramWorkerPort<Custom extends WorkerCustomMethods> imp
readonly getApiCrenetials
readonly getPoolSize
readonly getPrimaryDcId
readonly changePrimaryDc
readonly computeSrpParams
readonly computeNewPasswordHash
readonly startUpdatesLoop
@ -71,6 +70,7 @@ export abstract class TelegramWorkerPort<Custom extends WorkerCustomMethods> imp
this.getApiCrenetials = bind('getApiCrenetials')
this.getPoolSize = bind('getPoolSize')
this.getPrimaryDcId = bind('getPrimaryDcId')
this.changePrimaryDc = bind('changePrimaryDc')
this.computeSrpParams = bind('computeSrpParams')
this.computeNewPasswordHash = bind('computeNewPasswordHash')
this.startUpdatesLoop = bind('startUpdatesLoop')
@ -79,11 +79,15 @@ export abstract class TelegramWorkerPort<Custom extends WorkerCustomMethods> imp
abstract connectToWorker(worker: SomeWorker, handler: ClientMessageHandler): [SendFn, () => void]
private _serverUpdatesHandler: (updates: tl.TypeUpdates) => void = () => {}
onServerUpdate(handler: (updates: tl.TypeUpdates) => void): void {
private _serverUpdatesHandler: ServerUpdateHandler = () => {}
onServerUpdate(handler: ServerUpdateHandler): void {
this._serverUpdatesHandler = handler
}
getServerUpdateHandler(): ServerUpdateHandler {
return this._serverUpdatesHandler
}
private _errorHandler: (err: unknown) => void = () => {}
onError(handler: (err: unknown) => void): void {
this._errorHandler = handler