Логин через Telegram по-новому: разбираем OIDC-флоу oauth.telegram.org и собираем его на Python

Страницы:  1

Ответить
 

Professor Seleznov


Я пилил пет-проект — небольшой бэкенд на Litestar — и хотел прикрутить к нему логин через Telegram. Открыл первый попавшийся туториал на GitHub: HMAC от bot-token, /setdomain в BotFather, голые поля юзера в callback. Почти всё, что я нашёл, было про старый виджет telegram.org/js/telegram-widget.js.
Открыл доки Telegram — там написано: «новый flow, OpenID Connect, oauth.telegram.org». Сел разбираться. В итоге собрал PoC — он умеет логинить пользователя через новый OIDC, держит cookie-сессию для HTML-страниц и отдаёт пару access + refresh токенов для JSON API.
Эта статья — пересказ того, что мне самому хотелось бы прочитать в начале: где в потоке данных Telegram, где браузер, где наш бэк, и какие куски нужно реально писать руками. По ходу — туториал: настройка бота в BotFather, локальный тест через ngrok, запуск.
Код примеров — из репозитория https://github.com/andy-takker/tg-auth. Стек: Python 3.13, Litestar, PyJWT, SQLAlchemy 2.0, aiosqlite. Но сам OIDC-флоу со стеком не связан — те же шаги повторяются на FastAPI, Django или чём угодно ещё.
pic
-
Старый виджет ≠ новый OIDC
Если вы открывали старые туториалы про Telegram Login — забудьте их сразу, потоки разные.
Старый виджет (telegram-widget.js):
  • кнопка вставляется на любой http://-сайт;
  • Telegram возвращает поля юзера прямо в URL или в JS-callback;
  • подлинность проверяется HMAC-SHA256 от токена бота;
  • в BotFather вы прописываете домен через /setdomain.
Если в туториале видите что-то вроде hash = HMAC_SHA256(data_check_string, SHA256(bot_token)) и поля id, first_name, auth_date, hash — это и есть legacy-виджет, закрывайте.
Новый OIDC-флоу (oauth.telegram.org):
  • Telegram стал полноценным OpenID-провайдером;
  • есть /.well-known/openid-configuration, есть JWKS;
  • ID-токен — настоящий JWT, подписанный публичным ключом из JWKS Telegram;
  • бэкенд проверяет подпись по этому ключу, аудиторию (aud), издателя (iss), exp/iat;
  • bot-token при авторизации не используется — он только для Bot API. Не путать его с Client Secret: BotFather в Web Login выдаёт пару Client ID + Client Secret, и Client Secret нужен для manual OIDC code flow. В popup-варианте бэк его не видит, но в полном flow он есть.
Польза от перехода — стандартный протокол, никакой ручной HMAC-проверки legacy-полей и совместимость с любой OIDC-библиотекой. Цена — другой набор настроек в BotFather и обязательный HTTPS на origin’е.
Ещё один нюанс, на который я не сразу обратил внимание. У нового Telegram Login на самом деле два режима:
  • Login library — браузерный popup через telegram-login.js. Telegram внутри popup-а сам делает Authorization Code Flow с PKCE и возвращает нам уже готовый id_token через postMessage. Бэк только проверяет JWT — никакого Client Secret, никакого PKCE на нашей стороне.
  • Manual OIDC — обычный Authorization Code Flow: вы сами редиректите юзера на oauth.telegram.org/auth, ловите code в своём callback’е и меняете его на токены через /token (Basic Auth с Client ID + Client Secret).
В этой статье — первый вариант. Он проще и для типового веба его достаточно. Manual flow нужен, если у вас нативный клиент без браузера или хочется держать весь OAuth-обмен в своих руках.
-
Что мы соберём
Минимальный набор маршрутов:
Метод Путь Зачем
GET / Страница логина с виджетом Telegram
POST /api/v1/auth/telegram Принимает id_token, валидирует, апсертит юзера, ставит cookie + отдаёт access/refresh
POST /api/v1/auth/refresh Меняет refresh на новую пару
POST /api/v1/auth/logout Чистит cookie
GET /app Защищённая HTML-страница, читает юзера по cookie

