Аутентификация в мобильных приложениях с помощью Telegram Login Widget обделена информацией как официальной документации, так и в интернете. Меня зовут Александр, в этой статье поделюсь примером реализации входа в iOS приложение c помощью Telegram с блекджеком и граблями. В статье приведены сниппеты кода на Typescript + React, Go и Swift.
Содержание:
Нам понадобятся:
-
Отдельный маршрут
/oauth
в веб-приложении (в нашем случае, написанном на React) -
Набор эндпроитов на бекенде, которые обработают данные аутентификации
-
iOS приложение
Telegram Login Widget API
Если вы уже ранее писали аутентификацию с помощью любого популярного OAuth 2.0 провайдера, типа Google, вы наверняка ожидаете какую-то мобильную библиотеку, которая предоставит все необходимые API. Однако в Telegram пошли своим минималистичным путем, предоставив нам Telegram Login Widget - маленький файл Javascript, который разработчик должен просто добавить к своему web-сайту.
Документация Telegram, предлагает сделать быстрые настройки встраиваемого кусочка кода:
-
Имя бота
-
Размер кнопки и радиус краев (с показом или без фото юзера, если он уже аутентифицирован в web-версии Телеграм)
-
И самая важная, на мой взгляд настройка, - выбор callback или redirect url, который будет вызван в качестве ответа на успешную аутентификацию.
Вот и весь доступный API.
После встраивания, если всё прошло успешно, Telegram добавляет в DOM дерево iframe
фиксированного размера, в котором отображается кнопка и подгружаются другие скрипты, которыми заведует телеграм .
Telegram Login Widget Internal
Ниже код кнопки всей кнопки и разъяснение что же там происходит:
export type TGUser = {
id: number;
username?: string;
photo_url?: string;
first_name: string;
last_name?: string;
auth_date: number;
hash: string;
};
type Props = {
botName: string;
onAuthCallback?: (user: TGUser) => void;
};
export const TelegramLoginButton = (props: Props) => {
const _containerRef = useRef<HTMLDivElement | null>(null);
const { botName, onAuthCallback } = props;
useEffect(() => {
if (onAuthCallback != null) {
(window as any).TelegramOnAuthCb = (user: TGUser) => onAuthCallback(user);
}
const script = document.createElement('script');
script.src = 'https://telegram.org/js/telegram-widget.js?21';
script.async = true;
script.setAttribute('data-telegram-login', botName);
script.setAttribute('data-request-access', 'write');
script.setAttribute('data-onauth', 'TelegramOnAuthCb(user)');
script.setAttribute('data-lang', 'ru');
_containerRef.current?.appendChild(script);
return () => {
_containerRef.current?.removeChild(script);
(window as any).TelegramOnAuthCb = undefined;
};
}, []);
return (
<div className="relative w-[300px]">
<div className="absolute top-0.5 left-8 opacity-0" ref={_containerRef} />
<Button
style={{
pointerEvents: 'none',
background: 'var(--background-gradient-telegram-button)',
}}
leftSlot={<TelegramIcon />}
label="Войти с Telegram"
/>
</div>
);
};
Разберем по частям, что происходит в этом коде:
const { botName, onAuthCallback } = props;
- принимаем в качестве свойств кнопки username бота и коллбек, в который будут возвращены данные успешной регистрации
В хуке useEffect
добавляем к window
колбек, чтобы виджет нашел куда отдать ответ:
if (onAuthCallback != null) {
(window as any).TelegramOnAuthCb = (user: TGUser) => onAuthCallback(user);
}
Что такое useEffect
Это функция жизненнего цикла компонента (или хук в терминах React), которая принимает анонимную функцию и массив зависимостей, за изменениями которых будет наблюдать - если значение одной из зависимостей изменилось, будет вызвана переданная функция. Зависимости определенные как []
говорят, что функция будет вызвана 1 раз в начале жизни компонента.
Далее:
const script = document.createElement('script');
script.src = 'https://telegram.org/js/telegram-widget.js?21';
script.async = true;
script.setAttribute('data-telegram-login', botName);
script.setAttribute('data-request-access', 'write');
script.setAttribute('data-onauth', 'TelegramOnAuthCb(user)');
script.setAttribute('data-lang', 'ru');
Здесь мы создаем DOM-элемент script
и настраиваем его в соответствии с документацией. Обратите внимание на установку атрибута с колбеком - эта строка TelegramOnAuthCb(user)
указывает, что этот метод надо поискать у объекта window
. Не самый изящный путь, но вариантов других нет.
_containerRef.current?.appendChild(script);
- c помощью React Ref на реальный DOM-узел, добавляем скрипт, так чтобы сама кнопка управляла его жизненным циклом.
Заметка о нюансах безопасности:
Наш веб-сервер, занимающийся раздачей веб-ассетов был настроен на довольно строгий контроль (по части скриптов) Content Security
через такой заголовок, куда предварительно разрешили в iframe загружать отдельный адрес Telegram:
Content-Security-Policy "default-src 'self'; connect-src *; font-src 'self'; script-src 'self' https://telegram.org; img-src * data:; style-src 'self'; frame-src https://oauth.telegram.org" always;
Когда мы залили предварительную версию на стейдж, телеграм выдал ошибку:
Refused to frame 'https://oauth.telegram.org/' because an ancestor violates the following Content Security Policy directive: "frame-ancestors https://ourdomain.com”.
В iframe
виджета телеграм начинает загружать данные с другой страницы, которые отклоняются политикой контроля контента. Окей, расширили правило, тут вроде никакого криминала.
Далее интереснее:
Uncaught EvalError: Refused to evaluate a string as JavaScript because 'unsafe-eval' is not an allowed source of script in the following Content Security Policy directive: "script-src 'self' https://telegram.org.
Моя привычка следовать политике минимальных разрешений заставляет немного напрягаться , особенно когда сторонний скрипт требует от меня разрешить нечто именуемое
unsafe-eval
Почему unsafe-eval
плохо? Он позволяет выполнять произвольный текст кода, открывая возможности для XSS атак, выполнению на странице кода недобросовестных расширений и тд. Надеюсь специалисты по безопасности подкинут еще деталей в комментариях.
Очень странно, что помешанный (сарказм) на безопасности Telegram форсит пользователей сужать пространство защиты своих сервисов. Что ж, пока решение не в проде, есть время подумать о рисках, а пока двинемся к кастомизации визуала.
Кастомизируем некастомизируемое
Посмотрим как же сейчас выглядит кнопка:

Выглядит неплохо, но не так, как нам хотелось. А нам хотелось бы вот так, чтобы со своими шрифтами, цветами и тд:

Как я выше отметил, кнопка рисуется в iframe
, а значит кастомизации не подлежит. Обидно. Товарищ Дуров, мог бы предложить хоть какие-то настройки внешнего вида, помимо радиуса углов. Размер iframe
также фиксированный: 234 х 40 пикселей.
Из приведенного выше кода кнопки, может быть не совсем ясно, как же удалось кастомизировать. А собственно никак.
<div className="relative w-[300px]">
<div className="absolute top-0.5 left-8 opacity-0" ref={_containerRef} />
<Button
style={{
pointerEvents: 'none',
background: 'var(--background-gradient-telegram-button)',
}}
leftSlot={<TelegramIcon />}
label="Войти с Telegram"
/>
</div>
Трюк в том, что div
с ref={_containerRef}
делаем невидимым и располагаем поверх кнопки с нужным дизайном, центрируя абсолютными значениями. Кастомные кнопки меньшего размера идеально накрываются прозрачным iframe
. Из неприятного - частичное покрытие кнопок большего размера (как на картинке ниже) и лаг на подгрузку скриптов телеграма временно, из-за которого кнопка некоторое время остается некликабельной (но здесь есть пространство для улучшений).