Cookie-сессия нужна, чтобы HTML-страницы «помнили» юзера между запросами. JWT-пара — для JSON API (мобилка/SPA). Один логин выдаёт сразу обе вещи.
⚠️ Про сами JWT-токены глубоко лезть здесь не буду — в PoC это просто HS256 без хранения в БД. Полноценный refresh-флоу с одноразовостью, family_id и отслеживанием сессий — тема большая и заслуживает отдельной статьи. Здесь упор на сам OIDC-обмен.

-
Картинка целиком: кто с кем общается
Прежде чем нырять в код, удобно держать в голове ролевую схему. Вот кто участвует и за что отвечает:
pic
Главное, что стоит увидеть на этой схеме: полный OAuth-обмен/auth/tokenпроисходит внутри popup-а Telegram. Бэкенд не делает ни одного запроса к oauth.telegram.org/token, не хранит Client Secret, не возится с PKCE и redirect URI. Получает уже готовый id_token, и его остаётся только проверить.
-
Что происходит при первом логине: пошагово
Главная sequence-диаграмма статьи. Подробно — что и в каком порядке летает по сети.
pic
Что здесь важно:
1.id_tokenк нам приходит черезpostMessage, а не через redirect. Никаких ?code=... в URL, никакого редиректа на /callback. Telegram-popup внутри себя проводит полную OAuth-авторизацию и отдаёт нам уже подписанный JWT через window.postMessage. HTTPS на origin’е нужен не из-за postMessage (он как раз для cross-origin общения и сделан), а потому что Telegram пускает в Trusted Origins только HTTPS-схемы.
2. JWKS — это публичные ключи, по которым мы проверяем подпись JWT. Мы их забираем один раз и кэшируем. PyJWKClient из pyjwt это умеет из коробки:
from jwt import PyJWKClient
jwks_client = PyJWKClient(
"https://oauth.telegram.org/.well-known/jwks.json",
cache_keys=True,
lifespan=600, # 10 минут
)
В моём приложении этот клиент создаётся один раз при старте и инжектится в use-case через DI.
3. Сама валидация — это один вызовjwt.decodeс правильными параметрами. Никакого ручного HMAC-а, никаких сравнений строк:
def _verify_telegram_id_token(id_token: str, jwks_client, client_id, issuer):
if not id_token:
raise ValueError("Empty id_token")
signing_key = jwks_client.get_signing_key_from_jwt(id_token).key
return jwt.decode(
id_token,
signing_key,
algorithms=["RS256", "ES256"],
audience=client_id, # ваш Client ID из BotFather
issuer=issuer, # "https://oauth.telegram.org"
options={"require": ["iss", "aud", "exp", "iat", "sub"]},
leeway=30, # на рассинхрон часов
)
Если хоть что-то не сошлось — подпись, audience, истёкший срок — jwt.decode бросит исключение, мы переводим его в 401. Всё.
Один важный пункт, который в моём PoC не реализован — проверка nonce. В проде это делается так: сервер перед открытием popup-а генерирует случайное значение, кладёт его в свою сессию, передаёт в Telegram.Login.init(...), а после валидации id_token сверяет claim nonce с тем, что лежит в сессии. Это защита от replay: перехваченный когда-то id_token не сработает повторно. Если делаете прод — добавьте в options.require строку "nonce" и сравнивайте.
4. После валидации — делаем upsert юзера. В id_token лежат claim’ы: sub (стабильный OIDC-идентификатор пользователя у Telegram), id (числовой Telegram user id, может присутствовать отдельно), name, preferred_username, picture, phone_number (если юзер дал scope phone). В коде я беру id, а если его нет — фолбэк на sub; так чуть надёжнее, чем завязываться на один из двух. Из этих полей собираем запись TelegramAccount и линкуем к User. Логика такая:
  • Ищу TelegramAccount по telegram_id → если есть, переиспользую его юзера и обновляю мутабельные поля.
  • Иначе ищу User по phone_number → если есть, прикрепляю новый TelegramAccount к нему.
  • Иначе создаю и User, и TelegramAccount.
Шаг 2 — это то, что позволит позже добавить логин по SMS и не получить дублей юзеров, у которых один и тот же телефон, но два аккаунта.
-
Туториал: что куда нажимать
Теперь пошагово — как поднять всё это локально.
Шаг 1. Создать бота и настроить OIDC в BotFather
Открываете @BotFather, создаёте бота через /newbot (или используете существующего).
pic
Дальше — самое неочевидное. Настройки OIDC живут не в чате с BotFather, а в его mini-app. В чате нажимаете кнопку «Open» (или иконку приложения), внутри переходите в Bot Settings → Web Login.
Там два важных поля:
  • Trusted Origins — добавьте сюда https://<ваш-ngrok-домен>.ngrok-free.app. Только origin: ни пути, ни слеша на конце. Origin’ов можно несколько (например, localhost через прокси и прод).
  • Redirect URIs — для нашего флоу через telegram-login.js оставьте пустым. Это поле нужно только если вы сами реализуете server-side обмен code → token.
pic
Команда /setdomain — это от старого виджета. Для OIDC она не нужна.
Шаг 2. ngrok (или любой HTTPS-туннель)
Telegram-popup общается с нашим origin’ом через postMessage. Он откажется работать, если origin — не HTTPS. Поэтому http://localhost:8000 напрямую не подойдёт.
Самое простое — ngrok:
ngrok http 8000
Получаете URL вида https://abcd-1234.ngrok-free.app. Этот URL кладёте в Trusted Origins в BotFather. На бесплатном тарифе ngrok домен меняется при каждом перезапуске — придётся обновлять Trusted Origins каждый раз. Если играетесь часто — есть смысл купить статический домен или взять Cloudflare Tunnel.
pic
Шаг 3. .env и client_id
В корне проекта есть .env.dev — копируете в .env и заполняете:
APP_TG_CLIENT_ID=8506301481      # ваш Client ID из BotFather
APP_SECRET_KEY=<32 байта в hex> # ключ AES для подписи cookie-сессии
APP_DB_URL=sqlite+aiosqlite:///./tg_auth.db
Секрет для cookie-сессии у меня в Litestar-овском CookieBackendConfig используется как ключ AES — длина строго 16/24/32 байта. Сгенерировать просто:
python3 -c "import secrets; print(secrets.token_hex(16))"  # 32 hex-символа = 32-байтная строка для AES-ключа
Тот же Client ID нужно прописать в data-client-id в tg_auth/presentors/rest/templates/index.html — это атрибут тега <script>, который грузит виджет:
<script async src="https://oauth.telegram.org/js/telegram-login.js?3"
data-client-id="8506301481"
data-onauth="onTelegramAuth(data)"
data-request-access="write phone"></script>
<button class="tg-auth-button" data-style="shine">Sign In with Telegram</button>
Функция onTelegramAuth(data) — наш callback, в неё Telegram передаёт { id_token, user }. Всё, что она делает:
async function onTelegramAuth(data) {
if (!data || data.error) return;
const res = await fetch("/api/v1/auth/telegram", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ id_token: data.id_token }),
});
if (res.ok) window.location.href = "/app";
}
Ничего больше — это весь фронт.
⚠️ Важный момент: в data Telegram кладёт ещё поле user с распакованными полями (имя, аватарка, и так далее). Удобно для UI на фронте, но доверять им нельзя — это та же информация, что и в id_token, только без подписи. Источник истины для бэка — только server-side проверенный id_token. На фронте data.user показывайте, на бэке — игнорируйте.
Шаг 4. Запуск
make develop      # создаёт .venv, ставит зависимости через uv
make migrate # применяет alembic-миграции
make run # python -m tg_auth → uvicorn на :8000
В отдельном терминале — ngrok http 8000. Открываете https-URL ngrok’а, кликаете «Sign In with Telegram», подтверждаете в Telegram-приложении, и должны попасть на /app с вашим именем.
pic
pic
-
А что во второй раз?
Если cookie уже стоит и она валидная — мы вообще не дёргаем Telegram. Поток такой:
pic
Я держу в сессии буквально {"user": {"id": "<uuid>"}} — больше ничего. Имя/телефон тащу из БД на каждый запрос /app. Это даёт приятный side-эффект: если юзера в БД удалили, контроллер /app это видит, чистит cookie и редиректит на /. В коде — буквально пять строк:
sess_user = request.session.get("user")
if not sess_user:
return Redirect(path="/")
user = await fetch_user_by_id.execute(UserID(UUID(sess_user["id"])))
if user is None:
request.clear_session() # self-heal
return Redirect(path="/")