Особенности на бекенде
Что нужно сделать на беке:
-
Валидацию полученных данных
-
Выпуск JWT токена стандартным способом, если данные валидны
Что нам предлагает телеграм для бекенда? Снова почти ничего. На беке нам предлагается использовать только токен телеграм бота. Тут есть на чем заострить внимание.
Опишем данные в Golang
данные которые приходят от Телеги:
type AuthData struct {
ID int64 `json:"id" validate:"required,gte=0"`
AuthDate int64 `json:"auth_date" validate:"required"`
Username string `json:"username" validate:"required"`
FirstName string `json:"first_name" validate:"required"`
LastName string `json:"last_name,omitempty" validate:"omitempty"`
LanguageCode string `json:"language_code,omitempty" validate:"omitempty"`
PhotoURL string `json:"photo_url,omitempty" validate:"omitempty,url_encoded"`
Hash string `json:"hash" validate:"required"`
}
Из того, что Телеграм обещает вернуть: ID
, AuthDate
, Username
, FirstName
и Hash
. Остальное опционально. Тег validate
- из пакета validator. Вполне удобный способ проверить валидность данных и получить детальную ошибку если что-то не так.
Вся суть валидации этих данных состоит в том, чтобы расставить все поля (исключая hash) в алфавитном порядке имен ключей, соединить в строку через перенос n
и пропустить через SHA256, где секретом выступит токен бота. Полученную строку сравниваем с хешем и если они равны, то можно считать, что целостность (integrity) данных не была нарушена и пользователя можно авторизовать.
const (
telegramAuthTTL = time.Hour * 24 * 15
)
func validateStruct(authData AuthData, botApiKey string) error {
return validateMap(authData.toMap(), botApiKey, telegramAuthTTL)
}
func validateMap(initData map[string]string, botApiKey string, expIn time.Duration) error {
var (
authDate time.Time
hash string
pairs = make([]string, 0, len(initData))
)
for k, v := range initData {
if k == "hash" {
hash = v
continue
}
if k == "auth_date" {
i, err := strconv.Atoi(v)
if err != nil || i == 0 {
return ErrInvalidAuthDate
}
authDate = time.Unix(int64(i), 0)
}
pairs = append(pairs, fmt.Sprintf("%s=%s", k, v))
}
if authDate.IsZero() {
return ErrAuthDateMissing
}
if chk.IsBlank(hash) {
return ErrTgHashMissing
}
if expIn > 0 {
if authDate.Add(expIn).Before(time.Now().UTC()) {
return ErrExpired
}
}
sort.Strings(pairs)
if !chk.ConstantTimeStrEq(sign(strings.Join(pairs, "n"), botApiKey), hash) {
return ErrSignInvalid
}
return nil
}
func sign(payload, key string) string {
secretKey := sha256.Sum256([]byte(key))
h := hmac.New(sha256.New, secretKey[:])
h.Write([]byte(payload))
return hex.EncodeToString(h.Sum(nil))
}
Из интересного и странного:
telegramAuthTTL
- время жизни этой сессии аутентификации. Телеграм в документации приводит пример древнего gist с php , где предлагает нам самостоятельно контролировать время до инвалидации сессии. Это странно, так как в боте, который присылает запрос есть кнопка Terminate session
. Фактически, эта кнопка просто отзовет бот-специфичный токен сессии. В следующий раз, используя тот же браузер, юзер мог бы аутентифицироваться без запроса в боте, но закрыв сессию, бот снова пришлет запрос. На работу пользователя в вашем сервисе эта кнопка не произведет никакого эффекта покуда не истечет определенный нами TTL.
Сравнивая подходы Telegram и Google, последний предлагает гораздо более тщательную имплементацию OpenID Connect и защиту от различных векторов атак. Складывается впечатление, что в Telegram не считают предложенные методы, несущими потенциальную опасность для самого Telegram, поэтому ответственность за защиту своих сервисов оставляют на усмотрение разработчиков. Если у вас угнали телеграм аккаунт (что нынче не редкость) связанный с ботом, или сам токен - дела плохи. Ничего не мешает злоумышленнику взять ID пользователя, выдумать другие данные, сгенерировать от них хеш и успешно авторизоваться в вашем сервисе.
Всё это создает определенные риски, поэтому приведу ссылку на статью о безопасности мобильного OAuth 2.0. Но если кому лень читать, вот выжимка схемы взаимодействия между клиентом и сервером, которую лично я считаю предпочтительной:
-
Мобильное приложение генерирует и сохраняет code_verifier - и
CSRF
-токен. -
Выбираем code_challenge_method, пропускаем через него
code_verifier
и получаем хеш -code_challenge
. -
Далее открываем вкладку браузера для веб-аутентификации, в URL добавляем query -
code_challenge
,code_challenge_method
(опционально, если бекенд знает способ шифрования то можно опустить) иCSRF
-токен -
Авторизуемся в Telegram и получаем от него данные пользователя
-
Далее на бекенде запрашиваем code, в запрос добавляем данные от Telegram,
code_challenge
,code_challenge_method
иCSRF
-токен. -
Бекенд генерирует
code
и сохраняет его на короткое время (например в Redis с очень коротким временем экспирации) вместе сcode_challenge
,code_challenge_method
, затем возвращает его клиенту, включая в ответCSRF
-токен. -
Возвращаемся из браузера в приложение редиректом и возвращаем
code
и CSRF-токен, который кочует туда-сюда, всякий раз сигналазируя, что за сессия и чья она. -
Приложение проверяет CSRF-токен и если всё ок, едем дальше.
-
Далее запрашиваем
access_token
, по полученному ранееcode
, в запрос прикладываемcode_verifier
. -
Бекенд сверяет полученный и сохраненный
code
, получаетcode_challenge
из пришедшегоcode_verifier
(также как это делает мобилка в пункте 2), затем сверяет его сcode_challenge
и если все значения совпадают, выдает клиентуaccess_token
иrefresh_token
(по вкусу).
Этот подход стремится к минимальному доверию на стороне веба. Даже если на каком-то этапе данные будут перехвачены, верификация code_challenge
, связывающая начало и финал, предотвратит компрометацию посередине.
Интеграция в нативное приложение
Для целей статьи будет излишним расписывать каждый пункт приведенной схемы с примерами. Эти шаги покажутся вам достаточно тривиальными, когда мы зафиналим флоу в мобильном приложении элементарным примером, так чтобы у вас сложилось целостная картина, как все компоненты интегрируются друг с другом.
Вернемся на секунду в web-приложение и реализуем тот самый коллбек, который примет данные от телеги:
const tgCallback = (user: TGUser) => {
const url = new URL('appscheme://oauth/telegram');
Object.entries(user).forEach(([key, value]) => {
url.searchParams.set(key, `${value}`);
});
window.open(url.href, "target=_blank, rel='noopener'");
};
Тут ничего необычно кроме того, что для редиректа мы используем appscheme
в качестве схемы для url, чтобы выполнить редирект обратно в приложение, а поля объекта перекладываем строками в url query. Естественно, подразумевается, что вы зарегистрировали собственную схему так, как это рекомендует Applе в документации.
Теперь идем в Xcode и напишем демонстрационную реализацию вызова сессии веб-аутентификации и получения данных из URL редиректа:
import SwiftUI
import AuthenticationServices
struct ContentView: View {
@Environment(.webAuthenticationSession) var webAuthenticationSession: WebAuthenticationSession
@AppStorage("appId") var appId: String = UUID().uuidString
@State var showLogin: Bool = false
var OAuthUrl: URL {
URL(string: "https://our-domain.com/oauth")!
}
var body: some View {
Surface {
VStack {
Image(systemName: "globe")
.imageScale(.large)
.foregroundStyle(.tint)
Text("Hello, world!")
Button("Login with telegram", action: {
Task {
do {
let callbackURL = try await webAuthenticationSession.authenticate(
using: OAuthUrl,
callbackURLScheme: "appscheme"
)
print(callbackURL)
} catch {
print(error.localizedDescription)
}
}
})
.buttonStyle(.borderedProminent)
.controlSize(.large)
.clipShape(RoundedRectangle(
cornerRadius: 18,
style: .continuous
))
.tint(.blue)
}
}
}
}
Что интересного здесь:
-
@Environment(.webAuthenticationSession) var webAuthenticationSession: WebAuthenticationSession
- крайне удобный способ начать сессию аутентификации в SwiftUI, доступный из встроенной библиотекиAuthenticationServices
. Для UIKit делается иначе, но смысл тот же. -
Нажимая на кнопку, мы запускаем
Task
и код в его блоке. Если вы видите алерт с оповещением, что приложение хочет перейти на указанный домен, чтобы “Войти”, значит всё идет как надо. Обычныйwebview
такого предупреждения не выдает.

Что далее:
-
Нажимаем Continue и попадем на нашу веб-страницу с Telegram Login Widget
-
Входим с Telegram
-
Успешная аутентификация отдаст данные пользователя в tgCallback
-
window.open(url.href, "target=_blank, rel='noopener'");
- выполнит редирект в приложение с закрытием окна браузера -
let callbackURL = try await webAuthenticationSession.authenticate(...)
- вcallbackURL
вернется собранный намиurl.href
с query-хвостом из данных пользователя:
appscheme://oauth/telegram
?id=USER_ID
&username=USERNAME
&auth_date=1234567890
&first_name=FIRST_NAME
&last_name=LAST_NAME
&language_code=RU
&photo_url=URL
&hash=HASH
Вы наверное уже заметили, что скрестить это с более безопасным OAuth флоу, описанным выше, не составит особого труда.
Вот, собственно, и всё.
Спасибо за ваше внимание и за то, что дочитали до конца, надеюсь материал окажется для вас полезен! Буду рад вашим конструктивным комментариям. Также приглашаю в телеграм-канал, где я с недавних пор делюсь опытом строительства стартапа, создания мобильных приложений, кодерскими историями, шутками и мемами.
Автор: Spaceguest