-
Access/refresh — пара слов и тизер
Тот же POST /api/v1/auth/telegram после успешного апсерта возвращает и cookie, и пару JWT-токенов:
{
"user": { "id": "...", "name": "Jane", "phone_number": "+7..." },
"tokens": {
"access_token": "eyJ...",
"refresh_token": "eyJ...",
"token_type": "Bearer",
"expires_in": 900
}
}
Пара нужна для JSON API — мобильное приложение или SPA положит access в Authorization: Bearer ..., а когда тот протухнет — обменяет refresh на новую пару:
pic
В моём PoC это сделано максимально тупо: HS256, общий секрет, type claim различает access/refresh, никакого хранения в БД. Этого хватает, чтобы продемонстрировать обвязку, но в проде так делать нельзя — нет отзыва, нельзя выкинуть конкретного юзера, при утечке refresh-токена злоумышленник без проблем рефрешится дальше.
Полноценный refresh — это одноразовые токены, family_id для детекции переиспользования, журнал сессий в БД. Тема большая, и про неё я хочу написать отдельно. Здесь — сфокусирован на самом OIDC-обмене.
-
Грабли, на которые я наступил
Popup открывается, ноonTelegramAuthне вызывается. Почти всегда одно из двух: (а) origin не добавлен в Trusted Origins в BotFather, или (б) на сайт отдаётся заголовок Cross-Origin-Opener-Policy: same-origin — он блокирует postMessage из popup-а. Litestar по умолчанию его не ставит, но если у вас впереди nginx/CDN — стоит проверить.
401 Invalid id_token: Audience doesn't match. APP_TG_CLIENT_ID в .env не совпал с data-client-id в index.html. Я на это попадался дважды — поправил в одном месте, забыл в другом. В случае с шаблонами это можно решить одной переменной и прокидывать client_id в шаблон с бэкенда, но с полноценным фронтом в отдельной репе надо будет следить за двумя переменными.
401 Invalid id_token: Signature verification failed. Telegram ротировал ключи в JWKS, а у вас они закэшированы. У PyJWKClient я ставил lifespan=600 — то есть кэш в памяти живёт 10 минут. Простой ребут процесса лечит сразу.
ngrok пересоздал домен — ничего не работает. Бесплатный ngrok даёт новый поддомен на каждый старт. Trusted Origins нужно обновлять каждый раз. Лечится либо платным статическим доменом, либо cloudflared tunnel с привязкой к своему домену.
/appредиректит на/сразу после логина. Cookie ссылается на UUID юзера, которого в БД больше нет (типичная история — снёс файл tg_auth.db после логина). Контроллер сам это увидит и почистит сессию. Просто залогиньтесь снова.
-
Итог
Новый Telegram OIDC — это история про то, что Telegram перестал быть «своей особенной кнопкой» и стал обычным OpenID-провайдером. Вместо HMAC и /setdomain — JWKS, JWT, claims. Кода на бэке стало меньше: один jwt.decode с правильными параметрами вместо ручной проверки подписи. Цены две — обязательный HTTPS на origin’е и настройки в mini-app BotFather, а не в чате.
Если хотите потрогать руками — код тут: https://github.com/andy-takker/tg-auth. README пошагово описывает запуск, а в tests/ лежит AsyncTestClient-овые проверки на весь HTTP-поток, включая невалидный JWT и self-heal стейл-сессии.
В следующей статье разберу, как сделать refresh-токены так, чтобы за них не было стыдно: одноразовость, family_id, журнал сессий в БД и детекция переиспользования.-Источник
 
Loading...
Error